libraries-dev-svelte 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,495 @@
1
+ /* The renderer. The body is a stack of copies of its outline, spaced along
2
+ a depth axis with a pillow profile (smaller at the caps); each copy is
3
+ projected with the head's yaw and pitch, so the stack reads as a rounded
4
+ extruded solid that turns, flips and shows its side. Nearest copies are
5
+ lit with a directional gradient, the far side sits in shade. The face
6
+ lives on the front cap and follows it. */
7
+ import { shade } from './color';
8
+ import { drawPlasticCap, mulAffine } from './plastic';
9
+ /* The canvas is drawn larger than the avatar's layout box, so a hop or a
10
+ flip can leave the box without being clipped. */
11
+ export const OVERSCAN = 1.5;
12
+ /** the body's centre sits this fraction of the box below the canvas
13
+ centre: hops and flips need the room above, not below */
14
+ export const RISE = 0.1;
15
+ /* Copies through the depth, and the stock half-depth in body units. */
16
+ const SLICES = 17;
17
+ const HALF_DEPTH = 15;
18
+ /* Cap scale at the ends of the pillow, at the stock rim width. */
19
+ const CAP = 0.9;
20
+ const profile = (z, cap) => cap + (1 - cap) * Math.sqrt(Math.max(0, 1 - z * z));
21
+ /* Big, plain dark eyes: they carry the face at 24px. */
22
+ const EYE_GAP = 25;
23
+ const EYE_RX = 6.3;
24
+ const EYE_Y = { eyes: 1, mouth: -3.5 };
25
+ const paletteCache = new Map();
26
+ function palette(color, shadow, highlight) {
27
+ const key = `${color}|${shadow}|${highlight}`;
28
+ let p = paletteCache.get(key);
29
+ if (!p) {
30
+ const far = shade(color, -0.3 * shadow, 0.05 * shadow);
31
+ const near = shade(color, -0.12 * shadow, 0.03 * shadow);
32
+ const crispMix = [], smoothMix = [];
33
+ for (let j = 0; j < SLICES; j++) {
34
+ const t = j / (SLICES - 1);
35
+ crispMix.push(t > 0.6 ? '' : mixCss(far, near, t / 0.6));
36
+ smoothMix.push(t >= 0.5 ? color : mixCss(far, color, t / 0.5));
37
+ }
38
+ p = {
39
+ base: color,
40
+ far,
41
+ near,
42
+ light: shade(color, 0.04 * highlight),
43
+ dark: shade(color, -0.3 * shadow, 0.05 * shadow),
44
+ capTop: shade(color, 0.035 * highlight),
45
+ capBottom: shade(color, -0.035 * shadow),
46
+ crispMix,
47
+ smoothMix,
48
+ grad: null,
49
+ };
50
+ if (paletteCache.size > 200)
51
+ paletteCache.clear();
52
+ paletteCache.set(key, p);
53
+ }
54
+ return p;
55
+ }
56
+ /* the numbers of an hsl() string; any other colour is normalised through shade() first */
57
+ const hslNums = (c) => (c.startsWith('hsl(') ? c : shade(c, 0)).match(/[\d.]+/g).map(Number);
58
+ function mixCss(a, b, t) {
59
+ /* interpolate the hsl numbers */
60
+ const pa = hslNums(a);
61
+ const pb = hslNums(b);
62
+ const m = pa.map((v, i) => v + (pb[i] - v) * t);
63
+ return `hsl(${m[0].toFixed(1)} ${m[1].toFixed(1)}% ${m[2].toFixed(1)}%)`;
64
+ }
65
+ /* The whirl: the cartoon motion round a spinning body — one tapered
66
+ trail on a tilted ring, made of the body's own material: a translucent
67
+ plastic tube in the body colour, lit from the same light — a lighter
68
+ flank toward it, a darker underside, a white specular ridge along the
69
+ top — with a soft halo so it reads as a puff of plastic cloud. The
70
+ ring lies in the body's equatorial plane seen a little from above,
71
+ with a touch of perspective: the near half (sin > 0) is larger,
72
+ thicker and stronger and is drawn over the body and face, casting a
73
+ soft shadow on them; the far half is smaller and fainter and goes
74
+ behind. */
75
+ const WHIRL_SEGMENTS = 34;
76
+ const WHIRL_SPAN = Math.PI * 1.55;
77
+ const WHIRL_RX = 57;
78
+ const WHIRL_RATIO = 0.4;
79
+ const WHIRL_TILT = -0.28;
80
+ const whirlInkCache = new Map();
81
+ function whirlInk(color) {
82
+ let w = whirlInkCache.get(color);
83
+ if (!w) {
84
+ w = { base: shade(color, 0.1, 0.02), light: shade(color, 0.3, 0.04), dark: shade(color, -0.22, 0.08), halo: shade(color, 0.2) };
85
+ if (whirlInkCache.size > 200)
86
+ whirlInkCache.clear();
87
+ whirlInkCache.set(color, w);
88
+ }
89
+ return w;
90
+ }
91
+ /* an hsl() from shade() with an alpha */
92
+ const withAlpha = (hsl, a) => hsl.replace(')', ` / ${Math.max(0, Math.min(1, a)).toFixed(3)})`);
93
+ function drawWhirl(ctx, pose, color, lx, ly, near, knobs) {
94
+ const strength = knobs?.strength ?? 0;
95
+ const k = Math.min(1, pose.whirl * strength);
96
+ if (k <= 0.01)
97
+ return;
98
+ const sizeK = knobs?.size ?? 1, widthK = knobs?.width ?? 1, lengthK = knobs?.length ?? 1, tiltK = knobs?.tilt ?? 1;
99
+ const span = WHIRL_SPAN * lengthK;
100
+ const ink = whirlInk(color);
101
+ /* the ring runs the way the body's near face moves: to the right */
102
+ const head = -pose.whirlAngle;
103
+ const rx = WHIRL_RX * sizeK;
104
+ const ry = rx * WHIRL_RATIO * tiltK * (near ? 1.14 : 0.86);
105
+ /* where round the ring the light falls, in the ring's own frame */
106
+ const lightA = Math.atan2(ly, lx) - WHIRL_TILT;
107
+ ctx.save();
108
+ ctx.rotate(WHIRL_TILT);
109
+ ctx.translate(0, 5);
110
+ ctx.lineCap = 'butt';
111
+ const seg = (a0, a1, width, style, dy) => {
112
+ ctx.strokeStyle = style;
113
+ ctx.lineWidth = width;
114
+ ctx.beginPath();
115
+ ctx.ellipse(0, dy, rx, ry, 0, a0, a1, false);
116
+ ctx.stroke();
117
+ };
118
+ /* the near half casts a soft shadow on the body it crosses */
119
+ if (near) {
120
+ for (let i = 0; i < WHIRL_SEGMENTS; i++) {
121
+ const f = i / WHIRL_SEGMENTS;
122
+ const a1 = head + f * span, a0 = a1 + span / WHIRL_SEGMENTS + 0.012;
123
+ if (Math.sin((a0 + a1) / 2) <= 0)
124
+ continue;
125
+ const fade = Math.pow(1 - f, 1.3);
126
+ seg(a1, a0, (2 + 8 * fade) * 1.5 * widthK, `rgba(0,0,0,${(0.2 * k * fade).toFixed(3)})`, 3.5);
127
+ }
128
+ }
129
+ for (let i = 0; i < WHIRL_SEGMENTS; i++) {
130
+ const f = i / WHIRL_SEGMENTS;
131
+ /* the trail lies at the angles the head has already passed */
132
+ const a1 = head + f * span, a0 = a1 + span / WHIRL_SEGMENTS + 0.012;
133
+ const mid = (a0 + a1) / 2;
134
+ if ((Math.sin(mid) > 0) !== near)
135
+ continue;
136
+ /* perspective and depth: the nearest point of the ring is fullest */
137
+ const depth = 0.6 + 0.4 * Math.sin(mid);
138
+ const fade = Math.pow(1 - f, 1.3);
139
+ /* a gentle puff along the trail */
140
+ const puff = 1 + 0.18 * Math.sin(f * 9 + 1.2);
141
+ const width = (2 + 8 * fade) * depth * widthK * puff;
142
+ const a = k * (0.3 + 0.7 * fade) * depth;
143
+ /* how much this stretch of the ring faces the light */
144
+ const facing = 0.5 + 0.5 * Math.cos(mid - lightA);
145
+ /* halo, underside, body, lit flank, specular ridge: a plastic tube */
146
+ seg(a1, a0, width * 2.6, withAlpha(ink.halo, a * 0.2), 0);
147
+ seg(a1, a0, width * 0.8, withAlpha(ink.dark, a * 0.45), width * 0.32);
148
+ seg(a1, a0, width, withAlpha(ink.base, a * 0.72), 0);
149
+ seg(a1, a0, width * 0.62, withAlpha(ink.light, a * 0.78 * (0.4 + 0.6 * facing)), -width * 0.16);
150
+ seg(a1, a0, width * 0.24, `rgba(255,255,255,${(a * 0.9 * (0.15 + 0.85 * facing * facing)).toFixed(3)})`, -width * 0.3);
151
+ }
152
+ ctx.restore();
153
+ }
154
+ /**
155
+ * Draw one frame. `box` is the avatar's layout size in CSS px; the canvas
156
+ * is `box * OVERSCAN` square with the body's centre `RISE * box` below
157
+ * its middle, and the context already scaled for the device pixel ratio.
158
+ */
159
+ export function draw(ctx, box, pose, cfg) {
160
+ const full = box * OVERSCAN;
161
+ ctx.clearRect(0, 0, full, full);
162
+ const S = box / 100;
163
+ /* the context's transform as given (the device scale) is the base of
164
+ every transform set here; known from `dpr`, else read once */
165
+ let dpr, base;
166
+ if (cfg.dpr !== undefined) {
167
+ dpr = cfg.dpr;
168
+ base = [dpr, 0, 0, dpr, 0, 0];
169
+ }
170
+ else if (ctx.getTransform) {
171
+ const t = ctx.getTransform();
172
+ base = [t.a, t.b, t.c, t.d, t.e, t.f];
173
+ dpr = t.a || 1;
174
+ }
175
+ else {
176
+ dpr = 1;
177
+ base = [1, 0, 0, 1, 0, 0];
178
+ }
179
+ const shadow = cfg.shadow ?? 0.35, highlight = cfg.highlight ?? 1.3;
180
+ const halfDepth = HALF_DEPTH * (cfg.depth ?? 0.65);
181
+ const cap = 1 - (1 - CAP) * (cfg.rim ?? 0.5);
182
+ const spread = cfg.spread ?? 1.55;
183
+ /* the light's direction on screen: a unit vector toward the source */
184
+ const la = ((cfg.light ?? 265) * Math.PI) / 180;
185
+ const lx = Math.sin(la), ly = -Math.cos(la);
186
+ const pal = palette(cfg.color, shadow, highlight);
187
+ const cy0 = Math.cos(pose.yaw), sy = Math.sin(pose.yaw);
188
+ const cp0 = Math.cos(pose.pitch), sp = Math.sin(pose.pitch);
189
+ /* which cap faces the viewer: the front while this is positive */
190
+ const facing = cy0 * cp0;
191
+ /* edge-on, every slice would thin to a line and the stack would show
192
+ gaps; a floor on the foreshortening keeps it a solid */
193
+ const floor = (v) => (Math.abs(v) < 0.22 ? (v < 0 ? -0.22 : 0.22) : v);
194
+ const cy = floor(cy0), cp = floor(cp0);
195
+ /* body space: the box centre plus the pose's offset, its roll and
196
+ squash. The squash and stretch scale about the body's base (y = 50),
197
+ so a landing keeps the feet on the ground and presses the top down,
198
+ and a stretch rises from the base. */
199
+ const cr = Math.cos(pose.roll), sr = Math.sin(pose.roll), kx = pose.sx * S, ky = pose.sy * S;
200
+ const lift = 50 * (1 - pose.sy) * S;
201
+ const body = mulAffine(base, [cr * kx, sr * kx, -sr * ky, cr * ky, full / 2 + pose.x * S - sr * lift, full / 2 + RISE * box + pose.y * S + cr * lift]);
202
+ ctx.save();
203
+ ctx.setTransform(body[0], body[1], body[2], body[3], body[4], body[5]);
204
+ ctx.lineCap = 'round';
205
+ ctx.lineJoin = 'round';
206
+ const mode = cfg.shading;
207
+ /* one solid: the slice stack (or the plastic material) for an outline
208
+ at a depth; the thin parts come first with a fraction of the depth,
209
+ then the body over them */
210
+ const drawSolid = (path, key, halfDepth) => {
211
+ /* the lit gradient, in the body's own space: light from the upper left */
212
+ let lit = pal.near;
213
+ let capFill = pal.base;
214
+ if (mode === 'crisp') {
215
+ if (!pal.grad || pal.grad.lx !== lx || pal.grad.ly !== ly) {
216
+ const g = ctx.createLinearGradient(lx * 56, ly * 56, -lx * 56, -ly * 56);
217
+ g.addColorStop(0, pal.light);
218
+ g.addColorStop(0.45, pal.near);
219
+ g.addColorStop(1, pal.dark);
220
+ const c = ctx.createLinearGradient(lx * 46, ly * 46, -lx * 46, -ly * 46);
221
+ c.addColorStop(0, pal.capTop);
222
+ c.addColorStop(1, pal.capBottom);
223
+ pal.grad = { lx, ly, lit: g, cap: c };
224
+ }
225
+ lit = pal.grad.lit;
226
+ capFill = pal.grad.cap;
227
+ }
228
+ /* plastic: the material module draws the whole body — side copies from
229
+ its matcap and the front cap as a lit texture. While a form is still
230
+ baking on idle time it declines, and the stock slices with the smooth
231
+ overlay stand in for that frame. */
232
+ let plasticDone = false;
233
+ if (mode === 'plastic') {
234
+ plasticDone = drawPlasticCap(ctx, { ...cfg, path, typeKey: key }, { cy, sy, cp, sp, facing, roll: pose.roll, halfDepth, cap, lx, ly, dev: box * dpr, ctm: body, still: cfg.still }, pal, null, { shadow, highlight, spread, rim: cfg.rim ?? 0.5 });
235
+ }
236
+ const mode2 = mode === 'plastic' && !plasticDone ? 'smooth' : mode;
237
+ const soft = mode2 === 'smooth';
238
+ const union = soft && typeof Path2D === 'function' ? new Path2D() : null;
239
+ /* slices, far to near; each sets its transform outright from the
240
+ body's, no save/restore */
241
+ const order = facing >= 0 ? 1 : -1;
242
+ const [ca, cb, cc, cd, ce, cf] = body;
243
+ let fill = null;
244
+ /* each slice's affine is applied relative to the previous slice's: one
245
+ transform() per slice, no save/restore */
246
+ let pa = 1, pb = 0, pc = 0, pd = 1, pe = 0, pf = 0;
247
+ for (let j = 0; j < SLICES && !plasticDone; j++) {
248
+ const k = order > 0 ? j : SLICES - 1 - j;
249
+ const z = -1 + (2 * k) / (SLICES - 1);
250
+ const s = profile(z, cap);
251
+ const near = j / (SLICES - 1);
252
+ /* yaw about Y then pitch about X, orthographic: an affine per slice,
253
+ then the path's own origin at its centre */
254
+ const m0 = cy * s, m1 = sy * sp * s, m3 = cp * s;
255
+ const e = z * sy * halfDepth - 50 * m0, fo = -z * cy * sp * halfDepth - 50 * m1 - 50 * m3;
256
+ const det = pa * pd - pb * pc;
257
+ const ia = pd / det, ib = -pb / det, ic = -pc / det, id = pa / det, ie = (pc * pf - pd * pe) / det, jf = (pb * pe - pa * pf) / det;
258
+ ctx.transform(ia * m0 + ic * m1, ib * m0 + id * m1, ic * m3, id * m3, ia * e + ic * fo + ie, ib * e + id * fo + jf);
259
+ pa = m0;
260
+ pb = m1;
261
+ pc = 0;
262
+ pd = m3;
263
+ pe = e;
264
+ pf = fo;
265
+ let style;
266
+ /* smooth: one colour ramp through the depth to the front, no edge at the cap */
267
+ if (soft)
268
+ style = pal.smoothMix[j];
269
+ else if (j === SLICES - 1)
270
+ style = capFill;
271
+ else if (near > 0.6)
272
+ style = lit;
273
+ else
274
+ style = pal.crispMix[j];
275
+ if (style !== fill)
276
+ ctx.fillStyle = fill = style;
277
+ ctx.fill(path);
278
+ if (union)
279
+ union.addPath(path, { a: m0, b: m1, c: 0, d: m3, e, f: fo });
280
+ }
281
+ if (!plasticDone)
282
+ ctx.setTransform(ca, cb, cc, cd, ce, cf);
283
+ /* smooth: a soft shadow from the lower right and a light from the upper
284
+ left, laid over the whole form so nothing has an edge */
285
+ if (union && mode2 === 'smooth') {
286
+ /* the two gradients are made per frame on purpose: a kept one is
287
+ slower to use in Safari than a fresh one */
288
+ ctx.save();
289
+ ctx.clip(union);
290
+ const sa = Math.min(1, 0.34 * shadow);
291
+ const sg = ctx.createRadialGradient(-lx * 45, -ly * 45, 4 * spread, -lx * 45, -ly * 45, 84 * spread);
292
+ sg.addColorStop(0, `rgba(0,0,0,${sa})`);
293
+ sg.addColorStop(0.5, `rgba(0,0,0,${sa * 0.35})`);
294
+ sg.addColorStop(1, 'rgba(0,0,0,0)');
295
+ ctx.globalCompositeOperation = 'multiply';
296
+ ctx.fillStyle = sg;
297
+ ctx.fillRect(-120, -120, 240, 240);
298
+ ctx.globalCompositeOperation = 'source-over';
299
+ const ha = Math.min(1, 0.22 * highlight);
300
+ const hg = ctx.createRadialGradient(lx * 37, ly * 37, 0, lx * 37, ly * 37, 62 * spread);
301
+ hg.addColorStop(0, `rgba(255,255,255,${ha})`);
302
+ hg.addColorStop(0.6, `rgba(255,255,255,${ha * 0.23})`);
303
+ hg.addColorStop(1, 'rgba(255,255,255,0)');
304
+ ctx.fillStyle = hg;
305
+ ctx.fillRect(-120, -120, 240, 240);
306
+ ctx.restore();
307
+ }
308
+ return plasticDone;
309
+ };
310
+ /* the far half of the whirl sits behind everything */
311
+ drawWhirl(ctx, pose, cfg.color, lx, ly, false, cfg.whirl);
312
+ if (cfg.parts)
313
+ drawSolid(cfg.parts, `${cfg.typeKey ?? 'custom'}:parts`, halfDepth * (cfg.partsDepth ?? 0.4));
314
+ const plasticDone = drawSolid(cfg.path, cfg.typeKey ?? 'custom', halfDepth);
315
+ /* the face: each feature sits on a sphere behind the front cap, so a
316
+ turn slides it round the head — the eye moving toward the edge
317
+ narrows, the other comes to the front, and past the side they go */
318
+ if (facing > -0.2) {
319
+ ctx.save();
320
+ /* The face is printed on the front of the body, so it cannot leave
321
+ it: clip to the front slice's own outline first. On a wide turn a
322
+ feature's place on the sphere can reach past the body's foreshortened
323
+ silhouette, and without this it floats off the side. */
324
+ {
325
+ const zf = facing >= 0 ? 1 : -1;
326
+ const sf = profile(zf, cap);
327
+ const m0 = cy * sf, m1 = sy * sp * sf, m3 = cp * sf;
328
+ const e = zf * sy * halfDepth - 50 * m0;
329
+ const fo = -zf * cy * sp * halfDepth - 50 * m1 - 50 * m3;
330
+ const [ca, cb, cc, cd, ce, cf] = body;
331
+ ctx.setTransform(ca * m0 + cc * m1, cb * m0 + cd * m1, cc * m3, cd * m3, ca * e + cc * fo + ce, cb * e + cd * fo + cf);
332
+ ctx.clip(cfg.path);
333
+ ctx.setTransform(ca, cb, cc, cd, ce, cf);
334
+ }
335
+ ctx.translate(cfg.faceX - 50, cfg.faceY - 50);
336
+ ctx.scale(cfg.faceScale, cfg.faceScale);
337
+ /* under a clear coat the print shows the gloss faintly through it */
338
+ if (plasticDone)
339
+ ctx.globalAlpha = 0.93;
340
+ drawFace(ctx, pose, cfg);
341
+ ctx.restore();
342
+ }
343
+ /* the near half of the whirl passes in front of the face */
344
+ drawWhirl(ctx, pose, cfg.color, lx, ly, true, cfg.whirl);
345
+ ctx.restore();
346
+ }
347
+ /* Radius of the sphere the face is drawn on, in body units. */
348
+ const FACE_R = 30;
349
+ /* A feature's place on the sphere: longitude and latitude from its
350
+ design position, turned by the head. Returns its screen offset, its
351
+ foreshortening, and how much it faces the viewer. */
352
+ function onSphere(x, y, yaw, pitch) {
353
+ const lon = Math.asin(Math.max(-1, Math.min(1, x / FACE_R))) + yaw;
354
+ const lat = Math.asin(Math.max(-1, Math.min(1, -y / FACE_R))) + pitch;
355
+ const cl = Math.cos(lat);
356
+ return {
357
+ x: FACE_R * Math.sin(lon) * cl,
358
+ y: -FACE_R * Math.sin(lat),
359
+ sx: Math.cos(lon),
360
+ sy: cl,
361
+ z: Math.cos(lon) * cl,
362
+ };
363
+ }
364
+ /* An eye's curve as a short polyline with round joins, cached by its
365
+ numbers. Stroking the open eye's hairpin curve directly gives a pill in
366
+ Chrome but a teardrop in Safari, whose stroker does not round a cusp;
367
+ both stroke a polyline the same way. */
368
+ const EYE_STEPS = 8;
369
+ const eyePaths = new Map();
370
+ function eyePath(x0, y0, cy) {
371
+ const qx = Math.round(x0 * 50), qy = Math.round(y0 * 50), qc = Math.round(cy * 50);
372
+ const key = qx + 2000 * qy + 4e6 * qc;
373
+ let p = eyePaths.get(key);
374
+ if (!p) {
375
+ const ax = qx / 50, ay = qy / 50, ac = qc / 50;
376
+ let d = `M${-ax} ${ay}`;
377
+ for (let i = 1; i <= EYE_STEPS; i++) {
378
+ const t = i / EYE_STEPS, mt = 1 - t;
379
+ d += ` L${(mt * mt * -ax + t * t * ax).toFixed(3)} ${((mt * mt + t * t) * ay + 2 * mt * t * ac).toFixed(3)}`;
380
+ }
381
+ p = new Path2D(d);
382
+ if (eyePaths.size > 256)
383
+ eyePaths.clear();
384
+ eyePaths.set(key, p);
385
+ }
386
+ return p;
387
+ }
388
+ const MOUTH_SIN = Math.sin(0.684), MOUTH_COS = Math.cos(0.684);
389
+ const mouthPaths = new Map();
390
+ function mouthPath(m) {
391
+ const q = (v) => Math.round(v * 50) / 50;
392
+ const hw = q(m.hw), t0 = q(m.t0), a = q(m.a), yt = q(m.yt), ab = q(m.ab), yb = q(m.yb);
393
+ const key = `${hw},${t0},${a},${yt},${ab},${yb}`;
394
+ let p = mouthPaths.get(key);
395
+ if (p)
396
+ return p;
397
+ const f = (v) => v.toFixed(3);
398
+ /* the corners, offset along the end normal for the caps */
399
+ const nx = t0 * MOUTH_SIN, ny = t0 * MOUTH_COS;
400
+ const ltx = -hw + nx, lty = -ny, rtx = hw - nx, rty = -ny;
401
+ const lbx = -hw - nx, lby = ny, rbx = hw + nx, rby = ny;
402
+ /* the caps bulge outward along the end tangents */
403
+ const cx = (4 / 3) * t0 * MOUTH_COS, cy = (4 / 3) * t0 * MOUTH_SIN;
404
+ const d = `M${f(ltx)} ${f(lty)}` +
405
+ `C${f(-hw + a * hw)} ${f(yt - t0)} ${f(hw - a * hw)} ${f(yt - t0)} ${f(rtx)} ${f(rty)}` +
406
+ `C${f(rtx + cx)} ${f(rty - cy)} ${f(rbx + cx)} ${f(rby - cy)} ${f(rbx)} ${f(rby)}` +
407
+ `C${f(hw - ab * hw)} ${f(yb + t0)} ${f(-hw + ab * hw)} ${f(yb + t0)} ${f(lbx)} ${f(lby)}` +
408
+ `C${f(lbx - cx)} ${f(lby - cy)} ${f(ltx - cx)} ${f(lty - cy)} ${f(ltx)} ${f(lty)}Z`;
409
+ p = new Path2D(d);
410
+ if (mouthPaths.size > 256)
411
+ mouthPaths.clear();
412
+ mouthPaths.set(key, p);
413
+ return p;
414
+ }
415
+ function drawFace(ctx, pose, cfg) {
416
+ const [wd, ww, ws] = pose.w;
417
+ const ink = cfg.ink;
418
+ const ey = EYE_Y[cfg.face];
419
+ const half = EYE_GAP / 2;
420
+ const lx = pose.lookX, ly = pose.lookY;
421
+ const { yaw, pitch } = pose;
422
+ /* place a feature: skip it once it has gone round the side */
423
+ const at = (x, y, fn, alpha = 1) => {
424
+ const q = onSphere(x, y, yaw, pitch);
425
+ if (q.z <= 0.02 || alpha <= 0.01)
426
+ return;
427
+ ctx.save();
428
+ ctx.globalAlpha = alpha * Math.min(1, q.z * 5);
429
+ ctx.translate(q.x, q.y);
430
+ ctx.scale(Math.max(0.02, q.sx), Math.max(0.02, q.sy));
431
+ fn();
432
+ ctx.restore();
433
+ };
434
+ /* Each eye is one stroked curve — endpoints at ±x0,y0, a control point
435
+ at 0,cy, width w, round caps — so every look is the same shape with
436
+ different numbers, and a blend of the numbers is a real morph: the
437
+ upright pill of an open eye squashes into a shut line, swings up into
438
+ a laughing arc, or droops into a sleeping lid. */
439
+ /* the eyes take the head's aim, but only once it really aims
440
+ somewhere: at rest and through the small drift of a breath they keep
441
+ their own shape, and past that they draw taller looking up, shorter
442
+ looking down, and a little wider from the corner of a sideways
443
+ glance — the mimic that makes a turn read as a look */
444
+ const past = (v, d) => (Math.abs(v) <= d ? 0 : (Math.sign(v) * (Math.abs(v) - d)) / (1 - d));
445
+ const clamp1 = (v) => Math.max(-1, Math.min(1, v));
446
+ const up = past(clamp1(-pose.pitch / 0.26 - pose.lookY / 7), 0.34);
447
+ const side = Math.abs(past(clamp1(pose.lookX / 4.5), 0.4));
448
+ const tall = Math.max(0.3, 1 + 0.55 * up - 0.1 * side);
449
+ const wide = 1 - 0.05 * up + 0.12 * side;
450
+ const open = wd + ww * (1 - pose.laugh);
451
+ const laugh = ww * pose.laugh;
452
+ const lift = Math.max(0, -pose.y) / 26;
453
+ const sag = 0.5 + 0.5 * pose.breath;
454
+ for (const side of [-1, 1]) {
455
+ const lid = side < 0 ? pose.blinkL : pose.blinkR;
456
+ const e = Math.max(0, Math.min(1, pose.eyeOpen * (1 - lid)));
457
+ const kOpen = open * e, kShut = open * (1 - e), kLaugh = laugh, kSleep = ws;
458
+ const x0 = kOpen * 0.01 + kShut * 5.4 + kLaugh * 6.2 + kSleep * 6;
459
+ const y0 = kOpen * 1.1 * tall + kShut * 0.6 + kLaugh * (2.2 - lift * 1.5) + kSleep * (-1.4 + sag);
460
+ const cy = kOpen * -3.3 * tall + kShut * 0.6 + kLaugh * (-11.4 - 4 * lift) + kSleep * (5.4 + 2 * sag);
461
+ const w = kOpen * EYE_RX * 2 * wide + kShut * 2.8 + kLaugh * 4.4 + kSleep * 4;
462
+ /* the eyes drift toward the look when open, less so when shut */
463
+ const dx = lx * (kOpen + 0.5 * (kShut + kLaugh)), dy = ly * (kOpen + 0.5 * kShut);
464
+ at(side * half + dx, ey + dy, () => {
465
+ ctx.strokeStyle = ink;
466
+ ctx.lineWidth = w;
467
+ ctx.stroke(eyePath(x0, y0, cy));
468
+ });
469
+ }
470
+ if (cfg.face === 'mouth') {
471
+ const mx = lx * 0.35;
472
+ /* One mouth for every state, so a switch morphs it rather than fading
473
+ one shape into another: two cubic edges between the corners, round
474
+ caps of radius t0 at the corners (the smile is a thick line; the
475
+ open mouth and the sleeping "o" have sharp corners), every number a
476
+ blend of the three states'. The smile widens on the in-breath, the
477
+ open mouth at the top of a hop, the "o" swells with each breath. */
478
+ const kd = (0.6 + 0.4 * wd) * (1 + 0.06 * pose.breath);
479
+ const kw = (0.6 + 0.4 * ww) * (1 + (0.25 * Math.max(0, -pose.y)) / 26);
480
+ const r = 2.7 * ws * (1 + 0.25 * pose.breath);
481
+ const b = (d, w, s) => wd * d + ww * w + ws * s;
482
+ const m = {
483
+ hw: b(6.5 * kd, 9.5 * kw, r),
484
+ t0: b(1.9, 0, 0),
485
+ a: b(2 / 3, 2 / 3, 0),
486
+ yt: b(3.53 * kd, 1.6 * kw, (-4 * r) / 3),
487
+ ab: b(2 / 3, 0, 0),
488
+ yb: b(3.53 * kd, 17.3 * kw, (4 * r) / 3),
489
+ };
490
+ at(mx, b(12.5, 11.6, 15.5), () => {
491
+ ctx.fillStyle = ink;
492
+ ctx.fill(mouthPath(m));
493
+ });
494
+ }
495
+ }
@@ -0,0 +1,138 @@
1
+ import type { BotAvatarState, BotAvatarSquashEase } from './types';
2
+ export declare const STATES: BotAvatarState[];
3
+ /** The jump's numbers: an idle flip's and a click's. */
4
+ export interface JumpConfig {
5
+ /** how high, in body units (the body is 100 tall) */
6
+ height: number;
7
+ /** seconds in the air */
8
+ time: number;
9
+ /** how much the body stretches in the air, 0–2 */
10
+ stretch: number;
11
+ /** how much it squashes on the ground, before take-off and on landing, 0–2 */
12
+ squash: number;
13
+ /** seconds the landing squash takes, contact to recovered */
14
+ squashTime: number;
15
+ /** the landing squash's shape */
16
+ squashEase: BotAvatarSquashEase;
17
+ /** seconds the body holds its deepest squash on the ground */
18
+ groundTime: number;
19
+ /** how the weight settles through that hold */
20
+ groundEase: BotAvatarSquashEase;
21
+ /** seconds the body takes to rise from its deepest squash back to shape */
22
+ riseTime: number;
23
+ /** how it rises */
24
+ riseEase: BotAvatarSquashEase;
25
+ /** seconds a click's jump takes for its landing squash */
26
+ clickSquashTime: number;
27
+ /** whole turns in the air */
28
+ spin: number;
29
+ /** degrees of lean into it */
30
+ lean: number;
31
+ /** seconds between idle jumps, roughly (±40 %); 0 for none */
32
+ every: number;
33
+ /** when the landing squash begins: seconds before (negative) or after
34
+ touch-down; 0 is the moment of contact */
35
+ land: number;
36
+ }
37
+ export declare const JUMP_DEFAULTS: JumpConfig;
38
+ export interface Pose {
39
+ /** radians; yaw > 0 turns the face to the viewer's right, pitch > 0 looks up */
40
+ yaw: number;
41
+ pitch: number;
42
+ roll: number;
43
+ /** body-box units (the 100×100 design space) */
44
+ x: number;
45
+ y: number;
46
+ sx: number;
47
+ sy: number;
48
+ /** 0 shut … 1 open, before blinks */
49
+ eyeOpen: number;
50
+ /** how far each lid is down right now, 0 … 1 */
51
+ blinkL: number;
52
+ blinkR: number;
53
+ lookX: number;
54
+ lookY: number;
55
+ /** the breathing cycle, −1 … 1 */
56
+ breath: number;
57
+ /** working only: how far the eyes have closed into a laugh, 0 … 1 */
58
+ laugh: number;
59
+ /** the cartoon whirl round a spinning body: strength 0 … 1, and where
60
+ its head is, in radians round the ring */
61
+ whirl: number;
62
+ whirlAngle: number;
63
+ /** blend weights: default, working, sleeping — they sum to 1 */
64
+ w: [number, number, number];
65
+ }
66
+ export declare class Sim {
67
+ readonly pose: Pose;
68
+ state: BotAvatarState;
69
+ private rand;
70
+ private t;
71
+ private wFrom;
72
+ private tr;
73
+ private trDuration;
74
+ private yawW;
75
+ private pitchW;
76
+ private rollW;
77
+ private lookXW;
78
+ private lookYW;
79
+ private blink;
80
+ private blinkAt;
81
+ private blinkAgain;
82
+ private dart;
83
+ private dartAt;
84
+ private dartX;
85
+ private dartY;
86
+ private flip;
87
+ private flipPoked;
88
+ private jump;
89
+ private flipAt;
90
+ private flipSide;
91
+ private nod;
92
+ private nodAt;
93
+ private hopPhase;
94
+ private hopCount;
95
+ /** the hops' gain: the working weight while in the state, then held so
96
+ a hop under way finishes whole when the state is left */
97
+ private hopGain;
98
+ private laughEv;
99
+ private laughAt;
100
+ private prevYaw;
101
+ private jelly;
102
+ private jellyV;
103
+ /** how far the eyes run ahead of a head turn */
104
+ private gazeLead;
105
+ /** the idle gaze: where the head is looking, and when it moves on */
106
+ private gazeDir;
107
+ private gazeAt;
108
+ /** how far it turns to the side, 1 as the gaze has it */
109
+ private turnK;
110
+ /** the breathing cycle's phase, in turns */
111
+ private breathPhase;
112
+ private ptrX;
113
+ private ptrY;
114
+ private ptrS;
115
+ private ptrTargetX;
116
+ private ptrTargetY;
117
+ private ptrTargetS;
118
+ private baseYaw;
119
+ constructor(seed: number, state?: BotAvatarState);
120
+ setState(next: BotAvatarState, immediate?: boolean): void;
121
+ /** Where the pointer is, relative to the head (−1 … 1 across a head
122
+ width), and how strongly to follow it (0 lets go). */
123
+ setPointer(x: number, y: number, strength: number): void;
124
+ /** A hop and a full turn, right now, whatever the state. */
125
+ poke(): void;
126
+ /** How far the head turns to the side while idle: 1 as the gaze has
127
+ it, 0 keeps it facing forward. */
128
+ setTurn(k: number): void;
129
+ /** The jump's numbers; any subset. */
130
+ setJump(j: Partial<JumpConfig>): void;
131
+ private nextGaze;
132
+ /** when the next idle jump is due: `every` seconds, give or take 40 % */
133
+ private nextFlip;
134
+ /** Advance by `dt` seconds (already scaled by the speed). */
135
+ update(dt: number): void;
136
+ }
137
+ /** The still pose of a state, for reduced motion and the first paint. */
138
+ export declare function restPose(state: BotAvatarState): Pose;