@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/CHANGELOG.md +30 -1
- package/README.md +37 -0
- package/bin/homie-studio.mjs +34 -1
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/build.mjs +7 -0
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/scaffold.mjs +13 -2
- package/lib/template-history.json +38 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +2 -1
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/src/main.ts +22 -1
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +148 -5
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +21 -6
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +4 -1
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
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 ? [...
|
|
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 >
|
|
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));
|