@homie-rocks/studio 0.14.4 → 0.15.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/README.md +12 -5
- package/lib/build.mjs +3 -0
- package/lib/scaffold.mjs +9 -1
- package/lib/template-history.json +27 -0
- package/netplay/NETPLAY.md +119 -13
- package/netplay/netplay.ts +291 -5
- package/package.json +1 -1
- package/port/hud.ts +10 -7
- package/port/room.ts +15 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +28 -0
- package/starters/ember-vale/src/main.ts +37 -5
- package/starters/gem-rush/src/main.ts +45 -15
- package/worker/index.mjs +60 -8
- package/worker/pages.mjs +417 -4
- package/worker/room.mjs +65 -6
- package/worker/site.mjs +29 -4
- package/worker/stats.mjs +4 -3
- package/worker/version.mjs +1 -1
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* The host decides who killed what and sends the killer a `loot` event; only the hero's own browser changes the
|
|
15
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
16
|
* in the same step (saves.fall). Canvas 2D on purpose: the point is the pattern, in a few hundred lines.
|
|
17
|
+
*
|
|
18
|
+
* A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
|
|
19
|
+
* follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
|
|
20
|
+
* The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
|
|
17
21
|
*/
|
|
18
22
|
import { createControls, createRoom, createSaves, exposePort, q, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
|
|
19
23
|
|
|
@@ -40,6 +44,9 @@ interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; s
|
|
|
40
44
|
type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
|
|
41
45
|
|
|
42
46
|
/* ------------------------------------------------------------------ the save */
|
|
47
|
+
/** A spectator (a watcher, or a big screen) has no hero of its own: it never loads, makes or saves one. */
|
|
48
|
+
const shellCfg = (globalThis as { HOMIE_NET?: { want?: string; watch?: boolean } }).HOMIE_NET;
|
|
49
|
+
const lookOnly = shellCfg?.watch === true || shellCfg?.want === 'screen';
|
|
43
50
|
const saves = createSaves({ game: 'ember-vale' });
|
|
44
51
|
let hero: Hero | null = null;
|
|
45
52
|
let heroLoaded = false;
|
|
@@ -50,6 +57,7 @@ function saveSoon(): void {
|
|
|
50
57
|
saveTimer = setTimeout(() => { if (hero) void saves.set('hero', hero); }, 1000);
|
|
51
58
|
}
|
|
52
59
|
async function loadHero(): Promise<void> {
|
|
60
|
+
if (lookOnly) { heroLoaded = true; ui.paint(); return; }
|
|
53
61
|
const h = await saves.get<Hero>('hero');
|
|
54
62
|
hero = h && h.v === 1 ? h : null;
|
|
55
63
|
heroLoaded = true;
|
|
@@ -140,6 +148,7 @@ net.on('event', (e: NetEvent) => {
|
|
|
140
148
|
const d = (e.d ?? {}) as { xp?: number; gold?: number; cause?: string; to?: number };
|
|
141
149
|
if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
|
|
142
150
|
if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
|
|
151
|
+
if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
|
|
143
152
|
});
|
|
144
153
|
function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
|
|
145
154
|
if (b.bot || b.seat === null) return;
|
|
@@ -158,6 +167,8 @@ function strike(b: Body, now: number): void {
|
|
|
158
167
|
const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
|
|
159
168
|
b.score += xp;
|
|
160
169
|
tell(b, 'loot', { xp, gold });
|
|
170
|
+
// Everyone hears who slew it (a watcher on Auto cuts to them); the loot itself is the slayer's alone.
|
|
171
|
+
if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
|
|
161
172
|
}
|
|
162
173
|
}
|
|
163
174
|
slimes = slimes.filter((s) => s.hp > 0);
|
|
@@ -299,10 +310,12 @@ function draw(t: number): void {
|
|
|
299
310
|
ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x - r, y + r + 4, r * 2, 4);
|
|
300
311
|
ctx.fillStyle = '#ff7a59'; ctx.fillRect(x - r, y + r + 4, (r * 2 * clamp(hp, 0, max)) / max, 4);
|
|
301
312
|
}
|
|
302
|
-
|
|
313
|
+
// The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
|
|
314
|
+
const viewS = room.viewSeat();
|
|
303
315
|
for (const b of room.view()) {
|
|
304
|
-
const self = !b.bot && b.seat ===
|
|
305
|
-
const
|
|
316
|
+
const self = !b.bot && b.seat === viewS;
|
|
317
|
+
const local = self && !room.hosting && !net.watching;
|
|
318
|
+
const x = local ? me.x : b.x; const y = local ? me.y : b.y;
|
|
306
319
|
const isDown = Boolean(b.flags & DOWN);
|
|
307
320
|
ctx.globalAlpha = isDown ? 0.35 : 1;
|
|
308
321
|
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(); }
|
|
@@ -325,8 +338,23 @@ function hud(t: number): void {
|
|
|
325
338
|
const x0 = 14; const y0 = 14;
|
|
326
339
|
ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, 240, 62);
|
|
327
340
|
ctx.textAlign = 'left'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e';
|
|
328
|
-
|
|
329
|
-
|
|
341
|
+
if (lookOnly) {
|
|
342
|
+
// A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
|
|
343
|
+
const v = room.viewBody();
|
|
344
|
+
if (v) {
|
|
345
|
+
ctx.fillText(`${v.name} · Lv ${v.level}`, x0 + 10, y0 + 22);
|
|
346
|
+
ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
|
|
347
|
+
ctx.fillStyle = '#7ad35a'; ctx.fillRect(x0 + 10, y0 + 32, (140 * clamp(v.hp, 0, v.maxHp)) / Math.max(1, v.maxHp), 6);
|
|
348
|
+
ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
349
|
+
ctx.fillText(`${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}`, x0 + 10, y0 + 54);
|
|
350
|
+
} else {
|
|
351
|
+
const heroes = room.view().filter((b) => !b.bot).length;
|
|
352
|
+
ctx.fillText('Watching the vale', x0 + 10, y0 + 22);
|
|
353
|
+
ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
354
|
+
ctx.fillText(`${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`, x0 + 10, y0 + 46);
|
|
355
|
+
}
|
|
356
|
+
} else ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
|
|
357
|
+
if (hero && !lookOnly) {
|
|
330
358
|
ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
|
|
331
359
|
ctx.fillStyle = '#ffcf6e'; ctx.fillRect(x0 + 10, y0 + 32, (140 * hero.xp) / xpFor(hero.level), 6);
|
|
332
360
|
ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
@@ -359,6 +387,7 @@ const ui = {
|
|
|
359
387
|
paint(): void {
|
|
360
388
|
const p = saves.player;
|
|
361
389
|
$('who').textContent = p.local ? 'Playing here only' : p.signedIn ? `${p.name}${p.owner ? ' · owner' : ''}` : 'Guest · keep my hero';
|
|
390
|
+
$('who').hidden = lookOnly;
|
|
362
391
|
},
|
|
363
392
|
make(): void {
|
|
364
393
|
($('hero-name') as HTMLInputElement).value = '';
|
|
@@ -416,6 +445,9 @@ function frame(t: number): void {
|
|
|
416
445
|
requestAnimationFrame(frame);
|
|
417
446
|
}
|
|
418
447
|
|
|
448
|
+
// Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
|
|
449
|
+
net.expose({ scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) });
|
|
450
|
+
|
|
419
451
|
exposePort(net, {
|
|
420
452
|
view: 'top',
|
|
421
453
|
self: () => (me.has && room.mySeat() !== null ? { x: me.x, y: me.y } : null),
|
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
* zone (gems inside it score double) is slow state on the keyed state channel,
|
|
17
17
|
* so a joiner has it in its welcome and a promoted host inherits it.
|
|
18
18
|
*
|
|
19
|
+
* WATCH ANY PLAYER (contract revision 5): a watcher (/<game>/watch) never
|
|
20
|
+
* takes a seat. `net.viewSeat` says whose view to draw; the camera follows that
|
|
21
|
+
* player's body as their own browser does, the board marks their row, and with
|
|
22
|
+
* nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a
|
|
23
|
+
* watcher on Auto cuts to whoever just bumped.
|
|
24
|
+
*
|
|
19
25
|
* Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
|
|
20
26
|
*/
|
|
21
|
-
import { createNetplay, Roster, q, lerp, capMove, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
27
|
+
import { createNetplay, Roster, q, lerp, capMove, PALETTE, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
22
28
|
// The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).
|
|
23
29
|
import { exposePort } from '@homie-rocks/studio/port';
|
|
24
30
|
|
|
@@ -38,7 +44,8 @@ const KNOCK_RANGE = 110;
|
|
|
38
44
|
const KNOCK_SPEED = 950;
|
|
39
45
|
const KNOCK_MS = 420;
|
|
40
46
|
const ZONE_MS = 12_000;
|
|
41
|
-
|
|
47
|
+
/** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
|
|
48
|
+
const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
|
|
42
49
|
const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
|
|
43
50
|
const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
|
|
44
51
|
const label = (name: string, bot: boolean): string => (bot ? `${name} · bot` : name);
|
|
@@ -85,6 +92,8 @@ let predictionError = 0;
|
|
|
85
92
|
const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, knockUntil: 0, has: false };
|
|
86
93
|
/** Offline (no shell) plays as a local seat 0, with keys or touch. */
|
|
87
94
|
const mySeat = (): number | null => (net.offline ? 0 : net.seat);
|
|
95
|
+
/** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
|
|
96
|
+
const viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);
|
|
88
97
|
|
|
89
98
|
/* ------------------------------------------------------- replica view */
|
|
90
99
|
const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
|
|
@@ -342,6 +351,9 @@ function knock(b: Body, fx: number, fy: number): void {
|
|
|
342
351
|
}
|
|
343
352
|
function addWave(d: { slot: number }, isKnock = false): void {
|
|
344
353
|
const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
|
|
354
|
+
// The action, for a watcher on Auto: whoever waved (a person, never a bot).
|
|
355
|
+
const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
|
|
356
|
+
if (!isKnock) net.spotlight(waver);
|
|
345
357
|
const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
|
|
346
358
|
const at = mineSlot === d.slot && me.has ? me : pos;
|
|
347
359
|
if (!at) return;
|
|
@@ -527,7 +539,14 @@ function resize(): void {
|
|
|
527
539
|
addEventListener('resize', resize);
|
|
528
540
|
resize();
|
|
529
541
|
|
|
530
|
-
const cam = { x: W / 2, y: H / 2 };
|
|
542
|
+
const cam = { x: W / 2, y: H / 2, scale: 0 };
|
|
543
|
+
/** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
|
|
544
|
+
function seatPos(seat: number): { x: number; y: number } | null {
|
|
545
|
+
if (seat === mySeat() && me.has && !net.watching) return me;
|
|
546
|
+
if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
|
|
547
|
+
const d = [...drawn.values()].find((x) => x.seat === seat);
|
|
548
|
+
return d ? { x: d.x, y: d.y } : null;
|
|
549
|
+
}
|
|
531
550
|
const gemGlow = (() => {
|
|
532
551
|
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R_GEM * 2.8);
|
|
533
552
|
g.addColorStop(0, 'rgba(255,209,102,0.55)'); g.addColorStop(0.4, 'rgba(255,170,60,0.18)'); g.addColorStop(1, 'rgba(255,150,40,0)');
|
|
@@ -536,9 +555,15 @@ const gemGlow = (() => {
|
|
|
536
555
|
function draw(t: number): void {
|
|
537
556
|
const cw = innerWidth; const ch = innerHeight;
|
|
538
557
|
const phone = Math.min(cw, ch) <= 540;
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
const
|
|
558
|
+
// The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.
|
|
559
|
+
const view = viewSeat();
|
|
560
|
+
const followed = net.watching && view !== null ? seatPos(view) : null;
|
|
561
|
+
const overview = net.watching ? !followed : mySeat() === null;
|
|
562
|
+
const wantScale = overview ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);
|
|
563
|
+
// A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
|
|
564
|
+
cam.scale = cam.scale ? cam.scale + (wantScale - cam.scale) * 0.12 : wantScale;
|
|
565
|
+
const scale = cam.scale;
|
|
566
|
+
const target = net.watching ? (followed ?? { x: W / 2, y: H / 2 }) : overview || !me.has ? { x: W / 2, y: H / 2 } : me;
|
|
542
567
|
cam.x += (target.x - cam.x) * 0.2; cam.y += (target.y - cam.y) * 0.2;
|
|
543
568
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
544
569
|
const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
|
|
@@ -588,18 +613,20 @@ function draw(t: number): void {
|
|
|
588
613
|
|
|
589
614
|
// avatars
|
|
590
615
|
const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));
|
|
616
|
+
// `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
|
|
591
617
|
const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
|
|
618
|
+
const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
|
|
592
619
|
if (hosting) {
|
|
593
|
-
for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: b.seat
|
|
620
|
+
for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });
|
|
594
621
|
} else {
|
|
595
622
|
for (const d of drawn.values()) {
|
|
596
|
-
const
|
|
623
|
+
const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
|
|
597
624
|
const s = names.get(d.slot);
|
|
598
|
-
list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x:
|
|
625
|
+
list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });
|
|
599
626
|
}
|
|
600
627
|
}
|
|
601
628
|
for (const a of list) {
|
|
602
|
-
const colour =
|
|
629
|
+
const colour = colourOf(a.slot, a.bot ? null : a.seat);
|
|
603
630
|
ctx.globalAlpha = a.bot ? 0.62 : 1;
|
|
604
631
|
ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV, 0, Math.PI * 2); ctx.fill();
|
|
605
632
|
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
|
|
@@ -607,7 +634,7 @@ function draw(t: number): void {
|
|
|
607
634
|
ctx.globalAlpha = 1;
|
|
608
635
|
ctx.font = `600 ${Math.round(15 / Math.max(0.6, scale))}px ui-sans-serif, system-ui, sans-serif`;
|
|
609
636
|
ctx.textAlign = 'center'; ctx.fillStyle = a.mine ? '#ffffff' : 'rgba(232,236,245,0.8)';
|
|
610
|
-
ctx.fillText(a.mine ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
|
|
637
|
+
ctx.fillText(a.mine && !net.watching ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
|
|
611
638
|
}
|
|
612
639
|
ctx.restore();
|
|
613
640
|
|
|
@@ -640,11 +667,11 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
|
|
|
640
667
|
ctx.textAlign = 'right';
|
|
641
668
|
rows.forEach((a, i) => {
|
|
642
669
|
ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
|
|
643
|
-
ctx.fillText(`${a.mine ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
|
|
670
|
+
ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
|
|
644
671
|
});
|
|
645
672
|
// role badge
|
|
646
673
|
ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
|
|
647
|
-
ctx.fillText(net.offline ? 'OFFLINE HOST' : net.role.toUpperCase(), pad, ch - pad);
|
|
674
|
+
ctx.fillText(net.offline ? 'OFFLINE HOST' : net.watching ? 'WATCHING' : net.role.toUpperCase(), pad, ch - pad);
|
|
648
675
|
if (r && r.phase === 'over' && r.results) {
|
|
649
676
|
const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;
|
|
650
677
|
const x = (cw - w) / 2; const y = (ch - h) / 2;
|
|
@@ -653,10 +680,11 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
|
|
|
653
680
|
ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';
|
|
654
681
|
ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28);
|
|
655
682
|
ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
|
|
683
|
+
const view = viewSeat();
|
|
656
684
|
r.results.slice(0, 6).forEach((row, i) => {
|
|
657
|
-
const mine = row.seat !== null && row.seat ===
|
|
685
|
+
const mine = !row.bot && row.seat !== null && row.seat === view;
|
|
658
686
|
ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';
|
|
659
|
-
ctx.fillText(`${row.place}. ${mine ? 'You' : label(row.name, row.bot)} — ${row.score}`, cw / 2, y + 56 + i * 26);
|
|
687
|
+
ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
|
|
660
688
|
});
|
|
661
689
|
}
|
|
662
690
|
if (!net.offline && !net.connected && net.role !== 'host') {
|
|
@@ -699,6 +727,8 @@ net.expose({
|
|
|
699
727
|
movement: () => MOVEMENT,
|
|
700
728
|
controlResets: () => controlResets,
|
|
701
729
|
predictionError: () => predictionError,
|
|
730
|
+
/** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */
|
|
731
|
+
camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),
|
|
702
732
|
/** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
|
|
703
733
|
arrivals: () => ({ firstStateAt, firstSnapAt }),
|
|
704
734
|
/** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
|
package/worker/index.mjs
CHANGED
|
@@ -16,10 +16,15 @@
|
|
|
16
16
|
* room button shares it (Invite, Big screen, the room code)
|
|
17
17
|
* /<game>/tv the big screen: the same room as a spectator, with a QR code phones scan to join
|
|
18
18
|
* (also /<game>/play?screen=1)
|
|
19
|
+
* /<game>/watch?room= watch a live room from any player's view: the game itself, rendered by this browser
|
|
20
|
+
* as a watcher that never takes a seat, with a strip of the players to switch between
|
|
21
|
+
* (keys 1-9, A for Auto, O for the overview); no room: the busiest public room
|
|
22
|
+
* (NETPLAY.md section 16; game.json "watch": "overview" or false narrows it)
|
|
19
23
|
* /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
|
|
20
24
|
* /<game>/__net?room= the room's netplay socket (Table Durable Object)
|
|
21
25
|
* /<game>/__watch?room= the room's facts, for the shell
|
|
22
26
|
* /<game>/api/lobby which public room to join (Lobby Durable Object)
|
|
27
|
+
* /<game>/api/watch which public room to watch: the busiest one now (nothing is reserved)
|
|
23
28
|
* /api/games, /api/rooms the catalogue plus live counts; every public room playing now (/api/rooms may be
|
|
24
29
|
* cached for 15 s)
|
|
25
30
|
* /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
|
|
@@ -51,17 +56,17 @@
|
|
|
51
56
|
* after `homie-studio storage add` (R2 needs a payment method on the account),
|
|
52
57
|
* MEDIA. Everything else runs on Cloudflare's free Workers plan.
|
|
53
58
|
*
|
|
54
|
-
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev
|
|
59
|
+
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 5), run
|
|
55
60
|
* unchanged inside the Table, so a game that plays in `homie-studio dev`
|
|
56
61
|
* plays the same way here.
|
|
57
62
|
*/
|
|
58
|
-
import { NetRoom } from './room.mjs';
|
|
59
|
-
import { ROOM_ID, badRoomPage, frameAncestors, playPage } from './pages.mjs';
|
|
63
|
+
import { NetRoom, WATCH_POLICIES } from './room.mjs';
|
|
64
|
+
import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
60
65
|
import { qrSvg } from './qr.mjs';
|
|
61
66
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
62
67
|
import {
|
|
63
68
|
SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
|
|
64
|
-
notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
|
|
69
|
+
notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
|
|
65
70
|
} from './site.mjs';
|
|
66
71
|
import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
|
|
67
72
|
import { ownerRoutes } from './stats-page.mjs';
|
|
@@ -307,7 +312,12 @@ async function gameDocument(request, env, url, game, meta, cat) {
|
|
|
307
312
|
const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
|
|
308
313
|
if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`, cat);
|
|
309
314
|
const room = asked ?? 'main';
|
|
310
|
-
|
|
315
|
+
// A watcher (section 16): a screen that never takes a seat, following the player the watch page asks for.
|
|
316
|
+
const policy = watchOf(meta);
|
|
317
|
+
const watching = url.searchParams.get('watch') === '1';
|
|
318
|
+
if (watching && policy === 'off') return new Response(`${meta?.name ?? game} cannot be watched; play it instead.\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
319
|
+
const follow = /^(?:auto|overview|\d{1,2})$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
|
|
320
|
+
const want = watching || url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
|
|
311
321
|
const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
|
|
312
322
|
const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
|
|
313
323
|
// The browser's room key (b: what a kick holds out) and the play page's ticket (t: a game that is not public) ride
|
|
@@ -316,12 +326,13 @@ async function gameDocument(request, env, url, game, meta, cat) {
|
|
|
316
326
|
const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
|
|
317
327
|
const cfg = {
|
|
318
328
|
v: 1,
|
|
319
|
-
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}`,
|
|
329
|
+
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}`,
|
|
320
330
|
room,
|
|
321
331
|
...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
|
|
322
332
|
...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
|
|
323
333
|
...(device ? { device } : {}),
|
|
324
334
|
want,
|
|
335
|
+
...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
|
|
325
336
|
debug: url.searchParams.get('debug') === '1',
|
|
326
337
|
...(meta?.movement ? { movement: meta.movement } : {}),
|
|
327
338
|
// game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
|
|
@@ -619,7 +630,7 @@ async function route(request, env, ctx) {
|
|
|
619
630
|
const max = seatsFor(meta, settings);
|
|
620
631
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
621
632
|
if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
|
|
622
|
-
const door = launch !== 'public' && ['', 'live', 'credits', 'tv', 'play', 'api/lobby'].includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
|
|
633
|
+
const door = launch !== 'public' && ['', 'live', 'credits', 'tv', 'play', 'watch', 'api/lobby', 'api/watch'].includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
|
|
623
634
|
const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
|
|
624
635
|
if (sub === '') {
|
|
625
636
|
if (!door.ok) return shut();
|
|
@@ -672,6 +683,28 @@ async function route(request, env, ctx) {
|
|
|
672
683
|
shareDaily(cat, url, ctx);
|
|
673
684
|
return playPage(cat, meta, { ticket, owner, launch });
|
|
674
685
|
}
|
|
686
|
+
if (sub === 'watch') {
|
|
687
|
+
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
688
|
+
const asked = url.searchParams.get('room');
|
|
689
|
+
if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { watch: true });
|
|
690
|
+
if (!door.ok) return shut();
|
|
691
|
+
const policy = watchOf(meta);
|
|
692
|
+
if (policy === 'off') return noWatchPage(cat, meta);
|
|
693
|
+
const owner = door.owner || (launch === 'public' && await isOwner(request, env));
|
|
694
|
+
const holder = joinHolders(door.sub ?? (owner ? 'o' : null), await accountSub(request, env));
|
|
695
|
+
const ticket = holder ? await ticketFor(env, game, holder) : null;
|
|
696
|
+
await countVisit(request, env, ctx, game, 'watch');
|
|
697
|
+
shareDaily(cat, url, ctx);
|
|
698
|
+
return watchPage(cat, meta, { room: asked, ticket, policy });
|
|
699
|
+
}
|
|
700
|
+
if (sub === 'api/watch') {
|
|
701
|
+
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
702
|
+
// The busiest public room now, to watch; nothing is reserved (a watcher takes no seat).
|
|
703
|
+
const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
|
|
704
|
+
let best = null;
|
|
705
|
+
try { best = await (await lobby().fetch(`https://lobby/busiest${not ? `?not=${encodeURIComponent(not)}` : ''}`)).json(); } catch { best = null; }
|
|
706
|
+
return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
|
|
707
|
+
}
|
|
675
708
|
if (sub === 'api/lobby') {
|
|
676
709
|
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
677
710
|
// A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
|
|
@@ -686,8 +719,13 @@ async function route(request, env, ctx) {
|
|
|
686
719
|
const who = url.searchParams.get('t') ? await ticketSub(env, game, url.searchParams.get('t')) : null;
|
|
687
720
|
if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('this game is not open to you', { status: 403 });
|
|
688
721
|
const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : '';
|
|
722
|
+
// A watcher's socket (section 16): what the game lets watchers see goes with every socket, so a helper that only
|
|
723
|
+
// says `watch` in its hello is held to it too; a game that cannot be watched refuses a watch door's socket here.
|
|
724
|
+
const policy = watchOf(meta);
|
|
725
|
+
const w = sub === '__net' && url.searchParams.get('w') === '1';
|
|
726
|
+
if (w && policy === 'off') return new Response('this game cannot be watched', { status: 403 });
|
|
689
727
|
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
690
|
-
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}`;
|
|
728
|
+
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}`;
|
|
691
729
|
return stub.fetch(new Request(target, request));
|
|
692
730
|
}
|
|
693
731
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
@@ -795,6 +833,9 @@ export class Table {
|
|
|
795
833
|
browser: url.searchParams.get('b') || null,
|
|
796
834
|
via: via || null,
|
|
797
835
|
player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
|
|
836
|
+
// A socket opened through the game's watch door, and what the game lets watchers see (section 16).
|
|
837
|
+
watch: url.searchParams.get('w') === '1',
|
|
838
|
+
watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
|
|
798
839
|
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
799
840
|
qa: isQa(request),
|
|
800
841
|
send: (text) => { try { server.send(text); } catch { /* closed */ } },
|
|
@@ -1018,6 +1059,17 @@ export class Lobby {
|
|
|
1018
1059
|
return json({ ok: true });
|
|
1019
1060
|
}
|
|
1020
1061
|
if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
|
|
1062
|
+
if (url.pathname === '/busiest') {
|
|
1063
|
+
// The public room with the most players now, for a watcher (section 16): it reserves nothing, as a watcher takes
|
|
1064
|
+
// no seat. A closed room, or one named in `not`, is never the answer.
|
|
1065
|
+
const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
|
|
1066
|
+
let best = null;
|
|
1067
|
+
for (const r of this.rooms.values()) {
|
|
1068
|
+
if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now) continue;
|
|
1069
|
+
if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
|
|
1070
|
+
}
|
|
1071
|
+
return json({ room: best?.name ?? null, players: best?.players ?? 0 });
|
|
1072
|
+
}
|
|
1021
1073
|
if (url.pathname === '/office') {
|
|
1022
1074
|
// The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
|
|
1023
1075
|
for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
|