Helm split, simulation: Ship.Turn is the only facing command; LookDirection becomes Aim

Ship.Update reads Turn and feeds the rotation thrusters and aether drives; it no longer reads the
look direction or snaps Direction toward it. Steering.Toward is the one law from heading to demand
(a heading astern now turns at full demand). HelmInput owns the face-the-aim state. Accelerate,
Combat and MoveTo write Turn through Steering.Toward. Entity.LookDirection is renamed Aim in the
simulation, the Unity readers and the tests. Unity player input follows in the next commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-07 12:33:20 +02:00
commit c86423936e
29 changed files with 331 additions and 80 deletions

View file

@ -398,7 +398,7 @@ public class EntityInstance : MonoBehaviour
x.Value.material.SetFloat("_Emission", Entity.ItemManager.GameplaySettings.TemperatureEmissionCurve.Evaluate(x.Key.RadiatorTemperature));
}
LookAtPoint.position = transform.position + Entity.LookDirection.ToUnity() *
LookAtPoint.position = transform.position + Entity.Aim.ToUnity() *
(!Entity.Target.Value.IsNone ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000);
LocalSpace.localPosition = transform.position = Entity.Position.ToUnity();
if (_influenceInstance)

View file

@ -113,7 +113,7 @@ public class ShipInstance : EntityInstance
base.Update();
TractorBeam.Power = Entity.TractorPower;
TractorBeam.Direction = Entity.LookDirection.ToUnity();
TractorBeam.Direction = Entity.Aim.ToUnity();
if (_aetherDrive != null)
{

View file

@ -76,7 +76,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
p.Velocity = barrel.forward * weapon.Velocity;
p.Thrust = item.Evaluate(guidance.Thrust);
p.TopSpeed = item.Evaluate(guidance.MissileVelocity);
p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.LookDirection).ToUnity();
p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.Aim).ToUnity();
Bind(p, source.Entity.Zone, shotId);
}
else Debug.LogError($"Weapon {item.Data.Name} linked to {name} effect, but is not a Launcher!");

View file

@ -63,10 +63,10 @@ public class Agent
// If Delta V is above the threshold, direct the ship towards the delta and use only main thrusters
if (!noTurn && deltaVMag > FORWARD_DELTA_THRESHOLD)
{
Ship.LookDirection = float3(deltaVDirection.x, 0, deltaVDirection.y);
Ship.Turn = Steering.Toward(Ship, deltaVDirection);
Ship.MovementDirection = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2));
}
// If Delta V is low, direct the ship towards the target and use all thrusters
// If Delta V is low, use all thrusters and leave Turn to the caller (MoveTo already pointed it at the target)
else if(deltaVMag > THRUST_DELTA_THRESHOLD)
{
var right = Ship.Direction.Rotate(ItemRotation.Clockwise);

View file

@ -124,7 +124,8 @@ public class CombatState : BaseState
weapon.Deactivate();
}
}
_agent.Ship.LookDirection = toTarget;
_agent.Ship.Aim = toTarget;
_agent.Ship.Turn = Steering.Toward(_agent.Ship, toTarget.xz);
_agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0);
// Fire charged guns!

View file

@ -23,7 +23,8 @@ public abstract class MoveToState : BaseState
// We want to go top speed in the direction of our target
var desiredVelocity = dir * _agent.TopSpeed;
_agent.Ship.LookDirection = float3(dir.x, 0, dir.y);
_agent.Ship.Aim = float3(dir.x, 0, dir.y);
_agent.Ship.Turn = Steering.Toward(_agent.Ship, dir);
_agent.Accelerate(desiredVelocity);
}
}

View file

