@homie-rocks/studio 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/port/view.ts CHANGED
@@ -11,6 +11,10 @@
11
11
  * label is placed first and nothing covers it; a moved label keeps its new spot while it is
12
12
  * free (no flicker), and labels fade in and out rather than pop.
13
13
  *
14
+ * createBubbles() speech bubbles over characters (room chat, NETPLAY.md section 19): what a player said, over
15
+ * their body for a few seconds, the player's own placed first, never covering another bubble;
16
+ * `paintBubbles` draws them on a canvas, and their boxes keep the name labels off them.
17
+ *
14
18
  * Everything is in CSS pixels (screen) and world units; the game draws. See the starters (gem-rush, ember-vale).
15
19
  */
16
20
 
@@ -211,3 +215,237 @@ export function createLabels(opts: LabelOptions = {}): { place(labels: LabelIn[]
211
215
  },
212
216
  };
213
217
  }
218
+
219
+ /* ------------------------------------------------------------------ speech bubbles (room chat, NETPLAY.md section 19) */
220
+
221
+ export interface BubbleOptions {
222
+ /** A line's width as drawn, CSS px: `(t) => ctx.measureText(t).width` with the bubble's font set (paintBubbles uses BUBBLE_FONT). */
223
+ measure: (text: string) => number;
224
+ /** A text line's height, CSS px (default 17). */
225
+ lineHeight?: number;
226
+ /** The widest a bubble's text gets before it wraps, CSS px (default 190). */
227
+ maxWidth?: number;
228
+ /** Lines at most; the rest is cut with "…" (default 3). */
229
+ maxLines?: number;
230
+ /** How long a message stays, ms: default 4 s and 50 ms a character (at most 9 s); an emoji 2.6 s. */
231
+ ms?: (b: { text: string; kind: 'text' | 'line' | 'react' }) => number;
232
+ /** Space inside the bubble, CSS px (default x 8, y 5). */
233
+ pad?: { x: number; y: number };
234
+ /** The tail under the bubble, CSS px (default 7). */
235
+ tail?: number;
236
+ /** CSS px kept clear between two bubbles (default 4). */
237
+ gap?: number;
238
+ /** The screen, so a bubble is never pushed off it (default the window). */
239
+ screen?: () => { w: number; h: number };
240
+ /** Milliseconds now (default performance.now()). */
241
+ now?: () => number;
242
+ /** Boxes bubbles keep clear of, CSS px (a game's own speech bubbles, a panel): a bubble moves up past them. */
243
+ avoid?: () => LabelBox[];
244
+ }
245
+
246
+ export interface BubbleIn {
247
+ /** Whose bubble: the key `say(key, …)` was given (a seat, a slot). */
248
+ key: string | number;
249
+ /** Where the tail points, CSS px: just over the body's name label (its box's top middle) or its head. */
250
+ x: number;
251
+ y: number;
252
+ /** The player's own (or the player a watcher follows): placed first. */
253
+ self?: boolean;
254
+ }
255
+
256
+ export interface BubbleOut extends LabelBox {
257
+ key: string | number;
258
+ id: string;
259
+ kind: 'text' | 'line' | 'react';
260
+ /** The text, wrapped: one string per line (one line with the glyph for an emoji). */
261
+ lines: string[];
262
+ /** An emoji-only bubble (a reaction): draw it bigger, no words. */
263
+ emoji: boolean;
264
+ lineHeight: number;
265
+ pad: { x: number; y: number };
266
+ /** Where the tail points (the anchor), and the bubble's middle. */
267
+ tip: { x: number; y: number };
268
+ cx: number;
269
+ cy: number;
270
+ /** 0..1: fading in and out. `scale` pops in from 0.86. */
271
+ alpha: number;
272
+ scale: number;
273
+ }
274
+
275
+ export interface Bubbles {
276
+ /** A message over `key`'s character: it replaces what that key was saying. `id` lets `remove(id)` take it down. */
277
+ say(key: string | number, text: string, o?: { id?: string; kind?: 'text' | 'line' | 'react' }): void;
278
+ /** The studio took a message down (net.on('unchat')): its bubble goes at once. */
279
+ remove(id: string): void;
280
+ /** Every bubble, or one key's, goes. */
281
+ clear(key?: string | number): void;
282
+ /** Each frame: where every speaking key's anchor is now. Keys not in the list are not drawn (off screen). */
283
+ place(anchors: BubbleIn[], dt?: number): BubbleOut[];
284
+ /** The boxes placed last frame: give them to createLabels' `avoid` so names keep off the bubbles. */
285
+ boxes(): LabelBox[];
286
+ /** How many keys are speaking now. */
287
+ readonly size: number;
288
+ }
289
+
290
+ /** The bubble's font (paintBubbles draws with it; measure with it too). */
291
+ export const BUBBLE_FONT = '600 14px ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
292
+
293
+ /** Words to lines no wider than `max` (a word wider than a line is broken), at most `n` lines (the last cut with …). */
294
+ export function wrapText(text: string, max: number, measure: (t: string) => number, n = 3): string[] {
295
+ const words = String(text ?? '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
296
+ const lines: string[] = [];
297
+ let cur = '';
298
+ const push = (s: string): void => { lines.push(s); };
299
+ for (const w0 of words) {
300
+ let w = w0;
301
+ // A word wider than a line: broken by characters.
302
+ while (measure(w) > max) {
303
+ let cut = [...w].length - 1;
304
+ const chars = [...w];
305
+ while (cut > 1 && measure(chars.slice(0, cut).join('')) > max) cut -= 1;
306
+ if (cur) { push(cur); cur = ''; }
307
+ push(chars.slice(0, cut).join(''));
308
+ w = chars.slice(cut).join('');
309
+ }
310
+ const next = cur ? `${cur} ${w}` : w;
311
+ if (measure(next) <= max) cur = next; else { if (cur) push(cur); cur = w; }
312
+ }
313
+ if (cur) push(cur);
314
+ if (lines.length <= n) return lines;
315
+ const kept = lines.slice(0, n);
316
+ let last = `${kept[n - 1]}…`;
317
+ while (measure(last) > max && last.length > 2) last = `${[...last].slice(0, -2).join('')}…`;
318
+ kept[n - 1] = last;
319
+ return kept;
320
+ }
321
+
322
+ /**
323
+ * Speech bubbles over characters: what a player said in the room's chat, over their body, for a few seconds. Feed it
324
+ * from the netplay helper (`net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }))` and
325
+ * `net.on('unchat', (e) => e.ids.forEach((id) => bubbles.remove(id)))`), place it each frame with every speaking
326
+ * body's anchor (over its name label), and draw what comes back (`paintBubbles` on a canvas, or your own). Bubbles
327
+ * never cover each other: a bubble that would sits above the one in its way; the player's own is placed first.
328
+ */
329
+ export function createBubbles(opts: BubbleOptions): Bubbles {
330
+ const lh = opts.lineHeight ?? 17;
331
+ const maxW = opts.maxWidth ?? 190;
332
+ const maxLines = opts.maxLines ?? 3;
333
+ const pad = opts.pad ?? { x: 8, y: 5 };
334
+ const tail = opts.tail ?? 7;
335
+ const gap = opts.gap ?? 4;
336
+ const clock = opts.now ?? ((): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
337
+ const screen = opts.screen ?? ((): { w: number; h: number } => ({ w: typeof innerWidth === 'number' ? innerWidth : 1e9, h: typeof innerHeight === 'number' ? innerHeight : 1e9 }));
338
+ const life = opts.ms ?? ((b: { text: string; kind: string }): number => (b.kind === 'react' ? 2600 : Math.min(9000, 4000 + 50 * [...b.text].length)));
339
+ type Live = { id: string; key: string | number; kind: 'text' | 'line' | 'react'; text: string; lines: string[]; w: number; h: number; at: number; until: number };
340
+ const live = new Map<string | number, Live>();
341
+ let last: LabelBox[] = [];
342
+ let seq = 0;
343
+ return {
344
+ say(key, text, o = {}) {
345
+ const kind = o.kind ?? 'text';
346
+ const t = String(text ?? '').trim();
347
+ if (!t) return;
348
+ const emoji = kind === 'react';
349
+ const lines = emoji ? [t] : wrapText(t, maxW, opts.measure, maxLines);
350
+ const w = emoji ? lh * 1.6 : Math.max(...lines.map((l) => opts.measure(l)));
351
+ const h = emoji ? lh * 1.6 : lines.length * lh;
352
+ const at = clock();
353
+ seq += 1;
354
+ live.set(key, { id: o.id ?? `b${seq}`, key, kind, text: t, lines, w, h, at, until: at + life({ text: t, kind }) });
355
+ },
356
+ remove(id) { for (const [k, b] of live) if (b.id === id) live.delete(k); },
357
+ clear(key) { if (key === undefined) live.clear(); else live.delete(key); },
358
+ get size() { return live.size; },
359
+ boxes() { return last; },
360
+ place(anchors) {
361
+ const t = clock();
362
+ for (const [k, b] of live) if (t >= b.until) live.delete(k);
363
+ const { w: sw, h: sh } = screen();
364
+ const keep = opts.avoid?.() ?? [];
365
+ const placed: LabelBox[] = [];
366
+ const out: BubbleOut[] = [];
367
+ // The player's own first, then the newest: a new message keeps its spot and an older one moves up.
368
+ const order = anchors.filter((a) => live.has(a.key)).sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self)) || (live.get(b.key)!.at - live.get(a.key)!.at));
369
+ for (const a of order) {
370
+ const b = live.get(a.key)!;
371
+ const bw = b.w + pad.x * 2;
372
+ const bh = b.h + pad.y * 2;
373
+ const left = Math.max(2, Math.min(sw - bw - 2, a.x - bw / 2));
374
+ let bottom = a.y - tail;
375
+ let box = { left, top: bottom - bh, right: left + bw, bottom };
376
+ const hits = (x: LabelBox): boolean => box.left < x.right + gap && x.left < box.right + gap && box.top < x.bottom + gap && x.top < box.bottom + gap;
377
+ for (let i = 0; i < 6; i += 1) {
378
+ const other = placed.find(hits) ?? keep.find(hits);
379
+ if (!other) break;
380
+ bottom = other.top - gap;
381
+ box = { left, top: bottom - bh, right: left + bw, bottom };
382
+ }
383
+ if (box.top < 0 || box.left > sw || box.right < 0 || a.y > sh + bh) continue;
384
+ placed.push(box);
385
+ const age = t - b.at;
386
+ const leftMs = b.until - t;
387
+ const alpha = Math.max(0, Math.min(1, age / 120, leftMs / 400));
388
+ const scale = 0.86 + 0.14 * Math.min(1, age / 160);
389
+ out.push({ ...box, key: b.key, id: b.id, kind: b.kind, lines: b.lines, emoji: b.kind === 'react', lineHeight: lh, pad, tip: { x: Math.max(box.left + 8, Math.min(box.right - 8, a.x)), y: a.y }, cx: (box.left + box.right) / 2, cy: (box.top + box.bottom) / 2, alpha: Math.round(alpha * 100) / 100, scale: Math.round(scale * 1000) / 1000 });
390
+ }
391
+ last = placed;
392
+ return out;
393
+ },
394
+ };
395
+ }
396
+
397
+ export interface BubbleStyle {
398
+ font?: string;
399
+ /** The bubble's paper and ink (default white paper, near-black ink), its edge, and the player's own bubble's edge. */
400
+ paper?: string;
401
+ ink?: string;
402
+ edge?: string;
403
+ selfEdge?: string;
404
+ /** Corner radius, CSS px (default 10). */
405
+ radius?: number;
406
+ }
407
+
408
+ /** Draw placed bubbles on a 2D canvas (CSS px, the context's transform already set for the device pixel ratio). */
409
+ export function paintBubbles(ctx: CanvasRenderingContext2D, list: BubbleOut[], style: BubbleStyle = {}): void {
410
+ const font = style.font ?? BUBBLE_FONT;
411
+ const paper = style.paper ?? '#ffffff';
412
+ const ink = style.ink ?? '#15171f';
413
+ const edge = style.edge ?? 'rgba(0,0,0,0.18)';
414
+ const r = style.radius ?? 10;
415
+ for (const b of list) {
416
+ if (b.alpha <= 0) continue;
417
+ ctx.save();
418
+ ctx.globalAlpha = b.alpha;
419
+ const cx = b.cx;
420
+ const cy = b.bottom;
421
+ ctx.translate(cx, cy);
422
+ ctx.scale(b.scale, b.scale);
423
+ ctx.translate(-cx, -cy);
424
+ ctx.fillStyle = paper;
425
+ ctx.strokeStyle = edge;
426
+ ctx.lineWidth = 1;
427
+ ctx.beginPath();
428
+ ctx.roundRect(b.left, b.top, b.right - b.left, b.bottom - b.top, Math.min(r, (b.bottom - b.top) / 2));
429
+ ctx.fill();
430
+ ctx.stroke();
431
+ // The tail: a small triangle from the bubble's bottom to where it points.
432
+ const tx = b.tip.x;
433
+ ctx.beginPath();
434
+ ctx.moveTo(tx - 6, b.bottom - 1);
435
+ ctx.lineTo(tx, Math.min(b.tip.y, b.bottom + 7));
436
+ ctx.lineTo(tx + 6, b.bottom - 1);
437
+ ctx.closePath();
438
+ ctx.fill();
439
+ ctx.fillStyle = ink;
440
+ ctx.textAlign = 'center';
441
+ ctx.textBaseline = 'middle';
442
+ if (b.emoji) {
443
+ ctx.font = font.replace(/\d+(?:\.\d+)?px/, `${Math.round(b.lineHeight * 1.35)}px`);
444
+ ctx.fillText(b.lines[0] ?? '', b.cx, b.cy + 1);
445
+ } else {
446
+ ctx.font = font;
447
+ b.lines.forEach((line, i) => ctx.fillText(line, b.cx, b.top + b.pad.y + b.lineHeight * (i + 0.5) + 0.5));
448
+ }
449
+ ctx.restore();
450
+ }
451
+ }
package/site/SITE.md CHANGED
@@ -30,7 +30,17 @@ songs (each with a `cover`), videos (each with a `poster`), the latest posts, an
30
30
  (`games[].played`, `{ "days": 7, "plays", "rounds" }`). To keep the studio's rooms off the hub, studio.json takes
