/** * The "living" layer: idle body motion, blinking and idle eye drift. * * These never touch the saved state. `pose()` returns the base pose with the * idle offsets added, and `eyeOpen`/`look` are multipliers the caller merges * into the face parameters, so turning the animation off restores exactly the * pose and expression the user had before. */ export class Animator { constructor({ state }) { this.state = state; this.time = 0; this.blinkPhase = -1; this.blinkDuration = 0.17; this.blinkTimer = 2 + Math.random() * 1.5; this.eyeOpen = 1; this.look = { x: 0, y: 0 }; this.idleWeight = 0; } reset() { this.eyeOpen = 1; this.look = { x: 0, y: 0 }; this.blinkPhase = -1; this.blinkTimer = 2; this.time = 0; } update(dt) { const speed = clamp(this.state.anim.speed ?? 1, 0.1, 3); const step = Math.min(dt, 0.1) * speed; this.time += step; const moving = (this.state.anim.mode ?? 'off') !== 'off'; this.idleWeight += ((moving ? 1 : 0) - this.idleWeight) * Math.min(1, step * 4); this.updateBlink(step); this.updateLook(step); } updateBlink(dt) { if (!this.state.anim.blink) { this.eyeOpen = 1; this.blinkPhase = -1; return; } if (this.blinkPhase >= 0) { this.blinkPhase += dt / this.blinkDuration; if (this.blinkPhase >= 1) { this.blinkPhase = -1; this.eyeOpen = 1; const base = Math.max(0.5, this.state.anim.blinkInterval ?? 3.4); // Blink again sooner sometimes, so it does not look metronomic. this.blinkTimer = base * (0.55 + Math.random()); if (Math.random() < 0.22) this.blinkTimer *= 0.35; // occasional double blink } else { this.eyeOpen = 1 - Math.pow(Math.sin(Math.PI * this.blinkPhase), 0.8); } return; } this.blinkTimer -= dt; if (this.blinkTimer <= 0) this.blinkPhase = 0; } updateLook(dt) { if (!this.state.anim.lookAround) { this.look.x = 0; this.look.y = 0; return; } const t = this.time; this.look.x = Math.sin(t * 0.37) * 0.3 + Math.sin(t * 0.13 + 1.7) * 0.14; this.look.y = Math.sin(t * 0.29 + 0.6) * 0.2; } /** Base pose plus the current movement offsets; `pose` is `{ bones, root }`. */ pose(basePose = {}) { const bones = { ...(basePose.bones ?? {}) }; const root = [...(basePose.root ?? [0, 0, 0])]; const mode = this.state.anim.mode ?? 'off'; if (mode === 'off' || this.idleWeight <= 0.001) return { bones, root }; const w = this.idleWeight; const t = this.time; const add = (name, dx, dy, dz) => { const current = bones[name] ?? [0, 0, 0]; bones[name] = [current[0] + dx * w, current[1] + dy * w, current[2] + dz * w]; }; if (mode === 'walk') { // A waddle for a character with no legs: a step bob, a side-to-side rock // and alternating arms and feet. // // Z is the forward/back swing, and it is mirrored between the sides - so the // SAME value on both arms swings them opposite ways, which is what a walk // wants. X (which the first version used) is the *lift*, so opposite signs // there just raised one flipper and dropped the other one. // `legsupport` pivots at the middle of the body, so the legs take a much // smaller angle than the arms. const phase = Math.sin(t * 2.4); const bob = Math.abs(Math.sin(t * 2.4)); add('master', -2, 0, phase * 4); add('arm.l', 6, 0, phase * 26); add('arm.r', 6, 0, phase * 26); add('hand.l', 0, 0, phase * 10); add('hand.r', 0, 0, phase * 10); add('legsupport.l', 0, 0, phase * 9); add('legsupport.r', 0, 0, phase * 9); root[1] += bob * 0.05; return { bones, root }; } // Default: gentle breathing, as if standing there alive. add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6); add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5); add('arm.r', 0, 0, -Math.sin(t * 1.28 + 0.5) * 4.5); add('hand.l', 0, 0, Math.sin(t * 1.05 + 1) * 3); add('hand.r', 0, 0, -Math.sin(t * 1.05 + 1) * 3); root[1] += Math.sin(t * 1.5) * 0.022; return { bones, root }; } } /** * Records the viewport canvas to a WebM blob while the animation plays. * Chrome and Edge support `MediaRecorder` on `canvas.captureStream()`. */ export function createRecorder(canvas) { if (typeof MediaRecorder === 'undefined' || !canvas.captureStream) return null; const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'] .find((type) => MediaRecorder.isTypeSupported?.(type)); return { canvas, mimeType: mimeType ?? '', recorder: null, chunks: [] }; } export function startRecording(session) { if (!session) return false; const stream = session.canvas.captureStream(30); const recorder = session.mimeType ? new MediaRecorder(stream, { mimeType: session.mimeType, videoBitsPerSecond: 8000000 }) : new MediaRecorder(stream); session.chunks = []; session.recorder = recorder; recorder.ondataavailable = (event) => { if (event.data?.size) session.chunks.push(event.data); }; recorder.start(100); return true; } export function stopRecording(session) { return new Promise((resolve) => { if (!session?.recorder || session.recorder.state === 'inactive') { resolve(null); return; } const { recorder, chunks } = session; recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' })); recorder.stop(); }); } const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));