@waica/engine 0.25.0 → 0.27.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 +77 -1
- package/dist/anchored-pieces.d.ts +3 -2
- package/dist/anchored-pieces.js +12 -8
- package/dist/animation/clip-player.d.ts +7 -0
- package/dist/animation/clip-player.js +9 -0
- package/dist/animation/clip-target.d.ts +38 -0
- package/dist/animation/clip-target.js +9 -0
- package/dist/animation/pose-hash.d.ts +10 -0
- package/dist/animation/pose-hash.js +43 -0
- 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 +14 -3
- package/dist/components/animated-sprite.js +16 -2
- package/dist/components/animator.d.ts +98 -0
- package/dist/components/animator.js +197 -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 +71 -0
- package/dist/components/model.js +128 -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 +34 -4
- package/dist/index.js +15 -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/dist/state/state-machine.d.ts +32 -8
- package/dist/state/state-machine.js +88 -27
- package/package.json +2 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export const PERSPECTIVE_DEFAULTS = {
|
|
2
|
+
position: [0, 5, 10],
|
|
3
|
+
target: [0, 0, 0],
|
|
4
|
+
fov: 60,
|
|
5
|
+
near: 0.1,
|
|
6
|
+
far: 1000,
|
|
7
|
+
};
|
|
8
|
+
/** True for a perspective camera block; everything else (or none) is the orthographic one. */
|
|
9
|
+
export function isPerspectiveCameraJson(json) {
|
|
10
|
+
return json?.kind === 'perspective';
|
|
11
|
+
}
|
|
12
|
+
/** Fills a perspective camera block with the engine defaults. */
|
|
13
|
+
export function resolvePerspectiveCamera(json = { kind: 'perspective' }) {
|
|
14
|
+
return {
|
|
15
|
+
kind: 'perspective',
|
|
16
|
+
position: json.position ?? PERSPECTIVE_DEFAULTS.position,
|
|
17
|
+
target: json.target ?? PERSPECTIVE_DEFAULTS.target,
|
|
18
|
+
fov: json.fov ?? PERSPECTIVE_DEFAULTS.fov,
|
|
19
|
+
near: json.near ?? PERSPECTIVE_DEFAULTS.near,
|
|
20
|
+
far: json.far ?? PERSPECTIVE_DEFAULTS.far,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Points `camera` as the resolved block says, at the given aspect ratio. */
|
|
24
|
+
export function placePerspectiveCamera(camera, resolved, aspect) {
|
|
25
|
+
camera.fov = resolved.fov;
|
|
26
|
+
camera.near = resolved.near;
|
|
27
|
+
camera.far = resolved.far;
|
|
28
|
+
camera.aspect = aspect;
|
|
29
|
+
camera.position.set(...resolved.position);
|
|
30
|
+
camera.lookAt(...resolved.target);
|
|
31
|
+
camera.updateProjectionMatrix();
|
|
32
|
+
camera.updateMatrixWorld();
|
|
33
|
+
}
|
|
34
|
+
/** A JSON value as a message quotes it: strings in quotes, everything else as `String()` shows it. */
|
|
35
|
+
export const shown = (value) => (typeof value === 'string' ? JSON.stringify(value) : String(value));
|
|
36
|
+
export function isFiniteNumber(value) {
|
|
37
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
38
|
+
}
|
|
39
|
+
function vectorIssue(field, value, issues) {
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return;
|
|
42
|
+
if (Array.isArray(value) && value.length === 3 && value.every(isFiniteNumber))
|
|
43
|
+
return;
|
|
44
|
+
issues.push({ field, message: `${field} must be three finite numbers [x, y, z]; got ${JSON.stringify(value)}.` });
|
|
45
|
+
}
|
|
46
|
+
function fovIssue(value, issues) {
|
|
47
|
+
if (value === undefined)
|
|
48
|
+
return;
|
|
49
|
+
if (isFiniteNumber(value) && value > 0 && value < 180)
|
|
50
|
+
return;
|
|
51
|
+
issues.push({ field: 'camera.fov', message: `camera.fov must be a number above 0 and below 180; got ${shown(value)}.` });
|
|
52
|
+
}
|
|
53
|
+
/** `position` and `target` as the camera would use them, when both are well-formed (declared or default). */
|
|
54
|
+
function resolvedAim(block) {
|
|
55
|
+
const position = block.position ?? PERSPECTIVE_DEFAULTS.position;
|
|
56
|
+
const target = block.target ?? PERSPECTIVE_DEFAULTS.target;
|
|
57
|
+
const wellFormed = (value) => Array.isArray(value) && value.length === 3 && value.every(isFiniteNumber);
|
|
58
|
+
return wellFormed(position) && wellFormed(target) ? { position, target } : null;
|
|
59
|
+
}
|
|
60
|
+
function aimIssue(block, issues) {
|
|
61
|
+
const aim = resolvedAim(block);
|
|
62
|
+
if (!aim || aim.position.some((value, axis) => value !== aim.target[axis]))
|
|
63
|
+
return;
|
|
64
|
+
issues.push({
|
|
65
|
+
field: 'camera.target',
|
|
66
|
+
message: `camera.target must differ from camera.position (${JSON.stringify(aim.position)}): a camera needs a direction to look in.`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function planeIssues(near, far, issues) {
|
|
70
|
+
const nearIsValid = isFiniteNumber(near) && near > 0;
|
|
71
|
+
if (near !== undefined && !nearIsValid) {
|
|
72
|
+
issues.push({ field: 'camera.near', message: `camera.near must be a number above 0; got ${shown(near)}.` });
|
|
73
|
+
}
|
|
74
|
+
const nearValue = nearIsValid ? near : PERSPECTIVE_DEFAULTS.near;
|
|
75
|
+
if (far !== undefined && !(isFiniteNumber(far) && far > nearValue)) {
|
|
76
|
+
issues.push({ field: 'camera.far', message: `camera.far must be a number above near (${nearValue}); got ${shown(far)}.` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The orthographic block's fields, which a perspective block does not accept. */
|
|
80
|
+
const ORTHOGRAPHIC_ONLY_FIELDS = [
|
|
81
|
+
'follow',
|
|
82
|
+
'zoom',
|
|
83
|
+
'deadzoneWidth',
|
|
84
|
+
'deadzoneHeight',
|
|
85
|
+
'lookahead',
|
|
86
|
+
'lookaheadY',
|
|
87
|
+
'smoothing',
|
|
88
|
+
'limits',
|
|
89
|
+
];
|
|
90
|
+
function orthographicFieldIssues(block, issues) {
|
|
91
|
+
for (const field of ORTHOGRAPHIC_ONLY_FIELDS) {
|
|
92
|
+
if (block[field] === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
issues.push({ field: `camera.${field}`, message: `camera.${field} belongs to an orthographic camera; a perspective camera does not accept it.` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Every field of a perspective camera block outside its range: `fov` in
|
|
99
|
+
* (0, 180), `near` above 0, `far` above `near`, and `position` and `target`
|
|
100
|
+
* three finite numbers, and none of the orthographic block's fields. Anything
|
|
101
|
+
* that is not a perspective block reports none.
|
|
102
|
+
*/
|
|
103
|
+
export function perspectiveCameraIssues(camera) {
|
|
104
|
+
if (typeof camera !== 'object' || camera === null)
|
|
105
|
+
return [];
|
|
106
|
+
const block = { ...camera };
|
|
107
|
+
if (block.kind !== 'perspective')
|
|
108
|
+
return [];
|
|
109
|
+
const issues = [];
|
|
110
|
+
vectorIssue('camera.position', block.position, issues);
|
|
111
|
+
vectorIssue('camera.target', block.target, issues);
|
|
112
|
+
fovIssue(block.fov, issues);
|
|
113
|
+
aimIssue(block, issues);
|
|
114
|
+
planeIssues(block.near, block.far, issues);
|
|
115
|
+
orthographicFieldIssues(block, issues);
|
|
116
|
+
return issues;
|
|
117
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { SceneSpace } from './scene-space.js';
|
|
3
|
+
import type { AmbientLight } from './scene-render-options.js';
|
|
4
|
+
/**
|
|
5
|
+
* The Ambient Light of a 3D scene (ADR 0027): one `THREE.AmbientLight` the
|
|
6
|
+
* Game owns, fed by `game.lighting.ambient` (`render.lighting.ambient` and the
|
|
7
|
+
* runtime setter), color × intensity, full white when nothing says otherwise.
|
|
8
|
+
* It exists only while the live scene is 3D; a 2D scene lights through the
|
|
9
|
+
* light-map instead (ADR 0026).
|
|
10
|
+
*/
|
|
11
|
+
export declare class SceneAmbientLight3d {
|
|
12
|
+
private readonly scene;
|
|
13
|
+
private light;
|
|
14
|
+
constructor(scene: THREE.Scene);
|
|
15
|
+
/** Adds, updates or removes the light for the live scene's space; called once per frame before it is drawn. */
|
|
16
|
+
sync(space: SceneSpace, ambient: AmbientLight): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
/**
|
|
3
|
+
* The Ambient Light of a 3D scene (ADR 0027): one `THREE.AmbientLight` the
|
|
4
|
+
* Game owns, fed by `game.lighting.ambient` (`render.lighting.ambient` and the
|
|
5
|
+
* runtime setter), color × intensity, full white when nothing says otherwise.
|
|
6
|
+
* It exists only while the live scene is 3D; a 2D scene lights through the
|
|
7
|
+
* light-map instead (ADR 0026).
|
|
8
|
+
*/
|
|
9
|
+
export class SceneAmbientLight3d {
|
|
10
|
+
scene;
|
|
11
|
+
light = null;
|
|
12
|
+
constructor(scene) {
|
|
13
|
+
this.scene = scene;
|
|
14
|
+
}
|
|
15
|
+
/** Adds, updates or removes the light for the live scene's space; called once per frame before it is drawn. */
|
|
16
|
+
sync(space, ambient) {
|
|
17
|
+
if (space !== '3d') {
|
|
18
|
+
this.light?.removeFromParent();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
this.light ??= new THREE.AmbientLight();
|
|
22
|
+
if (this.light.parent !== this.scene)
|
|
23
|
+
this.scene.add(this.light);
|
|
24
|
+
this.light.color.set(ambient.color);
|
|
25
|
+
this.light.intensity = ambient.intensity;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type SceneFieldIssue, type Vec3Json } from './scene-camera-3d.js';
|
|
2
|
+
import type { SceneSpace } from './scene-space.js';
|
|
3
|
+
/**
|
|
4
|
+
* A 3D scene's `simulation` block (issue #159): the physics world's settings.
|
|
5
|
+
* Absent, or with `gravity` absent, the world pulls along -y at 9.81.
|
|
6
|
+
*/
|
|
7
|
+
export interface SceneSimulationJson {
|
|
8
|
+
/** The world's gravity vector in units per second squared. Default `[0, -9.81, 0]`. */
|
|
9
|
+
gravity?: Vec3Json;
|
|
10
|
+
}
|
|
11
|
+
export interface ResolvedSceneSimulation {
|
|
12
|
+
gravity: Vec3Json;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_GRAVITY: Readonly<Vec3Json>;
|
|
15
|
+
/** The simulation a scene runs with: its `simulation` block, defaults filling the rest. A malformed gravity reads as the default (`validate_project` reports it). */
|
|
16
|
+
export declare function resolveSceneSimulation(json: SceneSimulationJson | undefined): ResolvedSceneSimulation;
|
|
17
|
+
/**
|
|
18
|
+
* Every invalid field of a scene's `simulation` block: a gravity that is not
|
|
19
|
+
* three finite numbers in a 3d scene, and any block at all in a 2d one, where
|
|
20
|
+
* nothing simulates.
|
|
21
|
+
*/
|
|
22
|
+
export declare function sceneSimulationIssues(simulation: unknown, space: SceneSpace): SceneFieldIssue[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isFiniteNumber } from './scene-camera-3d.js';
|
|
2
|
+
export const DEFAULT_GRAVITY = [0, -9.81, 0];
|
|
3
|
+
function isVec3(value) {
|
|
4
|
+
return Array.isArray(value) && value.length === 3 && value.every(isFiniteNumber);
|
|
5
|
+
}
|
|
6
|
+
/** The simulation a scene runs with: its `simulation` block, defaults filling the rest. A malformed gravity reads as the default (`validate_project` reports it). */
|
|
7
|
+
export function resolveSceneSimulation(json) {
|
|
8
|
+
const gravity = json?.gravity;
|
|
9
|
+
return { gravity: isVec3(gravity) ? [...gravity] : [...DEFAULT_GRAVITY] };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Every invalid field of a scene's `simulation` block: a gravity that is not
|
|
13
|
+
* three finite numbers in a 3d scene, and any block at all in a 2d one, where
|
|
14
|
+
* nothing simulates.
|
|
15
|
+
*/
|
|
16
|
+
export function sceneSimulationIssues(simulation, space) {
|
|
17
|
+
if (simulation === undefined)
|
|
18
|
+
return [];
|
|
19
|
+
if (space === '2d') {
|
|
20
|
+
return [{ field: 'simulation', message: `A simulation block needs render.space '3d'; this scene is 2d, where nothing simulates.` }];
|
|
21
|
+
}
|
|
22
|
+
if (typeof simulation !== 'object' || simulation === null || Array.isArray(simulation)) {
|
|
23
|
+
return [{ field: 'simulation', message: `simulation must be an object; got ${JSON.stringify(simulation)}.` }];
|
|
24
|
+
}
|
|
25
|
+
const gravity = Reflect.get(simulation, 'gravity');
|
|
26
|
+
if (gravity === undefined || isVec3(gravity))
|
|
27
|
+
return [];
|
|
28
|
+
return [{ field: 'simulation.gravity', message: `simulation.gravity must be three finite numbers [x, y, z]; got ${JSON.stringify(gravity)}.` }];
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ComponentClass, ComponentSpace } from './component.js';
|
|
2
|
+
import { type SceneFieldIssue } from './scene-camera-3d.js';
|
|
3
|
+
/**
|
|
4
|
+
* A scene's space (ADR 0027): `'2d'` is the engine's orthographic world,
|
|
5
|
+
* where `z` is draw order; `'3d'` makes `z` a world axis under a perspective
|
|
6
|
+
* camera and a real depth buffer. A scene declares it as `render.space`.
|
|
7
|
+
*/
|
|
8
|
+
export type SceneSpace = '2d' | '3d';
|
|
9
|
+
export declare const SCENE_SPACES: readonly SceneSpace[];
|
|
10
|
+
/** The scene's space: 3d only when `render.space` says exactly so. */
|
|
11
|
+
export declare function resolveSceneSpace(render: unknown): SceneSpace;
|
|
12
|
+
/**
|
|
13
|
+
* What a scene's render block makes of the draw-order options at runtime. A
|
|
14
|
+
* 3D scene has no y-sort and no isometric projection whatever the block
|
|
15
|
+
* says (`validate_project` reports them); a 2D scene reads them as always.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveRenderPolicy(render: unknown): {
|
|
18
|
+
space: SceneSpace;
|
|
19
|
+
sort: 'y' | null;
|
|
20
|
+
projection: 'isometric' | null;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* What a component class says about the space it belongs to: its `static
|
|
24
|
+
* space` marker, or nothing. Only that slice of `ComponentClass`, so the
|
|
25
|
+
* abstract `Component` and a class the caller builds by hand qualify too.
|
|
26
|
+
*/
|
|
27
|
+
export type SpaceMarked = Pick<ComponentClass, 'space'>;
|
|
28
|
+
/** The space a component class belongs to; a class that declares none is `'both'`. */
|
|
29
|
+
export declare function componentSpaceOf(Class: SpaceMarked): ComponentSpace;
|
|
30
|
+
/**
|
|
31
|
+
* True when a component of this class does not belong in a scene of this
|
|
32
|
+
* space: a `'2d'` component in 3d, a `'3d'` one in 2d. Reads the class's
|
|
33
|
+
* `static space` marker, so engine, behavior and project-owned components
|
|
34
|
+
* answer the same way. A class the caller does not know (undefined) is
|
|
35
|
+
* space-neutral.
|
|
36
|
+
*/
|
|
37
|
+
export declare function componentSpaceMismatch(space: SceneSpace, Class: SpaceMarked | undefined): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Every invalid field of a scene's `render.space`, its camera block and the
|
|
40
|
+
* render options a 3d scene cannot use: a space outside `'2d' | '3d'`, a
|
|
41
|
+
* camera that does not match the space, a perspective camera out of range
|
|
42
|
+
* and `render.sort`, `render.projection` or `render.batch` under 3d. Other
|
|
43
|
+
* render fields are `sceneRenderIssues`'s.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sceneSpaceIssues(render: unknown, camera: unknown): SceneFieldIssue[];
|
|
46
|
+
/**
|
|
47
|
+
* An entity's `position` (2 or 3 finite numbers), `rotation` (degrees) and
|
|
48
|
+
* `scale` (3 finite numbers each) that are anything else.
|
|
49
|
+
*/
|
|
50
|
+
export declare function entityTransformIssues(entity: unknown): SceneFieldIssue[];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { isFiniteNumber, perspectiveCameraIssues, shown } from './scene-camera-3d.js';
|
|
2
|
+
export const SCENE_SPACES = ['2d', '3d'];
|
|
3
|
+
/** The scene's space: 3d only when `render.space` says exactly so. */
|
|
4
|
+
export function resolveSceneSpace(render) {
|
|
5
|
+
return recordOf(render).space === '3d' ? '3d' : '2d';
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* What a scene's render block makes of the draw-order options at runtime. A
|
|
9
|
+
* 3D scene has no y-sort and no isometric projection whatever the block
|
|
10
|
+
* says (`validate_project` reports them); a 2D scene reads them as always.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveRenderPolicy(render) {
|
|
13
|
+
const block = recordOf(render);
|
|
14
|
+
const space = resolveSceneSpace(block);
|
|
15
|
+
const flat = space === '2d';
|
|
16
|
+
return {
|
|
17
|
+
space,
|
|
18
|
+
sort: flat && block.sort === 'y' ? 'y' : null,
|
|
19
|
+
projection: flat && block.projection === 'isometric' ? 'isometric' : null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** The space a component class belongs to; a class that declares none is `'both'`. */
|
|
23
|
+
export function componentSpaceOf(Class) {
|
|
24
|
+
return Class.space ?? 'both';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* True when a component of this class does not belong in a scene of this
|
|
28
|
+
* space: a `'2d'` component in 3d, a `'3d'` one in 2d. Reads the class's
|
|
29
|
+
* `static space` marker, so engine, behavior and project-owned components
|
|
30
|
+
* answer the same way. A class the caller does not know (undefined) is
|
|
31
|
+
* space-neutral.
|
|
32
|
+
*/
|
|
33
|
+
export function componentSpaceMismatch(space, Class) {
|
|
34
|
+
if (Class === undefined)
|
|
35
|
+
return false;
|
|
36
|
+
const own = componentSpaceOf(Class);
|
|
37
|
+
return own !== 'both' && own !== space;
|
|
38
|
+
}
|
|
39
|
+
function recordOf(value) {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? { ...value } : {};
|
|
41
|
+
}
|
|
42
|
+
/** Fields of `render` that only mean something in a 2D scene. */
|
|
43
|
+
const TWO_D_RENDER_FIELDS = ['sort', 'projection', 'batch'];
|
|
44
|
+
function cameraKindIssue(space, camera, issues) {
|
|
45
|
+
if (camera === undefined || camera === null)
|
|
46
|
+
return;
|
|
47
|
+
const kind = recordOf(camera).kind;
|
|
48
|
+
if (kind !== undefined && kind !== 'orthographic' && kind !== 'perspective') {
|
|
49
|
+
issues.push({ field: 'camera.kind', message: `camera.kind must be 'orthographic' or 'perspective'; got ${shown(kind)}.` });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (space === '2d' && kind === 'perspective') {
|
|
53
|
+
issues.push({ field: 'camera.kind', message: `A perspective camera needs render.space '3d'; this scene is 2d.` });
|
|
54
|
+
}
|
|
55
|
+
if (space === '3d' && kind !== 'perspective') {
|
|
56
|
+
issues.push({ field: 'camera.kind', message: `A 3d scene needs a perspective camera (kind: 'perspective'); got an orthographic one.` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Every invalid field of a scene's `render.space`, its camera block and the
|
|
61
|
+
* render options a 3d scene cannot use: a space outside `'2d' | '3d'`, a
|
|
62
|
+
* camera that does not match the space, a perspective camera out of range
|
|
63
|
+
* and `render.sort`, `render.projection` or `render.batch` under 3d. Other
|
|
64
|
+
* render fields are `sceneRenderIssues`'s.
|
|
65
|
+
*/
|
|
66
|
+
export function sceneSpaceIssues(render, camera) {
|
|
67
|
+
const block = recordOf(render);
|
|
68
|
+
const issues = [];
|
|
69
|
+
if (block.space !== undefined && block.space !== '2d' && block.space !== '3d') {
|
|
70
|
+
issues.push({ field: 'render.space', message: `render.space must be '2d' or '3d'; got ${shown(block.space)}.` });
|
|
71
|
+
}
|
|
72
|
+
const space = resolveSceneSpace(block);
|
|
73
|
+
cameraKindIssue(space, camera, issues);
|
|
74
|
+
issues.push(...perspectiveCameraIssues(camera));
|
|
75
|
+
if (space === '3d') {
|
|
76
|
+
for (const field of TWO_D_RENDER_FIELDS) {
|
|
77
|
+
if (block[field] === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
issues.push({ field: `render.${field}`, message: `render.${field} is a 2D option and has no meaning in a 3d scene.` });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return issues;
|
|
83
|
+
}
|
|
84
|
+
function tupleIssue(issues, { field, lengths }, value) {
|
|
85
|
+
if (value === undefined)
|
|
86
|
+
return;
|
|
87
|
+
if (Array.isArray(value) && lengths.includes(value.length) && value.every(isFiniteNumber))
|
|
88
|
+
return;
|
|
89
|
+
const need = lengths.join(' or ');
|
|
90
|
+
issues.push({ field, message: `${field} must be ${need} finite numbers; got ${JSON.stringify(value)}.` });
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* An entity's `position` (2 or 3 finite numbers), `rotation` (degrees) and
|
|
94
|
+
* `scale` (3 finite numbers each) that are anything else.
|
|
95
|
+
*/
|
|
96
|
+
export function entityTransformIssues(entity) {
|
|
97
|
+
const json = recordOf(entity);
|
|
98
|
+
const issues = [];
|
|
99
|
+
tupleIssue(issues, { field: 'position', lengths: [2, 3] }, json.position);
|
|
100
|
+
tupleIssue(issues, { field: 'rotation', lengths: [3] }, json.rotation);
|
|
101
|
+
tupleIssue(issues, { field: 'scale', lengths: [3] }, json.scale);
|
|
102
|
+
return issues;
|
|
103
|
+
}
|
package/dist/scene.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { SceneCameraJson } from './camera.js';
|
|
2
2
|
import type { ComponentClass } from './component.js';
|
|
3
3
|
import type { Entity } from './entity.js';
|
|
4
|
+
import { type TransformJson } from './entity-transform.js';
|
|
4
5
|
import type { Game } from './game.js';
|
|
6
|
+
import type { SceneSimulationJson } from './scene-simulation.js';
|
|
7
|
+
import type { SceneSpace } from './scene-space.js';
|
|
5
8
|
/**
|
|
6
9
|
* Waica's scene format: declarative, git-friendly data, editable by the
|
|
7
10
|
* visual editor. The scene is the source of truth; the Game is its live
|
|
@@ -11,9 +14,8 @@ export interface SceneComponentJson {
|
|
|
11
14
|
type: string;
|
|
12
15
|
props?: Record<string, unknown>;
|
|
13
16
|
}
|
|
14
|
-
export interface SceneEntityJson {
|
|
17
|
+
export interface SceneEntityJson extends TransformJson {
|
|
15
18
|
name: string;
|
|
16
|
-
position?: [number, number];
|
|
17
19
|
/** Prefab ref like "characters/slime", resolved against SceneRegistry.prefabs. */
|
|
18
20
|
prefab?: string;
|
|
19
21
|
/** Per-component prop overrides on top of the prefab: componentType -> propName -> value. */
|
|
@@ -55,6 +57,12 @@ export interface ScenePostJson {
|
|
|
55
57
|
}
|
|
56
58
|
/** Scene-wide render options (v3). */
|
|
57
59
|
export interface SceneRenderJson {
|
|
60
|
+
/**
|
|
61
|
+
* `'2d'` (the default) or `'3d'` (ADR 0027). A 3D scene makes `z` a world
|
|
62
|
+
* axis under a perspective camera with a depth buffer; there is no layer
|
|
63
|
+
* band, y-sort, isometric projection, sprite batch or light-map in it.
|
|
64
|
+
*/
|
|
65
|
+
space?: SceneSpace;
|
|
58
66
|
/** Ambient Light; with it (or any Light) the scene is lit (ADR 0026). */
|
|
59
67
|
lighting?: SceneLightingJson;
|
|
60
68
|
/** Vignette and color grade; with either on, the frame renders through a render target. */
|
|
@@ -83,6 +91,8 @@ export interface SceneJson {
|
|
|
83
91
|
camera?: SceneCameraJson;
|
|
84
92
|
/** Draw-order policy (v3); absent = layer bands with spawn-order ties. */
|
|
85
93
|
render?: SceneRenderJson;
|
|
94
|
+
/** The physics world's settings (v3, 3D scenes only): gravity. Absent = earth gravity. */
|
|
95
|
+
simulation?: SceneSimulationJson;
|
|
86
96
|
entities: SceneEntityJson[];
|
|
87
97
|
/** UI pieces (src/ui/*.html) mounted visible when the scene loads. */
|
|
88
98
|
ui?: string[];
|
package/dist/scene.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyTransformJson } from './entity-transform.js';
|
|
1
2
|
/**
|
|
2
3
|
* Own-property lookup. Scene JSON is data — a component or prefab named
|
|
3
4
|
* "constructor" or "toString" must read as missing, not as whatever sits
|
|
@@ -69,6 +70,7 @@ const COMPONENT_MEMBERS = {
|
|
|
69
70
|
onProjectionChange: true,
|
|
70
71
|
onCollide: true,
|
|
71
72
|
onContact: true,
|
|
73
|
+
onBodyContact: true,
|
|
72
74
|
onInteract: true,
|
|
73
75
|
onDestroy: true,
|
|
74
76
|
};
|
|
@@ -91,8 +93,7 @@ function sceneProps(props, type, entityName) {
|
|
|
91
93
|
}
|
|
92
94
|
export function spawnFromJson(game, json, registry) {
|
|
93
95
|
const entity = game.spawn(json.name);
|
|
94
|
-
|
|
95
|
-
entity.position.set(json.position[0], json.position[1], 0);
|
|
96
|
+
applyTransformJson(entity, json);
|
|
96
97
|
for (const comp of resolveEntityComponents(json, registry.prefabs)) {
|
|
97
98
|
const Class = registryEntry(registry.components, comp.type);
|
|
98
99
|
if (!Class) {
|
|
@@ -131,10 +132,16 @@ export function loadScene(game, scene, registry) {
|
|
|
131
132
|
}
|
|
132
133
|
game.registry = registry;
|
|
133
134
|
game.setSceneRender(scene.render);
|
|
135
|
+
game.setSceneSimulation(scene.simulation);
|
|
136
|
+
// A perspective camera has no follow target to wait for: components see it from their first onReady.
|
|
137
|
+
const adoptsBeforeSpawns = game.space === '3d';
|
|
138
|
+
if (adoptsBeforeSpawns)
|
|
139
|
+
game.setSceneCamera(scene.camera);
|
|
134
140
|
for (const entityJson of scene.entities)
|
|
135
141
|
spawnFromJson(game, entityJson, registry);
|
|
136
|
-
// After the spawns: with a follow target, the camera starts centered on it.
|
|
137
|
-
|
|
142
|
+
// After the spawns: with a follow target, the orthographic camera starts centered on it.
|
|
143
|
+
if (!adoptsBeforeSpawns)
|
|
144
|
+
game.setSceneCamera(scene.camera);
|
|
138
145
|
if (registry.ui)
|
|
139
146
|
game.ui.defineAll(registry.ui);
|
|
140
147
|
// Scene-scoped: Game.unloadScene() unmounts these along with the entities.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Game } from './game.js';
|
|
2
|
+
/**
|
|
3
|
+
* What a Simulation Step does after every component's `onUpdate` (issue #159
|
|
4
|
+
* CA-10), in this order: the 3D physics step (one `world.step()` of the live
|
|
5
|
+
* scene's Rapier world, a no-op in a 2D scene or while the module loads), the
|
|
6
|
+
* solid contacts it produced, the 2D Hitbox triggers, and the 3D sensor
|
|
7
|
+
* triggers. Kept out of `game.ts`, which is at the file-size tier.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runSimulationPhases(game: Game): void;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { dispatchCollisions as dispatchHitboxCollisions } from './collision-dispatch.js';
|
|
2
|
+
import { dispatchBodyContacts } from './physics-3d/body-contacts.js';
|
|
3
|
+
import { dispatchSensorCollisions } from './physics-3d/sensor-dispatch.js';
|
|
4
|
+
/**
|
|
5
|
+
* What a Simulation Step does after every component's `onUpdate` (issue #159
|
|
6
|
+
* CA-10), in this order: the 3D physics step (one `world.step()` of the live
|
|
7
|
+
* scene's Rapier world, a no-op in a 2D scene or while the module loads), the
|
|
8
|
+
* solid contacts it produced, the 2D Hitbox triggers, and the 3D sensor
|
|
9
|
+
* triggers. Kept out of `game.ts`, which is at the file-size tier.
|
|
10
|
+
*/
|
|
11
|
+
export function runSimulationPhases(game) {
|
|
12
|
+
const world = game.physics.world;
|
|
13
|
+
world?.step();
|
|
14
|
+
if (world)
|
|
15
|
+
dispatchBodyContacts(game, world);
|
|
16
|
+
dispatchHitboxCollisions(game);
|
|
17
|
+
if (world && game.physics.world === world)
|
|
18
|
+
dispatchSensorCollisions(game, world);
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Collider } from './components/collider.js';
|
|
2
|
+
import type { Entity } from './entity.js';
|
|
3
|
+
import type { Game } from './game.js';
|
|
4
|
+
import { type Point3d, type QueryVolume3d } from './physics-3d/world-queries.js';
|
|
5
|
+
import type { ComponentClasses, NearestSpatialQueryFilter, SpatialQueryFilter } from './spatial-query.js';
|
|
6
|
+
export type { Point3d, QueryVolume3d } from './physics-3d/world-queries.js';
|
|
7
|
+
/** The first solid collider a 3D ray reached, with its entity and the crossing (issue #159 CA-21). */
|
|
8
|
+
export interface RayHit3d<T extends Entity = Entity> {
|
|
9
|
+
readonly entity: T;
|
|
10
|
+
readonly collider: Collider;
|
|
11
|
+
readonly distance: number;
|
|
12
|
+
readonly point: Readonly<Point3d>;
|
|
13
|
+
readonly normal: Readonly<Point3d>;
|
|
14
|
+
}
|
|
15
|
+
type Filter = SpatialQueryFilter<ComponentClasses> | undefined;
|
|
16
|
+
type NearestFilter = NearestSpatialQueryFilter<ComponentClasses> | undefined;
|
|
17
|
+
/** The structural part of the entity filter the 2D forms use (`with`, `without`, `exclude`), shared with the 3D ones (`spatial-query.ts` owns it). */
|
|
18
|
+
export interface EntityRules {
|
|
19
|
+
matchesCommon(entity: Entity, filter: NearestFilter): boolean;
|
|
20
|
+
}
|
|
21
|
+
/** The 3D forms of `game.query` (ADR 0015's domains kept): `ray` reads solids, `area` and `point` read sensors, `nearest` reads transforms. */
|
|
22
|
+
export declare class SpatialQuery3d {
|
|
23
|
+
private readonly game;
|
|
24
|
+
private readonly rules;
|
|
25
|
+
constructor(game: Game, rules: EntityRules);
|
|
26
|
+
/** `direction` need not be a unit vector; `maxDistance` is in world units. */
|
|
27
|
+
ray(aim: {
|
|
28
|
+
origin: Point3d;
|
|
29
|
+
direction: Point3d;
|
|
30
|
+
}, maxDistance: number, filter?: Filter): RayHit3d | null;
|
|
31
|
+
area(volume: QueryVolume3d, filter?: Filter): Entity[];
|
|
32
|
+
point(at: Point3d, filter?: Filter): Entity[];
|
|
33
|
+
nearest(at: Point3d, filter?: NearestFilter): Entity | null;
|
|
34
|
+
/** How far the entity is from the search, when it is alive, passes the filter and lies within range. */
|
|
35
|
+
private distanceIfEligible;
|
|
36
|
+
/** The live world with its query structure current, or null while there is none. */
|
|
37
|
+
private worldNow;
|
|
38
|
+
/**
|
|
39
|
+
* A live entity's collider the query domain reads (solids for a ray, sensors
|
|
40
|
+
* for area and point) that passes the structural filter. Runs inside
|
|
41
|
+
* Rapier's callback, so it reads the record and nothing else.
|
|
42
|
+
*/
|
|
43
|
+
private eligible;
|
|
44
|
+
/** The user's `where`, run after Rapier has answered: it may throw, spawn or destroy without the world mid-query. */
|
|
45
|
+
private wanted;
|
|
46
|
+
/** The entities of the records that pass `where`, once each, in the order they were spawned. */
|
|
47
|
+
private inEntityOrder;
|
|
48
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { recordsAtPoint, recordsInVolume, worldRayHits } from './physics-3d/world-queries.js';
|
|
2
|
+
const isPoint3d = (value) => typeof value === 'object' && value !== null && ['x', 'y', 'z'].every((axis) => Number.isFinite(Reflect.get(value, axis)));
|
|
3
|
+
const isExtent = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
4
|
+
/** A finite box or sphere with a place: anything else is a volume Rapier cannot test. */
|
|
5
|
+
function isVolume3d(value) {
|
|
6
|
+
if (typeof value !== 'object' || value === null || !isPoint3d(Reflect.get(value, 'center')))
|
|
7
|
+
return false;
|
|
8
|
+
const shape = Reflect.get(value, 'shape');
|
|
9
|
+
if (shape === 'sphere')
|
|
10
|
+
return isExtent(Reflect.get(value, 'radius'));
|
|
11
|
+
const size = Reflect.get(value, 'size');
|
|
12
|
+
return shape === 'box' && typeof size === 'object' && size !== null && ['x', 'y', 'z'].every((axis) => isExtent(Reflect.get(size, axis)));
|
|
13
|
+
}
|
|
14
|
+
/** The farthest a nearest query looks: its `maxDistance`, or no limit. */
|
|
15
|
+
const rangeOf = (filter) => filter?.maxDistance ?? Infinity;
|
|
16
|
+
/** The 3D forms of `game.query` (ADR 0015's domains kept): `ray` reads solids, `area` and `point` read sensors, `nearest` reads transforms. */
|
|
17
|
+
export class SpatialQuery3d {
|
|
18
|
+
game;
|
|
19
|
+
rules;
|
|
20
|
+
constructor(game, rules) {
|
|
21
|
+
this.game = game;
|
|
22
|
+
this.rules = rules;
|
|
23
|
+
}
|
|
24
|
+
/** `direction` need not be a unit vector; `maxDistance` is in world units. */
|
|
25
|
+
ray(aim, maxDistance, filter) {
|
|
26
|
+
const { origin, direction } = aim;
|
|
27
|
+
const world = this.worldNow();
|
|
28
|
+
const length = isPoint3d(direction) ? Math.hypot(direction.x, direction.y, direction.z) : 0;
|
|
29
|
+
if (!world || !isPoint3d(origin) || length === 0 || !(maxDistance >= 0) || !Number.isFinite(maxDistance))
|
|
30
|
+
return null;
|
|
31
|
+
const unit = { x: direction.x / length, y: direction.y / length, z: direction.z / length };
|
|
32
|
+
const hits = worldRayHits(world, { origin, direction: unit, maxDistance }, (record) => this.eligible(record, filter, false));
|
|
33
|
+
// The user's `where` runs here, outside Rapier's borrow of the world: the first hit it keeps wins.
|
|
34
|
+
const hit = hits.find(({ record }) => this.wanted(record, filter));
|
|
35
|
+
if (!hit)
|
|
36
|
+
return null;
|
|
37
|
+
return { entity: hit.record.entity, collider: hit.record.collider, distance: hit.distance, point: hit.point, normal: hit.normal };
|
|
38
|
+
}
|
|
39
|
+
area(volume, filter) {
|
|
40
|
+
const world = this.worldNow();
|
|
41
|
+
if (!world || !isVolume3d(volume))
|
|
42
|
+
return [];
|
|
43
|
+
return this.inEntityOrder(recordsInVolume(world, volume, (record) => this.eligible(record, filter, true)), filter);
|
|
44
|
+
}
|
|
45
|
+
point(at, filter) {
|
|
46
|
+
const world = this.worldNow();
|
|
47
|
+
if (!world || !isPoint3d(at))
|
|
48
|
+
return [];
|
|
49
|
+
return this.inEntityOrder(recordsAtPoint(world, at, (record) => this.eligible(record, filter, true)), filter);
|
|
50
|
+
}
|
|
51
|
+
nearest(at, filter) {
|
|
52
|
+
const maxDistance = rangeOf(filter);
|
|
53
|
+
if (!isPoint3d(at) || !(maxDistance >= 0))
|
|
54
|
+
return null;
|
|
55
|
+
let best = null;
|
|
56
|
+
for (const entity of this.game.entities) {
|
|
57
|
+
const distance = this.distanceIfEligible(entity, { at, filter, maxDistance });
|
|
58
|
+
if (distance !== null && (best === null || distance < best.distance))
|
|
59
|
+
best = { entity, distance };
|
|
60
|
+
}
|
|
61
|
+
return best?.entity ?? null;
|
|
62
|
+
}
|
|
63
|
+
/** How far the entity is from the search, when it is alive, passes the filter and lies within range. */
|
|
64
|
+
distanceIfEligible(entity, { at, filter, maxDistance }) {
|
|
65
|
+
if (!entity.alive || !this.rules.matchesCommon(entity, filter))
|
|
66
|
+
return null;
|
|
67
|
+
const { x, y, z } = entity.position;
|
|
68
|
+
const distance = Math.hypot(x - at.x, y - at.y, z - at.z);
|
|
69
|
+
if (Number.isNaN(distance) || distance > maxDistance)
|
|
70
|
+
return null;
|
|
71
|
+
return filter?.where && !filter.where(entity, { distance }) ? null : distance;
|
|
72
|
+
}
|
|
73
|
+
/** The live world with its query structure current, or null while there is none. */
|
|
74
|
+
worldNow() {
|
|
75
|
+
const world = this.game.physics.world;
|
|
76
|
+
world?.refreshQueries();
|
|
77
|
+
return world;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A live entity's collider the query domain reads (solids for a ray, sensors
|
|
81
|
+
* for area and point) that passes the structural filter. Runs inside
|
|
82
|
+
* Rapier's callback, so it reads the record and nothing else.
|
|
83
|
+
*/
|
|
84
|
+
eligible(record, filter, sensors) {
|
|
85
|
+
return record.sensor === sensors && record.entity.alive && this.rules.matchesCommon(record.entity, filter);
|
|
86
|
+
}
|
|
87
|
+
/** The user's `where`, run after Rapier has answered: it may throw, spawn or destroy without the world mid-query. */
|
|
88
|
+
wanted(record, filter) {
|
|
89
|
+
return record.entity.alive && (!filter?.where || filter.where(record.entity));
|
|
90
|
+
}
|
|
91
|
+
/** The entities of the records that pass `where`, once each, in the order they were spawned. */
|
|
92
|
+
inEntityOrder(records, filter) {
|
|
93
|
+
const found = new Set(records.filter((record) => this.wanted(record, filter)).map((record) => record.entity));
|
|
94
|
+
return this.game.entities.filter((entity) => found.has(entity));
|
|
95
|
+
}
|
|
96
|
+
}
|