@netforge/template-party 0.0.0-stage → 0.2.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/AGENTS.md +63 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +74 -2
- package/client/debris.ts +267 -0
- package/client/decor.ts +13 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +576 -0
- package/client/icons.ts +42 -0
- package/client/index.html +277 -0
- package/client/input.ts +172 -0
- package/client/library.ts +250 -0
- package/client/main.ts +607 -0
- package/client/names.ts +47 -0
- package/client/palette.ts +7 -0
- package/client/scene.ts +941 -0
- package/client/stage.ts +266 -0
- package/client/stake.ts +101 -0
- package/client/threats.ts +157 -0
- package/llms-full.txt +716 -0
- package/llms.txt +375 -0
- package/package.json +82 -3
- package/src/game.ts +326 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +107 -0
package/client/hud.ts
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
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 show: the round being played (each round's end counts as a wave).
|
|
436
|
+
$("wave").textContent = run.paused ? "PAUSED" : run.phase === "playing" ? `ROUND ${run.wave}` : "";
|
|
437
|
+
$("kills").textContent = `${run.kills.toLocaleString("en-US")}`;
|
|
438
|
+
this.bosses(run.bosses ?? (run.boss ? [run.boss] : []));
|
|
439
|
+
this.team(run);
|
|
440
|
+
this.end(run, self?.owner ?? 0);
|
|
441
|
+
this.announce(run);
|
|
442
|
+
$("next").textContent = run.next && run.phase === "playing" ? `${run.next.label} ${mmss(run.next.in)}` : "";
|
|
443
|
+
const level1 = self ? (self.values[F.level] ?? 1) === 1 && decodeChoices(self.values[F.choices] ?? 0).pending === 0 : false;
|
|
444
|
+
this.keepers(run.phase !== "playing" || (level1 && run.seconds < 30), run.phase !== "playing");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** A bar for every living boss, oldest first. */
|
|
450
|
+
private bosses(list: RunInfo["bosses"]): void {
|
|
451
|
+
const box = $("boss");
|
|
452
|
+
box.classList.toggle("show", list.length > 0);
|
|
453
|
+
while (box.children.length < list.length) {
|
|
454
|
+
const row = document.createElement("div");
|
|
455
|
+
row.className = "boss-row";
|
|
456
|
+
row.innerHTML = '<div class="boss-name"></div><div class="bar"><div class="boss-fill"></div></div>';
|
|
457
|
+
box.append(row);
|
|
458
|
+
}
|
|
459
|
+
while (box.children.length > list.length) box.lastElementChild?.remove();
|
|
460
|
+
list.forEach((b, i) => {
|
|
461
|
+
const row = box.children[i] as HTMLElement;
|
|
462
|
+
(row.querySelector(".boss-name") as HTMLElement).textContent = ENEMY_NAMES[b.prefab] ?? b.prefab;
|
|
463
|
+
(row.querySelector(".boss-fill") as HTMLElement).style.transform = `scaleX(${Math.max(0, b.health / Math.max(1, b.max))})`;
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** A new announcement from the run: a big centred banner for a few seconds. */
|
|
468
|
+
private announce(run: RunInfo): void {
|
|
469
|
+
const a = run.announce;
|
|
470
|
+
if (!a || a.id === this.lastAnnounce) return;
|
|
471
|
+
this.lastAnnounce = a.id;
|
|
472
|
+
const el = $("announce");
|
|
473
|
+
$("announce-text").textContent = a.text;
|
|
474
|
+
el.classList.remove("danger", "reward");
|
|
475
|
+
if (a.tone !== "info") el.classList.add(a.tone);
|
|
476
|
+
el.classList.add("show");
|
|
477
|
+
clearTimeout(this.announceTimer);
|
|
478
|
+
this.announceTimer = window.setTimeout(() => el.classList.remove("show"), 2800);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** An arrow on the screen edge toward each boss that is off screen. */
|
|
482
|
+
private arrows(run: RunInfo | null, project: (x: number, y: number, z: number) => { x: number; y: number; onScreen: boolean }): void {
|
|
483
|
+
const box = $("arrows");
|
|
484
|
+
const list = (run?.bosses ?? []).filter((b) => !project(b.x, 1.5, b.z).onScreen);
|
|
485
|
+
while (box.children.length < list.length) {
|
|
486
|
+
const a = document.createElement("div");
|
|
487
|
+
a.className = "arrow";
|
|
488
|
+
box.append(a);
|
|
489
|
+
}
|
|
490
|
+
while (box.children.length > list.length) box.lastElementChild?.remove();
|
|
491
|
+
const w = window.innerWidth;
|
|
492
|
+
const h = window.innerHeight;
|
|
493
|
+
list.forEach((b, i) => {
|
|
494
|
+
const s = project(b.x, 1.5, b.z);
|
|
495
|
+
const dx = s.x - w / 2;
|
|
496
|
+
const dy = s.y - h / 2;
|
|
497
|
+
const k = Math.min((w / 2 - 36) / Math.max(1, Math.abs(dx)), (h / 2 - 36) / Math.max(1, Math.abs(dy)));
|
|
498
|
+
const ang = Math.atan2(dy, dx) + Math.PI / 2;
|
|
499
|
+
(box.children[i] as HTMLElement).style.transform = `translate(${w / 2 + dx * k - 11}px, ${h / 2 + dy * k - 11}px) rotate(${ang}rad)`;
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private team(run: RunInfo): void {
|
|
504
|
+
const list = $("team");
|
|
505
|
+
const rows = run.players.map((p) => {
|
|
506
|
+
const color = KEEPER_COLORS[(p.id - 1) % KEEPER_COLORS.length] ?? 0xffffff;
|
|
507
|
+
const state = p.dead ? "OUT" : p.downed ? "DOWN" : `LV ${p.level}`;
|
|
508
|
+
const name = p.keeper ? (this.game.manifest.keepers.get(p.keeper)?.name ?? `Guest ${p.id}`) : `Guest ${p.id}`;
|
|
509
|
+
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>`;
|
|
510
|
+
});
|
|
511
|
+
const html = rows.join("");
|
|
512
|
+
if (list.innerHTML !== html) list.innerHTML = html;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Goals reached this frame (main remembers them for the day). */
|
|
516
|
+
goalsReached(run: RunInfo | null, me: number): string[] {
|
|
517
|
+
return run ? this.goalsUpdate(run, me) : [];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The end screen, all from the run's summary data: each keeper's build
|
|
522
|
+
* (weapons with level and damage), what downed them, their titles, the
|
|
523
|
+
* coin breakdown, and the "Play again" vote (the run restarts when
|
|
524
|
+
* everyone votes, or by itself after the countdown).
|
|
525
|
+
*/
|
|
526
|
+
private end(run: RunInfo, me: number): void {
|
|
527
|
+
const el = $("end");
|
|
528
|
+
const show = run.phase !== "playing";
|
|
529
|
+
el.classList.toggle("show", show);
|
|
530
|
+
if (!show) {
|
|
531
|
+
if (this.voted) $("end-again").classList.remove("voted");
|
|
532
|
+
this.voted = false;
|
|
533
|
+
this.endKey = "";
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const s = run.summary;
|
|
537
|
+
$("end-title").textContent = run.phase === "won" ? "THE BELL BREAKS" : "THE LIGHT WENT OUT";
|
|
538
|
+
$("end-stats").innerHTML = [
|
|
539
|
+
["Time", mmss(s?.seconds ?? run.seconds)],
|
|
540
|
+
["Peak crowd", String(s?.peak ?? this.peak)],
|
|
541
|
+
["Kills", run.kills.toLocaleString("en-US")],
|
|
542
|
+
]
|
|
543
|
+
.map(([k, v]) => `<div><small>${k}</small><b>${v}</b></div>`)
|
|
544
|
+
.join("");
|
|
545
|
+
const key = JSON.stringify(s?.players ?? []);
|
|
546
|
+
if (key !== this.endKey) {
|
|
547
|
+
this.endKey = key;
|
|
548
|
+
const keepers = this.game.manifest.keepers;
|
|
549
|
+
$("end-players").innerHTML = (s?.players ?? [])
|
|
550
|
+
.map((p) => {
|
|
551
|
+
const color = KEEPER_COLORS[(p.id - 1) % KEEPER_COLORS.length] ?? 0xffffff;
|
|
552
|
+
const top = Math.max(1, ...p.weapons.map((w) => w.damage));
|
|
553
|
+
const rows = p.weapons
|
|
554
|
+
.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>`)
|
|
555
|
+
.join("");
|
|
556
|
+
const passives = p.passives.map((u) => `${UPGRADE_NAMES[u.id]?.name ?? words(u.id)} ${u.level}`).join(" · ");
|
|
557
|
+
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";
|
|
558
|
+
const keeper = p.keeper ? (keepers.get(p.keeper)?.name ?? words(p.keeper)) : `Guest ${p.id}`;
|
|
559
|
+
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(" · ");
|
|
560
|
+
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>`;
|
|
561
|
+
})
|
|
562
|
+
.join("");
|
|
563
|
+
}
|
|
564
|
+
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>`);
|
|
565
|
+
$("end-earned").innerHTML =
|
|
566
|
+
(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>` : "");
|
|
567
|
+
const humans = run.players.filter((p) => !p.bot);
|
|
568
|
+
const ready = humans.filter((p) => p.ready).length;
|
|
569
|
+
$("end-again").textContent = this.voted ? `Ready ${ready}/${humans.length}` : "Play again";
|
|
570
|
+
$("end-next").textContent = run.nextRunIn !== null ? `Next run in ${run.nextRunIn}s` : "";
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
stats(text: string): void {
|
|
574
|
+
$("stats").textContent = text;
|
|
575
|
+
}
|
|
576
|
+
}
|
package/client/icons.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tray icons: one small line drawing per weapon kind and passive stat, so
|
|
3
|
+
* the ability tray reads at a glance instead of as two-letter codes. Data
|
|
4
|
+
* driven (by kind and stat), so a generated game's weapons get icons too.
|
|
5
|
+
* 24x24 viewBox, stroked in currentColor.
|
|
6
|
+
*/
|
|
7
|
+
const svg = (body: string): string =>
|
|
8
|
+
`<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
9
|
+
|
|
10
|
+
export const WEAPON_ICONS: Readonly<Record<string, string>> = {
|
|
11
|
+
// A burst: a ring with rays.
|
|
12
|
+
nova: svg('<circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/>'),
|
|
13
|
+
// A field you carry: two rings.
|
|
14
|
+
aura: svg('<circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="4"/>'),
|
|
15
|
+
// Shots: an arrow.
|
|
16
|
+
bolt: svg('<path d="M4 20L18 6M11 6h7v7"/>'),
|
|
17
|
+
// Blades around you: a dot on an orbit.
|
|
18
|
+
orbit: svg('<circle cx="12" cy="12" r="7"/><circle cx="19" cy="12" r="2.2" fill="currentColor"/><circle cx="5" cy="12" r="2.2" fill="currentColor"/>'),
|
|
19
|
+
// A sweep: a fan.
|
|
20
|
+
cone: svg('<path d="M12 20L4 8a11 11 0 0 1 16 0z"/>'),
|
|
21
|
+
// Lightning between enemies.
|
|
22
|
+
chain: svg('<path d="M13 2L6 13h5l-1 9 8-12h-5z"/>'),
|
|
23
|
+
// A falling star.
|
|
24
|
+
meteor: svg('<path d="M3 3l8 8M7 3l6 6M3 7l6 6"/><circle cx="16" cy="16" r="5"/>'),
|
|
25
|
+
// Out and back.
|
|
26
|
+
boomerang: svg('<path d="M5 19c0-8 6-14 14-14-4 3-6 6-6 9 3 0 6-2 6-2-2 5-8 7-14 7z"/>'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const STAT_ICONS: Readonly<Record<string, string>> = {
|
|
30
|
+
speed: svg('<path d="M4 7h7M2 12h9M4 17h7M13 6l6 6-6 6"/>'),
|
|
31
|
+
maxHealth: svg('<path d="M12 20s-7-4.5-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.5-7 10-7 10z"/>'),
|
|
32
|
+
magnet: svg('<path d="M6 4v8a6 6 0 0 0 12 0V4M6 8h4M14 8h4"/>'),
|
|
33
|
+
might: svg('<path d="M12 3c2 4-2 5 0 9 1-2 3-2 3-4 2 3 2 9-3 11-5-2-6-7-3-11 0 2 1 3 2 3-1-3 1-6 1-8z"/>'),
|
|
34
|
+
haste: svg('<circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2M9 2h6"/>'),
|
|
35
|
+
regen: svg('<path d="M12 5v14M5 12h14"/><circle cx="12" cy="12" r="10" stroke-dasharray="2 3"/>'),
|
|
36
|
+
armor: svg('<path d="M12 3l8 3v6c0 5-4 8-8 9-4-1-8-4-8-9V6z"/>'),
|
|
37
|
+
area: svg('<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/><circle cx="12" cy="12" r="2"/>'),
|
|
38
|
+
amount: svg('<rect x="3" y="9" width="10" height="10" rx="2"/><path d="M8 5h11v11"/>'),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Fallback when a kind or stat has no drawing: a plain dot. */
|
|
42
|
+
export const DOT_ICON = svg('<circle cx="12" cy="12" r="4" fill="currentColor"/>');
|