@homie-rocks/studio 0.24.4 → 0.25.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 +55 -1
- package/README.md +50 -3
- package/assets/animate.ts +521 -0
- package/assets/render-page.ts +77 -4
- package/bin/homie-studio.mjs +13 -4
- package/chat/CHAT.md +36 -5
- package/lib/art-cli.mjs +105 -5
- package/lib/art-tools.mjs +168 -1
- package/lib/asset-check.mjs +72 -5
- package/lib/build.mjs +3 -0
- package/lib/characters.mjs +380 -0
- package/lib/clips.mjs +417 -0
- package/lib/decisions.mjs +38 -8
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +2 -2
- package/lib/optimise.mjs +79 -3
- package/lib/render3d.mjs +2 -0
- package/lib/rig.mjs +431 -0
- package/lib/scaffold.mjs +17 -4
- package/lib/studio.mjs +18 -3
- package/lib/style-board.mjs +3 -1
- package/lib/template-history.json +78 -0
- package/mcp/ui/animation.js +56 -0
- package/mcp/ui/card.css +4 -0
- package/mcp/ui/cast.js +36 -2
- package/mcp/ui/lineup.js +7 -7
- package/netplay/NETPLAY.md +9 -5
- package/package.json +5 -1
- package/site/SITE.md +4 -0
- package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
- package/starters/hero-rush-3d/assets/manifest.json +1471 -0
- package/starters/hero-rush-3d/credits.json +40 -0
- package/starters/hero-rush-3d/game.json +11 -0
- package/starters/hero-rush-3d/index.html +20 -0
- package/starters/hero-rush-3d/lab.json +32 -0
- package/starters/hero-rush-3d/src/main.ts +2361 -0
- package/starters/hero-rush-3d/style.json +68 -0
- package/starters/hero-rush-3d/tunables.json +32 -0
- package/worker/chat-page.mjs +173 -28
- package/worker/chat.mjs +4 -1
- package/worker/version.mjs +1 -1
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
-
|
|
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/chat/CHAT.md
CHANGED
|
@@ -14,9 +14,12 @@ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
|
|
|
14
14
|
|
|
15
15
|
- **The Chat pill** on the play page, beside the room button: the room's last minutes, the
|
|
16
16
|
reactions, the quick lines, typing where allowed, Report on a line, and two switches ("Show my
|
|
17
|
-
messages over my character", "Show chat on this screen").
|
|
17
|
+
messages over my character", "Show chat on this screen"). On a phone it is a round icon, as the
|
|
18
|
+
room button is, with the count of new lines on its corner; on a computer it says "Chat" unless
|
|
19
|
+
the word would reach the middle of the top edge, where games keep a clock or a title.
|
|
18
20
|
- **The float:** every reaction rises up every screen in the room.
|
|
19
|
-
- **The
|
|
21
|
+
- **The strip:** a new line shows for a moment where the play page's status chip sits (the bottom
|
|
22
|
+
left), or where the game says (below).
|
|
20
23
|
- **The big screen** (`/<game>/tv`) shows the room's lines in a corner; the **watch page** has a
|
|
21
24
|
Chat button; **homie.rocks** shows the room's chat on its card when the studio allows it.
|
|
22
25
|
|
|
@@ -49,9 +52,37 @@ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
|
|
|
49
52
|
| `block`, `allow` | the studio's own words to hold (`word*` inside words) or to let through | none |
|
|
50
53
|
|
|
51
54
|
`"chat": false` turns it off. A kids server, and every beginner server, keeps chat to emoji and
|
|
52
|
-
quick lines whatever the game says.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
quick lines whatever the game says.
|
|
56
|
+
|
|
57
|
+
**Where it sits on the screen, in game.json `"screen"`**, so a game with a busy HUD keeps typed
|
|
58
|
+
chat without anything covering its clock, its title or its controls. The Chat pill sits in the
|
|
59
|
+
room button's band, so `"screen": { "share": … }` places both (`"label": false` keeps both round
|
|
60
|
+
icons). `"screen": { "chat": … }` places the strip of new lines and the big screen's corner:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
"screen": {
|
|
64
|
+
"share": { "phone": { "at": "top-left", "label": false } },
|
|
65
|
+
"chat": {
|
|
66
|
+
"desk": { "at": "top-left", "y": 130 },
|
|
67
|
+
"phone": { "lines": "sheet-only" },
|
|
68
|
+
"tv": { "at": "top-right" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Field | | Default |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `at` | `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center` or `bottom-right` | the bottom left (the bottom right when the room button is at the bottom left) |
|
|
76
|
+
| `x`, `y` | move it in from its side and its edge, in CSS pixels (0 to 600; in the middle of an edge, `x` moves it either way). Sharing the room button's place, it also sits past the button | 0 |
|
|
77
|
+
| `lines` | `strip` (new lines show for a moment), or `sheet-only` (they wait in the Chat sheet and the pill counts them) | `strip` |
|
|
78
|
+
| `desk`, `phone`, `sideways`, `tv` | a computer, a phone held upright, a phone turned sideways (else as `phone`), the big screen: each changes only what it names | |
|
|
79
|
+
|
|
80
|
+
A plain string (`"chat": "bottom-right"`) is a place for every device, and `false` is
|
|
81
|
+
`"lines": "sheet-only"` for every device. The big screen has no sheet: it takes a corner only
|
|
82
|
+
(the top level's when that is a corner, else the one its join card leaves free) and keeps its
|
|
83
|
+
lines unless its own `tv` entry says `"sheet-only"`, which leaves it the float. A wrong field is
|
|
84
|
+
the default on the page, and `homie-studio build` says which. Look at the play page on a phone
|
|
85
|
+
and a computer while a round is on and somebody says something, and at `/<id>/tv`.
|
|
55
86
|
|
|
56
87
|
**Speech bubbles over characters**, with the port kit (the starters draw them):
|
|
57
88
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|