@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.
@@ -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 sibling AnimatedSprite
50
- * clip of the same name (or the state's `clip` override); a missing
51
- * clip warns and keeps the previous one.
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
- const sprite = this.entity.get(AnimatedSprite);
186
- if (!sprite)
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(sprite, clip))
209
+ if (this.playDirectionalClip(target, clip))
190
210
  return;
191
- if (sprite.clips[clip]) {
192
- sprite.play(clip);
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 "${sprite.current ?? 'none'}"`);
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(sprite, clip) {
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 false;
245
+ return undefined;
209
246
  const provider = this.facingProvider();
210
247
  if (!provider)
211
- return false;
248
+ return undefined;
212
249
  const facing = provider.getAnimationFacing();
213
250
  this.lastFacing = facing;
214
- const resolved = resolveDirectionalClip(animation, Object.keys(sprite.clips), clip, facing);
215
- if (!resolved.clip || !sprite.clips[resolved.clip])
216
- return false;
217
- sprite.setFlipX(resolved.flip);
218
- sprite.play(resolved.clip);
219
- return true;
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
- * AnimatedSprite.play() no-ops on an unchanged clip name, so this is
230
- * cheap when facing hasn't moved the resolved clip.
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 sprite = this.entity.get(AnimatedSprite);
236
- if (!sprite)
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.playDirectionalClip(sprite, this.states[this.current]?.clip ?? this.current);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",