@homie-rocks/studio 0.30.2 → 0.31.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
package/README.md CHANGED
@@ -15,6 +15,7 @@ cd night-owls && npm install
15
15
  npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
17
17
  npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
+ npx homie-studio shoot crown-thief --url http://127.0.0.1:8787 # frames on a stepped clock (works without a GPU) and a two-client seat smoke check
18
19
  npx homie-studio perf crown-thief --url http://127.0.0.1:8787 # how fast it runs: a computer and an emulated phone, host and replica
19
20
  npx homie-studio lab crown-thief # the Game Lab: how a move feels, New beside Today, frame by frame
20
21
  npx homie-studio style board crown-thief # its look as decisions: three directions drawn by the engine (free)
@@ -42,7 +43,7 @@ npx -y @homie-rocks/studio mcp --studios "<the folder the studios live in>" #
42
43
  ```
43
44
 
44
45
  The toolkit as MCP tools, so the chat that shows Homie's cards also does the work: the setup status, a new studio,
45
- the live demo, make / remix / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
46
+ the live demo, make / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
46
47
  playtest, deploy, publish, the progress feed, the studio's files, and the music, sound, art and video scripts where
47
48
  their provider is set up. Where a tool overlaps the remote Homie MCP (homie.rocks/mcp) it has the same name and
48
49
  input shape. Long work (npm install, a check, a deploy) runs in the background and reports through the progress
@@ -99,10 +100,11 @@ featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every
99
100
  joinable and watchable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
100
101
  page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
101
102
  pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
102
- live rooms, credits (the original and its licence for a port; "Remix of <game> by <studio>", linked, for a
103
- remix), and "Make a game like this". A game's shared source (`/games/<id>/source.json`) says who made it and the
104
- licence its owner picked in game.json `"license"`: `"remix-with-credit"` (the default), `"remix-freely"` or
105
- `"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
103
+ live rooms and credits (the original and its licence for a port; "Based on <game> by <studio>", linked, for a
104
+ game that was made from another studio's). game.json `"license": "MIT"` (an SPDX identifier) is the licence the game
105
+ names for itself, said in its credits; a game that names none says nothing. No game's source is served: games build on
106
+ each other through parts ("Game parts", below). studio.json `"site": { "order": ["<id>", …] }` is the
107
+ order of the games in every list, the directory's manifest included. Every page ends with "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
106
108
  files. `site/SITE.md` says all of it.
107
109
 
108
110
  The play page never opens blank (0.26.0): from its first paint an arrival card shows the game's own look (its title,
@@ -118,7 +120,7 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
118
120
 
119
121
  Search engines and AI agents read a studio correctly (0.27.0): every generated page carries schema.org JSON-LD
120
122
  (`worker/schema.mjs`: the studio's `Organization` and `WebSite` on Home, a full `VideoGame` on each landing with its
121
- players, platforms, licence, remix original, trailer, dates and a free-to-play offer whose add-ons are what the shop
123
+ players, platforms, licence, trailer, dates and a free-to-play offer whose add-ons are what the shop
122
124
  really sells, `MusicRecording`, `VideoObject`, `BlogPosting`, `ItemList` and `BreadcrumbList`; never a rating), and the
123
125
  site makes `/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/llms-full.txt` from its public catalogue
124
126
  (`worker/discover.mjs`). `lib/schema-check.mjs` checks every block against schema.org's own vocabulary and what Google
@@ -155,6 +157,29 @@ shows how it differs from the template. `--apply` also pins this version in pack
155
157
  `homie.studio`. When the template's words change, `node scripts/studio-template-history.mjs` records them (a test
156
158
  fails until it has).
157
159
 
160
+ ### Remix was retired
161
+
162
+ Handing over a whole game (`game remix`, the `game_remix` tool, a game's `/games/<id>/source.json`, the office's
163
+ Remixable switch, the "Make a game like this" band) is gone. Games build on each other through **parts**: pieces of a
164
+ game its studio chooses to share, each with its own licence ("Game parts", below). For a studio that upgrades:
165
+
166
+ - **Old settings still build.** A game.json that carries `"remix"`, `"share"`, `landing.make`, or a `"license"` that
167
+ is one of the old words (`"remix-with-credit"`, `"remix-freely"`, `"no-remix"`, or an object with `"kind"`) builds
168
+ as before. The keys do nothing, and the build says so in one note that lists them; delete them when convenient. An
169
+ SPDX identifier in `"license"` (`"MIT"`, or `{ "spdx": "MIT" }`) is still the licence the game names on its page.
170
+ - **A game that was a remix keeps its credit.** game.json `"remixOf"` is still read: the landing and the credits page
171
+ say "Based on <game> by <studio>", linked to the original. That credit is owed, so leave the key where it is. The
172
+ files such a game carried keep saying whose they were in its `assets/RIGHTS.md`.
173
+ - **No source is served.** The build writes no `source.json` or `assets.json` and takes out the ones an older build
174
+ left. `/games/<id>/source.json` answers 410 with `{ "retired": "remix", "see": "/parts/" }`, and
175
+ `/.well-known/homie-studio.json` no longer has `remix`, `source` or `remixOf`. The first deploy after the upgrade
176
+ asks the directory to read a listed studio again, once, so it drops the old offer.
177
+ - **Nothing is dropped from the database.** The office's `office_games.remix` column stays in D1 with whatever it
178
+ held; nothing reads or writes it.
179
+ - **Asset records.** `license.remix` on an entry of `assets/manifest.json` is ignored and no longer written, and
180
+ RIGHTS.md has no "A remix" line. Whether a file may be handed on in a shared part is still its licence's.
181
+ - `homie-studio game remix` answers with a sentence that points to parts; `office launch --remixable` is ignored.
182
+
158
183
  ## Rooms of up to 32
159
184
 
160
185
  Every room of a game has the seats its netplay manifest names: game.json's `netplay`
@@ -164,6 +189,24 @@ Bots, a late joiner taking a bot's body and a new host after the old one leaves
164
189
  the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
165
190
  on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
166
191
 
