@waica/engine 0.16.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/README.md +31 -0
- package/dist/anchored-pieces.d.ts +122 -0
- package/dist/anchored-pieces.js +330 -0
- package/dist/component.d.ts +1 -1
- package/dist/game.d.ts +1 -1
- package/dist/game.js +10 -2
- package/dist/index.d.ts +2 -1
- package/dist/input.d.ts +6 -0
- package/dist/input.js +8 -0
- 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/README.md
CHANGED
|
@@ -193,3 +193,34 @@ game.time.now // seconds of Game Time since the Game started
|
|
|
193
193
|
- **Step placement.** At the start of every Simulation Step, before the Component Update Schedule, `game.time` advances `now`, runs every due timer (due time, then creation order), then advances every tween that existed before that step (creation order). Work a callback creates is never run or advanced in that same step.
|
|
194
194
|
- **Scope.** A timer or tween is scene-scoped by default: `unloadScene()` and every scene load — including a Game's first — cancel it, running no callback. `{ scope: 'session' }` survives a scene change. `{ owner: entity }` cancels it immediately when that entity is destroyed, whatever its scope; an owner already dead at scheduling time yields an inactive handle. `game.dispose()` cancels everything in both scopes. This is the opposite default from `game.onUpdate`/`game.events` (ADR 0011), which survive a scene change by construction, and the same one `game.audio.play()` uses (ADR 0012) — see ADR 0017 for why timers follow audio's rule rather than the host-subscription one: a timer's callback almost always closes over the scene that scheduled it.
|
|
195
195
|
- **No Promises.** Nothing here returns one, and nothing is async — a `.then` continuation is not step-exact (it runs after the whole synchronous frame), which is exactly what `game.time` exists to avoid. Compose delays with `after`, not `await`.
|
|
196
|
+
|
|
197
|
+
## game.ui.attach: Anchored Pieces
|
|
198
|
+
|
|
199
|
+
A UI Piece shown with `game.ui.show(name)` is a screen-space singleton. `game.ui.attach(piece, entity, options?)` instead creates an **Anchored Piece**: a new instance of the piece that follows `entity` across the screen, with its own shadow root and its own values. Every call is a new instance — five orcs can each carry a `health-bar` — and the screen piece of the same name is never mounted, shown or changed by it.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
const hit = game.ui.attach('damage-number', orc, { offset: [0, 1.2], seconds: 0.8, values: { amount: 3 } })
|
|
203
|
+
const bar = game.ui.attach('health-bar', orc, { offset: [0, 1.4], values: { current: 7, max: 10 } })
|
|
204
|
+
bar.set('current', 6) // {{current}} and --current update live
|
|
205
|
+
bar.remove()
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```html
|
|
209
|
+
<style>
|
|
210
|
+
.bar { position: absolute; transform: translate(-50%, -100%); width: calc(1.2 * var(--waica-unit)); height: 4px; background: #0008 }
|
|
211
|
+
.fill { width: calc(var(--current) / var(--max) * 100%); height: 100%; background: #ef476f }
|
|
212
|
+
</style>
|
|
213
|
+
<div class="bar"><div class="fill"></div></div>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- **Options.** `offset: [x, y]` (default `[0, 0]`) is in world units, added to the entity's render point in render space: `[0, 1]` is one unit up on screen, under `projection: 'isometric'` too. `seconds` gives the instance its own lifetime (below). `values` are the instance's own values.
|
|
217
|
+
- **Handle.** `set(name, value)`, `remove()`, `alive` and `element` — the piece's content root inside the instance's shadow root, `null` once removed.
|
|
218
|
+
- **Placement.** Once per render frame — after the isometric projection and the camera step, before the render, never per Simulation Step — each instance's shadow host is placed as a zero-size box at its anchor point, converted to whole CSS pixels from the top-left corner of the game viewport; the piece's own CSS centres itself around that point (e.g. `transform: translate(-50%, -100%)`). A move therefore shows on the next frame, not before. All instances live in one layer fitted to the game viewport — the whole canvas, or the letterboxed rectangle under a fixed `resolution` — with `overflow: hidden`, so they never draw over the letterbox bars.
|
|
219
|
+
- **Per-instance values.** Inside an instance, `{{name}}` renders the instance's own value when it has one and the Game stat of that name otherwise (booleans as ✓/✕, missing as empty); `set` updates it in place, and a stat change still updates every placeholder with no instance value. Every number value is also published as the custom property `--name` on the instance (`values: { current: 7, max: 10 }` gives `--current: 7` and `--max: 10`), booleans as `1`/`0`; strings are text only. There are no binding expressions: arithmetic belongs in CSS `calc()`.
|
|
220
|
+
- **`--waica-unit`.** Every frame, the anchored layer carries `--waica-unit` inline, and every instance inherits it through its shadow host: CSS pixels per world unit, the game viewport's CSS height divided by the current view height. It follows camera zoom and the letterbox scale, so `calc(1.1 * var(--waica-unit))` sizes a piece in world units while everything else keeps its CSS pixel size.
|
|
221
|
+
- **Draw order.** The anchored layer sits below every screen-space piece. Within it, every frame, the instance lower on screen draws on top, like y-sort; equal heights keep creation order, later on top.
|
|
222
|
+
- **Lifetime.** Without `seconds`, an instance is removed before its entity's `destroy()` returns. With `seconds: S`, it is removed exactly when a `game.time.after(S, …)` scheduled at the same moment would fire — it counts Game Time (ADR 0017), so it never runs out while the Game is paused or not simulating, and until then it counts in `game.time.pending`. If its entity is destroyed first, it lingers frozen where the entity was at `destroy()` time (still moving with the camera) until it expires. `unloadScene()`, every scene load that unloads and `game.dispose()` remove every instance: none outlives its scene. `remove()` is immediate; `set` and `remove` on a removed handle are silent no-ops. CSS animations and transitions inside an instance follow Game Time, not the wall clock: every frame each is paused at the Game Time since it started (the attach, for those the piece starts with), so a paused Game freezes a damage number mid-flight — it resumes from there, even after the overlay hid while not simulating — and `step { frames: N }` advances it by N/60 s.
|
|
223
|
+
- **Invalid attach never throws.** An undefined piece name logs one `[waica]` warning per name per Game; an entity that is no longer `alive` logs one per call. Both return a handle with `alive: false` and `element: null` that mounts nothing. To attach only when the project defines the piece, as `Interactable` does with `npc-bubble` and `interact-prompt`, check `game.ui.has(name)` first.
|
|
224
|
+
- **Runtime Snapshot.** Every snapshot carries `ui: { shown, anchored }`: the visible screen pieces by name, and each live instance in creation order as `{ piece, entity, x, y, clipped, values }`, with the pixel coordinates of its last placement; `values` are bounded like component state (a string over 4 KiB becomes a `$waica: 'truncated'` marker), and once every entity is cut the 1 MiB snapshot cap drops instances from the end.
|
|
225
|
+
|
|
226
|
+
The trade-off is ADR 0018's: Anchored Pieces are HTML drawn over the game view, not text rendered in the three scene. They always draw above the world — a label behind a tree draws over it — hide with the rest of the UI overlay while the Game is not simulating (including the editor's edit mode), and do not align to a pixel-art grid.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Entity } from './entity.js';
|
|
2
|
+
import type { PointerCamera, PointerResolution } from './pointer.js';
|
|
3
|
+
import type { RuntimeSnapshotUi } from './runtime-inspection.js';
|
|
4
|
+
import type { Stats, StatValue } from './stats.js';
|
|
5
|
+
/** Options for `game.ui.attach` (issue #72). */
|
|
6
|
+
export interface AttachOptions {
|
|
7
|
+
/**
|
|
8
|
+
* World units added to the entity's render point, in render space:
|
|
9
|
+
* `[0, 1]` is one unit up on screen, under isometric projection too.
|
|
10
|
+
* Default `[0, 0]`. Pixel fine-tuning belongs in the piece's CSS.
|
|
11
|
+
*/
|
|
12
|
+
offset?: [number, number];
|
|
13
|
+
/**
|
|
14
|
+
* Seconds of Game Time the instance lives for (ADR 0017). It outlives its
|
|
15
|
+
* entity, frozen where the entity last was, until then. Absent: the
|
|
16
|
+
* instance dies with its entity.
|
|
17
|
+
*/
|
|
18
|
+
seconds?: number;
|
|
19
|
+
/**
|
|
20
|
+
* The instance's own values: `{{name}}` reads these before the Game's
|
|
21
|
+
* stats, and every number (verbatim) or boolean (`1`/`0`) is also
|
|
22
|
+
* published as the custom property `--name` on the instance.
|
|
23
|
+
*/
|
|
24
|
+
values?: Record<string, StatValue>;
|
|
25
|
+
}
|
|
26
|
+
/** One Anchored Piece, as `game.ui.attach` hands it back. */
|
|
27
|
+
export interface AnchoredPieceHandle {
|
|
28
|
+
/** Sets one of the instance's own values: its `{{name}}` text and `--name` update in place. */
|
|
29
|
+
set(name: string, value: StatValue): void;
|
|
30
|
+
/** Removes the instance now. `set` and `remove` on a removed handle are silent no-ops. */
|
|
31
|
+
remove(): void;
|
|
32
|
+
/** False once removed, and from the start on an invalid attach. */
|
|
33
|
+
readonly alive: boolean;
|
|
34
|
+
/** The piece's content root inside the instance's own shadow root; null once removed. */
|
|
35
|
+
readonly element: HTMLElement | null;
|
|
36
|
+
}
|
|
37
|
+
/** A rectangle in CSS px, relative to the canvas's top-left corner. */
|
|
38
|
+
export interface ViewportRect {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
}
|
|
44
|
+
/** What the Game tells the anchored layer each time it places: read live, never cached. */
|
|
45
|
+
export interface AnchorView {
|
|
46
|
+
camera: PointerCamera;
|
|
47
|
+
/** The game viewport: see gameViewport. */
|
|
48
|
+
viewport: ViewportRect;
|
|
49
|
+
projection: 'isometric' | null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The game viewport (issue #72): the rectangle the renderer draws into —
|
|
53
|
+
* the whole canvas without a fixed resolution, the largest centred rect
|
|
54
|
+
* with its aspect (letterbox) with one. The same math as Game.resize() and
|
|
55
|
+
* the Pointer's letterbox, whose screen→world mapping placement inverts.
|
|
56
|
+
*/
|
|
57
|
+
export declare function gameViewport(width: number, height: number, resolution: PointerResolution | null): ViewportRect;
|
|
58
|
+
/** What GameUi lends the anchored layer: its catalog, the stats and its overlay. */
|
|
59
|
+
export interface AnchoredPiecesDeps {
|
|
60
|
+
stats: Stats;
|
|
61
|
+
/** A piece's HTML source, or undefined when the piece was never defined. */
|
|
62
|
+
source(name: string): string | undefined;
|
|
63
|
+
/** GameUi's overlay, mounted on demand. */
|
|
64
|
+
overlay(): HTMLElement;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Anchored Pieces (issue #72, ADR 0018) — instances of a UI Piece, as many
|
|
68
|
+
* as needed, each following an entity in one layer of GameUi's overlay,
|
|
69
|
+
* each with its own shadow root and its own values ahead of the Game's stats.
|
|
70
|
+
* Owned by GameUi, which reaches it through `attach`; the Game drives the
|
|
71
|
+
* rest through `anchoredPiecesOf` (ui.ts): `connect` once, `place` every
|
|
72
|
+
* render frame.
|
|
73
|
+
*/
|
|
74
|
+
export declare class AnchoredPieces {
|
|
75
|
+
private readonly deps;
|
|
76
|
+
private readonly instances;
|
|
77
|
+
/** Undefined piece names already warned about: one warning per name per Game. */
|
|
78
|
+
private readonly warnedPieces;
|
|
79
|
+
private layer?;
|
|
80
|
+
private view;
|
|
81
|
+
constructor(deps: AnchoredPiecesDeps);
|
|
82
|
+
/** Called once by the Game: where the camera, viewport and projection are read from. */
|
|
83
|
+
connect(view: () => AnchorView): void;
|
|
84
|
+
attach(piece: string, entity: Entity, options?: AttachOptions): AnchoredPieceHandle;
|
|
85
|
+
/**
|
|
86
|
+
* Called by the Game before an entity's destroy() returns: its instances
|
|
87
|
+
* go with it, except those given `seconds`, which stay frozen at its
|
|
88
|
+
* current anchor point until they expire.
|
|
89
|
+
*/
|
|
90
|
+
release(entity: Entity): void;
|
|
91
|
+
/**
|
|
92
|
+
* The live instances for the Runtime Snapshot, in creation order, with
|
|
93
|
+
* the coordinates of their last placement — or, for one attached since
|
|
94
|
+
* the last render frame, where the next frame will place it.
|
|
95
|
+
*/
|
|
96
|
+
snapshot(): RuntimeSnapshotUi['anchored'];
|
|
97
|
+
/** Removes every instance, lingering ones too (GameUi.unloadScene). */
|
|
98
|
+
clear(): void;
|
|
99
|
+
/** Removes every instance and forgets the layer along with the overlay (GameUi.dispose). */
|
|
100
|
+
dispose(): void;
|
|
101
|
+
/**
|
|
102
|
+
* Called by the Game once per render frame, after the isometric pass and
|
|
103
|
+
* before the render (never per Simulation Step, never on attach): fits
|
|
104
|
+
* the layer to the game viewport, sets the frame's `--waica-unit` on it
|
|
105
|
+
* (every instance inherits it through its shadow host) and puts every
|
|
106
|
+
* instance's zero-size shadow host at its anchor point, its CSS
|
|
107
|
+
* animations set to Game Time (followGameTime).
|
|
108
|
+
*/
|
|
109
|
+
place(): void;
|
|
110
|
+
private bind;
|
|
111
|
+
/** Writes a name's current value — the instance's own, else the Game stat — into its placeholders. */
|
|
112
|
+
private render;
|
|
113
|
+
private set;
|
|
114
|
+
private remove;
|
|
115
|
+
private handleFor;
|
|
116
|
+
/**
|
|
117
|
+
* The single layer every instance lives in, kept as the overlay's first
|
|
118
|
+
* child — even when a screen piece created the overlay first — so every
|
|
119
|
+
* screen-space piece draws above every anchored instance.
|
|
120
|
+
*/
|
|
121
|
+
private mountLayer;
|
|
122
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { projectIsometric } from './projection.js';
|
|
2
|
+
import { placeholders, renderStat } from './ui-bindings.js';
|
|
3
|
+
/**
|
|
4
|
+
* The game viewport (issue #72): the rectangle the renderer draws into —
|
|
5
|
+
* the whole canvas without a fixed resolution, the largest centred rect
|
|
6
|
+
* with its aspect (letterbox) with one. The same math as Game.resize() and
|
|
7
|
+
* the Pointer's letterbox, whose screen→world mapping placement inverts.
|
|
8
|
+
*/
|
|
9
|
+
export function gameViewport(width, height, resolution) {
|
|
10
|
+
if (!resolution)
|
|
11
|
+
return { x: 0, y: 0, width, height };
|
|
12
|
+
const aspect = resolution.width / resolution.height;
|
|
13
|
+
const vw = Math.min(width, height * aspect);
|
|
14
|
+
const vh = vw / aspect;
|
|
15
|
+
return { x: (width - vw) / 2, y: (height - vh) / 2, width: vw, height: vh };
|
|
16
|
+
}
|
|
17
|
+
/** The handle of an invalid attach (CA-7): nothing mounted, nothing to change. */
|
|
18
|
+
const INERT_HANDLE = Object.freeze({
|
|
19
|
+
set() { },
|
|
20
|
+
remove() { },
|
|
21
|
+
alive: false,
|
|
22
|
+
element: null,
|
|
23
|
+
});
|
|
24
|
+
/** A GameUi no Game connected has no viewport: nothing it holds is ever placed. */
|
|
25
|
+
const UNPLACED = { x: 0, y: 0, clipped: true, depth: 0 };
|
|
26
|
+
/**
|
|
27
|
+
* Anchored Pieces (issue #72, ADR 0018) — instances of a UI Piece, as many
|
|
28
|
+
* as needed, each following an entity in one layer of GameUi's overlay,
|
|
29
|
+
* each with its own shadow root and its own values ahead of the Game's stats.
|
|
30
|
+
* Owned by GameUi, which reaches it through `attach`; the Game drives the
|
|
31
|
+
* rest through `anchoredPiecesOf` (ui.ts): `connect` once, `place` every
|
|
32
|
+
* render frame.
|
|
33
|
+
*/
|
|
34
|
+
export class AnchoredPieces {
|
|
35
|
+
deps;
|
|
36
|
+
instances = [];
|
|
37
|
+
/** Undefined piece names already warned about: one warning per name per Game. */
|
|
38
|
+
warnedPieces = new Set();
|
|
39
|
+
layer;
|
|
40
|
+
view = null;
|
|
41
|
+
constructor(deps) {
|
|
42
|
+
this.deps = deps;
|
|
43
|
+
}
|
|
44
|
+
/** Called once by the Game: where the camera, viewport and projection are read from. */
|
|
45
|
+
connect(view) {
|
|
46
|
+
this.view = view;
|
|
47
|
+
}
|
|
48
|
+
attach(piece, entity, options = {}) {
|
|
49
|
+
const html = this.deps.source(piece);
|
|
50
|
+
if (html === undefined && !this.warnedPieces.has(piece)) {
|
|
51
|
+
this.warnedPieces.add(piece);
|
|
52
|
+
console.warn(`[waica] cannot attach unknown ui piece: "${piece}"`);
|
|
53
|
+
}
|
|
54
|
+
if (!entity.alive) {
|
|
55
|
+
console.warn(`[waica] cannot attach ui piece "${piece}" to destroyed entity "${entity.name}"`);
|
|
56
|
+
}
|
|
57
|
+
if (html === undefined || !entity.alive)
|
|
58
|
+
return INERT_HANDLE;
|
|
59
|
+
const host = document.createElement('div');
|
|
60
|
+
host.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none';
|
|
61
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
62
|
+
const root = document.createElement('div');
|
|
63
|
+
root.style.display = 'contents';
|
|
64
|
+
root.innerHTML = html;
|
|
65
|
+
shadow.append(root);
|
|
66
|
+
const offset = options.offset ?? [0, 0];
|
|
67
|
+
const instance = {
|
|
68
|
+
piece,
|
|
69
|
+
entity,
|
|
70
|
+
offset: [offset[0], offset[1]],
|
|
71
|
+
values: new Map(Object.entries(options.values ?? {})),
|
|
72
|
+
host,
|
|
73
|
+
root,
|
|
74
|
+
texts: new Map(),
|
|
75
|
+
unsubs: [],
|
|
76
|
+
born: entity.game.time.now,
|
|
77
|
+
clocks: new WeakMap(),
|
|
78
|
+
named: new WeakMap(),
|
|
79
|
+
expiry: null,
|
|
80
|
+
frozen: null,
|
|
81
|
+
placed: null,
|
|
82
|
+
alive: true,
|
|
83
|
+
};
|
|
84
|
+
this.bind(instance);
|
|
85
|
+
for (const [name, value] of instance.values)
|
|
86
|
+
publish(host, name, value);
|
|
87
|
+
this.mountLayer().append(host);
|
|
88
|
+
this.instances.push(instance);
|
|
89
|
+
if (options.seconds !== undefined) {
|
|
90
|
+
// Not owned by the entity: it must outlive it. Scene-scoped, like the
|
|
91
|
+
// instance itself. A duration game.time rejects (it warns) leaves the
|
|
92
|
+
// instance to die with its entity instead of lingering forever.
|
|
93
|
+
const expiry = entity.game.time.after(options.seconds, () => this.remove(instance));
|
|
94
|
+
instance.expiry = expiry.active ? expiry : null;
|
|
95
|
+
}
|
|
96
|
+
return this.handleFor(instance);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Called by the Game before an entity's destroy() returns: its instances
|
|
100
|
+
* go with it, except those given `seconds`, which stay frozen at its
|
|
101
|
+
* current anchor point until they expire.
|
|
102
|
+
*/
|
|
103
|
+
release(entity) {
|
|
104
|
+
for (const instance of this.instances.filter((candidate) => candidate.entity === entity)) {
|
|
105
|
+
if (instance.expiry)
|
|
106
|
+
instance.frozen = anchorPoint(instance, this.view?.().projection ?? null);
|
|
107
|
+
else
|
|
108
|
+
this.remove(instance);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The live instances for the Runtime Snapshot, in creation order, with
|
|
113
|
+
* the coordinates of their last placement — or, for one attached since
|
|
114
|
+
* the last render frame, where the next frame will place it.
|
|
115
|
+
*/
|
|
116
|
+
snapshot() {
|
|
117
|
+
return this.instances.map((instance) => {
|
|
118
|
+
const { x, y, clipped } = instance.placed ?? (this.view ? locate(instance, this.view()) : UNPLACED);
|
|
119
|
+
return {
|
|
120
|
+
piece: instance.piece,
|
|
121
|
+
entity: instance.entity.name,
|
|
122
|
+
x,
|
|
123
|
+
y,
|
|
124
|
+
clipped,
|
|
125
|
+
values: Object.fromEntries(instance.values),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Removes every instance, lingering ones too (GameUi.unloadScene). */
|
|
130
|
+
clear() {
|
|
131
|
+
for (const instance of [...this.instances])
|
|
132
|
+
this.remove(instance);
|
|
133
|
+
}
|
|
134
|
+
/** Removes every instance and forgets the layer along with the overlay (GameUi.dispose). */
|
|
135
|
+
dispose() {
|
|
136
|
+
this.clear();
|
|
137
|
+
this.layer = undefined;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Called by the Game once per render frame, after the isometric pass and
|
|
141
|
+
* before the render (never per Simulation Step, never on attach): fits
|
|
142
|
+
* the layer to the game viewport, sets the frame's `--waica-unit` on it
|
|
143
|
+
* (every instance inherits it through its shadow host) and puts every
|
|
144
|
+
* instance's zero-size shadow host at its anchor point, its CSS
|
|
145
|
+
* animations set to Game Time (followGameTime).
|
|
146
|
+
*/
|
|
147
|
+
place() {
|
|
148
|
+
const layer = this.layer;
|
|
149
|
+
if (!layer || !this.view)
|
|
150
|
+
return;
|
|
151
|
+
const view = this.view();
|
|
152
|
+
const { camera, viewport } = view;
|
|
153
|
+
layer.style.left = `${viewport.x}px`;
|
|
154
|
+
layer.style.top = `${viewport.y}px`;
|
|
155
|
+
layer.style.width = `${viewport.width}px`;
|
|
156
|
+
layer.style.height = `${viewport.height}px`;
|
|
157
|
+
// The camera frames exactly viewHeight world units vertically (Game.resize).
|
|
158
|
+
layer.style.setProperty('--waica-unit', `${viewport.height / (camera.top - camera.bottom)}px`);
|
|
159
|
+
// Before any instance's style writes below: getAnimations() flushes style.
|
|
160
|
+
for (const instance of this.instances)
|
|
161
|
+
followGameTime(instance);
|
|
162
|
+
const byDepth = [];
|
|
163
|
+
for (const instance of this.instances) {
|
|
164
|
+
const placement = locate(instance, view);
|
|
165
|
+
instance.placed = placement;
|
|
166
|
+
instance.host.style.left = `${placement.x}px`;
|
|
167
|
+
instance.host.style.top = `${placement.y}px`;
|
|
168
|
+
byDepth.push([instance, placement.depth]);
|
|
169
|
+
}
|
|
170
|
+
// Lower on screen draws on top, like y-sort; the sort is stable, so
|
|
171
|
+
// equal heights keep creation order, later on top.
|
|
172
|
+
byDepth.sort(([, a], [, b]) => a - b);
|
|
173
|
+
for (const [index, [instance]] of byDepth.entries())
|
|
174
|
+
instance.host.style.zIndex = String(index + 1);
|
|
175
|
+
}
|
|
176
|
+
bind(instance) {
|
|
177
|
+
for (const [name, text] of placeholders(instance.root)) {
|
|
178
|
+
const texts = instance.texts.get(name);
|
|
179
|
+
if (texts)
|
|
180
|
+
texts.push(text);
|
|
181
|
+
else
|
|
182
|
+
instance.texts.set(name, [text]);
|
|
183
|
+
}
|
|
184
|
+
for (const name of instance.texts.keys()) {
|
|
185
|
+
this.render(instance, name);
|
|
186
|
+
instance.unsubs.push(this.deps.stats.onChange(name, () => {
|
|
187
|
+
if (!instance.values.has(name))
|
|
188
|
+
this.render(instance, name);
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Writes a name's current value — the instance's own, else the Game stat — into its placeholders. */
|
|
193
|
+
render(instance, name) {
|
|
194
|
+
const value = instance.values.has(name) ? instance.values.get(name) : this.deps.stats.get(name);
|
|
195
|
+
for (const text of instance.texts.get(name) ?? [])
|
|
196
|
+
text.nodeValue = renderStat(value);
|
|
197
|
+
}
|
|
198
|
+
set(instance, name, value) {
|
|
199
|
+
if (!instance.alive)
|
|
200
|
+
return;
|
|
201
|
+
instance.values.set(name, value);
|
|
202
|
+
this.render(instance, name);
|
|
203
|
+
publish(instance.host, name, value);
|
|
204
|
+
}
|
|
205
|
+
remove(instance) {
|
|
206
|
+
if (!instance.alive)
|
|
207
|
+
return;
|
|
208
|
+
instance.alive = false;
|
|
209
|
+
instance.expiry?.cancel();
|
|
210
|
+
for (const off of instance.unsubs)
|
|
211
|
+
off();
|
|
212
|
+
instance.host.remove();
|
|
213
|
+
this.instances.splice(this.instances.indexOf(instance), 1);
|
|
214
|
+
}
|
|
215
|
+
handleFor(instance) {
|
|
216
|
+
const pieces = this;
|
|
217
|
+
return {
|
|
218
|
+
set(name, value) {
|
|
219
|
+
pieces.set(instance, name, value);
|
|
220
|
+
},
|
|
221
|
+
remove() {
|
|
222
|
+
pieces.remove(instance);
|
|
223
|
+
},
|
|
224
|
+
get alive() {
|
|
225
|
+
return instance.alive;
|
|
226
|
+
},
|
|
227
|
+
get element() {
|
|
228
|
+
return instance.alive ? instance.root : null;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The single layer every instance lives in, kept as the overlay's first
|
|
234
|
+
* child — even when a screen piece created the overlay first — so every
|
|
235
|
+
* screen-space piece draws above every anchored instance.
|
|
236
|
+
*/
|
|
237
|
+
mountLayer() {
|
|
238
|
+
if (this.layer)
|
|
239
|
+
return this.layer;
|
|
240
|
+
const layer = document.createElement('div');
|
|
241
|
+
// Until the first frame fits it to the game viewport, it covers the overlay.
|
|
242
|
+
// Its own stacking context (z-index:0) keeps every instance's z-index
|
|
243
|
+
// below the screen-piece shells that follow it.
|
|
244
|
+
layer.style.cssText =
|
|
245
|
+
'position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;z-index:0;pointer-events:none';
|
|
246
|
+
this.deps.overlay().prepend(layer);
|
|
247
|
+
this.layer = layer;
|
|
248
|
+
return layer;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** The entity's render point plus the offset, in render space — or the point frozen at its destroy(). */
|
|
252
|
+
function anchorPoint(instance, projection) {
|
|
253
|
+
if (instance.frozen)
|
|
254
|
+
return instance.frozen;
|
|
255
|
+
const { x, y } = instance.entity.position;
|
|
256
|
+
const render = projection === 'isometric' ? projectIsometric(x, y) : { x, y };
|
|
257
|
+
return { x: render.x + instance.offset[0], y: render.y + instance.offset[1] };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The anchor point in whole CSS px from the game viewport's top-left
|
|
261
|
+
* corner: the exact inverse of the Pointer's screen→world mapping.
|
|
262
|
+
*/
|
|
263
|
+
function locate(instance, view) {
|
|
264
|
+
const anchor = anchorPoint(instance, view.projection);
|
|
265
|
+
const { camera, viewport } = view;
|
|
266
|
+
const nx = (anchor.x - (camera.position.x + camera.left)) / (camera.right - camera.left);
|
|
267
|
+
const ny = (camera.position.y + camera.top - anchor.y) / (camera.top - camera.bottom);
|
|
268
|
+
return {
|
|
269
|
+
x: whole(nx * viewport.width),
|
|
270
|
+
y: whole(ny * viewport.height),
|
|
271
|
+
clipped: nx < 0 || nx > 1 || ny < 0 || ny > 1,
|
|
272
|
+
depth: ny,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Drives the instance's CSS animations from Game Time instead of the
|
|
277
|
+
* document timeline: each is paused at the Game Time elapsed since it
|
|
278
|
+
* started, so a frame that runs no Simulation Step leaves it exactly where
|
|
279
|
+
* it was. One there at the first placement started with the instance (its
|
|
280
|
+
* attach); a later one (a transition a set() started) with the frame it
|
|
281
|
+
* first shows up in. Called before the frame updates `placed`. A DOM
|
|
282
|
+
* without the Web Animations API (happy-dom returns none) is left alone.
|
|
283
|
+
*/
|
|
284
|
+
function followGameTime(instance) {
|
|
285
|
+
const animations = instance.host.shadowRoot?.getAnimations?.() ?? [];
|
|
286
|
+
const now = instance.entity.game.time.now;
|
|
287
|
+
const firstSeen = instance.placed ? now : instance.born;
|
|
288
|
+
for (const animation of animations) {
|
|
289
|
+
const start = animationStart(instance, animation, firstSeen);
|
|
290
|
+
animation.pause();
|
|
291
|
+
animation.currentTime = (now - start) * 1000;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The Game Time `animation` counts from, recorded as `firstSeen` the first
|
|
296
|
+
* time it is met. A named CSS animation is known by its target element and
|
|
297
|
+
* name, not by object: hiding the overlay (not simulating) cancels it, and
|
|
298
|
+
* the new one the browser makes when it shows again resumes mid-flight. A
|
|
299
|
+
* transition, or anything else without a name, is known by object.
|
|
300
|
+
*/
|
|
301
|
+
function animationStart(instance, animation, firstSeen) {
|
|
302
|
+
const name = 'animationName' in animation ? animation.animationName : undefined;
|
|
303
|
+
const effect = animation.effect;
|
|
304
|
+
const target = effect && 'target' in effect ? effect.target : null;
|
|
305
|
+
if (typeof name === 'string' && target instanceof Element) {
|
|
306
|
+
let byName = instance.named.get(target);
|
|
307
|
+
if (!byName)
|
|
308
|
+
instance.named.set(target, (byName = new Map()));
|
|
309
|
+
const start = byName.get(name) ?? firstSeen;
|
|
310
|
+
byName.set(name, start);
|
|
311
|
+
return start;
|
|
312
|
+
}
|
|
313
|
+
const start = instance.clocks.get(animation) ?? firstSeen;
|
|
314
|
+
instance.clocks.set(animation, start);
|
|
315
|
+
return start;
|
|
316
|
+
}
|
|
317
|
+
/** Rounds to a whole pixel, never -0. */
|
|
318
|
+
function whole(value) {
|
|
319
|
+
return Math.round(value) || 0;
|
|
320
|
+
}
|
|
321
|
+
/** Numbers verbatim and booleans as 1/0 become `--name`; a string publishes nothing. */
|
|
322
|
+
function publish(host, name, value) {
|
|
323
|
+
const property = `--${name}`;
|
|
324
|
+
if (typeof value === 'number')
|
|
325
|
+
host.style.setProperty(property, String(value));
|
|
326
|
+
else if (typeof value === 'boolean')
|
|
327
|
+
host.style.setProperty(property, value ? '1' : '0');
|
|
328
|
+
else
|
|
329
|
+
host.style.removeProperty(property);
|
|
330
|
+
}
|
package/dist/component.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface ParamSpec {
|
|
|
12
12
|
/** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
|
|
13
13
|
options?: string[];
|
|
14
14
|
/** Project value this string param names; rendered and validated as a typed reference. */
|
|
15
|
-
ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound';
|
|
15
|
+
ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound' | 'ui';
|
|
16
16
|
}
|
|
17
17
|
export interface ComponentClass<T extends Component = Component> {
|
|
18
18
|
new (): T;
|
package/dist/game.d.ts
CHANGED
|
@@ -171,7 +171,7 @@ export declare class Game {
|
|
|
171
171
|
setSceneCamera(json?: SceneCameraJson): void;
|
|
172
172
|
start(): void;
|
|
173
173
|
stop(): void;
|
|
174
|
-
/** Internal: called by Entity.destroy(). */
|
|
174
|
+
/** Internal: called by Entity.destroy(). Its Anchored Pieces go (or freeze) with it. */
|
|
175
175
|
removeEntity(entity: Entity): void;
|
|
176
176
|
/** The scene's render projection; null keeps logical and render space identical. */
|
|
177
177
|
get projection(): 'isometric' | null;
|
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';
|
|
@@ -16,7 +17,7 @@ import { isYSortParticipant, ySortZ } from './render-sort.js';
|
|
|
16
17
|
import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
|
|
17
18
|
import { createSpatialQuery } from './spatial-query.js';
|
|
18
19
|
import { Stats } from './stats.js';
|
|
19
|
-
import { GameUi } from './ui.js';
|
|
20
|
+
import { anchoredPiecesOf, GameUi } from './ui.js';
|
|
20
21
|
/**
|
|
21
22
|
* Engine core: loop, unified 2D/3D three scene, orthographic camera,
|
|
22
23
|
* entities with components, and input. See DESIGN.md.
|
|
@@ -88,6 +89,11 @@ export class Game {
|
|
|
88
89
|
this.query = createSpatialQuery(this);
|
|
89
90
|
this.stats = new Stats(options.stats);
|
|
90
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
|
+
}));
|
|
91
97
|
this.audio = new AudioSubsystem({
|
|
92
98
|
canvas,
|
|
93
99
|
backend: options.audio,
|
|
@@ -318,8 +324,9 @@ export class Game {
|
|
|
318
324
|
stop() {
|
|
319
325
|
this.renderer.setAnimationLoop(null);
|
|
320
326
|
}
|
|
321
|
-
/** Internal: called by Entity.destroy(). */
|
|
327
|
+
/** Internal: called by Entity.destroy(). Its Anchored Pieces go (or freeze) with it. */
|
|
322
328
|
removeEntity(entity) {
|
|
329
|
+
anchoredPiecesOf(this.ui).release(entity);
|
|
323
330
|
const i = this.entities.indexOf(entity);
|
|
324
331
|
if (i !== -1)
|
|
325
332
|
this.entities.splice(i, 1);
|
|
@@ -511,6 +518,7 @@ export class Game {
|
|
|
511
518
|
if (this.renderSort === 'y')
|
|
512
519
|
this.applyYSort();
|
|
513
520
|
this.ui.setActive(this.simulate);
|
|
521
|
+
anchoredPiecesOf(this.ui).place();
|
|
514
522
|
if (this.resolution) {
|
|
515
523
|
// Letterbox bars: clear the whole canvas, then render inside the scissor.
|
|
516
524
|
this.renderer.setScissorTest(false);
|
package/dist/index.d.ts
CHANGED
|
@@ -32,11 +32,12 @@ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from
|
|
|
32
32
|
export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
|
|
33
33
|
export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
|
|
34
34
|
export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
|
|
35
|
-
export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeTransformSnapshot, } from './runtime-inspection.js';
|
|
35
|
+
export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeSnapshotUi, RuntimeTransformSnapshot, } from './runtime-inspection.js';
|
|
36
36
|
export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
|
|
37
37
|
export { Stats } from './stats.js';
|
|
38
38
|
export type { StatValue } from './stats.js';
|
|
39
39
|
export { GameUi } from './ui.js';
|
|
40
|
+
export type { AnchoredPieceHandle, AttachOptions } from './anchored-pieces.js';
|
|
40
41
|
export { Sprite } from './components/sprite.js';
|
|
41
42
|
export { Solid } from './components/solid.js';
|
|
42
43
|
export { Hitbox } from './components/hitbox.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));
|
|
@@ -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
|
}
|