@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
@netforge/template-racer (TORQUEBAY)
|
|
2
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
3
|
+
|
|
4
|
+
An original game. No names, characters, maps, models, sounds or code from
|
|
5
|
+
any other game or franchise.
|
|
6
|
+
|
|
7
|
+
Art and sound
|
|
8
|
+
- Models come from the per-style asset library
|
|
9
|
+
(@netforge/asset-library): CC0 1.0 models by Kenney (Car Kit: the
|
|
10
|
+
karts and trucks; Nature Kit: trees; City Kit Commercial: buildings;
|
|
11
|
+
Space Kit: barrels) and Quaternius
|
|
12
|
+
(via Poly Pizza: the coin), each pack listed with its
|
|
13
|
+
source URL and licence verification in that package's NOTICE and
|
|
14
|
+
manifest/packs.json. The files are fetched and built, never committed
|
|
15
|
+
here; the build copies only the models this game uses.
|
|
16
|
+
- The road, kerbs, barriers, boost pads, the start line and the fallback
|
|
17
|
+
kart (client/scene.ts) are generated from three.js primitives; all textures
|
|
18
|
+
are drawn on a canvas at load. Sounds are the library's CC0 samples with
|
|
19
|
+
WebAudio-synthesized fallbacks (client/audio.ts).
|
|
20
|
+
|
|
21
|
+
Dependencies (unmodified, via npm)
|
|
22
|
+
- three.js 0.186.1 (https://github.com/mrdoob/three.js), MIT.
|
|
23
|
+
- @netforge/engine and @netforge/engine-sim (this repository);
|
|
24
|
+
see their NOTICE files (bitECS MPL-2.0, Rapier Apache-2.0, Colyseus MIT,
|
|
25
|
+
zod MIT).
|
|
26
|
+
|
|
27
|
+
Patterns (ideas only)
|
|
28
|
+
- The kart racing genre's general mechanics (laps, drifting to charge a
|
|
29
|
+
boost, boost pads, coins, a grid of rivals). Mechanics, not expression;
|
|
30
|
+
no track, vehicle, character or text was copied from any game.
|
|
31
|
+
- Arcade vehicle handling (forward and sideways velocity, grip, drift) as
|
|
32
|
+
described in common public write-ups on arcade car physics. Implemented
|
|
33
|
+
from scratch in engine-sim src/kart.ts.
|
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# TORQUEBAY (racer template)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Up to eight karts race laps round harbour circuits: hold the throttle, drift
|
|
4
|
+
through the bends to charge a mini-turbo (release it for the boost), hit the
|
|
5
|
+
boost pads, collect coins (each one held adds 1% top speed, up to ten) and
|
|
6
|
+
keep off the grass. A round is one race; the next round runs another
|
|
7
|
+
circuit. Seat bots fill the grid, so one person alone still has a field.
|
|
8
|
+
|
|
9
|
+
| Path | What |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `src/game.ts` | The game, as the model writes it (declarations only): three circuits, round upgrades, earn rules, stake, store. |
|
|
12
|
+
| `src/manifest.ts` | Loads and compiles it; `racerAdapter()` for the room server and the client. Room template `racer`. |
|
|
13
|
+
| `src/store.ts` | Store hooks on the Kit's API names: three round upgrades (40, 40, 60 coins), two paint jobs (300, 500), bumper kit gear (120); mock until the token is live. |
|
|
14
|
+
| `client/` | The browser client: chase camera, a streamed track, HUD with minimap, keyboard/gamepad/touch, game feel. |
|
|
15
|
+
| `test/` | Playtest bots through `engine.test.run`, the race rules, prediction, the store. |
|
|
16
|
+
| `llms.txt` | How to change the game (for agents). |
|
|
17
|
+
|
|
18
|
+
## How it plays on the server
|
|
19
|
+
|
|
20
|
+
- The sim is `@netforge/engine-sim`'s kart mode (`src/kart.ts`): arcade
|
|
21
|
+
vehicle physics on a 1/1024 grid at 60 Hz. Velocity splits into forward
|
|
22
|
+
and sideways parts each tick: the engine pushes the forward part toward
|
|
23
|
+
top speed, grip bleeds the sideways part (much less while drifting), and
|
|
24
|
+
steering turns with speed. A held drift charges a mini-turbo in three
|
|
25
|
+
tiers. Laps count through four quarter checkpoints in order. A client
|
|
26
|
+
predicts its own kart alone; kart bumps run only on the room server and
|
|
27
|
+
the next snapshot corrects them.
|
|
28
|
+
- The track streams: the client builds the road,
|
|
29
|
+
kerbs, barriers and scenery in chunks of about 40 m within 170 m of the
|
|
30
|
+
camera, two chunks a frame, nearest first, and drops chunks behind.
|
|
31
|
+
- The race runner (`@netforge/engine/game` `RaceRunner`, shared with
|
|
32
|
+
SKYLEDGE) owns rounds: countdown, race, results. `player.finished` pays
|
|
33
|
+
your own finish; `race.won` and `race.podium` pay only with another
|
|
34
|
+
person in the round. Seat bots never earn.
|
|
35
|
+
- Seat bots drive the racing line, pass the kart ahead, hold drifts through
|
|
36
|
+
long bends, and are shaded by skill (top speed and line).
|
|
37
|
+
|
|
38
|
+
## Run it
|
|
39
|
+
|
|
40
|
+
This template runs on your own computer with play money through
|
|
41
|
+
`kit play` (`@netforge/kit-play`). From the repository root:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pnpm install --frozen-lockfile && pnpm build
|
|
45
|
+
mkdir -p play-racer/src && cp packages/template-racer/src/game.ts play-racer/src/
|
|
46
|
+
echo '{"template":"racer"}' > play-racer/netnet.json
|
|
47
|
+
node packages/kit-play/dist/cli.js play-racer # opens http://127.0.0.1:4663
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Edit `play-racer/src/game.ts` and run it again to try a change. Real-token
|
|
51
|
+
earning, stakes and claims run only on the hosted Net Forge server.
|
|
52
|
+
|
|
53
|
+
Keys: W or Up accelerate, S or Down brake, A D or arrows steer, hold Space or
|
|
54
|
+
Shift through a bend to drift (release for the boost), R or Enter readies
|
|
55
|
+
the next race. ` shows stats.
|
package/client/audio.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TORQUEBAY's sound cues on the engine's FeelAudio. Coins, boosts and bumps
|
|
3
|
+
* play the asset library's CC0 samples (KART_SAMPLES); the drift screech and
|
|
4
|
+
* the countdown stay synthesized. Every cue keeps a synth fallback.
|
|
5
|
+
*/
|
|
6
|
+
import type { FeelAudio } from "@netforge/engine/runtime";
|
|
7
|
+
|
|
8
|
+
/** Cue name -> library sound id (soundBank's `cues` overrides). */
|
|
9
|
+
export const KART_SAMPLES: Readonly<Record<string, string>> = {
|
|
10
|
+
coin: "audio.sfx.coin",
|
|
11
|
+
boost: "audio.sfx.dash",
|
|
12
|
+
bump: "audio.sfx.impact",
|
|
13
|
+
lap: "audio.sfx.confirm",
|
|
14
|
+
finish: "audio.sfx.level_up",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function defineKartCues(a: FeelAudio): void {
|
|
18
|
+
a.define("coin", { wave: "triangle", f0: 1320, f1: 1980, ms: 90, gain: 0.45 }, { minGapMs: 30, voices: 3, pitchVar: 0.06 });
|
|
19
|
+
a.define("drift", { wave: "sawtooth", f0: 520, f1: 470, ms: 260, gain: 0.25 }, { minGapMs: 200, voices: 1, pitchVar: 0.05 });
|
|
20
|
+
a.define("boost", { wave: "sawtooth", f0: 180, f1: 900, ms: 320, gain: 0.5 }, { minGapMs: 150, voices: 1 });
|
|
21
|
+
a.define("bump", { wave: "square", f0: 140, f1: 50, ms: 140, gain: 0.55 }, { minGapMs: 120, voices: 2 });
|
|
22
|
+
a.define("lap", { wave: "triangle", f0: 660, f1: 990, ms: 180, gain: 0.6 }, { minGapMs: 300, voices: 1, pitchVar: 0 });
|
|
23
|
+
a.define("finish", { wave: "triangle", f0: 523, f1: 1046, ms: 420, gain: 0.7 }, { minGapMs: 400, voices: 1, pitchVar: 0 });
|
|
24
|
+
a.define("tick", { wave: "square", f0: 880, f1: 880, ms: 70, gain: 0.35 }, { minGapMs: 200, voices: 1, pitchVar: 0 });
|
|
25
|
+
a.define("go", { wave: "square", f0: 1320, f1: 1320, ms: 200, gain: 0.4 }, { minGapMs: 200, voices: 1, pitchVar: 0 });
|
|
26
|
+
}
|
package/client/decor.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library models the TORQUEBAY scene draws on its own (template machinery,
|
|
3
|
+
* not game content). The build copies these with the models game.ts uses,
|
|
4
|
+
* in every art style.
|
|
5
|
+
*/
|
|
6
|
+
/** Seat bots drive the other karts of the set. */
|
|
7
|
+
export const BOT_MODELS: readonly string[] = ["style.lowpoly.kart_b", "style.lowpoly.kart_c", "style.lowpoly.kart_d", "style.lowpoly.kart_e"];
|
|
8
|
+
export const COIN_MODEL = "style.lowpoly.coin";
|
|
9
|
+
/** Karts are drawn this tall, m. */
|
|
10
|
+
export const KART_HEIGHT = 1.1;
|
|
11
|
+
/** The kart models face -z; the scene drives along +x. */
|
|
12
|
+
export const MODEL_YAW = -Math.PI / 2;
|
|
13
|
+
|
|
14
|
+
/** Scenery past the barriers: [library id, height m, every n samples (~2 m each)]. */
|
|
15
|
+
export const SCENERY: readonly (readonly [string, number, number])[] = [
|
|
16
|
+
["style.lowpoly.tree_palm", 6, 7],
|
|
17
|
+
["style.lowpoly.tree_oak", 5, 9],
|
|
18
|
+
["style.lowpoly.building_c", 12, 22],
|
|
19
|
+
["style.lowpoly.barrels", 1.3, 11],
|
|
20
|
+
["style.lowpoly.truck_flat", 2.6, 19],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** The pixel style's own karts (the low-poly karts have no pixel twins). */
|
|
24
|
+
export const PIXEL_PLAYER = "style.pixel.kart";
|
|
25
|
+
export const PIXEL_BOTS: readonly string[] = ["style.pixel.buggy", "style.pixel.sports_car", "style.pixel.car", "style.pixel.taxi"];
|
|
26
|
+
|
|
27
|
+
export const SCENE_MODELS: readonly string[] = [...BOT_MODELS, COIN_MODEL, ...SCENERY.map(([id]) => id), PIXEL_PLAYER, ...PIXEL_BOTS];
|
package/client/host.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Net Forge as host. Framed by Net Forge, the page URL carries
|
|
3
|
+
* the Kit flags and the host posts one message; played on its own, none of
|
|
4
|
+
* this applies and the game behaves exactly as before.
|
|
5
|
+
*
|
|
6
|
+
* ?host=netnet the host draws Invite and sign-in; the game hides its own
|
|
7
|
+
* ?coin=TICKER the coin the HUD names (2 to 6 capitals or digits)
|
|
8
|
+
* ?title=NAME the game's own name for the wordmark and the tab title
|
|
9
|
+
* ?player=guest no wallet is connected: play pays nothing, and the HUD
|
|
10
|
+
* says "not earning" instead of a balance
|
|
11
|
+
* ?player=play the token has not launched: coins are play money
|
|
12
|
+
*
|
|
13
|
+
* The player flag changes while the game runs (a wallet connects), so the
|
|
14
|
+
* host also posts { kind: "netnet-kit", type: "player", mode }. Only the
|
|
15
|
+
* parent window is listened to, and only when the URL says it is the host.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type PlayerMode = "earning" | "guest" | "play";
|
|
19
|
+
|
|
20
|
+
export interface HostFlags {
|
|
21
|
+
/** Framed by Net Forge. */
|
|
22
|
+
readonly framed: boolean;
|
|
23
|
+
/** The coin's ticker, when the host names one. */
|
|
24
|
+
readonly coin: string | null;
|
|
25
|
+
/** The game's own name, when the host names one. */
|
|
26
|
+
readonly title: string | null;
|
|
27
|
+
/** What play pays the person at the screen. Standalone play is "earning" (the game's own kit decides). */
|
|
28
|
+
readonly player: PlayerMode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TICKER_RE = /^[A-Z0-9]{2,6}$/;
|
|
32
|
+
|
|
33
|
+
export function hostFlags(params: URLSearchParams): HostFlags {
|
|
34
|
+
const framed = params.get("host") === "netnet";
|
|
35
|
+
const coin = params.get("coin");
|
|
36
|
+
// eslint-disable-next-line no-control-regex
|
|
37
|
+
const title = (params.get("title") ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
38
|
+
const player = params.get("player");
|
|
39
|
+
return {
|
|
40
|
+
framed,
|
|
41
|
+
coin: coin && TICKER_RE.test(coin) ? coin : null,
|
|
42
|
+
title: title || null,
|
|
43
|
+
player: framed && (player === "guest" || player === "play") ? player : "earning",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The player mode the host named on the URL, or null when it named none (a
|
|
49
|
+
* connected player on a live game gets no flag, only the posted message) or
|
|
50
|
+
* the page is not framed by Net Forge.
|
|
51
|
+
*/
|
|
52
|
+
export function namedPlayer(params: URLSearchParams): PlayerMode | null {
|
|
53
|
+
if (params.get("host") !== "netnet") return null;
|
|
54
|
+
const player = params.get("player");
|
|
55
|
+
return player === "earning" || player === "guest" || player === "play" ? player : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The store's note under its prices, from one source of truth. Once the host
|
|
60
|
+
* has named the player mode (on the URL or in a message), that mode decides,
|
|
61
|
+
* exactly as it does for the coin box: a live game never calls its coins play
|
|
62
|
+
* money. Standalone play, or a host that names no mode (the studio's drafts),
|
|
63
|
+
* keeps the room's own word (`welcome.earnLive`).
|
|
64
|
+
*/
|
|
65
|
+
export function storeNote(hostMode: PlayerMode | null, earnLive: boolean, symbol: string): string {
|
|
66
|
+
if (hostMode === "play") return "Play money until launch";
|
|
67
|
+
if (hostMode === "guest") return `Connect a wallet to earn ${symbol}`;
|
|
68
|
+
if (hostMode === "earning") return "";
|
|
69
|
+
return earnLive ? "" : "Play money until launch";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A host message, checked: anything else is ignored. */
|
|
73
|
+
export function playerFrom(data: unknown): PlayerMode | null {
|
|
74
|
+
if (typeof data !== "object" || data === null) return null;
|
|
75
|
+
const m = data as Record<string, unknown>;
|
|
76
|
+
if (m["kind"] !== "netnet-kit" || m["type"] !== "player") return null;
|
|
77
|
+
const mode = m["mode"];
|
|
78
|
+
return mode === "earning" || mode === "guest" || mode === "play" ? mode : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Calls `cb` with each player mode the host posts. Framed pages only. */
|
|
82
|
+
export function onHostPlayer(flags: HostFlags, cb: (mode: PlayerMode) => void): void {
|
|
83
|
+
if (!flags.framed || window.parent === window) return;
|
|
84
|
+
window.addEventListener("message", (e: MessageEvent<unknown>) => {
|
|
85
|
+
if (e.source !== window.parent) return;
|
|
86
|
+
const mode = playerFrom(e.data);
|
|
87
|
+
if (mode) cb(mode);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Tells the host the game is up, so it can post the current player mode. */
|
|
92
|
+
export function postReady(flags: HostFlags): void {
|
|
93
|
+
if (!flags.framed || window.parent === window) return;
|
|
94
|
+
// The host's origin is not known to an opaque-origin frame; the message is one public flag.
|
|
95
|
+
window.parent.postMessage({ kind: "netnet-kit", type: "ready" }, "*");
|
|
96
|
+
}
|
package/client/hud.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TORQUEBAY's HUD (plain DOM and one canvas over the scene): lap and place,
|
|
3
|
+
* the race clock, a minimap of the circuit with every kart, speed, the drift
|
|
4
|
+
* charge, the countdown, standings, results with what this race paid, the
|
|
5
|
+
* coin box and toasts. Text updates are throttled to ten a second.
|
|
6
|
+
*/
|
|
7
|
+
import { KF } from "@netforge/engine/game";
|
|
8
|
+
import type { CompiledGame, RaceInfo } from "@netforge/engine/game";
|
|
9
|
+
import { BOT_PLAYER_BASE, kartCoinCount } from "@netforge/engine-sim";
|
|
10
|
+
import type { KartTrack } from "@netforge/engine-sim";
|
|
11
|
+
import { RULE_NAMES } from "./names.js";
|
|
12
|
+
import type { SceneEntity } from "./scene.js";
|
|
13
|
+
import { kartColor } from "./scene.js";
|
|
14
|
+
|
|
15
|
+
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
|
|
16
|
+
const el = document.getElementById(id);
|
|
17
|
+
if (!el) throw new Error(`missing #${id}`);
|
|
18
|
+
return el as T;
|
|
19
|
+
};
|
|
20
|
+
const ord = (n: number) => `${n}${n % 10 === 1 && n % 100 !== 11 ? "st" : n % 10 === 2 && n % 100 !== 12 ? "nd" : n % 10 === 3 && n % 100 !== 13 ? "rd" : "th"}`;
|
|
21
|
+
const clockText = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
22
|
+
const nameOf = (id: number, self: number) => (id === self ? "You" : id >= BOT_PLAYER_BASE ? `Driver ${id - BOT_PLAYER_BASE + 1}` : `Player ${id}`);
|
|
23
|
+
const css = (c: number) => `#${c.toString(16).padStart(6, "0")}`;
|
|
24
|
+
|
|
25
|
+
export interface HudActions {
|
|
26
|
+
invite(): void;
|
|
27
|
+
toggleStore(): void;
|
|
28
|
+
toggleSound(): boolean;
|
|
29
|
+
ready(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Info = RaceInfo & { level?: string | null; laps?: number };
|
|
33
|
+
|
|
34
|
+
export class Hud {
|
|
35
|
+
private symbol = "";
|
|
36
|
+
private mode: "earning" | "guest" | "play" = "earning";
|
|
37
|
+
private lastText = 0;
|
|
38
|
+
private toastTimer = 0;
|
|
39
|
+
private lastRun = -1;
|
|
40
|
+
private lastCount = -1;
|
|
41
|
+
private lastAnnounce = 0;
|
|
42
|
+
private readonly earnedThisRun = new Map<string, number>();
|
|
43
|
+
private mapTrack: KartTrack | null = null;
|
|
44
|
+
private mapBox = { minX: 0, minZ: 0, scale: 1 };
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly game: CompiledGame,
|
|
48
|
+
private readonly self: number,
|
|
49
|
+
actions: HudActions,
|
|
50
|
+
) {
|
|
51
|
+
$("invite").addEventListener("click", () => actions.invite());
|
|
52
|
+
$("store-btn").addEventListener("click", () => actions.toggleStore());
|
|
53
|
+
$("store-close").addEventListener("click", () => actions.toggleStore());
|
|
54
|
+
$("ready").addEventListener("click", () => actions.ready());
|
|
55
|
+
$("sound").addEventListener("click", (e) => {
|
|
56
|
+
const muted = actions.toggleSound();
|
|
57
|
+
(e.currentTarget as HTMLElement).textContent = muted ? "Sound off" : "Sound on";
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
coin(name: string, symbol: string, logo: string | null): void {
|
|
62
|
+
this.symbol = symbol;
|
|
63
|
+
const img = $<HTMLImageElement>("coin-logo");
|
|
64
|
+
if (logo) {
|
|
65
|
+
img.src = logo;
|
|
66
|
+
img.alt = name;
|
|
67
|
+
img.hidden = false;
|
|
68
|
+
} else img.hidden = true;
|
|
69
|
+
$("coin-name").textContent = name;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
title(name: string): void {
|
|
73
|
+
$("title").textContent = name.toUpperCase();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
player(mode: "earning" | "guest" | "play"): void {
|
|
77
|
+
this.mode = mode;
|
|
78
|
+
const box = $("coin-box");
|
|
79
|
+
box.classList.toggle("guest", mode === "guest");
|
|
80
|
+
box.classList.toggle("play", mode === "play");
|
|
81
|
+
box.title = mode === "guest" ? `Connect a wallet to earn ${this.symbol}` : mode === "play" ? `${this.symbol} has not launched: coins are play money` : "";
|
|
82
|
+
this.lastText = 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private balanceText(balance: number): string {
|
|
86
|
+
if (this.mode === "guest") return `${this.symbol} · not earning`;
|
|
87
|
+
return `${Math.floor(balance).toLocaleString("en-US")} ${this.symbol}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
earned(rule: string, units: number): void {
|
|
91
|
+
this.earnedThisRun.set(rule, (this.earnedThisRun.get(rule) ?? 0) + units);
|
|
92
|
+
const pay = this.game.manifest.earnRules.get(rule)?.pay ?? 0;
|
|
93
|
+
const name = RULE_NAMES[rule] ?? rule;
|
|
94
|
+
this.toast(this.mode === "guest" ? `${name} Connect to earn ${pay * units} ${this.symbol}` : `${name} +${pay * units} ${this.symbol}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
toast(text: string): void {
|
|
98
|
+
const t = $("toast");
|
|
99
|
+
t.textContent = text;
|
|
100
|
+
t.classList.add("show");
|
|
101
|
+
clearTimeout(this.toastTimer);
|
|
102
|
+
this.toastTimer = window.setTimeout(() => t.classList.remove("show"), 2400);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every frame: our predicted kart, every kart in view, the run info, the drawn track. */
|
|
106
|
+
update(now: number, self: SceneEntity | undefined, everyone: readonly SceneEntity[], run: Info | null, balance: number, buffs: string, track: KartTrack | null): void {
|
|
107
|
+
if (run && run.run !== this.lastRun) {
|
|
108
|
+
this.lastRun = run.run;
|
|
109
|
+
this.earnedThisRun.clear();
|
|
110
|
+
}
|
|
111
|
+
const count = $("count");
|
|
112
|
+
const counting = run?.phase === "countdown";
|
|
113
|
+
count.classList.toggle("show", counting);
|
|
114
|
+
if (counting && run && run.countdown !== this.lastCount) {
|
|
115
|
+
this.lastCount = run.countdown;
|
|
116
|
+
$("count-n").textContent = run.countdown > 0 ? String(run.countdown) : "GO";
|
|
117
|
+
$("count-sub").textContent = run.level ? run.level.toUpperCase() : "";
|
|
118
|
+
}
|
|
119
|
+
if (run?.announce && run.announce.id !== this.lastAnnounce) {
|
|
120
|
+
this.lastAnnounce = run.announce.id;
|
|
121
|
+
this.toast(run.announce.text);
|
|
122
|
+
}
|
|
123
|
+
// The drift charge bar and the speed read every frame.
|
|
124
|
+
if (self) {
|
|
125
|
+
const flags = self.values[KF.flags] ?? 0;
|
|
126
|
+
const charge = (self.values[KF.timers] ?? 0) & 0xfff;
|
|
127
|
+
const drift = $("drift");
|
|
128
|
+
drift.classList.toggle("on", (flags & 1) !== 0);
|
|
129
|
+
const bar = drift.firstElementChild as HTMLElement | null;
|
|
130
|
+
if (bar) {
|
|
131
|
+
bar.style.width = `${Math.min(100, (charge / 120) * 100).toFixed(0)}%`;
|
|
132
|
+
bar.style.setProperty("--c", charge >= 120 ? "#d16bff" : charge >= 72 ? "#ffa53a" : charge >= 33 ? "#5fc8ff" : "#bbbbbb");
|
|
133
|
+
}
|
|
134
|
+
const v = Math.hypot((self.values[KF.vx] ?? 0) / 1024, (self.values[KF.vz] ?? 0) / 1024);
|
|
135
|
+
$("kmh").textContent = String(Math.round(v * 3.6 * 1.6));
|
|
136
|
+
}
|
|
137
|
+
this.drawMap(track, self, everyone);
|
|
138
|
+
if (now - this.lastText < 100) return;
|
|
139
|
+
this.lastText = now;
|
|
140
|
+
$("coin-balance").textContent = this.balanceText(balance);
|
|
141
|
+
$("buffs").textContent = buffs;
|
|
142
|
+
$("course").textContent = run?.level ?? "";
|
|
143
|
+
const laps = run?.laps ?? 3;
|
|
144
|
+
if (self && track) {
|
|
145
|
+
const lap = (((self.values[KF.track] ?? 0) >>> 16) & 0xff) + 1;
|
|
146
|
+
$("lap").textContent = `LAP ${Math.min(laps, lap)}/${laps}`;
|
|
147
|
+
$("coin-count").textContent = String(kartCoinCount({ coinsLo: self.values[KF.coinsLo] ?? 0, coinsHi: self.values[KF.coinsHi] ?? 0 }));
|
|
148
|
+
$("coin-total").textContent = `/ ${track.coinAt.length}`;
|
|
149
|
+
}
|
|
150
|
+
const timer = $("timer");
|
|
151
|
+
if (run) {
|
|
152
|
+
const left = run.left;
|
|
153
|
+
timer.textContent = run.phase === "racing" ? clockText(run.seconds) : run.phase === "countdown" ? "READY" : "DONE";
|
|
154
|
+
timer.classList.toggle("low", run.phase === "racing" && left !== null && left <= 15);
|
|
155
|
+
const me = run.players.find((p) => p.id === this.self);
|
|
156
|
+
$("place").textContent = me ? (me.place !== null ? `${ord(me.place).toUpperCase()}` : `${ord(me.rank).toUpperCase()}/${run.players.length}`) : "";
|
|
157
|
+
$("board").replaceChildren(
|
|
158
|
+
...run.players.slice(0, 8).map((p) => {
|
|
159
|
+
const li = document.createElement("li");
|
|
160
|
+
if (p.id === this.self) li.className = "me";
|
|
161
|
+
li.style.setProperty("--c", css(kartColor(p.id)));
|
|
162
|
+
li.innerHTML = `<em></em><span class="dot"></span><span></span><b></b>`;
|
|
163
|
+
(li.children[0] as HTMLElement).textContent = String(p.rank);
|
|
164
|
+
(li.children[2] as HTMLElement).textContent = nameOf(p.id, this.self);
|
|
165
|
+
(li.children[3] as HTMLElement).textContent = p.place !== null ? `${p.time?.toFixed(1)}s` : `L${Math.min(laps, Math.floor(p.progress * laps) + 1)}`;
|
|
166
|
+
return li;
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
this.results(run);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The circuit as a line and every kart as a dot (ours larger). */
|
|
174
|
+
private drawMap(track: KartTrack | null, self: SceneEntity | undefined, everyone: readonly SceneEntity[]): void {
|
|
175
|
+
const cv = document.getElementById("map") as HTMLCanvasElement | null;
|
|
176
|
+
const g = cv?.getContext("2d");
|
|
177
|
+
if (!cv || !g || !track) return;
|
|
178
|
+
if (track !== this.mapTrack) {
|
|
179
|
+
this.mapTrack = track;
|
|
180
|
+
let minX = Infinity;
|
|
181
|
+
let maxX = -Infinity;
|
|
182
|
+
let minZ = Infinity;
|
|
183
|
+
let maxZ = -Infinity;
|
|
184
|
+
for (let i = 0; i < track.count; i++) {
|
|
185
|
+
minX = Math.min(minX, track.x[i] ?? 0);
|
|
186
|
+
maxX = Math.max(maxX, track.x[i] ?? 0);
|
|
187
|
+
minZ = Math.min(minZ, track.z[i] ?? 0);
|
|
188
|
+
maxZ = Math.max(maxZ, track.z[i] ?? 0);
|
|
189
|
+
}
|
|
190
|
+
const scale = (cv.width - 40) / Math.max(maxX - minX, maxZ - minZ, 1);
|
|
191
|
+
this.mapBox = { minX: minX - 20 / scale, minZ: minZ - 20 / scale, scale };
|
|
192
|
+
}
|
|
193
|
+
const { minX, minZ, scale } = this.mapBox;
|
|
194
|
+
const px = (x: number) => (x - minX) * scale;
|
|
195
|
+
const pz = (z: number) => (z - minZ) * scale;
|
|
196
|
+
g.clearRect(0, 0, cv.width, cv.height);
|
|
197
|
+
g.lineJoin = "round";
|
|
198
|
+
g.strokeStyle = "rgba(255,255,255,0.85)";
|
|
199
|
+
g.lineWidth = 9;
|
|
200
|
+
g.beginPath();
|
|
201
|
+
for (let i = 0; i <= track.count; i++) {
|
|
202
|
+
const k = i % track.count;
|
|
203
|
+
if (i === 0) g.moveTo(px(track.x[k] ?? 0), pz(track.z[k] ?? 0));
|
|
204
|
+
else g.lineTo(px(track.x[k] ?? 0), pz(track.z[k] ?? 0));
|
|
205
|
+
}
|
|
206
|
+
g.stroke();
|
|
207
|
+
for (const e of [...everyone, ...(self ? [self] : [])]) {
|
|
208
|
+
const mine = e === self;
|
|
209
|
+
g.fillStyle = css(kartColor(e.owner));
|
|
210
|
+
g.beginPath();
|
|
211
|
+
g.arc(px((e.values[KF.x] ?? 0) / 1024), pz((e.values[KF.z] ?? 0) / 1024), mine ? 11 : 7, 0, Math.PI * 2);
|
|
212
|
+
g.fill();
|
|
213
|
+
if (mine) {
|
|
214
|
+
g.strokeStyle = "#fff";
|
|
215
|
+
g.lineWidth = 3;
|
|
216
|
+
g.stroke();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private results(run: Info): void {
|
|
222
|
+
const end = $("end");
|
|
223
|
+
const show = run.phase === "results";
|
|
224
|
+
end.classList.toggle("show", show);
|
|
225
|
+
if (!show) return;
|
|
226
|
+
const me = run.players.find((p) => p.id === this.self);
|
|
227
|
+
$("end-title").textContent = me?.place === 1 ? "CHEQUERED FLAG: FIRST" : me?.place !== null && me?.place !== undefined ? `${ord(me.place).toUpperCase()} PLACE` : "DID NOT FINISH";
|
|
228
|
+
$("end-board").replaceChildren(
|
|
229
|
+
...run.players.map((p) => {
|
|
230
|
+
const li = document.createElement("li");
|
|
231
|
+
if (p.id === this.self) li.className = "me";
|
|
232
|
+
li.innerHTML = `<em></em><span></span><b></b>`;
|
|
233
|
+
(li.children[0] as HTMLElement).textContent = p.place !== null ? String(p.place) : "-";
|
|
234
|
+
(li.children[1] as HTMLElement).textContent = nameOf(p.id, this.self);
|
|
235
|
+
(li.children[2] as HTMLElement).textContent = p.time !== null ? `${p.time.toFixed(2)} s` : "did not finish";
|
|
236
|
+
return li;
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
const mine = run.coins.find((c) => c.id === this.self)?.rules ?? [...this.earnedThisRun].map(([rule, units]) => ({ rule, units }));
|
|
240
|
+
$("end-earned").replaceChildren(
|
|
241
|
+
...mine.map((r) => {
|
|
242
|
+
const li = document.createElement("li");
|
|
243
|
+
if (this.mode === "guest") li.className = "guest";
|
|
244
|
+
const pay = (this.game.manifest.earnRules.get(r.rule)?.pay ?? 0) * r.units;
|
|
245
|
+
li.innerHTML = `<span></span><b></b>`;
|
|
246
|
+
(li.children[0] as HTMLElement).textContent = RULE_NAMES[r.rule] ?? r.rule;
|
|
247
|
+
(li.children[1] as HTMLElement).textContent = this.mode === "guest" ? `${pay} ${this.symbol} (connect to earn)` : `+${pay} ${this.symbol}`;
|
|
248
|
+
return li;
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
$("end-next").textContent = run.nextRunIn !== null ? `NEXT RACE IN ${run.nextRunIn}` : "";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
stats(text: string): void {
|
|
255
|
+
$("stats").textContent = text;
|
|
256
|
+
}
|
|
257
|
+
}
|