diff --git a/frontend/src/lib/zen/aquarium.js b/frontend/src/lib/zen/aquarium.js index e29d3ec..fb131bc 100644 --- a/frontend/src/lib/zen/aquarium.js +++ b/frontend/src/lib/zen/aquarium.js @@ -15,25 +15,26 @@ // /zen?debug=1 — tuning a blind WebGL fish through redeploys was the slow path. import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { createSwimmer } from './behavior.js'; const MODEL_URL = '/models/ub-angelfish.glb'; -// UB is now a marine angelfish (Queen): ONE mesh, TWO materials (…_body, …_fins — fins -// covers the caudal tail too), and a single trimmed loop (Swim1_norm — a ~2.5s in-place -// swim cycle, zero root drift, so it undulates without traveling). Body renders OPAQUE + -// single-sided; fins default to OPAQUE alpha-tested (clean silhouette, no blend bleed — -// the koi's lesson) but can go translucent. All live-tunable at /zen?debug=1. +// UB is the Queen angelfish: ONE mesh, TWO materials (…_body opaque single-sided; …_fins +// opaque alpha-tested), and a multi-clip GLB (idle / cruise / burst / turnL / turnR). A +// behavior engine (behavior.js) owns locomotion — UB wanders, cruises, drifts, rests, +// darts, and banks through U-turns; the loop applies its position/heading and crossfades +// to the clip it asks for. Render + behavior are live-tunable at /zen?debug=1. export const DEFAULTS = { - yaw: Math.PI / 2, // ub.rotation.y — side-on-ish (tune live; head faces ±Z) - pitch: 0, // ub.rotation.x — nose up/down - scale: 1.2, // multiplier on the auto-fit size (a touch bigger = more presence) - speed: 0.7, // swim playback rate — <1 = calmer glide + scale: 1.0, // multiplier on the auto-fit size + baseSpeed: 0.34, // cruise speed (the tail beat follows it) + boundsX: 1.15, // half-width UB roams before turning back + boundsY: 0.5, // half-height of the vertical drift + liveliness: 1.0, // 0 = placid cruiser · higher = more rests + darts finTranslucent: false, // false = opaque alpha-tested (coherent); true = blended finSide: 'double', // front | back | double (fins are thin → double reads fuller) finOpacity: 0.9, // only when translucent finAlphaTest: 0.5, // clip the fin-edge alpha paused: false, - frame: 0, // 0..1 scrub position when paused }; const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide }; @@ -108,25 +109,30 @@ export async function createAquarium(canvas, initial = {}) { } } - function applyTransform() { - ub.rotation.set(params.pitch, params.yaw, 0); - ub.scale.setScalar(baseScale * (params.scale ?? 1)); - } - applyMaterials(); - applyTransform(); + ub.scale.setScalar(baseScale * (params.scale ?? 1)); scene.add(ub); - // Animation — keep every action by name so Phase B can crossfade (Idle_swim → - // Eat_Up / Roll). Idle_swim is the base loop. + // Clips by name (idle / cruise / burst / turnL / turnR). The behavior engine asks for + // one each frame; crossfade() blends to it over ~0.35s. const mixer = new THREE.AnimationMixer(ub); const actions = {}; for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip); - const baseClip = mixer.clipAction(gltf.animations[0]); // the trimmed swim loop - baseClip.play(); - const baseDuration = baseClip.getClip().duration || 1; - const applySpeed = () => { mixer.timeScale = (params.speed ?? 0.7) * (reduced ? 0.7 : 1); }; - applySpeed(); // calm glide by default; extra-calm under reduced-motion + let curClip = actions.cruise ? 'cruise' : Object.keys(actions)[0]; + actions[curClip]?.play(); + function crossfade(name) { + if (name === curClip || !actions[name]) return; + actions[name].reset().play(); + actions[curClip]?.crossFadeTo(actions[name], 0.35, false); + curClip = name; + } + + // The swim brain — owns UB's position + heading + which clip to show. + const swimmer = createSwimmer({ + reduced, + baseSpeed: params.baseSpeed * (reduced ? 0.7 : 1), + boundsX: params.boundsX, boundsY: params.boundsY, liveliness: params.liveliness, + }); resize(); // The canvas lives in a responsive container; a ResizeObserver catches layout @@ -139,23 +145,29 @@ export async function createAquarium(canvas, initial = {}) { const clock = new THREE.Clock(); renderer.setAnimationLoop(() => { const dt = clock.getDelta(); - if (params.paused) { - mixer.setTime(params.frame * baseDuration); // scrub to a frozen frame - } else { + if (!params.paused) { + const s = swimmer.update(dt); + ub.position.set(s.x, s.y, s.z); + ub.rotation.set(s.pitch, s.yaw, s.roll); // heading + bank + nose tilt from the brain + crossfade(s.clip); + mixer.timeScale = s.ts; // tail beats with swim speed mixer.update(dt); } renderer.render(scene, camera); }); return { - // exposed for Phase B tuning + the /zen?debug=1 panel - ub, actions, mixer, scene, camera, params, baseDuration, - // live setter: merge new values, re-apply materials + transform + // exposed for tuning + the /zen?debug=1 panel + ub, actions, mixer, swimmer, scene, camera, params, + // live setter: merge new values, re-apply materials + scale + behavior params setParams(next = {}) { Object.assign(params, next); applyMaterials(); - applyTransform(); - applySpeed(); + ub.scale.setScalar(baseScale * (params.scale ?? 1)); + swimmer.setParams({ + baseSpeed: params.baseSpeed * (reduced ? 0.7 : 1), + boundsX: params.boundsX, boundsY: params.boundsY, liveliness: params.liveliness, + }); return { ...params }; }, getParams() { return { ...params }; }, diff --git a/frontend/src/lib/zen/behavior.js b/frontend/src/lib/zen/behavior.js new file mode 100644 index 0000000..4e50b28 --- /dev/null +++ b/frontend/src/lib/zen/behavior.js @@ -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; + }, + }; +} diff --git a/frontend/src/routes/zen/+page.svelte b/frontend/src/routes/zen/+page.svelte index f8f9300..f76d6bf 100644 --- a/frontend/src/routes/zen/+page.svelte +++ b/frontend/src/routes/zen/+page.svelte @@ -89,14 +89,19 @@