@waica/engine 0.11.0 → 0.13.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,32 @@
1
+ /**
2
+ * Engine constants for CA-8 positional audio. There are no public tuning
3
+ * knobs (spec decision 20): the curve is picked once, in the spirit of
4
+ * `CAMERA_DEFAULTS` (`camera.ts`), and frozen — CA-21 is the human-only
5
+ * game-feel pass that may revisit these numbers by ear, never a per-call or
6
+ * per-channel option.
7
+ *
8
+ * The attenuation curve mirrors WebAudio's own `PannerNode` "linear"
9
+ * distance model: full volume at or inside `referenceDistance`, a straight
10
+ * ramp down to silence at `maxDistance`, silent beyond it. `panDistance` is
11
+ * the render-space horizontal offset (world units) that reaches full
12
+ * left/right pan.
13
+ */
14
+ export const AUDIO_SPATIAL_DEFAULTS = {
15
+ referenceDistance: 3,
16
+ maxDistance: 16,
17
+ panDistance: 6,
18
+ };
19
+ /** 1 at/inside referenceDistance, 0 at/beyond maxDistance, linear in between. */
20
+ export function attenuationForDistance(distance) {
21
+ const { referenceDistance, maxDistance } = AUDIO_SPATIAL_DEFAULTS;
22
+ if (distance <= referenceDistance)
23
+ return 1;
24
+ if (distance >= maxDistance)
25
+ return 0;
26
+ return 1 - (distance - referenceDistance) / (maxDistance - referenceDistance);
27
+ }
28
+ /** Maps a render-space horizontal offset (source minus listener) to a [-1, 1] stereo pan. */
29
+ export function panForOffset(dx) {
30
+ const { panDistance } = AUDIO_SPATIAL_DEFAULTS;
31
+ return Math.max(-1, Math.min(1, dx / panDistance));
32
+ }
@@ -0,0 +1,42 @@
1
+ import type { Entity } from '../entity.js';
2
+ export interface AudioPlayOptions {
3
+ /** Mixer channel; defaults to 'sfx'. Naming any other channel creates it at volume 1. */
4
+ channel?: string;
5
+ /** The sound's own gain, independent of the channel/master mix. Defaults to 1. */
6
+ volume?: number;
7
+ loop?: boolean;
8
+ /**
9
+ * 'session' survives Game.unloadScene() (CA-7); omitted means scene-scoped
10
+ * — the default, and the common case (ADR 0012).
11
+ */
12
+ scope?: 'session';
13
+ /**
14
+ * Positional playback (CA-8). An `Entity` tracks its current position
15
+ * every frame; a plain `{ x, y }` fixes the placement. Omitted, the sound
16
+ * is flat: no panning, no distance attenuation. Attenuation is computed
17
+ * from the distance to the listener (the camera) in logical coordinates;
18
+ * panning is computed from both positions after `game.renderPoint()`, so
19
+ * an isometric source that reads to the right on screen pans right even
20
+ * though its volume reflects real (logical) game distance.
21
+ */
22
+ at?: Entity | {
23
+ x: number;
24
+ y: number;
25
+ };
26
+ }
27
+ export interface SoundHandle {
28
+ stop(opts?: {
29
+ fadeMs?: number;
30
+ }): void;
31
+ readonly playing: boolean;
32
+ volume: number;
33
+ }
34
+ export interface AudioChannelState {
35
+ volume: number;
36
+ muted: boolean;
37
+ }
38
+ export interface LiveSoundInfo {
39
+ uri: string;
40
+ channel: string;
41
+ scope: 'scene' | 'session';
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions } from './backend.js';
2
+ /**
3
+ * The real WebAudio implementation (ADR 0013's default). Builds a small
4
+ * mixing graph — one GainNode per sound feeding a StereoPannerNode, feeding
5
+ * a per-channel GainNode, feeding a single master GainNode — so
6
+ * muting/volume/master changes are plain WebAudio gain assignments, and
7
+ * positional panning (CA-8) is a plain WebAudio pan assignment, neither
8
+ * something this class recomputes by hand for every live sound.
9
+ *
10
+ * The AudioContext itself is never created eagerly: `happy-dom` (this
11
+ * repo's test environment) has no AudioContext/AudioBuffer/GainNode at all,
12
+ * and real browsers block unsolicited audio output before a user gesture.
13
+ * `ensureContext()` is the one lazy constructor, reached only from
14
+ * `resume()` (the unlock, CA-6) or `load()` (so `preload()` can decode
15
+ * ahead of an unlock). If AudioContext isn't available in the current
16
+ * environment, every method degrades to a safe no-op / rejected load
17
+ * instead of throwing — the audio subsystem's own load-failure handling
18
+ * (CA-9) turns that rejection into a single warning, never a crash.
19
+ */
20
+ export declare class WebAudioBackend implements AudioBackend {
21
+ private context;
22
+ private masterGain;
23
+ private masterVolume;
24
+ private readonly channelGains;
25
+ private readonly channelVolumes;
26
+ private readonly channelMuted;
27
+ load(uri: string): Promise<AudioResource>;
28
+ play(resource: AudioResource, options: BackendPlayOptions): BackendPlayHandle;
29
+ setChannelVolume(channel: string, volume: number): void;
30
+ setChannelMuted(channel: string, muted: boolean): void;
31
+ setMasterVolume(volume: number): void;
32
+ suspend(): void;
33
+ resume(): void;
34
+ close(): void;
35
+ private ensureContext;
36
+ private ensureChannelGain;
37
+ private applyChannelGain;
38
+ private effectiveChannelGain;
39
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The real WebAudio implementation (ADR 0013's default). Builds a small
3
+ * mixing graph — one GainNode per sound feeding a StereoPannerNode, feeding
4
+ * a per-channel GainNode, feeding a single master GainNode — so
5
+ * muting/volume/master changes are plain WebAudio gain assignments, and
6
+ * positional panning (CA-8) is a plain WebAudio pan assignment, neither
7
+ * something this class recomputes by hand for every live sound.
8
+ *
9
+ * The AudioContext itself is never created eagerly: `happy-dom` (this
10
+ * repo's test environment) has no AudioContext/AudioBuffer/GainNode at all,
11
+ * and real browsers block unsolicited audio output before a user gesture.
12
+ * `ensureContext()` is the one lazy constructor, reached only from
13
+ * `resume()` (the unlock, CA-6) or `load()` (so `preload()` can decode
14
+ * ahead of an unlock). If AudioContext isn't available in the current
15
+ * environment, every method degrades to a safe no-op / rejected load
16
+ * instead of throwing — the audio subsystem's own load-failure handling
17
+ * (CA-9) turns that rejection into a single warning, never a crash.
18
+ */
19
+ export class WebAudioBackend {
20
+ context = null;
21
+ masterGain = null;
22
+ masterVolume = 1;
23
+ channelGains = new Map();
24
+ channelVolumes = new Map();
25
+ channelMuted = new Map();
26
+ async load(uri) {
27
+ const context = this.ensureContext();
28
+ if (!context)
29
+ throw new Error('AudioContext is not available in this environment');
30
+ const response = await fetch(uri);
31
+ if (!response.ok)
32
+ throw new Error(`HTTP ${response.status} fetching "${uri}"`);
33
+ const data = await response.arrayBuffer();
34
+ return context.decodeAudioData(data);
35
+ }
36
+ play(resource, options) {
37
+ const context = this.ensureContext();
38
+ if (!context)
39
+ return noopHandle();
40
+ const source = context.createBufferSource();
41
+ source.buffer = resource;
42
+ source.loop = options.loop;
43
+ const soundGain = context.createGain();
44
+ soundGain.gain.value = options.volume;
45
+ const panner = context.createStereoPanner();
46
+ source.connect(soundGain);
47
+ soundGain.connect(panner);
48
+ panner.connect(this.ensureChannelGain(options.channel));
49
+ let ended = false;
50
+ const finish = () => {
51
+ if (ended)
52
+ return;
53
+ ended = true;
54
+ options.onEnded();
55
+ };
56
+ source.onended = finish;
57
+ source.start();
58
+ return {
59
+ setVolume: (volume) => {
60
+ soundGain.gain.value = volume;
61
+ },
62
+ setPan: (pan) => {
63
+ panner.pan.value = pan;
64
+ },
65
+ stop: (fadeMs) => {
66
+ if (ended)
67
+ return;
68
+ const now = context.currentTime;
69
+ if (fadeMs && fadeMs > 0) {
70
+ soundGain.gain.linearRampToValueAtTime(0, now + fadeMs / 1000);
71
+ source.stop(now + fadeMs / 1000);
72
+ }
73
+ else {
74
+ source.stop();
75
+ }
76
+ },
77
+ };
78
+ }
79
+ setChannelVolume(channel, volume) {
80
+ this.channelVolumes.set(channel, volume);
81
+ this.applyChannelGain(channel);
82
+ }
83
+ setChannelMuted(channel, muted) {
84
+ this.channelMuted.set(channel, muted);
85
+ this.applyChannelGain(channel);
86
+ }
87
+ setMasterVolume(volume) {
88
+ this.masterVolume = volume;
89
+ if (this.masterGain)
90
+ this.masterGain.gain.value = volume;
91
+ }
92
+ suspend() {
93
+ void this.context?.suspend();
94
+ }
95
+ resume() {
96
+ void this.ensureContext()?.resume();
97
+ }
98
+ close() {
99
+ void this.context?.close();
100
+ this.context = null;
101
+ this.masterGain = null;
102
+ this.channelGains.clear();
103
+ }
104
+ ensureContext() {
105
+ if (this.context)
106
+ return this.context;
107
+ if (typeof AudioContext === 'undefined')
108
+ return null;
109
+ const context = new AudioContext();
110
+ this.context = context;
111
+ const masterGain = context.createGain();
112
+ masterGain.gain.value = this.masterVolume;
113
+ masterGain.connect(context.destination);
114
+ this.masterGain = masterGain;
115
+ for (const channel of this.channelVolumes.keys())
116
+ this.ensureChannelGain(channel);
117
+ return context;
118
+ }
119
+ ensureChannelGain(channel) {
120
+ const existing = this.channelGains.get(channel);
121
+ if (existing)
122
+ return existing;
123
+ // Safe: only called once ensureContext() has already produced a context.
124
+ const context = this.context;
125
+ const gain = context.createGain();
126
+ gain.gain.value = this.effectiveChannelGain(channel);
127
+ gain.connect(this.masterGain);
128
+ this.channelGains.set(channel, gain);
129
+ return gain;
130
+ }
131
+ applyChannelGain(channel) {
132
+ const gain = this.channelGains.get(channel);
133
+ if (gain)
134
+ gain.gain.value = this.effectiveChannelGain(channel);
135
+ }
136
+ effectiveChannelGain(channel) {
137
+ if (this.channelMuted.get(channel))
138
+ return 0;
139
+ return this.channelVolumes.get(channel) ?? 1;
140
+ }
141
+ }
142
+ /** Returned when the environment has no AudioContext at all: inert, never throws. */
143
+ function noopHandle() {
144
+ return {
145
+ setVolume: () => { },
146
+ setPan: () => { },
147
+ stop: () => { },
148
+ };
149
+ }
@@ -10,7 +10,7 @@ export interface ParamSpec {
10
10
  /** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
11
11
  options?: string[];
12
12
  /** Project value this string param names; rendered and validated as a typed reference. */
13
- ref?: 'prefab' | 'stat' | 'action' | 'clip';
13
+ ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound';
14
14
  }
15
15
  export interface ComponentClass<T extends Component = Component> {
16
16
  new (): T;
@@ -69,6 +69,11 @@ export declare abstract class Component {
69
69
  onCollide?(other: Entity): void;
70
70
  /** Runs when this entity's DynamicBody physically contacts a Solid. */
71
71
  onContact?(contact: SolidContact): void;
72
+ /**
73
+ * Runs when this entity wins the nearest-Interactable scan and the
74
+ * initiator interacts with it (a keypress or a click-to-move NPC order).
75
+ */
76
+ onInteract?(initiator: Entity): void;
72
77
  /** Runs when the entity is destroyed or the component removed. */
73
78
  onDestroy?(): void;
74
79
  }
package/dist/game.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import * as THREE from 'three';
2
+ import type { AudioBackend } from './audio/backend.js';
3
+ import { AudioSubsystem } from './audio/audio-subsystem.js';
2
4
  import { type SceneCameraJson } from './camera.js';
3
5
  import type { Component } from './component.js';
4
6
  import { Entity } from './entity.js';
5
7
  import { Emitter } from './events.js';
6
8
  import { Input, type InputBindings } from './input.js';
7
9
  import { Pointer } from './pointer.js';
8
- import { type SceneRegistry, type SceneRenderJson } from './scene.js';
10
+ import { type SceneJson, type SceneRegistry, type SceneRenderJson } from './scene.js';
9
11
  import { Stats, type StatValue } from './stats.js';
10
12
  import { GameUi } from './ui.js';
11
13
  /** Fixed game resolution: the view keeps this aspect, letterboxed. */
@@ -26,12 +28,25 @@ export interface GameOptions {
26
28
  bindings?: InputBindings;
27
29
  /** Initial stat values (points, lives…) from the project's stats.json. */
28
30
  stats?: Record<string, StatValue>;
31
+ /**
32
+ * Replaces the real WebAudio implementation (ADR 0013) — mainly for a
33
+ * project's own tests, since `happy-dom` has no AudioContext, AudioBuffer
34
+ * or GainNode at all. Defaults to the real backend either way; `game.audio`
35
+ * always exists, and the real AudioContext is constructed lazily, at the
36
+ * first unlock (CA-6), never eagerly here.
37
+ */
38
+ audio?: AudioBackend;
29
39
  }
30
40
  export type UpdateFn = (dt: number) => void;
31
41
  export interface SpawnPrefabOptions {
32
42
  name?: string;
33
43
  position?: [number, number];
34
44
  }
45
+ /** The Project's scenes by name (a file's stem), plus the registry shared by all of them. */
46
+ export interface SceneCatalog {
47
+ scenes: Record<string, SceneJson>;
48
+ registry: SceneRegistry;
49
+ }
35
50
  /** Persisted overrides: entity → componentName → prop → value. */
36
51
  export type ParamOverrides = Record<string, Record<string, Record<string, number | boolean | string>>>;
37
52
  /**
@@ -48,6 +63,8 @@ export declare class Game {
48
63
  readonly stats: Stats;
49
64
  /** The HTML UI layer: presentation-only pieces toggled from code. */
50
65
  readonly ui: GameUi;
66
+ /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
67
+ readonly audio: AudioSubsystem;
51
68
  /** Registry retained by loadScene for runtime prefab spawning. */
52
69
  registry: SceneRegistry | null;
53
70
  paramOverrides: ParamOverrides;
@@ -61,12 +78,22 @@ export declare class Game {
61
78
  private readonly updateFns;
62
79
  private readonly invalidUpdateCompositions;
63
80
  private readonly resolution;
81
+ /** The constructor's viewHeight — unloadScene() restores it. */
82
+ private readonly baseViewHeight;
64
83
  private viewHeight;
65
84
  private sceneCamera;
66
85
  private renderSort;
67
86
  private sceneProjection;
68
87
  private lastTime;
69
88
  private runtimeBridge;
89
+ /** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
90
+ private sceneCatalog;
91
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
92
+ private liveSceneName;
93
+ /** True for the whole extent of a runFrame() call, incl. its tail. */
94
+ private insideFrame;
95
+ /** A loadSceneByName() enqueued while insideFrame; applied at the next runFrame's start. */
96
+ private pendingSceneLoad;
70
97
  constructor(options: GameOptions);
71
98
  /** Creates a live entity in the scene. */
72
99
  spawn(name: string): Entity;
@@ -74,6 +101,35 @@ export declare class Game {
74
101
  spawnPrefab(prefab: string, options?: SpawnPrefabOptions): Entity | null;
75
102
  /** Finds an entity by name. */
76
103
  find(name: string): Entity | undefined;
104
+ /**
105
+ * Destroys the live scene — every entity (Entity.destroy(), so onDestroy
106
+ * cascades and GPU resources release) and its scene-scoped UI — and
107
+ * leaves the Game as newly constructed: no registry, no scene camera, no
108
+ * render sort or projection, viewHeight back to the constructor's.
109
+ * Session-scoped state (stats, paramOverrides, subscriptions, the scene
110
+ * catalog) is untouched. See ADR 0011. Public: the seam `loadScene` calls
111
+ * to replace a scene, and how a host leaves the Game with none loaded.
112
+ */
113
+ unloadScene(): void;
114
+ /** Registers the Project's scenes by name, resolved by loadSceneByName. */
115
+ registerSceneCatalog(catalog: SceneCatalog): void;
116
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
117
+ get sceneName(): string | null;
118
+ /** Names registered via registerSceneCatalog, in registration order. */
119
+ get availableScenes(): string[];
120
+ /**
121
+ * Resolves `name` through the registered catalog and loads it, replacing
122
+ * the live scene. An unknown name warns and leaves the live scene
123
+ * untouched. Triggered mid-frame (e.g. from a SceneTransition's
124
+ * onCollide/onInteract) the swap is deferred to the very start of the
125
+ * next runFrame — dispatchCollisions finishes its double loop over the
126
+ * outgoing scene, and the incoming scene's entities are present only
127
+ * from the next frame. Called from outside a frame (boot, or the Runtime
128
+ * Bridge's `scene` control operation) it applies synchronously and wins
129
+ * over anything queued earlier this frame. A second mid-frame request
130
+ * loses to the first and says so. Returns whether the load took effect.
131
+ */
132
+ loadSceneByName(name: string): boolean;
77
133
  /** Loads persisted parameter overrides (waica.params.json). */
78
134
  loadParams(url: string): Promise<void>;
79
135
  /** Applies persisted overrides to a freshly added component. */
@@ -104,6 +160,7 @@ export declare class Game {
104
160
  private resumeRuntime;
105
161
  private tick;
106
162
  private runFrame;
163
+ private flushPendingSceneLoad;
107
164
  private unregisterRuntimeBridge;
108
165
  /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
109
166
  private applyYSort;
@@ -111,6 +168,14 @@ export declare class Game {
111
168
  private componentUpdateSchedule;
112
169
  private updateSceneCamera;
113
170
  private renderPoint;
171
+ /**
172
+ * The audio listener's position (CA-8) in logical coordinates. The camera
173
+ * itself only ever holds render-space coordinates (see `updateSceneCamera`,
174
+ * `setSceneCamera`), so under `projection: 'isometric'` this is the exact
175
+ * inverse of `renderPoint` — without it, distance-based attenuation would
176
+ * measure render-space distance instead of real game distance.
177
+ */
178
+ private audioListenerPosition;
114
179
  private dispatchCollisions;
115
180
  private resize;
116
181
  }
package/dist/game.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as THREE from 'three';
2
+ import { AudioSubsystem } from './audio/audio-subsystem.js';
2
3
  import { collisionOverlap } from './collision-shape.js';
3
4
  import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
4
5
  import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
@@ -9,9 +10,9 @@ import { Input } from './input.js';
9
10
  import { Pointer } from './pointer.js';
10
11
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
11
12
  import { RuntimeInspector } from './runtime-inspection.js';
12
- import { projectIsometric } from './projection.js';
13
+ import { projectIsometric, unprojectIsometric } from './projection.js';
13
14
  import { isYSortParticipant, ySortZ } from './render-sort.js';
14
- import { registryEntry, spawnFromJson } from './scene.js';
15
+ import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
15
16
  import { Stats } from './stats.js';
16
17
  import { GameUi } from './ui.js';
17
18
  /**
@@ -28,6 +29,8 @@ export class Game {
28
29
  stats;
29
30
  /** The HTML UI layer: presentation-only pieces toggled from code. */
30
31
  ui;
32
+ /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
33
+ audio;
31
34
  /** Registry retained by loadScene for runtime prefab spawning. */
32
35
  registry = null;
33
36
  paramOverrides = {};
@@ -42,19 +45,39 @@ export class Game {
42
45
  updateFns = new Set();
43
46
  invalidUpdateCompositions = new WeakMap();
44
47
  resolution;
48
+ /** The constructor's viewHeight — unloadScene() restores it. */
49
+ baseViewHeight;
45
50
  viewHeight;
46
51
  sceneCamera = null;
47
52
  renderSort = null;
48
53
  sceneProjection = null;
49
54
  lastTime = 0;
50
55
  runtimeBridge = null;
56
+ /** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
57
+ sceneCatalog = null;
58
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
59
+ liveSceneName = null;
60
+ /** True for the whole extent of a runFrame() call, incl. its tail. */
61
+ insideFrame = false;
62
+ /** A loadSceneByName() enqueued while insideFrame; applied at the next runFrame's start. */
63
+ pendingSceneLoad = null;
51
64
  constructor(options) {
52
65
  const { canvas, background = 0x1a1a2e, viewHeight = 10 } = options;
66
+ this.baseViewHeight = viewHeight;
53
67
  this.viewHeight = viewHeight;
54
68
  this.resolution = options.resolution ?? null;
55
69
  this.input = new Input(options.bindings);
56
70
  this.stats = new Stats(options.stats);
57
71
  this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
72
+ this.audio = new AudioSubsystem({
73
+ canvas,
74
+ backend: options.audio,
75
+ // The catalog registered via registerSceneCatalog, never game.registry:
76
+ // unloadScene() nulls the latter but leaves the catalog (and its
77
+ // resolver) untouched, which is exactly what a { scope: 'session' }
78
+ // music bed needs across a scene swap.
79
+ resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
80
+ });
58
81
  this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
59
82
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
60
83
  this.scene.background = new THREE.Color(background);
@@ -97,6 +120,85 @@ export class Game {
97
120
  find(name) {
98
121
  return this.entities.find((e) => e.name === name);
99
122
  }
123
+ /**
124
+ * Destroys the live scene — every entity (Entity.destroy(), so onDestroy
125
+ * cascades and GPU resources release) and its scene-scoped UI — and
126
+ * leaves the Game as newly constructed: no registry, no scene camera, no
127
+ * render sort or projection, viewHeight back to the constructor's.
128
+ * Session-scoped state (stats, paramOverrides, subscriptions, the scene
129
+ * catalog) is untouched. See ADR 0011. Public: the seam `loadScene` calls
130
+ * to replace a scene, and how a host leaves the Game with none loaded.
131
+ */
132
+ unloadScene() {
133
+ this.ui.unloadScene();
134
+ this.audio.unloadScene();
135
+ // An explicit unload means "no scene": a swap queued earlier this frame
136
+ // would otherwise flush next frame and resurrect one.
137
+ this.pendingSceneLoad = null;
138
+ // Entity.destroy() splices itself out of `this.entities` in place — the
139
+ // Pointer holds that array by reference, so it must never be reassigned.
140
+ for (const entity of [...this.entities])
141
+ entity.destroy();
142
+ this.registry = null;
143
+ this.renderSort = null;
144
+ this.sceneProjection = null;
145
+ this.sceneCamera = null;
146
+ this.liveSceneName = null;
147
+ this.setViewHeight(this.baseViewHeight);
148
+ }
149
+ /** Registers the Project's scenes by name, resolved by loadSceneByName. */
150
+ registerSceneCatalog(catalog) {
151
+ this.sceneCatalog = catalog;
152
+ }
153
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
154
+ get sceneName() {
155
+ return this.liveSceneName;
156
+ }
157
+ /** Names registered via registerSceneCatalog, in registration order. */
158
+ get availableScenes() {
159
+ return this.sceneCatalog ? Object.keys(this.sceneCatalog.scenes) : [];
160
+ }
161
+ /**
162
+ * Resolves `name` through the registered catalog and loads it, replacing
163
+ * the live scene. An unknown name warns and leaves the live scene
164
+ * untouched. Triggered mid-frame (e.g. from a SceneTransition's
165
+ * onCollide/onInteract) the swap is deferred to the very start of the
166
+ * next runFrame — dispatchCollisions finishes its double loop over the
167
+ * outgoing scene, and the incoming scene's entities are present only
168
+ * from the next frame. Called from outside a frame (boot, or the Runtime
169
+ * Bridge's `scene` control operation) it applies synchronously and wins
170
+ * over anything queued earlier this frame. A second mid-frame request
171
+ * loses to the first and says so. Returns whether the load took effect.
172
+ */
173
+ loadSceneByName(name) {
174
+ const catalog = this.sceneCatalog;
175
+ const json = catalog ? registryEntry(catalog.scenes, name) : undefined;
176
+ if (!catalog || !json) {
177
+ console.warn(`[waica] unknown scene: "${name}"`);
178
+ return false;
179
+ }
180
+ const apply = () => {
181
+ loadScene(this, json, catalog.registry);
182
+ this.liveSceneName = name;
183
+ };
184
+ if (!this.insideFrame) {
185
+ // Authoritative: dropping the queue is the point. A swap a transition
186
+ // enqueued earlier would otherwise flush on the next frame and silently
187
+ // undo this load, reporting success for a scene the caller never got.
188
+ this.pendingSceneLoad = null;
189
+ apply();
190
+ return true;
191
+ }
192
+ if (this.pendingSceneLoad) {
193
+ // Two transitions resolving in the same collision dispatch: the one the
194
+ // simulation reached first wins, and the loser is told. Last-write-wins
195
+ // would drop the player in the other door's destination with no signal.
196
+ console.warn(`[waica] a scene swap is already queued this frame; ignoring "${name}"`);
197
+ return false;
198
+ }
199
+ this.pendingSceneLoad = apply;
200
+ return true;
201
+ }
100
202
  /** Loads persisted parameter overrides (waica.params.json). */
101
203
  async loadParams(url) {
102
204
  try {
@@ -173,8 +275,11 @@ export class Game {
173
275
  click: (x, y) => {
174
276
  this.pointer.injectClick(x, y);
175
277
  },
278
+ loadScene: (name) => this.loadSceneByName(name),
279
+ availableScenes: () => this.availableScenes,
176
280
  });
177
281
  activation.register(this.runtimeBridge);
282
+ this.audio.setSilenced(true);
178
283
  window.addEventListener('pagehide', this.unregisterRuntimeBridge);
179
284
  }
180
285
  this.renderSurface();
@@ -211,6 +316,7 @@ export class Game {
211
316
  this.pointer.dispose();
212
317
  this.resizeObserver.disconnect();
213
318
  this.ui.dispose();
319
+ this.audio.dispose();
214
320
  for (const entity of [...this.entities])
215
321
  entity.destroy();
216
322
  this.renderer.dispose();
@@ -230,28 +336,51 @@ export class Game {
230
336
  this.runFrame(dt);
231
337
  }
232
338
  runFrame(dt) {
233
- if (this.simulate) {
234
- for (const entity of [...this.entities]) {
235
- const schedule = this.componentUpdateSchedule(entity);
236
- if (!schedule)
237
- continue;
238
- for (const component of schedule)
239
- component.onUpdate?.(dt);
339
+ this.insideFrame = true;
340
+ try {
341
+ // Flushes a scene swap enqueued mid-frame last time (CA-7): applied
342
+ // before this frame's own simulation, so the incoming scene's
343
+ // entities are present only from this next frame onward.
344
+ this.flushPendingSceneLoad();
345
+ if (this.simulate) {
346
+ for (const entity of [...this.entities]) {
347
+ const schedule = this.componentUpdateSchedule(entity);
348
+ if (!schedule)
349
+ continue;
350
+ for (const component of schedule)
351
+ component.onUpdate?.(dt);
352
+ }
353
+ this.dispatchCollisions();
354
+ this.updateSceneCamera(dt);
240
355
  }
241
- this.dispatchCollisions();
242
- this.updateSceneCamera(dt);
356
+ // The UI must react to the pause itself (hide until resumed).
357
+ this.ui.setActive(this.simulate);
358
+ this.audio.setActive(this.simulate);
359
+ // Positional audio (CA-8): recomputed every frame, on this same pass —
360
+ // never a second walk of `this.entities`, since `this.audio` already
361
+ // holds direct references to whichever entities are tracked.
362
+ this.audio.updatePlacements(this.audioListenerPosition(), (x, y) => this.renderPoint(x, y));
363
+ for (const fn of this.updateFns)
364
+ fn(dt);
365
+ this.input.endFrame();
366
+ this.renderSurface();
243
367
  }
244
- // The UI must react to the pause itself (hide until resumed).
245
- this.ui.setActive(this.simulate);
246
- for (const fn of this.updateFns)
247
- fn(dt);
248
- this.input.endFrame();
249
- this.renderSurface();
368
+ finally {
369
+ this.insideFrame = false;
370
+ }
371
+ }
372
+ flushPendingSceneLoad() {
373
+ const pending = this.pendingSceneLoad;
374
+ if (!pending)
375
+ return;
376
+ this.pendingSceneLoad = null;
377
+ pending();
250
378
  }
251
379
  unregisterRuntimeBridge = () => {
252
380
  window.removeEventListener('pagehide', this.unregisterRuntimeBridge);
253
381
  this.runtimeBridge?.unregister();
254
382
  this.runtimeBridge = null;
383
+ this.audio.setSilenced(false);
255
384
  };
256
385
  /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
257
386
  applyYSort() {
@@ -348,6 +477,17 @@ export class Game {
348
477
  renderPoint(x, y) {
349
478
  return this.sceneProjection === 'isometric' ? projectIsometric(x, y) : { x, y };
350
479
  }
480
+ /**
481
+ * The audio listener's position (CA-8) in logical coordinates. The camera
482
+ * itself only ever holds render-space coordinates (see `updateSceneCamera`,
483
+ * `setSceneCamera`), so under `projection: 'isometric'` this is the exact
484
+ * inverse of `renderPoint` — without it, distance-based attenuation would
485
+ * measure render-space distance instead of real game distance.
486
+ */
487
+ audioListenerPosition() {
488
+ const { x, y } = this.camera.position;
489
+ return this.sceneProjection === 'isometric' ? unprojectIsometric(x, y) : { x, y };
490
+ }
351
491
  dispatchCollisions() {
352
492
  const boxed = this.entities.filter((e) => e.has(Hitbox));
353
493
  for (let i = 0; i < boxed.length; i++) {