192
+ ## A game's netplay manifest
193
+
194
+ game.json `"netplay"` says more than the room's size. All of it is optional, and `homie-studio build`
195
+ writes it to the catalogue for the site's rooms (`netplay/NETPLAY.md` sections 22 to 24):
196
+
197
+ | Field | What it does |
198
+ |---|---|
199
+ | `maxPlayers` | Seats in a room, up to 32. |
200
+ | `version` | The game's revision (a word or a number). Bump it when a change makes an already-open tab unable to play with a new one: a room then runs one build at a time, strangers are matched within the live build, and an old tab is told to reload at its round's break. |
201
+ | `stallMs` | How long a host may send no snapshot before the room is handed to another browser: 1500 (the default) to 10000. A heavy 3D game whose frames hitch on a slow phone raises it; the helper also sends a heartbeat for up to 4 s while a host's frames are stuck. |
202
+ | `params` | Names of the play page's query parameters to hand the game (`/<id>/play?seed=42`), beside `debug` and `q`, which always are. The game reads them as `net.params`. |
203
+
204
+ The game's frame is sandboxed without `allow-same-origin` (it must never read the site's storage or
205
+ the owner's session), so `localStorage` throws inside it: a setting or a personal best goes in
206
+ `net.prefs` (the play page keeps 16 KB a game), and progress that must last in saves, below.
207
+ `net.link` says whether the browser is in its room, reconnecting, or playing alone because the room
208
+ never answered; `guardGestures()` stops a long press on a phone from selecting text in a touch game.
209
+
167
210
  ## Player accounts and cloud saves
168
211
 
169
212
  A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
@@ -228,7 +271,7 @@ it):
228
271
  - **Close** a room: everyone is sent out with a thank-you; nobody gets in until it opens.
229
272
  - Per game: **players per room**, the **launch state** (`private`: only the owner;
230
273
  `invite`: an invite-only beta, where each invite link or code lets a browser in;
231
- `public`: anyone, listed), and **remixable** (publish or withdraw the game's source).
274
+ `public`: anyone, listed).
232
275
  A game that is not public is in no list and not in the directory manifest, so the
233
276
  directory drops it the next time it reads the studio. A new game is private from its
234
277
  first deploy with `"launch": "private"` in its game.json.
@@ -453,8 +496,8 @@ this toolkit alone (`new`, `deploy`, `publish`), and the connector row says so.
453
496
  an address or a command; the command itself is never read out.
454
497
  - **Homie's holds**: whether the plugin's hooks ran in this app in the last ten minutes, from the dated mark
455
498
  they leave in `.cache/homie-studio/holds/` of the home folder. Off means nothing is held: Codex runs no plugin's hooks
456
- until the person trusts them in `/hooks`, and the row says so; Grok Build 1.0.41 runs no plugin's hooks at all, so
457
- there the row is off until a Grok that does.
499
+ until the person trusts them in `/hooks`, and Grok Build runs them once the plugin is trusted
500
+ (`grok plugin install … --trust`); the row says which.
458
501
 
459
502
  `homie-studio demo` answers without a network too (0.30.2): where it cannot reach the arcade it names the arcade's
460
503
  standing first pick, with `reached: false`.
@@ -478,8 +521,8 @@ redraws itself whenever the feed changes and refreshes in the browser while a bu
478
521
  puts each game's codex on the site at `/_studio/codex/<id>/`, served only to the studio's owner (the same
479
522
  one-time sign-in as the stats page), never listed or indexed. Text is escaped and the page runs only its own
480
523
  script (the site allows it by its hash); pictures and fonts come only from inside the studio and are
481
- embedded, so it loads nothing but Google Fonts. CODEX.md is never in a game's remix source or among a
482
- static game's served files.
524
+ embedded, so it loads nothing but Google Fonts. CODEX.md is never among a static game's
525
+ served files.
483
526
 
484
527
  ## The Claude Code status line
485
528
 
@@ -519,7 +562,7 @@ one runs, a shared feed asks whether Stop was pressed; a stopped command closes
519
562
  browsers and says so, and nothing already built or deployed is undone.
520
563
 
521
564
  **From the Claude app.** The chat can open the build first (the Homie MCP tool `build_open`, or the "Build it"
522
- card on a make, port or remix) and hand the work to a Claude Code session with a prefilled prompt. The session
565
+ card on a make or a port) and hand the work to a Claude Code session with a prefilled prompt. The session
523
566
  takes the build once, with no key in the prompt, and the card follows it; the change goes out as a pull request
524
567
  the person merges with one tap:
525
568
 
