Merge origin/master into economy authoring command

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-23 07:16:44 +02:00
commit dd7f40b559
33 changed files with 1329 additions and 129 deletions

View file

@ -159,7 +159,7 @@ namespace Aetheria.EditorTools
MaxRange = 1e6f, FireRange = 1e6f, CommitTime = 1e5f, ArrivalTime = 2e5f
};
var projectile = new GameObject("Wiring Round").AddComponent<Projectile>();
projectile.Launch(zone, shot, new Vector3(0, 0, 0));
projectile.Launch(null, zone, shot, new Vector3(0, 0, 0));
yield return null;
zone.Update(.5f);

View file

@ -248,7 +248,7 @@ public class ActionGameManager : MonoBehaviour
public ItemManager ItemManager { get; private set; }
public Zone Zone { get; private set; }
public static SimClock Clock { get; } = new SimClock(1f / 60f);
public static SimClock Clock { get; } = SimClock.ForGame();
private bool _zoneEntered;
private readonly (float2 direction, string name)[] _directions = {

View file

@ -24,19 +24,16 @@ public class Projectile : MonoBehaviour
private CultMath.float3 _barrel;
private ShotOutcome _known;
private bool _alive;
private CompositeDisposable _binding;
private ProjectileManager _owner;
// 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)
public void Launch(ProjectileManager owner, Zone zone, PendingShot shot, Vector3 barrel)
{
_owner = owner;
_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();
@ -56,14 +53,14 @@ public class Projectile : MonoBehaviour
}
// 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)
internal 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)
internal void Resolve(ShotOutcome outcome)
{
if (!_alive) return;
_known = outcome;
@ -78,16 +75,14 @@ public class Projectile : MonoBehaviour
private void Finish()
{
_alive = false;
_binding?.Dispose();
_binding = null;
_owner?.Forget(_shot.ShotId);
StartCoroutine(Fade());
}
void OnDisable()
{
_alive = false;
_binding?.Dispose();
_binding = null;
_owner?.Forget(_shot.ShotId);
}
IEnumerator Fade()

View file

@ -1,17 +1,46 @@
using UnityEngine;
using System.Collections.Generic;
using UniRx;
using UnityEngine;
public class ProjectileManager : InstantWeaponEffectManager
{
public Prototype ProjectilePrototype;
// The rounds this manager has in the air by shot id, told of the sim's commit and verdict by one subscription per zone: a
// gun that fires hundreds of rounds in a step costs the dictionary an entry per round and nothing else (no subscription,
// closure or disposable per round).
private readonly Dictionary<int, Projectile> _live = new Dictionary<int, Projectile>();
private readonly CompositeDisposable _listening = new CompositeDisposable();
private Zone _zone;
// 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.
// A round the sim fired inside the step (rounds per step) is first seen where its shooter's barrel was at that moment:
// the barrel as it is now, carried back along the shooter's motion for the time since.
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;
Listen(zone);
var age = (float) (zone.ExactTime - shot.FireTime);
var velocity = source.Entity.Velocity;
var p = ProjectilePrototype.Instantiate<Projectile>();
p.Launch(zone, shot, source.GetBarrel(item.Hardpoint).position);
_live[shotId] = p;
p.Launch(this, zone, shot, source.GetBarrel(item.Hardpoint).position - new Vector3(velocity.x, 0, velocity.y) * age);
p.Trail.Clear();
}
internal void Forget(int shotId) => _live.Remove(shotId);
private void Listen(Zone zone)
{
if (_zone == zone) return;
_listening.Clear();
_live.Clear();
_zone = zone;
zone.ShotCommitted.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Committed(outcome); }).AddTo(_listening);
zone.ShotResolved.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Resolve(outcome); }).AddTo(_listening);
}
void OnDestroy() => _listening.Dispose();
}

View file

