@waica/engine 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/game.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { TextureBackend } from './assets/texture-backend.js';
4
4
  import type { AudioBackend } from './audio/backend.js';
5
5
  import { AudioSubsystem } from './audio/audio-subsystem.js';
6
6
  import { type SceneCameraJson } from './camera.js';
7
+ import { CameraEffects } from './camera-effects.js';
7
8
  import type { Component } from './component.js';
8
9
  import { Entity } from './entity.js';
9
10
  import { Emitter } from './events.js';
@@ -83,6 +84,11 @@ export declare class Game {
83
84
  * (ADR 0011): `unloadScene()` never touches it. See ADR 0019.
84
85
  */
85
86
  readonly assets: AssetLoader;
87
+ /**
88
+ * Shake, Fade and Flash, beside `camera` and never written into it
89
+ * (ADR 0020). A Fade survives `unloadScene()`; a Shake or Flash does not.
90
+ */
91
+ readonly cameraEffects: CameraEffects;
86
92
  /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
87
93
  readonly time: GameTime;
88
94
  /** Registry retained by loadScene for runtime prefab spawning. */
package/dist/game.js CHANGED
@@ -4,7 +4,8 @@ import { AssetLoader } from './assets/asset-loader.js';
4
4
  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
- import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
7
+ import { advanceCameraEffects, CameraEffects, layoutCameraEffects } from './camera-effects.js';
8
+ import { implementsOnUpdate, resolveComponentUpdateSchedule } from './component-update-schedule.js';
8
9
  import { Entity } from './entity.js';
9
10
  import { Emitter } from './events.js';
10
11
  import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
@@ -19,6 +20,16 @@ import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
19
20
  import { createSpatialQuery } from './spatial-query.js';
20
21
  import { Stats } from './stats.js';
21
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
+ }
22
33
  /**
23
34
  * Engine core: loop, unified 2D/3D three scene, orthographic camera,
24
35
  * entities with components, and input. See DESIGN.md.
@@ -42,6 +53,11 @@ export class Game {
42
53
  * (ADR 0011): `unloadScene()` never touches it. See ADR 0019.
43
54
  */
44
55
  assets;
56
+ /**
57
+ * Shake, Fade and Flash, beside `camera` and never written into it
58
+ * (ADR 0020). A Fade survives `unloadScene()`; a Shake or Flash does not.
59
+ */
60
+ cameraEffects;
45
61
  /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
46
62
  time = new GameTime();
47
63
  /** Registry retained by loadScene for runtime prefab spawning. */
@@ -116,6 +132,11 @@ export class Game {
116
132
  // catalog is registered at call time, which outlives every scene.
117
133
  resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
118
134
  });
135
+ this.cameraEffects = new CameraEffects({
136
+ host: () => canvas.parentElement ?? document.body,
137
+ // One screen pixel in world units: the shake snaps to it (never the base).
138
+ pixel: () => (this.resolution ? this.viewHeight / this.resolution.height : null),
139
+ });
119
140
  this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
120
141
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
121
142
  this.scene.background = new THREE.Color(background);
@@ -173,6 +194,7 @@ export class Game {
173
194
  // Scene-scoped timers/tweens die here too (ADR 0017): before entities are
174
195
  // destroyed below, so an owner's own destroy() cancellation is a no-op.
175
196
  this.time.cancelSceneScoped();
197
+ this.cameraEffects.unloadScene();
176
198
  // An explicit unload means "no scene": a swap queued earlier this frame
177
199
  // would otherwise flush next frame and resurrect one.
178
200
  this.pendingSceneLoad = null;
@@ -253,7 +275,7 @@ export class Game {
253
275
  }
254
276
  /** Applies persisted overrides to a freshly added component. */
255
277
  applyParamOverrides(entity, component) {
256
- const Class = component.constructor;
278
+ const Class = componentClassOf(component);
257
279
  const override = this.paramOverrides[entity.name]?.[Class.componentName];
258
280
  if (override)
259
281
  Object.assign(component, override);
@@ -366,6 +388,7 @@ export class Game {
366
388
  this.resizeObserver.disconnect();
367
389
  this.ui.dispose();
368
390
  this.audio.dispose();
391
+ this.cameraEffects.dispose();
369
392
  // Cancels both scopes, including session-scoped work no entity owns
370
393
  // (entity.destroy() below only ever reaches owned work) — ADR 0017.
371
394
  this.time.cancelAll();
@@ -478,6 +501,7 @@ export class Game {
478
501
  }
479
502
  this.dispatchCollisions();
480
503
  this.updateSceneCamera(SIMULATION_STEP);
504
+ advanceCameraEffects(this.cameraEffects);
481
505
  this.finishStep();
482
506
  }
483
507
  /** Closes a step (real or the non-simulating stand-in): host callbacks, then the input frame. */
@@ -521,8 +545,12 @@ export class Game {
521
545
  }
522
546
  }
523
547
  const z = ySortZ(entries);
524
- for (const [index, participant] of participants.entries())
525
- 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
+ }
526
554
  }
