diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index f920522ae..0476acde4 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,7 @@ public class ActionGameManager : MonoBehaviour public ItemManager ItemManager { get; private set; } public Zone Zone { get; private set; } + public static SimClock Clock { get; private set; } private readonly (float2 direction, string name)[] _directions = { (float2(0, 1), "Front"), @@ -728,6 +727,7 @@ public class ActionGameManager : MonoBehaviour galaxyZone.Contents = new Zone(ItemManager, Settings.PlanetSettings, galaxyZone.PackedContents, galaxyZone, CurrentGalaxy); } Zone = galaxyZone.Contents; + Clock = new SimClock(1f / 60f); PlayMusic(MusicType.Overworld); Zone.Log = s => Debug.Log($"Zone: {s}"); @@ -818,7 +818,6 @@ public class ActionGameManager : MonoBehaviour } else { - //StartCoroutine(IntroCutscene(targetEntity as Ship)); BindToEntity(targetEntity); } @@ -866,35 +865,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 +1326,9 @@ 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. + Clock.Advance(_paused ? 0f : Time.deltaTime, Zone.Update); } 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 cc110fc8f..18902a422 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -138,6 +138,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/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index e8498ca7f..fcb6de134 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -151,8 +151,9 @@ public class AetherDrive : Behavior, IPowerConsumer ThrustDirection = forward * (_axis.x * force.x / Entity.Mass) + right * (_axis.y * force.y / Entity.Mass); Entity.Velocity += ThrustDirection; - Entity.Direction = mul(Entity.Direction, - CultMath.float2x2.Rotate(force.z * _axis.z * ItemManager.GameplaySettings.AetherTorqueMultiplier / Entity.Mass)); + var turn = force.z * _axis.z * ItemManager.GameplaySettings.AetherTorqueMultiplier / Entity.Mass; + Entity.Direction = mul(Entity.Direction, CultMath.float2x2.Rotate(turn)); + if (dt > 0) Entity.TurnRate += turn / dt; if(float.IsNaN(Entity.Velocity.x)) ItemManager.Log("FUCK FUCK FUCK FUCK"); 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..da4e7d94a --- /dev/null +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -0,0 +1,36 @@ +/* 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 +{ + public static float3 Position(Entity entity, float lead) => + 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) + { + var q = ship.Rotation; + 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 = -ship.TurnRate * lead * .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)); + } + + // Zone time for a presenter that evaluates a sim function of time (FireControl.RoundAt). + public static float Time(Zone zone, float lead) => zone.Time + lead; +} 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 b83fc141e..a1eb14c49 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 DrawAhead. + 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..15e132ac0 --- /dev/null +++ b/Assets/Scripts/ServerShared/SimClock.cs @@ -0,0 +1,54 @@ +/* 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; + + 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); + + // 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; + while (_accumulator >= _period) + { + _accumulator -= _period; + step(Step); + Steps++; + ran++; + } + 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..0fdc55d81 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -0,0 +1,262 @@ +/* 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); + + 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 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; + 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 => + { + 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 ")); + } + + [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.Equal(abs(angle), abs(player.TurnRate * ClockFrame), 5); + // 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(arena.Time + lead, DrawAhead.Time(arena, lead)); + Assert.Equal(before, Snapshot(arena, new List())); + } + + // 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; + Assert.Equal(1f, abs(drawn.x * next.x + drawn.y * next.y + drawn.z * next.z + drawn.w * next.w), 4); + } +}