@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.
- package/README.md +77 -1
- package/dist/anchored-pieces.d.ts +3 -2
- package/dist/anchored-pieces.js +12 -8
- package/dist/animation/clip-player.d.ts +7 -0
- package/dist/animation/clip-player.js +9 -0
- package/dist/animation/clip-target.d.ts +38 -0
- package/dist/animation/clip-target.js +9 -0
- package/dist/animation/pose-hash.d.ts +10 -0
- package/dist/animation/pose-hash.js +43 -0
- package/dist/archetype.d.ts +2 -2
- package/dist/assets/asset-loader.d.ts +28 -5
- package/dist/assets/asset-loader.js +63 -7
- package/dist/assets/model-backend.d.ts +21 -0
- package/dist/assets/model-backend.js +21 -0
- package/dist/assets/model-cache.d.ts +47 -0
- package/dist/assets/model-cache.js +117 -0
- package/dist/audio/audio-subsystem.d.ts +6 -1
- package/dist/audio/audio-subsystem.js +8 -15
- package/dist/audio/placement.d.ts +41 -0
- package/dist/audio/placement.js +21 -0
- package/dist/audio/spatial.d.ts +20 -0
- package/dist/audio/spatial.js +26 -0
- package/dist/audio/types.d.ts +7 -1
- package/dist/camera-projection.d.ts +38 -0
- package/dist/camera-projection.js +44 -0
- package/dist/camera-rig.d.ts +48 -0
- package/dist/camera-rig.js +98 -0
- package/dist/camera.d.ts +20 -3
- package/dist/camera.js +3 -1
- package/dist/component.d.ts +38 -2
- package/dist/component.js +3 -0
- package/dist/components/animated-sprite.d.ts +14 -3
- package/dist/components/animated-sprite.js +16 -2
- package/dist/components/animator.d.ts +98 -0
- package/dist/components/animator.js +197 -0
- package/dist/components/collider.d.ts +89 -0
- package/dist/components/collider.js +57 -0
- package/dist/components/dynamic-body.d.ts +2 -1
- package/dist/components/dynamic-body.js +1 -0
- package/dist/components/hitbox.d.ts +2 -1
- package/dist/components/hitbox.js +1 -0
- package/dist/components/light.d.ts +2 -1
- package/dist/components/light.js +1 -0
- package/dist/components/model.d.ts +71 -0
- package/dist/components/model.js +128 -0
- package/dist/components/particle-emitter.d.ts +2 -1
- package/dist/components/particle-emitter.js +1 -0
- package/dist/components/point-light.d.ts +75 -0
- package/dist/components/point-light.js +102 -0
- package/dist/components/rigid-body.d.ts +114 -0
- package/dist/components/rigid-body.js +83 -0
- package/dist/components/solid.d.ts +2 -1
- package/dist/components/solid.js +1 -0
- package/dist/components/sprite.d.ts +2 -1
- package/dist/components/sprite.js +1 -0
- package/dist/components/sun.d.ts +46 -0
- package/dist/components/sun.js +74 -0
- package/dist/components/tilemap.d.ts +2 -1
- package/dist/components/tilemap.js +1 -0
- package/dist/entity-transform.d.ts +18 -0
- package/dist/entity-transform.js +17 -0
- package/dist/game.d.ts +51 -15
- package/dist/game.js +83 -70
- package/dist/index.d.ts +34 -4
- package/dist/index.js +15 -1
- package/dist/lit-frame.js +10 -3
- package/dist/physics-3d/body-contacts.d.ts +10 -0
- package/dist/physics-3d/body-contacts.js +59 -0
- package/dist/physics-3d/body-sync.d.ts +52 -0
- package/dist/physics-3d/body-sync.js +153 -0
- package/dist/physics-3d/character-controller.d.ts +44 -0
- package/dist/physics-3d/character-controller.js +126 -0
- package/dist/physics-3d/physics-host.d.ts +86 -0
- package/dist/physics-3d/physics-host.js +174 -0
- package/dist/physics-3d/physics-world.d.ts +65 -0
- package/dist/physics-3d/physics-world.js +158 -0
- package/dist/physics-3d/rapier-module.d.ts +17 -0
- package/dist/physics-3d/rapier-module.js +19 -0
- package/dist/physics-3d/sensor-dispatch.d.ts +10 -0
- package/dist/physics-3d/sensor-dispatch.js +77 -0
- package/dist/physics-3d/world-queries.d.ts +39 -0
- package/dist/physics-3d/world-queries.js +63 -0
- package/dist/pointer-raycast.d.ts +17 -0
- package/dist/pointer-raycast.js +48 -0
- package/dist/pointer.d.ts +27 -6
- package/dist/pointer.js +43 -17
- package/dist/runtime-bridge.d.ts +1 -1
- package/dist/runtime-inspection.d.ts +9 -0
- package/dist/runtime-inspection.js +8 -2
- package/dist/runtime-lighting-snapshot.d.ts +27 -1
- package/dist/runtime-lighting-snapshot.js +31 -0
- package/dist/runtime-physics-snapshot.d.ts +41 -0
- package/dist/runtime-physics-snapshot.js +45 -0
- package/dist/runtime-view-snapshot.d.ts +25 -0
- package/dist/runtime-view-snapshot.js +30 -0
- package/dist/scene-camera-3d.d.ts +66 -0
- package/dist/scene-camera-3d.js +117 -0
- package/dist/scene-lights-3d.d.ts +17 -0
- package/dist/scene-lights-3d.js +27 -0
- package/dist/scene-simulation.d.ts +22 -0
- package/dist/scene-simulation.js +29 -0
- package/dist/scene-space.d.ts +50 -0
- package/dist/scene-space.js +103 -0
- package/dist/scene.d.ts +12 -2
- package/dist/scene.js +11 -4
- package/dist/simulation-phases.d.ts +9 -0
- package/dist/simulation-phases.js +19 -0
- package/dist/spatial-query-3d.d.ts +48 -0
- package/dist/spatial-query-3d.js +96 -0
- package/dist/spatial-query.d.ts +18 -4
- package/dist/spatial-query.js +57 -1
- package/dist/state/state-machine.d.ts +32 -8
- package/dist/state/state-machine.js +88 -27
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -25,6 +25,36 @@ 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.27.0: 3D animation
|
|
29
|
+
|
|
30
|
+
A glTF's clips play (issue #166, ADR 0029): a `Model` keeps its `handle`, an `Animator` beside it plays the clips, a `StateMachine` drives either kind of Clip Target through one seam, and a 3D character turns to face its walk. Every 2D scene file loads as it did; the seam is additive, one type changes (`TriggerEnv.clipFinished`) and two behaviours change (`play` restarts a finished clip, a `CharacterMotor` turns by default):
|
|
31
|
+
|
|
32
|
+
- **The state machine plays through the Clip Target.** `StateMachine` no longer names `AnimatedSprite`: it plays each state's clip through the first sibling that satisfies `isClipTarget` (`{ current, clipNames, clipFinished, clipsPending?, play(name), setFlipX? }`), which is the `AnimatedSprite` in a 2D scene and the `Animator` in a 3D one. A project component that plays clips can be that target by exposing the same members. A target whose clips are pending (`clipsPending`: an Animator whose glb is still loading) is asked anyway and remembers the request; a sprite with no clips is not pending and still warns about its states. A mirrored directional clip on a target without `setFlipX` plays unmirrored and warns once per state.
|
|
33
|
+
- **`TriggerEnv.clipFinished`.** Code that builds a `TriggerEnv` for `evaluateTrigger` or `nextTransition` (tests of role graphs, mostly) adds `clipFinished: false`; the new `clip:end` trigger reads it.
|
|
34
|
+
- **`play(current)` restarts a finished clip.** `AnimatedSprite.play(name)` is still a no-op for the clip that is running, but once a `loop: false` clip has ended, playing it again restarts it from frame 0 (and clears `clipFinished`). A project that re-entered a state to keep holding a one-shot's last frame now sees the clip replay; keep the entity in its state instead.
|
|
35
|
+
- **A `CharacterMotor` turns by default.** `turnSpeed` (degrees per second, default 540) turns the entity's yaw toward where it walks; set `turnSpeed: 0` to snap, and keep the yaw out of your own code's hands or they will fight. The kinematic body is unaffected.
|
|
36
|
+
|
|
37
|
+
## Migrating to 0.26.0: 3D scenes
|
|
38
|
+
|
|
39
|
+
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:
|
|
40
|
+
|
|
41
|
+
- **`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.
|
|
42
|
+
- **`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).
|
|
43
|
+
- **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.
|
|
44
|
+
- **`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.
|
|
45
|
+
- **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.
|
|
46
|
+
- **`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.
|
|
47
|
+
- **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:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// vite.config.ts
|
|
51
|
+
export default defineConfig({
|
|
52
|
+
resolve: { alias: [{ find: /^three$/, replacement: 'three/webgpu' }] },
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`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'`.
|
|
57
|
+
|
|
28
58
|
## Hitbox Collision Layers and Masks
|
|
29
59
|
|
|
30
60
|
Every `Hitbox` belongs to one named Collision Layer and declares the other layers in which it is interested through a Collision Mask:
|
|
@@ -207,13 +237,58 @@ A scene can be lit (issue #78, ADR 0026). Without lighting and without a Post Ef
|
|
|
207
237
|
|
|
208
238
|
The CPU reference of a light-map texel — `lightFalloff`, `lightVisibility`, `lightMapValue` — is exported for tools and tests; the shader follows it.
|
|
209
239
|
|
|
240
|
+
## 3D scenes
|
|
241
|
+
|
|
242
|
+
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`).
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"waicaScene": 3,
|
|
247
|
+
"render": { "space": "3d", "lighting": { "ambient": { "color": "#b8c8ff", "intensity": 0.6 } } },
|
|
248
|
+
"camera": { "kind": "perspective", "position": [0, 5.5, 11], "target": [0, 0.8, 0], "fov": 50 },
|
|
249
|
+
"entities": [
|
|
250
|
+
{ "name": "Ground", "scale": [24, 1, 24], "components": [{ "type": "Model", "props": { "shape": "plane", "color": 5081930 } }] },
|
|
251
|
+
{ "name": "Tree", "position": [4, 0, -1], "components": [{ "type": "Model", "props": { "src": "src/art/tree.glb" } }] },
|
|
252
|
+
{ "name": "Daylight", "components": [{ "type": "Sun", "props": { "direction": [-0.5, -1, -0.35], "intensity": 3 } }] },
|
|
253
|
+
{ "name": "Lamp", "position": [2.5, 0, 3.5], "components": [{ "type": "PointLight", "props": { "intensity": 14, "distance": 10, "offsetY": 1.2 } }] }
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
- **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.
|
|
259
|
+
- **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. `handle` is the `ModelHandle` of the file it draws (null for a primitive): its clips play through an `Animator` beside it.
|
|
260
|
+
- **Animator.** Plays the glTF clips of the `Model` beside it (ADR 0029): one `AnimationMixer` over the entity's own clone, advanced by the Simulation Step, nothing per render frame. It is the 3D Clip Target, so a `StateMachine` plays each state's clip through it and `clip:end` fires when a `loop: false` clip finishes. Params: `initialClip` (plays when the file settles, if nothing asked for a clip before it loaded — a `play` before the model loads is remembered and applied then; `clipsPending` is true meanwhile, so a `StateMachine` asks for its state's clip anyway) and `fade` (seconds of crossfade, default 0.15; `0` cuts). The `clips` prop, authored in JSON like `AnimatedSprite.clips`, holds per-clip overrides keyed by the glTF clip name: `{ loop: false }` plays once and holds the last pose, `{ speed: 2 }` doubles the rate; they are read when the actions are built, as the file settles. A Model rebuild (a new `src` or `shape`) is noticed on the next step: the Animator drops the mixer over the old clone, attaches to the new one and asks for the clip it was playing. Clip names match the file exactly (a state's `clip` override maps `idle` to a pack's `Idle`); an unknown name warns once. `inspect_runtime` shows `{ loaded, current, time, playing, poseHash }`. In a 2D scene it creates nothing and `play` is a no-op; without a `Model` file beside it it warns once at runtime and `validate_project` reports `animator-without-model` per scene entity (an instance may complete its prefab with a `Model.src` override), while `missing-clip` checks its states, `initialClip` and override keys against the glb's clips, on the prefab and again only on an instance that changes them.
|
|
261
|
+
- **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).
|
|
262
|
+
- **`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.
|
|
263
|
+
- **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.
|
|
264
|
+
- **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`).
|
|
265
|
+
- **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.
|
|
266
|
+
- **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.
|
|
267
|
+
- **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`, `turnSpeed` 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`, and `invalid-character-motor-param` a param outside the range its spec declares.
|
|
268
|
+
- **Character.** A 3D character faces where it walks by turning, not by directional clips or flips (issue #166): each step it has a non-zero desired velocity, `CharacterMotor` turns the entity's yaw toward it through `turnYawToward(node, dirX, dirZ, turnSpeed, dt)` (`@waica/behaviors`; the target yaw is `atan2(dirX, dirZ)`, so a glTF's +z front leads; the shortest arc, at most `turnSpeed` degrees per second), and with no input, or a `speed` of 0, the yaw holds. `turnSpeed` defaults to 540 (about a third of a second for a U-turn), `0` snaps, and the kinematic body is unaffected: it keeps the rotation it spawned with, so give a turning character a `Collider` that looks the same from every side — a capsule or a sphere with no x/z `offset` — or the model and a box or offset collider disagree about where it ends. The `character` role (`CHARACTER_ROLE`, `CHARACTER_STATE_GRAPH`, `characterUpdate`) is the 3D counterpart of `player`: `idle`, `walk`, `jump` and `fall` on the signals its update emits by reading the kinematic `RigidBody` — `land` plus `move` (planar speed above 0.1 u/s) or `stop` on the ground, `rise` or `fall` in the air once it has vertical speed (air with none — the spawn, the steps while Rapier loads, the step after a teleport — reports nothing) — while the motor keeps reading the input itself; an `Animator` beside the machine plays each state's clip (a per-state `clip` maps `idle` to a pack's `Idle`). A project registers it with `defineRole('character', CHARACTER_ROLE)` in `src/roles/character.ts`; `validate_project` recognizes a `@waica/behaviors` role passed by reference there, so its states report no `no-state-code`.
|
|
269
|
+
- **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.
|
|
270
|
+
- **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.
|
|
271
|
+
- **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).
|
|
272
|
+
- **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.
|
|
273
|
+
|
|
274
|
+
## State machines
|
|
275
|
+
|
|
276
|
+
A `StateMachine` owns a character's per-frame logic: its `states` and their `transitions` are prefab data, the state code comes from the role's logic set (`defineRole`, `defineStates`), and `current` names the active state. Each transition is `{ on, to }`; a trigger is one of:
|
|
277
|
+
|
|
278
|
+
- `input:<action>` — an unconsumed press of the action this Simulation Step; the transition spends it.
|
|
279
|
+
- `timer:<seconds>` — the state has been active for at least that long (exact at the fixed step: `timer:0.3` is 18 steps).
|
|
280
|
+
- `signal:<name>` — the state code queued `fsm.signal(name)` this step or the last.
|
|
281
|
+
- `clip:end` — the Clip Target reports the clip the current state asked for finished (issue #166). It fires on the step after the clip's last frame has been held for its whole duration, and never for a looping clip (`validate_project` warns `clip-end-on-looping-clip`, the directional `<state>-<dir>` clips and the contract's fallbacks included). A state whose clip the target lacks keeps the previous clip playing but never reads its end as its own. Re-entering a state whose one-shot clip already ended replays it.
|
|
282
|
+
|
|
283
|
+
Entering a state plays the clip of its name (or its `clip` override) through the entity's **Clip Target**: the first sibling component that plays clips — an `AnimatedSprite` in a 2D scene, an `Animator` in a 3D one — found by `isClipTarget` (a `play` function and a `clipNames` member), not by class. The directional contract resolves `<state>-<dir>` against the target's `clipNames` and mirrors only a target that defines `setFlipX`; a mirrored clip on a target without it plays unmirrored and warns once per state. A turn that only mirrors the running clip (west playing east flipped) re-applies the mirror and leaves the clip alone — a finished one-shot keeps its last frame — while a turn onto another clip name plays it. A clip the target lacks warns once and keeps the previous one; a target whose clips are pending (an `Animator` whose glb is loading) is asked anyway and remembers the request.
|
|
284
|
+
|
|
210
285
|
## Component lifecycle
|
|
211
286
|
|
|
212
287
|
Waica keeps the lifecycle boundaries distinct:
|
|
213
288
|
|
|
214
289
|
1. `Entity.add()` mounts a component and calls its `onReady` immediately. This remains component insertion order so setup behavior does not silently move.
|
|
215
290
|
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.
|
|
216
|
-
3. Physical `onContact` hooks run from `DynamicBody` while it updates. Hitbox `onCollide` hooks run
|
|
291
|
+
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.
|
|
217
292
|
4. `Game.onUpdate` callbacks run after component updates, collisions, and camera work; input end-of-frame handling follows them.
|
|
218
293
|
5. `Entity.destroy()` calls `onDestroy` in component insertion order.
|
|
219
294
|
|
|
@@ -380,5 +455,6 @@ game.assets.status // { pending, loaded, failed } — a fresh object on every re
|
|
|
380
455
|
- **`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.
|
|
381
456
|
- **`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`.
|
|
382
457
|
- **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.
|
|
458
|
+
- **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.
|
|
383
459
|
- **`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.
|
|
384
460
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { type ViewCamera } from './camera-projection.js';
|
|
1
2
|
import type { Entity } from './entity.js';
|
|
2
|
-
import type {
|
|
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:
|
|
47
|
+
camera: ViewCamera;
|
|
47
48
|
/** The game viewport: see gameViewport. */
|
|
48
49
|
viewport: ViewportRect;
|
|
49
50
|
projection: 'isometric' | null;
|
package/dist/anchored-pieces.js
CHANGED
|
@@ -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
|
-
//
|
|
158
|
-
layer.style.setProperty('--waica-unit', `${
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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),
|
|
@@ -14,6 +14,13 @@ export declare class ClipPlayer {
|
|
|
14
14
|
private fps;
|
|
15
15
|
private loop;
|
|
16
16
|
private t;
|
|
17
|
+
/**
|
|
18
|
+
* Whether a non-looping clip has played through: its time reached
|
|
19
|
+
* `frames.length / fps` (the moment the last frame has been held for its
|
|
20
|
+
* whole duration), with the fixed-step epsilon so 30 steps of 1/60 s end a
|
|
21
|
+
* 0.5 s clip on the 30th. A looping clip never finishes; `set` clears it.
|
|
22
|
+
*/
|
|
23
|
+
get finished(): boolean;
|
|
17
24
|
set(clip: ClipDef): void;
|
|
18
25
|
/** Advances the clock and returns the sheet frame to show. */
|
|
19
26
|
advance(dt: number): number;
|
|
@@ -8,6 +8,15 @@ export class ClipPlayer {
|
|
|
8
8
|
fps = 1;
|
|
9
9
|
loop = true;
|
|
10
10
|
t = 0;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a non-looping clip has played through: its time reached
|
|
13
|
+
* `frames.length / fps` (the moment the last frame has been held for its
|
|
14
|
+
* whole duration), with the fixed-step epsilon so 30 steps of 1/60 s end a
|
|
15
|
+
* 0.5 s clip on the 30th. A looping clip never finishes; `set` clears it.
|
|
16
|
+
*/
|
|
17
|
+
get finished() {
|
|
18
|
+
return !this.loop && this.t + SIMULATION_TIME_EPSILON >= this.frames.length / this.fps;
|
|
19
|
+
}
|
|
11
20
|
set(clip) {
|
|
12
21
|
this.frames = clip.frames.length > 0 ? clip.frames : [0];
|
|
13
22
|
this.fps = clip.fps;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Component } from '../component.js';
|
|
2
|
+
/**
|
|
3
|
+
* The Clip Target (issue #166): the one sibling component a StateMachine
|
|
4
|
+
* plays clips through — `AnimatedSprite` in a 2D scene, `Animator` in a 3D
|
|
5
|
+
* one. The machine asks for a clip by name on entering a state, reads which
|
|
6
|
+
* names exist and which one runs, and learns from `clipFinished` that a
|
|
7
|
+
* one-shot clip ended (the `clip:end` trigger). `setFlipX` is the 2D
|
|
8
|
+
* directional contract's mirror; a target without it is never flipped.
|
|
9
|
+
*/
|
|
10
|
+
export interface ClipTarget {
|
|
11
|
+
/** The clip playing now, undefined (or absent) before the first `play`. */
|
|
12
|
+
readonly current?: string;
|
|
13
|
+
/** Every name `play` accepts. */
|
|
14
|
+
readonly clipNames: readonly string[];
|
|
15
|
+
/** True once a non-looping clip reached its end; false again after `play`. */
|
|
16
|
+
readonly clipFinished: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* True while the target expects clips it cannot list yet (an Animator
|
|
19
|
+
* whose glb is loading): the machine asks for a clip anyway and the target
|
|
20
|
+
* remembers the request. Absent or false, an empty `clipNames` means the
|
|
21
|
+
* target has no clips, and a missing clip warns.
|
|
22
|
+
*/
|
|
23
|
+
readonly clipsPending?: boolean;
|
|
24
|
+
play(name: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* The 2D mirror. A target without it is never flipped: a directional clip
|
|
27
|
+
* resolved through a mirror plays unmirrored there, and the machine warns
|
|
28
|
+
* once per state.
|
|
29
|
+
*/
|
|
30
|
+
setFlipX?(flip: boolean): void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Duck-typed like the facing provider: the first sibling with a `play`
|
|
34
|
+
* function and a `clipNames` member is the target, whatever its class. The
|
|
35
|
+
* member is tested with `in`, never read: a target computes `clipNames` in a
|
|
36
|
+
* getter, and this runs on every Simulation Step of every machine.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isClipTarget(component: Component): component is Component & ClipTarget;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duck-typed like the facing provider: the first sibling with a `play`
|
|
3
|
+
* function and a `clipNames` member is the target, whatever its class. The
|
|
4
|
+
* member is tested with `in`, never read: a target computes `clipNames` in a
|
|
5
|
+
* getter, and this runs on every Simulation Step of every machine.
|
|
6
|
+
*/
|
|
7
|
+
export function isClipTarget(component) {
|
|
8
|
+
return 'play' in component && typeof component.play === 'function' && 'clipNames' in component;
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
/** The 32-bit FNV-1a hash of `text` (UTF-8), as eight lowercase hex digits. Synchronous and dependency-free, the same on every backend. */
|
|
3
|
+
export declare function fnv1a(text: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* A fingerprint of a rig's pose (issue #166 CA-10): FNV-1a over every bone's
|
|
6
|
+
* local quaternion and position rounded to 1e-4, in skeleton order, skeleton
|
|
7
|
+
* by skeleton. Empty with no skinned mesh under `root`. Two frames of the same
|
|
8
|
+
* clip differ; the same step of the same clip hashes alike on every platform.
|
|
9
|
+
*/
|
|
10
|
+
export declare function poseHash(root: THREE.Object3D): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
const FNV_OFFSET = 0x811c9dc5;
|
|
3
|
+
const FNV_PRIME = 0x01000193;
|
|
4
|
+
/** The 32-bit FNV-1a hash of `text` (UTF-8), as eight lowercase hex digits. Synchronous and dependency-free, the same on every backend. */
|
|
5
|
+
export function fnv1a(text) {
|
|
6
|
+
let hash = FNV_OFFSET;
|
|
7
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
8
|
+
hash ^= byte;
|
|
9
|
+
hash = Math.imul(hash, FNV_PRIME) >>> 0;
|
|
10
|
+
}
|
|
11
|
+
return hash.toString(16).padStart(8, '0');
|
|
12
|
+
}
|
|
13
|
+
const round = (value) => (Math.round(value * 1e4) / 1e4).toFixed(4);
|
|
14
|
+
/** A skinned mesh, typed for what a glTF gives it (the generics three leaves at `any`). */
|
|
15
|
+
function isSkinnedMesh(object) {
|
|
16
|
+
return object instanceof THREE.SkinnedMesh;
|
|
17
|
+
}
|
|
18
|
+
/** The skinned meshes under `root`, in traversal order. */
|
|
19
|
+
function skinnedMeshesUnder(root) {
|
|
20
|
+
const found = [];
|
|
21
|
+
root.traverse((object) => {
|
|
22
|
+
if (isSkinnedMesh(object))
|
|
23
|
+
found.push(object);
|
|
24
|
+
});
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A fingerprint of a rig's pose (issue #166 CA-10): FNV-1a over every bone's
|
|
29
|
+
* local quaternion and position rounded to 1e-4, in skeleton order, skeleton
|
|
30
|
+
* by skeleton. Empty with no skinned mesh under `root`. Two frames of the same
|
|
31
|
+
* clip differ; the same step of the same clip hashes alike on every platform.
|
|
32
|
+
*/
|
|
33
|
+
export function poseHash(root) {
|
|
34
|
+
const parts = [];
|
|
35
|
+
for (const mesh of skinnedMeshesUnder(root)) {
|
|
36
|
+
for (const bone of mesh.skeleton.bones) {
|
|
37
|
+
const { x, y, z, w } = bone.quaternion;
|
|
38
|
+
const { x: px, y: py, z: pz } = bone.position;
|
|
39
|
+
parts.push([x, y, z, w, px, py, pz].map(round).join(','));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return parts.length === 0 ? '' : fnv1a(parts.join(';'));
|
|
43
|
+
}
|
package/dist/archetype.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
58
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
66
|
-
*
|
|
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) =>
|
|
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.
|
|
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
|
+
}
|