@waica/engine 0.27.0 → 0.29.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 +30 -8
- package/dist/anchored-pieces.js +21 -5
- package/dist/archetype.d.ts +15 -0
- package/dist/archetype.js +41 -1
- package/dist/assets/glb-clip-names.d.ts +8 -0
- package/dist/assets/glb-clip-names.js +49 -0
- package/dist/camera-projection.d.ts +6 -5
- package/dist/camera-projection.js +8 -7
- package/dist/camera-rig.d.ts +54 -4
- package/dist/camera-rig.js +116 -6
- package/dist/components/animator.d.ts +6 -0
- package/dist/components/animator.js +12 -0
- package/dist/entity-update-schedules.d.ts +21 -0
- package/dist/entity-update-schedules.js +67 -0
- package/dist/game.d.ts +14 -12
- package/dist/game.js +41 -75
- package/dist/index.d.ts +10 -2
- package/dist/index.js +4 -0
- package/dist/input-mouse.d.ts +83 -0
- package/dist/input-mouse.js +205 -0
- package/dist/input.d.ts +26 -5
- package/dist/input.js +58 -10
- package/dist/orbit-camera.d.ts +68 -0
- package/dist/orbit-camera.js +92 -0
- package/dist/physics-3d/world-queries.d.ts +10 -1
- package/dist/physics-3d/world-queries.js +6 -2
- package/dist/pointer-raycast.d.ts +24 -6
- package/dist/pointer-raycast.js +44 -14
- package/dist/runtime-bridge.d.ts +12 -2
- package/dist/runtime-bridge.js +12 -1
- package/dist/runtime-inspection.js +3 -2
- package/dist/runtime-view-snapshot.d.ts +5 -8
- package/dist/runtime-view-snapshot.js +15 -11
- package/dist/scene-camera-3d.d.ts +14 -15
- package/dist/scene-camera-3d.js +14 -10
- package/dist/scene-camera-orbit.d.ts +72 -0
- package/dist/scene-camera-orbit.js +155 -0
- package/dist/scene-field-issues.d.ts +16 -0
- package/dist/scene-field-issues.js +11 -0
- package/dist/scene.js +5 -3
- package/dist/spatial-query-3d.d.ts +2 -2
- package/dist/spatial-query-3d.js +13 -4
- package/package.json +1 -1
package/dist/pointer-raycast.js
CHANGED
|
@@ -16,33 +16,63 @@ function modelRoots(entities) {
|
|
|
16
16
|
}
|
|
17
17
|
return roots;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
function ownerOf(object,
|
|
19
|
+
/** What `object` picks: the target or Model root it hangs under, nearest first. */
|
|
20
|
+
function ownerOf(object, owners) {
|
|
21
21
|
for (let node = object; node; node = node.parent) {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
return
|
|
22
|
+
const owner = owners.get(node);
|
|
23
|
+
if (owner)
|
|
24
|
+
return owner;
|
|
25
25
|
}
|
|
26
26
|
return null;
|
|
27
27
|
}
|
|
28
|
+
/** Whether `target` is drawn and still picks something: itself and every ancestor visible, and its entity alive. */
|
|
29
|
+
function isPickable(target) {
|
|
30
|
+
if (target.entity !== 'camera' && !target.entity.alive)
|
|
31
|
+
return false;
|
|
32
|
+
for (let node = target.object; node; node = node.parent) {
|
|
33
|
+
if (!node.visible)
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/** The extra targets' hits: tested whatever their layers, since an editor overlay lives on the Emissive layer alone. */
|
|
39
|
+
function extraHits(targets) {
|
|
40
|
+
if (targets.length === 0)
|
|
41
|
+
return [];
|
|
42
|
+
const own = raycaster.layers.mask;
|
|
43
|
+
raycaster.layers.enableAll();
|
|
44
|
+
try {
|
|
45
|
+
return raycaster.intersectObjects(targets.filter(isPickable).map((target) => target.object), true);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
raycaster.layers.mask = own;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
28
51
|
/**
|
|
29
52
|
* The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
|
|
30
53
|
* letterboxed point, intersected with the roots of every live Model
|
|
31
|
-
* (recursively, so a glTF's meshes count
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
54
|
+
* (recursively, so a glTF's meshes count, posed where its skeleton is, and a
|
|
55
|
+
* glTF still loading does not) and with the `extra` targets a host adds (the
|
|
56
|
+
* editor's icons, issue #173 CA-2; one that is hidden, or whose entity is
|
|
57
|
+
* dead, is skipped). The nearest hit wins, with its
|
|
58
|
+
* world-space point. With no hit the pick has no entity and the point where
|
|
59
|
+
* the ray meets the ground plane y = 0; a ray that never meets it (it looks
|
|
60
|
+
* at the sky) yields no pick.
|
|
35
61
|
*/
|
|
36
|
-
|
|
62
|
+
// eslint-disable-next-line max-params -- the signature spec #173 fixes: the Pointer's three inputs plus the optional extra targets; bundling them would be an unrelated bag
|
|
63
|
+
export function raycastPick(camera, at, entities, extra = []) {
|
|
37
64
|
camera.updateMatrixWorld();
|
|
38
65
|
raycaster.setFromCamera(new THREE.Vector2(at.nx * 2 - 1, 1 - at.ny * 2), camera);
|
|
39
66
|
const roots = modelRoots(entities);
|
|
67
|
+
const owners = new Map(roots);
|
|
68
|
+
for (const target of extra)
|
|
69
|
+
owners.set(target.object, target.entity);
|
|
40
70
|
// A click can land before the first frame has placed anything.
|
|
41
|
-
for (const
|
|
42
|
-
|
|
43
|
-
const [hit] = raycaster.intersectObjects([...roots.keys()], true);
|
|
71
|
+
for (const object of owners.keys())
|
|
72
|
+
object.updateWorldMatrix(true, true);
|
|
73
|
+
const [hit] = [...raycaster.intersectObjects([...roots.keys()], true), ...extraHits(extra)].sort((a, b) => a.distance - b.distance);
|
|
44
74
|
if (hit)
|
|
45
|
-
return { entity: ownerOf(hit.object,
|
|
75
|
+
return { entity: ownerOf(hit.object, owners), point: { x: hit.point.x, y: hit.point.y, z: hit.point.z } };
|
|
46
76
|
const ground = raycaster.ray.intersectPlane(GROUND, new THREE.Vector3());
|
|
47
77
|
return ground ? { entity: null, point: { x: ground.x, y: ground.y, z: ground.z } } : null;
|
|
48
78
|
}
|
package/dist/runtime-bridge.d.ts
CHANGED
|
@@ -25,9 +25,10 @@ export type RuntimeMode = 'paused' | 'real-time';
|
|
|
25
25
|
* 0025) announces that every metadata carries `backend`: the Render Backend
|
|
26
26
|
* once the renderer is ready, null before — a Run Session waits for it — and
|
|
27
27
|
* that a renderer that fails to initialize reports through the activation's
|
|
28
|
-
* `fail`.
|
|
28
|
+
* `fail`. `look` (issue #181) announces the `look` operation: a mouse delta
|
|
29
|
+
* the next Simulation Step reads as locked mouse movement.
|
|
29
30
|
*/
|
|
30
|
-
export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects', 'analog-actions', 'render-backend'];
|
|
31
|
+
export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects', 'analog-actions', 'render-backend', 'look'];
|
|
31
32
|
export interface RuntimeMetadata {
|
|
32
33
|
bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
|
|
33
34
|
engineVersion: string;
|
|
@@ -63,6 +64,12 @@ export type RuntimeControlRequest = {
|
|
|
63
64
|
} | {
|
|
64
65
|
operation: 'scene';
|
|
65
66
|
scene: string;
|
|
67
|
+
}
|
|
68
|
+
/** Adds `dx`, `dy` CSS px of mouse movement to the next Simulation Step, as if the pointer were locked. */
|
|
69
|
+
| {
|
|
70
|
+
operation: 'look';
|
|
71
|
+
dx: number;
|
|
72
|
+
dy: number;
|
|
66
73
|
};
|
|
67
74
|
export interface RuntimeControlResult extends RuntimeMetadata {
|
|
68
75
|
heldActions: string[];
|
|
@@ -108,6 +115,8 @@ export interface RuntimeBridgeHost {
|
|
|
108
115
|
resume(onStep: () => void): void;
|
|
109
116
|
pause(): void;
|
|
110
117
|
injectAction(action: string, operation: 'press' | 'hold' | 'release', value?: number): boolean;
|
|
118
|
+
/** Adds a mouse movement to the next Simulation Step, as if the pointer were locked. */
|
|
119
|
+
look(dx: number, dy: number): void;
|
|
111
120
|
availableActions(): string[];
|
|
112
121
|
heldActions(): string[];
|
|
113
122
|
actionValues(): Record<string, number>;
|
|
@@ -137,6 +146,7 @@ export declare class EngineRuntimeBridge implements RuntimeBridge {
|
|
|
137
146
|
private injectAction;
|
|
138
147
|
/** Advances whole Simulation Steps while paused. */
|
|
139
148
|
private step;
|
|
149
|
+
private look;
|
|
140
150
|
private loadScene;
|
|
141
151
|
/** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
|
|
142
152
|
private advance;
|
package/dist/runtime-bridge.js
CHANGED
|
@@ -24,7 +24,8 @@ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
|
|
|
24
24
|
* 0025) announces that every metadata carries `backend`: the Render Backend
|
|
25
25
|
* once the renderer is ready, null before — a Run Session waits for it — and
|
|
26
26
|
* that a renderer that fails to initialize reports through the activation's
|
|
27
|
-
* `fail`.
|
|
27
|
+
* `fail`. `look` (issue #181) announces the `look` operation: a mouse delta
|
|
28
|
+
* the next Simulation Step reads as locked mouse movement.
|
|
28
29
|
*/
|
|
29
30
|
export const RUNTIME_BRIDGE_CAPABILITIES = [
|
|
30
31
|
'click',
|
|
@@ -34,6 +35,7 @@ export const RUNTIME_BRIDGE_CAPABILITIES = [
|
|
|
34
35
|
'camera-effects',
|
|
35
36
|
'analog-actions',
|
|
36
37
|
'render-backend',
|
|
38
|
+
'look',
|
|
37
39
|
];
|
|
38
40
|
export class RuntimeBridgeOperationError extends Error {
|
|
39
41
|
code;
|
|
@@ -122,6 +124,9 @@ export class EngineRuntimeBridge {
|
|
|
122
124
|
case 'scene':
|
|
123
125
|
this.loadScene(request.scene);
|
|
124
126
|
break;
|
|
127
|
+
case 'look':
|
|
128
|
+
this.look(request);
|
|
129
|
+
break;
|
|
125
130
|
default: {
|
|
126
131
|
const unsupported = request;
|
|
127
132
|
throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unsupported runtime control operation "${unsupported.operation}".`);
|
|
@@ -160,6 +165,12 @@ export class EngineRuntimeBridge {
|
|
|
160
165
|
this.host.step(() => this.advance());
|
|
161
166
|
}
|
|
162
167
|
}
|
|
168
|
+
look(request) {
|
|
169
|
+
if (!Number.isFinite(request.dx) || !Number.isFinite(request.dy)) {
|
|
170
|
+
throw new RuntimeBridgeOperationError('runtime-operation-failed', 'dx and dy must be finite numbers.');
|
|
171
|
+
}
|
|
172
|
+
this.host.look(request.dx, request.dy);
|
|
173
|
+
}
|
|
163
174
|
loadScene(scene) {
|
|
164
175
|
if (this.host.loadScene(scene))
|
|
165
176
|
return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { viewSnapshot } from './runtime-view-snapshot.js';
|
|
2
2
|
import { anchoredPiecesOf } from './ui.js';
|
|
3
3
|
import { componentClassOf } from './component-registry.js';
|
|
4
4
|
import { capLights, lightingSnapshot, postSnapshot, } from './runtime-lighting-snapshot.js';
|
|
@@ -295,7 +295,8 @@ export class RuntimeInspector {
|
|
|
295
295
|
audio: this.audioSnapshot(),
|
|
296
296
|
time: this.timeSnapshot(),
|
|
297
297
|
ui: this.uiSnapshot(projectionIssues),
|
|
298
|
-
camera
|
|
298
|
+
// The shake offset applies in both spaces since a perspective camera shakes too (issue #181 CA-11).
|
|
299
|
+
camera: this.game.cameraEffects.state,
|
|
299
300
|
space: this.game.space,
|
|
300
301
|
view: viewSnapshot(this.game),
|
|
301
302
|
lighting: lightingSnapshot(this.game, (entity) => this.idFor(entity)),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CameraEffectsState } from './camera-effects.js';
|
|
2
1
|
import type { Game } from './game.js';
|
|
2
|
+
import type { OrbitView } from './orbit-camera.js';
|
|
3
3
|
import type { Vec3Json } from './scene-camera-3d.js';
|
|
4
4
|
/**
|
|
5
5
|
* The view the scene is drawn from (issue #154 CA-16), beside the Camera
|
|
@@ -10,16 +10,13 @@ export type RuntimeSnapshotView = {
|
|
|
10
10
|
kind: 'orthographic';
|
|
11
11
|
position: [number, number];
|
|
12
12
|
zoom: number;
|
|
13
|
-
} |
|
|
13
|
+
} | PerspectiveView | (PerspectiveView & OrbitView);
|
|
14
|
+
/** A perspective camera's pose; a following one adds its whole orbit (issue #181 CA-10), so `'follow' in view` narrows to it. */
|
|
15
|
+
type PerspectiveView = {
|
|
14
16
|
kind: 'perspective';
|
|
15
17
|
position: Vec3Json;
|
|
16
18
|
target: Vec3Json;
|
|
17
19
|
fov: number;
|
|
18
20
|
};
|
|
19
21
|
export declare function viewSnapshot(game: Game): RuntimeSnapshotView;
|
|
20
|
-
|
|
21
|
-
* `game.cameraEffects.state` as the snapshot reports it. A perspective camera
|
|
22
|
-
* does not shake (spec decision 24: shake is a no-op in a 3D scene), so a 3D
|
|
23
|
-
* scene reports no offset even though the effect's own state keeps decaying.
|
|
24
|
-
*/
|
|
25
|
-
export declare function cameraEffectsSnapshot(game: Game): CameraEffectsState;
|
|
22
|
+
export {};
|
|
@@ -6,25 +6,29 @@ function vector(value) {
|
|
|
6
6
|
return [clean(value.x), clean(value.y), clean(value.z)];
|
|
7
7
|
}
|
|
8
8
|
export function viewSnapshot(game) {
|
|
9
|
-
|
|
9
|
+
// The scene's own camera: a host's view camera is not part of the simulated game, and `lookDistance` is the scene camera's.
|
|
10
|
+
const camera = game.cameraRig.sceneCamera;
|
|
10
11
|
if (!isPerspectiveCamera(camera)) {
|
|
11
12
|
return { kind: 'orthographic', position: [clean(camera.position.x), clean(camera.position.y)], zoom: game.view };
|
|
12
13
|
}
|
|
13
14
|
camera.updateMatrixWorld();
|
|
14
15
|
const ahead = camera.getWorldDirection(new THREE.Vector3()).multiplyScalar(game.cameraRig.lookDistance);
|
|
15
|
-
|
|
16
|
+
const pose = {
|
|
16
17
|
kind: 'perspective',
|
|
17
18
|
position: vector(camera.position),
|
|
18
19
|
target: vector(camera.position.clone().add(ahead)),
|
|
19
20
|
fov: camera.fov,
|
|
20
21
|
};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
const orbit = game.cameraRig.orbitView;
|
|
23
|
+
if (!orbit)
|
|
24
|
+
return pose;
|
|
25
|
+
// A following camera's orbit (issue #181 CA-10): the chosen distance and the placed one.
|
|
26
|
+
return {
|
|
27
|
+
...pose,
|
|
28
|
+
follow: orbit.follow,
|
|
29
|
+
yaw: clean(orbit.yaw),
|
|
30
|
+
pitch: clean(orbit.pitch),
|
|
31
|
+
distance: clean(orbit.distance),
|
|
32
|
+
distanceActual: clean(orbit.distanceActual),
|
|
33
|
+
};
|
|
30
34
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type * as THREE from 'three/webgpu';
|
|
2
|
+
import { type OrbitCameraJson, type ResolvedOrbit } from './scene-camera-orbit.js';
|
|
3
|
+
import { type SceneFieldIssue } from './scene-field-issues.js';
|
|
2
4
|
/** A world-space point or direction as scene JSON stores it. */
|
|
3
5
|
export type Vec3Json = [number, number, number];
|
|
4
6
|
/**
|
|
5
|
-
* A scene's perspective camera (3D scenes, ADR 0027).
|
|
6
|
-
* code may move it
|
|
7
|
+
* A scene's perspective camera (3D scenes, ADR 0027). Without `follow` it is
|
|
8
|
+
* fixed by the scene and game code may move it: Y is up and the camera looks
|
|
9
|
+
* from `position` at `target`. With `follow` it orbits that entity once per
|
|
10
|
+
* Simulation Step (issue #181, ADR 0031; the orbit fields are OrbitCameraJson's).
|
|
7
11
|
*/
|
|
8
|
-
export interface PerspectiveSceneCameraJson {
|
|
12
|
+
export interface PerspectiveSceneCameraJson extends OrbitCameraJson {
|
|
9
13
|
kind: 'perspective';
|
|
10
14
|
/** Where the camera sits. Default `[0, 5, 10]`. */
|
|
11
15
|
position?: Vec3Json;
|
|
@@ -18,7 +22,6 @@ export interface PerspectiveSceneCameraJson {
|
|
|
18
22
|
/** Far clipping plane, above `near`. Default 1000. */
|
|
19
23
|
far?: number;
|
|
20
24
|
zoom?: never;
|
|
21
|
-
follow?: never;
|
|
22
25
|
deadzoneWidth?: never;
|
|
23
26
|
deadzoneHeight?: never;
|
|
24
27
|
lookahead?: never;
|
|
@@ -33,6 +36,8 @@ export interface ResolvedPerspectiveCamera {
|
|
|
33
36
|
fov: number;
|
|
34
37
|
near: number;
|
|
35
38
|
far: number;
|
|
39
|
+
/** The orbit around `follow`; absent for a fixed camera. */
|
|
40
|
+
orbit?: ResolvedOrbit;
|
|
36
41
|
}
|
|
37
42
|
export declare const PERSPECTIVE_DEFAULTS: {
|
|
38
43
|
readonly position: Vec3Json;
|
|
@@ -49,18 +54,12 @@ export declare function isPerspectiveCameraJson(json: {
|
|
|
49
54
|
export declare function resolvePerspectiveCamera(json?: PerspectiveSceneCameraJson): ResolvedPerspectiveCamera;
|
|
50
55
|
/** Points `camera` as the resolved block says, at the given aspect ratio. */
|
|
51
56
|
export declare function placePerspectiveCamera(camera: THREE.PerspectiveCamera, resolved: ResolvedPerspectiveCamera, aspect: number): void;
|
|
52
|
-
|
|
53
|
-
export interface SceneFieldIssue {
|
|
54
|
-
field: string;
|
|
55
|
-
message: string;
|
|
56
|
-
}
|
|
57
|
-
/** A JSON value as a message quotes it: strings in quotes, everything else as `String()` shows it. */
|
|
58
|
-
export declare const shown: (value: unknown) => string;
|
|
59
|
-
export declare function isFiniteNumber(value: unknown): value is number;
|
|
57
|
+
export { isFiniteNumber, shown, type SceneFieldIssue } from './scene-field-issues.js';
|
|
60
58
|
/**
|
|
61
59
|
* Every field of a perspective camera block outside its range: `fov` in
|
|
62
|
-
* (0, 180), `near` above 0, `far` above `near`,
|
|
63
|
-
* three finite numbers, and none of the
|
|
64
|
-
* that is not a perspective block
|
|
60
|
+
* (0, 180), `near` above 0, `far` above `near`, `position` and `target`
|
|
61
|
+
* three finite numbers, the orbit fields (orbitCameraIssues), and none of the
|
|
62
|
+
* orthographic block's other fields. Anything that is not a perspective block
|
|
63
|
+
* reports none.
|
|
65
64
|
*/
|
|
66
65
|
export declare function perspectiveCameraIssues(camera: unknown): SceneFieldIssue[];
|
package/dist/scene-camera-3d.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { orbitCameraIssues, resolveOrbit } from './scene-camera-orbit.js';
|
|
2
|
+
import { isFiniteNumber, shown } from './scene-field-issues.js';
|
|
1
3
|
export const PERSPECTIVE_DEFAULTS = {
|
|
2
4
|
position: [0, 5, 10],
|
|
3
5
|
target: [0, 0, 0],
|
|
@@ -18,8 +20,13 @@ export function resolvePerspectiveCamera(json = { kind: 'perspective' }) {
|
|
|
18
20
|
fov: json.fov ?? PERSPECTIVE_DEFAULTS.fov,
|
|
19
21
|
near: json.near ?? PERSPECTIVE_DEFAULTS.near,
|
|
20
22
|
far: json.far ?? PERSPECTIVE_DEFAULTS.far,
|
|
23
|
+
...orbitOf(json),
|
|
21
24
|
};
|
|
22
25
|
}
|
|
26
|
+
function orbitOf(json) {
|
|
27
|
+
const orbit = resolveOrbit(json);
|
|
28
|
+
return orbit ? { orbit } : {};
|
|
29
|
+
}
|
|
23
30
|
/** Points `camera` as the resolved block says, at the given aspect ratio. */
|
|
24
31
|
export function placePerspectiveCamera(camera, resolved, aspect) {
|
|
25
32
|
camera.fov = resolved.fov;
|
|
@@ -31,11 +38,7 @@ export function placePerspectiveCamera(camera, resolved, aspect) {
|
|
|
31
38
|
camera.updateProjectionMatrix();
|
|
32
39
|
camera.updateMatrixWorld();
|
|
33
40
|
}
|
|
34
|
-
|
|
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
|
-
}
|
|
41
|
+
export { isFiniteNumber, shown } from './scene-field-issues.js';
|
|
39
42
|
function vectorIssue(field, value, issues) {
|
|
40
43
|
if (value === undefined)
|
|
41
44
|
return;
|
|
@@ -76,9 +79,8 @@ function planeIssues(near, far, issues) {
|
|
|
76
79
|
issues.push({ field: 'camera.far', message: `camera.far must be a number above near (${nearValue}); got ${shown(far)}.` });
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
|
-
/** The orthographic block's fields, which a perspective block does not accept. */
|
|
82
|
+
/** The orthographic block's fields, which a perspective block does not accept (`follow` it does, issue #181). */
|
|
80
83
|
const ORTHOGRAPHIC_ONLY_FIELDS = [
|
|
81
|
-
'follow',
|
|
82
84
|
'zoom',
|
|
83
85
|
'deadzoneWidth',
|
|
84
86
|
'deadzoneHeight',
|
|
@@ -96,9 +98,10 @@ function orthographicFieldIssues(block, issues) {
|
|
|
96
98
|
}
|
|
97
99
|
/**
|
|
98
100
|
* Every field of a perspective camera block outside its range: `fov` in
|
|
99
|
-
* (0, 180), `near` above 0, `far` above `near`,
|
|
100
|
-
* three finite numbers, and none of the
|
|
101
|
-
* that is not a perspective block
|
|
101
|
+
* (0, 180), `near` above 0, `far` above `near`, `position` and `target`
|
|
102
|
+
* three finite numbers, the orbit fields (orbitCameraIssues), and none of the
|
|
103
|
+
* orthographic block's other fields. Anything that is not a perspective block
|
|
104
|
+
* reports none.
|
|
102
105
|
*/
|
|
103
106
|
export function perspectiveCameraIssues(camera) {
|
|
104
107
|
if (typeof camera !== 'object' || camera === null)
|
|
@@ -113,5 +116,6 @@ export function perspectiveCameraIssues(camera) {
|
|
|
113
116
|
aimIssue(block, issues);
|
|
114
117
|
planeIssues(block.near, block.far, issues);
|
|
115
118
|
orthographicFieldIssues(block, issues);
|
|
119
|
+
issues.push(...orbitCameraIssues(block));
|
|
116
120
|
return issues;
|
|
117
121
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type SceneFieldIssue } from './scene-field-issues.js';
|
|
2
|
+
/**
|
|
3
|
+
* The orbit fields of a scene's perspective camera block (issue #181, ADR
|
|
4
|
+
* 0031): with `follow`, the camera orbits that entity instead of staying
|
|
5
|
+
* where `position` and `target` put it. Angles are degrees; `yaw` 0 looks
|
|
6
|
+
* toward -z (the camera sits on +z), 90 toward +x; `pitch` is the camera's
|
|
7
|
+
* height above the look point, positive looking down.
|
|
8
|
+
*/
|
|
9
|
+
export interface OrbitCameraJson {
|
|
10
|
+
/** The entity to orbit; absent or empty keeps the camera fixed. */
|
|
11
|
+
follow?: string;
|
|
12
|
+
/** The chosen distance from the look point. Default 6. */
|
|
13
|
+
distance?: number;
|
|
14
|
+
/** The wheel's closest distance. Default 2. */
|
|
15
|
+
minDistance?: number;
|
|
16
|
+
/** The wheel's farthest distance. Default 12. */
|
|
17
|
+
maxDistance?: number;
|
|
18
|
+
/** Initial yaw, in degrees. Default 0. */
|
|
19
|
+
yaw?: number;
|
|
20
|
+
/** Initial pitch, in degrees, in (-89, 89). Default 20. */
|
|
21
|
+
pitch?: number;
|
|
22
|
+
/** Lowest pitch, in (-89, 89). Default -10. */
|
|
23
|
+
minPitch?: number;
|
|
24
|
+
/** Highest pitch, in (-89, 89). Default 70. */
|
|
25
|
+
maxPitch?: number;
|
|
26
|
+
/** How far above the followed entity's position the camera looks. Default 1.2. */
|
|
27
|
+
lookHeight?: number;
|
|
28
|
+
/** Degrees of orbit per CSS pixel of locked mouse movement. Default 0.2. */
|
|
29
|
+
sensitivity?: number;
|
|
30
|
+
/** Degrees per second the orbit Actions turn at full value. Default 120. */
|
|
31
|
+
orbitSpeed?: number;
|
|
32
|
+
/** Flips the mouse's vertical orbit. Default false. */
|
|
33
|
+
invertY?: boolean;
|
|
34
|
+
/** The Action that orbits left (yaw down). Default `look_left`. */
|
|
35
|
+
orbitLeftAction?: string;
|
|
36
|
+
/** The Action that orbits right (yaw up). Default `look_right`. */
|
|
37
|
+
orbitRightAction?: string;
|
|
38
|
+
/** The Action that looks up (pitch down). Default `look_up`. */
|
|
39
|
+
orbitUpAction?: string;
|
|
40
|
+
/** The Action that looks down (pitch up). Default `look_down`. */
|
|
41
|
+
orbitDownAction?: string;
|
|
42
|
+
}
|
|
43
|
+
/** Every orbit field resolved; `follow` names the entity. */
|
|
44
|
+
export type ResolvedOrbit = Required<OrbitCameraJson>;
|
|
45
|
+
export declare const ORBIT_DEFAULTS: {
|
|
46
|
+
readonly distance: 6;
|
|
47
|
+
readonly minDistance: 2;
|
|
48
|
+
readonly maxDistance: 12;
|
|
49
|
+
readonly yaw: 0;
|
|
50
|
+
readonly pitch: 20;
|
|
51
|
+
readonly minPitch: -10;
|
|
52
|
+
readonly maxPitch: 70;
|
|
53
|
+
readonly lookHeight: 1.2;
|
|
54
|
+
readonly sensitivity: 0.2;
|
|
55
|
+
readonly orbitSpeed: 120;
|
|
56
|
+
readonly invertY: false;
|
|
57
|
+
readonly orbitLeftAction: 'look_left';
|
|
58
|
+
readonly orbitRightAction: 'look_right';
|
|
59
|
+
readonly orbitUpAction: 'look_up';
|
|
60
|
+
readonly orbitDownAction: 'look_down';
|
|
61
|
+
};
|
|
62
|
+
/** The orbit a block declares with its defaults filled, or null for a fixed camera (no `follow`). */
|
|
63
|
+
export declare function resolveOrbit(json: OrbitCameraJson): ResolvedOrbit | null;
|
|
64
|
+
/**
|
|
65
|
+
* Every orbit field of a perspective block outside its range: the numbers
|
|
66
|
+
* (distances above 0, pitches in (-89, 89), speeds at least 0), `invertY`,
|
|
67
|
+
* the Action names, and a `minDistance` or `minPitch` above its maximum
|
|
68
|
+
* (errors); and a following camera's initial `distance` or `pitch` outside
|
|
69
|
+
* its own range (warnings). Whether `follow` names an entity of the scene is
|
|
70
|
+
* the scene validator's.
|
|
71
|
+
*/
|
|
72
|
+
export declare function orbitCameraIssues(block: Record<string, unknown>): SceneFieldIssue[];
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isFiniteNumber, shown } from './scene-field-issues.js';
|
|
2
|
+
const ACTION_FIELDS = ['orbitLeftAction', 'orbitRightAction', 'orbitUpAction', 'orbitDownAction'];
|
|
3
|
+
export const ORBIT_DEFAULTS = {
|
|
4
|
+
distance: 6,
|
|
5
|
+
minDistance: 2,
|
|
6
|
+
maxDistance: 12,
|
|
7
|
+
yaw: 0,
|
|
8
|
+
pitch: 20,
|
|
9
|
+
minPitch: -10,
|
|
10
|
+
maxPitch: 70,
|
|
11
|
+
lookHeight: 1.2,
|
|
12
|
+
sensitivity: 0.2,
|
|
13
|
+
orbitSpeed: 120,
|
|
14
|
+
invertY: false,
|
|
15
|
+
orbitLeftAction: 'look_left',
|
|
16
|
+
orbitRightAction: 'look_right',
|
|
17
|
+
orbitUpAction: 'look_up',
|
|
18
|
+
orbitDownAction: 'look_down',
|
|
19
|
+
};
|
|
20
|
+
/** The orbit a block declares with its defaults filled, or null for a fixed camera (no `follow`). */
|
|
21
|
+
export function resolveOrbit(json) {
|
|
22
|
+
if (!json.follow)
|
|
23
|
+
return null;
|
|
24
|
+
return { follow: json.follow, ...resolveFraming(json), ...resolveControls(json) };
|
|
25
|
+
}
|
|
26
|
+
/** Where the camera sits: distances, angles and the look height. */
|
|
27
|
+
function resolveFraming(json) {
|
|
28
|
+
return {
|
|
29
|
+
distance: json.distance ?? ORBIT_DEFAULTS.distance,
|
|
30
|
+
minDistance: json.minDistance ?? ORBIT_DEFAULTS.minDistance,
|
|
31
|
+
maxDistance: json.maxDistance ?? ORBIT_DEFAULTS.maxDistance,
|
|
32
|
+
yaw: json.yaw ?? ORBIT_DEFAULTS.yaw,
|
|
33
|
+
pitch: json.pitch ?? ORBIT_DEFAULTS.pitch,
|
|
34
|
+
minPitch: json.minPitch ?? ORBIT_DEFAULTS.minPitch,
|
|
35
|
+
maxPitch: json.maxPitch ?? ORBIT_DEFAULTS.maxPitch,
|
|
36
|
+
lookHeight: json.lookHeight ?? ORBIT_DEFAULTS.lookHeight,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** How input turns it: the mouse, the orbit Actions and their speed. */
|
|
40
|
+
function resolveControls(json) {
|
|
41
|
+
return {
|
|
42
|
+
sensitivity: json.sensitivity ?? ORBIT_DEFAULTS.sensitivity,
|
|
43
|
+
orbitSpeed: json.orbitSpeed ?? ORBIT_DEFAULTS.orbitSpeed,
|
|
44
|
+
invertY: json.invertY ?? ORBIT_DEFAULTS.invertY,
|
|
45
|
+
orbitLeftAction: json.orbitLeftAction ?? ORBIT_DEFAULTS.orbitLeftAction,
|
|
46
|
+
orbitRightAction: json.orbitRightAction ?? ORBIT_DEFAULTS.orbitRightAction,
|
|
47
|
+
orbitUpAction: json.orbitUpAction ?? ORBIT_DEFAULTS.orbitUpAction,
|
|
48
|
+
orbitDownAction: json.orbitDownAction ?? ORBIT_DEFAULTS.orbitDownAction,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The rule each numeric orbit field must meet, and how its message says it. */
|
|
52
|
+
const NUMBER_RULES = [
|
|
53
|
+
{ field: 'distance', accepts: (value) => value > 0, rule: 'a number above 0' },
|
|
54
|
+
{ field: 'minDistance', accepts: (value) => value > 0, rule: 'a number above 0' },
|
|
55
|
+
{ field: 'maxDistance', accepts: (value) => value > 0, rule: 'a number above 0' },
|
|
56
|
+
{ field: 'yaw', accepts: () => true, rule: 'a finite number' },
|
|
57
|
+
{ field: 'pitch', accepts: (value) => Math.abs(value) < 89, rule: 'a number above -89 and below 89' },
|
|
58
|
+
{ field: 'minPitch', accepts: (value) => Math.abs(value) < 89, rule: 'a number above -89 and below 89' },
|
|
59
|
+
{ field: 'maxPitch', accepts: (value) => Math.abs(value) < 89, rule: 'a number above -89 and below 89' },
|
|
60
|
+
{ field: 'lookHeight', accepts: () => true, rule: 'a finite number' },
|
|
61
|
+
{ field: 'sensitivity', accepts: (value) => value >= 0, rule: 'a number of at least 0' },
|
|
62
|
+
{ field: 'orbitSpeed', accepts: (value) => value >= 0, rule: 'a number of at least 0' },
|
|
63
|
+
];
|
|
64
|
+
function numberIssues(block, issues) {
|
|
65
|
+
for (const { field, accepts, rule } of NUMBER_RULES) {
|
|
66
|
+
const value = block[field];
|
|
67
|
+
if (value === undefined || (isFiniteNumber(value) && accepts(value)))
|
|
68
|
+
continue;
|
|
69
|
+
issues.push({ field: `camera.${field}`, message: `camera.${field} must be ${rule}; got ${shown(value)}.` });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function actionIssues(block, issues) {
|
|
73
|
+
for (const field of ACTION_FIELDS) {
|
|
74
|
+
const value = block[field];
|
|
75
|
+
if (value === undefined || (typeof value === 'string' && value.length > 0))
|
|
76
|
+
continue;
|
|
77
|
+
issues.push({ field: `camera.${field}`, message: `camera.${field} must be an Action name; got ${shown(value)}.` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function fieldIssues(block, issues) {
|
|
81
|
+
if (block.follow !== undefined && typeof block.follow !== 'string') {
|
|
82
|
+
issues.push({ field: 'camera.follow', message: `camera.follow must be an entity name; got ${shown(block.follow)}.` });
|
|
83
|
+
}
|
|
84
|
+
numberIssues(block, issues);
|
|
85
|
+
if (block.invertY !== undefined && typeof block.invertY !== 'boolean') {
|
|
86
|
+
issues.push({ field: 'camera.invertY', message: `camera.invertY must be true or false; got ${shown(block.invertY)}.` });
|
|
87
|
+
}
|
|
88
|
+
actionIssues(block, issues);
|
|
89
|
+
}
|
|
90
|
+
function rangeIssue(block, [low, high], issues) {
|
|
91
|
+
const min = block[low];
|
|
92
|
+
const max = block[high];
|
|
93
|
+
const fallback = ORBIT_DEFAULTS;
|
|
94
|
+
const lowValue = min ?? fallback[low];
|
|
95
|
+
const highValue = max ?? fallback[high];
|
|
96
|
+
if (!isFiniteNumber(lowValue) || !isFiniteNumber(highValue) || lowValue <= highValue)
|
|
97
|
+
return;
|
|
98
|
+
issues.push({
|
|
99
|
+
field: `camera.${low}`,
|
|
100
|
+
message: `camera.${low} (${lowValue}) must not exceed camera.${high} (${highValue}).`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** A declared value or its default, when it is a number its rule accepts (else the field is already an error). */
|
|
104
|
+
function numberOr(block, field) {
|
|
105
|
+
const fallback = ORBIT_DEFAULTS;
|
|
106
|
+
const value = block[field] ?? fallback[field];
|
|
107
|
+
const rule = NUMBER_RULES.find((entry) => entry.field === field);
|
|
108
|
+
return isFiniteNumber(value) && (rule?.accepts(value) ?? true) ? value : null;
|
|
109
|
+
}
|
|
110
|
+
/** The range each initial orbit number starts inside. */
|
|
111
|
+
const START_RANGES = {
|
|
112
|
+
distance: ['minDistance', 'maxDistance'],
|
|
113
|
+
pitch: ['minPitch', 'maxPitch'],
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* A following camera's declared initial `distance` or `pitch` outside its
|
|
117
|
+
* valid range [low, high]: the camera clamps it, so it starts elsewhere than
|
|
118
|
+
* the scene says, a warning (issue #181, maintainer decision). An invalid
|
|
119
|
+
* value or an inverted range is already an error and is not repeated.
|
|
120
|
+
*/
|
|
121
|
+
function startIssue(block, field) {
|
|
122
|
+
const [low, high] = START_RANGES[field];
|
|
123
|
+
const value = block[field] === undefined ? null : numberOr(block, field);
|
|
124
|
+
const min = numberOr(block, low);
|
|
125
|
+
const max = numberOr(block, high);
|
|
126
|
+
if (!block.follow || value === null || min === null || max === null || min > max)
|
|
127
|
+
return null;
|
|
128
|
+
if (value >= min && value <= max)
|
|
129
|
+
return null;
|
|
130
|
+
return {
|
|
131
|
+
field: `camera.${field}`,
|
|
132
|
+
message: `camera.${field} (${value}) is outside [camera.${low} (${min}), camera.${high} (${max})]; the camera starts at ${Math.min(Math.max(value, min), max)}.`,
|
|
133
|
+
severity: 'warning',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Every orbit field of a perspective block outside its range: the numbers
|
|
138
|
+
* (distances above 0, pitches in (-89, 89), speeds at least 0), `invertY`,
|
|
139
|
+
* the Action names, and a `minDistance` or `minPitch` above its maximum
|
|
140
|
+
* (errors); and a following camera's initial `distance` or `pitch` outside
|
|
141
|
+
* its own range (warnings). Whether `follow` names an entity of the scene is
|
|
142
|
+
* the scene validator's.
|
|
143
|
+
*/
|
|
144
|
+
export function orbitCameraIssues(block) {
|
|
145
|
+
const issues = [];
|
|
146
|
+
fieldIssues(block, issues);
|
|
147
|
+
rangeIssue(block, ['minDistance', 'maxDistance'], issues);
|
|
148
|
+
rangeIssue(block, ['minPitch', 'maxPitch'], issues);
|
|
149
|
+
for (const field of ['distance', 'pitch']) {
|
|
150
|
+
const start = startIssue(block, field);
|
|
151
|
+
if (start)
|
|
152
|
+
issues.push(start);
|
|
153
|
+
}
|
|
154
|
+
return issues;
|
|
155
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the scene validators share (the camera, orbit, render and simulation
|
|
3
|
+
* blocks): the issue they report and how a message quotes a JSON value. A
|
|
4
|
+
* leaf module, so the orbit fields can use them without importing the
|
|
5
|
+
* perspective camera that imports them.
|
|
6
|
+
*/
|
|
7
|
+
/** One field of a scene's render, camera or entity transform that is invalid, for validate_project: an error unless it is a `'warning'`. */
|
|
8
|
+
export interface SceneFieldIssue {
|
|
9
|
+
field: string;
|
|
10
|
+
message: string;
|
|
11
|
+
/** A field the runtime accepts as written but adjusts (a clamp): reported as a warning. */
|
|
12
|
+
severity?: 'warning';
|
|
13
|
+
}
|
|
14
|
+
/** A JSON value as a message quotes it: strings in quotes, everything else as `String()` shows it. */
|
|
15
|
+
export declare const shown: (value: unknown) => string;
|
|
16
|
+
export declare function isFiniteNumber(value: unknown): value is number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the scene validators share (the camera, orbit, render and simulation
|
|
3
|
+
* blocks): the issue they report and how a message quotes a JSON value. A
|
|
4
|
+
* leaf module, so the orbit fields can use them without importing the
|
|
5
|
+
* perspective camera that imports them.
|
|
6
|
+
*/
|
|
7
|
+
/** A JSON value as a message quotes it: strings in quotes, everything else as `String()` shows it. */
|
|
8
|
+
export const shown = (value) => (typeof value === 'string' ? JSON.stringify(value) : String(value));
|
|
9
|
+
export function isFiniteNumber(value) {
|
|
10
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
11
|
+
}
|
package/dist/scene.js
CHANGED
|
@@ -133,14 +133,16 @@ export function loadScene(game, scene, registry) {
|
|
|
133
133
|
game.registry = registry;
|
|
134
134
|
game.setSceneRender(scene.render);
|
|
135
135
|
game.setSceneSimulation(scene.simulation);
|
|
136
|
-
// A perspective camera
|
|
136
|
+
// A perspective camera is adopted first, so components see it from their first onReady.
|
|
137
137
|
const adoptsBeforeSpawns = game.space === '3d';
|
|
138
138
|
if (adoptsBeforeSpawns)
|
|
139
139
|
game.setSceneCamera(scene.camera);
|
|
140
140
|
for (const entityJson of scene.entities)
|
|
141
141
|
spawnFromJson(game, entityJson, registry);
|
|
142
|
-
// After the spawns
|
|
143
|
-
if (
|
|
142
|
+
// After the spawns, with a follow target: the orthographic camera starts centered on it, an orbit camera on its orbit.
|
|
143
|
+
if (adoptsBeforeSpawns)
|
|
144
|
+
game.placeSceneCamera();
|
|
145
|
+
else
|
|
144
146
|
game.setSceneCamera(scene.camera);
|
|
145
147
|
if (registry.ui)
|
|
146
148
|
game.ui.defineAll(registry.ui);
|