@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.
@@ -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;
@@ -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.root.add(this.game.assets.model(this._src).root);
92
- else
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/game.d.ts CHANGED
@@ -255,7 +255,7 @@ export declare class Game {
255
255
  get projection(): 'isometric' | null;
256
256
  /** The live scene's space (`render.space`); `'2d'` with no scene (ADR 0027). */
257
257
  get space(): SceneSpace;
258
- /** The camera the live scene draws with: orthographic in 2D scenes, perspective in 3D ones. */
258
+ /** The camera the live scene draws with: orthographic in 2D scenes, perspective in 3D ones, or a host's view camera over a 3D one (`cameraRig.setViewCamera`). */
259
259
  get camera(): GameCamera;
260
260
  /**
261
261
  * Where a logical point falls on the canvas, in CSS px from its top-left
package/dist/game.js CHANGED
@@ -469,7 +469,7 @@ export class Game {
469
469
  get space() {
470
470
  return this.sceneSpace;
471
471
  }
472
- /** The camera the live scene draws with: orthographic in 2D scenes, perspective in 3D ones. */
472
+ /** The camera the live scene draws with: orthographic in 2D scenes, perspective in 3D ones, or a host's view camera over a 3D one (`cameraRig.setViewCamera`). */
473
473
  get camera() {
474
474
  return this.cameraRig.camera;
475
475
  }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { AssetLoader } from './assets/asset-loader.js';
4
4
  export type { AssetStatus } from './assets/asset-loader.js';
5
5
  export type { TextureBackend } from './assets/texture-backend.js';
6
6
  export { GltfModelBackend } from './assets/model-backend.js';
7
+ export { glbClipNames } from './assets/glb-clip-names.js';
7
8
  export type { LoadedModel, ModelBackend } from './assets/model-backend.js';
8
9
  export type { ModelHandle, ModelOutcome } from './assets/model-cache.js';
9
10
  export { AudioSubsystem } from './audio/audio-subsystem.js';
@@ -12,6 +13,8 @@ export type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions
12
13
  export type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './audio/types.js';
13
14
  export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
14
15
  export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
16
+ export { isClipTarget } from './animation/clip-target.js';
17
+ export type { ClipTarget } from './animation/clip-target.js';
15
18
  export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
16
19
  export type { YSortBatchParticipant, YSortEntry, YSortParticipant } from './render-sort.js';
17
20
  export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
@@ -47,6 +50,8 @@ export type { ActionName, InjectedActionOperation, InputBindings, InputOptions }
47
50
  export { ACTION_HELD_THRESHOLD, firstStandardPadValues, GAMEPAD_CODE_PREFIX, gamepadControl, } from './gamepad.js';
48
51
  export type { GamepadControl } from './gamepad.js';
49
52
  export { Pointer } from './pointer.js';
53
+ export { raycastPick } from './pointer-raycast.js';
54
+ export type { PickedTarget, PickTarget, RaycastPick, ViewportFraction } from './pointer-raycast.js';
50
55
  export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from './pointer.js';
51
56
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
52
57
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeBridgeFailure, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
@@ -63,6 +68,9 @@ export { Sprite } from './components/sprite.js';
63
68
  export { Light } from './components/light.js';
64
69
  export { Model } from './components/model.js';
65
70
  export type { ModelShape } from './components/model.js';
71
+ export { Animator } from './components/animator.js';
72
+ export type { AnimatorClipOverride, AnimatorState } from './components/animator.js';
73
+ export { poseHash } from './animation/pose-hash.js';
66
74
  export { Sun } from './components/sun.js';
67
75
  export { Collider, COLLIDER_SHAPES } from './components/collider.js';
68
76
  export type { ColliderShape } from './components/collider.js';
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  export { Game } from './game.js';
2
2
  export { AssetLoader } from './assets/asset-loader.js';
3
3
  export { GltfModelBackend } from './assets/model-backend.js';
4
+ export { glbClipNames } from './assets/glb-clip-names.js';
4
5
  export { AudioSubsystem } from './audio/audio-subsystem.js';
5
6
  export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
7
+ export { isClipTarget } from './animation/clip-target.js';
6
8
  export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
7
9
  export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
8
10
  export { spritePlacement } from './sprite-placement.js';
@@ -22,6 +24,7 @@ export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
22
24
  export { Input, DEFAULT_BINDINGS } from './input.js';
23
25
  export { ACTION_HELD_THRESHOLD, firstStandardPadValues, GAMEPAD_CODE_PREFIX, gamepadControl, } from './gamepad.js';
24
26
  export { Pointer } from './pointer.js';
27
+ export { raycastPick } from './pointer-raycast.js';
25
28
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
26
29
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
27
30
  export { Stats } from './stats.js';
@@ -29,6 +32,8 @@ export { GameUi } from './ui.js';
29
32
  export { Sprite } from './components/sprite.js';
30
33
  export { Light } from './components/light.js';
31
34
  export { Model } from './components/model.js';
35
+ export { Animator } from './components/animator.js';
36
+ export { poseHash } from './animation/pose-hash.js';
32
37
  export { Sun } from './components/sun.js';
33
38
  export { Collider, COLLIDER_SHAPES } from './components/collider.js';
34
39
  export { RigidBody, RIGID_BODY_TYPES } from './components/rigid-body.js';
@@ -1,17 +1,35 @@
1
1
  import * as THREE from 'three/webgpu';
2
2
  import type { Entity } from './entity.js';
3
- import type { PointerPick } from './pointer.js';
4
3
  /** A position inside the letterboxed game viewport, as fractions of its width and height. */
5
4
  export interface ViewportFraction {
6
5
  nx: number;
7
6
  ny: number;
8
7
  }
8
+ /** What an extra target picks: an entity (an editor's icon for it), or the scene camera's icon. */
9
+ export type PickedTarget = Entity | 'camera';
10
+ /** An object a ray is also tested against, and what a hit on it picks (issue #173 CA-2). */
11
+ export interface PickTarget<Picked extends PickedTarget = PickedTarget> {
12
+ readonly object: THREE.Object3D;
13
+ readonly entity: Picked;
14
+ }
15
+ /** A 3D pick: the hit's world point, and the entity (or extra target) hit, or null on the ground. */
16
+ export interface RaycastPick<Picked extends PickedTarget = Entity> {
17
+ readonly point: {
18
+ readonly x: number;
19
+ readonly y: number;
20
+ readonly z: number;
21
+ };
22
+ readonly entity: Entity | Picked | null;
23
+ }
9
24
  /**
10
25
  * The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
11
26
  * letterboxed point, intersected with the roots of every live Model
12
- * (recursively, so a glTF's meshes count and a glTF still loading does not).
13
- * The nearest hit wins, with its world-space point. With no hit the pick has
14
- * no entity and the point where the ray meets the ground plane y = 0; a ray
15
- * that never meets it (it looks at the sky) yields no pick.
27
+ * (recursively, so a glTF's meshes count, posed where its skeleton is, and a
28
+ * glTF still loading does not) and with the `extra` targets a host adds (the
29
+ * editor's icons, issue #173 CA-2; one that is hidden, or whose entity is
30
+ * dead, is skipped). The nearest hit wins, with its
31
+ * world-space point. With no hit the pick has no entity and the point where
32
+ * the ray meets the ground plane y = 0; a ray that never meets it (it looks
33
+ * at the sky) yields no pick.
16
34
  */
17
- export declare function raycastPick(camera: THREE.PerspectiveCamera, at: ViewportFraction, entities: readonly Entity[]): PointerPick | null;
35
+ export declare function raycastPick<Picked extends PickedTarget = Entity>(camera: THREE.PerspectiveCamera, at: ViewportFraction, entities: readonly Entity[], extra?: readonly PickTarget<Picked>[]): RaycastPick<Picked> | null;
@@ -16,33 +16,63 @@ function modelRoots(entities) {
16
16
  }
17
17
  return roots;
18
18
  }
