Merge origin/master into eureka/aetheria-release-thrust-allocator-core

This commit is contained in:
Meta Vens 2026-10-10 12:23:02 +02:00
commit 08d064c8dc
191 changed files with 10512 additions and 90 deletions

View file

@ -1,59 +1,96 @@
using System.Collections;
using System;
using System.Collections;
using UniRx;
using UnityEngine;
using static CultMath.math;
// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already
// decided this shot's fate before this object was ever spawned (R8). What is left is pure flight-path
// presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first.
// Cut 6b, 6.2 (12.4(b): Apply's own switch on the frozen Fuse decides this now, not Step): the flak-round
// blast resolves in the simulation, through FireControl.Detonate -- a MonoBehaviour calling it would be
// exactly the authority Cut 3 spent itself deleting, so the blast-distance/blast-radius fields this object
// used to carry for that are gone. This object's job stays fly-and-disappear.
// A ballistic round, drawn by DrawAhead.Round from what the simulation publishes and deciding nothing. The simulation
// owns the round's facts (the PendingShot copy taken at spawn, replaced by the sim's record at ShotCommitted, and the
// outcome it publishes), so this object stores no motion state of its own and computes no position, time, range or
// impact point: each frame it asks DrawAhead.Round where the round is at sim time plus the clock's Lead (the same
// draw-ahead ships use). The round ends on the sim's word: ShotResolved ends one that hit or burst, and
// DrawAhead.RoundOver ends one the sim published as a miss. The barrel is only where the round is first seen: its
// offset from the drawn line decays to zero over BlendTime sim seconds. The trail's fade after the end stays real time
// (pure presentation, ruling sim-speed-presentation).
public class Projectile : MonoBehaviour
{
public TrailRenderer Trail;
public float Gravity;
public float Drag = .1f;
public Prototype HitEffect;
// Sim seconds over which the barrel's offset from the sim line decays to nothing.
public float BlendTime = .1f;
private PendingShot _shot;
private Zone _zone;
private CultMath.float3 _barrel;
private ShotOutcome _known;
private bool _alive;
private CompositeDisposable _binding;
// Cut 3: FireControl.Fire's ShotId -- this projectile's handle onto Zone.ShotCommitted/ShotResolved.
public int ShotId { get; set; }
public Zone Zone { get; set; }
public Vector3 StartPosition { get; set; }
public Vector3 Velocity { get; set; }
public Entity SourceEntity { get; set; }
public float Range { get; set; }
private void OnEnable()
// Takes the shot as the sim holds it, the zone whose sim time it is drawn at, and where the barrel is as this round is first seen.
public void Launch(Zone zone, PendingShot shot, Vector3 barrel)
{
_zone = zone;
_shot = shot;
_barrel = new CultMath.float3(barrel.x, barrel.y, barrel.z);
_known = null;
_binding?.Dispose();
_binding = new CompositeDisposable(
zone.ShotCommitted.Where(o => o.ShotId == shot.ShotId).Subscribe(Committed),
zone.ShotResolved.Where(o => o.ShotId == shot.ShotId).Subscribe(Resolve));
_alive = true;
transform.forward = new Vector3(shot.TravelDirection.x, 0, shot.TravelDirection.y);
Draw();
}
// Update is called once per frame
void Update()
{
if (SourceEntity == null) return;
if(_alive)
{
var t = transform;
Velocity -= Vector3.up * (Gravity * Time.deltaTime);
Velocity *= max(0, 1 - Drag * Time.deltaTime);
t.forward = Velocity.normalized;
transform.position += Velocity * Time.deltaTime;
var distanceTraveled = (transform.position - StartPosition).magnitude;
if(distanceTraveled > Range)
StartCoroutine(Kill());
}
if (_alive) Draw();
}
IEnumerator Kill()
private void Draw()
{
var lead = ActionGameManager.Clock.Lead;
transform.position = V(DrawAhead.Round(_shot, _known, _barrel, BlendTime, _zone.Time, lead));
if (DrawAhead.RoundOver(_shot, _known, _zone.Time, lead)) Finish();
}
// The sim's commit: the record it holds now (a contact burst's arrival is shortened here) and what it decided.
private void Committed(ShotOutcome outcome)
{
if (_zone.TryGetShot(outcome.ShotId, out var record)) _shot = record;
_known = outcome;
}
// The simulation's verdict. A round that hit or burst is drawn at its end and stops; a miss keeps flying.
private void Resolve(ShotOutcome outcome)
{
if (!_alive) return;
_known = outcome;
if (outcome.Result == ShotResult.Miss) return;
Draw();
if (HitEffect != null) HitEffect.Instantiate<Transform>().position = transform.position;
Finish();
}
private static Vector3 V(CultMath.float3 p) => new Vector3(p.x, p.y, p.z);
private void Finish()
{
_alive = false;
_binding?.Dispose();
_binding = null;
StartCoroutine(Fade());
}
void OnDisable()
{
_alive = false;
_binding?.Dispose();
_binding = null;
}
IEnumerator Fade()
{
var startTime = Time.time;
var lifetime = Trail.time;
while (Time.time - startTime < lifetime)

View file

@ -1,32 +1,17 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Random = UnityEngine.Random;
using UnityEngine;
public class ProjectileManager : InstantWeaponEffectManager
{
public Prototype ProjectilePrototype;
public bool InheritVelocity;
// The round is drawn by DrawAhead from what the simulation publishes, so there is nothing to aim, jitter
// or inherit here: spread is priced by FireControl, and the sim's round inherits nothing from its shooter.
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var zone = source.Entity.Zone;
if (!zone.TryGetShot(shotId, out var shot)) return;
var p = ProjectilePrototype.Instantiate<Projectile>();
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
var angle = weapon.Spread / 2;
p.ShotId = shotId;
p.SourceEntity = source.Entity;
p.Velocity = Quaternion.Euler(
Random.Range(-angle, angle),
Random.Range(-angle, angle),
Random.Range(-angle, angle)) *
barrel.forward *
weapon.Velocity;
p.StartPosition = p.transform.position = barrel.position + p.Velocity * (Random.value * Time.deltaTime);
if(InheritVelocity)
p.Velocity += new Vector3(source.Entity.Velocity.x, 0, source.Entity.Velocity.y);
p.Range = weapon.Range;
p.Zone = source.Entity.Zone;
p.Launch(zone, shot, source.GetBarrel(item.Hardpoint).position);
p.Trail.Clear();
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@ -15,6 +16,9 @@ public static class AetheriaStores
// no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens.
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false)
{
// A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here.
if (!catalogWritable && !File.Exists(catalogPath))
throw new FileNotFoundException($"Catalog {catalogPath} does not exist; a read-only open never invents one.", catalogPath);
var cache = new CultCache();
try
{

View file

@ -187,6 +187,8 @@ public interface IPowerConsumer
// superseded draft quoted -- checked directly here rather than trusted from that document.
Union(39, typeof(TargetingSystemData)),
Union(40, typeof(MineLayerData)),
Union(41, typeof(ThrottleLockData)),
Union(42, typeof(VapourDumpData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData
{

View file

@ -160,17 +160,18 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
var diff = entity.Position.xz - Entity.Position.xz;
var dist = length(diff);
var obscuration = Entity.Zone.Obscuration(Entity.Position.xz, entity.Position.xz);
float previous, next;
Entity.EntityInfoGathered.TryGetValue(entity, out previous);
if (!_pingedEntities.Contains(entity) && dist < _pingRadius)
{
_pingedEntities.Add(entity);
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true, obscuration));
}
else
{
var angle = acos(dot(forward, normalize(diff)));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
}
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
@ -182,12 +183,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a
// target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds
// `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the
// sensitivity curve at the bearing for a passive tick. The entity loop above and the chunk query
// (Entity.ChunkInfo, through PassiveRate) both call this; nothing else multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping) =>
ping
// sensitivity curve at the bearing for a passive tick. `obscuration` is the fraction of the signal that
// survives the vapour clouds on the sight line (Zone.Obscuration, 1f with none), scaling both. The entity
// loop above and the chunk query (Entity.ChunkInfo, through PassiveRate) both call this; nothing else
// multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping, float obscuration) =>
(ping
? visibility * sensitivity * response * distance
: visibility * sensitivity * response * dt / distance;
: visibility * sensitivity * response * dt / distance) * obscuration;
// The passive gain per second this sensor draws from a target of `visibility` at the planar `position`:
// Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does.
@ -195,7 +198,8 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
{
var diff = position - Entity.Position.xz;
var angle = acos(dot(Direction.xz, normalize(diff)));
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false);
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false,
Entity.Zone.Obscuration(Entity.Position.xz, position));
}
// An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance

View file

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using MessagePack;
using Newtonsoft.Json;
// Presence marks the lock: Entity.ThrottleLocked is true while an active effect carries one, and Ship.Update
// then reads full forward intent in place of MovementDirection. The behaviour holds no state and does nothing
// itself; turning stays with the pilot.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class ThrottleLockData : BehaviorData
{
public override Behavior CreateInstance(EquippedItem item)
{
return new ThrottleLock(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new ThrottleLock(this, item);
}
}
public class ThrottleLock : Behavior
{
public ThrottleLock(ThrottleLockData data, EquippedItem item) : base(data, item)
{
}
public ThrottleLock(ThrottleLockData data, ConsumableItemEffect item) : base(data, item)
{
}
public override bool Execute(float dt) => true;
}

View file

@ -0,0 +1,61 @@
using MessagePack;
using Newtonsoft.Json;
// A coolant dump that becomes a vapour cloud: the first time it runs it vents one cloud around its venter,
// drifting with the venter's velocity. Radius, Opacity and Lifetime are evaluated through the host, so role
// quality applies as for any other stat. Later runs vent nothing.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class VapourDumpData : BehaviorData
{
[Inspectable, JsonProperty("radius"), Key(1), RuntimeInspectable]
public PerformanceStat Radius = new PerformanceStat();
// How much of a sight line's signal a fresh cloud removes, in [0, 1].
[Inspectable, JsonProperty("opacity"), Key(2), RuntimeInspectable]
public PerformanceStat Opacity = new PerformanceStat();
// Seconds from venting until the cloud has faded away and is removed.
[Inspectable, JsonProperty("lifetime"), Key(3), RuntimeInspectable]
public PerformanceStat Lifetime = new PerformanceStat();
public override Behavior CreateInstance(EquippedItem item)
{
return new VapourDump(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new VapourDump(this, item);
}
}
public class VapourDump : Behavior
{
private VapourDumpData _data;
private bool _vented;
public VapourDump(VapourDumpData data, EquippedItem item) : base(data, item)
{
_data = data;
}
public VapourDump(VapourDumpData data, ConsumableItemEffect item) : base(data, item)
{
_data = data;
}
public override bool Execute(float dt)
{
if (_vented) return true;
_vented = true;
Entity.Zone.Vent(new VapourCloud
{
Body = new KinematicBody { Position = Entity.Position.xz, Velocity = Entity.Velocity },
Radius = Evaluate(_data.Radius),
Opacity = Evaluate(_data.Opacity),
Lifetime = Evaluate(_data.Lifetime),
Venter = Entity
});
return true;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 961a74683c574807a300eb26694f206d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -5,9 +5,11 @@
using CultMath;
using static CultMath.math;
// Where a presenter draws the latest sim state when the frame falls Lead sim seconds after the last step. Pure reads
// of the simulation for presenters (never called by ServerShared): the sim owns every pose and no presenter stores
// one, a previous state or a velocity of its own.
// Pure presentation statics over sim state, never called by ServerShared, with no state of their own: the sim owns every
// pose and no presenter stores one, a previous state or a velocity of its own. Ships are drawn where the frame falls Lead
// sim seconds after the last step, by their last step's velocity and turn. A ballistic round's flight is presentation
// (ruling projectile-flight-is-presentation), drawn from the sim's facts: its frozen line, the published outcome, the
// commit tick, the impact cell on the target's drawn pose, the burst point.
public static class DrawAhead
{
// A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate,
@ -37,4 +39,62 @@ public static class DrawAhead
t.w * q.z + t.x * q.y - t.y * q.x + t.z * q.w,
t.w * q.w - t.x * q.x - t.y * q.y - t.z * q.z));
}
// A ballistic round at a sim time (the clock's Zone.Time + Lead): on its frozen line while the outcome is unknown or a
// miss, with the barrel's offset from that line decaying to nothing over blendTime sim seconds from the moment of firing.
// A round the sim published as a hit or burst steers from its line, from the commit tick, onto where it ends by the
// time damage lands: a burst's frozen point, else the impact cell on the target's drawn pose; and is held there after.
public static float3 Round(in PendingShot shot, ShotOutcome known, float3 barrel, float blendTime, float simTime, float lead)
{
var time = simTime + lead;
var drawn = Lifted(shot, Line(shot, known?.Result, time), barrel, blendTime, time);
if (known == null || known.Result == ShotResult.Miss) return drawn;
var end = known.HasBurstPoint
? float3(known.BurstPoint.x, shot.FireOrigin.y, known.BurstPoint.y)
: Impact(known.Target, known.Cell, lead);
var commit = shot.ArrivalTime - known.ArrivalIn;
var steer = shot.ArrivalTime > commit ? saturate((time - commit) / (shot.ArrivalTime - commit)) : 1f;
return drawn + (end - drawn) * steer;
}
// The impact cell's centre on the target's drawn pose: the cell at the sim pose, drawn ahead by the target's last
// step velocity and turned by its last step turn for lead seconds, as Position and Rotation draw the entity.
public static float3 Impact(Entity target, int2 cell, float lead)
{
var at = Position(target, lead);
var offset = target.ToWorldPoint((float2) cell) - target.Position.xz;
var turned = mul(offset, CultMath.float2x2.Rotate(Turn(target, lead)));
return float3(at.x + turned.x, at.y, at.z + turned.y);
}
// Whether the drawn flight is over by time: only a published miss ends by the clock. A hit or burst ends on ShotResolved.
public static bool RoundOver(in PendingShot shot, ShotOutcome known, float simTime, float lead) =>
known != null && known.Result == ShotResult.Miss && simTime + lead >= RoundEnd(shot, ShotResult.Miss);
// The sim time a round's drawn line ends, for what is known of its outcome. A round that hit or burst (or whose outcome
// is not yet published) ends at its ArrivalTime; a round the sim has published as a Miss flies on to the weapon's frozen
// range and never ends short of its own arrival (a direct round's FireRange is measured to any target, in range or not).
public static float RoundEnd(in PendingShot shot, ShotResult? known) =>
known == ShotResult.Miss
? max(shot.ArrivalTime, shot.FireTime + (shot.Speed > .01f ? shot.MaxRange / shot.Speed : 0f))
: shot.ArrivalTime;
// The frozen line a round follows until it steers: from its frozen origin along its frozen direction at its frozen
// speed, held at RoundEnd once it has flown that far. With no outcome known the round is drawn no further than its
// arrival point and waits there for the sim to publish one.
private static float2 Line(in PendingShot shot, ShotResult? known, float time)
{
if (shot.Speed <= .01f) return shot.FireOrigin.xz;
var flown = clamp(time - shot.FireTime, 0f, RoundEnd(shot, known) - shot.FireTime);
return shot.FireOrigin.xz + shot.TravelDirection * (shot.Speed * flown);
}
private static float Turn(Entity entity, float lead) =>
entity is Ship { WormholeAnimationInProgress: true } ? 0f : entity.TurnRate * lead;
private static float3 Lifted(in PendingShot shot, float2 planar, float3 barrel, float blendTime, float time)
{
var blend = blendTime > 0 ? saturate(1 - (time - shot.FireTime) / blendTime) : 0f;
return float3(planar.x, shot.FireOrigin.y, planar.y) + (barrel - shot.FireOrigin) * blend;
}
}

View file

@ -203,12 +203,10 @@ public abstract class Entity
return presencePermitted;
}), initialValue: true);
foreach (var item in Equipment)
foreach (var behavior in item.Behaviors)
{
if(behavior is IInitializableBehavior initializableBehavior)
initializableBehavior.Initialize();
}
// One initialiser for everything that computes targets here: gear and the effects still running, so a
// consumable active across a refit retargets to the new items exactly as gear does.
InitializeBehaviors(Equipment.SelectMany(item => item.Behaviors));
InitializeBehaviors(_activeConsumables.SelectMany(effect => effect.Behaviors));
foreach(var entity in Zone.Entities)
{
EntityInfoGathered[entity] = 0;
@ -860,9 +858,23 @@ public abstract class Entity
public void ActivateConsumable(ConsumableItem item)
{
_activeConsumables.Add(new ConsumableItemEffect(item, this));
var effect = new ConsumableItemEffect(item, this);
// The same hook Activate runs over Equipment: a behaviour that computes its targets there (StatModifier)
// would otherwise reach its second tick uninitialised.
InitializeBehaviors(effect.Behaviors);
_activeConsumables.Add(effect);
}
private static void InitializeBehaviors(IEnumerable<Behavior> behaviors)
{
foreach (var behavior in behaviors.ToList())
if (behavior is IInitializableBehavior initializable)
initializable.Initialize();
}
// Derived, never written: true iff an active consumable effect carries a ThrottleLock.
public bool ThrottleLocked => _activeConsumables.Any(e => e.Behaviors.OfType<ThrottleLock>().Any());
public ConsumableItemEffect FindActiveConsumable(ConsumableItemData data)
{
return _activeConsumables.FirstOrDefault(ac => ac.Data == data);
@ -1448,11 +1460,16 @@ public abstract class Entity
for (var i = 0; i < _activeConsumables.Count; i++)
{
_activeConsumables[i].Update(delta);
if (_activeConsumables[i].RemainingDuration < 0)
if (_activeConsumables[i].RemainingDuration <= 0)
{
// Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this
// list without ever telling the resolver, leaving its generation/cache/modifier entries
// reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.
// A StatModifier's attachments are keyed by its target items, not by the effect, so Forget alone
// leaves a boost attached to its targets: dispose each behaviour first.
foreach (var behavior in _activeConsumables[i].Behaviors)
if (behavior is IDisposable disposable)
disposable.Dispose();
Resolver.Forget(_activeConsumables[i]);
_activeConsumables.RemoveAt(i--);
}

View file

@ -755,6 +755,8 @@ public static class FireControl
Spread = weapon.Spread,
FireRange = fireRange,
FireTime = now,
Speed = weapon.Velocity,
MaxRange = weapon.Range,
FireOrigin = origin,
FireTargetPosition = targetPosition,
FireTargetVelocity = targetVelocity,
@ -853,6 +855,8 @@ public static class FireControl
// A contact burst short of max range arrives when the round has flown that far, not at the frozen
// max-range time (CommitBurst). Never later than the frozen time.
if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn);
// Written back before ShotCommitted fires: a presenter re-reading the record there (Zone.TryGetShot) sees
// the committed ArrivalTime a contact burst has just shortened.
shots[i] = shot;
zone.ShotCommitted.OnNext(shot.Outcome);
}
@ -1999,6 +2003,11 @@ public struct PendingShot
public float3 FireTargetPosition;
public float3 FireTargetVelocity;
public float FireTime;
// The weapon's Velocity at Fire, frozen with the rest so a presenter draws the same line whatever happens to the stat later.
public float Speed;
// The weapon's Range at Fire, likewise frozen: how far a presenter may draw a round that misses. FireRange above
// is the distance to the engaged target (0 for a round with none), not how far a round that misses flies on.
public float MaxRange;
public float CommitTime;
public float ArrivalTime;

View file

@ -183,6 +183,9 @@ public class ItemManager
};
}
if (design is ConsumableItemData)
return new ConsumableItem { Data = l.Design, Lot = lot };
return new CompoundCommodity
{
Data = l.Design, Lot = lot

View file

@ -233,27 +233,29 @@ public class Ship : Entity
if (_active && !_exitingWormhole && !_enteringWormhole)
{
RecalculateThrust();
// The lock replaces intent here, once; player and agent intent both pass through this one read.
var move = ThrottleLocked ? float2(0, 1) : MovementDirection;
foreach (var thruster in _allThrusters) thruster.Axis = 0;
var rightThrusterTorqueCompensation = abs(RightStrafeTotalTorque) / RightStrafeTorqueThrusters.Count;
foreach (var thruster in _rightThrusters)
{
var thrust = 0f;
thrust += MovementDirection.x;
thrust += move.x;
if (RightStrafeTorqueThrusters.Contains(thruster))
thrust -= MovementDirection.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thrust -= move.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thruster.Axis = thrust;
}
var leftThrusterTorqueCompensation = abs(LeftStrafeTotalTorque) / LeftStrafeTorqueThrusters.Count;
foreach (var thruster in _leftThrusters)
{
var thrust = 0f;
thrust += -MovementDirection.x;
thrust += -move.x;
if (LeftStrafeTorqueThrusters.Contains(thruster))
thrust += MovementDirection.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thrust += move.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thruster.Axis = thrust;
}
foreach (var thruster in _forwardThrusters) thruster.Axis += MovementDirection.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -MovementDirection.y;
foreach (var thruster in _forwardThrusters) thruster.Axis += move.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -move.y;
foreach (var thruster in _clockwiseThrusters) thruster.Axis += Turn;
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -Turn;

View file

@ -141,6 +141,9 @@ public class StatResolver
{
set.Scale.Remove(modifierKey);
set.Constant.Remove(modifierKey);
// An entry with nothing attached is no entry: a modifier that is gone leaves no trace in the resolver.
if (set.Scale.Count == 0 && set.Constant.Count == 0)
_modifiers.Remove(entry);
}
_cache.Remove(entry);
}

View file

@ -0,0 +1,32 @@
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// A vented vapour cloud: a free-floating disc that thins every sensor sight line through it. Not an Entity,
// never priced or rolled, never saved. Zone.Vent is the only adder and Zone's cloud step the only remover;
// Zone.Obscuration is the one reader detection uses.
public sealed class VapourCloud
{
public FloatingBodyId Id;
public KinematicBody Body;
public double VentedAt;
public float Lifetime;
public float Radius;
// The opacity at the moment of venting; it fades linearly to nothing over Lifetime.
public float Opacity;
public Entity Venter;
// A cloud with no lifetime divides by zero; saturate maps the NaN and the infinity to nothing left.
public float OpacityAt(double now) =>
saturate(Opacity) * saturate(1f - (float) (now - VentedAt) / Lifetime);
// Whether the segment a-b reaches the disc: the closest point of the segment to the centre lies within the
// radius. A cloud past either end of the segment touches it only if it is near that end.
public bool Touches(float2 a, float2 b)
{
var ab = b - a;
var length2 = lengthsq(ab);
var t = length2 > 0f ? saturate(dot(Body.Position - a, ab) / length2) : 0f;
return lengthsq(Body.Position - (a + ab * t)) <= Radius * Radius;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ced45a9738974452b0065b57ee527e8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -20,6 +20,10 @@ public class Zone
// Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover,
// and every removal is a blast. Runtime state, never saved.
public ReactiveCollection<Mine> Mines = new ReactiveCollection<Mine>();
// Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one
// remover, and Obscuration the one reader that detection uses. Runtime state, never saved.
public ReactiveCollection<VapourCloud> Clouds = new ReactiveCollection<VapourCloud>();
public Dictionary<CultRecordKey, BodyData> Planets = new Dictionary<CultRecordKey, BodyData>();
public Dictionary<CultRecordKey, Planet> PlanetInstances = new Dictionary<CultRecordKey, Planet>();
@ -225,6 +229,7 @@ public class Zone
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);
StepMines(deltaTime);
StepClouds(deltaTime);
// Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that
// firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and
@ -238,6 +243,39 @@ public class Zone
Mines.Add(mine);
}
public void Vent(VapourCloud cloud)
{
cloud.Id = NextBodyId();
cloud.VentedAt = _time;
Clouds.Add(cloud);
}
// The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of
// (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the
// live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection.
public float Obscuration(float2 a, float2 b)
{
var through = 1f;
for (var i = 0; i < Clouds.Count; i++)
{
var cloud = Clouds[i];
if (cloud.Touches(a, b)) through *= 1f - cloud.OpacityAt(_time);
}
return through;
}
// After StepMines: each cloud drifts as a KinematicBody on the zone's force, as a mine does, and is removed
// once its lifetime has run out. Nothing else removes a cloud.
private void StepClouds(float deltaTime)
{
var settings = _itemManager.GameplaySettings;
foreach (var cloud in Clouds.ToArray())
{
cloud.Body.Step(deltaTime, GetForce(cloud.Body.Position), settings);
if (cloud.VentedAt + cloud.Lifetime <= _time) Clouds.Remove(cloud);
}
}
// After the entity loop and before FireControl.Step, in lay order: each mine drifts as a KinematicBody, is
// triggered by the first eligible hull its armed disc touches, and detonates through FireControl.Detonate at
// its trigger plus fuse delay or at its lifetime, whatever its layer does (ruling mine-owner-leash). The