@waica/engine 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +77 -1
  2. package/dist/anchored-pieces.d.ts +3 -2
  3. package/dist/anchored-pieces.js +12 -8
  4. package/dist/animation/clip-player.d.ts +7 -0
  5. package/dist/animation/clip-player.js +9 -0
  6. package/dist/animation/clip-target.d.ts +38 -0
  7. package/dist/animation/clip-target.js +9 -0
  8. package/dist/animation/pose-hash.d.ts +10 -0
  9. package/dist/animation/pose-hash.js +43 -0
  10. package/dist/archetype.d.ts +2 -2
  11. package/dist/assets/asset-loader.d.ts +28 -5
  12. package/dist/assets/asset-loader.js +63 -7
  13. package/dist/assets/model-backend.d.ts +21 -0
  14. package/dist/assets/model-backend.js +21 -0
  15. package/dist/assets/model-cache.d.ts +47 -0
  16. package/dist/assets/model-cache.js +117 -0
  17. package/dist/audio/audio-subsystem.d.ts +6 -1
  18. package/dist/audio/audio-subsystem.js +8 -15
  19. package/dist/audio/placement.d.ts +41 -0
  20. package/dist/audio/placement.js +21 -0
  21. package/dist/audio/spatial.d.ts +20 -0
  22. package/dist/audio/spatial.js +26 -0
  23. package/dist/audio/types.d.ts +7 -1
  24. package/dist/camera-projection.d.ts +38 -0
  25. package/dist/camera-projection.js +44 -0
  26. package/dist/camera-rig.d.ts +48 -0
  27. package/dist/camera-rig.js +98 -0
  28. package/dist/camera.d.ts +20 -3
  29. package/dist/camera.js +3 -1
  30. package/dist/component.d.ts +38 -2
  31. package/dist/component.js +3 -0
  32. package/dist/components/animated-sprite.d.ts +14 -3
  33. package/dist/components/animated-sprite.js +16 -2
  34. package/dist/components/animator.d.ts +98 -0
  35. package/dist/components/animator.js +197 -0
  36. package/dist/components/collider.d.ts +89 -0
  37. package/dist/components/collider.js +57 -0
  38. package/dist/components/dynamic-body.d.ts +2 -1
  39. package/dist/components/dynamic-body.js +1 -0
  40. package/dist/components/hitbox.d.ts +2 -1
  41. package/dist/components/hitbox.js +1 -0
  42. package/dist/components/light.d.ts +2 -1
  43. package/dist/components/light.js +1 -0
  44. package/dist/components/model.d.ts +71 -0
  45. package/dist/components/model.js +128 -0
  46. package/dist/components/particle-emitter.d.ts +2 -1
  47. package/dist/components/particle-emitter.js +1 -0
  48. package/dist/components/point-light.d.ts +75 -0
  49. package/dist/components/point-light.js +102 -0
  50. package/dist/components/rigid-body.d.ts +114 -0
  51. package/dist/components/rigid-body.js +83 -0
  52. package/dist/components/solid.d.ts +2 -1
  53. package/dist/components/solid.js +1 -0
  54. package/dist/components/sprite.d.ts +2 -1
  55. package/dist/components/sprite.js +1 -0
  56. package/dist/components/sun.d.ts +46 -0
  57. package/dist/components/sun.js +74 -0
  58. package/dist/components/tilemap.d.ts +2 -1
  59. package/dist/components/tilemap.js +1 -0
  60. package/dist/entity-transform.d.ts +18 -0
  61. package/dist/entity-transform.js +17 -0
  62. package/dist/game.d.ts +51 -15
  63. package/dist/game.js +83 -70
  64. package/dist/index.d.ts +34 -4
  65. package/dist/index.js +15 -1
  66. package/dist/lit-frame.js +10 -3
  67. package/dist/physics-3d/body-contacts.d.ts +10 -0
  68. package/dist/physics-3d/body-contacts.js +59 -0
  69. package/dist/physics-3d/body-sync.d.ts +52 -0
  70. package/dist/physics-3d/body-sync.js +153 -0
  71. package/dist/physics-3d/character-controller.d.ts +44 -0
  72. package/dist/physics-3d/character-controller.js +126 -0
  73. package/dist/physics-3d/physics-host.d.ts +86 -0
  74. package/dist/physics-3d/physics-host.js +174 -0
  75. package/dist/physics-3d/physics-world.d.ts +65 -0
  76. package/dist/physics-3d/physics-world.js +158 -0
  77. package/dist/physics-3d/rapier-module.d.ts +17 -0
  78. package/dist/physics-3d/rapier-module.js +19 -0
  79. package/dist/physics-3d/sensor-dispatch.d.ts +10 -0
  80. package/dist/physics-3d/sensor-dispatch.js +77 -0
  81. package/dist/physics-3d/world-queries.d.ts +39 -0
  82. package/dist/physics-3d/world-queries.js +63 -0
  83. package/dist/pointer-raycast.d.ts +17 -0
  84. package/dist/pointer-raycast.js +48 -0
  85. package/dist/pointer.d.ts +27 -6
  86. package/dist/pointer.js +43 -17
  87. package/dist/runtime-bridge.d.ts +1 -1
  88. package/dist/runtime-inspection.d.ts +9 -0
  89. package/dist/runtime-inspection.js +8 -2
  90. package/dist/runtime-lighting-snapshot.d.ts +27 -1
  91. package/dist/runtime-lighting-snapshot.js +31 -0
  92. package/dist/runtime-physics-snapshot.d.ts +41 -0
  93. package/dist/runtime-physics-snapshot.js +45 -0
  94. package/dist/runtime-view-snapshot.d.ts +25 -0
  95. package/dist/runtime-view-snapshot.js +30 -0
  96. package/dist/scene-camera-3d.d.ts +66 -0
  97. package/dist/scene-camera-3d.js +117 -0
  98. package/dist/scene-lights-3d.d.ts +17 -0
  99. package/dist/scene-lights-3d.js +27 -0
  100. package/dist/scene-simulation.d.ts +22 -0
  101. package/dist/scene-simulation.js +29 -0
  102. package/dist/scene-space.d.ts +50 -0
  103. package/dist/scene-space.js +103 -0
  104. package/dist/scene.d.ts +12 -2
  105. package/dist/scene.js +11 -4
  106. package/dist/simulation-phases.d.ts +9 -0
  107. package/dist/simulation-phases.js +19 -0
  108. package/dist/spatial-query-3d.d.ts +48 -0
  109. package/dist/spatial-query-3d.js +96 -0
  110. package/dist/spatial-query.d.ts +18 -4
  111. package/dist/spatial-query.js +57 -1
  112. package/dist/state/state-machine.d.ts +32 -8
  113. package/dist/state/state-machine.js +88 -27
  114. package/package.json +2 -1