@@ -564,12 +607,103 @@ hours so the Claude app can show it: the feed only (plain bounded text, pictures
564
607
  never printed; the shared id is printed for the MCP tool `build_progress`. Without an open
565
608
  feed every command behaves exactly as before. `lib/progress.mjs` has the whole format.
566
609
 
567
- ## The site's address
610
+ ## Running it here: `homie-studio dev`
611
+
612
+ ```sh
613
+ npx homie-studio dev [--port 8787] [--timestamps] # the whole site on this computer, until it is stopped
614
+ npx homie-studio dev --stop # this studio's dev server only
615
+ ```
616
+
617
+ Every command needs Node.js 22 or newer and says so at once on an older one, before it does anything.
618
+
619
+ **Rooms here are always local.** A studio that is live on its own domain has routes in `wrangler.jsonc`, and Wrangler
620
+ serves a config with a route as that production hostname: games would be handed a room socket on the live site. So
621
+ `dev` runs Wrangler from a copy of the config without `routes` (in `.wrangler/`; `wrangler.jsonc` is never changed),
622
+ then reads one game's page back and checks that the room socket it hands out is this computer's own address, port
623
+ included. If it is not (a `dev.host` in the config, say), `dev` stops and says so. No wrapper script is needed for a
624
+ route-free preview.
625
+
626
+ **A game added while it runs** is picked up. `dev` serves what `build` wrote: a new game folder that is not built is
627
+ named with the command, and once it is built, a running site that still answers 404 for it is restarted by `dev`
628
+ itself, on the same address.
629
+
630
+ **Stopping, and stale state.** `dev --stop` stops exactly the processes this studio's dev server registered, and
631
+ nothing else. A server stopped another way (a closed terminal, a kill by process id or by port) leaves its
632
+ registration behind; `dev` and `dev --stop` both notice, remove it and say it was stale, stop a local runtime left on
633
+ the port when it runs from this studio's folder, and name anything else on the port without touching it. Never stop
634
+ a dev server with `pkill` or by port: another project's would go too.
635
+
636
+ The registration is one file, `<folder of wrangler.jsonc>/.wrangler/homie-dev.json`, and it is the whole contract:
637
+
638
+ ```json
639
+ { "pid": 4242, "child": 4243, "port": 8787, "at": "2026-10-06T12:00:00.000Z" }
640
+ ```
641
+
642
+ `pid` is the process that owns the server, `child` the Wrangler it started (optional), `port` the local port, `at`
643
+ when it started. `dev --stop` and the MCP tools `preview_run` / `preview_stop` read only this. A process it names is
644
+ signalled only while it is still this studio's: alive, and its command line names the studio's folder, or its working
645
+ directory is inside it, or it is still the kind of program its key says (`pid`: homie-studio; `child`: wrangler or
646
+ workerd). So a studio's own wrapper script that writes this file, and is started from the studio's folder, is stopped
647
+ by `dev --stop` like the built-in one; it should delete the file when it ends.
648
+
649
+ **Room lines carry a time.** Wrangler prints a room socket opening (`GET /<game>/__net 101`) and a runtime error such
650
+ as `Network connection lost` with no time on them. `dev` puts this computer's clock in front of those lines (every
651
+ line with `--timestamps`), so they can be laid beside a check's own report. A connection-loss line is printed when a
652
+ browser or a host leaves abruptly, and also when a room operation really failed: the time is what tells them apart.
653
+
654
+ ## The site's address, its routes, and what a deploy says
568
655
 
569
656
  `deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
570
657
  names the Cloudflare account, often after its owner. A custom domain goes in studio.json
571
658
  as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
572
659
 
660
+ **Routes.** `deploy` writes `wrangler.jsonc` again each time, and keeps the routes that are the studio's own exactly
661
+ as they were written: a custom domain (`{ "pattern": "play.example.com", "custom_domain": true }`) and an exact-host
662
+ route (`{ "pattern": "play.example.com/*", "zone_name": "example.com" }`). A zone's other routes are never
663
+ changed or removed. On a custom domain, `deploy` and `deploy --plan` read the domain's Worker routes first (two GETs
664
+ with the sign-in Wrangler already has) and warn, before anything is deployed, when another Worker's catch-all (`*/*`)
665
+ or wildcard (`*example.com/*`) covers the studio's hostname: that route answers the hostname before the studio does,
666
+ it belongs to another site on the domain, and it must not be edited or removed. The warning gives the one safe fix,
667
+ the studio's own exact-host route, as the exact line; `deploy --own-route` adds that line and nothing else, only when
668
+ the read showed it is needed. Routes that could not be read are said as unmeasured, never as fine; after the deploy,
669
+ a domain that answers as something else still gets the route to add. A wildcard or catch-all route (`*/*`) in the studio's own config would hand every
670
+ hostname of the zone to the studio's Worker, so `deploy` refuses it before it asks Cloudflare anything; a studio that
671
+ really owns the whole zone says so in studio.json (`"cloudflare": { "allowWildcardRoutes": true }`).
672
+
673
+ **One deploy at a time.** A second `deploy` of the same studio is refused while one runs (`.studio/deploy.lock` names
674
+ its process and start time); a lock left by a deploy that was killed is taken over by the next one.
675
+
676
+ **Which games changed.** Each deploy prints, per game, `changed`, `unchanged` or `new` against the last deploy from
677
+ this computer, and the game's **build hash**: the one `homie-studio build` printed for it, that
678
+ `site/dist/_site/build.json` keeps, that `perf` names a run's build by, and that the live site answers with in
679
+ `/.well-known/homie-studio.json` (`games[].build.hash`). There is one hash, so a local build is matched to what is
680
+ live by comparing two strings. `build` says changed "since the last build here"; `deploy` says changed "since the
681
+ last deploy from this computer", from its own record in `.studio/local.json`. A deploy from another computer, or the first one, has nothing to compare with and says so.
682
+
683
+ **The directory.** Going online lists nothing. For a studio this computer already listed with `publish`, the first
684
+ deploy after an older toolkit's (one that still offered a game's whole source) asks the directory to read the studio
685
+ again, once, so it does not keep that offer. `publish` says how many publishes are left today when the directory gives the number (the
686
+ beta has a daily cap), and before it sends, how many this computer has sent today; a refusal for the cap is said as
687
+ that, with when it ends.
688
+
689
+ **This computer's network.** When Node.js cannot look a public hostname up (a browser on the same computer may still
690
+ open it), the command says "network preflight failed", not that the site or a game is broken, and offers the local
691
+ dev site instead.
692
+
693
+ ## A trailer: `homie-studio trailer`
694
+
695
+ ```sh
696
+ npx homie-studio trailer crown-thief --url http://127.0.0.1:8787 --seconds 45 --length 20 --title "CROWN THIEF"
697
+ ```
698
+
699
+ One command, into `videos/crown-thief-trailer/`: the game rendered frame by frame on a virtual clock (its
700
+ `requestAnimationFrame`, `performance.now`, `Date`, timers and CSS and Web animations all move 1/30 s a frame, so no
701
+ frame is held however heavy the game), its sound rebuilt from the game's own sound files and the log its sound player
702
+ keeps of what it played, the shots picked from that log, an end card, and 16:9, 1:1 and 9:16 files. The work is the
703
+ Homie plugin's video skill; this command finds its script and hands over (`--skills <folder>` when the plugin is not
704
+ beside the package). The skill's guide says what the virtual clock does not reach (the audio clock, media elements,
705
+ Web Workers, a live server) and what a game must do for its sound to be in the film.
706
+
573
707
  ## How fast it runs: `homie-studio perf`
574
708
 
575
709
  ```sh
