@waica/engine 0.14.1 → 0.15.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
@@ -6,6 +6,52 @@ Waica's public engine core: entities and components, the game loop, scene and pr
6
6
  import { Component, Game, loadScene } from '@waica/engine'
7
7
  ```
8
8
 
9
+ ## Hitbox Collision Layers and Masks
10
+
11
+ Every `Hitbox` belongs to one named Collision Layer and declares the other layers in which it is interested through a Collision Mask:
12
+
13
+ ```ts
14
+ import { Hitbox } from '@waica/engine'
15
+
16
+ entity.add(Hitbox, {
17
+ layer: 'projectile',
18
+ collidesWith: ['enemy'],
19
+ })
20
+ ```
21
+
22
+ A layer must match `^[a-z][a-z0-9-]*$`. Layer names form an open, project-owned vocabulary; matching is exact and case-sensitive. `"*"` is valid only in a mask and matches every valid layer. An empty mask means no outgoing interest. Invalid layers cannot be targeted, and invalid or non-string mask entries are ignored without runtime coercion or warnings.
23
+
24
+ A pair reaches exact overlap testing when either side's mask names the other side's layer. After an overlap, only an interested side receives `onCollide`; a one-way projectile mask therefore does not notify its target. Omitted fields retain the compatibility defaults `layer: 'default'` and `collidesWith: ['*']`, so two unconfigured Hitboxes still notify both sides.
25
+
26
+ Layers and masks are read while the collision-pair snapshot is consumed. A component can change them during `onUpdate` or one callback and affect later pairs in that Simulation Step. Pair order remains the original lexicographic Entity order, component callbacks remain in insertion order, and destroyed entities are still skipped between sides.
27
+
28
+ ### Shipped taxonomy and migration
29
+
30
+ Waica's shipped prefabs use this directional policy:
31
+
32
+ | Hitbox owner | `layer` | `collidesWith` |
33
+ | --- | --- | --- |
34
+ | Player | `player` | `['*']` |
35
+ | Slime, blob, or orc / `Hazard` | `enemy` | `['player']` |
36
+ | Coin, potion, or crate / `Collectible` | `collectible` | `['player']` |
37
+ | Overlap `SceneTransition` | `scene-transition` | `['player']` |
38
+ | Platformer projectile | `projectile` | `['enemy']` |
39
+
40
+ `Collectible`, `Hazard`, overlap `SceneTransition`, and the example projectile trust those masks and no longer recheck the other Entity's role inside `onCollide`. Existing external projects using any of these handlers must explicitly migrate their sibling Hitboxes before relying on the new behavior. The defaults preserve engine-level callback delivery, but a wildcard-backed migrated handler can act on unintended default-layer entities.
41
+
42
+ 1. Assign `player` / `['*']` to player Hitboxes.
43
+ 2. Assign the relevant row above to each shipped handler carrier, or choose equivalent project-owned names.
44
+ 3. Run MCP `validate_project`; malformed values are errors and duplicate mask entries are warnings.
45
+ 4. Keep `trigger: 'interact'` Scene Transitions unchanged—their Hitbox mask is relevant only to overlap mode.
46
+
47
+ The editor and MCP author explicit categories for newly generated player/enemy identities. NPCs, custom or identity-less characters, generic objects, and existing external project files are not inferred or rewritten. `public/waica.params.json` may override either field with the same exact values, including a `string[]` mask.
48
+
49
+ ## Collision broadphase
50
+
51
+ The Game uses fresh internal uniform grids to accelerate automatic Hitbox dispatch, Hitbox-backed `area`/`point` queries, and Solid-backed `ray` queries. Hitboxes and Solids stay in separate domains. `nearest`, `DynamicBody` physical-contact solving, Pointer picking, navigation, and unrelated scans remain linear or otherwise unchanged.
52
+
53
+ The grid changes candidate discovery only: existing exact geometry is still authoritative. Each operation observes candidates alive at its start, preserves Entity or Solid source order and tie behavior, and recomputes after scene swaps, spawns, movement, shape edits, or Tilemap-derived Solid changes. Oversized bodies and query regions fall back conservatively, so they may cost more but cannot lose results. Grid sizing, occupancy limits, indices, and rebuild controls are deliberately package-internal and have no public tuning API.
54
+
9
55
  ## Logical spatial queries
10
56
 
11
57
  Every `Game` owns one stable `game.query` service. Queries use logical XY coordinates, including in isometric scenes, and return typed live engine objects:
@@ -45,7 +91,7 @@ Calls eagerly snapshot candidates that are alive at call start. Results preserve
45
91
 
46
92
  Invalid inputs fail closed without throwing: invalid `area` bodies and non-finite point coordinates return `[]`; invalid nearest coordinates or radii return `null`; and ray returns `null` for non-finite values, a zero direction, or a negative distance. Nearest permits positive `Infinity`; ray distance must be finite and may be zero. Zero-area candidate geometry never matches.
47
93
 
48
- Area and point intentionally use the collision system's polygonally approximated circle/ellipse outline. Ray queries intersect circle-shaped Solids as analytic ellipses and return their exact outward unit normal.
94
+ Area and point intentionally use the collision system's polygonally approximated circle/ellipse outline. Ray queries intersect circle-shaped Solids as analytic ellipses and return their exact outward unit normal. Collision Layers and Masks never filter `area` or `point`; use a query filter when category-like eligibility is needed.
49
95
 
50
96
  ## Component lifecycle
51
97
 
@@ -0,0 +1,4 @@
1
+ /** Package-internal runtime syntax check for one Collision Layer. */
2
+ export declare function validCollisionLayer(value: unknown): value is string;
3
+ /** Package-internal exact directional interest check. */
4
+ export declare function collisionMaskTargets(mask: unknown, targetLayer: unknown): boolean;
@@ -0,0 +1,11 @@
1
+ const COLLISION_LAYER_PATTERN = /^[a-z][a-z0-9-]*$/;
2
+ /** Package-internal runtime syntax check for one Collision Layer. */
3
+ export function validCollisionLayer(value) {
4
+ return typeof value === 'string' && COLLISION_LAYER_PATTERN.test(value);
5
+ }
6
+ /** Package-internal exact directional interest check. */
7
+ export function collisionMaskTargets(mask, targetLayer) {
8
+ if (!validCollisionLayer(targetLayer) || !Array.isArray(mask))
9
+ return false;
10
+ return mask.some((entry) => typeof entry === 'string' && (entry === '*' || entry === targetLayer));
11
+ }
@@ -0,0 +1,8 @@
1
+ import type { Game } from './game.js';
2
+ /** Package-internal deterministic work receipt used by focused tests. */
3
+ export interface CollisionDispatchStats {
4
+ readonly candidatePairs: number;
5
+ readonly narrowphaseCalls: number;
6
+ }
7
+ /** Game's package-internal trigger dispatch implementation. */
8
+ export declare function dispatchCollisions(game: Game): CollisionDispatchStats;
@@ -0,0 +1,36 @@
1
+ import { collisionBody } from './collision-body.js';
2
+ import { collisionMaskTargets } from './collision-category.js';
3
+ import { collisionOverlap } from './collision-shape.js';
4
+ import { Hitbox } from './components/hitbox.js';
5
+ import { createHitboxBroadphase } from './spatial-broadphase.js';
6
+ /** Game's package-internal trigger dispatch implementation. */
7
+ export function dispatchCollisions(game) {
8
+ const pairs = createHitboxBroadphase(game).pairs();
9
+ let narrowphaseCalls = 0;
10
+ for (const [first, second] of pairs) {
11
+ const a = first.entity;
12
+ const b = second.entity;
13
+ if (!a.alive || !b.alive)
14
+ continue;
15
+ if (a.get(Hitbox) !== first.hitbox || b.get(Hitbox) !== second.hitbox)
16
+ continue;
17
+ const notifyA = collisionMaskTargets(first.hitbox.collidesWith, second.hitbox.layer);
18
+ const notifyB = collisionMaskTargets(second.hitbox.collidesWith, first.hitbox.layer);
19
+ if (!notifyA && !notifyB)
20
+ continue;
21
+ narrowphaseCalls += 1;
22
+ if (!collisionOverlap(collisionBody(first.hitbox), collisionBody(second.hitbox)))
23
+ continue;
24
+ if (notifyA) {
25
+ for (const component of [...a.components])
26
+ component.onCollide?.(b);
27
+ }
28
+ if (!a.alive || !b.alive)
29
+ continue;
30
+ if (notifyB) {
31
+ for (const component of [...b.components])
32
+ component.onCollide?.(a);
33
+ }
34
+ }
35
+ return { candidatePairs: pairs.length, narrowphaseCalls };
36
+ }
@@ -7,6 +7,8 @@ export interface ParamSpec {
7
7
  min?: number;
8
8
  max?: number;
9
9
  step?: number;
10
+ /** Specialized editor control for values that are otherwise plain JSON. */
11
+ kind?: 'string-list';
10
12
  /** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
11
13
  options?: string[];
12
14
  /** Project value this string param names; rendered and validated as a typed reference. */
@@ -65,7 +67,7 @@ export declare abstract class Component {
65
67
  onUpdate?(dt: number): void;
66
68
  /** Runs after the scene changes between identity and projected rendering. */
67
69
  onProjectionChange?(projection: 'isometric' | null): void;
68
- /** Runs when this entity's Hitbox overlaps another one's. */
70
+ /** Runs on overlap when this entity's Hitbox mask names the other's layer. */
69
71
  onCollide?(other: Entity): void;
70
72
  /** Runs when this entity's DynamicBody physically contacts a Solid. */
71
73
  onContact?(contact: SolidContact): void;
@@ -1,13 +1,21 @@
1
1
  import { Component } from '../component.js';
2
2
  import { type CollisionPoint, type CollisionShape } from '../collision-shape.js';
3
3
  /**
4
- * Trigger collider: the Game detects overlaps between Hitboxes and calls
5
- * onCollide(other) on both entities' components. For static physical
4
+ * Trigger collider. A pair is tested when either Hitbox's Collision Mask
5
+ * names the other's Collision Layer; only each interested owner receives
6
+ * `onCollide`. Spatial Queries ignore categories. For static physical
6
7
  * collision see Solid.
7
8
  */
8
9
  export declare class Hitbox extends Component {
9
10
  static componentName: string;
10
11
  static params: {
12
+ layer: {
13
+ label: string;
14
+ };
15
+ collidesWith: {
16
+ label: string;
17
+ kind: 'string-list';
18
+ };
11
19
  offsetX: {
12
20
  label: string;
13
21
  };
@@ -15,6 +23,10 @@ export declare class Hitbox extends Component {
15
23
  label: string;
16
24
  };
17
25
  };
26
+ /** One exact, case-sensitive `^[a-z][a-z0-9-]*$` membership name. */
27
+ layer: string;
28
+ /** Outgoing layer interest; `'*'` means every valid layer and `[]` means none. */
29
+ collidesWith: string[];
18
30
  shape: CollisionShape;
19
31
  width: number;
20
32
  height: number;
@@ -1,16 +1,23 @@
1
1
  import { Component } from '../component.js';
2
2
  import { resolveCollisionPoints, } from '../collision-shape.js';
3
3
  /**
4
- * Trigger collider: the Game detects overlaps between Hitboxes and calls
5
- * onCollide(other) on both entities' components. For static physical
4
+ * Trigger collider. A pair is tested when either Hitbox's Collision Mask
5
+ * names the other's Collision Layer; only each interested owner receives
6
+ * `onCollide`. Spatial Queries ignore categories. For static physical
6
7
  * collision see Solid.
7
8
  */
8
9
  export class Hitbox extends Component {
9
10
  static componentName = 'Hitbox';
10
11
  static params = {
12
+ layer: { label: 'Collision Layer' },
13
+ collidesWith: { label: 'Collision Mask', kind: 'string-list' },
11
14
  offsetX: { label: 'x offset' },
12
15
  offsetY: { label: 'y offset' },
13
16
  };
17
+ /** One exact, case-sensitive `^[a-z][a-z0-9-]*$` membership name. */
18
+ layer = 'default';
19
+ /** Outgoing layer interest; `'*'` means every valid layer and `[]` means none. */
20
+ collidesWith = ['*'];
14
21
  shape = 'rectangle';
15
22
  width = 1;
16
23
  height = 1;
package/dist/game.d.ts CHANGED
@@ -49,7 +49,7 @@ export interface SceneCatalog {
49
49
  registry: SceneRegistry;
50
50
  }
51
51
  /** Persisted overrides: entity → componentName → prop → value. */
52
- export type ParamOverrides = Record<string, Record<string, Record<string, number | boolean | string>>>;
52
+ export type ParamOverrides = Record<string, Record<string, Record<string, number | boolean | string | string[]>>>;
53
53
  /**
54
54
  * Engine core: loop, unified 2D/3D three scene, orthographic camera,
55
55
  * entities with components, and input. See DESIGN.md.
@@ -137,8 +137,8 @@ export declare class Game {
137
137
  * the live scene. An unknown name warns and leaves the live scene
138
138
  * untouched. Triggered mid-frame (e.g. from a SceneTransition's
139
139
  * onCollide/onInteract) the swap is deferred to the very start of the
140
- * next runFrame — dispatchCollisions finishes its double loop over the
141
- * outgoing scene, and the incoming scene's entities are present only
140
+ * next runFrame — dispatchCollisions finishes its frozen pair snapshot for
141
+ * the outgoing scene, and the incoming scene's entities are present only
142
142
  * from the next frame. Called from outside a frame (boot, or the Runtime
143
143
  * Bridge's `scene` control operation) it applies synchronously and wins
144
144
  * over anything queued earlier this frame. A second mid-frame request
package/dist/game.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import * as THREE from 'three';
2
2
  import { AudioSubsystem } from './audio/audio-subsystem.js';
3
- import { collisionBody } from './collision-body.js';
4
- import { collisionOverlap } from './collision-shape.js';
3
+ import { dispatchCollisions as dispatchHitboxCollisions } from './collision-dispatch.js';
5
4
  import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
6
5
  import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
7
- import { Hitbox } from './components/hitbox.js';
8
6
  import { Entity } from './entity.js';
9
7
  import { Emitter } from './events.js';
10
8
  import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
@@ -181,8 +179,8 @@ export class Game {
181
179
  * the live scene. An unknown name warns and leaves the live scene
182
180
  * untouched. Triggered mid-frame (e.g. from a SceneTransition's
183
181
  * onCollide/onInteract) the swap is deferred to the very start of the
184
- * next runFrame — dispatchCollisions finishes its double loop over the
185
- * outgoing scene, and the incoming scene's entities are present only
182
+ * next runFrame — dispatchCollisions finishes its frozen pair snapshot for
183
+ * the outgoing scene, and the incoming scene's entities are present only
186
184
  * from the next frame. Called from outside a frame (boot, or the Runtime
187
185
  * Bridge's `scene` control operation) it applies synchronously and wins
188
186
  * over anything queued earlier this frame. A second mid-frame request
@@ -595,28 +593,7 @@ export class Game {
595
593
  return this.sceneProjection === 'isometric' ? unprojectIsometric(x, y) : { x, y };
596
594
  }
597
595
  dispatchCollisions() {
598
- const boxed = this.entities.filter((e) => e.has(Hitbox));
599
- for (let i = 0; i < boxed.length; i++) {
600
- for (let j = i + 1; j < boxed.length; j++) {
601
- const a = boxed[i];
602
- const b = boxed[j];
603
- if (!a?.alive || !b?.alive)
604
- continue;
605
- const ha = a.get(Hitbox);
606
- const hb = b.get(Hitbox);
607
- if (!ha || !hb)
608
- continue;
609
- const hit = collisionOverlap(collisionBody(ha), collisionBody(hb));
610
- if (!hit)
611
- continue;
612
- for (const c of [...a.components])
613
- c.onCollide?.(b);
614
- if (!a.alive || !b.alive)
615
- continue;
616
- for (const c of [...b.components])
617
- c.onCollide?.(a);
618
- }
619
- }
596
+ dispatchHitboxCollisions(this);
620
597
  }
621
598
  resize() {
622
599
  const canvas = this.renderer.domElement;
@@ -0,0 +1,38 @@
1
+ import { type CollisionBody, type CollisionBounds } from './collision-shape.js';
2
+ import { Hitbox } from './components/hitbox.js';
3
+ import { Solid } from './components/solid.js';
4
+ import type { Entity } from './entity.js';
5
+ import type { Game } from './game.js';
6
+ export interface SpatialBroadphaseSource<T> {
7
+ readonly value: T;
8
+ readonly order: number;
9
+ readonly body: CollisionBody;
10
+ }
11
+ export interface SpatialBroadphaseStats {
12
+ readonly indexed: number;
13
+ readonly overflow: number;
14
+ }
15
+ export interface SpatialBroadphase<T> {
16
+ readonly stats: SpatialBroadphaseStats;
17
+ candidates(bounds: CollisionBounds): T[];
18
+ pairs(): Array<readonly [T, T]>;
19
+ }
20
+ /**
21
+ * Package-internal fresh uniform grid. Bodies above the fixed occupancy cap
22
+ * remain in an overflow bucket and are conservatively visible everywhere.
23
+ */
24
+ export declare function createSpatialBroadphase<T>(sources: readonly SpatialBroadphaseSource<T>[], cellSize: number, isIndexable?: (body: CollisionBody) => boolean): SpatialBroadphase<T>;
25
+ /** Smallest current live Tilemap cell, or one logical unit. */
26
+ export declare function broadphaseCellSize(game: Game): number;
27
+ export interface HitboxBroadphaseCandidate {
28
+ readonly entity: Entity;
29
+ readonly hitbox: Hitbox;
30
+ readonly entityIndex: number;
31
+ }
32
+ export interface SolidBroadphaseCandidate {
33
+ readonly entity: Entity;
34
+ readonly solid: Solid;
35
+ readonly sourceIndex: number;
36
+ }
37
+ export declare function createHitboxBroadphase(game: Game): SpatialBroadphase<HitboxBroadphaseCandidate>;
38
+ export declare function createSolidBroadphase(game: Game): SpatialBroadphase<SolidBroadphaseCandidate>;
@@ -0,0 +1,180 @@
1
+ import { collisionBody } from './collision-body.js';
2
+ import { collisionBounds, } from './collision-shape.js';
3
+ import { Hitbox } from './components/hitbox.js';
4
+ import { Solid } from './components/solid.js';
5
+ import { Tilemap } from './components/tilemap.js';
6
+ import { sceneSolids } from './scene-solids.js';
7
+ import { usableCollisionBody } from './spatial-query-geometry.js';
8
+ const CELL_OCCUPANCY_CAP = 256;
9
+ function rangeSize(range) {
10
+ const width = range.maxX - range.minX + 1;
11
+ const height = range.maxY - range.minY + 1;
12
+ if (!Number.isSafeInteger(width) ||
13
+ !Number.isSafeInteger(height) ||
14
+ width <= 0 ||
15
+ height <= 0 ||
16
+ width > CELL_OCCUPANCY_CAP ||
17
+ height > CELL_OCCUPANCY_CAP ||
18
+ width * height > CELL_OCCUPANCY_CAP) {
19
+ return null;
20
+ }
21
+ return width * height;
22
+ }
23
+ function cellRange(bounds, cellSize) {
24
+ const left = Math.min(bounds.left, bounds.right);
25
+ const right = Math.max(bounds.left, bounds.right);
26
+ const bottom = Math.min(bounds.bottom, bounds.top);
27
+ const top = Math.max(bounds.bottom, bounds.top);
28
+ if (![left, right, bottom, top, cellSize].every(Number.isFinite) || cellSize <= 0) {
29
+ return null;
30
+ }
31
+ const range = {
32
+ minX: Math.floor(left / cellSize),
33
+ maxX: Math.floor(right / cellSize),
34
+ minY: Math.floor(bottom / cellSize),
35
+ maxY: Math.floor(top / cellSize),
36
+ };
37
+ if (!Object.values(range).every(Number.isSafeInteger))
38
+ return null;
39
+ return rangeSize(range) === null ? null : range;
40
+ }
41
+ function cellKey(x, y) {
42
+ return `${x}:${y}`;
43
+ }
44
+ function eachCell(range, visit) {
45
+ for (let x = range.minX; x <= range.maxX; x += 1) {
46
+ for (let y = range.minY; y <= range.maxY; y += 1)
47
+ visit(cellKey(x, y));
48
+ }
49
+ }
50
+ /**
51
+ * Package-internal fresh uniform grid. Bodies above the fixed occupancy cap
52
+ * remain in an overflow bucket and are conservatively visible everywhere.
53
+ */
54
+ export function createSpatialBroadphase(sources, cellSize, isIndexable = usableCollisionBody) {
55
+ const entries = sources
56
+ .map((source, serial) => {
57
+ if (!isIndexable(source.body))
58
+ return null;
59
+ return {
60
+ ...source,
61
+ serial,
62
+ range: cellRange(collisionBounds(source.body), cellSize),
63
+ };
64
+ })
65
+ .filter((entry) => entry !== null)
66
+ .sort((a, b) => a.order - b.order || a.serial - b.serial);
67
+ const buckets = new Map();
68
+ const overflow = [];
69
+ for (const [index, entry] of entries.entries()) {
70
+ if (!entry.range) {
71
+ overflow.push(index);
72
+ continue;
73
+ }
74
+ eachCell(entry.range, (key) => {
75
+ const bucket = buckets.get(key);
76
+ if (bucket)
77
+ bucket.push(index);
78
+ else
79
+ buckets.set(key, [index]);
80
+ });
81
+ }
82
+ return {
83
+ stats: { indexed: entries.length - overflow.length, overflow: overflow.length },
84
+ candidates(bounds) {
85
+ const range = cellRange(bounds, cellSize);
86
+ if (!range)
87
+ return entries.map((entry) => entry.value);
88
+ const selected = new Set(overflow);
89
+ eachCell(range, (key) => {
90
+ for (const index of buckets.get(key) ?? [])
91
+ selected.add(index);
92
+ });
93
+ return [...selected]
94
+ .sort((a, b) => a - b)
95
+ .map((index) => entries[index].value);
96
+ },
97
+ pairs() {
98
+ const pairKeys = new Set();
99
+ const width = entries.length;
100
+ const addPair = (left, right) => {
101
+ if (left === right)
102
+ return;
103
+ const first = Math.min(left, right);
104
+ const second = Math.max(left, right);
105
+ pairKeys.add(first * width + second);
106
+ };
107
+ for (const bucket of buckets.values()) {
108
+ for (let left = 0; left < bucket.length; left += 1) {
109
+ for (let right = left + 1; right < bucket.length; right += 1) {
110
+ addPair(bucket[left], bucket[right]);
111
+ }
112
+ }
113
+ }
114
+ for (const overflowIndex of overflow) {
115
+ for (let index = 0; index < entries.length; index += 1) {
116
+ addPair(overflowIndex, index);
117
+ }
118
+ }
119
+ return [...pairKeys]
120
+ .map((key) => [Math.floor(key / width), key % width])
121
+ .sort(([a1, a2], [b1, b2]) => a1 - b1 || a2 - b2)
122
+ .map(([first, second]) => [
123
+ entries[first].value,
124
+ entries[second].value,
125
+ ]);
126
+ },
127
+ };
128
+ }
129
+ /** Smallest current live Tilemap cell, or one logical unit. */
130
+ export function broadphaseCellSize(game) {
131
+ let result = Infinity;
132
+ for (const entity of [...game.entities]) {
133
+ if (!entity.alive)
134
+ continue;
135
+ const size = entity.get(Tilemap)?.cellSize;
136
+ if (typeof size === 'number' && Number.isFinite(size) && size > 0) {
137
+ result = Math.min(result, size);
138
+ }
139
+ }
140
+ return result === Infinity ? 1 : result;
141
+ }
142
+ export function createHitboxBroadphase(game) {
143
+ const sources = [];
144
+ for (const [entityIndex, entity] of [...game.entities].entries()) {
145
+ if (!entity.alive)
146
+ continue;
147
+ const hitbox = entity.get(Hitbox);
148
+ if (!hitbox)
149
+ continue;
150
+ sources.push({
151
+ value: { entity, hitbox, entityIndex },
152
+ order: entityIndex,
153
+ body: collisionBody(hitbox),
154
+ });
155
+ }
156
+ return createSpatialBroadphase(sources, broadphaseCellSize(game));
157
+ }
158
+ function rayIndexableBody(body) {
159
+ if (body.shape !== 'circle')
160
+ return usableCollisionBody(body);
161
+ return (Number.isFinite(body.x) &&
162
+ Number.isFinite(body.y) &&
163
+ Number.isFinite(body.width) &&
164
+ Number.isFinite(body.height) &&
165
+ body.width !== 0 &&
166
+ body.height !== 0);
167
+ }
168
+ export function createSolidBroadphase(game) {
169
+ const sources = [];
170
+ for (const [sourceIndex, solid] of [...sceneSolids(game)].entries()) {
171
+ if (!solid.entity.alive)
172
+ continue;
173
+ sources.push({
174
+ value: { entity: solid.entity, solid, sourceIndex },
175
+ order: sourceIndex,
176
+ body: collisionBody(solid),
177
+ });
178
+ }
179
+ return createSpatialBroadphase(sources, broadphaseCellSize(game), rayIndexableBody);
180
+ }
@@ -1,4 +1,4 @@
1
- import { type CollisionBody } from './collision-shape.js';
1
+ import { type CollisionBody, type CollisionBounds } from './collision-shape.js';
2
2
  import type { Component, ComponentClass } from './component.js';
3
3
  import { Hitbox } from './components/hitbox.js';
4
4
  import { Solid } from './components/solid.js';
@@ -56,11 +56,11 @@ type NearestGuardFilter<Classes extends ComponentClasses, Narrowed extends Query
56
56
  };
57
57
  /** Public logical-space spatial-query service owned by every Game. */
58
58
  export interface SpatialQuery {
59
- /** Hitbox owners with positive interior overlap, in Entity order. */
59
+ /** Hitbox owners with positive interior overlap, mask-agnostic and in Entity order. */
60
60
  area<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(body: CollisionBody, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
61
61
  area<Classes extends ComponentClasses = readonly []>(body: CollisionBody, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
62
62
  area(body: CollisionBody, filter?: SpatialQueryFilter): Entity[];
63
- /** Hitbox owners that strictly contain a logical point, in Entity order. */
63
+ /** Hitbox owners that strictly contain a point, mask-agnostic and in Entity order. */
64
64
  point<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
65
65
  point<Classes extends ComponentClasses = readonly []>(x: number, y: number, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
66
66
  point(x: number, y: number, filter?: SpatialQueryFilter): Entity[];
@@ -81,12 +81,16 @@ export interface SolidCandidate {
81
81
  readonly entity: Entity;
82
82
  readonly solid: Solid;
83
83
  }
84
- /** Package-internal candidate boundary reserved for future acceleration. */
84
+ /** Package-internal candidate boundary used by the Game-owned broadphase. */
85
85
  export interface SpatialQueryCandidateProviders {
86
- hitboxes(): readonly HitboxCandidate[];
86
+ hitboxes(bounds: CollisionBounds): readonly HitboxCandidate[];
87
87
  transforms(): readonly Entity[];
88
- solids(): readonly SolidCandidate[];
88
+ solids(bounds: CollisionBounds): readonly SolidCandidate[];
89
+ }
90
+ /** Package-internal deterministic candidate-count seam for tests. */
91
+ export interface SpatialQueryInstrumentation {
92
+ onCandidates(domain: 'hitbox' | 'solid', count: number): void;
89
93
  }
90
94
  /** Package-internal constructor; only the SpatialQuery interface is public. */
91
- export declare function createSpatialQuery(game: Game, candidates?: SpatialQueryCandidateProviders): SpatialQuery;
95
+ export declare function createSpatialQuery(game: Game, candidates?: SpatialQueryCandidateProviders, instrumentation?: SpatialQueryInstrumentation): SpatialQuery;
92
96
  export {};
@@ -1,29 +1,19 @@
1
1
  import { collisionBody } from './collision-body.js';
2
- import { collisionOverlap } from './collision-shape.js';
2
+ import { collisionBounds, collisionOverlap, } from './collision-shape.js';
3
3
  import { Hitbox } from './components/hitbox.js';
4
4
  import { Solid } from './components/solid.js';
5
- import { sceneSolids } from './scene-solids.js';
5
+ import { createHitboxBroadphase, createSolidBroadphase, } from './spatial-broadphase.js';
6
6
  import { collisionBodyContainsPoint, collisionBodyRay, SPATIAL_QUERY_EPSILON, usableCollisionBody, } from './spatial-query-geometry.js';
7
- function linearCandidateProviders(game) {
7
+ function indexedCandidateProviders(game) {
8
8
  return {
9
- hitboxes() {
10
- const result = [];
11
- for (const entity of [...game.entities]) {
12
- if (!entity.alive)
13
- continue;
14
- const hitbox = entity.get(Hitbox);
15
- if (hitbox)
16
- result.push({ entity, hitbox });
17
- }
18
- return result;
9
+ hitboxes(bounds) {
10
+ return createHitboxBroadphase(game).candidates(bounds);
19
11
  },
20
12
  transforms() {
21
13
  return [...game.entities].filter((entity) => entity.alive);
22
14
  },
23
- solids() {
24
- return sceneSolids(game)
25
- .filter((solid) => solid.entity.alive)
26
- .map((solid) => ({ entity: solid.entity, solid }));
15
+ solids(bounds) {
16
+ return createSolidBroadphase(game).candidates(bounds);
27
17
  },
28
18
  };
29
19
  }
@@ -59,13 +49,16 @@ function matchesFilter(entity, filter) {
59
49
  }
60
50
  class LinearSpatialQuery {
61
51
  candidates;
62
- constructor(candidates) {
52
+ instrumentation;
53
+ constructor(candidates, instrumentation) {
63
54
  this.candidates = candidates;
55
+ this.instrumentation = instrumentation;
64
56
  }
65
57
  area(body, filter) {
66
58
  if (!usableCollisionBody(body))
67
59
  return [];
68
- const candidates = [...this.candidates.hitboxes()].filter(({ entity }) => entity.alive);
60
+ const candidates = [...this.candidates.hitboxes(collisionBounds(body))].filter(({ entity }) => entity.alive);
61
+ this.instrumentation?.onCandidates('hitbox', candidates.length);
69
62
  const result = [];
70
63
  for (const { entity, hitbox } of candidates) {
71
64
  if (!matchesFilter(entity, filter))
@@ -80,7 +73,10 @@ class LinearSpatialQuery {
80
73
  point(x, y, filter) {
81
74
  if (!Number.isFinite(x) || !Number.isFinite(y))
82
75
  return [];
83
- const candidates = [...this.candidates.hitboxes()].filter(({ entity }) => entity.alive);
76
+ const candidates = [
77
+ ...this.candidates.hitboxes({ left: x, right: x, bottom: y, top: y }),
78
+ ].filter(({ entity }) => entity.alive);
79
+ this.instrumentation?.onCandidates('hitbox', candidates.length);
84
80
  const result = [];
85
81
  for (const { entity, hitbox } of candidates) {
86
82
  if (!matchesFilter(entity, filter))
@@ -135,7 +131,17 @@ class LinearSpatialQuery {
135
131
  const scaledLength = Math.hypot(scaledX, scaledY);
136
132
  const unitX = scaledX / scaledLength;
137
133
  const unitY = scaledY / scaledLength;
138
- const candidates = [...this.candidates.solids()].filter(({ entity }) => entity.alive);
134
+ const endX = x + unitX * maxDistance;
135
+ const endY = y + unitY * maxDistance;
136
+ const candidates = [
137
+ ...this.candidates.solids({
138
+ left: Math.min(x, endX) - SPATIAL_QUERY_EPSILON,
139
+ right: Math.max(x, endX) + SPATIAL_QUERY_EPSILON,
140
+ bottom: Math.min(y, endY) - SPATIAL_QUERY_EPSILON,
141
+ top: Math.max(y, endY) + SPATIAL_QUERY_EPSILON,
142
+ }),
143
+ ].filter(({ entity }) => entity.alive);
144
+ this.instrumentation?.onCandidates('solid', candidates.length);
139
145
  let result = null;
140
146
  for (const { entity, solid } of candidates) {
141
147
  if (!matchesFilter(entity, filter))
@@ -157,6 +163,6 @@ class LinearSpatialQuery {
157
163
  }
158
164
  }
159
165
  /** Package-internal constructor; only the SpatialQuery interface is public. */
160
- export function createSpatialQuery(game, candidates = linearCandidateProviders(game)) {
161
- return new LinearSpatialQuery(candidates);
166
+ export function createSpatialQuery(game, candidates = indexedCandidateProviders(game), instrumentation) {
167
+ return new LinearSpatialQuery(candidates, instrumentation);
162
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",