31
31
  `"rooms": { "share": false }`: the manifest then names no `rooms` (the studio's own pages still list them).
32
32
  Each room in `/api/rooms` carries `play` and, unless its game says `"watch": false`, `watch` (its watch door), so
33
- the hub offers Watch beside Join.
33
+ the hub offers Watch beside Join. From 0.23.0 a room whose chat is on and whose rules let homie.rocks show it
34
+ (`hub`, on by default) also says `chat: true`: the hub's room card then has Chat, which opens the room's own watch
35
+ socket from the visitor's browser and shows its lines and reactions live (NETPLAY.md section 19). A visitor there
36
+ sends what a watcher may send (reactions and quick lines when the room's `react` is `anyone`), never types.
37
+
38
+ ## Room chat on the play, watch and TV pages
39
+
40
+ Every play page, big screen and watch page carries room chat (worker/chat-page.mjs; chat/CHAT.md): the Chat pill
41
+ and its sheet, the float, the ticker, the big screen's corner. Reports go to `POST /<id>/api/chat/report` (this
42
+ site's pages only); the owner's tools are `/_studio/api/chat`, `/_studio/api/chat/rules`, `/_studio/api/chat/remove`,
43
+ `/_studio/api/chat/report` (dismiss) and `/_studio/api/chat/budget`, and `mute`/`kick` take a chat line.
34
44
 
35
45
  ## A game's landing: `/<id>/`
36
46
 
@@ -43,7 +43,7 @@
43
43
  * The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
44
44
  * every lab call is a no-op.
45
45
  */
46
- 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';
46
+ import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, 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';
47
47
  import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
48
48
  import vocabulary from '../agents.json';
49
49
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
@@ -712,8 +712,14 @@ const INSET = { top: 124, bottom: 100 }; // the hero panel and its pills; STRIKE
712
712
  let cam: Fit | null = null;
713
713
  let lastDraw = 0;
714
714
  // Names keep clear of the guides' bubbles and of the ask panel (a page element over the canvas).
715
- const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); return p && !p.hidden ? [...bubbleBoxes, p.getBoundingClientRect()] : bubbleBoxes; } });
715
+ const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); const all = [...bubbleBoxes, ...chatBubbles.boxes()]; return p && !p.hidden ? [...all, p.getBoundingClientRect()] : all; } });
716
716
  let bubbleBoxes: { left: number; top: number; right: number; bottom: number }[] = [];
717
+ // Room chat (NETPLAY.md section 19): what a hero's player says in the room's chat shows over the hero for a few seconds,
718
+ // clear of the guides' own bubbles. The studio taking a message down takes its bubble too.
719
+ const chatBubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: vw, h: vh }), avoid: () => bubbleBoxes });
720
+ net.on('say', (e) => chatBubbles.say(e.seat, e.text, { id: e.id, kind: e.kind }));
721
+ net.on('unchat', (e) => { for (const id of e.ids) chatBubbles.remove(id); });
722
+ let shownChat: BubbleOut[] = [];
717
723
  let shownLabels: LabelOut[] = [];
718
724
  function aim(): Fit {
719
725
  const v = lookOnly ? room.viewBody() : null;
@@ -772,6 +778,7 @@ function draw(t: number): void {
772
778
  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
773
779
  const names: LabelIn[] = [];
774
780
  const said: { x: number; y: number; head: number; text: string }[] = [];
781
+ const heads = new Map<number, { x: number; y: number; seat: number; self: boolean }>();
775
782
  const mine: ScreenBox[] = []; // your own hero and its name: no bubble covers them
776
783
  const meAt = (() => { const v = room.view().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
777
784
  for (const b of room.view()) {
@@ -801,6 +808,7 @@ function draw(t: number): void {
801
808
  const body = { left: sx(x - 24), top: sy(y - R - 12), right: sx(x + 24), bottom: sy(y + R) };
802
809
  if (body.right < 0 || body.left > vw || body.bottom < 0 || body.top > vh) continue; // off screen: no name at the edge
803
810
  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 }); }
811
+ if (!b.bot && b.seat !== null && b.seat !== undefined) heads.set(b.slot, { x: sx(x), y: body.top - 3, seat: b.seat, self });
804
812
  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 });
805
813
  const line = bubbles.get(b.slot);
806
814
  // The bubble's tail stops just above the guide's own name, so the name keeps its spot while it speaks.
@@ -830,6 +838,16 @@ function draw(t: number): void {
830
838
  ctx.fillText(l.text, l.cx, l.cy);
831
839
  }
832
840
  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
841
+ // Room chat's bubbles: over each speaking hero's name (your own placed first), clear of the guides' bubbles.
842
+ if (chatBubbles.size) {
843
+ const anchors: BubbleIn[] = [];
844
+ for (const [slot, h] of heads) {
845
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
846
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.self });
847
+ }
848
+ shownChat = chatBubbles.place(anchors);
849
+ paintBubbles(ctx, shownChat, { paper: '#f6efdc', ink: '#1a1710', edge: 'rgba(40,30,10,.35)' });
850
+ } else shownChat = [];
833
851
  ctx.setTransform(1, 0, 0, 1, 0, 0);
