@homie-rocks/studio 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,8 +110,8 @@ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
110
110
  A studio pins one version, so nothing changes until it asks. To take a newer one:
111
111
 
112
112
  ```sh
113
- npx -y @homie-rocks/studio@0.17.0 upgrade # the plan; changes nothing
114
- npx -y @homie-rocks/studio@0.17.0 upgrade --apply # after the person agrees
113
+ npx -y @homie-rocks/studio@0.18.1 upgrade # the plan; changes nothing
114
+ npx -y @homie-rocks/studio@0.18.1 upgrade --apply # after the person agrees
115
115
  npm install && npm run build
116
116
  ```
117
117
 
@@ -278,7 +278,7 @@ npx homie-studio agents pass night-rush --label Claude
278
278
  ## Setup status
279
279
 
280
280
  ```sh
281
- npx -y @homie-rocks/studio@0.17.0 setup status --connector yes # before a studio exists
281
+ npx -y @homie-rocks/studio@0.18.1 setup status --connector yes # before a studio exists
282
282
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
283
283
  ```
284
284
 
@@ -288,9 +288,16 @@ with the studio's own Wrangler, and the account's email verified: a deploy that
288
288
  deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
289
289
  fal (and, in Claude Code, the status line). Each row is green, missing or "do this now", says what it
290
290
  unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
291
- seconds, and never prints a key, a token or an account's name. A studio the Claude app's setup card made
291
+ seconds, and never prints a key, a token or an account's name or id. A studio the Claude app's setup card made
292
292
  (`setup attach`) counts as connected to Cloudflare and GitHub there. `--json` gives the rows.
293
293
 
294
+ When a server's AI guides think with Workers AI (0.18.1), a **Workers AI** row checks that the model they use
295
+ (`HOMIE_BRAIN_MODEL` in wrangler.jsonc `vars`, else `@cf/meta/llama-3.1-8b-instruct-fp8-fast`) answers on the
296
+ studio's account: one tiny call through Cloudflare's API with the studio's own Wrangler login (a one-word prompt,
297
+ one token out: about 0.1 of the 10,000 free neurons a day; `lib/brain-probe.mjs`). A model Cloudflare moved to
298
+ Workers Paid (error 5035) or retired (5007) is named, with what to do: pick another model, or, the person's money,
299
+ Workers Paid. A spent daily allowance (3036) is said as that, not as a broken model.
300
+
294
301
  ## The Game Codex
295
302
 
296
303
  `games/<id>/CODEX.md` is a game's plan: Markdown with a small frontmatter for its look (palette, fonts, a
@@ -459,7 +466,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
459
466
  | `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
460
467
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
461
468
  | `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
462
- | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
469
+ | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`; `fitView` and `createLabels` (`port/view.ts`): a flat world on every screen (the whole of it where it reads, else filling a phone held upright and following the player) and names that never pile up when bodies crowd (yours placed first, the rest moved or faded). |
463
470
  | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site's files or, once moved, from the studio's R2 at the same address; a loose file from `media put` at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
464
471
  | `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
