@heyramzi/ui 6.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMPONENTS.md +2 -2
- package/README.md +6 -1
- package/package.json +8 -2
- package/src/agents/agent-avatar.svelte +639 -0
- package/src/agents/agent-cast.svelte +173 -0
- package/src/agents/demo.svelte +78 -0
- package/src/agents/index.ts +11 -0
- package/src/agents/types.ts +72 -0
- package/{LICENSE → src/agents/vendor/LICENSE} +1 -1
- package/src/agents/vendor/color.ts +155 -0
- package/src/agents/vendor/draw.ts +840 -0
- package/src/agents/vendor/engine.ts +845 -0
- package/src/agents/vendor/mesh.ts +838 -0
- package/src/agents/vendor/plastic.ts +4958 -0
- package/src/agents/vendor/presets.ts +103 -0
- package/src/agents/vendor/shapes.ts +41 -0
- package/src/agents/vendor/ticker.ts +67 -0
- package/src/agents/vendor/types.ts +261 -0
- package/src/agents/vendor/wear.ts +1699 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
// @ts-nocheck WHY: vendored engine, kept as upstream wrote it so a re-sync stays a clean diff. Strict flags in consumers (review) would flag ~870 spots.
|
|
2
|
+
/* The rig. A pose is a handful of numbers — head yaw / pitch / roll, a
|
|
3
|
+
position, squash, how open the eyes are, where they look — and blend
|
|
4
|
+
weights for the three states. A Sim advances a pose through time: each
|
|
5
|
+
state sets targets and wanders around them, runs its own events (a
|
|
6
|
+
flip, a hop, a nod, a blink), and a state change eases from one set of
|
|
7
|
+
targets to the next on a timed curve, so it starts and ends softly. */
|
|
8
|
+
|
|
9
|
+
import type { BotAvatarState, BotAvatarSquashEase } from "./types";
|
|
10
|
+
|
|
11
|
+
export const STATES: BotAvatarState[] = ["default", "working", "sleeping"];
|
|
12
|
+
/* the working state's hop: its period, and the height of the spinning one */
|
|
13
|
+
const HOP_T = 0.68;
|
|
14
|
+
const HOP_SPIN_H = 26;
|
|
15
|
+
/* a flip is that spinning hop on its own: a crouch before it (the squash
|
|
16
|
+
that, in the working state, the previous landing leaves), the hop, and
|
|
17
|
+
the landing's recovery after — measured in hop periods */
|
|
18
|
+
const FLIP_PRE = 0.2;
|
|
19
|
+
const FLIP_POST = 0.66;
|
|
20
|
+
/** The jump's numbers: an idle flip's and a click's. */
|
|
21
|
+
export interface JumpConfig {
|
|
22
|
+
/** how high, in body units (the body is 100 tall) */
|
|
23
|
+
height: number;
|
|
24
|
+
/** seconds in the air */
|
|
25
|
+
time: number;
|
|
26
|
+
/** how much the body stretches in the air, 0–2 */
|
|
27
|
+
stretch: number;
|
|
28
|
+
/** how much it squashes on the ground, before take-off and on landing, 0–2 */
|
|
29
|
+
squash: number;
|
|
30
|
+
/** seconds the landing squash takes, contact to recovered */
|
|
31
|
+
squashTime: number;
|
|
32
|
+
/** the landing squash's shape */
|
|
33
|
+
squashEase: BotAvatarSquashEase;
|
|
34
|
+
/** seconds the body holds its deepest squash on the ground */
|
|
35
|
+
groundTime: number;
|
|
36
|
+
/** how the weight settles through that hold */
|
|
37
|
+
groundEase: BotAvatarSquashEase;
|
|
38
|
+
/** seconds the body takes to rise from its deepest squash back to shape */
|
|
39
|
+
riseTime: number;
|
|
40
|
+
/** how it rises */
|
|
41
|
+
riseEase: BotAvatarSquashEase;
|
|
42
|
+
/** seconds a click's jump takes for its landing squash */
|
|
43
|
+
clickSquashTime: number;
|
|
44
|
+
/** whole turns in the air */
|
|
45
|
+
spin: number;
|
|
46
|
+
/** degrees of lean into it */
|
|
47
|
+
lean: number;
|
|
48
|
+
/** seconds between idle jumps, roughly (±40 %); 0 for none */
|
|
49
|
+
every: number;
|
|
50
|
+
/** when the landing squash begins: seconds before (negative) or after
|
|
51
|
+
touch-down; 0 is the moment of contact */
|
|
52
|
+
land: number;
|
|
53
|
+
}
|
|
54
|
+
export const JUMP_DEFAULTS: JumpConfig = {
|
|
55
|
+
height: HOP_SPIN_H,
|
|
56
|
+
time: HOP_T,
|
|
57
|
+
stretch: 1,
|
|
58
|
+
squash: 1.15,
|
|
59
|
+
squashTime: 0.37,
|
|
60
|
+
squashEase: "pulse",
|
|
61
|
+
groundTime: 0.11,
|
|
62
|
+
groundEase: "pulse",
|
|
63
|
+
riseTime: 0.33,
|
|
64
|
+
riseEase: "pulse",
|
|
65
|
+
clickSquashTime: 0.24,
|
|
66
|
+
spin: 1,
|
|
67
|
+
lean: 6,
|
|
68
|
+
every: 8,
|
|
69
|
+
land: 0,
|
|
70
|
+
};
|
|
71
|
+
/* a click's jump crouches for its squash time before take-off and lands
|
|
72
|
+
with a squash of the same length; an idle jump's crouch is the working
|
|
73
|
+
hop's short one */
|
|
74
|
+
const flipPre = (j: JumpConfig, poked: boolean) => (poked ? j.clickSquashTime : FLIP_PRE * j.time);
|
|
75
|
+
const flipDuration = (j: JumpConfig, poked: boolean) =>
|
|
76
|
+
flipPre(j, poked) +
|
|
77
|
+
j.time +
|
|
78
|
+
SQUASH_PEAK[j.squashEase] * (poked ? j.clickSquashTime : j.squashTime) +
|
|
79
|
+
Math.max(0, j.groundTime) +
|
|
80
|
+
j.riseTime +
|
|
81
|
+
Math.max(0, j.land) +
|
|
82
|
+
0.05;
|
|
83
|
+
/* where in its time each easing reaches the deepest squash */
|
|
84
|
+
const SQUASH_PEAK: Record<BotAvatarSquashEase, number> = {
|
|
85
|
+
sharp: 0,
|
|
86
|
+
pulse: 2 / 7,
|
|
87
|
+
soft: 0.5,
|
|
88
|
+
bouncy: 0.144,
|
|
89
|
+
};
|
|
90
|
+
/* the rise: how the body comes back from its deepest squash to its own
|
|
91
|
+
shape, 1 → 0 over the rise time. `sharp` lets go at once and eases in
|
|
92
|
+
to rest, `pulse` leaves quickly with a long settle, `soft` eases out of
|
|
93
|
+
the squash and into rest, `bouncy` passes rest into a slight stretch
|
|
94
|
+
and settles back. */
|
|
95
|
+
const risePulse = (v: number, ease: BotAvatarSquashEase) => {
|
|
96
|
+
if (v <= 0) return 1;
|
|
97
|
+
if (v >= 1) return 0;
|
|
98
|
+
switch (ease) {
|
|
99
|
+
case "sharp":
|
|
100
|
+
return (1 - v) * (1 - v);
|
|
101
|
+
case "soft":
|
|
102
|
+
return 0.5 + 0.5 * Math.cos(Math.PI * v);
|
|
103
|
+
case "bouncy":
|
|
104
|
+
return Math.exp(-3.2 * v) * Math.cos(5.4 * v) - v * v * v * 0.026;
|
|
105
|
+
default: {
|
|
106
|
+
const k = 4.2 * v;
|
|
107
|
+
return (1 + k) * Math.exp(-k) - v * v * v * 0.078;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/* the weight settling through the hold, as a gain on the deepest squash:
|
|
113
|
+
the same four curves as the landing squash, pressing up to a quarter
|
|
114
|
+
deeper and back to the held depth, so the hold's ends never step */
|
|
115
|
+
const groundShape = (v: number, ease: BotAvatarSquashEase) => 1 + 0.25 * squashPulse(v, ease);
|
|
116
|
+
/* the landing squash over its time, 0–1 → its depth, 0–1 at the peak:
|
|
117
|
+
sharp is all there at contact and eases off; pulse presses quickly and
|
|
118
|
+
recovers without a wobble (a critically damped spring under a short
|
|
119
|
+
push); soft eases in and out; bouncy overshoots into a stretch and
|
|
120
|
+
settles. Each is faded out over the last part so it ends at nothing. */
|
|
121
|
+
const squashPulse = (u: number, ease: BotAvatarSquashEase) => {
|
|
122
|
+
if (u <= 0 || u >= 1) return 0;
|
|
123
|
+
let x: number;
|
|
124
|
+
switch (ease) {
|
|
125
|
+
case "sharp":
|
|
126
|
+
x = (1 - u) * (1 - u);
|
|
127
|
+
break;
|
|
128
|
+
case "soft":
|
|
129
|
+
x = Math.sin(Math.PI * u) ** 2;
|
|
130
|
+
break;
|
|
131
|
+
case "bouncy":
|
|
132
|
+
x = (Math.exp(-3.15 * u) * Math.sin(8.43 * u)) / 0.596;
|
|
133
|
+
break;
|
|
134
|
+
default: {
|
|
135
|
+
const k = 7 * u;
|
|
136
|
+
x = (k * k * Math.exp(2 - k)) / 4;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const tail = u > 0.85 ? 1 - (u - 0.85) / 0.15 : 1;
|
|
140
|
+
return x * tail * tail * (3 - 2 * tail);
|
|
141
|
+
};
|
|
142
|
+
/* the hop's shaping: squash on the ground, stretch at the top */
|
|
143
|
+
const hopSquash = (a: number) =>
|
|
144
|
+
Math.exp(-Math.pow(Math.min(Math.abs(a), Math.abs(a - 1)) / 0.11, 2));
|
|
145
|
+
|
|
146
|
+
export interface Pose {
|
|
147
|
+
/** radians; yaw > 0 turns the face to the viewer's right, pitch > 0 looks up */
|
|
148
|
+
yaw: number;
|
|
149
|
+
pitch: number;
|
|
150
|
+
roll: number;
|
|
151
|
+
/** body-box units (the 100×100 design space) */
|
|
152
|
+
x: number;
|
|
153
|
+
y: number;
|
|
154
|
+
sx: number;
|
|
155
|
+
sy: number;
|
|
156
|
+
/** 0 shut … 1 open, before blinks */
|
|
157
|
+
eyeOpen: number;
|
|
158
|
+
/** how far each lid is down right now, 0 … 1 */
|
|
159
|
+
blinkL: number;
|
|
160
|
+
blinkR: number;
|
|
161
|
+
lookX: number;
|
|
162
|
+
lookY: number;
|
|
163
|
+
/** the breathing cycle, −1 … 1 */
|
|
164
|
+
breath: number;
|
|
165
|
+
/** working only: how far the eyes have closed into a laugh, 0 … 1 */
|
|
166
|
+
laugh: number;
|
|
167
|
+
/** the cartoon whirl round a spinning body: strength 0 … 1, and where
|
|
168
|
+
its head is, in radians round the ring */
|
|
169
|
+
whirl: number;
|
|
170
|
+
whirlAngle: number;
|
|
171
|
+
/** blend weights: default, working, sleeping — they sum to 1 */
|
|
172
|
+
w: [number, number, number];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const DEG = Math.PI / 180;
|
|
176
|
+
const TAU = Math.PI * 2;
|
|
177
|
+
/* how long a state change takes: dozing off is slow, waking a little
|
|
178
|
+
quicker, the rest brisk */
|
|
179
|
+
/* How long a switch takes. Settling down takes longer than getting to
|
|
180
|
+
work: coming back to idle the body has to let go of the hops, so it
|
|
181
|
+
eases out over more than a second. */
|
|
182
|
+
const SWITCH_TO: Record<BotAvatarState, number> = { default: 1.2, working: 0.7, sleeping: 1.4 };
|
|
183
|
+
const SWITCH_FROM_SLEEP = 1;
|
|
184
|
+
|
|
185
|
+
/* Deterministic per-instance randomness (mulberry32). */
|
|
186
|
+
function rng(seed: number): () => number {
|
|
187
|
+
let a = (seed * 0x9e3779b1) >>> 0 || 1;
|
|
188
|
+
return () => {
|
|
189
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
190
|
+
let t = a;
|
|
191
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
192
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
193
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Exponential approach: `rate` per second, frame-rate independent. */
|
|
198
|
+
function approach(cur: number, target: number, rate: number, dt: number): number {
|
|
199
|
+
return cur + (target - cur) * (1 - Math.exp(-rate * dt));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const easeInOut = (p: number) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2);
|
|
203
|
+
/* gentler at both ends than the cubic: for drifting off and waking */
|
|
204
|
+
const easeSine = (p: number) => 0.5 - 0.5 * Math.cos(Math.PI * p);
|
|
205
|
+
|
|
206
|
+
/* A value that drifts: picks a new target inside its range every hold,
|
|
207
|
+
and eases toward it. Ranges change with the state; the value never
|
|
208
|
+
jumps. */
|
|
209
|
+
class Wander {
|
|
210
|
+
value = 0;
|
|
211
|
+
private vel = 0;
|
|
212
|
+
private target = 0;
|
|
213
|
+
private next = 0;
|
|
214
|
+
/* A channel that picks a new target now and then and moves to it. The
|
|
215
|
+
head channels move as a lightly damped spring — the turn starts and
|
|
216
|
+
ends softly, the way a head does — while the eyes dart with an
|
|
217
|
+
exponential approach, the way eyes do. */
|
|
218
|
+
constructor(
|
|
219
|
+
private rand: () => number,
|
|
220
|
+
public amp: number,
|
|
221
|
+
public holdMin: number,
|
|
222
|
+
public holdMax: number,
|
|
223
|
+
public rate: number,
|
|
224
|
+
private spring = false,
|
|
225
|
+
) {}
|
|
226
|
+
update(t: number, dt: number) {
|
|
227
|
+
if (t >= this.next) {
|
|
228
|
+
this.target = (this.rand() * 2 - 1) * this.amp;
|
|
229
|
+
this.next = t + this.holdMin + this.rand() * (this.holdMax - this.holdMin);
|
|
230
|
+
}
|
|
231
|
+
if (this.spring) {
|
|
232
|
+
const w = this.rate * 1.6,
|
|
233
|
+
z = 0.9;
|
|
234
|
+
this.vel += (w * w * (this.target - this.value) - 2 * z * w * this.vel) * dt;
|
|
235
|
+
this.value += this.vel * dt;
|
|
236
|
+
} else this.value = approach(this.value, this.target, this.rate, dt);
|
|
237
|
+
}
|
|
238
|
+
/** aim at a value and stay there: the rig picks the next one */
|
|
239
|
+
aim(v: number) {
|
|
240
|
+
this.target = v;
|
|
241
|
+
this.next = Infinity;
|
|
242
|
+
}
|
|
243
|
+
set(amp: number, holdMin: number, holdMax: number, rate: number) {
|
|
244
|
+
this.amp = amp;
|
|
245
|
+
this.holdMin = holdMin;
|
|
246
|
+
this.holdMax = holdMax;
|
|
247
|
+
this.rate = rate;
|
|
248
|
+
this.next = 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* A one-shot event: progress 0 → 1 over its duration, then idle at -1. */
|
|
253
|
+
class Event {
|
|
254
|
+
p = -1;
|
|
255
|
+
constructor(public duration: number) {}
|
|
256
|
+
fire() {
|
|
257
|
+
this.p = 0;
|
|
258
|
+
}
|
|
259
|
+
get active() {
|
|
260
|
+
return this.p >= 0;
|
|
261
|
+
}
|
|
262
|
+
update(dt: number) {
|
|
263
|
+
if (this.p < 0) return;
|
|
264
|
+
this.p += dt / this.duration;
|
|
265
|
+
if (this.p >= 1) this.p = -1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* The idle gaze: how far the head swings, and how long it stays. The
|
|
270
|
+
sideways reach is the big one — a look to a corner reads as a turn of
|
|
271
|
+
the head, with the rise and a slight tilt going along. */
|
|
272
|
+
const GAZE_YAW = 35 * DEG;
|
|
273
|
+
const GAZE_PITCH = 14 * DEG;
|
|
274
|
+
const GAZE_ROLL = 3.2 * DEG;
|
|
275
|
+
const GAZE_HOLD_MIN = 2.6;
|
|
276
|
+
const GAZE_HOLD_MAX = 4.4;
|
|
277
|
+
|
|
278
|
+
/* Resting targets per state. Everything the wanderers and events add sits
|
|
279
|
+
on top of these. */
|
|
280
|
+
interface Rest {
|
|
281
|
+
pitch: number;
|
|
282
|
+
roll: number;
|
|
283
|
+
y: number;
|
|
284
|
+
lookX: number;
|
|
285
|
+
lookY: number;
|
|
286
|
+
}
|
|
287
|
+
const REST: Record<BotAvatarState, Rest> = {
|
|
288
|
+
default: { pitch: 0, roll: 0, y: 0, lookX: 0, lookY: 0 },
|
|
289
|
+
working: { pitch: 5 * DEG, roll: 0, y: 0, lookX: 0, lookY: 0 },
|
|
290
|
+
sleeping: { pitch: -16 * DEG, roll: 6 * DEG, y: 3, lookX: 0, lookY: 1 },
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export class Sim {
|
|
294
|
+
readonly pose: Pose = {
|
|
295
|
+
yaw: 0,
|
|
296
|
+
pitch: 0,
|
|
297
|
+
roll: 0,
|
|
298
|
+
x: 0,
|
|
299
|
+
y: 0,
|
|
300
|
+
sx: 1,
|
|
301
|
+
sy: 1,
|
|
302
|
+
eyeOpen: 1,
|
|
303
|
+
blinkL: 0,
|
|
304
|
+
blinkR: 0,
|
|
305
|
+
lookX: 0,
|
|
306
|
+
lookY: 0,
|
|
307
|
+
breath: 0,
|
|
308
|
+
laugh: 0,
|
|
309
|
+
whirl: 0,
|
|
310
|
+
whirlAngle: 0,
|
|
311
|
+
w: [1, 0, 0],
|
|
312
|
+
};
|
|
313
|
+
state: BotAvatarState = "default";
|
|
314
|
+
|
|
315
|
+
private rand: () => number;
|
|
316
|
+
private t = 0;
|
|
317
|
+
/* a state change: the weights it started from and its progress */
|
|
318
|
+
private wFrom: [number, number, number] = [1, 0, 0];
|
|
319
|
+
private tr = 1;
|
|
320
|
+
private trDuration = 1.2;
|
|
321
|
+
private yawW: Wander;
|
|
322
|
+
private pitchW: Wander;
|
|
323
|
+
private rollW: Wander;
|
|
324
|
+
private lookXW: Wander;
|
|
325
|
+
private lookYW: Wander;
|
|
326
|
+
private blink = new Event(0.17);
|
|
327
|
+
private blinkAt: number;
|
|
328
|
+
private blinkAgain = false;
|
|
329
|
+
/* −1 left eye only, 1 right eye only, 0 both */
|
|
330
|
+
private dart = new Event(0.12);
|
|
331
|
+
private dartAt: number;
|
|
332
|
+
private dartX = 0;
|
|
333
|
+
private dartY = 0;
|
|
334
|
+
private flip = new Event(flipDuration(JUMP_DEFAULTS, false));
|
|
335
|
+
private flipPoked = false;
|
|
336
|
+
private jump: JumpConfig = { ...JUMP_DEFAULTS };
|
|
337
|
+
private flipAt: number;
|
|
338
|
+
private flipSide = 1;
|
|
339
|
+
private nod = new Event(1.7);
|
|
340
|
+
private nodAt: number;
|
|
341
|
+
private hopPhase = 0;
|
|
342
|
+
private hopCount = 0;
|
|
343
|
+
/** the hops' gain: the working weight while in the state, then held so
|
|
344
|
+
a hop under way finishes whole when the state is left */
|
|
345
|
+
private hopGain = 0;
|
|
346
|
+
private laughEv = new Event(0.8);
|
|
347
|
+
private laughAt: number;
|
|
348
|
+
/* the jelly: a damped spring driven by how fast the head turns, so a
|
|
349
|
+
sweep stretches the body and it wobbles back */
|
|
350
|
+
private prevYaw = 0;
|
|
351
|
+
private jelly = 0;
|
|
352
|
+
private jellyV = 0;
|
|
353
|
+
/** how far the eyes run ahead of a head turn */
|
|
354
|
+
private gazeLead = 0;
|
|
355
|
+
/** the idle gaze: where the head is looking, and when it moves on */
|
|
356
|
+
private gazeDir: [number, number] = [0, 0];
|
|
357
|
+
private gazeAt = 0;
|
|
358
|
+
/** how far it turns to the side, 1 as the gaze has it */
|
|
359
|
+
private turnK = 1;
|
|
360
|
+
/** the breathing cycle's phase, in turns */
|
|
361
|
+
private breathPhase = 0;
|
|
362
|
+
/* the pointer, as an offset from the head in head-widths, and how much
|
|
363
|
+
to follow it — both smoothed */
|
|
364
|
+
private ptrX = 0;
|
|
365
|
+
private ptrY = 0;
|
|
366
|
+
private ptrS = 0;
|
|
367
|
+
private ptrTargetX = 0;
|
|
368
|
+
private ptrTargetY = 0;
|
|
369
|
+
private ptrTargetS = 0;
|
|
370
|
+
/* the smoothed yaw the head is turning to on its own */
|
|
371
|
+
private baseYaw = 0;
|
|
372
|
+
|
|
373
|
+
constructor(seed: number, state: BotAvatarState = "default") {
|
|
374
|
+
this.rand = rng(Math.floor(seed * 1e6) + 1);
|
|
375
|
+
const r = this.rand;
|
|
376
|
+
this.yawW = new Wander(r, 36 * DEG, 1.1, 2.6, 3, true);
|
|
377
|
+
this.pitchW = new Wander(r, 10 * DEG, 1.1, 2.6, 2.6, true);
|
|
378
|
+
this.rollW = new Wander(r, 5 * DEG, 1.6, 3.2, 2, true);
|
|
379
|
+
this.lookXW = new Wander(r, 3.6, 0.5, 2, 14);
|
|
380
|
+
this.lookYW = new Wander(r, 2.4, 0.5, 2, 14);
|
|
381
|
+
/* every instance starts somewhere else in its loops */
|
|
382
|
+
this.t = r() * 10;
|
|
383
|
+
this.hopPhase = r();
|
|
384
|
+
this.breathPhase = r();
|
|
385
|
+
/* event timers count from that start, so nothing fires on the first tick */
|
|
386
|
+
this.blinkAt = this.t + 1 + r() * 3;
|
|
387
|
+
this.flipAt = this.nextFlip(this.t, 1);
|
|
388
|
+
this.nodAt = this.t + 3 + r() * 4;
|
|
389
|
+
this.dartAt = this.t + 1 + r() * 2;
|
|
390
|
+
this.laughAt = this.t + 0.6 + r() * 1.5;
|
|
391
|
+
this.setState(state, true);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
setState(next: BotAvatarState, immediate = false) {
|
|
395
|
+
if (next === this.state && !immediate) return;
|
|
396
|
+
const from = this.state;
|
|
397
|
+
this.state = next;
|
|
398
|
+
const w = this.pose.w;
|
|
399
|
+
if (immediate) {
|
|
400
|
+
for (let i = 0; i < 3; i++) w[i] = STATES[i] === next ? 1 : 0;
|
|
401
|
+
this.tr = 1;
|
|
402
|
+
} else {
|
|
403
|
+
this.wFrom = [w[0], w[1], w[2]];
|
|
404
|
+
this.tr = 0;
|
|
405
|
+
this.trDuration = from === "sleeping" ? SWITCH_FROM_SLEEP : SWITCH_TO[next];
|
|
406
|
+
}
|
|
407
|
+
switch (next) {
|
|
408
|
+
case "default":
|
|
409
|
+
/* the head takes its time: it holds a place, then swings to the
|
|
410
|
+
next over about a second — no short quick jabs. The gaze picks
|
|
411
|
+
where, so these carry the motion and their amplitude is unused. */
|
|
412
|
+
this.yawW.set(GAZE_YAW, 2.6, 5.4, 2);
|
|
413
|
+
this.pitchW.set(GAZE_PITCH, 2.8, 5.8, 1.8);
|
|
414
|
+
this.rollW.set(GAZE_ROLL, 3.4, 6.6, 1.5);
|
|
415
|
+
this.gazeAt = 0;
|
|
416
|
+
this.gazeDir = [0, 0];
|
|
417
|
+
/* the eyes keep their quickness: they dart, the head follows */
|
|
418
|
+
this.lookXW.set(3.6, 0.6, 2.2, 13);
|
|
419
|
+
this.lookYW.set(2.4, 0.6, 2.2, 13);
|
|
420
|
+
this.flipAt = this.nextFlip(this.t, 0.6);
|
|
421
|
+
break;
|
|
422
|
+
case "working":
|
|
423
|
+
this.yawW.set(16 * DEG, 0.9, 1.8, 4);
|
|
424
|
+
this.pitchW.set(3 * DEG, 1.2, 2.4, 3);
|
|
425
|
+
this.rollW.set(0, 1, 2, 3);
|
|
426
|
+
this.lookXW.set(2, 0.5, 1.2, 12);
|
|
427
|
+
this.lookYW.set(1, 0.5, 1.2, 12);
|
|
428
|
+
this.hopPhase = 0;
|
|
429
|
+
this.hopCount = 0;
|
|
430
|
+
this.laughAt = this.t + 0.5 + this.rand() * 1.2;
|
|
431
|
+
break;
|
|
432
|
+
case "sleeping":
|
|
433
|
+
this.yawW.set(7 * DEG, 3, 6, 0.7);
|
|
434
|
+
this.pitchW.set(3 * DEG, 3, 6, 0.7);
|
|
435
|
+
this.rollW.set(2 * DEG, 3, 6, 0.6);
|
|
436
|
+
this.lookXW.set(0, 2, 4, 2);
|
|
437
|
+
this.lookYW.set(0, 2, 4, 2);
|
|
438
|
+
this.nodAt = this.t + 2.5 + this.rand() * 4;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Where the pointer is, relative to the head (−1 … 1 across a head
|
|
444
|
+
width), and how strongly to follow it (0 lets go). */
|
|
445
|
+
setPointer(x: number, y: number, strength: number) {
|
|
446
|
+
this.ptrTargetX = Math.max(-1.2, Math.min(1.2, x));
|
|
447
|
+
this.ptrTargetY = Math.max(-1.2, Math.min(1.2, y));
|
|
448
|
+
this.ptrTargetS = Math.max(0, Math.min(1, strength));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** A hop and a full turn, right now, whatever the state. */
|
|
452
|
+
poke() {
|
|
453
|
+
if (this.flip.active && this.flip.p < 0.6) return;
|
|
454
|
+
this.flipPoked = true;
|
|
455
|
+
this.flip.duration = flipDuration(this.jump, true);
|
|
456
|
+
this.flipSide = this.rand() < 0.5 ? -1 : 1;
|
|
457
|
+
this.flip.fire();
|
|
458
|
+
this.flipAt = this.nextFlip(this.t, 1.1);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** How far the head turns to the side while idle: 1 as the gaze has
|
|
462
|
+
it, 0 keeps it facing forward. */
|
|
463
|
+
setTurn(k: number) {
|
|
464
|
+
const next = Math.max(0, k);
|
|
465
|
+
if (next === this.turnK) return;
|
|
466
|
+
this.turnK = next;
|
|
467
|
+
/* take the new reach at once rather than at the next place */
|
|
468
|
+
if (this.state === "default") this.gazeAt = 0;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The jump's numbers; any subset. */
|
|
472
|
+
setJump(j: Partial<JumpConfig>) {
|
|
473
|
+
const every = this.jump.every;
|
|
474
|
+
Object.assign(this.jump, j);
|
|
475
|
+
/* a new interval takes effect at once, so `every: 0` stops the next
|
|
476
|
+
jump that was already due and a first value starts the clock */
|
|
477
|
+
if (j.every !== undefined && j.every !== every) this.flipAt = this.nextFlip(this.t, 1);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/* Where the head looks next. From a corner it mostly swings straight
|
|
481
|
+
across to the opposite one — top right, stay, bottom left — now and
|
|
482
|
+
then only sideways, or back to the middle for a beat. */
|
|
483
|
+
private nextGaze(): [number, number] {
|
|
484
|
+
const r = this.rand;
|
|
485
|
+
const [px, py] = this.gazeDir;
|
|
486
|
+
if (px !== 0 || py !== 0) {
|
|
487
|
+
const p = r();
|
|
488
|
+
if (p < 0.66) return [-px, -py];
|
|
489
|
+
if (p < 0.85) return [-px, py];
|
|
490
|
+
return [0, 0];
|
|
491
|
+
}
|
|
492
|
+
const corners: Array<[number, number]> = [
|
|
493
|
+
[1, -1],
|
|
494
|
+
[-1, 1],
|
|
495
|
+
[-1, -1],
|
|
496
|
+
[1, 1],
|
|
497
|
+
];
|
|
498
|
+
return corners[Math.floor(r() * corners.length)];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** when the next idle jump is due: `every` seconds, give or take 40 % */
|
|
502
|
+
private nextFlip(t: number, k: number) {
|
|
503
|
+
const every = this.jump.every;
|
|
504
|
+
return every > 0 ? t + every * k * (0.625 + this.rand() * 0.75) : Infinity;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Advance by `dt` seconds (already scaled by the speed). */
|
|
508
|
+
update(dt: number) {
|
|
509
|
+
dt = Math.min(dt, 0.05);
|
|
510
|
+
this.t += dt;
|
|
511
|
+
const t = this.t;
|
|
512
|
+
const p = this.pose;
|
|
513
|
+
const w = p.w;
|
|
514
|
+
|
|
515
|
+
/* the state change eases from the weights it started with to the new
|
|
516
|
+
state's on an S-curve: soft start, soft finish, no creeping tail */
|
|
517
|
+
if (this.tr < 1) {
|
|
518
|
+
this.tr = Math.min(1, this.tr + dt / this.trDuration);
|
|
519
|
+
/* a sine ease: no kick at either end of a switch */
|
|
520
|
+
const e = easeSine(this.tr);
|
|
521
|
+
for (let i = 0; i < 3; i++) {
|
|
522
|
+
const target = STATES[i] === this.state ? 1 : 0;
|
|
523
|
+
w[i] = this.wFrom[i] + (target - this.wFrom[i]) * e;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const [wd, ww, ws] = w;
|
|
527
|
+
|
|
528
|
+
/* rest targets, blended */
|
|
529
|
+
const rest = { pitch: 0, roll: 0, y: 0, lookX: 0, lookY: 0 };
|
|
530
|
+
for (let i = 0; i < 3; i++) {
|
|
531
|
+
const r = REST[STATES[i]];
|
|
532
|
+
rest.pitch += r.pitch * w[i];
|
|
533
|
+
rest.roll += r.roll * w[i];
|
|
534
|
+
rest.y += r.y * w[i];
|
|
535
|
+
rest.lookX += r.lookX * w[i];
|
|
536
|
+
rest.lookY += r.lookY * w[i];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/* the idle gaze: a place to look, a while to stay, then the swing to
|
|
540
|
+
the next — the wanderers carry it on their own springs */
|
|
541
|
+
if (this.state === "default" && t >= this.gazeAt) {
|
|
542
|
+
const [gx, gy] = this.nextGaze();
|
|
543
|
+
this.gazeDir = [gx, gy];
|
|
544
|
+
const reach = 0.84 + this.rand() * 0.16;
|
|
545
|
+
this.yawW.aim(gx * GAZE_YAW * reach * this.turnK);
|
|
546
|
+
this.pitchW.aim(gy * GAZE_PITCH * reach);
|
|
547
|
+
/* the tilt goes with the turn, so it follows it */
|
|
548
|
+
this.rollW.aim(gx * GAZE_ROLL * reach * this.turnK);
|
|
549
|
+
this.gazeAt = t + GAZE_HOLD_MIN + this.rand() * (GAZE_HOLD_MAX - GAZE_HOLD_MIN);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/* wander */
|
|
553
|
+
this.yawW.update(t, dt);
|
|
554
|
+
this.pitchW.update(t, dt);
|
|
555
|
+
this.rollW.update(t, dt);
|
|
556
|
+
this.lookXW.update(t, dt);
|
|
557
|
+
this.lookYW.update(t, dt);
|
|
558
|
+
|
|
559
|
+
/* following the pointer: the eyes lead, the head turns after them,
|
|
560
|
+
and the wander quietens while it lasts */
|
|
561
|
+
this.ptrS = approach(this.ptrS, this.ptrTargetS, 8, dt);
|
|
562
|
+
this.ptrX = approach(this.ptrX, this.ptrTargetX, 14, dt);
|
|
563
|
+
this.ptrY = approach(this.ptrY, this.ptrTargetY, 14, dt);
|
|
564
|
+
const ps = this.ptrS;
|
|
565
|
+
const quiet = 1 - 0.75 * ps;
|
|
566
|
+
|
|
567
|
+
/* the base pose: the blended rest plus the smoothed wander and the
|
|
568
|
+
pointer's pull — every term is already continuous */
|
|
569
|
+
this.baseYaw = approach(
|
|
570
|
+
this.baseYaw,
|
|
571
|
+
this.yawW.value * quiet + 22 * DEG * this.ptrX * ps,
|
|
572
|
+
5,
|
|
573
|
+
dt,
|
|
574
|
+
);
|
|
575
|
+
const basePitch = rest.pitch + this.pitchW.value * quiet - 12 * DEG * this.ptrY * ps;
|
|
576
|
+
const baseRoll = rest.roll + this.rollW.value * quiet;
|
|
577
|
+
const baseY = rest.y;
|
|
578
|
+
const baseLookX = rest.lookX + this.lookXW.value * quiet + 4.5 * this.ptrX * ps;
|
|
579
|
+
const baseLookY = rest.lookY + this.lookYW.value * quiet + 3 * this.ptrY * ps;
|
|
580
|
+
|
|
581
|
+
/* ── events ── */
|
|
582
|
+
let spin = 0,
|
|
583
|
+
hopY = 0,
|
|
584
|
+
sx = 1,
|
|
585
|
+
sy = 1,
|
|
586
|
+
pitchAdd = 0,
|
|
587
|
+
rollAdd = 0,
|
|
588
|
+
blinkClose = 0,
|
|
589
|
+
lookXAdd = 0,
|
|
590
|
+
lookYAdd = 0,
|
|
591
|
+
laugh = 0;
|
|
592
|
+
let whirl = 0,
|
|
593
|
+
whirlAngle = 0;
|
|
594
|
+
/* the whirl: not there until the turn is visibly under way, gone
|
|
595
|
+
before the landing */
|
|
596
|
+
const smooth = (a: number, b: number, v: number) => {
|
|
597
|
+
const x = Math.min(1, Math.max(0, (v - a) / (b - a)));
|
|
598
|
+
return x * x * (3 - 2 * x);
|
|
599
|
+
};
|
|
600
|
+
const envelope = (q: number) => smooth(0.1, 0.26, q) * (1 - smooth(0.66, 0.9, q));
|
|
601
|
+
/* the ring's own travel: fast and steady from the first frame, with a
|
|
602
|
+
little of the body's own easing on top, so it is moving the moment
|
|
603
|
+
it shows */
|
|
604
|
+
const ringAngle = (q: number) => TAU * (1.5 * q + 0.9 * easeInOut(q));
|
|
605
|
+
|
|
606
|
+
/* blinks: idle and working blink; a double blink now and then */
|
|
607
|
+
if (t >= this.blinkAt && !this.blink.active && wd + ww > 0.5) {
|
|
608
|
+
this.blink.fire();
|
|
609
|
+
this.blinkAgain = !this.blinkAgain && this.rand() < 0.22;
|
|
610
|
+
this.blinkAt = t + (this.blinkAgain ? 0.28 : 2.2 + this.rand() * 2.6);
|
|
611
|
+
}
|
|
612
|
+
this.blink.update(dt);
|
|
613
|
+
if (this.blink.active) blinkClose = Math.sin(Math.PI * this.blink.p);
|
|
614
|
+
|
|
615
|
+
/* eye darts: a quick glance to the side and back, between the slower
|
|
616
|
+
looks — the eyes have a life of their own */
|
|
617
|
+
if (t >= this.dartAt && !this.dart.active && wd + ww > 0.5) {
|
|
618
|
+
this.dart.fire();
|
|
619
|
+
this.dartX = (this.rand() * 2 - 1) * 4;
|
|
620
|
+
this.dartY = (this.rand() * 2 - 1) * 2;
|
|
621
|
+
this.dart.duration = 0.25 + this.rand() * 0.45;
|
|
622
|
+
this.dartAt = t + 1.2 + this.rand() * 2.6;
|
|
623
|
+
}
|
|
624
|
+
this.dart.update(dt);
|
|
625
|
+
if (this.dart.active) {
|
|
626
|
+
const q = this.dart.p;
|
|
627
|
+
/* snap out, hold, snap back */
|
|
628
|
+
const hold = q < 0.15 ? q / 0.15 : q > 0.8 ? (1 - q) / 0.2 : 1;
|
|
629
|
+
lookXAdd += this.dartX * hold * (wd + ww);
|
|
630
|
+
lookYAdd += this.dartY * hold * (wd + ww);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* idle: a full turn now and then, with a jump */
|
|
634
|
+
if (this.state === "default" && t >= this.flipAt && !this.flip.active) {
|
|
635
|
+
this.flipPoked = false;
|
|
636
|
+
this.flip.duration = flipDuration(this.jump, false);
|
|
637
|
+
this.flipSide = this.rand() < 0.5 ? -1 : 1;
|
|
638
|
+
this.flip.fire();
|
|
639
|
+
this.flipAt = this.nextFlip(t, 1);
|
|
640
|
+
}
|
|
641
|
+
this.flip.update(dt);
|
|
642
|
+
if (this.flip.active) {
|
|
643
|
+
const J = this.jump;
|
|
644
|
+
/* the hop's own phase: below 0 the crouch, above 1 the recovery */
|
|
645
|
+
const preS = flipPre(J, this.flipPoked);
|
|
646
|
+
const a = (this.flip.p * this.flip.duration - preS) / J.time;
|
|
647
|
+
const q = Math.min(1, Math.max(0, a));
|
|
648
|
+
const arc = Math.sin(Math.PI * q);
|
|
649
|
+
/* whole turns, eased in and out, no overshoot to snap back from */
|
|
650
|
+
spin += TAU * J.spin * easeInOut(q);
|
|
651
|
+
hopY -= J.height * arc;
|
|
652
|
+
/* the crouch before take-off; then the landing squash from `land`
|
|
653
|
+
seconds round the moment of contact — before it, the body braces
|
|
654
|
+
for the ground; after it, it gives late */
|
|
655
|
+
const tl = (a - 1) * J.time - J.land;
|
|
656
|
+
const squashTime = this.flipPoked ? J.clickSquashTime : J.squashTime;
|
|
657
|
+
/* the crouch: a click's builds over its squash time, an idle jump's
|
|
658
|
+
is the hop's short bump; then the hop, then the landing squash */
|
|
659
|
+
const crouch = (u: number) => (this.flipPoked ? u * u * (3 - 2 * u) : hopSquash(u - 1));
|
|
660
|
+
/* the landing in three parts: the press down to the deepest squash
|
|
661
|
+
in the squash easing's own shape, the hold on the ground, then
|
|
662
|
+
the rise back to the body's shape — each with its own time and
|
|
663
|
+
easing, and all three meeting at the same depth, so nothing steps */
|
|
664
|
+
const hold = Math.max(0, J.groundTime);
|
|
665
|
+
const peakT = SQUASH_PEAK[J.squashEase] * squashTime;
|
|
666
|
+
const rise = tl - peakT - hold;
|
|
667
|
+
const depth =
|
|
668
|
+
tl <= peakT
|
|
669
|
+
? squashPulse(tl / squashTime, J.squashEase)
|
|
670
|
+
: rise <= 0
|
|
671
|
+
? groundShape((tl - peakT) / hold, J.groundEase)
|
|
672
|
+
: risePulse(rise / J.riseTime, J.riseEase);
|
|
673
|
+
const land =
|
|
674
|
+
(a < 0
|
|
675
|
+
? crouch(Math.max(0, 1 + (a * J.time) / preS))
|
|
676
|
+
: tl > 0
|
|
677
|
+
? depth
|
|
678
|
+
: a < 0.2
|
|
679
|
+
? hopSquash(a)
|
|
680
|
+
: 0) * J.squash;
|
|
681
|
+
sx += 0.16 * land - 0.06 * arc * J.stretch;
|
|
682
|
+
sy += -0.18 * land + 0.09 * arc * J.stretch;
|
|
683
|
+
/* lean into it; eyes shut for a turn */
|
|
684
|
+
rollAdd += this.flipSide * J.lean * DEG * arc;
|
|
685
|
+
if (J.spin > 0) {
|
|
686
|
+
laugh = Math.max(laugh, arc);
|
|
687
|
+
whirl = Math.max(whirl, envelope(q));
|
|
688
|
+
whirlAngle = ringAngle(q);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/* working: hops all the time; every third one spins. Leaving the
|
|
693
|
+
state, the hop under way keeps the strength it had — it flies its
|
|
694
|
+
whole arc, finishes its turn and lands — and that last landing is
|
|
695
|
+
not the rhythm's quick bump but a jump's proper one: the hold on
|
|
696
|
+
the ground and the rise back, from the depth the hop reached. */
|
|
697
|
+
const J = this.jump;
|
|
698
|
+
const exitHold = Math.max(0, J.groundTime);
|
|
699
|
+
const exitFor = exitHold + J.riseTime;
|
|
700
|
+
if (this.state === "working") this.hopGain = ww;
|
|
701
|
+
if (this.hopGain > 0.02 && (this.state === "working" || this.hopPhase > 0)) {
|
|
702
|
+
this.hopPhase += dt / HOP_T;
|
|
703
|
+
if (this.hopPhase >= 1) {
|
|
704
|
+
if (this.state === "working") {
|
|
705
|
+
this.hopPhase -= 1;
|
|
706
|
+
this.hopCount += 1;
|
|
707
|
+
} else if ((this.hopPhase - 1) * HOP_T >= exitFor) {
|
|
708
|
+
/* the last hop has landed and settled */
|
|
709
|
+
this.hopPhase = 0;
|
|
710
|
+
this.hopGain = 0;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const g = this.hopGain;
|
|
714
|
+
/* past 1 the body is down and only the landing is still playing */
|
|
715
|
+
const q = Math.min(1, this.hopPhase);
|
|
716
|
+
const arc = Math.sin(Math.PI * q);
|
|
717
|
+
const spinning = this.hopCount % 3 === 2;
|
|
718
|
+
const h = spinning ? HOP_SPIN_H : 18;
|
|
719
|
+
hopY -= h * arc * g;
|
|
720
|
+
/* squash on landing, stretch at the top */
|
|
721
|
+
const exitT =
|
|
722
|
+
this.state !== "working" && this.hopPhase > 1 ? (this.hopPhase - 1) * HOP_T : -1;
|
|
723
|
+
const land =
|
|
724
|
+
exitT < 0
|
|
725
|
+
? hopSquash(this.hopPhase)
|
|
726
|
+
: exitT < exitHold
|
|
727
|
+
? groundShape(exitT / exitHold, J.groundEase)
|
|
728
|
+
: risePulse((exitT - exitHold) / J.riseTime, J.riseEase);
|
|
729
|
+
sx += (0.16 * land - 0.06 * arc) * g;
|
|
730
|
+
sy += (-0.18 * land + 0.09 * arc) * g;
|
|
731
|
+
if (spinning) {
|
|
732
|
+
spin += TAU * easeInOut(q) * g;
|
|
733
|
+
/* eyes shut for the spin */
|
|
734
|
+
laugh = Math.max(laugh, arc * g);
|
|
735
|
+
if (envelope(q) * g > whirl) {
|
|
736
|
+
whirl = envelope(q) * g;
|
|
737
|
+
whirlAngle = ringAngle(q);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/* lean into each hop, alternating sides */
|
|
741
|
+
rollAdd += (this.hopCount % 2 === 0 ? 1 : -1) * 6 * DEG * arc * g;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/* working: now and then a laugh shuts the eyes into arcs, then they
|
|
745
|
+
open again */
|
|
746
|
+
if (this.state === "working" && t >= this.laughAt && !this.laughEv.active) {
|
|
747
|
+
this.laughEv.fire();
|
|
748
|
+
this.laughEv.duration = 0.6 + this.rand() * 0.5;
|
|
749
|
+
this.laughAt = t + 1.6 + this.rand() * 2.2;
|
|
750
|
+
}
|
|
751
|
+
this.laughEv.update(dt);
|
|
752
|
+
if (this.laughEv.active) {
|
|
753
|
+
const q = this.laughEv.p;
|
|
754
|
+
/* quick shut, hold, quick open */
|
|
755
|
+
laugh = Math.max(laugh, q < 0.18 ? q / 0.18 : q > 0.78 ? (1 - q) / 0.22 : 1);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/* sleeping: the head drops, then jerks back up */
|
|
759
|
+
if (this.state === "sleeping" && t >= this.nodAt && !this.nod.active) {
|
|
760
|
+
this.nod.fire();
|
|
761
|
+
this.nodAt = t + 4 + this.rand() * 4;
|
|
762
|
+
}
|
|
763
|
+
this.nod.update(dt);
|
|
764
|
+
if (this.nod.active) {
|
|
765
|
+
const q = this.nod.p;
|
|
766
|
+
/* slow slide down, quick recovery */
|
|
767
|
+
const dip = q < 0.72 ? easeInOut(q / 0.72) : 1 - easeInOut((q - 0.72) / 0.28);
|
|
768
|
+
pitchAdd -= 13 * DEG * dip * ws;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/* breathing, always, deeper and slower asleep. The phase is integrated
|
|
772
|
+
so the period can change with the state without the cycle jumping */
|
|
773
|
+
this.breathPhase += dt / (3.6 + 1.2 * ws);
|
|
774
|
+
const breath = Math.sin(this.breathPhase * TAU);
|
|
775
|
+
p.breath = breath;
|
|
776
|
+
sx += breath * (0.008 + 0.014 * ws);
|
|
777
|
+
sy += breath * (0.012 + 0.02 * ws);
|
|
778
|
+
const bob = Math.sin((t * TAU) / 3.4) * 2 * (1 - ws);
|
|
779
|
+
|
|
780
|
+
/* ── compose ── */
|
|
781
|
+
p.yaw = this.baseYaw + spin;
|
|
782
|
+
|
|
783
|
+
/* the jelly: the faster the head turns, the more the body stretches
|
|
784
|
+
along the turn, on a spring that overshoots and settles. A flip's
|
|
785
|
+
spin is left out: that is a jump, and the landing spring handles it. */
|
|
786
|
+
let dyaw = this.baseYaw - this.prevYaw;
|
|
787
|
+
dyaw = ((((dyaw + Math.PI) % TAU) + TAU) % TAU) - Math.PI;
|
|
788
|
+
this.prevYaw = this.baseYaw;
|
|
789
|
+
const rate = dt > 0 ? Math.abs(dyaw) / dt : 0;
|
|
790
|
+
/* the eyes lead a head turn, the way a gaze shift goes: they run
|
|
791
|
+
ahead of it and come back as the head settles */
|
|
792
|
+
const leadTarget = dt > 0 ? Math.max(-2.2, Math.min(2.2, (dyaw / dt) * 2.4)) : 0;
|
|
793
|
+
this.gazeLead = approach(this.gazeLead, leadTarget, 9, dt);
|
|
794
|
+
const jellyTarget = Math.min(0.22, 0.055 * rate);
|
|
795
|
+
const omega = 16,
|
|
796
|
+
zeta = 0.45;
|
|
797
|
+
this.jellyV +=
|
|
798
|
+
(omega * omega * (jellyTarget - this.jelly) - 2 * zeta * omega * this.jellyV) * dt;
|
|
799
|
+
this.jelly += this.jellyV * dt;
|
|
800
|
+
const jelly = Math.max(-0.08, Math.min(0.28, this.jelly)) * 0.6;
|
|
801
|
+
sx *= 1 + jelly;
|
|
802
|
+
sy *= 1 - 0.55 * jelly;
|
|
803
|
+
|
|
804
|
+
p.pitch = basePitch + pitchAdd;
|
|
805
|
+
p.roll = baseRoll + rollAdd;
|
|
806
|
+
p.x = 0;
|
|
807
|
+
p.y = baseY + hopY + bob;
|
|
808
|
+
p.sx = sx;
|
|
809
|
+
p.sy = sy;
|
|
810
|
+
/* the lids: the sleeping state closes them through its own weight in
|
|
811
|
+
the renderer, so here the eyes stay open apart from blinks */
|
|
812
|
+
p.eyeOpen = 1;
|
|
813
|
+
p.laugh = approach(p.laugh, laugh, 30, dt);
|
|
814
|
+
p.blinkL = blinkClose;
|
|
815
|
+
p.blinkR = blinkClose;
|
|
816
|
+
p.lookX = baseLookX + lookXAdd + this.gazeLead;
|
|
817
|
+
p.lookY = baseLookY + lookYAdd;
|
|
818
|
+
p.whirl = whirl;
|
|
819
|
+
p.whirlAngle = whirlAngle;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** The still pose of a state, for reduced motion and the first paint. */
|
|
824
|
+
export function restPose(state: BotAvatarState): Pose {
|
|
825
|
+
const r = REST[state];
|
|
826
|
+
return {
|
|
827
|
+
yaw: 0,
|
|
828
|
+
pitch: r.pitch,
|
|
829
|
+
roll: r.roll,
|
|
830
|
+
x: 0,
|
|
831
|
+
y: r.y,
|
|
832
|
+
sx: 1,
|
|
833
|
+
sy: 1,
|
|
834
|
+
eyeOpen: 1,
|
|
835
|
+
blinkL: 0,
|
|
836
|
+
blinkR: 0,
|
|
837
|
+
lookX: r.lookX,
|
|
838
|
+
lookY: r.lookY,
|
|
839
|
+
breath: 0,
|
|
840
|
+
laugh: 0,
|
|
841
|
+
whirl: 0,
|
|
842
|
+
whirlAngle: 0,
|
|
843
|
+
w: STATES.map((s) => (s === state ? 1 : 0)) as [number, number, number],
|
|
844
|
+
};
|
|
845
|
+
}
|