@waica/engine 0.14.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/collision-body.d.ts +5 -0
- package/dist/collision-body.js +11 -0
- package/dist/game.d.ts +2 -0
- package/dist/game.js +5 -15
- package/dist/index.d.ts +1 -0
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,47 @@ 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
|
+
## Logical spatial queries
|
|
10
|
+
|
|
11
|
+
Every `Game` owns one stable `game.query` service. Queries use logical XY coordinates, including in isometric scenes, and return typed live engine objects:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Component, type CollisionBody, type Entity, type Game } from '@waica/engine'
|
|
15
|
+
|
|
16
|
+
declare const game: Game
|
|
17
|
+
declare const player: Entity
|
|
18
|
+
|
|
19
|
+
class Faction extends Component {
|
|
20
|
+
name = 'neutral'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const search: CollisionBody = { x: 0, y: 0, width: 8, height: 6 }
|
|
24
|
+
const nearby = game.query.area(search, {
|
|
25
|
+
with: [Faction] as const,
|
|
26
|
+
exclude: player,
|
|
27
|
+
where: (entity) => entity.get(Faction).name !== 'friendly',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// `with` makes this non-optional at compile time.
|
|
31
|
+
nearby[0]?.get(Faction).name
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Method | Spatial domain | Result |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `area(body, filter?)` | Hitbox collision outlines | All overlapping Hitbox owners |
|
|
37
|
+
| `point(x, y, filter?)` | Hitbox collision outlines | All strict point containers |
|
|
38
|
+
| `nearest(x, y, filter?)` | Entity logical transforms | The nearest Entity or `null` |
|
|
39
|
+
| `ray(x, y, dx, dy, maxDistance, filter?)` | Direct and source-derived Solids | The first `RayHit` or `null` |
|
|
40
|
+
| `Pointer` picking | Sprite visuals in projected/render space | The front-most visual owner |
|
|
41
|
+
|
|
42
|
+
Every filter can require all classes in `with`, reject any class in `without`, exclude one Entity, a readonly Entity array, or a `ReadonlySet` by identity, and apply a final `where` predicate. `nearest` also accepts inclusive `maxDistance` and passes `{ distance }` to `where`. Filters compose conjunctively. Ray filters apply to each Solid's owner; for example, a generated Tilemap Solid can match `with: [Tilemap]`, but only an owner with a direct Solid matches `with: [Solid]`.
|
|
43
|
+
|
|
44
|
+
Calls eagerly snapshot candidates that are alive at call start. Results preserve scene/source order, and ties keep the first candidate. Later spawns do not enter a result and later destruction does not remove it. Returned Entity, Component, and Solid references remain live; a `RayHit`'s distance, point, and normal are detached query-time values.
|
|
45
|
+
|
|
46
|
+
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
|
+
|
|
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.
|
|
49
|
+
|
|
9
50
|
## Component lifecycle
|
|
10
51
|
|
|
11
52
|
Waica keeps the lifecycle boundaries distinct:
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CollisionBody } from './collision-shape.js';
|
|
2
|
+
import type { Hitbox } from './components/hitbox.js';
|
|
3
|
+
import type { Solid } from './components/solid.js';
|
|
4
|
+
/** Package-internal conversion shared by trigger dispatch and spatial queries. */
|
|
5
|
+
export declare function collisionBody(component: Hitbox | Solid): CollisionBody;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Package-internal conversion shared by trigger dispatch and spatial queries. */
|
|
2
|
+
export function collisionBody(component) {
|
|
3
|
+
return {
|
|
4
|
+
x: component.entity.position.x + component.offsetX,
|
|
5
|
+
y: component.entity.position.y + component.offsetY,
|
|
6
|
+
width: component.width,
|
|
7
|
+
height: component.height,
|
|
8
|
+
shape: component.shape,
|
|
9
|
+
points: component.points,
|
|
10
|
+
};
|
|
11
|
+
}
|
package/dist/game.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { Emitter } from './events.js';
|
|
|
8
8
|
import { Input, type InputBindings } from './input.js';
|
|
9
9
|
import { Pointer } from './pointer.js';
|
|
10
10
|
import { type SceneJson, type SceneRegistry, type SceneRenderJson } from './scene.js';
|
|
11
|
+
import { type SpatialQuery } from './spatial-query.js';
|
|
11
12
|
import { Stats, type StatValue } from './stats.js';
|
|
12
13
|
import { GameUi } from './ui.js';
|
|
13
14
|
/** Fixed game resolution: the view keeps this aspect, letterboxed. */
|
|
@@ -58,6 +59,7 @@ export declare class Game {
|
|
|
58
59
|
readonly camera: THREE.OrthographicCamera;
|
|
59
60
|
readonly input: Input;
|
|
60
61
|
readonly pointer: Pointer;
|
|
62
|
+
readonly query: SpatialQuery;
|
|
61
63
|
readonly entities: Entity[];
|
|
62
64
|
readonly events: Emitter;
|
|
63
65
|
readonly stats: Stats;
|
package/dist/game.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
3
|
+
import { collisionBody } from './collision-body.js';
|
|
3
4
|
import { collisionOverlap } from './collision-shape.js';
|
|
4
5
|
import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
|
|
5
6
|
import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
|
|
@@ -14,6 +15,7 @@ import { RuntimeInspector } from './runtime-inspection.js';
|
|
|
14
15
|
import { projectIsometric, unprojectIsometric } from './projection.js';
|
|
15
16
|
import { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
16
17
|
import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
|
|
18
|
+
import { createSpatialQuery } from './spatial-query.js';
|
|
17
19
|
import { Stats } from './stats.js';
|
|
18
20
|
import { GameUi } from './ui.js';
|
|
19
21
|
/**
|
|
@@ -25,6 +27,7 @@ export class Game {
|
|
|
25
27
|
camera;
|
|
26
28
|
input;
|
|
27
29
|
pointer;
|
|
30
|
+
query;
|
|
28
31
|
entities = [];
|
|
29
32
|
events = new Emitter();
|
|
30
33
|
stats;
|
|
@@ -81,6 +84,7 @@ export class Game {
|
|
|
81
84
|
this.viewHeight = viewHeight;
|
|
82
85
|
this.resolution = options.resolution ?? null;
|
|
83
86
|
this.input = new Input(options.bindings);
|
|
87
|
+
this.query = createSpatialQuery(this);
|
|
84
88
|
this.stats = new Stats(options.stats);
|
|
85
89
|
this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
|
|
86
90
|
this.audio = new AudioSubsystem({
|
|
@@ -602,21 +606,7 @@ export class Game {
|
|
|
602
606
|
const hb = b.get(Hitbox);
|
|
603
607
|
if (!ha || !hb)
|
|
604
608
|
continue;
|
|
605
|
-
const hit = collisionOverlap(
|
|
606
|
-
x: a.position.x + ha.offsetX,
|
|
607
|
-
y: a.position.y + ha.offsetY,
|
|
608
|
-
width: ha.width,
|
|
609
|
-
height: ha.height,
|
|
610
|
-
shape: ha.shape,
|
|
611
|
-
points: ha.points,
|
|
612
|
-
}, {
|
|
613
|
-
x: b.position.x + hb.offsetX,
|
|
614
|
-
y: b.position.y + hb.offsetY,
|
|
615
|
-
width: hb.width,
|
|
616
|
-
height: hb.height,
|
|
617
|
-
shape: hb.shape,
|
|
618
|
-
points: hb.points,
|
|
619
|
-
});
|
|
609
|
+
const hit = collisionOverlap(collisionBody(ha), collisionBody(hb));
|
|
620
610
|
if (!hit)
|
|
621
611
|
continue;
|
|
622
612
|
for (const c of [...a.components])
|
package/dist/index.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export { cellAt, cellBounds, cellIndex } from './tilemap-grid.js';
|
|
|
50
50
|
export type { TilemapCell, TilemapCellBounds, TilemapGridSpec, } from './tilemap-grid.js';
|
|
51
51
|
export { COLLISION_SHAPES, DEFAULT_COLLISION_POLYGON, collisionBounds, collisionOverlap, collisionVertices, resolveCollisionPoints, } from './collision-shape.js';
|
|
52
52
|
export type { CollisionBody, CollisionBounds, CollisionPoint, CollisionShape, } from './collision-shape.js';
|
|
53
|
+
export type { EntityWith, NearestQueryContext, NearestSpatialQueryFilter, RayHit, SpatialQuery, SpatialQueryFilter, } from './spatial-query.js';
|
|
53
54
|
export { Emitter } from './events.js';
|
|
54
55
|
export { loadScene, spawnFromJson, resolveEntityComponents, resolveProps } from './scene.js';
|
|
55
56
|
export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, SceneRenderJson, PrefabJson, } from './scene.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CollisionBody } from './collision-shape.js';
|
|
2
|
+
/** Internal absolute tolerance shared by point and ray boundary rules. */
|
|
3
|
+
export declare const SPATIAL_QUERY_EPSILON = 1e-9;
|
|
4
|
+
export interface SpatialGeometryRayHit {
|
|
5
|
+
readonly distance: number;
|
|
6
|
+
readonly point: Readonly<{
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
}>;
|
|
10
|
+
readonly normal: Readonly<{
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
/** Valid finite geometry with a positive two-dimensional area. */
|
|
16
|
+
export declare function usableCollisionBody(value: unknown): value is CollisionBody;
|
|
17
|
+
/** Strict containment in the existing collision outline. */
|
|
18
|
+
export declare function collisionBodyContainsPoint(body: unknown, x: number, y: number): boolean;
|
|
19
|
+
/** First strict crossing against one resolved body. Direction must be normalized. */
|
|
20
|
+
export declare function collisionBodyRay(body: unknown, x: number, y: number, dx: number, dy: number, maxDistance: number): SpatialGeometryRayHit | null;
|
|
@@ -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
|
+
}
|