@genex-ai/cli-demo 0.95.0-dev.271 → 0.96.0-dev.272

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.95.0-dev.271",
3
+ "version": "0.96.0-dev.272",
4
4
  "description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -266,6 +266,210 @@ export function buildClipMap(
266
266
  return map;
267
267
  }
268
268
 
269
+ // ---------------------------------------------------------------------------
270
+ // Upper-body layering
271
+ // ---------------------------------------------------------------------------
272
+ //
273
+ // This is how an attack lands while the character is still running. Without it
274
+ // every one-shot is full-body and freezes the legs — a moving character slides
275
+ // across the ground in a static pose for the clip's whole length.
276
+ //
277
+ // three.js has NO bone mask. The only mechanism that works is filtering a
278
+ // clip's `tracks` down to one region and playing the result as an ordinary
279
+ // action on the SAME mixer. Three facts from the three.js source shape
280
+ // everything below; each one is a day lost if rediscovered the hard way:
281
+ //
282
+ // 1. `PropertyMixer.accumulate` NORMALIZES. Two normal-mode actions at weight 1
283
+ // on a shared bone give a 50/50 slerp, not "last one wins". The layer needs
284
+ // an elevated weight to actually override — see UPPER_LAYER_WEIGHT.
285
+ // 2. NEVER `crossFadeFrom` between the base and a layer. `apply()` mixes every
286
+ // bone toward its ORIGINAL BIND POSE at `1 - cumulativeWeight`, so fading the
287
+ // full-body base out drags the torso toward T-pose. The base stays at weight
288
+ // 1 forever; only the layer fades.
289
+ // 3. No additive blending. `AnimationUtils.makeClipAdditive` MUTATES the clip in
290
+ // place, and a character loader shares clip objects between every character
291
+ // built on the same rig — one additive conversion would corrupt the roster.
292
+
293
+ /**
294
+ * Weight of an upper-body layer over the full-body base.
295
+ *
296
+ * `accumulate` normalizes, so the layer's share is `w / (1 + w)`: 4 -> 80%
297
+ * (visibly limp — the run still bleeds through the swing), 12 -> 92%, 19 -> 95%.
298
+ * 12 reads as an override while leaving just enough base for the torso to keep
299
+ * breathing with the gait. Raise it if a swing looks half-hearted.
300
+ */
301
+ const UPPER_LAYER_WEIGHT = 12;
302
+ /** Crossfade for an upper-body layer, seconds. */
303
+ const UPPER_FADE_DURATION = 0.12;
304
+
305
+ /** Bones that must NOT end up inside an upper-body mask (verification). */
306
+ const LEG_PATTERN = /upleg|upperleg|thigh|shin|calf|knee|ankle|foot|toe|leg/i;
307
+ /** Bones whose presence identifies the spine subtree (never a leg). */
308
+ const TORSO_PATTERN = /head|neck|skull|hand|wrist|palm|finger|thumb|shoulder|clavicle|arm/i;
309
+
310
+ /** The result of {@link deriveUpperBody} — a bone-name set plus its provenance. */
311
+ export interface UpperBodyMask {
312
+ /** Bone names belonging to the upper body (spine subtree, hips excluded). */
313
+ readonly names: ReadonlySet<string>;
314
+ /** The root/pelvis bone the mask was derived from. Never in `names`. */
315
+ readonly rootBone: string;
316
+ /** Total bones on the skeleton (for logging / sanity). */
317
+ readonly boneCount: number;
318
+ }
319
+
320
+ function collectBones(model: THREE.Object3D): THREE.Object3D[] {
321
+ // Prefer the real skeleton: it is the authoritative joint list, and a rig can
322
+ // carry helper Object3Ds (attachment sockets, the Armature node) that are not
323
+ // joints at all.
324
+ let bones: THREE.Object3D[] | null = null;
325
+ model.traverse((object) => {
326
+ if (bones) return;
327
+ const skinned = object as THREE.SkinnedMesh;
328
+ if (skinned.isSkinnedMesh && skinned.skeleton?.bones?.length) {
329
+ bones = [...skinned.skeleton.bones];
330
+ }
331
+ });
332
+ if (bones) return bones;
333
+ const found: THREE.Object3D[] = [];
334
+ model.traverse((object) => {
335
+ if ((object as THREE.Bone).isBone) found.push(object);
336
+ });
337
+ return found;
338
+ }
339
+
340
+ function subtreeNames(root: THREE.Object3D, within: Set<THREE.Object3D>): Set<string> {
341
+ const out = new Set<string>();
342
+ const stack = [root];
343
+ while (stack.length > 0) {
344
+ const node = stack.pop()!;
345
+ if (!within.has(node)) continue;
346
+ out.add(node.name);
347
+ for (const child of node.children) stack.push(child);
348
+ }
349
+ return out;
350
+ }
351
+
352
+ /**
353
+ * Derive the upper-body bone set of a humanoid rig, or null when the skeleton
354
+ * does not look like one.
355
+ *
356
+ * STRUCTURAL, not name-driven, on purpose: generated rigs use Mixamo-ish bone
357
+ * names but can INVERT the spine convention — on one shipped rig the hips'
358
+ * child is `Spine02` and the bone actually called `Spine` is the TOPMOST one,
359
+ * carrying the shoulders and neck. Anything keying off "Spine" vs "Spine1"
360
+ * picks the wrong end. So: walk the pelvis' children, keep the subtree that
361
+ * owns the head/arms, and reject the ones that own the feet.
362
+ *
363
+ * Returns null (never a guess) when any check fails — every caller degrades to
364
+ * full-body playback, which is merely worse-looking rather than broken.
365
+ */
366
+ export function deriveUpperBody(model: THREE.Object3D): UpperBodyMask | null {
367
+ const bones = collectBones(model);
368
+ if (bones.length < 6) return null;
369
+ const boneSet = new Set(bones);
370
+
371
+ // Root = the bone whose parent is not itself a bone (the Armature/scene node).
372
+ // Several can qualify on odd exports; the one with the most descendants wins.
373
+ const roots = bones.filter((bone) => !bone.parent || !boneSet.has(bone.parent));
374
+ if (roots.length === 0) return null;
375
+ let root = roots[0]!;
376
+ if (roots.length > 1) {
377
+ let best = -1;
378
+ for (const candidate of roots) {
379
+ const size = subtreeNames(candidate, boneSet).size;
380
+ if (size > best) {
381
+ best = size;
382
+ root = candidate;
383
+ }
384
+ }
385
+ }
386
+
387
+ // Score the pelvis' children: the spine subtree owns head/arms and no legs.
388
+ let spine: THREE.Object3D | null = null;
389
+ let spineSize = 0;
390
+ for (const child of root.children) {
391
+ if (!boneSet.has(child)) continue;
392
+ const names = subtreeNames(child, boneSet);
393
+ let hasLeg = false;
394
+ let hasTorso = false;
395
+ for (const name of names) {
396
+ if (LEG_PATTERN.test(name)) hasLeg = true;
397
+ else if (TORSO_PATTERN.test(name)) hasTorso = true;
398
+ }
399
+ if (hasLeg || !hasTorso) continue;
400
+ if (names.size > spineSize) {
401
+ spine = child;
402
+ spineSize = names.size;
403
+ }
404
+ }
405
+ if (!spine) return null;
406
+
407
+ const names = subtreeNames(spine, boneSet);
408
+
409
+ // Verify before trusting it. A mask that quietly swallowed a leg would make
410
+ // the character slide with frozen feet — worse than never layering at all.
411
+ if (names.size === 0 || names.size >= bones.length) return null;
412
+ for (const name of names) if (LEG_PATTERN.test(name)) return null;
413
+ const legOutside = bones.some((bone) => !names.has(bone.name) && LEG_PATTERN.test(bone.name));
414
+ if (!legOutside) return null;
415
+ const ratio = names.size / bones.length;
416
+ if (ratio < 0.2 || ratio > 0.9) return null;
417
+
418
+ return { names, rootBone: root.name, boneCount: bones.length };
419
+ }
420
+
421
+ /**
422
+ * Copy of `clip` keeping only the tracks that address a bone in `mask`.
423
+ *
424
+ * NEVER mutates the source: a character loader shares clip objects across every
425
+ * character on the same rig, so an in-place edit here would silently change
426
+ * what the others play.
427
+ */
428
+ export function filterClipToBones(
429
+ clip: THREE.AnimationClip,
430
+ mask: ReadonlySet<string>,
431
+ name: string,
432
+ ): THREE.AnimationClip | null {
433
+ const tracks = clip.tracks.filter((track) => {
434
+ const parsed = THREE.PropertyBinding.parseTrackName(track.name);
435
+ return parsed.nodeName !== undefined && mask.has(parsed.nodeName);
436
+ });
437
+ if (tracks.length === 0) return null;
438
+ // Duration is copied, not recomputed: a track set that happens to end early
439
+ // must still occupy the clip's full length or the one-shot fires "finished"
440
+ // ahead of the visual.
441
+ return new THREE.AnimationClip(name, clip.duration, tracks.map((track) => track.clone()));
442
+ }
443
+
444
+ /** Logged once per session, not once per character. */
445
+ let upperBodyReported = false;
446
+
447
+ export interface PlayUpperBodyOptions {
448
+ /** Fade-in seconds for the layer. Default 0.12. */
449
+ fadeIn?: number;
450
+ /** Playback speed. Default 1. */
451
+ timeScale?: number;
452
+ /** Layer weight; the layer's share of a shared bone is `w / (1 + w)`. Default 12. */
453
+ weight?: number;
454
+ /**
455
+ * Hold the clamped final frame instead of fading back to the carry stance.
456
+ * This is how a wind-up tell stays drawn back on the torso — indefinitely —
457
+ * while the legs keep running underneath. Release with `clearUpperBody()`.
458
+ */
459
+ clamp?: boolean;
460
+ /** Fired when the clip finishes (skipped if a newer layer clip replaces it). */
461
+ onDone?: () => void;
462
+ }
463
+
464
+ export interface HoldUpperBodyOptions {
465
+ /** Fade-in seconds for the stance. Default 0.12. */
466
+ fadeIn?: number;
467
+ /** Playback speed. Default 1. */
468
+ timeScale?: number;
469
+ /** Layer weight; the stance's share of a shared bone is `w / (1 + w)`. Default 12. */
470
+ weight?: number;
471
+ }
472
+
269
473
  // ---------------------------------------------------------------------------
