zen: Phase B — UB swims with a behavior engine (wander, cruise/rest/burst, U-turns)
UB is no longer a static in-place loop. New behavior.js owns locomotion: UB wanders a bounded tank, cruises at a chosen speed, drifts to new depths (nose tilts into it), occasionally rests or darts, and banks through smooth U-turns at the edges — the tail beats faster/slower with speed. All clips are in-place, so the engine drives world position + heading and crossfades between the named clips (idle/cruise/burst/turnL/turnR). Multi-clip GLB built via tools/glb-split/build-clips.mjs (5 clips, 8.7MB — orphaned Take accessors explicitly disposed). aquarium.js reworked: clip crossfade + per-frame behavior apply. Tuner (/zen?debug=1) now exposes scale + Behavior (cruise speed / roam width / roam height / liveliness) + the fins section. Reduced-motion calms speed + liveliness. Still admin-gated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// UB's swim brain. The GLB clips are all in-place, so this owns the LOCOMOTION: it moves
|
||||
// UB around the tank and picks which clip the tank should crossfade to. Calm by design —
|
||||
// UB mostly cruises, drifts up and down to new depths, occasionally rests or darts, and
|
||||
// banks through smooth U-turns when it reaches an edge. Frame-rate-independent easing.
|
||||
//
|
||||
// Heading convention: yaw is rotation about Y; worldForward.x = sin(yaw). yaw = +π/2 faces
|
||||
// +X, +3π/2 faces −X, so a U-turn is just targetYaw += π (UB arcs through facing the back
|
||||
// as sin(yaw) sweeps +1 → 0 → −1, naturally decelerating then reversing).
|
||||
const rand = (a, b) => a + Math.random() * (b - a);
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const damp = (cur, tgt, rate, dt) => cur + (tgt - cur) * (1 - Math.exp(-rate * dt));
|
||||
function shortAngle(a, b) { let d = (b - a) % (Math.PI * 2); if (d > Math.PI) d -= Math.PI * 2; if (d < -Math.PI) d += Math.PI * 2; return d; }
|
||||
|
||||
export function createSwimmer(opts = {}) {
|
||||
const reduced = opts.reduced ?? false;
|
||||
const P = { boundsX: 1.15, boundsY: 0.5, boundsZ: 0.28, baseSpeed: 0.34, liveliness: 1, ...opts };
|
||||
const S = {
|
||||
x: 0, y: 0, z: 0, yaw: Math.PI / 2, targetYaw: Math.PI / 2, roll: 0, pitch: 0,
|
||||
speed: P.baseSpeed, targetSpeed: P.baseSpeed, targetY: 0, targetZ: 0,
|
||||
mode: 'cruise', timer: rand(4, 8), turning: false, turnSign: 1, clip: 'cruise', ts: 1,
|
||||
};
|
||||
|
||||
function pickMode() {
|
||||
const live = P.liveliness * (reduced ? 0.5 : 1);
|
||||
const rest = 0.05 + 0.12 * live, burst = 0.11 * live, r = Math.random();
|
||||
if (r < rest) { S.mode = 'rest'; S.targetSpeed = P.baseSpeed * 0.05; S.timer = rand(2.4, 5); }
|
||||
else if (r < rest + burst) { S.mode = 'burst'; S.targetSpeed = P.baseSpeed * rand(1.9, 2.6); S.timer = rand(0.7, 1.5); }
|
||||
else { S.mode = 'cruise'; S.targetSpeed = P.baseSpeed * rand(0.8, 1.15); S.timer = rand(4, 9); }
|
||||
S.targetY = rand(-P.boundsY, P.boundsY) * 0.85;
|
||||
S.targetZ = rand(-P.boundsZ, P.boundsZ);
|
||||
}
|
||||
pickMode();
|
||||
|
||||
return {
|
||||
state: S,
|
||||
params: P,
|
||||
setParams(n = {}) { Object.assign(P, n); },
|
||||
update(dt) {
|
||||
dt = Math.min(dt, 0.05); // clamp long frame gaps (tab switch)
|
||||
S.timer -= dt;
|
||||
if (S.timer <= 0 && !S.turning) pickMode();
|
||||
|
||||
S.speed = damp(S.speed, S.turning ? P.baseSpeed * 0.5 : S.targetSpeed, 1.6, dt);
|
||||
|
||||
// drift toward a chosen depth/height; nose tilts gently toward vertical motion
|
||||
const yPrev = S.y;
|
||||
S.y = damp(S.y, S.targetY, 0.5, dt);
|
||||
S.z = damp(S.z, S.targetZ, 0.4, dt);
|
||||
S.pitch = damp(S.pitch, clamp((S.y - yPrev) / dt * 0.6 || 0, -0.28, 0.28), 3, dt);
|
||||
|
||||
if (S.turning) {
|
||||
S.yaw += shortAngle(S.yaw, S.targetYaw) * (1 - Math.exp(-3 * dt));
|
||||
S.roll = damp(S.roll, 0.42 * S.turnSign, 4, dt);
|
||||
S.clip = S.turnSign > 0 ? 'turnR' : 'turnL';
|
||||
if (Math.abs(shortAngle(S.yaw, S.targetYaw)) < 0.04) S.turning = false;
|
||||
} else {
|
||||
S.roll = damp(S.roll, 0, 3, dt);
|
||||
const fwd = Math.sin(S.yaw);
|
||||
if ((fwd > 0 && S.x > P.boundsX) || (fwd < 0 && S.x < -P.boundsX)) {
|
||||
S.turning = true; S.turnSign = fwd > 0 ? 1 : -1; S.targetYaw = S.yaw + Math.PI;
|
||||
}
|
||||
S.clip = S.mode === 'rest' ? 'idle' : S.mode === 'burst' ? 'burst' : 'cruise';
|
||||
}
|
||||
|
||||
S.x += S.speed * Math.sin(S.yaw) * dt; // move along heading (arcs through the turn)
|
||||
S.ts = (S.mode === 'rest' && !S.turning) ? 0.55 : clamp(S.speed / P.baseSpeed, 0.5, 2); // tail beats with speed
|
||||
return S;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user