@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.
- package/CHANGELOG.md +43 -4
- package/dist/auto.d.ts +3 -3
- package/dist/auto.js +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 +3 -3
- package/dist/index.js +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 +88 -11
- package/dist/shared/index.d.ts.map +1 -1
- 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 +178 -28
- 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/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +23 -1
- package/dist/turns.d.ts.map +1 -1
- package/dist/turns.js +79 -13
- package/dist/turns.js.map +1 -1
- package/package.json +8 -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/hub-turns.ts +25 -4
- package/src/core/turns/turn-game.ts +143 -21
- package/src/core/turns/your-turn-buzz.ts +69 -0
- 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 +2 -1
- 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-kit/check-rules.ts +116 -17
- package/src/turns-rules.ts +12 -0
- 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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 / (
|
|
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 -
|
|
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 / (
|
|
281
|
+
phase: (t / (cycles[body] ?? 600)) % 1, face: isEmoting ? 'happy' : 'neutral',
|
|
258
282
|
});
|
|
259
|
-
|
|
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 (
|
|
265
|
-
entry.sprite.anchor.set(
|
|
266
|
-
entry.sprite.scale.
|
|
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 /
|
|
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}` : '';
|
package/src/avatar/atlas.ts
CHANGED
|
@@ -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
|
|
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
|
|
199
|
-
const
|
|
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 -
|
|
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 -
|
|
237
|
+
footX: 1 - box.x * scale, footY: 1 - box.y * scale, mirror: false, version: page.version,
|
|
228
238
|
});
|
|
229
239
|
return true;
|
|
230
240
|
}
|
package/src/avatar/layout.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|