@ -97,7 +97,7 @@ public class LockWeapon : InstantWeapon
DirectionImpact = Evaluate(_data.DirectionImpact);
Decay = Evaluate(_data.Decay);
var degrees = acos(dot(normalize(target.Position - Entity.Position), normalize(Entity.LookDirection))) * 57.2958f;
var degrees = acos(dot(normalize(target.Position - Entity.Position), normalize(Entity.Aim))) * 57.2958f;
if (degrees < LockAngle)
{
var lerp = 1 - unlerp(0, 90, degrees);

View file

@ -71,10 +71,10 @@ public class TurretController : Behavior, IInitializableBehavior
target.Position, targetVelocity
);
predictedPosition.y = Entity.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset;
Entity.LookDirection = normalize(predictedPosition - Entity.Position);
Entity.Aim = normalize(predictedPosition - Entity.Position);
}
else
Entity.LookDirection = normalize(diff);
Entity.Aim = normalize(diff);
foreach (var x in _weapons)
{

View file

@ -55,7 +55,9 @@ public abstract class Entity
// notice and clear it.
public ReactiveProperty<EquippedItem> TargetItem = new ReactiveProperty<EquippedItem>((EquippedItem)null);
public float3 LookDirection;
// Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the
// hull turns only by Ship.Turn.
public float3 Aim;
public string Name;
@ -406,7 +408,7 @@ public abstract class Entity
// The candidate nearest the look direction by planar angle; picking the held target again clears it.
public bool TargetUnderReticle()
{
var look = float2(LookDirection.x, LookDirection.z);
var look = float2(Aim.x, Aim.z);
if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false;
return SetTarget(Target.Value.Equals(picked) ? TargetRef.None : picked);
}

View file

@ -107,16 +107,16 @@ public static class FireControl
}
// Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming
// (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The
// (Entity.Aim -- the player's mouse or a turret's tracking), restricted by the mount's arc. The
// arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of
// the same cone. There is no second bearing test here; the edge is where InArc stops passing. A shooter
// with no aim set (LookDirection zero) aims down the mount, the same fallback InArc takes for a zero bearing.
// with no aim set (Aim zero) aims down the mount, the same fallback InArc takes for a zero bearing.
// The simulation had no aim clamp before this: the Unity barrel's ArticulationPoint clamps the picture,
// and a target outside the arc merely priced the shot at zero.
public static float2 AimDirection(EquippedItem item, Entity shooter)
{
var mount = MountDirection(item).xz;
var look = shooter.LookDirection.xz;
var look = shooter.Aim.xz;
if (lengthsq(look) < 1e-6f) return mount;
look = normalize(look);
if (InArc(item, float3(look.x, 0, look.y))) return look;

View file

@ -0,0 +1,18 @@
using CultMath;
using static CultMath.math;
// The player's face-the-aim state, engine-free. Held follows the Face Aim hold input each frame; Latched is
// flipped by the Caps Lock press. Either one faces the aim; neither leaves the hull to the turn axis.
// Runtime only, never saved: Latched starts false each session.
public class HelmInput
{
public bool Held;
public bool Latched;
public bool FaceAim => Held || Latched;
public void Toggle() => Latched = !Latched;
public float Demand(Ship ship, float2 aim, float turnAxis) =>
FaceAim ? Steering.Toward(ship, aim) : clamp(turnAxis, -1f, 1f);
}

View file

@ -25,6 +25,9 @@ public class Ship : Entity
// [IgnoreMember] public Targetable Target;
public Entity HomeEntity;
public float2 MovementDirection;
// The one facing command, -1..1, positive clockwise. Written each tick by the pilot (player input or the
// ship's agent); a heading becomes a demand only through Steering.Toward. Runtime only, never packed.
public float Turn;
public bool IsPlayerShip;
private HashSet<EquippedItem> _thrusterItems;
@ -275,20 +278,12 @@ public class Ship : Entity
foreach (var thruster in _forwardThrusters) thruster.Axis += MovementDirection.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -MovementDirection.y;
var look = normalize(LookDirection.xz);
var deltaRot = dot(look, normalize(Direction).Rotate(ItemRotation.Clockwise));
if (abs(deltaRot) < .01f)
{
deltaRot = 0;
Direction = lerp(Direction, look, min(delta, 1));
}
deltaRot = pow(abs(deltaRot), .5f) * sign(deltaRot);
foreach (var thruster in _clockwiseThrusters) thruster.Axis += deltaRot;
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -deltaRot;
var turn = clamp(Turn, -1f, 1f);
foreach (var thruster in _clockwiseThrusters) thruster.Axis += turn;
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -turn;
foreach (var drive in _aetherDrives)
drive.Axis = float3(MovementDirection.y, MovementDirection.x, deltaRot);
drive.Axis = float3(MovementDirection.y, MovementDirection.x, turn);
}
var velocityMagnitude = length(Velocity);

View file