@ -9,23 +9,17 @@ using static CultMath.math;
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class AutoWeaponData : InstantWeaponData
{
public override Behavior CreateInstance(EquippedItem item)
{
return new AutoWeapon(this, item);
}
[IgnoreMember]
public override bool Automatic => true;
}
// A fully automatic gun: it fires every round its cooldown allows while its trigger is held, from the press (Activate)
// to the let-go (Deactivate). A plain InstantWeapon is semi-automatic.
public class AutoWeapon : InstantWeapon
{
protected override bool Repeats => _firing;
public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { }
public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { }
public override bool Execute(float dt)
{
if(_firing && _burstRemaining == 0 && _cooldown < 0)
Trigger();
return base.Execute(dt);
}
}

View file

@ -157,8 +157,7 @@ public class ChargedWeapon : InstantWeapon
if (_data.FailureCharge > 1 && _charge > _data.FailureCharge)
{
_charging = false;
_cooldown = 1;
_coolingDown = true;
StartCountdown();
_charge = 0;
OnFailed?.Invoke();
CauseDamage(_data.FailureDamage);

View file

@ -146,9 +146,10 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
}
_ammoInterval -= dt / _data.AmmoInterval;
if (_ammoInterval < 0)
// An interval shorter than the step spends every round it spans.
while (_ammoInterval < 0)
{
_ammoInterval = 1;
_ammoInterval += 1;
if (_data.MagazineSize > 1 && _ammo > 0) _ammo--;
else
{

View file

@ -48,7 +48,7 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
{
if (_cooldown < 0)
{
_cooldown = 1;
_cooldown += 1;
return true;
}
@ -57,6 +57,8 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
public void Update(float delta)
{
_cooldown -= delta / Evaluate(_data.Cooldown);
// A finished timer keeps what the last step overshot, at most one step, so the restart in Execute is exact.
var step = delta / Evaluate(_data.Cooldown);
_cooldown = max(_cooldown - step, -step);
}
}

View file

@ -42,6 +42,18 @@ public class InputCapacitor
public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);
// A weapon held through a step may fire several rounds in it (ruling rounds-per-step): the charge it holds and what flows in
// during the step pay for them, so for that step its buffer is a full activation plus the step's inflow; Settle trims what
// the step did not spend back to Capacity. The fill rate is unchanged, so the energy a gun draws is still exactly its
// rounds' cost. A sensor or a shield, and a weapon not held, keep the one-activation buffer above.
private float StepReach(float dt) => Capacity + Rate * dt;
public float RequestedStepFill(float dt) => min(Rate * dt, max(0f, StepReach(dt) - Charge));
public void AddStepCharge(float amount, float dt) => Charge = clamp(Charge + amount, 0f, StepReach(dt));
public void Settle() => Charge = min(Charge, Capacity);
public bool CanSpend(float cost) => Charge >= cost;
// Atomic and whole-or-nothing, mirroring Entity.TrySpendCapacitorCharge's old contract exactly (down to the

View file

@ -21,14 +21,39 @@ public class InstantWeaponData : WeaponData
[Inspectable, JsonProperty("ammoInterval"), Key(20)]
public bool SingleAmmoBurst;
// The fire mode, the one fact creation and the semi-auto catalog check both read: an automatic gun fires every round its
// cooldown allows while its trigger is held, every other kind is semi-automatic (a press fires one burst).
[IgnoreMember]
public virtual bool Automatic => false;
public override Behavior CreateInstance(EquippedItem item)
{
return new InstantWeapon(this, item);
return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new InstantWeapon(this, item);
return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item);
}
// The shortest cooldown a plain (semi-automatic) gun may be authored with, 0.1 s: about ten presses a second. A faster one
// is a fire-as-fast-as-you-can-press mechanic, unfriendly to accessibility and a special case for the AI (ruling
// fire-mode-is-the-design); a gun that cycles faster is an AutoWeapon.
public const float ShortestSemiAutoCooldown = .1f;
// The semi-automatic weapons of a catalog (gear and consumables, every kind that is not Automatic: plain, guided, mine,
// launcher, charged) that cycle faster than a finger should have to: one fault line per weapon.
public static IEnumerable<string> SemiAutoFaults(IEnumerable<ItemData> catalog)
{
foreach (var item in catalog)
{
var behaviors = (item as GearData)?.Behaviors ?? (item as ConsumableItemData)?.Behaviors;
if (behaviors == null) continue;
foreach (var behavior in behaviors)
if (behavior is InstantWeaponData weapon && !weapon.Automatic &&
Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max) < ShortestSemiAutoCooldown)
yield return $"{item.Name}: a {weapon.GetType().Name} with a {Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it";
}
}
// The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated
@ -58,6 +83,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
protected float _cooldown; // Normalized
private int _ammo = 0;
protected bool _coolingDown;
// Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held
// trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart.
private float _overshoot;
// The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step.
private const float ShortestCycle = .0005f;
public float BurstCount { get; protected set; }
public float BurstTime { get; protected set; }
@ -87,6 +117,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// manager can bind its presentation to the one shot it belongs to instead of applying damage itself.
public event Action<int> OnFire;
// Whether a down trigger keeps firing: a plain gun is semi-automatic, one burst per press (a held key does not repeat, and
// an agent presses it again each time it is ready); an AutoWeapon repeats while its trigger is held, every round its
// cooldown allows at any step length. Fire mode is authored by the kind of the weapon (ruling fire-mode-is-the-design).
protected virtual bool Repeats => false;
public virtual void ResetEvents()
{
OnReloadBegin = null;
@ -165,11 +200,15 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
_capacitor.UpdateStats(perShotEnergy, cooldown, rateOverride: RateOverride);
}
// What the capacitor asks the bus for this step: a weapon held through it may fire several rounds in it, so it asks for
// the step's whole inflow; any other tops up to one activation.
private float Fill(float dt) => Repeats ? _capacitor.RequestedStepFill(dt) : _capacitor.RequestedFill(dt);
// Cut 4: the request PowerBus needs before Execute runs, mirroring EnergyDraw's IPowerConsumer pattern.
public float PowerRequest(float dt)
{
RefreshInputCapacitor();
return _capacitor.RequestedFill(dt);
return Fill(dt);
}
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what
@ -183,7 +222,40 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever.
protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity);
private bool UseAmmo()
// Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one.
// True when it ended the countdown now.
private bool FinishCountdown(float dt)
{
if (!_coolingDown || !(_cooldown < 0)) return false;
_overshoot = min(-_cooldown * CountdownSpan, dt);
_coolingDown = false;
if (_data.MagazineSize > 0 && _ammo == 0)
{
_ammo = _data.MagazineSize;
OnReloadComplete?.Invoke();
}
else
OnCooldownComplete?.Invoke();
return true;
}
private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
// A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart.
protected void StartCountdown()
{
_cooldown = 1 - TakeOvershoot() / CountdownSpan;
_coolingDown = true;
}
private float TakeOvershoot()
{
var overshoot = _overshoot;
_overshoot = 0;
return overshoot;
}
private bool UseAmmo(float dt)
{
if (_data.MagazineSize <= 1) return true;
@ -208,7 +280,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
if(hasAmmo)
{
OnReloadBegin?.Invoke();
_cooldown = 1;
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime;
_coolingDown = true;
_firing = false;
}
@ -218,29 +290,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
}
public override bool Execute(float dt)
{
try { return Step(dt); }
finally { _capacitor.Settle(); }
}
// One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a
// cooldown that ends inside the step while the trigger is held starts the next burst at that moment, so a gun fires
// every round its cooldown allows in a step, each with its own roll, however short the cooldown or long the step.
private bool Step(float dt)
{
base.Execute(dt);
// Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item
// instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge.
if (Item != null)
_capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);
_capacitor.AddStepCharge(Fill(dt) * Item.PowerSupply, dt);
var ended = false;
var retriggered = false;
if (_coolingDown)
{
_cooldown -= dt / (_data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
if (_cooldown < 0)
{
_coolingDown = false;
if (_data.MagazineSize > 0 && _ammo == 0)
{
_ammo = _data.MagazineSize;
OnReloadComplete?.Invoke();
}
else
OnCooldownComplete?.Invoke();
}
_cooldown -= dt / CountdownSpan;
ended = FinishCountdown(dt);
}
_burstTimer += dt;
while (true)
{
while (_burstRemaining > 0 && _burstTimer > 0)
{
// A fused round whose arming distance Range cannot reach is refused, per round, before anything is
@ -249,7 +324,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
var refused = FireControl.Refuses(this, Entity);
// Ammo and energy are consumed here, per round, or once per burst at its first unrefused round
// (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo()))
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt)))
{
_burstRemaining = 0;
return false;
@ -260,8 +335,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
if (!_burstStarted)
{
_burstStarted = true;
_cooldown = 1 - dt / Cooldown;
// The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / CountdownSpan;
// The round starts the cooldown after this tick's decrement above: charge the tick and the last
// countdown's overshoot to it, so the cadence stays Cooldown
_coolingDown = true;
}
}
@ -277,12 +353,27 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// whole expression, argument included, whenever OnFire has no subscriber (a headless run with no
// Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired.
// Evaluate it into a local first.
var shotId = FireControl.Fire(this, Item, Entity);
var shotId = FireControl.Fire(this, Item, Entity, age: _burstTimer + _burstInterval);
OnFire?.Invoke(shotId);
CauseWearDamage(1);
AddHeat(Heat);
Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item);
}
// A cooldown shorter than the step it started in is over in that step.
ended |= FinishCountdown(dt);
// The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it
// ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready.
if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break;
retriggered = true;
Trigger();
if (_burstRemaining == 0) break;
if (ended)
{
_burstTimer = TakeOvershoot();
ended = false;
}
else _burstTimer = dt;
}
return true;
}

