@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/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
|
+
}
|