@@ -585,15 +719,28 @@ on a fast computer is less than its rate). They play the same seeded presses thr
585
719
  window, and each run's JSON says, per browser: the time between animation frames (median, p95, p99, the share over 33
586
720
  and 50 ms), the game's JavaScript per frame (every requestAnimationFrame callback timed), the main thread per frame
587
721
  (Chrome's TaskDuration), time to the first meaningful frame (`load.look`: the play page's arrival card), to the
588
- game's first frame, to a seat and to playable, the game's files on the wire, the heap
589
- after a garbage collection, and netplay messages and kilobytes a second each way. `--profile` adds a CPU profile per
722
+ game's first frame, to a seat and to playable (control-ready: seated, a body reported, the loading cover gone) and,
723
+ beside it, `load.ready` (when the game itself called `net.playable()`) with the arrival mode (`load.arrival`: `auto`,
724
+ `game`, or `seat` for an older helper; under `auto`, `lateMs` is how long after the cover lifted the game said it was
725
+ ready, which is a game that should declare `arrival: 'game'`), the game's files on the wire, the heap
726
+ after a garbage collection, and netplay messages and kilobytes a second each way. Two more are measurements of the
727
+ running game, not estimates from its files: `load.prePlay` (every request the browser really made up to playable,
728
+ with the bytes that had arrived of any still in flight; the metric is `load.prePlayKb`), which is first-play traffic
729
+ and not the shipped payload that `perf sizes` and `assets check` total from the built folder; and `render` (the
730
+ renderer's own draw calls and triangles while playing, metrics `render.calls` and `render.triangles`), read when the
731
+ game exposes them on its port probe (`exposePort(net, { extra: { drawCalls: () => renderer.info.render.calls,
732
+ triangles: () => renderer.info.render.triangles } })`, as the 3D starters do; `PortExtra` in the port kit types these
733
+ and the other names the playtest reads: `alive`, `mode`, `loadout`, `touchHeld`). A game that exposes neither has no
734
+ `render` numbers: the run says the cost was not measured, never zero. `--profile` adds a CPU profile per
590
735
  browser in a window of its own (a `.cpuprofile` for Chrome DevTools) and its hottest functions, read through the
591
736
  game's source map when the build kept one (`homie-studio build --maps` keeps it in `.studio/maps/<id>/`, never in
592
737
  `site/dist`).
593
738
 
594
739
  The computer is shared, so every run waits for the 1-minute load to fall under 0.8 per core (`--max-load`) and
595
740
  records the load before and after; a run that started busy says `loaded` and `compare` leaves it out. A software
596
- renderer (SwiftShader) makes the run `blocked`: nothing on it is judged. `compare` calls a metric better only when a
741
+ renderer (SwiftShader) makes the run `blocked`: nothing on it is judged. So does an address this computer's Node.js
742
+ cannot look up (Node and a browser do not look names up the same way): "network preflight failed", with the local
743
+ site as the way round it, never "the site is down". `compare` calls a metric better only when a
597
744
  rank test says it is unlikely to be chance (p < 0.05), the 95% bootstrap interval of the change stays below zero, and
