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:
parent
cf0979481f
commit
c86423936e
29 changed files with 331 additions and 80 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
18
Assets/Scripts/ServerShared/HelmInput.cs
Normal file
18
Assets/Scripts/ServerShared/HelmInput.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
24
Assets/Scripts/ServerShared/Steering.cs
Normal file
24
Assets/Scripts/ServerShared/Steering.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
177
tests/Aetheria.Shared.Tests/SteeringTests.cs
Normal file
177
tests/Aetheria.Shared.Tests/SteeringTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue