@waica/engine 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +79 -1
  2. package/dist/anchored-pieces.d.ts +3 -2
  3. package/dist/anchored-pieces.js +12 -8
  4. package/dist/archetype.d.ts +2 -2
  5. package/dist/assets/asset-loader.d.ts +28 -5
  6. package/dist/assets/asset-loader.js +63 -7
  7. package/dist/assets/model-backend.d.ts +21 -0
  8. package/dist/assets/model-backend.js +21 -0
  9. package/dist/assets/model-cache.d.ts +47 -0
  10. package/dist/assets/model-cache.js +117 -0
  11. package/dist/audio/audio-subsystem.d.ts +6 -1
  12. package/dist/audio/audio-subsystem.js +8 -15
  13. package/dist/audio/placement.d.ts +41 -0
  14. package/dist/audio/placement.js +21 -0
  15. package/dist/audio/spatial.d.ts +20 -0
  16. package/dist/audio/spatial.js +26 -0
  17. package/dist/audio/types.d.ts +7 -1
  18. package/dist/camera-projection.d.ts +38 -0
  19. package/dist/camera-projection.js +44 -0
  20. package/dist/camera-rig.d.ts +48 -0
  21. package/dist/camera-rig.js +98 -0
  22. package/dist/camera.d.ts +20 -3
  23. package/dist/camera.js +3 -1
  24. package/dist/component.d.ts +38 -2
  25. package/dist/component.js +3 -0
  26. package/dist/components/animated-sprite.d.ts +8 -1
  27. package/dist/components/animated-sprite.js +16 -1
  28. package/dist/components/collider.d.ts +89 -0
  29. package/dist/components/collider.js +57 -0
  30. package/dist/components/dynamic-body.d.ts +2 -1
  31. package/dist/components/dynamic-body.js +1 -0
  32. package/dist/components/hitbox.d.ts +2 -1
  33. package/dist/components/hitbox.js +1 -0
  34. package/dist/components/light.d.ts +69 -0
  35. package/dist/components/light.js +60 -0
  36. package/dist/components/model.d.ts +63 -0
  37. package/dist/components/model.js +117 -0
  38. package/dist/components/particle-emitter.d.ts +8 -1
  39. package/dist/components/particle-emitter.js +14 -0
  40. package/dist/components/point-light.d.ts +75 -0
  41. package/dist/components/point-light.js +102 -0
  42. package/dist/components/rigid-body.d.ts +114 -0
  43. package/dist/components/rigid-body.js +83 -0
  44. package/dist/components/solid.d.ts +2 -1
  45. package/dist/components/solid.js +1 -0
  46. package/dist/components/sprite.d.ts +8 -1
  47. package/dist/components/sprite.js +16 -1
  48. package/dist/components/sun.d.ts +46 -0
  49. package/dist/components/sun.js +74 -0
  50. package/dist/components/tilemap.d.ts +2 -1
  51. package/dist/components/tilemap.js +7 -0
  52. package/dist/entity-transform.d.ts +18 -0
  53. package/dist/entity-transform.js +17 -0
  54. package/dist/frame-quad.d.ts +21 -0
  55. package/dist/frame-quad.js +50 -0
  56. package/dist/game.d.ts +59 -15
  57. package/dist/game.js +100 -75
  58. package/dist/index.d.ts +39 -4
  59. package/dist/index.js +18 -1
  60. package/dist/light-field.d.ts +78 -0
  61. package/dist/light-field.js +145 -0
  62. package/dist/light-map-material.d.ts +40 -0
  63. package/dist/light-map-material.js +203 -0
  64. package/dist/light-map.d.ts +46 -0
  65. package/dist/light-map.js +132 -0
  66. package/dist/lit-frame.d.ts +48 -0
  67. package/dist/lit-frame.js +174 -0
  68. package/dist/occluder-grid.d.ts +19 -0
  69. package/dist/occluder-grid.js +50 -0
  70. package/dist/particle-batch.js +3 -2
  71. package/dist/physics-3d/body-contacts.d.ts +10 -0
  72. package/dist/physics-3d/body-contacts.js +59 -0
  73. package/dist/physics-3d/body-sync.d.ts +52 -0
  74. package/dist/physics-3d/body-sync.js +153 -0
  75. package/dist/physics-3d/character-controller.d.ts +44 -0
  76. package/dist/physics-3d/character-controller.js +126 -0
  77. package/dist/physics-3d/physics-host.d.ts +86 -0
  78. package/dist/physics-3d/physics-host.js +174 -0
  79. package/dist/physics-3d/physics-world.d.ts +65 -0
  80. package/dist/physics-3d/physics-world.js +158 -0
  81. package/dist/physics-3d/rapier-module.d.ts +17 -0
  82. package/dist/physics-3d/rapier-module.js +19 -0
  83. package/dist/physics-3d/sensor-dispatch.d.ts +10 -0
  84. package/dist/physics-3d/sensor-dispatch.js +77 -0
  85. package/dist/physics-3d/world-queries.d.ts +39 -0
  86. package/dist/physics-3d/world-queries.js +63 -0
  87. package/dist/pointer-raycast.d.ts +17 -0
  88. package/dist/pointer-raycast.js +48 -0
  89. package/dist/pointer.d.ts +27 -6
  90. package/dist/pointer.js +43 -17
  91. package/dist/post-effects.d.ts +33 -0
  92. package/dist/post-effects.js +48 -0
  93. package/dist/post-pass.d.ts +23 -0
  94. package/dist/post-pass.js +93 -0
  95. package/dist/render-layers.d.ts +18 -0
  96. package/dist/render-layers.js +19 -0
  97. package/dist/render-output.d.ts +7 -0
  98. package/dist/render-output.js +17 -1
  99. package/dist/runtime-bridge.d.ts +1 -1
  100. package/dist/runtime-inspection.d.ts +14 -0
  101. package/dist/runtime-inspection.js +12 -2
  102. package/dist/runtime-lighting-snapshot.d.ts +67 -0
  103. package/dist/runtime-lighting-snapshot.js +77 -0
  104. package/dist/runtime-physics-snapshot.d.ts +41 -0
  105. package/dist/runtime-physics-snapshot.js +45 -0
  106. package/dist/runtime-view-snapshot.d.ts +25 -0
  107. package/dist/runtime-view-snapshot.js +30 -0
  108. package/dist/scene-camera-3d.d.ts +66 -0
  109. package/dist/scene-camera-3d.js +117 -0
  110. package/dist/scene-lighting.d.ts +52 -0
  111. package/dist/scene-lighting.js +107 -0
  112. package/dist/scene-lights-3d.d.ts +17 -0
  113. package/dist/scene-lights-3d.js +27 -0
  114. package/dist/scene-render-options.d.ts +51 -0
  115. package/dist/scene-render-options.js +126 -0
  116. package/dist/scene-simulation.d.ts +22 -0
  117. package/dist/scene-simulation.js +29 -0
  118. package/dist/scene-space.d.ts +50 -0
  119. package/dist/scene-space.js +103 -0
  120. package/dist/scene.d.ts +41 -2
  121. package/dist/scene.js +11 -4
  122. package/dist/simulation-phases.d.ts +9 -0
  123. package/dist/simulation-phases.js +19 -0
  124. package/dist/spatial-query-3d.d.ts +48 -0
  125. package/dist/spatial-query-3d.js +96 -0
  126. package/dist/spatial-query.d.ts +18 -4
  127. package/dist/spatial-query.js +57 -1
  128. package/dist/sprite-batch.js +5 -1
  129. package/package.json +2 -1
@@ -114,10 +114,15 @@ export declare class AudioSubsystem {
114
114
  updatePlacements(listener: {
115
115
  x: number;
116
116
  y: number;
117
+ z?: number;
117
118
  }, toRenderSpace: (x: number, y: number) => {
118
119
  x: number;
119
120
  y: number;
120
- }): void;
121
+ }, panOf3d?: (source: {
122
+ x: number;
123
+ y: number;
124
+ z: number;
125
+ }) => number): void;
121
126
  /** Stops every live sound — including session-scoped ones — and closes the backend (CA-10). */
122
127
  dispose(): void;
123
128
  private ensureChannel;
@@ -1,12 +1,12 @@
1
1
  import { Entity } from '../entity.js';
2
- import { attenuationForDistance, panForOffset } from './spatial.js';
2
+ import { placementMix, placementView } from './placement.js';
3
3
  import { WebAudioBackend } from './web-audio-backend.js';
4
4
  function resolvePlacement(at) {
5
5
  if (!at)
6
6
  return null;
7
7
  if (at instanceof Entity)
8
8
  return { kind: 'entity', entity: at };
9
- return { kind: 'point', x: at.x, y: at.y };
9
+ return { kind: 'point', x: at.x, y: at.y, z: at.z ?? 0 };
10
10
  }
11
11
  const FACTORY_CHANNELS = ['music', 'sfx'];
12
12
  /**
@@ -180,23 +180,16 @@ export class AudioSubsystem {
180
180
  * reads to the right on screen pans right regardless of its logical
181
181
  * distance. A sound with no placement (a flat sound) is never touched.
182
182
  */
183
- updatePlacements(listener, toRenderSpace) {
183
+ updatePlacements(listener, toRenderSpace, panOf3d) {
184
184
  if (this.live.size === 0)
185
185
  return;
186
- let listenerRender = null;
186
+ const view = placementView(listener, toRenderSpace, panOf3d);
187
187
  for (const sound of this.live) {
188
- if (sound.fading)
188
+ if (sound.fading || !sound.placement)
189
189
  continue;
190
- const placement = sound.placement;
191
- if (!placement)
192
- continue;
193
- const source = placement.kind === 'entity'
194
- ? { x: placement.entity.position.x, y: placement.entity.position.y }
195
- : placement;
196
- sound.attenuation = attenuationForDistance(Math.hypot(source.x - listener.x, source.y - listener.y));
197
- listenerRender ??= toRenderSpace(listener.x, listener.y);
198
- const sourceRender = toRenderSpace(source.x, source.y);
199
- sound.pan = panForOffset(sourceRender.x - listenerRender.x);
190
+ const mix = placementMix(sound.placement, view);
191
+ sound.attenuation = mix.attenuation;
192
+ sound.pan = mix.pan;
200
193
  sound.backendHandle?.setVolume(sound.volume * sound.attenuation);
201
194
  sound.backendHandle?.setPan(sound.pan);
202
195
  }
@@ -0,0 +1,41 @@
1
+ import type { Entity } from '../entity.js';
2
+ /** Where a positional sound's placement comes from (CA-8): a tracked entity, or a fixed point. */
3
+ export type SoundPlacement = {
4
+ kind: 'entity';
5
+ entity: Entity;
6
+ } | {
7
+ kind: 'point';
8
+ x: number;
9
+ y: number;
10
+ z: number;
11
+ };
12
+ /** What one update pass places sounds against: the listener and how to pan. */
13
+ interface PlacementView {
14
+ listener: {
15
+ x: number;
16
+ y: number;
17
+ z?: number;
18
+ };
19
+ /** The listener in render space, converted once per pass and only when a 2D sound needs it. */
20
+ listenerRender(): {
21
+ x: number;
22
+ y: number;
23
+ };
24
+ toRenderSpace(x: number, y: number): {
25
+ x: number;
26
+ y: number;
27
+ };
28
+ /** A 3D scene's pan from where a point lands on screen; absent in a 2D scene. */
29
+ panOf3d?: (source: {
30
+ x: number;
31
+ y: number;
32
+ z: number;
33
+ }) => number;
34
+ }
35
+ export declare function placementView(listener: PlacementView['listener'], toRenderSpace: PlacementView['toRenderSpace'], panOf3d: PlacementView['panOf3d']): PlacementView;
36
+ /** The attenuation and pan one placed sound has this frame. */
37
+ export declare function placementMix(placement: SoundPlacement, view: PlacementView): {
38
+ attenuation: number;
39
+ pan: number;
40
+ };
41
+ export {};
@@ -0,0 +1,21 @@
1
+ import { attenuationForDistance, panForOffset } from './spatial.js';
2
+ export function placementView(listener, toRenderSpace, panOf3d) {
3
+ let converted = null;
4
+ return {
5
+ listener,
6
+ toRenderSpace,
7
+ panOf3d,
8
+ listenerRender: () => (converted ??= toRenderSpace(listener.x, listener.y)),
9
+ };
10
+ }
11
+ /** The attenuation and pan one placed sound has this frame. */
12
+ export function placementMix(placement, view) {
13
+ const source = placement.kind === 'entity' ? placement.entity.position : placement;
14
+ const { listener } = view;
15
+ // Only a listener with a z (a 3D scene's camera) measures depth.
16
+ const depth = listener.z === undefined ? 0 : source.z - listener.z;
17
+ const attenuation = attenuationForDistance(Math.hypot(source.x - listener.x, source.y - listener.y, depth));
18
+ if (view.panOf3d)
19
+ return { attenuation, pan: view.panOf3d(source) };
20
+ return { attenuation, pan: panForOffset(view.toRenderSpace(source.x, source.y).x - view.listenerRender().x) };
21
+ }
@@ -1,3 +1,5 @@
1
+ import { type GameCamera, type ViewCamera, type WorldPoint } from '../camera-projection.js';
2
+ import type { SceneSpace } from '../scene-space.js';
1
3
  /**
2
4
  * Engine constants for CA-8 positional audio. There are no public tuning
3
5
  * knobs (spec decision 20): the curve is picked once, in the spirit of
@@ -20,3 +22,21 @@ export declare const AUDIO_SPATIAL_DEFAULTS: {
20
22
  export declare function attenuationForDistance(distance: number): number;
21
23
  /** Maps a render-space horizontal offset (source minus listener) to a [-1, 1] stereo pan. */
22
24
  export declare function panForOffset(dx: number): number;
25
+ /**
26
+ * The stereo pan of a world point in a 3D scene: where it lands across the
27
+ * live camera's view, -1 at the left edge to 1 at the right; 0 for a point
28
+ * the camera cannot see in front of it. The camera is read at every call.
29
+ */
30
+ export declare function screenPanOf(camera: () => ViewCamera): (point: WorldPoint) => number;
31
+ /**
32
+ * The audio listener's position (CA-8) in logical coordinates. The camera
33
+ * only ever holds render-space coordinates, so under `projection:
34
+ * 'isometric'` this is the exact inverse of the render projection — without
35
+ * it, distance-based attenuation would measure render-space distance instead
36
+ * of real game distance. A 3D scene's listener is its camera, in world space.
37
+ */
38
+ export declare function listenerPosition(space: SceneSpace, camera: GameCamera, projection: 'isometric' | null): {
39
+ x: number;
40
+ y: number;
41
+ z?: number;
42
+ };
@@ -1,3 +1,5 @@
1
+ import { worldToNormalized } from '../camera-projection.js';
2
+ import { unprojectIsometric } from '../projection.js';
1
3
  /**
2
4
  * Engine constants for CA-8 positional audio. There are no public tuning
3
5
  * knobs (spec decision 20): the curve is picked once, in the spirit of
@@ -30,3 +32,27 @@ export function panForOffset(dx) {
30
32
  const { panDistance } = AUDIO_SPATIAL_DEFAULTS;
31
33
  return Math.max(-1, Math.min(1, dx / panDistance));
32
34
  }
35
+ /**
36
+ * The stereo pan of a world point in a 3D scene: where it lands across the
37
+ * live camera's view, -1 at the left edge to 1 at the right; 0 for a point
38
+ * the camera cannot see in front of it. The camera is read at every call.
39
+ */
40
+ export function screenPanOf(camera) {
41
+ return (point) => {
42
+ const at = worldToNormalized(camera(), point);
43
+ return at ? Math.max(-1, Math.min(1, (at.nx - 0.5) * 2)) : 0;
44
+ };
45
+ }
46
+ /**
47
+ * The audio listener's position (CA-8) in logical coordinates. The camera
48
+ * only ever holds render-space coordinates, so under `projection:
49
+ * 'isometric'` this is the exact inverse of the render projection — without
50
+ * it, distance-based attenuation would measure render-space distance instead
51
+ * of real game distance. A 3D scene's listener is its camera, in world space.
52
+ */
53
+ export function listenerPosition(space, camera, projection) {
54
+ const { x, y, z } = camera.position;
55
+ if (space === '3d')
56
+ return { x, y, z };
57
+ return projection === 'isometric' ? unprojectIsometric(x, y) : { x, y };
58
+ }
@@ -17,11 +17,17 @@ export interface AudioPlayOptions {
17
17
  * from the distance to the listener (the camera) in logical coordinates;
18
18
  * panning is computed from both positions after `game.renderPoint()`, so
19
19
  * an isometric source that reads to the right on screen pans right even
20
- * though its volume reflects real (logical) game distance.
20
+ * though its volume reflects real (logical) game distance. In a 3D scene
21
+ * the listener is the camera, attenuation uses the 3D distance (`z`
22
+ * included) and the pan follows where the source lands on screen. The
23
+ * 2D curve (full volume within 3 units, silent at 16) is reused, so a
24
+ * camera set back from the action plays what it frames quieter: from the
25
+ * default camera, a sound at the centre of the view is at about 37%.
21
26
  */
22
27
  at?: Entity | {
23
28
  x: number;
24
29
  y: number;
30
+ z?: number;
25
31
  };
26
32
  }
27
33
  export interface SoundHandle {
@@ -0,0 +1,38 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import type { PointerCamera } from './pointer.js';
3
+ /**
4
+ * What the Game's views read: the orthographic camera's frame (a duck-typed
5
+ * `THREE.OrthographicCamera`, which tests and the Pointer rely on) or the
6
+ * perspective camera of a 3D scene.
7
+ */
8
+ export type ViewCamera = PointerCamera | THREE.PerspectiveCamera;
9
+ /** The scene camera the Game draws with: one per loaded scene, by its space. */
10
+ export type GameCamera = THREE.OrthographicCamera | THREE.PerspectiveCamera;
11
+ /** A point in the scene's world; `z` only matters under a perspective camera. */
12
+ export interface WorldPoint {
13
+ x: number;
14
+ y: number;
15
+ z?: number;
16
+ }
17
+ /** A position inside the game viewport: 0..1 across (left to right) and 0..1 down (top to bottom). */
18
+ export interface NormalizedPoint {
19
+ nx: number;
20
+ ny: number;
21
+ }
22
+ export declare function isPerspectiveCamera(camera: ViewCamera): camera is THREE.PerspectiveCamera;
23
+ /**
24
+ * The one world→view projection (CA-6): where `point` falls in the game
25
+ * viewport, as fractions of its width and height. Under the orthographic
26
+ * camera this is the formula the Pointer's screen→world mapping inverts,
27
+ * and `z` (draw order there) is ignored; under a perspective camera it is the
28
+ * camera's projection, `null` when the point is not in front of it.
29
+ */
30
+ export declare function worldToNormalized(camera: ViewCamera, point: WorldPoint): NormalizedPoint | null;
31
+ /**
32
+ * CSS px per world unit at the game viewport's height. Orthographic: the
33
+ * same everywhere. Perspective: at the depth of the world origin (the ground
34
+ * a scene is built around), where a unit of an Anchored Piece's CSS reads as
35
+ * a unit of world (its distance from the camera, front or back); nearer
36
+ * things look bigger, as the camera draws them.
37
+ */
38
+ export declare function pixelsPerUnit(camera: ViewCamera, viewportHeight: number): number;
@@ -0,0 +1,44 @@
1
+ import * as THREE from 'three/webgpu';
2
+ export function isPerspectiveCamera(camera) {
3
+ return 'isPerspectiveCamera' in camera && camera.isPerspectiveCamera === true;
4
+ }
5
+ const scratch = new THREE.Vector3();
6
+ function projectPerspective(camera, point) {
7
+ camera.updateMatrixWorld();
8
+ scratch.set(point.x, point.y, point.z ?? 0).applyMatrix4(camera.matrixWorldInverse);
9
+ // The camera looks down its own -Z: at or past the near plane there is nothing to draw.
10
+ if (scratch.z >= -camera.near)
11
+ return null;
12
+ scratch.applyMatrix4(camera.projectionMatrix);
13
+ return { nx: (scratch.x + 1) / 2, ny: (1 - scratch.y) / 2 };
14
+ }
15
+ /**
16
+ * The one world→view projection (CA-6): where `point` falls in the game
17
+ * viewport, as fractions of its width and height. Under the orthographic
18
+ * camera this is the formula the Pointer's screen→world mapping inverts,
19
+ * and `z` (draw order there) is ignored; under a perspective camera it is the
20
+ * camera's projection, `null` when the point is not in front of it.
21
+ */
22
+ export function worldToNormalized(camera, point) {
23
+ if (isPerspectiveCamera(camera))
24
+ return projectPerspective(camera, point);
25
+ return {
26
+ nx: (point.x - (camera.position.x + camera.left)) / (camera.right - camera.left),
27
+ ny: (camera.position.y + camera.top - point.y) / (camera.top - camera.bottom),
28
+ };
29
+ }
30
+ /**
31
+ * CSS px per world unit at the game viewport's height. Orthographic: the
32
+ * same everywhere. Perspective: at the depth of the world origin (the ground
33
+ * a scene is built around), where a unit of an Anchored Piece's CSS reads as
34
+ * a unit of world (its distance from the camera, front or back); nearer
35
+ * things look bigger, as the camera draws them.
36
+ */
37
+ export function pixelsPerUnit(camera, viewportHeight) {
38
+ if (!isPerspectiveCamera(camera))
39
+ return viewportHeight / (camera.top - camera.bottom);
40
+ camera.updateMatrixWorld();
41
+ // Absolute: a camera aimed away from the origin still measures how far the ground is, not the near plane.
42
+ const depth = Math.max(camera.near, Math.abs(scratch.set(0, 0, 0).applyMatrix4(camera.matrixWorldInverse).z));
43
+ return viewportHeight / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * depth);
44
+ }
@@ -0,0 +1,48 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { type ResolvedSceneCamera, type SceneCameraJson } from './camera.js';
3
+ import type { GameCamera } from './camera-projection.js';
4
+ import type { Entity } from './entity.js';
5
+ import type { SceneSpace } from './scene-space.js';
6
+ /** What following an entity needs from the Game: the scene's entities and its render projection. */
7
+ export interface CameraFollowHost {
8
+ find(name: string): Entity | undefined;
9
+ /** A logical point in render space (the isometric projection, when the scene has one). */
10
+ renderPoint(x: number, y: number): {
11
+ x: number;
12
+ y: number;
13
+ };
14
+ /** The visible world height, i.e. the orthographic zoom, as it is when called. */
15
+ viewHeight(): number;
16
+ }
17
+ /**
18
+ * The Game's cameras. The orthographic one is built with the Game and frames
19
+ * every 2D scene; a 3D scene swaps in a perspective one (built on first use).
20
+ * `camera` is whichever the loaded scene uses (ADR 0027).
21
+ */
22
+ export declare class CameraRig {
23
+ readonly orthographic: THREE.OrthographicCamera;
24
+ private perspective;
25
+ private current;
26
+ private aspect;
27
+ /** How far the perspective camera's declared target was from its declared position. */
28
+ private distance;
29
+ constructor();
30
+ get camera(): GameCamera;
31
+ /** The distance from the perspective camera to the point it was aimed at; a snapshot reports its target from it. */
32
+ get lookDistance(): number;
33
+ /**
34
+ * Adopts a scene's camera block for its space: a 3D scene gets the
35
+ * perspective camera (the block's, or the defaults), a 2D scene the
36
+ * orthographic one. Returns the orthographic block resolved, for follow
37
+ * and zoom; null when the host keeps control (no block, or a block of the
38
+ * wrong kind for the space, which `validate_project` reports).
39
+ */
40
+ adopt(json: SceneCameraJson | undefined, space: SceneSpace): ResolvedSceneCamera | null;
41
+ /** Back to the orthographic camera, as a Game with no scene has it. */
42
+ reset(): void;
43
+ /** Fits both cameras to the letterboxed view: the orthographic frame from `viewHeight`, the perspective aspect. */
44
+ fit(aspect: number, viewHeight: number): void;
45
+ /** One Simulation Step of the orthographic follow camera (deadzone, lookahead, smoothing, limits). */
46
+ follow(cam: ResolvedSceneCamera, host: CameraFollowHost, dt: number): void;
47
+ private useBlock;
48
+ }
@@ -0,0 +1,98 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
3
+ import { EMISSIVE_LAYER } from './render-layers.js';
4
+ import { placePerspectiveCamera, resolvePerspectiveCamera } from './scene-camera-3d.js';
5
+ /**
6
+ * The Game's cameras. The orthographic one is built with the Game and frames
7
+ * every 2D scene; a 3D scene swaps in a perspective one (built on first use).
8
+ * `camera` is whichever the loaded scene uses (ADR 0027).
9
+ */
10
+ export class CameraRig {
11
+ orthographic = new THREE.OrthographicCamera();
12
+ perspective = null;
13
+ current = this.orthographic;
14
+ aspect = 1;
15
+ /** How far the perspective camera's declared target was from its declared position. */
16
+ distance = 1;
17
+ constructor() {
18
+ this.orthographic.position.z = 10;
19
+ // Emissive drawables sit on their own layer; an unlit frame draws it in place.
20
+ this.orthographic.layers.enable(EMISSIVE_LAYER);
21
+ }
22
+ get camera() {
23
+ return this.current;
24
+ }
25
+ /** The distance from the perspective camera to the point it was aimed at; a snapshot reports its target from it. */
26
+ get lookDistance() {
27
+ return this.distance;
28
+ }
29
+ /**
30
+ * Adopts a scene's camera block for its space: a 3D scene gets the
31
+ * perspective camera (the block's, or the defaults), a 2D scene the
32
+ * orthographic one. Returns the orthographic block resolved, for follow
33
+ * and zoom; null when the host keeps control (no block, or a block of the
34
+ * wrong kind for the space, which `validate_project` reports).
35
+ */
36
+ adopt(json, space) {
37
+ if (space === '3d') {
38
+ this.useBlock(json?.kind === 'perspective' ? json : undefined);
39
+ return null;
40
+ }
41
+ this.current = this.orthographic;
42
+ if (!json || json.kind === 'perspective')
43
+ return null;
44
+ return resolveSceneCamera(json);
45
+ }
46
+ /** Back to the orthographic camera, as a Game with no scene has it. */
47
+ reset() {
48
+ this.current = this.orthographic;
49
+ }
50
+ /** Fits both cameras to the letterboxed view: the orthographic frame from `viewHeight`, the perspective aspect. */
51
+ fit(aspect, viewHeight) {
52
+ this.aspect = aspect;
53
+ const halfH = viewHeight / 2;
54
+ const halfW = halfH * aspect;
55
+ const camera = this.orthographic;
56
+ camera.left = -halfW;
57
+ camera.right = halfW;
58
+ camera.top = halfH;
59
+ camera.bottom = -halfH;
60
+ camera.updateProjectionMatrix();
61
+ if (!this.perspective)
62
+ return;
63
+ this.perspective.aspect = aspect;
64
+ this.perspective.updateProjectionMatrix();
65
+ }
66
+ /** One Simulation Step of the orthographic follow camera (deadzone, lookahead, smoothing, limits). */
67
+ follow(cam, host, dt) {
68
+ const camera = this.orthographic;
69
+ const followed = cam.follow ? host.find(cam.follow) : undefined;
70
+ const provider = followed?.components.find((c) => isCameraVelocityProvider(c));
71
+ const velocity = provider?.getCameraVelocity();
72
+ const target = followed ? host.renderPoint(followed.position.x, followed.position.y) : null;
73
+ const renderVelocity = velocity ? host.renderPoint(velocity.vx, velocity.vy) : { x: 0, y: 0 };
74
+ const next = stepSceneCamera(cam, {
75
+ x: camera.position.x,
76
+ y: camera.position.y,
77
+ halfW: (camera.right - camera.left) / 2,
78
+ halfH: host.viewHeight() / 2,
79
+ target,
80
+ vx: renderVelocity.x,
81
+ vy: renderVelocity.y,
82
+ dt,
83
+ });
84
+ camera.position.x = next.x;
85
+ camera.position.y = next.y;
86
+ }
87
+ useBlock(json) {
88
+ if (!this.perspective) {
89
+ this.perspective = new THREE.PerspectiveCamera();
90
+ // A 3D scene is never lit, so an Emissive drawable is drawn in place on the camera's own layers.
91
+ this.perspective.layers.enable(EMISSIVE_LAYER);
92
+ }
93
+ const resolved = resolvePerspectiveCamera(json);
94
+ placePerspectiveCamera(this.perspective, resolved, this.aspect);
95
+ this.distance = new THREE.Vector3(...resolved.position).distanceTo(new THREE.Vector3(...resolved.target)) || 1;
96
+ this.current = this.perspective;
97
+ }
98
+ }
package/dist/camera.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type PerspectiveSceneCameraJson, type ResolvedPerspectiveCamera } from './scene-camera-3d.js';
1
2
  /**
2
3
  * The scene camera: a built-in, singular part of every scene — not a
3
4
  * component you attach to an entity. The scene JSON carries its framing
@@ -11,7 +12,10 @@ export interface CameraLimitsJson {
11
12
  minY: number;
12
13
  maxY: number;
13
14
  }
14
- export interface SceneCameraJson {
15
+ /** The 2D scene camera: an orthographic view of the XY plane. */
16
+ export interface OrthographicSceneCameraJson {
17
+ /** The default; written only to be explicit. */
18
+ kind?: 'orthographic';
15
19
  /** Where the camera starts (and stays, without a follow target). */
16
20
  position?: [number, number];
17
21
  /** Visible world height in units — the camera's zoom. */
@@ -26,7 +30,15 @@ export interface SceneCameraJson {
26
30
  smoothing?: number;
27
31
  limits?: CameraLimitsJson;
28
32
  }
33
+ /**
34
+ * A scene's camera block: orthographic (2D scenes) or perspective (scenes
35
+ * with `render.space: '3d'`), told apart by `kind`.
36
+ */
37
+ export type SceneCameraJson = OrthographicSceneCameraJson | PerspectiveSceneCameraJson;
38
+ export type { PerspectiveSceneCameraJson, ResolvedPerspectiveCamera };
39
+ /** The orthographic camera block, filled in. It carries no `kind`: the shape 2D scenes always had. */
29
40
  export interface ResolvedSceneCamera {
41
+ kind?: 'orthographic';
30
42
  position: [number, number];
31
43
  zoom: number;
32
44
  follow: string;
@@ -47,8 +59,13 @@ export declare const CAMERA_DEFAULTS: {
47
59
  readonly lookaheadY: 0;
48
60
  readonly smoothing: 6;
49
61
  };
50
- /** Fills a scene's camera block with the engine defaults. */
51
- export declare function resolveSceneCamera(json?: SceneCameraJson): ResolvedSceneCamera;
62
+ /**
63
+ * Fills a scene's camera block with the engine defaults: the orthographic
64
+ * block (or none) as it always was, a perspective block with the 3D defaults.
65
+ */
66
+ export declare function resolveSceneCamera(json?: OrthographicSceneCameraJson): ResolvedSceneCamera;
67
+ export declare function resolveSceneCamera(json: PerspectiveSceneCameraJson): ResolvedPerspectiveCamera;
68
+ export declare function resolveSceneCamera(json?: SceneCameraJson): ResolvedSceneCamera | ResolvedPerspectiveCamera;
52
69
  /** Two-axis velocity a followed entity reports for camera lookahead. */
53
70
  export interface CameraVelocity {
54
71
  vx: number;
package/dist/camera.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as THREE from 'three/webgpu';
2
+ import { resolvePerspectiveCamera, } from './scene-camera-3d.js';
2
3
  export const CAMERA_DEFAULTS = {
3
4
  position: [0, 0],
4
5
  zoom: 12,
@@ -9,8 +10,9 @@ export const CAMERA_DEFAULTS = {
9
10
  lookaheadY: 0,
10
11
  smoothing: 6,
11
12
  };
12
- /** Fills a scene's camera block with the engine defaults. */
13
13
  export function resolveSceneCamera(json) {
14
+ if (json?.kind === 'perspective')
15
+ return resolvePerspectiveCamera(json);
14
16
  return {
15
17
  position: json?.position ?? CAMERA_DEFAULTS.position,
16
18
  zoom: json?.zoom ?? CAMERA_DEFAULTS.zoom,
@@ -8,12 +8,21 @@ export interface ParamSpec {
8
8
  max?: number;
9
9
  step?: number;
10
10
  /** Specialized editor control for values that are otherwise plain JSON. */
11
- kind?: 'string-list' | 'vector2' | 'color' | 'texture';
11
+ kind?: 'string-list' | 'vector2' | 'vector3' | 'color' | 'texture' | 'model';
12
12
  /** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
13
13
  options?: string[];
14
14
  /** Project value this string param names; rendered and validated as a typed reference. */
15
15
  ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound' | 'ui';
16
16
  }
17
+ /**
18
+ * The scene space a component belongs to (ADR 0027): `'2d'` components draw,
19
+ * collide or light the orthographic world, move or query over its x/y plane,
20
+ * or need a sibling that does; `'3d'` ones need a perspective camera;
21
+ * `'both'` run anywhere. A component that declares nothing is `'both'`.
22
+ */
23
+ export type ComponentSpace = '2d' | '3d' | 'both';
24
+ /** The values of `ComponentSpace`, for the checks that read a marker from data (the project component protocol, conformance). */
25
+ export declare const COMPONENT_SPACES: readonly ComponentSpace[];
17
26
  export interface ComponentClass<T extends Component = Component> {
18
27
  new (): T;
19
28
  /**
@@ -27,6 +36,8 @@ export interface ComponentClass<T extends Component = Component> {
27
36
  params?: Record<string, ParamSpec>;
28
37
  /** Sibling component updates that must complete before this one when present. */
29
38
  updateAfter?: readonly string[];
39
+ /** The scene space this component belongs to; absent means both. */
40
+ space?: ComponentSpace;
30
41
  /**
31
42
  * Instance fields holding runtime state rather than authorable defaults.
32
43
  * Excluded from authoringDefaults(); a subclass that does not redeclare
@@ -47,6 +58,25 @@ export interface SolidContact {
47
58
  readonly axis: 'x' | 'y';
48
59
  readonly normal: ContactNormal;
49
60
  }
61
+ /** A 3D contact between two entities' solid Colliders, read after the physics step. */
62
+ export interface BodyContact {
63
+ /** The entity whose component receives the hook. */
64
+ readonly entity: Entity;
65
+ /** The entity it is in contact with. */
66
+ readonly other: Entity;
67
+ /** Unit normal in world space, pointing from `entity` to `other`. */
68
+ readonly normal: {
69
+ readonly x: number;
70
+ readonly y: number;
71
+ readonly z: number;
72
+ };
73
+ /** A world-space point on the contact. */
74
+ readonly point: {
75
+ readonly x: number;
76
+ readonly y: number;
77
+ readonly z: number;
78
+ };
79
+ }
50
80
  /**
51
81
  * A pluggable piece of an entity. User behaviors and engine ones are the
52
82
  * same thing: Component subclasses with public props.
@@ -56,6 +86,7 @@ export declare abstract class Component {
56
86
  static displayName?: string;
57
87
  static params?: Record<string, ParamSpec>;
58
88
  static updateAfter?: readonly string[];
89
+ static space?: ComponentSpace;
59
90
  static transient?: readonly string[];
60
91
  entity: Entity;
61
92
  game: Game;
@@ -67,8 +98,13 @@ export declare abstract class Component {
67
98
  onUpdate?(dt: number): void;
68
99
  /** Runs after the scene changes between identity and projected rendering. */
69
100
  onProjectionChange?(projection: 'isometric' | null): void;
70
- /** Runs on overlap when this entity's Hitbox mask names the other's layer. */
101
+ /**
102
+ * Runs on overlap when this entity's Hitbox (2D) or sensor Collider (3D)
103
+ * mask names the other's layer.
104
+ */
71
105
  onCollide?(other: Entity): void;
106
+ /** Runs once per Simulation Step for each entity this entity's solid Collider is in contact with (3D, issue #159). */
107
+ onBodyContact?(contact: BodyContact): void;
72
108
  /** Runs when this entity's DynamicBody physically contacts a Solid. */
73
109
  onContact?(contact: SolidContact): void;
74
110
  /**
package/dist/component.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** The values of `ComponentSpace`, for the checks that read a marker from data (the project component protocol, conformance). */
2
+ export const COMPONENT_SPACES = ['2d', '3d', 'both'];
1
3
  /**
2
4
  * A pluggable piece of an entity. User behaviors and engine ones are the
3
5
  * same thing: Component subclasses with public props.
@@ -7,6 +9,7 @@ export class Component {
7
9
  static displayName;
8
10
  static params;
9
11
  static updateAfter;
12
+ static space;
10
13
  static transient;
11
14
  entity;
12
15
  game;
@@ -1,4 +1,4 @@
1
- import { Component } from '../component.js';
1
+ import { Component, type ComponentSpace } from '../component.js';
2
2
  import { type ClipDef } from '../animation/clip-player.js';
3
3
  import { type SheetCell, type SheetDef } from '../animation/sheet.js';
4
4
  import type { YSortParticipant } from '../render-sort.js';
@@ -14,6 +14,7 @@ import type { YSortParticipant } from '../render-sort.js';
14
14
  */
15
15
  export declare class AnimatedSprite extends Component implements YSortParticipant {
16
16
  static componentName: string;
17
+ static space: ComponentSpace;
17
18
  static updateAfter: readonly string[];
18
19
  static params: {
19
20
  offsetX: {
@@ -40,6 +41,9 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
40
41
  max: number;
41
42
  step: number;
42
43
  };
44
+ emissive: {
45
+ label: string;
46
+ };
43
47
  };
44
48
  static transient: string[];
45
49
  /** Spritesheet URL. */
@@ -85,6 +89,9 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
85
89
  flipX: boolean;
86
90
  /** Mirrors the quad horizontally — directional clips reuse east art for west. */
87
91
  setFlipX(value: boolean): void;
92
+ private _emissive;
93
+ get emissive(): boolean;
94
+ set emissive(value: boolean);
88
95
  clips: Record<string, ClipDef>;
89
96
  initialClip?: string;
90
97
  /** Clip currently playing. */