@netforge/tabletop 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/LICENSE +21 -0
- package/NOTICE +6 -0
- package/README.md +54 -2
- package/package.json +59 -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/src/stage.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 3D stage every board template draws on: one three.js scene through
|
|
3
|
+
* the engine's render stack in the game's art style (lowpoly, toon, ps1,
|
|
4
|
+
* pixel, voxel, handdrawn), a tilted top-down camera that frames a
|
|
5
|
+
* rectangle of ground, ground picking, library models with fallback shapes
|
|
6
|
+
* (so a game always draws, built library or not) and instanced crowds.
|
|
7
|
+
*
|
|
8
|
+
* Network: one GET per distinct library id (LibraryClient). No chain reads.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
Box3,
|
|
12
|
+
BoxGeometry,
|
|
13
|
+
CanvasTexture,
|
|
14
|
+
Color,
|
|
15
|
+
ConeGeometry,
|
|
16
|
+
CylinderGeometry,
|
|
17
|
+
Group,
|
|
18
|
+
IcosahedronGeometry,
|
|
19
|
+
Mesh,
|
|
20
|
+
MeshStandardMaterial,
|
|
21
|
+
Object3D,
|
|
22
|
+
PerspectiveCamera,
|
|
23
|
+
Plane,
|
|
24
|
+
PlaneGeometry,
|
|
25
|
+
Raycaster,
|
|
26
|
+
RepeatWrapping,
|
|
27
|
+
Scene,
|
|
28
|
+
SRGBColorSpace,
|
|
29
|
+
Vector2,
|
|
30
|
+
Vector3,
|
|
31
|
+
} from "three/webgpu";
|
|
32
|
+
import type { BufferGeometry, Material } from "three/webgpu";
|
|
33
|
+
import { AnimatedCrowd, createRenderStack, styleKit } from "@netforge/engine/runtime";
|
|
34
|
+
import type { FieldSpec, LookPreset, RenderStack, RendererHandle, StyleKit } from "@netforge/engine/runtime";
|
|
35
|
+
import type { LibraryClient } from "@netforge/asset-library/three";
|
|
36
|
+
|
|
37
|
+
export interface StageOptions {
|
|
38
|
+
/** The art style drawn (the game's declared style or ?style=). */
|
|
39
|
+
readonly style: string;
|
|
40
|
+
readonly preset?: LookPreset;
|
|
41
|
+
/** Real models; null draws the fallback shapes only (?shapes=1, or no library). */
|
|
42
|
+
readonly library: LibraryClient | null;
|
|
43
|
+
/** The ground the game stands on, metres, centred on the origin. */
|
|
44
|
+
readonly ground: { readonly w: number; readonly h: number };
|
|
45
|
+
/** Words a style kit dresses the ground with ("meadow", "yard"). */
|
|
46
|
+
readonly theme?: string;
|
|
47
|
+
/** Base colour of the template's own ground (no style surface). */
|
|
48
|
+
readonly groundColor?: number;
|
|
49
|
+
/** Camera pitch, radians above the ground (default 0.95). */
|
|
50
|
+
readonly pitch?: number;
|
|
51
|
+
/** Let a style that builds fields (voxel) lay its own land (default true). A game played on a surface of its own (a desk) says false. */
|
|
52
|
+
readonly field?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A model handle that swaps the fallback for the real model when it loads. */
|
|
56
|
+
export interface Placed {
|
|
57
|
+
readonly root: Group;
|
|
58
|
+
/** Resolves once the library model replaced the fallback (or failed and kept it). */
|
|
59
|
+
readonly ready: Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An instanced crowd of one model: write instances between begin() and end() each frame. */
|
|
63
|
+
export interface Crowd {
|
|
64
|
+
begin(): void;
|
|
65
|
+
push(x: number, y: number, z: number, yaw: number, phase: number, speed: number, hit?: number, flash?: number, scale?: number): void;
|
|
66
|
+
end(seconds: number): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const mat = (color: number, emissive = 0) => new MeshStandardMaterial({ color: new Color(color), emissive: new Color(emissive), roughness: 0.75, metalness: 0.1, flatShading: true });
|
|
70
|
+
|
|
71
|
+
/** A fallback shape by the library id's kind words: creatures are blobs, props are boxes, characters capsules. */
|
|
72
|
+
export function fallbackShape(id: string, height: number, color = 0xb7c0cc): { geometry: BufferGeometry; material: Material } {
|
|
73
|
+
const name = id.split(".")[2] ?? "";
|
|
74
|
+
let g: BufferGeometry;
|
|
75
|
+
if (/turret|tower|generator|dish|obelisk|column/.test(name)) {
|
|
76
|
+
g = new CylinderGeometry(height * 0.28, height * 0.36, height, 8);
|
|
77
|
+
g.translate(0, height / 2, 0);
|
|
78
|
+
} else if (/slime|blob|frog|crab|bee|bug|ghost|bat|fox|wolf|cat|pug|shiba|dino|imp|jelly|fluffy/.test(name)) {
|
|
79
|
+
g = new IcosahedronGeometry(height * 0.45, 1);
|
|
80
|
+
g.translate(0, height * 0.45, 0);
|
|
81
|
+
} else if (/tree|pine|bush|rock|crystal|stump|mushroom/.test(name)) {
|
|
82
|
+
g = new ConeGeometry(height * 0.35, height, 7);
|
|
83
|
+
g.translate(0, height / 2, 0);
|
|
84
|
+
} else {
|
|
85
|
+
g = new BoxGeometry(height * 0.5, height, height * 0.5);
|
|
86
|
+
g.translate(0, height / 2, 0);
|
|
87
|
+
}
|
|
88
|
+
return { geometry: g, material: mat(color) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function groundTexture(color: number): CanvasTexture {
|
|
92
|
+
const c = document.createElement("canvas");
|
|
93
|
+
c.width = c.height = 256;
|
|
94
|
+
const g = c.getContext("2d");
|
|
95
|
+
if (!g) throw new Error("no 2d canvas");
|
|
96
|
+
const base = new Color(color);
|
|
97
|
+
g.fillStyle = `#${base.getHexString()}`;
|
|
98
|
+
g.fillRect(0, 0, 256, 256);
|
|
99
|
+
let s = 7;
|
|
100
|
+
const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
|
|
101
|
+
for (let i = 0; i < 2600; i++) {
|
|
102
|
+
const k = 0.82 + rnd() * 0.3;
|
|
103
|
+
g.fillStyle = `rgba(${Math.round(base.r * 255 * k)},${Math.round(base.g * 255 * k)},${Math.round(base.b * 255 * k)},0.55)`;
|
|
104
|
+
g.fillRect(rnd() * 256, rnd() * 256, 1 + rnd() * 3, 1 + rnd() * 3);
|
|
105
|
+
}
|
|
106
|
+
const t = new CanvasTexture(c);
|
|
107
|
+
t.wrapS = t.wrapT = RepeatWrapping;
|
|
108
|
+
t.colorSpace = SRGBColorSpace;
|
|
109
|
+
return t;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class BoardStage {
|
|
113
|
+
readonly scene = new Scene();
|
|
114
|
+
readonly camera = new PerspectiveCamera(40, 16 / 9, 0.5, 400);
|
|
115
|
+
readonly stack: RenderStack;
|
|
116
|
+
readonly kit: StyleKit | null;
|
|
117
|
+
readonly style: string;
|
|
118
|
+
/** Ground height at a point (a style that builds its own field may raise it). */
|
|
119
|
+
readonly groundAt: (x: number, z: number) => number;
|
|
120
|
+
private readonly raycaster = new Raycaster();
|
|
121
|
+
private readonly plane = new Plane(new Vector3(0, 1, 0), 0);
|
|
122
|
+
private readonly target = new Vector3();
|
|
123
|
+
private readonly look = new Vector3();
|
|
124
|
+
private span = { w: 20, h: 14 };
|
|
125
|
+
private pitch: number;
|
|
126
|
+
private aspect = 16 / 9;
|
|
127
|
+
private shake = 0;
|
|
128
|
+
private time = 0;
|
|
129
|
+
/** Effects fired (playtest checks read these). */
|
|
130
|
+
readonly counts = { bursts: 0, rings: 0, models: 0, failed: 0 };
|
|
131
|
+
|
|
132
|
+
constructor(
|
|
133
|
+
private readonly handle: RendererHandle,
|
|
134
|
+
private readonly opts: StageOptions,
|
|
135
|
+
) {
|
|
136
|
+
this.style = opts.style;
|
|
137
|
+
this.pitch = opts.pitch ?? 0.95;
|
|
138
|
+
this.kit = styleKit(opts.style);
|
|
139
|
+
const library = opts.library;
|
|
140
|
+
this.stack = createRenderStack(handle, this.scene, this.camera, {
|
|
141
|
+
preset: opts.preset ?? "dusk",
|
|
142
|
+
style: opts.style,
|
|
143
|
+
quality: "auto",
|
|
144
|
+
shadowExtent: Math.max(opts.ground.w, opts.ground.h) * 0.7,
|
|
145
|
+
atmosphere: { heightFog: 0, near: 60, far: 160 },
|
|
146
|
+
resolve: (id) => {
|
|
147
|
+
try {
|
|
148
|
+
return library?.url(id) ?? null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
if (this.kit?.stack?.(opts.preset ?? "dusk").shadows === false) this.stack.lighting.setShadowMapSize(0);
|
|
155
|
+
const { w, h } = opts.ground;
|
|
156
|
+
let field: FieldSpec | null = null;
|
|
157
|
+
if (this.kit?.field && opts.field !== false) {
|
|
158
|
+
// A style that builds fields (voxel) lays the ground and the land past it.
|
|
159
|
+
field = { half: Math.max(w, h) / 2 + 1, wallHeight: 0.6, outside: 30, theme: opts.theme ?? "meadow", wall: false };
|
|
160
|
+
this.scene.add(...this.kit.field(field));
|
|
161
|
+
} else {
|
|
162
|
+
const session = this.stack.session;
|
|
163
|
+
const tex = session?.surface?.("ground", { tile: 8, mood: opts.theme ?? "meadow" }) ?? groundTexture(opts.groundColor ?? 0x4f6b3a);
|
|
164
|
+
const size = Math.max(w, h) + 120;
|
|
165
|
+
const tile = (tex.userData as { tile?: number }).tile ?? 8;
|
|
166
|
+
tex.repeat.set(size / tile, size / tile);
|
|
167
|
+
const ground = new Mesh(new PlaneGeometry(size, size), new MeshStandardMaterial({ map: tex, roughness: 1, metalness: 0 }));
|
|
168
|
+
ground.rotation.x = -Math.PI / 2;
|
|
169
|
+
ground.position.y = -0.02;
|
|
170
|
+
ground.receiveShadow = true;
|
|
171
|
+
ground.userData.surface = "ground";
|
|
172
|
+
this.scene.add(ground);
|
|
173
|
+
}
|
|
174
|
+
const spec = field;
|
|
175
|
+
this.groundAt = spec && this.kit?.groundHeight ? (x, z) => this.kit?.groundHeight?.(spec, x, z) ?? 0 : () => 0;
|
|
176
|
+
this.frameRect(0, 0, w, h);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Frame a w x h metre rectangle centred on (x, z). */
|
|
180
|
+
frameRect(x: number, z: number, w: number, h: number): void {
|
|
181
|
+
this.target.set(x, 0, z);
|
|
182
|
+
this.span = { w, h };
|
|
183
|
+
this.place();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Keep the frame's size, move its centre (a walking hero). */
|
|
187
|
+
follow(x: number, z: number, dt: number): void {
|
|
188
|
+
const k = 1 - Math.exp(-dt * 6);
|
|
189
|
+
this.target.x += (x - this.target.x) * k;
|
|
190
|
+
this.target.z += (z - this.target.z) * k;
|
|
191
|
+
this.place();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private place(): void {
|
|
195
|
+
const fov = (this.camera.fov * Math.PI) / 180;
|
|
196
|
+
// Distance so the span fits: height through the vertical fov (foreshortened by the pitch), width through the horizontal.
|
|
197
|
+
const vSpan = this.span.h * Math.sin(this.pitch) + 2;
|
|
198
|
+
const dV = vSpan / 2 / Math.tan(fov / 2);
|
|
199
|
+
const hFov = 2 * Math.atan(Math.tan(fov / 2) * this.aspect);
|
|
200
|
+
const dH = (this.span.w + 2) / 2 / Math.tan(hFov / 2);
|
|
201
|
+
const d = Math.max(dV, dH);
|
|
202
|
+
const j = this.shake > 0 ? this.shake * 0.25 : 0;
|
|
203
|
+
const jx = j ? (Math.sin(this.time * 61) * j) : 0;
|
|
204
|
+
const jz = j ? (Math.cos(this.time * 53) * j) : 0;
|
|
205
|
+
this.camera.position.set(this.target.x + jx, Math.sin(this.pitch) * d, this.target.z + Math.cos(this.pitch) * d + jz);
|
|
206
|
+
this.look.set(this.target.x + jx, 0, this.target.z + jz);
|
|
207
|
+
this.camera.lookAt(this.look);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resize(width: number, height: number): void {
|
|
211
|
+
this.aspect = Math.max(0.3, width / Math.max(1, height));
|
|
212
|
+
this.camera.aspect = this.aspect;
|
|
213
|
+
this.camera.updateProjectionMatrix();
|
|
214
|
+
this.stack.resize(width, height);
|
|
215
|
+
this.place();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The ground point under a client pixel, or null. */
|
|
219
|
+
pick(clientX: number, clientY: number, rect: DOMRect): { x: number; z: number } | null {
|
|
220
|
+
const ndc = new Vector2(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
|
|
221
|
+
this.raycaster.setFromCamera(ndc, this.camera);
|
|
222
|
+
const p = new Vector3();
|
|
223
|
+
return this.raycaster.ray.intersectPlane(this.plane, p) ? { x: p.x, z: p.z } : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Screen pixel of a world point (and whether it is in front of the camera). */
|
|
227
|
+
project(x: number, y: number, z: number, width: number, height: number): { x: number; y: number; visible: boolean } {
|
|
228
|
+
const v = new Vector3(x, y, z).project(this.camera);
|
|
229
|
+
return { x: (v.x * 0.5 + 0.5) * width, y: (-v.y * 0.5 + 0.5) * height, visible: v.z < 1 };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A static model by library id, standing `height` m tall: the fallback shape at once, the real model when it loads. */
|
|
233
|
+
model(id: string, height: number, color?: number, fit?: number): Placed {
|
|
234
|
+
const root = new Group();
|
|
235
|
+
const fb = fallbackShape(id, height, color);
|
|
236
|
+
const shape = new Mesh(fb.geometry, fb.material);
|
|
237
|
+
shape.castShadow = true;
|
|
238
|
+
root.add(shape);
|
|
239
|
+
const lib = this.opts.library;
|
|
240
|
+
const ready = lib
|
|
241
|
+
? lib.prop(id, undefined, { height }).then(
|
|
242
|
+
(m) => {
|
|
243
|
+
m.traverse((o) => {
|
|
244
|
+
if ((o as Mesh).isMesh) o.castShadow = true;
|
|
245
|
+
});
|
|
246
|
+
// Wide models (a flat rock) shrink to fit `fit` metres across.
|
|
247
|
+
if (fit !== undefined) {
|
|
248
|
+
const box = new Box3().setFromObject(m);
|
|
249
|
+
const across = Math.max(box.max.x - box.min.x, box.max.z - box.min.z);
|
|
250
|
+
if (across > fit) m.scale.multiplyScalar(fit / across);
|
|
251
|
+
}
|
|
252
|
+
root.remove(shape);
|
|
253
|
+
root.add(m);
|
|
254
|
+
this.counts.models++;
|
|
255
|
+
},
|
|
256
|
+
() => {
|
|
257
|
+
this.counts.failed++;
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
: Promise.resolve();
|
|
261
|
+
return { root, ready };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** An instanced crowd of one model (up to `capacity`): the fallback shape until the library's parts load. */
|
|
265
|
+
crowd(id: string, height: number, capacity: number, color?: number): Crowd {
|
|
266
|
+
const fb = fallbackShape(id, height, color);
|
|
267
|
+
let crowds: AnimatedCrowd[] = [new AnimatedCrowd(fb.geometry, fb.material as MeshStandardMaterial, capacity).addTo(this.scene)];
|
|
268
|
+
const lib = this.opts.library;
|
|
269
|
+
if (lib)
|
|
270
|
+
lib.crowd(id, undefined, { height }).then(
|
|
271
|
+
(c) => {
|
|
272
|
+
for (const old of crowds) old.dispose();
|
|
273
|
+
crowds = c.parts.map((p) => new AnimatedCrowd(p.geometry, p.material as MeshStandardMaterial, capacity).addTo(this.scene));
|
|
274
|
+
this.counts.models++;
|
|
275
|
+
},
|
|
276
|
+
() => {
|
|
277
|
+
this.counts.failed++;
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
return {
|
|
281
|
+
begin: () => {
|
|
282
|
+
for (const c of crowds) c.begin();
|
|
283
|
+
},
|
|
284
|
+
push: (x, y, z, yaw, phase, speed, hit = 0, flash = 0, scale = 1) => {
|
|
285
|
+
for (const c of crowds) c.push(x, y, z, yaw, phase, speed, hit, flash, scale);
|
|
286
|
+
},
|
|
287
|
+
end: (t) => {
|
|
288
|
+
for (const c of crowds) c.end(t);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
add(...o: Object3D[]): void {
|
|
294
|
+
this.scene.add(...o);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A burst of particles (preset "burst", "sparks", "magic", "kill", "dust"...). */
|
|
298
|
+
burst(preset: string, x: number, y: number, z: number, color?: number, count?: number): void {
|
|
299
|
+
this.stack.particles.burst(preset, x, y, z, { ...(color !== undefined ? { color } : {}), ...(count !== undefined ? { count } : {}) });
|
|
300
|
+
this.counts.bursts++;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** A shockwave ring on the ground. */
|
|
304
|
+
ring(x: number, z: number, radius: number, color: number): void {
|
|
305
|
+
this.stack.rings.fire(x, z, radius, { color });
|
|
306
|
+
this.counts.rings++;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Camera shake, 0..1. */
|
|
310
|
+
kick(amount: number): void {
|
|
311
|
+
this.shake = Math.min(1, this.shake + amount);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Draw one frame. */
|
|
315
|
+
frame(dt: number): void {
|
|
316
|
+
this.time += dt;
|
|
317
|
+
if (this.shake > 0) {
|
|
318
|
+
this.shake = Math.max(0, this.shake - dt * 2.5);
|
|
319
|
+
this.place();
|
|
320
|
+
}
|
|
321
|
+
this.stack.frame(dt, { x: this.target.x, y: 0, z: this.target.z });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
get renderer(): RendererHandle {
|
|
325
|
+
return this.handle;
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/stake.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buy-in run in the store panel (earn.stake, the mandatory sink loop).
|
|
3
|
+
* The player picks an amount inside the game's terms and stakes it: the Kit
|
|
4
|
+
* spends it under the reason "stake" (earned coins first, then the wallet),
|
|
5
|
+
* then the room is told. The room fixes the stake from its own ledger when
|
|
6
|
+
* the next run starts (or this one, if it only just began) and says so.
|
|
7
|
+
*/
|
|
8
|
+
import type { PotMessage, StakeMessage, StakeTermsView } from "@netforge/engine/net";
|
|
9
|
+
import type { KitStoreBridge } from "./kit.js";
|
|
10
|
+
|
|
11
|
+
export interface StakeConn {
|
|
12
|
+
readonly welcome: { readonly stake?: StakeTermsView };
|
|
13
|
+
stake(amount?: number): void;
|
|
14
|
+
onStake(cb: (m: StakeMessage) => void): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const fmt = (n: number) => (Math.round(n * 100) / 100).toLocaleString("en-US");
|
|
18
|
+
|
|
19
|
+
/** Wires #stake-amount, #stake-go and #stake-line; hides #stake-row when the game takes no stakes. */
|
|
20
|
+
export function wireStake(conn: StakeConn, kit: KitStoreBridge, toast: (t: string) => void): void {
|
|
21
|
+
const row = document.getElementById("stake-row");
|
|
22
|
+
const input = document.getElementById("stake-amount") as HTMLInputElement | null;
|
|
23
|
+
const go = document.getElementById("stake-go") as HTMLButtonElement | null;
|
|
24
|
+
const line = document.getElementById("stake-line");
|
|
25
|
+
const terms = conn.welcome.stake;
|
|
26
|
+
if (!row || !input || !go || !line) return;
|
|
27
|
+
if (!terms) {
|
|
28
|
+
row.style.display = "none";
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const sym = kit.coins.symbol();
|
|
32
|
+
input.min = String(terms.min);
|
|
33
|
+
input.max = String(terms.max);
|
|
34
|
+
input.step = "any";
|
|
35
|
+
input.value = String(terms.min);
|
|
36
|
+
const idle = () => {
|
|
37
|
+
const a = Number(input.value) || terms.min;
|
|
38
|
+
line.textContent = `Up to ${fmt(a * terms.multiple)} ${sym} back`;
|
|
39
|
+
};
|
|
40
|
+
idle();
|
|
41
|
+
input.addEventListener("input", idle);
|
|
42
|
+
go.addEventListener("click", async () => {
|
|
43
|
+
const a = Number(input.value);
|
|
44
|
+
if (!(a >= terms.min && a <= terms.max)) return toast(`Stake ${fmt(terms.min)} to ${fmt(terms.max)} ${sym}`);
|
|
45
|
+
go.disabled = true;
|
|
46
|
+
try {
|
|
47
|
+
await kit.coins.spend(a, "stake");
|
|
48
|
+
conn.stake(a);
|
|
49
|
+
} catch {
|
|
50
|
+
toast(`Not enough ${sym}`);
|
|
51
|
+
} finally {
|
|
52
|
+
go.disabled = false;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
conn.onStake((m) => {
|
|
56
|
+
if (m.state === "live") line.textContent = `Staked ${fmt(m.stake)}, up to ${fmt(m.ceiling ?? 0)} ${sym} this run`;
|
|
57
|
+
else if (m.state === "waiting") line.textContent = m.stake > 0 ? `Next run: ${fmt(m.stake)} ${sym}` : "Stake waits for tomorrow's cap";
|
|
58
|
+
else if (m.state === "closed") {
|
|
59
|
+
toast(`Stake ${fmt(m.stake)} ${sym} ${fmt(m.paid ?? 0)} back${m.lost && m.lost.length > 0 ? ` · gear lost` : ""}`);
|
|
60
|
+
idle();
|
|
61
|
+
} else toast(m.reason === "sign in to stake" ? "Sign in to stake" : "Stake not taken");
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One losable gear SKU (store.sku grants { gear, loseBelow }): a buy button and its loss line. Hides #gear-row without one. */
|
|
66
|
+
export function wireGear(gear: { id: string; name: string; coins: number; loseBelow: number } | null, kit: KitStoreBridge, toast: (t: string) => void): void {
|
|
67
|
+
const row = document.getElementById("gear-row");
|
|
68
|
+
const name = document.getElementById("gear-name");
|
|
69
|
+
const line = document.getElementById("gear-line");
|
|
70
|
+
const buy = document.getElementById("gear-buy") as HTMLButtonElement | null;
|
|
71
|
+
if (!row || !name || !line || !buy) return;
|
|
72
|
+
if (!gear) {
|
|
73
|
+
row.style.display = "none";
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const sym = kit.coins.symbol();
|
|
77
|
+
name.textContent = gear.name;
|
|
78
|
+
line.textContent = `Staked runs only. Lost under ${Math.round(gear.loseBelow * 100)}% of a run's earn`;
|
|
79
|
+
const show = () => {
|
|
80
|
+
buy.textContent = kit.store.owns(gear.id) ? "Owned" : `${gear.coins} ${sym}`;
|
|
81
|
+
buy.disabled = kit.store.owns(gear.id);
|
|
82
|
+
};
|
|
83
|
+
show();
|
|
84
|
+
buy.addEventListener("click", async () => {
|
|
85
|
+
try {
|
|
86
|
+
await kit.store.buy(gear.id, { pay: "coins" });
|
|
87
|
+
} catch {
|
|
88
|
+
toast(`Not enough ${sym}`);
|
|
89
|
+
}
|
|
90
|
+
show();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The game's first gear SKU from its manifest, with its label. */
|
|
95
|
+
export function gearOf(skus: ReadonlyMap<string, { coins?: number; grants?: unknown }>, names: Readonly<Record<string, string>>): { id: string; name: string; coins: number; loseBelow: number } | null {
|
|
96
|
+
for (const [id, s] of skus) {
|
|
97
|
+
const g = s.grants as { gear?: string; loseBelow?: number } | undefined;
|
|
98
|
+
if (g?.gear && typeof g.loseBelow === "number" && s.coins !== undefined) return { id, name: names[id] ?? id, coins: s.coins, loseBelow: g.loseBelow };
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface PotConn {
|
|
104
|
+
readonly welcome: { readonly pot?: { readonly entry: number; readonly recycle: number } };
|
|
105
|
+
pot(): void;
|
|
106
|
+
onPot(cb: (m: PotMessage) => void): void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The round's PvP pot: #pot-go pays the entry and tells the room; #pot-line shows the pot. Hides #pot-row without one. */
|
|
110
|
+
export function wirePot(conn: PotConn, kit: KitStoreBridge, toast: (t: string) => void): void {
|
|
111
|
+
const row = document.getElementById("pot-row");
|
|
112
|
+
const go = document.getElementById("pot-go") as HTMLButtonElement | null;
|
|
113
|
+
const line = document.getElementById("pot-line");
|
|
114
|
+
const pot = conn.welcome.pot;
|
|
115
|
+
if (!row || !go || !line) return;
|
|
116
|
+
if (!pot) {
|
|
117
|
+
row.style.display = "none";
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const sym = kit.coins.symbol();
|
|
121
|
+
const idle = `${fmt(pot.entry)} ${sym} in. The round splits it`;
|
|
122
|
+
line.textContent = idle;
|
|
123
|
+
go.textContent = `${fmt(pot.entry)} ${sym}`;
|
|
124
|
+
go.addEventListener("click", async () => {
|
|
125
|
+
go.disabled = true;
|
|
126
|
+
try {
|
|
127
|
+
await kit.coins.spend(pot.entry, "pot");
|
|
128
|
+
conn.pot();
|
|
129
|
+
} catch {
|
|
130
|
+
toast(`Not enough ${sym}`);
|
|
131
|
+
} finally {
|
|
132
|
+
go.disabled = false;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
conn.onPot((m) => {
|
|
136
|
+
if (m.state === "in") line.textContent = `In. Pot ${fmt(m.pot ?? m.entry)} ${sym}`;
|
|
137
|
+
else if (m.state === "waiting") line.textContent = "In the next round";
|
|
138
|
+
else if (m.state === "closed") {
|
|
139
|
+
toast(`Pot ${fmt(m.pot ?? 0)} ${sym} ${fmt(m.paid ?? 0)} to you`);
|
|
140
|
+
line.textContent = idle;
|
|
141
|
+
} else toast(m.reason === "sign in to enter" ? "Sign in to enter" : "Not entered");
|
|
142
|
+
});
|
|
143
|
+
}
|