@forgeax/engine-input 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +312 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/browser-backend-dual-instance.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-dual-instance.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-focus-loss.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-focus-loss.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-error.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-error.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-gate.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-gate.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-release.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-release.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-state.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-state.test.d.ts.map +1 -0
- package/dist/__tests__/canvas-input-boundary.test.d.ts +2 -0
- package/dist/__tests__/canvas-input-boundary.test.d.ts.map +1 -0
- package/dist/__tests__/canvas-input-owner.test-d.d.ts +2 -0
- package/dist/__tests__/canvas-input-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/composite-backend.test.d.ts +2 -0
- package/dist/__tests__/composite-backend.test.d.ts.map +1 -0
- package/dist/__tests__/controller-platform-owner.test-d.d.ts +2 -0
- package/dist/__tests__/controller-platform-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/input-snapshot.test-d.d.ts +2 -0
- package/dist/__tests__/input-snapshot.test-d.d.ts.map +1 -0
- package/dist/__tests__/input-snapshot.test.d.ts +2 -0
- package/dist/__tests__/input-snapshot.test.d.ts.map +1 -0
- package/dist/__tests__/swipe-direction-owner.test-d.d.ts +2 -0
- package/dist/__tests__/swipe-direction-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/ui-input-matrix.test.d.ts +2 -0
- package/dist/__tests__/ui-input-matrix.test.d.ts.map +1 -0
- package/dist/__tests__/ui-ownership-edge.test.d.ts +2 -0
- package/dist/__tests__/ui-ownership-edge.test.d.ts.map +1 -0
- package/dist/__tests__/ui-ownership.test.d.ts +2 -0
- package/dist/__tests__/ui-ownership.test.d.ts.map +1 -0
- package/dist/__tests__/ui-reset-idempotence.test.d.ts +2 -0
- package/dist/__tests__/ui-reset-idempotence.test.d.ts.map +1 -0
- package/dist/__tests__/ui-reset.test.d.ts +2 -0
- package/dist/__tests__/ui-reset.test.d.ts.map +1 -0
- package/dist/action-state.d.ts +146 -0
- package/dist/action-state.d.ts.map +1 -0
- package/dist/browser-backend.d.ts +121 -0
- package/dist/browser-backend.d.ts.map +1 -0
- package/dist/canvas-input-boundary.d.ts +13 -0
- package/dist/canvas-input-boundary.d.ts.map +1 -0
- package/dist/composite-backend.d.ts +34 -0
- package/dist/composite-backend.d.ts.map +1 -0
- package/dist/controller-db-data.d.ts +5 -0
- package/dist/controller-db-data.d.ts.map +1 -0
- package/dist/controller-db-data.mjs +12 -0
- package/dist/controller-db-data.mjs.map +1 -0
- package/dist/controller-db.d.ts +70 -0
- package/dist/controller-db.d.ts.map +1 -0
- package/dist/controller-db.mjs +119 -0
- package/dist/controller-db.mjs.map +1 -0
- package/dist/frame-start-scan-system.d.ts +37 -0
- package/dist/frame-start-scan-system.d.ts.map +1 -0
- package/dist/gamepad-frame.d.ts +42 -0
- package/dist/gamepad-frame.d.ts.map +1 -0
- package/dist/gesture-recognizer.d.ts +145 -0
- package/dist/gesture-recognizer.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1502 -0
- package/dist/index.mjs.map +1 -0
- package/dist/input-snapshot.d.ts +420 -0
- package/dist/input-snapshot.d.ts.map +1 -0
- package/dist/plugin-service.d.ts +11 -0
- package/dist/plugin-service.d.ts.map +1 -0
- package/dist/ui-ownership.d.ts +17 -0
- package/dist/ui-ownership.d.ts.map +1 -0
- package/dist/virtual-joystick.d.ts +39 -0
- package/dist/virtual-joystick.d.ts.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/browser-backend-dual-instance.test.ts +379 -0
- package/src/__tests__/browser-backend-focus-loss.test.ts +239 -0
- package/src/__tests__/browser-backend-lock-error.test.ts +425 -0
- package/src/__tests__/browser-backend-lock-gate.test.ts +283 -0
- package/src/__tests__/browser-backend-lock-release.test.ts +641 -0
- package/src/__tests__/browser-backend-lock-state.test.ts +604 -0
- package/src/__tests__/canvas-input-boundary.test.ts +65 -0
- package/src/__tests__/canvas-input-owner.test-d.ts +39 -0
- package/src/__tests__/composite-backend.test.ts +299 -0
- package/src/__tests__/controller-platform-owner.test-d.ts +46 -0
- package/src/__tests__/input-snapshot.test-d.ts +78 -0
- package/src/__tests__/input-snapshot.test.ts +96 -0
- package/src/__tests__/swipe-direction-owner.test-d.ts +45 -0
- package/src/__tests__/ui-input-matrix.test.ts +31 -0
- package/src/__tests__/ui-ownership-edge.test.ts +33 -0
- package/src/__tests__/ui-ownership.test.ts +35 -0
- package/src/__tests__/ui-reset-idempotence.test.ts +17 -0
- package/src/__tests__/ui-reset.test.ts +25 -0
- package/src/action-state.ts +380 -0
- package/src/browser-backend.ts +884 -0
- package/src/canvas-input-boundary.ts +81 -0
- package/src/composite-backend.ts +189 -0
- package/src/controller-db-data.ts +25 -0
- package/src/controller-db.ts +218 -0
- package/src/frame-start-scan-system.ts +93 -0
- package/src/gamepad-frame.ts +259 -0
- package/src/gesture-recognizer.ts +495 -0
- package/src/index.ts +101 -0
- package/src/input-snapshot.ts +732 -0
- package/src/plugin-service.ts +31 -0
- package/src/ui-ownership.ts +61 -0
- package/src/virtual-joystick.ts +103 -0
- package/vendor/gamecontrollerdb.txt +2114 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { isUiOwnedEvent } from '../ui-ownership';
|
|
6
|
+
|
|
7
|
+
describe('UI event ownership', () => {
|
|
8
|
+
it('uses composedPath for a shadow-dom event', () => {
|
|
9
|
+
const host = document.createElement('div');
|
|
10
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
11
|
+
const input = document.createElement('input');
|
|
12
|
+
shadow.append(input);
|
|
13
|
+
const event = new Event('keydown', { bubbles: true, composed: true });
|
|
14
|
+
input.dispatchEvent(event);
|
|
15
|
+
|
|
16
|
+
expect(isUiOwnedEvent(event, host)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('does not claim an unrelated gameplay event', () => {
|
|
20
|
+
const host = document.createElement('div');
|
|
21
|
+
const canvas = document.createElement('canvas');
|
|
22
|
+
const event = new Event('keydown', { bubbles: true });
|
|
23
|
+
canvas.dispatchEvent(event);
|
|
24
|
+
|
|
25
|
+
expect(isUiOwnedEvent(event, host)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('treats a direct root event as owned', () => {
|
|
29
|
+
const host = document.createElement('div');
|
|
30
|
+
const event = new Event('keydown');
|
|
31
|
+
host.dispatchEvent(event);
|
|
32
|
+
|
|
33
|
+
expect(isUiOwnedEvent(event, host)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createUiInputResetBoundary } from '../ui-ownership';
|
|
4
|
+
|
|
5
|
+
describe('UI reset idempotence', () => {
|
|
6
|
+
it('can be disposed and reset repeatedly without throwing', () => {
|
|
7
|
+
const clear = vi.fn();
|
|
8
|
+
const boundary = createUiInputResetBoundary({ clear });
|
|
9
|
+
|
|
10
|
+
boundary.reset();
|
|
11
|
+
boundary.dispose();
|
|
12
|
+
boundary.dispose();
|
|
13
|
+
boundary.reset();
|
|
14
|
+
|
|
15
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createUiInputResetBoundary } from '../ui-ownership';
|
|
4
|
+
|
|
5
|
+
describe('UI input reset boundary', () => {
|
|
6
|
+
it('clears a held key when ownership enters the UI', () => {
|
|
7
|
+
const clear = vi.fn();
|
|
8
|
+
const boundary = createUiInputResetBoundary({ clear });
|
|
9
|
+
|
|
10
|
+
boundary.reset();
|
|
11
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
12
|
+
boundary.dispose();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('resets from an abort signal exactly once', () => {
|
|
16
|
+
const clear = vi.fn();
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const boundary = createUiInputResetBoundary({ clear, signal: controller.signal });
|
|
19
|
+
|
|
20
|
+
controller.abort();
|
|
21
|
+
controller.abort();
|
|
22
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
23
|
+
boundary.dispose();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// action-state.ts — action mapping pure-function module for forgeax-engine.
|
|
2
|
+
//
|
|
3
|
+
// Derives per-frame ActionState[] from an InputBackendSample and an InputMap.
|
|
4
|
+
// Zero ECS/DOM dependencies; fully node-testable.
|
|
5
|
+
//
|
|
6
|
+
// charter awareness:
|
|
7
|
+
// P3 explicit failure — unmapped action names return empty signal, never throw
|
|
8
|
+
// P4 consistent abstraction — consumer only sees action name, never device
|
|
9
|
+
|
|
10
|
+
import type { GamepadAxisIndex, GamepadButtonIndex, InputBackendSample } from './input-snapshot';
|
|
11
|
+
|
|
12
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Closed 4-member discriminant union for mapping raw input sources to actions.
|
|
16
|
+
* The `type` field is the discriminant; consumers switch on it with no `default`
|
|
17
|
+
* branch (AC-08b: TypeScript checks exhaustiveness at compile time).
|
|
18
|
+
*/
|
|
19
|
+
export type ActionBinding =
|
|
20
|
+
| { readonly type: 'key'; readonly key: string }
|
|
21
|
+
| { readonly type: 'mouseButton'; readonly button: 0 | 1 | 2 }
|
|
22
|
+
| { readonly type: 'gamepadButton'; readonly button: GamepadButtonIndex }
|
|
23
|
+
| {
|
|
24
|
+
readonly type: 'gamepadAxis';
|
|
25
|
+
readonly axis: GamepadAxisIndex;
|
|
26
|
+
/**
|
|
27
|
+
* Sign applied to the raw axis value before aggregation.
|
|
28
|
+
* - Omitted: contributes |value| (trigger semantics, e.g. right trigger on axis 2).
|
|
29
|
+
* - `1`: contributes max(0, value) (positive direction).
|
|
30
|
+
* - `-1`: contributes max(0, -value) (negative direction, e.g. left stick).
|
|
31
|
+
*/
|
|
32
|
+
readonly sign?: 1 | -1;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An action definition in the InputMap: a semantic name + one or more bindings
|
|
37
|
+
* to raw input sources, plus an optional per-action deadzone override.
|
|
38
|
+
*/
|
|
39
|
+
export interface ActionConfig {
|
|
40
|
+
/** Semantic action name (e.g. 'jump', 'moveRight'). Referenced by snap.action(name). */
|
|
41
|
+
readonly action: string;
|
|
42
|
+
/** Raw input bindings that contribute to this action. Aggregated via OR/MAX. */
|
|
43
|
+
readonly bindings: readonly ActionBinding[];
|
|
44
|
+
/**
|
|
45
|
+
* Per-action deadzone applied to analog strength remapping.
|
|
46
|
+
* Default: 0.2 (Godot prior-art, F2). Analog values with |raw| < deadzone
|
|
47
|
+
* produce strength=0. Values >= deadzone are linearly remapped into [0,1].
|
|
48
|
+
*/
|
|
49
|
+
readonly deadzone?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-frame derived action state. Returned by deriveActionStates(); consumed
|
|
54
|
+
* by snap.action(name) readpoints. All fields are frame-frozen (AC-11).
|
|
55
|
+
*/
|
|
56
|
+
export interface ActionState {
|
|
57
|
+
readonly action: string;
|
|
58
|
+
/** true when ANY binding contributes a press (OR aggregation). */
|
|
59
|
+
readonly pressed: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* true only on the frame when pressed transitions false→true.
|
|
62
|
+
* One-frame lifetime; held state does not re-fire (AC-03).
|
|
63
|
+
*/
|
|
64
|
+
readonly justPressed: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* true only on the frame when pressed transitions true→false.
|
|
67
|
+
* One-frame lifetime (AC-03).
|
|
68
|
+
*/
|
|
69
|
+
readonly justReleased: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Deadzone-remapped analog strength in [0, 1]. Digital press → 1.0.
|
|
72
|
+
* Analog values < deadzone → 0. Aggregate: MAX across bindings.
|
|
73
|
+
*/
|
|
74
|
+
readonly strength: number;
|
|
75
|
+
/**
|
|
76
|
+
* Raw un-deadzoned magnitude in [0, 1]. Used by getVector (which applies its
|
|
77
|
+
* own single radial deadzone to avoid per-axis deadzone stacking, F2).
|
|
78
|
+
* Aggregate: MAX across bindings.
|
|
79
|
+
*/
|
|
80
|
+
readonly raw: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Constants ───────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resource key for the immutable InputMap (ActionConfig[]) inserted into
|
|
87
|
+
* the World. Canvas-form createApp does this automatically; assemble-form
|
|
88
|
+
* hosts insert it directly (D-7).
|
|
89
|
+
*/
|
|
90
|
+
export const INPUT_MAP_KEY = 'InputMap';
|
|
91
|
+
|
|
92
|
+
/** Default per-action deadzone (Godot prior-art, F2). */
|
|
93
|
+
const DEFAULT_DEADZONE = 0.2;
|
|
94
|
+
|
|
95
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Inverse lerp: maps a value from [a, b] to [0, 1] linearly.
|
|
99
|
+
* Returns 0 when val <= a, 1 when val >= b.
|
|
100
|
+
*/
|
|
101
|
+
function inverseLerp(a: number, b: number, val: number): number {
|
|
102
|
+
if (val <= a) return 0;
|
|
103
|
+
if (val >= b) return 1;
|
|
104
|
+
return (val - a) / (b - a);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Deadzone remap: maps |raw| from [deadzone, 1] to [0, 1].
|
|
109
|
+
* |raw| < deadzone → 0. |raw| >= deadzone → inverse_lerp(deadzone, 1, |raw|).
|
|
110
|
+
*/
|
|
111
|
+
function applyDeadzone(rawAbs: number, deadzone: number): number {
|
|
112
|
+
return rawAbs < deadzone ? 0 : inverseLerp(deadzone, 1, rawAbs);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract the raw contribution from a single binding against the sample.
|
|
117
|
+
* Returns { rawAbs: number, pressed: boolean }.
|
|
118
|
+
*
|
|
119
|
+
* - Digital types (key/mouseButton/gamepadButton): rawAbs = 1.0 if pressed, 0 otherwise.
|
|
120
|
+
* - Analog type (gamepadAxis): rawAbs = max(0, |value| or max(0, value*sign)).
|
|
121
|
+
* For gamepadAxis, aggregates across ALL connected standardMapping slots (D-9).
|
|
122
|
+
*/
|
|
123
|
+
function bindingContribution(
|
|
124
|
+
binding: ActionBinding,
|
|
125
|
+
sample: InputBackendSample,
|
|
126
|
+
): { rawAbs: number; pressed: boolean } {
|
|
127
|
+
switch (binding.type) {
|
|
128
|
+
case 'key': {
|
|
129
|
+
const down = sample.downKeys.has(binding.key);
|
|
130
|
+
return { rawAbs: down ? 1.0 : 0, pressed: down };
|
|
131
|
+
}
|
|
132
|
+
case 'mouseButton': {
|
|
133
|
+
const down = sample.buttons[binding.button] === true;
|
|
134
|
+
return { rawAbs: down ? 1.0 : 0, pressed: down };
|
|
135
|
+
}
|
|
136
|
+
case 'gamepadButton': {
|
|
137
|
+
// D-9: aggregate across ALL connected standardMapping slots.
|
|
138
|
+
const slots = sample.gamepads ?? [];
|
|
139
|
+
let anyPressed = false;
|
|
140
|
+
for (const slot of slots) {
|
|
141
|
+
if (!slot.standardMapping) continue;
|
|
142
|
+
if (slot.pressed.has(binding.button)) {
|
|
143
|
+
anyPressed = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { rawAbs: anyPressed ? 1.0 : 0, pressed: anyPressed };
|
|
148
|
+
}
|
|
149
|
+
case 'gamepadAxis': {
|
|
150
|
+
// D-9: aggregate across ALL connected standardMapping slots (MAX).
|
|
151
|
+
const slots = sample.gamepads ?? [];
|
|
152
|
+
let maxRawAbs = 0;
|
|
153
|
+
for (const slot of slots) {
|
|
154
|
+
if (!slot.standardMapping) continue;
|
|
155
|
+
const rawAxis = slot.axes[binding.axis];
|
|
156
|
+
const sign = binding.sign;
|
|
157
|
+
if (sign === undefined) {
|
|
158
|
+
// Trigger semantics: contribute |value|
|
|
159
|
+
const abs = Math.abs(rawAxis);
|
|
160
|
+
if (abs > maxRawAbs) maxRawAbs = abs;
|
|
161
|
+
} else if (sign === 1) {
|
|
162
|
+
const v = Math.max(0, rawAxis);
|
|
163
|
+
if (v > maxRawAbs) maxRawAbs = v;
|
|
164
|
+
} else {
|
|
165
|
+
// sign === -1
|
|
166
|
+
const v = Math.max(0, -rawAxis);
|
|
167
|
+
if (v > maxRawAbs) maxRawAbs = v;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { rawAbs: maxRawAbs, pressed: false };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── deriveActionStates ──────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Derive per-frame action states from a backend sample and an InputMap.
|
|
179
|
+
*
|
|
180
|
+
* Pure function with no side effects. Called once per frame by the
|
|
181
|
+
* frame-start scan system. The result is frozen into the InputSnapshot
|
|
182
|
+
* so all snap.action() calls in the same frame see identical state (AC-11).
|
|
183
|
+
*
|
|
184
|
+
* @param sample - One frame's raw input backend sample (POD).
|
|
185
|
+
* @param inputMap - ActionConfig array; duplicate action names → last-wins (D-8).
|
|
186
|
+
* @param prevActionStates - Previous frame's action states for edge diff
|
|
187
|
+
* (justPressed/justReleased derivation). Omitted on the first frame or
|
|
188
|
+
* when actions are not being tracked across frames.
|
|
189
|
+
* @returns ActionState[] with one entry per action name in inputMap (last-wins dedup).
|
|
190
|
+
*/
|
|
191
|
+
export function deriveActionStates(
|
|
192
|
+
sample: InputBackendSample,
|
|
193
|
+
inputMap: readonly ActionConfig[],
|
|
194
|
+
prevActionStates?: readonly ActionState[],
|
|
195
|
+
): ActionState[] {
|
|
196
|
+
// D-8: last-wins dedup — later configs overwrite earlier for same action name.
|
|
197
|
+
const deduped = new Map<string, ActionConfig>();
|
|
198
|
+
for (const config of inputMap) {
|
|
199
|
+
deduped.set(config.action, config);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Build prev lookup for edge diff.
|
|
203
|
+
const prevByAction = new Map<string, boolean>();
|
|
204
|
+
if (prevActionStates) {
|
|
205
|
+
for (const s of prevActionStates) {
|
|
206
|
+
prevByAction.set(s.action, s.pressed);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const results: ActionState[] = [];
|
|
211
|
+
|
|
212
|
+
for (const config of deduped.values()) {
|
|
213
|
+
const deadzone = config.deadzone ?? DEFAULT_DEADZONE;
|
|
214
|
+
|
|
215
|
+
// Aggregate across all bindings: OR for pressed, MAX for strength/raw.
|
|
216
|
+
let aggregatedPressed = false;
|
|
217
|
+
let aggregatedStrength = 0;
|
|
218
|
+
let aggregatedRaw = 0;
|
|
219
|
+
|
|
220
|
+
for (const binding of config.bindings) {
|
|
221
|
+
const { rawAbs } = bindingContribution(binding, sample);
|
|
222
|
+
|
|
223
|
+
// Update raw: MAX aggregation.
|
|
224
|
+
if (rawAbs > aggregatedRaw) aggregatedRaw = rawAbs;
|
|
225
|
+
|
|
226
|
+
// Update pressed: need to consider analog deadzone threshold.
|
|
227
|
+
if (rawAbs >= deadzone) {
|
|
228
|
+
aggregatedPressed = true;
|
|
229
|
+
const str = applyDeadzone(rawAbs, deadzone);
|
|
230
|
+
if (str > aggregatedStrength) aggregatedStrength = str;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Digital sources already contribute 1.0/0 rawAbs.
|
|
234
|
+
// For digital, rawAbs=1.0 >= deadzone(0.2) → pressed=true, strength=applyDeadzone(1.0,0.2)=1.0.
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Edge detection: justPressed / justReleased from prev frame's pressed state.
|
|
238
|
+
const prevPressed = prevByAction.get(config.action) ?? false;
|
|
239
|
+
const justPressed = aggregatedPressed && !prevPressed;
|
|
240
|
+
const justReleased = sample.focusReset !== true && !aggregatedPressed && prevPressed;
|
|
241
|
+
|
|
242
|
+
results.push({
|
|
243
|
+
action: config.action,
|
|
244
|
+
pressed: aggregatedPressed,
|
|
245
|
+
justPressed,
|
|
246
|
+
justReleased,
|
|
247
|
+
strength: aggregatedStrength,
|
|
248
|
+
raw: aggregatedRaw,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return results;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── getAxis / getVector ───────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Options for getVector deadzone override.
|
|
259
|
+
*/
|
|
260
|
+
export interface GetVectorOptions {
|
|
261
|
+
/**
|
|
262
|
+
* Override the default radial deadzone. When omitted, the deadzone is
|
|
263
|
+
* computed as the average of the 4 per-action deadzones.
|
|
264
|
+
*/
|
|
265
|
+
readonly deadzone?: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Compose a 1D axis value from two opposing actions.
|
|
270
|
+
*
|
|
271
|
+
* Returns `strength(pos) - strength(neg)`, range [-1, 1].
|
|
272
|
+
*
|
|
273
|
+
* - Both registered: combines normally.
|
|
274
|
+
* - One unregistered (E-3): contributes 0.
|
|
275
|
+
* - Neither registered: returns 0.
|
|
276
|
+
* - Same action for both ends (E-12): always 0.
|
|
277
|
+
*
|
|
278
|
+
* @param inputMap - Input map (ActionConfig[]), used to look up per-action deadzone for getVector default.
|
|
279
|
+
* @param actionStates - Derived action states (from deriveActionStates).
|
|
280
|
+
* @param neg - Action name for the negative direction (e.g. 'moveLeft').
|
|
281
|
+
* @param pos - Action name for the positive direction (e.g. 'moveRight').
|
|
282
|
+
*/
|
|
283
|
+
export function getAxis(
|
|
284
|
+
_inputMap: readonly ActionConfig[],
|
|
285
|
+
actionStates: readonly ActionState[],
|
|
286
|
+
neg: string,
|
|
287
|
+
pos: string,
|
|
288
|
+
): number {
|
|
289
|
+
const posState = actionStates.find((s) => s.action === pos);
|
|
290
|
+
const negState = actionStates.find((s) => s.action === neg);
|
|
291
|
+
const posStrength = posState?.strength ?? 0;
|
|
292
|
+
const negStrength = negState?.strength ?? 0;
|
|
293
|
+
if (neg === pos) return 0;
|
|
294
|
+
return posStrength - negStrength;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Compose a 2D vector from four directional actions using a single radial
|
|
299
|
+
* deadzone (Godot input.cpp three-branch formula, F2).
|
|
300
|
+
*
|
|
301
|
+
* Reads `raw` (not `strength`) from actionStates to avoid per-axis deadzone
|
|
302
|
+
* stacking into a square deadzone. Applies one radial deadzone:
|
|
303
|
+
*
|
|
304
|
+
* - length <= deadzone → (0, 0)
|
|
305
|
+
* - length > 1 → vector / length (clamp to unit circle)
|
|
306
|
+
* - otherwise → vector * inverse_lerp(deadzone, 1, length) / length
|
|
307
|
+
*
|
|
308
|
+
* Default deadzone = average of the 4 actions' per-action deadzones.
|
|
309
|
+
* `opts.deadzone` overrides it.
|
|
310
|
+
*
|
|
311
|
+
* @param inputMap - Input map, used for per-action deadzone lookup.
|
|
312
|
+
* @param actionStates - Derived action states.
|
|
313
|
+
* @param negX - Action name for negative X (e.g. 'moveLeft').
|
|
314
|
+
* @param posX - Action name for positive X (e.g. 'moveRight').
|
|
315
|
+
* @param negY - Action name for negative Y (e.g. 'moveUp').
|
|
316
|
+
* @param posY - Action name for positive Y (e.g. 'moveDown').
|
|
317
|
+
* @param opts - Optional override (deadzone).
|
|
318
|
+
*/
|
|
319
|
+
export function getVector(
|
|
320
|
+
inputMap: readonly ActionConfig[],
|
|
321
|
+
actionStates: readonly ActionState[],
|
|
322
|
+
negX: string,
|
|
323
|
+
posX: string,
|
|
324
|
+
negY: string,
|
|
325
|
+
posY: string,
|
|
326
|
+
opts?: GetVectorOptions,
|
|
327
|
+
): { readonly x: number; readonly y: number } {
|
|
328
|
+
// Look up raw values (not strength!) for each directional action.
|
|
329
|
+
// getVector uses raw to avoid per-action deadzone stacking into a square
|
|
330
|
+
// deadzone (F2: Godot getVector applies a SINGLE radial deadzone).
|
|
331
|
+
const posXState = actionStates.find((s) => s.action === posX);
|
|
332
|
+
const negXState = actionStates.find((s) => s.action === negX);
|
|
333
|
+
const posYState = actionStates.find((s) => s.action === posY);
|
|
334
|
+
const negYState = actionStates.find((s) => s.action === negY);
|
|
335
|
+
|
|
336
|
+
const x = (posXState?.raw ?? 0) - (negXState?.raw ?? 0);
|
|
337
|
+
const y = (posYState?.raw ?? 0) - (negYState?.raw ?? 0);
|
|
338
|
+
|
|
339
|
+
// Compute radial deadzone.
|
|
340
|
+
// Default = average of the 4 per-action deadzones, using only registered actions.
|
|
341
|
+
const deadzone = opts?.deadzone ?? computeAverageDeadzone(inputMap, negX, posX, negY, posY);
|
|
342
|
+
|
|
343
|
+
const length = Math.sqrt(x * x + y * y);
|
|
344
|
+
|
|
345
|
+
// Three-branch formula (Godot input.cpp, F2):
|
|
346
|
+
if (length <= deadzone) {
|
|
347
|
+
// Below deadzone → zero vector (deadzone applied).
|
|
348
|
+
return { x: 0, y: 0 };
|
|
349
|
+
}
|
|
350
|
+
if (length > 1) {
|
|
351
|
+
// Clamp to unit circle.
|
|
352
|
+
return { x: x / length, y: y / length };
|
|
353
|
+
}
|
|
354
|
+
// Intermediate zone: smoothly remap from deadzone→1 to 0→unit.
|
|
355
|
+
// vec * inverse_lerp(deadzone, 1, length) / length
|
|
356
|
+
const factor = inverseLerp(deadzone, 1, length) / length;
|
|
357
|
+
return { x: x * factor, y: y * factor };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Compute the average deadzone for 4 action names from the input map.
|
|
362
|
+
* Actions not registered contribute DEFAULT_DEADZONE.
|
|
363
|
+
*/
|
|
364
|
+
function computeAverageDeadzone(
|
|
365
|
+
inputMap: readonly ActionConfig[],
|
|
366
|
+
negX: string,
|
|
367
|
+
posX: string,
|
|
368
|
+
negY: string,
|
|
369
|
+
posY: string,
|
|
370
|
+
): number {
|
|
371
|
+
const map = new Map<string, number>();
|
|
372
|
+
for (const c of inputMap) {
|
|
373
|
+
map.set(c.action, c.deadzone ?? DEFAULT_DEADZONE);
|
|
374
|
+
}
|
|
375
|
+
const dzNegX = map.get(negX) ?? DEFAULT_DEADZONE;
|
|
376
|
+
const dzPosX = map.get(posX) ?? DEFAULT_DEADZONE;
|
|
377
|
+
const dzNegY = map.get(negY) ?? DEFAULT_DEADZONE;
|
|
378
|
+
const dzPosY = map.get(posY) ?? DEFAULT_DEADZONE;
|
|
379
|
+
return (dzNegX + dzPosX + dzNegY + dzPosY) / 4;
|
|
380
|
+
}
|