834
852
  hud(t);
835
853
  }
@@ -1116,6 +1134,8 @@ function frame(t: number): void {
1116
1134
 
1117
1135
  // Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
1118
1136
  net.expose({
1137
+ // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
1138
+ chatBubbles: () => shownChat.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
1119
1139
  scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })),
1120
1140
  // The guides, for the e2e probe: each guide's goal now, and the log of goals, asks and lines (seats and ids only).
1121
1141
  guides: () => ({
@@ -39,7 +39,7 @@
39
39
  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';
40
40
  // The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
41
41
  // and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
42
- import { createLabels, exposePort, fitView, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
42
+ import { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
43
43
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
44
44
  import { lab } from '@homie-rocks/studio/lab';
45
45
  import tuning from '../tunables.json';
@@ -768,6 +768,12 @@ resize();
768
768
 
769
769
  const cam = { x: W / 2, y: H / 2, scale: 0 };
770
770
  const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }) });
771
+ // Room chat (NETPLAY.md section 19): what a player says in the room's chat (the play page's Chat, or a quick line) shows
772
+ // over their body for a few seconds, when they want it there. The studio taking a message down takes its bubble too.
773
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }) });
774
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
775
+ net.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });
776
+ let shownBubbles: BubbleOut[] = [];
771
777
  let shownLabels: LabelOut[] = [];