270
474
  // Playback constants (upstream demo values)
271
475
  // ---------------------------------------------------------------------------
@@ -363,6 +567,25 @@ export class CharacterAnimations {
363
567
  // locomotion states (any installed pack clip: Punch_*, Sword_*, Sitting_*…).
364
568
  #clipsByName = new Map<string, THREE.AnimationClip>();
365
569
  #missingOneShotWarnings = new Set<string>();
570
+ // Upper-body layering. `#upperMask` is derived lazily on first use and cached
571
+ // as `null` when this rig cannot be split, so a rejected skeleton is probed
572
+ // exactly once.
573
+ #upperMask: UpperBodyMask | null = null;
574
+ #upperMaskResolved = false;
575
+ #upperClips = new Map<string, THREE.AnimationClip>();
576
+ #upperAction: THREE.AnimationAction | null = null;
577
+ // The SOURCE clip name, not the filtered action's (`X__upper`): callers ask
578
+ // "is my cast still playing?" about the clip they requested.
579
+ #upperName: string | null = null;
580
+ #upperHoldPose = false;
581
+ #upperOnDone: (() => void) | undefined;
582
+ #upperHoldAction: THREE.AnimationAction | null = null;
583
+ #upperHoldName: string | null = null;
584
+ #upperHoldWeight = UPPER_LAYER_WEIGHT;
585
+ // Set when playUpperBody could not layer and ran the clip FULL-BODY instead.
586
+ // clearUpperBody() has to release it through the one-shot path it actually
587
+ // took, or a clamped fallback pose strands the character forever.
588
+ #upperFellBack: string | null = null;
366
589
  #oneShotAction: THREE.AnimationAction | null = null;
367
590
  #locomotionOneShotAction: THREE.AnimationAction | null = null;
368
591
  #oneShotOnDone: (() => void) | undefined;
@@ -485,6 +708,25 @@ export class CharacterAnimations {
485
708
  };
486
709
 
487
710
  this.#onFinished = (event) => {
711
+ // Upper-body layer one-shot finished: fade the layer out and let the
712
+ // carry stance (if any) come back. The base never moved, so there is
713
+ // nothing to recover — that is the whole point of layering.
714
+ if (this.#upperAction && event.action === this.#upperAction) {
715
+ const done = this.#upperOnDone;
716
+ this.#upperOnDone = undefined;
717
+ if (this.#upperHoldPose) {
718
+ // clamp:true — the drawn-back pose stays on the torso until an
719
+ // explicit clearUpperBody() (the charge release, a death, a respawn).
720
+ done?.();
721
+ return;
722
+ }
723
+ this.#upperAction.fadeOut(UPPER_FADE_DURATION);
724
+ this.#upperAction = null;
725
+ this.#upperName = null;
726
+ this.#restoreUpperHold();
727
+ done?.();
728
+ return;
729
+ }
488
730
  // One-shot (punch/gesture) finished.
489
731
  if (this.#oneShotAction && event.action === this.#oneShotAction) {
490
732
  const finished = this.#oneShotAction;
@@ -506,6 +748,10 @@ export class CharacterAnimations {
506
748
  this.#oneShotOnDone = undefined;
507
749
  this.#canPlayNext = true;
508
750
  this.#recoverFromOneShot(finished);
751
+ this.#upperFellBack = null;
752
+ // A full-body one-shot owns every bone, so any carry stance stepped
753
+ // aside for it; give the weapon back now that the body is free.
754
+ this.#restoreUpperHold();
509
755
  done?.();
510
756
  return;
511
757
  }
@@ -707,6 +953,10 @@ export class CharacterAnimations {
707
953
 
708
954
  this.#oneShotAction = action;
709
955
  this.#oneShotOnDone = options.onDone;
956
+ // A full-body one-shot owns every bone. `accumulate` normalizes, so leaving
957
+ // a carry stance at layer weight would average it INTO the one-shot and
958
+ // half-cancel it. The stance steps aside and is restored on 'finished'.
959
+ this.#upperHoldAction?.fadeOut(UPPER_FADE_DURATION);
710
960
  // Freeze the locomotion transition until the one-shot's 'finished' event
711
961
  // reopens it; marking the one-shot as the "current" action makes
712
962
  // #applyTransition a no-op meanwhile.
@@ -743,6 +993,258 @@ export class CharacterAnimations {
743
993
  if (finished) this.#recoverFromOneShot(finished);
744
994
  }
745
995
 
996
+ /**
997
+ * Can this rig be split into an upper body at all?
998
+ *
999
+ * Ask BEFORE designing around layering: a rig that fails the structural
1000
+ * checks in {@link deriveUpperBody} makes every `playUpperBody` return false,
1001
+ * and the game should fall back to full-body one-shots rather than silently
1002
+ * dropping its attacks. Derives the mask on first call and caches it.
1003
+ */
1004
+ get canLayerUpperBody(): boolean {
1005
+ return this.#resolveUpperMask() !== null;
1006
+ }
1007
+
1008
+ /** Is a layered upper-body clip playing (or holding a clamped pose)? */
1009
+ get upperBodyActive(): boolean {
1010
+ return this.#upperAction !== null;
1011
+ }
1012
+
1013
+ /** Source clip name of the active upper-body layer, or null. */
1014
+ get currentUpperBodyName(): string | null {
1015
+ return this.#upperName;
1016
+ }
1017
+
1018
+ /** Source clip name of the held carry stance, or null. */
1019
+ get currentUpperHoldName(): string | null {
1020
+ return this.#upperHoldName;
1021
+ }
1022
+
1023
+ /**
1024
+ * Is the held carry stance still LOOPING?
1025
+ *
1026
+ * Exposed because the filtered upper-body actions are cached per clip name
1027
+ * and shared, so a clip played as a clamped one-shot leaves its action
1028
+ * clamped for the stance that also uses it. That is invisible from the
1029
+ * outside — the pose simply stops moving — and it needs to be assertable.
1030
+ */
1031
+ get upperHoldLooping(): boolean {
1032
+ const hold = this.#upperHoldAction;
1033
+ return hold ? hold.loop === THREE.LoopRepeat && !hold.clampWhenFinished : false;
1034
+ }
1035
+
1036
+ /**
1037
+ * Play a clip on the torso/arms/head only, over whatever the legs are doing.
1038
+ * This is how an attack lands while the character is still running.
1039
+ *
1040
+ * ALWAYS PLAYS THE CLIP. On a rig this mask cannot split it falls back to a
1041
+ * full-body {@link playOneShot} — legs frozen, which is how every one-shot
1042
+ * behaved before layering existed. So `true` means "the clip is playing",
1043
+ * NOT "the clip is layered"; read {@link upperBodyActive} if you need to
1044
+ * know which. It returns false only for the same reason `playOneShot` does:
1045
+ * the clip is not installed.
1046
+ *
1047
+ * You therefore do not need to write a fallback, and should not have to
1048
+ * remember to. {@link clearUpperBody} releases either form.
1049
+ *
1050
+ * @example
1051
+ * anims.playUpperBody("Sword_Slash"); // layers if it can, plays regardless
1052
+ */
1053
+ playUpperBody(clipName: string, options: PlayUpperBodyOptions = {}): boolean {
1054
+ if (this.#disposed) return false;
1055
+ this.#upperFellBack = null;
1056
+ const action = this.#upperActionFor(clipName);
1057
+ if (!action) {
1058
+ // NOT A SILENT NO-OP. A rig this mask cannot split must still PLAY the
1059
+ // clip — full-body, legs frozen, exactly as `playOneShot` always did.
1060
+ // Returning false and leaving nothing playing would trade a visible ugly
1061
+ // failure for an invisible one, and the caller who forgets the fallback
1062
+ // ships a game whose attacks have no animation at all. (Measured on the
1063
+ // game this came from: two of its three call sites checked the return
1064
+ // value and one did not.)
1065
+ //
1066
+ // The clip simply not being installed is a different thing and still
1067
+ // returns false — same contract as `playOneShot`, which the clip-missing
1068
+ // branch of `#upperActionFor` has already warned about.
1069
+ if (!this.#clipsByName.has(clipName)) return false;
1070
+ const played = this.playOneShot(clipName, {
1071
+ fadeIn: options.fadeIn,
1072
+ timeScale: options.timeScale,
1073
+ clamp: options.clamp,
1074
+ onDone: options.onDone,
1075
+ });
1076
+ if (played) this.#upperFellBack = clipName;
1077
+ return played;
1078
+ }
1079
+
1080
+ const previous = this.#upperAction;
1081
+ this.#upperAction = action;
1082
+ this.#upperName = clipName;
1083
+ this.#upperOnDone = options.onDone;
1084
+ // The stance and the swing fight over the same bones, and `accumulate`
1085
+ // normalizes — leaving both at layer weight would average them into a
1086
+ // half-swing. The stance steps aside and comes back on "finished".
1087
+ this.#upperHoldAction?.fadeOut(UPPER_FADE_DURATION);
1088
+ if (previous && previous !== action) previous.fadeOut(UPPER_FADE_DURATION);
1089
+
1090
+ action.enabled = true;
1091
+ action.setLoop(THREE.LoopOnce, 1);
1092
+ action.clampWhenFinished = true;
1093
+ this.#upperHoldPose = options.clamp ?? false;
1094
+ action.timeScale = options.timeScale ?? 1;
1095
+ action.weight = options.weight ?? UPPER_LAYER_WEIGHT;
1096
+ action.reset();
1097
+ action.fadeIn(options.fadeIn ?? UPPER_FADE_DURATION);
1098
+ action.play();
1099
+ return true;
1100
+ }
1101
+
1102
+ /**
1103
+ * Hold a LOOPING clip on the upper body indefinitely — the weapon carry
1104
+ * stance (sword held low, bow at the hip) that rides on top of the stock
1105
+ * unarmed locomotion.
1106
+ *
1107
+ * This is what makes a weapon affordable: an immutable controller pack owns
1108
+ * `walk.forward` / `run.forward` and cannot be replaced, but nothing stops a
1109
+ * stance from posing the torso above those legs. Release it on weapon swap
1110
+ * with {@link releaseUpperHold}.
1111
+ *
1112
+ * UNLIKE {@link playUpperBody}, THIS HAS NO FULL-BODY FALLBACK and returns
1113
+ * false on a rig the mask cannot split. That asymmetry is deliberate: a
1114
+ * one-shot played full-body is merely uglier, but a persistent stance played
1115
+ * full-body would REPLACE locomotion outright — the character would stop
1116
+ * walking for as long as the weapon is drawn. Carrying no stance is the
1117
+ * correct degradation; there is nothing to fall back to.
1118
+ */
1119
+ holdUpperBody(clipName: string, options: HoldUpperBodyOptions = {}): boolean {
1120
+ if (this.#disposed) return false;
1121
+ if (this.#upperHoldName === clipName) return true;
1122
+ const action = this.#upperActionFor(clipName);
1123
+ if (!action) return false;
1124
+
1125
+ this.#upperHoldAction?.fadeOut(options.fadeIn ?? UPPER_FADE_DURATION);
1126
+ this.#upperHoldWeight = options.weight ?? UPPER_LAYER_WEIGHT;
1127
+ this.#upperHoldAction = action;
1128
+ this.#upperHoldName = clipName;
1129
+ action.enabled = true;
1130
+ action.setLoop(THREE.LoopRepeat, Infinity);
1131
+ action.clampWhenFinished = false;
1132
+ action.timeScale = options.timeScale ?? 1;
1133
+ action.weight = this.#upperHoldWeight;
1134
+ action.reset();
1135
+ // A swing owns the torso right now; the stance waits its turn rather than
1136
+ // averaging itself into the attack.
1137
+ if (this.#upperAction) action.setEffectiveWeight(0);
1138
+ else action.fadeIn(options.fadeIn ?? UPPER_FADE_DURATION);
1139
+ action.play();
1140
+ return true;
1141
+ }
1142
+
1143
+ /** Drop the held carry stance (weapon stowed / swapped). No-op when none. */
1144
+ releaseUpperHold(fadeOut = UPPER_FADE_DURATION): void {
1145
+ if (this.#disposed || !this.#upperHoldAction) return;
1146
+ this.#upperHoldAction.fadeOut(fadeOut);
1147
+ this.#upperHoldAction = null;
1148
+ this.#upperHoldName = null;
1149
+ }
1150
+
1151
+ /** Cancel a layered one-shot NOW (interrupt, death, respawn) and let the
1152
+ * stance — if any — take the torso back. Safe when nothing is playing.
1153
+ *
1154
+ * Releases a FULL-BODY fallback too (see `playUpperBody`), so a caller that
1155
+ * never learns whether the rig could be split still gets a clean release —
1156
+ * otherwise a `clamp: true` fallback would hold its final frame forever. */
1157
+ clearUpperBody(fadeOut = UPPER_FADE_DURATION): void {
1158
+ if (this.#upperFellBack !== null) {
1159
+ if (this.currentOneShotName === this.#upperFellBack) this.clearOneShot();
1160
+ this.#upperFellBack = null;
1161
+ return;
1162
+ }
1163
+ if (this.#cancelUpperOneShot(fadeOut)) this.#restoreUpperHold();
1164
+ }
1165
+
1166
+ /** Drop the stance AND any layered one-shot — the full "back to unarmed". */
1167
+ clearUpperLayers(): void {
1168
+ this.clearUpperBody();
1169
+ this.releaseUpperHold();
1170
+ }
1171
+
1172
+ #resolveUpperMask(): UpperBodyMask | null {
1173
+ if (!this.#upperMaskResolved) {
1174
+ this.#upperMaskResolved = true;
1175
+ this.#upperMask = deriveUpperBody(this.#model);
1176
+ if (!upperBodyReported) {
1177
+ upperBodyReported = true;
1178
+ console.info(
1179
+ this.#upperMask
1180
+ ? `[character-animations] upper-body layer: ${this.#upperMask.names.size}/${this.#upperMask.boneCount} bones above "${this.#upperMask.rootBone}"`
1181
+ : "[character-animations] this rig cannot be split into an upper body — layered actions fall back to full-body playback.",
1182
+ );
1183
+ }
1184
+ }
1185
+ return this.#upperMask;
1186
+ }
1187
+
1188
+ /** Filtered action for `clipName`, or null when unsplittable / not installed. */
1189
+ #upperActionFor(clipName: string): THREE.AnimationAction | null {
1190
+ const mask = this.#resolveUpperMask();
1191
+ if (!mask) return null;
1192
+ let filtered = this.#upperClips.get(clipName);
1193
+ if (!filtered) {
1194
+ const source = this.#clipsByName.get(clipName);
1195
+ if (!source) {
1196
+ if (!this.#missingOneShotWarnings.has(clipName)) {
1197
+ this.#missingOneShotWarnings.add(clipName);
1198
+ console.warn(
1199
+ `[character-animations] upper-body clip "${clipName}" is not installed; playUpperBody() returned false.`,
1200
+ );
1201
+ }
1202
+ return null;
1203
+ }
1204
+ const built = filterClipToBones(source, mask.names, `${clipName}__upper`);
1205
+ if (!built) return null;
1206
+ filtered = built;
1207
+ this.#upperClips.set(clipName, filtered);
1208
+ }
1209
+ return this.mixer.clipAction(filtered);
1210
+ }
1211
+
1212
+ /** Fade out the layered one-shot without touching the stance.
1213
+ * Returns true when something was actually cancelled. */
1214
+ #cancelUpperOneShot(fadeOut: number): boolean {
1215
+ if (this.#disposed || !this.#upperAction) return false;
1216
+ this.#upperAction.fadeOut(fadeOut);
1217
+ this.#upperAction = null;
1218
+ this.#upperName = null;
1219
+ this.#upperHoldPose = false;
1220
+ this.#upperOnDone = undefined;
1221
+ return true;
1222
+ }
1223
+
1224
+ /**
1225
+ * Fade the carry stance back in after a layered one-shot released the torso.
1226
+ *
1227
+ * THE LOOP HAS TO BE RESTORED EXPLICITLY. `#upperActionFor` caches one filtered
1228
+ * clip per name and hands back the SAME `AnimationAction` every time, and
1229
+ * `playUpperBody` mutates that shared action to LoopOnce + clampWhenFinished.
1230
+ * So a clip used as both a held stance and a clamped one-shot comes back from
1231
+ * the one-shot permanently clamped: the carry pose plays once and freezes.
1232
+ *
1233
+ * Unreachable until one clip does both jobs — and reachable the moment a
1234
+ * sneak stance moves onto the same clip as a blade carry. Restoring both
1235
+ * flags here is correct regardless of who happens to collide.
1236
+ */
1237
+ #restoreUpperHold(): void {
1238
+ const hold = this.#upperHoldAction;
1239
+ if (!hold) return;
1240
+ hold.enabled = true;
1241
+ hold.weight = this.#upperHoldWeight;
1242
+ hold.setLoop(THREE.LoopRepeat, Infinity);
1243
+ hold.clampWhenFinished = false;
1244
+ hold.fadeIn(UPPER_FADE_DURATION);
1245
+ hold.play();
1246
+ }
1247
+
746
1248
  /** Clip name of the currently-playing (or clamped-held) one-shot, or null.
747
1249
  * Lets a caller cancel a SPECIFIC one-shot (e.g. a held charge wind-up)
748
1250
  * without clobbering whatever replaced it:
@@ -761,6 +1263,12 @@ export class CharacterAnimations {
761
1263
  this.mixer.uncacheClip(action.getClip());
762
1264
  }
763
1265
  this.#actions.clear();
1266
+ // Filtered upper-body clips are OURS (built by filterClipToBones, never
1267
+ // shared with the loader), so they are ours to uncache.
1268
+ for (const clip of this.#upperClips.values()) this.mixer.uncacheClip(clip);
1269
+ this.#upperClips.clear();
1270
+ this.#upperAction = null;
1271
+ this.#upperHoldAction = null;
764
1272
  this.#locomotionOneShotAction = null;
765
1273
  }
766
1274
 
@@ -342,8 +342,12 @@ export class CharacterController {
342
342
  private readonly capsuleRadius: number;
343
343
  private readonly useCustomForward: boolean;
344
344
  private readonly gravityDirLerpSpeed: number;
345
- private readonly maxWalkVel: number;
346
- private readonly maxRunVel: number;
345
+ private maxWalkVel: number;
346
+ private maxRunVel: number;
347
+ /** The constructed speeds, so `setSpeedScale` always scales from the preset
348
+ * rather than compounding off whatever the last call left behind. */
349
+ private readonly baseWalkVel: number;
350
+ private readonly baseRunVel: number;
347
351
  private readonly accDeltaTime: number;
348
352
  private readonly decDeltaTime: number;
349
353
  private readonly rejectVelFactor: number;
@@ -570,6 +574,8 @@ export class CharacterController {
570
574
  this.gravityDirLerpSpeed = options.gravityDirLerpSpeed ?? 6;
571
575
  this.maxWalkVel = options.maxWalkVel ?? 2;
572
576
  this.maxRunVel = options.maxRunVel ?? 5;
577
+ this.baseWalkVel = this.maxWalkVel;
578
+ this.baseRunVel = this.maxRunVel;
573
579
  this.accDeltaTime = options.accDeltaTime ?? 0.2;
574
580
  this.decDeltaTime = options.decDeltaTime ?? 0.2;
575
581
  this.rejectVelFactor = options.rejectVelFactor ?? 1;
@@ -899,11 +905,33 @@ export class CharacterController {
899
905
  if (movement.crouch !== undefined) this.movementState.crouch = movement.crouch;
900
906
  }
901
907
 
908
+ /**
909
+ * Scale walk and run speed at runtime — haste and slow effects, encumbrance,
910
+ * a per-class or per-difficulty pace, wading through mud.
911
+ *
912
+ * Always relative to the CONSTRUCTED speeds, so repeated calls do not compound
913
+ * and `setSpeedScale(1)` is an exact reset. Non-finite or non-positive values
914
+ * are ignored rather than freezing the character in place.
915
+ *
916
+ * This drives the real target velocity, so the animation controller's
917
+ * playback-rate matching follows it for free — a hasted character's legs
918
+ * speed up with them instead of skating.
919
+ */
920
+ setSpeedScale(scale: number): void {
921
+ const s = Number.isFinite(scale) && scale > 0 ? scale : 1;
922
+ this.maxWalkVel = this.baseWalkVel * s;
923
+ this.maxRunVel = this.baseRunVel * s;
924
+ }
925
+
902
926
  /**
903
927
  * Give an authored action collision-aware planar motion without moving the visual root.
904
928
  * Call with a fresh local velocity before each physics step; call with `null` to return
905
929
  * authority to normal ECCTRL input. Vertical velocity, gravity, slopes, and collisions
906
930
  * remain owned by the dynamic Rapier body.
931
+ *
932
+ * THIS IS ALSO HOW YOU DASH. See the dash recipe in the character-controller
933
+ * skill's `references/tuning-and-presets.md`: an impulse applied above the
934
+ * centre of mass is TORQUE, and it pitches a moving character over backwards.
907
935
  */
908
936
  setActionMotion(motion: CharacterActionMotion | null): void {
909
937
  if (motion === null) {
@@ -87,6 +87,12 @@ Open or link all three real candidate images, then wait for the user's choice.
87
87
  Do not treat a text description, task ID, filename, or your own preference as
88
88
  approval. Continue with the selected candidate only:
89
89
 
90
+ The candidate images are permanent and already paid for. Reuse them as game art
91
+ instead of generating new pictures of the same character: a character-select or
92
+ roster portrait, a party or inventory panel, a dialogue bust, a versus card, a
93
+ death screen. They are full-body neutral A-pose renders on a plain backdrop, so
94
+ they crop well to a portrait and read as one set across the cast.
95
+
90
96
  ```bash
91
97
  npx genex character preview <concept-id> --candidate <1|2|3> --user-approved
92
98
  ```
@@ -35,6 +35,8 @@ copy demo architecture.
35
35
  | --- | --- |
36
36
  | shot composition, chase/side/orbit rigs, camera handoffs, projection ownership, pointer look, mouse-aimed action, mouse-look, the screen-direction contract for hand-rolled steering/pan/look input signs, floating origins | `$genex-threejs-camera-direction` |
37
37
  | on-foot player movement: walk/run/jump/crouch, third-person character, slopes, stairs, moving platforms, the player's body loader, directional locomotion, transitions, action motion | `$genex-threejs-character-controller` |
38
+ | **attacking, casting, aiming or reloading WHILE moving** — any action the legs must keep running under; a weapon carry stance over stock locomotion; a wind-up the character holds while walking | `$genex-threejs-character-controller` (`references/animations.md`, upper-body layering) |
39
+ | dash, dodge, roll, blink, backstep, a lunging attack — any burst that moves the character itself | `$genex-threejs-character-controller` (`references/tuning-and-presets.md`, dash recipe) |
38
40
  | the game's own generated character—the player's body wherever a human body appears—or Meshy animation coverage beyond the stock pack: reference-informed A-pose concepts, exact action IDs, same-rig adapter | `$genex-ai-character` + `$genex-threejs-character-controller` |
39
41
  | a character/enemy needs motion the catalog lacks—a signature move, boss telegraph, death, full 8-way set, or the player's footage; free plan before spend | `$genex-ai-character` motion section + `references/motion-generation.md` |
40
42
  | remote player bodies in multiplayer—never hand-built primitives: the game's generated character when it has one, otherwise the player's `p.avatarUrl` VRM | `$genex-threejs-multiplayer` + `$genex-threejs-character-controller` |
@@ -281,8 +281,17 @@ cost. The installed Meshy manifest supplies exact-signature clips, directional
281
281
  slots, fallbacks, and cadence data. UAL clips still land in
282
282
  `public/assets/anims/` and are additive. Read
283
283
  [references/animations.md](references/animations.md) for the tag catalog with
284
- genre hints, `playOneShot` options, overrides, foot IK, and remote-player
285
- animation.
284
+ genre hints, `playOneShot` options, **upper-body layering** (attack, cast or
285
+ shoot while the legs keep running — `playUpperBody` / `holdUpperBody`),
286
+ overrides, foot IK, and remote-player animation.
287
+
288
+ ## Dash, dodge, roll
289
+
290
+ Any burst that moves the character itself: read the dash recipe in
291
+ [references/tuning-and-presets.md](references/tuning-and-presets.md). The short
292
+ version is that it uses `setActionMotion` and **never** `applyImpulse` — the
293
+ controller's movement impulse is applied above the centre of mass to make the
294
+ run lean, so it is a torque, and it pitches a *moving* character over backwards.
286
295
 
287
296
  For Meshy characters, do not repair hands, arms, or legs at runtime. Meshy limb
288
297
  rotations play unchanged. Never freeze hand tracks or apply post-mixer arm,
@@ -371,7 +380,36 @@ players; their bodies don't have to.
371
380
 
372
381
  The loader shares one parsed base across every remote (N remotes ≈ 1 body of
373
382
  GPU memory) and `remote.dispose()` detaches the clone without touching those
374
- shared resources. Move it with the interpolator from
383
+ shared resources.
384
+
385
+ **Tinting a clone (team colours, per-player robes) has two traps, and both are
386
+ silent.** Clones share MATERIALS, not just geometry, so `mesh.material.color.set()`
387
+ on one repaints every player on both teams. And the fix has its own trap:
388
+
389
+ ```ts
390
+ model.traverse((o) => {
391
+ const mesh = o as THREE.Mesh;
392
+ if (!mesh.isMesh) return;
393
+ const src = mesh.material;
394
+ const list = Array.isArray(src) ? src : [src];
395
+ const tinted = list.map((m) => {
396
+ const c = (m as THREE.MeshStandardMaterial).clone(); // clone = safe to mutate
397
+ c.color.lerp(teamColor, 0.42); // lerp, don't replace
398
+ return c;
399
+ });
400
+ // UNWRAP. `.map()` returns an array whatever went in, and an array material
401
+ // sends three down its multi-material path — one draw call per
402
+ // `geometry.groups` entry. A single-material body has NO groups, so the loop
403
+ // body never runs: the mesh is in the scene, visible, lit, in frustum, and
404
+ // draws NOTHING. `renderer.info.render.calls === 0` with everything else
405
+ // looking correct is the only signature of this bug.
406
+ mesh.material = Array.isArray(src) ? tinted : tinted[0]!;
407
+ });
408
+ ```
409
+
410
+ `controllers/character/first-person.ts` does the same array-aware clone for a
411
+ different reason — copy its shape if you need to save and restore the original.
412
+ Dispose the CLONED materials when the player leaves; never the shared textures. Move it with the interpolator from
375
413
  `$genex-threejs-multiplayer`, and **never** create a rigid body, a
376
414
  `CharacterController`, or any physics for it. Simulating remote players'
377
415
  physics locally guarantees divergence — every client would compute a different
@@ -270,9 +270,12 @@ addEventListener("pointerdown", () => {
270
270
  `options`: `fadeIn` (default 0.1 s), `timeScale`, `clamp` (hold the final pose —
271
271
  for deaths), `interruptible` (grounded movement cancels the clip), `onDone`.
272
272
 
273
- **Every one-shot is FULL-BODY** (there is no upper-body layering), so it freezes
274
- the legs — a moving character SLIDES across the ground in a frozen pose for the
275
- clip's whole length. Two rules keep that from ever being visible:
273
+ **Every one-shot is FULL-BODY**, so it freezes the legs a moving character
274
+ SLIDES across the ground in a frozen pose for the clip's whole length. For a
275
+ swing, a cast or a shot that should land WHILE the character keeps running, use
276
+ `playUpperBody` instead (next section). For everything that genuinely should
277
+ stop the legs — a dodge roll, a death, a knockdown — a one-shot is correct, and
278
+ two rules keep the freeze from ever being visible:
276
279
 
277
280
  - **Reactions and gestures a player can walk out of get `interruptible: true`**
278
281
  (hit flinches, casts, taunts, celebrations). Movement intent releases the clip
@@ -339,6 +342,94 @@ are in-place. Meshy planar actions remain anchored until their manifest
339
342
  explicitly validates the extracted trajectory; in every case the physics
340
343
  controller owns world translation.
341
344
 
345
+ ## Upper-body layering — attack while moving
346
+
347
+ `playUpperBody(clipName, options)` plays a clip on the torso, arms and head only,
348
+ over whatever the legs are already doing. This is how a swing lands while the
349
+ character is still running, and how a weapon carry stance rides on top of stock
350
+ unarmed locomotion.
351
+
352
+ ```ts
353
+ // Layers when the rig allows it, plays FULL-BODY when it does not. Either way
354
+ // the clip plays — you do not write the fallback.
355
+ anims.playUpperBody("Sword_Slash");
356
+
357
+ // A carry stance that persists under walk/run until the weapon is stowed:
358
+ anims.holdUpperBody("blade_carry");
359
+ anims.releaseUpperHold(); // on stow / swap
360
+
361
+ // A wind-up the character holds while still walking:
362
+ anims.playUpperBody("Draw_Bow", { clamp: true });
363
+ anims.clearUpperBody(); // on release / cancel — releases either form
364
+ ```
365
+
366
+ **`playUpperBody` never silently does nothing.** On a rig the mask cannot split
367
+ it falls back to a full-body `playOneShot` — legs frozen, which is how every
368
+ one-shot behaved before layering existed. `true` therefore means "the clip is
369
+ playing", not "the clip is layered"; read `anims.upperBodyActive` if you need to
370
+ know which. It returns false only when the clip is not installed, the same
371
+ reason `playOneShot` does.
372
+
373
+ `anims.canLayerUpperBody` tells you up front whether this rig can split, which
374
+ is worth knowing when you are DESIGNING around layering (a combat system built
375
+ on attack-while-running plays very differently if every swing roots the
376
+ character). It is not something you have to check before each call.
377
+
378
+ **`holdUpperBody` has no fallback and does return false on an unsplittable
379
+ rig** — deliberately. A one-shot played full-body is merely uglier, but a
380
+ persistent carry stance played full-body would replace locomotion outright and
381
+ the character would stop walking for as long as the weapon is drawn. No stance
382
+ is the correct degradation.
383
+
384
+ Why the stance matters: an immutable controller pack owns `walk.forward` and
385
+ `run.forward` and cannot be replaced, but nothing stops a stance from posing the
386
+ torso above those legs. That is what makes a visible weapon affordable without
387
+ regenerating a whole armed locomotion set.
388
+
389
+ ### Three facts that shape the whole thing
390
+
391
+ three.js has **no bone mask**. The only mechanism that works is filtering a
392
+ clip's tracks to one region and playing the result as an ordinary action on the
393
+ SAME mixer. Each of these costs a day if rediscovered the hard way:
394
+
395
+ 1. **`PropertyMixer.accumulate` NORMALIZES.** Two normal-mode actions at weight 1
396
+ on a shared bone give a 50/50 slerp, not "last one wins". The layer's share is
397
+ `w / (1 + w)` — so weight 4 is 80% (visibly limp; the run bleeds through the
398
+ swing), 12 is 92%, 19 is 95%. The default 12 reads as an override while
399
+ leaving the torso enough base to keep breathing with the gait. Raise it via
400
+ `{ weight }` if a swing looks half-hearted.
401
+ 2. **Never `crossFadeFrom` between the base and a layer.** `apply()` mixes every
402
+ bone toward its ORIGINAL BIND POSE at `1 - cumulativeWeight`, so fading the
403
+ full-body base out drags the torso toward T-pose. The base stays at weight 1
404
+ forever; only the layer fades.
405
+ 3. **No additive blending.** `AnimationUtils.makeClipAdditive` MUTATES the clip
406
+ in place, and the character loader shares clip objects between every character
407
+ on the same rig — one additive conversion corrupts the whole roster.
408
+
409
+ ### The mask is derived structurally, never by name
410
+
411
+ `deriveUpperBody(model)` walks the pelvis' children and keeps the subtree that
412
+ owns the head and arms and no legs. It does NOT key off bone names, because
413
+ generated rigs use Mixamo-ish names but can INVERT the spine convention — on one
414
+ shipped rig the hips' child is `Spine02` and the bone actually called `Spine` is
415
+ the TOPMOST one, carrying the shoulders and neck. Anything matching "Spine" vs
416
+ "Spine1" picks the wrong end and masks the legs instead of the torso.
417
+
418
+ It then VERIFIES before trusting the result — no leg bone inside the mask, at
419
+ least one leg bone outside it, and a sane size ratio — and returns null rather
420
+ than a guess. A mask that quietly swallowed a leg would make the character slide
421
+ with frozen feet, which is worse than never layering at all.
422
+
423
+ ### Gotcha: one clip used as both a stance and a one-shot
424
+
425
+ Filtered actions are cached per clip name, so `playUpperBody` and
426
+ `holdUpperBody` on the SAME clip name share one `AnimationAction` — and
427
+ `playUpperBody` mutates it to `LoopOnce` + `clampWhenFinished`. The controller
428
+ restores the loop flags when the stance comes back, so this works; it is called
429
+ out because the failure mode is invisible from the outside (the carry pose plays
430
+ once and freezes for the rest of the match) and it only appears once a game
431
+ reuses one clip for both jobs.
432
+
342
433
  ## Binding arbitrary clip names on a compatible rig
343
434
 
344
435
  `buildClipMap(clips, overrides?)` resolves each state in priority order:
@@ -69,7 +69,7 @@ tip-overs mean the `autoBalance*` pair is too soft.
69
69
  | "crouch should be hold, not toggle" | `crouchMode: "hold"` (default `"toggle"` — C flips it) |
70
70
  | "no control in the air" | raise `airDragFactor` (default 0.1) |
71
71
  | "falls too fast at terminal velocity" | `fallingMaxVel` (default 20 m/s) |
72
- | "leans too much when running" | lower `moveImpulsePointOffset` (default 0.5; 0 = no lean) |
72
+ | "leans too much when running" | lower `moveImpulsePointOffset` (default 0.5; 0 = no lean). That same offset is why a dash must never use an impulse — see the dash recipe below |
73
73
  | "wobbles / tips over" | raise `autoBalanceSpringK` + `autoBalanceDampingC` |
74
74
  | "turns to face direction too slowly" | raise `autoBalanceSpringOnY` |
75
75
  | "grounded flag flickers on stairs / ledges" | raise `rayHitForgiveness` (default 0.28) |
@@ -103,3 +103,75 @@ capsule is a heavier body at the same density.
103
103
  `applyCounterJumpImp`, `applyCounterMoveImp`) defaults to physically-honest
104
104
  and already handles moving/rotating platforms; only touch these for
105
105
  deliberate arcade effects.
106
+
107
+ ## Dash, dodge, roll — burst movement
108
+
109
+ **Never use `applyImpulse` / `applyImpulseAtPoint` on the character body.**
110
+
111
+ The controller applies its normal movement impulse ABOVE the centre of mass, at
112
+ `moveImpulsePointOffset` (default 0.5), because that offset is what produces the
113
+ run lean. An impulse applied off-centre is a force AND a torque. When the
114
+ character is standing still the torque is small; when they are already moving it
115
+ adds to the existing lean and the character pitches over backwards.
116
+
117
+ Measured on a real dash: **60.3° of backward lean while moving, 0° from a
118
+ standing start.** That "only breaks when I'm running" signature is the tell, and
119
+ it sends you hunting the animation instead of the impulse.
120
+
121
+ Use `setActionMotion`, which drives `setLinvel` — velocity, no torque — while
122
+ Rapier keeps gravity, slopes and collisions:
123
+
124
+ ```ts
125
+ // ONE number is yours: how far a dash should carry the character, in metres.
126
+ // Everything else derives from it and from the controller's OWN run speed, so
127
+ // a dash stays in proportion when you retune movement and you are never
128
+ // copying somebody else's game feel. (A dash that reads as a burst is roughly
129
+ // 2-3x run speed; below ~2x it reads as a sprint, above ~4x as a teleport.)
130
+ const DASH_DISTANCE = 5; // metres — YOUR game lives here
131
+ const RUN_SPEED = preset.options.maxRunVel ?? 5;
132
+ const DASH_SPEED = RUN_SPEED * 2.8;
133
+ const DASH_TIME = DASH_DISTANCE / DASH_SPEED;
134
+ const EXIT_SPEED = RUN_SPEED; // see endDash
135
+
136
+ function startDash(): void {
137
+ dashLeft = DASH_TIME;
138
+ // Freeze the direction at the moment of the press: a dash that keeps
139
+ // steering is a strafe, not a dash.
140
+ dashDir.copy(worldMoveDir.lengthSq() > 0 ? worldMoveDir : bodyForward).normalize();
141
+ anims.playUpperBody("Dodge_Right"); // a dodge is full-body; see below
142
+ }
143
+
144
+ function driveDash(dt: number): void {
145
+ // RE-PROJECT EVERY STEP. setActionMotion takes a BODY-LOCAL velocity, and the
146
+ // body rotates during the dash — a direction captured once in local space
147
+ // curves the dash into an arc.
148
+ local.copy(dashDir).applyQuaternion(bodyQuat.clone().invert());
149
+ controller.setActionMotion({ x: local.x * DASH_SPEED, z: local.z * DASH_SPEED });
150
+ dashLeft -= dt;
151
+ if (dashLeft <= 0) endDash();
152
+ }
153
+
154
+ function endDash(): void {
155
+ controller.setActionMotion(null);
156
+ // Clamp the exit velocity to normal run speed. Handing the full burst speed
157
+ // straight back to the controller makes it brake hard, and the brake is an
158
+ // impulse at the same offset — which re-creates the lean you just avoided.
159
+ // Measured: 19.8° of hand-off lean before the clamp, 4.6° after.
160
+ const v = body.linvel();
161
+ const planar = Math.hypot(v.x, v.z);
162
+ if (planar > EXIT_SPEED) {
163
+ const k = EXIT_SPEED / planar;
164
+ body.setLinvel({ x: v.x * k, y: v.y, z: v.z * k }, true);
165
+ }
166
+ }
167
+ ```
168
+
169
+ Two more things that are not optional:
170
+
171
+ - **Suppress steering input for the duration.** Feed the controller no movement
172
+ while `dashLeft > 0`, or its own movement impulse fights the action motion —
173
+ and that impulse is the torque you are avoiding.
174
+ - **A dodge is full-body; an attack is not.** A dodge SHOULD stop the legs doing
175
+ their own thing, so `playOneShot` (or `playUpperBody`, which falls back to it
176
+ on a rig it cannot split) is right. A swing that happens *while* running wants
177
+ the upper-body layer instead — see `references/animations.md`.
@@ -98,6 +98,13 @@ function play(rig: CreatureRig, name: keyof CreatureRig["actions"], fade = 0.18)
98
98
  // instance (THREE.AnimationUtils / SkeletonUtils.clone for shared GLBs).
99
99
  ```
100
100
 
101
+ **An attacking enemy should keep moving.** A rigged creature
102
+ (`npx genex creature`) is a humanoid on the same controller, so its swing can
103
+ land while it advances — `anims.playUpperBody("<attack clip>")` layers the
104
+ attack over whatever the legs are doing instead of freezing them mid-stride.
105
+ See `$genex-threejs-character-controller` (`references/animations.md`). An
106
+ enemy that stops dead to attack reads as a turn-based unit, not a threat.
107
+
101
108
  ## The mechanical floor — ALL enemies, rigged or not
102
109
 
103
110
  ### 1. A collider — the player never walks through a body
@@ -184,7 +184,9 @@ SKILL.md is the assignment story, verbatim. Everything else is Recipe 1/2 mechan
184
184
  round-robin balance, late joiners to the short side, the map in `shared` survives host
185
185
  migration. Do not re-derive teams anywhere else, and never read `mm.matchmaking.teams` (empty
186
186
  on `open`).
187
- - **Everything team-flavored READS the map:** tint/skin by `teams[id]`, spawn each player on their
187
+ - **Everything team-flavored READS the map:** tint/skin by `teams[id]` (the tint recipe is in
188
+ `$genex-threejs-character-controller` — clones share MATERIALS, so the naive version repaints
189
+ both teams, and the naive fix draws zero calls), spawn each player on their
188
190
  team's side (respawn via `me.snap`), gate friendly fire in the hit-test — skip targets where
189
191
  `teams[target] === teams[me]` before applying damage — and frame the HUD ("your team" vs
190
192
  "enemy") from your own entry. A player not yet in the map renders neutral and takes no damage;