@waica/engine 0.6.1 → 0.8.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
  }
@@ -63,6 +63,8 @@ export declare abstract class Component {
63
63
  onReady?(): void;
64
64
  /** Runs once per frame. */
65
65
  onUpdate?(dt: number): void;
66
+ /** Runs after the scene changes between identity and projected rendering. */
67
+ onProjectionChange?(projection: 'isometric' | null): void;
66
68
  /** Runs when this entity's Hitbox overlaps another one's. */
67
69
  onCollide?(other: Entity): void;
68
70
  /** Runs when this entity's DynamicBody physically contacts a Solid. */
@@ -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: {
@@ -20,6 +21,18 @@ export declare class AnimatedSprite extends Component {
20
21
  offsetY: {
21
22
  label: string;
22
23
  };
24
+ anchorX: {
25
+ label: string;
26
+ min: number;
27
+ max: number;
28
+ step: number;
29
+ };
30
+ anchorY: {
31
+ label: string;
32
+ min: number;
33
+ max: number;
34
+ step: number;
35
+ };
23
36
  layer: {
24
37
  label: string;
25
38
  min: number;
@@ -55,10 +68,22 @@ export declare class AnimatedSprite extends Component {
55
68
  set offsetX(value: number);
56
69
  get offsetY(): number;
57
70
  set offsetY(value: number);
71
+ private _anchorX;
72
+ private _anchorY;
73
+ get anchorX(): number;
74
+ set anchorX(value: number);
75
+ get anchorY(): number;
76
+ set anchorY(value: number);
58
77
  pixelArt: boolean;
59
78
  private _layer;
60
79
  get layer(): number;
61
80
  set layer(value: number);
81
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
82
+ setSortZ(z: number): void;
83
+ /** Mirrored state, readable by Runtime Snapshots; write via setFlipX. */
84
+ flipX: boolean;
85
+ /** Mirrors the quad horizontally — directional clips reuse east art for west. */
86
+ setFlipX(value: boolean): void;
62
87
  clips: Record<string, ClipDef>;
63
88
  initialClip?: string;
64
89
  /** Clip currently playing. */
@@ -76,7 +101,7 @@ export declare class AnimatedSprite extends Component {
76
101
  onUpdate(dt: number): void;
77
102
  onDestroy(): void;
78
103
  private showFrame;
79
- /** Repositions/rescales the quad from size, offsets and the frame scale. */
104
+ /** Repositions/rescales the displayed frame inside its anchored full-size box. */
80
105
  private syncQuad;
81
106
  private applyFrame;
82
107
  }
@@ -2,7 +2,9 @@ import * as THREE from 'three';
2
2
  import { Component } from '../component.js';
3
3
  import { ClipPlayer } from '../animation/clip-player.js';
4
4
  import { locateFrame, sheetCell } from '../animation/sheet.js';
5
+ import { spritePlacement } from '../sprite-placement.js';
5
6
  const loader = new THREE.TextureLoader();
7
+ const clampAnchor = (value) => Math.min(1, Math.max(0, value));
6
8
  /**
7
9
  * Sprite animated from one or more spritesheets. The main sheet is the
8
10
  * top-level texture/cols/rows (or explicit cells); extraSheets append after
@@ -18,10 +20,13 @@ export class AnimatedSprite extends Component {
18
20
  static params = {
19
21
  offsetX: { label: 'x offset' },
20
22
  offsetY: { label: 'y offset' },
23
+ anchorX: { label: 'x anchor', min: 0, max: 1, step: 0.25 },
24
+ anchorY: { label: 'y anchor', min: 0, max: 1, step: 0.25 },
21
25
  layer: { label: 'layer', min: -5, max: 5, step: 1 },
22
26
  };
23
27
  static transient = [
24
28
  'current',
29
+ 'flipX',
25
30
  'player',
26
31
  'sheets',
27
32
  'texs',
@@ -81,6 +86,22 @@ export class AnimatedSprite extends Component {
81
86
  this._offsetY = value;
82
87
  this.syncQuad();
83
88
  }
89
+ _anchorX = 0.5;
90
+ _anchorY = 0.5;
91
+ get anchorX() {
92
+ return this._anchorX;
93
+ }
94
+ set anchorX(value) {
95
+ this._anchorX = clampAnchor(value);
96
+ this.syncQuad();
97
+ }
98
+ get anchorY() {
99
+ return this._anchorY;
100
+ }
101
+ set anchorY(value) {
102
+ this._anchorY = clampAnchor(value);
103
+ this.syncQuad();
104
+ }
84
105
  pixelArt = true;
85
106
  // Draw order among sprites: higher layers render in front. Same-layer
86
107
  // sprites fall back to spawn order, so give overlap an explicit layer.
@@ -93,6 +114,20 @@ export class AnimatedSprite extends Component {
93
114
  if (this.mesh)
94
115
  this.mesh.position.z = value * 0.01;
95
116
  }
117
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
118
+ setSortZ(z) {
119
+ if (this.mesh)
120
+ this.mesh.position.z = z;
121
+ }
122
+ /** Mirrored state, readable by Runtime Snapshots; write via setFlipX. */
123
+ flipX = false;
124
+ /** Mirrors the quad horizontally — directional clips reuse east art for west. */
125
+ setFlipX(value) {
126
+ if (this.flipX === value)
127
+ return;
128
+ this.flipX = value;
129
+ this.syncQuad();
130
+ }
96
131
  clips = {};
97
132
  initialClip;
98
133
  /** Clip currently playing. */
@@ -167,14 +202,24 @@ export class AnimatedSprite extends Component {
167
202
  this.frame = index;
168
203
  this.applyFrame();
169
204
  }
170
- /** Repositions/rescales the quad from size, offsets and the frame scale. */
205
+ /** Repositions/rescales the displayed frame inside its anchored full-size box. */
171
206
  syncQuad() {
172
207
  if (!this.mesh)
173
208
  return;
174
- this.mesh.scale.set(this._width * this.frameScaleX, this._height * this.frameScaleY, 1);
175
- // Bottom-center anchor: a shrunk frame keeps its feet on the quad's floor.
176
- this.mesh.position.x = this._offsetX;
177
- this.mesh.position.y = this._offsetY - (this._height * (1 - this.frameScaleY)) / 2;
209
+ const placement = spritePlacement({
210
+ width: this.width,
211
+ height: this.height,
212
+ offsetX: this.offsetX,
213
+ offsetY: this.offsetY,
214
+ anchorX: this.anchorX,
215
+ anchorY: this.anchorY,
216
+ flipX: this.flipX,
217
+ frameScaleX: this.frameScaleX,
218
+ frameScaleY: this.frameScaleY,
219
+ });
220
+ this.mesh.position.x = placement.x;
221
+ this.mesh.position.y = placement.y;
222
+ this.mesh.scale.set(placement.scaleX, placement.scaleY, 1);
178
223
  }
179
224
  applyFrame() {
180
225
  const located = locateFrame(this.sheets, this.frame);
@@ -1,5 +1,6 @@
1
1
  import { COLLISION_SHAPES, collisionBounds, collisionOverlap, resolveCollisionPoints, } from '../collision-shape.js';
2
2
  import { Component } from '../component.js';
3
+ import { sceneSolids } from '../scene-solids.js';
3
4
  import { resolveSolidAxis } from '../solid-axis.js';
4
5
  import { Solid } from './solid.js';
5
6
  /** Stable tie order for equal-distance initial-overlap recovery. */
@@ -161,10 +162,7 @@ export class DynamicBody extends Component {
161
162
  return { entity: solid.entity, solid, axis, normal };
162
163
  }
163
164
  solids() {
164
- return this.game.entities
165
- .filter((entity) => entity !== this.entity)
166
- .map((entity) => entity.get(Solid))
167
- .filter((solid) => solid !== undefined);
165
+ return sceneSolids(this.game, this.entity);
168
166
  }
169
167
  collisionBody() {
170
168
  return {
@@ -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: {
@@ -13,6 +14,18 @@ export declare class Sprite extends Component {
13
14
  offsetY: {
14
15
  label: string;
15
16
  };
17
+ anchorX: {
18
+ label: string;
19
+ min: number;
20
+ max: number;
21
+ step: number;
22
+ };
23
+ anchorY: {
24
+ label: string;
25
+ min: number;
26
+ max: number;
27
+ step: number;
28
+ };
16
29
  layer: {
17
30
  label: string;
18
31
  min: number;
@@ -36,17 +49,26 @@ export declare class Sprite extends Component {
36
49
  set offsetX(value: number);
37
50
  get offsetY(): number;
38
51
  set offsetY(value: number);
52
+ private _anchorX;
53
+ private _anchorY;
54
+ get anchorX(): number;
55
+ set anchorX(value: number);
56
+ get anchorY(): number;
57
+ set anchorY(value: number);
39
58
  /** Optional texture URL; with pixelArt on it filters in nearest. */
40
59
  texture?: string;
41
60
  pixelArt: boolean;
42
61
  private _layer;
43
62
  get layer(): number;
44
63
  set layer(value: number);
64
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
65
+ setSortZ(z: number): void;
45
66
  private _shape;
46
67
  get shape(): SpriteShape;
47
68
  set shape(value: SpriteShape);
48
69
  private mesh?;
49
70
  onReady(): void;
50
71
  onDestroy(): void;
72
+ private syncQuad;
51
73
  private createGeometry;
52
74
  }
@@ -1,6 +1,8 @@
1
1
  import * as THREE from 'three';
2
2
  import { Component } from '../component.js';
3
+ import { spritePlacement } from '../sprite-placement.js';
3
4
  const loader = new THREE.TextureLoader();
5
+ const clampAnchor = (value) => Math.min(1, Math.max(0, value));
4
6
  /**
5
7
  * Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
6
8
  * plane in front of the orthographic camera (see DESIGN.md §6, decision 2).
@@ -10,6 +12,8 @@ export class Sprite extends Component {
10
12
  static params = {
11
13
  offsetX: { label: 'x offset' },
12
14
  offsetY: { label: 'y offset' },
15
+ anchorX: { label: 'x anchor', min: 0, max: 1, step: 0.25 },
16
+ anchorY: { label: 'y anchor', min: 0, max: 1, step: 0.25 },
13
17
  layer: { label: 'layer', min: -5, max: 5, step: 1 },
14
18
  };
15
19
  static transient = ['mesh'];
@@ -22,14 +26,14 @@ export class Sprite extends Component {
22
26
  }
23
27
  set width(value) {
24
28
  this._width = value;
25
- this.mesh?.scale.set(this._width, this._height, 1);
29
+ this.syncQuad();
26
30
  }
27
31
  get height() {
28
32
  return this._height;
29
33
  }
30
34
  set height(value) {
31
35
  this._height = value;
32
- this.mesh?.scale.set(this._width, this._height, 1);
36
+ this.syncQuad();
33
37
  }
34
38
  _color = 0xffffff;
35
39
  get color() {
@@ -46,16 +50,30 @@ export class Sprite extends Component {
46
50
  }
47
51
  set offsetX(value) {
48
52
  this._offsetX = value;
49
- if (this.mesh)
50
- this.mesh.position.x = value;
53
+ this.syncQuad();
51
54
  }
52
55
  get offsetY() {
53
56
  return this._offsetY;
54
57
  }
55
58
  set offsetY(value) {
56
59
  this._offsetY = value;
57
- if (this.mesh)
58
- this.mesh.position.y = value;
60
+ this.syncQuad();
61
+ }
62
+ _anchorX = 0.5;
63
+ _anchorY = 0.5;
64
+ get anchorX() {
65
+ return this._anchorX;
66
+ }
67
+ set anchorX(value) {
68
+ this._anchorX = clampAnchor(value);
69
+ this.syncQuad();
70
+ }
71
+ get anchorY() {
72
+ return this._anchorY;
73
+ }
74
+ set anchorY(value) {
75
+ this._anchorY = clampAnchor(value);
76
+ this.syncQuad();
59
77
  }
60
78
  /** Optional texture URL; with pixelArt on it filters in nearest. */
61
79
  texture;
@@ -71,6 +89,11 @@ export class Sprite extends Component {
71
89
  if (this.mesh)
72
90
  this.mesh.position.z = value * 0.01;
73
91
  }
92
+ /** Y-sort pass hook: overrides the layer-derived z for this frame. */
93
+ setSortZ(z) {
94
+ if (this.mesh)
95
+ this.mesh.position.z = z;
96
+ }
74
97
  _shape = 'rectangle';
75
98
  get shape() {
76
99
  return this._shape;
@@ -96,8 +119,8 @@ export class Sprite extends Component {
96
119
  material.color.set(0xffffff);
97
120
  }
98
121
  this.mesh = new THREE.Mesh(this.createGeometry(), material);
99
- this.mesh.scale.set(this._width, this._height, 1);
100
- this.mesh.position.set(this._offsetX, this._offsetY, this.layer * 0.01);
122
+ this.mesh.position.z = this.layer * 0.01;
123
+ this.syncQuad();
101
124
  this.entity.node.add(this.mesh);
102
125
  }
103
126
  onDestroy() {
@@ -105,6 +128,24 @@ export class Sprite extends Component {
105
128
  this.mesh?.geometry.dispose();
106
129
  this.mesh?.material.dispose();
107
130
  }
131
+ syncQuad() {
132
+ if (!this.mesh)
133
+ return;
134
+ const placement = spritePlacement({
135
+ width: this.width,
136
+ height: this.height,
137
+ offsetX: this.offsetX,
138
+ offsetY: this.offsetY,
139
+ anchorX: this.anchorX,
140
+ anchorY: this.anchorY,
141
+ flipX: false,
142
+ frameScaleX: 1,
143
+ frameScaleY: 1,
144
+ });
145
+ this.mesh.position.x = placement.x;
146
+ this.mesh.position.y = placement.y;
147
+ this.mesh.scale.set(placement.scaleX, placement.scaleY, 1);
148
+ }
108
149
  createGeometry() {
109
150
  return this._shape === 'circle'
110
151
  ? new THREE.CircleGeometry(0.5, 32)