@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,84 @@
1
+ /**
2
+ * The slice of PixiJS the adapters use, typed structurally so the hub has no
3
+ * dependency on pixi. Pixi 8 is the target; Pixi 7 works through the same
4
+ * calls where they exist (`Texture.from(canvas)`, `texture.update()`).
5
+ */
6
+
7
+ /** A canvas-like source Pixi can upload. */
8
+ export type PixiSource = HTMLCanvasElement | OffscreenCanvas;
9
+
10
+ /** A texture's uploadable source (Pixi 8). */
11
+ export interface PixiTextureSource {
12
+ update(): void;
13
+ }
14
+
15
+ /** A texture (Pixi 8: `source.update()`; Pixi 7: `update()`). */
16
+ export interface PixiTexture {
17
+ readonly source?: PixiTextureSource;
18
+ update?(): void;
19
+ destroy(destroySource?: boolean): void;
20
+ }
21
+
22
+ /** A display object's shared fields. */
23
+ export interface PixiDisplay {
24
+ x: number;
25
+ y: number;
26
+ visible: boolean;
27
+ alpha: number;
28
+ zIndex: number;
29
+ readonly scale: { x: number; y: number; set(x: number, y?: number): void };
30
+ destroy(options?: { children?: boolean }): void;
31
+ }
32
+
33
+ /** A sprite showing a `TTexture`. */
34
+ export interface PixiSprite<TTexture extends PixiTexture = PixiTexture> extends PixiDisplay {
35
+ texture: TTexture;
36
+ readonly anchor: { set(x: number, y?: number): void };
37
+ }
38
+
39
+ /** A container that takes `TChild` sprites. */
40
+ export interface PixiContainer<TChild extends PixiDisplay = PixiSprite> extends PixiDisplay {
41
+ sortableChildren: boolean;
42
+ addChild(child: TChild): void;
43
+ removeChild(child: TChild): void;
44
+ }
45
+
46
+ /** A frame rectangle. */
47
+ export interface PixiRectangle {
48
+ readonly x: number;
49
+ readonly y: number;
50
+ readonly width: number;
51
+ readonly height: number;
52
+ }
53
+
54
+ /**
55
+ * The PIXI namespace, as passed in by the game (`import * as PIXI from
56
+ * 'pixi.js'`, or an object of the named imports). It is generic over the
57
+ * game's own classes, inferred from what is passed, so the sprites and
58
+ * textures that come back are pixi's own types and go straight into a
59
+ * `Container` with no cast.
60
+ */
61
+ export interface PixiNs<
62
+ TTexture extends PixiTexture = PixiTexture,
63
+ TSprite extends PixiSprite<TTexture> = PixiSprite<TTexture>,
64
+ TRectangle extends PixiRectangle = PixiRectangle,
65
+ > {
66
+ readonly Texture: {
67
+ from(source: PixiSource): TTexture;
68
+ new (options: { source: NonNullable<TTexture['source']>; frame: TRectangle }): TTexture;
69
+ };
70
+ readonly Sprite: new (texture?: TTexture) => TSprite;
71
+ /** Not used by the adapters; accepted so an object of the named imports can include it. */
72
+ readonly Container?: new () => PixiDisplay;
73
+ readonly Rectangle: new (x: number, y: number, width: number, height: number) => TRectangle;
74
+ }
75
+
76
+ /** Re-uploads a texture after its canvas changed (Pixi 8 and 7). */
77
+ export function refresh(texture: PixiTexture): void {
78
+ if (texture.source) {
79
+ texture.source.update();
80
+ } else {
81
+ texture.update?.();
82
+ }
83
+ }
84
+
@@ -0,0 +1,126 @@
1
+ /**
2
+ * three.js helpers (§3.8): an avatar on a `CanvasTexture` (redrawn only when
3
+ * the pose changes, at most 30 times a second) and a ready-made `Sprite`.
4
+ * Pass the game's three namespace; the hub never imports three. A 3D head is
5
+ * out of scope (§14.4, Q11).
6
+ */
7
+ import type { Animator } from '../animator/animator';
8
+ import { liveCanvas } from '../outputs/live-canvas';
9
+ import type { LookLike, Pose, RenderOptions } from '../render/types';
10
+
11
+ /**
12
+ * The slice of three.js {@link avatarCanvasTexture} uses: `import * as THREE
13
+ * from 'three'`, or an object of the named imports. Generic over the game's
14
+ * own `CanvasTexture`, inferred from what is passed, so the texture that comes
15
+ * back is three's own type.
16
+ */
17
+ export interface ThreeTextureNs<TTexture extends ThreeTexture = ThreeTexture> {
18
+ readonly CanvasTexture: new (canvas: HTMLCanvasElement | OffscreenCanvas) => TTexture;
19
+ readonly SRGBColorSpace?: string;
20
+ }
21
+
22
+ /**
23
+ * The slice of three.js {@link avatarSprite} uses (the namespace, or the named
24
+ * imports). The sprite that comes back is three's own `Sprite`, so
25
+ * `scene.add(sprite)` needs no cast. `TMap` is what the game's
26
+ * `SpriteMaterial` takes as its `map` (three's `Texture`); it is inferred on
27
+ * its own so an inline object of the named imports infers too.
28
+ */
29
+ export interface ThreeNs<
30
+ TTexture extends ThreeTexture = ThreeTexture,
31
+ TMaterial extends ThreeMaterial = ThreeMaterial,
32
+ TSprite extends ThreeSprite = ThreeSprite,
33
+ TMap = TTexture,
34
+ > extends ThreeTextureNs<TTexture> {
35
+ readonly SpriteMaterial: new (params: { map: TMap; transparent: boolean }) => TMaterial;
36
+ readonly Sprite: new (material: TMaterial) => TSprite;
37
+ }
38
+
39
+ /** A texture. */
40
+ export interface ThreeTexture {
41
+ needsUpdate: boolean;
42
+ colorSpace?: string;
43
+ dispose(): void;
44
+ }
45
+
46
+ /** A material. */
47
+ export interface ThreeMaterial {
48
+ dispose(): void;
49
+ }
50
+
51
+ /** A sprite. */
52
+ export interface ThreeSprite {
53
+ readonly scale: { set(x: number, y: number, z: number): void };
54
+ }
55
+
56
+ /** Options for the three.js helpers. */
57
+ export interface ThreeAvatarOptions extends RenderOptions {
58
+ readonly animator?: Animator;
59
+ /** Texture pixels per `size` pixel (default 2). */
60
+ readonly resolution?: number;
61
+ }
62
+
63
+ /** A live avatar texture; `texture` is the game's own `CanvasTexture`. */
64
+ export interface ThreeAvatarTexture<TTexture extends ThreeTexture = ThreeTexture> {
65
+ readonly texture: TTexture;
66
+ /** Redraws for the animator's pose at `t` (or a given pose), at most 30 times a second. */
67
+ update(t: number | Pose): void;
68
+ setLook(like: LookLike): void;
69
+ dispose(): void;
70
+ }
71
+
72
+ const MIN_FRAME_MS = 1000 / 30;
73
+
74
+ /** A `CanvasTexture` showing the avatar. */
75
+ export function avatarCanvasTexture<TTexture extends ThreeTexture>(
76
+ three: ThreeTextureNs<TTexture>,
77
+ like: LookLike,
78
+ opts: ThreeAvatarOptions = {},
79
+ ): ThreeAvatarTexture<TTexture> {
80
+ const live = liveCanvas(like, opts, opts.resolution ?? 2);
81
+ live.draw(opts.pose);
82
+ const texture = new three.CanvasTexture(live.canvas);
83
+ if (three.SRGBColorSpace) {
84
+ texture.colorSpace = three.SRGBColorSpace;
85
+ }
86
+ let last = -Infinity;
87
+ return {
88
+ texture,
89
+ update(t) {
90
+ if (typeof t === 'number') {
91
+ if (t - last < MIN_FRAME_MS) {
92
+ return;
93
+ }
94
+ last = t;
95
+ }
96
+ const pose = typeof t === 'number' ? opts.animator?.pose(t) : t;
97
+ if (live.draw(pose ?? opts.pose)) {
98
+ texture.needsUpdate = true;
99
+ }
100
+ },
101
+ setLook(next) {
102
+ live.setLook(next);
103
+ opts.animator?.setLook(next);
104
+ if (live.draw(opts.pose)) {
105
+ texture.needsUpdate = true;
106
+ }
107
+ },
108
+ dispose() {
109
+ texture.dispose();
110
+ },
111
+ };
112
+ }
113
+
114
+ /** A billboard `Sprite` of the avatar, `height` world units tall (default 1). */
115
+ export function avatarSprite<TTexture extends ThreeTexture & TMap, TMaterial extends ThreeMaterial, TSprite extends ThreeSprite, TMap>(
116
+ three: ThreeNs<TTexture, TMaterial, TSprite, TMap>,
117
+ like: LookLike,
118
+ opts: ThreeAvatarOptions & { readonly height?: number } = {},
119
+ ): { readonly sprite: TSprite; readonly avatar: ThreeAvatarTexture<TTexture> } {
120
+ const avatar = avatarCanvasTexture(three, like, opts);
121
+ const sprite = new three.Sprite(new three.SpriteMaterial({ map: avatar.texture, transparent: true }));
122
+ const h = opts.height ?? 1;
123
+ const aspect = opts.frame === 'full' ? 172 / 206 : 1;
124
+ sprite.scale.set(h * aspect, h, 1);
125
+ return { sprite, avatar };
126
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The animator (avatars.md §3.7): a pure, time-based face and body driver.
3
+ * Every call (`express`, `lookAt`, `react`, …) appends to a timestamped
4
+ * schedule and `pose(t)` evaluates it, with no clock or animation frame of
5
+ * its own: the same schedule and `t` always give the same pose, so it suits
6
+ * game loops, Pixi tickers, three.js, CSS-driven DOM, tests and replays.
7
+ */
8
+ import { hashSeed } from '@diffenderfer-games/hub-contract/avatar';
9
+ import { EXPRESSIONS, faceOf, mix, type Expression, type FaceParams } from '../face';
10
+ import type { BodyPose, LookDir, LookLike, Pose, StatusProp } from '../render/types';
11
+ import { REACTIONS, STILL, type Reaction, type ReactionFrame } from './reactions';
12
+ import { blinkAt, breathAt, microSmileAt, wanderAt } from './schedule';
13
+
14
+ /** Options for {@link animator}. */
15
+ export interface AnimatorOptions {
16
+ /** Seeds blinks and idle motion (a user id is ideal). */
17
+ readonly seed?: string | number;
18
+ /** `'auto'` reads `prefers-reduced-motion`; `true` forces it. */
19
+ readonly reducedMotion?: 'auto' | boolean;
20
+ /** Idle bob, sway, wandering gaze and micro expressions (default on). */
21
+ readonly idle?: boolean;
22
+ /** The resting expression (default `neutral`). */
23
+ readonly base?: Expression;
24
+ /** The time source for calls without `at` (default `performance.now()`). */
25
+ readonly clock?: () => number;
26
+ }
27
+
28
+ /** When a call happens; defaults to the animator's clock. */
29
+ export interface At {
30
+ readonly at?: number;
31
+ }
32
+
33
+ /** Game events mapped to tuned faces, so every game feels the same. */
34
+ export type Feeling = 'win' | 'lose' | 'bigPlay' | 'victim' | 'waiting' | 'yourTurn' | 'idle';
35
+
36
+ interface ExpressEvent { readonly t: number; readonly face: FaceParams; readonly fade: number; readonly hold: number }
37
+ interface BaseEvent { readonly t: number; readonly face: FaceParams; readonly fade: number }
38
+ interface LookEvent { readonly t: number; readonly target: LookDir | null; readonly speed: number }
39
+ interface TalkEvent { readonly t: number; readonly level: number; readonly until: number }
40
+ interface ReactEvent { readonly t: number; readonly reaction: Reaction }
41
+ interface BodyEvent { readonly t: number; readonly body: BodyPose; readonly cycleMs: number }
42
+ interface StatusEvent { readonly t: number; readonly status: StatusProp | null }
43
+
44
+ const KEEP = 48;
45
+ const push = <T>(list: T[], item: T): void => {
46
+ list.push(item);
47
+ if (list.length > KEEP) {
48
+ list.shift();
49
+ }
50
+ };
51
+ /** The last event at or before t. */
52
+ const latest = <T extends { readonly t: number }>(list: readonly T[], t: number): T | undefined => {
53
+ for (let i = list.length - 1; i >= 0; i -= 1) {
54
+ const e = list[i];
55
+ if (e && e.t <= t) {
56
+ return e;
57
+ }
58
+ }
59
+ return undefined;
60
+ };
61
+ const easeOut = (u: number): number => 1 - (1 - Math.min(1, Math.max(0, u))) ** 2;
62
+
63
+ function prefersReducedMotion(): boolean {
64
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
65
+ }
66
+
67
+ /** A face and body driver for one avatar. */
68
+ export class Animator {
69
+ /** Reduced motion: no idle bob, sway or reaction movement; quick cross-fades; a static open mouth when talking. */
70
+ readonly reducedMotion: boolean;
71
+ private readonly seed: number;
72
+ private readonly idle: boolean;
73
+ private readonly clock: () => number;
74
+ private readonly bases: BaseEvent[] = [];
75
+ private readonly expresses: ExpressEvent[] = [];
76
+ private readonly looks: LookEvent[] = [];
77
+ private readonly talks: TalkEvent[] = [];
78
+ private readonly reacts: ReactEvent[] = [];
79
+ private readonly bodies: BodyEvent[] = [];
80
+ private readonly statuses: StatusEvent[] = [];
81
+ private current: LookLike;
82
+
83
+ constructor(look: LookLike, opts: AnimatorOptions = {}) {
84
+ this.current = look;
85
+ this.seed = hashSeed(opts.seed ?? (typeof look === 'string' ? look : 'dg'));
86
+ this.idle = opts.idle ?? true;
87
+ this.reducedMotion = opts.reducedMotion === 'auto' || opts.reducedMotion === undefined ? prefersReducedMotion() : opts.reducedMotion;
88
+ this.clock = opts.clock ?? (() => (typeof performance === 'undefined' ? Date.now() : performance.now()));
89
+ this.bases.push({ t: -Infinity, face: EXPRESSIONS[opts.base ?? 'neutral'], fade: 0 });
90
+ }
91
+
92
+ /** The look this animator drives (adapters render it with each pose). */
93
+ get look(): LookLike {
94
+ return this.current;
95
+ }
96
+
97
+ /** Swaps the look (an `ev:avatar` change) without resetting the schedule. */
98
+ setLook(look: LookLike): this {
99
+ this.current = look;
100
+ return this;
101
+ }
102
+
103
+ private now(o?: At): number {
104
+ return o?.at ?? this.clock();
105
+ }
106
+
107
+ private fade(ms: number): number {
108
+ return this.reducedMotion ? Math.min(ms, 120) : ms;
109
+ }
110
+
111
+ /** Shows an expression (or raw parameters), then returns to the base. */
112
+ express(e: Expression | FaceParams, o: At & { readonly fadeMs?: number; readonly holdMs?: number } = {}): this {
113
+ push(this.expresses, { t: this.now(o), face: faceOf(e), fade: this.fade(o.fadeMs ?? 180), hold: o.holdMs ?? 1500 });
114
+ return this;
115
+ }
116
+
117
+ /** Sets the resting expression. */
118
+ setBase(e: Expression | FaceParams, o: At & { readonly fadeMs?: number } = {}): this {
119
+ push(this.bases, { t: this.now(o), face: faceOf(e), fade: this.fade(o.fadeMs ?? 250) });
120
+ return this;
121
+ }
122
+
123
+ /** Eases the gaze toward a direction; `null` goes back to wandering (or the centre with `idle: false`). */
124
+ lookAt(target: LookDir | null, o: At & { readonly speed?: number } = {}): this {
125
+ push(this.looks, { t: this.now(o), target, speed: o.speed ?? 6 });
126
+ return this;
127
+ }
128
+
129
+ /** Flaps the mouth until `talk(false)`. */
130
+ talk(isOn: boolean, o: At = {}): this {
131
+ const t = this.now(o);
132
+ push(this.talks, { t, level: isOn ? 1 : 0, until: Infinity });
133
+ return this;
134
+ }
135
+
136
+ /** Talks for a duration. */
137
+ talkFor(ms: number, o: At = {}): this {
138
+ const t = this.now(o);
139
+ push(this.talks, { t, level: 1, until: t + ms });
140
+ return this;
141
+ }
142
+
143
+ /** Drives the mouth from audio amplitude (0 … 1). */
144
+ talkLevel(level: number, o: At = {}): this {
145
+ push(this.talks, { t: this.now(o), level: Math.max(0, Math.min(1, level)), until: Infinity });
146
+ return this;
147
+ }
148
+
149
+ /** Plays a one-shot reaction. */
150
+ react(reaction: Reaction, o: At = {}): this {
151
+ push(this.reacts, { t: this.now(o), reaction });
152
+ return this;
153
+ }
154
+
155
+ /** Sets the body pose; cycle poses (`walk`, `run`, `dance`) loop every `cycleMs`. */
156
+ setBody(body: BodyPose, o: At & { readonly cycleMs?: number } = {}): this {
157
+ push(this.bodies, { t: this.now(o), body, cycleMs: o.cycleMs ?? (body === 'run' ? 320 : 520) });
158
+ return this;
159
+ }
160
+
161
+ /** Sets or clears the status prop. */
162
+ setStatus(status: StatusProp | null, o: At = {}): this {
163
+ push(this.statuses, { t: this.now(o), status });
164
+ return this;
165
+ }
166
+
167
+ /** Plays the tuned face and reaction for a game event. */
168
+ feel(feeling: Feeling, o: At = {}): this {
169
+ const at = { at: this.now(o) };
170
+ switch (feeling) {
171
+ case 'win': return this.express('laugh', { ...at, holdMs: 1800 }).react('cheer', at);
172
+ case 'lose': return this.express('sad', { ...at, holdMs: 1800 });
173
+ case 'bigPlay': return this.express('grin', at).react('bounce', at);
174
+ case 'victim': return this.express('shocked', { ...at, holdMs: 900 }).react('flinch', at);
175
+ case 'waiting': return this.setBase('thinking', at);
176
+ case 'yourTurn': return this.express('determined', at).react('nod', at);
177
+ case 'idle': return this.setBase('neutral', at);
178
+ }
179
+ }
180
+
181
+ private faceAt(t: number): FaceParams {
182
+ const base = latest(this.bases, t);
183
+ const prior = base ? latest(this.bases.filter((b) => b.t < base.t), t) : undefined;
184
+ let face = base ? (prior && base.fade > 0 ? mix(prior.face, base.face, easeOut((t - base.t) / base.fade)) : base.face) : EXPRESSIONS.neutral;
185
+ if (this.idle) {
186
+ const smile = microSmileAt(this.seed, t);
187
+ if (smile > 0) {
188
+ face = mix(face, 'happy', smile);
189
+ }
190
+ }
191
+ const e = latest(this.expresses, t);
192
+ if (e) {
193
+ const dt = t - e.t;
194
+ const total = e.fade * 2 + e.hold;
195
+ if (dt < total) {
196
+ const w = dt < e.fade ? easeOut(dt / e.fade) : dt < e.fade + e.hold ? 1 : 1 - easeOut((dt - e.fade - e.hold) / e.fade);
197
+ face = mix(face, e.face, w);
198
+ }
199
+ }
200
+ const r = latest(this.reacts, t);
201
+ const track = r ? REACTIONS[r.reaction] : undefined;
202
+ if (r && track?.expression && t - r.t < track.ms) {
203
+ face = mix(face, track.expression, Math.min(1, ((t - r.t) / 120)));
204
+ }
205
+ return face;
206
+ }
207
+
208
+ private gazeAt(t: number): LookDir {
209
+ let from: LookDir = { x: 0, y: 0 };
210
+ let current: LookEvent | undefined;
211
+ for (const e of this.looks) {
212
+ if (e.t > t) {
213
+ break;
214
+ }
215
+ if (current) {
216
+ from = this.approach(from, current, e.t);
217
+ }
218
+ current = e;
219
+ }
220
+ if (!current || current.target === null) {
221
+ const wander = this.idle && !this.reducedMotion ? wanderAt(this.seed, t) : { x: 0, y: 0 };
222
+ return current ? this.approach(from, { ...current, target: wander }, t) : wander;
223
+ }
224
+ return this.approach(from, current, t);
225
+ }
226
+
227
+ private approach(from: LookDir, e: LookEvent, t: number): LookDir {
228
+ const target = e.target ?? { x: 0, y: 0 };
229
+ const k = this.reducedMotion ? 1 : 1 - Math.exp((-e.speed * Math.max(0, t - e.t)) / 1000);
230
+ return { x: from.x + (target.x - from.x) * k, y: from.y + (target.y - from.y) * k };
231
+ }
232
+
233
+ private talkAt(t: number): number {
234
+ const e = latest(this.talks, t);
235
+ if (!e || e.level === 0 || t > e.until) {
236
+ return 0;
237
+ }
238
+ if (this.reducedMotion) {
239
+ return 0.5 * e.level;
240
+ }
241
+ const flap = Math.abs(Math.sin((t - e.t) / 1000 * Math.PI * 5.5)) * (0.65 + 0.35 * Math.sin((t - e.t) / 1000 * 3.1));
242
+ return e.level * flap;
243
+ }
244
+
245
+ private reactionAt(t: number): { readonly frame: ReactionFrame; readonly body?: BodyPose } {
246
+ const r = latest(this.reacts, t);
247
+ if (!r) {
248
+ return { frame: STILL };
249
+ }
250
+ const track = REACTIONS[r.reaction];
251
+ const u = (t - r.t) / track.ms;
252
+ if (u >= 1) {
253
+ return { frame: STILL };
254
+ }
255
+ return { frame: this.reducedMotion ? STILL : track.at(u), ...(track.body ? { body: track.body } : {}) };
256
+ }
257
+
258
+ /** The pose at time `t` (same clock as the calls). Pure: the same schedule and `t` give the same pose. */
259
+ pose(t: number = this.clock()): Pose {
260
+ const face = this.faceAt(t);
261
+ const reaction = this.reactionAt(t);
262
+ const bodyEvent = latest(this.bodies, t);
263
+ const body = reaction.body ?? bodyEvent?.body ?? 'idle';
264
+ const cycle = bodyEvent ? ((t - bodyEvent.t) / bodyEvent.cycleMs) % 1 : 0;
265
+ const breath = this.idle && !this.reducedMotion ? breathAt(this.seed, t) : { bob: 0, sway: 0 };
266
+ const status = latest(this.statuses, t)?.status ?? null;
267
+ return {
268
+ face,
269
+ look: this.gazeAt(t),
270
+ blink: blinkAt(this.seed, t),
271
+ talk: this.talkAt(t),
272
+ body,
273
+ phase: reaction.body === 'cheer' ? (t / 467) % 1 : cycle,
274
+ status,
275
+ bob: { x: reaction.frame.bob.x, y: reaction.frame.bob.y + breath.bob },
276
+ tilt: reaction.frame.tilt + breath.sway,
277
+ squash: reaction.frame.squash,
278
+ };
279
+ }
280
+ }
281
+
282
+ /** A new animator for a look: `animator(look, { seed: userId })`. */
283
+ export function animator(look: LookLike, opts: AnimatorOptions = {}): Animator {
284
+ return new Animator(look, opts);
285
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * One-shot reactions (§3.7): short keyframe tracks of head bob, tilt and
3
+ * squash layered on top of the face, with an optional expression and body
4
+ * pose while they play.
5
+ */
6
+ import type { Expression } from '../face';
7
+ import type { BodyPose, Point } from '../render/types';
8
+
9
+ /** A reaction name. */
10
+ export type Reaction = 'nod' | 'shake' | 'bounce' | 'cheer' | 'gasp' | 'flinch' | 'shrug' | 'sleep' | 'wave';
11
+
12
+ /** What a reaction adds at a moment. */
13
+ export interface ReactionFrame {
14
+ readonly bob: Point;
15
+ readonly tilt: number;
16
+ readonly squash: number;
17
+ }
18
+
19
+ /** A reaction's track. */
20
+ export interface ReactionTrack {
21
+ readonly ms: number;
22
+ readonly expression?: Expression;
23
+ readonly body?: BodyPose;
24
+ readonly at: (u: number) => ReactionFrame;
25
+ }
26
+
27
+ const still: ReactionFrame = { bob: { x: 0, y: 0 }, tilt: 0, squash: 0 };
28
+ const ease = (u: number): number => Math.sin(u * Math.PI);
29
+
30
+ /** Every reaction's track; `u` runs 0 … 1 over `ms`. */
31
+ export const REACTIONS: Readonly<Record<Reaction, ReactionTrack>> = {
32
+ nod: { ms: 700, at: (u) => ({ bob: { x: 0, y: Math.sin(u * Math.PI * 2) * 3 * (1 - u) }, tilt: 0, squash: 0 }) },
33
+ shake: { ms: 800, at: (u) => ({ bob: { x: Math.sin(u * Math.PI * 4) * 4 * (1 - u), y: 0 }, tilt: 0, squash: 0 }) },
34
+ bounce: { ms: 600, expression: 'happy', at: (u) => ({ bob: { x: 0, y: -ease(u) * 8 }, tilt: 0, squash: -ease(u) * 0.04 }) },
35
+ cheer: { ms: 1400, expression: 'laugh', body: 'cheer', at: (u) => ({ bob: { x: 0, y: -Math.abs(Math.sin(u * Math.PI * 3)) * 6 }, tilt: 0, squash: 0 }) },
36
+ gasp: { ms: 900, expression: 'surprised', at: (u) => ({ bob: { x: 0, y: -ease(Math.min(1, u * 2)) * 3 }, tilt: 0, squash: -ease(u) * 0.05 }) },
37
+ flinch: { ms: 600, expression: 'shocked', at: (u) => ({ bob: { x: -ease(u) * 5, y: ease(u) * 2 }, tilt: -ease(u) * 6, squash: ease(u) * 0.04 }) },
38
+ shrug: { ms: 900, expression: 'smug', at: (u) => ({ bob: { x: 0, y: -ease(u) * 3 }, tilt: Math.sin(u * Math.PI * 2) * 5, squash: 0 }) },
39
+ sleep: { ms: 3000, expression: 'sleepy', body: 'away', at: () => still },
40
+ wave: { ms: 1500, expression: 'happy', body: 'wave', at: (u) => ({ bob: { x: 0, y: 0 }, tilt: Math.sin(u * Math.PI * 4) * 3, squash: 0 }) },
41
+ };
42
+
43
+ /** No motion. */
44
+ export const STILL = still;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Seeded, stateless schedules: blinks, gaze wander, breathing and micro
3
+ * expressions are pure functions of (seed, t), so the same t always gives
4
+ * the same pose (snapshot tests, replays, many tabs agreeing).
5
+ */
6
+ import { hashSeed, mulberry32 } from '@diffenderfer-games/hub-contract/avatar';
7
+
8
+ /** A blink lasts this long (ms). */
9
+ export const BLINK_MS = 150;
10
+ const BLINK_BUCKET = 4000;
11
+
12
+ /** A deterministic random in [0, 1) for (seed, channel, bucket). */
13
+ function cell(seed: number, channel: number, bucket: number): () => number {
14
+ return mulberry32(hashSeed(`${String(seed)}:${String(channel)}:${String(bucket)}`));
15
+ }
16
+
17
+ /** One blink's closure at offset `dt` from its start: up then down. */
18
+ function closure(dt: number): number {
19
+ if (dt < 0 || dt > BLINK_MS) {
20
+ return 0;
21
+ }
22
+ const half = BLINK_MS / 2;
23
+ return dt < half ? dt / half : 1 - (dt - half) / half;
24
+ }
25
+
26
+ /**
27
+ * Lid closure from blinking, 0 … 1: one blink per 4 s bucket at a seeded
28
+ * offset (so intervals fall between about 2 and 6 s), 10% of them doubled.
29
+ */
30
+ export function blinkAt(seed: number, t: number): number {
31
+ const bucket = Math.floor(t / BLINK_BUCKET);
32
+ let out = 0;
33
+ for (const b of [bucket - 1, bucket]) {
34
+ const r = cell(seed, 1, b);
35
+ const start = b * BLINK_BUCKET + 1000 + r() * 2000;
36
+ const isDouble = r() < 0.1;
37
+ out = Math.max(out, closure(t - start), isDouble ? closure(t - start - BLINK_MS - 90) : 0);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** An idle wandering gaze target: holds for 3–8 s, small amplitude. */
43
+ export function wanderAt(seed: number, t: number): { readonly x: number; readonly y: number } {
44
+ const bucket = Math.floor(t / 5500);
45
+ const r = cell(seed, 2, bucket);
46
+ if (r() < 0.45) {
47
+ return { x: 0, y: 0 };
48
+ }
49
+ return { x: (r() * 2 - 1) * 0.6, y: (r() * 2 - 1) * 0.3 };
50
+ }
51
+
52
+ /** Breathing bob (rig units) and a slow sway (degrees). */
53
+ export function breathAt(seed: number, t: number): { readonly bob: number; readonly sway: number } {
54
+ const phase = (seed % 1000) / 1000;
55
+ return {
56
+ bob: Math.sin((t / 2400 + phase) * Math.PI * 2) * 0.6,
57
+ sway: Math.sin((t / 5200 + phase) * Math.PI * 2) * 1.2,
58
+ };
59
+ }
60
+
61
+ /** An occasional micro smile while idle: its weight at t (0 most of the time). */
62
+ export function microSmileAt(seed: number, t: number): number {
63
+ const bucket = Math.floor(t / 11000);
64
+ const r = cell(seed, 3, bucket);
65
+ if (r() < 0.6) {
66
+ return 0;
67
+ }
68
+ const dt = t - (bucket * 11000 + 2000 + r() * 6000);
69
+ if (dt < 0 || dt > 900) {
70
+ return 0;
71
+ }
72
+ return Math.sin((dt / 900) * Math.PI) * 0.35;
73
+ }
@@ -0,0 +1,49 @@
1
+ /** Facial hair (catalog `beard.*`): `beard` sits below the mouth, `moustache` above it. */
2
+ import { blob, type Pt } from '../geom';
3
+ import type { FacialHairPart } from './shape';
4
+
5
+ const jaw: Pt[] = [[-45, 0], [-44, 22], [-30, 46], [0, 56], [30, 46], [44, 22], [45, 0]];
6
+
7
+ /** Every facial-hair part by name. */
8
+ export const BEARD_ART: Readonly<Record<string, FacialHairPart>> = {
9
+ none: {},
10
+ stubble: {
11
+ beard: [{ d: blob([[-45, -4], [-44, 22], [-30, 46], [0, 54], [30, 46], [44, 22], [45, -4], [36, 6], [22, 18], [10, 19], [0, 20], [-10, 19], [-22, 18], [-36, 6]]), fill: 'stubble', clip: 'head', stroke: 'none', opacity: 0.5 }],
12
+ },
13
+ moustache: {
14
+ moustache: [{ d: blob([[0, 21.5], [6, 19.5], [13, 21], [16, 26, 'c'], [9, 24.5], [0, 24.5], [-9, 24.5], [-16, 26, 'c'], [-13, 21], [-6, 19.5]]), fill: 'beard' }],
15
+ },
16
+ handlebar: {
17
+ moustache: [{ d: blob([[0, 21], [8, 19.5], [16, 22], [22, 22, 'c'], [24, 17], [26, 20], [22, 26], [14, 25], [0, 24.5], [-14, 25], [-22, 26], [-26, 20], [-24, 17], [-22, 22, 'c'], [-16, 22], [-8, 19.5]]), fill: 'beard' }],
18
+ },
19
+ goatee: {
20
+ beard: [{ d: blob([[-8, 34], [0, 33], [8, 34], [7, 43], [0, 50], [-7, 43]]), fill: 'beard' }],
21
+ moustache: [{ d: blob([[0, 21.5], [7, 20], [12, 21.5], [13, 25, 'c'], [6, 24.5], [0, 24.5], [-6, 24.5], [-13, 25, 'c'], [-12, 21.5], [-7, 20]]), fill: 'beard' }],
22
+ },
23
+ chinstrap: {
24
+ beard: [{ d: blob([...jaw, [40, 2], [38, 22], [26, 40], [0, 47], [-26, 40], [-38, 22], [-40, 2]]), fill: 'beard' }],
25
+ },
26
+ short: {
27
+ // Trimmed close along the jaw: thin sideburns, a little past the chin, the cheeks left clear.
28
+ beard: [{
29
+ d: blob([[-47, -1], [-45.5, 16], [-37.5, 34], [-21, 48.5], [0, 53.5], [21, 48.5], [37.5, 34], [45.5, 16], [47, -1], [42, 3], [39.5, 16], [32, 30], [18, 38.5], [0, 41], [-18, 38.5], [-32, 30], [-39.5, 16], [-42, 3]]),
30
+ fill: 'beard',
31
+ }, { d: blob([[-4, 47.5], [0, 49], [4, 47.5]], false), fill: 'none', line: true, stroke: 'beardShade', sw: 2 }],
32
+ moustache: [{ d: blob([[0, 21.5], [6.5, 20], [13, 21.5], [15.5, 25.5, 'c'], [8, 24.3], [0, 24.3], [-8, 24.3], [-15.5, 25.5, 'c'], [-13, 21.5], [-6.5, 20]]), fill: 'beard' }],
33
+ },
34
+ full: {
35
+ beard: [{
36
+ d: blob([[-46, -4], [-46, 22], [-34, 48], [-14, 60], [0, 62], [14, 60], [34, 48], [46, 22], [46, -4], [38, 4], [30, 14], [18, 20], [0, 20], [-18, 20], [-30, 14], [-38, 4]]),
37
+ fill: 'beard',
38
+ }, { d: blob([[-6, 49], [0, 52], [6, 49]], false), fill: 'none', line: true, stroke: 'beardShade', sw: 2 }],
39
+ moustache: [{ d: blob([[0, 21], [8, 19.5], [16, 22], [18, 27, 'c'], [10, 25], [0, 25], [-10, 25], [-18, 27, 'c'], [-16, 22], [-8, 19.5]]), fill: 'beard' }],
40
+ },
41
+ long: {
42
+ beard: [{
43
+ d: blob([[-46, -4], [-46, 24], [-36, 54], [-20, 74], [0, 82, 'c'], [20, 74], [36, 54], [46, 24], [46, -4], [38, 4], [30, 14], [18, 20], [0, 20], [-18, 20], [-30, 14], [-38, 4]]),
44
+ fill: 'beard',
45
+ }, { d: blob([[-10, 52], [-6, 66], [0, 76]], false), fill: 'none', line: true, stroke: 'beardShade', sw: 2 },
46
+ { d: blob([[10, 52], [6, 66]], false), fill: 'none', line: true, stroke: 'beardShade', sw: 2 }],
47
+ moustache: [{ d: blob([[0, 21], [8, 19.5], [16, 22], [18, 27, 'c'], [10, 25], [0, 25], [-10, 25], [-18, 27, 'c'], [-16, 22], [-8, 19.5]]), fill: 'beard' }],
48
+ },
49
+ };