465
472
  | `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `homie-studio doctor`'s Workers AI row: when a server's AI guides think with Workers AI (brain `workers-ai`), is the
3
+ * model they use (wrangler.jsonc var HOMIE_BRAIN_MODEL, else worker/brain.mjs DEFAULT_MODEL) one this studio's
4
+ * Cloudflare account can run? Cloudflare moves models to the Workers Paid plan from time to time (three on 2026-07-28,
5
+ * error 5035) and retires others (5007); either way the guides fall back to the game's script without a word, so the
6
+ * doctor asks once.
7
+ *
8
+ * The check is ONE tiny call to the model through Cloudflare's REST API (`POST /accounts/:id/ai/run/:model`, a
9
+ * one-word prompt, max_tokens 1: about 0.1 of the 10,000 free neurons a day), with the studio's own Wrangler login
10
+ * (`wrangler auth token`, held in this process only) or CLOUDFLARE_API_TOKEN. It never prints the token, the account's
11
+ * id or its name. A call that cannot be made (not signed in, several accounts and none named) says so and spends
12
+ * nothing.
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { DEFAULT_MODEL } from '../worker/brain.mjs';
17
+ import { configPath, workerDir } from './studio.mjs';
18
+
19
+ const MODELS = 'https://developers.cloudflare.com/workers-ai/models/';
20
+ /** A Workers AI model id: @cf/<maker>/<model> (or @hf/…); nothing that could leave the API path. */
21
+ export const MODEL_ID = /^@[a-z0-9-]+\/[A-Za-z0-9._-]+\/[A-Za-z0-9._:-]+$/;
22
+
23
+ const parseJson = (text) => { const s = String(text ?? ''); const i = s.search(/[[{]/); if (i < 0) return null; try { return JSON.parse(s.slice(i)); } catch { return null; } };
24
+
25
+ /** The model the studio's guides think with: wrangler.jsonc "vars".HOMIE_BRAIN_MODEL, else the toolkit's default. */
26
+ export function brainModelOf(root) {
27
+ try {
28
+ const json = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''));
29
+ const v = json?.vars?.HOMIE_BRAIN_MODEL;
30
+ if (typeof v === 'string' && v.trim()) return { model: v.trim(), set: true };
31
+ } catch { /* no config: the default */ }
32
+ return { model: DEFAULT_MODEL, set: false };
33
+ }
34
+
35
+ /**
36
+ * Whether any open server of the studio's games thinks with Workers AI: a game.json seed in the built catalogue, or a
37
+ * row in the live database (one read-only `wrangler d1 execute --remote`). A database without the servers table, or
38
+ * one that cannot be read, says no.
39
+ */
40
+ export async function usesWorkersAi(root, { exec, env = process.env, accountId = null, bin } = {}) {
41
+ try {
42
+ const cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8'));
43
+ if ((cat.games ?? []).some((g) => (g.servers ?? []).some((s) => s.brain === 'workers-ai'))) return true;
44
+ } catch { /* not built */ }
45
+ if (!bin || !existsSync(bin)) return false;
46
+ let db = 'DB';
47
+ try { db = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''))?.d1_databases?.[0]?.binding ?? 'DB'; } catch { /* the default binding */ }
48
+ const r = await exec(bin, ['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"], {
49
+ cwd: workerDir(root), timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1', ...(accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}) },
50
+ });
51
+ if (r.code !== 0) return false;
52
+ const parsed = parseJson(r.stdout);
53
+ return Number((Array.isArray(parsed) ? parsed[0] : parsed)?.results?.[0]?.n) > 0;
54
+ }
55
+
56
+ /** How to call Cloudflare's API as this computer's Wrangler login: headers, or null. Never printed. */
57
+ export async function cloudflareAuth({ exec, env = process.env, bin, cwd } = {}) {
58
+ if (env.CLOUDFLARE_API_TOKEN) return { authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` };
59
+ if (env.CLOUDFLARE_API_KEY && env.CLOUDFLARE_EMAIL) return { 'x-auth-key': env.CLOUDFLARE_API_KEY, 'x-auth-email': env.CLOUDFLARE_EMAIL };
60
+ if (!bin || !existsSync(bin)) return null;
61
+ const r = await exec(bin, ['auth', 'token', '--json'], { cwd, timeout: 15_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
62
+ if (r.code !== 0) return null;
63
+ const j = parseJson(r.stdout);
64
+ if (typeof j?.token === 'string' && j.token) return { authorization: `Bearer ${j.token}` };
65
+ if (typeof j?.key === 'string' && typeof j?.email === 'string') return { 'x-auth-key': j.key, 'x-auth-email': j.email };
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * What Cloudflare's answer to the probe means (its errors: developers.cloudflare.com/workers-ai/platform/errors/):
71
+ * `{ why, state }` with why one of ok, paid-only, missing, not-allowed, agreement, allowance, busy, auth, other.
72
+ */
73
+ export function classifyProbe(status, body) {
74
+ const errs = Array.isArray(body?.errors) ? body.errors : [];
75
+ const codes = errs.map((e) => Number(e?.code)).filter(Number.isFinite);
76
+ const msg = errs.map((e) => String(e?.message ?? '')).filter(Boolean).join('; ');
77
+ const has = (c) => codes.includes(c);
78
+ if (status >= 200 && status < 300 && body?.success !== false) return { why: 'ok', state: 'ok', msg };
79
+ if (has(5035) || (status === 403 && /workers paid|paid plan|upgrade/i.test(msg))) return { why: 'paid-only', state: 'act', msg };
80
+ if (has(5007) || /no such model/i.test(msg) || status === 404) return { why: 'missing', state: 'act', msg };
81
+ if (has(5018)) return { why: 'not-allowed', state: 'act', msg };
82
+ if (has(5016) || /agree/i.test(msg)) return { why: 'agreement', state: 'act', msg };
83
+ if (has(3036) || (status === 429 && /free allocation|neurons/i.test(msg))) return { why: 'allowance', state: 'later', msg };
84
+ if (has(3040) || status === 429 || status >= 500) return { why: 'busy', state: 'unknown', msg };
85
+ if (status === 401 || has(10000) || has(10001) || (status === 403 && /auth/i.test(msg))) return { why: 'auth', state: 'unknown', msg };
86
+ if (status === 400) return { why: 'input', state: 'act', msg };
87
+ return { why: 'other', state: 'unknown', msg };
88
+ }
89
+
90
+ /**
91
+ * The one call. `{ status, body }`, or `{ error }` when the network failed. `fetchFn` stands in for the network in
92
+ * tests; the call is a one-word prompt with max_tokens 1.
93
+ */
94
+ export async function probeModel({ model, accountId, headers, fetchFn = globalThis.fetch, base = 'https://api.cloudflare.com/client/v4', ms = 12_000 }) {
95
+ try {
96
+ const res = await fetchFn(`${String(base).replace(/\/+$/, '')}/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`, {
97
+ method: 'POST',
98
+ headers: { ...headers, 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'homie-studio-doctor' },
99
+ body: JSON.stringify({ messages: [{ role: 'user', content: 'Say OK.' }], max_tokens: 1 }),
100
+ signal: AbortSignal.timeout(ms),
101
+ });
102
+ return { status: res.status, body: await res.json().catch(() => null) };
103
+ } catch (error) {
104
+ return { error: String(error?.name === 'TimeoutError' ? `no answer in ${Math.round(ms / 1000)} s` : error?.message ?? error) };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The doctor's row, or null when no server thinks with Workers AI. `who`: the doctor's Wrangler answer
110
+ * (`signedIn`, `accountIds`); `bin`: the studio's own Wrangler; `remote`: the studio is live, so its database may be
111
+ * read; `exec`, `fetchFn` replaceable for tests.
112
+ */
113
+ export async function workersAiRow({ root, studio, env = process.env, exec, fetchFn = globalThis.fetch, who = {}, bin, remote = false, cli = 'npx --no-install homie-studio' }) {
114
+ if (!root) return null;
115
+ const accountId = env.CLOUDFLARE_ACCOUNT_ID || studio?.cloudflare?.accountId || (who.accountIds?.length === 1 ? who.accountIds[0] : null);
116
+ if (!(await usesWorkersAi(root, { exec, env, accountId, bin: remote ? bin : null }))) return null;
117
+ const { model, set } = brainModelOf(root);
118
+ const row = {
119
+ id: 'workers-ai', label: 'Workers AI', need: 'for AI guides', model,
120
+ unlocks: 'AI guides that think and talk on servers whose brain is workers-ai, on the studio\'s own Workers AI (free: 10,000 neurons a day for the account)',
121
+ };
122
+ const where = set ? 'HOMIE_BRAIN_MODEL in wrangler.jsonc' : 'the toolkit\'s default';
123
+ const pick = (lead) => ({
124
+ who: 'ai', open: MODELS,
125
+ say: `${lead}Pick a model the account can run: set HOMIE_BRAIN_MODEL in wrangler.jsonc "vars" to a text-generation model from ${MODELS} that is not marked Workers Paid${set && model !== DEFAULT_MODEL ? ` (or remove it for the default, ${DEFAULT_MODEL})` : ''}, run \`${cli} doctor\` again, then \`npm run deploy\`. Until then the guides answer from the game's script.`,
126
+ });
127
+ const money = ' Moving the account to Workers Paid ($5 a month) also works, but that is the person\'s money: ask them, never decide it.';
128
+ if (!MODEL_ID.test(model)) return { ...row, state: 'act', detail: `${where} is "${model.slice(0, 80)}", which is not a Workers AI model id (@cf/<maker>/<model>)`, fix: pick('') };
129
+ if (who.signedIn === false && !env.CLOUDFLARE_API_TOKEN) return { ...row, state: 'later', detail: `${model} (${where}): checked once Wrangler is signed in`, fix: null };
130
+ if (!accountId) return { ...row, state: 'unknown', detail: `${model}: this login reaches several Cloudflare accounts and studio.json does not say which is the studio's (cloudflare.accountId, written at the first deploy)`, fix: null };
131
+ const headers = await cloudflareAuth({ exec, env, bin, cwd: workerDir(root) });
132
+ if (!headers) return { ...row, state: 'unknown', detail: `${model}: could not ask Cloudflare (no sign-in to call its API with)`, fix: { who: 'ai', run: 'npx wrangler login', say: 'Sign in to Cloudflare again (you approve once in the browser), then run doctor again.' } };
133
+ const answer = await probeModel({ model, accountId, headers, fetchFn, base: env.CLOUDFLARE_API_BASE_URL || undefined });
134
+ // Cloudflare's own words, without the account's id, and short.
135
+ const plain = (s) => String(s ?? '').split(accountId).join('<account>').replace(/\s+/g, ' ').trim().slice(0, 160);
136
+ if (answer.error) return { ...row, state: 'unknown', detail: `${model}: this computer's request to Cloudflare failed (${plain(answer.error)}); run doctor again`, fix: null };
137
+ const c = classifyProbe(answer.status, answer.body);
138
+ const said = c.msg ? ` (Cloudflare: "${plain(c.msg)}")` : '';
139
+ switch (c.why) {
140
+ case 'ok': return { ...row, state: 'ok', detail: `${model} answers on this studio's Cloudflare account (${where}; one tiny call just now)`, fix: null };
141
+ case 'paid-only': {
142
+ const fix = pick(model === DEFAULT_MODEL ? 'The toolkit\'s default model is paid-only now. ' : '');
143
+ return { ...row, state: 'act', detail: `${model} needs the Workers Paid plan, and this account is on Workers Free: every guide on a workers-ai server answers from the game's script${said}`, fix: { ...fix, say: fix.say + money } };
144
+ }
145
+ case 'missing': return { ...row, state: 'act', detail: `Cloudflare has no model ${model} (renamed or retired)${said}`, fix: pick('') };
146
+ case 'not-allowed': return { ...row, state: 'act', detail: `this account may not use ${model} (a private model)${said}`, fix: pick('') };
147
+ case 'agreement': return { ...row, state: 'act', detail: `${model} asks for its maker's licence to be agreed once before it is used${said}`, fix: pick('Agreeing to a licence is the person\'s to do, never yours; or simply: ') };
148
+ case 'input': return { ...row, state: 'act', detail: `${model} did not take a chat message: the guides need a text-generation model${said}`, fix: pick('') };
149
+ case 'allowance': return { ...row, state: 'later', detail: `today's free Workers AI allowance (10,000 neurons for the account) is used up, so ${model} could not be checked; the guides answer from the script until 00:00 UTC`, fix: { who: 'person', say: `Nothing to do now: run doctor again after 00:00 UTC. The studio's daily budget (\`${cli} agents brain <game> <server> workers-ai --budget <neurons>\`) keeps the guides under the allowance.` } };
150
+ case 'busy': return { ...row, state: 'unknown', detail: `Workers AI was busy just now (status ${answer.status})${said}; run doctor again in a minute`, fix: null };
151
+ case 'auth': return { ...row, state: 'unknown', detail: `Cloudflare did not let this sign-in run Workers AI (status ${answer.status})${said}`, fix: { who: 'ai', run: 'npx wrangler login', say: 'Sign in to Cloudflare again (the login includes Workers AI), then run doctor again.' } };
152
+ default: return { ...row, state: 'unknown', detail: `${model}: Cloudflare answered ${answer.status}${said}`, fix: null };
153
+ }
154
+ }
package/lib/doctor.mjs CHANGED
@@ -8,6 +8,9 @@
8
8
  * Homie tools are there: --connector yes|no)
9
9
  * Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
10
10
  * first deploy; a deploy that went through proves it)
11
+ * Workers AI for AI guides only when a server's guides think with Workers AI: the model they use
12
+ * (HOMIE_BRAIN_MODEL, else the default) answers on the studio's account, not paid-only
13
+ * or retired (lib/brain-probe.mjs: one tiny call, about 0.1 of the free daily neurons)
11
14
  * Chrome for checks the two-browser check, the look pictures and playtests
12
15
  * ffmpeg recommended sound effects and the theme, trailers and hero footage
13
16
  * GitHub optional a private backup, publishing by pull request, building from the Claude app
@@ -17,12 +20,14 @@
17
20
  *
18
21
  * It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
19
22
  * tools, `wrangler whoami`, `gh auth status`, `elevenlabs auth status`, one GET to the directory and, when a fal key
20
- * is set, fal's free pricing API), never changes anything, and never prints a key, a token or an account's name.
23
+ * is set, fal's free pricing API; when a server's AI guides use Workers AI, one read of the live database and one
24
+ * one-word call to the model), never changes anything, and never prints a key, a token or an account's name or id.
21
25
  * Every check has a time limit, so it answers in seconds even with no network.
22
26
  */
23
27
  import { spawn } from 'node:child_process';
24
28
  import { existsSync, readFileSync } from 'node:fs';
25
29
  import { join } from 'node:path';
30
+ import { workersAiRow } from './brain-probe.mjs';
26
31
  import { findChrome } from './chrome.mjs';
27
32
  import { whyFailed } from './net.mjs';
28
33
  import { isOurs } from './statusline.mjs';
@@ -98,7 +103,8 @@ export async function setupStatus({
98
103
  if (!bin || !existsSync(bin)) return { signedIn: null };
99
104
  const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
100
105
  if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
101
- try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null }; } catch { return { signedIn: false }; }
106
+ // The accounts' ids stay in this process (the Workers AI row calls the studio's own account); never printed.
107
+ try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null, accountIds: Array.isArray(d.accounts) ? d.accounts.map((a) => a?.id).filter(Boolean) : [] }; } catch { return { signedIn: false }; }
102
108
  })(),
103
109
  (async () => {
104
110
  const git = await exec('git', ['--version']);
@@ -182,6 +188,15 @@ export async function setupStatus({
182
188
  });
183
189
  }
184
190
 
191
+ // Workers AI, only when a server's AI guides think with it: does the model they use answer on this account?
192
+ {
193
+ const local = root ? readLocal(root) : {};
194
+ const live = Boolean(local.deployedAt || local.connectedAt || (studio?.cloudflare?.created ?? []).some((c) => String(c).startsWith('worker:')));
195
+ const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
196
+ const row = root ? await workersAiRow({ root, studio, env, exec, fetchFn, who, bin, remote: live && Boolean(who.signedIn || env.CLOUDFLARE_API_TOKEN), cli }) : null;
197
+ if (row) rows.push(row);
198
+ }
199
+
185
200
  // Chrome, for the checks.
186
201
  {
187
202
  const found = chrome();
@@ -254,6 +269,7 @@ export async function setupStatus({
254
269
  { feature: 'Backup and pull requests', state: ready(by.github.state), needs: ['github'] },
255
270
  { feature: 'Songs and game scores', state: ready(by.elevenlabs.state), needs: ['elevenlabs'] },
256
271
  { feature: 'Painted art and generated video', state: ready(by.fal.state), needs: ['fal'] },
272
+ ...(by['workers-ai'] ? [{ feature: 'AI guides that think (Workers AI)', state: ready(by['workers-ai'].state), needs: ['workers-ai'] }] : []),
257
273
  ];
258
274
  const now = rows.filter((r) => ['missing', 'act'].includes(r.state) && r.fix);
259
275
  const meanwhile = rows.filter((r) => r.fix?.open && r.state !== 'ok' && (r.fix.who === 'person' || r.state === 'later'));
@@ -546,6 +546,34 @@
546
546
  "## Beta": "9d02638207a26e73"
547
547
  }
548
548
  }
549
+ },
550
+ "0.18.1": {
551
+ "files": {
552
+ "CLAUDE.md": "ddf2b2a0376202f6",
553
+ "README.md": "684b3cca8001673c",
554
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
555
+ "games/README.md": "5ad32fe80c61f117",
556
+ "music/README.md": "a3dc042dadd39df8",
557
+ "videos/README.md": "16babc3437d5545b",
558
+ "posts/README.md": "37841aae476bd540",
559
+ "site/README.md": "ca131cd0472464fb",
560
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
561
+ },
562
+ "sections": {
563
+ "AGENTS.md": {
564
+ "(intro)": "214aa46eb3a895d5",
565
+ "## Layout": "eb1942414b8af7f8",
566
+ "## Commands (all through the pinned CLI in node_modules)": "9c51f189412e6a9f",
567
+ "## The site": "ab922bb6bde23293",
568
+ "## Making games": "a8a893eeada33bf5",
569
+ "## The Game Codex and progress": "0738ef719cef3a4c",
570
+ "## Running live games (the back office)": "f0566f07cff04113",
571
+ "## Servers and AI seats": "0a332b07e22d3fce",
572
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
573
+ "## Rules": "969318425c99f5a5",
574
+ "## Beta": "9d02638207a26e73"
575
+ }
576
+ }
549
577
  }
550
578
  }
551
579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/port/index.ts CHANGED
@@ -8,6 +8,9 @@
8
8
  * keys on computers, a floating stick and small buttons on phones
9
9
  * groundBasis / screenToGround / PlayerYaw
10
10
  * the camera rules: input on screen axes, a yaw only the player turns
11
+ * fitView / easeView / toScreen / createLabels
12
+ * a flat world on every screen (the whole of it, or filling an upright phone and following the
13
+ * player) and name labels that never pile up
11
14
  * exposePort what `homie-studio port check` reads (owner tests)
12
15
  * createHud a minimal clock / scores / results overlay
13
16
  * BotBrain, seek, nearest, rubberBand
@@ -26,6 +29,7 @@ export { createKeys, synthKey, holdKey, tapKey, keyInfo, releaseAllSynthKeys, ty
26
29
  export { createTouchControls, type TouchControls, type TouchOptions, type TouchButton } from './touch';
27
30
  export { createControls, type Controls, type ControlsOptions } from './controls';
28
31
  export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLike } from './camera';
32
+ export { fitView, easeView, toScreen, createLabels, type Fit, type FitOptions, type LabelIn, type LabelOut, type LabelOptions, type LabelBox } from './view';
29
33
  export { exposePort, type PortProbeOptions, type View } from './probe';
30
34
  export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
31
35
  export { createHud, type HudOptions } from './hud';
