@homie-rocks/studio 0.24.5 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +56 -6
  3. package/assets/animate.ts +521 -0
  4. package/assets/render-page.ts +77 -4
  5. package/bin/homie-studio.mjs +13 -4
  6. package/lib/art-cli.mjs +105 -5
  7. package/lib/art-tools.mjs +168 -1
  8. package/lib/asset-check.mjs +72 -5
  9. package/lib/characters.mjs +380 -0
  10. package/lib/clips.mjs +417 -0
  11. package/lib/decisions.mjs +38 -8
  12. package/lib/mcp-tools.mjs +1 -1
  13. package/lib/mcp.mjs +2 -2
  14. package/lib/optimise.mjs +79 -3
  15. package/lib/perf.mjs +15 -7
  16. package/lib/render3d.mjs +2 -0
  17. package/lib/rig.mjs +431 -0
  18. package/lib/scaffold.mjs +7 -0
  19. package/lib/site.mjs +14 -3
  20. package/lib/studio.mjs +18 -3
  21. package/lib/style-board.mjs +3 -1
  22. package/lib/template-history.json +78 -0
  23. package/mcp/ui/animation.js +56 -0
  24. package/mcp/ui/card.css +4 -0
  25. package/mcp/ui/cast.js +36 -2
  26. package/mcp/ui/lineup.js +7 -7
  27. package/netplay/NETPLAY.md +38 -2
  28. package/netplay/netplay.ts +58 -3
  29. package/package.json +5 -1
  30. package/site/SITE.md +19 -1
  31. package/starters/gem-rush/game.json +2 -1
  32. package/starters/gem-rush-3d/game.json +2 -1
  33. package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
  34. package/starters/hero-rush-3d/assets/manifest.json +1471 -0
  35. package/starters/hero-rush-3d/credits.json +40 -0
  36. package/starters/hero-rush-3d/game.json +12 -0
  37. package/starters/hero-rush-3d/index.html +20 -0
  38. package/starters/hero-rush-3d/lab.json +32 -0
  39. package/starters/hero-rush-3d/src/main.ts +2588 -0
  40. package/starters/hero-rush-3d/style.json +68 -0
  41. package/starters/hero-rush-3d/tunables.json +32 -0
  42. package/worker/arrival.mjs +207 -0
  43. package/worker/pages.mjs +23 -5
  44. package/worker/site.mjs +26 -4
  45. package/worker/version.mjs +1 -1
@@ -219,6 +219,12 @@
219
219
  * glTF-Validator, licences, stale assets, big files in git
220
220
  * homie-studio assets lineup|review|rights|stale|remove <id> … the lineup (true scale, silhouettes, palette drift),
221
221
  * a reviewer's score, RIGHTS.md, what a changed decision made stale
222
+ * homie-studio cast <id> the characters: proportions, silhouette, palette, skeleton family, clips
223
+ * homie-studio anim [plan] <id> each character's clips against the verbs the game needs (idle, run, jump,
224
+ * attack, hit, die, ...) and where each comes from
225
+ * homie-studio anim add <id> <asset> --verbs jump,attack [--from <library item>] more verbs, retargeted onto its
226
+ * skeleton at build time into its clip library (public/anims/<skeleton>.glb)
227
+ * homie-studio anim preview <id> [--asset <asset>] looping previews of every clip (animated WebP) and a sheet
222
228
  *
