@homie-rocks/studio 0.17.0 → 0.18.1

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.
@@ -31,7 +31,7 @@
31
31
  * none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
32
32
  * never from a model; "Quiet AI" hides them.
33
33
  */
34
- import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent, type Skill } from '@homie-rocks/studio/port';
34
+ import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
35
35
  import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
36
36
  import vocabulary from '../agents.json';
37
37
 
@@ -444,6 +444,8 @@ function stepSlimes(dt: number, now: number): void {
444
444
 
445
445
  /* ------------------------------------------------------------------ input */
446
446
  const input = createControls({ actions: { strike: ['Space', 'KeyJ', 'Enter'] }, touch: { buttons: [{ id: 'strike', label: 'STRIKE' }] } });
447
+ // A touch screen has STRIKE at the bottom right: the ask panel stands right above it (index.html, .touch).
448
+ document.body.classList.toggle('touch', input.touch.enabled);
447
449
  function stepMe(dt: number): void {
448
450
  const b = room.hosting ? room.mine() : null;
449
451
  const isDown = b ? Boolean(b.flags & DOWN) : Boolean(myViewBody()?.flags && (myViewBody()!.flags & DOWN));
@@ -458,22 +460,54 @@ function myViewBody(): Body | null { const s = room.mySeat(); return room.view()
458
460
  /* ------------------------------------------------------------------ drawing */
459
461
  const canvas = document.getElementById('c') as HTMLCanvasElement;
460
462
  const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
461
- let scale = 1; let ox = 0; let oy = 0;
463
+ let dpr = 1; let vw = 1; let vh = 1;
462
464
  function resize(): void {
463
- const dpr = Math.min(2, devicePixelRatio || 1);
464
- canvas.width = Math.round(innerWidth * dpr); canvas.height = Math.round(innerHeight * dpr);
465
- scale = Math.min(canvas.width / W, canvas.height / H); ox = (canvas.width - W * scale) / 2; oy = (canvas.height - H * scale) / 2;
465
+ dpr = Math.min(2, devicePixelRatio || 1); vw = innerWidth; vh = innerHeight;
466
+ canvas.width = Math.round(vw * dpr); canvas.height = Math.round(vh * dpr);
467
+ // The page's pills sit under the hero panel, which grows with the screen (a TV's would cover them).
468
+ const pills = document.querySelector<HTMLElement>('.pill');
469
+ if (pills) pills.style.top = `calc(${Math.max(86, Math.round(76 * hudScale() + 10))}px + env(safe-area-inset-top))`;
466
470
  }
471
+ /** CSS px per HUD unit, from the screen's CSS size: 0.7 on a phone either way up, 0.8 on a computer, 1.2 on a TV. */
472
+ function hudScale(): number { return Math.max(Math.min(vw, vh) <= 540 ? 0.7 : 0.8, Math.min(1.4, vh / 900, vw / 560)); }
467
473
  addEventListener('resize', resize);
468
474
  resize();
469
475
  let bannerText = ''; let bannerUntil = 0;
470
476
  function banner(t: string): void { bannerText = t; bannerUntil = performance.now() + 2600; }
471
477
  const SLIME = ['#7ad35a', '#e0a83a', '#c74bd8'];
472
478
 
479
+ /*
480
+ * The camera (port/view.ts). Where the whole vale fits at a size that reads (a computer, a TV) it all shows, as it
481
+ * always did. Where it would be small (a phone, above all held upright, where the vale was a strip a quarter of the
482
+ * screen tall), the vale fills the screen and follows your hero (a watcher: the hero followed), never past the vale's
483
+ * edge, except as far as it takes to keep your hero clear of the panel at the top and STRIKE at the bottom.
484
+ */
485
+ const READABLE = 0.62; // CSS px per vale unit: a body 27 px across, a name 12 px tall
486
+ const INSET = { top: 124, bottom: 100 }; // the hero panel and its pills; STRIKE and the ask panel's foot
487
+ let cam: Fit | null = null;
488
+ let lastDraw = 0;
489
+ // Names keep clear of the guides' bubbles and of the ask panel (a page element over the canvas).
490
+ const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); return p && !p.hidden ? [...bubbleBoxes, p.getBoundingClientRect()] : bubbleBoxes; } });
491
+ let bubbleBoxes: { left: number; top: number; right: number; bottom: number }[] = [];
492
+ let shownLabels: LabelOut[] = [];
493
+ function aim(): Fit {
494
+ const v = lookOnly ? room.viewBody() : null;
495
+ const focus = !lookOnly && me.has ? { x: me.x, y: me.y } : v ? { x: v.x, y: v.y } : null;
496
+ const phone = Math.min(vw, vh) <= 540;
497
+ return fitView({ world: { w: W, h: H }, screen: { w: vw, h: vh }, readable: READABLE, zoom: Math.max(READABLE, Math.min(vw, vh) / (phone ? 560 : 820)), focus, whole: lookOnly && !v, inset: INSET });
498
+ }
499
+
473
500
  function draw(t: number): void {
474
- const s = scale;
501
+ const dt = lastDraw ? Math.min(0.1, (t - lastDraw) / 1000) : 0;
502
+ lastDraw = t;
503
+ const want = aim();
504
+ // A cut when the camera changes kind (a phone turned, a watcher's pick); else it glides.
505
+ cam = !cam || cam.follow !== want.follow ? want : easeView(cam, want, dt, 10);
506
+ const s = cam.scale;
507
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
475
508
  ctx.fillStyle = '#0b0f0a'; ctx.fillRect(0, 0, canvas.width, canvas.height);
476
- ctx.save(); ctx.translate(ox, oy); ctx.scale(s, s);
509
+ ctx.save();
510
+ ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * (vw / 2 - cam.x * s), dpr * (vh / 2 - cam.y * s));
477
511
  // The vale: moss, and embers that drift.
