@netforge/template-racer 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/LICENSE +21 -0
- package/NOTICE +33 -0
- package/README.md +54 -2
- package/client/audio.ts +26 -0
- package/client/decor.ts +27 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +257 -0
- package/client/index.html +189 -0
- package/client/input.ts +105 -0
- package/client/main.ts +464 -0
- package/client/names.ts +42 -0
- package/client/scene.ts +646 -0
- package/client/stake.ts +143 -0
- package/llms.txt +125 -0
- package/package.json +53 -3
- package/src/game.ts +96 -0
- package/src/manifest.ts +44 -0
- package/src/store.ts +109 -0
package/client/main.ts
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TORQUEBAY client: join a room by link, predict your own kart (steering,
|
|
3
|
+
* throttle and drifts apply the instant you press), draw the other karts
|
|
4
|
+
* ~100 ms in the past, and dress it with the game-feel kit. The
|
|
5
|
+
* room's race runner decides places, coins and pay.
|
|
6
|
+
*
|
|
7
|
+
* URL parameters:
|
|
8
|
+
* ?room=<id> join a friend's instance (the Invite button makes this link)
|
|
9
|
+
* ?place=<name> the place (default "cliffs")
|
|
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 drives (headless measurement and screenshots); =newcomer drives loose
|
|
13
|
+
* ?preview=1 the Launcher's style previews
|
|
14
|
+
* ?quality=<tier> low, medium or high (default: detected)
|
|
15
|
+
* ?stats=1 show frame, network and server numbers (also the ` key)
|
|
16
|
+
* ?shapes=1 draw the fallback shapes instead of library models
|
|
17
|
+
* ?style=<style> draw the game in another art style (toon, ps1, pixel, voxel, handdrawn; default: the style game.ts declares)
|
|
18
|
+
* ?library=<url> asset library base URL (default: VITE_LIBRARY_URL, else ./library)
|
|
19
|
+
* ?host=netnet, ?coin=, ?title=, ?player= set by Net Forge (client/host.ts)
|
|
20
|
+
*/
|
|
21
|
+
import { initSim, KART_STATS } from "@netforge/engine-sim";
|
|
22
|
+
import type { InputCommand } from "@netforge/engine-sim";
|
|
23
|
+
import { KF, kartAutopilot } from "@netforge/engine/game";
|
|
24
|
+
import type { KartRoomWorld, RaceInfo } from "@netforge/engine/game";
|
|
25
|
+
import { guestId, joinRoom, savedSession } from "@netforge/engine/net";
|
|
26
|
+
import type { RoomConnection } from "@netforge/engine/net";
|
|
27
|
+
import { createRenderer, FeelAudio, FeelKit, FrameStats, pickStyle, SoundPlayer } from "@netforge/engine/runtime";
|
|
28
|
+
import { soundBank } from "@netforge/asset-library";
|
|
29
|
+
import { LibraryClient } from "@netforge/asset-library/three";
|
|
30
|
+
import { COSMETICS, GAME_VERSION, PLACE, racerAdapter, racerGame, TEMPLATE } from "../src/manifest.js";
|
|
31
|
+
import { COSMETIC_SKUS, mockKit, UPGRADE_SKUS } from "../src/store.js";
|
|
32
|
+
import type { KitStoreBridge, SkuPrice } from "../src/store.js";
|
|
33
|
+
import { defineKartCues, KART_SAMPLES } from "./audio.js";
|
|
34
|
+
import { hostFlags, namedPlayer, onHostPlayer, postReady, storeNote } from "./host.js";
|
|
35
|
+
import type { PlayerMode } from "./host.js";
|
|
36
|
+
import { Hud } from "./hud.js";
|
|
37
|
+
import { KartInput } from "./input.js";
|
|
38
|
+
import { SKU_NAMES, TITLE, UPGRADE_NAMES } from "./names.js";
|
|
39
|
+
import { KartScene, paintOf } from "./scene.js";
|
|
40
|
+
import { PIXEL_PLAYER } from "./decor.js";
|
|
41
|
+
import type { SceneEntity } from "./scene.js";
|
|
42
|
+
import { gearOf, wireGear, wirePot, wireStake } from "./stake.js";
|
|
43
|
+
|
|
44
|
+
export interface RacerStats {
|
|
45
|
+
backend: string;
|
|
46
|
+
fps: number;
|
|
47
|
+
frameMs: { mean: number; p50: number; p95: number; p99: number };
|
|
48
|
+
cpuMs: { mean: number; p95: number };
|
|
49
|
+
drawCalls: number;
|
|
50
|
+
triangles: number;
|
|
51
|
+
playersInView: number;
|
|
52
|
+
/** Climbers on screen (the Launcher's style previews score frames by it). */
|
|
53
|
+
busy: number;
|
|
54
|
+
downKBps: number;
|
|
55
|
+
upKBps: number;
|
|
56
|
+
rttMs: number;
|
|
57
|
+
joinMs: number;
|
|
58
|
+
firstFrameMs: number;
|
|
59
|
+
/** performance.now() of the first drawn frame: ms from navigation start (includes the download). */
|
|
60
|
+
firstFrameAt: number;
|
|
61
|
+
corrections: number;
|
|
62
|
+
inputBuffer: number | null;
|
|
63
|
+
frames: number;
|
|
64
|
+
roomId: string;
|
|
65
|
+
phase: string;
|
|
66
|
+
course: number;
|
|
67
|
+
place: number | null;
|
|
68
|
+
progress: number;
|
|
69
|
+
finishes: number;
|
|
70
|
+
pixelRatio: number;
|
|
71
|
+
feel: { coins: number; turbos: number; laps: number; walls: number };
|
|
72
|
+
/** Track chunks streamed in and out, and held now. */
|
|
73
|
+
streaming: { built: number; dropped: number; live: number };
|
|
74
|
+
sound: { cues: number; failed: number; music: boolean; played: number };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
declare global {
|
|
78
|
+
interface Window {
|
|
79
|
+
__racer?: RacerStats;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const params = new URLSearchParams(location.search);
|
|
84
|
+
const launcher = hostFlags(params);
|
|
85
|
+
if (launcher.framed) document.documentElement.dataset.host = "netnet";
|
|
86
|
+
let playerMode: PlayerMode = launcher.player;
|
|
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
|
+
/** A game played in Net Forge names its own coin (?coin=TICKER); the template's token keeps its logo. */
|
|
96
|
+
function withDraftCoin(kit: KitStoreBridge): KitStoreBridge {
|
|
97
|
+
const coin = launcher.coin;
|
|
98
|
+
if (!coin || coin === kit.coins.symbol()) return kit;
|
|
99
|
+
return { ...kit, coins: { ...kit.coins, name: () => coin, symbol: () => coin, logo: () => null } };
|
|
100
|
+
}
|
|
101
|
+
const GAME_TITLE = launcher.title ?? TITLE;
|
|
102
|
+
const bootAt = performance.now();
|
|
103
|
+
const env = (import.meta as unknown as { env: Record<string, string | undefined> }).env;
|
|
104
|
+
const endpoint = params.get("server") ?? env.VITE_ROOMS_URL ?? `${location.protocol}//${location.hostname}:2567`;
|
|
105
|
+
const autopilotMode = params.get("autopilot");
|
|
106
|
+
const autopilot = autopilotMode === "1" || autopilotMode === "newcomer";
|
|
107
|
+
const banner = document.getElementById("banner");
|
|
108
|
+
const bannerText = document.getElementById("banner-text");
|
|
109
|
+
const host = document.getElementById("game");
|
|
110
|
+
document.title = GAME_TITLE;
|
|
111
|
+
const bannerTitle = banner?.querySelector("b");
|
|
112
|
+
if (bannerTitle) bannerTitle.textContent = GAME_TITLE.toUpperCase();
|
|
113
|
+
|
|
114
|
+
async function main(): Promise<void> {
|
|
115
|
+
if (!host) throw new Error("missing #game");
|
|
116
|
+
await initSim();
|
|
117
|
+
const game = racerGame();
|
|
118
|
+
const canvas = document.createElement("canvas");
|
|
119
|
+
host.append(canvas);
|
|
120
|
+
const handle = await createRenderer(canvas, { forceWebGL: params.get("webgl") === "1", antialias: true });
|
|
121
|
+
|
|
122
|
+
const adapter = racerAdapter();
|
|
123
|
+
const friendRoomId = params.get("room") ?? undefined;
|
|
124
|
+
const place = params.get("place") ?? PLACE;
|
|
125
|
+
const joinStart = performance.now();
|
|
126
|
+
const session = savedSession(endpoint);
|
|
127
|
+
const conn: RoomConnection<KartRoomWorld, InputCommand> = await joinRoom(adapter, {
|
|
128
|
+
endpoint,
|
|
129
|
+
place,
|
|
130
|
+
template: TEMPLATE,
|
|
131
|
+
guest: guestId(),
|
|
132
|
+
...(session ? { session: session.token } : {}),
|
|
133
|
+
...(friendRoomId ? { friendRoomId } : {}),
|
|
134
|
+
});
|
|
135
|
+
const joinMs = performance.now() - joinStart;
|
|
136
|
+
const self = conn.welcome.player;
|
|
137
|
+
if (friendRoomId && conn.roomId !== friendRoomId) setTimeout(() => hud.toast("Room full, new room"), 500);
|
|
138
|
+
if (conn.welcome.version !== GAME_VERSION) setTimeout(() => hud.toast("Update ready. Reload."), 800);
|
|
139
|
+
const linkFor = (base: string) =>
|
|
140
|
+
`${base}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}`;
|
|
141
|
+
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") ?? "")}` : ""}${params.has("preview") ? "&preview=1" : ""}`);
|
|
142
|
+
|
|
143
|
+
const libraryBase = params.get("library") ?? env.VITE_LIBRARY_URL ?? "./library";
|
|
144
|
+
const artStyle = pickStyle(game.manifest.game.style, params.get("style"));
|
|
145
|
+
const library = params.get("shapes") === "1" ? null : new LibraryClient(handle, { baseUrl: libraryBase, style: artStyle });
|
|
146
|
+
const sfx = new FeelAudio();
|
|
147
|
+
defineKartCues(sfx);
|
|
148
|
+
const sound = new SoundPlayer(sfx, { key: "torquebay:sound", musicVolume: 0.28 });
|
|
149
|
+
sound.load(soundBank(libraryBase, { cues: KART_SAMPLES }));
|
|
150
|
+
const feel = new FeelKit({ preset: "default", audio: sfx });
|
|
151
|
+
const q = params.get("quality");
|
|
152
|
+
// The pixel style has its own kart sprite (the low-poly kart has no pixel twin).
|
|
153
|
+
const playerModel = artStyle === "pixel" ? PIXEL_PLAYER : game.manifest.game.players.prefab;
|
|
154
|
+
const scene = new KartScene(handle, game.racer, library, feel, playerModel, { style: artStyle, ...(q === "low" || q === "medium" || q === "high" ? { quality: q } : {}) });
|
|
155
|
+
feel.camera = scene.rig;
|
|
156
|
+
const input = new KartInput(document.body);
|
|
157
|
+
const prices: Record<string, SkuPrice> = Object.fromEntries(game.manifest.skus);
|
|
158
|
+
// The Kit plugs in here (packages/kit, kit.init({ game })): kit.coins and kit.store have these exact names.
|
|
159
|
+
const kit: KitStoreBridge = withDraftCoin(mockKit({ earned: 120, wallet: 0, usdg: 5 }, prices));
|
|
160
|
+
let storeOpen = false;
|
|
161
|
+
let run: (RaceInfo & { level?: string | null }) | null = null;
|
|
162
|
+
conn.onRun((info) => {
|
|
163
|
+
run = info as RaceInfo & { level?: string | null };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const hud = new Hud(game, self, {
|
|
167
|
+
invite: () => {
|
|
168
|
+
const url = linkFor(`${location.origin}${location.pathname}`);
|
|
169
|
+
void navigator.clipboard?.writeText(url).then(
|
|
170
|
+
() => hud.toast("Invite link copied"),
|
|
171
|
+
() => hud.toast(url),
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
toggleStore: () => {
|
|
175
|
+
storeOpen = !storeOpen;
|
|
176
|
+
document.getElementById("store")?.classList.toggle("show", storeOpen);
|
|
177
|
+
},
|
|
178
|
+
toggleSound: () => {
|
|
179
|
+
sfx.unlock();
|
|
180
|
+
return sound.toggle();
|
|
181
|
+
},
|
|
182
|
+
ready: () => input.ready(),
|
|
183
|
+
});
|
|
184
|
+
conn.onEarn((e) => hud.earned(e.rule, e.units));
|
|
185
|
+
const soundBtn = document.getElementById("sound");
|
|
186
|
+
if (soundBtn && sound.muted) soundBtn.textContent = "Sound off";
|
|
187
|
+
hud.coin(kit.coins.name(), kit.coins.symbol(), kit.coins.logo());
|
|
188
|
+
wireStake(conn, kit, (t) => hud.toast(t));
|
|
189
|
+
wirePot(conn, kit, (t) => hud.toast(t));
|
|
190
|
+
wireGear(gearOf(game.manifest.skus, SKU_NAMES), kit, (t) => hud.toast(t));
|
|
191
|
+
hud.title(GAME_TITLE);
|
|
192
|
+
hud.player(playerMode);
|
|
193
|
+
const note = document.getElementById("store-note");
|
|
194
|
+
const showNote = () => {
|
|
195
|
+
if (note) note.textContent = storeNote(hostMode, conn.welcome.earnLive, kit.coins.symbol());
|
|
196
|
+
};
|
|
197
|
+
showNote();
|
|
198
|
+
onPlayer = (mode) => {
|
|
199
|
+
hud.player(mode);
|
|
200
|
+
showNote();
|
|
201
|
+
};
|
|
202
|
+
postReady(launcher);
|
|
203
|
+
|
|
204
|
+
// The store: round upgrades (spent, applied by the room) and outfits (owned for good).
|
|
205
|
+
const sym = kit.coins.symbol();
|
|
206
|
+
const upgradesEl = document.getElementById("store-upgrades");
|
|
207
|
+
for (const id of UPGRADE_SKUS) {
|
|
208
|
+
const price = prices[id]?.coins;
|
|
209
|
+
if (price === undefined || !upgradesEl) continue;
|
|
210
|
+
const row = document.createElement("div");
|
|
211
|
+
row.className = "item";
|
|
212
|
+
row.innerHTML = `<div><div><span class="name"></span><br /><small></small></div></div><button></button>`;
|
|
213
|
+
(row.querySelector(".name") as HTMLElement).textContent = SKU_NAMES[id] ?? id;
|
|
214
|
+
(row.querySelector("small") as HTMLElement).textContent = UPGRADE_NAMES[id]?.line ?? "";
|
|
215
|
+
const btn = row.querySelector("button") as HTMLButtonElement;
|
|
216
|
+
btn.textContent = `${price} ${sym}`;
|
|
217
|
+
btn.addEventListener("click", () => void buyUpgrade(id, price));
|
|
218
|
+
upgradesEl.append(row);
|
|
219
|
+
}
|
|
220
|
+
const finishesEl = document.getElementById("store-finishes");
|
|
221
|
+
for (const id of COSMETIC_SKUS) {
|
|
222
|
+
const price = prices[id]?.coins;
|
|
223
|
+
const cosmetic = COSMETICS[id];
|
|
224
|
+
if (price === undefined || cosmetic === undefined || !finishesEl) continue;
|
|
225
|
+
const look = paintOf(cosmetic, 0xff8a3d);
|
|
226
|
+
const row = document.createElement("div");
|
|
227
|
+
row.className = "item";
|
|
228
|
+
row.innerHTML = `<div><span class="swatch"></span><div><span class="name"></span><br /><small>Paint</small></div></div><button></button>`;
|
|
229
|
+
(row.querySelector(".swatch") as HTMLElement).style.background = `linear-gradient(135deg, #${look.body.toString(16).padStart(6, "0")}, #${look.trim.toString(16).padStart(6, "0")})`;
|
|
230
|
+
(row.querySelector(".name") as HTMLElement).textContent = SKU_NAMES[id] ?? id;
|
|
231
|
+
const btn = row.querySelector("button") as HTMLButtonElement;
|
|
232
|
+
btn.textContent = `${price} ${sym}`;
|
|
233
|
+
btn.addEventListener("click", () => void buyPaint(id, cosmetic, btn));
|
|
234
|
+
finishesEl.append(row);
|
|
235
|
+
}
|
|
236
|
+
async function buyUpgrade(id: string, price: number): Promise<void> {
|
|
237
|
+
sfx.unlock();
|
|
238
|
+
let proof: string | null;
|
|
239
|
+
try {
|
|
240
|
+
proof = (await kit.coins.spend(price, id)).key;
|
|
241
|
+
} catch {
|
|
242
|
+
hud.toast(`Not enough ${sym}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const r = await conn.command({ name: id, ...(proof ? { proof } : {}) });
|
|
246
|
+
hud.toast(r.ok ? `${SKU_NAMES[id] ?? id} on` : r.reason === "store not live" ? "Store opens at launch" : "Already on this round");
|
|
247
|
+
}
|
|
248
|
+
async function buyPaint(id: string, cosmetic: number, btn: HTMLButtonElement): Promise<void> {
|
|
249
|
+
sfx.unlock();
|
|
250
|
+
if (!kit.store.owns(id)) {
|
|
251
|
+
try {
|
|
252
|
+
await kit.store.buy(id, { pay: "coins" });
|
|
253
|
+
} catch {
|
|
254
|
+
hud.toast(`Not enough ${sym}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const r = await conn.command({ name: "cosmetic", value: cosmetic });
|
|
259
|
+
if (!r.ok) {
|
|
260
|
+
hud.toast("Paint opens at launch");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
for (const b of finishesEl?.querySelectorAll("button") ?? []) if (b !== btn && b.textContent === "Painted") b.textContent = "Paint";
|
|
264
|
+
btn.textContent = "Painted";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let rtt = 0;
|
|
268
|
+
conn.room.onMessage("pong", (t: number) => {
|
|
269
|
+
rtt = performance.now() - t;
|
|
270
|
+
});
|
|
271
|
+
setInterval(() => conn.room.send("ping", performance.now()), 2000);
|
|
272
|
+
|
|
273
|
+
// Autopilot (measurement and previews): the seat bot's driving on our newest predicted kart.
|
|
274
|
+
const pilot = kartAutopilot(game.racer, game.tickRate);
|
|
275
|
+
let lastView: { self: SceneEntity | undefined; others: readonly SceneEntity[] } = { self: undefined, others: [] };
|
|
276
|
+
const autopilotInput = (tick: number): InputCommand => pilot(conn.net.predicted() ?? lastView.self, tick, autopilotMode === "newcomer" ? 0.4 : 0.95);
|
|
277
|
+
|
|
278
|
+
let nextTick = performance.now();
|
|
279
|
+
let ticks = 0;
|
|
280
|
+
setInterval(() => {
|
|
281
|
+
const now = performance.now();
|
|
282
|
+
let n = 0;
|
|
283
|
+
while (nextTick <= now && n < 8) {
|
|
284
|
+
let cmd = autopilot ? autopilotInput(ticks) : input.sample(ticks);
|
|
285
|
+
// Autopilot also votes for the next course on the results screen.
|
|
286
|
+
if (autopilot && (run as RaceInfo | null)?.phase === "results") cmd = { ...cmd, buttons: cmd.buttons | (1 << 10) };
|
|
287
|
+
conn.net.tick(cmd, now);
|
|
288
|
+
ticks++;
|
|
289
|
+
nextTick += conn.net.tickIntervalMs();
|
|
290
|
+
n++;
|
|
291
|
+
}
|
|
292
|
+
if (n === 8) nextTick = now + conn.net.tickIntervalMs();
|
|
293
|
+
}, 4);
|
|
294
|
+
// Rumble (navigator.vibrate) needs a user gesture first: until then the feel kit's land and dash stay silent on the pad.
|
|
295
|
+
let gestured = false;
|
|
296
|
+
const gesture = () => {
|
|
297
|
+
gestured = true;
|
|
298
|
+
sfx.unlock();
|
|
299
|
+
};
|
|
300
|
+
addEventListener("pointerdown", gesture, { once: true });
|
|
301
|
+
addEventListener("keydown", gesture, { once: true });
|
|
302
|
+
addEventListener("keydown", (e) => {
|
|
303
|
+
if (e.code === "Backquote") document.getElementById("stats")?.classList.toggle("show");
|
|
304
|
+
});
|
|
305
|
+
if (params.get("stats") === "1") document.getElementById("stats")?.classList.add("show");
|
|
306
|
+
|
|
307
|
+
const resize = () => scene.resize(Math.max(1, host.clientWidth), Math.max(1, host.clientHeight));
|
|
308
|
+
resize();
|
|
309
|
+
new ResizeObserver(resize).observe(host);
|
|
310
|
+
|
|
311
|
+
const frame = new FrameStats(600);
|
|
312
|
+
const cpu = new FrameStats(600);
|
|
313
|
+
let last = 0;
|
|
314
|
+
let frames = 0;
|
|
315
|
+
let firstFrameMs = 0;
|
|
316
|
+
let firstFrameAt = 0;
|
|
317
|
+
let lastBytes = 0;
|
|
318
|
+
let lastUp = 0;
|
|
319
|
+
let lastNet = performance.now();
|
|
320
|
+
let downKBps = 0;
|
|
321
|
+
let upKBps = 0;
|
|
322
|
+
let lastStats = 0;
|
|
323
|
+
let pixelRatio = handle.renderer.getPixelRatio();
|
|
324
|
+
let resChanged = 0;
|
|
325
|
+
let lastCount = -1;
|
|
326
|
+
let finishes = 0;
|
|
327
|
+
const flash = document.getElementById("flash");
|
|
328
|
+
let flashT = 0;
|
|
329
|
+
|
|
330
|
+
handle.renderer.setAnimationLoop((t: number) => {
|
|
331
|
+
const real = performance.now();
|
|
332
|
+
if (last !== 0) frame.push(t - last);
|
|
333
|
+
last = t;
|
|
334
|
+
const { now, dt, realDt } = feel.tick(real);
|
|
335
|
+
const view = conn.net.render(now);
|
|
336
|
+
lastView = view;
|
|
337
|
+
const r = run ?? (conn.latestRun() as (RaceInfo & { level?: string | null }) | null);
|
|
338
|
+
const ev = scene.frame({ dt, realDt, self: view.self, others: view.others, course: r?.course ?? 0 });
|
|
339
|
+
if (frames === 0) {
|
|
340
|
+
firstFrameMs = real - bootAt;
|
|
341
|
+
firstFrameAt = real;
|
|
342
|
+
banner?.remove();
|
|
343
|
+
}
|
|
344
|
+
frames++;
|
|
345
|
+
// Game feel.
|
|
346
|
+
if (ev.coins > 0) sfx.play("coin");
|
|
347
|
+
if (ev.driftStart) sfx.play("drift");
|
|
348
|
+
if (ev.boostStart) {
|
|
349
|
+
sfx.play("boost");
|
|
350
|
+
scene.rig.punch(6);
|
|
351
|
+
input.rumble(0.5, 120);
|
|
352
|
+
if (gestured) feel.dash();
|
|
353
|
+
}
|
|
354
|
+
if (ev.lap > 0) {
|
|
355
|
+
sfx.play("lap");
|
|
356
|
+
hud.toast(`Lap ${ev.lap + 1}`);
|
|
357
|
+
}
|
|
358
|
+
if (ev.wall) {
|
|
359
|
+
sfx.play("bump");
|
|
360
|
+
scene.rig.addTrauma(0.35);
|
|
361
|
+
input.rumble(0.7, 120);
|
|
362
|
+
flashT = 0.25;
|
|
363
|
+
}
|
|
364
|
+
if (ev.finished) {
|
|
365
|
+
finishes++;
|
|
366
|
+
sfx.play("finish");
|
|
367
|
+
scene.rig.punch(-3);
|
|
368
|
+
}
|
|
369
|
+
if (r?.phase === "countdown" && r.countdown !== lastCount) {
|
|
370
|
+
lastCount = r.countdown;
|
|
371
|
+
sfx.play(r.countdown > 0 ? "tick" : "go");
|
|
372
|
+
} else if (r?.phase === "racing" && lastCount > 0) {
|
|
373
|
+
lastCount = 0;
|
|
374
|
+
sfx.play("go");
|
|
375
|
+
}
|
|
376
|
+
flashT = Math.max(0, flashT - realDt);
|
|
377
|
+
if (flash) flash.style.opacity = String(flashT * 2);
|
|
378
|
+
const me = view.self;
|
|
379
|
+
// This race's upgrades (store or gear), from our kart's own stat levels.
|
|
380
|
+
const statWord = me ? (me.values[KF.stats] ?? 0) : 0;
|
|
381
|
+
const buffs = [...game.manifest.upgrades.keys()]
|
|
382
|
+
.filter((id, i) => {
|
|
383
|
+
const stat = game.racer.upgradeStat[i];
|
|
384
|
+
return stat !== undefined && ((statWord >>> (KART_STATS.indexOf(stat) * 4)) & 0xf) > 0;
|
|
385
|
+
})
|
|
386
|
+
.map((id) => UPGRADE_NAMES[id]?.name ?? id)
|
|
387
|
+
.join(" · ");
|
|
388
|
+
hud.update(real, view.self, view.others, r, kit.coins.balance(), buffs, scene.trackNow);
|
|
389
|
+
feel.update(dt, realDt);
|
|
390
|
+
cpu.push(performance.now() - real);
|
|
391
|
+
|
|
392
|
+
// Dynamic resolution: hold 60 fps by trading pixels (spec §5 quality tiers).
|
|
393
|
+
if (frame.count >= 120 && real - resChanged > 2000) {
|
|
394
|
+
const p95 = frame.percentile(0.95);
|
|
395
|
+
const cap = Math.min(globalThis.devicePixelRatio || 1, 2);
|
|
396
|
+
let next = pixelRatio;
|
|
397
|
+
if (p95 > 21 && pixelRatio > 0.75) next = Math.max(0.75, pixelRatio - 0.25);
|
|
398
|
+
else if (p95 < 17.5 && pixelRatio < cap) next = Math.min(cap, pixelRatio + 0.25);
|
|
399
|
+
if (next !== pixelRatio) {
|
|
400
|
+
pixelRatio = next;
|
|
401
|
+
handle.renderer.setPixelRatio(next);
|
|
402
|
+
resize();
|
|
403
|
+
resChanged = real;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (real - lastNet >= 1000) {
|
|
407
|
+
const secs = (real - lastNet) / 1000;
|
|
408
|
+
downKBps = (conn.bytesIn() - lastBytes) / secs / 1024;
|
|
409
|
+
upKBps = (conn.net.stats.inputBytes - lastUp) / secs / 1024;
|
|
410
|
+
lastBytes = conn.bytesIn();
|
|
411
|
+
lastUp = conn.net.stats.inputBytes;
|
|
412
|
+
lastNet = real;
|
|
413
|
+
}
|
|
414
|
+
const info = handle.renderer.info.render;
|
|
415
|
+
const mean = frame.mean();
|
|
416
|
+
const mine = r?.players.find((p) => p.id === self);
|
|
417
|
+
const stats: RacerStats = {
|
|
418
|
+
backend: handle.backend,
|
|
419
|
+
fps: mean > 0 ? 1000 / mean : 0,
|
|
420
|
+
frameMs: { mean, p50: frame.percentile(0.5), p95: frame.percentile(0.95), p99: frame.percentile(0.99) },
|
|
421
|
+
cpuMs: { mean: cpu.mean(), p95: cpu.percentile(0.95) },
|
|
422
|
+
drawCalls: info.drawCalls,
|
|
423
|
+
triangles: info.triangles,
|
|
424
|
+
playersInView: view.others.length + (view.self ? 1 : 0),
|
|
425
|
+
busy: view.others.length + (view.self ? 1 : 0),
|
|
426
|
+
downKBps,
|
|
427
|
+
upKBps,
|
|
428
|
+
rttMs: rtt,
|
|
429
|
+
joinMs,
|
|
430
|
+
firstFrameMs,
|
|
431
|
+
firstFrameAt,
|
|
432
|
+
corrections: conn.net.corrections,
|
|
433
|
+
inputBuffer: conn.net.serverInputBuffer,
|
|
434
|
+
frames,
|
|
435
|
+
roomId: conn.roomId,
|
|
436
|
+
phase: r?.phase ?? "",
|
|
437
|
+
course: r?.course ?? 0,
|
|
438
|
+
place: mine?.place ?? null,
|
|
439
|
+
progress: mine?.progress ?? 0,
|
|
440
|
+
finishes,
|
|
441
|
+
pixelRatio,
|
|
442
|
+
feel: { ...scene.counts },
|
|
443
|
+
streaming: { ...scene.streaming },
|
|
444
|
+
sound: { ...sound.loaded, played: sfx.played },
|
|
445
|
+
};
|
|
446
|
+
window.__racer = stats;
|
|
447
|
+
if (real - lastStats > 250) {
|
|
448
|
+
lastStats = real;
|
|
449
|
+
hud.stats(
|
|
450
|
+
[
|
|
451
|
+
`${stats.backend.toUpperCase()} ${stats.fps.toFixed(0)} fps p95 ${stats.frameMs.p95.toFixed(1)} ms dpr ${pixelRatio}`,
|
|
452
|
+
`draws ${stats.drawCalls} tris ${(stats.triangles / 1000).toFixed(0)}k karts ${stats.playersInView} chunks ${scene.streaming.live}`,
|
|
453
|
+
`down ${downKBps.toFixed(1)} KB/s up ${upKBps.toFixed(2)} KB/s rtt ${rtt.toFixed(0)} ms queue ${conn.net.serverInputBuffer?.toFixed(1) ?? "-"}`,
|
|
454
|
+
`join ${joinMs.toFixed(0)} ms first frame ${firstFrameMs.toFixed(0)} ms corrections ${conn.net.corrections}`,
|
|
455
|
+
].join("\n"),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
main().catch((e: unknown) => {
|
|
462
|
+
if (bannerText) bannerText.textContent = `Could not start: ${e instanceof Error ? e.message : String(e)}`;
|
|
463
|
+
console.error(e);
|
|
464
|
+
});
|
package/client/names.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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 = "Torquebay";
|
|
8
|
+
|
|
9
|
+
// The racer has no weapons; the table stays for the generator's names.json shape.
|
|
10
|
+
export const WEAPON_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const UPGRADE_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
14
|
+
turbo: { name: "Turbo", line: "Boosts last 25% longer" },
|
|
15
|
+
tuned_engine: { name: "Tuned engine", line: "5% more top speed" },
|
|
16
|
+
soft_tyres: { name: "Soft tyres", line: "Drifts charge faster" },
|
|
17
|
+
bumpers: { name: "Bumpers", line: "Lose less on grass and walls" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// The harbour has no creatures; a game that adds named things labels them here.
|
|
21
|
+
export const ENEMY_NAMES: Readonly<Record<string, string>> = {
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const RULE_NAMES: Readonly<Record<string, string>> = {
|
|
25
|
+
hot_lap: "Hot Lap",
|
|
26
|
+
chequered: "Chequered",
|
|
27
|
+
clean_line: "Clean Line",
|
|
28
|
+
pole_to_flag: "Pole to Flag",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const SKU_NAMES: Readonly<Record<string, string>> = {
|
|
32
|
+
turbo: "Turbo",
|
|
33
|
+
tuned_engine: "Tuned engine",
|
|
34
|
+
soft_tyres: "Soft tyres",
|
|
35
|
+
skin_flame: "Flame paint",
|
|
36
|
+
skin_chrome: "Chrome paint",
|
|
37
|
+
bumper_kit: "Bumper kit",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Kart colours by slot (the generated names module carries these too). */
|
|
41
|
+
export const KEEPER_COLORS = [0xffb347, 0x5ec8ff, 0x9dff7a, 0xff7ab8, 0xc9a7ff, 0xffe066, 0x7affd4, 0xff9a5c];
|
|
42
|
+
export const AURORA = { body: 0xff5a1f, glow: 0xffd23f };
|