@waica/engine 0.21.0 → 0.22.0

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/dist/game.d.ts CHANGED
@@ -29,8 +29,10 @@ export interface GameOptions {
29
29
  viewHeight?: number;
30
30
  /** Fixed resolution (from the project's game.json); absent = fill the canvas. */
31
31
  resolution?: GameResolution;
32
- /** Control overrides (action → key codes) on top of the defaults. */
32
+ /** Control overrides (action → key and `Gamepad:` codes) on top of the defaults. */
33
33
  bindings?: InputBindings;
34
+ /** Radial stick dead zone of `game.input`, in [0, 1); absent or invalid means 0.2 (ADR 0023). */
35
+ gamepadDeadZone?: number;
34
36
  /** Initial stat values (points, lives…) from the project's stats.json. */
35
37
  stats?: Record<string, StatValue>;
36
38
  /**
@@ -233,8 +235,8 @@ export declare class Game {
233
235
  */
234
236
  private runFrame;
235
237
  /**
236
- * One Simulation Step: game.time's start-of-step pass (ADR 0017, CA-3)
237
- * first, then the Component Update Schedule (ADR 0004) in full,
238
+ * One Simulation Step: the pad is read first (issue #75 CA-6), then
239
+ * game.time's start-of-step pass (ADR 0017, CA-3), then the Component Update Schedule (ADR 0004) in full,
238
240
  * collisions, the scene camera and the host's callbacks, every one of
239
241
  * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
240
242
  */
package/dist/game.js CHANGED
@@ -109,7 +109,7 @@ export class Game {
109
109
  this.baseViewHeight = viewHeight;
110
110
  this.viewHeight = viewHeight;
111
111
  this.resolution = options.resolution ?? null;
112
- this.input = new Input(options.bindings);
112
+ this.input = new Input(options.bindings, { gamepadDeadZone: options.gamepadDeadZone });
113
113
  this.query = createSpatialQuery(this);
114
114
  this.stats = new Stats(options.stats);
115
115
  this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
@@ -339,9 +339,10 @@ export class Game {
339
339
  step: (onStep) => this.runFrame(1, onStep),
340
340
  resume: (onStep) => this.resumeRuntime(onStep),
341
341
  pause: () => this.stop(),
342
- injectAction: (action, operation) => this.input.injectAction(action, operation),
342
+ injectAction: (action, operation, value) => this.input.injectAction(action, operation, value),
343
343
  availableActions: () => this.input.availableActions(),
344
344
  heldActions: () => this.input.heldActions(),
345
+ actionValues: () => this.input.actionValues(),
345
346
  inspect: (metadata, filters) => inspector.snapshot(metadata, filters),
346
347
  click: (x, y) => {
347
348
  this.pointer.injectClick(x, y);
@@ -489,12 +490,13 @@ export class Game {
489
490
  }
490
491
  }
491
492
  /**
492
- * One Simulation Step: game.time's start-of-step pass (ADR 0017, CA-3)
493
- * first, then the Component Update Schedule (ADR 0004) in full,
493
+ * One Simulation Step: the pad is read first (issue #75 CA-6), then
494
+ * game.time's start-of-step pass (ADR 0017, CA-3), then the Component Update Schedule (ADR 0004) in full,
494
495
  * collisions, the scene camera and the host's callbacks, every one of
495
496
  * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
496
497
  */
497
498
  simulateStep() {
499
+ this.input.pollGamepad();
498
500
  advanceGameTime(this.time);
499
501
  // Existing drains advance before entity updates, so one detached during
500
502
  // this step begins on the next Simulation Step (ADR 0022).
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Gamepad sources for Actions (ADR 0023): the `Gamepad:<control>` binding
3
+ * vocabulary over the W3C `standard` mapping, the radial dead zone, and the
4
+ * player-1 pad slot. `Input` consumes this module; behaviors never see a pad.
5
+ */
6
+ /** The slice of the W3C `Gamepad` object Waica reads, so tests can hand in plain objects. */
7
+ export interface GamepadLike {
8
+ readonly index: number;
9
+ readonly id: string;
10
+ readonly mapping: string;
11
+ readonly connected: boolean;
12
+ readonly axes: readonly number[];
13
+ readonly buttons: readonly {
14
+ readonly value: number;
15
+ }[];
16
+ }
17
+ /** One pad control a binding code names: a button by index, or one half of a stick axis. */
18
+ export type GamepadControl = {
19
+ kind: 'button';
20
+ index: number;
21
+ } | {
22
+ kind: 'stick';
23
+ axis: number;
24
+ sign: 1 | -1;
25
+ };
26
+ export declare const GAMEPAD_CODE_PREFIX = "Gamepad:";
27
+ /** An Action's source counts as held at this value or above (keys are always 1 while down). */
28
+ export declare const ACTION_HELD_THRESHOLD = 0.5;
29
+ /** Radial dead zone used when `GameOptions.gamepadDeadZone` is absent or invalid. */
30
+ export declare const DEFAULT_GAMEPAD_DEAD_ZONE = 0.2;
31
+ /** Every known `Gamepad:` code: buttons in index order, then the stick halves. */
32
+ export declare const GAMEPAD_CODES: readonly string[];
33
+ /** The control a binding code names; undefined for a keyboard code or an unknown pad name. */
34
+ export declare function gamepadControl(code: string): GamepadControl | undefined;
35
+ /**
36
+ * Radial dead zone: a stick shorter than `deadZone` reads as centered on
37
+ * both axes; past it the direction is kept and the magnitude is rescaled
38
+ * to `(m − d) / (1 − d)`, clamped to 1.
39
+ */
40
+ export declare function radialDeadZone(x: number, y: number, deadZone: number): {
41
+ x: number;
42
+ y: number;
43
+ };
44
+ /** The configured dead zone if it is a finite number in [0, 1); otherwise warns once and uses the default. */
45
+ export declare function resolveGamepadDeadZone(value: number | undefined, warn: (message: string) => void): number;
46
+ /** Each known code's 0..1 value on this pad: buttons by `value`, stick halves dead-zoned per stick. */
47
+ export declare function gamepadValues(pad: GamepadLike, deadZone: number): Map<string, number>;
48
+ /** The browser's pads, or none where the Gamepad API is missing or refused (Node, happy-dom, a policy-blocked frame). */
49
+ export declare function connectedGamepads(): readonly (GamepadLike | null)[];
50
+ /** One poll of the player-1 slot: its pad, and whether the poll lost the pad that was active before it. */
51
+ export interface GamepadSlotPoll {
52
+ pad: GamepadLike | null;
53
+ lostActive: boolean;
54
+ }
55
+ /**
56
+ * @internal Editor support (the controls panel's pad capture), not a stable
57
+ * API: each known code's value on the first connected standard pad at the
58
+ * default dead zone, or null when no such pad is present.
59
+ */
60
+ export declare function firstStandardPadValues(): Map<string, number> | null;
61
+ /**
62
+ * Player 1's pad: the first `standard` pad to connect, sticky until it
63
+ * disconnects; then the next one in connection order takes over. Order is
64
+ * the order polls first saw each pad (by index on the same poll).
65
+ */
66
+ export declare class GamepadSlot {
67
+ private order;
68
+ private activeKey;
69
+ poll(pads: readonly (GamepadLike | null)[]): GamepadSlotPoll;
70
+ }
71
+ /** A connected pad with the W3C `standard` mapping; any other pad is ignored entirely. */
72
+ export declare function isStandardPad(pad: GamepadLike | null | undefined): pad is GamepadLike;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Gamepad sources for Actions (ADR 0023): the `Gamepad:<control>` binding
3
+ * vocabulary over the W3C `standard` mapping, the radial dead zone, and the
4
+ * player-1 pad slot. `Input` consumes this module; behaviors never see a pad.
5
+ */
6
+ export const GAMEPAD_CODE_PREFIX = 'Gamepad:';
7
+ /** An Action's source counts as held at this value or above (keys are always 1 while down). */
8
+ export const ACTION_HELD_THRESHOLD = 0.5;
9
+ /** Radial dead zone used when `GameOptions.gamepadDeadZone` is absent or invalid. */
10
+ export const DEFAULT_GAMEPAD_DEAD_ZONE = 0.2;
11
+ /** Standard-mapping buttons in W3C index order (0–16). */
12
+ const BUTTON_NAMES = [
13
+ 'A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LeftStick', 'RightStick',
14
+ 'DPadUp', 'DPadDown', 'DPadLeft', 'DPadRight', 'Home',
15
+ ];
16
+ /** Stick halves: axes 0/1 are the left stick, 2/3 the right; "Up" is the negative Y axis. */
17
+ const STICK_HALVES = {
18
+ LeftStickLeft: { axis: 0, sign: -1 },
19
+ LeftStickRight: { axis: 0, sign: 1 },
20
+ LeftStickUp: { axis: 1, sign: -1 },
21
+ LeftStickDown: { axis: 1, sign: 1 },
22
+ RightStickLeft: { axis: 2, sign: -1 },
23
+ RightStickRight: { axis: 2, sign: 1 },
24
+ RightStickUp: { axis: 3, sign: -1 },
25
+ RightStickDown: { axis: 3, sign: 1 },
26
+ };
27
+ const CONTROLS = new Map([
28
+ ...BUTTON_NAMES.map((name, index) => [
29
+ `${GAMEPAD_CODE_PREFIX}${name}`,
30
+ { kind: 'button', index },
31
+ ]),
32
+ ...Object.entries(STICK_HALVES).map(([name, half]) => [
33
+ `${GAMEPAD_CODE_PREFIX}${name}`,
34
+ { kind: 'stick', ...half },
35
+ ]),
36
+ ]);
37
+ /** Every known `Gamepad:` code: buttons in index order, then the stick halves. */
38
+ export const GAMEPAD_CODES = [...CONTROLS.keys()];
39
+ /** The control a binding code names; undefined for a keyboard code or an unknown pad name. */
40
+ export function gamepadControl(code) {
41
+ const control = CONTROLS.get(code);
42
+ return control ? { ...control } : undefined;
43
+ }
44
+ /**
45
+ * Radial dead zone: a stick shorter than `deadZone` reads as centered on
46
+ * both axes; past it the direction is kept and the magnitude is rescaled
47
+ * to `(m − d) / (1 − d)`, clamped to 1.
48
+ */
49
+ export function radialDeadZone(x, y, deadZone) {
50
+ const magnitude = Math.hypot(x, y);
51
+ if (magnitude === 0 || magnitude < deadZone)
52
+ return { x: 0, y: 0 };
53
+ const scaled = Math.min(1, (magnitude - deadZone) / (1 - deadZone));
54
+ return { x: (x / magnitude) * scaled, y: (y / magnitude) * scaled };
55
+ }
56
+ /** The configured dead zone if it is a finite number in [0, 1); otherwise warns once and uses the default. */
57
+ export function resolveGamepadDeadZone(value, warn) {
58
+ if (value === undefined)
59
+ return DEFAULT_GAMEPAD_DEAD_ZONE;
60
+ if (Number.isFinite(value) && value >= 0 && value < 1)
61
+ return value;
62
+ warn(`[waica] gamepadDeadZone must be a finite number from 0 up to (not including) 1; ` +
63
+ `got ${String(value)}, using ${DEFAULT_GAMEPAD_DEAD_ZONE}.`);
64
+ return DEFAULT_GAMEPAD_DEAD_ZONE;
65
+ }
66
+ /** Each known code's 0..1 value on this pad: buttons by `value`, stick halves dead-zoned per stick. */
67
+ export function gamepadValues(pad, deadZone) {
68
+ const axis = (index) => pad.axes[index] ?? 0;
69
+ const sticks = [
70
+ radialDeadZone(axis(0), axis(1), deadZone),
71
+ radialDeadZone(axis(2), axis(3), deadZone),
72
+ ];
73
+ const values = new Map();
74
+ for (const [code, control] of CONTROLS) {
75
+ if (control.kind === 'button') {
76
+ values.set(code, pad.buttons[control.index]?.value ?? 0);
77
+ continue;
78
+ }
79
+ const stick = sticks[control.axis >> 1] ?? { x: 0, y: 0 };
80
+ const component = control.axis % 2 === 0 ? stick.x : stick.y;
81
+ values.set(code, Math.max(0, component * control.sign));
82
+ }
83
+ return values;
84
+ }
85
+ /** The browser's pads, or none where the Gamepad API is missing or refused (Node, happy-dom, a policy-blocked frame). */
86
+ export function connectedGamepads() {
87
+ if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function')
88
+ return [];
89
+ try {
90
+ return navigator.getGamepads();
91
+ }
92
+ catch {
93
+ // A permissions policy can make getGamepads throw; input stays keyboard-only.
94
+ return [];
95
+ }
96
+ }
97
+ /**
98
+ * @internal Editor support (the controls panel's pad capture), not a stable
99
+ * API: each known code's value on the first connected standard pad at the
100
+ * default dead zone, or null when no such pad is present.
101
+ */
102
+ export function firstStandardPadValues() {
103
+ const pad = connectedGamepads().find(isStandardPad);
104
+ return pad ? gamepadValues(pad, DEFAULT_GAMEPAD_DEAD_ZONE) : null;
105
+ }
106
+ /**
107
+ * Player 1's pad: the first `standard` pad to connect, sticky until it
108
+ * disconnects; then the next one in connection order takes over. Order is
109
+ * the order polls first saw each pad (by index on the same poll).
110
+ */
111
+ export class GamepadSlot {
112
+ order = [];
113
+ activeKey = null;
114
+ poll(pads) {
115
+ const present = new Map();
116
+ for (const pad of pads)
117
+ if (isStandardPad(pad))
118
+ present.set(slotKey(pad), pad);
119
+ this.order = this.order.filter((key) => present.has(key));
120
+ for (const key of present.keys())
121
+ if (!this.order.includes(key))
122
+ this.order.push(key);
123
+ const previous = this.activeKey;
124
+ this.activeKey = this.order[0] ?? null;
125
+ return {
126
+ pad: this.activeKey === null ? null : (present.get(this.activeKey) ?? null),
127
+ lostActive: previous !== null && previous !== this.activeKey,
128
+ };
129
+ }
130
+ }
131
+ /** A connected pad with the W3C `standard` mapping; any other pad is ignored entirely. */
132
+ export function isStandardPad(pad) {
133
+ return pad?.connected === true && pad.mapping === 'standard';
134
+ }
135
+ function slotKey(pad) {
136
+ return `${pad.index}|${pad.id}`;
137
+ }
package/dist/index.d.ts CHANGED
@@ -31,7 +31,9 @@ export type { ComponentModule } from './component-registry.js';
31
31
  export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
32
32
  export type { ComponentUpdateCycleIssue, ComponentUpdateRegistry, ComponentUpdateScheduleIssue, ComponentUpdateScheduleResult, DuplicateComponentUpdateIssue, InvalidComponentUpdateConstraintIssue, InvalidComponentUpdateSchedule, InvalidUpdateConstraintReason, ValidComponentUpdateSchedule, } from './component-update-schedule.js';
33
33
  export { Input, DEFAULT_BINDINGS } from './input.js';
34
- export type { ActionName, InjectedActionOperation, InputBindings } from './input.js';
34
+ export type { ActionName, InjectedActionOperation, InputBindings, InputOptions } from './input.js';
35
+ export { ACTION_HELD_THRESHOLD, firstStandardPadValues, GAMEPAD_CODE_PREFIX, gamepadControl, } from './gamepad.js';
36
+ export type { GamepadControl } from './gamepad.js';
35
37
  export { Pointer } from './pointer.js';
36
38
  export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from './pointer.js';
37
39
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export { authoringDefaults } from './authoring-defaults.js';
15
15
  export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
16
16
  export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
17
17
  export { Input, DEFAULT_BINDINGS } from './input.js';
18
+ export { ACTION_HELD_THRESHOLD, firstStandardPadValues, GAMEPAD_CODE_PREFIX, gamepadControl, } from './gamepad.js';
18
19
  export { Pointer } from './pointer.js';
19
20
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
20
21
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
package/dist/input.d.ts CHANGED
@@ -1,41 +1,89 @@
1
1
  export type ActionName = string;
2
2
  export type InjectedActionOperation = 'press' | 'hold' | 'release';
3
- /** Action → KeyboardEvent.code list. */
3
+ /** Action → source codes: `KeyboardEvent.code` values and `Gamepad:<control>` codes (ADR 0023). */
4
4
  export type InputBindings = Record<string, string[]>;
5
5
  /** Neutral engine baseline; archetypes own their action vocabulary. */
6
6
  export declare const DEFAULT_BINDINGS: Readonly<InputBindings>;
7
+ /** An injection's checked `value`, or why it is rejected. */
8
+ export type InjectedValueCheck = {
9
+ value: number | undefined;
10
+ } | {
11
+ error: string;
12
+ };
7
13
  /**
8
- * Action-based input with archetype default bindings.
9
- * v0: keyboard. TODO(H1): gamepad and touch.
14
+ * The injection value rule Input and the Runtime Bridge share: only `hold`
15
+ * takes a value, a finite number in (0, 1]; an absent value always passes.
16
+ */
17
+ export declare function checkInjectedValue(operation: InjectedActionOperation, value: unknown): InjectedValueCheck;
18
+ export interface InputOptions {
19
+ /** Radial stick dead zone in [0, 1); absent or invalid means 0.2. */
20
+ gamepadDeadZone?: number;
21
+ }
22
+ /**
23
+ * Action-based input with archetype default bindings. Sources are keys and
24
+ * player 1's standard gamepad (ADR 0023); every Action has a 0..1 value, the
25
+ * max across its sources, and is held at 0.5 or above. Touch is still TODO.
10
26
  */
11
27
  export declare class Input {
12
28
  private readonly bindings;
13
29
  private readonly down;
14
30
  private readonly justDown;
15
- private readonly injectedDown;
31
+ private readonly injectedHolds;
16
32
  private readonly injectedJustDown;
17
33
  private readonly injectedPresses;
18
34
  private readonly used;
35
+ private readonly deadZone;
36
+ private readonly padSlot;
37
+ /** Live pad values by code, after the last poll; disarmed codes read 0. */
38
+ private padValues;
39
+ /** Pad codes released while held: they count again only after dropping below the threshold. */
40
+ private readonly disarmed;
41
+ /** Actions held when the last step closed: a press is a step that starts from none of them (CA-5). */
42
+ private heldAtStepEnd;
19
43
  /** Installs exactly the action map supplied by the active archetype/project. */
20
- constructor(bindings?: Readonly<InputBindings>);
21
- /** Is the action held this frame? */
44
+ constructor(bindings?: Readonly<InputBindings>, options?: InputOptions);
45
+ /** Is the action held this frame (some source at 0.5 or above)? */
22
46
  held(action: ActionName): boolean;
23
- /** Was the action pressed exactly this frame? */
47
+ /**
48
+ * Was the action pressed exactly this frame? True when no source held it
49
+ * as the last step closed and some source holds it now — or a key or an
50
+ * injection held it since, even if already released (a tap between steps).
51
+ */
24
52
  justPressed(action: ActionName): boolean;
53
+ /**
54
+ * The action's 0..1 value: the max across its sources — 1 for a key that
55
+ * is down, a pad button's value, a stick half's dead-zoned component, an
56
+ * injected hold's value. 0 for an unknown or unbound action.
57
+ */
58
+ value(action: ActionName): number;
25
59
  /** Installed semantic action names in deterministic order. */
26
60
  availableActions(): ActionName[];
27
61
  /**
28
- * The key codes bound to the action, in their declared order (e.g.
62
+ * The source codes bound to the action, in their declared order (e.g.
29
63
  * `['KeyE', 'Space']`); `[]` for an unknown or unbound action. A new
30
64
  * array every call: mutating it never changes the bindings.
31
65
  */
32
66
  bindingsFor(action: ActionName): string[];
33
67
  /** Currently held semantic action names in deterministic order. */
34
68
  heldActions(): ActionName[];
35
- /** Injects an action by semantic name; false means the action is not installed. */
36
- injectAction(action: ActionName, operation: InjectedActionOperation): boolean;
37
- /** -1..1 axis from two actions (left/right by default). */
69
+ /** The value of every held action, keyed in the same order as heldActions(). */
70
+ actionValues(): Record<ActionName, number>;
71
+ /**
72
+ * Injects an action by semantic name; false means the action is not
73
+ * installed. `hold` takes an optional value in (0, 1] (default 1) and is
74
+ * one more source of the action: below 0.5 it moves it without holding it.
75
+ * A value outside (0, 1], or any value on `press`/`release`, throws a
76
+ * RangeError before anything changes (checkInjectedValue, the Runtime
77
+ * Bridge's rule).
78
+ */
79
+ injectAction(action: ActionName, operation: InjectedActionOperation, value?: number): boolean;
80
+ /** -1..1 axis from two actions (left/right by default): value(positive) − value(negative). */
38
81
  axis(negative?: ActionName, positive?: ActionName): number;
82
+ /**
83
+ * Reads player 1's pad. The Game calls this once per Simulation Step,
84
+ * before component updates.
85
+ */
86
+ pollGamepad(): void;
39
87
  /**
40
88
  * Marks this frame's press of the action as spent, so consumers that
41
89
  * honor consumption (state-machine 'input:' triggers) ignore it. One
@@ -49,6 +97,8 @@ export declare class Input {
49
97
  endFrame(): void;
50
98
  dispose(): void;
51
99
  private isActive;
100
+ /** Every pad source reads 0; the ones that were held stay released until they drop below the threshold. */
101
+ private releasePad;
52
102
  private releaseAll;
53
103
  private onVisibilityChange;
54
104
  private onKeyDown;
package/dist/input.js CHANGED
@@ -1,41 +1,96 @@
1
+ import { ACTION_HELD_THRESHOLD, connectedGamepads, GAMEPAD_CODE_PREFIX, gamepadControl, gamepadValues, GamepadSlot, resolveGamepadDeadZone, } from './gamepad.js';
1
2
  /** Neutral engine baseline; archetypes own their action vocabulary. */
2
3
  export const DEFAULT_BINDINGS = {};
3
4
  /**
4
- * Action-based input with archetype default bindings.
5
- * v0: keyboard. TODO(H1): gamepad and touch.
5
+ * The injection value rule Input and the Runtime Bridge share: only `hold`
6
+ * takes a value, a finite number in (0, 1]; an absent value always passes.
7
+ */
8
+ export function checkInjectedValue(operation, value) {
9
+ if (value === undefined)
10
+ return { value };
11
+ if (operation !== 'hold')
12
+ return { error: `${operation} does not accept a value; only hold does.` };
13
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
14
+ return { error: 'value must be a finite number greater than 0 and at most 1.' };
15
+ }
16
+ return { value };
17
+ }
18
+ /**
19
+ * Action-based input with archetype default bindings. Sources are keys and
20
+ * player 1's standard gamepad (ADR 0023); every Action has a 0..1 value, the
21
+ * max across its sources, and is held at 0.5 or above. Touch is still TODO.
6
22
  */
7
23
  export class Input {
8
24
  bindings = new Map();
9
25
  down = new Set();
10
26
  justDown = new Set();
11
- injectedDown = new Set();
27
+ injectedHolds = new Map();
12
28
  injectedJustDown = new Set();
13
29
  injectedPresses = new Set();
14
30
  used = new Set();
31
+ deadZone;
32
+ padSlot = new GamepadSlot();
33
+ /** Live pad values by code, after the last poll; disarmed codes read 0. */
34
+ padValues = new Map();
35
+ /** Pad codes released while held: they count again only after dropping below the threshold. */
36
+ disarmed = new Set();
37
+ /** Actions held when the last step closed: a press is a step that starts from none of them (CA-5). */
38
+ heldAtStepEnd = new Set();
15
39
  /** Installs exactly the action map supplied by the active archetype/project. */
16
- constructor(bindings = DEFAULT_BINDINGS) {
40
+ constructor(bindings = DEFAULT_BINDINGS, options = {}) {
41
+ const warned = new Set();
17
42
  for (const [action, codes] of Object.entries(bindings)) {
18
43
  this.bindings.set(action, new Set(codes));
44
+ for (const code of codes)
45
+ warnUnknownPadCode(code, warned);
19
46
  }
47
+ this.deadZone = resolveGamepadDeadZone(options.gamepadDeadZone, (message) => {
48
+ console.warn(message);
49
+ });
20
50
  window.addEventListener('keydown', this.onKeyDown);
21
51
  window.addEventListener('keyup', this.onKeyUp);
22
52
  window.addEventListener('blur', this.releaseAll);
23
53
  document.addEventListener('visibilitychange', this.onVisibilityChange);
24
54
  }
25
- /** Is the action held this frame? */
55
+ /** Is the action held this frame (some source at 0.5 or above)? */
26
56
  held(action) {
27
- return this.injectedDown.has(action) || this.isActive(action, this.down);
57
+ return this.value(action) >= ACTION_HELD_THRESHOLD;
28
58
  }
29
- /** Was the action pressed exactly this frame? */
59
+ /**
60
+ * Was the action pressed exactly this frame? True when no source held it
61
+ * as the last step closed and some source holds it now — or a key or an
62
+ * injection held it since, even if already released (a tap between steps).
63
+ */
30
64
  justPressed(action) {
31
- return this.injectedJustDown.has(action) || this.isActive(action, this.justDown);
65
+ if (this.heldAtStepEnd.has(action))
66
+ return false;
67
+ return (this.held(action) ||
68
+ this.injectedJustDown.has(action) ||
69
+ this.isActive(action, this.justDown));
70
+ }
71
+ /**
72
+ * The action's 0..1 value: the max across its sources — 1 for a key that
73
+ * is down, a pad button's value, a stick half's dead-zoned component, an
74
+ * injected hold's value. 0 for an unknown or unbound action.
75
+ */
76
+ value(action) {
77
+ const codes = this.bindings.get(action);
78
+ if (!codes)
79
+ return 0;
80
+ let value = this.injectedPresses.has(action) ? 1 : (this.injectedHolds.get(action) ?? 0);
81
+ for (const code of codes) {
82
+ if (this.down.has(code))
83
+ return 1;
84
+ value = Math.max(value, this.padValues.get(code) ?? 0);
85
+ }
86
+ return value;
32
87
  }
33
88
  /** Installed semantic action names in deterministic order. */
34
89
  availableActions() {
35
90
  return [...this.bindings.keys()].sort();
36
91
  }
37
92
  /**
38
- * The key codes bound to the action, in their declared order (e.g.
93
+ * The source codes bound to the action, in their declared order (e.g.
39
94
  * `['KeyE', 'Space']`); `[]` for an unknown or unbound action. A new
40
95
  * array every call: mutating it never changes the bindings.
41
96
  */
@@ -46,29 +101,65 @@ export class Input {
46
101
  heldActions() {
47
102
  return this.availableActions().filter((action) => this.held(action));
48
103
  }
49
- /** Injects an action by semantic name; false means the action is not installed. */
50
- injectAction(action, operation) {
104
+ /** The value of every held action, keyed in the same order as heldActions(). */
105
+ actionValues() {
106
+ return Object.fromEntries(this.heldActions().map((action) => [action, this.value(action)]));
107
+ }
108
+ /**
109
+ * Injects an action by semantic name; false means the action is not
110
+ * installed. `hold` takes an optional value in (0, 1] (default 1) and is
111
+ * one more source of the action: below 0.5 it moves it without holding it.
112
+ * A value outside (0, 1], or any value on `press`/`release`, throws a
113
+ * RangeError before anything changes (checkInjectedValue, the Runtime
114
+ * Bridge's rule).
115
+ */
116
+ injectAction(action, operation, value) {
117
+ const checked = checkInjectedValue(operation, value);
118
+ if ('error' in checked)
119
+ throw new RangeError(checked.error);
51
120
  if (!this.bindings.has(action))
52
121
  return false;
53
122
  if (operation === 'release') {
54
- this.injectedDown.delete(action);
123
+ this.injectedHolds.delete(action);
55
124
  this.injectedPresses.delete(action);
56
125
  return true;
57
126
  }
58
- if (this.held(action)) {
59
- if (operation === 'hold')
60
- this.injectedPresses.delete(action);
61
- return true;
127
+ const wasHeld = this.held(action);
128
+ if (operation === 'hold') {
129
+ // A queued press becomes this persistent hold rather than a second edge.
130
+ this.injectedPresses.delete(action);
131
+ this.injectedHolds.set(action, checked.value ?? 1);
62
132
  }
63
- this.injectedDown.add(action);
64
- this.injectedJustDown.add(action);
65
- if (operation === 'press')
133
+ else if (!wasHeld) {
66
134
  this.injectedPresses.add(action);
135
+ }
136
+ if (!wasHeld && this.held(action))
137
+ this.injectedJustDown.add(action);
67
138
  return true;
68
139
  }
69
- /** -1..1 axis from two actions (left/right by default). */
140
+ /** -1..1 axis from two actions (left/right by default): value(positive) − value(negative). */
70
141
  axis(negative = 'left', positive = 'right') {
71
- return (this.held(positive) ? 1 : 0) - (this.held(negative) ? 1 : 0);
142
+ return this.value(positive) - this.value(negative);
143
+ }
144
+ /**
145
+ * Reads player 1's pad. The Game calls this once per Simulation Step,
146
+ * before component updates.
147
+ */
148
+ pollGamepad() {
149
+ const { pad, lostActive } = this.padSlot.poll(connectedGamepads());
150
+ // No pad, none lost and no pad values: nothing can change (the common keyboard-only case).
151
+ if (!pad && !lostActive && this.padValues.size === 0)
152
+ return;
153
+ if (lostActive)
154
+ this.releasePad();
155
+ const raw = pad ? gamepadValues(pad, this.deadZone) : new Map();
156
+ for (const [code, value] of raw) {
157
+ if (value < ACTION_HELD_THRESHOLD)
158
+ this.disarmed.delete(code);
159
+ else if (this.disarmed.has(code))
160
+ raw.set(code, 0);
161
+ }
162
+ this.padValues = raw;
72
163
  }
73
164
  /**
74
165
  * Marks this frame's press of the action as spent, so consumers that
@@ -87,13 +178,12 @@ export class Input {
87
178
  endFrame() {
88
179
  this.justDown.clear();
89
180
  this.injectedJustDown.clear();
90
- for (const action of this.injectedPresses)
91
- this.injectedDown.delete(action);
92
181
  this.injectedPresses.clear();
93
182
  this.used.clear();
183
+ this.heldAtStepEnd = new Set(this.heldActions());
94
184
  }
95
185
  dispose() {
96
- this.injectedDown.clear();
186
+ this.injectedHolds.clear();
97
187
  this.injectedJustDown.clear();
98
188
  this.injectedPresses.clear();
99
189
  window.removeEventListener('keydown', this.onKeyDown);
@@ -110,9 +200,18 @@ export class Input {
110
200
  return true;
111
201
  return false;
112
202
  }
203
+ /** Every pad source reads 0; the ones that were held stay released until they drop below the threshold. */
204
+ releasePad() {
205
+ for (const [code, value] of this.padValues) {
206
+ if (value >= ACTION_HELD_THRESHOLD)
207
+ this.disarmed.add(code);
208
+ }
209
+ this.padValues = new Map();
210
+ }
113
211
  releaseAll = () => {
114
212
  this.down.clear();
115
213
  this.justDown.clear();
214
+ this.releasePad();
116
215
  };
117
216
  onVisibilityChange = () => {
118
217
  if (document.visibilityState === 'hidden')
@@ -128,3 +227,10 @@ export class Input {
128
227
  this.down.delete(e.code);
129
228
  };
130
229
  }
230
+ /** An unknown `Gamepad:` code never matches; it is reported once per code per Input. */
231
+ function warnUnknownPadCode(code, warned) {
232
+ if (!code.startsWith(GAMEPAD_CODE_PREFIX) || warned.has(code) || gamepadControl(code))
233
+ return;
234
+ warned.add(code);
235
+ console.warn(`[waica] unknown gamepad binding "${code}": it never matches.`);
236
+ }
@@ -18,8 +18,11 @@ export type RuntimeMode = 'paused' | 'real-time';
18
18
  * operation and before a screenshot. `camera-effects` (issue #74, ADR 0020)
19
19
  * announces that every snapshot carries `camera: { shake, fade, flash }`
20
20
  * from `game.cameraEffects`; the `scene` operation stays a hard cut.
21
+ * `analog-actions` (issue #75, ADR 0023) announces that `hold` takes an
22
+ * optional `value` in (0, 1] and that every control result carries
23
+ * `actionValues` beside `heldActions`.
21
24
  */
22
- export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects'];
25
+ export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects', 'analog-actions'];
23
26
  export interface RuntimeMetadata {
24
27
  bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
25
28
  engineVersion: string;
@@ -31,8 +34,14 @@ export interface RuntimeMetadata {
31
34
  assets: AssetStatus;
32
35
  }
33
36
  export type RuntimeControlRequest = {
34
- operation: 'press' | 'hold' | 'release';
37
+ operation: 'press' | 'release';
35
38
  action: string;
39
+ }
40
+ /** Holds at `value` in (0, 1]; without one, at 1. */
41
+ | {
42
+ operation: 'hold';
43
+ action: string;
44
+ value?: number;
36
45
  } | {
37
46
  operation: 'pause' | 'resume';
38
47
  }
@@ -50,6 +59,8 @@ export type RuntimeControlRequest = {
50
59
  };
51
60
  export interface RuntimeControlResult extends RuntimeMetadata {
52
61
  heldActions: string[];
62
+ /** The value of each held action, in the same order as `heldActions`. */
63
+ actionValues: Record<string, number>;
53
64
  }
54
65
  export declare class RuntimeBridgeOperationError extends Error {
55
66
  readonly code: 'runtime-invalid-state' | 'runtime-operation-failed';
@@ -82,9 +93,10 @@ export interface RuntimeBridgeHost {
82
93
  /** Starts clock-driven playback; `onStep` is told after every Simulation Step. */
83
94
  resume(onStep: () => void): void;
84
95
  pause(): void;
85
- injectAction(action: string, operation: 'press' | 'hold' | 'release'): boolean;
96
+ injectAction(action: string, operation: 'press' | 'hold' | 'release', value?: number): boolean;
86
97
  availableActions(): string[];
87
98
  heldActions(): string[];
99
+ actionValues(): Record<string, number>;
88
100
  inspect(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
89
101
  click(x: number, y: number): void;
90
102
  /** Resolves `name` through the registered catalog and loads it. */
@@ -1,5 +1,6 @@
1
1
  import enginePackage from '../package.json' with { type: 'json' };
2
2
  import { SIMULATION_STEP } from './fixed-step.js';
3
+ import { checkInjectedValue } from './input.js';
3
4
  export const RUNTIME_BRIDGE_PROTOCOL_VERSION = 1;
4
5
  export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
5
6
  /**
@@ -17,6 +18,9 @@ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
17
18
  * operation and before a screenshot. `camera-effects` (issue #74, ADR 0020)
18
19
  * announces that every snapshot carries `camera: { shake, fade, flash }`
19
20
  * from `game.cameraEffects`; the `scene` operation stays a hard cut.
21
+ * `analog-actions` (issue #75, ADR 0023) announces that `hold` takes an
22
+ * optional `value` in (0, 1] and that every control result carries
23
+ * `actionValues` beside `heldActions`.
20
24
  */
21
25
  export const RUNTIME_BRIDGE_CAPABILITIES = [
22
26
  'click',
@@ -24,6 +28,7 @@ export const RUNTIME_BRIDGE_CAPABILITIES = [
24
28
  'fixed-step',
25
29
  'assets',
26
30
  'camera-effects',
31
+ 'analog-actions',
27
32
  ];
28
33
  export class RuntimeBridgeOperationError extends Error {
29
34
  code;
@@ -116,10 +121,17 @@ export class EngineRuntimeBridge {
116
121
  throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unsupported runtime control operation "${unsupported.operation}".`);
117
122
  }
118
123
  }
119
- return { ...this.metadata(), heldActions: this.host.heldActions() };
124
+ return {
125
+ ...this.metadata(),
126
+ heldActions: this.host.heldActions(),
127
+ actionValues: this.host.actionValues(),
128
+ };
120
129
  }
121
130
  injectAction(request) {
122
- if (this.host.injectAction(request.action, request.operation))
131
+ const checked = checkInjectedValue(request.operation, 'value' in request ? request.value : undefined);
132
+ if ('error' in checked)
133
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', checked.error);
134
+ if (this.host.injectAction(request.action, request.operation, checked.value))
123
135
  return;
124
136
  const available = this.host.availableActions();
125
137
  throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown action "${request.action}". Available actions: ${available.join(', ') || '(none)'}.`, available);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",