@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.
@@ -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 / 8) * Math.PI * 2;
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 scale = 1; let ox = 0; let oy = 0;
521
+ let dpr = 1; let vw = 1; let vh = 1;
462
522
  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;
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 s = scale;
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(); ctx.translate(ox, oy); ctx.scale(s, s);
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
- 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);
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
- /** 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';
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)) + 22;
531
- const hgt = shown.length * 20 + 12;
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(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));
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
- const k = Math.max(0.8, Math.min(1.4, canvas.height / 900));
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
- 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) {
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 = '#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);
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.textAlign = 'center'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#f3ead2';
712
+ ctx.font = TITLE; ctx.fillStyle = '#f3ead2';
569
713
  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);
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
- 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);
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.name}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, 90 + i * 22));
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
- if (d < 340 && (!best || d < best.d)) best = { slot: b.slot, name: b.name, d };
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
- 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
- }
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'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. */
@@ -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.0';
2
+ export const STUDIO_VERSION_TAG = '0.18.2';