@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/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));
@@ -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;
@@ -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 (utf8Bytes(JSON.stringify(snapshot)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
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
- const candidate = { ...snapshot, entities: retained, projectionIssues };
276
- if (utf8Bytes(JSON.stringify(candidate)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
277
- return candidate;
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. The definition
48
- * catalog (sources) always survives — Game.unloadScene.
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. The definition
97
- * catalog (sources) always survives — Game.unloadScene.
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
- const BINDING = /\{\{\s*([\w-]+)\s*\}\}/g;
162
- function renderStat(value) {
163
- if (value === undefined)
164
- return '';
165
- if (typeof value === 'boolean')
166
- return value ? '✓' : '✕';
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
- * Replaces {{stat}} placeholders in the fragment's text with reactive text
171
- * nodes kept in sync with the stats. Text-only by design: the binding
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
- const unsubs = [];
176
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
177
- const targets = [];
178
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",