@waica/engine 0.13.0 → 0.14.1
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 +41 -0
- package/dist/animation/clip-player.js +6 -1
- package/dist/collision-body.d.ts +5 -0
- package/dist/collision-body.js +11 -0
- package/dist/fixed-step-test-support.d.ts +9 -0
- package/dist/fixed-step-test-support.js +9 -0
- package/dist/fixed-step.d.ts +93 -0
- package/dist/fixed-step.js +109 -0
- package/dist/game.d.ts +58 -1
- package/dist/game.js +149 -57
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/runtime-bridge.d.ts +16 -7
- package/dist/runtime-bridge.js +18 -14
- package/dist/spatial-query-geometry.d.ts +20 -0
- package/dist/spatial-query-geometry.js +291 -0
- package/dist/spatial-query.d.ts +92 -0
- package/dist/spatial-query.js +162 -0
- package/dist/state/state-machine.js +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { collisionVertices, } from './collision-shape.js';
|
|
2
|
+
/** Internal absolute tolerance shared by point and ray boundary rules. */
|
|
3
|
+
export const SPATIAL_QUERY_EPSILON = 1e-9;
|
|
4
|
+
function signedArea(points) {
|
|
5
|
+
let twiceArea = 0;
|
|
6
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
7
|
+
const point = points[index];
|
|
8
|
+
const next = points[(index + 1) % points.length];
|
|
9
|
+
twiceArea += point[0] * next[1] - next[0] * point[1];
|
|
10
|
+
}
|
|
11
|
+
return twiceArea / 2;
|
|
12
|
+
}
|
|
13
|
+
function finiteCollisionBody(value) {
|
|
14
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
15
|
+
return false;
|
|
16
|
+
const body = value;
|
|
17
|
+
return (Number.isFinite(body.x) &&
|
|
18
|
+
Number.isFinite(body.y) &&
|
|
19
|
+
Number.isFinite(body.width) &&
|
|
20
|
+
Number.isFinite(body.height));
|
|
21
|
+
}
|
|
22
|
+
/** Valid finite geometry with a positive two-dimensional area. */
|
|
23
|
+
export function usableCollisionBody(value) {
|
|
24
|
+
if (!finiteCollisionBody(value))
|
|
25
|
+
return false;
|
|
26
|
+
const vertices = collisionVertices(value);
|
|
27
|
+
return (vertices.length >= 3 &&
|
|
28
|
+
vertices.every(([x, y]) => Number.isFinite(x) && Number.isFinite(y)) &&
|
|
29
|
+
Math.abs(signedArea(vertices)) > SPATIAL_QUERY_EPSILON);
|
|
30
|
+
}
|
|
31
|
+
function cross(a, b, point) {
|
|
32
|
+
return (b[0] - a[0]) * (point[1] - a[1]) -
|
|
33
|
+
(b[1] - a[1]) * (point[0] - a[0]);
|
|
34
|
+
}
|
|
35
|
+
function vectorCross(a, b) {
|
|
36
|
+
return a[0] * b[1] - a[1] * b[0];
|
|
37
|
+
}
|
|
38
|
+
function pointOnSegment(point, a, b) {
|
|
39
|
+
if (Math.abs(cross(a, b, point)) > SPATIAL_QUERY_EPSILON)
|
|
40
|
+
return false;
|
|
41
|
+
return (point[0] >= Math.min(a[0], b[0]) - SPATIAL_QUERY_EPSILON &&
|
|
42
|
+
point[0] <= Math.max(a[0], b[0]) + SPATIAL_QUERY_EPSILON &&
|
|
43
|
+
point[1] >= Math.min(a[1], b[1]) - SPATIAL_QUERY_EPSILON &&
|
|
44
|
+
point[1] <= Math.max(a[1], b[1]) + SPATIAL_QUERY_EPSILON);
|
|
45
|
+
}
|
|
46
|
+
function polygonPointLocation(polygon, point) {
|
|
47
|
+
let inside = false;
|
|
48
|
+
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) {
|
|
49
|
+
const a = polygon[index];
|
|
50
|
+
const b = polygon[previous];
|
|
51
|
+
if (pointOnSegment(point, a, b))
|
|
52
|
+
return 'boundary';
|
|
53
|
+
const crosses = (a[1] > point[1]) !== (b[1] > point[1]) &&
|
|
54
|
+
point[0] <
|
|
55
|
+
((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0];
|
|
56
|
+
if (crosses)
|
|
57
|
+
inside = !inside;
|
|
58
|
+
}
|
|
59
|
+
return inside ? 'inside' : 'outside';
|
|
60
|
+
}
|
|
61
|
+
/** Strict containment in the existing collision outline. */
|
|
62
|
+
export function collisionBodyContainsPoint(body, x, y) {
|
|
63
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !usableCollisionBody(body))
|
|
64
|
+
return false;
|
|
65
|
+
return polygonPointLocation(collisionVertices(body), [x, y]) === 'inside';
|
|
66
|
+
}
|
|
67
|
+
function snappedDistance(distance, maxDistance) {
|
|
68
|
+
if (Math.abs(distance) <= SPATIAL_QUERY_EPSILON)
|
|
69
|
+
return 0;
|
|
70
|
+
if (Math.abs(distance - maxDistance) <= SPATIAL_QUERY_EPSILON)
|
|
71
|
+
return maxDistance;
|
|
72
|
+
return distance;
|
|
73
|
+
}
|
|
74
|
+
function inRayRange(distance, maxDistance) {
|
|
75
|
+
return (distance >= -SPATIAL_QUERY_EPSILON &&
|
|
76
|
+
distance <= maxDistance + SPATIAL_QUERY_EPSILON);
|
|
77
|
+
}
|
|
78
|
+
function onForwardRay(distance) {
|
|
79
|
+
return Number.isFinite(distance) && distance >= -SPATIAL_QUERY_EPSILON;
|
|
80
|
+
}
|
|
81
|
+
function cleanUnitValue(value) {
|
|
82
|
+
return Math.abs(value) <= SPATIAL_QUERY_EPSILON ? 0 : value;
|
|
83
|
+
}
|
|
84
|
+
function edgeNormal(a, b, winding) {
|
|
85
|
+
const edgeX = b[0] - a[0];
|
|
86
|
+
const edgeY = b[1] - a[1];
|
|
87
|
+
const length = Math.hypot(edgeX, edgeY);
|
|
88
|
+
if (length <= SPATIAL_QUERY_EPSILON)
|
|
89
|
+
return null;
|
|
90
|
+
return [
|
|
91
|
+
cleanUnitValue((winding * edgeY) / length),
|
|
92
|
+
cleanUnitValue((-winding * edgeX) / length),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
function edgeEvents(origin, direction, maxDistance, a, b, normal, edgeOrder) {
|
|
96
|
+
const edge = [b[0] - a[0], b[1] - a[1]];
|
|
97
|
+
const relative = [a[0] - origin[0], a[1] - origin[1]];
|
|
98
|
+
const denominator = vectorCross(direction, edge);
|
|
99
|
+
if (Math.abs(denominator) <= SPATIAL_QUERY_EPSILON) {
|
|
100
|
+
if (Math.abs(vectorCross(relative, direction)) > SPATIAL_QUERY_EPSILON)
|
|
101
|
+
return [];
|
|
102
|
+
const result = [];
|
|
103
|
+
for (const vertex of [a, b]) {
|
|
104
|
+
const distance = (vertex[0] - origin[0]) * direction[0] +
|
|
105
|
+
(vertex[1] - origin[1]) * direction[1];
|
|
106
|
+
if (onForwardRay(distance)) {
|
|
107
|
+
result.push({
|
|
108
|
+
distance: snappedDistance(distance, maxDistance),
|
|
109
|
+
edgeOrder,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
const distance = vectorCross(relative, edge) / denominator;
|
|
116
|
+
const edgeFraction = vectorCross(relative, direction) / denominator;
|
|
117
|
+
if (!onForwardRay(distance) ||
|
|
118
|
+
!Number.isFinite(edgeFraction) ||
|
|
119
|
+
edgeFraction < -SPATIAL_QUERY_EPSILON ||
|
|
120
|
+
edgeFraction > 1 + SPATIAL_QUERY_EPSILON) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
return [{
|
|
124
|
+
distance: snappedDistance(distance, maxDistance),
|
|
125
|
+
edgeOrder,
|
|
126
|
+
normal,
|
|
127
|
+
}];
|
|
128
|
+
}
|
|
129
|
+
function groupEvents(events) {
|
|
130
|
+
const sorted = [...events].sort((a, b) => a.distance === b.distance ? a.edgeOrder - b.edgeOrder : a.distance - b.distance);
|
|
131
|
+
const groups = [];
|
|
132
|
+
for (const event of sorted) {
|
|
133
|
+
const current = groups.at(-1);
|
|
134
|
+
if (!current || Math.abs(event.distance - current.distance) > SPATIAL_QUERY_EPSILON) {
|
|
135
|
+
groups.push({ distance: event.distance, normals: [] });
|
|
136
|
+
}
|
|
137
|
+
if (event.normal) {
|
|
138
|
+
groups.at(-1).normals.push({ edgeOrder: event.edgeOrder, normal: event.normal });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return groups.map((group) => ({
|
|
142
|
+
distance: group.distance,
|
|
143
|
+
normals: group.normals.sort((a, b) => a.edgeOrder - b.edgeOrder),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
function pointAlongRay(origin, direction, distance) {
|
|
147
|
+
return [
|
|
148
|
+
origin[0] + direction[0] * distance,
|
|
149
|
+
origin[1] + direction[1] * distance,
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
function selectVertexNormal(normals, direction, crossing) {
|
|
153
|
+
const first = normals[0];
|
|
154
|
+
if (!first)
|
|
155
|
+
return null;
|
|
156
|
+
let selected = first.normal;
|
|
157
|
+
let selectedDot = selected[0] * direction[0] + selected[1] * direction[1];
|
|
158
|
+
for (const candidate of normals.slice(1)) {
|
|
159
|
+
const dot = candidate.normal[0] * direction[0] + candidate.normal[1] * direction[1];
|
|
160
|
+
const isBetter = crossing === 'entry'
|
|
161
|
+
? dot < selectedDot - SPATIAL_QUERY_EPSILON
|
|
162
|
+
: dot > selectedDot + SPATIAL_QUERY_EPSILON;
|
|
163
|
+
if (isBetter) {
|
|
164
|
+
selected = candidate.normal;
|
|
165
|
+
selectedDot = dot;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return selected;
|
|
169
|
+
}
|
|
170
|
+
function polygonRay(body, origin, direction, maxDistance) {
|
|
171
|
+
const polygon = collisionVertices(body);
|
|
172
|
+
if (!polygon.every(([x, y]) => Number.isFinite(x) && Number.isFinite(y)))
|
|
173
|
+
return null;
|
|
174
|
+
const area = signedArea(polygon);
|
|
175
|
+
if (Math.abs(area) <= SPATIAL_QUERY_EPSILON)
|
|
176
|
+
return null;
|
|
177
|
+
const winding = area > 0 ? 1 : -1;
|
|
178
|
+
const events = [];
|
|
179
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
180
|
+
const a = polygon[index];
|
|
181
|
+
const b = polygon[(index + 1) % polygon.length];
|
|
182
|
+
const normal = edgeNormal(a, b, winding);
|
|
183
|
+
if (!normal)
|
|
184
|
+
continue;
|
|
185
|
+
events.push(...edgeEvents(origin, direction, maxDistance, a, b, normal, index));
|
|
186
|
+
}
|
|
187
|
+
const groups = groupEvents(events);
|
|
188
|
+
const originLocation = polygonPointLocation(polygon, origin);
|
|
189
|
+
const scale = Math.max(Math.abs(body.width), Math.abs(body.height), 1);
|
|
190
|
+
const terminalProbe = Math.max(SPATIAL_QUERY_EPSILON * 4, Math.min(scale * 1e-6, 1e-3));
|
|
191
|
+
for (let index = 0; index < groups.length; index += 1) {
|
|
192
|
+
const group = groups[index];
|
|
193
|
+
if (group.distance > maxDistance + SPATIAL_QUERY_EPSILON)
|
|
194
|
+
break;
|
|
195
|
+
const previous = groups[index - 1];
|
|
196
|
+
const next = groups[index + 1];
|
|
197
|
+
const beforeLocation = group.distance === 0
|
|
198
|
+
? originLocation
|
|
199
|
+
: polygonPointLocation(polygon, pointAlongRay(origin, direction, previous ? (previous.distance + group.distance) / 2 : group.distance / 2));
|
|
200
|
+
const afterLocation = polygonPointLocation(polygon, pointAlongRay(origin, direction, next ? (group.distance + next.distance) / 2 : group.distance + terminalProbe));
|
|
201
|
+
let crossing = null;
|
|
202
|
+
if ((beforeLocation === 'outside' ||
|
|
203
|
+
(group.distance === 0 && beforeLocation === 'boundary')) &&
|
|
204
|
+
afterLocation === 'inside') {
|
|
205
|
+
crossing = 'entry';
|
|
206
|
+
}
|
|
207
|
+
else if (beforeLocation === 'inside' && afterLocation === 'outside') {
|
|
208
|
+
crossing = 'exit';
|
|
209
|
+
}
|
|
210
|
+
if (!crossing)
|
|
211
|
+
continue;
|
|
212
|
+
const normal = selectVertexNormal(group.normals, direction, crossing);
|
|
213
|
+
if (!normal)
|
|
214
|
+
continue;
|
|
215
|
+
const point = pointAlongRay(origin, direction, group.distance);
|
|
216
|
+
return {
|
|
217
|
+
distance: group.distance,
|
|
218
|
+
point: { x: point[0], y: point[1] },
|
|
219
|
+
normal: { x: normal[0], y: normal[1] },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
function ellipseNormal(body, radiusX, radiusY, point) {
|
|
225
|
+
const gradientX = ((point[0] - body.x) / radiusX) / radiusX;
|
|
226
|
+
const gradientY = ((point[1] - body.y) / radiusY) / radiusY;
|
|
227
|
+
const length = Math.hypot(gradientX, gradientY);
|
|
228
|
+
if (!Number.isFinite(length) || length === 0)
|
|
229
|
+
return null;
|
|
230
|
+
return [cleanUnitValue(gradientX / length), cleanUnitValue(gradientY / length)];
|
|
231
|
+
}
|
|
232
|
+
function ellipseRay(body, origin, direction, maxDistance) {
|
|
233
|
+
const radiusX = Math.abs(body.width) / 2;
|
|
234
|
+
const radiusY = Math.abs(body.height) / 2;
|
|
235
|
+
if (radiusX === 0 || radiusY === 0)
|
|
236
|
+
return null;
|
|
237
|
+
const originX = (origin[0] - body.x) / radiusX;
|
|
238
|
+
const originY = (origin[1] - body.y) / radiusY;
|
|
239
|
+
const directionX = direction[0] / radiusX;
|
|
240
|
+
const directionY = direction[1] / radiusY;
|
|
241
|
+
const directionScale = Math.max(Math.abs(directionX), Math.abs(directionY));
|
|
242
|
+
if (![originX, originY, directionX, directionY, directionScale].every(Number.isFinite) ||
|
|
243
|
+
directionScale === 0) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const scaledDirectionX = directionX / directionScale;
|
|
247
|
+
const scaledDirectionY = directionY / directionScale;
|
|
248
|
+
const directionLengthSquared = scaledDirectionX * scaledDirectionX + scaledDirectionY * scaledDirectionY;
|
|
249
|
+
const closestParameter = -(originX * scaledDirectionX + originY * scaledDirectionY) / directionLengthSquared;
|
|
250
|
+
const perpendicular = originX * scaledDirectionY - originY * scaledDirectionX;
|
|
251
|
+
const radialGap = 1 - (perpendicular * perpendicular) / directionLengthSquared;
|
|
252
|
+
if (!Number.isFinite(closestParameter) || radialGap <= 0)
|
|
253
|
+
return null;
|
|
254
|
+
const halfChordParameter = Math.sqrt(radialGap / directionLengthSquared);
|
|
255
|
+
const halfChordDistance = halfChordParameter / directionScale;
|
|
256
|
+
if (!Number.isFinite(halfChordParameter) ||
|
|
257
|
+
halfChordDistance <= SPATIAL_QUERY_EPSILON) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const roots = [
|
|
261
|
+
(closestParameter - halfChordParameter) / directionScale,
|
|
262
|
+
(closestParameter + halfChordParameter) / directionScale,
|
|
263
|
+
];
|
|
264
|
+
const rawDistance = roots[0] >= -SPATIAL_QUERY_EPSILON
|
|
265
|
+
? roots[0]
|
|
266
|
+
: roots[1] > SPATIAL_QUERY_EPSILON
|
|
267
|
+
? roots[1]
|
|
268
|
+
: undefined;
|
|
269
|
+
if (rawDistance === undefined || !inRayRange(rawDistance, maxDistance))
|
|
270
|
+
return null;
|
|
271
|
+
const distance = snappedDistance(rawDistance, maxDistance);
|
|
272
|
+
const point = pointAlongRay(origin, direction, distance);
|
|
273
|
+
const normal = ellipseNormal(body, radiusX, radiusY, point);
|
|
274
|
+
if (!normal)
|
|
275
|
+
return null;
|
|
276
|
+
return {
|
|
277
|
+
distance,
|
|
278
|
+
point: { x: point[0], y: point[1] },
|
|
279
|
+
normal: { x: normal[0], y: normal[1] },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** First strict crossing against one resolved body. Direction must be normalized. */
|
|
283
|
+
export function collisionBodyRay(body, x, y, dx, dy, maxDistance) {
|
|
284
|
+
if (!finiteCollisionBody(body))
|
|
285
|
+
return null;
|
|
286
|
+
const origin = [x, y];
|
|
287
|
+
const direction = [dx, dy];
|
|
288
|
+
return body.shape === 'circle'
|
|
289
|
+
? ellipseRay(body, origin, direction, maxDistance)
|
|
290
|
+
: polygonRay(body, origin, direction, maxDistance);
|
|
291
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type CollisionBody } from './collision-shape.js';
|
|
2
|
+
import type { Component, ComponentClass } from './component.js';
|
|
3
|
+
import { Hitbox } from './components/hitbox.js';
|
|
4
|
+
import { Solid } from './components/solid.js';
|
|
5
|
+
import type { Entity } from './entity.js';
|
|
6
|
+
import type { Game } from './game.js';
|
|
7
|
+
type ComponentClasses = readonly ComponentClass[];
|
|
8
|
+
type QueryEntity<Classes extends ComponentClasses> = Classes extends readonly [] ? Entity : EntityWith<Classes>;
|
|
9
|
+
/** An Entity whose required component classes return non-optional instances. */
|
|
10
|
+
export interface EntityWith<Classes extends ComponentClasses> extends Entity {
|
|
11
|
+
get<Class extends Classes[number]>(component: Class): InstanceType<Class>;
|
|
12
|
+
get<T extends Component>(component: ComponentClass<T>): T | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Declarative eligibility shared by area, point, and ray queries. */
|
|
15
|
+
export interface SpatialQueryFilter<Classes extends ComponentClasses = readonly []> {
|
|
16
|
+
/** Every listed component class must be present. */
|
|
17
|
+
with?: Classes;
|
|
18
|
+
/** A candidate carrying any listed class is rejected. */
|
|
19
|
+
without?: readonly ComponentClass[];
|
|
20
|
+
/** Entity identities omitted from the result. */
|
|
21
|
+
exclude?: Entity | readonly Entity[] | ReadonlySet<Entity>;
|
|
22
|
+
/** Final eligibility predicate. */
|
|
23
|
+
where?: (entity: QueryEntity<Classes>) => boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface NearestQueryContext {
|
|
26
|
+
readonly distance: number;
|
|
27
|
+
}
|
|
28
|
+
/** Entity eligibility plus an optional inclusive logical-space radius. */
|
|
29
|
+
export interface NearestSpatialQueryFilter<Classes extends ComponentClasses = readonly []> {
|
|
30
|
+
with?: Classes;
|
|
31
|
+
without?: readonly ComponentClass[];
|
|
32
|
+
exclude?: Entity | readonly Entity[] | ReadonlySet<Entity>;
|
|
33
|
+
where?: (entity: QueryEntity<Classes>, context: NearestQueryContext) => boolean;
|
|
34
|
+
/** Inclusive logical-space radius; omitted or positive Infinity is unlimited. */
|
|
35
|
+
maxDistance?: number;
|
|
36
|
+
}
|
|
37
|
+
/** One query-time crossing paired with the live Solid and owning Entity. */
|
|
38
|
+
export interface RayHit<T extends Entity = Entity> {
|
|
39
|
+
readonly entity: T;
|
|
40
|
+
readonly solid: Solid;
|
|
41
|
+
readonly distance: number;
|
|
42
|
+
readonly point: Readonly<{
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
}>;
|
|
46
|
+
readonly normal: Readonly<{
|
|
47
|
+
x: number;
|
|
48
|
+
y: number;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
type SpatialGuardFilter<Classes extends ComponentClasses, Narrowed extends QueryEntity<Classes>> = Omit<SpatialQueryFilter<Classes>, 'where'> & {
|
|
52
|
+
where: (entity: QueryEntity<Classes>) => entity is Narrowed;
|
|
53
|
+
};
|
|
54
|
+
type NearestGuardFilter<Classes extends ComponentClasses, Narrowed extends QueryEntity<Classes>> = Omit<NearestSpatialQueryFilter<Classes>, 'where'> & {
|
|
55
|
+
where: (entity: QueryEntity<Classes>, context: NearestQueryContext) => entity is Narrowed;
|
|
56
|
+
};
|
|
57
|
+
/** Public logical-space spatial-query service owned by every Game. */
|
|
58
|
+
export interface SpatialQuery {
|
|
59
|
+
/** Hitbox owners with positive interior overlap, in Entity order. */
|
|
60
|
+
area<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(body: CollisionBody, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
|
|
61
|
+
area<Classes extends ComponentClasses = readonly []>(body: CollisionBody, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
|
|
62
|
+
area(body: CollisionBody, filter?: SpatialQueryFilter): Entity[];
|
|
63
|
+
/** Hitbox owners that strictly contain a logical point, in Entity order. */
|
|
64
|
+
point<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
|
|
65
|
+
point<Classes extends ComponentClasses = readonly []>(x: number, y: number, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
|
|
66
|
+
point(x: number, y: number, filter?: SpatialQueryFilter): Entity[];
|
|
67
|
+
/** Closest filtered logical transform; equal distances retain Entity order. */
|
|
68
|
+
nearest<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, filter: NearestGuardFilter<Classes, Narrowed>): Narrowed | null;
|
|
69
|
+
nearest<Classes extends ComponentClasses = readonly []>(x: number, y: number, filter: NearestSpatialQueryFilter<Classes>): QueryEntity<Classes> | null;
|
|
70
|
+
nearest(x: number, y: number, filter?: NearestSpatialQueryFilter): Entity | null;
|
|
71
|
+
/** First strict crossing of direct or source-derived Solid geometry. */
|
|
72
|
+
ray<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, dx: number, dy: number, maxDistance: number, filter: SpatialGuardFilter<Classes, Narrowed>): RayHit<Narrowed> | null;
|
|
73
|
+
ray<Classes extends ComponentClasses = readonly []>(x: number, y: number, dx: number, dy: number, maxDistance: number, filter: SpatialQueryFilter<Classes>): RayHit<QueryEntity<Classes>> | null;
|
|
74
|
+
ray(x: number, y: number, dx: number, dy: number, maxDistance: number, filter?: SpatialQueryFilter): RayHit | null;
|
|
75
|
+
}
|
|
76
|
+
export interface HitboxCandidate {
|
|
77
|
+
readonly entity: Entity;
|
|
78
|
+
readonly hitbox: Hitbox;
|
|
79
|
+
}
|
|
80
|
+
export interface SolidCandidate {
|
|
81
|
+
readonly entity: Entity;
|
|
82
|
+
readonly solid: Solid;
|
|
83
|
+
}
|
|
84
|
+
/** Package-internal candidate boundary reserved for future acceleration. */
|
|
85
|
+
export interface SpatialQueryCandidateProviders {
|
|
86
|
+
hitboxes(): readonly HitboxCandidate[];
|
|
87
|
+
transforms(): readonly Entity[];
|
|
88
|
+
solids(): readonly SolidCandidate[];
|
|
89
|
+
}
|
|
90
|
+
/** Package-internal constructor; only the SpatialQuery interface is public. */
|
|
91
|
+
export declare function createSpatialQuery(game: Game, candidates?: SpatialQueryCandidateProviders): SpatialQuery;
|
|
92
|
+
export {};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { collisionBody } from './collision-body.js';
|
|
2
|
+
import { collisionOverlap } from './collision-shape.js';
|
|
3
|
+
import { Hitbox } from './components/hitbox.js';
|
|
4
|
+
import { Solid } from './components/solid.js';
|
|
5
|
+
import { sceneSolids } from './scene-solids.js';
|
|
6
|
+
import { collisionBodyContainsPoint, collisionBodyRay, SPATIAL_QUERY_EPSILON, usableCollisionBody, } from './spatial-query-geometry.js';
|
|
7
|
+
function linearCandidateProviders(game) {
|
|
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;
|
|
19
|
+
},
|
|
20
|
+
transforms() {
|
|
21
|
+
return [...game.entities].filter((entity) => entity.alive);
|
|
22
|
+
},
|
|
23
|
+
solids() {
|
|
24
|
+
return sceneSolids(game)
|
|
25
|
+
.filter((solid) => solid.entity.alive)
|
|
26
|
+
.map((solid) => ({ entity: solid.entity, solid }));
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function isReadonlyEntitySet(value) {
|
|
31
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
32
|
+
return false;
|
|
33
|
+
const candidate = value;
|
|
34
|
+
return (typeof candidate.size === 'number' &&
|
|
35
|
+
typeof candidate.has === 'function' &&
|
|
36
|
+
typeof candidate[Symbol.iterator] === 'function');
|
|
37
|
+
}
|
|
38
|
+
function isExcluded(entity, exclude) {
|
|
39
|
+
if (!exclude)
|
|
40
|
+
return false;
|
|
41
|
+
if (Array.isArray(exclude))
|
|
42
|
+
return exclude.includes(entity);
|
|
43
|
+
if (isReadonlyEntitySet(exclude))
|
|
44
|
+
return exclude.has(entity);
|
|
45
|
+
return exclude === entity;
|
|
46
|
+
}
|
|
47
|
+
function matchesCommonFilter(entity, filter) {
|
|
48
|
+
if (!filter)
|
|
49
|
+
return true;
|
|
50
|
+
if (filter.with?.some((component) => !entity.has(component)))
|
|
51
|
+
return false;
|
|
52
|
+
if (filter.without?.some((component) => entity.has(component)))
|
|
53
|
+
return false;
|
|
54
|
+
return !isExcluded(entity, filter.exclude);
|
|
55
|
+
}
|
|
56
|
+
function matchesFilter(entity, filter) {
|
|
57
|
+
return (matchesCommonFilter(entity, filter) &&
|
|
58
|
+
(!filter?.where || filter.where(entity)));
|
|
59
|
+
}
|
|
60
|
+
class LinearSpatialQuery {
|
|
61
|
+
candidates;
|
|
62
|
+
constructor(candidates) {
|
|
63
|
+
this.candidates = candidates;
|
|
64
|
+
}
|
|
65
|
+
area(body, filter) {
|
|
66
|
+
if (!usableCollisionBody(body))
|
|
67
|
+
return [];
|
|
68
|
+
const candidates = [...this.candidates.hitboxes()].filter(({ entity }) => entity.alive);
|
|
69
|
+
const result = [];
|
|
70
|
+
for (const { entity, hitbox } of candidates) {
|
|
71
|
+
if (!matchesFilter(entity, filter))
|
|
72
|
+
continue;
|
|
73
|
+
const candidateBody = collisionBody(hitbox);
|
|
74
|
+
if (usableCollisionBody(candidateBody) && collisionOverlap(body, candidateBody)) {
|
|
75
|
+
result.push(entity);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
point(x, y, filter) {
|
|
81
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
82
|
+
return [];
|
|
83
|
+
const candidates = [...this.candidates.hitboxes()].filter(({ entity }) => entity.alive);
|
|
84
|
+
const result = [];
|
|
85
|
+
for (const { entity, hitbox } of candidates) {
|
|
86
|
+
if (!matchesFilter(entity, filter))
|
|
87
|
+
continue;
|
|
88
|
+
if (collisionBodyContainsPoint(collisionBody(hitbox), x, y))
|
|
89
|
+
result.push(entity);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
nearest(x, y, filter) {
|
|
94
|
+
const maxDistance = filter?.maxDistance ?? Infinity;
|
|
95
|
+
if (!Number.isFinite(x) ||
|
|
96
|
+
!Number.isFinite(y) ||
|
|
97
|
+
Number.isNaN(maxDistance) ||
|
|
98
|
+
maxDistance < 0 ||
|
|
99
|
+
(maxDistance !== Infinity && !Number.isFinite(maxDistance))) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const candidates = [...this.candidates.transforms()].filter((entity) => entity.alive);
|
|
103
|
+
let result = null;
|
|
104
|
+
let nearestDistance = Infinity;
|
|
105
|
+
for (const entity of candidates) {
|
|
106
|
+
if (!matchesCommonFilter(entity, filter))
|
|
107
|
+
continue;
|
|
108
|
+
const distance = Math.hypot(entity.position.x - x, entity.position.y - y);
|
|
109
|
+
if (Number.isNaN(distance) || distance > maxDistance)
|
|
110
|
+
continue;
|
|
111
|
+
if (filter?.where &&
|
|
112
|
+
!filter.where(entity, { distance })) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (result === null || distance < nearestDistance) {
|
|
116
|
+
result = entity;
|
|
117
|
+
nearestDistance = distance;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
ray(x, y, dx, dy, maxDistance, filter) {
|
|
123
|
+
const directionScale = Math.max(Math.abs(dx), Math.abs(dy));
|
|
124
|
+
if (!Number.isFinite(x) ||
|
|
125
|
+
!Number.isFinite(y) ||
|
|
126
|
+
!Number.isFinite(dx) ||
|
|
127
|
+
!Number.isFinite(dy) ||
|
|
128
|
+
!Number.isFinite(maxDistance) ||
|
|
129
|
+
directionScale === 0 ||
|
|
130
|
+
maxDistance < 0) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const scaledX = dx / directionScale;
|
|
134
|
+
const scaledY = dy / directionScale;
|
|
135
|
+
const scaledLength = Math.hypot(scaledX, scaledY);
|
|
136
|
+
const unitX = scaledX / scaledLength;
|
|
137
|
+
const unitY = scaledY / scaledLength;
|
|
138
|
+
const candidates = [...this.candidates.solids()].filter(({ entity }) => entity.alive);
|
|
139
|
+
let result = null;
|
|
140
|
+
for (const { entity, solid } of candidates) {
|
|
141
|
+
if (!matchesFilter(entity, filter))
|
|
142
|
+
continue;
|
|
143
|
+
const hit = collisionBodyRay(collisionBody(solid), x, y, unitX, unitY, maxDistance);
|
|
144
|
+
if (!hit)
|
|
145
|
+
continue;
|
|
146
|
+
if (result && hit.distance >= result.distance - SPATIAL_QUERY_EPSILON)
|
|
147
|
+
continue;
|
|
148
|
+
result = {
|
|
149
|
+
entity,
|
|
150
|
+
solid,
|
|
151
|
+
distance: hit.distance,
|
|
152
|
+
point: { x: hit.point.x, y: hit.point.y },
|
|
153
|
+
normal: { x: hit.normal.x, y: hit.normal.y },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Package-internal constructor; only the SpatialQuery interface is public. */
|
|
160
|
+
export function createSpatialQuery(game, candidates = linearCandidateProviders(game)) {
|
|
161
|
+
return new LinearSpatialQuery(candidates);
|
|
162
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from '../animation/directional.js';
|
|
2
2
|
import { Component } from '../component.js';
|
|
3
3
|
import { AnimatedSprite } from '../components/animated-sprite.js';
|
|
4
|
+
import { MAX_CHAINED_HOPS, SIMULATION_TIME_EPSILON } from '../fixed-step.js';
|
|
4
5
|
import { closestLogicSet, logicSet, registeredLogicSets, } from './hooks.js';
|
|
5
6
|
/** Whether one trigger fires. Unknown or malformed triggers never fire. */
|
|
6
7
|
export function evaluateTrigger(on, env) {
|
|
@@ -11,8 +12,15 @@ export function evaluateTrigger(on, env) {
|
|
|
11
12
|
const arg = on.slice(sep + 1);
|
|
12
13
|
if (kind === 'input')
|
|
13
14
|
return env.justPressed(arg);
|
|
15
|
+
// A float epsilon of tolerance, not half a Simulation Step: `elapsed` is
|
|
16
|
+
// a sum of many SIMULATION_STEP-sized dts, and float error can leave it a
|
|
17
|
+
// hair under an exact multiple (15 additions of 1/60 give
|
|
18
|
+
// 0.24999999999999997, not 0.25), which a bare `>=` would fire one whole
|
|
19
|
+
// step late. A tolerance as wide as half a step instead fired non-multiple
|
|
20
|
+
// durations one whole step early, since a target can sit closer to the
|
|
21
|
+
// step below than to the one it truly belongs to.
|
|
14
22
|
if (kind === 'timer')
|
|
15
|
-
return env.elapsed >= Number(arg);
|
|
23
|
+
return env.elapsed + SIMULATION_TIME_EPSILON >= Number(arg);
|
|
16
24
|
if (kind === 'signal')
|
|
17
25
|
return env.signals.has(arg);
|
|
18
26
|
return false;
|
|
@@ -105,7 +113,7 @@ export class StateMachine extends Component {
|
|
|
105
113
|
this.elapsed += dt;
|
|
106
114
|
// Chained transitions settle within the frame (e.g. land → idle → run),
|
|
107
115
|
// capped so a degenerate cyclic graph can't hang the loop.
|
|
108
|
-
for (let hops = 0; hops <
|
|
116
|
+
for (let hops = 0; hops < MAX_CHAINED_HOPS; hops++) {
|
|
109
117
|
const edge = nextTransition(this.states, this.current, this.env());
|
|
110
118
|
// A '*' edge is re-merged against whatever state the loop just
|
|
111
119
|
// entered, so a still-queued signal (signals.clear() only runs after
|