@waica/engine 0.10.0 → 0.12.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.
@@ -22,6 +22,13 @@ export interface ArchetypeManifest {
22
22
  id: string;
23
23
  label: string;
24
24
  scene: SceneJson;
25
+ /**
26
+ * Additional demo scenes beyond `scene` (its name is always "main"),
27
+ * keyed by name — the isometric archetype's second demo scene (CA-13).
28
+ * `start:blank` never emits these; `start:demo` emits one file per entry
29
+ * alongside `scene`.
30
+ */
31
+ extraScenes?: Readonly<Record<string, SceneJson>>;
25
32
  blankScene: SceneJson;
26
33
  registry: SceneRegistry;
27
34
  palette: EntityTemplate[];
@@ -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
@@ -4,7 +4,8 @@ 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, type SceneRenderJson } from './scene.js';
7
+ import { Pointer } from './pointer.js';
8
+ import { type SceneJson, type SceneRegistry, type SceneRenderJson } from './scene.js';
8
9
  import { Stats, type StatValue } from './stats.js';
9
10
  import { GameUi } from './ui.js';
10
11
  /** Fixed game resolution: the view keeps this aspect, letterboxed. */
@@ -31,6 +32,11 @@ export interface SpawnPrefabOptions {
31
32
  name?: string;
32
33
  position?: [number, number];
33
34
  }
35
+ /** The Project's scenes by name (a file's stem), plus the registry shared by all of them. */
36
+ export interface SceneCatalog {
37
+ scenes: Record<string, SceneJson>;
38
+ registry: SceneRegistry;
39
+ }
34
40
  /** Persisted overrides: entity → componentName → prop → value. */
35
41
  export type ParamOverrides = Record<string, Record<string, Record<string, number | boolean | string>>>;
36
42
  /**
@@ -41,6 +47,7 @@ export declare class Game {
41
47
  readonly scene: THREE.Scene<THREE.Object3DEventMap>;
42
48
  readonly camera: THREE.OrthographicCamera;
43
49
  readonly input: Input;
50
+ readonly pointer: Pointer;
44
51
  readonly entities: Entity[];
45
52
  readonly events: Emitter;
46
53
  readonly stats: Stats;
@@ -59,12 +66,22 @@ export declare class Game {
59
66
  private readonly updateFns;
60
67
  private readonly invalidUpdateCompositions;
61
68
  private readonly resolution;
69
+ /** The constructor's viewHeight — unloadScene() restores it. */
70
+ private readonly baseViewHeight;
62
71
  private viewHeight;
63
72
  private sceneCamera;
64
73
  private renderSort;
65
74
  private sceneProjection;
66
75
  private lastTime;
67
76
  private runtimeBridge;
77
+ /** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
78
+ private sceneCatalog;
79
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
80
+ private liveSceneName;
81
+ /** True for the whole extent of a runFrame() call, incl. its tail. */
82
+ private insideFrame;
83
+ /** A loadSceneByName() enqueued while insideFrame; applied at the next runFrame's start. */
84
+ private pendingSceneLoad;
68
85
  constructor(options: GameOptions);
69
86
  /** Creates a live entity in the scene. */
70
87
  spawn(name: string): Entity;
@@ -72,6 +89,35 @@ export declare class Game {
72
89
  spawnPrefab(prefab: string, options?: SpawnPrefabOptions): Entity | null;
73
90
  /** Finds an entity by name. */
74
91
  find(name: string): Entity | undefined;
92
+ /**
93
+ * Destroys the live scene — every entity (Entity.destroy(), so onDestroy
94
+ * cascades and GPU resources release) and its scene-scoped UI — and
95
+ * leaves the Game as newly constructed: no registry, no scene camera, no
96
+ * render sort or projection, viewHeight back to the constructor's.
97
+ * Session-scoped state (stats, paramOverrides, subscriptions, the scene
98
+ * catalog) is untouched. See ADR 0011. Public: the seam `loadScene` calls
99
+ * to replace a scene, and how a host leaves the Game with none loaded.
100
+ */
101
+ unloadScene(): void;
102
+ /** Registers the Project's scenes by name, resolved by loadSceneByName. */
103
+ registerSceneCatalog(catalog: SceneCatalog): void;
104
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
105
+ get sceneName(): string | null;
106
+ /** Names registered via registerSceneCatalog, in registration order. */
107
+ get availableScenes(): string[];
108
+ /**
109
+ * Resolves `name` through the registered catalog and loads it, replacing
110
+ * the live scene. An unknown name warns and leaves the live scene
111
+ * untouched. Triggered mid-frame (e.g. from a SceneTransition's
112
+ * onCollide/onInteract) the swap is deferred to the very start of the
113
+ * next runFrame — dispatchCollisions finishes its double loop over the
114
+ * outgoing scene, and the incoming scene's entities are present only
115
+ * from the next frame. Called from outside a frame (boot, or the Runtime
116
+ * Bridge's `scene` control operation) it applies synchronously and wins
117
+ * over anything queued earlier this frame. A second mid-frame request
118
+ * loses to the first and says so. Returns whether the load took effect.
119
+ */
120
+ loadSceneByName(name: string): boolean;
75
121
  /** Loads persisted parameter overrides (waica.params.json). */
76
122
  loadParams(url: string): Promise<void>;
77
123
  /** Applies persisted overrides to a freshly added component. */
@@ -102,6 +148,7 @@ export declare class Game {
102
148
  private resumeRuntime;
103
149
  private tick;
104
150
  private runFrame;
151
+ private flushPendingSceneLoad;
105
152
  private unregisterRuntimeBridge;
106
153
  /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
107
154
  private applyYSort;
package/dist/game.js CHANGED
@@ -6,11 +6,12 @@ import { Hitbox } from './components/hitbox.js';
6
6
  import { Entity } from './entity.js';
7
7
  import { Emitter } from './events.js';
8
8
  import { Input } from './input.js';
9
+ import { Pointer } from './pointer.js';
9
10
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
10
11
  import { RuntimeInspector } from './runtime-inspection.js';
11
12
  import { projectIsometric } from './projection.js';
12
13
  import { isYSortParticipant, ySortZ } from './render-sort.js';
13
- import { registryEntry, spawnFromJson } from './scene.js';
14
+ import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
14
15
  import { Stats } from './stats.js';
15
16
  import { GameUi } from './ui.js';
16
17
  /**
@@ -21,6 +22,7 @@ export class Game {
21
22
  scene = new THREE.Scene();
22
23
  camera;
23
24
  input;
25
+ pointer;
24
26
  entities = [];
25
27
  events = new Emitter();
26
28
  stats;
@@ -40,14 +42,25 @@ export class Game {
40
42
  updateFns = new Set();
41
43
  invalidUpdateCompositions = new WeakMap();
42
44
  resolution;
45
+ /** The constructor's viewHeight — unloadScene() restores it. */
46
+ baseViewHeight;
43
47
  viewHeight;
44
48
  sceneCamera = null;
45
49
  renderSort = null;
46
50
  sceneProjection = null;
47
51
  lastTime = 0;
48
52
  runtimeBridge = null;
53
+ /** Host-registered scenes by name, resolved by loadSceneByName. Session-scoped. */
54
+ sceneCatalog = null;
55
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
56
+ liveSceneName = null;
57
+ /** True for the whole extent of a runFrame() call, incl. its tail. */
58
+ insideFrame = false;
59
+ /** A loadSceneByName() enqueued while insideFrame; applied at the next runFrame's start. */
60
+ pendingSceneLoad = null;
49
61
  constructor(options) {
50
62
  const { canvas, background = 0x1a1a2e, viewHeight = 10 } = options;
63
+ this.baseViewHeight = viewHeight;
51
64
  this.viewHeight = viewHeight;
52
65
  this.resolution = options.resolution ?? null;
53
66
  this.input = new Input(options.bindings);
@@ -58,6 +71,12 @@ export class Game {
58
71
  this.scene.background = new THREE.Color(background);
59
72
  this.camera = new THREE.OrthographicCamera();
60
73
  this.camera.position.z = 10;
74
+ this.pointer = new Pointer(canvas, {
75
+ camera: this.camera,
76
+ resolution: this.resolution,
77
+ projection: () => this.sceneProjection,
78
+ entities: this.entities,
79
+ });
61
80
  this.resize();
62
81
  this.resizeObserver = new ResizeObserver(() => this.resize());
63
82
  this.resizeObserver.observe(canvas);
@@ -89,6 +108,84 @@ export class Game {
89
108
  find(name) {
90
109
  return this.entities.find((e) => e.name === name);
91
110
  }
111
+ /**
112
+ * Destroys the live scene — every entity (Entity.destroy(), so onDestroy
113
+ * cascades and GPU resources release) and its scene-scoped UI — and
114
+ * leaves the Game as newly constructed: no registry, no scene camera, no
115
+ * render sort or projection, viewHeight back to the constructor's.
116
+ * Session-scoped state (stats, paramOverrides, subscriptions, the scene
117
+ * catalog) is untouched. See ADR 0011. Public: the seam `loadScene` calls
118
+ * to replace a scene, and how a host leaves the Game with none loaded.
119
+ */
120
+ unloadScene() {
121
+ this.ui.unloadScene();
122
+ // An explicit unload means "no scene": a swap queued earlier this frame
123
+ // would otherwise flush next frame and resurrect one.
124
+ this.pendingSceneLoad = null;
125
+ // Entity.destroy() splices itself out of `this.entities` in place — the
126
+ // Pointer holds that array by reference, so it must never be reassigned.
127
+ for (const entity of [...this.entities])
128
+ entity.destroy();
129
+ this.registry = null;
130
+ this.renderSort = null;
131
+ this.sceneProjection = null;
132
+ this.sceneCamera = null;
133
+ this.liveSceneName = null;
134
+ this.setViewHeight(this.baseViewHeight);
135
+ }
136
+ /** Registers the Project's scenes by name, resolved by loadSceneByName. */
137
+ registerSceneCatalog(catalog) {
138
+ this.sceneCatalog = catalog;
139
+ }
140
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
141
+ get sceneName() {
142
+ return this.liveSceneName;
143
+ }
144
+ /** Names registered via registerSceneCatalog, in registration order. */
145
+ get availableScenes() {
146
+ return this.sceneCatalog ? Object.keys(this.sceneCatalog.scenes) : [];
147
+ }
148
+ /**
149
+ * Resolves `name` through the registered catalog and loads it, replacing
150
+ * the live scene. An unknown name warns and leaves the live scene
151
+ * untouched. Triggered mid-frame (e.g. from a SceneTransition's
152
+ * onCollide/onInteract) the swap is deferred to the very start of the
153
+ * next runFrame — dispatchCollisions finishes its double loop over the
154
+ * outgoing scene, and the incoming scene's entities are present only
155
+ * from the next frame. Called from outside a frame (boot, or the Runtime
156
+ * Bridge's `scene` control operation) it applies synchronously and wins
157
+ * over anything queued earlier this frame. A second mid-frame request
158
+ * loses to the first and says so. Returns whether the load took effect.
159
+ */
160
+ loadSceneByName(name) {
161
+ const catalog = this.sceneCatalog;
162
+ const json = catalog ? registryEntry(catalog.scenes, name) : undefined;
163
+ if (!catalog || !json) {
164
+ console.warn(`[waica] unknown scene: "${name}"`);
165
+ return false;
166
+ }
167
+ const apply = () => {
168
+ loadScene(this, json, catalog.registry);
169
+ this.liveSceneName = name;
170
+ };
171
+ if (!this.insideFrame) {
172
+ // Authoritative: dropping the queue is the point. A swap a transition
173
+ // enqueued earlier would otherwise flush on the next frame and silently
174
+ // undo this load, reporting success for a scene the caller never got.
175
+ this.pendingSceneLoad = null;
176
+ apply();
177
+ return true;
178
+ }
179
+ if (this.pendingSceneLoad) {
180
+ // Two transitions resolving in the same collision dispatch: the one the
181
+ // simulation reached first wins, and the loser is told. Last-write-wins
182
+ // would drop the player in the other door's destination with no signal.
183
+ console.warn(`[waica] a scene swap is already queued this frame; ignoring "${name}"`);
184
+ return false;
185
+ }
186
+ this.pendingSceneLoad = apply;
187
+ return true;
188
+ }
92
189
  /** Loads persisted parameter overrides (waica.params.json). */
93
190
  async loadParams(url) {
94
191
  try {
@@ -162,6 +259,11 @@ export class Game {
162
259
  availableActions: () => this.input.availableActions(),
163
260
  heldActions: () => this.input.heldActions(),
164
261
  inspect: (metadata, filters) => inspector.snapshot(metadata, filters),
262
+ click: (x, y) => {
263
+ this.pointer.injectClick(x, y);
264
+ },
265
+ loadScene: (name) => this.loadSceneByName(name),
266
+ availableScenes: () => this.availableScenes,
165
267
  });
166
268
  activation.register(this.runtimeBridge);
167
269
  window.addEventListener('pagehide', this.unregisterRuntimeBridge);
@@ -197,6 +299,7 @@ export class Game {
197
299
  this.stop();
198
300
  this.unregisterRuntimeBridge();
199
301
  this.input.dispose();
302
+ this.pointer.dispose();
200
303
  this.resizeObserver.disconnect();
201
304
  this.ui.dispose();
202
305
  for (const entity of [...this.entities])
@@ -218,23 +321,40 @@ export class Game {
218
321
  this.runFrame(dt);
219
322
  }
220
323
  runFrame(dt) {
221
- if (this.simulate) {
222
- for (const entity of [...this.entities]) {
223
- const schedule = this.componentUpdateSchedule(entity);
224
- if (!schedule)
225
- continue;
226
- for (const component of schedule)
227
- component.onUpdate?.(dt);
324
+ this.insideFrame = true;
325
+ try {
326
+ // Flushes a scene swap enqueued mid-frame last time (CA-7): applied
327
+ // before this frame's own simulation, so the incoming scene's
328
+ // entities are present only from this next frame onward.
329
+ this.flushPendingSceneLoad();
330
+ if (this.simulate) {
331
+ for (const entity of [...this.entities]) {
332
+ const schedule = this.componentUpdateSchedule(entity);
333
+ if (!schedule)
334
+ continue;
335
+ for (const component of schedule)
336
+ component.onUpdate?.(dt);
337
+ }
338
+ this.dispatchCollisions();
339
+ this.updateSceneCamera(dt);
228
340
  }
229
- this.dispatchCollisions();
230
- this.updateSceneCamera(dt);
341
+ // The UI must react to the pause itself (hide until resumed).
342
+ this.ui.setActive(this.simulate);
343
+ for (const fn of this.updateFns)
344
+ fn(dt);
345
+ this.input.endFrame();
346
+ this.renderSurface();
231
347
  }
232
- // The UI must react to the pause itself (hide until resumed).
233
- this.ui.setActive(this.simulate);
234
- for (const fn of this.updateFns)
235
- fn(dt);
236
- this.input.endFrame();
237
- this.renderSurface();
348
+ finally {
349
+ this.insideFrame = false;
350
+ }
351
+ }
352
+ flushPendingSceneLoad() {
353
+ const pending = this.pendingSceneLoad;
354
+ if (!pending)
355
+ return;
356
+ this.pendingSceneLoad = null;
357
+ pending();
238
358
  }
239
359
  unregisterRuntimeBridge = () => {
240
360
  window.removeEventListener('pagehide', this.unregisterRuntimeBridge);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Game } from './game.js';
2
- export type { GameOptions, GameResolution, SpawnPrefabOptions, UpdateFn, ParamOverrides, } from './game.js';
2
+ export type { GameOptions, GameResolution, SceneCatalog, SpawnPrefabOptions, UpdateFn, ParamOverrides, } from './game.js';
3
3
  export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
4
4
  export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
5
5
  export { isYSortParticipant, ySortZ } from './render-sort.js';
@@ -20,7 +20,9 @@ export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
20
20
  export type { ComponentUpdateCycleIssue, ComponentUpdateRegistry, ComponentUpdateScheduleIssue, ComponentUpdateScheduleResult, DuplicateComponentUpdateIssue, InvalidComponentUpdateConstraintIssue, InvalidComponentUpdateSchedule, InvalidUpdateConstraintReason, ValidComponentUpdateSchedule, } from './component-update-schedule.js';
21
21
  export { Input, DEFAULT_BINDINGS } from './input.js';
22
22
  export type { ActionName, InjectedActionOperation, InputBindings } from './input.js';
23
- export { RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
23
+ export { Pointer } from './pointer.js';
24
+ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from './pointer.js';
25
+ export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
24
26
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
25
27
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
26
28
  export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@ export { authoringDefaults } from './authoring-defaults.js';
10
10
  export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
11
11
  export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
12
12
  export { Input, DEFAULT_BINDINGS } from './input.js';
13
- export { RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
13
+ export { Pointer } from './pointer.js';
14
+ export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
14
15
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
15
16
  export { Stats } from './stats.js';
16
17
  export { GameUi } from './ui.js';
@@ -0,0 +1,71 @@
1
+ import type { Entity } from './entity.js';
2
+ import { type ProjectedPoint } from './projection.js';
3
+ /** Fixed-resolution letterbox target; a duck-typed subset of GameResolution. */
4
+ export interface PointerResolution {
5
+ width: number;
6
+ height: number;
7
+ }
8
+ /** The orthographic camera state a Pointer needs; a duck-typed THREE.OrthographicCamera. */
9
+ export interface PointerCamera {
10
+ position: {
11
+ x: number;
12
+ y: number;
13
+ };
14
+ left: number;
15
+ right: number;
16
+ top: number;
17
+ bottom: number;
18
+ }
19
+ export interface PointerDeps {
20
+ camera: PointerCamera;
21
+ /** Fixed resolution (letterbox target), or null to fill the canvas. */
22
+ resolution: PointerResolution | null;
23
+ /** The scene's active render projection, read live. */
24
+ projection: () => 'isometric' | null;
25
+ /** Live entity list — read at pick time, so later spawns are visible. */
26
+ entities: readonly Entity[];
27
+ }
28
+ /** One resolved click: the logical-space point, plus the entity picked there (if any). */
29
+ export interface PointerPick {
30
+ readonly point: ProjectedPoint;
31
+ readonly entity: Entity | null;
32
+ }
33
+ /**
34
+ * Engine-owned pointer primitive (ADR-0010): the only place a click/tap on
35
+ * the game canvas is read. Converts a real `pointerdown` into a logical-space
36
+ * point (camera, letterbox, isometric unprojection) plus the entity picked
37
+ * there — resolved against declared sprite bounds (width/height/offset/anchor
38
+ * at the entity's projected position), y-sort breaking ties front-most wins —
39
+ * and queues it as one pending click for a consumer (ClickToMove) to drain.
40
+ * The Runtime Bridge injects a click through the same resolution, in logical
41
+ * coordinates, via injectClick.
42
+ */
43
+ export declare class Pointer {
44
+ private readonly canvas;
45
+ private readonly deps;
46
+ private pending;
47
+ constructor(canvas: HTMLCanvasElement, deps: PointerDeps);
48
+ dispose(): void;
49
+ /**
50
+ * Consumes and clears the pending click, if any. A consumer (the grid
51
+ * player role's update) drains this once per simulated frame, so a click
52
+ * queued while paused takes effect on the next stepped frame.
53
+ */
54
+ takePending(): PointerPick | null;
55
+ /**
56
+ * Programmatic click injection (Runtime Bridge CA-10), in logical
57
+ * coordinates — resolved through the exact same picking a real click uses.
58
+ */
59
+ injectClick(x: number, y: number): PointerPick;
60
+ private handlePointerDown;
61
+ private letterboxRect;
62
+ private toRenderPoint;
63
+ private resolveRenderPoint;
64
+ /**
65
+ * The entity whose projected sprite bounds contain the render-space point,
66
+ * front-most under y-sort winning ties (ADR-0010, CA-1). Ignores current
67
+ * animation frame and mirroring: picking is scoped to the declared
68
+ * width/height/offset/anchor box, not the momentary displayed frame.
69
+ */
70
+ private pickEntity;
71
+ }
@@ -0,0 +1,162 @@
1
+ import { projectIsometric, unprojectIsometric } from './projection.js';
2
+ import { ySortZ } from './render-sort.js';
3
+ import { AnimatedSprite } from './components/animated-sprite.js';
4
+ import { Sprite } from './components/sprite.js';
5
+ import { spritePlacement } from './sprite-placement.js';
6
+ function spriteBoxOf(component) {
7
+ if (!(component instanceof Sprite) && !(component instanceof AnimatedSprite))
8
+ return null;
9
+ return {
10
+ width: component.width,
11
+ height: component.height,
12
+ offsetX: component.offsetX,
13
+ offsetY: component.offsetY,
14
+ anchorX: component.anchorX,
15
+ anchorY: component.anchorY,
16
+ layer: component.layer,
17
+ };
18
+ }
19
+ /**
20
+ * Engine-owned pointer primitive (ADR-0010): the only place a click/tap on
21
+ * the game canvas is read. Converts a real `pointerdown` into a logical-space
22
+ * point (camera, letterbox, isometric unprojection) plus the entity picked
23
+ * there — resolved against declared sprite bounds (width/height/offset/anchor
24
+ * at the entity's projected position), y-sort breaking ties front-most wins —
25
+ * and queues it as one pending click for a consumer (ClickToMove) to drain.
26
+ * The Runtime Bridge injects a click through the same resolution, in logical
27
+ * coordinates, via injectClick.
28
+ */
29
+ export class Pointer {
30
+ canvas;
31
+ deps;
32
+ pending = null;
33
+ constructor(canvas, deps) {
34
+ this.canvas = canvas;
35
+ this.deps = deps;
36
+ this.canvas.addEventListener('pointerdown', this.handlePointerDown);
37
+ }
38
+ dispose() {
39
+ this.canvas.removeEventListener('pointerdown', this.handlePointerDown);
40
+ this.pending = null;
41
+ }
42
+ /**
43
+ * Consumes and clears the pending click, if any. A consumer (the grid
44
+ * player role's update) drains this once per simulated frame, so a click
45
+ * queued while paused takes effect on the next stepped frame.
46
+ */
47
+ takePending() {
48
+ const pick = this.pending;
49
+ this.pending = null;
50
+ return pick;
51
+ }
52
+ /**
53
+ * Programmatic click injection (Runtime Bridge CA-10), in logical
54
+ * coordinates — resolved through the exact same picking a real click uses.
55
+ */
56
+ injectClick(x, y) {
57
+ const renderPoint = this.toRenderPoint(x, y);
58
+ const pick = this.resolveRenderPoint(renderPoint.x, renderPoint.y);
59
+ this.pending = pick;
60
+ return pick;
61
+ }
62
+ handlePointerDown = (event) => {
63
+ // Primary button only; a touch tap reports the same button (ADR-0010).
64
+ if (event.button !== 0)
65
+ return;
66
+ const w = this.canvas.clientWidth;
67
+ const h = this.canvas.clientHeight;
68
+ if (w <= 0 || h <= 0)
69
+ return;
70
+ const { vx, vy, vw, vh } = this.letterboxRect(w, h);
71
+ if (vw <= 0 || vh <= 0)
72
+ return;
73
+ const rect = this.canvas.getBoundingClientRect();
74
+ const px = event.clientX - rect.left;
75
+ const py = event.clientY - rect.top;
76
+ const nx = (px - vx) / vw;
77
+ const ny = (py - vy) / vh;
78
+ // Outside the visible viewport (a letterbox bar): not a click on the world.
79
+ if (nx < 0 || nx > 1 || ny < 0 || ny > 1)
80
+ return;
81
+ const camera = this.deps.camera;
82
+ const renderX = camera.position.x + camera.left + nx * (camera.right - camera.left);
83
+ const renderY = camera.position.y + camera.top - ny * (camera.top - camera.bottom);
84
+ this.pending = this.resolveRenderPoint(renderX, renderY);
85
+ };
86
+ letterboxRect(w, h) {
87
+ const resolution = this.deps.resolution;
88
+ if (!resolution)
89
+ return { vx: 0, vy: 0, vw: w, vh: h };
90
+ const aspect = resolution.width / resolution.height;
91
+ const vw = Math.min(w, h * aspect);
92
+ const vh = vw / aspect;
93
+ return { vx: (w - vw) / 2, vy: (h - vh) / 2, vw, vh };
94
+ }
95
+ toRenderPoint(logicalX, logicalY) {
96
+ return this.deps.projection() === 'isometric'
97
+ ? projectIsometric(logicalX, logicalY)
98
+ : { x: logicalX, y: logicalY };
99
+ }
100
+ resolveRenderPoint(renderX, renderY) {
101
+ const point = this.deps.projection() === 'isometric'
102
+ ? unprojectIsometric(renderX, renderY)
103
+ : { x: renderX, y: renderY };
104
+ return { point, entity: this.pickEntity(renderX, renderY) };
105
+ }
106
+ /**
107
+ * The entity whose projected sprite bounds contain the render-space point,
108
+ * front-most under y-sort winning ties (ADR-0010, CA-1). Ignores current
109
+ * animation frame and mirroring: picking is scoped to the declared
110
+ * width/height/offset/anchor box, not the momentary displayed frame.
111
+ */
112
+ pickEntity(renderX, renderY) {
113
+ const hits = [];
114
+ for (const entity of this.deps.entities) {
115
+ if (!entity.alive)
116
+ continue;
117
+ for (const component of entity.components) {
118
+ const box = spriteBoxOf(component);
119
+ if (!box)
120
+ continue;
121
+ const anchor = this.deps.projection() === 'isometric'
122
+ ? projectIsometric(entity.position.x, entity.position.y)
123
+ : { x: entity.position.x, y: entity.position.y };
124
+ const placement = spritePlacement({
125
+ width: box.width,
126
+ height: box.height,
127
+ offsetX: box.offsetX,
128
+ offsetY: box.offsetY,
129
+ anchorX: box.anchorX,
130
+ anchorY: box.anchorY,
131
+ flipX: false,
132
+ frameScaleX: 1,
133
+ frameScaleY: 1,
134
+ });
135
+ const halfW = Math.abs(placement.scaleX) / 2;
136
+ const halfH = Math.abs(placement.scaleY) / 2;
137
+ const centerX = anchor.x + placement.x;
138
+ const centerY = anchor.y + placement.y;
139
+ if (renderX >= centerX - halfW &&
140
+ renderX <= centerX + halfW &&
141
+ renderY >= centerY - halfH &&
142
+ renderY <= centerY + halfH) {
143
+ // Matches Game.applyYSort's key exactly: the entity's node
144
+ // position, which for an isometric scene is the projected Y (the
145
+ // same `anchor.y` just used to place this box), not the logical one.
146
+ hits.push({ entity, layer: box.layer, y: anchor.y });
147
+ break;
148
+ }
149
+ }
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;
161
+ }
162
+ }
@@ -2,12 +2,23 @@ import type { RuntimeSnapshot, RuntimeSnapshotFilters } from './runtime-inspecti
2
2
  export declare const RUNTIME_BRIDGE_PROTOCOL_VERSION: 1;
3
3
  export declare const RUNTIME_BRIDGE_SYMBOL: unique symbol;
4
4
  export type RuntimeMode = 'paused' | 'real-time';
5
+ /**
6
+ * Operations this build's control() actually implements, beyond the
7
+ * baseline protocol 1 set (press/hold/release/pause/resume/step) every
8
+ * bridge has always supported. Additive metadata, not a protocol bump: a
9
+ * pre-CA-10 engine simply lacks this field, which is exactly what callers
10
+ * gating on capabilities check for (review finding #4) — protocol 1 alone
11
+ * doesn't distinguish an engine that silently no-ops an unknown operation
12
+ * from one that runs it.
13
+ */
14
+ export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene'];
5
15
  export interface RuntimeMetadata {
6
16
  bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
7
17
  engineVersion: string;
8
18
  mode: RuntimeMode;
9
19
  frame: number;
10
20
  simulationTime: number;
21
+ capabilities: readonly string[];
11
22
  }
12
23
  export type RuntimeControlRequest = {
13
24
  operation: 'press' | 'hold' | 'release';
@@ -18,6 +29,13 @@ export type RuntimeControlRequest = {
18
29
  operation: 'step';
19
30
  dt?: number;
20
31
  frames?: number;
32
+ } | {
33
+ operation: 'click';
34
+ x: number;
35
+ y: number;
36
+ } | {
37
+ operation: 'scene';
38
+ scene: string;
21
39
  };
22
40
  export interface RuntimeControlResult extends RuntimeMetadata {
23
41
  heldActions: string[];
@@ -25,8 +43,9 @@ export interface RuntimeControlResult extends RuntimeMetadata {
25
43
  export declare class RuntimeBridgeOperationError extends Error {
26
44
  readonly code: 'runtime-invalid-state' | 'runtime-operation-failed';
27
45
  readonly availableActions?: string[] | undefined;
46
+ readonly availableScenes?: string[] | undefined;
28
47
  readonly stage: 'control';
29
- constructor(code: 'runtime-invalid-state' | 'runtime-operation-failed', message: string, availableActions?: string[] | undefined);
48
+ constructor(code: 'runtime-invalid-state' | 'runtime-operation-failed', message: string, availableActions?: string[] | undefined, availableScenes?: string[] | undefined);
30
49
  }
31
50
  /** Engine-owned capability registered only in an MCP-activated page. */
32
51
  export interface RuntimeBridge {
@@ -50,6 +69,10 @@ export interface RuntimeBridgeHost {
50
69
  availableActions(): string[];
51
70
  heldActions(): string[];
52
71
  inspect(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
72
+ click(x: number, y: number): void;
73
+ /** Resolves `name` through the registered catalog and loads it. */
74
+ loadScene(name: string): boolean;
75
+ availableScenes(): string[];
53
76
  }
54
77
  export declare class EngineRuntimeBridge implements RuntimeBridge {
55
78
  readonly surface: HTMLCanvasElement;
@@ -1,14 +1,26 @@
1
1
  import enginePackage from '../package.json' with { type: 'json' };
2
2
  export const RUNTIME_BRIDGE_PROTOCOL_VERSION = 1;
3
3
  export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
4
+ /**
5
+ * Operations this build's control() actually implements, beyond the
6
+ * baseline protocol 1 set (press/hold/release/pause/resume/step) every
7
+ * bridge has always supported. Additive metadata, not a protocol bump: a
8
+ * pre-CA-10 engine simply lacks this field, which is exactly what callers
9
+ * gating on capabilities check for (review finding #4) — protocol 1 alone
10
+ * doesn't distinguish an engine that silently no-ops an unknown operation
11
+ * from one that runs it.
12
+ */
13
+ export const RUNTIME_BRIDGE_CAPABILITIES = ['click', 'scene'];
4
14
  export class RuntimeBridgeOperationError extends Error {
5
15
  code;
6
16
  availableActions;
17
+ availableScenes;
7
18
  stage = 'control';
8
- constructor(code, message, availableActions) {
19
+ constructor(code, message, availableActions, availableScenes) {
9
20
  super(message);
10
21
  this.code = code;
11
22
  this.availableActions = availableActions;
23
+ this.availableScenes = availableScenes;
12
24
  this.name = 'RuntimeBridgeOperationError';
13
25
  }
14
26
  }
@@ -45,6 +57,7 @@ export class EngineRuntimeBridge {
45
57
  mode: this.mode,
46
58
  frame: this.frame,
47
59
  simulationTime: this.simulationTime,
60
+ capabilities: RUNTIME_BRIDGE_CAPABILITIES,
48
61
  };
49
62
  }
50
63
  inspect(filters = {}) {
@@ -88,6 +101,24 @@ export class EngineRuntimeBridge {
88
101
  this.advance(dt);
89
102
  break;
90
103
  }
104
+ case 'click': {
105
+ if (!Number.isFinite(request.x) || !Number.isFinite(request.y)) {
106
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', 'x and y must be finite numbers.');
107
+ }
108
+ this.host.click(request.x, request.y);
109
+ break;
110
+ }
111
+ case 'scene': {
112
+ if (!this.host.loadScene(request.scene)) {
113
+ const available = this.host.availableScenes();
114
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unknown scene "${request.scene}". Available scenes: ${available.join(', ') || '(none)'}.`, undefined, available);
115
+ }
116
+ break;
117
+ }
118
+ default: {
119
+ const unsupported = request;
120
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unsupported runtime control operation "${unsupported.operation}".`);
121
+ }
91
122
  }
92
123
  return { ...this.metadata(), heldActions: this.host.heldActions() };
93
124
  }
@@ -50,6 +50,8 @@ export interface RuntimeEntitySnapshot {
50
50
  }
51
51
  export interface RuntimeSnapshot extends RuntimeMetadata {
52
52
  stats: Record<string, StatValue>;
53
+ /** The live scene's name (its catalog key), or null with no scene loaded. */
54
+ scene: string | null;
53
55
  entities: RuntimeEntitySnapshot[];
54
56
  projectionIssues: ProjectionIssue[];
55
57
  }
@@ -234,6 +234,7 @@ export class RuntimeInspector {
234
234
  return this.capSnapshot({
235
235
  ...metadata,
236
236
  stats: Object.fromEntries([...this.game.stats.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
237
+ scene: this.game.sceneName,
237
238
  entities,
238
239
  projectionIssues,
239
240
  });
package/dist/scene.d.ts CHANGED
@@ -88,5 +88,9 @@ export declare function resolveProps(props: Record<string, unknown> | undefined,
88
88
  export declare function resolveEntityComponents(entity: SceneEntityJson, prefabs?: Record<string, PrefabJson>): SceneComponentJson[];
89
89
  /** Instantiates a scene entity into the game. */
90
90
  export declare function spawnFromJson(game: Game, json: SceneEntityJson, registry: SceneRegistry): Entity;
91
- /** Loads a full scene into the game. */
91
+ /**
92
+ * Loads a full scene into the game, replacing whatever scene is already
93
+ * live (Game.unloadScene() first) — see ADR 0011. A Game shows one scene
94
+ * at a time.
95
+ */
92
96
  export declare function loadScene(game: Game, scene: SceneJson, registry: SceneRegistry): void;
package/dist/scene.js CHANGED
@@ -79,8 +79,14 @@ export function spawnFromJson(game, json, registry) {
79
79
  }
80
80
  return entity;
81
81
  }
82
- /** Loads a full scene into the game. */
82
+ /**
83
+ * Loads a full scene into the game, replacing whatever scene is already
84
+ * live (Game.unloadScene() first) — see ADR 0011. A Game shows one scene
85
+ * at a time.
86
+ */
83
87
  export function loadScene(game, scene, registry) {
88
+ if (game.registry)
89
+ game.unloadScene();
84
90
  game.registry = registry;
85
91
  game.setSceneRender(scene.render);
86
92
  for (const entityJson of scene.entities)
@@ -89,6 +95,7 @@ export function loadScene(game, scene, registry) {
89
95
  game.setSceneCamera(scene.camera);
90
96
  if (registry.ui)
91
97
  game.ui.defineAll(registry.ui);
98
+ // Scene-scoped: Game.unloadScene() unmounts these along with the entities.
92
99
  for (const name of scene.ui ?? [])
93
- game.ui.show(name);
100
+ game.ui.show(name, { scope: 'scene' });
94
101
  }
package/dist/ui.d.ts CHANGED
@@ -27,7 +27,7 @@ export declare class GameUi {
27
27
  defineAll(pieces: Record<string, string>): void;
28
28
  /** Piece names available to show (defined via the registry or define()). */
29
29
  names(): string[];
30
- show(name: string): void;
30
+ show(name: string, options?: ShowOptions): void;
31
31
  hide(name: string): void;
32
32
  toggle(name: string): void;
33
33
  isVisible(name: string): boolean;
@@ -41,7 +41,18 @@ export declare class GameUi {
41
41
  setActive(active: boolean): void;
42
42
  /** Unmounts every piece and removes the overlay (Game.dispose). */
43
43
  dispose(): void;
44
+ /**
45
+ * Unmounts every scene-scoped piece: the ones `loadScene` showed from the
46
+ * outgoing scene's `ui` list, plus any shown with `{ scope: 'scene' }`.
47
+ * A piece the host showed with no scope is untouched. The definition
48
+ * catalog (sources) always survives — Game.unloadScene.
49
+ */
50
+ unloadScene(): void;
44
51
  private mount;
45
52
  private mountOverlay;
46
53
  private sync;
47
54
  }
55
+ export interface ShowOptions {
56
+ /** 'scene': unmounted by Game.unloadScene() along with the rest of the scene. */
57
+ scope?: 'scene';
58
+ }
package/dist/ui.js CHANGED
@@ -35,10 +35,19 @@ export class GameUi {
35
35
  names() {
36
36
  return [...this.sources.keys()];
37
37
  }
38
- show(name) {
38
+ show(name, options = {}) {
39
+ const mounted = this.pieces.has(name);
39
40
  const piece = this.mount(name);
40
- if (piece)
41
+ if (piece) {
41
42
  piece.visible = true;
43
+ // Scope belongs to whoever mounts the piece, and a later show never
44
+ // changes it. Otherwise a scene whose `ui` list happens to name a piece
45
+ // the host already mounted would quietly take ownership of it and
46
+ // destroy it on the next unload — a session-scoped HUD dying with a
47
+ // map it merely shares a name with.
48
+ if (!mounted)
49
+ piece.scope = options.scope;
50
+ }
42
51
  this.sync();
43
52
  }
44
53
  hide(name) {
@@ -81,6 +90,23 @@ export class GameUi {
81
90
  this.overlay?.remove();
82
91
  this.overlay = undefined;
83
92
  }
93
+ /**
94
+ * Unmounts every scene-scoped piece: the ones `loadScene` showed from the
95
+ * outgoing scene's `ui` list, plus any shown with `{ scope: 'scene' }`.
96
+ * A piece the host showed with no scope is untouched. The definition
97
+ * catalog (sources) always survives — Game.unloadScene.
98
+ */
99
+ unloadScene() {
100
+ for (const [name, piece] of this.pieces) {
101
+ if (piece.scope !== 'scene')
102
+ continue;
103
+ for (const off of piece.unsubs)
104
+ off();
105
+ piece.shell.remove();
106
+ this.pieces.delete(name);
107
+ }
108
+ this.sync();
109
+ }
84
110
  mount(name) {
85
111
  const existing = this.pieces.get(name);
86
112
  if (existing)
@@ -100,7 +126,13 @@ export class GameUi {
100
126
  root.style.display = 'contents';
101
127
  root.innerHTML = html;
102
128
  shadow.append(root);
103
- const piece = { shell, root, visible: false, unsubs: bindStats(root, this.stats) };
129
+ const piece = {
130
+ shell,
131
+ root,
132
+ visible: false,
133
+ scope: undefined,
134
+ unsubs: bindStats(root, this.stats),
135
+ };
104
136
  this.mountOverlay().append(shell);
105
137
  this.pieces.set(name, piece);
106
138
  return piece;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",