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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +65 -19
  2. package/README.md +3 -6
  3. package/dist/auto.d.ts +5 -5
  4. package/dist/auto.js +2 -2
  5. package/dist/avatar-pixi.d.ts +147 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +280 -0
  8. package/dist/avatar-pixi.js.map +1 -0
  9. package/dist/avatar-svg.d.ts +4 -0
  10. package/dist/avatar-svg.js +5 -0
  11. package/dist/avatar-three.d.ts +69 -0
  12. package/dist/avatar-three.d.ts.map +1 -0
  13. package/dist/avatar-three.js +49 -0
  14. package/dist/avatar-three.js.map +1 -0
  15. package/dist/avatar.d.ts +7 -0
  16. package/dist/avatar.js +487 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +1 -1
  20. package/dist/index.d.ts +5 -5
  21. package/dist/index.js +2 -2
  22. package/dist/input.d.ts +2 -2
  23. package/dist/shared/animator.d.ts +104 -0
  24. package/dist/shared/animator.d.ts.map +1 -0
  25. package/dist/shared/art.js +1946 -0
  26. package/dist/shared/art.js.map +1 -0
  27. package/dist/shared/atlas.d.ts +134 -0
  28. package/dist/shared/atlas.d.ts.map +1 -0
  29. package/dist/shared/avatar.d.ts +117 -0
  30. package/dist/shared/avatar.d.ts.map +1 -0
  31. package/dist/shared/bridge.js +13 -5
  32. package/dist/shared/bridge.js.map +1 -1
  33. package/dist/shared/client-version.gen.js +1 -1
  34. package/dist/shared/daily.d.ts +129 -18
  35. package/dist/shared/daily.d.ts.map +1 -1
  36. package/dist/shared/daily.js +120 -26
  37. package/dist/shared/daily.js.map +1 -1
  38. package/dist/shared/defineProperty.js +39 -0
  39. package/dist/shared/describe.d.ts +32 -0
  40. package/dist/shared/describe.d.ts.map +1 -0
  41. package/dist/shared/describe.js +109 -0
  42. package/dist/shared/describe.js.map +1 -0
  43. package/dist/shared/globals.js +1 -39
  44. package/dist/shared/globals.js.map +1 -1
  45. package/dist/shared/index.d.ts +503 -14
  46. package/dist/shared/index.d.ts.map +1 -1
  47. package/dist/shared/input.d.ts +31 -13
  48. package/dist/shared/input.d.ts.map +1 -1
  49. package/dist/shared/input.js +40 -2
  50. package/dist/shared/input.js.map +1 -1
  51. package/dist/shared/json-guards.js +48 -0
  52. package/dist/shared/json-guards.js.map +1 -0
  53. package/dist/shared/live-canvas.js +62 -0
  54. package/dist/shared/live-canvas.js.map +1 -0
  55. package/dist/shared/mp.js +1 -1
  56. package/dist/shared/overlays.js +800 -0
  57. package/dist/shared/overlays.js.map +1 -0
  58. package/dist/shared/page-stack.js +2 -1
  59. package/dist/shared/page-stack.js.map +1 -1
  60. package/dist/shared/pose-key.js +249 -0
  61. package/dist/shared/pose-key.js.map +1 -0
  62. package/dist/shared/race.js +1 -1
  63. package/dist/shared/rooms.js +1 -1
  64. package/dist/shared/scene.d.ts +90 -0
  65. package/dist/shared/scene.d.ts.map +1 -0
  66. package/dist/shared/social.d.ts +55 -4
  67. package/dist/shared/social.d.ts.map +1 -1
  68. package/dist/shared/src.js +2020 -144
  69. package/dist/shared/src.js.map +1 -1
  70. package/dist/shared/svg-markup.js +1945 -0
  71. package/dist/shared/svg-markup.js.map +1 -0
  72. package/dist/shared/svg.js +27 -0
  73. package/dist/shared/svg.js.map +1 -0
  74. package/dist/shared/types.d.ts +166 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/social.d.ts +2 -2
  77. package/dist/social.js +2 -2
  78. package/dist/turns.d.ts +39 -0
  79. package/dist/turns.d.ts.map +1 -0
  80. package/dist/turns.js +117 -0
  81. package/dist/turns.js.map +1 -0
  82. package/package.json +33 -3
  83. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  84. package/src/avatar/adapters/pixi-crowd.ts +302 -0
  85. package/src/avatar/adapters/pixi-types.ts +84 -0
  86. package/src/avatar/adapters/three.ts +126 -0
  87. package/src/avatar/animator/animator.ts +285 -0
  88. package/src/avatar/animator/reactions.ts +44 -0
  89. package/src/avatar/animator/schedule.ts +73 -0
  90. package/src/avatar/art/beard.ts +49 -0
  91. package/src/avatar/art/glasses.ts +67 -0
  92. package/src/avatar/art/hair.ts +254 -0
  93. package/src/avatar/art/headwear.ts +75 -0
  94. package/src/avatar/art/index.ts +21 -0
  95. package/src/avatar/art/shape.ts +87 -0
  96. package/src/avatar/art-store.ts +36 -0
  97. package/src/avatar/atlas.ts +236 -0
  98. package/src/avatar/catalog-view.ts +14 -0
  99. package/src/avatar/colour.ts +109 -0
  100. package/src/avatar/describe.ts +79 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +173 -0
  103. package/src/avatar/layout.ts +47 -0
  104. package/src/avatar/look-like.ts +52 -0
  105. package/src/avatar/outputs/bitmap.ts +75 -0
  106. package/src/avatar/outputs/canvas-paint.ts +177 -0
  107. package/src/avatar/outputs/canvas.ts +62 -0
  108. package/src/avatar/outputs/custom-element.ts +140 -0
  109. package/src/avatar/outputs/element.ts +58 -0
  110. package/src/avatar/outputs/live-canvas.ts +57 -0
  111. package/src/avatar/outputs/overlay-svg.ts +22 -0
  112. package/src/avatar/outputs/pose-key.ts +30 -0
  113. package/src/avatar/outputs/svg-markup.ts +113 -0
  114. package/src/avatar/outputs/svg.ts +41 -0
  115. package/src/avatar/overlays.ts +128 -0
  116. package/src/avatar/render/body.ts +116 -0
  117. package/src/avatar/render/colours.ts +81 -0
  118. package/src/avatar/render/compose.ts +303 -0
  119. package/src/avatar/render/context.ts +117 -0
  120. package/src/avatar/render/eyes.ts +78 -0
  121. package/src/avatar/render/eyewear.ts +118 -0
  122. package/src/avatar/render/frame.ts +60 -0
  123. package/src/avatar/render/head.ts +81 -0
  124. package/src/avatar/render/mouth.ts +86 -0
  125. package/src/avatar/render/props.ts +91 -0
  126. package/src/avatar/render/types.ts +84 -0
  127. package/src/avatar/scene.ts +119 -0
  128. package/src/avatar-pixi.ts +20 -0
  129. package/src/avatar-svg.ts +21 -0
  130. package/src/avatar-three.ts +18 -0
  131. package/src/avatar.ts +62 -0
  132. package/src/core/avatar/avatar-cache.ts +95 -0
  133. package/src/core/avatar/avatar-ref.ts +34 -0
  134. package/src/core/avatar/avatar-types.ts +43 -0
  135. package/src/core/avatar/hub-avatar.ts +308 -0
  136. package/src/core/connection.ts +8 -0
  137. package/src/core/errors.ts +7 -1
  138. package/src/core/hub-progress.ts +39 -8
  139. package/src/core/hub.ts +174 -6
  140. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  141. package/src/core/offline/hub-kinds/index.ts +9 -0
  142. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  143. package/src/core/page-account.ts +121 -0
  144. package/src/core/rest/routes.gen.ts +31 -1
  145. package/src/core/resume/hub-resume.ts +39 -5
  146. package/src/core/resume/resume-types.ts +2 -2
  147. package/src/core/saved/daily-saves.ts +196 -0
  148. package/src/core/saved/daily-seed.ts +16 -0
  149. package/src/core/saved/hub-saved.ts +221 -0
  150. package/src/core/saved/local-saved.ts +122 -0
  151. package/src/core/saved/saved-list.ts +129 -0
  152. package/src/core/saved/saved-open.ts +104 -0
  153. package/src/core/saved/saved-order.ts +49 -0
  154. package/src/core/saved/saved-types.ts +24 -0
  155. package/src/core/turns/hub-turns.ts +140 -0
  156. package/src/core/turns/turn-game.ts +388 -0
  157. package/src/core/version/client-version.gen.ts +2 -2
  158. package/src/daily/daily-system.ts +112 -13
  159. package/src/daily/page-daily.ts +2 -2
  160. package/src/daily/types.ts +46 -9
  161. package/src/doctor/hub-version-fetch.ts +43 -43
  162. package/src/index.ts +10 -1
  163. package/src/input/input-system.ts +10 -1
  164. package/src/input/navigator.ts +11 -0
  165. package/src/input.ts +1 -0
  166. package/src/overlay/overlay.ts +25 -1
  167. package/src/realtime/runtime-api.ts +14 -3
  168. package/src/social.ts +1 -0
  169. package/src/turns-kit/check-rules.ts +182 -0
  170. package/src/turns.ts +13 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The body blob and its poses (§3.11). Each pose is a transform of the same
