@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +50 -3
  3. package/assets/animate.ts +521 -0
  4. package/assets/render-page.ts +77 -4
  5. package/bin/homie-studio.mjs +13 -4
  6. package/chat/CHAT.md +36 -5
  7. package/lib/art-cli.mjs +105 -5
  8. package/lib/art-tools.mjs +168 -1
  9. package/lib/asset-check.mjs +72 -5
  10. package/lib/build.mjs +3 -0
  11. package/lib/characters.mjs +380 -0
  12. package/lib/clips.mjs +417 -0
  13. package/lib/decisions.mjs +38 -8
  14. package/lib/mcp-tools.mjs +1 -1
  15. package/lib/mcp.mjs +2 -2
  16. package/lib/optimise.mjs +79 -3
  17. package/lib/render3d.mjs +2 -0
  18. package/lib/rig.mjs +431 -0
  19. package/lib/scaffold.mjs +17 -4
  20. package/lib/studio.mjs +18 -3
  21. package/lib/style-board.mjs +3 -1
  22. package/lib/template-history.json +78 -0
  23. package/mcp/ui/animation.js +56 -0
  24. package/mcp/ui/card.css +4 -0
  25. package/mcp/ui/cast.js +36 -2
  26. package/mcp/ui/lineup.js +7 -7
  27. package/netplay/NETPLAY.md +9 -5
  28. package/package.json +5 -1
  29. package/site/SITE.md +4 -0
  30. package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
  31. package/starters/hero-rush-3d/assets/manifest.json +1471 -0
  32. package/starters/hero-rush-3d/credits.json +40 -0
  33. package/starters/hero-rush-3d/game.json +11 -0
  34. package/starters/hero-rush-3d/index.html +20 -0
  35. package/starters/hero-rush-3d/lab.json +32 -0
  36. package/starters/hero-rush-3d/src/main.ts +2361 -0
  37. package/starters/hero-rush-3d/style.json +68 -0
  38. package/starters/hero-rush-3d/tunables.json +32 -0
  39. package/worker/chat-page.mjs +173 -28
  40. package/worker/chat.mjs +4 -1
  41. package/worker/version.mjs +1 -1