598
745
  it is at least 3% better; runs taken side by side with the same `--pair` label are judged as pairs (a signed-rank
599
746
  test on their ratios), so a computer that drifted busier cancels. Guards (frame time, main thread per frame, time to
@@ -607,6 +754,48 @@ Minified JavaScript gzips about as well as source text, and a bundle with three.
607
754
  source in strings, which no minifier touches; the line for such a file says `minified, 19% GLSL shader source in
608
755
  strings`, and the JSON's `code` has every measure.
609
756
 
757
+ ## Game parts: how games build on each other
758
+
759
+ Games build on each other by sharing **parts**: a piece of a game that its studio chooses to share, so somebody
760
+ making another game can use it. A creature from one game, a level from another, a bot brain from a third. Without
761
+ parts, a creator's AI has to come up with everything on its own. [`parts/PARTS.md`](parts/PARTS.md) is the design;
762
+ the plugin's `parts` skill is the method.
763
+
764
+ Creators work in chat and never type a command: "what parts are there for a chase camera?", "add the Pickup field
765
+ part from owls.example/pickup-field to my game", "make my creature a part", "share it under CC BY". Four tools do
766
+ it, named like the asset tools: `parts_find`, `part_add`, `part_new` and `part_share`.
767
+
768
+ - **A part is a piece of a game, never the whole game**, in whatever form suits it: code, assets, data, a JSON
769
+ description, a tuned config, or a mix.
770
+ - **Agents look first.** `game_plan` and the game and plan skills tell an agent to check the `@homie-rocks/*`
771
+ packages for the general mechanism and `parts_find` for pieces other studios shared, before writing from
772
+ scratch, and to record in the game's CODEX (Built from) and its credits what came from where. `parts_find` reads
773
+ the hub's catalogue and the studio's own parts; when the hub cannot be reached it says so and still answers with
774
+ the studio's own, so an empty list never stands in for "could not look".
775
+ - **Packages are not parts.** `@homie-rocks/*` packages come from npm and are never in the catalogue. A part says
776
+ which packages it builds on, and npm does the rest: `npm ls` says whether the studio has one, `npm install`
777
+ installs what is missing after one line saying what and why, and npm's own output is relayed. There is no
778
+ resolver, no version ranges and no lockfile of Homie's.
779
+ - **Use.** `part_add` fetches one exact version (the latest shared unless named), checks every file's SHA-256 and
780
+ size before a byte is written, copies it into `parts/_vendor/<site>/<id>/` where the studio owns and tunes it,
781
+ records where it came from (`parts/origins.json`), and credits it in the game's `credits.json` with its licence,
782
+ who made it and the game it came from. The game imports it in one line,
783
+ `import { createPickupField } from '@parts/owls.example/pickup-field'` (one of the studio's own is
784
+ `@parts/<id>`). Adding it again brings the newer version: what changed is shown, `tuning.json` is kept, and a
785
+ file the studio edited is never replaced without saying so.
786
+ - **Share.** `part_new` lifts a piece out of a game (the game still builds and plays the same) and records the
787
+ game and studio it came from. A part is private until `part_share`, which needs an SPDX licence, an attribution
788
+ when the licence asks for credit, and known rights to every file. It is live after the studio's next deploy.
789
+ - **What the site serves** is `sharedPartsOf(studio)` and nothing else: `/.well-known/homie-parts.json`, each
790
+ shared version's content at `/parts/<id>/<version>/…` exactly as hashed (immutable, CORS open for GET), the
791
+ studio's own pages `/parts/` and `/parts/<id>/`, and a **Parts from this game** band on a game's landing. A
792
+ private part is never copied into the built site, and the Worker answers only what the built index names.
793
+ - **Publishing a game that uses parts**: `deploy --plan` shows each part's licence and attribution and says
794
+ plainly when licences cannot be combined; `publish` (and `studio_publish`, also with `before: true`) says the
795
+ same lines before the studio is listed. `parts add --no-install` brings a part in and installs nothing:
796
+ `package.json` is left as it is, and a package the part builds on that the studio lacks is named NOT INSTALLED
797
+ with the npm command that installs it.
798
+
610
799
  ## How it feels: the Game Lab (`homie-studio lab`)
611
800
 
612
801
  ```sh
@@ -659,7 +848,9 @@ npx homie-studio assets find "fox" --kind creature # the starter library
659
848
  npx homie-studio assets add crown-thief kenney-cube-pets/animal-fox --height 0.7 --card "Characters/Fox"
660
849
  npx homie-studio assets add crown-thief --file ./lantern.glb --license own --height 0.6
661
850
  npx homie-studio assets check crown-thief # budgets, the glTF-Validator, licences, staleness, big files
851
+ npx homie-studio assets use crown-thief old-tree unused # what the game does with it now: cast, prop, environment, unused
662
852
  npx homie-studio assets lineup crown-thief # true scale, silhouettes, palette drift: .studio/art/<id>/
853
+ # three lineups: the inventory (every recorded model), the cast (what is in play), the environment (the scenery)
663
854
  npx homie-studio assets rights crown-thief # games/<id>/assets/RIGHTS.md and the credits
664
855
  npx homie-studio assets redo crown-thief lantern # made again from its kept raw file, free
665
856
  npx homie-studio assets remove crown-thief lantern # the record and the copy the game ships
@@ -674,14 +865,40 @@ what remaking it would cost; nothing is remade by itself. `games/<id>/style.json
674
865
  and camera the game, its landing and its title cards draw with.
675
866
 
676
867
  Every model, texture and sky a game ships has an entry in `games/<id>/assets/manifest.json`: its route (procedural,
677
- library, generated, imported), each step that made it, its licence and what a remix gets, its measurements. From it:
678
- `RIGHTS.md`, the credits on the game's landing, `assets check`, the licence check `publish` makes, and
679
- `/games/<id>/assets.json` on the site, from which `game remix` fetches every model its licence lets a remix carry
680
- (checked by SHA-256; a grey placeholder of the same size for the rest). Models are refused when they load anything
868
+ library, generated, imported), each step that made it, its licence, its measurements. From it:
869
+ `RIGHTS.md`, the credits on the game's landing, `assets check` and the licence check `publish` makes. Models are refused when they load anything
681
870
  from an address or are over the size caps; `assets add` makes them phone-sized (meshopt geometry, WebP pictures, the
682
871
  pivot at the bottom centre, scaled to metres) with @gltf-transform, meshoptimizer and sharp; raw files stay in
683
872
  `art/<slug>/raw/`, which git ignores.
684
873
 
