@diffenderfer-games/hub 2.0.0-next.5 → 2.0.0-next.7

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 (170) hide show
  1. package/CHANGELOG.md +65 -19
  2. package/README.md +3 -6
  3. package/dist/auto.d.ts +5 -5
  4. package/dist/auto.js +2 -2
  5. package/dist/avatar-pixi.d.ts +147 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +280 -0
  8. package/dist/avatar-pixi.js.map +1 -0
  9. package/dist/avatar-svg.d.ts +4 -0
  10. package/dist/avatar-svg.js +5 -0
  11. package/dist/avatar-three.d.ts +69 -0
  12. package/dist/avatar-three.d.ts.map +1 -0
  13. package/dist/avatar-three.js +49 -0
  14. package/dist/avatar-three.js.map +1 -0
  15. package/dist/avatar.d.ts +7 -0
  16. package/dist/avatar.js +487 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +1 -1
  20. package/dist/index.d.ts +5 -5
  21. package/dist/index.js +2 -2
  22. package/dist/input.d.ts +2 -2
  23. package/dist/shared/animator.d.ts +104 -0
  24. package/dist/shared/animator.d.ts.map +1 -0
  25. package/dist/shared/art.js +1946 -0
  26. package/dist/shared/art.js.map +1 -0
  27. package/dist/shared/atlas.d.ts +134 -0
  28. package/dist/shared/atlas.d.ts.map +1 -0
  29. package/dist/shared/avatar.d.ts +117 -0
  30. package/dist/shared/avatar.d.ts.map +1 -0
  31. package/dist/shared/bridge.js +13 -5
  32. package/dist/shared/bridge.js.map +1 -1
  33. package/dist/shared/client-version.gen.js +1 -1
  34. package/dist/shared/daily.d.ts +129 -18
  35. package/dist/shared/daily.d.ts.map +1 -1
  36. package/dist/shared/daily.js +120 -26
  37. package/dist/shared/daily.js.map +1 -1
  38. package/dist/shared/defineProperty.js +39 -0
  39. package/dist/shared/describe.d.ts +32 -0
  40. package/dist/shared/describe.d.ts.map +1 -0
  41. package/dist/shared/describe.js +109 -0
  42. package/dist/shared/describe.js.map +1 -0
  43. package/dist/shared/globals.js +1 -39
  44. package/dist/shared/globals.js.map +1 -1
  45. package/dist/shared/index.d.ts +503 -14
  46. package/dist/shared/index.d.ts.map +1 -1
  47. package/dist/shared/input.d.ts +31 -13
  48. package/dist/shared/input.d.ts.map +1 -1
  49. package/dist/shared/input.js +40 -2
  50. package/dist/shared/input.js.map +1 -1
  51. package/dist/shared/json-guards.js +48 -0
  52. package/dist/shared/json-guards.js.map +1 -0
  53. package/dist/shared/live-canvas.js +62 -0
  54. package/dist/shared/live-canvas.js.map +1 -0
  55. package/dist/shared/mp.js +1 -1
  56. package/dist/shared/overlays.js +800 -0
  57. package/dist/shared/overlays.js.map +1 -0
  58. package/dist/shared/page-stack.js +2 -1
  59. package/dist/shared/page-stack.js.map +1 -1
  60. package/dist/shared/pose-key.js +249 -0
  61. package/dist/shared/pose-key.js.map +1 -0
  62. package/dist/shared/race.js +1 -1
  63. package/dist/shared/rooms.js +1 -1
  64. package/dist/shared/scene.d.ts +90 -0
  65. package/dist/shared/scene.d.ts.map +1 -0
  66. package/dist/shared/social.d.ts +55 -4
  67. package/dist/shared/social.d.ts.map +1 -1
  68. package/dist/shared/src.js +2020 -144
  69. package/dist/shared/src.js.map +1 -1
  70. package/dist/shared/svg-markup.js +1945 -0
  71. package/dist/shared/svg-markup.js.map +1 -0
  72. package/dist/shared/svg.js +27 -0
  73. package/dist/shared/svg.js.map +1 -0
  74. package/dist/shared/types.d.ts +166 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/social.d.ts +2 -2
  77. package/dist/social.js +2 -2
  78. package/dist/turns.d.ts +39 -0
  79. package/dist/turns.d.ts.map +1 -0
  80. package/dist/turns.js +117 -0
  81. package/dist/turns.js.map +1 -0
  82. package/package.json +33 -3
  83. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  84. package/src/avatar/adapters/pixi-crowd.ts +302 -0
  85. package/src/avatar/adapters/pixi-types.ts +84 -0
  86. package/src/avatar/adapters/three.ts +126 -0
  87. package/src/avatar/animator/animator.ts +285 -0
  88. package/src/avatar/animator/reactions.ts +44 -0
  89. package/src/avatar/animator/schedule.ts +73 -0
  90. package/src/avatar/art/beard.ts +49 -0
  91. package/src/avatar/art/glasses.ts +67 -0
  92. package/src/avatar/art/hair.ts +254 -0
  93. package/src/avatar/art/headwear.ts +75 -0
  94. package/src/avatar/art/index.ts +21 -0
  95. package/src/avatar/art/shape.ts +87 -0
  96. package/src/avatar/art-store.ts +36 -0
  97. package/src/avatar/atlas.ts +236 -0
  98. package/src/avatar/catalog-view.ts +14 -0
  99. package/src/avatar/colour.ts +109 -0
  100. package/src/avatar/describe.ts +79 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +173 -0
  103. package/src/avatar/layout.ts +47 -0
  104. package/src/avatar/look-like.ts +52 -0
  105. package/src/avatar/outputs/bitmap.ts +75 -0
  106. package/src/avatar/outputs/canvas-paint.ts +177 -0
  107. package/src/avatar/outputs/canvas.ts +62 -0
  108. package/src/avatar/outputs/custom-element.ts +140 -0
  109. package/src/avatar/outputs/element.ts +58 -0
  110. package/src/avatar/outputs/live-canvas.ts +57 -0
  111. package/src/avatar/outputs/overlay-svg.ts +22 -0
  112. package/src/avatar/outputs/pose-key.ts +30 -0
  113. package/src/avatar/outputs/svg-markup.ts +113 -0
  114. package/src/avatar/outputs/svg.ts +41 -0
  115. package/src/avatar/overlays.ts +128 -0
  116. package/src/avatar/render/body.ts +116 -0
  117. package/src/avatar/render/colours.ts +81 -0
  118. package/src/avatar/render/compose.ts +303 -0
  119. package/src/avatar/render/context.ts +117 -0
  120. package/src/avatar/render/eyes.ts +78 -0
  121. package/src/avatar/render/eyewear.ts +118 -0
  122. package/src/avatar/render/frame.ts +60 -0
  123. package/src/avatar/render/head.ts +81 -0
  124. package/src/avatar/render/mouth.ts +86 -0
  125. package/src/avatar/render/props.ts +91 -0
  126. package/src/avatar/render/types.ts +84 -0
  127. package/src/avatar/scene.ts +119 -0
  128. package/src/avatar-pixi.ts +20 -0
  129. package/src/avatar-svg.ts +21 -0
  130. package/src/avatar-three.ts +18 -0
  131. package/src/avatar.ts +62 -0
  132. package/src/core/avatar/avatar-cache.ts +95 -0
  133. package/src/core/avatar/avatar-ref.ts +34 -0
  134. package/src/core/avatar/avatar-types.ts +43 -0
  135. package/src/core/avatar/hub-avatar.ts +308 -0
  136. package/src/core/connection.ts +8 -0
  137. package/src/core/errors.ts +7 -1
  138. package/src/core/hub-progress.ts +39 -8
  139. package/src/core/hub.ts +174 -6
  140. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  141. package/src/core/offline/hub-kinds/index.ts +9 -0
  142. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  143. package/src/core/page-account.ts +121 -0
  144. package/src/core/rest/routes.gen.ts +31 -1
  145. package/src/core/resume/hub-resume.ts +39 -5
  146. package/src/core/resume/resume-types.ts +2 -2
  147. package/src/core/saved/daily-saves.ts +196 -0
  148. package/src/core/saved/daily-seed.ts +16 -0
  149. package/src/core/saved/hub-saved.ts +221 -0
  150. package/src/core/saved/local-saved.ts +122 -0
  151. package/src/core/saved/saved-list.ts +129 -0
  152. package/src/core/saved/saved-open.ts +104 -0
  153. package/src/core/saved/saved-order.ts +49 -0
  154. package/src/core/saved/saved-types.ts +24 -0
  155. package/src/core/turns/hub-turns.ts +140 -0
  156. package/src/core/turns/turn-game.ts +388 -0
  157. package/src/core/version/client-version.gen.ts +2 -2
  158. package/src/daily/daily-system.ts +112 -13
  159. package/src/daily/page-daily.ts +2 -2
  160. package/src/daily/types.ts +46 -9
  161. package/src/doctor/hub-version-fetch.ts +43 -43
  162. package/src/index.ts +10 -1
  163. package/src/input/input-system.ts +10 -1
  164. package/src/input/navigator.ts +11 -0
  165. package/src/input.ts +1 -0
  166. package/src/overlay/overlay.ts +25 -1
  167. package/src/realtime/runtime-api.ts +14 -3
  168. package/src/social.ts +1 -0
  169. package/src/turns-kit/check-rules.ts +182 -0
  170. package/src/turns.ts +13 -0
@@ -0,0 +1,1945 @@
1
+ import { t as _defineProperty } from "./defineProperty.js";
2
+ import { decodeLook, encodeLook, normaliseHex, partName, resolveColour } from "@diffenderfer-games/hub-contract/avatar";
3
+ //#region src/avatar/look-like.ts
4
+ /** Turns anything a render accepts into a look (or `null` for the silhouette), caching decoded codes. */
5
+ const decoded = /* @__PURE__ */ new Map();
6
+ const DECODE_CACHE = 512;
7
+ /** Decodes a code once (an LRU of 512). */
8
+ function fromCode(code) {
9
+ const hit = decoded.get(code);
10
+ if (hit !== void 0 || decoded.has(code)) return hit ?? null;
11
+ const look = decodeLook(code)?.look ?? null;
12
+ if (decoded.size >= DECODE_CACHE) {
13
+ const oldest = decoded.keys().next().value;
14
+ if (oldest !== void 0) decoded.delete(oldest);
15
+ }
16
+ decoded.set(code, look);
17
+ return look;
18
+ }
19
+ /** The look a {@link LookLike} names, or `null` (hidden, malformed, missing). */
20
+ function toLook(like) {
21
+ if (like === null || like === void 0 || like === "") return null;
22
+ if (typeof like === "string") return fromCode(like);
23
+ if ("v" in like) return like;
24
+ if ("look" in like) return like.look;
25
+ return fromCode(like.code);
26
+ }
27
+ /** The code a {@link LookLike} names (`''` for none). */
28
+ function toCode(like) {
29
+ if (typeof like === "string") return like;
30
+ if (like && "code" in like) return like.code;
31
+ const look = toLook(like);
32
+ return look ? encodeLook(look) : "";
33
+ }
34
+ //#endregion
35
+ //#region src/avatar/colour.ts
36
+ const toLin = (c) => {
37
+ const v = c / 255;
38
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
39
+ };
40
+ const fromLin = (c) => {
41
+ const v = c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055;
42
+ return Math.round(Math.min(1, Math.max(0, v)) * 255);
43
+ };
44
+ /** A `#rrggbb` colour in OKLab. */
45
+ function hexToLab(hex) {
46
+ const r = toLin(parseInt(hex.slice(1, 3), 16));
47
+ const g = toLin(parseInt(hex.slice(3, 5), 16));
48
+ const bl = toLin(parseInt(hex.slice(5, 7), 16));
49
+ const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * bl);
50
+ const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * bl);
51
+ const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * bl);
52
+ return {
53
+ L: .2104542553 * l + .793617785 * m - .0040720468 * s,
54
+ a: 1.9779984951 * l - 2.428592205 * m + .4505937099 * s,
55
+ b: .0259040371 * l + .7827717662 * m - .808675766 * s
56
+ };
57
+ }
58
+ /** An OKLab colour as `#rrggbb` (clipped to sRGB). */
59
+ function labToHex(lab) {
60
+ const { a, b } = lab;
61
+ const l = (lab.L + .3963377774 * a + .2158037573 * b) ** 3;
62
+ const m = (lab.L - .1055613458 * a - .0638541728 * b) ** 3;
63
+ const s = (lab.L - .0894841775 * a - 1.291485548 * b) ** 3;
64
+ return `#${[
65
+ fromLin(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
66
+ fromLin(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
67
+ fromLin(-.0041960863 * l - .7034186147 * m + 1.707614701 * s)
68
+ ].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
69
+ }
70
+ /** Shifts lightness by `dL` (OKLab L units) and scales chroma. */
71
+ function adjust(hex, dL, chroma = 1) {
72
+ const c = hexToLab(hex);
73
+ return labToHex({
74
+ L: Math.min(.98, Math.max(.03, c.L + dL)),
75
+ a: c.a * chroma,
76
+ b: c.b * chroma
77
+ });
78
+ }
79
+ /** The one shade tone of a fill. */
80
+ const shade = (hex) => adjust(hex, -.1, 1.08);
81
+ /** The one highlight tone of a fill. */
82
+ const light = (hex) => adjust(hex, .09, .9);
83
+ /** Style C's tonal outline: a darker shade of the fill's own colour (§14.1). */
84
+ const outline = (hex) => adjust(hex, -.24, 1.1);
85
+ /** A dark ink in the fill's own hue (status props keep a strong edge). */
86
+ function ink(hex) {
87
+ const c = hexToLab(hex);
88
+ return labToHex({
89
+ L: Math.min(.24, c.L * .42),
90
+ a: c.a * .6,
91
+ b: c.b * .6
92
+ });
93
+ }
94
+ /** Linear mix in OKLab. */
95
+ function mix$1(a, b, t) {
96
+ const lhs = hexToLab(a);
97
+ const rhs = hexToLab(b);
98
+ return labToHex({
99
+ L: lhs.L + (rhs.L - lhs.L) * t,
100
+ a: lhs.a + (rhs.a - lhs.a) * t,
101
+ b: lhs.b + (rhs.b - lhs.b) * t
102
+ });
103
+ }
104
+ /** OKLab lightness, 0 … 1. */
105
+ const lightness = (hex) => hexToLab(hex).L;
106
+ /** Perceptual distance (OKLab ΔE × 100). */
107
+ function deltaE(a, b) {
108
+ const lhs = hexToLab(a);
109
+ const rhs = hexToLab(b);
110
+ return Math.hypot(lhs.L - rhs.L, lhs.a - rhs.a, lhs.b - rhs.b) * 100;
111
+ }
112
+ /**
113
+ * Keeps brows and facial hair visible on the skin (§14.2): a colour within
114
+ * ΔE 12 of the skin is darkened by 0.18 L.
115
+ */
116
+ function keepApart(colour, skin) {
117
+ return deltaE(colour, skin) >= 12 ? colour : adjust(colour, -.18);
118
+ }
119
+ /** WCAG relative luminance of a `#rrggbb`. */
120
+ function luminance(hex) {
121
+ const r = toLin(parseInt(hex.slice(1, 3), 16));
122
+ const g = toLin(parseInt(hex.slice(3, 5), 16));
123
+ const b = toLin(parseInt(hex.slice(5, 7), 16));
124
+ return .2126 * r + .7152 * g + .0722 * b;
125
+ }
126
+ /** WCAG contrast ratio between two colours. */
127
+ function contrast(a, b) {
128
+ const lhs = luminance(a);
129
+ const rhs = luminance(b);
130
+ return (Math.max(lhs, rhs) + .05) / (Math.min(lhs, rhs) + .05);
131
+ }
132
+ //#endregion
133
+ //#region src/avatar/scene.ts
134
+ /** A group helper that drops empty groups' wrapping. */
135
+ function g(children, opts = {}) {
136
+ return {
137
+ k: "g",
138
+ ...opts,
139
+ children
140
+ };
141
+ }
142
+ /** A path helper. */
143
+ function path(d, paint, tf) {
144
+ return {
145
+ k: "path",
146
+ d,
147
+ ...paint,
148
+ ...tf ? { tf } : {}
149
+ };
150
+ }
151
+ /** An ellipse helper. */
152
+ function ellipse(cx, cy, rx, ry, paint, tf) {
153
+ return {
154
+ k: "ellipse",
155
+ cx,
156
+ cy,
157
+ rx,
158
+ ry,
159
+ ...paint,
160
+ ...tf ? { tf } : {}
161
+ };
162
+ }
163
+ /** A rect helper. */
164
+ function rect(left, top, w, h, paint, r) {
165
+ return {
166
+ k: "rect",
167
+ x: left,
168
+ y: top,
169
+ w,
170
+ h,
171
+ ...paint,
172
+ ...r === void 0 ? {} : { r }
173
+ };
174
+ }
175
+ /** A translate step. */
176
+ const tr = (dx, dy) => ({
177
+ k: "t",
178
+ x: dx,
179
+ y: dy
180
+ });
181
+ /** A scale step (uniform when `sy` is omitted). */
182
+ const sc = (sx, sy = sx) => ({
183
+ k: "s",
184
+ x: sx,
185
+ y: sy
186
+ });
187
+ /** A rotate step, degrees. */
188
+ const rot = (a) => ({
189
+ k: "r",
190
+ a
191
+ });
192
+ /** A horizontal skew step, degrees. */
193
+ const skewX = (a) => ({
194
+ k: "skx",
195
+ a
196
+ });
197
+ /** A round-joined stroke paint. */
198
+ function line(colour, width, cap = "round", opacity) {
199
+ return {
200
+ fill: "none",
201
+ stroke: colour,
202
+ sw: width,
203
+ cap,
204
+ ...opacity === void 0 ? {} : { opacity }
205
+ };
206
+ }
207
+ //#endregion
208
+ //#region src/avatar/render/frame.ts
209
+ /**
210
+ * Frames (§3.4): which part of rig space an output shows, and the badge
211
+ * background (the site's rounded panel with a ring that lightens on dark
212
+ * skin, §3.9).
213
+ */
214
+ /** The three frames' boxes. */
215
+ const FRAMES = {
216
+ head: [
217
+ -66,
218
+ -164,
219
+ 132,
220
+ 132
221
+ ],
222
+ bust: [
223
+ -84,
224
+ -188,
225
+ 168,
226
+ 168
227
+ ],
228
+ full: [
229
+ -86,
230
+ -196,
231
+ 172,
232
+ 206
233
+ ]
234
+ };
235
+ /** The site panel colour behind badges. */
236
+ const PANEL = "#11141c";
237
+ /** The frame an options object asks for (default `head`). */
238
+ function frameOf(opts) {
239
+ return opts.frame ?? "head";
240
+ }
241
+ /** Output pixel size for a frame (the longer edge is `size`). */
242
+ function pixelSize(opts) {
243
+ const [, , w, h] = FRAMES[frameOf(opts)];
244
+ const scale = (opts.size ?? 48) / Math.max(w, h);
245
+ return {
246
+ width: w * scale,
247
+ height: h * scale,
248
+ scale
249
+ };
250
+ }
251
+ /** The badge ring colour: lighter when the skin is darker than the panel, so every skin has an edge. */
252
+ function ringColour(look) {
253
+ return look && lightness(resolveColour(look.skin)) < .42 ? "#6f7787" : "#2a3142";
254
+ }
255
+ /** Wraps the avatar's nodes in the frame's background (a badge clips to the rounded panel). */
256
+ function framed(nodes, look, opts) {
257
+ const [left, top, w, h] = FRAMES[frameOf(opts)];
258
+ const background = opts.background ?? (frameOf(opts) === "head" ? "badge" : "none");
259
+ if (background === "badge") {
260
+ const r = w * .24;
261
+ return [
262
+ rect(left, top, w, h, { fill: PANEL }, r),
263
+ g(nodes, { clip: { shapes: [rect(left, top, w, h, {}, r)] } }),
264
+ rect(left + 1.5, top + 1.5, w - 3, h - 3, {
265
+ fill: "none",
266
+ stroke: ringColour(look),
267
+ sw: 3
268
+ }, r - 1.5)
269
+ ];
270
+ }
271
+ if (background === "none") return [...nodes];
272
+ const fill = normaliseHex(background);
273
+ return fill ? [rect(left, top, w, h, { fill }), ...nodes] : [...nodes];
274
+ }
275
+ //#endregion
276
+ //#region src/avatar/art-store.ts
277
+ let art = null;
278
+ let loading = null;
279
+ const waiters = /* @__PURE__ */ new Set();
280
+ /** The loaded art, or `null` before {@link ready} resolves. */
281
+ function currentArt() {
282
+ return art;
283
+ }
284
+ /** Loads the art chunk (once). Resolves when rendering is synchronous. */
285
+ function ready() {
286
+ loading ?? (loading = import("./art.js").then((mod) => {
287
+ art = mod.ART;
288
+ for (const waiter of waiters) waiter();
289
+ waiters.clear();
290
+ }));
291
+ return loading;
292
+ }
293
+ /** Calls `fn` once the art has loaded (now, when it already has). */
294
+ function onArtReady(fn) {
295
+ if (art) {
296
+ fn();
297
+ return;
298
+ }
299
+ waiters.add(fn);
300
+ }
301
+ //#endregion
302
+ //#region src/avatar/face.ts
303
+ const brow$1 = (dy = 0, tilt = 0, arch = 2.5) => ({
304
+ dy,
305
+ tilt,
306
+ arch
307
+ });
308
+ const mouth$1 = (m) => ({
309
+ w: 13,
310
+ sL: 1.5,
311
+ sR: 1.5,
312
+ open: 0,
313
+ round: 0,
314
+ teeth: 0,
315
+ tongue: 0,
316
+ dx: 0,
317
+ ...m
318
+ });
319
+ const BASE = {
320
+ browL: brow$1(),
321
+ browR: brow$1(),
322
+ lidL: .12,
323
+ lidR: .12,
324
+ lowL: 0,
325
+ lowR: 0,
326
+ lidTilt: 0,
327
+ closedCurveL: 1,
328
+ closedCurveR: 1,
329
+ pupil: 1,
330
+ mouth: mouth$1({}),
331
+ cheeks: .25,
332
+ headTilt: 0,
333
+ squash: 0,
334
+ extras: []
335
+ };
336
+ const face = (f) => ({
337
+ ...BASE,
338
+ ...f
339
+ });
340
+ /** The launch set of 16 expressions (§3.5). Kid-friendly: angry and shocked stay cartoon-cute, cry is one tear. */
341
+ const EXPRESSIONS = {
342
+ neutral: BASE,
343
+ happy: face({
344
+ browL: brow$1(-2, 0, 3.5),
345
+ browR: brow$1(-2, 0, 3.5),
346
+ lowL: .22,
347
+ lowR: .22,
348
+ mouth: mouth$1({
349
+ w: 19,
350
+ sL: 5,
351
+ sR: 5,
352
+ open: 7,
353
+ teeth: .5,
354
+ tongue: .6
355
+ }),
356
+ cheeks: .65
357
+ }),
358
+ laugh: face({
359
+ browL: brow$1(-4, -1, 4),
360
+ browR: brow$1(-4, -1, 4),
361
+ lidL: 1,
362
+ lidR: 1,
363
+ mouth: mouth$1({
364
+ w: 22,
365
+ sL: 6,
366
+ sR: 6,
367
+ open: 13,
368
+ teeth: .45,
369
+ tongue: .9
370
+ }),
371
+ cheeks: .9,
372
+ headTilt: -6,
373
+ squash: .03,
374
+ extras: ["sparkle"]
375
+ }),
376
+ grin: face({
377
+ browL: brow$1(-2.5, 0, 3.5),
378
+ browR: brow$1(-2.5, 0, 3.5),
379
+ lowL: .3,
380
+ lowR: .3,
381
+ mouth: mouth$1({
382
+ w: 21,
383
+ sL: 5.5,
384
+ sR: 5.5,
385
+ open: 4.5,
386
+ teeth: 1
387
+ }),
388
+ cheeks: .7
389
+ }),
390
+ sad: face({
391
+ browL: brow$1(1, -4.5, 1),
392
+ browR: brow$1(1, -4.5, 1),
393
+ lidL: .3,
394
+ lidR: .3,
395
+ lidTilt: -3,
396
+ mouth: mouth$1({
397
+ w: 12,
398
+ sL: -4,
399
+ sR: -4
400
+ }),
401
+ cheeks: .1,
402
+ headTilt: 4
403
+ }),
404
+ cry: face({
405
+ browL: brow$1(1, -5.5, 1),
406
+ browR: brow$1(1, -5.5, 1),
407
+ lidL: .4,
408
+ lidR: .4,
409
+ lidTilt: -3,
410
+ lowL: .15,
411
+ lowR: .15,
412
+ mouth: mouth$1({
413
+ w: 13,
414
+ sL: -4.5,
415
+ sR: -4.5,
416
+ open: 3
417
+ }),
418
+ cheeks: .4,
419
+ headTilt: 5,
420
+ extras: ["tear"]
421
+ }),
422
+ surprised: face({
423
+ browL: brow$1(-7, -1, 4),
424
+ browR: brow$1(-7, -1, 4),
425
+ lidL: 0,
426
+ lidR: 0,
427
+ pupil: .8,
428
+ mouth: mouth$1({
429
+ w: 9,
430
+ sL: 0,
431
+ sR: 0,
432
+ open: 11,
433
+ round: 1,
434
+ tongue: .3
435
+ }),
436
+ cheeks: .2,
437
+ squash: -.05
438
+ }),
439
+ shocked: face({
440
+ browL: brow$1(-9, -2, 4.5),
441
+ browR: brow$1(-9, -2, 4.5),
442
+ lidL: 0,
443
+ lidR: 0,
444
+ pupil: .7,
445
+ mouth: mouth$1({
446
+ w: 11,
447
+ sL: -1,
448
+ sR: -1,
449
+ open: 14,
450
+ round: .8,
451
+ tongue: .4
452
+ }),
453
+ cheeks: .15,
454
+ squash: -.07,
455
+ extras: ["sweat"]
456
+ }),
457
+ angry: face({
458
+ browL: brow$1(2, 5.5, 0),
459
+ browR: brow$1(2, 5.5, 0),
460
+ lidL: .32,
461
+ lidR: .32,
462
+ lidTilt: 4,
463
+ mouth: mouth$1({
464
+ w: 15,
465
+ sL: -2.5,
466
+ sR: -2.5,
467
+ open: 3.5,
468
+ teeth: .9
469
+ }),
470
+ cheeks: .35,
471
+ extras: ["anger"]
472
+ }),
473
+ grumpy: face({
474
+ browL: brow$1(2, 3.5, .5),
475
+ browR: brow$1(2, 3.5, .5),
476
+ lidL: .42,
477
+ lidR: .42,
478
+ lidTilt: 2,
479
+ mouth: mouth$1({
480
+ w: 12,
481
+ sL: -2,
482
+ sR: -2.5,
483
+ dx: -1
484
+ }),
485
+ cheeks: .1,
486
+ headTilt: -3
487
+ }),
488
+ thinking: face({
489
+ browL: brow$1(-5, -1.5, 3.5),
490
+ browR: brow$1(1.5, 3, .5),
491
+ lidL: .1,
492
+ lidR: .3,
493
+ mouth: mouth$1({
494
+ w: 9,
495
+ sL: -.5,
496
+ sR: 1.5,
497
+ dx: 4
498
+ }),
499
+ cheeks: .15,
500
+ headTilt: -5,
501
+ extras: ["question"]
502
+ }),
503
+ wink: face({
504
+ browL: brow$1(-2, 0, 3.5),
505
+ browR: brow$1(1.5, 1.5, 1.5),
506
+ lidR: 1,
507
+ lowL: .15,
508
+ mouth: mouth$1({
509
+ w: 16,
510
+ sL: 2,
511
+ sR: 5.5,
512
+ dx: 1.5
513
+ }),
514
+ cheeks: .6,
515
+ headTilt: 3,
516
+ extras: ["sparkle"]
517
+ }),
518
+ smug: face({
519
+ browL: brow$1(0, 1, 1.5),
520
+ browR: brow$1(-4, -1, 3.5),
521
+ lidL: .45,
522
+ lidR: .4,
523
+ lowL: .1,
524
+ lowR: .1,
525
+ mouth: mouth$1({
526
+ w: 14,
527
+ sL: 1,
528
+ sR: 5,
529
+ dx: 3
530
+ }),
531
+ cheeks: .45,
532
+ headTilt: 4
533
+ }),
534
+ worried: face({
535
+ browL: brow$1(-2, -5, 2),
536
+ browR: brow$1(-2, -5, 2),
537
+ lidL: .05,
538
+ lidR: .05,
539
+ pupil: .9,
540
+ mouth: mouth$1({
541
+ w: 11,
542
+ sL: -1.5,
543
+ sR: -1,
544
+ open: 1.5
545
+ }),
546
+ cheeks: .1,
547
+ extras: ["sweat"]
548
+ }),
549
+ sleepy: face({
550
+ browL: brow$1(1, -1, 1.5),
551
+ browR: brow$1(1, -1, 1.5),
552
+ lidL: .72,
553
+ lidR: .72,
554
+ closedCurveL: -1,
555
+ closedCurveR: -1,
556
+ mouth: mouth$1({
557
+ w: 7,
558
+ sL: 0,
559
+ sR: 0,
560
+ open: 2.5,
561
+ round: 1
562
+ }),
563
+ cheeks: .2,
564
+ headTilt: 7,
565
+ extras: ["zzz"]
566
+ }),
567
+ determined: face({
568
+ browL: brow$1(1, 3.5, .5),
569
+ browR: brow$1(1, 3.5, .5),
570
+ lidL: .22,
571
+ lidR: .22,
572
+ lidTilt: 2,
573
+ mouth: mouth$1({
574
+ w: 12,
575
+ sL: 1,
576
+ sR: 1
577
+ }),
578
+ cheeks: .25
579
+ })
580
+ };
581
+ /** Every expression name, in the launch order. */
582
+ const EXPRESSION_NAMES = [
583
+ "neutral",
584
+ "happy",
585
+ "laugh",
586
+ "grin",
587
+ "sad",
588
+ "cry",
589
+ "surprised",
590
+ "shocked",
591
+ "angry",
592
+ "grumpy",
593
+ "thinking",
594
+ "wink",
595
+ "smug",
596
+ "worried",
597
+ "sleepy",
598
+ "determined"
599
+ ];
600
+ /** True for a known expression name. */
601
+ function isExpression(name) {
602
+ return Object.prototype.hasOwnProperty.call(EXPRESSIONS, name);
603
+ }
604
+ const lerp = (a, b, t) => a + (b - a) * t;
605
+ const mixBrow = (a, b, t) => ({
606
+ dy: lerp(a.dy, b.dy, t),
607
+ tilt: lerp(a.tilt, b.tilt, t),
608
+ arch: lerp(a.arch, b.arch, t)
609
+ });
610
+ const mixMouth = (a, b, t) => ({
611
+ w: lerp(a.w, b.w, t),
612
+ sL: lerp(a.sL, b.sL, t),
613
+ sR: lerp(a.sR, b.sR, t),
614
+ open: lerp(a.open, b.open, t),
615
+ round: lerp(a.round, b.round, t),
616
+ teeth: lerp(a.teeth, b.teeth, t),
617
+ tongue: lerp(a.tongue, b.tongue, t),
618
+ dx: lerp(a.dx, b.dx, t)
619
+ });
620
+ /** An expression's parameters (a name or parameters pass through). */
621
+ function faceOf(e) {
622
+ return typeof e === "string" ? EXPRESSIONS[e] : e;
623
+ }
624
+ /** Blends two expressions: `mix('happy', 'surprised', 0.4)`. */
625
+ function mix(a, b, t) {
626
+ const from = faceOf(a);
627
+ const to = faceOf(b);
628
+ return {
629
+ browL: mixBrow(from.browL, to.browL, t),
630
+ browR: mixBrow(from.browR, to.browR, t),
631
+ lidL: lerp(from.lidL, to.lidL, t),
632
+ lidR: lerp(from.lidR, to.lidR, t),
633
+ lowL: lerp(from.lowL, to.lowL, t),
634
+ lowR: lerp(from.lowR, to.lowR, t),
635
+ lidTilt: lerp(from.lidTilt, to.lidTilt, t),
636
+ closedCurveL: lerp(from.closedCurveL, to.closedCurveL, t),
637
+ closedCurveR: lerp(from.closedCurveR, to.closedCurveR, t),
638
+ pupil: lerp(from.pupil, to.pupil, t),
639
+ mouth: mixMouth(from.mouth, to.mouth, t),
640
+ cheeks: lerp(from.cheeks, to.cheeks, t),
641
+ headTilt: lerp(from.headTilt, to.headTilt, t),
642
+ squash: lerp(from.squash, to.squash, t),
643
+ extras: t < .5 ? from.extras : to.extras
644
+ };
645
+ }
646
+ /** The hub's quick-chat emotes (`e:<emoji>`, the safety package's EMOTES) mapped to faces (§3.7), so an emote can also play on the sender's face. */
647
+ const EMOTE_FACES = {
648
+ "😀": "happy",
649
+ "😂": "laugh",
650
+ "😮": "surprised",
651
+ "😢": "sad",
652
+ "😎": "smug",
653
+ "🤔": "thinking",
654
+ "👍": "grin",
655
+ "👏": "happy",
656
+ "🎉": "laugh",
657
+ "❤️": "happy",
658
+ "🔥": "determined",
659
+ "⭐": "grin"
660
+ };
661
+ /** The face for a quick-chat emote (`e:😂` or the bare emoji), or `null`. */
662
+ function expressionFromEmote(emoteId) {
663
+ return EMOTE_FACES[emoteId.startsWith("e:") ? emoteId.slice(2) : emoteId] ?? null;
664
+ }
665
+ //#endregion
666
+ //#region src/avatar/geom.ts
667
+ /** Rounds to one decimal and drops a trailing `.0` and negative zero. */
668
+ function n(v) {
669
+ const r = Math.round(v * 10) / 10;
670
+ return Object.is(r, -0) ? "0" : String(r);
671
+ }
672
+ /** A path of absolute M/L/C/Q/Z commands that can be warped as a whole. */
673
+ var Path = class {
674
+ constructor() {
675
+ _defineProperty(this, "cmds", []);
676
+ }
677
+ m(px, py) {
678
+ this.cmds.push({
679
+ c: "M",
680
+ p: [px, py]
681
+ });
682
+ return this;
683
+ }
684
+ l(px, py) {
685
+ this.cmds.push({
686
+ c: "L",
687
+ p: [px, py]
688
+ });
689
+ return this;
690
+ }
691
+ c(x1, y1, x2, y2, px, py) {
692
+ this.cmds.push({
693
+ c: "C",
694
+ p: [
695
+ x1,
696
+ y1,
697
+ x2,
698
+ y2,
699
+ px,
700
+ py
701
+ ]
702
+ });
703
+ return this;
704
+ }
705
+ q(x1, y1, px, py) {
706
+ this.cmds.push({
707
+ c: "Q",
708
+ p: [
709
+ x1,
710
+ y1,
711
+ px,
712
+ py
713
+ ]
714
+ });
715
+ return this;
716
+ }
717
+ z() {
718
+ this.cmds.push({
719
+ c: "Z",
720
+ p: []
721
+ });
722
+ return this;
723
+ }
724
+ /** Appends every command of another path. */
725
+ add(other) {
726
+ this.cmds.push(...other.cmds);
727
+ return this;
728
+ }
729
+ /** The lowest y of any point (control points included, so it never under-reports). */
730
+ bottom() {
731
+ return this.cmds.reduce((low, { p }) => p.reduce((lo, v, i) => i % 2 === 1 ? Math.max(lo, v) : lo, low), -Infinity);
732
+ }
733
+ /** The SVG `d` string, optionally warping every point. */
734
+ d(warp) {
735
+ return this.cmds.map(({ c, p }) => {
736
+ if (c === "Z") return "Z";
737
+ const out = [];
738
+ for (let i = 0; i < p.length; i += 2) {
739
+ const px = p[i] ?? 0;
740
+ const py = p[i + 1] ?? 0;
741
+ const [wx, wy] = warp ? warp(px, py) : [px, py];
742
+ out.push(`${n(wx)} ${n(wy)}`);
743
+ }
744
+ return c + out.join(" ");
745
+ }).join("");
746
+ }
747
+ };
748
+ /** A point by index, or the origin. */
749
+ const at = (pts, i) => pts[i] ?? [0, 0];
750
+ /**
751
+ * A smooth Catmull-Rom curve through the points (cubic segments). Points
752
+ * flagged `'c'` become sharp corners.
753
+ */
754
+ function blob(pts, isClosed = true, tension = 1, into = new Path()) {
755
+ const count = pts.length;
756
+ if (count === 0) return into;
757
+ const wrap = (i) => isClosed ? at(pts, (i + count) % count) : at(pts, Math.max(0, Math.min(count - 1, i)));
758
+ const tangent = (i) => {
759
+ if (wrap(i)[2] === "c" || !isClosed && (i === 0 || i === count - 1)) return [0, 0];
760
+ const a = wrap(i - 1);
761
+ const b = wrap(i + 1);
762
+ return [(b[0] - a[0]) * tension / 6, (b[1] - a[1]) * tension / 6];
763
+ };
764
+ const first = at(pts, 0);
765
+ into.m(first[0], first[1]);
766
+ const segs = isClosed ? count : count - 1;
767
+ for (let i = 0; i < segs; i += 1) {
768
+ const p1 = wrap(i);
769
+ const p2 = wrap(i + 1);
770
+ const t1 = tangent(i);
771
+ const t2 = tangent(i + 1);
772
+ into.c(p1[0] + t1[0], p1[1] + t1[1], p2[0] - t2[0], p2[1] - t2[1], p2[0], p2[1]);
773
+ }
774
+ if (isClosed) into.z();
775
+ return into;
776
+ }
777
+ /** Points along an ellipse arc; angles in degrees, 0 = right, 90 = down. */
778
+ function arc(cx, cy, rx, ry, a0, a1, steps) {
779
+ const out = [];
780
+ for (let i = 0; i <= steps; i += 1) {
781
+ const a = (a0 + (a1 - a0) * i / steps) * Math.PI / 180;
782
+ out.push([cx + rx * Math.cos(a), cy + ry * Math.sin(a)]);
783
+ }
784
+ return out;
785
+ }
786
+ /**
787
+ * A scalloped edge: one outward bump between each pair of points (quadratic
788
+ * curves), for curls and clouds. `side` 1 bumps to the left of travel.
789
+ */
790
+ function scallop(pts, bump, isClosed, side = 1, into = new Path()) {
791
+ const count = pts.length;
792
+ const first = at(pts, 0);
793
+ into.m(first[0], first[1]);
794
+ const segs = isClosed ? count : count - 1;
795
+ for (let i = 0; i < segs; i += 1) {
796
+ const a = at(pts, i);
797
+ const b = at(pts, (i + 1) % count);
798
+ const dx = b[0] - a[0];
799
+ const dy = b[1] - a[1];
800
+ const len = Math.hypot(dx, dy) || 1;
801
+ into.q((a[0] + b[0]) / 2 + dy / len * bump * side, (a[1] + b[1]) / 2 + -dx / len * bump * side, b[0], b[1]);
802
+ }
803
+ if (isClosed) into.z();
804
+ return into;
805
+ }
806
+ /** A rounded rectangle path. */
807
+ function roundedRect(px, py, w, h, r) {
808
+ return new Path().m(px + r, py).l(px + w - r, py).q(px + w, py, px + w, py + r).l(px + w, py + h - r).q(px + w, py + h, px + w - r, py + h).l(px + r, py + h).q(px, py + h, px, py + h - r).l(px, py + r).q(px, py, px + r, py).z();
809
+ }
810
+ /** Clamps `v` to [lo, hi]. */
811
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
812
+ //#endregion
813
+ //#region src/avatar/render/colours.ts
814
+ /**
815
+ * Resolves a look's colours into every tint slot the art uses (§3.2): each
816
+ * fill gets its shade, highlight and tonal outline (style C, §14.1), brows
817
+ * and beards are kept apart from the skin (§14.2), and accessories default
818
+ * to the body colour.
819
+ */
820
+ /** The soft dark-brown ink for lids, mouths and closed eyes (§14.1). */
821
+ const SOFT_INK = "#3a2a2a";
822
+ /** The fixed colours every look shares. */
823
+ const FIXED = {
824
+ ink: SOFT_INK,
825
+ white: "#fbfaf6",
826
+ mouth: "#5a1d2a",
827
+ tongue: "#ff7d8e",
828
+ blush: "#ff7b8f",
829
+ pupil: "#140f0d"
830
+ };
831
+ /** A fill and its tones. */
832
+ function tones(hex) {
833
+ return {
834
+ base: hex,
835
+ shade: shade(hex),
836
+ light: light(hex),
837
+ ink: outline(hex)
838
+ };
839
+ }
840
+ /** The colours of a look. */
841
+ function lookColours(look) {
842
+ const skin = resolveColour(look.skin);
843
+ const hair = resolveColour(look.hair.colour);
844
+ const browBase = look.brows.colour === void 0 ? lightness(hair) > .78 ? adjust(hair, -.22) : adjust(hair, -.05) : resolveColour(look.brows.colour);
845
+ const beard = keepApart(look.facialHair.colour === void 0 ? hair : resolveColour(look.facialHair.colour), skin);
846
+ const eye = resolveColour(look.eyes.colour);
847
+ const body = resolveColour(look.body.colour);
848
+ return {
849
+ skin,
850
+ skinShade: shade(skin),
851
+ skinLight: light(skin),
852
+ skinInk: outline(skin),
853
+ eyeRim: adjust(skin, -.2, 1.1),
854
+ hair,
855
+ hairShade: shade(hair),
856
+ hairLight: light(hair),
857
+ hairInk: outline(hair),
858
+ hairBuzz: mix$1(hair, skin, .42),
859
+ brow: keepApart(browBase, skin),
860
+ beard,
861
+ beardShade: shade(beard),
862
+ beardInk: outline(beard),
863
+ stubble: mix$1(beard, skin, .35),
864
+ eye,
865
+ eyeDark: adjust(eye, -.18),
866
+ body,
867
+ bodyShade: shade(body),
868
+ bodyLight: light(body),
869
+ bodyInk: outline(body)
870
+ };
871
+ }
872
+ /** An accessory's colour: its own, else the body colour. */
873
+ function accessoryColour(look, accessory) {
874
+ return accessory?.colour === void 0 ? resolveColour(look.body.colour) : resolveColour(accessory.colour);
875
+ }
876
+ /** Style C's outline width at the 100-unit head height (§14.1). */
877
+ const LINE = 2.4;
878
+ /** Part fills are outlined a little thinner than lines (the POC's style C). */
879
+ const FILL_OUTLINE = .8;
880
+ /** The head outline (centre 0,0; 100 units tall). */
881
+ const HEAD_PATH = new Path().m(0, -50).c(27, -50, 46, -32, 46, -4).c(46, 24, 30, 50, 0, 50).c(-30, 50, -46, 24, -46, -4).c(-46, -32, -27, -50, 0, -50).z();
882
+ /** The head as a clip region. */
883
+ const HEAD_CLIP = { shapes: [path(HEAD_PATH.d(), {})] };
884
+ /** Where a face feature centred at `x0` lands after the head turn. */
885
+ function feat(ctx, x0) {
886
+ const t0 = Math.asin(clamp(x0 / 46, -1, 1));
887
+ const t = t0 + ctx.phi;
888
+ return {
889
+ x: 46 * Math.sin(t),
890
+ sx: Math.max(.05, Math.cos(t) / Math.cos(t0)),
891
+ vis: Math.cos(t) > .22
892
+ };
893
+ }
894
+ /**
895
+ * A warp that wraps a part around the skull by `phi`. Unlike a strict
896
+ * cylinder, the far side is squeezed toward the silhouette instead of
897
+ * folding behind it, so outlines never collapse into wedges (§14.2).
898
+ */
899
+ function turnWarp(phi, dy = 0) {
900
+ if (phi === 0 && dy === 0) return;
901
+ const half = Math.PI / 2;
902
+ return (px, py) => {
903
+ const ax = Math.abs(px);
904
+ const sg = Math.sign(px) || 1;
905
+ const t0 = Math.asin(Math.min(ax, 46) / 46) * sg;
906
+ const t = t0 + phi * (phi >= 0 ? t0 <= 0 ? 1 : 1 - t0 / half : t0 >= 0 ? 1 : 1 + t0 / half);
907
+ const over = Math.max(0, ax - 46) * Math.max(.25, Math.abs(Math.sin(t)));
908
+ return [46 * Math.sin(t) + sg * over, py + dy];
909
+ };
910
+ }
911
+ /** Resolves a tint slot. `acc` is the accessory (or hair-tie) colour for this part. */
912
+ function slotColour(c, acc, slot) {
913
+ switch (slot) {
914
+ case "none": return "none";
915
+ case "acc": return acc.base;
916
+ case "accShade": return acc.shade;
917
+ case "accLight": return acc.light;
918
+ case "accInk": return acc.ink;
919
+ case "ink": return FIXED.ink;
920
+ case "white": return FIXED.white;
921
+ default: return c[slot];
922
+ }
923
+ }
924
+ /** The tonal outline slot for a fill slot. */
925
+ function outlineOf(slot) {
926
+ if (slot.startsWith("hair")) return "hairInk";
927
+ if (slot === "beard" || slot === "beardShade" || slot === "stubble") return "beardInk";
928
+ if (slot.startsWith("acc")) return "accInk";
929
+ if (slot.startsWith("skin")) return "skinInk";
930
+ return "ink";
931
+ }
932
+ /** Paints catalog shapes with an optional warp; `clip: 'head'` shapes are clipped to the head. */
933
+ function paintShapes(c, acc, shapes, warp) {
934
+ if (!shapes) return [];
935
+ return shapes.map((s) => {
936
+ const d = s.d.d(warp);
937
+ const opacity = s.opacity === void 0 ? {} : { opacity: s.opacity };
938
+ if (s.line) return path(d, {
939
+ fill: "none",
940
+ stroke: slotColour(c, acc, s.stroke ?? "ink"),
941
+ sw: s.sw ?? 2.4,
942
+ cap: s.cap ?? "round",
943
+ ...opacity
944
+ });
945
+ const strokeSlot = s.stroke ?? outlineOf(s.fill);
946
+ const stroke = strokeSlot === "none" ? {} : {
947
+ stroke: slotColour(c, acc, strokeSlot),
948
+ sw: (s.sw ?? 2.4) * FILL_OUTLINE
949
+ };
950
+ const node = path(d, {
951
+ fill: slotColour(c, acc, s.fill),
952
+ ...s.evenOdd ? { evenOdd: true } : {},
953
+ ...stroke,
954
+ ...opacity
955
+ });
956
+ return s.clip === "head" ? g([node], { clip: HEAD_CLIP }) : node;
957
+ });
958
+ }
959
+ //#endregion
960
+ //#region src/avatar/render/body.ts
961
+ /**
962
+ * The body blob and its poses (§3.11). Each pose is a transform of the same
963
+ * gumdrop plus where the head sits; a future limbed body re-implements the
964
+ * same names. The cycle poses (`walk`, `run`, `dance`, `jump`, `cheer`) read
965
+ * `phase` (0 … 1).
966
+ */
967
+ const BODY = blob([
968
+ [-35, -2],
969
+ [-40, -24],
970
+ [-30, -50],
971
+ [0, -60],
972
+ [30, -50],
973
+ [40, -24],
974
+ [35, -2],
975
+ [0, 2]
976
+ ]).d();
977
+ const STOOL = [
978
+ rect(-27, -12, 6, 12, {
979
+ fill: "#2a3142",
980
+ stroke: "#0d0f14",
981
+ sw: 2
982
+ }),
983
+ rect(21, -12, 6, 12, {
984
+ fill: "#2a3142",
985
+ stroke: "#0d0f14",
986
+ sw: 2
987
+ }),
988
+ rect(-38, -22, 76, 11, {
989
+ fill: "#4a5470",
990
+ stroke: "#0d0f14",
991
+ sw: 3
992
+ }, 5.5)
993
+ ];
994
+ const hand = (c, cx, cy) => ellipse(cx, cy, 8, 8, {
995
+ fill: c.body,
996
+ stroke: c.bodyInk,
997
+ sw: LINE
998
+ });
999
+ /** Lays out the body for a pose; `phi` is the body turn (shading shifts with it). */
1000
+ function bodyLayout(c, pose, phi, phase, hasStool) {
1001
+ const wave = Math.sin(phase * Math.PI * 2);
1002
+ const hop = Math.abs(Math.sin(phase * Math.PI));
1003
+ let tf = [];
1004
+ let headDx = 0;
1005
+ let headDy = 0;
1006
+ let tilt = 0;
1007
+ let lift = 0;
1008
+ const behind = [];
1009
+ const front = [];
1010
+ switch (pose) {
1011
+ case "sit":
1012
+ case "play": {
1013
+ const up = hasStool ? -18 : 0;
1014
+ tf = [tr(0, up), sc(1.12, .8)];
1015
+ headDy = 12 + up;
1016
+ if (hasStool) behind.push(...STOOL);
1017
+ break;
1018
+ }
1019
+ case "walk":
1020
+ case "run": {
1021
+ const k = pose === "run" ? 1.6 : 1;
1022
+ tf = [
1023
+ tr(0, -10 * k),
1024
+ skewX(-10 * k),
1025
+ sc(1 - wave * .04, 1 + wave * .06)
1026
+ ];
1027
+ headDy = -14 * k + wave * 3;
1028
+ headDx = 7 * k;
1029
+ tilt = 6 * k;
1030
+ behind.push(path("M-52 -30h-14M-50 -18h-20M-54 -6h-10", line("#6f7787", 3, "round", .8)));
1031
+ break;
1032
+ }
1033
+ case "wave":
1034
+ tf = [rot(-6)];
1035
+ headDx = -5;
1036
+ tilt = -7;
1037
+ front.push(hand(c, 44, -58), path("M56 -70Q61 -64 58 -56M62 -76Q69 -66 64 -54", line("#e8e6df", 2.2, "round", .8)));
1038
+ break;
1039
+ case "cheer":
1040
+ lift = hop * 10;
1041
+ tf = [sc(1 - hop * .04, 1 + hop * .05)];
1042
+ headDy = -4 * hop;
1043
+ front.push(hand(c, -42, -66 - hop * 4), hand(c, 42, -66 - hop * 4));
1044
+ break;
1045
+ case "jump":
1046
+ lift = hop * 18;
1047
+ tf = [sc(1 - hop * .06, 1 + hop * .08)];
1048
+ headDy = -6 * hop;
1049
+ break;
1050
+ case "dance":
1051
+ tf = [rot(wave * 8), sc(1 + hop * .03, 1 - hop * .03)];
1052
+ headDx = wave * 5;
1053
+ tilt = wave * 7;
1054
+ break;
1055
+ case "away":
1056
+ tf = [sc(1.04, .95)];
1057
+ headDy = 3;
1058
+ tilt = 5;
1059
+ }
1060
+ const hl = -16 - Math.sin(phi) * 8;
1061
+ const body = [g([
1062
+ path(BODY, { fill: c.bodyShade }),
1063
+ path(BODY, { fill: c.body }, [tr(-3 - Math.sin(phi) * 2, -3), sc(.9)]),
1064
+ ellipse(0, -54, 20, 6, { fill: c.bodyShade }),
1065
+ ellipse(0, 0, 6, 4, { fill: c.bodyLight }, [tr(hl, -36), rot(-30)]),
1066
+ path(BODY, {
1067
+ fill: "none",
1068
+ stroke: c.bodyInk,
1069
+ sw: LINE
1070
+ })
1071
+ ], { tf })];
1072
+ const isSitting = pose === "sit" || pose === "play";
1073
+ return {
1074
+ behind,
1075
+ body,
1076
+ front,
1077
+ headDx,
1078
+ headDy,
1079
+ tilt,
1080
+ lift,
1081
+ prop: {
1082
+ x: headDx * .5,
1083
+ y: isSitting ? -34 + (hasStool ? -18 : 0) * .8 : -30
1084
+ }
1085
+ };
1086
+ }
1087
+ /** The soft ground shadow at the feet (shrinks while lifted). */
1088
+ function groundShadow(lift) {
1089
+ const k = Math.max(.5, 1 - lift / 40);
1090
+ return ellipse(0, 0, 36 * k, 8 * k, {
1091
+ fill: "#000000",
1092
+ opacity: .3
1093
+ });
1094
+ }
1095
+ //#endregion
1096
+ //#region src/avatar/render/eyewear.ts
1097
+ /** The default frame colour. */
1098
+ const FRAME_BLACK = "#1d1a1e";
1099
+ const SHINE = "M-6 -4L-2 -7";
1100
+ const MIRROR_STREAKS = "M-9 7L3 -11M-1 10L9 -4";
1101
+ /** A frame stroke along a path, over its edge when it has one. */
1102
+ function frameLine(d, frame, sw = frame.sw) {
1103
+ return [...frame.edge === void 0 ? [] : [path(d, line(frame.edge, sw + 1.6))], path(d, line(frame.colour, sw))];
1104
+ }
1105
+ /** One lens (or visor) in its own coordinates: the glass, then the frame. */
1106
+ function lensNodes(d, glass, frame, shines) {
1107
+ const out = [];
1108
+ const clip = { shapes: [path(d, {})] };
1109
+ if (glass.tint === void 0) out.push(path(d, {
1110
+ fill: "#ffffff",
1111
+ fillOpacity: .16
1112
+ }));
1113
+ else if (glass.isMirror === true) out.push(path(d, { fill: glass.tint }), g([rect(-60, 2, 120, 40, {
1114
+ fill: outline(glass.tint),
1115
+ opacity: .75
1116
+ }), ...shines.map(([px, py]) => path(MIRROR_STREAKS, line("#ffffff", 2.6, "round", .85), [tr(px, py)]))], { clip }));
1117
+ else out.push(path(d, {
1118
+ fill: glass.tint,
1119
+ fillOpacity: .86
1120
+ }), g([rect(-60, 3, 120, 40, {
1121
+ fill: "#ffffff",
1122
+ opacity: .12
1123
+ })], { clip }));
1124
+ out.push(...frameLine(d, frame));
1125
+ out.push(...shines.map(([px, py]) => path(SHINE, line("#ffffff", 1.8, "round", glass.tint === void 0 ? .7 : .9), [tr(px, py)])));
1126
+ return out;
1127
+ }
1128
+ /** Glasses: arms toward the ears, a lens at each eye (or one visor), and the bridge. */
1129
+ function glasses(ctx, part, colour, glass) {
1130
+ const out = [];
1131
+ const warp = turnWarp(ctx.phi, ctx.dy);
1132
+ const isEdged = lightness(colour) > .7 || deltaE(colour, ctx.c.skin) < 24;
1133
+ const frame = {
1134
+ colour,
1135
+ sw: part.frame ?? 3.2,
1136
+ edge: isEdged ? ink(colour) : void 0
1137
+ };
1138
+ const tinted = part.tint === void 0 ? {} : {
1139
+ tint: glass.tint ?? part.tint,
1140
+ ...glass.isMirror === true ? { isMirror: true } : {}
1141
+ };
1142
+ if (part.isSingle !== true) {
1143
+ for (const side of [-1, 1]) if (feat(ctx, 30 * side).vis) out.push(...frameLine(new Path().m(30 * side, 2).l(45 * side, 0).d(warp), frame, 2.6));
1144
+ }
1145
+ if (part.visor) {
1146
+ const shines = [feat(ctx, -17), feat(ctx, 17)].filter((f) => f.vis).map((f) => [f.x, 4 + ctx.dy]);
1147
+ out.push(...lensNodes(part.visor.d(warp), tinted, frame, shines));
1148
+ return out;
1149
+ }
1150
+ const lens = part.lens;
1151
+ for (const side of [-1, 1]) {
1152
+ const f = feat(ctx, 17 * side);
1153
+ if (!lens || !f.vis || part.isSingle === true && side < 0) continue;
1154
+ const flip = part.isMirrored === true && side < 0 ? -1 : 1;
1155
+ out.push(g([...lensNodes(lens.d(), tinted, frame, [[0, 0]]), ...part.bar ? frameLine(part.bar.d(), frame, 4.4) : []], { tf: [tr(f.x, 4 + ctx.dy), sc(f.sx * flip, 1)] }));
1156
+ }
1157
+ if (part.chain && feat(ctx, 30).vis) out.push(path(part.chain.d(warp), line(colour, 1.6, "round", .9)));
1158
+ if (part.bridge) {
1159
+ const fb = feat(ctx, 0);
1160
+ out.push(g(frameLine(part.bridge.d(), frame, 2.8), { tf: [tr(fb.x, 4 + ctx.dy), sc(fb.sx, 1)] }));
1161
+ }
1162
+ return out;
1163
+ }
1164
+ const STRAP_FRONT = new Path().m(-47, -2).c(-30, -6, 30, -6, 47, -2);
1165
+ const STRAP_BACK = new Path().m(-47, -2).c(-30, 4, 30, 4, 47, -2);
1166
+ const SHELL = blob([
1167
+ [-31, -9],
1168
+ [-4, -10],
1169
+ [0, -6],
1170
+ [4, -10],
1171
+ [31, -9],
1172
+ [34, 4],
1173
+ [26, 16],
1174
+ [7, 16],
1175
+ [0, 11],
1176
+ [-7, 16],
1177
+ [-26, 16],
1178
+ [-34, 4]
1179
+ ]);
1180
+ const GLASS_L = blob([
1181
+ [-27, -5],
1182
+ [-7, -6],
1183
+ [-5, 8],
1184
+ [-9, 12],
1185
+ [-24, 12],
1186
+ [-29, 4]
1187
+ ]);
1188
+ const GLASS_R = blob([
1189
+ [27, -5],
1190
+ [7, -6],
1191
+ [5, 8],
1192
+ [9, 12],
1193
+ [24, 12],
1194
+ [29, 4]
1195
+ ]);
1196
+ /** Play goggles over the eyes (they replace the eyes and any glasses); the strap is the body colour. */
1197
+ function goggles(ctx) {
1198
+ const w = turnWarp(ctx.phi, ctx.dy);
1199
+ const band = ctx.c.body;
1200
+ const strap = STRAP_FRONT.d(w);
1201
+ return [
1202
+ path(strap, line(ink(band), 9)),
1203
+ path(strap, line(band, 6)),
1204
+ path(SHELL.d(w), {
1205
+ fill: "#2a3142",
1206
+ stroke: "#0d0f14",
1207
+ sw: LINE
1208
+ }),
1209
+ path(GLASS_L.d(w), { fill: "#5fe3f2" }),
1210
+ path(GLASS_R.d(w), { fill: "#5fe3f2" }),
1211
+ path(new Path().m(-22, 6).l(-14, -2).d(w), line("#e8fbff", 3)),
1212
+ path(new Path().m(12, 6).l(20, -2).d(w), line("#e8fbff", 3))
1213
+ ];
1214
+ }
1215
+ /** The goggles strap seen from behind. */
1216
+ function gogglesStrapBack(ctx, phi) {
1217
+ const d = STRAP_BACK.d(turnWarp(phi));
1218
+ return [path(d, line(ink(ctx.c.body), 9)), path(d, line(ctx.c.body, 6))];
1219
+ }
1220
+ //#endregion
1221
+ //#region src/avatar/render/eyes.ts
1222
+ /**
1223
+ * Eyes and brows in style C (§14.1): whites 7% larger than style A's with a
1224
+ * 1.6-unit rim in a darker shade of the skin, an iris with a darker ring, a
1225
+ * pupil, two glints, and a soft dark-brown lid line. No dark outline.
1226
+ */
1227
+ /** Eye-white radii: style A's 9.6 × 11.8, 7% larger. */
1228
+ const RX = 10.272;
1229
+ const RY = 11.8 * 1.07;
1230
+ /** One eye; `side` −1 is the viewer's left. */
1231
+ function eye(ctx, side) {
1232
+ const f = feat(ctx, 17 * side);
1233
+ if (!f.vis) return [];
1234
+ const fp = ctx.face;
1235
+ const lidBase = side < 0 ? fp.lidL : fp.lidR;
1236
+ const lid = lidBase + (1 - lidBase) * ctx.blink;
1237
+ const low = side < 0 ? fp.lowL : fp.lowR;
1238
+ const curve = side < 0 ? fp.closedCurveL : fp.closedCurveR;
1239
+ const place = [tr(f.x, 4 + ctx.dy), sc(f.sx, 1)];
1240
+ if (lid >= .9) return [g([path(`M-8 1.5Q0 ${n(1.5 - 9 * curve)} 8 1.5`, line(SOFT_INK, LINE * 1.05))], { tf: place })];
1241
+ const top = -12.626000000000001 + lid * 2 * RY;
1242
+ const edgeY = (px) => top + fp.lidTilt * (px * -side) / 9;
1243
+ const bot = RY - low * 2 * RY;
1244
+ const gx = ctx.gaze.x * 2.8;
1245
+ const gy = ctx.gaze.y * 3.4;
1246
+ const clip = { shapes: [ellipse(0, 0, RX, RY, {})] };
1247
+ const inner = [g([
1248
+ ellipse(0, 0, 7, 7, { fill: ctx.c.eye }),
1249
+ ellipse(0, 0, 6.4, 6.4, line(ctx.c.eyeDark, 1.2)),
1250
+ ellipse(0, 0, 3.6 * fp.pupil, 3.6 * fp.pupil, { fill: FIXED.pupil }),
1251
+ ellipse(-2.4, -2.8, 2.2, 2.2, { fill: "#ffffff" }),
1252
+ ellipse(2.1, 2, .9, .9, { fill: "#ffffff" })
1253
+ ], { tf: [tr(gx, gy)] })];
1254
+ if (lid > .02) inner.push(path(`M-12 -14L12 -14L12 ${n(edgeY(12))}Q0 ${n(top + 1.5)} -12 ${n(edgeY(-12))}Z`, { fill: ctx.c.skin }));
1255
+ if (low > .02) inner.push(path(`M-12 14L12 14L12 ${n(bot)}Q0 ${n(bot - 3)} -12 ${n(bot)}Z`, { fill: ctx.c.skin }));
1256
+ const out = [ellipse(0, 0, RX, RY, { fill: FIXED.white }), g(inner, { clip })];
1257
+ if (lid > .06) out.push(g([path(`M${n(-9.772)} ${n(edgeY(-9.772))}Q0 ${n(top + 1.5)} ${n(9.772)} ${n(edgeY(9.772))}`, line(SOFT_INK, 1.9))], { clip }));
1258
+ out.push(ellipse(0, 0, RX, RY, line(ctx.c.eyeRim, 1.6)));
1259
+ return [g(out, { tf: place })];
1260
+ }
1261
+ const BROW_W = {
1262
+ soft: 3.8,
1263
+ thin: 2.4,
1264
+ thick: 5.6,
1265
+ arched: 3.6,
1266
+ straight: 4.2
1267
+ };
1268
+ const BROW_ARCH = {
1269
+ soft: 1,
1270
+ thin: 1,
1271
+ thick: .8,
1272
+ arched: 1.9,
1273
+ straight: .2
1274
+ };
1275
+ /** One brow in a style (`soft`, `thin`, …). */
1276
+ function brow(ctx, side, style) {
1277
+ const f = feat(ctx, 17 * side);
1278
+ if (!f.vis) return [];
1279
+ const p = side < 0 ? ctx.face.browL : ctx.face.browR;
1280
+ const by = -14 + p.dy + ctx.dy * 1.1;
1281
+ const xi = -7.5 * side;
1282
+ const xo = 7.5 * side;
1283
+ const yi = by + p.tilt * .8;
1284
+ const yo = by - p.tilt * .35;
1285
+ const arch = p.arch * (BROW_ARCH[style] ?? 1);
1286
+ const width = (BROW_W[style] ?? 3.8) * 1.15;
1287
+ return [g([path(`M${n(xi)} ${n(yi)}Q0 ${n(Math.min(yi, yo) - arch * 2)} ${n(xo)} ${n(yo)}`, line(ctx.c.brow, width))], { tf: [tr(f.x, 0), sc(f.sx, 1)] })];
1288
+ }
1289
+ //#endregion
1290
+ //#region src/avatar/render/head.ts
1291
+ /** The head, ears, cheeks and the small face and ear extras. */
1292
+ const EAR = "M-2 -10C6 -12 11 -6 10 1C9 8 5 11 -1 10Z";
1293
+ const EAR_FOLD = "M1.5 -5C5.5 -5 6.5 0 5 3.5";
1294
+ /** Ears behind the head (`behind`) or in front of it (`front`), hidden when turned away. */
1295
+ function ears(ctx, which, extra) {
1296
+ const out = [];
1297
+ for (const side of [-1, 1]) {
1298
+ const t = Math.PI / 2 * side + ctx.phi;
1299
+ const cos = Math.cos(t);
1300
+ if (cos < -.15) continue;
1301
+ const isInFront = cos > .3;
1302
+ if (which === "front" !== isInFront) continue;
1303
+ const earX = clamp(46 * Math.sin(t), -44, 44);
1304
+ const w = isInFront ? .75 + .25 * cos : 1;
1305
+ const flip = isInFront ? -Math.sign(earX || side) : side;
1306
+ const parts = [path(EAR, {
1307
+ fill: ctx.c.skin,
1308
+ stroke: ctx.c.skinInk,
1309
+ sw: LINE
1310
+ }), path(EAR_FOLD, line(ctx.c.skinShade, 2.2))];
1311
+ if (extra?.kind === "studs") parts.push(ellipse(3, 7, 2.2, 2.2, {
1312
+ fill: extra.colour,
1313
+ stroke: "#3a2a2a",
1314
+ sw: .8
1315
+ }));
1316
+ if (extra?.kind === "hoops") parts.push(ellipse(3, 12, 3.6, 4.4, line(extra.colour, 1.8)));
1317
+ out.push(g(parts, { tf: [tr(earX, 6 + ctx.dy * .3), sc(flip * w, 1)] }));
1318
+ }
1319
+ return out;
1320
+ }
1321
+ /** The head: a shade base, the lit face, and the tonal outline. */
1322
+ function head(ctx) {
1323
+ const d = HEAD_PATH.d();
1324
+ const lx = -2.5 + Math.sin(ctx.phi) * 3;
1325
+ return [
1326
+ path(d, { fill: ctx.c.skinShade }),
1327
+ path(d, { fill: ctx.c.skin }, [tr(lx, -3), sc(.95)]),
1328
+ path(d, {
1329
+ fill: "none",
1330
+ stroke: ctx.c.skinInk,
1331
+ sw: LINE
1332
+ })
1333
+ ];
1334
+ }
1335
+ const FRECKLES = [
1336
+ [-3.5, -1.5],
1337
+ [0, .8],
1338
+ [3.5, -1.2],
1339
+ [1.5, -3.5]
1340
+ ];
1341
+ /** Cheek blush (from the expression, plus `rosy`) and freckles. */
1342
+ function cheeks(ctx, hasFreckles, isRosy) {
1343
+ const out = [];
1344
+ const blush = Math.min(1, ctx.face.cheeks + (isRosy ? .45 : 0));
1345
+ for (const side of [-1, 1]) {
1346
+ const f = feat(ctx, 25 * side);
1347
+ if (!f.vis) continue;
1348
+ const parts = [];
1349
+ if (blush > .02) parts.push(ellipse(0, 0, 6.5, 3.8, {
1350
+ fill: FIXED.blush,
1351
+ opacity: Math.round(blush * 5) / 10
1352
+ }));
1353
+ if (hasFreckles) parts.push(...FRECKLES.map(([fx, fy]) => ellipse(fx, fy, 1.05, 1.05, {
1354
+ fill: ctx.c.skinInk,
1355
+ opacity: .45
1356
+ })));
1357
+ if (parts.length > 0) out.push(g(parts, { tf: [tr(f.x, 18 + ctx.dy), sc(f.sx, 1)] }));
1358
+ }
1359
+ return out;
1360
+ }
1361
+ //#endregion
1362
+ //#region src/avatar/render/mouth.ts
1363
+ /**
1364
+ * The nose, the mouth morph (one cubic structure for every shape, so any two
1365
+ * expressions tween, §3.5) and the small FX glyphs.
1366
+ */
1367
+ /** The nose: a soft shade with a highlight. */
1368
+ function nose(ctx) {
1369
+ const f = feat(ctx, 0);
1370
+ return [g([ellipse(0, 0, 4.4, 3, { fill: ctx.c.skinShade }), ellipse(-1.5, -1, 1.2, 1.2, {
1371
+ fill: ctx.c.skinLight,
1372
+ opacity: .9
1373
+ })], { tf: [tr(f.x, 16 + ctx.dy), sc(Math.max(.6, f.sx), 1)] })];
1374
+ }
1375
+ /** The mouth: a line when closed, a filled shape with teeth and tongue when open. Talking adds opening. */
1376
+ function mouth(ctx) {
1377
+ const base = ctx.face.mouth;
1378
+ const m = {
1379
+ ...base,
1380
+ open: base.open + ctx.talk * 9 * (1 - base.round * .4)
1381
+ };
1382
+ const f = feat(ctx, m.dx);
1383
+ const baseY = 27;
1384
+ const lx = -m.w / 2;
1385
+ const ly = baseY - m.sL;
1386
+ const rx = m.w / 2;
1387
+ const ry = baseY - m.sR;
1388
+ const s = (m.sL + m.sR) / 2;
1389
+ const corner = baseY - s;
1390
+ const ctrl = (target) => (target - .25 * corner) / .75;
1391
+ const place = [tr(f.x, ctx.dy * .8), sc(Math.max(.35, f.sx), 1)];
1392
+ if (m.open < 1) {
1393
+ const c = ctrl(baseY + s * .55);
1394
+ return [g([path(`M${n(lx)} ${n(ly)}C${n(-m.w * .18)} ${n(c)} ${n(m.w * .18)} ${n(c)} ${n(rx)} ${n(ry)}`, line(SOFT_INK, LINE * .95))], { tf: place })];
1395
+ }
1396
+ const up = baseY + s * .3 - m.round * m.open * .45;
1397
+ const lo = baseY + s * .55 + m.open * (1 - m.round * .45);
1398
+ const kx = m.w / 2 * (.55 + m.round * .8);
1399
+ const d = `M${n(lx)} ${n(ly)}C${n(-kx)} ${n(ctrl(up))} ${n(kx)} ${n(ctrl(up))} ${n(rx)} ${n(ry)}C${n(kx)} ${n(ctrl(lo))} ${n(-kx)} ${n(ctrl(lo))} ${n(lx)} ${n(ly)}Z`;
1400
+ const inside = [];
1401
+ if (m.tongue > .05) inside.push(ellipse(0, lo + 1, m.w * .32, m.open * .42 * m.tongue + 1, { fill: FIXED.tongue }));
1402
+ if (m.teeth > .05) inside.push(rect(-m.w / 2, up - 6, m.w, 6 + m.open * .22 * m.teeth, { fill: FIXED.white }));
1403
+ return [g([
1404
+ path(d, { fill: FIXED.mouth }),
1405
+ g(inside, { clip: { shapes: [path(d, {})] } }),
1406
+ path(d, line(SOFT_INK, LINE * .9))
1407
+ ], { tf: place })];
1408
+ }
1409
+ /** FX glyphs beside the head (sparkle, tear, sweat, anger, question, zzz). */
1410
+ function extras(ctx) {
1411
+ const out = [];
1412
+ const f = feat(ctx, 0);
1413
+ for (const e of ctx.face.extras) {
1414
+ if (e === "sparkle") out.push(path("M0 -9Q1.4 -1.4 9 0Q1.4 1.4 0 9Q-1.4 1.4 -9 0Q-1.4 -1.4 0 -9Z", {
1415
+ fill: "#ffd23f",
1416
+ stroke: "#0d0f14",
1417
+ sw: 1.6
1418
+ }, [tr(48 + f.x * .2, -44)]));
1419
+ if (e === "tear") {
1420
+ const t = feat(ctx, -16);
1421
+ if (t.vis) out.push(path("M0 -5Q4.5 2 0 5Q-4.5 2 0 -5Z", {
1422
+ fill: "#7cc7ff",
1423
+ stroke: "#1f3b5c",
1424
+ sw: 1.4
1425
+ }, [tr(t.x - 5, 16 + ctx.dy)]));
1426
+ }
1427
+ if (e === "sweat") out.push(path("M0 -6Q5 2 0 6Q-5 2 0 -6Z", {
1428
+ fill: "#9fdcff",
1429
+ stroke: "#1f3b5c",
1430
+ sw: 1.4
1431
+ }, [tr(40 + f.x * .2, -26)]));
1432
+ if (e === "anger") out.push(g([
1433
+ path("M-6 -2Q-2 -2 -2 -6", line("#ff4d4d", 2.6)),
1434
+ path("M2 -6Q2 -2 6 -2", line("#ff4d4d", 2.6)),
1435
+ path("M6 2Q2 2 2 6", line("#ff4d4d", 2.6)),
1436
+ path("M-2 6Q-2 2 -6 2", line("#ff4d4d", 2.6))
1437
+ ], { tf: [tr(30 + f.x * .3, -36)] }));
1438
+ if (e === "question") out.push({
1439
+ k: "text",
1440
+ x: 50 + f.x * .2,
1441
+ y: -40,
1442
+ text: "?",
1443
+ size: 26,
1444
+ font: "pixel",
1445
+ fill: "#ffd23f",
1446
+ stroke: "#0d0f14",
1447
+ sw: 1.2
1448
+ });
1449
+ if (e === "zzz") out.push({
1450
+ k: "text",
1451
+ x: 44 + f.x * .2,
1452
+ y: -42,
1453
+ text: "z z",
1454
+ size: 20,
1455
+ font: "pixel",
1456
+ fill: "#8ab4ff",
1457
+ stroke: "#0d0f14",
1458
+ sw: 1.2
1459
+ });
1460
+ }
1461
+ return out;
1462
+ }
1463
+ //#endregion
1464
+ //#region src/avatar/render/props.ts
1465
+ /**
1466
+ * Status props (§3.11, §14.3): runtime-only parts the world (or a preview)
1467
+ * puts on a player. `goggles` lives in eyewear.ts; the head props here sit on
1468
+ * the head, the hand props at the `prop` anchor in front of the body.
1469
+ */
1470
+ const DARK = "#0d0f14";
1471
+ /** Hand props are drawn at this scale so they read at small sizes. */
1472
+ const PROP_SCALE = 1.45;
1473
+ /** A headset: a band over the head, ear cups and a mic (head space). */
1474
+ function headset(ctx, isBack) {
1475
+ const w = turnWarp(isBack ? 0 : ctx.phi);
1476
+ const band = blob(arc(0, -4, 51, 51, 196, 344, 8), false);
1477
+ const nodes = [path(band.d(w), line(DARK, 8)), path(band.d(w), line("#4a5470", 5))];
1478
+ const phi = isBack ? 0 : ctx.phi;
1479
+ for (const side of [-1, 1]) {
1480
+ const t = Math.PI / 2 * side + phi;
1481
+ if (Math.cos(t) < -.15) continue;
1482
+ const cupX = Math.max(-47, Math.min(47, 46 * Math.sin(t)));
1483
+ const wide = 6 + 3 * Math.max(0, Math.cos(t));
1484
+ nodes.push(path(roundedRect(cupX - wide, -6, wide * 2, 20, 5).d(), {
1485
+ fill: "#2a3142",
1486
+ stroke: DARK,
1487
+ sw: LINE
1488
+ }));
1489
+ }
1490
+ if (!isBack && Math.abs(phi) < .7) nodes.push(path(new Path().m(-46, 12).q(-40, 30, -18, 30).d(w), line("#4a5470", 3)), ellipse(-17, 30, 3, 3, {
1491
+ fill: "#ff5f3b",
1492
+ stroke: DARK,
1493
+ sw: 1.2
1494
+ }));
1495
+ return nodes;
1496
+ }
1497
+ /** A small crown on top of the head (head space; `top` is the hair or hat top). */
1498
+ function crown(top) {
1499
+ return [g([path("M-16 0L-18 -16L-8 -8L0 -20L8 -8L18 -16L16 0Z", {
1500
+ fill: "#ffd23f",
1501
+ stroke: "#8a5a00",
1502
+ sw: LINE
1503
+ }), ellipse(0, -5, 2.4, 2.4, { fill: "#ff5f3b" })], { tf: [tr(0, -50 - top + 4)] })];
1504
+ }
1505
+ const card = (colour) => rect(-6, -9, 12, 17, {
1506
+ fill: colour,
1507
+ stroke: DARK,
1508
+ sw: 1.6
1509
+ }, 2.5);
1510
+ /** Nub hands holding something at the prop anchor (body space). */
1511
+ function nubs(body, ink, spread, atY) {
1512
+ return [-1, 1].map((s) => ellipse(spread * s, atY, 7, 6, {
1513
+ fill: body,
1514
+ stroke: ink,
1515
+ sw: LINE
1516
+ }));
1517
+ }
1518
+ /** A hand prop drawn at the `prop` anchor (x, y) in front of the body. */
1519
+ function handProp(prop, atX, atY, body, bodyInk) {
1520
+ switch (prop) {
1521
+ case "cards": return [g([
1522
+ g([card("#ff5f3b")], { tf: [tr(-8, 0), rot(-18)] }),
1523
+ g([card("#ffd23f")], { tf: [tr(0, -3)] }),
1524
+ g([card("#3d7bff")], { tf: [tr(8, 0), rot(18)] }),
1525
+ ...nubs(body, bodyInk, 13, 8)
1526
+ ], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
1527
+ case "controller": return [g([
1528
+ path(new Path().m(-20, -4).c(-20, -12, 20, -12, 20, -4).c(24, 10, 14, 14, 8, 6).l(-8, 6).c(-14, 14, -24, 10, -20, -4).z().d(), {
1529
+ fill: "#2a3142",
1530
+ stroke: DARK,
1531
+ sw: LINE
1532
+ }),
1533
+ ellipse(-9, -2, 2.6, 2.6, { fill: "#8ab4ff" }),
1534
+ ellipse(10, -4, 2, 2, { fill: "#ff5f3b" }),
1535
+ ellipse(13, 0, 2, 2, { fill: "#4ade80" }),
1536
+ ...nubs(body, bodyInk, 20, 4)
1537
+ ], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
1538
+ case "book": return [g([
1539
+ path("M0 -2C-8 -8 -18 -8 -24 -5L-24 12C-18 9 -8 9 0 14C8 9 18 9 24 12L24 -5C18 -8 8 -8 0 -2Z", {
1540
+ fill: "#f4f1ea",
1541
+ stroke: DARK,
1542
+ sw: LINE
1543
+ }),
1544
+ path("M0 -2L0 14", line("#c9c2b4", 1.6)),
1545
+ path("M-19 0L-5 3M-19 5L-5 8M5 3L19 0M5 8L19 5", line("#9a9384", 1.2)),
1546
+ path("M-24 12C-18 9 -8 9 0 14C8 9 18 9 24 12L24 15C18 12 8 12 0 17C-8 12 -18 12 -24 15Z", {
1547
+ fill: "#ff5f3b",
1548
+ stroke: DARK,
1549
+ sw: 1.4
1550
+ }),
1551
+ ...nubs(body, bodyInk, 25, 6)
1552
+ ], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
1553
+ case "trophy": return [g([
1554
+ path("M-11 -14L11 -14L9 0C8 6 -8 6 -9 0Z", {
1555
+ fill: "#ffd23f",
1556
+ stroke: "#8a5a00",
1557
+ sw: LINE
1558
+ }),
1559
+ path("M-11 -11C-18 -11 -18 -2 -9 -1M11 -11C18 -11 18 -2 9 -1", line("#8a5a00", 2.2)),
1560
+ rect(-3, 4, 6, 6, { fill: "#e0a800" }),
1561
+ rect(-9, 10, 18, 5, {
1562
+ fill: "#6b4f12",
1563
+ stroke: DARK,
1564
+ sw: 1.2
1565
+ }, 1.5),
1566
+ ellipse(13, 6, 7, 6, {
1567
+ fill: body,
1568
+ stroke: bodyInk,
1569
+ sw: LINE
1570
+ })
1571
+ ], { tf: [tr(atX + 24, atY - 10), sc(PROP_SCALE)] })];
1572
+ default: return [];
1573
+ }
1574
+ }
1575
+ //#endregion
1576
+ //#region src/avatar/render/compose.ts
1577
+ /**
1578
+ * Composes a look and a pose into a scene in rig space: the feet at (0, 0),
1579
+ * y down, the head centre near (0, −96) (§14.2). Layers go back to front as
1580
+ * in §3.2, with facial hair split into beard and moustache layers.
1581
+ */
1582
+ /** The lens tint drawn with a mirror finish. */
1583
+ const MIRROR_TINT = "p:lens-mirror";
1584
+ /** The head centre's height above the feet. */
1585
+ const HEAD_Y = -96;
1586
+ const DEG = Math.PI / 180;
1587
+ const FACING_ANGLE = {
1588
+ S: 0,
1589
+ SE: 34,
1590
+ E: 70,
1591
+ NE: 145,
1592
+ N: 180
1593
+ };
1594
+ const MIRROR = {
1595
+ SW: "SE",
1596
+ W: "E",
1597
+ NW: "NE"
1598
+ };
1599
+ const VIEW_FACING = {
1600
+ front: "S",
1601
+ back: "N",
1602
+ left: "W",
1603
+ right: "E"
1604
+ };
1605
+ /** The facing an options object asks for. */
1606
+ function facingOf(opts) {
1607
+ return opts.facing ?? (opts.view ? VIEW_FACING[opts.view] : "S");
1608
+ }
1609
+ function resolveParts(look, art) {
1610
+ const slot = (name) => look.accessories.find((a) => a.slot === name);
1611
+ const hatAcc = slot("headwear");
1612
+ const glassAcc = slot("glasses");
1613
+ const earAcc = slot("ears");
1614
+ const face = slot("face");
1615
+ return {
1616
+ c: lookColours(look),
1617
+ hair: art.hair[partName(look.hair.style)],
1618
+ hat: hatAcc ? art.headwear[partName(hatAcc.id)] : void 0,
1619
+ hatColour: accessoryColour(look, hatAcc),
1620
+ beard: art.beard[partName(look.facialHair.style)],
1621
+ glasses: glassAcc ? art.glasses[partName(glassAcc.id)] : void 0,
1622
+ glassesColour: glassAcc?.colour === void 0 ? FRAME_BLACK : accessoryColour(look, glassAcc),
1623
+ lensGlass: glassAcc?.tint === void 0 ? {} : {
1624
+ tint: resolveColour(glassAcc.tint),
1625
+ ...glassAcc.tint === MIRROR_TINT ? { isMirror: true } : {}
1626
+ },
1627
+ freckles: face?.id === "face.freckles",
1628
+ rosy: face?.id === "face.rosy",
1629
+ earExtra: earAcc ? {
1630
+ kind: earAcc.id === "ears.hoops" ? "hoops" : "studs",
1631
+ colour: earAcc.colour === void 0 ? "#f5c542" : accessoryColour(look, earAcc)
1632
+ } : void 0,
1633
+ browStyle: partName(look.brows.style)
1634
+ };
1635
+ }
1636
+ /** The far-side clip for a turned head's rear art (a back-of-skull crescent). */
1637
+ function backOfHead(phi) {
1638
+ const left = -46 * Math.cos(phi) - 3;
1639
+ const cx = (left + 46 + 12) / 2;
1640
+ const rx = (58 - left) / 2;
1641
+ return {
1642
+ crescent: {
1643
+ shapes: [path(`M-200 -200H200V200H-200Z M${n(cx - rx)} 8A${n(rx)} 64 0 1 0 ${n(cx + rx)} 8A${n(rx)} 64 0 1 0 ${n(cx - rx)} 8Z`, {})],
1644
+ evenOdd: true
1645
+ },
1646
+ half: { shapes: [rect(-200, -200, 200 + cx, 400, {})] }
1647
+ };
1648
+ }
1649
+ const ABOVE_SHOULDERS = { shapes: [rect(-200, -200, 400, 237, {}), path(HEAD_PATH.d(), {})] };
1650
+ const BELOW_SHOULDERS = { shapes: [rect(-200, 35, 400, 300, {})] };
1651
+ /** True when a layer reaches below the shoulder line. */
1652
+ function hangs(shapes) {
1653
+ return shapes?.some((s) => s.d.bottom() > 36) === true;
1654
+ }
1655
+ /** Splits a hair layer at the shoulders: the part above stays in `over`, the part below goes in `behind`. */
1656
+ function splitAtShoulders(nodes, doesHang, over, behind) {
1657
+ if (nodes.length === 0) return;
1658
+ if (!doesHang) {
1659
+ over.push(...nodes);
1660
+ return;
1661
+ }
1662
+ over.push(g(nodes, { clip: ABOVE_SHOULDERS }));
1663
+ behind.push(g(nodes, { clip: BELOW_SHOULDERS }));
1664
+ }
1665
+ /** The front and side views of the head. */
1666
+ function frontHead(ctx, p, isPlaying, status, angle, gazeX) {
1667
+ const hatCut = p.hat && typeof p.hat.hides === "number" ? { shapes: [rect(-120, p.hat.hides, 240, 260, {})] } : void 0;
1668
+ const isHairShown = !(p.hat && p.hat.hides === "all");
1669
+ const cut = (nodes) => hatCut && nodes.length > 0 ? [g(nodes, { clip: hatCut })] : nodes;
1670
+ const hairTie = tones(ctx.c.body);
1671
+ const hatTones = tones(p.hatColour);
1672
+ const warpFront = turnWarp((angle + gazeX * 6) * DEG);
1673
+ const warpBack = ctx.phi === 0 ? void 0 : (px, py) => [px - Math.sin(ctx.phi) * 8, py];
1674
+ const behindBody = [];
1675
+ const out = [];
1676
+ if (isHairShown) behindBody.push(...cut(paintShapes(ctx.c, hairTie, p.hair?.back, warpBack)));
1677
+ if (p.hat?.back) out.push(...paintShapes(ctx.c, hatTones, p.hat.back, warpBack));
1678
+ if (!p.hat?.hidesEars) out.push(...ears(ctx, "behind", p.earExtra));
1679
+ out.push(...head(ctx));
1680
+ const turnS = Math.sin(ctx.phi);
1681
+ if (Math.abs(turnS) > .3) {
1682
+ const co = Math.cos(ctx.phi);
1683
+ const shift = (px, py) => [px * (.92 + .08 * co) - turnS * 5, py];
1684
+ const clips = backOfHead(ctx.phi);
1685
+ const onFarSide = (nodes) => nodes.length > 0 ? [g([g(nodes, { clip: clips.crescent })], { clip: clips.half })] : [];
1686
+ if (isHairShown) splitAtShoulders(onFarSide(cut(paintShapes(ctx.c, hairTie, p.hair?.rear, shift))), hangs(p.hair?.rear), out, behindBody);
1687
+ if (p.hat) out.push(...onFarSide(paintShapes(ctx.c, hatTones, p.hat.rear, shift)));
1688
+ }
1689
+ if (!p.hat?.hidesEars) out.push(...ears(ctx, "front", p.earExtra));
1690
+ out.push(...cheeks(ctx, p.freckles, p.rosy));
1691
+ const faceWarp = turnWarp(ctx.phi, ctx.dy * .8);
1692
+ out.push(...paintShapes(ctx.c, hairTie, p.beard?.beard, faceWarp));
1693
+ if (!isPlaying) out.push(...eye(ctx, -1), ...eye(ctx, 1));
1694
+ out.push(...brow(ctx, -1, p.browStyle), ...brow(ctx, 1, p.browStyle));
1695
+ if (turnS > .8) out.push(path("M43 9C50 11 51 18 43.5 20", {
1696
+ fill: ctx.c.skin,
1697
+ stroke: ctx.c.skinInk,
1698
+ sw: LINE
1699
+ }));
1700
+ out.push(...nose(ctx), ...mouth(ctx));
1701
+ out.push(...paintShapes(ctx.c, hairTie, p.beard?.moustache, faceWarp));
1702
+ if (isHairShown) {
1703
+ splitAtShoulders(cut(paintShapes(ctx.c, hairTie, p.hair?.front, warpFront)), hangs(p.hair?.front), out, behindBody);
1704
+ if (p.hair?.front?.some((s) => s.clip === "head")) out.push(path(HEAD_PATH.d(), {
1705
+ fill: "none",
1706
+ stroke: ctx.c.skinInk,
1707
+ sw: LINE
1708
+ }));
1709
+ }
1710
+ if (isPlaying) out.push(...goggles(ctx));
1711
+ else if (p.glasses) out.push(...glasses(ctx, p.glasses, p.glassesColour, p.lensGlass));
1712
+ if (p.hat) out.push(...paintShapes(ctx.c, hatTones, p.hat.front, warpFront));
1713
+ if (status === "headset") out.push(...headset(ctx, false));
1714
+ out.push(...extras(ctx));
1715
+ return {
1716
+ behindBody,
1717
+ head: out
1718
+ };
1719
+ }
1720
+ /** The back views (N, NE): the rear art wrapped past 180°. */
1721
+ function backHead(ctx, p, isPlaying, status, angle) {
1722
+ const phi = (angle - 180) * DEG;
1723
+ const warp = turnWarp(phi);
1724
+ const isHairShown = !(p.hat && p.hat.hides === "all");
1725
+ const hatCut = p.hat && typeof p.hat.hides === "number" ? { shapes: [rect(-120, p.hat.hides, 240, 260, {})] } : void 0;
1726
+ const out = [];
1727
+ if (!p.hat?.hidesEars) out.push(...ears({
1728
+ ...ctx,
1729
+ phi
1730
+ }, "behind", p.earExtra));
1731
+ out.push(...head({
1732
+ ...ctx,
1733
+ phi: 0
1734
+ }));
1735
+ if (isHairShown) {
1736
+ const rear = paintShapes(ctx.c, tones(ctx.c.body), p.hair?.rear, warp);
1737
+ out.push(...hatCut && rear.length > 0 ? [g(rear, { clip: hatCut })] : rear);
1738
+ if (p.hair?.rear.some((s) => s.clip === "head")) out.push(path(HEAD_PATH.d(), {
1739
+ fill: "none",
1740
+ stroke: ctx.c.skinInk,
1741
+ sw: LINE
1742
+ }));
1743
+ }
1744
+ if (isPlaying) out.push(...gogglesStrapBack(ctx, phi));
1745
+ if (p.hat) out.push(...paintShapes(ctx.c, tones(p.hatColour), p.hat.rear, warp));
1746
+ if (status === "headset") out.push(...headset(ctx, true));
1747
+ return {
1748
+ behindBody: [],
1749
+ head: out
1750
+ };
1751
+ }
1752
+ /** The grey silhouette: a malformed code, a hidden player, or before the art loads. */
1753
+ function silhouette(hasShadow) {
1754
+ const grey = "#6f7787";
1755
+ return [
1756
+ ...hasShadow ? [groundShadow(0)] : [],
1757
+ path("M-35 -2C-42 -20 -38 -46 -22 -56C-8 -62 8 -62 22 -56C38 -46 42 -20 35 -2C20 3 -20 3 -35 -2Z", { fill: grey }),
1758
+ path(HEAD_PATH.d(), { fill: grey }, [tr(0, -96), sc(.94)])
1759
+ ];
1760
+ }
1761
+ function anchorsFor(headX, headY, top, prop, mirror) {
1762
+ const at = (px, py) => ({
1763
+ x: px * mirror,
1764
+ y: py
1765
+ });
1766
+ return {
1767
+ feet: at(0, 0),
1768
+ body: at(0, -30),
1769
+ neck: at(headX, headY + 46),
1770
+ head: at(headX, headY),
1771
+ eyeLine: at(headX, headY + 4),
1772
+ mouth: at(headX, headY + 27),
1773
+ hatTop: at(headX, top),
1774
+ nameplate: at(0, top - 10),
1775
+ bubble: at(0, top - 14),
1776
+ seat: at(0, 0),
1777
+ prop: at(prop.x, prop.y),
1778
+ shadow: at(0, 0),
1779
+ handL: at(-40, -30),
1780
+ handR: at(40, -30),
1781
+ pod: at(0, 14)
1782
+ };
1783
+ }
1784
+ /** Composes a look (or `null` for the silhouette) with a pose. */
1785
+ function composeAvatar(look, opts = {}) {
1786
+ const art = currentArt();
1787
+ const facingIn = facingOf(opts);
1788
+ const isMirrored = MIRROR[facingIn] !== void 0;
1789
+ const facing = MIRROR[facingIn] ?? (facingIn === "S" || facingIn === "SE" || facingIn === "E" || facingIn === "NE" ? facingIn : "N");
1790
+ const hasShadow = opts.shadow ?? opts.frame === "full";
1791
+ if (!look || !art) return {
1792
+ nodes: silhouette(hasShadow),
1793
+ anchors: anchorsFor(0, -96, -156, {
1794
+ x: 0,
1795
+ y: -30
1796
+ }, 1)
1797
+ };
1798
+ const pose = opts.pose ?? {};
1799
+ const bodyPose = pose.body ?? "idle";
1800
+ const status = pose.status ?? (bodyPose === "play" ? "goggles" : null);
1801
+ const isPlaying = status === "goggles";
1802
+ const face = pose.face ?? EXPRESSIONS[pose.expression ?? (bodyPose === "play" ? "determined" : bodyPose === "away" ? "sleepy" : "neutral")];
1803
+ const gaze = pose.look ?? {
1804
+ x: 0,
1805
+ y: 0
1806
+ };
1807
+ const angle = FACING_ANGLE[facing];
1808
+ const isBack = angle > 90;
1809
+ const parts = resolveParts(look, art);
1810
+ const ctx = {
1811
+ c: parts.c,
1812
+ face,
1813
+ gaze,
1814
+ phi: (angle + gaze.x * 11) * DEG,
1815
+ dy: gaze.y * 3,
1816
+ blink: pose.blink ?? 0,
1817
+ talk: pose.talk ?? 0
1818
+ };
1819
+ const b = bodyLayout(parts.c, bodyPose, ctx.phi, pose.phase ?? .25, opts.stool === true);
1820
+ const headX = b.headDx + (pose.bob?.x ?? 0);
1821
+ const headY = -96 + b.headDy + (pose.bob?.y ?? 0);
1822
+ const tilt = face.headTilt + b.tilt + (pose.tilt ?? 0);
1823
+ const sq = face.squash + (pose.squash ?? 0);
1824
+ const drawn = isBack ? backHead(ctx, parts, isPlaying, status, angle) : frontHead(ctx, parts, isPlaying, status, angle, gaze.x);
1825
+ const headNodes = drawn.head;
1826
+ const top = headY - 50 - Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0);
1827
+ if (status === "crown") headNodes.push(...crown(Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0)));
1828
+ const handProps = status && !isBack ? handProp(status, b.prop.x, b.prop.y, parts.c.body, parts.c.bodyInk) : [];
1829
+ const headTf = [
1830
+ tr(headX, headY),
1831
+ rot(tilt),
1832
+ sc(1 + sq, 1 - sq)
1833
+ ];
1834
+ const character = [
1835
+ ...b.behind,
1836
+ ...drawn.behindBody.length > 0 ? [g(drawn.behindBody, { tf: headTf })] : [],
1837
+ ...b.body,
1838
+ g(headNodes, { tf: headTf }),
1839
+ ...b.front,
1840
+ ...handProps
1841
+ ];
1842
+ const lifted = b.lift > 0 ? [g(character, { tf: [tr(0, -b.lift)] })] : character;
1843
+ const rimmed = opts.rim ? [g(lifted, { rim: true })] : lifted;
1844
+ const all = [...hasShadow ? [groundShadow(b.lift)] : [], ...rimmed];
1845
+ const mirror = isMirrored ? -1 : 1;
1846
+ return {
1847
+ nodes: isMirrored ? [g(all, { tf: [sc(-1, 1)] })] : all,
1848
+ anchors: anchorsFor(headX, headY - b.lift, top - b.lift, {
1849
+ x: b.prop.x,
1850
+ y: b.prop.y - b.lift
1851
+ }, mirror)
1852
+ };
1853
+ }
1854
+ //#endregion
1855
+ //#region src/avatar/outputs/svg-markup.ts
1856
+ /**
1857
+ * Scene → SVG markup. Ids for clips and the rim filter are prefixed per call,
1858
+ * so many avatars can be inlined on one page. The output holds only numbers,
1859
+ * derived colours and escaped overlay text, so it is safe to insert as HTML.
1860
+ */
1861
+ /** The rim filter: a dilated, soft light edge behind the silhouette. */
1862
+ const RIM_COLOUR = "#aab3c7";
1863
+ const FONTS = {
1864
+ mono: "'Space Mono', monospace",
1865
+ pixel: "'VT323', monospace"
1866
+ };
1867
+ /** Three decimals, for scales and opacities. */
1868
+ const fine = (v) => String(Math.round(v * 1e3) / 1e3);
1869
+ /** Escapes text for SVG content. */
1870
+ function escapeXml(text) {
1871
+ return text.replace(/[&<>"']/g, (c) => `&#${String(c.charCodeAt(0))};`);
1872
+ }
1873
+ /** A transform list as an SVG `transform` attribute (empty for none). */
1874
+ function tfAttr(tf) {
1875
+ if (!tf || tf.length === 0) return "";
1876
+ return ` transform="${tf.map((op) => {
1877
+ switch (op.k) {
1878
+ case "t": return `translate(${n(op.x)} ${n(op.y)})`;
1879
+ case "s": return `scale(${fine(op.x)} ${fine(op.y)})`;
1880
+ case "r": return `rotate(${n(op.a)})`;
1881
+ case "skx": return `skewX(${n(op.a)})`;
1882
+ }
1883
+ }).join(" ")}"`;
1884
+ }
1885
+ function paintAttrs(p) {
1886
+ let out = ` fill="${p.fill ?? "none"}"`;
1887
+ if (p.evenOdd) out += " fill-rule=\"evenodd\"";
1888
+ if (p.fillOpacity !== void 0) out += ` fill-opacity="${fine(p.fillOpacity)}"`;
1889
+ if (p.stroke && p.stroke !== "none") out += ` stroke="${p.stroke}" stroke-width="${n(p.sw ?? 1)}" stroke-linejoin="round" stroke-linecap="${p.cap ?? "round"}"`;
1890
+ if (p.opacity !== void 0 && p.opacity < 1) out += ` opacity="${fine(p.opacity)}"`;
1891
+ return out;
1892
+ }
1893
+ /** Collects `<defs>` while serialising. */
1894
+ var Writer = class {
1895
+ constructor(prefix) {
1896
+ _defineProperty(this, "defs", []);
1897
+ _defineProperty(this, "count", 0);
1898
+ _defineProperty(this, "usesRim", false);
1899
+ _defineProperty(this, "prefix", void 0);
1900
+ this.prefix = prefix;
1901
+ }
1902
+ id(kind) {
1903
+ this.count += 1;
1904
+ return `${this.prefix}${kind}${this.count.toString(36)}`;
1905
+ }
1906
+ clip(clip) {
1907
+ const id = this.id("c");
1908
+ const rule = clip.evenOdd ? " clip-rule=\"evenodd\"" : "";
1909
+ this.defs.push(`<clipPath id="${id}">${clip.shapes.map((s) => this.node(s).replace("<path ", `<path${rule} `)).join("")}</clipPath>`);
1910
+ return id;
1911
+ }
1912
+ node(node) {
1913
+ switch (node.k) {
1914
+ case "g": {
1915
+ let attrs = tfAttr(node.tf);
1916
+ if (node.clip) attrs += ` clip-path="url(#${this.clip(node.clip)})"`;
1917
+ if (node.opacity !== void 0 && node.opacity < 1) attrs += ` opacity="${fine(node.opacity)}"`;
1918
+ if (node.rim) {
1919
+ this.usesRim = true;
1920
+ attrs += ` filter="url(#${this.prefix}rim)"`;
1921
+ }
1922
+ const inner = node.children.map((child) => this.node(child)).join("");
1923
+ return attrs === "" ? inner : `<g${attrs}>${inner}</g>`;
1924
+ }
1925
+ case "path": return `<path d="${node.d}"${tfAttr(node.tf)}${paintAttrs(node)}/>`;
1926
+ case "ellipse": return node.rx === node.ry ? `<circle cx="${n(node.cx)}" cy="${n(node.cy)}" r="${n(node.rx)}"${tfAttr(node.tf)}${paintAttrs(node)}/>` : `<ellipse cx="${n(node.cx)}" cy="${n(node.cy)}" rx="${n(node.rx)}" ry="${n(node.ry)}"${tfAttr(node.tf)}${paintAttrs(node)}/>`;
1927
+ case "rect": return `<rect x="${n(node.x)}" y="${n(node.y)}" width="${n(node.w)}" height="${n(node.h)}"${node.r ? ` rx="${n(node.r)}"` : ""}${tfAttr(node.tf)}${paintAttrs(node)}/>`;
1928
+ case "text": return `<text x="${n(node.x)}" y="${n(node.y)}" font-family="${FONTS[node.font]}" font-size="${n(node.size)}"${node.bold ? " font-weight=\"700\"" : ""}${node.anchor === "middle" ? " text-anchor=\"middle\"" : ""} paint-order="stroke"${paintAttrs(node)}>${escapeXml(node.text)}</text>`;
1929
+ }
1930
+ }
1931
+ };
1932
+ /** A scene as SVG markup plus the `<defs>` it needs. */
1933
+ function sceneToSvg(nodes, prefix) {
1934
+ const writer = new Writer(prefix);
1935
+ const body = nodes.map((node) => writer.node(node)).join("");
1936
+ if (writer.usesRim) writer.defs.push(`<filter id="${prefix}rim" x="-20%" y="-20%" width="140%" height="140%"><feMorphology in="SourceAlpha" operator="dilate" radius="2.8" result="d"/><feFlood flood-color="${RIM_COLOUR}" flood-opacity=".48"/><feComposite in2="d" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>`);
1937
+ return {
1938
+ body,
1939
+ defs: writer.defs.join("")
1940
+ };
1941
+ }
1942
+ //#endregion
1943
+ export { tr as A, pixelSize as C, path as D, line as E, toLook as F, deltaE as M, lightness as N, rect as O, toCode as P, framed as S, g as T, mix as _, composeAvatar as a, FRAMES as b, blob as c, scallop as d, EXPRESSIONS as f, isExpression as g, faceOf as h, HEAD_Y as i, contrast as j, rot as k, n as l, expressionFromEmote as m, escapeXml as n, Path as o, EXPRESSION_NAMES as p, sceneToSvg as r, arc as s, RIM_COLOUR as t, roundedRect as u, onArtReady as v, ellipse as w, frameOf as x, ready as y };
1944
+
1945
+ //# sourceMappingURL=svg-markup.js.map