@ -0,0 +1,24 @@
using System;
using CultMath;
using static CultMath.math;
// The one law that turns a wanted heading into a facing command. Ship.Update reads only Ship.Turn; whoever
// wants the hull to face somewhere (the player's face-the-aim state, an agent state) asks here and writes Turn.
public static class Steering
{
const float Deadband = .01f;
// Positive is clockwise. Lateral component of the heading with sqrt shaping and a deadband; a heading
// behind the hull turns at full demand (its lateral component alone would vanish at exactly astern).
// A zero or non-finite heading holds course.
public static float Toward(Ship ship, float2 heading)
{
if (!float.IsFinite(heading.x) || !float.IsFinite(heading.y) || lengthsq(heading) < 1e-12f) return 0f;
var dir = normalize(ship.Direction);
var wanted = normalize(heading);
var d = dot(wanted, dir.Rotate(ItemRotation.Clockwise));
if (dot(wanted, dir) < 0f) return d < 0f ? -1f : 1f;
if (abs(d) < Deadband) return 0f;
return sqrt(abs(d)) * sign(d);
}
}

View file

@ -437,7 +437,7 @@ public class ZoneRenderer : MonoBehaviour
foreach (var loot in _loot)
{
loot.ViewOrigin = PerspectiveEntity.Position;
loot.ViewDirection = PerspectiveEntity.LookDirection;
loot.ViewDirection = PerspectiveEntity.Aim;
}
// if (SlimeRenderer.SpawnPositions.Length != _suns.Length)

View file

@ -121,11 +121,9 @@ public sealed class BrownoutTests : IDisposable
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Consumer"))));
zone.Entities.Add(ship);
// A ship already "looking" the way Direction already points keeps Ship.Update's own steering pass a
// no-op (deltaRot == 0): no NaN from an unset LookDirection propagating into AetherDrive's Axis.z, and
// no incidental torque thrust from the ship trying to turn toward a look direction none of these tests
// care about.
ship.LookDirection = float3(0, 0, 1);
// Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the
// ship trying to turn. Aim steers nothing, so this one is only a plausible aim.
ship.Aim = float3(0, 0, 1);
ship.Activate();
return ship;
}
@ -184,7 +182,7 @@ public sealed class BrownoutTests : IDisposable
// --- AetherDrive: PassiveCoupling = 1 engages the rotor regardless of axis input, so the drive can be
// --- exercised without threading Ship's own movement/torque classification -- Axis stays at Ship.Update's
// --- own default (0,0,deltaRot), and deltaRot is 0 by BuildShip's own LookDirection setup. Rpm starts at 0,
// --- own default (0,0,Turn), and Turn is 0 by default. Rpm starts at 0,
// --- so decay(0, ...) contributes nothing and the whole tick's Rpm gain is exactly this behaviour's own
// --- spin-up under test. ---
private (float rpmGain, AetherDrive drive) RunAetherDrive(float reactorCharge, float exponent = 1)

View file

@ -95,7 +95,7 @@ public sealed class ConditionRatioTests : IDisposable
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Consumer"))));
zone.Entities.Add(ship);
ship.LookDirection = float3(0, 0, 1); // Ship.Update's own steering pass stays a no-op, as BrownoutTests
ship.Aim = float3(0, 0, 1); // Turn stays 0, as in BrownoutTests
ship.Activate();
consumer = ship.Equipment.Single(e => e.Data.Name == "Consumer");

View file