478
512
  ctx.fillStyle = '#16210f'; ctx.fillRect(0, 0, W, H);
479
513
  for (let i = 0; i < 70; i++) {
@@ -494,6 +528,15 @@ function draw(t: number): void {
494
528
  }
495
529
  // The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
496
530
  const viewS = room.viewSeat();
531
+ const sx = (x: number): number => vw / 2 + (x - (cam as Fit).x) * s;
532
+ const sy = (y: number): number => vh / 2 + (y - (cam as Fit).y) * s;
533
+ // Names are drawn after the vale, on the screen, at a size that reads on any screen; they never pile up.
534
+ const fs = clamp(18 * s, 12, 20);
535
+ ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
536
+ const names: LabelIn[] = [];
537
+ const said: { x: number; y: number; head: number; text: string }[] = [];
538
+ const mine: ScreenBox[] = []; // your own hero and its name: no bubble covers them
539
+ const meAt = (() => { const v = room.view().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
497
540
  for (const b of room.view()) {
498
541
  const self = !b.bot && b.seat === viewS;
499
542
  const local = self && !room.hosting && !net.watching;
@@ -508,72 +551,121 @@ function draw(t: number): void {
508
551
  ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(x - 24, y - R - 12, 48, 5);
509
552
  ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
510
553
  ctx.globalAlpha = 1;
511
- ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
512
554
  // An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
513
- ctx.fillText(`${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''}${isGuide ? ' · guide' : ''} · ${b.level}`, x, y - R - 18);
514
- const said = bubbles.get(b.slot);
515
- if (said && said.until > performance.now()) bubble(x, y - R - 44, said.text);
555
+ const text = `${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''}${isGuide ? ' · guide' : ''} · ${b.level}`;
556
+ const near = meAt ? Math.hypot(x - meAt.x, y - meAt.y) : 0;
557
+ // Its spot is above its health bar; its body (and bar) is what other names keep off. People first, then guides,
558
+ // then bots; nearer you first.
559
+ const body = { left: sx(x - 24), top: sy(y - R - 12), right: sx(x + 24), bottom: sy(y + R) };
560
+ if (body.right < 0 || body.left > vw || body.bottom < 0 || body.top > vh) continue; // off screen: no name at the edge
561
+ if (self) { const w = ctx.measureText(text).width; mine.push(body, { left: sx(x) - w / 2 - 6, top: body.top - 4 - fs * 1.2, right: sx(x) + w / 2 + 6, bottom: body.top }); }
562
+ names.push({ key: b.slot, text, x: sx(x), y: body.top - 3, w: ctx.measureText(text).width, h: fs * 1.2, below: body.bottom + 4 + fs * 1.2, body, self, rank: (b.bot ? (isGuide ? 1 : 2) : 0) * 10_000 + near });
563
+ const line = bubbles.get(b.slot);
564
+ // The bubble's tail stops just above the guide's own name, so the name keeps its spot while it speaks.
565
+ if (line && line.until > performance.now()) said.push({ x: sx(x), y: body.top - 3 - fs * 1.2 - 12, head: body.top, text: line.text });
516
566
  }
517
567
  ctx.restore();
568
+ // Speech bubbles first (labels keep clear of them), then the names; your own last, on a dark chip.
569
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
570
+ bubbleBoxes = said.map((b) => bubble(b.x, b.y, b.text, fs, b.head, mine));
571
+ ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
572
+ shownLabels = labels.place(names, dt);
573
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
574
+ for (const l of [...shownLabels].sort((a, b) => Number(Boolean(a.self)) - Number(Boolean(b.self)))) {
575
+ if (l.alpha <= 0) continue;
576
+ ctx.globalAlpha = l.alpha;
577
+ if (l.moved) {
578
+ // Moved off its own spot: a thin line to its body says whose it is.
579
+ const under = l.top > l.y;
580
+ ctx.strokeStyle = 'rgba(231,217,180,.45)'; ctx.lineWidth = 1;
581
+ ctx.beginPath(); ctx.moveTo(l.cx, under ? l.top : l.bottom); ctx.lineTo(l.x, under ? (l.body?.bottom ?? l.top - 6) : l.y + 2); ctx.stroke();
582
+ }
583
+ if (l.self) { ctx.fillStyle = 'rgba(10,14,9,.72)'; ctx.beginPath(); ctx.roundRect(l.left - 5, l.top - 1, l.right - l.left + 10, l.bottom - l.top + 2, 6); ctx.fill(); }
584
+ else { ctx.strokeStyle = 'rgba(8,11,6,.8)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }
585
+ ctx.fillStyle = l.self ? '#ffcf6e' : '#e7d9b4';
586
+ ctx.fillText(l.text, l.cx, l.cy);
587
+ }
588
+ ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
589
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
518
590
  hud(t);
519
591
  }
520
592
 
521
- /** A speech bubble: up to three short lines, above a guide. */
522
- function bubble(x: number, y: number, text: string): void {
523
- ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
593
+ type ScreenBox = { left: number; top: number; right: number; bottom: number };
594
+ const touches = (a: ScreenBox, b: ScreenBox): boolean => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
595
+ /**
596
+ * A speech bubble: up to three short lines, above a guide (screen px). It never covers your own hero or its name
597
+ * (`clearOf`): it moves aside, or higher, and its tail runs down to the guide's head (`head`), so whose line it is
598
+ * stays plain. Returns the box it covers.
599
+ */
600
+ function bubble(x: number, y: number, text: string, fs: number, head: number, clearOf: ScreenBox[]): ScreenBox {
601
+ const f = Math.max(12, fs - 1);
602
+ ctx.font = `600 ${f}px ui-sans-serif, system-ui, sans-serif`;
524
603
  const words = text.split(' ');
525
604
  const lines: string[] = [];
526
605
  let line = '';
527
606
  for (const w of words) { if ((line + ' ' + w).trim().length > 26 && line) { lines.push(line); line = w; } else line = (line + ' ' + w).trim(); }
528
607
  if (line) lines.push(line);
529
608
  const shown = lines.slice(0, 3);
530
- const wid = Math.max(...shown.map((l) => ctx.measureText(l).width)) + 22;
531
- const hgt = shown.length * 20 + 12;
609
+ const wid = Math.max(...shown.map((l) => ctx.measureText(l).width)) + 20;
610
+ const hgt = shown.length * f * 1.25 + 10;
611
+ // Where it goes: above the guide; else beside that, either way; else higher, over whatever it must keep clear of.
612
+ const above = Math.min(y, ...clearOf.map((c) => c.top - 12));
613
+ const spots = [y, above].flatMap((by) => [x, x + wid * 0.6 + 16, x - wid * 0.6 - 16].map((bx) => ({ bx: clamp(bx, wid / 2 + 4, vw - wid / 2 - 4), by })));
614
+ const at = spots.find((p) => p.by - hgt >= 0 && !clearOf.some((c) => touches({ left: p.bx - wid / 2, top: p.by - hgt, right: p.bx + wid / 2, bottom: p.by + 8 }, c))) ?? spots[0]!;
615
+ const cx = at.bx; const by = at.by;
616
+ const tail = clamp(x, cx - wid / 2 + 12, cx + wid / 2 - 12);
532
617
  ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.strokeStyle = 'rgba(127,216,200,.75)'; ctx.lineWidth = 2;
533
- ctx.beginPath(); ctx.roundRect(x - wid / 2, y - hgt, wid, hgt, 10); ctx.fill(); ctx.stroke();
534
- ctx.beginPath(); ctx.moveTo(x - 7, y); ctx.lineTo(x + 7, y); ctx.lineTo(x, y + 9); ctx.closePath(); ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.fill();
535
- ctx.fillStyle = '#e9fff9'; ctx.textAlign = 'center';
536
- shown.forEach((l, i) => ctx.fillText(l, x, y - hgt + 22 + i * 20));
618
+ ctx.beginPath(); ctx.roundRect(cx - wid / 2, by - hgt, wid, hgt, 10); ctx.fill(); ctx.stroke();
619
+ ctx.beginPath(); ctx.moveTo(tail - 6, by); ctx.lineTo(tail + 6, by); ctx.lineTo(tail, by + 8); ctx.closePath(); ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.fill();
620
+ if (head > by + 12 || tail !== x) { ctx.strokeStyle = 'rgba(127,216,200,.75)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(tail, by + 8); ctx.lineTo(x, head - 2); ctx.stroke(); }
621
+ ctx.fillStyle = '#e9fff9'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
622
+ shown.forEach((l, i) => ctx.fillText(l, cx, by - hgt + 5 + f * 1.25 * (i + 0.5)));
623
+ ctx.textBaseline = 'alphabetic';
624
+ return { left: cx - wid / 2, top: by - hgt, right: cx + wid / 2, bottom: by + 8 };
537
625
  }
538
626
 
539
627
  function hud(t: number): void {
540
- const k = Math.max(0.8, Math.min(1.4, canvas.height / 900));
628
+ // Device px per HUD unit: as before on an upright phone, a computer and a TV; readable on a phone on its side and on
629
+ // a high-density computer screen (it was sized by device pixels, so half as big there).
630
+ const k = dpr * hudScale();
541
631
  ctx.save(); ctx.scale(k, k);
542
632
  const x0 = 14; const y0 = 14;
543
- ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, 240, 62);
544
- ctx.textAlign = 'left'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e';
545
- if (lookOnly) {
546
- // A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
547
- const v = room.viewBody();
548
- if (v) {
549
- ctx.fillText(`${v.name} · Lv ${v.level}`, x0 + 10, y0 + 22);
550
- ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
551
- ctx.fillStyle = '#7ad35a'; ctx.fillRect(x0 + 10, y0 + 32, (140 * clamp(v.hp, 0, v.maxHp)) / Math.max(1, v.maxHp), 6);
552
- ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
553
- ctx.fillText(`${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}`, x0 + 10, y0 + 54);
554
- } else {
555
- const heroes = room.view().filter((b) => !b.bot).length;
556
- ctx.fillText('Watching the vale', x0 + 10, y0 + 22);
557
- ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
558
- ctx.fillText(`${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`, x0 + 10, y0 + 46);
559
- }
560
- } else ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
561
- if (hero && !lookOnly) {
633
+ const TITLE = '700 16px ui-sans-serif, system-ui, sans-serif';
634
+ const SMALL = '600 13px ui-sans-serif, system-ui, sans-serif';
635
+ // A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
636
+ const v = lookOnly ? room.viewBody() : null;
637
+ const heroes = room.view().filter((b) => !b.bot).length;
638
+ const title = lookOnly ? (v ? `${v.name} · Lv ${v.level}` : 'Watching the vale') : hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…';
639
+ const sub = lookOnly ? (v ? `${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}` : `${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`) : hero ? `${hero.gold} gold · ${hero.kills} slain` : '';
640
+ const bar = v ? { colour: '#7ad35a', of: clamp(v.hp, 0, v.maxHp) / Math.max(1, v.maxHp) } : hero && !lookOnly ? { colour: '#ffcf6e', of: hero.xp / xpFor(hero.level) } : null;
641
+ // The panel is as wide as what it says: on a phone the vale runs under it, and the clock sits beside it.
642
+ ctx.font = TITLE; const titleW = ctx.measureText(title).width;
643
+ ctx.font = SMALL; const subW = sub ? ctx.measureText(sub).width : 0;
644
+ const pw = Math.max(bar ? 140 : 0, titleW, subW) + 20;
645
+ ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, pw, 62);
646
+ ctx.textAlign = 'left'; ctx.font = TITLE; ctx.fillStyle = '#ffcf6e';
647
+ ctx.fillText(title, x0 + 10, y0 + 22);
648
+ if (bar) {
562
649
  ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
563
- ctx.fillStyle = '#ffcf6e'; ctx.fillRect(x0 + 10, y0 + 32, (140 * hero.xp) / xpFor(hero.level), 6);
564
- ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
565
- ctx.fillText(`${hero.gold} gold · ${hero.kills} slain`, x0 + 10, y0 + 54);
650
+ ctx.fillStyle = bar.colour; ctx.fillRect(x0 + 10, y0 + 32, 140 * clamp(bar.of, 0, 1), 6);
566
651
  }
652
+ if (sub) { ctx.font = SMALL; ctx.fillStyle = '#e7d9b4'; ctx.fillText(sub, x0 + 10, y0 + (bar ? 54 : 46)); }
567
653
  const c = room.clock();
568
- ctx.textAlign = 'center'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#f3ead2';
654
+ ctx.font = TITLE; ctx.fillStyle = '#f3ead2';
569
655
  const cw = canvas.width / k;
570
- 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);
656
+ const clock = 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')}`;
657
+ // Top centre; on a narrow screen where that would touch the panel, just under it.
658
+ if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
659
+ else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
660
+ ctx.textAlign = 'center';
571
661
  if (c.phase === 'over') {
572
662
  const rows = room.results().slice(0, 5);
573
- ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, 44, 300, 30 + rows.length * 22);
574
- ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, 66);
663
+ // Under the page's own pills on a narrow screen (they are buttons, above the canvas), else under the clock.
664
+ const top = vw <= 540 ? 132 * (dpr / k) : 44;
665
+ ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, top, 300, 30 + rows.length * 22);
666
+ ctx.font = TITLE; ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, top + 22);
575
667
  ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
576
- rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, 90 + i * 22));
668
+ rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, top + 46 + i * 22));
577
669
  }
