@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
package/README.md CHANGED
@@ -25,6 +25,27 @@ A `Game` draws through three's `WebGPURenderer` (ADR 0025): WebGPU when the brow
25
25
  - **Tests under happy-dom.** happy-dom has no GPU: a project test that builds a `Game` replaces `WebGPURenderer` from `three/webgpu` with a fake (it previously replaced `WebGLRenderer` from `three`), whose `init()` resolves.
26
26
  - **Run Sessions.** A Run Session waits for the Render Backend before it reports ready, steps or screenshots, and its snapshots carry `backend`. A renderer that cannot initialize ends `start_project` with a runtime error that names the failure.
27
27
 
28
+ ## Migrating to 0.26.0: 3D scenes
29
+
30
+ A scene can declare a 3D space (issue #154, ADR 0027). Nothing about a 2D scene changes — every existing scene file loads as it did — but a few types and one build setting do:
31
+
32
+ - **`game.camera` is a union.** It is `THREE.OrthographicCamera | THREE.PerspectiveCamera`: the orthographic one in every 2D scene, a perspective one in a 3D scene. Code that read `game.camera.left/right/top/bottom` narrows first (`camera instanceof THREE.OrthographicCamera`, or `isPerspectiveCamera(camera)`), or goes through `game.worldToScreen(point)`. `game.view` and `setViewHeight()` are the orthographic zoom: under a perspective camera they change nothing.
33
+ - **`SceneCameraJson` is a union.** The existing block (optionally `kind: 'orthographic'`) or `{ kind: 'perspective', position?, target?, fov?, near?, far? }`. `resolveSceneCamera` keeps returning the orthographic block with no `kind`; a perspective block gets its own defaults. A scene's camera must match its space (`validate_project` reports a mismatch).
34
+ - **Scene entities take 3-number positions.** `position` is `[x, y]` (z = 0, as before) or `[x, y, z]`, and an entity may declare `rotation: [x, y, z]` in degrees (Euler order XYZ) and `scale: [x, y, z]`. In a 2D scene `z` is still only draw order.
35
+ - **`ArchetypeArt.kind` and the editor's art `kind` gain `'model'`** (`.glb` and `.gltf`), and `ParamSpec.kind` gains `'model'` and `'vector3'`. Code that switches over these kinds handles the new member.
36
+ - **Components declare their space on the class (issue #159).** `Component` has an optional `static space: 'both' | '2d' | '3d'`, and `TWO_D_COMPONENTS` and `THREE_D_COMPONENTS` are no longer exported: `componentSpaceOf(Class)` and `componentSpaceMismatch(space, Class)` read the marker. The behaviors of `@waica/behaviors` that read or move x/y as the orthographic plane, or need a 2D sibling to work (the motors, `Chaser`, `Hazard`, `MeleeAttack`, `Interactable`, `Patrol`, `ClickToMove`, and the particle cues `DamagePuff`, `DustPuffs`, `DustTrail` and `SwingSparks`, which need a `ParticleEmitter`) are tagged `'2d'`, so a project that put one in a 3D scene now gets `component-space-mismatch` from `validate_project` where it used to validate clean. A project-owned component opts in with its own `static space`. 3D scenes also simulate (ADR 0028): the engine and the CLI depend on `@dimforge/rapier3d-deterministic-compat`, loaded only by a 3D scene, and a project's bundle gets one extra async chunk for it. `game.query`'s 2D forms throw in a 3D scene.
37
+ - **`Pointer.injectClick(x, y)` may return `null`.** In a 3D scene `x` and `y` are canvas pixels and a ray that meets neither a model nor the ground yields no pick; in a 2D scene it still always returns one.
38
+ - **One three.** `GLTFLoader` and `SkeletonUtils` import the bare `'three'`, the engine draws with `'three/webgpu'`. Every bundler of a project that uses `Model` (or any three addon) aliases the bare specifier to the WebGPU build, and only it:
39
+
40
+ ```ts
41
+ // vite.config.ts
42
+ export default defineConfig({
43
+ resolve: { alias: [{ find: /^three$/, replacement: 'three/webgpu' }] },
44
+ })
45
+ ```
46
+
47
+ `three/webgpu`, `three/tsl` and `three/addons/*` are left alone. Projects created by `create_project` after this release carry the alias; an earlier project adds it by hand. The same alias goes in a project's Vitest config if its tests import plain `'three'`.
48
+
28
49
  ## Hitbox Collision Layers and Masks
29
50
 
30
51
  Every `Hitbox` belongs to one named Collision Layer and declares the other layers in which it is interested through a Collision Mask:
@@ -183,13 +204,69 @@ Under batching, the object a sprite adds to its entity is a hidden placement anc
183
204
 
184
205
  How much batching saves depends on how interleaved a scene is: one texture over the whole scene draws in one call, while a top-down scene that alternates many textures by y gains little. Runtime Snapshots are unchanged — batches are not entities, and sprites report the same state either way.
185
206
 
207
+ ## Lighting and Post Effects
208
+
209
+ A scene can be lit (issue #78, ADR 0026). Without lighting and without a Post Effect a scene is drawn exactly as before: one render straight into the canvas, no render target.
210
+
211
+ ```json
212
+ "render": {
213
+ "lighting": { "ambient": { "color": "#5a6a9a", "intensity": 0.3 } },
214
+ "post": {
215
+ "vignette": { "intensity": 0.45, "radius": 0.35 },
216
+ "colorGrade": { "tint": "#ffeedd", "contrast": 1.1, "saturation": 0.9 }
217
+ }
218
+ }
219
+ ```
220
+
221
+ - **Ambient Light.** `render.lighting.ambient` is where no light reaches: `color` (`#rrggbb`, default `#ffffff`) times `intensity` (0..1, default 1). `game.lighting.ambient` reads it and sets it at runtime, field by field (`game.lighting.ambient = { intensity: 0.2 }`), so a day/night cycle is a `game.time.tween` whose `onUpdate` sets it. Setting it lights a scene that declared none. It dies with its scene: a scene load or swap takes the next scene's value.
222
+ - **Light.** A component: `radius` (logical units), `color`, `intensity` (≥ 0), `bands` (0 smooth, up to 16 equal steps), `softness` (0 hard shadow edge, up to 1), `castShadows` (default true) and `offsetX`/`offsetY` from its entity. Its falloff is `1 − smoothstep(d / radius)`, measured in logical space, so on an isometric screen it is a 2:1 ellipse. Lights add to the Ambient Light and never darken; the sum clamps at 1. A scene with a live Light is lit even without `render.lighting`, but under full Ambient Light (`#ffffff`, 1) a light changes no pixel, so such a frame skips the light-map altogether. `game.lighting.lights` lists them.
223
+ - **Occlusion.** Only Tilemap `solidTiles` stop light: a point whose line to the light crosses a solid tile other than its own is in shadow, while the solid tile itself keeps the light that reaches it (its lit face). Every Tilemap of the scene contributes; the grid is rebuilt only when a Tilemap spawns, is destroyed or changes its tiles. Props with a `Solid` and moving entities cast no shadow.
224
+ - **Emissive.** `Sprite`, `AnimatedSprite` and `ParticleEmitter` take `emissive: true`: drawn after the light-map at full brightness (a flame, sparks), still in their own order and Sprite Batch runs. Every drawable discards its fully transparent texels, so an Emissive drawable behind a lit sprite shows through that sprite's transparent texels and stays hidden behind its opaque ones. Engine-wise they sit on `EMISSIVE_LAYER`; `setEmissive(object, true)` puts any three.js object there.
225
+ - **How a lit frame is drawn.** The scene without its Emissive drawables goes straight to the canvas as always; then a light-map — an 8-bit target the size of the fixed `resolution` (nearest upscale), or of the drawing buffer without one — cleared to the Ambient Light and lit by one quad per Light is multiplied over it; then the Emissive drawables. The multiply works on the canvas's sRGB values, so Ambient Light 1 with no light leaves the frame identical.
226
+ - **Post Effects.** `render.post.vignette` (`intensity` and `radius`, 0..1) darkens towards the corners; `render.post.colorGrade` (`tint`, `contrast` and `saturation` 0..2, 1 = unchanged) grades the frame. With either on, the frame (lighting and Emissive drawables included) renders into an 8-bit target at the light-map's size, drawn exactly as onto the canvas (sRGB-encoded, blending on sRGB values, so no sprite edge changes), and one full-screen pass grades it and writes it to the canvas. `game.post.vignette` and `game.post.colorGrade` read and set them field by field over the current values, like the ambient (null turns one off); they die with their scene. Camera Effects stay above and are never altered.
227
+ - **Validation and snapshots.** `validate_project` reports an out-of-range render field as `invalid-scene-render` and a Light param outside its range as `invalid-light-param`, naming it; at runtime out-of-range values are clamped. Every Runtime Snapshot carries `lighting: { ambient, lights: [{ entity, id, x, y, radius, color, intensity, bands, softness, castShadows }] }` in logical coordinates and `post: { vignette, colorGrade }` (null when off).
228
+
229
+ The CPU reference of a light-map texel — `lightFalloff`, `lightVisibility`, `lightMapValue` — is exported for tools and tests; the shader follows it.
230
+
231
+ ## 3D scenes
232
+
233
+ A scene declares its space (ADR 0027): `render.space: '2d'` (the default) or `'3d'`. `game.space` reports the live scene's, `'2d'` with no scene. In a 3D scene `z` is a world axis (Y is up, the ground is the XZ plane, the default camera looks down −Z), the frame is drawn straight to the canvas with a depth buffer deciding what is in front, and there is no layer band, y-sort, isometric projection, Sprite Batch or light-map. `render.post` still works. `validate_project` rejects `render.sort`, `render.projection` and `render.batch` in a 3D scene, a 2D component on a 3D scene's entities and a 3D component in a 2D scene (`component-space-mismatch`). A component declares its space on its class, `static space = '2d' | '3d' | 'both'` (absent means both): `Sprite`, `AnimatedSprite`, `Tilemap`, `Solid`, `DynamicBody`, `Hitbox`, `Light`, `ParticleEmitter` and the behaviors of `@waica/behaviors` that read or move x/y as the orthographic plane or need a 2D sibling (the motors, `Chaser`, `Hazard`, `MeleeAttack`, `Interactable`, `Patrol`, `ClickToMove`, and the particle cues `DamagePuff`, `DustPuffs`, `DustTrail`, `SwingSparks`) are `'2d'`; `Model`, `Sun`, `PointLight`, `Collider`, `RigidBody` and `CharacterMotor` are `'3d'`; `StateMachine`, the behaviors that only react to an overlap or read y as up, and a project-owned component without a marker are allowed in both (a project-owned component says otherwise with its own `static space`).
234
+
235
+ ```json
236
+ {
237
+ "waicaScene": 3,
238
+ "render": { "space": "3d", "lighting": { "ambient": { "color": "#b8c8ff", "intensity": 0.6 } } },
239
+ "camera": { "kind": "perspective", "position": [0, 5.5, 11], "target": [0, 0.8, 0], "fov": 50 },
240
+ "entities": [
241
+ { "name": "Ground", "scale": [24, 1, 24], "components": [{ "type": "Model", "props": { "shape": "plane", "color": 5081930 } }] },
242
+ { "name": "Tree", "position": [4, 0, -1], "components": [{ "type": "Model", "props": { "src": "src/art/tree.glb" } }] },
243
+ { "name": "Daylight", "components": [{ "type": "Sun", "props": { "direction": [-0.5, -1, -0.35], "intensity": 3 } }] },
244
+ { "name": "Lamp", "position": [2.5, 0, 3.5], "components": [{ "type": "PointLight", "props": { "intensity": 14, "distance": 10, "offsetY": 1.2 } }] }
245
+ ]
246
+ }
247
+ ```
248
+
249
+ - **Camera.** `{ kind: 'perspective', position, target, fov, near, far }`, defaults `[0, 5, 10]`, `[0, 0, 0]`, 60, 0.1, 1000; `follow` and the 2D fields are not accepted. A 3D scene without a camera block gets the defaults. The camera is fixed by the scene; game code may move `game.camera`. `resize()` keeps the letterbox and sets the camera's aspect from the letterboxed size. Camera Fade and Flash work in 3D; Shake does nothing.
250
+ - **Model.** Draws a glTF/glb from `src` (a `waica:` uri or a project path under `src/art/`, loaded once through `game.assets.model(uri)`) or a primitive `shape` (`'box'`, `'sphere'`, `'plane'` — the plane lies on the ground) of a `color` (`0xrrggbb`) and a uniform `size`, with a standard (lit) material. With both `src` and `shape`, `src` wins. The entity's `position`, `rotation` and `scale` place it; `color` and `size` follow the inspector live, a new `src` or `shape` rebuilds it. glTF animations load with the file (`ModelHandle.animations`) and are not played yet.
251
+ - **Lights.** The Ambient Light of a 3D scene drives one `THREE.AmbientLight` the Game owns (`render.lighting.ambient` and `game.lighting.ambient`, full white by default). `Sun` is a directional light: `direction` `[x, y, z]` in world space (default `[-1, -2, -1]`), `color`, `intensity`. `PointLight` shines from its entity plus `offsetX/Y/Z`: `color`, `intensity`, `distance` (0 = unlimited), `decay` (default 2). Both create nothing in a 2D scene, follow their props live and leave with their entity. No shadows. three's lights are physically based: a point light of intensity 1 is dim, so a scene tunes intensities (the smoke example uses 3 for the Sun and 14 for the lamp).
252
+ - **`game.worldToScreen(point)`.** `{ x, y, z? }` in logical coordinates to canvas CSS pixels inside the letterbox, or `null` when a perspective camera cannot see the point (behind its near plane). Anchored Pieces and spatial audio go through the same projection: in 3D a piece behind the camera is hidden, sounds attenuate by the 3D distance from the camera and pan by where they land on screen.
253
+ - **Picking.** A click in a 3D scene casts a ray through the letterboxed point against every live `Model` (a glTF still loading is skipped) and resolves `PointerPick` with the nearest hit's entity and its world-space point (`point.z`). With no hit the entity is `null` and the point is where the ray meets the ground plane y = 0; a ray at the sky yields no pick.
254
+ - **Assets.** `game.assets.model(uri)` returns a `ModelHandle` at once: `root` is empty until the file settles, then holds your own clone of the cached glTF (skinned meshes cloned with `SkeletonUtils.clone`), `animations` its clips, `settled` resolves `'loaded'` or `'failed'`. One backend load per URL for the Game's life; `status` counts models with textures, `preload` routes `.glb`/`.gltf` to them, `GameOptions.models` replaces the real `GLTFLoader` backend (`ModelBackend { load(url) }`) for a project's own tests. Only a `.glb`, or a `.gltf` with every buffer and image embedded as a `data:` uri, works in the editor (which serves each file from a `blob:` url) and in a built project (which hashes every art file's name): a `.gltf` that points at an external `.bin` or textures loads empty there, and `validate_project` warns (`gltf-external-resource`).
255
+ - **Simulation.** A 3D scene gets one Rapier world (ADR 0028, `@dimforge/rapier3d-deterministic-compat` 0.21.0), stepped once per Simulation Step after every component's `onUpdate`. `simulation: { gravity: [x, y, z] }` in the scene sets its gravity (default `[0, -9.81, 0]`; a `simulation` block in a 2D scene is an error, `invalid-scene-simulation`). Rapier is imported dynamically by the first 3D scene that loads, counts as a pending asset (`game.assets.ready()` and `game.ready()` of a Game with a live 3D scene wait for it, and `game.ready()` rejects naming the package if it fails), and a 2D project never loads it. Steps taken while it loads run every component update and skip only the physics. `GameOptions.physics` replaces the import for a project's own tests.
256
+ - **Collider.** `shape` (`'box'`, `'sphere'`, `'capsule'`), `size` (a box's full extents), `radius`, `height` (a capsule's end-to-end height), `offset` (in the entity's local frame), `sensor`, `layer`, `collidesWith`, `friction`, `restitution`. Alone on an entity it is a fixed wall or floor, created at the entity's position and rotation and scaled by its `scale` (box size and capsule height per axis, a sphere's radius by the largest axis, a capsule's by the larger of x and z); moving the entity later does not move it. A capsule's `height` is at least twice its radius (a shorter one would be a taller sphere). `validate_project` reports `invalid-collider-param` for an unknown shape, a non-positive dimension, a radius or height under 0.01, a capsule shorter than twice its radius, an `offset` that is not three finite numbers, a bad layer or mask and a friction or restitution outside 0 to 1; at runtime such a Collider creates no body and warns once.
257
+ - **RigidBody.** `type` (`'dynamic'` or `'kinematic'`), `mass`, `gravityScale`, `linearDamping`, `angularDamping`, `lockRotations`, `velocity` (initial). It needs a `Collider` on the same entity (`rigid-body-without-collider`). A dynamic body is Rapier's: the engine writes the entity's position and rotation from it after every step; `linearVelocity` (get and set) and `applyImpulse` are its public API (`grounded` is always false on it). A kinematic body is a character: each step it moves through Rapier's character controller by `desiredVelocity` (`{ x, z }`, set by a motor) and its own vertical velocity under gravity, climbs steps up to `stepHeight`, stands on slopes up to `maxSlope` degrees and slides down steeper ones, sticks to the ground within `snapDistance`, pushes dynamic bodies, ignores sensors, and `jump(speed)` launches it while `grounded` and reports whether it did. Setting its entity's position teleports it, with no fall speed left. `validate_project` reports a `type` outside its options, a number outside its range or a `velocity` that is not three numbers as `invalid-rigid-body-param`; at runtime such a body is not created and one warning says why. `CharacterMotor` (`@waica/behaviors`) is the stock motor: `speed`, `jumpSpeed` and five action names, walking on the world axes (`-z` is forward, diagonals at the cardinal speed), consuming the jump press only when it jumped; `character-motor-without-body` reports one without a kinematic `RigidBody` and a `Collider`.
258
+ - **Contacts and sensors.** Components on an entity with a solid `Collider` get `onBodyContact({ entity, other, normal, point })` once per step for each entity they touch, the normal pointing from the entity to the other; kinematic bodies included, so a character hears the floor and the walls it touches. A `Collider{ sensor: true }` is the 3D `Hitbox`: it fires `onCollide(other)` every step it overlaps another collider, on each side whose `collidesWith` names the other's `layer` (ADR 0016); a solid collider is told only if it declares its own mask, and a sensor with the default mask (`['*']`) is not told about fixed solid colliders (the level's walls and floors), as a Hitbox never sees a Solid — a mask naming their layer still is.
259
+ - **Queries.** `game.query` gains 3D forms told apart by argument shape: `ray(origin, direction, maxDistance, filter?)` returns the first solid collider (`{ entity, collider, distance, point, normal }`, sensors never block it), `area({ shape: 'box', center, size } | { shape: 'sphere', center, radius }, filter?)` and `point(at, filter?)` return the entities whose sensor colliders intersect or contain them, `nearest(at, filter?)` the closest transform with `z`. The filter object is the 2D one; its `where` runs after Rapier has answered, so it may throw or change the scene, and `area` answers `[]` for a volume that is not a finite box or sphere. A form in the other space's scene throws an error naming the space. Rapier updates what queries read inside the step, so the engine refreshes it when bodies came or went.
260
+ - **Runtime Snapshot.** `space`, `view` (`{ kind: 'orthographic', position, zoom }` or `{ kind: 'perspective', position, target, fov }`) and, in a 3D scene, `lighting.sun[]` and `lighting.pointLights[]`, and `physics: { state: 'loading' | 'ready' | 'failed', gravity, bodies: [{ entity, id, type: 'fixed' | 'dynamic' | 'kinematic', velocity, grounded }] }` (bodies in spawn order, never filtered).
261
+ - **Editor.** A 3D scene opens read-only: the viewport draws it through its perspective camera (the one Play uses) with no gesture, grid or gizmo; the inspector edits X/Y/Z, rotation and scale, and a perspective camera's position, target and fov.
262
+
186
263
  ## Component lifecycle
187
264
 
188
265
  Waica keeps the lifecycle boundaries distinct:
189
266
 
190
267
  1. `Entity.add()` mounts a component and calls its `onReady` immediately. This remains component insertion order so setup behavior does not silently move.
191
268
  2. During each simulated frame, entities keep their existing entity order. When an entity's turn begins, `Game` snapshots and resolves that entity's component `onUpdate` schedule, then dispatches only that schedule.
192
- 3. Physical `onContact` hooks run from `DynamicBody` while it updates. Hitbox `onCollide` hooks run after all entity component updates. Their existing component dispatch order is unchanged.
269
+ 3. Physical `onContact` hooks run from `DynamicBody` while it updates. After all entity component updates a 3D scene's physics world steps and `onBodyContact` follows, then Hitbox `onCollide` hooks run, then sensor `onCollide` hooks. Their existing component dispatch order is unchanged.
193
270
  4. `Game.onUpdate` callbacks run after component updates, collisions, and camera work; input end-of-frame handling follows them.
194
271
  5. `Entity.destroy()` calls `onDestroy` in component insertion order.
195
272
 
@@ -356,5 +433,6 @@ game.assets.status // { pending, loaded, failed } — a fresh object on every re
356
433
  - **`preload(uris)`.** Requests every uri and resolves once all of them settled, failures included. A component that later asks for the same resolved URL is a cache hit.
357
434
  - **`status`.** `pending` is current; `loaded` and `failed` are cumulative for the Game and only grow until `dispose()`. A host that wants a loading bar reads it from `game.onUpdate`.
358
435
  - **Failure rule.** A texture that fails to load is recorded, not thrown: `console.warn('[waica] assets: failed to load "<url>"', error)` once per Game per URL, `failed` counts it, `ready()` resolves, and every consumer falls back to its flat material — `Sprite` and `Tilemap` drop the failed map, dispose their clone and render their `color` again; `AnimatedSprite` never installs the failed sheet's clone, so that sheet's frames show a plain white quad. Asking for that URL again on the same Game is a cached failure — no retry, no second warning.
436
+ - **Models.** `game.assets.model(uri)` is the glTF half of the same cache (see [3D scenes](#3d-scenes)): one load per URL, `status` and `ready()` count it, `preload` routes `.glb` and `.gltf` to it.
359
437
  - **`GameOptions.textures`.** Replaces the real `THREE.TextureLoader` backend for that Game (ADR 0013's seam, applied to textures): `happy-dom` decodes no images, so a project's own tests inject a backend that resolves at once. The Runtime Bridge reports `assets` in its metadata under the `'assets'` capability, and a Run Session waits for `pending === 0` at readiness, after a `scene` operation and before every screenshot.
360
438
 
@@ -1,5 +1,6 @@
1
+ import { type ViewCamera } from './camera-projection.js';
1
2
  import type { Entity } from './entity.js';
2
- import type { PointerCamera, PointerResolution } from './pointer.js';
3
+ import type { PointerResolution } from './pointer.js';
3
4
  import type { RuntimeSnapshotUi } from './runtime-inspection.js';
4
5
  import type { Stats, StatValue } from './stats.js';
5
6
  /** Options for `game.ui.attach` (issue #72). */
@@ -43,7 +44,7 @@ export interface ViewportRect {
43
44
  }
44
45
  /** What the Game tells the anchored layer each time it places: read live, never cached. */
45
46
  export interface AnchorView {
46
- camera: PointerCamera;
47
+ camera: ViewCamera;
47
48
  /** The game viewport: see gameViewport. */
48
49
  viewport: ViewportRect;
49
50
  projection: 'isometric' | null;
@@ -1,3 +1,4 @@
1
+ import { pixelsPerUnit, worldToNormalized } from './camera-projection.js';
1
2
  import { projectIsometric } from './projection.js';
2
3
  import { placeholders, renderStat } from './ui-bindings.js';
3
4
  /**
@@ -154,8 +155,8 @@ export class AnchoredPieces {
154
155
  layer.style.top = `${viewport.y}px`;
155
156
  layer.style.width = `${viewport.width}px`;
156
157
  layer.style.height = `${viewport.height}px`;
157
- // The camera frames exactly viewHeight world units vertically (Game.resize).
158
- layer.style.setProperty('--waica-unit', `${viewport.height / (camera.top - camera.bottom)}px`);
158
+ // Orthographic: the camera frames exactly viewHeight world units vertically (Game.resize).
159
+ layer.style.setProperty('--waica-unit', `${pixelsPerUnit(camera, viewport.height)}px`);
159
160
  // Before any instance's style writes below: getAnimations() flushes style.
160
161
  for (const instance of this.instances)
161
162
  followGameTime(instance);
@@ -165,6 +166,7 @@ export class AnchoredPieces {
165
166
  instance.placed = placement;
166
167
  instance.host.style.left = `${placement.x}px`;
167
168
  instance.host.style.top = `${placement.y}px`;
169
+ instance.host.style.visibility = placement.behind ? 'hidden' : '';
168
170
  byDepth.push([instance, placement.depth]);
169
171
  }
170
172
  // Lower on screen draws on top, like y-sort; the sort is stable, so
@@ -252,19 +254,21 @@ export class AnchoredPieces {
252
254
  function anchorPoint(instance, projection) {
253
255
  if (instance.frozen)
254
256
  return instance.frozen;
255
- const { x, y } = instance.entity.position;
257
+ const { x, y, z } = instance.entity.position;
256
258
  const render = projection === 'isometric' ? projectIsometric(x, y) : { x, y };
257
- return { x: render.x + instance.offset[0], y: render.y + instance.offset[1] };
259
+ return { x: render.x + instance.offset[0], y: render.y + instance.offset[1], z };
258
260
  }
259
261
  /**
260
262
  * The anchor point in whole CSS px from the game viewport's top-left
261
263
  * corner: the exact inverse of the Pointer's screen→world mapping.
262
264
  */
263
265
  function locate(instance, view) {
264
- const anchor = anchorPoint(instance, view.projection);
265
- const { camera, viewport } = view;
266
- const nx = (anchor.x - (camera.position.x + camera.left)) / (camera.right - camera.left);
267
- const ny = (camera.position.y + camera.top - anchor.y) / (camera.top - camera.bottom);
266
+ const at = worldToNormalized(view.camera, anchorPoint(instance, view.projection));
267
+ // Behind a perspective camera there is no place on screen to put it.
268
+ if (!at)
269
+ return { x: 0, y: 0, clipped: true, depth: 1, behind: true };
270
+ const { nx, ny } = at;
271
+ const { viewport } = view;
268
272
  return {
269
273
  x: whole(nx * viewport.width),
270
274
  y: whole(ny * viewport.height),
@@ -16,8 +16,8 @@ export interface ArchetypeArt {
16
16
  file: string;
17
17
  /** Registry URI resolved by the archetype at runtime. */
18
18
  uri: string;
19
- /** What kind of asset this is — a sprite sheet or texture, or a sound file. */
20
- kind: 'image' | 'sound';
19
+ /** What kind of asset this is — a sprite sheet or texture, a sound file, or a glTF model. */
20
+ kind: 'image' | 'sound' | 'model';
21
21
  }
22
22
  /** The conventional contract exported by every archetype package. */
23
23
  export interface ArchetypeManifest {
@@ -1,10 +1,12 @@
1
1
  import * as THREE from 'three/webgpu';
2
+ import { type ModelBackend } from './model-backend.js';
3
+ import { type ModelHandle } from './model-cache.js';
2
4
  import { type TextureBackend } from './texture-backend.js';
3
- /** `game.assets.status`: a fresh snapshot of the cache's counters, cumulative for the Game. */
5
+ /** `game.assets.status`: a fresh snapshot of the caches' counters, textures and models together, cumulative for the Game. */
4
6
  export interface AssetStatus {
5
7
  /** Requested URLs that have not settled yet. */
6
8
  pending: number;
7
- /** Settled URLs whose image arrived. */
9
+ /** Settled URLs whose image or model arrived. */
8
10
  loaded: number;
9
11
  /** Settled URLs whose load failed — recorded, warned once, never retried. */
10
12
  failed: number;
@@ -18,6 +20,8 @@ export interface TextureHandle {
18
20
  export interface AssetLoaderOptions {
19
21
  /** Replaces the real `THREE.TextureLoader` implementation (ADR 0013); defaults to it. */
20
22
  backend?: TextureBackend;
23
+ /** Replaces the real `GLTFLoader` implementation behind `model()`; defaults to it. */
24
+ models?: ModelBackend;
21
25
  /**
22
26
  * Resolves a uri for `preload()` the way the scene loader resolves every
23
27
  * prefab's string prop (`resolveProps` in scene.ts). Looked up on every
@@ -28,7 +32,7 @@ export interface AssetLoaderOptions {
28
32
  resolveAsset?: (uri: string) => string;
29
33
  }
30
34
  /**
31
- * The engine's texture cache (`game.assets`, ADR 0019): keep-all for the
35
+ * The engine's asset cache (`game.assets`, ADR 0019): keep-all for the
32
36
  * Game's whole life, keyed by the URL as received, one backend load and one
33
37
  * base texture per URL. `ready()` is the **Assets Ready** signal — a
34
38
  * promise beside the synchronous scene load — and a failed image is
@@ -37,8 +41,11 @@ export interface AssetLoaderOptions {
37
41
  */
38
42
  export declare class AssetLoader {
39
43
  private readonly backend;
44
+ private readonly models;
40
45
  private readonly resolveAsset;
41
46
  private readonly entries;
47
+ /** Work other than a texture or a model that counts as an asset (the physics module), until it settles. */
48
+ private readonly tracked;
42
49
  private pendingCount;
43
50
  private loadedCount;
44
51
  private failedCount;
@@ -52,10 +59,25 @@ export declare class AssetLoader {
52
59
  * settlement of that URL, which also fires on a cache hit.
53
60
  */
54
61
  texture(url: string): TextureHandle;
62
+ /**
63
+ * The consumer entry for glTF (Model): requests `url` — already resolved
64
+ * by `resolveProps` — and returns at once a root that fills with the
65
+ * consumer's own clone of the parsed file when it settles, with the file's
66
+ * animation clips. One backend load per URL, kept for the Game's life.
67
+ */
68
+ model(url: string): ModelHandle;
69
+ /**
70
+ * Counts `work` as an asset (the physics module is one): `pending` is one
71
+ * higher until it settles, then `loaded` or, when it rejects, `failed` with
72
+ * one warning naming `label`. Assets Ready waits for it. Never rejects.
73
+ */
74
+ track(work: Promise<unknown>, label: string): void;
55
75
  /**
56
76
  * Requests every uri ahead of time, each resolved through the registered
57
- * scene catalog. Resolves once all of them settled, failures included —
58
- * a failure still only warns once, the same as a component's request.
77
+ * scene catalog: a `.glb` or `.gltf` goes to the model cache, anything
78
+ * else to the texture cache. Resolves once all of them settled, failures
79
+ * included — a failure still only warns once, the same as a component's
80
+ * request.
59
81
  */
60
82
  preload(uris: string[]): Promise<void>;
61
83
  /**
@@ -69,6 +91,7 @@ export declare class AssetLoader {
69
91
  ready(): Promise<void>;
70
92
  /** Disposes every cached base exactly once and forgets everything; called by `Game.dispose()`. */
71
93
  dispose(): void;
94
+ private settleTracked;
72
95
  private request;
73
96
  private load;
74
97
  /** One backend load as an outcome, never a rejection — a backend that throws synchronously included. */
@@ -1,4 +1,6 @@
1
1
  import * as THREE from 'three/webgpu';
2
+ import { GltfModelBackend } from './model-backend.js';
3
+ import { ModelCache } from './model-cache.js';
2
4
  import { ThreeTextureBackend } from './texture-backend.js';
3
5
  import { reportRejection } from '../report-rejection.js';
4
6
  /**
@@ -18,8 +20,13 @@ function cloneOf(base) {
18
20
  clone.version = base.version;
19
21
  return clone;
20
22
  }
23
+ /** Whether a resolved uri names a glTF file: `.glb` or `.gltf`, ignoring a query string or fragment. */
24
+ function isModelUrl(url) {
25
+ const path = (url.split(/[?#]/, 1)[0] ?? '').toLowerCase();
26
+ return path.endsWith('.glb') || path.endsWith('.gltf');
27
+ }
21
28
  /**
22
- * The engine's texture cache (`game.assets`, ADR 0019): keep-all for the
29
+ * The engine's asset cache (`game.assets`, ADR 0019): keep-all for the
23
30
  * Game's whole life, keyed by the URL as received, one backend load and one
24
31
  * base texture per URL. `ready()` is the **Assets Ready** signal — a
25
32
  * promise beside the synchronous scene load — and a failed image is
@@ -28,18 +35,27 @@ function cloneOf(base) {
28
35
  */
29
36
  export class AssetLoader {
30
37
  backend;
38
+ models;
31
39
  resolveAsset;
32
40
  entries = new Map();
41
+ /** Work other than a texture or a model that counts as an asset (the physics module), until it settles. */
42
+ tracked = new Set();
33
43
  pendingCount = 0;
34
44
  loadedCount = 0;
35
45
  failedCount = 0;
36
46
  constructor(options = {}) {
37
47
  this.backend = options.backend ?? new ThreeTextureBackend();
48
+ this.models = new ModelCache(options.models ?? new GltfModelBackend());
38
49
  this.resolveAsset = options.resolveAsset ?? ((uri) => uri);
39
50
  }
40
51
  /** A fresh object on every read: `pending` is current, `loaded` and `failed` only grow until `dispose()`. */
41
52
  get status() {
42
- return { pending: this.pendingCount, loaded: this.loadedCount, failed: this.failedCount };
53
+ const models = this.models.counts;
54
+ return {
55
+ pending: this.pendingCount + models.pending,
56
+ loaded: this.loadedCount + models.loaded,
57
+ failed: this.failedCount + models.failed,
58
+ };
43
59
  }
44
60
  /**
45
61
  * The consumer entry (Sprite, AnimatedSprite, Tilemap): requests `url`
@@ -60,13 +76,41 @@ export class AssetLoader {
60
76
  }
61
77
  return { texture, settled: entry.settled };
62
78
  }
79
+ /**
80
+ * The consumer entry for glTF (Model): requests `url` — already resolved
81
+ * by `resolveProps` — and returns at once a root that fills with the
82
+ * consumer's own clone of the parsed file when it settles, with the file's
83
+ * animation clips. One backend load per URL, kept for the Game's life.
84
+ */
85
+ model(url) {
86
+ return this.models.handle(url);
87
+ }
88
+ /**
89
+ * Counts `work` as an asset (the physics module is one): `pending` is one
90
+ * higher until it settles, then `loaded` or, when it rejects, `failed` with
91
+ * one warning naming `label`. Assets Ready waits for it. Never rejects.
92
+ */
93
+ track(work, label) {
94
+ this.pendingCount += 1;
95
+ const settled = work.then(() => this.settleTracked(settled, 'loaded'), (error) => {
96
+ console.warn(`[waica] assets: failed to load "${label}"`, error);
97
+ this.settleTracked(settled, 'failed');
98
+ });
99
+ this.tracked.add(settled);
100
+ }
63
101
  /**
64
102
  * Requests every uri ahead of time, each resolved through the registered
65
- * scene catalog. Resolves once all of them settled, failures included —
66
- * a failure still only warns once, the same as a component's request.
103
+ * scene catalog: a `.glb` or `.gltf` goes to the model cache, anything
104
+ * else to the texture cache. Resolves once all of them settled, failures
105
+ * included — a failure still only warns once, the same as a component's
106
+ * request.
67
107
  */
68
108
  async preload(uris) {
69
- await Promise.all(uris.map((uri) => this.request(this.resolveAsset(uri)).settled));
109
+ await Promise.all(uris.map((uri) => {
110
+ const url = this.resolveAsset(uri);
111
+ // A resolver may hand back a url with no extension (a blob: or data: one): the uri as written still names the file.
112
+ return isModelUrl(uri) || isModelUrl(url) ? this.models.preload(url) : this.request(url).settled;
113
+ }));
70
114
  }
71
115
  /**
72
116
  * Assets Ready: resolves the first time `pending` is 0 after the call —
@@ -77,20 +121,32 @@ export class AssetLoader {
77
121
  * first frame with the textures in place.
78
122
  */
79
123
  async ready() {
80
- while (this.pendingCount > 0) {
124
+ while (this.status.pending > 0) {
81
125
  const inFlight = [...this.entries.values()].filter((entry) => entry.outcome === null);
82
- await Promise.all(inFlight.map((entry) => entry.settled));
126
+ await Promise.all([...inFlight.map((entry) => entry.settled), ...this.models.inFlight(), ...this.tracked]);
83
127
  }
84
128
  }
85
129
  /** Disposes every cached base exactly once and forgets everything; called by `Game.dispose()`. */
86
130
  dispose() {
87
131
  for (const entry of this.entries.values())
88
132
  entry.base.dispose();
133
+ this.models.dispose();
89
134
  this.entries.clear();
135
+ this.tracked.clear();
90
136
  this.pendingCount = 0;
91
137
  this.loadedCount = 0;
92
138
  this.failedCount = 0;
93
139
  }
140
+ settleTracked(settled, outcome) {
141
+ // A dispose() in flight already forgot it and reset the counters.
142
+ if (!this.tracked.delete(settled))
143
+ return;
144
+ this.pendingCount -= 1;
145
+ if (outcome === 'loaded')
146
+ this.loadedCount += 1;
147
+ else
148
+ this.failedCount += 1;
149
+ }
94
150
  request(url) {
95
151
  const existing = this.entries.get(url);
96
152
  if (existing)
@@ -0,0 +1,21 @@
1
+ import type * as THREE from 'three/webgpu';
2
+ /** What a model backend hands the cache: the glTF's scene graph and its animation clips. */
3
+ export interface LoadedModel {
4
+ scene: THREE.Group;
5
+ animations: THREE.AnimationClip[];
6
+ }
7
+ /**
8
+ * The seam between `game.assets.model()` and the browser (ADR 0013's move,
9
+ * applied to glTF — ADR 0019). `load` resolves once the file behind `url` is
10
+ * parsed, embedded textures included; it rejects on a load error and never
11
+ * throws synchronously. A test injects `FakeModelBackend`
12
+ * (assets/test-helpers.ts) through `GameOptions.models`.
13
+ */
14
+ export interface ModelBackend {
15
+ load(url: string): Promise<LoadedModel>;
16
+ }
17
+ /** The real implementation: one `GLTFLoader` per Game, wrapped in a promise. */
18
+ export declare class GltfModelBackend implements ModelBackend {
19
+ private readonly loader;
20
+ load(url: string): Promise<LoadedModel>;
21
+ }
@@ -0,0 +1,21 @@
1
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
2
+ /** What a loader reports (an `ErrorEvent`, a string, an `Error`) as an `Error` naming the file. */
3
+ function asError(error, url) {
4
+ if (error instanceof Error)
5
+ return error;
6
+ return new Error(`failed to load "${url}"`, { cause: error });
7
+ }
8
+ /** The real implementation: one `GLTFLoader` per Game, wrapped in a promise. */
9
+ export class GltfModelBackend {
10
+ loader = new GLTFLoader();
11
+ load(url) {
12
+ return new Promise((resolve, reject) => {
13
+ try {
14
+ this.loader.load(url, (gltf) => resolve({ scene: gltf.scene, animations: gltf.animations }), undefined, (error) => reject(asError(error, url)));
15
+ }
16
+ catch (error) {
17
+ reject(asError(error, url));
18
+ }
19
+ });
20
+ }
21
+ }
@@ -0,0 +1,47 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import type { ModelBackend } from './model-backend.js';
3
+ export type ModelOutcome = 'loaded' | 'failed';
4
+ /**
5
+ * What a consumer gets from `game.assets.model(url)`: a root that is empty
6
+ * until the glTF settles and then holds the consumer's own clone of it, the
7
+ * file's animation clips (empty until then, and for a failed load), and when
8
+ * the file settled. The cached glTF itself is never handed out.
9
+ */
10
+ export interface ModelHandle {
11
+ readonly root: THREE.Group;
12
+ readonly animations: readonly THREE.AnimationClip[];
13
+ readonly settled: Promise<ModelOutcome>;
14
+ }
15
+ /** The cache's counters, summed into `game.assets.status` beside the textures'. */
16
+ export interface ModelCounts {
17
+ pending: number;
18
+ loaded: number;
19
+ failed: number;
20
+ }
21
+ /**
22
+ * The model half of `game.assets` (ADR 0019): keep-all for the Game's whole
23
+ * life, keyed by the URL as received, one backend load and one parsed glTF
24
+ * per URL, counted like the texture cache so Assets Ready waits for it. A
25
+ * failed file is recorded and warned about once, never retried.
26
+ */
27
+ export declare class ModelCache {
28
+ private readonly backend;
29
+ private readonly entries;
30
+ private pendingCount;
31
+ private loadedCount;
32
+ private failedCount;
33
+ constructor(backend: ModelBackend);
34
+ get counts(): ModelCounts;
35
+ /** The consumer entry: requests `url` and returns a handle whose root fills with a clone once the file settles. */
36
+ handle(url: string): ModelHandle;
37
+ /** Requests `url` ahead of time; resolves with its outcome. */
38
+ preload(url: string): Promise<ModelOutcome>;
39
+ /** The settlements of every file that has not settled yet, for Assets Ready. */
40
+ inFlight(): Promise<ModelOutcome>[];
41
+ /** Disposes every cached glTF exactly once and forgets everything. */
42
+ dispose(): void;
43
+ private request;
44
+ private load;
45
+ /** One backend load as an outcome, never a rejection — a backend that throws synchronously included. */
46
+ private attempt;
47
+ }
@@ -0,0 +1,117 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { clone as cloneWithSkeleton } from 'three/addons/utils/SkeletonUtils.js';
3
+ function isMesh(object) {
4
+ return object instanceof THREE.Mesh;
5
+ }
6
+ /** The textures a material references, for disposal. */
7
+ function texturesOf(material) {
8
+ const values = Object.values(material);
9
+ return values.filter((value) => value instanceof THREE.Texture);
10
+ }
11
+ /** Disposes the geometry, materials and textures a glTF scene owns. */
12
+ function disposeScene(scene) {
13
+ scene.traverse((object) => {
14
+ if (!isMesh(object))
15
+ return;
16
+ object.geometry.dispose();
17
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
18
+ for (const material of materials) {
19
+ for (const texture of texturesOf(material))
20
+ texture.dispose();
21
+ material.dispose();
22
+ }
23
+ });
24
+ }
25
+ /**
26
+ * The model half of `game.assets` (ADR 0019): keep-all for the Game's whole
27
+ * life, keyed by the URL as received, one backend load and one parsed glTF
28
+ * per URL, counted like the texture cache so Assets Ready waits for it. A
29
+ * failed file is recorded and warned about once, never retried.
30
+ */
31
+ export class ModelCache {
32
+ backend;
33
+ entries = new Map();
34
+ pendingCount = 0;
35
+ loadedCount = 0;
36
+ failedCount = 0;
37
+ constructor(backend) {
38
+ this.backend = backend;
39
+ }
40
+ get counts() {
41
+ return { pending: this.pendingCount, loaded: this.loadedCount, failed: this.failedCount };
42
+ }
43
+ /** The consumer entry: requests `url` and returns a handle whose root fills with a clone once the file settles. */
44
+ handle(url) {
45
+ const entry = this.request(url);
46
+ const root = new THREE.Group();
47
+ const animations = [];
48
+ const settled = entry.settled.then((outcome) => {
49
+ const model = entry.base;
50
+ if (outcome === 'loaded' && model && this.entries.get(url) === entry) {
51
+ root.add(cloneWithSkeleton(model.scene));
52
+ animations.push(...model.animations);
53
+ }
54
+ return outcome;
55
+ });
56
+ return { root, animations, settled };
57
+ }
58
+ /** Requests `url` ahead of time; resolves with its outcome. */
59
+ preload(url) {
60
+ return this.request(url).settled;
61
+ }
62
+ /** The settlements of every file that has not settled yet, for Assets Ready. */
63
+ inFlight() {
64
+ return [...this.entries.values()].filter((entry) => entry.outcome === null).map((entry) => entry.settled);
65
+ }
66
+ /** Disposes every cached glTF exactly once and forgets everything. */
67
+ dispose() {
68
+ for (const entry of this.entries.values())
69
+ if (entry.base)
70
+ disposeScene(entry.base.scene);
71
+ this.entries.clear();
72
+ this.pendingCount = 0;
73
+ this.loadedCount = 0;
74
+ this.failedCount = 0;
75
+ }
76
+ request(url) {
77
+ const existing = this.entries.get(url);
78
+ if (existing)
79
+ return existing;
80
+ const entry = { base: null, outcome: null, settled: Promise.resolve('failed') };
81
+ // Registered and counted before the backend runs, so a backend that
82
+ // throws synchronously still settles this very entry.
83
+ this.entries.set(url, entry);
84
+ this.pendingCount += 1;
85
+ entry.settled = this.load(url, entry);
86
+ return entry;
87
+ }
88
+ async load(url, entry) {
89
+ const attempt = await this.attempt(url);
90
+ // A dispose() in flight already forgot this entry: only the settlement is still owed.
91
+ if (this.entries.get(url) !== entry) {
92
+ if (attempt.outcome === 'loaded')
93
+ disposeScene(attempt.model.scene);
94
+ return attempt.outcome;
95
+ }
96
+ if (attempt.outcome === 'loaded') {
97
+ entry.base = attempt.model;
98
+ this.loadedCount += 1;
99
+ }
100
+ else {
101
+ console.warn(`[waica] assets: failed to load "${url}"`, attempt.error);
102
+ this.failedCount += 1;
103
+ }
104
+ entry.outcome = attempt.outcome;
105
+ this.pendingCount -= 1;
106
+ return attempt.outcome;
107
+ }
108
+ /** One backend load as an outcome, never a rejection — a backend that throws synchronously included. */
109
+ async attempt(url) {
110
+ try {
111
+ return { outcome: 'loaded', model: await this.backend.load(url) };
112
+ }
113
+ catch (error) {
114
+ return { outcome: 'failed', error };
115
+ }
116
+ }
117
+ }