mochi-avatar 1.0.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.
Files changed (96) hide show
  1. package/BRAND.md +85 -0
  2. package/LICENSE.md +104 -0
  3. package/README.md +135 -0
  4. package/dist/canvas2d/avatar.d.ts +264 -0
  5. package/dist/canvas2d/avatar.d.ts.map +1 -0
  6. package/dist/canvas2d/avatar.js +811 -0
  7. package/dist/canvas2d/avatar.js.map +1 -0
  8. package/dist/canvas2d/face.d.ts +74 -0
  9. package/dist/canvas2d/face.d.ts.map +1 -0
  10. package/dist/canvas2d/face.js +298 -0
  11. package/dist/canvas2d/face.js.map +1 -0
  12. package/dist/canvas2d/paths.d.ts +13 -0
  13. package/dist/canvas2d/paths.d.ts.map +1 -0
  14. package/dist/canvas2d/paths.js +24 -0
  15. package/dist/canvas2d/paths.js.map +1 -0
  16. package/dist/characters/colourways.d.ts +78 -0
  17. package/dist/characters/colourways.d.ts.map +1 -0
  18. package/dist/characters/colourways.js +76 -0
  19. package/dist/characters/colourways.js.map +1 -0
  20. package/dist/characters/index.d.ts +8 -0
  21. package/dist/characters/index.d.ts.map +1 -0
  22. package/dist/characters/index.js +8 -0
  23. package/dist/characters/index.js.map +1 -0
  24. package/dist/characters/mochi.d.ts +19 -0
  25. package/dist/characters/mochi.d.ts.map +1 -0
  26. package/dist/characters/mochi.js +66 -0
  27. package/dist/characters/mochi.js.map +1 -0
  28. package/dist/core/colour.d.ts +25 -0
  29. package/dist/core/colour.d.ts.map +1 -0
  30. package/dist/core/colour.js +37 -0
  31. package/dist/core/colour.js.map +1 -0
  32. package/dist/core/envelope.d.ts +153 -0
  33. package/dist/core/envelope.d.ts.map +1 -0
  34. package/dist/core/envelope.js +141 -0
  35. package/dist/core/envelope.js.map +1 -0
  36. package/dist/core/geometry.d.ts +106 -0
  37. package/dist/core/geometry.d.ts.map +1 -0
  38. package/dist/core/geometry.js +157 -0
  39. package/dist/core/geometry.js.map +1 -0
  40. package/dist/core/idle.d.ts +163 -0
  41. package/dist/core/idle.d.ts.map +1 -0
  42. package/dist/core/idle.js +262 -0
  43. package/dist/core/idle.js.map +1 -0
  44. package/dist/core/layout.d.ts +142 -0
  45. package/dist/core/layout.d.ts.map +1 -0
  46. package/dist/core/layout.js +172 -0
  47. package/dist/core/layout.js.map +1 -0
  48. package/dist/core/lens.d.ts +47 -0
  49. package/dist/core/lens.d.ts.map +1 -0
  50. package/dist/core/lens.js +67 -0
  51. package/dist/core/lens.js.map +1 -0
  52. package/dist/core/looks.d.ts +77 -0
  53. package/dist/core/looks.d.ts.map +1 -0
  54. package/dist/core/looks.js +168 -0
  55. package/dist/core/looks.js.map +1 -0
  56. package/dist/core/motion.d.ts +168 -0
  57. package/dist/core/motion.d.ts.map +1 -0
  58. package/dist/core/motion.js +527 -0
  59. package/dist/core/motion.js.map +1 -0
  60. package/dist/core/mouth.d.ts +93 -0
  61. package/dist/core/mouth.d.ts.map +1 -0
  62. package/dist/core/mouth.js +92 -0
  63. package/dist/core/mouth.js.map +1 -0
  64. package/dist/core/plain.d.ts +23 -0
  65. package/dist/core/plain.d.ts.map +1 -0
  66. package/dist/core/plain.js +63 -0
  67. package/dist/core/plain.js.map +1 -0
  68. package/dist/core/spec.d.ts +156 -0
  69. package/dist/core/spec.d.ts.map +1 -0
  70. package/dist/core/spec.js +236 -0
  71. package/dist/core/spec.js.map +1 -0
  72. package/dist/core/spring.d.ts +53 -0
  73. package/dist/core/spring.d.ts.map +1 -0
  74. package/dist/core/spring.js +68 -0
  75. package/dist/core/spring.js.map +1 -0
  76. package/dist/core/vocabulary.d.ts +182 -0
  77. package/dist/core/vocabulary.d.ts.map +1 -0
  78. package/dist/core/vocabulary.js +66 -0
  79. package/dist/core/vocabulary.js.map +1 -0
  80. package/dist/element/dough-avatar.d.ts +34 -0
  81. package/dist/element/dough-avatar.d.ts.map +1 -0
  82. package/dist/element/dough-avatar.js +189 -0
  83. package/dist/element/dough-avatar.js.map +1 -0
  84. package/dist/element/index.d.ts +3 -0
  85. package/dist/element/index.d.ts.map +1 -0
  86. package/dist/element/index.js +35 -0
  87. package/dist/element/index.js.map +1 -0
  88. package/dist/index.d.ts +38 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +42 -0
  91. package/dist/index.js.map +1 -0
  92. package/dist/svg/silhouette.d.ts +97 -0
  93. package/dist/svg/silhouette.d.ts.map +1 -0
  94. package/dist/svg/silhouette.js +143 -0
  95. package/dist/svg/silhouette.js.map +1 -0
  96. package/package.json +90 -0
