@waica/engine 0.26.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 +24 -2
- 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/components/animated-sprite.d.ts +12 -2
- package/dist/components/animated-sprite.js +15 -2
- package/dist/components/animator.d.ts +98 -0
- package/dist/components/animator.js +197 -0
- package/dist/components/model.d.ts +8 -0
- package/dist/components/model.js +15 -4
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/state/state-machine.d.ts +32 -8
- package/dist/state/state-machine.js +88 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,15 @@ 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
|
+
|
|
28
37
|
## Migrating to 0.26.0: 3D scenes
|
|
29
38
|
|
|
30
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:
|
|
@@ -247,19 +256,32 @@ A scene declares its space (ADR 0027): `render.space: '2d'` (the default) or `'3
|
|
|
247
256
|
```
|
|
248
257
|
|
|
249
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.
|
|
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.
|
|
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.
|
|
251
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).
|
|
252
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.
|
|
253
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.
|
|
254
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`).
|
|
255
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.
|
|
256
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.
|
|
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
|
|
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`.
|
|
258
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.
|
|
259
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.
|
|
260
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).
|
|
261
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.
|
|
262
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
|
+
|
|
263
285
|
## Component lifecycle
|
|
264
286
|
|
|
265
287
|
Waica keeps the lifecycle boundaries distinct:
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import { poseHash } from '../animation/pose-hash.js';
|
|
3
|
+
import { Component } from '../component.js';
|
|
4
|
+
import { reportRejection } from '../report-rejection.js';
|
|
5
|
+
import { Model } from './model.js';
|
|
6
|
+
/**
|
|
7
|
+
* Plays the glTF clips of the Model beside it (issue #166, ADR 0029): the
|
|
8
|
+
* 3D Clip Target. One AnimationMixer over the Model's own clone, one action
|
|
9
|
+
* per clip, advanced by the Simulation Step in onUpdate — nothing runs per
|
|
10
|
+
* render frame, so the pose is deterministic and visible step by step. A
|
|
11
|
+
* StateMachine finds it like it finds an AnimatedSprite and plays the clip of
|
|
12
|
+
* each state; `clip:end` fires when a `loop: false` clip finishes. A `play`
|
|
13
|
+
* before the model loaded is remembered and applied when it settles;
|
|
14
|
+
* otherwise `initialClip` plays then. In a 2D scene it creates nothing.
|
|
15
|
+
*/
|
|
16
|
+
export class Animator extends Component {
|
|
17
|
+
static componentName = 'Animator';
|
|
18
|
+
static space = '3d';
|
|
19
|
+
static updateAfter = ['StateMachine'];
|
|
20
|
+
static params = {
|
|
21
|
+
initialClip: { label: 'Initial clip' },
|
|
22
|
+
fade: { label: 'Fade (s)', min: 0, max: 1, step: 0.05 },
|
|
23
|
+
};
|
|
24
|
+
static transient = ['current', 'mixer', 'actions', 'active', 'requested', 'finished', 'root', 'warned', 'attached', 'handle', 'names', 'pending'];
|
|
25
|
+
/** The clip that plays once the model settles, when nothing asked for one before. */
|
|
26
|
+
initialClip = '';
|
|
27
|
+
/** Crossfade between clips, in seconds; 0 cuts. */
|
|
28
|
+
fade = 0.15;
|
|
29
|
+
/**
|
|
30
|
+
* Per-clip overrides by glTF clip name (authored in JSON, like
|
|
31
|
+
* AnimatedSprite.clips). Read when the actions are built, as the model
|
|
32
|
+
* settles: a change after that applies on the next build (a Model rebuild).
|
|
33
|
+
*/
|
|
34
|
+
clips = {};
|
|
35
|
+
/** The clip playing now; undefined before the first play. */
|
|
36
|
+
current;
|
|
37
|
+
mixer = null;
|
|
38
|
+
actions = new Map();
|
|
39
|
+
active = null;
|
|
40
|
+
/** A play asked for before the model loaded. */
|
|
41
|
+
requested;
|
|
42
|
+
finished = false;
|
|
43
|
+
root = null;
|
|
44
|
+
warned = new Set();
|
|
45
|
+
attached = false;
|
|
46
|
+
/** The Model handle the mixer was built over, or is waiting on: a Model rebuild replaces it (review of PR #168). */
|
|
47
|
+
handle = null;
|
|
48
|
+
/** The clip names, computed once per build: the StateMachine reads them every step. */
|
|
49
|
+
names = [];
|
|
50
|
+
/** True while clips are expected but not listed yet (the glb is loading); false once built, or once no Model can provide them. */
|
|
51
|
+
pending = false;
|
|
52
|
+
get clipNames() {
|
|
53
|
+
return this.names;
|
|
54
|
+
}
|
|
55
|
+
get clipFinished() {
|
|
56
|
+
return this.finished;
|
|
57
|
+
}
|
|
58
|
+
get clipsPending() {
|
|
59
|
+
return this.pending;
|
|
60
|
+
}
|
|
61
|
+
onReady() {
|
|
62
|
+
if (this.game.space !== '3d')
|
|
63
|
+
return;
|
|
64
|
+
this.pending = true;
|
|
65
|
+
this.attach();
|
|
66
|
+
}
|
|
67
|
+
/** Plays a clip: a no-op for the running one, a restart for a finished one, a crossfade from any other over `fade` seconds. */
|
|
68
|
+
play(name) {
|
|
69
|
+
if (this.game.space !== '3d')
|
|
70
|
+
return;
|
|
71
|
+
if (!this.mixer) {
|
|
72
|
+
this.requested = name;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const action = this.actions.get(name);
|
|
76
|
+
if (!action) {
|
|
77
|
+
this.warnOnce(name, `[waica] "${this.entity.name}": no clip "${name}"`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (action === this.active && !this.finished)
|
|
81
|
+
return;
|
|
82
|
+
const previous = this.active;
|
|
83
|
+
this.active = action;
|
|
84
|
+
this.current = name;
|
|
85
|
+
this.finished = false;
|
|
86
|
+
action.reset().setEffectiveWeight(1).play();
|
|
87
|
+
if (!previous || previous === action)
|
|
88
|
+
return;
|
|
89
|
+
if (this.fade <= 0)
|
|
90
|
+
previous.stop();
|
|
91
|
+
else
|
|
92
|
+
previous.crossFadeTo(action, this.fade, false);
|
|
93
|
+
}
|
|
94
|
+
onUpdate(dt) {
|
|
95
|
+
if (this.game.space !== '3d')
|
|
96
|
+
return;
|
|
97
|
+
// A rebuilt Model (a new src or shape) draws a new clone: the mixer over
|
|
98
|
+
// the old one would animate a graph nobody draws, so it is torn down and
|
|
99
|
+
// the clip it was playing is asked for again on the new one.
|
|
100
|
+
if (this.attached && (this.entity.get(Model)?.handle ?? null) !== this.handle)
|
|
101
|
+
this.detach();
|
|
102
|
+
if (!this.attached)
|
|
103
|
+
this.attach(true);
|
|
104
|
+
this.mixer?.update(dt);
|
|
105
|
+
}
|
|
106
|
+
onDestroy() {
|
|
107
|
+
this.release();
|
|
108
|
+
this.pending = false;
|
|
109
|
+
}
|
|
110
|
+
inspectState() {
|
|
111
|
+
return {
|
|
112
|
+
loaded: this.mixer !== null,
|
|
113
|
+
current: this.current ?? null,
|
|
114
|
+
time: this.active?.time ?? 0,
|
|
115
|
+
playing: this.active !== null && this.active.isRunning(),
|
|
116
|
+
poseHash: this.root ? poseHash(this.root) : '',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Takes the sibling Model's handle and builds the mixer when it settles. A
|
|
121
|
+
* Model later in the component order is not there at onReady, so the first
|
|
122
|
+
* onUpdate tries once more (`final`); a primitive Model warns at once, a
|
|
123
|
+
* missing one once it is certain, and either way exactly once.
|
|
124
|
+
*/
|
|
125
|
+
attach(final = false) {
|
|
126
|
+
const model = this.entity.get(Model);
|
|
127
|
+
const handle = model?.handle;
|
|
128
|
+
if (!handle) {
|
|
129
|
+
if ((model && !model.src) || final) {
|
|
130
|
+
this.attached = true;
|
|
131
|
+
this.pending = false;
|
|
132
|
+
this.warnOnce('model', `[waica] "${this.entity.name}": Animator needs a Model with a src beside it; nothing plays`);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.attached = true;
|
|
137
|
+
this.handle = handle;
|
|
138
|
+
reportRejection(handle.settled.then((outcome) => {
|
|
139
|
+
// Only the handle still attached builds: a Model rebuilt meanwhile has a newer one.
|
|
140
|
+
if (outcome === 'loaded' && this.entity.alive && !this.mixer && this.handle === handle)
|
|
141
|
+
this.build(handle);
|
|
142
|
+
}), 'animator model settle');
|
|
143
|
+
}
|
|
144
|
+
/** Forgets the Model's old clone and keeps the clip it played as the request for the next build. */
|
|
145
|
+
detach() {
|
|
146
|
+
this.requested = this.current ?? this.requested;
|
|
147
|
+
this.release();
|
|
148
|
+
this.attached = false;
|
|
149
|
+
this.pending = true;
|
|
150
|
+
}
|
|
151
|
+
/** Stops and drops the mixer, its actions and the clone they ran over. */
|
|
152
|
+
release() {
|
|
153
|
+
this.mixer?.stopAllAction();
|
|
154
|
+
if (this.mixer && this.root)
|
|
155
|
+
this.mixer.uncacheRoot(this.root);
|
|
156
|
+
this.mixer = null;
|
|
157
|
+
this.active = null;
|
|
158
|
+
this.current = undefined;
|
|
159
|
+
this.finished = false;
|
|
160
|
+
this.actions.clear();
|
|
161
|
+
this.names = [];
|
|
162
|
+
this.root = null;
|
|
163
|
+
this.handle = null;
|
|
164
|
+
}
|
|
165
|
+
build(handle) {
|
|
166
|
+
const mixer = new THREE.AnimationMixer(handle.root);
|
|
167
|
+
this.mixer = mixer;
|
|
168
|
+
this.root = handle.root;
|
|
169
|
+
for (const clip of handle.animations)
|
|
170
|
+
this.actions.set(clip.name, this.configure(mixer.clipAction(clip), clip.name));
|
|
171
|
+
this.names = [...this.actions.keys()];
|
|
172
|
+
this.pending = false;
|
|
173
|
+
mixer.addEventListener('finished', (event) => {
|
|
174
|
+
if (event.action === this.active)
|
|
175
|
+
this.finished = true;
|
|
176
|
+
});
|
|
177
|
+
const first = this.requested ?? this.initialClip;
|
|
178
|
+
this.requested = undefined;
|
|
179
|
+
if (first)
|
|
180
|
+
this.play(first);
|
|
181
|
+
}
|
|
182
|
+
configure(action, name) {
|
|
183
|
+
const override = this.clips[name];
|
|
184
|
+
if (override?.loop === false) {
|
|
185
|
+
action.setLoop(THREE.LoopOnce, 1);
|
|
186
|
+
action.clampWhenFinished = true;
|
|
187
|
+
}
|
|
188
|
+
action.timeScale = override?.speed ?? 1;
|
|
189
|
+
return action;
|
|
190
|
+
}
|
|
191
|
+
warnOnce(key, message) {
|
|
192
|
+
if (this.warned.has(key))
|
|
193
|
+
return;
|
|
194
|
+
this.warned.add(key);
|
|
195
|
+
console.warn(message);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { ModelHandle } from '../assets/model-cache.js';
|
|
2
3
|
import { Component, type ComponentSpace } from '../component.js';
|
|
3
4
|
export type ModelShape = 'box' | 'sphere' | 'plane';
|
|
4
5
|
/**
|
|
@@ -37,6 +38,13 @@ export declare class Model extends Component {
|
|
|
37
38
|
static transient: string[];
|
|
38
39
|
/** What this Model draws, under the entity's node; null until it is ready. The Pointer ray-casts against it. */
|
|
39
40
|
root: THREE.Group | null;
|
|
41
|
+
/**
|
|
42
|
+
* The handle of the glTF it draws (issue #166 CA-6): `handle.root` is the
|
|
43
|
+
* child of `root`, `handle.animations` its clips once settled — what an
|
|
44
|
+
* Animator beside it plays. Null for a primitive, in a 2D scene and before
|
|
45
|
+
* the component is ready; replaced on every rebuild.
|
|
46
|
+
*/
|
|
47
|
+
handle: ModelHandle | null;
|
|
40
48
|
private _src;
|
|
41
49
|
private _shape;
|
|
42
50
|
private _color;
|
package/dist/components/model.js
CHANGED
|
@@ -32,9 +32,16 @@ export class Model extends Component {
|
|
|
32
32
|
color: { label: 'Color', kind: 'color' },
|
|
33
33
|
size: { label: 'Size', min: 0.01, step: 0.1 },
|
|
34
34
|
};
|
|
35
|
-
static transient = ['root'];
|
|
35
|
+
static transient = ['root', 'handle'];
|
|
36
36
|
/** What this Model draws, under the entity's node; null until it is ready. The Pointer ray-casts against it. */
|
|
37
37
|
root = null;
|
|
38
|
+
/**
|
|
39
|
+
* The handle of the glTF it draws (issue #166 CA-6): `handle.root` is the
|
|
40
|
+
* child of `root`, `handle.animations` its clips once settled — what an
|
|
41
|
+
* Animator beside it plays. Null for a primitive, in a 2D scene and before
|
|
42
|
+
* the component is ready; replaced on every rebuild.
|
|
43
|
+
*/
|
|
44
|
+
handle = null;
|
|
38
45
|
_src = '';
|
|
39
46
|
_shape = 'box';
|
|
40
47
|
_color = 0xffffff;
|
|
@@ -87,10 +94,13 @@ export class Model extends Component {
|
|
|
87
94
|
build() {
|
|
88
95
|
if (!this.root)
|
|
89
96
|
return;
|
|
90
|
-
if (this._src)
|
|
91
|
-
this.
|
|
92
|
-
|
|
97
|
+
if (this._src) {
|
|
98
|
+
this.handle = this.game.assets.model(this._src);
|
|
99
|
+
this.root.add(this.handle.root);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
93
102
|
this.buildPrimitive(this.root);
|
|
103
|
+
}
|
|
94
104
|
}
|
|
95
105
|
buildPrimitive(root) {
|
|
96
106
|
const material = new THREE.MeshStandardNodeMaterial({ color: this._color });
|
|
@@ -108,6 +118,7 @@ export class Model extends Component {
|
|
|
108
118
|
/** Empties the root and disposes the primitive this Model created; a glTF's clone only leaves the graph. */
|
|
109
119
|
clear() {
|
|
110
120
|
this.root?.clear();
|
|
121
|
+
this.handle = null;
|
|
111
122
|
if (!this._primitive)
|
|
112
123
|
return;
|
|
113
124
|
this._primitive.mesh.geometry.dispose();
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions
|
|
|
12
12
|
export type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './audio/types.js';
|
|
13
13
|
export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
|
|
14
14
|
export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
|
|
15
|
+
export { isClipTarget } from './animation/clip-target.js';
|
|
16
|
+
export type { ClipTarget } from './animation/clip-target.js';
|
|
15
17
|
export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
|
|
16
18
|
export type { YSortBatchParticipant, YSortEntry, YSortParticipant } from './render-sort.js';
|
|
17
19
|
export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
|
|
@@ -63,6 +65,9 @@ export { Sprite } from './components/sprite.js';
|
|
|
63
65
|
export { Light } from './components/light.js';
|
|
64
66
|
export { Model } from './components/model.js';
|
|
65
67
|
export type { ModelShape } from './components/model.js';
|
|
68
|
+
export { Animator } from './components/animator.js';
|
|
69
|
+
export type { AnimatorClipOverride, AnimatorState } from './components/animator.js';
|
|
70
|
+
export { poseHash } from './animation/pose-hash.js';
|
|
66
71
|
export { Sun } from './components/sun.js';
|
|
67
72
|
export { Collider, COLLIDER_SHAPES } from './components/collider.js';
|
|
68
73
|
export type { ColliderShape } from './components/collider.js';
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { AssetLoader } from './assets/asset-loader.js';
|
|
|
3
3
|
export { GltfModelBackend } from './assets/model-backend.js';
|
|
4
4
|
export { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
5
5
|
export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
|
|
6
|
+
export { isClipTarget } from './animation/clip-target.js';
|
|
6
7
|
export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
|
|
7
8
|
export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
|
|
8
9
|
export { spritePlacement } from './sprite-placement.js';
|
|
@@ -29,6 +30,8 @@ export { GameUi } from './ui.js';
|
|
|
29
30
|
export { Sprite } from './components/sprite.js';
|
|
30
31
|
export { Light } from './components/light.js';
|
|
31
32
|
export { Model } from './components/model.js';
|
|
33
|
+
export { Animator } from './components/animator.js';
|
|
34
|
+
export { poseHash } from './animation/pose-hash.js';
|
|
32
35
|
export { Sun } from './components/sun.js';
|
|
33
36
|
export { Collider, COLLIDER_SHAPES } from './components/collider.js';
|
|
34
37
|
export { RigidBody, RIGID_BODY_TYPES } from './components/rigid-body.js';
|
|
@@ -3,7 +3,7 @@ import type { Entity } from '../entity.js';
|
|
|
3
3
|
import { type StateHooks } from './hooks.js';
|
|
4
4
|
/** One outgoing edge: when `on` fires, the machine moves to `to`. */
|
|
5
5
|
export interface StateTransitionJson {
|
|
6
|
-
/** Trigger: 'input:<action>' | 'timer:<seconds>' | 'signal:<name>'. */
|
|
6
|
+
/** Trigger: 'input:<action>' | 'timer:<seconds>' | 'signal:<name>' | 'clip:end'. */
|
|
7
7
|
on: string;
|
|
8
8
|
to: string;
|
|
9
9
|
}
|
|
@@ -13,7 +13,7 @@ export interface StateTransitionJson {
|
|
|
13
13
|
* transitions instead of a real state.
|
|
14
14
|
*/
|
|
15
15
|
export interface StateJson {
|
|
16
|
-
/** Clip reference resolved against the sibling AnimatedSprite; defaults to the state's name. */
|
|
16
|
+
/** Clip reference resolved against the sibling Clip Target (an AnimatedSprite or an Animator); defaults to the state's name. */
|
|
17
17
|
clip?: string;
|
|
18
18
|
transitions?: StateTransitionJson[];
|
|
19
19
|
}
|
|
@@ -24,6 +24,8 @@ export interface TriggerEnv {
|
|
|
24
24
|
/** Seconds spent in the current state. */
|
|
25
25
|
elapsed: number;
|
|
26
26
|
signals: ReadonlySet<string>;
|
|
27
|
+
/** Whether the Clip Target reports its non-looping clip finished (issue #166); false with no target. */
|
|
28
|
+
clipFinished: boolean;
|
|
27
29
|
}
|
|
28
30
|
/** Whether one trigger fires. Unknown or malformed triggers never fire. */
|
|
29
31
|
export declare function evaluateTrigger(on: string, env: TriggerEnv): boolean;
|
|
@@ -41,9 +43,10 @@ export declare function phaseHooks(hooks: StateHooks[], fallback: StateHooks | u
|
|
|
41
43
|
* logic — only the active state's code moves the body, so behaviors
|
|
42
44
|
* never fight over velocity. States and transitions are prefab data;
|
|
43
45
|
* state code lives in the role's logic set (defineRole / defineStates)
|
|
44
|
-
* picked by `role`. Entering a state plays the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
46
|
+
* picked by `role`. Entering a state plays the clip of the same name (or
|
|
47
|
+
* the state's `clip` override) through the entity's Clip Target — the
|
|
48
|
+
* first sibling that plays clips, an AnimatedSprite in 2D or an Animator
|
|
49
|
+
* in 3D (issue #166); a missing clip warns and keeps the previous one.
|
|
47
50
|
*/
|
|
48
51
|
export declare class StateMachine extends Component {
|
|
49
52
|
static componentName: string;
|
|
@@ -66,8 +69,17 @@ export declare class StateMachine extends Component {
|
|
|
66
69
|
private readonly instanceHooks;
|
|
67
70
|
private readonly signals;
|
|
68
71
|
private readonly warnedClips;
|
|
72
|
+
/** States already warned that their mirrored clip met a target that cannot flip. */
|
|
73
|
+
private readonly warnedFlips;
|
|
69
74
|
/** Facing last used to resolve a directional clip — detects a turn mid-state. */
|
|
70
75
|
private lastFacing;
|
|
76
|
+
/**
|
|
77
|
+
* The clip the current state asked its Clip Target for, undefined when it
|
|
78
|
+
* asked for nothing (no target, or a clip the target lacks). `clip:end`
|
|
79
|
+
* reads only this clip: a one-shot left finished by an earlier state never
|
|
80
|
+
* counts for a state that could not play its own.
|
|
81
|
+
*/
|
|
82
|
+
private requestedClip;
|
|
71
83
|
onReady(): void;
|
|
72
84
|
/** Adds instance-level hooks on top of the logic set — the escape hatch. */
|
|
73
85
|
on(state: string, hooks: StateHooks): void;
|
|
@@ -83,20 +95,32 @@ export declare class StateMachine extends Component {
|
|
|
83
95
|
private runCollision;
|
|
84
96
|
private enter;
|
|
85
97
|
private playClip;
|
|
98
|
+
/** The Clip Target: the first sibling that plays clips (duck-typed, like the facing provider). */
|
|
99
|
+
private clipTarget;
|
|
86
100
|
/**
|
|
87
101
|
* Directional resolution: with an installed contract AND a sibling that
|
|
88
102
|
* reports facing, plays state × facing (mirroring included). Returns false
|
|
89
103
|
* to keep the name-based path — no contract, no facing, no playable clip.
|
|
90
104
|
*/
|
|
91
105
|
private playDirectionalClip;
|
|
106
|
+
/** State × facing through the installed contract and the target's clips, remembering the facing used; undefined keeps the name-based path. */
|
|
107
|
+
private resolveFacingClip;
|
|
108
|
+
/**
|
|
109
|
+
* Applies the resolved mirror. A target without `setFlipX` (an Animator:
|
|
110
|
+
* 3D faces by turning) plays a mirrored clip unmirrored and warns once
|
|
111
|
+
* per state, so a 2D target that forgot its mirror is not silent.
|
|
112
|
+
*/
|
|
113
|
+
private mirror;
|
|
92
114
|
private facingProvider;
|
|
93
115
|
/**
|
|
94
116
|
* playDirectionalClip only runs at enter() time (via playClip), so a
|
|
95
117
|
* facing change that doesn't cross a state boundary — turning while
|
|
96
118
|
* still walking, say — never re-resolves the clip on its own. Catch that
|
|
97
|
-
* here every frame: same state, new facing, re-run the resolution.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
119
|
+
* here every frame: same state, new facing, re-run the resolution. A
|
|
120
|
+
* turn that only mirrors (west playing east flipped) re-applies the
|
|
121
|
+
* mirror and leaves the clip alone — a running one keeps its frame and a
|
|
122
|
+
* finished one-shot keeps its last — so only a turn onto another clip
|
|
123
|
+
* name plays, as re-entering the state would.
|
|
100
124
|
*/
|
|
101
125
|
private reresolveOnFacingChange;
|
|
102
126
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from '../animation/directional.js';
|
|
2
|
+
import { isClipTarget } from '../animation/clip-target.js';
|
|
2
3
|
import { Component } from '../component.js';
|
|
3
|
-
import { AnimatedSprite } from '../components/animated-sprite.js';
|
|
4
4
|
import { MAX_CHAINED_HOPS, SIMULATION_TIME_EPSILON } from '../fixed-step.js';
|
|
5
5
|
import { closestLogicSet, logicSet, registeredLogicSets, } from './hooks.js';
|
|
6
6
|
/** Whether one trigger fires. Unknown or malformed triggers never fire. */
|
|
@@ -23,6 +23,10 @@ export function evaluateTrigger(on, env) {
|
|
|
23
23
|
return env.elapsed + SIMULATION_TIME_EPSILON >= Number(arg);
|
|
24
24
|
if (kind === 'signal')
|
|
25
25
|
return env.signals.has(arg);
|
|
26
|
+
// The target updates after the machine (updateAfter: ['StateMachine']), so
|
|
27
|
+
// this fires on the Simulation Step after the clip finished, never for a loop.
|
|
28
|
+
if (kind === 'clip')
|
|
29
|
+
return arg === 'end' && env.clipFinished;
|
|
26
30
|
return false;
|
|
27
31
|
}
|
|
28
32
|
/** The first firing transition: the state's own edges, then '*''s. */
|
|
@@ -46,9 +50,10 @@ export function phaseHooks(hooks, fallback, phase) {
|
|
|
46
50
|
* logic — only the active state's code moves the body, so behaviors
|
|
47
51
|
* never fight over velocity. States and transitions are prefab data;
|
|
48
52
|
* state code lives in the role's logic set (defineRole / defineStates)
|
|
49
|
-
* picked by `role`. Entering a state plays the
|
|
50
|
-
*
|
|
51
|
-
*
|
|
53
|
+
* picked by `role`. Entering a state plays the clip of the same name (or
|
|
54
|
+
* the state's `clip` override) through the entity's Clip Target — the
|
|
55
|
+
* first sibling that plays clips, an AnimatedSprite in 2D or an Animator
|
|
56
|
+
* in 3D (issue #166); a missing clip warns and keeps the previous one.
|
|
52
57
|
*/
|
|
53
58
|
export class StateMachine extends Component {
|
|
54
59
|
static componentName = 'StateMachine';
|
|
@@ -62,7 +67,9 @@ export class StateMachine extends Component {
|
|
|
62
67
|
'instanceHooks',
|
|
63
68
|
'signals',
|
|
64
69
|
'warnedClips',
|
|
70
|
+
'warnedFlips',
|
|
65
71
|
'lastFacing',
|
|
72
|
+
'requestedClip',
|
|
66
73
|
];
|
|
67
74
|
/** The character's role — names the logic set providing its state code. */
|
|
68
75
|
role = '';
|
|
@@ -76,8 +83,17 @@ export class StateMachine extends Component {
|
|
|
76
83
|
instanceHooks = new Map();
|
|
77
84
|
signals = new Set();
|
|
78
85
|
warnedClips = new Set();
|
|
86
|
+
/** States already warned that their mirrored clip met a target that cannot flip. */
|
|
87
|
+
warnedFlips = new Set();
|
|
79
88
|
/** Facing last used to resolve a directional clip — detects a turn mid-state. */
|
|
80
89
|
lastFacing;
|
|
90
|
+
/**
|
|
91
|
+
* The clip the current state asked its Clip Target for, undefined when it
|
|
92
|
+
* asked for nothing (no target, or a clip the target lacks). `clip:end`
|
|
93
|
+
* reads only this clip: a one-shot left finished by an earlier state never
|
|
94
|
+
* counts for a state that could not play its own.
|
|
95
|
+
*/
|
|
96
|
+
requestedClip;
|
|
81
97
|
onReady() {
|
|
82
98
|
if (this.role && !logicSet(this.role)) {
|
|
83
99
|
const sets = registeredLogicSets();
|
|
@@ -111,10 +127,12 @@ export class StateMachine extends Component {
|
|
|
111
127
|
this.run('*', 'onUpdate', dt);
|
|
112
128
|
this.run(this.current, 'onUpdate', dt);
|
|
113
129
|
this.elapsed += dt;
|
|
130
|
+
// The target is found once per step: entering a state never changes it.
|
|
131
|
+
const target = this.clipTarget();
|
|
114
132
|
// Chained transitions settle within the frame (e.g. land → idle → run),
|
|
115
133
|
// capped so a degenerate cyclic graph can't hang the loop.
|
|
116
134
|
for (let hops = 0; hops < MAX_CHAINED_HOPS; hops++) {
|
|
117
|
-
const edge = nextTransition(this.states, this.current, this.env());
|
|
135
|
+
const edge = nextTransition(this.states, this.current, this.env(target));
|
|
118
136
|
// A '*' edge is re-merged against whatever state the loop just
|
|
119
137
|
// entered, so a still-queued signal (signals.clear() only runs after
|
|
120
138
|
// this loop) keeps firing every remaining hop. Without this guard
|
|
@@ -145,11 +163,12 @@ export class StateMachine extends Component {
|
|
|
145
163
|
return;
|
|
146
164
|
this.runCollision(state, other);
|
|
147
165
|
}
|
|
148
|
-
env() {
|
|
166
|
+
env(target) {
|
|
149
167
|
return {
|
|
150
168
|
justPressed: (action) => this.game.input.justPressed(action) && !this.game.input.consumed(action),
|
|
151
169
|
elapsed: this.elapsed,
|
|
152
170
|
signals: this.signals,
|
|
171
|
+
clipFinished: this.requestedClip !== undefined && target?.current === this.requestedClip && target.clipFinished,
|
|
153
172
|
};
|
|
154
173
|
}
|
|
155
174
|
hooksFor(state) {
|
|
@@ -182,41 +201,74 @@ export class StateMachine extends Component {
|
|
|
182
201
|
this.run(state, 'onEnter');
|
|
183
202
|
}
|
|
184
203
|
playClip(state) {
|
|
185
|
-
|
|
186
|
-
|
|
204
|
+
this.requestedClip = undefined;
|
|
205
|
+
const target = this.clipTarget();
|
|
206
|
+
if (!target)
|
|
187
207
|
return;
|
|
188
208
|
const clip = this.states[state]?.clip ?? state;
|
|
189
|
-
if (this.playDirectionalClip(
|
|
209
|
+
if (this.playDirectionalClip(target, clip))
|
|
190
210
|
return;
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
// A target whose clips are pending (an Animator whose glb is still
|
|
212
|
+
// loading) is asked anyway and remembers the request (issue #166).
|
|
213
|
+
if (target.clipsPending || target.clipNames.includes(clip)) {
|
|
214
|
+
target.play(clip);
|
|
215
|
+
this.requestedClip = clip;
|
|
193
216
|
}
|
|
194
217
|
else if (!this.warnedClips.has(state)) {
|
|
195
218
|
this.warnedClips.add(state);
|
|
196
219
|
console.warn(`[waica] "${this.entity.name}": no clip "${clip}" for state "${state}" — ` +
|
|
197
|
-
`keeping "${
|
|
220
|
+
`keeping "${target.current ?? 'none'}"`);
|
|
198
221
|
}
|
|
199
222
|
}
|
|
223
|
+
/** The Clip Target: the first sibling that plays clips (duck-typed, like the facing provider). */
|
|
224
|
+
clipTarget() {
|
|
225
|
+
return this.entity.components.find((c) => isClipTarget(c));
|
|
226
|
+
}
|
|
200
227
|
/**
|
|
201
228
|
* Directional resolution: with an installed contract AND a sibling that
|
|
202
229
|
* reports facing, plays state × facing (mirroring included). Returns false
|
|
203
230
|
* to keep the name-based path — no contract, no facing, no playable clip.
|
|
204
231
|
*/
|
|
205
|
-
playDirectionalClip(
|
|
232
|
+
playDirectionalClip(target, clip) {
|
|
233
|
+
const resolved = this.resolveFacingClip(target, clip);
|
|
234
|
+
if (!resolved)
|
|
235
|
+
return false;
|
|
236
|
+
this.mirror(target, resolved);
|
|
237
|
+
target.play(resolved.clip);
|
|
238
|
+
this.requestedClip = resolved.clip;
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
/** State × facing through the installed contract and the target's clips, remembering the facing used; undefined keeps the name-based path. */
|
|
242
|
+
resolveFacingClip(target, clip) {
|
|
206
243
|
const animation = installedDirectionalAnimation();
|
|
207
244
|
if (!animation)
|
|
208
|
-
return
|
|
245
|
+
return undefined;
|
|
209
246
|
const provider = this.facingProvider();
|
|
210
247
|
if (!provider)
|
|
211
|
-
return
|
|
248
|
+
return undefined;
|
|
212
249
|
const facing = provider.getAnimationFacing();
|
|
213
250
|
this.lastFacing = facing;
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
251
|
+
const names = target.clipNames;
|
|
252
|
+
const resolved = resolveDirectionalClip(animation, names, clip, facing);
|
|
253
|
+
if (!resolved.clip || !names.includes(resolved.clip))
|
|
254
|
+
return undefined;
|
|
255
|
+
return { clip: resolved.clip, flip: resolved.flip };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Applies the resolved mirror. A target without `setFlipX` (an Animator:
|
|
259
|
+
* 3D faces by turning) plays a mirrored clip unmirrored and warns once
|
|
260
|
+
* per state, so a 2D target that forgot its mirror is not silent.
|
|
261
|
+
*/
|
|
262
|
+
mirror(target, resolved) {
|
|
263
|
+
if (target.setFlipX) {
|
|
264
|
+
target.setFlipX(resolved.flip);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (!resolved.flip || this.warnedFlips.has(this.current))
|
|
268
|
+
return;
|
|
269
|
+
this.warnedFlips.add(this.current);
|
|
270
|
+
console.warn(`[waica] "${this.entity.name}": clip "${resolved.clip}" is mirrored for facing "${this.lastFacing ?? ''}", ` +
|
|
271
|
+
'but the Clip Target cannot flip — playing it unmirrored');
|
|
220
272
|
}
|
|
221
273
|
facingProvider() {
|
|
222
274
|
return this.entity.components.find((c) => isAnimationFacingProvider(c));
|
|
@@ -225,19 +277,28 @@ export class StateMachine extends Component {
|
|
|
225
277
|
* playDirectionalClip only runs at enter() time (via playClip), so a
|
|
226
278
|
* facing change that doesn't cross a state boundary — turning while
|
|
227
279
|
* still walking, say — never re-resolves the clip on its own. Catch that
|
|
228
|
-
* here every frame: same state, new facing, re-run the resolution.
|
|
229
|
-
*
|
|
230
|
-
*
|
|
280
|
+
* here every frame: same state, new facing, re-run the resolution. A
|
|
281
|
+
* turn that only mirrors (west playing east flipped) re-applies the
|
|
282
|
+
* mirror and leaves the clip alone — a running one keeps its frame and a
|
|
283
|
+
* finished one-shot keeps its last — so only a turn onto another clip
|
|
284
|
+
* name plays, as re-entering the state would.
|
|
231
285
|
*/
|
|
232
286
|
reresolveOnFacingChange() {
|
|
233
287
|
if (!this.current || !installedDirectionalAnimation())
|
|
234
288
|
return;
|
|
235
|
-
const
|
|
236
|
-
if (!
|
|
289
|
+
const target = this.clipTarget();
|
|
290
|
+
if (!target)
|
|
237
291
|
return;
|
|
238
292
|
const provider = this.facingProvider();
|
|
239
293
|
if (!provider || provider.getAnimationFacing() === this.lastFacing)
|
|
240
294
|
return;
|
|
241
|
-
this.
|
|
295
|
+
const resolved = this.resolveFacingClip(target, this.states[this.current]?.clip ?? this.current);
|
|
296
|
+
if (!resolved)
|
|
297
|
+
return;
|
|
298
|
+
this.mirror(target, resolved);
|
|
299
|
+
if (resolved.clip === target.current)
|
|
300
|
+
return;
|
|
301
|
+
target.play(resolved.clip);
|
|
302
|
+
this.requestedClip = resolved.clip;
|
|
242
303
|
}
|
|
243
304
|
}
|