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.
- package/BRAND.md +85 -0
- package/LICENSE.md +104 -0
- package/README.md +135 -0
- package/dist/canvas2d/avatar.d.ts +264 -0
- package/dist/canvas2d/avatar.d.ts.map +1 -0
- package/dist/canvas2d/avatar.js +811 -0
- package/dist/canvas2d/avatar.js.map +1 -0
- package/dist/canvas2d/face.d.ts +74 -0
- package/dist/canvas2d/face.d.ts.map +1 -0
- package/dist/canvas2d/face.js +298 -0
- package/dist/canvas2d/face.js.map +1 -0
- package/dist/canvas2d/paths.d.ts +13 -0
- package/dist/canvas2d/paths.d.ts.map +1 -0
- package/dist/canvas2d/paths.js +24 -0
- package/dist/canvas2d/paths.js.map +1 -0
- package/dist/characters/colourways.d.ts +78 -0
- package/dist/characters/colourways.d.ts.map +1 -0
- package/dist/characters/colourways.js +76 -0
- package/dist/characters/colourways.js.map +1 -0
- package/dist/characters/index.d.ts +8 -0
- package/dist/characters/index.d.ts.map +1 -0
- package/dist/characters/index.js +8 -0
- package/dist/characters/index.js.map +1 -0
- package/dist/characters/mochi.d.ts +19 -0
- package/dist/characters/mochi.d.ts.map +1 -0
- package/dist/characters/mochi.js +66 -0
- package/dist/characters/mochi.js.map +1 -0
- package/dist/core/colour.d.ts +25 -0
- package/dist/core/colour.d.ts.map +1 -0
- package/dist/core/colour.js +37 -0
- package/dist/core/colour.js.map +1 -0
- package/dist/core/envelope.d.ts +153 -0
- package/dist/core/envelope.d.ts.map +1 -0
- package/dist/core/envelope.js +141 -0
- package/dist/core/envelope.js.map +1 -0
- package/dist/core/geometry.d.ts +106 -0
- package/dist/core/geometry.d.ts.map +1 -0
- package/dist/core/geometry.js +157 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/idle.d.ts +163 -0
- package/dist/core/idle.d.ts.map +1 -0
- package/dist/core/idle.js +262 -0
- package/dist/core/idle.js.map +1 -0
- package/dist/core/layout.d.ts +142 -0
- package/dist/core/layout.d.ts.map +1 -0
- package/dist/core/layout.js +172 -0
- package/dist/core/layout.js.map +1 -0
- package/dist/core/lens.d.ts +47 -0
- package/dist/core/lens.d.ts.map +1 -0
- package/dist/core/lens.js +67 -0
- package/dist/core/lens.js.map +1 -0
- package/dist/core/looks.d.ts +77 -0
- package/dist/core/looks.d.ts.map +1 -0
- package/dist/core/looks.js +168 -0
- package/dist/core/looks.js.map +1 -0
- package/dist/core/motion.d.ts +168 -0
- package/dist/core/motion.d.ts.map +1 -0
- package/dist/core/motion.js +527 -0
- package/dist/core/motion.js.map +1 -0
- package/dist/core/mouth.d.ts +93 -0
- package/dist/core/mouth.d.ts.map +1 -0
- package/dist/core/mouth.js +92 -0
- package/dist/core/mouth.js.map +1 -0
- package/dist/core/plain.d.ts +23 -0
- package/dist/core/plain.d.ts.map +1 -0
- package/dist/core/plain.js +63 -0
- package/dist/core/plain.js.map +1 -0
- package/dist/core/spec.d.ts +156 -0
- package/dist/core/spec.d.ts.map +1 -0
- package/dist/core/spec.js +236 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/spring.d.ts +53 -0
- package/dist/core/spring.d.ts.map +1 -0
- package/dist/core/spring.js +68 -0
- package/dist/core/spring.js.map +1 -0
- package/dist/core/vocabulary.d.ts +182 -0
- package/dist/core/vocabulary.d.ts.map +1 -0
- package/dist/core/vocabulary.js +66 -0
- package/dist/core/vocabulary.js.map +1 -0
- package/dist/element/dough-avatar.d.ts +34 -0
- package/dist/element/dough-avatar.d.ts.map +1 -0
- package/dist/element/dough-avatar.js +189 -0
- package/dist/element/dough-avatar.js.map +1 -0
- package/dist/element/index.d.ts +3 -0
- package/dist/element/index.d.ts.map +1 -0
- package/dist/element/index.js +35 -0
- package/dist/element/index.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/svg/silhouette.d.ts +97 -0
- package/dist/svg/silhouette.d.ts.map +1 -0
- package/dist/svg/silhouette.js +143 -0
- package/dist/svg/silhouette.js.map +1 -0
- 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
|