@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.11

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 (246) hide show
  1. package/CHANGELOG.md +173 -5
  2. package/README.md +27 -15
  3. package/dist/auto.d.ts +8 -8
  4. package/dist/auto.js +3 -3
  5. package/dist/avatar-pixi.d.ts +165 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +299 -0
  8. package/dist/avatar-pixi.js.map +1 -0
  9. package/dist/avatar-svg.d.ts +4 -0
  10. package/dist/avatar-svg.js +5 -0
  11. package/dist/avatar-three.d.ts +69 -0
  12. package/dist/avatar-three.d.ts.map +1 -0
  13. package/dist/avatar-three.js +49 -0
  14. package/dist/avatar-three.js.map +1 -0
  15. package/dist/avatar.d.ts +7 -0
  16. package/dist/avatar.js +500 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +134 -10
  20. package/dist/doctor/cli.mjs.map +1 -1
  21. package/dist/index.d.ts +8 -8
  22. package/dist/index.js +3 -3
  23. package/dist/input.d.ts +2 -2
  24. package/dist/input.js +2 -2
  25. package/dist/mp.d.ts +3 -3
  26. package/dist/race.d.ts +1 -1
  27. package/dist/rooms.d.ts +3 -3
  28. package/dist/rooms.js +1 -1
  29. package/dist/shared/animator.d.ts +104 -0
  30. package/dist/shared/animator.d.ts.map +1 -0
  31. package/dist/shared/art.js +1946 -0
  32. package/dist/shared/art.js.map +1 -0
  33. package/dist/shared/atlas.d.ts +138 -0
  34. package/dist/shared/atlas.d.ts.map +1 -0
  35. package/dist/shared/avatar.d.ts +123 -0
  36. package/dist/shared/avatar.d.ts.map +1 -0
  37. package/dist/shared/bridge.js +18 -4
  38. package/dist/shared/bridge.js.map +1 -1
  39. package/dist/shared/client-version.gen.js +1 -1
  40. package/dist/shared/daily.d.ts +145 -19
  41. package/dist/shared/daily.d.ts.map +1 -1
  42. package/dist/shared/daily.js +138 -25
  43. package/dist/shared/daily.js.map +1 -1
  44. package/dist/shared/defineProperty.js +39 -0
  45. package/dist/shared/describe.d.ts +32 -0
  46. package/dist/shared/describe.d.ts.map +1 -0
  47. package/dist/shared/describe.js +109 -0
  48. package/dist/shared/describe.js.map +1 -0
  49. package/dist/shared/globals.js +1 -39
  50. package/dist/shared/globals.js.map +1 -1
  51. package/dist/shared/index.d.ts +1122 -46
  52. package/dist/shared/index.d.ts.map +1 -1
  53. package/dist/shared/input.d.ts +74 -14
  54. package/dist/shared/input.d.ts.map +1 -1
  55. package/dist/shared/input.js +179 -25
  56. package/dist/shared/input.js.map +1 -1
  57. package/dist/shared/json-guards.js +48 -0
  58. package/dist/shared/json-guards.js.map +1 -0
  59. package/dist/shared/latest-state.js +1 -1
  60. package/dist/shared/live-canvas.js +62 -0
  61. package/dist/shared/live-canvas.js.map +1 -0
  62. package/dist/shared/mp.js +10 -2
  63. package/dist/shared/mp.js.map +1 -1
  64. package/dist/shared/multiplayer-types.d.ts +15 -2
  65. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  66. package/dist/shared/overlays.js +800 -0
  67. package/dist/shared/overlays.js.map +1 -0
  68. package/dist/shared/page-stack.js +2 -1
  69. package/dist/shared/page-stack.js.map +1 -1
  70. package/dist/shared/pose-key.js +249 -0
  71. package/dist/shared/pose-key.js.map +1 -0
  72. package/dist/shared/race-types.d.ts +1 -1
  73. package/dist/shared/race.js +1 -1
  74. package/dist/shared/room-types.d.ts +28 -3
  75. package/dist/shared/room-types.d.ts.map +1 -1
  76. package/dist/shared/rooms.d.ts.map +1 -1
  77. package/dist/shared/rooms.js +23 -4
  78. package/dist/shared/rooms.js.map +1 -1
  79. package/dist/shared/runtime-opener.js +1 -1
  80. package/dist/shared/scene.d.ts +90 -0
  81. package/dist/shared/scene.d.ts.map +1 -0
  82. package/dist/shared/social.d.ts +90 -9
  83. package/dist/shared/social.d.ts.map +1 -1
  84. package/dist/shared/social.js +6 -1
  85. package/dist/shared/social.js.map +1 -1
  86. package/dist/shared/src.js +4563 -667
  87. package/dist/shared/src.js.map +1 -1
  88. package/dist/shared/svg-markup.js +2411 -0
  89. package/dist/shared/svg-markup.js.map +1 -0
  90. package/dist/shared/svg.js +27 -0
  91. package/dist/shared/svg.js.map +1 -0
  92. package/dist/shared/types.d.ts +174 -0
  93. package/dist/shared/types.d.ts.map +1 -0
  94. package/dist/shared/wire-types.d.ts +6 -2
  95. package/dist/shared/wire-types.d.ts.map +1 -1
  96. package/dist/social.d.ts +5 -5
  97. package/dist/social.js +2 -2
  98. package/dist/turns-rules.d.ts +2 -0
  99. package/dist/turns-rules.js +1 -0
  100. package/dist/turns.d.ts +61 -0
  101. package/dist/turns.d.ts.map +1 -0
  102. package/dist/turns.js +183 -0
  103. package/dist/turns.js.map +1 -0
  104. package/package.json +39 -3
  105. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  106. package/src/avatar/adapters/pixi-crowd.ts +329 -0
  107. package/src/avatar/adapters/pixi-types.ts +84 -0
  108. package/src/avatar/adapters/three.ts +126 -0
  109. package/src/avatar/animator/animator.ts +285 -0
  110. package/src/avatar/animator/reactions.ts +44 -0
  111. package/src/avatar/animator/schedule.ts +73 -0
  112. package/src/avatar/art/beard.ts +49 -0
  113. package/src/avatar/art/glasses.ts +67 -0
  114. package/src/avatar/art/hair.ts +254 -0
  115. package/src/avatar/art/headwear.ts +75 -0
  116. package/src/avatar/art/index.ts +21 -0
  117. package/src/avatar/art/shape.ts +87 -0
  118. package/src/avatar/art-store.ts +36 -0
  119. package/src/avatar/atlas.ts +246 -0
  120. package/src/avatar/catalog-view.ts +14 -0
  121. package/src/avatar/colour.ts +109 -0
  122. package/src/avatar/describe.ts +79 -0
  123. package/src/avatar/face.ts +187 -0
  124. package/src/avatar/geom.ts +173 -0
  125. package/src/avatar/layout.ts +47 -0
  126. package/src/avatar/look-like.ts +52 -0
  127. package/src/avatar/outputs/bitmap.ts +75 -0
  128. package/src/avatar/outputs/canvas-paint.ts +177 -0
  129. package/src/avatar/outputs/canvas.ts +62 -0
  130. package/src/avatar/outputs/custom-element.ts +140 -0
  131. package/src/avatar/outputs/element.ts +58 -0
  132. package/src/avatar/outputs/live-canvas.ts +57 -0
  133. package/src/avatar/outputs/overlay-svg.ts +22 -0
  134. package/src/avatar/outputs/pose-key.ts +30 -0
  135. package/src/avatar/outputs/svg-markup.ts +113 -0
  136. package/src/avatar/outputs/svg.ts +41 -0
  137. package/src/avatar/overlays.ts +128 -0
  138. package/src/avatar/render/body.ts +116 -0
  139. package/src/avatar/render/colours.ts +81 -0
  140. package/src/avatar/render/compose.ts +346 -0
  141. package/src/avatar/render/context.ts +117 -0
  142. package/src/avatar/render/eyes.ts +78 -0
  143. package/src/avatar/render/eyewear.ts +118 -0
  144. package/src/avatar/render/frame.ts +73 -0
  145. package/src/avatar/render/head.ts +81 -0
  146. package/src/avatar/render/mouth.ts +86 -0
  147. package/src/avatar/render/props.ts +91 -0
  148. package/src/avatar/render/types.ts +93 -0
  149. package/src/avatar/render/walker-parts.ts +124 -0
  150. package/src/avatar/render/walker.ts +239 -0
  151. package/src/avatar/scene.ts +119 -0
  152. package/src/avatar-pixi.ts +20 -0
  153. package/src/avatar-svg.ts +21 -0
  154. package/src/avatar-three.ts +18 -0
  155. package/src/avatar.ts +63 -0
  156. package/src/core/avatar/avatar-cache.ts +95 -0
  157. package/src/core/avatar/avatar-ref.ts +34 -0
  158. package/src/core/avatar/avatar-types.ts +43 -0
  159. package/src/core/avatar/hub-avatar.ts +308 -0
  160. package/src/core/connection.ts +201 -92
  161. package/src/core/errors.ts +15 -2
  162. package/src/core/game-data.ts +11 -9
  163. package/src/core/hub-economy.ts +47 -10
  164. package/src/core/hub-progress.ts +94 -30
  165. package/src/core/hub.ts +228 -7
  166. package/src/core/offline/cloud-saves.ts +62 -0
  167. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  168. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  169. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  170. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  171. package/src/core/offline/hub-kinds/index.ts +52 -0
  172. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  173. package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
  174. package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
  175. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  176. package/src/core/offline/kind-adapters.ts +48 -0
  177. package/src/core/offline/local-inventory.ts +64 -0
  178. package/src/core/offline/local-store.ts +66 -23
  179. package/src/core/offline/offline-api.ts +65 -0
  180. package/src/core/offline/outbox-entry.ts +59 -0
  181. package/src/core/offline/outbox-kinds.ts +78 -0
  182. package/src/core/offline/outbox-merge.ts +39 -0
  183. package/src/core/offline/outbox-replayer.ts +141 -0
  184. package/src/core/offline/outbox-store.ts +85 -0
  185. package/src/core/offline/outbox.ts +232 -0
  186. package/src/core/offline/queue.ts +4 -49
  187. package/src/core/offline/replay-lease.ts +87 -0
  188. package/src/core/offline/replay-policy.ts +52 -0
  189. package/src/core/page-account.ts +121 -0
  190. package/src/core/page.ts +14 -1
  191. package/src/core/rest/rest-client.ts +14 -3
  192. package/src/core/rest/routes.gen.ts +144 -88
  193. package/src/core/resume/hub-resume.ts +286 -0
  194. package/src/core/resume/local-resume.ts +130 -0
  195. package/src/core/resume/resume-list.ts +65 -0
  196. package/src/core/resume/resume-request.ts +52 -0
  197. package/src/core/resume/resume-types.ts +64 -0
  198. package/src/core/saved/daily-saves.ts +196 -0
  199. package/src/core/saved/daily-seed.ts +16 -0
  200. package/src/core/saved/hub-saved.ts +221 -0
  201. package/src/core/saved/local-saved.ts +122 -0
  202. package/src/core/saved/saved-list.ts +129 -0
  203. package/src/core/saved/saved-open.ts +104 -0
  204. package/src/core/saved/saved-order.ts +49 -0
  205. package/src/core/saved/saved-types.ts +24 -0
  206. package/src/core/storage/json-guards.ts +6 -0
  207. package/src/core/turns/hub-turns.ts +161 -0
  208. package/src/core/turns/turn-game.ts +510 -0
  209. package/src/core/turns/your-turn-buzz.ts +69 -0
  210. package/src/core/version/client-version.gen.ts +2 -2
  211. package/src/daily/daily-system.ts +127 -12
  212. package/src/daily/page-daily.ts +2 -2
  213. package/src/daily/types.ts +49 -11
  214. package/src/doctor/checks.ts +12 -4
  215. package/src/doctor/cli.ts +8 -2
  216. package/src/doctor/embed-check.ts +61 -0
  217. package/src/doctor/hub-version-fetch.ts +43 -43
  218. package/src/doctor/world-check.ts +61 -0
  219. package/src/index.ts +20 -2
  220. package/src/input/focus-ring.ts +13 -0
  221. package/src/input/input-registry.ts +9 -2
  222. package/src/input/input-system.ts +14 -1
  223. package/src/input/modality.ts +122 -0
  224. package/src/input/nav-state.ts +13 -4
  225. package/src/input/navigator.ts +11 -0
  226. package/src/input/zero-config.ts +1 -14
  227. package/src/input.ts +4 -0
  228. package/src/mp.ts +1 -1
  229. package/src/overlay/overlay.ts +30 -4
  230. package/src/overlay/styles.ts +4 -3
  231. package/src/realtime/bridge.ts +5 -0
  232. package/src/realtime/multiplayer-forwarder.ts +13 -1
  233. package/src/realtime/multiplayer-types.ts +15 -1
  234. package/src/realtime/room-types.ts +28 -2
  235. package/src/realtime/rooms-forwarder.ts +27 -2
  236. package/src/realtime/runtime-api.ts +33 -8
  237. package/src/realtime/social-forwarder.ts +5 -0
  238. package/src/realtime/social-types.ts +8 -0
  239. package/src/realtime/types.ts +1 -0
  240. package/src/realtime/wire-types.ts +7 -0
  241. package/src/realtime/world-types.ts +20 -0
  242. package/src/rooms.ts +1 -1
  243. package/src/social.ts +1 -0
  244. package/src/turns-kit/check-rules.ts +281 -0
  245. package/src/turns-rules.ts +12 -0
  246. package/src/turns.ts +14 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * `AvatarAtlas` (§3.11): bakes avatar frames into shared canvas pages for
