@waica/engine 0.15.0 → 0.17.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/game.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as THREE from 'three';
2
+ import { gameViewport } from './anchored-pieces.js';
2
3
  import { AudioSubsystem } from './audio/audio-subsystem.js';
3
4
  import { dispatchCollisions as dispatchHitboxCollisions } from './collision-dispatch.js';
4
5
  import { isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera, } from './camera.js';
@@ -6,6 +7,7 @@ import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
6
7
  import { Entity } from './entity.js';
7
8
  import { Emitter } from './events.js';
8
9
  import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
10
+ import { advanceGameTime, GameTime } from './game-time.js';
9
11
  import { Input } from './input.js';
10
12
  import { Pointer } from './pointer.js';
11
13
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
@@ -15,7 +17,7 @@ import { isYSortParticipant, ySortZ } from './render-sort.js';
15
17
  import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
16
18
  import { createSpatialQuery } from './spatial-query.js';
17
19
  import { Stats } from './stats.js';
18
- import { GameUi } from './ui.js';
20
+ import { anchoredPiecesOf, GameUi } from './ui.js';
19
21
  /**
20
22
  * Engine core: loop, unified 2D/3D three scene, orthographic camera,
21
23
  * entities with components, and input. See DESIGN.md.
@@ -33,6 +35,8 @@ export class Game {
33
35
  ui;
34
36
  /** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
35
37
  audio;
38
+ /** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
39
+ time = new GameTime();
36
40
  /** Registry retained by loadScene for runtime prefab spawning. */
37
41
  registry = null;
38
42
  paramOverrides = {};
@@ -85,6 +89,11 @@ export class Game {
85
89
  this.query = createSpatialQuery(this);
86
90
  this.stats = new Stats(options.stats);
87
91
  this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
92
+ anchoredPiecesOf(this.ui).connect(() => ({
93
+ camera: this.camera,
94
+ viewport: gameViewport(canvas.clientWidth, canvas.clientHeight, this.resolution),
95
+ projection: this.sceneProjection,
96
+ }));
88
97
  this.audio = new AudioSubsystem({
89
98
  canvas,
90
99
  backend: options.audio,
@@ -148,6 +157,9 @@ export class Game {
148
157
  unloadScene() {
149
158
  this.ui.unloadScene();
150
159
  this.audio.unloadScene();
160
+ // Scene-scoped timers/tweens die here too (ADR 0017): before entities are
161
+ // destroyed below, so an owner's own destroy() cancellation is a no-op.
162
+ this.time.cancelSceneScoped();
151
163
  // An explicit unload means "no scene": a swap queued earlier this frame
152
164
  // would otherwise flush next frame and resurrect one.
153
165
  this.pendingSceneLoad = null;
@@ -312,8 +324,9 @@ export class Game {
312
324
  stop() {
313
325
  this.renderer.setAnimationLoop(null);
314
326
  }
315
- /** Internal: called by Entity.destroy(). */
327
+ /** Internal: called by Entity.destroy(). Its Anchored Pieces go (or freeze) with it. */
316
328
  removeEntity(entity) {
329
+ anchoredPiecesOf(this.ui).release(entity);
317
330
  const i = this.entities.indexOf(entity);
318
331
  if (i !== -1)
319
332
  this.entities.splice(i, 1);
@@ -339,6 +352,9 @@ export class Game {
339
352
  this.resizeObserver.disconnect();
340
353
  this.ui.dispose();
341
354
  this.audio.dispose();
355
+ // Cancels both scopes, including session-scoped work no entity owns
356
+ // (entity.destroy() below only ever reaches owned work) — ADR 0017.
357
+ this.time.cancelAll();
342
358
  for (const entity of [...this.entities])
343
359
  entity.destroy();
344
360
  this.renderer.dispose();
@@ -429,11 +445,13 @@ export class Game {
429
445
  }
430
446
  }
431
447
  /**
432
- * One Simulation Step: the Component Update Schedule (ADR 0004) in full,
448
+ * One Simulation Step: game.time's start-of-step pass (ADR 0017, CA-3)
449
+ * first, then the Component Update Schedule (ADR 0004) in full,
433
450
  * collisions, the scene camera and the host's callbacks, every one of
434
451
  * them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
435
452
  */
436
453
  simulateStep() {
454
+ advanceGameTime(this.time);
437
455
  for (const entity of [...this.entities]) {
438
456
  const schedule = this.componentUpdateSchedule(entity);
439
457
  if (!schedule)
@@ -500,6 +518,7 @@ export class Game {
500
518
  if (this.renderSort === 'y')
501
519
  this.applyYSort();
502
520
  this.ui.setActive(this.simulate);
521
+ anchoredPiecesOf(this.ui).place();
503
522
  if (this.resolution) {
504
523
  // Letterbox bars: clear the whole canvas, then render inside the scissor.
505
524
  this.renderer.setScissorTest(false);
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export { spritePlacement } from './sprite-placement.js';
14
14
  export type { SpritePlacement, SpritePlacementInput } from './sprite-placement.js';
15
15
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
16
16
  export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
17
+ export { GameTime, advanceGameTime } from './game-time.js';
18
+ export type { EasingName, TimerHandle, TimerOptions, TweenOptions } from './game-time.js';
17
19
  export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
18
20
  export { Entity } from './entity.js';
19
21
  export { Component } from './component.js';
@@ -30,11 +32,12 @@ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from
30
32
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
31
33
  export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
32
34
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
33
- export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
35
+ export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeSnapshotUi, RuntimeTransformSnapshot, } from './runtime-inspection.js';
34
36
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
35
37
  export { Stats } from './stats.js';
36
38
  export type { StatValue } from './stats.js';
37
39
  export { GameUi } from './ui.js';
40
+ export type { AnchoredPieceHandle, AttachOptions } from './anchored-pieces.js';
38
41
  export { Sprite } from './components/sprite.js';
39
42
  export { Solid } from './components/solid.js';
40
43
  export { Hitbox } from './components/hitbox.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { projectIsometric, screenInputToLogical, unprojectIsometric } from './pr
6
6
  export { spritePlacement } from './sprite-placement.js';
7
7
  export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
8
8
  export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
9
+ export { GameTime, advanceGameTime } from './game-time.js';
9
10
  export { Entity } from './entity.js';
10
11
  export { Component } from './component.js';
11
12
  export { authoringDefaults } from './authoring-defaults.js';
package/dist/input.d.ts CHANGED
@@ -24,6 +24,12 @@ export declare class Input {
24
24
  justPressed(action: ActionName): boolean;
25
25
  /** Installed semantic action names in deterministic order. */
26
26
  availableActions(): ActionName[];
27
+ /**
28
+ * The key codes bound to the action, in their declared order (e.g.
29
+ * `['KeyE', 'Space']`); `[]` for an unknown or unbound action. A new
30
+ * array every call: mutating it never changes the bindings.
31
+ */
32
+ bindingsFor(action: ActionName): string[];
27
33
  /** Currently held semantic action names in deterministic order. */
28
34
  heldActions(): ActionName[];
29
35
  /** Injects an action by semantic name; false means the action is not installed. */
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));
@@ -65,6 +65,47 @@ export interface RuntimeSnapshotAudio {
65
65
  channels: Record<string, AudioChannelState>;
66
66
  playing: LiveSoundInfo[];
67
67
  }
68
+ /**
69
+ * `game.time`'s pending work (CA-10), beside `audio`: `pending` counts
70
+ * active timers plus active tweens across both scopes; `nextInSteps` is the
71
+ * smallest positive integer n such that `step { frames: n }` makes an
72
+ * active timer run or an active tween complete, or null when `pending` is
73
+ * 0. Emitted unconditionally, like `audio` — never filtered, and never
74
+ * repeats `now` (metadata already carries `simulationTime`). That promise
75
+ * holds only while the Game is simulating: `runFrame` runs zero steps with
76
+ * `simulate === false` (`game.ts`), so on a Game that is paused AND not
77
+ * simulating, `nextInSteps` is still reported but stepping never consumes
78
+ * it — a Run Session starts with `simulate === true`, so this only matters
79
+ * if project code flips it.
80
+ */
81
+ export interface RuntimeSnapshotTime {
82
+ pending: number;
83
+ nextInSteps: number | null;
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
+ }
68
109
  export interface RuntimeSnapshot extends RuntimeMetadata {
69
110
  stats: Record<string, StatValue>;
70
111
  /** The live scene's name (its catalog key), or null with no scene loaded. */
@@ -72,6 +113,8 @@ export interface RuntimeSnapshot extends RuntimeMetadata {
72
113
  entities: RuntimeEntitySnapshot[];
73
114
  projectionIssues: ProjectionIssue[];
74
115
  audio: RuntimeSnapshotAudio;
116
+ time: RuntimeSnapshotTime;
117
+ ui: RuntimeSnapshotUi;
75
118
  }
76
119
  export declare const RUNTIME_PROJECTION_LIMITS: {
77
120
  readonly depth: 5;
@@ -87,6 +130,8 @@ export declare class RuntimeInspector {
87
130
  constructor(game: Game);
88
131
  snapshot(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
89
132
  private audioSnapshot;
133
+ private timeSnapshot;
134
+ private uiSnapshot;
90
135
  private capSnapshot;
91
136
  private idFor;
92
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();
@@ -238,6 +270,8 @@ export class RuntimeInspector {
238
270
  entities,
239
271
  projectionIssues,
240
272
  audio: this.audioSnapshot(),
273
+ time: this.timeSnapshot(),
274
+ ui: this.uiSnapshot(projectionIssues),
241
275
  });
242
276
  }
243
277
  audioSnapshot() {
@@ -251,10 +285,27 @@ export class RuntimeInspector {
251
285
  playing: this.game.audio.liveSounds(),
252
286
  };
253
287
  }
288
+ timeSnapshot() {
289
+ return {
290
+ pending: this.game.time.pending,
291
+ nextInSteps: this.game.time.nextInSteps,
292
+ };
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
+ }
254
305
  capSnapshot(snapshot) {
255
- if (utf8Bytes(JSON.stringify(snapshot)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
306
+ if (fitsSnapshot(snapshot))
256
307
  return snapshot;
257
- }
308
+ let capped = snapshot;
258
309
  const retained = [...snapshot.entities];
259
310
  const removedIds = new Set();
260
311
  while (retained.length > 0) {
@@ -265,20 +316,11 @@ export class RuntimeInspector {
265
316
  const projectionIssues = snapshot.projectionIssues
266
317
  .filter((issue) => [...removedIds].every((id) => !issue.path.startsWith(`entities[${id}]`)))
267
318
  .concat({ path: `entities[${retained.length}]`, marker: 'truncated', omitted });
268
- const candidate = { ...snapshot, entities: retained, projectionIssues };
269
- if (utf8Bytes(JSON.stringify(candidate)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
270
- return candidate;
271
- }
319
+ capped = { ...snapshot, entities: retained, projectionIssues };
320
+ if (fitsSnapshot(capped))
321
+ return capped;
272
322
  }
273
- return {
274
- ...snapshot,
275
- entities: [],
276
- projectionIssues: [{
277
- path: 'entities[0]',
278
- marker: 'truncated',
279
- omitted: snapshot.entities.length,
280
- }],
281
- };
323
+ return capAnchored(capped);
282
324
  }
283
325
  idFor(entity) {
284
326
  const existing = this.ids.get(entity);
package/dist/scene.js CHANGED
@@ -85,8 +85,17 @@ export function spawnFromJson(game, json, registry) {
85
85
  * at a time.
86
86
  */
87
87
  export function loadScene(game, scene, registry) {
88
- if (game.registry)
88
+ if (game.registry) {
89
89
  game.unloadScene();
90
+ }
91
+ else {
92
+ // unloadScene() would otherwise be skipped on a Game's first load
93
+ // (F11), leaving a scene-scoped timer/tween created before it — a boot
94
+ // clock, say — to survive this load and die only at the next one
95
+ // instead. Cancel that slice of unloadScene()'s work directly; there is
96
+ // no scene, UI or audio yet for the rest of it to touch (ADR 0017, CA-4).
97
+ game.time.cancelSceneScoped();
98
+ }
90
99
  game.registry = registry;
91
100
  game.setSceneRender(scene.render);
92
101
  for (const entityJson of scene.entities)
@@ -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
  }