@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/main.ts
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROMPHOUSE client: join a room by link, predict your own party-goer, draw
|
|
3
|
+
* everyone else ~100 ms in the past, and dress it with the game-feel kit.
|
|
4
|
+
*
|
|
5
|
+
* URL parameters:
|
|
6
|
+
* ?room=<id> join a friend's instance (the Invite button makes this link)
|
|
7
|
+
* ?place=<name> the place (default "playroom")
|
|
8
|
+
* ?server=<url> room server (default: VITE_ROOMS_URL, else this host on :2567)
|
|
9
|
+
* ?webgl=1 force the WebGL2 backend
|
|
10
|
+
* ?autopilot=1 a bot plays (headless measurement)
|
|
11
|
+
* ?stats=1 show frame, network and server numbers (also the ` key)
|
|
12
|
+
* ?quality=low|medium|high pin the render quality tier (default: auto-detected)
|
|
13
|
+
* ?feel=subtle|default|heavy the game-feel preset
|
|
14
|
+
* ?shapes=1 draw the fallback shapes instead of library models
|
|
15
|
+
* ?style=<style> draw the game in another art style (toon, ps1, pixel, voxel, handdrawn; default: the style game.ts declares)
|
|
16
|
+
* ?library=<url> asset library base URL (default: VITE_LIBRARY_URL, else ./library)
|
|
17
|
+
* ?host=netnet, ?coin=, ?title=, ?player= set by Net Forge (client/host.ts)
|
|
18
|
+
*/
|
|
19
|
+
import { initSim, keeperButtons, maxHealthFor, resolveConfig } from "@netforge/engine-sim";
|
|
20
|
+
import type { InputCommand } from "@netforge/engine-sim";
|
|
21
|
+
import { CROWD_UNITS, F, HumanBot, insideView, PLAYER_UNITS } from "@netforge/engine/game";
|
|
22
|
+
import type { EngineRoomWorld, RunInfo } from "@netforge/engine/game";
|
|
23
|
+
import { guestId, joinRoom, ProfileApi, rotateGuestId, savedSession, saveSession, signInWithWallet } from "@netforge/engine/net";
|
|
24
|
+
import type { Eip1193, GameProfile, ProfileAuth, RoomConnection } from "@netforge/engine/net";
|
|
25
|
+
import { createRenderer, DamageNumbers, FeelKit, FrameStats, pickStyle, SoundPlayer } from "@netforge/engine/runtime";
|
|
26
|
+
import type { FeelPresetName, QualityTier } from "@netforge/engine/runtime";
|
|
27
|
+
import { soundBank } from "@netforge/asset-library";
|
|
28
|
+
import { LibraryClient } from "@netforge/asset-library/three";
|
|
29
|
+
import { COSMETICS, GAME_VERSION, PLACE, partyAdapter, partyGame, TEMPLATE } from "../src/manifest.js";
|
|
30
|
+
import { COSMETIC_PRICE, mockKit, REVIVE_PRICE } from "../src/store.js";
|
|
31
|
+
import type { KitStoreBridge, SkuPrice } from "../src/store.js";
|
|
32
|
+
import { hostFlags, namedPlayer, onHostPlayer, postReady, storeNote } from "./host.js";
|
|
33
|
+
import type { PlayerMode } from "./host.js";
|
|
34
|
+
import { Hud } from "./hud.js";
|
|
35
|
+
import { PartyInput } from "./input.js";
|
|
36
|
+
import { PartyScene } from "./scene.js";
|
|
37
|
+
import type { SceneEntity } from "./scene.js";
|
|
38
|
+
import { gearOf, wireGear, wireStake } from "./stake.js";
|
|
39
|
+
import { SKU_NAMES, TITLE } from "./names.js";
|
|
40
|
+
|
|
41
|
+
export interface PartyStats {
|
|
42
|
+
backend: string;
|
|
43
|
+
fps: number;
|
|
44
|
+
frameMs: { mean: number; p50: number; p95: number; p99: number };
|
|
45
|
+
cpuMs: { mean: number; p95: number };
|
|
46
|
+
drawCalls: number;
|
|
47
|
+
triangles: number;
|
|
48
|
+
enemiesDrawn: number;
|
|
49
|
+
entitiesInView: number;
|
|
50
|
+
downKBps: number;
|
|
51
|
+
upKBps: number;
|
|
52
|
+
rttMs: number;
|
|
53
|
+
joinMs: number;
|
|
54
|
+
firstFrameMs: number;
|
|
55
|
+
corrections: number;
|
|
56
|
+
inputBuffer: number | null;
|
|
57
|
+
frames: number;
|
|
58
|
+
roomId: string;
|
|
59
|
+
phase: string;
|
|
60
|
+
wave: number;
|
|
61
|
+
level: number;
|
|
62
|
+
pixelRatio: number;
|
|
63
|
+
/** Game-feel effects fired so far. */
|
|
64
|
+
feel: { numbers: number; rings: number; bursts: number; flashes: number; decals: number; impacts: number; crits: number; rises: number; slowMos: number; levelUps: number; hitStops: number; shakes: number; kills: number; chunks: number; blasts: number };
|
|
65
|
+
/** Render quality tier in use. */
|
|
66
|
+
quality: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
declare global {
|
|
70
|
+
interface Window {
|
|
71
|
+
__party?: PartyStats;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const params = new URLSearchParams(location.search);
|
|
76
|
+
// The Kit flags (client/host.ts): framed by Net Forge (?host=netnet),
|
|
77
|
+
// the host draws Invite and sign-in, so the game hides its own and the two
|
|
78
|
+
// never both show; it names the game's coin and title, and says whether play
|
|
79
|
+
// pays the person at the screen. Standalone play reads none of this.
|
|
80
|
+
const launcher = hostFlags(params);
|
|
81
|
+
if (launcher.framed) document.documentElement.dataset.host = "netnet";
|
|
82
|
+
// The game's own title (a draft's names.json), unless the host names it.
|
|
83
|
+
document.title = launcher.title ?? TITLE;
|
|
84
|
+
let playerMode: PlayerMode = launcher.player;
|
|
85
|
+
// The mode the host has named, on the URL or in a message: once it has, it
|
|
86
|
+
// alone says whether coins are real, for the coin box and the store alike.
|
|
87
|
+
let hostMode: PlayerMode | null = namedPlayer(params);
|
|
88
|
+
let onPlayer: ((mode: PlayerMode) => void) | null = null;
|
|
89
|
+
onHostPlayer(launcher, (mode) => {
|
|
90
|
+
playerMode = mode;
|
|
91
|
+
hostMode = mode;
|
|
92
|
+
onPlayer?.(mode);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A game played in Net Forge names its own coin (?coin=TICKER, 2 to 6
|
|
97
|
+
* capitals or digits), so its HUD never shows the template's coin. The
|
|
98
|
+
* template's own token keeps its logo.
|
|
99
|
+
*/
|
|
100
|
+
function withDraftCoin(kit: KitStoreBridge): KitStoreBridge {
|
|
101
|
+
const coin = launcher.coin;
|
|
102
|
+
if (!coin || coin === kit.coins.symbol()) return kit;
|
|
103
|
+
return { ...kit, coins: { ...kit.coins, name: () => coin, symbol: () => coin, logo: () => null } };
|
|
104
|
+
}
|
|
105
|
+
const bootAt = performance.now();
|
|
106
|
+
const env = (import.meta as unknown as { env: Record<string, string | undefined> }).env;
|
|
107
|
+
const endpoint = params.get("server") ?? env.VITE_ROOMS_URL ?? `${location.protocol}//${location.hostname}:2567`;
|
|
108
|
+
const autopilot = params.get("autopilot") === "1";
|
|
109
|
+
const banner = document.getElementById("banner");
|
|
110
|
+
const bannerText = document.getElementById("banner-text");
|
|
111
|
+
const bannerTitle = banner?.querySelector("b");
|
|
112
|
+
if (bannerTitle) bannerTitle.textContent = (launcher.title ?? TITLE).toUpperCase();
|
|
113
|
+
const host = document.getElementById("game");
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What this browser kept before saves moved to the server: read once,
|
|
117
|
+
* imported into the guest profile, then left alone. Every access may fail.
|
|
118
|
+
*/
|
|
119
|
+
const legacy = {
|
|
120
|
+
get(key: string): string | null {
|
|
121
|
+
try {
|
|
122
|
+
return localStorage.getItem(`romphouse:${key}`);
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
set(key: string, value: string): void {
|
|
128
|
+
try {
|
|
129
|
+
localStorage.setItem(`romphouse:${key}`, value);
|
|
130
|
+
} catch {
|
|
131
|
+
// Private window or blocked storage.
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const loadSet = (key: string): Set<string> => new Set((legacy.get(key) ?? "").split(",").filter(Boolean));
|
|
136
|
+
|
|
137
|
+
async function main(): Promise<void> {
|
|
138
|
+
if (!host) throw new Error("missing #game");
|
|
139
|
+
await initSim();
|
|
140
|
+
const game = partyGame();
|
|
141
|
+
const config = resolveConfig(game.sim);
|
|
142
|
+
const canvas = document.createElement("canvas");
|
|
143
|
+
host.append(canvas);
|
|
144
|
+
const handle = await createRenderer(canvas, { forceWebGL: params.get("webgl") === "1", antialias: true });
|
|
145
|
+
|
|
146
|
+
const adapter = partyAdapter();
|
|
147
|
+
const friendRoomId = params.get("room") ?? undefined;
|
|
148
|
+
const place = params.get("place") ?? PLACE;
|
|
149
|
+
const joinStart = performance.now();
|
|
150
|
+
// Saves live on the room server: a guest id until the player signs in with a wallet.
|
|
151
|
+
let session = savedSession(endpoint);
|
|
152
|
+
const conn: RoomConnection<EngineRoomWorld, InputCommand> = await joinRoom(adapter, {
|
|
153
|
+
endpoint,
|
|
154
|
+
place,
|
|
155
|
+
template: TEMPLATE,
|
|
156
|
+
guest: guestId(),
|
|
157
|
+
...(session ? { session: session.token } : {}),
|
|
158
|
+
...(friendRoomId ? { friendRoomId } : {}),
|
|
159
|
+
});
|
|
160
|
+
const joinMs = performance.now() - joinStart;
|
|
161
|
+
if (friendRoomId && conn.roomId !== friendRoomId) setTimeout(() => hud.toast("That room was full, started a new one"), 500);
|
|
162
|
+
if (conn.welcome.version !== GAME_VERSION) setTimeout(() => hud.toast("A new version is live. Reload to update."), 800);
|
|
163
|
+
history.replaceState(null, "", `${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${params.has("game") ? `&game=${encodeURIComponent(params.get("game") ?? "")}` : ""}${autopilot ? "&autopilot=1" : ""}${params.has("webgl") ? "&webgl=1" : ""}${params.has("stats") ? "&stats=1" : ""}${params.has("style") ? `&style=${encodeURIComponent(params.get("style") ?? "")}` : ""}`);
|
|
164
|
+
|
|
165
|
+
// The engine's feel kit: hit-stop clock, trauma shake, flash, knockback, numbers, sound, rumble.
|
|
166
|
+
const feelParam = params.get("feel");
|
|
167
|
+
const feel = new FeelKit({
|
|
168
|
+
preset: feelParam === "subtle" || feelParam === "heavy" ? (feelParam as FeelPresetName) : "default",
|
|
169
|
+
// Crits show in gold.
|
|
170
|
+
numbers: new DamageNumbers(document.body, { colors: { crit: "#ffd23f" } }),
|
|
171
|
+
});
|
|
172
|
+
const qualityParam = params.get("quality");
|
|
173
|
+
// ?style=<art style> previews the game in another style (default: the one game.ts declares).
|
|
174
|
+
const artStyle = pickStyle(game.manifest.game.style, params.get("style"));
|
|
175
|
+
// The asset library: real models by library id (./library next to the page, or the published CID). ?shapes=1 draws the fallback shapes only.
|
|
176
|
+
const libraryBase = params.get("library") ?? env.VITE_LIBRARY_URL ?? "./library";
|
|
177
|
+
const library = params.get("shapes") === "1" ? null : new LibraryClient(handle, { baseUrl: libraryBase, style: artStyle });
|
|
178
|
+
// Real CC0 samples for the feel kit's cues and a music loop, from the library's audio section. Starts on the first press; the mute is remembered.
|
|
179
|
+
const sound = feel.audio ? new SoundPlayer(feel.audio, { key: "romphouse:sound", musicVolume: 0.35 }) : null;
|
|
180
|
+
sound?.load(soundBank(libraryBase));
|
|
181
|
+
const scene = new PartyScene(handle, game, config, feel, {
|
|
182
|
+
library,
|
|
183
|
+
style: artStyle,
|
|
184
|
+
quality: qualityParam === "low" || qualityParam === "medium" || qualityParam === "high" ? (qualityParam as QualityTier) : "auto",
|
|
185
|
+
switches: {
|
|
186
|
+
...(params.get("shadows") === "0" ? { shadows: false } : params.get("shadows") === "1" ? { shadows: true } : {}),
|
|
187
|
+
...(params.get("post") === "0" ? { post: false } : {}),
|
|
188
|
+
...(params.get("lights") === "0" ? { lights: false } : {}),
|
|
189
|
+
...(params.get("props") === "0" ? { props: false } : {}),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
feel.camera = scene.rig;
|
|
193
|
+
feel.project = (x, y, z) => scene.project(x, y, z);
|
|
194
|
+
// Build every GPU pipeline now, behind the banner, so the first kill does not hitch.
|
|
195
|
+
scene.resize(Math.max(1, host.clientWidth), Math.max(1, host.clientHeight));
|
|
196
|
+
await scene.warmup();
|
|
197
|
+
const input = new PartyInput(document.body);
|
|
198
|
+
const sfx = feel.audio;
|
|
199
|
+
// Low health: a heartbeat under the music.
|
|
200
|
+
sfx?.define("heartbeat", { wave: "sine", f0: 86, f1: 42, ms: 170, gain: 0.9 }, { minGapMs: 280, voices: 1, pitchVar: 0 });
|
|
201
|
+
// Achievements, unlocked keepers, today's goals and the keeper pick come from the player's profile on the server.
|
|
202
|
+
const unlocked = new Set<string>();
|
|
203
|
+
const goalsDone = new Set<string>();
|
|
204
|
+
const open = new Set<string>();
|
|
205
|
+
const keeperIds = [...game.manifest.keepers.keys()];
|
|
206
|
+
const keeperOpen = (i: number) => {
|
|
207
|
+
const k = [...game.manifest.keepers.values()][i];
|
|
208
|
+
return !!k && (k.unlock === undefined || open.has(keeperIds[i] ?? ""));
|
|
209
|
+
};
|
|
210
|
+
let keeperPick = 0;
|
|
211
|
+
input.keeper = keeperIds.length > 0 ? keeperPick : -1;
|
|
212
|
+
let api: ProfileApi | null = null;
|
|
213
|
+
let profile: GameProfile | null = null;
|
|
214
|
+
const auth = (): ProfileAuth => (session ? { session: session.token } : { guest: guestId() });
|
|
215
|
+
const prices: Record<string, SkuPrice> = Object.fromEntries(game.manifest.skus);
|
|
216
|
+
// The Kit plugs in here (packages/kit, kit.init({ game })): kit.coins and
|
|
217
|
+
// kit.store have these exact names. The mock stands in until the token is live.
|
|
218
|
+
const kit: KitStoreBridge = withDraftCoin(mockKit({ earned: 60, wallet: 0, usdg: 5 }, prices));
|
|
219
|
+
let storeOpen = false;
|
|
220
|
+
let run: RunInfo | null = null;
|
|
221
|
+
conn.onRun((info) => {
|
|
222
|
+
run = info as RunInfo;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const hud = new Hud(game, config, {
|
|
226
|
+
pick: (k) => {
|
|
227
|
+
sfx?.unlock();
|
|
228
|
+
sfx?.play("pick");
|
|
229
|
+
input.pick(k);
|
|
230
|
+
},
|
|
231
|
+
reroll: () => input.reroll(),
|
|
232
|
+
skip: () => input.skip(),
|
|
233
|
+
revive: () => void paidRevive(),
|
|
234
|
+
getUp: () => input.getUp(),
|
|
235
|
+
invite: () => {
|
|
236
|
+
const url = `${location.origin}${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${params.has("game") ? `&game=${encodeURIComponent(params.get("game") ?? "")}` : ""}`;
|
|
237
|
+
void navigator.clipboard?.writeText(url).then(
|
|
238
|
+
() => hud.toast("Invite link copied"),
|
|
239
|
+
() => hud.toast(url),
|
|
240
|
+
);
|
|
241
|
+
},
|
|
242
|
+
toggleStore: () => {
|
|
243
|
+
storeOpen = !storeOpen;
|
|
244
|
+
document.getElementById("store")?.classList.toggle("show", storeOpen);
|
|
245
|
+
},
|
|
246
|
+
toggleSound: () => {
|
|
247
|
+
if (!sound) return true;
|
|
248
|
+
sfx?.unlock();
|
|
249
|
+
return sound.toggle();
|
|
250
|
+
},
|
|
251
|
+
keeper: (i) => {
|
|
252
|
+
if (!keeperOpen(i)) return;
|
|
253
|
+
keeperPick = i;
|
|
254
|
+
input.keeper = i;
|
|
255
|
+
void api?.setSettings(auth(), { ...(profile?.settings ?? {}), keeper: keeperIds[i] ?? "" }).catch(() => undefined);
|
|
256
|
+
hud.setKeepers(keeperPick, open, goalsDone);
|
|
257
|
+
sfx?.play("pick");
|
|
258
|
+
},
|
|
259
|
+
signIn: () => void signIn(),
|
|
260
|
+
again: () => {
|
|
261
|
+
input.ready = true;
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
hud.setKeepers(keeperPick, open, goalsDone);
|
|
265
|
+
hud.setSound(sound?.muted ?? true);
|
|
266
|
+
hud.setAccount(session?.address ?? null);
|
|
267
|
+
|
|
268
|
+
/** The room pushes the profile on join and on every change (an achievement from the sim, a merge, a purchase). */
|
|
269
|
+
let firstProfile = true;
|
|
270
|
+
conn.onProfile((p) => {
|
|
271
|
+
const before = new Set(unlocked);
|
|
272
|
+
profile = p;
|
|
273
|
+
api ??= new ProfileApi(endpoint, p.game);
|
|
274
|
+
unlocked.clear();
|
|
275
|
+
for (const a of p.achievements) unlocked.add(a.id);
|
|
276
|
+
open.clear();
|
|
277
|
+
for (const k of p.keepers) open.add(k);
|
|
278
|
+
goalsDone.clear();
|
|
279
|
+
for (const g of p.goals.done) goalsDone.add(g);
|
|
280
|
+
if (firstProfile) {
|
|
281
|
+
firstProfile = false;
|
|
282
|
+
const want = typeof p.settings.keeper === "string" ? keeperIds.indexOf(p.settings.keeper) : -1;
|
|
283
|
+
if (want >= 0 && keeperOpen(want)) keeperPick = want;
|
|
284
|
+
void carryOver(p);
|
|
285
|
+
} else {
|
|
286
|
+
for (const id of unlocked) {
|
|
287
|
+
if (before.has(id)) continue;
|
|
288
|
+
const keeperName = [...game.manifest.keepers.values()].find((k) => k.unlock === id)?.name;
|
|
289
|
+
hud.toast(`${game.manifest.achievements.get(id)?.title ?? id}${keeperName ? ` · ${keeperName} unlocked` : ""}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!keeperOpen(keeperPick)) keeperPick = 0;
|
|
293
|
+
input.keeper = keeperIds.length > 0 ? keeperPick : -1;
|
|
294
|
+
hud.setKeepers(keeperPick, open, goalsDone);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Once: progress this browser kept before saves moved to the server goes
|
|
299
|
+
* into the guest profile; a signed-in player's guest progress then merges
|
|
300
|
+
* into the wallet (and the guest id is spent).
|
|
301
|
+
*/
|
|
302
|
+
async function carryOver(p: GameProfile): Promise<void> {
|
|
303
|
+
if (!api) return;
|
|
304
|
+
try {
|
|
305
|
+
const old = loadSet("achievements");
|
|
306
|
+
const today = Math.floor(Date.now() / 86_400_000);
|
|
307
|
+
const oldGoals = loadSet(`goals-${today}`);
|
|
308
|
+
const oldKeeper = legacy.get("keeper");
|
|
309
|
+
if (legacy.get("imported") !== "1" && (old.size > 0 || oldGoals.size > 0 || oldKeeper)) {
|
|
310
|
+
if (p.kind === "guest" && p.imported) legacy.set("imported", "1");
|
|
311
|
+
else {
|
|
312
|
+
await api.importLocal(guestId(), { achievements: [...old], goals: { day: today, done: [...oldGoals] }, ...(oldKeeper ? { settings: { keeper: oldKeeper } } : {}) });
|
|
313
|
+
legacy.set("imported", "1");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (session) {
|
|
317
|
+
await api.claim(session.token, guestId());
|
|
318
|
+
rotateGuestId();
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
// The server keeps whatever it has; the next load tries again.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Sign in with a wallet (one signature, no gas): the guest's progress merges into the wallet's saves. */
|
|
326
|
+
async function signIn(): Promise<void> {
|
|
327
|
+
const provider = (window as unknown as { ethereum?: Eip1193 }).ethereum;
|
|
328
|
+
if (!provider) return hud.toast("No wallet found in this browser");
|
|
329
|
+
try {
|
|
330
|
+
session = await signInWithWallet(endpoint, provider);
|
|
331
|
+
if (api) await api.claim(session.token, guestId()).catch(() => undefined);
|
|
332
|
+
rotateGuestId();
|
|
333
|
+
const r = await conn.signIn(session.token);
|
|
334
|
+
hud.setAccount(session.address);
|
|
335
|
+
hud.toast(r.ok ? "Signed in: progress saved to your wallet" : "Signed in");
|
|
336
|
+
} catch {
|
|
337
|
+
saveSession(endpoint, null);
|
|
338
|
+
session = null;
|
|
339
|
+
hud.toast("Sign-in cancelled");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
let runNumber = 0;
|
|
343
|
+
conn.onEarn((e) => hud.earned(e.rule, e.units));
|
|
344
|
+
|
|
345
|
+
async function paidRevive(): Promise<void> {
|
|
346
|
+
let proof: string | null;
|
|
347
|
+
try {
|
|
348
|
+
proof = (await kit.coins.spend(REVIVE_PRICE, "revive")).key;
|
|
349
|
+
} catch {
|
|
350
|
+
hud.toast(`Not enough ${kit.coins.symbol()}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// The spend's key proves the paid revive to the room, once.
|
|
354
|
+
const r = await conn.command({ name: "revive", ...(proof ? { proof } : {}) });
|
|
355
|
+
if (!r.ok) hud.toast(r.reason === "store not live" ? "Revives open when the store goes live" : "Revive not available now");
|
|
356
|
+
}
|
|
357
|
+
async function equipCosmetic(): Promise<void> {
|
|
358
|
+
const r = await conn.command({ name: "cosmetic", value: COSMETICS.skin_disco ?? 1 });
|
|
359
|
+
if (!r.ok) hud.toast("Cosmetics open when the store goes live");
|
|
360
|
+
}
|
|
361
|
+
const buy = document.getElementById("buy-disco") as HTMLButtonElement | null;
|
|
362
|
+
buy?.addEventListener("click", async () => {
|
|
363
|
+
if (!kit.store.owns("skin_disco")) {
|
|
364
|
+
try {
|
|
365
|
+
await kit.store.buy("skin_disco", { pay: "coins" });
|
|
366
|
+
} catch {
|
|
367
|
+
hud.toast(`Not enough ${kit.coins.symbol()}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
buy.textContent = "Equipped";
|
|
372
|
+
await equipCosmetic();
|
|
373
|
+
});
|
|
374
|
+
hud.coin(kit.coins.name(), kit.coins.symbol(), kit.coins.logo());
|
|
375
|
+
wireStake(conn, kit, (t) => hud.toast(t));
|
|
376
|
+
wireGear(gearOf(game.manifest.skus, SKU_NAMES), kit, (t) => hud.toast(t));
|
|
377
|
+
// Signed out in Net Forge, the HUD says play is not earning instead of showing a balance that never moves.
|
|
378
|
+
hud.player(playerMode);
|
|
379
|
+
// The store's note follows the same mode as the coin box (client/host.ts storeNote).
|
|
380
|
+
const note = document.getElementById("store-note");
|
|
381
|
+
const showNote = () => {
|
|
382
|
+
if (note) note.textContent = storeNote(hostMode, conn.welcome.earnLive, kit.coins.symbol());
|
|
383
|
+
};
|
|
384
|
+
showNote();
|
|
385
|
+
onPlayer = (mode) => {
|
|
386
|
+
hud.player(mode);
|
|
387
|
+
showNote();
|
|
388
|
+
};
|
|
389
|
+
postReady(launcher);
|
|
390
|
+
const priceEl = document.getElementById("revive-price");
|
|
391
|
+
if (priceEl) priceEl.textContent = `${REVIVE_PRICE} ${kit.coins.symbol()}`;
|
|
392
|
+
if (buy) buy.textContent = `${COSMETIC_PRICE} ${kit.coins.symbol()}`;
|
|
393
|
+
|
|
394
|
+
// RTT probe every 2 s (a control message, not the hot path).
|
|
395
|
+
let rtt = 0;
|
|
396
|
+
conn.room.onMessage("pong", (t: number) => {
|
|
397
|
+
rtt = performance.now() - t;
|
|
398
|
+
});
|
|
399
|
+
setInterval(() => conn.room.send("ping", performance.now()), 2000);
|
|
400
|
+
|
|
401
|
+
// ?autopilot=1: the human-like playtest bot (200 ms reactions, kiting, a second per card).
|
|
402
|
+
const autopilotBot = new HumanBot({ picks: "greedy", weaponCount: game.weaponIds.length, seed: conn.welcome.player });
|
|
403
|
+
// Input on its own fixed clock, steered by the server's queue (clock adjustment).
|
|
404
|
+
let nextTick = performance.now();
|
|
405
|
+
let ticks = 0;
|
|
406
|
+
let lastView: { self: SceneEntity | undefined; others: readonly SceneEntity[] } = { self: undefined, others: [] };
|
|
407
|
+
setInterval(() => {
|
|
408
|
+
const now = performance.now();
|
|
409
|
+
let n = 0;
|
|
410
|
+
while (nextTick <= now && n < 5) {
|
|
411
|
+
let cmd: InputCommand;
|
|
412
|
+
if (autopilot) {
|
|
413
|
+
const me = lastView.self;
|
|
414
|
+
const enemies: [number, number][] = [];
|
|
415
|
+
for (const e of lastView.others) if (e.kind === 2) enemies.push([(e.values[F.ex] ?? 0) / CROWD_UNITS, (e.values[F.ez] ?? 0) / CROWD_UNITS]);
|
|
416
|
+
const gems: [number, number][] = [];
|
|
417
|
+
for (const e of lastView.others) if (e.kind === 4) gems.push([(e.values[F.ex] ?? 0) / CROWD_UNITS, (e.values[F.ez] ?? 0) / CROWD_UNITS]);
|
|
418
|
+
if (me) {
|
|
419
|
+
const x = (me.values[F.px] ?? 0) / PLAYER_UNITS;
|
|
420
|
+
const z = (me.values[F.pz] ?? 0) / PLAYER_UNITS;
|
|
421
|
+
const seen = (q: readonly [number, number]) => insideView(game.view, q[0] - x, q[1] - z);
|
|
422
|
+
const flags = me.values[F.flags] ?? 0;
|
|
423
|
+
cmd = autopilotBot.input(
|
|
424
|
+
{ x, z, up: (flags & 6) === 0, choices: me.values[F.choices] ?? 0, weapons: me.values[F.weapons] ?? 0, half: config.arena.halfExtent, pillars: config.arena.pillars, enemies: enemies.filter(seen), gems: gems.filter(seen) },
|
|
425
|
+
ticks,
|
|
426
|
+
);
|
|
427
|
+
} else cmd = adapter.input.idle;
|
|
428
|
+
// The autopilot plays the picked keeper and votes to play again.
|
|
429
|
+
cmd = { ...cmd, buttons: cmd.buttons | (keeperIds.length > 0 ? keeperButtons(keeperPick) : 0) | (input.ready ? 1 << 10 : 0) };
|
|
430
|
+
} else cmd = input.sample(ticks);
|
|
431
|
+
conn.net.tick(cmd, now);
|
|
432
|
+
ticks++;
|
|
433
|
+
nextTick += conn.net.tickIntervalMs();
|
|
434
|
+
n++;
|
|
435
|
+
}
|
|
436
|
+
if (n === 5) nextTick = now + conn.net.tickIntervalMs();
|
|
437
|
+
}, 4);
|
|
438
|
+
sfx?.unlockOnGesture();
|
|
439
|
+
// Buttons click (cards and keepers play their own confirm).
|
|
440
|
+
addEventListener("click", (e) => {
|
|
441
|
+
const b = (e.target as HTMLElement | null)?.closest?.("button");
|
|
442
|
+
if (b && !b.closest("#cards") && !b.closest("#keepers")) sfx?.play("ui");
|
|
443
|
+
});
|
|
444
|
+
if (input.touch) hud.touchHint();
|
|
445
|
+
addEventListener("keydown", (e) => {
|
|
446
|
+
if (e.code === "Backquote") document.getElementById("stats")?.classList.toggle("show");
|
|
447
|
+
});
|
|
448
|
+
if (params.get("stats") === "1") document.getElementById("stats")?.classList.add("show");
|
|
449
|
+
|
|
450
|
+
const resize = () => scene.resize(Math.max(1, host.clientWidth), Math.max(1, host.clientHeight));
|
|
451
|
+
resize();
|
|
452
|
+
new ResizeObserver(resize).observe(host);
|
|
453
|
+
|
|
454
|
+
const frame = new FrameStats(600);
|
|
455
|
+
const cpu = new FrameStats(600);
|
|
456
|
+
let last = 0;
|
|
457
|
+
let frames = 0;
|
|
458
|
+
let firstFrameMs = 0;
|
|
459
|
+
let level = 1;
|
|
460
|
+
let xpSeen = 0;
|
|
461
|
+
let wasDown = false;
|
|
462
|
+
let nextBeat = 0;
|
|
463
|
+
let lastBytes = 0;
|
|
464
|
+
let lastUp = 0;
|
|
465
|
+
let lastNet = performance.now();
|
|
466
|
+
let downKBps = 0;
|
|
467
|
+
let upKBps = 0;
|
|
468
|
+
let lastStats = 0;
|
|
469
|
+
let pixelRatio = handle.renderer.getPixelRatio();
|
|
470
|
+
// The red vignette is drawn by the post stack now (FeelKit.hurtPulse).
|
|
471
|
+
const hurt = document.getElementById("hurt");
|
|
472
|
+
if (hurt) hurt.style.display = "none";
|
|
473
|
+
const tickMs = 1000 / adapter.tickHz;
|
|
474
|
+
|
|
475
|
+
handle.renderer.setAnimationLoop((t: number) => {
|
|
476
|
+
const real = performance.now();
|
|
477
|
+
if (last !== 0) frame.push(t - last);
|
|
478
|
+
last = t;
|
|
479
|
+
const { now, dt, realDt } = feel.tick(real);
|
|
480
|
+
const view = conn.net.render(now);
|
|
481
|
+
lastView = view;
|
|
482
|
+
const worldTick = conn.net.renderTick(now) + 100 / tickMs;
|
|
483
|
+
const players = view.others.filter((e) => e.kind === 1);
|
|
484
|
+
// Low health: a pulsing red edge and a heartbeat (faster the lower it gets).
|
|
485
|
+
const selfHp = view.self?.values[F.health] ?? 0;
|
|
486
|
+
const selfMax = Math.max(1, maxHealthFor(config, view.self?.values[F.stats] ?? 0));
|
|
487
|
+
const selfUp = view.self ? ((view.self.values[F.flags] ?? 0) & 6) === 0 : false;
|
|
488
|
+
const low = selfUp && selfHp > 0 && selfHp / selfMax < 0.3;
|
|
489
|
+
if (low && real >= nextBeat) {
|
|
490
|
+
sfx?.play("heartbeat");
|
|
491
|
+
nextBeat = real + 520 + (selfHp / selfMax) * 1600;
|
|
492
|
+
}
|
|
493
|
+
const lowPulse = low ? 0.28 + 0.16 * Math.max(0, Math.sin(real / 140)) : 0;
|
|
494
|
+
const ev = scene.frame({ dt, realDt, worldTick, self: view.self, others: view.others, selfPlayer: conn.welcome.player, hurt: Math.max(feel.hurtPulse * 0.7, lowPulse) });
|
|
495
|
+
if (frames === 0) {
|
|
496
|
+
firstFrameMs = real - bootAt;
|
|
497
|
+
banner?.remove();
|
|
498
|
+
}
|
|
499
|
+
frames++;
|
|
500
|
+
|
|
501
|
+
// Game feel the scene does not fire itself: our own hurt and level-ups.
|
|
502
|
+
const w = host.clientWidth;
|
|
503
|
+
const h = host.clientHeight;
|
|
504
|
+
if (ev.selfHurt > 0) feel.hurt(ev.selfHurt, Math.max(1, (view.self?.values[F.health] ?? 0) + ev.selfHurt));
|
|
505
|
+
const myLevel = view.self?.values[F.level] ?? level;
|
|
506
|
+
if (myLevel > level && view.self) {
|
|
507
|
+
// Yours only: the flash, the word and the sound never fire for a teammate's level.
|
|
508
|
+
feel.levelUp();
|
|
509
|
+
hud.levelUp();
|
|
510
|
+
// A level-up burst around the keeper.
|
|
511
|
+
const lx = (view.self.values[F.px] ?? 0) / PLAYER_UNITS;
|
|
512
|
+
const lz = (view.self.values[F.pz] ?? 0) / PLAYER_UNITS;
|
|
513
|
+
scene.stack.particles.burst("magic", lx, 0.6, lz, { count: 36, color: 0x7ad7ff, speed: 6 });
|
|
514
|
+
scene.stack.particles.burst("embers", lx, 0.2, lz, { count: 18, color: 0xffd36b, speed: 4 });
|
|
515
|
+
scene.counts.levelUps++;
|
|
516
|
+
}
|
|
517
|
+
feel.update(dt, realDt);
|
|
518
|
+
// Gems: a soft tink as your XP climbs (the cue's own gap keeps a vacuum from machine-gunning).
|
|
519
|
+
const myXp = view.self?.values[F.xp] ?? 0;
|
|
520
|
+
if (view.self && (myXp > xpSeen || myLevel > level) && frames > 1) sfx?.play("gem", { gain: 0.8 });
|
|
521
|
+
xpSeen = myXp;
|
|
522
|
+
// Going down and getting back up have their own sounds.
|
|
523
|
+
const myFlags = view.self?.values[F.flags] ?? 0;
|
|
524
|
+
const downNow = (myFlags & 6) !== 0;
|
|
525
|
+
if (view.self && downNow !== wasDown && frames > 1) sfx?.play(downNow ? "downed" : "revive");
|
|
526
|
+
wasDown = downNow;
|
|
527
|
+
level = myLevel;
|
|
528
|
+
const runNow = run ?? (conn.latestRun() as RunInfo | null);
|
|
529
|
+
hud.update(real, view.self, players, runNow, (x, y, z) => scene.project(x, y, z, w, h), {
|
|
530
|
+
revivePrice: REVIVE_PRICE,
|
|
531
|
+
balance: kit.coins.balance(),
|
|
532
|
+
});
|
|
533
|
+
if (runNow) {
|
|
534
|
+
if (runNow.run !== runNumber) {
|
|
535
|
+
runNumber = runNow.run;
|
|
536
|
+
input.ready = false;
|
|
537
|
+
}
|
|
538
|
+
// Achievements and goals are saved by the server (the profile message above); the HUD shows the run's progress.
|
|
539
|
+
for (const g of hud.goalsReached(runNow, conn.welcome.player)) hud.toast(`Goal: ${game.manifest.missions.get(g)?.text ?? g}`);
|
|
540
|
+
}
|
|
541
|
+
cpu.push(performance.now() - real);
|
|
542
|
+
|
|
543
|
+
// Dynamic resolution and quality tiers live in the engine's render stack (spec §5).
|
|
544
|
+
pixelRatio = handle.renderer.getPixelRatio();
|
|
545
|
+
|
|
546
|
+
if (real - lastNet >= 1000) {
|
|
547
|
+
const secs = (real - lastNet) / 1000;
|
|
548
|
+
downKBps = (conn.bytesIn() - lastBytes) / secs / 1024;
|
|
549
|
+
upKBps = (conn.net.stats.inputBytes - lastUp) / secs / 1024;
|
|
550
|
+
lastBytes = conn.bytesIn();
|
|
551
|
+
lastUp = conn.net.stats.inputBytes;
|
|
552
|
+
lastNet = real;
|
|
553
|
+
}
|
|
554
|
+
const info = handle.renderer.info.render;
|
|
555
|
+
const mean = frame.mean();
|
|
556
|
+
const stats: PartyStats = {
|
|
557
|
+
backend: handle.backend,
|
|
558
|
+
fps: mean > 0 ? 1000 / mean : 0,
|
|
559
|
+
frameMs: { mean, p50: frame.percentile(0.5), p95: frame.percentile(0.95), p99: frame.percentile(0.99) },
|
|
560
|
+
cpuMs: { mean: cpu.mean(), p95: cpu.percentile(0.95) },
|
|
561
|
+
drawCalls: info.drawCalls,
|
|
562
|
+
triangles: info.triangles,
|
|
563
|
+
enemiesDrawn: ev.enemiesDrawn,
|
|
564
|
+
entitiesInView: view.others.length,
|
|
565
|
+
downKBps,
|
|
566
|
+
upKBps,
|
|
567
|
+
rttMs: rtt,
|
|
568
|
+
joinMs,
|
|
569
|
+
firstFrameMs,
|
|
570
|
+
corrections: conn.net.corrections,
|
|
571
|
+
inputBuffer: conn.net.serverInputBuffer,
|
|
572
|
+
frames,
|
|
573
|
+
roomId: conn.roomId,
|
|
574
|
+
phase: run?.phase ?? "",
|
|
575
|
+
wave: run?.wave ?? 0,
|
|
576
|
+
level,
|
|
577
|
+
pixelRatio,
|
|
578
|
+
feel: {
|
|
579
|
+
numbers: feel.numbers?.shown ?? 0,
|
|
580
|
+
...scene.counts,
|
|
581
|
+
hitStops: feel.clock.stops,
|
|
582
|
+
shakes: feel.counts.hits + feel.counts.hurts + feel.counts.bigKills,
|
|
583
|
+
kills: feel.counts.kills,
|
|
584
|
+
chunks: scene.dressing.counts.chunks,
|
|
585
|
+
blasts: scene.dressing.counts.blasts,
|
|
586
|
+
},
|
|
587
|
+
quality: scene.stack.quality.tier,
|
|
588
|
+
};
|
|
589
|
+
window.__party = stats;
|
|
590
|
+
if (real - lastStats > 250) {
|
|
591
|
+
lastStats = real;
|
|
592
|
+
hud.stats(
|
|
593
|
+
[
|
|
594
|
+
`${stats.backend.toUpperCase()} ${stats.fps.toFixed(0)} fps p95 ${stats.frameMs.p95.toFixed(1)} ms dpr ${pixelRatio}`,
|
|
595
|
+
`draws ${stats.drawCalls} tris ${(stats.triangles / 1000).toFixed(0)}k enemies drawn ${stats.enemiesDrawn}`,
|
|
596
|
+
`down ${downKBps.toFixed(1)} KB/s up ${upKBps.toFixed(2)} KB/s rtt ${rtt.toFixed(0)} ms queue ${conn.net.serverInputBuffer?.toFixed(1) ?? "-"}`,
|
|
597
|
+
`join ${joinMs.toFixed(0)} ms first frame ${firstFrameMs.toFixed(0)} ms corrections ${conn.net.corrections}`,
|
|
598
|
+
].join("\n"),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
main().catch((e: unknown) => {
|
|
605
|
+
if (bannerText) bannerText.textContent = `Could not start: ${e instanceof Error ? e.message : String(e)}`;
|
|
606
|
+
console.error(e);
|
|
607
|
+
});
|
package/client/names.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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 it next to game.ts).
|
|
5
|
+
*/
|
|
6
|
+
export const WEAPON_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
7
|
+
confetti_pop: { name: "Confetti Pop", line: "Burst around you" },
|
|
8
|
+
streamer: { name: "Streamer", line: "Seeks the nearest" },
|
|
9
|
+
spinning_plates: { name: "Spinning Plates", line: "Plates circle you" },
|
|
10
|
+
pie_toss: { name: "Pie Toss", line: "Lands on the crowd" },
|
|
11
|
+
limbo_bar: { name: "Limbo Bar", line: "Sweeps ahead" },
|
|
12
|
+
kazoo_chain: { name: "Kazoo Chain", line: "Jumps between foes" },
|
|
13
|
+
confetti_cannon: { name: "Confetti Cannon", line: "A wide, heavy burst" },
|
|
14
|
+
streamer_storm: { name: "Streamer Storm", line: "Six piercing streamers" },
|
|
15
|
+
plate_carousel: { name: "Plate Carousel", line: "Six wide plates" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const UPGRADE_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
19
|
+
dance_shoes: { name: "Dance Shoes", line: "Move speed" },
|
|
20
|
+
second_helping: { name: "Second Helping", line: "Max health" },
|
|
21
|
+
party_hat: { name: "Party Hat", line: "More projectiles" },
|
|
22
|
+
sugar_rush: { name: "Sugar Rush", line: "Fire rate" },
|
|
23
|
+
disco_ball: { name: "Disco Ball", line: "Bigger area" },
|
|
24
|
+
lucky_sock: { name: "Lucky Sock", line: "Damage" },
|
|
25
|
+
goodie_bag: { name: "Goodie Bag", line: "Star reach" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const ENEMY_NAMES: Readonly<Record<string, string>> = {
|
|
29
|
+
big_balloon: "The Big Balloon",
|
|
30
|
+
party_pup: "The Party Pup",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const RULE_NAMES: Readonly<Record<string, string>> = {
|
|
34
|
+
first_win: "First Win",
|
|
35
|
+
crowd_favourite: "Crowd Favourite",
|
|
36
|
+
full_house: "Full House",
|
|
37
|
+
confetti_storm: "Confetti Storm",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const SKU_NAMES: Readonly<Record<string, string>> = {
|
|
41
|
+
revive: "Revive",
|
|
42
|
+
skin_disco: "Disco palette",
|
|
43
|
+
lucky_charm: "Lucky charm",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The game's title (Net Forge reads it for the catalog). */
|
|
47
|
+
export const TITLE = "Romphouse";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colours the client draws with (not labels, so they live outside names.ts,
|
|
3
|
+
* which a Net Forge draft replaces with its own names.json).
|
|
4
|
+
*/
|
|
5
|
+
/** Party-goer colours by player slot, and the Disco palette (mirror silver, hot pink). */
|
|
6
|
+
export const KEEPER_COLORS = [0xffb347, 0x5ec8ff, 0x9dff7a, 0xff7ab8, 0xc9a7ff, 0xffe066, 0x7affd4, 0xff9a5c];
|
|
7
|
+
export const DISCO = { body: 0xd8e4f0, glow: 0xff5ab4 };
|