Files
upbeatBytes/frontend/src/lib/zen/behavior.js
T
thejayman77 d13811319d 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>
2026-07-01 20:38:43 -04:00

71 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
},
};
}