@netforge/template-dungeon 0.0.0-stage → 0.1.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/client/host.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Net Forge as host. Framed by Net Forge, the page URL carries
3
+ * the Kit flags and the host posts one message; played on its own, none of
4
+ * this applies and the game behaves exactly as before.
5
+ *
6
+ * ?host=netnet the host draws Invite and sign-in; the game hides its own
7
+ * ?coin=TICKER the coin the HUD names (2 to 6 capitals or digits)
8
+ * ?title=NAME the game's own name for the wordmark and the tab title
9
+ * ?player=guest no wallet is connected: play pays nothing, and the HUD
10
+ * says "not earning" instead of a balance
11
+ * ?player=play the token has not launched: coins are play money
12
+ *
13
+ * The player flag changes while the game runs (a wallet connects), so the
14
+ * host also posts { kind: "netnet-kit", type: "player", mode }. Only the
15
+ * parent window is listened to, and only when the URL says it is the host.
16
+ */
17
+
18
+ export type PlayerMode = "earning" | "guest" | "play";
19
+
20
+ export interface HostFlags {
21
+ /** Framed by Net Forge. */
22
+ readonly framed: boolean;
23
+ /** The coin's ticker, when the host names one. */
24
+ readonly coin: string | null;
25
+ /** The game's own name, when the host names one. */
26
+ readonly title: string | null;
27
+ /** What play pays the person at the screen. Standalone play is "earning" (the game's own kit decides). */
28
+ readonly player: PlayerMode;
29
+ }
30
+
31
+ const TICKER_RE = /^[A-Z0-9]{2,6}$/;
32
+
33
+ export function hostFlags(params: URLSearchParams): HostFlags {
34
+ const framed = params.get("host") === "netnet";
35
+ const coin = params.get("coin");
36
+ // eslint-disable-next-line no-control-regex
37
+ const title = (params.get("title") ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 40);
38
+ const player = params.get("player");
39
+ return {
40
+ framed,
41
+ coin: coin && TICKER_RE.test(coin) ? coin : null,
42
+ title: title || null,
43
+ player: framed && (player === "guest" || player === "play") ? player : "earning",
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The player mode the host named on the URL, or null when it named none (a
49
+ * connected player on a live game gets no flag, only the posted message) or
50
+ * the page is not framed by Net Forge.
51
+ */
52
+ export function namedPlayer(params: URLSearchParams): PlayerMode | null {
53
+ if (params.get("host") !== "netnet") return null;
54
+ const player = params.get("player");
55
+ return player === "earning" || player === "guest" || player === "play" ? player : null;
56
+ }
57
+
58
+ /**
59
+ * The store's note under its prices, from one source of truth. Once the host
60
+ * has named the player mode (on the URL or in a message), that mode decides,
61
+ * exactly as it does for the coin box: a live game never calls its coins play
62
+ * money. Standalone play, or a host that names no mode (the studio's drafts),
63
+ * keeps the room's own word (`welcome.earnLive`).
64
+ */
65
+ export function storeNote(hostMode: PlayerMode | null, earnLive: boolean, symbol: string): string {
66
+ if (hostMode === "play") return "Play money until launch";
67
+ if (hostMode === "guest") return `Connect a wallet to earn ${symbol}`;
68
+ if (hostMode === "earning") return "";
69
+ return earnLive ? "" : "Play money until launch";
70
+ }
71
+
72
+ /** A host message, checked: anything else is ignored. */
73
+ export function playerFrom(data: unknown): PlayerMode | null {
74
+ if (typeof data !== "object" || data === null) return null;
75
+ const m = data as Record<string, unknown>;
76
+ if (m["kind"] !== "netnet-kit" || m["type"] !== "player") return null;
77
+ const mode = m["mode"];
78
+ return mode === "earning" || mode === "guest" || mode === "play" ? mode : null;
79
+ }
80
+
81
+ /** Calls `cb` with each player mode the host posts. Framed pages only. */
82
+ export function onHostPlayer(flags: HostFlags, cb: (mode: PlayerMode) => void): void {
83
+ if (!flags.framed || window.parent === window) return;
84
+ window.addEventListener("message", (e: MessageEvent<unknown>) => {
85
+ if (e.source !== window.parent) return;
86
+ const mode = playerFrom(e.data);
87
+ if (mode) cb(mode);
88
+ });
89
+ }
90
+
91
+ /** Tells the host the game is up, so it can post the current player mode. */
92
+ export function postReady(flags: HostFlags): void {
93
+ if (!flags.framed || window.parent === window) return;
94
+ // The host's origin is not known to an opaque-origin frame; the message is one public flag.
95
+ window.parent.postMessage({ kind: "netnet-kit", type: "ready" }, "*");
96
+ }
package/client/hud.ts ADDED
@@ -0,0 +1,577 @@
1
+ /**
2
+ * The HUD: plain DOM over the canvas (Preact or plain
3
+ * DOM). Text updates are throttled; bars move every frame with transforms.
4
+ * Copy is labels, not sentences.
5
+ */
6
+ import { DOT_ICON, STAT_ICONS, WEAPON_ICONS } from "./icons.js";
7
+ import { decodeChoices, EntityFlag, maxHealthFor, slotLevel, slotWeapon, statLevel, xpForLevel } from "@netforge/engine-sim";
8
+ import type { WeaponDef } from "@netforge/engine";
9
+ import type { ResolvedSimConfig } from "@netforge/engine-sim";
10
+ import { F, PLAYER_UNITS, todaysGoals } from "@netforge/engine/game";
11
+ import type { CompiledGame, RunInfo } from "@netforge/engine/game";
12
+ import type { SceneEntity } from "./scene.js";
13
+ import { ENEMY_NAMES, RULE_NAMES, UPGRADE_NAMES, WEAPON_NAMES } from "./names.js";
14
+ import { KEEPER_COLORS } from "./palette.js";
15
+
16
+ const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
17
+ const e = document.getElementById(id);
18
+ if (!e) throw new Error(`#${id} missing`);
19
+ return e as T;
20
+ };
21
+
22
+ /** What one weapon level changes, in words ("+1 projectile, -20% cooldown"), from its declared levels or the generic curve. */
23
+ function levelChange(def: WeaponDef | undefined, level: number): string {
24
+ if (!def) return "";
25
+ const change = def.levels?.[level - 1];
26
+ if (!change) return def.levels ? "" : "+25% damage";
27
+ const noun: Record<string, string> = { bolt: "projectile", boomerang: "blade", orbit: "blade", chain: "jump", meteor: "meteor", cone: "sweep", nova: "pulse", aura: "pulse" };
28
+ return Object.entries(change)
29
+ .map(([field, v]) => {
30
+ if (typeof v === "string") return `${v} ${field}`;
31
+ const sign = v >= 0 ? "+" : "";
32
+ if (field === "count") return `${sign}${v} ${noun[def.kind] ?? "count"}`;
33
+ if (field === "cooldown") return `${sign}${v}s cooldown`;
34
+ if (field === "range") return `${sign}${v} m range`;
35
+ if (field === "spread") return `${sign}${v}° wider`;
36
+ return `${sign}${v} ${field}`;
37
+ })
38
+ .join(", ");
39
+ }
40
+
41
+ const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
42
+ /** A declared id as words ("gloam_ram" → "Gloam Ram") when the names table has no entry. */
43
+ const words = (id: string) => id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
44
+ const SOURCE_WORDS: Readonly<Record<string, string>> = { contact: "touch", charge: "charge", blast: "blast", projectile: "shot", slam: "slam", hazard: "hazard" };
45
+
46
+ /** Today's goals: three of the declared missions, the same for everyone on a UTC day (the engine's rule; the server counts the same ones). */
47
+ export { todaysGoals };
48
+
49
+ export interface HudCallbacks {
50
+ pick(k: number): void;
51
+ reroll(): void;
52
+ skip(): void;
53
+ revive(): void;
54
+ /** Spend the free self-revive (the same as Space while downed). */
55
+ getUp(): void;
56
+ invite(): void;
57
+ toggleStore(): void;
58
+ toggleSound(): boolean;
59
+ /** Sign in with a wallet: saves follow the wallet to every device. */
60
+ signIn(): void;
61
+ /** Choose keeper `index` (taken at the start of a run). */
62
+ keeper(index: number): void;
63
+ /** The end screen's "Play again" vote. */
64
+ again(): void;
65
+ }
66
+
67
+ export class Hud {
68
+ private lastText = 0;
69
+ private offerKey = "";
70
+ private readonly earnedThisRun = new Map<string, number>();
71
+ private toastTimer = 0;
72
+ private lastRun = 0;
73
+ private lastAnnounce = 0;
74
+ private announceTimer = 0;
75
+ /** Most enemies alive this run (the end screen shows it). */
76
+ private peak = 0;
77
+ /** The game's coin: its token's own symbol and logo (never a generic "COIN"). */
78
+ private symbol = "";
79
+ /** What play pays here (client/host.ts): a guest in Net Forge earns nothing, and the HUD says so. */
80
+ private mode: "earning" | "guest" | "play" = "earning";
81
+ /** The keeper this viewer picked (index), and the achievements this browser has seen. */
82
+ private keeperPick = 0;
83
+ private unlocked: ReadonlySet<string> = new Set();
84
+ private keepersKey = "";
85
+ private invKey = "";
86
+ private goalsKey = "";
87
+ private endKey = "";
88
+ private voted = false;
89
+ /** Goals done today (this browser). */
90
+ private goalsDone = new Set<string>();
91
+ private readonly day = Math.floor(Date.now() / 86_400_000);
92
+ private readonly goals: string[];
93
+
94
+ constructor(
95
+ private readonly game: CompiledGame,
96
+ private readonly config: ResolvedSimConfig,
97
+ private readonly cb: HudCallbacks,
98
+ ) {
99
+ for (let k = 0; k < 3; k++) $(`card${k}`).addEventListener("click", () => cb.pick(k));
100
+ $("reroll").addEventListener("click", () => cb.reroll());
101
+ $("skip").addEventListener("click", () => cb.skip());
102
+ $("revive-btn").addEventListener("click", () => cb.revive());
103
+ $("getup-btn").addEventListener("click", () => cb.getUp());
104
+ $("invite").addEventListener("click", () => cb.invite());
105
+ $("store-btn").addEventListener("click", () => cb.toggleStore());
106
+ $("store-close").addEventListener("click", () => cb.toggleStore());
107
+ // The label names the current state: "Sound on" while you hear it.
108
+ $("sound").addEventListener("click", () => this.setSound(cb.toggleSound()));
109
+ $("signin").addEventListener("click", () => cb.signIn());
110
+ $("end-again").addEventListener("click", () => {
111
+ this.voted = true;
112
+ $("end-again").classList.add("voted");
113
+ cb.again();
114
+ });
115
+ $("keepers").addEventListener("click", (e) => {
116
+ const b = (e.target as HTMLElement).closest<HTMLElement>("[data-k]");
117
+ if (!b || b.classList.contains("locked")) return;
118
+ cb.keeper(Number(b.dataset.k));
119
+ });
120
+ this.goals = todaysGoals([...game.manifest.missions.keys()], this.day);
121
+ }
122
+
123
+ /** Your own level-up: a warm flash and the word, for you only. */
124
+ levelUp(): void {
125
+ const el = $("lvlflash");
126
+ el.classList.remove("go");
127
+ void el.offsetWidth;
128
+ el.classList.add("go");
129
+ }
130
+
131
+ /** Touch screens: say how to move and dash with the stick and the button. */
132
+ touchHint(): void {
133
+ $("intro").textContent = "Drag to move · DASH to dodge";
134
+ }
135
+
136
+ /** Show whether sound is muted (the button names the current state). */
137
+ setSound(muted: boolean): void {
138
+ const b = $("sound");
139
+ b.textContent = muted ? "Sound off" : "Sound on";
140
+ b.setAttribute("aria-pressed", muted ? "false" : "true");
141
+ }
142
+
143
+ /** Who the saves belong to: a wallet (short address) or a guest (the button signs in). */
144
+ setAccount(address: string | null): void {
145
+ const b = $("signin");
146
+ b.textContent = address ? `${address.slice(0, 6)}…${address.slice(-4)}` : "Sign in to save";
147
+ b.classList.toggle("signed", address !== null);
148
+ b.toggleAttribute("disabled", address !== null);
149
+ }
150
+
151
+ /** The viewer's keeper pick, the keeper ids its profile opened (achievements or coins), and today's goals done (all from the server). */
152
+ setKeepers(pick: number, unlocked: ReadonlySet<string>, goalsDone: ReadonlySet<string>): void {
153
+ this.keeperPick = pick;
154
+ this.unlocked = unlocked;
155
+ this.goalsDone = new Set(goalsDone);
156
+ this.keepersKey = "";
157
+ }
158
+
159
+ /** True when a keeper may be picked now (no unlock, or the profile reached its achievement). */
160
+ keeperOpen(index: number): boolean {
161
+ const [id, k] = [...this.game.manifest.keepers][index] ?? [];
162
+ return !!k && (k.unlock === undefined || this.unlocked.has(id ?? ""));
163
+ }
164
+
165
+ /** The keeper picker: shown at the start of a run (before the first level) and on the end screen. */
166
+ private keepers(show: boolean, inEnd: boolean): void {
167
+ const box = $("keepers");
168
+ const list = [...this.game.manifest.keepers.values()];
169
+ const visible = show && list.length > 0;
170
+ // It lives in the end box while the end screen shows.
171
+ const host = inEnd ? $("end-keeper-slot") : document.body;
172
+ if (box.parentElement !== host) host.append(box);
173
+ box.classList.toggle("show", visible);
174
+ if (!visible) return;
175
+ const key = `${this.keeperPick}:${[...this.unlocked].join(",")}`;
176
+ if (key === this.keepersKey) return;
177
+ this.keepersKey = key;
178
+ box.innerHTML = list
179
+ .map((k, i) => {
180
+ const open = this.keeperOpen(i);
181
+ return `<button class="keeper${i === this.keeperPick ? " on" : ""}${open ? "" : " locked"}" data-k="${i}" style="--k:${k.tint ?? "#ffb347"}"><b></b><small></small></button>`;
182
+ })
183
+ .join("");
184
+ list.forEach((k, i) => {
185
+ const b = box.children[i] as HTMLElement;
186
+ (b.querySelector("b") as HTMLElement).textContent = k.name;
187
+ const lock = k.unlock ? (this.game.manifest.achievements.get(k.unlock)?.text ?? k.unlock) : "";
188
+ (b.querySelector("small") as HTMLElement).textContent = this.keeperOpen(i) ? (k.text ?? "") : `Locked: ${lock}`;
189
+ });
190
+ }
191
+
192
+ /** Weapons and passives with levels, and the dash cooldown ring. */
193
+ private inventory(self: SceneEntity): void {
194
+ const dash = self.values[F.dash] ?? 0;
195
+ const cdMax = Math.max(1, this.config.content.coop.dashCooldownTicks);
196
+ $("dash-ring").style.setProperty("--p", String(1 - ((dash >>> 8) & 0xff) / cdMax));
197
+ const weapons = self.values[F.weapons] ?? 0;
198
+ const stats = self.values[F.stats] ?? 0;
199
+ const key = `${weapons}:${stats}`;
200
+ if (key === this.invKey) return;
201
+ this.invKey = key;
202
+ const evolvedIds = new Set([...this.game.manifest.weapons.values()].map((w) => w.evolve?.into).filter((x): x is string => !!x));
203
+ const slots: string[] = [];
204
+ for (let s = 0; s < 4; s++) {
205
+ const wi = slotWeapon(weapons, s);
206
+ if (wi < 0) continue;
207
+ const id = this.game.weaponIds[wi] ?? "";
208
+ const name = WEAPON_NAMES[id]?.name ?? words(id);
209
+ const icon = WEAPON_ICONS[this.config.content.weapons[wi]?.kind ?? ""] ?? DOT_ICON;
210
+ slots.push(`<div class="slot${evolvedIds.has(id) ? " evolved" : ""}" title="${name}">${icon}<span>${slotLevel(weapons, s)}</span></div>`);
211
+ }
212
+ this.game.upgradeIds.forEach((id, i) => {
213
+ const lvl = statLevel(stats, i);
214
+ if (lvl === 0) return;
215
+ const name = UPGRADE_NAMES[id]?.name ?? words(id);
216
+ const icon = STAT_ICONS[this.config.content.stats[i]?.stat ?? ""] ?? DOT_ICON;
217
+ slots.push(`<div class="slot passive" title="${name}">${icon}<span>${lvl}</span></div>`);
218
+ });
219
+ const ring = $("dash-ring");
220
+ $("inv").innerHTML = slots.join("");
221
+ $("inv").prepend(ring);
222
+ }
223
+
224
+ /** Today's three goals with this run's progress; a goal reached is remembered for the day. Returns newly done ids. */
225
+ private goalsUpdate(run: RunInfo, me: number): string[] {
226
+ const missions = this.game.manifest.missions;
227
+ if (this.goals.length === 0) return [];
228
+ const counts = run.players.find((p) => p.id === me)?.counts ?? {};
229
+ const fresh: string[] = [];
230
+ const rows = this.goals.map((id) => {
231
+ const m = missions.get(id);
232
+ if (!m) return "";
233
+ const n = Math.min(m.n, counts[m.when] ?? 0);
234
+ if (n >= m.n && !this.goalsDone.has(id)) {
235
+ this.goalsDone.add(id);
236
+ fresh.push(id);
237
+ }
238
+ const done = this.goalsDone.has(id);
239
+ return `<li class="${done ? "done" : ""}"><span>${done ? "✓" : "○"}</span>${m.text}<em>${done ? "" : m.n > 1 ? `${n}/${m.n}` : ""}</em></li>`;
240
+ });
241
+ const html = rows.join("");
242
+ if (html !== this.goalsKey) {
243
+ this.goalsKey = html;
244
+ $("goals").innerHTML = html;
245
+ }
246
+ return fresh;
247
+ }
248
+
249
+ /**
250
+ * A card's words: the name, what it does or what this level changes
251
+ * ("+1 projectile, -20% cooldown"), a tag (NEW, Lv 3 → 4, EVOLUTION) and,
252
+ * for weapons, the passive it evolves with.
253
+ */
254
+ private offerLabel(u: number, weapons: number, stats: number): { name: string; line: string; tag: string; hint: string } {
255
+ const nW = this.game.weaponIds.length;
256
+ if (u < nW) {
257
+ const id = this.game.weaponIds[u] ?? "";
258
+ const n = WEAPON_NAMES[id] ?? { name: id, line: "" };
259
+ const def = this.game.manifest.weapons.get(id);
260
+ const hintId = def?.evolve?.requires;
261
+ const hint = hintId ? `Evolves with ${UPGRADE_NAMES[hintId]?.name ?? hintId}` : "";
262
+ let level = 0;
263
+ for (let s = 0; s < 4; s++) if (slotWeapon(weapons, s) === u) level = slotLevel(weapons, s);
264
+ if (level > 0) return { name: n.name, line: levelChange(def, level), tag: `Lv ${level} → ${level + 1}`, hint };
265
+ const from = [...this.game.manifest.weapons].find(([, w]) => w.evolve?.into === id)?.[0];
266
+ if (from) return { name: n.name, line: `Replaces ${WEAPON_NAMES[from]?.name ?? from}`, tag: "Evolution", hint: "" };
267
+ return { ...n, tag: "New", hint };
268
+ }
269
+ const id = this.game.upgradeIds[u - nW] ?? "";
270
+ const n = UPGRADE_NAMES[id] ?? { name: id, line: "" };
271
+ const lvl = statLevel(stats, u - nW);
272
+ return { ...n, tag: lvl > 0 ? `Lv ${lvl} → ${lvl + 1}` : "New", hint: "" };
273
+ }
274
+
275
+ /** Names the coin everywhere the HUD shows it: the token's own name, symbol and logo. */
276
+ coin(name: string, symbol: string, logo: string | null): void {
277
+ this.symbol = symbol;
278
+ const img = $<HTMLImageElement>("coin-logo");
279
+ if (logo) {
280
+ img.src = logo;
281
+ img.alt = name;
282
+ img.hidden = false;
283
+ } else img.hidden = true;
284
+ $("coin-name").textContent = name;
285
+ }
286
+
287
+ /**
288
+ * What play pays the person at the screen. A guest (no wallet in
289
+ * Net Forge) sees "not earning" where the balance was, and earn toasts say
290
+ * how to get paid; play money says so.
291
+ */
292
+ player(mode: "earning" | "guest" | "play"): void {
293
+ this.mode = mode;
294
+ const box = $("coin-box");
295
+ box.classList.toggle("guest", mode === "guest");
296
+ box.classList.toggle("play", mode === "play");
297
+ box.title = mode === "guest" ? `Connect a wallet to earn ${this.symbol}` : mode === "play" ? `${this.symbol} has not launched: coins are play money` : "";
298
+ this.lastText = 0;
299
+ }
300
+
301
+ /** The coin box's text: the balance, or "EMBER · not earning" for a guest, as VOLTYARD says it (play money gets a caption in CSS). */
302
+ private balanceText(balance: number): string {
303
+ if (this.mode === "guest") return `${this.symbol} · not earning`;
304
+ return `${Math.floor(balance).toLocaleString("en-US")} ${this.symbol}`;
305
+ }
306
+
307
+ earned(rule: string, units: number): void {
308
+ this.earnedThisRun.set(rule, (this.earnedThisRun.get(rule) ?? 0) + units);
309
+ const pay = this.game.manifest.earnRules.get(rule)?.pay ?? 0;
310
+ const name = RULE_NAMES[rule] ?? rule;
311
+ this.toast(this.mode === "guest" ? `${name} Connect to earn ${pay * units} ${this.symbol}` : `${name} +${pay * units} ${this.symbol}`);
312
+ }
313
+
314
+ toast(text: string): void {
315
+ const t = $("toast");
316
+ t.textContent = text;
317
+ t.classList.add("show");
318
+ clearTimeout(this.toastTimer);
319
+ this.toastTimer = window.setTimeout(() => t.classList.remove("show"), 2600);
320
+ }
321
+
322
+ /** Every frame. `self` is the predicted character; `players` every keeper in view (others interpolated). */
323
+ update(now: number, self: SceneEntity | undefined, players: readonly SceneEntity[], run: RunInfo | null, project: (x: number, y: number, z: number) => { x: number; y: number; onScreen: boolean }, store: { revivePrice: number; balance: number }): void {
324
+ if (run && run.run !== this.lastRun) {
325
+ this.lastRun = run.run;
326
+ this.earnedThisRun.clear();
327
+ this.peak = 0;
328
+ }
329
+ if (run) this.peak = Math.max(this.peak, run.enemies);
330
+ if (self) {
331
+ this.inventory(self);
332
+ const level = self.values[F.level] ?? 1;
333
+ const xp = self.values[F.xp] ?? 0;
334
+ const need = xpForLevel(this.config, level);
335
+ const hp = self.values[F.health] ?? 0;
336
+ const max = maxHealthFor(this.config, self.values[F.stats] ?? 0);
337
+ $("xp-fill").style.transform = `scaleX(${Math.min(1, xp / need)})`;
338
+ $("hp-fill").style.transform = `scaleX(${Math.min(1, hp / Math.max(1, max))})`;
339
+ const flags = self.values[F.flags] ?? 0;
340
+ const downed = (flags & EntityFlag.Downed) !== 0;
341
+ const dead = (flags & EntityFlag.Dead) !== 0;
342
+ const ov = $("downed");
343
+ ov.classList.toggle("show", (downed || dead) && run?.phase === "playing");
344
+ // Upgrade offers.
345
+ const { offers, pending } = decodeChoices(self.values[F.choices] ?? 0);
346
+ const rerolls = Math.max(0, this.config.content.progression.rerolls - ((self.values[F.value] ?? 0) & 0xf));
347
+ const key = pending > 0 && !downed && !dead ? offers.join(",") + ":" + pending + ":" + rerolls : "";
348
+ if (key !== this.offerKey) {
349
+ this.offerKey = key;
350
+ $("cards").classList.toggle("show", key !== "");
351
+ const weapons = self.values[F.weapons] ?? 0;
352
+ const stats = self.values[F.stats] ?? 0;
353
+ offers.forEach((u, k) => {
354
+ const card = $(`card${k}`);
355
+ card.style.display = u >= 0 ? "" : "none";
356
+ if (u < 0) return;
357
+ const l = this.offerLabel(u, weapons, stats);
358
+ card.innerHTML = `<kbd>${k + 1}</kbd><i></i><b></b><span></span><em></em>`;
359
+ (card.querySelector("b") as HTMLElement).textContent = l.name;
360
+ (card.querySelector("span") as HTMLElement).textContent = l.line;
361
+ (card.querySelector("i") as HTMLElement).textContent = l.tag;
362
+ (card.querySelector("em") as HTMLElement).textContent = l.hint;
363
+ card.classList.toggle("evolve", l.tag === "Evolution");
364
+ });
365
+ $("pending").textContent = pending > 1 ? `+${pending - 1}` : "";
366
+ const reroll = $<HTMLButtonElement>("reroll");
367
+ reroll.textContent = `Reroll ${rerolls}`;
368
+ reroll.disabled = rerolls === 0;
369
+ }
370
+ if (now - this.lastText > 100) {
371
+ $("level").textContent = `LV ${level}`;
372
+ $("hp-text").textContent = `${hp} / ${max}`;
373
+ // Game.bleed: downed or respawn ticks in bits 0..11, free get-ups used in bits 12..15.
374
+ const bleed = self.values[F.bleed] ?? 0;
375
+ const secs = Math.ceil((bleed & 0xfff) / this.config.tickRate);
376
+ const coop = this.config.content.coop;
377
+ const getUps = Math.max(0, coop.selfRevives - ((bleed >>> 12) & 0xf));
378
+ const getUp = $<HTMLButtonElement>("getup-btn");
379
+ getUp.style.display = downed && getUps > 0 ? "" : "none";
380
+ if (downed) {
381
+ $("downed-title").textContent = "DOWNED";
382
+ $("downed-sub").textContent = getUps > 0 ? `${secs}s · free get-up` : `${secs}s · crawl to a friend`;
383
+ } else if (dead) {
384
+ const teamUp = players.some((p) => p.owner !== self.owner && ((p.values[F.flags] ?? 0) & (EntityFlag.Downed | EntityFlag.Dead)) === 0);
385
+ $("downed-title").textContent = "OUT";
386
+ $("downed-sub").textContent = teamUp && coop.respawnTicks > 0 ? `Back in ${secs}s` : "";
387
+ }
388
+ const btn = $<HTMLButtonElement>("revive-btn");
389
+ btn.textContent = `Revive ${store.revivePrice} ${this.symbol}`;
390
+ btn.disabled = store.balance < store.revivePrice;
391
+ }
392
+ }
393
+ if (now - this.lastText > 100) $("coin-balance").textContent = this.balanceText(store.balance);
394
+
395
+ // The controls, centred, for the first 5 s of each run.
396
+ $("intro").classList.toggle("show", !!run && run.phase === "playing" && run.seconds < 5 && !!self);
397
+ this.arrows(run, project);
398
+ // Teammates: list, and a marker with revive progress over anyone downed.
399
+ const markers = $("markers");
400
+ let mi = 0;
401
+ for (const p of players) {
402
+ if (self && p.owner === self.owner) continue;
403
+ const flags = p.values[F.flags] ?? 0;
404
+ const downed = (flags & EntityFlag.Downed) !== 0;
405
+ const x = (p.values[F.px] ?? 0) / PLAYER_UNITS;
406
+ const z = (p.values[F.pz] ?? 0) / PLAYER_UNITS;
407
+ const s = project(x, 1.2, z);
408
+ let m = markers.children[mi] as HTMLElement | undefined;
409
+ if (!m) {
410
+ m = document.createElement("div");
411
+ m.className = "marker";
412
+ markers.append(m);
413
+ }
414
+ mi++;
415
+ const w = window.innerWidth;
416
+ const h = window.innerHeight;
417
+ // Off-screen teammates sit on the screen edge in their direction (clear of the HUD corners).
418
+ const cx = Math.max(24, Math.min(w - 24, s.x));
419
+ const cy = Math.max(150, Math.min(h - 60, s.y));
420
+ const color = KEEPER_COLORS[(p.owner - 1) % KEEPER_COLORS.length] ?? 0xffffff;
421
+ const progress = downed ? (p.values[F.revive] ?? 0) / this.config.content.coop.reviveTicks : 0;
422
+ m.style.transform = `translate(${cx}px, ${cy}px) translate(-50%, -50%)`;
423
+ m.style.setProperty("--c", `#${color.toString(16).padStart(6, "0")}`);
424
+ m.style.setProperty("--p", String(progress));
425
+ m.classList.toggle("down", downed);
426
+ m.classList.toggle("off", !s.onScreen);
427
+ m.style.display = s.onScreen && !downed ? "none" : "";
428
+ }
429
+ while (markers.children.length > mi) markers.lastElementChild?.remove();
430
+
431
+ if (now - this.lastText > 100) {
432
+ this.lastText = now;
433
+ if (run) {
434
+ $("timer").textContent = mmss(run.seconds);
435
+ // The descent: rooms cleared of the crypt's rooms (the start room counts as cleared).
436
+ const rooms = (this.config.arena.floors ?? []).filter((f) => Math.min(f.hx, f.hz) >= 4).length;
437
+ $("wave").textContent = run.paused ? "PAUSED" : rooms > 0 ? `ROOM ${Math.min(rooms, run.wave)} OF ${rooms}` : "";
438
+ $("kills").textContent = `${run.kills.toLocaleString("en-US")}`;
439
+ this.bosses(run.bosses ?? (run.boss ? [run.boss] : []));
440
+ this.team(run);
441
+ this.end(run, self?.owner ?? 0);
442
+ this.announce(run);
443
+ $("next").textContent = run.next && run.phase === "playing" ? `${run.next.label} ${mmss(run.next.in)}` : "";
444
+ const level1 = self ? (self.values[F.level] ?? 1) === 1 && decodeChoices(self.values[F.choices] ?? 0).pending === 0 : false;
445
+ this.keepers(run.phase !== "playing" || (level1 && run.seconds < 30), run.phase !== "playing");
446
+ }
447
+ }
448
+ }
449
+
450
+ /** A bar for every living boss, oldest first. */
451
+ private bosses(list: RunInfo["bosses"]): void {
452
+ const box = $("boss");
453
+ box.classList.toggle("show", list.length > 0);
454
+ while (box.children.length < list.length) {
455
+ const row = document.createElement("div");
456
+ row.className = "boss-row";
457
+ row.innerHTML = '<div class="boss-name"></div><div class="bar"><div class="boss-fill"></div></div>';
458
+ box.append(row);
459
+ }
460
+ while (box.children.length > list.length) box.lastElementChild?.remove();
461
+ list.forEach((b, i) => {
462
+ const row = box.children[i] as HTMLElement;
463
+ (row.querySelector(".boss-name") as HTMLElement).textContent = ENEMY_NAMES[b.prefab] ?? b.prefab;
464
+ (row.querySelector(".boss-fill") as HTMLElement).style.transform = `scaleX(${Math.max(0, b.health / Math.max(1, b.max))})`;
465
+ });
466
+ }
467
+
468
+ /** A new announcement from the run: a big centred banner for a few seconds. */
469
+ private announce(run: RunInfo): void {
470
+ const a = run.announce;
471
+ if (!a || a.id === this.lastAnnounce) return;
472
+ this.lastAnnounce = a.id;
473
+ const el = $("announce");
474
+ $("announce-text").textContent = a.text;
475
+ el.classList.remove("danger", "reward");
476
+ if (a.tone !== "info") el.classList.add(a.tone);
477
+ el.classList.add("show");
478
+ clearTimeout(this.announceTimer);
479
+ this.announceTimer = window.setTimeout(() => el.classList.remove("show"), 2800);
480
+ }
481
+
482
+ /** An arrow on the screen edge toward each boss that is off screen. */
483
+ private arrows(run: RunInfo | null, project: (x: number, y: number, z: number) => { x: number; y: number; onScreen: boolean }): void {
484
+ const box = $("arrows");
485
+ const list = (run?.bosses ?? []).filter((b) => !project(b.x, 1.5, b.z).onScreen);
486
+ while (box.children.length < list.length) {
487
+ const a = document.createElement("div");
488
+ a.className = "arrow";
489
+ box.append(a);
490
+ }
491
+ while (box.children.length > list.length) box.lastElementChild?.remove();
492
+ const w = window.innerWidth;
493
+ const h = window.innerHeight;
494
+ list.forEach((b, i) => {
495
+ const s = project(b.x, 1.5, b.z);
496
+ const dx = s.x - w / 2;
497
+ const dy = s.y - h / 2;
498
+ const k = Math.min((w / 2 - 36) / Math.max(1, Math.abs(dx)), (h / 2 - 36) / Math.max(1, Math.abs(dy)));
499
+ const ang = Math.atan2(dy, dx) + Math.PI / 2;
500
+ (box.children[i] as HTMLElement).style.transform = `translate(${w / 2 + dx * k - 11}px, ${h / 2 + dy * k - 11}px) rotate(${ang}rad)`;
501
+ });
502
+ }
503
+
504
+ private team(run: RunInfo): void {
505
+ const list = $("team");
506
+ const rows = run.players.map((p) => {
507
+ const color = KEEPER_COLORS[(p.id - 1) % KEEPER_COLORS.length] ?? 0xffffff;
508
+ const state = p.dead ? "OUT" : p.downed ? "DOWN" : `LV ${p.level}`;
509
+ const name = p.keeper ? (this.game.manifest.keepers.get(p.keeper)?.name ?? `Wickbearer ${p.id}`) : `Wickbearer ${p.id}`;
510
+ return `<li style="--c:#${color.toString(16).padStart(6, "0")}"><span class="dot"></span>${name}${p.buffed ? " ✦" : ""}<em>${state}</em><small>${p.kills}</small></li>`;
511
+ });
512
+ const html = rows.join("");
513
+ if (list.innerHTML !== html) list.innerHTML = html;
514
+ }
515
+
516
+ /** Goals reached this frame (main remembers them for the day). */
517
+ goalsReached(run: RunInfo | null, me: number): string[] {
518
+ return run ? this.goalsUpdate(run, me) : [];
519
+ }
520
+
521
+ /**
522
+ * The end screen, all from the run's summary data: each keeper's build
523
+ * (weapons with level and damage), what downed them, their titles, the
524
+ * coin breakdown, and the "Play again" vote (the run restarts when
525
+ * everyone votes, or by itself after the countdown).
526
+ */
527
+ private end(run: RunInfo, me: number): void {
528
+ const el = $("end");
529
+ const show = run.phase !== "playing";
530
+ el.classList.toggle("show", show);
531
+ if (!show) {
532
+ if (this.voted) $("end-again").classList.remove("voted");
533
+ this.voted = false;
534
+ this.endKey = "";
535
+ return;
536
+ }
537
+ const s = run.summary;
538
+ $("end-title").textContent = run.phase === "won" ? "THE BELL BREAKS" : "THE LIGHT WENT OUT";
539
+ $("end-stats").innerHTML = [
540
+ ["Time", mmss(s?.seconds ?? run.seconds)],
541
+ ["Peak crowd", String(s?.peak ?? this.peak)],
542
+ ["Kills", run.kills.toLocaleString("en-US")],
543
+ ]
544
+ .map(([k, v]) => `<div><small>${k}</small><b>${v}</b></div>`)
545
+ .join("");
546
+ const key = JSON.stringify(s?.players ?? []);
547
+ if (key !== this.endKey) {
548
+ this.endKey = key;
549
+ const keepers = this.game.manifest.keepers;
550
+ $("end-players").innerHTML = (s?.players ?? [])
551
+ .map((p) => {
552
+ const color = KEEPER_COLORS[(p.id - 1) % KEEPER_COLORS.length] ?? 0xffffff;
553
+ const top = Math.max(1, ...p.weapons.map((w) => w.damage));
554
+ const rows = p.weapons
555
+ .map((w) => `<li><span>${WEAPON_NAMES[w.id]?.name ?? words(w.id)} <small>Lv ${w.level}</small></span><span>${w.damage.toLocaleString("en-US")}</span><div class="dmgbar" style="transform: scaleX(${w.damage / top})"></div></li>`)
556
+ .join("");
557
+ const passives = p.passives.map((u) => `${UPGRADE_NAMES[u.id]?.name ?? words(u.id)} ${u.level}`).join(" · ");
558
+ const by = p.downedBy ? `Downed by ${ENEMY_NAMES[p.downedBy.prefab] ?? words(p.downedBy.prefab)} (${SOURCE_WORDS[p.downedBy.source] ?? p.downedBy.source})` : "Never downed";
559
+ const keeper = p.keeper ? (keepers.get(p.keeper)?.name ?? words(p.keeper)) : `Wickbearer ${p.id}`;
560
+ const coins = p.coins.map((c) => `${RULE_NAMES[c.rule] ?? words(c.rule)} +${(this.game.manifest.earnRules.get(c.rule)?.pay ?? 0) * c.units} ${this.symbol}`).join(" · ");
561
+ return `<section class="end-player" style="--c:#${color.toString(16).padStart(6, "0")}"><header><span class="dot"></span>${keeper}${p.id === me ? " <small>(you)</small>" : ""} <small>LV ${p.level} · ${p.kills} kills · ${p.revives} revives</small><span class="titles">${p.titles.join(" · ")}</span></header><ul>${rows}</ul><div class="line">${passives}</div><div class="line">${by}${coins ? ` · ${coins}` : ""}</div></section>`;
562
+ })
563
+ .join("");
564
+ }
565
+ const earned = [...this.earnedThisRun].map(([r, u]) => `<li>${RULE_NAMES[r] ?? r}<b>+${(this.game.manifest.earnRules.get(r)?.pay ?? 0) * u} ${this.symbol}</b></li>`);
566
+ $("end-earned").innerHTML =
567
+ (earned.length > 0 ? earned.join("") : "<li>No earn this run</li>") + (this.mode === "guest" && earned.length > 0 ? `<li class="guest">Not paid: connect a wallet to earn ${this.symbol}</li>` : "");
568
+ const humans = run.players.filter((p) => !p.bot);
569
+ const ready = humans.filter((p) => p.ready).length;
570
+ $("end-again").textContent = this.voted ? `Ready ${ready}/${humans.length}` : "Play again";
571
+ $("end-next").textContent = run.nextRunIn !== null ? `Next run in ${run.nextRunIn}s` : "";
572
+ }
573
+
574
+ stats(text: string): void {
575
+ $("stats").textContent = text;
576
+ }
577
+ }