@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 +1 -1
- package/templates/controllers/character/character-animations.ts +508 -0
- package/templates/controllers/character/character-controller.ts +30 -2
- package/templates/skills/genex-ai-character/SKILL.md +6 -0
- package/templates/skills/genex-game-director/references/routing-map.md +2 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +41 -3
- package/templates/skills/genex-threejs-character-controller/references/animations.md +94 -3
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +73 -1
- package/templates/skills/genex-threejs-creatures/SKILL.md +7 -0
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
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
|
|
346
|
-
private
|
|
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,
|
|
285
|
-
|
|
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.
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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:
|
package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md
CHANGED
|
@@ -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]
|
|
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;
|