@netforge/template-dungeon 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +74 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +35 -0
- package/README.md +84 -2
- package/client/crypt.ts +274 -0
- package/client/debris.ts +267 -0
- package/client/decor.ts +13 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +577 -0
- package/client/icons.ts +42 -0
- package/client/index.html +277 -0
- package/client/input.ts +172 -0
- package/client/library.ts +250 -0
- package/client/main.ts +607 -0
- package/client/names.ts +55 -0
- package/client/palette.ts +7 -0
- package/client/scene.ts +941 -0
- package/client/stake.ts +101 -0
- package/client/threats.ts +157 -0
- package/llms-full.txt +790 -0
- package/llms.txt +398 -0
- package/package.json +83 -3
- package/src/game.ts +328 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +107 -0
package/client/debris.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side physics dressing on engine-sim's PhysicsWorld (Rapier, the
|
|
3
|
+
* same build the server runs): chunks that fly off killed enemies and
|
|
4
|
+
* bounce off the floor, walls and pillars, and a scatter of rocks and stumps
|
|
5
|
+
* the keepers shove around and novas blast away.
|
|
6
|
+
*
|
|
7
|
+
* Presentation only. None of it feeds back into the authoritative sim, so
|
|
8
|
+
* it never needs to match the server or other players exactly; every
|
|
9
|
+
* client drives the props from the same interpolated keeper positions, so
|
|
10
|
+
* what they see agrees closely anyway.
|
|
11
|
+
*/
|
|
12
|
+
import { Color, InstancedMesh, Matrix4, MeshStandardMaterial, Quaternion, Vector3 } from "three/webgpu";
|
|
13
|
+
import type { BufferGeometry, Material, Scene } from "three/webgpu";
|
|
14
|
+
import { CylinderGeometry, DodecahedronGeometry } from "three/webgpu";
|
|
15
|
+
import { Layer, createPhysics } from "@netforge/engine-sim";
|
|
16
|
+
import type { BodyId, PhysicsWorld, ResolvedSimConfig } from "@netforge/engine-sim";
|
|
17
|
+
|
|
18
|
+
const HZ = 60;
|
|
19
|
+
const MAX_CHUNKS = 220;
|
|
20
|
+
const MAX_STEPS = 4;
|
|
21
|
+
const CHUNK_LIFE = 2.2;
|
|
22
|
+
|
|
23
|
+
interface Chunk {
|
|
24
|
+
id: BodyId;
|
|
25
|
+
life: number;
|
|
26
|
+
max: number;
|
|
27
|
+
size: number;
|
|
28
|
+
color: Color;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Prop {
|
|
32
|
+
id: BodyId;
|
|
33
|
+
barrel: boolean;
|
|
34
|
+
/** Last speed, for impact thuds. */
|
|
35
|
+
speed: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Seeded PRNG so every client scatters the props in the same places. */
|
|
39
|
+
function rng(seed: number): () => number {
|
|
40
|
+
let s = seed >>> 0;
|
|
41
|
+
return () => {
|
|
42
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
43
|
+
let t = s;
|
|
44
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
45
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
46
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An art style's own drawing of a debris shape (the voxel style's cube chunks, rocks and stumps); null keeps the primitive. */
|
|
51
|
+
export type DebrisShape = (name: "chunk" | "rock" | "stump") => { readonly geometry: BufferGeometry; readonly material: Material } | null;
|
|
52
|
+
|
|
53
|
+
function instanced(geometry: BufferGeometry, color: number, capacity: number, emissive = 0x000000, styled?: ReturnType<DebrisShape>): InstancedMesh {
|
|
54
|
+
const material = styled?.material ?? new MeshStandardMaterial({ color, roughness: 0.75, flatShading: true, emissive, emissiveIntensity: 0.6 });
|
|
55
|
+
const mesh = new InstancedMesh(styled?.geometry ?? geometry, material, capacity);
|
|
56
|
+
mesh.count = 0;
|
|
57
|
+
mesh.frustumCulled = false;
|
|
58
|
+
mesh.castShadow = true;
|
|
59
|
+
mesh.receiveShadow = true;
|
|
60
|
+
const white = new Color(1, 1, 1);
|
|
61
|
+
for (let i = 0; i < capacity; i++) mesh.setColorAt(i, white);
|
|
62
|
+
return mesh;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class PhysicsDressing {
|
|
66
|
+
readonly physics: PhysicsWorld;
|
|
67
|
+
private readonly chunks: Chunk[] = [];
|
|
68
|
+
private readonly props: Prop[] = [];
|
|
69
|
+
private readonly keepers = new Map<number, BodyId>();
|
|
70
|
+
private readonly chunkMesh: InstancedMesh;
|
|
71
|
+
private readonly crateMesh: InstancedMesh;
|
|
72
|
+
private readonly barrelMesh: InstancedMesh;
|
|
73
|
+
private acc = 0;
|
|
74
|
+
private readonly m = new Matrix4();
|
|
75
|
+
private readonly q = new Quaternion();
|
|
76
|
+
private readonly p = new Vector3();
|
|
77
|
+
private readonly s = new Vector3();
|
|
78
|
+
private readonly random = rng(0xe3b1);
|
|
79
|
+
/** Impacts this frame (world position and speed), for thud sounds. */
|
|
80
|
+
readonly impacts: { x: number; y: number; z: number; speed: number }[] = [];
|
|
81
|
+
/** Effects fired so far (playtest checks). */
|
|
82
|
+
readonly counts = { chunks: 0, blasts: 0 };
|
|
83
|
+
|
|
84
|
+
constructor(scene: Scene, config: ResolvedSimConfig, opts: { props?: boolean; shape?: DebrisShape } = {}) {
|
|
85
|
+
this.physics = createPhysics({ gravity: [0, -24, 0], tickRate: HZ });
|
|
86
|
+
const P = this.physics;
|
|
87
|
+
const h = config.arena.halfExtent;
|
|
88
|
+
const wall = config.arena.wallHeight;
|
|
89
|
+
P.addStaticBox([h + 1, 0.5, h + 1], [0, -0.5, 0], { friction: 0.8, layer: Layer.Level });
|
|
90
|
+
P.addStaticBox([h + 1, wall, 0.5], [0, wall, h + 0.5], { layer: Layer.Level });
|
|
91
|
+
P.addStaticBox([h + 1, wall, 0.5], [0, wall, -h - 0.5], { layer: Layer.Level });
|
|
92
|
+
P.addStaticBox([0.5, wall, h + 1], [h + 0.5, wall, 0], { layer: Layer.Level });
|
|
93
|
+
P.addStaticBox([0.5, wall, h + 1], [-h - 0.5, wall, 0], { layer: Layer.Level });
|
|
94
|
+
for (const pl of config.arena.pillars) {
|
|
95
|
+
P.addBody({ type: "static", shape: { cylinder: [pl.height / 2, pl.radius] }, position: [pl.x, pl.height / 2, pl.z], layer: Layer.Level, friction: 0.6 });
|
|
96
|
+
}
|
|
97
|
+
// The crypt's walls stop rubble and chunks too.
|
|
98
|
+
for (const w of config.arena.walls ?? []) {
|
|
99
|
+
P.addStaticBox([w.hx, wall / 2, w.hz], [w.x, wall / 2, w.z], { layer: Layer.Level, friction: 0.6 });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Props: crates and barrels around the pillars and in the open, the same scatter on every client.
|
|
103
|
+
const props = rng(0x51f7);
|
|
104
|
+
const spots: [number, number][] = [];
|
|
105
|
+
for (const pl of config.arena.pillars) {
|
|
106
|
+
for (let k = 0; k < 3; k++) {
|
|
107
|
+
const a = props() * Math.PI * 2;
|
|
108
|
+
const d = pl.radius + 1.2 + props() * 1.5;
|
|
109
|
+
spots.push([pl.x + Math.cos(a) * d, pl.z + Math.sin(a) * d]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Loose rubble in the rooms (never in the rock between them): two or three per room, off its middle.
|
|
113
|
+
for (const f of config.arena.floors ?? []) {
|
|
114
|
+
if (f.hx < 4 || f.hz < 4) continue;
|
|
115
|
+
const n = 2 + Math.floor(props() * 2);
|
|
116
|
+
for (let k = 0; k < n; k++) {
|
|
117
|
+
const x = f.x + (props() * 2 - 1) * (f.hx - 1.2);
|
|
118
|
+
const z = f.z + (props() * 2 - 1) * (f.hz - 1.2);
|
|
119
|
+
if (Math.abs(x - f.x) < 2.5 && Math.abs(z - f.z) < 2.5) continue;
|
|
120
|
+
spots.push([x, z]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (opts.props === false) spots.length = 0;
|
|
124
|
+
for (const [x, z] of spots) {
|
|
125
|
+
const barrel = props() < 0.4;
|
|
126
|
+
const yaw = props() * Math.PI;
|
|
127
|
+
const id = P.addBody({
|
|
128
|
+
type: "dynamic",
|
|
129
|
+
shape: barrel ? { cylinder: [0.45, 0.35] } : { box: [0.4, 0.4, 0.4] },
|
|
130
|
+
position: [x, barrel ? 0.45 : 0.4, z],
|
|
131
|
+
rotation: [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)],
|
|
132
|
+
density: 40,
|
|
133
|
+
friction: 0.7,
|
|
134
|
+
restitution: 0.15,
|
|
135
|
+
linearDamping: 0.4,
|
|
136
|
+
angularDamping: 0.6,
|
|
137
|
+
layer: Layer.Prop,
|
|
138
|
+
mask: Layer.Level | Layer.Prop | Layer.Player | Layer.Debris,
|
|
139
|
+
});
|
|
140
|
+
this.props.push({ id, barrel, speed: 0 });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.chunkMesh = instanced(new DodecahedronGeometry(1, 0), 0xffffff, MAX_CHUNKS, 0, opts.shape?.("chunk"));
|
|
144
|
+
// One family with the field's stone and the Nature Kit stumps: loose rocks and cut stumps, not crates and barrels.
|
|
145
|
+
this.crateMesh = instanced(new DodecahedronGeometry(0.5), 0x5e5246, Math.max(1, this.props.length), 0, opts.shape?.("rock"));
|
|
146
|
+
this.barrelMesh = instanced(new CylinderGeometry(0.33, 0.38, 0.9, 7), 0x4e3a28, Math.max(1, this.props.length), 0, opts.shape?.("stump"));
|
|
147
|
+
scene.add(this.chunkMesh, this.crateMesh, this.barrelMesh);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Keepers push props: one kinematic capsule per player, moved to where it is drawn. */
|
|
151
|
+
keeper(owner: number, x: number, y: number, z: number, up: boolean): void {
|
|
152
|
+
let id = this.keepers.get(owner);
|
|
153
|
+
if (id === undefined) {
|
|
154
|
+
id = this.physics.addBody({ type: "kinematic", shape: { capsule: [0.5, 0.4] }, position: [x, y, z], layer: Layer.Player, mask: Layer.Prop | Layer.Debris });
|
|
155
|
+
this.keepers.set(owner, id);
|
|
156
|
+
}
|
|
157
|
+
this.physics.setKinematicTarget(id, [x, up ? y : -5, z]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Forget keepers not seen this frame. */
|
|
161
|
+
keepOnly(owners: ReadonlySet<number>): void {
|
|
162
|
+
for (const [owner, id] of this.keepers) {
|
|
163
|
+
if (owners.has(owner)) continue;
|
|
164
|
+
this.physics.removeBody(id);
|
|
165
|
+
this.keepers.delete(owner);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Chunks off a killed enemy, thrown along the push direction. */
|
|
170
|
+
shatter(x: number, z: number, radius: number, color: Color, count: number, dirX = 0, dirZ = 0): void {
|
|
171
|
+
const P = this.physics;
|
|
172
|
+
this.counts.chunks += count;
|
|
173
|
+
for (let i = 0; i < count; i++) {
|
|
174
|
+
if (this.chunks.length >= MAX_CHUNKS) this.retire(0);
|
|
175
|
+
const size = radius * (0.22 + this.random() * 0.25);
|
|
176
|
+
const a = this.random() * Math.PI * 2;
|
|
177
|
+
const sp = 2 + this.random() * 3.5;
|
|
178
|
+
const id = P.addBody({
|
|
179
|
+
type: "dynamic",
|
|
180
|
+
shape: { box: [size, size, size] },
|
|
181
|
+
position: [x + Math.cos(a) * radius * 0.5, radius + 0.2 + this.random() * radius, z + Math.sin(a) * radius * 0.5],
|
|
182
|
+
density: 8,
|
|
183
|
+
friction: 0.6,
|
|
184
|
+
restitution: 0.35,
|
|
185
|
+
angularDamping: 0.3,
|
|
186
|
+
linearVelocity: [Math.cos(a) * sp + dirX * 5, 3.5 + this.random() * 4, Math.sin(a) * sp + dirZ * 5],
|
|
187
|
+
angularVelocity: [(this.random() - 0.5) * 18, (this.random() - 0.5) * 18, (this.random() - 0.5) * 18],
|
|
188
|
+
layer: Layer.Debris,
|
|
189
|
+
mask: Layer.Level | Layer.Prop,
|
|
190
|
+
});
|
|
191
|
+
const life = CHUNK_LIFE * (0.7 + this.random() * 0.6);
|
|
192
|
+
this.chunks.push({ id, life, max: life, size, color });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A blast (nova) shoves props and chunks out of `radius`. */
|
|
197
|
+
blast(x: number, z: number, radius: number, strength: number): void {
|
|
198
|
+
const P = this.physics;
|
|
199
|
+
this.counts.blasts++;
|
|
200
|
+
const hits = P.overlap({ cylinder: [1.5, radius] }, [x, 1.5, z], { mask: Layer.Prop | Layer.Debris });
|
|
201
|
+
for (const id of hits) P.applyKnockback(id, [x, 0, z], strength, { lift: strength * 0.35 });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private retire(i: number): void {
|
|
205
|
+
const c = this.chunks[i];
|
|
206
|
+
if (!c) return;
|
|
207
|
+
this.physics.removeBody(c.id);
|
|
208
|
+
this.chunks[i] = this.chunks[this.chunks.length - 1] as Chunk;
|
|
209
|
+
this.chunks.pop();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
update(dt: number): void {
|
|
213
|
+
this.impacts.length = 0;
|
|
214
|
+
this.acc = Math.min(this.acc + dt, MAX_STEPS / HZ);
|
|
215
|
+
while (this.acc >= 1 / HZ) {
|
|
216
|
+
this.acc -= 1 / HZ;
|
|
217
|
+
this.physics.step();
|
|
218
|
+
}
|
|
219
|
+
for (let i = this.chunks.length - 1; i >= 0; i--) {
|
|
220
|
+
const c = this.chunks[i] as Chunk;
|
|
221
|
+
c.life -= dt;
|
|
222
|
+
if (c.life <= 0) this.retire(i);
|
|
223
|
+
}
|
|
224
|
+
// Draw.
|
|
225
|
+
const P = this.physics;
|
|
226
|
+
let n = 0;
|
|
227
|
+
for (const c of this.chunks) {
|
|
228
|
+
const [x, y, z] = P.position(c.id);
|
|
229
|
+
const [qx, qy, qz, qw] = P.rotation(c.id);
|
|
230
|
+
const k = Math.min(1, c.life / (c.max * 0.35));
|
|
231
|
+
this.p.set(x, y, z);
|
|
232
|
+
this.q.set(qx, qy, qz, qw);
|
|
233
|
+
this.s.setScalar(c.size * 1.25 * k);
|
|
234
|
+
this.m.compose(this.p, this.q, this.s);
|
|
235
|
+
this.chunkMesh.setMatrixAt(n, this.m);
|
|
236
|
+
this.chunkMesh.setColorAt(n, c.color);
|
|
237
|
+
n++;
|
|
238
|
+
}
|
|
239
|
+
this.chunkMesh.count = n;
|
|
240
|
+
this.chunkMesh.instanceMatrix.needsUpdate = true;
|
|
241
|
+
if (this.chunkMesh.instanceColor) this.chunkMesh.instanceColor.needsUpdate = true;
|
|
242
|
+
let crates = 0;
|
|
243
|
+
let barrels = 0;
|
|
244
|
+
this.s.setScalar(1);
|
|
245
|
+
for (const prop of this.props) {
|
|
246
|
+
const [x, y, z] = P.position(prop.id);
|
|
247
|
+
const [vx, vy, vz] = P.velocity(prop.id);
|
|
248
|
+
const speed = Math.hypot(vx, vy, vz);
|
|
249
|
+
if (prop.speed - speed > 3) this.impacts.push({ x, y, z, speed: prop.speed - speed });
|
|
250
|
+
prop.speed = speed;
|
|
251
|
+
const [qx, qy, qz, qw] = P.rotation(prop.id);
|
|
252
|
+
this.p.set(x, y, z);
|
|
253
|
+
this.q.set(qx, qy, qz, qw);
|
|
254
|
+
this.m.compose(this.p, this.q, this.s);
|
|
255
|
+
if (prop.barrel) this.barrelMesh.setMatrixAt(barrels++, this.m);
|
|
256
|
+
else this.crateMesh.setMatrixAt(crates++, this.m);
|
|
257
|
+
}
|
|
258
|
+
this.crateMesh.count = crates;
|
|
259
|
+
this.barrelMesh.count = barrels;
|
|
260
|
+
this.crateMesh.instanceMatrix.needsUpdate = true;
|
|
261
|
+
this.barrelMesh.instanceMatrix.needsUpdate = true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
get liveChunks(): number {
|
|
265
|
+
return this.chunks.length;
|
|
266
|
+
}
|
|
267
|
+
}
|
package/client/decor.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library models the CRYPTWICK scene draws on its own (template machinery,
|
|
3
|
+
* not game content). The crypt draws its walls, floors and candles itself.
|
|
4
|
+
* The build copies these with the models game.ts uses.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Enemy models are drawn about this many hit radii tall. */
|
|
8
|
+
export const ENEMY_HEIGHT_PER_RADIUS = 3.2;
|
|
9
|
+
/** Keepers are drawn this tall, m. */
|
|
10
|
+
export const KEEPER_HEIGHT = 1.8;
|
|
11
|
+
|
|
12
|
+
/** The crypt draws its own walls, floors and candles: no library dressing of its own. */
|
|
13
|
+
export const SCENE_MODELS: readonly string[] = [];
|
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
|
+
}
|