@@ -4,7 +4,8 @@ import { Hitbox } from './components/hitbox.js';
4
4
  import { Solid } from './components/solid.js';
5
5
  import type { Entity } from './entity.js';
6
6
  import type { Game } from './game.js';
7
- type ComponentClasses = readonly ComponentClass[];
7
+ import { type Point3d, type QueryVolume3d, type RayHit3d } from './spatial-query-3d.js';
8
+ export type ComponentClasses = readonly ComponentClass[];
8
9
  type QueryEntity<Classes extends ComponentClasses> = Classes extends readonly [] ? Entity : EntityWith<Classes>;
9
10
  /** An Entity whose required component classes return non-optional instances. */
10
11
  export interface EntityWith<Classes extends ComponentClasses> extends Entity {
@@ -57,21 +58,34 @@ type NearestGuardFilter<Classes extends ComponentClasses, Narrowed extends Query
57
58
  /** Public logical-space spatial-query service owned by every Game. */
58
59
  export interface SpatialQuery {
59
60
  /** Hitbox owners with positive interior overlap, mask-agnostic and in Entity order. */
60
- area<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(body: CollisionBody, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
61
- area<Classes extends ComponentClasses = readonly []>(body: CollisionBody, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
62
- area(body: CollisionBody, filter?: SpatialQueryFilter): Entity[];
61
+ area<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(body: CollisionBody | QueryVolume3d, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
62
+ /** In a 3D scene `area` takes a volume and returns the entities whose sensor colliders intersect it, in Entity order. */
63
+ area<Classes extends ComponentClasses = readonly []>(body: CollisionBody | QueryVolume3d, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
64
+ area(body: CollisionBody | QueryVolume3d, filter?: SpatialQueryFilter): Entity[];
63
65
  /** Hitbox owners that strictly contain a point, mask-agnostic and in Entity order. */
64
66
  point<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
65
67
  point<Classes extends ComponentClasses = readonly []>(x: number, y: number, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
66
68
  point(x: number, y: number, filter?: SpatialQueryFilter): Entity[];
69
+ /** 3D scenes: entities whose sensor colliders contain the point, in Entity order. */
70
+ point<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(at: Point3d, filter: SpatialGuardFilter<Classes, Narrowed>): Narrowed[];
71
+ point<Classes extends ComponentClasses = readonly []>(at: Point3d, filter: SpatialQueryFilter<Classes>): QueryEntity<Classes>[];
72
+ point(at: Point3d, filter?: SpatialQueryFilter): Entity[];
67
73
  /** Closest filtered logical transform; equal distances retain Entity order. */
68
74
  nearest<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, filter: NearestGuardFilter<Classes, Narrowed>): Narrowed | null;
69
75
  nearest<Classes extends ComponentClasses = readonly []>(x: number, y: number, filter: NearestSpatialQueryFilter<Classes>): QueryEntity<Classes> | null;
70
76
  nearest(x: number, y: number, filter?: NearestSpatialQueryFilter): Entity | null;
77
+ /** 3D scenes: the closest filtered transform, z included. */
78
+ nearest<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(at: Point3d, filter: NearestGuardFilter<Classes, Narrowed>): Narrowed | null;
79
+ nearest<Classes extends ComponentClasses = readonly []>(at: Point3d, filter: NearestSpatialQueryFilter<Classes>): QueryEntity<Classes> | null;
80
+ nearest(at: Point3d, filter?: NearestSpatialQueryFilter): Entity | null;
71
81
  /** First strict crossing of direct or source-derived Solid geometry. */
72
82
  ray<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(x: number, y: number, dx: number, dy: number, maxDistance: number, filter: SpatialGuardFilter<Classes, Narrowed>): RayHit<Narrowed> | null;
73
83
  ray<Classes extends ComponentClasses = readonly []>(x: number, y: number, dx: number, dy: number, maxDistance: number, filter: SpatialQueryFilter<Classes>): RayHit<QueryEntity<Classes>> | null;
74
84
  ray(x: number, y: number, dx: number, dy: number, maxDistance: number, filter?: SpatialQueryFilter): RayHit | null;
85
+ /** 3D scenes: the first solid collider along the ray (sensors never block it); `direction` need not be a unit vector. */
86
+ ray<Classes extends ComponentClasses = readonly [], Narrowed extends QueryEntity<Classes> = QueryEntity<Classes>>(origin: Point3d, direction: Point3d, maxDistance: number, filter: SpatialGuardFilter<Classes, Narrowed>): RayHit3d<Narrowed> | null;
87
+ ray<Classes extends ComponentClasses = readonly []>(origin: Point3d, direction: Point3d, maxDistance: number, filter: SpatialQueryFilter<Classes>): RayHit3d<QueryEntity<Classes>> | null;
88
+ ray(origin: Point3d, direction: Point3d, maxDistance: number, filter?: SpatialQueryFilter): RayHit3d | null;
75
89
  }
76
90
  export interface HitboxCandidate {
77
91
  readonly entity: Entity;
@@ -3,6 +3,7 @@ import { collisionBounds, collisionOverlap, } from './collision-shape.js';
3
3
  import { Hitbox } from './components/hitbox.js';
4
4
  import { Solid } from './components/solid.js';
5
5
  import { createHitboxBroadphase, createSolidBroadphase, } from './spatial-broadphase.js';
6
+ import { SpatialQuery3d } from './spatial-query-3d.js';
6
7
  import { collisionBodyContainsPoint, collisionBodyRay, SPATIAL_QUERY_EPSILON, usableCollisionBody, } from './spatial-query-geometry.js';
7
8
  function indexedCandidateProviders(game) {
8
9
  return {
@@ -162,7 +163,62 @@ class LinearSpatialQuery {
162
163
  return result;
163
164
  }
164
165
  }
166
+ /** A volume names its place by `center`; a collision body by `x` and `y`. */
167
+ const isVolume = (value) => typeof value === 'object' && value !== null && 'center' in value;
168
+ const isRay3d = (args) => typeof args[0] === 'object';
169
+ /**
170
+ * Sends each call to the form its arguments name and refuses the form of the
171
+ * other space with an error naming both (issue #159 CA-21): the 2D forms read
172
+ * Hitboxes and Solids and take numbers or a collision body; the 3D forms read
173
+ * the physics world and take `{ x, y, z }` objects.
174
+ */
175
+ class SpaceSpatialQuery {
176
+ game;
177
+ flat;
178
+ solid;
179
+ constructor(game, flat) {
180
+ this.game = game;
181
+ this.flat = flat;
182
+ this.solid = new SpatialQuery3d(game, { matchesCommon: matchesCommonFilter });
183
+ }
184
+ area(first, filter) {
185
+ if (isVolume(first))
186
+ return this.in3d('area').area(first, filter);
187
+ this.require('area', '2d');
188
+ return this.flat.area(first, filter);
189
+ }
190
+ point(first, second, third) {
191
+ if (typeof first === 'object')
192
+ return this.in3d('point').point(first, typeof second === 'object' ? second : undefined);
193
+ this.require('point', '2d');
194
+ return this.flat.point(first, typeof second === 'number' ? second : Number.NaN, third);
195
+ }
196
+ nearest(first, second, third) {
197
+ if (typeof first === 'object')
198
+ return this.in3d('nearest').nearest(first, typeof second === 'object' ? second : undefined);
199
+ this.require('nearest', '2d');
200
+ return this.flat.nearest(first, typeof second === 'number' ? second : Number.NaN, third);
201
+ }
202
+ ray(...args) {
203
+ if (isRay3d(args)) {
204
+ const [origin, direction, maxDistance, filter] = args;
205
+ return this.in3d('ray').ray({ origin, direction }, maxDistance, filter);
206
+ }
207
+ this.require('ray', '2d');
208
+ return this.flat.ray(...args);
209
+ }
210
+ in3d(operation) {
211
+ this.require(operation, '3d');
212
+ return this.solid;
213
+ }
214
+ require(operation, space) {
215
+ // Only a 3D scene turns the 2D forms away; anything else (no scene, or a stand-in Game) is the 2D world.
216
+ if (space === '3d' ? this.game.space === '3d' : this.game.space !== '3d')
217
+ return;
218
+ throw new Error(`game.query.${operation}: the ${space === '3d' ? '3D' : '2D'} form needs a ${space} scene, and the live scene is ${this.game.space}.`);
219
+ }
220
+ }
165
221
  /** Package-internal constructor; only the SpatialQuery interface is public. */
166
222
  export function createSpatialQuery(game, candidates = indexedCandidateProviders(game), instrumentation) {
167
- return new LinearSpatialQuery(candidates, instrumentation);
223
+ return new SpaceSpatialQuery(game, new LinearSpatialQuery(candidates, instrumentation));
168
224
  }
@@ -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
  }
@@ -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.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
+ "@dimforge/rapier3d-deterministic-compat": "0.21.0",
23
24
  "@types/three": "^0.186.0",
24
25
  "three": "^0.186.1"
25
26
  }