@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.11
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 +173 -5
- package/README.md +27 -15
- package/dist/auto.d.ts +8 -8
- package/dist/auto.js +3 -3
- package/dist/avatar-pixi.d.ts +165 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +299 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +500 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +134 -10
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/rooms.js +1 -1
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +138 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +123 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +18 -4
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +145 -19
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +138 -25
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +109 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +1 -39
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1122 -46
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +74 -14
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +179 -25
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +23 -4
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +90 -9
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +4563 -667
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +2411 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +174 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +2 -2
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +61 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +183 -0
- package/dist/turns.js.map +1 -0
- package/package.json +39 -3
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +329 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +67 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +87 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +246 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +79 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +173 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +346 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +118 -0
- package/src/avatar/render/frame.ts +73 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +93 -0
- package/src/avatar/render/walker-parts.ts +124 -0
- package/src/avatar/render/walker.ts +239 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +63 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +201 -92
- package/src/core/errors.ts +15 -2
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +94 -30
- package/src/core/hub.ts +228 -7
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +52 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +66 -23
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +4 -49
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/page.ts +14 -1
- package/src/core/rest/rest-client.ts +14 -3
- package/src/core/rest/routes.gen.ts +144 -88
- package/src/core/resume/hub-resume.ts +286 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/storage/json-guards.ts +6 -0
- package/src/core/turns/hub-turns.ts +161 -0
- package/src/core/turns/turn-game.ts +510 -0
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +127 -12
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +49 -11
- package/src/doctor/checks.ts +12 -4
- package/src/doctor/cli.ts +8 -2
- package/src/doctor/embed-check.ts +61 -0
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/doctor/world-check.ts +61 -0
- package/src/index.ts +20 -2
- package/src/input/focus-ring.ts +13 -0
- package/src/input/input-registry.ts +9 -2
- package/src/input/input-system.ts +14 -1
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +13 -4
- package/src/input/navigator.ts +11 -0
- package/src/input/zero-config.ts +1 -14
- package/src/input.ts +4 -0
- package/src/mp.ts +1 -1
- package/src/overlay/overlay.ts +30 -4
- package/src/overlay/styles.ts +4 -3
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +27 -2
- package/src/realtime/runtime-api.ts +33 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/types.ts +1 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +1 -1
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
|
@@ -0,0 +1,2411 @@
|
|
|
1
|
+
import { t as _defineProperty } from "./defineProperty.js";
|
|
2
|
+
import { decodeLook, encodeLook, normaliseHex, partName, resolveColour } from "@diffenderfer-games/hub-contract/avatar";
|
|
3
|
+
//#region src/avatar/look-like.ts
|
|
4
|
+
/** Turns anything a render accepts into a look (or `null` for the silhouette), caching decoded codes. */
|
|
5
|
+
const decoded = /* @__PURE__ */ new Map();
|
|
6
|
+
const DECODE_CACHE = 512;
|
|
7
|
+
/** Decodes a code once (an LRU of 512). */
|
|
8
|
+
function fromCode(code) {
|
|
9
|
+
const hit = decoded.get(code);
|
|
10
|
+
if (hit !== void 0 || decoded.has(code)) return hit ?? null;
|
|
11
|
+
const look = decodeLook(code)?.look ?? null;
|
|
12
|
+
if (decoded.size >= DECODE_CACHE) {
|
|
13
|
+
const oldest = decoded.keys().next().value;
|
|
14
|
+
if (oldest !== void 0) decoded.delete(oldest);
|
|
15
|
+
}
|
|
16
|
+
decoded.set(code, look);
|
|
17
|
+
return look;
|
|
18
|
+
}
|
|
19
|
+
/** The look a {@link LookLike} names, or `null` (hidden, malformed, missing). */
|
|
20
|
+
function toLook(like) {
|
|
21
|
+
if (like === null || like === void 0 || like === "") return null;
|
|
22
|
+
if (typeof like === "string") return fromCode(like);
|
|
23
|
+
if ("v" in like) return like;
|
|
24
|
+
if ("look" in like) return like.look;
|
|
25
|
+
return fromCode(like.code);
|
|
26
|
+
}
|
|
27
|
+
/** The code a {@link LookLike} names (`''` for none). */
|
|
28
|
+
function toCode(like) {
|
|
29
|
+
if (typeof like === "string") return like;
|
|
30
|
+
if (like && "code" in like) return like.code;
|
|
31
|
+
const look = toLook(like);
|
|
32
|
+
return look ? encodeLook(look) : "";
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/avatar/colour.ts
|
|
36
|
+
const toLin = (c) => {
|
|
37
|
+
const v = c / 255;
|
|
38
|
+
return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
39
|
+
};
|
|
40
|
+
const fromLin = (c) => {
|
|
41
|
+
const v = c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055;
|
|
42
|
+
return Math.round(Math.min(1, Math.max(0, v)) * 255);
|
|
43
|
+
};
|
|
44
|
+
/** A `#rrggbb` colour in OKLab. */
|
|
45
|
+
function hexToLab(hex) {
|
|
46
|
+
const r = toLin(parseInt(hex.slice(1, 3), 16));
|
|
47
|
+
const g = toLin(parseInt(hex.slice(3, 5), 16));
|
|
48
|
+
const bl = toLin(parseInt(hex.slice(5, 7), 16));
|
|
49
|
+
const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * bl);
|
|
50
|
+
const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * bl);
|
|
51
|
+
const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * bl);
|
|
52
|
+
return {
|
|
53
|
+
L: .2104542553 * l + .793617785 * m - .0040720468 * s,
|
|
54
|
+
a: 1.9779984951 * l - 2.428592205 * m + .4505937099 * s,
|
|
55
|
+
b: .0259040371 * l + .7827717662 * m - .808675766 * s
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** An OKLab colour as `#rrggbb` (clipped to sRGB). */
|
|
59
|
+
function labToHex(lab) {
|
|
60
|
+
const { a, b } = lab;
|
|
61
|
+
const l = (lab.L + .3963377774 * a + .2158037573 * b) ** 3;
|
|
62
|
+
const m = (lab.L - .1055613458 * a - .0638541728 * b) ** 3;
|
|
63
|
+
const s = (lab.L - .0894841775 * a - 1.291485548 * b) ** 3;
|
|
64
|
+
return `#${[
|
|
65
|
+
fromLin(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
|
|
66
|
+
fromLin(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
|
|
67
|
+
fromLin(-.0041960863 * l - .7034186147 * m + 1.707614701 * s)
|
|
68
|
+
].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
69
|
+
}
|
|
70
|
+
/** Shifts lightness by `dL` (OKLab L units) and scales chroma. */
|
|
71
|
+
function adjust(hex, dL, chroma = 1) {
|
|
72
|
+
const c = hexToLab(hex);
|
|
73
|
+
return labToHex({
|
|
74
|
+
L: Math.min(.98, Math.max(.03, c.L + dL)),
|
|
75
|
+
a: c.a * chroma,
|
|
76
|
+
b: c.b * chroma
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** The one shade tone of a fill. */
|
|
80
|
+
const shade = (hex) => adjust(hex, -.1, 1.08);
|
|
81
|
+
/** The one highlight tone of a fill. */
|
|
82
|
+
const light = (hex) => adjust(hex, .09, .9);
|
|
83
|
+
/** Style C's tonal outline: a darker shade of the fill's own colour (§14.1). */
|
|
84
|
+
const outline = (hex) => adjust(hex, -.24, 1.1);
|
|
85
|
+
/** A dark ink in the fill's own hue (status props keep a strong edge). */
|
|
86
|
+
function ink(hex) {
|
|
87
|
+
const c = hexToLab(hex);
|
|
88
|
+
return labToHex({
|
|
89
|
+
L: Math.min(.24, c.L * .42),
|
|
90
|
+
a: c.a * .6,
|
|
91
|
+
b: c.b * .6
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Linear mix in OKLab. */
|
|
95
|
+
function mix$1(a, b, t) {
|
|
96
|
+
const lhs = hexToLab(a);
|
|
97
|
+
const rhs = hexToLab(b);
|
|
98
|
+
return labToHex({
|
|
99
|
+
L: lhs.L + (rhs.L - lhs.L) * t,
|
|
100
|
+
a: lhs.a + (rhs.a - lhs.a) * t,
|
|
101
|
+
b: lhs.b + (rhs.b - lhs.b) * t
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** OKLab lightness, 0 … 1. */
|
|
105
|
+
const lightness = (hex) => hexToLab(hex).L;
|
|
106
|
+
/** Perceptual distance (OKLab ΔE × 100). */
|
|
107
|
+
function deltaE(a, b) {
|
|
108
|
+
const lhs = hexToLab(a);
|
|
109
|
+
const rhs = hexToLab(b);
|
|
110
|
+
return Math.hypot(lhs.L - rhs.L, lhs.a - rhs.a, lhs.b - rhs.b) * 100;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Keeps brows and facial hair visible on the skin (§14.2): a colour within
|
|
114
|
+
* ΔE 12 of the skin is darkened by 0.18 L.
|
|
115
|
+
*/
|
|
116
|
+
function keepApart(colour, skin) {
|
|
117
|
+
return deltaE(colour, skin) >= 12 ? colour : adjust(colour, -.18);
|
|
118
|
+
}
|
|
119
|
+
/** WCAG relative luminance of a `#rrggbb`. */
|
|
120
|
+
function luminance(hex) {
|
|
121
|
+
const r = toLin(parseInt(hex.slice(1, 3), 16));
|
|
122
|
+
const g = toLin(parseInt(hex.slice(3, 5), 16));
|
|
123
|
+
const b = toLin(parseInt(hex.slice(5, 7), 16));
|
|
124
|
+
return .2126 * r + .7152 * g + .0722 * b;
|
|
125
|
+
}
|
|
126
|
+
/** WCAG contrast ratio between two colours. */
|
|
127
|
+
function contrast(a, b) {
|
|
128
|
+
const lhs = luminance(a);
|
|
129
|
+
const rhs = luminance(b);
|
|
130
|
+
return (Math.max(lhs, rhs) + .05) / (Math.min(lhs, rhs) + .05);
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/avatar/scene.ts
|
|
134
|
+
/** A group helper that drops empty groups' wrapping. */
|
|
135
|
+
function g(children, opts = {}) {
|
|
136
|
+
return {
|
|
137
|
+
k: "g",
|
|
138
|
+
...opts,
|
|
139
|
+
children
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** A path helper. */
|
|
143
|
+
function path(d, paint, tf) {
|
|
144
|
+
return {
|
|
145
|
+
k: "path",
|
|
146
|
+
d,
|
|
147
|
+
...paint,
|
|
148
|
+
...tf ? { tf } : {}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** An ellipse helper. */
|
|
152
|
+
function ellipse(cx, cy, rx, ry, paint, tf) {
|
|
153
|
+
return {
|
|
154
|
+
k: "ellipse",
|
|
155
|
+
cx,
|
|
156
|
+
cy,
|
|
157
|
+
rx,
|
|
158
|
+
ry,
|
|
159
|
+
...paint,
|
|
160
|
+
...tf ? { tf } : {}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** A rect helper. */
|
|
164
|
+
function rect(left, top, w, h, paint, r) {
|
|
165
|
+
return {
|
|
166
|
+
k: "rect",
|
|
167
|
+
x: left,
|
|
168
|
+
y: top,
|
|
169
|
+
w,
|
|
170
|
+
h,
|
|
171
|
+
...paint,
|
|
172
|
+
...r === void 0 ? {} : { r }
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** A translate step. */
|
|
176
|
+
const tr = (dx, dy) => ({
|
|
177
|
+
k: "t",
|
|
178
|
+
x: dx,
|
|
179
|
+
y: dy
|
|
180
|
+
});
|
|
181
|
+
/** A scale step (uniform when `sy` is omitted). */
|
|
182
|
+
const sc = (sx, sy = sx) => ({
|
|
183
|
+
k: "s",
|
|
184
|
+
x: sx,
|
|
185
|
+
y: sy
|
|
186
|
+
});
|
|
187
|
+
/** A rotate step, degrees. */
|
|
188
|
+
const rot = (a) => ({
|
|
189
|
+
k: "r",
|
|
190
|
+
a
|
|
191
|
+
});
|
|
192
|
+
/** A horizontal skew step, degrees. */
|
|
193
|
+
const skewX = (a) => ({
|
|
194
|
+
k: "skx",
|
|
195
|
+
a
|
|
196
|
+
});
|
|
197
|
+
/** A round-joined stroke paint. */
|
|
198
|
+
function line(colour, width, cap = "round", opacity) {
|
|
199
|
+
return {
|
|
200
|
+
fill: "none",
|
|
201
|
+
stroke: colour,
|
|
202
|
+
sw: width,
|
|
203
|
+
cap,
|
|
204
|
+
...opacity === void 0 ? {} : { opacity }
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/avatar/render/frame.ts
|
|
209
|
+
/**
|
|
210
|
+
* Frames (§3.4): which part of rig space an output shows, and the badge
|
|
211
|
+
* background (the site's rounded panel with a ring that lightens on dark
|
|
212
|
+
* skin, §3.9).
|
|
213
|
+
*/
|
|
214
|
+
/** The three frames' boxes. */
|
|
215
|
+
const FRAMES = {
|
|
216
|
+
head: [
|
|
217
|
+
-66,
|
|
218
|
+
-164,
|
|
219
|
+
132,
|
|
220
|
+
132
|
|
221
|
+
],
|
|
222
|
+
bust: [
|
|
223
|
+
-84,
|
|
224
|
+
-188,
|
|
225
|
+
168,
|
|
226
|
+
168
|
|
227
|
+
],
|
|
228
|
+
full: [
|
|
229
|
+
-86,
|
|
230
|
+
-196,
|
|
231
|
+
172,
|
|
232
|
+
206
|
|
233
|
+
]
|
|
234
|
+
};
|
|
235
|
+
/** The walker's full frame: a short, chunky figure, with room for hats, jumps and feet dangling below a seat. */
|
|
236
|
+
const WALKER_FULL = [
|
|
237
|
+
-60,
|
|
238
|
+
-144,
|
|
239
|
+
120,
|
|
240
|
+
170
|
|
241
|
+
];
|
|
242
|
+
/** True when options draw the walker figure (full frames only). */
|
|
243
|
+
function isWalker(opts) {
|
|
244
|
+
return opts.figure === "walker" && frameOf(opts) === "full";
|
|
245
|
+
}
|
|
246
|
+
/** The box options draw, in rig units. */
|
|
247
|
+
function frameBox(opts) {
|
|
248
|
+
return isWalker(opts) ? WALKER_FULL : FRAMES[frameOf(opts)];
|
|
249
|
+
}
|
|
250
|
+
/** The site panel colour behind badges. */
|
|
251
|
+
const PANEL = "#11141c";
|
|
252
|
+
/** The frame an options object asks for (default `head`). */
|
|
253
|
+
function frameOf(opts) {
|
|
254
|
+
return opts.frame ?? "head";
|
|
255
|
+
}
|
|
256
|
+
/** Output pixel size for a frame (the longer edge is `size`). */
|
|
257
|
+
function pixelSize(opts) {
|
|
258
|
+
const [, , w, h] = frameBox(opts);
|
|
259
|
+
const scale = (opts.size ?? 48) / Math.max(w, h);
|
|
260
|
+
return {
|
|
261
|
+
width: w * scale,
|
|
262
|
+
height: h * scale,
|
|
263
|
+
scale
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/** The badge ring colour: lighter when the skin is darker than the panel, so every skin has an edge. */
|
|
267
|
+
function ringColour(look) {
|
|
268
|
+
return look && lightness(resolveColour(look.skin)) < .42 ? "#6f7787" : "#2a3142";
|
|
269
|
+
}
|
|
270
|
+
/** Wraps the avatar's nodes in the frame's background (a badge clips to the rounded panel). */
|
|
271
|
+
function framed(nodes, look, opts) {
|
|
272
|
+
const [left, top, w, h] = frameBox(opts);
|
|
273
|
+
const background = opts.background ?? (frameOf(opts) === "head" ? "badge" : "none");
|
|
274
|
+
if (background === "badge") {
|
|
275
|
+
const r = w * .24;
|
|
276
|
+
return [
|
|
277
|
+
rect(left, top, w, h, { fill: PANEL }, r),
|
|
278
|
+
g(nodes, { clip: { shapes: [rect(left, top, w, h, {}, r)] } }),
|
|
279
|
+
rect(left + 1.5, top + 1.5, w - 3, h - 3, {
|
|
280
|
+
fill: "none",
|
|
281
|
+
stroke: ringColour(look),
|
|
282
|
+
sw: 3
|
|
283
|
+
}, r - 1.5)
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
if (background === "none") return [...nodes];
|
|
287
|
+
const fill = normaliseHex(background);
|
|
288
|
+
return fill ? [rect(left, top, w, h, { fill }), ...nodes] : [...nodes];
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/avatar/art-store.ts
|
|
292
|
+
let art = null;
|
|
293
|
+
let loading = null;
|
|
294
|
+
const waiters = /* @__PURE__ */ new Set();
|
|
295
|
+
/** The loaded art, or `null` before {@link ready} resolves. */
|
|
296
|
+
function currentArt() {
|
|
297
|
+
return art;
|
|
298
|
+
}
|
|
299
|
+
/** Loads the art chunk (once). Resolves when rendering is synchronous. */
|
|
300
|
+
function ready() {
|
|
301
|
+
loading ?? (loading = import("./art.js").then((mod) => {
|
|
302
|
+
art = mod.ART;
|
|
303
|
+
for (const waiter of waiters) waiter();
|
|
304
|
+
waiters.clear();
|
|
305
|
+
}));
|
|
306
|
+
return loading;
|
|
307
|
+
}
|
|
308
|
+
/** Calls `fn` once the art has loaded (now, when it already has). */
|
|
309
|
+
function onArtReady(fn) {
|
|
310
|
+
if (art) {
|
|
311
|
+
fn();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
waiters.add(fn);
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/avatar/face.ts
|
|
318
|
+
const brow$1 = (dy = 0, tilt = 0, arch = 2.5) => ({
|
|
319
|
+
dy,
|
|
320
|
+
tilt,
|
|
321
|
+
arch
|
|
322
|
+
});
|
|
323
|
+
const mouth$1 = (m) => ({
|
|
324
|
+
w: 13,
|
|
325
|
+
sL: 1.5,
|
|
326
|
+
sR: 1.5,
|
|
327
|
+
open: 0,
|
|
328
|
+
round: 0,
|
|
329
|
+
teeth: 0,
|
|
330
|
+
tongue: 0,
|
|
331
|
+
dx: 0,
|
|
332
|
+
...m
|
|
333
|
+
});
|
|
334
|
+
const BASE = {
|
|
335
|
+
browL: brow$1(),
|
|
336
|
+
browR: brow$1(),
|
|
337
|
+
lidL: .12,
|
|
338
|
+
lidR: .12,
|
|
339
|
+
lowL: 0,
|
|
340
|
+
lowR: 0,
|
|
341
|
+
lidTilt: 0,
|
|
342
|
+
closedCurveL: 1,
|
|
343
|
+
closedCurveR: 1,
|
|
344
|
+
pupil: 1,
|
|
345
|
+
mouth: mouth$1({}),
|
|
346
|
+
cheeks: .25,
|
|
347
|
+
headTilt: 0,
|
|
348
|
+
squash: 0,
|
|
349
|
+
extras: []
|
|
350
|
+
};
|
|
351
|
+
const face = (f) => ({
|
|
352
|
+
...BASE,
|
|
353
|
+
...f
|
|
354
|
+
});
|
|
355
|
+
/** The launch set of 16 expressions (§3.5). Kid-friendly: angry and shocked stay cartoon-cute, cry is one tear. */
|
|
356
|
+
const EXPRESSIONS = {
|
|
357
|
+
neutral: BASE,
|
|
358
|
+
happy: face({
|
|
359
|
+
browL: brow$1(-2, 0, 3.5),
|
|
360
|
+
browR: brow$1(-2, 0, 3.5),
|
|
361
|
+
lowL: .22,
|
|
362
|
+
lowR: .22,
|
|
363
|
+
mouth: mouth$1({
|
|
364
|
+
w: 19,
|
|
365
|
+
sL: 5,
|
|
366
|
+
sR: 5,
|
|
367
|
+
open: 7,
|
|
368
|
+
teeth: .5,
|
|
369
|
+
tongue: .6
|
|
370
|
+
}),
|
|
371
|
+
cheeks: .65
|
|
372
|
+
}),
|
|
373
|
+
laugh: face({
|
|
374
|
+
browL: brow$1(-4, -1, 4),
|
|
375
|
+
browR: brow$1(-4, -1, 4),
|
|
376
|
+
lidL: 1,
|
|
377
|
+
lidR: 1,
|
|
378
|
+
mouth: mouth$1({
|
|
379
|
+
w: 22,
|
|
380
|
+
sL: 6,
|
|
381
|
+
sR: 6,
|
|
382
|
+
open: 13,
|
|
383
|
+
teeth: .45,
|
|
384
|
+
tongue: .9
|
|
385
|
+
}),
|
|
386
|
+
cheeks: .9,
|
|
387
|
+
headTilt: -6,
|
|
388
|
+
squash: .03,
|
|
389
|
+
extras: ["sparkle"]
|
|
390
|
+
}),
|
|
391
|
+
grin: face({
|
|
392
|
+
browL: brow$1(-2.5, 0, 3.5),
|
|
393
|
+
browR: brow$1(-2.5, 0, 3.5),
|
|
394
|
+
lowL: .3,
|
|
395
|
+
lowR: .3,
|
|
396
|
+
mouth: mouth$1({
|
|
397
|
+
w: 21,
|
|
398
|
+
sL: 5.5,
|
|
399
|
+
sR: 5.5,
|
|
400
|
+
open: 4.5,
|
|
401
|
+
teeth: 1
|
|
402
|
+
}),
|
|
403
|
+
cheeks: .7
|
|
404
|
+
}),
|
|
405
|
+
sad: face({
|
|
406
|
+
browL: brow$1(1, -4.5, 1),
|
|
407
|
+
browR: brow$1(1, -4.5, 1),
|
|
408
|
+
lidL: .3,
|
|
409
|
+
lidR: .3,
|
|
410
|
+
lidTilt: -3,
|
|
411
|
+
mouth: mouth$1({
|
|
412
|
+
w: 12,
|
|
413
|
+
sL: -4,
|
|
414
|
+
sR: -4
|
|
415
|
+
}),
|
|
416
|
+
cheeks: .1,
|
|
417
|
+
headTilt: 4
|
|
418
|
+
}),
|
|
419
|
+
cry: face({
|
|
420
|
+
browL: brow$1(1, -5.5, 1),
|
|
421
|
+
browR: brow$1(1, -5.5, 1),
|
|
422
|
+
lidL: .4,
|
|
423
|
+
lidR: .4,
|
|
424
|
+
lidTilt: -3,
|
|
425
|
+
lowL: .15,
|
|
426
|
+
lowR: .15,
|
|
427
|
+
mouth: mouth$1({
|
|
428
|
+
w: 13,
|
|
429
|
+
sL: -4.5,
|
|
430
|
+
sR: -4.5,
|
|
431
|
+
open: 3
|
|
432
|
+
}),
|
|
433
|
+
cheeks: .4,
|
|
434
|
+
headTilt: 5,
|
|
435
|
+
extras: ["tear"]
|
|
436
|
+
}),
|
|
437
|
+
surprised: face({
|
|
438
|
+
browL: brow$1(-7, -1, 4),
|
|
439
|
+
browR: brow$1(-7, -1, 4),
|
|
440
|
+
lidL: 0,
|
|
441
|
+
lidR: 0,
|
|
442
|
+
pupil: .8,
|
|
443
|
+
mouth: mouth$1({
|
|
444
|
+
w: 9,
|
|
445
|
+
sL: 0,
|
|
446
|
+
sR: 0,
|
|
447
|
+
open: 11,
|
|
448
|
+
round: 1,
|
|
449
|
+
tongue: .3
|
|
450
|
+
}),
|
|
451
|
+
cheeks: .2,
|
|
452
|
+
squash: -.05
|
|
453
|
+
}),
|
|
454
|
+
shocked: face({
|
|
455
|
+
browL: brow$1(-9, -2, 4.5),
|
|
456
|
+
browR: brow$1(-9, -2, 4.5),
|
|
457
|
+
lidL: 0,
|
|
458
|
+
lidR: 0,
|
|
459
|
+
pupil: .7,
|
|
460
|
+
mouth: mouth$1({
|
|
461
|
+
w: 11,
|
|
462
|
+
sL: -1,
|
|
463
|
+
sR: -1,
|
|
464
|
+
open: 14,
|
|
465
|
+
round: .8,
|
|
466
|
+
tongue: .4
|
|
467
|
+
}),
|
|
468
|
+
cheeks: .15,
|
|
469
|
+
squash: -.07,
|
|
470
|
+
extras: ["sweat"]
|
|
471
|
+
}),
|
|
472
|
+
angry: face({
|
|
473
|
+
browL: brow$1(2, 5.5, 0),
|
|
474
|
+
browR: brow$1(2, 5.5, 0),
|
|
475
|
+
lidL: .32,
|
|
476
|
+
lidR: .32,
|
|
477
|
+
lidTilt: 4,
|
|
478
|
+
mouth: mouth$1({
|
|
479
|
+
w: 15,
|
|
480
|
+
sL: -2.5,
|
|
481
|
+
sR: -2.5,
|
|
482
|
+
open: 3.5,
|
|
483
|
+
teeth: .9
|
|
484
|
+
}),
|
|
485
|
+
cheeks: .35,
|
|
486
|
+
extras: ["anger"]
|
|
487
|
+
}),
|
|
488
|
+
grumpy: face({
|
|
489
|
+
browL: brow$1(2, 3.5, .5),
|
|
490
|
+
browR: brow$1(2, 3.5, .5),
|
|
491
|
+
lidL: .42,
|
|
492
|
+
lidR: .42,
|
|
493
|
+
lidTilt: 2,
|
|
494
|
+
mouth: mouth$1({
|
|
495
|
+
w: 12,
|
|
496
|
+
sL: -2,
|
|
497
|
+
sR: -2.5,
|
|
498
|
+
dx: -1
|
|
499
|
+
}),
|
|
500
|
+
cheeks: .1,
|
|
501
|
+
headTilt: -3
|
|
502
|
+
}),
|
|
503
|
+
thinking: face({
|
|
504
|
+
browL: brow$1(-5, -1.5, 3.5),
|
|
505
|
+
browR: brow$1(1.5, 3, .5),
|
|
506
|
+
lidL: .1,
|
|
507
|
+
lidR: .3,
|
|
508
|
+
mouth: mouth$1({
|
|
509
|
+
w: 9,
|
|
510
|
+
sL: -.5,
|
|
511
|
+
sR: 1.5,
|
|
512
|
+
dx: 4
|
|
513
|
+
}),
|
|
514
|
+
cheeks: .15,
|
|
515
|
+
headTilt: -5,
|
|
516
|
+
extras: ["question"]
|
|
517
|
+
}),
|
|
518
|
+
wink: face({
|
|
519
|
+
browL: brow$1(-2, 0, 3.5),
|
|
520
|
+
browR: brow$1(1.5, 1.5, 1.5),
|
|
521
|
+
lidR: 1,
|
|
522
|
+
lowL: .15,
|
|
523
|
+
mouth: mouth$1({
|
|
524
|
+
w: 16,
|
|
525
|
+
sL: 2,
|
|
526
|
+
sR: 5.5,
|
|
527
|
+
dx: 1.5
|
|
528
|
+
}),
|
|
529
|
+
cheeks: .6,
|
|
530
|
+
headTilt: 3,
|
|
531
|
+
extras: ["sparkle"]
|
|
532
|
+
}),
|
|
533
|
+
smug: face({
|
|
534
|
+
browL: brow$1(0, 1, 1.5),
|
|
535
|
+
browR: brow$1(-4, -1, 3.5),
|
|
536
|
+
lidL: .45,
|
|
537
|
+
lidR: .4,
|
|
538
|
+
lowL: .1,
|
|
539
|
+
lowR: .1,
|
|
540
|
+
mouth: mouth$1({
|
|
541
|
+
w: 14,
|
|
542
|
+
sL: 1,
|
|
543
|
+
sR: 5,
|
|
544
|
+
dx: 3
|
|
545
|
+
}),
|
|
546
|
+
cheeks: .45,
|
|
547
|
+
headTilt: 4
|
|
548
|
+
}),
|
|
549
|
+
worried: face({
|
|
550
|
+
browL: brow$1(-2, -5, 2),
|
|
551
|
+
browR: brow$1(-2, -5, 2),
|
|
552
|
+
lidL: .05,
|
|
553
|
+
lidR: .05,
|
|
554
|
+
pupil: .9,
|
|
555
|
+
mouth: mouth$1({
|
|
556
|
+
w: 11,
|
|
557
|
+
sL: -1.5,
|
|
558
|
+
sR: -1,
|
|
559
|
+
open: 1.5
|
|
560
|
+
}),
|
|
561
|
+
cheeks: .1,
|
|
562
|
+
extras: ["sweat"]
|
|
563
|
+
}),
|
|
564
|
+
sleepy: face({
|
|
565
|
+
browL: brow$1(1, -1, 1.5),
|
|
566
|
+
browR: brow$1(1, -1, 1.5),
|
|
567
|
+
lidL: .72,
|
|
568
|
+
lidR: .72,
|
|
569
|
+
closedCurveL: -1,
|
|
570
|
+
closedCurveR: -1,
|
|
571
|
+
mouth: mouth$1({
|
|
572
|
+
w: 7,
|
|
573
|
+
sL: 0,
|
|
574
|
+
sR: 0,
|
|
575
|
+
open: 2.5,
|
|
576
|
+
round: 1
|
|
577
|
+
}),
|
|
578
|
+
cheeks: .2,
|
|
579
|
+
headTilt: 7,
|
|
580
|
+
extras: ["zzz"]
|
|
581
|
+
}),
|
|
582
|
+
determined: face({
|
|
583
|
+
browL: brow$1(1, 3.5, .5),
|
|
584
|
+
browR: brow$1(1, 3.5, .5),
|
|
585
|
+
lidL: .22,
|
|
586
|
+
lidR: .22,
|
|
587
|
+
lidTilt: 2,
|
|
588
|
+
mouth: mouth$1({
|
|
589
|
+
w: 12,
|
|
590
|
+
sL: 1,
|
|
591
|
+
sR: 1
|
|
592
|
+
}),
|
|
593
|
+
cheeks: .25
|
|
594
|
+
})
|
|
595
|
+
};
|
|
596
|
+
/** Every expression name, in the launch order. */
|
|
597
|
+
const EXPRESSION_NAMES = [
|
|
598
|
+
"neutral",
|
|
599
|
+
"happy",
|
|
600
|
+
"laugh",
|
|
601
|
+
"grin",
|
|
602
|
+
"sad",
|
|
603
|
+
"cry",
|
|
604
|
+
"surprised",
|
|
605
|
+
"shocked",
|
|
606
|
+
"angry",
|
|
607
|
+
"grumpy",
|
|
608
|
+
"thinking",
|
|
609
|
+
"wink",
|
|
610
|
+
"smug",
|
|
611
|
+
"worried",
|
|
612
|
+
"sleepy",
|
|
613
|
+
"determined"
|
|
614
|
+
];
|
|
615
|
+
/** True for a known expression name. */
|
|
616
|
+
function isExpression(name) {
|
|
617
|
+
return Object.prototype.hasOwnProperty.call(EXPRESSIONS, name);
|
|
618
|
+
}
|
|
619
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
620
|
+
const mixBrow = (a, b, t) => ({
|
|
621
|
+
dy: lerp(a.dy, b.dy, t),
|
|
622
|
+
tilt: lerp(a.tilt, b.tilt, t),
|
|
623
|
+
arch: lerp(a.arch, b.arch, t)
|
|
624
|
+
});
|
|
625
|
+
const mixMouth = (a, b, t) => ({
|
|
626
|
+
w: lerp(a.w, b.w, t),
|
|
627
|
+
sL: lerp(a.sL, b.sL, t),
|
|
628
|
+
sR: lerp(a.sR, b.sR, t),
|
|
629
|
+
open: lerp(a.open, b.open, t),
|
|
630
|
+
round: lerp(a.round, b.round, t),
|
|
631
|
+
teeth: lerp(a.teeth, b.teeth, t),
|
|
632
|
+
tongue: lerp(a.tongue, b.tongue, t),
|
|
633
|
+
dx: lerp(a.dx, b.dx, t)
|
|
634
|
+
});
|
|
635
|
+
/** An expression's parameters (a name or parameters pass through). */
|
|
636
|
+
function faceOf(e) {
|
|
637
|
+
return typeof e === "string" ? EXPRESSIONS[e] : e;
|
|
638
|
+
}
|
|
639
|
+
/** Blends two expressions: `mix('happy', 'surprised', 0.4)`. */
|
|
640
|
+
function mix(a, b, t) {
|
|
641
|
+
const from = faceOf(a);
|
|
642
|
+
const to = faceOf(b);
|
|
643
|
+
return {
|
|
644
|
+
browL: mixBrow(from.browL, to.browL, t),
|
|
645
|
+
browR: mixBrow(from.browR, to.browR, t),
|
|
646
|
+
lidL: lerp(from.lidL, to.lidL, t),
|
|
647
|
+
lidR: lerp(from.lidR, to.lidR, t),
|
|
648
|
+
lowL: lerp(from.lowL, to.lowL, t),
|
|
649
|
+
lowR: lerp(from.lowR, to.lowR, t),
|
|
650
|
+
lidTilt: lerp(from.lidTilt, to.lidTilt, t),
|
|
651
|
+
closedCurveL: lerp(from.closedCurveL, to.closedCurveL, t),
|
|
652
|
+
closedCurveR: lerp(from.closedCurveR, to.closedCurveR, t),
|
|
653
|
+
pupil: lerp(from.pupil, to.pupil, t),
|
|
654
|
+
mouth: mixMouth(from.mouth, to.mouth, t),
|
|
655
|
+
cheeks: lerp(from.cheeks, to.cheeks, t),
|
|
656
|
+
headTilt: lerp(from.headTilt, to.headTilt, t),
|
|
657
|
+
squash: lerp(from.squash, to.squash, t),
|
|
658
|
+
extras: t < .5 ? from.extras : to.extras
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/** The hub's quick-chat emotes (`e:<emoji>`, the safety package's EMOTES) mapped to faces (§3.7), so an emote can also play on the sender's face. */
|
|
662
|
+
const EMOTE_FACES = {
|
|
663
|
+
"😀": "happy",
|
|
664
|
+
"😂": "laugh",
|
|
665
|
+
"😮": "surprised",
|
|
666
|
+
"😢": "sad",
|
|
667
|
+
"😎": "smug",
|
|
668
|
+
"🤔": "thinking",
|
|
669
|
+
"👍": "grin",
|
|
670
|
+
"👏": "happy",
|
|
671
|
+
"🎉": "laugh",
|
|
672
|
+
"❤️": "happy",
|
|
673
|
+
"🔥": "determined",
|
|
674
|
+
"⭐": "grin"
|
|
675
|
+
};
|
|
676
|
+
/** The face for a quick-chat emote (`e:😂` or the bare emoji), or `null`. */
|
|
677
|
+
function expressionFromEmote(emoteId) {
|
|
678
|
+
return EMOTE_FACES[emoteId.startsWith("e:") ? emoteId.slice(2) : emoteId] ?? null;
|
|
679
|
+
}
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region src/avatar/geom.ts
|
|
682
|
+
/** Rounds to one decimal and drops a trailing `.0` and negative zero. */
|
|
683
|
+
function n(v) {
|
|
684
|
+
const r = Math.round(v * 10) / 10;
|
|
685
|
+
return Object.is(r, -0) ? "0" : String(r);
|
|
686
|
+
}
|
|
687
|
+
/** A path of absolute M/L/C/Q/Z commands that can be warped as a whole. */
|
|
688
|
+
var Path = class {
|
|
689
|
+
constructor() {
|
|
690
|
+
_defineProperty(this, "cmds", []);
|
|
691
|
+
}
|
|
692
|
+
m(px, py) {
|
|
693
|
+
this.cmds.push({
|
|
694
|
+
c: "M",
|
|
695
|
+
p: [px, py]
|
|
696
|
+
});
|
|
697
|
+
return this;
|
|
698
|
+
}
|
|
699
|
+
l(px, py) {
|
|
700
|
+
this.cmds.push({
|
|
701
|
+
c: "L",
|
|
702
|
+
p: [px, py]
|
|
703
|
+
});
|
|
704
|
+
return this;
|
|
705
|
+
}
|
|
706
|
+
c(x1, y1, x2, y2, px, py) {
|
|
707
|
+
this.cmds.push({
|
|
708
|
+
c: "C",
|
|
709
|
+
p: [
|
|
710
|
+
x1,
|
|
711
|
+
y1,
|
|
712
|
+
x2,
|
|
713
|
+
y2,
|
|
714
|
+
px,
|
|
715
|
+
py
|
|
716
|
+
]
|
|
717
|
+
});
|
|
718
|
+
return this;
|
|
719
|
+
}
|
|
720
|
+
q(x1, y1, px, py) {
|
|
721
|
+
this.cmds.push({
|
|
722
|
+
c: "Q",
|
|
723
|
+
p: [
|
|
724
|
+
x1,
|
|
725
|
+
y1,
|
|
726
|
+
px,
|
|
727
|
+
py
|
|
728
|
+
]
|
|
729
|
+
});
|
|
730
|
+
return this;
|
|
731
|
+
}
|
|
732
|
+
z() {
|
|
733
|
+
this.cmds.push({
|
|
734
|
+
c: "Z",
|
|
735
|
+
p: []
|
|
736
|
+
});
|
|
737
|
+
return this;
|
|
738
|
+
}
|
|
739
|
+
/** Appends every command of another path. */
|
|
740
|
+
add(other) {
|
|
741
|
+
this.cmds.push(...other.cmds);
|
|
742
|
+
return this;
|
|
743
|
+
}
|
|
744
|
+
/** The lowest y of any point (control points included, so it never under-reports). */
|
|
745
|
+
bottom() {
|
|
746
|
+
return this.cmds.reduce((low, { p }) => p.reduce((lo, v, i) => i % 2 === 1 ? Math.max(lo, v) : lo, low), -Infinity);
|
|
747
|
+
}
|
|
748
|
+
/** The SVG `d` string, optionally warping every point. */
|
|
749
|
+
d(warp) {
|
|
750
|
+
return this.cmds.map(({ c, p }) => {
|
|
751
|
+
if (c === "Z") return "Z";
|
|
752
|
+
const out = [];
|
|
753
|
+
for (let i = 0; i < p.length; i += 2) {
|
|
754
|
+
const px = p[i] ?? 0;
|
|
755
|
+
const py = p[i + 1] ?? 0;
|
|
756
|
+
const [wx, wy] = warp ? warp(px, py) : [px, py];
|
|
757
|
+
out.push(`${n(wx)} ${n(wy)}`);
|
|
758
|
+
}
|
|
759
|
+
return c + out.join(" ");
|
|
760
|
+
}).join("");
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
/** A point by index, or the origin. */
|
|
764
|
+
const at = (pts, i) => pts[i] ?? [0, 0];
|
|
765
|
+
/**
|
|
766
|
+
* A smooth Catmull-Rom curve through the points (cubic segments). Points
|
|
767
|
+
* flagged `'c'` become sharp corners.
|
|
768
|
+
*/
|
|
769
|
+
function blob(pts, isClosed = true, tension = 1, into = new Path()) {
|
|
770
|
+
const count = pts.length;
|
|
771
|
+
if (count === 0) return into;
|
|
772
|
+
const wrap = (i) => isClosed ? at(pts, (i + count) % count) : at(pts, Math.max(0, Math.min(count - 1, i)));
|
|
773
|
+
const tangent = (i) => {
|
|
774
|
+
if (wrap(i)[2] === "c" || !isClosed && (i === 0 || i === count - 1)) return [0, 0];
|
|
775
|
+
const a = wrap(i - 1);
|
|
776
|
+
const b = wrap(i + 1);
|
|
777
|
+
return [(b[0] - a[0]) * tension / 6, (b[1] - a[1]) * tension / 6];
|
|
778
|
+
};
|
|
779
|
+
const first = at(pts, 0);
|
|
780
|
+
into.m(first[0], first[1]);
|
|
781
|
+
const segs = isClosed ? count : count - 1;
|
|
782
|
+
for (let i = 0; i < segs; i += 1) {
|
|
783
|
+
const p1 = wrap(i);
|
|
784
|
+
const p2 = wrap(i + 1);
|
|
785
|
+
const t1 = tangent(i);
|
|
786
|
+
const t2 = tangent(i + 1);
|
|
787
|
+
into.c(p1[0] + t1[0], p1[1] + t1[1], p2[0] - t2[0], p2[1] - t2[1], p2[0], p2[1]);
|
|
788
|
+
}
|
|
789
|
+
if (isClosed) into.z();
|
|
790
|
+
return into;
|
|
791
|
+
}
|
|
792
|
+
/** Points along an ellipse arc; angles in degrees, 0 = right, 90 = down. */
|
|
793
|
+
function arc(cx, cy, rx, ry, a0, a1, steps) {
|
|
794
|
+
const out = [];
|
|
795
|
+
for (let i = 0; i <= steps; i += 1) {
|
|
796
|
+
const a = (a0 + (a1 - a0) * i / steps) * Math.PI / 180;
|
|
797
|
+
out.push([cx + rx * Math.cos(a), cy + ry * Math.sin(a)]);
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* A scalloped edge: one outward bump between each pair of points (quadratic
|
|
803
|
+
* curves), for curls and clouds. `side` 1 bumps to the left of travel.
|
|
804
|
+
*/
|
|
805
|
+
function scallop(pts, bump, isClosed, side = 1, into = new Path()) {
|
|
806
|
+
const count = pts.length;
|
|
807
|
+
const first = at(pts, 0);
|
|
808
|
+
into.m(first[0], first[1]);
|
|
809
|
+
const segs = isClosed ? count : count - 1;
|
|
810
|
+
for (let i = 0; i < segs; i += 1) {
|
|
811
|
+
const a = at(pts, i);
|
|
812
|
+
const b = at(pts, (i + 1) % count);
|
|
813
|
+
const dx = b[0] - a[0];
|
|
814
|
+
const dy = b[1] - a[1];
|
|
815
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
816
|
+
into.q((a[0] + b[0]) / 2 + dy / len * bump * side, (a[1] + b[1]) / 2 + -dx / len * bump * side, b[0], b[1]);
|
|
817
|
+
}
|
|
818
|
+
if (isClosed) into.z();
|
|
819
|
+
return into;
|
|
820
|
+
}
|
|
821
|
+
/** A rounded rectangle path. */
|
|
822
|
+
function roundedRect(px, py, w, h, r) {
|
|
823
|
+
return new Path().m(px + r, py).l(px + w - r, py).q(px + w, py, px + w, py + r).l(px + w, py + h - r).q(px + w, py + h, px + w - r, py + h).l(px + r, py + h).q(px, py + h, px, py + h - r).l(px, py + r).q(px, py, px + r, py).z();
|
|
824
|
+
}
|
|
825
|
+
/** Clamps `v` to [lo, hi]. */
|
|
826
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region src/avatar/render/colours.ts
|
|
829
|
+
/**
|
|
830
|
+
* Resolves a look's colours into every tint slot the art uses (§3.2): each
|
|
831
|
+
* fill gets its shade, highlight and tonal outline (style C, §14.1), brows
|
|
832
|
+
* and beards are kept apart from the skin (§14.2), and accessories default
|
|
833
|
+
* to the body colour.
|
|
834
|
+
*/
|
|
835
|
+
/** The soft dark-brown ink for lids, mouths and closed eyes (§14.1). */
|
|
836
|
+
const SOFT_INK = "#3a2a2a";
|
|
837
|
+
/** The fixed colours every look shares. */
|
|
838
|
+
const FIXED = {
|
|
839
|
+
ink: SOFT_INK,
|
|
840
|
+
white: "#fbfaf6",
|
|
841
|
+
mouth: "#5a1d2a",
|
|
842
|
+
tongue: "#ff7d8e",
|
|
843
|
+
blush: "#ff7b8f",
|
|
844
|
+
pupil: "#140f0d"
|
|
845
|
+
};
|
|
846
|
+
/** A fill and its tones. */
|
|
847
|
+
function tones(hex) {
|
|
848
|
+
return {
|
|
849
|
+
base: hex,
|
|
850
|
+
shade: shade(hex),
|
|
851
|
+
light: light(hex),
|
|
852
|
+
ink: outline(hex)
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
/** The colours of a look. */
|
|
856
|
+
function lookColours(look) {
|
|
857
|
+
const skin = resolveColour(look.skin);
|
|
858
|
+
const hair = resolveColour(look.hair.colour);
|
|
859
|
+
const browBase = look.brows.colour === void 0 ? lightness(hair) > .78 ? adjust(hair, -.22) : adjust(hair, -.05) : resolveColour(look.brows.colour);
|
|
860
|
+
const beard = keepApart(look.facialHair.colour === void 0 ? hair : resolveColour(look.facialHair.colour), skin);
|
|
861
|
+
const eye = resolveColour(look.eyes.colour);
|
|
862
|
+
const body = resolveColour(look.body.colour);
|
|
863
|
+
return {
|
|
864
|
+
skin,
|
|
865
|
+
skinShade: shade(skin),
|
|
866
|
+
skinLight: light(skin),
|
|
867
|
+
skinInk: outline(skin),
|
|
868
|
+
eyeRim: adjust(skin, -.2, 1.1),
|
|
869
|
+
hair,
|
|
870
|
+
hairShade: shade(hair),
|
|
871
|
+
hairLight: light(hair),
|
|
872
|
+
hairInk: outline(hair),
|
|
873
|
+
hairBuzz: mix$1(hair, skin, .42),
|
|
874
|
+
brow: keepApart(browBase, skin),
|
|
875
|
+
beard,
|
|
876
|
+
beardShade: shade(beard),
|
|
877
|
+
beardInk: outline(beard),
|
|
878
|
+
stubble: mix$1(beard, skin, .35),
|
|
879
|
+
eye,
|
|
880
|
+
eyeDark: adjust(eye, -.18),
|
|
881
|
+
body,
|
|
882
|
+
bodyShade: shade(body),
|
|
883
|
+
bodyLight: light(body),
|
|
884
|
+
bodyInk: outline(body)
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/** An accessory's colour: its own, else the body colour. */
|
|
888
|
+
function accessoryColour(look, accessory) {
|
|
889
|
+
return accessory?.colour === void 0 ? resolveColour(look.body.colour) : resolveColour(accessory.colour);
|
|
890
|
+
}
|
|
891
|
+
/** Style C's outline width at the 100-unit head height (§14.1). */
|
|
892
|
+
const LINE = 2.4;
|
|
893
|
+
/** Part fills are outlined a little thinner than lines (the POC's style C). */
|
|
894
|
+
const FILL_OUTLINE = .8;
|
|
895
|
+
/** The head outline (centre 0,0; 100 units tall). */
|
|
896
|
+
const HEAD_PATH = new Path().m(0, -50).c(27, -50, 46, -32, 46, -4).c(46, 24, 30, 50, 0, 50).c(-30, 50, -46, 24, -46, -4).c(-46, -32, -27, -50, 0, -50).z();
|
|
897
|
+
/** The head as a clip region. */
|
|
898
|
+
const HEAD_CLIP = { shapes: [path(HEAD_PATH.d(), {})] };
|
|
899
|
+
/** Where a face feature centred at `x0` lands after the head turn. */
|
|
900
|
+
function feat(ctx, x0) {
|
|
901
|
+
const t0 = Math.asin(clamp(x0 / 46, -1, 1));
|
|
902
|
+
const t = t0 + ctx.phi;
|
|
903
|
+
return {
|
|
904
|
+
x: 46 * Math.sin(t),
|
|
905
|
+
sx: Math.max(.05, Math.cos(t) / Math.cos(t0)),
|
|
906
|
+
vis: Math.cos(t) > .22
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* A warp that wraps a part around the skull by `phi`. Unlike a strict
|
|
911
|
+
* cylinder, the far side is squeezed toward the silhouette instead of
|
|
912
|
+
* folding behind it, so outlines never collapse into wedges (§14.2).
|
|
913
|
+
*/
|
|
914
|
+
function turnWarp(phi, dy = 0) {
|
|
915
|
+
if (phi === 0 && dy === 0) return;
|
|
916
|
+
const half = Math.PI / 2;
|
|
917
|
+
return (px, py) => {
|
|
918
|
+
const ax = Math.abs(px);
|
|
919
|
+
const sg = Math.sign(px) || 1;
|
|
920
|
+
const t0 = Math.asin(Math.min(ax, 46) / 46) * sg;
|
|
921
|
+
const t = t0 + phi * (phi >= 0 ? t0 <= 0 ? 1 : 1 - t0 / half : t0 >= 0 ? 1 : 1 + t0 / half);
|
|
922
|
+
const over = Math.max(0, ax - 46) * Math.max(.25, Math.abs(Math.sin(t)));
|
|
923
|
+
return [46 * Math.sin(t) + sg * over, py + dy];
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
/** Resolves a tint slot. `acc` is the accessory (or hair-tie) colour for this part. */
|
|
927
|
+
function slotColour(c, acc, slot) {
|
|
928
|
+
switch (slot) {
|
|
929
|
+
case "none": return "none";
|
|
930
|
+
case "acc": return acc.base;
|
|
931
|
+
case "accShade": return acc.shade;
|
|
932
|
+
case "accLight": return acc.light;
|
|
933
|
+
case "accInk": return acc.ink;
|
|
934
|
+
case "ink": return FIXED.ink;
|
|
935
|
+
case "white": return FIXED.white;
|
|
936
|
+
default: return c[slot];
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/** The tonal outline slot for a fill slot. */
|
|
940
|
+
function outlineOf(slot) {
|
|
941
|
+
if (slot.startsWith("hair")) return "hairInk";
|
|
942
|
+
if (slot === "beard" || slot === "beardShade" || slot === "stubble") return "beardInk";
|
|
943
|
+
if (slot.startsWith("acc")) return "accInk";
|
|
944
|
+
if (slot.startsWith("skin")) return "skinInk";
|
|
945
|
+
return "ink";
|
|
946
|
+
}
|
|
947
|
+
/** Paints catalog shapes with an optional warp; `clip: 'head'` shapes are clipped to the head. */
|
|
948
|
+
function paintShapes(c, acc, shapes, warp) {
|
|
949
|
+
if (!shapes) return [];
|
|
950
|
+
return shapes.map((s) => {
|
|
951
|
+
const d = s.d.d(warp);
|
|
952
|
+
const opacity = s.opacity === void 0 ? {} : { opacity: s.opacity };
|
|
953
|
+
if (s.line) return path(d, {
|
|
954
|
+
fill: "none",
|
|
955
|
+
stroke: slotColour(c, acc, s.stroke ?? "ink"),
|
|
956
|
+
sw: s.sw ?? 2.4,
|
|
957
|
+
cap: s.cap ?? "round",
|
|
958
|
+
...opacity
|
|
959
|
+
});
|
|
960
|
+
const strokeSlot = s.stroke ?? outlineOf(s.fill);
|
|
961
|
+
const stroke = strokeSlot === "none" ? {} : {
|
|
962
|
+
stroke: slotColour(c, acc, strokeSlot),
|
|
963
|
+
sw: (s.sw ?? 2.4) * FILL_OUTLINE
|
|
964
|
+
};
|
|
965
|
+
const node = path(d, {
|
|
966
|
+
fill: slotColour(c, acc, s.fill),
|
|
967
|
+
...s.evenOdd ? { evenOdd: true } : {},
|
|
968
|
+
...stroke,
|
|
969
|
+
...opacity
|
|
970
|
+
});
|
|
971
|
+
return s.clip === "head" ? g([node], { clip: HEAD_CLIP }) : node;
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/avatar/render/body.ts
|
|
976
|
+
/**
|
|
977
|
+
* The body blob and its poses (§3.11). Each pose is a transform of the same
|
|
978
|
+
* gumdrop plus where the head sits; a future limbed body re-implements the
|
|
979
|
+
* same names. The cycle poses (`walk`, `run`, `dance`, `jump`, `cheer`) read
|
|
980
|
+
* `phase` (0 … 1).
|
|
981
|
+
*/
|
|
982
|
+
const BODY = blob([
|
|
983
|
+
[-35, -2],
|
|
984
|
+
[-40, -24],
|
|
985
|
+
[-30, -50],
|
|
986
|
+
[0, -60],
|
|
987
|
+
[30, -50],
|
|
988
|
+
[40, -24],
|
|
989
|
+
[35, -2],
|
|
990
|
+
[0, 2]
|
|
991
|
+
]).d();
|
|
992
|
+
const STOOL = [
|
|
993
|
+
rect(-27, -12, 6, 12, {
|
|
994
|
+
fill: "#2a3142",
|
|
995
|
+
stroke: "#0d0f14",
|
|
996
|
+
sw: 2
|
|
997
|
+
}),
|
|
998
|
+
rect(21, -12, 6, 12, {
|
|
999
|
+
fill: "#2a3142",
|
|
1000
|
+
stroke: "#0d0f14",
|
|
1001
|
+
sw: 2
|
|
1002
|
+
}),
|
|
1003
|
+
rect(-38, -22, 76, 11, {
|
|
1004
|
+
fill: "#4a5470",
|
|
1005
|
+
stroke: "#0d0f14",
|
|
1006
|
+
sw: 3
|
|
1007
|
+
}, 5.5)
|
|
1008
|
+
];
|
|
1009
|
+
const hand$1 = (c, cx, cy) => ellipse(cx, cy, 8, 8, {
|
|
1010
|
+
fill: c.body,
|
|
1011
|
+
stroke: c.bodyInk,
|
|
1012
|
+
sw: LINE
|
|
1013
|
+
});
|
|
1014
|
+
/** Lays out the body for a pose; `phi` is the body turn (shading shifts with it). */
|
|
1015
|
+
function bodyLayout(c, pose, phi, phase, hasStool) {
|
|
1016
|
+
const wave = Math.sin(phase * Math.PI * 2);
|
|
1017
|
+
const hop = Math.abs(Math.sin(phase * Math.PI));
|
|
1018
|
+
let tf = [];
|
|
1019
|
+
let headDx = 0;
|
|
1020
|
+
let headDy = 0;
|
|
1021
|
+
let tilt = 0;
|
|
1022
|
+
let lift = 0;
|
|
1023
|
+
const behind = [];
|
|
1024
|
+
const front = [];
|
|
1025
|
+
switch (pose) {
|
|
1026
|
+
case "sit":
|
|
1027
|
+
case "play": {
|
|
1028
|
+
const up = hasStool ? -18 : 0;
|
|
1029
|
+
tf = [tr(0, up), sc(1.12, .8)];
|
|
1030
|
+
headDy = 12 + up;
|
|
1031
|
+
if (hasStool) behind.push(...STOOL);
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
case "walk":
|
|
1035
|
+
case "run": {
|
|
1036
|
+
const k = pose === "run" ? 1.6 : 1;
|
|
1037
|
+
tf = [
|
|
1038
|
+
tr(0, -10 * k),
|
|
1039
|
+
skewX(-10 * k),
|
|
1040
|
+
sc(1 - wave * .04, 1 + wave * .06)
|
|
1041
|
+
];
|
|
1042
|
+
headDy = -14 * k + wave * 3;
|
|
1043
|
+
headDx = 7 * k;
|
|
1044
|
+
tilt = 6 * k;
|
|
1045
|
+
behind.push(path("M-52 -30h-14M-50 -18h-20M-54 -6h-10", line("#6f7787", 3, "round", .8)));
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
1048
|
+
case "wave":
|
|
1049
|
+
tf = [rot(-6)];
|
|
1050
|
+
headDx = -5;
|
|
1051
|
+
tilt = -7;
|
|
1052
|
+
front.push(hand$1(c, 44, -58), path("M56 -70Q61 -64 58 -56M62 -76Q69 -66 64 -54", line("#e8e6df", 2.2, "round", .8)));
|
|
1053
|
+
break;
|
|
1054
|
+
case "cheer":
|
|
1055
|
+
lift = hop * 10;
|
|
1056
|
+
tf = [sc(1 - hop * .04, 1 + hop * .05)];
|
|
1057
|
+
headDy = -4 * hop;
|
|
1058
|
+
front.push(hand$1(c, -42, -66 - hop * 4), hand$1(c, 42, -66 - hop * 4));
|
|
1059
|
+
break;
|
|
1060
|
+
case "jump":
|
|
1061
|
+
lift = hop * 18;
|
|
1062
|
+
tf = [sc(1 - hop * .06, 1 + hop * .08)];
|
|
1063
|
+
headDy = -6 * hop;
|
|
1064
|
+
break;
|
|
1065
|
+
case "dance":
|
|
1066
|
+
tf = [rot(wave * 8), sc(1 + hop * .03, 1 - hop * .03)];
|
|
1067
|
+
headDx = wave * 5;
|
|
1068
|
+
tilt = wave * 7;
|
|
1069
|
+
break;
|
|
1070
|
+
case "away":
|
|
1071
|
+
tf = [sc(1.04, .95)];
|
|
1072
|
+
headDy = 3;
|
|
1073
|
+
tilt = 5;
|
|
1074
|
+
}
|
|
1075
|
+
const hl = -16 - Math.sin(phi) * 8;
|
|
1076
|
+
const body = [g([
|
|
1077
|
+
path(BODY, { fill: c.bodyShade }),
|
|
1078
|
+
path(BODY, { fill: c.body }, [tr(-3 - Math.sin(phi) * 2, -3), sc(.9)]),
|
|
1079
|
+
ellipse(0, -54, 20, 6, { fill: c.bodyShade }),
|
|
1080
|
+
ellipse(0, 0, 6, 4, { fill: c.bodyLight }, [tr(hl, -36), rot(-30)]),
|
|
1081
|
+
path(BODY, {
|
|
1082
|
+
fill: "none",
|
|
1083
|
+
stroke: c.bodyInk,
|
|
1084
|
+
sw: LINE
|
|
1085
|
+
})
|
|
1086
|
+
], { tf })];
|
|
1087
|
+
const isSitting = pose === "sit" || pose === "play";
|
|
1088
|
+
return {
|
|
1089
|
+
behind,
|
|
1090
|
+
body,
|
|
1091
|
+
front,
|
|
1092
|
+
headDx,
|
|
1093
|
+
headDy,
|
|
1094
|
+
tilt,
|
|
1095
|
+
lift,
|
|
1096
|
+
prop: {
|
|
1097
|
+
x: headDx * .5,
|
|
1098
|
+
y: isSitting ? -34 + (hasStool ? -18 : 0) * .8 : -30
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
/** The soft ground shadow at the feet (shrinks while lifted). */
|
|
1103
|
+
function groundShadow(lift, width = 36) {
|
|
1104
|
+
const k = Math.max(.5, 1 - lift / 40);
|
|
1105
|
+
return ellipse(0, 0, width * k, width / 4.5 * k, {
|
|
1106
|
+
fill: "#000000",
|
|
1107
|
+
opacity: .3
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/avatar/render/eyewear.ts
|
|
1112
|
+
/** The default frame colour. */
|
|
1113
|
+
const FRAME_BLACK = "#1d1a1e";
|
|
1114
|
+
const SHINE = "M-6 -4L-2 -7";
|
|
1115
|
+
const MIRROR_STREAKS = "M-9 7L3 -11M-1 10L9 -4";
|
|
1116
|
+
/** A frame stroke along a path, over its edge when it has one. */
|
|
1117
|
+
function frameLine(d, frame, sw = frame.sw) {
|
|
1118
|
+
return [...frame.edge === void 0 ? [] : [path(d, line(frame.edge, sw + 1.6))], path(d, line(frame.colour, sw))];
|
|
1119
|
+
}
|
|
1120
|
+
/** One lens (or visor) in its own coordinates: the glass, then the frame. */
|
|
1121
|
+
function lensNodes(d, glass, frame, shines) {
|
|
1122
|
+
const out = [];
|
|
1123
|
+
const clip = { shapes: [path(d, {})] };
|
|
1124
|
+
if (glass.tint === void 0) out.push(path(d, {
|
|
1125
|
+
fill: "#ffffff",
|
|
1126
|
+
fillOpacity: .16
|
|
1127
|
+
}));
|
|
1128
|
+
else if (glass.isMirror === true) out.push(path(d, { fill: glass.tint }), g([rect(-60, 2, 120, 40, {
|
|
1129
|
+
fill: outline(glass.tint),
|
|
1130
|
+
opacity: .75
|
|
1131
|
+
}), ...shines.map(([px, py]) => path(MIRROR_STREAKS, line("#ffffff", 2.6, "round", .85), [tr(px, py)]))], { clip }));
|
|
1132
|
+
else out.push(path(d, {
|
|
1133
|
+
fill: glass.tint,
|
|
1134
|
+
fillOpacity: .86
|
|
1135
|
+
}), g([rect(-60, 3, 120, 40, {
|
|
1136
|
+
fill: "#ffffff",
|
|
1137
|
+
opacity: .12
|
|
1138
|
+
})], { clip }));
|
|
1139
|
+
out.push(...frameLine(d, frame));
|
|
1140
|
+
out.push(...shines.map(([px, py]) => path(SHINE, line("#ffffff", 1.8, "round", glass.tint === void 0 ? .7 : .9), [tr(px, py)])));
|
|
1141
|
+
return out;
|
|
1142
|
+
}
|
|
1143
|
+
/** Glasses: arms toward the ears, a lens at each eye (or one visor), and the bridge. */
|
|
1144
|
+
function glasses(ctx, part, colour, glass) {
|
|
1145
|
+
const out = [];
|
|
1146
|
+
const warp = turnWarp(ctx.phi, ctx.dy);
|
|
1147
|
+
const isEdged = lightness(colour) > .7 || deltaE(colour, ctx.c.skin) < 24;
|
|
1148
|
+
const frame = {
|
|
1149
|
+
colour,
|
|
1150
|
+
sw: part.frame ?? 3.2,
|
|
1151
|
+
edge: isEdged ? ink(colour) : void 0
|
|
1152
|
+
};
|
|
1153
|
+
const tinted = part.tint === void 0 ? {} : {
|
|
1154
|
+
tint: glass.tint ?? part.tint,
|
|
1155
|
+
...glass.isMirror === true ? { isMirror: true } : {}
|
|
1156
|
+
};
|
|
1157
|
+
if (part.isSingle !== true) {
|
|
1158
|
+
for (const side of [-1, 1]) if (feat(ctx, 30 * side).vis) out.push(...frameLine(new Path().m(30 * side, 2).l(45 * side, 0).d(warp), frame, 2.6));
|
|
1159
|
+
}
|
|
1160
|
+
if (part.visor) {
|
|
1161
|
+
const shines = [feat(ctx, -17), feat(ctx, 17)].filter((f) => f.vis).map((f) => [f.x, 4 + ctx.dy]);
|
|
1162
|
+
out.push(...lensNodes(part.visor.d(warp), tinted, frame, shines));
|
|
1163
|
+
return out;
|
|
1164
|
+
}
|
|
1165
|
+
const lens = part.lens;
|
|
1166
|
+
for (const side of [-1, 1]) {
|
|
1167
|
+
const f = feat(ctx, 17 * side);
|
|
1168
|
+
if (!lens || !f.vis || part.isSingle === true && side < 0) continue;
|
|
1169
|
+
const flip = part.isMirrored === true && side < 0 ? -1 : 1;
|
|
1170
|
+
out.push(g([...lensNodes(lens.d(), tinted, frame, [[0, 0]]), ...part.bar ? frameLine(part.bar.d(), frame, 4.4) : []], { tf: [tr(f.x, 4 + ctx.dy), sc(f.sx * flip, 1)] }));
|
|
1171
|
+
}
|
|
1172
|
+
if (part.chain && feat(ctx, 30).vis) out.push(path(part.chain.d(warp), line(colour, 1.6, "round", .9)));
|
|
1173
|
+
if (part.bridge) {
|
|
1174
|
+
const fb = feat(ctx, 0);
|
|
1175
|
+
out.push(g(frameLine(part.bridge.d(), frame, 2.8), { tf: [tr(fb.x, 4 + ctx.dy), sc(fb.sx, 1)] }));
|
|
1176
|
+
}
|
|
1177
|
+
return out;
|
|
1178
|
+
}
|
|
1179
|
+
const STRAP_FRONT = new Path().m(-47, -2).c(-30, -6, 30, -6, 47, -2);
|
|
1180
|
+
const STRAP_BACK = new Path().m(-47, -2).c(-30, 4, 30, 4, 47, -2);
|
|
1181
|
+
const SHELL = blob([
|
|
1182
|
+
[-31, -9],
|
|
1183
|
+
[-4, -10],
|
|
1184
|
+
[0, -6],
|
|
1185
|
+
[4, -10],
|
|
1186
|
+
[31, -9],
|
|
1187
|
+
[34, 4],
|
|
1188
|
+
[26, 16],
|
|
1189
|
+
[7, 16],
|
|
1190
|
+
[0, 11],
|
|
1191
|
+
[-7, 16],
|
|
1192
|
+
[-26, 16],
|
|
1193
|
+
[-34, 4]
|
|
1194
|
+
]);
|
|
1195
|
+
const GLASS_L = blob([
|
|
1196
|
+
[-27, -5],
|
|
1197
|
+
[-7, -6],
|
|
1198
|
+
[-5, 8],
|
|
1199
|
+
[-9, 12],
|
|
1200
|
+
[-24, 12],
|
|
1201
|
+
[-29, 4]
|
|
1202
|
+
]);
|
|
1203
|
+
const GLASS_R = blob([
|
|
1204
|
+
[27, -5],
|
|
1205
|
+
[7, -6],
|
|
1206
|
+
[5, 8],
|
|
1207
|
+
[9, 12],
|
|
1208
|
+
[24, 12],
|
|
1209
|
+
[29, 4]
|
|
1210
|
+
]);
|
|
1211
|
+
/** Play goggles over the eyes (they replace the eyes and any glasses); the strap is the body colour. */
|
|
1212
|
+
function goggles(ctx) {
|
|
1213
|
+
const w = turnWarp(ctx.phi, ctx.dy);
|
|
1214
|
+
const band = ctx.c.body;
|
|
1215
|
+
const strap = STRAP_FRONT.d(w);
|
|
1216
|
+
return [
|
|
1217
|
+
path(strap, line(ink(band), 9)),
|
|
1218
|
+
path(strap, line(band, 6)),
|
|
1219
|
+
path(SHELL.d(w), {
|
|
1220
|
+
fill: "#2a3142",
|
|
1221
|
+
stroke: "#0d0f14",
|
|
1222
|
+
sw: LINE
|
|
1223
|
+
}),
|
|
1224
|
+
path(GLASS_L.d(w), { fill: "#5fe3f2" }),
|
|
1225
|
+
path(GLASS_R.d(w), { fill: "#5fe3f2" }),
|
|
1226
|
+
path(new Path().m(-22, 6).l(-14, -2).d(w), line("#e8fbff", 3)),
|
|
1227
|
+
path(new Path().m(12, 6).l(20, -2).d(w), line("#e8fbff", 3))
|
|
1228
|
+
];
|
|
1229
|
+
}
|
|
1230
|
+
/** The goggles strap seen from behind. */
|
|
1231
|
+
function gogglesStrapBack(ctx, phi) {
|
|
1232
|
+
const d = STRAP_BACK.d(turnWarp(phi));
|
|
1233
|
+
return [path(d, line(ink(ctx.c.body), 9)), path(d, line(ctx.c.body, 6))];
|
|
1234
|
+
}
|
|
1235
|
+
//#endregion
|
|
1236
|
+
//#region src/avatar/render/eyes.ts
|
|
1237
|
+
/**
|
|
1238
|
+
* Eyes and brows in style C (§14.1): whites 7% larger than style A's with a
|
|
1239
|
+
* 1.6-unit rim in a darker shade of the skin, an iris with a darker ring, a
|
|
1240
|
+
* pupil, two glints, and a soft dark-brown lid line. No dark outline.
|
|
1241
|
+
*/
|
|
1242
|
+
/** Eye-white radii: style A's 9.6 × 11.8, 7% larger. */
|
|
1243
|
+
const RX = 10.272;
|
|
1244
|
+
const RY = 11.8 * 1.07;
|
|
1245
|
+
/** One eye; `side` −1 is the viewer's left. */
|
|
1246
|
+
function eye(ctx, side) {
|
|
1247
|
+
const f = feat(ctx, 17 * side);
|
|
1248
|
+
if (!f.vis) return [];
|
|
1249
|
+
const fp = ctx.face;
|
|
1250
|
+
const lidBase = side < 0 ? fp.lidL : fp.lidR;
|
|
1251
|
+
const lid = lidBase + (1 - lidBase) * ctx.blink;
|
|
1252
|
+
const low = side < 0 ? fp.lowL : fp.lowR;
|
|
1253
|
+
const curve = side < 0 ? fp.closedCurveL : fp.closedCurveR;
|
|
1254
|
+
const place = [tr(f.x, 4 + ctx.dy), sc(f.sx, 1)];
|
|
1255
|
+
if (lid >= .9) return [g([path(`M-8 1.5Q0 ${n(1.5 - 9 * curve)} 8 1.5`, line(SOFT_INK, LINE * 1.05))], { tf: place })];
|
|
1256
|
+
const top = -12.626000000000001 + lid * 2 * RY;
|
|
1257
|
+
const edgeY = (px) => top + fp.lidTilt * (px * -side) / 9;
|
|
1258
|
+
const bot = RY - low * 2 * RY;
|
|
1259
|
+
const gx = ctx.gaze.x * 2.8;
|
|
1260
|
+
const gy = ctx.gaze.y * 3.4;
|
|
1261
|
+
const clip = { shapes: [ellipse(0, 0, RX, RY, {})] };
|
|
1262
|
+
const inner = [g([
|
|
1263
|
+
ellipse(0, 0, 7, 7, { fill: ctx.c.eye }),
|
|
1264
|
+
ellipse(0, 0, 6.4, 6.4, line(ctx.c.eyeDark, 1.2)),
|
|
1265
|
+
ellipse(0, 0, 3.6 * fp.pupil, 3.6 * fp.pupil, { fill: FIXED.pupil }),
|
|
1266
|
+
ellipse(-2.4, -2.8, 2.2, 2.2, { fill: "#ffffff" }),
|
|
1267
|
+
ellipse(2.1, 2, .9, .9, { fill: "#ffffff" })
|
|
1268
|
+
], { tf: [tr(gx, gy)] })];
|
|
1269
|
+
if (lid > .02) inner.push(path(`M-12 -14L12 -14L12 ${n(edgeY(12))}Q0 ${n(top + 1.5)} -12 ${n(edgeY(-12))}Z`, { fill: ctx.c.skin }));
|
|
1270
|
+
if (low > .02) inner.push(path(`M-12 14L12 14L12 ${n(bot)}Q0 ${n(bot - 3)} -12 ${n(bot)}Z`, { fill: ctx.c.skin }));
|
|
1271
|
+
const out = [ellipse(0, 0, RX, RY, { fill: FIXED.white }), g(inner, { clip })];
|
|
1272
|
+
if (lid > .06) out.push(g([path(`M${n(-9.772)} ${n(edgeY(-9.772))}Q0 ${n(top + 1.5)} ${n(9.772)} ${n(edgeY(9.772))}`, line(SOFT_INK, 1.9))], { clip }));
|
|
1273
|
+
out.push(ellipse(0, 0, RX, RY, line(ctx.c.eyeRim, 1.6)));
|
|
1274
|
+
return [g(out, { tf: place })];
|
|
1275
|
+
}
|
|
1276
|
+
const BROW_W = {
|
|
1277
|
+
soft: 3.8,
|
|
1278
|
+
thin: 2.4,
|
|
1279
|
+
thick: 5.6,
|
|
1280
|
+
arched: 3.6,
|
|
1281
|
+
straight: 4.2
|
|
1282
|
+
};
|
|
1283
|
+
const BROW_ARCH = {
|
|
1284
|
+
soft: 1,
|
|
1285
|
+
thin: 1,
|
|
1286
|
+
thick: .8,
|
|
1287
|
+
arched: 1.9,
|
|
1288
|
+
straight: .2
|
|
1289
|
+
};
|
|
1290
|
+
/** One brow in a style (`soft`, `thin`, …). */
|
|
1291
|
+
function brow(ctx, side, style) {
|
|
1292
|
+
const f = feat(ctx, 17 * side);
|
|
1293
|
+
if (!f.vis) return [];
|
|
1294
|
+
const p = side < 0 ? ctx.face.browL : ctx.face.browR;
|
|
1295
|
+
const by = -14 + p.dy + ctx.dy * 1.1;
|
|
1296
|
+
const xi = -7.5 * side;
|
|
1297
|
+
const xo = 7.5 * side;
|
|
1298
|
+
const yi = by + p.tilt * .8;
|
|
1299
|
+
const yo = by - p.tilt * .35;
|
|
1300
|
+
const arch = p.arch * (BROW_ARCH[style] ?? 1);
|
|
1301
|
+
const width = (BROW_W[style] ?? 3.8) * 1.15;
|
|
1302
|
+
return [g([path(`M${n(xi)} ${n(yi)}Q0 ${n(Math.min(yi, yo) - arch * 2)} ${n(xo)} ${n(yo)}`, line(ctx.c.brow, width))], { tf: [tr(f.x, 0), sc(f.sx, 1)] })];
|
|
1303
|
+
}
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/avatar/render/head.ts
|
|
1306
|
+
/** The head, ears, cheeks and the small face and ear extras. */
|
|
1307
|
+
const EAR = "M-2 -10C6 -12 11 -6 10 1C9 8 5 11 -1 10Z";
|
|
1308
|
+
const EAR_FOLD = "M1.5 -5C5.5 -5 6.5 0 5 3.5";
|
|
1309
|
+
/** Ears behind the head (`behind`) or in front of it (`front`), hidden when turned away. */
|
|
1310
|
+
function ears(ctx, which, extra) {
|
|
1311
|
+
const out = [];
|
|
1312
|
+
for (const side of [-1, 1]) {
|
|
1313
|
+
const t = Math.PI / 2 * side + ctx.phi;
|
|
1314
|
+
const cos = Math.cos(t);
|
|
1315
|
+
if (cos < -.15) continue;
|
|
1316
|
+
const isInFront = cos > .3;
|
|
1317
|
+
if (which === "front" !== isInFront) continue;
|
|
1318
|
+
const earX = clamp(46 * Math.sin(t), -44, 44);
|
|
1319
|
+
const w = isInFront ? .75 + .25 * cos : 1;
|
|
1320
|
+
const flip = isInFront ? -Math.sign(earX || side) : side;
|
|
1321
|
+
const parts = [path(EAR, {
|
|
1322
|
+
fill: ctx.c.skin,
|
|
1323
|
+
stroke: ctx.c.skinInk,
|
|
1324
|
+
sw: LINE
|
|
1325
|
+
}), path(EAR_FOLD, line(ctx.c.skinShade, 2.2))];
|
|
1326
|
+
if (extra?.kind === "studs") parts.push(ellipse(3, 7, 2.2, 2.2, {
|
|
1327
|
+
fill: extra.colour,
|
|
1328
|
+
stroke: "#3a2a2a",
|
|
1329
|
+
sw: .8
|
|
1330
|
+
}));
|
|
1331
|
+
if (extra?.kind === "hoops") parts.push(ellipse(3, 12, 3.6, 4.4, line(extra.colour, 1.8)));
|
|
1332
|
+
out.push(g(parts, { tf: [tr(earX, 6 + ctx.dy * .3), sc(flip * w, 1)] }));
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
1335
|
+
}
|
|
1336
|
+
/** The head: a shade base, the lit face, and the tonal outline. */
|
|
1337
|
+
function head(ctx) {
|
|
1338
|
+
const d = HEAD_PATH.d();
|
|
1339
|
+
const lx = -2.5 + Math.sin(ctx.phi) * 3;
|
|
1340
|
+
return [
|
|
1341
|
+
path(d, { fill: ctx.c.skinShade }),
|
|
1342
|
+
path(d, { fill: ctx.c.skin }, [tr(lx, -3), sc(.95)]),
|
|
1343
|
+
path(d, {
|
|
1344
|
+
fill: "none",
|
|
1345
|
+
stroke: ctx.c.skinInk,
|
|
1346
|
+
sw: LINE
|
|
1347
|
+
})
|
|
1348
|
+
];
|
|
1349
|
+
}
|
|
1350
|
+
const FRECKLES = [
|
|
1351
|
+
[-3.5, -1.5],
|
|
1352
|
+
[0, .8],
|
|
1353
|
+
[3.5, -1.2],
|
|
1354
|
+
[1.5, -3.5]
|
|
1355
|
+
];
|
|
1356
|
+
/** Cheek blush (from the expression, plus `rosy`) and freckles. */
|
|
1357
|
+
function cheeks(ctx, hasFreckles, isRosy) {
|
|
1358
|
+
const out = [];
|
|
1359
|
+
const blush = Math.min(1, ctx.face.cheeks + (isRosy ? .45 : 0));
|
|
1360
|
+
for (const side of [-1, 1]) {
|
|
1361
|
+
const f = feat(ctx, 25 * side);
|
|
1362
|
+
if (!f.vis) continue;
|
|
1363
|
+
const parts = [];
|
|
1364
|
+
if (blush > .02) parts.push(ellipse(0, 0, 6.5, 3.8, {
|
|
1365
|
+
fill: FIXED.blush,
|
|
1366
|
+
opacity: Math.round(blush * 5) / 10
|
|
1367
|
+
}));
|
|
1368
|
+
if (hasFreckles) parts.push(...FRECKLES.map(([fx, fy]) => ellipse(fx, fy, 1.05, 1.05, {
|
|
1369
|
+
fill: ctx.c.skinInk,
|
|
1370
|
+
opacity: .45
|
|
1371
|
+
})));
|
|
1372
|
+
if (parts.length > 0) out.push(g(parts, { tf: [tr(f.x, 18 + ctx.dy), sc(f.sx, 1)] }));
|
|
1373
|
+
}
|
|
1374
|
+
return out;
|
|
1375
|
+
}
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/avatar/render/mouth.ts
|
|
1378
|
+
/**
|
|
1379
|
+
* The nose, the mouth morph (one cubic structure for every shape, so any two
|
|
1380
|
+
* expressions tween, §3.5) and the small FX glyphs.
|
|
1381
|
+
*/
|
|
1382
|
+
/** The nose: a soft shade with a highlight. */
|
|
1383
|
+
function nose(ctx) {
|
|
1384
|
+
const f = feat(ctx, 0);
|
|
1385
|
+
return [g([ellipse(0, 0, 4.4, 3, { fill: ctx.c.skinShade }), ellipse(-1.5, -1, 1.2, 1.2, {
|
|
1386
|
+
fill: ctx.c.skinLight,
|
|
1387
|
+
opacity: .9
|
|
1388
|
+
})], { tf: [tr(f.x, 16 + ctx.dy), sc(Math.max(.6, f.sx), 1)] })];
|
|
1389
|
+
}
|
|
1390
|
+
/** The mouth: a line when closed, a filled shape with teeth and tongue when open. Talking adds opening. */
|
|
1391
|
+
function mouth(ctx) {
|
|
1392
|
+
const base = ctx.face.mouth;
|
|
1393
|
+
const m = {
|
|
1394
|
+
...base,
|
|
1395
|
+
open: base.open + ctx.talk * 9 * (1 - base.round * .4)
|
|
1396
|
+
};
|
|
1397
|
+
const f = feat(ctx, m.dx);
|
|
1398
|
+
const baseY = 27;
|
|
1399
|
+
const lx = -m.w / 2;
|
|
1400
|
+
const ly = baseY - m.sL;
|
|
1401
|
+
const rx = m.w / 2;
|
|
1402
|
+
const ry = baseY - m.sR;
|
|
1403
|
+
const s = (m.sL + m.sR) / 2;
|
|
1404
|
+
const corner = baseY - s;
|
|
1405
|
+
const ctrl = (target) => (target - .25 * corner) / .75;
|
|
1406
|
+
const place = [tr(f.x, ctx.dy * .8), sc(Math.max(.35, f.sx), 1)];
|
|
1407
|
+
if (m.open < 1) {
|
|
1408
|
+
const c = ctrl(baseY + s * .55);
|
|
1409
|
+
return [g([path(`M${n(lx)} ${n(ly)}C${n(-m.w * .18)} ${n(c)} ${n(m.w * .18)} ${n(c)} ${n(rx)} ${n(ry)}`, line(SOFT_INK, LINE * .95))], { tf: place })];
|
|
1410
|
+
}
|
|
1411
|
+
const up = baseY + s * .3 - m.round * m.open * .45;
|
|
1412
|
+
const lo = baseY + s * .55 + m.open * (1 - m.round * .45);
|
|
1413
|
+
const kx = m.w / 2 * (.55 + m.round * .8);
|
|
1414
|
+
const d = `M${n(lx)} ${n(ly)}C${n(-kx)} ${n(ctrl(up))} ${n(kx)} ${n(ctrl(up))} ${n(rx)} ${n(ry)}C${n(kx)} ${n(ctrl(lo))} ${n(-kx)} ${n(ctrl(lo))} ${n(lx)} ${n(ly)}Z`;
|
|
1415
|
+
const inside = [];
|
|
1416
|
+
if (m.tongue > .05) inside.push(ellipse(0, lo + 1, m.w * .32, m.open * .42 * m.tongue + 1, { fill: FIXED.tongue }));
|
|
1417
|
+
if (m.teeth > .05) inside.push(rect(-m.w / 2, up - 6, m.w, 6 + m.open * .22 * m.teeth, { fill: FIXED.white }));
|
|
1418
|
+
return [g([
|
|
1419
|
+
path(d, { fill: FIXED.mouth }),
|
|
1420
|
+
g(inside, { clip: { shapes: [path(d, {})] } }),
|
|
1421
|
+
path(d, line(SOFT_INK, LINE * .9))
|
|
1422
|
+
], { tf: place })];
|
|
1423
|
+
}
|
|
1424
|
+
/** FX glyphs beside the head (sparkle, tear, sweat, anger, question, zzz). */
|
|
1425
|
+
function extras(ctx) {
|
|
1426
|
+
const out = [];
|
|
1427
|
+
const f = feat(ctx, 0);
|
|
1428
|
+
for (const e of ctx.face.extras) {
|
|
1429
|
+
if (e === "sparkle") out.push(path("M0 -9Q1.4 -1.4 9 0Q1.4 1.4 0 9Q-1.4 1.4 -9 0Q-1.4 -1.4 0 -9Z", {
|
|
1430
|
+
fill: "#ffd23f",
|
|
1431
|
+
stroke: "#0d0f14",
|
|
1432
|
+
sw: 1.6
|
|
1433
|
+
}, [tr(48 + f.x * .2, -44)]));
|
|
1434
|
+
if (e === "tear") {
|
|
1435
|
+
const t = feat(ctx, -16);
|
|
1436
|
+
if (t.vis) out.push(path("M0 -5Q4.5 2 0 5Q-4.5 2 0 -5Z", {
|
|
1437
|
+
fill: "#7cc7ff",
|
|
1438
|
+
stroke: "#1f3b5c",
|
|
1439
|
+
sw: 1.4
|
|
1440
|
+
}, [tr(t.x - 5, 16 + ctx.dy)]));
|
|
1441
|
+
}
|
|
1442
|
+
if (e === "sweat") out.push(path("M0 -6Q5 2 0 6Q-5 2 0 -6Z", {
|
|
1443
|
+
fill: "#9fdcff",
|
|
1444
|
+
stroke: "#1f3b5c",
|
|
1445
|
+
sw: 1.4
|
|
1446
|
+
}, [tr(40 + f.x * .2, -26)]));
|
|
1447
|
+
if (e === "anger") out.push(g([
|
|
1448
|
+
path("M-6 -2Q-2 -2 -2 -6", line("#ff4d4d", 2.6)),
|
|
1449
|
+
path("M2 -6Q2 -2 6 -2", line("#ff4d4d", 2.6)),
|
|
1450
|
+
path("M6 2Q2 2 2 6", line("#ff4d4d", 2.6)),
|
|
1451
|
+
path("M-2 6Q-2 2 -6 2", line("#ff4d4d", 2.6))
|
|
1452
|
+
], { tf: [tr(30 + f.x * .3, -36)] }));
|
|
1453
|
+
if (e === "question") out.push({
|
|
1454
|
+
k: "text",
|
|
1455
|
+
x: 50 + f.x * .2,
|
|
1456
|
+
y: -40,
|
|
1457
|
+
text: "?",
|
|
1458
|
+
size: 26,
|
|
1459
|
+
font: "pixel",
|
|
1460
|
+
fill: "#ffd23f",
|
|
1461
|
+
stroke: "#0d0f14",
|
|
1462
|
+
sw: 1.2
|
|
1463
|
+
});
|
|
1464
|
+
if (e === "zzz") out.push({
|
|
1465
|
+
k: "text",
|
|
1466
|
+
x: 44 + f.x * .2,
|
|
1467
|
+
y: -42,
|
|
1468
|
+
text: "z z",
|
|
1469
|
+
size: 20,
|
|
1470
|
+
font: "pixel",
|
|
1471
|
+
fill: "#8ab4ff",
|
|
1472
|
+
stroke: "#0d0f14",
|
|
1473
|
+
sw: 1.2
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
return out;
|
|
1477
|
+
}
|
|
1478
|
+
//#endregion
|
|
1479
|
+
//#region src/avatar/render/props.ts
|
|
1480
|
+
/**
|
|
1481
|
+
* Status props (§3.11, §14.3): runtime-only parts the world (or a preview)
|
|
1482
|
+
* puts on a player. `goggles` lives in eyewear.ts; the head props here sit on
|
|
1483
|
+
* the head, the hand props at the `prop` anchor in front of the body.
|
|
1484
|
+
*/
|
|
1485
|
+
const DARK = "#0d0f14";
|
|
1486
|
+
/** Hand props are drawn at this scale so they read at small sizes. */
|
|
1487
|
+
const PROP_SCALE = 1.45;
|
|
1488
|
+
/** A headset: a band over the head, ear cups and a mic (head space). */
|
|
1489
|
+
function headset(ctx, isBack) {
|
|
1490
|
+
const w = turnWarp(isBack ? 0 : ctx.phi);
|
|
1491
|
+
const band = blob(arc(0, -4, 51, 51, 196, 344, 8), false);
|
|
1492
|
+
const nodes = [path(band.d(w), line(DARK, 8)), path(band.d(w), line("#4a5470", 5))];
|
|
1493
|
+
const phi = isBack ? 0 : ctx.phi;
|
|
1494
|
+
for (const side of [-1, 1]) {
|
|
1495
|
+
const t = Math.PI / 2 * side + phi;
|
|
1496
|
+
if (Math.cos(t) < -.15) continue;
|
|
1497
|
+
const cupX = Math.max(-47, Math.min(47, 46 * Math.sin(t)));
|
|
1498
|
+
const wide = 6 + 3 * Math.max(0, Math.cos(t));
|
|
1499
|
+
nodes.push(path(roundedRect(cupX - wide, -6, wide * 2, 20, 5).d(), {
|
|
1500
|
+
fill: "#2a3142",
|
|
1501
|
+
stroke: DARK,
|
|
1502
|
+
sw: LINE
|
|
1503
|
+
}));
|
|
1504
|
+
}
|
|
1505
|
+
if (!isBack && Math.abs(phi) < .7) nodes.push(path(new Path().m(-46, 12).q(-40, 30, -18, 30).d(w), line("#4a5470", 3)), ellipse(-17, 30, 3, 3, {
|
|
1506
|
+
fill: "#ff5f3b",
|
|
1507
|
+
stroke: DARK,
|
|
1508
|
+
sw: 1.2
|
|
1509
|
+
}));
|
|
1510
|
+
return nodes;
|
|
1511
|
+
}
|
|
1512
|
+
/** A small crown on top of the head (head space; `top` is the hair or hat top). */
|
|
1513
|
+
function crown(top) {
|
|
1514
|
+
return [g([path("M-16 0L-18 -16L-8 -8L0 -20L8 -8L18 -16L16 0Z", {
|
|
1515
|
+
fill: "#ffd23f",
|
|
1516
|
+
stroke: "#8a5a00",
|
|
1517
|
+
sw: LINE
|
|
1518
|
+
}), ellipse(0, -5, 2.4, 2.4, { fill: "#ff5f3b" })], { tf: [tr(0, -50 - top + 4)] })];
|
|
1519
|
+
}
|
|
1520
|
+
const card = (colour) => rect(-6, -9, 12, 17, {
|
|
1521
|
+
fill: colour,
|
|
1522
|
+
stroke: DARK,
|
|
1523
|
+
sw: 1.6
|
|
1524
|
+
}, 2.5);
|
|
1525
|
+
/** Nub hands holding something at the prop anchor (body space). */
|
|
1526
|
+
function nubs(body, ink, spread, atY) {
|
|
1527
|
+
return [-1, 1].map((s) => ellipse(spread * s, atY, 7, 6, {
|
|
1528
|
+
fill: body,
|
|
1529
|
+
stroke: ink,
|
|
1530
|
+
sw: LINE
|
|
1531
|
+
}));
|
|
1532
|
+
}
|
|
1533
|
+
/** A hand prop drawn at the `prop` anchor (x, y) in front of the body. */
|
|
1534
|
+
function handProp(prop, atX, atY, body, bodyInk) {
|
|
1535
|
+
switch (prop) {
|
|
1536
|
+
case "cards": return [g([
|
|
1537
|
+
g([card("#ff5f3b")], { tf: [tr(-8, 0), rot(-18)] }),
|
|
1538
|
+
g([card("#ffd23f")], { tf: [tr(0, -3)] }),
|
|
1539
|
+
g([card("#3d7bff")], { tf: [tr(8, 0), rot(18)] }),
|
|
1540
|
+
...nubs(body, bodyInk, 13, 8)
|
|
1541
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
1542
|
+
case "controller": return [g([
|
|
1543
|
+
path(new Path().m(-20, -4).c(-20, -12, 20, -12, 20, -4).c(24, 10, 14, 14, 8, 6).l(-8, 6).c(-14, 14, -24, 10, -20, -4).z().d(), {
|
|
1544
|
+
fill: "#2a3142",
|
|
1545
|
+
stroke: DARK,
|
|
1546
|
+
sw: LINE
|
|
1547
|
+
}),
|
|
1548
|
+
ellipse(-9, -2, 2.6, 2.6, { fill: "#8ab4ff" }),
|
|
1549
|
+
ellipse(10, -4, 2, 2, { fill: "#ff5f3b" }),
|
|
1550
|
+
ellipse(13, 0, 2, 2, { fill: "#4ade80" }),
|
|
1551
|
+
...nubs(body, bodyInk, 20, 4)
|
|
1552
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
1553
|
+
case "book": return [g([
|
|
1554
|
+
path("M0 -2C-8 -8 -18 -8 -24 -5L-24 12C-18 9 -8 9 0 14C8 9 18 9 24 12L24 -5C18 -8 8 -8 0 -2Z", {
|
|
1555
|
+
fill: "#f4f1ea",
|
|
1556
|
+
stroke: DARK,
|
|
1557
|
+
sw: LINE
|
|
1558
|
+
}),
|
|
1559
|
+
path("M0 -2L0 14", line("#c9c2b4", 1.6)),
|
|
1560
|
+
path("M-19 0L-5 3M-19 5L-5 8M5 3L19 0M5 8L19 5", line("#9a9384", 1.2)),
|
|
1561
|
+
path("M-24 12C-18 9 -8 9 0 14C8 9 18 9 24 12L24 15C18 12 8 12 0 17C-8 12 -18 12 -24 15Z", {
|
|
1562
|
+
fill: "#ff5f3b",
|
|
1563
|
+
stroke: DARK,
|
|
1564
|
+
sw: 1.4
|
|
1565
|
+
}),
|
|
1566
|
+
...nubs(body, bodyInk, 25, 6)
|
|
1567
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
1568
|
+
case "trophy": return [g([
|
|
1569
|
+
path("M-11 -14L11 -14L9 0C8 6 -8 6 -9 0Z", {
|
|
1570
|
+
fill: "#ffd23f",
|
|
1571
|
+
stroke: "#8a5a00",
|
|
1572
|
+
sw: LINE
|
|
1573
|
+
}),
|
|
1574
|
+
path("M-11 -11C-18 -11 -18 -2 -9 -1M11 -11C18 -11 18 -2 9 -1", line("#8a5a00", 2.2)),
|
|
1575
|
+
rect(-3, 4, 6, 6, { fill: "#e0a800" }),
|
|
1576
|
+
rect(-9, 10, 18, 5, {
|
|
1577
|
+
fill: "#6b4f12",
|
|
1578
|
+
stroke: DARK,
|
|
1579
|
+
sw: 1.2
|
|
1580
|
+
}, 1.5),
|
|
1581
|
+
ellipse(13, 6, 7, 6, {
|
|
1582
|
+
fill: body,
|
|
1583
|
+
stroke: bodyInk,
|
|
1584
|
+
sw: LINE
|
|
1585
|
+
})
|
|
1586
|
+
], { tf: [tr(atX + 24, atY - 10), sc(PROP_SCALE)] })];
|
|
1587
|
+
default: return [];
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
//#endregion
|
|
1591
|
+
//#region src/avatar/render/walker-parts.ts
|
|
1592
|
+
/**
|
|
1593
|
+
* The walker's drawing parts (`figure: 'walker'`): limbs as outlined round
|
|
1594
|
+
* strokes, the shirt, trousers and shoes. Colours come from the look: the
|
|
1595
|
+
* shirt is the body colour, the trousers and shoes are picked from the body
|
|
1596
|
+
* colour so they always stand apart from the shirt.
|
|
1597
|
+
*/
|
|
1598
|
+
const TROUSERS = [
|
|
1599
|
+
"#34466e",
|
|
1600
|
+
"#2e323f",
|
|
1601
|
+
"#6a5642",
|
|
1602
|
+
"#4a3a60",
|
|
1603
|
+
"#3c5a4c",
|
|
1604
|
+
"#5a3a3a"
|
|
1605
|
+
];
|
|
1606
|
+
const SHOES = [
|
|
1607
|
+
"#2c2430",
|
|
1608
|
+
"#f1ece2",
|
|
1609
|
+
"#5a3a2a",
|
|
1610
|
+
"#2c2430"
|
|
1611
|
+
];
|
|
1612
|
+
/** A small stable hash of a colour string. */
|
|
1613
|
+
function hashOf(text) {
|
|
1614
|
+
let hash = 7;
|
|
1615
|
+
for (let i = 0; i < text.length; i++) hash = hash * 31 + text.charCodeAt(i) >>> 0;
|
|
1616
|
+
return hash;
|
|
1617
|
+
}
|
|
1618
|
+
/** Trousers and shoes for a shirt colour: a trouser tone at least a little lighter or darker than the shirt. */
|
|
1619
|
+
function outfitFor(c) {
|
|
1620
|
+
const hash = hashOf(c.body);
|
|
1621
|
+
const shirt = lightness(c.body);
|
|
1622
|
+
let trousers = TROUSERS[hash % TROUSERS.length] ?? TROUSERS[0];
|
|
1623
|
+
for (let i = 1; i < TROUSERS.length && Math.abs(lightness(trousers) - shirt) < .12; i++) trousers = TROUSERS[(hash + i) % TROUSERS.length] ?? trousers;
|
|
1624
|
+
const shoes = SHOES[(hash >>> 4) % SHOES.length] ?? SHOES[0];
|
|
1625
|
+
return {
|
|
1626
|
+
trousers,
|
|
1627
|
+
trousersInk: outline(trousers),
|
|
1628
|
+
shoes,
|
|
1629
|
+
shoesInk: outline(shoes)
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
/** A polyline as a path `d`. */
|
|
1633
|
+
function polyline(points) {
|
|
1634
|
+
const line = new Path();
|
|
1635
|
+
points.forEach(([px, py], i) => i === 0 ? line.m(px, py) : line.l(px, py));
|
|
1636
|
+
return line.d();
|
|
1637
|
+
}
|
|
1638
|
+
/** A limb: a round stroke with a tonal outline under it. */
|
|
1639
|
+
function limb(points, width, fill, ink) {
|
|
1640
|
+
const d = polyline(points);
|
|
1641
|
+
return [path(d, {
|
|
1642
|
+
fill: "none",
|
|
1643
|
+
stroke: ink,
|
|
1644
|
+
sw: width + LINE * 1.3,
|
|
1645
|
+
cap: "round"
|
|
1646
|
+
}), path(d, {
|
|
1647
|
+
fill: "none",
|
|
1648
|
+
stroke: fill,
|
|
1649
|
+
sw: width,
|
|
1650
|
+
cap: "round"
|
|
1651
|
+
})];
|
|
1652
|
+
}
|
|
1653
|
+
/** A shoe at a foot point, nudged along the facing: round and stubby. */
|
|
1654
|
+
function shoe(at, forward, o) {
|
|
1655
|
+
return ellipse(at[0] + forward[0] * 2.4, at[1] + forward[1] * 1.6 - 1.6, 7.2 + Math.abs(forward[0]) * 1.4, 4.1, {
|
|
1656
|
+
fill: o.shoes,
|
|
1657
|
+
stroke: o.shoesInk,
|
|
1658
|
+
sw: LINE * .8
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
/** A hand: a round mitten. */
|
|
1662
|
+
function hand(c, at) {
|
|
1663
|
+
return ellipse(at[0], at[1], 5.2, 5.2, {
|
|
1664
|
+
fill: c.skin,
|
|
1665
|
+
stroke: c.skinInk,
|
|
1666
|
+
sw: LINE * .8
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
/** The torso's half-widths (before the turn's width factor): square shoulders over a round belly. */
|
|
1670
|
+
const TORSO = {
|
|
1671
|
+
shoulder: 20,
|
|
1672
|
+
belly: 23.5,
|
|
1673
|
+
hip: 21
|
|
1674
|
+
};
|
|
1675
|
+
/** The torso's outline: shoulders at `top`, the hem at `hem`, `w` its width factor (narrower when turned). */
|
|
1676
|
+
function torsoPath(w, top, hem) {
|
|
1677
|
+
const shoulder = TORSO.shoulder * w;
|
|
1678
|
+
const belly = TORSO.belly * w;
|
|
1679
|
+
const hip = TORSO.hip * w;
|
|
1680
|
+
const mid = (top + hem) / 2 + 2;
|
|
1681
|
+
return new Path().m(-shoulder, top + 5).c(-shoulder, top - 1, -shoulder + 6, top - 3, -shoulder * .35, top - 3).l(shoulder * .35, top - 3).c(shoulder - 6, top - 3, shoulder, top - 1, shoulder, top + 5).c(belly, mid - 4, belly, mid + 4, hip, hem - 1).q(0, hem + 4, -hip, hem - 1).c(-belly, mid + 4, -belly, mid - 4, -shoulder, top + 5).z().d();
|
|
1682
|
+
}
|
|
1683
|
+
/** The shirt: a shade base, the lit front, the outline, and the neckline (front) or collar (back). */
|
|
1684
|
+
function shirt(c, w, top, hem, lean, isBack) {
|
|
1685
|
+
const d = torsoPath(w, top, hem);
|
|
1686
|
+
const cy = (top + hem) / 2;
|
|
1687
|
+
const hemHalf = (TORSO.hip - 3) * w;
|
|
1688
|
+
const nodes = [
|
|
1689
|
+
path(d, { fill: c.bodyShade }),
|
|
1690
|
+
g([path(d, { fill: c.body })], { tf: [
|
|
1691
|
+
tr(-1.5 - lean * 2, cy - 1.5),
|
|
1692
|
+
sc(.88, .9),
|
|
1693
|
+
tr(0, -cy)
|
|
1694
|
+
] }),
|
|
1695
|
+
path(`M${String(-hemHalf)} ${String(hem - 2)}Q0 ${String(hem + 1.5)} ${String(hemHalf)} ${String(hem - 2)}`, {
|
|
1696
|
+
fill: "none",
|
|
1697
|
+
stroke: shade(c.bodyShade),
|
|
1698
|
+
sw: 2.2,
|
|
1699
|
+
opacity: .7
|
|
1700
|
+
}),
|
|
1701
|
+
path(d, {
|
|
1702
|
+
fill: "none",
|
|
1703
|
+
stroke: c.bodyInk,
|
|
1704
|
+
sw: LINE
|
|
1705
|
+
})
|
|
1706
|
+
];
|
|
1707
|
+
const neckX = lean * 7;
|
|
1708
|
+
if (isBack) nodes.push(path(`M${String(neckX - 6)} ${String(top - 2)}Q${String(neckX)} ${String(top + 1.5)} ${String(neckX + 6)} ${String(top - 2)}`, {
|
|
1709
|
+
fill: "none",
|
|
1710
|
+
stroke: c.bodyShade,
|
|
1711
|
+
sw: 3
|
|
1712
|
+
}));
|
|
1713
|
+
else nodes.push(path(`M${String(neckX - 6 * w)} ${String(top - 2.6)}Q${String(neckX)} ${String(top + 5)} ${String(neckX + 6 * w)} ${String(top - 2.6)}Z`, {
|
|
1714
|
+
fill: c.skin,
|
|
1715
|
+
stroke: c.bodyInk,
|
|
1716
|
+
sw: LINE * .8
|
|
1717
|
+
}));
|
|
1718
|
+
return nodes;
|
|
1719
|
+
}
|
|
1720
|
+
/** The seat of the trousers joining the legs under the hem. */
|
|
1721
|
+
function hips(o, w, hipY) {
|
|
1722
|
+
const half = 16 * w;
|
|
1723
|
+
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(), {
|
|
1724
|
+
fill: o.trousers,
|
|
1725
|
+
stroke: o.trousersInk,
|
|
1726
|
+
sw: LINE * .8
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
/** The neck under the head (a short one: the big head sits almost on the shoulders). */
|
|
1730
|
+
function neck(c, top, dx) {
|
|
1731
|
+
return path(new Path().m(dx - 5, top - 6).l(dx + 5, top - 6).l(dx + 5, top).l(dx - 5, top).z().d(), {
|
|
1732
|
+
fill: c.skinShade,
|
|
1733
|
+
stroke: c.skinInk,
|
|
1734
|
+
sw: LINE * .6
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
//#endregion
|
|
1738
|
+
//#region src/avatar/render/walker.ts
|
|
1739
|
+
/**
|
|
1740
|
+
* The world walker (`figure: 'walker'`): a small, chunky, chibi-like
|
|
1741
|
+
* full-body character for maps and crowds: a short round body with stubby
|
|
1742
|
+
* legs and arms, a shirt, trousers and shoes, and a big head on top
|
|
1743
|
+
* ({@link WALKER_HEAD_SCALE}). It takes the blob's pose names: `walk` and
|
|
1744
|
+
* `run` waddle (quick steps that bounce and rock from foot to foot), `sit`
|
|
1745
|
+
* folds the legs over a seat, `idle` breathes, `wave` and `cheer` lift the
|
|
1746
|
+
* arms.
|
|
1747
|
+
*
|
|
1748
|
+
* Standing, the origin is the feet. Sitting (`sit`, `play`), the origin is
|
|
1749
|
+
* the seat: the hips rest on it and the short legs dangle over its front
|
|
1750
|
+
* edge, the feet {@link WALKER_SEAT_DROP} units below it.
|
|
1751
|
+
*/
|
|
1752
|
+
/** The head's scale on a walker (the blob draws it at 1). */
|
|
1753
|
+
const WALKER_HEAD_SCALE = .62;
|
|
1754
|
+
/** How far below the seat a sitter's dangling feet reach (rig units): short legs swing clear of the floor. */
|
|
1755
|
+
const WALKER_SEAT_DROP = 13;
|
|
1756
|
+
/** Standing: a short, round body (half the first walker's 72 units to the shoulders) under the head. */
|
|
1757
|
+
const WALKER_STANDING = {
|
|
1758
|
+
hip: -18,
|
|
1759
|
+
shoulder: -38,
|
|
1760
|
+
head: -70
|
|
1761
|
+
};
|
|
1762
|
+
const SEATED = {
|
|
1763
|
+
hip: -3,
|
|
1764
|
+
shoulder: -23,
|
|
1765
|
+
head: -55
|
|
1766
|
+
};
|
|
1767
|
+
const LEG = 18;
|
|
1768
|
+
const UPPER_ARM = 8;
|
|
1769
|
+
const FOREARM = 7;
|
|
1770
|
+
const LEG_W = 13;
|
|
1771
|
+
const SLEEVE_W = 12.5;
|
|
1772
|
+
const ARM_W = 9;
|
|
1773
|
+
/** How far out the hips and shoulders sit from the middle. */
|
|
1774
|
+
const HIP_X = 7.5;
|
|
1775
|
+
const SHOULDER_X = 19;
|
|
1776
|
+
/** The seated thigh, along the facing. */
|
|
1777
|
+
const THIGH = 9;
|
|
1778
|
+
const DEG$1 = Math.PI / 180;
|
|
1779
|
+
function viewOf(angleDeg) {
|
|
1780
|
+
const side = Math.sin(angleDeg * DEG$1);
|
|
1781
|
+
const front = Math.cos(angleDeg * DEG$1);
|
|
1782
|
+
return {
|
|
1783
|
+
side,
|
|
1784
|
+
front,
|
|
1785
|
+
forward: [side, front * .45],
|
|
1786
|
+
isBack: angleDeg > 90,
|
|
1787
|
+
width: 1 - .26 * Math.abs(side)
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
/** A body side's screen x for a lateral distance (sides overlap when turned). */
|
|
1791
|
+
const lateralX = (v, sideOf, distance) => sideOf * distance * v.front;
|
|
1792
|
+
/** How near the viewer a side is (> 0: drawn in front when turned). */
|
|
1793
|
+
const nearness = (v, sideOf) => -sideOf * v.side;
|
|
1794
|
+
const add = (a, b, k = 1) => [a[0] + b[0] * k, a[1] + b[1] * k];
|
|
1795
|
+
/** The walk's and run's step: stride angle, foot lift, bounce and side-to-side rock (degrees). */
|
|
1796
|
+
const GAIT = {
|
|
1797
|
+
walk: {
|
|
1798
|
+
stride: 36,
|
|
1799
|
+
lift: 3.6,
|
|
1800
|
+
bounce: 3.2,
|
|
1801
|
+
rock: 5
|
|
1802
|
+
},
|
|
1803
|
+
run: {
|
|
1804
|
+
stride: 46,
|
|
1805
|
+
lift: 5.4,
|
|
1806
|
+
bounce: 4.4,
|
|
1807
|
+
rock: 3.5
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
/** The gait a pose steps with, if it steps. */
|
|
1811
|
+
function gaitOf(pose) {
|
|
1812
|
+
return pose === "run" ? GAIT.run : pose === "walk" || pose === "dance" ? GAIT.walk : null;
|
|
1813
|
+
}
|
|
1814
|
+
/** A shoulder joint: out at the torso's edge (narrower when turned), nudged along the facing. */
|
|
1815
|
+
const shoulderAt = (v, h, sideOf, bob) => [lateralX(v, sideOf, SHOULDER_X * v.width) + v.forward[0] * 1.5, h.shoulder + 4 + bob];
|
|
1816
|
+
function standingLimbs(v, h, pose, phase, bob) {
|
|
1817
|
+
const gait = gaitOf(pose);
|
|
1818
|
+
const stepOf = (sideOf) => (phase + (sideOf === 1 ? 0 : .5)) * Math.PI * 2;
|
|
1819
|
+
const swingOf = (sideOf) => gait ? Math.sin(stepOf(sideOf)) : 0;
|
|
1820
|
+
return {
|
|
1821
|
+
leg: (sideOf) => {
|
|
1822
|
+
const hip = [lateralX(v, sideOf, HIP_X), h.hip + bob];
|
|
1823
|
+
const lift = gait ? Math.max(0, Math.cos(stepOf(sideOf))) * gait.lift : 0;
|
|
1824
|
+
const reach = gait ? Math.sin(gait.stride * DEG$1) * LEG * swingOf(sideOf) : 0;
|
|
1825
|
+
const foot = [hip[0] + v.forward[0] * reach, -bob * .2 + v.forward[1] * reach - lift];
|
|
1826
|
+
return [
|
|
1827
|
+
hip,
|
|
1828
|
+
[(hip[0] + foot[0]) / 2 + v.forward[0] * lift, (hip[1] + foot[1]) / 2 + v.forward[1] * lift],
|
|
1829
|
+
foot
|
|
1830
|
+
];
|
|
1831
|
+
},
|
|
1832
|
+
arm: (sideOf) => {
|
|
1833
|
+
const shoulder = shoulderAt(v, h, sideOf, bob);
|
|
1834
|
+
const out = sideOf * v.front * 3.4;
|
|
1835
|
+
if (pose === "cheer" || pose === "wave" && sideOf === -1) {
|
|
1836
|
+
const wave = pose === "wave" ? Math.sin(phase * Math.PI * 2) * 3 : 0;
|
|
1837
|
+
const elbow = [shoulder[0] + out * 2.2, shoulder[1] - 7];
|
|
1838
|
+
return [
|
|
1839
|
+
shoulder,
|
|
1840
|
+
elbow,
|
|
1841
|
+
[elbow[0] + out * .8 + wave, elbow[1] - 10]
|
|
1842
|
+
];
|
|
1843
|
+
}
|
|
1844
|
+
const swing = gait ? -swingOf(sideOf) * (pose === "run" ? 1.4 : 1.1) : Math.sin(phase * Math.PI * 2) * .06;
|
|
1845
|
+
const reach = Math.sin(30 * DEG$1) * swing;
|
|
1846
|
+
const elbow = [shoulder[0] + out + v.forward[0] * UPPER_ARM * reach * .6, shoulder[1] + UPPER_ARM + v.forward[1] * UPPER_ARM * reach];
|
|
1847
|
+
return [
|
|
1848
|
+
shoulder,
|
|
1849
|
+
elbow,
|
|
1850
|
+
[elbow[0] + out * .35 + v.forward[0] * FOREARM * reach * 1.6, elbow[1] + FOREARM - Math.abs(reach) * 2 + v.forward[1] * FOREARM * reach]
|
|
1851
|
+
];
|
|
1852
|
+
}
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
function seatedLimbs(v, h, pose) {
|
|
1856
|
+
const thigh = [v.forward[0] * THIGH, v.forward[1] * 10];
|
|
1857
|
+
return {
|
|
1858
|
+
leg: (sideOf) => {
|
|
1859
|
+
const hip = [lateralX(v, sideOf, HIP_X), h.hip];
|
|
1860
|
+
const knee = add(hip, thigh);
|
|
1861
|
+
return [
|
|
1862
|
+
hip,
|
|
1863
|
+
knee,
|
|
1864
|
+
[knee[0] + v.forward[0] * 2, 11]
|
|
1865
|
+
];
|
|
1866
|
+
},
|
|
1867
|
+
arm: (sideOf) => {
|
|
1868
|
+
const shoulder = shoulderAt(v, h, sideOf, 0);
|
|
1869
|
+
return [
|
|
1870
|
+
shoulder,
|
|
1871
|
+
[shoulder[0] + sideOf * v.front * 3 + v.forward[0] * 2, shoulder[1] + UPPER_ARM],
|
|
1872
|
+
add([lateralX(v, sideOf, pose === "play" ? 4 : 8), h.hip - 3], thigh, pose === "play" ? .9 : .6)
|
|
1873
|
+
];
|
|
1874
|
+
}
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
/** The long-hair split in head space: a little above the torso's top edge (the first walker's line sat 12 head units above it). */
|
|
1878
|
+
function shoulderLineOf(torsoTop, headY) {
|
|
1879
|
+
return (torsoTop - 3 - headY) / WALKER_HEAD_SCALE - 12;
|
|
1880
|
+
}
|
|
1881
|
+
/** The walker's drawing for a pose, at a facing angle (0 = S, 180 = N; west facings are mirrored by the caller). */
|
|
1882
|
+
function walkerLayout(c, pose, angleDeg, phase, outfit = outfitFor(c)) {
|
|
1883
|
+
const v = viewOf(angleDeg);
|
|
1884
|
+
const isSitting = pose === "sit" || pose === "play";
|
|
1885
|
+
const h = isSitting ? SEATED : WALKER_STANDING;
|
|
1886
|
+
const cycle = Math.sin(phase * Math.PI * 2);
|
|
1887
|
+
const gait = isSitting ? null : gaitOf(pose);
|
|
1888
|
+
const hop = pose === "jump" || pose === "cheer" ? Math.abs(Math.sin(phase * Math.PI)) * (pose === "jump" ? 12 : 6) : 0;
|
|
1889
|
+
const bob = gait ? -gait.bounce * (1 - Math.abs(cycle)) : pose === "idle" ? cycle * .6 : 0;
|
|
1890
|
+
const rock = gait ? cycle * gait.rock * (.4 + .6 * Math.abs(v.front)) : 0;
|
|
1891
|
+
const limbs = isSitting ? seatedLimbs(v, h, pose) : standingLimbs(v, h, pose, phase, bob);
|
|
1892
|
+
const o = outfit;
|
|
1893
|
+
const [farSide, nearSide] = nearness(v, 1) > nearness(v, -1) ? [-1, 1] : [1, -1];
|
|
1894
|
+
const isTurned = Math.abs(v.side) > .3;
|
|
1895
|
+
const lean = v.side * (isSitting ? .6 : 1);
|
|
1896
|
+
const armNodes = (sideOf) => {
|
|
1897
|
+
const [shoulder, elbow, wrist] = limbs.arm(sideOf);
|
|
1898
|
+
return [
|
|
1899
|
+
...limb([
|
|
1900
|
+
shoulder,
|
|
1901
|
+
elbow,
|
|
1902
|
+
wrist
|
|
1903
|
+
], ARM_W, c.skin, c.skinInk),
|
|
1904
|
+
...limb([shoulder, add(shoulder, [elbow[0] - shoulder[0], elbow[1] - shoulder[1]], .7)], SLEEVE_W, c.body, c.bodyInk),
|
|
1905
|
+
hand(c, wrist)
|
|
1906
|
+
];
|
|
1907
|
+
};
|
|
1908
|
+
const legNodes = (sideOf) => {
|
|
1909
|
+
const points = limbs.leg(sideOf);
|
|
1910
|
+
const foot = points[2];
|
|
1911
|
+
return [...limb(points, LEG_W, o.trousers, o.trousersInk), shoe(foot, v.forward, o)];
|
|
1912
|
+
};
|
|
1913
|
+
const torsoTop = h.shoulder + bob;
|
|
1914
|
+
const torso = shirt(c, v.width, torsoTop, h.hip + 3 + bob, lean, v.isBack);
|
|
1915
|
+
const isTucked = isSitting && v.isBack;
|
|
1916
|
+
const behind = isTucked ? [...armNodes(farSide), ...armNodes(nearSide)] : isTurned ? armNodes(farSide) : [];
|
|
1917
|
+
const isLegsFirst = v.isBack || !isSitting;
|
|
1918
|
+
const legs = isTucked ? [] : [...legNodes(farSide), ...legNodes(nearSide)];
|
|
1919
|
+
const body = [
|
|
1920
|
+
...isLegsFirst ? legs : [],
|
|
1921
|
+
hips(o, v.width, h.hip + bob),
|
|
1922
|
+
neck(c, torsoTop, v.forward[0] * 2),
|
|
1923
|
+
...torso,
|
|
1924
|
+
...isLegsFirst ? [] : legs,
|
|
1925
|
+
...isTucked ? [] : isTurned ? armNodes(nearSide) : [...armNodes(-1), ...armNodes(1)]
|
|
1926
|
+
];
|
|
1927
|
+
const rocked = (nodes) => rock === 0 || nodes.length === 0 ? nodes : [g(nodes, { tf: [rot(rock)] })];
|
|
1928
|
+
const headY = h.head + bob * 1.1;
|
|
1929
|
+
const prop = add([0, h.hip - 6], v.forward, 8);
|
|
1930
|
+
return {
|
|
1931
|
+
behind: rocked(behind),
|
|
1932
|
+
body: rocked(body),
|
|
1933
|
+
front: [],
|
|
1934
|
+
headDx: v.forward[0] * 1.5 - headY * Math.sin(rock * DEG$1),
|
|
1935
|
+
headDy: 0,
|
|
1936
|
+
headY,
|
|
1937
|
+
headScale: WALKER_HEAD_SCALE,
|
|
1938
|
+
shoulderLine: shoulderLineOf(torsoTop, headY),
|
|
1939
|
+
tilt: (gait ? v.side * 2 : 0) + rock,
|
|
1940
|
+
lift: hop,
|
|
1941
|
+
prop: {
|
|
1942
|
+
x: prop[0],
|
|
1943
|
+
y: prop[1]
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
const GREY = "#6f7787";
|
|
1948
|
+
const GREY_INK = "#5c6474";
|
|
1949
|
+
/** Every colour slot in the silhouette grey. */
|
|
1950
|
+
const GREY_COLOURS = {
|
|
1951
|
+
skin: GREY,
|
|
1952
|
+
skinShade: GREY,
|
|
1953
|
+
skinLight: GREY,
|
|
1954
|
+
skinInk: GREY_INK,
|
|
1955
|
+
eyeRim: GREY,
|
|
1956
|
+
hair: GREY,
|
|
1957
|
+
hairShade: GREY,
|
|
1958
|
+
hairLight: GREY,
|
|
1959
|
+
hairInk: GREY_INK,
|
|
1960
|
+
hairBuzz: GREY,
|
|
1961
|
+
brow: GREY,
|
|
1962
|
+
beard: GREY,
|
|
1963
|
+
beardShade: GREY,
|
|
1964
|
+
beardInk: GREY_INK,
|
|
1965
|
+
stubble: GREY,
|
|
1966
|
+
eye: GREY,
|
|
1967
|
+
eyeDark: GREY,
|
|
1968
|
+
body: GREY,
|
|
1969
|
+
bodyShade: GREY,
|
|
1970
|
+
bodyLight: GREY,
|
|
1971
|
+
bodyInk: GREY_INK
|
|
1972
|
+
};
|
|
1973
|
+
/** The grey walker shown before the art loads (or for a hidden player): its nodes, head centre and top. */
|
|
1974
|
+
function walkerSilhouette() {
|
|
1975
|
+
const b = walkerLayout(GREY_COLOURS, "idle", 0, 0, {
|
|
1976
|
+
trousers: GREY,
|
|
1977
|
+
trousersInk: GREY_INK,
|
|
1978
|
+
shoes: GREY,
|
|
1979
|
+
shoesInk: GREY_INK
|
|
1980
|
+
});
|
|
1981
|
+
const r = 46 * WALKER_HEAD_SCALE;
|
|
1982
|
+
return {
|
|
1983
|
+
nodes: [
|
|
1984
|
+
...b.behind,
|
|
1985
|
+
...b.body,
|
|
1986
|
+
ellipse(0, b.headY, r, r * 1.05, { fill: GREY })
|
|
1987
|
+
],
|
|
1988
|
+
headY: b.headY,
|
|
1989
|
+
top: b.headY - r - 4
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
//#endregion
|
|
1993
|
+
//#region src/avatar/render/compose.ts
|
|
1994
|
+
/**
|
|
1995
|
+
* Composes a look and a pose into a scene in rig space: the feet at (0, 0),
|
|
1996
|
+
* y down, the head centre near (0, −96) (§14.2). Layers go back to front as
|
|
1997
|
+
* in §3.2, with facial hair split into beard and moustache layers.
|
|
1998
|
+
*/
|
|
1999
|
+
/** The lens tint drawn with a mirror finish. */
|
|
2000
|
+
const MIRROR_TINT = "p:lens-mirror";
|
|
2001
|
+
/** The head centre's height above the feet. */
|
|
2002
|
+
const HEAD_Y = -96;
|
|
2003
|
+
const DEG = Math.PI / 180;
|
|
2004
|
+
const FACING_ANGLE = {
|
|
2005
|
+
S: 0,
|
|
2006
|
+
SE: 34,
|
|
2007
|
+
E: 70,
|
|
2008
|
+
NE: 145,
|
|
2009
|
+
N: 180
|
|
2010
|
+
};
|
|
2011
|
+
const MIRROR = {
|
|
2012
|
+
SW: "SE",
|
|
2013
|
+
W: "E",
|
|
2014
|
+
NW: "NE"
|
|
2015
|
+
};
|
|
2016
|
+
const VIEW_FACING = {
|
|
2017
|
+
front: "S",
|
|
2018
|
+
back: "N",
|
|
2019
|
+
left: "W",
|
|
2020
|
+
right: "E"
|
|
2021
|
+
};
|
|
2022
|
+
/** The facing an options object asks for. */
|
|
2023
|
+
function facingOf(opts) {
|
|
2024
|
+
return opts.facing ?? (opts.view ? VIEW_FACING[opts.view] : "S");
|
|
2025
|
+
}
|
|
2026
|
+
function resolveParts(look, art) {
|
|
2027
|
+
const slot = (name) => look.accessories.find((a) => a.slot === name);
|
|
2028
|
+
const hatAcc = slot("headwear");
|
|
2029
|
+
const glassAcc = slot("glasses");
|
|
2030
|
+
const earAcc = slot("ears");
|
|
2031
|
+
const face = slot("face");
|
|
2032
|
+
return {
|
|
2033
|
+
c: lookColours(look),
|
|
2034
|
+
hair: art.hair[partName(look.hair.style)],
|
|
2035
|
+
hat: hatAcc ? art.headwear[partName(hatAcc.id)] : void 0,
|
|
2036
|
+
hatColour: accessoryColour(look, hatAcc),
|
|
2037
|
+
beard: art.beard[partName(look.facialHair.style)],
|
|
2038
|
+
glasses: glassAcc ? art.glasses[partName(glassAcc.id)] : void 0,
|
|
2039
|
+
glassesColour: glassAcc?.colour === void 0 ? FRAME_BLACK : accessoryColour(look, glassAcc),
|
|
2040
|
+
lensGlass: glassAcc?.tint === void 0 ? {} : {
|
|
2041
|
+
tint: resolveColour(glassAcc.tint),
|
|
2042
|
+
...glassAcc.tint === MIRROR_TINT ? { isMirror: true } : {}
|
|
2043
|
+
},
|
|
2044
|
+
freckles: face?.id === "face.freckles",
|
|
2045
|
+
rosy: face?.id === "face.rosy",
|
|
2046
|
+
earExtra: earAcc ? {
|
|
2047
|
+
kind: earAcc.id === "ears.hoops" ? "hoops" : "studs",
|
|
2048
|
+
colour: earAcc.colour === void 0 ? "#f5c542" : accessoryColour(look, earAcc)
|
|
2049
|
+
} : void 0,
|
|
2050
|
+
browStyle: partName(look.brows.style)
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
/** The far-side clip for a turned head's rear art (a back-of-skull crescent). */
|
|
2054
|
+
function backOfHead(phi) {
|
|
2055
|
+
const left = -46 * Math.cos(phi) - 3;
|
|
2056
|
+
const cx = (left + 46 + 12) / 2;
|
|
2057
|
+
const rx = (58 - left) / 2;
|
|
2058
|
+
return {
|
|
2059
|
+
crescent: {
|
|
2060
|
+
shapes: [path(`M-200 -200H200V200H-200Z M${n(cx - rx)} 8A${n(rx)} 64 0 1 0 ${n(cx + rx)} 8A${n(rx)} 64 0 1 0 ${n(cx - rx)} 8Z`, {})],
|
|
2061
|
+
evenOdd: true
|
|
2062
|
+
},
|
|
2063
|
+
half: { shapes: [rect(-200, -200, 200 + cx, 400, {})] }
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
const splits = /* @__PURE__ */ new Map();
|
|
2067
|
+
/** The split at a shoulder line (head space), cached per line. */
|
|
2068
|
+
function shoulderSplit(line) {
|
|
2069
|
+
const at = Math.round(line * 4) / 4;
|
|
2070
|
+
let split = splits.get(at);
|
|
2071
|
+
if (!split) {
|
|
2072
|
+
split = {
|
|
2073
|
+
y: at,
|
|
2074
|
+
above: { shapes: [rect(-200, -200, 400, 200 + at + 1, {}), path(HEAD_PATH.d(), {})] },
|
|
2075
|
+
below: { shapes: [rect(-200, at - 1, 400, 300, {})] }
|
|
2076
|
+
};
|
|
2077
|
+
splits.set(at, split);
|
|
2078
|
+
}
|
|
2079
|
+
return split;
|
|
2080
|
+
}
|
|
2081
|
+
/** True when a layer reaches below the shoulder line. */
|
|
2082
|
+
function hangs(shapes, split) {
|
|
2083
|
+
return shapes?.some((s) => s.d.bottom() > split.y) === true;
|
|
2084
|
+
}
|
|
2085
|
+
/** Splits a hair layer at the shoulders: the part above stays in `over`, the part below goes in `behind`. */
|
|
2086
|
+
function splitAtShoulders(nodes, split, doesHang, over, behind) {
|
|
2087
|
+
if (nodes.length === 0) return;
|
|
2088
|
+
if (!doesHang) {
|
|
2089
|
+
over.push(...nodes);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
over.push(g(nodes, { clip: split.above }));
|
|
2093
|
+
behind.push(g(nodes, { clip: split.below }));
|
|
2094
|
+
}
|
|
2095
|
+
/** The front and side views of the head. */
|
|
2096
|
+
function frontHead(ctx, p, isPlaying, status, angle, gazeX, split) {
|
|
2097
|
+
const hatCut = p.hat && typeof p.hat.hides === "number" ? { shapes: [rect(-120, p.hat.hides, 240, 260, {})] } : void 0;
|
|
2098
|
+
const isHairShown = !(p.hat && p.hat.hides === "all");
|
|
2099
|
+
const cut = (nodes) => hatCut && nodes.length > 0 ? [g(nodes, { clip: hatCut })] : nodes;
|
|
2100
|
+
const hairTie = tones(ctx.c.body);
|
|
2101
|
+
const hatTones = tones(p.hatColour);
|
|
2102
|
+
const warpFront = turnWarp((angle + gazeX * 6) * DEG);
|
|
2103
|
+
const warpBack = ctx.phi === 0 ? void 0 : (px, py) => [px - Math.sin(ctx.phi) * 8, py];
|
|
2104
|
+
const behindBody = [];
|
|
2105
|
+
const out = [];
|
|
2106
|
+
if (isHairShown) behindBody.push(...cut(paintShapes(ctx.c, hairTie, p.hair?.back, warpBack)));
|
|
2107
|
+
if (p.hat?.back) out.push(...paintShapes(ctx.c, hatTones, p.hat.back, warpBack));
|
|
2108
|
+
if (!p.hat?.hidesEars) out.push(...ears(ctx, "behind", p.earExtra));
|
|
2109
|
+
out.push(...head(ctx));
|
|
2110
|
+
const turnS = Math.sin(ctx.phi);
|
|
2111
|
+
if (Math.abs(turnS) > .3) {
|
|
2112
|
+
const co = Math.cos(ctx.phi);
|
|
2113
|
+
const shift = (px, py) => [px * (.92 + .08 * co) - turnS * 5, py];
|
|
2114
|
+
const clips = backOfHead(ctx.phi);
|
|
2115
|
+
const onFarSide = (nodes) => nodes.length > 0 ? [g([g(nodes, { clip: clips.crescent })], { clip: clips.half })] : [];
|
|
2116
|
+
if (isHairShown) splitAtShoulders(onFarSide(cut(paintShapes(ctx.c, hairTie, p.hair?.rear, shift))), split, hangs(p.hair?.rear, split), out, behindBody);
|
|
2117
|
+
if (p.hat) out.push(...onFarSide(paintShapes(ctx.c, hatTones, p.hat.rear, shift)));
|
|
2118
|
+
}
|
|
2119
|
+
if (!p.hat?.hidesEars) out.push(...ears(ctx, "front", p.earExtra));
|
|
2120
|
+
out.push(...cheeks(ctx, p.freckles, p.rosy));
|
|
2121
|
+
const faceWarp = turnWarp(ctx.phi, ctx.dy * .8);
|
|
2122
|
+
out.push(...paintShapes(ctx.c, hairTie, p.beard?.beard, faceWarp));
|
|
2123
|
+
if (!isPlaying) out.push(...eye(ctx, -1), ...eye(ctx, 1));
|
|
2124
|
+
out.push(...brow(ctx, -1, p.browStyle), ...brow(ctx, 1, p.browStyle));
|
|
2125
|
+
if (turnS > .8) out.push(path("M43 9C50 11 51 18 43.5 20", {
|
|
2126
|
+
fill: ctx.c.skin,
|
|
2127
|
+
stroke: ctx.c.skinInk,
|
|
2128
|
+
sw: LINE
|
|
2129
|
+
}));
|
|
2130
|
+
out.push(...nose(ctx), ...mouth(ctx));
|
|
2131
|
+
out.push(...paintShapes(ctx.c, hairTie, p.beard?.moustache, faceWarp));
|
|
2132
|
+
if (isHairShown) {
|
|
2133
|
+
splitAtShoulders(cut(paintShapes(ctx.c, hairTie, p.hair?.front, warpFront)), split, hangs(p.hair?.front, split), out, behindBody);
|
|
2134
|
+
if (p.hair?.front?.some((s) => s.clip === "head")) out.push(path(HEAD_PATH.d(), {
|
|
2135
|
+
fill: "none",
|
|
2136
|
+
stroke: ctx.c.skinInk,
|
|
2137
|
+
sw: LINE
|
|
2138
|
+
}));
|
|
2139
|
+
}
|
|
2140
|
+
if (isPlaying) out.push(...goggles(ctx));
|
|
2141
|
+
else if (p.glasses) out.push(...glasses(ctx, p.glasses, p.glassesColour, p.lensGlass));
|
|
2142
|
+
if (p.hat) out.push(...paintShapes(ctx.c, hatTones, p.hat.front, warpFront));
|
|
2143
|
+
if (status === "headset") out.push(...headset(ctx, false));
|
|
2144
|
+
out.push(...extras(ctx));
|
|
2145
|
+
return {
|
|
2146
|
+
behindBody,
|
|
2147
|
+
head: out
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
/** The back views (N, NE): the rear art wrapped past 180°. */
|
|
2151
|
+
function backHead(ctx, p, isPlaying, status, angle) {
|
|
2152
|
+
const phi = (angle - 180) * DEG;
|
|
2153
|
+
const warp = turnWarp(phi);
|
|
2154
|
+
const isHairShown = !(p.hat && p.hat.hides === "all");
|
|
2155
|
+
const hatCut = p.hat && typeof p.hat.hides === "number" ? { shapes: [rect(-120, p.hat.hides, 240, 260, {})] } : void 0;
|
|
2156
|
+
const out = [];
|
|
2157
|
+
if (!p.hat?.hidesEars) out.push(...ears({
|
|
2158
|
+
...ctx,
|
|
2159
|
+
phi
|
|
2160
|
+
}, "behind", p.earExtra));
|
|
2161
|
+
out.push(...head({
|
|
2162
|
+
...ctx,
|
|
2163
|
+
phi: 0
|
|
2164
|
+
}));
|
|
2165
|
+
if (isHairShown) {
|
|
2166
|
+
const rear = paintShapes(ctx.c, tones(ctx.c.body), p.hair?.rear, warp);
|
|
2167
|
+
out.push(...hatCut && rear.length > 0 ? [g(rear, { clip: hatCut })] : rear);
|
|
2168
|
+
if (p.hair?.rear.some((s) => s.clip === "head")) out.push(path(HEAD_PATH.d(), {
|
|
2169
|
+
fill: "none",
|
|
2170
|
+
stroke: ctx.c.skinInk,
|
|
2171
|
+
sw: LINE
|
|
2172
|
+
}));
|
|
2173
|
+
}
|
|
2174
|
+
if (isPlaying) out.push(...gogglesStrapBack(ctx, phi));
|
|
2175
|
+
if (p.hat) out.push(...paintShapes(ctx.c, tones(p.hatColour), p.hat.rear, warp));
|
|
2176
|
+
if (status === "headset") out.push(...headset(ctx, true));
|
|
2177
|
+
return {
|
|
2178
|
+
behindBody: [],
|
|
2179
|
+
head: out
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
/** The grey silhouette: a malformed code, a hidden player, or before the art loads. */
|
|
2183
|
+
function silhouette(hasShadow) {
|
|
2184
|
+
const grey = "#6f7787";
|
|
2185
|
+
return [
|
|
2186
|
+
...hasShadow ? [groundShadow(0)] : [],
|
|
2187
|
+
path("M-35 -2C-42 -20 -38 -46 -22 -56C-8 -62 8 -62 22 -56C38 -46 42 -20 35 -2C20 3 -20 3 -35 -2Z", { fill: grey }),
|
|
2188
|
+
path(HEAD_PATH.d(), { fill: grey }, [tr(0, -96), sc(.94)])
|
|
2189
|
+
];
|
|
2190
|
+
}
|
|
2191
|
+
function anchorsFor(headX, headY, top, prop, mirror) {
|
|
2192
|
+
const at = (px, py) => ({
|
|
2193
|
+
x: px * mirror,
|
|
2194
|
+
y: py
|
|
2195
|
+
});
|
|
2196
|
+
return {
|
|
2197
|
+
feet: at(0, 0),
|
|
2198
|
+
body: at(0, -30),
|
|
2199
|
+
neck: at(headX, headY + 46),
|
|
2200
|
+
head: at(headX, headY),
|
|
2201
|
+
eyeLine: at(headX, headY + 4),
|
|
2202
|
+
mouth: at(headX, headY + 27),
|
|
2203
|
+
hatTop: at(headX, top),
|
|
2204
|
+
nameplate: at(0, top - 10),
|
|
2205
|
+
bubble: at(0, top - 14),
|
|
2206
|
+
seat: at(0, 0),
|
|
2207
|
+
prop: at(prop.x, prop.y),
|
|
2208
|
+
shadow: at(0, 0),
|
|
2209
|
+
handL: at(-40, -30),
|
|
2210
|
+
handR: at(40, -30),
|
|
2211
|
+
pod: at(0, 14)
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
/** The body for a figure. */
|
|
2215
|
+
function figureLayout(c, opts, pose, phi, angle, phase) {
|
|
2216
|
+
if (isWalker(opts)) {
|
|
2217
|
+
const walker = walkerLayout(c, pose, angle, phase);
|
|
2218
|
+
return {
|
|
2219
|
+
b: walker,
|
|
2220
|
+
headY: walker.headY,
|
|
2221
|
+
headScale: walker.headScale,
|
|
2222
|
+
shoulderLine: walker.shoulderLine
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
const b = bodyLayout(c, pose, phi, phase, opts.stool === true);
|
|
2226
|
+
return {
|
|
2227
|
+
b,
|
|
2228
|
+
headY: -96 + b.headDy,
|
|
2229
|
+
headScale: 1,
|
|
2230
|
+
shoulderLine: 36
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
/** Composes a look (or `null` for the silhouette) with a pose. */
|
|
2234
|
+
function composeAvatar(look, opts = {}) {
|
|
2235
|
+
const art = currentArt();
|
|
2236
|
+
const facingIn = facingOf(opts);
|
|
2237
|
+
const isMirrored = MIRROR[facingIn] !== void 0;
|
|
2238
|
+
const facing = MIRROR[facingIn] ?? (facingIn === "S" || facingIn === "SE" || facingIn === "E" || facingIn === "NE" ? facingIn : "N");
|
|
2239
|
+
const hasShadow = opts.shadow ?? opts.frame === "full";
|
|
2240
|
+
if (!look || !art) {
|
|
2241
|
+
if (isWalker(opts)) {
|
|
2242
|
+
const shape = walkerSilhouette();
|
|
2243
|
+
return {
|
|
2244
|
+
nodes: [...hasShadow ? [groundShadow(0, 22)] : [], ...shape.nodes],
|
|
2245
|
+
anchors: anchorsFor(0, shape.headY, shape.top, {
|
|
2246
|
+
x: 0,
|
|
2247
|
+
y: -40
|
|
2248
|
+
}, 1)
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
return {
|
|
2252
|
+
nodes: silhouette(hasShadow),
|
|
2253
|
+
anchors: anchorsFor(0, -96, -156, {
|
|
2254
|
+
x: 0,
|
|
2255
|
+
y: -30
|
|
2256
|
+
}, 1)
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
const pose = opts.pose ?? {};
|
|
2260
|
+
const bodyPose = pose.body ?? "idle";
|
|
2261
|
+
const status = pose.status ?? (bodyPose === "play" ? "goggles" : null);
|
|
2262
|
+
const isPlaying = status === "goggles";
|
|
2263
|
+
const face = pose.face ?? EXPRESSIONS[pose.expression ?? (bodyPose === "play" ? "determined" : bodyPose === "away" ? "sleepy" : "neutral")];
|
|
2264
|
+
const gaze = pose.look ?? {
|
|
2265
|
+
x: 0,
|
|
2266
|
+
y: 0
|
|
2267
|
+
};
|
|
2268
|
+
const angle = FACING_ANGLE[facing];
|
|
2269
|
+
const isBack = angle > 90;
|
|
2270
|
+
const parts = resolveParts(look, art);
|
|
2271
|
+
const ctx = {
|
|
2272
|
+
c: parts.c,
|
|
2273
|
+
face,
|
|
2274
|
+
gaze,
|
|
2275
|
+
phi: (angle + gaze.x * 11) * DEG,
|
|
2276
|
+
dy: gaze.y * 3,
|
|
2277
|
+
blink: pose.blink ?? 0,
|
|
2278
|
+
talk: pose.talk ?? 0
|
|
2279
|
+
};
|
|
2280
|
+
const { b, headY: bodyHeadY, headScale, shoulderLine } = figureLayout(parts.c, opts, bodyPose, ctx.phi, angle, pose.phase ?? .25);
|
|
2281
|
+
const headX = b.headDx + (pose.bob?.x ?? 0);
|
|
2282
|
+
const headY = bodyHeadY + (pose.bob?.y ?? 0);
|
|
2283
|
+
const tilt = face.headTilt + b.tilt + (pose.tilt ?? 0);
|
|
2284
|
+
const sq = face.squash + (pose.squash ?? 0);
|
|
2285
|
+
const drawn = isBack ? backHead(ctx, parts, isPlaying, status, angle) : frontHead(ctx, parts, isPlaying, status, angle, gaze.x, shoulderSplit(shoulderLine));
|
|
2286
|
+
const headNodes = drawn.head;
|
|
2287
|
+
const top = headY - (50 + Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0)) * headScale;
|
|
2288
|
+
if (status === "crown") headNodes.push(...crown(Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0)));
|
|
2289
|
+
const props = status && !isBack ? handProp(status, b.prop.x, b.prop.y, parts.c.body, parts.c.bodyInk) : [];
|
|
2290
|
+
const handProps = headScale === 1 || props.length === 0 ? props : [g(props, { tf: [
|
|
2291
|
+
tr(b.prop.x, b.prop.y),
|
|
2292
|
+
sc(headScale * 1.1),
|
|
2293
|
+
tr(-b.prop.x, -b.prop.y)
|
|
2294
|
+
] })];
|
|
2295
|
+
const headTf = [
|
|
2296
|
+
tr(headX, headY),
|
|
2297
|
+
rot(tilt),
|
|
2298
|
+
sc((1 + sq) * headScale, (1 - sq) * headScale)
|
|
2299
|
+
];
|
|
2300
|
+
const character = [
|
|
2301
|
+
...b.behind,
|
|
2302
|
+
...drawn.behindBody.length > 0 ? [g(drawn.behindBody, { tf: headTf })] : [],
|
|
2303
|
+
...b.body,
|
|
2304
|
+
g(headNodes, { tf: headTf }),
|
|
2305
|
+
...b.front,
|
|
2306
|
+
...handProps
|
|
2307
|
+
];
|
|
2308
|
+
const lifted = b.lift > 0 ? [g(character, { tf: [tr(0, -b.lift)] })] : character;
|
|
2309
|
+
const rimmed = opts.rim ? [g(lifted, { rim: true })] : lifted;
|
|
2310
|
+
const all = [...hasShadow ? [groundShadow(b.lift, headScale === 1 ? 36 : 22)] : [], ...rimmed];
|
|
2311
|
+
const mirror = isMirrored ? -1 : 1;
|
|
2312
|
+
return {
|
|
2313
|
+
nodes: isMirrored ? [g(all, { tf: [sc(-1, 1)] })] : all,
|
|
2314
|
+
anchors: anchorsFor(headX, headY - b.lift, top - b.lift, {
|
|
2315
|
+
x: b.prop.x,
|
|
2316
|
+
y: b.prop.y - b.lift
|
|
2317
|
+
}, mirror)
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
//#endregion
|
|
2321
|
+
//#region src/avatar/outputs/svg-markup.ts
|
|
2322
|
+
/**
|
|
2323
|
+
* Scene → SVG markup. Ids for clips and the rim filter are prefixed per call,
|
|
2324
|
+
* so many avatars can be inlined on one page. The output holds only numbers,
|
|
2325
|
+
* derived colours and escaped overlay text, so it is safe to insert as HTML.
|
|
2326
|
+
*/
|
|
2327
|
+
/** The rim filter: a dilated, soft light edge behind the silhouette. */
|
|
2328
|
+
const RIM_COLOUR = "#aab3c7";
|
|
2329
|
+
const FONTS = {
|
|
2330
|
+
mono: "'Space Mono', monospace",
|
|
2331
|
+
pixel: "'VT323', monospace"
|
|
2332
|
+
};
|
|
2333
|
+
/** Three decimals, for scales and opacities. */
|
|
2334
|
+
const fine = (v) => String(Math.round(v * 1e3) / 1e3);
|
|
2335
|
+
/** Escapes text for SVG content. */
|
|
2336
|
+
function escapeXml(text) {
|
|
2337
|
+
return text.replace(/[&<>"']/g, (c) => `&#${String(c.charCodeAt(0))};`);
|
|
2338
|
+
}
|
|
2339
|
+
/** A transform list as an SVG `transform` attribute (empty for none). */
|
|
2340
|
+
function tfAttr(tf) {
|
|
2341
|
+
if (!tf || tf.length === 0) return "";
|
|
2342
|
+
return ` transform="${tf.map((op) => {
|
|
2343
|
+
switch (op.k) {
|
|
2344
|
+
case "t": return `translate(${n(op.x)} ${n(op.y)})`;
|
|
2345
|
+
case "s": return `scale(${fine(op.x)} ${fine(op.y)})`;
|
|
2346
|
+
case "r": return `rotate(${n(op.a)})`;
|
|
2347
|
+
case "skx": return `skewX(${n(op.a)})`;
|
|
2348
|
+
}
|
|
2349
|
+
}).join(" ")}"`;
|
|
2350
|
+
}
|
|
2351
|
+
function paintAttrs(p) {
|
|
2352
|
+
let out = ` fill="${p.fill ?? "none"}"`;
|
|
2353
|
+
if (p.evenOdd) out += " fill-rule=\"evenodd\"";
|
|
2354
|
+
if (p.fillOpacity !== void 0) out += ` fill-opacity="${fine(p.fillOpacity)}"`;
|
|
2355
|
+
if (p.stroke && p.stroke !== "none") out += ` stroke="${p.stroke}" stroke-width="${n(p.sw ?? 1)}" stroke-linejoin="round" stroke-linecap="${p.cap ?? "round"}"`;
|
|
2356
|
+
if (p.opacity !== void 0 && p.opacity < 1) out += ` opacity="${fine(p.opacity)}"`;
|
|
2357
|
+
return out;
|
|
2358
|
+
}
|
|
2359
|
+
/** Collects `<defs>` while serialising. */
|
|
2360
|
+
var Writer = class {
|
|
2361
|
+
constructor(prefix) {
|
|
2362
|
+
_defineProperty(this, "defs", []);
|
|
2363
|
+
_defineProperty(this, "count", 0);
|
|
2364
|
+
_defineProperty(this, "usesRim", false);
|
|
2365
|
+
_defineProperty(this, "prefix", void 0);
|
|
2366
|
+
this.prefix = prefix;
|
|
2367
|
+
}
|
|
2368
|
+
id(kind) {
|
|
2369
|
+
this.count += 1;
|
|
2370
|
+
return `${this.prefix}${kind}${this.count.toString(36)}`;
|
|
2371
|
+
}
|
|
2372
|
+
clip(clip) {
|
|
2373
|
+
const id = this.id("c");
|
|
2374
|
+
const rule = clip.evenOdd ? " clip-rule=\"evenodd\"" : "";
|
|
2375
|
+
this.defs.push(`<clipPath id="${id}">${clip.shapes.map((s) => this.node(s).replace("<path ", `<path${rule} `)).join("")}</clipPath>`);
|
|
2376
|
+
return id;
|
|
2377
|
+
}
|
|
2378
|
+
node(node) {
|
|
2379
|
+
switch (node.k) {
|
|
2380
|
+
case "g": {
|
|
2381
|
+
let attrs = tfAttr(node.tf);
|
|
2382
|
+
if (node.clip) attrs += ` clip-path="url(#${this.clip(node.clip)})"`;
|
|
2383
|
+
if (node.opacity !== void 0 && node.opacity < 1) attrs += ` opacity="${fine(node.opacity)}"`;
|
|
2384
|
+
if (node.rim) {
|
|
2385
|
+
this.usesRim = true;
|
|
2386
|
+
attrs += ` filter="url(#${this.prefix}rim)"`;
|
|
2387
|
+
}
|
|
2388
|
+
const inner = node.children.map((child) => this.node(child)).join("");
|
|
2389
|
+
return attrs === "" ? inner : `<g${attrs}>${inner}</g>`;
|
|
2390
|
+
}
|
|
2391
|
+
case "path": return `<path d="${node.d}"${tfAttr(node.tf)}${paintAttrs(node)}/>`;
|
|
2392
|
+
case "ellipse": return node.rx === node.ry ? `<circle cx="${n(node.cx)}" cy="${n(node.cy)}" r="${n(node.rx)}"${tfAttr(node.tf)}${paintAttrs(node)}/>` : `<ellipse cx="${n(node.cx)}" cy="${n(node.cy)}" rx="${n(node.rx)}" ry="${n(node.ry)}"${tfAttr(node.tf)}${paintAttrs(node)}/>`;
|
|
2393
|
+
case "rect": return `<rect x="${n(node.x)}" y="${n(node.y)}" width="${n(node.w)}" height="${n(node.h)}"${node.r ? ` rx="${n(node.r)}"` : ""}${tfAttr(node.tf)}${paintAttrs(node)}/>`;
|
|
2394
|
+
case "text": return `<text x="${n(node.x)}" y="${n(node.y)}" font-family="${FONTS[node.font]}" font-size="${n(node.size)}"${node.bold ? " font-weight=\"700\"" : ""}${node.anchor === "middle" ? " text-anchor=\"middle\"" : ""} paint-order="stroke"${paintAttrs(node)}>${escapeXml(node.text)}</text>`;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
/** A scene as SVG markup plus the `<defs>` it needs. */
|
|
2399
|
+
function sceneToSvg(nodes, prefix) {
|
|
2400
|
+
const writer = new Writer(prefix);
|
|
2401
|
+
const body = nodes.map((node) => writer.node(node)).join("");
|
|
2402
|
+
if (writer.usesRim) writer.defs.push(`<filter id="${prefix}rim" x="-20%" y="-20%" width="140%" height="140%"><feMorphology in="SourceAlpha" operator="dilate" radius="2.8" result="d"/><feFlood flood-color="${RIM_COLOUR}" flood-opacity=".48"/><feComposite in2="d" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>`);
|
|
2403
|
+
return {
|
|
2404
|
+
body,
|
|
2405
|
+
defs: writer.defs.join("")
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
//#endregion
|
|
2409
|
+
export { rect as A, frameBox as C, g as D, ellipse as E, lightness as F, toCode as I, toLook as L, tr as M, contrast as N, line as O, deltaE as P, WALKER_FULL as S, pixelSize as T, faceOf as _, composeAvatar as a, onArtReady as b, Path as c, n as d, roundedRect as f, expressionFromEmote as g, EXPRESSION_NAMES as h, HEAD_Y as i, rot as j, path as k, arc as l, EXPRESSIONS as m, escapeXml as n, WALKER_HEAD_SCALE as o, scallop as p, sceneToSvg as r, WALKER_SEAT_DROP as s, RIM_COLOUR as t, blob as u, isExpression as v, framed as w, ready as x, mix as y };
|
|
2410
|
+
|
|
2411
|
+
//# sourceMappingURL=svg-markup.js.map
|