Files
game-engine/managed/Engine.Runtime/Input.cs
T
1d88bc42d2 [P7-07] Android player engine-side: touch input, lifecycle, ABI 4.28 (#215)
* parity: 136 Android player engine-side: touch input, lifecycle, ABI 4.28

* fix(input,ci): address CodeRabbit & SonarCloud findings in touch input and Android CI

* parity: P7-07 Android CI review (independent probes, -no-window, read-only perms)

* parity: P7-07 notes renumber ABI 4.24 -> 4.28

* parity: P7-07 ABI layout checks to 4.28 (1384B table, 1744B input)

---------

Co-authored-by: Hermes <hermes@local>
Co-authored-by: John Smith <[email protected]>
Co-authored-by: Hermes <[email protected]>
2026-11-06 13:13:23 -05:00

466 lines
13 KiB
C#

// Input.cs — P2-02: C# Input API + JSON action maps.
//
// Static Input reads the native EngineInputState snapshot zero-copy
// (input_get_state borrows renderer memory; valid until the next frame
// pump). Key enum values equal SDL scancodes (SDL_scancode.h), so a C#
// Key casts directly to the native table index. Action maps load once
// from input_actions.json ({"axes":{"Horizontal":{"negative":["A","Left"],
// "positive":["D","Right"],"gamepadAxis":"LeftX"}}}) via the static
// constructor + Reload for tests. No managed gameplay state:
// Input only views the snapshot pointer + parsed map (load-once config,
// not frame state).
using System.Text.Json;
using Engine.Bindings;
namespace Engine.Runtime;
/// <summary>Keyboard key. Values equal SDL scancodes (SDL_scancode.h).</summary>
public enum Key
{
Unknown = 0,
A = 4,
B = 5,
C = 6,
D = 7,
E = 8,
F = 9,
G = 10,
H = 11,
I = 12,
J = 13,
K = 14,
L = 15,
M = 16,
N = 17,
O = 18,
P = 19,
Q = 20,
R = 21,
S = 22,
T = 23,
U = 24,
V = 25,
W = 26,
X = 27,
Y = 28,
Z = 29,
D1 = 30,
D2 = 31,
D3 = 32,
D4 = 33,
D5 = 34,
D6 = 35,
D7 = 36,
D8 = 37,
D9 = 38,
D0 = 39,
Return = 40,
Escape = 41,
Backspace = 42,
Tab = 43,
Space = 44,
Minus = 45,
Equals = 46,
Leftbracket = 47,
Rightbracket = 48,
Backslash = 49,
Nonushash = 50,
Semicolon = 51,
Apostrophe = 52,
Grave = 53,
Comma = 54,
Period = 55,
Slash = 56,
Capslock = 57,
F1 = 58,
F2 = 59,
F3 = 60,
F4 = 61,
F5 = 62,
F6 = 63,
F7 = 64,
F8 = 65,
F9 = 66,
F10 = 67,
F11 = 68,
F12 = 69,
Printscreen = 70,
Scrolllock = 71,
Pause = 72,
Insert = 73,
Home = 74,
Pageup = 75,
Delete = 76,
End = 77,
Pagedown = 78,
Right = 79,
Left = 80,
Down = 81,
Up = 82,
Numlockclear = 83,
KpDivide = 84,
KpMultiply = 85,
KpMinus = 86,
KpPlus = 87,
KpEnter = 88,
Kp1 = 89,
Kp2 = 90,
Kp3 = 91,
Kp4 = 92,
Kp5 = 93,
Kp6 = 94,
Kp7 = 95,
Kp8 = 96,
Kp9 = 97,
Kp0 = 98,
KpPeriod = 99,
Nonusbackslash = 100,
Application = 101,
Power = 102,
KpEquals = 103,
F13 = 104,
F14 = 105,
F15 = 106,
F16 = 107,
F17 = 108,
F18 = 109,
F19 = 110,
F20 = 111,
F21 = 112,
F22 = 113,
F23 = 114,
F24 = 115,
Execute = 116,
Help = 117,
Menu = 118,
Select = 119,
Stop = 120,
Again = 121,
Undo = 122,
Cut = 123,
Copy = 124,
Paste = 125,
Find = 126,
Mute = 127,
Volumeup = 128,
Volumedown = 129,
KpComma = 133,
KpEqualsas400 = 134,
International1 = 135,
International2 = 136,
International3 = 137,
International4 = 138,
International5 = 139,
International6 = 140,
International7 = 141,
International8 = 142,
International9 = 143,
Lang1 = 144,
Lang2 = 145,
Lang3 = 146,
Lang4 = 147,
Lang5 = 148,
Lang6 = 149,
Lang7 = 150,
Lang8 = 151,
Lang9 = 152,
Alterase = 153,
Sysreq = 154,
Cancel = 155,
Clear = 156,
Prior = 157,
Return2 = 158,
Separator = 159,
Out = 160,
Oper = 161,
Clearagain = 162,
Crsel = 163,
Exsel = 164,
Kp00 = 176,
Kp000 = 177,
Thousandsseparator = 178,
Decimalseparator = 179,
Currencyunit = 180,
Currencysubunit = 181,
KpLeftparen = 182,
KpRightparen = 183,
KpLeftbrace = 184,
KpRightbrace = 185,
KpTab = 186,
KpBackspace = 187,
KpA = 188,
KpB = 189,
KpC = 190,
KpD = 191,
KpE = 192,
KpF = 193,
KpXor = 194,
KpPower = 195,
KpPercent = 196,
KpLess = 197,
KpGreater = 198,
KpAmpersand = 199,
KpDblampersand = 200,
KpVerticalbar = 201,
KpDblverticalbar = 202,
KpColon = 203,
KpHash = 204,
KpSpace = 205,
KpAt = 206,
KpExclam = 207,
KpMemstore = 208,
KpMemrecall = 209,
KpMemclear = 210,
KpMemadd = 211,
KpMemsubtract = 212,
KpMemmultiply = 213,
KpMemdivide = 214,
KpPlusminus = 215,
KpClear = 216,
KpClearentry = 217,
KpBinary = 218,
KpOctal = 219,
KpDecimal = 220,
KpHexadecimal = 221,
Lctrl = 224,
Lshift = 225,
Lalt = 226,
Lgui = 227,
Rctrl = 228,
Rshift = 229,
Ralt = 230,
Rgui = 231,
Mode = 257,
Sleep = 258,
Wake = 259,
ChannelIncrement = 260,
ChannelDecrement = 261,
MediaPlay = 262,
MediaPause = 263,
MediaRecord = 264,
MediaFastForward = 265,
MediaRewind = 266,
MediaNextTrack = 267,
MediaPreviousTrack = 268,
MediaStop = 269,
MediaEject = 270,
MediaPlayPause = 271,
MediaSelect = 272,
AcNew = 273,
AcOpen = 274,
AcClose = 275,
AcExit = 276,
AcSave = 277,
AcPrint = 278,
AcProperties = 279,
AcSearch = 280,
AcHome = 281,
AcBack = 282,
AcForward = 283,
AcStop = 284,
AcRefresh = 285,
AcBookmarks = 286,
Softleft = 287,
Softright = 288,
Call = 289,
Endcall = 290,
Reserved = 400,
Count = 512,
}
/// <summary>
/// Zero-copy input API over the native EngineInputState snapshot
/// (input_get_state borrows renderer memory; valid until the next pump).
/// Feed a snapshot via SetTestState for tests (live path: InputState* from
/// the ABI table entry); GetAxis resolves the load-once action map
/// (Horizontal default; D/Right - A/Left, else gamepad LeftX).
/// P7-07 touch: GetTouch reads the 8-slot normalised table (primary
/// finger mirrors to MousePosition/Mouse button 1); Paused/SurfaceLost
/// expose the lifecycle latches (consume via input_lifecycle on live).
/// </summary>
public static unsafe class Input
{
private static NativeApi.InputState* _state;
private static Dictionary<string, AxisMap> _axes = new(StringComparer.Ordinal);
static Input()
{
Reload(LoadDefaultActions());
}
/// <summary>Held (scancode index; out-of-range reads released).</summary>
public static bool GetKey(Key key) => _state != null && (int)key >= 0 && (int)key < 512 && _state->Keys[(int)key] != 0;
/// <summary>Went down this frame.</summary>
public static bool GetKeyDown(Key key) => _state != null && (int)key >= 0 && (int)key < 512 && _state->Pressed[(int)key] != 0;
/// <summary>Went up this frame.</summary>
public static bool GetKeyUp(Key key) => _state != null && (int)key >= 0 && (int)key < 512 && _state->Released[(int)key] != 0;
/// <summary>Last motion position (pixels).</summary>
public static (float X, float Y) MousePosition => _state != null ? (_state->MouseX, _state->MouseY) : (0.0f, 0.0f);
/// <summary>Accumulated motion this frame (pixels; clears on begin_frame).</summary>
public static (float X, float Y) MouseDelta => _state != null ? (_state->MouseDx, _state->MouseDy) : (0.0f, 0.0f);
/// <summary>SDL mouse button held (1=left, 2=middle, 3=right, 4=X1, 5=X2).</summary>
public static bool GetMouseButton(int button) =>
_state != null && button >= 1 && button <= 32 && (_state->MouseButtons & (1u << (button - 1))) != 0;
/// <summary>Gamepad axis normalised to [-1, 1] (unknown pad reads 0).</summary>
public static float GetGamepadAxis(int pad, int axis) =>
_state != null && (uint)pad < 4 && (uint)axis < 6 ? _state->PadAxes[pad * 6 + axis] : 0.0f;
/// <summary>Gamepad button held (SDL button ordinal; unknown pad released).</summary>
public static bool GetGamepadButton(int pad, int button) =>
_state != null && (uint)pad < 4 && (uint)button < 32 && (_state->PadButtons[pad] & (1u << button)) != 0;
/// <summary>Active touch count (slots with finger down, max 8).</summary>
public static int TouchCount => _state != null ? _state->TouchCount : 0;
/// <summary>Touch slot position (normalised 0..1; OOB reads 0).</summary>
public static (float X, float Y) GetTouch(int slot) =>
_state != null && (uint)slot < 8 ? (_state->TouchX[slot], _state->TouchY[slot]) : (0.0f, 0.0f);
/// <summary>Touch slot pressed edge this frame (OOB reads false).</summary>
public static bool GetTouchDown(int slot) =>
_state != null && (uint)slot < 8 && _state->TouchPressed[slot] != 0;
/// <summary>True after background event until foreground (P7-07 lifecycle).</summary>
public static bool Paused => _state != null && _state->Paused != 0;
/// <summary>Sticky surface-loss latch (P7-07; consumed live via input_lifecycle).</summary>
public static bool SurfaceLost => _state != null && _state->SurfaceLost != 0;
/// <summary>
/// Action-map axis in [-1, 1]: +1 when any positive key held, -1 when any
/// negative key held, 0 when both sides held, else the mapped gamepad
/// axis (LeftX = pad 0 axis 0). Unknown axis name reads 0.
/// </summary>
public static float GetAxis(string name)
{
if (!_axes.TryGetValue(name, out var map))
{
return 0.0f;
}
var pos = AnyDown(map.Positive);
var neg = AnyDown(map.Negative);
if (pos && neg)
{
return 0.0f;
}
if (pos)
{
return 1.0f;
}
if (neg)
{
return -1.0f;
}
// No keys held: fall back to the mapped gamepad axis. LeftY is
// negated — SDL reports stick-up as negative Y, but the Vertical
// action is positive-up (W/Up = +1), so raw LeftY would drive
// backwards. Other axes pass through unchanged.
var gamepad = GamepadAxisByName(map.GamepadAxis);
return map.GamepadAxis == "LeftY" ? -gamepad : gamepad;
}
/// <summary>Bind the live native snapshot (P1-09 renderer_frame path).</summary>
public static void SetState(NativeApi.InputState* state) => _state = state;
/// <summary>Replace the action map (tests + input_actions.json load).</summary>
public static void Reload(string json) => _axes = ParseAxes(json);
/// <summary>Load action maps from input_actions.json next to the game assembly (once at startup; missing file keeps defaults).</summary>
public static void LoadActionMaps(string gameAssemblyPath)
{
var dir = Path.GetDirectoryName(Path.GetFullPath(gameAssemblyPath));
if (dir == null)
{
return;
}
var path = Path.Combine(dir, "input_actions.json");
if (!File.Exists(path))
{
return;
}
Reload(File.ReadAllText(path));
}
private static bool AnyDown(Key[] keys)
{
foreach (var k in keys)
{
if (GetKey(k))
{
return true;
}
}
return false;
}
private static float GamepadAxisByName(string name) => name switch
{
"LeftX" => GetGamepadAxis(0, 0),
"LeftY" => GetGamepadAxis(0, 1),
"RightX" => GetGamepadAxis(0, 2),
"RightY" => GetGamepadAxis(0, 3),
"LeftTrigger" => GetGamepadAxis(0, 4),
"RightTrigger" => GetGamepadAxis(0, 5),
_ => 0.0f,
};
private static string LoadDefaultActions() =>
"{\"axes\":{\"Horizontal\":{\"negative\":[\"A\",\"Left\"],\"positive\":[\"D\",\"Right\"],\"gamepadAxis\":\"LeftX\"}}}";
private sealed record AxisMap(Key[] Positive, Key[] Negative, string GamepadAxis);
private static Dictionary<string, AxisMap> ParseAxes(string json)
{
var axes = new Dictionary<string, AxisMap>(StringComparer.Ordinal);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("axes"u8, out var root))
{
return axes;
}
foreach (var prop in root.EnumerateObject())
{
var positive = ParseKeys(prop.Value, "positive");
var negative = ParseKeys(prop.Value, "negative");
var pad = prop.Value.TryGetProperty("gamepadAxis"u8, out var g) && g.ValueKind == JsonValueKind.String
? g.GetString() ?? string.Empty
: string.Empty;
axes[prop.Name] = new AxisMap(positive, negative, pad);
}
return axes;
}
private static Key[] ParseKeys(JsonElement axis, string prop)
{
if (!axis.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
{
return [];
}
var keys = new List<Key>();
foreach (var el in arr.EnumerateArray())
{
if (el.ValueKind == JsonValueKind.String && Enum.TryParse<Key>(el.GetString(), out var key))
{
keys.Add(key);
}
}
return [.. keys];
}
}