@homie-rocks/studio 0.17.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.
@@ -518,6 +518,62 @@
518
518
  "## Beta": "9d02638207a26e73"
519
519
  }
520
520
  }
521
+ },
522
+ "0.18.0": {
523
+ "files": {
524
+ "CLAUDE.md": "ddf2b2a0376202f6",
525
+ "README.md": "684b3cca8001673c",
526
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
527
+ "games/README.md": "5ad32fe80c61f117",
528
+ "music/README.md": "a3dc042dadd39df8",
529
+ "videos/README.md": "16babc3437d5545b",
530
+ "posts/README.md": "37841aae476bd540",
531
+ "site/README.md": "ca131cd0472464fb",
532
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
533
+ },
534
+ "sections": {
535
+ "AGENTS.md": {
536
+ "(intro)": "214aa46eb3a895d5",
537
+ "## Layout": "eb1942414b8af7f8",
538
+ "## Commands (all through the pinned CLI in node_modules)": "9c51f189412e6a9f",
539
+ "## The site": "ab922bb6bde23293",
540
+ "## Making games": "a8a893eeada33bf5",
541
+ "## The Game Codex and progress": "0738ef719cef3a4c",
542
+ "## Running live games (the back office)": "f0566f07cff04113",
543
+ "## Servers and AI seats": "0a332b07e22d3fce",
544
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
545
+ "## Rules": "969318425c99f5a5",
546
+ "## Beta": "9d02638207a26e73"
547
+ }
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
+ }
521
577
  }
522
578
  }
523
579
  }
package/lib/upgrade.mjs CHANGED
@@ -21,11 +21,16 @@
21
21
  * Without --apply it changes nothing and says what --apply would do: the person agrees first. It never touches
22
22
  * studio.json beyond `homie.studio`, the studio's look (site/theme.json), its Worker config (deploy's), its games,
23
23
  * media or posts.
24
+ *
25
+ * Media (0.18.0): the plan also says which songs and videos are big media (over studio.json media.r2Over, 1 MiB
26
+ * unless set, or left out of git), what is in the studio's R2 already, and the step that moves the rest
27
+ * (`media move`, which every deploy of a studio with storage also runs). Upgrade itself never moves a file.
24
28
  */
25
29
  import { createHash } from 'node:crypto';
26
30
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
27
31
  import { spawnSync } from 'node:child_process';
28
32
  import { dirname, join } from 'node:path';
33
+ import { R2_COST, mediaPlan, r2OverOf, sizeOf } from './media.mjs';
29
34
  import { studioFiles } from './scaffold.mjs';
30
35
  import { readStudio } from './studio.mjs';
31
36
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
@@ -210,14 +215,36 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
210
215
 
211
216
  const git = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
212
217
  const dirty = git.status === 0 ? git.stdout.split('\n').filter(Boolean).length : null;
218
+ const media = mediaNote(root, studio);
213
219
  return {
214
220
  ok: true, command: 'upgrade', applied: false, root, studio: name, from, to: STUDIO_VERSION,
215
221
  changes, kept,
222
+ ...(media ? { media } : {}),
216
223
  ...(dirty ? { uncommitted: dirty } : {}),
217
224
  next: changes.length ? ['npx --no-install homie-studio upgrade --apply (after the person agrees)'] : [],
218
225
  };
219
226
  }
220
227
 
