@diffenderfer-games/hub 2.0.0-next.8 → 2.0.0-next.9

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 (69) hide show
  1. package/CHANGELOG.md +32 -13
  2. package/dist/auto.d.ts +2 -2
  3. package/dist/avatar-pixi.d.ts +19 -1
  4. package/dist/avatar-pixi.d.ts.map +1 -1
  5. package/dist/avatar-pixi.js +37 -18
  6. package/dist/avatar-pixi.js.map +1 -1
  7. package/dist/avatar-svg.d.ts +1 -1
  8. package/dist/avatar-svg.js +1 -1
  9. package/dist/avatar-three.d.ts +1 -1
  10. package/dist/avatar.d.ts +3 -3
  11. package/dist/avatar.js +20 -7
  12. package/dist/avatar.js.map +1 -1
  13. package/dist/doctor/cli.mjs +58 -4
  14. package/dist/doctor/cli.mjs.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/shared/animator.d.ts +1 -1
  17. package/dist/shared/art.js +1 -1
  18. package/dist/shared/atlas.d.ts +5 -1
  19. package/dist/shared/atlas.d.ts.map +1 -1
  20. package/dist/shared/avatar.d.ts +10 -4
  21. package/dist/shared/avatar.d.ts.map +1 -1
  22. package/dist/shared/bridge.js +2 -2
  23. package/dist/shared/bridge.js.map +1 -1
  24. package/dist/shared/client-version.gen.js +1 -1
  25. package/dist/shared/daily.js +1 -1
  26. package/dist/shared/describe.d.ts +1 -1
  27. package/dist/shared/describe.d.ts.map +1 -1
  28. package/dist/shared/describe.js +1 -1
  29. package/dist/shared/index.d.ts +2 -2
  30. package/dist/shared/live-canvas.js +1 -1
  31. package/dist/shared/overlays.js +1 -1
  32. package/dist/shared/overlays.js.map +1 -1
  33. package/dist/shared/pose-key.js +2 -2
  34. package/dist/shared/pose-key.js.map +1 -1
  35. package/dist/shared/social.d.ts +19 -4
  36. package/dist/shared/social.d.ts.map +1 -1
  37. package/dist/shared/src.js +6 -0
  38. package/dist/shared/src.js.map +1 -1
  39. package/dist/shared/svg-markup.js +430 -21
  40. package/dist/shared/svg-markup.js.map +1 -1
  41. package/dist/shared/svg.js +2 -2
  42. package/dist/shared/svg.js.map +1 -1
  43. package/dist/shared/types.d.ts +9 -1
  44. package/dist/shared/types.d.ts.map +1 -1
  45. package/dist/social.d.ts +2 -2
  46. package/package.json +3 -2
  47. package/src/avatar/adapters/pixi-crowd.ts +45 -18
  48. package/src/avatar/atlas.ts +16 -6
  49. package/src/avatar/layout.ts +1 -1
  50. package/src/avatar/outputs/canvas.ts +2 -2
  51. package/src/avatar/outputs/svg.ts +2 -2
  52. package/src/avatar/render/body.ts +2 -2
  53. package/src/avatar/render/compose.ts +25 -8
  54. package/src/avatar/render/frame.ts +15 -2
  55. package/src/avatar/render/types.ts +9 -0
  56. package/src/avatar/render/walker-parts.ts +121 -0
  57. package/src/avatar/render/walker.ts +199 -0
  58. package/src/avatar.ts +2 -1
  59. package/src/core/rest/routes.gen.ts +1 -0
  60. package/src/core/turns/your-turn-buzz.ts +69 -69
  61. package/src/core/version/client-version.gen.ts +1 -1
  62. package/src/doctor/checks.ts +2 -2
  63. package/src/doctor/cli.ts +5 -2
  64. package/src/doctor/world-check.ts +61 -0
  65. package/src/index.ts +68 -68
  66. package/src/realtime/runtime-api.ts +5 -2
  67. package/src/realtime/types.ts +1 -0
  68. package/src/realtime/world-types.ts +20 -0
  69. package/src/turns-rules.ts +12 -12
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { toLook } from '../look-like';
8
8
  import { composeAvatar } from '../render/compose';
9
- import { FRAMES, frameOf, framed, pixelSize } from '../render/frame';
9
+ import { frameBox, framed, pixelSize } from '../render/frame';
10
10
  import type { LookLike, RenderOptions } from '../render/types';
11
11
  import type { SceneNode } from '../scene';
12
12
  import { paintNodes, type Ctx2D, type LayerFactory } from './canvas-paint';
