@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 +12 -5
- package/lib/brain-probe.mjs +154 -0
- package/lib/doctor.mjs +18 -2
- package/lib/template-history.json +28 -0
- package/package.json +1 -1
- package/port/index.ts +4 -0
- package/port/view.ts +213 -0
- package/starters/ember-vale/index.html +8 -3
- package/starters/ember-vale/src/main.ts +172 -63
- package/starters/gem-rush/src/main.ts +46 -9
- package/worker/version.mjs +1 -1
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.
|
|
114
|
-
npx -y @homie-rocks/studio@0.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
463
|
+
let dpr = 1; let vw = 1; let vh = 1;
|
|
462
464
|
function resize(): void {
|
|
463
|
-
|
|
464
|
-
canvas.width = Math.round(
|
|
465
|
-
|
|
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
|
|
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();
|
|
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
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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)) +
|
|
531
|
-
const hgt = shown.length *
|
|
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(
|
|
534
|
-
ctx.beginPath(); ctx.moveTo(
|
|
535
|
-
ctx.
|
|
536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
} else ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
|
|
561
|
-
if (hero && !lookOnly) {
|
|
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 =
|
|
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.
|
|
654
|
+
ctx.font = TITLE; ctx.fillStyle = '#f3ead2';
|
|
569
655
|
const cw = canvas.width / k;
|
|
570
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
36
|
-
|
|
35
|
+
// The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
|
|
36
|
+
// and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
|
|
37
|
+
import { createLabels, exposePort, fitView, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
|
|
37
38
|
|
|
38
39
|
/* ------------------------------------------------------------------ rules */
|
|
39
40
|
const W = 1600;
|
|
@@ -601,6 +602,9 @@ addEventListener('resize', resize);
|
|
|
601
602
|
resize();
|
|
602
603
|
|
|
603
604
|
const cam = { x: W / 2, y: H / 2, scale: 0 };
|
|
605
|
+
const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }) });
|
|
606
|
+
let shownLabels: LabelOut[] = [];
|
|
607
|
+
let lastDraw = 0;
|
|
604
608
|
/** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
|
|
605
609
|
function seatPos(seat: number): { x: number; y: number } | null {
|
|
606
610
|
if (seat === mySeat() && me.has && !net.watching) return me;
|
|
@@ -620,12 +624,17 @@ function draw(t: number): void {
|
|
|
620
624
|
const view = viewSeat();
|
|
621
625
|
const followed = net.watching && view !== null ? seatPos(view) : null;
|
|
622
626
|
const overview = net.watching ? !followed : mySeat() === null;
|
|
623
|
-
|
|
627
|
+
// The camera: close on the body whose view this is, never past the arena's edge (except as far as it takes to keep
|
|
628
|
+
// that body clear of the HUD), and never so far out that an empty band shows beside the arena (held upright, a
|
|
629
|
+
// phone showed the arena as a band with a third of the screen dark below or above it; now the arena fills the
|
|
630
|
+
// screen). Following nobody: the whole arena.
|
|
631
|
+
const focus = net.watching ? followed : overview || !me.has ? null : me;
|
|
632
|
+
const want = overview ? { scale: Math.min(cw / (W + 80), ch / (H + 80)), x: W / 2, y: H / 2 }
|
|
633
|
+
: fitView({ world: { w: W, h: H }, screen: { w: cw, h: ch }, readable: 0, zoom: Math.min(cw, ch) / (phone ? 560 : 820), focus, inset: { top: 52, bottom: 36 } });
|
|
624
634
|
// A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
|
|
625
|
-
cam.scale = cam.scale ? cam.scale + (
|
|
635
|
+
cam.scale = cam.scale ? cam.scale + (want.scale - cam.scale) * 0.12 : want.scale;
|
|
626
636
|
const scale = cam.scale;
|
|
627
|
-
|
|
628
|
-
cam.x += (target.x - cam.x) * 0.2; cam.y += (target.y - cam.y) * 0.2;
|
|
637
|
+
cam.x += (want.x - cam.x) * 0.2; cam.y += (want.y - cam.y) * 0.2;
|
|
629
638
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
630
639
|
const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
|
|
631
640
|
bg.addColorStop(0, '#0d1426'); bg.addColorStop(1, '#04060c');
|
|
@@ -686,6 +695,11 @@ function draw(t: number): void {
|
|
|
686
695
|
list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });
|
|
687
696
|
}
|
|
688
697
|
}
|
|
698
|
+
// Names go on after the arena, on the screen, so they read at one size on any screen and never pile up.
|
|
699
|
+
const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
|
|
700
|
+
ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
|
|
701
|
+
const tags: LabelIn[] = [];
|
|
702
|
+
const viewAt = list.find((a) => a.mine) ?? null;
|
|
689
703
|
for (const a of list) {
|
|
690
704
|
const colour = colourOf(a.slot, a.bot ? null : a.seat);
|
|
691
705
|
ctx.globalAlpha = a.bot ? 0.62 : 1;
|
|
@@ -693,11 +707,32 @@ function draw(t: number): void {
|
|
|
693
707
|
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
|
|
694
708
|
if (a.mine) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV + 7, 0, Math.PI * 2); ctx.stroke(); }
|
|
695
709
|
ctx.globalAlpha = 1;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
710
|
+
const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
|
|
711
|
+
const sx = cw / 2 + (a.x - cam.x) * scale; const sy = ch / 2 + (a.y - cam.y) * scale; const r = (R_AV + (a.mine ? 7 : 0)) * scale;
|
|
712
|
+
if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge
|
|
713
|
+
// People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
|
|
714
|
+
tags.push({ key: a.slot, text, x: sx, y: sy - r - 5, w: ctx.measureText(text).width, h: fs * 1.2, below: sy + r + 4 + fs * 1.2, body: { left: sx - r, top: sy - r, right: sx + r, bottom: sy + r }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) : 0) });
|
|
699
715
|
}
|
|
700
716
|
ctx.restore();
|
|
717
|
+
shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));
|
|
718
|
+
lastDraw = t;
|
|
719
|
+
ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
|
|
720
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
|
|
721
|
+
for (const l of [...shownLabels].sort((x, y) => Number(Boolean(x.self)) - Number(Boolean(y.self)))) {
|
|
722
|
+
if (l.alpha <= 0) continue;
|
|
723
|
+
ctx.globalAlpha = l.alpha;
|
|
724
|
+
if (l.moved) {
|
|
725
|
+
// Off its own spot: a thin line to its body says whose it is.
|
|
726
|
+
const under = l.top > l.y;
|
|
727
|
+
ctx.strokeStyle = 'rgba(232,236,245,0.4)'; ctx.lineWidth = 1;
|
|
728
|
+
ctx.beginPath(); ctx.moveTo(l.cx, under ? l.top : l.bottom); ctx.lineTo(l.x, under ? (l.body?.bottom ?? l.top - 6) : l.y + 2); ctx.stroke();
|
|
729
|
+
}
|
|
730
|
+
if (l.self) { ctx.fillStyle = 'rgba(6,10,20,0.7)'; ctx.beginPath(); ctx.roundRect(l.left - 5, l.top - 1, l.right - l.left + 10, l.bottom - l.top + 2, 6); ctx.fill(); }
|
|
731
|
+
else { ctx.strokeStyle = 'rgba(4,6,12,0.85)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }
|
|
732
|
+
ctx.fillStyle = l.self ? '#ffffff' : 'rgba(232,236,245,0.85)';
|
|
733
|
+
ctx.fillText(l.text, l.cx, l.cy);
|
|
734
|
+
}
|
|
735
|
+
ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
|
|
701
736
|
|
|
702
737
|
hud(cw, ch, phone, list);
|
|
703
738
|
if (stick.active) {
|
|
@@ -797,6 +832,8 @@ net.expose({
|
|
|
797
832
|
predictionError: () => predictionError,
|
|
798
833
|
/** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */
|
|
799
834
|
camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),
|
|
835
|
+
/** The names as drawn (boxes, never the text): the e2e probe counts overlaps and checks your own. */
|
|
836
|
+
labels: () => shownLabels.map((l) => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
|
|
800
837
|
/** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
|
|
801
838
|
arrivals: () => ({ firstStateAt, firstSnapAt }),
|
|
802
839
|
/** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.18.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.18.1';
|