@@ -0,0 +1,811 @@
1
+ /**
2
+ * The mochi, drawn.
3
+ *
4
+ * This is the composition root for the rig: it owns time and state, and defers
5
+ * every shape to the pure modules beside it. The layer order is the one the
6
+ * architecture fixes -- idle, then expression, then the mouth, always last.
7
+ * Nothing above the mouth may write to it, because a gesture that holds her
8
+ * jaw shut while audio is still playing reads as broken rather than as angry.
9
+ *
10
+ * The silhouette is kept as a Path2D and used for BOTH filling and hit testing,
11
+ * so "only what you can see takes the mouse" is true by construction rather
12
+ * than by two pieces of geometry agreeing. Everything drawn is clipped to it.
13
+ */
14
+ import { clamp01, clampSigned, } from '../core/vocabulary.js';
15
+ import {} from '../core/spec.js';
16
+ import { PLAIN } from '../core/plain.js';
17
+ import { BREATHING_UNITS, FEET_FROM_TOP, SQUASH_LIMIT, feetY, fitToCanvas, layoutFor, } from '../core/layout.js';
18
+ import { driftAt, IdleLayer } from '../core/idle.js';
19
+ import { blendLook } from '../core/looks.js';
20
+ import { BUILT_IN_MOTIONS, poseAt, progress, } from '../core/motion.js';
21
+ import { Spring } from '../core/spring.js';
22
+ import { paintCheeks, paintEyes, paintMouth } from './face.js';
23
+ import { toPath } from './paths.js';
24
+ import { domeOutline, placeFeature, squashed, } from '../core/geometry.js';
25
+ const NEUTRAL_SIGNAL = { emotion: 'neutral', intensity: 0 };
26
+ /** How fast the gaze follows the cursor. A hard snap reads as a machine. */
27
+ const GAZE_TIME_CONSTANT_MS = 120;
28
+ /**
29
+ * How far a full `turn` slides her features, as a fraction of her half-width.
30
+ *
31
+ * Under 1 deliberately. At 1 a feature sits on the outline itself, and past the
32
+ * edge the clip stops reading as a turn and starts reading as a face coming
33
+ * off. 0.62 puts the far eye most of the way out of sight while the near one is
34
+ * still comfortably inside her.
35
+ */
36
+ const TURN_REACH = 0.62;
37
+ /**
38
+ * How fast a poke fades, per SECOND.
39
+ *
40
+ * Per second rather than per frame. The old `impulse *= 0.86` made the length
41
+ * of a poke a property of the display: the same tap lasted twice as long on a
42
+ * 120Hz panel and stretched out again whenever the tab was throttled, while the
43
+ * spring and the gaze beside it were already time-based. The value is the exact
44
+ * continuous equivalent of that per-frame constant at 60Hz -- `0.86^60`, so at
45
+ * the rate it was tuned on she decays as she always did.
46
+ */
47
+ const IMPULSE_DECAY_PER_S = -Math.log(0.86) * 60;
48
+ /**
49
+ * How much of the drift survives sleep.
50
+ *
51
+ * A trace rather than none, for the reason the breath is kept: a companion who
52
+ * stops moving altogether reads as a crash, which is the one thing the resting
53
+ * state must not look like. A fifth is enough to see if you watch and not
54
+ * enough to look awake.
55
+ */
56
+ const ASLEEP_DRIFT = 0.2;
57
+ /**
58
+ * How much deeper she breathes asleep, against the waking amplitude.
59
+ *
60
+ * ## Why sleep needs its own number at all
61
+ *
62
+ * The breath was made one-sided and quieter to stop it stretching her head into
63
+ * a point, and awake that is right — there is a blink, there is drift, there is
64
+ * a face doing things, and the breath only has to keep her from looking frozen.
65
+ *
66
+ * Asleep there is none of that. The eyes are held shut, `ASLEEP_DRIFT` keeps a
67
+ * fifth of the drift, and the breath is the ONLY thing left moving. At the
68
+ * waking amplitude that came to 2.0px of width and 1.5px of height on a 94px
69
+ * body across 3.4 seconds — measured, not guessed — which is not a quiet breath,
70
+ * it is an invisible one. She read as switched off, which is the exact reading
71
+ * the kept breath exists to prevent.
72
+ *
73
+ * ## Why 2, and not more
74
+ *
75
+ * Twice the waking breath puts the asleep excursion at 0.048, on a `sleepy`
76
+ * resting pose of 0.09, so the widest frame is 0.138. The pose plus breath
77
+ * already peaked at 0.135 before any of this session's changes and was fine
78
+ * there; past that lies the 1.24-times-her-width spread that `looks.ts` records
79
+ * as reading like a puddle. So this restores the movement without reopening the
80
+ * defect the sleeping pose was cut for.
81
+ *
82
+ * A real body breathes deeper asleep than awake, so the direction is not a
83
+ * concession to visibility — it is what sleep looks like.
84
+ */
85
+ export const ASLEEP_BREATH_GAIN = 2;
86
+ /**
87
+ * And slower, by half again.
88
+ *
89
+ * Respiration drops in sleep, and a slow deep swell reads as sleeping where a
90
+ * quick shallow one reads as a smaller version of awake. It also helps the eye:
91
+ * the same excursion spread over 5.1 seconds is easier to see as breathing than
92
+ * the same distance travelled in 3.4.
93
+ */
94
+ const ASLEEP_BREATH_SLOWER = 1.5;
95
+ /** No drift at all — the tuner wants her still. See `setIdle`. */
96
+ const NO_DRIFT = { lean: 0, shift: 0, lift: 0 };
97
+ export class DoughAvatar {
98
+ ctx;
99
+ kind = 'mochi';
100
+ caps = {
101
+ presetExpressions: true,
102
+ customExpressions: false,
103
+ licenseMetadata: false,
104
+ supportsPhysics: false,
105
+ // TRUE now, because there is a library: `BUILT_IN_MOTIONS`. It was false
106
+ // while `playMotion` was a no-op, which is the honest pairing -- a flag
107
+ // claiming a capability with nothing behind it makes every call report
108
+ // success over a face that does not move. `playMotion` says so out loud
109
+ // for a name it does not have, which is the check that survives.
110
+ supportsMotions: true,
111
+ // The lens primitive could distinguish five vowels, but nothing emits
112
+ // phoneme timings yet -- the cloud speech-to-speech path carries none. A
113
+ // `true` here would route callers down the precise path to a mouth driven
114
+ // by weights nobody sends, which is worse than the honest envelope.
115
+ visemes: false,
116
+ };
117
+ face;
118
+ sizePercent;
119
+ idleLayer;
120
+ squashSpring = new Spring(0);
121
+ /** The clip playing, and when it began. Null start = begins on the next frame. */
122
+ motion = null;
123
+ motionStartedAt = null;
124
+ cssWidth = 0;
125
+ cssHeight = 0;
126
+ /** How far into the canvas she stands. See `setFeet`. */
127
+ feetFromTop = FEET_FROM_TOP;
128
+ /** Eyes shut and not listening. See `setAsleep`. */
129
+ asleep = false;
130
+ /** Whether a voice is coming out of her right now. See `setSpeaking`. */
131
+ speaking = false;
132
+ pixelRatio = 1;
133
+ disposed = false;
134
+ mouthOpen = 0;
135
+ idle = true;
136
+ /**
137
+ * Somebody asked their system for less movement, and this is where it lands.
138
+ *
139
+ * The one flag, because there are two kinds of motion here and both are
140
+ * ambient: the idle layer — breath, drift, the blink schedule — and any clip
141
+ * that LOOPS. A looping clip is ambient by definition; it stands for a state
142
+ * rather than answering an event, which is what `stopMotion`'s own comment
143
+ * says about `sway` running from the first turn to the end of the session.
144
+ *
145
+ * One-shots are left alone deliberately. `nod`, `hop` and `turn` are replies
146
+ * — she was spoken to, or she was picked up — and a companion who answers
147
+ * nothing is a picture, not a quieter companion. The preference asks for less
148
+ * movement, not for no feedback.
149
+ */
150
+ reducedMotion = false;
151
+ emotion = NEUTRAL_SIGNAL;
152
+ emotionExpiresAt = null;
153
+ /** A hold requested before the first frame, when there was no clock to add it to. */
154
+ pendingHoldMs = null;
155
+ /** When the blink schedule was last armed against a real timestamp. */
156
+ idleSeededAt = null;
157
+ impulse = 0;
158
+ gaze = { x: 0, y: 0 };
159
+ gazeTarget = { x: 0, y: 0 };
160
+ lastRenderMs = null;
161
+ /** This frame's outline. Filled and hit-tested, so the two cannot disagree. */
162
+ silhouette = null;
163
+ constructor(ctx, options) {
164
+ this.ctx = ctx;
165
+ this.face = options.face ?? PLAIN;
166
+ this.sizePercent = options.size;
167
+ this.idleLayer = new IdleLayer(0, options.random);
168
+ }
169
+ /**
170
+ * Tell the rig how big it is, in CSS pixels.
171
+ *
172
+ * Explicit rather than read from the canvas element, because the backend has
173
+ * no element -- it has a context. That is what lets the same class render in
174
+ * the browser, in the tuner, and in a test against a headless rasteriser.
175
+ */
176
+ resize(cssWidth, cssHeight, pixelRatio = 1) {
177
+ // FINITE, not merely non-negative. `Math.max(0, NaN)` is `NaN` and
178
+ // `Math.max(0, Infinity)` is `Infinity`, and `Infinity > 0` passes the ratio
179
+ // check -- so all three fields could hold a value that is not a length, and
180
+ // `lookAt` a few lines below already refuses exactly that. This is the same
181
+ // rule applied to the geometry.
182
+ //
183
+ // UNTESTED, deliberately, and worth saying so. Against `@napi-rs/canvas` a
184
+ // NaN or Infinity size is indistinguishable from the guarded behaviour:
185
+ // both draw nothing, both report no hit, and both recover completely on the
186
+ // next good resize. Chromium may well differ -- these values reach a real
187
+ // `setTransform` there -- but no test here can tell the two apart, so
188
+ // nothing pins this and a test asserting otherwise would be theatre.
189
+ this.cssWidth = finiteOrZero(cssWidth);
190
+ this.cssHeight = finiteOrZero(cssHeight);
191
+ this.pixelRatio = Number.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : 1;
192
+ // Stale geometry would keep reporting hits on a shape no longer drawn.
193
+ this.silhouette = null;
194
+ }
195
+ /**
196
+ * Resize her without rebuilding the rig.
197
+ *
198
+ * A setter because the window can be resized while she is awake and mid
199
+ * sentence; recreating the backend would drop the session's mouth state and
200
+ * restart the blink schedule.
201
+ */
202
+ /**
203
+ * How far into the canvas she stands.
204
+ *
205
+ * Set by main during a drag: against the top of the display the window can
206
+ * rise no further, so she rises inside it instead. See `dragTo`.
207
+ */
208
+ setFeet(feetFromTop) {
209
+ /*
210
+ Refused OUT LOUD, because a refusal here is invisible and enormous.
211
+
212
+ This returned silently. The value it guards is where she is painted, and
213
+ every other thing in her window — the halo, the bubble's anchor, the chip,
214
+ the rectangle that decides whether a click reaches her — is measured from
215
+ a SEPARATE copy of the same number in `face.ts`. So a refused update did
216
+ not fail: it left the two copies describing bodies hundreds of pixels
217
+ apart, and the window went on drawing, cheerfully, with her face in one
218
+ place and everything about her in another.
219
+
220
+ Warned rather than thrown: this is reached from the render loop, and an
221
+ exception there stops the loop rather than reporting it. Once per bad
222
+ value is not once per frame — the caller only sets this when the layout
223
+ changes.
224
+ */
225
+ if (!Number.isFinite(feetFromTop) || feetFromTop <= 0) {
226
+ console.warn(`[rig] refusing a standing height of ${String(feetFromTop)}`);
227
+ return;
228
+ }
229
+ this.feetFromTop = feetFromTop;
230
+ }
231
+ setSizePercent(percent) {
232
+ if (percent === this.sizePercent)
233
+ return;
234
+ this.sizePercent = percent;
235
+ // The same reason `resize()` clears it: the cached path is the shape she was
236
+ // last PAINTED as, and between this call and the next frame it describes a
237
+ // body of the previous size. A click in the gap would be answered from
238
+ // geometry nothing is drawing. Null means "no hits until the next render",
239
+ // which sends the click to the desktop rather than to the wrong place.
240
+ this.silhouette = null;
241
+ }
242
+ /**
243
+ * Recolour her, or reshape her, without rebuilding the rig.
244
+ *
245
+ * The same argument as `setSizePercent`: the window can be open and mid
246
+ * sentence when a theme changes, and recreating the backend would drop the
247
+ * session's mouth state and restart the blink schedule. The silhouette is
248
+ * dropped because a face carries geometry as well as colour — a themed face
249
+ * changes only the palette today, but nothing in this signature promises
250
+ * that, and a cached hit region from the previous shape is exactly the bug
251
+ * `setSizePercent` already had.
252
+ */
253
+ setFace(next) {
254
+ this.face = next;
255
+ this.silhouette = null;
256
+ }
257
+ setMouthOpen(value) {
258
+ this.mouthOpen = clamp01(value);
259
+ }
260
+ /** No-op, per caps.visemes === false. Explicitly does not fall back to driving
261
+ * mouthOpen from the loudest vowel: a caller that sent visemes and saw the
262
+ * mouth move would conclude the precise path works here. */
263
+ setVisemes(_weights) { }
264
+ setEmotion(signal) {
265
+ this.emotion = { ...signal, intensity: clamp01(signal.intensity) };
266
+ const hold = signal.holdMs;
267
+ const valid = typeof hold === 'number' && Number.isFinite(hold) && hold >= 0 ? hold : null;
268
+ if (valid === null) {
269
+ this.emotionExpiresAt = null;
270
+ this.pendingHoldMs = null;
271
+ return;
272
+ }
273
+ // ALWAYS deferred to the next frame, never measured from the last one.
274
+ //
275
+ // Before the first frame there is obviously no clock to add the hold to --
276
+ // an earlier version added it to zero and the emotion expired on the very
277
+ // first render, because a RAF timestamp is already well past any plausible
278
+ // hold. But `lastRenderMs` is the wrong base at every other moment too:
279
+ // rendering is driven by requestAnimationFrame, which the browser throttles
280
+ // to a crawl for a hidden or occluded window, and stops entirely for an
281
+ // unfocused one on some platforms. A 1400ms smile set while she was hidden
282
+ // was therefore stamped with a timestamp seconds or minutes old, and
283
+ // expired on the first frame after she came back -- the reaction the
284
+ // lifecycle set it for never appeared.
285
+ //
286
+ // Deferring costs nothing: the next `render` resolves it against that
287
+ // frame's own timestamp, which is the moment she is actually seen.
288
+ this.pendingHoldMs = valid;
289
+ this.emotionExpiresAt = null;
290
+ }
291
+ /**
292
+ * Start a motion, or say nothing happened.
293
+ *
294
+ * An unknown name is reported rather than ignored: a motion that silently
295
+ * does nothing is indistinguishable from one that played and was too subtle
296
+ * to see, and the second is what somebody will assume.
297
+ */
298
+ playMotion(name) {
299
+ // `hasOwn` first: a plain index signature resolves inherited names, so
300
+ // `playMotion('toString')` found a "clip", skipped the warning below,
301
+ // replaced whatever was playing, and then vanished on the next frame
302
+ // because its duration was undefined.
303
+ const clip = Object.hasOwn(BUILT_IN_MOTIONS, name) ? BUILT_IN_MOTIONS[name] : undefined;
304
+ if (clip === undefined) {
305
+ console.warn(`[rig] no motion called ${JSON.stringify(name)}`);
306
+ return;
307
+ }
308
+ /*
309
+ REFUSED HERE rather than at the two call sites that start one.
310
+
311
+ `face.ts` starts an ambient loop from `repose.step` and starts `sway` when
312
+ she is thinking, and a third site is one feature away. A rule applied at
313
+ the door every caller already goes through cannot be forgotten by the next
314
+ one — the same argument `discardWrite` and `finishDeletion` make about
315
+ guarding both paths into one `rmSync`.
316
+
317
+ Anything already looping is cleared, so turning the preference on mid
318
+ session stops her rather than waiting for a state change that may not
319
+ come: a loop never ends by itself.
320
+ */
321
+ if (this.reducedMotion && clip.loop) {
322
+ this.motion = null;
323
+ this.motionStartedAt = null;
324
+ return;
325
+ }
326
+ this.motion = clip;
327
+ this.motionStartedAt = null;
328
+ }
329
+ /**
330
+ * Honour `prefers-reduced-motion`, or stop honouring it.
331
+ *
332
+ * Separate from `setIdle`, which is the TUNER's switch: that one exists so a
333
+ * face can be measured against a still body, and it is called by the shelf's
334
+ * preview tiles. Folding the two together would mean a preview could not be
335
+ * told apart from an accessibility preference, and one of them wants the
336
+ * blink schedule re-armed on the way out while the other does not care.
337
+ */
338
+ setReducedMotion(on) {
339
+ if (on === this.reducedMotion)
340
+ return;
341
+ this.reducedMotion = on;
342
+ // A loop that is already running has to be cleared here too: the check in
343
+ // `playMotion` only sees clips that have not started yet.
344
+ if (on && this.motion?.loop === true)
345
+ this.stopMotion();
346
+ // Re-armed rather than resumed, for the reason `setIdle` gives: seeding the
347
+ // blink schedule from a stale timestamp puts the first blink in the past.
348
+ if (!on)
349
+ this.idleSeededAt = null;
350
+ }
351
+ /**
352
+ * Stop whatever is playing, and let the springs settle her back.
353
+ *
354
+ * The missing half of `playMotion`, and it is only missing for one-shots. A
355
+ * clip with `loop: true` never ends by itself — `progress` wraps it forever
356
+ * rather than returning null — so `sway` was started on the first turn and
357
+ * played for the rest of the session. Nobody reported it because a slow lean
358
+ * looks like idle motion, which is exactly why it is worth a method: a state
359
+ * whose animation outlives it is a state that has stopped meaning anything.
360
+ *
361
+ * Clearing the clip is enough. The pose layer simply stops contributing, and
362
+ * `spring.ts` carries her back rather than snapping — the same path a
363
+ * finished one-shot already takes.
364
+ */
365
+ stopMotion() {
366
+ this.motion = null;
367
+ this.motionStartedAt = null;
368
+ }
369
+ lookAt(nx, ny) {
370
+ // Non-finite leaves the previous target alone. Coercing to 0 would snap her
371
+ // gaze to a corner on one bad sample.
372
+ if (!Number.isFinite(nx) || !Number.isFinite(ny))
373
+ return;
374
+ // SIGNED, clamped to -1..1 — see `AvatarBackend.lookAt`. This used to read
375
+ // `(clamp01(n) - 0.5) * 2`, which took 0..1 while every caller sent -1..1,
376
+ // so centre landed hard up-left and the left half of the screen was one
377
+ // point. `setAsleep` and the reset path assign this field directly in the
378
+ // signed range, so those two disagreed with this one about where centre is.
379
+ this.gazeTarget = { x: clampSigned(nx), y: clampSigned(ny) };
380
+ }
381
+ /**
382
+ * Asleep, or awake.
383
+ *
384
+ * Only the drawing. The microphone is the renderer's to close and main's to
385
+ * decide about — this makes her LOOK asleep, which is the half that has to be
386
+ * true on screen for the other half to be believable.
387
+ */
388
+ setAsleep(on) {
389
+ if (on === this.asleep)
390
+ return;
391
+ this.asleep = on;
392
+ // Her gaze goes back to centre rather than staying wherever it was left
393
+ // following a cursor. Eyes shut and still tracking is uncanny in a way it
394
+ // takes a while to name.
395
+ if (on)
396
+ this.gazeTarget = { x: 0, y: 0 };
397
+ }
398
+ /**
399
+ * Whether sound is actually coming out of her — the analyser's answer.
400
+ *
401
+ * ## Why the rig needs to know
402
+ *
403
+ * So that she cannot talk with her eyes shut. `asleep` held `blink: 1` for the
404
+ * whole of the state, and there were real paths to her speaking inside it:
405
+ * `voice:config` handed back a greeting whenever the `speak_first` grant was
406
+ * on, without consulting rest, and a session is re-opened every hour (§53) —
407
+ * each one a NEW session, so each one greeted. Nobody saw it because it
408
+ * happened to an empty room.
409
+ *
410
+ * Both halves are fixed and both are needed. Main no longer asks for the
411
+ * greeting while she rests, which is the cause; this is the property, and it
412
+ * holds for any path anybody adds later. A mouth moving under closed eyes is
413
+ * the thing to make impossible, not the thing to remember to avoid.
414
+ *
415
+ * ## The analyser, not a frame
416
+ *
417
+ * `face.ts` passes `envelope.speaking`, which is measured from her own audio.
418
+ * `output_audio_buffer.started` is a promise of audio and §64 measured it
419
+ * arriving followed by silence, so it would open her eyes on a turn where
420
+ * nothing was ever said.
421
+ */
422
+ setSpeaking(on) {
423
+ this.speaking = on;
424
+ }
425
+ setIdle(on) {
426
+ if (on === this.idle)
427
+ return;
428
+ this.idle = on;
429
+ // Re-armed at the next render rather than here, because here there may be
430
+ // no clock yet -- and `idle.ts` is explicit that seeding the blink schedule
431
+ // from zero puts the first blink in the past whenever startup took longer
432
+ // than the minimum gap, so she blinks the instant she appears. Porting that
433
+ // module while passing it a zero is exactly the mistake its comment warns
434
+ // about.
435
+ if (on)
436
+ this.idleSeededAt = null;
437
+ else
438
+ this.gazeTarget = { x: 0, y: 0 };
439
+ }
440
+ /** A squash impulse — a poke, or a reaction. The spring resolves it. */
441
+ poke(amount = -0.3) {
442
+ if (Number.isFinite(amount))
443
+ this.impulse = amount;
444
+ }
445
+ hitTest(x, y) {
446
+ if (this.disposed || this.silhouette === null)
447
+ return false;
448
+ // The transform is restored first, because a Path2D carries no transform of
449
+ // its own: it is transformed by whatever matrix is current when it is used.
450
+ // Filling and hit testing must therefore agree on the matrix, or they are
451
+ // asking about two different shapes.
452
+ this.applyTransform();
453
+ // Device pixels, NOT the CSS pixels this method is given.
454
+ //
455
+ // Measured, not assumed: `isPointInPath` treats its point as being in the
456
+ // canvas coordinate space UNAFFECTED by the current transformation, while
457
+ // the path itself IS transformed by it. Probed against a real rasteriser at
458
+ // dpr 2 -- a point inside the path's own coordinates but outside its
459
+ // painted footprint returns false, and one outside its coordinates but
460
+ // inside the footprint returns true. So the answer tracks painted pixels,
461
+ // which is exactly the contract, provided the point arrives in their space.
462
+ //
463
+ // The two readings coincide at dpr 1, so this is invisible on a non-Retina
464
+ // display and shifts the whole clickable region by a factor of two on every
465
+ // other. The dpr-2 test is what stops it coming back.
466
+ const ratio = this.pixelRatio;
467
+ return this.ctx.isPointInPath(this.silhouette, x * ratio, y * ratio);
468
+ }
469
+ render(now) {
470
+ if (this.disposed || this.cssWidth === 0 || this.cssHeight === 0)
471
+ return;
472
+ // A non-finite timestamp is DROPPED, and the last good one is kept.
473
+ // Storing it poisoned the clock permanently: every later `dt` was NaN, the
474
+ // gaze and the spring went to NaN with it, emotions stopped expiring
475
+ // because `now >= expiresAt` is false against NaN, and nothing could bring
476
+ // any of it back. One bad sample cost the rest of the session.
477
+ if (!Number.isFinite(now))
478
+ return;
479
+ const dt = this.lastRenderMs === null ? 1 / 60 : Math.max(0, (now - this.lastRenderMs) / 1000);
480
+ this.lastRenderMs = now;
481
+ // The first real timestamp is where anything deferred gets resolved. Both
482
+ // of these were previously computed against zero, which is not a time.
483
+ if (this.idle && this.idleSeededAt === null) {
484
+ this.idleLayer.reset(now);
485
+ this.idleSeededAt = now;
486
+ }
487
+ if (this.pendingHoldMs !== null) {
488
+ this.emotionExpiresAt = now + this.pendingHoldMs;
489
+ this.pendingHoldMs = null;
490
+ }
491
+ if (this.emotionExpiresAt !== null && now >= this.emotionExpiresAt) {
492
+ this.emotion = NEUTRAL_SIGNAL;
493
+ this.emotionExpiresAt = null;
494
+ }
495
+ /**
496
+ * Asleep is a POSE, and it outranks whatever expression she is carrying.
497
+ *
498
+ * `sleepy` was drawn for exactly this state — `looks.ts` says `mouthAlpha`
499
+ * exists for its resting pose and nothing else — and for a while nothing
500
+ * wore it: rest shut the eyes and left the body in whatever look was
501
+ * current, so a sleeping mochi was an awake one with its eyes closed.
502
+ *
503
+ * Rendered HERE rather than by setting the emotion on the way down, and
504
+ * that distinction is the whole of it. `face.ts` clears her chosen
505
+ * expression when she rests, because an expression must not outlive the
506
+ * presence it belonged to — a character told to look `angry` used to wake
507
+ * up angry into a session that had never heard of it. The rule arrived
508
+ * with `set_expression` and outlived it. If sleep also
509
+ * ASSIGNED `sleepy`, the two would be writing to the same slot, and waking
510
+ * would have to guess which of them put it there. The rig knows it is
511
+ * asleep; it does not need to be told twice.
512
+ *
513
+ * Tied to `asleep` rather than to `shutEyes`, so a voice coming out of a
514
+ * sleeping face keeps the drowsy posture while the eyes open (see below).
515
+ * The mouth is safe: `paintMouth` takes the LOUDER of `mouthAlpha` and
516
+ * whatever is driving the mouth, so anything making a sound wins.
517
+ */
518
+ const look = this.asleep
519
+ ? blendLook('sleepy', 1)
520
+ : blendLook(this.emotion.emotion, this.emotion.intensity);
521
+ /**
522
+ * Asleep: eyes shut, and the breath left running.
523
+ *
524
+ * `blink: 1` is a held blink, which `paintEyes` floors at a hairline rather
525
+ * than closing entirely — an eye that vanishes reads as a dropped frame,
526
+ * and the hairline is what makes it read as shut instead.
527
+ *
528
+ * The BREATH is deliberately kept. She is asleep, not switched off, and a
529
+ * companion who stops moving altogether reads as a crash — which is the one
530
+ * thing this state must not look like, since the whole point of it is that
531
+ * she is fine and simply not listening.
532
+ *
533
+ * ## Unless she is SPEAKING, in which case her eyes open first
534
+ *
535
+ * A held blink for the whole of `asleep` meant a voice could come out of a
536
+ * face with its eyes shut, and there were live paths to exactly that — see
537
+ * `setSpeaking`. The eyes are the first thing to move, so the ordinary
538
+ * blink schedule is what she gets while there is sound, and the shut lids
539
+ * come back the moment there is not.
540
+ *
541
+ * She is still `asleep` while this happens, and deliberately so: this does
542
+ * not wake her, mute anything, or change what the halo says. It refuses one
543
+ * specific impossible picture and nothing else.
544
+ */
545
+ /*
546
+ The DRIFT, added to whatever a clip is doing rather than replacing it.
547
+
548
+ This is the layer that separates "alive" from "a picture that sometimes
549
+ moves". Breathing was the only thing running between gestures, so she was
550
+ a still image punctuated by clips -- and the clips were then blamed for
551
+ being coarse, when what was coarse was the silence around them.
552
+
553
+ Additive, so a hop happens on top of a body that was already shifting its
554
+ weight, and so nothing here has to know whether a clip is playing.
555
+ `idle` gates it for the same reason it gates the breath: the tuner wants
556
+ her still, and a still she can be measured against.
557
+ */
558
+ const stirring = this.idle && !this.reducedMotion;
559
+ const drift = stirring ? driftAt(now, this.asleep ? ASLEEP_DRIFT : 1) : NO_DRIFT;
560
+ const shutEyes = this.asleep && !this.speaking;
561
+ const pose = shutEyes
562
+ ? // The asleep breath is gated by the PREFERENCE only, never by `idle`.
563
+ // `idle` is the tuner's switch and this branch deliberately kept
564
+ // breathing through it — "asleep: eyes shut, and the breath left
565
+ // running", above. Widening the gate here would have made a preview
566
+ // tile stop breathing as a side effect of an accessibility fix.
567
+ {
568
+ blink: 1,
569
+ // Slower asleep — see `ASLEEP_BREATH_SLOWER`. The AMPLITUDE is applied
570
+ // where every other squash contribution is summed, below.
571
+ breath: this.reducedMotion
572
+ ? 0
573
+ : this.idleLayer.pose(now, this.face.breathMs * ASLEEP_BREATH_SLOWER).breath,
574
+ }
575
+ : stirring
576
+ ? this.idleLayer.pose(now, this.face.breathMs)
577
+ : { blink: 0, breath: 0 };
578
+ // Layer 2, motion. The layer order is the ORDER OF THESE LINES:
579
+ // idle feeds the squash target below, motion adds to it, the expression
580
+ // (`look`) is already in it, and the mouth is written last in `paint`.
581
+ // A motion cannot reach the mouth because its vocabulary has no channel
582
+ // for one -- see `MOTION_CHANNELS`.
583
+ const moved = this.motionPose(now);
584
+ // Layer 1, idle: breath feeds the same squash channel a poke does, so they
585
+ // compose instead of fighting over the body scale.
586
+ this.impulse *= Math.exp(-IMPULSE_DECAY_PER_S * dt);
587
+ /*
588
+ Deeper asleep, for the reason `ASLEEP_BREATH_GAIN` gives: with the eyes
589
+ held shut and the drift at a fifth, the breath is the only thing left
590
+ moving, and at the waking amplitude it moved her 2px.
591
+
592
+ Gated on `shutEyes` rather than on `asleep`, so a voice coming out of a
593
+ sleeping face — which opens her eyes, see above — breathes at the waking
594
+ depth while it does. Sleeping and speaking should not look like sleeping.
595
+ */
596
+ const breathAmp = this.face.breathAmp * (shutEyes ? ASLEEP_BREATH_GAIN : 1);
597
+ const target = look.squash + (moved.squash ?? 0) + pose.breath * breathAmp + this.impulse;
598
+ const settled = this.squashSpring.step(dt, target, {
599
+ stiffness: this.face.stiffness,
600
+ damping: this.face.damping,
601
+ });
602
+ const squash = Math.max(-SQUASH_LIMIT, Math.min(SQUASH_LIMIT, settled));
603
+ // Snapped, not merely drawn clamped. Letting the spring run on past the
604
+ // wall while only the painting is limited makes her STICK at the limit on
605
+ // the way back, for however long the hidden position takes to unwind --
606
+ // which looks like the animation hanging. `snap` also kills the velocity,
607
+ // which is what hitting a wall does.
608
+ if (squash !== settled)
609
+ this.squashSpring.snap(squash);
610
+ this.advanceGaze(dt, look, moved);
611
+ this.paint(look, pose.blink, squash, moved, drift);
612
+ }
613
+ /**
614
+ * Where the playing clip is now, or nothing.
615
+ *
616
+ * The start is taken on the first frame AFTER `playMotion` rather than in
617
+ * it: `performance.now()` at the moment of the call can be most of a frame
618
+ * before the frame that draws it, so the first visible frame would already
619
+ * be part-way in. Clearing the clip when it finishes is what stops a
620
+ * one-shot holding its last pose forever.
621
+ */
622
+ motionPose(now) {
623
+ if (this.motion === null)
624
+ return {};
625
+ this.motionStartedAt ??= now;
626
+ const at = progress(this.motion, now - this.motionStartedAt);
627
+ if (at === null) {
628
+ this.motion = null;
629
+ this.motionStartedAt = null;
630
+ return {};
631
+ }
632
+ return poseAt(this.motion, at);
633
+ }
634
+ dispose() {
635
+ if (this.disposed)
636
+ return;
637
+ this.disposed = true;
638
+ // Clear against the backing store rather than the cached CSS size: after a
639
+ // resize to zero those are 0 while the backing store still holds the last
640
+ // frame, which would reappear if the element became visible again.
641
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
642
+ this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
643
+ this.silhouette = null;
644
+ }
645
+ // ── internals ─────────────────────────────────────────────────────────────
646
+ applyTransform() {
647
+ const r = this.pixelRatio;
648
+ this.ctx.setTransform(r, 0, 0, r, 0, 0);
649
+ }
650
+ advanceGaze(dt, look, moved) {
651
+ // Exponential follow from elapsed time, so gaze speed does not vary with
652
+ // refresh rate.
653
+ const alpha = 1 - Math.exp(-(dt * 1000) / GAZE_TIME_CONSTANT_MS);
654
+ // ADDED to the cursor target, like the expression's own bias, rather than
655
+ // replacing it: a motion that swept her gaze absolutely would make her
656
+ // stop following the pointer for its duration, which reads as her having
657
+ // frozen rather than as her having moved.
658
+ const tx = this.gazeTarget.x + look.gazeX + (moved.gazeX ?? 0);
659
+ const ty = this.gazeTarget.y + look.gazeY + (moved.gazeY ?? 0);
660
+ this.gaze = {
661
+ x: this.gaze.x + (tx - this.gaze.x) * alpha,
662
+ y: this.gaze.y + (ty - this.gaze.y) * alpha,
663
+ };
664
+ }
665
+ /**
666
+ * `drift` is passed in rather than computed here, because this method has no
667
+ * clock — `render` owns time, and a second reading of it inside the paint
668
+ * would be a second answer to what instant this frame is.
669
+ */
670
+ paint(look, blink, squash, moved, drift) {
671
+ const { ctx, face } = this;
672
+ this.applyTransform();
673
+ ctx.clearRect(0, 0, this.cssWidth, this.cssHeight);
674
+ // Her size comes from the LAYOUT, not from a fraction of the canvas. Main
675
+ // sized the window from the same call, so the two cannot disagree about how
676
+ // big she is or where the ground is. `bodyW` and `bodyH` are FULL
677
+ // dimensions; `BodyShape.halfWidth` is a half and `height` is a full,
678
+ // because the shape rests on y = 0 rather than straddling it — mixing those
679
+ // up put the rendered aspect ratio at 2:1 against the icon's 1.28.
680
+ const size = this.sizePercent;
681
+ const layout = size === 'fit-canvas' ? null : layoutFor(face, size);
682
+ const scale = layout?.scale ?? fitToCanvas(face, this.cssWidth, this.cssHeight);
683
+ // Nothing to draw, and say so by drawing nothing. A non-positive scale
684
+ // otherwise paints a sub-pixel speck and caches a silhouette around it,
685
+ // which `hitTest` would then answer questions about.
686
+ if (scale <= 0) {
687
+ this.silhouette = null;
688
+ return;
689
+ }
690
+ /**
691
+ * Where she is standing THIS frame, translation included.
692
+ *
693
+ * Applied to the ORIGIN rather than to each thing drawn, and that is the
694
+ * whole reason translation is safe here: `toCanvas` is the one function
695
+ * every coordinate goes through, so the outline, the features and the
696
+ * silhouette all move together. The silhouette is what `hitTest` answers
697
+ * from, so click-through follows her painted pixels without anything else
698
+ * being told she moved.
699
+ *
700
+ * In fractions of her own body, so a clip means the same thing at every
701
+ * size — `shift: 0.5` is half a body width whether she is drawn at 50% or
702
+ * 200%.
703
+ */
704
+ const travelX = ((moved.shift ?? 0) + drift.shift) * face.bodyW * scale;
705
+ const travelY = ((moved.lift ?? 0) + drift.lift) * face.bodyH * scale;
706
+ const originX = this.cssWidth / 2 + travelX;
707
+ // Measured from the BOTTOM of the canvas she was actually given, so a canvas
708
+ // that is not the size the layout asked for still rests her on a surface
709
+ // rather than floating her.
710
+ /**
711
+ * Where she stands — from `feetY`, which the bubble's anchor also calls.
712
+ *
713
+ * This used to be a FRACTION of her layout height applied to the canvas,
714
+ * while `face.ts` computed the same thing as an offset from the canvas
715
+ * bottom. The two agree only when the canvas happens to be exactly her
716
+ * layout's height, which it never is, so the tail pointed at a head that
717
+ * was not quite there.
718
+ *
719
+ * `fit-canvas` keeps the old fraction: the tuner sizes its own cells and
720
+ * wants her filling each one, not standing at a fixed height in it.
721
+ */
722
+ const originY = (layout === null
723
+ ? this.cssHeight * (1 - (BREATHING_UNITS * scale) / this.cssHeight)
724
+ : feetY(this.cssHeight, BREATHING_UNITS * scale, this.feetFromTop)) -
725
+ // MINUS, because the canvas is +y down and `lift` is up.
726
+ travelY;
727
+ const base = {
728
+ halfWidth: (face.bodyW / 2) * scale,
729
+ height: face.bodyH * scale,
730
+ waist: face.waist,
731
+ upperShoulder: face.upperShoulder,
732
+ lowerShoulder: face.lowerShoulder,
733
+ // The motion layer adds to the expression's lean rather than replacing
734
+ // it -- a shy tilt and a sway are both true at once. The drift adds to
735
+ // both, for the same reason: she is never doing only one thing.
736
+ lean: look.lean + (moved.lean ?? 0) + drift.lean,
737
+ };
738
+ const body = squashed(base, squash);
739
+ // Local space is +y up with the base at 0; the canvas is +y down.
740
+ const toCanvas = (p) => ({ x: originX + p.x, y: originY - p.y });
741
+ const outline = domeOutline(body).map(toCanvas);
742
+ this.silhouette = toPath(outline);
743
+ /**
744
+ * Turning: the features slide, the body does not.
745
+ *
746
+ * There is one silhouette and no second view, so this cannot be a
747
+ * rotation. What it can be is the flat-character trick -- everything drawn
748
+ * on her moves toward one edge, and because the whole of `paint` is clipped
749
+ * to the outline, the far side passes out of sight around her rather than
750
+ * sticking out of it. The clip that already exists for a different reason
751
+ * is what makes this read correctly.
752
+ *
753
+ * `TURN_REACH` is in the same normalised space `placeFeature` takes, where
754
+ * 1 is her half-width — so a full `turn: 1` would put a feature on the
755
+ * outline itself. Kept well under that: past the edge it stops looking like
756
+ * a turn and starts looking like a face sliding off.
757
+ */
758
+ const turn = (moved.turn ?? 0) * TURN_REACH;
759
+ const place = (nx, ny) => toCanvas(placeFeature(base, body, nx + turn, ny, face.gripX, face.gripY));
760
+ // EVERYTHING is clipped to the silhouette, not just the body.
761
+ //
762
+ // This is the promise the class makes: only what you can see takes the
763
+ // mouse. A cheek gradient near the edge, or a wide eye on a squashed frame,
764
+ // paints outside the outline if it is drawn unclipped -- and then there are
765
+ // visible pixels that hitTest calls empty desktop, so the click lands on
766
+ // whatever is behind her. Clipping once here makes that unrepresentable
767
+ // rather than a rule each paint method has to remember.
768
+ ctx.save();
769
+ ctx.clip(this.silhouette);
770
+ this.paintBody(outline, body, this.silhouette);
771
+ // Layer 3, expression. Cheeks under the eyes, so a wide eye covers a cheek
772
+ // rather than being crossed by one.
773
+ paintCheeks(ctx, face, look, place, scale);
774
+ paintEyes(ctx, face, look, blink, this.gaze, place, scale);
775
+ // Layer 4, the mouth. LAST, unconditionally.
776
+ paintMouth(ctx, face, look, this.mouthOpen, place, scale);
777
+ ctx.restore();
778
+ }
779
+ /**
780
+ * Shadow first, then the lit shape on top of it, displaced.
781
+ *
782
+ * Two flat fills and no gradient at all, which is what the artwork turned out
783
+ * to be: its interior luminance is bimodal, 86% at 0.725 against 14% at
784
+ * 0.675, with nothing between the two. So the band has a hard inner edge, and
785
+ * the way to get a hard edge that follows the contour is to cover the shadow
786
+ * with a copy of the SAME outline rather than to fade one colour into
787
+ * another. The crescent the copy fails to cover is the band.
788
+ *
789
+ * Everything here is already clipped to the silhouette by the caller, so the
790
+ * displaced copy cannot spill past her outline on the up-right side.
791
+ */
792
+ paintBody(outline, body, shape) {
793
+ const { ctx, face } = this;
794
+ // The caller has already built this exact path for the silhouette, so the
795
+ // shadow reuses it. Rebuilding it here traced ~200 points into a second
796
+ // identical Path2D on every frame for no difference in output.
797
+ ctx.fillStyle = face.colShadow;
798
+ ctx.fill(shape);
799
+ const dx = face.shadowX * body.halfWidth * 2;
800
+ // Canvas y grows downward and the light comes from above, so the lit copy
801
+ // moves toward smaller y.
802
+ const dy = face.shadowY * body.height;
803
+ ctx.fillStyle = face.colBody;
804
+ ctx.fill(toPath(outline.map((point) => ({ x: point.x + dx, y: point.y - dy }))));
805
+ }
806
+ }
807
+ /** A non-negative, finite length. Anything else is not a size. */
808
+ function finiteOrZero(value) {
809
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
810
+ }
811
+ //# sourceMappingURL=avatar.js.map