@waica/engine 0.25.0 → 0.26.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 +55 -1
- package/dist/anchored-pieces.d.ts +3 -2
- package/dist/anchored-pieces.js +12 -8
- package/dist/archetype.d.ts +2 -2
- package/dist/assets/asset-loader.d.ts +28 -5
- package/dist/assets/asset-loader.js +63 -7
- package/dist/assets/model-backend.d.ts +21 -0
- package/dist/assets/model-backend.js +21 -0
- package/dist/assets/model-cache.d.ts +47 -0
- package/dist/assets/model-cache.js +117 -0
- package/dist/audio/audio-subsystem.d.ts +6 -1
- package/dist/audio/audio-subsystem.js +8 -15
- package/dist/audio/placement.d.ts +41 -0
- package/dist/audio/placement.js +21 -0
- package/dist/audio/spatial.d.ts +20 -0
- package/dist/audio/spatial.js +26 -0
- package/dist/audio/types.d.ts +7 -1
- package/dist/camera-projection.d.ts +38 -0
- package/dist/camera-projection.js +44 -0
- package/dist/camera-rig.d.ts +48 -0
- package/dist/camera-rig.js +98 -0
- package/dist/camera.d.ts +20 -3
- package/dist/camera.js +3 -1
- package/dist/component.d.ts +38 -2
- package/dist/component.js +3 -0
- package/dist/components/animated-sprite.d.ts +2 -1
- package/dist/components/animated-sprite.js +1 -0
- package/dist/components/collider.d.ts +89 -0
- package/dist/components/collider.js +57 -0
- package/dist/components/dynamic-body.d.ts +2 -1
- package/dist/components/dynamic-body.js +1 -0
- package/dist/components/hitbox.d.ts +2 -1
- package/dist/components/hitbox.js +1 -0
- package/dist/components/light.d.ts +2 -1
- package/dist/components/light.js +1 -0
- package/dist/components/model.d.ts +63 -0
- package/dist/components/model.js +117 -0
- package/dist/components/particle-emitter.d.ts +2 -1
- package/dist/components/particle-emitter.js +1 -0
- package/dist/components/point-light.d.ts +75 -0
- package/dist/components/point-light.js +102 -0
- package/dist/components/rigid-body.d.ts +114 -0
- package/dist/components/rigid-body.js +83 -0
- package/dist/components/solid.d.ts +2 -1
- package/dist/components/solid.js +1 -0
- package/dist/components/sprite.d.ts +2 -1
- package/dist/components/sprite.js +1 -0
- package/dist/components/sun.d.ts +46 -0
- package/dist/components/sun.js +74 -0
- package/dist/components/tilemap.d.ts +2 -1
- package/dist/components/tilemap.js +1 -0
- package/dist/entity-transform.d.ts +18 -0
- package/dist/entity-transform.js +17 -0
- package/dist/game.d.ts +51 -15
- package/dist/game.js +83 -70
- package/dist/index.d.ts +29 -4
- package/dist/index.js +12 -1
- package/dist/lit-frame.js +10 -3
- package/dist/physics-3d/body-contacts.d.ts +10 -0
- package/dist/physics-3d/body-contacts.js +59 -0
- package/dist/physics-3d/body-sync.d.ts +52 -0
- package/dist/physics-3d/body-sync.js +153 -0
- package/dist/physics-3d/character-controller.d.ts +44 -0
- package/dist/physics-3d/character-controller.js +126 -0
- package/dist/physics-3d/physics-host.d.ts +86 -0
- package/dist/physics-3d/physics-host.js +174 -0
- package/dist/physics-3d/physics-world.d.ts +65 -0
- package/dist/physics-3d/physics-world.js +158 -0
- package/dist/physics-3d/rapier-module.d.ts +17 -0
- package/dist/physics-3d/rapier-module.js +19 -0
- package/dist/physics-3d/sensor-dispatch.d.ts +10 -0
- package/dist/physics-3d/sensor-dispatch.js +77 -0
- package/dist/physics-3d/world-queries.d.ts +39 -0
- package/dist/physics-3d/world-queries.js +63 -0
- package/dist/pointer-raycast.d.ts +17 -0
- package/dist/pointer-raycast.js +48 -0
- package/dist/pointer.d.ts +27 -6
- package/dist/pointer.js +43 -17
- package/dist/runtime-bridge.d.ts +1 -1
- package/dist/runtime-inspection.d.ts +9 -0
- package/dist/runtime-inspection.js +8 -2
- package/dist/runtime-lighting-snapshot.d.ts +27 -1
- package/dist/runtime-lighting-snapshot.js +31 -0
- package/dist/runtime-physics-snapshot.d.ts +41 -0
- package/dist/runtime-physics-snapshot.js +45 -0
- package/dist/runtime-view-snapshot.d.ts +25 -0
- package/dist/runtime-view-snapshot.js +30 -0
- package/dist/scene-camera-3d.d.ts +66 -0
- package/dist/scene-camera-3d.js +117 -0
- package/dist/scene-lights-3d.d.ts +17 -0
- package/dist/scene-lights-3d.js +27 -0
- package/dist/scene-simulation.d.ts +22 -0
- package/dist/scene-simulation.js +29 -0
- package/dist/scene-space.d.ts +50 -0
- package/dist/scene-space.js +103 -0
- package/dist/scene.d.ts +12 -2
- package/dist/scene.js +11 -4
- package/dist/simulation-phases.d.ts +9 -0
- package/dist/simulation-phases.js +19 -0
- package/dist/spatial-query-3d.d.ts +48 -0
- package/dist/spatial-query-3d.js +96 -0
- package/dist/spatial-query.d.ts +18 -4
- package/dist/spatial-query.js +57 -1
- package/package.json +2 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Collider } from '../components/collider.js';
|
|
2
|
+
import { RigidBody } from '../components/rigid-body.js';
|
|
3
|
+
import { SIMULATION_STEP } from '../fixed-step.js';
|
|
4
|
+
import { colliderProblem, createBody, rigidBodyProblem, syncBodyPose } from './body-sync.js';
|
|
5
|
+
import { CharacterMotion } from './character-controller.js';
|
|
6
|
+
/**
|
|
7
|
+
* One Rapier world and the bodies of the live 3D scene (ADR 0028): created
|
|
8
|
+
* with the scene, stepped once per Simulation Step, freed with the scene. It
|
|
9
|
+
* owns every Rapier handle; components only hold plain params and ask the
|
|
10
|
+
* Game's `physics` for velocity, impulses and the like by entity.
|
|
11
|
+
*/
|
|
12
|
+
export class PhysicsWorld {
|
|
13
|
+
R;
|
|
14
|
+
/** The Rapier world itself, for the modules that query it; nothing outside `physics-3d/` and its tests should touch it. */
|
|
15
|
+
raw;
|
|
16
|
+
records = new Map();
|
|
17
|
+
byCollider = new Map();
|
|
18
|
+
/** Entities with a RigidBody and, so far, no Collider: warned about at the first step if it stays so. */
|
|
19
|
+
orphans = new Set();
|
|
20
|
+
/** The last warning each entity got, so the same problem seen from both of its components is said once. */
|
|
21
|
+
warned = new WeakMap();
|
|
22
|
+
sensors = 0;
|
|
23
|
+
/** Bodies came or went since Rapier last updated the structure its queries read. */
|
|
24
|
+
queriesStale = false;
|
|
25
|
+
characters;
|
|
26
|
+
constructor(
|
|
27
|
+
/** The Rapier module the world was made with, for the shapes and rays its queries build. */
|
|
28
|
+
R, gravity) {
|
|
29
|
+
this.R = R;
|
|
30
|
+
this.raw = new R.World({ x: gravity[0], y: gravity[1], z: gravity[2] });
|
|
31
|
+
this.raw.timestep = SIMULATION_STEP;
|
|
32
|
+
this.characters = new CharacterMotion(R, this.raw);
|
|
33
|
+
}
|
|
34
|
+
/** Whether any body has a sensor Collider, so the sensor dispatch can be skipped when none does. */
|
|
35
|
+
get hasSensors() {
|
|
36
|
+
return this.sensors > 0;
|
|
37
|
+
}
|
|
38
|
+
get gravity() {
|
|
39
|
+
const { x, y, z } = this.raw.gravity;
|
|
40
|
+
return [x, y, z];
|
|
41
|
+
}
|
|
42
|
+
set gravity(value) {
|
|
43
|
+
this.raw.gravity = { x: value[0], y: value[1], z: value[2] };
|
|
44
|
+
}
|
|
45
|
+
/** The record of an entity's body, if it has one. */
|
|
46
|
+
recordOf(entity) {
|
|
47
|
+
return this.records.get(entity);
|
|
48
|
+
}
|
|
49
|
+
/** The record that owns a Rapier collider handle. */
|
|
50
|
+
recordOfCollider(handle) {
|
|
51
|
+
return this.byCollider.get(handle);
|
|
52
|
+
}
|
|
53
|
+
/** The bodies of the given entities, in their order. */
|
|
54
|
+
bodiesOf(entities) {
|
|
55
|
+
return entities.flatMap((entity) => {
|
|
56
|
+
const record = this.records.get(entity);
|
|
57
|
+
return record ? [record] : [];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Builds (or rebuilds) the entity's body from the Collider and RigidBody it
|
|
62
|
+
* has now. Called from each component's `onReady`, so either order of the
|
|
63
|
+
* two works: the later one replaces the earlier one's body.
|
|
64
|
+
*/
|
|
65
|
+
attach(entity) {
|
|
66
|
+
const collider = entity.get(Collider);
|
|
67
|
+
const rigid = entity.get(RigidBody) ?? null;
|
|
68
|
+
if (!collider) {
|
|
69
|
+
if (rigid)
|
|
70
|
+
this.orphans.add(entity);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.detach(entity);
|
|
74
|
+
const problem = colliderProblem(collider);
|
|
75
|
+
const bodyProblem = problem === null && rigid ? rigidBodyProblem(rigid) : null;
|
|
76
|
+
if (problem !== null)
|
|
77
|
+
return this.warnOnce(entity, `Collider on "${entity.name}" creates no body: ${problem}.`);
|
|
78
|
+
if (bodyProblem !== null)
|
|
79
|
+
return this.warnOnce(entity, `RigidBody on "${entity.name}" creates no body: ${bodyProblem}.`);
|
|
80
|
+
const record = createBody(this.R, this.raw, { entity, collider, rigid });
|
|
81
|
+
this.records.set(entity, record);
|
|
82
|
+
this.queriesStale = true;
|
|
83
|
+
this.byCollider.set(record.shape.handle, record);
|
|
84
|
+
if (record.sensor)
|
|
85
|
+
this.sensors += 1;
|
|
86
|
+
}
|
|
87
|
+
/** Removes the entity's body and collider; a no-op for an entity without one. */
|
|
88
|
+
detach(entity) {
|
|
89
|
+
this.orphans.delete(entity);
|
|
90
|
+
const record = this.records.get(entity);
|
|
91
|
+
if (!record)
|
|
92
|
+
return;
|
|
93
|
+
this.records.delete(entity);
|
|
94
|
+
this.byCollider.delete(record.shape.handle);
|
|
95
|
+
if (record.sensor)
|
|
96
|
+
this.sensors -= 1;
|
|
97
|
+
this.raw.removeRigidBody(record.body);
|
|
98
|
+
this.queriesStale = true;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* One Simulation Step of physics: every kinematic body is moved through the
|
|
102
|
+
* character controller, Rapier steps, then moving bodies write their pose to
|
|
103
|
+
* their entities.
|
|
104
|
+
*/
|
|
105
|
+
step() {
|
|
106
|
+
this.warnOrphans();
|
|
107
|
+
this.characters.follow(this.records.values());
|
|
108
|
+
for (const record of this.records.values()) {
|
|
109
|
+
if (record.kind !== 'kinematic')
|
|
110
|
+
continue;
|
|
111
|
+
this.refreshQueries();
|
|
112
|
+
this.characters.move(record);
|
|
113
|
+
}
|
|
114
|
+
this.raw.step();
|
|
115
|
+
this.queriesStale = false;
|
|
116
|
+
for (const record of this.records.values())
|
|
117
|
+
syncBodyPose(record);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Makes ray casts, shape queries and the character controller see the bodies
|
|
121
|
+
* created or removed since the last step: Rapier only updates the structure
|
|
122
|
+
* they read inside `step()`, so a body spawned this frame would be invisible
|
|
123
|
+
* (a character would fall through a floor spawned with it). A step of zero
|
|
124
|
+
* seconds updates it and moves nothing.
|
|
125
|
+
*/
|
|
126
|
+
refreshQueries() {
|
|
127
|
+
if (!this.queriesStale)
|
|
128
|
+
return;
|
|
129
|
+
this.queriesStale = false;
|
|
130
|
+
const timestep = this.raw.timestep;
|
|
131
|
+
this.raw.timestep = 0;
|
|
132
|
+
this.raw.step();
|
|
133
|
+
this.raw.timestep = timestep;
|
|
134
|
+
}
|
|
135
|
+
/** Frees the Rapier world and forgets every body. The entities are destroyed (and detached) before this. */
|
|
136
|
+
free() {
|
|
137
|
+
this.records.clear();
|
|
138
|
+
this.byCollider.clear();
|
|
139
|
+
this.orphans.clear();
|
|
140
|
+
this.sensors = 0;
|
|
141
|
+
this.characters.free();
|
|
142
|
+
this.raw.free();
|
|
143
|
+
}
|
|
144
|
+
warnOrphans() {
|
|
145
|
+
for (const entity of this.orphans) {
|
|
146
|
+
this.orphans.delete(entity);
|
|
147
|
+
if (!entity.alive || entity.has(Collider))
|
|
148
|
+
continue;
|
|
149
|
+
this.warnOnce(entity, `RigidBody on "${entity.name}" needs a Collider on the same entity; no body was created.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
warnOnce(entity, message) {
|
|
153
|
+
if (this.warned.get(entity) === message)
|
|
154
|
+
return;
|
|
155
|
+
this.warned.set(entity, message);
|
|
156
|
+
console.warn(`[waica] ${message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type * as Rapier from '@dimforge/rapier3d-deterministic-compat';
|
|
2
|
+
/** The initialized Rapier module: the `RAPIER` namespace the package documents. */
|
|
3
|
+
export type RapierModule = typeof Rapier;
|
|
4
|
+
export type { Collider as RapierCollider, ColliderDesc as RapierColliderDesc, KinematicCharacterController as RapierCharacterController, RigidBody as RapierBody, TempContactManifold as RapierManifold, World as RapierWorld, } from '@dimforge/rapier3d-deterministic-compat';
|
|
5
|
+
/** The package name, for the messages that name it. */
|
|
6
|
+
export declare const RAPIER_PACKAGE = "@dimforge/rapier3d-deterministic-compat";
|
|
7
|
+
/**
|
|
8
|
+
* Where a Game gets Rapier (ADR 0013's seam, applied to physics): resolves to
|
|
9
|
+
* the initialized module. Defaults to `loadRapier`; a project's own tests pass
|
|
10
|
+
* the real module they initialized once, or a rejecting stub for the failure path.
|
|
11
|
+
*/
|
|
12
|
+
export type PhysicsBackend = () => Promise<RapierModule>;
|
|
13
|
+
/**
|
|
14
|
+
* Imports and initializes Rapier, once per page: every Game that asks shares
|
|
15
|
+
* the same module. A failed load is forgotten, so a later Game may retry.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadRapier(): Promise<RapierModule>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The package name, for the messages that name it. */
|
|
2
|
+
export const RAPIER_PACKAGE = '@dimforge/rapier3d-deterministic-compat';
|
|
3
|
+
let shared = null;
|
|
4
|
+
/**
|
|
5
|
+
* Imports and initializes Rapier, once per page: every Game that asks shares
|
|
6
|
+
* the same module. A failed load is forgotten, so a later Game may retry.
|
|
7
|
+
*/
|
|
8
|
+
export function loadRapier() {
|
|
9
|
+
shared ??= import('@dimforge/rapier3d-deterministic-compat')
|
|
10
|
+
.then(async (module) => {
|
|
11
|
+
await module.init();
|
|
12
|
+
return module;
|
|
13
|
+
})
|
|
14
|
+
.catch((error) => {
|
|
15
|
+
shared = null;
|
|
16
|
+
throw error;
|
|
17
|
+
});
|
|
18
|
+
return shared;
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Game } from '../game.js';
|
|
2
|
+
import type { PhysicsWorld } from './physics-world.js';
|
|
3
|
+
/**
|
|
4
|
+
* The 3D counterpart of the Hitbox trigger dispatch (CA-16): after the physics
|
|
5
|
+
* step, every overlap involving a sensor Collider fires `onCollide(other)` on
|
|
6
|
+
* the components of each side whose interest names the other's layer, every
|
|
7
|
+
* step while they overlap. Pairs are frozen first and each is re-checked live,
|
|
8
|
+
* like the 2D dispatch.
|
|
9
|
+
*/
|
|
10
|
+
export declare function dispatchSensorCollisions(game: Game, world: PhysicsWorld): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { collisionMaskTargets } from '../collision-category.js';
|
|
2
|
+
import { Collider } from '../components/collider.js';
|
|
3
|
+
/** The mask a Collider has when nothing was declared: every layer. */
|
|
4
|
+
function isDefaultMask(mask) {
|
|
5
|
+
return mask.length === 1 && mask[0] === '*';
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether `self` is notified about `target` (ADR 0016's directional
|
|
9
|
+
* interest). A sensor always has an interest, its `collidesWith` (default
|
|
10
|
+
* every layer), except that the default mask leaves out the level's fixed
|
|
11
|
+
* solids: a 2D Hitbox never sees a Solid, so a pickup resting on the floor is
|
|
12
|
+
* not told about the floor every step; naming their layer still works. A
|
|
13
|
+
* solid collider has no interest until it declares a mask of its own
|
|
14
|
+
* (inference 14, issue #159): the default `['*']` on a solid means "no
|
|
15
|
+
* interest", so a wall or a character is not woken by every trigger it touches.
|
|
16
|
+
*/
|
|
17
|
+
function interestedIn(self, target) {
|
|
18
|
+
const { collider } = self;
|
|
19
|
+
const defaultMask = isDefaultMask(collider.collidesWith);
|
|
20
|
+
if (!self.sensor && defaultMask)
|
|
21
|
+
return false;
|
|
22
|
+
if (defaultMask && !target.sensor && target.kind === 'fixed')
|
|
23
|
+
return false;
|
|
24
|
+
return collisionMaskTargets(collider.collidesWith, target.collider.layer);
|
|
25
|
+
}
|
|
26
|
+
/** Every sensor overlap of the world as a frozen snapshot: each pair once, the earlier-spawned sensor first. */
|
|
27
|
+
function sensorPairs(game, world) {
|
|
28
|
+
const records = world.bodiesOf(game.entities);
|
|
29
|
+
const spawnIndex = new Map(records.map((record, index) => [record, index]));
|
|
30
|
+
const spawnedBefore = (a, b) => (spawnIndex.get(a) ?? 0) < (spawnIndex.get(b) ?? 0);
|
|
31
|
+
const pairs = [];
|
|
32
|
+
for (const sensor of records.filter((record) => record.sensor)) {
|
|
33
|
+
world.raw.intersectionPairsWith(sensor.shape, (shape) => {
|
|
34
|
+
const other = world.recordOfCollider(shape.handle);
|
|
35
|
+
if (!other || other === sensor)
|
|
36
|
+
return;
|
|
37
|
+
// Two sensors see each other: the earlier one reports the pair.
|
|
38
|
+
if (other.sensor && spawnedBefore(other, sensor))
|
|
39
|
+
return;
|
|
40
|
+
pairs.push([sensor, other]);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return pairs;
|
|
44
|
+
}
|
|
45
|
+
/** Both entities of a snapshotted pair are alive and still carry the Collider it was taken from. */
|
|
46
|
+
function stillOverlapping([first, second]) {
|
|
47
|
+
return (first.entity.alive &&
|
|
48
|
+
second.entity.alive &&
|
|
49
|
+
first.entity.get(Collider) === first.collider &&
|
|
50
|
+
second.entity.get(Collider) === second.collider);
|
|
51
|
+
}
|
|
52
|
+
/** `onCollide(other)` on every component of `self`'s entity, when its interest names `other`'s layer. */
|
|
53
|
+
function notify(self, other) {
|
|
54
|
+
if (!interestedIn(self, other))
|
|
55
|
+
return;
|
|
56
|
+
for (const component of [...self.entity.components])
|
|
57
|
+
component.onCollide?.(other.entity);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The 3D counterpart of the Hitbox trigger dispatch (CA-16): after the physics
|
|
61
|
+
* step, every overlap involving a sensor Collider fires `onCollide(other)` on
|
|
62
|
+
* the components of each side whose interest names the other's layer, every
|
|
63
|
+
* step while they overlap. Pairs are frozen first and each is re-checked live,
|
|
64
|
+
* like the 2D dispatch.
|
|
65
|
+
*/
|
|
66
|
+
export function dispatchSensorCollisions(game, world) {
|
|
67
|
+
if (!world.hasSensors)
|
|
68
|
+
return;
|
|
69
|
+
for (const pair of sensorPairs(game, world)) {
|
|
70
|
+
if (!stillOverlapping(pair))
|
|
71
|
+
continue;
|
|
72
|
+
const [first, second] = pair;
|
|
73
|
+
notify(first, second);
|
|
74
|
+
if (stillOverlapping(pair))
|
|
75
|
+
notify(second, first);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { BodyRecord } from './body-sync.js';
|
|
2
|
+
import type { PhysicsWorld } from './physics-world.js';
|
|
3
|
+
/** A point or a direction in world space, as `game.query` takes and returns it. */
|
|
4
|
+
export interface Point3d {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
z: number;
|
|
8
|
+
}
|
|
9
|
+
/** A volume `game.query.area` tests sensor colliders against. */
|
|
10
|
+
export type QueryVolume3d = {
|
|
11
|
+
shape: 'box';
|
|
12
|
+
center: Point3d;
|
|
13
|
+
size: Point3d;
|
|
14
|
+
} | {
|
|
15
|
+
shape: 'sphere';
|
|
16
|
+
center: Point3d;
|
|
17
|
+
radius: number;
|
|
18
|
+
};
|
|
19
|
+
/** One collider a ray reaches: whose it is, how far, where and which way its surface faces. */
|
|
20
|
+
export interface WorldRayHit {
|
|
21
|
+
record: BodyRecord;
|
|
22
|
+
distance: number;
|
|
23
|
+
point: Point3d;
|
|
24
|
+
normal: Point3d;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Every collider along a ray whose record `accepts`, nearest first.
|
|
28
|
+
* `direction` is a unit vector, so the distance is in world units; an origin
|
|
29
|
+
* inside a collider hits it at distance 0.
|
|
30
|
+
*/
|
|
31
|
+
export declare function worldRayHits(world: PhysicsWorld, ray: {
|
|
32
|
+
origin: Point3d;
|
|
33
|
+
direction: Point3d;
|
|
34
|
+
maxDistance: number;
|
|
35
|
+
}, accepts: (record: BodyRecord) => boolean): WorldRayHit[];
|
|
36
|
+
/** Every record whose collider `accepts` and that intersects the volume. */
|
|
37
|
+
export declare function recordsInVolume(world: PhysicsWorld, volume: QueryVolume3d, accepts: (record: BodyRecord) => boolean): BodyRecord[];
|
|
38
|
+
/** Every record whose collider `accepts` and that contains the point. */
|
|
39
|
+
export declare function recordsAtPoint(world: PhysicsWorld, at: Point3d, accepts: (record: BodyRecord) => boolean): BodyRecord[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const IDENTITY = { x: 0, y: 0, z: 0, w: 1 };
|
|
2
|
+
/**
|
|
3
|
+
* Rapier's predicate for a query: only colliders whose body record `accepts`.
|
|
4
|
+
* It runs inside Rapier's borrow of the world, so `accepts` must be a plain
|
|
5
|
+
* read of the record (the domain, liveness, the structural filter); game code
|
|
6
|
+
* such as a `where` predicate runs afterwards, on the collected records.
|
|
7
|
+
*/
|
|
8
|
+
function accepting(world, accepts) {
|
|
9
|
+
return (collider) => {
|
|
10
|
+
const record = world.recordOfCollider(collider.handle);
|
|
11
|
+
return record !== undefined && accepts(record);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Every collider along a ray whose record `accepts`, nearest first.
|
|
16
|
+
* `direction` is a unit vector, so the distance is in world units; an origin
|
|
17
|
+
* inside a collider hits it at distance 0.
|
|
18
|
+
*/
|
|
19
|
+
export function worldRayHits(world, ray, accepts) {
|
|
20
|
+
const { R, raw } = world;
|
|
21
|
+
const { origin, direction } = ray;
|
|
22
|
+
const hits = [];
|
|
23
|
+
raw.intersectionsWithRay(new R.Ray(origin, direction), ray.maxDistance, true, (hit) => {
|
|
24
|
+
const record = world.recordOfCollider(hit.collider.handle);
|
|
25
|
+
const distance = hit.timeOfImpact;
|
|
26
|
+
if (record) {
|
|
27
|
+
hits.push({
|
|
28
|
+
record,
|
|
29
|
+
distance,
|
|
30
|
+
point: { x: origin.x + direction.x * distance, y: origin.y + direction.y * distance, z: origin.z + direction.z * distance },
|
|
31
|
+
normal: { x: hit.normal.x, y: hit.normal.y, z: hit.normal.z },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}, undefined, undefined, undefined, undefined, accepting(world, accepts));
|
|
36
|
+
return hits.sort((a, b) => a.distance - b.distance);
|
|
37
|
+
}
|
|
38
|
+
/** Every record whose collider `accepts` and that intersects the volume. */
|
|
39
|
+
export function recordsInVolume(world, volume, accepts) {
|
|
40
|
+
const { R, raw } = world;
|
|
41
|
+
const shape = volume.shape === 'box'
|
|
42
|
+
? new R.Cuboid(volume.size.x / 2, volume.size.y / 2, volume.size.z / 2)
|
|
43
|
+
: new R.Ball(volume.radius);
|
|
44
|
+
const found = [];
|
|
45
|
+
raw.intersectionsWithShape(volume.center, IDENTITY, shape, (collider) => {
|
|
46
|
+
const record = world.recordOfCollider(collider.handle);
|
|
47
|
+
if (record)
|
|
48
|
+
found.push(record);
|
|
49
|
+
return true;
|
|
50
|
+
}, undefined, undefined, undefined, undefined, accepting(world, accepts));
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
/** Every record whose collider `accepts` and that contains the point. */
|
|
54
|
+
export function recordsAtPoint(world, at, accepts) {
|
|
55
|
+
const found = [];
|
|
56
|
+
world.raw.intersectionsWithPoint(at, (collider) => {
|
|
57
|
+
const record = world.recordOfCollider(collider.handle);
|
|
58
|
+
if (record)
|
|
59
|
+
found.push(record);
|
|
60
|
+
return true;
|
|
61
|
+
}, undefined, undefined, undefined, undefined, accepting(world, accepts));
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { Entity } from './entity.js';
|
|
3
|
+
import type { PointerPick } from './pointer.js';
|
|
4
|
+
/** A position inside the letterboxed game viewport, as fractions of its width and height. */
|
|
5
|
+
export interface ViewportFraction {
|
|
6
|
+
nx: number;
|
|
7
|
+
ny: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
|
|
11
|
+
* letterboxed point, intersected with the roots of every live Model
|
|
12
|
+
* (recursively, so a glTF's meshes count and a glTF still loading does not).
|
|
13
|
+
* The nearest hit wins, with its world-space point. With no hit the pick has
|
|
14
|
+
* no entity and the point where the ray meets the ground plane y = 0; a ray
|
|
15
|
+
* that never meets it (it looks at the sky) yields no pick.
|
|
16
|
+
*/
|
|
17
|
+
export declare function raycastPick(camera: THREE.PerspectiveCamera, at: ViewportFraction, entities: readonly Entity[]): PointerPick | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import { Model } from './components/model.js';
|
|
3
|
+
const raycaster = new THREE.Raycaster();
|
|
4
|
+
/** The ground of a 3D scene: y = 0, normal up. */
|
|
5
|
+
const GROUND = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
6
|
+
/** Every live Model's root, with the entity that owns it. */
|
|
7
|
+
function modelRoots(entities) {
|
|
8
|
+
const roots = new Map();
|
|
9
|
+
for (const entity of entities) {
|
|
10
|
+
if (!entity.alive)
|
|
11
|
+
continue;
|
|
12
|
+
for (const component of entity.components) {
|
|
13
|
+
if (component instanceof Model && component.root)
|
|
14
|
+
roots.set(component.root, entity);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return roots;
|
|
18
|
+
}
|
|
19
|
+
/** The entity whose Model root `object` hangs under. */
|
|
20
|
+
function ownerOf(object, roots) {
|
|
21
|
+
for (let node = object; node; node = node.parent) {
|
|
22
|
+
const entity = roots.get(node);
|
|
23
|
+
if (entity)
|
|
24
|
+
return entity;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
|
|
30
|
+
* letterboxed point, intersected with the roots of every live Model
|
|
31
|
+
* (recursively, so a glTF's meshes count and a glTF still loading does not).
|
|
32
|
+
* The nearest hit wins, with its world-space point. With no hit the pick has
|
|
33
|
+
* no entity and the point where the ray meets the ground plane y = 0; a ray
|
|
34
|
+
* that never meets it (it looks at the sky) yields no pick.
|
|
35
|
+
*/
|
|
36
|
+
export function raycastPick(camera, at, entities) {
|
|
37
|
+
camera.updateMatrixWorld();
|
|
38
|
+
raycaster.setFromCamera(new THREE.Vector2(at.nx * 2 - 1, 1 - at.ny * 2), camera);
|
|
39
|
+
const roots = modelRoots(entities);
|
|
40
|
+
// A click can land before the first frame has placed anything.
|
|
41
|
+
for (const root of roots.keys())
|
|
42
|
+
root.updateWorldMatrix(true, true);
|
|
43
|
+
const [hit] = raycaster.intersectObjects([...roots.keys()], true);
|
|
44
|
+
if (hit)
|
|
45
|
+
return { entity: ownerOf(hit.object, roots), point: { x: hit.point.x, y: hit.point.y, z: hit.point.z } };
|
|
46
|
+
const ground = raycaster.ray.intersectPlane(GROUND, new THREE.Vector3());
|
|
47
|
+
return ground ? { entity: null, point: { x: ground.x, y: ground.y, z: ground.z } } : null;
|
|
48
|
+
}
|
package/dist/pointer.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ViewCamera } from './camera-projection.js';
|
|
1
2
|
import type { Entity } from './entity.js';
|
|
2
3
|
import { type ProjectedPoint } from './projection.js';
|
|
3
4
|
/** Fixed-resolution letterbox target; a duck-typed subset of GameResolution. */
|
|
@@ -17,7 +18,8 @@ export interface PointerCamera {
|
|
|
17
18
|
bottom: number;
|
|
18
19
|
}
|
|
19
20
|
export interface PointerDeps {
|
|
20
|
-
camera
|
|
21
|
+
/** The live camera, or a function returning it when the scene can swap cameras. */
|
|
22
|
+
camera: ViewCamera | (() => ViewCamera);
|
|
21
23
|
/** Fixed resolution (letterbox target), or null to fill the canvas. */
|
|
22
24
|
resolution: PointerResolution | null;
|
|
23
25
|
/** The scene's active render projection, read live. */
|
|
@@ -25,9 +27,14 @@ export interface PointerDeps {
|
|
|
25
27
|
/** Live entity list — read at pick time, so later spawns are visible. */
|
|
26
28
|
entities: readonly Entity[];
|
|
27
29
|
}
|
|
28
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* One resolved click: the logical-space point, plus the entity picked there
|
|
32
|
+
* (if any). In a 3D scene the point is in world space and carries `z`.
|
|
33
|
+
*/
|
|
29
34
|
export interface PointerPick {
|
|
30
|
-
readonly point: ProjectedPoint
|
|
35
|
+
readonly point: ProjectedPoint & {
|
|
36
|
+
readonly z?: number;
|
|
37
|
+
};
|
|
31
38
|
readonly entity: Entity | null;
|
|
32
39
|
}
|
|
33
40
|
/**
|
|
@@ -44,6 +51,7 @@ export declare class Pointer {
|
|
|
44
51
|
private readonly canvas;
|
|
45
52
|
private readonly deps;
|
|
46
53
|
private pending;
|
|
54
|
+
private readonly liveCamera;
|
|
47
55
|
constructor(canvas: HTMLCanvasElement, deps: PointerDeps);
|
|
48
56
|
dispose(): void;
|
|
49
57
|
/**
|
|
@@ -53,11 +61,24 @@ export declare class Pointer {
|
|
|
53
61
|
*/
|
|
54
62
|
takePending(): PointerPick | null;
|
|
55
63
|
/**
|
|
56
|
-
* Programmatic click injection (Runtime Bridge CA-10)
|
|
57
|
-
*
|
|
64
|
+
* Programmatic click injection (Runtime Bridge CA-10) — resolved through
|
|
65
|
+
* the exact same picking a real click uses. In a 2D scene `x` and `y` are
|
|
66
|
+
* logical coordinates and the pick always exists. In a 3D scene they are
|
|
67
|
+
* canvas CSS pixels from its top-left corner, as a pointer event reports
|
|
68
|
+
* them, and a ray that meets neither a model nor the ground gives null.
|
|
58
69
|
*/
|
|
59
|
-
injectClick(x: number, y: number): PointerPick;
|
|
70
|
+
injectClick(x: number, y: number): PointerPick | null;
|
|
71
|
+
/** Makes a pick the pending click; no pick leaves any earlier click alone. */
|
|
72
|
+
private queue;
|
|
60
73
|
private handlePointerDown;
|
|
74
|
+
/** The pick under a viewport position: the 2D sprite boxes, or a 3D scene's raycast. */
|
|
75
|
+
private pickAt;
|
|
76
|
+
/**
|
|
77
|
+
* Where a canvas point (CSS px from its top-left corner) falls inside the
|
|
78
|
+
* letterboxed viewport, as fractions of its width and height; null on a
|
|
79
|
+
* letterbox bar (not a click on the world) or before the canvas has a size.
|
|
80
|
+
*/
|
|
81
|
+
private viewportFraction;
|
|
61
82
|
private letterboxRect;
|
|
62
83
|
private toRenderPoint;
|
|
63
84
|
private resolveRenderPoint;
|
package/dist/pointer.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isPerspectiveCamera } from './camera-projection.js';
|
|
2
|
+
import { raycastPick } from './pointer-raycast.js';
|
|
1
3
|
import { projectIsometric, unprojectIsometric } from './projection.js';
|
|
2
4
|
import { ySortZ } from './render-sort.js';
|
|
3
5
|
import { AnimatedSprite } from './components/animated-sprite.js';
|
|
@@ -30,9 +32,12 @@ export class Pointer {
|
|
|
30
32
|
canvas;
|
|
31
33
|
deps;
|
|
32
34
|
pending = null;
|
|
35
|
+
liveCamera;
|
|
33
36
|
constructor(canvas, deps) {
|
|
34
37
|
this.canvas = canvas;
|
|
35
38
|
this.deps = deps;
|
|
39
|
+
const { camera } = this.deps;
|
|
40
|
+
this.liveCamera = typeof camera === 'function' ? camera : () => camera;
|
|
36
41
|
this.canvas.addEventListener('pointerdown', this.handlePointerDown);
|
|
37
42
|
}
|
|
38
43
|
dispose() {
|
|
@@ -50,39 +55,60 @@ export class Pointer {
|
|
|
50
55
|
return pick;
|
|
51
56
|
}
|
|
52
57
|
/**
|
|
53
|
-
* Programmatic click injection (Runtime Bridge CA-10)
|
|
54
|
-
*
|
|
58
|
+
* Programmatic click injection (Runtime Bridge CA-10) — resolved through
|
|
59
|
+
* the exact same picking a real click uses. In a 2D scene `x` and `y` are
|
|
60
|
+
* logical coordinates and the pick always exists. In a 3D scene they are
|
|
61
|
+
* canvas CSS pixels from its top-left corner, as a pointer event reports
|
|
62
|
+
* them, and a ray that meets neither a model nor the ground gives null.
|
|
55
63
|
*/
|
|
56
64
|
injectClick(x, y) {
|
|
65
|
+
const camera = this.liveCamera();
|
|
66
|
+
if (isPerspectiveCamera(camera))
|
|
67
|
+
return this.queue(this.pickAt(this.viewportFraction(x, y)));
|
|
57
68
|
const renderPoint = this.toRenderPoint(x, y);
|
|
58
|
-
|
|
59
|
-
|
|
69
|
+
return this.queue(this.resolveRenderPoint(renderPoint.x, renderPoint.y));
|
|
70
|
+
}
|
|
71
|
+
/** Makes a pick the pending click; no pick leaves any earlier click alone. */
|
|
72
|
+
queue(pick) {
|
|
73
|
+
if (pick)
|
|
74
|
+
this.pending = pick;
|
|
60
75
|
return pick;
|
|
61
76
|
}
|
|
62
77
|
handlePointerDown = (event) => {
|
|
63
78
|
// Primary button only; a touch tap reports the same button (ADR-0010).
|
|
64
79
|
if (event.button !== 0)
|
|
65
80
|
return;
|
|
81
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
82
|
+
this.queue(this.pickAt(this.viewportFraction(event.clientX - rect.left, event.clientY - rect.top)));
|
|
83
|
+
};
|
|
84
|
+
/** The pick under a viewport position: the 2D sprite boxes, or a 3D scene's raycast. */
|
|
85
|
+
pickAt(at) {
|
|
86
|
+
const camera = this.liveCamera();
|
|
87
|
+
if (!at)
|
|
88
|
+
return null;
|
|
89
|
+
if (isPerspectiveCamera(camera))
|
|
90
|
+
return raycastPick(camera, at, this.deps.entities);
|
|
91
|
+
const renderX = camera.position.x + camera.left + at.nx * (camera.right - camera.left);
|
|
92
|
+
const renderY = camera.position.y + camera.top - at.ny * (camera.top - camera.bottom);
|
|
93
|
+
return this.resolveRenderPoint(renderX, renderY);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Where a canvas point (CSS px from its top-left corner) falls inside the
|
|
97
|
+
* letterboxed viewport, as fractions of its width and height; null on a
|
|
98
|
+
* letterbox bar (not a click on the world) or before the canvas has a size.
|
|
99
|
+
*/
|
|
100
|
+
viewportFraction(px, py) {
|
|
66
101
|
const w = this.canvas.clientWidth;
|
|
67
102
|
const h = this.canvas.clientHeight;
|
|
68
103
|
if (w <= 0 || h <= 0)
|
|
69
|
-
return;
|
|
104
|
+
return null;
|
|
70
105
|
const { vx, vy, vw, vh } = this.letterboxRect(w, h);
|
|
71
106
|
if (vw <= 0 || vh <= 0)
|
|
72
|
-
return;
|
|
73
|
-
const rect = this.canvas.getBoundingClientRect();
|
|
74
|
-
const px = event.clientX - rect.left;
|
|
75
|
-
const py = event.clientY - rect.top;
|
|
107
|
+
return null;
|
|
76
108
|
const nx = (px - vx) / vw;
|
|
77
109
|
const ny = (py - vy) / vh;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return;
|
|
81
|
-
const camera = this.deps.camera;
|
|
82
|
-
const renderX = camera.position.x + camera.left + nx * (camera.right - camera.left);
|
|
83
|
-
const renderY = camera.position.y + camera.top - ny * (camera.top - camera.bottom);
|
|
84
|
-
this.pending = this.resolveRenderPoint(renderX, renderY);
|
|
85
|
-
};
|
|
110
|
+
return nx < 0 || nx > 1 || ny < 0 || ny > 1 ? null : { nx, ny };
|
|
111
|
+
}
|
|
86
112
|
letterboxRect(w, h) {
|
|
87
113
|
const resolution = this.deps.resolution;
|
|
88
114
|
if (!resolution)
|
package/dist/runtime-bridge.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export interface RuntimeBridge {
|
|
|
85
85
|
}
|
|
86
86
|
/** Why a Game can never become operational; reported through the activation. */
|
|
87
87
|
export interface RuntimeBridgeFailure {
|
|
88
|
-
code: 'render-backend-failed';
|
|
88
|
+
code: 'render-backend-failed' | 'physics-backend-failed';
|
|
89
89
|
message: string;
|
|
90
90
|
}
|
|
91
91
|
/** Ephemeral pre-page hook installed by the owner of a browser context. */
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { AudioChannelState, LiveSoundInfo } from './audio/types.js';
|
|
2
2
|
import type { CameraEffectsState } from './camera-effects.js';
|
|
3
|
+
import type { SceneSpace } from './scene-space.js';
|
|
4
|
+
import { type RuntimeSnapshotView } from './runtime-view-snapshot.js';
|
|
3
5
|
import type { Game } from './game.js';
|
|
4
6
|
import type { RuntimeMetadata } from './runtime-bridge.js';
|
|
5
7
|
import type { StatValue } from './stats.js';
|
|
6
8
|
import { type RuntimeSnapshotLighting, type RuntimeSnapshotPost } from './runtime-lighting-snapshot.js';
|
|
9
|
+
import { type RuntimeSnapshotPhysics } from './runtime-physics-snapshot.js';
|
|
7
10
|
export type ProjectionMarkerKind = 'cycle' | 'unsupported' | 'error' | 'truncated';
|
|
8
11
|
export interface ProjectionMarker {
|
|
9
12
|
$waica: ProjectionMarkerKind | 'date' | 'bigint' | 'map' | 'set';
|
|
@@ -125,10 +128,16 @@ export interface RuntimeSnapshot extends RuntimeMetadata {
|
|
|
125
128
|
time: RuntimeSnapshotTime;
|
|
126
129
|
ui: RuntimeSnapshotUi;
|
|
127
130
|
camera: RuntimeSnapshotCamera;
|
|
131
|
+
/** The live scene's space (`render.space`, ADR 0027); `'2d'` with no scene. Never filtered. */
|
|
132
|
+
space: SceneSpace;
|
|
133
|
+
/** The view the scene is drawn from: the orthographic centre and zoom, or the perspective pose (issue #154 CA-16). Never filtered. */
|
|
134
|
+
view: RuntimeSnapshotView;
|
|
128
135
|
/** Ambient Light and live Lights (issue #78 CA-12), logical coordinates; never filtered. */
|
|
129
136
|
lighting: RuntimeSnapshotLighting;
|
|
130
137
|
/** Post Effects (issue #78 CA-12), each null when off; never filtered. */
|
|
131
138
|
post: RuntimeSnapshotPost;
|
|
139
|
+
/** The live 3D scene's physics world: state, gravity and bodies (issue #159 CA-17); absent in a 2D scene, never filtered. */
|
|
140
|
+
physics?: RuntimeSnapshotPhysics;
|
|
132
141
|
}
|
|
133
142
|
export declare const RUNTIME_PROJECTION_LIMITS: {
|
|
134
143
|
readonly depth: 5;
|