874
+ `assets check` is an **inventory estimate**, and says so in everything it prints: the recorded files' own triangles,
875
+ draw calls and pictures, each times its declared `placements`. It never runs the game, so geometry the code builds,
876
+ copies the code draws beyond `placements`, effects, and shadow and post passes are not in it; a pass is not a measured
877
+ frame (the playtest and perf guides measure the running game). What it does measure:
878
+
879
+ - **Standalone textures and skies** (a `texture` or `sky` record's own pictures): there, the file recorded (SHA-256),
880
+ decodable, within size (a note past 1,024 px, a problem past 2,048 px; a sky twice that), and their mipmapped memory
881
+ added once per unique picture. A row with nothing measured prints `??` and why, never `ok`. Shipped pictures under
882
+ `public/` that no record names are listed with their size, apart from pictures the folder keeps but never ships;
883
+ neither is in the sum.
884
+ - **Maps the game does not draw.** A model's normal and roughness maps are counted unless the manifest says the game
885
+ draws base colour only (`stylize()` with a toon, flat, pixel or hand-painted material model keeps nothing else):
886
+ `"inGame": { "maps": "base" }` on a record, or once at the top of `assets/manifest.json` for every model
887
+ (`"maps": "all"` on a record keeps its own). The check never guesses this from the code; without the declaration it
888
+ says how many MB are such maps.
889
+ - **The game's own model budget.** game.json `"assets": { "budgets": { "triangles": 4000, "bytes": 600000 } }` (the key
890
+ the loader's development warnings read) raises what a model may be in the check and in `assets add`, never past a
891
+ hard cap and never lowering a hero's. One key, read once: the check, `assets add` and the build (for the loader)
892
+ take the same numbers. A budget may also be set for one tier (`"hero": { "triangles": 12000 }`; tiers are hero,
893
+ npc, prop, signature, kit); the loader knows no tiers, so it is given the loosest one, and a number past a hard cap
894
+ is held to the cap by all three (the build says so).
895
+ - **Usage.** `assets use <game> <asset> unused` marks a file the game no longer draws: it stays in the inventory
896
+ lineup, leaves the cast and environment lineups and the scene sums, and still counts in the shipped payload until
897
+ `assets remove`. Unset, usage follows the asset's kind.
898
+ - **Shipped payload** (`totals.shippedPayloadMB`; `firstPlayMB` is its old name, kept for existing readers): every
899
+ file of the last build, gzipped. A conservative gate on what the game ships, not a trace of what a browser fetched
900
+ before the first round.
901
+
685
902
  In a three.js game, `@homie-rocks/studio/assets` is the one loader:
686
903
 
687
904
  ```ts
@@ -692,7 +909,8 @@ const fox = await models.instance('./models/fox.glb').catch(() => models.placeho
692
909
 
693
910
  It checks every file before three.js parses it (no external URIs, no buffer over the cap, only extensions three.js
694
911
  reads), decodes meshopt, clones skinned models properly, and says in development when a model is over its budget
695
- (`stats()`, `window.__homieModels`). `game new <id> --from gem-rush-3d` is Gem Rush in 3D, dressed from the library.
912
+ (`stats()`, `window.__homieModels`). The budget is a small prop's (1,500 triangles, 300 KB) unless the game sets its
913
+ own once, in game.json: `"assets": { "budgets": { "triangles": 8000, "bytes": 1500000 } }` (per model). `game new <id> --from gem-rush-3d` is Gem Rush in 3D, dressed from the library.
696
914
  The local MCP's `style_explore`, `decision_set`, `assets_plan`, `assets_find`, `asset_add`, `asset_make`,
697
915
  `asset_check`, `asset_lineup` and `asset_rights` do the same with cards. Generated props (on the person's own fal
698
916
  account, under a budget, receipted) are the Homie plugin's `models` skill.
@@ -717,7 +935,14 @@ clip library `public/anims/<skeleton>.glb`, shared by every character with that
717
935
  has them, else retargeted at build time from the starter library's CC0 humanoid clips (KayKit's): every bone turns in
718
936
  the world the way the source's turned from its rest, after the two rest poses are lined up bone by bone, so a T-pose
719
937
  clip plays on an A-pose rig; a one-piece limb aims where the source's whole limb points. It is plain matrix math in
720
- Node (no renderer), so it runs in CI and a cloud session too. `assets check` holds characters to four influences a
938
+ Node (no renderer), so it runs in CI and a cloud session too. One clip library a skeleton: a second one is refused
939
+ when it is recorded (it used to hide the first), unless its record says `"rig": { "supplemental": true }`, in which case
940
+ its verbs join the character's coverage and previews and the game loads both
941
+ (`loadCharacter(models, url, { anims: [base, extra] })`). `anim plan` and `anim preview` also show how fast each
942
+ character's walk and run move its feet over the ground on its own rig (kept as `rig.gait`): animate's default
943
+ `runSpeed` of 4.2 m/s is not measured on your rig, and a clip retargeted onto a taller character covers more ground,
944
+ so start `tune: { runSpeed }` from that number. It is a guide from the foot bones, not a verified foot contact.
945
+ Removing a starter character keeps its pack's credit while any clip retargeted from it remains. `assets check` holds characters to four influences a
721
946
  vertex, the tier's bones, a clip library of 1 MB at 30 samples a second, every verb the game's `anim.clips` names,
722
947
  and the skinning a room costs on a phone (players times the heaviest character: 60,000 vertices and 1,200 bones a
723
948
  frame).
@@ -743,6 +968,71 @@ move), `anim_add`, `anim_preview` and `character_make` do the same with cards. A
743
968
  in the locked style, then Meshy's mesh and auto-rig on the person's own fal account, about US$1.44, priced and
744
969
  receipted) is the Homie plugin's `models` skill; rigs, clips and feel are its `animate` skill.
745
970
 
971
+ ## A model you can stand on (`homie-studio collision`)
972
+
973
+ Importing a model records what it looks like. It does not say where a figure may walk on it, so the first playable
974
+ version of a generated landmark is one figures fall through.
975
+
976
+ ```sh
977
+ npx homie-studio collision bake harbour-run lighthouse.glb --cell 0.25 # a height grid off its triangles, beside it
978
+ npx homie-studio collision check harbour-run # stale? proxies well formed? every route walked?
979
+ ```
980
+
981
+ `bake` writes `lighthouse.collision.json` (the grid sampled off the model's outside surface at the cell size you give,
982
+ the SHA-256 of the file and a checksum of its triangles) and `lighthouse.collision.svg`, the plan at true scale (40 px to
983
+ the metre). Three things in the JSON are yours, and a re-bake keeps them: `"proxies"` (boxes, cylinders and ramps for
984
+ what a figure walks around), `"mover"` (the body that must fit: radius, height, step, and the arrival radius the
985
+ game's bots really use) and `"routes"` (ways a bot should be able to walk, as lists of points). `check` fails, with a
986
+ row for each thing wrong, when the model's surface changed since the bake, a proxy is malformed, or a body of the
987
+ mover's size cannot walk a route: it says where it fell or what was in the way, and marks the spot on the plan.
988
+
989
+ The game loads the JSON with `unpackBake` and `bakeField` from `@homie-rocks/heightfield/MeshBake.js`, and calls
990
+ `bakeStale` against the mesh it loaded. The command needs `@homie-rocks/heightfield` installed in the studio (it says
991
+ so when it is not). Buildings with rooms and roofs, zone marks on every level and staged capture positions are that
992
+ package's `Levels.js`, `Route.js`, `Markers.js` and `Pose.js`: its README has them.
993
+
994
+ ## Bots that want different things (`@homie-rocks/studio/personas`)
995
+
996
+ ```ts
997
+ import { PERSONAS, createPersonaBrain, botStats, seededRng } from '@homie-rocks/studio/personas';
998
+
999
+ const who = createPersonaBrain(PERSONAS.bully, { rng: seededRng(roomSeed + slot), arena: { solid } });
1000
+ const target = who.choose(self, candidates, rivals); // candidates: { x, y, kind, value, size? }
1001
+ const push = who.steer(self, seek(self, target)); // turned off any wall ahead
1002
+ ```
1003
+
1004
+ One scorer (value against distance, with a target behind, a wall in the way, company near it and a bigger rival each
1005
+ costing some of the score) and five rows of weights: `glutton`, `bully`, `racer`, `scavenger`, `sneak`.
1006
+ `persona('sneak', { kinds: { beacon: 6 } })` extends one. `solid(x, y)` is the game's own wall test; line of sight
1007
+ and wall avoidance use nothing else. `who.chooser(...)` is the `choose` the port kit's `BotBrain.think` takes, so the
1008
+ room's skill dial still sets how late a bot reacts and how well it aims: a persona is what it wants.
1009
+ `who.stats()` / `botStats([...])` count what each bot picked (kinds, mean value, distance, facing, company, picks
1010
+ behind walls) and `personaSpread` is one number for how unlike each other they were: assert on those in the game's
1011
+ own test, because weights that look different often play the same. The only randomness is the `rng` you pass. The
1012
+ host owns the bots; nothing here is sent anywhere.
1013
+
1014
+ ## Power-ups whose timers survive a host change (`@homie-rocks/studio/powerups`)
1015
+
1016
+ ```ts
1017
+ import { createPowerups, stepPowerups, collectPowerups, resolveHit, biteRadius, collides, packPowerups, unpackPowerups } from '@homie-rocks/studio/powerups';
1018
+
1019
+ let power = createPowerups(SPOTS.length, roomSeed); // host
1020
+ ({ state: power } = stepPowerups(power, dtMs)); // host tick: timers, expiry, spawns
1021
+ ({ state: power, events } = collectPowerups(power, bodies, SPOTS)); // who touched what (lower slot on a tie)
1022
+ snapshot.power = packPowerups(power); // host: in the snapshot AND the checkpoint
1023
+ power = unpackPowerups(snapshot.power); // replicas, and a new host, once
1024
+ ```
1025
+
1026
+ Everything is one plain object and pure functions over it, so the rules are unit-tested and nothing reads a clock.
1027
+ The state holds how long each power-up has **left**, not a deadline, and the spawn sequence's seed: a browser that
1028
+ becomes host mid-timer carries on from the last snapshot and spawns the same next pickup. Three kinds are built in:
1029
+ Magnet (`biteRadius` is wider, `magnetPull` draws loose things toward the mouth), Phase (`collides(state, a, b)` is
1030
+ false) and Shield (`resolveHit` blocks one hit and returns the velocity to bounce the attacker with). Add a kind by
1031
+ adding a row to the rules, last (a kind travels as its index). `powerFlags` is a bitmask per body. It is the game's
1032
+ own snapshot data: the netplay wire protocol is unchanged, and a tab on an older build drops kinds it does not know.
1033
+ `powerDraws` fills instance matrices for three instanced meshes the game owns (gems, magnet rings, shield bubbles) and
1034
+ `hudPills` says what the HUD shows; both are optional and import no renderer.
1035
+
746
1036
  ## Checks on a computer without a GPU
747
1037
 
748
1038
  `check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
@@ -751,6 +1041,40 @@ the home folder's `.cache/homie-studio` (from storage.googleapis.com, which a cl
751
1041
  renders with SwiftShader. `check` reports how fast each browser drew the game and on what renderer; on SwiftShader
752
1042
  it says the frame rate is not a person's, and judges only seats, rooms and rounds.
753
1043
 
1044
+ `check` reports three things apart: **seated** (a room, a seat, a role), **ready** (`readiness`: the play page's
1045
+ loading cover has lifted; the picture it calls `<browser>-playing.png` is taken only then, and a browser still
1046
+ covered is saved as `<browser>-loading.png`), and **connected** (`connection`, `reconnects`, `uninterrupted`: a
1047
+ round both browsers finished is `ok`, and whether one of them reconnected on the way is said beside it; a browser
1048
+ seen `reconnecting`, `alone`, `offline` or `closed` (the helper's link) is listed as `cutOff` and the connection is
1049
+ not called uninterrupted, whether or not a counter moved). `perf` leaves out a run whose browser ended its window cut
1050
+ off from the room. A site this computer's Node.js cannot look up does not stop `check` or `shoot` (their browsers may
1051
+ still open it); if the browsers cannot either, they report the same BLOCKED network preflight as `perf` and `port check`.
1052
+ Nothing listening on this computer's own address stops all four at once: "does not answer: start the site".
1053
+ `port check`'s big-screen row requires the join QR on an address a phone can reach and marks it not applicable on a
1054
+ loopback preview, where the page hides the join card on purpose.
1055
+
1056
+ ### Pictures on a stepped clock: `homie-studio shoot`
1057
+
1058
+ ```sh
1059
+ npx homie-studio shoot crown-thief --url http://127.0.0.1:8787 [--frames 60] [--fps 30] [--device computer|phone] [--hold KeyD] [--no-smoke] [--timeout 180]
1060
+ ```
1061
+
1062
+ For a 3D game where there is no GPU and the software renderer draws two frames a second. First a smoke check: two
1063
+ clients (a computer and a phone) open the play page in one private room (`?room=shoot-…`) and must both get a seat
1064
+ there, on different seats, with neither playing alone offline. Then the first client's clocks are frozen and stepped:
1065
+ `requestAnimationFrame`, `performance.now`, `Date`, `setTimeout`, `setInterval` and CSS/Web animations move by
1066
+ exactly 1/fps of a second per frame, in the page and in the game's frame, and each step is one picture
1067
+ (`.studio/shoot/<id>/frame-0001.png` …, with `shoot.json`: each frame's time, and the round phase, position and
1068
+ renderer counters when the game has a port probe). Not stepped, and said in the result: the room's socket and its
1069
+ server clock (other players arrive in real time), Web Audio's clock, `<video>`, workers. The frames show what was
1070
+ drawn; they are never a frame rate. Everything is bounded by `--timeout`; a run that hits it keeps the frames it has
1071
+ and says `partial`.
1072
+
1073
+ `shoot <id> --preview` needs no site running: it serves the built game itself (the light server `preview <id>` uses:
1074
+ no Wrangler, no rooms, the game alone and offline with its bots) and shoots that page. The smoke check is not run
1075
+ there (it needs rooms: use `dev` and `--url`). The result names the build the frames are of, by the hash `build`
1076
+ printed and the hashed bundle the page loaded.
1077
+
754
1078
  ## Another way to Cloudflare: Stripe Projects (a prototype, 0.24.3)
755
1079
 
756
1080
  `npx wrangler login` stays the way. For a creator with no Cloudflare account (and no ElevenLabs), Stripe Projects
@@ -812,7 +1136,9 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
812
1136
  | --- | --- |
813
1137
  | `bin/homie-studio.mjs` | The CLI. |
814
1138
  | `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
815
- | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
1139
+ | `lib/build.mjs`, `lib/stage.mjs` | `build`: games bundled with esbuild (ES modules with code splitting, the bundle named by its content) into `site/dist`, `games.json` and `_site/build.json` (each game's build hash and whether it changed). Built in a folder of its own and put in place only when all of it is there; a game that does not build fails the command and leaves `site/dist` as it was (`site/SITE.md`, "The build"). |
1140
+ | `lib/typecheck.mjs` | `build --types`: the games' TypeScript checked with the studio's own `typescript` before anything is built. |
1141
+ | `lib/preview.mjs` | `preview <id>`: one built game's files at an address on this computer, with no Wrangler and no rooms. |
816
1142
  | `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations; refuses resources it did not create. In Workers Builds, migrations and deploy only. `storage add`: the optional R2 bucket; `media move`: big media into it, checked by SHA-256 (every deploy runs it). |
817
1143
  | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it, with each one's frame rate; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. `lib/chrome.mjs`: which Chrome, and how (the GPU on a Mac, SwiftShader on Linux). |
818
1144
  | `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
package/assets/animate.ts CHANGED
@@ -49,7 +49,9 @@ const HERO_BUDGET = { triangles: 8000, texturePx: 1024, materials: 2, bytes: 153
49
49
  export const ANIM_TUNING = Object.freeze({
50
50
  fade: { value: 0.14, min: 0, max: 0.6, step: 0.01, unit: 's', group: 'Motion', note: 'How long one clip takes to blend into the next' },
51
51
  walkSpeed: { value: 1.5, min: 0.3, max: 6, step: 0.05, unit: 'm/s', group: 'Motion', note: 'The ground speed the walk clip\'s feet move at (feet slide when this is wrong)' },
52
- runSpeed: { value: 4.2, min: 1, max: 12, step: 0.05, unit: 'm/s', group: 'Motion', note: 'The ground speed the run clip\'s feet move at' },
52
+ // 4.2 m/s suits the starter library's chibi rigs. It is a default, NOT a reading of your rig: a clip retargeted onto
53
+ // a taller character covers more ground a cycle (`homie-studio anim plan` shows the speed measured on each rig).
54
+ runSpeed: { value: 4.2, min: 1, max: 12, step: 0.05, unit: 'm/s', group: 'Motion', note: 'The ground speed the run clip\'s feet move at (a default, not measured on your rig: anim plan shows the measured speed)' },
53
55
  runFrom: { value: 2.4, min: 0.5, max: 8, step: 0.05, unit: 'm/s', group: 'Motion', note: 'From this speed up, the walk becomes a run' },
54
56
  actSpeed: { value: 1.25, min: 0.5, max: 3, step: 0.05, group: 'Motion', note: 'How fast actions (attacks, casts) play: higher is snappier' },
55
57
  jumpStretch: { value: 0.16, min: 0, max: 0.5, step: 0.01, group: 'Motion', note: 'How tall and thin the body goes as it leaves the ground' },
@@ -64,8 +66,11 @@ export const ANIM_TUNING = Object.freeze({
64
66
  export type AnimTuning = { -readonly [K in keyof typeof ANIM_TUNING]?: number };
65
67
 
66
68
  export interface CharacterOptions {
67
- /** The clip library's address (default: the one the model names, beside it in ./anims/). */
68
- anims?: string | null;
69
+ /**
70
+ * The clip library's address (default: the one the model names, beside it in ./anims/); null for none; or a list of
71
+ * addresses when its skeleton has a supplemental library too (the base first).
72
+ */
73
+ anims?: string | string[] | null;
69
74
  /** Live numbers (lab.tunables(tuning)): any ANIM_TUNING key, read every frame. */
70
75
  tune?: AnimTuning;
71
76
  /** Bones that swing on springs (default: names with tail, ear, cape, hair, antenna). */
@@ -157,10 +162,15 @@ export async function loadCharacter(models: ModelSource, url: string, opts: Char
157
162
  const loaded = await models.load(url, { budget: HERO_BUDGET });
158
163
  const info = (loaded.scene.userData?.homie ?? {}) as { anims?: string; family?: string; sockets?: Record<string, string> };
159
164
  let clips = loaded.animations ?? [];
160
- const animsUrl = opts.anims === null ? null : (opts.anims ?? (info.anims ? new URL(info.anims, new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/')).href : null));
161
- if (animsUrl) {
162
- try { clips = [...(await models.load(animsUrl, { budget: { triangles: 0, bytes: 3 * 1024 * 1024 } })).animations, ...clips]; } catch (error) { if (!clips.length) console.warn(`[animate] ${url}: its clips did not load (${(error as Error).message})`); }
165
+ // `anims`: the clip library to use instead of the one the model names; null for none; or a LIST, for a skeleton
166
+ // with a supplemental library beside its own (the base first: where two hold a verb, the earlier one's clip plays).
167
+ const own = info.anims ? new URL(info.anims, new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/')).href : null;
168
+ const animsUrls = opts.anims === null ? [] : Array.isArray(opts.anims) ? opts.anims : [opts.anims ?? own].filter((u): u is string => Boolean(u));
169
+ const fromLibraries: AnimationClip[] = [];
170
+ for (const animsUrl of animsUrls) {
171
+ try { for (const c of (await models.load(animsUrl, { budget: { triangles: 0, bytes: 3 * 1024 * 1024 } })).animations) if (!fromLibraries.some((x) => x.name === c.name)) fromLibraries.push(c); } catch (error) { if (!clips.length && !fromLibraries.length) console.warn(`[animate] ${url}: its clips did not load (${(error as Error).message})`); }
163
172
  }
173
+ clips = [...fromLibraries, ...clips];
164
174
  const model = await models.instance(url, { budget: HERO_BUDGET });
165
175
  return createCharacter(model, clips, { ...opts, family: info.family ?? null, sockets: info.sockets ?? {} });
166
176
  }