228
+ /** What the studio's big media does from this version on: what R2 holds, what would go there, and the step that does it. */
229
+ function mediaNote(root, studio) {
230
+ const cf = studio.cloudflare ?? {};
231
+ const storage = cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`) ? cf.r2 : null;
232
+ const over = r2OverOf(studio);
233
+ let rows = [];
234
+ try { rows = mediaPlan(root, { over }); } catch { return null; }
235
+ const big = rows.filter((r) => r.state === 'move' || r.state === 'too-big').map(({ abs, held, ...r }) => r);
236
+ const inR2 = rows.filter((r) => r.state === 'r2').map(({ abs, held, ...r }) => r);
237
+ if (!big.length && !inR2.length) return null;
238
+ const bytes = big.reduce((n, r) => n + (r.bytes ?? 0), 0);
239
+ return {
240
+ storage, over, big, inR2,
241
+ next: !big.length ? null : storage
242
+ ? `npx --no-install homie-studio media move --dry-run, then media move (the next npm run deploy also does it): ${big.length} file(s), ${sizeOf(bytes)}, go to R2 ${storage}, each read back and checked by SHA-256 before the site stops carrying it; the addresses stay the same and the files stay in this folder. Upgrade itself moves nothing.`
243
+ : `no storage: these stay on the site (up to 25 MiB a file), and a deploy from another computer or Workers Builds leaves out any that git does not keep. With the person's agreement: npx --no-install homie-studio storage add (Cloudflare asks for a payment method on the account first), then media move.`,
244
+ cost: R2_COST,
245
+ };
246
+ }
247
+
221
248
  function writeAtomic(path, text) {
222
249
  mkdirSync(dirname(path), { recursive: true });
223
250
  const tmp = `${path}.upgrade-${process.pid}`;
package/media/MEDIA.md CHANGED
@@ -75,20 +75,71 @@ homie.rocks shows a cover only from the studio's own site, as a `.jpg`, `.png`,
75
75
 
76
76
  ## Where the bytes come from
77
77
 
78
- A studio needs no storage for songs and videos: the site serves them itself. For every public
79
- file of a published entry, the build picks the first that exists:
80
-
81
- 1. `key`: the file is in the studio's storage, an R2 bucket that `homie-studio storage add`
82
- creates (Cloudflare asks for a payment method on the account before R2 works, so the person
83
- agrees first) and `homie-studio media put <file>` uploads to; the site serves it at
84
- `/media/<key>`, with byte ranges so phones can seek.
85
- 2. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when
86
- it is 25 MiB or smaller (Cloudflare's limit for one static file), and the Worker answers byte
87
- ranges for it too. This is the default, and it needs nothing but a deploy.
88
- 3. `url`: an absolute `https://` address somewhere else.
89
-
90
- A file with none of these is left out and `media list` says so. Media never goes into git: the
91
- studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`, `*.mov` and `*.webm` under
92
- `music/` and `videos/` out of the repository, so without storage the site is deployed from the
93
- computer that has the files. Keep deliveries under 25 MiB (the video skill's cuts do) or add
94
- storage for bigger ones.
78
+ A new studio needs no storage for songs and videos: the site serves each file itself, up to 25 MiB
79
+ a file (Cloudflare's limit for one static file of a Worker). Once the studio has storage, its big
80
+ media lives in R2 instead. For every public file of a published entry, the build picks the first
81
+ that applies:
82
+
83
+ 1. `r2`: the file is in the studio's own R2 bucket, put there by `homie-studio media move` (or a
84
+ deploy). The site serves it at **the same address it always had**, `/<path>` (for example
85
+ `/videos/trailer/trailer.mp4`), from R2, with byte ranges (206: phones seek with them, Safari needs
86
+ them to play a video at all), HEAD, an ETag and `If-None-Match` (304), and the same
87
+ `Cache-Control` as the site's own files (`public, max-age=0, must-revalidate`). A deploy's build
88
+ no longer carries the file in the site's static assets.
89
+ 2. `key`: a file `homie-studio media put` uploaded before 0.18.0 is served at `/media/<key>`, as it
90
+ always was (`media move` leaves it alone).
91
+ 3. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when it
92
+ is 25 MiB or smaller, and the Worker answers byte ranges for it too. This is the default, and it
93
+ needs nothing but a deploy.
94
+ 4. `url`: an absolute `https://` address somewhere else.
95
+
96
+ A file with none of these is left out and `media list` says so.
97
+
98
+ ### Big media goes to R2, by default, once the studio has storage
99
+
100
+ `homie-studio storage add` makes the studio's R2 bucket (one step the person agrees to: Cloudflare
101
+ asks for a payment method on the account before R2 works, even inside its free tier). From then on,
102
+ every `npm run deploy` first moves the studio's big media into it, and `homie-studio media move` does
103
+ the same on its own:
104
+
105
+ - **What moves:** every public file of a published song or video that is over 1 MiB, or that git
106
+ leaves out of the repository (`*.mp4`, `*.mp3`, `*.wav`… under `music/` and `videos/`, by the
107
+ studio's `.gitignore`). A deploy from another computer, or Cloudflare's Workers Builds, does not
108
+ have git-ignored files, so a file that is only on this computer would disappear from the site;
109
+ in R2 it stays. `studio.json` sets another size: `"media": { "r2Over": 5242880 }` (bytes), or
110
+ `"media": { "r2Over": false }` to move nothing on its own (`media move <file>` still moves the file
111
+ it names).
112
+ - **How:** each file is hashed (SHA-256), uploaded with `wrangler r2 object put` to the key that is
113
+ its path, read back with `wrangler r2 object get --pipe` and hashed again. Only when the size and
114
+ the hash match does `media move` record it on the manifest's file:
115
+
116
+ ```json
117
+ { "role": "video", "path": "videos/trailer/trailer.mp4", "type": "video/mp4", "bytes": 45431041,
118
+ "r2": { "key": "videos/trailer/trailer.mp4", "sha256": "8efe9b96…", "bytes": 45431041, "at": "2026-10-02T03:00:00Z" } }
119
+ ```
120
+
121
+ A copy that comes back different is refused, nothing is recorded, and the file stays on the site.
122
+ The file on this computer is never deleted, moved or changed.
123
+ - **After a re-cut** at the same path, the next `media move` or deploy sees a different hash and
124
+ uploads it again; until then a deploy's site carries the changed file itself when it fits.
125
+ - **`media move --dry-run`** says what would move and calls nothing; **`media move --verify`** reads
126
+ every file R2 already holds back and checks its hash again.
127
+ - **Local `dev`** still copies a moved file into the local site when it is 25 MiB or smaller,
128
+ because `wrangler dev`'s own R2 is empty; a bigger one does not play locally.
129
+
130
+ **What it costs (Cloudflare's R2 pricing):** serving costs nothing in bandwidth (R2 has no egress
131
+ fees). Storage is free up to 10 GB-month, then US$0.015 per GB-month; uploads (Class A operations)
132
+ are free up to 1 million a month, reads (Class B) up to 10 million a month. A video served from R2
133
+ costs one or two reads per byte range a player asks for. Beyond the free tier Cloudflare bills the
134
+ studio's own account; `media move` and `media list` say how much the studio's media adds up to, and
135
+ warn past 10 GB. Wrangler uploads at most 300 MiB in one go: a bigger file is refused with the
136
+ reason (the video skill's deliveries are far smaller).
137
+
138
+ Why not the site's own files for everything? A Worker's static files are at most 25 MiB each (and
139
+ 20,000 per version on the free plan); every deploy of a site carrying them uploads whichever
140
+ changed; a byte range of a static file is cut out by the Worker after reading the whole file; and
141
+ files git does not keep exist only on the computer that made them.
142
+
143
+ Media never goes into git: the studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`,
144
+ `*.mov` and `*.webm` under `music/` and `videos/` out of the repository. Commit the manifests: they
145
+ name each file's R2 copy, which is all a deploy from anywhere else needs.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.17.0",
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, optional R2 storage for large media, deploy, and the homie.rocks directory.",
3
+ "version": "0.18.1",
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",
7
7
  "homepage": "https://homie.rocks/studio/",
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>