diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index deeaf2459..70db9ae92 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -177,8 +177,6 @@ public class ActionGameManager : MonoBehaviour public Sprite ShieldIcon; public Sprite NoShieldIcon; - public float IntroDuration; - //public PlayerInput Input; // private CinemachineFramingTransposer _transposer; @@ -231,6 +229,8 @@ 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); + private bool _zoneEntered; private readonly (float2 direction, string name)[] _directions = { (float2(0, 1), "Front"), @@ -728,6 +728,7 @@ public class ActionGameManager : MonoBehaviour galaxyZone.Contents = new Zone(ItemManager, Settings.PlanetSettings, galaxyZone.PackedContents, galaxyZone, CurrentGalaxy); } Zone = galaxyZone.Contents; + _zoneEntered = true; // the clock forgets its lead between frames, never inside a step PlayMusic(MusicType.Overworld); Zone.Log = s => Debug.Log($"Zone: {s}"); @@ -818,7 +819,6 @@ public class ActionGameManager : MonoBehaviour } else { - //StartCoroutine(IntroCutscene(targetEntity as Ship)); BindToEntity(targetEntity); } @@ -866,35 +866,6 @@ public class ActionGameManager : MonoBehaviour BindToEntity(staged.Player); } - private IEnumerator IntroCutscene(Ship ship) - { - ZoneRenderer.PerspectiveEntity = ship; - var entityPosition = ship.Position.xz; - var followOrbit = Zone.Orbits.Keys.MinBy(o => lengthsq(Zone.GetOrbitPosition(o) - entityPosition)); - var followPlanet = ZoneRenderer.Planets[Zone.Planets.FirstOrDefault(p => p.Value.Orbit.Key.Equals(followOrbit)).Key]; - DockCamera.Follow = followPlanet.Body.transform; - var rootOrbit = followOrbit; - while (Zone.Orbits[rootOrbit].Data.Parent.IsSet()) - rootOrbit = Zone.Orbits[rootOrbit].Data.Parent.Key; - var rootPlanet = ZoneRenderer.Planets[Zone.Planets.FirstOrDefault(p => p.Value.Orbit.Key.Equals(rootOrbit)).Key]; - DockCamera.LookAt = rootPlanet.Body.transform; - - var shipVelocity = ship.GetBehavior().Limit; - var followOrbitPosition = Zone.GetOrbitPosition(followOrbit); - var shipDirection = normalize(Zone.GetOrbitPosition(rootOrbit) - followOrbitPosition); - ship.Position.xz = followOrbitPosition - shipDirection * shipVelocity * IntroDuration; - - var startTime = Time.time; - while (Time.time - startTime < IntroDuration) - { - ship.Direction = shipDirection; - ship.Velocity = shipDirection * shipVelocity; - yield return null; - } - - BindToEntity(ship); - } - public void Dock() { if (CurrentEntity.Parent != null) return; @@ -1356,8 +1327,14 @@ public class ActionGameManager : MonoBehaviour CurrentEntity.TractorPower = saturate(CurrentEntity.TractorPower + sign(tractorPower - CurrentEntity.TractorPower) * Time.deltaTime * 2); } - Zone.Update(Time.deltaTime); } + // One clock steps the zone, paused or not: a paused frame feeds it no real time. + // A zone entered inside a step (a wormhole) ends this frame's stepping: the rest of its steps are the old zone's. + void EnterNewZone() { if (_zoneEntered) { Clock.Reset(); _zoneEntered = false; } } + EnterNewZone(); + var steppedZone = Zone; + Clock.Advance(_paused ? 0f : Time.deltaTime, delta => { if (Zone == steppedZone) steppedZone.Update(delta); }); + EnterNewZone(); } private static string ResultLabel(ShotResult result) => result switch diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 61146034e..318a3007e 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -416,9 +416,10 @@ public class EntityInstance : MonoBehaviour var direction = FireControl.Solution(weapon, Entity, Entity.Target.Value).Direction; group.Value.position = barrel + new Vector3(direction.x, 0, direction.y) * aimDistance; } - LocalSpace.localPosition = transform.position = Entity.Position.ToUnity(); + var drawn = DrawAhead.Position(Entity, ActionGameManager.Clock.Lead); + LocalSpace.localPosition = transform.position = drawn.ToUnity(); if (_influenceInstance) - _influenceInstance.position = new Vector3(Entity.Position.x, 0, Entity.Position.z); + _influenceInstance.position = new Vector3(drawn.x, 0, drawn.z); } public virtual void OnDestroy() diff --git a/Assets/Scripts/Gameplay/ShipInstance.cs b/Assets/Scripts/Gameplay/ShipInstance.cs index 287eb42aa..3befea9b0 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -102,6 +102,6 @@ public class ShipInstance : EntityInstance emissionModule.rateOverTimeMultiplier = thrusterInstance.BaseEmission * thrusterInstance.Thruster.Axis * thrusterInstance.Thruster.Condition; } - transform.rotation = Ship.Rotation.ToUnity(); + transform.rotation = DrawAhead.Rotation(Ship, ActionGameManager.Clock.Lead).ToUnity(); } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index be0d79900..007bf66bc 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -125,8 +125,9 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer { var thrust = Thrust; Entity.Velocity -= Direction.xz * _input * thrust / Entity.Mass * dt; - Entity.Direction = mul(Entity.Direction, - CultMath.float2x2.Rotate(_input * Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass * dt)); + var turnRate = _input * Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass; + Entity.Direction = mul(Entity.Direction, CultMath.float2x2.Rotate(turnRate * dt)); + Entity.TurnRate += turnRate; AddHeat(_input * Evaluate(_data.Heat) * dt); var vis = _input * Evaluate(_data.Visibility); if (!Entity.VisibilitySources.ContainsKey(this) || vis > Entity.VisibilitySources[this]) diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs new file mode 100644 index 000000000..d26bf91b4 --- /dev/null +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using CultMath; +using static CultMath.math; + +// Where a presenter draws the latest sim state when the frame falls Lead sim seconds after the last step. Pure reads +// of the simulation for presenters (never called by ServerShared): the sim owns every pose and no presenter stores +// one, a previous state or a velocity of its own. +public static class DrawAhead +{ + // A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate, + // so it is drawn where the step put it: extrapolating a scripted pose draws a streak that resets every step. + public static float3 Position(Entity entity, float lead) => + entity is Ship { WormholeAnimationInProgress: true } + ? entity.Position + : entity.Position + float3(entity.Velocity.x, 0, entity.Velocity.y) * lead; + + // The ship's rotation turned about its own up axis by the rate its last step turned, for lead seconds. + public static quaternion Rotation(Ship ship, float lead) => + ship.WormholeAnimationInProgress ? ship.Rotation : Turned(ship.Rotation, ship.TurnRate * lead); + + // q turned about its own up axis by the angle, in the sense Direction turns for a positive TurnRate. + public static quaternion Turned(quaternion q, float angle) + { + var u = float3(q.x, q.y, q.z); + var up = float3(0, 1, 0); + up += 2 * (q.w * cross(u, up) + cross(u, cross(u, up))); + var half = angle * .5f; + var s = sin(half); + var t = new quaternion(up.x * s, up.y * s, up.z * s, cos(half)); + // Hamilton product t * q: CultMath carries no quaternion product (a gap in CultMath, not filled here). + return normalize(new quaternion( + t.w * q.x + t.x * q.w + t.y * q.z - t.z * q.y, + t.w * q.y - t.x * q.z + t.y * q.w + t.z * q.x, + t.w * q.z + t.x * q.y - t.y * q.x + t.z * q.w, + t.w * q.w - t.x * q.x - t.y * q.y - t.z * q.z)); + } +} diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs.meta b/Assets/Scripts/ServerShared/DrawAhead.cs.meta new file mode 100644 index 000000000..628d71b5d --- /dev/null +++ b/Assets/Scripts/ServerShared/DrawAhead.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 007fe4255cd84c26a010151a399f45c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 5a75d93ae..1da6e68d8 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -27,6 +27,10 @@ public abstract class Entity public float3 Position; public float2 Direction = float2(0,1); public float2 Velocity; + // Rad per sim second, signed as the step turns Direction: the sum of this step's rotation actuators' applied + // angle over the step. Runtime only, zeroed at the start of Update, so a staged, loaded or wormhole write to + // Direction is never a turn. Read only by presenters that draw ahead of the step. + public float TurnRate; // Runtime only, never saved; Entity.Update is the one writer of both. Acceleration is the planar velocity change // across the base update over its delta (thrust, not the drag or gravity a subclass applies before calling it), @@ -1359,6 +1363,7 @@ public abstract class Entity public virtual void Update(float delta) { + TurnRate = 0; if (!_active) return; var velocityBefore = Velocity; diff --git a/Assets/Scripts/ServerShared/SimClock.cs b/Assets/Scripts/ServerShared/SimClock.cs new file mode 100644 index 000000000..6d00c46d0 --- /dev/null +++ b/Assets/Scripts/ServerShared/SimClock.cs @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; + +// The one relation between simulation time and real time. Real seconds go in; whole fixed steps come out, each +// handed to the caller's step action with the same Step. Nothing else in the game converts frame time to sim time, +// and nothing integrates a sim quantity on this clock: presenters read Lead to draw ahead of the latest step +// (DrawAhead) and decide no sim fact. +public sealed class SimClock +{ + // The longest real time one frame may feed. A hitch (a load, a breakpoint) is a slow frame, not a burst of + // catch-up steps. + public const float MaxFrameSeconds = .1f; + + private readonly double _period; + private double _accumulator; + private bool _advancing; + + public SimClock(float step, float stepsPerRealSecond = 60) + { + if (!(step > 0)) throw new ArgumentOutOfRangeException(nameof(step)); + if (!(stepsPerRealSecond > 0)) throw new ArgumentOutOfRangeException(nameof(stepsPerRealSecond)); + Step = step; + StepsPerRealSecond = stepsPerRealSecond; + _period = 1.0 / stepsPerRealSecond; + } + + // Sim seconds per step, fixed for the clock's life. + public float Step { get; } + + public float StepsPerRealSecond { get; } + + public long Steps { get; private set; } + + // Sim seconds since the last step, 0 <= Lead < Step. + public float Lead => (float) (_accumulator * StepsPerRealSecond * Step); + + // Forgets the real time paid toward the next step (a new zone starts between steps). A step cannot do this to the + // clock that is running it. + public void Reset() + { + if (_advancing) throw new InvalidOperationException("A step cannot reset the clock that is running it."); + _accumulator = 0; + } + + // Feeds real seconds (negative counts as none, one frame counts at most MaxFrameSeconds) and runs the whole + // steps they pay for. Returns how many ran. + public int Advance(float realSeconds, Action step) + { + _accumulator += Math.Min(Math.Max(realSeconds, 0f), MaxFrameSeconds); + var ran = 0; + _advancing = true; + try + { + while (_accumulator >= _period) + { + _accumulator -= _period; + step(Step); + Steps++; + ran++; + } + } + finally { _advancing = false; } + return ran; + } +} diff --git a/Assets/Scripts/ServerShared/SimClock.cs.meta b/Assets/Scripts/ServerShared/SimClock.cs.meta new file mode 100644 index 000000000..30e06c489 --- /dev/null +++ b/Assets/Scripts/ServerShared/SimClock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a9e3edac65f4a8e8313bdbfe69ccebd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs new file mode 100644 index 000000000..db79647bb --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -0,0 +1,356 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using CultMath; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// SimClock owns sim time's relation to real time: frames of real seconds in, whole fixed steps out. Nothing here hands +// a clock to a presenter; DrawAhead is pure reads of the latest state. +public sealed class SimClockTests +{ + private static readonly float[] Irregular = { 1f / 144, 1f / 30, 1f / 90 }; + + // Runs frames cycling through the lengths until the clock has run the steps; returns the step values it passed. + private static List Drive(SimClock clock, float[] frames, long steps) + { + var passed = new List(); + for (var frame = 0; clock.Steps < steps; frame++) + clock.Advance(frames[frame % frames.Length], dt => passed.Add(dt)); + return passed; + } + + [Fact] + public void TheStepNeverChanges() + { + foreach (var frames in new[] { new[] { 1f / 60 }, Irregular, new[] { .09f, .004f, 0f, 3f } }) + { + var clock = new SimClock(1f / 60f); + var passed = Drive(clock, frames, 500); + Assert.True(passed.Count >= 500); + Assert.All(passed, dt => Assert.Equal(clock.Step, dt)); + } + var slow = new SimClock(.1f, 6); + Assert.All(Drive(slow, Irregular, 50), dt => Assert.Equal(.1f, dt)); + } + + [Fact] + public void StepsFollowRealTime() + { + // Ten real seconds of irregular frames. + var clock = new SimClock(1f / 60f); + var elapsed = 0.0; + for (var frame = 0; elapsed < 10.0; frame++) + { + var seconds = Irregular[frame % Irregular.Length]; + elapsed += seconds; + clock.Advance(seconds, _ => { }); + } + Assert.InRange(elapsed, 10.0, 10.1); + Assert.InRange(clock.Steps, Math.Round(elapsed * 60) - 1, Math.Round(elapsed * 60) + 1); + + // A frame that pays for exactly one step runs it (binary-exact seconds, so nothing rounds). + var exact = new SimClock(.25f, 16); + Assert.Equal(1, exact.Advance(.0625f, _ => { })); + Assert.Equal(0f, exact.Lead); + + var slow = new SimClock(1f / 60f, 30); + for (var frame = 0; frame < 600; frame++) slow.Advance(1f / 60, _ => { }); + Assert.InRange(slow.Steps, 299, 301); + } + + [Fact] + public void AHitchIsClamped() + { + var clock = new SimClock(1f / 60f); + var ran = clock.Advance(5f, _ => { }); + Assert.Equal(6, ran); + Assert.Equal(6, clock.Steps); + } + + [Fact] + public void AStepCannotResetItsClock() + { + var clock = new SimClock(1f / 60f); + Assert.Throws(() => clock.Advance(1f / 60f, _ => clock.Reset())); + clock.Advance(.005f, _ => { }); + Assert.True(clock.Lead > 0); + clock.Reset(); + Assert.Equal(0f, clock.Lead); + Assert.Equal(1, clock.Advance(1f / 60f, _ => { })); + } + + [Fact] + public void PauseFeedsNothing() + { + var clock = new SimClock(1f / 60f); + clock.Advance(.025f, _ => { }); + var lead = clock.Lead; + var steps = clock.Steps; + Assert.True(lead > 0); + foreach (var seconds in new[] { 0f, -1f }) + { + Assert.Equal(0, clock.Advance(seconds, _ => throw new InvalidOperationException())); + Assert.Equal(lead, clock.Lead); + Assert.Equal(steps, clock.Steps); + } + } + + [Fact] + public void LeadIsHonest() + { + var clock = new SimClock(.1f, 6); + double sinceLastStep = 0; + for (var frame = 0; frame < 400; frame++) + { + // The real time since the last step: what was left over plus this frame, less the steps it paid for. + sinceLastStep += Irregular[frame % Irregular.Length]; + var seconds = Irregular[frame % Irregular.Length]; + clock.Advance(seconds, _ => sinceLastStep -= 1.0 / clock.StepsPerRealSecond); + Assert.InRange(clock.Lead, 0f, clock.Step * .99999f); + Assert.Equal(sinceLastStep * clock.StepsPerRealSecond * clock.Step, clock.Lead, 4); + } + } + + [Fact] + public void ABadClockIsRefusedByFieldName() + { + Assert.Equal("step", Assert.Throws(() => new SimClock(0f)).ParamName); + Assert.Equal("stepsPerRealSecond", Assert.Throws(() => new SimClock(1f, 0f)).ParamName); + } +} + +public sealed partial class RunStartTests +{ + private const float ClockFrame = 1f / 60f; + private const int HistorySteps = 1800; + + // The Duel launched as the game launches it, its item random reseeded so play draws the same numbers every time. + private (Zone arena, Ship player) LaunchDuel() + { + var launched = Launch(new Duel()); + Assert.Empty(launched.failures); + _items.Random = new CultMath.Random(0xC0FFEE); + launched.staged.Player.MovementDirection = float2(0, 1); + launched.staged.Player.Turn = .5f; + foreach (var ship in launched.arena.Entities.OfType()) + ship.SetTarget(launched.arena.Entities.OfType().First(other => other != ship)); + return (launched.arena, launched.staged.Player); + } + + private static string R(float value) => value.ToString("R", CultureInfo.InvariantCulture); + + // Everything the step decides, as text: the zone clock, each entity's pose and condition, and each shot resolved. + private static string Snapshot(Zone zone, List shots) + { + var text = new StringBuilder(R(zone.Time)); + foreach (var entity in zone.Entities) + { + text.Append($"|{entity.Name} {R(entity.Position.x)},{R(entity.Position.z)} v{R(entity.Velocity.x)},{R(entity.Velocity.y)} " + + $"d{R(entity.Direction.x)},{R(entity.Direction.y)} w{R(entity.TurnRate)} h{R(entity.Hull.Durability)}"); + if (entity.Shield != null) text.Append($" s{R(entity.Shield.Progress)}"); + if (entity.Temperature != null) + { + var heat = 0f; + foreach (var cell in entity.Temperature) heat += cell; + text.Append($" t{R(heat)}"); + } + } + text.Append($"|shots {string.Join(",", shots)}"); + shots.Clear(); + return text.ToString(); + } + + // Feeds the zone through one SimClock with the frames cycled, snapshotting every step until HistorySteps have run. + private static List History(Zone zone, float[] frames) + { + var clock = new SimClock(1f / 60f); + var shots = new List(); + using var _ = zone.ShotResolved.Subscribe(outcome => shots.Add($"{outcome.ShotId}:{outcome.Result}")); + var history = new List(); + for (var frame = 0; history.Count < HistorySteps; frame++) + clock.Advance(frames[frame % frames.Length], dt => + { + // Both ships open fire every 90th step, on the step count, so the fight is the same fight whatever + // the frames were. + if (history.Count % 90 == 0) + foreach (var ship in zone.Entities.OfType()) + foreach (var weapon in ship.GetBehaviors()) + FireControl.Fire(weapon, weapon.Item, ship); + zone.Update(dt); + history.Add(Snapshot(zone, shots)); + }); + return history.Take(HistorySteps).ToList(); + } + + // Control: the same launch driven twice the same way is the same history. If this fails the simulation is + // nondeterministic before the clock is involved, and FrameTimesDoNotChangeHistory proves nothing. + [Fact] + public void OneHistoryTwice() + { + var first = History(LaunchDuel().arena, new[] { ClockFrame }); + var second = History(LaunchDuel().arena, new[] { ClockFrame }); + Assert.Equal(first, second); + Assert.Contains(first, line => !line.EndsWith("|shots ")); // shots were fired and resolved + } + + [Fact] + public void FrameTimesDoNotChangeHistory() + { + var steady = History(LaunchDuel().arena, new[] { ClockFrame }); + var irregular = History(LaunchDuel().arena, new[] { 1f / 144, 1f / 30, 1f / 90 }); + Assert.Equal(steady.Count, irregular.Count); + for (var step = 0; step < steady.Count; step++) + Assert.True(steady[step] == irregular[step], $"step {step}: {steady[step]} != {irregular[step]}"); + } + + private static float SignedTurn(float2 before, float2 after) => + atan2(before.x * after.y - before.y * after.x, dot(before, after)); + + [Fact] + public void TurnRateIsTheActuatorsTurn() + { + var (arena, player) = LaunchDuel(); + Assert.Equal(0f, player.TurnRate); // staged: nothing has turned it + var turned = 0; + for (var step = 0; step < 240; step++) + { + var before = player.Direction; + arena.Update(ClockFrame); + var angle = SignedTurn(before, player.Direction); + Assert.InRange(abs(abs(angle) - abs(player.TurnRate * ClockFrame)), 0f, 1e-6f); + // A positive turn demand turns clockwise, and TurnRate is signed the way the step turned Direction. + if (abs(player.TurnRate) > 1e-4f) + { + turned++; + Assert.True(player.TurnRate > 0 == dot(player.Direction, before.Rotate(ItemRotation.Clockwise)) > 0); + } + } + Assert.True(turned > 60, "the held turn turned the ship"); + + // A write to Direction between steps (staging, a load, a wormhole exit) is not a turn. + player.Turn = 0; + for (var step = 0; step < 600 && abs(player.TurnRate) > 0; step++) arena.Update(ClockFrame); + player.Direction = normalize(float2(3, -4)); + arena.Update(ClockFrame); + Assert.Equal(0f, player.TurnRate); + } + + [Fact] + public void DrawAheadIsTheStateAtZeroLead() + { + var (arena, player) = LaunchDuel(); + for (var step = 0; step < 120; step++) arena.Update(ClockFrame); + var before = Snapshot(arena, new List()); + + var zero = DrawAhead.Position(player, 0); + Assert.Equal(player.Position.x, zero.x); + Assert.Equal(player.Position.y, zero.y); + Assert.Equal(player.Position.z, zero.z); + var rotation = DrawAhead.Rotation(player, 0); + Assert.Equal(1f, abs(rotation.x * player.Rotation.x + rotation.y * player.Rotation.y + rotation.z * player.Rotation.z + rotation.w * player.Rotation.w), 5); + + const float lead = .007f; + var ahead = DrawAhead.Position(player, lead); + Assert.Equal(player.Position.x + player.Velocity.x * lead, ahead.x, 6); + Assert.Equal(player.Position.y, ahead.y); + Assert.Equal(player.Position.z + player.Velocity.y * lead, ahead.z, 6); + Assert.Equal(before, Snapshot(arena, new List())); + } + + // A pose the step scripts (the wormhole animation) is drawn where the step put it, at any lead. + [Fact] + public void ScriptedPosesAreNotDrawnAhead() + { + var (arena, player) = LaunchDuel(); + for (var step = 0; step < 120; step++) arena.Update(ClockFrame); + player.EnterWormhole(player.Position.xz + float2(50, 0)); + arena.Update(ClockFrame); + Assert.True(player.WormholeAnimationInProgress); + Assert.True(length(player.Velocity) > .01f, "the ship carries speed into the animation"); + var position = DrawAhead.Position(player, ClockFrame); + Assert.Equal(player.Position.x, position.x); + Assert.Equal(player.Position.y, position.y); + Assert.Equal(player.Position.z, position.z); + var rotation = DrawAhead.Rotation(player, ClockFrame); + Assert.Equal(player.Rotation.x, rotation.x); + Assert.Equal(player.Rotation.y, rotation.y); + Assert.Equal(player.Rotation.z, rotation.z); + Assert.Equal(player.Rotation.w, rotation.w); + } + + // The turn a presenter draws ahead is the turn the next step makes: a ship held in a steady turn, drawn one whole + // step ahead of the pose it was last given, is where the sim puts that pose next. + [Fact] + public void DrawnRotationFollowsTheTurn() + { + var (arena, player) = LaunchDuel(); + player.Turn = 1f; + for (var step = 0; step < 300; step++) arena.Update(ClockFrame); + var rate = player.TurnRate; + Assert.True(abs(rate) > .05f, "the ship is turning"); + var drawn = DrawAhead.Rotation(player, ClockFrame); + arena.Update(ClockFrame); + Assert.True(abs(player.TurnRate - rate) < 1e-3f * abs(rate), "the turn is steady"); + var next = player.Rotation; + var error = 2 * acos(min(1f, abs(drawn.x * next.x + drawn.y * next.y + drawn.z * next.z + drawn.w * next.w))); + var turn = abs(rate * ClockFrame); + Assert.True(turn > .005f, "the step turns the ship enough to tell its sense"); + Assert.True(error < .2f * turn, $"drawn one step ahead is where the sim puts the pose next (off by {error} of a {turn} turn)"); + } + + // Thrusters turn the same way: a Longinus held in a turn, settled, turns each step by exactly its TurnRate. + [Fact] + public void ThrustersReportTheirTurn() + { + var (_, longinus, _) = EvFleet(); + EvSettle(longinus); + longinus.Turn = 1f; + var turned = 0; + for (var step = 0; step < 120; step++) + { + var before = longinus.Direction; + longinus.Update(ClockFrame); + Assert.InRange(abs(abs(SignedTurn(before, longinus.Direction)) - abs(longinus.TurnRate * ClockFrame)), 0f, 1e-6f); + if (abs(longinus.TurnRate) > 1e-4f) + { + turned++; + Assert.True(longinus.TurnRate > 0 == dot(longinus.Direction, before.Rotate(ItemRotation.Clockwise)) > 0); + } + } + Assert.True(turned > 30, "the thrusters turned the ship"); + } + + // The draw-ahead turn on a pose that is not flat: the up axis stays the pose's up, and the forward axis swings about + // it by the angle (Rodrigues' rotation, written out apart from DrawAhead's quaternion product). + [Fact] + public void TheDrawnTurnIsAboutThePosesOwnUp() + { + var up = normalize(float3(.3f, .8f, -.2f)); + var pose = quaternion.LookRotation(normalize(cross(float3(1, 0, .4f), up)), up); + const float angle = .6f; + var turned = DrawAhead.Turned(pose, angle); + + float3 Axis(quaternion q, float3 v) + { + var u = float3(q.x, q.y, q.z); + return v + 2 * (q.w * cross(u, v) + cross(u, cross(u, v))); + } + float3 Close(float3 a, float3 b) => float3(abs(a.x - b.x), abs(a.y - b.y), abs(a.z - b.z)); + var forward = Axis(pose, float3(0, 0, 1)); + var rodrigues = forward * cos(angle) + cross(Axis(pose, float3(0, 1, 0)), forward) * sin(angle); + var upBefore = Axis(pose, float3(0, 1, 0)); + var tolerance = float3(1e-5f, 1e-5f, 1e-5f); + Assert.True(all(Close(Axis(turned, float3(0, 1, 0)), upBefore) < tolerance), "the pose's up does not move"); + Assert.True(all(Close(Axis(turned, float3(0, 0, 1)), rodrigues) < tolerance), "the forward axis swings about it by the angle"); + } +}