3
+ * crowds (the world, leaderboards). Frames are baked on demand and
4
+ * time-sliced (`bake(budgetMs)` once per animation frame), shelf-packed into
5
+ * square pages, and whole pages are recycled least-recently-used. A frame
6
+ * that isn't baked yet comes back `null`, and the caller shows a silhouette.
7
+ * Mirrored facings (W, SW, NW) reuse the drawn ones with `mirror: true`.
8
+ */
9
+ import type { Expression } from './face';
10
+ import { toCode, toLook } from './look-like';
11
+ import { defaultLayer } from './outputs/canvas';
12
+ import { paintNodes, type Ctx2D } from './outputs/canvas-paint';
13
+ import { composeAvatar } from './render/compose';
14
+ import { WALKER_FULL } from './render/frame';
15
+ import type { BodyPose, Facing, Figure, LookLike, StatusProp } from './render/types';
16
+
17
+ /** What to bake. */
18
+ export interface AtlasKey {
19
+ readonly look: LookLike;
20
+ readonly facing?: Facing;
21
+ readonly body?: BodyPose;
22
+ /** 0 … 1 through the pose's cycle (walk: 8 steps, idle: 4). */
23
+ readonly phase?: number;
24
+ /** One of the baked faces (default `neutral`). */
25
+ readonly face?: 'neutral' | 'happy' | 'surprised';
26
+ readonly status?: StatusProp | null;
27
+ /** Bake at this many px per rig unit (default the atlas scale; far avatars use half). */
28
+ readonly scale?: number;
29
+ }
30
+
31
+ /** A baked frame: where it is on which page, and where the feet are inside it. */
32
+ export interface AtlasFrame {
33
+ readonly page: number;
34
+ readonly source: CanvasImageSource;
35
+ readonly x: number;
36
+ readonly y: number;
37
+ readonly w: number;
38
+ readonly h: number;
39
+ /** The feet inside the frame, px from its top-left. */
40
+ readonly footX: number;
41
+ readonly footY: number;
42
+ /** Draw flipped horizontally (W, SW, NW). */
43
+ readonly mirror: boolean;
44
+ }
45
+
46
+ /** Atlas options. */
47
+ export interface AtlasOptions {
48
+ /** Page edge in px (default 2048, 1024 on low-end devices). */
49
+ readonly pageSize?: number;
50
+ /** At most this many pages (default 4); the least recently used is recycled. */
51
+ readonly maxPages?: number;
52
+ /** Px per rig unit (default 0.32: a full character ≈ 64 px tall). */
53
+ readonly scale?: number;
54
+ /** The light rim for dark floors (default on). */
55
+ readonly rim?: boolean;
56
+ /** The figure every frame draws (default `blob`); worlds use `walker`. */
57
+ readonly figure?: Figure;
58
+ /** Makes page canvases (tests, workers); default an `OffscreenCanvas` or DOM canvas. */
59
+ readonly makeCanvas?: (size: number) => { readonly canvas: CanvasImageSource; readonly ctx: Ctx2D } | null;
60
+ }
61
+
62
+ interface Page {
63
+ readonly canvas: CanvasImageSource;
64
+ readonly ctx: Ctx2D;
65
+ shelfY: number;
66
+ shelfX: number;
67
+ shelfH: number;
68
+ used: number;
69
+ version: number;
70
+ }
71
+
72
+ const MIRRORED: Readonly<Partial<Record<Facing, Facing>>> = { W: 'E', SW: 'SE', NW: 'NE' };
73
+ const STEPS: Readonly<Partial<Record<BodyPose, number>>> = { walk: 8, run: 8, idle: 4, dance: 8, jump: 6, cheer: 6 };
74
+ /** The rig box every frame bakes: wide enough for hats, props and the rim. */
75
+ const BOX = { x: -90, y: -210, w: 180, h: 226 };
76
+ /** The walker's box: its full frame plus a margin for the rim. */
77
+ const WALKER_BOX = { x: WALKER_FULL[0] + 4, y: WALKER_FULL[1] - 2, w: WALKER_FULL[2] - 8, h: WALKER_FULL[3] + 2 };
78
+
79
+ /** The atlas. */
80
+ export class AvatarAtlas {
81
+ readonly pageSize: number;
82
+ readonly scale: number;
83
+ private readonly maxPages: number;
84
+ private readonly rim: boolean;
85
+ private readonly figure: Figure;
86
+ private readonly box: typeof BOX;
87
+ private readonly makeCanvas: NonNullable<AtlasOptions['makeCanvas']>;
88
+ private readonly pages: Page[] = [];
89
+ private readonly frames = new Map<string, AtlasFrame & { readonly version: number }>();
90
+ private readonly queue = new Map<string, AtlasKey>();
91
+ /** The last frame baked per look code (a stand-in while new frames bake). */
92
+ private readonly latestByLook = new Map<string, string>();
93
+ private clock = 0;
94
+
95
+ constructor(opts: AtlasOptions = {}) {
96
+ this.pageSize = opts.pageSize ?? 2048;
97
+ this.maxPages = opts.maxPages ?? 4;
98
+ this.scale = opts.scale ?? 0.32;
99
+ this.rim = opts.rim ?? true;
100
+ this.figure = opts.figure ?? 'blob';
101
+ this.box = this.figure === 'walker' ? WALKER_BOX : BOX;
102
+ this.makeCanvas = opts.makeCanvas ?? ((size) => defaultLayer(size, size));
103
+ }
104
+
105
+ /** The frame for a key: the baked frame, or `null` (queued for {@link bake}). */
106
+ frame(key: AtlasKey): AtlasFrame | null {
107
+ const id = this.keyOf(key);
108
+ const isMirror = MIRRORED[key.facing ?? 'S'] !== undefined;
109
+ const hit = this.live(id);
110
+ if (hit) {
111
+ return { ...hit, mirror: isMirror };
112
+ }
113
+ this.queue.set(id, key);
114
+ // While this frame waits, the look's most recently baked frame stands in (no popping to a silhouette).
115
+ const standIn = this.live(this.latestByLook.get(toCode(key.look)) ?? '');
116
+ return standIn ? { ...standIn, mirror: isMirror } : null;
117
+ }
118
+
119
+ /** A baked frame whose page still holds it (touching the page for the LRU). */
120
+ private live(id: string): (AtlasFrame & { readonly version: number }) | undefined {
121
+ const hit = this.frames.get(id);
122
+ const page = hit ? this.pages[hit.page] : undefined;
123
+ if (!hit || page?.version !== hit.version) {
124
+ return undefined;
125
+ }
126
+ page.used = (this.clock += 1);
127
+ return hit;
128
+ }
129
+
130
+ /** Bakes queued frames for up to `budgetMs` (default 4 ms), oldest first. Returns how many were baked. */
131
+ bake(budgetMs = 4, now: () => number = () => (typeof performance === 'undefined' ? Date.now() : performance.now())): number {
132
+ const start = now();
133
+ let baked = 0;
134
+ for (const [id, key] of this.queue) {
135
+ if (baked > 0 && now() - start > budgetMs) {
136
+ break;
137
+ }
138
+ this.queue.delete(id);
139
+ if (this.bakeOne(id, key)) {
140
+ baked += 1;
141
+ }
142
+ }
143
+ return baked;
144
+ }
145
+
146
+ /** Frames still waiting to bake. */
147
+ get pending(): number {
148
+ return this.queue.size;
149
+ }
150
+
151
+ /** Every page's image (for texture uploads). */
152
+ get sources(): readonly CanvasImageSource[] {
153
+ return this.pages.map((p) => p.canvas);
154
+ }
155
+
156
+ private keyOf(key: AtlasKey): string {
157
+ const facing = MIRRORED[key.facing ?? 'S'] ?? key.facing ?? 'S';
158
+ const body = key.body ?? 'idle';
159
+ const steps = STEPS[body] ?? 1;
160
+ const step = Math.floor(((key.phase ?? 0) % 1) * steps);
161
+ return [toCode(key.look), facing, body, String(step), key.face ?? 'neutral', key.status ?? '', String(key.scale ?? this.scale)].join('~');
162
+ }
163
+
164
+ private place(w: number, h: number): { readonly page: number; readonly x: number; readonly y: number } | null {
165
+ for (let i = 0; i < this.pages.length; i += 1) {
166
+ const p = this.pages[i];
167
+ if (!p) {
168
+ continue;
169
+ }
170
+ if (p.shelfX + w > this.pageSize) {
171
+ p.shelfY += p.shelfH;
172
+ p.shelfX = 0;
173
+ p.shelfH = 0;
174
+ }
175
+ if (p.shelfY + h <= this.pageSize) {
176
+ const spot = { page: i, x: p.shelfX, y: p.shelfY };
177
+ p.shelfX += w;
178
+ p.shelfH = Math.max(p.shelfH, h);
179
+ return spot;
180
+ }
181
+ }
182
+ if (this.pages.length < this.maxPages) {
183
+ const made = this.makeCanvas(this.pageSize);
184
+ if (!made) {
185
+ return null;
186
+ }
187
+ this.pages.push({ canvas: made.canvas, ctx: made.ctx, shelfX: 0, shelfY: 0, shelfH: 0, used: 0, version: 1 });
188
+ return this.place(w, h);
189
+ }
190
+ // Recycle the least recently used page.
191
+ let oldest = 0;
192
+ this.pages.forEach((p, i) => { if (p.used < (this.pages[oldest]?.used ?? Infinity)) {oldest = i;} });
193
+ const page = this.pages[oldest];
194
+ if (!page) {
195
+ return null;
196
+ }
197
+ page.ctx.clearRect(0, 0, this.pageSize, this.pageSize);
198
+ page.version += 1;
199
+ page.shelfX = 0;
200
+ page.shelfY = 0;
201
+ page.shelfH = 0;
202
+ return this.place(w, h);
203
+ }
204
+
205
+ private bakeOne(id: string, key: AtlasKey): boolean {
206
+ const scale = key.scale ?? this.scale;
207
+ const box = this.box;
208
+ const w = Math.ceil(box.w * scale) + 2;
209
+ const h = Math.ceil(box.h * scale) + 2;
210
+ const spot = this.place(w, h);
211
+ const page = spot ? this.pages[spot.page] : undefined;
212
+ if (!spot || !page) {
213
+ return false;
214
+ }
215
+ const facing = MIRRORED[key.facing ?? 'S'] ?? key.facing ?? 'S';
216
+ const body = key.body ?? 'idle';
217
+ const steps = STEPS[body] ?? 1;
218
+ const phase = (Math.floor(((key.phase ?? 0) % 1) * steps) + 0.5) / steps;
219
+ const face: Expression = key.face ?? 'neutral';
220
+ const { nodes } = composeAvatar(toLook(key.look), {
221
+ frame: 'full', figure: this.figure, facing, rim: this.rim, shadow: false, pose: { expression: face, body, phase, status: key.status ?? null },
222
+ });
223
+ // Paint into a frame-sized scratch canvas (so the rim's layer is small too), then stamp it on the page.
224
+ const scratch = defaultLayer(w, h);
225
+ if (!scratch) {
226
+ return false;
227
+ }
228
+ scratch.ctx.translate(1 - box.x * scale, 1 - box.y * scale);
229
+ scratch.ctx.scale(scale, scale);
230
+ paintNodes(scratch.ctx, nodes, defaultLayer);
231
+ page.ctx.clearRect(spot.x, spot.y, w, h);
232
+ page.ctx.drawImage(scratch.canvas, spot.x, spot.y);
233
+ page.used = (this.clock += 1);
234
+ this.latestByLook.set(toCode(key.look), id);
235
+ this.frames.set(id, {
236
+ page: spot.page, source: page.canvas, x: spot.x, y: spot.y, w, h,
237
+ footX: 1 - box.x * scale, footY: 1 - box.y * scale, mirror: false, version: page.version,
238
+ });
239
+ return true;
240
+ }
241
+ }
242
+
243
+ /** A new atlas (one per scene; pages are shared by every avatar in it). */
244
+ export function createAtlas(opts: AtlasOptions = {}): AvatarAtlas {
245
+ return new AvatarAtlas(opts);
246
+ }
@@ -0,0 +1,14 @@
1
+ /** The catalog as the designer and games see it: parts per category, palettes and the revision. */
2
+ import { CATALOG_REVISION, PALETTES, partsOf, type CatalogEntry, type PartCategory } from '@diffenderfer-games/hub-contract/avatar';
3
+
4
+ /** The catalog view. */
5
+ export const catalog = {
6
+ /** The parts of a category in designer order (retired parts hidden). */
7
+ parts(category: PartCategory): readonly CatalogEntry[] {
8
+ return partsOf(category);
9
+ },
10
+ /** Every colour palette. */
11
+ palettes: PALETTES,
12
+ /** The catalog revision this build knows. */
13
+ revision: CATALOG_REVISION,
14
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Colour derivation in OKLab: from one fill we derive its shade, highlight
3
+ * and tonal outline, so any `#rrggbb` works with no new art (avatars.md §2.3,
4
+ * §14.1).
5
+ */
6
+
7
+ interface Lab {
8
+ readonly L: number;
9
+ readonly a: number;
10
+ readonly b: number;
11
+ }
12
+
13
+ const toLin = (c: number): number => {
14
+ const v = c / 255;
15
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
16
+ };
17
+ const fromLin = (c: number): number => {
18
+ const v = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
19
+ return Math.round(Math.min(1, Math.max(0, v)) * 255);
20
+ };
21
+
22
+ /** A `#rrggbb` colour in OKLab. */
23
+ export function hexToLab(hex: string): Lab {
24
+ const r = toLin(parseInt(hex.slice(1, 3), 16));
25
+ const g = toLin(parseInt(hex.slice(3, 5), 16));
26
+ const bl = toLin(parseInt(hex.slice(5, 7), 16));
27
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * bl);
28
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * bl);
29
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * bl);
30
+ return {
31
+ L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
32
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
33
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
34
+ };
35
+ }
36
+
37
+ /** An OKLab colour as `#rrggbb` (clipped to sRGB). */
38
+ export function labToHex(lab: Lab): string {
39
+ const { a, b } = lab;
40
+ const l = (lab.L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
41
+ const m = (lab.L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
42
+ const s = (lab.L - 0.0894841775 * a - 1.291485548 * b) ** 3;
43
+ const r = fromLin(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
44
+ const g = fromLin(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
45
+ const bl = fromLin(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
46
+ return `#${[r, g, bl].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
47
+ }
48
+
49
+ /** Shifts lightness by `dL` (OKLab L units) and scales chroma. */
50
+ export function adjust(hex: string, dL: number, chroma = 1): string {
51
+ const c = hexToLab(hex);
52
+ return labToHex({ L: Math.min(0.98, Math.max(0.03, c.L + dL)), a: c.a * chroma, b: c.b * chroma });
53
+ }
54
+
55
+ /** The one shade tone of a fill. */
56
+ export const shade = (hex: string): string => adjust(hex, -0.1, 1.08);
57
+
58
+ /** The one highlight tone of a fill. */
59
+ export const light = (hex: string): string => adjust(hex, 0.09, 0.9);
60
+
61
+ /** Style C's tonal outline: a darker shade of the fill's own colour (§14.1). */
62
+ export const outline = (hex: string): string => adjust(hex, -0.24, 1.1);
63
+
64
+ /** A dark ink in the fill's own hue (status props keep a strong edge). */
65
+ export function ink(hex: string): string {
66
+ const c = hexToLab(hex);
67
+ return labToHex({ L: Math.min(0.24, c.L * 0.42), a: c.a * 0.6, b: c.b * 0.6 });
68
+ }
69
+
70
+ /** Linear mix in OKLab. */
71
+ export function mix(a: string, b: string, t: number): string {
72
+ const lhs = hexToLab(a);
73
+ const rhs = hexToLab(b);
74
+ return labToHex({ L: lhs.L + (rhs.L - lhs.L) * t, a: lhs.a + (rhs.a - lhs.a) * t, b: lhs.b + (rhs.b - lhs.b) * t });
75
+ }
76
+
77
+ /** OKLab lightness, 0 … 1. */
78
+ export const lightness = (hex: string): number => hexToLab(hex).L;
79
+
80
+ /** Perceptual distance (OKLab ΔE × 100). */
81
+ export function deltaE(a: string, b: string): number {
82
+ const lhs = hexToLab(a);
83
+ const rhs = hexToLab(b);
84
+ return Math.hypot(lhs.L - rhs.L, lhs.a - rhs.a, lhs.b - rhs.b) * 100;
85
+ }
86
+
87
+ /**
88
+ * Keeps brows and facial hair visible on the skin (§14.2): a colour within
89
+ * ΔE 12 of the skin is darkened by 0.18 L.
90
+ */
91
+ export function keepApart(colour: string, skin: string): string {
92
+ // Always darker: lightening on deep skin read as a grey mask in the review; the tonal outline does the rest.
93
+ return deltaE(colour, skin) >= 12 ? colour : adjust(colour, -0.18);
94
+ }
95
+
96
+ /** WCAG relative luminance of a `#rrggbb`. */
97
+ export function luminance(hex: string): number {
98
+ const r = toLin(parseInt(hex.slice(1, 3), 16));
99
+ const g = toLin(parseInt(hex.slice(3, 5), 16));
100
+ const b = toLin(parseInt(hex.slice(5, 7), 16));
101
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
102
+ }
103
+
104
+ /** WCAG contrast ratio between two colours. */
105
+ export function contrast(a: string, b: string): number {
106
+ const lhs = luminance(a);
107
+ const rhs = luminance(b);
108
+ return (Math.max(lhs, rhs) + 0.05) / (Math.min(lhs, rhs) + 0.05);
109
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Plain-text forms of a look: `describe()` for alt text where the avatar is
3
+ * the content (the designer preview), and `avatarEmoji()` for plain-text
4
+ * places (push titles, the console). Never gendered (§10).
5
+ */
6
+ import { findPreset, partName, resolveColour, type AvatarLook, type LookColour } from '@diffenderfer-games/hub-contract/avatar';
7
+ import { lightness } from './colour';
8
+ import { toLook } from './look-like';
9
+ import type { LookLike } from './render/types';
10
+
11
+ /** Hair phrases; `{c}` is the colour word. */
12
+ const HAIR_WORDS: Readonly<Record<string, string>> = {
13
+ bald: 'no hair', buzz: 'a {c} buzz cut', crop: 'short {c} hair', swoop: 'short swept {c} hair', spiky: 'spiky {c} hair',
14
+ curly: 'short curly {c} hair', coily: 'short coily {c} hair', afro: 'a {c} afro', bob: 'a {c} bob', long: 'long {c} hair',
15
+ ponytail: 'a {c} ponytail', bun: 'a {c} bun', puffs: 'two {c} puffs', mohawk: 'a {c} mohawk', locs: '{c} locs', braids: '{c} braids',
16
+ };
17
+ const BEARD_WORDS: Readonly<Record<string, string>> = {
18
+ stubble: 'stubble', moustache: 'a moustache', handlebar: 'a handlebar moustache', goatee: 'a goatee',
19
+ chinstrap: 'a chinstrap beard', short: 'a short beard', full: 'a beard', long: 'a long beard',
20
+ };
21
+ const EXTRA_WORDS: Readonly<Record<string, string>> = {
22
+ 'glasses.round': 'round glasses', 'glasses.square': 'square glasses', 'glasses.owl': 'big round glasses',
23
+ 'glasses.cat-eye': 'cat-eye glasses', 'glasses.half-rim': 'half-rim glasses', 'glasses.aviator': 'aviator glasses',
24
+ 'glasses.sunglasses': 'sunglasses', 'glasses.sport': 'sport sunglasses', 'glasses.hearts': 'heart glasses', 'glasses.monocle': 'a monocle',
25
+ 'headwear.hijab': 'a hijab',
26
+ 'headwear.headwrap': 'a headwrap', 'headwear.beanie': 'a beanie', 'headwear.cap': 'a cap',
27
+ 'face.freckles': 'freckles', 'face.rosy': 'rosy cheeks', 'ears.studs': 'earrings', 'ears.hoops': 'hoop earrings',
28
+ };
29
+
30
+ /** A colour word: the preset's name (`p:hair-dark-blond` → "dark blond"), else "light" or "dark". */
31
+ function colourWord(colour: LookColour, prefix: 'hair' | 'eye'): string {
32
+ if (findPreset(colour) && colour.startsWith(`p:${prefix}-`)) {
33
+ return colour.slice(prefix.length + 3).replace(/-/g, ' ');
34
+ }
35
+ return lightness(resolveColour(colour)) > 0.6 ? 'light' : 'dark';
36
+ }
37
+
38
+ /** "Short curly blond hair, green eyes and freckles." */
39
+ export function describeLook(look: AvatarLook): string {
40
+ const isCovered = look.accessories.some((a) => a.id === 'headwear.hijab' || a.id === 'headwear.headwrap');
41
+ const parts: string[] = [];
42
+ if (!isCovered) {
43
+ parts.push((HAIR_WORDS[partName(look.hair.style)] ?? '{c} hair').replace('{c}', colourWord(look.hair.colour, 'hair')));
44
+ }
45
+ parts.push(`${colourWord(look.eyes.colour, 'eye')} eyes`);
46
+ const beard = BEARD_WORDS[partName(look.facialHair.style)];
47
+ if (beard) {
48
+ parts.push(beard);
49
+ }
50
+ for (const accessory of look.accessories) {
51
+ const word = EXTRA_WORDS[accessory.id];
52
+ if (word) {
53
+ parts.push(word);
54
+ }
55
+ }
56
+ const text = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}` : parts.join('');
57
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
58
+ }
59
+
60
+ /** Alt text for any {@link LookLike} (`''` for none). */
61
+ export function describe(like: LookLike): string {
62
+ const look = toLook(like);
63
+ return look ? describeLook(look) : '';
64
+ }
65
+
66
+ const TONES = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
67
+ const HAIR_HINT: Readonly<Record<string, string>> = { curly: '\u{1F9B1}', coily: '\u{1F9B1}', afro: '\u{1F9B1}', bald: '\u{1F9B2}', buzz: '\u{1F9B2}' };
68
+
69
+ /** A person emoji with the nearest of the five skin tones and a hair hint where Unicode has one: `🧑🏽‍🦱`. */
70
+ export function avatarEmoji(like: LookLike): string {
71
+ const look = toLook(like);
72
+ if (!look) {
73
+ return '\u{1F464}';
74
+ }
75
+ const l = lightness(resolveColour(look.skin));
76
+ const tone = TONES[Math.max(0, Math.min(4, Math.round((0.86 - l) / 0.09)))] ?? '';
77
+ const hint = HAIR_HINT[partName(look.hair.style)];
78
+ return `\u{1F9D1}${tone}${hint ? `‍${hint}` : ''}`;
79
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Expressions are parameter sets, not drawings (avatars.md §3.5), so they
3
+ * mix freely with look direction, blinking and talking. Every field is a
4
+ * number (except the FX list), so any two expressions tween.
5
+ */
6
+
7
+ /** One brow: vertical offset, tilt (+ lowers the inner end) and arch. */
8
+ export interface BrowParams {
9
+ readonly dy: number;
10
+ readonly tilt: number;
11
+ readonly arch: number;
12
+ }
13
+
14
+ /** The mouth morph: every expression uses the same structure. */
15
+ export interface MouthParams {
16
+ /** Corner-to-corner width. */
17
+ readonly w: number;
18
+ /** Left corner raise (+ up). */
19
+ readonly sL: number;
20
+ /** Right corner raise. */
21
+ readonly sR: number;
22
+ /** 0 closed … ~16 wide open. */
23
+ readonly open: number;
24
+ /** 0 … 1, pulls the lips into an "o". */
25
+ readonly round: number;
26
+ /** 0 … 1, the top-teeth band. */
27
+ readonly teeth: number;
28
+ /** 0 … 1. */
29
+ readonly tongue: number;
30
+ /** Sideways shift (a smirk). */
31
+ readonly dx: number;
32
+ }
33
+
34
+ /** Small FX glyphs drawn beside the head. */
35
+ export type FaceExtra = 'sweat' | 'tear' | 'sparkle' | 'anger' | 'question' | 'zzz';
36
+
37
+ /** A face: brows, lids, pupils, mouth, cheeks, head tilt and squash. */
38
+ export interface FaceParams {
39
+ readonly browL: BrowParams;
40
+ readonly browR: BrowParams;
41
+ /** Upper lid, 0 open … 1 closed. */
42
+ readonly lidL: number;
43
+ readonly lidR: number;
44
+ /** Lower lid (a smile squint), 0 … 1. */
45
+ readonly lowL: number;
46
+ readonly lowR: number;
47
+ /** + lowers the inner corners (a cross look). */
48
+ readonly lidTilt: number;
49
+ /** When closed: +1 a happy ^, −1 a calm ∪. */
50
+ readonly closedCurveL: number;
51
+ readonly closedCurveR: number;
52
+ /** Pupil scale, 0.7 … 1.3. */
53
+ readonly pupil: number;
54
+ readonly mouth: MouthParams;
55
+ /** Blush, 0 … 1. */
56
+ readonly cheeks: number;
57
+ /** Degrees, −12 … 12. */
58
+ readonly headTilt: number;
59
+ /** −0.08 … 0.08 (stretch on surprise). */
60
+ readonly squash: number;
61
+ readonly extras: readonly FaceExtra[];
62
+ }
63
+
64
+ const brow = (dy = 0, tilt = 0, arch = 2.5): BrowParams => ({ dy, tilt, arch });
65
+ const mouth = (m: Partial<MouthParams>): MouthParams => ({ w: 13, sL: 1.5, sR: 1.5, open: 0, round: 0, teeth: 0, tongue: 0, dx: 0, ...m });
66
+
67
+ const BASE: FaceParams = {
68
+ browL: brow(), browR: brow(), lidL: 0.12, lidR: 0.12, lowL: 0, lowR: 0, lidTilt: 0,
69
+ closedCurveL: 1, closedCurveR: 1, pupil: 1, mouth: mouth({}), cheeks: 0.25, headTilt: 0, squash: 0, extras: [],
70
+ };
71
+
72
+ const face = (f: Partial<FaceParams>): FaceParams => ({ ...BASE, ...f });
73
+
74
+ /** The launch set of 16 expressions (§3.5). Kid-friendly: angry and shocked stay cartoon-cute, cry is one tear. */
75
+ export const EXPRESSIONS = {
76
+ neutral: BASE,
77
+ happy: face({
78
+ browL: brow(-2, 0, 3.5), browR: brow(-2, 0, 3.5), lowL: 0.22, lowR: 0.22,
79
+ mouth: mouth({ w: 19, sL: 5, sR: 5, open: 7, teeth: 0.5, tongue: 0.6 }), cheeks: 0.65,
80
+ }),
81
+ laugh: face({
82
+ browL: brow(-4, -1, 4), browR: brow(-4, -1, 4), lidL: 1, lidR: 1,
83
+ mouth: mouth({ w: 22, sL: 6, sR: 6, open: 13, teeth: 0.45, tongue: 0.9 }), cheeks: 0.9, headTilt: -6, squash: 0.03, extras: ['sparkle'],
84
+ }),
85
+ grin: face({
86
+ browL: brow(-2.5, 0, 3.5), browR: brow(-2.5, 0, 3.5), lowL: 0.3, lowR: 0.3,
87
+ mouth: mouth({ w: 21, sL: 5.5, sR: 5.5, open: 4.5, teeth: 1 }), cheeks: 0.7,
88
+ }),
89
+ sad: face({
90
+ browL: brow(1, -4.5, 1), browR: brow(1, -4.5, 1), lidL: 0.3, lidR: 0.3, lidTilt: -3,
91
+ mouth: mouth({ w: 12, sL: -4, sR: -4 }), cheeks: 0.1, headTilt: 4,
92
+ }),
93
+ cry: face({
94
+ browL: brow(1, -5.5, 1), browR: brow(1, -5.5, 1), lidL: 0.4, lidR: 0.4, lidTilt: -3, lowL: 0.15, lowR: 0.15,
95
+ mouth: mouth({ w: 13, sL: -4.5, sR: -4.5, open: 3 }), cheeks: 0.4, headTilt: 5, extras: ['tear'],
96
+ }),
97
+ surprised: face({
98
+ browL: brow(-7, -1, 4), browR: brow(-7, -1, 4), lidL: 0, lidR: 0, pupil: 0.8,
99
+ mouth: mouth({ w: 9, sL: 0, sR: 0, open: 11, round: 1, tongue: 0.3 }), cheeks: 0.2, squash: -0.05,
100
+ }),
101
+ shocked: face({
102
+ browL: brow(-9, -2, 4.5), browR: brow(-9, -2, 4.5), lidL: 0, lidR: 0, pupil: 0.7,
103
+ mouth: mouth({ w: 11, sL: -1, sR: -1, open: 14, round: 0.8, tongue: 0.4 }), cheeks: 0.15, squash: -0.07, extras: ['sweat'],
104
+ }),
105
+ angry: face({
106
+ browL: brow(2, 5.5, 0), browR: brow(2, 5.5, 0), lidL: 0.32, lidR: 0.32, lidTilt: 4,
107
+ mouth: mouth({ w: 15, sL: -2.5, sR: -2.5, open: 3.5, teeth: 0.9 }), cheeks: 0.35, extras: ['anger'],
108
+ }),
109
+ grumpy: face({
110
+ browL: brow(2, 3.5, 0.5), browR: brow(2, 3.5, 0.5), lidL: 0.42, lidR: 0.42, lidTilt: 2,
111
+ mouth: mouth({ w: 12, sL: -2, sR: -2.5, dx: -1 }), cheeks: 0.1, headTilt: -3,
112
+ }),
113
+ thinking: face({
114
+ browL: brow(-5, -1.5, 3.5), browR: brow(1.5, 3, 0.5), lidL: 0.1, lidR: 0.3,
115
+ mouth: mouth({ w: 9, sL: -0.5, sR: 1.5, dx: 4 }), cheeks: 0.15, headTilt: -5, extras: ['question'],
116
+ }),
117
+ wink: face({
118
+ browL: brow(-2, 0, 3.5), browR: brow(1.5, 1.5, 1.5), lidR: 1, lowL: 0.15,
119
+ mouth: mouth({ w: 16, sL: 2, sR: 5.5, dx: 1.5 }), cheeks: 0.6, headTilt: 3, extras: ['sparkle'],
120
+ }),
121
+ smug: face({
122
+ browL: brow(0, 1, 1.5), browR: brow(-4, -1, 3.5), lidL: 0.45, lidR: 0.4, lowL: 0.1, lowR: 0.1,
123
+ mouth: mouth({ w: 14, sL: 1, sR: 5, dx: 3 }), cheeks: 0.45, headTilt: 4,
124
+ }),
125
+ worried: face({
126
+ browL: brow(-2, -5, 2), browR: brow(-2, -5, 2), lidL: 0.05, lidR: 0.05, pupil: 0.9,
127
+ mouth: mouth({ w: 11, sL: -1.5, sR: -1, open: 1.5 }), cheeks: 0.1, extras: ['sweat'],
128
+ }),
129
+ sleepy: face({
130
+ browL: brow(1, -1, 1.5), browR: brow(1, -1, 1.5), lidL: 0.72, lidR: 0.72, closedCurveL: -1, closedCurveR: -1,
131
+ mouth: mouth({ w: 7, sL: 0, sR: 0, open: 2.5, round: 1 }), cheeks: 0.2, headTilt: 7, extras: ['zzz'],
132
+ }),
133
+ determined: face({
134
+ browL: brow(1, 3.5, 0.5), browR: brow(1, 3.5, 0.5), lidL: 0.22, lidR: 0.22, lidTilt: 2,
135
+ mouth: mouth({ w: 12, sL: 1, sR: 1 }), cheeks: 0.25,
136
+ }),
137
+ } satisfies Record<string, FaceParams>;
138
+
139
+ /** A named expression. */
140
+ export type Expression = keyof typeof EXPRESSIONS;
141
+
142
+ /** Every expression name, in the launch order. */
143
+ export const EXPRESSION_NAMES: readonly Expression[] = [
144
+ 'neutral', 'happy', 'laugh', 'grin', 'sad', 'cry', 'surprised', 'shocked',
145
+ 'angry', 'grumpy', 'thinking', 'wink', 'smug', 'worried', 'sleepy', 'determined',
146
+ ];
147
+
148
+ /** True for a known expression name. */
149
+ export function isExpression(name: string): name is Expression {
150
+ return Object.prototype.hasOwnProperty.call(EXPRESSIONS, name);
151
+ }
152
+
153
+ const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
154
+ const mixBrow = (a: BrowParams, b: BrowParams, t: number): BrowParams => ({ dy: lerp(a.dy, b.dy, t), tilt: lerp(a.tilt, b.tilt, t), arch: lerp(a.arch, b.arch, t) });
155
+ const mixMouth = (a: MouthParams, b: MouthParams, t: number): MouthParams => ({
156
+ w: lerp(a.w, b.w, t), sL: lerp(a.sL, b.sL, t), sR: lerp(a.sR, b.sR, t), open: lerp(a.open, b.open, t),
157
+ round: lerp(a.round, b.round, t), teeth: lerp(a.teeth, b.teeth, t), tongue: lerp(a.tongue, b.tongue, t), dx: lerp(a.dx, b.dx, t),
158
+ });
159
+
160
+ /** An expression's parameters (a name or parameters pass through). */
161
+ export function faceOf(e: Expression | FaceParams): FaceParams {
162
+ return typeof e === 'string' ? EXPRESSIONS[e] : e;
163
+ }
164
+
165
+ /** Blends two expressions: `mix('happy', 'surprised', 0.4)`. */
166
+ export function mix(a: Expression | FaceParams, b: Expression | FaceParams, t: number): FaceParams {
167
+ const from = faceOf(a);
168
+ const to = faceOf(b);
169
+ return {
170
+ browL: mixBrow(from.browL, to.browL, t), browR: mixBrow(from.browR, to.browR, t),
171
+ lidL: lerp(from.lidL, to.lidL, t), lidR: lerp(from.lidR, to.lidR, t), lowL: lerp(from.lowL, to.lowL, t), lowR: lerp(from.lowR, to.lowR, t),
172
+ lidTilt: lerp(from.lidTilt, to.lidTilt, t), closedCurveL: lerp(from.closedCurveL, to.closedCurveL, t), closedCurveR: lerp(from.closedCurveR, to.closedCurveR, t),
173
+ pupil: lerp(from.pupil, to.pupil, t), mouth: mixMouth(from.mouth, to.mouth, t), cheeks: lerp(from.cheeks, to.cheeks, t),
174
+ headTilt: lerp(from.headTilt, to.headTilt, t), squash: lerp(from.squash, to.squash, t), extras: t < 0.5 ? from.extras : to.extras,
175
+ };
176
+ }
177
+
178
+ /** 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. */
179
+ const EMOTE_FACES: Readonly<Record<string, Expression>> = {
180
+ '😀': 'happy', '😂': 'laugh', '😮': 'surprised', '😢': 'sad', '😎': 'smug', '🤔': 'thinking',
181
+ '👍': 'grin', '👏': 'happy', '🎉': 'laugh', '❤️': 'happy', '🔥': 'determined', '⭐': 'grin',
182
+ };
183
+
184
+ /** The face for a quick-chat emote (`e:😂` or the bare emoji), or `null`. */
185
+ export function expressionFromEmote(emoteId: string): Expression | null {
186
+ return EMOTE_FACES[emoteId.startsWith('e:') ? emoteId.slice(2) : emoteId] ?? null;
187
+ }