@ -1155,7 +1155,7 @@ public sealed class FireControlCut124Tests : IDisposable
}
// Operator ruling 2026-09-30: "fused weapons without a lock explode at max range." A fused shot with no
// target bursts at the weapon's Range along its aim (Entity.LookDirection), whatever its fuse. The fixture
// target bursts at the weapon's Range along its aim (Entity.Aim), whatever its fuse. The fixture
// aims the shooter along (2,1), a non-axis facing; witnesses sit at the burst point and beside it. Radius 4
// with the witness hull 5x4 cells of 2 world units: a witness centred on the burst point is covered, and
// one 30 units off the aim line is not.
@ -1234,7 +1234,7 @@ public sealed class FireControlCut124Tests : IDisposable
// ==== Operator rulings 2026-09-30 (Soul's pass on the no-lock fix): aim, invalid target, contact, shooter ====
private static void Aim(Engagement e, float2 direction) => e.Shooter.LookDirection = float3(direction.x, 0, direction.y);
private static void Aim(Engagement e, float2 direction) => e.Shooter.Aim = float3(direction.x, 0, direction.y);
private static float3 PointAlong(Engagement e, float2 direction, float distance) =>
e.Shooter.Position + float3(direction.x, 0, direction.y) * distance;
@ -1266,7 +1266,7 @@ public sealed class FireControlCut124Tests : IDisposable
}
// Ruling 1: an aim inside the arc is used as it is (unnormalised here), and a shooter aiming at nothing
// (LookDirection zero) fires down its mount. Kills: reading MountDirection instead of the aim; a missing
// (Aim zero) fires down its mount. Kills: reading MountDirection instead of the aim; a missing
// zero-aim fallback, which would normalise to NaN and burst nowhere.
[Fact]
public void ANoLockShotFliesAlongTheAimAndDownTheMountWhenNothingIsAimed()
@ -2113,9 +2113,9 @@ public sealed class FireControlCut124Tests : IDisposable
Near(float2(0, 1), Clamped(e, float2(0, 0)));
Near(float2(0, 1), Clamped(e, float2(5e-4f, 0)));
Near(Dir(85), Clamped(e, float2(2e-3f, 0)));
e.Shooter.LookDirection = float3(0, 1, 0);
e.Shooter.Aim = float3(0, 1, 0);
Near(float2(0, 1), FireControl.AimDirection(e.WeaponItem, e.Shooter));
e.Shooter.LookDirection = float3(float.NaN, 0, float.NaN);
e.Shooter.Aim = float3(float.NaN, 0, float.NaN);
var nan = FireControl.AimDirection(e.WeaponItem, e.Shooter);
Assert.False(float.IsNaN(nan.x) || float.IsNaN(nan.y), "a NaN aim must not produce a NaN flight direction");
}

View file

@ -224,7 +224,7 @@ public sealed class FireControlCut8Tests : IDisposable
locker.Position = float3.zero;
bystander.Position = float3(0, 0, 100);
locker.LookDirection = float3(0, 0, 1);
locker.Aim = float3(0, 0, 1);
locker.SetTarget(bystander);
locker.EntityInfoGathered[bystander] = 1f;
locker.SetIff(bystander, true);

View file

@ -560,7 +560,7 @@ public sealed class FireControlCut9Tests : IDisposable
locker.Position = float3.zero;
bystander.Position = float3(0, 0, 100);
locker.LookDirection = float3(0, 0, 1);
locker.Aim = float3(0, 0, 1);
locker.SetTarget(bystander);
locker.EntityInfoGathered[bystander] = 1f;
locker.SetIff(bystander, true);

View file

@ -257,7 +257,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
return rig;
}
private static void Aim(Rig r, float2 direction) => r.Shooter.LookDirection = float3(direction.x, 0, direction.y);
private static void Aim(Rig r, float2 direction) => r.Shooter.Aim = float3(direction.x, 0, direction.y);
private static readonly (ItemRotation, float)[] SideThenForward = { (ItemRotation.Clockwise, 0f), (ItemRotation.None, 0f) };
private static readonly (ItemRotation, float)[] SideMount = { (ItemRotation.Clockwise, 0f) };

View file