package/port/view.ts ADDED
@@ -0,0 +1,213 @@
1
+ /*
2
+ * A flat world on every screen: a phone held upright, the same phone on its side, a computer and a television.
3
+ * Two pieces for a top-down canvas game (both starters use them):
4
+ *
5
+ * fitView(o) where the camera looks and how close. The whole world where it fits at a size that reads
6
+ * (a computer, a TV); elsewhere (most of all a phone held upright, where "fit" drew a strip
7
+ * a quarter of the screen tall) the world FILLS the screen and follows the player, never
8
+ * past the world's edge, except as far as it takes to keep the player clear of the HUD.
9
+ * createLabels() name labels that never pile up. Each label wants its spot above its body; when that spot
10
+ * is taken it tries a row higher, then under the body, else it fades out. The player's own
11
+ * label is placed first and nothing covers it; a moved label keeps its new spot while it is
12
+ * free (no flicker), and labels fade in and out rather than pop.
13
+ *
14
+ * Everything is in CSS pixels (screen) and world units; the game draws. See the starters (gem-rush, ember-vale).
15
+ */
16
+
17
+ export interface FitOptions {
18
+ /** The world's size, in world units. */
19
+ world: { w: number; h: number };
20
+ /** The screen (the canvas's CSS size). */
21
+ screen: { w: number; h: number };
22
+ /** CSS px per world unit below which the whole world is too small to play (bodies too small to see, names too small
23
+ * to read): under it the camera follows `focus`. 0: never show the whole world unless `whole`. */
24
+ readable: number;
25
+ /** How close a following camera is, in CSS px per world unit (default `readable`). It is never less than what
26
+ * fills the screen, so a following camera never shows an empty band beside the world. */
27
+ zoom?: number;
28
+ /** Where a following camera looks, in world units (the player's body, or the player a watcher follows). null: the
29
+ * world's middle. */
30
+ focus?: { x: number; y: number } | null;
31
+ /** Show the whole world whatever its size (an overview: a watcher following nobody). */
32
+ whole?: boolean;
33
+ /** CSS px of HUD along each edge: a following camera slides past the world's edge (up to this much) only when the
34
+ * player would otherwise be under the HUD, at the very edge of the world. */
35
+ inset?: { top?: number; right?: number; bottom?: number; left?: number };
36
+ }
37
+
38
+ export interface Fit {
39
+ /** CSS px per world unit. */
40
+ scale: number;
41
+ /** The world point at the screen's centre. */
42
+ x: number;
43
+ y: number;
44
+ /** True when the camera follows (the whole world is not on screen). */
45
+ follow: boolean;
46
+ }
47
+
48
+ /** Where the camera should look this frame (ease towards it: a cut only at a round start or a respawn). */
49
+ export function fitView(o: FitOptions): Fit {
50
+ const W = o.world.w; const H = o.world.h;
51
+ const sw = Math.max(1, o.screen.w); const sh = Math.max(1, o.screen.h);
52
+ const contain = Math.min(sw / W, sh / H);
53
+ if (o.whole || (o.readable > 0 && contain >= o.readable)) return { scale: contain, x: W / 2, y: H / 2, follow: false };
54
+ const cover = Math.max(sw / W, sh / H);
55
+ const scale = Math.max(cover, o.zoom ?? o.readable);
56
+ const f = o.focus ?? { x: W / 2, y: H / 2 };
57
+ const i = { top: 0, right: 0, bottom: 0, left: 0, ...(o.inset ?? {}) };
58
+ const hw = sw / 2 / scale; const hh = sh / 2 / scale;
59
+ const within = (v: number, lo: number, hi: number): number => (lo > hi ? (lo + hi) / 2 : Math.max(lo, Math.min(hi, v)));
60
+ // Centred on the focus, kept inside the world; then moved just enough that the focus is clear of the HUD's margins.
61
+ const clear = (c: number, focus: number, half: number, lo: number, hi: number): number => {
62
+ const at = (half + focus - c) * scale; // the focus on screen, from the near edge
63
+ const span = 2 * half * scale;
64
+ if (at < lo) return c - (lo - at) / scale;
65
+ if (at > span - hi) return c + (at - (span - hi)) / scale;
66
+ return c;
67
+ };
68
+ return {
69
+ scale,
70
+ x: clear(within(f.x, hw, W - hw), f.x, hw, i.left, i.right),
71
+ y: clear(within(f.y, hh, H - hh), f.y, hh, i.top, i.bottom),
72
+ follow: true,
73
+ };
74
+ }
75
+
76
+ /** A world point on screen (CSS px) under a camera. */
77
+ export function toScreen(fit: Fit, screen: { w: number; h: number }, x: number, y: number): { x: number; y: number } {
78
+ return { x: screen.w / 2 + (x - fit.x) * fit.scale, y: screen.h / 2 + (y - fit.y) * fit.scale };
79
+ }
80
+
81
+ /** Ease a camera towards where it should be (frame-rate independent; `rate` about 8 to 12 a second). */
82
+ export function easeView(cam: Fit | null, want: Fit, dt: number, rate = 10): Fit {
83
+ if (!cam) return { ...want };
84
+ const k = 1 - Math.exp(-Math.max(0, dt) * rate);
85
+ return { scale: cam.scale + (want.scale - cam.scale) * k, x: cam.x + (want.x - cam.x) * k, y: cam.y + (want.y - cam.y) * k, follow: want.follow };
86
+ }
87
+
88
+ export interface LabelBox { left: number; top: number; right: number; bottom: number }
89
+
90
+ export interface LabelIn {
91
+ /** Stable per body (its slot): a label keeps its spot and its fade from frame to frame. */
92
+ key: string | number;
93
+ text: string;
94
+ /** The label's centre x and its BOTTOM edge where it wants to sit (above its body), CSS px. */
95
+ x: number;
96
+ y: number;
97
+ /** Its size as drawn, CSS px (measureText's width; the font's line height). */
98
+ w: number;
99
+ h: number;
100
+ /** Where its bottom edge goes if it sits under its body instead (CSS px); omitted: never under. */
101
+ below?: number;
102
+ /** Its body on screen (with its bars): other labels keep off it when they can. */
103
+ body?: LabelBox;
104
+ /** The player's own label (or the player a watcher follows): placed first, never covered, never faded. */
105
+ self?: boolean;
106
+ /** Lower first: who keeps their spot when two want it (people before AI before bots, near before far). */
107
+ rank?: number;
108
+ }
109
+
110
+ export interface LabelOut extends LabelIn, LabelBox {
111
+ /** The text's centre (draw with textAlign center, textBaseline middle). */
112
+ cx: number;
113
+ cy: number;
114
+ /** 0..1: 1 shown, 0 gone (no room for it). */
115
+ alpha: number;
116
+ /** Not in its own spot above its body (draw a thin line to the body, so whose it is stays plain). */
117
+ moved: boolean;
118
+ }
119
+
120
+ export interface LabelOptions {
121
+ /** CSS px kept clear between two labels (default 3). */
122
+ gap?: number;
123
+ /** CSS px kept clear round the player's own label, for a chip drawn behind it (default 4). */
124
+ selfPad?: number;
125
+ /** How fast a label fades in (default 8 a second). One with no room left goes at once if it would touch another. */
126
+ fade?: number;
127
+ /** The screen, so a label is never pushed off it (default the window). */
128
+ screen?: () => { w: number; h: number };
129
+ /** Boxes labels must keep clear of (the game's own speech bubbles, a panel), CSS px. */
130
+ avoid?: () => LabelBox[];
131
+ }
132
+
133
+ /**
134
+ * Name labels that never pile up. Call `place(labels, dt)` each frame with every label that wants drawing; draw what
135
+ * comes back (skip alpha 0). The order of the input does not matter: `self` first, then `rank`.
136
+ *
137
+ * Each label tries, in order: its own spot above its body, a row higher, under its body, two rows higher; first
138
+ * clear of every placed label and of other bodies, then clear of labels only. A label that moved keeps its new spot
139
+ * until its own is clear by a margin, so labels do not flicker between two spots as bodies jostle.
140
+ */
141
+ export function createLabels(opts: LabelOptions = {}): { place(labels: LabelIn[], dt: number): LabelOut[]; reset(): void } {
142
+ const gap = opts.gap ?? 3;
143
+ const selfPad = opts.selfPad ?? 4;
144
+ const fadeRate = opts.fade ?? 8;
145
+ const state = new Map<string | number, { spot: number; alpha: number }>();
146
+ const screen = opts.screen ?? ((): { w: number; h: number } => ({ w: typeof innerWidth === 'number' ? innerWidth : 1e9, h: typeof innerHeight === 'number' ? innerHeight : 1e9 }));
147
+ const hits = (a: LabelBox, b: LabelBox, m = gap): boolean => a.left < b.right + m && b.left < a.right + m && a.top < b.bottom + m && b.top < a.bottom + m;
148
+ return {
149
+ reset() { state.clear(); },
150
+ place(labels, dt) {
151
+ const { w: sw, h: sh } = screen();
152
+ const order = [...labels].sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self)) || (a.rank ?? 0) - (b.rank ?? 0));
153
+ const taken: LabelBox[] = [...(opts.avoid?.() ?? [])];
154
+ const bodies = labels.filter((l) => l.body).map((l) => ({ key: l.key, box: l.body as LabelBox }));
155
+ const out: LabelOut[] = [];
156
+ const seen = new Set<string | number>();
157
+ const k = 1 - Math.exp(-Math.max(0, dt) * fadeRate);
158
+ for (const l of order) {
159
+ seen.add(l.key);
160
+ // Spots: 0 its own (above the body), 1 a row higher, 2 under the body, 3 two rows higher.
161
+ const bottoms = [l.y, l.y - l.h - gap, l.below, l.y - 2 * (l.h + gap)];
162
+ const boxAt = (spot: number): LabelBox | null => {
163
+ const b = bottoms[spot];
164
+ if (b === undefined) return null;
165
+ const half = l.w / 2;
166
+ // Kept on screen sideways; a spot off the top or the bottom is not a spot.
167
+ const cx = Math.max(half + 2, Math.min(sw - half - 2, l.x));
168
+ const box = { left: cx - half, top: b - l.h, right: cx + half, bottom: b };
169
+ return box.top < 0 || box.bottom > sh ? null : box;
170
+ };
171
+ const clear = (b: LabelBox, strict: boolean, m = gap): boolean => !taken.some((t) => hits(b, t, m)) && (!strict || !bodies.some((o) => o.key !== l.key && hits(b, o.box, 0)));
172
+ const st = state.get(l.key) ?? { spot: 0, alpha: l.self ? 1 : 0 };
173
+ let spot = -1; let box: LabelBox | null = null;
174
+ if (l.self) {
175
+ spot = boxAt(0) ? 0 : boxAt(2) ? 2 : 0;
176
+ box = boxAt(spot) ?? { left: l.x - l.w / 2, top: l.y - l.h, right: l.x + l.w / 2, bottom: l.y };
177
+ } else {
178
+ // Home first (back home only with room to spare, once moved), then last frame's spot, then the rest.
179
+ const home = boxAt(0);
180
+ const tries = [st.spot, 1, 2, 3].filter((x, i, all) => x !== 0 && all.indexOf(x) === i);
181
+ for (const strict of [true, false]) {
182
+ if (home && clear(home, strict, st.spot === 0 ? gap : gap + 8)) { spot = 0; box = home; break; }
183
+ for (const x of tries) { const b = boxAt(x); if (b && clear(b, strict)) { spot = x; box = b; break; } }
184
+ if (box) break;
185
+ }
186
+ }
187
+ if (box) {
188
+ st.spot = spot;
189
+ st.alpha = l.self ? 1 : st.alpha + (1 - st.alpha) * k;
190
+ taken.push(l.self ? { left: box.left - selfPad, top: box.top - selfPad, right: box.right + selfPad, bottom: box.bottom + selfPad } : box);
191
+ } else {
192
+ // No room: it fades where it was, or goes at once if it would touch a label that has room.
193
+ const was = boxAt(st.spot);
194
+ st.alpha = !was || taken.some((t) => hits(was, t, 0)) ? 0 : st.alpha * (1 - k);
195
+ if (st.alpha < 0.05) st.alpha = 0;
196
+ }
197
+ state.set(l.key, st);
198
+ const shown = box ?? boxAt(st.spot) ?? boxAt(0) ?? { left: l.x - l.w / 2, top: l.y - l.h, right: l.x + l.w / 2, bottom: l.y };
199
+ out.push({ ...l, ...shown, cx: (shown.left + shown.right) / 2, cy: (shown.top + shown.bottom) / 2, alpha: Math.round(st.alpha * 100) / 100, moved: st.spot !== 0, ...(box ? {} : { fading: true }) });
200
+ }
201
+ // A name fading out never touches a name that has its place, nor another one fading (whatever their order).
202
+ const kept: LabelBox[] = [...taken];
203
+ for (const o of out as (LabelOut & { fading?: boolean })[]) {
204
+ if (!o.fading) continue;
205
+ delete o.fading;
206
+ if (o.alpha > 0 && kept.some((t) => hits(o, t, 0))) { o.alpha = 0; (state.get(o.key) as { alpha: number }).alpha = 0; }
207
+ if (o.alpha > 0) kept.push(o);
208
+ }
209
+ for (const key of [...state.keys()]) if (!seen.has(key)) state.delete(key);
210
+ return out;
211
+ },
212
+ };
213
+ }
@@ -21,20 +21,25 @@
21
21
  .panel li { margin: 4px 0; color: #e7d9b4; }
22
22
  .panel li small { color: #a39a80; }
23
23
  .panel .close { float: right; background: none; border: 0; color: #c9c0a6; font-size: 20px; cursor: pointer; }
24
- /* Asking a guide: buttons (agents.json "asks"), never a text box. Bottom centre, clear of the stick and STRIKE. */
24
+ /* Asking a guide: buttons (agents.json "asks"), never a text box. On a computer: bottom centre. */
25
25
  .asks { left: 50%; transform: translateX(-50%); bottom: calc(14px + env(safe-area-inset-bottom)); max-width: min(560px, calc(100vw - 200px)); box-sizing: border-box; padding: 8px 10px; border-radius: 14px; background: rgba(10,20,18,.84); border: 1px solid rgba(127,216,200,.45); text-align: center; }
26
26
  .asks .who { font: 700 12.5px/1.2 inherit; font-family: inherit; color: #7fd8c8; margin: 0 0 6px; }
27
27
  .asks .list { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
28
28
  .asks button { min-height: 34px; padding: 0 12px; border-radius: 999px; border: 1px solid rgba(127,216,200,.5); background: rgba(127,216,200,.12); color: #e9fff9; font: 600 13px/1 inherit; font-family: inherit; cursor: pointer; }
29
29
  .asks button.sent { background: #7fd8c8; color: #0b1512; }
30
- @media (max-width: 540px) { .asks { max-width: calc(100vw - 168px); bottom: calc(10px + env(safe-area-inset-bottom)); } .asks button { min-height: 40px; padding: 0 10px; } }
30
+ /* On a touch screen (body.touch: the touch kit drew STRIKE), either way up: a column right above STRIKE (68 px, 24 px
31
+ up from the bottom, 18 px in), where the thumb that strikes reaches it, clear of the stick and of the vale's
32
+ middle, where your hero is. */
33
+ .touch .asks { left: auto; transform: none; right: calc(12px + env(safe-area-inset-right)); bottom: calc(106px + env(safe-area-inset-bottom)); width: min(232px, calc(100vw - 24px)); max-width: none; max-height: calc(100vh - 166px); overflow: auto; }
34
+ .touch .asks .list { flex-direction: column; flex-wrap: nowrap; }
35
+ .touch .asks button { min-height: 40px; padding: 4px 10px; line-height: 1.15; }
31
36
  [hidden] { display: none !important; }
32
37
  </style>
33
38
  </head>
34
39
  <body>
35
40
  <canvas id="c"></canvas>
36
41
  <div class="ui pill"><button type="button" id="who"></button><button type="button" id="hall-btn">Hall of the Fallen</button></div>
37
- <div class="ui asks" id="asks" hidden aria-live="polite"><p class="who" id="asks-who"></p><div class="list" id="asks-list"></div></div>
42
+ <div class="ui asks" id="asks" hidden aria-live="polite" data-touch-pass><p class="who" id="asks-who"></p><div class="list" id="asks-list"></div></div>
38
43
  <div class="ui panel" id="make" hidden>
39
44
  <h2>A new hero</h2>
40
45
  <p>Your hero keeps every level and coin, on every device you play on.</p>
@@ -31,7 +31,7 @@
31
31
  * none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
32
32
  * never from a model; "Quiet AI" hides them.
33
33
  */
34
- import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent, type Skill } from '@homie-rocks/studio/port';
34
+ import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
35
35
  import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
36
36
  import vocabulary from '../agents.json';
37
37
 
@@ -444,6 +444,8 @@ function stepSlimes(dt: number, now: number): void {
444
444
 
445
445
  /* ------------------------------------------------------------------ input */
446
446
  const input = createControls({ actions: { strike: ['Space', 'KeyJ', 'Enter'] }, touch: { buttons: [{ id: 'strike', label: 'STRIKE' }] } });
447
+ // A touch screen has STRIKE at the bottom right: the ask panel stands right above it (index.html, .touch).
448
+ document.body.classList.toggle('touch', input.touch.enabled);
447
449
  function stepMe(dt: number): void {
448
450
  const b = room.hosting ? room.mine() : null;
449
451
  const isDown = b ? Boolean(b.flags & DOWN) : Boolean(myViewBody()?.flags && (myViewBody()!.flags & DOWN));
@@ -458,22 +460,54 @@ function myViewBody(): Body | null { const s = room.mySeat(); return room.view()
458
460
  /* ------------------------------------------------------------------ drawing */
459
461
  const canvas = document.getElementById('c') as HTMLCanvasElement;
460
462
  const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
461
- let scale = 1; let ox = 0; let oy = 0;
463
+ let dpr = 1; let vw = 1; let vh = 1;
462
464
  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;
465
+ dpr = Math.min(2, devicePixelRatio || 1); vw = innerWidth; vh = innerHeight;
466
+ canvas.width = Math.round(vw * dpr); canvas.height = Math.round(vh * dpr);
467
+ // The page's pills sit under the hero panel, which grows with the screen (a TV's would cover them).
468
+ const pills = document.querySelector<HTMLElement>('.pill');
469
+ if (pills) pills.style.top = `calc(${Math.max(86, Math.round(76 * hudScale() + 10))}px + env(safe-area-inset-top))`;
466
470
  }
471
+ /** CSS px per HUD unit, from the screen's CSS size: 0.7 on a phone either way up, 0.8 on a computer, 1.2 on a TV. */
472
+ function hudScale(): number { return Math.max(Math.min(vw, vh) <= 540 ? 0.7 : 0.8, Math.min(1.4, vh / 900, vw / 560)); }
467
473
  addEventListener('resize', resize);
468
474
  resize();
469
475
  let bannerText = ''; let bannerUntil = 0;
470
476
  function banner(t: string): void { bannerText = t; bannerUntil = performance.now() + 2600; }
471
477
  const SLIME = ['#7ad35a', '#e0a83a', '#c74bd8'];
472
478
 
479
+ /*
480
+ * The camera (port/view.ts). Where the whole vale fits at a size that reads (a computer, a TV) it all shows, as it
481
+ * always did. Where it would be small (a phone, above all held upright, where the vale was a strip a quarter of the
482
+ * screen tall), the vale fills the screen and follows your hero (a watcher: the hero followed), never past the vale's
483
+ * edge, except as far as it takes to keep your hero clear of the panel at the top and STRIKE at the bottom.
484
+ */
485
+ const READABLE = 0.62; // CSS px per vale unit: a body 27 px across, a name 12 px tall
486
+ const INSET = { top: 124, bottom: 100 }; // the hero panel and its pills; STRIKE and the ask panel's foot
487
+ let cam: Fit | null = null;
488
+ let lastDraw = 0;
489
+ // Names keep clear of the guides' bubbles and of the ask panel (a page element over the canvas).
490
+ const labels = createLabels({ screen: () => ({ w: vw, h: vh }), avoid: () => { const p = document.getElementById('asks'); return p && !p.hidden ? [...bubbleBoxes, p.getBoundingClientRect()] : bubbleBoxes; } });
491
+ let bubbleBoxes: { left: number; top: number; right: number; bottom: number }[] = [];
492
+ let shownLabels: LabelOut[] = [];
493
+ function aim(): Fit {
494
+ const v = lookOnly ? room.viewBody() : null;
495
+ const focus = !lookOnly && me.has ? { x: me.x, y: me.y } : v ? { x: v.x, y: v.y } : null;
496
+ const phone = Math.min(vw, vh) <= 540;
497
+ return fitView({ world: { w: W, h: H }, screen: { w: vw, h: vh }, readable: READABLE, zoom: Math.max(READABLE, Math.min(vw, vh) / (phone ? 560 : 820)), focus, whole: lookOnly && !v, inset: INSET });
498
+ }
499
+
473
500
  function draw(t: number): void {
474
- const s = scale;
501
+ const dt = lastDraw ? Math.min(0.1, (t - lastDraw) / 1000) : 0;
502
+ lastDraw = t;
503
+ const want = aim();
504
+ // A cut when the camera changes kind (a phone turned, a watcher's pick); else it glides.
505
+ cam = !cam || cam.follow !== want.follow ? want : easeView(cam, want, dt, 10);
506
+ const s = cam.scale;
507
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
475
508
  ctx.fillStyle = '#0b0f0a'; ctx.fillRect(0, 0, canvas.width, canvas.height);
476
- ctx.save(); ctx.translate(ox, oy); ctx.scale(s, s);
509
+ ctx.save();
510
+ ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * (vw / 2 - cam.x * s), dpr * (vh / 2 - cam.y * s));
477
511
  // The vale: moss, and embers that drift.
478
512
  ctx.fillStyle = '#16210f'; ctx.fillRect(0, 0, W, H);
479
513
  for (let i = 0; i < 70; i++) {
@@ -494,6 +528,15 @@ function draw(t: number): void {
494
528
  }
495
529
  // The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
496
530
  const viewS = room.viewSeat();
531
+ const sx = (x: number): number => vw / 2 + (x - (cam as Fit).x) * s;
532
+ const sy = (y: number): number => vh / 2 + (y - (cam as Fit).y) * s;
533
+ // Names are drawn after the vale, on the screen, at a size that reads on any screen; they never pile up.
534
+ const fs = clamp(18 * s, 12, 20);
535
+ ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
536
+ const names: LabelIn[] = [];
537
+ const said: { x: number; y: number; head: number; text: string }[] = [];
538
+ const mine: ScreenBox[] = []; // your own hero and its name: no bubble covers them
539
+ const meAt = (() => { const v = room.view().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
497
540
  for (const b of room.view()) {
498
541
  const self = !b.bot && b.seat === viewS;
499
542
  const local = self && !room.hosting && !net.watching;
@@ -508,72 +551,121 @@ function draw(t: number): void {
508
551
  ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(x - 24, y - R - 12, 48, 5);
509
552
  ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
510
553
  ctx.globalAlpha = 1;
511
- ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
512
554
  // An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
513
- 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);
555
+ const text = `${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''}${isGuide ? ' · guide' : ''} · ${b.level}`;
556
+ const near = meAt ? Math.hypot(x - meAt.x, y - meAt.y) : 0;
557
+ // Its spot is above its health bar; its body (and bar) is what other names keep off. People first, then guides,
558
+ // then bots; nearer you first.
559
+ const body = { left: sx(x - 24), top: sy(y - R - 12), right: sx(x + 24), bottom: sy(y + R) };
560
+ if (body.right < 0 || body.left > vw || body.bottom < 0 || body.top > vh) continue; // off screen: no name at the edge
561
+ if (self) { const w = ctx.measureText(text).width; mine.push(body, { left: sx(x) - w / 2 - 6, top: body.top - 4 - fs * 1.2, right: sx(x) + w / 2 + 6, bottom: body.top }); }
562
+ names.push({ key: b.slot, text, x: sx(x), y: body.top - 3, w: ctx.measureText(text).width, h: fs * 1.2, below: body.bottom + 4 + fs * 1.2, body, self, rank: (b.bot ? (isGuide ? 1 : 2) : 0) * 10_000 + near });
563
+ const line = bubbles.get(b.slot);
564
+ // The bubble's tail stops just above the guide's own name, so the name keeps its spot while it speaks.
565
+ if (line && line.until > performance.now()) said.push({ x: sx(x), y: body.top - 3 - fs * 1.2 - 12, head: body.top, text: line.text });
516
566
  }
517
567
  ctx.restore();
568
+ // Speech bubbles first (labels keep clear of them), then the names; your own last, on a dark chip.
569
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
570
+ bubbleBoxes = said.map((b) => bubble(b.x, b.y, b.text, fs, b.head, mine));
571
+ ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
572
+ shownLabels = labels.place(names, dt);
573
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
574
+ for (const l of [...shownLabels].sort((a, b) => Number(Boolean(a.self)) - Number(Boolean(b.self)))) {
575
+ if (l.alpha <= 0) continue;
576
+ ctx.globalAlpha = l.alpha;
577
+ if (l.moved) {
578
+ // Moved off its own spot: a thin line to its body says whose it is.
579
+ const under = l.top > l.y;
580
+ ctx.strokeStyle = 'rgba(231,217,180,.45)'; ctx.lineWidth = 1;
581
+ ctx.beginPath(); ctx.moveTo(l.cx, under ? l.top : l.bottom); ctx.lineTo(l.x, under ? (l.body?.bottom ?? l.top - 6) : l.y + 2); ctx.stroke();
582
+ }
583
+ if (l.self) { ctx.fillStyle = 'rgba(10,14,9,.72)'; ctx.beginPath(); ctx.roundRect(l.left - 5, l.top - 1, l.right - l.left + 10, l.bottom - l.top + 2, 6); ctx.fill(); }
584
+ else { ctx.strokeStyle = 'rgba(8,11,6,.8)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }
585
+ ctx.fillStyle = l.self ? '#ffcf6e' : '#e7d9b4';
586
+ ctx.fillText(l.text, l.cx, l.cy);
587
+ }
588
+ ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
589
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
518
590
  hud(t);
519
591
  }
520
592
 
521
- /** 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';
593
+ type ScreenBox = { left: number; top: number; right: number; bottom: number };
594
+ const touches = (a: ScreenBox, b: ScreenBox): boolean => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
595
+ /**
596
+ * A speech bubble: up to three short lines, above a guide (screen px). It never covers your own hero or its name
597
+ * (`clearOf`): it moves aside, or higher, and its tail runs down to the guide's head (`head`), so whose line it is
598
+ * stays plain. Returns the box it covers.
599
+ */
600
+ function bubble(x: number, y: number, text: string, fs: number, head: number, clearOf: ScreenBox[]): ScreenBox {
601
+ const f = Math.max(12, fs - 1);
602
+ ctx.font = `600 ${f}px ui-sans-serif, system-ui, sans-serif`;
524
603
  const words = text.split(' ');
525
604
  const lines: string[] = [];
526
605
  let line = '';
527
606
  for (const w of words) { if ((line + ' ' + w).trim().length > 26 && line) { lines.push(line); line = w; } else line = (line + ' ' + w).trim(); }
528
607
  if (line) lines.push(line);
529
608
  const shown = lines.slice(0, 3);
530
- const wid = Math.max(...shown.map((l) => ctx.measureText(l).width)) + 22;
531
- const hgt = shown.length * 20 + 12;
609
+ const wid = Math.max(...shown.map((l) => ctx.measureText(l).width)) + 20;
610
+ const hgt = shown.length * f * 1.25 + 10;
611
+ // Where it goes: above the guide; else beside that, either way; else higher, over whatever it must keep clear of.
612
+ const above = Math.min(y, ...clearOf.map((c) => c.top - 12));
613
+ const spots = [y, above].flatMap((by) => [x, x + wid * 0.6 + 16, x - wid * 0.6 - 16].map((bx) => ({ bx: clamp(bx, wid / 2 + 4, vw - wid / 2 - 4), by })));
614
+ const at = spots.find((p) => p.by - hgt >= 0 && !clearOf.some((c) => touches({ left: p.bx - wid / 2, top: p.by - hgt, right: p.bx + wid / 2, bottom: p.by + 8 }, c))) ?? spots[0]!;
615
+ const cx = at.bx; const by = at.by;
616
+ const tail = clamp(x, cx - wid / 2 + 12, cx + wid / 2 - 12);
532
617
  ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.strokeStyle = 'rgba(127,216,200,.75)'; ctx.lineWidth = 2;
533
- ctx.beginPath(); ctx.roundRect(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));
618
+ ctx.beginPath(); ctx.roundRect(cx - wid / 2, by - hgt, wid, hgt, 10); ctx.fill(); ctx.stroke();
619
+ ctx.beginPath(); ctx.moveTo(tail - 6, by); ctx.lineTo(tail + 6, by); ctx.lineTo(tail, by + 8); ctx.closePath(); ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.fill();
620
+ if (head > by + 12 || tail !== x) { ctx.strokeStyle = 'rgba(127,216,200,.75)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(tail, by + 8); ctx.lineTo(x, head - 2); ctx.stroke(); }
621
+ ctx.fillStyle = '#e9fff9'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
622
+ shown.forEach((l, i) => ctx.fillText(l, cx, by - hgt + 5 + f * 1.25 * (i + 0.5)));
623
+ ctx.textBaseline = 'alphabetic';
624
+ return { left: cx - wid / 2, top: by - hgt, right: cx + wid / 2, bottom: by + 8 };
537
625
  }
538
626
 
539
627
  function hud(t: number): void {
540
- const k = Math.max(0.8, Math.min(1.4, canvas.height / 900));
628
+ // Device px per HUD unit: as before on an upright phone, a computer and a TV; readable on a phone on its side and on
629
+ // a high-density computer screen (it was sized by device pixels, so half as big there).
630
+ const k = dpr * hudScale();
541
631
  ctx.save(); ctx.scale(k, k);
542
632
  const x0 = 14; const y0 = 14;
543
- 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) {
633
+ const TITLE = '700 16px ui-sans-serif, system-ui, sans-serif';
634
+ const SMALL = '600 13px ui-sans-serif, system-ui, sans-serif';
635
+ // A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
636
+ const v = lookOnly ? room.viewBody() : null;
637
+ const heroes = room.view().filter((b) => !b.bot).length;
638
+ const title = lookOnly ? (v ? `${v.name} · Lv ${v.level}` : 'Watching the vale') : hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…';
639
+ const sub = lookOnly ? (v ? `${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}` : `${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`) : hero ? `${hero.gold} gold · ${hero.kills} slain` : '';
640
+ const bar = v ? { colour: '#7ad35a', of: clamp(v.hp, 0, v.maxHp) / Math.max(1, v.maxHp) } : hero && !lookOnly ? { colour: '#ffcf6e', of: hero.xp / xpFor(hero.level) } : null;
641
+ // The panel is as wide as what it says: on a phone the vale runs under it, and the clock sits beside it.
642
+ ctx.font = TITLE; const titleW = ctx.measureText(title).width;
643
+ ctx.font = SMALL; const subW = sub ? ctx.measureText(sub).width : 0;
644
+ const pw = Math.max(bar ? 140 : 0, titleW, subW) + 20;
645
+ ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, pw, 62);
646
+ ctx.textAlign = 'left'; ctx.font = TITLE; ctx.fillStyle = '#ffcf6e';
647
+ ctx.fillText(title, x0 + 10, y0 + 22);
648
+ if (bar) {
562
649
  ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
563
- ctx.fillStyle = '#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);
650
+ ctx.fillStyle = bar.colour; ctx.fillRect(x0 + 10, y0 + 32, 140 * clamp(bar.of, 0, 1), 6);
566
651
  }
652
+ if (sub) { ctx.font = SMALL; ctx.fillStyle = '#e7d9b4'; ctx.fillText(sub, x0 + 10, y0 + (bar ? 54 : 46)); }
567
653
  const c = room.clock();
568
- ctx.textAlign = 'center'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#f3ead2';
654
+ ctx.font = TITLE; ctx.fillStyle = '#f3ead2';
569
655
  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);
656
+ const clock = c.phase === 'over' ? `Dawn · night ${c.n + 1} in ${c.secondsLeft}s` : `Night ${c.n} · ${Math.floor(c.secondsLeft / 60)}:${String(c.secondsLeft % 60).padStart(2, '0')}`;
657
+ // Top centre; on a narrow screen where that would touch the panel, just under it.
658
+ if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
659
+ else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
660
+ ctx.textAlign = 'center';
571
661
  if (c.phase === 'over') {
572
662
  const rows = room.results().slice(0, 5);
573
- 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);
663
+ // Under the page's own pills on a narrow screen (they are buttons, above the canvas), else under the clock.
664
+ const top = vw <= 540 ? 132 * (dpr / k) : 44;
665
+ ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, top, 300, 30 + rows.length * 22);
666
+ ctx.font = TITLE; ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, top + 22);
575
667
  ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
576
- rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, 90 + i * 22));
668
+ rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, top + 46 + i * 22));
577
669
  }
578
670
  if (performance.now() < bannerUntil) { ctx.font = '800 22px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e'; ctx.fillText(bannerText, cw / 2, canvas.height / k - 90); }
579
671
  const st = saves.status();
@@ -640,6 +732,7 @@ setInterval(() => { if (hero && document.visibilityState === 'visible') void sav
640
732
  /** The guide nearest my hero (within 340 px), and the asks I can make of it: drawn as buttons, four times a second. */
641
733
  let asksFor = -1;
642
734
  let asksSig = '';
735
+ const askEls = new Map<string, HTMLButtonElement>();
643
736
  function paintAsks(): void {
644
737
  const panel = $('asks');
645
738
  const mySeat = room.mySeat();
@@ -649,18 +742,21 @@ function paintAsks(): void {
649
742
  for (const b of room.view()) {
650
743
  const sl = (net.slots ?? []).find((x) => x.slot === b.slot);
651
744
  if (!sl?.agent || sl.agent.role !== 'guide') continue;
745
+ // The guide the panel is for keeps it while it is in reach, unless another is much nearer: two guides at the
746
+ // party's side would otherwise swap it four times a second, and a thumb's tap would land on a button just redrawn.
652
747
  const d = Math.hypot(b.x - me.x, b.y - me.y);
653
- if (d < 340 && (!best || d < best.d)) best = { slot: b.slot, name: b.name, d };
748
+ const rank = d - (b.slot === asksFor ? 120 : 0);
749
+ if (d < 340 && (!best || rank < best.d)) best = { slot: b.slot, name: b.name, d: rank };
654
750
  }
655
751
  }
656
752
  if (!best) { panel.hidden = true; asksFor = -1; asksSig = ''; return; }
657
753
  const quests = (net.stateOf<string[]>('quests') ?? (room.hosting ? openQuests() : ['slime-hunt'])).slice(0, 3);
658
- // Help with each open quest, one place to be led to (not the one I am in), and "No thanks"; three on a phone.
754
+ // Help with each open quest, one place to be led to (not the one I am in), and "No thanks"; three on a phone (either way up).
659
755
  const zone = zoneOf(me.x, me.y);
660
756
  const all = agents.askButtons(best.slot, { quests });
661
757
  const lead = all.filter((b) => b.k === 'lead_me' && b.args['place'] !== zone).slice(0, 1);
662
758
  const order = [...all.filter((b) => b.k === 'ask_help'), ...lead, ...all.filter((b) => b.k === 'no_thanks')];
663
- const buttons = order.slice(0, innerWidth < 540 ? 3 : 5);
759
+ const buttons = order.slice(0, Math.min(innerWidth, innerHeight) < 540 ? 3 : 5);
664
760
  const sig = `${best.slot}|${best.name}|${buttons.map((b) => b.text).join('|')}`;
665
761
  panel.hidden = false;
666
762
  if (sig === asksSig) return;
@@ -668,19 +764,29 @@ function paintAsks(): void {
668
764
  asksFor = best.slot;
669
765
  panel.dataset['slot'] = String(best.slot);
670
766
  $('asks-who').textContent = `${best.name} · guide`;
767
+ // A button that stays is the same element (a quest that opens or closes adds or takes one): a thumb's tap that lands
768
+ // while the list changes around it still counts.
671
769
  const list = $('asks-list');
672
- 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
- }
770
+ const keep = new Set<string>();
771
+ buttons.forEach((b, i) => {
772
+ const key = `${b.k}|${JSON.stringify(b.args)}`;
773
+ keep.add(key);
774
+ let el = askEls.get(key);
775
+ if (!el) {
776
+ const btn = document.createElement('button');
777
+ btn.type = 'button';
778
+ btn.dataset['ask'] = b.k;
779
+ btn.addEventListener('click', () => {
780
+ if (asksFor < 0) return;
781
+ if (agents.ask(asksFor, b.k, b.args)) { logGuide({ ev: 'asked', slot: asksFor, k: b.k, args: b.args }); btn.classList.add('sent'); setTimeout(() => btn.classList.remove('sent'), 900); }
782
+ });
783
+ askEls.set(key, btn);
784
+ el = btn;
785
+ }
786
+ if (el.textContent !== b.text) el.textContent = b.text;
787
+ if (list.children[i] !== el) list.insertBefore(el, list.children[i] ?? null);
788
+ });
789
+ for (const [key, el] of askEls) if (!keep.has(key)) { el.remove(); askEls.delete(key); }
684
790
  }
685
791
  setInterval(paintAsks, 250);
686
792
 
@@ -707,6 +813,9 @@ net.expose({
707
813
  me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
708
814
  log: guideLog.slice(),
709
815
  }),
816
+ // Where the camera looks, and the names as drawn (boxes only: the e2e probe counts overlaps and checks your own).
817
+ camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
818
+ labels: () => shownLabels.map((l) => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
710
819
  });
711
820
 
712
821
  exposePort(net, {
@@ -32,8 +32,9 @@
32
32
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
33
33
  */
34
34
  import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
35
- // The port toolkit'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.1';