@waica/engine 0.16.0 → 0.18.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/README.md +53 -0
- package/dist/anchored-pieces.d.ts +122 -0
- package/dist/anchored-pieces.js +330 -0
- package/dist/assets/asset-loader.d.ts +76 -0
- package/dist/assets/asset-loader.js +138 -0
- package/dist/assets/texture-backend.d.ts +17 -0
- package/dist/assets/texture-backend.js +15 -0
- package/dist/component.d.ts +1 -1
- package/dist/components/animated-sprite.d.ts +17 -4
- package/dist/components/animated-sprite.js +41 -12
- package/dist/components/sprite.d.ts +6 -0
- package/dist/components/sprite.js +28 -6
- package/dist/components/tilemap.d.ts +6 -0
- package/dist/components/tilemap.js +28 -4
- package/dist/game.d.ts +16 -1
- package/dist/game.js +27 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1 -0
- package/dist/input.d.ts +6 -0
- package/dist/input.js +8 -0
- package/dist/runtime-bridge.d.ts +11 -2
- package/dist/runtime-bridge.js +7 -2
- package/dist/runtime-inspection.d.ts +26 -0
- package/dist/runtime-inspection.js +50 -15
- package/dist/ui-bindings.d.ts +12 -0
- package/dist/ui-bindings.js +51 -0
- package/dist/ui.d.ts +35 -3
- package/dist/ui.js +54 -45
- package/package.json +1 -1
package/dist/input.js
CHANGED
|
@@ -34,6 +34,14 @@ export class Input {
|
|
|
34
34
|
availableActions() {
|
|
35
35
|
return [...this.bindings.keys()].sort();
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The key codes bound to the action, in their declared order (e.g.
|
|
39
|
+
* `['KeyE', 'Space']`); `[]` for an unknown or unbound action. A new
|
|
40
|
+
* array every call: mutating it never changes the bindings.
|
|
41
|
+
*/
|
|
42
|
+
bindingsFor(action) {
|
|
43
|
+
return [...(this.bindings.get(action) ?? [])];
|
|
44
|
+
}
|
|
37
45
|
/** Currently held semantic action names in deterministic order. */
|
|
38
46
|
heldActions() {
|
|
39
47
|
return this.availableActions().filter((action) => this.held(action));
|
package/dist/runtime-bridge.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AssetStatus } from './assets/asset-loader.js';
|
|
1
2
|
import type { RuntimeSnapshot, RuntimeSnapshotFilters } from './runtime-inspection.js';
|
|
2
3
|
export declare const RUNTIME_BRIDGE_PROTOCOL_VERSION: 1;
|
|
3
4
|
export declare const RUNTIME_BRIDGE_SYMBOL: unique symbol;
|
|
@@ -10,9 +11,13 @@ export type RuntimeMode = 'paused' | 'real-time';
|
|
|
10
11
|
* gating on capabilities check for (review finding #4) — protocol 1 alone
|
|
11
12
|
* doesn't distinguish an engine that silently no-ops an unknown operation
|
|
12
13
|
* from one that runs it. `fixed-step` (ADR 0014) announces that `step`
|
|
13
|
-
* advances whole 1/60 s Simulation Steps and takes no `dt`.
|
|
14
|
+
* advances whole 1/60 s Simulation Steps and takes no `dt`. `assets`
|
|
15
|
+
* (ADR 0019) announces that every metadata carries `assets: { pending,
|
|
16
|
+
* loaded, failed }` from `game.assets.status`, so a Run Session can wait
|
|
17
|
+
* for Assets Ready (`pending === 0`) at readiness, after a `scene`
|
|
18
|
+
* operation and before a screenshot.
|
|
14
19
|
*/
|
|
15
|
-
export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step'];
|
|
20
|
+
export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets'];
|
|
16
21
|
export interface RuntimeMetadata {
|
|
17
22
|
bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
|
|
18
23
|
engineVersion: string;
|
|
@@ -20,6 +25,8 @@ export interface RuntimeMetadata {
|
|
|
20
25
|
frame: number;
|
|
21
26
|
simulationTime: number;
|
|
22
27
|
capabilities: readonly string[];
|
|
28
|
+
/** `game.assets.status` at the moment of the read — reading it never advances `frame`. */
|
|
29
|
+
assets: AssetStatus;
|
|
23
30
|
}
|
|
24
31
|
export type RuntimeControlRequest = {
|
|
25
32
|
operation: 'press' | 'hold' | 'release';
|
|
@@ -81,6 +88,8 @@ export interface RuntimeBridgeHost {
|
|
|
81
88
|
/** Resolves `name` through the registered catalog and loads it. */
|
|
82
89
|
loadScene(name: string): boolean;
|
|
83
90
|
availableScenes(): string[];
|
|
91
|
+
/** A fresh `game.assets.status`. */
|
|
92
|
+
assets(): AssetStatus;
|
|
84
93
|
}
|
|
85
94
|
export declare class EngineRuntimeBridge implements RuntimeBridge {
|
|
86
95
|
readonly surface: HTMLCanvasElement;
|
package/dist/runtime-bridge.js
CHANGED
|
@@ -10,9 +10,13 @@ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
|
|
|
10
10
|
* gating on capabilities check for (review finding #4) — protocol 1 alone
|
|
11
11
|
* doesn't distinguish an engine that silently no-ops an unknown operation
|
|
12
12
|
* from one that runs it. `fixed-step` (ADR 0014) announces that `step`
|
|
13
|
-
* advances whole 1/60 s Simulation Steps and takes no `dt`.
|
|
13
|
+
* advances whole 1/60 s Simulation Steps and takes no `dt`. `assets`
|
|
14
|
+
* (ADR 0019) announces that every metadata carries `assets: { pending,
|
|
15
|
+
* loaded, failed }` from `game.assets.status`, so a Run Session can wait
|
|
16
|
+
* for Assets Ready (`pending === 0`) at readiness, after a `scene`
|
|
17
|
+
* operation and before a screenshot.
|
|
14
18
|
*/
|
|
15
|
-
export const RUNTIME_BRIDGE_CAPABILITIES = ['click', 'scene', 'fixed-step'];
|
|
19
|
+
export const RUNTIME_BRIDGE_CAPABILITIES = ['click', 'scene', 'fixed-step', 'assets'];
|
|
16
20
|
export class RuntimeBridgeOperationError extends Error {
|
|
17
21
|
code;
|
|
18
22
|
availableActions;
|
|
@@ -61,6 +65,7 @@ export class EngineRuntimeBridge {
|
|
|
61
65
|
// Derived, never summed: 60 steps are exactly 1 s, with no float drift.
|
|
62
66
|
simulationTime: this.frame * SIMULATION_STEP,
|
|
63
67
|
capabilities: RUNTIME_BRIDGE_CAPABILITIES,
|
|
68
|
+
assets: this.host.assets(),
|
|
64
69
|
};
|
|
65
70
|
}
|
|
66
71
|
inspect(filters = {}) {
|
|
@@ -82,6 +82,30 @@ export interface RuntimeSnapshotTime {
|
|
|
82
82
|
pending: number;
|
|
83
83
|
nextInSteps: number | null;
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* `game.ui` (issue #72 CA-9), beside `audio` and `time`: `shown` lists the
|
|
87
|
+
* screen pieces whose visibility flag is on, sorted by name; `anchored`
|
|
88
|
+
* lists the live Anchored Pieces in creation order. For each, `entity` is
|
|
89
|
+
* its anchor entity's name (kept for a lingering instance whose entity is
|
|
90
|
+
* gone); `x`/`y` are the whole CSS px of its last placement inside the game
|
|
91
|
+
* viewport — for one attached since the last render frame, where the next
|
|
92
|
+
* frame will place it; `clipped` is true when its anchor point lies outside
|
|
93
|
+
* the game viewport; `values` holds only its own values, after every `set`,
|
|
94
|
+
* bounded like component state (CA-5): sorted by name, a string over 4 KiB
|
|
95
|
+
* becomes a truncated marker, and past 100 values the record does too.
|
|
96
|
+
* Emitted unconditionally, like `audio` and `time` — never filtered.
|
|
97
|
+
*/
|
|
98
|
+
export interface RuntimeSnapshotUi {
|
|
99
|
+
shown: string[];
|
|
100
|
+
anchored: Array<{
|
|
101
|
+
piece: string;
|
|
102
|
+
entity: string;
|
|
103
|
+
x: number;
|
|
104
|
+
y: number;
|
|
105
|
+
clipped: boolean;
|
|
106
|
+
values: Record<string, StatValue | ProjectionMarker> | ProjectionMarker;
|
|
107
|
+
}>;
|
|
108
|
+
}
|
|
85
109
|
export interface RuntimeSnapshot extends RuntimeMetadata {
|
|
86
110
|
stats: Record<string, StatValue>;
|
|
87
111
|
/** The live scene's name (its catalog key), or null with no scene loaded. */
|
|
@@ -90,6 +114,7 @@ export interface RuntimeSnapshot extends RuntimeMetadata {
|
|
|
90
114
|
projectionIssues: ProjectionIssue[];
|
|
91
115
|
audio: RuntimeSnapshotAudio;
|
|
92
116
|
time: RuntimeSnapshotTime;
|
|
117
|
+
ui: RuntimeSnapshotUi;
|
|
93
118
|
}
|
|
94
119
|
export declare const RUNTIME_PROJECTION_LIMITS: {
|
|
95
120
|
readonly depth: 5;
|
|
@@ -106,6 +131,7 @@ export declare class RuntimeInspector {
|
|
|
106
131
|
snapshot(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
|
|
107
132
|
private audioSnapshot;
|
|
108
133
|
private timeSnapshot;
|
|
134
|
+
private uiSnapshot;
|
|
109
135
|
private capSnapshot;
|
|
110
136
|
private idFor;
|
|
111
137
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { anchoredPiecesOf } from './ui.js';
|
|
1
2
|
export const RUNTIME_PROJECTION_LIMITS = {
|
|
2
3
|
depth: 5,
|
|
3
4
|
entries: 100,
|
|
@@ -176,6 +177,37 @@ function boundedComponentState(component, path, context) {
|
|
|
176
177
|
path,
|
|
177
178
|
});
|
|
178
179
|
}
|
|
180
|
+
function fitsSnapshot(snapshot) {
|
|
181
|
+
return utf8Bytes(JSON.stringify(snapshot)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes;
|
|
182
|
+
}
|
|
183
|
+
/** The index of the `ui.anchored` instance an issue path points into, or null. */
|
|
184
|
+
function anchoredIndex(path) {
|
|
185
|
+
const match = /^ui\.anchored\[(\d+)\]/.exec(path);
|
|
186
|
+
return match ? Number(match[1]) : null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The global cap's second stage, once every entity is gone: drops
|
|
190
|
+
* `ui.anchored` instances from the end, with their projection issues,
|
|
191
|
+
* until the snapshot fits, recording how many went.
|
|
192
|
+
*/
|
|
193
|
+
function capAnchored(snapshot) {
|
|
194
|
+
let capped = snapshot;
|
|
195
|
+
const retained = [...snapshot.ui.anchored];
|
|
196
|
+
while (retained.length > 0) {
|
|
197
|
+
retained.pop();
|
|
198
|
+
const omitted = snapshot.ui.anchored.length - retained.length;
|
|
199
|
+
const projectionIssues = snapshot.projectionIssues
|
|
200
|
+
.filter((issue) => {
|
|
201
|
+
const index = anchoredIndex(issue.path);
|
|
202
|
+
return index === null || index < retained.length;
|
|
203
|
+
})
|
|
204
|
+
.concat({ path: `ui.anchored[${retained.length}]`, marker: 'truncated', omitted });
|
|
205
|
+
capped = { ...snapshot, ui: { ...snapshot.ui, anchored: retained }, projectionIssues };
|
|
206
|
+
if (fitsSnapshot(capped))
|
|
207
|
+
return capped;
|
|
208
|
+
}
|
|
209
|
+
return capped;
|
|
210
|
+
}
|
|
179
211
|
export class RuntimeInspector {
|
|
180
212
|
game;
|
|
181
213
|
ids = new WeakMap();
|
|
@@ -239,6 +271,7 @@ export class RuntimeInspector {
|
|
|
239
271
|
projectionIssues,
|
|
240
272
|
audio: this.audioSnapshot(),
|
|
241
273
|
time: this.timeSnapshot(),
|
|
274
|
+
ui: this.uiSnapshot(projectionIssues),
|
|
242
275
|
});
|
|
243
276
|
}
|
|
244
277
|
audioSnapshot() {
|
|
@@ -258,10 +291,21 @@ export class RuntimeInspector {
|
|
|
258
291
|
nextInSteps: this.game.time.nextInSteps,
|
|
259
292
|
};
|
|
260
293
|
}
|
|
294
|
+
uiSnapshot(issues) {
|
|
295
|
+
const ui = this.game.ui;
|
|
296
|
+
return {
|
|
297
|
+
shown: ui.names().filter((name) => ui.isVisible(name)).sort(),
|
|
298
|
+
anchored: anchoredPiecesOf(ui).snapshot().map((instance, index) => ({
|
|
299
|
+
...instance,
|
|
300
|
+
// A record of StatValues projects to one of these, never to anything else.
|
|
301
|
+
values: projectValue(instance.values, `ui.anchored[${index}].values`, { issues, seen: new Map() }),
|
|
302
|
+
})),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
261
305
|
capSnapshot(snapshot) {
|
|
262
|
-
if (
|
|
306
|
+
if (fitsSnapshot(snapshot))
|
|
263
307
|
return snapshot;
|
|
264
|
-
|
|
308
|
+
let capped = snapshot;
|
|
265
309
|
const retained = [...snapshot.entities];
|
|
266
310
|
const removedIds = new Set();
|
|
267
311
|
while (retained.length > 0) {
|
|
@@ -272,20 +316,11 @@ export class RuntimeInspector {
|
|
|
272
316
|
const projectionIssues = snapshot.projectionIssues
|
|
273
317
|
.filter((issue) => [...removedIds].every((id) => !issue.path.startsWith(`entities[${id}]`)))
|
|
274
318
|
.concat({ path: `entities[${retained.length}]`, marker: 'truncated', omitted });
|
|
275
|
-
|
|
276
|
-
if (
|
|
277
|
-
return
|
|
278
|
-
}
|
|
319
|
+
capped = { ...snapshot, entities: retained, projectionIssues };
|
|
320
|
+
if (fitsSnapshot(capped))
|
|
321
|
+
return capped;
|
|
279
322
|
}
|
|
280
|
-
return
|
|
281
|
-
...snapshot,
|
|
282
|
-
entities: [],
|
|
283
|
-
projectionIssues: [{
|
|
284
|
-
path: 'entities[0]',
|
|
285
|
-
marker: 'truncated',
|
|
286
|
-
omitted: snapshot.entities.length,
|
|
287
|
-
}],
|
|
288
|
-
};
|
|
323
|
+
return capAnchored(capped);
|
|
289
324
|
}
|
|
290
325
|
idFor(entity) {
|
|
291
326
|
const existing = this.ids.get(entity);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StatValue } from './stats.js';
|
|
2
|
+
/** How a bound value reads in a piece: booleans as ✓/✕, missing as empty. */
|
|
3
|
+
export declare function renderStat(value: StatValue | undefined): string;
|
|
4
|
+
/**
|
|
5
|
+
* Splits every {{name}} placeholder in the fragment's text into its own
|
|
6
|
+
* (empty) text node and returns those nodes with the names they bind, in
|
|
7
|
+
* document order — the caller fills and keeps them in sync. Text-only by
|
|
8
|
+
* design: the binding language has no expressions — presentation, never
|
|
9
|
+
* logic. Shared by screen pieces (Game stats) and Anchored Pieces (their
|
|
10
|
+
* own values first, then the Game stats).
|
|
11
|
+
*/
|
|
12
|
+
export declare function placeholders(root: HTMLElement): Array<[name: string, text: Text]>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const BINDING = /\{\{\s*([\w-]+)\s*\}\}/g;
|
|
2
|
+
/** How a bound value reads in a piece: booleans as ✓/✕, missing as empty. */
|
|
3
|
+
export function renderStat(value) {
|
|
4
|
+
if (value === undefined)
|
|
5
|
+
return '';
|
|
6
|
+
if (typeof value === 'boolean')
|
|
7
|
+
return value ? '✓' : '✕';
|
|
8
|
+
return String(value);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Splits every {{name}} placeholder in the fragment's text into its own
|
|
12
|
+
* (empty) text node and returns those nodes with the names they bind, in
|
|
13
|
+
* document order — the caller fills and keeps them in sync. Text-only by
|
|
14
|
+
* design: the binding language has no expressions — presentation, never
|
|
15
|
+
* logic. Shared by screen pieces (Game stats) and Anchored Pieces (their
|
|
16
|
+
* own values first, then the Game stats).
|
|
17
|
+
*/
|
|
18
|
+
export function placeholders(root) {
|
|
19
|
+
const bound = [];
|
|
20
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
21
|
+
const targets = [];
|
|
22
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
23
|
+
// Braces inside <style>/<script> are CSS/code, not bindings.
|
|
24
|
+
if (node.parentElement?.closest('style, script'))
|
|
25
|
+
continue;
|
|
26
|
+
if ((node.nodeValue ?? '').includes('{{'))
|
|
27
|
+
targets.push(node);
|
|
28
|
+
}
|
|
29
|
+
for (const text of targets) {
|
|
30
|
+
const source = text.nodeValue ?? '';
|
|
31
|
+
const parts = [];
|
|
32
|
+
let last = 0;
|
|
33
|
+
for (const match of source.matchAll(BINDING)) {
|
|
34
|
+
const name = match[1];
|
|
35
|
+
if (name === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
if (match.index > last)
|
|
38
|
+
parts.push(document.createTextNode(source.slice(last, match.index)));
|
|
39
|
+
const placeholder = document.createTextNode('');
|
|
40
|
+
bound.push([name, placeholder]);
|
|
41
|
+
parts.push(placeholder);
|
|
42
|
+
last = match.index + match[0].length;
|
|
43
|
+
}
|
|
44
|
+
if (parts.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
if (last < source.length)
|
|
47
|
+
parts.push(document.createTextNode(source.slice(last)));
|
|
48
|
+
text.replaceWith(...parts);
|
|
49
|
+
}
|
|
50
|
+
return bound;
|
|
51
|
+
}
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import { AnchoredPieces, type AnchoredPieceHandle, type AttachOptions } from './anchored-pieces.js';
|
|
2
|
+
import type { Entity } from './entity.js';
|
|
1
3
|
import type { Stats } from './stats.js';
|
|
4
|
+
/**
|
|
5
|
+
* Module-private key for the anchored layer. Not exported, so
|
|
6
|
+
* `ui[ANCHORED]()` cannot be spelled outside this file — `anchoredPiecesOf`
|
|
7
|
+
* (below, exported, but not from the package entry) is the Game's only way in.
|
|
8
|
+
*/
|
|
9
|
+
declare const ANCHORED: unique symbol;
|
|
2
10
|
/**
|
|
3
11
|
* The HTML UI layer. Each piece is a self-contained HTML fragment
|
|
4
12
|
* (markup + <style>) that only DRAWS: it declares which stats it shows
|
|
@@ -10,6 +18,10 @@ import type { Stats } from './stats.js';
|
|
|
10
18
|
* (each in its own shadow root, so styles never leak between pieces or
|
|
11
19
|
* into the hosting page). The whole overlay hides while the game is not
|
|
12
20
|
* simulating (pause / editor edit mode).
|
|
21
|
+
*
|
|
22
|
+
* Screen pieces are singletons by name (show/hide). An Anchored Piece is
|
|
23
|
+
* one more instance of a piece that follows an entity (attach), in a layer
|
|
24
|
+
* below every screen piece — see ADR 0018.
|
|
13
25
|
*/
|
|
14
26
|
export declare class GameUi {
|
|
15
27
|
private readonly stats;
|
|
@@ -17,6 +29,7 @@ export declare class GameUi {
|
|
|
17
29
|
private readonly host;
|
|
18
30
|
private readonly sources;
|
|
19
31
|
private readonly pieces;
|
|
32
|
+
private readonly anchored;
|
|
20
33
|
private overlay?;
|
|
21
34
|
private active;
|
|
22
35
|
constructor(stats: Stats,
|
|
@@ -27,6 +40,8 @@ export declare class GameUi {
|
|
|
27
40
|
defineAll(pieces: Record<string, string>): void;
|
|
28
41
|
/** Piece names available to show (defined via the registry or define()). */
|
|
29
42
|
names(): string[];
|
|
43
|
+
/** Whether a piece of this name is defined — `names().includes(name)` without building the list. */
|
|
44
|
+
has(name: string): boolean;
|
|
30
45
|
show(name: string, options?: ShowOptions): void;
|
|
31
46
|
hide(name: string): void;
|
|
32
47
|
toggle(name: string): void;
|
|
@@ -37,22 +52,39 @@ export declare class GameUi {
|
|
|
37
52
|
* Mounts the piece hidden if it wasn't mounted yet.
|
|
38
53
|
*/
|
|
39
54
|
element(name: string): HTMLElement | null;
|
|
55
|
+
/**
|
|
56
|
+
* Anchors a new instance of the piece to `entity` (issue #72): its own
|
|
57
|
+
* shadow root and values, placed every render frame at the entity's
|
|
58
|
+
* render point plus `offset`. Every call is a new instance; the screen
|
|
59
|
+
* piece of the same name is never touched. An undefined piece or a dead
|
|
60
|
+
* entity warns and returns an inert handle — it never throws.
|
|
61
|
+
*/
|
|
62
|
+
attach(piece: string, entity: Entity, options?: AttachOptions): AnchoredPieceHandle;
|
|
40
63
|
/** Called by the game loop: the overlay only draws while simulating. */
|
|
41
64
|
setActive(active: boolean): void;
|
|
42
|
-
/** Unmounts every piece and removes the overlay (Game.dispose). */
|
|
65
|
+
/** Unmounts every piece and Anchored Piece and removes the overlay (Game.dispose). */
|
|
43
66
|
dispose(): void;
|
|
44
67
|
/**
|
|
45
68
|
* Unmounts every scene-scoped piece: the ones `loadScene` showed from the
|
|
46
69
|
* outgoing scene's `ui` list, plus any shown with `{ scope: 'scene' }`.
|
|
47
|
-
* A piece the host showed with no scope is untouched.
|
|
48
|
-
*
|
|
70
|
+
* A piece the host showed with no scope is untouched. Every Anchored
|
|
71
|
+
* Piece goes too, lingering ones included: none outlives its scene. The
|
|
72
|
+
* definition catalog (sources) always survives — Game.unloadScene.
|
|
49
73
|
*/
|
|
50
74
|
unloadScene(): void;
|
|
75
|
+
/** Engine-internal: see anchoredPiecesOf. */
|
|
76
|
+
[ANCHORED](): AnchoredPieces;
|
|
51
77
|
private mount;
|
|
52
78
|
private mountOverlay;
|
|
53
79
|
private sync;
|
|
54
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Engine-internal: the anchored layer behind `ui.attach`, which the Game
|
|
83
|
+
* connects to its camera and viewport and places every render frame.
|
|
84
|
+
*/
|
|
85
|
+
export declare function anchoredPiecesOf(ui: GameUi): AnchoredPieces;
|
|
55
86
|
export interface ShowOptions {
|
|
56
87
|
/** 'scene': unmounted by Game.unloadScene() along with the rest of the scene. */
|
|
57
88
|
scope?: 'scene';
|
|
58
89
|
}
|
|
90
|
+
export {};
|
package/dist/ui.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { AnchoredPieces } from './anchored-pieces.js';
|
|
2
|
+
import { placeholders, renderStat } from './ui-bindings.js';
|
|
3
|
+
/**
|
|
4
|
+
* Module-private key for the anchored layer. Not exported, so
|
|
5
|
+
* `ui[ANCHORED]()` cannot be spelled outside this file — `anchoredPiecesOf`
|
|
6
|
+
* (below, exported, but not from the package entry) is the Game's only way in.
|
|
7
|
+
*/
|
|
8
|
+
const ANCHORED = Symbol('waica.ui.anchored');
|
|
1
9
|
/**
|
|
2
10
|
* The HTML UI layer. Each piece is a self-contained HTML fragment
|
|
3
11
|
* (markup + <style>) that only DRAWS: it declares which stats it shows
|
|
@@ -9,12 +17,17 @@
|
|
|
9
17
|
* (each in its own shadow root, so styles never leak between pieces or
|
|
10
18
|
* into the hosting page). The whole overlay hides while the game is not
|
|
11
19
|
* simulating (pause / editor edit mode).
|
|
20
|
+
*
|
|
21
|
+
* Screen pieces are singletons by name (show/hide). An Anchored Piece is
|
|
22
|
+
* one more instance of a piece that follows an entity (attach), in a layer
|
|
23
|
+
* below every screen piece — see ADR 0018.
|
|
12
24
|
*/
|
|
13
25
|
export class GameUi {
|
|
14
26
|
stats;
|
|
15
27
|
host;
|
|
16
28
|
sources = new Map();
|
|
17
29
|
pieces = new Map();
|
|
30
|
+
anchored;
|
|
18
31
|
overlay;
|
|
19
32
|
active = true;
|
|
20
33
|
constructor(stats,
|
|
@@ -22,6 +35,11 @@ export class GameUi {
|
|
|
22
35
|
host) {
|
|
23
36
|
this.stats = stats;
|
|
24
37
|
this.host = host;
|
|
38
|
+
this.anchored = new AnchoredPieces({
|
|
39
|
+
stats,
|
|
40
|
+
source: (name) => this.sources.get(name),
|
|
41
|
+
overlay: () => this.mountOverlay(),
|
|
42
|
+
});
|
|
25
43
|
}
|
|
26
44
|
/** Registers a piece's HTML source. Re-defining an unmounted name wins. */
|
|
27
45
|
define(name, html) {
|
|
@@ -35,6 +53,10 @@ export class GameUi {
|
|
|
35
53
|
names() {
|
|
36
54
|
return [...this.sources.keys()];
|
|
37
55
|
}
|
|
56
|
+
/** Whether a piece of this name is defined — `names().includes(name)` without building the list. */
|
|
57
|
+
has(name) {
|
|
58
|
+
return this.sources.has(name);
|
|
59
|
+
}
|
|
38
60
|
show(name, options = {}) {
|
|
39
61
|
const mounted = this.pieces.has(name);
|
|
40
62
|
const piece = this.mount(name);
|
|
@@ -73,6 +95,16 @@ export class GameUi {
|
|
|
73
95
|
element(name) {
|
|
74
96
|
return this.mount(name)?.root ?? null;
|
|
75
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Anchors a new instance of the piece to `entity` (issue #72): its own
|
|
100
|
+
* shadow root and values, placed every render frame at the entity's
|
|
101
|
+
* render point plus `offset`. Every call is a new instance; the screen
|
|
102
|
+
* piece of the same name is never touched. An undefined piece or a dead
|
|
103
|
+
* entity warns and returns an inert handle — it never throws.
|
|
104
|
+
*/
|
|
105
|
+
attach(piece, entity, options = {}) {
|
|
106
|
+
return this.anchored.attach(piece, entity, options);
|
|
107
|
+
}
|
|
76
108
|
/** Called by the game loop: the overlay only draws while simulating. */
|
|
77
109
|
setActive(active) {
|
|
78
110
|
if (this.active === active)
|
|
@@ -80,23 +112,26 @@ export class GameUi {
|
|
|
80
112
|
this.active = active;
|
|
81
113
|
this.sync();
|
|
82
114
|
}
|
|
83
|
-
/** Unmounts every piece and removes the overlay (Game.dispose). */
|
|
115
|
+
/** Unmounts every piece and Anchored Piece and removes the overlay (Game.dispose). */
|
|
84
116
|
dispose() {
|
|
85
117
|
for (const piece of this.pieces.values()) {
|
|
86
118
|
for (const off of piece.unsubs)
|
|
87
119
|
off();
|
|
88
120
|
}
|
|
89
121
|
this.pieces.clear();
|
|
122
|
+
this.anchored.dispose();
|
|
90
123
|
this.overlay?.remove();
|
|
91
124
|
this.overlay = undefined;
|
|
92
125
|
}
|
|
93
126
|
/**
|
|
94
127
|
* Unmounts every scene-scoped piece: the ones `loadScene` showed from the
|
|
95
128
|
* outgoing scene's `ui` list, plus any shown with `{ scope: 'scene' }`.
|
|
96
|
-
* A piece the host showed with no scope is untouched.
|
|
97
|
-
*
|
|
129
|
+
* A piece the host showed with no scope is untouched. Every Anchored
|
|
130
|
+
* Piece goes too, lingering ones included: none outlives its scene. The
|
|
131
|
+
* definition catalog (sources) always survives — Game.unloadScene.
|
|
98
132
|
*/
|
|
99
133
|
unloadScene() {
|
|
134
|
+
this.anchored.clear();
|
|
100
135
|
for (const [name, piece] of this.pieces) {
|
|
101
136
|
if (piece.scope !== 'scene')
|
|
102
137
|
continue;
|
|
@@ -107,6 +142,10 @@ export class GameUi {
|
|
|
107
142
|
}
|
|
108
143
|
this.sync();
|
|
109
144
|
}
|
|
145
|
+
/** Engine-internal: see anchoredPiecesOf. */
|
|
146
|
+
[ANCHORED]() {
|
|
147
|
+
return this.anchored;
|
|
148
|
+
}
|
|
110
149
|
mount(name) {
|
|
111
150
|
const existing = this.pieces.get(name);
|
|
112
151
|
if (existing)
|
|
@@ -158,50 +197,20 @@ export class GameUi {
|
|
|
158
197
|
}
|
|
159
198
|
}
|
|
160
199
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return String(value);
|
|
200
|
+
/**
|
|
201
|
+
* Engine-internal: the anchored layer behind `ui.attach`, which the Game
|
|
202
|
+
* connects to its camera and viewport and places every render frame.
|
|
203
|
+
*/
|
|
204
|
+
export function anchoredPiecesOf(ui) {
|
|
205
|
+
return ui[ANCHORED]();
|
|
168
206
|
}
|
|
169
207
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* language has no expressions — presentation, never logic.
|
|
208
|
+
* Fills each {{stat}} placeholder with the stat's value and keeps it in
|
|
209
|
+
* sync; returns the unsubscribes.
|
|
173
210
|
*/
|
|
174
211
|
function bindStats(root, stats) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// Braces inside <style>/<script> are CSS/code, not bindings.
|
|
180
|
-
if (node.parentElement?.closest('style, script'))
|
|
181
|
-
continue;
|
|
182
|
-
if ((node.nodeValue ?? '').includes('{{'))
|
|
183
|
-
targets.push(node);
|
|
184
|
-
}
|
|
185
|
-
for (const text of targets) {
|
|
186
|
-
const source = text.nodeValue ?? '';
|
|
187
|
-
const parts = [];
|
|
188
|
-
let last = 0;
|
|
189
|
-
for (const match of source.matchAll(BINDING)) {
|
|
190
|
-
const stat = match[1];
|
|
191
|
-
if (stat === undefined)
|
|
192
|
-
continue;
|
|
193
|
-
if (match.index > last)
|
|
194
|
-
parts.push(document.createTextNode(source.slice(last, match.index)));
|
|
195
|
-
const bound = document.createTextNode(renderStat(stats.get(stat)));
|
|
196
|
-
unsubs.push(stats.onChange(stat, (value) => (bound.nodeValue = renderStat(value))));
|
|
197
|
-
parts.push(bound);
|
|
198
|
-
last = match.index + match[0].length;
|
|
199
|
-
}
|
|
200
|
-
if (parts.length === 0)
|
|
201
|
-
continue;
|
|
202
|
-
if (last < source.length)
|
|
203
|
-
parts.push(document.createTextNode(source.slice(last)));
|
|
204
|
-
text.replaceWith(...parts);
|
|
205
|
-
}
|
|
206
|
-
return unsubs;
|
|
212
|
+
return placeholders(root).map(([stat, text]) => {
|
|
213
|
+
text.nodeValue = renderStat(stats.get(stat));
|
|
214
|
+
return stats.onChange(stat, (value) => (text.nodeValue = renderStat(value)));
|
|
215
|
+
});
|
|
207
216
|
}
|