@ -175,13 +175,8 @@ public sealed class FireControlTests : IDisposable
Assert.True(FireControl.InArc(defaultArc, AtAngle(5)));
}
// The cut's real deliverable: Entity.HardpointTransforms is gone, so a Minion can step through a full
// Zone.Update tick and reach the fire decision without Unity ever having run. Before this cut,
// Combat.cs:100-102 threw KeyNotFoundException on the first combat tick headless, because the AI's aim
// direction was a readback from a renderer that never ran. Mutation: restore the HardpointTransforms
// read in Behavior.Direction / Combat.cs -- this throws again outside Unity.
[Fact]
public void CombatStateStepsHeadless()
// A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter.
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition)
{
var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f));
var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData);
@ -203,7 +198,7 @@ public sealed class FireControlTests : IDisposable
target.Activate();
shooter.Position = float3.zero;
target.Position = float3(0, 0, 10); // dead ahead of the None-rotation hardpoint, well within range and arc
target.Position = targetPosition;
shooter.SetTarget(target);
// Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target
@ -216,6 +211,19 @@ public sealed class FireControlTests : IDisposable
// unaided shot still counts as "worth it."
items.GameplaySettings.AgentMinHitProbability = 0f;
zone.Agents.Add(new Minion(shooter));
return (shooter, target, zone);
}
// The cut's real deliverable: Entity.HardpointTransforms is gone, so a Minion can step through a full
// Zone.Update tick and reach the fire decision without Unity ever having run. Before this cut,
// Combat.cs:100-102 threw KeyNotFoundException on the first combat tick headless, because the AI's aim
// direction was a readback from a renderer that never ran. Mutation: restore the HardpointTransforms
// read in Behavior.Direction / Combat.cs -- this throws again outside Unity.
[Fact]
public void CombatStateStepsHeadless()
{
// dead ahead of the None-rotation hardpoint, well within range and arc
var (shooter, _, zone) = BuildCombatEncounter(float3(0, 0, 10));
var ex = Record.Exception(() =>
{
@ -227,4 +235,23 @@ public sealed class FireControlTests : IDisposable
var weapon = shooter.Weapons.Single();
Assert.True(weapon.Firing); // arc + range satisfied: the fight actually decided to fire, not just avoided a crash
}
// Controls cut (helm split): Combat aims the shooter at the intercept (Aim) and commands the hull toward it
// through the one steering law (Turn); it never turns the hull by writing Aim.
[Fact]
public void CombatAimsAtTheInterceptAndTurnsTowardIt()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(10, 0, 10)); // 45 degrees to starboard, in arc
var direction = shooter.Direction;
zone.Update(1f);
zone.Update(1f);
var bearing = normalize(target.Position.xz - shooter.Position.xz);
var aim = normalize(shooter.Aim.xz);
Assert.True(dot(aim, bearing) > .999f, "Aim points at the stationary target's intercept");
Assert.Equal(direction, shooter.Direction); // no thrusters on this hull: only Turn was commanded
Assert.Equal(Steering.Toward(shooter, shooter.Aim.xz), shooter.Turn);
Assert.True(abs(shooter.Turn) > .5f);
}
}

View file

@ -307,7 +307,7 @@ public sealed class IffAndCombatTests : IDisposable
// detected (EntityInfoGathered=1 saturates the sensor-impact term). This makes the assertion
// below actually depend on the gate, instead of a coincidental zero from unset geometry.
shooter.Position = float3(0, 0, 0);
shooter.LookDirection = float3(0, 0, 1);
shooter.Aim = float3(0, 0, 1);
target.Position = float3(0, 0, 10);
shooter.EntityInfoGathered[target] = 1;

View file

