@waica/engine 0.26.0 → 0.28.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 CHANGED
@@ -25,6 +25,23 @@ 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.28.0: 3D editor
29
+
30
+ The editor edits 3D scenes (issue #173, ADR 0030). Scene files and game code are untouched; three engine seams become public, all additive:
31
+
32
+ - **`raycastPick(camera, at, entities, extra?)`** is exported with its types (`PickTarget`, `PickedTarget`, `RaycastPick`, `ViewportFraction`). It is the raycast the Pointer uses in a 3D scene: every live Model root and the optional `extra` targets (`{ object, entity }`, where `entity` is an `Entity` or `'camera'`, tested whatever their layers), nearest hit first, with the ground plane y = 0 as the fallback point. `Pointer` behaves as before.
33
+ - **`glbClipNames(bytes)`** returns a glb's animation clip names in file order (an unnamed clip is `animation_<index>`, as three names it), `[]` with none, and `null` for bytes that are not a glb or a JSON chunk that does not parse. It reads only the header and the JSON chunk, with no Node or DOM API beyond `TextDecoder`.
34
+ - **`CameraRig.setViewCamera(camera | null)` and `CameraRig.sceneCamera`.** A host can draw a 3D scene through a camera of its own: while one is set, `game.camera` returns it, so the frame, the Pointer and `game.worldToScreen` follow it; `sceneCamera` keeps returning the camera the scene JSON places, and `setSceneCamera` keeps updating it. A 2D scene ignores the call with one warning. A game never sets one; the editor sets it only in edit mode, where its Game does not simulate.
35
+
36
+ ## Migrating to 0.27.0: 3D animation
37
+
38
+ 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):
39
+
40
+ - **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.
41
+ - **`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.
42
+ - **`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.
43
+ - **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.
44
+
28
45
  ## Migrating to 0.26.0: 3D scenes
29
46
 
30
47
  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:
@@ -247,18 +264,31 @@ A scene declares its space (ADR 0027): `render.space: '2d'` (the default) or `'3
247
264
  ```
248
265
 
249
266
  - **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.
267
+ - **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.
268
+ - **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.
251
269
  - **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
270
  - **`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
271
  - **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
272
  - **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
273
  - **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
274
  - **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`.
275
+ - **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.
276
+ - **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`.
258
277
  - **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
278
  - **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
279
  - **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.
280
+ - **Editor.** A 3D scene is edited through the Editor Camera (ADR 0030): in edit mode the viewport draws, picks and projects through an editor-owned perspective camera set with `game.cameraRig.setViewCamera(camera)` (`game.camera` returns it while set; `game.cameraRig.sceneCamera` keeps the one the JSON places; `null` restores it; a 2D scene ignores it with a warning). It starts at the scene camera's pose, orbits (middle button or Alt+left), pans (right button or Shift+middle), zooms (wheel), steps with the arrows, Shift+arrows, `+` and `-` on the focused viewport, frames the selection with `F`, and is remembered per scene; Play draws through the scene camera. A click picks through `raycastPick` (Models, and the icons of Suns, PointLights, meshless entities and the scene camera); `TransformControls` move, rotate and scale the selection (W, E, R; World or Local; Ctrl snaps to per-user steps), one undo per drag; a prefab drops where the ray meets a Model or the ground; an XZ grid and the helpers (the camera's frustum, each Sun's direction, each PointLight's range, each Collider's shape) draw in edit mode only. The inspector edits X/Y/Z, rotation and scale and a perspective camera's position, target and fov, and the Animator's clip fields list the glb's clips, read by `glbClipNames(bytes)`.
281
+
282
+ ## State machines
283
+
284
+ 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:
285
+
286
+ - `input:<action>` — an unconsumed press of the action this Simulation Step; the transition spends it.
287
+ - `timer:<seconds>` — the state has been active for at least that long (exact at the fixed step: `timer:0.3` is 18 steps).
288
+ - `signal:<name>` — the state code queued `fsm.signal(name)` this step or the last.
289
+ - `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.
290
+
291
+ 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.
262
292
 
263
293
  ## Component lifecycle