772
778
  let lastDraw = 0;
773
779
  /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
@@ -868,6 +874,7 @@ function draw(t: number): void {
868
874
  const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
869
875
  ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
870
876
  const tags: LabelIn[] = [];
877
+ const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();
871
878
  const viewAt = list.find((a) => a.mine) ?? null;
872
879
  for (const a of list) {
873
880
  const colour = colourOf(a.slot, a.bot ? null : a.seat);
@@ -884,6 +891,7 @@ function draw(t: number): void {
884
891
  const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
885
892
  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;
886
893
  if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge
894
+ if (a.seat !== null && !a.bot) heads.set(a.slot, { x: sx, y: sy - r - 5, seat: a.seat, mine: a.mine });
887
895
  // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
888
896
  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) });
889
897
  }
@@ -909,6 +917,16 @@ function draw(t: number): void {
909
917
  ctx.fillText(l.text, l.cx, l.cy);
910
918
  }
911
919
  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
920
+ // Speech bubbles (room chat): over each speaker's name, the view's own player first.
921
+ if (bubbles.size) {
922
+ const anchors: BubbleIn[] = [];
923
+ for (const [slot, h] of heads) {
924
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
925
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.mine });
926
+ }
927
+ shownBubbles = bubbles.place(anchors);
928
+ paintBubbles(ctx, shownBubbles);
929
+ } else shownBubbles = [];
912
930
 
