@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.
Files changed (43) hide show
  1. package/README.md +30 -8
  2. package/dist/anchored-pieces.js +21 -5
  3. package/dist/archetype.d.ts +15 -0
  4. package/dist/archetype.js +41 -1
  5. package/dist/assets/glb-clip-names.d.ts +8 -0
  6. package/dist/assets/glb-clip-names.js +49 -0
  7. package/dist/camera-projection.d.ts +6 -5
  8. package/dist/camera-projection.js +8 -7
  9. package/dist/camera-rig.d.ts +54 -4
  10. package/dist/camera-rig.js +116 -6
  11. package/dist/components/animator.d.ts +6 -0
  12. package/dist/components/animator.js +12 -0
  13. package/dist/entity-update-schedules.d.ts +21 -0
  14. package/dist/entity-update-schedules.js +67 -0
  15. package/dist/game.d.ts +14 -12
  16. package/dist/game.js +41 -75
  17. package/dist/index.d.ts +10 -2
  18. package/dist/index.js +4 -0
  19. package/dist/input-mouse.d.ts +83 -0
  20. package/dist/input-mouse.js +205 -0
  21. package/dist/input.d.ts +26 -5
  22. package/dist/input.js +58 -10
  23. package/dist/orbit-camera.d.ts +68 -0
  24. package/dist/orbit-camera.js +92 -0
  25. package/dist/physics-3d/world-queries.d.ts +10 -1
  26. package/dist/physics-3d/world-queries.js +6 -2
  27. package/dist/pointer-raycast.d.ts +24 -6
  28. package/dist/pointer-raycast.js +44 -14
  29. package/dist/runtime-bridge.d.ts +12 -2
  30. package/dist/runtime-bridge.js +12 -1
  31. package/dist/runtime-inspection.js +3 -2
  32. package/dist/runtime-view-snapshot.d.ts +5 -8
  33. package/dist/runtime-view-snapshot.js +15 -11
  34. package/dist/scene-camera-3d.d.ts +14 -15
  35. package/dist/scene-camera-3d.js +14 -10
  36. package/dist/scene-camera-orbit.d.ts +72 -0
  37. package/dist/scene-camera-orbit.js +155 -0
  38. package/dist/scene-field-issues.d.ts +16 -0
  39. package/dist/scene-field-issues.js +11 -0
  40. package/dist/scene.js +5 -3
  41. package/dist/spatial-query-3d.d.ts +2 -2
  42. package/dist/spatial-query-3d.js +13 -4
  43. package/package.json +1 -1
@@ -16,33 +16,63 @@ function modelRoots(entities) {
16
16
  }
17
17
  return roots;
18
18
  }
19
- /** The entity whose Model root `object` hangs under. */
20
- function ownerOf(object, roots) {
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 entity = roots.get(node);
23
- if (entity)
24
- return entity;
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 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.
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
- export function raycastPick(camera, at, entities) {
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 root of roots.keys())
42
- root.updateWorldMatrix(true, true);
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, roots), point: { x: hit.point.x, y: hit.point.y, z: hit.point.z } };
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
  }
@@ -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;
@@ -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 { cameraEffectsSnapshot, viewSnapshot } from './runtime-view-snapshot.js';
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: cameraEffectsSnapshot(this.game),
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
- const camera = game.camera;
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
- return {
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
- * `game.cameraEffects.state` as the snapshot reports it. A perspective camera
24
- * does not shake (spec decision 24: shake is a no-op in a 3D scene), so a 3D
25
- * scene reports no offset even though the effect's own state keeps decaying.
26
- */
27
- export function cameraEffectsSnapshot(game) {
28
- const state = game.cameraEffects.state;
29
- return game.space === '3d' ? { ...state, shake: { x: 0, y: 0 } } : state;
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). Fixed by the scene; game
6
- * code may move it. Y is up and the camera looks from `position` at `target`.
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
- /** One field of a scene's render, camera or entity transform that is invalid, for validate_project. */
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`, and `position` and `target`
63
- * three finite numbers, and none of the orthographic block's fields. Anything
64
- * that is not a perspective block reports none.
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[];
@@ -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
- /** 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
- }
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`, 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.
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 has no follow target to wait for: components see it from their first onReady.
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: with a follow target, the orthographic camera starts centered on it.
143
- if (!adoptsBeforeSpawns)
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);