@ -224,7 +224,7 @@ public sealed partial class MiningCut3Tests : IDisposable
}
s.Zone.Entities.Add(ship);
Hold(ship, at);
ship.LookDirection = float3(0, 0, 1);
ship.Aim = float3(0, 0, 1);
ship.Activate();
return ship;
}
@ -588,7 +588,7 @@ public sealed partial class MiningCut3Tests : IDisposable
var enemy = SpawnShip(s, eyeAt + float2(0, 50));
shooter.SetIff(enemy, true);
shooter.EntityInfoGathered[enemy] = 1;
shooter.LookDirection = float3(0, 0, 1);
shooter.Aim = float3(0, 0, 1);
var launcher = new LockWeapon(new LockWeaponData
{
LockSpeed = Constant(.5f),
@ -817,7 +817,7 @@ public sealed partial class MiningCut3Tests : IDisposable
foreach (var weapon in weapons) Assert.True(ship.TryEquip(Mint(s, weapon)));
s.Zone.Entities.Add(ship);
Hold(ship, eyeAt);
ship.LookDirection = float3(0, 0, 1);
ship.Aim = float3(0, 0, 1);
ship.Activate();
Tick((ship, eyeAt)); // weapons read their ranges when they run
return ship;

View file

@ -56,7 +56,7 @@ public sealed partial class MiningCut3Tests
Assert.True(ship.TryEquip(Mint(s, gun)));
s.Zone.Entities.Add(ship);
Hold(ship, at);
ship.LookDirection = float3(0, 0, 1);
ship.Aim = float3(0, 0, 1);
ship.Activate();
Tick((ship, at)); // equipment comes online and weapons read their ranges on the first tick
return ship;

View file

@ -157,7 +157,7 @@ public sealed partial class MiningCut3Tests
for (var i = 0; i < 60; i++)
{
var heading = rng.NextDouble() * 2 * Math.PI;
observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading));
observer.Aim = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading));
double Angle(TargetRef t)
{
var to = Where(t) - at;
@ -188,7 +188,7 @@ public sealed partial class MiningCut3Tests
var s = BuildSceneAt(float2.zero, 20000f, 0, null, new[] { kind }, Belt(kind, Annulus(new System.Random(21), count, 2000f, outer)));
var at = 2100f * float2(cos(1f), sin(1f));
var observer = Observer(s, at, 150f, 3f, Falloff());
observer.LookDirection = float3(1, 0, 0);
observer.Aim = float3(1, 0, 0);
var order = ByDistance(s, observer, 150f);
Assert.True(order.Count > 5);
if (held) observer.SetTarget(order[order.Count / 2]);
@ -262,7 +262,7 @@ public sealed partial class MiningCut3Tests
Check("Previous from the median rock", Cost(observer.TargetPrevious, median), previousHeld);
foreach (var heading in new[] { 0f, 2f, 4f })
{
observer.LookDirection = float3(cos(heading), 0, sin(heading));
observer.Aim = float3(cos(heading), 0, sin(heading));
Check($"the reticle at {heading}", Cost(observer.TargetUnderReticle, TargetRef.None), reticle);
}
s.Zone.Entities.Remove(observer);
@ -314,7 +314,7 @@ public sealed partial class MiningCut3Tests
for (var i = 0; i < 200; i++)
{
var heading = rng.NextDouble() * 2 * Math.PI;
observer.LookDirection = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading));
observer.Aim = float3((float) Math.Cos(heading), 0, (float) Math.Sin(heading));
observer.SetTarget(TargetRef.None);
switch (i % 3)
{
@ -338,7 +338,7 @@ public sealed partial class MiningCut3Tests
Assert.True(ship.TryEquip(Mint(s, launcher)));
s.Zone.Entities.Add(ship);
Hold(ship, at);
ship.LookDirection = float3(0, 0, 1);
ship.Aim = float3(0, 0, 1);
ship.Activate();
Tick((ship, at));
Assert.Contains(ship.Weapons, w => w.Range == 400f);
@ -410,7 +410,7 @@ public sealed partial class MiningCut3Tests
// visible target, never a hidden one.
for (var heading = 0f; heading < 2 * PI; heading += .7f)
{
observer.LookDirection = float3(sin(heading), 0, cos(heading));
observer.Aim = float3(sin(heading), 0, cos(heading));
observer.SetTarget(TargetRef.None);
Assert.True(observer.TargetUnderReticle());
Assert.DoesNotContain(observer.Target.Value, hidden);
@ -462,7 +462,7 @@ public sealed partial class MiningCut3Tests
}
// The reticle, looking at the spot: the ship; then with the ship gone from sight, the first chunk.
observer.LookDirection = float3(0, 0, 1);
observer.Aim = float3(0, 0, 1);
observer.SetTarget(TargetRef.None);
Assert.True(observer.TargetUnderReticle());
Assert.Equal(expected[0], observer.Target.Value);

View file

@ -18,7 +18,7 @@ using Random = CultMath.Random;
// Cut 1 (docs/locomotion-cut.md, "Restore the legacy thruster ship hulls to the live catalog"). Verifies the
// content restore-hulls landed, against the real shipped catalog, read-only: the hulls exist with the expected
// thruster hardpoint counts, every thruster hardpoint accepts a catalog thruster design through TryEquip at its
// own position, and the ship then moves and turns for a few ticks under MovementDirection/LookDirection on the
// own position, and the ship then moves and turns for a few ticks under MovementDirection/Turn on the
// pre-Cut-3 mixer -- proof of the restored content, not of a controller this cut does not touch.
public sealed class RestoredHullsTests
{
@ -31,7 +31,7 @@ public sealed class RestoredHullsTests
// (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to
// inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of
// hand-copying its fields a second time.
private static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
internal static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
// Same composition FireControlCut7Tests.OpenReadOnlyRealCatalog uses: a registry scoped to the shipped
// assembly's own [CultDocument] types, so this test's own process-wide type registration never pollutes the
@ -54,7 +54,7 @@ public sealed class RestoredHullsTests
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
}
private static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"));
internal static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"));
// Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out
// so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds
@ -201,16 +201,10 @@ public sealed class RestoredHullsTests
}
}
// "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips
// every thruster hardpoint with a fitting design, then drives MovementDirection/LookDirection the same way
// ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and
// Entity.Direction both move over a few ticks on the current (pre-Cut-3) mixer.
[Theory]
[InlineData("Longinus")]
[InlineData("Djinni")]
public void RestoredHullMovesAndTurnsUnderMovementAndLookDirection(string name)
// A restored hull with every thruster hardpoint (and its reactor) filled, activated and warmed up: the
// rotation thrusters the helm tests need. The caller owns the cache.
internal static Ship BuildThrustedShip(CultCache cache, string name)
{
using var cache = OpenCatalog();
var hull = cache.GetByName<HullData>(name);
var thrusterDesigns = cache.GetAll<GearData>().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray();
@ -247,12 +241,27 @@ public sealed class RestoredHullsTests
ship.Position = float3.zero;
ship.Activate();
zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them
return ship;
}
// "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips
// every thruster hardpoint with a fitting design, then drives MovementDirection/Turn the same way
// ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and
// Entity.Direction both move over a few ticks on the current (pre-Cut-3) mixer.
[Theory]
[InlineData("Longinus")]
[InlineData("Djinni")]
public void RestoredHullMovesAndTurnsUnderMovementAndTurn(string name)
{
using var cache = OpenCatalog();
var ship = BuildThrustedShip(cache, name);
var zone = ship.Zone;
var startVelocity = ship.Velocity;
var startDirection = ship.Direction;
ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives
ship.LookDirection = float3(1, 0, 0); // off-axis target heading, so the mixer's yaw thrusters have work to do
ship.Turn = 1; // a turn command, so the mixer's yaw thrusters have work to do
for (var i = 0; i < 10; i++) zone.Update(1f / 60f);
Assert.NotEqual(startVelocity, ship.Velocity);
@ -538,7 +547,6 @@ public sealed class RestoredHullsTests
zone.Update(0f);
ship.MovementDirection = float2(0, 1); // full forward
ship.LookDirection = float3(ship.Direction.x, 0, ship.Direction.y); // straight line: hold the ship's own heading
// Soul measured this loadout's forward thrusters crossing 386 K (thermal shutdown) around 7.3 s under
// real settings; 5 s keeps every tick inside the window where thrust runs continuously and unforced, so

View file

@ -317,12 +317,12 @@ public sealed partial class RunStartTests
Assert.Equal(1, AgentsOf(zone, hostile));
Assert.Equal(0, AgentsOf(zone, neutral));
Assert.Equal(0, AgentsOf(zone, player));
var look = neutral.LookDirection;
var look = neutral.Aim;
for (var tick = 0; tick < 50; tick++)
{
zone.Update(.1f);
Assert.Equal(float2.zero, ((Ship) neutral).MovementDirection);
Assert.Equal(look, neutral.LookDirection);
Assert.Equal(look, neutral.Aim);
}
Assert.Equal(0, AgentsOf(zone, neutral));
}

