diff --git a/Assets/Resources/Aetheria.inputactions b/Assets/Resources/Aetheria.inputactions index 86044799b..fe1ef6086 100644 --- a/Assets/Resources/Aetheria.inputactions +++ b/Assets/Resources/Aetheria.inputactions @@ -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": "/leftStick", + "path": "2DVector(mode=2)", + "interactions": "", + "processors": "", + "groups": "", + "action": "Move", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "e9c57b4d-1cd8-4b2f-93d6-1745a37cabc9", + "path": "/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": "/leftStick/down", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "1ce4a07f-43af-4e82-a631-dbb43c8191d3", + "path": "/leftShoulder", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "bd1aed7e-b80b-4341-a550-4ef10bacf4b4", + "path": "/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": "/a", + "path": "/q", "interactions": "", "processors": "", "groups": ";Keyboard&Mouse", @@ -209,7 +290,7 @@ { "name": "right", "id": "fcfe95b8-67b9-4526-84b5-5d0bc98d6400", - "path": "/d", + "path": "/e", "interactions": "", "processors": "", "groups": ";Keyboard&Mouse", @@ -418,7 +499,7 @@ { "name": "negative", "id": "8536ca60-1fed-4f29-8060-04d46cfffcab", - "path": "/q", + "path": "/a", "interactions": "", "processors": "", "groups": "", @@ -429,13 +510,57 @@ { "name": "positive", "id": "19c175bf-d4e6-4626-8bee-91e0c473a94b", - "path": "/e", + "path": "/d", "interactions": "", "processors": "", "groups": "", "action": "Turn", "isComposite": false, "isPartOfComposite": true + }, + { + "name": "", + "id": "fdff8f1f-0824-49bd-b09a-c1b792f16299", + "path": "/leftStick/x", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Turn", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "1ee2fadd-3db4-45e6-8fa4-7a621ef7d165", + "path": "/leftShift", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Face Aim", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "e9520d95-0458-439b-a37c-84c45237e110", + "path": "/capsLock", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Toggle Face Aim", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "596ed367-be34-4951-9aac-d8caa57b411f", + "path": "/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": [ diff --git a/Assets/Scripts/AetheriaInput.cs b/Assets/Scripts/AetheriaInput.cs index 938444fef..373c0b8bb 100644 --- a/Assets/Scripts/AetheriaInput.cs +++ b/Assets/Scripts/AetheriaInput.cs @@ -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"": ""/leftStick"", + ""path"": ""2DVector(mode=2)"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""Move"", + ""isComposite"": true, + ""isPartOfComposite"": false + }, + { + ""name"": ""up"", + ""id"": ""e9c57b4d-1cd8-4b2f-93d6-1745a37cabc9"", + ""path"": ""/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"": ""/leftStick/down"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Gamepad"", + ""action"": ""Move"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""left"", + ""id"": ""1ce4a07f-43af-4e82-a631-dbb43c8191d3"", + ""path"": ""/leftShoulder"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Gamepad"", + ""action"": ""Move"", + ""isComposite"": false, + ""isPartOfComposite"": true + }, + { + ""name"": ""right"", + ""id"": ""bd1aed7e-b80b-4341-a550-4ef10bacf4b4"", + ""path"": ""/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"": ""/a"", + ""path"": ""/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"": ""/d"", + ""path"": ""/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"": ""/q"", + ""path"": ""/a"", ""interactions"": """", ""processors"": """", ""groups"": """", @@ -532,13 +596,57 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable { ""name"": ""positive"", ""id"": ""19c175bf-d4e6-4626-8bee-91e0c473a94b"", - ""path"": ""/e"", + ""path"": ""/d"", ""interactions"": """", ""processors"": """", ""groups"": """", ""action"": ""Turn"", ""isComposite"": false, ""isPartOfComposite"": true + }, + { + ""name"": """", + ""id"": ""fdff8f1f-0824-49bd-b09a-c1b792f16299"", + ""path"": ""/leftStick/x"", + ""interactions"": """", + ""processors"": """", + ""groups"": "";Gamepad"", + ""action"": ""Turn"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, + { + ""name"": """", + ""id"": ""1ee2fadd-3db4-45e6-8fa4-7a621ef7d165"", + ""path"": ""/leftShift"", + ""interactions"": """", + ""processors"": """", + ""groups"": ""Keyboard&Mouse"", + ""action"": ""Face Aim"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, + { + ""name"": """", + ""id"": ""e9520d95-0458-439b-a37c-84c45237e110"", + ""path"": ""/capsLock"", + ""interactions"": """", + ""processors"": """", + ""groups"": ""Keyboard&Mouse"", + ""action"": ""Toggle Face Aim"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, + { + ""name"": """", + ""id"": ""596ed367-be34-4951-9aac-d8caa57b411f"", + ""path"": ""/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; /// /// Provides access to input actions defined in input action map "Player". /// @@ -1401,6 +1513,14 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// public InputAction @Turn => m_Wrapper.m_Player_Turn; /// + /// Provides access to the underlying input action "Player/FaceAim". + /// + public InputAction @FaceAim => m_Wrapper.m_Player_FaceAim; + /// + /// Provides access to the underlying input action "Player/ToggleFaceAim". + /// + public InputAction @ToggleFaceAim => m_Wrapper.m_Player_ToggleFaceAim; + /// /// Provides access to the underlying input action map instance. /// 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; } /// @@ -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; } /// @@ -2059,6 +2191,20 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// /// void OnTurn(InputAction.CallbackContext context); + /// + /// Method invoked when associated input action "Face Aim" is either , or . + /// + /// + /// + /// + void OnFaceAim(InputAction.CallbackContext context); + /// + /// Method invoked when associated input action "Toggle Face Aim" is either , or . + /// + /// + /// + /// + void OnToggleFaceAim(InputAction.CallbackContext context); } /// /// Interface to implement callback methods for all input action callbacks associated with input actions defined by "UI" which allows adding and removing callbacks. diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index d65ce15c0..7972d57ea 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -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 _visibleHostileIndicators = new Dictionary(); @@ -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(); + _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().ToCultMath(); + _helm.Held = Input.Player.FaceAim.IsPressed(); + ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue()); } UpdateFireControlDebug(CurrentEntity.Target.Value); diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 31b6d093c..fb356dd75 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -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) diff --git a/Assets/Scripts/Gameplay/ShipInstance.cs b/Assets/Scripts/Gameplay/ShipInstance.cs index 8f4fe40bf..cc110fc8f 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -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) { diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index a3302a93e..876dda1f6 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -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!"); diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index e9710b464..1d51a855f 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index c93fb3c99..092c6af4c 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -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! diff --git a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs index 39c4e1135..b677cf0cd 100644 --- a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs +++ b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs @@ -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); } } diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index 6b88f4ba4..16f56b72d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index a5a395373..654ee5ec1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -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) { diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 853bea91e..bcd6ad7ab 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -55,7 +55,9 @@ public abstract class Entity // notice and clear it. public ReactiveProperty TargetItem = new ReactiveProperty((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); } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 768077deb..af93d7af3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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; diff --git a/Assets/Scripts/ServerShared/HelmInput.cs b/Assets/Scripts/ServerShared/HelmInput.cs new file mode 100644 index 000000000..fe1316aff --- /dev/null +++ b/Assets/Scripts/ServerShared/HelmInput.cs @@ -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); +} diff --git a/Assets/Scripts/ServerShared/HelmInput.cs.meta b/Assets/Scripts/ServerShared/HelmInput.cs.meta new file mode 100644 index 000000000..400e47215 --- /dev/null +++ b/Assets/Scripts/ServerShared/HelmInput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f863bb50e53ae30408f5ef8be1462e12 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index b257f1cc9..7a1d40e86 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -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 _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); diff --git a/Assets/Scripts/ServerShared/Steering.cs b/Assets/Scripts/ServerShared/Steering.cs new file mode 100644 index 000000000..de477332f --- /dev/null +++ b/Assets/Scripts/ServerShared/Steering.cs @@ -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); + } +} diff --git a/Assets/Scripts/ServerShared/Steering.cs.meta b/Assets/Scripts/ServerShared/Steering.cs.meta new file mode 100644 index 000000000..bf9a98148 --- /dev/null +++ b/Assets/Scripts/ServerShared/Steering.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8b84ac5db42877d45b7f6286cc2e2b93 \ No newline at end of file diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index b6021027a..5ef289cde 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -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) diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index e9a2dbf11..660b4106b 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -121,11 +121,9 @@ public sealed class BrownoutTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("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) diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index 2ff03d18f..9781f0ec9 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -95,7 +95,7 @@ public sealed class ConditionRatioTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("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"); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 92b26bbce..c6b1bb00c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -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"); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index a978aed7f..ffde777bc 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -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); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 811fc3a47..c5aa51857 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -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); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index edca68dde..60bc55e00 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -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) }; diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 33c90eeba..a6d40a089 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -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); + } } diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 4c56b5ff6..e77bfad51 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -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; diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 1170ade7a..496955b98 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -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; diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index d6880d32d..785854911 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -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; diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index 5cfd6d72b..64304e066 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -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); diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 328009b24..229cb042f 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -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"); + internal static GameplaySettings Settings() => AuthoredSettings.Load(FindRepoRoot()).Read("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(name); var thrusterDesigns = cache.GetAll().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 diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 44d64db50..cfd5cc174 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -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)); } diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs new file mode 100644 index 000000000..b87e91f71 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -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.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().ToArray(); + var nose = normalize(ship.Direction); + var right = Right(ship); + + ship.Turn = turn; + ship.Update(dt); + + float Rate(Func 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)); + } +} diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index 29bb3f954..bc6af3597 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -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; }