@diffenderfer-games/hub 2.0.0-next.7 → 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 (82) hide show
  1. package/CHANGELOG.md +43 -4
  2. package/dist/auto.d.ts +3 -3
  3. package/dist/auto.js +2 -2
  4. package/dist/avatar-pixi.d.ts +19 -1
  5. package/dist/avatar-pixi.d.ts.map +1 -1
  6. package/dist/avatar-pixi.js +37 -18
  7. package/dist/avatar-pixi.js.map +1 -1
  8. package/dist/avatar-svg.d.ts +1 -1
  9. package/dist/avatar-svg.js +1 -1
  10. package/dist/avatar-three.d.ts +1 -1
  11. package/dist/avatar.d.ts +3 -3
  12. package/dist/avatar.js +20 -7
  13. package/dist/avatar.js.map +1 -1
  14. package/dist/doctor/cli.mjs +58 -4
  15. package/dist/doctor/cli.mjs.map +1 -1
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.js +2 -2
  18. package/dist/shared/animator.d.ts +1 -1
  19. package/dist/shared/art.js +1 -1
  20. package/dist/shared/atlas.d.ts +5 -1
  21. package/dist/shared/atlas.d.ts.map +1 -1
  22. package/dist/shared/avatar.d.ts +10 -4
  23. package/dist/shared/avatar.d.ts.map +1 -1
  24. package/dist/shared/bridge.js +2 -2
  25. package/dist/shared/bridge.js.map +1 -1
  26. package/dist/shared/client-version.gen.js +1 -1
  27. package/dist/shared/daily.js +1 -1
  28. package/dist/shared/describe.d.ts +1 -1
  29. package/dist/shared/describe.d.ts.map +1 -1
  30. package/dist/shared/describe.js +1 -1
  31. package/dist/shared/index.d.ts +88 -11
  32. package/dist/shared/index.d.ts.map +1 -1
  33. package/dist/shared/live-canvas.js +1 -1
  34. package/dist/shared/overlays.js +1 -1
  35. package/dist/shared/overlays.js.map +1 -1
  36. package/dist/shared/pose-key.js +2 -2
  37. package/dist/shared/pose-key.js.map +1 -1
  38. package/dist/shared/social.d.ts +19 -4
  39. package/dist/shared/social.d.ts.map +1 -1
  40. package/dist/shared/src.js +178 -28
  41. package/dist/shared/src.js.map +1 -1
  42. package/dist/shared/svg-markup.js +430 -21
  43. package/dist/shared/svg-markup.js.map +1 -1
  44. package/dist/shared/svg.js +2 -2
  45. package/dist/shared/svg.js.map +1 -1
  46. package/dist/shared/types.d.ts +9 -1
  47. package/dist/shared/types.d.ts.map +1 -1
  48. package/dist/social.d.ts +2 -2
  49. package/dist/turns-rules.d.ts +2 -0
  50. package/dist/turns-rules.js +1 -0
  51. package/dist/turns.d.ts +23 -1
  52. package/dist/turns.d.ts.map +1 -1
  53. package/dist/turns.js +79 -13
  54. package/dist/turns.js.map +1 -1
  55. package/package.json +8 -2
  56. package/src/avatar/adapters/pixi-crowd.ts +45 -18
  57. package/src/avatar/atlas.ts +16 -6
  58. package/src/avatar/layout.ts +1 -1
  59. package/src/avatar/outputs/canvas.ts +2 -2
  60. package/src/avatar/outputs/svg.ts +2 -2
  61. package/src/avatar/render/body.ts +2 -2
  62. package/src/avatar/render/compose.ts +25 -8
  63. package/src/avatar/render/frame.ts +15 -2
  64. package/src/avatar/render/types.ts +9 -0
  65. package/src/avatar/render/walker-parts.ts +121 -0
  66. package/src/avatar/render/walker.ts +199 -0
  67. package/src/avatar.ts +2 -1
  68. package/src/core/rest/routes.gen.ts +1 -0
  69. package/src/core/turns/hub-turns.ts +25 -4
  70. package/src/core/turns/turn-game.ts +143 -21
  71. package/src/core/turns/your-turn-buzz.ts +69 -0
  72. package/src/core/version/client-version.gen.ts +1 -1
  73. package/src/doctor/checks.ts +2 -2
  74. package/src/doctor/cli.ts +5 -2
  75. package/src/doctor/world-check.ts +61 -0
  76. package/src/index.ts +2 -1
  77. package/src/realtime/runtime-api.ts +5 -2
  78. package/src/realtime/types.ts +1 -0
  79. package/src/realtime/world-types.ts +20 -0
  80. package/src/turns-kit/check-rules.ts +116 -17
  81. package/src/turns-rules.ts +12 -0
  82. package/src/turns.ts +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffenderfer-games/hub",
