@waica/engine 0.6.0 → 0.7.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,48 @@
1
+ import type { AnimationContract } from './contract.js';
2
+ /**
3
+ * Directional animation contract: the archetype declares which facing
4
+ * directions exist, how clips are named per direction (`<state>-<dir>`),
5
+ * and how a missing direction degrades — including mirroring, so west can
6
+ * reuse east art flipped. Extends the base AnimationContract thesis to
7
+ * genres where characters face more than one way (top-down, isometric).
8
+ */
9
+ export interface DirectionalFallback<Dir extends string = string> {
10
+ dir: Dir;
11
+ /** Mirror the resolved clip horizontally (e.g. west plays east flipped). */
12
+ flip?: boolean;
13
+ }
14
+ export interface DirectionalAnimation<Dir extends string = string> {
15
+ /** Declared facing directions, e.g. ['n', 's', 'e', 'w'] — 8 for isometric. */
16
+ directions: readonly Dir[];
17
+ /** Directional degradation: a missing facing resolves to another, optionally mirrored. */
18
+ fallbacks?: Partial<Record<Dir, DirectionalFallback<Dir>>>;
19
+ /** State-level degradation chain applied when no directional clip resolves. */
20
+ contract: AnimationContract;
21
+ }
22
+ export interface ResolvedDirectionalClip {
23
+ clip: string | undefined;
24
+ flip: boolean;
25
+ }
26
+ /**
27
+ * Resolves state × facing to a playable clip: the exact `<state>-<dir>`
28
+ * clip, else the declared directional fallback chain (accumulating flips).
29
+ * On a dead end, walks the base AnimationContract's state fallback chain
30
+ * (cycle-safe) and, for each candidate state in turn, retries the
31
+ * directional chain against it before trying its bare name. An invalid
32
+ * facing, or a chain that dead-ends everywhere, resolves to nothing rather
33
+ * than guessing — the caller's name-based fallback path handles that.
34
+ */
35
+ export declare function resolveDirectionalClip(animation: DirectionalAnimation, available: Iterable<string>, state: string, facing: string): ResolvedDirectionalClip;
36
+ /** Installs (or clears, with null) the active directional contract. */
37
+ export declare function installDirectionalAnimation(animation: DirectionalAnimation | null): void;
38
+ /** The active directional contract, if an archetype installed one. */
39
+ export declare function installedDirectionalAnimation(): DirectionalAnimation | null;
40
+ /**
41
+ * The explicit seam a driving component (a motor) opts into so StateMachine
42
+ * can resolve directional clips: it reports the entity's current facing as
43
+ * one of the contract's declared directions.
44
+ */
45
+ export interface AnimationFacingProvider {
46
+ getAnimationFacing(): string;
47
+ }
48
+ export declare function isAnimationFacingProvider(value: unknown): value is AnimationFacingProvider;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Walks the exact `<state>-<dir>` clip, then the declared directional
3
+ * fallback chain (accumulating flips), cycle-safe. Undefined at a dead end —
4
+ * callers decide what to try next, instead of this reaching for anything else.
5
+ */
6
+ function directionalChain(animation, available, state, facing) {
7
+ const seen = new Set();
8
+ let dir = facing;
9
+ let flip = false;
10
+ while (dir && !seen.has(dir)) {
11
+ const candidate = `${state}-${dir}`;
12
+ if (available.has(candidate))
13
+ return { clip: candidate, flip };
14
+ seen.add(dir);
15
+ const fallback = animation.fallbacks?.[dir];
16
+ if (!fallback)
17
+ break;
18
+ if (fallback.flip)
19
+ flip = !flip;
20
+ dir = fallback.dir;
21
+ }
22
+ return undefined;
23
+ }
24
+ /**
25
+ * Resolves state × facing to a playable clip: the exact `<state>-<dir>`
26
+ * clip, else the declared directional fallback chain (accumulating flips).
27
+ * On a dead end, walks the base AnimationContract's state fallback chain
28
+ * (cycle-safe) and, for each candidate state in turn, retries the
29
+ * directional chain against it before trying its bare name. An invalid
30
+ * facing, or a chain that dead-ends everywhere, resolves to nothing rather
31
+ * than guessing — the caller's name-based fallback path handles that.
32
+ */
33
+ export function resolveDirectionalClip(animation, available, state, facing) {
34
+ if (!animation.directions.includes(facing))
35
+ return { clip: undefined, flip: false };
36
+ const set = new Set(available);
37
+ const direct = directionalChain(animation, set, state, facing);
38
+ if (direct)
39
+ return direct;
40
+ const seenStates = new Set();
41
+ let candidate = state;
42
+ while (candidate && !seenStates.has(candidate)) {
43
+ seenStates.add(candidate);
44
+ if (candidate !== state) {
45
+ const viaDirection = directionalChain(animation, set, candidate, facing);
46
+ if (viaDirection)
47
+ return viaDirection;
48
+ }
49
+ if (set.has(candidate))
50
+ return { clip: candidate, flip: false };
51
+ candidate = animation.contract.fallbacks[candidate];
52
+ }
53
+ return { clip: undefined, flip: false };
54
+ }
55
+ let installed = null;
56
+ /** Installs (or clears, with null) the active directional contract. */
57
+ export function installDirectionalAnimation(animation) {
58
+ installed = animation;
59
+ }
60
+ /** The active directional contract, if an archetype installed one. */
61
+ export function installedDirectionalAnimation() {
62
+ return installed;
63
+ }
64
+ export function isAnimationFacingProvider(value) {
65
+ return (typeof value === 'object' &&
66
+ value !== null &&
67
+ typeof value.getAnimationFacing === 'function');
68
+ }
@@ -1,3 +1,4 @@
1
+ import type { DirectionalAnimation } from './animation/directional.js';
1
2
  import type { InputBindings } from './input.js';
