@netforge/template-arena 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 +75 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +34 -0
- package/README.md +82 -2
- package/client/audio.ts +43 -0
- package/client/decor.ts +22 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +330 -0
- package/client/index.html +230 -0
- package/client/input.ts +233 -0
- package/client/library.ts +146 -0
- package/client/main.ts +544 -0
- package/client/names.ts +43 -0
- package/client/palette.ts +35 -0
- package/client/scene.ts +1075 -0
- package/client/stake.ts +143 -0
- package/llms-full.txt +346 -0
- package/llms.txt +164 -0
- package/package.json +80 -3
- package/src/game.ts +117 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +109 -0
package/client/hud.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HUD: plain DOM over the canvas. Text updates are
|
|
3
|
+
* throttled; bars move every frame with transforms. Copy is labels, not
|
|
4
|
+
* sentences.
|
|
5
|
+
*/
|
|
6
|
+
import { BOT_PLAYER_BASE, EntityFlag, maxHealthFor, selectedSlot, slotWeapon } from "@netforge/engine-sim";
|
|
7
|
+
import type { ResolvedSimConfig } from "@netforge/engine-sim";
|
|
8
|
+
import { F, PLAYER_UNITS } from "@netforge/engine/game";
|
|
9
|
+
import type { CompiledGame, RunInfo } from "@netforge/engine/game";
|
|
10
|
+
import type { SceneEntity } from "./scene.js";
|
|
11
|
+
import { RULE_NAMES, TITLE, UPGRADE_NAMES, WEAPON_NAMES } from "./names.js";
|
|
12
|
+
import { css, playerColor } from "./palette.js";
|
|
13
|
+
|
|
14
|
+
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
|
|
15
|
+
const e = document.getElementById(id);
|
|
16
|
+
if (!e) throw new Error(`#${id} missing`);
|
|
17
|
+
return e as T;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
21
|
+
|
|
22
|
+
/** Seat bots go by first names (the full board still marks them as bots). */
|
|
23
|
+
export const BOT_NAMES = ["Mara", "Dex", "Juno", "Rook", "Vega", "Nico", "Tamsin", "Ozzy", "Lark", "Remy", "Sable", "Bram", "Ivy", "Cass", "Wren", "Kit"];
|
|
24
|
+
|
|
25
|
+
/** Short player labels: YOU, P2, or a seat bot's name. */
|
|
26
|
+
export function playerLabel(id: number, self: number): string {
|
|
27
|
+
if (id === self) return "YOU";
|
|
28
|
+
return id >= BOT_PLAYER_BASE ? (BOT_NAMES[(id - BOT_PLAYER_BASE) % BOT_NAMES.length] ?? "Bot").toUpperCase() : `P${id}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Off-screen opponents within this many metres get an edge arrow. */
|
|
32
|
+
const ARROW_RANGE = 45;
|
|
33
|
+
|
|
34
|
+
export interface HudCallbacks {
|
|
35
|
+
pickWeapon(slot: number): void;
|
|
36
|
+
invite(): void;
|
|
37
|
+
toggleStore(): void;
|
|
38
|
+
toggleSound(): boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class Hud {
|
|
42
|
+
private lastText = 0;
|
|
43
|
+
private readonly earnedThisRun = new Map<string, number>();
|
|
44
|
+
private toastTimer = 0;
|
|
45
|
+
private lastRun = 0;
|
|
46
|
+
private symbol = "";
|
|
47
|
+
/** What play pays here (client/host.ts): a guest in Net Forge earns nothing, and the HUD says so. */
|
|
48
|
+
private mode: "earning" | "guest" | "play" = "earning";
|
|
49
|
+
private slotKey = "";
|
|
50
|
+
private boardOpen = false;
|
|
51
|
+
private readonly cdTicks: number[];
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly game: CompiledGame,
|
|
55
|
+
private readonly config: ResolvedSimConfig,
|
|
56
|
+
private readonly selfPlayer: number,
|
|
57
|
+
private readonly cb: HudCallbacks,
|
|
58
|
+
) {
|
|
59
|
+
$("invite").addEventListener("click", () => cb.invite());
|
|
60
|
+
$("store-btn").addEventListener("click", () => cb.toggleStore());
|
|
61
|
+
$("store-close").addEventListener("click", () => cb.toggleStore());
|
|
62
|
+
// The label names the current state. Sound starts on, so the first click mutes and reads "Sound off".
|
|
63
|
+
$("sound").textContent = "Sound on";
|
|
64
|
+
$("sound").addEventListener("click", () => {
|
|
65
|
+
$("sound").textContent = cb.toggleSound() ? "Sound off" : "Sound on";
|
|
66
|
+
});
|
|
67
|
+
$("title").textContent = TITLE.toUpperCase();
|
|
68
|
+
this.cdTicks = config.content.weapons.map((w) => w.cooldownTicks);
|
|
69
|
+
// Weapon slots: one card per loadout weapon.
|
|
70
|
+
const slots = $("slots");
|
|
71
|
+
slots.innerHTML = "";
|
|
72
|
+
game.loadout.forEach((wi, s) => {
|
|
73
|
+
const id = game.weaponIds[wi] ?? "";
|
|
74
|
+
const card = document.createElement("button");
|
|
75
|
+
card.className = "slot";
|
|
76
|
+
card.id = `slot${s}`;
|
|
77
|
+
card.innerHTML = `<kbd>${s + 1}</kbd><b></b><span></span><i class="cd"><i></i></i>`;
|
|
78
|
+
(card.querySelector("b") as HTMLElement).textContent = WEAPON_NAMES[id]?.name ?? id;
|
|
79
|
+
(card.querySelector("span") as HTMLElement).textContent = WEAPON_NAMES[id]?.line ?? "";
|
|
80
|
+
card.addEventListener("click", () => cb.pickWeapon(s));
|
|
81
|
+
slots.append(card);
|
|
82
|
+
});
|
|
83
|
+
// Tab holds the full board open; the corner keeps only the top three and your own row.
|
|
84
|
+
addEventListener("keydown", (e) => {
|
|
85
|
+
if (e.code === "Tab") {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
this.boardOpen = true;
|
|
88
|
+
$("board-full").classList.add("open");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
addEventListener("keyup", (e) => {
|
|
92
|
+
if (e.code === "Tab") {
|
|
93
|
+
this.boardOpen = false;
|
|
94
|
+
$("board-full").classList.remove("open");
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Names the coin everywhere the HUD shows it: the token's own name, symbol and logo. */
|
|
100
|
+
coin(name: string, symbol: string, logo: string | null): void {
|
|
101
|
+
this.symbol = symbol;
|
|
102
|
+
const img = $<HTMLImageElement>("coin-logo");
|
|
103
|
+
if (logo) {
|
|
104
|
+
img.src = logo;
|
|
105
|
+
img.alt = name;
|
|
106
|
+
img.hidden = false;
|
|
107
|
+
} else img.hidden = true;
|
|
108
|
+
$("coin-name").textContent = name;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get coinSymbol(): string {
|
|
112
|
+
return this.symbol;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The wordmark: the game's own name (Net Forge names it), else the template's. */
|
|
116
|
+
title(name: string): void {
|
|
117
|
+
$("title").textContent = name.toUpperCase();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What play pays the person at the screen. A guest (no wallet in
|
|
122
|
+
* Net Forge) sees "not earning" where the balance was, and earn toasts say
|
|
123
|
+
* how to get paid; play money says so.
|
|
124
|
+
*/
|
|
125
|
+
player(mode: "earning" | "guest" | "play"): void {
|
|
126
|
+
this.mode = mode;
|
|
127
|
+
const box = $("coin-box");
|
|
128
|
+
box.classList.toggle("guest", mode === "guest");
|
|
129
|
+
box.classList.toggle("play", mode === "play");
|
|
130
|
+
box.title = mode === "guest" ? `Connect a wallet to earn ${this.symbol}` : mode === "play" ? `${this.symbol} has not launched: coins are play money` : "";
|
|
131
|
+
this.lastText = 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The coin box's text: the balance, or "VOLT · not earning" for a guest (play money gets a caption in CSS). */
|
|
135
|
+
private balanceText(balance: number): string {
|
|
136
|
+
if (this.mode === "guest") return `${this.symbol} · not earning`;
|
|
137
|
+
return `${Math.floor(balance).toLocaleString("en-US")} ${this.symbol}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
earned(rule: string, units: number): void {
|
|
141
|
+
this.earnedThisRun.set(rule, (this.earnedThisRun.get(rule) ?? 0) + units);
|
|
142
|
+
const pay = this.game.manifest.earnRules.get(rule)?.pay ?? 0;
|
|
143
|
+
const name = RULE_NAMES[rule] ?? rule;
|
|
144
|
+
this.toast(this.mode === "guest" ? `${name} Connect to earn ${pay * units} ${this.symbol}` : `${name} +${pay * units} ${this.symbol}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
toast(text: string): void {
|
|
148
|
+
const t = $("toast");
|
|
149
|
+
t.textContent = text;
|
|
150
|
+
t.classList.add("show");
|
|
151
|
+
clearTimeout(this.toastTimer);
|
|
152
|
+
this.toastTimer = window.setTimeout(() => t.classList.remove("show"), 2400);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Every frame. `self` is the predicted character; `players` every wirejack in view (others interpolated). */
|
|
156
|
+
update(
|
|
157
|
+
now: number,
|
|
158
|
+
self: SceneEntity | undefined,
|
|
159
|
+
players: readonly SceneEntity[],
|
|
160
|
+
run: RunInfo | null,
|
|
161
|
+
project: (x: number, y: number, z: number) => { x: number; y: number; onScreen: boolean },
|
|
162
|
+
balance: number,
|
|
163
|
+
): void {
|
|
164
|
+
if (run && run.run !== this.lastRun) {
|
|
165
|
+
this.lastRun = run.run;
|
|
166
|
+
this.earnedThisRun.clear();
|
|
167
|
+
}
|
|
168
|
+
const text = now - this.lastText > 100;
|
|
169
|
+
if (self) {
|
|
170
|
+
const hp = self.values[F.health] ?? 0;
|
|
171
|
+
const max = maxHealthFor(this.config, self.values[F.stats] ?? 0);
|
|
172
|
+
$("hp-fill").style.transform = `scaleX(${Math.min(1, hp / Math.max(1, max))})`;
|
|
173
|
+
const dead = ((self.values[F.flags] ?? 0) & EntityFlag.Dead) !== 0;
|
|
174
|
+
$("down").classList.toggle("show", dead && run?.phase === "playing");
|
|
175
|
+
// Weapon slots: the selected one lit, cooldowns as bars.
|
|
176
|
+
const weapons = self.values[F.weapons] ?? 0;
|
|
177
|
+
const wcd = self.values[F.wcd] ?? 0;
|
|
178
|
+
const slot = selectedSlot(self.values[F.choices] ?? 0);
|
|
179
|
+
for (let s = 0; s < this.game.loadout.length; s++) {
|
|
180
|
+
const card = document.getElementById(`slot${s}`);
|
|
181
|
+
if (!card) continue;
|
|
182
|
+
const wi = slotWeapon(weapons, s);
|
|
183
|
+
const full = this.cdTicks[wi] ?? 1;
|
|
184
|
+
const cd = (wcd >>> (s * 8)) & 0xff;
|
|
185
|
+
(card.querySelector(".cd > i") as HTMLElement | null)?.style.setProperty("transform", `scaleX(${Math.min(1, cd / full)})`);
|
|
186
|
+
}
|
|
187
|
+
const key = String(slot);
|
|
188
|
+
if (key !== this.slotKey) {
|
|
189
|
+
this.slotKey = key;
|
|
190
|
+
for (let s = 0; s < this.game.loadout.length; s++) document.getElementById(`slot${s}`)?.classList.toggle("on", s === slot);
|
|
191
|
+
}
|
|
192
|
+
if (text) {
|
|
193
|
+
$("hp-text").textContent = `${hp}`;
|
|
194
|
+
if (dead) $("down-sub").textContent = `${Math.max(1, Math.ceil((self.values[F.cooldown] ?? 0) / this.config.tickRate))}s`;
|
|
195
|
+
// Store upgrades this round.
|
|
196
|
+
const stats = self.values[F.stats] ?? 0;
|
|
197
|
+
const active = this.game.upgradeIds.filter((_, i) => ((stats >>> (i * 4)) & 0x0f) > 0).map((id) => UPGRADE_NAMES[id]?.name ?? id);
|
|
198
|
+
$("buffs").textContent = active.join(" ");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (text) $("coin-balance").textContent = this.balanceText(balance);
|
|
202
|
+
|
|
203
|
+
// Name tags and health over every other wirejack on screen; an edge arrow for each one nearby but off screen.
|
|
204
|
+
const tags = $("tags");
|
|
205
|
+
const arrows = $("arrows");
|
|
206
|
+
const sx = (self?.values[F.px] ?? 0) / PLAYER_UNITS;
|
|
207
|
+
const sz = (self?.values[F.pz] ?? 0) / PLAYER_UNITS;
|
|
208
|
+
const w = innerWidth;
|
|
209
|
+
const h = innerHeight;
|
|
210
|
+
let ti = 0;
|
|
211
|
+
let ai = 0;
|
|
212
|
+
for (const p of players) {
|
|
213
|
+
if (self && p.owner === self.owner) continue;
|
|
214
|
+
if (((p.values[F.flags] ?? 0) & EntityFlag.Dead) !== 0) continue;
|
|
215
|
+
const px = (p.values[F.px] ?? 0) / PLAYER_UNITS;
|
|
216
|
+
const pz = (p.values[F.pz] ?? 0) / PLAYER_UNITS;
|
|
217
|
+
const s = project(px, 2.4, pz);
|
|
218
|
+
if (!s.onScreen) {
|
|
219
|
+
const dist = Math.hypot(px - sx, pz - sz);
|
|
220
|
+
if (!self || dist > ARROW_RANGE) continue;
|
|
221
|
+
// From the screen centre toward the opponent, pinned inside the HUD margins (the weapon cards sit at the bottom).
|
|
222
|
+
const dx = s.x - w / 2;
|
|
223
|
+
const dy = s.y - h / 2;
|
|
224
|
+
const hx = w / 2 - 34;
|
|
225
|
+
const hy = dy < 0 ? h / 2 - 80 : h / 2 - 118;
|
|
226
|
+
const k = Math.min(hx / Math.max(1e-3, Math.abs(dx)), hy / Math.max(1e-3, Math.abs(dy)));
|
|
227
|
+
let a = arrows.children[ai] as HTMLElement | undefined;
|
|
228
|
+
if (!a) {
|
|
229
|
+
a = document.createElement("i");
|
|
230
|
+
arrows.append(a);
|
|
231
|
+
}
|
|
232
|
+
ai++;
|
|
233
|
+
const near = 1 - Math.min(1, Math.max(0, (dist - 12) / (ARROW_RANGE - 12)));
|
|
234
|
+
a.style.setProperty("--c", css(playerColor(p.owner)));
|
|
235
|
+
a.style.opacity = (0.45 + 0.55 * near).toFixed(2);
|
|
236
|
+
a.style.transform = `translate(${(w / 2 + dx * k).toFixed(1)}px, ${(h / 2 + dy * k).toFixed(1)}px) rotate(${Math.atan2(dy, dx).toFixed(3)}rad) scale(${(0.8 + 0.5 * near).toFixed(2)})`;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
let t = tags.children[ti] as HTMLElement | undefined;
|
|
240
|
+
if (!t) {
|
|
241
|
+
t = document.createElement("div");
|
|
242
|
+
t.className = "tag";
|
|
243
|
+
t.innerHTML = "<span></span><i><i></i></i>";
|
|
244
|
+
tags.append(t);
|
|
245
|
+
}
|
|
246
|
+
ti++;
|
|
247
|
+
const max = maxHealthFor(this.config, p.values[F.stats] ?? 0);
|
|
248
|
+
t.style.transform = `translate(${s.x}px, ${s.y}px) translate(-50%, -100%)`;
|
|
249
|
+
t.style.setProperty("--c", css(playerColor(p.owner)));
|
|
250
|
+
const label = playerLabel(p.owner, this.selfPlayer);
|
|
251
|
+
const span = t.firstElementChild as HTMLElement;
|
|
252
|
+
if (span.textContent !== label) span.textContent = label;
|
|
253
|
+
((t.lastElementChild as HTMLElement).firstElementChild as HTMLElement).style.transform = `scaleX(${Math.min(1, (p.values[F.health] ?? 0) / Math.max(1, max))})`;
|
|
254
|
+
}
|
|
255
|
+
while (tags.children.length > ti) tags.lastElementChild?.remove();
|
|
256
|
+
while (arrows.children.length > ai) arrows.lastElementChild?.remove();
|
|
257
|
+
|
|
258
|
+
if (text) {
|
|
259
|
+
this.lastText = now;
|
|
260
|
+
if (run) {
|
|
261
|
+
const left = run.round?.left ?? 0;
|
|
262
|
+
$("timer").textContent = mmss(left);
|
|
263
|
+
$("timer").classList.toggle("low", left <= 15 && run.phase === "playing");
|
|
264
|
+
$("goal").textContent = run.round?.frags ? `FIRST TO ${run.round.frags}` : "MOST KILLS";
|
|
265
|
+
this.board(run);
|
|
266
|
+
this.feed(run);
|
|
267
|
+
this.end(run);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Kills first; on a tie a person ranks above a seat bot (bots never take the round). */
|
|
273
|
+
private standings(run: RunInfo) {
|
|
274
|
+
return [...run.players].sort((a, b) => b.kills - a.kills || Number(a.bot) - Number(b.bot) || a.deaths - b.deaths || a.id - b.id);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private board(run: RunInfo): void {
|
|
278
|
+
const all = this.standings(run);
|
|
279
|
+
const rank = all.findIndex((p) => p.id === this.selfPlayer);
|
|
280
|
+
const row = (p: RunInfo["players"][number], i: number, full: boolean) => {
|
|
281
|
+
const me = p.id === this.selfPlayer ? ' class="me"' : "";
|
|
282
|
+
const tag = full && p.bot ? '<i class="bot">BOT</i>' : "";
|
|
283
|
+
return `<li${me} style="--c:${css(playerColor(p.id))}"><em>${i + 1}</em><span class="dot"></span>${playerLabel(p.id, this.selfPlayer)}${tag}<b>${p.kills}</b>${full ? `<small>${p.deaths}</small>` : ""}</li>`;
|
|
284
|
+
};
|
|
285
|
+
// The corner: the top three, and your own row when you are lower.
|
|
286
|
+
const mini = all
|
|
287
|
+
.map((p, i) => ({ p, i }))
|
|
288
|
+
.filter(({ i }) => i < 3 || i === rank)
|
|
289
|
+
.map(({ p, i }) => row(p, i, false))
|
|
290
|
+
.join("");
|
|
291
|
+
const list = $("board");
|
|
292
|
+
if (list.innerHTML !== mini) list.innerHTML = mini;
|
|
293
|
+
if (!this.boardOpen) return;
|
|
294
|
+
const html = `<li class="head"><em></em><span></span>PLAYER<b>K</b><small>D</small></li>${all.map((p, i) => row(p, i, true)).join("")}`;
|
|
295
|
+
const full = $("board-full");
|
|
296
|
+
if (full.innerHTML !== html) full.innerHTML = html;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private feed(run: RunInfo): void {
|
|
300
|
+
const items = run.feed
|
|
301
|
+
.filter((f) => f.ago < 6)
|
|
302
|
+
.slice(-4)
|
|
303
|
+
.map((f) => `<li><span style="color:${css(playerColor(f.by))}">${playerLabel(f.by, this.selfPlayer)}</span><i>✕</i><span style="color:${css(playerColor(f.victim))}">${playerLabel(f.victim, this.selfPlayer)}</span></li>`);
|
|
304
|
+
const html = items.join("");
|
|
305
|
+
const list = $("feed");
|
|
306
|
+
if (list.innerHTML !== html) list.innerHTML = html;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private end(run: RunInfo): void {
|
|
310
|
+
const el = $("end");
|
|
311
|
+
const show = run.phase !== "playing";
|
|
312
|
+
el.classList.toggle("show", show);
|
|
313
|
+
if (!show) return;
|
|
314
|
+
// The round goes to the best person in it; a seat bot never takes it.
|
|
315
|
+
const top = this.standings(run).find((p) => !p.bot);
|
|
316
|
+
$("end-title").textContent = top && top.kills > 0 ? (top.id === this.selfPlayer ? "TOP OF THE YARD" : `${playerLabel(top.id, this.selfPlayer)} TAKES IT`) : "ROUND OVER";
|
|
317
|
+
$("end-board").innerHTML = this.standings(run)
|
|
318
|
+
.slice(0, 8)
|
|
319
|
+
.map((p, i) => `<li${p.id === this.selfPlayer ? ' class="me"' : ""}><em>${i + 1}</em>${playerLabel(p.id, this.selfPlayer)}<b>${p.kills}</b><small>${p.deaths}</small></li>`)
|
|
320
|
+
.join("");
|
|
321
|
+
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>`);
|
|
322
|
+
$("end-earned").innerHTML =
|
|
323
|
+
(earned.length > 0 ? earned.join("") : "<li>No earn this round</li>") + (this.mode === "guest" && earned.length > 0 ? `<li class="guest">Not paid: connect a wallet to earn ${this.symbol}</li>` : "");
|
|
324
|
+
$("end-next").textContent = run.nextRunIn !== null ? `Next round ${run.nextRunIn}s` : "";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
stats(text: string): void {
|
|
328
|
+
$("stats").textContent = text;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
|
6
|
+
<title>Voltyard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
--ink: #e9f3ff;
|
|
11
|
+
--dim: #8b98ad;
|
|
12
|
+
--cyan: #16e0ff;
|
|
13
|
+
--pink: #ff4fd8;
|
|
14
|
+
--amber: #ffb020;
|
|
15
|
+
--panel: rgba(8, 12, 22, 0.78);
|
|
16
|
+
--line: rgba(22, 224, 255, 0.22);
|
|
17
|
+
--bad: #ff5a4f;
|
|
18
|
+
font-family: "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
19
|
+
}
|
|
20
|
+
html, body { margin: 0; height: 100%; background: #0b1020; color: var(--ink); overflow: hidden; touch-action: none; -webkit-user-select: none; user-select: none; }
|
|
21
|
+
#game { position: fixed; inset: 0; cursor: none; }
|
|
22
|
+
#game canvas { display: block; width: 100%; height: 100%; }
|
|
23
|
+
.hud { position: fixed; z-index: 5; pointer-events: none; font-variant-numeric: tabular-nums; }
|
|
24
|
+
.hud button, .hud .slot { pointer-events: auto; }
|
|
25
|
+
button { font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 7px 11px; cursor: pointer; }
|
|
26
|
+
button:disabled { opacity: 0.45; cursor: default; }
|
|
27
|
+
button:active { transform: scale(0.97); }
|
|
28
|
+
|
|
29
|
+
#top-left { top: max(12px, env(safe-area-inset-top)); left: 12px; width: min(300px, 40vw); }
|
|
30
|
+
#title { font-weight: 800; letter-spacing: 0.24em; font-size: 12px; color: var(--cyan); }
|
|
31
|
+
.bar { position: relative; height: 12px; border-radius: 3px; background: rgba(255, 255, 255, 0.08); overflow: hidden; margin-top: 6px; transform: skewX(-12deg); }
|
|
32
|
+
.bar > div { position: absolute; inset: 0; transform-origin: left; transition: transform 90ms linear; }
|
|
33
|
+
#hp-fill { background: linear-gradient(90deg, #ff4fd8, #16e0ff); }
|
|
34
|
+
#hp-text { position: absolute; right: 8px; top: -1px; font-size: 10px; font-weight: 800; color: #fff; text-shadow: 0 1px 2px #000; transform: skewX(12deg); }
|
|
35
|
+
#buffs { margin-top: 5px; font-size: 10px; letter-spacing: 0.16em; color: var(--amber); text-transform: uppercase; min-height: 12px; }
|
|
36
|
+
|
|
37
|
+
#slots { position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; gap: 8px; z-index: 5; }
|
|
38
|
+
.slot { width: min(150px, 28vw); padding: 8px 10px 9px; border-radius: 8px; background: var(--panel); border: 1px solid rgba(255, 255, 255, 0.1); text-align: left; display: grid; gap: 2px; opacity: 0.62; transition: opacity 120ms, border-color 120ms, transform 120ms; }
|
|
39
|
+
.slot.on { opacity: 1; border-color: var(--cyan); transform: translateY(-3px); box-shadow: 0 0 18px rgba(22, 224, 255, 0.25); }
|
|
40
|
+
.slot kbd { font: 700 10px ui-monospace, monospace; color: var(--cyan); }
|
|
41
|
+
.slot b { font-size: 13px; letter-spacing: 0.04em; }
|
|
42
|
+
.slot span { font-size: 11px; color: var(--dim); }
|
|
43
|
+
.slot .cd { display: block; height: 3px; background: rgba(255, 255, 255, 0.08); border-radius: 2px; overflow: hidden; margin-top: 3px; }
|
|
44
|
+
.slot .cd > i { display: block; height: 100%; background: var(--amber); transform-origin: left; transform: scaleX(0); }
|
|
45
|
+
|
|
46
|
+
#top-center { top: max(8px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); text-align: center; }
|
|
47
|
+
#timer { font-size: 30px; font-weight: 800; letter-spacing: 0.06em; text-shadow: 0 0 14px rgba(22, 224, 255, 0.5); }
|
|
48
|
+
#timer.low { color: var(--pink); }
|
|
49
|
+
#goal { font-size: 10px; letter-spacing: 0.24em; color: var(--dim); }
|
|
50
|
+
|
|
51
|
+
#top-right { top: max(10px, env(safe-area-inset-top)); right: 12px; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; width: min(240px, 46vw); }
|
|
52
|
+
#top-right .row { display: flex; gap: 6px; }
|
|
53
|
+
/* The coin sits on its own dark plate, so the arena's hazard lines never run through it. */
|
|
54
|
+
#coin-box { display: flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 8px; background: rgba(8, 12, 22, 0.88); border: 1px solid var(--line); font-weight: 700; white-space: nowrap; }
|
|
55
|
+
#coin-box.guest #coin-balance { color: var(--dim); font-weight: 600; }
|
|
56
|
+
/* Play money: a second line inside the plate, so the plate (and the Store row under it) makes room for it. */
|
|
57
|
+
#coin-box.play { display: block; text-align: right; }
|
|
58
|
+
#coin-box.play #coin-logo { vertical-align: middle; margin-right: 6px; }
|
|
59
|
+
#coin-box.play::after { content: "play money"; display: block; margin-top: 1px; font-size: 10px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--dim); }
|
|
60
|
+
#end-earned li.guest { color: var(--dim); }
|
|
61
|
+
#coin-logo { border-radius: 50%; }
|
|
62
|
+
#board { list-style: none; padding: 6px 8px; margin: 0; width: 100%; box-sizing: border-box; background: var(--panel); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; font-size: 12px; }
|
|
63
|
+
#board { width: auto; min-width: 150px; }
|
|
64
|
+
#board:empty { display: none; }
|
|
65
|
+
#board-full { list-style: none; margin: 0; display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(420px, 90vw); box-sizing: border-box; font-size: 14px; padding: 14px 16px; z-index: 9; background: rgba(8, 12, 22, 0.94); border: 1px solid var(--line); border-radius: 10px; }
|
|
66
|
+
#board-full.open { display: block; }
|
|
67
|
+
#board li, #board-full li { display: flex; align-items: center; gap: 6px; padding: 2px 0; }
|
|
68
|
+
#board-full li { padding: 4px 0; }
|
|
69
|
+
#board-full li.head { color: var(--dim); font-size: 9px; letter-spacing: 0.18em; }
|
|
70
|
+
#board li.me, #board-full li.me { color: var(--cyan); font-weight: 700; }
|
|
71
|
+
#board em, #board-full em { font-style: normal; color: var(--dim); width: 14px; }
|
|
72
|
+
#board .dot, #board-full .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--c, transparent); }
|
|
73
|
+
#board b, #board-full b { margin-left: auto; min-width: 22px; text-align: right; }
|
|
74
|
+
#board-full small { min-width: 20px; text-align: right; color: var(--dim); }
|
|
75
|
+
#board-full i.bot { font-style: normal; font-size: 9px; letter-spacing: 0.14em; color: var(--dim); border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 4px; padding: 0 4px; }
|
|
76
|
+
#arrows { inset: 0; }
|
|
77
|
+
#arrows i { position: absolute; left: 0; top: 0; width: 0; height: 0; margin: -9px 0 0 -6px; border-top: 9px solid transparent; border-bottom: 9px solid transparent; border-left: 16px solid var(--c); filter: drop-shadow(0 0 6px var(--c)); transform-origin: 6px 9px; }
|
|
78
|
+
#feed { list-style: none; padding: 0; margin: 0; font-size: 12px; font-weight: 700; text-align: right; }
|
|
79
|
+
#feed li { padding: 2px 6px; margin-top: 3px; background: rgba(8, 12, 22, 0.6); border-radius: 5px; }
|
|
80
|
+
#feed i { font-style: normal; color: var(--dim); margin: 0 6px; }
|
|
81
|
+
|
|
82
|
+
#tags .tag { position: absolute; left: 0; top: 0; text-align: center; font-size: 10px; font-weight: 800; letter-spacing: 0.08em; color: var(--c); text-shadow: 0 1px 2px #000; }
|
|
83
|
+
#tags .tag > i { display: block; width: 44px; height: 4px; margin: 2px auto 0; background: rgba(0, 0, 0, 0.55); border-radius: 2px; overflow: hidden; }
|
|
84
|
+
#tags .tag > i > i { display: block; height: 100%; background: var(--c); transform-origin: left; }
|
|
85
|
+
|
|
86
|
+
#crosshair { left: 0; top: 0; width: 0; height: 0; }
|
|
87
|
+
#crosshair .ring { position: absolute; left: -13px; top: -13px; width: 26px; height: 26px; border: 2px solid var(--cyan); border-radius: 50%; box-sizing: border-box; opacity: 0.9; }
|
|
88
|
+
#crosshair .ring::after { content: ""; position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px 0 0 -2px; background: var(--pink); border-radius: 50%; }
|
|
89
|
+
#crosshair.fire .ring { border-color: var(--pink); }
|
|
90
|
+
#crosshair .mark { position: absolute; left: 0; top: 0; opacity: 0; }
|
|
91
|
+
#crosshair .mark b { position: absolute; left: -1.5px; top: -5px; width: 3px; height: 10px; border-radius: 1px; background: #fff; box-shadow: 0 0 6px rgba(255, 255, 255, 0.9); }
|
|
92
|
+
#crosshair .mark b:nth-child(1) { transform: rotate(45deg) translateY(-13px); }
|
|
93
|
+
#crosshair .mark b:nth-child(2) { transform: rotate(135deg) translateY(-13px); }
|
|
94
|
+
#crosshair .mark b:nth-child(3) { transform: rotate(225deg) translateY(-13px); }
|
|
95
|
+
#crosshair .mark b:nth-child(4) { transform: rotate(315deg) translateY(-13px); }
|
|
96
|
+
#crosshair.kill .mark b { background: #ff3b5c; box-shadow: 0 0 10px rgba(255, 59, 92, 0.95); height: 14px; top: -7px; }
|
|
97
|
+
|
|
98
|
+
#down { inset: 0; display: none; place-items: center; background: radial-gradient(circle, transparent 35%, rgba(40, 0, 30, 0.55)); }
|
|
99
|
+
#down.show { display: grid; }
|
|
100
|
+
#down .box { text-align: center; }
|
|
101
|
+
#down-title { font-size: 36px; font-weight: 900; letter-spacing: 0.2em; color: var(--pink); }
|
|
102
|
+
#down-sub { color: var(--dim); font-size: 14px; letter-spacing: 0.2em; }
|
|
103
|
+
|
|
104
|
+
#end { inset: 0; display: none; place-items: center; background: rgba(4, 6, 12, 0.74); }
|
|
105
|
+
#end.show { display: grid; }
|
|
106
|
+
#end .box { width: min(420px, 90vw); padding: 22px; border-radius: 14px; background: rgba(10, 14, 26, 0.96); border: 1px solid var(--line); text-align: center; }
|
|
107
|
+
#end-title { font-size: 24px; font-weight: 900; letter-spacing: 0.14em; color: var(--cyan); }
|
|
108
|
+
#end-board { list-style: none; padding: 0; margin: 14px 0; text-align: left; font-size: 14px; }
|
|
109
|
+
#end-board li { display: flex; gap: 8px; padding: 4px 0; border-top: 1px solid rgba(255, 255, 255, 0.06); }
|
|
110
|
+
#end-board li.me { color: var(--cyan); font-weight: 700; }
|
|
111
|
+
#end-board em { font-style: normal; color: var(--dim); width: 16px; }
|
|
112
|
+
#end-board b { margin-left: auto; }
|
|
113
|
+
#end-board small { width: 24px; text-align: right; color: var(--dim); }
|
|
114
|
+
#end-earned { list-style: none; padding: 0; margin: 0 0 12px; text-align: left; font-size: 14px; }
|
|
115
|
+
#end-earned li { display: flex; justify-content: space-between; padding: 6px 0; border-top: 1px solid var(--line); }
|
|
116
|
+
#end-earned b { color: var(--amber); }
|
|
117
|
+
#end-next { color: var(--dim); font-size: 12px; letter-spacing: 0.12em; }
|
|
118
|
+
|
|
119
|
+
#store { top: 0; right: 0; bottom: 0; width: min(330px, 88vw); background: rgba(8, 11, 20, 0.97); border-left: 1px solid var(--line); padding: 18px; box-sizing: border-box; transform: translateX(100%); transition: transform 220ms ease; pointer-events: auto; overflow-y: auto; z-index: 8; }
|
|
120
|
+
#store.show { transform: none; }
|
|
121
|
+
#store h2 { margin: 0 0 2px; font-size: 15px; letter-spacing: 0.2em; }
|
|
122
|
+
#store h3 { margin: 16px 0 0; font-size: 10px; letter-spacing: 0.24em; color: var(--dim); font-weight: 600; }
|
|
123
|
+
#store .item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 0; border-top: 1px solid rgba(255, 255, 255, 0.06); }
|
|
124
|
+
#store .item > div { display: flex; align-items: center; gap: 10px; }
|
|
125
|
+
#store .swatch { width: 26px; height: 26px; border-radius: 7px; flex: none; }
|
|
126
|
+
#store small { color: var(--dim); }
|
|
127
|
+
#store button { min-width: 88px; }
|
|
128
|
+
#store-close { margin-top: 16px; }
|
|
129
|
+
|
|
130
|
+
.dmg { position: fixed; left: 0; top: 0; z-index: 4; pointer-events: none; font: 800 15px "Avenir Next", system-ui, sans-serif; color: #fff; text-shadow: 0 1px 0 #000, 0 0 8px rgba(255, 79, 216, 0.8); opacity: 0; will-change: transform, opacity; }
|
|
131
|
+
.dmg.big { font-size: 20px; color: var(--pink); }
|
|
132
|
+
|
|
133
|
+
#toast { left: 50%; top: 74px; transform: translate(-50%, -10px); padding: 8px 14px; border-radius: 999px; background: rgba(8, 12, 22, 0.92); border: 1px solid var(--amber); font-weight: 700; opacity: 0; transition: opacity 200ms, transform 200ms; white-space: nowrap; }
|
|
134
|
+
#toast.show { opacity: 1; transform: translate(-50%, 0); }
|
|
135
|
+
#hurt { inset: 0; background: radial-gradient(circle, transparent 45%, rgba(255, 40, 120, 0.45)); opacity: 0; }
|
|
136
|
+
#stats { left: 12px; bottom: 90px; font: 11px/1.4 ui-monospace, monospace; color: var(--dim); white-space: pre; display: none; }
|
|
137
|
+
#stats.show { display: block; }
|
|
138
|
+
#help { left: 12px; bottom: max(14px, env(safe-area-inset-bottom)); font-size: 10px; letter-spacing: 0.08em; color: var(--dim); }
|
|
139
|
+
#banner { left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; font-size: 14px; color: var(--dim); letter-spacing: 0.12em; }
|
|
140
|
+
#banner b { display: block; font-size: 44px; letter-spacing: 0.3em; color: var(--cyan); margin-bottom: 8px; text-shadow: 0 0 22px rgba(22, 224, 255, 0.6); }
|
|
141
|
+
|
|
142
|
+
.stick-zone { position: fixed; top: 80px; bottom: 90px; width: 50%; z-index: 3; touch-action: none; }
|
|
143
|
+
.stick-zone.left { left: 0; }
|
|
144
|
+
.stick-zone.right { right: 0; }
|
|
145
|
+
.touch-button { position: fixed; z-index: 6; width: 70px; height: 70px; border-radius: 50%; font-weight: 800; letter-spacing: 0.08em; font-size: 12px; }
|
|
146
|
+
.touch-button.dash { right: 18px; bottom: max(100px, env(safe-area-inset-bottom)); border: 2px solid var(--cyan); background: rgba(22, 224, 255, 0.14); }
|
|
147
|
+
.touch-button.swap { right: 98px; bottom: max(100px, env(safe-area-inset-bottom)); border: 2px solid var(--amber); background: rgba(255, 176, 32, 0.14); }
|
|
148
|
+
/* Framed by Net Forge: the host's own Invite replaces this one. */
|
|
149
|
+
html[data-host="netnet"] #invite { display: none; }
|
|
150
|
+
/* A phone held sideways: Swap and Dash drop to the bottom corner, clear of the
|
|
151
|
+
kill feed (two lines), and stay right of the weapon slots. */
|
|
152
|
+
@media (pointer: coarse) and (max-height: 500px) {
|
|
153
|
+
#feed li:nth-child(n + 3) { display: none; }
|
|
154
|
+
.touch-button.dash, .touch-button.swap { bottom: max(14px, env(safe-area-inset-bottom)); }
|
|
155
|
+
}
|
|
156
|
+
@media (pointer: coarse) {
|
|
157
|
+
#help, #crosshair { display: none; }
|
|
158
|
+
#game { cursor: auto; }
|
|
159
|
+
.slot span { display: none; }
|
|
160
|
+
}
|
|
161
|
+
@media (max-width: 560px) {
|
|
162
|
+
#board li:nth-child(n + 5) { display: none; }
|
|
163
|
+
#timer { font-size: 22px; }
|
|
164
|
+
#goal { display: none; }
|
|
165
|
+
#top-left { width: 34vw; }
|
|
166
|
+
#top-right { top: max(52px, calc(env(safe-area-inset-top) + 42px)); width: 62vw; }
|
|
167
|
+
#top-right button { padding: 5px 8px; font-size: 12px; white-space: nowrap; }
|
|
168
|
+
#board, #feed { font-size: 11px; }
|
|
169
|
+
.slot { width: 30vw; padding: 6px 8px; }
|
|
170
|
+
}
|
|
171
|
+
</style>
|
|
172
|
+
</head>
|
|
173
|
+
<body>
|
|
174
|
+
<div id="game"></div>
|
|
175
|
+
<div id="hurt" class="hud"></div>
|
|
176
|
+
<div id="tags" class="hud" style="inset: 0"></div>
|
|
177
|
+
<div id="arrows" class="hud"></div>
|
|
178
|
+
|
|
179
|
+
<div id="top-left" class="hud">
|
|
180
|
+
<div id="title">VOLTYARD</div>
|
|
181
|
+
<div class="bar"><div id="hp-fill"></div><span id="hp-text"></span></div>
|
|
182
|
+
<div id="buffs"></div>
|
|
183
|
+
</div>
|
|
184
|
+
<div id="top-center" class="hud">
|
|
185
|
+
<div id="timer">3:00</div>
|
|
186
|
+
<div id="goal"></div>
|
|
187
|
+
</div>
|
|
188
|
+
<div id="top-right" class="hud">
|
|
189
|
+
<div id="coin-box"><img id="coin-logo" alt="" width="18" height="18" hidden /><span id="coin-balance"></span></div>
|
|
190
|
+
<div class="row"><button id="invite">Invite</button><button id="store-btn">Store</button><button id="sound">Sound off</button></div>
|
|
191
|
+
<ul id="board"></ul>
|
|
192
|
+
<ul id="feed"></ul>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div id="slots"></div>
|
|
196
|
+
<div id="crosshair" class="hud"><i class="ring"></i><i class="mark"><b></b><b></b><b></b><b></b></i></div>
|
|
197
|
+
|
|
198
|
+
<div id="down" class="hud"><div class="box"><div id="down-title">DOWN</div><div id="down-sub"></div></div></div>
|
|
199
|
+
|
|
200
|
+
<div id="end" class="hud">
|
|
201
|
+
<div class="box">
|
|
202
|
+
<div id="end-title"></div>
|
|
203
|
+
<ul id="end-board"></ul>
|
|
204
|
+
<ul id="end-earned"></ul>
|
|
205
|
+
<div id="end-next"></div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<aside id="store" class="hud">
|
|
210
|
+
<h2>STORE</h2>
|
|
211
|
+
<small id="coin-name"></small>
|
|
212
|
+
<h3>THIS ROUND</h3>
|
|
213
|
+
<div id="store-upgrades"></div>
|
|
214
|
+
<h3>FINISHES</h3>
|
|
215
|
+
<div id="store-finishes"></div>
|
|
216
|
+
<div class="item" id="gear-row"><div style="display: block"><span id="gear-name"></span><br /><small id="gear-line"></small></div><button id="gear-buy" style="white-space: nowrap"></button></div>
|
|
217
|
+
<div class="item" id="pot-row"><div style="display: block">Round pot<br /><small id="pot-line"></small></div><button id="pot-go" style="white-space: nowrap"></button></div>
|
|
218
|
+
<div class="item" id="stake-row"><div style="display: block">Stake a run<br /><small id="stake-line"></small></div><span style="white-space: nowrap"><input id="stake-amount" type="number" aria-label="Stake" style="width: 56px" /> <button id="stake-go" style="min-width: 0">Stake</button></span></div>
|
|
219
|
+
<small id="store-note"></small>
|
|
220
|
+
<br /><button id="store-close">Close</button>
|
|
221
|
+
</aside>
|
|
222
|
+
|
|
223
|
+
<ul id="board-full" class="hud"></ul>
|
|
224
|
+
<div id="toast" class="hud"></div>
|
|
225
|
+
<div id="stats" class="hud"></div>
|
|
226
|
+
<div id="help" class="hud">WASD move · mouse aim · click fire · 1 2 3 or Q weapon · Space dash · Tab board</div>
|
|
227
|
+
<div id="banner" class="hud"><b>VOLTYARD</b><span id="banner-text">Charging the yard</span></div>
|
|
228
|
+
<script type="module" src="./main.ts"></script>
|
|
229
|
+
</body>
|
|
230
|
+
</html>
|