@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.
Files changed (114) hide show
  1. package/README.md +77 -1
  2. package/dist/anchored-pieces.d.ts +3 -2
  3. package/dist/anchored-pieces.js +12 -8
  4. package/dist/animation/clip-player.d.ts +7 -0
  5. package/dist/animation/clip-player.js +9 -0
  6. package/dist/animation/clip-target.d.ts +38 -0
  7. package/dist/animation/clip-target.js +9 -0
  8. package/dist/animation/pose-hash.d.ts +10 -0
  9. package/dist/animation/pose-hash.js +43 -0
  10. package/dist/archetype.d.ts +2 -2
  11. package/dist/assets/asset-loader.d.ts +28 -5
  12. package/dist/assets/asset-loader.js +63 -7
  13. package/dist/assets/model-backend.d.ts +21 -0
  14. package/dist/assets/model-backend.js +21 -0
  15. package/dist/assets/model-cache.d.ts +47 -0
  16. package/dist/assets/model-cache.js +117 -0
  17. package/dist/audio/audio-subsystem.d.ts +6 -1
  18. package/dist/audio/audio-subsystem.js +8 -15
  19. package/dist/audio/placement.d.ts +41 -0
  20. package/dist/audio/placement.js +21 -0
  21. package/dist/audio/spatial.d.ts +20 -0
  22. package/dist/audio/spatial.js +26 -0
  23. package/dist/audio/types.d.ts +7 -1
  24. package/dist/camera-projection.d.ts +38 -0
  25. package/dist/camera-projection.js +44 -0
  26. package/dist/camera-rig.d.ts +48 -0
  27. package/dist/camera-rig.js +98 -0
  28. package/dist/camera.d.ts +20 -3
  29. package/dist/camera.js +3 -1
  30. package/dist/component.d.ts +38 -2
  31. package/dist/component.js +3 -0
  32. package/dist/components/animated-sprite.d.ts +14 -3
  33. package/dist/components/animated-sprite.js +16 -2
  34. package/dist/components/animator.d.ts +98 -0
  35. package/dist/components/animator.js +197 -0
  36. package/dist/components/collider.d.ts +89 -0
  37. package/dist/components/collider.js +57 -0
  38. package/dist/components/dynamic-body.d.ts +2 -1
  39. package/dist/components/dynamic-body.js +1 -0
  40. package/dist/components/hitbox.d.ts +2 -1
  41. package/dist/components/hitbox.js +1 -0
  42. package/dist/components/light.d.ts +2 -1
  43. package/dist/components/light.js +1 -0
  44. package/dist/components/model.d.ts +71 -0
  45. package/dist/components/model.js +128 -0
  46. package/dist/components/particle-emitter.d.ts +2 -1
  47. package/dist/components/particle-emitter.js +1 -0
  48. package/dist/components/point-light.d.ts +75 -0
  49. package/dist/components/point-light.js +102 -0
  50. package/dist/components/rigid-body.d.ts +114 -0
  51. package/dist/components/rigid-body.js +83 -0
  52. package/dist/components/solid.d.ts +2 -1
  53. package/dist/components/solid.js +1 -0
  54. package/dist/components/sprite.d.ts +2 -1
  55. package/dist/components/sprite.js +1 -0
  56. package/dist/components/sun.d.ts +46 -0
  57. package/dist/components/sun.js +74 -0
  58. package/dist/components/tilemap.d.ts +2 -1
  59. package/dist/components/tilemap.js +1 -0
  60. package/dist/entity-transform.d.ts +18 -0
  61. package/dist/entity-transform.js +17 -0
  62. package/dist/game.d.ts +51 -15
  63. package/dist/game.js +83 -70
  64. package/dist/index.d.ts +34 -4
  65. package/dist/index.js +15 -1
  66. package/dist/lit-frame.js +10 -3
  67. package/dist/physics-3d/body-contacts.d.ts +10 -0
  68. package/dist/physics-3d/body-contacts.js +59 -0
  69. package/dist/physics-3d/body-sync.d.ts +52 -0
  70. package/dist/physics-3d/body-sync.js +153 -0
  71. package/dist/physics-3d/character-controller.d.ts +44 -0
  72. package/dist/physics-3d/character-controller.js +126 -0
  73. package/dist/physics-3d/physics-host.d.ts +86 -0
  74. package/dist/physics-3d/physics-host.js +174 -0
  75. package/dist/physics-3d/physics-world.d.ts +65 -0
  76. package/dist/physics-3d/physics-world.js +158 -0
  77. package/dist/physics-3d/rapier-module.d.ts +17 -0
  78. package/dist/physics-3d/rapier-module.js +19 -0
  79. package/dist/physics-3d/sensor-dispatch.d.ts +10 -0
  80. package/dist/physics-3d/sensor-dispatch.js +77 -0
  81. package/dist/physics-3d/world-queries.d.ts +39 -0
  82. package/dist/physics-3d/world-queries.js +63 -0
  83. package/dist/pointer-raycast.d.ts +17 -0
  84. package/dist/pointer-raycast.js +48 -0
  85. package/dist/pointer.d.ts +27 -6
  86. package/dist/pointer.js +43 -17
  87. package/dist/runtime-bridge.d.ts +1 -1
  88. package/dist/runtime-inspection.d.ts +9 -0
  89. package/dist/runtime-inspection.js +8 -2
  90. package/dist/runtime-lighting-snapshot.d.ts +27 -1
  91. package/dist/runtime-lighting-snapshot.js +31 -0
  92. package/dist/runtime-physics-snapshot.d.ts +41 -0
  93. package/dist/runtime-physics-snapshot.js +45 -0
  94. package/dist/runtime-view-snapshot.d.ts +25 -0
  95. package/dist/runtime-view-snapshot.js +30 -0
  96. package/dist/scene-camera-3d.d.ts +66 -0
  97. package/dist/scene-camera-3d.js +117 -0
  98. package/dist/scene-lights-3d.d.ts +17 -0
  99. package/dist/scene-lights-3d.js +27 -0
  100. package/dist/scene-simulation.d.ts +22 -0
  101. package/dist/scene-simulation.js +29 -0
  102. package/dist/scene-space.d.ts +50 -0
  103. package/dist/scene-space.js +103 -0
  104. package/dist/scene.d.ts +12 -2
  105. package/dist/scene.js +11 -4
  106. package/dist/simulation-phases.d.ts +9 -0
  107. package/dist/simulation-phases.js +19 -0
  108. package/dist/spatial-query-3d.d.ts +48 -0
  109. package/dist/spatial-query-3d.js +96 -0
  110. package/dist/spatial-query.d.ts +18 -4
  111. package/dist/spatial-query.js +57 -1
  112. package/dist/state/state-machine.d.ts +32 -8
  113. package/dist/state/state-machine.js +88 -27
  114. package/package.json +2 -1
@@ -0,0 +1,63 @@
1
+ const IDENTITY = { x: 0, y: 0, z: 0, w: 1 };
2
+ /**
3
+ * Rapier's predicate for a query: only colliders whose body record `accepts`.
4
+ * It runs inside Rapier's borrow of the world, so `accepts` must be a plain
5
+ * read of the record (the domain, liveness, the structural filter); game code
6
+ * such as a `where` predicate runs afterwards, on the collected records.
7
+ */
8
+ function accepting(world, accepts) {
9
+ return (collider) => {
10
+ const record = world.recordOfCollider(collider.handle);
11
+ return record !== undefined && accepts(record);
12
+ };
13
+ }
14
+ /**
15
+ * Every collider along a ray whose record `accepts`, nearest first.
16
+ * `direction` is a unit vector, so the distance is in world units; an origin
17
+ * inside a collider hits it at distance 0.
18
+ */
19
+ export function worldRayHits(world, ray, accepts) {
20
+ const { R, raw } = world;
21
+ const { origin, direction } = ray;
22
+ const hits = [];
23
+ raw.intersectionsWithRay(new R.Ray(origin, direction), ray.maxDistance, true, (hit) => {
24
+ const record = world.recordOfCollider(hit.collider.handle);
25
+ const distance = hit.timeOfImpact;
26
+ if (record) {
27
+ hits.push({
28
+ record,
29
+ distance,
30
+ point: { x: origin.x + direction.x * distance, y: origin.y + direction.y * distance, z: origin.z + direction.z * distance },
31
+ normal: { x: hit.normal.x, y: hit.normal.y, z: hit.normal.z },
32
+ });
33
+ }
34
+ return true;
35
+ }, undefined, undefined, undefined, undefined, accepting(world, accepts));
36
+ return hits.sort((a, b) => a.distance - b.distance);
37
+ }
38
+ /** Every record whose collider `accepts` and that intersects the volume. */
39
+ export function recordsInVolume(world, volume, accepts) {
40
+ const { R, raw } = world;
41
+ const shape = volume.shape === 'box'
42
+ ? new R.Cuboid(volume.size.x / 2, volume.size.y / 2, volume.size.z / 2)
43
+ : new R.Ball(volume.radius);
44
+ const found = [];
45
+ raw.intersectionsWithShape(volume.center, IDENTITY, shape, (collider) => {
46
+ const record = world.recordOfCollider(collider.handle);
47
+ if (record)
48
+ found.push(record);
49
+ return true;
50
+ }, undefined, undefined, undefined, undefined, accepting(world, accepts));
51
+ return found;
52
+ }
53
+ /** Every record whose collider `accepts` and that contains the point. */
54
+ export function recordsAtPoint(world, at, accepts) {
55
+ const found = [];
56
+ world.raw.intersectionsWithPoint(at, (collider) => {
57
+ const record = world.recordOfCollider(collider.handle);
58
+ if (record)
59
+ found.push(record);
60
+ return true;
61
+ }, undefined, undefined, undefined, undefined, accepting(world, accepts));
62
+ return found;
63
+ }
@@ -0,0 +1,17 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import type { Entity } from './entity.js';
3
+ import type { PointerPick } from './pointer.js';
4
+ /** A position inside the letterboxed game viewport, as fractions of its width and height. */
5
+ export interface ViewportFraction {
6
+ nx: number;
7
+ ny: number;
8
+ }
9
+ /**
10
+ * The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
11
+ * letterboxed point, intersected with the roots of every live Model
12
+ * (recursively, so a glTF's meshes count and a glTF still loading does not).
13
+ * The nearest hit wins, with its world-space point. With no hit the pick has
14
+ * no entity and the point where the ray meets the ground plane y = 0; a ray
15
+ * that never meets it (it looks at the sky) yields no pick.
16
+ */
17
+ export declare function raycastPick(camera: THREE.PerspectiveCamera, at: ViewportFraction, entities: readonly Entity[]): PointerPick | null;
@@ -0,0 +1,48 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { Model } from './components/model.js';
3
+ const raycaster = new THREE.Raycaster();
4
+ /** The ground of a 3D scene: y = 0, normal up. */
5
+ const GROUND = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
6
+ /** Every live Model's root, with the entity that owns it. */
7
+ function modelRoots(entities) {
8
+ const roots = new Map();
9
+ for (const entity of entities) {
10
+ if (!entity.alive)
11
+ continue;
12
+ for (const component of entity.components) {
13
+ if (component instanceof Model && component.root)
14
+ roots.set(component.root, entity);
15
+ }
16
+ }
17
+ return roots;
18
+ }
19
+ /** The entity whose Model root `object` hangs under. */
20
+ function ownerOf(object, roots) {
21
+ for (let node = object; node; node = node.parent) {
22
+ const entity = roots.get(node);
23
+ if (entity)
24
+ return entity;
25
+ }
26
+ return null;
27
+ }
28
+ /**
29
+ * The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
30
+ * letterboxed point, intersected with the roots of every live Model
31
+ * (recursively, so a glTF's meshes count and a glTF still loading does not).
32
+ * The nearest hit wins, with its world-space point. With no hit the pick has
33
+ * no entity and the point where the ray meets the ground plane y = 0; a ray
34
+ * that never meets it (it looks at the sky) yields no pick.
35
+ */
36
+ export function raycastPick(camera, at, entities) {
37
+ camera.updateMatrixWorld();
38
+ raycaster.setFromCamera(new THREE.Vector2(at.nx * 2 - 1, 1 - at.ny * 2), camera);
39
+ const roots = modelRoots(entities);
40
+ // A click can land before the first frame has placed anything.
41
+ for (const root of roots.keys())
42
+ root.updateWorldMatrix(true, true);
43
+ const [hit] = raycaster.intersectObjects([...roots.keys()], true);
44
+ if (hit)
45
+ return { entity: ownerOf(hit.object, roots), point: { x: hit.point.x, y: hit.point.y, z: hit.point.z } };
46
+ const ground = raycaster.ray.intersectPlane(GROUND, new THREE.Vector3());
47
+ return ground ? { entity: null, point: { x: ground.x, y: ground.y, z: ground.z } } : null;
48
+ }
package/dist/pointer.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ViewCamera } from './camera-projection.js';
1
2
  import type { Entity } from './entity.js';
