@doan-labs/peek 0.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/src/animate.ts ADDED
@@ -0,0 +1,670 @@
1
+ /*
2
+ * The rig: springs, choreography, blinks, saccades and breathing, transcribed
3
+ * from the v1.2 page. One module-level requestAnimationFrame loop steps every
4
+ * live avatar; each step draws the tree for the current pose and writes only
5
+ * the attributes that changed straight onto the DOM. React never hears about
6
+ * a frame.
7
+ *
8
+ * Randomness lives here and only here: a face may be random in what it does,
9
+ * never in what it is.
10
+ */
11
+
12
+ import {
13
+ clamp,
14
+ type Drawable,
15
+ type DrawOptions,
16
+ draw,
17
+ frameOf,
18
+ type Gaze,
19
+ type Node,
20
+ type Pose,
21
+ restPose,
22
+ smooth,
23
+ thingGaze,
24
+ VB,
25
+ } from './draw'
26
+ import {
27
+ type Channel,
28
+ type Channels,
29
+ EXPRESSIONS,
30
+ type Expression,
31
+ FACES,
32
+ GROUPS,
33
+ type Group,
34
+ } from './tables'
35
+
36
+ type Spring = [omega: number, zeta: number]
37
+ type KeyValue = number | ((v: number) => number)
38
+ type Key = [
39
+ at: number,
40
+ set: Partial<Record<Channel | 'look', KeyValue>>,
41
+ spr?: Partial<Record<Group, Spring>>,
42
+ ]
43
+ type Choreo = {
44
+ delay?: Partial<Record<Group, number>>
45
+ spr?: Partial<Record<Group, Spring>>
46
+ keys?: Key[]
47
+ }
48
+
49
+ const GROUP_OF = {} as Record<Channel, Group>
50
+ for (const gr of Object.keys(GROUPS) as Group[])
51
+ for (const c of GROUPS[gr]) GROUP_OF[c] = gr
52
+ const CHANNELS = Object.keys(GROUP_OF) as Channel[]
53
+
54
+ /* choreography: when each group starts moving, how springy it is, and timed
55
+ * keys */
56
+ const DEF_DELAY: Record<Group, number> = {
57
+ eyes: 0,
58
+ pupil: 50,
59
+ brows: 110,
60
+ mouth: 170,
61
+ extra: 140,
62
+ body: 230,
63
+ }
64
+ const DEF_SPR: Record<Group, Spring> = {
65
+ eyes: [14, 0.82],
66
+ pupil: [15, 0.8],
67
+ brows: [11, 0.72],
68
+ mouth: [12, 0.74],
69
+ body: [7, 0.72],
70
+ extra: [8, 0.5],
71
+ }
72
+ const CHOREO: Partial<Record<Expression, Choreo>> = {
73
+ happy: {
74
+ delay: {
75
+ eyes: 0,
76
+ pupil: 40,
77
+ brows: 120,
78
+ mouth: 200,
79
+ extra: 220,
80
+ body: 280,
81
+ },
82
+ spr: { body: [9, 0.42], extra: [9, 0.35] },
83
+ keys: [
84
+ [280, { alt: 0.2 }, { body: [11, 0.5] }],
85
+ [520, { alt: 0.05 }, { body: [8, 0.38] }],
86
+ ],
87
+ },
88
+ sad: {
89
+ delay: {
90
+ eyes: 0,
91
+ pupil: 160,
92
+ brows: 260,
93
+ mouth: 380,
94
+ extra: 420,
95
+ body: 560,
96
+ },
97
+ spr: {
98
+ eyes: [6, 0.95],
99
+ pupil: [5, 0.95],
100
+ brows: [6, 0.95],
101
+ mouth: [6, 0.9],
102
+ body: [3.2, 0.95],
103
+ extra: [3, 0.9],
104
+ },
105
+ },
106
+ angry: {
107
+ delay: { brows: 0, eyes: 40, pupil: 60, mouth: 160, body: 100, extra: 60 },
108
+ spr: { brows: [18, 0.7], eyes: [16, 0.75], body: [14, 0.5] },
109
+ },
110
+ sleepy: {
111
+ delay: {
112
+ eyes: 0,
113
+ pupil: 200,
114
+ brows: 750,
115
+ mouth: 950,
116
+ extra: 900,
117
+ body: 1150,
118
+ },
119
+ spr: {
120
+ eyes: [2.6, 1],
121
+ pupil: [3, 1],
122
+ brows: [3.2, 1],
123
+ mouth: [3, 1],
124
+ extra: [2.4, 0.9],
125
+ body: [1.9, 1],
126
+ },
127
+ keys: [
128
+ [0, { lid: 0.62 }],
129
+ [1300, { lid: 1 }],
130
+ ],
131
+ },
132
+ curious: {
133
+ delay: { eyes: 0, pupil: 0, brows: 220, mouth: 320, extra: 0, body: 0 },
134
+ spr: { body: [6, 0.95] },
135
+ keys: [
136
+ [0, { alt: -0.8, rot: 0, gx: 0, gy: -0.1, thing: 0 }],
137
+ [900, { alt: -0.56 }, { body: [2.2, 1] }],
138
+ [1500, { thing: 1 }, { extra: [5, 0.9] }],
139
+ [2200, { look: 1 }, { pupil: [6, 0.9] }],
140
+ [2500, { rot: 7 }, { body: [3, 0.9] }],
141
+ [2900, { alt: -0.32 }, { body: [2, 1] }],
142
+ ],
143
+ },
144
+ surprised: {
145
+ delay: { eyes: 0, pupil: 0, brows: 0, mouth: 40, extra: 60, body: 0 },
146
+ spr: {
147
+ eyes: [30, 0.62],
148
+ pupil: [26, 0.7],
149
+ brows: [24, 0.55],
150
+ mouth: [22, 0.6],
151
+ extra: [15, 0.32],
152
+ },
153
+ keys: [
154
+ [0, { alt: (v) => v - 0.1, sy: 0.9, sx: 1.05 }, { body: [30, 1] }],
155
+ [110, { alt: 0.36, sy: 1.08, sx: 0.97 }, { body: [17, 0.42] }],
156
+ [380, { sy: 1, sx: 1 }, { body: [10, 0.5] }],
157
+ ],
158
+ },
159
+ excited: {
160
+ delay: { eyes: 0, pupil: 30, brows: 80, mouth: 140, extra: 120, body: 180 },
161
+ spr: { body: [9, 0.4], extra: [12, 0.3] },
162
+ },
163
+ confused: {
164
+ delay: {
165
+ eyes: 0,
166
+ brows: 60,
167
+ pupil: 100,
168
+ mouth: 250,
169
+ body: 200,
170
+ extra: 200,
171
+ },
172
+ spr: { body: [5, 0.7] },
173
+ },
174
+ bored: {
175
+ delay: {
176
+ eyes: 0,
177
+ pupil: 300,
178
+ brows: 500,
179
+ mouth: 600,
180
+ extra: 700,
181
+ body: 800,
182
+ },
183
+ spr: {
184
+ eyes: [3, 1],
185
+ pupil: [2.5, 1],
186
+ brows: [3, 1],
187
+ mouth: [3, 1],
188
+ body: [1.6, 1],
189
+ extra: [2, 1],
190
+ },
191
+ },
192
+ attentive: {
193
+ delay: {
194
+ eyes: 0,
195
+ pupil: 180,
196
+ brows: 90,
197
+ mouth: 240,
198
+ extra: 200,
199
+ body: 360,
200
+ },
201
+ spr: {
202
+ eyes: [20, 0.8],
203
+ pupil: [20, 0.85],
204
+ brows: [16, 0.7],
205
+ body: [5, 0.78],
206
+ extra: [11, 0.4],
207
+ },
208
+ },
209
+ }
210
+
211
+ /* how much the pointer steers the eyes, and the blink interval, per state */
212
+ const ATTENTION: Record<Expression, number> = {
213
+ normal: 0.8,
214
+ happy: 0.6,
215
+ sad: 0.2,
216
+ angry: 0.6,
217
+ sleepy: 0,
218
+ curious: 1,
219
+ surprised: 0.5,
220
+ excited: 0.5,
221
+ confused: 0.3,
222
+ bored: 0.1,
223
+ attentive: 1,
224
+ }
225
+ const BLINK: Record<Expression, [number, number] | null> = {
226
+ normal: [2.4, 5],
227
+ happy: [2.2, 4],
228
+ sad: [3, 6],
229
+ angry: [3.5, 6],
230
+ sleepy: null,
231
+ curious: [3.5, 6],
232
+ surprised: [3, 5],
233
+ excited: [1.8, 3.2],
234
+ confused: [2, 4],
235
+ bored: [4.5, 7.5],
236
+ attentive: [4.5, 7.5],
237
+ }
238
+
239
+ const rand = (a: number, b: number) => a + Math.random() * (b - a)
240
+ const zero = () => {
241
+ const o = {} as Channels
242
+ for (const c of CHANNELS) o[c] = 0
243
+ return o
244
+ }
245
+
246
+ /* ---------- the shared loop ---------- */
247
+
248
+ /* every bound avatar, and the ones the loop steps (not under reduced
249
+ * motion) */
250
+ const ALL = new Set<Live>()
251
+ const LIVE = new Set<Live>()
252
+ let raf = 0
253
+ let last = 0
254
+ function tick(now: number) {
255
+ const dt = Math.min(0.05, (now - last) / 1000)
256
+ last = now
257
+ if (!document.hidden) {
258
+ // every layout read before any write: one layout a frame, not one a face
259
+ for (const l of LIVE) if (l.visible) l.measure()
260
+ for (const l of LIVE) if (l.visible) l.step(dt)
261
+ }
262
+ raf = LIVE.size ? requestAnimationFrame(tick) : 0
263
+ }
264
+ function run() {
265
+ if (raf) return
266
+ last = performance.now()
267
+ raf = requestAnimationFrame(tick)
268
+ }
269
+
270
+ /* prefers-reduced-motion, watched once for every avatar */
271
+ let motionQuery: MediaQueryList | null = null
272
+ function reducedMotion() {
273
+ if (!motionQuery) {
274
+ motionQuery = matchMedia('(prefers-reduced-motion: reduce)')
275
+ motionQuery.addEventListener('change', (e) => {
276
+ for (const l of ALL) l.setReduced(e.matches)
277
+ })
278
+ }
279
+ return motionQuery.matches
280
+ }
281
+
282
+ /* what the pointer is doing, in client px; listened to once, by the first
283
+ * avatar that watches it */
284
+ let pointer: [number, number] | null = null
285
+ let listening = false
286
+ function listen() {
287
+ if (listening) return
288
+ listening = true
289
+ const at = (e: PointerEvent) => {
290
+ pointer = [e.clientX, e.clientY]
291
+ }
292
+ document.addEventListener('pointermove', at, { passive: true })
293
+ document.addEventListener('pointerdown', at, { passive: true })
294
+ document.documentElement.addEventListener('pointerleave', () => {
295
+ pointer = null
296
+ })
297
+ }
298
+
299
+ type Bump = {
300
+ ch: Channel
301
+ amt: number
302
+ t0: number
303
+ a: number
304
+ h: number
305
+ r: number
306
+ }
307
+ type Queued = { at: number; set: Key[1]; spr?: Key[2] }
308
+
309
+ /**
310
+ * One live avatar bound to an SVG that already shows `tree`. Starts settled
311
+ * in `expression`, the same pose the server painted.
312
+ */
313
+ export class Live {
314
+ visible = true
315
+ private state: Expression
316
+ private reduced: boolean
317
+ private nodes = new Map<string, Element>()
318
+ private prev = new Map<string, Record<string, string>>()
319
+ private io: IntersectionObserver | null = null
320
+ private clock = 0
321
+ private enteredAt = 0
322
+ private val: Channels
323
+ private vel = zero()
324
+ private base: Channels
325
+ private spr = {} as Record<Channel, Spring>
326
+ private out = zero()
327
+ private queue: Queued[] = []
328
+ private bumps: Bump[] = []
329
+ private blinkAt = rand(1, 3)
330
+ private blinkT = -1
331
+ private blinkDur = 0.16
332
+ private blinkV = 0
333
+ private doubleBlink = false
334
+ private saccAt = rand(0.6, 1.8)
335
+ private sacc = { gx: 0, gy: 0 }
336
+ private nodAt = 0
337
+ private lookThing: boolean
338
+ private lag = { y: 0, v: 0 }
339
+ private fixed: Gaze | null = null
340
+ private watch = false
341
+ private rect: DOMRect | null = null
342
+
343
+ constructor(
344
+ private svg: SVGSVGElement,
345
+ tree: Node,
346
+ private who: Drawable,
347
+ private opts: DrawOptions,
348
+ expression: Expression,
349
+ gaze?: Gaze | 'pointer',
350
+ ) {
351
+ this.state = expression
352
+ this.bind(tree, svg)
353
+ this.setGaze(gaze)
354
+ const start = restPose(who, expression, this.fixed ?? undefined)
355
+ this.val = { ...start }
356
+ this.base = { ...EXPRESSIONS[expression] }
357
+ if (this.fixed) [this.base.gx, this.base.gy] = this.fixed
358
+ for (const c of CHANNELS) this.spr[c] = DEF_SPR[GROUP_OF[c]]
359
+ this.lookThing = expression === 'curious'
360
+ this.lag.y = -start.alt * frameOf(FACES[who.face]).R
361
+ this.reduced = reducedMotion()
362
+ // the DOM may hold a pose other than this one: the first paint fixes it
363
+ this.paint()
364
+ this.io = new IntersectionObserver((es) => {
365
+ for (const e of es) this.visible = e.isIntersecting
366
+ })
367
+ this.io.observe(svg)
368
+ ALL.add(this)
369
+ if (this.reduced) return
370
+ LIVE.add(this)
371
+ run()
372
+ }
373
+
374
+ destroy() {
375
+ ALL.delete(this)
376
+ LIVE.delete(this)
377
+ this.io?.disconnect()
378
+ }
379
+
380
+ /** Reduced motion turned on or off while the page is open. */
381
+ setReduced(reduced: boolean) {
382
+ if (reduced === this.reduced) return
383
+ this.reduced = reduced
384
+ if (!reduced) {
385
+ LIVE.add(this)
386
+ run()
387
+ return
388
+ }
389
+ LIVE.delete(this)
390
+ this.snap()
391
+ }
392
+
393
+ /** Read before any face writes this frame; see tick(). */
394
+ measure() {
395
+ this.rect = this.watch && pointer ? this.svg.getBoundingClientRect() : null
396
+ }
397
+
398
+ /**
399
+ * Pairs every node in the tree with the element React rendered for it,
400
+ * and remembers what that element shows now, which is not always `node`.
401
+ */
402
+ private bind(node: Node, el: Element) {
403
+ this.nodes.set(node.key, el)
404
+ const now: Record<string, string> = {}
405
+ for (const a of Array.from(el.attributes))
406
+ if (a.name !== 'class' && a.name !== 'style') now[a.name] = a.value
407
+ this.prev.set(node.key, now)
408
+ node.children.forEach((c, i) => {
409
+ const child = el.children[i]
410
+ if (child) this.bind(c, child)
411
+ })
412
+ }
413
+
414
+ private write(node: Node) {
415
+ const el = this.nodes.get(node.key)
416
+ const prev = this.prev.get(node.key) ?? {}
417
+ if (el) {
418
+ for (const k in node.attrs)
419
+ if (prev[k] !== node.attrs[k]) el.setAttribute(k, node.attrs[k]!)
420
+ for (const k in prev) if (!(k in node.attrs)) el.removeAttribute(k)
421
+ }
422
+ this.prev.set(node.key, node.attrs)
423
+ for (const c of node.children) this.write(c)
424
+ }
425
+
426
+ setGaze(gaze?: Gaze | 'pointer') {
427
+ this.fixed = Array.isArray(gaze) ? (gaze as Gaze) : null
428
+ this.watch = gaze === 'pointer'
429
+ if (this.watch) listen()
430
+ if (this.reduced && this.fixed) {
431
+ ;[this.val.gx, this.val.gy] = this.fixed
432
+ this.paint()
433
+ }
434
+ }
435
+
436
+ /* each group starts on its own beat */
437
+ setExpression(name: Expression) {
438
+ if (name === this.state) return
439
+ const prev = this.state
440
+ this.state = name
441
+ this.enteredAt = this.clock
442
+ this.lookThing = false
443
+ this.queue = []
444
+ const tgt = EXPRESSIONS[name]
445
+ const ch = CHOREO[name] ?? {}
446
+ const delay = { ...DEF_DELAY, ...ch.delay }
447
+ const spr = { ...DEF_SPR, ...ch.spr }
448
+ if (this.reduced) {
449
+ this.snap()
450
+ return
451
+ }
452
+ for (const gr of Object.keys(GROUPS) as Group[]) {
453
+ const set: Key[1] = {}
454
+ for (const c of GROUPS[gr]) set[c] = tgt[c]
455
+ this.queue.push({
456
+ at: this.clock + delay[gr] / 1000,
457
+ set,
458
+ spr: { [gr]: spr[gr] },
459
+ })
460
+ }
461
+ for (const [t, set, s] of ch.keys ?? [])
462
+ this.queue.push({ at: this.clock + t / 1000 + 1e-4, set, spr: s })
463
+ this.queue.sort((a, b) => a.at - b.at)
464
+ // a startled face wakes with a double blink
465
+ if (name === 'surprised') {
466
+ this.blinkAt = this.clock + 0.75
467
+ this.doubleBlink = true
468
+ }
469
+ if (name === 'sleepy') this.nodAt = this.clock + rand(6, 8)
470
+ if (prev === 'sleepy') this.bumps = []
471
+ }
472
+
473
+ /** Straight to the rest pose of the current state, no motion. */
474
+ private snap() {
475
+ const rest = restPose(this.who, this.state, this.fixed ?? undefined)
476
+ for (const c of CHANNELS) {
477
+ this.base[c] = EXPRESSIONS[this.state][c]
478
+ this.val[c] = rest[c]
479
+ this.vel[c] = 0
480
+ }
481
+ this.lookThing = this.state === 'curious'
482
+ this.queue = []
483
+ this.bumps = []
484
+ this.blinkT = -1
485
+ this.blinkV = 0
486
+ this.out = zero()
487
+ this.paint()
488
+ }
489
+
490
+ private bump(ch: Channel, amt: number, a: number, h: number, r: number) {
491
+ this.bumps.push({ ch, amt, t0: this.clock, a, h, r })
492
+ }
493
+
494
+ step(dt: number) {
495
+ this.clock += dt
496
+ const t = this.clock
497
+ const age = t - this.enteredAt
498
+ const st = this.state
499
+ const face = FACES[this.who.face]
500
+ while (this.queue.length && this.queue[0]!.at <= t) {
501
+ const q = this.queue.shift()!
502
+ if (q.spr)
503
+ for (const gr of Object.keys(q.spr) as Group[]) {
504
+ const s = q.spr[gr]
505
+ if (s) for (const c of GROUPS[gr]) this.spr[c] = s
506
+ }
507
+ for (const c of Object.keys(q.set) as (Channel | 'look')[]) {
508
+ if (c === 'look') {
509
+ this.lookThing = true
510
+ continue
511
+ }
512
+ const v = q.set[c]!
513
+ this.base[c] = typeof v === 'function' ? v(this.val[c]) : v
514
+ }
515
+ }
516
+
517
+ const tgt = zero()
518
+ const out = zero()
519
+
520
+ // gaze: resting target, micro saccades, the thing, the pointer
521
+ let gx = this.base.gx
522
+ let gy = this.base.gy
523
+ if (this.lookThing) [gx, gy] = thingGaze(face, this.val.x, this.val.alt)
524
+ if (this.fixed) [gx, gy] = this.fixed
525
+ const still = st === 'sleepy' || st === 'bored'
526
+ if (!still && t >= this.saccAt) {
527
+ this.sacc = { gx: rand(-0.17, 0.17), gy: rand(-0.1, 0.1) }
528
+ this.saccAt = t + rand(0.8, 2.6)
529
+ }
530
+ if (!still) {
531
+ gx += this.sacc.gx
532
+ gy += this.sacc.gy
533
+ }
534
+ const w = ATTENTION[st]
535
+ const r = this.rect
536
+ if (pointer && r && w > 0) {
537
+ if (r.width > 0) {
538
+ const fx = VB[0] + ((pointer[0] - r.left) / r.width) * VB[2]
539
+ const fy = VB[1] + ((pointer[1] - r.top) / r.height) * VB[3]
540
+ const { baseY, eyeY, R } = frameOf(face)
541
+ const ex = 170 + this.val.x
542
+ const ey = eyeY + baseY - this.val.alt * R
543
+ const px = clamp((fx - ex) / 170, -1, 1)
544
+ const py = clamp((fy - ey) / 170, -1, 1)
545
+ gx += (px - gx) * w
546
+ gy += (py - gy) * w
547
+ }
548
+ }
549
+ tgt.gx = gx - this.base.gx
550
+ tgt.gy = gy - this.base.gy
551
+
552
+ // per-state life
553
+ const br =
554
+ st === 'sleepy'
555
+ ? 1.3
556
+ : st === 'bored'
557
+ ? 1.2
558
+ : st === 'attentive'
559
+ ? 2.6
560
+ : 2.1
561
+ const ba = st === 'sleepy' ? 0.018 : st === 'attentive' ? 0.004 : 0.007
562
+ out.sy += ba * Math.sin(t * br)
563
+ out.alt += (st === 'sleepy' ? 0.03 : 0.008) * Math.sin(t * br + 1.1)
564
+ if (st === 'happy') out.rot += 1.4 * Math.sin(t * 1.7)
565
+ if (st === 'angry') {
566
+ out.x += 7 * Math.sin(age * 42) * Math.exp(-age * 6)
567
+ out.x += 0.35 * Math.sin(t * 31)
568
+ }
569
+ if (st === 'excited') {
570
+ const ph = age % 1.5
571
+ if (ph < 0.9) {
572
+ const h = Math.max(0, Math.sin((ph / 0.45) * Math.PI))
573
+ out.alt += h * 0.17
574
+ out.sy += h * 0.045
575
+ out.rot += 2 * Math.sin(t * 9) * h
576
+ }
577
+ }
578
+ if (st === 'confused') {
579
+ tgt.gx += 0.45 * Math.sin(age * 1.1)
580
+ out.rot += 2.2 * Math.sin(age * 0.7)
581
+ }
582
+ if (st === 'bored') tgt.gx += 0.22 * Math.sin(t * 0.33)
583
+ if (st === 'sleepy' && t >= this.nodAt && age > 3) {
584
+ // nods back up, half opens its eyes, gives in again
585
+ this.bump('alt', 0.3, 0.14, 0.5, 1.6)
586
+ this.bump('lid', -0.5, 0.14, 0.45, 1.3)
587
+ this.bump('hair', 0.6, 0.12, 0.4, 1.2)
588
+ this.nodAt = t + rand(6.5, 9.5)
589
+ }
590
+ if ((st === 'sad' || st === 'bored') && Math.random() < dt / 7) {
591
+ this.bump('sy', -0.035, 0.5, 0.25, 0.9)
592
+ this.bump('alt', -0.05, 0.5, 0.25, 0.9)
593
+ }
594
+
595
+ // springs, two substeps
596
+ const h = dt / 2
597
+ for (const c of CHANNELS) {
598
+ const [om, ze] = this.spr[c]
599
+ const target = this.base[c] + tgt[c]
600
+ let x = this.val[c]
601
+ let v = this.vel[c]
602
+ for (let i = 0; i < 2; i++) {
603
+ v += (om * om * (target - x) - 2 * ze * om * v) * h
604
+ x += v * h
605
+ }
606
+ this.val[c] = x
607
+ this.vel[c] = v
608
+ }
609
+
610
+ this.bumps = this.bumps.filter((b) => {
611
+ const u = t - b.t0
612
+ if (u > b.a + b.h + b.r) return false
613
+ const e =
614
+ u < b.a
615
+ ? smooth(0, 1, u / b.a)
616
+ : u < b.a + b.h
617
+ ? 1
618
+ : 1 - smooth(0, 1, (u - b.a - b.h) / b.r)
619
+ out[b.ch] += b.amt * e
620
+ return true
621
+ })
622
+
623
+ // blinks
624
+ const bl = BLINK[st]
625
+ let blink = 0
626
+ if (bl && this.val.lid < 0.75) {
627
+ if (this.blinkT < 0 && t >= this.blinkAt) {
628
+ this.blinkT = 0
629
+ this.blinkDur = st === 'bored' ? 0.42 : 0.16
630
+ }
631
+ if (this.blinkT >= 0) {
632
+ this.blinkT += dt
633
+ const u = this.blinkT / this.blinkDur
634
+ if (u >= 1) {
635
+ this.blinkT = -1
636
+ if (this.doubleBlink) {
637
+ this.doubleBlink = false
638
+ this.blinkAt = t + 0.09
639
+ } else {
640
+ const k = this.who.persona.blink
641
+ this.blinkAt = t + rand(bl[0] * k, bl[1] * k)
642
+ }
643
+ } else blink = Math.sin(u * Math.PI)
644
+ }
645
+ } else if (!bl) this.blinkT = -1
646
+ this.blinkV = blink
647
+
648
+ // the trait lags behind the body a little
649
+ const R = frameOf(face).R
650
+ const avY = -(this.val.alt + out.alt) * R
651
+ const lom = 10
652
+ const lze = 0.32
653
+ this.lag.v +=
654
+ (lom * lom * (avY - this.lag.y) - 2 * lze * lom * this.lag.v) * dt
655
+ this.lag.y += this.lag.v * dt
656
+
657
+ this.out = out
658
+ this.paint()
659
+ }
660
+
661
+ private paint() {
662
+ const R = frameOf(FACES[this.who.face]).R
663
+ const pose = { blink: this.blinkV, expression: this.state } as Pose
664
+ for (const c of CHANNELS) pose[c] = this.val[c] + this.out[c]
665
+ pose.lag = this.reduced
666
+ ? 0
667
+ : clamp((this.lag.y + pose.alt * R) * 0.4, -16, 16)
668
+ this.write(draw(this.who, pose, this.opts))
669
+ }
670
+ }