View file

@ -145,7 +145,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
{
_pinging = true;
_pingZone = Entity.Zone;
_pingCooldown = 1;
_pingCooldown += 1;
_pingLerp = 0;
_pingRadius = 0;
_reached.Clear();
@ -180,8 +180,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
if (_pingLerp > 1) EndPing();
}
_pingCooldown -= dt / Evaluate(_data.PingCooldown);
// A finished timer keeps what the last step overshot, at most one step, so the restart in Ping is exact.
var pingStep = dt / Evaluate(_data.PingCooldown);
_pingCooldown = max(_pingCooldown - pingStep, -pingStep);
var infoDecay = ItemManager.GameplaySettings.TargetInfoDecay;
var decay = exp(-infoDecay * dt);
var settle = infoDecay > 0 ? (1 - decay) / infoDecay : dt;
var forward = Direction.xz;
foreach (var entity in Entity.Zone.Entities)
{
@ -221,14 +227,16 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
credit += Gain(entity.Visibility, sensitivity, response, sqrt(r.IlluminatedRange * dist), dt, true, sqrt(r.IlluminatedObscuration * obscuration));
}
// The exact decay at this step: x <- x e^(-k dt) + (G / k)(1 - e^(-k dt)) for the passive gain G per second,
// which settles at G / k (Entity.PassiveInfo) at every dt. A ping's credit is an impulse added first.
if (heard)
next = saturate(previous + credit);
next = saturate(previous + credit) * decay;
else
{
var angle = acos(dot(forward, normalize(diff)));
next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
var passive = Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, 1f, false, obscuration);
next = saturate(previous * decay + passive * settle);
}
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
Entity.EntityInfoGathered[entity] = next;
}

