@netforge/template-party 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 +63 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +74 -2
- package/client/debris.ts +267 -0
- package/client/decor.ts +13 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +576 -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 +47 -0
- package/client/palette.ts +7 -0
- package/client/scene.ts +941 -0
- package/client/stage.ts +266 -0
- package/client/stake.ts +101 -0
- package/client/threats.ts +157 -0
- package/llms-full.txt +716 -0
- package/llms.txt +375 -0
- package/package.json +82 -3
- package/src/game.ts +326 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +107 -0
package/client/stage.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playroom as drawn: a checkered dance floor on the room's floor
|
|
3
|
+
* (game.ts field.floors), striped party wallpaper on every wall box
|
|
4
|
+
* (field.walls) and a ring of coloured party lights along the walls. One
|
|
5
|
+
* merged mesh for all the walls and one for each floor, so the room is a
|
|
6
|
+
* handful of draws.
|
|
7
|
+
*
|
|
8
|
+
* The whole room is lit from the start (a party has no fog of war). An
|
|
9
|
+
* x-ray cut keeps a wall from hiding your player.
|
|
10
|
+
*
|
|
11
|
+
* An art style that paints surfaces (ps1's textures) paints these too
|
|
12
|
+
* (surface roles "floor", "wall"); the toon, pixel, voxel and hand-drawn
|
|
13
|
+
* styles reshade them in post like everything else.
|
|
14
|
+
*/
|
|
15
|
+
import { BufferAttribute, BufferGeometry, CanvasTexture, Color, CylinderGeometry, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, MeshStandardNodeMaterial, PlaneGeometry, RepeatWrapping, SphereGeometry, SRGBColorSpace, Vector3 } from "three/webgpu";
|
|
16
|
+
import { clamp, dot, float, length, positionWorld, select, uniform } from "three/tsl";
|
|
17
|
+
import type { Texture } from "three/webgpu";
|
|
18
|
+
import type { WallBox } from "@netforge/engine-sim";
|
|
19
|
+
|
|
20
|
+
/** Walls are drawn this tall (the sim's are 3 m): low enough that a wall south of you hides less of the room. */
|
|
21
|
+
export const WALL_DRAW_HEIGHT = 2.2;
|
|
22
|
+
/** Party lights hang this high along the walls, under the wall tops. */
|
|
23
|
+
const SCONCE_HEIGHT = 1.75;
|
|
24
|
+
/** Brightness of a floor nobody has walked into yet, and of one a wickbearer has. */
|
|
25
|
+
const UNSEEN = 0.2;
|
|
26
|
+
const LIT = 0.85;
|
|
27
|
+
|
|
28
|
+
function hash01(n: number): number {
|
|
29
|
+
let t = (n + 0x6d2b79f5) >>> 0;
|
|
30
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
31
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
32
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A checkered dance floor, drawn on a canvas (one repeat covers 4 m: 4 x 4 checks of 1 m). */
|
|
36
|
+
function danceFloor(): CanvasTexture {
|
|
37
|
+
const c = document.createElement("canvas");
|
|
38
|
+
c.width = c.height = 256;
|
|
39
|
+
const g = c.getContext("2d");
|
|
40
|
+
if (g) {
|
|
41
|
+
// Soft checks (dark plum and dusty pink, light lilac and blush): busy enough to read as a dance floor, quiet enough for the HUD over it.
|
|
42
|
+
const tones = ["#4a3670", "#b9a3d9", "#5a3463", "#d3a3c4"];
|
|
43
|
+
for (let j = 0; j < 4; j++) {
|
|
44
|
+
for (let i = 0; i < 4; i++) {
|
|
45
|
+
const dark = (i + j) % 2 === 0;
|
|
46
|
+
g.fillStyle = tones[(dark ? 0 : 1) + (hash01(j * 4 + i) < 0.5 ? 0 : 2)] ?? "#3a2a5c";
|
|
47
|
+
g.fillRect(i * 64, j * 64, 64, 64);
|
|
48
|
+
g.strokeStyle = "rgba(0, 0, 0, 0.18)";
|
|
49
|
+
g.strokeRect(i * 64 + 1, j * 64 + 1, 62, 62);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
g.fillStyle = "rgba(255, 255, 255, 0.06)";
|
|
53
|
+
for (let n = 0; n < 200; n++) g.fillRect(hash01(n * 3) * 256, hash01(n * 3 + 1) * 256, 2, 2);
|
|
54
|
+
}
|
|
55
|
+
const t = new CanvasTexture(c);
|
|
56
|
+
t.wrapS = t.wrapT = RepeatWrapping;
|
|
57
|
+
t.colorSpace = SRGBColorSpace;
|
|
58
|
+
t.userData = { tile: 4 };
|
|
59
|
+
return t;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Party wallpaper: candy stripes (one repeat covers 2 m). */
|
|
63
|
+
function wallpaper(): CanvasTexture {
|
|
64
|
+
const c = document.createElement("canvas");
|
|
65
|
+
c.width = c.height = 128;
|
|
66
|
+
const g = c.getContext("2d");
|
|
67
|
+
if (g) {
|
|
68
|
+
for (let i = 0; i < 8; i++) {
|
|
69
|
+
g.fillStyle = i % 2 === 0 ? "#ffb3d6" : "#fff2c9";
|
|
70
|
+
g.fillRect(i * 16, 0, 16, 128);
|
|
71
|
+
}
|
|
72
|
+
g.fillStyle = "rgba(122, 215, 255, 0.55)";
|
|
73
|
+
g.fillRect(0, 96, 128, 10);
|
|
74
|
+
}
|
|
75
|
+
const t = new CanvasTexture(c);
|
|
76
|
+
t.wrapS = t.wrapT = RepeatWrapping;
|
|
77
|
+
t.colorSpace = SRGBColorSpace;
|
|
78
|
+
t.userData = { tile: 2 };
|
|
79
|
+
return t;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Boxes as one geometry with world-space UVs (so a texture tiles at the
|
|
84
|
+
* same scale on a 1 m stub and a 40 m run). `h` is the height; the floor
|
|
85
|
+
* is y = 0.
|
|
86
|
+
*/
|
|
87
|
+
function boxesGeometry(boxes: readonly WallBox[], h: number, tile: number): BufferGeometry {
|
|
88
|
+
const pos: number[] = [];
|
|
89
|
+
const nor: number[] = [];
|
|
90
|
+
const uv: number[] = [];
|
|
91
|
+
const idx: number[] = [];
|
|
92
|
+
const quad = (a: number[], b: number[], c: number[], d: number[], n: number[], uvOf: (p: number[]) => [number, number]) => {
|
|
93
|
+
const base = pos.length / 3;
|
|
94
|
+
for (const p of [a, b, c, d]) {
|
|
95
|
+
pos.push(p[0] ?? 0, p[1] ?? 0, p[2] ?? 0);
|
|
96
|
+
nor.push(n[0] ?? 0, n[1] ?? 0, n[2] ?? 0);
|
|
97
|
+
const [u, v] = uvOf(p);
|
|
98
|
+
uv.push(u / tile, v / tile);
|
|
99
|
+
}
|
|
100
|
+
idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
|
|
101
|
+
};
|
|
102
|
+
for (const b of boxes) {
|
|
103
|
+
const x0 = b.x - b.hx;
|
|
104
|
+
const x1 = b.x + b.hx;
|
|
105
|
+
const z0 = b.z - b.hz;
|
|
106
|
+
const z1 = b.z + b.hz;
|
|
107
|
+
const xz = (p: number[]): [number, number] => [p[0] ?? 0, p[2] ?? 0];
|
|
108
|
+
const xy = (p: number[]): [number, number] => [p[0] ?? 0, p[1] ?? 0];
|
|
109
|
+
const zy = (p: number[]): [number, number] => [p[2] ?? 0, p[1] ?? 0];
|
|
110
|
+
quad([x0, h, z1], [x1, h, z1], [x1, h, z0], [x0, h, z0], [0, 1, 0], xz);
|
|
111
|
+
quad([x0, 0, z1], [x1, 0, z1], [x1, h, z1], [x0, h, z1], [0, 0, 1], xy);
|
|
112
|
+
quad([x1, 0, z0], [x0, 0, z0], [x0, h, z0], [x1, h, z0], [0, 0, -1], xy);
|
|
113
|
+
quad([x1, 0, z1], [x1, 0, z0], [x1, h, z0], [x1, h, z1], [1, 0, 0], zy);
|
|
114
|
+
quad([x0, 0, z0], [x0, 0, z1], [x0, h, z1], [x0, h, z0], [-1, 0, 0], zy);
|
|
115
|
+
}
|
|
116
|
+
const g = new BufferGeometry();
|
|
117
|
+
g.setAttribute("position", new BufferAttribute(new Float32Array(pos), 3));
|
|
118
|
+
g.setAttribute("normal", new BufferAttribute(new Float32Array(nor), 3));
|
|
119
|
+
g.setAttribute("uv", new BufferAttribute(new Float32Array(uv), 2));
|
|
120
|
+
g.setIndex(idx);
|
|
121
|
+
g.computeBoundingSphere();
|
|
122
|
+
return g;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The x-ray cut: wall fragments near the line from the camera to your
|
|
127
|
+
* wickbearer (and in front of them) are not drawn, so a wall south of you
|
|
128
|
+
* never hides you. Works on the node material every backend draws, and the
|
|
129
|
+
* art styles carry it over (they copy opacity nodes and the alpha test).
|
|
130
|
+
*/
|
|
131
|
+
export class WallCut {
|
|
132
|
+
readonly camera = uniform(new Vector3(0, 20, 20));
|
|
133
|
+
readonly hero = uniform(new Vector3(0, 1, 0));
|
|
134
|
+
/** Radius of the cut, m. */
|
|
135
|
+
readonly radius = uniform(1.6);
|
|
136
|
+
|
|
137
|
+
/** A wall material with the cut in it. */
|
|
138
|
+
material(color: number, map: Texture | null, roughness: number): MeshStandardNodeMaterial {
|
|
139
|
+
const m = new MeshStandardNodeMaterial({ color, roughness, flatShading: true });
|
|
140
|
+
if (map) m.map = map;
|
|
141
|
+
const c = this.camera;
|
|
142
|
+
const seg = this.hero.sub(c);
|
|
143
|
+
const p = positionWorld;
|
|
144
|
+
const t = clamp(dot(p.sub(c), seg).div(dot(seg, seg)), 0, 1);
|
|
145
|
+
const d = length(p.sub(c.add(seg.mul(t))));
|
|
146
|
+
// Cut: close to the sight line, before the hero, and above the wall's foot (the base still outlines the room).
|
|
147
|
+
const cut = d.lessThan(this.radius).and(t.lessThan(0.97)).and(p.y.greaterThan(0.12));
|
|
148
|
+
m.opacityNode = select(cut, float(0), float(1));
|
|
149
|
+
m.alphaTest = 0.5;
|
|
150
|
+
return m;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
update(camera: Vector3, hero: Vector3): void {
|
|
154
|
+
this.camera.value.copy(camera);
|
|
155
|
+
this.hero.value.set(hero.x, hero.y + 0.9, hero.z);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface FloorPiece {
|
|
160
|
+
readonly box: WallBox;
|
|
161
|
+
readonly material: MeshStandardMaterial;
|
|
162
|
+
lit: number;
|
|
163
|
+
seen: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export class PartyStage {
|
|
167
|
+
readonly group = new Group();
|
|
168
|
+
/** Where the party lights hang ([x, y, z]): the scene drifts sparkles off the near ones. */
|
|
169
|
+
readonly flames: [number, number, number][] = [];
|
|
170
|
+
private readonly floors: FloorPiece[] = [];
|
|
171
|
+
readonly cut = new WallCut();
|
|
172
|
+
|
|
173
|
+
constructor(
|
|
174
|
+
walls: readonly WallBox[],
|
|
175
|
+
floors: readonly WallBox[],
|
|
176
|
+
half: number,
|
|
177
|
+
surface: ((kind: "floor" | "wall" | "stone" | "ground") => Texture | null) | null,
|
|
178
|
+
) {
|
|
179
|
+
// A deep plum carpet under everything: what the camera sees past the walls.
|
|
180
|
+
const rock = new Mesh(new PlaneGeometry(half * 2 + 60, half * 2 + 60), new MeshStandardMaterial({ color: 0x241534, roughness: 1 }));
|
|
181
|
+
rock.rotation.x = -Math.PI / 2;
|
|
182
|
+
rock.position.y = -0.02;
|
|
183
|
+
rock.userData.surface = "rock";
|
|
184
|
+
this.group.add(rock);
|
|
185
|
+
|
|
186
|
+
// Floors: one mesh per room or corridor, so each can lift out of the dark on its own.
|
|
187
|
+
const floorMap = surface?.("floor") ?? danceFloor();
|
|
188
|
+
const ftile = (floorMap.userData as { tile?: number }).tile ?? 4;
|
|
189
|
+
for (const f of floors) {
|
|
190
|
+
const map = floorMap.clone();
|
|
191
|
+
map.needsUpdate = true;
|
|
192
|
+
map.repeat.set((f.hx * 2) / ftile, (f.hz * 2) / ftile);
|
|
193
|
+
map.offset.set(((f.x - f.hx) / ftile) % 1, ((f.z - f.hz) / ftile) % 1);
|
|
194
|
+
const material = new MeshStandardMaterial({ map, roughness: 0.95, color: new Color(UNSEEN, UNSEEN, UNSEEN) });
|
|
195
|
+
const m = new Mesh(new PlaneGeometry(f.hx * 2, f.hz * 2), material);
|
|
196
|
+
m.rotation.x = -Math.PI / 2;
|
|
197
|
+
m.position.set(f.x, 0.005, f.z);
|
|
198
|
+
m.receiveShadow = true;
|
|
199
|
+
m.userData.surface = "floor";
|
|
200
|
+
this.group.add(m);
|
|
201
|
+
// A party has no fog of war: every floor is lit from the start.
|
|
202
|
+
material.color.setScalar(LIT);
|
|
203
|
+
this.floors.push({ box: f, material, lit: LIT, seen: true });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Walls: one merged mesh with a darker cap, so the plan reads from above.
|
|
207
|
+
const wallMap = surface?.("wall") ?? wallpaper();
|
|
208
|
+
wallMap.wrapS = wallMap.wrapT = RepeatWrapping;
|
|
209
|
+
const wtile = (wallMap.userData as { tile?: number }).tile ?? 2;
|
|
210
|
+
const wall = new Mesh(boxesGeometry(walls, WALL_DRAW_HEIGHT, wtile), this.cut.material(0xffffff, wallMap, 0.85));
|
|
211
|
+
wall.castShadow = true;
|
|
212
|
+
wall.receiveShadow = true;
|
|
213
|
+
wall.userData.surface = "wall";
|
|
214
|
+
this.group.add(wall);
|
|
215
|
+
const capMat = this.cut.material(0x7a2f62, null, 0.7);
|
|
216
|
+
const cap = new Mesh(boxesGeometry(walls.map((b) => ({ x: b.x, z: b.z, hx: b.hx + 0.04, hz: b.hz + 0.04 })), 0.08, 1), capMat);
|
|
217
|
+
cap.position.y = WALL_DRAW_HEIGHT;
|
|
218
|
+
this.group.add(cap);
|
|
219
|
+
|
|
220
|
+
// Candles: a sconce flame at each room's inner corners (rooms are the floors big enough to be rooms).
|
|
221
|
+
const flameMat = new MeshBasicMaterial({ color: new Color(0xffb04a).multiplyScalar(5) });
|
|
222
|
+
const flameGeo = new SphereGeometry(0.16, 8, 6);
|
|
223
|
+
const sconceGeo = new CylinderGeometry(0.22, 0.12, 0.24, 6);
|
|
224
|
+
const sconceMat = new MeshStandardMaterial({ color: 0x2a2420, roughness: 0.6, metalness: 0.4 });
|
|
225
|
+
const bulbColors = [0xff5ab4, 0xffe066, 0x7ad7ff, 0x7affd4, 0xc9a7ff];
|
|
226
|
+
const bulbMats = bulbColors.map((c) => new MeshBasicMaterial({ color: new Color(c).multiplyScalar(4) }));
|
|
227
|
+
for (const f of floors) {
|
|
228
|
+
if (f.hx < 4 || f.hz < 4) continue;
|
|
229
|
+
// A ring of party lights along the walls, one every 4 m, in turn through the colours.
|
|
230
|
+
let k = 0;
|
|
231
|
+
const ring: [number, number][] = [];
|
|
232
|
+
for (let x = -f.hx + 1; x <= f.hx - 1; x += 4) ring.push([f.x + x, f.z - f.hz + 0.3], [f.x + x, f.z + f.hz - 0.3]);
|
|
233
|
+
for (let z = -f.hz + 3; z <= f.hz - 3; z += 4) ring.push([f.x - f.hx + 0.3, f.z + z], [f.x + f.hx - 0.3, f.z + z]);
|
|
234
|
+
for (const [x, z] of ring) {
|
|
235
|
+
const sconce = new Mesh(sconceGeo, sconceMat);
|
|
236
|
+
sconce.position.set(x, SCONCE_HEIGHT - 0.12, z);
|
|
237
|
+
this.group.add(sconce);
|
|
238
|
+
const bulb = new Mesh(flameGeo, bulbMats[k++ % bulbMats.length] ?? flameMat);
|
|
239
|
+
bulb.position.set(x, SCONCE_HEIGHT + 0.1, z);
|
|
240
|
+
this.group.add(bulb);
|
|
241
|
+
if (k % 3 === 0) this.flames.push([x, SCONCE_HEIGHT + 0.15, z]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Lift the dark off every floor a wickbearer stands on (or within 2 m of). */
|
|
247
|
+
update(dt: number, players: readonly (readonly [number, number])[]): void {
|
|
248
|
+
for (const f of this.floors) {
|
|
249
|
+
if (!f.seen) {
|
|
250
|
+
for (const [x, z] of players) {
|
|
251
|
+
if (Math.abs(x - f.box.x) <= f.box.hx + 2 && Math.abs(z - f.box.z) <= f.box.hz + 2) f.seen = true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const goal = f.seen ? LIT : UNSEEN;
|
|
255
|
+
if (Math.abs(goal - f.lit) < 0.002) continue;
|
|
256
|
+
f.lit += (goal - f.lit) * Math.min(1, dt * 2.5);
|
|
257
|
+
// Candle-warm: a touch less blue than red.
|
|
258
|
+
f.material.color.setRGB(f.lit, f.lit * 0.93, f.lit * 0.84);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A fresh descent: every room goes back into the dark. */
|
|
263
|
+
reset(): void {
|
|
264
|
+
for (const f of this.floors) f.seen = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
package/client/stake.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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 { StakeMessage, StakeTermsView } from "@netforge/engine/net";
|
|
9
|
+
import type { KitStoreBridge } from "../src/store.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
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegraphs and hostile shots (GAME-DESIGN.md "telegraph every big hit").
|
|
3
|
+
* Every warning is a pooled ground decal that fills as the attack winds
|
|
4
|
+
* up: a ring outline plus a filling disc for circles, an outlined lane
|
|
5
|
+
* with a fill that grows along it for charges and aim lines, and a soft
|
|
6
|
+
* dashed pulse for support auras. Shapes and fill carry the meaning, not
|
|
7
|
+
* colour alone, so it reads for colour-blind players too. The data is the
|
|
8
|
+
* snapshot's telegraph word (engine/game/threats.ts), so nothing extra is
|
|
9
|
+
* sent.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
NormalBlending,
|
|
13
|
+
CircleGeometry,
|
|
14
|
+
Color,
|
|
15
|
+
DoubleSide,
|
|
16
|
+
Group,
|
|
17
|
+
Mesh,
|
|
18
|
+
MeshBasicMaterial,
|
|
19
|
+
PlaneGeometry,
|
|
20
|
+
RingGeometry,
|
|
21
|
+
SphereGeometry,
|
|
22
|
+
} from "three/webgpu";
|
|
23
|
+
import type { Scene } from "three/webgpu";
|
|
24
|
+
import type { ThreatShape } from "@netforge/engine/game";
|
|
25
|
+
|
|
26
|
+
/** Danger reads warm and bright on the night palette; support reads cool. */
|
|
27
|
+
const DANGER = new Color(0xff5a3c);
|
|
28
|
+
const DANGER_EDGE = new Color(0xffe2b0);
|
|
29
|
+
const SUPPORT = new Color(0x6fd3ff);
|
|
30
|
+
/** A keeper's own meteor about to land. */
|
|
31
|
+
const FRIENDLY = new Color(0xffd36b);
|
|
32
|
+
const MAX_DECALS = 64;
|
|
33
|
+
const MAX_SHOTS = 160;
|
|
34
|
+
|
|
35
|
+
interface Decal {
|
|
36
|
+
group: Group;
|
|
37
|
+
fill: Mesh;
|
|
38
|
+
edge: Mesh;
|
|
39
|
+
laneFill: Mesh;
|
|
40
|
+
laneEdge: Mesh;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const basic = (color: Color, opacity: number) =>
|
|
44
|
+
// Normal blending: under the render stack's bloom, stacked additive warnings would wash the screen out.
|
|
45
|
+
new MeshBasicMaterial({ color, transparent: true, opacity, depthWrite: false, side: DoubleSide, blending: NormalBlending, fog: false });
|
|
46
|
+
|
|
47
|
+
export class ThreatLayer {
|
|
48
|
+
private readonly decals: Decal[] = [];
|
|
49
|
+
private used = 0;
|
|
50
|
+
private readonly shots: Mesh[] = [];
|
|
51
|
+
private shotUsed = 0;
|
|
52
|
+
private time = 0;
|
|
53
|
+
|
|
54
|
+
constructor(scene: Scene) {
|
|
55
|
+
const disc = new CircleGeometry(1, 40);
|
|
56
|
+
disc.rotateX(-Math.PI / 2);
|
|
57
|
+
const ring = new RingGeometry(0.94, 1, 48);
|
|
58
|
+
ring.rotateX(-Math.PI / 2);
|
|
59
|
+
// Lanes are unit squares starting at the origin and running along +x.
|
|
60
|
+
const lane = new PlaneGeometry(1, 1);
|
|
61
|
+
lane.rotateX(-Math.PI / 2);
|
|
62
|
+
lane.translate(0.5, 0, 0);
|
|
63
|
+
for (let i = 0; i < MAX_DECALS; i++) {
|
|
64
|
+
const group = new Group();
|
|
65
|
+
const fill = new Mesh(disc, basic(DANGER, 0.35));
|
|
66
|
+
const edge = new Mesh(ring, basic(DANGER_EDGE, 0.9));
|
|
67
|
+
const laneFill = new Mesh(lane, basic(DANGER, 0.4));
|
|
68
|
+
const laneEdge = new Mesh(lane, basic(DANGER_EDGE, 0.16));
|
|
69
|
+
group.add(fill, edge, laneFill, laneEdge);
|
|
70
|
+
group.visible = false;
|
|
71
|
+
scene.add(group);
|
|
72
|
+
this.decals.push({ group, fill, edge, laneFill, laneEdge });
|
|
73
|
+
}
|
|
74
|
+
const orb = new SphereGeometry(0.28, 10, 8);
|
|
75
|
+
const orbMat = new MeshBasicMaterial({ color: 0xff7a4a });
|
|
76
|
+
for (let i = 0; i < MAX_SHOTS; i++) {
|
|
77
|
+
const m = new Mesh(orb, orbMat);
|
|
78
|
+
m.visible = false;
|
|
79
|
+
scene.add(m);
|
|
80
|
+
this.shots.push(m);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Call before drawing a frame's threats. */
|
|
85
|
+
begin(dt: number): void {
|
|
86
|
+
this.time += dt;
|
|
87
|
+
this.used = 0;
|
|
88
|
+
this.shotUsed = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Draw one warning (from engine/game threatOf). `friendly`: a keeper's own meteor, drawn gold. */
|
|
92
|
+
draw(t: ThreatShape, friendly = false): void {
|
|
93
|
+
if (t.shot) {
|
|
94
|
+
const m = this.shots[this.shotUsed++];
|
|
95
|
+
if (!m) return;
|
|
96
|
+
m.position.set(t.x, 0.8, t.z);
|
|
97
|
+
m.visible = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const d = this.decals[this.used++];
|
|
101
|
+
if (!d) return;
|
|
102
|
+
const g = d.group;
|
|
103
|
+
g.visible = true;
|
|
104
|
+
g.position.set(t.x, 0.07, t.z);
|
|
105
|
+
g.rotation.set(0, 0, 0);
|
|
106
|
+
const support = t.kind === "aura";
|
|
107
|
+
const fillMat = d.fill.material as MeshBasicMaterial;
|
|
108
|
+
const edgeMat = d.edge.material as MeshBasicMaterial;
|
|
109
|
+
fillMat.color.copy(friendly ? FRIENDLY : support ? SUPPORT : DANGER);
|
|
110
|
+
edgeMat.color.copy(friendly ? FRIENDLY : support ? SUPPORT : DANGER_EDGE);
|
|
111
|
+
if (t.kind === "lane") {
|
|
112
|
+
d.fill.visible = d.edge.visible = false;
|
|
113
|
+
d.laneFill.visible = d.laneEdge.visible = true;
|
|
114
|
+
const len = t.len ?? 6;
|
|
115
|
+
const w = t.r * 2;
|
|
116
|
+
g.rotation.y = -Math.atan2(t.dz ?? 0, t.dx ?? 1);
|
|
117
|
+
// The fill grows down the lane: when it reaches the end, the charge goes.
|
|
118
|
+
d.laneEdge.scale.set(len, 1, w);
|
|
119
|
+
d.laneFill.scale.set(Math.max(0.05, len * t.progress), 1, w * 0.9);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
d.laneFill.visible = d.laneEdge.visible = false;
|
|
123
|
+
d.fill.visible = d.edge.visible = true;
|
|
124
|
+
d.edge.scale.setScalar(t.r);
|
|
125
|
+
// The disc fills from the centre out; a support pulse is a faint breathing ring (it warns, it does not hurt).
|
|
126
|
+
const p = support ? 0.85 + 0.15 * Math.sin(this.time * 8) : t.progress;
|
|
127
|
+
d.fill.scale.setScalar(Math.max(0.05, t.r * p));
|
|
128
|
+
fillMat.opacity = support ? 0.05 : 0.18 + 0.22 * t.progress;
|
|
129
|
+
edgeMat.opacity = support ? 0.25 : 0.9;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A plain ring under an entity: gold for elites, blue for a shield. */
|
|
133
|
+
mark(x: number, z: number, r: number, color: number): void {
|
|
134
|
+
const d = this.decals[this.used++];
|
|
135
|
+
if (!d) return;
|
|
136
|
+
d.group.visible = true;
|
|
137
|
+
d.group.position.set(x, 0.06, z);
|
|
138
|
+
d.group.rotation.set(0, 0, 0);
|
|
139
|
+
d.laneFill.visible = d.laneEdge.visible = d.fill.visible = false;
|
|
140
|
+
d.edge.visible = true;
|
|
141
|
+
d.edge.scale.setScalar(r);
|
|
142
|
+
(d.edge.material as MeshBasicMaterial).color.setHex(color);
|
|
143
|
+
(d.edge.material as MeshBasicMaterial).opacity = 0.9;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Hide what this frame did not draw. */
|
|
147
|
+
end(): void {
|
|
148
|
+
for (let i = this.used; i < this.decals.length; i++) {
|
|
149
|
+
const d = this.decals[i];
|
|
150
|
+
if (d && d.group.visible) d.group.visible = false;
|
|
151
|
+
}
|
|
152
|
+
for (let i = this.shotUsed; i < this.shots.length; i++) {
|
|
153
|
+
const m = this.shots[i];
|
|
154
|
+
if (m && m.visible) m.visible = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|