@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.
- package/CHANGELOG.md +54 -1
- package/README.md +56 -6
- package/assets/animate.ts +521 -0
- package/assets/render-page.ts +77 -4
- package/bin/homie-studio.mjs +13 -4
- package/lib/art-cli.mjs +105 -5
- package/lib/art-tools.mjs +168 -1
- package/lib/asset-check.mjs +72 -5
- 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/perf.mjs +15 -7
- package/lib/render3d.mjs +2 -0
- package/lib/rig.mjs +431 -0
- package/lib/scaffold.mjs +7 -0
- package/lib/site.mjs +14 -3
- 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 +38 -2
- package/netplay/netplay.ts +58 -3
- package/package.json +5 -1
- package/site/SITE.md +19 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/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 +12 -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 +2588 -0
- package/starters/hero-rush-3d/style.json +68 -0
- package/starters/hero-rush-3d/tunables.json +32 -0
- package/worker/arrival.mjs +207 -0
- package/worker/pages.mjs +23 -5
- package/worker/site.mjs +26 -4
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHARACTERS AND THEIR CLIPS IN A GAME (free, on this computer): a rigged character comes in from the starter library,
|
|
3
|
+
* the person's files or a provider, and leaves as two files a phone can afford:
|
|
4
|
+
*
|
|
5
|
+
* public/models/<asset>.glb the character: its joints renamed to the skeleton standard (lib/rig.mjs), helper
|
|
6
|
+
* bones that move nothing removed, every part and every held thing it keeps merged
|
|
7
|
+
* into one skinned mesh (one draw call), phone-sized, no clips
|
|
8
|
+
* public/anims/<skeleton>.glb the clip library of its skeleton: one clip per verb the game uses (idle, run, jump,
|
|
9
|
+
* attack, hit, die, ...), its own where it has them, else RETARGETED from a source
|
|
10
|
+
* (KayKit's CC0 humanoid clips by default) at build time; every character with that
|
|
11
|
+
* skeleton shares it
|
|
12
|
+
*
|
|
13
|
+
* Both are recorded in assets/manifest.json (the character, kind character or creature; the library, kind clip) with
|
|
14
|
+
* where every clip came from and its licence, so RIGHTS.md, the remix carry-over and `assets check` see them. The model
|
|
15
|
+
* names its clip library in its scene's extras (homie.anims), so the game's `loadCharacter(models, url)` finds it.
|
|
16
|
+
*
|
|
17
|
+
* addCharacter(root, game, { item | file, ... }) in, optimised, clips baked, recorded
|
|
18
|
+
* bakeClips(root, game, asset, { verbs, from }) more verbs for a character's skeleton (retargeted from a source)
|
|
19
|
+
* animPlan(root, game) each character: its skeleton, its clips against the verbs the game
|
|
20
|
+
* needs (anim.clips), what is missing and where it would come from
|
|
21
|
+
* animPreview(root, game, { asset, verbs }) looping previews: each verb as a small animated WebP, and a sheet
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { dirname, join, resolve } from 'node:path';
|
|
26
|
+
import { LIMITS, checkGlb } from '../assets/safety.mjs';
|
|
27
|
+
import { readManifest, recordAsset } from './asset-manifest.mjs';
|
|
28
|
+
import { VERBS, VERB_IDS, bakeLibrary, findClip, rigOf, writeLibrary } from './clips.mjs';
|
|
29
|
+
import { readDecisions } from './decisions.mjs';
|
|
30
|
+
import { FAMILIES, normaliseRig, skeletonOf, socketBone } from './rig.mjs';
|
|
31
|
+
import { GAME_ID } from './studio.mjs';
|
|
32
|
+
|
|
33
|
+
/** Where humanoid clips come from when a character has none of its own: KayKit's adventurers (CC0, 76 clips). */
|
|
34
|
+
export const CLIP_SOURCES = Object.freeze({
|
|
35
|
+
humanoid: 'kaykit-adventurers/knight',
|
|
36
|
+
mini: 'kaykit-adventurers/knight',
|
|
37
|
+
});
|
|
38
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
39
|
+
const kb = (n) => Math.round(n / 1024);
|
|
40
|
+
|
|
41
|
+
/** The game's verbs: its anim.clips decision (words mapped to VERBS), always idle, plus any asked for. */
|
|
42
|
+
export function verbsFor(doc, extra = []) {
|
|
43
|
+
const want = [...(doc?.decisions?.['anim.clips']?.value ?? ['idle', 'run', 'jump', 'attack', 'hit']), ...extra];
|
|
44
|
+
const alias = { 'pick-up': 'pickup', pick: 'pickup', kick: 'attack2', aim: 'shoot', boost: 'run', crash: 'hit', walk: 'walk', swing: 'attack', punch: 'attack', bump: 'attack', cheer: 'emote', wave: 'emote', dance: 'emote', victory: 'win' };
|
|
45
|
+
const out = new Set(['idle']);
|
|
46
|
+
for (const w of want) { const v = alias[w] ?? w; if (VERB_IDS.includes(v)) out.add(v); }
|
|
47
|
+
return [...out];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The model tools, once. */
|
|
51
|
+
async function tools() { const { modelTools } = await import('./optimise.mjs'); return modelTools(); }
|
|
52
|
+
|
|
53
|
+
/** A Document from bytes (or a path resolving its sibling pictures). */
|
|
54
|
+
async function docOf(input) {
|
|
55
|
+
const t = await tools();
|
|
56
|
+
const { selfContained } = await import('./optimise.mjs');
|
|
57
|
+
const bytes = await selfContained(input, LIMITS.import);
|
|
58
|
+
const safe = checkGlb(bytes, LIMITS.import);
|
|
59
|
+
if (!safe.ok) throw new Error(`refused: ${safe.problems.slice(0, 3).join('; ')}`);
|
|
60
|
+
const doc = await t.io.readBinary(bytes);
|
|
61
|
+
doc.setLogger(t.logger);
|
|
62
|
+
return { doc, bytes };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A clip source (a library item id, or a file): its Document renamed to the standard, and its clip names. */
|
|
66
|
+
async function clipSource(from, { lib = null, cache = new Map() } = {}) {
|
|
67
|
+
if (cache.has(from)) return cache.get(from);
|
|
68
|
+
let bytes;
|
|
69
|
+
if (existsSync(resolve(String(from)))) bytes = readFileSync(resolve(String(from)));
|
|
70
|
+
else {
|
|
71
|
+
const { fetchItemFile, libraryBase, loadIndex } = await import('./library.mjs');
|
|
72
|
+
const L = lib ?? libraryBase();
|
|
73
|
+
const { index } = await loadIndex({ lib: L });
|
|
74
|
+
const item = index.items.find((x) => x.id === from);
|
|
75
|
+
if (!item) throw new Error(`no library item "${from}" to take clips from`);
|
|
76
|
+
if (item.license !== 'cc0') throw new Error(`${from} is not CC0`);
|
|
77
|
+
({ bytes } = await fetchItemFile(L, item));
|
|
78
|
+
}
|
|
79
|
+
const { doc } = await docOf(bytes);
|
|
80
|
+
normaliseRig(doc, { merge: false, helpers: false, takeClips: false });
|
|
81
|
+
const src = { from: String(from), doc, rig: rigOf(doc), names: doc.getRoot().listAnimations().map((a) => a.getName()) };
|
|
82
|
+
cache.set(from, src);
|
|
83
|
+
return src;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The clips for `verbs` on `target` (a rig): the character's own where it has one, else from `fallback` (retargeted).
|
|
88
|
+
* Returns { sources: [{ rig, anim, verb, from }], missing: [verb] }.
|
|
89
|
+
*/
|
|
90
|
+
async function pickSources(own, verbs, fallback, opts) {
|
|
91
|
+
const sources = []; const missing = [];
|
|
92
|
+
let fb = null;
|
|
93
|
+
for (const verb of verbs) {
|
|
94
|
+
const mine = own ? findClip(own.names, verb) : null;
|
|
95
|
+
if (mine) { sources.push({ rig: own.rig, anim: own.doc.getRoot().listAnimations().find((a) => a.getName() === mine), verb, from: own.from }); continue; }
|
|
96
|
+
if (fallback && !fb) { try { fb = await clipSource(fallback, opts); } catch (error) { fb = { error: error.message }; } }
|
|
97
|
+
const theirs = fb && !fb.error ? findClip(fb.names, verb) : null;
|
|
98
|
+
if (theirs) sources.push({ rig: fb.rig, anim: fb.doc.getRoot().listAnimations().find((a) => a.getName() === theirs), verb, from: fb.from });
|
|
99
|
+
else missing.push(verb);
|
|
100
|
+
}
|
|
101
|
+
return { sources, missing, fallbackError: fb?.error ?? null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A clip library file's path in a game, for a skeleton. */
|
|
105
|
+
export const animsPath = (skeleton) => `public/anims/${skeleton}.glb`;
|
|
106
|
+
|
|
107
|
+
/** Read a clip library already in the game (its verbs), or null. */
|
|
108
|
+
async function existingLibrary(gdir, skeleton) {
|
|
109
|
+
const file = join(gdir, animsPath(skeleton));
|
|
110
|
+
if (!existsSync(file)) return null;
|
|
111
|
+
const t = await tools();
|
|
112
|
+
const doc = await t.io.readBinary(readFileSync(file));
|
|
113
|
+
doc.setLogger(t.logger);
|
|
114
|
+
return { doc, verbs: doc.getRoot().listAnimations().map((a) => a.getName()) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Copy the animations of `from` (a baked library Document) into `into` (an existing one), by node name. */
|
|
118
|
+
function appendClips(into, from) {
|
|
119
|
+
const root = into.getRoot();
|
|
120
|
+
const buffer = root.listBuffers()[0] ?? into.createBuffer();
|
|
121
|
+
const nodes = new Map(root.listNodes().map((n) => [n.getName(), n]));
|
|
122
|
+
for (const a of from.getRoot().listAnimations()) {
|
|
123
|
+
for (const old of root.listAnimations().filter((x) => x.getName() === a.getName())) old.dispose();
|
|
124
|
+
const b = into.createAnimation(a.getName()).setExtras(a.getExtras());
|
|
125
|
+
for (const ch of a.listChannels()) {
|
|
126
|
+
const node = nodes.get(ch.getTargetNode()?.getName());
|
|
127
|
+
if (!node) continue;
|
|
128
|
+
const s = ch.getSampler();
|
|
129
|
+
const sampler = into.createAnimationSampler()
|
|
130
|
+
.setInput(into.createAccessor().setType('SCALAR').setArray(s.getInput().getArray().slice()).setBuffer(buffer))
|
|
131
|
+
.setOutput(into.createAccessor().setType(s.getOutput().getType()).setArray(s.getOutput().getArray().slice()).setBuffer(buffer))
|
|
132
|
+
.setInterpolation(s.getInterpolation());
|
|
133
|
+
b.addSampler(sampler).addChannel(into.createAnimationChannel().setTargetNode(node).setTargetPath(ch.getTargetPath()).setSampler(sampler));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const ex = root.getExtras()?.homie ?? {};
|
|
137
|
+
root.setExtras({ homie: { ...ex, verbs: root.listAnimations().map((x) => x.getName()) } });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Bake (or extend) a skeleton's clip library in a game and record it. `rig`: the normalised character's rig;
|
|
142
|
+
* `own`: its own clips (a clip source) or null. Returns { path, skeleton, verbs, clips, missing, bytes, entry }.
|
|
143
|
+
*/
|
|
144
|
+
async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, cache = new Map(), license = null } = {}) {
|
|
145
|
+
const gdir = join(root, 'games', game);
|
|
146
|
+
const skel = rig.skeleton;
|
|
147
|
+
const fallback = from ?? CLIP_SOURCES[skel.family] ?? null;
|
|
148
|
+
const existing = await existingLibrary(gdir, skel.id);
|
|
149
|
+
const todo = verbs.filter((v) => !existing?.verbs.includes(v));
|
|
150
|
+
const { sources, missing, fallbackError } = await pickSources(own, todo, fallback, { lib, cache });
|
|
151
|
+
const t = await tools();
|
|
152
|
+
const rel = animsPath(skel.id);
|
|
153
|
+
const manifest = readManifest(root, game);
|
|
154
|
+
const entryId = `anims-${skel.id}`.slice(0, 48);
|
|
155
|
+
const prev = manifest.assets.find((a) => a.id === entryId);
|
|
156
|
+
let doc; let clips;
|
|
157
|
+
if (sources.length || !existing) {
|
|
158
|
+
({ doc, clips } = await bakeLibrary(rig, sources, { core: t.core, logger: t.logger }));
|
|
159
|
+
if (existing) { appendClips(existing.doc, doc); doc = existing.doc; }
|
|
160
|
+
} else { doc = existing.doc; clips = []; }
|
|
161
|
+
if (!sources.length && existing) return { path: rel, skeleton: skel.id, verbs: existing.verbs, clips: prev?.clips ?? [], missing, bytes: null, entry: prev ?? null, fallbackError };
|
|
162
|
+
const out = await writeLibrary(doc, { tools: t });
|
|
163
|
+
mkdirSync(dirname(join(gdir, rel)), { recursive: true });
|
|
164
|
+
writeFileSync(join(gdir, rel), out.bytes);
|
|
165
|
+
const allClips = [...(prev?.clips ?? []).filter((c) => !clips.some((n) => n.verb === c.verb)), ...clips].sort((a, b) => VERB_IDS.indexOf(a.verb) - VERB_IDS.indexOf(b.verb));
|
|
166
|
+
const froms = [...new Set(allClips.map((c) => c.from).filter(Boolean))];
|
|
167
|
+
// Clips taken from CC0 sources (KayKit, Kenney) stay CC0 when retargeted; a character's own clips carry its licence.
|
|
168
|
+
const lic = license && license.kind !== 'cc0' && allClips.some((c) => c.from && !String(c.from).includes('/')) ? license : { kind: 'cc0', spdx: 'CC0-1.0', owner: null, attribution: null, remix: 'include', notes: `Clips from ${froms.join(', ') || 'the character itself'} (CC0), ${allClips.some((c) => c.retargeted) ? 'retargeted onto this skeleton at build time' : 'as they came'}.` };
|
|
169
|
+
const entry = {
|
|
170
|
+
id: entryId, kind: 'clip', tier: 'clip', route: allClips.every((c) => !c.from || String(c.from).includes('/')) ? 'library' : 'imported',
|
|
171
|
+
card: null,
|
|
172
|
+
from: froms.length ? { library: 'homie-starter', items: froms } : null,
|
|
173
|
+
files: [{ role: 'clip', path: rel, bytes: out.bytes.length, sha256: sha256(out.bytes) }],
|
|
174
|
+
rig: { family: skel.family, skeleton: skel.id, fingerprint: skel.fingerprint },
|
|
175
|
+
clips: allClips,
|
|
176
|
+
measured: { clips: out.clips, keys: out.keys, kb: kb(out.bytes.length), fps: 30, verbs: allClips.map((c) => c.verb) },
|
|
177
|
+
license: lic,
|
|
178
|
+
made: { steps: [{ what: 'clips', tool: 'lib/clips.mjs (retarget and bake)', verbs: allClips.map((c) => `${c.verb}<${c.source}${c.retargeted ? ' (retargeted)' : ''}`) }] },
|
|
179
|
+
review: { state: 'auto' },
|
|
180
|
+
};
|
|
181
|
+
const { entry: e } = recordAsset(root, game, entry);
|
|
182
|
+
return { path: rel, skeleton: skel.id, verbs: allClips.map((c) => c.verb), clips: allClips, missing, bytes: out.bytes.length, entry: e, fallbackError };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One rigged (or node-animated) character into a game. Options:
|
|
187
|
+
* item a starter-library item id (kaykit-adventurers/knight), or
|
|
188
|
+
* file a .glb on this computer, with license (own, cc0, cc-by-4.0, generated, ...)
|
|
189
|
+
* as the asset id; kind character (default) or creature; card ("Characters/Knight")
|
|
190
|
+
* height metres to scale it to (default: the game's cast.scale character height, else the item's suggestion)
|
|
191
|
+
* keep the held things to keep (a sword, a shield, a hat: names; part swaps); null keeps them all
|
|
192
|
+
* verbs verbs to bake besides the game's anim.clips
|
|
193
|
+
* clipsFrom where clips it lacks come from (a library item or a file); default by its skeleton family
|
|
194
|
+
* texture largest picture side (default 512: a character is a hundred pixels tall on a phone)
|
|
195
|
+
* triangles budget (default: the tier's, a hero 8,000)
|
|
196
|
+
* route, steps, raw, concept, owner, attribution, notes: for a generated or imported file (as importModel takes)
|
|
197
|
+
* Returns { ok, asset, model, anims, skeleton, verbs, missing, before, after, merged, dropped, removedHelpers }.
|
|
198
|
+
*/
|
|
199
|
+
export async function addCharacter(root, game, opts = {}) {
|
|
200
|
+
if (!GAME_ID.test(String(game ?? ''))) throw new Error(`"${game}" is not a game id`);
|
|
201
|
+
const gdir = join(root, 'games', game);
|
|
202
|
+
if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${game}"`);
|
|
203
|
+
const t = await tools();
|
|
204
|
+
const { optimiseModel, measureDoc } = await import('./optimise.mjs');
|
|
205
|
+
const decisions = readDecisions(root, game);
|
|
206
|
+
let bytes; let item = null; let idx = null; let lib = opts.lib ?? null;
|
|
207
|
+
if (opts.item) {
|
|
208
|
+
const L = await import('./library.mjs');
|
|
209
|
+
lib = lib ?? L.libraryBase();
|
|
210
|
+
idx = opts.index ?? (await L.loadIndex({ lib })).index;
|
|
211
|
+
item = idx.items.find((x) => x.id === opts.item) ?? idx.items.find((x) => x.item === opts.item);
|
|
212
|
+
if (!item) throw new Error(`no library item "${opts.item}" (assets find "<words>" --kind character lists them)`);
|
|
213
|
+
if (item.license !== 'cc0') throw new Error(`${item.id} is not CC0; the starter library holds only CC0`);
|
|
214
|
+
({ bytes } = await L.fetchItemFile(lib, item));
|
|
215
|
+
} else if (opts.file) {
|
|
216
|
+
const { screenImport } = await import('./asset-import.mjs');
|
|
217
|
+
screenImport(resolve(root, String(opts.file)));
|
|
218
|
+
bytes = (await docOf(resolve(root, String(opts.file)))).bytes;
|
|
219
|
+
} else throw new Error('a library item or a file');
|
|
220
|
+
const id = String(opts.as ?? item?.item ?? 'character').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
|
|
221
|
+
const kind = opts.kind === 'creature' || item?.kind === 'creature' ? 'creature' : 'character';
|
|
222
|
+
const cache = new Map();
|
|
223
|
+
|
|
224
|
+
// Its own clips, renamed to the standard (a separate copy: the model itself loses them).
|
|
225
|
+
const ownDoc = (await docOf(bytes)).doc;
|
|
226
|
+
const raw = skeletonOf(ownDoc);
|
|
227
|
+
if (raw.family === 'none') throw new Error(`${item?.id ?? opts.file} has no skeleton and no moving parts: add it as a prop (assets add without a rig), or rig it first (the animate guide)`);
|
|
228
|
+
normaliseRig(ownDoc, { merge: false, helpers: false, takeClips: false });
|
|
229
|
+
const own = ownDoc.getRoot().listAnimations().length ? { from: item?.id ?? 'own', doc: ownDoc, rig: rigOf(ownDoc), names: ownDoc.getRoot().listAnimations().map((a) => a.getName()) } : null;
|
|
230
|
+
|
|
231
|
+
// The character as shipped: renamed, helpers out, merged, clips out.
|
|
232
|
+
const { doc } = await docOf(bytes);
|
|
233
|
+
const rigged = normaliseRig(doc, { keep: opts.keep ?? null, takeClips: true });
|
|
234
|
+
const rig = rigOf(doc);
|
|
235
|
+
const verbs = verbsFor(decisions, opts.verbs ?? []);
|
|
236
|
+
const anims = await bakeFor(root, game, rig, own, verbs, { from: opts.clipsFrom ?? null, lib, cache, license: opts.license ? { kind: opts.license } : null });
|
|
237
|
+
|
|
238
|
+
// The model: phone-sized, its clip library named in its scene's extras.
|
|
239
|
+
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0];
|
|
240
|
+
scene.setExtras({ ...(scene.getExtras() ?? {}), homie: { kind, skeleton: rig.skeleton.id, family: rig.skeleton.family, anims: `../anims/${rig.skeleton.id}.glb`, sockets: Object.fromEntries(['rightHand', 'leftHand', 'head', 'back'].map((s) => [s, socketBone(rig.nodes.map((n) => n.name), s)]).filter(([, b]) => b)) } });
|
|
241
|
+
const normalised = await t.io.writeBinary(doc);
|
|
242
|
+
const castH = Number(decisions?.decisions?.['cast.scale']?.value?.characterHeightM ?? 0);
|
|
243
|
+
const height = Number(opts.height ?? 0) || castH || Number(item?.suggestM ?? 0) || null;
|
|
244
|
+
const tri = Number(opts.triangles ?? 0) || (kind === 'creature' ? 3000 : 8000);
|
|
245
|
+
const r = await optimiseModel(normalised, { height, triangles: tri, texture: Number(opts.texture ?? 512), rigged: true, metal: false });
|
|
246
|
+
const rel = `public/models/${id}.glb`;
|
|
247
|
+
mkdirSync(dirname(join(gdir, rel)), { recursive: true });
|
|
248
|
+
writeFileSync(join(gdir, rel), r.glb);
|
|
249
|
+
const after = r.after;
|
|
250
|
+
const files = [{ role: 'model', path: rel, bytes: r.bytes, sha256: r.sha256 }];
|
|
251
|
+
if (opts.rawPath) files.push({ role: 'raw', path: opts.rawPath, public: false });
|
|
252
|
+
if (opts.concept) files.push({ role: 'concept', path: String(opts.concept).startsWith('../') ? String(opts.concept) : `../../${String(opts.concept).replace(/^\.\//, '')}`, public: false });
|
|
253
|
+
const steps = [
|
|
254
|
+
...(item ? [{ what: 'library', item: item.id, sha256: item.files?.[0]?.sha256 ?? null }] : []),
|
|
255
|
+
...(opts.steps ?? []),
|
|
256
|
+
{ what: 'rig', tool: 'lib/rig.mjs', family: rig.skeleton.family, skeleton: rig.skeleton.id, renamed: rigged.renamed, removedHelpers: rigged.removedHelpers.length, merged: rigged.merged, dropped: rigged.dropped },
|
|
257
|
+
{ what: 'clips', anims: anims.path, verbs: anims.verbs },
|
|
258
|
+
{ what: 'optimise', tool: r.tool, ops: r.ops },
|
|
259
|
+
];
|
|
260
|
+
const license = item
|
|
261
|
+
? { kind: 'cc0', spdx: 'CC0-1.0', owner: item.author ?? null, attribution: null, remix: 'include', notes: `From the Homie starter library (${item.pack}); the pack's own licence text: ${idx?.packs?.find((p) => p.id === item.pack)?.licenseFile ?? 'licenses/'}` }
|
|
262
|
+
: { kind: String(opts.license ?? ''), owner: opts.owner ?? (opts.license === 'generated' || opts.license === 'own' ? 'studio' : null), attribution: opts.attribution ?? null, notes: opts.notes ?? (opts.license === 'generated' ? 'Made through fal on the studio\'s own account (a concept image, then Meshy image-to-3D with its auto-rig); its clips are the starter library\'s CC0 clips retargeted onto its skeleton. fal\'s terms do not assign output ownership expressly (RIGHTS.md).' : null) };
|
|
263
|
+
const entry = {
|
|
264
|
+
id, kind, tier: kind === 'creature' ? 'npc' : 'hero', card: opts.card ?? null, route: item ? 'library' : (opts.route === 'generated' ? 'generated' : 'imported'),
|
|
265
|
+
...(item ? { from: { library: 'homie-starter', version: idx?.version ?? 'v0', pack: item.pack, packLabel: idx?.packs?.find((p) => p.id === item.pack)?.label ?? item.pack, packUrl: idx?.packs?.find((p) => p.id === item.pack)?.url ?? null, item: item.id, author: item.author ?? null, origin: item.origin ?? null, sha256: item.files?.[0]?.sha256 ?? null, paletteSwap: Boolean(item.paletteSwap) } } : {}),
|
|
266
|
+
files,
|
|
267
|
+
rig: { family: rig.skeleton.family, skeleton: rig.skeleton.id, fingerprint: rig.skeleton.fingerprint, skinned: rig.skeleton.skinned, bones: after.bones || rig.skeleton.bones, deform: rig.skeleton.deform, influences: rig.skeleton.influences, anims: anims.path, verbs: anims.verbs, keep: rigged.kept, dropped: rigged.dropped },
|
|
268
|
+
measured: { tris: after.triangles, materials: after.materials, drawCalls: after.drawCalls, textures: after.textures.map((x) => ({ px: x.px, format: x.mimeType, gpuKB: Math.round(x.gpuBytes / 1024) })), bones: after.bones, clips: anims.verbs.length, heightM: after.heightM, box: after.box, glbKB: kb(r.bytes), animsKB: anims.bytes ? kb(anims.bytes) : null },
|
|
269
|
+
license,
|
|
270
|
+
made: { steps },
|
|
271
|
+
review: { state: 'auto' },
|
|
272
|
+
};
|
|
273
|
+
const { entry: e, pinned } = recordAsset(root, game, entry);
|
|
274
|
+
return { ok: true, command: 'assets add', game, asset: e.id, item: item?.id ?? null, route: e.route, raw: opts.rawPath ?? null, license: e.license.kind, model: rel, anims: anims.path, skeleton: rig.skeleton.id, family: rig.skeleton.family, verbs: anims.verbs, missing: anims.missing, clipsFrom: anims.clips.filter((c) => c.retargeted).map((c) => c.from).filter((v, i, a) => a.indexOf(v) === i), merged: rigged.merged, dropped: rigged.dropped, removedHelpers: rigged.removedHelpers.length, before: { tris: r.before.triangles, kb: kb(r.rawBytes), drawCalls: r.before.drawCalls }, after: { tris: after.triangles, kb: kb(r.bytes), heightM: after.heightM, drawCalls: after.drawCalls, bones: after.bones, animsKB: anims.bytes ? kb(anims.bytes) : null }, pinned, warnings: r.warnings, fallbackError: anims.fallbackError };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Whether a library item is a character with a rig or moving parts (assets add sends those here). */
|
|
278
|
+
export const isAnimated = (item) => Boolean(item && (item.rigged || (item.clips ?? []).length >= 1) && ['character', 'creature'].includes(item.kind));
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* More verbs for a character already in the game: baked into its skeleton's clip library, retargeted from `from`
|
|
282
|
+
* (a library item or a file; default: its family's clip source). Returns the bake's result.
|
|
283
|
+
*/
|
|
284
|
+
export async function bakeClips(root, game, asset, { verbs = [], from = null, lib = null } = {}) {
|
|
285
|
+
const m = readManifest(root, game);
|
|
286
|
+
const a = m.assets.find((x) => x.id === asset);
|
|
287
|
+
if (!a?.rig) throw new Error(`${asset} is not a rigged character in ${game} (assets add brings one in)`);
|
|
288
|
+
const t = await tools();
|
|
289
|
+
const model = a.files.find((f) => f.role === 'model');
|
|
290
|
+
const doc = await t.io.readBinary(readFileSync(join(root, 'games', game, model.path)));
|
|
291
|
+
doc.setLogger(t.logger);
|
|
292
|
+
const rig = rigOf(doc);
|
|
293
|
+
if (rig.skeleton.id !== a.rig.skeleton) rig.skeleton.id = a.rig.skeleton;
|
|
294
|
+
const want = verbs.filter((v) => VERB_IDS.includes(v));
|
|
295
|
+
if (!want.length) throw new Error(`which verbs? ${VERB_IDS.join(', ')}`);
|
|
296
|
+
let own = null;
|
|
297
|
+
if (a.from?.item && !from) { try { own = await clipSource(a.from.item, { lib }); } catch { own = null; } }
|
|
298
|
+
const r = await bakeFor(root, game, rig, own, want, { from, lib });
|
|
299
|
+
// The character's own record learns its verbs.
|
|
300
|
+
const fresh = readManifest(root, game);
|
|
301
|
+
const me = fresh.assets.find((x) => x.id === asset);
|
|
302
|
+
if (me?.rig) { me.rig.verbs = r.verbs; me.measured = { ...(me.measured ?? {}), clips: r.verbs.length }; const { writeManifest } = await import('./asset-manifest.mjs'); writeManifest(root, game, fresh); }
|
|
303
|
+
return { ok: true, command: 'anim add', game, asset, skeleton: r.skeleton, anims: r.path, verbs: r.verbs, added: want.filter((v) => r.verbs.includes(v)), missing: r.missing, kb: r.bytes ? kb(r.bytes) : null, why: r.fallbackError };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Each character, its skeleton and its clips against the verbs the game needs. */
|
|
307
|
+
export function animPlan(root, game) {
|
|
308
|
+
const m = readManifest(root, game);
|
|
309
|
+
const doc = readDecisions(root, game);
|
|
310
|
+
const need = verbsFor(doc);
|
|
311
|
+
const libs = new Map(m.assets.filter((a) => a.kind === 'clip').map((a) => [a.rig?.skeleton, a]));
|
|
312
|
+
const rows = m.assets.filter((a) => (a.kind === 'character' || a.kind === 'creature') && a.rig).map((a) => {
|
|
313
|
+
const L = libs.get(a.rig.skeleton);
|
|
314
|
+
const have = L?.clips?.map((c) => c.verb) ?? a.rig.verbs ?? [];
|
|
315
|
+
return {
|
|
316
|
+
id: a.id, kind: a.kind, route: a.route, family: a.rig.family, familyLabel: FAMILIES[a.rig.family]?.label ?? a.rig.family, skeleton: a.rig.skeleton, bones: a.rig.bones, anims: a.rig.anims,
|
|
317
|
+
clips: need.map((v) => { const c = L?.clips?.find((x) => x.verb === v); return { verb: v, note: VERBS[v]?.note ?? '', loop: Boolean(VERBS[v]?.loop), have: have.includes(v), source: c?.source ?? null, from: c?.from ?? null, retargeted: Boolean(c?.retargeted), seconds: c?.duration ?? null }; }),
|
|
318
|
+
extra: have.filter((v) => !need.includes(v)),
|
|
319
|
+
missing: need.filter((v) => !have.includes(v)),
|
|
320
|
+
animsKB: L?.measured?.kb ?? null,
|
|
321
|
+
};
|
|
322
|
+
});
|
|
323
|
+
const unrigged = m.assets.filter((a) => (a.kind === 'character' || a.kind === 'creature') && !a.rig).map((a) => a.id);
|
|
324
|
+
const d = doc?.decisions ?? {};
|
|
325
|
+
return {
|
|
326
|
+
ok: true, command: 'anim plan', game, verbs: need, rows, unrigged,
|
|
327
|
+
decisions: Object.fromEntries(['anim.style', 'anim.clips', 'anim.source', 'anim.motion', 'anim.blend', 'anim.procedural', 'rig.skeleton', 'rig.source', 'rig.bones', 'game.feel'].map((k) => [k, d[k] ? { label: d[k].label, state: d[k].state } : null])),
|
|
328
|
+
lab: existsSync(join(root, 'games', game, 'lab.json')) ? JSON.parse(readFileSync(join(root, 'games', game, 'lab.json'), 'utf8')) : null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Looping previews: each character's verbs as small animated WebP files (and one sheet a person can read), under the
|
|
334
|
+
* game's light, in .studio/art/<game>/anim/. Free; one headless Chrome. Returns { rows: [{ id, verbs: [{ verb, file,
|
|
335
|
+
* frames, seconds }], sheet }] }.
|
|
336
|
+
*/
|
|
337
|
+
export async function animPreview(root, game, { asset = null, verbs = null, size = 144, count = 12, log = () => {} } = {}) {
|
|
338
|
+
const plan = animPlan(root, game);
|
|
339
|
+
const rows = plan.rows.filter((r) => !asset || r.id === asset);
|
|
340
|
+
if (!rows.length) return { ok: false, command: 'anim preview', game, why: asset ? `${asset} is not a rigged character in ${game}` : `${game} has no rigged characters yet (assets add <game> <character item>)` };
|
|
341
|
+
const m = readManifest(root, game);
|
|
342
|
+
const gdir = join(root, 'games', game);
|
|
343
|
+
let tokens = null;
|
|
344
|
+
try { tokens = JSON.parse(readFileSync(join(gdir, 'style.json'), 'utf8')); } catch { tokens = null; }
|
|
345
|
+
tokens = tokens?.palette ? tokens : { palette: { bg: '#e9eef2', ink: '#1d2b3a', accent: '#ff8a5b', accent2: '#5cbf8a', danger: '#e2553f', good: '#45b36b', gold: '#ffd25a' } };
|
|
346
|
+
const dir = join(root, '.studio', 'art', game, 'anim');
|
|
347
|
+
mkdirSync(dir, { recursive: true });
|
|
348
|
+
const { withRenderer, modelIn, dataUrlBytes } = await import('./render3d.mjs');
|
|
349
|
+
let sharp = null;
|
|
350
|
+
try { sharp = (await import('sharp')).default; } catch { sharp = null; }
|
|
351
|
+
const out = [];
|
|
352
|
+
await withRenderer(async (r) => {
|
|
353
|
+
for (const row of rows) {
|
|
354
|
+
const a = m.assets.find((x) => x.id === row.id);
|
|
355
|
+
const model = readFileSync(join(gdir, a.files.find((f) => f.role === 'model').path));
|
|
356
|
+
const animsFile = join(gdir, row.anims);
|
|
357
|
+
if (!existsSync(animsFile)) { out.push({ id: row.id, verbs: [], why: `${row.anims} is missing` }); continue; }
|
|
358
|
+
const want = verbs ?? row.clips.filter((c) => c.have).map((c) => c.verb).concat(row.extra);
|
|
359
|
+
const res = await r.frames(modelIn(row.id, model, { anims: readFileSync(animsFile).toString('base64') }), tokens, { verbs: want, count, size, sheet: true, loops: want.filter((v) => VERBS[v]?.loop) });
|
|
360
|
+
const files = [];
|
|
361
|
+
for (const v of res.verbs) {
|
|
362
|
+
const file = join(dir, `${row.id}-${v.verb}.webp`);
|
|
363
|
+
if (sharp) {
|
|
364
|
+
const frames = v.frames.map((f) => dataUrlBytes(f).bytes);
|
|
365
|
+
// One animated WebP a verb: the frames at the clip's own pace, looping.
|
|
366
|
+
const delay = Math.max(40, Math.round((v.duration * 1000) / Math.max(1, frames.length)));
|
|
367
|
+
await sharp(frames, { join: { animated: true } }).webp({ quality: 70, loop: 0, delay: frames.map(() => delay), effort: 4 }).toFile(file);
|
|
368
|
+
} else writeFileSync(file.replace(/\.webp$/, '.png'), dataUrlBytes(v.frames[0]).bytes);
|
|
369
|
+
files.push({ verb: v.verb, file: file.slice(root.length + 1), frames: v.frames.length, seconds: v.duration });
|
|
370
|
+
}
|
|
371
|
+
let sheet = null;
|
|
372
|
+
if (res.sheet) { sheet = join(dir, `${row.id}-sheet.jpg`); writeFileSync(sheet, dataUrlBytes(res.sheet).bytes); sheet = sheet.slice(root.length + 1); }
|
|
373
|
+
out.push({ id: row.id, verbs: files, sheet, missing: res.missing });
|
|
374
|
+
log(`${row.id}: ${files.length} preview(s)`);
|
|
375
|
+
}
|
|
376
|
+
}, { log });
|
|
377
|
+
const result = { ok: true, command: 'anim preview', game, rows: out, at: new Date().toISOString() };
|
|
378
|
+
writeFileSync(join(dir, 'previews.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
379
|
+
return result;
|
|
380
|
+
}
|