View file

@ -1782,13 +1782,18 @@ public abstract class Entity
Parent = null;
}
// Indexed, not foreach: Equipment is a ReactiveCollection whose enumerator is heap-allocated, and every round a gun fires
// asks for the targeting system several times (rounds per step: hundreds of rounds a step must not allocate each).
public T GetBehavior<T>() where T : Behavior
{
foreach (var equippedItem in Equipment)
if(equippedItem.Behaviors != null)
foreach (var behavior in equippedItem.Behaviors)
if (behavior is T b)
return b;
for (var i = 0; i < Equipment.Count; i++)
{
var behaviors = Equipment[i].Behaviors;
if (behaviors == null) continue;
foreach (var behavior in behaviors)
if (behavior is T b)
return b;
}
return null;
}

View file

@ -698,7 +698,11 @@ public static class FireControl
// Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's
// authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation,
// unchanged). Every other caller keeps reading the frozen weapon.Damage it always did.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null)
// Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born
// `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities).
// FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round
// from where it left and the commit measures the target's stray from the line it was fired along.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f)
{
var zone = source.Zone;
if (IsMineLayer(weapon))
@ -708,7 +712,7 @@ public static class FireControl
}
// Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks.
var target = source.Target.Value.Entity;
var now = zone.Time;
var now = zone.Time - age;
// R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time
// re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share --
@ -724,14 +728,14 @@ public static class FireControl
var burstPosition = solution.BurstPosition;
var fuse = solution.Fuse;
var blastRadius = solution.BlastRadius;
var origin = source.Position;
var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age;
var targetVelocity = float3.zero;
var targetPosition = origin;
if (engaged != null)
{
targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y);
targetPosition = engaged.Position;
targetPosition = engaged.Position - targetVelocity * age;
}
var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f;
@ -779,7 +783,12 @@ public static class FireControl
// point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once.
if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach);
zone.PendingShots.Add(shot);
// The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds
// of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one.
var queue = zone.PendingShots;
var place = queue.Count;
while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--;
queue.Insert(place, shot);
return shot.ShotId;
}
@ -845,7 +854,10 @@ public static class FireControl
if (shots.Count == 0) return;
var now = zone.Time;
for (var i = shots.Count - 1; i >= 0; i--)
// Commits walk the queue in fire-time order, oldest first (Fire keeps it in it). Shots queued while the walk runs are
// left to the next step.
List<(float Arrival, int Id)> arrived = null;
for (int i = 0, end = shots.Count; i < end;)
{
var shot = shots[i];
@ -859,6 +871,7 @@ public static class FireControl
// target is gone. Republishing the committed outcome here used to put a hit marker on a corpse.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
shots.RemoveAt(i);
end--;
if (!shot.Committed) zone.ShotCommitted.OnNext(miss);
zone.ShotResolved.OnNext(miss);
continue;
@ -879,16 +892,41 @@ public static class FireControl
if (shot.Committed && now >= shot.ArrivalTime)
{
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
// the same way; only Apply decides what that means.
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
// that throws would resolve, and hurt, again on every later tick.
shots.RemoveAt(i);
Apply(zone, shot);
zone.ShotResolved.OnNext(shot.Outcome);
(arrived ??= new List<(float, int)>()).Add((shot.ArrivalTime, shot.ShotId));
}
i++;
}
if (arrived == null) return;
// The rounds that arrive in this step apply in arrival order, ties in shot-id order (ids rise as shots are made), so
// which of them breaks a shield or lands a kill follows when each hit, not the order the shooters were stepped in or the
// order their rounds left.
arrived.Sort((a, b) => a.Arrival != b.Arrival ? a.Arrival.CompareTo(b.Arrival) : a.Id.CompareTo(b.Id));
foreach (var (_, id) in arrived)
{
var i = shots.FindIndex(q => q.ShotId == id);
if (i < 0) continue;
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
if (targetGone)
{
// As in the commit walk: a shot whose target left the zone in this step resolves as a miss.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
shots.RemoveAt(i);
zone.ShotResolved.OnNext(miss);
continue;
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
// the same way; only Apply decides what that means.
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
// that throws would resolve, and hurt, again on every later tick.
shots.RemoveAt(i);
Apply(zone, shot);
zone.ShotResolved.OnNext(shot.Outcome);
}
}

View file

@ -14,11 +14,19 @@ public sealed class SimClock
// catch-up steps.
public const float MaxFrameSeconds = .1f;
// Game steps per real second at design speed 1, and the sim seconds one step carries. The one owner of the game's
// tick: every driver of a game history steps Zone.Update by GameStep. Design speed (ruling sim-speed-surface) will
// set Step = DesignSpeed / TickRate in ForGame and nowhere else.
public const int TickRate = 30;
public const float GameStep = 1f / TickRate;
public static SimClock ForGame() => new SimClock(GameStep, TickRate);
private readonly double _period;
private double _accumulator;
private bool _advancing;
public SimClock(float step, float stepsPerRealSecond = 60)
public SimClock(float step, float stepsPerRealSecond)
{
if (!(step > 0)) throw new ArgumentOutOfRangeException(nameof(step));
if (!(stepsPerRealSecond > 0)) throw new ArgumentOutOfRangeException(nameof(stepsPerRealSecond));