2
3
  import { type ProjectedPoint } from './projection.js';
3
4
  /** Fixed-resolution letterbox target; a duck-typed subset of GameResolution. */
@@ -17,7 +18,8 @@ export interface PointerCamera {
17
18
  bottom: number;
18
19
  }
19
20
  export interface PointerDeps {
20
- camera: PointerCamera;
21
+ /** The live camera, or a function returning it when the scene can swap cameras. */
22
+ camera: ViewCamera | (() => ViewCamera);
21
23
  /** Fixed resolution (letterbox target), or null to fill the canvas. */
22
24
  resolution: PointerResolution | null;
23
25
  /** The scene's active render projection, read live. */
@@ -25,9 +27,14 @@ export interface PointerDeps {
25
27
  /** Live entity list — read at pick time, so later spawns are visible. */
26
28
  entities: readonly Entity[];
27
29
  }
28
- /** One resolved click: the logical-space point, plus the entity picked there (if any). */
30
+ /**
31
+ * One resolved click: the logical-space point, plus the entity picked there
32
+ * (if any). In a 3D scene the point is in world space and carries `z`.
33
+ */
29
34
  export interface PointerPick {
30
- readonly point: ProjectedPoint;
35
+ readonly point: ProjectedPoint & {
36
+ readonly z?: number;
37
+ };
31
38
  readonly entity: Entity | null;
32
39
  }
33
40
  /**
@@ -44,6 +51,7 @@ export declare class Pointer {
44
51
  private readonly canvas;
45
52
  private readonly deps;
46
53
  private pending;
54
+ private readonly liveCamera;
47
55
  constructor(canvas: HTMLCanvasElement, deps: PointerDeps);
48
56
  dispose(): void;
49
57
  /**
@@ -53,11 +61,24 @@ export declare class Pointer {
53
61
  */
54
62
  takePending(): PointerPick | null;
55
63
  /**
56
- * Programmatic click injection (Runtime Bridge CA-10), in logical
57
- * coordinates — resolved through the exact same picking a real click uses.
64
+ * Programmatic click injection (Runtime Bridge CA-10) — resolved through
65
+ * the exact same picking a real click uses. In a 2D scene `x` and `y` are
66
+ * logical coordinates and the pick always exists. In a 3D scene they are
67
+ * canvas CSS pixels from its top-left corner, as a pointer event reports
68
+ * them, and a ray that meets neither a model nor the ground gives null.
58
69
  */
59
- injectClick(x: number, y: number): PointerPick;
70
+ injectClick(x: number, y: number): PointerPick | null;
71
+ /** Makes a pick the pending click; no pick leaves any earlier click alone. */
72
+ private queue;
60
73
  private handlePointerDown;
74
+ /** The pick under a viewport position: the 2D sprite boxes, or a 3D scene's raycast. */
75
+ private pickAt;
76
+ /**
77
+ * Where a canvas point (CSS px from its top-left corner) falls inside the
78
+ * letterboxed viewport, as fractions of its width and height; null on a
79
+ * letterbox bar (not a click on the world) or before the canvas has a size.
80
+ */
81
+ private viewportFraction;
61
82
  private letterboxRect;
62
83
  private toRenderPoint;
63
84
  private resolveRenderPoint;
package/dist/pointer.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { isPerspectiveCamera } from './camera-projection.js';
2
+ import { raycastPick } from './pointer-raycast.js';
1
3
  import { projectIsometric, unprojectIsometric } from './projection.js';
2
4
  import { ySortZ } from './render-sort.js';
3
5
  import { AnimatedSprite } from './components/animated-sprite.js';
@@ -30,9 +32,12 @@ export class Pointer {
30
32
  canvas;
31
33
  deps;
32
34
  pending = null;
35
+ liveCamera;
33
36
  constructor(canvas, deps) {
34
37
  this.canvas = canvas;
35
38
  this.deps = deps;
39
+ const { camera } = this.deps;
40
+ this.liveCamera = typeof camera === 'function' ? camera : () => camera;
36
41
  this.canvas.addEventListener('pointerdown', this.handlePointerDown);
37
42
  }
38
43
  dispose() {
@@ -50,39 +55,60 @@ export class Pointer {
50
55
  return pick;
51
56
  }
52
57
  /**
53
- * Programmatic click injection (Runtime Bridge CA-10), in logical
54
- * coordinates — resolved through the exact same picking a real click uses.
58
+ * Programmatic click injection (Runtime Bridge CA-10) — resolved through
59
+ * the exact same picking a real click uses. In a 2D scene `x` and `y` are
60
+ * logical coordinates and the pick always exists. In a 3D scene they are
61
+ * canvas CSS pixels from its top-left corner, as a pointer event reports
62
+ * them, and a ray that meets neither a model nor the ground gives null.
55
63
  */
56
64
  injectClick(x, y) {
65
+ const camera = this.liveCamera();
66
+ if (isPerspectiveCamera(camera))
67
+ return this.queue(this.pickAt(this.viewportFraction(x, y)));
57
68
  const renderPoint = this.toRenderPoint(x, y);
58
- const pick = this.resolveRenderPoint(renderPoint.x, renderPoint.y);
59
- this.pending = pick;
69
+ return this.queue(this.resolveRenderPoint(renderPoint.x, renderPoint.y));
70
+ }
71
+ /** Makes a pick the pending click; no pick leaves any earlier click alone. */
72
+ queue(pick) {
73
+ if (pick)
74
+ this.pending = pick;
60
75
  return pick;
61
76
  }
62
77
  handlePointerDown = (event) => {
63
78
  // Primary button only; a touch tap reports the same button (ADR-0010).
64
79
  if (event.button !== 0)
65
80
  return;
81
+ const rect = this.canvas.getBoundingClientRect();
82
+ this.queue(this.pickAt(this.viewportFraction(event.clientX - rect.left, event.clientY - rect.top)));
83
+ };
84
+ /** The pick under a viewport position: the 2D sprite boxes, or a 3D scene's raycast. */
85
+ pickAt(at) {
86
+ const camera = this.liveCamera();
87
+ if (!at)
88
+ return null;
89
+ if (isPerspectiveCamera(camera))
90
+ return raycastPick(camera, at, this.deps.entities);
91
+ const renderX = camera.position.x + camera.left + at.nx * (camera.right - camera.left);
92
+ const renderY = camera.position.y + camera.top - at.ny * (camera.top - camera.bottom);
93
+ return this.resolveRenderPoint(renderX, renderY);
94
+ }
95
+ /**
96
+ * Where a canvas point (CSS px from its top-left corner) falls inside the
97
+ * letterboxed viewport, as fractions of its width and height; null on a
98
+ * letterbox bar (not a click on the world) or before the canvas has a size.
99
+ */
100
+ viewportFraction(px, py) {
66
101
  const w = this.canvas.clientWidth;
67
102
  const h = this.canvas.clientHeight;
68
103
  if (w <= 0 || h <= 0)
69
- return;
104
+ return null;
70
105
  const { vx, vy, vw, vh } = this.letterboxRect(w, h);
71
106
  if (vw <= 0 || vh <= 0)
72
- return;
73
- const rect = this.canvas.getBoundingClientRect();
74
- const px = event.clientX - rect.left;
75
- const py = event.clientY - rect.top;
107
+ return null;
76
108
  const nx = (px - vx) / vw;
77
109
  const ny = (py - vy) / vh;
78
- // Outside the visible viewport (a letterbox bar): not a click on the world.
79
- if (nx < 0 || nx > 1 || ny < 0 || ny > 1)
80
- return;
81
- const camera = this.deps.camera;
82
- const renderX = camera.position.x + camera.left + nx * (camera.right - camera.left);
83
- const renderY = camera.position.y + camera.top - ny * (camera.top - camera.bottom);
84
- this.pending = this.resolveRenderPoint(renderX, renderY);
85
- };
110
+ return nx < 0 || nx > 1 || ny < 0 || ny > 1 ? null : { nx, ny };
111
+ }
86
112
  letterboxRect(w, h) {
87
113
  const resolution = this.deps.resolution;
88
114
  if (!resolution)
@@ -85,7 +85,7 @@ export interface RuntimeBridge {
85
85
  }
86
86
  /** Why a Game can never become operational; reported through the activation. */
87
87
  export interface RuntimeBridgeFailure {
88
- code: 'render-backend-failed';
88
+ code: 'render-backend-failed' | 'physics-backend-failed';
89
89
  message: string;
90
90
  }
91
91
  /** Ephemeral pre-page hook installed by the owner of a browser context. */
@@ -1,9 +1,12 @@
1
1
  import type { AudioChannelState, LiveSoundInfo } from './audio/types.js';
2
2
  import type { CameraEffectsState } from './camera-effects.js';
3
+ import type { SceneSpace } from './scene-space.js';
4
+ import { type RuntimeSnapshotView } from './runtime-view-snapshot.js';
3
5
  import type { Game } from './game.js';
4
6
  import type { RuntimeMetadata } from './runtime-bridge.js';
5
7
  import type { StatValue } from './stats.js';
6
8
  import { type RuntimeSnapshotLighting, type RuntimeSnapshotPost } from './runtime-lighting-snapshot.js';
9
+ import { type RuntimeSnapshotPhysics } from './runtime-physics-snapshot.js';
7
10
  export type ProjectionMarkerKind = 'cycle' | 'unsupported' | 'error' | 'truncated';
8
11
  export interface ProjectionMarker {
9
12
  $waica: ProjectionMarkerKind | 'date' | 'bigint' | 'map' | 'set';
@@ -125,10 +128,16 @@ export interface RuntimeSnapshot extends RuntimeMetadata {
125
128
  time: RuntimeSnapshotTime;
126
129
  ui: RuntimeSnapshotUi;
127
130
  camera: RuntimeSnapshotCamera;
131
+ /** The live scene's space (`render.space`, ADR 0027); `'2d'` with no scene. Never filtered. */
132
+ space: SceneSpace;
133
+ /** The view the scene is drawn from: the orthographic centre and zoom, or the perspective pose (issue #154 CA-16). Never filtered. */
134
+ view: RuntimeSnapshotView;
128
135
  /** Ambient Light and live Lights (issue #78 CA-12), logical coordinates; never filtered. */
129
136
  lighting: RuntimeSnapshotLighting;
130
137
  /** Post Effects (issue #78 CA-12), each null when off; never filtered. */
131
138
  post: RuntimeSnapshotPost;
139
+ /** The live 3D scene's physics world: state, gravity and bodies (issue #159 CA-17); absent in a 2D scene, never filtered. */
140
+ physics?: RuntimeSnapshotPhysics;
132
141
  }
133
142
  export declare const RUNTIME_PROJECTION_LIMITS: {
134
143
  readonly depth: 5;
@@ -1,6 +1,8 @@
1
+ import { cameraEffectsSnapshot, viewSnapshot } from './runtime-view-snapshot.js';
1
2
  import { anchoredPiecesOf } from './ui.js';
2
3
  import { componentClassOf } from './component-registry.js';
3
4
  import { capLights, lightingSnapshot, postSnapshot, } from './runtime-lighting-snapshot.js';
5
+ import { capBodies, physicsSection } from './runtime-physics-snapshot.js';
4
6
  export const RUNTIME_PROJECTION_LIMITS = {
5
7
  depth: 5,
6
8
  entries: 100,
@@ -293,9 +295,12 @@ export class RuntimeInspector {
293
295
  audio: this.audioSnapshot(),
294
296
  time: this.timeSnapshot(),
295
297
  ui: this.uiSnapshot(projectionIssues),
296
- camera: this.game.cameraEffects.state,
298
+ camera: cameraEffectsSnapshot(this.game),
299
+ space: this.game.space,
300
+ view: viewSnapshot(this.game),
297
301
  lighting: lightingSnapshot(this.game, (entity) => this.idFor(entity)),
298
302
  post: postSnapshot(this.game),
303
+ ...physicsSection(this.game, (entity) => this.idFor(entity)),
299
304
  });
300
305
  }
301
306
  audioSnapshot() {
@@ -345,7 +350,8 @@ export class RuntimeInspector {
345
350
  return capped;
346
351
  }
347
352
  const anchoredCapped = capAnchored(capped);
348
- return fitsSnapshot(anchoredCapped) ? anchoredCapped : capLights(anchoredCapped, fitsSnapshot);
353
+ const bodiesCapped = capBodies(anchoredCapped, fitsSnapshot);
354
+ return fitsSnapshot(bodiesCapped) ? bodiesCapped : capLights(bodiesCapped, fitsSnapshot);
349
355
  }
350
356
  idFor(entity) {
351
357
  const existing = this.ids.get(entity);
@@ -1,6 +1,7 @@
1
1
  import type { Entity } from './entity.js';
2
2
  import type { Game } from './game.js';
3
3
  import type { ProjectionIssue } from './runtime-inspection.js';
4
+ import type { Vec3Json } from './scene-camera-3d.js';
4
5
  import type { AmbientLight, PostEffectsState } from './scene-render-options.js';
5
6
  /** One live Light as a Runtime Snapshot reports it: its entity's name, logical position and params. */
6
7
  export interface RuntimeSnapshotLight {
@@ -18,10 +19,35 @@ export interface RuntimeSnapshotLight {
18
19
  softness: number;
19
20
  castShadows: boolean;
20
21
  }
21
- /** `game.lighting` (issue #78 CA-12): the Ambient Light now and every live Light, in logical coordinates. */
22
+ /** One live Sun in a 3D scene (issue #154 CA-16): its entity's name and id, the unit direction it shines along, color and intensity. */
23
+ export interface RuntimeSnapshotSun {
24
+ entity: string;
25
+ id: string;
26
+ direction: Vec3Json;
27
+ /** `#rrggbb`. */
28
+ color: string;
29
+ intensity: number;
30
+ }
31
+ /** One live Point Light in a 3D scene (issue #154 CA-16): its world position and look; `distance` 0 is unlimited. */
32
+ export interface RuntimeSnapshotPointLight {
33
+ entity: string;
34
+ id: string;
35
+ position: Vec3Json;
36
+ /** `#rrggbb`. */
37
+ color: string;
38
+ intensity: number;
39
+ distance: number;
40
+ }
41
+ /**
42
+ * `game.lighting` (issue #78 CA-12): the Ambient Light now and every live
43
+ * Light, in logical coordinates. A 3D scene (issue #154 CA-16) adds its
44
+ * Suns and Point Lights; a 2D scene's snapshot has neither key.
45
+ */
22
46
  export interface RuntimeSnapshotLighting {
23
47
  ambient: AmbientLight;
24
48
  lights: RuntimeSnapshotLight[];
49
+ sun?: RuntimeSnapshotSun[];
50
+ pointLights?: RuntimeSnapshotPointLight[];
25
51
  }
26
52
  /** `game.post` (issue #78 CA-12): each Post Effect, null when off. */
27
53
  export type RuntimeSnapshotPost = PostEffectsState;
@@ -1,6 +1,37 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { PointLight } from './components/point-light.js';
3
+ import { Sun } from './components/sun.js';
1
4
  const hex = (channel) => Math.round(channel * 255).toString(16).padStart(2, '0');
5
+ const rounded = (value) => Math.round(value * 1e6) / 1e6 || 0;
6
+ const hexOf = (color) => `#${(color & 0xffffff).toString(16).padStart(6, '0')}`;
7
+ function unit(direction) {
8
+ const [x, y, z] = new THREE.Vector3(...direction).normalize().toArray();
9
+ return [rounded(x), rounded(y), rounded(z)];
10
+ }
11
+ /** The Suns and Point Lights of a 3D scene's entities, in spawn order. */
12
+ function lights3dSnapshot(game, idFor) {
13
+ const sun = [];
14
+ const pointLights = [];
15
+ for (const entity of game.entities) {
16
+ for (const component of entity.components) {
17
+ if (component instanceof Sun) {
18
+ const { direction, color, intensity } = component;
19
+ sun.push({ entity: entity.name, id: idFor(entity), direction: unit(direction), color: hexOf(color), intensity });
20
+ }
21
+ else if (component instanceof PointLight && component.light) {
22
+ const { color, intensity, distance, light } = component;
23
+ light.updateWorldMatrix(true, false);
24
+ const at = light.getWorldPosition(new THREE.Vector3());
25
+ const position = [rounded(at.x), rounded(at.y), rounded(at.z)];
26
+ pointLights.push({ entity: entity.name, id: idFor(entity), position, color: hexOf(color), intensity, distance });
27
+ }
28
+ }
29
+ }
30
+ return { sun, pointLights };
31
+ }
2
32
  export function lightingSnapshot(game, idFor) {
3
33
  return {
34
+ ...(game.space === '3d' ? lights3dSnapshot(game, idFor) : {}),
4
35
  ambient: game.lighting.ambient,
5
36
  lights: game.lighting.lights.map((light) => {
6
37
  const field = light.field();
@@ -0,0 +1,41 @@
1
+ import type { Entity } from './entity.js';
2
+ import type { Game } from './game.js';
3
+ import { type BodyKind } from './physics-3d/body-sync.js';
4
+ import type { PhysicsState } from './physics-3d/physics-host.js';
5
+ import type { ProjectionIssue } from './runtime-inspection.js';
6
+ import type { Vec3Json } from './scene-camera-3d.js';
7
+ /** One live body of a 3D scene (issue #159 CA-17): its entity's name and snapshot id, its Rapier type, velocity and whether it stands on something. */
8
+ export interface RuntimeSnapshotBody {
9
+ entity: string;
10
+ id: string;
11
+ type: BodyKind;
12
+ velocity: Vec3Json;
13
+ grounded: boolean;
14
+ }
15
+ /**
16
+ * `game.physics` of a 3D scene (issue #159 CA-17), beside `lighting`: the
17
+ * world's state (`loading` until the Rapier module arrives), its gravity and
18
+ * every body in spawn order, rounded to 1e-6. Never filtered by entity or
19
+ * component filters; a 2D scene's snapshot has no `physics` key.
20
+ */
21
+ export interface RuntimeSnapshotPhysics {
22
+ state: PhysicsState;
23
+ gravity: Vec3Json;
24
+ bodies: RuntimeSnapshotBody[];
25
+ }
26
+ /** The `physics` section of a 3D scene's snapshot, as the spread that adds it; nothing in a 2D scene. */
27
+ export declare function physicsSection(game: Game, idFor: (entity: Entity) => string): {
28
+ physics?: RuntimeSnapshotPhysics;
29
+ };
30
+ /** What capping the bodies needs from a snapshot: its physics section and its projection issues. */
31
+ interface BodiesCappable {
32
+ physics?: RuntimeSnapshotPhysics;
33
+ projectionIssues: ProjectionIssue[];
34
+ }
35
+ /**
36
+ * The global cap's stage for bodies, beside the lights' (`capLights`): drops
37
+ * bodies from the end until the snapshot fits, recording how many went with a
38
+ * `truncated` marker at `physics.bodies[n]`.
39
+ */
40
+ export declare function capBodies<T extends BodiesCappable>(snapshot: T, fits: (candidate: T) => boolean): T;
41
+ export {};
@@ -0,0 +1,45 @@
1
+ import { bodyVelocity } from './physics-3d/body-sync.js';
2
+ const rounded = (value) => Math.round(value * 1e6) / 1e6 || 0;
3
+ const roundedVector = (x, y, z) => [rounded(x), rounded(y), rounded(z)];
4
+ /** The `physics` section of a 3D scene's snapshot, as the spread that adds it; nothing in a 2D scene. */
5
+ export function physicsSection(game, idFor) {
6
+ if (game.space !== '3d')
7
+ return {};
8
+ const world = game.physics.world;
9
+ const [gx, gy, gz] = game.physics.sceneGravity;
10
+ const bodies = (world?.bodiesOf(game.entities) ?? []).map((record) => {
11
+ const { x, y, z } = bodyVelocity(record);
12
+ return {
13
+ entity: record.entity.name,
14
+ id: idFor(record.entity),
15
+ type: record.kind,
16
+ velocity: roundedVector(x, y, z),
17
+ grounded: record.grounded,
18
+ };
19
+ });
20
+ return { physics: { state: game.physics.state, gravity: roundedVector(gx, gy, gz), bodies } };
21
+ }
22
+ /**
23
+ * The global cap's stage for bodies, beside the lights' (`capLights`): drops
24
+ * bodies from the end until the snapshot fits, recording how many went with a
25
+ * `truncated` marker at `physics.bodies[n]`.
26
+ */
27
+ export function capBodies(snapshot, fits) {
28
+ const physics = snapshot.physics;
29
+ if (!physics || fits(snapshot))
30
+ return snapshot;
31
+ let capped = snapshot;
32
+ const retained = [...physics.bodies];
33
+ while (retained.length > 0) {
34
+ retained.pop();
35
+ const omitted = physics.bodies.length - retained.length;
36
+ capped = {
37
+ ...snapshot,
38
+ physics: { ...physics, bodies: [...retained] },
39
+ projectionIssues: [...snapshot.projectionIssues, { path: `physics.bodies[${retained.length}]`, marker: 'truncated', omitted }],
40
+ };
41
+ if (fits(capped))
42
+ return capped;
43
+ }
44
+ return capped;
45
+ }
@@ -0,0 +1,25 @@
1
+ import type { CameraEffectsState } from './camera-effects.js';
2
+ import type { Game } from './game.js';
3
+ import type { Vec3Json } from './scene-camera-3d.js';
4
+ /**
5
+ * The view the scene is drawn from (issue #154 CA-16), beside the Camera
6
+ * Effects `camera`: the orthographic camera's centre and visible height, or
7
+ * the perspective camera's pose. Always reflects the live camera.
8
+ */
9
+ export type RuntimeSnapshotView = {
10
+ kind: 'orthographic';
11
+ position: [number, number];
12
+ zoom: number;
13
+ } | {
14
+ kind: 'perspective';
15
+ position: Vec3Json;
16
+ target: Vec3Json;
17
+ fov: number;
18
+ };
19
+ 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;
@@ -0,0 +1,30 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { isPerspectiveCamera } from './camera-projection.js';
3
+ /** Float noise off a pose, so a camera that did not move reports the numbers it was declared with. */
4
+ const clean = (value) => Math.round(value * 1e6) / 1e6 || 0;
5
+ function vector(value) {
6
+ return [clean(value.x), clean(value.y), clean(value.z)];
7
+ }
8
+ export function viewSnapshot(game) {
9
+ const camera = game.camera;
10
+ if (!isPerspectiveCamera(camera)) {
11
+ return { kind: 'orthographic', position: [clean(camera.position.x), clean(camera.position.y)], zoom: game.view };
12
+ }
13
+ camera.updateMatrixWorld();
14
+ const ahead = camera.getWorldDirection(new THREE.Vector3()).multiplyScalar(game.cameraRig.lookDistance);
15
+ return {
16
+ kind: 'perspective',
17
+ position: vector(camera.position),
18
+ target: vector(camera.position.clone().add(ahead)),
19
+ fov: camera.fov,
20
+ };
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;
30
+ }
@@ -0,0 +1,66 @@
1
+ import type * as THREE from 'three/webgpu';
2
+ /** A world-space point or direction as scene JSON stores it. */
3
+ export type Vec3Json = [number, number, number];
4
+ /**
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
+ */
8
+ export interface PerspectiveSceneCameraJson {
9
+ kind: 'perspective';
10
+ /** Where the camera sits. Default `[0, 5, 10]`. */
11
+ position?: Vec3Json;
12
+ /** The point it looks at. Default `[0, 0, 0]`. */
13
+ target?: Vec3Json;
14
+ /** Vertical field of view in degrees, in (0, 180). Default 60. */
15
+ fov?: number;
16
+ /** Near clipping plane, above 0. Default 0.1. */
17
+ near?: number;
18
+ /** Far clipping plane, above `near`. Default 1000. */
19
+ far?: number;
20
+ zoom?: never;
21
+ follow?: never;
22
+ deadzoneWidth?: never;
23
+ deadzoneHeight?: never;
24
+ lookahead?: never;
25
+ lookaheadY?: never;
26
+ smoothing?: never;
27
+ limits?: never;
28
+ }
29
+ export interface ResolvedPerspectiveCamera {
30
+ kind: 'perspective';
31
+ position: Vec3Json;
32
+ target: Vec3Json;
33
+ fov: number;
34
+ near: number;
35
+ far: number;
36
+ }
37
+ export declare const PERSPECTIVE_DEFAULTS: {
38
+ readonly position: Vec3Json;
39
+ readonly target: Vec3Json;
40
+ readonly fov: 60;
41
+ readonly near: 0.1;
42
+ readonly far: 1000;
43
+ };
44
+ /** True for a perspective camera block; everything else (or none) is the orthographic one. */
45
+ export declare function isPerspectiveCameraJson(json: {
46
+ kind?: string;
47
+ } | undefined): json is PerspectiveSceneCameraJson;
48
+ /** Fills a perspective camera block with the engine defaults. */
49
+ export declare function resolvePerspectiveCamera(json?: PerspectiveSceneCameraJson): ResolvedPerspectiveCamera;
50
+ /** Points `camera` as the resolved block says, at the given aspect ratio. */
51
+ 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;
60
+ /**
61
+ * 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.
65
+ */
66
+ export declare function perspectiveCameraIssues(camera: unknown): SceneFieldIssue[];