@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.
- package/dist/archetype.d.ts +7 -0
- package/dist/component.d.ts +5 -0
- package/dist/game.d.ts +48 -1
- package/dist/game.js +136 -16
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/pointer.d.ts +71 -0
- package/dist/pointer.js +162 -0
- package/dist/runtime-bridge.d.ts +24 -1
- package/dist/runtime-bridge.js +32 -1
- package/dist/runtime-inspection.d.ts +2 -0
- package/dist/runtime-inspection.js +1 -0
- package/dist/scene.d.ts +5 -1
- package/dist/scene.js +9 -2
- package/dist/ui.d.ts +12 -1
- package/dist/ui.js +35 -3
- package/package.json +1 -1
package/dist/archetype.d.ts
CHANGED
|
@@ -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[];
|
package/dist/component.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
this.
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
this.
|
|
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 {
|
|
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 {
|
|
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
|
+
}
|
package/dist/pointer.js
ADDED
|
@@ -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
|
+
}
|
package/dist/runtime-bridge.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtime-bridge.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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 = {
|
|
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;
|