aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/animation.js
diff options
context:
space:
mode:
Diffstat (limited to 'bluebey-studio/src/animation.js')
-rw-r--r--bluebey-studio/src/animation.js204
1 files changed, 204 insertions, 0 deletions
diff --git a/bluebey-studio/src/animation.js b/bluebey-studio/src/animation.js
new file mode 100644
index 0000000..e569d12
--- /dev/null
+++ b/bluebey-studio/src/animation.js
@@ -0,0 +1,204 @@
+/**
+ * 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;
+ this.snotScale = 1;
+ }
+
+ reset() {
+ this.eyeOpen = 1;
+ this.look = { x: 0, y: 0 };
+ this.blinkPhase = -1;
+ this.blinkTimer = 2;
+ this.time = 0;
+ this.snotScale = 1;
+ }
+
+ 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);
+
+ // 鼻ちょうちん: while sleeping, the bubble swells on the out-breath and shrinks
+ // again. It is a real object (see src/main.js), so the motion is published here
+ // as a plain multiplier the render loop applies to the bubble's scale.
+ this.snotScale = (this.state.anim.mode ?? 'off') === 'sleep'
+ ? 0.45 + 0.55 * (0.5 - 0.5 * Math.cos(this.time * 1.3))
+ : 1;
+
+ this.updateBlink(step);
+ this.updateLook(step);
+ }
+
+ updateBlink(dt) {
+ if ((this.state.anim.mode ?? 'off') === 'sleep') {
+ // Asleep: the eyes stay shut, whatever the blink setting says.
+ this.eyeOpen = 0;
+ this.blinkPhase = -1;
+ return;
+ }
+ 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. The legs swing on the OPPOSITE phase to the
+ // arms, so when a hand comes forward it is the opposite foot that steps out
+ // (the same value as the arms put them in step, i.e. a "same-side" waddle).
+ 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 };
+ }
+
+ if (mode === 'wave') {
+ // One flipper is held up and sweeps back and forth, with the hand lagging a
+ // little behind it; the body and the other flipper rock along gently. The
+ // lift is kept modest: raising the arm much further folds the flipper over
+ // the top of the head, where its inner edge cuts into the face, so the X
+ // here (and its swing) stop well short of that. The Z swing is what reads
+ // as the wave, and it is biased slightly *back* for the same reason.
+ const wave = Math.sin(t * 3.2);
+ add('arm.l', 66 + wave * 8, 0, 6 + wave * 10);
+ add('hand.l', 0, 0, Math.sin(t * 3.2 + 0.6) * 14);
+ add('arm.r', 0, 0, Math.sin(t * 1.6 + 1) * 4);
+ add('master', Math.sin(t * 1.6) * 1.2, 0, Math.sin(t * 1.6 + 0.4) * 3);
+ return { bones, root };
+ }
+
+ if (mode === 'sleep') {
+ // Slow breathing: the body rises and settles, the flippers drift, and the
+ // bubble swells and shrinks (that swell is `snotScale`, read by the loop).
+ const breath = 0.5 - 0.5 * Math.cos(t * 1.3);
+ add('master', Math.sin(t * 0.65) * 1.1, 0, 0);
+ add('arm.l', 0, 0, Math.sin(t * 0.9) * 2.5);
+ add('arm.r', 0, 0, -Math.sin(t * 0.9) * 2.5);
+ root[1] += breath * 0.03;
+ 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));