19
- /** The entity whose Model root `object` hangs under. */
20
- function ownerOf(object, roots) {
19
+ /** What `object` picks: the target or Model root it hangs under, nearest first. */
20
+ function ownerOf(object, owners) {
21
21
  for (let node = object; node; node = node.parent) {
22
- const entity = roots.get(node);
23
- if (entity)
24
- return entity;
22
+ const owner = owners.get(node);
23
+ if (owner)
24
+ return owner;
25
25
  }
26
26
  return null;
27
27
  }
28
+ /** Whether `target` is drawn and still picks something: itself and every ancestor visible, and its entity alive. */
29
+ function isPickable(target) {
30
+ if (target.entity !== 'camera' && !target.entity.alive)
31
+ return false;
32
+ for (let node = target.object; node; node = node.parent) {
33
+ if (!node.visible)
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+ /** The extra targets' hits: tested whatever their layers, since an editor overlay lives on the Emissive layer alone. */
39
+ function extraHits(targets) {
40
+ if (targets.length === 0)
41
+ return [];
42
+ const own = raycaster.layers.mask;
43
+ raycaster.layers.enableAll();
44
+ try {
45
+ return raycaster.intersectObjects(targets.filter(isPickable).map((target) => target.object), true);
46
+ }
47
+ finally {
48
+ raycaster.layers.mask = own;
49
+ }
50
+ }
28
51
  /**
29
52
  * The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
30
53
  * letterboxed point, intersected with the roots of every live Model
31
- * (recursively, so a glTF's meshes count and a glTF still loading does not).
32
- * The nearest hit wins, with its world-space point. With no hit the pick has
33
- * no entity and the point where the ray meets the ground plane y = 0; a ray
34
- * that never meets it (it looks at the sky) yields no pick.
54
+ * (recursively, so a glTF's meshes count, posed where its skeleton is, and a
55
+ * glTF still loading does not) and with the `extra` targets a host adds (the
56
+ * editor's icons, issue #173 CA-2; one that is hidden, or whose entity is
57
+ * dead, is skipped). The nearest hit wins, with its
58
+ * world-space point. With no hit the pick has no entity and the point where
59
+ * the ray meets the ground plane y = 0; a ray that never meets it (it looks
60
+ * at the sky) yields no pick.
35
61
  */
36
- export function raycastPick(camera, at, entities) {
62
+ // eslint-disable-next-line max-params -- the signature spec #173 fixes: the Pointer's three inputs plus the optional extra targets; bundling them would be an unrelated bag
63
+ export function raycastPick(camera, at, entities, extra = []) {
37
64
  camera.updateMatrixWorld();
38
65
  raycaster.setFromCamera(new THREE.Vector2(at.nx * 2 - 1, 1 - at.ny * 2), camera);
39
66
  const roots = modelRoots(entities);
67
+ const owners = new Map(roots);
68
+ for (const target of extra)
69
+ owners.set(target.object, target.entity);
40
70
  // A click can land before the first frame has placed anything.
41
- for (const root of roots.keys())
42
- root.updateWorldMatrix(true, true);
43
- const [hit] = raycaster.intersectObjects([...roots.keys()], true);
71
+ for (const object of owners.keys())
72
+ object.updateWorldMatrix(true, true);
73
+ const [hit] = [...raycaster.intersectObjects([...roots.keys()], true), ...extraHits(extra)].sort((a, b) => a.distance - b.distance);
44
74
  if (hit)
45
- return { entity: ownerOf(hit.object, roots), point: { x: hit.point.x, y: hit.point.y, z: hit.point.z } };
75
+ return { entity: ownerOf(hit.object, owners), point: { x: hit.point.x, y: hit.point.y, z: hit.point.z } };
46
76
  const ground = raycaster.ray.intersectPlane(GROUND, new THREE.Vector3());
47
77
  return ground ? { entity: null, point: { x: ground.x, y: ground.y, z: ground.z } } : null;
48
78
  }
@@ -6,7 +6,8 @@ function vector(value) {
6
6
  return [clean(value.x), clean(value.y), clean(value.z)];
7
7
  }
8
8
  export function viewSnapshot(game) {
9
- const camera = game.camera;
9
+ // The scene's own camera: a host's view camera is not part of the simulated game, and `lookDistance` is the scene camera's.
10
+ const camera = game.cameraRig.sceneCamera;
10
11
  if (!isPerspectiveCamera(camera)) {
11
12
  return { kind: 'orthographic', position: [clean(camera.position.x), clean(camera.position.y)], zoom: game.view };
12
13
  }
@@ -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 sibling AnimatedSprite
45
- * clip of the same name (or the state's `clip` override); a missing
46
- * clip warns and keeps the previous one.
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
- * AnimatedSprite.play() no-ops on an unchanged clip name, so this is
99
- * cheap when facing hasn't moved the resolved clip.
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
  }