@diffenderfer-games/hub 2.0.0-next.5 → 2.0.0-next.7
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 +65 -19
- package/README.md +3 -6
- package/dist/auto.d.ts +5 -5
- package/dist/auto.js +2 -2
- package/dist/avatar-pixi.d.ts +147 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +280 -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 +487 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/input.d.ts +2 -2
- 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 +134 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +117 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +13 -5
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +129 -18
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +120 -26
- 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 +503 -14
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +31 -13
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +40 -2
- 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/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +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.js +1 -1
- package/dist/shared/rooms.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 +55 -4
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/src.js +2020 -144
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +1945 -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 +166 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/social.d.ts +2 -2
- package/dist/social.js +2 -2
- package/dist/turns.d.ts +39 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +117 -0
- package/dist/turns.js.map +1 -0
- package/package.json +33 -3
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +302 -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 +236 -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 +303 -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 +60 -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 +84 -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 +62 -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 +8 -0
- package/src/core/errors.ts +7 -1
- package/src/core/hub-progress.ts +39 -8
- package/src/core/hub.ts +174 -6
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/index.ts +9 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/rest/routes.gen.ts +31 -1
- package/src/core/resume/hub-resume.ts +39 -5
- package/src/core/resume/resume-types.ts +2 -2
- 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/turns/hub-turns.ts +140 -0
- package/src/core/turns/turn-game.ts +388 -0
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +112 -13
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +46 -9
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/index.ts +10 -1
- package/src/input/input-system.ts +10 -1
- package/src/input/navigator.ts +11 -0
- package/src/input.ts +1 -0
- package/src/overlay/overlay.ts +25 -1
- package/src/realtime/runtime-api.ts +14 -3
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +182 -0
- package/src/turns.ts +13 -0
|
@@ -0,0 +1,1945 @@
|
|
|
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 site panel colour behind badges. */
|
|
236
|
+
const PANEL = "#11141c";
|
|
237
|
+
/** The frame an options object asks for (default `head`). */
|
|
238
|
+
function frameOf(opts) {
|
|
239
|
+
return opts.frame ?? "head";
|
|
240
|
+
}
|
|
241
|
+
/** Output pixel size for a frame (the longer edge is `size`). */
|
|
242
|
+
function pixelSize(opts) {
|
|
243
|
+
const [, , w, h] = FRAMES[frameOf(opts)];
|
|
244
|
+
const scale = (opts.size ?? 48) / Math.max(w, h);
|
|
245
|
+
return {
|
|
246
|
+
width: w * scale,
|
|
247
|
+
height: h * scale,
|
|
248
|
+
scale
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** The badge ring colour: lighter when the skin is darker than the panel, so every skin has an edge. */
|
|
252
|
+
function ringColour(look) {
|
|
253
|
+
return look && lightness(resolveColour(look.skin)) < .42 ? "#6f7787" : "#2a3142";
|
|
254
|
+
}
|
|
255
|
+
/** Wraps the avatar's nodes in the frame's background (a badge clips to the rounded panel). */
|
|
256
|
+
function framed(nodes, look, opts) {
|
|
257
|
+
const [left, top, w, h] = FRAMES[frameOf(opts)];
|
|
258
|
+
const background = opts.background ?? (frameOf(opts) === "head" ? "badge" : "none");
|
|
259
|
+
if (background === "badge") {
|
|
260
|
+
const r = w * .24;
|
|
261
|
+
return [
|
|
262
|
+
rect(left, top, w, h, { fill: PANEL }, r),
|
|
263
|
+
g(nodes, { clip: { shapes: [rect(left, top, w, h, {}, r)] } }),
|
|
264
|
+
rect(left + 1.5, top + 1.5, w - 3, h - 3, {
|
|
265
|
+
fill: "none",
|
|
266
|
+
stroke: ringColour(look),
|
|
267
|
+
sw: 3
|
|
268
|
+
}, r - 1.5)
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
if (background === "none") return [...nodes];
|
|
272
|
+
const fill = normaliseHex(background);
|
|
273
|
+
return fill ? [rect(left, top, w, h, { fill }), ...nodes] : [...nodes];
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/avatar/art-store.ts
|
|
277
|
+
let art = null;
|
|
278
|
+
let loading = null;
|
|
279
|
+
const waiters = /* @__PURE__ */ new Set();
|
|
280
|
+
/** The loaded art, or `null` before {@link ready} resolves. */
|
|
281
|
+
function currentArt() {
|
|
282
|
+
return art;
|
|
283
|
+
}
|
|
284
|
+
/** Loads the art chunk (once). Resolves when rendering is synchronous. */
|
|
285
|
+
function ready() {
|
|
286
|
+
loading ?? (loading = import("./art.js").then((mod) => {
|
|
287
|
+
art = mod.ART;
|
|
288
|
+
for (const waiter of waiters) waiter();
|
|
289
|
+
waiters.clear();
|
|
290
|
+
}));
|
|
291
|
+
return loading;
|
|
292
|
+
}
|
|
293
|
+
/** Calls `fn` once the art has loaded (now, when it already has). */
|
|
294
|
+
function onArtReady(fn) {
|
|
295
|
+
if (art) {
|
|
296
|
+
fn();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
waiters.add(fn);
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/avatar/face.ts
|
|
303
|
+
const brow$1 = (dy = 0, tilt = 0, arch = 2.5) => ({
|
|
304
|
+
dy,
|
|
305
|
+
tilt,
|
|
306
|
+
arch
|
|
307
|
+
});
|
|
308
|
+
const mouth$1 = (m) => ({
|
|
309
|
+
w: 13,
|
|
310
|
+
sL: 1.5,
|
|
311
|
+
sR: 1.5,
|
|
312
|
+
open: 0,
|
|
313
|
+
round: 0,
|
|
314
|
+
teeth: 0,
|
|
315
|
+
tongue: 0,
|
|
316
|
+
dx: 0,
|
|
317
|
+
...m
|
|
318
|
+
});
|
|
319
|
+
const BASE = {
|
|
320
|
+
browL: brow$1(),
|
|
321
|
+
browR: brow$1(),
|
|
322
|
+
lidL: .12,
|
|
323
|
+
lidR: .12,
|
|
324
|
+
lowL: 0,
|
|
325
|
+
lowR: 0,
|
|
326
|
+
lidTilt: 0,
|
|
327
|
+
closedCurveL: 1,
|
|
328
|
+
closedCurveR: 1,
|
|
329
|
+
pupil: 1,
|
|
330
|
+
mouth: mouth$1({}),
|
|
331
|
+
cheeks: .25,
|
|
332
|
+
headTilt: 0,
|
|
333
|
+
squash: 0,
|
|
334
|
+
extras: []
|
|
335
|
+
};
|
|
336
|
+
const face = (f) => ({
|
|
337
|
+
...BASE,
|
|
338
|
+
...f
|
|
339
|
+
});
|
|
340
|
+
/** The launch set of 16 expressions (§3.5). Kid-friendly: angry and shocked stay cartoon-cute, cry is one tear. */
|
|
341
|
+
const EXPRESSIONS = {
|
|
342
|
+
neutral: BASE,
|
|
343
|
+
happy: face({
|
|
344
|
+
browL: brow$1(-2, 0, 3.5),
|
|
345
|
+
browR: brow$1(-2, 0, 3.5),
|
|
346
|
+
lowL: .22,
|
|
347
|
+
lowR: .22,
|
|
348
|
+
mouth: mouth$1({
|
|
349
|
+
w: 19,
|
|
350
|
+
sL: 5,
|
|
351
|
+
sR: 5,
|
|
352
|
+
open: 7,
|
|
353
|
+
teeth: .5,
|
|
354
|
+
tongue: .6
|
|
355
|
+
}),
|
|
356
|
+
cheeks: .65
|
|
357
|
+
}),
|
|
358
|
+
laugh: face({
|
|
359
|
+
browL: brow$1(-4, -1, 4),
|
|
360
|
+
browR: brow$1(-4, -1, 4),
|
|
361
|
+
lidL: 1,
|
|
362
|
+
lidR: 1,
|
|
363
|
+
mouth: mouth$1({
|
|
364
|
+
w: 22,
|
|
365
|
+
sL: 6,
|
|
366
|
+
sR: 6,
|
|
367
|
+
open: 13,
|
|
368
|
+
teeth: .45,
|
|
369
|
+
tongue: .9
|
|
370
|
+
}),
|
|
371
|
+
cheeks: .9,
|
|
372
|
+
headTilt: -6,
|
|
373
|
+
squash: .03,
|
|
374
|
+
extras: ["sparkle"]
|
|
375
|
+
}),
|
|
376
|
+
grin: face({
|
|
377
|
+
browL: brow$1(-2.5, 0, 3.5),
|
|
378
|
+
browR: brow$1(-2.5, 0, 3.5),
|
|
379
|
+
lowL: .3,
|
|
380
|
+
lowR: .3,
|
|
381
|
+
mouth: mouth$1({
|
|
382
|
+
w: 21,
|
|
383
|
+
sL: 5.5,
|
|
384
|
+
sR: 5.5,
|
|
385
|
+
open: 4.5,
|
|
386
|
+
teeth: 1
|
|
387
|
+
}),
|
|
388
|
+
cheeks: .7
|
|
389
|
+
}),
|
|
390
|
+
sad: face({
|
|
391
|
+
browL: brow$1(1, -4.5, 1),
|
|
392
|
+
browR: brow$1(1, -4.5, 1),
|
|
393
|
+
lidL: .3,
|
|
394
|
+
lidR: .3,
|
|
395
|
+
lidTilt: -3,
|
|
396
|
+
mouth: mouth$1({
|
|
397
|
+
w: 12,
|
|
398
|
+
sL: -4,
|
|
399
|
+
sR: -4
|
|
400
|
+
}),
|
|
401
|
+
cheeks: .1,
|
|
402
|
+
headTilt: 4
|
|
403
|
+
}),
|
|
404
|
+
cry: face({
|
|
405
|
+
browL: brow$1(1, -5.5, 1),
|
|
406
|
+
browR: brow$1(1, -5.5, 1),
|
|
407
|
+
lidL: .4,
|
|
408
|
+
lidR: .4,
|
|
409
|
+
lidTilt: -3,
|
|
410
|
+
lowL: .15,
|
|
411
|
+
lowR: .15,
|
|
412
|
+
mouth: mouth$1({
|
|
413
|
+
w: 13,
|
|
414
|
+
sL: -4.5,
|
|
415
|
+
sR: -4.5,
|
|
416
|
+
open: 3
|
|
417
|
+
}),
|
|
418
|
+
cheeks: .4,
|
|
419
|
+
headTilt: 5,
|
|
420
|
+
extras: ["tear"]
|
|
421
|
+
}),
|
|
422
|
+
surprised: face({
|
|
423
|
+
browL: brow$1(-7, -1, 4),
|
|
424
|
+
browR: brow$1(-7, -1, 4),
|
|
425
|
+
lidL: 0,
|
|
426
|
+
lidR: 0,
|
|
427
|
+
pupil: .8,
|
|
428
|
+
mouth: mouth$1({
|
|
429
|
+
w: 9,
|
|
430
|
+
sL: 0,
|
|
431
|
+
sR: 0,
|
|
432
|
+
open: 11,
|
|
433
|
+
round: 1,
|
|
434
|
+
tongue: .3
|
|
435
|
+
}),
|
|
436
|
+
cheeks: .2,
|
|
437
|
+
squash: -.05
|
|
438
|
+
}),
|
|
439
|
+
shocked: face({
|
|
440
|
+
browL: brow$1(-9, -2, 4.5),
|
|
441
|
+
browR: brow$1(-9, -2, 4.5),
|
|
442
|
+
lidL: 0,
|
|
443
|
+
lidR: 0,
|
|
444
|
+
pupil: .7,
|
|
445
|
+
mouth: mouth$1({
|
|
446
|
+
w: 11,
|
|
447
|
+
sL: -1,
|
|
448
|
+
sR: -1,
|
|
449
|
+
open: 14,
|
|
450
|
+
round: .8,
|
|
451
|
+
tongue: .4
|
|
452
|
+
}),
|
|
453
|
+
cheeks: .15,
|
|
454
|
+
squash: -.07,
|
|
455
|
+
extras: ["sweat"]
|
|
456
|
+
}),
|
|
457
|
+
angry: face({
|
|
458
|
+
browL: brow$1(2, 5.5, 0),
|
|
459
|
+
browR: brow$1(2, 5.5, 0),
|
|
460
|
+
lidL: .32,
|
|
461
|
+
lidR: .32,
|
|
462
|
+
lidTilt: 4,
|
|
463
|
+
mouth: mouth$1({
|
|
464
|
+
w: 15,
|
|
465
|
+
sL: -2.5,
|
|
466
|
+
sR: -2.5,
|
|
467
|
+
open: 3.5,
|
|
468
|
+
teeth: .9
|
|
469
|
+
}),
|
|
470
|
+
cheeks: .35,
|
|
471
|
+
extras: ["anger"]
|
|
472
|
+
}),
|
|
473
|
+
grumpy: face({
|
|
474
|
+
browL: brow$1(2, 3.5, .5),
|
|
475
|
+
browR: brow$1(2, 3.5, .5),
|
|
476
|
+
lidL: .42,
|
|
477
|
+
lidR: .42,
|
|
478
|
+
lidTilt: 2,
|
|
479
|
+
mouth: mouth$1({
|
|
480
|
+
w: 12,
|
|
481
|
+
sL: -2,
|
|
482
|
+
sR: -2.5,
|
|
483
|
+
dx: -1
|
|
484
|
+
}),
|
|
485
|
+
cheeks: .1,
|
|
486
|
+
headTilt: -3
|
|
487
|
+
}),
|
|
488
|
+
thinking: face({
|
|
489
|
+
browL: brow$1(-5, -1.5, 3.5),
|
|
490
|
+
browR: brow$1(1.5, 3, .5),
|
|
491
|
+
lidL: .1,
|
|
492
|
+
lidR: .3,
|
|
493
|
+
mouth: mouth$1({
|
|
494
|
+
w: 9,
|
|
495
|
+
sL: -.5,
|
|
496
|
+
sR: 1.5,
|
|
497
|
+
dx: 4
|
|
498
|
+
}),
|
|
499
|
+
cheeks: .15,
|
|
500
|
+
headTilt: -5,
|
|
501
|
+
extras: ["question"]
|
|
502
|
+
}),
|
|
503
|
+
wink: face({
|
|
504
|
+
browL: brow$1(-2, 0, 3.5),
|
|
505
|
+
browR: brow$1(1.5, 1.5, 1.5),
|
|
506
|
+
lidR: 1,
|
|
507
|
+
lowL: .15,
|
|
508
|
+
mouth: mouth$1({
|
|
509
|
+
w: 16,
|
|
510
|
+
sL: 2,
|
|
511
|
+
sR: 5.5,
|
|
512
|
+
dx: 1.5
|
|
513
|
+
}),
|
|
514
|
+
cheeks: .6,
|
|
515
|
+
headTilt: 3,
|
|
516
|
+
extras: ["sparkle"]
|
|
517
|
+
}),
|
|
518
|
+
smug: face({
|
|
519
|
+
browL: brow$1(0, 1, 1.5),
|
|
520
|
+
browR: brow$1(-4, -1, 3.5),
|
|
521
|
+
lidL: .45,
|
|
522
|
+
lidR: .4,
|
|
523
|
+
lowL: .1,
|
|
524
|
+
lowR: .1,
|
|
525
|
+
mouth: mouth$1({
|
|
526
|
+
w: 14,
|
|
527
|
+
sL: 1,
|
|
528
|
+
sR: 5,
|
|
529
|
+
dx: 3
|
|
530
|
+
}),
|
|
531
|
+
cheeks: .45,
|
|
532
|
+
headTilt: 4
|
|
533
|
+
}),
|
|
534
|
+
worried: face({
|
|
535
|
+
browL: brow$1(-2, -5, 2),
|
|
536
|
+
browR: brow$1(-2, -5, 2),
|
|
537
|
+
lidL: .05,
|
|
538
|
+
lidR: .05,
|
|
539
|
+
pupil: .9,
|
|
540
|
+
mouth: mouth$1({
|
|
541
|
+
w: 11,
|
|
542
|
+
sL: -1.5,
|
|
543
|
+
sR: -1,
|
|
544
|
+
open: 1.5
|
|
545
|
+
}),
|
|
546
|
+
cheeks: .1,
|
|
547
|
+
extras: ["sweat"]
|
|
548
|
+
}),
|
|
549
|
+
sleepy: face({
|
|
550
|
+
browL: brow$1(1, -1, 1.5),
|
|
551
|
+
browR: brow$1(1, -1, 1.5),
|
|
552
|
+
lidL: .72,
|
|
553
|
+
lidR: .72,
|
|
554
|
+
closedCurveL: -1,
|
|
555
|
+
closedCurveR: -1,
|
|
556
|
+
mouth: mouth$1({
|
|
557
|
+
w: 7,
|
|
558
|
+
sL: 0,
|
|
559
|
+
sR: 0,
|
|
560
|
+
open: 2.5,
|
|
561
|
+
round: 1
|
|
562
|
+
}),
|
|
563
|
+
cheeks: .2,
|
|
564
|
+
headTilt: 7,
|
|
565
|
+
extras: ["zzz"]
|
|
566
|
+
}),
|
|
567
|
+
determined: face({
|
|
568
|
+
browL: brow$1(1, 3.5, .5),
|
|
569
|
+
browR: brow$1(1, 3.5, .5),
|
|
570
|
+
lidL: .22,
|
|
571
|
+
lidR: .22,
|
|
572
|
+
lidTilt: 2,
|
|
573
|
+
mouth: mouth$1({
|
|
574
|
+
w: 12,
|
|
575
|
+
sL: 1,
|
|
576
|
+
sR: 1
|
|
577
|
+
}),
|
|
578
|
+
cheeks: .25
|
|
579
|
+
})
|
|
580
|
+
};
|
|
581
|
+
/** Every expression name, in the launch order. */
|
|
582
|
+
const EXPRESSION_NAMES = [
|
|
583
|
+
"neutral",
|
|
584
|
+
"happy",
|
|
585
|
+
"laugh",
|
|
586
|
+
"grin",
|
|
587
|
+
"sad",
|
|
588
|
+
"cry",
|
|
589
|
+
"surprised",
|
|
590
|
+
"shocked",
|
|
591
|
+
"angry",
|
|
592
|
+
"grumpy",
|
|
593
|
+
"thinking",
|
|
594
|
+
"wink",
|
|
595
|
+
"smug",
|
|
596
|
+
"worried",
|
|
597
|
+
"sleepy",
|
|
598
|
+
"determined"
|
|
599
|
+
];
|
|
600
|
+
/** True for a known expression name. */
|
|
601
|
+
function isExpression(name) {
|
|
602
|
+
return Object.prototype.hasOwnProperty.call(EXPRESSIONS, name);
|
|
603
|
+
}
|
|
604
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
605
|
+
const mixBrow = (a, b, t) => ({
|
|
606
|
+
dy: lerp(a.dy, b.dy, t),
|
|
607
|
+
tilt: lerp(a.tilt, b.tilt, t),
|
|
608
|
+
arch: lerp(a.arch, b.arch, t)
|
|
609
|
+
});
|
|
610
|
+
const mixMouth = (a, b, t) => ({
|
|
611
|
+
w: lerp(a.w, b.w, t),
|
|
612
|
+
sL: lerp(a.sL, b.sL, t),
|
|
613
|
+
sR: lerp(a.sR, b.sR, t),
|
|
614
|
+
open: lerp(a.open, b.open, t),
|
|
615
|
+
round: lerp(a.round, b.round, t),
|
|
616
|
+
teeth: lerp(a.teeth, b.teeth, t),
|
|
617
|
+
tongue: lerp(a.tongue, b.tongue, t),
|
|
618
|
+
dx: lerp(a.dx, b.dx, t)
|
|
619
|
+
});
|
|
620
|
+
/** An expression's parameters (a name or parameters pass through). */
|
|
621
|
+
function faceOf(e) {
|
|
622
|
+
return typeof e === "string" ? EXPRESSIONS[e] : e;
|
|
623
|
+
}
|
|
624
|
+
/** Blends two expressions: `mix('happy', 'surprised', 0.4)`. */
|
|
625
|
+
function mix(a, b, t) {
|
|
626
|
+
const from = faceOf(a);
|
|
627
|
+
const to = faceOf(b);
|
|
628
|
+
return {
|
|
629
|
+
browL: mixBrow(from.browL, to.browL, t),
|
|
630
|
+
browR: mixBrow(from.browR, to.browR, t),
|
|
631
|
+
lidL: lerp(from.lidL, to.lidL, t),
|
|
632
|
+
lidR: lerp(from.lidR, to.lidR, t),
|
|
633
|
+
lowL: lerp(from.lowL, to.lowL, t),
|
|
634
|
+
lowR: lerp(from.lowR, to.lowR, t),
|
|
635
|
+
lidTilt: lerp(from.lidTilt, to.lidTilt, t),
|
|
636
|
+
closedCurveL: lerp(from.closedCurveL, to.closedCurveL, t),
|
|
637
|
+
closedCurveR: lerp(from.closedCurveR, to.closedCurveR, t),
|
|
638
|
+
pupil: lerp(from.pupil, to.pupil, t),
|
|
639
|
+
mouth: mixMouth(from.mouth, to.mouth, t),
|
|
640
|
+
cheeks: lerp(from.cheeks, to.cheeks, t),
|
|
641
|
+
headTilt: lerp(from.headTilt, to.headTilt, t),
|
|
642
|
+
squash: lerp(from.squash, to.squash, t),
|
|
643
|
+
extras: t < .5 ? from.extras : to.extras
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
/** 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. */
|
|
647
|
+
const EMOTE_FACES = {
|
|
648
|
+
"😀": "happy",
|
|
649
|
+
"😂": "laugh",
|
|
650
|
+
"😮": "surprised",
|
|
651
|
+
"😢": "sad",
|
|
652
|
+
"😎": "smug",
|
|
653
|
+
"🤔": "thinking",
|
|
654
|
+
"👍": "grin",
|
|
655
|
+
"👏": "happy",
|
|
656
|
+
"🎉": "laugh",
|
|
657
|
+
"❤️": "happy",
|
|
658
|
+
"🔥": "determined",
|
|
659
|
+
"⭐": "grin"
|
|
660
|
+
};
|
|
661
|
+
/** The face for a quick-chat emote (`e:😂` or the bare emoji), or `null`. */
|
|
662
|
+
function expressionFromEmote(emoteId) {
|
|
663
|
+
return EMOTE_FACES[emoteId.startsWith("e:") ? emoteId.slice(2) : emoteId] ?? null;
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region src/avatar/geom.ts
|
|
667
|
+
/** Rounds to one decimal and drops a trailing `.0` and negative zero. */
|
|
668
|
+
function n(v) {
|
|
669
|
+
const r = Math.round(v * 10) / 10;
|
|
670
|
+
return Object.is(r, -0) ? "0" : String(r);
|
|
671
|
+
}
|
|
672
|
+
/** A path of absolute M/L/C/Q/Z commands that can be warped as a whole. */
|
|
673
|
+
var Path = class {
|
|
674
|
+
constructor() {
|
|
675
|
+
_defineProperty(this, "cmds", []);
|
|
676
|
+
}
|
|
677
|
+
m(px, py) {
|
|
678
|
+
this.cmds.push({
|
|
679
|
+
c: "M",
|
|
680
|
+
p: [px, py]
|
|
681
|
+
});
|
|
682
|
+
return this;
|
|
683
|
+
}
|
|
684
|
+
l(px, py) {
|
|
685
|
+
this.cmds.push({
|
|
686
|
+
c: "L",
|
|
687
|
+
p: [px, py]
|
|
688
|
+
});
|
|
689
|
+
return this;
|
|
690
|
+
}
|
|
691
|
+
c(x1, y1, x2, y2, px, py) {
|
|
692
|
+
this.cmds.push({
|
|
693
|
+
c: "C",
|
|
694
|
+
p: [
|
|
695
|
+
x1,
|
|
696
|
+
y1,
|
|
697
|
+
x2,
|
|
698
|
+
y2,
|
|
699
|
+
px,
|
|
700
|
+
py
|
|
701
|
+
]
|
|
702
|
+
});
|
|
703
|
+
return this;
|
|
704
|
+
}
|
|
705
|
+
q(x1, y1, px, py) {
|
|
706
|
+
this.cmds.push({
|
|
707
|
+
c: "Q",
|
|
708
|
+
p: [
|
|
709
|
+
x1,
|
|
710
|
+
y1,
|
|
711
|
+
px,
|
|
712
|
+
py
|
|
713
|
+
]
|
|
714
|
+
});
|
|
715
|
+
return this;
|
|
716
|
+
}
|
|
717
|
+
z() {
|
|
718
|
+
this.cmds.push({
|
|
719
|
+
c: "Z",
|
|
720
|
+
p: []
|
|
721
|
+
});
|
|
722
|
+
return this;
|
|
723
|
+
}
|
|
724
|
+
/** Appends every command of another path. */
|
|
725
|
+
add(other) {
|
|
726
|
+
this.cmds.push(...other.cmds);
|
|
727
|
+
return this;
|
|
728
|
+
}
|
|
729
|
+
/** The lowest y of any point (control points included, so it never under-reports). */
|
|
730
|
+
bottom() {
|
|
731
|
+
return this.cmds.reduce((low, { p }) => p.reduce((lo, v, i) => i % 2 === 1 ? Math.max(lo, v) : lo, low), -Infinity);
|
|
732
|
+
}
|
|
733
|
+
/** The SVG `d` string, optionally warping every point. */
|
|
734
|
+
d(warp) {
|
|
735
|
+
return this.cmds.map(({ c, p }) => {
|
|
736
|
+
if (c === "Z") return "Z";
|
|
737
|
+
const out = [];
|
|
738
|
+
for (let i = 0; i < p.length; i += 2) {
|
|
739
|
+
const px = p[i] ?? 0;
|
|
740
|
+
const py = p[i + 1] ?? 0;
|
|
741
|
+
const [wx, wy] = warp ? warp(px, py) : [px, py];
|
|
742
|
+
out.push(`${n(wx)} ${n(wy)}`);
|
|
743
|
+
}
|
|
744
|
+
return c + out.join(" ");
|
|
745
|
+
}).join("");
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
/** A point by index, or the origin. */
|
|
749
|
+
const at = (pts, i) => pts[i] ?? [0, 0];
|
|
750
|
+
/**
|
|
751
|
+
* A smooth Catmull-Rom curve through the points (cubic segments). Points
|
|
752
|
+
* flagged `'c'` become sharp corners.
|
|
753
|
+
*/
|
|
754
|
+
function blob(pts, isClosed = true, tension = 1, into = new Path()) {
|
|
755
|
+
const count = pts.length;
|
|
756
|
+
if (count === 0) return into;
|
|
757
|
+
const wrap = (i) => isClosed ? at(pts, (i + count) % count) : at(pts, Math.max(0, Math.min(count - 1, i)));
|
|
758
|
+
const tangent = (i) => {
|
|
759
|
+
if (wrap(i)[2] === "c" || !isClosed && (i === 0 || i === count - 1)) return [0, 0];
|
|
760
|
+
const a = wrap(i - 1);
|
|
761
|
+
const b = wrap(i + 1);
|
|
762
|
+
return [(b[0] - a[0]) * tension / 6, (b[1] - a[1]) * tension / 6];
|
|
763
|
+
};
|
|
764
|
+
const first = at(pts, 0);
|
|
765
|
+
into.m(first[0], first[1]);
|
|
766
|
+
const segs = isClosed ? count : count - 1;
|
|
767
|
+
for (let i = 0; i < segs; i += 1) {
|
|
768
|
+
const p1 = wrap(i);
|
|
769
|
+
const p2 = wrap(i + 1);
|
|
770
|
+
const t1 = tangent(i);
|
|
771
|
+
const t2 = tangent(i + 1);
|
|
772
|
+
into.c(p1[0] + t1[0], p1[1] + t1[1], p2[0] - t2[0], p2[1] - t2[1], p2[0], p2[1]);
|
|
773
|
+
}
|
|
774
|
+
if (isClosed) into.z();
|
|
775
|
+
return into;
|
|
776
|
+
}
|
|
777
|
+
/** Points along an ellipse arc; angles in degrees, 0 = right, 90 = down. */
|
|
778
|
+
function arc(cx, cy, rx, ry, a0, a1, steps) {
|
|
779
|
+
const out = [];
|
|
780
|
+
for (let i = 0; i <= steps; i += 1) {
|
|
781
|
+
const a = (a0 + (a1 - a0) * i / steps) * Math.PI / 180;
|
|
782
|
+
out.push([cx + rx * Math.cos(a), cy + ry * Math.sin(a)]);
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* A scalloped edge: one outward bump between each pair of points (quadratic
|
|
788
|
+
* curves), for curls and clouds. `side` 1 bumps to the left of travel.
|
|
789
|
+
*/
|
|
790
|
+
function scallop(pts, bump, isClosed, side = 1, into = new Path()) {
|
|
791
|
+
const count = pts.length;
|
|
792
|
+
const first = at(pts, 0);
|
|
793
|
+
into.m(first[0], first[1]);
|
|
794
|
+
const segs = isClosed ? count : count - 1;
|
|
795
|
+
for (let i = 0; i < segs; i += 1) {
|
|
796
|
+
const a = at(pts, i);
|
|
797
|
+
const b = at(pts, (i + 1) % count);
|
|
798
|
+
const dx = b[0] - a[0];
|
|
799
|
+
const dy = b[1] - a[1];
|
|
800
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
801
|
+
into.q((a[0] + b[0]) / 2 + dy / len * bump * side, (a[1] + b[1]) / 2 + -dx / len * bump * side, b[0], b[1]);
|
|
802
|
+
}
|
|
803
|
+
if (isClosed) into.z();
|
|
804
|
+
return into;
|
|
805
|
+
}
|
|
806
|
+
/** A rounded rectangle path. */
|
|
807
|
+
function roundedRect(px, py, w, h, r) {
|
|
808
|
+
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();
|
|
809
|
+
}
|
|
810
|
+
/** Clamps `v` to [lo, hi]. */
|
|
811
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/avatar/render/colours.ts
|
|
814
|
+
/**
|
|
815
|
+
* Resolves a look's colours into every tint slot the art uses (§3.2): each
|
|
816
|
+
* fill gets its shade, highlight and tonal outline (style C, §14.1), brows
|
|
817
|
+
* and beards are kept apart from the skin (§14.2), and accessories default
|
|
818
|
+
* to the body colour.
|
|
819
|
+
*/
|
|
820
|
+
/** The soft dark-brown ink for lids, mouths and closed eyes (§14.1). */
|
|
821
|
+
const SOFT_INK = "#3a2a2a";
|
|
822
|
+
/** The fixed colours every look shares. */
|
|
823
|
+
const FIXED = {
|
|
824
|
+
ink: SOFT_INK,
|
|
825
|
+
white: "#fbfaf6",
|
|
826
|
+
mouth: "#5a1d2a",
|
|
827
|
+
tongue: "#ff7d8e",
|
|
828
|
+
blush: "#ff7b8f",
|
|
829
|
+
pupil: "#140f0d"
|
|
830
|
+
};
|
|
831
|
+
/** A fill and its tones. */
|
|
832
|
+
function tones(hex) {
|
|
833
|
+
return {
|
|
834
|
+
base: hex,
|
|
835
|
+
shade: shade(hex),
|
|
836
|
+
light: light(hex),
|
|
837
|
+
ink: outline(hex)
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/** The colours of a look. */
|
|
841
|
+
function lookColours(look) {
|
|
842
|
+
const skin = resolveColour(look.skin);
|
|
843
|
+
const hair = resolveColour(look.hair.colour);
|
|
844
|
+
const browBase = look.brows.colour === void 0 ? lightness(hair) > .78 ? adjust(hair, -.22) : adjust(hair, -.05) : resolveColour(look.brows.colour);
|
|
845
|
+
const beard = keepApart(look.facialHair.colour === void 0 ? hair : resolveColour(look.facialHair.colour), skin);
|
|
846
|
+
const eye = resolveColour(look.eyes.colour);
|
|
847
|
+
const body = resolveColour(look.body.colour);
|
|
848
|
+
return {
|
|
849
|
+
skin,
|
|
850
|
+
skinShade: shade(skin),
|
|
851
|
+
skinLight: light(skin),
|
|
852
|
+
skinInk: outline(skin),
|
|
853
|
+
eyeRim: adjust(skin, -.2, 1.1),
|
|
854
|
+
hair,
|
|
855
|
+
hairShade: shade(hair),
|
|
856
|
+
hairLight: light(hair),
|
|
857
|
+
hairInk: outline(hair),
|
|
858
|
+
hairBuzz: mix$1(hair, skin, .42),
|
|
859
|
+
brow: keepApart(browBase, skin),
|
|
860
|
+
beard,
|
|
861
|
+
beardShade: shade(beard),
|
|
862
|
+
beardInk: outline(beard),
|
|
863
|
+
stubble: mix$1(beard, skin, .35),
|
|
864
|
+
eye,
|
|
865
|
+
eyeDark: adjust(eye, -.18),
|
|
866
|
+
body,
|
|
867
|
+
bodyShade: shade(body),
|
|
868
|
+
bodyLight: light(body),
|
|
869
|
+
bodyInk: outline(body)
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
/** An accessory's colour: its own, else the body colour. */
|
|
873
|
+
function accessoryColour(look, accessory) {
|
|
874
|
+
return accessory?.colour === void 0 ? resolveColour(look.body.colour) : resolveColour(accessory.colour);
|
|
875
|
+
}
|
|
876
|
+
/** Style C's outline width at the 100-unit head height (§14.1). */
|
|
877
|
+
const LINE = 2.4;
|
|
878
|
+
/** Part fills are outlined a little thinner than lines (the POC's style C). */
|
|
879
|
+
const FILL_OUTLINE = .8;
|
|
880
|
+
/** The head outline (centre 0,0; 100 units tall). */
|
|
881
|
+
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();
|
|
882
|
+
/** The head as a clip region. */
|
|
883
|
+
const HEAD_CLIP = { shapes: [path(HEAD_PATH.d(), {})] };
|
|
884
|
+
/** Where a face feature centred at `x0` lands after the head turn. */
|
|
885
|
+
function feat(ctx, x0) {
|
|
886
|
+
const t0 = Math.asin(clamp(x0 / 46, -1, 1));
|
|
887
|
+
const t = t0 + ctx.phi;
|
|
888
|
+
return {
|
|
889
|
+
x: 46 * Math.sin(t),
|
|
890
|
+
sx: Math.max(.05, Math.cos(t) / Math.cos(t0)),
|
|
891
|
+
vis: Math.cos(t) > .22
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* A warp that wraps a part around the skull by `phi`. Unlike a strict
|
|
896
|
+
* cylinder, the far side is squeezed toward the silhouette instead of
|
|
897
|
+
* folding behind it, so outlines never collapse into wedges (§14.2).
|
|
898
|
+
*/
|
|
899
|
+
function turnWarp(phi, dy = 0) {
|
|
900
|
+
if (phi === 0 && dy === 0) return;
|
|
901
|
+
const half = Math.PI / 2;
|
|
902
|
+
return (px, py) => {
|
|
903
|
+
const ax = Math.abs(px);
|
|
904
|
+
const sg = Math.sign(px) || 1;
|
|
905
|
+
const t0 = Math.asin(Math.min(ax, 46) / 46) * sg;
|
|
906
|
+
const t = t0 + phi * (phi >= 0 ? t0 <= 0 ? 1 : 1 - t0 / half : t0 >= 0 ? 1 : 1 + t0 / half);
|
|
907
|
+
const over = Math.max(0, ax - 46) * Math.max(.25, Math.abs(Math.sin(t)));
|
|
908
|
+
return [46 * Math.sin(t) + sg * over, py + dy];
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/** Resolves a tint slot. `acc` is the accessory (or hair-tie) colour for this part. */
|
|
912
|
+
function slotColour(c, acc, slot) {
|
|
913
|
+
switch (slot) {
|
|
914
|
+
case "none": return "none";
|
|
915
|
+
case "acc": return acc.base;
|
|
916
|
+
case "accShade": return acc.shade;
|
|
917
|
+
case "accLight": return acc.light;
|
|
918
|
+
case "accInk": return acc.ink;
|
|
919
|
+
case "ink": return FIXED.ink;
|
|
920
|
+
case "white": return FIXED.white;
|
|
921
|
+
default: return c[slot];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/** The tonal outline slot for a fill slot. */
|
|
925
|
+
function outlineOf(slot) {
|
|
926
|
+
if (slot.startsWith("hair")) return "hairInk";
|
|
927
|
+
if (slot === "beard" || slot === "beardShade" || slot === "stubble") return "beardInk";
|
|
928
|
+
if (slot.startsWith("acc")) return "accInk";
|
|
929
|
+
if (slot.startsWith("skin")) return "skinInk";
|
|
930
|
+
return "ink";
|
|
931
|
+
}
|
|
932
|
+
/** Paints catalog shapes with an optional warp; `clip: 'head'` shapes are clipped to the head. */
|
|
933
|
+
function paintShapes(c, acc, shapes, warp) {
|
|
934
|
+
if (!shapes) return [];
|
|
935
|
+
return shapes.map((s) => {
|
|
936
|
+
const d = s.d.d(warp);
|
|
937
|
+
const opacity = s.opacity === void 0 ? {} : { opacity: s.opacity };
|
|
938
|
+
if (s.line) return path(d, {
|
|
939
|
+
fill: "none",
|
|
940
|
+
stroke: slotColour(c, acc, s.stroke ?? "ink"),
|
|
941
|
+
sw: s.sw ?? 2.4,
|
|
942
|
+
cap: s.cap ?? "round",
|
|
943
|
+
...opacity
|
|
944
|
+
});
|
|
945
|
+
const strokeSlot = s.stroke ?? outlineOf(s.fill);
|
|
946
|
+
const stroke = strokeSlot === "none" ? {} : {
|
|
947
|
+
stroke: slotColour(c, acc, strokeSlot),
|
|
948
|
+
sw: (s.sw ?? 2.4) * FILL_OUTLINE
|
|
949
|
+
};
|
|
950
|
+
const node = path(d, {
|
|
951
|
+
fill: slotColour(c, acc, s.fill),
|
|
952
|
+
...s.evenOdd ? { evenOdd: true } : {},
|
|
953
|
+
...stroke,
|
|
954
|
+
...opacity
|
|
955
|
+
});
|
|
956
|
+
return s.clip === "head" ? g([node], { clip: HEAD_CLIP }) : node;
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
//#endregion
|
|
960
|
+
//#region src/avatar/render/body.ts
|
|
961
|
+
/**
|
|
962
|
+
* The body blob and its poses (§3.11). Each pose is a transform of the same
|
|
963
|
+
* gumdrop plus where the head sits; a future limbed body re-implements the
|
|
964
|
+
* same names. The cycle poses (`walk`, `run`, `dance`, `jump`, `cheer`) read
|
|
965
|
+
* `phase` (0 … 1).
|
|
966
|
+
*/
|
|
967
|
+
const BODY = blob([
|
|
968
|
+
[-35, -2],
|
|
969
|
+
[-40, -24],
|
|
970
|
+
[-30, -50],
|
|
971
|
+
[0, -60],
|
|
972
|
+
[30, -50],
|
|
973
|
+
[40, -24],
|
|
974
|
+
[35, -2],
|
|
975
|
+
[0, 2]
|
|
976
|
+
]).d();
|
|
977
|
+
const STOOL = [
|
|
978
|
+
rect(-27, -12, 6, 12, {
|
|
979
|
+
fill: "#2a3142",
|
|
980
|
+
stroke: "#0d0f14",
|
|
981
|
+
sw: 2
|
|
982
|
+
}),
|
|
983
|
+
rect(21, -12, 6, 12, {
|
|
984
|
+
fill: "#2a3142",
|
|
985
|
+
stroke: "#0d0f14",
|
|
986
|
+
sw: 2
|
|
987
|
+
}),
|
|
988
|
+
rect(-38, -22, 76, 11, {
|
|
989
|
+
fill: "#4a5470",
|
|
990
|
+
stroke: "#0d0f14",
|
|
991
|
+
sw: 3
|
|
992
|
+
}, 5.5)
|
|
993
|
+
];
|
|
994
|
+
const hand = (c, cx, cy) => ellipse(cx, cy, 8, 8, {
|
|
995
|
+
fill: c.body,
|
|
996
|
+
stroke: c.bodyInk,
|
|
997
|
+
sw: LINE
|
|
998
|
+
});
|
|
999
|
+
/** Lays out the body for a pose; `phi` is the body turn (shading shifts with it). */
|
|
1000
|
+
function bodyLayout(c, pose, phi, phase, hasStool) {
|
|
1001
|
+
const wave = Math.sin(phase * Math.PI * 2);
|
|
1002
|
+
const hop = Math.abs(Math.sin(phase * Math.PI));
|
|
1003
|
+
let tf = [];
|
|
1004
|
+
let headDx = 0;
|
|
1005
|
+
let headDy = 0;
|
|
1006
|
+
let tilt = 0;
|
|
1007
|
+
let lift = 0;
|
|
1008
|
+
const behind = [];
|
|
1009
|
+
const front = [];
|
|
1010
|
+
switch (pose) {
|
|
1011
|
+
case "sit":
|
|
1012
|
+
case "play": {
|
|
1013
|
+
const up = hasStool ? -18 : 0;
|
|
1014
|
+
tf = [tr(0, up), sc(1.12, .8)];
|
|
1015
|
+
headDy = 12 + up;
|
|
1016
|
+
if (hasStool) behind.push(...STOOL);
|
|
1017
|
+
break;
|
|
1018
|
+
}
|
|
1019
|
+
case "walk":
|
|
1020
|
+
case "run": {
|
|
1021
|
+
const k = pose === "run" ? 1.6 : 1;
|
|
1022
|
+
tf = [
|
|
1023
|
+
tr(0, -10 * k),
|
|
1024
|
+
skewX(-10 * k),
|
|
1025
|
+
sc(1 - wave * .04, 1 + wave * .06)
|
|
1026
|
+
];
|
|
1027
|
+
headDy = -14 * k + wave * 3;
|
|
1028
|
+
headDx = 7 * k;
|
|
1029
|
+
tilt = 6 * k;
|
|
1030
|
+
behind.push(path("M-52 -30h-14M-50 -18h-20M-54 -6h-10", line("#6f7787", 3, "round", .8)));
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
case "wave":
|
|
1034
|
+
tf = [rot(-6)];
|
|
1035
|
+
headDx = -5;
|
|
1036
|
+
tilt = -7;
|
|
1037
|
+
front.push(hand(c, 44, -58), path("M56 -70Q61 -64 58 -56M62 -76Q69 -66 64 -54", line("#e8e6df", 2.2, "round", .8)));
|
|
1038
|
+
break;
|
|
1039
|
+
case "cheer":
|
|
1040
|
+
lift = hop * 10;
|
|
1041
|
+
tf = [sc(1 - hop * .04, 1 + hop * .05)];
|
|
1042
|
+
headDy = -4 * hop;
|
|
1043
|
+
front.push(hand(c, -42, -66 - hop * 4), hand(c, 42, -66 - hop * 4));
|
|
1044
|
+
break;
|
|
1045
|
+
case "jump":
|
|
1046
|
+
lift = hop * 18;
|
|
1047
|
+
tf = [sc(1 - hop * .06, 1 + hop * .08)];
|
|
1048
|
+
headDy = -6 * hop;
|
|
1049
|
+
break;
|
|
1050
|
+
case "dance":
|
|
1051
|
+
tf = [rot(wave * 8), sc(1 + hop * .03, 1 - hop * .03)];
|
|
1052
|
+
headDx = wave * 5;
|
|
1053
|
+
tilt = wave * 7;
|
|
1054
|
+
break;
|
|
1055
|
+
case "away":
|
|
1056
|
+
tf = [sc(1.04, .95)];
|
|
1057
|
+
headDy = 3;
|
|
1058
|
+
tilt = 5;
|
|
1059
|
+
}
|
|
1060
|
+
const hl = -16 - Math.sin(phi) * 8;
|
|
1061
|
+
const body = [g([
|
|
1062
|
+
path(BODY, { fill: c.bodyShade }),
|
|
1063
|
+
path(BODY, { fill: c.body }, [tr(-3 - Math.sin(phi) * 2, -3), sc(.9)]),
|
|
1064
|
+
ellipse(0, -54, 20, 6, { fill: c.bodyShade }),
|
|
1065
|
+
ellipse(0, 0, 6, 4, { fill: c.bodyLight }, [tr(hl, -36), rot(-30)]),
|
|
1066
|
+
path(BODY, {
|
|
1067
|
+
fill: "none",
|
|
1068
|
+
stroke: c.bodyInk,
|
|
1069
|
+
sw: LINE
|
|
1070
|
+
})
|
|
1071
|
+
], { tf })];
|
|
1072
|
+
const isSitting = pose === "sit" || pose === "play";
|
|
1073
|
+
return {
|
|
1074
|
+
behind,
|
|
1075
|
+
body,
|
|
1076
|
+
front,
|
|
1077
|
+
headDx,
|
|
1078
|
+
headDy,
|
|
1079
|
+
tilt,
|
|
1080
|
+
lift,
|
|
1081
|
+
prop: {
|
|
1082
|
+
x: headDx * .5,
|
|
1083
|
+
y: isSitting ? -34 + (hasStool ? -18 : 0) * .8 : -30
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
/** The soft ground shadow at the feet (shrinks while lifted). */
|
|
1088
|
+
function groundShadow(lift) {
|
|
1089
|
+
const k = Math.max(.5, 1 - lift / 40);
|
|
1090
|
+
return ellipse(0, 0, 36 * k, 8 * k, {
|
|
1091
|
+
fill: "#000000",
|
|
1092
|
+
opacity: .3
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
//#endregion
|
|
1096
|
+
//#region src/avatar/render/eyewear.ts
|
|
1097
|
+
/** The default frame colour. */
|
|
1098
|
+
const FRAME_BLACK = "#1d1a1e";
|
|
1099
|
+
const SHINE = "M-6 -4L-2 -7";
|
|
1100
|
+
const MIRROR_STREAKS = "M-9 7L3 -11M-1 10L9 -4";
|
|
1101
|
+
/** A frame stroke along a path, over its edge when it has one. */
|
|
1102
|
+
function frameLine(d, frame, sw = frame.sw) {
|
|
1103
|
+
return [...frame.edge === void 0 ? [] : [path(d, line(frame.edge, sw + 1.6))], path(d, line(frame.colour, sw))];
|
|
1104
|
+
}
|
|
1105
|
+
/** One lens (or visor) in its own coordinates: the glass, then the frame. */
|
|
1106
|
+
function lensNodes(d, glass, frame, shines) {
|
|
1107
|
+
const out = [];
|
|
1108
|
+
const clip = { shapes: [path(d, {})] };
|
|
1109
|
+
if (glass.tint === void 0) out.push(path(d, {
|
|
1110
|
+
fill: "#ffffff",
|
|
1111
|
+
fillOpacity: .16
|
|
1112
|
+
}));
|
|
1113
|
+
else if (glass.isMirror === true) out.push(path(d, { fill: glass.tint }), g([rect(-60, 2, 120, 40, {
|
|
1114
|
+
fill: outline(glass.tint),
|
|
1115
|
+
opacity: .75
|
|
1116
|
+
}), ...shines.map(([px, py]) => path(MIRROR_STREAKS, line("#ffffff", 2.6, "round", .85), [tr(px, py)]))], { clip }));
|
|
1117
|
+
else out.push(path(d, {
|
|
1118
|
+
fill: glass.tint,
|
|
1119
|
+
fillOpacity: .86
|
|
1120
|
+
}), g([rect(-60, 3, 120, 40, {
|
|
1121
|
+
fill: "#ffffff",
|
|
1122
|
+
opacity: .12
|
|
1123
|
+
})], { clip }));
|
|
1124
|
+
out.push(...frameLine(d, frame));
|
|
1125
|
+
out.push(...shines.map(([px, py]) => path(SHINE, line("#ffffff", 1.8, "round", glass.tint === void 0 ? .7 : .9), [tr(px, py)])));
|
|
1126
|
+
return out;
|
|
1127
|
+
}
|
|
1128
|
+
/** Glasses: arms toward the ears, a lens at each eye (or one visor), and the bridge. */
|
|
1129
|
+
function glasses(ctx, part, colour, glass) {
|
|
1130
|
+
const out = [];
|
|
1131
|
+
const warp = turnWarp(ctx.phi, ctx.dy);
|
|
1132
|
+
const isEdged = lightness(colour) > .7 || deltaE(colour, ctx.c.skin) < 24;
|
|
1133
|
+
const frame = {
|
|
1134
|
+
colour,
|
|
1135
|
+
sw: part.frame ?? 3.2,
|
|
1136
|
+
edge: isEdged ? ink(colour) : void 0
|
|
1137
|
+
};
|
|
1138
|
+
const tinted = part.tint === void 0 ? {} : {
|
|
1139
|
+
tint: glass.tint ?? part.tint,
|
|
1140
|
+
...glass.isMirror === true ? { isMirror: true } : {}
|
|
1141
|
+
};
|
|
1142
|
+
if (part.isSingle !== true) {
|
|
1143
|
+
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));
|
|
1144
|
+
}
|
|
1145
|
+
if (part.visor) {
|
|
1146
|
+
const shines = [feat(ctx, -17), feat(ctx, 17)].filter((f) => f.vis).map((f) => [f.x, 4 + ctx.dy]);
|
|
1147
|
+
out.push(...lensNodes(part.visor.d(warp), tinted, frame, shines));
|
|
1148
|
+
return out;
|
|
1149
|
+
}
|
|
1150
|
+
const lens = part.lens;
|
|
1151
|
+
for (const side of [-1, 1]) {
|
|
1152
|
+
const f = feat(ctx, 17 * side);
|
|
1153
|
+
if (!lens || !f.vis || part.isSingle === true && side < 0) continue;
|
|
1154
|
+
const flip = part.isMirrored === true && side < 0 ? -1 : 1;
|
|
1155
|
+
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)] }));
|
|
1156
|
+
}
|
|
1157
|
+
if (part.chain && feat(ctx, 30).vis) out.push(path(part.chain.d(warp), line(colour, 1.6, "round", .9)));
|
|
1158
|
+
if (part.bridge) {
|
|
1159
|
+
const fb = feat(ctx, 0);
|
|
1160
|
+
out.push(g(frameLine(part.bridge.d(), frame, 2.8), { tf: [tr(fb.x, 4 + ctx.dy), sc(fb.sx, 1)] }));
|
|
1161
|
+
}
|
|
1162
|
+
return out;
|
|
1163
|
+
}
|
|
1164
|
+
const STRAP_FRONT = new Path().m(-47, -2).c(-30, -6, 30, -6, 47, -2);
|
|
1165
|
+
const STRAP_BACK = new Path().m(-47, -2).c(-30, 4, 30, 4, 47, -2);
|
|
1166
|
+
const SHELL = blob([
|
|
1167
|
+
[-31, -9],
|
|
1168
|
+
[-4, -10],
|
|
1169
|
+
[0, -6],
|
|
1170
|
+
[4, -10],
|
|
1171
|
+
[31, -9],
|
|
1172
|
+
[34, 4],
|
|
1173
|
+
[26, 16],
|
|
1174
|
+
[7, 16],
|
|
1175
|
+
[0, 11],
|
|
1176
|
+
[-7, 16],
|
|
1177
|
+
[-26, 16],
|
|
1178
|
+
[-34, 4]
|
|
1179
|
+
]);
|
|
1180
|
+
const GLASS_L = blob([
|
|
1181
|
+
[-27, -5],
|
|
1182
|
+
[-7, -6],
|
|
1183
|
+
[-5, 8],
|
|
1184
|
+
[-9, 12],
|
|
1185
|
+
[-24, 12],
|
|
1186
|
+
[-29, 4]
|
|
1187
|
+
]);
|
|
1188
|
+
const GLASS_R = blob([
|
|
1189
|
+
[27, -5],
|
|
1190
|
+
[7, -6],
|
|
1191
|
+
[5, 8],
|
|
1192
|
+
[9, 12],
|
|
1193
|
+
[24, 12],
|
|
1194
|
+
[29, 4]
|
|
1195
|
+
]);
|
|
1196
|
+
/** Play goggles over the eyes (they replace the eyes and any glasses); the strap is the body colour. */
|
|
1197
|
+
function goggles(ctx) {
|
|
1198
|
+
const w = turnWarp(ctx.phi, ctx.dy);
|
|
1199
|
+
const band = ctx.c.body;
|
|
1200
|
+
const strap = STRAP_FRONT.d(w);
|
|
1201
|
+
return [
|
|
1202
|
+
path(strap, line(ink(band), 9)),
|
|
1203
|
+
path(strap, line(band, 6)),
|
|
1204
|
+
path(SHELL.d(w), {
|
|
1205
|
+
fill: "#2a3142",
|
|
1206
|
+
stroke: "#0d0f14",
|
|
1207
|
+
sw: LINE
|
|
1208
|
+
}),
|
|
1209
|
+
path(GLASS_L.d(w), { fill: "#5fe3f2" }),
|
|
1210
|
+
path(GLASS_R.d(w), { fill: "#5fe3f2" }),
|
|
1211
|
+
path(new Path().m(-22, 6).l(-14, -2).d(w), line("#e8fbff", 3)),
|
|
1212
|
+
path(new Path().m(12, 6).l(20, -2).d(w), line("#e8fbff", 3))
|
|
1213
|
+
];
|
|
1214
|
+
}
|
|
1215
|
+
/** The goggles strap seen from behind. */
|
|
1216
|
+
function gogglesStrapBack(ctx, phi) {
|
|
1217
|
+
const d = STRAP_BACK.d(turnWarp(phi));
|
|
1218
|
+
return [path(d, line(ink(ctx.c.body), 9)), path(d, line(ctx.c.body, 6))];
|
|
1219
|
+
}
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/avatar/render/eyes.ts
|
|
1222
|
+
/**
|
|
1223
|
+
* Eyes and brows in style C (§14.1): whites 7% larger than style A's with a
|
|
1224
|
+
* 1.6-unit rim in a darker shade of the skin, an iris with a darker ring, a
|
|
1225
|
+
* pupil, two glints, and a soft dark-brown lid line. No dark outline.
|
|
1226
|
+
*/
|
|
1227
|
+
/** Eye-white radii: style A's 9.6 × 11.8, 7% larger. */
|
|
1228
|
+
const RX = 10.272;
|
|
1229
|
+
const RY = 11.8 * 1.07;
|
|
1230
|
+
/** One eye; `side` −1 is the viewer's left. */
|
|
1231
|
+
function eye(ctx, side) {
|
|
1232
|
+
const f = feat(ctx, 17 * side);
|
|
1233
|
+
if (!f.vis) return [];
|
|
1234
|
+
const fp = ctx.face;
|
|
1235
|
+
const lidBase = side < 0 ? fp.lidL : fp.lidR;
|
|
1236
|
+
const lid = lidBase + (1 - lidBase) * ctx.blink;
|
|
1237
|
+
const low = side < 0 ? fp.lowL : fp.lowR;
|
|
1238
|
+
const curve = side < 0 ? fp.closedCurveL : fp.closedCurveR;
|
|
1239
|
+
const place = [tr(f.x, 4 + ctx.dy), sc(f.sx, 1)];
|
|
1240
|
+
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 })];
|
|
1241
|
+
const top = -12.626000000000001 + lid * 2 * RY;
|
|
1242
|
+
const edgeY = (px) => top + fp.lidTilt * (px * -side) / 9;
|
|
1243
|
+
const bot = RY - low * 2 * RY;
|
|
1244
|
+
const gx = ctx.gaze.x * 2.8;
|
|
1245
|
+
const gy = ctx.gaze.y * 3.4;
|
|
1246
|
+
const clip = { shapes: [ellipse(0, 0, RX, RY, {})] };
|
|
1247
|
+
const inner = [g([
|
|
1248
|
+
ellipse(0, 0, 7, 7, { fill: ctx.c.eye }),
|
|
1249
|
+
ellipse(0, 0, 6.4, 6.4, line(ctx.c.eyeDark, 1.2)),
|
|
1250
|
+
ellipse(0, 0, 3.6 * fp.pupil, 3.6 * fp.pupil, { fill: FIXED.pupil }),
|
|
1251
|
+
ellipse(-2.4, -2.8, 2.2, 2.2, { fill: "#ffffff" }),
|
|
1252
|
+
ellipse(2.1, 2, .9, .9, { fill: "#ffffff" })
|
|
1253
|
+
], { tf: [tr(gx, gy)] })];
|
|
1254
|
+
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 }));
|
|
1255
|
+
if (low > .02) inner.push(path(`M-12 14L12 14L12 ${n(bot)}Q0 ${n(bot - 3)} -12 ${n(bot)}Z`, { fill: ctx.c.skin }));
|
|
1256
|
+
const out = [ellipse(0, 0, RX, RY, { fill: FIXED.white }), g(inner, { clip })];
|
|
1257
|
+
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 }));
|
|
1258
|
+
out.push(ellipse(0, 0, RX, RY, line(ctx.c.eyeRim, 1.6)));
|
|
1259
|
+
return [g(out, { tf: place })];
|
|
1260
|
+
}
|
|
1261
|
+
const BROW_W = {
|
|
1262
|
+
soft: 3.8,
|
|
1263
|
+
thin: 2.4,
|
|
1264
|
+
thick: 5.6,
|
|
1265
|
+
arched: 3.6,
|
|
1266
|
+
straight: 4.2
|
|
1267
|
+
};
|
|
1268
|
+
const BROW_ARCH = {
|
|
1269
|
+
soft: 1,
|
|
1270
|
+
thin: 1,
|
|
1271
|
+
thick: .8,
|
|
1272
|
+
arched: 1.9,
|
|
1273
|
+
straight: .2
|
|
1274
|
+
};
|
|
1275
|
+
/** One brow in a style (`soft`, `thin`, …). */
|
|
1276
|
+
function brow(ctx, side, style) {
|
|
1277
|
+
const f = feat(ctx, 17 * side);
|
|
1278
|
+
if (!f.vis) return [];
|
|
1279
|
+
const p = side < 0 ? ctx.face.browL : ctx.face.browR;
|
|
1280
|
+
const by = -14 + p.dy + ctx.dy * 1.1;
|
|
1281
|
+
const xi = -7.5 * side;
|
|
1282
|
+
const xo = 7.5 * side;
|
|
1283
|
+
const yi = by + p.tilt * .8;
|
|
1284
|
+
const yo = by - p.tilt * .35;
|
|
1285
|
+
const arch = p.arch * (BROW_ARCH[style] ?? 1);
|
|
1286
|
+
const width = (BROW_W[style] ?? 3.8) * 1.15;
|
|
1287
|
+
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)] })];
|
|
1288
|
+
}
|
|
1289
|
+
//#endregion
|
|
1290
|
+
//#region src/avatar/render/head.ts
|
|
1291
|
+
/** The head, ears, cheeks and the small face and ear extras. */
|
|
1292
|
+
const EAR = "M-2 -10C6 -12 11 -6 10 1C9 8 5 11 -1 10Z";
|
|
1293
|
+
const EAR_FOLD = "M1.5 -5C5.5 -5 6.5 0 5 3.5";
|
|
1294
|
+
/** Ears behind the head (`behind`) or in front of it (`front`), hidden when turned away. */
|
|
1295
|
+
function ears(ctx, which, extra) {
|
|
1296
|
+
const out = [];
|
|
1297
|
+
for (const side of [-1, 1]) {
|
|
1298
|
+
const t = Math.PI / 2 * side + ctx.phi;
|
|
1299
|
+
const cos = Math.cos(t);
|
|
1300
|
+
if (cos < -.15) continue;
|
|
1301
|
+
const isInFront = cos > .3;
|
|
1302
|
+
if (which === "front" !== isInFront) continue;
|
|
1303
|
+
const earX = clamp(46 * Math.sin(t), -44, 44);
|
|
1304
|
+
const w = isInFront ? .75 + .25 * cos : 1;
|
|
1305
|
+
const flip = isInFront ? -Math.sign(earX || side) : side;
|
|
1306
|
+
const parts = [path(EAR, {
|
|
1307
|
+
fill: ctx.c.skin,
|
|
1308
|
+
stroke: ctx.c.skinInk,
|
|
1309
|
+
sw: LINE
|
|
1310
|
+
}), path(EAR_FOLD, line(ctx.c.skinShade, 2.2))];
|
|
1311
|
+
if (extra?.kind === "studs") parts.push(ellipse(3, 7, 2.2, 2.2, {
|
|
1312
|
+
fill: extra.colour,
|
|
1313
|
+
stroke: "#3a2a2a",
|
|
1314
|
+
sw: .8
|
|
1315
|
+
}));
|
|
1316
|
+
if (extra?.kind === "hoops") parts.push(ellipse(3, 12, 3.6, 4.4, line(extra.colour, 1.8)));
|
|
1317
|
+
out.push(g(parts, { tf: [tr(earX, 6 + ctx.dy * .3), sc(flip * w, 1)] }));
|
|
1318
|
+
}
|
|
1319
|
+
return out;
|
|
1320
|
+
}
|
|
1321
|
+
/** The head: a shade base, the lit face, and the tonal outline. */
|
|
1322
|
+
function head(ctx) {
|
|
1323
|
+
const d = HEAD_PATH.d();
|
|
1324
|
+
const lx = -2.5 + Math.sin(ctx.phi) * 3;
|
|
1325
|
+
return [
|
|
1326
|
+
path(d, { fill: ctx.c.skinShade }),
|
|
1327
|
+
path(d, { fill: ctx.c.skin }, [tr(lx, -3), sc(.95)]),
|
|
1328
|
+
path(d, {
|
|
1329
|
+
fill: "none",
|
|
1330
|
+
stroke: ctx.c.skinInk,
|
|
1331
|
+
sw: LINE
|
|
1332
|
+
})
|
|
1333
|
+
];
|
|
1334
|
+
}
|
|
1335
|
+
const FRECKLES = [
|
|
1336
|
+
[-3.5, -1.5],
|
|
1337
|
+
[0, .8],
|
|
1338
|
+
[3.5, -1.2],
|
|
1339
|
+
[1.5, -3.5]
|
|
1340
|
+
];
|
|
1341
|
+
/** Cheek blush (from the expression, plus `rosy`) and freckles. */
|
|
1342
|
+
function cheeks(ctx, hasFreckles, isRosy) {
|
|
1343
|
+
const out = [];
|
|
1344
|
+
const blush = Math.min(1, ctx.face.cheeks + (isRosy ? .45 : 0));
|
|
1345
|
+
for (const side of [-1, 1]) {
|
|
1346
|
+
const f = feat(ctx, 25 * side);
|
|
1347
|
+
if (!f.vis) continue;
|
|
1348
|
+
const parts = [];
|
|
1349
|
+
if (blush > .02) parts.push(ellipse(0, 0, 6.5, 3.8, {
|
|
1350
|
+
fill: FIXED.blush,
|
|
1351
|
+
opacity: Math.round(blush * 5) / 10
|
|
1352
|
+
}));
|
|
1353
|
+
if (hasFreckles) parts.push(...FRECKLES.map(([fx, fy]) => ellipse(fx, fy, 1.05, 1.05, {
|
|
1354
|
+
fill: ctx.c.skinInk,
|
|
1355
|
+
opacity: .45
|
|
1356
|
+
})));
|
|
1357
|
+
if (parts.length > 0) out.push(g(parts, { tf: [tr(f.x, 18 + ctx.dy), sc(f.sx, 1)] }));
|
|
1358
|
+
}
|
|
1359
|
+
return out;
|
|
1360
|
+
}
|
|
1361
|
+
//#endregion
|
|
1362
|
+
//#region src/avatar/render/mouth.ts
|
|
1363
|
+
/**
|
|
1364
|
+
* The nose, the mouth morph (one cubic structure for every shape, so any two
|
|
1365
|
+
* expressions tween, §3.5) and the small FX glyphs.
|
|
1366
|
+
*/
|
|
1367
|
+
/** The nose: a soft shade with a highlight. */
|
|
1368
|
+
function nose(ctx) {
|
|
1369
|
+
const f = feat(ctx, 0);
|
|
1370
|
+
return [g([ellipse(0, 0, 4.4, 3, { fill: ctx.c.skinShade }), ellipse(-1.5, -1, 1.2, 1.2, {
|
|
1371
|
+
fill: ctx.c.skinLight,
|
|
1372
|
+
opacity: .9
|
|
1373
|
+
})], { tf: [tr(f.x, 16 + ctx.dy), sc(Math.max(.6, f.sx), 1)] })];
|
|
1374
|
+
}
|
|
1375
|
+
/** The mouth: a line when closed, a filled shape with teeth and tongue when open. Talking adds opening. */
|
|
1376
|
+
function mouth(ctx) {
|
|
1377
|
+
const base = ctx.face.mouth;
|
|
1378
|
+
const m = {
|
|
1379
|
+
...base,
|
|
1380
|
+
open: base.open + ctx.talk * 9 * (1 - base.round * .4)
|
|
1381
|
+
};
|
|
1382
|
+
const f = feat(ctx, m.dx);
|
|
1383
|
+
const baseY = 27;
|
|
1384
|
+
const lx = -m.w / 2;
|
|
1385
|
+
const ly = baseY - m.sL;
|
|
1386
|
+
const rx = m.w / 2;
|
|
1387
|
+
const ry = baseY - m.sR;
|
|
1388
|
+
const s = (m.sL + m.sR) / 2;
|
|
1389
|
+
const corner = baseY - s;
|
|
1390
|
+
const ctrl = (target) => (target - .25 * corner) / .75;
|
|
1391
|
+
const place = [tr(f.x, ctx.dy * .8), sc(Math.max(.35, f.sx), 1)];
|
|
1392
|
+
if (m.open < 1) {
|
|
1393
|
+
const c = ctrl(baseY + s * .55);
|
|
1394
|
+
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 })];
|
|
1395
|
+
}
|
|
1396
|
+
const up = baseY + s * .3 - m.round * m.open * .45;
|
|
1397
|
+
const lo = baseY + s * .55 + m.open * (1 - m.round * .45);
|
|
1398
|
+
const kx = m.w / 2 * (.55 + m.round * .8);
|
|
1399
|
+
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`;
|
|
1400
|
+
const inside = [];
|
|
1401
|
+
if (m.tongue > .05) inside.push(ellipse(0, lo + 1, m.w * .32, m.open * .42 * m.tongue + 1, { fill: FIXED.tongue }));
|
|
1402
|
+
if (m.teeth > .05) inside.push(rect(-m.w / 2, up - 6, m.w, 6 + m.open * .22 * m.teeth, { fill: FIXED.white }));
|
|
1403
|
+
return [g([
|
|
1404
|
+
path(d, { fill: FIXED.mouth }),
|
|
1405
|
+
g(inside, { clip: { shapes: [path(d, {})] } }),
|
|
1406
|
+
path(d, line(SOFT_INK, LINE * .9))
|
|
1407
|
+
], { tf: place })];
|
|
1408
|
+
}
|
|
1409
|
+
/** FX glyphs beside the head (sparkle, tear, sweat, anger, question, zzz). */
|
|
1410
|
+
function extras(ctx) {
|
|
1411
|
+
const out = [];
|
|
1412
|
+
const f = feat(ctx, 0);
|
|
1413
|
+
for (const e of ctx.face.extras) {
|
|
1414
|
+
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", {
|
|
1415
|
+
fill: "#ffd23f",
|
|
1416
|
+
stroke: "#0d0f14",
|
|
1417
|
+
sw: 1.6
|
|
1418
|
+
}, [tr(48 + f.x * .2, -44)]));
|
|
1419
|
+
if (e === "tear") {
|
|
1420
|
+
const t = feat(ctx, -16);
|
|
1421
|
+
if (t.vis) out.push(path("M0 -5Q4.5 2 0 5Q-4.5 2 0 -5Z", {
|
|
1422
|
+
fill: "#7cc7ff",
|
|
1423
|
+
stroke: "#1f3b5c",
|
|
1424
|
+
sw: 1.4
|
|
1425
|
+
}, [tr(t.x - 5, 16 + ctx.dy)]));
|
|
1426
|
+
}
|
|
1427
|
+
if (e === "sweat") out.push(path("M0 -6Q5 2 0 6Q-5 2 0 -6Z", {
|
|
1428
|
+
fill: "#9fdcff",
|
|
1429
|
+
stroke: "#1f3b5c",
|
|
1430
|
+
sw: 1.4
|
|
1431
|
+
}, [tr(40 + f.x * .2, -26)]));
|
|
1432
|
+
if (e === "anger") out.push(g([
|
|
1433
|
+
path("M-6 -2Q-2 -2 -2 -6", line("#ff4d4d", 2.6)),
|
|
1434
|
+
path("M2 -6Q2 -2 6 -2", line("#ff4d4d", 2.6)),
|
|
1435
|
+
path("M6 2Q2 2 2 6", line("#ff4d4d", 2.6)),
|
|
1436
|
+
path("M-2 6Q-2 2 -6 2", line("#ff4d4d", 2.6))
|
|
1437
|
+
], { tf: [tr(30 + f.x * .3, -36)] }));
|
|
1438
|
+
if (e === "question") out.push({
|
|
1439
|
+
k: "text",
|
|
1440
|
+
x: 50 + f.x * .2,
|
|
1441
|
+
y: -40,
|
|
1442
|
+
text: "?",
|
|
1443
|
+
size: 26,
|
|
1444
|
+
font: "pixel",
|
|
1445
|
+
fill: "#ffd23f",
|
|
1446
|
+
stroke: "#0d0f14",
|
|
1447
|
+
sw: 1.2
|
|
1448
|
+
});
|
|
1449
|
+
if (e === "zzz") out.push({
|
|
1450
|
+
k: "text",
|
|
1451
|
+
x: 44 + f.x * .2,
|
|
1452
|
+
y: -42,
|
|
1453
|
+
text: "z z",
|
|
1454
|
+
size: 20,
|
|
1455
|
+
font: "pixel",
|
|
1456
|
+
fill: "#8ab4ff",
|
|
1457
|
+
stroke: "#0d0f14",
|
|
1458
|
+
sw: 1.2
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
return out;
|
|
1462
|
+
}
|
|
1463
|
+
//#endregion
|
|
1464
|
+
//#region src/avatar/render/props.ts
|
|
1465
|
+
/**
|
|
1466
|
+
* Status props (§3.11, §14.3): runtime-only parts the world (or a preview)
|
|
1467
|
+
* puts on a player. `goggles` lives in eyewear.ts; the head props here sit on
|
|
1468
|
+
* the head, the hand props at the `prop` anchor in front of the body.
|
|
1469
|
+
*/
|
|
1470
|
+
const DARK = "#0d0f14";
|
|
1471
|
+
/** Hand props are drawn at this scale so they read at small sizes. */
|
|
1472
|
+
const PROP_SCALE = 1.45;
|
|
1473
|
+
/** A headset: a band over the head, ear cups and a mic (head space). */
|
|
1474
|
+
function headset(ctx, isBack) {
|
|
1475
|
+
const w = turnWarp(isBack ? 0 : ctx.phi);
|
|
1476
|
+
const band = blob(arc(0, -4, 51, 51, 196, 344, 8), false);
|
|
1477
|
+
const nodes = [path(band.d(w), line(DARK, 8)), path(band.d(w), line("#4a5470", 5))];
|
|
1478
|
+
const phi = isBack ? 0 : ctx.phi;
|
|
1479
|
+
for (const side of [-1, 1]) {
|
|
1480
|
+
const t = Math.PI / 2 * side + phi;
|
|
1481
|
+
if (Math.cos(t) < -.15) continue;
|
|
1482
|
+
const cupX = Math.max(-47, Math.min(47, 46 * Math.sin(t)));
|
|
1483
|
+
const wide = 6 + 3 * Math.max(0, Math.cos(t));
|
|
1484
|
+
nodes.push(path(roundedRect(cupX - wide, -6, wide * 2, 20, 5).d(), {
|
|
1485
|
+
fill: "#2a3142",
|
|
1486
|
+
stroke: DARK,
|
|
1487
|
+
sw: LINE
|
|
1488
|
+
}));
|
|
1489
|
+
}
|
|
1490
|
+
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, {
|
|
1491
|
+
fill: "#ff5f3b",
|
|
1492
|
+
stroke: DARK,
|
|
1493
|
+
sw: 1.2
|
|
1494
|
+
}));
|
|
1495
|
+
return nodes;
|
|
1496
|
+
}
|
|
1497
|
+
/** A small crown on top of the head (head space; `top` is the hair or hat top). */
|
|
1498
|
+
function crown(top) {
|
|
1499
|
+
return [g([path("M-16 0L-18 -16L-8 -8L0 -20L8 -8L18 -16L16 0Z", {
|
|
1500
|
+
fill: "#ffd23f",
|
|
1501
|
+
stroke: "#8a5a00",
|
|
1502
|
+
sw: LINE
|
|
1503
|
+
}), ellipse(0, -5, 2.4, 2.4, { fill: "#ff5f3b" })], { tf: [tr(0, -50 - top + 4)] })];
|
|
1504
|
+
}
|
|
1505
|
+
const card = (colour) => rect(-6, -9, 12, 17, {
|
|
1506
|
+
fill: colour,
|
|
1507
|
+
stroke: DARK,
|
|
1508
|
+
sw: 1.6
|
|
1509
|
+
}, 2.5);
|
|
1510
|
+
/** Nub hands holding something at the prop anchor (body space). */
|
|
1511
|
+
function nubs(body, ink, spread, atY) {
|
|
1512
|
+
return [-1, 1].map((s) => ellipse(spread * s, atY, 7, 6, {
|
|
1513
|
+
fill: body,
|
|
1514
|
+
stroke: ink,
|
|
1515
|
+
sw: LINE
|
|
1516
|
+
}));
|
|
1517
|
+
}
|
|
1518
|
+
/** A hand prop drawn at the `prop` anchor (x, y) in front of the body. */
|
|
1519
|
+
function handProp(prop, atX, atY, body, bodyInk) {
|
|
1520
|
+
switch (prop) {
|
|
1521
|
+
case "cards": return [g([
|
|
1522
|
+
g([card("#ff5f3b")], { tf: [tr(-8, 0), rot(-18)] }),
|
|
1523
|
+
g([card("#ffd23f")], { tf: [tr(0, -3)] }),
|
|
1524
|
+
g([card("#3d7bff")], { tf: [tr(8, 0), rot(18)] }),
|
|
1525
|
+
...nubs(body, bodyInk, 13, 8)
|
|
1526
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
1527
|
+
case "controller": return [g([
|
|
1528
|
+
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(), {
|
|
1529
|
+
fill: "#2a3142",
|
|
1530
|
+
stroke: DARK,
|
|
1531
|
+
sw: LINE
|
|
1532
|
+
}),
|
|
1533
|
+
ellipse(-9, -2, 2.6, 2.6, { fill: "#8ab4ff" }),
|
|
1534
|
+
ellipse(10, -4, 2, 2, { fill: "#ff5f3b" }),
|
|
1535
|
+
ellipse(13, 0, 2, 2, { fill: "#4ade80" }),
|
|
1536
|
+
...nubs(body, bodyInk, 20, 4)
|
|
1537
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
1538
|
+
case "book": return [g([
|
|
1539
|
+
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", {
|
|
1540
|
+
fill: "#f4f1ea",
|
|
1541
|
+
stroke: DARK,
|
|
1542
|
+
sw: LINE
|
|
1543
|
+
}),
|
|
1544
|
+
path("M0 -2L0 14", line("#c9c2b4", 1.6)),
|
|
1545
|
+
path("M-19 0L-5 3M-19 5L-5 8M5 3L19 0M5 8L19 5", line("#9a9384", 1.2)),
|
|
1546
|
+
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", {
|
|
1547
|
+
fill: "#ff5f3b",
|
|
1548
|
+
stroke: DARK,
|
|
1549
|
+
sw: 1.4
|
|
1550
|
+
}),
|
|
1551
|
+
...nubs(body, bodyInk, 25, 6)
|
|
1552
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
1553
|
+
case "trophy": return [g([
|
|
1554
|
+
path("M-11 -14L11 -14L9 0C8 6 -8 6 -9 0Z", {
|
|
1555
|
+
fill: "#ffd23f",
|
|
1556
|
+
stroke: "#8a5a00",
|
|
1557
|
+
sw: LINE
|
|
1558
|
+
}),
|
|
1559
|
+
path("M-11 -11C-18 -11 -18 -2 -9 -1M11 -11C18 -11 18 -2 9 -1", line("#8a5a00", 2.2)),
|
|
1560
|
+
rect(-3, 4, 6, 6, { fill: "#e0a800" }),
|
|
1561
|
+
rect(-9, 10, 18, 5, {
|
|
1562
|
+
fill: "#6b4f12",
|
|
1563
|
+
stroke: DARK,
|
|
1564
|
+
sw: 1.2
|
|
1565
|
+
}, 1.5),
|
|
1566
|
+
ellipse(13, 6, 7, 6, {
|
|
1567
|
+
fill: body,
|
|
1568
|
+
stroke: bodyInk,
|
|
1569
|
+
sw: LINE
|
|
1570
|
+
})
|
|
1571
|
+
], { tf: [tr(atX + 24, atY - 10), sc(PROP_SCALE)] })];
|
|
1572
|
+
default: return [];
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
//#endregion
|
|
1576
|
+
//#region src/avatar/render/compose.ts
|
|
1577
|
+
/**
|
|
1578
|
+
* Composes a look and a pose into a scene in rig space: the feet at (0, 0),
|
|
1579
|
+
* y down, the head centre near (0, −96) (§14.2). Layers go back to front as
|
|
1580
|
+
* in §3.2, with facial hair split into beard and moustache layers.
|
|
1581
|
+
*/
|
|
1582
|
+
/** The lens tint drawn with a mirror finish. */
|
|
1583
|
+
const MIRROR_TINT = "p:lens-mirror";
|
|
1584
|
+
/** The head centre's height above the feet. */
|
|
1585
|
+
const HEAD_Y = -96;
|
|
1586
|
+
const DEG = Math.PI / 180;
|
|
1587
|
+
const FACING_ANGLE = {
|
|
1588
|
+
S: 0,
|
|
1589
|
+
SE: 34,
|
|
1590
|
+
E: 70,
|
|
1591
|
+
NE: 145,
|
|
1592
|
+
N: 180
|
|
1593
|
+
};
|
|
1594
|
+
const MIRROR = {
|
|
1595
|
+
SW: "SE",
|
|
1596
|
+
W: "E",
|
|
1597
|
+
NW: "NE"
|
|
1598
|
+
};
|
|
1599
|
+
const VIEW_FACING = {
|
|
1600
|
+
front: "S",
|
|
1601
|
+
back: "N",
|
|
1602
|
+
left: "W",
|
|
1603
|
+
right: "E"
|
|
1604
|
+
};
|
|
1605
|
+
/** The facing an options object asks for. */
|
|
1606
|
+
function facingOf(opts) {
|
|
1607
|
+
return opts.facing ?? (opts.view ? VIEW_FACING[opts.view] : "S");
|
|
1608
|
+
}
|
|
1609
|
+
function resolveParts(look, art) {
|
|
1610
|
+
const slot = (name) => look.accessories.find((a) => a.slot === name);
|
|
1611
|
+
const hatAcc = slot("headwear");
|
|
1612
|
+
const glassAcc = slot("glasses");
|
|
1613
|
+
const earAcc = slot("ears");
|
|
1614
|
+
const face = slot("face");
|
|
1615
|
+
return {
|
|
1616
|
+
c: lookColours(look),
|
|
1617
|
+
hair: art.hair[partName(look.hair.style)],
|
|
1618
|
+
hat: hatAcc ? art.headwear[partName(hatAcc.id)] : void 0,
|
|
1619
|
+
hatColour: accessoryColour(look, hatAcc),
|
|
1620
|
+
beard: art.beard[partName(look.facialHair.style)],
|
|
1621
|
+
glasses: glassAcc ? art.glasses[partName(glassAcc.id)] : void 0,
|
|
1622
|
+
glassesColour: glassAcc?.colour === void 0 ? FRAME_BLACK : accessoryColour(look, glassAcc),
|
|
1623
|
+
lensGlass: glassAcc?.tint === void 0 ? {} : {
|
|
1624
|
+
tint: resolveColour(glassAcc.tint),
|
|
1625
|
+
...glassAcc.tint === MIRROR_TINT ? { isMirror: true } : {}
|
|
1626
|
+
},
|
|
1627
|
+
freckles: face?.id === "face.freckles",
|
|
1628
|
+
rosy: face?.id === "face.rosy",
|
|
1629
|
+
earExtra: earAcc ? {
|
|
1630
|
+
kind: earAcc.id === "ears.hoops" ? "hoops" : "studs",
|
|
1631
|
+
colour: earAcc.colour === void 0 ? "#f5c542" : accessoryColour(look, earAcc)
|
|
1632
|
+
} : void 0,
|
|
1633
|
+
browStyle: partName(look.brows.style)
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
/** The far-side clip for a turned head's rear art (a back-of-skull crescent). */
|
|
1637
|
+
function backOfHead(phi) {
|
|
1638
|
+
const left = -46 * Math.cos(phi) - 3;
|
|
1639
|
+
const cx = (left + 46 + 12) / 2;
|
|
1640
|
+
const rx = (58 - left) / 2;
|
|
1641
|
+
return {
|
|
1642
|
+
crescent: {
|
|
1643
|
+
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`, {})],
|
|
1644
|
+
evenOdd: true
|
|
1645
|
+
},
|
|
1646
|
+
half: { shapes: [rect(-200, -200, 200 + cx, 400, {})] }
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
const ABOVE_SHOULDERS = { shapes: [rect(-200, -200, 400, 237, {}), path(HEAD_PATH.d(), {})] };
|
|
1650
|
+
const BELOW_SHOULDERS = { shapes: [rect(-200, 35, 400, 300, {})] };
|
|
1651
|
+
/** True when a layer reaches below the shoulder line. */
|
|
1652
|
+
function hangs(shapes) {
|
|
1653
|
+
return shapes?.some((s) => s.d.bottom() > 36) === true;
|
|
1654
|
+
}
|
|
1655
|
+
/** Splits a hair layer at the shoulders: the part above stays in `over`, the part below goes in `behind`. */
|
|
1656
|
+
function splitAtShoulders(nodes, doesHang, over, behind) {
|
|
1657
|
+
if (nodes.length === 0) return;
|
|
1658
|
+
if (!doesHang) {
|
|
1659
|
+
over.push(...nodes);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
over.push(g(nodes, { clip: ABOVE_SHOULDERS }));
|
|
1663
|
+
behind.push(g(nodes, { clip: BELOW_SHOULDERS }));
|
|
1664
|
+
}
|
|
1665
|
+
/** The front and side views of the head. */
|
|
1666
|
+
function frontHead(ctx, p, isPlaying, status, angle, gazeX) {
|
|
1667
|
+
const hatCut = p.hat && typeof p.hat.hides === "number" ? { shapes: [rect(-120, p.hat.hides, 240, 260, {})] } : void 0;
|
|
1668
|
+
const isHairShown = !(p.hat && p.hat.hides === "all");
|
|
1669
|
+
const cut = (nodes) => hatCut && nodes.length > 0 ? [g(nodes, { clip: hatCut })] : nodes;
|
|
1670
|
+
const hairTie = tones(ctx.c.body);
|
|
1671
|
+
const hatTones = tones(p.hatColour);
|
|
1672
|
+
const warpFront = turnWarp((angle + gazeX * 6) * DEG);
|
|
1673
|
+
const warpBack = ctx.phi === 0 ? void 0 : (px, py) => [px - Math.sin(ctx.phi) * 8, py];
|
|
1674
|
+
const behindBody = [];
|
|
1675
|
+
const out = [];
|
|
1676
|
+
if (isHairShown) behindBody.push(...cut(paintShapes(ctx.c, hairTie, p.hair?.back, warpBack)));
|
|
1677
|
+
if (p.hat?.back) out.push(...paintShapes(ctx.c, hatTones, p.hat.back, warpBack));
|
|
1678
|
+
if (!p.hat?.hidesEars) out.push(...ears(ctx, "behind", p.earExtra));
|
|
1679
|
+
out.push(...head(ctx));
|
|
1680
|
+
const turnS = Math.sin(ctx.phi);
|
|
1681
|
+
if (Math.abs(turnS) > .3) {
|
|
1682
|
+
const co = Math.cos(ctx.phi);
|
|
1683
|
+
const shift = (px, py) => [px * (.92 + .08 * co) - turnS * 5, py];
|
|
1684
|
+
const clips = backOfHead(ctx.phi);
|
|
1685
|
+
const onFarSide = (nodes) => nodes.length > 0 ? [g([g(nodes, { clip: clips.crescent })], { clip: clips.half })] : [];
|
|
1686
|
+
if (isHairShown) splitAtShoulders(onFarSide(cut(paintShapes(ctx.c, hairTie, p.hair?.rear, shift))), hangs(p.hair?.rear), out, behindBody);
|
|
1687
|
+
if (p.hat) out.push(...onFarSide(paintShapes(ctx.c, hatTones, p.hat.rear, shift)));
|
|
1688
|
+
}
|
|
1689
|
+
if (!p.hat?.hidesEars) out.push(...ears(ctx, "front", p.earExtra));
|
|
1690
|
+
out.push(...cheeks(ctx, p.freckles, p.rosy));
|
|
1691
|
+
const faceWarp = turnWarp(ctx.phi, ctx.dy * .8);
|
|
1692
|
+
out.push(...paintShapes(ctx.c, hairTie, p.beard?.beard, faceWarp));
|
|
1693
|
+
if (!isPlaying) out.push(...eye(ctx, -1), ...eye(ctx, 1));
|
|
1694
|
+
out.push(...brow(ctx, -1, p.browStyle), ...brow(ctx, 1, p.browStyle));
|
|
1695
|
+
if (turnS > .8) out.push(path("M43 9C50 11 51 18 43.5 20", {
|
|
1696
|
+
fill: ctx.c.skin,
|
|
1697
|
+
stroke: ctx.c.skinInk,
|
|
1698
|
+
sw: LINE
|
|
1699
|
+
}));
|
|
1700
|
+
out.push(...nose(ctx), ...mouth(ctx));
|
|
1701
|
+
out.push(...paintShapes(ctx.c, hairTie, p.beard?.moustache, faceWarp));
|
|
1702
|
+
if (isHairShown) {
|
|
1703
|
+
splitAtShoulders(cut(paintShapes(ctx.c, hairTie, p.hair?.front, warpFront)), hangs(p.hair?.front), out, behindBody);
|
|
1704
|
+
if (p.hair?.front?.some((s) => s.clip === "head")) out.push(path(HEAD_PATH.d(), {
|
|
1705
|
+
fill: "none",
|
|
1706
|
+
stroke: ctx.c.skinInk,
|
|
1707
|
+
sw: LINE
|
|
1708
|
+
}));
|
|
1709
|
+
}
|
|
1710
|
+
if (isPlaying) out.push(...goggles(ctx));
|
|
1711
|
+
else if (p.glasses) out.push(...glasses(ctx, p.glasses, p.glassesColour, p.lensGlass));
|
|
1712
|
+
if (p.hat) out.push(...paintShapes(ctx.c, hatTones, p.hat.front, warpFront));
|
|
1713
|
+
if (status === "headset") out.push(...headset(ctx, false));
|
|
1714
|
+
out.push(...extras(ctx));
|
|
1715
|
+
return {
|
|
1716
|
+
behindBody,
|
|
1717
|
+
head: out
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
/** The back views (N, NE): the rear art wrapped past 180°. */
|
|
1721
|
+
function backHead(ctx, p, isPlaying, status, angle) {
|
|
1722
|
+
const phi = (angle - 180) * DEG;
|
|
1723
|
+
const warp = turnWarp(phi);
|
|
1724
|
+
const isHairShown = !(p.hat && p.hat.hides === "all");
|
|
1725
|
+
const hatCut = p.hat && typeof p.hat.hides === "number" ? { shapes: [rect(-120, p.hat.hides, 240, 260, {})] } : void 0;
|
|
1726
|
+
const out = [];
|
|
1727
|
+
if (!p.hat?.hidesEars) out.push(...ears({
|
|
1728
|
+
...ctx,
|
|
1729
|
+
phi
|
|
1730
|
+
}, "behind", p.earExtra));
|
|
1731
|
+
out.push(...head({
|
|
1732
|
+
...ctx,
|
|
1733
|
+
phi: 0
|
|
1734
|
+
}));
|
|
1735
|
+
if (isHairShown) {
|
|
1736
|
+
const rear = paintShapes(ctx.c, tones(ctx.c.body), p.hair?.rear, warp);
|
|
1737
|
+
out.push(...hatCut && rear.length > 0 ? [g(rear, { clip: hatCut })] : rear);
|
|
1738
|
+
if (p.hair?.rear.some((s) => s.clip === "head")) out.push(path(HEAD_PATH.d(), {
|
|
1739
|
+
fill: "none",
|
|
1740
|
+
stroke: ctx.c.skinInk,
|
|
1741
|
+
sw: LINE
|
|
1742
|
+
}));
|
|
1743
|
+
}
|
|
1744
|
+
if (isPlaying) out.push(...gogglesStrapBack(ctx, phi));
|
|
1745
|
+
if (p.hat) out.push(...paintShapes(ctx.c, tones(p.hatColour), p.hat.rear, warp));
|
|
1746
|
+
if (status === "headset") out.push(...headset(ctx, true));
|
|
1747
|
+
return {
|
|
1748
|
+
behindBody: [],
|
|
1749
|
+
head: out
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
/** The grey silhouette: a malformed code, a hidden player, or before the art loads. */
|
|
1753
|
+
function silhouette(hasShadow) {
|
|
1754
|
+
const grey = "#6f7787";
|
|
1755
|
+
return [
|
|
1756
|
+
...hasShadow ? [groundShadow(0)] : [],
|
|
1757
|
+
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 }),
|
|
1758
|
+
path(HEAD_PATH.d(), { fill: grey }, [tr(0, -96), sc(.94)])
|
|
1759
|
+
];
|
|
1760
|
+
}
|
|
1761
|
+
function anchorsFor(headX, headY, top, prop, mirror) {
|
|
1762
|
+
const at = (px, py) => ({
|
|
1763
|
+
x: px * mirror,
|
|
1764
|
+
y: py
|
|
1765
|
+
});
|
|
1766
|
+
return {
|
|
1767
|
+
feet: at(0, 0),
|
|
1768
|
+
body: at(0, -30),
|
|
1769
|
+
neck: at(headX, headY + 46),
|
|
1770
|
+
head: at(headX, headY),
|
|
1771
|
+
eyeLine: at(headX, headY + 4),
|
|
1772
|
+
mouth: at(headX, headY + 27),
|
|
1773
|
+
hatTop: at(headX, top),
|
|
1774
|
+
nameplate: at(0, top - 10),
|
|
1775
|
+
bubble: at(0, top - 14),
|
|
1776
|
+
seat: at(0, 0),
|
|
1777
|
+
prop: at(prop.x, prop.y),
|
|
1778
|
+
shadow: at(0, 0),
|
|
1779
|
+
handL: at(-40, -30),
|
|
1780
|
+
handR: at(40, -30),
|
|
1781
|
+
pod: at(0, 14)
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
/** Composes a look (or `null` for the silhouette) with a pose. */
|
|
1785
|
+
function composeAvatar(look, opts = {}) {
|
|
1786
|
+
const art = currentArt();
|
|
1787
|
+
const facingIn = facingOf(opts);
|
|
1788
|
+
const isMirrored = MIRROR[facingIn] !== void 0;
|
|
1789
|
+
const facing = MIRROR[facingIn] ?? (facingIn === "S" || facingIn === "SE" || facingIn === "E" || facingIn === "NE" ? facingIn : "N");
|
|
1790
|
+
const hasShadow = opts.shadow ?? opts.frame === "full";
|
|
1791
|
+
if (!look || !art) return {
|
|
1792
|
+
nodes: silhouette(hasShadow),
|
|
1793
|
+
anchors: anchorsFor(0, -96, -156, {
|
|
1794
|
+
x: 0,
|
|
1795
|
+
y: -30
|
|
1796
|
+
}, 1)
|
|
1797
|
+
};
|
|
1798
|
+
const pose = opts.pose ?? {};
|
|
1799
|
+
const bodyPose = pose.body ?? "idle";
|
|
1800
|
+
const status = pose.status ?? (bodyPose === "play" ? "goggles" : null);
|
|
1801
|
+
const isPlaying = status === "goggles";
|
|
1802
|
+
const face = pose.face ?? EXPRESSIONS[pose.expression ?? (bodyPose === "play" ? "determined" : bodyPose === "away" ? "sleepy" : "neutral")];
|
|
1803
|
+
const gaze = pose.look ?? {
|
|
1804
|
+
x: 0,
|
|
1805
|
+
y: 0
|
|
1806
|
+
};
|
|
1807
|
+
const angle = FACING_ANGLE[facing];
|
|
1808
|
+
const isBack = angle > 90;
|
|
1809
|
+
const parts = resolveParts(look, art);
|
|
1810
|
+
const ctx = {
|
|
1811
|
+
c: parts.c,
|
|
1812
|
+
face,
|
|
1813
|
+
gaze,
|
|
1814
|
+
phi: (angle + gaze.x * 11) * DEG,
|
|
1815
|
+
dy: gaze.y * 3,
|
|
1816
|
+
blink: pose.blink ?? 0,
|
|
1817
|
+
talk: pose.talk ?? 0
|
|
1818
|
+
};
|
|
1819
|
+
const b = bodyLayout(parts.c, bodyPose, ctx.phi, pose.phase ?? .25, opts.stool === true);
|
|
1820
|
+
const headX = b.headDx + (pose.bob?.x ?? 0);
|
|
1821
|
+
const headY = -96 + b.headDy + (pose.bob?.y ?? 0);
|
|
1822
|
+
const tilt = face.headTilt + b.tilt + (pose.tilt ?? 0);
|
|
1823
|
+
const sq = face.squash + (pose.squash ?? 0);
|
|
1824
|
+
const drawn = isBack ? backHead(ctx, parts, isPlaying, status, angle) : frontHead(ctx, parts, isPlaying, status, angle, gaze.x);
|
|
1825
|
+
const headNodes = drawn.head;
|
|
1826
|
+
const top = headY - 50 - Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0);
|
|
1827
|
+
if (status === "crown") headNodes.push(...crown(Math.max(parts.hair?.top ?? 0, parts.hat?.top ?? 0)));
|
|
1828
|
+
const handProps = status && !isBack ? handProp(status, b.prop.x, b.prop.y, parts.c.body, parts.c.bodyInk) : [];
|
|
1829
|
+
const headTf = [
|
|
1830
|
+
tr(headX, headY),
|
|
1831
|
+
rot(tilt),
|
|
1832
|
+
sc(1 + sq, 1 - sq)
|
|
1833
|
+
];
|
|
1834
|
+
const character = [
|
|
1835
|
+
...b.behind,
|
|
1836
|
+
...drawn.behindBody.length > 0 ? [g(drawn.behindBody, { tf: headTf })] : [],
|
|
1837
|
+
...b.body,
|
|
1838
|
+
g(headNodes, { tf: headTf }),
|
|
1839
|
+
...b.front,
|
|
1840
|
+
...handProps
|
|
1841
|
+
];
|
|
1842
|
+
const lifted = b.lift > 0 ? [g(character, { tf: [tr(0, -b.lift)] })] : character;
|
|
1843
|
+
const rimmed = opts.rim ? [g(lifted, { rim: true })] : lifted;
|
|
1844
|
+
const all = [...hasShadow ? [groundShadow(b.lift)] : [], ...rimmed];
|
|
1845
|
+
const mirror = isMirrored ? -1 : 1;
|
|
1846
|
+
return {
|
|
1847
|
+
nodes: isMirrored ? [g(all, { tf: [sc(-1, 1)] })] : all,
|
|
1848
|
+
anchors: anchorsFor(headX, headY - b.lift, top - b.lift, {
|
|
1849
|
+
x: b.prop.x,
|
|
1850
|
+
y: b.prop.y - b.lift
|
|
1851
|
+
}, mirror)
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
//#endregion
|
|
1855
|
+
//#region src/avatar/outputs/svg-markup.ts
|
|
1856
|
+
/**
|
|
1857
|
+
* Scene → SVG markup. Ids for clips and the rim filter are prefixed per call,
|
|
1858
|
+
* so many avatars can be inlined on one page. The output holds only numbers,
|
|
1859
|
+
* derived colours and escaped overlay text, so it is safe to insert as HTML.
|
|
1860
|
+
*/
|
|
1861
|
+
/** The rim filter: a dilated, soft light edge behind the silhouette. */
|
|
1862
|
+
const RIM_COLOUR = "#aab3c7";
|
|
1863
|
+
const FONTS = {
|
|
1864
|
+
mono: "'Space Mono', monospace",
|
|
1865
|
+
pixel: "'VT323', monospace"
|
|
1866
|
+
};
|
|
1867
|
+
/** Three decimals, for scales and opacities. */
|
|
1868
|
+
const fine = (v) => String(Math.round(v * 1e3) / 1e3);
|
|
1869
|
+
/** Escapes text for SVG content. */
|
|
1870
|
+
function escapeXml(text) {
|
|
1871
|
+
return text.replace(/[&<>"']/g, (c) => `&#${String(c.charCodeAt(0))};`);
|
|
1872
|
+
}
|
|
1873
|
+
/** A transform list as an SVG `transform` attribute (empty for none). */
|
|
1874
|
+
function tfAttr(tf) {
|
|
1875
|
+
if (!tf || tf.length === 0) return "";
|
|
1876
|
+
return ` transform="${tf.map((op) => {
|
|
1877
|
+
switch (op.k) {
|
|
1878
|
+
case "t": return `translate(${n(op.x)} ${n(op.y)})`;
|
|
1879
|
+
case "s": return `scale(${fine(op.x)} ${fine(op.y)})`;
|
|
1880
|
+
case "r": return `rotate(${n(op.a)})`;
|
|
1881
|
+
case "skx": return `skewX(${n(op.a)})`;
|
|
1882
|
+
}
|
|
1883
|
+
}).join(" ")}"`;
|
|
1884
|
+
}
|
|
1885
|
+
function paintAttrs(p) {
|
|
1886
|
+
let out = ` fill="${p.fill ?? "none"}"`;
|
|
1887
|
+
if (p.evenOdd) out += " fill-rule=\"evenodd\"";
|
|
1888
|
+
if (p.fillOpacity !== void 0) out += ` fill-opacity="${fine(p.fillOpacity)}"`;
|
|
1889
|
+
if (p.stroke && p.stroke !== "none") out += ` stroke="${p.stroke}" stroke-width="${n(p.sw ?? 1)}" stroke-linejoin="round" stroke-linecap="${p.cap ?? "round"}"`;
|
|
1890
|
+
if (p.opacity !== void 0 && p.opacity < 1) out += ` opacity="${fine(p.opacity)}"`;
|
|
1891
|
+
return out;
|
|
1892
|
+
}
|
|
1893
|
+
/** Collects `<defs>` while serialising. */
|
|
1894
|
+
var Writer = class {
|
|
1895
|
+
constructor(prefix) {
|
|
1896
|
+
_defineProperty(this, "defs", []);
|
|
1897
|
+
_defineProperty(this, "count", 0);
|
|
1898
|
+
_defineProperty(this, "usesRim", false);
|
|
1899
|
+
_defineProperty(this, "prefix", void 0);
|
|
1900
|
+
this.prefix = prefix;
|
|
1901
|
+
}
|
|
1902
|
+
id(kind) {
|
|
1903
|
+
this.count += 1;
|
|
1904
|
+
return `${this.prefix}${kind}${this.count.toString(36)}`;
|
|
1905
|
+
}
|
|
1906
|
+
clip(clip) {
|
|
1907
|
+
const id = this.id("c");
|
|
1908
|
+
const rule = clip.evenOdd ? " clip-rule=\"evenodd\"" : "";
|
|
1909
|
+
this.defs.push(`<clipPath id="${id}">${clip.shapes.map((s) => this.node(s).replace("<path ", `<path${rule} `)).join("")}</clipPath>`);
|
|
1910
|
+
return id;
|
|
1911
|
+
}
|
|
1912
|
+
node(node) {
|
|
1913
|
+
switch (node.k) {
|
|
1914
|
+
case "g": {
|
|
1915
|
+
let attrs = tfAttr(node.tf);
|
|
1916
|
+
if (node.clip) attrs += ` clip-path="url(#${this.clip(node.clip)})"`;
|
|
1917
|
+
if (node.opacity !== void 0 && node.opacity < 1) attrs += ` opacity="${fine(node.opacity)}"`;
|
|
1918
|
+
if (node.rim) {
|
|
1919
|
+
this.usesRim = true;
|
|
1920
|
+
attrs += ` filter="url(#${this.prefix}rim)"`;
|
|
1921
|
+
}
|
|
1922
|
+
const inner = node.children.map((child) => this.node(child)).join("");
|
|
1923
|
+
return attrs === "" ? inner : `<g${attrs}>${inner}</g>`;
|
|
1924
|
+
}
|
|
1925
|
+
case "path": return `<path d="${node.d}"${tfAttr(node.tf)}${paintAttrs(node)}/>`;
|
|
1926
|
+
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)}/>`;
|
|
1927
|
+
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)}/>`;
|
|
1928
|
+
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>`;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
};
|
|
1932
|
+
/** A scene as SVG markup plus the `<defs>` it needs. */
|
|
1933
|
+
function sceneToSvg(nodes, prefix) {
|
|
1934
|
+
const writer = new Writer(prefix);
|
|
1935
|
+
const body = nodes.map((node) => writer.node(node)).join("");
|
|
1936
|
+
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>`);
|
|
1937
|
+
return {
|
|
1938
|
+
body,
|
|
1939
|
+
defs: writer.defs.join("")
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
//#endregion
|
|
1943
|
+
export { tr as A, pixelSize as C, path as D, line as E, toLook as F, deltaE as M, lightness as N, rect as O, toCode as P, framed as S, g as T, mix as _, composeAvatar as a, FRAMES as b, blob as c, scallop as d, EXPRESSIONS as f, isExpression as g, faceOf as h, HEAD_Y as i, contrast as j, rot as k, n as l, expressionFromEmote as m, escapeXml as n, Path as o, EXPRESSION_NAMES as p, sceneToSvg as r, arc as s, RIM_COLOUR as t, roundedRect as u, onArtReady as v, ellipse as w, frameOf as x, ready as y };
|
|
1944
|
+
|
|
1945
|
+
//# sourceMappingURL=svg-markup.js.map
|