Files
thejayman77 cf65243e07 zen: rebuild UB locomotion — real 3D steering + motionRoot/visualRig split (Codex)
Not a tuning pass — a locomotion rebuild per Codex's review. Replaces the X-axis shuttle +
scripted 180° U-turn with a proper 3D steering controller (behavior.js): UB seeks wandering
waypoints through the whole tank (XYZ incl. near/far depth passes), eases speed via limited
accel, steers with a rate-limited quaternion toward its velocity, banks into curves, and
softly veers away from walls BEFORE reaching them — no scripted turns.

Architecture: motionRoot (Group) owns world position + heading + bank; the visual rig plays
in-place body clips inside it, so navigation never fights the skeleton and the controller is
the sole heading authority (clips carry no root motion — verified). Fuller clip set exported
(idle/cruise/fast/turn{L,R}{in,loop,out}/eat*, build-clips.mjs). Authored clips play at their
own timing; only cruise cadence scales with speed. Debug panel: cruise speed + roam W/H/DEPTH
+ liveliness, a live readout (mode/clip/speed/turn/pos), and the raw-clip preview.

Next (Codex step 3): needs-driven personality — forage/zoomies/surface/inspect — on this base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:53:35 -04:00

61 lines
3.1 KiB
JavaScript

// Build a multi-clip GLB from the pack's single baked Take: extract a chosen set of
// clips (by frame range from angelfish-clips.json) into SEPARATE named animations, each
// rebased to start at 0, then drop the original 75s Take. This is what the Zen Den's
// behavior engine crossfades between. Usage: node build-clips.mjs in.glb out.glb
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { prune } from '@gltf-transform/functions';
import fs from 'node:fs';
const [, , inPath, outPath] = process.argv;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(inPath);
const root = doc.getRoot();
const buffer = root.listBuffers()[0];
const meta = JSON.parse(fs.readFileSync(new URL('./angelfish-clips.json', import.meta.url))).clips;
// output-clip-name -> source clip in the pack. Full turn SEQUENCES (in→loop→out) so the
// controller can play a real turn; swim intensities for cadence/zoomies; eats for forage.
const MAP = {
idle: 'Idle',
cruise: 'Swim1_norm',
fast: 'Swim2_Fast',
turnLin: 'Turn_L_in', turnLloop: 'Turn_L_loop', turnLout: 'Turn_L_out',
turnRin: 'Turn_R_in', turnRloop: 'Turn_R_loop', turnRout: 'Turn_R_out',
eatswim: 'Eat_Swim', eatground: 'Eat_Ground',
};
const src = root.listAnimations()[0];
const srcChannels = src.listChannels();
const EPS = 1e-4;
// The original Take's time/value accessors — captured now, disposed after we've copied
// the windows we need (new clips create their own fresh accessors, so these become dead).
const oldAccessors = new Set();
for (const sm of src.listSamplers()) { oldAccessors.add(sm.getInput()); oldAccessors.add(sm.getOutput()); }
for (const [outName, srcName] of Object.entries(MAP)) {
const [s0, s1] = meta[srcName].seconds;
const anim = doc.createAnimation(outName);
for (const ch of srcChannels) {
const sm = ch.getSampler();
const times = sm.getInput().getArray();
let a = 0; while (a < times.length - 1 && times[a] < s0 - EPS) a++;
let b = times.length - 1; while (b > a && times[b] > s1 + EPS) b--;
const base = times[a];
const nTimes = Float32Array.from(times.slice(a, b + 1), (x) => x - base);
const out = sm.getOutput();
const c = out.getElementSize();
const nOut = out.getArray().slice(a * c, (b + 1) * c);
const inAcc = doc.createAccessor().setType('SCALAR').setArray(nTimes).setBuffer(buffer);
const outAcc = doc.createAccessor().setType(out.getType()).setArray(nOut).setBuffer(buffer);
const ns = doc.createAnimationSampler().setInput(inAcc).setOutput(outAcc).setInterpolation(sm.getInterpolation());
anim.addSampler(ns);
anim.addChannel(doc.createAnimationChannel().setTargetNode(ch.getTargetNode()).setTargetPath(ch.getTargetPath()).setSampler(ns));
}
}
src.dispose(); // remove the original 75s Take
for (const acc of oldAccessors) acc.dispose(); // free its ~10MB of keyframe data
await doc.transform(prune());
await io.write(outPath, doc);
console.log('clips:', root.listAnimations().map((a) => `${a.getName()}(${a.listChannels().length}ch)`).join(', '));