3
+ * gumdrop plus where the head sits; a future limbed body re-implements the
4
+ * same names. The cycle poses (`walk`, `run`, `dance`, `jump`, `cheer`) read
5
+ * `phase` (0 … 1).
6
+ */
7
+ import { blob } from '../geom';
8
+ import { ellipse, g, line, path, rect, rot, sc, skewX, tr, type SceneNode, type TfOp } from '../scene';
9
+ import { LINE } from './context';
10
+ import type { Colours } from './colours';
11
+ import type { BodyPose } from './types';
12
+
13
+ const BODY = blob([[-35, -2], [-40, -24], [-30, -50], [0, -60], [30, -50], [40, -24], [35, -2], [0, 2]]).d();
14
+
15
+ /** The body's drawing plus where the head goes. */
16
+ export interface BodyLayout {
17
+ readonly behind: readonly SceneNode[];
18
+ readonly body: readonly SceneNode[];
19
+ readonly front: readonly SceneNode[];
20
+ readonly headDx: number;
21
+ readonly headDy: number;
22
+ readonly tilt: number;
23
+ /** How far the whole character is lifted off the ground (jumps). */
24
+ readonly lift: number;
25
+ /** The `prop` anchor in rig space. */
26
+ readonly prop: { readonly x: number; readonly y: number };
27
+ }
28
+
29
+ const STOOL: readonly SceneNode[] = [
30
+ rect(-27, -12, 6, 12, { fill: '#2a3142', stroke: '#0d0f14', sw: 2 }),
31
+ rect(21, -12, 6, 12, { fill: '#2a3142', stroke: '#0d0f14', sw: 2 }),
32
+ rect(-38, -22, 76, 11, { fill: '#4a5470', stroke: '#0d0f14', sw: 3 }, 5.5),
33
+ ];
34
+
35
+ const hand = (c: Colours, cx: number, cy: number): SceneNode => ellipse(cx, cy, 8, 8, { fill: c.body, stroke: c.bodyInk, sw: LINE });
36
+
37
+ /** Lays out the body for a pose; `phi` is the body turn (shading shifts with it). */
38
+ export function bodyLayout(c: Colours, pose: BodyPose, phi: number, phase: number, hasStool: boolean): BodyLayout {
39
+ const wave = Math.sin(phase * Math.PI * 2);
40
+ const hop = Math.abs(Math.sin(phase * Math.PI));
41
+ let tf: TfOp[] = [];
42
+ let headDx = 0;
43
+ let headDy = 0;
44
+ let tilt = 0;
45
+ let lift = 0;
46
+ const behind: SceneNode[] = [];
47
+ const front: SceneNode[] = [];
48
+ switch (pose) {
49
+ case 'sit':
50
+ case 'play': {
51
+ const up = hasStool ? -18 : 0;
52
+ tf = [tr(0, up), sc(1.12, 0.8)];
53
+ headDy = 12 + up;
54
+ if (hasStool) {
55
+ behind.push(...STOOL);
56
+ }
57
+ break;
58
+ }
59
+ case 'walk':
60
+ case 'run': {
61
+ const k = pose === 'run' ? 1.6 : 1;
62
+ tf = [tr(0, -10 * k), skewX(-10 * k), sc(1 - wave * 0.04, 1 + wave * 0.06)];
63
+ headDy = -14 * k + wave * 3;
64
+ headDx = 7 * k;
65
+ tilt = 6 * k;
66
+ behind.push(path('M-52 -30h-14M-50 -18h-20M-54 -6h-10', line('#6f7787', 3, 'round', 0.8)));
67
+ break;
68
+ }
69
+ case 'wave':
70
+ tf = [rot(-6)];
71
+ headDx = -5;
72
+ tilt = -7;
73
+ front.push(hand(c, 44, -58), path('M56 -70Q61 -64 58 -56M62 -76Q69 -66 64 -54', line('#e8e6df', 2.2, 'round', 0.8)));
74
+ break;
75
+ case 'cheer':
76
+ lift = hop * 10;
77
+ tf = [sc(1 - hop * 0.04, 1 + hop * 0.05)];
78
+ headDy = -4 * hop;
79
+ front.push(hand(c, -42, -66 - hop * 4), hand(c, 42, -66 - hop * 4));
80
+ break;
81
+ case 'jump':
82
+ lift = hop * 18;
83
+ tf = [sc(1 - hop * 0.06, 1 + hop * 0.08)];
84
+ headDy = -6 * hop;
85
+ break;
86
+ case 'dance':
87
+ tf = [rot(wave * 8), sc(1 + hop * 0.03, 1 - hop * 0.03)];
88
+ headDx = wave * 5;
89
+ tilt = wave * 7;
90
+ break;
91
+ case 'away':
92
+ tf = [sc(1.04, 0.95)];
93
+ headDy = 3;
94
+ tilt = 5;
95
+ break;
96
+ case 'idle':
97
+ break;
98
+ }
99
+ const hl = -16 - Math.sin(phi) * 8;
100
+ const body: SceneNode[] = [g([
101
+ path(BODY, { fill: c.bodyShade }),
102
+ path(BODY, { fill: c.body }, [tr(-3 - Math.sin(phi) * 2, -3), sc(0.9)]),
103
+ ellipse(0, -54, 20, 6, { fill: c.bodyShade }),
104
+ ellipse(0, 0, 6, 4, { fill: c.bodyLight }, [tr(hl, -36), rot(-30)]),
105
+ path(BODY, { fill: 'none', stroke: c.bodyInk, sw: LINE }),
106
+ ], { tf })];
107
+ const isSitting = pose === 'sit' || pose === 'play';
108
+ return { behind, body, front, headDx, headDy, tilt, lift, prop: { x: headDx * 0.5, y: isSitting ? -34 + (hasStool ? -18 : 0) * 0.8 : -30 } };
109
+ }
110
+
111
+ /** The soft ground shadow at the feet (shrinks while lifted). */
112
+ export function groundShadow(lift: number): SceneNode {
113
+ const k = Math.max(0.5, 1 - lift / 40);
114
+ return ellipse(0, 0, 36 * k, 8 * k, { fill: '#000000', opacity: 0.3 });
115
+ }
116
+
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Resolves a look's colours into every tint slot the art uses (§3.2): each
3
+ * fill gets its shade, highlight and tonal outline (style C, §14.1), brows
4
+ * and beards are kept apart from the skin (§14.2), and accessories default
5
+ * to the body colour.
6
+ */
7
+ import { resolveColour, type AvatarLook, type LookAccessory } from '@diffenderfer-games/hub-contract/avatar';
8
+ import { adjust, keepApart, light, lightness, mix, outline, shade } from '../colour';
9
+
10
+ /** The soft dark-brown ink for lids, mouths and closed eyes (§14.1). */
11
+ export const SOFT_INK = '#3a2a2a';
12
+
13
+ /** Resolved colours for one look. */
14
+ export interface Colours {
15
+ readonly skin: string;
16
+ readonly skinShade: string;
17
+ readonly skinLight: string;
18
+ readonly skinInk: string;
19
+ /** The eye-white rim: a darker shade of the skin. */
20
+ readonly eyeRim: string;
21
+ readonly hair: string;
22
+ readonly hairShade: string;
23
+ readonly hairLight: string;
24
+ readonly hairInk: string;
25
+ /** A buzz cut: hair mixed into the skin. */
26
+ readonly hairBuzz: string;
27
+ readonly brow: string;
28
+ readonly beard: string;
29
+ readonly beardShade: string;
30
+ readonly beardInk: string;
31
+ readonly stubble: string;
32
+ readonly eye: string;
33
+ readonly eyeDark: string;
34
+ readonly body: string;
35
+ readonly bodyShade: string;
36
+ readonly bodyLight: string;
37
+ readonly bodyInk: string;
38
+ }
39
+
40
+ /** The fixed colours every look shares. */
41
+ export const FIXED = {
42
+ ink: SOFT_INK, white: '#fbfaf6', mouth: '#5a1d2a', tongue: '#ff7d8e', blush: '#ff7b8f', pupil: '#140f0d',
43
+ } as const;
44
+
45
+ /** A colour and its derived tones. */
46
+ export interface Tones {
47
+ readonly base: string;
48
+ readonly shade: string;
49
+ readonly light: string;
50
+ readonly ink: string;
51
+ }
52
+
53
+ /** A fill and its tones. */
54
+ export function tones(hex: string): Tones {
55
+ return { base: hex, shade: shade(hex), light: light(hex), ink: outline(hex) };
56
+ }
57
+
58
+ /** The colours of a look. */
59
+ export function lookColours(look: AvatarLook): Colours {
60
+ const skin = resolveColour(look.skin);
61
+ const hair = resolveColour(look.hair.colour);
62
+ const browBase = look.brows.colour === undefined
63
+ ? (lightness(hair) > 0.78 ? adjust(hair, -0.22) : adjust(hair, -0.05))
64
+ : resolveColour(look.brows.colour);
65
+ const beard = keepApart(look.facialHair.colour === undefined ? hair : resolveColour(look.facialHair.colour), skin);
66
+ const eye = resolveColour(look.eyes.colour);
67
+ const body = resolveColour(look.body.colour);
68
+ return {
69
+ skin, skinShade: shade(skin), skinLight: light(skin), skinInk: outline(skin), eyeRim: adjust(skin, -0.2, 1.1),
70
+ hair, hairShade: shade(hair), hairLight: light(hair), hairInk: outline(hair), hairBuzz: mix(hair, skin, 0.42),
71
+ brow: keepApart(browBase, skin),
72
+ beard, beardShade: shade(beard), beardInk: outline(beard), stubble: mix(beard, skin, 0.35),
73
+ eye, eyeDark: adjust(eye, -0.18),
74
+ body, bodyShade: shade(body), bodyLight: light(body), bodyInk: outline(body),
75
+ };
76
+ }
77
+
78
+ /** An accessory's colour: its own, else the body colour. */
79
+ export function accessoryColour(look: AvatarLook, accessory: LookAccessory | undefined): string {
80
+ return accessory?.colour === undefined ? resolveColour(look.body.colour) : resolveColour(accessory.colour);
81
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Composes a look and a pose into a scene in rig space: the feet at (0, 0),
3
+ * y down, the head centre near (0, −96) (§14.2). Layers go back to front as
4
+ * in §3.2, with facial hair split into beard and moustache layers.
5
+ */
6
+ import { partName, resolveColour, type AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
7
+ import type { ArtSet } from '../art';
8
+ import type { HeadwearPart, Shape } from '../art/shape';
9
+ import { currentArt } from '../art-store';
10
+ import { EXPRESSIONS } from '../face';
11
+ import { n, type Warp } from '../geom';
12
+ import { g, path, rect, rot, sc, tr, type Clip, type SceneNode } from '../scene';
13
+ import { bodyLayout, groundShadow } from './body';
14
+ import { accessoryColour, lookColours, tones, type Colours } from './colours';
15
+ import { HEAD_PATH, LINE, paintShapes, SKULL_R, turnWarp, type Ctx } from './context';
16
+ import { FRAME_BLACK, glasses, goggles, gogglesStrapBack, type LensGlass } from './eyewear';
17
+ import { brow, eye } from './eyes';
18
+ import { cheeks, ears, head, type EarExtra } from './head';
19
+ import { extras, mouth, nose } from './mouth';
20
+ import { crown, handProp, headset } from './props';
21
+ import type { Anchor, Facing, Point, Pose, RenderOptions } from './types';
22
+
23
+ /** The lens tint drawn with a mirror finish. */
24
+ const MIRROR_TINT = 'p:lens-mirror';
25
+
26
+ /** The head centre's height above the feet. */
27
+ export const HEAD_Y = -96;
28
+
29
+ const DEG = Math.PI / 180;
30
+ type Drawn = 'S' | 'SE' | 'E' | 'NE' | 'N';
31
+ const FACING_ANGLE: Readonly<Record<Drawn, number>> = { S: 0, SE: 34, E: 70, NE: 145, N: 180 };
32
+ const MIRROR: Readonly<Partial<Record<Facing, Drawn>>> = { SW: 'SE', W: 'E', NW: 'NE' };
33
+ const VIEW_FACING = { front: 'S', back: 'N', left: 'W', right: 'E' } as const;
34
+
35
+ /** A composed avatar: scene nodes in rig space and its anchors. */
36
+ export interface Composed {
37
+ readonly nodes: readonly SceneNode[];
38
+ readonly anchors: Readonly<Record<Anchor, Point>>;
39
+ }
40
+
41
+ /** The facing an options object asks for. */
42
+ export function facingOf(opts: RenderOptions): Facing {
43
+ return opts.facing ?? (opts.view ? VIEW_FACING[opts.view] : 'S');
44
+ }
45
+
46
+ /** Per-look parts resolved against the art. */
47
+ interface Parts {
48
+ readonly c: Colours;
49
+ readonly hair: ArtSet['hair'][string] | undefined;
50
+ readonly hat: HeadwearPart | undefined;
51
+ readonly hatColour: string;
52
+ readonly beard: ArtSet['beard'][string] | undefined;
53
+ readonly glasses: ArtSet['glasses'][string] | undefined;
54
+ readonly glassesColour: string;
55
+ readonly lensGlass: LensGlass;
56
+ readonly freckles: boolean;
57
+ readonly rosy: boolean;
58
+ readonly earExtra: EarExtra | undefined;
59
+ readonly browStyle: string;
60
+ }
61
+
62
+ function resolveParts(look: AvatarLook, art: ArtSet): Parts {
63
+ const slot = (name: string) => look.accessories.find((a) => a.slot === name);
64
+ const hatAcc = slot('headwear');
65
+ const glassAcc = slot('glasses');
66
+ const earAcc = slot('ears');
67
+ const face = slot('face');
68
+ return {
69
+ c: lookColours(look),
70
+ hair: art.hair[partName(look.hair.style)],
71
+ hat: hatAcc ? art.headwear[partName(hatAcc.id)] : undefined,
72
+ hatColour: accessoryColour(look, hatAcc),
73
+ beard: art.beard[partName(look.facialHair.style)],
74
+ glasses: glassAcc ? art.glasses[partName(glassAcc.id)] : undefined,
75
+ glassesColour: glassAcc?.colour === undefined ? FRAME_BLACK : accessoryColour(look, glassAcc),
76
+ lensGlass: glassAcc?.tint === undefined ? {} : { tint: resolveColour(glassAcc.tint), ...(glassAcc.tint === MIRROR_TINT ? { isMirror: true } : {}) },
77
+ freckles: face?.id === 'face.freckles',
78
+ rosy: face?.id === 'face.rosy',
79
+ earExtra: earAcc ? { kind: earAcc.id === 'ears.hoops' ? 'hoops' : 'studs', colour: earAcc.colour === undefined ? '#f5c542' : accessoryColour(look, earAcc) } : undefined,
80
+ browStyle: partName(look.brows.style),
81
+ };
82
+ }
83
+
84
+ /** The far-side clip for a turned head's rear art (a back-of-skull crescent). */
85
+ function backOfHead(phi: number): { readonly crescent: Clip; readonly half: Clip } {
86
+ const co = Math.cos(phi);
87
+ const left = -SKULL_R * co - 3;
88
+ const cx = (left + SKULL_R + 12) / 2;
89
+ const rx = (SKULL_R + 12 - left) / 2;
90
+ const crescent = `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`;
91
+ return {
92
+ crescent: { shapes: [path(crescent, {})], evenOdd: true },
93
+ half: { shapes: [rect(-200, -200, 200 + cx, 400, {})] },
94
+ };
95
+ }
96
+
97
+ /**
98
+ * The shoulder line in head space: hair below it hangs behind the body and
99
+ * shoulders, hair above it (or over the face) stays over the head (§14.6
100
+ * "Long hair"). The two halves overlap by a unit so the seam never shows.
101
+ */
102
+ export const SHOULDER_Y = 36;
103
+ const ABOVE_SHOULDERS: Clip = { shapes: [rect(-200, -200, 400, 200 + SHOULDER_Y + 1, {}), path(HEAD_PATH.d(), {})] };
104
+ const BELOW_SHOULDERS: Clip = { shapes: [rect(-200, SHOULDER_Y - 1, 400, 300, {})] };
105
+
106
+ /** True when a layer reaches below the shoulder line. */
107
+ function hangs(shapes: readonly Shape[] | undefined): boolean {
108
+ return shapes?.some((s) => s.d.bottom() > SHOULDER_Y) === true;
109
+ }
110
+
111
+ /** A head's drawing: what goes behind the body (hanging hair) and the head itself. */
112
+ interface HeadNodes {
113
+ readonly behindBody: SceneNode[];
114
+ readonly head: SceneNode[];
115
+ }
116
+
117
+ /** Splits a hair layer at the shoulders: the part above stays in `over`, the part below goes in `behind`. */
118
+ function splitAtShoulders(nodes: readonly SceneNode[], doesHang: boolean, over: SceneNode[], behind: SceneNode[]): void {
119
+ if (nodes.length === 0) {
120
+ return;
121
+ }
122
+ if (!doesHang) {
123
+ over.push(...nodes);
124
+ return;
125
+ }
126
+ over.push(g(nodes, { clip: ABOVE_SHOULDERS }));
127
+ behind.push(g(nodes, { clip: BELOW_SHOULDERS }));
128
+ }
129
+
130
+ /** The front and side views of the head. */
131
+ function frontHead(ctx: Ctx, p: Parts, isPlaying: boolean, status: Pose['status'], angle: number, gazeX: number): HeadNodes {
132
+ const hatCut: Clip | undefined = p.hat && typeof p.hat.hides === 'number' ? { shapes: [rect(-120, p.hat.hides, 240, 260, {})] } : undefined;
133
+ const isHairShown = !(p.hat && p.hat.hides === 'all');
134
+ const cut = (nodes: SceneNode[]): SceneNode[] => (hatCut && nodes.length > 0 ? [g(nodes, { clip: hatCut })] : nodes);
135
+ const hairTie = tones(ctx.c.body);
136
+ const hatTones = tones(p.hatColour);
137
+ const warpFront = turnWarp((angle + gazeX * 6) * DEG);
138
+ const warpBack: Warp | undefined = ctx.phi === 0 ? undefined : (px, py) => [px - Math.sin(ctx.phi) * 8, py];
139
+ const behindBody: SceneNode[] = [];
140
+ const out: SceneNode[] = [];
141
+ if (isHairShown) {
142
+ // The hair behind the head is behind the body too: it hangs down the back.
143
+ behindBody.push(...cut(paintShapes(ctx.c, hairTie, p.hair?.back, warpBack)));
144
+ }
145
+ if (p.hat?.back) {
146
+ out.push(...paintShapes(ctx.c, hatTones, p.hat.back, warpBack));
147
+ }
148
+ if (!p.hat?.hidesEars) {
149
+ out.push(...ears(ctx, 'behind', p.earExtra));
150
+ }
151
+ out.push(...head(ctx));
152
+ const turnS = Math.sin(ctx.phi);
153
+ if (Math.abs(turnS) > 0.3) {
154
+ // A turned head shows the back of the hair (the rear art) on the far side (§14.2).
155
+ const co = Math.cos(ctx.phi);
156
+ const shift: Warp = (px, py) => [px * (0.92 + 0.08 * co) - turnS * 5, py];
157
+ const clips = backOfHead(ctx.phi);
158
+ const onFarSide = (nodes: SceneNode[]): SceneNode[] => (nodes.length > 0 ? [g([g(nodes, { clip: clips.crescent })], { clip: clips.half })] : []);
159
+ if (isHairShown) {
160
+ splitAtShoulders(onFarSide(cut(paintShapes(ctx.c, hairTie, p.hair?.rear, shift))), hangs(p.hair?.rear), out, behindBody);
161
+ }
162
+ if (p.hat) {
163
+ out.push(...onFarSide(paintShapes(ctx.c, hatTones, p.hat.rear, shift)));
164
+ }
165
+ }
166
+ if (!p.hat?.hidesEars) {
167
+ out.push(...ears(ctx, 'front', p.earExtra));
168
+ }
169
+ out.push(...cheeks(ctx, p.freckles, p.rosy));
170
+ const faceWarp = turnWarp(ctx.phi, ctx.dy * 0.8);
171
+ out.push(...paintShapes(ctx.c, hairTie, p.beard?.beard, faceWarp));
172
+ if (!isPlaying) {
173
+ out.push(...eye(ctx, -1), ...eye(ctx, 1));
174
+ }
175
+ out.push(...brow(ctx, -1, p.browStyle), ...brow(ctx, 1, p.browStyle));
176
+ if (turnS > 0.8) {
177
+ out.push(path('M43 9C50 11 51 18 43.5 20', { fill: ctx.c.skin, stroke: ctx.c.skinInk, sw: LINE }));
178
+ }
179
+ out.push(...nose(ctx), ...mouth(ctx));
180
+ out.push(...paintShapes(ctx.c, hairTie, p.beard?.moustache, faceWarp));
181
+ if (isHairShown) {
182
+ splitAtShoulders(cut(paintShapes(ctx.c, hairTie, p.hair?.front, warpFront)), hangs(p.hair?.front), out, behindBody);
183
+ if (p.hair?.front?.some((s) => s.clip === 'head')) {
184
+ out.push(path(HEAD_PATH.d(), { fill: 'none', stroke: ctx.c.skinInk, sw: LINE }));
185
+ }
186
+ }
187
+ if (isPlaying) {
188
+ out.push(...goggles(ctx));
189
+ } else if (p.glasses) {
190
+ out.push(...glasses(ctx, p.glasses, p.glassesColour, p.lensGlass));
191
+ }
192
+ if (p.hat) {
193
+ out.push(...paintShapes(ctx.c, hatTones, p.hat.front, warpFront));
194
+ }
195
+ if (status === 'headset') {
196
+ out.push(...headset(ctx, false));
197
+ }
198
+ out.push(...extras(ctx));
199
+ return { behindBody, head: out };
200
+ }
201
+
202
+ /** The back views (N, NE): the rear art wrapped past 180°. */
203
+ function backHead(ctx: Ctx, p: Parts, isPlaying: boolean, status: Pose['status'], angle: number): HeadNodes {
204
+ const phi = (angle - 180) * DEG;
205
+ const warp = turnWarp(phi);
206
+ const isHairShown = !(p.hat && p.hat.hides === 'all');
207
+ const hatCut: Clip | undefined = p.hat && typeof p.hat.hides === 'number' ? { shapes: [rect(-120, p.hat.hides, 240, 260, {})] } : undefined;
208
+ const out: SceneNode[] = [];
209
+ if (!p.hat?.hidesEars) {
210
+ out.push(...ears({ ...ctx, phi }, 'behind', p.earExtra));
211
+ }
212
+ out.push(...head({ ...ctx, phi: 0 }));
213
+ if (isHairShown) {
214
+ const rear = paintShapes(ctx.c, tones(ctx.c.body), p.hair?.rear, warp);
215
+ out.push(...(hatCut && rear.length > 0 ? [g(rear, { clip: hatCut })] : rear));
216
+ if (p.hair?.rear.some((s) => s.clip === 'head')) {
217
+ out.push(path(HEAD_PATH.d(), { fill: 'none', stroke: ctx.c.skinInk, sw: LINE }));
218
+ }
219
+ }
220
+ if (isPlaying) {
221
+ out.push(...gogglesStrapBack(ctx, phi));
222
+ }
223
+ if (p.hat) {
224
+ out.push(...paintShapes(ctx.c, tones(p.hatColour), p.hat.rear, warp));
225
+ }
226
+ if (status === 'headset') {
227
+ out.push(...headset(ctx, true));
228
+ }
229
+ // From behind, long hair falls down the back, over the body.
230
+ return { behindBody: [], head: out };
231
+ }
232
+
233
+ /** The grey silhouette: a malformed code, a hidden player, or before the art loads. */
234
+ function silhouette(hasShadow: boolean): SceneNode[] {
235
+ const grey = '#6f7787';
236
+ return [
237
+ ...(hasShadow ? [groundShadow(0)] : []),
238
+ 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 }),
239
+ path(HEAD_PATH.d(), { fill: grey }, [tr(0, HEAD_Y), sc(0.94)]),
240
+ ];
241
+ }
242
+
243
+ function anchorsFor(headX: number, headY: number, top: number, prop: Point, mirror: number): Record<Anchor, Point> {
244
+ const at = (px: number, py: number): Point => ({ x: px * mirror, y: py });
245
+ return {
246
+ feet: at(0, 0), body: at(0, -30), neck: at(headX, headY + 46), head: at(headX, headY), eyeLine: at(headX, headY + 4),
247
+ mouth: at(headX, headY + 27), hatTop: at(headX, top), nameplate: at(0, top - 10), bubble: at(0, top - 14),
248
+ seat: at(0, 0), prop: at(prop.x, prop.y), shadow: at(0, 0), handL: at(-40, -30), handR: at(40, -30), pod: at(0, 14),
249
+ };
250
+ }
251
+
252
+ /** Composes a look (or `null` for the silhouette) with a pose. */
253
+ export function composeAvatar(look: AvatarLook | null, opts: RenderOptions = {}): Composed {
254
+ const art = currentArt();
255
+ const facingIn = facingOf(opts);
256
+ const isMirrored = MIRROR[facingIn] !== undefined;
257
+ const facing: Drawn = MIRROR[facingIn] ?? (facingIn === 'S' || facingIn === 'SE' || facingIn === 'E' || facingIn === 'NE' ? facingIn : 'N');
258
+ const hasShadow = opts.shadow ?? opts.frame === 'full';
259
+ if (!look || !art) {
260
+ return { nodes: silhouette(hasShadow), anchors: anchorsFor(0, HEAD_Y, HEAD_Y - 60, { x: 0, y: -30 }, 1) };
261
+ }
262
+ const pose = opts.pose ?? {};
263
+ const bodyPose = pose.body ?? 'idle';
264
+ const status = pose.status ?? (bodyPose === 'play' ? 'goggles' : null);
265
+ const isPlaying = status === 'goggles';
266
+ const face = pose.face ?? EXPRESSIONS[pose.expression ?? (bodyPose === 'play' ? 'determined' : bodyPose === 'away' ? 'sleepy' : 'neutral')];
267
+ const gaze = pose.look ?? { x: 0, y: 0 };
268
+ const angle = FACING_ANGLE[facing];
269
+ const isBack = angle > 90;
270
+ const parts = resolveParts(look, art);
271
+ const ctx: Ctx = {
272
+ c: parts.c, face, gaze, phi: (angle + gaze.x * 11) * DEG, dy: gaze.y * 3,
273
+ blink: pose.blink ?? 0, talk: pose.talk ?? 0,
274
+ };
275
+ const b = bodyLayout(parts.c, bodyPose, ctx.phi, pose.phase ?? 0.25, opts.stool === true);
276
+ const headX = b.headDx + (pose.bob?.x ?? 0);
277
+ const headY = HEAD_Y + b.headDy + (pose.bob?.y ?? 0);
278
+ const tilt = face.headTilt + b.tilt + (pose.tilt ?? 0);
279
+ const sq = face.squash + (pose.squash ?? 0);
280
+ const drawn = isBack ? backHead(ctx, parts, isPlaying, status, angle) : frontHead(ctx, parts, isPlaying, status, angle, gaze.x);
281
+ const headNodes = drawn.head;
282
+ const top = headY - 50 - Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0);
283
+ if (status === 'crown') {
284
+ headNodes.push(...crown(Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0)));
285
+ }
286
+ const handProps = status && !isBack ? handProp(status, b.prop.x, b.prop.y, parts.c.body, parts.c.bodyInk) : [];
287
+ const headTf = [tr(headX, headY), rot(tilt), sc(1 + sq, 1 - sq)];
288
+ const character: SceneNode[] = [
289
+ ...b.behind,
290
+ ...(drawn.behindBody.length > 0 ? [g(drawn.behindBody, { tf: headTf })] : []),
291
+ ...b.body,
292
+ g(headNodes, { tf: headTf }),
293
+ ...b.front, ...handProps,
294
+ ];
295
+ const lifted = b.lift > 0 ? [g(character, { tf: [tr(0, -b.lift)] })] : character;
296
+ const rimmed = opts.rim ? [g(lifted, { rim: true })] : lifted;
297
+ const all: SceneNode[] = [...(hasShadow ? [groundShadow(b.lift)] : []), ...rimmed];
298
+ const mirror = isMirrored ? -1 : 1;
299
+ return {
300
+ nodes: isMirrored ? [g(all, { tf: [sc(-1, 1)] })] : all,
301
+ anchors: anchorsFor(headX, headY - b.lift, top - b.lift, { x: b.prop.x, y: b.prop.y - b.lift }, mirror),
302
+ };
303
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * What every layer needs while drawing, the 2.5D head turn, and painting
3
+ * catalog shapes into scene nodes.
4
+ */
5
+ import type { Shape, Slot } from '../art/shape';
6
+ import type { FaceParams } from '../face';
7
+ import { clamp, Path, type Warp } from '../geom';
8
+ import { g, path, type Clip, type SceneNode } from '../scene';
9
+ import { FIXED, type Colours, type Tones } from './colours';
10
+ import type { LookDir } from './types';
11
+
12
+ /** The skull radius used by the head turn. */
13
+ export const SKULL_R = 46;
14
+
15
+ /** Style C's outline width at the 100-unit head height (§14.1). */
16
+ export const LINE = 2.4;
17
+
18
+ /** Part fills are outlined a little thinner than lines (the POC's style C). */
19
+ const FILL_OUTLINE = 0.8;
20
+
21
+ /** The head outline (centre 0,0; 100 units tall). */
22
+ export 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();
23
+
24
+ /** The head as a clip region. */
25
+ export const HEAD_CLIP: Clip = { shapes: [path(HEAD_PATH.d(), {})] };
26
+
27
+ /** Drawing state for one head. */
28
+ export interface Ctx {
29
+ readonly c: Colours;
30
+ readonly face: FaceParams;
31
+ readonly gaze: LookDir;
32
+ /** Head turn, radians: the facing plus a little from the gaze. */
33
+ readonly phi: number;
34
+ /** Vertical feature shift from the gaze. */
35
+ readonly dy: number;
36
+ /** Extra lid closure (blink). */
37
+ readonly blink: number;
38
+ /** Extra mouth opening (talk). */
39
+ readonly talk: number;
40
+ }
41
+
42
+ /** Where a face feature centred at `x0` lands after the head turn. */
43
+ export function feat(ctx: Ctx, x0: number): { readonly x: number; readonly sx: number; readonly vis: boolean } {
44
+ const t0 = Math.asin(clamp(x0 / SKULL_R, -1, 1));
45
+ const t = t0 + ctx.phi;
46
+ return { x: SKULL_R * Math.sin(t), sx: Math.max(0.05, Math.cos(t) / Math.cos(t0)), vis: Math.cos(t) > 0.22 };
47
+ }
48
+
49
+ /**
50
+ * A warp that wraps a part around the skull by `phi`. Unlike a strict
51
+ * cylinder, the far side is squeezed toward the silhouette instead of
52
+ * folding behind it, so outlines never collapse into wedges (§14.2).
53
+ */
54
+ export function turnWarp(phi: number, dy = 0): Warp | undefined {
55
+ if (phi === 0 && dy === 0) {
56
+ return undefined;
57
+ }
58
+ const half = Math.PI / 2;
59
+ return (px, py) => {
60
+ const ax = Math.abs(px);
61
+ const sg = Math.sign(px) || 1;
62
+ const t0 = Math.asin(Math.min(ax, SKULL_R) / SKULL_R) * sg;
63
+ const lead = phi >= 0 ? (t0 <= 0 ? 1 : 1 - t0 / half) : (t0 >= 0 ? 1 : 1 + t0 / half);
64
+ const t = t0 + phi * lead;
65
+ const over = Math.max(0, ax - SKULL_R) * Math.max(0.25, Math.abs(Math.sin(t)));
66
+ return [SKULL_R * Math.sin(t) + sg * over, py + dy];
67
+ };
68
+ }
69
+
70
+ /** Resolves a tint slot. `acc` is the accessory (or hair-tie) colour for this part. */
71
+ export function slotColour(c: Colours, acc: Tones, slot: Slot): string {
72
+ switch (slot) {
73
+ case 'none': return 'none';
74
+ case 'acc': return acc.base;
75
+ case 'accShade': return acc.shade;
76
+ case 'accLight': return acc.light;
77
+ case 'accInk': return acc.ink;
78
+ case 'ink': return FIXED.ink;
79
+ case 'white': return FIXED.white;
80
+ default: return c[slot];
81
+ }
82
+ }
83
+
84
+ /** The tonal outline slot for a fill slot. */
85
+ function outlineOf(slot: Slot): Slot {
86
+ if (slot.startsWith('hair')) {
87
+ return 'hairInk';
88
+ }
89
+ if (slot === 'beard' || slot === 'beardShade' || slot === 'stubble') {
90
+ return 'beardInk';
91
+ }
92
+ if (slot.startsWith('acc')) {
93
+ return 'accInk';
94
+ }
95
+ if (slot.startsWith('skin')) {
96
+ return 'skinInk';
97
+ }
98
+ return 'ink';
99
+ }
100
+
101
+ /** Paints catalog shapes with an optional warp; `clip: 'head'` shapes are clipped to the head. */
102
+ export function paintShapes(c: Colours, acc: Tones, shapes: readonly Shape[] | undefined, warp?: Warp): SceneNode[] {
103
+ if (!shapes) {
104
+ return [];
105
+ }
106
+ return shapes.map((s): SceneNode => {
107
+ const d = s.d.d(warp);
108
+ const opacity = s.opacity === undefined ? {} : { opacity: s.opacity };
109
+ if (s.line) {
110
+ return path(d, { fill: 'none', stroke: slotColour(c, acc, s.stroke ?? 'ink'), sw: s.sw ?? LINE, cap: s.cap ?? 'round', ...opacity });
111
+ }
112
+ const strokeSlot = s.stroke ?? outlineOf(s.fill);
113
+ const stroke = strokeSlot === 'none' ? {} : { stroke: slotColour(c, acc, strokeSlot), sw: (s.sw ?? LINE) * FILL_OUTLINE };
114
+ const node = path(d, { fill: slotColour(c, acc, s.fill), ...(s.evenOdd ? { evenOdd: true } : {}), ...stroke, ...opacity });
115
+ return s.clip === 'head' ? g([node], { clip: HEAD_CLIP }) : node;
116
+ });
117
+ }