3
- "version": "2.0.0-next.7",
3
+ "version": "2.0.0-next.9",
4
4
  "description": "The diffenderfer.games hub client for games: accounts, saves, stats, leaderboards, inventory, daily challenges, the input system and the social/multiplayer forwarders, typed from the hub contract.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,6 +64,11 @@
64
64
  "types": "./dist/turns.d.ts",
65
65
  "default": "./dist/turns.js"
66
66
  },
67
+ "./turns-rules": {
68
+ "source": "./src/turns-rules.ts",
69
+ "types": "./dist/turns-rules.d.ts",
70
+ "default": "./dist/turns-rules.js"
71
+ },
67
72
  "./types": {
68
73
  "source": "./src/types.ts",
69
74
  "types": "./dist/types.d.ts",
@@ -106,7 +111,8 @@
106
111
  },
107
112
  "dependencies": {
108
113
  "@ayepi/core": "0.4.1",
109
- "@diffenderfer-games/hub-contract": "2.0.0-next.7"
114
+ "@diffenderfer-games/hub-contract": "2.0.0-next.9",
115
+ "@diffenderfer-games/world": "2.0.0-next.9"
110
116
  },
111
117
  "publishConfig": {
112
118
  "access": "public"
@@ -15,7 +15,8 @@ import { toCode } from '../look-like';
15
15
  import { defaultLayer } from '../outputs/canvas';
16
16
  import { paintNodes } from '../outputs/canvas-paint';
17
17
  import { overlays, type Overlay, type ReactionGlyph } from '../overlays';
18
- import type { BodyPose, Facing, LookLike, StatusProp } from '../render/types';
18
+ import { frameBox } from '../render/frame';
19
+ import type { BodyPose, Facing, Figure, LookLike, StatusProp } from '../render/types';
19
20
  import { pixiAvatar, type PixiAvatar } from './pixi-avatar';
20
21
  import { refresh, type PixiContainer, type PixiNs, type PixiRectangle, type PixiSprite, type PixiTexture } from './pixi-types';
21
22
 
@@ -40,6 +41,8 @@ export interface CrowdMember {
40
41
  readonly live?: boolean;
41
42
  /** Hide the tag (the world fades tags by distance and hides them when crowded). */
42
43
  readonly tagHidden?: boolean;
44
+ /** Moves the tag and bubble up by this much (container units), so a world can stack tags that would overlap. */
45
+ readonly tagLift?: number;
43
46
  }
44
47
 
45
48
  /** Crowd options; `container` is the game's own pixi `Container`. */
@@ -51,6 +54,22 @@ export interface CrowdOptions<TSprite extends PixiSprite = PixiSprite> {
51
54
  readonly liveMax?: number;
52
55
  /** Atlas baking budget per update, ms (default 4). */
53
56
  readonly bakeMs?: number;
57
+ /**
58
+ * Where name tags and bubbles go (default `container`). A world puts them in
59
+ * a container above its lighting so tags stay readable at night.
60
+ */
61
+ readonly overlays?: PixiContainer<TSprite>;
62
+ /**
63
+ * Draws atlas avatars at this scale (default 1). Bake the atlas at
64
+ * `scale × devicePixelRatio` and pass `1 / devicePixelRatio` for crisp
65
+ * avatars on high-density screens; positions stay in the container's units.
66
+ */
67
+ readonly displayScale?: number;
68
+ /**
69
+ * The figure the atlas draws (default `blob`). Pass the atlas's own
70
+ * figure, so name tags sit on the right heads and walks step in time.
71
+ */
72
+ readonly figure?: Figure;
54
73
  }
55
74
 
56
75
  /** The crowd. */
@@ -74,6 +93,8 @@ interface Entry<TSprite extends PixiSprite> {
74
93
  readonly tag: OverlaySlot<TSprite>;
75
94
  readonly bubble: OverlaySlot<TSprite>;
76
95
  emoteAt: number;
96
+ /** A baked frame has been shown (while a new look bakes, the old one stays instead of the silhouette). */
97
+ hasFrame: boolean;
77
98
  live: PixiAvatar<TSprite> | null;
78
99
  body: BodyPose | null;
79
100
  readonly anim: Animator;
@@ -81,6 +102,8 @@ interface Entry<TSprite extends PixiSprite> {
81
102
 
82
103
  const EMOTE_MS = 2500;
83
104
  const CYCLE: Readonly<Partial<Record<BodyPose, number>>> = { walk: 520, run: 320, dance: 900, jump: 700, cheer: 600, idle: 2400 };
105
+ /** The walker's shorter legs step quicker. */
106
+ const WALKER_CYCLE: Readonly<Partial<Record<BodyPose, number>>> = { ...CYCLE, walk: 440, run: 300, idle: 2800 };
84
107
 
85
108
  /** A crowd over an atlas: `createCrowd(pixi, { atlas, container })`. */
86
109
  export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSprite<TTexture>, TRectangle extends PixiRectangle>(
@@ -88,8 +111,14 @@ export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSp
88
111
  opts: CrowdOptions<NoInfer<TSprite>>,
89
112
  ): AvatarCrowd {
90
113
  const { atlas, container } = opts;
114
+ const overlayLayer = opts.overlays ?? container;
115
+ const displayScale = opts.displayScale ?? 1;
91
116
  container.sortableChildren = true;
117
+ overlayLayer.sortableChildren = true;
92
118
  const liveMax = opts.liveMax ?? 6;
119
+ const figure = opts.figure ?? 'blob';
120
+ const cycles = figure === 'walker' ? WALKER_CYCLE : CYCLE;
121
+ const [, liveTop, , liveHeight] = frameBox({ frame: 'full', figure });
93
122
  const entries = new Map<string, Entry<TSprite>>();
94
123
  const pageTextures: TTexture[] = [];
95
124
  const frameTextures = new Map<string, TTexture>();
@@ -101,7 +130,7 @@ export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSp
101
130
  const key = `${toCode(look)}|${body}|${status ?? ''}`;
102
131
  let plateY = nameplates.get(key);
103
132
  if (plateY === undefined) {
104
- plateY = layout(look, { pose: { body, status } }).nameplate.y;
133
+ plateY = layout(look, { figure, pose: { body, status } }).nameplate.y;
105
134
  nameplates.set(key, plateY);
106
135
  }
107
136
  return plateY;
@@ -126,11 +155,6 @@ export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSp
126
155
  return out;
127
156
  };
128
157
 
129
- const silhouette = (): TTexture | null => {
130
- const frame = atlas.frame({ look: null });
131
- return frame ? textureFor(frame.page, frame.x, frame.y, frame.w, frame.h) : null;
132
- };
133
-
134
158
  const textureFor = (page: number, left: number, top: number, w: number, h: number): TTexture | null => {
135
159
  const sources = atlas.sources;
136
160
  while (pageTextures.length < sources.length) {
@@ -169,7 +193,7 @@ export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSp
169
193
  }
170
194
  if (!sprite) {
171
195
  sprite = new pixi.Sprite(made.texture);
172
- container.addChild(sprite);
196
+ overlayLayer.addChild(sprite);
173
197
  }
174
198
  slot.sprite = sprite;
175
199
  sprite.texture = made.texture;
@@ -204,7 +228,7 @@ export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSp
204
228
  const sprite = new pixi.Sprite();
205
229
  container.addChild(sprite);
206
230
  entries.set(key, {
207
- member, sprite, tag: { sprite: null, key: '' }, bubble: { sprite: null, key: '' }, emoteAt: member.emote ? -1 : 0, live: null, body: null,
231
+ member, sprite, tag: { sprite: null, key: '' }, bubble: { sprite: null, key: '' }, emoteAt: member.emote ? -1 : 0, hasFrame: false, live: null, body: null,
208
232
  anim: animator(member.look, { seed: key }),
209
233
  });
210
234
  },
@@ -234,7 +258,7 @@ export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSp
234
258
  const isEmoting = m.emote !== undefined && t - entry.emoteAt < EMOTE_MS;
235
259
  if (liveSet.has(entry)) {
236
260
  if (!entry.live) {
237
- entry.live = pixiAvatar(pixi, m.look, { frame: 'full', size: 64, rim: true, shadow: false, animator: entry.anim });
261
+ entry.live = pixiAvatar(pixi, m.look, { frame: 'full', figure, size: 64, rim: true, shadow: false, animator: entry.anim });
238
262
  container.addChild(entry.live.view);
239
263
  }
240
264
  entry.sprite.visible = false;
@@ -243,9 +267,9 @@ export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSp
243
267
  entry.body = body;
244
268
  entry.anim.setBody(body, { at: t });
245
269
  }
246
- entry.live.update({ ...entry.anim.pose(t), body, status: m.status ?? null, phase: (t / (CYCLE[body] ?? 600)) % 1 });
270
+ entry.live.update({ ...entry.anim.pose(t), body, status: m.status ?? null, phase: (t / (cycles[body] ?? 600)) % 1 });
247
271
  view.x = m.x;
248
- view.y = m.y - 32;
272
+ view.y = m.y + (figure === 'walker' ? (liveTop + liveHeight / 2) * (64 / liveHeight) : -32);
249
273
  view.zIndex = m.y;
250
274
  view.visible = true;
251
275
  } else {
@@ -254,22 +278,25 @@ export function createCrowd<TTexture extends PixiTexture, TSprite extends PixiSp
254
278
  }
255
279
  const frame = atlas.frame({
256
280
  look: m.look, facing: m.facing ?? 'S', body, status: m.status ?? null,
257
- phase: (t / (CYCLE[body] ?? 600)) % 1, face: isEmoting ? 'happy' : 'neutral',
281
+ phase: (t / (cycles[body] ?? 600)) % 1, face: isEmoting ? 'happy' : 'neutral',
258
282
  });
259
- const tex = frame ? textureFor(frame.page, frame.x, frame.y, frame.w, frame.h) : silhouette();
283
+ // Until its first frame bakes, a member shows the silhouette, anchored and scaled like a frame.
284
+ const shown = frame ?? (entry.hasFrame ? null : atlas.frame({ look: null }));
285
+ entry.hasFrame ||= frame !== null;
286
+ const tex = shown ? textureFor(shown.page, shown.x, shown.y, shown.w, shown.h) : null;
260
287
  if (tex) {
261
288
  entry.sprite.texture = tex;
262
289
  entry.sprite.visible = true;
263
290
  }
264
- if (frame) {
265
- entry.sprite.anchor.set(frame.footX / frame.w, frame.footY / frame.h);
266
- entry.sprite.scale.x = frame.mirror ? -1 : 1;
291
+ if (shown) {
292
+ entry.sprite.anchor.set(shown.footX / shown.w, shown.footY / shown.h);
293
+ entry.sprite.scale.set(shown.mirror ? -displayScale : displayScale, displayScale);
267
294
  }
268
295
  entry.sprite.x = m.x;
269
296
  entry.sprite.y = m.y;
270
297
  entry.sprite.zIndex = m.y;
271
298
  }
272
- const top = m.y + nameplateY(m.look, body, m.status ?? null) * (entry.live?.view.visible === true ? 64 / 206 : atlas.scale);
299
+ const top = m.y - (m.tagLift ?? 0) + nameplateY(m.look, body, m.status ?? null) * (entry.live?.view.visible === true ? 64 / liveHeight : atlas.scale * displayScale);
273
300
  const tagKey = m.name && !m.tagHidden ? `tag:${m.name}:${m.friend ? 1 : 0}:${m.you ? 1 : 0}` : '';
274
301
  placeOverlay(entry.tag, tagKey, () => (m.name ? overlays.nameTag({ name: m.name, friend: m.friend === true, you: m.you === true }) : null), m.x, top);
275
302
  const bubbleKey = isEmoting && m.emote ? `emote:${m.emote}` : m.reaction ? `react:${m.reaction}` : m.activity ? `act:${m.activity}` : '';
@@ -11,7 +11,8 @@ import { toCode, toLook } from './look-like';
11
11
  import { defaultLayer } from './outputs/canvas';
12
12
  import { paintNodes, type Ctx2D } from './outputs/canvas-paint';
13
13
  import { composeAvatar } from './render/compose';
14
- import type { BodyPose, Facing, LookLike, StatusProp } from './render/types';
14
+ import { WALKER_FULL } from './render/frame';
15
+ import type { BodyPose, Facing, Figure, LookLike, StatusProp } from './render/types';
15
16
 
16
17
  /** What to bake. */
17
18
  export interface AtlasKey {
@@ -52,6 +53,8 @@ export interface AtlasOptions {
52
53
  readonly scale?: number;
53
54
  /** The light rim for dark floors (default on). */
54
55
  readonly rim?: boolean;
56
+ /** The figure every frame draws (default `blob`); worlds use `walker`. */
57
+ readonly figure?: Figure;
55
58
  /** Makes page canvases (tests, workers); default an `OffscreenCanvas` or DOM canvas. */
56
59
  readonly makeCanvas?: (size: number) => { readonly canvas: CanvasImageSource; readonly ctx: Ctx2D } | null;
57
60
  }
@@ -70,6 +73,8 @@ const MIRRORED: Readonly<Partial<Record<Facing, Facing>>> = { W: 'E', SW: 'SE',
70
73
  const STEPS: Readonly<Partial<Record<BodyPose, number>>> = { walk: 8, run: 8, idle: 4, dance: 8, jump: 6, cheer: 6 };
71
74
  /** The rig box every frame bakes: wide enough for hats, props and the rim. */
72
75
  const BOX = { x: -90, y: -210, w: 180, h: 226 };
76
+ /** The walker's box: its full frame plus a margin for the rim. */
77
+ const WALKER_BOX = { x: WALKER_FULL[0] + 4, y: WALKER_FULL[1] - 2, w: WALKER_FULL[2] - 8, h: WALKER_FULL[3] + 2 };
73
78
 
74
79
  /** The atlas. */
75
80
  export class AvatarAtlas {
@@ -77,6 +82,8 @@ export class AvatarAtlas {
77
82
  readonly scale: number;
78
83
  private readonly maxPages: number;
79
84
  private readonly rim: boolean;
85
+ private readonly figure: Figure;
86
+ private readonly box: typeof BOX;
80
87
  private readonly makeCanvas: NonNullable<AtlasOptions['makeCanvas']>;
81
88
  private readonly pages: Page[] = [];
82
89
  private readonly frames = new Map<string, AtlasFrame & { readonly version: number }>();
@@ -90,6 +97,8 @@ export class AvatarAtlas {
90
97
  this.maxPages = opts.maxPages ?? 4;
91
98
  this.scale = opts.scale ?? 0.32;
92
99
  this.rim = opts.rim ?? true;
100
+ this.figure = opts.figure ?? 'blob';
101
+ this.box = this.figure === 'walker' ? WALKER_BOX : BOX;
93
102
  this.makeCanvas = opts.makeCanvas ?? ((size) => defaultLayer(size, size));
94
103
  }
95
104
 
@@ -195,8 +204,9 @@ export class AvatarAtlas {
195
204
 
196
205
  private bakeOne(id: string, key: AtlasKey): boolean {
197
206
  const scale = key.scale ?? this.scale;
198
- const w = Math.ceil(BOX.w * scale) + 2;
199
- const h = Math.ceil(BOX.h * scale) + 2;
207
+ const box = this.box;
208
+ const w = Math.ceil(box.w * scale) + 2;
209
+ const h = Math.ceil(box.h * scale) + 2;
200
210
  const spot = this.place(w, h);
201
211
  const page = spot ? this.pages[spot.page] : undefined;
202
212
  if (!spot || !page) {
@@ -208,14 +218,14 @@ export class AvatarAtlas {
208
218
  const phase = (Math.floor(((key.phase ?? 0) % 1) * steps) + 0.5) / steps;
209
219
  const face: Expression = key.face ?? 'neutral';
210
220
  const { nodes } = composeAvatar(toLook(key.look), {
211
- frame: 'full', facing, rim: this.rim, shadow: false, pose: { expression: face, body, phase, status: key.status ?? null },
221
+ frame: 'full', figure: this.figure, facing, rim: this.rim, shadow: false, pose: { expression: face, body, phase, status: key.status ?? null },
212
222
  });
213
223
  // Paint into a frame-sized scratch canvas (so the rim's layer is small too), then stamp it on the page.
214
224
  const scratch = defaultLayer(w, h);
215
225
  if (!scratch) {
216
226
  return false;
217
227
  }
218
- scratch.ctx.translate(1 - BOX.x * scale, 1 - BOX.y * scale);
228
+ scratch.ctx.translate(1 - box.x * scale, 1 - box.y * scale);
219
229
  scratch.ctx.scale(scale, scale);
220
230
  paintNodes(scratch.ctx, nodes, defaultLayer);
221
231
  page.ctx.clearRect(spot.x, spot.y, w, h);
@@ -224,7 +234,7 @@ export class AvatarAtlas {
224
234
  this.latestByLook.set(toCode(key.look), id);
225
235
  this.frames.set(id, {
226
236
  page: spot.page, source: page.canvas, x: spot.x, y: spot.y, w, h,
227
- footX: 1 - BOX.x * scale, footY: 1 - BOX.y * scale, mirror: false, version: page.version,
237
+ footX: 1 - box.x * scale, footY: 1 - box.y * scale, mirror: false, version: page.version,
228
238
  });
229
239
  return true;
230
240
  }
@@ -8,7 +8,7 @@ import type { Anchor, Facing, LookDir, LookLike, Point, Pose, RenderOptions } fr
8
8
  * bubbles, props and world objects with these, never with magic numbers.
9
9
  * Rig units (a 100-unit head), feet at (0, 0), y down.
10
10
  */
11
- export function layout(like: LookLike, opts: Pick<RenderOptions, 'facing' | 'view' | 'pose'> = {}): Readonly<Record<Anchor, Point>> {
11
+ export function layout(like: LookLike, opts: Pick<RenderOptions, 'facing' | 'view' | 'pose' | 'figure'> = {}): Readonly<Record<Anchor, Point>> {
12
12
  return composeAvatar(toLook(like), { frame: 'full', ...opts }).anchors;
13
13
  }
14
14
 
@@ -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
+ }