@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/README.md +38 -0
- package/dist/animate.d.ts +58 -0
- package/dist/draw.d.ts +69 -0
- package/dist/identity.d.ts +55 -0
- package/dist/identity.js +398 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1700 -0
- package/dist/peek.d.ts +13 -0
- package/dist/svg.d.ts +33 -0
- package/dist/svg.js +1053 -0
- package/dist/tables.d.ts +258 -0
- package/package.json +70 -0
- package/src/animate.ts +670 -0
- package/src/draw.ts +849 -0
- package/src/identity.ts +147 -0
- package/src/index.ts +28 -0
- package/src/peek.tsx +98 -0
- package/src/svg.ts +94 -0
- package/src/tables.ts +414 -0
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
|
+
}
|