diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/animation.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/animation.js')
| -rw-r--r-- | public/bluebey-studio/src/animation.js | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js new file mode 100644 index 0000000..4446878 --- /dev/null +++ b/public/bluebey-studio/src/animation.js @@ -0,0 +1,161 @@ +/** + * 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)); |
