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

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 +54 -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 +1804 -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 +101 -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 +1860 -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 +9 -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 +68 -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 +76 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +168 -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 +258 -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 +56 -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 +269 -238
  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,56 @@
1
+ /** Glasses and the `goggles` status prop, both following the head turn. */
2
+ import type { GlassesPart } from '../art/shape';
3
+ import { ink } from '../colour';
4
+ import { blob, Path } from '../geom';
5
+ import { g, line, path, sc, tr, type SceneNode } from '../scene';
6
+ import { feat, LINE, turnWarp, type Ctx } from './context';
7
+
8
+ /** Glasses: arms toward the ears, a lens at each eye, and the bridge. */
9
+ export function glasses(ctx: Ctx, part: GlassesPart, frame: string): SceneNode[] {
10
+ const out: SceneNode[] = [];
11
+ const warp = turnWarp(ctx.phi, ctx.dy);
12
+ for (const side of [-1, 1] as const) {
13
+ if (feat(ctx, 30 * side).vis) {
14
+ out.push(path(new Path().m(30 * side, 2).l(45 * side, 0).d(warp), line(frame, 2.6)));
15
+ }
16
+ }
17
+ for (const side of [-1, 1] as const) {
18
+ const f = feat(ctx, 17 * side);
19
+ if (!f.vis) {
20
+ continue;
21
+ }
22
+ out.push(g([
23
+ path(part.lens.d(), { fill: '#ffffff', fillOpacity: 0.16, stroke: frame, sw: 3.2 }),
24
+ path('M-6 -4L-2 -7', line('#ffffff', 1.8, 'round', 0.7)),
25
+ ], { tf: [tr(f.x, 4 + ctx.dy), sc(f.sx, 1)] }));
26
+ }
27
+ const fb = feat(ctx, 0);
28
+ out.push(g([path(part.bridge.d(), line(frame, 2.8))], { tf: [tr(fb.x, 4 + ctx.dy), sc(fb.sx, 1)] }));
29
+ return out;
30
+ }
31
+
32
+ const STRAP_FRONT = new Path().m(-47, -2).c(-30, -6, 30, -6, 47, -2);
33
+ const STRAP_BACK = new Path().m(-47, -2).c(-30, 4, 30, 4, 47, -2);
34
+ const SHELL = blob([[-31, -9], [-4, -10], [0, -6], [4, -10], [31, -9], [34, 4], [26, 16], [7, 16], [0, 11], [-7, 16], [-26, 16], [-34, 4]]);
35
+ const GLASS_L = blob([[-27, -5], [-7, -6], [-5, 8], [-9, 12], [-24, 12], [-29, 4]]);
36
+ const GLASS_R = blob([[27, -5], [7, -6], [5, 8], [9, 12], [24, 12], [29, 4]]);
37
+
38
+ /** Play goggles over the eyes (they replace the eyes and any glasses); the strap is the body colour. */
39
+ export function goggles(ctx: Ctx): SceneNode[] {
40
+ const w = turnWarp(ctx.phi, ctx.dy);
41
+ const band = ctx.c.body;
42
+ const strap = STRAP_FRONT.d(w);
43
+ return [
44
+ path(strap, line(ink(band), 9)), path(strap, line(band, 6)),
45
+ path(SHELL.d(w), { fill: '#2a3142', stroke: '#0d0f14', sw: LINE }),
46
+ path(GLASS_L.d(w), { fill: '#5fe3f2' }), path(GLASS_R.d(w), { fill: '#5fe3f2' }),
47
+ path(new Path().m(-22, 6).l(-14, -2).d(w), line('#e8fbff', 3)),
48
+ path(new Path().m(12, 6).l(20, -2).d(w), line('#e8fbff', 3)),
49
+ ];
50
+ }
51
+
52
+ /** The goggles strap seen from behind. */
53
+ export function gogglesStrapBack(ctx: Ctx, phi: number): SceneNode[] {
54
+ const d = STRAP_BACK.d(turnWarp(phi));
55
+ return [path(d, line(ink(ctx.c.body), 9)), path(d, line(ctx.c.body, 6))];
56
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Frames (§3.4): which part of rig space an output shows, and the badge
3
+ * background (the site's rounded panel with a ring that lightens on dark
4
+ * skin, §3.9).
5
+ */
6
+ import { normaliseHex, resolveColour, type AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
7
+ import { lightness } from '../colour';
8
+ import { g, rect, type SceneNode } from '../scene';
9
+ import type { Frame, RenderOptions } from './types';
10
+
11
+ /** A frame's box in rig units: x, y, width, height. */
12
+ export type Box = readonly [number, number, number, number];
13
+
14
+ /** The three frames' boxes. */
15
+ export const FRAMES: Readonly<Record<Frame, Box>> = {
16
+ head: [-66, -164, 132, 132],
17
+ bust: [-84, -188, 168, 168],
18
+ full: [-86, -196, 172, 206],
19
+ };
20
+
21
+ /** The site panel colour behind badges. */
22
+ export const PANEL = '#11141c';
23
+
24
+ /** The frame an options object asks for (default `head`). */
25
+ export function frameOf(opts: RenderOptions): Frame {
26
+ return opts.frame ?? 'head';
27
+ }
28
+
29
+ /** Output pixel size for a frame (the longer edge is `size`). */
30
+ export function pixelSize(opts: RenderOptions): { readonly width: number; readonly height: number; readonly scale: number } {
31
+ const [, , w, h] = FRAMES[frameOf(opts)];
32
+ const size = opts.size ?? 48;
33
+ const scale = size / Math.max(w, h);
34
+ return { width: w * scale, height: h * scale, scale };
35
+ }
36
+
37
+ /** The badge ring colour: lighter when the skin is darker than the panel, so every skin has an edge. */
38
+ export function ringColour(look: AvatarLook | null): string {
39
+ return look && lightness(resolveColour(look.skin)) < 0.42 ? '#6f7787' : '#2a3142';
40
+ }
41
+
42
+ /** Wraps the avatar's nodes in the frame's background (a badge clips to the rounded panel). */
43
+ export function framed(nodes: readonly SceneNode[], look: AvatarLook | null, opts: RenderOptions): SceneNode[] {
44
+ const [left, top, w, h] = FRAMES[frameOf(opts)];
45
+ const background = opts.background ?? (frameOf(opts) === 'head' ? 'badge' : 'none');
46
+ if (background === 'badge') {
47
+ const r = w * 0.24;
48
+ return [
49
+ rect(left, top, w, h, { fill: PANEL }, r),
50
+ g(nodes, { clip: { shapes: [rect(left, top, w, h, {}, r)] } }),
51
+ rect(left + 1.5, top + 1.5, w - 3, h - 3, { fill: 'none', stroke: ringColour(look), sw: 3 }, r - 1.5),
52
+ ];
53
+ }
54
+ if (background === 'none') {
55
+ return [...nodes];
56
+ }
57
+ // A caller-supplied colour is normalised, so it can never become markup.
58
+ const fill = normaliseHex(background);
59
+ return fill ? [rect(left, top, w, h, { fill }), ...nodes] : [...nodes];
60
+ }
@@ -0,0 +1,81 @@
1
+ /** The head, ears, cheeks and the small face and ear extras. */
2
+ import { clamp } from '../geom';
3
+ import { ellipse, g, line, path, sc, tr, type SceneNode } from '../scene';
4
+ import { FIXED } from './colours';
5
+ import { feat, HEAD_PATH, LINE, SKULL_R, type Ctx } from './context';
6
+
7
+ const EAR = 'M-2 -10C6 -12 11 -6 10 1C9 8 5 11 -1 10Z';
8
+ const EAR_FOLD = 'M1.5 -5C5.5 -5 6.5 0 5 3.5';
9
+
10
+ /** Ear extras: studs or hoops, in a colour. */
11
+ export interface EarExtra {
12
+ readonly kind: 'studs' | 'hoops';
13
+ readonly colour: string;
14
+ }
15
+
16
+ /** Ears behind the head (`behind`) or in front of it (`front`), hidden when turned away. */
17
+ export function ears(ctx: Ctx, which: 'behind' | 'front', extra?: EarExtra): SceneNode[] {
18
+ const out: SceneNode[] = [];
19
+ for (const side of [-1, 1] as const) {
20
+ const t = (Math.PI / 2) * side + ctx.phi;
21
+ const cos = Math.cos(t);
22
+ if (cos < -0.15) {
23
+ continue;
24
+ }
25
+ const isInFront = cos > 0.3;
26
+ if ((which === 'front') !== isInFront) {
27
+ continue;
28
+ }
29
+ const earX = clamp(SKULL_R * Math.sin(t), -SKULL_R + 2, SKULL_R - 2);
30
+ const w = isInFront ? 0.75 + 0.25 * cos : 1;
31
+ const flip = isInFront ? -Math.sign(earX || side) : side;
32
+ const parts: SceneNode[] = [
33
+ path(EAR, { fill: ctx.c.skin, stroke: ctx.c.skinInk, sw: LINE }),
34
+ path(EAR_FOLD, line(ctx.c.skinShade, 2.2)),
35
+ ];
36
+ if (extra?.kind === 'studs') {
37
+ parts.push(ellipse(3, 7, 2.2, 2.2, { fill: extra.colour, stroke: '#3a2a2a', sw: 0.8 }));
38
+ }
39
+ if (extra?.kind === 'hoops') {
40
+ parts.push(ellipse(3, 12, 3.6, 4.4, line(extra.colour, 1.8)));
41
+ }
42
+ out.push(g(parts, { tf: [tr(earX, 6 + ctx.dy * 0.3), sc(flip * w, 1)] }));
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** The head: a shade base, the lit face, and the tonal outline. */
48
+ export function head(ctx: Ctx): SceneNode[] {
49
+ const d = HEAD_PATH.d();
50
+ const lx = -2.5 + Math.sin(ctx.phi) * 3;
51
+ return [
52
+ path(d, { fill: ctx.c.skinShade }),
53
+ path(d, { fill: ctx.c.skin }, [tr(lx, -3), sc(0.95)]),
54
+ path(d, { fill: 'none', stroke: ctx.c.skinInk, sw: LINE }),
55
+ ];
56
+ }
57
+
58
+ const FRECKLES: readonly (readonly [number, number])[] = [[-3.5, -1.5], [0, 0.8], [3.5, -1.2], [1.5, -3.5]];
59
+
60
+ /** Cheek blush (from the expression, plus `rosy`) and freckles. */
61
+ export function cheeks(ctx: Ctx, hasFreckles: boolean, isRosy: boolean): SceneNode[] {
62
+ const out: SceneNode[] = [];
63
+ const blush = Math.min(1, ctx.face.cheeks + (isRosy ? 0.45 : 0));
64
+ for (const side of [-1, 1] as const) {
65
+ const f = feat(ctx, 25 * side);
66
+ if (!f.vis) {
67
+ continue;
68
+ }
69
+ const parts: SceneNode[] = [];
70
+ if (blush > 0.02) {
71
+ parts.push(ellipse(0, 0, 6.5, 3.8, { fill: FIXED.blush, opacity: Math.round(blush * 5) / 10 }));
72
+ }
73
+ if (hasFreckles) {
74
+ parts.push(...FRECKLES.map(([fx, fy]) => ellipse(fx, fy, 1.05, 1.05, { fill: ctx.c.skinInk, opacity: 0.45 })));
75
+ }
76
+ if (parts.length > 0) {
77
+ out.push(g(parts, { tf: [tr(f.x, 18 + ctx.dy), sc(f.sx, 1)] }));
78
+ }
79
+ }
80
+ return out;
81
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The nose, the mouth morph (one cubic structure for every shape, so any two
3
+ * expressions tween, §3.5) and the small FX glyphs.
4
+ */
5
+ import { n } from '../geom';
6
+ import { ellipse, g, line, path, rect, sc, tr, type SceneNode } from '../scene';
7
+ import { FIXED, SOFT_INK } from './colours';
8
+ import { feat, LINE, type Ctx } from './context';
9
+
10
+ /** The nose: a soft shade with a highlight. */
11
+ export function nose(ctx: Ctx): SceneNode[] {
12
+ const f = feat(ctx, 0);
13
+ return [g([
14
+ ellipse(0, 0, 4.4, 3, { fill: ctx.c.skinShade }),
15
+ ellipse(-1.5, -1, 1.2, 1.2, { fill: ctx.c.skinLight, opacity: 0.9 }),
16
+ ], { tf: [tr(f.x, 16 + ctx.dy), sc(Math.max(0.6, f.sx), 1)] })];
17
+ }
18
+
19
+ /** The mouth: a line when closed, a filled shape with teeth and tongue when open. Talking adds opening. */
20
+ export function mouth(ctx: Ctx): SceneNode[] {
21
+ const base = ctx.face.mouth;
22
+ const m = { ...base, open: base.open + ctx.talk * 9 * (1 - base.round * 0.4) };
23
+ const f = feat(ctx, m.dx);
24
+ const baseY = 27;
25
+ const lx = -m.w / 2;
26
+ const ly = baseY - m.sL;
27
+ const rx = m.w / 2;
28
+ const ry = baseY - m.sR;
29
+ const s = (m.sL + m.sR) / 2;
30
+ const corner = baseY - s;
31
+ const ctrl = (target: number): number => (target - 0.25 * corner) / 0.75;
32
+ const place = [tr(f.x, ctx.dy * 0.8), sc(Math.max(0.35, f.sx), 1)];
33
+ if (m.open < 1) {
34
+ const c = ctrl(baseY + s * 0.55);
35
+ return [g([path(`M${n(lx)} ${n(ly)}C${n(-m.w * 0.18)} ${n(c)} ${n(m.w * 0.18)} ${n(c)} ${n(rx)} ${n(ry)}`, line(SOFT_INK, LINE * 0.95))], { tf: place })];
36
+ }
37
+ const up = baseY + s * 0.3 - m.round * m.open * 0.45;
38
+ const lo = baseY + s * 0.55 + m.open * (1 - m.round * 0.45);
39
+ const kx = (m.w / 2) * (0.55 + m.round * 0.8);
40
+ const d = `M${n(lx)} ${n(ly)}C${n(-kx)} ${n(ctrl(up))} ${n(kx)} ${n(ctrl(up))} ${n(rx)} ${n(ry)}C${n(kx)} ${n(ctrl(lo))} ${n(-kx)} ${n(ctrl(lo))} ${n(lx)} ${n(ly)}Z`;
41
+ const inside: SceneNode[] = [];
42
+ if (m.tongue > 0.05) {
43
+ inside.push(ellipse(0, lo + 1, m.w * 0.32, m.open * 0.42 * m.tongue + 1, { fill: FIXED.tongue }));
44
+ }
45
+ if (m.teeth > 0.05) {
46
+ inside.push(rect(-m.w / 2, up - 6, m.w, 6 + m.open * 0.22 * m.teeth, { fill: FIXED.white }));
47
+ }
48
+ return [g([
49
+ path(d, { fill: FIXED.mouth }),
50
+ g(inside, { clip: { shapes: [path(d, {})] } }),
51
+ path(d, line(SOFT_INK, LINE * 0.9)),
52
+ ], { tf: place })];
53
+ }
54
+
55
+ /** FX glyphs beside the head (sparkle, tear, sweat, anger, question, zzz). */
56
+ export function extras(ctx: Ctx): SceneNode[] {
57
+ const out: SceneNode[] = [];
58
+ const f = feat(ctx, 0);
59
+ for (const e of ctx.face.extras) {
60
+ if (e === 'sparkle') {
61
+ out.push(path('M0 -9Q1.4 -1.4 9 0Q1.4 1.4 0 9Q-1.4 1.4 -9 0Q-1.4 -1.4 0 -9Z', { fill: '#ffd23f', stroke: '#0d0f14', sw: 1.6 }, [tr(48 + f.x * 0.2, -44)]));
62
+ }
63
+ if (e === 'tear') {
64
+ const t = feat(ctx, -16);
65
+ if (t.vis) {
66
+ out.push(path('M0 -5Q4.5 2 0 5Q-4.5 2 0 -5Z', { fill: '#7cc7ff', stroke: '#1f3b5c', sw: 1.4 }, [tr(t.x - 5, 16 + ctx.dy)]));
67
+ }
68
+ }
69
+ if (e === 'sweat') {
70
+ out.push(path('M0 -6Q5 2 0 6Q-5 2 0 -6Z', { fill: '#9fdcff', stroke: '#1f3b5c', sw: 1.4 }, [tr(40 + f.x * 0.2, -26)]));
71
+ }
72
+ if (e === 'anger') {
73
+ out.push(g([
74
+ path('M-6 -2Q-2 -2 -2 -6', line('#ff4d4d', 2.6)), path('M2 -6Q2 -2 6 -2', line('#ff4d4d', 2.6)),
75
+ path('M6 2Q2 2 2 6', line('#ff4d4d', 2.6)), path('M-2 6Q-2 2 -6 2', line('#ff4d4d', 2.6)),
76
+ ], { tf: [tr(30 + f.x * 0.3, -36)] }));
77
+ }
78
+ if (e === 'question') {
79
+ out.push({ k: 'text', x: 50 + f.x * 0.2, y: -40, text: '?', size: 26, font: 'pixel', fill: '#ffd23f', stroke: '#0d0f14', sw: 1.2 });
80
+ }
81
+ if (e === 'zzz') {
82
+ out.push({ k: 'text', x: 44 + f.x * 0.2, y: -42, text: 'z z', size: 20, font: 'pixel', fill: '#8ab4ff', stroke: '#0d0f14', sw: 1.2 });
83
+ }
84
+ }
85
+ return out;
86
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Status props (§3.11, §14.3): runtime-only parts the world (or a preview)
3
+ * puts on a player. `goggles` lives in eyewear.ts; the head props here sit on
4
+ * the head, the hand props at the `prop` anchor in front of the body.
5
+ */
6
+ import { arc, blob, Path, roundedRect } from '../geom';
7
+ import { ellipse, g, line, path, rect, rot, sc, tr, type SceneNode } from '../scene';
8
+ import { LINE, SKULL_R, turnWarp, type Ctx } from './context';
9
+ import type { StatusProp } from './types';
10
+
11
+ const DARK = '#0d0f14';
12
+ /** Hand props are drawn at this scale so they read at small sizes. */
13
+ const PROP_SCALE = 1.45;
14
+
15
+ /** A headset: a band over the head, ear cups and a mic (head space). */
16
+ export function headset(ctx: Ctx, isBack: boolean): SceneNode[] {
17
+ const w = turnWarp(isBack ? 0 : ctx.phi);
18
+ const band = blob(arc(0, -4, 51, 51, 196, 344, 8), false);
19
+ const nodes: SceneNode[] = [path(band.d(w), line(DARK, 8)), path(band.d(w), line('#4a5470', 5))];
20
+ const phi = isBack ? 0 : ctx.phi;
21
+ for (const side of [-1, 1] as const) {
22
+ // The cups sit over the ears, so they follow the ears through a turn.
23
+ const t = (Math.PI / 2) * side + phi;
24
+ if (Math.cos(t) < -0.15) {
25
+ continue;
26
+ }
27
+ const cupX = Math.max(-SKULL_R - 1, Math.min(SKULL_R + 1, SKULL_R * Math.sin(t)));
28
+ const wide = 6 + 3 * Math.max(0, Math.cos(t));
29
+ nodes.push(path(roundedRect(cupX - wide, -6, wide * 2, 20, 5).d(), { fill: '#2a3142', stroke: DARK, sw: LINE }));
30
+ }
31
+ if (!isBack && Math.abs(phi) < 0.7) {
32
+ nodes.push(path(new Path().m(-46, 12).q(-40, 30, -18, 30).d(w), line('#4a5470', 3)), ellipse(-17, 30, 3, 3, { fill: '#ff5f3b', stroke: DARK, sw: 1.2 }));
33
+ }
34
+ return nodes;
35
+ }
36
+
37
+ /** A small crown on top of the head (head space; `top` is the hair or hat top). */
38
+ export function crown(top: number): SceneNode[] {
39
+ const d = 'M-16 0L-18 -16L-8 -8L0 -20L8 -8L18 -16L16 0Z';
40
+ return [g([
41
+ path(d, { fill: '#ffd23f', stroke: '#8a5a00', sw: LINE }),
42
+ ellipse(0, -5, 2.4, 2.4, { fill: '#ff5f3b' }),
43
+ ], { tf: [tr(0, -50 - top + 4)] })];
44
+ }
45
+
46
+ const card = (colour: string): SceneNode =>
47
+ rect(-6, -9, 12, 17, { fill: colour, stroke: DARK, sw: 1.6 }, 2.5);
48
+
49
+ /** Nub hands holding something at the prop anchor (body space). */
50
+ function nubs(body: string, ink: string, spread: number, atY: number): SceneNode[] {
51
+ return [-1, 1].map((s) => ellipse(spread * s, atY, 7, 6, { fill: body, stroke: ink, sw: LINE }));
52
+ }
53
+
54
+ /** A hand prop drawn at the `prop` anchor (x, y) in front of the body. */
55
+ export function handProp(prop: StatusProp, atX: number, atY: number, body: string, bodyInk: string): SceneNode[] {
56
+ switch (prop) {
57
+ case 'cards':
58
+ return [g([
59
+ g([card('#ff5f3b')], { tf: [tr(-8, 0), rot(-18)] }),
60
+ g([card('#ffd23f')], { tf: [tr(0, -3)] }),
61
+ g([card('#3d7bff')], { tf: [tr(8, 0), rot(18)] }),
62
+ ...nubs(body, bodyInk, 13, 8),
63
+ ], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
64
+ case 'controller':
65
+ return [g([
66
+ path(new Path().m(-20, -4).c(-20, -12, 20, -12, 20, -4).c(24, 10, 14, 14, 8, 6).l(-8, 6).c(-14, 14, -24, 10, -20, -4).z().d(), { fill: '#2a3142', stroke: DARK, sw: LINE }),
67
+ ellipse(-9, -2, 2.6, 2.6, { fill: '#8ab4ff' }), ellipse(10, -4, 2, 2, { fill: '#ff5f3b' }), ellipse(13, 0, 2, 2, { fill: '#4ade80' }),
68
+ ...nubs(body, bodyInk, 20, 4),
69
+ ], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
70
+ case 'book':
71
+ return [g([
72
+ path('M0 -2C-8 -8 -18 -8 -24 -5L-24 12C-18 9 -8 9 0 14C8 9 18 9 24 12L24 -5C18 -8 8 -8 0 -2Z', { fill: '#f4f1ea', stroke: DARK, sw: LINE }),
73
+ path('M0 -2L0 14', line('#c9c2b4', 1.6)),
74
+ path('M-19 0L-5 3M-19 5L-5 8M5 3L19 0M5 8L19 5', line('#9a9384', 1.2)),
75
+ path('M-24 12C-18 9 -8 9 0 14C8 9 18 9 24 12L24 15C18 12 8 12 0 17C-8 12 -18 12 -24 15Z', { fill: '#ff5f3b', stroke: DARK, sw: 1.4 }),
76
+ ...nubs(body, bodyInk, 25, 6),
77
+ ], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
78
+ case 'trophy':
79
+ return [g([
80
+ path('M-11 -14L11 -14L9 0C8 6 -8 6 -9 0Z', { fill: '#ffd23f', stroke: '#8a5a00', sw: LINE }),
81
+ path('M-11 -11C-18 -11 -18 -2 -9 -1M11 -11C18 -11 18 -2 9 -1', line('#8a5a00', 2.2)),
82
+ rect(-3, 4, 6, 6, { fill: '#e0a800' }), rect(-9, 10, 18, 5, { fill: '#6b4f12', stroke: DARK, sw: 1.2 }, 1.5),
83
+ ellipse(13, 6, 7, 6, { fill: body, stroke: bodyInk, sw: LINE }),
84
+ ], { tf: [tr(atX + 24, atY - 10), sc(PROP_SCALE)] })];
85
+ default:
86
+ return [];
87
+ }
88
+ }
89
+
90
+ /** Props worn on the head (drawn in head space). */
91
+ export const HEAD_PROPS: ReadonlySet<StatusProp> = new Set(['goggles', 'headset', 'crown']);
@@ -0,0 +1,84 @@
1
+ /** The renderer's public option and pose types (avatars.md §3.3, §3.4, §3.11). */
2
+ import type { AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
3
+ import type { Expression, FaceParams } from '../face';
4
+
5
+ /** Eight facings: `S` faces the viewer; `W`, `SW` and `NW` mirror `E`, `SE` and `NE`. */
6
+ export type Facing = 'S' | 'SE' | 'E' | 'NE' | 'N' | 'NW' | 'W' | 'SW';
7
+
8
+ /** The 4-direction aliases. */
9
+ export type View = 'front' | 'back' | 'left' | 'right';
10
+
11
+ /** Body poses on the blob; a future limbed body re-implements the same names. */
12
+ export type BodyPose = 'idle' | 'walk' | 'run' | 'sit' | 'play' | 'wave' | 'cheer' | 'jump' | 'dance' | 'away';
13
+
14
+ /** Runtime status props (never stored in the look). */
15
+ export type StatusProp = 'goggles' | 'headset' | 'cards' | 'controller' | 'trophy' | 'crown' | 'book';
16
+
17
+ /** How much of the character a frame shows. */
18
+ export type Frame = 'head' | 'bust' | 'full';
19
+
20
+ /** A point in rig units. */
21
+ export interface Point {
22
+ readonly x: number;
23
+ readonly y: number;
24
+ }
25
+
26
+ /** Where the eyes look: x right, y down, each in [−1, 1]. */
27
+ export type LookDir = Point;
28
+
29
+ /**
30
+ * Everything that moves: the face (an expression or raw parameters), gaze,
31
+ * blink, talk, the body pose and the status prop. The animator produces one
32
+ * per frame; a still render takes a partial one.
33
+ */
34
+ export interface Pose {
35
+ readonly expression?: Expression;
36
+ /** Raw face parameters (wins over `expression`). */
37
+ readonly face?: FaceParams;
38
+ readonly look?: LookDir;
39
+ /** Extra lid closure, 0 … 1 (a blink). */
40
+ readonly blink?: number;
41
+ /** Extra mouth opening, 0 … 1 (talking). */
42
+ readonly talk?: number;
43
+ readonly body?: BodyPose;
44
+ /** Walk, dance or jump cycle phase, 0 … 1. */
45
+ readonly phase?: number;
46
+ readonly status?: StatusProp | null;
47
+ /** Reaction offset of the head, rig units. */
48
+ readonly bob?: Point;
49
+ /** Reaction tilt added to the head, degrees. */
50
+ readonly tilt?: number;
51
+ /** Reaction squash added to the head. */
52
+ readonly squash?: number;
53
+ }
54
+
55
+ /** Render options shared by every output. */
56
+ export interface RenderOptions {
57
+ /** Output px of the frame's longer edge (default 48). */
58
+ readonly size?: number;
59
+ /** `head` badge (default), `bust` or `full` character. */
60
+ readonly frame?: Frame;
61
+ /** `badge` = the site's rounded panel and ring (the default for `head`); `none`; or a `#rrggbb`. */
62
+ readonly background?: 'badge' | 'none' | `#${string}`;
63
+ readonly facing?: Facing;
64
+ readonly view?: View;
65
+ readonly pose?: Pose;
66
+ /** The accessible name ("Sam's avatar"); omitted, the avatar is decorative. */
67
+ readonly title?: string;
68
+ /** A prefix for SVG ids when inlining many. */
69
+ readonly idPrefix?: string;
70
+ /** A soft light rim for dark scenes (the world; on by default in the atlas). */
71
+ readonly rim?: boolean;
72
+ /** The ground shadow (default: on for `full`). */
73
+ readonly shadow?: boolean;
74
+ /** Draws a stool under `sit` and `play` (pose sheets and previews). */
75
+ readonly stool?: boolean;
76
+ }
77
+
78
+ /** Anything a render accepts: a look, an `AV1…` code, or an object carrying a code. */
79
+ export type LookLike = AvatarLook | string | { readonly code: string } | { readonly look: AvatarLook } | null | undefined;
80
+
81
+ /** Named rig anchors (feet origin, y down), for name plates, bubbles, props and the world. */
82
+ export type Anchor =
83
+ | 'feet' | 'body' | 'neck' | 'head' | 'eyeLine' | 'mouth' | 'hatTop' | 'nameplate' | 'bubble'
84
+ | 'seat' | 'prop' | 'shadow' | 'handL' | 'handR' | 'pod';
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The display list every renderer draws: a small tree of groups and shapes
3
+ * in rig units. The composer builds it once per pose; `toSvg` turns it into
4
+ * markup, `paintCanvas` draws it on a 2D canvas, and the Pixi and three.js
5
+ * adapters draw it through a canvas. Colours in a scene are always derived
6
+ * `#rrggbb` values or fixed constants, never caller text.
7
+ */
8
+
9
+ /** One transform step, applied in order (SVG semantics). */
10
+ export type TfOp =
11
+ | { readonly k: 't'; readonly x: number; readonly y: number }
12
+ | { readonly k: 's'; readonly x: number; readonly y: number }
13
+ | { readonly k: 'r'; readonly a: number }
14
+ | { readonly k: 'skx'; readonly a: number };
15
+
16
+ /** How a shape is painted. */
17
+ export interface Paint {
18
+ readonly fill?: string;
19
+ readonly fillOpacity?: number;
20
+ readonly stroke?: string;
21
+ readonly sw?: number;
22
+ readonly cap?: 'round' | 'butt';
23
+ readonly opacity?: number;
24
+ readonly evenOdd?: boolean;
25
+ }
26
+
27
+ /** A path shape. */
28
+ export interface PathNode extends Paint {
29
+ readonly k: 'path';
30
+ readonly d: string;
31
+ readonly tf?: readonly TfOp[];
32
+ }
33
+
34
+ /** An ellipse (a circle when rx = ry). */
35
+ export interface EllipseNode extends Paint {
36
+ readonly k: 'ellipse';
37
+ readonly cx: number;
38
+ readonly cy: number;
39
+ readonly rx: number;
40
+ readonly ry: number;
41
+ readonly tf?: readonly TfOp[];
42
+ }
43
+
44
+ /** A rectangle with optional rounded corners. */
45
+ export interface RectNode extends Paint {
46
+ readonly k: 'rect';
47
+ readonly x: number;
48
+ readonly y: number;
49
+ readonly w: number;
50
+ readonly h: number;
51
+ readonly r?: number;
52
+ readonly tf?: readonly TfOp[];
53
+ }
54
+
55
+ /** A line of text (overlay labels and FX glyphs). */
56
+ export interface TextNode extends Paint {
57
+ readonly k: 'text';
58
+ readonly x: number;
59
+ readonly y: number;
60
+ readonly text: string;
61
+ readonly size: number;
62
+ readonly font: 'mono' | 'pixel';
63
+ readonly anchor?: 'start' | 'middle';
64
+ readonly bold?: boolean;
65
+ }
66
+
67
+ /** A clip region: the union of its shapes (even-odd when asked). */
68
+ export interface Clip {
69
+ readonly shapes: readonly (PathNode | EllipseNode | RectNode)[];
70
+ readonly evenOdd?: boolean;
71
+ }
72
+
73
+ /** A group: a transform, an optional clip and opacity, and children. */
74
+ export interface GroupNode {
75
+ readonly k: 'g';
76
+ readonly tf?: readonly TfOp[];
77
+ readonly clip?: Clip;
78
+ readonly opacity?: number;
79
+ /** A soft light rim around the silhouette (the world, §14.2). */
80
+ readonly rim?: boolean;
81
+ readonly children: readonly SceneNode[];
82
+ }
83
+
84
+ /** Anything in a scene. */
85
+ export type SceneNode = GroupNode | PathNode | EllipseNode | RectNode | TextNode;
86
+
87
+ /** A group helper that drops empty groups' wrapping. */
88
+ export function g(children: readonly SceneNode[], opts: Omit<GroupNode, 'k' | 'children'> = {}): GroupNode {
89
+ return { k: 'g', ...opts, children };
90
+ }
91
+
92
+ /** A path helper. */
93
+ export function path(d: string, paint: Paint, tf?: readonly TfOp[]): PathNode {
94
+ return { k: 'path', d, ...paint, ...(tf ? { tf } : {}) };
95
+ }
96
+
97
+ /** An ellipse helper. */
98
+ export function ellipse(cx: number, cy: number, rx: number, ry: number, paint: Paint, tf?: readonly TfOp[]): EllipseNode {
99
+ return { k: 'ellipse', cx, cy, rx, ry, ...paint, ...(tf ? { tf } : {}) };
100
+ }
101
+
102
+ /** A rect helper. */
103
+ export function rect(left: number, top: number, w: number, h: number, paint: Paint, r?: number): RectNode {
104
+ return { k: 'rect', x: left, y: top, w, h, ...paint, ...(r === undefined ? {} : { r }) };
105
+ }
106
+
107
+ /** A translate step. */
108
+ export const tr = (dx: number, dy: number): TfOp => ({ k: 't', x: dx, y: dy });
109
+ /** A scale step (uniform when `sy` is omitted). */
110
+ export const sc = (sx: number, sy = sx): TfOp => ({ k: 's', x: sx, y: sy });
111
+ /** A rotate step, degrees. */
112
+ export const rot = (a: number): TfOp => ({ k: 'r', a });
113
+ /** A horizontal skew step, degrees. */
114
+ export const skewX = (a: number): TfOp => ({ k: 'skx', a });
115
+
116
+ /** A round-joined stroke paint. */
117
+ export function line(colour: string, width: number, cap: 'round' | 'butt' = 'round', opacity?: number): Paint {
118
+ return { fill: 'none', stroke: colour, sw: width, cap, ...(opacity === undefined ? {} : { opacity }) };
119
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/avatar/pixi`: avatars in PixiJS. Pass your PIXI
3
+ * namespace (the hub has no pixi dependency):
4
+ *
5
+ * ```ts
6
+ * import * as PIXI from 'pixi.js';
7
+ * import { ready, animator } from '@diffenderfer-games/hub/avatar';
8
+ * import { pixiAvatar } from '@diffenderfer-games/hub/avatar/pixi';
9
+ *
10
+ * await ready();
11
+ * const face = pixiAvatar(PIXI, seat.look, { size: 64, animator: animator(seat.look, { seed: seat.userId }) });
12
+ * stage.addChild(face.view);
13
+ * app.ticker.add(() => face.update(performance.now()));
14
+ * ```
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+ export { pixiAvatar, avatarTexture, type PixiAvatar, type PixiAvatarOptions } from './avatar/adapters/pixi-avatar';
19
+ export { createCrowd, type AvatarCrowd, type CrowdMember, type CrowdOptions } from './avatar/adapters/pixi-crowd';
20
+ export type { PixiContainer, PixiNs, PixiSprite, PixiTexture } from './avatar/adapters/pixi-types';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/avatar/svg`: avatars as SVG markup with no DOM,
3
+ * for servers, workers and server-side rendering. The same renderer as
4
+ * `@diffenderfer-games/hub/avatar` (`render.svg`), without the DOM, canvas
5
+ * and animation outputs.
6
+ *
7
+ * ```ts
8
+ * import { ready, renderSvg } from '@diffenderfer-games/hub/avatar/svg';
9
+ *
10
+ * await ready();
11
+ * const markup = renderSvg(player.look, { size: 64, pose: { expression: 'happy' } });
12
+ * ```
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ export { decodeLook, defaultLook, encodeLook, type AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
17
+ export { ready } from './avatar/art-store';
18
+ export { EXPRESSION_NAMES, isExpression, type Expression } from './avatar/face';
19
+ export { renderSvg } from './avatar/outputs/svg';
20
+ export { describe } from './avatar/describe';
21
+ export type { Facing, Frame, LookLike, Pose, RenderOptions } from './avatar/render/types';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/avatar/three`: avatars in three.js, as a
3
+ * `CanvasTexture` or a billboard `Sprite`. Pass your THREE namespace (the hub
4
+ * has no three dependency):
5
+ *
6
+ * ```ts
7
+ * import * as THREE from 'three';
8
+ * import { ready } from '@diffenderfer-games/hub/avatar';
9
+ * import { avatarSprite } from '@diffenderfer-games/hub/avatar/three';
10
+ *
11
+ * await ready();
12
+ * const { sprite } = avatarSprite(THREE, me.look, { frame: 'head', size: 128, height: 0.4 });
13
+ * helmet.add(sprite);
14
+ * ```
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+ export { avatarCanvasTexture, avatarSprite, type ThreeAvatarOptions, type ThreeAvatarTexture, type ThreeNs, type ThreeTextureNs } from './avatar/adapters/three';