1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
|
/**
* 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. 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 };
}
// 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));
|