@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.
- package/README.md +77 -1
- package/dist/anchored-pieces.d.ts +3 -2
- package/dist/anchored-pieces.js +12 -8
- 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/archetype.d.ts +2 -2
- package/dist/assets/asset-loader.d.ts +28 -5
- package/dist/assets/asset-loader.js +63 -7
- package/dist/assets/model-backend.d.ts +21 -0
- package/dist/assets/model-backend.js +21 -0
- package/dist/assets/model-cache.d.ts +47 -0
- package/dist/assets/model-cache.js +117 -0
- package/dist/audio/audio-subsystem.d.ts +6 -1
- package/dist/audio/audio-subsystem.js +8 -15
- package/dist/audio/placement.d.ts +41 -0
- package/dist/audio/placement.js +21 -0
- package/dist/audio/spatial.d.ts +20 -0
- package/dist/audio/spatial.js +26 -0
- package/dist/audio/types.d.ts +7 -1
- package/dist/camera-projection.d.ts +38 -0
- package/dist/camera-projection.js +44 -0
- package/dist/camera-rig.d.ts +48 -0
- package/dist/camera-rig.js +98 -0
- package/dist/camera.d.ts +20 -3
- package/dist/camera.js +3 -1
- package/dist/component.d.ts +38 -2
- package/dist/component.js +3 -0
- package/dist/components/animated-sprite.d.ts +14 -3
- package/dist/components/animated-sprite.js +16 -2
- package/dist/components/animator.d.ts +98 -0
- package/dist/components/animator.js +197 -0
- package/dist/components/collider.d.ts +89 -0
- package/dist/components/collider.js +57 -0
- package/dist/components/dynamic-body.d.ts +2 -1
- package/dist/components/dynamic-body.js +1 -0
- package/dist/components/hitbox.d.ts +2 -1
- package/dist/components/hitbox.js +1 -0
- package/dist/components/light.d.ts +2 -1
- package/dist/components/light.js +1 -0
- package/dist/components/model.d.ts +71 -0
- package/dist/components/model.js +128 -0
- package/dist/components/particle-emitter.d.ts +2 -1
- package/dist/components/particle-emitter.js +1 -0
- package/dist/components/point-light.d.ts +75 -0
- package/dist/components/point-light.js +102 -0
- package/dist/components/rigid-body.d.ts +114 -0
- package/dist/components/rigid-body.js +83 -0
- package/dist/components/solid.d.ts +2 -1
- package/dist/components/solid.js +1 -0
- package/dist/components/sprite.d.ts +2 -1
- package/dist/components/sprite.js +1 -0
- package/dist/components/sun.d.ts +46 -0
- package/dist/components/sun.js +74 -0
- package/dist/components/tilemap.d.ts +2 -1
- package/dist/components/tilemap.js +1 -0
- package/dist/entity-transform.d.ts +18 -0
- package/dist/entity-transform.js +17 -0
- package/dist/game.d.ts +51 -15
- package/dist/game.js +83 -70
- package/dist/index.d.ts +34 -4
- package/dist/index.js +15 -1
- package/dist/lit-frame.js +10 -3
- package/dist/physics-3d/body-contacts.d.ts +10 -0
- package/dist/physics-3d/body-contacts.js +59 -0
- package/dist/physics-3d/body-sync.d.ts +52 -0
- package/dist/physics-3d/body-sync.js +153 -0
- package/dist/physics-3d/character-controller.d.ts +44 -0
- package/dist/physics-3d/character-controller.js +126 -0
- package/dist/physics-3d/physics-host.d.ts +86 -0
- package/dist/physics-3d/physics-host.js +174 -0
- package/dist/physics-3d/physics-world.d.ts +65 -0
- package/dist/physics-3d/physics-world.js +158 -0
- package/dist/physics-3d/rapier-module.d.ts +17 -0
- package/dist/physics-3d/rapier-module.js +19 -0
- package/dist/physics-3d/sensor-dispatch.d.ts +10 -0
- package/dist/physics-3d/sensor-dispatch.js +77 -0
- package/dist/physics-3d/world-queries.d.ts +39 -0
- package/dist/physics-3d/world-queries.js +63 -0
- package/dist/pointer-raycast.d.ts +17 -0
- package/dist/pointer-raycast.js +48 -0
- package/dist/pointer.d.ts +27 -6
- package/dist/pointer.js +43 -17
- package/dist/runtime-bridge.d.ts +1 -1
- package/dist/runtime-inspection.d.ts +9 -0
- package/dist/runtime-inspection.js +8 -2
- package/dist/runtime-lighting-snapshot.d.ts +27 -1
- package/dist/runtime-lighting-snapshot.js +31 -0
- package/dist/runtime-physics-snapshot.d.ts +41 -0
- package/dist/runtime-physics-snapshot.js +45 -0
- package/dist/runtime-view-snapshot.d.ts +25 -0
- package/dist/runtime-view-snapshot.js +30 -0
- package/dist/scene-camera-3d.d.ts +66 -0
- package/dist/scene-camera-3d.js +117 -0
- package/dist/scene-lights-3d.d.ts +17 -0
- package/dist/scene-lights-3d.js +27 -0
- package/dist/scene-simulation.d.ts +22 -0
- package/dist/scene-simulation.js +29 -0
- package/dist/scene-space.d.ts +50 -0
- package/dist/scene-space.js +103 -0
- package/dist/scene.d.ts +12 -2
- package/dist/scene.js +11 -4
- package/dist/simulation-phases.d.ts +9 -0
- package/dist/simulation-phases.js +19 -0
- package/dist/spatial-query-3d.d.ts +48 -0
- package/dist/spatial-query-3d.js +96 -0
- package/dist/spatial-query.d.ts +18 -4
- package/dist/spatial-query.js +57 -1
- package/dist/state/state-machine.d.ts +32 -8
- package/dist/state/state-machine.js +88 -27
- package/package.json +2 -1
package/dist/spatial-query.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
62
|
-
area(body: CollisionBody, filter
|
|
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;
|
package/dist/spatial-query.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waica/engine",
|
|
3
|
-
"version": "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
|
}
|