264
294
 
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The names of a glb's animation clips, in file order, as three's loader
3
+ * names them: an unnamed clip is `animation_<index>`. Only the header and the
4
+ * JSON chunk are read, so bytes cut off after the JSON chunk still answer.
5
+ * `[]` for a glb with no animations; null for bytes that are not a glb or a
6
+ * JSON chunk that does not parse.
7
+ */
8
+ export declare function glbClipNames(bytes: Uint8Array): string[] | null;
@@ -0,0 +1,49 @@
1
+ // The clip names a glb carries, read from its JSON chunk alone (issue #173
2
+ // CA-3): what the editor offers in its clip dropdowns and the MCP validates
3
+ // clip references against, without loading the file. Pure: no Node or DOM API
4
+ // beyond TextDecoder, so it runs in the browser, in Node and in a worker.
5
+ /** The 12-byte glb header (magic, version, total length), then the 8-byte header of the first chunk, which must be JSON. */
6
+ const HEADER_BYTES = 12;
7
+ const CHUNK_HEADER_BYTES = 8;
8
+ /** 'glTF' and 'JSON', read as little-endian uint32. */
9
+ const GLB_MAGIC = 0x46546c67;
10
+ const JSON_CHUNK = 0x4e4f534a;
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ /** The glb's JSON document, or null when the bytes are not a glb or its JSON chunk is cut short or does not parse to an object. */
15
+ function glbDocument(bytes) {
16
+ const start = HEADER_BYTES + CHUNK_HEADER_BYTES;
17
+ if (bytes.byteLength < start)
18
+ return null;
19
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
20
+ if (view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(HEADER_BYTES + 4, true) !== JSON_CHUNK)
21
+ return null;
22
+ const length = view.getUint32(HEADER_BYTES, true);
23
+ if (start + length > bytes.byteLength)
24
+ return null;
25
+ try {
26
+ const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(start, start + length)));
27
+ return isRecord(parsed) ? parsed : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /**
34
+ * The names of a glb's animation clips, in file order, as three's loader
35
+ * names them: an unnamed clip is `animation_<index>`. Only the header and the
36
+ * JSON chunk are read, so bytes cut off after the JSON chunk still answer.
37
+ * `[]` for a glb with no animations; null for bytes that are not a glb or a
38
+ * JSON chunk that does not parse.
39
+ */
40
+ export function glbClipNames(bytes) {
41
+ const document = glbDocument(bytes);
42
+ if (!document)
43
+ return null;
44
+ const animations = Array.isArray(document.animations) ? document.animations : [];
45
+ return animations.map((animation, index) => {
46
+ const name = isRecord(animation) ? animation.name : undefined;
47
+ return typeof name === 'string' && name ? name : `animation_${index}`;
48
+ });
49
+ }
@@ -17,17 +17,38 @@ export interface CameraFollowHost {
17
17
  /**
18
18
  * The Game's cameras. The orthographic one is built with the Game and frames
19
19
  * every 2D scene; a 3D scene swaps in a perspective one (built on first use).
20
- * `camera` is whichever the loaded scene uses (ADR 0027).
20
+ * `sceneCamera` is whichever the loaded scene uses (ADR 0027); `camera` is
21
+ * that one, or the host's view camera over a 3D scene while one is set.
21
22
  */
22
23
  export declare class CameraRig {
23
24
  readonly orthographic: THREE.OrthographicCamera;
24
25
  private perspective;
25
26
  private current;
27
+ /** A host-owned camera drawn through instead of the scene's (the editor's, issue #173). */
28
+ private view;
29
+ /** Set once the 2D-scene warning of setViewCamera was logged. */
30
+ private warnedFlatView;
26
31
  private aspect;
27
32
  /** How far the perspective camera's declared target was from its declared position. */
28
33
  private distance;
29
34
  constructor();
35
+ /**
36
+ * The camera the frame draws, the Pointer picks and `worldToScreen`
37
+ * projects through: the view camera over a 3D scene while one is set,
38
+ * else the scene camera.
39
+ */
30
40
  get camera(): GameCamera;
41
+ /** The camera placed from the scene's JSON, whatever view camera is set; `adopt` keeps updating it. */
42
+ get sceneCamera(): GameCamera;
43
+ /**
44
+ * Draws a 3D scene through `camera` instead of the scene camera, until
45
+ * cleared with null (issue #173 CA-1): the editor's own camera, which game
46
+ * code never sees because the editor's Game does not simulate. The rig fits
47
+ * its aspect to the view and lets it see the Emissive layer, as it does the
48
+ * scene camera. Over a 2D scene (or none yet) it has no effect and warns
49
+ * once; the camera is still kept, so it takes over when a 3D scene loads.
50
+ */
51
+ setViewCamera(camera: THREE.PerspectiveCamera | null): void;
31
52
  /** The distance from the perspective camera to the point it was aimed at; a snapshot reports its target from it. */
32
53
  get lookDistance(): number;
33
54
  /**
@@ -5,12 +5,17 @@ import { placePerspectiveCamera, resolvePerspectiveCamera } from './scene-camera
5
5
  /**
6
6
  * The Game's cameras. The orthographic one is built with the Game and frames
7
7
  * every 2D scene; a 3D scene swaps in a perspective one (built on first use).
8
- * `camera` is whichever the loaded scene uses (ADR 0027).
8
+ * `sceneCamera` is whichever the loaded scene uses (ADR 0027); `camera` is
9
+ * that one, or the host's view camera over a 3D scene while one is set.
9
10
  */
10
11
  export class CameraRig {
11
12
  orthographic = new THREE.OrthographicCamera();
12
13
  perspective = null;
13
14
  current = this.orthographic;
15
+ /** A host-owned camera drawn through instead of the scene's (the editor's, issue #173). */
16
+ view = null;
17
+ /** Set once the 2D-scene warning of setViewCamera was logged. */
18
+ warnedFlatView = false;
14
19
  aspect = 1;
15
20
  /** How far the perspective camera's declared target was from its declared position. */
16
21
  distance = 1;
@@ -19,9 +24,38 @@ export class CameraRig {
19
24
  // Emissive drawables sit on their own layer; an unlit frame draws it in place.
20
25
  this.orthographic.layers.enable(EMISSIVE_LAYER);
21
26
  }
27
+ /**
28
+ * The camera the frame draws, the Pointer picks and `worldToScreen`
29
+ * projects through: the view camera over a 3D scene while one is set,
30
+ * else the scene camera.
31
+ */
22
32
  get camera() {
33
+ return this.view && this.current === this.perspective ? this.view : this.current;
34
+ }
35
+ /** The camera placed from the scene's JSON, whatever view camera is set; `adopt` keeps updating it. */
36
+ get sceneCamera() {
23
37
  return this.current;
24
38
  }
39
+ /**
40
+ * Draws a 3D scene through `camera` instead of the scene camera, until
41
+ * cleared with null (issue #173 CA-1): the editor's own camera, which game
42
+ * code never sees because the editor's Game does not simulate. The rig fits
43
+ * its aspect to the view and lets it see the Emissive layer, as it does the
44
+ * scene camera. Over a 2D scene (or none yet) it has no effect and warns
45
+ * once; the camera is still kept, so it takes over when a 3D scene loads.
46
+ */
47
+ setViewCamera(camera) {
48
+ if (camera && this.current !== this.perspective && !this.warnedFlatView) {
49
+ console.warn('[waica] setViewCamera has no effect over a 2D scene or none: its camera is orthographic');
50
+ this.warnedFlatView = true;
51
+ }
52
+ this.view = camera;
53
+ if (!camera)
54
+ return;
55
+ camera.layers.enable(EMISSIVE_LAYER);
56
+ camera.aspect = this.aspect;
57
+ camera.updateProjectionMatrix();
58
+ }
25
59
  /** The distance from the perspective camera to the point it was aimed at; a snapshot reports its target from it. */
26
60
  get lookDistance() {
27
61
  return this.distance;
@@ -58,10 +92,12 @@ export class CameraRig {
58
92
  camera.top = halfH;
59
93
  camera.bottom = -halfH;
60
94
  camera.updateProjectionMatrix();
61
- if (!this.perspective)
62
- return;
63
- this.perspective.aspect = aspect;
64
- this.perspective.updateProjectionMatrix();
95
+ for (const lens of [this.perspective, this.view]) {
96
+ if (!lens)
97
+ continue;
98
+ lens.aspect = aspect;
99
+ lens.updateProjectionMatrix();
100
+ }
65
101
  }
66
102
  /** One Simulation Step of the orthographic follow camera (deadzone, lookahead, smoothing, limits). */
67
103
  follow(cam, host, dt) {
@@ -1,5 +1,6 @@
1
1
  import { Component, type ComponentSpace } from '../component.js';
2
2
  import { type ClipDef } from '../animation/clip-player.js';
3
+ import type { ClipTarget } from '../animation/clip-target.js';
3
4
  import { type SheetCell, type SheetDef } from '../animation/sheet.js';
4
5
  import type { YSortParticipant } from '../render-sort.js';
5
6
  /**
@@ -12,7 +13,7 @@ import type { YSortParticipant } from '../render-sort.js';
12
13
  * per frame, anchored bottom-center — width/height size the sheet's largest
13
14
  * frame and smaller ones keep their feet planted.
14
15
  */
15
- export declare class AnimatedSprite extends Component implements YSortParticipant {
16
+ export declare class AnimatedSprite extends Component implements YSortParticipant, ClipTarget {
16
17
  static componentName: string;
17
18
  static space: ComponentSpace;
18
19
  static updateAfter: readonly string[];
@@ -96,6 +97,10 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
96
97
  initialClip?: string;
97
98
  /** Clip currently playing. */
98
99
  current?: string;
100
+ /** The names a StateMachine can ask for (the Clip Target seam, issue #166). */
101
+ get clipNames(): readonly string[];
102
+ /** True once a non-looping clip has held its last frame for its whole duration; false after `play`. */
103
+ get clipFinished(): boolean;
99
104
  private readonly player;
100
105
  private sheets;
101
106
  private texs;
@@ -105,7 +110,12 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
105
110
  private frameScaleX;
106
111
  private frameScaleY;
107
112
  onReady(): void;
108
- /** Switches clip; repeating the same name doesn't restart the animation. */
113
+ /**
114
+ * Switches clip. Repeating the running clip's name doesn't restart it; once
115
+ * a non-looping clip has finished it does (issue #166 CA-3), so a state
116
+ * re-entered after its one-shot clip ended replays it instead of reporting
117
+ * `clip:end` on every step.
118
+ */
109
119
  play(name: string): void;
110
120
  onUpdate(dt: number): void;
111
121
  onDestroy(): void;
@@ -151,6 +151,14 @@ export class AnimatedSprite extends Component {
151
151
  initialClip;
152
152
  /** Clip currently playing. */
153
153
  current;
154
+ /** The names a StateMachine can ask for (the Clip Target seam, issue #166). */
155
+ get clipNames() {
156
+ return Object.keys(this.clips);
157
+ }
158
+ /** True once a non-looping clip has held its last frame for its whole duration; false after `play`. */
159
+ get clipFinished() {
160
+ return this.player.finished;
161
+ }
154
162
  player = new ClipPlayer();
155
163
  sheets = [];
156
164
  texs = [];
@@ -206,9 +214,14 @@ export class AnimatedSprite extends Component {
206
214
  if (this.initialClip)
207
215
  this.play(this.initialClip);
208
216
  }
209
- /** Switches clip; repeating the same name doesn't restart the animation. */
217
+ /**
218
+ * Switches clip. Repeating the running clip's name doesn't restart it; once
219
+ * a non-looping clip has finished it does (issue #166 CA-3), so a state
220
+ * re-entered after its one-shot clip ended replays it instead of reporting
221
+ * `clip:end` on every step.
222
+ */
210
223
  play(name) {
211
- if (this.current === name)
224
+ if (this.current === name && !this.player.finished)
212
225
  return;
213
226
  const clip = this.clips[name];
214
227
  if (!clip)
@@ -0,0 +1,98 @@
1
+ import type { ClipTarget } from '../animation/clip-target.js';
2
+ import { Component, type ComponentSpace } from '../component.js';
3
+ /** Per-clip overrides, keyed by the glTF's clip name: a one-shot (`loop: false`) and a playback rate. */
4
+ export interface AnimatorClipOverride {
5
+ /** Defaults to true; false plays the clip once and holds its last pose. */
6
+ loop?: boolean;
7
+ /** Playback rate; 1 by default, 2 finishes in half the time. */
8
+ speed?: number;
9
+ }
10
+ /** What `inspect_runtime` shows of an Animator. */
11
+ export interface AnimatorState {
12
+ loaded: boolean;
13
+ /** The playing clip; null before the first play (an undefined would project as an unsupported marker). */
14
+ current: string | null;
15
+ /** The current action's time in seconds (0 with none). */
16
+ time: number;
17
+ /** False before the model loaded or with no clip playing. */
18
+ playing: boolean;
19
+ /** The pose fingerprint of the rig (see poseHash); empty with no skeleton. */
20
+ poseHash: string;
21
+ }
22
+ /**
23
+ * Plays the glTF clips of the Model beside it (issue #166, ADR 0029): the
24
+ * 3D Clip Target. One AnimationMixer over the Model's own clone, one action
25
+ * per clip, advanced by the Simulation Step in onUpdate — nothing runs per
26
+ * render frame, so the pose is deterministic and visible step by step. A
27
+ * StateMachine finds it like it finds an AnimatedSprite and plays the clip of
28
+ * each state; `clip:end` fires when a `loop: false` clip finishes. A `play`
29
+ * before the model loaded is remembered and applied when it settles;
30
+ * otherwise `initialClip` plays then. In a 2D scene it creates nothing.
31
+ */
32
+ export declare class Animator extends Component implements ClipTarget {
33
+ static componentName: string;
34
+ static space: ComponentSpace;
35
+ static updateAfter: readonly string[];
36
+ static params: {
37
+ initialClip: {
38
+ label: string;
39
+ };
40
+ fade: {
41
+ label: string;
42
+ min: number;
43
+ max: number;
44
+ step: number;
45
+ };
46
+ };
47
+ static transient: string[];
48
+ /** The clip that plays once the model settles, when nothing asked for one before. */
49
+ initialClip: string;
50
+ /** Crossfade between clips, in seconds; 0 cuts. */
51
+ fade: number;
52
+ /**
53
+ * Per-clip overrides by glTF clip name (authored in JSON, like
54
+ * AnimatedSprite.clips). Read when the actions are built, as the model
55
+ * settles: a change after that applies on the next build (a Model rebuild).
56
+ */
57
+ clips: Record<string, AnimatorClipOverride>;
58
+ /** The clip playing now; undefined before the first play. */
59
+ current: string | undefined;
60
+ private mixer;
61
+ private readonly actions;
62
+ private active;
63
+ /** A play asked for before the model loaded. */
64
+ private requested;
65
+ private finished;
66
+ private root;
67
+ private readonly warned;
68
+ private attached;
69
+ /** The Model handle the mixer was built over, or is waiting on: a Model rebuild replaces it (review of PR #168). */
70
+ private handle;
71
+ /** The clip names, computed once per build: the StateMachine reads them every step. */
72
+ private names;
73
+ /** True while clips are expected but not listed yet (the glb is loading); false once built, or once no Model can provide them. */
74
+ private pending;
75
+ get clipNames(): readonly string[];
76
+ get clipFinished(): boolean;
77
+ get clipsPending(): boolean;
78
+ onReady(): void;
79
+ /** Plays a clip: a no-op for the running one, a restart for a finished one, a crossfade from any other over `fade` seconds. */
80
+ play(name: string): void;
81
+ onUpdate(dt: number): void;
82
+ onDestroy(): void;
83
+ inspectState(): AnimatorState;
84
+ /**
85
+ * Takes the sibling Model's handle and builds the mixer when it settles. A
86
+ * Model later in the component order is not there at onReady, so the first
87
+ * onUpdate tries once more (`final`); a primitive Model warns at once, a
88
+ * missing one once it is certain, and either way exactly once.
89
+ */
90
+ private attach;
91
+ /** Forgets the Model's old clone and keeps the clip it played as the request for the next build. */
92
+ private detach;
93
+ /** Stops and drops the mixer, its actions and the clone they ran over. */
94
+ private release;
95
+ private build;
96
+ private configure;
97
+ private warnOnce;
98
+ }