@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.
- package/README.md +70 -1
- package/dist/collision-category.d.ts +4 -0
- package/dist/collision-category.js +11 -0
- package/dist/collision-dispatch.d.ts +8 -0
- package/dist/collision-dispatch.js +36 -0
- package/dist/component.d.ts +3 -1
- package/dist/components/hitbox.d.ts +14 -2
- package/dist/components/hitbox.js +9 -2
- package/dist/entity.js +4 -0
- package/dist/game-time.d.ts +189 -0
- package/dist/game-time.js +401 -0
- package/dist/game.d.ts +8 -4
- package/dist/game.js +16 -28
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/runtime-inspection.d.ts +19 -0
- package/dist/runtime-inspection.js +7 -0
- package/dist/scene.js +10 -1
- package/dist/spatial-broadphase.d.ts +38 -0
- package/dist/spatial-broadphase.js +180 -0
- package/dist/spatial-query.d.ts +11 -7
- package/dist/spatial-query.js +29 -23
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/spatial-query.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {};
|
package/dist/spatial-query.js
CHANGED
|
@@ -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 {
|
|
5
|
+
import { createHitboxBroadphase, createSolidBroadphase, } from './spatial-broadphase.js';
|
|
6
6
|
import { collisionBodyContainsPoint, collisionBodyRay, SPATIAL_QUERY_EPSILON, usableCollisionBody, } from './spatial-query-geometry.js';
|
|
7
|
-
function
|
|
7
|
+
function indexedCandidateProviders(game) {
|
|
8
8
|
return {
|
|
9
|
-
hitboxes() {
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
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 =
|
|
161
|
-
return new LinearSpatialQuery(candidates);
|
|
166
|
+
export function createSpatialQuery(game, candidates = indexedCandidateProviders(game), instrumentation) {
|
|
167
|
+
return new LinearSpatialQuery(candidates, instrumentation);
|
|
162
168
|
}
|