@homie-rocks/studio 0.10.0 → 0.12.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/saves/saves.ts ADDED
@@ -0,0 +1,326 @@
1
+ /*
2
+ * saves.ts — cloud saves for a game on a Homie studio (@homie-rocks/studio/saves). SAVES.md beside it is the guide.
3
+ * =============================================================================
4
+ *
5
+ * import { createSaves } from '@homie-rocks/studio/saves';
6
+ * const saves = createSaves({ game: 'my-game' });
7
+ * const hero = (await saves.get<Hero>('hero')) ?? newHero();
8
+ * await saves.set('hero', hero); // safe in this browser at once, in the cloud when online
9
+ * saves.stats.add({ kills: 1, seconds: 30 }); // lifetime numbers that survive a wipe
10
+ * await saves.fall({ character: hero.name, summary: { level: hero.level }, wipe: true }); // hardcore death
11
+ * saves.on('player', () => reload()); // the player signed in on this device: their saves changed
12
+ *
13
+ * WHERE IT KEEPS THINGS. A game on a studio site runs in a sandboxed frame that can keep nothing of its own. The
14
+ * play shell around it (on the studio's own origin) does it: a local copy in that browser, an outbox that syncs to
15
+ * the studio's Worker and D1, and the player's passkey sign-in. This file only talks to the shell (postMessage),
16
+ * so the game never sees a session or a cookie, and can only reach its OWN game's saves. Without a shell (the
17
+ * game opened as a plain file, a Vite dev server, a game.json with no "saves": true) it keeps everything in this
18
+ * page's localStorage instead (mode 'local'), so the same code runs everywhere.
19
+ *
20
+ * ROOMS ARE NOT SAVES. A room forgets everything 60 s after its last player leaves. A character that must last
21
+ * days lives in saves: load it when the player arrives, save it when it changes, and let the room carry only the
22
+ * live round.
23
+ *
24
+ * It has no imports and uses only erasable TypeScript: vendor it into a game like netplay.ts, or import it.
25
+ * =============================================================================
26
+ */
27
+
28
+ export const SAVES_VERSION = 1;
29
+
30
+ export interface Player {
31
+ /** `pl_…` on a studio site (null before this browser saved anything, and in local mode). */
32
+ id: string | null;
33
+ name: string;
34
+ /** No passkey yet: progress is kept in this browser only until the player makes an account. */
35
+ guest: boolean;
36
+ /** The studio owner's own account (marked once with `homie-studio players owner`). Show, never trust. */
37
+ owner: boolean;
38
+ signedIn: boolean;
39
+ /** No studio shell around the game: everything is in this page's localStorage. */
40
+ local: boolean;
41
+ }
42
+
43
+ export interface SaveEntry { key: string; version: number; updatedAt: number | null; pending?: boolean }
44
+ export interface SetResult { ok: boolean; synced: boolean; version: number; error?: string; message?: string }
45
+ export interface Memorial { character: string; player: string; summary: Record<string, unknown>; at: number }
46
+ export interface SavesStatus { mode: 'cloud' | 'local'; online: boolean; pending: number; lastSyncAt: number | null; lastError: string | null }
47
+ export interface Conflict<T = unknown> { key: string; mine: T | null; theirs: T | null; theirsVersion: number }
48
+ export type SavesEvent = 'player' | 'synced' | 'conflict' | 'status' | 'error';
49
+
50
+ export interface SavesOptions {
51
+ /** The game's id (its folder): names the local-mode store. On a studio site the shell's own game id is used. */
52
+ game?: string;
53
+ /**
54
+ * Another device saved a key since this one's copy (this browser was offline, or two devices played at once).
55
+ * Return the value to keep (merge them, or return `mine`), or `undefined` for the cloud's (the default: a stale
56
+ * offline copy never overwrites newer progress, so a hardcore character cannot come back from the dead).
57
+ */
58
+ onConflict?: (c: Conflict) => unknown | Promise<unknown>;
59
+ /** Tests and custom shells: where messages go (default: the parent frame) and the shell's answer window. */
60
+ target?: { postMessage: (m: unknown, origin: string) => void } | null;
61
+ /** Force local mode (true) or cloud mode (false). Default: cloud when the shell said so (HOMIE_NET.saves). */
62
+ local?: boolean;
63
+ /** Local mode's storage (default: localStorage when usable, else memory). */
64
+ storage?: { getItem(k: string): string | null; setItem(k: string, v: string): void } | null;
65
+ }
66
+
67
+ export interface Saves {
68
+ /** Resolves once the player and their saves are loaded (cloud mode: from the shell's copy, then the cloud). */
69
+ readonly ready: Promise<Player>;
70
+ readonly player: Player;
71
+ readonly mode: 'cloud' | 'local';
72
+ /** The last known value of a key, at once (for a render loop). `get` is the same, as a promise. */
73
+ peek<T = unknown>(key: string): T | null;
74
+ get<T = unknown>(key: string): Promise<T | null>;
75
+ /** Keep a value (JSON, 64 KiB; 1 MiB with the studio's storage). Resolves when synced, or after ~4 s saved locally. */
76
+ set<T = unknown>(key: string, value: T): Promise<SetResult>;
77
+ remove(key: string): Promise<SetResult>;
78
+ list(): Promise<SaveEntry[]>;
79
+ /** Delete this game's saves (except `keep`): "new game". Lifetime stats and memorials stay. */
80
+ wipe(opts?: { keep?: string[] }): Promise<{ ok: boolean; synced: boolean }>;
81
+ readonly stats: {
82
+ get(): Promise<Record<string, number>>;
83
+ /** Add to counters: { kills: 1, gold: 25, seconds: 30 }. */
84
+ add(values: Record<string, number>): Promise<{ ok: boolean }>;
85
+ /** Keep the highest: { level: 12 }. */
86
+ max(values: Record<string, number>): Promise<{ ok: boolean }>;
87
+ /** Keep the lowest: { fastestRun: 312 }. */
88
+ min(values: Record<string, number>): Promise<{ ok: boolean }>;
89
+ };
90
+ /**
91
+ * A character died for good (hardcore): a memorial in the hall of the fallen (its name, your display name, a
92
+ * small summary), and with `wipe: true` this game's saves are deleted in the same step (except `keep`).
93
+ */
94
+ fall(m: { character: string; summary?: Record<string, unknown>; wipe?: boolean; keep?: string[] }): Promise<{ ok: boolean; synced: boolean; memorial: Memorial | null }>;
95
+ /** The hall of the fallen: the newest memorials of this game (everyone's), or `mine: true`. */
96
+ fallen(opts?: { limit?: number; mine?: boolean }): Promise<Memorial[]>;
97
+ /** Change the player's display name on this studio (the same rules as the account page). */
98
+ rename(name: string): Promise<{ ok: boolean; name?: string; message?: string }>;
99
+ /** Ask the shell to show its sign-in sheet (a passkey). Call it from a button the player pressed. */
100
+ signIn(reason?: string): void;
101
+ /** Wait until every change is in the cloud (or the network said no). */
102
+ flush(): Promise<SavesStatus>;
103
+ status(): SavesStatus;
104
+ on(ev: 'player', fn: (p: Player) => void): () => void;
105
+ on(ev: 'conflict', fn: (c: Conflict & { kept: 'theirs' | 'game' }) => void): () => void;
106
+ on(ev: 'synced' | 'status', fn: (s: SavesStatus) => void): () => void;
107
+ on(ev: 'error', fn: (e: { key?: string; error: string; message?: string }) => void): () => void;
108
+ }
109
+
110
+ const KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
111
+ type Msg = Record<string, unknown> & { t?: string; q?: number; ev?: string; ok?: boolean };
112
+
113
+ export function createSaves(opts: SavesOptions = {}): Saves {
114
+ const g = globalThis as unknown as {
115
+ HOMIE_NET?: { saves?: boolean }; parent?: unknown; addEventListener?: typeof addEventListener; localStorage?: Storage;
116
+ };
117
+ const cloud = opts.local === undefined ? Boolean(g.HOMIE_NET?.saves) : !opts.local;
118
+ const game = String(opts.game ?? 'game').replace(/[^a-z0-9-]/g, '').slice(0, 40) || 'game';
119
+ const listeners = new Map<string, Set<(x: unknown) => void>>();
120
+ const emit = (ev: string, x: unknown): void => { for (const fn of listeners.get(ev) ?? []) { try { fn(x); } catch { /* the game's */ } } };
121
+ let player: Player = { id: null, name: 'Guest', guest: true, owner: false, signedIn: false, local: !cloud };
122
+ let mirror: Record<string, unknown> = {};
123
+ let st: SavesStatus = { mode: cloud ? 'cloud' : 'local', online: true, pending: 0, lastSyncAt: null, lastError: null };
124
+
125
+ /* ------------------------------------------------------------ cloud mode: the shell */
126
+ const target = opts.target !== undefined ? opts.target : (g.parent && g.parent !== (globalThis as unknown) ? g.parent as { postMessage: (m: unknown, o: string) => void } : null);
127
+ let seq = 0;
128
+ const waiting = new Map<number, (m: Msg) => void>();
129
+ const ask = (op: string, args: Record<string, unknown> = {}, timeoutMs = 20_000): Promise<Msg> => new Promise((resolve) => {
130
+ if (!target) { resolve({ ok: false, error: 'no-shell', message: 'no studio shell around this game' }); return; }
131
+ const q = ++seq;
132
+ const timer = setTimeout(() => { waiting.delete(q); resolve({ ok: false, error: 'timeout', message: 'the play shell did not answer' }); }, timeoutMs);
133
+ waiting.set(q, (m) => { clearTimeout(timer); resolve(m); });
134
+ try { target.postMessage({ t: 'homie-save', q, op, ...args }, '*'); } catch { clearTimeout(timer); waiting.delete(q); resolve({ ok: false, error: 'no-shell' }); }
135
+ });
136
+ const asPlayer = (p: unknown): Player => {
137
+ const o = (p ?? {}) as Partial<Player>;
138
+ return { id: typeof o.id === 'string' ? o.id : null, name: typeof o.name === 'string' ? o.name : 'Guest', guest: o.guest !== false, owner: o.owner === true, signedIn: o.guest === false, local: false };
139
+ };
140
+
141
+ if (cloud && g.addEventListener) {
142
+ g.addEventListener('message', (ev: MessageEvent) => {
143
+ const m = ev.data as Msg;
144
+ if (!m || typeof m !== 'object' || m.t !== 'homie-save') return;
145
+ if (target && ev.source && ev.source !== (target as unknown)) return;
146
+ if (typeof m.q === 'number' && waiting.has(m.q)) { const f = waiting.get(m.q)!; waiting.delete(m.q); f(m); return; }
147
+ if (m.ev === 'player') {
148
+ player = asPlayer(m.player);
149
+ if (m.cache && typeof m.cache === 'object') mirror = { ...(m.cache as Record<string, unknown>) };
150
+ emit('player', player);
151
+ } else if (m.ev === 'status' || m.ev === 'synced') {
152
+ if (m.status) st = { ...(m.status as SavesStatus) };
153
+ emit(m.ev, st);
154
+ } else if (m.ev === 'error') emit('error', { key: m.key, error: m.error, message: m.message });
155
+ else if (m.ev === 'conflict') {
156
+ const c = { key: String(m.key), mine: m.mine ?? null, theirs: m.theirs ?? null, theirsVersion: Number(m.theirsVersion) || 0 };
157
+ if (typeof m.ask === 'number' && opts.onConflict) {
158
+ Promise.resolve().then(() => opts.onConflict!(c)).then((keep) => {
159
+ target?.postMessage({ t: 'homie-save', op: 'resolve', ask: m.ask, ...(keep === undefined ? { keep: 'theirs' } : { keep: 'value', value: keep }) }, '*');
160
+ }, () => target?.postMessage({ t: 'homie-save', op: 'resolve', ask: m.ask, keep: 'theirs' }, '*'));
161
+ return;
162
+ }
163
+ if (m.kept === 'theirs') { if (c.theirs === null) delete mirror[c.key]; else mirror[c.key] = c.theirs; }
164
+ emit('conflict', { ...c, kept: m.kept === 'game' ? 'game' : 'theirs' });
165
+ }
166
+ });
167
+ }
168
+
169
+ /* ------------------------------------------------------------ local mode: this page's storage */
170
+ const memory = new Map<string, string>();
171
+ const store = opts.storage !== undefined && opts.storage !== null ? opts.storage : (() => {
172
+ try { const ls = g.localStorage; if (ls) { ls.setItem('homie-saves.test', '1'); return ls; } } catch { /* sandboxed */ }
173
+ return { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); } };
174
+ })();
175
+ interface LocalDb { saves: Record<string, { v: unknown; ver: number; at: number }>; stats: Record<string, number>; fallen: Memorial[] }
176
+ const LOCAL = `homie-saves.local.${game}`;
177
+ const readLocal = (): LocalDb => { try { const j = JSON.parse(store.getItem(LOCAL) ?? 'null'); if (j && j.saves) return j; } catch { /* fresh */ } return { saves: {}, stats: {}, fallen: [] }; };
178
+ const writeLocal = (db: LocalDb): void => { try { store.setItem(LOCAL, JSON.stringify(db)); } catch { /* full */ } };
179
+
180
+ /* ------------------------------------------------------------ ready */
181
+ const ready: Promise<Player> = (async () => {
182
+ if (!cloud || !target) {
183
+ player = { id: null, name: 'You', guest: true, owner: false, signedIn: false, local: true };
184
+ st = { ...st, mode: 'local' };
185
+ const db = readLocal();
186
+ mirror = Object.fromEntries(Object.entries(db.saves).map(([k, e]) => [k, e.v]));
187
+ return player;
188
+ }
189
+ const r = await ask('hello', { v: SAVES_VERSION, resolver: Boolean(opts.onConflict) }, 15_000);
190
+ if (r.ok) {
191
+ player = asPlayer(r.player);
192
+ mirror = { ...((r.cache as Record<string, unknown>) ?? {}) };
193
+ if (r.status) st = { ...(r.status as SavesStatus) };
194
+ } else st = { ...st, online: false, lastError: String(r.message ?? r.error ?? 'no shell') };
195
+ return player;
196
+ })();
197
+
198
+ const checkKey = (key: string): string | null => (KEY.test(String(key)) ? null : 'a save key is 1 to 64 letters, digits, _ . : or -');
199
+
200
+ const api: Saves = {
201
+ ready,
202
+ get player() { return player; },
203
+ get mode() { return cloud && target ? 'cloud' : 'local'; },
204
+ peek<T>(key: string): T | null { return (key in mirror ? mirror[key] : null) as T | null; },
205
+ async get<T>(key: string): Promise<T | null> {
206
+ await ready;
207
+ if (checkKey(key)) return null;
208
+ if (api.mode === 'local') return (readLocal().saves[key]?.v ?? null) as T | null;
209
+ const r = await ask('get', { key });
210
+ if (r.ok) { if (r.value === null || r.value === undefined) delete mirror[key]; else mirror[key] = r.value; }
211
+ return (r.ok ? r.value ?? null : mirror[key] ?? null) as T | null;
212
+ },
213
+ async set<T>(key: string, value: T): Promise<SetResult> {
214
+ await ready;
215
+ const bad = checkKey(key);
216
+ if (bad) return { ok: false, synced: false, version: 0, error: 'key', message: bad };
217
+ if (value === undefined) return { ok: false, synced: false, version: 0, error: 'value', message: 'a save needs a value; use remove() to delete a key' };
218
+ let copy: unknown;
219
+ try { copy = JSON.parse(JSON.stringify(value)); } catch { return { ok: false, synced: false, version: 0, error: 'value', message: 'a save is JSON' }; }
220
+ mirror[key] = copy;
221
+ if (api.mode === 'local') {
222
+ const db = readLocal();
223
+ const ver = (db.saves[key]?.ver ?? 0) + 1;
224
+ db.saves[key] = { v: copy, ver, at: Date.now() };
225
+ writeLocal(db);
226
+ return { ok: true, synced: true, version: ver };
227
+ }
228
+ const r = await ask('set', { key, value: copy });
229
+ return { ok: Boolean(r.ok), synced: Boolean(r.synced), version: Number(r.version) || 0, ...(r.ok ? {} : { error: String(r.error), message: String(r.message ?? '') }) };
230
+ },
231
+ async remove(key: string): Promise<SetResult> {
232
+ await ready;
233
+ const bad = checkKey(key);
234
+ if (bad) return { ok: false, synced: false, version: 0, error: 'key', message: bad };
235
+ delete mirror[key];
236
+ if (api.mode === 'local') { const db = readLocal(); delete db.saves[key]; writeLocal(db); return { ok: true, synced: true, version: 0 }; }
237
+ const r = await ask('del', { key });
238
+ return { ok: Boolean(r.ok), synced: Boolean(r.synced), version: 0, ...(r.ok ? {} : { error: String(r.error), message: String(r.message ?? '') }) };
239
+ },
240
+ async list(): Promise<SaveEntry[]> {
241
+ await ready;
242
+ if (api.mode === 'local') return Object.entries(readLocal().saves).sort(([a], [b]) => a.localeCompare(b)).map(([key, e]) => ({ key, version: e.ver, updatedAt: e.at }));
243
+ const r = await ask('list');
244
+ return Array.isArray(r.keys) ? r.keys as SaveEntry[] : [];
245
+ },
246
+ async wipe({ keep = [] } = {}) {
247
+ await ready;
248
+ for (const k of Object.keys(mirror)) if (!keep.includes(k)) delete mirror[k];
249
+ if (api.mode === 'local') { const db = readLocal(); for (const k of Object.keys(db.saves)) if (!keep.includes(k)) delete db.saves[k]; writeLocal(db); return { ok: true, synced: true }; }
250
+ const r = await ask('wipe', { keep });
251
+ return { ok: Boolean(r.ok), synced: Boolean(r.synced) };
252
+ },
253
+ stats: {
254
+ async get() {
255
+ await ready;
256
+ if (api.mode === 'local') return { ...readLocal().stats };
257
+ const r = await ask('stats', { kind: 'get' });
258
+ return (r.stats as Record<string, number>) ?? {};
259
+ },
260
+ add: (values) => bump('add', values),
261
+ max: (values) => bump('max', values),
262
+ min: (values) => bump('min', values),
263
+ },
264
+ async fall({ character, summary = {}, wipe = false, keep = [] }) {
265
+ await ready;
266
+ if (wipe) for (const k of Object.keys(mirror)) if (!keep.includes(k)) delete mirror[k];
267
+ if (api.mode === 'local') {
268
+ const db = readLocal();
269
+ const memorial: Memorial = { character: String(character).slice(0, 32), player: player.name, summary, at: Date.now() };
270
+ db.fallen.unshift(memorial);
271
+ db.fallen = db.fallen.slice(0, 50);
272
+ if (wipe) for (const k of Object.keys(db.saves)) if (!keep.includes(k)) delete db.saves[k];
273
+ writeLocal(db);
274
+ return { ok: true, synced: true, memorial };
275
+ }
276
+ const r = await ask('fall', { character, summary, wipe, keep });
277
+ return { ok: Boolean(r.ok), synced: Boolean(r.synced), memorial: (r.memorial as Memorial) ?? null };
278
+ },
279
+ async fallen({ limit = 20, mine = false } = {}) {
280
+ await ready;
281
+ if (api.mode === 'local') return readLocal().fallen.slice(0, limit);
282
+ const r = await ask('fallen', { limit, mine });
283
+ return Array.isArray(r.fallen) ? r.fallen as Memorial[] : [];
284
+ },
285
+ async rename(name: string) {
286
+ await ready;
287
+ if (api.mode === 'local') { player = { ...player, name: String(name).slice(0, 24) }; emit('player', player); return { ok: true, name: player.name }; }
288
+ const r = await ask('rename', { name });
289
+ return { ok: Boolean(r.ok), ...(r.name ? { name: String(r.name) } : {}), ...(r.message ? { message: String(r.message) } : {}) };
290
+ },
291
+ signIn(reason?: string) { if (api.mode === 'cloud') void ask('signin', reason ? { reason } : {}); },
292
+ async flush() {
293
+ await ready;
294
+ if (api.mode === 'local') return st;
295
+ const r = await ask('flush', {}, 30_000);
296
+ if (r.status) st = { ...(r.status as SavesStatus) };
297
+ return st;
298
+ },
299
+ status() { return st; },
300
+ on(ev: SavesEvent, fn: (x: never) => void) {
301
+ const set = listeners.get(ev) ?? new Set();
302
+ set.add(fn as (x: unknown) => void);
303
+ listeners.set(ev, set);
304
+ return () => { set.delete(fn as (x: unknown) => void); };
305
+ },
306
+ } as Saves;
307
+
308
+ async function bump(kind: 'add' | 'max' | 'min', values: Record<string, number>): Promise<{ ok: boolean }> {
309
+ await ready;
310
+ if (api.mode === 'local') {
311
+ const db = readLocal();
312
+ for (const [k, raw] of Object.entries(values ?? {})) {
313
+ const n = Number(raw);
314
+ if (!Number.isFinite(n)) continue;
315
+ const cur = db.stats[k];
316
+ db.stats[k] = cur === undefined ? n : kind === 'add' ? cur + n : kind === 'max' ? Math.max(cur, n) : Math.min(cur, n);
317
+ }
318
+ writeLocal(db);
319
+ return { ok: true };
320
+ }
321
+ const r = await ask('stats', { kind, values });
322
+ return { ok: Boolean(r.ok) };
323
+ }
324
+
325
+ return api;
326
+ }
package/site/SITE.md CHANGED
@@ -156,6 +156,15 @@ The game runs in a sandboxed frame with an opaque origin, which is why its allow
156
156
  (`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
157
157
  SecurityError), so a controller would not work.
158
158
 
159
+ ## Player accounts: `/account/`
160
+
161
+ A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player
162
+ account page at `/account/`, in the studio's look: make an account with a passkey, sign in on another device,
163
+ choose a name, add or remove passkeys, a recovery email (only with a mail sender), download everything, delete
164
+ everything. The play page of such a game carries the saves bridge and a "who is playing" row in its room sheet,
165
+ and shows a passkey sheet over the game when the game asks (`saves.signIn()`); its landing links the account page.
166
+ `/account/` belongs to the site: `site/pages/account/` and `site/public/account/` are refused.
167
+
159
168
  ## The arcade knock
160
169
 
161
170
  A game made with Homie's arcade controls asks its page's origin for a Homie box (`GET /__homie/call`), and a score
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "ember-vale",
3
+ "name": "Ember Vale",
4
+ "blurb": "A hero who lasts for days. Hunt slimes with whoever is here tonight; your level, gold and name follow you to every device. Go hardcore and a death is forever.",
5
+ "players": { "min": 1, "max": 8 },
6
+ "roundSeconds": 90,
7
+ "entry": "src/main.ts",
8
+ "netplay": { "v": 1, "public": true, "movement": "owner" },
9
+ "saves": true,
10
+ "landing": {
11
+ "pitch": "Your hero keeps every level. The night does not.",
12
+ "howToPlay": [
13
+ "Make a hero, or pick up the one you left.",
14
+ "Strike slimes for experience and gold; the King Slime pays best.",
15
+ "Every night ends with a ranking; your hero keeps what it earned.",
16
+ "Hardcore: one life. Fall and you join the Hall of the Fallen."
17
+ ],
18
+ "controls": { "phone": "Drag on the left to move, STRIKE on the right.", "computer": "WASD or arrows to move, Space to strike." }
19
+ }
20
+ }
@@ -0,0 +1,46 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
6
+ <title>Ember Vale</title>
7
+ <link rel="icon" href="data:,">
8
+ <style>
9
+ html, body { margin: 0; height: 100%; background: #0b0f0a; overflow: hidden; overscroll-behavior: none; -webkit-user-select: none; user-select: none; font: 15px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #f3ead2; }
10
+ canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
11
+ .ui { position: fixed; z-index: 3; }
12
+ .pill { left: calc(12px + env(safe-area-inset-left)); top: calc(86px + env(safe-area-inset-top)); display: flex; gap: 6px; }
13
+ .pill button { min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid rgba(243,234,210,.25); background: rgba(10,14,9,.6); color: inherit; font: 600 12.5px/1 inherit; font-family: inherit; cursor: pointer; backdrop-filter: blur(4px); }
14
+ .panel { left: 50%; top: 50%; transform: translate(-50%, -50%); box-sizing: border-box; width: min(360px, calc(100vw - 24px)); padding: 18px; border-radius: 16px; background: rgba(14,19,12,.94); border: 1px solid rgba(243,234,210,.2); box-shadow: 0 20px 60px rgba(0,0,0,.55); -webkit-user-select: text; user-select: text; }
15
+ .panel h2 { margin: 0 0 6px; font-size: 20px; color: #ffcf6e; }
16
+ .panel p { margin: 0 0 12px; color: #c9c0a6; }
17
+ .panel input[type=text] { box-sizing: border-box; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 10px; border: 1px solid rgba(243,234,210,.25); background: #070a06; color: #fff; font: 16px inherit; font-family: inherit; }
18
+ .panel label.hc { display: flex; gap: 8px; align-items: center; margin: 12px 0; color: #e7d9b4; }
19
+ .panel .go { width: 100%; min-height: 46px; border: 0; border-radius: 12px; background: #ffcf6e; color: #1b1405; font: 800 16px inherit; font-family: inherit; cursor: pointer; }
20
+ .panel ol { margin: 0 0 12px; padding-left: 20px; max-height: 40vh; overflow: auto; }
21
+ .panel li { margin: 4px 0; color: #e7d9b4; }
22
+ .panel li small { color: #a39a80; }
23
+ .panel .close { float: right; background: none; border: 0; color: #c9c0a6; font-size: 20px; cursor: pointer; }
24
+ [hidden] { display: none !important; }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <canvas id="c"></canvas>
29
+ <div class="ui pill"><button type="button" id="who"></button><button type="button" id="hall-btn">Hall of the Fallen</button></div>
30
+ <div class="ui panel" id="make" hidden>
31
+ <h2>A new hero</h2>
32
+ <p>Your hero keeps every level and coin, on every device you play on.</p>
33
+ <input type="text" id="hero-name" maxlength="20" placeholder="Name your hero" autocomplete="off">
34
+ <label class="hc"><input type="checkbox" id="hero-hc"> Hardcore: one life. A death is forever, and the Hall remembers.</label>
35
+ <button type="button" class="go" id="hero-go">Into the vale</button>
36
+ </div>
37
+ <div class="ui panel" id="hall" hidden>
38
+ <button type="button" class="close" id="hall-close" aria-label="Close">×</button>
39
+ <h2 id="hall-title">Hall of the Fallen</h2>
40
+ <p id="hall-text">Hardcore heroes who fell in Ember Vale.</p>
41
+ <ol id="hall-list"></ol>
42
+ <button type="button" class="go" id="hall-go" hidden>Make a new hero</button>
43
+ </div>
44
+ <script type="module" crossorigin src="./assets/main.js"></script>
45
+ </body>
46
+ </html>