2
3
  import type { PrefabJson, SceneEntityJson, SceneJson, SceneRegistry } from './scene.js';
3
4
  import type { ArchetypeBundle } from './state/hooks.js';
@@ -30,6 +31,8 @@ export interface ArchetypeManifest {
30
31
  bindings: Readonly<InputBindings>;
31
32
  actionLabels: Readonly<Record<string, string>>;
32
33
  bundle: ArchetypeBundle;
34
+ /** Directional animation contract, for genres where characters face around. */
35
+ animation?: DirectionalAnimation;
33
36
  }
34
37
  /** Browser manifest enriched with URLs produced by an asset-aware bundler. */
35
38
  export interface BrowserArchetypeManifest extends ArchetypeManifest {
package/dist/camera.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface SceneCameraJson {
21
21
  deadzoneWidth?: number;
22
22
  deadzoneHeight?: number;
23
23
  lookahead?: number;
24
+ /** Vertical lookahead in world units; 0 (the default) disables it. */
25
+ lookaheadY?: number;
24
26
  smoothing?: number;
25
27
  limits?: CameraLimitsJson;
26
28
  }
@@ -31,6 +33,7 @@ export interface ResolvedSceneCamera {
31
33
  deadzoneWidth: number;
32
34
  deadzoneHeight: number;
33
35
  lookahead: number;
36
+ lookaheadY: number;
34
37
  smoothing: number;
35
38
  limits: CameraLimitsJson | null;
36
39
  }
@@ -41,10 +44,24 @@ export declare const CAMERA_DEFAULTS: {
41
44
  readonly deadzoneWidth: 2;
42
45
  readonly deadzoneHeight: 2.5;
43
46
  readonly lookahead: 1.5;
47
+ readonly lookaheadY: 0;
44
48
  readonly smoothing: 6;
45
49
  };
46
50
  /** Fills a scene's camera block with the engine defaults. */
47
51
  export declare function resolveSceneCamera(json?: SceneCameraJson): ResolvedSceneCamera;
52
+ /** Two-axis velocity a followed entity reports for camera lookahead. */
53
+ export interface CameraVelocity {
54
+ vx: number;
55
+ vy: number;
56
+ }
57
+ /**
58
+ * The explicit seam a component opts into so the scene camera can read the
59
+ * followed entity's velocity — the engine never guesses from field names.
60
+ */
61
+ export interface CameraVelocityProvider {
62
+ getCameraVelocity(): CameraVelocity;
63
+ }
64
+ export declare function isCameraVelocityProvider(value: unknown): value is CameraVelocityProvider;
48
65
  export interface CameraStepInput {
49
66
  /** Current camera center. */
50
67
  x: number;
@@ -59,6 +76,8 @@ export interface CameraStepInput {
59
76
  } | null;
60
77
  /** Followed entity's horizontal velocity, for lookahead. */
61
78
  vx: number;
79
+ /** Followed entity's vertical velocity, for lookaheadY; omitted = 0. */
80
+ vy?: number;
62
81
  dt: number;
63
82
  }
64
83
  /**
package/dist/camera.js CHANGED
@@ -6,6 +6,7 @@ export const CAMERA_DEFAULTS = {
6
6
  deadzoneWidth: 2,
7
7
  deadzoneHeight: 2.5,
8
8
  lookahead: 1.5,
9
+ lookaheadY: 0,
9
10
  smoothing: 6,
10
11
  };
11
12
  /** Fills a scene's camera block with the engine defaults. */
@@ -17,6 +18,7 @@ export function resolveSceneCamera(json) {
17
18
  deadzoneWidth: json?.deadzoneWidth ?? CAMERA_DEFAULTS.deadzoneWidth,
18
19
  deadzoneHeight: json?.deadzoneHeight ?? CAMERA_DEFAULTS.deadzoneHeight,
19
20
  lookahead: json?.lookahead ?? CAMERA_DEFAULTS.lookahead,
21
+ lookaheadY: json?.lookaheadY ?? CAMERA_DEFAULTS.lookaheadY,
20
22
  smoothing: json?.smoothing ?? CAMERA_DEFAULTS.smoothing,
21
23
  limits: json?.limits ?? null,
22
24
  };
@@ -28,6 +30,11 @@ function clampAxis(center, halfView, min, max) {
28
30
  return (min + max) / 2;
29
31
  return Math.min(Math.max(center, min + halfView), max - halfView);
30
32
  }
33
+ export function isCameraVelocityProvider(value) {
34
+ return (typeof value === 'object' &&
35
+ value !== null &&
36
+ typeof value.getCameraVelocity === 'function');
37
+ }
31
38
  /**
32
39
  * One simulation step of the camera: deadzone-follow with lookahead and
33
40
  * exponential smoothing, then limits. Pure — returns the next center.
@@ -48,6 +55,9 @@ export function stepSceneCamera(cam, input) {
48
55
  wantY = input.target.y - Math.sign(dy) * halfDzH;
49
56
  if (Math.abs(input.vx) > 1)
50
57
  wantX += Math.sign(input.vx) * cam.lookahead;
58
+ const vy = input.vy ?? 0;
59
+ if (Math.abs(vy) > 1)
60
+ wantY += Math.sign(vy) * cam.lookaheadY;
51
61
  x = THREE.MathUtils.damp(x, wantX, cam.smoothing, input.dt);
52
62
  y = THREE.MathUtils.damp(y, wantY, cam.smoothing, input.dt);
53
63
  }
@@ -1,6 +1,7 @@
1
1
  import { Component } from '../component.js';
2
2
  import { type ClipDef } from '../animation/clip-player.js';
3
3
  import { type SheetCell, type SheetDef } from '../animation/sheet.js';
4
+ import type { YSortParticipant } from '../render-sort.js';
4
5
  /**
5
6
  * Sprite animated from one or more spritesheets. The main sheet is the
6
7
  * top-level texture/cols/rows (or explicit cells); extraSheets append after
@@ -10,7 +11,7 @@ import { type SheetCell, type SheetDef } from '../animation/sheet.js';
10
11
  * so the quad rescales per frame, anchored bottom-center — width/height size
11
12
  * the sheet's largest frame and smaller ones keep their feet planted.
12
13
  */
13
- export declare class AnimatedSprite extends Component {
14
+ export declare class AnimatedSprite extends Component implements YSortParticipant {
14
15
  static componentName: string;
15
16
  static updateAfter: readonly string[];
16
17
  static params: {
@@ -59,6 +60,12 @@ export declare class AnimatedSprite extends Component {
59
60
  private _layer;
60
61
  get layer(): number;
61
62
  set layer(value: number);
63
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
64
+ setSortZ(z: number): void;
65
+ /** Mirrored state, readable by Runtime Snapshots; write via setFlipX. */
66
+ flipX: boolean;
67
+ /** Mirrors the quad horizontally — directional clips reuse east art for west. */
68
+ setFlipX(value: boolean): void;
62
69
  clips: Record<string, ClipDef>;
63
70
  initialClip?: string;
64
71
  /** Clip currently playing. */
@@ -22,6 +22,7 @@ export class AnimatedSprite extends Component {
22
22
  };
23
23
  static transient = [
24
24
  'current',
25
+ 'flipX',
25
26
  'player',
26
27
  'sheets',
27
28
  'texs',
@@ -93,6 +94,20 @@ export class AnimatedSprite extends Component {
93
94
  if (this.mesh)
94
95
  this.mesh.position.z = value * 0.01;
95
96
  }
97
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
98
+ setSortZ(z) {
99
+ if (this.mesh)
100
+ this.mesh.position.z = z;
101
+ }
102
+ /** Mirrored state, readable by Runtime Snapshots; write via setFlipX. */
103
+ flipX = false;
104
+ /** Mirrors the quad horizontally — directional clips reuse east art for west. */
105
+ setFlipX(value) {
106
+ if (this.flipX === value)
107
+ return;
108
+ this.flipX = value;
109
+ this.syncQuad();
110
+ }
96
111
  clips = {};
97
112
  initialClip;
98
113
  /** Clip currently playing. */
@@ -171,9 +186,11 @@ export class AnimatedSprite extends Component {
171
186
  syncQuad() {
172
187
  if (!this.mesh)
173
188
  return;
174
- this.mesh.scale.set(this._width * this.frameScaleX, this._height * this.frameScaleY, 1);
189
+ this.mesh.scale.set(this._width * this.frameScaleX * (this.flipX ? -1 : 1), this._height * this.frameScaleY, 1);
175
190
  // Bottom-center anchor: a shrunk frame keeps its feet on the quad's floor.
176
- this.mesh.position.x = this._offsetX;
191
+ // The offset mirrors along with the art, or a flipped sprite with a
192
+ // nonzero offsetX would shift to the wrong side.
193
+ this.mesh.position.x = this.flipX ? -this._offsetX : this._offsetX;
177
194
  this.mesh.position.y = this._offsetY - (this._height * (1 - this.frameScaleY)) / 2;
178
195
  }
179
196
  applyFrame() {
@@ -1,10 +1,11 @@
1
1
  import { Component } from '../component.js';
2
+ import type { YSortParticipant } from '../render-sort.js';
2
3
  export type SpriteShape = 'rectangle' | 'circle';
3
4
  /**
4
5
  * Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
5
6
  * plane in front of the orthographic camera (see DESIGN.md §6, decision 2).
6
7
  */
7
- export declare class Sprite extends Component {
8
+ export declare class Sprite extends Component implements YSortParticipant {
8
9
  static componentName: string;
9
10
  static params: {
10
11
  offsetX: {
@@ -42,6 +43,8 @@ export declare class Sprite extends Component {
42
43
  private _layer;
43
44
  get layer(): number;
44
45
  set layer(value: number);
46
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
47
+ setSortZ(z: number): void;
45
48
  private _shape;
46
49
  get shape(): SpriteShape;
47
50
  set shape(value: SpriteShape);
@@ -71,6 +71,11 @@ export class Sprite extends Component {
71
71
  if (this.mesh)
72
72
  this.mesh.position.z = value * 0.01;
73
73
  }
74
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
75
+ setSortZ(z) {
76
+ if (this.mesh)
77
+ this.mesh.position.z = z;
78
+ }
74
79
  _shape = 'rectangle';
75
80
  get shape() {
76
81
  return this._shape;
package/dist/game.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { Component } from './component.js';
4
4
  import { Entity } from './entity.js';
5
5
  import { Emitter } from './events.js';
6
6
  import { Input, type InputBindings } from './input.js';
7
- import { type SceneRegistry } from './scene.js';
7
+ import { type SceneRegistry, type SceneRenderJson } from './scene.js';
8
8
  import { Stats, type StatValue } from './stats.js';
9
9
  import { GameUi } from './ui.js';
10
10
  /** Fixed game resolution: the view keeps this aspect, letterboxed. */
@@ -61,6 +61,7 @@ export declare class Game {
61
61
  private readonly resolution;
62
62
  private viewHeight;
63
63
  private sceneCamera;
64
+ private renderSort;
64
65
  private lastTime;
65
66
  private runtimeBridge;
66
67
  constructor(options: GameOptions);
@@ -76,6 +77,11 @@ export declare class Game {
76
77
  applyParamOverrides(entity: Entity, component: Component): void;
77
78
  /** Registers a function that runs once per frame. Returns the unsubscribe. */
78
79
  onUpdate(fn: UpdateFn): () => void;
80
+ /**
81
+ * Adopts a scene's render block. Called by loadScene; without a block the
82
+ * draw order stays layer-banded with spawn-order ties.
83
+ */
84
+ setSceneRender(json?: SceneRenderJson): void;
79
85
  /**
80
86
  * Adopts a scene's camera block: jumps to its framing and, while
81
87
  * simulating, follows/clamps per its settings. Called by loadScene.
@@ -94,6 +100,8 @@ export declare class Game {
94
100
  private tick;
95
101
  private runFrame;
96
102
  private unregisterRuntimeBridge;
103
+ /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
104
+ private applyYSort;
97
105
  private renderSurface;
98
106
  private componentUpdateSchedule;
99
107
  private updateSceneCamera;
package/dist/game.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as THREE from 'three';
2
2
  import { collisionOverlap } from './collision-shape.js';
3
- import { resolveSceneCamera, stepSceneCamera } from './camera.js';
3
+ import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
4
4
  import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
5
5
  import { Hitbox } from './components/hitbox.js';
6
6
  import { Entity } from './entity.js';
@@ -8,6 +8,7 @@ import { Emitter } from './events.js';
8
8
  import { Input } from './input.js';
9
9
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
10
10
  import { RuntimeInspector } from './runtime-inspection.js';
11
+ import { isYSortParticipant, ySortZ } from './render-sort.js';
11
12
  import { registryEntry, spawnFromJson } from './scene.js';
12
13
  import { Stats } from './stats.js';
13
14
  import { GameUi } from './ui.js';
@@ -40,6 +41,7 @@ export class Game {
40
41
  resolution;
41
42
  viewHeight;
42
43
  sceneCamera = null;
44
+ renderSort = null;
43
45
  lastTime = 0;
44
46
  runtimeBridge = null;
45
47
  constructor(options) {
@@ -107,6 +109,13 @@ export class Game {
107
109
  this.updateFns.add(fn);
108
110
  return () => this.updateFns.delete(fn);
109
111
  }
112
+ /**
113
+ * Adopts a scene's render block. Called by loadScene; without a block the
114
+ * draw order stays layer-banded with spawn-order ties.
115
+ */
116
+ setSceneRender(json) {
117
+ this.renderSort = json?.sort === 'y' ? 'y' : null;
118
+ }
110
119
  /**
111
120
  * Adopts a scene's camera block: jumps to its framing and, while
112
121
  * simulating, follows/clamps per its settings. Called by loadScene.
@@ -216,7 +225,25 @@ export class Game {
216
225
  this.runtimeBridge?.unregister();
217
226
  this.runtimeBridge = null;
218
227
  };
228
+ /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
229
+ applyYSort() {
230
+ const participants = [];
231
+ const entries = [];
232
+ for (const entity of this.entities) {
233
+ for (const component of entity.components) {
234
+ if (isYSortParticipant(component)) {
235
+ participants.push(component);
236
+ entries.push({ layer: component.layer, y: entity.position.y });
237
+ }
238
+ }
239
+ }
240
+ const z = ySortZ(entries);
241
+ for (const [index, participant] of participants.entries())
242
+ participant.setSortZ(z[index]);
243
+ }
219
244
  renderSurface() {
245
+ if (this.renderSort === 'y')
246
+ this.applyYSort();
220
247
  this.ui.setActive(this.simulate);
221
248
  if (this.resolution) {
222
249
  // Letterbox bars: clear the whole canvas, then render inside the scissor.
@@ -262,14 +289,16 @@ export class Game {
262
289
  if (!cam)
263
290
  return;
264
291
  const followed = cam.follow ? this.find(cam.follow) : undefined;
265
- const mover = followed?.components.find((c) => typeof c.vx === 'number');
292
+ const provider = followed?.components.find((c) => isCameraVelocityProvider(c));
293
+ const velocity = provider?.getCameraVelocity();
266
294
  const next = stepSceneCamera(cam, {
267
295
  x: this.camera.position.x,
268
296
  y: this.camera.position.y,
269
297
  halfW: (this.camera.right - this.camera.left) / 2,
270
298
  halfH: this.viewHeight / 2,
271
299
  target: followed ? { x: followed.position.x, y: followed.position.y } : null,
272
- vx: mover?.vx ?? 0,
300
+ vx: velocity?.vx ?? 0,
301
+ vy: velocity?.vy ?? 0,
273
302
  dt,
274
303
  });
275
304
  this.camera.position.x = next.x;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export { Game } from './game.js';
2
2
  export type { GameOptions, GameResolution, SpawnPrefabOptions, UpdateFn, ParamOverrides, } from './game.js';
3
- export { CAMERA_DEFAULTS, resolveSceneCamera, stepSceneCamera } from './camera.js';
4
- export type { SceneCameraJson, CameraLimitsJson, ResolvedSceneCamera } from './camera.js';
3
+ export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
4
+ export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
5
+ export { isYSortParticipant, ySortZ } from './render-sort.js';
6
+ export type { YSortEntry, YSortParticipant } from './render-sort.js';
7
+ export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
8
+ export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
5
9
  export { Entity } from './entity.js';
6
10
  export { Component } from './component.js';
7
11
  export { authoringDefaults } from './authoring-defaults.js';
@@ -32,7 +36,7 @@ export { COLLISION_SHAPES, DEFAULT_COLLISION_POLYGON, collisionBounds, collision
32
36
  export type { CollisionBody, CollisionBounds, CollisionPoint, CollisionShape, } from './collision-shape.js';
33
37
  export { Emitter } from './events.js';
34
38
  export { loadScene, spawnFromJson, resolveEntityComponents, resolveProps } from './scene.js';
35
- export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, PrefabJson } from './scene.js';
39
+ export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, SceneRenderJson, PrefabJson, } from './scene.js';
36
40
  export { ClipPlayer } from './animation/clip-player.js';
37
41
  export type { ClipDef } from './animation/clip-player.js';
38
42
  export { sheetCell, sheetFrameCount, locateFrame } from './animation/sheet.js';
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { Game } from './game.js';
2
- export { CAMERA_DEFAULTS, resolveSceneCamera, stepSceneCamera } from './camera.js';
2
+ export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
3
+ export { isYSortParticipant, ySortZ } from './render-sort.js';
4
+ export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
3
5
  export { Entity } from './entity.js';
4
6
  export { Component } from './component.js';
5
7
  export { authoringDefaults } from './authoring-defaults.js';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Y-sort draw ordering: an opt-in render mode (scene JSON `render.sort: 'y'`)
3
+ * for top-down scenes where "lower on screen" means "closer to the camera".
4
+ */
5
+ export interface YSortEntry {
6
+ /** The sprite's layer — the primary draw-order band, exactly as without y-sort. */
7
+ layer: number;
8
+ /** The owning entity's world Y — the sort key. Sprite offsets don't shift it. */
9
+ y: number;
10
+ }
11
+ /**
12
+ * The explicit seam a component opts into to participate in y-sort: it
13
+ * exposes its draw-order layer and accepts the per-frame z the pass derives.
14
+ * Both stock sprite classes implement it; a custom renderable can too.
15
+ */
16
+ export interface YSortParticipant {
17
+ readonly layer: number;
18
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
19
+ setSortZ(z: number): void;
20
+ }
21
+ export declare function isYSortParticipant(value: unknown): value is YSortParticipant;
22
+ /**
23
+ * Z per entry under y-sort. Each layer keeps its 0.01 band; within a band,
24
+ * lower Y gets a higher z (renders in front), and exact Y ties keep input
25
+ * order. Offsets stay strictly inside (layer, layer + 1) × 0.01 for integer
26
+ * layers — but a fractional layer (e.g. 0.5) can sit closer than that to the
27
+ * next one present, so each band is capped at the gap to the next distinct
28
+ * layer above it, never wider than 0.01. Integer layers are always >= 1
29
+ * apart, so their band is exactly the old fixed 0.01.
30
+ */
31
+ export declare function ySortZ(entries: readonly YSortEntry[]): number[];
@@ -0,0 +1,39 @@
1
+ export function isYSortParticipant(value) {
2
+ return (typeof value === 'object' &&
3
+ value !== null &&
4
+ typeof value.layer === 'number' &&
5
+ typeof value.setSortZ === 'function');
6
+ }
7
+ /**
8
+ * Z per entry under y-sort. Each layer keeps its 0.01 band; within a band,
9
+ * lower Y gets a higher z (renders in front), and exact Y ties keep input
10
+ * order. Offsets stay strictly inside (layer, layer + 1) × 0.01 for integer
11
+ * layers — but a fractional layer (e.g. 0.5) can sit closer than that to the
12
+ * next one present, so each band is capped at the gap to the next distinct
13
+ * layer above it, never wider than 0.01. Integer layers are always >= 1
14
+ * apart, so their band is exactly the old fixed 0.01.
15
+ */
16
+ export function ySortZ(entries) {
17
+ const byLayer = new Map();
18
+ for (const [index, entry] of entries.entries()) {
19
+ const group = byLayer.get(entry.layer);
20
+ if (group)
21
+ group.push(index);
22
+ else
23
+ byLayer.set(entry.layer, [index]);
24
+ }
25
+ const layers = [...byLayer.keys()].sort((a, b) => a - b);
26
+ const z = new Array(entries.length);
27
+ for (const [i, layer] of layers.entries()) {
28
+ const indices = byLayer.get(layer);
29
+ const next = layers[i + 1];
30
+ const width = next === undefined ? 0.01 : Math.min(0.01, (next - layer) * 0.01);
31
+ // Stable sort: back-to-front is descending Y, ties keep input order.
32
+ const ordered = [...indices].sort((a, b) => entries[b].y - entries[a].y);
33
+ const step = width / (ordered.length + 1);
34
+ for (const [rank, index] of ordered.entries()) {
35
+ z[index] = layer * 0.01 + (rank + 1) * step;
36
+ }
37
+ }
38
+ return z;
39
+ }
package/dist/scene.d.ts CHANGED
@@ -28,10 +28,20 @@ export interface PrefabJson {
28
28
  type: 'character' | 'object' | 'tile';
29
29
  components: SceneComponentJson[];
30
30
  }
31
+ /** Scene-wide render options (v3). */
32
+ export interface SceneRenderJson {
33
+ /**
34
+ * 'y' orders same-layer sprites by their entity's world Y — lower Y renders
35
+ * in front (top-down depth). Absent: spawn order breaks same-layer ties.
36
+ */
37
+ sort?: 'y';
38
+ }
31
39
  export interface SceneJson {
32
40
  waicaScene: 1 | 2 | 3;
33
41
  /** The scene's built-in camera (v3); absent = the host keeps control. */
34
42
  camera?: SceneCameraJson;
43
+ /** Draw-order policy (v3); absent = layer bands with spawn-order ties. */
44
+ render?: SceneRenderJson;
35
45
  entities: SceneEntityJson[];
36
46
  /** UI pieces (src/ui/*.html) mounted visible when the scene loads. */
37
47
  ui?: string[];
package/dist/scene.js CHANGED
@@ -82,6 +82,7 @@ export function spawnFromJson(game, json, registry) {
82
82
  /** Loads a full scene into the game. */
83
83
  export function loadScene(game, scene, registry) {
84
84
  game.registry = registry;
85
+ game.setSceneRender(scene.render);
85
86
  for (const entityJson of scene.entities)
86
87
  spawnFromJson(game, entityJson, registry);
87
88
  // After the spawns: with a follow target, the camera starts centered on it.
@@ -63,7 +63,7 @@ export interface ArchetypeBundle {
63
63
  /** Extra named logic sets that are not themselves roles. */
64
64
  logicSets?: Readonly<Record<string, StateLogic>>;
65
65
  }
66
- /** Clears role definitions and every named logic set. */
66
+ /** Clears role definitions, every named logic set and the directional contract. */
67
67
  export declare function resetRegistries(): void;
68
68
  /**
69
69
  * Replaces the active registry contents with one archetype's complete bundle.
@@ -1,3 +1,4 @@
1
+ import { installDirectionalAnimation } from '../animation/directional.js';
1
2
  const sets = new Map();
2
3
  /**
3
4
  * Registers state code under a logic-set name. A role's name is also its
@@ -8,10 +9,11 @@ export function defineStates(name, states) {
8
9
  sets.set(name, { ...sets.get(name), ...states });
9
10
  }
10
11
  const roles = new Map();
11
- /** Clears role definitions and every named logic set. */
12
+ /** Clears role definitions, every named logic set and the directional contract. */
12
13
  export function resetRegistries() {
13
14
  roles.clear();
14
15
  sets.clear();
16
+ installDirectionalAnimation(null);
15
17
  }
16
18
  /**
17
19
  * Replaces the active registry contents with one archetype's complete bundle.
@@ -66,6 +66,8 @@ export declare class StateMachine extends Component {
66
66
  private readonly instanceHooks;
67
67
  private readonly signals;
68
68
  private readonly warnedClips;
69
+ /** Facing last used to resolve a directional clip — detects a turn mid-state. */
70
+ private lastFacing;
69
71
  onReady(): void;
70
72
  /** Adds instance-level hooks on top of the logic set — the escape hatch. */
71
73
  on(state: string, hooks: StateHooks): void;
@@ -81,4 +83,20 @@ export declare class StateMachine extends Component {
81
83
  private runCollision;
82
84
  private enter;
83
85
  private playClip;
86
+ /**
87
+ * Directional resolution: with an installed contract AND a sibling that
88
+ * reports facing, plays state × facing (mirroring included). Returns false
89
+ * to keep the name-based path — no contract, no facing, no playable clip.
90
+ */
91
+ private playDirectionalClip;
92
+ private facingProvider;
93
+ /**
94
+ * playDirectionalClip only runs at enter() time (via playClip), so a
95
+ * facing change that doesn't cross a state boundary — turning while
96
+ * 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.
100
+ */
101
+ private reresolveOnFacingChange;
84
102
  }
@@ -1,3 +1,4 @@
1
+ import { installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from '../animation/directional.js';
1
2
  import { Component } from '../component.js';
2
3
  import { AnimatedSprite } from '../components/animated-sprite.js';
3
4
  import { closestLogicSet, logicSet, registeredLogicSets, } from './hooks.js';
@@ -47,7 +48,14 @@ export class StateMachine extends Component {
47
48
  static params = {
48
49
  role: { label: 'Role' },
49
50
  };
50
- static transient = ['current', 'elapsed', 'instanceHooks', 'signals', 'warnedClips'];
51
+ static transient = [
52
+ 'current',
53
+ 'elapsed',
54
+ 'instanceHooks',
55
+ 'signals',
56
+ 'warnedClips',
57
+ 'lastFacing',
58
+ ];
51
59
  /** The character's role — names the logic set providing its state code. */
52
60
  role = '';
53
61
  /** Starting state; defaults to the first declared state. */
@@ -60,6 +68,8 @@ export class StateMachine extends Component {
60
68
  instanceHooks = new Map();
61
69
  signals = new Set();
62
70
  warnedClips = new Set();
71
+ /** Facing last used to resolve a directional clip — detects a turn mid-state. */
72
+ lastFacing;
63
73
  onReady() {
64
74
  if (this.role && !logicSet(this.role)) {
65
75
  const sets = registeredLogicSets();
@@ -111,6 +121,7 @@ export class StateMachine extends Component {
111
121
  this.enter(edge.to);
112
122
  }
113
123
  this.signals.clear();
124
+ this.reresolveOnFacingChange();
114
125
  }
115
126
  onCollide(other) {
116
127
  // The state that was active when the contact happened owns it: a
@@ -167,6 +178,8 @@ export class StateMachine extends Component {
167
178
  if (!sprite)
168
179
  return;
169
180
  const clip = this.states[state]?.clip ?? state;
181
+ if (this.playDirectionalClip(sprite, clip))
182
+ return;
170
183
  if (sprite.clips[clip]) {
171
184
  sprite.play(clip);
172
185
  }
@@ -176,4 +189,47 @@ export class StateMachine extends Component {
176
189
  `keeping "${sprite.current ?? 'none'}"`);
177
190
  }
178
191
  }
192
+ /**
193
+ * Directional resolution: with an installed contract AND a sibling that
194
+ * reports facing, plays state × facing (mirroring included). Returns false
195
+ * to keep the name-based path — no contract, no facing, no playable clip.
196
+ */
197
+ playDirectionalClip(sprite, clip) {
198
+ const animation = installedDirectionalAnimation();
199
+ if (!animation)
200
+ return false;
201
+ const provider = this.facingProvider();
202
+ if (!provider)
203
+ return false;
204
+ const facing = provider.getAnimationFacing();
205
+ this.lastFacing = facing;
206
+ const resolved = resolveDirectionalClip(animation, Object.keys(sprite.clips), clip, facing);
207
+ if (!resolved.clip || !sprite.clips[resolved.clip])
208
+ return false;
209
+ sprite.setFlipX(resolved.flip);
210
+ sprite.play(resolved.clip);
211
+ return true;
212
+ }
213
+ facingProvider() {
214
+ return this.entity.components.find((c) => isAnimationFacingProvider(c));
215
+ }
216
+ /**
217
+ * playDirectionalClip only runs at enter() time (via playClip), so a
218
+ * facing change that doesn't cross a state boundary — turning while
219
+ * still walking, say — never re-resolves the clip on its own. Catch that
220
+ * here every frame: same state, new facing, re-run the resolution.
221
+ * AnimatedSprite.play() no-ops on an unchanged clip name, so this is
222
+ * cheap when facing hasn't moved the resolved clip.
223
+ */
224
+ reresolveOnFacingChange() {
225
+ if (!this.current || !installedDirectionalAnimation())
226
+ return;
227
+ const sprite = this.entity.get(AnimatedSprite);
228
+ if (!sprite)
229
+ return;
230
+ const provider = this.facingProvider();
231
+ if (!provider || provider.getAnimationFacing() === this.lastFacing)
232
+ return;
233
+ this.playDirectionalClip(sprite, this.states[this.current]?.clip ?? this.current);
234
+ }
179
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",