package/lib/clips.mjs ADDED
@@ -0,0 +1,417 @@
1
+ /**
2
+ * CLIPS: a game's animation verbs, where each comes from, retargeting onto a skeleton, and the clip library a skeleton
3
+ * ships (public/anims/<skeleton>.glb), baked here at build time, free, on this computer.
4
+ *
5
+ * VERBS the verbs a game's clips are named by (idle, walk, run, jump, fall, land, attack, hit, die,
6
+ * emote, win, interact, pickup, cast, block, dodge, crouch, sit), each with its loop rule
7
+ * findClip(names, verb) a source's own clip for a verb (KayKit "Running_A", Kenney "sprint", Mixamo "Running")
8
+ * retargetClip(from, clip, to, opts) one clip onto another skeleton: every mapped bone turns in the WORLD the way
9
+ * the source's turned from its rest pose, after the two rest poses are lined up bone by bone
10
+ * (a T-pose clip on an A-pose rig keeps its arms down); the hips move by the clip's motion
11
+ * scaled to the target's hip height; loops are kept in place (the game moves bodies)
12
+ * bakeLibrary(to, sources, verbs) a skeleton's clip library: the target's joints (no mesh) and one clip per verb,
13
+ * sampled at 30 fps, constant tracks dropped, quaternions kept continuous
14
+ * writeLibrary(doc) resampled and meshopt-compressed bytes, with its measurements
15
+ *
16
+ * Retargeting is plain matrix and quaternion math (three.js's math classes, no renderer), so it runs in Node, in a
17
+ * cloud session and in CI. A clip baked onto a skeleton plays on every character with that skeleton's fingerprint
18
+ * (lib/rig.mjs skeletonOf): shared clips are the biggest byte and memory saving for a room of 32.
19
+ */
20
+ // three's maths, loaded if it is there: a studio has it (its games draw with it); Homie for Claude Desktop's packed
21
+ // server does not, and only reads verbs and plans there (retargeting runs in the studio's own homie-studio).
22
+ let Matrix4; let Quaternion; let Vector3;
23
+ try { ({ Matrix4, Quaternion, Vector3 } = await import('three')); } catch { Matrix4 = null; }
24
+ const needThree = () => { if (!Matrix4) throw new Error('retargeting needs three (npm install in the studio)'); };
25
+ import { jointsOf, skeletonOf } from './rig.mjs';
26
+
27
+ /** The verbs, in the order a card shows them. `loop`: plays forever; `hold`: stays on its last frame (a fall, a death). */
28
+ export const VERBS = Object.freeze({
29
+ idle: { loop: true, note: 'standing, breathing' },
30
+ walk: { loop: true, note: 'moving slowly' },
31
+ run: { loop: true, note: 'moving fast' },
32
+ jump: { loop: false, note: 'leaving the ground' },
33
+ fall: { loop: true, note: 'in the air' },
34
+ land: { loop: false, note: 'touching down' },
35
+ attack: { loop: false, note: 'the main action: a swing, a punch, a bump' },
36
+ attack2: { loop: false, note: 'a second action' },
37
+ hit: { loop: false, note: 'being hit' },
38
+ die: { loop: false, hold: true, note: 'out of the round' },
39
+ emote: { loop: false, note: 'a cheer, a wave' },
40
+ win: { loop: true, note: 'the winner\'s pose' },
41
+ interact: { loop: false, note: 'using something' },
42
+ pickup: { loop: false, note: 'picking something up' },
43
+ cast: { loop: false, note: 'a spell' },
44
+ shoot: { loop: false, note: 'firing' },
45
+ block: { loop: true, note: 'guarding' },
46
+ dodge: { loop: false, note: 'a roll or a sidestep' },
47
+ crouch: { loop: true, note: 'low' },
48
+ sit: { loop: true, note: 'sitting' },
49
+ drive: { loop: true, note: 'at the wheel' },
50
+ spawn: { loop: false, note: 'arriving' },
51
+ });
52
+ export const VERB_IDS = Object.keys(VERBS);
53
+
54
+ /**
55
+ * Each verb's clip in the packs and rigs the pipeline knows, best first. Matched without case or separators, so
56
+ * "1H_Melee_Attack_Chop" and "1h-melee-attack-chop" are one name. A verb with no match anywhere falls to the words
57
+ * in the clip names (a Mixamo "Standing Idle" is an idle) and is left out when nothing fits.
58
+ */
59
+ export const CLIP_NAMES = Object.freeze({
60
+ idle: ['Idle', 'Unarmed_Idle', 'idle', 'Idle_A', 'Standing Idle', 'Breathing Idle', 'static'],
61
+ walk: ['Walking_A', 'walk', 'Walking', 'Walk'],
62
+ run: ['Running_A', 'sprint', 'run', 'Running', 'Run', 'Running_B'],
63
+ jump: ['Jump_Start', 'jump', 'Jump', 'Jumping', 'Jump_Full_Short'],
64
+ fall: ['Jump_Idle', 'fall', 'Falling', 'Falling Idle', 'Fall'],
65
+ land: ['Jump_Land', 'land', 'Landing', 'Land'],
66
+ attack: ['1H_Melee_Attack_Chop', 'attack-melee-right', 'Unarmed_Melee_Attack_Punch_A', 'attack', 'Punching', 'Attack', 'Sword And Shield Slash', 'Slash'],
67
+ attack2: ['1H_Melee_Attack_Slice_Horizontal', 'attack-kick-right', 'Unarmed_Melee_Attack_Kick', 'Kick', 'Kicking'],
68
+ hit: ['Hit_A', 'hit', 'Hit', 'Hit Reaction', 'Reaction', 'gesture-negative'],
69
+ die: ['Death_A', 'die', 'Death', 'Dying', 'Die'],
70
+ emote: ['Cheer', 'emote-yes', 'gesture-positive', 'Waving', 'Wave', 'Cheering', 'dance'],
71
+ win: ['Cheer', 'emote-yes', 'dance', 'Victory', 'Cheering', 'Dancing'],
72
+ interact: ['Interact', 'interact-right', 'eat', 'Use_Item', 'Interact'],
73
+ pickup: ['PickUp', 'pick-up', 'Picking Up', 'Pick Up'],
74
+ cast: ['Spellcast_Shoot', 'Spellcast_Long', 'Spellcasting', 'Casting', 'Spell'],
75
+ shoot: ['1H_Ranged_Shoot', 'holding-right-shoot', '2H_Ranged_Shoot', 'Shooting', 'Shoot'],
76
+ block: ['Blocking', 'Block', 'block'],
77
+ dodge: ['Dodge_Forward', 'Dodge_Backward', 'roll', 'Dodge', 'Roll'],
78
+ crouch: ['crouch', 'Crouching', 'Crouch'],
79
+ sit: ['Sit_Floor_Idle', 'sit', 'Sit_Chair_Idle', 'Sitting', 'Sit'],
80
+ drive: ['drive', 'Driving', 'Drive'],
81
+ spawn: ['Spawn_Ground', 'Skeletons_Awaken_Standing', 'spawn', 'Spawn'],
82
+ });
83
+ const WORDS = { idle: /idle|breath/, walk: /walk/, run: /run|sprint/, jump: /jump/, fall: /fall/, land: /land/, attack: /attack|slash|punch|swing|chop|stab/, hit: /hit|react|flinch|damage/, die: /death|die|dying/, emote: /cheer|wave|emote|dance/, win: /victor|cheer|win|dance/, interact: /interact|use/, pickup: /pick/, cast: /spell|cast/, shoot: /shoot/, block: /block/, dodge: /dodge|roll/, crouch: /crouch/, sit: /sit/, drive: /drive/, spawn: /spawn|awaken/ };
84
+ const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
85
+
86
+ /** A source's clip for a verb: its name, or null. `names`: the clip names a file has. */
87
+ export function findClip(names, verb) {
88
+ const byKey = new Map(names.map((n) => [norm(n), n]));
89
+ for (const want of CLIP_NAMES[verb] ?? []) { const hit = byKey.get(norm(want)); if (hit) return hit; }
90
+ const re = WORDS[verb];
91
+ if (!re) return null;
92
+ // By words, preferring the shortest name (the plain one, not "Running_Strafe_Left").
93
+ return names.filter((n) => re.test(n.toLowerCase())).sort((a, b) => a.length - b.length)[0] ?? null;
94
+ }
95
+
96
+ /* ------------------------------------------------------------------ a rig as plain math */
97
+
98
+ /**
99
+ * A rig read from a Document: every node from the scene down, with its rest TRS, its parent and its rest world matrix.
100
+ * Joints are found by name (after a rig was normalised, the standard names).
101
+ */
102
+ export function rigOf(doc) {
103
+ needThree();
104
+ const root = doc.getRoot();
105
+ const scene = root.getDefaultScene() ?? root.listScenes()[0];
106
+ const nodes = [];
107
+ const byName = new Map();
108
+ const walk = (node, parent) => {
109
+ const t = node.getTranslation(); const r = node.getRotation(); const s = node.getScale();
110
+ const rec = { node, name: node.getName(), parent, t: new Vector3(...t), r: new Quaternion(...r), s: new Vector3(...s), children: [] };
111
+ rec.local = new Matrix4().compose(rec.t, rec.r, rec.s);
112
+ rec.world = parent ? parent.world.clone().multiply(rec.local) : rec.local.clone();
113
+ rec.worldQ = new Quaternion(); rec.worldP = new Vector3(); rec.world.decompose(rec.worldP, rec.worldQ, new Vector3());
114
+ nodes.push(rec);
115
+ if (!byName.has(rec.name)) byName.set(rec.name, rec);
116
+ if (parent) parent.children.push(rec);
117
+ for (const c of node.listChildren()) walk(c, rec);
118
+ };
119
+ for (const c of scene?.listChildren() ?? []) walk(c, null);
120
+ const { joints, skinned, skin } = jointsOf(doc);
121
+ return { nodes, byName, joints: new Set(joints.map((j) => j.getName())), skinned, skin, doc, skeleton: skeletonOf(doc) };
122
+ }
123
+
124
+ /** Sample one animation channel's sampler at time t (linear, step, or the value part of a cubic spline). */
125
+ function sample(sampler, t, out) {
126
+ const input = sampler.getInput(); const output = sampler.getOutput();
127
+ const n = input.getCount();
128
+ const interp = sampler.getInterpolation();
129
+ const width = output.getElementSize();
130
+ const cubic = interp === 'CUBICSPLINE';
131
+ const at = (i, o) => { output.getElement(cubic ? i * 3 + 1 : i, o); return o; };
132
+ const times = input.getArray();
133
+ if (n === 1 || t <= times[0]) return at(0, out);
134
+ if (t >= times[n - 1]) return at(n - 1, out);
135
+ let lo = 0; let hi = n - 1;
136
+ while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (times[mid] <= t) lo = mid; else hi = mid; }
137
+ if (interp === 'STEP') return at(lo, out);
138
+ const u = (t - times[lo]) / Math.max(1e-9, times[hi] - times[lo]);
139
+ const a = at(lo, new Array(width)); const b = at(hi, new Array(width));
140
+ if (width === 4) { const qa = new Quaternion(...a); const qb = new Quaternion(...b); qa.slerp(qb, u); out[0] = qa.x; out[1] = qa.y; out[2] = qa.z; out[3] = qa.w; return out; }
141
+ for (let k = 0; k < width; k++) out[k] = a[k] + (b[k] - a[k]) * u;
142
+ return out;
143
+ }
144
+
145
+ const durationOf = (anim) => anim.listSamplers().reduce((m, s) => Math.max(m, s.getInput().getMax([])[0] ?? 0), 0);
146
+
147
+ /** The world rotation and position of every node of a rig at time t of a clip (forward kinematics). */
148
+ function poseAt(rig, channels, t) {
149
+ const world = new Map();
150
+ const tmpT = new Vector3(); const tmpR = new Quaternion(); const tmpS = new Vector3();
151
+ const v = [0, 0, 0, 0];
152
+ for (const rec of rig.nodes) {
153
+ const ch = channels.get(rec.node);
154
+ tmpT.copy(rec.t); tmpR.copy(rec.r); tmpS.copy(rec.s);
155
+ if (ch?.translation) { sample(ch.translation, t, v); tmpT.set(v[0], v[1], v[2]); }
156
+ if (ch?.rotation) { sample(ch.rotation, t, v); tmpR.set(v[0], v[1], v[2], v[3]).normalize(); }
157
+ if (ch?.scale) { sample(ch.scale, t, v); tmpS.set(v[0], v[1], v[2]); }
158
+ const local = new Matrix4().compose(tmpT, tmpR, tmpS);
159
+ const w = rec.parent ? world.get(rec.parent).m.clone().multiply(local) : local;
160
+ const q = new Quaternion(); const p = new Vector3(); w.decompose(p, q, new Vector3());
161
+ world.set(rec, { m: w, q, p });
162
+ }
163
+ return world;
164
+ }
165
+
166
+ /** A clip's channels by node: Map(node -> { rotation, translation, scale } samplers). */
167
+ function channelsOf(anim) {
168
+ const out = new Map();
169
+ for (const c of anim.listChannels()) {
170
+ const n = c.getTargetNode(); if (!n) continue;
171
+ if (!out.has(n)) out.set(n, {});
172
+ out.get(n)[c.getTargetPath()] = c.getSampler();
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** The bone after this one along its limb, to read which way it points (the first that the rig has). */
178
+ const NEXT = {
179
+ hips: ['spine'], spine: ['chest', 'upperChest', 'neck', 'head'], chest: ['upperChest', 'neck', 'head'], upperChest: ['neck', 'head'], neck: ['head'],
180
+ leftShoulder: ['leftUpperArm'], leftUpperArm: ['leftLowerArm', 'leftHand'], leftLowerArm: ['leftHand'], leftUpperLeg: ['leftLowerLeg', 'leftFoot'], leftLowerLeg: ['leftFoot'], leftFoot: ['leftToes'],
181
+ rightShoulder: ['rightUpperArm'], rightUpperArm: ['rightLowerArm', 'rightHand'], rightLowerArm: ['rightHand'], rightUpperLeg: ['rightLowerLeg', 'rightFoot'], rightLowerLeg: ['rightFoot'], rightFoot: ['rightToes'],
182
+ frontLeftLeg: [], frontRightLeg: [], backLeftLeg: [], backRightLeg: [], body: ['head', 'neck'], tail: [],
183
+ };
184
+ /** For a whole limb in one piece (the mini family), where the limb ends on a full humanoid. */
185
+ const LIMB_END = { leftUpperArm: 'leftHand', rightUpperArm: 'rightHand', leftUpperLeg: 'leftFoot', rightUpperLeg: 'rightFoot', spine: 'head' };
186
+
187
+ /** Where a bone's own vertices sit, in its rig's rest world (skinned: vertices it mostly carries; rigid: its mesh). */
188
+ function meshCentroid(rig, rec) {
189
+ const doc = rig.doc;
190
+ const acc = new Vector3(); let n = 0;
191
+ const v = [0, 0, 0]; const j = [0, 0, 0, 0]; const w = [0, 0, 0, 0];
192
+ if (rig.skinned && rig.skin) {
193
+ const idx = rig.skin.listJoints().indexOf(rec.node);
194
+ for (const node of doc.getRoot().listNodes()) {
195
+ if (node.getSkin() !== rig.skin || !node.getMesh()) continue;
196
+ for (const p of node.getMesh().listPrimitives()) {
197
+ const P = p.getAttribute('POSITION'); const J = p.getAttribute('JOINTS_0'); const W = p.getAttribute('WEIGHTS_0');
198
+ if (!P || !J || !W) continue;
199
+ for (let i = 0; i < P.getCount(); i++) {
200
+ J.getElement(i, j); W.getElement(i, w);
201
+ let mine = 0; for (let k = 0; k < 4; k++) if (j[k] === idx) mine += w[k];
202
+ if (mine < 0.5) continue;
203
+ P.getElement(i, v); acc.add(new Vector3(...v)); n += 1;
204
+ }
205
+ }
206
+ }
207
+ } else if (rec.node.getMesh()) {
208
+ for (const p of rec.node.getMesh().listPrimitives()) {
209
+ const P = p.getAttribute('POSITION'); if (!P) continue;
210
+ for (let i = 0; i < P.getCount(); i++) { P.getElement(i, v); acc.add(new Vector3(...v).applyMatrix4(rec.world)); n += 1; }
211
+ }
212
+ }
213
+ return n ? acc.divideScalar(n) : null;
214
+ }
215
+
216
+ /**
217
+ * Which way a standard bone points at rest in its rig's world (a unit vector), or null: towards the next bone along its
218
+ * limb; for a limb in one piece (`mesh`), towards the middle of its own vertices. A bone with nothing after it (a head,
219
+ * a hand) has no direction here: lining those up by their vertices would tip a big chibi head over.
220
+ */
221
+ function restDirection(rig, std, rec, { wholeLimb = false, mesh = false } = {}) {
222
+ const ends = wholeLimb && LIMB_END[std] ? [LIMB_END[std]] : NEXT[std] ?? [];
223
+ for (const e of ends) {
224
+ const c = rig.byName.get(e);
225
+ if (c && (rig.joints.has(e) || !rig.skinned)) { const d = c.worldP.clone().sub(rec.worldP); if (d.lengthSq() > 1e-10) return d.normalize(); }
226
+ }
227
+ if (!mesh) return null;
228
+ const centre = meshCentroid(rig, rec);
229
+ if (centre) { const d = centre.sub(rec.worldP); if (d.lengthSq() > 1e-10) return d.normalize(); }
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * How high a rig's legs reach at rest (world units): the hip joints (the tops of the upper legs) above the ground (its
235
+ * feet, else 0). The scale a clip's hip motion takes from one rig to another: a chibi's bob is a chibi's size. A mini
236
+ * rig's hips bone sits on the ground; its legs still say how tall it stands.
237
+ */
238
+ function hipsHeight(rig) {
239
+ const legs = ['leftUpperLeg', 'rightUpperLeg', 'frontLeftLeg', 'backLeftLeg'].map((n) => rig.byName.get(n)).filter(Boolean);
240
+ const hips = rig.byName.get('hips') ?? rig.byName.get('body');
241
+ const top = legs.length ? legs.reduce((s, r) => s + r.worldP.y, 0) / legs.length : hips?.worldP.y;
242
+ if (!Number.isFinite(top)) return 1;
243
+ let low = Infinity;
244
+ for (const n of ['leftFoot', 'rightFoot', 'leftToes', 'rightToes']) { const r = rig.byName.get(n); if (r) low = Math.min(low, r.worldP.y); }
245
+ if (!Number.isFinite(low)) low = 0;
246
+ return Math.max(1e-6, top - Math.min(low, top * 0.1));
247
+ }
248
+
249
+ /**
250
+ * One clip of `from` (a rig, normalised to standard names) onto `to` (another rig). Options:
251
+ * fps samples a second (30)
252
+ * inPlace 'loops' (default): a looping clip's drift across the ground is removed; true: every clip's; false: none
253
+ * loop whether this clip loops (from VERBS)
254
+ * same the two rigs share a fingerprint: copy local rotations bone for bone (exact)
255
+ * Returns { name, duration, tracks: [{ node, path, times: Float32Array, values: Float32Array }] }.
256
+ */
257
+ export function retargetClip(from, anim, to, { name = anim.getName(), fps = 30, inPlace = 'loops', loop = false, same = false } = {}) {
258
+ needThree();
259
+ const duration = durationOf(anim);
260
+ const frames = Math.max(2, Math.round(duration * fps) + 1);
261
+ const times = new Float32Array(frames);
262
+ for (let i = 0; i < frames; i++) times[i] = Math.min(duration, i / fps);
263
+ const ch = channelsOf(anim);
264
+ // Pairs: every target joint with a source node of the same (standard) name.
265
+ const pairs = [];
266
+ const wholeLimb = to.skeleton.family === 'mini' && from.skeleton.family === 'humanoid';
267
+ for (const rec of to.nodes) {
268
+ if (!to.joints.has(rec.name) && to.skinned) continue;
269
+ const src = from.byName.get(rec.name);
270
+ if (!src) continue;
271
+ // A limb in one piece (a chibi's arm) AIMS where the source's whole limb points (shoulder to hand), so a bent
272
+ // elbow does not swing a stiff arm out sideways. Everything else turns by its own bone's world turn.
273
+ const aim = wholeLimb && Boolean(LIMB_END[rec.name]) && Boolean(from.byName.get(LIMB_END[rec.name]));
274
+ // Alignment of the two rest poses (world), bone by bone: the target's rest direction turned onto the source's.
275
+ let align = new Quaternion();
276
+ let rest = null;
277
+ if (!same) {
278
+ const ds = restDirection(from, rec.name, src, { wholeLimb: aim });
279
+ const dt = restDirection(to, rec.name, rec, { mesh: aim });
280
+ if (ds && dt && ds.dot(dt) < 0.9995) align = new Quaternion().setFromUnitVectors(dt, ds);
281
+ if (aim) rest = ds;
282
+ }
283
+ pairs.push({ rec, src, align, aim: aim && rest ? { end: from.byName.get(LIMB_END[rec.name]), rest } : null });
284
+ }
285
+ const paired = new Map(pairs.map((p) => [p.rec, p]));
286
+ const hipsName = to.byName.get('hips') ? 'hips' : to.byName.get('body') ? 'body' : null;
287
+ const hs = hipsName && from.byName.get(hipsName) ? hipsHeight(to) / hipsHeight(from) : 1;
288
+ const out = new Map(pairs.map((p) => [p.rec, { q: new Float32Array(frames * 4), t: p.rec.name === hipsName ? new Float32Array(frames * 3) : null }]));
289
+ const srcHips = hipsName ? from.byName.get(hipsName) : null;
290
+ const first = srcHips ? poseAt(from, ch, 0).get(srcHips).p.clone() : null;
291
+ const last = srcHips ? poseAt(from, ch, duration).get(srcHips).p.clone() : null;
292
+ for (let f = 0; f < frames; f++) {
293
+ const t = times[f];
294
+ const pose = poseAt(from, ch, t);
295
+ const world = new Map(); // target rec -> world matrix this frame
296
+ for (const rec of to.nodes) {
297
+ const parentW = rec.parent ? world.get(rec.parent) : new Matrix4();
298
+ const p = paired.get(rec);
299
+ let local;
300
+ if (!p) local = rec.local.clone();
301
+ else if (same) {
302
+ // Same skeleton: the source's own local rotation, exactly.
303
+ const sp = pose.get(p.src);
304
+ const sParent = p.src.parent ? pose.get(p.src.parent).m : new Matrix4();
305
+ const l = sParent.clone().invert().multiply(sp.m);
306
+ const lp = new Vector3(); const lq = new Quaternion(); l.decompose(lp, lq, new Vector3());
307
+ local = new Matrix4().compose(rec.t, lq, rec.s);
308
+ } else {
309
+ // World delta from the source's rest, applied to the target's (aligned) rest. A whole limb: the turn that takes
310
+ // the source limb's rest direction to where it points now.
311
+ let delta;
312
+ if (p.aim) {
313
+ const now = pose.get(p.aim.end).p.clone().sub(pose.get(p.src).p);
314
+ delta = now.lengthSq() > 1e-12 ? new Quaternion().setFromUnitVectors(p.aim.rest, now.normalize()) : new Quaternion();
315
+ } else delta = pose.get(p.src).q.clone().multiply(p.src.worldQ.clone().invert());
316
+ const wq = delta.multiply(p.align.clone()).multiply(rec.worldQ.clone());
317
+ const pq = new Quaternion(); parentW.decompose(new Vector3(), pq, new Vector3());
318
+ const lq = pq.invert().multiply(wq).normalize();
319
+ local = new Matrix4().compose(rec.t, lq, rec.s);
320
+ }
321
+ if (p && rec.name === hipsName && srcHips) {
322
+ // The hips: the source's motion from its rest, scaled to this rig, in place for a loop.
323
+ const sp = pose.get(srcHips).p.clone().sub(srcHips.worldP);
324
+ if (inPlace === true || (inPlace === 'loops' && loop)) {
325
+ const u = duration > 0 ? t / duration : 0;
326
+ const drift = last.clone().sub(first).multiplyScalar(u).add(first).sub(srcHips.worldP);
327
+ sp.x -= drift.x; sp.z -= drift.z;
328
+ }
329
+ const want = rec.worldP.clone().add(sp.multiplyScalar(hs));
330
+ const lp = want.applyMatrix4(parentW.clone().invert());
331
+ const lq = new Quaternion(); const ls = new Vector3(); local.decompose(new Vector3(), lq, ls);
332
+ local = new Matrix4().compose(lp, lq, ls);
333
+ }
334
+ world.set(rec, parentW.clone().multiply(local));
335
+ if (p) {
336
+ const lp = new Vector3(); const lq = new Quaternion(); local.decompose(lp, lq, new Vector3());
337
+ const o = out.get(rec);
338
+ // Keep quaternions on one side, so a blend between frames never turns the long way round.
339
+ if (f > 0) { const pq = o.q.subarray((f - 1) * 4, f * 4); if (pq[0] * lq.x + pq[1] * lq.y + pq[2] * lq.z + pq[3] * lq.w < 0) { lq.x = -lq.x; lq.y = -lq.y; lq.z = -lq.z; lq.w = -lq.w; } }
340
+ o.q.set([lq.x, lq.y, lq.z, lq.w], f * 4);
341
+ if (o.t) o.t.set([lp.x, lp.y, lp.z], f * 3);
342
+ }
343
+ }
344
+ }
345
+ const tracks = [];
346
+ for (const [rec, o] of out) {
347
+ if (!constantAt(o.q, 4, [rec.r.x, rec.r.y, rec.r.z, rec.r.w], 1e-4, true)) tracks.push({ node: rec.name, path: 'rotation', times, values: o.q });
348
+ if (o.t && !constantAt(o.t, 3, [rec.t.x, rec.t.y, rec.t.z], 1e-4 * Math.max(1, rec.t.length()))) tracks.push({ node: rec.name, path: 'translation', times, values: o.t });
349
+ }
350
+ return { name, duration, tracks, frames, scale: hs };
351
+ }
352
+
353
+ /** True when every sample equals `rest` (a quaternion: either sign). */
354
+ function constantAt(values, width, rest, tol, quat = false) {
355
+ for (let i = 0; i < values.length; i += width) {
356
+ let d = 0; let dn = 0;
357
+ for (let k = 0; k < width; k++) { d = Math.max(d, Math.abs(values[i + k] - rest[k])); dn = Math.max(dn, Math.abs(values[i + k] + rest[k])); }
358
+ if (Math.min(d, quat ? dn : d) > tol) return false;
359
+ }
360
+ return true;
361
+ }
362
+
363
+ /**
364
+ * A skeleton's clip library: a Document with the target's node tree (names and rest pose, no mesh) and one clip per
365
+ * verb. `sources`: [{ rig, anim, verb, from }] (from: a label of where the clip came from). Returns { doc, clips }.
366
+ */
367
+ export async function bakeLibrary(target, sources, { fps = 30, inPlace = 'loops', core, logger = null }) {
368
+ needThree();
369
+ const doc = new core.Document();
370
+ if (logger) doc.setLogger(logger);
371
+ const buffer = doc.createBuffer();
372
+ const made = new Map();
373
+ const scene = doc.createScene('anims');
374
+ const copy = (rec, parent) => {
375
+ // Only the joints (and the nodes above them): what a clip can move.
376
+ const n = doc.createNode(rec.name).setTranslation(rec.t.toArray()).setRotation(rec.r.toArray()).setScale(rec.s.toArray());
377
+ made.set(rec.name, n);
378
+ if (parent) parent.addChild(n); else scene.addChild(n);
379
+ for (const c of rec.children) if (hasJoint(target, c)) copy(c, n);
380
+ };
381
+ for (const rec of target.nodes.filter((r) => !r.parent)) if (hasJoint(target, rec)) copy(rec, null);
382
+ const clips = [];
383
+ for (const s of sources) {
384
+ const same = s.rig.skeleton.fingerprint && s.rig.skeleton.fingerprint === target.skeleton.fingerprint;
385
+ const baked = retargetClip(s.rig, s.anim, target, { name: s.verb, fps, inPlace, loop: Boolean(VERBS[s.verb]?.loop), same });
386
+ const a = doc.createAnimation(s.verb);
387
+ for (const tr of baked.tracks) {
388
+ const node = made.get(tr.node);
389
+ if (!node) continue;
390
+ const sampler = doc.createAnimationSampler()
391
+ .setInput(doc.createAccessor().setType('SCALAR').setArray(tr.times).setBuffer(buffer))
392
+ .setOutput(doc.createAccessor().setType(tr.path === 'rotation' ? 'VEC4' : 'VEC3').setArray(tr.values).setBuffer(buffer))
393
+ .setInterpolation('LINEAR');
394
+ a.addSampler(sampler).addChannel(doc.createAnimationChannel().setTargetNode(node).setTargetPath(tr.path).setSampler(sampler));
395
+ }
396
+ a.setExtras({ homie: { verb: s.verb, source: s.anim.getName(), from: s.from ?? null, retargeted: !same, loop: Boolean(VERBS[s.verb]?.loop), hold: Boolean(VERBS[s.verb]?.hold) } });
397
+ clips.push({ verb: s.verb, source: s.anim.getName(), from: s.from ?? null, retargeted: !same, duration: +baked.duration.toFixed(3), tracks: baked.tracks.length });
398
+ }
399
+ doc.getRoot().setExtras({ homie: { kind: 'anims', skeleton: target.skeleton.id, family: target.skeleton.family, fingerprint: target.skeleton.fingerprint, fps, verbs: clips.map((c) => c.verb) } });
400
+ return { doc, clips };
401
+ }
402
+
403
+ function hasJoint(rig, rec) {
404
+ if (rig.joints.has(rec.name)) return true;
405
+ return rec.children.some((c) => hasJoint(rig, c));
406
+ }
407
+
408
+ /** A clip library's bytes: resampled (keys that a straight line already gives are dropped), meshopt filters, measured. */
409
+ export async function writeLibrary(doc, { tools }) {
410
+ const { fn, ext, io } = tools;
411
+ await doc.transform(fn.resample({ tolerance: 1e-4 }), fn.prune(), fn.dedup());
412
+ doc.createExtension(ext.EXTMeshoptCompression).setRequired(true).setEncoderOptions({ method: ext.EXTMeshoptCompression.EncoderMethod.FILTER });
413
+ doc.createExtension(ext.KHRMeshQuantization).setRequired(true);
414
+ const bytes = await io.writeBinary(doc);
415
+ const anims = doc.getRoot().listAnimations();
416
+ return { bytes, clips: anims.length, keys: anims.reduce((n, a) => n + a.listSamplers().reduce((m, s) => m + s.getInput().getCount(), 0), 0) };
417
+ }
package/lib/decisions.mjs CHANGED
@@ -56,7 +56,27 @@ export function contextOf(root, id, { prompt = '' } = {}) {
56
56
  const mood = Object.fromEntries(Object.entries(MOODS).map(([m, re]) => [m, score(re)]));
57
57
  let theme = {};
58
58
  try { theme = readTheme(root); } catch { theme = {}; }
59
- return { id, prompt: clean(prompt, 600), words, score, genre, mood, codexPalette: pal, codexFonts: fonts, theme, cards, players: Number(game.players?.max ?? game.netplay?.maxPlayers ?? 8) || 8, name: game.name ?? id, codexArt: clean(section('(?:art|look|style|visual)'), 400) };
59
+ // A game made from a starter already draws with a style.json (its palette, fonts, light, camera, render style): the
60
+ // automatic picks start from what it draws, so `style init` never repaints a working game behind its back.
61
+ let drawn = null;
62
+ try { const t = JSON.parse(readFileSync(join(dir, 'style.json'), 'utf8')); if (t?.palette?.bg && !existsSync(join(dir, DECISIONS_FILE))) drawn = t; } catch { drawn = null; }
63
+ // The verbs its characters already play (a starter's clip libraries): the clip set starts from them.
64
+ // And how tall its characters already stand (the median measured height): the proportions start from them.
65
+ let clipVerbs = [];
66
+ let castHeightM = null;
67
+ try {
68
+ const assets = JSON.parse(readFileSync(join(dir, 'assets', 'manifest.json'), 'utf8')).assets ?? [];
69
+ // A verb counts when at least half the characters can play it: one generated hero's extra clip is its own, not
70
+ // a verb the whole cast now lacks.
71
+ const libs = new Map(assets.filter((a) => a.kind === 'clip').map((a) => [a.rig?.skeleton, (a.clips ?? []).map((c) => c.verb)]));
72
+ const cast = assets.filter((a) => (a.kind === 'character' || a.kind === 'creature') && a.rig);
73
+ const count = new Map();
74
+ for (const a of cast) for (const v of libs.get(a.rig.skeleton) ?? a.rig.verbs ?? []) count.set(v, (count.get(v) ?? 0) + 1);
75
+ clipVerbs = cast.length ? [...count].filter(([, n]) => n * 2 >= cast.length).map(([v]) => v) : [...new Set([...libs.values()].flat())];
76
+ const hs = assets.filter((a) => a.kind === 'character' && Number(a.measured?.heightM) > 0).map((a) => Number(a.measured.heightM)).sort((a, b) => a - b);
77
+ if (hs.length) castHeightM = Math.round(hs[Math.floor(hs.length / 2)] * 100) / 100;
78
+ } catch { clipVerbs = []; }
79
+ return { id, prompt: clean(prompt, 600), words, score, genre, mood, codexPalette: pal, codexFonts: fonts, theme, cards, players: Number(game.players?.max ?? game.netplay?.maxPlayers ?? 8) || 8, name: game.name ?? id, codexArt: clean(section('(?:art|look|style|visual)'), 400), drawn, clipVerbs, castHeightM };
60
80
  }
61
81
 
62
82
  /** The best match in a preset table by word score: [key, score] or null. */
@@ -107,11 +127,13 @@ function cameraValue(key, over = {}) {
107
127
  */
108
128
  export const CATALOGUE = Object.freeze([
109
129
  ['style.render', 'style', 'How is the world drawn?', [], (ctx) => {
130
+ if (ctx.drawn?.render && RENDERS[ctx.drawn.render]) return { value: ctx.drawn.render, label: RENDERS[ctx.drawn.render].label, why: 'what the game draws now (its style.json)', options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
110
131
  const hit = best(RENDERS, ctx);
111
132
  const key = hit?.[0] ?? 'lowpoly-flat';
112
133
  return { value: key, label: RENDERS[key].label, why: hit ? `your words ${said(ctx, RENDERS[key].words)}: ${RENDERS[key].note}` : `nothing named a look: ${RENDERS[key].note}`, options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
113
134
  }],
114
135
  ['style.palette', 'style', 'Which colours, and what is each one for?', ['style.render', 'style.light'], (ctx) => {
136
+ if (ctx.drawn?.palette) { const v = paletteValue('custom', ctx.drawn.palette); v.ramp = Array.isArray(ctx.drawn.palette.ramp) && ctx.drawn.palette.ramp.length ? ctx.drawn.palette.ramp.filter((c) => HEX.test(String(c))) : rampFrom(v); return { value: v, label: 'The game\'s own colours', why: 'what the game draws now (its style.json)', options: paletteOptions(ctx) }; }
115
137
  const own = PALETTE_KEYS.filter((k) => ctx.codexPalette[k]).length >= 3 && !isStudioTheme(ctx.codexPalette, ctx.theme);
116
138
  if (own) {
117
139
  const v = paletteValue('custom', ctx.codexPalette);
@@ -131,29 +153,34 @@ export const CATALOGUE = Object.freeze([
131
153
  const g = GENRES[ctx.genre] ?? GENRES.party;
132
154
  const cute = /\b(cute|chibi|tiny|little|baby)\b/i.test(ctx.words); const real = /\b(realistic|heroic|tall)\b/i.test(ctx.words);
133
155
  const heads = cute ? 2.5 : real ? 6.5 : g.heads;
134
- return { value: { heads, heightM: real ? 1.8 : g.heightM, hands: heads < 4 ? 'big' : 'normal' }, label: `${heads} heads tall, ${real ? 1.8 : g.heightM} m`, why: cute || real ? 'your words' : `a ${ctx.genre} game seen from ${CAMERAS[g.camera].label.toLowerCase()}: big heads read on a phone`, options: [2.5, 4.5, 6.5].map((h) => ({ id: `h${h}`, label: `${h} heads`, value: { heads: h, heightM: h < 3 ? 0.9 : h < 5 ? 1.5 : 1.8, hands: h < 4 ? 'big' : 'normal' } })) };
156
+ const heightM = ctx.castHeightM ?? (real ? 1.8 : g.heightM);
157
+ const why = ctx.castHeightM ? `the characters it already has stand ${ctx.castHeightM} m` : cute || real ? 'your words' : `a ${ctx.genre} game seen from ${CAMERAS[g.camera].label.toLowerCase()}: big heads read on a phone`;
158
+ return { value: { heads, heightM, hands: heads < 4 ? 'big' : 'normal' }, label: `${heads} heads tall, ${heightM} m`, why, options: [2.5, 4.5, 6.5].map((h) => ({ id: `h${h}`, label: `${h} heads`, value: { heads: h, heightM: h < 3 ? 0.9 : h < 5 ? 1.5 : 1.8, hands: h < 4 ? 'big' : 'normal' } })) };
135
159
  }],
136
160
  ['style.materials', 'style', 'What are surfaces made of: flat colour, a gradient atlas, painted pictures or PBR?', ['style.render', 'game.devices'], (ctx, p) => {
161
+ if (ctx.drawn?.materials?.model) { const m = ctx.drawn.materials; return { value: { model: m.model, outline: Boolean(m.outline), texelDensity: m.texelDensity ?? null, atlas: Boolean(m.atlas) }, label: labelOf('style.materials', m), why: 'what the game draws now (its style.json)', options: [] }; }
137
162
  const r = RENDERS[p['style.render']] ?? RENDERS['lowpoly-flat'];
138
163
  const density = { flat: null, toon: 128, 'hand-painted': 256, pbr: 384, pixel: 32 }[r.materials] ?? null;
139
164
  return { value: { model: r.materials, outline: r.outline, texelDensity: density, atlas: r.materials === 'flat' }, label: `${({ flat: 'Flat palette colours', toon: 'Toon ramp', 'hand-painted': 'Hand-painted albedo', pbr: 'PBR metal and roughness', pixel: 'Pixel textures' })[r.materials]}${r.outline ? ', outlines' : ''}`, why: `follows the render style (${r.label}); phones: one material per asset`, options: [['flat', 'Flat palette colours'], ['toon', 'Toon ramp'], ['hand-painted', 'Hand-painted albedo'], ['pbr', 'PBR']].map(([k, l]) => ({ id: k, label: l, value: { model: k, outline: k === 'toon', texelDensity: null, atlas: k === 'flat' } })) };
140
165
  }],
141
166
  ['style.light', 'style', 'Where does the light come from, how hard, and what time of day?', ['style.render'], (ctx, p) => {
167
+ if (ctx.drawn?.light?.time && LIGHTS[ctx.drawn.light.time]) { const base = lightValue(ctx.drawn.light.time, p['style.palette'] ?? paletteValue('meadow-morning')); return { value: { ...base, ...Object.fromEntries(Object.entries(ctx.drawn.light).filter(([k]) => k in base)) }, label: LIGHTS[ctx.drawn.light.time].label, why: 'what the game draws now (its style.json)', options: Object.entries(LIGHTS).map(([k, v]) => ({ id: k, label: v.label, value: k })) }; }
142
168
  const hit = best(LIGHTS, ctx);
143
169
  const palName = p['style.palette']?.name;
144
170
  const key = hit?.[0] ?? ({ 'moonlit-grove': 'night', 'neon-dusk': 'night', 'embers-night': 'night', 'desert-noon': 'noon', 'autumn-grove': 'golden', 'ash-dawn': 'overcast', 'space-ink': 'night' })[palName] ?? 'morning';
145
171
  return { value: lightValue(key, p['style.palette'] ?? paletteValue('meadow-morning')), label: LIGHTS[key].label, why: hit ? `your words ${said(ctx, LIGHTS[key].words)}` : 'fits the palette', options: Object.entries(LIGHTS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
146
172
  }],
147
173
  ['style.camera', 'style', 'Where does the camera sit?', [], (ctx) => {
174
+ if (ctx.drawn?.camera?.angle && CAMERAS[ctx.drawn.camera.angle]) { const c = ctx.drawn.camera; return { value: cameraValue(c.angle, Object.fromEntries(['pitch', 'distance', 'fov', 'projection'].filter((k) => c[k] !== undefined).map((k) => [k, c[k]]))), label: CAMERAS[c.angle].label, why: 'what the game draws now (its style.json)', options: Object.entries(CAMERAS).map(([k, v]) => ({ id: k, label: v.label, value: k })) }; }
148
175
  const asked = /\btop[- ]?down\b/i.test(ctx.words) ? 'top-down' : /\biso(metric)?\b/i.test(ctx.words) ? 'iso' : /\bside[- ]?(on|scroll\w*|view)?\b/i.test(ctx.words) ? 'side' : /\b(chase|third[- ]person)\b/i.test(ctx.words) ? 'chase' : null;
149
176
  const key = asked ?? (GENRES[ctx.genre] ?? GENRES.party).camera;
150
177
  return { value: cameraValue(key), label: CAMERAS[key].label, why: asked ? 'your words' : `a ${ctx.genre} game: ${CAMERAS[key].note}`, options: Object.entries(CAMERAS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
151
178
  }],
152
179
  ['style.ui', 'style', 'Which fonts, icons and HUD shape?', ['style.render', 'style.shape'], (ctx, p) => {
153
180
  const r = RENDERS[p['style.render']] ?? RENDERS['lowpoly-flat'];
154
- const fonts = { display: ctx.codexFonts.display ?? r.fonts.display, body: ctx.codexFonts.body ?? r.fonts.body };
181
+ const fonts = { display: ctx.codexFonts.display ?? ctx.drawn?.fonts?.display ?? r.fonts.display, body: ctx.codexFonts.body ?? ctx.drawn?.fonts?.body ?? r.fonts.body };
155
182
  const radius = p['style.shape']?.language === 'angular' ? 4 : p['style.shape']?.language === 'blocky' ? 8 : 14;
156
- return { value: { ...fonts, icons: p['style.render'] === 'pixel-hd2d' ? 'pixel' : 'filled', radius, hud: 'chips' }, label: `${fonts.display} and ${fonts.body}`, why: ctx.codexFonts.display ? 'the codex names the fonts' : `Google Fonts (OFL) that suit ${r.label.toLowerCase()}`, options: Object.entries(FONT_SETS).map(([k, v]) => ({ id: k, label: `${v.display} / ${v.body}`, value: v })) };
183
+ return { value: { ...fonts, icons: p['style.render'] === 'pixel-hd2d' ? 'pixel' : 'filled', radius, hud: 'chips' }, label: `${fonts.display} and ${fonts.body}`, why: ctx.codexFonts.display ? 'the codex names the fonts' : ctx.drawn?.fonts?.display ? 'what the game draws now (its style.json)' : `Google Fonts (OFL) that suit ${r.label.toLowerCase()}`, options: Object.entries(FONT_SETS).map(([k, v]) => ({ id: k, label: `${v.display} / ${v.body}`, value: v })) };
157
184
  }],
158
185
  ['style.vfx', 'style', 'What do effects look like?', ['style.render', 'style.light'], (ctx, p) => {
159
186
  const r = RENDERS[p['style.render']] ?? RENDERS['lowpoly-flat'];
@@ -188,7 +215,7 @@ export const CATALOGUE = Object.freeze([
188
215
  const beast = /\b(fox(es)?|cats?|dogs?|wolf|wolves|bears?|horses?|deer|rabbits?|bunn(y|ies)|animals?|pets?|creatures?|dragons?)\b/i.test(ctx.words);
189
216
  return { value: beast ? 'quadruped' : 'humanoid', label: beast ? 'Per-species (four legs)' : 'Homie humanoid (VRM names, Mixamo in)', why: beast ? `your characters are animals (${said(ctx, /\b(fox(es)?|cats?|dogs?|wolf|wolves|bears?|horses?|deer|rabbits?|bunn(y|ies)|animals?|pets?|creatures?|dragons?)\b/i)})` : 'people: one humanoid vocabulary shares one clip library', options: [{ id: 'humanoid', label: 'Humanoid', value: 'humanoid' }, { id: 'quadruped', label: 'Quadruped', value: 'quadruped' }, { id: 'none', label: 'None', value: 'none' }] };
190
217
  }],
191
- ['rig.source', 'rigs', 'Where do rigs come from?', ['rig.skeleton'], () => ({ value: 'library', label: 'The library\'s rigs', why: 'free; generated rigs are phase 2', options: [] })],
218
+ ['rig.source', 'rigs', 'Where do rigs come from?', ['rig.skeleton'], () => ({ value: 'library', label: 'The library\'s rigs', why: 'free; a character the library cannot cover can be generated with its rig on your fal account (the models skill)', options: [] })],
192
219
  ['rig.bones', 'rigs', 'How many bones?', ['cast.tiers', 'style.camera'], () => ({ value: { hero: 48, npc: 32, fingers: false, influences: 4 }, label: '48 a hero, 32 an NPC, 4 influences', why: 'three.js reads 4 influences; fingers only for close cameras', options: [] })],
193
220
  ['rig.sockets', 'rigs', 'Where do held things attach?', [], () => ({ value: ['hand.R', 'hand.L', 'head', 'back'], label: 'hand.R, hand.L, head, back', why: 'the standard set', options: [] })],
194
221
  ['rig.face', 'rigs', 'How do faces move?', ['style.camera'], () => ({ value: 'none', label: 'None (phones)', why: 'a face is a few pixels on a phone at this camera', options: [] })],
@@ -197,8 +224,10 @@ export const CATALOGUE = Object.freeze([
197
224
  const k = p['style.render'] === 'pixel-hd2d' ? 'stepped' : ctx.genre === 'rpg' ? 'grounded' : 'snappy';
198
225
  return { value: k, label: ({ snappy: 'Snappy cartoon', grounded: 'Grounded, stylised', stepped: 'Stepped (pixel)' })[k], why: `a ${ctx.genre} game`, options: [] };
199
226
  }],
200
- ['anim.clips', 'animations', 'Which clips each role needs?', [], (ctx) => ({ value: (GENRES[ctx.genre] ?? GENRES.party).clips, label: (GENRES[ctx.genre] ?? GENRES.party).clips.join(', '), why: `the verbs of a ${ctx.genre} game`, options: [] })],
201
- ['anim.source', 'animations', 'Where do clips come from?', ['anim.clips'], () => ({ value: 'library', label: 'Library clips, retargeted', why: 'free first (phase 2 adds generated motion)', options: [] })],
227
+ ['anim.clips', 'animations', 'Which clips each role needs?', [], (ctx) => (ctx.clipVerbs?.length
228
+ ? { value: ctx.clipVerbs, label: ctx.clipVerbs.join(', '), why: 'the clips its characters already play', options: [] }
229
+ : { value: (GENRES[ctx.genre] ?? GENRES.party).clips, label: (GENRES[ctx.genre] ?? GENRES.party).clips.join(', '), why: `the verbs of a ${ctx.genre} game`, options: [] })],
230
+ ['anim.source', 'animations', 'Where do clips come from?', ['anim.clips'], () => ({ value: 'library', label: 'Library clips, retargeted', why: 'free CC0 clips, retargeted onto every skeleton; text-to-motion is not offered (the models available are non-commercial in effect)', options: [] })],
202
231
  ['anim.motion', 'animations', 'In place or root motion?', [], () => ({ value: { root: 'in-place', fps: 30 }, label: 'In place, 30 fps', why: 'the host moves bodies; clips never do (netplay)', options: [] })],
203
232
  ['anim.blend', 'animations', 'Crossfades and layers?', ['anim.style'], (ctx, p) => ({ value: { crossfadeS: p['anim.style'] === 'grounded' ? 0.25 : 0.12, upperBody: true, additiveHits: true }, label: p['anim.style'] === 'grounded' ? '0.25 s crossfades' : '0.12 s crossfades', why: 'follows the motion style', options: [] })],
204
233
  ['anim.procedural', 'animations', 'Which procedural layers?', ['style.camera'], (ctx) => ({ value: { lookAt: true, footIK: false, springs: /\b(tail|cape|hair|fox|ears?)\b/i.test(ctx.words), lean: true, squash: true }, label: 'Look-at, lean, squash; springs on tails and capes', why: 'cheap life on top of clips; foot planting off at this camera', options: [] })],
@@ -340,7 +369,8 @@ export function initDecisions(root, id, { prompt = '', path = null, budget = nul
340
369
  for (const [did] of CATALOGUE) {
341
370
  const def = CAT.get(did);
342
371
  const rec = doc.decisions[did];
343
- if (rec && rec.state !== 'auto') { picked[did] = rec.value; kept.push(did); continue; }
372
+ // Kept: anything the person steered or locked, anything pinned, and what a starter's style.json already draws.
373
+ if (rec && (rec.state !== 'auto' || /^what the game draws now/.test(String(rec.why ?? '')))) { picked[did] = rec.value; kept.push(did); continue; }
344
374
  const pick = def.pick(ctx, picked);
345
375
  picked[did] = pick.value;
346
376
  if (rec && JSON.stringify(rec.value) === JSON.stringify(pick.value)) continue;
package/lib/mcp-tools.mjs CHANGED
@@ -701,7 +701,7 @@ export function toolDefs(ctx, avail = {}) {
701
701
  },
702
702
  {
703
703
  name: 'game_make', title: 'Make a multiplayer game',
704
- description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays.',
704
+ description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays.',
705
705
  inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
706
706
  annotations: { title: 'Make a game', ...RW },
707
707
  run: async (a) => {
package/lib/mcp.mjs CHANGED
@@ -52,8 +52,8 @@ A game's look is a set of decisions (render style, palette, light, camera, fonts
52
52
 
53
53
  Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
54
54
 
55
- const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js', [UI.lab]: 'lab.js', [UI.style]: 'style.js', [UI.decision]: 'decision.js', [UI.cast]: 'cast.js', [UI.lineup]: 'lineup.js', [UI.rights]: 'rights.js' };
56
- const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex', [UI.lab]: 'Game Lab', [UI.style]: 'Style board', [UI.decision]: 'Look decision', [UI.cast]: 'Cast', [UI.lineup]: 'Lineup', [UI.rights]: 'Rights' };
55
+ const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js', [UI.lab]: 'lab.js', [UI.style]: 'style.js', [UI.decision]: 'decision.js', [UI.cast]: 'cast.js', [UI.lineup]: 'lineup.js', [UI.rights]: 'rights.js', [UI.animation]: 'animation.js' };
56
+ const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex', [UI.lab]: 'Game Lab', [UI.style]: 'Style board', [UI.decision]: 'Look decision', [UI.cast]: 'Cast', [UI.lineup]: 'Lineup', [UI.rights]: 'Rights', [UI.animation]: 'Clips' };
57
57
  const UI_DIR = join(PACKAGE_ROOT, 'mcp', 'ui');
58
58
 
59
59
  /** One card's whole document: the shared look and bridge, and the card's own script, as the files are. */