@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/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/dist/scene.d.ts CHANGED
@@ -37,6 +37,16 @@ export interface SceneRenderJson {
37
37
  sort?: 'y';
38
38
  /** Logical positions are projected to the fixed 2:1 render lattice. */
39
39
  projection?: 'isometric';
40
+ /**
41
+ * Sprite Batches (ADR 0024). Absent or true: sprites that share art —
42
+ * same texture (an AnimatedSprite's current sheet), pixel-art filtering and
43
+ * shape — and sit next to each other in draw order are drawn together in
44
+ * one call, each one an instance with its own placement, color and frame;
45
+ * what is drawn in front of what never changes. false: one mesh, one
46
+ * material and one draw call per sprite, the pre-batching path — set it to
47
+ * rule batching out of a visual difference or while debugging draw order.
48
+ */
49
+ batch?: boolean;
40
50
  }
41
51
  export interface SceneJson {
42
52
  waicaScene: 1 | 2 | 3;
@@ -15,7 +15,13 @@ export interface SpatialBroadphaseStats {
15
15
  export interface SpatialBroadphase<T> {
16
16
  readonly stats: SpatialBroadphaseStats;
17
17
  candidates(bounds: CollisionBounds): T[];
18
- pairs(): Array<readonly [T, T]>;
18
+ /**
19
+ * Visits every distinct pair sharing a cell, or involving an overflow
20
+ * entry, once, in ascending query order. The grid is frozen at creation,
21
+ * so callbacks that move, spawn or destroy entities do not change which
22
+ * pairs are visited.
23
+ */
24
+ forEachPair(visit: (first: T, second: T) => void): void;
19
25
  }
20
26
  /**
21
27
  * Package-internal fresh uniform grid. Bodies above the fixed occupancy cap
@@ -4,6 +4,7 @@ import { Hitbox } from './components/hitbox.js';
4
4
  import { Solid } from './components/solid.js';
5
5
  import { Tilemap } from './components/tilemap.js';
6
6
  import { sceneSolids } from './scene-solids.js';
7
+ import { forEachCandidatePair } from './spatial-pairs.js';
7
8
  import { usableCollisionBody } from './spatial-query-geometry.js';
8
9
  const CELL_OCCUPANCY_CAP = 256;
9
10
  function rangeSize(range) {
@@ -54,13 +55,6 @@ function entryValue(entries, index) {
54
55
  throw new Error(`Spatial broadphase has no entry at index ${index}`);
55
56
  return entry.value;
56
57
  }
57
- /** The entry index stored at `position` of a bucket that is iterated within its bounds. */
58
- function indexAt(bucket, position) {
59
- const index = bucket[position];
60
- if (index === undefined)
61
- throw new Error(`Spatial broadphase bucket has no index at position ${position}`);
62
- return index;
63
- }
64
58
  /** The indexable sources in query order, each with the cell range it covers. */
65
59
  function spatialEntries(sources, cellSize, isIndexable) {
66
60
  return sources
@@ -79,47 +73,25 @@ function spatialEntries(sources, cellSize, isIndexable) {
79
73
  function spatialGrid(entries) {
80
74
  const buckets = new Map();
81
75
  const overflow = [];
76
+ const entryBuckets = [];
82
77
  for (const [index, entry] of entries.entries()) {
78
+ const own = [];
79
+ entryBuckets.push(own);
83
80
  if (!entry.range) {
84
81
  overflow.push(index);
85
82
  continue;
86
83
  }
87
84
  eachCell(entry.range, (key) => {
88
- const bucket = buckets.get(key);
89
- if (bucket)
90
- bucket.push(index);
91
- else
92
- buckets.set(key, [index]);
93
- });
94
- }
95
- return { buckets, overflow };
96
- }
97
- /** Distinct index pairs sharing a cell, or involving an overflow entry, in ascending order. */
98
- function candidatePairIndices(grid, entryCount) {
99
- const pairKeys = new Set();
100
- const width = entryCount;
101
- const addPair = (left, right) => {
102
- if (left === right)
103
- return;
104
- const first = Math.min(left, right);
105
- const second = Math.max(left, right);
106
- pairKeys.add(first * width + second);
107
- };
108
- for (const bucket of grid.buckets.values()) {
109
- for (let left = 0; left < bucket.length; left += 1) {
110
- for (let right = left + 1; right < bucket.length; right += 1) {
111
- addPair(indexAt(bucket, left), indexAt(bucket, right));
85
+ let bucket = buckets.get(key);
86
+ if (!bucket) {
87
+ bucket = [];
88
+ buckets.set(key, bucket);
112
89
  }
113
- }
114
- }
115
- for (const overflowIndex of grid.overflow) {
116
- for (let index = 0; index < entryCount; index += 1) {
117
- addPair(overflowIndex, index);
118
- }
90
+ bucket.push(index);
91
+ own.push(bucket);
92
+ });
119
93
  }
120
- return [...pairKeys]
121
- .map((key) => [Math.floor(key / width), key % width])
122
- .sort(([a1, a2], [b1, b2]) => a1 - b1 || a2 - b2);
94
+ return { buckets, overflow, entryBuckets };
123
95
  }
124
96
  /**
125
97
  * Package-internal fresh uniform grid. Bodies above the fixed occupancy cap
@@ -144,11 +116,10 @@ export function createSpatialBroadphase(sources, cellSize, isIndexable = usableC
144
116
  .sort((a, b) => a - b)
145
117
  .map((index) => entryValue(entries, index));
146
118
  },
147
- pairs() {
148
- return candidatePairIndices(grid, entries.length).map(([first, second]) => [
149
- entryValue(entries, first),
150
- entryValue(entries, second),
151
- ]);
119
+ forEachPair(visit) {
120
+ forEachCandidatePair(grid, (first, second) => {
121
+ visit(entryValue(entries, first), entryValue(entries, second));
122
+ });
152
123
  },
153
124
  };
154
125
  }
@@ -0,0 +1,17 @@
1
+ /** The grid shape the pair walk needs: which buckets each entry is in, and the overflow. */
2
+ export interface PairGrid {
3
+ /** Entries too large to bucket, ascending; they pair with every other entry. */
4
+ readonly overflow: readonly number[];
5
+ /** Per entry, the buckets it was added to, each ascending by entry index (empty for overflow entries). */
6
+ readonly entryBuckets: readonly (readonly (readonly number[])[])[];
7
+ }
8
+ type PairVisitor = (first: number, second: number) => void;
9
+ /**
10
+ * Visits every distinct index pair sharing a cell, or involving an overflow
11
+ * entry, in ascending (first, second) order. Buckets fill in index order, so
12
+ * each one is already ascending: a body in one cell, with no overflow after
13
+ * it, needs no deduplication or sort. Equivalent to collecting all pairs,
14
+ * deduplicating and sorting them, without allocating per pair.
15
+ */
16
+ export declare function forEachCandidatePair(grid: PairGrid, visit: PairVisitor): void;
17
+ export {};