@homie-rocks/studio 0.11.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.
@@ -0,0 +1,429 @@
1
+ /*
2
+ * EMBER VALE — the persistent-character starter (@homie-rocks/studio/saves; the guide is saves/SAVES.md).
3
+ *
4
+ * Two kinds of state, kept apart on purpose:
5
+ *
6
+ * THE ROOM (createRoom, netplay): tonight's hunt. Bodies, slimes, the clock, who struck what. Rounds ("nights")
7
+ * start the moment the first browser arrives (bots in the empty seats), arrivals take a bot's place, and a room
8
+ * forgets everything 60 s after its last player leaves. That is fine: nothing here has to last.
9
+ *
10
+ * THE SAVE (createSaves): your hero. Name, level, experience, gold, hardcore or not. Loaded when you arrive,
11
+ * saved when it changes, on this device at once and in the studio's cloud when online, so it follows you to
12
+ * every device you sign in on. Lifetime stats (kills, gold, time played) only add up and outlive any hero.
13
+ *
14
+ * The host decides who killed what and sends the killer a `loot` event; only the hero's own browser changes the
15
+ * hero and saves it. A hardcore hero who falls becomes a memorial in the Hall of the Fallen, and its save is wiped
16
+ * in the same step (saves.fall). Canvas 2D on purpose: the point is the pattern, in a few hundred lines.
17
+ */
18
+ import { createControls, createRoom, createSaves, exposePort, q, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
19
+
20
+ /* ------------------------------------------------------------------ rules */
21
+ const W = 1600;
22
+ const H = 1000;
23
+ const R = 22;
24
+ const SPEED = 300;
25
+ const STRIKE_RANGE = 96;
26
+ const STRIKE_MS = 380;
27
+ const DOWN_MS = 4000;
28
+ const DOWN = 1;
29
+ const STRIKING = 2;
30
+ const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
31
+ const xpFor = (level: number): number => Math.round(20 * level ** 1.5);
32
+ const maxHpOf = (level: number): number => 60 + level * 12;
33
+ const damageOf = (level: number): number => 10 + level * 3;
34
+ const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(b, v));
35
+
36
+ /** The hero: the one thing that lasts. Kept in the save key 'hero'. */
37
+ interface Hero { v: 1; name: string; level: number; xp: number; gold: number; hardcore: boolean; kills: number; deaths: number; born: number }
38
+ interface Body extends BodyBase { x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number }
39
+ interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number }
40
+ type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
41
+
42
+ /* ------------------------------------------------------------------ the save */
43
+ const saves = createSaves({ game: 'ember-vale' });
44
+ let hero: Hero | null = null;
45
+ let heroLoaded = false;
46
+ let saveTimer: ReturnType<typeof setTimeout> | undefined;
47
+ /** Saves are cheap, but not free: one write a second at most, however often the hero changes. */
48
+ function saveSoon(): void {
49
+ clearTimeout(saveTimer);
50
+ saveTimer = setTimeout(() => { if (hero) void saves.set('hero', hero); }, 1000);
51
+ }
52
+ async function loadHero(): Promise<void> {
53
+ const h = await saves.get<Hero>('hero');
54
+ hero = h && h.v === 1 ? h : null;
55
+ heroLoaded = true;
56
+ ui.paint();
57
+ if (!hero) ui.make(); else ui.hide('make');
58
+ }
59
+ void saves.ready.then(loadHero);
60
+ // Signing in on this device changes whose saves these are: load that player's hero.
61
+ saves.on('player', () => { void loadHero(); });
62
+
63
+ function gain(xp: number, gold: number): void {
64
+ if (!hero) return;
65
+ hero.xp += xp; hero.gold += gold; hero.kills += 1;
66
+ while (hero.xp >= xpFor(hero.level)) { hero.xp -= xpFor(hero.level); hero.level += 1; banner(`Level ${hero.level}!`); }
67
+ saveSoon();
68
+ void saves.stats.add({ kills: 1, gold });
69
+ void saves.stats.max({ level: hero.level });
70
+ }
71
+
72
+ async function fell(cause: string): Promise<void> {
73
+ if (!hero) return;
74
+ if (!hero.hardcore) {
75
+ hero.deaths += 1;
76
+ hero.gold = Math.floor(hero.gold * 0.9);
77
+ saveSoon();
78
+ void saves.stats.add({ deaths: 1 });
79
+ banner('You fell, and dropped a tenth of your gold.');
80
+ return;
81
+ }
82
+ // Hardcore: a memorial, and the hero's save wiped in the same step. Lifetime stats stay.
83
+ const h = hero;
84
+ hero = null;
85
+ clearTimeout(saveTimer);
86
+ void saves.stats.add({ deaths: 1, heroesLost: 1 });
87
+ const days = Math.max(1, Math.ceil((Date.now() - h.born) / 86400_000));
88
+ await saves.fall({ character: h.name, summary: { level: h.level, gold: h.gold, kills: h.kills, cause, days }, wipe: true, keep: ['settings'] });
89
+ void ui.hall(`${h.name} has fallen`, `Level ${h.level}, ${h.kills} slimes, ${h.gold} gold, slain by ${cause}. The Hall remembers.`, true);
90
+ }
91
+
92
+ /* ------------------------------------------------------------------ the room */
93
+ let slimes: Slime[] = [];
94
+ let slimeSeq = 0;
95
+ let spawnAt = 0;
96
+ let kingAt = 0;
97
+ const me = { x: W / 2, y: H / 2, face: 0, has: false, strikes: 0, flashAt: 0 };
98
+
99
+ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
100
+ game: 'ember-vale',
101
+ maxPlayers: 8,
102
+ minBodies: 3,
103
+ roundSeconds: 90,
104
+ breakSeconds: 8,
105
+ botName: (slot) => BOT_NAMES[slot % BOT_NAMES.length] as string,
106
+ spawn: (slot, i) => {
107
+ const a = (i / 8) * Math.PI * 2;
108
+ const lvl = slot.bot ? 1 + (slot.slot % 4) : 1;
109
+ const x = W / 2 + Math.cos(a) * 160; const y = H / 2 + Math.sin(a) * 120;
110
+ return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, score: 0, x, y, hp: maxHpOf(lvl), maxHp: maxHpOf(lvl), level: lvl, face: a, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: x, ty: y };
111
+ },
112
+ pack: (b) => [q(b.x, 0), q(b.y, 0), Math.max(0, Math.round(b.hp)), b.maxHp, b.level, q(b.face, 2), b.flags],
113
+ unpack: (f, b) => { b.x = f[0] as number; b.y = f[1] as number; b.hp = f[2] as number; b.maxHp = f[3] as number; b.level = f[4] as number; b.face = f[5] as number; b.flags = f[6] as number; },
114
+ angles: [5],
115
+ discrete: [2, 3, 4, 6],
116
+ onRoundStart: () => { slimes = []; spawnAt = 0; kingAt = room.net.now() + 45_000; },
117
+ fastWorld: () => slimes.map((s) => [s.id, Math.round(s.x), Math.round(s.y), Math.max(0, Math.round(s.hp)), s.size]),
118
+ saveWorld: () => ({ slimes, seq: slimeSeq }),
119
+ loadWorld: (w, fast) => {
120
+ slimes = w?.slimes?.map((s) => ({ ...s })) ?? [];
121
+ slimeSeq = Math.max(w?.seq ?? 0, ...slimes.map((s) => s.id));
122
+ for (const [id, x, y, hp, size] of fast ?? []) {
123
+ const s = slimes.find((o) => o.id === id);
124
+ if (s) { s.x = x; s.y = y; s.hp = hp; } else slimes.push({ id, x, y, hp, maxHp: hp, size, hitAt: 0, lastHit: -1 });
125
+ }
126
+ },
127
+ adopt: (b) => { me.x = b.x; me.y = b.y; me.has = true; },
128
+ local: () => (me.has ? ({ ...mine(), x: me.x, y: me.y } as Body) : null),
129
+ onTakeover: (b) => { b.score = 0; b.flags = 0; b.hp = b.maxHp; },
130
+ });
131
+ const net = room.net;
132
+ const myLevel = (): number => hero?.level ?? 1;
133
+ function mine(): Body {
134
+ const b = room.mine();
135
+ return b ?? ({ slot: -1, seat: room.mySeat(), name: net.name, bot: false, score: 0, x: me.x, y: me.y, hp: maxHpOf(myLevel()), maxHp: maxHpOf(myLevel()), level: myLevel(), face: me.face, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: 0, ty: 0 } as Body);
136
+ }
137
+
138
+ /* The host tells the killer (and the fallen) by seat: only the hero's own browser changes its hero. */
139
+ net.on('event', (e: NetEvent) => {
140
+ const d = (e.d ?? {}) as { xp?: number; gold?: number; cause?: string; to?: number };
141
+ if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
142
+ if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
143
+ });
144
+ function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
145
+ if (b.bot || b.seat === null) return;
146
+ if (b.seat === room.mySeat()) { if (k === 'loot') gain(Number(d.xp), Number(d.gold)); else void fell(String(d.cause)); return; }
147
+ net.send(k, { ...d, to: b.seat }, b.seat);
148
+ }
149
+
150
+ function strike(b: Body, now: number): void {
151
+ b.atkAt = now; b.strikeAt = now;
152
+ for (const s of slimes) {
153
+ if (s.hp <= 0 || Math.hypot(s.x - b.x, s.y - b.y) > STRIKE_RANGE + s.size * 10) continue;
154
+ s.hp -= damageOf(b.level);
155
+ s.lastHit = b.slot;
156
+ if (s.hp <= 0) {
157
+ const xp = s.size === 3 ? 60 : 6 * s.size;
158
+ const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
159
+ b.score += xp;
160
+ tell(b, 'loot', { xp, gold });
161
+ }
162
+ }
163
+ slimes = slimes.filter((s) => s.hp > 0);
164
+ }
165
+
166
+ function down(b: Body, now: number, cause: string): void {
167
+ b.flags = DOWN; b.hp = 0; b.downUntil = now + DOWN_MS;
168
+ tell(b, 'down', { cause });
169
+ }
170
+
171
+ function stepHost(dt: number): void {
172
+ const now = net.now();
173
+ const live = room.round?.phase === 'live';
174
+ const myS = room.mySeat();
175
+ for (const b of room.bodies.values()) {
176
+ if (b.flags & DOWN) {
177
+ if (now >= b.downUntil) { b.flags = 0; b.hp = b.maxHp; b.x = W / 2 + (Math.random() - 0.5) * 300; b.y = H / 2 + (Math.random() - 0.5) * 200; room.moved(b); }
178
+ continue;
179
+ }
180
+ let wants = false;
181
+ if (b.bot) {
182
+ // Bots are company, not carries: they hunt only what is near them, and strike slower than a person.
183
+ const near = nearestSlime(b.x, b.y);
184
+ const t = near && Math.hypot(near.x - b.x, near.y - b.y) < 360 ? near : null;
185
+ const tx = t ? t.x : b.tx; const ty = t ? t.y : b.ty;
186
+ if (!t && Math.hypot(b.tx - b.x, b.ty - b.y) < 30) { b.tx = 200 + Math.random() * (W - 400); b.ty = 150 + Math.random() * (H - 300); }
187
+ const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
188
+ if (dist > 60) { b.x += (dx / dist) * SPEED * 0.75 * dt; b.y += (dy / dist) * SPEED * 0.75 * dt; b.face = Math.atan2(dy, dx); }
189
+ wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 900);
190
+ } else if (b.seat === myS) {
191
+ b.x = me.x; b.y = me.y; b.face = me.face; b.level = myLevel(); b.maxHp = maxHpOf(b.level);
192
+ wants = me.strikes > 0; me.strikes = 0;
193
+ } else {
194
+ const a = room.avatar(b);
195
+ if (a) {
196
+ room.bound(b, { x: Number(a[0]), y: Number(a[1]) }, SPEED, dt);
197
+ b.face = Number(a[2]) || 0;
198
+ // The hero's level comes from its own browser (it owns the save). A game with stakes would check it.
199
+ b.level = clamp(Math.floor(Number(a[3]) || 1), 1, 99); b.maxHp = maxHpOf(b.level);
200
+ }
201
+ wants = Boolean(room.presses(b)['strike']);
202
+ }
203
+ b.hp = Math.min(b.hp, b.maxHp);
204
+ b.flags = now - b.strikeAt < 200 ? STRIKING : 0;
205
+ if (wants && live && now - b.atkAt > STRIKE_MS) strike(b, now);
206
+ }
207
+ if (live) stepSlimes(dt, now);
208
+ room.update();
209
+ }
210
+
211
+ function nearestSlime(x: number, y: number): Slime | null {
212
+ let best: Slime | null = null; let bd = Infinity;
213
+ for (const s of slimes) { const d = Math.hypot(s.x - x, s.y - y); if (d < bd) { bd = d; best = s; } }
214
+ return best;
215
+ }
216
+
217
+ function stepSlimes(dt: number, now: number): void {
218
+ const bodies = [...room.bodies.values()].filter((b) => !(b.flags & DOWN));
219
+ const people = bodies.filter((b) => !b.bot).length;
220
+ if (now >= spawnAt && slimes.length < 6 + people * 2) {
221
+ spawnAt = now + 1100;
222
+ const edge = Math.floor(Math.random() * 4);
223
+ const x = edge === 0 ? 30 : edge === 1 ? W - 30 : Math.random() * W;
224
+ const y = edge === 2 ? 30 : edge === 3 ? H - 30 : Math.random() * H;
225
+ const size = Math.random() < 0.25 ? 2 : 1;
226
+ slimes.push({ id: ++slimeSeq, x, y, hp: 18 * size, maxHp: 18 * size, size, hitAt: 0, lastHit: -1 });
227
+ }
228
+ if (now >= kingAt && !slimes.some((s) => s.size === 3)) {
229
+ kingAt = now + 60_000;
230
+ slimes.push({ id: ++slimeSeq, x: W / 2, y: 40, hp: 160, maxHp: 160, size: 3, hitAt: 0, lastHit: -1 });
231
+ room.send('king', {});
232
+ }
233
+ // Slimes hunt heroes: the nearest person, and a bot only when no person is up.
234
+ const prey = bodies.some((b) => !b.bot) ? bodies.filter((b) => !b.bot) : bodies;
235
+ for (const s of slimes) {
236
+ let target: Body | null = null; let bd = Infinity;
237
+ for (const b of prey) { const d = Math.hypot(b.x - s.x, b.y - s.y); if (d < bd) { bd = d; target = b; } }
238
+ const speed = s.size === 3 ? 70 : 95 - s.size * 10;
239
+ if (target) {
240
+ const dx = target.x - s.x; const dy = target.y - s.y; const dist = Math.hypot(dx, dy) || 1;
241
+ s.x += (dx / dist) * speed * dt; s.y += (dy / dist) * speed * dt;
242
+ if (dist < R + 10 + s.size * 10 && now - s.hitAt > 800) {
243
+ s.hitAt = now;
244
+ target.hp -= 5 * s.size + (s.size === 3 ? 10 : 0);
245
+ if (target.hp <= 0) down(target, now, s.size === 3 ? 'the King Slime' : s.size === 2 ? 'a big slime' : 'a slime');
246
+ }
247
+ } else { s.x += Math.sin(now / 900 + s.id) * 20 * dt; s.y += Math.cos(now / 1100 + s.id) * 20 * dt; }
248
+ s.x = clamp(s.x, 20, W - 20); s.y = clamp(s.y, 20, H - 20);
249
+ }
250
+ }
251
+
252
+ /* ------------------------------------------------------------------ input */
253
+ const input = createControls({ actions: { strike: ['Space', 'KeyJ', 'Enter'] }, touch: { buttons: [{ id: 'strike', label: 'STRIKE' }] } });
254
+ function stepMe(dt: number): void {
255
+ const b = room.hosting ? room.mine() : null;
256
+ const isDown = b ? Boolean(b.flags & DOWN) : Boolean(myViewBody()?.flags && (myViewBody()!.flags & DOWN));
257
+ const typing = !ui.idle();
258
+ const m = typing || isDown ? { x: 0, y: 0 } : input.move();
259
+ if (Math.hypot(m.x, m.y) > 0.05) { me.face = Math.atan2(m.y, m.x); me.x = clamp(me.x + m.x * SPEED * dt, R, W - R); me.y = clamp(me.y + m.y * SPEED * dt, R, H - R); me.has = true; }
260
+ if (!typing && input.pressed('strike')) { if (room.hosting) me.strikes += 1; else room.press('strike'); me.flashAt = performance.now(); }
261
+ room.input([q(me.x, 0), q(me.y, 0), q(me.face, 2), myLevel()]);
262
+ }
263
+ function myViewBody(): Body | null { const s = room.mySeat(); return room.view().find((b) => b.seat === s && !b.bot) ?? null; }
264
+
265
+ /* ------------------------------------------------------------------ drawing */
266
+ const canvas = document.getElementById('c') as HTMLCanvasElement;
267
+ const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
268
+ let scale = 1; let ox = 0; let oy = 0;
269
+ function resize(): void {
270
+ const dpr = Math.min(2, devicePixelRatio || 1);
271
+ canvas.width = Math.round(innerWidth * dpr); canvas.height = Math.round(innerHeight * dpr);
272
+ scale = Math.min(canvas.width / W, canvas.height / H); ox = (canvas.width - W * scale) / 2; oy = (canvas.height - H * scale) / 2;
273
+ }
274
+ addEventListener('resize', resize);
275
+ resize();
276
+ let bannerText = ''; let bannerUntil = 0;
277
+ function banner(t: string): void { bannerText = t; bannerUntil = performance.now() + 2600; }
278
+ const SLIME = ['#7ad35a', '#e0a83a', '#c74bd8'];
279
+
280
+ function draw(t: number): void {
281
+ const s = scale;
282
+ ctx.fillStyle = '#0b0f0a'; ctx.fillRect(0, 0, canvas.width, canvas.height);
283
+ ctx.save(); ctx.translate(ox, oy); ctx.scale(s, s);
284
+ // The vale: moss, and embers that drift.
285
+ ctx.fillStyle = '#16210f'; ctx.fillRect(0, 0, W, H);
286
+ for (let i = 0; i < 70; i++) {
287
+ const x = (i * 263) % W; const y = (i * 151 + (t / 40) * ((i % 3) + 1)) % H;
288
+ ctx.fillStyle = i % 5 ? 'rgba(122,211,90,.10)' : 'rgba(255,170,80,.35)';
289
+ ctx.beginPath(); ctx.arc(x, H - y, i % 5 ? 26 : 2.5, 0, Math.PI * 2); ctx.fill();
290
+ }
291
+ const fast = room.fast() ?? [];
292
+ for (const [, x, y, hp, size] of fast) {
293
+ const r = 12 + size * 10;
294
+ ctx.fillStyle = SLIME[size - 1] ?? '#7ad35a';
295
+ ctx.beginPath(); ctx.ellipse(x, y, r, r * (0.78 + Math.sin(t / 180 + x) * 0.06), 0, 0, Math.PI * 2); ctx.fill();
296
+ ctx.fillStyle = '#0b0f0a'; ctx.fillRect(x - r * 0.4, y - r * 0.2, 4, 4); ctx.fillRect(x + r * 0.3, y - r * 0.2, 4, 4);
297
+ if (size === 3) { ctx.fillStyle = '#ffcf6e'; ctx.fillRect(x - 14, y - r - 12, 28, 8); }
298
+ const max = size === 3 ? 160 : 18 * size;
299
+ ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x - r, y + r + 4, r * 2, 4);
300
+ ctx.fillStyle = '#ff7a59'; ctx.fillRect(x - r, y + r + 4, (r * 2 * clamp(hp, 0, max)) / max, 4);
301
+ }
302
+ const mySeat = room.mySeat();
303
+ for (const b of room.view()) {
304
+ const self = !b.bot && b.seat === mySeat;
305
+ const x = self && !room.hosting ? me.x : b.x; const y = self && !room.hosting ? me.y : b.y;
306
+ const isDown = Boolean(b.flags & DOWN);
307
+ ctx.globalAlpha = isDown ? 0.35 : 1;
308
+ if ((b.flags & STRIKING) || (self && performance.now() - me.flashAt < 160)) { ctx.strokeStyle = 'rgba(255,207,110,.7)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(x, y, STRIKE_RANGE * 0.8, 0, Math.PI * 2); ctx.stroke(); }
309
+ ctx.fillStyle = self ? '#ffcf6e' : b.bot ? '#8aa0b8' : '#f3ead2';
310
+ ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill();
311
+ ctx.fillStyle = '#0b0f0a'; ctx.beginPath(); ctx.arc(x + Math.cos(b.face) * 10, y + Math.sin(b.face) * 10, 5, 0, Math.PI * 2); ctx.fill();
312
+ ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(x - 24, y - R - 12, 48, 5);
313
+ ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
314
+ ctx.globalAlpha = 1;
315
+ ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
316
+ ctx.fillText(`${b.name}${b.bot ? ' · bot' : ''} · ${b.level}`, x, y - R - 18);
317
+ }
318
+ ctx.restore();
319
+ hud(t);
320
+ }
321
+
322
+ function hud(t: number): void {
323
+ const k = Math.max(0.8, Math.min(1.4, canvas.height / 900));
324
+ ctx.save(); ctx.scale(k, k);
325
+ const x0 = 14; const y0 = 14;
326
+ ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, 240, 62);
327
+ ctx.textAlign = 'left'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e';
328
+ ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
329
+ if (hero) {
330
+ ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
331
+ ctx.fillStyle = '#ffcf6e'; ctx.fillRect(x0 + 10, y0 + 32, (140 * hero.xp) / xpFor(hero.level), 6);
332
+ ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
333
+ ctx.fillText(`${hero.gold} gold · ${hero.kills} slain`, x0 + 10, y0 + 54);
334
+ }
335
+ const c = room.clock();
336
+ ctx.textAlign = 'center'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#f3ead2';
337
+ const cw = canvas.width / k;
338
+ ctx.fillText(c.phase === 'over' ? `Dawn · night ${c.n + 1} in ${c.secondsLeft}s` : `Night ${c.n} · ${Math.floor(c.secondsLeft / 60)}:${String(c.secondsLeft % 60).padStart(2, '0')}`, cw / 2, 30);
339
+ if (c.phase === 'over') {
340
+ const rows = room.results().slice(0, 5);
341
+ ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, 44, 300, 30 + rows.length * 22);
342
+ ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, 66);
343
+ ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
344
+ rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, 90 + i * 22));
345
+ }
346
+ if (performance.now() < bannerUntil) { ctx.font = '800 22px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e'; ctx.fillText(bannerText, cw / 2, canvas.height / k - 90); }
347
+ const st = saves.status();
348
+ // Under the clock: the pills below the hero panel are the page's own buttons and would cover it.
349
+ if (st.pending && !st.online) { ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#c9c0a6'; ctx.textAlign = 'center'; ctx.fillText('Offline: your hero is saved on this device and syncs when you are back', cw / 2, 52); }
350
+ void t;
351
+ ctx.restore();
352
+ }
353
+
354
+ /* ------------------------------------------------------------------ the little bit of DOM */
355
+ const $ = (id: string): HTMLElement => document.getElementById(id) as HTMLElement;
356
+ const ui = {
357
+ idle: (): boolean => $('make').hidden && $('hall').hidden,
358
+ hide: (id: string): void => { $(id).hidden = true; },
359
+ paint(): void {
360
+ const p = saves.player;
361
+ $('who').textContent = p.local ? 'Playing here only' : p.signedIn ? `${p.name}${p.owner ? ' · owner' : ''}` : 'Guest · keep my hero';
362
+ },
363
+ make(): void {
364
+ ($('hero-name') as HTMLInputElement).value = '';
365
+ $('make').hidden = false;
366
+ $('hall').hidden = true;
367
+ },
368
+ async hall(title: string, text: string, mourn = false): Promise<void> {
369
+ $('hall-title').textContent = title;
370
+ $('hall-text').textContent = text;
371
+ $('hall-go').hidden = !mourn;
372
+ const list = $('hall-list');
373
+ list.textContent = 'Reading the names…';
374
+ $('hall').hidden = false;
375
+ const fallen = await saves.fallen({ limit: 12 });
376
+ list.textContent = '';
377
+ if (!fallen.length) { const li = document.createElement('li'); li.textContent = 'Nobody yet. May it stay that way.'; list.append(li); }
378
+ for (const f of fallen) {
379
+ const li = document.createElement('li');
380
+ const s = f.summary as { level?: number; cause?: string };
381
+ li.textContent = `${f.character}, level ${s.level ?? '?'} `;
382
+ const small = document.createElement('small');
383
+ small.textContent = `(${f.player}) slain by ${s.cause ?? 'the vale'}, ${new Date(f.at).toLocaleDateString()}`;
384
+ li.append(small);
385
+ list.append(li);
386
+ }
387
+ },
388
+ };
389
+ $('who').addEventListener('click', () => { if (saves.player.signedIn) window.open('/account/', '_blank', 'noopener'); else saves.signIn('Keep your hero on every device: make an account with a passkey.'); });
390
+ $('hall-btn').addEventListener('click', () => { void ui.hall('Hall of the Fallen', 'Hardcore heroes who fell in Ember Vale.'); });
391
+ $('hall-close').addEventListener('click', () => { $('hall').hidden = true; if (heroLoaded && !hero) ui.make(); });
392
+ $('hall-go').addEventListener('click', () => ui.make());
393
+ // Keys typed into the name box are words, not moves.
394
+ $('hero-name').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') $('hero-go').click(); });
395
+ $('hero-go').addEventListener('click', () => {
396
+ const typed = ($('hero-name') as HTMLInputElement).value.replace(/\s+/g, ' ').trim().slice(0, 20);
397
+ hero = { v: 1, name: typed || saves.player.name || 'Wanderer', level: 1, xp: 0, gold: 0, hardcore: ($('hero-hc') as HTMLInputElement).checked, kills: 0, deaths: 0, born: Date.now() };
398
+ void saves.set('hero', hero);
399
+ $('make').hidden = true;
400
+ banner(hero.hardcore ? 'One life. Make it count.' : `Welcome to the vale, ${hero.name}.`);
401
+ });
402
+ saves.on('player', () => ui.paint());
403
+ // Time in the vale, a lifetime stat: added every 30 s while the page is open and visible.
404
+ setInterval(() => { if (hero && document.visibilityState === 'visible') void saves.stats.add({ seconds: 30 }); }, 30_000);
405
+
406
+ /* ------------------------------------------------------------------ loop */
407
+ let last = performance.now();
408
+ let frames = 0;
409
+ function frame(t: number): void {
410
+ const dt = Math.min(0.05, (t - last) / 1000);
411
+ last = t;
412
+ stepMe(dt);
413
+ if (room.hosting) stepHost(dt);
414
+ draw(t);
415
+ frames += 1;
416
+ requestAnimationFrame(frame);
417
+ }
418
+
419
+ exposePort(net, {
420
+ view: 'top',
421
+ self: () => (me.has && room.mySeat() !== null ? { x: me.x, y: me.y } : null),
422
+ size: R,
423
+ score: () => { const s = room.mySeat(); return room.view().find((b) => b.seat === s && !b.bot)?.score ?? null; },
424
+ busy: () => !ui.idle(),
425
+ extra: { level: () => hero?.level ?? 0, frames: () => frames, saves: () => saves.status().mode },
426
+ });
427
+
428
+ void net.ready.then(() => { const b = room.mine(); if (b) { me.x = b.x; me.y = b.y; me.has = true; } });
429
+ requestAnimationFrame(frame);