@netforge/tabletop 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 +6 -0
- package/README.md +6 -2
- package/package.json +44 -3
- package/src/boot.ts +333 -0
- package/src/connect.ts +116 -0
- package/src/host.ts +96 -0
- package/src/hud.ts +391 -0
- package/src/index.ts +12 -0
- package/src/kit.ts +92 -0
- package/src/stage.ts +327 -0
- package/src/stake.ts +143 -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
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @netforge/tabletop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The client kit the board-style Net Forge templates share (tower defense,
|
|
4
|
+
idle, card battler, monster collector): the room connection and input clock
|
|
5
|
+
for rules genres, the styled 3D stage, the store, stake and gear wiring, and
|
|
6
|
+
the HUD shell. Used by `@netforge/template-*` clients; see
|
|
7
|
+
`@netforge/kit-play` to run one on your own computer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/tabletop",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The client kit the board-style templates share (tower defense, idle, card battler, monster collector): the room connection and input clock for rules genres, the styled 3D stage, the Kit store, stake and gear wiring, and the HUD shell.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"!src/measure.ts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"NOTICE",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/netnetdev/net-forge.git",
|
|
23
|
+
"directory": "packages/tabletop"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/tabletop#readme",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"three": "0.186.1",
|
|
31
|
+
"@netforge/asset-library": "0.1.0",
|
|
32
|
+
"@netforge/engine": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "22.20.1",
|
|
36
|
+
"@types/three": "0.186.0",
|
|
37
|
+
"typescript": "5.8.3",
|
|
38
|
+
"vitest": "3.2.7"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/netnetdev/net-forge/issues"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
45
|
+
"test": "vitest run --passWithNoTests"
|
|
46
|
+
}
|
|
6
47
|
}
|
package/src/boot.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boots a board template's page: the Kit flags from Net Forge, the
|
|
3
|
+
* renderer and the stage in the game's art style, the room by link, the
|
|
4
|
+
* input clock, the store, sound and the stats overlay. The template then
|
|
5
|
+
* draws its own board on `stage` and reads its own run state from `view()`.
|
|
6
|
+
*
|
|
7
|
+
* URL parameters (every board template):
|
|
8
|
+
* ?room=<id> join a friend's instance (the Invite link)
|
|
9
|
+
* ?place=<name> the place
|
|
10
|
+
* ?server=<url> room server (default: VITE_ROOMS_URL, else this host on :2567)
|
|
11
|
+
* ?webgl=1 force the WebGL2 backend
|
|
12
|
+
* ?autopilot=1 the genre's bot plays (measurement and screenshots)
|
|
13
|
+
* ?preview=1 a style preview: the camera frames the board, the HUD hides
|
|
14
|
+
* ?stats=1 show frame, network and server numbers (also the ` key)
|
|
15
|
+
* ?shapes=1 draw the fallback shapes instead of library models
|
|
16
|
+
* ?style=<style> another art style (toon, ps1, pixel, voxel, handdrawn)
|
|
17
|
+
* ?library=<url> asset library base URL (default: VITE_LIBRARY_URL, else ./library)
|
|
18
|
+
* ?host=netnet, ?coin=, ?title=, ?player= set by Net Forge (host.ts)
|
|
19
|
+
*/
|
|
20
|
+
import type { GameManifest } from "@netforge/engine";
|
|
21
|
+
import type { SimAdapter } from "@netforge/engine/net";
|
|
22
|
+
import type { RulesInput, RulesWorld } from "@netforge/engine/rules";
|
|
23
|
+
import { createRenderer, FeelAudio, FrameStats, pickStyle, SoundPlayer } from "@netforge/engine/runtime";
|
|
24
|
+
import type { LookPreset } from "@netforge/engine/runtime";
|
|
25
|
+
import { soundBank } from "@netforge/asset-library";
|
|
26
|
+
import { LibraryClient } from "@netforge/asset-library/three";
|
|
27
|
+
import { InputClock, joinBoard, roomsEndpoint, type BoardConnection } from "./connect.js";
|
|
28
|
+
import { hostFlags, namedPlayer, onHostPlayer, postReady, type PlayerMode } from "./host.js";
|
|
29
|
+
import { Hud, wireStore, type EarnProgress, type HudLabels, type HudTheme } from "./hud.js";
|
|
30
|
+
import { mockKit, type KitStoreBridge, type SkuPrice } from "./kit.js";
|
|
31
|
+
import { BoardStage } from "./stage.js";
|
|
32
|
+
|
|
33
|
+
export interface BoardBootOptions<S> {
|
|
34
|
+
readonly adapter: SimAdapter<RulesWorld<S>, RulesInput>;
|
|
35
|
+
readonly manifest: GameManifest;
|
|
36
|
+
/** Room template and default place. */
|
|
37
|
+
readonly template: string;
|
|
38
|
+
readonly place: string;
|
|
39
|
+
readonly version: string;
|
|
40
|
+
readonly labels: HudLabels;
|
|
41
|
+
readonly theme: HudTheme;
|
|
42
|
+
/** The template's own coin until a creator renames it. */
|
|
43
|
+
readonly coin: { name: string; symbol: string; logo: string | null };
|
|
44
|
+
readonly cosmetics: Readonly<Record<string, number>>;
|
|
45
|
+
readonly swatch: (cosmetic: number) => string;
|
|
46
|
+
/** Ground the stage frames, metres. */
|
|
47
|
+
readonly ground: { w: number; h: number };
|
|
48
|
+
readonly preset?: LookPreset;
|
|
49
|
+
readonly theme3d?: string;
|
|
50
|
+
readonly groundColor?: number;
|
|
51
|
+
readonly pitch?: number;
|
|
52
|
+
/** false: no style-built land (voxel) under the game's own surface. */
|
|
53
|
+
readonly field?: boolean;
|
|
54
|
+
/** The autopilot's input (?autopilot=1): the genre's own bot over the latest run state. */
|
|
55
|
+
readonly autopilot?: (tick: number) => RulesInput | null;
|
|
56
|
+
/** Key for the remembered mute. */
|
|
57
|
+
readonly soundKey: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The per-player run state every board view carries (adapter infoFor). */
|
|
61
|
+
export interface BoardView {
|
|
62
|
+
readonly you?: number;
|
|
63
|
+
readonly phase?: "playing" | "ended";
|
|
64
|
+
readonly outcome?: "won" | "lost" | null;
|
|
65
|
+
readonly endsIn?: number | null;
|
|
66
|
+
readonly earn?: readonly EarnProgress[];
|
|
67
|
+
readonly upgrades?: Readonly<Record<string, number>>;
|
|
68
|
+
readonly [k: string]: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BoardApp<S> {
|
|
72
|
+
readonly params: URLSearchParams;
|
|
73
|
+
readonly conn: () => BoardConnection<S>;
|
|
74
|
+
readonly self: number;
|
|
75
|
+
readonly hud: Hud;
|
|
76
|
+
readonly stage: BoardStage;
|
|
77
|
+
readonly clock: InputClock;
|
|
78
|
+
readonly kit: KitStoreBridge;
|
|
79
|
+
readonly sfx: FeelAudio;
|
|
80
|
+
readonly sound: SoundPlayer;
|
|
81
|
+
readonly autopilot: boolean;
|
|
82
|
+
readonly preview: boolean;
|
|
83
|
+
readonly style: string;
|
|
84
|
+
readonly canvas: HTMLCanvasElement;
|
|
85
|
+
readonly joinMs: number;
|
|
86
|
+
/** The latest per-player run state. */
|
|
87
|
+
view<V extends BoardView>(): V | null;
|
|
88
|
+
/** Every frame: dt in seconds. The stage draws after `fn`. */
|
|
89
|
+
loop(fn: (dt: number, now: number) => void): void;
|
|
90
|
+
/** Extra lines for the stats overlay and window.__board. */
|
|
91
|
+
extraStats(fn: () => Record<string, unknown>): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface BoardStats {
|
|
95
|
+
backend: string;
|
|
96
|
+
fps: number;
|
|
97
|
+
frameMs: { mean: number; p50: number; p95: number; p99: number };
|
|
98
|
+
drawCalls: number;
|
|
99
|
+
triangles: number;
|
|
100
|
+
downKBps: number;
|
|
101
|
+
upKBps: number;
|
|
102
|
+
rttMs: number;
|
|
103
|
+
joinMs: number;
|
|
104
|
+
firstFrameMs: number;
|
|
105
|
+
/** First drawn frame since navigation start (download, parse, join and the first draw). */
|
|
106
|
+
firstFrameNavMs: number;
|
|
107
|
+
corrections: number;
|
|
108
|
+
frames: number;
|
|
109
|
+
roomId: string;
|
|
110
|
+
style: string;
|
|
111
|
+
models: number;
|
|
112
|
+
failedModels: number;
|
|
113
|
+
actions: number;
|
|
114
|
+
extra: Record<string, unknown>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
declare global {
|
|
118
|
+
interface Window {
|
|
119
|
+
__board?: BoardStats;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function bootBoard<S>(o: BoardBootOptions<S>): Promise<BoardApp<S>> {
|
|
124
|
+
const bootAt = performance.now();
|
|
125
|
+
const params = new URLSearchParams(location.search);
|
|
126
|
+
const launcher = hostFlags(params);
|
|
127
|
+
if (launcher.framed) document.documentElement.dataset.host = "netnet";
|
|
128
|
+
const preview = params.get("preview") === "1";
|
|
129
|
+
const hud = new Hud(o.labels, o.theme, o.manifest);
|
|
130
|
+
if (preview) document.documentElement.dataset.preview = "1";
|
|
131
|
+
let playerMode: PlayerMode = launcher.player;
|
|
132
|
+
let hostMode: PlayerMode | null = namedPlayer(params);
|
|
133
|
+
onHostPlayer(launcher, (mode) => {
|
|
134
|
+
playerMode = mode;
|
|
135
|
+
hostMode = mode;
|
|
136
|
+
hud.player(mode);
|
|
137
|
+
});
|
|
138
|
+
const title = launcher.title ?? o.labels.title;
|
|
139
|
+
hud.title(title);
|
|
140
|
+
|
|
141
|
+
const host = document.getElementById("game");
|
|
142
|
+
if (!host) throw new Error("missing #game");
|
|
143
|
+
const canvas = document.createElement("canvas");
|
|
144
|
+
host.append(canvas);
|
|
145
|
+
const handle = await createRenderer(canvas, { forceWebGL: params.get("webgl") === "1", antialias: true });
|
|
146
|
+
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {};
|
|
147
|
+
const libraryBase = params.get("library") ?? env.VITE_LIBRARY_URL ?? "./library";
|
|
148
|
+
const style = pickStyle(o.manifest.game.style, params.get("style"));
|
|
149
|
+
const library = params.get("shapes") === "1" ? null : new LibraryClient(handle, { baseUrl: libraryBase, style });
|
|
150
|
+
const stage = new BoardStage(handle, {
|
|
151
|
+
style,
|
|
152
|
+
library,
|
|
153
|
+
ground: o.ground,
|
|
154
|
+
...(o.preset ? { preset: o.preset } : {}),
|
|
155
|
+
...(o.theme3d ? { theme: o.theme3d } : {}),
|
|
156
|
+
...(o.groundColor !== undefined ? { groundColor: o.groundColor } : {}),
|
|
157
|
+
...(o.pitch !== undefined ? { pitch: o.pitch } : {}),
|
|
158
|
+
...(o.field !== undefined ? { field: o.field } : {}),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const endpoint = roomsEndpoint(params);
|
|
162
|
+
const friendRoomId = params.get("room") ?? undefined;
|
|
163
|
+
const place = params.get("place") ?? o.place;
|
|
164
|
+
const { conn: first, joinMs } = await joinBoard(o.adapter, { endpoint, place, template: o.template, ...(friendRoomId ? { friendRoomId } : {}) });
|
|
165
|
+
const conn = first;
|
|
166
|
+
const self = conn.welcome.player;
|
|
167
|
+
if (friendRoomId && conn.roomId !== friendRoomId) setTimeout(() => hud.toast("Room full, new room"), 500);
|
|
168
|
+
if (conn.welcome.version !== o.version) setTimeout(() => hud.toast("Update ready. Reload."), 800);
|
|
169
|
+
const autopilot = params.get("autopilot") === "1";
|
|
170
|
+
const linkFor = (base: string) => `${base}?room=${conn.roomId}${place !== o.place ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}`;
|
|
171
|
+
const keep = ["autopilot", "webgl", "stats", "style", "preview", "shapes", "host", "coin", "title", "player", "library"].filter((k) => params.has(k)).map((k) => `&${k}=${encodeURIComponent(params.get(k) ?? "")}`).join("");
|
|
172
|
+
history.replaceState(null, "", `${linkFor(location.pathname)}${keep}`);
|
|
173
|
+
|
|
174
|
+
let latest: BoardView | null = null;
|
|
175
|
+
conn.onRun((info) => {
|
|
176
|
+
latest = info as BoardView;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// The Kit plugs in here (packages/kit, kit.init({ game })): kit.coins and kit.store have these exact names.
|
|
180
|
+
const prices: Record<string, SkuPrice> = Object.fromEntries(o.manifest.skus);
|
|
181
|
+
const coin = launcher.coin ? { name: launcher.coin, symbol: launcher.coin, logo: null } : o.coin;
|
|
182
|
+
const kit = mockKit({ earned: 120, wallet: 0, usdg: 5 }, prices, coin);
|
|
183
|
+
hud.coin(kit.coins.name(), kit.coins.symbol(), kit.coins.logo());
|
|
184
|
+
hud.player(playerMode);
|
|
185
|
+
conn.onEarn((e) => {
|
|
186
|
+
hud.earned(e.rule, e.units);
|
|
187
|
+
sfx.play("coin");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const sfx = new FeelAudio();
|
|
191
|
+
const sound = new SoundPlayer(sfx, { key: o.soundKey, musicVolume: 0.25 });
|
|
192
|
+
try {
|
|
193
|
+
sound.load(soundBank(libraryBase));
|
|
194
|
+
} catch {
|
|
195
|
+
// no library audio: the synth cues play
|
|
196
|
+
}
|
|
197
|
+
const soundBtn = document.getElementById("sound");
|
|
198
|
+
if (soundBtn) {
|
|
199
|
+
soundBtn.textContent = sound.muted ? "Sound off" : "Sound";
|
|
200
|
+
soundBtn.addEventListener("click", () => {
|
|
201
|
+
sfx.unlock();
|
|
202
|
+
soundBtn.textContent = sound.toggle() ? "Sound off" : "Sound";
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
addEventListener("pointerdown", () => sfx.unlock(), { once: true });
|
|
206
|
+
addEventListener("keydown", () => sfx.unlock(), { once: true });
|
|
207
|
+
wireStore({ hud, manifest: o.manifest, kit, conn: conn as never, labels: o.labels, cosmetics: o.cosmetics, swatch: o.swatch, hostMode: () => hostMode, unlock: () => sfx.unlock() });
|
|
208
|
+
document.getElementById("invite")?.addEventListener("click", () => {
|
|
209
|
+
const url = linkFor(`${location.origin}${location.pathname}`);
|
|
210
|
+
void navigator.clipboard?.writeText(url).then(
|
|
211
|
+
() => hud.toast("Invite link copied"),
|
|
212
|
+
() => hud.toast(url),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
postReady(launcher);
|
|
216
|
+
|
|
217
|
+
const clock = new InputClock(() => conn as BoardConnection<unknown>, autopilot && o.autopilot ? o.autopilot : null);
|
|
218
|
+
clock.start();
|
|
219
|
+
|
|
220
|
+
let rtt = 0;
|
|
221
|
+
conn.room.onMessage("pong", (t: number) => {
|
|
222
|
+
rtt = performance.now() - t;
|
|
223
|
+
});
|
|
224
|
+
setInterval(() => conn.room.send("ping", performance.now()), 2000);
|
|
225
|
+
addEventListener("keydown", (e) => {
|
|
226
|
+
if (e.code === "Backquote") document.getElementById("stats")?.classList.toggle("show");
|
|
227
|
+
});
|
|
228
|
+
if (params.get("stats") === "1") document.getElementById("stats")?.classList.add("show");
|
|
229
|
+
|
|
230
|
+
const resize = () => stage.resize(Math.max(1, host.clientWidth), Math.max(1, host.clientHeight));
|
|
231
|
+
resize();
|
|
232
|
+
new ResizeObserver(resize).observe(host);
|
|
233
|
+
|
|
234
|
+
const frame = new FrameStats(600);
|
|
235
|
+
const extras: (() => Record<string, unknown>)[] = [];
|
|
236
|
+
let fns: ((dt: number, now: number) => void)[] = [];
|
|
237
|
+
let last = 0;
|
|
238
|
+
let frames = 0;
|
|
239
|
+
let firstFrameMs = 0;
|
|
240
|
+
let firstFrameNavMs = 0;
|
|
241
|
+
let lastBytes = 0;
|
|
242
|
+
let lastUp = 0;
|
|
243
|
+
let lastNet = performance.now();
|
|
244
|
+
let downKBps = 0;
|
|
245
|
+
let upKBps = 0;
|
|
246
|
+
let lastStats = 0;
|
|
247
|
+
handle.renderer.setAnimationLoop((t: number) => {
|
|
248
|
+
const real = performance.now();
|
|
249
|
+
const dt = last === 0 ? 1 / 60 : Math.min(0.1, (t - last) / 1000);
|
|
250
|
+
if (last !== 0) frame.push(t - last);
|
|
251
|
+
last = t;
|
|
252
|
+
for (const f of fns) f(dt, real);
|
|
253
|
+
stage.frame(dt);
|
|
254
|
+
if (frames === 0) {
|
|
255
|
+
firstFrameMs = real - bootAt;
|
|
256
|
+
firstFrameNavMs = real;
|
|
257
|
+
hud.ready();
|
|
258
|
+
}
|
|
259
|
+
frames++;
|
|
260
|
+
hud.balance(kit.coins.balance());
|
|
261
|
+
const v = latest;
|
|
262
|
+
if (v) {
|
|
263
|
+
hud.goals(v.earn);
|
|
264
|
+
}
|
|
265
|
+
if (real - lastNet >= 1000) {
|
|
266
|
+
const secs = (real - lastNet) / 1000;
|
|
267
|
+
downKBps = (conn.bytesIn() - lastBytes) / secs / 1024;
|
|
268
|
+
upKBps = (conn.net.stats.inputBytes - lastUp) / secs / 1024;
|
|
269
|
+
lastBytes = conn.bytesIn();
|
|
270
|
+
lastUp = conn.net.stats.inputBytes;
|
|
271
|
+
lastNet = real;
|
|
272
|
+
}
|
|
273
|
+
const info = handle.renderer.info.render;
|
|
274
|
+
const mean = frame.mean();
|
|
275
|
+
const extra: Record<string, unknown> = {};
|
|
276
|
+
for (const e of extras) Object.assign(extra, e());
|
|
277
|
+
const stats: BoardStats = {
|
|
278
|
+
backend: handle.backend,
|
|
279
|
+
fps: mean > 0 ? 1000 / mean : 0,
|
|
280
|
+
frameMs: { mean, p50: frame.percentile(0.5), p95: frame.percentile(0.95), p99: frame.percentile(0.99) },
|
|
281
|
+
drawCalls: info.drawCalls,
|
|
282
|
+
triangles: info.triangles,
|
|
283
|
+
downKBps,
|
|
284
|
+
upKBps,
|
|
285
|
+
rttMs: rtt,
|
|
286
|
+
joinMs,
|
|
287
|
+
firstFrameMs,
|
|
288
|
+
firstFrameNavMs,
|
|
289
|
+
corrections: conn.net.corrections,
|
|
290
|
+
frames,
|
|
291
|
+
roomId: conn.roomId,
|
|
292
|
+
style,
|
|
293
|
+
models: stage.counts.models,
|
|
294
|
+
failedModels: stage.counts.failed,
|
|
295
|
+
actions: clock.sent,
|
|
296
|
+
extra,
|
|
297
|
+
};
|
|
298
|
+
window.__board = stats;
|
|
299
|
+
if (real - lastStats > 250) {
|
|
300
|
+
lastStats = real;
|
|
301
|
+
hud.stats(
|
|
302
|
+
[
|
|
303
|
+
`${stats.backend.toUpperCase()} ${stats.fps.toFixed(0)} fps p95 ${stats.frameMs.p95.toFixed(1)} ms ${style}`,
|
|
304
|
+
`draws ${stats.drawCalls} tris ${(stats.triangles / 1000).toFixed(0)}k models ${stats.models}`,
|
|
305
|
+
`down ${downKBps.toFixed(1)} KB/s up ${upKBps.toFixed(2)} KB/s rtt ${rtt.toFixed(0)} ms`,
|
|
306
|
+
`join ${joinMs.toFixed(0)} ms first frame ${firstFrameMs.toFixed(0)} ms corrections ${conn.net.corrections}`,
|
|
307
|
+
].join("\n"),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
params,
|
|
314
|
+
conn: () => conn,
|
|
315
|
+
self,
|
|
316
|
+
hud,
|
|
317
|
+
stage,
|
|
318
|
+
clock,
|
|
319
|
+
kit,
|
|
320
|
+
sfx,
|
|
321
|
+
sound,
|
|
322
|
+
autopilot,
|
|
323
|
+
preview,
|
|
324
|
+
style,
|
|
325
|
+
canvas,
|
|
326
|
+
joinMs,
|
|
327
|
+
view: <V extends BoardView>() => latest as V | null,
|
|
328
|
+
loop: (fn) => {
|
|
329
|
+
fns = [...fns, fn];
|
|
330
|
+
},
|
|
331
|
+
extraStats: (fn) => extras.push(fn),
|
|
332
|
+
};
|
|
333
|
+
}
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The room connection for a board template: join by link, an input clock
|
|
3
|
+
* that sends one input a tick (the server consumes one a tick), the avatar
|
|
4
|
+
* predicted from it, and actions queued so each rides exactly one input.
|
|
5
|
+
*/
|
|
6
|
+
import { guestId, joinRoom, savedSession } from "@netforge/engine/net";
|
|
7
|
+
import type { RoomConnection } from "@netforge/engine/net";
|
|
8
|
+
import type { RulesInput, RulesWorld } from "@netforge/engine/rules";
|
|
9
|
+
import { IDLE_INPUT } from "@netforge/engine/rules";
|
|
10
|
+
import type { SimAdapter } from "@netforge/engine/net";
|
|
11
|
+
|
|
12
|
+
export interface BoardJoin {
|
|
13
|
+
readonly endpoint: string;
|
|
14
|
+
readonly place: string;
|
|
15
|
+
readonly template: string;
|
|
16
|
+
readonly friendRoomId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type BoardConnection<S> = RoomConnection<RulesWorld<S>, RulesInput>;
|
|
20
|
+
|
|
21
|
+
/** Joins the room (saves under the signed-in wallet or this browser's guest id). */
|
|
22
|
+
export async function joinBoard<S>(adapter: SimAdapter<RulesWorld<S>, RulesInput>, j: BoardJoin): Promise<{ conn: BoardConnection<S>; joinMs: number }> {
|
|
23
|
+
const t0 = performance.now();
|
|
24
|
+
const session = savedSession(j.endpoint);
|
|
25
|
+
const conn = await joinRoom(adapter, {
|
|
26
|
+
endpoint: j.endpoint,
|
|
27
|
+
place: j.place,
|
|
28
|
+
template: j.template,
|
|
29
|
+
guest: guestId(),
|
|
30
|
+
...(session ? { session: session.token } : {}),
|
|
31
|
+
...(j.friendRoomId ? { friendRoomId: j.friendRoomId } : {}),
|
|
32
|
+
});
|
|
33
|
+
return { conn, joinMs: performance.now() - t0 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One input a tick, on the server's clock (clock adjustment from the
|
|
38
|
+
* snapshot headers). The cursor and the stick are sampled each tick; an
|
|
39
|
+
* action waits in a queue and rides the next tick's input, one per tick.
|
|
40
|
+
*/
|
|
41
|
+
export class InputClock {
|
|
42
|
+
private stickX = 0;
|
|
43
|
+
private stickZ = 0;
|
|
44
|
+
private cursorX = 0;
|
|
45
|
+
private cursorZ = 0;
|
|
46
|
+
private readonly queue: { act: number; a: number; b: number; c: number }[] = [];
|
|
47
|
+
ticks = 0;
|
|
48
|
+
/** Actions sent so far. */
|
|
49
|
+
sent = 0;
|
|
50
|
+
private timer = 0;
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly conn: () => BoardConnection<unknown>,
|
|
54
|
+
/** An autopilot input (playtest and measure), or null to play from the queue. */
|
|
55
|
+
private readonly auto: ((tick: number) => RulesInput | null) | null = null,
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
/** Stick, -1..1 each axis (walking heroes). */
|
|
59
|
+
stick(x: number, z: number): void {
|
|
60
|
+
this.stickX = Math.max(-1, Math.min(1, x));
|
|
61
|
+
this.stickZ = Math.max(-1, Math.min(1, z));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Cursor on the ground, metres. */
|
|
65
|
+
cursor(x: number, z: number): void {
|
|
66
|
+
this.cursorX = x;
|
|
67
|
+
this.cursorZ = z;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Queue an action (sent on the next tick). */
|
|
71
|
+
act(act: number, a = 0, b = 0, c = 0): void {
|
|
72
|
+
if (this.queue.length < 16) this.queue.push({ act, a, b, c });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private sample(): RulesInput {
|
|
76
|
+
const auto = this.auto?.(this.ticks) ?? null;
|
|
77
|
+
if (auto) return auto;
|
|
78
|
+
const q = this.queue.shift();
|
|
79
|
+
return {
|
|
80
|
+
...IDLE_INPUT,
|
|
81
|
+
mx: Math.round(this.stickX * 127),
|
|
82
|
+
mz: Math.round(this.stickZ * 127),
|
|
83
|
+
cx: Math.round(this.cursorX * 16),
|
|
84
|
+
cz: Math.round(this.cursorZ * 16),
|
|
85
|
+
...(q ? { act: q.act, a: q.a, b: q.b, c: q.c } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
start(): void {
|
|
90
|
+
let next = performance.now();
|
|
91
|
+
this.timer = window.setInterval(() => {
|
|
92
|
+
const now = performance.now();
|
|
93
|
+
let n = 0;
|
|
94
|
+
const c = this.conn();
|
|
95
|
+
while (next <= now && n < 8) {
|
|
96
|
+
const i = this.sample();
|
|
97
|
+
if (i.act !== 0) this.sent++;
|
|
98
|
+
c.net.tick(i, now);
|
|
99
|
+
this.ticks++;
|
|
100
|
+
next += c.net.tickIntervalMs();
|
|
101
|
+
n++;
|
|
102
|
+
}
|
|
103
|
+
if (n === 8) next = now + c.net.tickIntervalMs();
|
|
104
|
+
}, 4);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
stop(): void {
|
|
108
|
+
clearInterval(this.timer);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The room server: ?server=, else VITE_ROOMS_URL, else this host on :2567. */
|
|
113
|
+
export function roomsEndpoint(params: URLSearchParams): string {
|
|
114
|
+
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {};
|
|
115
|
+
return params.get("server") ?? env.VITE_ROOMS_URL ?? `${location.protocol}//${location.hostname}:2567`;
|
|
116
|
+
}
|
package/src/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
|
+
}
|