@genex-ai/cli-demo 0.85.0-dev.216 → 0.86.0-dev.217

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.85.0-dev.216",
3
+ "version": "0.86.0-dev.217",
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": {
@@ -324,6 +324,17 @@ export interface PlayOneShotOptions {
324
324
  clamp?: boolean;
325
325
  /** Fired when the clip finishes (skipped if a newer one-shot interrupts it). */
326
326
  onDone?: () => void;
327
+ /**
328
+ * Movement cancels this one-shot. Every one-shot is FULL-BODY (there is no
329
+ * upper-body layering), so it freezes the legs — a moving character SLIDES
330
+ * across the ground in a static pose for the clip's whole length (a 2.9s hit
331
+ * reaction, a 2.7s cast). With `interruptible: true`, grounded movement
332
+ * intent releases the clip via {@link CharacterAnimations.clearOneShot} and
333
+ * locomotion takes over immediately. Use it for reactions and gestures a
334
+ * player can walk out of; leave it off for poses that must hold to their end
335
+ * (a death/knockdown). Default false.
336
+ */
337
+ interruptible?: boolean;
327
338
  }
328
339
 
329
340
  type MutableAnimationStateContext = {
@@ -358,6 +369,9 @@ export class CharacterAnimations {
358
369
  // When true, the active one-shot holds its final pose on finish (clamp:true —
359
370
  // e.g. Death01) instead of crossfading back to locomotion.
360
371
  #oneShotHoldPose = false;
372
+ // When true, grounded movement intent cancels the active one-shot (see
373
+ // PlayOneShotOptions.interruptible).
374
+ #oneShotInterruptible = false;
361
375
 
362
376
  #state: CharacterAnimationState = "IDLE";
363
377
  #prevActionName: string | null;
@@ -398,7 +412,27 @@ export class CharacterAnimations {
398
412
  options: CharacterAnimationsOptions = {}
399
413
  ) {
400
414
  this.#model = model;
401
- this.#clipMap = buildClipMap(clips, options.clipMap);
415
+ // Seed the fuzzy name-matched map from the locomotion profile's own slots
416
+ // (explicit options.clipMap still wins). Simple boolean snapshots (remote
417
+ // players, bots) never reach the profile's directional resolver and fall
418
+ // through to this map — without the seed they bind DIFFERENT clips than the
419
+ // local player ("Running" vs the profile's run.forward), so the same
420
+ // movement renders as two different gaits on two screens. Jump states are
421
+ // deliberately NOT seeded: jump.full would one-shot-clamp as a loop.
422
+ const slots = options.locomotionProfile?.slots ?? {};
423
+ const profileSeed: Partial<Record<CharacterAnimationState, string>> = {};
424
+ const seedPairs: ReadonlyArray<[CharacterAnimationState, string]> = [
425
+ ["IDLE", "idle.default"],
426
+ ["WALK", "walk.forward"],
427
+ ["RUN", "run.forward"],
428
+ ["CROUCH_IDLE", "crouch.idle"],
429
+ ["CROUCH_MOVE", "crouch.forward"],
430
+ ];
431
+ for (const [state, slot] of seedPairs) {
432
+ const clip = slots[slot];
433
+ if (clip) profileSeed[state] = clip;
434
+ }
435
+ this.#clipMap = buildClipMap(clips, { ...profileSeed, ...options.clipMap });
402
436
  this.#resolver = options.resolver ?? resolveAnimationState;
403
437
  this.#onChange = options.onChange;
404
438
  this.#locomotionProfile = options.locomotionProfile;
@@ -542,6 +576,13 @@ export class CharacterAnimations {
542
576
  // Optional in the snapshot (older five-boolean remote-player objects).
543
577
  ctx.crouchActive = snapshot.crouchActive ?? false;
544
578
 
579
+ // Grounded movement cancels an interruptible one-shot — otherwise the
580
+ // frozen full-body pose slides across the ground for the clip's whole
581
+ // length. clearOneShot crossfades locomotion back in with no T-pose frame.
582
+ if (this.#oneShotAction && this.#oneShotInterruptible && ctx.isMoving && ctx.isOnGround) {
583
+ this.clearOneShot();
584
+ }
585
+
545
586
  const next = this.#resolver(ctx);
546
587
  const stateChanged = next !== this.#state;
547
588
  if (stateChanged) this.#state = next;
@@ -550,6 +591,19 @@ export class CharacterAnimations {
550
591
  // (Jump_Start/Jump_Land) finishes and unlocks, the pending loop clip must still fade in
551
592
  // even though the state did not change again. Internal guards make this a no-op otherwise.
552
593
  this.#desiredMotionName = this.#resolveLocomotionClip(snapshot, next) ?? this.#clipMap[next];
594
+ // Rigs whose airborne slots fall back to the SAME clip as jump.start (every
595
+ // Meshy rig: jump.rise/jump.fall → jump.full) never re-enter #applyTransition
596
+ // while airborne — the name never changes — so once the one-shot finishes the
597
+ // character hangs FROZEN in the jump's final frame for the rest of the fall
598
+ // (long drops, knockback launches). Once the held action has finished
599
+ // (paused+clamped), retarget sustained airtime at the idle loop so the limbs
600
+ // keep animating while descending.
601
+ if ((next === "JUMP_IDLE" || next === "JUMP_FALL") && this.#desiredMotionName !== null) {
602
+ const held = this.#actions.get(this.#desiredMotionName);
603
+ if (held && held.paused) {
604
+ this.#desiredMotionName = this.#resolveProfileSlot("idle.default") ?? this.#clipMap.IDLE;
605
+ }
606
+ }
553
607
  // A full-jump profile clip may outlast a very short physics hop. Release
554
608
  // its one-shot lock as soon as the controller lands so idle/walk can
555
609
  // crossfade immediately instead of waiting for the authored clip to end.
@@ -643,6 +697,7 @@ export class CharacterAnimations {
643
697
  // finish and the rig snaps to bind pose for a frame — the T-pose flash.
644
698
  action.clampWhenFinished = true;
645
699
  this.#oneShotHoldPose = options.clamp ?? false;
700
+ this.#oneShotInterruptible = options.interruptible ?? false;
646
701
  action.timeScale = options.timeScale ?? 1;
647
702
  action.reset();
648
703
  if (current && current !== action) {
@@ -660,6 +715,42 @@ export class CharacterAnimations {
660
715
  return true;
661
716
  }
662
717
 
718
+ /**
719
+ * Cancel a one-shot and hand control back to locomotion NOW. The case that
720
+ * REQUIRES it: a `clamp:true` one-shot (death/knockdown, a held wind-up)
721
+ * holds its final frame and keeps the locomotion lock closed FOREVER by
722
+ * design — so a respawn or an aborted action must explicitly release it, or
723
+ * the rig keeps the clamped pose while the body moves around. Safe to call
724
+ * at any time; a no-op when nothing is held.
725
+ *
726
+ * Call it on TRANSITIONS (respawn, revive, action aborted) — never per
727
+ * frame, or it cancels every in-flight reaction clip.
728
+ *
729
+ * @example
730
+ * // on respawn, before teleporting the body:
731
+ * anims.clearOneShot();
732
+ */
733
+ clearOneShot(): void {
734
+ if (this.#disposed) return;
735
+ const finished = this.#oneShotAction;
736
+ this.#oneShotAction = null;
737
+ this.#oneShotOnDone = undefined;
738
+ this.#oneShotHoldPose = false;
739
+ this.#oneShotInterruptible = false;
740
+ this.#canPlayNext = true;
741
+ // Crossfade the current state's loop in FROM the held pose — same path the
742
+ // "finished" handler uses, so there is no unposed/T-pose frame.
743
+ if (finished) this.#recoverFromOneShot(finished);
744
+ }
745
+
746
+ /** Clip name of the currently-playing (or clamped-held) one-shot, or null.
747
+ * Lets a caller cancel a SPECIFIC one-shot (e.g. a held charge wind-up)
748
+ * without clobbering whatever replaced it:
749
+ * `if (anims.currentOneShotName === WINDUP_CLIP) anims.clearOneShot();` */
750
+ get currentOneShotName(): string | null {
751
+ return this.#oneShotAction?.getClip().name ?? null;
752
+ }
753
+
663
754
  /** Stop all actions, uncache clips, remove the mixer's 'finished' listener. */
664
755
  dispose(): void {
665
756
  if (this.#disposed) return;
@@ -766,9 +857,23 @@ export class CharacterAnimations {
766
857
  #releaseStuckLocks(): void {
767
858
  const prevActionName = this.#prevActionName;
768
859
  if (prevActionName === null) return;
860
+ // Match BOTH bindings a jump one-shot can have: the fuzzy clipMap names and
861
+ // the profile slots. On profile rigs the clipMap entries are usually null
862
+ // (no clip is literally named "jump_start"), which made this escape hatch
863
+ // dead code — a stuck jump lock could never self-release. The jump.land
864
+ // slot is ignored when it falls back to idle.default (same guard
865
+ // #applyTransition uses), or an idle prevAction would spuriously match.
866
+ const startName = this.#clipMap.JUMP_START ?? this.#resolveProfileSlot("jump.start");
867
+ const profileLand = this.#resolveProfileSlot("jump.land");
868
+ const landName =
869
+ this.#clipMap.JUMP_LAND ??
870
+ (profileLand !== this.#resolveProfileSlot("idle.default") ? profileLand : null);
871
+ // A manual playOneShot() lock is NOT ours to release — even when its clip
872
+ // happens to share a name with the jump binding (clearOneShot owns that).
873
+ if (this.#oneShotAction) return;
769
874
  if (
770
875
  !this.#canPlayNext &&
771
- prevActionName === this.#clipMap.JUMP_START &&
876
+ prevActionName === startName &&
772
877
  this.#state !== "JUMP_IDLE" &&
773
878
  this.#state !== "JUMP_START"
774
879
  ) {
@@ -776,7 +881,7 @@ export class CharacterAnimations {
776
881
  }
777
882
  if (
778
883
  !this.#canPlayNext &&
779
- prevActionName === this.#clipMap.JUMP_LAND &&
884
+ prevActionName === landName &&
780
885
  this.#state !== "IDLE" &&
781
886
  this.#state !== "JUMP_LAND"
782
887
  ) {
@@ -878,13 +983,19 @@ export class CharacterAnimations {
878
983
  snapshot: CharacterStateSnapshot,
879
984
  state: CharacterAnimationState,
880
985
  ): void {
881
- if (!this.#locomotionProfile || !isAdvancedSnapshot(snapshot) || this.oneShotActive) return;
986
+ // Gate on a numeric moveSpeed rather than the full advanced snapshot:
987
+ // remote players and bots drive with simple boolean snapshots plus an
988
+ // estimated speed (position deltas / their own velocity), and without this
989
+ // they play locomotion at a flat 1.0 while the local player's clips
990
+ // speed-match — visibly different gaits for the same movement.
991
+ const speed = (snapshot as Partial<AdvancedCharacterStateSnapshot>).moveSpeed;
992
+ if (!this.#locomotionProfile || typeof speed !== "number" || this.oneShotActive) return;
882
993
  const band = state === "RUN" ? "run" : state === "CROUCH_MOVE" ? "crouch" : state === "WALK" ? "walk" : null;
883
994
  if (!band || !this.#prevActionName) return;
884
995
  const nominal = this.#locomotionProfile.nominalSpeed?.[band];
885
996
  if (!nominal || nominal <= 0) return;
886
997
  const limits = this.#locomotionProfile.playbackRate ?? { min: 0.75, max: 1.35 };
887
- const rate = THREE.MathUtils.clamp(snapshot.moveSpeed / nominal, limits.min, limits.max);
998
+ const rate = THREE.MathUtils.clamp(speed / nominal, limits.min, limits.max);
888
999
  this.#actions.get(this.#prevActionName)?.setEffectiveTimeScale(rate);
889
1000
  }
890
1001
  }
@@ -257,7 +257,34 @@ addEventListener("pointerdown", () => {
257
257
  ```
258
258
 
259
259
  `options`: `fadeIn` (default 0.1 s), `timeScale`, `clamp` (hold the final pose —
260
- for deaths), `onDone`.
260
+ for deaths), `interruptible` (grounded movement cancels the clip), `onDone`.
261
+
262
+ **Every one-shot is FULL-BODY** (there is no upper-body layering), so it freezes
263
+ the legs — a moving character SLIDES across the ground in a frozen pose for the
264
+ clip's whole length. Two rules keep that from ever being visible:
265
+
266
+ - **Reactions and gestures a player can walk out of get `interruptible: true`**
267
+ (hit flinches, casts, taunts, celebrations). Movement intent releases the clip
268
+ and locomotion takes over the same frame. A 2.9 s hit reaction without it
269
+ locks the player's animation for 2.9 s *per hit taken*.
270
+ - **`clamp: true` holds the lock FOREVER by design** — nothing releases it
271
+ automatically, not even the clip ending. Every clamped one-shot needs a
272
+ guaranteed paired release: call `anims.clearOneShot()` on the transition out
273
+ (respawn, revive, action aborted). Audit every early-return on the path
274
+ between "clamped one-shot started" and "the replacing clip plays" — an abort
275
+ branch that skips the replacement strands the pose and the character slides
276
+ around locked until something else clears it.
277
+
278
+ `anims.clearOneShot()` cancels the active one-shot and crossfades locomotion
279
+ back in with no T-pose frame; it is safe to call when nothing is held. Call it
280
+ on TRANSITIONS only — per-frame calls cancel every in-flight reaction clip. To
281
+ cancel one SPECIFIC one-shot without clobbering whatever replaced it, check
282
+ `anims.currentOneShotName` first:
283
+
284
+ ```ts
285
+ // abort a held charge wind-up, but never a death pose that replaced it:
286
+ if (anims.currentOneShotName === WINDUP_CLIP) anims.clearOneShot();
287
+ ```
261
288
 
262
289
  The 12 core clips are always available; everything else comes from
263
290
  `npx genex controller anims <selectors…>` — selectors are **tags** (install a
@@ -658,6 +658,25 @@ Corollary: **cap top speeds against the network, not just the physics** — an o
658
658
  crossing time should stay above ~2× the smoothing delay (~0.25 s), or receivers are reacting to
659
659
  history no matter how correct the code is.
660
660
 
661
+ **Packed animation-flag bitfields are the sneakiest violation.** Smoothing lerps EVERY numeric
662
+ field — there is no integer exemption — and a lerped bitfield decodes to garbage: with
663
+ `moving=1, running=2, grounded=8`, walking is `f=9` and running is `f=11`, and the interpolation
664
+ passes through `10`, where `10 & moving === 0` — the sprinting opponent renders as *standing
665
+ still*. Worse, the exponential ease approaches an increasing target from below, so truncation
666
+ reads `target - 1` for the whole approach (~1.5 s median before RUN appears; at a vsync-locked
667
+ frame rate it can stick one ULP below the target *forever*). A field-verified failure: a
668
+ playtester swore he was running while his opponent's screen showed him walking — both were right.
669
+ Always drive remote animation from `stateRaw`:
670
+
671
+ ```js
672
+ avatar.updateFromFlags(p.stateRaw.f ?? 0, dt); // flags are DISCRETE — never p.state.f
673
+ ```
674
+
675
+ **Reaction one-shots on remotes fire on EDGES, never per frame.** If you replay a remote's
676
+ death/hit pose from their published `out`/`hp` state, track the previous value and act only on
677
+ the transition — a per-frame `reset()`/`playDown()` restarts or cancels every in-flight reaction
678
+ clip (and re-writing ghost/material flags 60×/s is pure churn).
679
+
661
680
  ## Shared objects (the ball, the NPC) — use `objects`, never `shared`
662
681
 
663
682
  A ball belongs to no player. Put it on `objects`: exactly one client owns it at a time (the SDK +