Rounds per step: a held gun fires every round its cooldown allows in a step, each born at its own moment inside it; the shortest-timer pin is gone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d4508d196
commit
95b7aeb7a2
5 changed files with 114 additions and 68 deletions
|
|
@ -21,11 +21,7 @@ public class AutoWeapon : InstantWeapon
|
|||
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);
|
||||
}
|
||||
// The trigger is held while it is down: InstantWeapon starts the next burst the moment the cooldown ends.
|
||||
protected override bool Repeats => _firing;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -146,7 +146,8 @@ 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;
|
||||
if (_data.MagazineSize > 1 && _ammo > 0) _ammo--;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
// 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 shooter asked for fire during this step (Activate): a weapon asked of for the whole step fires every round its
|
||||
// cooldown allows inside it, rather than one per step. Cleared when the step ends.
|
||||
private bool _pulled;
|
||||
// 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; }
|
||||
|
|
@ -90,6 +95,10 @@ 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 the trigger is held through this step, so a cooldown that ends inside it starts the next burst inside it. A
|
||||
// tap or a press fires once per pull (a pull is held for its step); an automatic weapon holds while its trigger is down.
|
||||
protected virtual bool Repeats => _pulled;
|
||||
|
||||
public virtual void ResetEvents()
|
||||
{
|
||||
OnReloadBegin = null;
|
||||
|
|
@ -187,9 +196,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity);
|
||||
|
||||
// Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one.
|
||||
private void FinishCountdown(float dt)
|
||||
// True when it ended the countdown now.
|
||||
private bool FinishCountdown(float dt)
|
||||
{
|
||||
if (!_coolingDown || !(_cooldown < 0)) return;
|
||||
if (!_coolingDown || !(_cooldown < 0)) return false;
|
||||
_overshoot = min(-_cooldown * CountdownSpan, dt);
|
||||
_coolingDown = false;
|
||||
if (_data.MagazineSize > 0 && _ammo == 0)
|
||||
|
|
@ -199,9 +209,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
}
|
||||
else
|
||||
OnCooldownComplete?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
private float CountdownSpan => _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown;
|
||||
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()
|
||||
|
|
@ -217,7 +228,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
return overshoot;
|
||||
}
|
||||
|
||||
private bool UseAmmo()
|
||||
private bool UseAmmo(float dt)
|
||||
{
|
||||
if (_data.MagazineSize <= 1) return true;
|
||||
|
||||
|
|
@ -242,7 +253,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
if(hasAmmo)
|
||||
{
|
||||
OnReloadBegin?.Invoke();
|
||||
_cooldown = 1 - TakeOvershoot() / _data.ReloadTime;
|
||||
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime;
|
||||
_coolingDown = true;
|
||||
_firing = false;
|
||||
}
|
||||
|
|
@ -252,19 +263,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
}
|
||||
|
||||
public override bool Execute(float dt)
|
||||
{
|
||||
try { return Step(dt); }
|
||||
finally { _pulled = false; }
|
||||
}
|
||||
|
||||
// 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);
|
||||
var ended = false;
|
||||
var retriggered = false;
|
||||
if (_coolingDown)
|
||||
{
|
||||
_cooldown -= dt / CountdownSpan;
|
||||
FinishCountdown(dt);
|
||||
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
|
||||
|
|
@ -273,7 +297,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;
|
||||
|
|
@ -284,7 +308,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
if (!_burstStarted)
|
||||
{
|
||||
_burstStarted = true;
|
||||
_cooldown = 1 - (dt + TakeOvershoot()) / CountdownSpan;
|
||||
_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;
|
||||
|
|
@ -302,14 +326,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, so the next trigger can fire on the next one.
|
||||
FinishCountdown(dt);
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -57,12 +57,16 @@ public sealed partial class ContactRevealTests
|
|||
|
||||
// Holds the trigger for `seconds`, topping up the capacitor so only the cooldown paces the gun. Returns the zone
|
||||
// time of each round.
|
||||
private List<float> HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds)
|
||||
private List<float> HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds, List<float> fireTimes = null)
|
||||
{
|
||||
var gun = (InstantWeapon) item.Behaviors.Single(b => b is InstantWeapon);
|
||||
var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun);
|
||||
var rounds = new List<float>();
|
||||
gun.OnFire += _ => rounds.Add(_zone.Time);
|
||||
gun.OnFire += id =>
|
||||
{
|
||||
rounds.Add(_zone.Time);
|
||||
if (fireTimes != null && _zone.TryGetShot(id, out var shot)) fireTimes.Add(shot.FireTime);
|
||||
};
|
||||
ship.SetTarget(TargetRef.None);
|
||||
for (var step = 0; step < (int) MathF.Round(seconds / dt); step++)
|
||||
{
|
||||
|
|
@ -277,6 +281,54 @@ public sealed partial class ContactRevealTests
|
|||
}
|
||||
|
||||
// A magazine of three, cooldown .1 s and a .9 s reload is three rounds a 1.1 s cycle, at any step.
|
||||
// Ruling rounds-per-step: cadence is authored in seconds at any tick rate. A gun of 0.01 s fires 100 rounds a second at 15, 30
|
||||
// and 60 Hz (several in one step), each born at its own moment inside the step, one cooldown after the one before.
|
||||
[Fact]
|
||||
public void AFastGunDeliversItsAuthoredRoundsAtEveryRate()
|
||||
{
|
||||
foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f })
|
||||
{
|
||||
var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" });
|
||||
var fireTimes = new List<float>();
|
||||
var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 10f, fireTimes);
|
||||
Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1");
|
||||
Assert.Equal(rounds.Count, fireTimes.Count);
|
||||
for (var i = 1; i < fireTimes.Count; i++)
|
||||
Assert.True(abs(fireTimes[i] - fireTimes[i - 1] - .01f) < 2e-4f, $"dt {dt}: rounds {i - 1} and {i} born {fireTimes[i - 1]:R} and {fireTimes[i]:R}");
|
||||
}
|
||||
}
|
||||
|
||||
// The cluster's cost: a point-defence battery of 0.005 s guns holding its trigger, every round a queued shot with its own
|
||||
// roll. The step wall time and the heap bytes per round are written beside the test for the report.
|
||||
[Fact, Trait("Category", "Slow")]
|
||||
public void ADenseClusterStepsAtAffordableCost()
|
||||
{
|
||||
const int guns = 16;
|
||||
var dt = SimClock.GameStep;
|
||||
var ships = Enumerable.Range(0, guns).Select(i => Add(float3(i * 3, 0, 0), new[] { GunWithCooldown(.005f) }, new[] { "Targeting", "Reactor" })).ToList();
|
||||
var weapons = ships.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(b => b is InstantWeapon)).ToList();
|
||||
var capacitors = weapons.Select(w => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(w)).ToList();
|
||||
var rounds = 0;
|
||||
foreach (var w in weapons) w.OnFire += _ => rounds++;
|
||||
foreach (var s in ships) s.SetTarget(TargetRef.None);
|
||||
void Step()
|
||||
{
|
||||
for (var i = 0; i < guns; i++) { capacitors[i].AddCharge(1000f); weapons[i].Activate(); }
|
||||
Tick(dt);
|
||||
}
|
||||
for (var i = 0; i < 30; i++) Step(); // warm the JIT and the shot queue
|
||||
rounds = 0;
|
||||
var steps = 300;
|
||||
var bytes = GC.GetAllocatedBytesForCurrentThread();
|
||||
var watch = System.Diagnostics.Stopwatch.StartNew();
|
||||
for (var i = 0; i < steps; i++) Step();
|
||||
watch.Stop();
|
||||
bytes = GC.GetAllocatedBytesForCurrentThread() - bytes;
|
||||
var line = $"{guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {bytes / (double) rounds:F0} heap bytes per round";
|
||||
System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-stepcost.txt"), line);
|
||||
Assert.True(abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MagazineCadenceIsPerSecond()
|
||||
{
|
||||
|
|
@ -341,48 +393,4 @@ public sealed partial class RunStartTests
|
|||
Assert.True(Math.Abs(now.survivor - old.survivor) <= .1 * old.survivor, table.ToString());
|
||||
Assert.True(Math.Abs(now.firstKill - old.firstKill) <= .1 * old.firstKill, table.ToString());
|
||||
}
|
||||
|
||||
// Every shipped cooldown, ping cooldown and ammo interval spans at least two steps, but the four guns below are
|
||||
// authored faster (a restarted cooldown ends within its step, so they fire at most once per step: the step caps their
|
||||
// rate). That is a content decision awaiting a ruling, so the offenders are pinned: a new one fails here. A burst
|
||||
// interval under one step fires several rounds in one step (carry makes that exact on average) and is not a fault.
|
||||
private static readonly string[] KnownFastCooldowns =
|
||||
{
|
||||
"Light Machine Gun.Cooldown", "Long Slug Rifle.Cooldown", "Point Defense Gun.Cooldown", "Slug Rifle.Cooldown"
|
||||
};
|
||||
[Fact]
|
||||
public void ShortestTimerIsSeveralSteps()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var timers = new List<(string item, string timer, float seconds)>();
|
||||
var bursts = new List<string>();
|
||||
void Read(string item, IEnumerable<BehaviorData> behaviors)
|
||||
{
|
||||
foreach (var behavior in behaviors.Where(b => b != null))
|
||||
switch (behavior)
|
||||
{
|
||||
case InstantWeaponData gun:
|
||||
timers.Add((item, "Cooldown", Math.Min(gun.Cooldown.Min, gun.Cooldown.Max)));
|
||||
var count = Math.Max(1f, Math.Min(gun.Count.Min, gun.Count.Max));
|
||||
var interval = Math.Min(gun.BurstTime.Min, gun.BurstTime.Max) / count;
|
||||
if (count > 1 && interval < SimClock.GameStep) bursts.Add($"{item}: burst interval {interval:R} s");
|
||||
break;
|
||||
case SensorData sensor:
|
||||
timers.Add((item, "PingCooldown", Math.Min(sensor.PingCooldown.Min, sensor.PingCooldown.Max)));
|
||||
break;
|
||||
case ConstantWeaponData beam when beam.AmmoType.IsSet():
|
||||
timers.Add((item, "AmmoInterval", beam.AmmoInterval));
|
||||
break;
|
||||
case CooldownData cooldown:
|
||||
timers.Add((item, "Cooldown", Math.Min(cooldown.Cooldown.Min, cooldown.Cooldown.Max)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (var item in cache.GetAll<EquippableItemData>()) Read(item.Name, item.Behaviors);
|
||||
foreach (var item in cache.GetAll<ConsumableItemData>()) Read(item.Name, item.Behaviors);
|
||||
|
||||
var offenders = timers.Where(t => t.seconds > 0 && t.seconds < 2 * SimClock.GameStep)
|
||||
.Select(t => $"{t.item}.{t.timer}").OrderBy(name => name, StringComparer.Ordinal).ToList();
|
||||
Assert.True(offenders.SequenceEqual(KnownFastCooldowns), $"timers under two steps of {SimClock.GameStep:R} s: {string.Join("; ", offenders)}. Burst intervals under a step: {string.Join("; ", bursts)}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue