Merge origin/master into economy authoring command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dd7f40b559
33 changed files with 1329 additions and 129 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue