@netforge/template-arena 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/main.ts ADDED
@@ -0,0 +1,544 @@
1
+ /**
2
+ * VOLTYARD client: join a room by link, predict your own wirejack (move,
3
+ * aim, weapon and cooldowns), draw everyone else ~100 ms in the past, and
4
+ * dress it with the game-feel kit. The server decides every hit; beams are
5
+ * lag-compensated, so you hit what you saw.
6
+ *
7
+ * URL parameters:
8
+ * ?room=<id> join a friend's instance (the Invite button makes this link)
9
+ * ?place=<name> the place (default "yard")
10
+ * ?server=<url> room server (default: VITE_ROOMS_URL, else this host on :2567)
11
+ * ?webgl=1 force the WebGL2 backend
12
+ * ?autopilot=1 a bot plays (headless measurement and screenshots); =newcomer plays loose, like a new person
13
+ * ?preview=1 the camera frames the biggest fight instead of our own fighter (the Launcher's style previews)
14
+ * ?stats=1 show frame, network and server numbers (also the ` key)
15
+ * ?shapes=1 draw the fallback shapes instead of library models
16
+ * ?style=<style> draw the game in another art style (toon, ps1, pixel, voxel, handdrawn; default: the style game.ts declares)
17
+ * ?library=<url> asset library base URL (default: VITE_LIBRARY_URL, else ./library)
18
+ * ?host=netnet, ?coin=, ?title=, ?player= set by Net Forge (client/host.ts)
19
+ */
20
+ import { Button, EntityKind, initSim, resolveConfig } from "@netforge/engine-sim";
21
+ import type { InputCommand } from "@netforge/engine-sim";
22
+ import { arenaBotInput, arenaBotMemory, F, PLAYER_UNITS, CROWD_UNITS } from "@netforge/engine/game";
23
+ import type { EngineRoomWorld, RunInfo } from "@netforge/engine/game";
24
+ import { guestId, joinRoom, savedSession } from "@netforge/engine/net";
25
+ import type { RoomConnection } from "@netforge/engine/net";
26
+ import { createRenderer, DamageNumbers, FeelAudio, FeelKit, FrameStats, pickStyle, SoundPlayer } from "@netforge/engine/runtime";
27
+ import { soundBank } from "@netforge/asset-library";
28
+ import { LibraryClient } from "@netforge/asset-library/three";
29
+ import { arenaAdapter, arenaGame, COSMETICS, GAME_VERSION, PLACE, TEMPLATE } from "../src/manifest.js";
30
+ import { COSMETIC_SKUS, mockKit, UPGRADE_SKUS } from "../src/store.js";
31
+ import type { KitStoreBridge, SkuPrice } from "../src/store.js";
32
+ import { ARENA_SAMPLES, defineArenaCues, killConfirm } from "./audio.js";
33
+ import { hostFlags, namedPlayer, onHostPlayer, postReady, storeNote } from "./host.js";
34
+ import type { PlayerMode } from "./host.js";
35
+ import { Hud } from "./hud.js";
36
+ import { ArenaInput } from "./input.js";
37
+ import { SKU_NAMES, TITLE, UPGRADE_NAMES } from "./names.js";
38
+ import { css, finishOf } from "./palette.js";
39
+ import { ArenaScene } from "./scene.js";
40
+ import type { SceneEntity } from "./scene.js";
41
+ import { gearOf, wireGear, wirePot, wireStake } from "./stake.js";
42
+
43
+ export interface ArenaStats {
44
+ backend: string;
45
+ fps: number;
46
+ frameMs: { mean: number; p50: number; p95: number; p99: number };
47
+ cpuMs: { mean: number; p95: number };
48
+ drawCalls: number;
49
+ triangles: number;
50
+ playersInView: number;
51
+ entitiesInView: number;
52
+ /** ?preview=1: standing fighters in the fight the camera frames. */
53
+ previewFight: number;
54
+ downKBps: number;
55
+ upKBps: number;
56
+ rttMs: number;
57
+ joinMs: number;
58
+ firstFrameMs: number;
59
+ corrections: number;
60
+ inputBuffer: number | null;
61
+ frames: number;
62
+ roomId: string;
63
+ phase: string;
64
+ kills: number;
65
+ deaths: number;
66
+ pixelRatio: number;
67
+ /** Game-feel effects fired so far. */
68
+ feel: { numbers: number; beams: number; flashes: number; bursts: number; shots: number; hitStops: number; shakes: number; hits: number; kills: number };
69
+ /** Library samples decoded and failed, and whether the music loop started. */
70
+ sound: { cues: number; failed: number; music: boolean; played: number };
71
+ }
72
+
73
+ declare global {
74
+ interface Window {
75
+ __arena?: ArenaStats;
76
+ }
77
+ }
78
+
79
+ const params = new URLSearchParams(location.search);
80
+ // The Kit flags (client/host.ts): framed by Net Forge (?host=netnet),
81
+ // the host draws Invite, so the game hides its own and the two never both
82
+ // show; it names the game's coin and title, and says whether play pays the
83
+ // person at the screen. Standalone play reads none of this.
84
+ const launcher = hostFlags(params);
85
+ if (launcher.framed) document.documentElement.dataset.host = "netnet";
86
+ let playerMode: PlayerMode = launcher.player;
87
+ // The mode the host has named, on the URL or in a message: once it has, it
88
+ // alone says whether coins are real, for the coin box and the store alike.
89
+ let hostMode: PlayerMode | null = namedPlayer(params);
90
+ let onPlayer: ((mode: PlayerMode) => void) | null = null;
91
+ onHostPlayer(launcher, (mode) => {
92
+ playerMode = mode;
93
+ hostMode = mode;
94
+ onPlayer?.(mode);
95
+ });
96
+
97
+ /**
98
+ * A game played in Net Forge names its own coin (?coin=TICKER, 2 to 6
99
+ * capitals or digits), so its HUD never shows the template's coin. The
100
+ * template's own token keeps its logo.
101
+ */
102
+ function withDraftCoin(kit: KitStoreBridge): KitStoreBridge {
103
+ const coin = launcher.coin;
104
+ if (!coin || coin === kit.coins.symbol()) return kit;
105
+ return { ...kit, coins: { ...kit.coins, name: () => coin, symbol: () => coin, logo: () => null } };
106
+ }
107
+ /** The wordmark: the game's own name when Net Forge names it, else the template's. */
108
+ const GAME_TITLE = launcher.title ?? TITLE;
109
+ const bootAt = performance.now();
110
+ const env = (import.meta as unknown as { env: Record<string, string | undefined> }).env;
111
+ const endpoint = params.get("server") ?? env.VITE_ROOMS_URL ?? `${location.protocol}//${location.hostname}:2567`;
112
+ const autopilotMode = params.get("autopilot");
113
+ const autopilot = autopilotMode === "1" || autopilotMode === "newcomer";
114
+ const banner = document.getElementById("banner");
115
+ const bannerText = document.getElementById("banner-text");
116
+ const host = document.getElementById("game");
117
+ document.title = GAME_TITLE;
118
+ const bannerTitle = banner?.querySelector("b");
119
+ if (bannerTitle) bannerTitle.textContent = GAME_TITLE.toUpperCase();
120
+
121
+ async function main(): Promise<void> {
122
+ if (!host) throw new Error("missing #game");
123
+ await initSim();
124
+ const game = arenaGame();
125
+ const config = resolveConfig(game.sim);
126
+ const canvas = document.createElement("canvas");
127
+ host.append(canvas);
128
+ const handle = await createRenderer(canvas, { forceWebGL: params.get("webgl") === "1", antialias: true });
129
+
130
+ const adapter = arenaAdapter();
131
+ const friendRoomId = params.get("room") ?? undefined;
132
+ const place = params.get("place") ?? PLACE;
133
+ const joinStart = performance.now();
134
+ // Saves (owned finishes, settings) live on the room server under the signed-in wallet or this browser's guest id.
135
+ const session = savedSession(endpoint);
136
+ const conn: RoomConnection<EngineRoomWorld, InputCommand> = await joinRoom(adapter, {
137
+ endpoint,
138
+ place,
139
+ template: TEMPLATE,
140
+ guest: guestId(),
141
+ ...(session ? { session: session.token } : {}),
142
+ ...(friendRoomId ? { friendRoomId } : {}),
143
+ });
144
+ const joinMs = performance.now() - joinStart;
145
+ const self = conn.welcome.player;
146
+ if (friendRoomId && conn.roomId !== friendRoomId) setTimeout(() => hud.toast("Room full, new room"), 500);
147
+ if (conn.welcome.version !== GAME_VERSION) setTimeout(() => hud.toast("Update ready. Reload."), 800);
148
+ const linkFor = (base: string) =>
149
+ `${base}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}`;
150
+ history.replaceState(null, "", `${linkFor(location.pathname)}${autopilot ? `&autopilot=${autopilotMode}` : ""}${params.has("webgl") ? "&webgl=1" : ""}${params.has("stats") ? "&stats=1" : ""}${params.has("style") ? `&style=${encodeURIComponent(params.get("style") ?? "")}` : ""}`);
151
+
152
+ // The asset library: real models by library id (./library next to the page, or the published CID). ?shapes=1 draws the fallback shapes only.
153
+ const libraryBase = params.get("library") ?? env.VITE_LIBRARY_URL ?? "./library";
154
+ // ?style=<art style> previews the game in another style (default: the one game.ts declares).
155
+ const artStyle = pickStyle(game.manifest.game.style, params.get("style"));
156
+ const library = params.get("shapes") === "1" ? null : new LibraryClient(handle, { baseUrl: libraryBase, style: artStyle });
157
+ // The engine's feel kit: hit-stop clock, trauma shake, flash, knockback, numbers, sound, rumble.
158
+ const sfx = new FeelAudio();
159
+ defineArenaCues(sfx);
160
+ // Real CC0 samples for the cues and a music loop, from the library's audio section. Starts on the first press; the mute is remembered.
161
+ const sound = new SoundPlayer(sfx, { key: "voltyard:sound", musicVolume: 0.3 });
162
+ sound.load(soundBank(libraryBase, { cues: ARENA_SAMPLES }));
163
+ const feel = new FeelKit({ preset: "default", audio: sfx, numbers: new DamageNumbers(document.body) });
164
+ const scene = new ArenaScene(handle, game, config, library, feel, { style: artStyle });
165
+ if (params.get("preview") === "1") scene.setPreview(true);
166
+ feel.camera = scene.rig;
167
+ feel.project = (x, y, z) => scene.project(x, y, z, Math.max(1, host.clientWidth), Math.max(1, host.clientHeight));
168
+ let lastView: { self: SceneEntity | undefined; others: readonly SceneEntity[] } = { self: undefined, others: [] };
169
+ const aimFrom = (cx: number, cy: number): number | null => {
170
+ const me = lastView.self;
171
+ if (!me) return null;
172
+ const rect = canvas.getBoundingClientRect();
173
+ const p = scene.groundPoint(cx - rect.left, cy - rect.top, rect.width, rect.height);
174
+ if (!p) return null;
175
+ const dx = p.x - (me.values[F.px] ?? 0) / PLAYER_UNITS;
176
+ const dz = p.z - (me.values[F.pz] ?? 0) / PLAYER_UNITS;
177
+ return dx * dx + dz * dz < 0.01 ? null : Math.atan2(-dx, -dz);
178
+ };
179
+ const input = new ArenaInput(document.body, aimFrom);
180
+ const prices: Record<string, SkuPrice> = Object.fromEntries(game.manifest.skus);
181
+ // The Kit plugs in here (packages/kit, kit.init({ game })): kit.coins and
182
+ // kit.store have these exact names. The mock stands in until the token is live.
183
+ const kit: KitStoreBridge = withDraftCoin(mockKit({ earned: 120, wallet: 0, usdg: 5 }, prices));
184
+ let storeOpen = false;
185
+ let run: RunInfo | null = null;
186
+ conn.onRun((info) => {
187
+ run = info as RunInfo;
188
+ });
189
+
190
+ const hud = new Hud(game, config, self, {
191
+ pickWeapon: (slot) => {
192
+ sfx.unlock();
193
+ input.press(Button.Choice1 << slot);
194
+ },
195
+ invite: () => {
196
+ const url = linkFor(`${location.origin}${location.pathname}`);
197
+ void navigator.clipboard?.writeText(url).then(
198
+ () => hud.toast("Invite link copied"),
199
+ () => hud.toast(url),
200
+ );
201
+ },
202
+ toggleStore: () => {
203
+ storeOpen = !storeOpen;
204
+ document.getElementById("store")?.classList.toggle("show", storeOpen);
205
+ },
206
+ toggleSound: () => {
207
+ sfx.unlock();
208
+ return sound.toggle();
209
+ },
210
+ });
211
+ conn.onEarn((e) => hud.earned(e.rule, e.units));
212
+ // The mute is remembered between visits: the button names the current state.
213
+ const soundBtn = document.getElementById("sound");
214
+ if (soundBtn && sound.muted) soundBtn.textContent = "Sound off";
215
+ hud.coin(kit.coins.name(), kit.coins.symbol(), kit.coins.logo());
216
+ wireStake(conn, kit, (t) => hud.toast(t));
217
+ wirePot(conn, kit, (t) => hud.toast(t));
218
+ wireGear(gearOf(game.manifest.skus, SKU_NAMES), kit, (t) => hud.toast(t));
219
+ hud.title(GAME_TITLE);
220
+ // Signed out in Net Forge, the HUD says play is not earning instead of showing a balance that never moves.
221
+ hud.player(playerMode);
222
+ // The store's note follows the same mode as the coin box (client/host.ts storeNote).
223
+ const note = document.getElementById("store-note");
224
+ const showNote = () => {
225
+ if (note) note.textContent = storeNote(hostMode, conn.welcome.earnLive, kit.coins.symbol());
226
+ };
227
+ showNote();
228
+ onPlayer = (mode) => {
229
+ hud.player(mode);
230
+ showNote();
231
+ };
232
+ postReady(launcher);
233
+
234
+ // The store: round upgrades (spent, applied by the room) and finishes (owned for good).
235
+ const sym = kit.coins.symbol();
236
+ const upgradesEl = document.getElementById("store-upgrades");
237
+ for (const id of UPGRADE_SKUS) {
238
+ const price = prices[id]?.coins;
239
+ if (price === undefined || !upgradesEl) continue;
240
+ const row = document.createElement("div");
241
+ row.className = "item";
242
+ row.innerHTML = `<div><div><span class="name"></span><br /><small></small></div></div><button></button>`;
243
+ (row.querySelector(".name") as HTMLElement).textContent = SKU_NAMES[id] ?? id;
244
+ (row.querySelector("small") as HTMLElement).textContent = UPGRADE_NAMES[id]?.line ?? "";
245
+ const btn = row.querySelector("button") as HTMLButtonElement;
246
+ btn.textContent = `${price} ${sym}`;
247
+ btn.addEventListener("click", () => void buyUpgrade(id, price));
248
+ upgradesEl.append(row);
249
+ }
250
+ const finishesEl = document.getElementById("store-finishes");
251
+ for (const id of COSMETIC_SKUS) {
252
+ const price = prices[id]?.coins;
253
+ const cosmetic = COSMETICS[id];
254
+ if (price === undefined || cosmetic === undefined || !finishesEl) continue;
255
+ const look = finishOf(cosmetic, 0x16e0ff);
256
+ const row = document.createElement("div");
257
+ row.className = "item";
258
+ row.innerHTML = `<div><span class="swatch"></span><div><span class="name"></span><br /><small>Finish</small></div></div><button></button>`;
259
+ (row.querySelector(".swatch") as HTMLElement).style.background = `linear-gradient(135deg, ${css(look.body)}, ${css(look.glow)})`;
260
+ (row.querySelector(".name") as HTMLElement).textContent = SKU_NAMES[id] ?? id;
261
+ const btn = row.querySelector("button") as HTMLButtonElement;
262
+ btn.textContent = `${price} ${sym}`;
263
+ btn.addEventListener("click", () => void buyFinish(id, cosmetic, btn));
264
+ finishesEl.append(row);
265
+ }
266
+
267
+ async function buyUpgrade(id: string, price: number): Promise<void> {
268
+ sfx.unlock();
269
+ let proof: string | null;
270
+ try {
271
+ proof = (await kit.coins.spend(price, id)).key;
272
+ } catch {
273
+ hud.toast(`Not enough ${sym}`);
274
+ return;
275
+ }
276
+ // The spend's key proves the paid command to the room, once.
277
+ const r = await conn.command({ name: id, ...(proof ? { proof } : {}) });
278
+ hud.toast(r.ok ? `${SKU_NAMES[id] ?? id} on` : r.reason === "store not live" ? "Store opens at launch" : "Already on this round");
279
+ }
280
+ async function buyFinish(id: string, cosmetic: number, btn: HTMLButtonElement): Promise<void> {
281
+ sfx.unlock();
282
+ if (!kit.store.owns(id)) {
283
+ try {
284
+ await kit.store.buy(id, { pay: "coins" });
285
+ } catch {
286
+ hud.toast(`Not enough ${sym}`);
287
+ return;
288
+ }
289
+ }
290
+ const r = await conn.command({ name: "cosmetic", value: cosmetic });
291
+ if (!r.ok) {
292
+ hud.toast("Finishes open at launch");
293
+ return;
294
+ }
295
+ for (const b of finishesEl?.querySelectorAll("button") ?? []) if (b !== btn && b.textContent === "Equipped") b.textContent = "Equip";
296
+ btn.textContent = "Equipped";
297
+ }
298
+
299
+ // RTT probe every 2 s (a control message, not the hot path).
300
+ let rtt = 0;
301
+ conn.room.onMessage("pong", (t: number) => {
302
+ rtt = performance.now() - t;
303
+ });
304
+ setInterval(() => conn.room.send("ping", performance.now()), 2000);
305
+
306
+ // Input on its own fixed clock, steered by the server's queue (clock adjustment).
307
+ let nextTick = performance.now();
308
+ let ticks = 0;
309
+ const memory = arenaBotMemory();
310
+ const loadout = game.loadout.map((wi) => {
311
+ const w = config.content.weapons[wi];
312
+ return { kind: w?.kind ?? "blaster", range: w?.range ?? 10, count: w?.count ?? 1 };
313
+ });
314
+ let autoAim = 0;
315
+ const autopilotInput = (): InputCommand => {
316
+ const me = lastView.self;
317
+ if (!me) return adapter.input.idle;
318
+ const opponents = lastView.others
319
+ .filter((e) => e.kind === EntityKind.Player && ((e.values[F.flags] ?? 0) & 2) === 0)
320
+ .map((e) => ({ id: e.owner, x: (e.values[F.px] ?? 0) / PLAYER_UNITS, z: (e.values[F.pz] ?? 0) / PLAYER_UNITS }));
321
+ const pickups = lastView.others.filter((e) => e.kind === EntityKind.Pickup).map((e) => ({ x: (e.values[F.ex] ?? 0) / CROWD_UNITS, z: (e.values[F.ez] ?? 0) / CROWD_UNITS }));
322
+ const cmd = arenaBotInput(
323
+ {
324
+ id: self,
325
+ x: (me.values[F.px] ?? 0) / PLAYER_UNITS,
326
+ z: (me.values[F.pz] ?? 0) / PLAYER_UNITS,
327
+ health: me.values[F.health] ?? 0,
328
+ maxHealth: 100,
329
+ up: ((me.values[F.flags] ?? 0) & 2) === 0 && (run?.phase ?? "playing") === "playing",
330
+ slot: (me.values[F.choices] ?? 0) & 3,
331
+ loadout,
332
+ opponents,
333
+ pickups,
334
+ pillars: config.arena.pillars,
335
+ half: config.arena.halfExtent,
336
+ ...(autopilotMode === "newcomer" ? { skill: 0.35 } : {}),
337
+ },
338
+ memory,
339
+ ticks,
340
+ config.tickRate,
341
+ );
342
+ autoAim = cmd.yaw;
343
+ return cmd;
344
+ };
345
+ setInterval(() => {
346
+ const now = performance.now();
347
+ let n = 0;
348
+ while (nextTick <= now && n < 8) {
349
+ const cmd = autopilot ? autopilotInput() : input.sample(ticks);
350
+ conn.net.tick(cmd, now);
351
+ ticks++;
352
+ nextTick += conn.net.tickIntervalMs();
353
+ n++;
354
+ }
355
+ if (n === 8) nextTick = now + conn.net.tickIntervalMs();
356
+ }, 4);
357
+ addEventListener("pointerdown", () => sfx.unlock(), { once: true });
358
+ addEventListener("keydown", () => sfx.unlock(), { once: true });
359
+ addEventListener("keydown", (e) => {
360
+ if (e.code === "Backquote") document.getElementById("stats")?.classList.toggle("show");
361
+ });
362
+ if (params.get("stats") === "1") document.getElementById("stats")?.classList.add("show");
363
+
364
+ // A crosshair that follows the mouse.
365
+ const cross = document.getElementById("crosshair");
366
+ const crossRing = cross?.querySelector<HTMLElement>(".ring") ?? null;
367
+ const crossMark = cross?.querySelector<HTMLElement>(".mark") ?? null;
368
+ let crossHit = 0;
369
+ let crossKill = 0;
370
+ let crossKick = 0;
371
+ let knownKills = 0;
372
+ let lastKillFeel = -1e9;
373
+ addEventListener("pointermove", (e) => {
374
+ if (cross && e.pointerType === "mouse") cross.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
375
+ });
376
+
377
+ const resize = () => scene.resize(Math.max(1, host.clientWidth), Math.max(1, host.clientHeight));
378
+ resize();
379
+ new ResizeObserver(resize).observe(host);
380
+
381
+ const frame = new FrameStats(600);
382
+ const cpu = new FrameStats(600);
383
+ let last = 0;
384
+ let frames = 0;
385
+ let firstFrameMs = 0;
386
+ let lastBytes = 0;
387
+ let lastUp = 0;
388
+ let lastNet = performance.now();
389
+ let downKBps = 0;
390
+ let upKBps = 0;
391
+ let lastStats = 0;
392
+ let pixelRatio = handle.renderer.getPixelRatio();
393
+ let resChanged = 0;
394
+ const hurt = document.getElementById("hurt");
395
+ let shakes = 0;
396
+
397
+ handle.renderer.setAnimationLoop((t: number) => {
398
+ const real = performance.now();
399
+ if (last !== 0) frame.push(t - last);
400
+ last = t;
401
+ const { now, dt, realDt } = feel.tick(real);
402
+ const view = conn.net.render(now);
403
+ lastView = view;
404
+ const players = view.others.filter((e) => e.kind === EntityKind.Player);
405
+ const ev = scene.frame({ dt, realDt, self: view.self, others: view.others, selfPlayer: self, aim: autopilot ? autoAim : input.aim });
406
+ if (frames === 0) {
407
+ firstFrameMs = real - bootAt;
408
+ banner?.remove();
409
+ }
410
+ frames++;
411
+
412
+ // Game feel (FeelKit): every shot of ours kicks and sounds, hits flash, push and mark the crosshair, kills stop time.
413
+ for (const s of ev.shots.slice(0, 6)) {
414
+ const cue = s.kind === "beam" ? "beam" : s.kind === "blaster-wide" ? "wide" : "zap";
415
+ sfx.play(s.mine ? cue : `${cue}-o`, { pitch: s.kind === "blaster-wide" ? 0.62 : 1, gain: s.mine ? 1 : 0.35 });
416
+ if (!s.mine) continue;
417
+ crossKick = 1;
418
+ scene.rig.addTrauma(s.kind === "beam" ? 0.32 : s.kind === "blaster-wide" ? 0.22 : 0.07);
419
+ if (s.kind === "beam") scene.rig.punch(1.5);
420
+ shakes++;
421
+ }
422
+ for (const hit of ev.hits.slice(0, 8)) feel.hit({ id: hit.id, x: hit.x, y: hit.y, z: hit.z, amount: hit.amount, big: hit.amount >= 40, dirX: hit.dirX, dirZ: hit.dirZ });
423
+ if (ev.hits.length > 0) {
424
+ sfx.play("hitmark");
425
+ crossHit = 0.14;
426
+ }
427
+ for (const k of ev.kills) {
428
+ feel.kill({ x: k.x, y: 1, z: k.z, big: true, color: k.color, dirX: k.dirX, dirZ: k.dirZ });
429
+ killConfirm(sfx);
430
+ crossKill = 0.4;
431
+ lastKillFeel = real;
432
+ }
433
+ // The room's count is the referee: a kill it credits that the screen did not show still confirms.
434
+ const myKills = (run as RunInfo | null)?.players.find((p) => p.id === self)?.kills ?? 0;
435
+ if (myKills > knownKills && real - lastKillFeel > 1000) {
436
+ killConfirm(sfx);
437
+ crossKill = 0.4;
438
+ feel.clock.hitStop(feel.preset.bigKillStopMs, real);
439
+ }
440
+ knownKills = myKills;
441
+ if (ev.downs > ev.kills.length) sfx.play("down-o");
442
+ if (ev.selfHurt > 0) {
443
+ feel.hurt(ev.selfHurt, 100);
444
+ shakes++;
445
+ }
446
+ if (ev.selfDown) {
447
+ feel.clock.hitStop(70, real);
448
+ scene.shake(0.7, 0.3);
449
+ shakes++;
450
+ sfx.play("down");
451
+ }
452
+ if (ev.selfHealed) sfx.play("heal");
453
+ if (hurt) hurt.style.opacity = String(feel.hurtPulse * 0.85);
454
+ crossHit = Math.max(0, crossHit - realDt);
455
+ crossKill = Math.max(0, crossKill - realDt);
456
+ crossKick = Math.max(0, crossKick - realDt * 9);
457
+ if (cross) {
458
+ cross.classList.toggle("fire", input.firing);
459
+ cross.classList.toggle("kill", crossKill > 0);
460
+ if (crossRing) crossRing.style.transform = `scale(${(1 + crossKick * 0.35).toFixed(3)})`;
461
+ if (crossMark) {
462
+ const t = Math.max(crossHit / 0.14, crossKill / 0.4);
463
+ crossMark.style.opacity = t > 0 ? String(Math.min(1, t * 1.6).toFixed(2)) : "0";
464
+ crossMark.style.transform = `scale(${(crossKill > 0 ? 1.25 : 1) + (1 - Math.min(1, t)) * 0.25})`;
465
+ }
466
+ }
467
+ const w = host.clientWidth;
468
+ const h = host.clientHeight;
469
+ hud.update(real, view.self, players, run ?? (conn.latestRun() as RunInfo | null), (x, y, z) => scene.project(x, y, z, w, h), kit.coins.balance());
470
+ feel.update(dt, realDt);
471
+ cpu.push(performance.now() - real);
472
+
473
+ // Dynamic resolution: hold 60 fps by trading pixels (spec §5 quality tiers).
474
+ if (frame.count >= 120 && real - resChanged > 2000) {
475
+ const p95 = frame.percentile(0.95);
476
+ const cap = Math.min(globalThis.devicePixelRatio || 1, 2);
477
+ let next = pixelRatio;
478
+ if (p95 > 21 && pixelRatio > 0.75) next = Math.max(0.75, pixelRatio - 0.25);
479
+ else if (p95 < 17.5 && pixelRatio < cap) next = Math.min(cap, pixelRatio + 0.25);
480
+ if (next !== pixelRatio) {
481
+ pixelRatio = next;
482
+ handle.renderer.setPixelRatio(next);
483
+ resize();
484
+ resChanged = real;
485
+ }
486
+ }
487
+
488
+ if (real - lastNet >= 1000) {
489
+ const secs = (real - lastNet) / 1000;
490
+ downKBps = (conn.bytesIn() - lastBytes) / secs / 1024;
491
+ upKBps = (conn.net.stats.inputBytes - lastUp) / secs / 1024;
492
+ lastBytes = conn.bytesIn();
493
+ lastUp = conn.net.stats.inputBytes;
494
+ lastNet = real;
495
+ }
496
+ const info = handle.renderer.info.render;
497
+ const mean = frame.mean();
498
+ const r = run as RunInfo | null;
499
+ const mine = r?.players.find((p) => p.id === self);
500
+ const stats: ArenaStats = {
501
+ backend: handle.backend,
502
+ fps: mean > 0 ? 1000 / mean : 0,
503
+ frameMs: { mean, p50: frame.percentile(0.5), p95: frame.percentile(0.95), p99: frame.percentile(0.99) },
504
+ cpuMs: { mean: cpu.mean(), p95: cpu.percentile(0.95) },
505
+ drawCalls: info.drawCalls,
506
+ triangles: info.triangles,
507
+ playersInView: players.length + (view.self ? 1 : 0),
508
+ entitiesInView: view.others.length,
509
+ previewFight: scene.previewFight,
510
+ downKBps,
511
+ upKBps,
512
+ rttMs: rtt,
513
+ joinMs,
514
+ firstFrameMs,
515
+ corrections: conn.net.corrections,
516
+ inputBuffer: conn.net.serverInputBuffer,
517
+ frames,
518
+ roomId: conn.roomId,
519
+ phase: r?.phase ?? "",
520
+ kills: mine?.kills ?? 0,
521
+ deaths: mine?.deaths ?? 0,
522
+ pixelRatio,
523
+ feel: { numbers: feel.numbers?.shown ?? 0, ...scene.counts, hitStops: feel.clock.stops, shakes, hits: feel.counts.hits, kills: feel.counts.kills },
524
+ sound: { ...sound.loaded, played: sfx.played },
525
+ };
526
+ window.__arena = stats;
527
+ if (real - lastStats > 250) {
528
+ lastStats = real;
529
+ hud.stats(
530
+ [
531
+ `${stats.backend.toUpperCase()} ${stats.fps.toFixed(0)} fps p95 ${stats.frameMs.p95.toFixed(1)} ms dpr ${pixelRatio}`,
532
+ `draws ${stats.drawCalls} tris ${(stats.triangles / 1000).toFixed(0)}k players ${stats.playersInView} entities ${stats.entitiesInView}`,
533
+ `down ${downKBps.toFixed(1)} KB/s up ${upKBps.toFixed(2)} KB/s rtt ${rtt.toFixed(0)} ms queue ${conn.net.serverInputBuffer?.toFixed(1) ?? "-"}`,
534
+ `join ${joinMs.toFixed(0)} ms first frame ${firstFrameMs.toFixed(0)} ms corrections ${conn.net.corrections}`,
535
+ ].join("\n"),
536
+ );
537
+ }
538
+ });
539
+ }
540
+
541
+ main().catch((e: unknown) => {
542
+ if (bannerText) bannerText.textContent = `Could not start: ${e instanceof Error ? e.message : String(e)}`;
543
+ console.error(e);
544
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Display names for the template's content ids. Labels only; the rules are
3
+ * in src/game.ts. A Net Forge game generated on this template ships its own
4
+ * table (the model writes src/names.json; the build turns it into this
5
+ * module), so the client imports only these names from here.
6
+ */
7
+ export const TITLE = "Voltyard";
8
+
9
+ export const WEAPON_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
10
+ arc_rifle: { name: "Arc Rifle", line: "Fast, steady" },
11
+ scatter_coil: { name: "Scatter Coil", line: "Close range spread" },
12
+ rail_lance: { name: "Rail Lance", line: "Instant, long range" },
13
+ };
14
+
15
+ export const UPGRADE_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
16
+ plating: { name: "Plating", line: "+25 max health" },
17
+ servo: { name: "Servo", line: "Move faster" },
18
+ capacitor: { name: "Capacitor", line: "Fire faster" },
19
+ };
20
+
21
+ // Bosses and creatures by prefab id (the yard has none; a game that adds enemies labels them here).
22
+ export const ENEMY_NAMES: Readonly<Record<string, string>> = {
23
+ };
24
+
25
+ export const RULE_NAMES: Readonly<Record<string, string>> = {
26
+ live_wire: "Live Wire",
27
+ long_shift: "Long Shift",
28
+ overload: "Overload",
29
+ top_of_the_yard: "Top of the Yard",
30
+ };
31
+
32
+ export const SKU_NAMES: Readonly<Record<string, string>> = {
33
+ plating: "Plating",
34
+ servo: "Servo",
35
+ capacitor: "Capacitor",
36
+ skin_neon: "Neon finish",
37
+ skin_chrome: "Chrome finish",
38
+ plating_kit: "Plating kit",
39
+ };
40
+
41
+ /** Player colours by slot, and a highlight palette (the generated names module carries both). */
42
+ export const KEEPER_COLORS = [0x16e0ff, 0xff4fd8, 0xc6ff3d, 0xffb020, 0x9c7bff, 0xff5a4f, 0x3dffb0, 0xf2f2f2];
43
+ export const AURORA = { body: 0x3cf2c4, glow: 0xa77bff };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Player colours and the store's cosmetic finishes. The cosmetic id rides
3
+ * in the player's `type` field (0 default, then COSMETICS in manifest.ts).
4
+ */
5
+ import { BOT_PLAYER_BASE } from "@netforge/engine-sim";
6
+ import { KEEPER_COLORS } from "./names.js";
7
+
8
+ /**
9
+ * Seat bots' colours: hot and saturated, so every opponent pops off the dark
10
+ * yard (the scenery is greys and dim blues; people keep their slot colours).
11
+ */
12
+ export const BOT_COLORS = [0xff4433, 0xff9a1a, 0xffe14d, 0xff3d9a, 0xb36bff, 0x4dff8a, 0xff6f4d];
13
+
14
+ /** A player's colour: humans by slot, bots from BOT_COLORS. */
15
+ export function playerColor(owner: number): number {
16
+ if (owner >= BOT_PLAYER_BASE) return BOT_COLORS[(owner - BOT_PLAYER_BASE) % BOT_COLORS.length] ?? 0xff4433;
17
+ return KEEPER_COLORS[(owner - 1) % KEEPER_COLORS.length] ?? 0xffffff;
18
+ }
19
+
20
+ export interface Finish {
21
+ readonly body: number;
22
+ readonly glow: number;
23
+ readonly roughness: number;
24
+ readonly metalness: number;
25
+ }
26
+
27
+ /** 0 default, 1 Neon (a hot two-tone glow), 2 Chrome (mirror metal with a coloured trim). */
28
+ export function finishOf(id: number, base: number): Finish {
29
+ if (id === 1) return { body: 0x1a0f2e, glow: 0xff4fd8, roughness: 0.3, metalness: 0.2 };
30
+ if (id === 2) return { body: 0xdfe6f0, glow: base, roughness: 0.12, metalness: 1 };
31
+ return { body: base, glow: base, roughness: 0.45, metalness: 0.35 };
32
+ }
33
+
34
+ /** CSS colour from a hex number. */
35
+ export const css = (c: number): string => `#${c.toString(16).padStart(6, "0")}`;