@@ -36,7 +36,7 @@ export interface CanvasPlacement extends RenderOptions {
36
36
 
37
37
  /** Paints scene nodes with rig space mapped to the frame box at (x, y). */
38
38
  export function paintFramed(ctx: Ctx2D, nodes: readonly SceneNode[], opts: CanvasPlacement): void {
39
- const [fx, fy] = FRAMES[frameOf(opts)];
39
+ const [fx, fy] = frameBox(opts);
40
40
  const { scale } = pixelSize(opts);
41
41
  ctx.save();
42
42
  ctx.translate(opts.x, opts.y);
@@ -8,7 +8,7 @@ import type { AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
8
8
  import { n } from '../geom';
9
9
  import { toLook } from '../look-like';
10
10
  import { composeAvatar, type Composed } from '../render/compose';
11
- import { FRAMES, frameOf, framed, pixelSize } from '../render/frame';
11
+ import { frameBox, framed, pixelSize } from '../render/frame';
12
12
  import type { LookLike, RenderOptions } from '../render/types';
13
13
  import { escapeXml, sceneToSvg } from './svg-markup';
14
14
 
@@ -25,7 +25,7 @@ export function nextPrefix(opts: RenderOptions): string {
25
25
 
26
26
  /** The `<svg>` for an already composed avatar. */
27
27
  export function svgFromComposed(composed: Composed, look: AvatarLook | null, opts: RenderOptions): string {
28
- const [left, top, w, h] = FRAMES[frameOf(opts)];
28
+ const [left, top, w, h] = frameBox(opts);
29
29
  const { width, height } = pixelSize(opts);
30
30
  const { body, defs } = sceneToSvg(framed(composed.nodes, look, opts), nextPrefix(opts));
31
31
  const a11y = opts.title ? ` role="img" aria-label="${escapeXml(opts.title)}"` : ' aria-hidden="true"';
@@ -109,8 +109,8 @@ export function bodyLayout(c: Colours, pose: BodyPose, phi: number, phase: numbe
109
109
  }
110
110
 
111
111
  /** The soft ground shadow at the feet (shrinks while lifted). */
112
- export function groundShadow(lift: number): SceneNode {
112
+ export function groundShadow(lift: number, width = 36): SceneNode {
113
113
  const k = Math.max(0.5, 1 - lift / 40);
114
- return ellipse(0, 0, 36 * k, 8 * k, { fill: '#000000', opacity: 0.3 });
114
+ return ellipse(0, 0, width * k, (width / 4.5) * k, { fill: '#000000', opacity: 0.3 });
115
115
  }
116
116
 
@@ -10,15 +10,17 @@ import { currentArt } from '../art-store';
10
10
  import { EXPRESSIONS } from '../face';
11
11
  import { n, type Warp } from '../geom';
12
12
  import { g, path, rect, rot, sc, tr, type Clip, type SceneNode } from '../scene';
13
- import { bodyLayout, groundShadow } from './body';
13
+ import { bodyLayout, groundShadow, type BodyLayout } from './body';
14
14
  import { accessoryColour, lookColours, tones, type Colours } from './colours';
15
15
  import { HEAD_PATH, LINE, paintShapes, SKULL_R, turnWarp, type Ctx } from './context';
16
16
  import { FRAME_BLACK, glasses, goggles, gogglesStrapBack, type LensGlass } from './eyewear';
17
17
  import { brow, eye } from './eyes';
18
18
  import { cheeks, ears, head, type EarExtra } from './head';
19
19
  import { extras, mouth, nose } from './mouth';
20
+ import { isWalker } from './frame';
20
21
  import { crown, handProp, headset } from './props';
21
- import type { Anchor, Facing, Point, Pose, RenderOptions } from './types';
22
+ import type { Anchor, BodyPose, Facing, Point, Pose, RenderOptions } from './types';
23
+ import { walkerLayout, walkerSilhouette } from './walker';
22
24
 
23
25
  /** The lens tint drawn with a mirror finish. */
24
26
  const MIRROR_TINT = 'p:lens-mirror';
@@ -249,6 +251,16 @@ function anchorsFor(headX: number, headY: number, top: number, prop: Point, mirr
249
251
  };
250
252
  }
251
253
 
254
+ /** The body for a figure: its layout, where the head centre goes, and the head's scale. */
255
+ function figureLayout(c: Colours, opts: RenderOptions, pose: BodyPose, phi: number, angle: number, phase: number): { readonly b: BodyLayout; readonly headY: number; readonly headScale: number } {
256
+ if (isWalker(opts)) {
257
+ const walker = walkerLayout(c, pose, angle, phase);
258
+ return { b: walker, headY: walker.headY, headScale: walker.headScale };
259
+ }
260
+ const b = bodyLayout(c, pose, phi, phase, opts.stool === true);
261
+ return { b, headY: HEAD_Y + b.headDy, headScale: 1 };
262
+ }
263
+
252
264
  /** Composes a look (or `null` for the silhouette) with a pose. */
253
265
  export function composeAvatar(look: AvatarLook | null, opts: RenderOptions = {}): Composed {
254
266
  const art = currentArt();
@@ -257,6 +269,10 @@ export function composeAvatar(look: AvatarLook | null, opts: RenderOptions = {})
257
269
  const facing: Drawn = MIRROR[facingIn] ?? (facingIn === 'S' || facingIn === 'SE' || facingIn === 'E' || facingIn === 'NE' ? facingIn : 'N');
258
270
  const hasShadow = opts.shadow ?? opts.frame === 'full';
259
271
  if (!look || !art) {
272
+ if (isWalker(opts)) {
273
+ const shape = walkerSilhouette();
274
+ return { nodes: [...(hasShadow ? [groundShadow(0, 18)] : []), ...shape.nodes], anchors: anchorsFor(0, shape.headY, shape.top, { x: 0, y: -40 }, 1) };
275
+ }
260
276
  return { nodes: silhouette(hasShadow), anchors: anchorsFor(0, HEAD_Y, HEAD_Y - 60, { x: 0, y: -30 }, 1) };
261
277
  }
262
278
  const pose = opts.pose ?? {};
@@ -272,19 +288,20 @@ export function composeAvatar(look: AvatarLook | null, opts: RenderOptions = {})
272
288
  c: parts.c, face, gaze, phi: (angle + gaze.x * 11) * DEG, dy: gaze.y * 3,
273
289
  blink: pose.blink ?? 0, talk: pose.talk ?? 0,
274
290
  };
275
- const b = bodyLayout(parts.c, bodyPose, ctx.phi, pose.phase ?? 0.25, opts.stool === true);
291
+ const { b, headY: bodyHeadY, headScale } = figureLayout(parts.c, opts, bodyPose, ctx.phi, angle, pose.phase ?? 0.25);
276
292
  const headX = b.headDx + (pose.bob?.x ?? 0);
277
- const headY = HEAD_Y + b.headDy + (pose.bob?.y ?? 0);
293
+ const headY = bodyHeadY + (pose.bob?.y ?? 0);
278
294
  const tilt = face.headTilt + b.tilt + (pose.tilt ?? 0);
279
295
  const sq = face.squash + (pose.squash ?? 0);
280
296
  const drawn = isBack ? backHead(ctx, parts, isPlaying, status, angle) : frontHead(ctx, parts, isPlaying, status, angle, gaze.x);
281
297
  const headNodes = drawn.head;
282
- const top = headY - 50 - Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0);
298
+ const top = headY - (50 + Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0)) * headScale;
283
299
  if (status === 'crown') {
284
300
  headNodes.push(...crown(Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0)));
285
301
  }
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)];
302
+ const props = status && !isBack ? handProp(status, b.prop.x, b.prop.y, parts.c.body, parts.c.bodyInk) : [];
303
+ const handProps = headScale === 1 || props.length === 0 ? props : [g(props, { tf: [tr(b.prop.x, b.prop.y), sc(headScale * 1.1), tr(-b.prop.x, -b.prop.y)] })];
304
+ const headTf = [tr(headX, headY), rot(tilt), sc((1 + sq) * headScale, (1 - sq) * headScale)];
288
305
  const character: SceneNode[] = [
289
306
  ...b.behind,
290
307
  ...(drawn.behindBody.length > 0 ? [g(drawn.behindBody, { tf: headTf })] : []),
@@ -294,7 +311,7 @@ export function composeAvatar(look: AvatarLook | null, opts: RenderOptions = {})
294
311
  ];
295
312
  const lifted = b.lift > 0 ? [g(character, { tf: [tr(0, -b.lift)] })] : character;
296
313
  const rimmed = opts.rim ? [g(lifted, { rim: true })] : lifted;
297
- const all: SceneNode[] = [...(hasShadow ? [groundShadow(b.lift)] : []), ...rimmed];
314
+ const all: SceneNode[] = [...(hasShadow ? [groundShadow(b.lift, headScale === 1 ? 36 : 18)] : []), ...rimmed];
298
315
  const mirror = isMirrored ? -1 : 1;
299
316
  return {
300
317
  nodes: isMirrored ? [g(all, { tf: [sc(-1, 1)] })] : all,
@@ -18,6 +18,19 @@ export const FRAMES: Readonly<Record<Frame, Box>> = {
18
18
  full: [-86, -196, 172, 206],
19
19
  };
20
20
 
21
+ /** The walker's full frame: a smaller head, and room for feet hanging below a seat. */
22
+ export const WALKER_FULL: Box = [-60, -176, 120, 214];
23
+
24
+ /** True when options draw the walker figure (full frames only). */
25
+ export function isWalker(opts: RenderOptions): boolean {
26
+ return opts.figure === 'walker' && frameOf(opts) === 'full';
27
+ }
28
+
29
+ /** The box options draw, in rig units. */
30
+ export function frameBox(opts: RenderOptions): Box {
31
+ return isWalker(opts) ? WALKER_FULL : FRAMES[frameOf(opts)];
32
+ }
33
+
21
34
  /** The site panel colour behind badges. */
22
35
  export const PANEL = '#11141c';
23
36
 
@@ -28,7 +41,7 @@ export function frameOf(opts: RenderOptions): Frame {
28
41
 
29
42
  /** Output pixel size for a frame (the longer edge is `size`). */
30
43
  export function pixelSize(opts: RenderOptions): { readonly width: number; readonly height: number; readonly scale: number } {
31
- const [, , w, h] = FRAMES[frameOf(opts)];
44
+ const [, , w, h] = frameBox(opts);
32
45
  const size = opts.size ?? 48;
33
46
  const scale = size / Math.max(w, h);
34
47
  return { width: w * scale, height: h * scale, scale };
@@ -41,7 +54,7 @@ export function ringColour(look: AvatarLook | null): string {
41
54
 
42
55
  /** Wraps the avatar's nodes in the frame's background (a badge clips to the rounded panel). */
43
56
  export function framed(nodes: readonly SceneNode[], look: AvatarLook | null, opts: RenderOptions): SceneNode[] {
44
- const [left, top, w, h] = FRAMES[frameOf(opts)];
57
+ const [left, top, w, h] = frameBox(opts);
45
58
  const background = opts.background ?? (frameOf(opts) === 'head' ? 'badge' : 'none');
46
59
  if (background === 'badge') {
47
60
  const r = w * 0.24;
@@ -17,6 +17,13 @@ export type StatusProp = 'goggles' | 'headset' | 'cards' | 'controller' | 'troph
17
17
  /** How much of the character a frame shows. */
18
18
  export type Frame = 'head' | 'bust' | 'full';
19
19
 
20
+ /**
21
+ * The full-body figure: `blob` (the default, a big head on a gumdrop) or
22
+ * `walker` (a small full-body character with legs and arms, for worlds and
23
+ * crowds). Head and bust frames always draw the blob.
24
+ */
25
+ export type Figure = 'blob' | 'walker';
26
+
20
27
  /** A point in rig units. */
21
28
  export interface Point {
22
29
  readonly x: number;
@@ -73,6 +80,8 @@ export interface RenderOptions {
73
80
  readonly shadow?: boolean;
74
81
  /** Draws a stool under `sit` and `play` (pose sheets and previews). */
75
82
  readonly stool?: boolean;
83
+ /** The full frame's figure (default `blob`). */
84
+ readonly figure?: Figure;
76
85
  }
77
86
 
78
87
  /** Anything a render accepts: a look, an `AV1…` code, or an object carrying a code. */
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The walker's drawing parts (`figure: 'walker'`): limbs as outlined round
3
+ * strokes, the shirt, trousers and shoes. Colours come from the look: the
4
+ * shirt is the body colour, the trousers and shoes are picked from the body
5
+ * colour so they always stand apart from the shirt.
6
+ */
7
+ import { lightness, outline, shade } from '../colour';
8
+ import { Path } from '../geom';
9
+ import { ellipse, g, path, sc, tr, type SceneNode } from '../scene';
10
+ import type { Colours } from './colours';
11
+ import { LINE } from './context';
12
+
13
+ /** A point in rig units. */
14
+ export type Vec = readonly [number, number];
15
+
16
+ /** The walker's clothes, derived from a look's colours. */
17
+ export interface Outfit {
18
+ readonly trousers: string;
19
+ readonly trousersInk: string;
20
+ readonly shoes: string;
21
+ readonly shoesInk: string;
22
+ }
23
+
24
+ const TROUSERS = ['#34466e', '#2e323f', '#6a5642', '#4a3a60', '#3c5a4c', '#5a3a3a'] as const;
25
+ const SHOES = ['#2c2430', '#f1ece2', '#5a3a2a', '#2c2430'] as const;
26
+
27
+ /** A small stable hash of a colour string. */
28
+ function hashOf(text: string): number {
29
+ let hash = 7;
30
+ for (let i = 0; i < text.length; i++) {
31
+ hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
32
+ }
33
+ return hash;
34
+ }
35
+
36
+ /** Trousers and shoes for a shirt colour: a trouser tone at least a little lighter or darker than the shirt. */
37
+ export function outfitFor(c: Colours): Outfit {
38
+ const hash = hashOf(c.body);
39
+ const shirt = lightness(c.body);
40
+ let trousers: string = TROUSERS[hash % TROUSERS.length] ?? TROUSERS[0];
41
+ for (let i = 1; i < TROUSERS.length && Math.abs(lightness(trousers) - shirt) < 0.12; i++) {
42
+ trousers = TROUSERS[(hash + i) % TROUSERS.length] ?? trousers;
43
+ }
44
+ const shoes: string = SHOES[(hash >>> 4) % SHOES.length] ?? SHOES[0];
45
+ return { trousers, trousersInk: outline(trousers), shoes, shoesInk: outline(shoes) };
46
+ }
47
+
48
+ /** A polyline as a path `d`. */
49
+ function polyline(points: readonly Vec[]): string {
50
+ const line = new Path();
51
+ points.forEach(([px, py], i) => (i === 0 ? line.m(px, py) : line.l(px, py)));
52
+ return line.d();
53
+ }
54
+
55
+ /** A limb: a round stroke with a tonal outline under it. */
56
+ export function limb(points: readonly Vec[], width: number, fill: string, ink: string): SceneNode[] {
57
+ const d = polyline(points);
58
+ return [
59
+ path(d, { fill: 'none', stroke: ink, sw: width + LINE * 1.3, cap: 'round' }),
60
+ path(d, { fill: 'none', stroke: fill, sw: width, cap: 'round' }),
61
+ ];
62
+ }
63
+
64
+ /** A shoe at a foot point, nudged along the facing. */
65
+ export function shoe(at: Vec, forward: Vec, o: Outfit): SceneNode {
66
+ return ellipse(at[0] + forward[0] * 2.4, at[1] + forward[1] * 1.6 - 1.5, 6.2 + Math.abs(forward[0]) * 1.4, 3.8, { fill: o.shoes, stroke: o.shoesInk, sw: LINE * 0.8 });
67
+ }
68
+
69
+ /** A hand. */
70
+ export function hand(c: Colours, at: Vec): SceneNode {
71
+ return ellipse(at[0], at[1], 4.3, 4.3, { fill: c.skin, stroke: c.skinInk, sw: LINE * 0.8 });
72
+ }
73
+
74
+ /** The torso's outline: shoulders at `top`, the hem at `hem`, `w` its width factor (narrower when turned). */
75
+ export function torsoPath(w: number, top: number, hem: number): string {
76
+ const shoulder = 17.5 * w;
77
+ const waist = 14.5 * w;
78
+ const hip = 15.5 * w;
79
+ const mid = (top + hem) / 2 + 4;
80
+ return new Path()
81
+ .m(-shoulder, top + 6)
82
+ .c(-shoulder, top - 1, -shoulder + 5, top - 3, -shoulder * 0.35, top - 3)
83
+ .l(shoulder * 0.35, top - 3)
84
+ .c(shoulder - 5, top - 3, shoulder, top - 1, shoulder, top + 6)
85
+ .l(waist, mid)
86
+ .l(hip, hem)
87
+ .q(0, hem + 3, -hip, hem)
88
+ .l(-waist, mid)
89
+ .z()
90
+ .d();
91
+ }
92
+
93
+ /** The shirt: a shade base, the lit front, the outline, and the neckline (front) or collar (back). */
94
+ export function shirt(c: Colours, w: number, top: number, hem: number, lean: number, isBack: boolean): SceneNode[] {
95
+ const d = torsoPath(w, top, hem);
96
+ const cy = (top + hem) / 2;
97
+ const nodes: SceneNode[] = [
98
+ path(d, { fill: c.bodyShade }),
99
+ g([path(d, { fill: c.body })], { tf: [tr(-1.5 - lean * 2, cy - 1.5), sc(0.9, 0.94), tr(0, -cy)] }),
100
+ path(`M${String(-15 * w)} ${String(hem - 2.5)}Q0 ${String(hem + 0.5)} ${String(15 * w)} ${String(hem - 2.5)}`, { fill: 'none', stroke: shade(c.bodyShade), sw: 2.2, opacity: 0.7 }),
101
+ path(d, { fill: 'none', stroke: c.bodyInk, sw: LINE }),
102
+ ];
103
+ const neckX = lean * 7;
104
+ if (isBack) {
105
+ nodes.push(path(`M${String(neckX - 6)} ${String(top - 2)}Q${String(neckX)} ${String(top + 1.5)} ${String(neckX + 6)} ${String(top - 2)}`, { fill: 'none', stroke: c.bodyShade, sw: 3 }));
106
+ } else {
107
+ nodes.push(path(`M${String(neckX - 6 * w)} ${String(top - 2.6)}Q${String(neckX)} ${String(top + 6)} ${String(neckX + 6 * w)} ${String(top - 2.6)}Z`, { fill: c.skin, stroke: c.bodyInk, sw: LINE * 0.8 }));
108
+ }
109
+ return nodes;
110
+ }
111
+
112
+ /** The seat of the trousers joining the legs under the hem. */
113
+ export function hips(o: Outfit, w: number, hipY: number): SceneNode {
114
+ const half = 12 * w;
115
+ return path(new Path().m(-half, hipY - 4).l(half, hipY - 4).l(half, hipY + 4).q(0, hipY + 9, -half, hipY + 4).z().d(), { fill: o.trousers, stroke: o.trousersInk, sw: LINE * 0.8 });
116
+ }
117
+
118
+ /** The neck under the head. */
119
+ export function neck(c: Colours, top: number, dx: number): SceneNode {
120
+ return path(new Path().m(dx - 4.5, top - 10).l(dx + 4.5, top - 10).l(dx + 4.5, top).l(dx - 4.5, top).z().d(), { fill: c.skinShade, stroke: c.skinInk, sw: LINE * 0.6 });
121
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The world walker (`figure: 'walker'`): a small full-body character for
3
+ * maps and crowds, with legs, arms, a shirt, trousers and shoes, and the
4
+ * head drawn smaller on top ({@link WALKER_HEAD_SCALE}). It takes the blob's
5
+ * pose names: `walk` and `run` swing the legs and arms along the facing,
6
+ * `sit` folds the legs over a seat, `idle` breathes, `wave` and `cheer` lift
7
+ * the arms.
8
+ *
9
+ * Standing, the origin is the feet. Sitting (`sit`, `play`), the origin is
10
+ * the seat: the hips rest on it and the feet hang {@link WALKER_SEAT_DROP}
11
+ * units below, on the floor in front of it.
12
+ */
13
+ import { ellipse, type SceneNode } from '../scene';
14
+ import type { BodyLayout } from './body';
15
+ import type { Colours } from './colours';
16
+ import type { BodyPose } from './types';
17
+ import { hand, hips, limb, neck, outfitFor, shirt, shoe, type Outfit, type Vec } from './walker-parts';
18
+
19
+ /** The head's scale on a walker (the blob draws it at 1). */
20
+ export const WALKER_HEAD_SCALE = 0.58;
21
+
22
+ /** How far below the seat a sitter's feet reach (rig units). */
23
+ export const WALKER_SEAT_DROP = 26;
24
+
25
+ /** Heights of the hips, shoulders and head centre above the origin. */
26
+ interface Heights {
27
+ readonly hip: number;
28
+ readonly shoulder: number;
29
+ readonly head: number;
30
+ }
31
+
32
+ const STANDING: Heights = { hip: -35, shoulder: -72, head: -103 };
33
+ const SEATED: Heights = { hip: -3, shoulder: -40, head: -71 };
34
+ const LEG = 32;
35
+ const UPPER_ARM = 13;
36
+ const FOREARM = 12;
37
+ const LEG_W = 10.5;
38
+ const SLEEVE_W = 10;
39
+ const ARM_W = 7.5;
40
+ const DEG = Math.PI / 180;
41
+
42
+ /** A walker's layout: the blob's fields plus where the head goes and its scale. */
43
+ export interface WalkerLayout extends BodyLayout {
44
+ readonly headY: number;
45
+ readonly headScale: number;
46
+ }
47
+
48
+ /** One side of the body: −1 is the character's right, +1 its left. */
49
+ type Side = -1 | 1;
50
+
51
+ /** The view of a facing: how far it is turned, which way is forward on screen. */
52
+ interface View {
53
+ /** sin of the turn: 0 front or back, ~1 side on. */
54
+ readonly side: number;
55
+ /** cos of the turn: 1 front, −1 back. */
56
+ readonly front: number;
57
+ /** Forward on the floor, in screen units per unit (y is foreshortened). */
58
+ readonly forward: Vec;
59
+ readonly isBack: boolean;
60
+ /** The torso's width factor. */
61
+ readonly width: number;
62
+ }
63
+
64
+ function viewOf(angleDeg: number): View {
65
+ const side = Math.sin(angleDeg * DEG);
66
+ const front = Math.cos(angleDeg * DEG);
67
+ return { side, front, forward: [side, front * 0.45], isBack: angleDeg > 90, width: 1 - 0.36 * Math.abs(side) };
68
+ }
69
+
70
+ /** A body side's screen x for a lateral distance (sides overlap when turned). */
71
+ const lateralX = (v: View, sideOf: Side, distance: number): number => sideOf * distance * v.front;
72
+
73
+ /** How near the viewer a side is (> 0: drawn in front when turned). */
74
+ const nearness = (v: View, sideOf: Side): number => -sideOf * v.side;
75
+
76
+ const add = (a: Vec, b: Vec, k = 1): Vec => [a[0] + b[0] * k, a[1] + b[1] * k];
77
+
78
+ /** What each limb does in a pose. */
79
+ interface Limbs {
80
+ /** Foot (standing) or knee-then-foot (sitting) per side. */
81
+ readonly leg: (sideOf: Side) => readonly [Vec, Vec, Vec];
82
+ readonly arm: (sideOf: Side) => readonly [Vec, Vec, Vec];
83
+ }
84
+
85
+ function standingLimbs(v: View, h: Heights, pose: BodyPose, phase: number, bob: number): Limbs {
86
+ const isRun = pose === 'run';
87
+ const isWalking = pose === 'walk' || isRun || pose === 'dance';
88
+ const stride = isRun ? 38 : 27;
89
+ const swingOf = (sideOf: Side): number => (isWalking ? Math.sin((phase + (sideOf === 1 ? 0 : 0.5)) * Math.PI * 2) : 0);
90
+ return {
91
+ leg: (sideOf) => {
92
+ const hip: Vec = [lateralX(v, sideOf, 6), h.hip + bob];
93
+ const swing = swingOf(sideOf);
94
+ const lift = isWalking ? Math.max(0, Math.cos((phase + (sideOf === 1 ? 0 : 0.5)) * Math.PI * 2)) * (isRun ? 7 : 4.5) : 0;
95
+ const reach = Math.sin(stride * DEG) * LEG * swing;
96
+ const foot: Vec = [hip[0] + v.forward[0] * reach, -bob * 0.2 + v.forward[1] * reach - lift];
97
+ const knee: Vec = [(hip[0] + foot[0]) / 2 + v.forward[0] * lift * 0.9, (hip[1] + foot[1]) / 2 + v.forward[1] * lift * 0.9];
98
+ return [hip, knee, foot];
99
+ },
100
+ arm: (sideOf) => {
101
+ const shoulder: Vec = [lateralX(v, sideOf, 15.5 * v.width) + v.forward[0] * 1.5, h.shoulder + 4 + bob];
102
+ const out = sideOf * v.front * 2.2;
103
+ if (pose === 'cheer' || (pose === 'wave' && sideOf === -1)) {
104
+ const wave = pose === 'wave' ? Math.sin(phase * Math.PI * 2) * 4 : 0;
105
+ const elbow: Vec = [shoulder[0] + out * 3, shoulder[1] - 10];
106
+ return [shoulder, elbow, [elbow[0] + out * 1.5 + wave, elbow[1] - 12]];
107
+ }
108
+ const swing = isWalking ? -swingOf(sideOf) * (isRun ? 1.3 : 1) : Math.sin(phase * Math.PI * 2) * 0.06;
109
+ const reach = Math.sin(24 * DEG) * swing;
110
+ const elbow: Vec = [shoulder[0] + out + v.forward[0] * UPPER_ARM * reach * 0.6, shoulder[1] + UPPER_ARM + v.forward[1] * UPPER_ARM * reach];
111
+ const wrist: Vec = [elbow[0] + out * 0.4 + v.forward[0] * FOREARM * reach * 1.6, elbow[1] + FOREARM - Math.abs(reach) * 3 + v.forward[1] * FOREARM * reach];
112
+ return [shoulder, elbow, wrist];
113
+ },
114
+ };
115
+ }
116
+
117
+ function seatedLimbs(v: View, h: Heights, pose: BodyPose): Limbs {
118
+ const thigh: Vec = [v.forward[0] * 15, v.forward[1] * 16];
119
+ return {
120
+ leg: (sideOf) => {
121
+ const hip: Vec = [lateralX(v, sideOf, 6), h.hip];
122
+ const knee = add(hip, thigh);
123
+ return [hip, knee, [knee[0] + v.forward[0] * 3, WALKER_SEAT_DROP - 2]];
124
+ },
125
+ arm: (sideOf) => {
126
+ const shoulder: Vec = [lateralX(v, sideOf, 15.5 * v.width) + v.forward[0] * 1.5, h.shoulder + 4];
127
+ const elbow: Vec = [shoulder[0] + sideOf * v.front * 2 + v.forward[0] * 3, shoulder[1] + UPPER_ARM];
128
+ const lap = add([lateralX(v, sideOf, pose === 'play' ? 4 : 7), h.hip - 4], thigh, pose === 'play' ? 0.9 : 0.6);
129
+ return [shoulder, elbow, lap];
130
+ },
131
+ };
132
+ }
133
+
134
+ /** The walker's drawing for a pose, at a facing angle (0 = S, 180 = N; west facings are mirrored by the caller). */
135
+ export function walkerLayout(c: Colours, pose: BodyPose, angleDeg: number, phase: number, outfit: Outfit = outfitFor(c)): WalkerLayout {
136
+ const v = viewOf(angleDeg);
137
+ const isSitting = pose === 'sit' || pose === 'play';
138
+ const h = isSitting ? SEATED : STANDING;
139
+ const cycle = Math.sin(phase * Math.PI * 2);
140
+ const isWalking = pose === 'walk' || pose === 'run' || pose === 'dance';
141
+ const hop = pose === 'jump' || pose === 'cheer' ? Math.abs(Math.sin(phase * Math.PI)) * (pose === 'jump' ? 16 : 8) : 0;
142
+ const bob = isWalking ? -2.2 * (1 - Math.abs(cycle)) : pose === 'idle' ? cycle * 0.6 : 0;
143
+ const limbs = isSitting ? seatedLimbs(v, h, pose) : standingLimbs(v, h, pose, phase, bob);
144
+ const o = outfit;
145
+ const [farSide, nearSide]: readonly [Side, Side] = nearness(v, 1) > nearness(v, -1) ? [-1, 1] : [1, -1];
146
+ const isTurned = Math.abs(v.side) > 0.3;
147
+ const lean = v.side * (isSitting ? 0.6 : 1);
148
+ const armNodes = (sideOf: Side): SceneNode[] => {
149
+ const [shoulder, elbow, wrist] = limbs.arm(sideOf);
150
+ return [...limb([shoulder, elbow, wrist], ARM_W, c.skin, c.skinInk), ...limb([shoulder, add(shoulder, [elbow[0] - shoulder[0], elbow[1] - shoulder[1]], 0.62)], SLEEVE_W, c.body, c.bodyInk), hand(c, wrist)];
151
+ };
152
+ const legNodes = (sideOf: Side): SceneNode[] => {
153
+ const points = limbs.leg(sideOf);
154
+ const foot = points[2];
155
+ return [...limb(points, LEG_W, o.trousers, o.trousersInk), shoe(foot, v.forward, o)];
156
+ };
157
+ const torso = shirt(c, v.width, h.shoulder + (isWalking ? bob : 0), h.hip + 3 + (isWalking ? bob : 0), lean, v.isBack);
158
+ // Seated with our back to us, the arms reach forward and the legs are under the seat: both hide behind the back.
159
+ const isTucked = isSitting && v.isBack;
160
+ const behind: SceneNode[] = isTucked ? [...armNodes(farSide), ...armNodes(nearSide)] : isTurned ? armNodes(farSide) : [];
161
+ // Sitting facing us, the thighs come toward the viewer over the shirt's hem.
162
+ const isLegsFirst = v.isBack || !isSitting;
163
+ const legs = isTucked ? [] : [...legNodes(farSide), ...legNodes(nearSide)];
164
+ const body: SceneNode[] = [
165
+ ...(isLegsFirst ? legs : []),
166
+ hips(o, v.width, h.hip + (isWalking ? bob : 0)),
167
+ neck(c, h.shoulder + bob, v.forward[0] * 2),
168
+ ...torso,
169
+ ...(isLegsFirst ? [] : legs),
170
+ ...(isTucked ? [] : isTurned ? armNodes(nearSide) : [...armNodes(-1), ...armNodes(1)]),
171
+ ];
172
+ const prop = add([0, h.hip - 12], v.forward, 8);
173
+ return {
174
+ behind, body, front: [],
175
+ headDx: v.forward[0] * 1.5, headDy: 0, headY: h.head + bob * 1.1, headScale: WALKER_HEAD_SCALE,
176
+ tilt: isWalking ? v.side * 2 : 0, lift: hop, prop: { x: prop[0], y: prop[1] },
177
+ };
178
+ }
179
+
180
+ const GREY = '#6f7787';
181
+ const GREY_INK = '#5c6474';
182
+
183
+ /** Every colour slot in the silhouette grey. */
184
+ const GREY_COLOURS: Colours = {
185
+ skin: GREY, skinShade: GREY, skinLight: GREY, skinInk: GREY_INK, eyeRim: GREY, hair: GREY, hairShade: GREY, hairLight: GREY,
186
+ hairInk: GREY_INK, hairBuzz: GREY, brow: GREY, beard: GREY, beardShade: GREY, beardInk: GREY_INK, stubble: GREY, eye: GREY,
187
+ eyeDark: GREY, body: GREY, bodyShade: GREY, bodyLight: GREY, bodyInk: GREY_INK,
188
+ };
189
+
190
+ /** The grey walker shown before the art loads (or for a hidden player): its nodes, head centre and top. */
191
+ export function walkerSilhouette(): { readonly nodes: readonly SceneNode[]; readonly headY: number; readonly top: number } {
192
+ const b = walkerLayout(GREY_COLOURS, 'idle', 0, 0, { trousers: GREY, trousersInk: GREY_INK, shoes: GREY, shoesInk: GREY_INK });
193
+ const r = 46 * WALKER_HEAD_SCALE;
194
+ return {
195
+ nodes: [...b.behind, ...b.body, ellipse(0, b.headY, r, r * 1.05, { fill: GREY })],
196
+ headY: b.headY,
197
+ top: b.headY - r - 4,
198
+ };
199
+ }
package/src/avatar.ts CHANGED
@@ -43,12 +43,13 @@ export { overlays, type ActivityIcon, type Overlay, type ReactionGlyph } from '.
43
43
  export { overlaySvg, paintOverlay } from './avatar/outputs/overlay-svg';
44
44
  export { catalog } from './avatar/catalog-view';
45
45
  export { composeAvatar, HEAD_Y, type Composed } from './avatar/render/compose';
46
+ export { WALKER_HEAD_SCALE, WALKER_SEAT_DROP } from './avatar/render/walker';
46
47
  export { paintNodes, type Ctx2D } from './avatar/outputs/canvas-paint';
47
48
  export { sceneToSvg } from './avatar/outputs/svg-markup';
48
49
  export { toCode, toLook } from './avatar/look-like';
49
50
  export { contrast, deltaE, lightness } from './avatar/colour';
50
51
  export type { SceneNode } from './avatar/scene';
51
- export type { Anchor, BodyPose, Facing, Frame, LookDir, LookLike, Point, Pose, RenderOptions, StatusProp, View } from './avatar/render/types';
52
+ export type { Anchor, BodyPose, Facing, Figure, Frame, LookDir, LookLike, Point, Pose, RenderOptions, StatusProp, View } from './avatar/render/types';
52
53
  export { AvatarAtlas, createAtlas, type AtlasFrame, type AtlasOptions } from './avatar/atlas';
53
54
 
54
55
  /** Every output: SVG markup, a live element, Canvas 2D, a bitmap or a data URL. */
@@ -139,6 +139,7 @@ export const ROUTES = {
139
139
  getTurnGameHistory: { method: 'GET', path: '/turns/:id/history', query: ['since'], offline: null },
140
140
  listOpenTurnGames: { method: 'GET', path: '/open-turns', query: ['game'], offline: null },
141
141
  listMyTurnGames: { method: 'GET', path: '/turns', query: ['game'], offline: null },
142
+ getWorldMap: { method: 'GET', path: '/world/maps/:key', query: ['rev'], offline: null },
142
143
  } as const;
143
144
 
144
145
  /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */