@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.10

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