578
670
  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); }
579
671
  const st = saves.status();
@@ -640,6 +732,7 @@ setInterval(() => { if (hero && document.visibilityState === 'visible') void sav
640
732
  /** The guide nearest my hero (within 340 px), and the asks I can make of it: drawn as buttons, four times a second. */
641
733
  let asksFor = -1;
642
734
  let asksSig = '';
735
+ const askEls = new Map<string, HTMLButtonElement>();
643
736
  function paintAsks(): void {
644
737
  const panel = $('asks');
645
738
  const mySeat = room.mySeat();
@@ -649,18 +742,21 @@ function paintAsks(): void {
649
742
  for (const b of room.view()) {
650
743
  const sl = (net.slots ?? []).find((x) => x.slot === b.slot);
651
744
  if (!sl?.agent || sl.agent.role !== 'guide') continue;
745
+ // The guide the panel is for keeps it while it is in reach, unless another is much nearer: two guides at the
746
+ // party's side would otherwise swap it four times a second, and a thumb's tap would land on a button just redrawn.
652
747
  const d = Math.hypot(b.x - me.x, b.y - me.y);
653
- if (d < 340 && (!best || d < best.d)) best = { slot: b.slot, name: b.name, d };
748
+ const rank = d - (b.slot === asksFor ? 120 : 0);
749
+ if (d < 340 && (!best || rank < best.d)) best = { slot: b.slot, name: b.name, d: rank };
654
750
  }
655
751
  }
656
752
  if (!best) { panel.hidden = true; asksFor = -1; asksSig = ''; return; }
657
753
  const quests = (net.stateOf<string[]>('quests') ?? (room.hosting ? openQuests() : ['slime-hunt'])).slice(0, 3);
658
- // Help with each open quest, one place to be led to (not the one I am in), and "No thanks"; three on a phone.
754
+ // Help with each open quest, one place to be led to (not the one I am in), and "No thanks"; three on a phone (either way up).
659
755
  const zone = zoneOf(me.x, me.y);
660
756
  const all = agents.askButtons(best.slot, { quests });
661
757
  const lead = all.filter((b) => b.k === 'lead_me' && b.args['place'] !== zone).slice(0, 1);
662
758
  const order = [...all.filter((b) => b.k === 'ask_help'), ...lead, ...all.filter((b) => b.k === 'no_thanks')];
663
- const buttons = order.slice(0, innerWidth < 540 ? 3 : 5);
759
+ const buttons = order.slice(0, Math.min(innerWidth, innerHeight) < 540 ? 3 : 5);
664
760
  const sig = `${best.slot}|${best.name}|${buttons.map((b) => b.text).join('|')}`;
665
761
  panel.hidden = false;
666
762
  if (sig === asksSig) return;
@@ -668,19 +764,29 @@ function paintAsks(): void {
668
764
  asksFor = best.slot;
669
765
  panel.dataset['slot'] = String(best.slot);
670
766
  $('asks-who').textContent = `${best.name} · guide`;
767
+ // A button that stays is the same element (a quest that opens or closes adds or takes one): a thumb's tap that lands
768
+ // while the list changes around it still counts.
671
769
  const list = $('asks-list');
672
- list.textContent = '';
673
- for (const b of buttons) {
674
- const btn = document.createElement('button');
675
- btn.type = 'button';
676
- btn.textContent = b.text;
677
- btn.dataset['ask'] = b.k;
678
- btn.addEventListener('click', () => {
679
- if (asksFor < 0) return;
680
- if (agents.ask(asksFor, b.k, b.args)) { logGuide({ ev: 'asked', slot: asksFor, k: b.k, args: b.args }); btn.classList.add('sent'); setTimeout(() => btn.classList.remove('sent'), 900); }
681
- });
682
- list.append(btn);
683
- }
770
+ const keep = new Set<string>();
771
+ buttons.forEach((b, i) => {
772
+ const key = `${b.k}|${JSON.stringify(b.args)}`;
773
+ keep.add(key);
774
+ let el = askEls.get(key);
775
+ if (!el) {
776
+ const btn = document.createElement('button');
777
+ btn.type = 'button';
778
+ btn.dataset['ask'] = b.k;
779
+ btn.addEventListener('click', () => {
780
+ if (asksFor < 0) return;
781
+ if (agents.ask(asksFor, b.k, b.args)) { logGuide({ ev: 'asked', slot: asksFor, k: b.k, args: b.args }); btn.classList.add('sent'); setTimeout(() => btn.classList.remove('sent'), 900); }
782
+ });
783
+ askEls.set(key, btn);
784
+ el = btn;
785
+ }
786
+ if (el.textContent !== b.text) el.textContent = b.text;
787
+ if (list.children[i] !== el) list.insertBefore(el, list.children[i] ?? null);
788
+ });
789
+ for (const [key, el] of askEls) if (!keep.has(key)) { el.remove(); askEls.delete(key); }
684
790
  }
685
791
  setInterval(paintAsks, 250);
686
792
 
@@ -707,6 +813,9 @@ net.expose({
707
813
  me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
708
814
  log: guideLog.slice(),
709
815
  }),
816
+ // Where the camera looks, and the names as drawn (boxes only: the e2e probe counts overlaps and checks your own).
817
+ camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
818
+ labels: () => shownLabels.map((l) => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
710
819
  });
711
820
 
712
821
  exposePort(net, {
@@ -32,8 +32,9 @@
32
32
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
33
33
  */
34
34
  import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
35
- // The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).
36
- import { exposePort } from '@homie-rocks/studio/port';
35
+ // The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
36
+ // and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
37
+ import { createLabels, exposePort, fitView, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
37
38
 
38
39
  /* ------------------------------------------------------------------ rules */
39
40
  const W = 1600;
@@ -601,6 +602,9 @@ addEventListener('resize', resize);
601
602
  resize();
602
603
 
603
604
  const cam = { x: W / 2, y: H / 2, scale: 0 };
605
+ const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }) });
606
+ let shownLabels: LabelOut[] = [];
607
+ let lastDraw = 0;
604
608
  /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
605
609
  function seatPos(seat: number): { x: number; y: number } | null {
606
610
  if (seat === mySeat() && me.has && !net.watching) return me;
@@ -620,12 +624,17 @@ function draw(t: number): void {
620
624
  const view = viewSeat();
621
625
  const followed = net.watching && view !== null ? seatPos(view) : null;
622
626
  const overview = net.watching ? !followed : mySeat() === null;
623
- const wantScale = overview ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);
627
+ // The camera: close on the body whose view this is, never past the arena's edge (except as far as it takes to keep
628
+ // that body clear of the HUD), and never so far out that an empty band shows beside the arena (held upright, a
629
+ // phone showed the arena as a band with a third of the screen dark below or above it; now the arena fills the
630
+ // screen). Following nobody: the whole arena.
631
+ const focus = net.watching ? followed : overview || !me.has ? null : me;
632
+ const want = overview ? { scale: Math.min(cw / (W + 80), ch / (H + 80)), x: W / 2, y: H / 2 }
633
+ : fitView({ world: { w: W, h: H }, screen: { w: cw, h: ch }, readable: 0, zoom: Math.min(cw, ch) / (phone ? 560 : 820), focus, inset: { top: 52, bottom: 36 } });
624
634
  // A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
625
- cam.scale = cam.scale ? cam.scale + (wantScale - cam.scale) * 0.12 : wantScale;
635
+ cam.scale = cam.scale ? cam.scale + (want.scale - cam.scale) * 0.12 : want.scale;
626
636
  const scale = cam.scale;
627
- const target = net.watching ? (followed ?? { x: W / 2, y: H / 2 }) : overview || !me.has ? { x: W / 2, y: H / 2 } : me;
628
- cam.x += (target.x - cam.x) * 0.2; cam.y += (target.y - cam.y) * 0.2;
637
+ cam.x += (want.x - cam.x) * 0.2; cam.y += (want.y - cam.y) * 0.2;
629
638
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
630
639
  const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
631
640
  bg.addColorStop(0, '#0d1426'); bg.addColorStop(1, '#04060c');
@@ -686,6 +695,11 @@ function draw(t: number): void {
686
695
  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) });
