@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.
- package/CHANGELOG.md +32 -13
- package/dist/auto.d.ts +2 -2
- package/dist/avatar-pixi.d.ts +19 -1
- package/dist/avatar-pixi.d.ts.map +1 -1
- package/dist/avatar-pixi.js +37 -18
- package/dist/avatar-pixi.js.map +1 -1
- package/dist/avatar-svg.d.ts +1 -1
- package/dist/avatar-svg.js +1 -1
- package/dist/avatar-three.d.ts +1 -1
- package/dist/avatar.d.ts +3 -3
- package/dist/avatar.js +20 -7
- package/dist/avatar.js.map +1 -1
- package/dist/doctor/cli.mjs +58 -4
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/shared/animator.d.ts +1 -1
- package/dist/shared/art.js +1 -1
- package/dist/shared/atlas.d.ts +5 -1
- package/dist/shared/atlas.d.ts.map +1 -1
- package/dist/shared/avatar.d.ts +10 -4
- package/dist/shared/avatar.d.ts.map +1 -1
- package/dist/shared/bridge.js +2 -2
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/describe.d.ts +1 -1
- package/dist/shared/describe.d.ts.map +1 -1
- package/dist/shared/describe.js +1 -1
- package/dist/shared/index.d.ts +2 -2
- package/dist/shared/live-canvas.js +1 -1
- package/dist/shared/overlays.js +1 -1
- package/dist/shared/overlays.js.map +1 -1
- package/dist/shared/pose-key.js +2 -2
- package/dist/shared/pose-key.js.map +1 -1
- package/dist/shared/social.d.ts +19 -4
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/src.js +6 -0
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +430 -21
- package/dist/shared/svg-markup.js.map +1 -1
- package/dist/shared/svg.js +2 -2
- package/dist/shared/svg.js.map +1 -1
- package/dist/shared/types.d.ts +9 -1
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/social.d.ts +2 -2
- package/package.json +3 -2
- package/src/avatar/adapters/pixi-crowd.ts +45 -18
- package/src/avatar/atlas.ts +16 -6
- package/src/avatar/layout.ts +1 -1
- package/src/avatar/outputs/canvas.ts +2 -2
- package/src/avatar/outputs/svg.ts +2 -2
- package/src/avatar/render/body.ts +2 -2
- package/src/avatar/render/compose.ts +25 -8
- package/src/avatar/render/frame.ts +15 -2
- package/src/avatar/render/types.ts +9 -0
- package/src/avatar/render/walker-parts.ts +121 -0
- package/src/avatar/render/walker.ts +199 -0
- package/src/avatar.ts +2 -1
- package/src/core/rest/routes.gen.ts +1 -0
- package/src/core/turns/your-turn-buzz.ts +69 -69
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/doctor/checks.ts +2 -2
- package/src/doctor/cli.ts +5 -2
- package/src/doctor/world-check.ts +61 -0
- package/src/index.ts +68 -68
- package/src/realtime/runtime-api.ts +5 -2
- package/src/realtime/types.ts +1 -0
- package/src/realtime/world-types.ts +20 -0
- 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 {
|
|
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] =
|
|
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 {
|
|
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] =
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
287
|
-
const
|
|
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] =
|
|
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] =
|
|
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`). */
|