@waica/engine 0.14.1 → 0.16.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.
@@ -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.16.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",