@homie-rocks/studio 0.18.0 → 0.18.2
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/brain-probe.mjs +154 -0
- package/lib/doctor.mjs +18 -2
- package/lib/template-history.json +202 -9
- package/lib/upgrade.mjs +38 -11
- package/package.json +1 -1
- package/port/index.ts +4 -0
- package/port/room.ts +36 -9
- package/port/view.ts +213 -0
- package/saves/SAVES.md +4 -1
- package/starters/ember-vale/index.html +8 -3
- package/starters/ember-vale/src/main.ts +233 -65
- package/starters/gem-rush/src/main.ts +46 -9
- package/worker/version.mjs +1 -1
|
@@ -15,6 +15,12 @@
|
|
|
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
17
|
*
|
|
18
|
+
* NAMES: the room knows a person by their seat's name (their account's, else a two-word handle such as "Velvet
|
|
19
|
+
* Comet"); the vale shows their HERO's name, over their head and in the night's ranking, for them and for everyone
|
|
20
|
+
* else. Each hero's browser tells the host its hero's name (a `hero` event); the host keeps every seat's in the keyed
|
|
21
|
+
* state `heroes`, which a new host and a late joiner have already. On a kids server nobody's typed name reaches
|
|
22
|
+
* anyone else (NETPLAY.md section 17): there the others are shown by their handles, as the room has them.
|
|
23
|
+
*
|
|
18
24
|
* A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
|
|
19
25
|
* follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
|
|
20
26
|
* The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
|
|
@@ -31,7 +37,7 @@
|
|
|
31
37
|
* none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
|
|
32
38
|
* never from a model; "Quiet AI" hides them.
|
|
33
39
|
*/
|
|
34
|
-
import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
40
|
+
import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
35
41
|
import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
|
|
36
42
|
import vocabulary from '../agents.json';
|
|
37
43
|
|
|
@@ -46,6 +52,8 @@ const DOWN_MS = 4000;
|
|
|
46
52
|
const DOWN = 1;
|
|
47
53
|
const STRIKING = 2;
|
|
48
54
|
const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
|
|
55
|
+
/** The eight spots round the camp, in the order bodies take them: any first few are spread all the way round. */
|
|
56
|
+
const SPREAD = [0, 4, 2, 6, 1, 5, 3, 7];
|
|
49
57
|
const xpFor = (level: number): number => Math.round(20 * level ** 1.5);
|
|
50
58
|
const maxHpOf = (level: number): number => 60 + level * 12;
|
|
51
59
|
const damageOf = (level: number): number => 10 + level * 3;
|
|
@@ -129,10 +137,13 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
|
129
137
|
roundSeconds: 90,
|
|
130
138
|
breakSeconds: 8,
|
|
131
139
|
botName: (slot) => BOT_NAMES[slot % BOT_NAMES.length] as string,
|
|
140
|
+
// createRoom gives every body in the room its own index (a mid-round arrival the lowest free one), so no two
|
|
141
|
+
// bodies ever spawn on one spot; SPREAD puts the first few far apart round the camp (0°, 180°, 90°, 270°…).
|
|
132
142
|
spawn: (slot, i) => {
|
|
133
|
-
const a = (i /
|
|
143
|
+
const a = ((SPREAD[i % SPREAD.length] as number) / SPREAD.length) * Math.PI * 2;
|
|
144
|
+
const ring = 1 + Math.floor(i / SPREAD.length) * 0.5;
|
|
134
145
|
const lvl = slot.bot ? 1 + (slot.slot % 4) : 1;
|
|
135
|
-
const x = W / 2 + Math.cos(a) * 160; const y = H / 2 + Math.sin(a) * 120;
|
|
146
|
+
const x = W / 2 + Math.cos(a) * 160 * ring; const y = H / 2 + Math.sin(a) * 120 * ring;
|
|
136
147
|
return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, score: 0, x, y, hp: maxHpOf(lvl), maxHp: maxHpOf(lvl), level: lvl, face: a, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: x, ty: y };
|
|
137
148
|
},
|
|
138
149
|
pack: (b) => [q(b.x, 0), q(b.y, 0), Math.max(0, Math.round(b.hp)), b.maxHp, b.level, q(b.face, 2), b.flags],
|
|
@@ -157,6 +168,51 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
|
157
168
|
const net = room.net;
|
|
158
169
|
const myLevel = (): number => hero?.level ?? 1;
|
|
159
170
|
|
|
171
|
+
/* ------------------------------------------------------------------ heroes' names */
|
|
172
|
+
/** A hero's name as the vale shows it: one line of at most 20 characters, never ending in an AI or bot mark. */
|
|
173
|
+
const heroName = (raw: unknown): string =>
|
|
174
|
+
stripAi(String(raw ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')).slice(0, 20).trim();
|
|
175
|
+
/** On a kids server nobody's typed name reaches anyone else: the room's handles stay. */
|
|
176
|
+
const kids = (): boolean => net.policy.kids === true;
|
|
177
|
+
/** Every seat's hero name, as the host keeps it (keyed state `heroes`: seat → name). */
|
|
178
|
+
const heroNames = (): Record<string, string> => net.stateOf<Record<string, string>>('heroes') ?? {};
|
|
179
|
+
/** Host: one seat's hero name (null or '': none). */
|
|
180
|
+
function setHeroName(seat: number, name: string | null): void {
|
|
181
|
+
const next = { ...heroNames() };
|
|
182
|
+
if (name && !kids()) next[seat] = name; else delete next[seat];
|
|
183
|
+
net.state('heroes', next);
|
|
184
|
+
}
|
|
185
|
+
/** The name over a body and in the ranking: a person's hero's (on your own screen, yours at once), else the room's. */
|
|
186
|
+
function nameOf(b: { seat: number | null; bot: boolean; name: string; agent?: unknown }): string {
|
|
187
|
+
// A bot or an AI is always the room's name for it: an AI's ends in " · AI", and no hero name may hide that.
|
|
188
|
+
if (b.bot || b.agent || b.seat === null || net.isAgent(b.seat)) return b.name;
|
|
189
|
+
if (!lookOnly && hero && b.seat === room.mySeat()) return heroName(hero.name) || b.name;
|
|
190
|
+
return (kids() ? '' : heroNames()[b.seat]) || b.name;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Every frame: my hero's name goes to the host until the room has it (so a new host, a reload and a lost event all
|
|
194
|
+
* heal by themselves); the host keeps only seats a person still holds.
|
|
195
|
+
*/
|
|
196
|
+
let heroToldAt = 0;
|
|
197
|
+
let heroesKeptAt = 0;
|
|
198
|
+
function keepNames(now: number): void {
|
|
199
|
+
const seat = room.mySeat();
|
|
200
|
+
if (!lookOnly && seat !== null) {
|
|
201
|
+
const want = hero && !kids() ? heroName(hero.name) : '';
|
|
202
|
+
if ((heroNames()[seat] ?? '') !== want) {
|
|
203
|
+
if (room.hosting) setHeroName(seat, want);
|
|
204
|
+
else if (now - heroToldAt > 1500) { heroToldAt = now; net.send('hero', { name: want }); }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!room.hosting || now - heroesKeptAt < 1000) return;
|
|
208
|
+
heroesKeptAt = now;
|
|
209
|
+
const held = new Set([...net.peers.values()].filter((p) => p.seat !== null && !p.agent && !p.watch).map((p) => String(p.seat)));
|
|
210
|
+
if (seat !== null) held.add(String(seat));
|
|
211
|
+
const names = heroNames();
|
|
212
|
+
const keep = kids() ? {} : Object.fromEntries(Object.entries(names).filter(([k]) => held.has(k)));
|
|
213
|
+
if (Object.keys(keep).length !== Object.keys(names).length) net.state('heroes', keep);
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
/* ------------------------------------------------------------------ the guides' brain (agents.json) */
|
|
161
217
|
type PartyRow = { seat: number; dist: number; hp: number; down: boolean };
|
|
162
218
|
const greeted = new Map<number, Set<number>>();
|
|
@@ -223,6 +279,8 @@ net.on('event', (e: NetEvent) => {
|
|
|
223
279
|
if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
|
|
224
280
|
if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
|
|
225
281
|
if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
|
|
282
|
+
// A hero's browser names its hero (its own seat only: the relay stamps who sent it).
|
|
283
|
+
if (e.k === 'hero' && room.hosting && typeof e.from === 'number' && !net.isAgent(e.from)) setHeroName(e.from, heroName((e.d as { name?: unknown } | null)?.name));
|
|
226
284
|
});
|
|
227
285
|
function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
|
|
228
286
|
if (b.bot || b.seat === null) return;
|
|
@@ -444,6 +502,8 @@ function stepSlimes(dt: number, now: number): void {
|
|
|
444
502
|
|
|
445
503
|
/* ------------------------------------------------------------------ input */
|
|
446
504
|
const input = createControls({ actions: { strike: ['Space', 'KeyJ', 'Enter'] }, touch: { buttons: [{ id: 'strike', label: 'STRIKE' }] } });
|
|
505
|
+
// A touch screen has STRIKE at the bottom right: the ask panel stands right above it (index.html, .touch).
|
|
506
|
+
document.body.classList.toggle('touch', input.touch.enabled);
|
|
447
507
|
function stepMe(dt: number): void {
|
|
448
508
|
const b = room.hosting ? room.mine() : null;
|
|
449
509
|
const isDown = b ? Boolean(b.flags & DOWN) : Boolean(myViewBody()?.flags && (myViewBody()!.flags & DOWN));
|
|
@@ -458,22 +518,54 @@ function myViewBody(): Body | null { const s = room.mySeat(); return room.view()
|
|
|
458
518
|
/* ------------------------------------------------------------------ drawing */
|
|
459
519
|
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
|
460
520
|
const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
|
|
461
|
-
let
|
|
521
|
+
let dpr = 1; let vw = 1; let vh = 1;
|
|
462
522
|
function resize(): void {
|
|
463
|
-
|
|
464
|
-
canvas.width = Math.round(
|
|
465
|
-
|
|
523
|
+
dpr = Math.min(2, devicePixelRatio || 1); vw = innerWidth; vh = innerHeight;
|
|
524
|
+
canvas.width = Math.round(vw * dpr); canvas.height = Math.round(vh * dpr);
|
|
525
|
+
// The page's pills sit under the hero panel, which grows with the screen (a TV's would cover them).
|
|
526
|
+
const pills = document.querySelector<HTMLElement>('.pill');
|
|
527
|
+
if (pills) pills.style.top = `calc(${Math.max(86, Math.round(76 * hudScale() + 10))}px + env(safe-area-inset-top))`;
|
|
466
528
|
}
|
|
529
|
+
/** 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. */
|
|
530
|
+
function hudScale(): number { return Math.max(Math.min(vw, vh) <= 540 ? 0.7 : 0.8, Math.min(1.4, vh / 900, vw / 560)); }
|
|
467
531
|
addEventListener('resize', resize);
|
|
468
532
|
resize();
|
|
469
533
|
let bannerText = ''; let bannerUntil = 0;
|
|
470
534
|
function banner(t: string): void { bannerText = t; bannerUntil = performance.now() + 2600; }
|
|
471
535
|
const SLIME = ['#7ad35a', '#e0a83a', '#c74bd8'];
|
|
472
536
|
|
|
537
|
+
/*
|
|
538
|
+
* The camera (port/view.ts). Where the whole vale fits at a size that reads (a computer, a TV) it all shows, as it
|
|
539
|
+
* always did. Where it would be small (a phone, above all held upright, where the vale was a strip a quarter of the
|
|
540
|
+
* screen tall), the vale fills the screen and follows your hero (a watcher: the hero followed), never past the vale's
|
|
541
|
+
* edge, except as far as it takes to keep your hero clear of the panel at the top and STRIKE at the bottom.
|
|
542
|
+
*/
|
|
543
|
+
const READABLE = 0.62; // CSS px per vale unit: a body 27 px across, a name 12 px tall
|
|
544
|
+
const INSET = { top: 124, bottom: 100 }; // the hero panel and its pills; STRIKE and the ask panel's foot
|
|
545
|
+
let cam: Fit | null = null;
|
|
546
|
+
let lastDraw = 0;
|
|
547
|
+
// Names keep clear of the guides' bubbles and of the ask panel (a page element over the canvas).
|
|
548
|
+
const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); return p && !p.hidden ? [...bubbleBoxes, p.getBoundingClientRect()] : bubbleBoxes; } });
|
|
549
|
+
let bubbleBoxes: { left: number; top: number; right: number; bottom: number }[] = [];
|
|
550
|
+
let shownLabels: LabelOut[] = [];
|
|
551
|
+
function aim(): Fit {
|
|
552
|
+
const v = lookOnly ? room.viewBody() : null;
|
|
553
|
+
const focus = !lookOnly && me.has ? { x: me.x, y: me.y } : v ? { x: v.x, y: v.y } : null;
|
|
554
|
+
const phone = Math.min(vw, vh) <= 540;
|
|
555
|
+
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 });
|
|
556
|
+
}
|
|
557
|
+
|
|
473
558
|
function draw(t: number): void {
|
|
474
|
-
const
|
|
559
|
+
const dt = lastDraw ? Math.min(0.1, (t - lastDraw) / 1000) : 0;
|
|
560
|
+
lastDraw = t;
|
|
561
|
+
const want = aim();
|
|
562
|
+
// A cut when the camera changes kind (a phone turned, a watcher's pick); else it glides.
|
|
563
|
+
cam = !cam || cam.follow !== want.follow ? want : easeView(cam, want, dt, 10);
|
|
564
|
+
const s = cam.scale;
|
|
565
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
475
566
|
ctx.fillStyle = '#0b0f0a'; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
476
|
-
ctx.save();
|
|
567
|
+
ctx.save();
|
|
568
|
+
ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * (vw / 2 - cam.x * s), dpr * (vh / 2 - cam.y * s));
|
|
477
569
|
// The vale: moss, and embers that drift.
|
|
478
570
|
ctx.fillStyle = '#16210f'; ctx.fillRect(0, 0, W, H);
|
|
479
571
|
for (let i = 0; i < 70; i++) {
|
|
@@ -494,6 +586,15 @@ function draw(t: number): void {
|
|
|
494
586
|
}
|
|
495
587
|
// The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
|
|
496
588
|
const viewS = room.viewSeat();
|
|
589
|
+
const sx = (x: number): number => vw / 2 + (x - (cam as Fit).x) * s;
|
|
590
|
+
const sy = (y: number): number => vh / 2 + (y - (cam as Fit).y) * s;
|
|
591
|
+
// Names are drawn after the vale, on the screen, at a size that reads on any screen; they never pile up.
|
|
592
|
+
const fs = clamp(18 * s, 12, 20);
|
|
593
|
+
ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
|
|
594
|
+
const names: LabelIn[] = [];
|
|
595
|
+
const said: { x: number; y: number; head: number; text: string }[] = [];
|
|
596
|
+
const mine: ScreenBox[] = []; // your own hero and its name: no bubble covers them
|
|
597
|
+
const meAt = (() => { const v = room.view().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
|
|
497
598
|
for (const b of room.view()) {
|
|
498
599
|
const self = !b.bot && b.seat === viewS;
|
|
499
600
|
const local = self && !room.hosting && !net.watching;
|
|
@@ -508,72 +609,121 @@ function draw(t: number): void {
|
|
|
508
609
|
ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(x - 24, y - R - 12, 48, 5);
|
|
509
610
|
ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
|
|
510
611
|
ctx.globalAlpha = 1;
|
|
511
|
-
ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
|
|
512
612
|
// An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
|
|
513
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
613
|
+
const text = `${nameOf(b)}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''}${isGuide ? ' · guide' : ''} · ${b.level}`;
|
|
614
|
+
const near = meAt ? Math.hypot(x - meAt.x, y - meAt.y) : 0;
|
|
615
|
+
// Its spot is above its health bar; its body (and bar) is what other names keep off. People first, then guides,
|
|
616
|
+
// then bots; nearer you first.
|
|
617
|
+
const body = { left: sx(x - 24), top: sy(y - R - 12), right: sx(x + 24), bottom: sy(y + R) };
|
|
618
|
+
if (body.right < 0 || body.left > vw || body.bottom < 0 || body.top > vh) continue; // off screen: no name at the edge
|
|
619
|
+
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 }); }
|
|
620
|
+
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 });
|
|
621
|
+
const line = bubbles.get(b.slot);
|
|
622
|
+
// The bubble's tail stops just above the guide's own name, so the name keeps its spot while it speaks.
|
|
623
|
+
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
624
|
}
|
|
517
625
|
ctx.restore();
|
|
626
|
+
// Speech bubbles first (labels keep clear of them), then the names; your own last, on a dark chip.
|
|
627
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
628
|
+
bubbleBoxes = said.map((b) => bubble(b.x, b.y, b.text, fs, b.head, mine));
|
|
629
|
+
ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
|
|
630
|
+
shownLabels = labels.place(names, dt);
|
|
631
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
|
|
632
|
+
for (const l of [...shownLabels].sort((a, b) => Number(Boolean(a.self)) - Number(Boolean(b.self)))) {
|
|
633
|
+
if (l.alpha <= 0) continue;
|
|
634
|
+
ctx.globalAlpha = l.alpha;
|
|
635
|
+
if (l.moved) {
|
|
636
|
+
// Moved off its own spot: a thin line to its body says whose it is.
|
|
637
|
+
const under = l.top > l.y;
|
|
638
|
+
ctx.strokeStyle = 'rgba(231,217,180,.45)'; ctx.lineWidth = 1;
|
|
639
|
+
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();
|
|
640
|
+
}
|
|
641
|
+
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(); }
|
|
642
|
+
else { ctx.strokeStyle = 'rgba(8,11,6,.8)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }
|
|
643
|
+
ctx.fillStyle = l.self ? '#ffcf6e' : '#e7d9b4';
|
|
644
|
+
ctx.fillText(l.text, l.cx, l.cy);
|
|
645
|
+
}
|
|
646
|
+
ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
|
|
647
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
518
648
|
hud(t);
|
|
519
649
|
}
|
|
520
650
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
651
|
+
type ScreenBox = { left: number; top: number; right: number; bottom: number };
|
|
652
|
+
const touches = (a: ScreenBox, b: ScreenBox): boolean => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
|
653
|
+
/**
|
|
654
|
+
* A speech bubble: up to three short lines, above a guide (screen px). It never covers your own hero or its name
|
|
655
|
+
* (`clearOf`): it moves aside, or higher, and its tail runs down to the guide's head (`head`), so whose line it is
|
|
656
|
+
* stays plain. Returns the box it covers.
|
|
657
|
+
*/
|
|
658
|
+
function bubble(x: number, y: number, text: string, fs: number, head: number, clearOf: ScreenBox[]): ScreenBox {
|
|
659
|
+
const f = Math.max(12, fs - 1);
|
|
660
|
+
ctx.font = `600 ${f}px ui-sans-serif, system-ui, sans-serif`;
|
|
524
661
|
const words = text.split(' ');
|
|
525
662
|
const lines: string[] = [];
|
|
526
663
|
let line = '';
|
|
527
664
|
for (const w of words) { if ((line + ' ' + w).trim().length > 26 && line) { lines.push(line); line = w; } else line = (line + ' ' + w).trim(); }
|
|
528
665
|
if (line) lines.push(line);
|
|
529
666
|
const shown = lines.slice(0, 3);
|
|
530
|
-
const wid = Math.max(...shown.map((l) => ctx.measureText(l).width)) +
|
|
531
|
-
const hgt = shown.length *
|
|
667
|
+
const wid = Math.max(...shown.map((l) => ctx.measureText(l).width)) + 20;
|
|
668
|
+
const hgt = shown.length * f * 1.25 + 10;
|
|
669
|
+
// Where it goes: above the guide; else beside that, either way; else higher, over whatever it must keep clear of.
|
|
670
|
+
const above = Math.min(y, ...clearOf.map((c) => c.top - 12));
|
|
671
|
+
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 })));
|
|
672
|
+
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]!;
|
|
673
|
+
const cx = at.bx; const by = at.by;
|
|
674
|
+
const tail = clamp(x, cx - wid / 2 + 12, cx + wid / 2 - 12);
|
|
532
675
|
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
|
-
|
|
676
|
+
ctx.beginPath(); ctx.roundRect(cx - wid / 2, by - hgt, wid, hgt, 10); ctx.fill(); ctx.stroke();
|
|
677
|
+
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();
|
|
678
|
+
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(); }
|
|
679
|
+
ctx.fillStyle = '#e9fff9'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
680
|
+
shown.forEach((l, i) => ctx.fillText(l, cx, by - hgt + 5 + f * 1.25 * (i + 0.5)));
|
|
681
|
+
ctx.textBaseline = 'alphabetic';
|
|
682
|
+
return { left: cx - wid / 2, top: by - hgt, right: cx + wid / 2, bottom: by + 8 };
|
|
537
683
|
}
|
|
538
684
|
|
|
539
685
|
function hud(t: number): void {
|
|
540
|
-
|
|
686
|
+
// 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
|
|
687
|
+
// a high-density computer screen (it was sized by device pixels, so half as big there).
|
|
688
|
+
const k = dpr * hudScale();
|
|
541
689
|
ctx.save(); ctx.scale(k, k);
|
|
542
690
|
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) {
|
|
691
|
+
const TITLE = '700 16px ui-sans-serif, system-ui, sans-serif';
|
|
692
|
+
const SMALL = '600 13px ui-sans-serif, system-ui, sans-serif';
|
|
693
|
+
// A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
|
|
694
|
+
const v = lookOnly ? room.viewBody() : null;
|
|
695
|
+
const heroes = room.view().filter((b) => !b.bot).length;
|
|
696
|
+
const title = lookOnly ? (v ? `${nameOf(v)} · Lv ${v.level}` : 'Watching the vale') : hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…';
|
|
697
|
+
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` : '';
|
|
698
|
+
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;
|
|
699
|
+
// The panel is as wide as what it says: on a phone the vale runs under it, and the clock sits beside it.
|
|
700
|
+
ctx.font = TITLE; const titleW = ctx.measureText(title).width;
|
|
701
|
+
ctx.font = SMALL; const subW = sub ? ctx.measureText(sub).width : 0;
|
|
702
|
+
const pw = Math.max(bar ? 140 : 0, titleW, subW) + 20;
|
|
703
|
+
ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, pw, 62);
|
|
704
|
+
ctx.textAlign = 'left'; ctx.font = TITLE; ctx.fillStyle = '#ffcf6e';
|
|
705
|
+
ctx.fillText(title, x0 + 10, y0 + 22);
|
|
706
|
+
if (bar) {
|
|
562
707
|
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);
|
|
708
|
+
ctx.fillStyle = bar.colour; ctx.fillRect(x0 + 10, y0 + 32, 140 * clamp(bar.of, 0, 1), 6);
|
|
566
709
|
}
|
|
710
|
+
if (sub) { ctx.font = SMALL; ctx.fillStyle = '#e7d9b4'; ctx.fillText(sub, x0 + 10, y0 + (bar ? 54 : 46)); }
|
|
567
711
|
const c = room.clock();
|
|
568
|
-
ctx.
|
|
712
|
+
ctx.font = TITLE; ctx.fillStyle = '#f3ead2';
|
|
569
713
|
const cw = canvas.width / k;
|
|
570
|
-
|
|
714
|
+
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')}`;
|
|
715
|
+
// Top centre; on a narrow screen where that would touch the panel, just under it.
|
|
716
|
+
if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
|
|
717
|
+
else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
|
|
718
|
+
ctx.textAlign = 'center';
|
|
571
719
|
if (c.phase === 'over') {
|
|
572
720
|
const rows = room.results().slice(0, 5);
|
|
573
|
-
|
|
574
|
-
|
|
721
|
+
// Under the page's own pills on a narrow screen (they are buttons, above the canvas), else under the clock.
|
|
722
|
+
const top = vw <= 540 ? 132 * (dpr / k) : 44;
|
|
723
|
+
ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, top, 300, 30 + rows.length * 22);
|
|
724
|
+
ctx.font = TITLE; ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, top + 22);
|
|
575
725
|
ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
576
|
-
rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r
|
|
726
|
+
rows.forEach((r, i) => ctx.fillText(`${r.place}. ${nameOf(r)}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, top + 46 + i * 22));
|
|
577
727
|
}
|
|
578
728
|
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
729
|
const st = saves.status();
|
|
@@ -640,6 +790,7 @@ setInterval(() => { if (hero && document.visibilityState === 'visible') void sav
|
|
|
640
790
|
/** The guide nearest my hero (within 340 px), and the asks I can make of it: drawn as buttons, four times a second. */
|
|
641
791
|
let asksFor = -1;
|
|
642
792
|
let asksSig = '';
|
|
793
|
+
const askEls = new Map<string, HTMLButtonElement>();
|
|
643
794
|
function paintAsks(): void {
|
|
644
795
|
const panel = $('asks');
|
|
645
796
|
const mySeat = room.mySeat();
|
|
@@ -649,18 +800,21 @@ function paintAsks(): void {
|
|
|
649
800
|
for (const b of room.view()) {
|
|
650
801
|
const sl = (net.slots ?? []).find((x) => x.slot === b.slot);
|
|
651
802
|
if (!sl?.agent || sl.agent.role !== 'guide') continue;
|
|
803
|
+
// The guide the panel is for keeps it while it is in reach, unless another is much nearer: two guides at the
|
|
804
|
+
// party's side would otherwise swap it four times a second, and a thumb's tap would land on a button just redrawn.
|
|
652
805
|
const d = Math.hypot(b.x - me.x, b.y - me.y);
|
|
653
|
-
|
|
806
|
+
const rank = d - (b.slot === asksFor ? 120 : 0);
|
|
807
|
+
if (d < 340 && (!best || rank < best.d)) best = { slot: b.slot, name: b.name, d: rank };
|
|
654
808
|
}
|
|
655
809
|
}
|
|
656
810
|
if (!best) { panel.hidden = true; asksFor = -1; asksSig = ''; return; }
|
|
657
811
|
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.
|
|
812
|
+
// 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
813
|
const zone = zoneOf(me.x, me.y);
|
|
660
814
|
const all = agents.askButtons(best.slot, { quests });
|
|
661
815
|
const lead = all.filter((b) => b.k === 'lead_me' && b.args['place'] !== zone).slice(0, 1);
|
|
662
816
|
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);
|
|
817
|
+
const buttons = order.slice(0, Math.min(innerWidth, innerHeight) < 540 ? 3 : 5);
|
|
664
818
|
const sig = `${best.slot}|${best.name}|${buttons.map((b) => b.text).join('|')}`;
|
|
665
819
|
panel.hidden = false;
|
|
666
820
|
if (sig === asksSig) return;
|
|
@@ -668,19 +822,29 @@ function paintAsks(): void {
|
|
|
668
822
|
asksFor = best.slot;
|
|
669
823
|
panel.dataset['slot'] = String(best.slot);
|
|
670
824
|
$('asks-who').textContent = `${best.name} · guide`;
|
|
825
|
+
// A button that stays is the same element (a quest that opens or closes adds or takes one): a thumb's tap that lands
|
|
826
|
+
// while the list changes around it still counts.
|
|
671
827
|
const list = $('asks-list');
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
828
|
+
const keep = new Set<string>();
|
|
829
|
+
buttons.forEach((b, i) => {
|
|
830
|
+
const key = `${b.k}|${JSON.stringify(b.args)}`;
|
|
831
|
+
keep.add(key);
|
|
832
|
+
let el = askEls.get(key);
|
|
833
|
+
if (!el) {
|
|
834
|
+
const btn = document.createElement('button');
|
|
835
|
+
btn.type = 'button';
|
|
836
|
+
btn.dataset['ask'] = b.k;
|
|
837
|
+
btn.addEventListener('click', () => {
|
|
838
|
+
if (asksFor < 0) return;
|
|
839
|
+
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); }
|
|
840
|
+
});
|
|
841
|
+
askEls.set(key, btn);
|
|
842
|
+
el = btn;
|
|
843
|
+
}
|
|
844
|
+
if (el.textContent !== b.text) el.textContent = b.text;
|
|
845
|
+
if (list.children[i] !== el) list.insertBefore(el, list.children[i] ?? null);
|
|
846
|
+
});
|
|
847
|
+
for (const [key, el] of askEls) if (!keep.has(key)) { el.remove(); askEls.delete(key); }
|
|
684
848
|
}
|
|
685
849
|
setInterval(paintAsks, 250);
|
|
686
850
|
|
|
@@ -691,6 +855,7 @@ function frame(t: number): void {
|
|
|
691
855
|
const dt = Math.min(0.05, (t - last) / 1000);
|
|
692
856
|
last = t;
|
|
693
857
|
stepMe(dt);
|
|
858
|
+
keepNames(t);
|
|
694
859
|
if (room.hosting) stepHost(dt);
|
|
695
860
|
draw(t);
|
|
696
861
|
frames += 1;
|
|
@@ -707,6 +872,9 @@ net.expose({
|
|
|
707
872
|
me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
|
|
708
873
|
log: guideLog.slice(),
|
|
709
874
|
}),
|
|
875
|
+
// Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
|
|
876
|
+
camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
|
|
877
|
+
labels: () => shownLabels.map((l) => ({ text: l.text, 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
878
|
});
|
|
711
879
|
|
|
712
880
|
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/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.18.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.18.2';
|