@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.
- package/README.md +28 -16
- package/bin/homie-studio.mjs +53 -9
- package/lib/brain-probe.mjs +154 -0
- package/lib/build.mjs +9 -5
- package/lib/cloudflare.mjs +135 -9
- package/lib/doctor.mjs +18 -2
- package/lib/media.mjs +185 -16
- package/lib/scaffold.mjs +15 -9
- package/lib/template-history.json +56 -0
- package/lib/upgrade.mjs +27 -0
- package/media/MEDIA.md +68 -17
- package/package.json +2 -2
- 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/index.mjs +80 -14
- package/worker/version.mjs +1 -1
|
@@ -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
|
|
79
|
-
file of a
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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>
|