527
555
  renderSurface() {
528
556
  if (this.sceneProjection === 'isometric') {
@@ -535,15 +563,28 @@ export class Game {
535
563
  if (this.renderSort === 'y')
536
564
  this.applyYSort();
537
565
  this.ui.setActive(this.simulate);
538
- anchoredPiecesOf(this.ui).place();
539
- if (this.resolution) {
540
- // Letterbox bars: clear the whole canvas, then render inside the scissor.
541
- this.renderer.setScissorTest(false);
542
- this.renderer.setClearColor(0x000000, 1);
543
- this.renderer.clear(true, false, false);
544
- this.renderer.setScissorTest(true);
566
+ // The shake offset exists only while drawing (ADR 0020): Anchored Pieces
567
+ // and the render see the drawn center, then the base comes back exactly,
568
+ // so follow smoothing never starts from its own jitter.
569
+ const { x, y } = this.camera.position;
570
+ const { shake } = this.cameraEffects.state;
571
+ this.camera.position.x = x + shake.x;
572
+ this.camera.position.y = y + shake.y;
573
+ try {
574
+ anchoredPiecesOf(this.ui).place();
575
+ if (this.resolution) {
576
+ // Letterbox bars: clear the whole canvas, then render inside the scissor.
577
+ this.renderer.setScissorTest(false);
578
+ this.renderer.setClearColor(0x000000, 1);
579
+ this.renderer.clear(true, false, false);
580
+ this.renderer.setScissorTest(true);
581
+ }
582
+ this.renderer.render(this.scene, this.camera);
583
+ }
584
+ finally {
585
+ this.camera.position.x = x;
586
+ this.camera.position.y = y;
545
587
  }
546
- this.renderer.render(this.scene, this.camera);
547
588
  }
548
589
  componentUpdateSchedule(entity) {
549
590
  const components = [...entity.components];
@@ -551,11 +592,11 @@ export class Game {
551
592
  const names = [];
552
593
  const signatureParts = [];
553
594
  for (const component of components) {
554
- const Class = component.constructor;
595
+ const Class = componentClassOf(component);
555
596
  const name = Class.componentName;
556
597
  names.push(name);
557
598
  byName.set(name, component);
558
- signatureParts.push(`${name}:${typeof Class.prototype.onUpdate === 'function' ? 'updates' : 'passive'}:` +
599
+ signatureParts.push(`${name}:${implementsOnUpdate(Class) ? 'updates' : 'passive'}:` +
559
600
  [...new Set(Class.updateAfter ?? [])].sort().join(','));
560
601
  }
561
602
  const signature = signatureParts.sort().join('|');
@@ -565,13 +606,13 @@ export class Game {
565
606
  // when nothing about this entity's components changed since last time.
566
607
  const cached = this.updateScheduleCache.get(entity);
567
608
  if (cached && cached.signature === signature) {
568
- return cached.order.map((name) => byName.get(name));
609
+ return componentsInOrder(cached.order, byName);
569
610
  }
570
611
  const registry = {
571
612
  ...(this.registry?.components ?? {}),
572
613
  };
573
614
  for (const component of components) {
574
- const Class = component.constructor;
615
+ const Class = componentClassOf(component);
575
616
  registry[Class.componentName] = Class;
576
617
  }
577
618
  const result = resolveComponentUpdateSchedule(names, registry);
@@ -586,7 +627,7 @@ export class Game {
586
627
  }
587
628
  this.invalidUpdateCompositions.delete(entity);
588
629
  this.updateScheduleCache.set(entity, { signature, order: result.order });
589
- return result.order.map((name) => byName.get(name));
630
+ return componentsInOrder(result.order, byName);
590
631
  }
591
632
  updateSceneCamera(dt) {
592
633
  const cam = this.sceneCamera;
@@ -655,5 +696,6 @@ export class Game {
655
696
  this.camera.top = halfH;
656
697
  this.camera.bottom = -halfH;
657
698
  this.camera.updateProjectionMatrix();
699
+ layoutCameraEffects(this.cameraEffects, gameViewport(w, h, this.resolution));
658
700
  }
659
701
  }
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export { spritePlacement } from './sprite-placement.js';
17
17
  export type { SpritePlacement, SpritePlacementInput } from './sprite-placement.js';
18
18
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
19
19
  export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
20
+ export { CameraEffects } from './camera-effects.js';
21
+ export type { CameraEffectColor, CameraEffectHandle, CameraEffectsOptions, CameraEffectsState, FadeOptions, FlashOptions, ShakeOptions, } from './camera-effects.js';
20
22
  export { GameTime, advanceGameTime } from './game-time.js';
21
23
  export type { EasingName, TimerHandle, TimerOptions, TweenOptions } from './game-time.js';
22
24
  export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
@@ -35,7 +37,7 @@ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from
35
37
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
36
38
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
37
39
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
38
- export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeSnapshotUi, RuntimeTransformSnapshot, } from './runtime-inspection.js';
40
+ export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotCamera, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeSnapshotUi, RuntimeTransformSnapshot, } from './runtime-inspection.js';
39
41
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
40
42
  export { Stats } from './stats.js';
41
43
  export type { StatValue } from './stats.js';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export { projectIsometric, screenInputToLogical, unprojectIsometric } from './pr
7
7
  export { spritePlacement } from './sprite-placement.js';
8
8
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
9
9
  export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
10
+ export { CameraEffects } from './camera-effects.js';
10
11
  export { GameTime, advanceGameTime } from './game-time.js';
11
12
  export { Entity } from './entity.js';
12
13
  export { Component } from './component.js';
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
+ }
@@ -15,9 +15,11 @@ export type RuntimeMode = 'paused' | 'real-time';
15
15
  * (ADR 0019) announces that every metadata carries `assets: { pending,
16
16
  * loaded, failed }` from `game.assets.status`, so a Run Session can wait
17
17
  * for Assets Ready (`pending === 0`) at readiness, after a `scene`
18
- * operation and before a screenshot.
18
+ * operation and before a screenshot. `camera-effects` (issue #74, ADR 0020)
19
+ * announces that every snapshot carries `camera: { shake, fade, flash }`
20
+ * from `game.cameraEffects`; the `scene` operation stays a hard cut.
19
21
  */
20
- export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets'];
22
+ export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects'];
21
23
  export interface RuntimeMetadata {
22
24
  bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
23
25
  engineVersion: string;
@@ -104,6 +106,10 @@ export declare class EngineRuntimeBridge implements RuntimeBridge {
104
106
  metadata(): RuntimeMetadata;
105
107
  inspect(filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
106
108
  control(request: RuntimeControlRequest): RuntimeControlResult;
109
+ private injectAction;
110
+ /** Advances whole Simulation Steps while paused. */
111
+ private step;
112
+ private loadScene;
107
113
  /** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
108
114
  private advance;
109
115
  unregister(): void;
@@ -14,9 +14,17 @@ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
14
14
  * (ADR 0019) announces that every metadata carries `assets: { pending,
15
15
  * loaded, failed }` from `game.assets.status`, so a Run Session can wait
16
16
  * for Assets Ready (`pending === 0`) at readiness, after a `scene`
17
- * operation and before a screenshot.
17
+ * operation and before a screenshot. `camera-effects` (issue #74, ADR 0020)
18
+ * announces that every snapshot carries `camera: { shake, fade, flash }`
19
+ * from `game.cameraEffects`; the `scene` operation stays a hard cut.
18
20
  */
19
- export const RUNTIME_BRIDGE_CAPABILITIES = ['click', 'scene', 'fixed-step', 'assets'];
21
+ export const RUNTIME_BRIDGE_CAPABILITIES = [
22
+ 'click',
23
+ 'scene',
24
+ 'fixed-step',
25
+ 'assets',
26
+ 'camera-effects',
27
+ ];
20
28
  export class RuntimeBridgeOperationError extends Error {
21
29
  code;
22
30
  availableActions;
@@ -88,29 +96,11 @@ export class EngineRuntimeBridge {
88
96
  case 'press':
89
97
  case 'hold':
90
98
  case 'release':
91
- if (!this.host.injectAction(request.action, request.operation)) {
92
- const available = this.host.availableActions();
93
- throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown action "${request.action}". Available actions: ${available.join(', ') || '(none)'}.`, available);
94
- }
99
+ this.injectAction(request);
95
100
  break;
96
- case 'step': {
97
- if (this.mode !== 'paused') {
98
- throw new RuntimeBridgeOperationError('runtime-invalid-state', 'step is only available while the Runtime Bridge is paused.');
99
- }
100
- // A caller-chosen dt is rejected outright, never ignored: a pre-ADR-0014
101
- // client that still sends one would otherwise believe it stepped by it.
102
- if ('dt' in request) {
103
- 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.');
104
- }
105
- const frames = request.frames ?? 1;
106
- if (!Number.isInteger(frames) || frames < 1 || frames > 600) {
107
- throw new RuntimeBridgeOperationError('runtime-operation-failed', 'frames must be an integer from 1 through 600.');
108
- }
109
- for (let index = 0; index < frames; index += 1) {
110
- this.host.step(() => this.advance());
111
- }
101
+ case 'step':
102
+ this.step(request);
112
103
  break;
113
- }
114
104
  case 'click': {
115
105
  if (!Number.isFinite(request.x) || !Number.isFinite(request.y)) {
116
106
  throw new RuntimeBridgeOperationError('runtime-operation-failed', 'x and y must be finite numbers.');
@@ -118,13 +108,9 @@ export class EngineRuntimeBridge {
118
108
  this.host.click(request.x, request.y);
119
109
  break;
120
110
  }
121
- case 'scene': {
122
- if (!this.host.loadScene(request.scene)) {
123
- const available = this.host.availableScenes();
124
- throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown scene "${request.scene}". Available scenes: ${available.join(', ') || '(none)'}.`, undefined, available);
125
- }
111
+ case 'scene':
112
+ this.loadScene(request.scene);
126
113
  break;
127
- }
128
114
  default: {
129
115
  const unsupported = request;
130
116
  throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unsupported runtime control operation "${unsupported.operation}".`);
@@ -132,6 +118,36 @@ export class EngineRuntimeBridge {
132
118
  }
133
119
  return { ...this.metadata(), heldActions: this.host.heldActions() };
134
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
+ }
135
151
  /** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
136
152
  advance() {
137
153
  this.frame += 1;
@@ -1,4 +1,5 @@
1
1
  import type { AudioChannelState, LiveSoundInfo } from './audio/types.js';
2
+ import type { CameraEffectsState } from './camera-effects.js';
2
3
  import type { Game } from './game.js';
3
4
  import type { RuntimeMetadata } from './runtime-bridge.js';
4
5
  import type { StatValue } from './stats.js';
@@ -106,6 +107,13 @@ export interface RuntimeSnapshotUi {
106
107
  values: Record<string, StatValue | ProjectionMarker> | ProjectionMarker;
107
108
  }>;
108
109
  }
110
+ /**
111
+ * `game.cameraEffects.state` (issue #74 CA-17), beside `time` and `ui`: the
112
+ * shake offset (world units, added to the camera only while drawing) and
113
+ * the Fade and Flash layers' `#rrggbb` color and opacity, all from the
114
+ * last completed Simulation Step. Emitted unconditionally — never filtered.
115
+ */
116
+ export type RuntimeSnapshotCamera = CameraEffectsState;
109
117
  export interface RuntimeSnapshot extends RuntimeMetadata {
110
118
  stats: Record<string, StatValue>;
111
119
  /** The live scene's name (its catalog key), or null with no scene loaded. */
@@ -115,6 +123,7 @@ export interface RuntimeSnapshot extends RuntimeMetadata {
115
123
  audio: RuntimeSnapshotAudio;
116
124
  time: RuntimeSnapshotTime;
117
125
  ui: RuntimeSnapshotUi;
126
+ camera: RuntimeSnapshotCamera;
118
127
  }
119
128
  export declare const RUNTIME_PROJECTION_LIMITS: {
120
129
  readonly depth: 5;