@homie-rocks/studio 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/README.md +9 -5
- package/lib/perf.mjs +15 -7
- package/lib/site.mjs +14 -3
- package/lib/template-history.json +39 -0
- package/netplay/NETPLAY.md +38 -2
- package/netplay/netplay.ts +58 -3
- package/package.json +1 -1
- package/site/SITE.md +19 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/src/main.ts +336 -109
- package/worker/arrival.mjs +207 -0
- package/worker/pages.mjs +23 -5
- package/worker/site.mjs +26 -4
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,31 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.26.0] - 2026-10-03
|
|
21
|
+
|
|
22
|
+
**Plugin 0.27.0** · [#40](https://github.com/homie-rocks/homie/pull/40)
|
|
23
|
+
|
|
24
|
+
A game's first minute: Play never opens on a blank screen, a bright game gets a bright landing in its own palette, and in Hero Rush 3D a new player's first round is a race they are in.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- The arrival card. From the play page's first paint (and the big screen's), the game's own look covers its frame while the room connects and the game loads: its title and pitch, its key art (the landing's hero still, a trailer's poster or its cover, drifting slowly), a progress line that says what is happening ("Finding a room…", "Room 4 · 3 playing · 2 AI", "Loading the game…", "Joining Room 4…", the game's own "Loading the heroes… 60%"), and the controls for this device from `landing.controls`. It is drawn in the game's palette (style.json), else its landing's colours, else the studio's, and it fades the moment the game is playable. `?arrive=0` leaves it out.
|
|
29
|
+
- The netplay helper tells the play page when the game is playable (NETPLAY.md section 21): by itself once the browser is seated and has the room's state, or, for a game that keeps loading after that (models, textures, a baked world), at the game's own `net.playable()` (`createNetplay({ arrival: 'game' })`), with `net.loading(fraction, 'the heroes')` for the progress line. Nothing crosses the relay; the wire stays revision 8.
|
|
30
|
+
- `homie-studio perf` measures the time to the first meaningful frame (`load.look`: the arrival card on the screen), and its time to playable waits for the card to lift. The perf skill's goals include it ("it opens on a blank screen").
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- A game with its own palette (style.json) and no `landing.scheme` or `landing.theme` of its own gets its landing in that palette: its paper as the page, its ink as the words, its accents, light or dark as its paper is, when the ink reads on the paper (4.5:1). Its footage keeps its colour, and the words sit on a soft panel of its paper. A bright game is no longer shown on the studio's dark page. A scheme or colours the owner set, a page of the studio's own and `site/theme.css` still win; a game without a style.json keeps the studio's look.
|
|
35
|
+
- Hero Rush 3D: the bots never swamp a new player. In somebody's first round in the room, when nobody set the AI dial, the bots play a level easier. A bot far ahead of the best person in the room eases off (it walks, reacts later, swings less, then races for the coins near that person, or keeps near them, up their screen, without taking coins) and plays at its dial again once it is within its lead: Rookie −1, Steady 1, Fair 2, Strong 5, Maxed none. Scores stay honest (no score is ever changed, no coin given, taken or faked), and every bot is labelled, the phone's compact board too ("bot" or "AI" after the name). A casual player's first round at the default dial ended 61 coins behind the best bot before, and about 4 behind now; a player who chases the coins still wins clearly, and Maxed bots still beat a casual player.
|
|
36
|
+
- Hero Rush 3D on a phone held upright: the camera follows lower and closer (30 degrees, about 5.5 m across), so every hero reads as a character (a mage was the top of its hat), and your name chip says what you are ("You · Mage"). The game passes `arrival: 'game'`: the card lifts on your own hero in its real model, never a stand-in. Your own hero is asked for first, the moment your seat is known, then the other heroes, then the skeletons (on an emulated slow phone your real hero is drawn at about 1.4 s instead of 2.1 s, the same bytes), the in-game title card is gone (the arrival card has the title), the "×2" mark moves off your hero and the controls hint sits above the touch buttons.
|
|
37
|
+
|
|
38
|
+
### Upgrade notes
|
|
39
|
+
|
|
40
|
+
- Nothing to do: `homie-studio upgrade` brings the arrival card to every game, and a game built before 0.26.0 lifts it once it has a seat. A game whose world keeps loading after its first state can say when it is ready: `createNetplay({ …, arrival: 'game' })`, `net.loading(p, 'what')` while it loads, and `net.playable()` once its world and the player's own body are drawn. A game made from Hero Rush 3D before 0.26.0 keeps its own code: tell Claude "bring the starter's first-round bots and phone camera into my game" if you want them.
|
|
41
|
+
|
|
20
42
|
## [0.25.0] - 2026-10-03
|
|
21
43
|
|
|
22
|
-
**Plugin 0.26.0** · [#38](https://github.com/homie-rocks/homie/pull/38)
|
|
44
|
+
**Plugin 0.26.0** · [#38](https://github.com/homie-rocks/homie/pull/38) · [release-2026-10-03-studio-0.25.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.25.0)
|
|
23
45
|
|
|
24
46
|
Animated characters for 3D games: free CC0 heroes or one generated and auto-rigged on your fal account, on one skeleton standard with the library's clips retargeted onto it, and a shared player that runs, jumps and swings with IK feet and springs.
|
|
25
47
|
|
package/README.md
CHANGED
|
@@ -96,11 +96,14 @@ licence its owner picked in game.json `"license"`: `"remix-with-credit"` (the de
|
|
|
96
96
|
`"no-remix"`, which `game remix` refuses. Every page ends with "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
|
|
97
97
|
files. `site/SITE.md` says all of it.
|
|
98
98
|
|
|
99
|
-
The play page
|
|
100
|
-
|
|
99
|
+
The play page never opens blank (0.26.0): from its first paint an arrival card shows the game's own look (its title,
|
|
100
|
+
pitch and key art in its palette, a progress line that says what is happening, and the controls for the device) until
|
|
101
|
+
the game says it is playable (`NETPLAY.md` section 21; `worker/arrival.mjs`). It writes its room into the address, and
|
|
102
|
+
a small room button at the edge shares it: Invite, Big screen and the room code. game.json's `screen.share` puts it where each game has room, per device (a corner or
|
|
101
103
|
the top's middle, moved in by `x` / `y`, or kept a small icon), so it never covers a scoreboard. A game whose
|
|
102
104
|
picture is white or cream gets a light landing (`landing.scheme: "light"`). Every card and the directory show a
|
|
103
|
-
game's landing still.
|
|
105
|
+
game's landing still. A game with its own palette (style.json) and no landing colours of its own gets its landing
|
|
106
|
+
in that palette, light or dark as its paper is (0.26.0). Every HTML answer is `no-transform`, and the site's pages are never framed by another
|
|
104
107
|
site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
|
|
105
108
|
`not-a-homie`, at the site's root as under the game.
|
|
106
109
|
|
|
@@ -519,7 +522,8 @@ rules, the bots, the snapshots) and a replica, both a computer (1280x800 at 2x)
|
|
|
519
522
|
on a fast computer is less than its rate). They play the same seeded presses through a warm-up and a measured
|
|
520
523
|
window, and each run's JSON says, per browser: the time between animation frames (median, p95, p99, the share over 33
|
|
521
524
|
and 50 ms), the game's JavaScript per frame (every requestAnimationFrame callback timed), the main thread per frame
|
|
522
|
-
(Chrome's TaskDuration), time to the first frame
|
|
525
|
+
(Chrome's TaskDuration), time to the first meaningful frame (`load.look`: the play page's arrival card), to the
|
|
526
|
+
game's first frame, to a seat and to playable, the game's files on the wire, the heap
|
|
523
527
|
after a garbage collection, and netplay messages and kilobytes a second each way. `--profile` adds a CPU profile per
|
|
524
528
|
browser in a window of its own (a `.cpuprofile` for Chrome DevTools) and its hottest functions, read through the
|
|
525
529
|
game's source map when the build kept one (`homie-studio build --maps` keeps it in `.studio/maps/<id>/`, never in
|
|
@@ -758,7 +762,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
758
762
|
| `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. |
|
|
759
763
|
| `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). |
|
|
760
764
|
| `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). |
|
|
761
|
-
| `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. |
|
|
765
|
+
| `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; `worker/arrival.mjs`: its arrival card. |
|
|
762
766
|
| `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`. |
|
|
763
767
|
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's R2, or a link). `media list` shows it; `media move` puts the big ones in R2 and records each one's SHA-256; `media put` uploads a loose file to `/media/<key>`. |
|
|
764
768
|
| `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
|
package/lib/perf.mjs
CHANGED
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
* Each browser plays (the same seeded presses every run) through a warm-up and then a measured window, and the run
|
|
16
16
|
* writes, per browser (host and replica):
|
|
17
17
|
*
|
|
18
|
-
* load ms from opening the page to the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* load ms from opening the page to the first meaningful frame (`look`: the play page's arrival card on the
|
|
19
|
+
* screen, the game's title and art; a page without one, the game's first animation frame), to the game's
|
|
20
|
+
* first animation frame, to a seat, and to playable (seated with a body that the game's port probe reports,
|
|
21
|
+
* or seated and drawing when it has none, and the arrival card lifted); what the game's own files weighed on
|
|
22
|
+
* the wire, the biggest first
|
|
21
23
|
* frames the time between animation frames: median, 95th and 99th percentile, the worst, and the share of frames
|
|
22
24
|
* over 33 ms (a dropped frame at 60 Hz, twice) and over 50 ms (a hitch anyone sees)
|
|
23
25
|
* work the game's JavaScript inside each animation frame (every requestAnimationFrame callback, timed)
|
|
@@ -197,7 +199,7 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
|
|
|
197
199
|
let seatedAt = null; let playableAt = null; let shell = null; let body = null;
|
|
198
200
|
const deadline = Date.now() + 90_000;
|
|
199
201
|
while (Date.now() < deadline && playableAt === null) {
|
|
200
|
-
shell = await T(page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null } : null; }), 5000);
|
|
202
|
+
shell = await T(page.evaluate(() => { const s = window.__shell; const a = s?.arrival; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, arrival: a ? { phase: a.phase, lookMs: a.lookMs, liftedMs: a.liftedMs, by: a.by } : null } : null; }), 5000);
|
|
201
203
|
const now = Date.now();
|
|
202
204
|
if (seatedAt === null && shell?.room && Number.isInteger(shell.seat) && shell.role) seatedAt = now;
|
|
203
205
|
const g = await inGame(h, () => {
|
|
@@ -206,7 +208,9 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
|
|
|
206
208
|
if (p && p.view !== 'board') { const r = p.rows(p.now() - 400); const last = r[r.length - 1]; self = last ? Number.isFinite(last[1]) : false; }
|
|
207
209
|
return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
|
|
208
210
|
});
|
|
209
|
-
|
|
211
|
+
// The play page's arrival card (0.26.0) covers the game until the game says it is playable: not playable before.
|
|
212
|
+
const lifted = !shell?.arrival || shell.arrival.phase === 'done';
|
|
213
|
+
if (seatedAt !== null && g?.first !== null && g?.first !== undefined && lifted) {
|
|
210
214
|
// A body the game reports (its port probe), or no body to wait for (a board, or a game with no probe).
|
|
211
215
|
if (!g.port || g.view === 'board' || g.self) { playableAt = now; body = g.port && g.view !== 'board' ? 'reported' : 'none to report'; }
|
|
212
216
|
else if (now - seatedAt > 10_000) { playableAt = null; body = 'never reported by the port probe in 10 s'; break; }
|
|
@@ -223,7 +227,11 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
|
|
|
223
227
|
const at = (epoch) => (epoch && origin ? Math.round(epoch - origin) : null);
|
|
224
228
|
const files = [...h.requests.values()].filter((r) => r.done);
|
|
225
229
|
const gameFiles = files.filter((r) => /\/__game\//.test(r.url));
|
|
230
|
+
// The first meaningful frame: the arrival card's first frame (the page's own clock), else the game's first frame.
|
|
231
|
+
const look = Number.isFinite(shell?.arrival?.lookMs) ? shell.arrival.lookMs : at(g?.first);
|
|
226
232
|
h.loadInfo = {
|
|
233
|
+
lookMs: look,
|
|
234
|
+
arrival: shell?.arrival ? { by: shell.arrival.by ?? null, liftedMs: shell.arrival.liftedMs ?? null } : null,
|
|
227
235
|
firstFrameMs: at(g?.first),
|
|
228
236
|
seatedMs: at(seatedAt),
|
|
229
237
|
playableMs: at(playableAt),
|
|
@@ -518,7 +526,7 @@ export function metricsOfRun(run) {
|
|
|
518
526
|
put('work.p50', b.work?.p50); put('work.p95', b.work?.p95); put('work.mean', b.work?.mean);
|
|
519
527
|
put('busy', b.main?.busyPerFrame); put('script', b.main?.scriptMsPerS);
|
|
520
528
|
put('heap', b.heap?.afterGcMb); put('heap.growth', b.heap?.gcGrowthMbPerMin);
|
|
521
|
-
put('load.firstFrame', b.load?.firstFrameMs); put('load.seated', b.load?.seatedMs); put('load.playable', b.load?.playableMs); put('load.gameKb', b.load?.gameKb);
|
|
529
|
+
put('load.look', b.load?.lookMs); put('load.firstFrame', b.load?.firstFrameMs); put('load.seated', b.load?.seatedMs); put('load.playable', b.load?.playableMs); put('load.gameKb', b.load?.gameKb);
|
|
522
530
|
put('net.msgsOut', b.net?.msgsOut); put('net.msgsIn', b.net?.msgsIn); put('net.kbOut', b.net?.kbOut); put('net.kbIn', b.net?.kbIn);
|
|
523
531
|
}
|
|
524
532
|
return out;
|
|
@@ -541,7 +549,7 @@ function headlineOf(summary) {
|
|
|
541
549
|
for (const role of ['host', 'replica']) {
|
|
542
550
|
const m = (k) => summary.metrics[`${device}.${role}.${k}`]?.median;
|
|
543
551
|
if (m('frame.p50') === undefined) continue;
|
|
544
|
-
out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; playable at ${m('load.playable') ?? '?'} ms; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB`);
|
|
552
|
+
out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; first look at ${m('load.look') ?? '?'} ms, playable at ${m('load.playable') ?? '?'} ms; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB`);
|
|
545
553
|
}
|
|
546
554
|
}
|
|
547
555
|
return out;
|
package/lib/site.mjs
CHANGED
|
@@ -419,8 +419,19 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
419
419
|
// The landing's own colours (landing.theme): only colours, and the ink on its own accent worked out like the studio's.
|
|
420
420
|
// Else the game's own palette (games/<id>/style.json, its art-direction decisions): one look from the game to its page.
|
|
421
421
|
const style = !L.theme ? readJson(join(g.dir, 'style.json'))?.palette : null;
|
|
422
|
-
//
|
|
423
|
-
|
|
422
|
+
// 0.26.0: the whole landing in that palette (its paper as the page, its ink as the words, its accents) when the game
|
|
423
|
+
// sets no landing colours or scheme of its own and its ink reads on its paper (4.5:1): a bright game is no longer
|
|
424
|
+
// shown on the studio's dark page. The scheme follows the paper. Anything less readable: its accents only, on the
|
|
425
|
+
// studio's scheme, as before.
|
|
426
|
+
let fromStyle = style && typeof style === 'object' ? { accent: style.accent, glow: style.accent2 } : null;
|
|
427
|
+
let ownScheme = null;
|
|
428
|
+
if (fromStyle && L.scheme === undefined) {
|
|
429
|
+
const paper = luminance(style.bg); const ink = luminance(style.ink);
|
|
430
|
+
if (paper !== null && ink !== null && (Math.max(paper, ink) + 0.05) / (Math.min(paper, ink) + 0.05) >= 4.5) {
|
|
431
|
+
fromStyle = { ...fromStyle, bg: style.bg, fg: style.ink };
|
|
432
|
+
ownScheme = paper > ink ? 'light' : 'dark';
|
|
433
|
+
}
|
|
434
|
+
}
|
|
424
435
|
const raw = L.theme && typeof L.theme === 'object' ? L.theme : fromStyle;
|
|
425
436
|
const theme = raw ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(raw[k] ?? ''))).map((k) => [k, raw[k]])) : null;
|
|
426
437
|
if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
|
|
@@ -443,7 +454,7 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
443
454
|
source: g.share?.source !== false,
|
|
444
455
|
tv: g.screen?.tv !== false && L.tv !== false,
|
|
445
456
|
theme: theme && Object.keys(theme).length ? theme : null,
|
|
446
|
-
...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : {}),
|
|
457
|
+
...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : ownScheme ? { scheme: ownScheme, schemeFrom: 'style' } : {}),
|
|
447
458
|
};
|
|
448
459
|
}
|
|
449
460
|
|
|
@@ -1234,6 +1234,45 @@
|
|
|
1234
1234
|
}
|
|
1235
1235
|
}
|
|
1236
1236
|
}
|
|
1237
|
+
},
|
|
1238
|
+
"0.26.0": {
|
|
1239
|
+
"files": {
|
|
1240
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1241
|
+
"README.md": "684b3cca8001673c",
|
|
1242
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1243
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1244
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1245
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1246
|
+
"posts/README.md": "37841aae476bd540",
|
|
1247
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1248
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1249
|
+
},
|
|
1250
|
+
"sections": {
|
|
1251
|
+
"AGENTS.md": {
|
|
1252
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1253
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1254
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1255
|
+
"## The site": "ab922bb6bde23293",
|
|
1256
|
+
"## Making games": "abf2f0d59341512d",
|
|
1257
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1258
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1259
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1260
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1261
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1262
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1263
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1264
|
+
"## Beta": "9d02638207a26e73"
|
|
1265
|
+
}
|
|
1266
|
+
},
|
|
1267
|
+
"named": {
|
|
1268
|
+
"Homie Arcade": {
|
|
1269
|
+
"sections": {
|
|
1270
|
+
"AGENTS.md": {
|
|
1271
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1237
1276
|
}
|
|
1238
1277
|
}
|
|
1239
1278
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -583,13 +583,16 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
|
|
|
583
583
|
overrides). `?screen=1` makes a spectator that shows a QR code to the game URL.
|
|
584
584
|
`/<game>/watch?room=` makes a watcher, with a strip of the players (section 16).
|
|
585
585
|
8. **Show room facts** from the watch feed or the frame's messages: the lobby line,
|
|
586
|
-
results, and with `?debug=1` the debug strip.
|
|
586
|
+
results, and with `?debug=1` the debug strip. Until the game is playable, show them on
|
|
587
|
+
the arrival card, in the game's own look (section 21), never a blank screen.
|
|
587
588
|
9. **One matcher:** every Play door for a game lands in the same rooms (the `Lobby`).
|
|
588
589
|
|
|
589
590
|
### The game (a vendored `netplay.ts`)
|
|
590
591
|
|
|
591
592
|
1. **Call `createNetplay({ game, maxPlayers, movement, checkpoint })` once** and render
|
|
592
|
-
from the first frame. Never wait for `ready`.
|
|
593
|
+
from the first frame. Never wait for `ready`. A game that loads for seconds after its
|
|
594
|
+
first state passes `arrival: 'game'` and calls `net.playable()` once its world and the
|
|
595
|
+
player's own body are drawn (section 21): the play page's arrival card covers the wait.
|
|
593
596
|
2. **Handle `role` at any time.**
|
|
594
597
|
- Promoted: restore (section 9).
|
|
595
598
|
- Host but not promoted: start a fresh round.
|
|
@@ -1422,3 +1425,36 @@ account or typed text.
|
|
|
1422
1425
|
the host asks how the slimes hunt (chase, surround, gang up on the most hurt, regroup at the King),
|
|
1423
1426
|
whether a wave comes, and how hard to push; every screen shows what the slimes are up to when a model
|
|
1424
1427
|
chose it. The floor is the vale as it always played.
|
|
1428
|
+
|
|
1429
|
+
## 21. The arrival (0.26.0)
|
|
1430
|
+
|
|
1431
|
+
The play page and the big screen never open on a blank screen. From the page's first paint until
|
|
1432
|
+
the game says it is playable, an **arrival card** covers the game's frame in the game's own look:
|
|
1433
|
+
its title and pitch, its key art (the landing's hero still, a trailer's poster or its cover,
|
|
1434
|
+
drifting slowly), a progress line that says what is happening ("Finding a room…", "Room 4 · 3
|
|
1435
|
+
playing · 2 AI", "Loading the game…", "Joining Room 4…", the game's own "Loading the heroes…
|
|
1436
|
+
60%") and the controls for this device (game.json `landing.controls`). Then it fades and the
|
|
1437
|
+
game has the screen. Its colours are the game's palette (style.json), else its landing's, else
|
|
1438
|
+
the studio's (`worker/arrival.mjs`). Nothing here crosses the relay: it is between the game's frame
|
|
1439
|
+
and its page, so the wire stays revision 8.
|
|
1440
|
+
|
|
1441
|
+
| From the helper to its page | When |
|
|
1442
|
+
|---|---|
|
|
1443
|
+
| `{ what: 'attached', …, arrival: 'auto' \| 'game' }` | the helper starts (a helper older than 0.26.0 sends no `arrival`) |
|
|
1444
|
+
| `{ what: 'loading', p, label? }` | the game's `net.loading(p, label)`: 0 to 1, at most ten a second, never after `playable` |
|
|
1445
|
+
| `{ what: 'playable', by: 'game' \| 'auto' }` | once: the game's `net.playable()`, or (`arrival: 'auto'`) the helper's own |
|
|
1446
|
+
|
|
1447
|
+
- **`arrival: 'auto'`** (the default, so every game gets the card with no code): the helper says
|
|
1448
|
+
`playable` once this browser has its role and the room's state (a host at once, anyone else at
|
|
1449
|
+
its first snapshot), two animation frames later, so the game has drawn with it.
|
|
1450
|
+
- **`arrival: 'game'`**: the game says when, with `net.playable()` once its world and the player's
|
|
1451
|
+
own body are drawn (Hero Rush 3D: its own hero with its real model, not the stand-in), and
|
|
1452
|
+
`net.loading(p, 'the heroes')` while it loads. A game whose own loading takes seconds after the
|
|
1453
|
+
first state (models, textures, a baked terrain) uses this, so the player never sees stand-ins.
|
|
1454
|
+
- **The page's fallbacks**, so a game is never hidden behind the card: a helper older than 0.26.0
|
|
1455
|
+
lifts it 0.6 s after the browser has a seat; a frame with no helper, 8 s after it loaded; any game,
|
|
1456
|
+
15 s after its helper attached; and 30 s after the page opened, whatever happens. A kick or a
|
|
1457
|
+
closed room lifts it for its notice. `?arrive=0` leaves it out.
|
|
1458
|
+
- **Measured** by `homie-studio perf` (`load.look`: the card's first frame, the first meaningful
|
|
1459
|
+
frame; `load.playable`: seated, the body drawn and the card lifted), from the page's own marks
|
|
1460
|
+
`homie:look` and `homie:playable` and `window.__shell.arrival`.
|
package/netplay/netplay.ts
CHANGED
|
@@ -50,6 +50,11 @@
|
|
|
50
50
|
* them), messages taken down (`on('unchat')`), and the game's own chat UI
|
|
51
51
|
* (`net.say(text)`, `net.sayLine(id)`, `net.react(kind)`); the room's rules
|
|
52
52
|
* are `net.chatRules`. The play page's chat panel needs none of it.
|
|
53
|
+
* - The arrival (0.26.0, NETPLAY.md section 21): tells its page when the game
|
|
54
|
+
* is playable, so the play page's arrival card (the game's title, art and a
|
|
55
|
+
* progress line) gives it the screen: by itself once seated with the room's
|
|
56
|
+
* state, or the game's own `net.playable()` (`arrival: 'game'`), with
|
|
57
|
+
* `net.loading(p, what)` while it loads. Nothing crosses the relay.
|
|
53
58
|
*
|
|
54
59
|
* WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
|
|
55
60
|
* is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
|
|
@@ -451,6 +456,14 @@ export interface NetplayOptions<C = unknown> {
|
|
|
451
456
|
* moves an AI's body with its own bot code (a Roster that passes `p.agent`). Reading the dial declares 'skill' too.
|
|
452
457
|
*/
|
|
453
458
|
caps?: ('skill' | 'agents')[];
|
|
459
|
+
/**
|
|
460
|
+
* THE ARRIVAL (NETPLAY.md section 21): who says when the play page's arrival card (the game's title, art and a
|
|
461
|
+
* progress line while the room connects and the game loads) gives the screen to the game. 'auto' (the default): this
|
|
462
|
+
* helper, once this browser has its role and the room's state (a host at once, anyone else at its first snapshot),
|
|
463
|
+
* two animation frames later. 'game': the game itself, with `net.playable()` once its world and the player's own body
|
|
464
|
+
* are drawn (the page lifts the card anyway 12 s after the helper attached, so a game that never says is never hidden).
|
|
465
|
+
*/
|
|
466
|
+
arrival?: 'auto' | 'game';
|
|
454
467
|
}
|
|
455
468
|
|
|
456
469
|
type WebSocketCtor = new (url: string) => WebSocketLike;
|
|
@@ -609,6 +622,17 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
609
622
|
hushed: boolean;
|
|
610
623
|
/** This browser plays as an AI (the frame of an agent pass). */
|
|
611
624
|
readonly asAgent: boolean;
|
|
625
|
+
/**
|
|
626
|
+
* THE ARRIVAL (section 21): the world and the player's own body are drawn, so the play page's arrival card gives the
|
|
627
|
+
* screen to the game. Once; later calls do nothing. Needed only with `arrival: 'game'` (an 'auto' game may call it
|
|
628
|
+
* earlier than the helper would).
|
|
629
|
+
*/
|
|
630
|
+
playable(): void;
|
|
631
|
+
/**
|
|
632
|
+
* While it loads: how far along the game is (0 to 1) and what it is loading ("the heroes"), for the arrival card's
|
|
633
|
+
* progress line. At most ten a second reach the page; nothing after `playable()`.
|
|
634
|
+
*/
|
|
635
|
+
loading(fraction: number, what?: string): void;
|
|
612
636
|
/** Resolves with the first role (welcome, or offline fallback). */
|
|
613
637
|
readonly ready: Promise<RoleChange<S, C>>;
|
|
614
638
|
on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
|
|
@@ -1110,6 +1134,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1110
1134
|
let idleSkipped = 0;
|
|
1111
1135
|
/** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
|
|
1112
1136
|
let closedWhy: string | null = null;
|
|
1137
|
+
// The arrival (section 21): the play page's card lifts at `playable` (the game's word, or this helper's).
|
|
1138
|
+
const arrival: 'auto' | 'game' = opts.arrival === 'game' ? 'game' : 'auto';
|
|
1139
|
+
let playableSent = false;
|
|
1140
|
+
let autoArmed = false;
|
|
1141
|
+
let snapSeen = false;
|
|
1142
|
+
let loadingAt = 0;
|
|
1143
|
+
function sayPlayable(by: 'game' | 'auto'): void {
|
|
1144
|
+
if (playableSent) return;
|
|
1145
|
+
playableSent = true;
|
|
1146
|
+
post?.({ what: 'playable', by });
|
|
1147
|
+
}
|
|
1148
|
+
/** 'auto': a host at once, anyone else at its first snapshot, then two animation frames (the game drew with it). */
|
|
1149
|
+
function autoPlayable(): void {
|
|
1150
|
+
if (arrival !== 'auto' || playableSent || autoArmed || !roleKnown) return;
|
|
1151
|
+
if (role !== 'host' && !snapSeen) return;
|
|
1152
|
+
autoArmed = true;
|
|
1153
|
+
const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => number }).requestAnimationFrame;
|
|
1154
|
+
const later = (fn: () => void): void => { if (typeof raf === 'function') raf(fn); else setTimeout(fn, 16); };
|
|
1155
|
+
later(() => later(() => sayPlayable('auto')));
|
|
1156
|
+
}
|
|
1113
1157
|
/** The studio's announcement now showing, and the seats the studio muted (until when, server ms). */
|
|
1114
1158
|
let announcement: Announcement | null = null;
|
|
1115
1159
|
const muted = new Map<number, number>();
|
|
@@ -1354,6 +1398,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1354
1398
|
emit('role', e);
|
|
1355
1399
|
resolveView('seat');
|
|
1356
1400
|
notifyStats(true);
|
|
1401
|
+
autoPlayable();
|
|
1357
1402
|
}
|
|
1358
1403
|
|
|
1359
1404
|
/** The newest snapshot this browser holds, if recent: a promoted host's restore when the relay has none. */
|
|
@@ -1584,7 +1629,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1584
1629
|
replaceState(m['state']);
|
|
1585
1630
|
}
|
|
1586
1631
|
rebase = true;
|
|
1587
|
-
|
|
1632
|
+
// The relay's last snapshot comes with the welcome: the room's state is in (the arrival, section 21).
|
|
1633
|
+
if (snap && next !== 'host' && acceptSnap(snap, JSON.stringify(snap).length, false)) snapSeen = true;
|
|
1588
1634
|
if (next === 'host' && !snap && !continuing) snap = localSnap();
|
|
1589
1635
|
offline = false;
|
|
1590
1636
|
connected = true;
|
|
@@ -1685,7 +1731,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1685
1731
|
if (role === 'host') return; // a stale frame from a previous host
|
|
1686
1732
|
const s: Snapshot<S> = { k: Number(m['k']) || 0, st: Number(m['st']), d: m['d'] as S, from: typeof m['from'] === 'number' ? m['from'] : null };
|
|
1687
1733
|
if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
|
|
1688
|
-
if (acceptSnap(s, bytes, true)) emit('snapshot', s);
|
|
1734
|
+
if (acceptSnap(s, bytes, true)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
|
|
1689
1735
|
}
|
|
1690
1736
|
|
|
1691
1737
|
function onInput(m: Record<string, unknown>): void {
|
|
@@ -1877,7 +1923,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1877
1923
|
// No shell (a plain file, a dev server): the game is its own host, offline.
|
|
1878
1924
|
queueMicrotask(() => goOfflineHost('offline'));
|
|
1879
1925
|
} else {
|
|
1880
|
-
post?.({ what: 'attached', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, mark: NETPLAY_MARK });
|
|
1926
|
+
post?.({ what: 'attached', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, mark: NETPLAY_MARK, arrival });
|
|
1881
1927
|
open();
|
|
1882
1928
|
// A relay that never answers must not leave a game on a black screen. But the wait is counted only while
|
|
1883
1929
|
// this page is able to listen: a phone compiling shaders under load is blocked for seconds at a time, and
|
|
@@ -2050,6 +2096,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2050
2096
|
get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
|
|
2051
2097
|
isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
|
|
2052
2098
|
pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
|
|
2099
|
+
playable(): void { sayPlayable('game'); },
|
|
2100
|
+
loading(fraction: number, what?: string): void {
|
|
2101
|
+
if (playableSent) return;
|
|
2102
|
+
const t = wall();
|
|
2103
|
+
const p = Math.max(0, Math.min(1, Number(fraction) || 0));
|
|
2104
|
+
if (t - loadingAt < 100 && p < 1) return;
|
|
2105
|
+
loadingAt = t;
|
|
2106
|
+
post?.({ what: 'loading', p: Math.round(p * 100) / 100, ...(typeof what === 'string' && what ? { label: what.slice(0, 40) } : {}) });
|
|
2107
|
+
},
|
|
2053
2108
|
get watching() { return watching; },
|
|
2054
2109
|
get viewSeat() { markFollows(); return watching ? viewNow : (offline ? null : seat); },
|
|
2055
2110
|
get following() { return watching ? following : seat; },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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/site/SITE.md
CHANGED
|
@@ -47,7 +47,13 @@ site's pages only); the owner's tools are `/_studio/api/chat`, `/_studio/api/cha
|
|
|
47
47
|
Made from the game's own files, nothing invented:
|
|
48
48
|
|
|
49
49
|
- **The hero**, full-bleed: the game's footage when it has some, else its cover (or key art) moving slowly, tinted
|
|
50
|
-
with the studio's colour so the words read.
|
|
50
|
+
with the studio's colour so the words read. **In the game's own palette (0.26.0):** a game with a style.json (its
|
|
51
|
+
art direction) and no `landing.scheme` or `landing.theme` of its own gets its whole landing in that palette, its
|
|
52
|
+
paper as the page, its ink as the words, its accents, light or dark as its paper is, when the ink reads on the paper
|
|
53
|
+
(4.5:1); its footage then keeps its colour (a third of the tint) and the words sit on a soft panel of its paper. A
|
|
54
|
+
bright game is no longer shown on the studio's dark page. `landing.scheme`, `landing.theme`, a `site/pages/<id>/`
|
|
55
|
+
page and `site/theme.css` are the owner's and always win; a palette whose ink does not read keeps its accents only,
|
|
56
|
+
on the studio's scheme, as before. A game whose picture is white or cream (a light arena) takes
|
|
51
57
|
`"scheme": "light"` in its `landing` block: its landing is drawn light, the hero tinted and shaded with a light
|
|
52
58
|
background and the words dark, where a dark studio's tint would turn it grey (`"dark"` is the other way round,
|
|
53
59
|
for a dark game in a light studio). `landing.theme` colours still win, and the rest of the site keeps its look. Footage is looked for in this order: `landing.hero` in game.json;
|
|
@@ -143,6 +149,18 @@ A studio with its own landing in `site/` keeps it: the template never replaces w
|
|
|
143
149
|
|
|
144
150
|
## The play page
|
|
145
151
|
|
|
152
|
+
**The arrival (0.26.0).** The play page and the big screen never open blank. From the first paint, an arrival
|
|
153
|
+
card covers the game's frame in the game's own look (worker/arrival.mjs): its title, its pitch (`landing.pitch`, else
|
|
154
|
+
the blurb's first sentence), its key art (the landing's hero still, a trailer's poster or its cover, drifting slowly;
|
|
155
|
+
`landing.hero.tallImage` on an upright phone when there is one), a progress line that says what is happening
|
|
156
|
+
("Finding a room…", "Room 4 · 3 playing · 2 AI", "Loading the game…", "Joining Room 4…", and the game's own
|
|
157
|
+
"Loading the heroes… 60%"), and the controls for this device (`landing.controls.phone`, `.computer`, `.tv`). Its
|
|
158
|
+
colours are the game's palette (style.json), else the landing's (`landing.theme`, `landing.scheme`), else the
|
|
159
|
+
studio's. It lifts when the game says it is playable (NETPLAY.md section 21): by itself once seated with the room's
|
|
160
|
+
state, or at the game's own `net.playable()`; a game built before 0.26.0 lifts it once it has a seat, and nothing
|
|
161
|
+
keeps a game behind it longer than 15 s after the game started (30 s after the page opened). `?arrive=0` leaves it
|
|
162
|
+
out. `homie-studio perf` reads its first frame as the time to the first meaningful frame (`load.look`).
|
|
163
|
+
|
|
146
164
|
`/<id>/play` puts the visitor in a public room at once and writes the room into the address, so a reload comes
|
|
147
165
|
back to it and a copied address brings a friend into it. A small room button at the edge (top right by default)
|
|
148
166
|
opens: **Invite** (the phone's share sheet, or the link copied), **Big screen** (`/<id>/tv` of this room, on
|
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
"players": { "min": 1, "max": 8 },
|
|
6
6
|
"roundSeconds": 60,
|
|
7
7
|
"entry": "src/main.ts",
|
|
8
|
-
"netplay": { "v": 1, "public": true, "movement": "owner" }
|
|
8
|
+
"netplay": { "v": 1, "public": true, "movement": "owner" },
|
|
9
|
+
"landing": { "pitch": "Grab the gems. Bump your rivals away.", "controls": { "phone": "Drag to move; a second finger bumps.", "computer": "WASD or arrows to move, Space to bump." } }
|
|
9
10
|
}
|
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
"entry": "src/main.ts",
|
|
8
8
|
"netplay": { "v": 1, "public": true, "movement": "owner" },
|
|
9
9
|
"needs": { "three": "0.185.1" },
|
|
10
|
-
"assets": "library"
|
|
10
|
+
"assets": "library",
|
|
11
|
+
"landing": { "pitch": "Grab the gems. Bump your rivals away.", "controls": { "phone": "Drag to move; a second finger bumps.", "computer": "WASD or arrows to move, Space to bump." } }
|
|
11
12
|
}
|
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
"entry": "src/main.ts",
|
|
8
8
|
"netplay": { "v": 1, "public": true, "movement": "owner" },
|
|
9
9
|
"needs": { "three": "0.185.1" },
|
|
10
|
-
"assets": "library"
|
|
10
|
+
"assets": "library",
|
|
11
|
+
"landing": { "pitch": "Grab the coins. Jump their swings. Knock the loot loose.", "controls": { "phone": "Drag anywhere to run; Jump and Swing at the bottom right.", "computer": "WASD or arrows to run, Space to jump, F or a click to swing." } }
|
|
11
12
|
}
|