687
696
  }
688
697
  }
698
+ // Names go on after the arena, on the screen, so they read at one size on any screen and never pile up.
699
+ const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
700
+ ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
701
+ const tags: LabelIn[] = [];
702
+ const viewAt = list.find((a) => a.mine) ?? null;
689
703
  for (const a of list) {
690
704
  const colour = colourOf(a.slot, a.bot ? null : a.seat);
691
705
  ctx.globalAlpha = a.bot ? 0.62 : 1;
@@ -693,11 +707,32 @@ function draw(t: number): void {
693
707
  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();
694
708
  if (a.mine) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV + 7, 0, Math.PI * 2); ctx.stroke(); }
695
709
  ctx.globalAlpha = 1;
696
- ctx.font = `600 ${Math.round(15 / Math.max(0.6, scale))}px ui-sans-serif, system-ui, sans-serif`;
697
- ctx.textAlign = 'center'; ctx.fillStyle = a.mine ? '#ffffff' : 'rgba(232,236,245,0.8)';
698
- ctx.fillText(a.mine && !net.watching ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
710
+ const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
711
+ const sx = cw / 2 + (a.x - cam.x) * scale; const sy = ch / 2 + (a.y - cam.y) * scale; const r = (R_AV + (a.mine ? 7 : 0)) * scale;
712
+ if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge
713
+ // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
714
+ tags.push({ key: a.slot, text, x: sx, y: sy - r - 5, w: ctx.measureText(text).width, h: fs * 1.2, below: sy + r + 4 + fs * 1.2, body: { left: sx - r, top: sy - r, right: sx + r, bottom: sy + r }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) : 0) });
699
715
  }
