@waica/engine 0.19.0 → 0.19.2

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.
@@ -5,6 +5,7 @@ import { projectIsometric } from '../projection.js';
5
5
  import { SOLID_SOURCE_SYMBOL } from '../scene-solids.js';
6
6
  import { cellAt as gridCellAt, cellBounds as gridCellBounds, cellIndex as gridCellIndex, } from '../tilemap-grid.js';
7
7
  import { Solid } from './solid.js';
8
+ import { reportRejection } from '../report-rejection.js';
8
9
  /** One authorable cell map rendered as a single merged geometry. */
9
10
  export class Tilemap extends Component {
10
11
  static componentName = 'Tilemap';
@@ -218,14 +219,14 @@ export class Tilemap extends Component {
218
219
  // happens if the texture was replaced or the component destroyed
219
220
  // meanwhile.
220
221
  const { texture, settled } = this.game.assets.texture(requested);
221
- void settled.then((outcome) => {
222
+ reportRejection(settled.then((outcome) => {
222
223
  if (this.loadedTexture !== texture || this.texture !== requested)
223
224
  return;
224
225
  if (outcome === 'loaded')
225
226
  this.rebuildGeometry();
226
227
  else
227
228
  this.dropFailedTexture();
228
- });
229
+ }), 'tilemap texture settle');
229
230
  if (this.pixelArt) {
230
231
  texture.magFilter = THREE.NearestFilter;
231
232
  texture.minFilter = THREE.NearestFilter;
@@ -260,56 +261,66 @@ export class Tilemap extends Component {
260
261
  const mesh = this.mesh;
261
262
  if (!mesh)
262
263
  return;
263
- const positions = [];
264
- const uvs = [];
265
- const indices = [];
264
+ const buffers = { positions: [], uvs: [], indices: [] };
266
265
  const width = Math.max(0, Math.floor(this.mapWidth));
267
266
  const height = Math.max(0, Math.floor(this.mapHeight));
268
267
  const size = this.cellSize;
269
268
  if (Number.isFinite(size) && size > 0) {
270
- const image = this.loadedTexture?.image;
271
- const imageWidth = image?.width && image.width > 0 ? image.width : Math.max(1, this.cols);
272
- const imageHeight = image?.height && image.height > 0 ? image.height : Math.max(1, this.rows);
273
- const frameParams = {
274
- gridOffsetX: this.gridOffsetX,
275
- gridOffsetY: this.gridOffsetY,
276
- spacingX: this.spacingX,
277
- spacingY: this.spacingY,
278
- cellWidth: this.cellWidth,
279
- cellHeight: this.cellHeight,
280
- };
269
+ const sheet = this.sheetSize();
281
270
  for (let index = 0; index < width * height; index++) {
282
271
  const tile = this.cells[index] ?? -1;
283
272
  if (!Number.isFinite(tile) || tile < 0)
284
273
  continue;
285
- const column = index % width;
286
- const row = Math.floor(index / width);
287
- const logicalCenterX = (column + 0.5) * size;
288
- const logicalCenterY = (row + 0.5) * size;
289
- const center = this.game.projection === 'isometric'
290
- ? projectIsometric(logicalCenterX, logicalCenterY)
291
- : { x: logicalCenterX, y: logicalCenterY };
292
- const halfWidth = this.game.projection === 'isometric' ? size : size / 2;
293
- const halfHeight = size / 2;
294
- const z = this.layer * 0.01;
295
- 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);
296
- const frame = sheetCell(imageWidth, imageHeight, this.cols, this.rows, tile, frameParams);
297
- const left = frame.x / imageWidth;
298
- const right = (frame.x + frame.width) / imageWidth;
299
- const bottom = 1 - (frame.y + frame.height) / imageHeight;
300
- const top = 1 - frame.y / imageHeight;
301
- uvs.push(left, bottom, right, bottom, right, top, left, top);
302
- const vertex = positions.length / 3 - 4;
303
- indices.push(vertex, vertex + 1, vertex + 2, vertex, vertex + 2, vertex + 3);
274
+ this.pushTileQuad(buffers.positions, index % width, Math.floor(index / width));
275
+ buffers.uvs.push(...this.tileUvs(tile, sheet));
276
+ const vertex = buffers.positions.length / 3 - 4;
277
+ buffers.indices.push(vertex, vertex + 1, vertex + 2, vertex, vertex + 2, vertex + 3);
304
278
  }
305
279
  }
306
280
  const geometry = new THREE.BufferGeometry();
307
- geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
308
- geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
309
- geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
281
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(buffers.positions, 3));
282
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(buffers.uvs, 2));
283
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(buffers.indices), 1));
310
284
  mesh.geometry.dispose();
