@homie-rocks/studio 0.20.0 → 0.22.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 +78 -1
- package/README.md +55 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +52 -3
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +25 -2
- package/lib/check.mjs +3 -1
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/office.mjs +30 -8
- package/lib/optimise.mjs +380 -0
- package/lib/port-check.mjs +3 -1
- package/lib/progress.mjs +16 -1
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +32 -1
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +75 -0
- package/lib/thumb.mjs +82 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/package.json +14 -2
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2086 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/index.mjs +5 -3
- package/worker/pages.mjs +13 -5
- package/worker/qr.mjs +11 -0
- package/worker/site.mjs +5 -3
- package/worker/version.mjs +1 -1
package/lib/scaffold.mjs
CHANGED
|
@@ -106,6 +106,7 @@ directory lists its games; homie.rocks does not host them.
|
|
|
106
106
|
| --- | --- |
|
|
107
107
|
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
|
|
108
108
|
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
109
|
+
| \`games/<id>/assets/\` | Every model and texture a game ships, with where it came from and its licence (\`manifest.json\`), and \`RIGHTS.md\` in plain words; \`games/<id>/codex/decisions.json\` (private, like the codex) is its look as decisions, and \`style.json\` the palette, fonts, light and camera the game draws with. \`art/<slug>/\` holds art jobs (concepts, receipts; \`raw/\` is git-ignored). |
|
|
109
110
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
110
111
|
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
|
|
111
112
|
| \`wrangler.jsonc\` | The Worker's Cloudflare config (the Worker, D1, the Table and Lobby Durable Objects, and \`previews\` for branch Previews), at the root, where Cloudflare's Workers Builds reads it. A studio made before 0.10.0 keeps it in \`site/\` and deploys from a computer; every command finds either. |
|
|
@@ -264,6 +265,26 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
264
265
|
Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
|
|
265
266
|
block anything.
|
|
266
267
|
|
|
268
|
+
## Art direction and models
|
|
269
|
+
|
|
270
|
+
- **A game's look is a set of decisions** (\`games/<id>/codex/decisions.json\`, drawn in the codex's Art direction
|
|
271
|
+
tab): render style, palette, shape, proportions, materials, light, camera, fonts, effects; the cast, its library
|
|
272
|
+
family, scale and phone budgets. \`npx --no-install homie-studio style init <id> --prompt "<the person's words>"\`
|
|
273
|
+
picks every one automatically with a why. The person can steer one ("warmer"), lock one, or explore three
|
|
274
|
+
directions drawn by the game engine itself (\`style board <id>\`, free). The first asset built on a decision pins
|
|
275
|
+
it; a locked one changes only with the person's yes after \`style blast\` shows what goes stale and what remaking
|
|
276
|
+
it would cost. Nothing is ever remade by itself. The Homie plugin's \`style\` skill has the rest.
|
|
277
|
+
- **Models come free first**: the engine, then Homie's CC0 starter library (\`assets find "<words>"\`, then
|
|
278
|
+
\`assets add <id> <item>\`, copied in, never hot-linked), then the person's own files (\`assets add <id> --file …
|
|
279
|
+
--license …\`), then generated props on the person's own fal account under a budget with receipts (the plugin's
|
|
280
|
+
\`models\` skill). Every model is checked (no external URIs, no oversized files), made phone-sized and recorded
|
|
281
|
+
with its licence; \`RIGHTS.md\` and the credits page follow. \`assets check <id>\` holds a game to the phone
|
|
282
|
+
budgets; \`assets lineup <id>\` shows everything at true scale. A three.js game loads models through
|
|
283
|
+
\`@homie-rocks/studio/assets\` (\`createModels\`); \`game new <id> --from gem-rush-3d\` starts one.
|
|
284
|
+
- **Licences**: a public game ships only assets whose licence allows it (CC0, CC BY with credit, the studio's own,
|
|
285
|
+
generated); \`publish\` refuses an asset with no licence record. A remix gets each redistributable model,
|
|
286
|
+
checked by SHA-256, and a grey placeholder for the rest.
|
|
287
|
+
|
|
267
288
|
## Running live games (the back office)
|
|
268
289
|
|
|
269
290
|
- The owner runs the studio's live games from \`/_studio/office\` (\`npx --no-install homie-studio office link\` gives
|
|
@@ -279,7 +300,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
279
300
|
- The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
|
|
280
301
|
Mute, Kick; Announce). Nobody else's page has it.
|
|
281
302
|
- **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
|
|
282
|
-
\`office invite <id>\` happen at once; \`office kick\`, \`office close\` and \`office launch\` only ASK, and print a
|
|
303
|
+
\`office invite <id>\` happen at once; \`office kick\`, \`office mute\`, \`office close\` and \`office launch\` only ASK, and print a
|
|
283
304
|
one-time link that opens the ask in the owner's own browser, where one tap does it. An ask is not done until the
|
|
284
305
|
owner tapped. \`office key\` gives a key for the Homie MCP's owner tools (\`studio_office\`, \`room_announce\`,
|
|
285
306
|
\`room_kick\`, \`room_close\`, \`game_launch_state\`), which ask the same way; never paste a key anywhere else.
|
|
@@ -337,6 +358,11 @@ chat's card follows the work from then on) and, for a studio still being set up,
|
|
|
337
358
|
studio.json as \`cloudflare.domain\`.
|
|
338
359
|
- A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
|
|
339
360
|
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
361
|
+
- Optional in studio.json: \`"protect"\` (globs of files the owner wants to look at before any change, such as
|
|
362
|
+
\`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
|
|
363
|
+
in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
|
|
364
|
+
the budget until the person says Proceed, and takes keys out of command output. When it refuses a call, say what
|
|
365
|
+
you meant to do and why; do not retry it unless the person asks.
|
|
340
366
|
|
|
341
367
|
## Beta
|
|
342
368
|
|
|
@@ -480,6 +506,11 @@ music/**/*.flac
|
|
|
480
506
|
videos/**/*.mp4
|
|
481
507
|
videos/**/*.mov
|
|
482
508
|
videos/**/*.webm
|
|
509
|
+
# 3D: raw provider output, high-poly sources and Blender files stay on this computer (or the studio's R2): never shipped, never committed. The phone-sized model in games/<id>/public/models/ is committed.
|
|
510
|
+
art/**/raw/
|
|
511
|
+
*.blend
|
|
512
|
+
*.blend1
|
|
513
|
+
*.fbx
|
|
483
514
|
`;
|
|
484
515
|
|
|
485
516
|
/** Every file a new studio gets, relative to its folder. */
|
package/lib/site.mjs
CHANGED
|
@@ -417,7 +417,12 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
417
417
|
|
|
418
418
|
const words = L.players && typeof L.players === 'object' ? { one: str(L.players.one, 24), many: str(L.players.many, 24) } : null;
|
|
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
|
+
const style = !L.theme ? readJson(join(g.dir, 'style.json'))?.palette : null;
|
|
422
|
+
// Its accents only: the page keeps the studio's light or dark scheme, so its text always reads.
|
|
423
|
+
const fromStyle = style && typeof style === 'object' ? { accent: style.accent, glow: style.accent2 } : null;
|
|
424
|
+
const raw = L.theme && typeof L.theme === 'object' ? L.theme : fromStyle;
|
|
425
|
+
const theme = raw ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(raw[k] ?? ''))).map((k) => [k, raw[k]])) : null;
|
|
421
426
|
if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
|
|
422
427
|
// landing.scheme: "light" for a white or cream game (its hero is tinted light and its words dark), "dark" for a
|
|
423
428
|
// dark game in a light studio. Anything else follows the studio's look.
|
package/lib/studio.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* current directory, and its games.
|
|
4
4
|
*/
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
|
|
9
9
|
|
|
@@ -115,17 +115,24 @@ function renameTakeSaves(dir, from, id) {
|
|
|
115
115
|
if (next !== text) writeFileSync(file, next);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
118
|
+
/**
|
|
119
|
+
* `homie-studio game new <id> --from <starter> --name "<Name>"`: a starter copied in as this studio's own game. A starter
|
|
120
|
+
* whose game.json says `"assets": "library"` gets its models from the starter library here (fetchStarterModels).
|
|
121
|
+
*/
|
|
122
|
+
export async function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
120
123
|
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
|
|
121
124
|
const src = join(PACKAGE_ROOT, 'starters', from);
|
|
122
125
|
if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
|
|
123
126
|
const dest = join(root, 'games', id);
|
|
124
127
|
if (existsSync(join(dest, 'game.json'))) throw new Error(`games/${id} already exists; pick another id or change that game`);
|
|
125
|
-
// A planned game (its folder holds the Game Codex, maybe art, and no game.json yet): the
|
|
126
|
-
// what is there, and nothing of the plan is overwritten.
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
// A planned game (its folder holds the Game Codex, maybe its art direction and models, and no game.json yet): the
|
|
129
|
+
// starter goes in around what is there, and nothing of the plan is overwritten. Its style.json (from its own
|
|
130
|
+
// decisions) and credits.json stay; the starter's models join its assets/manifest.json beside the game's own.
|
|
131
|
+
const ADOPT = ['style.json', 'credits.json', 'assets'];
|
|
132
|
+
const planned = existsSync(dest) ? readdirSync(dest).filter((f) => existsSync(join(src, f))) : [];
|
|
133
|
+
const clash = planned.filter((f) => !ADOPT.includes(f));
|
|
134
|
+
if (clash.length) throw new Error(`games/${id} holds files of its own that a starter would overwrite (${clash.join(', ')}); pick another id`);
|
|
135
|
+
cpSync(src, dest, { recursive: true, filter: (from) => !planned.some((f) => from === join(src, f) || from.startsWith(join(src, f) + sep)) });
|
|
129
136
|
const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
|
|
130
137
|
meta.id = id;
|
|
131
138
|
if (name) meta.name = String(name).slice(0, 60);
|
|
@@ -136,7 +143,113 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
|
136
143
|
renameTakeSaves(dest, from, id);
|
|
137
144
|
const html = join(dest, 'index.html');
|
|
138
145
|
if (name && existsSync(html)) writeFileSync(html, readFileSync(html, 'utf8').replace(/<title>[^<]*<\/title>/, `<title>${String(name).replace(/[<&]/g, '')}</title>`));
|
|
139
|
-
|
|
146
|
+
const merged = planned.includes('assets') && existsSync(join(src, 'assets', 'manifest.json')) ? await adoptStarterAssets(root, id, src) : null;
|
|
147
|
+
const needs = addNeeds(root, meta.needs);
|
|
148
|
+
const models = meta.assets === 'library' ? await fetchStarterModels(dest, merged) : null;
|
|
149
|
+
// A starter with models carries their rights: assets/RIGHTS.md and the credits, written again for this game.
|
|
150
|
+
if (existsSync(join(dest, 'assets', 'manifest.json'))) {
|
|
151
|
+
const { readManifest, syncCredits, writeRights } = await import('./asset-manifest.mjs');
|
|
152
|
+
const manifest = readManifest(root, id);
|
|
153
|
+
writeRights(root, id, manifest);
|
|
154
|
+
syncCredits(root, id, manifest);
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...needs, ...(models ? { models } : {}) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A planned game that already has models: the starter's entries join its assets/manifest.json (an id the game
|
|
161
|
+
* already uses keeps the game's own). The game's code draws them under the game's current decisions (it reads
|
|
162
|
+
* style.json), so each adopted entry is recorded as made under those revisions, not the starter's. Returns the
|
|
163
|
+
* adopted ids (the only ones fetched).
|
|
164
|
+
*/
|
|
165
|
+
async function adoptStarterAssets(root, id, src) {
|
|
166
|
+
const { readManifest, writeManifest } = await import('./asset-manifest.mjs');
|
|
167
|
+
const { readDecisions, revisionsFor, setDecision } = await import('./decisions.mjs');
|
|
168
|
+
const mine = readManifest(root, id);
|
|
169
|
+
const theirs = JSON.parse(readFileSync(join(src, 'assets', 'manifest.json'), 'utf8'));
|
|
170
|
+
// The starter is dressed from one library family: an automatic pick follows it (one family a game); one the person
|
|
171
|
+
// chose stays, and the lineup flags the starter's models until they are swapped.
|
|
172
|
+
const family = (theirs.assets ?? []).map((a) => String(a.from?.pack ?? '').split('-')[0]).find(Boolean);
|
|
173
|
+
const before = readDecisions(root, id)?.decisions?.['cast.family'];
|
|
174
|
+
if (family && before && before.value !== family && before.state === 'auto') {
|
|
175
|
+
setDecision(root, id, 'cast.family', family, { by: 'ai', why: `the starter's models are ${family}; one family a game, so every piece fits` });
|
|
176
|
+
}
|
|
177
|
+
const doc = readDecisions(root, id);
|
|
178
|
+
const adopted = [];
|
|
179
|
+
for (const a of theirs.assets ?? []) {
|
|
180
|
+
if (mine.assets.some((x) => x.id === a.id)) continue;
|
|
181
|
+
if (doc && a.made?.under) a.made.under = revisionsFor(doc, Object.keys(a.made.under));
|
|
182
|
+
mine.assets.push(a);
|
|
183
|
+
adopted.push(a.id);
|
|
184
|
+
}
|
|
185
|
+
writeManifest(root, id, mine);
|
|
186
|
+
return adopted;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A STARTER'S MODELS COME FROM THE STARTER LIBRARY: the repository keeps no binaries, so such a starter ships only its
|
|
191
|
+
* assets/manifest.json, each model a library item (`from.item`) and the file it goes to. Each is fetched from the
|
|
192
|
+
* library (HOMIE_LIBRARY: a folder or an address; homie.rocks by default), checked by the index's SHA-256 and the GLB
|
|
193
|
+
* safety rules (lib/library.mjs fetchItemFile), and written there; the manifest keeps the SHA-256 of what was written.
|
|
194
|
+
* When the library cannot be reached the models stay out, `why` says so, and the game draws its stand-ins until they
|
|
195
|
+
* come (`homie-studio assets add <game> <item> --as <asset>`). Returns { fetched, missing: [{ asset, item, why }], from }.
|
|
196
|
+
*/
|
|
197
|
+
async function fetchStarterModels(dest, only = null) {
|
|
198
|
+
const file = join(dest, 'assets', 'manifest.json');
|
|
199
|
+
let manifest;
|
|
200
|
+
try { manifest = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
201
|
+
const wanted = (manifest.assets ?? []).filter((a) => (!only || only.includes(a.id)) && a.route === 'library' && a.from?.item && (a.files ?? []).some((f) => f.role === 'model' && /^public\/models\/[a-z0-9][a-z0-9-]{0,47}\.glb$/.test(String(f.path))));
|
|
202
|
+
if (!wanted.length) return null;
|
|
203
|
+
const { fetchItemFile, libraryBase, loadIndex } = await import('./library.mjs');
|
|
204
|
+
const lib = libraryBase();
|
|
205
|
+
let index;
|
|
206
|
+
try { index = (await loadIndex({ lib })).index; } catch (error) {
|
|
207
|
+
return { fetched: 0, missing: wanted.map((a) => ({ asset: a.id, item: a.from.item, why: 'no library' })), why: error.message, from: lib.base };
|
|
208
|
+
}
|
|
209
|
+
const missing = [];
|
|
210
|
+
let fetched = 0;
|
|
211
|
+
for (const a of wanted) {
|
|
212
|
+
const item = index.items.find((x) => x.id === a.from.item);
|
|
213
|
+
const f = a.files.find((x) => x.role === 'model');
|
|
214
|
+
if (!item) { missing.push({ asset: a.id, item: a.from.item, why: `the library has no ${a.from.item}` }); continue; }
|
|
215
|
+
try {
|
|
216
|
+
const { bytes, sha256 } = await fetchItemFile(lib, item);
|
|
217
|
+
mkdirSync(dirname(join(dest, f.path)), { recursive: true });
|
|
218
|
+
writeFileSync(join(dest, f.path), bytes);
|
|
219
|
+
Object.assign(f, { bytes: bytes.byteLength, sha256 });
|
|
220
|
+
a.from.sha256 = sha256;
|
|
221
|
+
fetched += 1;
|
|
222
|
+
} catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
|
|
223
|
+
}
|
|
224
|
+
writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
225
|
+
return { fetched, missing, from: lib.base };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* WHAT A STARTER NEEDS FROM NPM. A starter's game.json may say `"needs": { "three": "0.185.1" }`: libraries its code
|
|
230
|
+
* imports that a studio does not have yet. Each one the studio's package.json lacks (in dependencies or
|
|
231
|
+
* devDependencies) goes into devDependencies at that exact version, so the game bundles one copy of it (a second
|
|
232
|
+
* three.js beside the one @homie-rocks/studio/assets imports would be two engines). A version the studio already
|
|
233
|
+
* pins is left as it is and named in `needsHeld`. `needs` stays in the game's game.json: it is what the game was
|
|
234
|
+
* written against. Returns { needs, needsAdded, needsHeld, installNeeded }.
|
|
235
|
+
*/
|
|
236
|
+
export function addNeeds(root, needs) {
|
|
237
|
+
const want = needs && typeof needs === 'object' && !Array.isArray(needs) ? Object.entries(needs).filter(([n, v]) => /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]{0,213}$/.test(n) && /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(String(v))) : [];
|
|
238
|
+
if (!want.length) return {};
|
|
239
|
+
const file = join(root, 'package.json');
|
|
240
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
241
|
+
const added = []; const held = [];
|
|
242
|
+
for (const [n, v] of want) {
|
|
243
|
+
const have = pkg.dependencies?.[n] ?? pkg.devDependencies?.[n];
|
|
244
|
+
if (have) { if (have !== v) held.push({ name: n, want: v, have }); continue; }
|
|
245
|
+
pkg.devDependencies = { ...(pkg.devDependencies ?? {}), [n]: String(v) };
|
|
246
|
+
added.push({ name: n, version: String(v) });
|
|
247
|
+
}
|
|
248
|
+
if (added.length) {
|
|
249
|
+
pkg.devDependencies = Object.fromEntries(Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
|
|
250
|
+
writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
251
|
+
}
|
|
252
|
+
return { needs: Object.fromEntries(want), needsAdded: added, needsHeld: held, installNeeded: added.length > 0 };
|
|
140
253
|
}
|
|
141
254
|
|
|
142
255
|
/**
|
|
@@ -188,5 +301,19 @@ export async function remixGame(root, source, id, { name } = {}) {
|
|
|
188
301
|
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
189
302
|
if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
190
303
|
if (from) renameTakeSaves(dest, from, id);
|
|
191
|
-
|
|
304
|
+
// Its models and pictures (0.22.0): fetched from the original site and checked by SHA-256 where their licence lets a
|
|
305
|
+
// remix carry them; a grey placeholder of the same size where it does not (lib/remix-assets.mjs).
|
|
306
|
+
let assets = null;
|
|
307
|
+
if (existsSync(join(dest, 'assets', 'manifest.json'))) {
|
|
308
|
+
const { fetchRemixAssets } = await import('./remix-assets.mjs');
|
|
309
|
+
assets = await fetchRemixAssets(root, id, url, { credit: { studio: credit.studio ?? null, game: credit.game ?? meta.name ?? from } });
|
|
310
|
+
}
|
|
311
|
+
// What its code imports beyond the toolkit (a 3D game: three.js). A remix source is someone else's text, so only the
|
|
312
|
+
// libraries the toolkit's own starters use are added; anything else is named for the person to decide.
|
|
313
|
+
const asked = meta.needs && typeof meta.needs === 'object' && !Array.isArray(meta.needs) ? meta.needs : {};
|
|
314
|
+
const refusedNeeds = Object.keys(asked).filter((n) => !REMIX_NEEDS.has(n));
|
|
315
|
+
const needs = addNeeds(root, Object.fromEntries(Object.entries(asked).filter(([n]) => REMIX_NEEDS.has(n))));
|
|
316
|
+
return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote, ...(assets ? { assets } : {}), ...needs, ...(refusedNeeds.length ? { needsNotAdded: refusedNeeds } : {}) };
|
|
192
317
|
}
|
|
318
|
+
/** The libraries a remix may add to a studio's package.json by itself (the ones the toolkit's starters use). */
|
|
319
|
+
const REMIX_NEEDS = new Set(['three']);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STYLE BOARD AND THE LINEUP: pictures the engine draws for free (lib/render3d.mjs).
|
|
3
|
+
*
|
|
4
|
+
* styleBoard(root, id) three coherent directions (lib/decisions.mjs directionsFor), each drawn as the game would
|
|
5
|
+
* draw it: its palette, light, camera, material model, fonts and proportions, with the
|
|
6
|
+
* starter library family's own pieces re-tinted into its palette. Saved as
|
|
7
|
+
* games/<id>/codex/board/<a|b|c>-swatch.jpg (private, under 300 KB each), the board in
|
|
8
|
+
* decisions.json. A painted mood image per direction is optional and paid: the art skill
|
|
9
|
+
* makes it on the creator's own fal account and `style mood` records it, labelled a target.
|
|
10
|
+
* assetsLineup(root, id) every made asset at true scale on a 1 m grid under the game's light (front and
|
|
11
|
+
* three-quarter), silhouettes black on white at 64 px tall, and flags: over budget, palette
|
|
12
|
+
* drift (CIEDE2000 from the locked palette), an unreadable silhouette, another library family,
|
|
13
|
+
* stale. .studio/art/<id>/lineup-*.jpg and lineup.json.
|
|
14
|
+
*/
|
|
15
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { extname, join, relative } from 'node:path';
|
|
17
|
+
import { readManifest } from './asset-manifest.mjs';
|
|
18
|
+
import { assetsCheck } from './asset-check.mjs';
|
|
19
|
+
import { mix, nearest } from './colour.mjs';
|
|
20
|
+
import { directionsFor, initDecisions, labelOf, lightColours, readDecisions, staleAssets, styleTokens, writeDecisions, addLatestLine } from './decisions.mjs';
|
|
21
|
+
import { PALETTE_KEYS } from './style-presets.mjs';
|
|
22
|
+
import { loadIndex, searchLibrary, fetchItemFile, libraryBase } from './library.mjs';
|
|
23
|
+
import { modelIn, saveDataUrl, withRenderer, dataUrlBytes } from './render3d.mjs';
|
|
24
|
+
|
|
25
|
+
const BOARD_DIR = join('codex', 'board');
|
|
26
|
+
const PICTURE_MAX = 300 * 1024;
|
|
27
|
+
|
|
28
|
+
/** A direction's (or a game's) style values as the render page's tokens. */
|
|
29
|
+
export function tokensOf(values) {
|
|
30
|
+
const ui = values['style.ui'] ?? {};
|
|
31
|
+
return {
|
|
32
|
+
palette: values['style.palette'], light: values['style.light'], camera: values['style.camera'], render: values['style.render'],
|
|
33
|
+
materials: values['style.materials'], fonts: { display: ui.display, body: ui.body }, shape: values['style.shape'], proportions: values['style.proportions'],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Up to `n` library pieces of a family that suit the game's words, as model inputs (empty when no library is reachable). */
|
|
38
|
+
async function libraryPieces(family, query, n, log) {
|
|
39
|
+
try {
|
|
40
|
+
const { index, lib } = await loadIndex({ lib: libraryBase() });
|
|
41
|
+
const picks = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const q of [query, 'tree', 'rock', 'bush plant', 'crate barrel']) {
|
|
44
|
+
for (const hit of searchLibrary(index, q, { family, limit: 6 })) {
|
|
45
|
+
if (picks.length >= n) break;
|
|
46
|
+
const it = hit.item;
|
|
47
|
+
if (seen.has(it.id) || it.kind === 'texture' || it.kind === 'sky' || it.rigged) continue;
|
|
48
|
+
seen.add(it.id);
|
|
49
|
+
try { const { bytes } = await fetchItemFile(lib, it); picks.push(modelIn(it.id, bytes, { label: it.name })); } catch (error) { log(`library ${it.id}: ${error.message}`); }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return picks;
|
|
53
|
+
} catch (error) { log(`no library pieces: ${error.message}`); return []; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Save a data: URL picture under `max` bytes (re-encoding JPEG quality down if it is over). */
|
|
57
|
+
async function savePicture(url, file, max = PICTURE_MAX) {
|
|
58
|
+
let bytes = saveDataUrl(url, file);
|
|
59
|
+
if (bytes <= max) return bytes;
|
|
60
|
+
try {
|
|
61
|
+
const sharp = (await import('sharp')).default;
|
|
62
|
+
for (const q of [78, 68, 58]) {
|
|
63
|
+
const out = await sharp(dataUrlBytes(url).bytes).jpeg({ quality: q, mozjpeg: true }).toBuffer();
|
|
64
|
+
writeFileSync(file, out);
|
|
65
|
+
bytes = out.byteLength;
|
|
66
|
+
if (bytes <= max) break;
|
|
67
|
+
}
|
|
68
|
+
} catch { /* kept as it is */ }
|
|
69
|
+
return bytes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function styleBoard(root, id, { prompt = '', log = () => {}, library = true } = {}) {
|
|
73
|
+
let doc = readDecisions(root, id);
|
|
74
|
+
if (!doc) { initDecisions(root, id, { prompt, path: 'hands-on' }); doc = readDecisions(root, id); }
|
|
75
|
+
const { directions } = directionsFor(root, id, { prompt: prompt || doc.prompt });
|
|
76
|
+
let game = {};
|
|
77
|
+
try { game = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { game = {}; }
|
|
78
|
+
const title = game.name ?? id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
79
|
+
const words = [prompt, doc.prompt, (doc.decisions['cast.list']?.value ?? []).map((c) => c.id).join(' ')].join(' ');
|
|
80
|
+
const dir = join(root, 'games', id, BOARD_DIR);
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
const pieces = {};
|
|
83
|
+
if (library) for (const d of directions) if (!pieces[d.family]) pieces[d.family] = await libraryPieces(d.family, words, 4, log);
|
|
84
|
+
const out = [];
|
|
85
|
+
const renderer = await withRenderer(async (r) => {
|
|
86
|
+
for (const d of directions) {
|
|
87
|
+
const url = await r.swatch(tokensOf(d.values), pieces[d.family] ?? [], { title });
|
|
88
|
+
const file = join(dir, `${d.id}-swatch.jpg`);
|
|
89
|
+
const bytes = await savePicture(url, file);
|
|
90
|
+
out.push({ ...d, swatch: relative(join(root, 'games', id), file), bytes, library: (pieces[d.family] ?? []).map((m) => m.id) });
|
|
91
|
+
}
|
|
92
|
+
return r.renderer;
|
|
93
|
+
}, { log });
|
|
94
|
+
const prev = doc.board?.directions ?? [];
|
|
95
|
+
doc = readDecisions(root, id);
|
|
96
|
+
// A pick survives a redraw only while its letter still names the same direction.
|
|
97
|
+
const was = prev.find((p) => p.id === doc.board?.chosen);
|
|
98
|
+
const still = was && out.some((d) => d.id === was.id && d.label === was.label);
|
|
99
|
+
doc.board = {
|
|
100
|
+
at: new Date().toISOString(), renderer, chosen: still ? was.id : null,
|
|
101
|
+
directions: out.map((d) => ({ id: d.id, label: d.label, family: d.family, values: d.values, swatch: d.swatch.split('\\').join('/'), library: d.library, mood: prev.find((p) => p.id === d.id && p.label === d.label)?.mood ?? null })),
|
|
102
|
+
};
|
|
103
|
+
writeDecisions(root, id, doc);
|
|
104
|
+
return { ok: true, command: 'style board', id, title, renderer, directions: boardView(root, id, doc) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The board as cards and the CLI show it: per direction its rows (decision, label), swatch path and mood. */
|
|
108
|
+
export function boardView(root, id, doc = readDecisions(root, id)) {
|
|
109
|
+
return (doc?.board?.directions ?? []).map((d) => ({
|
|
110
|
+
id: d.id, label: d.label, family: d.family, swatch: d.swatch, mood: d.mood,
|
|
111
|
+
chosen: doc.board.chosen === d.id,
|
|
112
|
+
colours: PALETTE_KEYS.map((k) => d.values['style.palette']?.[k]).filter(Boolean),
|
|
113
|
+
fonts: { display: d.values['style.ui']?.display, body: d.values['style.ui']?.body },
|
|
114
|
+
rows: Object.entries(d.values).map(([k, v]) => ({ decision: k, label: labelOf(k, v), same: JSON.stringify(doc.decisions[k]?.value) === JSON.stringify(v), state: doc.decisions[k]?.state ?? null })),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `style mood <id> <dir> --image <file>`: a painted mood image for one direction (made and paid for by the art skill). */
|
|
119
|
+
export async function recordMood(root, id, dirId, image, { usd = null, receipt = null, model = null } = {}) {
|
|
120
|
+
const doc = readDecisions(root, id);
|
|
121
|
+
const d = doc?.board?.directions?.find((x) => x.id === dirId);
|
|
122
|
+
if (!d) throw new Error(`no direction "${dirId}" on the style board (style board ${id} draws it first)`);
|
|
123
|
+
if (!existsSync(image)) throw new Error(`no picture at ${image}`);
|
|
124
|
+
const file = join(root, 'games', id, BOARD_DIR, `${dirId}-mood.jpg`);
|
|
125
|
+
const sharp = (await import('sharp')).default;
|
|
126
|
+
let q = 82;
|
|
127
|
+
let out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer();
|
|
128
|
+
while (out.byteLength > PICTURE_MAX && q > 50) { q -= 10; out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer(); }
|
|
129
|
+
writeFileSync(file, out);
|
|
130
|
+
d.mood = { path: relative(join(root, 'games', id), file).split('\\').join('/'), usd: usd === null ? null : Number(usd), receipt, model, label: 'target (painted, not what the game draws)', at: new Date().toISOString() };
|
|
131
|
+
writeDecisions(root, id, doc);
|
|
132
|
+
return { ok: true, command: 'style mood', id, direction: dirId, mood: d.mood };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `style golden <id> add <image>`: one of two to six golden images (references for every concept call). */
|
|
136
|
+
export async function addGolden(root, id, image, { from = null } = {}) {
|
|
137
|
+
const doc = readDecisions(root, id);
|
|
138
|
+
if (!doc) throw new Error(`games/${id} has no decisions yet`);
|
|
139
|
+
if (!existsSync(image)) throw new Error(`no picture at ${image}`);
|
|
140
|
+
doc.golden = doc.golden ?? [];
|
|
141
|
+
if (doc.golden.length >= 6) throw new Error('six golden images already: remove one first (style golden <id> remove <n>)');
|
|
142
|
+
const n = doc.golden.reduce((m, g) => Math.max(m, Number(/(\d+)\.jpg$/.exec(g.path)?.[1] ?? 0)), 0) + 1;
|
|
143
|
+
const dir = join(root, 'games', id, BOARD_DIR, 'golden');
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const file = join(dir, `${n}.jpg`);
|
|
146
|
+
try {
|
|
147
|
+
const sharp = (await import('sharp')).default;
|
|
148
|
+
writeFileSync(file, await sharp(readFileSync(image)).resize(1024, 1024, { fit: 'inside' }).jpeg({ quality: 84, mozjpeg: true }).toBuffer());
|
|
149
|
+
} catch { if (/\.jpe?g$/i.test(extname(image))) copyFileSync(image, file); else throw new Error('sharp could not read that picture'); }
|
|
150
|
+
doc.golden.push({ path: relative(join(root, 'games', id), file).split('\\').join('/'), from: from ?? relative(root, image), approvedBy: 'person', at: new Date().toISOString() });
|
|
151
|
+
writeDecisions(root, id, doc);
|
|
152
|
+
addLatestLine(root, id, `Added golden image ${n} (from ${from ?? relative(root, image)}): every concept is made with it as a reference.`);
|
|
153
|
+
return { ok: true, command: 'style golden', id, golden: doc.golden };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ the lineup */
|
|
157
|
+
|
|
158
|
+
const DRIFT = 18; // mean CIEDE2000 (area-weighted) from the palette above which an asset is flagged
|
|
159
|
+
|
|
160
|
+
export async function assetsLineup(root, id, { log = () => {} } = {}) {
|
|
161
|
+
const manifest = readManifest(root, id);
|
|
162
|
+
const doc = readDecisions(root, id);
|
|
163
|
+
const tokens = styleTokens(doc) ?? { palette: { bg: '#20242c', ink: '#f2f2f2', accent: '#ffcf5a', accent2: '#7dffb0', danger: '#ff5d5d', good: '#5fdc8b', gold: '#f2c14e', ramp: [] } };
|
|
164
|
+
const models = [];
|
|
165
|
+
for (const a of manifest.assets) {
|
|
166
|
+
const f = (a.files ?? []).find((x) => x.role === 'model');
|
|
167
|
+
if (!f) continue;
|
|
168
|
+
const abs = join(root, 'games', id, f.path);
|
|
169
|
+
if (!existsSync(abs)) continue;
|
|
170
|
+
// `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
|
|
171
|
+
// lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
|
|
172
|
+
const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
|
|
173
|
+
models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
|
|
174
|
+
}
|
|
175
|
+
if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
|
|
176
|
+
const dir = join(root, '.studio', 'art', id);
|
|
177
|
+
mkdirSync(dir, { recursive: true });
|
|
178
|
+
const shot = await withRenderer(async (r) => ({ renderer: r.renderer, ...(await r.lineup(models.map((m) => m.input), tokens)) }), { log });
|
|
179
|
+
const images = { front: join(dir, 'lineup-front.jpg'), quarter: join(dir, 'lineup-quarter.jpg'), silhouettes: join(dir, 'lineup-silhouettes.png') };
|
|
180
|
+
saveDataUrl(shot.front, images.front); saveDataUrl(shot.quarter, images.quarter); saveDataUrl(shot.silhouettes, images.silhouettes);
|
|
181
|
+
const palette = [...PALETTE_KEYS.map((k) => tokens.palette[k]), ...(tokens.palette.ramp ?? [])].filter(Boolean);
|
|
182
|
+
const family = doc?.decisions?.['cast.family']?.value ?? null;
|
|
183
|
+
const check = await assetsCheck(root, id, { validate: false, write: true }).catch(() => null);
|
|
184
|
+
const stale = doc ? staleAssets(doc, manifest) : [];
|
|
185
|
+
const { albedoOf } = await import('./optimise.mjs');
|
|
186
|
+
const rows = [];
|
|
187
|
+
for (const m of models) {
|
|
188
|
+
const flags = [];
|
|
189
|
+
let drift = null;
|
|
190
|
+
try {
|
|
191
|
+
const colours = await albedoOf(m.abs);
|
|
192
|
+
// A model the game pulls toward the palette (inGame.pull) is scored on the colours it is drawn in.
|
|
193
|
+
const pull = Math.max(0, Math.min(1, Number(m.a.inGame?.pull) || 0));
|
|
194
|
+
const drawn = colours.map((c) => (pull ? { ...c, hex: mix(c.hex, nearest(c.hex, palette).hex, pull) } : c));
|
|
195
|
+
const scored = drawn.map((c) => { const n = nearest(c.hex, palette); return { hex: c.hex, share: c.share, palette: n.hex, delta: +n.delta.toFixed(1) }; });
|
|
196
|
+
const mean = scored.reduce((n, c) => n + c.delta * c.share, 0) / Math.max(0.0001, scored.reduce((n, c) => n + c.share, 0));
|
|
197
|
+
drift = { mean: +mean.toFixed(1), colours: scored };
|
|
198
|
+
if (m.a.inGame?.repaint) drift.repaint = m.a.inGame.repaint;
|
|
199
|
+
else if (mean > DRIFT) flags.push(`palette drift: its colours sit ${drift.mean} from the palette (over ${DRIFT}); a re-tint (free) or a remake`);
|
|
200
|
+
} catch (error) { log(`${m.a.id}: colours not read (${error.message})`); }
|
|
201
|
+
const row = shot.rows.find((x) => x.id === m.a.id);
|
|
202
|
+
if (row && ['character', 'creature'].includes(m.a.kind) && (row.silhouette.widthPx < 6 || row.silhouette.fill > 0.92)) flags.push(`silhouette: ${row.silhouette.widthPx < 6 ? 'too thin to read at 64 px' : 'a blob at 64 px (no limbs, ears or tail break the outline)'}`);
|
|
203
|
+
const c = check?.rows?.find((x) => x.id === m.a.id);
|
|
204
|
+
if (c && !c.ok) flags.push(`over budget: ${c.problems[0]}`);
|
|
205
|
+
if (family && m.a.route === 'library' && m.a.from?.pack && !String(m.a.from.pack).startsWith(family)) flags.push(`another library family (${m.a.from.pack}; the game's is ${family}): mixing families shows`);
|
|
206
|
+
if (stale.some((s) => s.id === m.a.id)) flags.push('stale: made under an older decision');
|
|
207
|
+
rows.push({ id: m.a.id, label: m.input.label, kind: m.a.kind, route: m.a.route, size: row?.size ?? null, silhouette: row?.silhouette ?? null, drift, flags });
|
|
208
|
+
}
|
|
209
|
+
const result = { ok: true, command: 'assets lineup', id, renderer: shot.renderer, images: Object.fromEntries(Object.entries(images).map(([k, v]) => [k, relative(root, v)])), rows, palette: palette.slice(0, 7), family, at: new Date().toISOString(), flagged: rows.filter((r) => r.flags.length).length };
|
|
210
|
+
writeFileSync(join(dir, 'lineup.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `assets review <id> --score n --outliers a,b`: a fresh reviewer's "one game?" score, and the assets it named. */
|
|
215
|
+
export function recordReview(root, id, { score, outliers = [], note = null, by = 'reviewer' } = {}) {
|
|
216
|
+
const s = Number(score);
|
|
217
|
+
if (!(s >= 1 && s <= 10)) throw new Error('--score 1 to 10 ("does it look like one game?")');
|
|
218
|
+
const dir = join(root, '.studio', 'art', id);
|
|
219
|
+
mkdirSync(dir, { recursive: true });
|
|
220
|
+
const manifest = readManifest(root, id);
|
|
221
|
+
const needs = new Set(outliers.filter(Boolean));
|
|
222
|
+
if (s < 7) for (const a of manifest.assets) if (a.route !== 'procedural') needs.add(a.id);
|
|
223
|
+
const review = { score: s, outliers: [...needs], note, by, at: new Date().toISOString(), verdict: s >= 7 && !outliers.length ? 'one game' : 'needs review' };
|
|
224
|
+
writeFileSync(join(dir, 'review.json'), `${JSON.stringify(review, null, 2)}\n`);
|
|
225
|
+
return { ok: true, command: 'assets review', id, review };
|
|
226
|
+
}
|
|
227
|
+
|