700
716
  ctx.restore();
717
+ shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));
718
+ lastDraw = t;
719
+ ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
720
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
721
+ for (const l of [...shownLabels].sort((x, y) => Number(Boolean(x.self)) - Number(Boolean(y.self)))) {
722
+ if (l.alpha <= 0) continue;
723
+ ctx.globalAlpha = l.alpha;
724
+ if (l.moved) {
725
+ // Off its own spot: a thin line to its body says whose it is.
726
+ const under = l.top > l.y;
727
+ ctx.strokeStyle = 'rgba(232,236,245,0.4)'; ctx.lineWidth = 1;
728
+ ctx.beginPath(); ctx.moveTo(l.cx, under ? l.top : l.bottom); ctx.lineTo(l.x, under ? (l.body?.bottom ?? l.top - 6) : l.y + 2); ctx.stroke();
729
+ }
730
+ if (l.self) { ctx.fillStyle = 'rgba(6,10,20,0.7)'; ctx.beginPath(); ctx.roundRect(l.left - 5, l.top - 1, l.right - l.left + 10, l.bottom - l.top + 2, 6); ctx.fill(); }
731
+ else { ctx.strokeStyle = 'rgba(4,6,12,0.85)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }
732
+ ctx.fillStyle = l.self ? '#ffffff' : 'rgba(232,236,245,0.85)';
733
+ ctx.fillText(l.text, l.cx, l.cy);
734
+ }
735
+ ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
701
736
 
702
737
  hud(cw, ch, phone, list);
703
738
  if (stick.active) {
@@ -797,6 +832,8 @@ net.expose({
797
832
  predictionError: () => predictionError,
798
833
  /** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */
799
834
  camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),
835
+ /** The names as drawn (boxes, never the text): the e2e probe counts overlaps and checks your own. */
836
+ labels: () => shownLabels.map((l) => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
800
837
  /** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
801
838
  arrivals: () => ({ firstStateAt, firstSnapAt }),
802
839
  /** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
package/worker/index.mjs CHANGED
@@ -37,7 +37,10 @@
37
37
  * knocks for a Homie box, so it stops knocking (a studio site is not a box)
38
38
  * /.well-known/homie-studio.json what the homie.rocks directory reads
39
39
  * /games/<id>/source.json a public game's source for remixing, with its credit and licence (worker/license.mjs)
40
- * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
40
+ * /media/<key> a loose file in the studio's R2 (`media put`), with byte ranges
41
+ * /music/<slug>/<file>, /videos/<slug>/<file> a song's or video's files: from the site's own files, or, for a
42
+ * file `media move` put in the studio's R2 (the catalogue names its key), from R2 at the
43
+ * same address, with byte ranges, HEAD, ETag and the site's own cache headers
41
44
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
42
45
  * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
43
46
  * /_studio/office the owner's back office: live rooms and who is in them, kick, mute, announce, close,
@@ -278,21 +281,50 @@ async function assetWithRange(request, env, url) {
278
281
  return new Response(body.subarray(r.start, r.end + 1), { status: 206, headers });
279
282
  }
280
283
 
281
- /** A file from the studio's R2 bucket, with byte ranges. */
282
- async function mediaObject(request, env, key) {
284
+ /** Does an If-None-Match header name this ETag (or `*`)? Weak and strong forms compare alike for a GET. */
285
+ function etagMatches(header, etag) {
286
+ if (!header || !etag) return false;
287
+ const bare = (t) => String(t).trim().replace(/^W\//, '');
288
+ return header.split(',').some((t) => t.trim() === '*' || bare(t) === bare(etag));
289
+ }
290
+
291
+ /**
292
+ * A file from the studio's R2 bucket, with byte ranges (206), HEAD, and If-None-Match (304).
293
+ * `cache`: the Cache-Control it is served with. /media/<key> keeps its hour; a song or video served at its own
294
+ * address (media move) gets exactly what the site's static files get, `public, max-age=0, must-revalidate` with an
295
+ * ETag, so moving a file to R2 changes nothing a browser or a phone sees. `type`: the catalogue's type, for an
296
+ * object stored without one.
297
+ */
298
+ async function mediaObject(request, env, key, { cache = 'public, max-age=3600', type = null } = {}) {
283
299
  const range = request.headers.get('range');
300
+ const inm = request.headers.get('if-none-match');
301
+ const headOnly = request.method === 'HEAD';
284
302
  let head = null;
285
- if (range) head = await env.MEDIA.head(key);
286
- if (range && !head) return new Response('not found', { status: 404 });
287
- const r = head ? parseRange(range, head.size) : null;
288
- if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}` } });
303
+ if (range || inm || headOnly) {
304
+ head = await env.MEDIA.head(key);
305
+ if (!head) return new Response('not found', { status: 404 });
306
+ }
307
+ const base = (obj) => {
308
+ const headers = new Headers();
309
+ obj.writeHttpMetadata?.(headers);
310
+ if (!headers.get('content-type') && type) headers.set('content-type', type);
311
+ if (obj.httpEtag) headers.set('etag', obj.httpEtag);
312
+ if (obj.uploaded) headers.set('last-modified', new Date(obj.uploaded).toUTCString());
313
+ headers.set('cache-control', cache);
314
+ headers.set('accept-ranges', 'bytes');
315
+ return headers;
316
+ };
317
+ if (head && inm && etagMatches(inm, head.httpEtag)) return new Response(null, { status: 304, headers: base(head) });
318
+ const r = head && range ? parseRange(range, head.size) : null;
319
+ if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}`, 'accept-ranges': 'bytes' } });
320
+ if (headOnly) {
321
+ const headers = base(head);
322
+ headers.set('content-length', String(head.size));
323
+ return new Response(null, { headers });
324
+ }
289
325
  const obj = await env.MEDIA.get(key, r ? { range: { offset: r.start, length: r.end - r.start + 1 } } : undefined);
290
326
  if (!obj) return new Response('not found', { status: 404 });
291
- const headers = new Headers();
292
- obj.writeHttpMetadata(headers);
293
- headers.set('etag', obj.httpEtag);
294
- headers.set('cache-control', 'public, max-age=3600');
295
- headers.set('accept-ranges', 'bytes');
327
+ const headers = base(obj);
296
328
  if (r) {
297
329
  headers.set('content-range', `bytes ${r.start}-${r.end}/${head.size}`);
298
330
  headers.set('content-length', String(r.end - r.start + 1));
@@ -302,6 +334,30 @@ async function mediaObject(request, env, key) {
302
334
  return new Response(obj.body, { headers });
303
335
  }
304
336
 
337
+ /** The same Cache-Control the site's static files are served with (Workers static assets' default). */
338
+ const ASSET_CACHE = 'public, max-age=0, must-revalidate';
339
+
340
+ /**
341
+ * A song's or video's file that `media move` put in R2, by the address it always had (/music/<slug>/x.mp3,
342
+ * /videos/<slug>/x.mp4): the catalogue names its key (`r2`). Only files the catalogue lists as public are served this
343
+ * way, never any other key of the bucket.
344
+ */
345
+ async function heldInR2(env, getCat, path) {
346
+ if (!env.MEDIA) return null;
347
+ const cat = await getCat();
348
+ let want;
349
+ try { want = decodeURIComponent(path); } catch { return null; }
350
+ for (const e of [...(cat.songs ?? []), ...(cat.videos ?? [])]) {
351
+ for (const f of e.files ?? []) {
352
+ if (typeof f.r2 !== 'string' || !f.r2 || f.r2.startsWith('players/') || typeof f.url !== 'string') continue;
353
+ let at;
354
+ try { at = decodeURIComponent(f.url); } catch { continue; }
355
+ if (at === want) return f;
356
+ }
357
+ }
358
+ return null;
359
+ }
360
+
305
361
  /**
306
362
  * A catalogue media row as the directory reads it (absolute addresses). A song's cover (a video's poster) is its
307
363
  * own, else the manifest's default, else the landing still of the game it was made for (site.mjs mediaArt).
@@ -617,7 +673,12 @@ async function route(request, env, ctx) {
617
673
  }
618
674
  if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.', cat);
619
675
  const file = await assetWithRange(request, env, url);
620
- if (file.status === 404) return notFoundPage('Nothing here.', cat);
676
+ if (file.status === 404) {
677
+ // Not in the site's files: a song's or video's file that lives in the studio's R2 (media move), same address.
678
+ const held = await heldInR2(env, getCat, path);
679
+ if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
680
+ return notFoundPage('Nothing here.', cat);
681
+ }
621
682
  return file;
622
683
  }
623
684
  if (path === '/' || path === '/index.html') {
@@ -928,7 +989,12 @@ async function weekOf(env, game) {
928
989
  /** Everything else is a file of the built site (site/public, the games' files), with byte ranges for media. */
929
990
  async function fallback(request, env, url, getCat) {
930
991
  const asset = MEDIA_FILE.test(url.pathname) && request.headers.get('range') ? await assetWithRange(request, env, url) : await env.ASSETS.fetch(request);
931
- if (asset.status === 404) return notFoundPage('Nothing here.', await getCat());
992
+ if (asset.status === 404) {
993
+ // A manifest file outside music/ and videos/ that media move put in R2 keeps its address too.
994
+ const held = (request.method === 'GET' || request.method === 'HEAD') ? await heldInR2(env, getCat, url.pathname) : null;
995
+ if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
996
+ return notFoundPage('Nothing here.', await getCat());
997
+ }
932
998
  return asset;
933
999
  }
934
1000
 
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.17.0';
2
+ export const STUDIO_VERSION_TAG = '0.18.1';