913
931
  hud(cw, ch, phone, list);
914
932
  if (stick.active) {
@@ -985,6 +1003,8 @@ function frame(t: number): void {
985
1003
  }
986
1004
 
987
1005
  net.expose({
1006
+ // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
1007
+ bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
988
1008
  self: () => (me.has ? { x: me.x, y: me.y } : null),
989
1009
  peer: (seat: number) => {
990
1010
  if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
@@ -45,7 +45,7 @@ import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies
45
45
  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';
46
46
  // The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
47
47
  // labels that never pile up (port/view.ts).
48
- import { createLabels, exposePort, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
48
+ import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
49
49
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
50
50
  import { lab } from '@homie-rocks/studio/lab';
51
51
  import tuning from '../tunables.json';
@@ -1659,6 +1659,13 @@ resize();
1659
1659
  let zoneMark: { left: number; top: number; right: number; bottom: number } | null = null;
1660
1660
  const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }), avoid: () => (zoneMark ? [zoneMark] : []) });
1661
1661
  let shownLabels: LabelOut[] = [];
1662
+ // Room chat (NETPLAY.md section 19): what a player says in the room's chat shows over their animal for a few seconds,
1663
+ // on the HUD's own paper (one UI with the names, the clock and the board). The studio taking a message down takes it too.
1664
+ const BUBBLE_TYPE = `600 14px ${FONT_BODY}`;
1665
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_TYPE; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }), avoid: () => (zoneMark ? [zoneMark] : []) });
1666
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
1667
+ net.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });
1668
+ let shownBubbles: BubbleOut[] = [];
1662
1669
  let lastDraw = 0;
