@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/lib/asset-check.mjs
CHANGED
|
@@ -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} (
|
|
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}`);
|
package/lib/build.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { STUDIO_VERSION } from './version.mjs';
|
|
|
47
47
|
import { licenseOf, remixRow } from '../worker/license.mjs';
|
|
48
48
|
import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
|
|
49
49
|
import { chatProblems } from '../worker/chat.mjs';
|
|
50
|
+
import { screenChatProblems } from '../worker/chat-page.mjs';
|
|
50
51
|
import { vocabularyOf } from '../worker/brain.mjs';
|
|
51
52
|
import { shopForBuild } from './shop.mjs';
|
|
52
53
|
import { audienceOf } from '../worker/shop-rules.mjs';
|
|
@@ -323,6 +324,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
323
324
|
if (chatBad.length) throw new Error(`games/${g.id}/game.json: ${chatBad.join('; ')}`);
|
|
324
325
|
// A game's own decisions (NETPLAY.md section 20): game.json "decide" is true or false (the Worker answers net.decide only then).
|
|
325
326
|
if (g.decide !== undefined && typeof g.decide !== 'boolean') throw new Error(`games/${g.id}/game.json: "decide" is true (the host may ask the studio's decision model, within the AI brains' day) or false`);
|
|
327
|
+
// Where chat sits on the screen (game.json "screen": { "chat" }, 0.24.5): a wrong field is the default on the page.
|
|
328
|
+
for (const p of screenChatProblems(g.screen?.chat)) log(`warning: games/${g.id}/game.json: ${p}; the play page uses the default there`);
|
|
326
329
|
// The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
|
|
327
330
|
if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
|
|
328
331
|
// Its assets' licences, and the address and SHA-256 of each one a remix may carry (`game remix` fetches them).
|
|
@@ -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
|
+
}
|