Ballistic flight r3: DrawAhead draws a round from the sim's facts, a hit ends at its impact cell; FireControl draws nothing

FireControl.RoundEnd and RoundAt leave FireControl (RoundEnd to DrawAhead verbatim, RoundAt as DrawAhead's private Line);
DrawAhead.Round takes the published outcome, steers a hit or burst from its frozen line at the commit tick onto its
end (burst point, else the impact cell on the target's drawn pose), and RoundStop is deleted. Projectile caches the
last published ShotOutcome and calls DrawAhead only. BallisticFlightTests move to the new signatures; the frozen-line
moving-target test is replaced by impact-cell, steer and no-drawing-function tests. Unverified: not yet built or run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-09 23:27:22 +02:00
commit 2dcb7b2078
5 changed files with 288 additions and 146 deletions

View file

@ -3,13 +3,13 @@ using System.Collections;
using UniRx;
using UnityEngine;
// A ballistic round, drawn by DrawAhead's round statics and deciding nothing. The simulation owns the round's whole
// flight (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 or range: each frame it
// asks DrawAhead.Round where the round is at sim time plus the clock's Lead (the same draw-ahead ships use), and the
// round ends on the sim's word: ShotResolved stops a round that hit or burst where DrawAhead.RoundStop puts it, and
// 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 sim line decays to zero over BlendTime sim seconds. The trail's fade after the end stays real time
// 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
{
@ -22,7 +22,7 @@ public class Projectile : MonoBehaviour
private PendingShot _shot;
private Zone _zone;
private CultMath.float3 _barrel;
private ShotResult? _known;
private ShotOutcome _known;
private bool _alive;
private CompositeDisposable _binding;
@ -49,25 +49,25 @@ public class Projectile : MonoBehaviour
private void Draw()
{
var time = _zone.Time + ActionGameManager.Clock.Lead;
transform.position = V(DrawAhead.Round(_shot, _known, _barrel, BlendTime, time));
if (DrawAhead.RoundOver(_shot, _known, time)) Finish();
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.Result;
_known = outcome;
}
// The simulation's verdict. A round that hit or burst stops where the sim put it; a miss keeps flying.
// 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.Result;
_known = outcome;
if (outcome.Result == ShotResult.Miss) return;
transform.position = V(DrawAhead.RoundStop(_shot, outcome, _barrel, BlendTime));
Draw();
if (HitEffect != null) HitEffect.Instantiate<Transform>().position = transform.position;
Finish();
}

View file

@ -4,7 +4,7 @@ public class ProjectileManager : InstantWeaponEffectManager
{
public Prototype ProjectilePrototype;
// The round is drawn on the line the simulation flies it (FireControl.RoundAt), so there is nothing to aim, jitter
// 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)
{

View file

@ -5,10 +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: ships by
// their last step's velocity, ballistic rounds on FireControl's RoundAt and RoundEnd. Pure reads of the simulation for
// presenters (never called by ServerShared, no state of their own): 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,
@ -39,19 +40,57 @@ public static class DrawAhead
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 FireControl's line for what the sim has published
// of its outcome, with the barrel's offset from that line decaying to nothing over blendTime sim seconds from the moment of firing.
public static float3 Round(in PendingShot shot, ShotResult? known, float3 barrel, float blendTime, float time) =>
Lifted(shot, FireControl.RoundAt(shot, known, time), barrel, blendTime, time);
// 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;
}
// Where a round that hit or burst stops: the outcome's burst point, else where it arrives on its line.
public static float3 RoundStop(in PendingShot shot, ShotOutcome outcome, float3 barrel, float blendTime) =>
Lifted(shot, outcome.HasBurstPoint ? outcome.BurstPoint : FireControl.RoundAt(shot, outcome.Result, shot.ArrivalTime),
barrel, blendTime, shot.ArrivalTime);
// 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, 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, ShotResult? known, float time) =>
known == ShotResult.Miss && time >= FireControl.RoundEnd(shot, ShotResult.Miss);
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)
{

View file

@ -514,26 +514,6 @@ public static class FireControl
return shot.Target == null ? 1f : PDeviation(deviation, shot.Tracking);
}
// The sim time a ballistic round's drawn flight ends, for what is known of its outcome: the one statement of a round's
// end. 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). Pure: it reads the shot alone, and the simulation's own
// arithmetic (Step, Commit, Apply) does not call it. Presenters end rounds here, through DrawAhead.
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;
// Where a ballistic round is at a sim time: the one statement of the round's line, 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. Pure, like RoundEnd.
public static float2 RoundAt(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);
}
// How far a target can and does move off the line of fire in the solution window: the term DeviationProbability
// and HitProbability both add. Behaviour (its observed unpredictability across the line of sight) realises the
// evasion and capability (its envelope's reach) caps it, so a coasting ship evades nothing whatever it could do
@ -2023,9 +2003,9 @@ public struct PendingShot
public float3 FireTargetPosition;
public float3 FireTargetVelocity;
public float FireTime;
// The weapon's Velocity at Fire: frozen with the rest, so RoundAt states the same line whatever happens to the stat later.
// 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 round that misses flies on, read only by RoundEnd. FireRange above
// 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;

View file

@ -4,18 +4,23 @@
using System;
using System.Linq;
using System.Reflection;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float2x2 = CultMath.float2x2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Ballistic flight: FireControl.RoundAt is the one statement of where a fired round is at a sim time, from the
// PendingShot's frozen origin, direction, speed and fire time, and FireControl.RoundEnd the one statement of when its
// drawn flight ends for what the sim has published of its outcome. Unity draws rounds through DrawAhead's round statics
// and integrates nothing, so these tests pin the line, the end and the drawing against first principles (the shooter's
// position, the aim, the authored velocity and range) rather than against the code that computes them. Shares the
// Cut 12.4 engagement fixture.
// Ballistic flight: a round's flight is presentation (ruling projectile-flight-is-presentation). The simulation publishes
// the facts (the PendingShot's frozen origin, direction, speed, fire time and arrival, the outcome with its commit tick,
// impact cell and burst point) and DrawAhead's statics draw the round from them: Round the one statement of where a round
// is at a sim time, Impact where a hit lands on the target's drawn pose, RoundEnd and RoundOver when its drawn flight
// ends. FireControl owns none of it (ruling direct-arrival-keep-timing: damage keeps landing at the arrival time, the
// hit is drawn at its cell). Unity's Projectile calls Round and integrates nothing, so these tests pin the drawing
// against first principles (the shooter's position, the aim, the authored velocity and range, the hull's own frame)
// rather than against the code that computes it. Shares the Cut 12.4 engagement fixture.
public sealed partial class FireControlCut124Tests
{
private const float BallisticSpeed = 20f;
@ -32,57 +37,77 @@ public sealed partial class FireControlCut124Tests
return e;
}
// What the sim publishes for a shot, as a presenter holds it: only the result (no impact, no burst point).
private static ShotOutcome Known(in PendingShot shot, ShotResult result) =>
new ShotOutcome { ShotId = shot.ShotId, Result = result, ArrivalIn = max(0f, shot.ArrivalTime - shot.CommitTime) };
// A hit on a cell of the target, committed at `commit`: ArrivalIn is how long after the commit damage lands.
private static ShotOutcome Hit(in PendingShot shot, Entity target, int2 cell, float commit) =>
new ShotOutcome { ShotId = shot.ShotId, Result = ShotResult.Hit, Target = target, Cell = cell, ArrivalIn = shot.ArrivalTime - commit };
// The hull's occupied coordinate farthest from its centre of mass: a cell well off the target's position.
private static int2 FarCell(Engagement e) =>
e.HullData.Shape.Coordinates.OrderByDescending(c => length((float2) c - e.HullData.Shape.CenterOfMass)).First();
[Fact]
public void RoundAtIsTheFiredLine()
public void ARoundIsDrawnOnItsFiredLine()
{
// Burst round: leaves the shooter, is where its own speed puts it a second in, and ends on the burst point.
var e = FireBurstRound(out var origin);
var burst = SafeAssert.OnlyShot(e.Zone);
ShotOutcome outcome = null;
PendingShot burst = default;
using var c = e.Zone.ShotCommitted.Subscribe(o =>
{
outcome = o;
e.Zone.TryGetShot(o.ShotId, out burst);
});
e.Zone.Update(.01f);
Assert.NotNull(outcome);
Assert.True(outcome.HasBurstPoint);
var aim = normalize(float2(2, 1));
// The shooter moves on after the round left: the line is the one frozen at Fire, not one drawn from where it is now.
e.Shooter.Position += float3(7, 0, 3);
var start = FireControl.RoundAt(burst, null, burst.FireTime);
var start = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime, 0f);
Assert.Equal(origin.x, start.x, 3);
Assert.Equal(origin.z, start.y, 3);
Assert.Equal(origin.z, start.z, 3);
var second = FireControl.RoundAt(burst, null, burst.FireTime + 1f);
var second = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime + 1f, 0f);
Assert.Equal(origin.x + aim.x * BallisticSpeed, second.x, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, second.y, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, second.z, 3);
foreach (ShotResult? known in new ShotResult?[] { null, ShotResult.Burst })
foreach (var known in new[] { null, outcome })
foreach (var later in new[] { 0f, 1f, 50f })
{
var arrival = FireControl.RoundAt(burst, known, burst.ArrivalTime + later);
var arrival = DrawAhead.Round(burst, known, burst.FireOrigin, 0f, burst.ArrivalTime + later, 0f);
Assert.Equal(burst.BurstPosition.x, arrival.x, 3);
Assert.Equal(burst.BurstPosition.z, arrival.y, 3);
Assert.Equal(burst.BurstPosition.z, arrival.z, 3);
}
// Past the weapon's range it is held at the range point, however late it is asked.
var held = FireControl.RoundAt(burst, ShotResult.Miss, burst.FireTime + NoLockRange / BallisticSpeed + 50f);
var held = DrawAhead.Round(burst, Known(burst, ShotResult.Miss), burst.FireOrigin, 0f, burst.FireTime + NoLockRange / BallisticSpeed + 50f, 0f);
Assert.Equal(origin.x + aim.x * NoLockRange, held.x, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.y, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.z, 3);
// Before it was fired it is still at the origin, not behind it.
var before = FireControl.RoundAt(burst, null, burst.FireTime - 5f);
var before = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime - 5f, 0f);
Assert.Equal(origin.x, before.x, 3);
Assert.Equal(origin.z, before.y, 3);
Assert.Equal(origin.z, before.z, 3);
// Direct round at a stationary target 100 ahead (+z): arrives on the target, and a miss would fly on to the weapon's range.
var d = Build(TestSettings(), SolidShape(5, 4), velocity: BallisticSpeed, weaponRange: 1000f);
var dOrigin = d.Shooter.Position;
FireControl.Fire(d.Weapon, d.WeaponItem, d.Shooter);
var direct = SafeAssert.OnlyShot(d.Zone);
foreach (ShotResult? known in new ShotResult?[] { null, ShotResult.Hit })
// A round not known to miss waits at its arrival point however late it is asked.
foreach (var at in new[] { direct.ArrivalTime, direct.FireTime + 99f })
{
var hit = FireControl.RoundAt(direct, known, at);
Assert.Equal(d.Target.Position.x, hit.x, 2);
Assert.Equal(d.Target.Position.z, hit.y, 2);
}
Assert.Equal(dOrigin.z + 100f / 2f, FireControl.RoundAt(direct, null, direct.FireTime + 2.5f).y, 2);
var heldDirect = FireControl.RoundAt(direct, ShotResult.Miss, direct.FireTime + 1000f / BallisticSpeed + 99f);
Assert.Equal(dOrigin.z + 1000f, heldDirect.y, 2);
// A round not known to miss waits at its arrival point however late it is asked.
foreach (var at in new[] { direct.ArrivalTime, direct.FireTime + 99f })
{
var waiting = DrawAhead.Round(direct, null, direct.FireOrigin, 0f, at, 0f);
Assert.Equal(d.Target.Position.x, waiting.x, 2);
Assert.Equal(d.Target.Position.z, waiting.z, 2);
}
Assert.Equal(dOrigin.z + 100f / 2f, DrawAhead.Round(direct, null, direct.FireOrigin, 0f, direct.FireTime + 2.5f, 0f).z, 2);
var heldDirect = DrawAhead.Round(direct, Known(direct, ShotResult.Miss), direct.FireOrigin, 0f, direct.FireTime + 1000f / BallisticSpeed + 99f, 0f);
Assert.Equal(dOrigin.z + 1000f, heldDirect.z, 2);
}
// A round with no speed (the weapon authors Velocity 0, or effectively 0) does not fly: it is at its origin at any
@ -95,9 +120,9 @@ public sealed partial class FireControlCut124Tests
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = SafeAssert.OnlyShot(e.Zone);
var late = FireControl.RoundAt(shot, ShotResult.Miss, shot.FireTime + 5000f);
var late = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.FireTime + 5000f, 0f);
Assert.Equal(origin.x, late.x, 3);
Assert.Equal(origin.z, late.y, 3);
Assert.Equal(origin.z, late.z, 3);
}
[Fact]
@ -115,39 +140,44 @@ public sealed partial class FireControlCut124Tests
var after = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(BallisticSpeed, after.Speed, 4);
var aim = normalize(float2(2, 1));
var at = FireControl.RoundAt(after, null, after.FireTime + 1f);
var at = DrawAhead.Round(after, null, after.FireOrigin, 0f, after.FireTime + 1f, 0f);
Assert.Equal(origin.x + aim.x * BallisticSpeed, at.x, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, at.y, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, at.z, 3);
}
[Fact]
public void RoundAtIsPure()
public void ARoundIsPure()
{
var e = FireBurstRound(out _);
var e = FireDirectRound(20f, 1000f);
var shot = SafeAssert.OnlyShot(e.Zone);
e.Target.Direction = normalize(float2(1, 2));
e.Target.TurnRate = 3f;
var time = e.Zone.Time;
var entities = e.Zone.Entities.ToList();
var positions = entities.Select(x => x.Position).ToList();
var velocities = entities.Select(x => x.Velocity).ToList();
var directions = entities.Select(x => x.Direction).ToList();
var turnRates = entities.Select(x => x.TurnRate).ToList();
var barrel = shot.FireOrigin + float3(1, .5f, -2);
for (var i = 0; i < 25; i++)
{
var at = shot.FireTime + i * .37f;
foreach (ShotResult? known in new ShotResult?[] { null, ShotResult.Miss })
var cell = FarCell(e);
foreach (var known in new[] { null, Known(shot, ShotResult.Miss), Hit(shot, e.Target, cell, shot.CommitTime) })
for (var i = 0; i < 25; i++)
{
FireControl.RoundAt(shot, known, at);
FireControl.RoundEnd(shot, known);
DrawAhead.Round(shot, known, barrel, .1f, at);
DrawAhead.RoundOver(shot, known, at);
var at = shot.FireTime + i * .37f;
DrawAhead.Round(shot, known, barrel, .1f, at, .01f);
DrawAhead.RoundOver(shot, known, at, .01f);
DrawAhead.RoundEnd(shot, known?.Result);
DrawAhead.Impact(e.Target, cell, .01f * i);
}
}
Assert.Equal(time, e.Zone.Time);
Assert.True(e.Zone.PendingShots.Count == 1 && e.Zone.PendingShots[0].Equals(shot), "the pending shot changed");
Assert.True(e.Zone.Entities.SequenceEqual(entities), "the zone's entities changed");
Assert.True(entities.Select(x => x.Position).SequenceEqual(positions), "an entity moved");
Assert.True(entities.Select(x => x.Velocity).SequenceEqual(velocities), "an entity's velocity changed");
Assert.True(entities.Select(x => x.Direction).SequenceEqual(directions), "an entity turned");
Assert.True(entities.Select(x => x.TurnRate).SequenceEqual(turnRates), "an entity's turn rate changed");
}
// The weapon's Range is frozen with the rest: a round that misses flies on to the range it left with, whatever the stat
@ -158,7 +188,7 @@ public sealed partial class FireControlCut124Tests
var e = FireBurstRound(out var origin);
var shot = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(NoLockRange, shot.MaxRange, 4);
var endBefore = FireControl.RoundEnd(shot, ShotResult.Miss);
var endBefore = DrawAhead.RoundEnd(shot, ShotResult.Miss);
// The stat changes after the round left, and the weapon re-reads it.
((InstantWeaponData) e.Weapon.WeaponData).Range = Constant(NoLockRange * 5f);
@ -167,11 +197,11 @@ public sealed partial class FireControlCut124Tests
var after = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(NoLockRange, after.MaxRange, 4);
Assert.Equal(endBefore, FireControl.RoundEnd(after, ShotResult.Miss), 4);
Assert.Equal(endBefore, DrawAhead.RoundEnd(after, ShotResult.Miss), 4);
var aim = normalize(float2(2, 1));
var held = FireControl.RoundAt(after, ShotResult.Miss, after.FireTime + 1000f);
var held = DrawAhead.Round(after, Known(after, ShotResult.Miss), after.FireOrigin, 0f, after.FireTime + 1000f, 0f);
Assert.Equal(origin.x + aim.x * NoLockRange, held.x, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.y, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.z, 3);
}
// A direct round at a stationary target `targetRange` ahead (+z) of the shooter, weapon Range `range`, at `speed`;
@ -198,29 +228,30 @@ public sealed partial class FireControlCut124Tests
var d = FireDirectRound(speed, 1000f);
var direct = SafeAssert.OnlyShot(d.Zone);
Assert.Equal(direct.FireTime + 100f / speed, direct.ArrivalTime, 4);
Assert.Equal(direct.FireTime + 100f / speed, FireControl.RoundEnd(direct, null), 4);
Assert.Equal(direct.FireTime + 100f / speed, FireControl.RoundEnd(direct, ShotResult.Hit), 4);
Assert.Equal(direct.FireTime + 100f / speed, FireControl.RoundEnd(direct, ShotResult.Burst), 4);
Assert.Equal(direct.FireTime + 1000f / speed, FireControl.RoundEnd(direct, ShotResult.Miss), 4);
Assert.Equal(direct.FireTime + 100f / speed, DrawAhead.RoundEnd(direct, null), 4);
Assert.Equal(direct.FireTime + 100f / speed, DrawAhead.RoundEnd(direct, ShotResult.Hit), 4);
Assert.Equal(direct.FireTime + 100f / speed, DrawAhead.RoundEnd(direct, ShotResult.Burst), 4);
Assert.Equal(direct.FireTime + 1000f / speed, DrawAhead.RoundEnd(direct, ShotResult.Miss), 4);
foreach (ShotResult? known in new ShotResult?[] { null, ShotResult.Hit, ShotResult.Burst })
Assert.False(DrawAhead.RoundOver(direct, known, direct.FireTime + 10000f));
Assert.False(DrawAhead.RoundOver(direct, null, direct.FireTime + 10000f, 0f));
foreach (var result in new[] { ShotResult.Hit, ShotResult.Burst })
Assert.False(DrawAhead.RoundOver(direct, Known(direct, result), direct.FireTime + 10000f, 0f));
var missEnd = direct.FireTime + 1000f / speed;
Assert.False(DrawAhead.RoundOver(direct, ShotResult.Miss, missEnd - .01f));
Assert.True(DrawAhead.RoundOver(direct, ShotResult.Miss, missEnd));
Assert.False(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd - .01f, 0f));
Assert.True(DrawAhead.RoundOver(direct, Known(direct, ShotResult.Miss), missEnd, 0f));
// A target beyond the weapon's range: a miss never ends short of its own arrival.
var far = FireDirectRound(speed, 100f, targetRange: 150f);
var beyond = SafeAssert.OnlyShot(far.Zone);
Assert.Equal(beyond.FireTime + 150f / speed, beyond.ArrivalTime, 4);
Assert.Equal(beyond.FireTime + 150f / speed, FireControl.RoundEnd(beyond, ShotResult.Miss), 4);
Assert.Equal(beyond.FireTime + 150f / speed, DrawAhead.RoundEnd(beyond, ShotResult.Miss), 4);
// A round fired at nothing arrives where it left, and a miss flies on to the range.
var none = FireDirectRound(speed, 100f, atNothing: true);
var nothing = SafeAssert.OnlyShot(none.Zone);
Assert.Equal(nothing.FireTime, nothing.ArrivalTime, 4);
Assert.Equal(nothing.FireTime, FireControl.RoundEnd(nothing, null), 4);
Assert.Equal(nothing.FireTime + 100f / speed, FireControl.RoundEnd(nothing, ShotResult.Miss), 4);
Assert.Equal(nothing.FireTime, DrawAhead.RoundEnd(nothing, null), 4);
Assert.Equal(nothing.FireTime + 100f / speed, DrawAhead.RoundEnd(nothing, ShotResult.Miss), 4);
}
}
@ -239,21 +270,17 @@ public sealed partial class FireControlCut124Tests
foreach (var lead in new[] { 0f, step / 2f, .999f * step })
{
var time = shot.ArrivalTime + lead;
foreach (ShotResult? known in new ShotResult?[] { null, ShotResult.Hit, ShotResult.Burst })
{
var drawn = DrawAhead.Round(shot, known, shot.FireOrigin, 0f, time);
Assert.Equal(origin.x, drawn.x, 3);
Assert.Equal(origin.z + 100f, drawn.z, 3);
}
var missed = DrawAhead.Round(shot, ShotResult.Miss, shot.FireOrigin, 0f, time);
var waiting = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime, lead);
Assert.Equal(origin.x, waiting.x, 3);
Assert.Equal(origin.z + 100f, waiting.z, 3);
var missed = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.ArrivalTime, lead);
Assert.Equal(origin.x, missed.x, 3);
Assert.Equal(origin.z + 100f + lead * speed, missed.z, 3);
}
for (var time = shot.FireTime; time <= shot.ArrivalTime + 1f; time += 1f / 144f)
{
var drawn = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, time);
var drawn = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, time, 0f);
Assert.True(drawn.z - origin.z <= 100f + 1e-3f, "the round was drawn past its arrival point");
}
}
@ -289,7 +316,7 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(fired.FireTime + 1.05f, record.ArrivalTime, 2);
Assert.True(record.ArrivalTime < fired.ArrivalTime);
var stop = DrawAhead.RoundStop(record, outcome, record.FireOrigin, 0f);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime, 0f);
var expected = outcome.HasBurstPoint
? outcome.BurstPoint
: record.FireOrigin.xz + record.TravelDirection * (20f * (record.ArrivalTime - record.FireTime));
@ -297,7 +324,7 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(expected.y, stop.z, 3);
foreach (var later in new[] { 0f, .5f, 5f })
{
var drawn = DrawAhead.Round(record, ShotResult.Burst, record.FireOrigin, 0f, record.ArrivalTime + later);
var drawn = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime + later, 0f);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.z, 3);
}
@ -324,11 +351,11 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(want.z, got.z, 3);
}
Close(barrel, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime));
Close(Line(blend / 2f) + offset * .5f, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend / 2f));
Close(Line(blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend));
Close(Line(2f * blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + 2f * blend));
Close(Line(0f), DrawAhead.Round(shot, null, barrel, 0f, shot.FireTime));
Close(barrel, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime, 0f));
Close(Line(blend / 2f) + offset * .5f, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend / 2f, 0f));
Close(Line(blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend, 0f));
Close(Line(2f * blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + 2f * blend, 0f));
Close(Line(0f), DrawAhead.Round(shot, null, barrel, 0f, shot.FireTime, 0f));
}
// Placement depends on sim time alone: any history of frames walked to a checkpoint draws what a fresh call draws, which
@ -342,6 +369,7 @@ public sealed partial class FireControlCut124Tests
var shot = SafeAssert.OnlyShot(e.Zone);
var checkpoints = new[] { .05f, .3f, 1f, 3f };
var histories = new[] { new[] { 1f / 144f }, new[] { 1f / 30f }, new[] { 1f / 144f, 1f / 30f, 1f / 90f } };
var miss = Known(shot, ShotResult.Miss);
foreach (var frames in histories)
{
@ -352,10 +380,18 @@ public sealed partial class FireControlCut124Tests
while (elapsed + 1e-4f < checkpoint)
{
elapsed = min(checkpoint, elapsed + frames[frame++ % frames.Length]);
DrawAhead.Round(shot, ShotResult.Miss, shot.FireOrigin, .1f, shot.FireTime + elapsed);
DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + elapsed, 0f);
}
var drawn = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, 0f);
var fresh = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, 0f);
// A frame that falls `lead` after the step draws what the sim time lead later would.
foreach (var lead in new[] { 1f / 60f, 1f / 90f })
{
var ahead = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint - lead, lead);
Assert.Equal(fresh.x, ahead.x, 3);
Assert.Equal(fresh.y, ahead.y, 3);
Assert.Equal(fresh.z, ahead.z, 3);
}
var drawn = DrawAhead.Round(shot, ShotResult.Miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint);
var fresh = DrawAhead.Round(shot, ShotResult.Miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint);
Assert.Equal(fresh.z, drawn.z, 3);
Assert.Equal(shot.FireOrigin.z + speed * min(checkpoint, 1000f / speed), drawn.z, 3);
Assert.Equal(shot.FireOrigin.x, drawn.x, 3);
@ -364,31 +400,118 @@ public sealed partial class FireControlCut124Tests
}
}
// Today's sim geometry (a target crossing at 10 against a speed of 20, so an intercept exists): the round flies the line frozen at Fire and stops at the arrival point on it, whatever the
// target does afterwards. (Where a leading round's arrival should lie is the sim's question, never Projectile's.)
[Fact]
public void AHitOnAMovingTargetStopsOnItsFrozenLine()
// A hit ends at its impact cell on the target's drawn pose when damage lands, not at the arrival point of the line
// frozen at Fire (ruling direct-arrival-keep-timing): the target has moved and turned since, and is drawn ahead by its
// last step's velocity and turn. The expected point is computed apart from DrawAhead, by moving the entity itself and
// asking its own schematic frame (Entity.ToWorldPoint) where the cell is.
[Theory]
[InlineData(20f)]
[InlineData(200f)]
public void AHitEndsAtItsImpactCellOnTheTargetsDrawnPose(float speed)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: BallisticSpeed, weaponRange: 1000f);
var e = Build(TestSettings(), SolidShape(5, 4), velocity: speed, weaponRange: 1000f);
e.Target.Velocity = float2(10, 0);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = SafeAssert.OnlyShot(e.Zone);
Assert.True(shot.TravelDirection.x > 0f, "the round did not lead the target");
const float step = 1f / 30f;
e.Target.Position += float3(40, 0, 0);
var stop = DrawAhead.RoundStop(shot, new ShotOutcome { ShotId = shot.ShotId, Result = ShotResult.Hit }, shot.FireOrigin, 0f);
var line = shot.FireOrigin.xz + shot.TravelDirection * (BallisticSpeed * (shot.ArrivalTime - shot.FireTime));
Assert.Equal(line.x, stop.x, 3);
Assert.Equal(shot.FireOrigin.y, stop.y, 3);
Assert.Equal(line.y, stop.z, 3);
e.Target.Direction = normalize(float2(1, 2));
e.Target.TurnRate = 3f;
var cell = FarCell(e);
var hit = Hit(shot, e.Target, cell, shot.CommitTime);
var arrivalOnTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime, 0f);
var cellSize = e.Items.GameplaySettings.SchematicCellSize;
foreach (var lead in new[] { 0f, step / 2f, .999f * step })
{
var position = e.Target.Position;
var direction = e.Target.Direction;
e.Target.Position += float3(e.Target.Velocity.x, 0, e.Target.Velocity.y) * lead;
e.Target.Direction = mul(e.Target.Direction, float2x2.Rotate(e.Target.TurnRate * lead));
var cellPoint = e.Target.ToWorldPoint((float2) cell);
var expected = float3(cellPoint.x, e.Target.Position.y, cellPoint.y);
e.Target.Position = position;
e.Target.Direction = direction;
Assert.True(length((expected - arrivalOnTheLine).xz) > cellSize, "the fixture put the cell on the frozen line's arrival point");
var impact = DrawAhead.Impact(e.Target, cell, lead);
Assert.Equal(expected.x, impact.x, 3);
Assert.Equal(expected.y, impact.y, 3);
Assert.Equal(expected.z, impact.z, 3);
foreach (var simTime in new[] { shot.ArrivalTime, shot.ArrivalTime + .5f })
{
var drawn = DrawAhead.Round(shot, hit, shot.FireOrigin, .1f, simTime, lead);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.y, 3);
Assert.Equal(expected.z, drawn.z, 3);
}
}
}
// A hit leaves its frozen line at the commit tick and steers onto its end by the time damage lands: before the commit it
// is on the line, half way through the steer it is half way to the impact cell, and a frame drawn ahead by the clock's
// lead places the round where the sim time that much later would.
[Fact]
public void ASteeredRoundLeavesItsLineAtTheCommit()
{
var e = FireDirectRound(20f, 1000f);
var shot = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(shot.FireTime + 5f, shot.ArrivalTime, 3);
var cell = FarCell(e);
var commit = shot.ArrivalTime - 2f;
var hit = Hit(shot, e.Target, cell, commit);
foreach (var t in new[] { commit - 1f, commit })
{
var steered = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, t, 0f);
var line = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, t, 0f);
Assert.Equal(line.x, steered.x, 3);
Assert.Equal(line.y, steered.y, 3);
Assert.Equal(line.z, steered.z, 3);
}
var m = commit + 1f;
var onTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, m, 0f);
var end = DrawAhead.Impact(e.Target, cell, 0f);
Assert.True(length((end - onTheLine).xz) > 1f, "the fixture put the impact cell on the line");
var halfway = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m, 0f);
Assert.Equal(onTheLine.x + (end.x - onTheLine.x) * .5f, halfway.x, 3);
Assert.Equal(onTheLine.y + (end.y - onTheLine.y) * .5f, halfway.y, 3);
Assert.Equal(onTheLine.z + (end.z - onTheLine.z) * .5f, halfway.z, 3);
var ahead = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m - .02f, .02f);
Assert.Equal(halfway.x, ahead.x, 3);
Assert.Equal(halfway.y, ahead.y, 3);
Assert.Equal(halfway.z, ahead.z, 3);
}
// The sim states facts and draws nothing: no drawing function, and no lead, blend or barrel, lives in FireControl;
// those belong to DrawAhead, the presentation statics.
[Fact]
public void FireControlHasNoDrawingFunctions()
{
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly;
var methods = typeof(FireControl).GetMethods(all).Cast<MethodBase>().Concat(typeof(FireControl).GetConstructors(all)).ToList();
Assert.NotEmpty(methods);
foreach (var method in methods)
{
Assert.False(method.Name.StartsWith("Round") || method.Name.StartsWith("Draw"), "FireControl has a drawing function: " + method.Name);
foreach (var parameter in method.GetParameters())
Assert.False(new[] { "lead", "blendTime", "barrel" }.Contains(parameter.Name), "FireControl takes a drawing parameter: " + parameter.Name);
}
foreach (var name in new[] { "Round", "Impact", "RoundOver", "RoundEnd" })
Assert.NotNull(typeof(DrawAhead).GetMethod(name, BindingFlags.Public | BindingFlags.Static));
}
// A proximity round at a target that recedes faster than the round closes bursts at the weapon's range, pulled back
// along its line, while it arrives when it has flown the distance the target was fired at: its burst point is not
// the line at its arrival. The stop is the sim's burst point, so a presenter that stopped at the arrival line would
// the line at its arrival. The end is the sim's burst point, so a presenter that ended at the arrival line would
// end the tracer short of where the round burst.
[Fact]
public void ABurstRoundStopsAtItsBurstPointNotItsArrivalLine()
public void ABurstRoundEndsAtItsBurstPointNotItsArrivalLine()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: BallisticSpeed, fuse: WeaponFuse.Proximity, blastRadius: 4f,
damage: 100f, weaponRange: NoLockRange, targetRange: 50f);
@ -404,8 +527,8 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(origin.x, outcome.BurstPoint.x, 3);
Assert.Equal(origin.z + NoLockRange, outcome.BurstPoint.y, 3);
Assert.Equal(origin.z + 50f, FireControl.RoundAt(record, outcome.Result, record.ArrivalTime).y, 3);
var stop = DrawAhead.RoundStop(record, outcome, record.FireOrigin, 0f);
Assert.Equal(origin.z + 50f, DrawAhead.Round(record, null, record.FireOrigin, 0f, record.ArrivalTime, 0f).z, 3);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime, 0f);
Assert.Equal(outcome.BurstPoint.x, stop.x, 3);
Assert.Equal(outcome.BurstPoint.y, stop.z, 3);
}