311
285
  mesh.geometry = geometry;
312
286
  }
287
+ /** The loaded sheet image's size, or one pixel per sheet cell until it arrives. */
288
+ sheetSize() {
289
+ const image = this.loadedTexture?.image;
290
+ return {
291
+ width: image?.width && image.width > 0 ? image.width : Math.max(1, this.cols),
292
+ height: image?.height && image.height > 0 ? image.height : Math.max(1, this.rows),
293
+ };
294
+ }
295
+ /** The four corners of a cell's quad, projected for the Game, on this layer's depth. */
296
+ pushTileQuad(positions, column, row) {
297
+ const size = this.cellSize;
298
+ const logicalCenterX = (column + 0.5) * size;
299
+ const logicalCenterY = (row + 0.5) * size;
300
+ const center = this.game.projection === 'isometric'
301
+ ? projectIsometric(logicalCenterX, logicalCenterY)
302
+ : { x: logicalCenterX, y: logicalCenterY };
303
+ const halfWidth = this.game.projection === 'isometric' ? size : size / 2;
304
+ const halfHeight = size / 2;
305
+ const z = this.layer * 0.01;
306
+ 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);
307
+ }
308
+ /** The UVs of a tile's sheet cell, in the quad's corner order. */
309
+ tileUvs(tile, sheet) {
310
+ const frame = sheetCell(sheet.width, sheet.height, this.cols, this.rows, tile, {
311
+ gridOffsetX: this.gridOffsetX,
312
+ gridOffsetY: this.gridOffsetY,
313
+ spacingX: this.spacingX,
314
+ spacingY: this.spacingY,
315
+ cellWidth: this.cellWidth,
316
+ cellHeight: this.cellHeight,
317
+ });
318
+ const left = frame.x / sheet.width;
319
+ const right = (frame.x + frame.width) / sheet.width;
320
+ const bottom = 1 - (frame.y + frame.height) / sheet.height;
321
+ const top = 1 - frame.y / sheet.height;
322
+ return [left, bottom, right, bottom, right, top, left, top];
323
+ }
313
324
  rebuildSolids() {
314
325
  if (!this.entity || !this.game)
315
326
  return;
package/dist/entity.js CHANGED
@@ -22,13 +22,15 @@ export class Entity {
22
22
  }
23
23
  /** Keeps identity scenes zero-copy while projected scenes own a logical transform. */
24
24
  setProjected(projected) {
25
- if (projected === (this.logicalPosition !== null))
26
- return;
25
+ const logical = this.logicalPosition;
27
26
  if (projected) {
28
- this.logicalPosition = this.node.position.clone();
27
+ if (logical === null)
28
+ this.logicalPosition = this.node.position.clone();
29
29
  return;
30
30
  }
31
- this.node.position.copy(this.logicalPosition);
31
+ if (logical === null)
32
+ return;
33
+ this.node.position.copy(logical);
32
34
  this.logicalPosition = null;
33
35
  }
34
36
  get scale() {
package/dist/game.js CHANGED
@@ -5,7 +5,7 @@ import { AudioSubsystem } from './audio/audio-subsystem.js';
5
5
  import { dispatchCollisions as dispatchHitboxCollisions } from './collision-dispatch.js';
6
6
  import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
7
7
  import { advanceCameraEffects, CameraEffects, layoutCameraEffects } from './camera-effects.js';
8
- import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
8
+ import { implementsOnUpdate, resolveComponentUpdateSchedule } from './component-update-schedule.js';
9
9
  import { Entity } from './entity.js';
10
10
  import { Emitter } from './events.js';
11
11
  import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
@@ -20,6 +20,16 @@ import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
20
20
  import { createSpatialQuery } from './spatial-query.js';
21
21
  import { Stats } from './stats.js';
22
22
  import { anchoredPiecesOf, GameUi } from './ui.js';
23
+ import { componentClassOf } from './component-registry.js';
24
+ /** Maps a resolved update order back to the entity's components; the order only names components it was built from. */
25
+ function componentsInOrder(order, byName) {
26
+ return order.map((name) => {
27
+ const component = byName.get(name);
28
+ if (!component)
29
+ throw new Error(`Component update schedule names "${name}", which is not on the entity`);
30
+ return component;
31
+ });
32
+ }
23
33
  /**
24
34
  * Engine core: loop, unified 2D/3D three scene, orthographic camera,
25
35
  * entities with components, and input. See DESIGN.md.
@@ -265,7 +275,7 @@ export class Game {
265
275
  }
266
276
  /** Applies persisted overrides to a freshly added component. */
267
277
  applyParamOverrides(entity, component) {
268
- const Class = component.constructor;
278
+ const Class = componentClassOf(component);
269
279
  const override = this.paramOverrides[entity.name]?.[Class.componentName];
270
280
  if (override)
271
281
  Object.assign(component, override);
@@ -535,8 +545,12 @@ export class Game {
535
545
  }
536
546
  }
537
547
  const z = ySortZ(entries);
538
- for (const [index, participant] of participants.entries())
539
- participant.setSortZ(z[index]);
548
+ for (const [index, participant] of participants.entries()) {
549
+ const sortZ = z[index];
550
+ if (sortZ === undefined)
551
+ throw new Error(`ySortZ returned no z for y-sort participant ${index}`);
552
+ participant.setSortZ(sortZ);
553
+ }
540
554
  }
541
555
  renderSurface() {
542
556
  if (this.sceneProjection === 'isometric') {
@@ -578,11 +592,11 @@ export class Game {
578
592
  const names = [];
579
593
  const signatureParts = [];
580
594
  for (const component of components) {
581
- const Class = component.constructor;
595
+ const Class = componentClassOf(component);
582
596
  const name = Class.componentName;
583
597
  names.push(name);
584
598
  byName.set(name, component);
585
- signatureParts.push(`${name}:${typeof Class.prototype.onUpdate === 'function' ? 'updates' : 'passive'}:` +
599
+ signatureParts.push(`${name}:${implementsOnUpdate(Class) ? 'updates' : 'passive'}:` +
586
600
  [...new Set(Class.updateAfter ?? [])].sort().join(','));
587
601
  }
588
602
  const signature = signatureParts.sort().join('|');
@@ -592,13 +606,13 @@ export class Game {
592
606
  // when nothing about this entity's components changed since last time.
593
607
  const cached = this.updateScheduleCache.get(entity);
594
608
  if (cached && cached.signature === signature) {
595
- return cached.order.map((name) => byName.get(name));
609
+ return componentsInOrder(cached.order, byName);
596
610
  }
597
611
  const registry = {
598
612
  ...(this.registry?.components ?? {}),
599
613
  };
600
614
  for (const component of components) {
601
- const Class = component.constructor;
615
+ const Class = componentClassOf(component);
602
616
  registry[Class.componentName] = Class;
603
617
  }
604
618
  const result = resolveComponentUpdateSchedule(names, registry);
@@ -613,7 +627,7 @@ export class Game {
613
627
  }
614
628
  this.invalidUpdateCompositions.delete(entity);
615
629
  this.updateScheduleCache.set(entity, { signature, order: result.order });
616
- return result.order.map((name) => byName.get(name));
630
+ return componentsInOrder(result.order, byName);
617
631
  }
618
632
  updateSceneCamera(dt) {
619
633
  const cam = this.sceneCamera;
package/dist/pointer.js CHANGED
@@ -148,15 +148,32 @@ export class Pointer {
148
148
  }
149
149
  }
150
150
  }
151
- if (hits.length === 0)
152
- return null;
153
- if (hits.length === 1)
154
- return hits[0].entity;
155
- const z = ySortZ(hits.map((h) => ({ layer: h.layer, y: h.y })));
156
- let bestIndex = 0;
157
- for (let i = 1; i < hits.length; i += 1)
158
- if (z[i] > z[bestIndex])
159
- bestIndex = i;
160
- return hits[bestIndex].entity;
151
+ return frontMostHit(hits);
161
152
  }
162
153
  }
154
+ /** The front-most hit under y-sort; on an exact z tie the earlier hit wins. */
155
+ function frontMostHit(hits) {
156
+ const [first] = hits;
157
+ if (!first)
158
+ return null;
159
+ if (hits.length === 1)
160
+ return first.entity;
161
+ const z = ySortZ(hits.map((h) => ({ layer: h.layer, y: h.y })));
162
+ let best = first;
163
+ let bestZ = zOfHit(z, 0);
164
+ for (const [index, hit] of hits.entries()) {
165
+ const hitZ = zOfHit(z, index);
166
+ if (hitZ > bestZ) {
167
+ best = hit;
168
+ bestZ = hitZ;
169
+ }
170
+ }
171
+ return best.entity;
172
+ }
173
+ /** `ySortZ` returns one z per entry, so every hit index has one. */
174
+ function zOfHit(z, index) {
175
+ const value = z[index];
176
+ if (value === undefined)
177
+ throw new Error(`ySortZ returned no z for pointer hit ${index}`);
178
+ return value;
179
+ }
@@ -4,6 +4,13 @@ export function isYSortParticipant(value) {
4
4
  typeof value.layer === 'number' &&
5
5
  typeof value.setSortZ === 'function');
6
6
  }
7
+ /** The Y of the entry at `index`, which `ySortZ` only reads for indices it grouped from `entries`. */
8
+ function entryY(entries, index) {
9
+ const entry = entries[index];
10
+ if (!entry)
11
+ throw new Error(`ySortZ has no entry at index ${index}`);
12
+ return entry.y;
13
+ }
7
14
  /**
8
15
  * Z per entry under y-sort. Each layer keeps its 0.01 band; within a band,
9
16
  * lower Y gets a higher z (renders in front), and exact Y ties keep input
@@ -22,14 +29,13 @@ export function ySortZ(entries) {
22
29
  else
23
30
  byLayer.set(entry.layer, [index]);
24
31
  }
25
- const layers = [...byLayer.keys()].sort((a, b) => a - b);
32
+ const layers = [...byLayer].sort(([a], [b]) => a - b);
26
33
  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];
34
+ for (const [i, [layer, indices]] of layers.entries()) {
35
+ const next = layers[i + 1]?.[0];
30
36
  const width = next === undefined ? 0.01 : Math.min(0.01, (next - layer) * 0.01);
31
37
  // 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);
38
+ const ordered = [...indices].sort((a, b) => entryY(entries, b) - entryY(entries, a));
33
39
  const step = width / (ordered.length + 1);
34
40
  for (const [rank, index] of ordered.entries()) {
35
41
  z[index] = layer * 0.01 + (rank + 1) * step;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Observes a task the engine starts without awaiting (a texture settling, an
3
+ * AudioContext state change): its rejection is logged once as a `[waica]`
4
+ * error instead of surfacing as an unhandled rejection. Internal; not part of
5
+ * the package entry.
6
+ */
7
+ export declare function reportRejection(task: Promise<unknown>, context: string): void;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Observes a task the engine starts without awaiting (a texture settling, an
3
+ * AudioContext state change): its rejection is logged once as a `[waica]`
4
+ * error instead of surfacing as an unhandled rejection. Internal; not part of
5
+ * the package entry.
6
+ */
7
+ export function reportRejection(task, context) {
8
+ task.catch((error) => {
9
+ const message = error instanceof Error ? error.message : String(error);
10
+ console.error(`[waica] ${context} failed: ${message}`);
11
+ });
12
+ }
@@ -106,6 +106,10 @@ export declare class EngineRuntimeBridge implements RuntimeBridge {
106
106
  metadata(): RuntimeMetadata;
107
107
  inspect(filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
108
108
  control(request: RuntimeControlRequest): RuntimeControlResult;
109
+ private injectAction;
110
+ /** Advances whole Simulation Steps while paused. */
111
+ private step;
112
+ private loadScene;
109
113
  /** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
110
114
  private advance;
111
115
  unregister(): void;
@@ -96,29 +96,11 @@ export class EngineRuntimeBridge {
96
96
  case 'press':
97
97
  case 'hold':
98
98
  case 'release':
99
- if (!this.host.injectAction(request.action, request.operation)) {
100
- const available = this.host.availableActions();
101
- throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown action "${request.action}". Available actions: ${available.join(', ') || '(none)'}.`, available);
102
- }
99
+ this.injectAction(request);
103
100
  break;
104
- case 'step': {
105
- if (this.mode !== 'paused') {
106
- throw new RuntimeBridgeOperationError('runtime-invalid-state', 'step is only available while the Runtime Bridge is paused.');
107
- }
108
- // A caller-chosen dt is rejected outright, never ignored: a pre-ADR-0014
109
- // client that still sends one would otherwise believe it stepped by it.
110
- if ('dt' in request) {
111
- throw new RuntimeBridgeOperationError('runtime-operation-failed', 'step takes no dt: it advances whole Simulation Steps of 1/60 s each; pass frames (1 through 600) instead.');
112
- }
113
- const frames = request.frames ?? 1;
114
- if (!Number.isInteger(frames) || frames < 1 || frames > 600) {
115
- throw new RuntimeBridgeOperationError('runtime-operation-failed', 'frames must be an integer from 1 through 600.');
116
- }
117
- for (let index = 0; index < frames; index += 1) {
118
- this.host.step(() => this.advance());
119
- }
101
+ case 'step':
102
+ this.step(request);
120
103
  break;
121
- }
122
104
  case 'click': {
123
105
  if (!Number.isFinite(request.x) || !Number.isFinite(request.y)) {
124
106
  throw new RuntimeBridgeOperationError('runtime-operation-failed', 'x and y must be finite numbers.');
@@ -126,13 +108,9 @@ export class EngineRuntimeBridge {
126
108
  this.host.click(request.x, request.y);
127
109
  break;
128
110
  }
129
- case 'scene': {
130
- if (!this.host.loadScene(request.scene)) {
131
- const available = this.host.availableScenes();
132
- throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown scene "${request.scene}". Available scenes: ${available.join(', ') || '(none)'}.`, undefined, available);
133
- }
111
+ case 'scene':
112
+ this.loadScene(request.scene);
134
113
  break;
135
- }
136
114
  default: {
137
115
  const unsupported = request;
138
116
  throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unsupported runtime control operation "${unsupported.operation}".`);
@@ -140,6 +118,36 @@ export class EngineRuntimeBridge {
140
118
  }
141
119
  return { ...this.metadata(), heldActions: this.host.heldActions() };
142
120
  }
121
+ injectAction(request) {
122
+ if (this.host.injectAction(request.action, request.operation))
123
+ return;
124
+ const available = this.host.availableActions();
125
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown action "${request.action}". Available actions: ${available.join(', ') || '(none)'}.`, available);
126
+ }
127
+ /** Advances whole Simulation Steps while paused. */
128
+ step(request) {
129
+ if (this.mode !== 'paused') {
130
+ throw new RuntimeBridgeOperationError('runtime-invalid-state', 'step is only available while the Runtime Bridge is paused.');
131
+ }
132
+ // A caller-chosen dt is rejected outright, never ignored: a pre-ADR-0014
133
+ // client that still sends one would otherwise believe it stepped by it.
134
+ if ('dt' in request) {
135
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', 'step takes no dt: it advances whole Simulation Steps of 1/60 s each; pass frames (1 through 600) instead.');
136
+ }
137
+ const frames = request.frames ?? 1;
138
+ if (!Number.isInteger(frames) || frames < 1 || frames > 600) {
139
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', 'frames must be an integer from 1 through 600.');
140
+ }
141
+ for (let index = 0; index < frames; index += 1) {
142
+ this.host.step(() => this.advance());
143
+ }
144
+ }
145
+ loadScene(scene) {
146
+ if (this.host.loadScene(scene))
147
+ return;
148
+ const available = this.host.availableScenes();
149
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown scene "${scene}". Available scenes: ${available.join(', ') || '(none)'}.`, undefined, available);
150
+ }
143
151
  /** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
144
152
  advance() {
145
153
  this.frame += 1;