223
229
  * homie-studio statusline the current build in one line (what Claude Code's status line shows)
224
230
  * homie-studio statusline --install [--project <folder>]
@@ -266,12 +272,12 @@ import { installStatusLine, statusLine } from '../lib/statusline.mjs';
266
272
  import { restartWithProxy } from '../lib/net.mjs';
267
273
  import { demoGames, formatDemo } from '../lib/demo.mjs';
268
274
  import { serveMcp } from '../lib/mcp.mjs';
269
- import { artLines, assetsCommand, styleCommand } from '../lib/art-cli.mjs';
275
+ import { animCommand, artLines, assetsCommand, castView, styleCommand } from '../lib/art-cli.mjs';
270
276
  import { formatHandoff, handoff } from '../lib/handoff.mjs';
271
277
 
272
278
  const argv = process.argv.slice(2);
273
279
  const flags = new Map();
274
- const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai'];
280
+ const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai'];
275
281
  const positional = [];
276
282
  for (let i = 0; i < argv.length; i++) {
277
283
  const a = argv[i];
@@ -287,10 +293,11 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
287
293
 
288
294
  function print(result) {
289
295
  if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
290
- if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
296
+ // A check that found problems still prints them (its rows say what to fix), never a bare "failed".
297
+ if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows) && !(result.command === 'assets check' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
291
298
  if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
292
299
  const lines = [];
293
- if (/^(style|assets)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
300
+ if (/^(style|assets|cast|anim)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
294
301
  switch (result.command) {
295
302
  case 'new':
296
303
  lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
@@ -686,6 +693,8 @@ async function main() {
686
693
  const root = requireStudio();
687
694
  if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
688
695
  if (cmd === 'assets') return assetsCommand(root, sub, positional, flags, { log });
696
+ if (cmd === 'anim') return animCommand(root, sub, positional, flags, { log });
697
+ if (cmd === 'cast') { const games = listGames(root); const id = sub ?? (games.length === 1 ? games[0].id : null); if (!id) return { ok: false, command: 'cast', why: `name the game: homie-studio cast <id>${games.length ? ` (${games.map((g) => g.id).join(', ')})` : ''}` }; return castView(root, id); }
689
698
  if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
690
699
  if (cmd === 'codex') return codexCommand(root, sub);
691
700
  if (cmd === 'progress') return progressCommand(root, sub);
package/lib/art-cli.mjs CHANGED
@@ -29,6 +29,14 @@
29
29
  * assets stale <id> assets made under an older decision
30
30
  * assets redo <id> <asset> made again from its kept raw file (free): today's budgets and render style
31
31
  * assets remove <id> <asset>
32
+ * assets add <id> <character item> [--keep "1H_Sword,Round_Shield"] [--verbs jump,attack] [--clips-from <item|file>]
33
+ * an animated library character (a rig, or parts with clips): the character pipeline (lib/characters.mjs)
34
+ * assets add <id> --file <rigged.glb> --kind character --rigged --license <kind> [--clips-from <item>]
35
+ *
36
+ * cast <id> the characters: proportions, silhouette, palette, skeleton family, source, clips
37
+ * anim [plan] <id> each character's clips against the verbs the game needs, and where each comes from
38
+ * anim add <id> <asset> --verbs jump,attack [--from <library item | file>] more verbs, retargeted onto its skeleton
39
+ * anim preview <id> [--asset <asset>] [--verbs idle,run] looping previews (animated WebP) and a sheet, free
32
40
  */
33
41
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
42
  import { join, resolve } from 'node:path';
@@ -36,6 +44,7 @@ import { licenceProblems, readManifest, removeAsset, rightsMarkdown, syncCredits
36
44
  import { CATALOGUE_IDS, PHASES, blastRadius, decisionRows, derivedPrompt, initDecisions, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
37
45
  import { listGames } from './studio.mjs';
38
46
  import { checkLines } from './asset-check.mjs';
47
+ import { animPlan as animPlanOf } from './characters.mjs';
39
48
 
40
49
  /** The game a command means: the one named, else the only one. */
41
50
  function gameOf(root, id, command) {
@@ -91,6 +100,13 @@ export function writeArtSummary(root, id) {
91
100
  spend: artSpend(root, id),
92
101
  check: check ? { ok: check.ok, at: check.at, totals: check.totals, budgets: { drawCalls: check.budgets?.drawCalls, triangles: check.budgets?.triangles, textureMB: check.budgets?.textureMB, firstPlayMB: check.budgets?.firstPlayMB }, failing: check.rows.filter((r) => !r.ok).map((r) => r.id) } : null,
93
102
  lineup: lineup ? { at: lineup.at, flagged: lineup.flagged, images: lineup.images } : null,
103
+ // The characters and their clips (lib/characters.mjs animPlan): the Art tab's Characters, /cast and /clips.
104
+ ...(() => {
105
+ try {
106
+ const plan = animPlanOf(root, id);
107
+ return { need: plan.verbs, characters: plan.rows.map((r) => ({ id: r.id, kind: r.kind, route: r.route, family: r.family, skeleton: r.skeleton, bones: r.bones, verbs: [...r.clips.filter((c) => c.have).map((c) => c.verb), ...r.extra], missing: r.missing, retargeted: r.clips.filter((c) => c.retargeted).length, animsKB: r.animsKB })), skinning: check?.skinning ? { players: check.skinning.players, vertices: check.skinning.vertices, bones: check.skinning.bones, budget: check.skinning.budget } : null };
108
+ } catch { return { need: [], characters: [], skinning: null }; }
109
+ })(),
94
110
  board: doc?.board ? { chosen: doc.board.chosen, directions: doc.board.directions.map((d) => ({ id: d.id, label: d.label, swatch: d.swatch, mood: d.mood?.path ?? null })) } : null,
95
111
  };
96
112
  writeFileSync(join(dir, 'latest.json'), `${JSON.stringify(summary, null, 2)}\n`);
@@ -100,6 +116,18 @@ export function writeArtSummary(root, id) {
100
116
 
101
117
  const list = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
102
118
 
119
+ /** A raw character file kept beside its art job (art/<slug>/raw/, git-ignored), as importModel keeps a prop's. */
120
+ async function keepRaw(root, file, slug) {
121
+ const { copyFileSync } = await import('node:fs');
122
+ const { basename } = await import('node:path');
123
+ const src = resolve(root, file);
124
+ const safe = String(slug).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'character';
125
+ const dir = join(root, 'art', safe, 'raw');
126
+ const to = join(dir, basename(src));
127
+ if (src !== to) { mkdirSync(dir, { recursive: true }); copyFileSync(src, to); }
128
+ return `../../art/${safe}/raw/${basename(src)}`;
129
+ }
130
+
103
131
  export async function styleCommand(root, sub, positional, flags, { log = () => {} } = {}) {
104
132
  const known = ['init', 'show', 'set', 'steer', 'lock', 'unlock', 'board', 'pick', 'mood', 'golden', 'blast', 'prompt'];
105
133
  const verb = known.includes(sub) ? sub : 'show';
@@ -165,15 +193,30 @@ export async function assetsCommand(root, sub, positional, flags, { log = () =>
165
193
  const doc = readDecisions(root, id);
166
194
  r = { ok: true, command: 'assets', id, assets: m.assets.map((a) => ({ id: a.id, kind: a.kind, route: a.route, tier: a.tier ?? null, card: a.card ?? null, license: a.license?.kind ?? null, remix: a.license?.remix ?? null, file: (a.files ?? []).find((f) => f.role === 'model')?.path ?? null, tris: a.measured?.tris ?? null, kb: a.measured?.glbKB ?? null, usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3) })), stale: doc ? staleAssets(doc, m).map((s) => s.id) : [], spend: artSpend(root, id) };
167
195
  } else if (verb === 'add') {
168
- if (flags.get('file')) {
196
+ const rigFlags = { keep: flags.has('keep') ? list(flags.get('keep')) : null, verbs: list(flags.get('verbs')), clipsFrom: flags.get('clips-from') ?? null, texture: flags.get('texture') ?? null, triangles: flags.get('triangles') ?? null };
197
+ const characterKind = ['character', 'creature'].includes(String(flags.get('kind') ?? ''));
198
+ if (flags.get('file') && (flags.has('rigged') && characterKind) && !flags.has('no-rig')) {
199
+ // A rigged character file (the person's own, or a provider's): the character pipeline, clips and all.
200
+ const { addCharacter } = await import('./characters.mjs');
201
+ let steps = [];
202
+ if (flags.get('steps')) steps = JSON.parse(readFileSync(resolve(String(flags.get('steps'))), 'utf8'));
203
+ const raw = await keepRaw(root, String(flags.get('file')), flags.get('slug') ?? flags.get('as') ?? 'character');
204
+ r = await addCharacter(root, id, { file: String(flags.get('file')), as: flags.get('as') ?? args[1] ?? null, kind: flags.get('kind'), card: flags.get('card') ?? null, height: flags.get('height') ?? null, license: flags.get('license') ?? null, attribution: flags.get('attribution') ?? null, owner: flags.get('owner') ?? null, notes: flags.get('notes') ?? null, route: flags.get('route') ?? 'imported', steps, concept: flags.get('concept') ?? null, rawPath: raw, ...rigFlags });
205
+ } else if (flags.get('file')) {
169
206
  const { importModel } = await import('./asset-import.mjs');
170
207
  let steps = [];
171
208
  if (flags.get('steps')) steps = JSON.parse(readFileSync(resolve(String(flags.get('steps'))), 'utf8'));
172
209
  r = await importModel(root, id, String(flags.get('file')), { as: flags.get('as') ?? args[1] ?? null, kind: flags.get('kind') ?? 'prop', tier: flags.get('tier') ?? null, card: flags.get('card') ?? null, height: flags.get('height') ?? null, triangles: flags.get('triangles') ?? null, texture: flags.get('texture') ?? null, license: flags.get('license') ?? null, attribution: flags.get('attribution') ?? null, notes: flags.get('notes') ?? null, owner: flags.get('owner') ?? null, route: flags.get('route') ?? 'imported', steps, concept: flags.get('concept') ?? null, slug: flags.get('slug') ?? null, rigged: flags.has('rigged') });
173
210
  } else {
174
211
  if (!args[1]) return { ok: false, command: 'assets add', why: 'usage: homie-studio assets add <game> <library item> (assets find "<words>" lists them), or --file <model> --license <kind>' };
175
- const { addFromLibrary, libraryBase } = await import('./library.mjs');
176
- r = await addFromLibrary(root, id, args[1], { as: flags.get('as') ?? null, height: flags.get('height') ?? null, card: flags.get('card') ?? null, lib: libraryBase(flags.get('library') ?? undefined) });
212
+ const { addFromLibrary, libraryBase, loadIndex } = await import('./library.mjs');
213
+ const lib = libraryBase(flags.get('library') ?? undefined);
214
+ const { index } = await loadIndex({ lib });
215
+ const item = index.items.find((x) => x.id === args[1]) ?? index.items.find((x) => x.item === args[1]);
216
+ const { addCharacter, isAnimated } = await import('./characters.mjs');
217
+ // An animated character (a rig, or parts with clips): phone-sized with its clips in its skeleton's clip library.
218
+ if (isAnimated(item) && !flags.has('no-rig')) r = await addCharacter(root, id, { item: item.id, as: flags.get('as') ?? null, height: flags.get('height') ?? null, card: flags.get('card') ?? null, kind: flags.get('kind') ?? null, lib, index, ...rigFlags });
219
+ else r = await addFromLibrary(root, id, args[1], { as: flags.get('as') ?? null, height: flags.get('height') ?? null, card: flags.get('card') ?? null, lib, index });
177
220
  }
178
221
  } else if (verb === 'redo') {
179
222
  if (!args[1]) return { ok: false, command: 'assets redo', why: 'usage: homie-studio assets redo <game> <asset> (made again from its kept raw file, free)' };
@@ -189,6 +232,44 @@ export async function assetsCommand(root, sub, positional, flags, { log = () =>
189
232
  return r;
190
233
  }
191
234
 
235
+ /** `cast <id>`: the game's characters and the decisions that shape them (a cast list for the cast card and the mod). */
236
+ export function castView(root, id) {
237
+ const doc = readDecisions(root, id);
238
+ const m = readManifest(root, id);
239
+ const d = doc?.decisions ?? {};
240
+ const pal = d['style.palette']?.value ?? null;
241
+ const planned = (d['cast.list']?.value ?? []).filter((c) => c.kind === 'character' || c.kind === 'creature');
242
+ const made = m.assets.filter((a) => a.kind === 'character' || a.kind === 'creature');
243
+ const rows = [
244
+ ...made.map((a) => ({ id: a.id, card: a.card ?? null, kind: a.kind, state: 'made', route: a.route, source: a.from?.item ?? (a.route === 'generated' ? 'generated' : a.license?.kind ?? null), family: a.rig?.family ?? null, skeleton: a.rig?.skeleton ?? null, bones: a.rig?.bones ?? a.measured?.bones ?? null, tris: a.measured?.tris ?? null, heightM: a.measured?.heightM ?? null, kb: a.measured?.glbKB ?? null, verbs: a.rig?.verbs ?? [], keep: a.rig?.keep ?? [], usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3), license: a.license?.kind ?? null })),
245
+ ...planned.filter((c) => !made.some((a) => a.id === c.id || a.card === c.card)).map((c) => ({ id: c.id, card: c.card, kind: c.kind, state: 'planned', route: null, source: null, family: null, skeleton: null, bones: null, tris: null, heightM: c.heightM ?? null, kb: null, verbs: [], keep: [], usd: 0, license: null })),
246
+ ];
247
+ return {
248
+ ok: true, command: 'cast', id,
249
+ decisions: Object.fromEntries(['style.proportions', 'style.shape', 'style.palette', 'cast.family', 'cast.scale', 'cast.variation', 'rig.skeleton', 'rig.source', 'rig.bones'].map((k) => [k, d[k] ? { label: d[k].label, state: d[k].state, why: d[k].why } : null])),
250
+ silhouette: d['style.shape']?.value?.silhouette ?? 'every character readable black on white at 64 px tall',
251
+ palette: pal ? ['bg', 'ink', 'accent', 'accent2', 'danger', 'good', 'gold'].map((k) => pal[k]).filter(Boolean) : [],
252
+ rows,
253
+ spend: artSpend(root, id),
254
+ };
255
+ }
256
+
257
+ export async function animCommand(root, sub, positional, flags, { log = () => {} } = {}) {
258
+ const known = ['plan', 'add', 'preview'];
259
+ const verb = known.includes(sub) ? sub : 'plan';
260
+ const args = known.includes(sub) ? positional.slice(2) : positional.slice(1);
261
+ const id = gameOf(root, args[0], `anim ${verb}`);
262
+ const C = await import('./characters.mjs');
263
+ let r;
264
+ if (verb === 'plan') r = C.animPlan(root, id);
265
+ else if (verb === 'add') {
266
+ if (!args[1]) return { ok: false, command: 'anim add', why: 'usage: homie-studio anim add <game> <character asset> --verbs jump,attack [--from <library item | file>]' };
267
+ r = await C.bakeClips(root, id, args[1], { verbs: list(flags.get('verbs')), from: flags.get('from') ?? null });
268
+ } else r = await C.animPreview(root, id, { asset: flags.get('asset') ?? args[1] ?? null, verbs: flags.has('verbs') ? list(flags.get('verbs')) : null, log });
269
+ writeArtSummary(root, id);
270
+ return r;
271
+ }
272
+
192
273
  /* ------------------------------------------------------------------ what a person reads */
193
274
 
194
275
  const MARK = { auto: '·', steered: '~', pinned: '●', locked: '■' };
@@ -197,7 +278,7 @@ export function artLines(r) {
197
278
  const L = [];
198
279
  switch (r.command) {
199
280
  case 'style init':
200
- L.push(`${r.summary}.`, ` ${r.picked.length} decision${r.picked.length === 1 ? '' : 's'} picked automatically (a ${r.genre} game)${r.kept.length ? `; ${r.kept.length} kept as the person set them` : ''}. ${r.file}`, ' Open the codex to change anything; steer, lock or the style board give the person control.');
281
+ L.push(`${r.summary}.`, ` ${r.picked.length} decision${r.picked.length === 1 ? '' : 's'} picked automatically (a ${r.genre} game)${r.kept.length ? `; ${r.kept.length} kept (set by the person, or what the game already draws)` : ''}. ${r.file}`, ' Open the codex to change anything; steer, lock or the style board give the person control.');
201
282
  break;
202
283
  case 'style': {
203
284
  L.push(`${r.id}: ${r.line}`, ` path: ${r.path}${r.budget ? `, art budget US$${r.budget.usd}` : ', free routes only (no art budget)'}`);
@@ -252,7 +333,8 @@ export function artLines(r) {
252
333
  if (r.items.length) L.push(` Add one: homie-studio assets add <game> ${r.items[0].id} [--height <m>]`);
253
334
  break;
254
335
  case 'assets add':
255
- L.push(`games/${r.game}: ${r.asset} added (${r.item ? `from the library: ${r.item}` : r.route}; licence ${r.license}).`, ...(r.after ? [` ${r.before.tris} -> ${r.after.tris} triangles, ${r.before.kb} -> ${r.after.kb} KB, ${r.after.heightM} m tall; raw kept in ${r.raw} (git-ignored)`] : []), ...(r.pinned?.length ? [` pinned by use: ${r.pinned.join(', ')}`] : []), ...(r.warnings ?? []).map((w) => ` note: ${w}`));
336
+ L.push(`games/${r.game}: ${r.asset} added (${r.item ? `from the library: ${r.item}` : r.route}; licence ${r.license}).`, ...(r.after ? [` ${r.before.tris} -> ${r.after.tris} triangles, ${r.before.kb} -> ${r.after.kb} KB, ${r.after.heightM} m tall${r.raw ? `; raw kept in ${r.raw} (git-ignored)` : ''}`] : []),
337
+ ...(r.skeleton ? [` ${r.family} skeleton ${r.skeleton} (${r.after?.bones ?? '?'} bones, ${r.after?.drawCalls ?? '?'} draw call${r.after?.drawCalls === 1 ? '' : 's'}${r.merged ? `, ${r.merged} parts merged` : ''}${r.dropped?.length ? `, left out: ${r.dropped.join(', ')}` : ''}); clips in ${r.anims}: ${r.verbs.join(', ')}${r.clipsFrom?.length ? ` (retargeted from ${r.clipsFrom.join(', ')})` : ''}${r.missing?.length ? `; no source for ${r.missing.join(', ')}` : ''}`] : []), ...(r.pinned?.length ? [` pinned by use: ${r.pinned.join(', ')}`] : []), ...(r.warnings ?? []).map((w) => ` note: ${w}`));
256
338
  break;
257
339
  case 'assets redo':
258
340
  L.push(`games/${r.game}: ${r.asset} made again from its raw file (free): ${r.before.tris} -> ${r.after.tris} triangles, ${r.after.kb} KB, ${r.after.heightM} m (${r.ops.join(', ')})`, ...(r.warnings ?? []).map((w) => ` note: ${w}`));
@@ -278,6 +360,24 @@ export function artLines(r) {
278
360
  case 'assets remove':
279
361
  L.push(r.ok ? `Removed ${r.asset} from the manifest${r.deleted.length ? `, and its shipped copy (${r.deleted.join(', ')})` : ''}.${r.kept.length ? ` Kept ${r.kept.join(', ')}: changed since it was recorded, so delete it yourself if it is unused.` : ''} A raw file in art/ stays.` : r.why);
280
362
  break;
363
+ case 'cast': {
364
+ const out = [`The cast of ${r.id}: ${r.rows.length} character${r.rows.length === 1 ? '' : 's'}`];
365
+ for (const [k, v] of Object.entries(r.decisions)) if (v) out.push(` ${k}: ${v.label} [${v.state}]`);
366
+ for (const row of r.rows) out.push(` ${row.state === 'made' ? 'made ' : 'planned'} ${row.id} (${row.kind}${row.route ? `, ${row.route}` : ''})${row.family ? `: ${row.family} skeleton ${row.skeleton}, ${row.bones} bones, ${row.tris} triangles, ${row.heightM} m, ${row.verbs.length} clips` : ''}${row.usd ? `, US$${row.usd}` : ''}`);
367
+ return out;
368
+ }
369
+ case 'anim plan': {
370
+ const out = [`Clips for ${r.game}: the game needs ${r.verbs.join(', ')}`];
371
+ for (const row of r.rows) {
372
+ out.push(` ${row.id} (${row.familyLabel}, ${row.skeleton}, ${row.bones} bones${row.animsKB ? `, its clip library ${row.animsKB} KB` : ''})`);
373
+ for (const c of row.clips) out.push(` ${c.have ? 'ok ' : 'MISS'} ${c.verb}${c.have ? ` <- ${c.source}${c.retargeted ? ` (retargeted from ${c.from})` : ''}` : ''}`);
374
+ if (row.extra.length) out.push(` also: ${row.extra.join(', ')}`);
375
+ }
376
+ for (const u of r.unrigged) out.push(` ${u}: no rig (a static model; the animate guide says how to rig one)`);
377
+ return out;
378
+ }
379
+ case 'anim add': return [`${r.asset}: ${r.added.length ? `added ${r.added.join(', ')}` : 'nothing new'} (its skeleton ${r.skeleton}: ${r.verbs.join(', ')}${r.kb ? `; ${r.kb} KB` : ''})${r.missing.length ? `; no source has ${r.missing.join(', ')}` : ''}${r.why ? ` (${r.why})` : ''}`];
380
+ case 'anim preview': return [`Previews in .studio/art/${r.game}/anim/:`, ...r.rows.map((row) => ` ${row.id}: ${row.verbs.map((v) => v.verb).join(', ')}${row.sheet ? ` (sheet ${row.sheet})` : ''}`)];
281
381
  case 'assets check':
282
382
  L.push(...checkLines(r));
283
383
  break;
package/lib/art-tools.mjs CHANGED
@@ -14,6 +14,14 @@
14
14
  * asset_check phone budgets, the validator, licences, staleness
15
15
  * asset_lineup true scale, silhouettes, palette drift, flags lineup card
16
16
  * asset_rights RIGHTS.md and every asset's licence rights card
17
+ * cast_plan the characters: proportions, silhouette, palette, skeleton family, source, clips, cost cast card
18
+ * character_make one generated, rigged character on the person's own fal account: a priced dry run unless approve;
19
+ * the A-pose concept first, then (after looking) Meshy's mesh and auto-rig, the library's clips
20
+ * retargeted onto it for free lineup card
21
+ * anim_plan each character's clips against the verbs the game needs, as looping previews; Feel opens the
22
+ * Game Lab on the move animation card
23
+ * anim_add more verbs for a character, retargeted onto its skeleton (free) animation card
24
+ * anim_preview the looping previews drawn again (free) animation card
17
25
  *
18
26
  * Work that needs the studio's own tools (rendering, optimising) runs the studio's pinned CLI as a job; decisions are
19
27
  * plain files and change here directly. Pictures go to cards as small data: URLs (each a few hundred KB at most).
@@ -21,7 +29,8 @@
21
29
  import { existsSync, readFileSync } from 'node:fs';
22
30
  import { join, resolve } from 'node:path';
23
31
  import { readManifest } from './asset-manifest.mjs';
24
- import { artSpend, writeArtSummary } from './art-cli.mjs';
32
+ import { artSpend, castView, writeArtSummary } from './art-cli.mjs';
33
+ import { animPlan } from './characters.mjs';
25
34
  import { CATALOGUE_IDS, decisionRows, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
26
35
  import { libraryBase, loadIndex, searchLibrary, itemThumb } from './library.mjs';
27
36
  import { boardView } from './style-board.mjs';
@@ -33,6 +42,7 @@ export const ART_UI = Object.freeze({
33
42
  cast: 'ui://homie-studio/cast',
34
43
  lineup: 'ui://homie-studio/lineup',
35
44
  rights: 'ui://homie-studio/rights',
45
+ animation: 'ui://homie-studio/animation',
36
46
  });
37
47
 
38
48
  /** A picture file as a data: URL no bigger than `max` (a smaller JPEG when it is over), or null. */
@@ -77,6 +87,68 @@ function styleText(d) {
77
87
  ].join('\n');
78
88
  }
79
89
 
90
+ /** A character's small picture: its looping idle preview, else its library thumbnail, else its concept. */
91
+ async function characterThumb(h, root, game, row) {
92
+ const idle = join(root, '.studio', 'art', game, 'anim', `${row.id}-idle.webp`);
93
+ if (existsSync(idle)) return picture(h, idle, 40 * 1024);
94
+ const m = readManifest(root, game).assets.find((a) => a.id === row.id);
95
+ if (m?.from?.item) {
96
+ try { const lib = await loadIndex({ lib: libraryBase() }); const it = lib.index.items.find((x) => x.id === m.from.item); const t = it ? await itemThumb(lib.lib, it) : null; if (t) return `data:image/webp;base64,${Buffer.from(t).toString('base64')}`; } catch { /* no library here */ }
97
+ }
98
+ const concept = (m?.files ?? []).find((f) => f.role === 'concept');
99
+ return concept ? picture(h, join(root, 'games', game, concept.path), 60 * 1024) : null;
100
+ }
101
+
102
+ /** Which Game Lab take tunes a verb (lab.json): one named for it or whose note says it, else the default. */
103
+ function takeFor(lab, verb) {
104
+ const takes = Object.entries(lab?.takes ?? {});
105
+ const words = { attack: /swing|attack|strike|hit|punch|knock/i, jump: /jump|hop|leap/i, cast: /cast|spell/i, dodge: /dodge|roll/i, run: /run|dash|sprint/i };
106
+ const hit = takes.find(([k]) => k === verb) ?? takes.find(([k, t]) => (words[verb] ?? new RegExp(verb, 'i')).test(`${k} ${t?.note ?? ''}`));
107
+ return hit ? hit[0] : null;
108
+ }
109
+
110
+ /** The animation card's data: the plan, with looping previews for up to `max` characters (the answer stays small). */
111
+ function animationData(h, root, game, { asset = null, max = 4 } = {}) {
112
+ const plan = animPlan(root, game);
113
+ const dir = join(root, '.studio', 'art', game, 'anim');
114
+ let budget = 560 * 1024;
115
+ const order = asset ? [...plan.rows.filter((r) => r.id === asset), ...plan.rows.filter((r) => r.id !== asset)] : plan.rows;
116
+ // One character a skeleton first (its clips are its skeleton's), then the rest.
117
+ const seen = new Set(); const first = []; const rest = [];
118
+ for (const r of order) { if (!seen.has(r.skeleton) || r.id === asset) { seen.add(r.skeleton); first.push(r); } else rest.push(r); }
119
+ const shown = new Set([...first, ...rest].slice(0, Math.max(1, max)).map((r) => r.id));
120
+ // Each shown character gets an even share of the answer, its core verbs first, so none is left all placeholders.
121
+ const CORE = ['idle', 'run', 'jump', 'attack', 'cast', 'hit', 'walk', 'land'];
122
+ const share = Math.floor(budget / Math.max(1, shown.size));
123
+ const rows = order.map((r) => {
124
+ const have = [...r.clips.filter((c) => c.have).map((c) => c.verb), ...r.extra];
125
+ const verbs = [...CORE.filter((v) => have.includes(v)), ...have.filter((v) => !CORE.includes(v))];
126
+ const previews = {};
127
+ if (shown.has(r.id)) {
128
+ let left = Math.min(share, budget);
129
+ for (const v of verbs) {
130
+ const f = join(dir, `${r.id}-${v}.webp`);
131
+ if (!existsSync(f)) continue;
132
+ const pic = picture(h, f, 40 * 1024);
133
+ if (!pic || pic.length > left) continue;
134
+ left -= pic.length; budget -= pic.length; previews[v] = pic;
135
+ }
136
+ }
137
+ return { ...r, shown: shown.has(r.id), previews, previewed: Object.keys(previews).length };
138
+ });
139
+ const takes = plan.lab?.takes ? Object.fromEntries(Object.entries(plan.lab.takes).map(([k, t]) => [k, t?.note ?? ''])) : {};
140
+ const feel = Object.fromEntries(['jump', 'attack', 'cast', 'run', 'dodge', 'hit', 'land'].map((v) => [v, takeFor(plan.lab, v)]).filter(([, t]) => t));
141
+ return { kind: 'animation', game, verbs: plan.verbs, rows, decisions: plan.decisions, takes, feel, unrigged: plan.unrigged };
142
+ }
143
+
144
+ function animationText(d) {
145
+ return [
146
+ `Clips for ${d.game}: the game needs ${d.verbs.join(', ')}.`,
147
+ ...d.rows.map((r) => ` ${r.id} (${r.familyLabel}, ${r.bones} bones): ${r.clips.map((c) => `${c.verb}${c.have ? '' : ' MISSING'}`).join(', ')}${r.extra.length ? `; also ${r.extra.join(', ')}` : ''}${r.previewed ? ` (${r.previewed} previews on the card)` : ''}`),
148
+ ...(Object.keys(d.feel).length ? [`Feel: the Game Lab tunes ${Object.entries(d.feel).map(([v, t]) => `${v} (take "${t}")`).join(', ')} New beside Today (game_lab { "take": "<take>" }).`] : ['Feel: the game has no Game Lab take for a move yet (lab.json; the lab guide).']),
149
+ ].join('\n');
150
+ }
151
+
80
152
  export function artToolDefs(ctx, h) {
81
153
  const { ok, fail, cli, stillRunning, whyOf, needsInstall, ui, str, STUDIO_ARG, RO, RW } = h;
82
154
  const GAME = { game: str('The game\'s id (default: the only game)'), ...STUDIO_ARG };
@@ -327,5 +399,100 @@ export function artToolDefs(ctx, h) {
327
399
  return ok(`${x.file}:\n\n${x.text.slice(0, 6000)}`, { kind: 'rights', game, file: x.file, rows, problems: x.licence, text: x.text.slice(0, 20_000) });
328
400
  },
329
401
  },
402
+ {
403
+ name: 'cast_plan', title: 'Plan the characters',
404
+ description: 'The cast card for a game\'s characters: the decisions that shape them (proportions in heads and metres, the silhouette rule, the palette, the skeleton family, where rigs come from) and every character made or planned, with its picture, source (the free CC0 starter library, generated on the person\'s fal account, their own), skeleton and bones, triangles, height and clips. Free; nothing is made. A library character comes in with asset_add (its clips baked into its skeleton\'s clip library); a generated one with character_make (paid, priced first).',
405
+ inputSchema: { type: 'object', properties: { ...GAME } },
406
+ annotations: { title: 'Plan the characters', ...RO }, _meta: ui(ART_UI.cast),
407
+ run: async (a) => {
408
+ const root = ctx.root(a.studio);
409
+ const game = gameOf(root, a.game);
410
+ const c = castView(root, game);
411
+ const rows = [];
412
+ for (const r of c.rows) rows.push({ ...r, thumb: r.state === 'made' ? await characterThumb(h, root, game, r) : null });
413
+ const data = { kind: 'cast', mode: 'characters', game, decisions: c.decisions, silhouette: c.silhouette, palette: c.palette, rows, spend: c.spend };
414
+ return ok([`The characters of ${game} (${rows.length}):`, ...Object.entries(c.decisions).filter(([, v]) => v).map(([k, v]) => ` ${k}: ${v.label} [${v.state}]`), ...rows.map((r) => ` ${r.state} ${r.id}${r.family ? `: ${r.family} skeleton, ${r.bones} bones, ${r.tris} triangles, ${r.heightM} m, clips ${r.verbs.join(', ')}` : ''} (${r.route ?? 'planned'}${r.usd ? `, US$${r.usd}` : ''})`), 'anim_plan shows each one\'s clips, looping.'].join('\n'), data);
415
+ },
416
+ },
417
+ {
418
+ name: 'character_make', title: 'Make a character (paid)',
419
+ description: 'One generated, rigged character for a game, in its locked style, on the person\'s OWN fal account: a full-body concept image in an A-pose (the derived style prompt; the golden images, or like: a library character, as the style reference), then Meshy 7.1 image-to-3D with its humanoid auto-rig, then free local work: made phone-sized, its skeleton mapped to the standard, the starter library\'s CC0 clips retargeted onto it (idle, run, jump, attack, hit, ...), recorded with receipts and licence. About US$1.44 (US$0.035 concept + US$1.40 mesh and rig). Without approve it is a priced dry run: tell the person the price and ask. budget sets the cap they agreed to. Step 1 makes only the concept: LOOK at it; then step: "mesh" with approve. Humanoids with clear limbs only. Never in a loop.',
420
+ inputSchema: { type: 'object', properties: { ...GAME, asset: str('The new character\'s id, e.g. ranger'), card: str('Its codex card, e.g. "Players/Ranger"'), what: str('Who, in plain words: build, clothes, colours, what they carry'), height: { type: 'number', description: 'Its height in metres (default: the game\'s character height)' }, like: str('A starter-library character whose look it should match (a style reference), e.g. kaykit-adventurers/rogue'), step: str('concept (default) or mesh', { enum: ['concept', 'mesh'] }), approve: { type: 'boolean', description: 'The person agreed to the price' }, budget: { type: 'number', description: 'The cap in US dollars the person agreed to for this game\'s models' }, again: { type: 'boolean', description: 'A new concept with changed words (costs another concept)' } }, required: ['asset'] },
421
+ annotations: { title: 'Make a character (paid)', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(ART_UI.lineup),
422
+ run: async (a) => {
423
+ const root = ctx.root(a.studio);
424
+ const need = needsInstall(ctx, root); if (need) return need;
425
+ const game = gameOf(root, a.game);
426
+ const asset = String(a.asset ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
427
+ if (a.budget !== undefined) {
428
+ const b = await models(root, `budget for ${game}`, ['budget', game, '--cap', String(Number(a.budget))], { wait: 20_000 }).catch((e) => ({ error: e.message }));
429
+ if (b.error || b.job?.code) return fail(`The budget was not set: ${b.error ?? whyOf(b.job)}`);
430
+ }
431
+ const args = ['character', game, asset, ...(a.card ? ['--card', String(a.card)] : []), ...(a.what ? ['--what', String(a.what)] : []), ...(a.height ? ['--height', String(a.height)] : []), ...(a.like ? ['--like', String(a.like)] : []), ...(a.step === 'mesh' ? ['--mesh'] : []), ...(a.again ? ['--concept-again'] : []), ...(a.approve === true ? ['--yes'] : ['--dry-run'])];
432
+ let r;
433
+ try { r = await models(root, `${a.step === 'mesh' ? 'the rigged character' : 'the concept'} for ${asset}`, args, { wait: a.approve === true ? ctx.waitMs : 30_000 }); } catch (error) { return fail(error.message); }
434
+ if (!r.ended) return stillRunning(r.job, a.step === 'mesh' ? `The rigged character ${asset} (about 2 to 5 minutes)` : `The concept for ${asset}`);
435
+ const x = r.result ?? {};
436
+ writeArtSummary(root, game);
437
+ const concept = x.concept ? picture(h, join(root, x.concept), 260 * 1024) : null;
438
+ const request = { ...(a.card ? { card: String(a.card) } : {}), ...(a.what ? { what: String(a.what) } : {}), ...(a.height ? { height: Number(a.height) } : {}), ...(a.like ? { like: String(a.like) } : {}) };
439
+ const data = { kind: 'lineup', mode: 'make', character: true, game, asset, request, step: x.step ?? a.step ?? 'concept', dryRun: Boolean(x.dryRun), price: x.price ?? null, total: x.total ?? null, then: x.then ?? null, needs: x.needs ?? null, concept, model: x.model ?? null, before: x.before ?? null, after: x.after ?? null, usd: x.usd ?? null, verbs: x.verbs ?? [], skeleton: x.skeleton ?? null, spend: artSpend(root, game), next: x.next ?? null, why: x.why ?? null };
440
+ if (r.job.code !== 0 && !x.needs) return fail(`Not made: ${x.why ?? whyOf(r.job)}`, data);
441
+ const text = x.dryRun ? `${x.step === 'mesh' ? 'The rigged character' : `The concept for ${asset}`} would cost about US$${x.price?.usd} (${x.price?.basis})${x.total ? `; with the mesh and rig, about US$${x.total} for the character` : ''}, on the person's own fal account. Ask them; then character_make with approve: true (and budget: <their cap> the first time).`
442
+ : x.needs === 'approval' ? x.why
443
+ : x.step === 'concept' ? `The concept for ${asset} is made (US$${x.usd}). LOOK at it (file_read ${x.concept}): one character, whole, in an A-pose, in the game's style, on a plain background? Then character_make { "asset": "${asset}", "step": "mesh", "approve": true } (about US$${x.then?.usd ?? '1.40'}), or new words with again: true.`
444
+ : x.step === 'mesh' ? `${asset} is a rigged character in games/${game}: ${x.after?.tris} triangles, ${x.after?.kb} KB, ${x.after?.heightM} m, skeleton ${x.skeleton}; clips (retargeted, free): ${(x.verbs ?? []).join(', ')} (US$${x.usd}; receipts ${x.receipts?.join(', ')}). anim_preview shows its clips looping; asset_lineup shows it beside the rest.`
445
+ : (x.next ?? JSON.stringify(x));
446
+ return ok(text, data);
447
+ },
448
+ },
449
+ {
450
+ name: 'anim_plan', title: 'Clips',
451
+ description: 'The animation card: every character\'s clips against the verbs the game needs (its anim.clips decision: idle, walk, run, jump, attack, hit, die, ...), each as a LOOPING PREVIEW (when drawn: anim_preview), where it came from (its own, or the CC0 library\'s retargeted onto its skeleton), what is missing (anim_add adds it, free), and Feel: the Game Lab on the move (game_lab with the take that plays it), New beside Today. Free.',
452
+ inputSchema: { type: 'object', properties: { ...GAME, asset: str('Optional: the character to show first') } },
453
+ annotations: { title: 'Clips', ...RO }, _meta: ui(ART_UI.animation),
454
+ run: async (a) => {
455
+ const root = ctx.root(a.studio);
456
+ const game = gameOf(root, a.game);
457
+ const d = animationData(h, root, game, { asset: a.asset ?? null });
458
+ if (!d.rows.length) return fail(`${game} has no rigged characters yet: asset_add a library character (assets_find "knight" kind character), or character_make`, d);
459
+ return ok(animationText(d), d);
460
+ },
461
+ },
462
+ {
463
+ name: 'anim_add', title: 'Add clips',
464
+ description: 'More verbs for a character (jump, attack, hit, die, emote, cast, block, dodge, ...): baked into its skeleton\'s clip library at build time, its own clip where it has one, else retargeted from the starter library\'s CC0 humanoid clips (from: another library item or a file). Free, a few seconds; every character with that skeleton gets them.',
465
+ inputSchema: { type: 'object', properties: { ...GAME, asset: str('The character'), verbs: { description: 'Verbs to add, e.g. ["jump","attack"] or "jump,attack"' }, from: str('Optional: a library item or a file to take them from') }, required: ['asset', 'verbs'] },
466
+ annotations: { title: 'Add clips', ...RW }, _meta: ui(ART_UI.animation),
467
+ run: async (a) => {
468
+ const root = ctx.root(a.studio);
469
+ const need = needsInstall(ctx, root); if (need) return need;
470
+ const game = gameOf(root, a.game);
471
+ const verbs = (Array.isArray(a.verbs) ? a.verbs : String(a.verbs ?? '').split(',')).map((v) => String(v).trim()).filter(Boolean);
472
+ const r = await cli(ctx, root, `clips for ${a.asset}`, ['anim', 'add', game, String(a.asset), '--verbs', verbs.join(','), ...(a.from ? ['--from', String(a.from)] : [])]);
473
+ if (!r.ended) return stillRunning(r.job, 'Baking the clips');
474
+ if (r.job.code !== 0) return fail(`Not added: ${whyOf(r.job)}`);
475
+ const p = await cli(ctx, root, `previews of ${a.asset}`, ['anim', 'preview', game, '--asset', String(a.asset)]).catch(() => null);
476
+ const d = animationData(h, root, game, { asset: String(a.asset) });
477
+ const x = r.result ?? {};
478
+ return ok(`${a.asset}: ${x.added?.length ? `added ${x.added.join(', ')}` : 'nothing new'}${x.missing?.length ? `; no source has ${x.missing.join(', ')}` : ''}.${p?.ended ? '' : ' (Its previews are still being drawn.)'}\n${animationText(d)}`, { ...d, added: x.added ?? [], missing: x.missing ?? [] });
479
+ },
480
+ },
481
+ {
482
+ name: 'anim_preview', title: 'Draw the clips',
483
+ description: 'Draw every character\'s clips as small looping previews (animated WebP, under the game\'s light; free, one headless Chrome on this computer, a few seconds a character) and answer with the animation card.',
484
+ inputSchema: { type: 'object', properties: { ...GAME, asset: str('Optional: only this character') } },
485
+ annotations: { title: 'Draw the clips', ...RW }, _meta: ui(ART_UI.animation),
486
+ run: async (a) => {
487
+ const root = ctx.root(a.studio);
488
+ const need = needsInstall(ctx, root); if (need) return need;
489
+ const game = gameOf(root, a.game);
490
+ const r = await cli(ctx, root, `clip previews for ${game}`, ['anim', 'preview', game, ...(a.asset ? ['--asset', String(a.asset)] : [])]);
491
+ if (!r.ended) return stillRunning(r.job, 'The previews');
492
+ if (r.job.code !== 0) return fail(`Not drawn: ${whyOf(r.job)}`);
493
+ const d = animationData(h, root, game, { asset: a.asset ?? null });
494
+ return ok(`${animationText(d)}\nSheets (a person reads them with file_read): ${(r.result?.rows ?? []).map((x) => x.sheet).filter(Boolean).join(', ')}`, d);
495
+ },
496
+ },
330
497
  ];
331
498
  }
@@ -5,9 +5,14 @@
5
5
  * glTF-Validator; triangles, materials, picture sizes, bones and bytes against its tier's budget
6
6
  * (the game's cast.tiers decision, else the phone defaults); the pivot at the bottom centre; its height against
7
7
  * the card's; its licence (lib/asset-manifest.mjs); whether a decision it was made under has moved on
8
+ * characters four influences a vertex (three.js reads no more), bones within the tier, its skeleton's clip library
9
+ * present, and every verb the game needs (anim.clips) in it
10
+ * clips a skeleton's clip library (kind clip): at most 1 MB (3 MB hard), sampled at no more than 30 a second
8
11
  * the game shipped models with no record; draw calls, triangles and picture memory summed (each asset times its
9
12
  * `placements`, instanced ones drawn once); the first-play download from the last build (gzip); a
10
- * committed binary over 5 MB under games/ (it belongs in R2, never in git)
13
+ * committed binary over 5 MB under games/ (it belongs in R2, never in git); skinning on a phone: the
14
+ * room's players times the heaviest character's skinned vertices and bones, against SKINNING (beyond it,
15
+ * far characters must pose less often: @homie-rocks/studio/animate's crowd())
11
16
  *
12
17
  * The result also goes to .studio/art/<id>/check.json, which the Studio mod's Art tab and the cards read.
13
18
  */
@@ -28,10 +33,15 @@ export const BUDGETS = Object.freeze({
28
33
  kit: { triangles: 1000, texturePx: 512, kb: 200, bones: 0, materials: 1 },
29
34
  scene: { drawCalls: 100, triangles: 150_000, textureMB: 48, firstPlayMB: 5 },
30
35
  });
36
+ /**
37
+ * Skinning on a phone, every frame (judgment, from the house's 32-player rooms; the perf skill's measured gate decides):
38
+ * the vertices the GPU skins and the bones the CPU poses. Characters past `crowdFrom` need crowd mode.
39
+ */
40
+ export const SKINNING = Object.freeze({ vertices: 60_000, bones: 1_200, crowdFrom: 12, clipKB: 1024, clipHardKB: 3072, clipRate: 30 });
31
41
  export const HARD_CAPS = Object.freeze({ triangles: { hero: 15000, npc: 6000, prop: 8000, signature: 8000, kit: 3000 }, texturePx: 2048, kb: 5120, bones: 64, materials: 2, scene: { drawCalls: 150, triangles: 250_000, textureMB: 64, firstPlayMB: 8 } });
32
42
  const BIG_FILE = 5 * 1024 * 1024;
33
43
 
34
- const tierOf = (a) => (BUDGETS[a.tier] ? a.tier : a.kind === 'character' ? 'hero' : a.kind === 'creature' ? 'npc' : a.kind === 'kit' || a.kind === 'environment' ? 'kit' : 'prop');
44
+ const tierOf = (a) => (a.kind === 'clip' ? 'clip' : BUDGETS[a.tier] ? a.tier : a.kind === 'character' ? 'hero' : a.kind === 'creature' ? 'npc' : a.kind === 'kit' || a.kind === 'environment' ? 'kit' : 'prop');
35
45
 
36
46
  /** The budgets this game holds itself to: its cast.tiers decision over the phone defaults, never past a hard cap. */
37
47
  export function budgetsFor(doc) {
@@ -97,11 +107,16 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
97
107
  const rows = [];
98
108
  let tris = 0; let draws = 0; let texBytes = 0;
99
109
  const seenFiles = new Set();
110
+ const { verbsFor } = await import('./characters.mjs');
111
+ const needVerbs = verbsFor(doc);
112
+ const libs = new Map(manifest.assets.filter((x) => x.kind === 'clip').map((x) => [x.rig?.skeleton, x]));
113
+ let heaviest = { vertices: 0, bones: 0, id: null };
100
114
  for (const a of manifest.assets) {
101
115
  const problems = []; const warnings = [];
102
116
  const tier = tierOf(a);
103
117
  const b = budgets[tier] ?? budgets.prop;
104
118
  const n = Math.max(1, Number(a.placements ?? 1) || 1);
119
+ if (a.kind === 'clip') { rows.push(await clipRow(gdir, a, tools)); continue; }
105
120
  const model = (a.files ?? []).find((f) => f.role === 'model');
106
121
  let m = null;
107
122
  if (model) {
@@ -126,6 +141,19 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
126
141
  else if (m.materials > b.materials) warnings.push(`${m.materials} materials (the budget is ${b.materials}: one draw call per material)`);
127
142
  if (bytes.byteLength / 1024 > b.kb) problems.push(`${kb(bytes.byteLength)} KB (a ${tier}'s budget is ${b.kb} KB)`);
128
143
  if (m.bones > (b.bones || HARD_CAPS.bones)) problems.push(`${m.bones} bones (a ${tier}'s budget is ${b.bones || HARD_CAPS.bones})`);
144
+ if (m.influences > 4) problems.push(`${m.influences} joints move one vertex (JOINTS_1): three.js reads 4, so the rest are dropped and the mesh tears; export with 4 influences a vertex`);
145
+ if (m.skinnedVertices > heaviest.vertices) heaviest = { ...heaviest, vertices: m.skinnedVertices, id: a.id };
146
+ if (m.bones > heaviest.bones) heaviest.bones = m.bones;
147
+ if (a.rig) {
148
+ const lib = libs.get(a.rig.skeleton);
149
+ const animsFile = a.rig.anims ? join(gdir, a.rig.anims) : null;
150
+ if (!animsFile || !existsSync(animsFile)) problems.push(`its clip library ${a.rig.anims ?? '(none named)'} is missing: homie-studio anim add ${id} ${a.id} --verbs ${needVerbs.join(',')}`);
151
+ else {
152
+ const have = lib?.clips?.map((c) => c.verb) ?? a.rig.verbs ?? [];
153
+ const lacking = needVerbs.filter((v) => !have.includes(v));
154
+ if (lacking.length) warnings.push(`the game's clips (anim.clips) name ${lacking.join(', ')}, which ${a.id}'s clip library has not: homie-studio anim add ${id} ${a.id} --verbs ${lacking.join(',')}`);
155
+ }
156
+ }
129
157
  if (m.pivot && !(m.pivot.bottom && m.pivot.centred)) warnings.push('its pivot is not at the bottom centre (assets optimise puts it there)');
130
158
  const want = Number(a.heightM ?? a.card?.heightM ?? 0);
131
159
  if (want > 0 && m.heightM && Math.abs(m.heightM / want - 1) > 0.1) warnings.push(`${m.heightM} m tall; its card says ${want} m`);
@@ -141,14 +169,23 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
141
169
  if (a.route === 'procedural' && a.measured) { tris += (a.measured.tris ?? 0) * n; draws += a.instanced ? (a.measured.drawCalls ?? 1) : (a.measured.drawCalls ?? 1) * n; }
142
170
  rows.push({ id: a.id, kind: a.kind, tier, route: a.route, ok: problems.length === 0, problems, warnings, measured: m ? { tris: m.triangles, drawCalls: m.drawCalls, materials: m.materials, maxTexturePx: m.maxTexturePx, textureKB: kb(m.textureGpuBytes), bones: m.bones, heightM: m.heightM, kb: model ? kb(statSync(join(gdir, model.path)).size) : null } : null, budget: b, placements: n });
143
171
  }
172
+ // Skinning on a phone: the room's players, each the heaviest character (the worst case a room can draw).
173
+ let players = 8;
174
+ try { players = Math.max(1, Number(JSON.parse(readFileSync(join(gdir, 'game.json'), 'utf8')).players?.max ?? 8) || 8); } catch { players = 8; }
175
+ const skinning = heaviest.vertices ? { players, character: heaviest.id, vertices: heaviest.vertices * players, bones: heaviest.bones * players, budget: { vertices: SKINNING.vertices, bones: SKINNING.bones } } : null;
144
176
  const licence = licenceProblems(root, id, manifest, { public: publicSource(root, id) });
145
177
  const stale = doc ? staleAssets(doc, manifest) : [];
146
178
  const unrecorded = unrecordedModels(root, id, manifest);
147
179
  const bigFiles = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${id}/`));
148
180
  const firstPlay = firstPlayBytes(root, id);
149
181
  const s = budgets.scene;
150
- const totals = { assets: manifest.assets.length, triangles: tris, drawCalls: draws, textureMB: +(texBytes / 1024 / 1024).toFixed(1), firstPlayMB: firstPlay ? +(firstPlay.gzip / 1024 / 1024).toFixed(2) : null };
182
+ const totals = { assets: manifest.assets.length, triangles: tris, drawCalls: draws, textureMB: +(texBytes / 1024 / 1024).toFixed(1), firstPlayMB: firstPlay ? +(firstPlay.gzip / 1024 / 1024).toFixed(2) : null, skinnedVertices: skinning?.vertices ?? 0, bones: skinning?.bones ?? 0 };
151
183
  const problems = [];
184
+ if (skinning && (skinning.vertices > SKINNING.vertices || skinning.bones > SKINNING.bones)) {
185
+ const crowd = `far characters must pose less often: call crowd(characters, camera) from @homie-rocks/studio/animate every frame (and lighter characters, or fewer, for the rest)`;
186
+ if (players >= SKINNING.crowdFrom) problems.push(`a room of ${players} skins about ${skinning.vertices.toLocaleString('en-US')} vertices and poses ${skinning.bones.toLocaleString('en-US')} bones a frame on a phone (budget ${SKINNING.vertices.toLocaleString('en-US')} and ${SKINNING.bones.toLocaleString('en-US')}): ${crowd}`);
187
+ else problems.push(`a room of ${players} skins about ${skinning.vertices.toLocaleString('en-US')} vertices and poses ${skinning.bones.toLocaleString('en-US')} bones a frame (budget ${SKINNING.vertices.toLocaleString('en-US')} and ${SKINNING.bones.toLocaleString('en-US')}): a lighter character (assets optimise --triangles), or ${crowd}`);
188
+ }
152
189
  if (totals.drawCalls > s.drawCalls) problems.push(`about ${totals.drawCalls} draw calls in a scene (the phone budget is ${s.drawCalls}): instance repeated props (placements with instanced: true), or fewer materials`);
153
190
  if (totals.triangles > s.triangles) problems.push(`about ${totals.triangles.toLocaleString('en-US')} triangles in a scene (the phone budget is ${s.triangles.toLocaleString('en-US')})`);
154
191
  if (totals.textureMB > s.textureMB) problems.push(`${totals.textureMB} MB of picture memory (the phone budget is ${s.textureMB} MB; an iPhone page dies near 100 MB)`);
@@ -156,7 +193,7 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
156
193
  for (const l of licence.filter((x) => x.level === 'refuse')) problems.push(`${l.asset}: ${l.problem}${l.fix ? ` (${l.fix})` : ''}`);
157
194
  for (const f of bigFiles) problems.push(`${f.path} is ${(f.bytes / 1024 / 1024).toFixed(1)} MB in git: big files go to the studio's R2 (homie-studio storage add, then media move), raw files to art/<slug>/raw/ (git-ignored)`);
158
195
  const ok = rows.every((r) => r.ok) && problems.length === 0;
159
- const result = { ok, command: 'assets check', game: id, rows, totals, budgets: { ...s, tiers: Object.fromEntries(['hero', 'npc', 'prop', 'signature', 'kit'].map((t) => [t, budgets[t]])) }, licence, stale, unrecorded, bigFiles, firstPlay, problems, at: new Date().toISOString() };
196
+ const result = { ok, command: 'assets check', game: id, rows, totals, skinning, budgets: { ...s, skinning: SKINNING, tiers: Object.fromEntries(['hero', 'npc', 'prop', 'signature', 'kit'].map((t) => [t, budgets[t]])) }, licence, stale, unrecorded, bigFiles, firstPlay, problems, at: new Date().toISOString() };
160
197
  if (write) {
161
198
  const dir = join(root, '.studio', 'art', id);
162
199
  mkdirSync(dir, { recursive: true });
@@ -165,6 +202,34 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
165
202
  return result;
166
203
  }
167
204
 
205
+ /** A clip library's row: its file there and recorded, its size and sample rate against SKINNING, what it holds. */
206
+ async function clipRow(gdir, a, tools) {
207
+ const problems = []; const warnings = [];
208
+ const f = (a.files ?? []).find((x) => x.role === 'clip');
209
+ let measured = null;
210
+ if (!f) problems.push('no clip library file recorded');
211
+ else if (!existsSync(join(gdir, f.path))) problems.push(`${f.path} is missing`);
212
+ else {
213
+ const bytes = readFileSync(join(gdir, f.path));
214
+ const safe = checkGlb(bytes, LIMITS.game);
215
+ if (!safe.ok) problems.push(...safe.problems.map((p) => `unsafe: ${p}`));
216
+ const k = Math.round(bytes.byteLength / 1024);
217
+ if (k > SKINNING.clipHardKB) problems.push(`${k} KB of clips (at most ${SKINNING.clipHardKB} KB): fewer verbs, or resample`);
218
+ else if (k > SKINNING.clipKB) warnings.push(`${k} KB of clips (the budget is ${SKINNING.clipKB} KB a skeleton)`);
219
+ if (f.sha256 && tools && tools.sha256(bytes) !== f.sha256) warnings.push('the file changed since it was recorded (its SHA-256 differs)');
220
+ if (safe.ok && tools) {
221
+ const ins = await tools.inspectModel(bytes, { limits: LIMITS.game }).catch(() => null);
222
+ const m = ins?.measured;
223
+ if (m) {
224
+ measured = { clips: m.clips.length, verbs: m.clips, kb: k, rate: m.clipRate, seconds: m.clipSeconds, keys: m.clipKeys };
225
+ if (m.clipRate > SKINNING.clipRate + 0.5) warnings.push(`sampled at about ${m.clipRate} keys a second (30 is plenty: homie-studio anim add bakes at 30)`);
226
+ if (!m.clips.length) problems.push('it holds no clips');
227
+ }
228
+ }
229
+ }
230
+ return { id: a.id, kind: 'clip', tier: 'clip', route: a.route, ok: problems.length === 0, problems, warnings, measured: measured ? { tris: 0, drawCalls: 0, materials: 0, maxTexturePx: 0, textureKB: 0, bones: 0, heightM: null, kb: measured.kb, clips: measured.clips, verbs: measured.verbs, rate: measured.rate } : null, budget: { kb: SKINNING.clipKB }, placements: 1 };
231
+ }
232
+
168
233
  /** Whether a game's source is shared (game.json share.source is not false) and it is meant to be public. */
169
234
  export function publicSource(root, id) {
170
235
  try { const g = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); return g.share?.source !== false; } catch { return true; }
@@ -175,13 +240,15 @@ export function checkLines(r) {
175
240
  const lines = [`${r.ok ? 'PASS' : 'NOT YET'}: ${r.game}'s ${r.totals.assets} asset${r.totals.assets === 1 ? '' : 's'} against the phone budgets`];
176
241
  for (const row of r.rows) {
177
242
  const m = row.measured;
178
- lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (${row.kind}, ${row.tier}, ${row.route})${m ? `: ${m.tris} triangles, ${m.drawCalls} draw${m.drawCalls === 1 ? '' : 's'}, ${m.kb} KB, ${m.maxTexturePx ? `${m.maxTexturePx} px pictures` : 'no pictures'}${m.heightM ? `, ${m.heightM} m` : ''}` : ''}`);
243
+ if (row.kind === 'clip') { lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (clip library, ${row.route})${m ? `: ${m.clips} clips (${(m.verbs ?? []).join(', ')}), ${m.kb} KB, ${m.rate} keys a second` : ''}`); for (const p of row.problems) lines.push(` ${p}`); for (const w of row.warnings) lines.push(` note: ${w}`); continue; }
244
+ lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (${row.kind}, ${row.tier}, ${row.route})${m ? `: ${m.tris} triangles, ${m.drawCalls} draw${m.drawCalls === 1 ? '' : 's'}, ${m.kb} KB, ${m.maxTexturePx ? `${m.maxTexturePx} px pictures` : 'no pictures'}${m.heightM ? `, ${m.heightM} m` : ''}${m.bones ? `, ${m.bones} bones` : ''}` : ''}`);
179
245
  for (const p of row.problems) lines.push(` ${p}`);
180
246
  for (const w of row.warnings) lines.push(` note: ${w}`);
181
247
  }
182
248
  for (const u of r.unrecorded) lines.push(` FIX ${u}: shipped with no record (homie-studio assets add ${r.game} --file ${u} --license <kind>)`);
183
249
  const s = r.budgets;
184
250
  lines.push(` scene: about ${r.totals.drawCalls} draw calls (budget ${s.drawCalls}), ${r.totals.triangles.toLocaleString('en-US')} triangles (${s.triangles.toLocaleString('en-US')}), ${r.totals.textureMB} MB of picture memory (${s.textureMB} MB)${r.totals.firstPlayMB !== null ? `, ${r.totals.firstPlayMB} MB first play (${s.firstPlayMB} MB)` : ', first play: build first'}`);
251
+ if (r.skinning) lines.push(` skinning: a room of ${r.skinning.players} skins about ${r.skinning.vertices.toLocaleString('en-US')} vertices (budget ${r.skinning.budget.vertices.toLocaleString('en-US')}) and poses ${r.skinning.bones.toLocaleString('en-US')} bones (${r.skinning.budget.bones.toLocaleString('en-US')}) a frame on a phone; the heaviest is ${r.skinning.character}`);
185
252
  for (const l of r.licence) lines.push(` ${l.level === 'refuse' ? 'FIX ' : 'note'} licence ${l.asset}: ${l.problem}${l.fix ? ` (${l.fix})` : ''}`);
186
253
  for (const st of r.stale) lines.push(` stale ${st.id}: made under an older ${st.decisions.map((d) => d.id).join(', ')} (homie-studio style blast shows the cost; nothing is remade by itself)`);
187
254
  for (const p of r.problems.filter((x) => !/^[a-z0-9-]+: /.test(x))) lines.push(` FIX ${p}`);