1663
1670
  /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
1664
1671
  function seatPos(seat: number): { x: number; y: number } | null {
@@ -1823,6 +1830,7 @@ function draw(t: number, dt: number): void {
1823
1830
  const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
1824
1831
  ctx.font = `700 ${fs}px ${FONT_BODY}`;
1825
1832
  const tags: LabelIn[] = [];
1833
+ const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();
1826
1834
  const viewAt = list.find((a) => a.mine) ?? null;
1827
1835
  for (const a of list) {
1828
1836
  const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
@@ -1833,6 +1841,7 @@ function draw(t: number, dt: number): void {
1833
1841
  const r = Math.max(6, Math.hypot(side.x - mid.x, side.y - mid.y));
1834
1842
  if (mid.x + r < 0 || mid.x - r > cw || head.y > ch || mid.y + r < 0) continue; // off screen: no name at the edge
1835
1843
  const foot = onScreen(a.x, a.y, 0, cw, ch);
1844
+ if (a.seat !== null && !a.bot) heads.set(a.slot, { x: head.x, y: head.y - 3, seat: a.seat, mine: a.mine });
1836
1845
  // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
1837
1846
  tags.push({ key: a.slot, text, x: head.x, y: head.y - 3, w: ctx.measureText(text).width, h: fs * 1.2, below: foot.y + r * 0.6 + 4 + fs * 1.2, body: { left: mid.x - r, top: head.y, right: mid.x + r, bottom: foot.y + r * 0.4 }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) * 50 : 0) });
1838
1847
  }
@@ -1855,6 +1864,16 @@ function draw(t: number, dt: number): void {
1855
1864
  ctx.fillText(l.text, l.cx, l.cy);
1856
1865
  }
1857
1866
  ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
1867
+ // Speech bubbles (room chat): over each speaker's name pill, the view's own player first.
1868
+ if (bubbles.size) {
1869
+ const anchors: BubbleIn[] = [];
1870
+ for (const [slot, h] of heads) {
1871
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
1872
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 3 : h.y, self: h.mine });
1873
+ }
1874
+ shownBubbles = bubbles.place(anchors);
1875
+ paintBubbles(ctx, shownBubbles, { font: BUBBLE_TYPE, paper: PAPER, ink: TEXT, edge: mixHex(TEXT, PAPER, 0.7) });
1876
+ } else shownBubbles = [];
1858
1877
 
1859
1878
  hud(cw, ch, phone, list);
1860
1879
  if (stick.active) {
@@ -2028,6 +2047,8 @@ function frame(t: number): void {
2028
2047
  }
2029
2048
 
2030
2049
  net.expose({
2050
+ // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
2051
+ bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
2031
2052
  self: () => (me.has ? { x: me.x, y: me.y } : null),
2032
2053
  peer: (seat: number) => {
2033
2054
  if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
package/worker/agents.mjs CHANGED
@@ -74,10 +74,10 @@ export function encodeFacts(value) {
74
74
  for (const b of bytes) s += String.fromCharCode(b);
75
75
  return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
76
76
  }
77
- /** The reverse, or null for anything that is not one (at most 4 KB). */
78
- export function decodeFacts(text) {
77
+ /** The reverse, or null for anything that is not one (at most 4 KB, or `max`: a room's chat rules may be longer). */
78
+ export function decodeFacts(text, max = 4096) {
79
79
  const t = String(text ?? '');
80
- if (!t || t.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(t)) return null;
80
+ if (!t || t.length > max || !/^[A-Za-z0-9_-]+$/.test(t)) return null;
81
81
  try {
82
82
  const s = atob(t.replace(/-/g, '+').replace(/_/g, '/'));
83
83
  const bytes = Uint8Array.from(s, (c) => c.charCodeAt(0));