View file

@ -0,0 +1,177 @@
using System;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// The helm split: Ship.Turn is the one facing command, Steering.Toward turns a heading into it, HelmInput owns
// the player's face-the-aim state, and the hull never reads Aim. Ships are the restored Longinus with its real
// rotation thrusters, so rotation pays the thruster path the way it does in play.
public sealed class SteeringTests
{
private const string Hull = "Longinus";
private static float2 Right(Ship ship) => normalize(ship.Direction).Rotate(ItemRotation.Clockwise);
// A unit heading `degrees` clockwise of the hull's nose.
private static float2 Heading(Ship ship, float degrees)
{
var r = radians(degrees);
return normalize(ship.Direction) * cos(r) + Right(ship) * sin(r);
}
private static float Angle(float2 from, float2 to) => acos(clamp(dot(normalize(from), normalize(to)), -1f, 1f));
[Fact]
public void TowardIsZeroOnTheHeading()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
Assert.Equal(0f, Steering.Toward(ship, ship.Direction));
Assert.Equal(0f, Steering.Toward(ship, Heading(ship, .5f)));
Assert.Equal(0f, Steering.Toward(ship, Heading(ship, -.5f)));
}
[Fact]
public void TowardTurnsClockwiseForAStarboardHeading()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var expected = sqrt(sin(radians(45f)));
Assert.Equal(expected, Steering.Toward(ship, Heading(ship, 45f)), 4);
Assert.Equal(-expected, Steering.Toward(ship, Heading(ship, -45f)), 4);
}
[Fact]
public void AHeadingBehindStillTurns()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
Assert.Equal(1f, abs(Steering.Toward(ship, -ship.Direction)));
Assert.Equal(-1f, Steering.Toward(ship, Heading(ship, -170f)));
Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 170f)));
}
[Fact]
public void ANonFiniteHeadingHoldsCourse()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
foreach (var heading in new[] { float2.zero, float2(float.NaN, 1), float2(1, float.NaN), float2(float.PositiveInfinity, 0) })
{
ship.Turn = Steering.Toward(ship, heading);
Assert.Equal(0f, ship.Turn);
for (var i = 0; i < 5; i++) ship.Update(1f / 60f);
Assert.False(float.IsNaN(ship.Direction.x) || float.IsNaN(ship.Direction.y));
}
}
[Fact]
public void HoldAndToggleAgree()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var held = new HelmInput { Held = true };
var latched = new HelmInput();
latched.Toggle();
foreach (var direction in new[] { float2(0, 1), float2(1, 0), float2(-1, -1), float2(.3f, -.9f) })
foreach (var aim in new[] { float2(0, 1), float2(1, 1), float2(-1, 0), float2(0, -1), float2(.2f, -.7f), float2.zero })
foreach (var axis in new[] { -1f, 0f, .5f, 1f })
{
ship.Direction = direction;
var expected = Steering.Toward(ship, aim);
Assert.Equal(expected, held.Demand(ship, aim, axis));
Assert.Equal(expected, latched.Demand(ship, aim, axis));
}
}
[Fact]
public void FaceAimOffFollowsTheAxis()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var helm = new HelmInput();
foreach (var aim in new[] { float2(0, 1), float2(1, 0), float2(-1, -1) })
{
Assert.Equal(.25f, helm.Demand(ship, aim, .25f));
Assert.Equal(1f, helm.Demand(ship, aim, 3f));
Assert.Equal(-1f, helm.Demand(ship, aim, -3f));
}
helm.Toggle();
Assert.True(helm.Latched);
helm.Toggle();
Assert.False(helm.Latched);
Assert.False(helm.FaceAim);
}
[Fact]
public void TheHullIgnoresTheAim()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var start = ship.Direction;
ship.Turn = 0;
ship.Aim = float3(Right(ship).x, 0, Right(ship).y);
for (var i = 0; i < 60; i++) ship.Update(1f / 60f);
Assert.True(Angle(start, ship.Direction) < radians(.01f));
}
[Fact]
public void TurnRotatesThroughTheThrusters()
{
using var cache = RestoredHullsTests.OpenCatalog();
var clockwise = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var counter = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var dead = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var start = clockwise.Direction;
var right = Right(clockwise);
foreach (var item in dead.Equipment)
if (item.Behaviors.Any(b => b is Thruster)) item.Enabled.Value = false;
clockwise.Turn = 1;
counter.Turn = -1;
dead.Turn = 1;
for (var i = 0; i < 60; i++)
{
clockwise.Update(1f / 60f);
counter.Update(1f / 60f);
dead.Update(1f / 60f);
}
Assert.True(dot(clockwise.Direction, right) > .01f);
Assert.True(dot(counter.Direction, right) < -.01f);
Assert.True(Angle(start, dead.Direction) < radians(.01f));
}
[Fact]
public void AccelerateWritesTurnNotAim()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var agent = new Agent(ship);
var aim = float3(0, 0, 1);
ship.Aim = aim;
agent.Accelerate(Right(ship) * 100f);
Assert.True(ship.Turn > 0f);
Assert.Equal(aim, ship.Aim);
ship.Turn = .37f;
agent.Accelerate(-Right(ship) * 100f, true);
Assert.Equal(.37f, ship.Turn);
Assert.Equal(aim, ship.Aim);
}
}

View file

@ -352,7 +352,7 @@ public sealed class TargetingSystemTests : IDisposable
Assert.True(ship.TryEquip(targeting));
zone.Entities.Add(ship);
ship.LookDirection = float3(0, 0, 1);
ship.Aim = float3(0, 0, 1);
ship.Activate();
return ship;
}