Merge eureka/aetheria-release-sim-speed: one SimClock steps the zone, presenters draw ahead by Lead
AetherDrive.cs: master deleted the behaviour (2cfdc728); the branch only added a
TurnRate line to it. Deletion kept. Thruster remains the one rotation actuator
and feeds Entity.TurnRate. No sim stepping outside the clock on master
(mines presenter, Pirates AI, lot-product all step through Zone.Update).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f28d4b4b12
10 changed files with 508 additions and 38 deletions
|
|
@ -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<VelocityLimit>().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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
40
Assets/Scripts/ServerShared/DrawAhead.cs
Normal file
40
Assets/Scripts/ServerShared/DrawAhead.cs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/DrawAhead.cs.meta
Normal file
11
Assets/Scripts/ServerShared/DrawAhead.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 007fe4255cd84c26a010151a399f45c2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
68
Assets/Scripts/ServerShared/SimClock.cs
Normal file
68
Assets/Scripts/ServerShared/SimClock.cs
Normal file
|
|
@ -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<float> 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;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/SimClock.cs.meta
Normal file
11
Assets/Scripts/ServerShared/SimClock.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7a9e3edac65f4a8e8313bdbfe69ccebd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
356
tests/Aetheria.Shared.Tests/SimClockTests.cs
Normal file
356
tests/Aetheria.Shared.Tests/SimClockTests.cs
Normal file
|
|
@ -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<float> Drive(SimClock clock, float[] frames, long steps)
|
||||
{
|
||||
var passed = new List<float>();
|
||||
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<InvalidOperationException>(() => 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<ArgumentOutOfRangeException>(() => new SimClock(0f)).ParamName);
|
||||
Assert.Equal("stepsPerRealSecond", Assert.Throws<ArgumentOutOfRangeException>(() => 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>())
|
||||
ship.SetTarget(launched.arena.Entities.OfType<Ship>().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<string> 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<string> History(Zone zone, float[] frames)
|
||||
{
|
||||
var clock = new SimClock(1f / 60f);
|
||||
var shots = new List<string>();
|
||||
using var _ = zone.ShotResolved.Subscribe(outcome => shots.Add($"{outcome.ShotId}:{outcome.Result}"));
|
||||
var history = new List<string>();
|
||||
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<Ship>())
|
||||
foreach (var weapon in ship.GetBehaviors<Weapon>())
|
||||
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<string>());
|
||||
|
||||
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<string>()));
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue