Merge controls-helm: Ship.Turn is the only facing command; the helm split and face-the-aim

Soul gate closed at verdict cut-controls-helm.s3 (head b644fceb). Merged
under operator ruling aetheria-release:ruling:completed-cuts-merge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-07 20:08:31 +02:00
commit 3fcdc20c00
34 changed files with 950 additions and 129 deletions

View file

@ -1,5 +1,6 @@
{
"name": "Aetheria",
"version": 1,
"maps": [
{
"name": "Player",
@ -12,7 +13,8 @@
"expectedControlType": "Vector2",
"processors": "",
"interactions": "",
"initialStateCheck": true
"initialStateCheck": true,
"priority": 0
},
{
"name": "Look",
@ -21,7 +23,8 @@
"expectedControlType": "Vector2",
"processors": "",
"interactions": "",
"initialStateCheck": true
"initialStateCheck": true,
"priority": 0
},
{
"name": "Minimap Zoom",
@ -30,7 +33,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Target Reticle",
@ -39,7 +43,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Target Previous",
@ -48,7 +53,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Target Next",
@ -57,7 +63,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Target Nearest",
@ -66,7 +73,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Cycle Target Item",
@ -75,7 +83,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Toggle Heatsinks",
@ -84,7 +93,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Toggle Shield",
@ -93,7 +103,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Toggle Stance",
@ -102,7 +113,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Override Shutdown",
@ -111,7 +123,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Ping",
@ -120,7 +133,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Hide UI",
@ -129,7 +143,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Tractor Beam",
@ -138,7 +153,8 @@
"expectedControlType": "",
"processors": "",
"interactions": "",
"initialStateCheck": true
"initialStateCheck": true,
"priority": 0
},
{
"name": "Turn",
@ -147,23 +163,88 @@
"expectedControlType": "Axis",
"processors": "",
"interactions": "",
"initialStateCheck": true
"initialStateCheck": true,
"priority": 0
},
{
"name": "Face Aim",
"type": "Button",
"id": "6d5bd565-8c0e-4789-9d51-177dc0ec8e11",
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false,
"priority": 0
},
{
"name": "Toggle Face Aim",
"type": "Button",
"id": "4067c72f-3e3d-4d7b-a8b5-356cab9793fc",
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false,
"priority": 0
}
],
"bindings": [
{
"name": "",
"name": "Gamepad Move",
"id": "978bfe49-cc26-4a3d-ab7b-7d7a29327403",
"path": "<Gamepad>/leftStick",
"path": "2DVector(mode=2)",
"interactions": "",
"processors": "",
"groups": "",
"action": "Move",
"isComposite": true,
"isPartOfComposite": false
},
{
"name": "up",
"id": "e9c57b4d-1cd8-4b2f-93d6-1745a37cabc9",
"path": "<Gamepad>/leftStick/up",
"interactions": "",
"processors": "",
"groups": ";Gamepad",
"action": "Move",
"isComposite": false,
"isPartOfComposite": false
"isPartOfComposite": true
},
{
"name": "WASD",
"name": "down",
"id": "05f19282-8364-4ae0-b5c3-d471c10601a7",
"path": "<Gamepad>/leftStick/down",
"interactions": "",
"processors": "",
"groups": ";Gamepad",
"action": "Move",
"isComposite": false,
"isPartOfComposite": true
},
{
"name": "left",
"id": "1ce4a07f-43af-4e82-a631-dbb43c8191d3",
"path": "<Gamepad>/leftShoulder",
"interactions": "",
"processors": "",
"groups": ";Gamepad",
"action": "Move",
"isComposite": false,
"isPartOfComposite": true
},
{
"name": "right",
"id": "bd1aed7e-b80b-4341-a550-4ef10bacf4b4",
"path": "<Gamepad>/rightShoulder",
"interactions": "",
"processors": "",
"groups": ";Gamepad",
"action": "Move",
"isComposite": false,
"isPartOfComposite": true
},
{
"name": "W S Q E",
"id": "00ca640b-d935-4593-8157-c05846ea39b3",
"path": "Dpad(mode=1)",
"interactions": "",
@ -198,7 +279,7 @@
{
"name": "left",
"id": "d2581a9b-1d11-4566-b27d-b92aff5fabbc",
"path": "<Keyboard>/a",
"path": "<Keyboard>/q",
"interactions": "",
"processors": "",
"groups": ";Keyboard&Mouse",
@ -209,7 +290,7 @@
{
"name": "right",
"id": "fcfe95b8-67b9-4526-84b5-5d0bc98d6400",
"path": "<Keyboard>/d",
"path": "<Keyboard>/e",
"interactions": "",
"processors": "",
"groups": ";Keyboard&Mouse",
@ -418,7 +499,7 @@
{
"name": "negative",
"id": "8536ca60-1fed-4f29-8060-04d46cfffcab",
"path": "<Keyboard>/q",
"path": "<Keyboard>/a",
"interactions": "",
"processors": "",
"groups": "",
@ -429,13 +510,57 @@
{
"name": "positive",
"id": "19c175bf-d4e6-4626-8bee-91e0c473a94b",
"path": "<Keyboard>/e",
"path": "<Keyboard>/d",
"interactions": "",
"processors": "",
"groups": "",
"action": "Turn",
"isComposite": false,
"isPartOfComposite": true
},
{
"name": "",
"id": "fdff8f1f-0824-49bd-b09a-c1b792f16299",
"path": "<Gamepad>/leftStick/x",
"interactions": "",
"processors": "",
"groups": ";Gamepad",
"action": "Turn",
"isComposite": false,
"isPartOfComposite": false
},
{
"name": "",
"id": "1ee2fadd-3db4-45e6-8fa4-7a621ef7d165",
"path": "<Keyboard>/leftShift",
"interactions": "",
"processors": "",
"groups": "Keyboard&Mouse",
"action": "Face Aim",
"isComposite": false,
"isPartOfComposite": false
},
{
"name": "",
"id": "e9520d95-0458-439b-a37c-84c45237e110",
"path": "<Keyboard>/capsLock",
"interactions": "",
"processors": "",
"groups": "Keyboard&Mouse",
"action": "Toggle Face Aim",
"isComposite": false,
"isPartOfComposite": false
},
{
"name": "",
"id": "596ed367-be34-4951-9aac-d8caa57b411f",
"path": "<Gamepad>/leftStickPress",
"interactions": "",
"processors": "",
"groups": ";Gamepad",
"action": "Toggle Face Aim",
"isComposite": false,
"isPartOfComposite": false
}
]
},
@ -450,7 +575,8 @@
"expectedControlType": "Vector2",
"processors": "",
"interactions": "",
"initialStateCheck": true
"initialStateCheck": true,
"priority": 0
},
{
"name": "Submit",
@ -459,7 +585,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Cancel",
@ -468,7 +595,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Point",
@ -477,7 +605,8 @@
"expectedControlType": "Vector2",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Click",
@ -486,7 +615,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "ScrollWheel",
@ -495,7 +625,8 @@
"expectedControlType": "Vector2",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "MiddleClick",
@ -504,7 +635,8 @@
"expectedControlType": "",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "RightClick",
@ -513,7 +645,8 @@
"expectedControlType": "",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Drag",
@ -522,7 +655,8 @@
"expectedControlType": "Vector2",
"processors": "",
"interactions": "",
"initialStateCheck": true
"initialStateCheck": true,
"priority": 0
}
],
"bindings": [
@ -913,7 +1047,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Galaxy Map",
@ -922,7 +1057,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Inventory",
@ -931,7 +1067,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Main Menu",
@ -940,7 +1077,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Input Screen",
@ -949,7 +1087,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
},
{
"name": "Interact",
@ -958,7 +1097,8 @@
"expectedControlType": "Button",
"processors": "",
"interactions": "",
"initialStateCheck": false
"initialStateCheck": false,
"priority": 0
}
],
"bindings": [

View file

@ -251,22 +251,86 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
""interactions"": """",
""initialStateCheck"": true,
""priority"": 0
},
{
""name"": ""Face Aim"",
""type"": ""Button"",
""id"": ""6d5bd565-8c0e-4789-9d51-177dc0ec8e11"",
""expectedControlType"": ""Button"",
""processors"": """",
""interactions"": """",
""initialStateCheck"": false,
""priority"": 0
},
{
""name"": ""Toggle Face Aim"",
""type"": ""Button"",
""id"": ""4067c72f-3e3d-4d7b-a8b5-356cab9793fc"",
""expectedControlType"": ""Button"",
""processors"": """",
""interactions"": """",
""initialStateCheck"": false,
""priority"": 0
}
],
""bindings"": [
{
""name"": """",
""name"": ""Gamepad Move"",
""id"": ""978bfe49-cc26-4a3d-ab7b-7d7a29327403"",
""path"": ""<Gamepad>/leftStick"",
""path"": ""2DVector(mode=2)"",
""interactions"": """",
""processors"": """",
""groups"": """",
""action"": ""Move"",
""isComposite"": true,
""isPartOfComposite"": false
},
{
""name"": ""up"",
""id"": ""e9c57b4d-1cd8-4b2f-93d6-1745a37cabc9"",
""path"": ""<Gamepad>/leftStick/up"",
""interactions"": """",
""processors"": """",
""groups"": "";Gamepad"",
""action"": ""Move"",
""isComposite"": false,
""isPartOfComposite"": false
""isPartOfComposite"": true
},
{
""name"": ""WASD"",
""name"": ""down"",
""id"": ""05f19282-8364-4ae0-b5c3-d471c10601a7"",
""path"": ""<Gamepad>/leftStick/down"",
""interactions"": """",
""processors"": """",
""groups"": "";Gamepad"",
""action"": ""Move"",
""isComposite"": false,
""isPartOfComposite"": true
},
{
""name"": ""left"",
""id"": ""1ce4a07f-43af-4e82-a631-dbb43c8191d3"",
""path"": ""<Gamepad>/leftShoulder"",
""interactions"": """",
""processors"": """",
""groups"": "";Gamepad"",
""action"": ""Move"",
""isComposite"": false,
""isPartOfComposite"": true
},
{
""name"": ""right"",
""id"": ""bd1aed7e-b80b-4341-a550-4ef10bacf4b4"",
""path"": ""<Gamepad>/rightShoulder"",
""interactions"": """",
""processors"": """",
""groups"": "";Gamepad"",
""action"": ""Move"",
""isComposite"": false,
""isPartOfComposite"": true
},
{
""name"": ""W S Q E"",
""id"": ""00ca640b-d935-4593-8157-c05846ea39b3"",
""path"": ""Dpad(mode=1)"",
""interactions"": """",
@ -301,7 +365,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
{
""name"": ""left"",
""id"": ""d2581a9b-1d11-4566-b27d-b92aff5fabbc"",
""path"": ""<Keyboard>/a"",
""path"": ""<Keyboard>/q"",
""interactions"": """",
""processors"": """",
""groups"": "";Keyboard&Mouse"",
@ -312,7 +376,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
{
""name"": ""right"",
""id"": ""fcfe95b8-67b9-4526-84b5-5d0bc98d6400"",
""path"": ""<Keyboard>/d"",
""path"": ""<Keyboard>/e"",
""interactions"": """",
""processors"": """",
""groups"": "";Keyboard&Mouse"",
@ -521,7 +585,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
{
""name"": ""negative"",
""id"": ""8536ca60-1fed-4f29-8060-04d46cfffcab"",
""path"": ""<Keyboard>/q"",
""path"": ""<Keyboard>/a"",
""interactions"": """",
""processors"": """",
""groups"": """",
@ -532,13 +596,57 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
{
""name"": ""positive"",
""id"": ""19c175bf-d4e6-4626-8bee-91e0c473a94b"",
""path"": ""<Keyboard>/e"",
""path"": ""<Keyboard>/d"",
""interactions"": """",
""processors"": """",
""groups"": """",
""action"": ""Turn"",
""isComposite"": false,
""isPartOfComposite"": true
},
{
""name"": """",
""id"": ""fdff8f1f-0824-49bd-b09a-c1b792f16299"",
""path"": ""<Gamepad>/leftStick/x"",
""interactions"": """",
""processors"": """",
""groups"": "";Gamepad"",
""action"": ""Turn"",
""isComposite"": false,
""isPartOfComposite"": false
},
{
""name"": """",
""id"": ""1ee2fadd-3db4-45e6-8fa4-7a621ef7d165"",
""path"": ""<Keyboard>/leftShift"",
""interactions"": """",
""processors"": """",
""groups"": ""Keyboard&Mouse"",
""action"": ""Face Aim"",
""isComposite"": false,
""isPartOfComposite"": false
},
{
""name"": """",
""id"": ""e9520d95-0458-439b-a37c-84c45237e110"",
""path"": ""<Keyboard>/capsLock"",
""interactions"": """",
""processors"": """",
""groups"": ""Keyboard&Mouse"",
""action"": ""Toggle Face Aim"",
""isComposite"": false,
""isPartOfComposite"": false
},
{
""name"": """",
""id"": ""596ed367-be34-4951-9aac-d8caa57b411f"",
""path"": ""<Gamepad>/leftStickPress"",
""interactions"": """",
""processors"": """",
""groups"": "";Gamepad"",
""action"": ""Toggle Face Aim"",
""isComposite"": false,
""isPartOfComposite"": false
}
]
},
@ -1208,6 +1316,8 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
m_Player_HideUI = m_Player.FindAction("Hide UI", throwIfNotFound: true);
m_Player_TractorBeam = m_Player.FindAction("Tractor Beam", throwIfNotFound: true);
m_Player_Turn = m_Player.FindAction("Turn", throwIfNotFound: true);
m_Player_FaceAim = m_Player.FindAction("Face Aim", throwIfNotFound: true);
m_Player_ToggleFaceAim = m_Player.FindAction("Toggle Face Aim", throwIfNotFound: true);
// UI
m_UI = asset.FindActionMap("UI", throwIfNotFound: true);
m_UI_Navigate = m_UI.FindAction("Navigate", throwIfNotFound: true);
@ -1325,6 +1435,8 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
private readonly InputAction m_Player_HideUI;
private readonly InputAction m_Player_TractorBeam;
private readonly InputAction m_Player_Turn;
private readonly InputAction m_Player_FaceAim;
private readonly InputAction m_Player_ToggleFaceAim;
/// <summary>
/// Provides access to input actions defined in input action map "Player".
/// </summary>
@ -1401,6 +1513,14 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
/// </summary>
public InputAction @Turn => m_Wrapper.m_Player_Turn;
/// <summary>
/// Provides access to the underlying input action "Player/FaceAim".
/// </summary>
public InputAction @FaceAim => m_Wrapper.m_Player_FaceAim;
/// <summary>
/// Provides access to the underlying input action "Player/ToggleFaceAim".
/// </summary>
public InputAction @ToggleFaceAim => m_Wrapper.m_Player_ToggleFaceAim;
/// <summary>
/// Provides access to the underlying input action map instance.
/// </summary>
public InputActionMap Get() { return m_Wrapper.m_Player; }
@ -1474,6 +1594,12 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
@Turn.started += instance.OnTurn;
@Turn.performed += instance.OnTurn;
@Turn.canceled += instance.OnTurn;
@FaceAim.started += instance.OnFaceAim;
@FaceAim.performed += instance.OnFaceAim;
@FaceAim.canceled += instance.OnFaceAim;
@ToggleFaceAim.started += instance.OnToggleFaceAim;
@ToggleFaceAim.performed += instance.OnToggleFaceAim;
@ToggleFaceAim.canceled += instance.OnToggleFaceAim;
}
/// <summary>
@ -1533,6 +1659,12 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
@Turn.started -= instance.OnTurn;
@Turn.performed -= instance.OnTurn;
@Turn.canceled -= instance.OnTurn;
@FaceAim.started -= instance.OnFaceAim;
@FaceAim.performed -= instance.OnFaceAim;
@FaceAim.canceled -= instance.OnFaceAim;
@ToggleFaceAim.started -= instance.OnToggleFaceAim;
@ToggleFaceAim.performed -= instance.OnToggleFaceAim;
@ToggleFaceAim.canceled -= instance.OnToggleFaceAim;
}
/// <summary>
@ -2059,6 +2191,20 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
void OnTurn(InputAction.CallbackContext context);
/// <summary>
/// Method invoked when associated input action "Face Aim" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
/// </summary>
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
void OnFaceAim(InputAction.CallbackContext context);
/// <summary>
/// Method invoked when associated input action "Toggle Face Aim" is either <see cref="UnityEngine.InputSystem.InputAction.started" />, <see cref="UnityEngine.InputSystem.InputAction.performed" /> or <see cref="UnityEngine.InputSystem.InputAction.canceled" />.
/// </summary>
/// <seealso cref="UnityEngine.InputSystem.InputAction.started" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.performed" />
/// <seealso cref="UnityEngine.InputSystem.InputAction.canceled" />
void OnToggleFaceAim(InputAction.CallbackContext context);
}
/// <summary>
/// Interface to implement callback methods for all input action callbacks associated with input actions defined by "UI" which allows adding and removing callbacks.

View file

@ -192,6 +192,9 @@ public class ActionGameManager : MonoBehaviour
// private ShipInput _shipInput;
private float2 _entityYawPitch;
private float3 _viewDirection;
// The player's face-the-aim state (Left Shift held, Caps Lock latched); Ship.Turn is its only output.
private readonly HelmInput _helm = new HelmInput();
private TextMeshProUGUI _faceAimMark;
private (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] _articulationGroups;
private (LockWeapon targetLock, PlaceUIElementWorldspace indicator, Rotate spin)[] _lockingIndicators;
private Dictionary<Entity, VisibleTargetIndicator> _visibleHostileIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
@ -290,6 +293,20 @@ public class ActionGameManager : MonoBehaviour
InputDisplayLayout.Input = Input.asset;
Input.Global.Enable();
// The latch is the game's own, so it can disagree with the keyboard's Caps Lock light: show it.
Input.Player.ToggleFaceAim.performed += context => _helm.Toggle();
var markParent = GameplayUI != null ? GameplayUI.transform : UiRoot.transform;
var markObject = new GameObject("Face Aim Mark", typeof(RectTransform));
markObject.transform.SetParent(markParent, false);
_faceAimMark = markObject.AddComponent<TextMeshProUGUI>();
_faceAimMark.text = "FACE AIM";
_faceAimMark.fontSize = 24;
_faceAimMark.alignment = TextAlignmentOptions.Center;
_faceAimMark.raycastTarget = false;
_faceAimMark.rectTransform.anchorMin = _faceAimMark.rectTransform.anchorMax = new Vector2(.5f, .12f);
_faceAimMark.rectTransform.sizeDelta = new Vector2(300, 40);
_faceAimMark.enabled = false;
_zoomLevelIndex = Settings.DefaultMinimapZoom;
Input.Player.MinimapZoom.performed += context =>
{
@ -663,7 +680,7 @@ public class ActionGameManager : MonoBehaviour
if (!(CurrentEntity is Ship ship) || ship.WormholeAnimationInProgress) return;
// var wormholeCameraFollow = new GameObject("Wormhole Camera Follow").transform;
// wormholeCameraFollow.position = new Vector3(wormhole.Position.x, -50, wormhole.Position.y);
// wormholeCameraFollow.rotation = Quaternion.LookRotation(Vector3.down, ship.LookDirection);
// wormholeCameraFollow.rotation = Quaternion.LookRotation(Vector3.down, ship.Aim);
// WormholeCamera.enabled = true;
// WormholeCamera.Follow = wormholeCameraFollow;
// FollowCamera.enabled = false;
@ -1281,16 +1298,19 @@ public class ActionGameManager : MonoBehaviour
var sensitivity = PlayerSettings.InputSettings.Sensitivity;
_entityYawPitch = float2(_entityYawPitch.x + look.x * sensitivity.x, clamp(_entityYawPitch.y + look.y * sensitivity.y, -.45f * PI, .45f * PI));
_viewDirection = mul(float3(0, 0, 1), CultMath.float3x3.Euler(float3(_entityYawPitch.yx, 0), RotationOrder.YXZ));
CurrentEntity.LookDirection = _viewDirection;
CurrentEntity.Aim = _viewDirection;
HeatstrokePost.weight = saturate(unlerp(0, Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, CurrentEntity.Heatstroke));
var severeHeatstrokeLerp = saturate(unlerp(Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, 1, CurrentEntity.Heatstroke));
SevereHeatstrokePost.weight =
severeHeatstrokeLerp + severeHeatstrokeLerp * (1 - severeHeatstrokeLerp) *
max(Settings.HeatstrokePhasingFloor, sin(Time.time * Settings.HeatstrokePhasingFrequency));
_faceAimMark.enabled = _helm.Latched;
if(CurrentEntity is Ship ship)
{
ship.MovementDirection = Input.Player.Move.ReadValue<Vector2>().ToCultMath();
_helm.Held = Input.Player.FaceAim.IsPressed();
ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue<float>());
}
UpdateFireControlDebug(CurrentEntity.Target.Value);

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

@ -46,6 +46,9 @@ public class Agent
public void Update(float delta)
{
// The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its
// demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate.
Ship.Turn = 0;
_currentState.Update(delta);
foreach (var transition in _currentState.Transitions)
if (transition.Condition())
@ -63,10 +66,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

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f863bb50e53ae30408f5ef8be1462e12

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,11 @@ 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;
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

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8b84ac5db42877d45b7f6286cc2e2b93

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

@ -574,7 +574,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, float projectileSpeed = 0f)
{
var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f));
var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData);
@ -189,6 +184,7 @@ public sealed class FireControlTests : IDisposable
weaponData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
weaponData.Range = new PerformanceStat { Min = 1000, Max = 1000 };
weaponData.MinRange = new PerformanceStat { Min = 0, Max = 0 };
weaponData.Velocity = new PerformanceStat { Min = projectileSpeed, Max = projectileSpeed };
weaponData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
var zone = shooter.Zone;
@ -203,7 +199,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 +212,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 +236,64 @@ 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);
}
// Against a target that moves, Combat aims at where the round will meet it (FireControl.PredictedIntercept),
// not where the target is: the aim leads, and the hull's Turn follows the intercept bearing.
[Fact]
public void CombatLeadsAMovingTarget()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 100), projectileSpeed: 20f);
zone.Update(1f); // transitions the Minion into CombatState and evaluates weapon stats
// Agents update before entities, so Combat reads exactly this state on the next tick.
target.Position = float3(0, 0, 100);
target.Velocity = float2(4, 0);
var weapon = shooter.Weapons.Single();
var intercept = normalize((FireControl.PredictedIntercept(weapon, shooter, target) - shooter.Position).xz);
var bearing = normalize(target.Position.xz - shooter.Position.xz);
Assert.True(dot(intercept, bearing) < cos(radians(5f)), "the fixture really leads the target");
zone.Update(1f);
var aim = normalize(shooter.Aim.xz);
Assert.True(dot(aim, intercept) > .9999f, $"aim {shooter.Aim}");
Assert.Equal(Steering.Toward(shooter, intercept), shooter.Turn);
Assert.True(shooter.Turn > 0f);
}
// A Minion whose target goes away returns to its root state, which pilots nothing: the hull must stop
// turning rather than keep the rate Combat last commanded.
[Fact]
public void AMinionThatLosesItsTargetStopsTurning()
{
var (shooter, _, zone) = BuildCombatEncounter(float3(10, 0, 10));
zone.Update(1f);
zone.Update(1f);
Assert.True(abs(shooter.Turn) > .5f, "Combat commanded a turn");
shooter.SetTarget(TargetRef.None);
zone.Update(1f); // Combat has no target and the Minion transitions to its root state
zone.Update(1f);
Assert.Equal(0f, shooter.Turn);
}
}

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,393 @@
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, Steering.Toward(ship, -ship.Direction)); // exactly astern: no lateral component, so +1
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 < 10; i++)
{
clockwise.Update(1f / 60f);
counter.Update(1f / 60f);
dead.Update(1f / 60f);
}
Assert.True(dot(clockwise.Direction, right) > 1e-3f, $"clockwise {dot(clockwise.Direction, right)}");
Assert.True(dot(counter.Direction, right) < -1e-3f, $"counter {dot(counter.Direction, right)}");
Assert.True(Angle(start, dead.Direction) < radians(.01f), $"dead {Angle(start, dead.Direction)}");
}
[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);
}
// The thresholds Accelerate steers by: above 20 it turns toward the velocity error, between 1 and 20 it
// thrusts sideways and leaves Turn to the caller, at or below 1 it holds still.
[Fact]
public void AccelerateThresholdsAreExact()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var agent = new Agent(ship);
Assert.Equal(float2(0, 1), ship.Direction); // starboard is +x
ship.Turn = .37f;
agent.Accelerate(float2(20, 0), false); // exactly the forward threshold: not above it
Assert.Equal(.37f, ship.Turn);
Assert.Equal(float2(1, 0), ship.MovementDirection);
agent.Accelerate(float2(21, 0), false);
Assert.True(ship.Turn > .5f);
Assert.Equal(float2(0, 0), ship.MovementDirection);
ship.Turn = .37f;
ship.MovementDirection = float2(1, 1); // a stale command the hold branch must clear
agent.Accelerate(float2(1, 0), false); // exactly the thrust threshold: not above it
Assert.Equal(float2.zero, ship.MovementDirection);
Assert.Equal(.37f, ship.Turn);
agent.Accelerate(float2(5, 0), false);
Assert.Equal(float2(1, 0), ship.MovementDirection);
Assert.Equal(.37f, ship.Turn);
}
[Fact]
public void AccelerateSteersByTheVelocityError()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var agent = new Agent(ship);
ship.Velocity = float2(100, 0);
ship.Turn = .37f;
agent.Accelerate(float2(100, 0), false); // already at the wanted velocity: nothing to correct
Assert.Equal(.37f, ship.Turn);
Assert.Equal(float2.zero, ship.MovementDirection);
}
// Behind the hull is "dot < 0": 100 degrees off the nose is already behind, so it turns at full demand, and
// 80 degrees is not, so it is the shaped lateral component.
[Fact]
public void TheBehindRuleBeginsPastNinetyDegrees()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
// Either side of the 90 degree boundary: sqrt shaping at 85, full demand at 95.
Assert.Equal(sqrt(sin(radians(85f))), Steering.Toward(ship, Heading(ship, 85f)), 5);
Assert.Equal(-sqrt(sin(radians(85f))), Steering.Toward(ship, Heading(ship, -85f)), 5);
Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 95f)));
Assert.Equal(-1f, Steering.Toward(ship, Heading(ship, -95f)));
Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 100f)));
Assert.Equal(-1f, Steering.Toward(ship, Heading(ship, -100f)));
Assert.Equal(1f, Steering.Toward(ship, Heading(ship, 135f)));
Assert.Equal(sqrt(sin(radians(80f))), Steering.Toward(ship, Heading(ship, 80f)), 4);
Assert.Equal(-sqrt(sin(radians(80f))), Steering.Toward(ship, Heading(ship, -80f)), 4);
}
// The turn bound has one owner, the actuators: Ship.Update hands Turn to them unclamped, Thruster.Axis saturates to
// 0..1 and AetherDrive.Axis clamps to -1..1. A Turn of 3 therefore rotates the hull as a Turn of 1.
[Fact]
public void TheActuatorsBoundATurnBeyondOne()
{
using var cache = RestoredHullsTests.OpenCatalog();
var one = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var three = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var minusOne = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var minusThree = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var start = one.Direction;
one.Turn = 1;
three.Turn = 3;
minusOne.Turn = -1;
minusThree.Turn = -3;
for (var i = 0; i < 10; i++)
{
one.Update(1f / 60f);
three.Update(1f / 60f);
minusOne.Update(1f / 60f);
minusThree.Update(1f / 60f);
}
Assert.True(Angle(start, one.Direction) > radians(.02f), "the reference turn is real");
Assert.True(Angle(one.Direction, three.Direction) < radians(.001f), $"{Angle(one.Direction, three.Direction)}");
Assert.True(Angle(minusOne.Direction, minusThree.Direction) < radians(.001f));
// The bound itself, at its owners.
foreach (var thruster in one.Equipment.SelectMany(i => i.Behaviors).OfType<Thruster>())
{
thruster.Axis = 3f;
Assert.Equal(1f, thruster.Axis);
thruster.Axis = -3f;
Assert.Equal(0f, thruster.Axis);
}
// Axis is a pure property; no catalog hull carries a drive, so the instance is made without its item.
var drive = (AetherDrive)System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(AetherDrive));
drive.Axis = float3(3f, -3f, 3f);
Assert.Equal(float3(1f, -1f, 1f), drive.Axis);
drive.Axis = float3(0f, 0f, -3f);
Assert.Equal(-1f, drive.Axis.z);
}
// The size of a turn, computed from the hull and not read back from the code under test: each rotation thruster
// at Axis 1 turns the hull by Torque * Thrust * TorqueMultiplier / Mass * dt per tick (Thruster.Execute's law,
// summed here from the thrusters' own stats over the sets whose Torque clears the floor).
[Theory]
[InlineData(1f)]
[InlineData(-1f)]
[InlineData(.5f)]
[InlineData(-.5f)]
public void ATurnRotatesAtTheRateTheHullsTorqueGives(float turn)
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var settings = RestoredHullsTests.Settings();
const float dt = 1f / 60f;
var thrusters = ship.Equipment.SelectMany(i => i.Behaviors).OfType<Thruster>().ToArray();
var nose = normalize(ship.Direction);
var right = Right(ship);
ship.Turn = turn;
ship.Update(dt);
float Rate(Func<Thruster, bool> set, float axis) =>
thrusters.Where(set).Sum(t => axis * t.Torque * t.Thrust * settings.TorqueMultiplier / ship.Mass * dt);
var clockwise = Rate(t => t.Torque > settings.TorqueFloor, max(turn, 0f));
var counter = Rate(t => t.Torque < -settings.TorqueFloor, max(-turn, 0f));
var expected = clockwise + counter;
var after = normalize(ship.Direction);
var turned = atan2(dot(after, right), dot(after, nose)); // positive is clockwise
Assert.True(abs(expected) > radians(.01f), $"the expected turn is real: {expected}");
Assert.InRange(abs(turned / expected), .98f, 1.02f);
}
// A state that writes a constant turn, standing for any piloting state.
private sealed class SpinState : BaseState
{
public SpinState(Agent agent) : base(agent) { }
public override void Update(float delta) => _agent.Ship.Turn = .5f;
}
// Turn is a rate, so a state that stops piloting must not leave the last state's rate behind: the agent's
// tick writes Turn every tick, zero when its state demands none, and the hull then holds its heading.
[Fact]
public void AnAgentWhoseStateStopsPilotingHoldsItsHeading()
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var agent = new Agent(ship);
var start = ship.Direction;
agent.Transition(new SpinState(agent));
for (var i = 0; i < 10; i++)
{
agent.Update(1f / 60f);
ship.Update(1f / 60f);
}
Assert.True(Angle(start, ship.Direction) > radians(.05f), "the piloting state turns the hull");
Assert.Equal(.5f, ship.Turn);
agent.Transition(new BaseState(agent));
agent.Update(1f / 60f);
ship.Update(1f / 60f);
var held = ship.Direction;
for (var i = 0; i < 60; i++)
{
agent.Update(1f / 60f);
ship.Update(1f / 60f);
}
Assert.Equal(0f, ship.Turn);
Assert.True(Angle(held, ship.Direction) < radians(.01f), $"{Angle(held, ship.Direction)}");
}
private sealed class MoveToPointState : MoveToState
{
public float2 Point;
public MoveToPointState(Agent agent) : base(agent) { }
protected override float2 TargetPosition => Point;
}
// MoveTo aims at its target and commands the hull toward it. The ship already flies the wanted velocity, so
// Accelerate (which leaves Turn to the caller below its thresholds) writes none of it: what is measured is
// MoveTo's own write, to either side.
[Theory]
[InlineData(90f)]
[InlineData(-45f)]
public void MoveToAimsAtTheTargetAndTurnsTowardIt(float degrees)
{
using var cache = RestoredHullsTests.OpenCatalog();
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
var agent = new Agent(ship);
var state = new MoveToPointState(agent);
var dir = Heading(ship, degrees);
state.Point = dir * 500f;
ship.Velocity = dir * agent.TopSpeed; // deltaV is zero: Accelerate holds and leaves Turn alone
agent.Transition(state);
agent.Update(1f / 60f);
Assert.True(dot(normalize(ship.Aim.xz), dir) > .9999f, $"aim {ship.Aim}");
Assert.True(abs(ship.Aim.y) < 1e-6f);
Assert.Equal(Steering.Toward(ship, dir), ship.Turn);
Assert.True(abs(ship.Turn) > .5f);
Assert.Equal(degrees > 0 ? 1f : -1f, sign(ship.Turn));
}
}

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;
}