@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,113 @@
1
+ import { Component } from '../component.js';
2
+ import { SOLID_SOURCE_SYMBOL, type SolidSource } from '../scene-solids.js';
3
+ import { type TilemapCell, type TilemapCellBounds } from '../tilemap-grid.js';
4
+ import { Solid } from './solid.js';
5
+ /** One authorable cell map rendered as a single merged geometry. */
6
+ export declare class Tilemap extends Component implements SolidSource {
7
+ static componentName: string;
8
+ static params: {
9
+ color: {
10
+ label: string;
11
+ };
12
+ cols: {
13
+ label: string;
14
+ min: number;
15
+ step: number;
16
+ };
17
+ rows: {
18
+ label: string;
19
+ min: number;
20
+ step: number;
21
+ };
22
+ mapWidth: {
23
+ label: string;
24
+ min: number;
25
+ step: number;
26
+ };
27
+ mapHeight: {
28
+ label: string;
29
+ min: number;
30
+ step: number;
31
+ };
32
+ cellSize: {
33
+ label: string;
34
+ min: number;
35
+ step: number;
36
+ };
37
+ layer: {
38
+ label: string;
39
+ min: number;
40
+ max: number;
41
+ step: number;
42
+ };
43
+ };
44
+ static transient: string[];
45
+ readonly [SOLID_SOURCE_SYMBOL] = true;
46
+ private _texture;
47
+ get texture(): string;
48
+ set texture(value: string);
49
+ private _color;
50
+ get color(): number;
51
+ set color(value: number);
52
+ private _cols;
53
+ get cols(): number;
54
+ set cols(value: number);
55
+ private _rows;
56
+ get rows(): number;
57
+ set rows(value: number);
58
+ private _gridOffsetX;
59
+ get gridOffsetX(): number;
60
+ set gridOffsetX(value: number);
61
+ private _gridOffsetY;
62
+ get gridOffsetY(): number;
63
+ set gridOffsetY(value: number);
64
+ private _spacingX;
65
+ get spacingX(): number;
66
+ set spacingX(value: number);
67
+ private _spacingY;
68
+ get spacingY(): number;
69
+ set spacingY(value: number);
70
+ private _cellWidth;
71
+ get cellWidth(): number;
72
+ set cellWidth(value: number);
73
+ private _cellHeight;
74
+ get cellHeight(): number;
75
+ set cellHeight(value: number);
76
+ private _pixelArt;
77
+ get pixelArt(): boolean;
78
+ set pixelArt(value: boolean);
79
+ private _mapWidth;
80
+ get mapWidth(): number;
81
+ set mapWidth(value: number);
82
+ private _mapHeight;
83
+ get mapHeight(): number;
84
+ set mapHeight(value: number);
85
+ private _cellSize;
86
+ get cellSize(): number;
87
+ set cellSize(value: number);
88
+ private _cells;
89
+ get cells(): number[];
90
+ set cells(value: number[]);
91
+ private _solidTiles;
92
+ get solidTiles(): number[];
93
+ set solidTiles(value: number[]);
94
+ private _layer;
95
+ get layer(): number;
96
+ set layer(value: number);
97
+ private mesh?;
98
+ private loadedTexture?;
99
+ private derivedSolids;
100
+ onReady(): void;
101
+ onProjectionChange(): void;
102
+ onDestroy(): void;
103
+ solids(): readonly Solid[];
104
+ cellIndex(column: number, row: number): number | null;
105
+ cellAt(logicalX: number, logicalY: number): TilemapCell | null;
106
+ cellBounds(column: number, row: number): TilemapCellBounds | null;
107
+ private gridSpec;
108
+ private rebuildMap;
109
+ private rebuildMaterial;
110
+ private makeMaterial;
111
+ private rebuildGeometry;
112
+ private rebuildSolids;
113
+ }
@@ -0,0 +1,316 @@
1
+ import * as THREE from 'three';
2
+ import { sheetCell } from '../animation/sheet.js';
3
+ import { Component } from '../component.js';
4
+ import { projectIsometric } from '../projection.js';
5
+ import { SOLID_SOURCE_SYMBOL } from '../scene-solids.js';
6
+ import { cellAt as gridCellAt, cellBounds as gridCellBounds, cellIndex as gridCellIndex, } from '../tilemap-grid.js';
7
+ import { Solid } from './solid.js';
8
+ const loader = new THREE.TextureLoader();
9
+ /** One authorable cell map rendered as a single merged geometry. */
10
+ export class Tilemap extends Component {
11
+ static componentName = 'Tilemap';
12
+ static params = {
13
+ color: { label: 'color' },
14
+ cols: { label: 'tileset columns', min: 1, step: 1 },
15
+ rows: { label: 'tileset rows', min: 1, step: 1 },
16
+ mapWidth: { label: 'map width', min: 1, step: 1 },
17
+ mapHeight: { label: 'map height', min: 1, step: 1 },
18
+ cellSize: { label: 'cell size', min: 0.05, step: 0.25 },
19
+ layer: { label: 'layer', min: -5, max: 5, step: 1 },
20
+ };
21
+ static transient = ['mesh', 'loadedTexture', 'derivedSolids'];
22
+ [SOLID_SOURCE_SYMBOL] = true;
23
+ _texture = '';
24
+ get texture() {
25
+ return this._texture;
26
+ }
27
+ set texture(value) {
28
+ this._texture = value;
29
+ this.rebuildMaterial();
30
+ }
31
+ _color = 0xffffff;
32
+ get color() {
33
+ return this._color;
34
+ }
35
+ set color(value) {
36
+ this._color = value;
37
+ this.rebuildMaterial();
38
+ }
39
+ _cols = 1;
40
+ get cols() {
41
+ return this._cols;
42
+ }
43
+ set cols(value) {
44
+ this._cols = value;
45
+ this.rebuildGeometry();
46
+ }
47
+ _rows = 1;
48
+ get rows() {
49
+ return this._rows;
50
+ }
51
+ set rows(value) {
52
+ this._rows = value;
53
+ this.rebuildGeometry();
54
+ }
55
+ _gridOffsetX = 0;
56
+ get gridOffsetX() {
57
+ return this._gridOffsetX;
58
+ }
59
+ set gridOffsetX(value) {
60
+ this._gridOffsetX = value;
61
+ this.rebuildGeometry();
62
+ }
63
+ _gridOffsetY = 0;
64
+ get gridOffsetY() {
65
+ return this._gridOffsetY;
66
+ }
67
+ set gridOffsetY(value) {
68
+ this._gridOffsetY = value;
69
+ this.rebuildGeometry();
70
+ }
71
+ _spacingX = 0;
72
+ get spacingX() {
73
+ return this._spacingX;
74
+ }
75
+ set spacingX(value) {
76
+ this._spacingX = value;
77
+ this.rebuildGeometry();
78
+ }
79
+ _spacingY = 0;
80
+ get spacingY() {
81
+ return this._spacingY;
82
+ }
83
+ set spacingY(value) {
84
+ this._spacingY = value;
85
+ this.rebuildGeometry();
86
+ }
87
+ _cellWidth = 0;
88
+ get cellWidth() {
89
+ return this._cellWidth;
90
+ }
91
+ set cellWidth(value) {
92
+ this._cellWidth = value;
93
+ this.rebuildGeometry();
94
+ }
95
+ _cellHeight = 0;
96
+ get cellHeight() {
97
+ return this._cellHeight;
98
+ }
99
+ set cellHeight(value) {
100
+ this._cellHeight = value;
101
+ this.rebuildGeometry();
102
+ }
103
+ _pixelArt = true;
104
+ get pixelArt() {
105
+ return this._pixelArt;
106
+ }
107
+ set pixelArt(value) {
108
+ this._pixelArt = value;
109
+ this.rebuildMaterial();
110
+ }
111
+ _mapWidth = 1;
112
+ get mapWidth() {
113
+ return this._mapWidth;
114
+ }
115
+ set mapWidth(value) {
116
+ this._mapWidth = value;
117
+ this.rebuildMap();
118
+ }
119
+ _mapHeight = 1;
120
+ get mapHeight() {
121
+ return this._mapHeight;
122
+ }
123
+ set mapHeight(value) {
124
+ this._mapHeight = value;
125
+ this.rebuildMap();
126
+ }
127
+ _cellSize = 1;
128
+ get cellSize() {
129
+ return this._cellSize;
130
+ }
131
+ set cellSize(value) {
132
+ this._cellSize = value;
133
+ this.rebuildMap();
134
+ }
135
+ _cells = [];
136
+ get cells() {
137
+ return this._cells;
138
+ }
139
+ set cells(value) {
140
+ this._cells = [...value];
141
+ this.rebuildMap();
142
+ }
143
+ _solidTiles = [];
144
+ get solidTiles() {
145
+ return this._solidTiles;
146
+ }
147
+ set solidTiles(value) {
148
+ this._solidTiles = [...value];
149
+ this.rebuildSolids();
150
+ }
151
+ _layer = 0;
152
+ get layer() {
153
+ return this._layer;
154
+ }
155
+ set layer(value) {
156
+ this._layer = value;
157
+ this.rebuildGeometry();
158
+ }
159
+ mesh;
160
+ loadedTexture;
161
+ derivedSolids = [];
162
+ onReady() {
163
+ this.mesh = new THREE.Mesh(new THREE.BufferGeometry(), this.makeMaterial());
164
+ this.entity.node.add(this.mesh);
165
+ this.rebuildMaterial();
166
+ this.rebuildMap();
167
+ }
168
+ onProjectionChange() {
169
+ this.rebuildGeometry();
170
+ }
171
+ onDestroy() {
172
+ this.mesh?.removeFromParent();
173
+ this.mesh?.geometry.dispose();
174
+ this.mesh?.material.dispose();
175
+ this.loadedTexture?.dispose();
176
+ this.loadedTexture = undefined;
177
+ this.derivedSolids = [];
178
+ }
179
+ solids() {
180
+ return this.derivedSolids;
181
+ }
182
+ cellIndex(column, row) {
183
+ return gridCellIndex(this.mapWidth, this.mapHeight, column, row);
184
+ }
185
+ cellAt(logicalX, logicalY) {
186
+ return gridCellAt(this.gridSpec(), logicalX, logicalY);
187
+ }
188
+ cellBounds(column, row) {
189
+ return gridCellBounds(this.gridSpec(), column, row);
190
+ }
191
+ gridSpec() {
192
+ return {
193
+ mapWidth: this.mapWidth,
194
+ mapHeight: this.mapHeight,
195
+ cellSize: this.cellSize,
196
+ originX: this.entity?.position.x ?? 0,
197
+ originY: this.entity?.position.y ?? 0,
198
+ };
199
+ }
200
+ rebuildMap() {
201
+ this.rebuildGeometry();
202
+ this.rebuildSolids();
203
+ }
204
+ rebuildMaterial() {
205
+ const mesh = this.mesh;
206
+ if (!mesh)
207
+ return;
208
+ this.loadedTexture?.dispose();
209
+ this.loadedTexture = undefined;
210
+ mesh.material.dispose();
211
+ mesh.material = this.makeMaterial();
212
+ if (!this.texture)
213
+ return;
214
+ const requested = this.texture;
215
+ const texture = loader.load(requested, () => {
216
+ if (this.loadedTexture === texture && this.texture === requested)
217
+ this.rebuildGeometry();
218
+ });
219
+ if (this.pixelArt) {
220
+ texture.magFilter = THREE.NearestFilter;
221
+ texture.minFilter = THREE.NearestFilter;
222
+ }
223
+ texture.colorSpace = THREE.SRGBColorSpace;
224
+ this.loadedTexture = texture;
225
+ mesh.material.map = texture;
226
+ mesh.material.color.set(0xffffff);
227
+ mesh.material.needsUpdate = true;
228
+ }
229
+ makeMaterial() {
230
+ return new THREE.MeshBasicMaterial({
231
+ color: this.color,
232
+ transparent: true,
233
+ });
234
+ }
235
+ rebuildGeometry() {
236
+ const mesh = this.mesh;
237
+ if (!mesh)
238
+ return;
239
+ const positions = [];
240
+ const uvs = [];
241
+ const indices = [];
242
+ const width = Math.max(0, Math.floor(this.mapWidth));
243
+ const height = Math.max(0, Math.floor(this.mapHeight));
244
+ const size = this.cellSize;
245
+ if (Number.isFinite(size) && size > 0) {
246
+ const image = this.loadedTexture?.image;
247
+ const imageWidth = image?.width && image.width > 0 ? image.width : Math.max(1, this.cols);
248
+ const imageHeight = image?.height && image.height > 0 ? image.height : Math.max(1, this.rows);
249
+ const frameParams = {
250
+ gridOffsetX: this.gridOffsetX,
251
+ gridOffsetY: this.gridOffsetY,
252
+ spacingX: this.spacingX,
253
+ spacingY: this.spacingY,
254
+ cellWidth: this.cellWidth,
255
+ cellHeight: this.cellHeight,
256
+ };
257
+ for (let index = 0; index < width * height; index++) {
258
+ const tile = this.cells[index] ?? -1;
259
+ if (!Number.isFinite(tile) || tile < 0)
260
+ continue;
261
+ const column = index % width;
262
+ const row = Math.floor(index / width);
263
+ const logicalCenterX = (column + 0.5) * size;
264
+ const logicalCenterY = (row + 0.5) * size;
265
+ const center = this.game.projection === 'isometric'
266
+ ? projectIsometric(logicalCenterX, logicalCenterY)
267
+ : { x: logicalCenterX, y: logicalCenterY };
268
+ const halfWidth = this.game.projection === 'isometric' ? size : size / 2;
269
+ const halfHeight = size / 2;
270
+ const z = this.layer * 0.01;
271
+ positions.push(center.x - halfWidth, center.y - halfHeight, z, center.x + halfWidth, center.y - halfHeight, z, center.x + halfWidth, center.y + halfHeight, z, center.x - halfWidth, center.y + halfHeight, z);
272
+ const frame = sheetCell(imageWidth, imageHeight, this.cols, this.rows, tile, frameParams);
273
+ const left = frame.x / imageWidth;
274
+ const right = (frame.x + frame.width) / imageWidth;
275
+ const bottom = 1 - (frame.y + frame.height) / imageHeight;
276
+ const top = 1 - frame.y / imageHeight;
277
+ uvs.push(left, bottom, right, bottom, right, top, left, top);
278
+ const vertex = positions.length / 3 - 4;
279
+ indices.push(vertex, vertex + 1, vertex + 2, vertex, vertex + 2, vertex + 3);
280
+ }
281
+ }
282
+ const geometry = new THREE.BufferGeometry();
283
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
284
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
285
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
286
+ mesh.geometry.dispose();
287
+ mesh.geometry = geometry;
288
+ }
289
+ rebuildSolids() {
290
+ if (!this.entity || !this.game)
291
+ return;
292
+ const solids = new Set(this.solidTiles);
293
+ const width = Math.max(0, Math.floor(this.mapWidth));
294
+ const height = Math.max(0, Math.floor(this.mapHeight));
295
+ const next = [];
296
+ for (let index = 0; index < width * height; index++) {
297
+ const tile = this.cells[index] ?? -1;
298
+ if (!solids.has(tile))
299
+ continue;
300
+ const column = index % width;
301
+ const row = Math.floor(index / width);
302
+ const bounds = this.cellBounds(column, row);
303
+ if (!bounds)
304
+ continue;
305
+ const solid = new Solid();
306
+ solid.entity = this.entity;
307
+ solid.game = this.game;
308
+ solid.width = this.cellSize;
309
+ solid.height = this.cellSize;
310
+ solid.offsetX = bounds.centerX - this.entity.position.x;
311
+ solid.offsetY = bounds.centerY - this.entity.position.y;
312
+ next.push(solid);
313
+ }
314
+ this.derivedSolids = next;
315
+ }
316
+ }
package/dist/entity.d.ts CHANGED
@@ -10,10 +10,13 @@ export declare class Entity {
10
10
  readonly name: string;
11
11
  readonly node: THREE.Group<THREE.Object3DEventMap>;
12
12
  readonly components: Component[];
13
+ private logicalPosition;
13
14
  private destroyed;
14
15
  get alive(): boolean;
15
16
  constructor(game: Game, name: string);
16
17
  get position(): THREE.Vector3;
18
+ /** Keeps identity scenes zero-copy while projected scenes own a logical transform. */
19
+ setProjected(projected: boolean): void;
17
20
  get scale(): THREE.Vector3;
18
21
  add<T extends Component>(Class: ComponentClass<T>, props?: Partial<T>): T;
19
22
  get<T extends Component>(Class: ComponentClass<T>): T | undefined;
package/dist/entity.js CHANGED
@@ -8,6 +8,7 @@ export class Entity {
8
8
  name;
9
9
  node = new THREE.Group();
10
10
  components = [];
11
+ logicalPosition = null;
11
12
  destroyed = false;
12
13
  get alive() {
13
14
  return !this.destroyed;
@@ -17,7 +18,18 @@ export class Entity {
17
18
  this.name = name;
18
19
  }
19
20
  get position() {
20
- return this.node.position;
21
+ return this.logicalPosition ?? this.node.position;
22
+ }
23
+ /** Keeps identity scenes zero-copy while projected scenes own a logical transform. */
24
+ setProjected(projected) {
25
+ if (projected === (this.logicalPosition !== null))
26
+ return;
27
+ if (projected) {
28
+ this.logicalPosition = this.node.position.clone();
29
+ return;
30
+ }
31
+ this.node.position.copy(this.logicalPosition);
32
+ this.logicalPosition = null;
21
33
  }
22
34
  get scale() {
23
35
  return this.node.scale;
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,8 @@ export declare class Game {
61
61
  private readonly resolution;
62
62
  private viewHeight;
63
63
  private sceneCamera;
64
+ private renderSort;
65
+ private sceneProjection;
64
66
  private lastTime;
65
67
  private runtimeBridge;
66
68
  constructor(options: GameOptions);
@@ -76,6 +78,11 @@ export declare class Game {
76
78
  applyParamOverrides(entity: Entity, component: Component): void;
77
79
  /** Registers a function that runs once per frame. Returns the unsubscribe. */
78
80
  onUpdate(fn: UpdateFn): () => void;
81
+ /**
82
+ * Adopts a scene's render block. Called by loadScene; without a block the
83
+ * draw order stays layer-banded with spawn-order ties.
84
+ */
85
+ setSceneRender(json?: SceneRenderJson): void;
79
86
  /**
80
87
  * Adopts a scene's camera block: jumps to its framing and, while
81
88
  * simulating, follows/clamps per its settings. Called by loadScene.
@@ -85,6 +92,8 @@ export declare class Game {
85
92
  stop(): void;
86
93
  /** Internal: called by Entity.destroy(). */
87
94
  removeEntity(entity: Entity): void;
95
+ /** The scene's render projection; null keeps logical and render space identical. */
96
+ get projection(): 'isometric' | null;
88
97
  /** Visible world height (2D camera zoom). */
89
98
  get view(): number;
90
99
  setViewHeight(value: number): void;
@@ -94,9 +103,12 @@ export declare class Game {
94
103
  private tick;
95
104
  private runFrame;
96
105
  private unregisterRuntimeBridge;
106
+ /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
107
+ private applyYSort;
97
108
  private renderSurface;
98
109
  private componentUpdateSchedule;
99
110
  private updateSceneCamera;
111
+ private renderPoint;
100
112
  private dispatchCollisions;
101
113
  private resize;
102
114
  }
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,8 @@ 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 { projectIsometric } from './projection.js';
12
+ import { isYSortParticipant, ySortZ } from './render-sort.js';
11
13
  import { registryEntry, spawnFromJson } from './scene.js';
12
14
  import { Stats } from './stats.js';
13
15
  import { GameUi } from './ui.js';
@@ -40,6 +42,8 @@ export class Game {
40
42
  resolution;
41
43
  viewHeight;
42
44
  sceneCamera = null;
45
+ renderSort = null;
46
+ sceneProjection = null;
43
47
  lastTime = 0;
44
48
  runtimeBridge = null;
45
49
  constructor(options) {
@@ -61,6 +65,7 @@ export class Game {
61
65
  /** Creates a live entity in the scene. */
62
66
  spawn(name) {
63
67
  const entity = new Entity(this, name);
68
+ entity.setProjected(this.sceneProjection === 'isometric');
64
69
  this.entities.push(entity);
65
70
  this.scene.add(entity.node);
66
71
  return entity;
@@ -107,6 +112,22 @@ export class Game {
107
112
  this.updateFns.add(fn);
108
113
  return () => this.updateFns.delete(fn);
109
114
  }
115
+ /**
116
+ * Adopts a scene's render block. Called by loadScene; without a block the
117
+ * draw order stays layer-banded with spawn-order ties.
118
+ */
119
+ setSceneRender(json) {
120
+ this.renderSort = json?.sort === 'y' ? 'y' : null;
121
+ const projection = json?.projection === 'isometric' ? 'isometric' : null;
122
+ if (projection === this.sceneProjection)
123
+ return;
124
+ this.sceneProjection = projection;
125
+ for (const entity of this.entities) {
126
+ entity.setProjected(projection === 'isometric');
127
+ for (const component of entity.components)
128
+ component.onProjectionChange?.(projection);
129
+ }
130
+ }
110
131
  /**
111
132
  * Adopts a scene's camera block: jumps to its framing and, while
112
133
  * simulating, follows/clamps per its settings. Called by loadScene.
@@ -121,11 +142,11 @@ export class Game {
121
142
  // With a follow target the declared position is moot: start centered on
122
143
  // the target so play begins framed like the editor shows it.
123
144
  const followed = this.sceneCamera.follow ? this.find(this.sceneCamera.follow) : undefined;
124
- const [x, y] = followed
125
- ? [followed.position.x, followed.position.y]
126
- : this.sceneCamera.position;
127
- this.camera.position.x = x;
128
- this.camera.position.y = y;
145
+ const center = followed
146
+ ? this.renderPoint(followed.position.x, followed.position.y)
147
+ : { x: this.sceneCamera.position[0], y: this.sceneCamera.position[1] };
148
+ this.camera.position.x = center.x;
149
+ this.camera.position.y = center.y;
129
150
  this.setViewHeight(this.sceneCamera.zoom);
130
151
  }
131
152
  start() {
@@ -159,6 +180,10 @@ export class Game {
159
180
  if (i !== -1)
160
181
  this.entities.splice(i, 1);
161
182
  }
183
+ /** The scene's render projection; null keeps logical and render space identical. */
184
+ get projection() {
185
+ return this.sceneProjection;
186
+ }
162
187
  /** Visible world height (2D camera zoom). */
163
188
  get view() {
164
189
  return this.viewHeight;
@@ -216,7 +241,32 @@ export class Game {
216
241
  this.runtimeBridge?.unregister();
217
242
  this.runtimeBridge = null;
218
243
  };
244
+ /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
245
+ applyYSort() {
246
+ const participants = [];
247
+ const entries = [];
248
+ for (const entity of this.entities) {
249
+ for (const component of entity.components) {
250
+ if (isYSortParticipant(component)) {
251
+ participants.push(component);
252
+ entries.push({ layer: component.layer, y: entity.node.position.y });
253
+ }
254
+ }
255
+ }
256
+ const z = ySortZ(entries);
257
+ for (const [index, participant] of participants.entries())
258
+ participant.setSortZ(z[index]);
259
+ }
219
260
  renderSurface() {
261
+ if (this.sceneProjection === 'isometric') {
262
+ for (const entity of this.entities) {
263
+ const projected = projectIsometric(entity.position.x, entity.position.y);
264
+ entity.node.position.x = projected.x;
265
+ entity.node.position.y = projected.y;
266
+ }
267
+ }
268
+ if (this.renderSort === 'y')
269
+ this.applyYSort();
220
270
  this.ui.setActive(this.simulate);
221
271
  if (this.resolution) {
222
272
  // Letterbox bars: clear the whole canvas, then render inside the scissor.
@@ -262,19 +312,30 @@ export class Game {
262
312
  if (!cam)
263
313
  return;
264
314
  const followed = cam.follow ? this.find(cam.follow) : undefined;
265
- const mover = followed?.components.find((c) => typeof c.vx === 'number');
315
+ const provider = followed?.components.find((c) => isCameraVelocityProvider(c));
316
+ const velocity = provider?.getCameraVelocity();
317
+ const target = followed
318
+ ? this.renderPoint(followed.position.x, followed.position.y)
319
+ : null;
320
+ const renderVelocity = velocity
321
+ ? this.renderPoint(velocity.vx, velocity.vy)
322
+ : { x: 0, y: 0 };
266
323
  const next = stepSceneCamera(cam, {
267
324
  x: this.camera.position.x,
268
325
  y: this.camera.position.y,
269
326
  halfW: (this.camera.right - this.camera.left) / 2,
270
327
  halfH: this.viewHeight / 2,
271
- target: followed ? { x: followed.position.x, y: followed.position.y } : null,
272
- vx: mover?.vx ?? 0,
328
+ target,
329
+ vx: renderVelocity.x,
330
+ vy: renderVelocity.y,
273
331
  dt,
274
332
  });
275
333
  this.camera.position.x = next.x;
276
334
  this.camera.position.y = next.y;
277
335
  }
336
+ renderPoint(x, y) {
337
+ return this.sceneProjection === 'isometric' ? projectIsometric(x, y) : { x, y };
338
+ }
278
339
  dispatchCollisions() {
279
340
  const boxed = this.entities.filter((e) => e.has(Hitbox));
280
341
  for (let i = 0; i < boxed.length; i++) {