@waica/engine 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/dist/collision-body.d.ts +5 -0
- package/dist/collision-body.js +11 -0
- 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/game.d.ts +5 -3
- package/dist/game.js +7 -40
- package/dist/index.d.ts +1 -0
- package/dist/spatial-broadphase.d.ts +38 -0
- package/dist/spatial-broadphase.js +180 -0
- package/dist/spatial-query-geometry.d.ts +20 -0
- package/dist/spatial-query-geometry.js +291 -0
- package/dist/spatial-query.d.ts +96 -0
- package/dist/spatial-query.js +168 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,93 @@ Waica's public engine core: entities and components, the game loop, scene and pr
|
|
|
6
6
|
import { Component, Game, loadScene } from '@waica/engine'
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
## Hitbox Collision Layers and Masks
|
|
10
|
+
|
|
11
|
+
Every `Hitbox` belongs to one named Collision Layer and declares the other layers in which it is interested through a Collision Mask:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Hitbox } from '@waica/engine'
|
|
15
|
+
|
|
16
|
+
entity.add(Hitbox, {
|
|
17
|
+
layer: 'projectile',
|
|
18
|
+
collidesWith: ['enemy'],
|
|
19
|
+
})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A layer must match `^[a-z][a-z0-9-]*$`. Layer names form an open, project-owned vocabulary; matching is exact and case-sensitive. `"*"` is valid only in a mask and matches every valid layer. An empty mask means no outgoing interest. Invalid layers cannot be targeted, and invalid or non-string mask entries are ignored without runtime coercion or warnings.
|
|
23
|
+
|
|
24
|
+
A pair reaches exact overlap testing when either side's mask names the other side's layer. After an overlap, only an interested side receives `onCollide`; a one-way projectile mask therefore does not notify its target. Omitted fields retain the compatibility defaults `layer: 'default'` and `collidesWith: ['*']`, so two unconfigured Hitboxes still notify both sides.
|
|
25
|
+
|
|
26
|
+
Layers and masks are read while the collision-pair snapshot is consumed. A component can change them during `onUpdate` or one callback and affect later pairs in that Simulation Step. Pair order remains the original lexicographic Entity order, component callbacks remain in insertion order, and destroyed entities are still skipped between sides.
|
|
27
|
+
|
|
28
|
+
### Shipped taxonomy and migration
|
|
29
|
+
|
|
30
|
+
Waica's shipped prefabs use this directional policy:
|
|
31
|
+
|
|
32
|
+
| Hitbox owner | `layer` | `collidesWith` |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| Player | `player` | `['*']` |
|
|
35
|
+
| Slime, blob, or orc / `Hazard` | `enemy` | `['player']` |
|
|
36
|
+
| Coin, potion, or crate / `Collectible` | `collectible` | `['player']` |
|
|
37
|
+
| Overlap `SceneTransition` | `scene-transition` | `['player']` |
|
|
38
|
+
| Platformer projectile | `projectile` | `['enemy']` |
|
|
39
|
+
|
|
40
|
+
`Collectible`, `Hazard`, overlap `SceneTransition`, and the example projectile trust those masks and no longer recheck the other Entity's role inside `onCollide`. Existing external projects using any of these handlers must explicitly migrate their sibling Hitboxes before relying on the new behavior. The defaults preserve engine-level callback delivery, but a wildcard-backed migrated handler can act on unintended default-layer entities.
|
|
41
|
+
|
|
42
|
+
1. Assign `player` / `['*']` to player Hitboxes.
|
|
43
|
+
2. Assign the relevant row above to each shipped handler carrier, or choose equivalent project-owned names.
|
|
44
|
+
3. Run MCP `validate_project`; malformed values are errors and duplicate mask entries are warnings.
|
|
45
|
+
4. Keep `trigger: 'interact'` Scene Transitions unchanged—their Hitbox mask is relevant only to overlap mode.
|
|
46
|
+
|
|
47
|
+
The editor and MCP author explicit categories for newly generated player/enemy identities. NPCs, custom or identity-less characters, generic objects, and existing external project files are not inferred or rewritten. `public/waica.params.json` may override either field with the same exact values, including a `string[]` mask.
|
|
48
|
+
|
|
49
|
+
## Collision broadphase
|
|
50
|
+
|
|
51
|
+
The Game uses fresh internal uniform grids to accelerate automatic Hitbox dispatch, Hitbox-backed `area`/`point` queries, and Solid-backed `ray` queries. Hitboxes and Solids stay in separate domains. `nearest`, `DynamicBody` physical-contact solving, Pointer picking, navigation, and unrelated scans remain linear or otherwise unchanged.
|
|
52
|
+
|
|
53
|
+
The grid changes candidate discovery only: existing exact geometry is still authoritative. Each operation observes candidates alive at its start, preserves Entity or Solid source order and tie behavior, and recomputes after scene swaps, spawns, movement, shape edits, or Tilemap-derived Solid changes. Oversized bodies and query regions fall back conservatively, so they may cost more but cannot lose results. Grid sizing, occupancy limits, indices, and rebuild controls are deliberately package-internal and have no public tuning API.
|
|
54
|
+
|
|
55
|
+
## Logical spatial queries
|
|
56
|
+
|
|
57
|
+
Every `Game` owns one stable `game.query` service. Queries use logical XY coordinates, including in isometric scenes, and return typed live engine objects:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { Component, type CollisionBody, type Entity, type Game } from '@waica/engine'
|
|
61
|
+
|
|
62
|
+
declare const game: Game
|
|
63
|
+
declare const player: Entity
|
|
64
|
+
|
|
65
|
+
class Faction extends Component {
|
|
66
|
+
name = 'neutral'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const search: CollisionBody = { x: 0, y: 0, width: 8, height: 6 }
|
|
70
|
+
const nearby = game.query.area(search, {
|
|
71
|
+
with: [Faction] as const,
|
|
72
|
+
exclude: player,
|
|
73
|
+
where: (entity) => entity.get(Faction).name !== 'friendly',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// `with` makes this non-optional at compile time.
|
|
77
|
+
nearby[0]?.get(Faction).name
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
| Method | Spatial domain | Result |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| `area(body, filter?)` | Hitbox collision outlines | All overlapping Hitbox owners |
|
|
83
|
+
| `point(x, y, filter?)` | Hitbox collision outlines | All strict point containers |
|
|
84
|
+
| `nearest(x, y, filter?)` | Entity logical transforms | The nearest Entity or `null` |
|
|
85
|
+
| `ray(x, y, dx, dy, maxDistance, filter?)` | Direct and source-derived Solids | The first `RayHit` or `null` |
|
|
86
|
+
| `Pointer` picking | Sprite visuals in projected/render space | The front-most visual owner |
|
|
87
|
+
|
|
88
|
+
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]`.
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
Invalid inputs fail closed without throwing: invalid `area` bodies and non-finite point coordinates return `[]`; invalid nearest coordinates or radii return `null`; and ray returns `null` for non-finite values, a zero direction, or a negative distance. Nearest permits positive `Infinity`; ray distance must be finite and may be zero. Zero-area candidate geometry never matches.
|
|
93
|
+
|
|
94
|
+
Area and point intentionally use the collision system's polygonally approximated circle/ellipse outline. Ray queries intersect circle-shaped Solids as analytic ellipses and return their exact outward unit normal. Collision Layers and Masks never filter `area` or `point`; use a query filter when category-like eligibility is needed.
|
|
95
|
+
|
|
9
96
|
## Component lifecycle
|
|
10
97
|
|
|
11
98
|
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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Package-internal runtime syntax check for one Collision Layer. */
|
|
2
|
+
export declare function validCollisionLayer(value: unknown): value is string;
|
|
3
|
+
/** Package-internal exact directional interest check. */
|
|
4
|
+
export declare function collisionMaskTargets(mask: unknown, targetLayer: unknown): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const COLLISION_LAYER_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
2
|
+
/** Package-internal runtime syntax check for one Collision Layer. */
|
|
3
|
+
export function validCollisionLayer(value) {
|
|
4
|
+
return typeof value === 'string' && COLLISION_LAYER_PATTERN.test(value);
|
|
5
|
+
}
|
|
6
|
+
/** Package-internal exact directional interest check. */
|
|
7
|
+
export function collisionMaskTargets(mask, targetLayer) {
|
|
8
|
+
if (!validCollisionLayer(targetLayer) || !Array.isArray(mask))
|
|
9
|
+
return false;
|
|
10
|
+
return mask.some((entry) => typeof entry === 'string' && (entry === '*' || entry === targetLayer));
|
|
11
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Game } from './game.js';
|
|
2
|
+
/** Package-internal deterministic work receipt used by focused tests. */
|
|
3
|
+
export interface CollisionDispatchStats {
|
|
4
|
+
readonly candidatePairs: number;
|
|
5
|
+
readonly narrowphaseCalls: number;
|
|
6
|
+
}
|
|
7
|
+
/** Game's package-internal trigger dispatch implementation. */
|
|
8
|
+
export declare function dispatchCollisions(game: Game): CollisionDispatchStats;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { collisionBody } from './collision-body.js';
|
|
2
|
+
import { collisionMaskTargets } from './collision-category.js';
|
|
3
|
+
import { collisionOverlap } from './collision-shape.js';
|
|
4
|
+
import { Hitbox } from './components/hitbox.js';
|
|
5
|
+
import { createHitboxBroadphase } from './spatial-broadphase.js';
|
|
6
|
+
/** Game's package-internal trigger dispatch implementation. */
|
|
7
|
+
export function dispatchCollisions(game) {
|
|
8
|
+
const pairs = createHitboxBroadphase(game).pairs();
|
|
9
|
+
let narrowphaseCalls = 0;
|
|
10
|
+
for (const [first, second] of pairs) {
|
|
11
|
+
const a = first.entity;
|
|
12
|
+
const b = second.entity;
|
|
13
|
+
if (!a.alive || !b.alive)
|
|
14
|
+
continue;
|
|
15
|
+
if (a.get(Hitbox) !== first.hitbox || b.get(Hitbox) !== second.hitbox)
|
|
16
|
+
continue;
|
|
17
|
+
const notifyA = collisionMaskTargets(first.hitbox.collidesWith, second.hitbox.layer);
|
|
18
|
+
const notifyB = collisionMaskTargets(second.hitbox.collidesWith, first.hitbox.layer);
|
|
19
|
+
if (!notifyA && !notifyB)
|
|
20
|
+
continue;
|
|
21
|
+
narrowphaseCalls += 1;
|
|
22
|
+
if (!collisionOverlap(collisionBody(first.hitbox), collisionBody(second.hitbox)))
|
|
23
|
+
continue;
|
|
24
|
+
if (notifyA) {
|
|
25
|
+
for (const component of [...a.components])
|
|
26
|
+
component.onCollide?.(b);
|
|
27
|
+
}
|
|
28
|
+
if (!a.alive || !b.alive)
|
|
29
|
+
continue;
|
|
30
|
+
if (notifyB) {
|
|
31
|
+
for (const component of [...b.components])
|
|
32
|
+
component.onCollide?.(a);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { candidatePairs: pairs.length, narrowphaseCalls };
|
|
36
|
+
}
|
package/dist/component.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface ParamSpec {
|
|
|
7
7
|
min?: number;
|
|
8
8
|
max?: number;
|
|
9
9
|
step?: number;
|
|
10
|
+
/** Specialized editor control for values that are otherwise plain JSON. */
|
|
11
|
+
kind?: 'string-list';
|
|
10
12
|
/** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
|
|
11
13
|
options?: string[];
|
|
12
14
|
/** Project value this string param names; rendered and validated as a typed reference. */
|
|
@@ -65,7 +67,7 @@ export declare abstract class Component {
|
|
|
65
67
|
onUpdate?(dt: number): void;
|
|
66
68
|
/** Runs after the scene changes between identity and projected rendering. */
|
|
67
69
|
onProjectionChange?(projection: 'isometric' | null): void;
|
|
68
|
-
/** Runs when this entity's Hitbox
|
|
70
|
+
/** Runs on overlap when this entity's Hitbox mask names the other's layer. */
|
|
69
71
|
onCollide?(other: Entity): void;
|
|
70
72
|
/** Runs when this entity's DynamicBody physically contacts a Solid. */
|
|
71
73
|
onContact?(contact: SolidContact): void;
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { Component } from '../component.js';
|
|
2
2
|
import { type CollisionPoint, type CollisionShape } from '../collision-shape.js';
|
|
3
3
|
/**
|
|
4
|
-
* Trigger collider
|
|
5
|
-
*
|
|
4
|
+
* Trigger collider. A pair is tested when either Hitbox's Collision Mask
|
|
5
|
+
* names the other's Collision Layer; only each interested owner receives
|
|
6
|
+
* `onCollide`. Spatial Queries ignore categories. For static physical
|
|
6
7
|
* collision see Solid.
|
|
7
8
|
*/
|
|
8
9
|
export declare class Hitbox extends Component {
|
|
9
10
|
static componentName: string;
|
|
10
11
|
static params: {
|
|
12
|
+
layer: {
|
|
13
|
+
label: string;
|
|
14
|
+
};
|
|
15
|
+
collidesWith: {
|
|
16
|
+
label: string;
|
|
17
|
+
kind: 'string-list';
|
|
18
|
+
};
|
|
11
19
|
offsetX: {
|
|
12
20
|
label: string;
|
|
13
21
|
};
|
|
@@ -15,6 +23,10 @@ export declare class Hitbox extends Component {
|
|
|
15
23
|
label: string;
|
|
16
24
|
};
|
|
17
25
|
};
|
|
26
|
+
/** One exact, case-sensitive `^[a-z][a-z0-9-]*$` membership name. */
|
|
27
|
+
layer: string;
|
|
28
|
+
/** Outgoing layer interest; `'*'` means every valid layer and `[]` means none. */
|
|
29
|
+
collidesWith: string[];
|
|
18
30
|
shape: CollisionShape;
|
|
19
31
|
width: number;
|
|
20
32
|
height: number;
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { Component } from '../component.js';
|
|
2
2
|
import { resolveCollisionPoints, } from '../collision-shape.js';
|
|
3
3
|
/**
|
|
4
|
-
* Trigger collider
|
|
5
|
-
*
|
|
4
|
+
* Trigger collider. A pair is tested when either Hitbox's Collision Mask
|
|
5
|
+
* names the other's Collision Layer; only each interested owner receives
|
|
6
|
+
* `onCollide`. Spatial Queries ignore categories. For static physical
|
|
6
7
|
* collision see Solid.
|
|
7
8
|
*/
|
|
8
9
|
export class Hitbox extends Component {
|
|
9
10
|
static componentName = 'Hitbox';
|
|
10
11
|
static params = {
|
|
12
|
+
layer: { label: 'Collision Layer' },
|
|
13
|
+
collidesWith: { label: 'Collision Mask', kind: 'string-list' },
|
|
11
14
|
offsetX: { label: 'x offset' },
|
|
12
15
|
offsetY: { label: 'y offset' },
|
|
13
16
|
};
|
|
17
|
+
/** One exact, case-sensitive `^[a-z][a-z0-9-]*$` membership name. */
|
|
18
|
+
layer = 'default';
|
|
19
|
+
/** Outgoing layer interest; `'*'` means every valid layer and `[]` means none. */
|
|
20
|
+
collidesWith = ['*'];
|
|
14
21
|
shape = 'rectangle';
|
|
15
22
|
width = 1;
|
|
16
23
|
height = 1;
|
package/dist/game.d.ts
CHANGED
|
@@ -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. */
|
|
@@ -48,7 +49,7 @@ export interface SceneCatalog {
|
|
|
48
49
|
registry: SceneRegistry;
|
|
49
50
|
}
|
|
50
51
|
/** Persisted overrides: entity → componentName → prop → value. */
|
|
51
|
-
export type ParamOverrides = Record<string, Record<string, Record<string, number | boolean | string>>>;
|
|
52
|
+
export type ParamOverrides = Record<string, Record<string, Record<string, number | boolean | string | string[]>>>;
|
|
52
53
|
/**
|
|
53
54
|
* Engine core: loop, unified 2D/3D three scene, orthographic camera,
|
|
54
55
|
* entities with components, and input. See DESIGN.md.
|
|
@@ -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;
|
|
@@ -135,8 +137,8 @@ export declare class Game {
|
|
|
135
137
|
* the live scene. An unknown name warns and leaves the live scene
|
|
136
138
|
* untouched. Triggered mid-frame (e.g. from a SceneTransition's
|
|
137
139
|
* onCollide/onInteract) the swap is deferred to the very start of the
|
|
138
|
-
* next runFrame — dispatchCollisions finishes its
|
|
139
|
-
* outgoing scene, and the incoming scene's entities are present only
|
|
140
|
+
* next runFrame — dispatchCollisions finishes its frozen pair snapshot for
|
|
141
|
+
* the outgoing scene, and the incoming scene's entities are present only
|
|
140
142
|
* from the next frame. Called from outside a frame (boot, or the Runtime
|
|
141
143
|
* Bridge's `scene` control operation) it applies synchronously and wins
|
|
142
144
|
* over anything queued earlier this frame. A second mid-frame request
|
package/dist/game.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
3
|
-
import {
|
|
3
|
+
import { dispatchCollisions as dispatchHitboxCollisions } from './collision-dispatch.js';
|
|
4
4
|
import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
|
|
5
5
|
import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
|
|
6
|
-
import { Hitbox } from './components/hitbox.js';
|
|
7
6
|
import { Entity } from './entity.js';
|
|
8
7
|
import { Emitter } from './events.js';
|
|
9
8
|
import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
|
|
@@ -14,6 +13,7 @@ import { RuntimeInspector } from './runtime-inspection.js';
|
|
|
14
13
|
import { projectIsometric, unprojectIsometric } from './projection.js';
|
|
15
14
|
import { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
16
15
|
import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
|
|
16
|
+
import { createSpatialQuery } from './spatial-query.js';
|
|
17
17
|
import { Stats } from './stats.js';
|
|
18
18
|
import { GameUi } from './ui.js';
|
|
19
19
|
/**
|
|
@@ -25,6 +25,7 @@ export class Game {
|
|
|
25
25
|
camera;
|
|
26
26
|
input;
|
|
27
27
|
pointer;
|
|
28
|
+
query;
|
|
28
29
|
entities = [];
|
|
29
30
|
events = new Emitter();
|
|
30
31
|
stats;
|
|
@@ -81,6 +82,7 @@ export class Game {
|
|
|
81
82
|
this.viewHeight = viewHeight;
|
|
82
83
|
this.resolution = options.resolution ?? null;
|
|
83
84
|
this.input = new Input(options.bindings);
|
|
85
|
+
this.query = createSpatialQuery(this);
|
|
84
86
|
this.stats = new Stats(options.stats);
|
|
85
87
|
this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
|
|
86
88
|
this.audio = new AudioSubsystem({
|
|
@@ -177,8 +179,8 @@ export class Game {
|
|
|
177
179
|
* the live scene. An unknown name warns and leaves the live scene
|
|
178
180
|
* untouched. Triggered mid-frame (e.g. from a SceneTransition's
|
|
179
181
|
* onCollide/onInteract) the swap is deferred to the very start of the
|
|
180
|
-
* next runFrame — dispatchCollisions finishes its
|
|
181
|
-
* outgoing scene, and the incoming scene's entities are present only
|
|
182
|
+
* next runFrame — dispatchCollisions finishes its frozen pair snapshot for
|
|
183
|
+
* the outgoing scene, and the incoming scene's entities are present only
|
|
182
184
|
* from the next frame. Called from outside a frame (boot, or the Runtime
|
|
183
185
|
* Bridge's `scene` control operation) it applies synchronously and wins
|
|
184
186
|
* over anything queued earlier this frame. A second mid-frame request
|
|
@@ -591,42 +593,7 @@ export class Game {
|
|
|
591
593
|
return this.sceneProjection === 'isometric' ? unprojectIsometric(x, y) : { x, y };
|
|
592
594
|
}
|
|
593
595
|
dispatchCollisions() {
|
|
594
|
-
|
|
595
|
-
for (let i = 0; i < boxed.length; i++) {
|
|
596
|
-
for (let j = i + 1; j < boxed.length; j++) {
|
|
597
|
-
const a = boxed[i];
|
|
598
|
-
const b = boxed[j];
|
|
599
|
-
if (!a?.alive || !b?.alive)
|
|
600
|
-
continue;
|
|
601
|
-
const ha = a.get(Hitbox);
|
|
602
|
-
const hb = b.get(Hitbox);
|
|
603
|
-
if (!ha || !hb)
|
|
604
|
-
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
|
-
});
|
|
620
|
-
if (!hit)
|
|
621
|
-
continue;
|
|
622
|
-
for (const c of [...a.components])
|
|
623
|
-
c.onCollide?.(b);
|
|
624
|
-
if (!a.alive || !b.alive)
|
|
625
|
-
continue;
|
|
626
|
-
for (const c of [...b.components])
|
|
627
|
-
c.onCollide?.(a);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
596
|
+
dispatchHitboxCollisions(this);
|
|
630
597
|
}
|
|
631
598
|
resize() {
|
|
632
599
|
const canvas = this.renderer.domElement;
|
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,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
|
+
}
|
|
@@ -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,96 @@
|
|
|
1
|
+
import { type CollisionBody, type CollisionBounds } 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, mask-agnostic and 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 point, mask-agnostic and 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 used by the Game-owned broadphase. */
|
|
85
|
+
export interface SpatialQueryCandidateProviders {
|
|
86
|
+
hitboxes(bounds: CollisionBounds): readonly HitboxCandidate[];
|
|
87
|
+
transforms(): readonly Entity[];
|
|
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;
|
|
93
|
+
}
|
|
94
|
+
/** Package-internal constructor; only the SpatialQuery interface is public. */
|
|
95
|
+
export declare function createSpatialQuery(game: Game, candidates?: SpatialQueryCandidateProviders, instrumentation?: SpatialQueryInstrumentation): SpatialQuery;
|
|
96
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { collisionBody } from './collision-body.js';
|
|
2
|
+
import { collisionBounds, collisionOverlap, } from './collision-shape.js';
|
|
3
|
+
import { Hitbox } from './components/hitbox.js';
|
|
4
|
+
import { Solid } from './components/solid.js';
|
|
5
|
+
import { createHitboxBroadphase, createSolidBroadphase, } from './spatial-broadphase.js';
|
|
6
|
+
import { collisionBodyContainsPoint, collisionBodyRay, SPATIAL_QUERY_EPSILON, usableCollisionBody, } from './spatial-query-geometry.js';
|
|
7
|
+
function indexedCandidateProviders(game) {
|
|
8
|
+
return {
|
|
9
|
+
hitboxes(bounds) {
|
|
10
|
+
return createHitboxBroadphase(game).candidates(bounds);
|
|
11
|
+
},
|
|
12
|
+
transforms() {
|
|
13
|
+
return [...game.entities].filter((entity) => entity.alive);
|
|
14
|
+
},
|
|
15
|
+
solids(bounds) {
|
|
16
|
+
return createSolidBroadphase(game).candidates(bounds);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function isReadonlyEntitySet(value) {
|
|
21
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
22
|
+
return false;
|
|
23
|
+
const candidate = value;
|
|
24
|
+
return (typeof candidate.size === 'number' &&
|
|
25
|
+
typeof candidate.has === 'function' &&
|
|
26
|
+
typeof candidate[Symbol.iterator] === 'function');
|
|
27
|
+
}
|
|
28
|
+
function isExcluded(entity, exclude) {
|
|
29
|
+
if (!exclude)
|
|
30
|
+
return false;
|
|
31
|
+
if (Array.isArray(exclude))
|
|
32
|
+
return exclude.includes(entity);
|
|
33
|
+
if (isReadonlyEntitySet(exclude))
|
|
34
|
+
return exclude.has(entity);
|
|
35
|
+
return exclude === entity;
|
|
36
|
+
}
|
|
37
|
+
function matchesCommonFilter(entity, filter) {
|
|
38
|
+
if (!filter)
|
|
39
|
+
return true;
|
|
40
|
+
if (filter.with?.some((component) => !entity.has(component)))
|
|
41
|
+
return false;
|
|
42
|
+
if (filter.without?.some((component) => entity.has(component)))
|
|
43
|
+
return false;
|
|
44
|
+
return !isExcluded(entity, filter.exclude);
|
|
45
|
+
}
|
|
46
|
+
function matchesFilter(entity, filter) {
|
|
47
|
+
return (matchesCommonFilter(entity, filter) &&
|
|
48
|
+
(!filter?.where || filter.where(entity)));
|
|
49
|
+
}
|
|
50
|
+
class LinearSpatialQuery {
|
|
51
|
+
candidates;
|
|
52
|
+
instrumentation;
|
|
53
|
+
constructor(candidates, instrumentation) {
|
|
54
|
+
this.candidates = candidates;
|
|
55
|
+
this.instrumentation = instrumentation;
|
|
56
|
+
}
|
|
57
|
+
area(body, filter) {
|
|
58
|
+
if (!usableCollisionBody(body))
|
|
59
|
+
return [];
|
|
60
|
+
const candidates = [...this.candidates.hitboxes(collisionBounds(body))].filter(({ entity }) => entity.alive);
|
|
61
|
+
this.instrumentation?.onCandidates('hitbox', candidates.length);
|
|
62
|
+
const result = [];
|
|
63
|
+
for (const { entity, hitbox } of candidates) {
|
|
64
|
+
if (!matchesFilter(entity, filter))
|
|
65
|
+
continue;
|
|
66
|
+
const candidateBody = collisionBody(hitbox);
|
|
67
|
+
if (usableCollisionBody(candidateBody) && collisionOverlap(body, candidateBody)) {
|
|
68
|
+
result.push(entity);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
point(x, y, filter) {
|
|
74
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
75
|
+
return [];
|
|
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);
|
|
80
|
+
const result = [];
|
|
81
|
+
for (const { entity, hitbox } of candidates) {
|
|
82
|
+
if (!matchesFilter(entity, filter))
|
|
83
|
+
continue;
|
|
84
|
+
if (collisionBodyContainsPoint(collisionBody(hitbox), x, y))
|
|
85
|
+
result.push(entity);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
nearest(x, y, filter) {
|
|
90
|
+
const maxDistance = filter?.maxDistance ?? Infinity;
|
|
91
|
+
if (!Number.isFinite(x) ||
|
|
92
|
+
!Number.isFinite(y) ||
|
|
93
|
+
Number.isNaN(maxDistance) ||
|
|
94
|
+
maxDistance < 0 ||
|
|
95
|
+
(maxDistance !== Infinity && !Number.isFinite(maxDistance))) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const candidates = [...this.candidates.transforms()].filter((entity) => entity.alive);
|
|
99
|
+
let result = null;
|
|
100
|
+
let nearestDistance = Infinity;
|
|
101
|
+
for (const entity of candidates) {
|
|
102
|
+
if (!matchesCommonFilter(entity, filter))
|
|
103
|
+
continue;
|
|
104
|
+
const distance = Math.hypot(entity.position.x - x, entity.position.y - y);
|
|
105
|
+
if (Number.isNaN(distance) || distance > maxDistance)
|
|
106
|
+
continue;
|
|
107
|
+
if (filter?.where &&
|
|
108
|
+
!filter.where(entity, { distance })) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (result === null || distance < nearestDistance) {
|
|
112
|
+
result = entity;
|
|
113
|
+
nearestDistance = distance;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
ray(x, y, dx, dy, maxDistance, filter) {
|
|
119
|
+
const directionScale = Math.max(Math.abs(dx), Math.abs(dy));
|
|
120
|
+
if (!Number.isFinite(x) ||
|
|
121
|
+
!Number.isFinite(y) ||
|
|
122
|
+
!Number.isFinite(dx) ||
|
|
123
|
+
!Number.isFinite(dy) ||
|
|
124
|
+
!Number.isFinite(maxDistance) ||
|
|
125
|
+
directionScale === 0 ||
|
|
126
|
+
maxDistance < 0) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const scaledX = dx / directionScale;
|
|
130
|
+
const scaledY = dy / directionScale;
|
|
131
|
+
const scaledLength = Math.hypot(scaledX, scaledY);
|
|
132
|
+
const unitX = scaledX / scaledLength;
|
|
133
|
+
const unitY = scaledY / scaledLength;
|
|
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);
|
|
145
|
+
let result = null;
|
|
146
|
+
for (const { entity, solid } of candidates) {
|
|
147
|
+
if (!matchesFilter(entity, filter))
|
|
148
|
+
continue;
|
|
149
|
+
const hit = collisionBodyRay(collisionBody(solid), x, y, unitX, unitY, maxDistance);
|
|
150
|
+
if (!hit)
|
|
151
|
+
continue;
|
|
152
|
+
if (result && hit.distance >= result.distance - SPATIAL_QUERY_EPSILON)
|
|
153
|
+
continue;
|
|
154
|
+
result = {
|
|
155
|
+
entity,
|
|
156
|
+
solid,
|
|
157
|
+
distance: hit.distance,
|
|
158
|
+
point: { x: hit.point.x, y: hit.point.y },
|
|
159
|
+
normal: { x: hit.normal.x, y: hit.normal.y },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Package-internal constructor; only the SpatialQuery interface is public. */
|
|
166
|
+
export function createSpatialQuery(game, candidates = indexedCandidateProviders(game), instrumentation) {
|
|
167
|
+
return new LinearSpatialQuery(candidates, instrumentation);
|
|
168
|
+
}
|