@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.
- package/README.md +28 -16
- package/bin/homie-studio.mjs +53 -9
- package/lib/brain-probe.mjs +154 -0
- package/lib/build.mjs +9 -5
- package/lib/cloudflare.mjs +135 -9
- package/lib/doctor.mjs +18 -2
- package/lib/media.mjs +185 -16
- package/lib/scaffold.mjs +15 -9
- package/lib/template-history.json +56 -0
- package/lib/upgrade.mjs +27 -0
- package/media/MEDIA.md +68 -17
- package/package.json +2 -2
- package/port/index.ts +4 -0
- package/port/view.ts +213 -0
- package/starters/ember-vale/index.html +8 -3
- package/starters/ember-vale/src/main.ts +172 -63
- package/starters/gem-rush/src/main.ts +46 -9
- package/worker/index.mjs +80 -14
- package/worker/version.mjs +1 -1
|
@@ -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
|
|
463
|
+
let dpr = 1; let vw = 1; let vh = 1;
|
|
462
464
|
function resize(): void {
|
|
463
|
-
|
|
464
|
-
canvas.width = Math.round(
|
|
465
|
-
|
|
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
|
|
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();
|
|
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
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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)) +
|
|
531
|
-
const hgt = shown.length *
|
|
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(
|
|
534
|
-
ctx.beginPath(); ctx.moveTo(
|
|
535
|
-
ctx.
|
|
536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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 =
|
|
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.
|
|
654
|
+
ctx.font = TITLE; ctx.fillStyle = '#f3ead2';
|
|
569
655
|
const cw = canvas.width / k;
|
|
570
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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 + (
|
|
635
|
+
cam.scale = cam.scale ? cam.scale + (want.scale - cam.scale) * 0.12 : want.scale;
|
|
626
636
|
const scale = cam.scale;
|
|
627
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
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
|
-
/**
|
|
282
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 =
|
|
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)
|
|
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)
|
|
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
|
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.18.1';
|