@waica/engine 0.21.0 → 0.23.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/README.md CHANGED
@@ -150,6 +150,20 @@ The cues that hook a `StateMachine` must come after it and after the `ParticleEm
150
150
 
151
151
  `active`, `capacity`, and `emitting` are the only fields exposed by Runtime Snapshot. Particle arrays remain private. This first CPU implementation is intended for bounded 2D effects, not collision, per-particle scripting, GPU simulation, trails, rotation, or sub-emitters.
152
152
 
153
+ ## Sprite Batches
154
+
155
+ `Sprite` and `AnimatedSprite` are drawn through **Sprite Batches** by default (ADR 0024). Every frame, after y-sort, the engine orders everything it draws exactly as three would without batching — layer bands, then y-sort, then spawn order, with particle batches, tilemaps and any other mesh in between — and each run of consecutive sprites that share a batch key becomes one instanced draw call. The key is the art and how it is sampled: the texture (an `AnimatedSprite`'s current sheet), `pixelArt` and `shape`. Each sprite is one instance carrying its own placement, color, frame and flip, so tinting, resizing, re-anchoring or animating a sprite never creates a material, and what is drawn in front of what never changes.
156
+
157
+ - **On by default.** A scene without a `render` block, or with `render.batch: true`, batches. Nothing to configure: sprites of one texture in a row draw in one call, a sprite of other art (or a particle batch, a tilemap) in between starts a new run.
158
+ - **Culling and Group order kept.** A sprite outside the camera is skipped for the frame, as three culls a sprite's own mesh, and an off-camera renderable never splits a run. A run hangs under a Group with its sprites' Group `renderOrder`, so `entity.node.renderOrder` orders batched sprites exactly as it orders their meshes without batching.
159
+ - **Shared art.** All live sprites of one key share one material and one texture clone, scene-scoped: released with the scene at `unloadScene()` and `game.dispose()`. A sprite whose texture fails moves alone to the untextured entry of its shape and keeps showing its `color`.
160
+ - **Reusable slots and buffers.** A destroyed sprite's slot goes to the next spawn of its key. Each run draws through a pooled instance buffer sized by that run: 16 instances at first, doubling when a longer run needs it, never shrinking during the scene. A key drawn as several runs keeps one buffer per run, so instance memory stays proportional to the sprites drawn. Once the buffers have grown to a scene's longest runs (and as many runs as it ever splits into), spawning, destroying and reordering sprites allocates no GPU objects.
161
+ - **Turning it off.** Scene JSON `"render": { "batch": false }` (the editor's **Sprite batching** toggle in the scene inspector) restores the per-sprite path: one mesh, one material and one draw call per sprite. Use it to rule batching out when a scene looks different than you expect, or while debugging draw order; the cost is one draw call per sprite, which is what dominates frame time with thousands of sprites.
162
+
163
+ Under batching, the object a sprite adds to its entity is a hidden placement anchor that is never drawn. Hide or show a batched sprite through its entity (`entity.node.visible`, as `Health` blinking does); the anchor's own `visible` is not read for the batched draw, and setting it to true makes three also draw the anchor as a plain quad (without the sprite's tint or frame) — leave it alone, or use `render.batch: false` when code needs one real mesh per sprite.
164
+
165
+ How much batching saves depends on how interleaved a scene is: one texture over the whole scene draws in one call, while a top-down scene that alternates many textures by y gains little. Runtime Snapshots are unchanged — batches are not entities, and sprites report the same state either way.
166
+
153
167
  ## Component lifecycle
154
168
 
155
169
  Waica keeps the lifecycle boundaries distinct:
@@ -5,7 +5,10 @@ export function validCollisionLayer(value) {
5
5
  }
6
6
  /** Package-internal exact directional interest check. */
7
7
  export function collisionMaskTargets(mask, targetLayer) {
8
- if (!validCollisionLayer(targetLayer) || !Array.isArray(mask))
8
+ if (!Array.isArray(mask))
9
9
  return false;
10
- return mask.some((entry) => typeof entry === 'string' && (entry === '*' || entry === targetLayer));
10
+ // Membership first: with explicit masks it rejects most pairs of a dense
11
+ // scene before the layer's syntax check runs. A '*' mask still checks it.
12
+ const named = mask.some((entry) => typeof entry === 'string' && (entry === '*' || entry === targetLayer));
13
+ return named && validCollisionLayer(targetLayer);
11
14
  }
@@ -4,5 +4,11 @@ export interface CollisionDispatchStats {
4
4
  readonly candidatePairs: number;
5
5
  readonly narrowphaseCalls: number;
6
6
  }
7
- /** Game's package-internal trigger dispatch implementation. */
7
+ /**
8
+ * Game's package-internal trigger dispatch implementation. Pairs come from a
9
+ * frozen grid; each pair is then checked live, most selective checks first
10
+ * (liveness, masks, then the snapshotted Hitbox still mounted). The checks
11
+ * have no side effects, so their order only changes how fast a pair is
12
+ * rejected.
13
+ */
8
14
  export declare function dispatchCollisions(game: Game): CollisionDispatchStats;
@@ -3,34 +3,41 @@ import { collisionMaskTargets } from './collision-category.js';
3
3
  import { collisionOverlap } from './collision-shape.js';
4
4
  import { Hitbox } from './components/hitbox.js';
5
5
  import { createHitboxBroadphase } from './spatial-broadphase.js';
6
- /** Game's package-internal trigger dispatch implementation. */
6
+ /**
7
+ * Game's package-internal trigger dispatch implementation. Pairs come from a
8
+ * frozen grid; each pair is then checked live, most selective checks first
9
+ * (liveness, masks, then the snapshotted Hitbox still mounted). The checks
10
+ * have no side effects, so their order only changes how fast a pair is
11
+ * rejected.
12
+ */
7
13
  export function dispatchCollisions(game) {
8
- const pairs = createHitboxBroadphase(game).pairs();
14
+ let candidatePairs = 0;
9
15
  let narrowphaseCalls = 0;
10
- for (const [first, second] of pairs) {
16
+ createHitboxBroadphase(game).forEachPair((first, second) => {
17
+ candidatePairs += 1;
11
18
  const a = first.entity;
12
19
  const b = second.entity;
13
20
  if (!a.alive || !b.alive)
14
- continue;
15
- if (a.get(Hitbox) !== first.hitbox || b.get(Hitbox) !== second.hitbox)
16
- continue;
21
+ return;
17
22
  const notifyA = collisionMaskTargets(first.hitbox.collidesWith, second.hitbox.layer);
18
23
  const notifyB = collisionMaskTargets(second.hitbox.collidesWith, first.hitbox.layer);
19
24
  if (!notifyA && !notifyB)
20
- continue;
25
+ return;
26
+ if (a.get(Hitbox) !== first.hitbox || b.get(Hitbox) !== second.hitbox)
27
+ return;
21
28
  narrowphaseCalls += 1;
22
29
  if (!collisionOverlap(collisionBody(first.hitbox), collisionBody(second.hitbox)))
23
- continue;
30
+ return;
24
31
  if (notifyA) {
25
32
  for (const component of [...a.components])
26
33
  component.onCollide?.(b);
27
34
  }
28
35
  if (!a.alive || !b.alive)
29
- continue;
36
+ return;
30
37
  if (notifyB) {
31
38
  for (const component of [...b.components])
32
39
  component.onCollide?.(a);
33
40
  }
34
- }
35
- return { candidatePairs: pairs.length, narrowphaseCalls };
41
+ });
42
+ return { candidatePairs, narrowphaseCalls };
36
43
  }
@@ -113,6 +113,8 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
113
113
  * touches game.assets, so it counts nowhere and warns about nothing.
114
114
  */
115
115
  private sheetTexture;
116
+ /** The batch key of a sheet: its art, this sprite's filtering, a rectangle. */
117
+ private sheetKey;
116
118
  private showFrame;
117
119
  /** Repositions/rescales the displayed frame inside its anchored full-size box. */
118
120
  private syncQuad;
@@ -4,6 +4,9 @@ import { ClipPlayer } from '../animation/clip-player.js';
4
4
  import { locateFrame, sheetCell } from '../animation/sheet.js';
5
5
  import { spritePlacement } from '../sprite-placement.js';
6
6
  import { reportRejection } from '../report-rejection.js';
7
+ import { spriteBatchesOf, spriteInstanceOf } from '../sprite-batches.js';
8
+ /** Where a frame on a failed sheet draws: the flat (white) untextured quad. */
9
+ const UNTEXTURED = { texture: null, pixelArt: false, shape: 'rectangle' };
7
10
  const clampAnchor = (value) => Math.min(1, Math.max(0, value));
8
11
  /**
9
12
  * Sprite animated from one or more spritesheets. The main sheet is the
@@ -139,6 +142,8 @@ export class AnimatedSprite extends Component {
139
142
  texs = [];
140
143
  // Clones whose sheet failed to load: applyFrame never installs them (CA-4).
141
144
  failedSheets = new Set();
145
+ // The drawn quad, or under Sprite Batches (ADR 0024) the hidden anchor of
146
+ // this sprite's instance, whose frame UV is read from the sheet clone.
142
147
  mesh;
143
148
  frame = 0;
144
149
  // Current frame's quad scale relative to the sheet's largest cell: always
@@ -169,8 +174,17 @@ export class AnimatedSprite extends Component {
169
174
  tex.repeat.set(1 / Math.max(1, sheet.cols), 1 / Math.max(1, sheet.rows));
170
175
  return tex;
171
176
  });
172
- const material = new THREE.MeshBasicMaterial({ map: this.texs[0], transparent: true });
173
- this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
177
+ const batches = spriteBatchesOf(this.game);
178
+ if (batches?.enabled) {
179
+ const instance = batches.attach(this.sheetKey(0));
180
+ instance.shownSheet = 0;
181
+ instance.uvSource = this.texs[0] ?? instance.uvSource;
182
+ this.mesh = instance.anchor;
183
+ }
184
+ else {
185
+ const material = new THREE.MeshBasicMaterial({ map: this.texs[0], transparent: true });
186
+ this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
187
+ }
174
188
  this.mesh.position.z = this.layer * 0.01;
175
189
  this.syncQuad();
176
190
  this.entity.node.add(this.mesh);
@@ -193,9 +207,16 @@ export class AnimatedSprite extends Component {
193
207
  this.showFrame(this.player.advance(dt));
194
208
  }
195
209
  onDestroy() {
196
- this.mesh?.removeFromParent();
197
- this.mesh?.geometry.dispose();
198
- this.mesh?.material.dispose();
210
+ const instance = spriteInstanceOf(this.mesh);
211
+ if (instance) {
212
+ instance.release();
213
+ }
214
+ else {
215
+ this.mesh?.removeFromParent();
216
+ this.mesh?.geometry.dispose();
217
+ this.mesh?.material.dispose();
218
+ }
219
+ this.mesh = undefined;
199
220
  // Only this sprite's clones: the cached bases live with the Game.
200
221
  for (const tex of this.texs)
201
222
  tex.dispose();
@@ -225,6 +246,10 @@ export class AnimatedSprite extends Component {
225
246
  }), 'sheet texture settle');
226
247
  return texture;
227
248
  }
249
+ /** The batch key of a sheet: its art, this sprite's filtering, a rectangle. */
250
+ sheetKey(index) {
251
+ return { texture: this.sheets[index]?.texture ?? '', pixelArt: this.pixelArt, shape: 'rectangle' };
252
+ }
228
253
  showFrame(index) {
229
254
  this.frame = index;
230
255
  this.applyFrame();
@@ -254,7 +279,17 @@ export class AnimatedSprite extends Component {
254
279
  const tex = this.texs[located.sheet];
255
280
  if (!sheet || !tex)
256
281
  return;
257
- if (this.mesh) {
282
+ const instance = spriteInstanceOf(this.mesh);
283
+ if (instance) {
284
+ const failed = this.failedSheets.has(tex);
285
+ if (instance.shownSheet !== located.sheet || instance.shownSheetFailed !== failed) {
286
+ instance.moveTo(failed ? UNTEXTURED : this.sheetKey(located.sheet));
287
+ instance.shownSheet = located.sheet;
288
+ instance.shownSheetFailed = failed;
289
+ }
290
+ instance.uvSource = tex;
291
+ }
292
+ else if (this.mesh) {
258
293
  const map = this.failedSheets.has(tex) ? null : tex;
259
294
  if (this.mesh.material.map !== map) {
260
295
  this.mesh.material.map = map;
@@ -68,6 +68,12 @@ export declare class Sprite extends Component implements YSortParticipant {
68
68
  set shape(value: SpriteShape);
69
69
  private mesh?;
70
70
  onReady(): void;
71
+ private buildMesh;
72
+ /**
73
+ * One instance in the batch of this sprite's key, tinted like the material
74
+ * the per-sprite path would build: white over a texture, `color` without.
75
+ */
76
+ private joinBatch;
71
77
  onDestroy(): void;
72
78
  /**
73
79
  * CA-4's failure rule: an image that never arrives leaves the flat
@@ -2,6 +2,7 @@ import * as THREE from 'three';
2
2
  import { Component } from '../component.js';
3
3
  import { spritePlacement } from '../sprite-placement.js';
4
4
  import { reportRejection } from '../report-rejection.js';
5
+ import { spriteBatchesOf, spriteInstanceOf } from '../sprite-batches.js';
5
6
  const clampAnchor = (value) => Math.min(1, Math.max(0, value));
6
7
  /**
7
8
  * Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
@@ -41,7 +42,11 @@ export class Sprite extends Component {
41
42
  }
42
43
  set color(value) {
43
44
  this._color = value;
44
- this.mesh?.material.color.setHex(value);
45
+ const instance = spriteInstanceOf(this.mesh);
46
+ if (instance)
47
+ instance.color.setHex(value);
48
+ else
49
+ this.mesh?.material.color.setHex(value);
45
50
  }
46
51
  _offsetX = 0;
47
52
  _offsetY = 0;
@@ -102,11 +107,30 @@ export class Sprite extends Component {
102
107
  this._shape = value === 'circle' ? 'circle' : 'rectangle';
103
108
  if (!this.mesh)
104
109
  return;
110
+ const instance = spriteInstanceOf(this.mesh);
111
+ if (instance) {
112
+ instance.moveTo({ ...instance.key, shape: this._shape });
113
+ return;
114
+ }
105
115
  this.mesh.geometry.dispose();
106
116
  this.mesh.geometry = this.createGeometry();
107
117
  }
118
+ // The drawn quad, or under Sprite Batches (ADR 0024) the hidden anchor of
119
+ // this sprite's instance: the same placement either way.
108
120
  mesh;
109
121
  onReady() {
122
+ const batches = spriteBatchesOf(this.game);
123
+ if (batches?.enabled)
124
+ this.joinBatch(batches);
125
+ else
126
+ this.buildMesh();
127
+ if (!this.mesh)
128
+ return;
129
+ this.mesh.position.z = this.layer * 0.01;
130
+ this.syncQuad();
131
+ this.entity.node.add(this.mesh);
132
+ }
133
+ buildMesh() {
110
134
  const material = new THREE.MeshBasicMaterial({ color: this.color, transparent: true });
111
135
  if (this.texture) {
112
136
  // Its own clone of the cached base (game.assets, ADR 0019): filters
@@ -125,11 +149,35 @@ export class Sprite extends Component {
125
149
  }), 'sprite texture settle');
126
150
  }
127
151
  this.mesh = new THREE.Mesh(this.createGeometry(), material);
128
- this.mesh.position.z = this.layer * 0.01;
129
- this.syncQuad();
130
- this.entity.node.add(this.mesh);
152
+ }
153
+ /**
154
+ * One instance in the batch of this sprite's key, tinted like the material
155
+ * the per-sprite path would build: white over a texture, `color` without.
156
+ */
157
+ joinBatch(batches) {
158
+ const key = { texture: this.texture || null, pixelArt: this.pixelArt, shape: this._shape };
159
+ const instance = batches.attach(key);
160
+ instance.color.setHex(this.texture ? 0xffffff : this.color);
161
+ this.mesh = instance.anchor;
162
+ const settled = batches.settled(key);
163
+ if (!settled)
164
+ return;
165
+ reportRejection(settled.then((outcome) => {
166
+ // CA-4's failure rule, copy-on-write: only this sprite leaves the
167
+ // shared entry, for the untextured one of its shape, with its color.
168
+ if (outcome !== 'failed' || spriteInstanceOf(this.mesh) !== instance)
169
+ return;
170
+ instance.moveTo({ ...instance.key, texture: null });
171
+ instance.color.setHex(this.color);
172
+ }), 'sprite texture settle');
131
173
  }
132
174
  onDestroy() {
175
+ const instance = spriteInstanceOf(this.mesh);
176
+ if (instance) {
177
+ instance.release();
178
+ this.mesh = undefined;
179
+ return;
180
+ }
133
181
  this.mesh?.removeFromParent();
134
182
  this.mesh?.geometry.dispose();
135
183
  // Only this sprite's clone: the cached base lives with the Game.
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
  /**
@@ -101,6 +103,8 @@ export declare class Game {
101
103
  simulate: boolean;
102
104
  private readonly renderer;
103
105
  private readonly resizeObserver;
106
+ /** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
107
+ private readonly spriteBatches;
104
108
  private readonly updateFns;
105
109
  private readonly invalidUpdateCompositions;
106
110
  /**
@@ -233,8 +237,8 @@ export declare class Game {
233
237
  */
234
238
  private runFrame;
235
239
  /**
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,
240
+ * One Simulation Step: the pad is read first (issue #75 CA-6), then
241
+ * game.time's start-of-step pass (ADR 0017, CA-3), then the Component Update Schedule (ADR 0004) in full,
238
242
  * collisions, the scene camera and the host's callbacks, every one of
239
243
  * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
240
244
  */
package/dist/game.js CHANGED
@@ -18,6 +18,7 @@ import { projectIsometric, unprojectIsometric } from './projection.js';
18
18
  import { isYSortBatchParticipant, isYSortParticipant, ySortZ, } from './render-sort.js';
19
19
  import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
20
20
  import { sceneDrainsOf } from './scene-drains.js';
21
+ import { createSpriteBatches } from './sprite-batches.js';
21
22
  import { createSpatialQuery } from './spatial-query.js';
22
23
  import { Stats } from './stats.js';
23
24
  import { anchoredPiecesOf, GameUi } from './ui.js';
@@ -72,6 +73,8 @@ export class Game {
72
73
  // TODO(H1): migrate to WebGPURenderer (three/webgpu) with automatic WebGL2 fallback.
73
74
  renderer;
74
75
  resizeObserver;
76
+ /** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
77
+ spriteBatches;
75
78
  updateFns = new Set();
76
79
  invalidUpdateCompositions = new WeakMap();
77
80
  /**
@@ -109,7 +112,7 @@ export class Game {
109
112
  this.baseViewHeight = viewHeight;
110
113
  this.viewHeight = viewHeight;
111
114
  this.resolution = options.resolution ?? null;
112
- this.input = new Input(options.bindings);
115
+ this.input = new Input(options.bindings, { gamepadDeadZone: options.gamepadDeadZone });
113
116
  this.query = createSpatialQuery(this);
114
117
  this.stats = new Stats(options.stats);
115
118
  this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
@@ -133,6 +136,7 @@ export class Game {
133
136
  // catalog is registered at call time, which outlives every scene.
134
137
  resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
135
138
  });
139
+ this.spriteBatches = createSpriteBatches(this, this.assets);
136
140
  this.cameraEffects = new CameraEffects({
137
141
  host: () => canvas.parentElement ?? document.body,
138
142
  // One screen pixel in world units: the shake snaps to it (never the base).
@@ -205,6 +209,8 @@ export class Game {
205
209
  entity.destroy();
206
210
  // Drains transferred by the destruction cascade above are scene-scoped too.
207
211
  sceneDrainsOf(this).clear();
212
+ // After the cascade freed every slot: the batches go with their scene.
213
+ this.spriteBatches.unload();
208
214
  this.registry = null;
209
215
  this.renderSort = null;
210
216
  this.sceneProjection = null;
@@ -299,6 +305,7 @@ export class Game {
299
305
  */
300
306
  setSceneRender(json) {
301
307
  this.renderSort = json?.sort === 'y' ? 'y' : null;
308
+ this.spriteBatches.enabled = json?.batch !== false;
302
309
  const projection = json?.projection === 'isometric' ? 'isometric' : null;
303
310
  if (projection === this.sceneProjection)
304
311
  return;
@@ -339,9 +346,10 @@ export class Game {
339
346
  step: (onStep) => this.runFrame(1, onStep),
340
347
  resume: (onStep) => this.resumeRuntime(onStep),
341
348
  pause: () => this.stop(),
342
- injectAction: (action, operation) => this.input.injectAction(action, operation),
349
+ injectAction: (action, operation, value) => this.input.injectAction(action, operation, value),
343
350
  availableActions: () => this.input.availableActions(),
344
351
  heldActions: () => this.input.heldActions(),
352
+ actionValues: () => this.input.actionValues(),
345
353
  inspect: (metadata, filters) => inspector.snapshot(metadata, filters),
346
354
  click: (x, y) => {
347
355
  this.pointer.injectClick(x, y);
@@ -398,6 +406,7 @@ export class Game {
398
406
  for (const entity of [...this.entities])
399
407
  entity.destroy();
400
408
  sceneDrainsOf(this).clear();
409
+ this.spriteBatches.unload();
401
410
  // After the entities: their clones go with the cascade above, the
402
411
  // cached bases go here, exactly once (ADR 0019).
403
412
  this.assets.dispose();
@@ -489,12 +498,13 @@ export class Game {
489
498
  }
490
499
  }
491
500
  /**
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,
501
+ * One Simulation Step: the pad is read first (issue #75 CA-6), then
502
+ * game.time's start-of-step pass (ADR 0017, CA-3), then the Component Update Schedule (ADR 0004) in full,
494
503
  * collisions, the scene camera and the host's callbacks, every one of
495
504
  * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
496
505
  */
497
506
  simulateStep() {
507
+ this.input.pollGamepad();
498
508
  advanceGameTime(this.time);
499
509
  // Existing drains advance before entity updates, so one detached during
500
510
  // this step begins on the next Simulation Step (ADR 0022).
@@ -600,7 +610,7 @@ export class Game {
600
610
  this.renderer.clear(true, false, false);
601
611
  this.renderer.setScissorTest(true);
602
612
  }
603
- this.renderer.render(this.scene, this.camera);
613
+ this.spriteBatches.drawFrame(this.scene, this.camera, () => this.renderer.render(this.scene, this.camera));
604
614
  }
605
615
  finally {
606
616
  this.camera.position.x = x;
@@ -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';