@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/src/hud.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HUD shell the board templates share: plain DOM over the canvas.
|
|
3
|
+
* The coin box, Invite, Store and Sound, toasts, the
|
|
4
|
+
* end screen with what the run paid, the store sheet (run upgrades,
|
|
5
|
+
* finishes, losable gear and the stake), the earn goals, the stats overlay
|
|
6
|
+
* and the loading banner. Each template themes it with its own colours and
|
|
7
|
+
* type and adds its own panels. Copy is labels, not sentences.
|
|
8
|
+
*/
|
|
9
|
+
import type { GameManifest } from "@netforge/engine";
|
|
10
|
+
import type { RoomConnection } from "@netforge/engine/net";
|
|
11
|
+
import type { KitStoreBridge, SkuPrice } from "./kit.js";
|
|
12
|
+
import { gearOf, wireGear, wireStake } from "./stake.js";
|
|
13
|
+
import { storeNote, type PlayerMode } from "./host.js";
|
|
14
|
+
|
|
15
|
+
export interface HudTheme {
|
|
16
|
+
/** Main light (titles, focus). */
|
|
17
|
+
readonly accent: string;
|
|
18
|
+
/** Second light (money, rewards). */
|
|
19
|
+
readonly gold: string;
|
|
20
|
+
readonly ink: string;
|
|
21
|
+
readonly dim: string;
|
|
22
|
+
/** Panel fill. */
|
|
23
|
+
readonly panel: string;
|
|
24
|
+
/** Page background behind the canvas. */
|
|
25
|
+
readonly bg: string;
|
|
26
|
+
/** Font stack. */
|
|
27
|
+
readonly font: string;
|
|
28
|
+
/** Display font for titles (defaults to font). */
|
|
29
|
+
readonly display?: string;
|
|
30
|
+
/** Corner radius, px. */
|
|
31
|
+
readonly radius?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HudLabels {
|
|
35
|
+
readonly title: string;
|
|
36
|
+
readonly rules: Readonly<Record<string, string>>;
|
|
37
|
+
readonly skus: Readonly<Record<string, string>>;
|
|
38
|
+
readonly upgrades: Readonly<Record<string, { name: string; line: string }>>;
|
|
39
|
+
/** Shown under the banner while joining. */
|
|
40
|
+
readonly loading: string;
|
|
41
|
+
/** Help line at the bottom left (desktop only). */
|
|
42
|
+
readonly help: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const CSS = (t: HudTheme) => `
|
|
46
|
+
:root { color-scheme: dark; --ink: ${t.ink}; --dim: ${t.dim}; --accent: ${t.accent}; --gold: ${t.gold}; --panel: ${t.panel}; --line: color-mix(in srgb, ${t.accent} 30%, transparent); --r: ${t.radius ?? 8}px; font-family: ${t.font}; }
|
|
47
|
+
html, body { margin: 0; height: 100%; background: ${t.bg}; color: var(--ink); overflow: hidden; touch-action: none; -webkit-user-select: none; user-select: none; }
|
|
48
|
+
#game { position: fixed; inset: 0; }
|
|
49
|
+
#game canvas { display: block; width: 100%; height: 100%; }
|
|
50
|
+
.hud { position: fixed; z-index: 5; pointer-events: none; font-variant-numeric: tabular-nums; }
|
|
51
|
+
.hud button, .hud input, .hud .tap { pointer-events: auto; }
|
|
52
|
+
button { font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); padding: 7px 11px; cursor: pointer; }
|
|
53
|
+
button:disabled { opacity: 0.45; cursor: default; }
|
|
54
|
+
button:active { transform: scale(0.97); }
|
|
55
|
+
button.go { background: var(--accent); color: ${t.bg}; border-color: transparent; font-weight: 800; }
|
|
56
|
+
#top-left { top: max(10px, env(safe-area-inset-top)); left: 12px; max-width: min(360px, 52vw); }
|
|
57
|
+
#title { font: 900 13px ${t.display ?? t.font}; letter-spacing: 0.26em; color: var(--accent); text-shadow: 0 1px 0 rgba(0,0,0,0.6); }
|
|
58
|
+
#status { margin-top: 6px; display: grid; gap: 4px; }
|
|
59
|
+
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: var(--r); background: var(--panel); border: 1px solid rgba(255,255,255,0.08); font-weight: 700; font-size: 13px; width: fit-content; }
|
|
60
|
+
.chip small { color: var(--dim); font-weight: 600; letter-spacing: 0.08em; }
|
|
61
|
+
#top-right { top: max(10px, env(safe-area-inset-top)); right: 12px; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; width: min(260px, 46vw); }
|
|
62
|
+
#top-right .row { display: flex; gap: 6px; }
|
|
63
|
+
#coin-box { display: flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r); background: var(--panel); border: 1px solid var(--line); font-weight: 700; white-space: nowrap; }
|
|
64
|
+
#coin-box.guest #coin-balance { color: var(--dim); font-weight: 600; }
|
|
65
|
+
#coin-box.play { display: block; text-align: right; }
|
|
66
|
+
#coin-box.play #coin-logo { vertical-align: middle; margin-right: 6px; }
|
|
67
|
+
#coin-box.play::after { content: "play money"; display: block; margin-top: 1px; font-size: 10px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--dim); }
|
|
68
|
+
#coin-logo { border-radius: 50%; }
|
|
69
|
+
#goals { list-style: none; margin: 0; padding: 6px 9px; background: var(--panel); border-radius: var(--r); font-size: 11px; display: grid; gap: 3px; min-width: 170px; }
|
|
70
|
+
#goals:empty { display: none; }
|
|
71
|
+
#goals li { display: flex; justify-content: space-between; gap: 10px; color: var(--dim); }
|
|
72
|
+
#goals li b { color: var(--ink); font-weight: 700; }
|
|
73
|
+
#goals li.paid { color: var(--gold); }
|
|
74
|
+
#goals li.paid b { color: var(--gold); }
|
|
75
|
+
#toast { left: 50%; top: 70px; transform: translate(-50%, -10px); padding: 8px 14px; border-radius: 999px; background: var(--panel); border: 1px solid var(--gold); font-weight: 700; opacity: 0; transition: opacity 200ms, transform 200ms; white-space: nowrap; z-index: 9; }
|
|
76
|
+
#toast.show { opacity: 1; transform: translate(-50%, 0); }
|
|
77
|
+
#banner { left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; font-size: 14px; color: var(--dim); letter-spacing: 0.12em; }
|
|
78
|
+
#banner b { display: block; font: 900 44px ${t.display ?? t.font}; letter-spacing: 0.24em; color: var(--accent); margin-bottom: 8px; }
|
|
79
|
+
#end { inset: 0; display: none; place-items: center; background: rgba(0, 0, 0, 0.55); z-index: 8; }
|
|
80
|
+
#end.show { display: grid; }
|
|
81
|
+
#end .box { width: min(420px, 90vw); padding: 22px; border-radius: calc(var(--r) * 1.6); background: var(--panel); border: 1px solid var(--line); text-align: center; backdrop-filter: blur(6px); }
|
|
82
|
+
#end-title { font: 900 26px ${t.display ?? t.font}; letter-spacing: 0.14em; color: var(--accent); }
|
|
83
|
+
#end-sub { color: var(--dim); margin-top: 4px; font-size: 13px; }
|
|
84
|
+
#end-earned { list-style: none; padding: 0; margin: 14px 0 12px; text-align: left; font-size: 14px; }
|
|
85
|
+
#end-earned li { display: flex; justify-content: space-between; padding: 6px 0; border-top: 1px solid var(--line); }
|
|
86
|
+
#end-earned b { color: var(--gold); }
|
|
87
|
+
#end-earned li.none { color: var(--dim); justify-content: center; }
|
|
88
|
+
#end-next { color: var(--dim); font-size: 12px; letter-spacing: 0.12em; }
|
|
89
|
+
#store { top: 0; right: 0; bottom: 0; width: min(330px, 88vw); background: var(--panel); backdrop-filter: blur(8px); border-left: 1px solid var(--line); padding: 18px; box-sizing: border-box; transform: translateX(100%); transition: transform 220ms ease; pointer-events: auto; overflow-y: auto; z-index: 8; }
|
|
90
|
+
#store.show { transform: none; }
|
|
91
|
+
#store h2 { margin: 0 0 2px; font-size: 15px; letter-spacing: 0.2em; }
|
|
92
|
+
#store h3 { margin: 16px 0 0; font-size: 10px; letter-spacing: 0.24em; color: var(--dim); font-weight: 600; }
|
|
93
|
+
#store .item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 0; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
|
94
|
+
#store .item > div { display: flex; align-items: center; gap: 10px; }
|
|
95
|
+
#store .swatch { width: 26px; height: 26px; border-radius: 7px; flex: none; }
|
|
96
|
+
#store small { color: var(--dim); }
|
|
97
|
+
#store button { min-width: 88px; }
|
|
98
|
+
#store-close { margin-top: 16px; }
|
|
99
|
+
#stats { left: 12px; bottom: 120px; font: 11px/1.4 ui-monospace, monospace; color: var(--dim); white-space: pre; display: none; background: rgba(0,0,0,0.5); padding: 6px; border-radius: 6px; }
|
|
100
|
+
#stats.show { display: block; }
|
|
101
|
+
#help { left: 12px; bottom: max(10px, env(safe-area-inset-bottom)); font-size: 10px; letter-spacing: 0.08em; color: var(--dim); }
|
|
102
|
+
html[data-host="netnet"] #invite { display: none; }
|
|
103
|
+
html[data-preview="1"] .hud { display: none !important; }
|
|
104
|
+
@media (pointer: coarse) { #help { display: none; } }
|
|
105
|
+
@media (max-width: 560px) {
|
|
106
|
+
#top-right { width: 58vw; }
|
|
107
|
+
#top-right button { padding: 5px 8px; font-size: 12px; }
|
|
108
|
+
#goals { display: none; }
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const SHELL = (l: HudLabels) => `
|
|
113
|
+
<div id="game"></div>
|
|
114
|
+
<div id="top-left" class="hud"><div id="title"></div><div id="status"></div></div>
|
|
115
|
+
<div id="top-right" class="hud">
|
|
116
|
+
<div id="coin-box"><img id="coin-logo" alt="" width="18" height="18" hidden /><span id="coin-balance"></span></div>
|
|
117
|
+
<div class="row"><button id="invite">Invite</button><button id="store-btn">Store</button><button id="sound">Sound</button></div>
|
|
118
|
+
<ul id="goals"></ul>
|
|
119
|
+
</div>
|
|
120
|
+
<div id="end" class="hud"><div class="box"><div id="end-title"></div><div id="end-sub"></div><ul id="end-earned"></ul><div id="end-next"></div></div></div>
|
|
121
|
+
<aside id="store" class="hud">
|
|
122
|
+
<h2>STORE</h2>
|
|
123
|
+
<small id="coin-name"></small>
|
|
124
|
+
<h3>THIS RUN</h3>
|
|
125
|
+
<div id="store-upgrades"></div>
|
|
126
|
+
<h3>FINISHES</h3>
|
|
127
|
+
<div id="store-finishes"></div>
|
|
128
|
+
<div class="item" id="gear-row"><div style="display: block"><span id="gear-name"></span><br /><small id="gear-line"></small></div><button id="gear-buy" style="white-space: nowrap"></button></div>
|
|
129
|
+
<div class="item" id="stake-row"><div style="display: block">Stake a run<br /><small id="stake-line"></small></div><span style="white-space: nowrap"><input id="stake-amount" type="number" aria-label="Stake" style="width: 56px" /> <button id="stake-go" style="min-width: 0">Stake</button></span></div>
|
|
130
|
+
<small id="store-note"></small>
|
|
131
|
+
<br /><button id="store-close">Close</button>
|
|
132
|
+
</aside>
|
|
133
|
+
<div id="toast" class="hud"></div>
|
|
134
|
+
<div id="stats" class="hud"></div>
|
|
135
|
+
<div id="help" class="hud">${l.help}</div>
|
|
136
|
+
<div id="banner" class="hud"><b>${l.title.toUpperCase()}</b><span id="banner-text">${l.loading}</span></div>
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
export const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
|
|
140
|
+
const e = document.getElementById(id);
|
|
141
|
+
if (!e) throw new Error(`#${id} missing`);
|
|
142
|
+
return e as T;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** A swatch colour as CSS. */
|
|
146
|
+
export const cssColor = (c: number): string => `#${c.toString(16).padStart(6, "0")}`;
|
|
147
|
+
|
|
148
|
+
export interface EarnProgress {
|
|
149
|
+
readonly id: string;
|
|
150
|
+
readonly when: string;
|
|
151
|
+
readonly n: number;
|
|
152
|
+
readonly have: number;
|
|
153
|
+
readonly paid: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export class Hud {
|
|
157
|
+
private toastTimer = 0;
|
|
158
|
+
private symbol = "";
|
|
159
|
+
mode: PlayerMode = "earning";
|
|
160
|
+
private goalsKey = "";
|
|
161
|
+
private ended = false;
|
|
162
|
+
private earnedRun = new Map<string, number>();
|
|
163
|
+
|
|
164
|
+
constructor(
|
|
165
|
+
private readonly labels: HudLabels,
|
|
166
|
+
theme: HudTheme,
|
|
167
|
+
private readonly manifest: GameManifest,
|
|
168
|
+
) {
|
|
169
|
+
const style = document.createElement("style");
|
|
170
|
+
style.textContent = CSS(theme);
|
|
171
|
+
document.head.append(style);
|
|
172
|
+
document.body.insertAdjacentHTML("afterbegin", SHELL(labels));
|
|
173
|
+
$("title").textContent = labels.title.toUpperCase();
|
|
174
|
+
document.title = labels.title;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The wordmark (Net Forge names the game). */
|
|
178
|
+
title(name: string): void {
|
|
179
|
+
$("title").textContent = name.toUpperCase();
|
|
180
|
+
document.title = name;
|
|
181
|
+
const b = document.querySelector("#banner b");
|
|
182
|
+
if (b) b.textContent = name.toUpperCase();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
coin(name: string, symbol: string, logo: string | null): void {
|
|
186
|
+
this.symbol = symbol;
|
|
187
|
+
const img = $<HTMLImageElement>("coin-logo");
|
|
188
|
+
if (logo) {
|
|
189
|
+
img.src = logo;
|
|
190
|
+
img.alt = name;
|
|
191
|
+
img.hidden = false;
|
|
192
|
+
} else img.hidden = true;
|
|
193
|
+
$("coin-name").textContent = name;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
get coinSymbol(): string {
|
|
197
|
+
return this.symbol;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What play pays the person at the screen (Net Forge's flag). */
|
|
201
|
+
player(mode: PlayerMode): void {
|
|
202
|
+
this.mode = mode;
|
|
203
|
+
const box = $("coin-box");
|
|
204
|
+
box.classList.toggle("guest", mode === "guest");
|
|
205
|
+
box.classList.toggle("play", mode === "play");
|
|
206
|
+
box.title = mode === "guest" ? `Connect a wallet to earn ${this.symbol}` : mode === "play" ? `${this.symbol} has not launched: coins are play money` : "";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
balance(n: number): void {
|
|
210
|
+
$("coin-balance").textContent = this.mode === "guest" ? `${this.symbol} · not earning` : `${Math.floor(n).toLocaleString("en-US")} ${this.symbol}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
toast(text: string): void {
|
|
214
|
+
const t = $("toast");
|
|
215
|
+
t.textContent = text;
|
|
216
|
+
t.classList.add("show");
|
|
217
|
+
clearTimeout(this.toastTimer);
|
|
218
|
+
this.toastTimer = window.setTimeout(() => t.classList.remove("show"), 2400);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The room credited an earn rule. */
|
|
222
|
+
earned(rule: string, units: number): void {
|
|
223
|
+
this.earnedRun.set(rule, (this.earnedRun.get(rule) ?? 0) + units);
|
|
224
|
+
const pay = (this.manifest.earnRules.get(rule)?.pay ?? 0) * units;
|
|
225
|
+
const name = this.labels.rules[rule] ?? rule;
|
|
226
|
+
this.toast(this.mode === "guest" ? `${name} Connect to earn ${pay} ${this.symbol}` : `${name} +${pay} ${this.symbol}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The run's goals with this player's progress (the room's counts). */
|
|
230
|
+
goals(list: readonly EarnProgress[] | undefined): void {
|
|
231
|
+
if (!list) return;
|
|
232
|
+
const key = list.map((g) => `${g.id}:${g.have}:${g.paid}`).join("|");
|
|
233
|
+
if (key === this.goalsKey) return;
|
|
234
|
+
this.goalsKey = key;
|
|
235
|
+
const el = $("goals");
|
|
236
|
+
el.innerHTML = "";
|
|
237
|
+
for (const g of list) {
|
|
238
|
+
const li = document.createElement("li");
|
|
239
|
+
if (g.paid) li.className = "paid";
|
|
240
|
+
const pay = this.manifest.earnRules.get(g.id)?.pay ?? 0;
|
|
241
|
+
li.innerHTML = `<span></span><b></b>`;
|
|
242
|
+
(li.firstChild as HTMLElement).textContent = this.labels.rules[g.id] ?? g.id;
|
|
243
|
+
(li.lastChild as HTMLElement).textContent = g.paid ? `+${pay}` : g.n > 1 ? `${g.have}/${g.n}` : `${pay}`;
|
|
244
|
+
el.append(li);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Status chips at the top left: [label, value] pairs. */
|
|
249
|
+
status(chips: readonly (readonly [string, string])[]): void {
|
|
250
|
+
const el = $("status");
|
|
251
|
+
const want = chips.map(([a, b]) => `${a}\u0000${b}`).join("\u0001");
|
|
252
|
+
if (el.dataset.k === want) return;
|
|
253
|
+
el.dataset.k = want;
|
|
254
|
+
el.innerHTML = "";
|
|
255
|
+
for (const [label, value] of chips) {
|
|
256
|
+
const c = document.createElement("div");
|
|
257
|
+
c.className = "chip";
|
|
258
|
+
c.innerHTML = `<small></small><span></span>`;
|
|
259
|
+
(c.firstChild as HTMLElement).textContent = label;
|
|
260
|
+
(c.lastChild as HTMLElement).textContent = value;
|
|
261
|
+
el.append(c);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The end screen while the room shows it. */
|
|
266
|
+
end(show: boolean, title: string, sub: string, endsIn: number | null): void {
|
|
267
|
+
const el = $("end");
|
|
268
|
+
if (show && !this.ended) {
|
|
269
|
+
const list = $("end-earned");
|
|
270
|
+
list.innerHTML = "";
|
|
271
|
+
for (const [rule, units] of this.earnedRun) {
|
|
272
|
+
const li = document.createElement("li");
|
|
273
|
+
li.className = this.mode === "guest" ? "guest" : "";
|
|
274
|
+
li.innerHTML = `<span></span><b></b>`;
|
|
275
|
+
(li.firstChild as HTMLElement).textContent = this.labels.rules[rule] ?? rule;
|
|
276
|
+
(li.lastChild as HTMLElement).textContent = `+${(this.manifest.earnRules.get(rule)?.pay ?? 0) * units} ${this.symbol}`;
|
|
277
|
+
list.append(li);
|
|
278
|
+
}
|
|
279
|
+
if (this.earnedRun.size === 0) list.innerHTML = `<li class="none">No goals this run</li>`;
|
|
280
|
+
}
|
|
281
|
+
if (!show && this.ended) this.earnedRun.clear();
|
|
282
|
+
this.ended = show;
|
|
283
|
+
el.classList.toggle("show", show);
|
|
284
|
+
if (show) {
|
|
285
|
+
$("end-title").textContent = title;
|
|
286
|
+
$("end-sub").textContent = sub;
|
|
287
|
+
$("end-next").textContent = endsIn !== null ? `NEXT RUN IN ${Math.ceil(endsIn)}` : "";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
stats(text: string): void {
|
|
292
|
+
$("stats").textContent = text;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
ready(): void {
|
|
296
|
+
document.getElementById("banner")?.remove();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
failed(message: string): void {
|
|
300
|
+
const t = document.getElementById("banner-text");
|
|
301
|
+
if (t) t.textContent = message;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The store: run upgrades (a SKU named after a declared upgrade: spent, then
|
|
307
|
+
* the room applies one level for this run), finishes (owned for good,
|
|
308
|
+
* equipped by a room command), the first losable gear, and the stake.
|
|
309
|
+
*/
|
|
310
|
+
export function wireStore(opts: {
|
|
311
|
+
readonly hud: Hud;
|
|
312
|
+
readonly manifest: GameManifest;
|
|
313
|
+
readonly kit: KitStoreBridge;
|
|
314
|
+
readonly conn: RoomConnection<unknown, unknown>;
|
|
315
|
+
readonly labels: HudLabels;
|
|
316
|
+
readonly cosmetics: Readonly<Record<string, number>>;
|
|
317
|
+
readonly swatch: (cosmetic: number) => string;
|
|
318
|
+
readonly hostMode: () => PlayerMode | null;
|
|
319
|
+
readonly unlock?: () => void;
|
|
320
|
+
}): { toggle(): void } {
|
|
321
|
+
const { hud, manifest, kit, conn, labels } = opts;
|
|
322
|
+
const sym = kit.coins.symbol();
|
|
323
|
+
const prices: Record<string, SkuPrice> = Object.fromEntries(manifest.skus);
|
|
324
|
+
const upgradesEl = $("store-upgrades");
|
|
325
|
+
for (const [id, s] of manifest.skus) {
|
|
326
|
+
if (s.grants || s.coins === undefined || !manifest.upgrades.has(id)) continue;
|
|
327
|
+
const price = s.coins;
|
|
328
|
+
const row = document.createElement("div");
|
|
329
|
+
row.className = "item";
|
|
330
|
+
row.innerHTML = `<div><div><span class="name"></span><br /><small></small></div></div><button></button>`;
|
|
331
|
+
(row.querySelector(".name") as HTMLElement).textContent = labels.skus[id] ?? id;
|
|
332
|
+
(row.querySelector("small") as HTMLElement).textContent = labels.upgrades[id]?.line ?? "";
|
|
333
|
+
const btn = row.querySelector("button") as HTMLButtonElement;
|
|
334
|
+
btn.textContent = `${price} ${sym}`;
|
|
335
|
+
btn.addEventListener("click", async () => {
|
|
336
|
+
opts.unlock?.();
|
|
337
|
+
let proof: string | null;
|
|
338
|
+
try {
|
|
339
|
+
proof = (await kit.coins.spend(price, id)).key;
|
|
340
|
+
} catch {
|
|
341
|
+
hud.toast(`Not enough ${sym}`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const r = await conn.command({ name: id, ...(proof ? { proof } : {}) });
|
|
345
|
+
hud.toast(r.ok ? `${labels.skus[id] ?? id} on` : r.reason === "store not live" ? "Store opens at launch" : "Already on this run");
|
|
346
|
+
});
|
|
347
|
+
upgradesEl.append(row);
|
|
348
|
+
}
|
|
349
|
+
const finishesEl = $("store-finishes");
|
|
350
|
+
for (const [id, cosmetic] of Object.entries(opts.cosmetics)) {
|
|
351
|
+
const price = prices[id]?.coins;
|
|
352
|
+
if (price === undefined) continue;
|
|
353
|
+
const row = document.createElement("div");
|
|
354
|
+
row.className = "item";
|
|
355
|
+
row.innerHTML = `<div><span class="swatch"></span><div><span class="name"></span><br /><small>Finish</small></div></div><button></button>`;
|
|
356
|
+
(row.querySelector(".swatch") as HTMLElement).style.background = opts.swatch(cosmetic);
|
|
357
|
+
(row.querySelector(".name") as HTMLElement).textContent = labels.skus[id] ?? id;
|
|
358
|
+
const btn = row.querySelector("button") as HTMLButtonElement;
|
|
359
|
+
btn.textContent = `${price} ${sym}`;
|
|
360
|
+
btn.addEventListener("click", async () => {
|
|
361
|
+
opts.unlock?.();
|
|
362
|
+
if (!kit.store.owns(id)) {
|
|
363
|
+
try {
|
|
364
|
+
await kit.store.buy(id, { pay: "coins" });
|
|
365
|
+
} catch {
|
|
366
|
+
hud.toast(`Not enough ${sym}`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const r = await conn.command({ name: "cosmetic", value: cosmetic });
|
|
371
|
+
if (!r.ok) return hud.toast("Finishes open at launch");
|
|
372
|
+
for (const b of finishesEl.querySelectorAll("button")) if (b !== btn && b.textContent === "Equipped") b.textContent = "Equip";
|
|
373
|
+
btn.textContent = "Equipped";
|
|
374
|
+
});
|
|
375
|
+
finishesEl.append(row);
|
|
376
|
+
}
|
|
377
|
+
wireStake(conn as never, kit, (t) => hud.toast(t));
|
|
378
|
+
wireGear(gearOf(manifest.skus, labels.skus), kit, (t) => hud.toast(t));
|
|
379
|
+
const note = $("store-note");
|
|
380
|
+
const showNote = () => (note.textContent = storeNote(opts.hostMode(), conn.welcome.earnLive, sym));
|
|
381
|
+
showNote();
|
|
382
|
+
let open = false;
|
|
383
|
+
const toggle = () => {
|
|
384
|
+
open = !open;
|
|
385
|
+
$("store").classList.toggle("show", open);
|
|
386
|
+
showNote();
|
|
387
|
+
};
|
|
388
|
+
$("store-btn").addEventListener("click", toggle);
|
|
389
|
+
$("store-close").addEventListener("click", toggle);
|
|
390
|
+
return { toggle };
|
|
391
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @netforge/tabletop: the client kit the board templates share
|
|
3
|
+
* (COGSPIRE and the other rules-genre templates). Templates import it from
|
|
4
|
+
* their client; generated game code never does.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./boot.js";
|
|
7
|
+
export * from "./connect.js";
|
|
8
|
+
export * from "./host.js";
|
|
9
|
+
export * from "./hud.js";
|
|
10
|
+
export * from "./kit.js";
|
|
11
|
+
export * from "./stage.js";
|
|
12
|
+
export * from "./stake.js";
|
package/src/kit.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Kit's store and coins as a board template's client calls them
|
|
3
|
+
* (the Coins model): every game has coins,
|
|
4
|
+
* 1 coin = 1 game token, shown under the token's own name, symbol and logo.
|
|
5
|
+
*
|
|
6
|
+
* `KitStoreBridge` is the slice of @netforge/kit's `Kit` the clients
|
|
7
|
+
* call, with the Kit's own names (`kit.coins.*`, `kit.store.owns/buy`), so
|
|
8
|
+
* the real Kit drops in unchanged. Until a game's token is live `mockKit()`
|
|
9
|
+
* stands in, in memory. Earned coins are spent first (off chain, no wallet
|
|
10
|
+
* prompt), then the wallet, exactly as the Kit does.
|
|
11
|
+
*/
|
|
12
|
+
export interface KitStoreBridge {
|
|
13
|
+
readonly store: {
|
|
14
|
+
owns(id: string): boolean;
|
|
15
|
+
count(id: string): number;
|
|
16
|
+
buy(id: string, opts?: { pay?: "usdg" | "token" | "coins" }): Promise<unknown>;
|
|
17
|
+
};
|
|
18
|
+
readonly coins: {
|
|
19
|
+
/** The token's own name, symbol and logo (its metadata). Never a generic "COIN". */
|
|
20
|
+
name(): string;
|
|
21
|
+
symbol(): string;
|
|
22
|
+
logo(): string | null;
|
|
23
|
+
/** Whole coins: earned (unclaimed) plus the wallet. */
|
|
24
|
+
balance(): number;
|
|
25
|
+
/** Earned first, then the wallet. `key` proves the paid room command (sent as the command's `proof`). */
|
|
26
|
+
spend(amount: number, reason: string): Promise<{ key: string | null }>;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SkuPrice {
|
|
31
|
+
readonly coins?: number;
|
|
32
|
+
readonly usdg?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MockKit extends KitStoreBridge {
|
|
36
|
+
/** Dollars left (mock only). */
|
|
37
|
+
usdg(): number;
|
|
38
|
+
/** Earned, unclaimed coins left (mock only). */
|
|
39
|
+
earned(): number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An in-memory Kit for local play and tests: starting balances, prices from game.ts. */
|
|
43
|
+
export function mockKit(
|
|
44
|
+
start: { earned: number; wallet: number; usdg: number },
|
|
45
|
+
prices: Readonly<Record<string, SkuPrice>>,
|
|
46
|
+
coin: { name: string; symbol: string; logo: string | null },
|
|
47
|
+
): MockKit {
|
|
48
|
+
let earned = start.earned;
|
|
49
|
+
let wallet = start.wallet;
|
|
50
|
+
let usdg = start.usdg;
|
|
51
|
+
const owned = new Map<string, number>();
|
|
52
|
+
let spends = 0;
|
|
53
|
+
/** Earned first, then the wallet: the Kit's order. */
|
|
54
|
+
const spend = (amount: number, reason: string) => {
|
|
55
|
+
if (!Number.isInteger(amount) || amount <= 0 || earned + wallet < amount) throw new Error(`cannot spend ${amount} for ${reason}`);
|
|
56
|
+
const fromEarned = Math.min(earned, amount);
|
|
57
|
+
earned -= fromEarned;
|
|
58
|
+
wallet -= amount - fromEarned;
|
|
59
|
+
return { fromEarned, onChain: amount - fromEarned, key: `mock-spend-${++spends}` };
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
usdg: () => usdg,
|
|
63
|
+
earned: () => earned,
|
|
64
|
+
store: {
|
|
65
|
+
owns: (id) => (owned.get(id) ?? 0) > 0,
|
|
66
|
+
count: (id) => owned.get(id) ?? 0,
|
|
67
|
+
async buy(id, opts) {
|
|
68
|
+
const p = prices[id];
|
|
69
|
+
if (!p) throw new Error(`no SKU "${id}"`);
|
|
70
|
+
const payUsdg = opts?.pay === "usdg" || (opts?.pay === undefined && p.coins === undefined);
|
|
71
|
+
if (payUsdg) {
|
|
72
|
+
if (p.usdg === undefined || usdg < p.usdg) throw new Error("not enough USDG");
|
|
73
|
+
usdg -= p.usdg;
|
|
74
|
+
} else {
|
|
75
|
+
if (p.coins === undefined) throw new Error(`"${id}" has no coin price`);
|
|
76
|
+
spend(p.coins, id);
|
|
77
|
+
}
|
|
78
|
+
owned.set(id, (owned.get(id) ?? 0) + 1);
|
|
79
|
+
return { id };
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
coins: {
|
|
83
|
+
name: () => coin.name,
|
|
84
|
+
symbol: () => coin.symbol,
|
|
85
|
+
logo: () => coin.logo,
|
|
86
|
+
balance: () => earned + wallet,
|
|
87
|
+
async spend(amount, reason) {
|
|
88
|
+
return spend(amount, reason);
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|