@botharness/pixel-avatar 0.1.0 → 0.3.0

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/dist/index.js CHANGED
@@ -1,1519 +1,1613 @@
1
1
  import { escapeAttribute } from "@botharness/pixel-morph";
2
2
 
3
- //#region src/figure.ts
4
- const N = 32;
5
- const C = 16;
6
- const INK$1 = "#2a2230";
7
- const WHITE = "#ffffff";
8
- const BLUSH = "#f4879a";
9
- const MOUTH = "#7a2a38";
10
- const MOUTH_INSIDE = "#b8415a";
11
- const TONGUE = "#ef6f84";
12
- const GOLD = "#efb93f";
13
- const blank = () => Array.from({ length: N }, () => Array(N).fill(void 0));
14
- const ellipse = (cx, cy, rx, ry) => (x, y) => ((x + .5 - cx) / rx) ** 2 + ((y + .5 - cy) / ry) ** 2 <= 1;
15
- function channels$1(color) {
16
- const n = Number.parseInt(color.slice(1), 16);
17
- return [
18
- 16,
19
- 8,
20
- 0
21
- ].map((bit) => n >> bit & 255);
22
- }
23
- function hex(values) {
24
- return `#${values.map((v) => Math.min(255, Math.max(0, Math.round(v))).toString(16).padStart(2, "0")).join("")}`;
25
- }
26
- const shade = (color, k) => hex(channels$1(color).map((v) => v * k));
27
- const mix$1 = (base, tint, k) => {
28
- const t = channels$1(tint);
29
- return hex(channels$1(base).map((v, i) => v * (1 - k) + t[i] * k));
30
- };
31
- function paint(grid, test, color) {
32
- for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (test(x, y)) grid[y][x] = color;
33
- }
34
- function sprite(grid, rows, ox, oy, pal, flip = false) {
35
- rows.forEach((row, y) => [...row].forEach((code, i) => {
36
- const c = pal[code];
37
- const x = ox + (flip ? row.length - 1 - i : i);
38
- if (c && x >= 0 && x < N && oy + y >= 0 && oy + y < N) grid[oy + y][x] = c;
39
- }));
40
- }
41
- function outline(grid) {
42
- const out = grid.map((row) => [...row]);
43
- for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
44
- if (grid[y][x]) continue;
45
- const n = [
46
- grid[y]?.[x - 1],
47
- grid[y]?.[x + 1],
48
- grid[y - 1]?.[x],
49
- grid[y + 1]?.[x]
50
- ].find(Boolean);
51
- if (n) out[y][x] = mix$1(shade(n, .3), INK$1, .62);
52
- }
53
- return out;
54
- }
55
- const R = 6;
56
- const inTile = (x, y) => {
57
- const clamp = (v) => Math.min(Math.max(v, R), N - R);
58
- return (x + .5 - clamp(x + .5)) ** 2 + (y + .5 - clamp(y + .5)) ** 2 <= R * R;
59
- };
60
- const clip = (g) => g.map((row, y) => row.map((c, x) => inTile(x, y) ? c : void 0));
61
- function rects(grid) {
62
- let m = "";
63
- for (let y = 0; y < N; y++) {
64
- let x = 0;
65
- while (x < N) {
66
- const c = grid[y][x];
67
- if (!c) {
68
- x++;
69
- continue;
70
- }
71
- let w = 1;
72
- while (x + w < N && grid[y][x + w] === c) w++;
73
- m += `<rect x="${x}" y="${y}" width="${w}" height="1" fill="${escapeAttribute(c)}"/>`;
74
- x += w;
75
- }
3
+ //#region src/random.ts
4
+ function seededRandom(seed) {
5
+ let h = 2166136261;
6
+ for (const char of seed) {
7
+ h ^= char.codePointAt(0);
8
+ h = Math.imul(h, 16777619);
76
9
  }
77
- return m;
78
- }
79
- const FACE = {
80
- round: (s) => ellipse(C + s, 16.6, 8.6, 7.6),
81
- oval: (s) => ellipse(C + s, 16.6, 8, 8),
82
- chubby: (s) => ellipse(C + s, 17, 9.4, 7.2),
83
- long: (s) => ellipse(C + s, 16.6, 7.8, 8.4),
84
- square: (s) => (x, y) => {
85
- if (y < 8 || y > 23) return false;
86
- const dx = Math.abs(x + .5 - C - s);
87
- const r = 3;
88
- if (y <= 20) return dx <= 8.5;
89
- return dx <= 8.5 - (y - 20) ** 1.3 * 1.1 + .3 - (y === 23 ? 1 : 0) || dx <= 8.5 - r;
90
- },
91
- heart: (s) => taper(ellipse(C + s, 15.8, 9, 7.4), 16.5, 9, 2.6, 24.4, s),
92
- vchin: (s) => taper(ellipse(C + s, 15.8, 8.6, 7.4), 17.5, 8.6, 2.2, 24.4, s),
93
- diamond: (s) => taper((x, y) => y >= 9 && Math.abs(x + .5 - C - s) <= 6.4 + (y - 9) * .45, 15.8, 9.2, 2.6, 24.4, s)
94
- };
95
- function taper(top, from, width, end, chin, s) {
96
- return (x, y) => {
97
- const cy = y + .5;
98
- if (cy <= from) return top(x, y);
99
- if (cy > chin) return false;
100
- const t = (cy - from) / (chin - from);
101
- return Math.abs(x + .5 - C - s) <= width - (width - end) * t ** 1.5;
10
+ return () => {
11
+ h = Math.imul(h ^ h >>> 15, 2246822507);
12
+ h = Math.imul(h ^ h >>> 13, 3266489909);
13
+ h ^= h >>> 16;
14
+ return (h >>> 0) / 4294967296;
102
15
  };
103
16
  }
104
- const EYES = {
105
- round: [
106
- "KKKK",
107
- "DWDD",
108
- "IDDI",
109
- "LIIL"
17
+
18
+ //#endregion
19
+ //#region src/recipe.ts
20
+ const AVATAR_PARTS = {
21
+ head: [
22
+ "round",
23
+ "oval",
24
+ "square",
25
+ "long",
26
+ "heart",
27
+ "vchin",
28
+ "chubby",
29
+ "diamond"
110
30
  ],
111
- dot: [
112
- "....",
113
- ".KK.",
114
- ".KK.",
115
- "...."
31
+ pose: [
32
+ "front",
33
+ "left",
34
+ "right"
116
35
  ],
117
- sparkle: [
118
- "KKKK",
119
- "DWDW",
120
- "IDDI",
121
- "LIWL"
36
+ hair: [
37
+ "crop",
38
+ "sweep",
39
+ "spiky",
40
+ "buzz",
41
+ "curly",
42
+ "mohawk",
43
+ "bob",
44
+ "long",
45
+ "bun",
46
+ "pigtails",
47
+ "afro",
48
+ "twintails",
49
+ "drills",
50
+ "ponytail",
51
+ "sidetail",
52
+ "hime",
53
+ "odango",
54
+ "messy",
55
+ "wavy",
56
+ "braids",
57
+ "wolf",
58
+ "ahoge",
59
+ "none"
122
60
  ],
123
- lashes: [
124
- "KKKKK",
125
- "K.DWD",
126
- "..IDD",
127
- "..LII"
61
+ eyes: [
62
+ "round",
63
+ "dot",
64
+ "sparkle",
65
+ "lashes",
66
+ "sleepy",
67
+ "happy",
68
+ "wink",
69
+ "sharp"
128
70
  ],
129
- sleepy: [
130
- "....",
131
- "KKKK",
132
- "IDDI",
133
- "LIIL"
71
+ brows: [
72
+ "soft",
73
+ "thick",
74
+ "raised",
75
+ "angry",
76
+ "worried",
77
+ "none"
134
78
  ],
135
- happy: [
136
- "....",
137
- ".KK.",
138
- "K..K",
139
- "...."
79
+ nose: [
80
+ "button",
81
+ "dot",
82
+ "line",
83
+ "none"
140
84
  ],
141
- wink: [
142
- "KKKK",
143
- "DWDD",
144
- "IDDI",
145
- "LIIL"
85
+ mouth: [
86
+ "smile",
87
+ "grin",
88
+ "open",
89
+ "flat",
90
+ "smirk",
91
+ "cat",
92
+ "tongue",
93
+ "o"
146
94
  ],
147
- sharp: [
148
- "KK..",
149
- ".KKK",
150
- ".DWD",
151
- "..LI"
152
- ]
153
- };
154
- const CLOSED = [
155
- "....",
156
- "....",
157
- "KKKK",
158
- "...."
159
- ];
160
- const BROWS = {
161
- soft: [".BBB"],
162
- thick: ["BBBB"],
163
- raised: [".BB."],
164
- angry: ["BB..", "..BB"],
165
- worried: ["..BB", "BB.."],
166
- none: []
167
- };
168
- const MOUTHS = {
169
- smile: ["K..K", ".KK."],
170
- grin: ["KWWK", ".KK."],
171
- open: ["KKK", "MTM"],
172
- flat: [".KK."],
173
- smirk: ["...K", ".KK."],
174
- cat: ["K.K.K", ".K.K."],
175
- tongue: ["KKKK", ".TT."],
176
- o: [
177
- ".K.",
178
- "KMK",
179
- ".K."
180
- ]
181
- };
182
- function hair(recipe, s, b) {
183
- const none = () => false;
184
- const cap = ellipse(C + s, 14, 11, 10.6);
185
- const tip = (x, depth = 1) => [
186
- 0,
187
- depth,
188
- depth + 1,
189
- depth
190
- ][(x + 40 - s) % 4];
191
- const bangs = (line, depth = 1, sides = 22) => (x, y) => {
192
- if (!cap(x, Math.min(y, 13))) return false;
193
- const dx = x + .5 - C - s;
194
- if (Math.abs(dx) >= 7) return y <= sides - [
195
- 0,
196
- 1,
197
- 0,
198
- 2
199
- ][(x + 40 - s) % 4];
200
- return y <= line + tip(x, depth) - (Math.abs(dx) > 6 ? 0 : 0);
201
- };
202
- const longBack = (bottom, width = 11.4) => (x, y) => ellipse(C + b, 14, width + .4, 11)(x, y) && y <= 16 || y > 15 && y <= bottom && Math.abs(x + .5 - C - b) <= width - (y > bottom - 2 ? 1 : 0);
203
- switch (recipe.hair) {
204
- case "none": return {
205
- back: none,
206
- front: none
207
- };
208
- case "buzz": return {
209
- back: none,
210
- front: (x, y) => cap(x, y) && y <= 7
211
- };
212
- case "crop": return {
213
- back: none,
214
- front: bangs(10, 1, 15)
215
- };
216
- case "sweep": return {
217
- back: none,
218
- front: (x, y) => cap(x, Math.min(y, 13)) && (y <= 9 + Math.max(0, (x - 6 - s) * .45) || Math.abs(x + .5 - C - s) >= 7 && y <= 16)
219
- };
220
- case "spiky": return {
221
- back: none,
222
- front: (x, y) => cap(x, Math.max(y, 6)) && y >= [
223
- 1,
224
- 3,
225
- 5,
226
- 3
227
- ][(x + 40 - s) % 4] && bangs(11, 2, 15)(x, Math.max(y, 6))
228
- };
229
- case "curly": {
230
- const bumps = Array.from({ length: 9 }, (_, i) => {
231
- const a = Math.PI * (1.02 + i * .96 / 8);
232
- return [C + s + 11.5 * Math.cos(a), 14 + 11 * Math.sin(a)];
233
- });
234
- return {
235
- back: none,
236
- front: (x, y) => bangs(11, 1, 18)(x, y) || y <= 18 && bumps.some(([bx, by]) => (x + .5 - bx) ** 2 + (y + .5 - by) ** 2 <= 6.5)
237
- };
238
- }
239
- case "mohawk": return {
240
- back: none,
241
- front: (x, y) => Math.abs(x + .5 - C - s) <= 2 && y >= 1 && y <= 9
242
- };
243
- case "bob": return {
244
- back: longBack(25),
245
- front: bangs(13, 1, 24)
246
- };
247
- case "long": return {
248
- back: longBack(31),
249
- front: bangs(13, 1, 26)
250
- };
251
- case "bun": return {
252
- back: none,
253
- front: (x, y) => bangs(12, 1, 18)(x, y) || ellipse(C + s, 2.6, 4, 2.6)(x, y)
254
- };
255
- case "pigtails": return {
256
- back: (x, y) => ellipse(C + s - 11.5, 21, 3, 4.4)(x, y) || ellipse(C + s + 11.5, 21, 3, 4.4)(x, y),
257
- front: bangs(13, 1, 21),
258
- ties: [[C + s - 12, 16], [C + s + 11, 16]]
259
- };
260
- case "afro": return {
261
- back: ellipse(C + b, 12, 15, 11.5),
262
- front: (x, y) => cap(x, y) && y <= 8
263
- };
264
- case "twintails":
265
- case "drills": return {
266
- back: (x, y) => y >= 8 && (ellipse(C + s - 13.2, 20, 2.3, 12)(x, y) || ellipse(C + s + 13.2, 20, 2.3, 12)(x, y)),
267
- front: bangs(13, 1, 24),
268
- ties: [
269
- [C + s - 13, 9],
270
- [C + s - 12, 9],
271
- [C + s + 11, 9],
272
- [C + s + 12, 9]
273
- ],
274
- bands: recipe.hair === "drills"
275
- };
276
- case "ponytail": return {
277
- back: (x, y) => ellipse(C + s + 11.5, 19, 3, 9)(x, y) && y >= 8,
278
- front: bangs(12, 1, 20),
279
- ties: [[C + s + 10, 9], [C + s + 11, 9]]
280
- };
281
- case "sidetail": return {
282
- back: (x, y) => ellipse(C + s - 11.5, 21, 3, 8)(x, y) && y >= 12,
283
- front: (x, y) => cap(x, Math.min(y, 13)) && (y <= 9 + Math.max(0, (C + s - x) * .45) || Math.abs(x + .5 - C - s) >= 7 && y <= 18),
284
- ties: [[C + s - 13, 13], [C + s - 12, 13]]
285
- };
286
- case "hime": return {
287
- back: longBack(31),
288
- front: (x, y) => cap(x, Math.min(y, 13)) && (y <= 12 || Math.abs(x + .5 - C - s) >= 6.5 && y <= 24)
289
- };
290
- case "odango": return {
291
- back: none,
292
- front: (x, y) => bangs(12, 1, 18)(x, y) || ellipse(C + s - 8, 3.5, 3.3, 3)(x, y) || ellipse(C + s + 8, 3.5, 3.3, 3)(x, y)
293
- };
294
- case "messy": return {
295
- back: none,
296
- front: (x, y) => cap(x, Math.max(y, 4)) && y >= [
297
- 2,
298
- 0,
299
- 3,
300
- 1,
301
- 2
302
- ][(x + 50 - s) % 5] && bangs(11, 2, 17)(x, Math.max(y, 4))
303
- };
304
- case "wavy": return {
305
- back: (x, y) => longBack(30)(x, y) || y >= 16 && y <= 30 && Math.abs(x + .5 - C - b) <= 12.4 + Math.sin(y * .9) * .9,
306
- front: bangs(12, 2, 25)
307
- };
308
- case "braids": return {
309
- back: none,
310
- front: (x, y) => bangs(13, 1, 20)(x, y) || [C + s - 9.5, C + s + 9.5].some((bx) => y >= 20 && y <= 30 && Math.abs(x + .5 - bx) <= 1.6 - ((y + 1) % 3 === 0 ? .6 : 0)),
311
- ties: [[C + s - 10, 30], [C + s + 9, 30]]
312
- };
313
- case "wolf": return {
314
- back: (x, y) => longBack(24, 11.8)(x, y) && (y < 20 || (x + y) % 3 !== 0),
315
- front: (x, y) => cap(x, Math.max(y, 5)) && y >= [
316
- 1,
317
- 2,
318
- 0,
319
- 2
320
- ][(x + 40 - s) % 4] && bangs(12, 2, 21)(x, Math.max(y, 5))
321
- };
322
- case "ahoge": return {
323
- back: longBack(23),
324
- front: (x, y) => bangs(13, 1, 22)(x, y) || [
325
- [0, 0],
326
- [1, 0],
327
- [1, 1],
328
- [2, 1],
329
- [2, 2],
330
- [1, 2]
331
- ].some(([ax, ay]) => x === Math.round(C + s) + ax && y === ay + 1)
332
- };
333
- }
334
- }
335
- function detailedHair(recipe, s, b) {
336
- const of = (style) => hair({
337
- ...recipe,
338
- hair: style ?? "none"
339
- }, s, b);
340
- const bangs = of(recipe.bangs);
341
- const side = of(recipe.sideHair);
342
- const back = of(recipe.backHair);
343
- const length = (recipe.hairLength ?? 0) * 2;
344
- const lengthen = (mask) => (x, y) => mask(x, y > 18 ? Math.round(y - length) : y);
345
- return {
346
- front: (x, y) => y <= 7 ? bangs.front(x, y) || back.front(x, y) : Math.abs(x + .5 - C - s) < 7 ? bangs.front(x, y) : lengthen(side.front)(x, y),
347
- back: length === 0 ? back.back : lengthen(back.back),
348
- ...back.ties ? { ties: back.ties } : {},
349
- ...back.bands ? { bands: back.bands } : {}
350
- };
351
- }
352
- function outfit(recipe, g, s) {
353
- const shirt = recipe.shirtColor;
354
- const skin = recipe.skinColor;
355
- const dark = shade(shirt, .75);
356
- const light = mix$1(shirt, WHITE, .8);
357
- const half = (y) => 6.5 + (y - 24) * 1.15;
358
- const torso = (x, y) => y >= 24 && Math.abs(x + .5 - C - s * .5) <= half(y);
359
- const dx = (x) => x + .5 - C - s * .5;
360
- paint(g, (x, y) => y >= 22 && y <= 25 && Math.abs(dx(x)) <= 2.5, shade(skin, .85));
361
- paint(g, torso, shirt);
362
- const v = (depth, w) => (x, y) => y >= 24 && y <= 24 + depth && Math.abs(dx(x)) <= w - (y - 24) * (w / (depth + 1));
363
- switch (recipe.outfit) {
364
- case "tee":
365
- paint(g, v(1, 2.5), shade(skin, .85));
366
- break;
367
- case "shirttie":
368
- paint(g, v(3, 4), light);
369
- paint(g, (x, y) => y >= 24 && Math.abs(dx(x)) <= .5 && y <= 28, "#d22a5a");
370
- break;
371
- case "hoodie":
372
- paint(g, (x, y) => torso(x, y) && y === 24 && Math.abs(dx(x)) >= 3, dark);
373
- paint(g, (x, y) => y >= 25 && y <= 27 && (Math.abs(dx(x) + 1.5) <= .5 || Math.abs(dx(x) - 1.5) <= .5), WHITE);
374
- break;
375
- case "turtleneck":
376
- paint(g, (x, y) => y >= 22 && y <= 25 && Math.abs(dx(x)) <= 3, shade(shirt, .88));
377
- break;
378
- case "sailor":
379
- paint(g, (x, y) => torso(x, y) && y <= 26 && Math.abs(dx(x)) >= 2, "#2f3a5a");
380
- paint(g, (x, y) => torso(x, y) && y === 26 && Math.abs(dx(x)) >= 3, WHITE);
381
- sprite(g, ["R.R", ".R."], Math.round(C + s * .5) - 2, 26, { R: "#e2565f" });
382
- break;
383
- case "blazer":
384
- paint(g, v(4, 4), light);
385
- paint(g, (x, y) => torso(x, y) && y >= 24 && Math.abs(Math.abs(dx(x)) - (4 - (y - 24) * .8)) <= .5, dark);
386
- sprite(g, ["G"], Math.round(C + s * .5), 28, { G: GOLD });
387
- break;
388
- case "overalls":
389
- paint(g, torso, light);
390
- paint(g, (x, y) => torso(x, y) && (y >= 27 || Math.abs(Math.abs(dx(x)) - 4) <= .6), shirt);
391
- sprite(g, ["G....G"], Math.round(C + s * .5) - 3, 27, { G: GOLD });
392
- break;
393
- case "dress":
394
- paint(g, (x, y) => torso(x, y) && y <= 25 && Math.abs(dx(x)) <= 4.5, WHITE);
395
- paint(g, (x, y) => torso(x, y) && y === 25 && Math.abs(dx(x)) <= 4.5 && x % 2 === 0, shade(WHITE, .86));
396
- sprite(g, [
397
- "RR.RR",
398
- ".RRR.",
399
- "R...R"
400
- ], Math.round(C + s * .5) - 2, 25, { R: "#e5566a" });
401
- paint(g, (x, y) => torso(x, y) && y === 29, mix$1(shirt, WHITE, .5));
402
- break;
403
- case "kimono":
404
- paint(g, (x, y) => torso(x, y) && y <= 28 && Math.abs(Math.abs(dx(x)) - (y - 23.5)) <= .6, WHITE);
405
- paint(g, (x, y) => torso(x, y) && y >= 28, "#e2565f");
406
- paint(g, (x, y) => torso(x, y) && y === 28 && x % 3 === 0, GOLD);
407
- paint(g, (x, y) => torso(x, y) && y <= 27 && (x * 3 + y * 2) % 7 === 0, mix$1(shirt, WHITE, .45));
408
- break;
409
- case "cardigan":
410
- paint(g, v(5, 3.5), light);
411
- paint(g, (x, y) => torso(x, y) && Math.abs(Math.abs(dx(x)) - 2) <= .5 && y >= 25, dark);
412
- sprite(g, [
413
- "W",
414
- ".",
415
- "W",
416
- ".",
417
- "W"
418
- ], Math.round(C + s * .5) - 3, 25, { W: WHITE });
419
- paint(g, (x, y) => torso(x, y) && y === 29, dark);
420
- break;
421
- case "maid":
422
- paint(g, (x, y) => torso(x, y), "#2f2a3a");
423
- paint(g, (x, y) => torso(x, y) && y >= 25 && Math.abs(dx(x)) <= 4 - (y - 25) * .2, WHITE);
424
- paint(g, (x, y) => y === 24 && Math.abs(dx(x)) <= 4.5, WHITE);
425
- sprite(g, ["RR.RR", ".RRR."], Math.round(C + s * .5) - 2, 24, { R: shirt });
426
- break;
427
- case "jacket":
428
- paint(g, v(6, 4.5), WHITE);
429
- paint(g, (x, y) => torso(x, y) && Math.abs(Math.abs(dx(x)) - (4.5 - (y - 24) * .6)) <= .6, dark);
430
- paint(g, (x, y) => torso(x, y) && y >= 27 && Math.abs(dx(x)) <= .5, "#9aa3ad");
431
- paint(g, (x, y) => torso(x, y) && y === 28 && Math.abs(dx(x)) >= half(y) - 2.5, mix$1(shirt, WHITE, .6));
432
- break;
433
- }
434
- const neck = shade(skin, .85);
435
- for (let y = 24; y < N; y++) for (let x = 0; x < N; x++) {
436
- const cell = g[y][x];
437
- if (!cell || cell === neck || !torso(x, y)) continue;
438
- const d = dx(x);
439
- const edge = half(y) - Math.abs(d);
440
- if (y === 24 && Math.abs(d) <= 4.5) g[y][x] = shade(cell, .78);
441
- else if (y >= 26 && edge >= 1.6 && edge < 2.6) g[y][x] = shade(cell, .82);
442
- else if (d > 0 && edge < 1.6) g[y][x] = shade(cell, .84);
443
- else if (d < 0 && edge < 1.6 && y <= 26) g[y][x] = mix$1(cell, WHITE, .28);
444
- else if (d < -2 && y <= 25) g[y][x] = mix$1(cell, WHITE, .14);
445
- }
446
- }
447
- function accessory(recipe, g, s, face) {
448
- const shirt = recipe.shirtColor;
449
- const cap = ellipse(C + s, 14, 11.6, 11.2);
450
- switch (recipe.accessory) {
451
- case "none": return;
452
- case "beanie":
453
- paint(g, (x, y) => cap(x, y) && y <= 8, shirt);
454
- paint(g, (x, y) => cap(x, y) && (y === 8 || y === 9), shade(shirt, .78));
455
- paint(g, ellipse(C + s, 1.6, 2.2, 1.8), WHITE);
456
- return;
457
- case "cap":
458
- paint(g, (x, y) => cap(x, y) && y <= 8, shirt);
459
- paint(g, (x, y) => y === 9 && x + .5 >= C + s - 10 && x + .5 <= C + s + 13, shade(shirt, .72));
460
- return;
461
- case "headphones": {
462
- const band = (x, y) => ellipse(C + s, 14, 12, 11.6)(x, y) && !ellipse(C + s, 14, 10.8, 10.4)(x, y) && y <= 13;
463
- paint(g, band, "#5d5b66");
464
- paint(g, (x, y) => y >= 12 && y <= 19 && (Math.abs(x + .5 - (C + s - 11)) <= 1.6 || Math.abs(x + .5 - (C + s + 11)) <= 1.6), "#e25d6a");
465
- return;
466
- }
467
- case "flower":
468
- sprite(g, [
469
- ".P.P.",
470
- "PPYPP",
471
- ".PPP."
472
- ], Math.round(C + s) + 6, 3, {
473
- P: "#f59fba",
474
- Y: GOLD
475
- });
476
- return;
477
- case "bow":
478
- sprite(g, [
479
- "RR.RR",
480
- "RRrRR",
481
- "RR.RR"
482
- ], Math.round(C + s) + 6, 3, {
483
- R: "#e5566a",
484
- r: "#b83a50"
485
- });
486
- return;
487
- case "earring": {
488
- const xs = [...Array(N).keys()].filter((x) => face(x, 19));
489
- if (xs.length) {
490
- sprite(g, ["G", "G"], xs[0] - 1, 20, { G: GOLD });
491
- sprite(g, ["G", "G"], xs.at(-1) + 1, 20, { G: GOLD });
492
- }
493
- return;
494
- }
495
- case "crown":
496
- sprite(g, [
497
- "G.G.G.G",
498
- "GGGGGGG",
499
- "GrGbGrG"
500
- ], Math.round(C + s) - 3, 0, {
501
- G: GOLD,
502
- r: "#e5566a",
503
- b: "#5a9be0"
504
- });
505
- return;
506
- case "halo":
507
- paint(g, (x, y) => ellipse(C + s, 1.6, 6.5, 1.8)(x, y) && !ellipse(C + s, 1.6, 4.4, .8)(x, y), GOLD);
508
- return;
509
- case "catears": {
510
- const ear = [
511
- "X...",
512
- "XX..",
513
- "XPX.",
514
- "XXXX"
515
- ];
516
- sprite(g, ear, Math.round(C + s) - 9, 2, {
517
- X: recipe.hairColor,
518
- P: "#f4a3b5"
519
- });
520
- sprite(g, ear, Math.round(C + s) + 5, 2, {
521
- X: recipe.hairColor,
522
- P: "#f4a3b5"
523
- }, true);
524
- return;
525
- }
526
- case "hairclip":
527
- sprite(g, ["CC.CC", ".CCC."], Math.round(C + s) + 3, 9, { C: "#e5566a" });
528
- return;
529
- case "horns": {
530
- const horn = [
531
- ".H",
532
- "HH",
533
- "Hh"
534
- ];
535
- sprite(g, horn, Math.round(C + s) - 8, 1, {
536
- H: "#5b3a6e",
537
- h: "#7d5694"
538
- });
539
- sprite(g, horn, Math.round(C + s) + 6, 1, {
540
- H: "#5b3a6e",
541
- h: "#7d5694"
542
- }, true);
543
- return;
544
- }
545
- case "beret":
546
- paint(g, (x, y) => ellipse(C + s + 1.5, 4.6, 10.5, 3.6)(x, y), shirt);
547
- paint(g, (x, y) => ellipse(C + s + 1.5, 4.6, 10.5, 3.6)(x, y) && y >= 6, shade(shirt, .78));
548
- paint(g, (x, y) => ellipse(C + s + 1.5, 4.6, 10.5, 3.6)(x, y) && y <= 2 && x + .5 < C + s, mix$1(shirt, WHITE, .3));
549
- sprite(g, ["S"], Math.round(C + s) + 1, 0, { S: shade(shirt, .6) });
550
- return;
551
- case "ribbon":
552
- sprite(g, [
553
- "RRR.RRR",
554
- "RrRRRrR",
555
- "RRR.RRR",
556
- ".R...R."
557
- ], Math.round(C + s) - 3, 0, {
558
- R: "#e5566a",
559
- r: "#f49aa8"
560
- });
561
- return;
562
- case "headband":
563
- paint(g, (x, y) => ellipse(C + s, 14, 11.2, 10.8)(x, y) && !ellipse(C + s, 14.2, 10.2, 9.8)(x, y) && y <= 8, shirt);
564
- paint(g, (x, y) => ellipse(C + s, 14, 11.2, 10.8)(x, y) && !ellipse(C + s, 14.2, 10.2, 9.8)(x, y) && y <= 5, mix$1(shirt, WHITE, .35));
565
- return;
566
- case "bunnyears": {
567
- const ear = [
568
- "WW",
569
- "WP",
570
- "WP",
571
- "WP",
572
- "WW"
573
- ];
574
- sprite(g, ear, Math.round(C + s) - 6, 0, {
575
- W: "#f4f1ec",
576
- P: "#f4a3b5"
577
- });
578
- sprite(g, ear, Math.round(C + s) + 4, 0, {
579
- W: "#f4f1ec",
580
- P: "#f4a3b5"
581
- }, true);
582
- return;
583
- }
584
- case "horseears": {
585
- const ear = [
586
- ".X.",
587
- "XPX",
588
- "XPX",
589
- "XXX"
590
- ];
591
- sprite(g, ear, Math.round(C + s) - 9, 1, {
592
- X: shade(recipe.hairColor, .9),
593
- P: "#f4a3b5"
594
- });
595
- sprite(g, ear, Math.round(C + s) + 6, 1, {
596
- X: shade(recipe.hairColor, .9),
597
- P: "#f4a3b5"
598
- });
599
- sprite(g, ["BB", "BB"], Math.round(C + s) + 3, 7, { B: "#5a9be0" });
600
- return;
601
- }
602
- case "flowercrown":
603
- for (const [fx, color] of [
604
- [-8, "#f59fba"],
605
- [-4, GOLD],
606
- [0, "#f4f1ec"],
607
- [4, "#f59fba"],
608
- [8, GOLD]
609
- ]) sprite(g, [
610
- ".F.",
611
- "FCF",
612
- ".F."
613
- ], Math.round(C + s) + fx - 1, 4 + Math.round(Math.abs(fx) / 4), {
614
- F: color,
615
- C: color === GOLD ? "#e2565f" : GOLD
616
- });
617
- paint(g, (x, y) => y === 6 + Math.round(Math.abs(x + .5 - C - s) / 4) && Math.abs(x + .5 - C - s) <= 9 && x % 2 === 1, "#5aa36b");
618
- return;
619
- case "witch":
620
- paint(g, (x, y) => y >= 6 && y <= 7 && Math.abs(x + .5 - C - s) <= 13, "#3a2f52");
621
- paint(g, (x, y) => y <= 6 && Math.abs(x + .5 - C - s - (6 - y) * .5) <= 1 + y * .9, "#4b3d6b");
622
- paint(g, (x, y) => y === 5 && Math.abs(x + .5 - C - s - .5) <= 5.2, "#efb93f");
623
- return;
624
- case "pins":
625
- sprite(g, [
626
- "Y.Y",
627
- ".Y.",
628
- "Y.Y",
629
- "...",
630
- "B.B",
631
- ".B.",
632
- "B.B"
633
- ], Math.round(C + s) + 6, 7, {
634
- Y: GOLD,
635
- B: "#5a9be0"
636
- });
637
- return;
95
+ cheeks: [
96
+ "blush",
97
+ "freckles",
98
+ "none"
99
+ ],
100
+ glasses: [
101
+ "none",
102
+ "round",
103
+ "square",
104
+ "shades",
105
+ "monocle"
106
+ ],
107
+ outfit: [
108
+ "tee",
109
+ "shirttie",
110
+ "hoodie",
111
+ "turtleneck",
112
+ "sailor",
113
+ "blazer",
114
+ "overalls",
115
+ "dress",
116
+ "kimono",
117
+ "cardigan",
118
+ "maid",
119
+ "jacket"
120
+ ],
121
+ backdrop: [
122
+ "sparkles",
123
+ "hearts",
124
+ "stars",
125
+ "dots",
126
+ "none"
127
+ ],
128
+ accessory: [
129
+ "none",
130
+ "beanie",
131
+ "cap",
132
+ "headphones",
133
+ "flower",
134
+ "bow",
135
+ "earring",
136
+ "crown",
137
+ "halo",
138
+ "catears",
139
+ "hairclip",
140
+ "horns",
141
+ "beret",
142
+ "ribbon",
143
+ "headband",
144
+ "bunnyears",
145
+ "horseears",
146
+ "flowercrown",
147
+ "witch",
148
+ "pins"
149
+ ]
150
+ };
151
+ const AVATAR_HAIR_PARTS = {
152
+ bangs: [
153
+ "crop",
154
+ "bob",
155
+ "sweep",
156
+ "spiky",
157
+ "curly",
158
+ "messy",
159
+ "wolf",
160
+ "wavy",
161
+ "ahoge",
162
+ "hime",
163
+ "buzz",
164
+ "mohawk",
165
+ "none"
166
+ ],
167
+ sideHair: [
168
+ "crop",
169
+ "bun",
170
+ "bob",
171
+ "long",
172
+ "hime",
173
+ "curly",
174
+ "braids",
175
+ "none"
176
+ ],
177
+ backHair: [
178
+ "none",
179
+ "bob",
180
+ "long",
181
+ "wavy",
182
+ "wolf",
183
+ "bun",
184
+ "odango",
185
+ "pigtails",
186
+ "twintails",
187
+ "drills",
188
+ "ponytail",
189
+ "sidetail",
190
+ "afro"
191
+ ]
192
+ };
193
+ const HAIR_SPLIT = {
194
+ crop: {
195
+ bangs: "crop",
196
+ sideHair: "crop",
197
+ backHair: "none"
198
+ },
199
+ sweep: {
200
+ bangs: "sweep",
201
+ sideHair: "crop",
202
+ backHair: "none"
203
+ },
204
+ spiky: {
205
+ bangs: "spiky",
206
+ sideHair: "crop",
207
+ backHair: "none"
208
+ },
209
+ buzz: {
210
+ bangs: "buzz",
211
+ sideHair: "none",
212
+ backHair: "none"
213
+ },
214
+ curly: {
215
+ bangs: "curly",
216
+ sideHair: "curly",
217
+ backHair: "none"
218
+ },
219
+ mohawk: {
220
+ bangs: "mohawk",
221
+ sideHair: "none",
222
+ backHair: "none"
223
+ },
224
+ bob: {
225
+ bangs: "bob",
226
+ sideHair: "bob",
227
+ backHair: "bob"
228
+ },
229
+ long: {
230
+ bangs: "bob",
231
+ sideHair: "long",
232
+ backHair: "long"
233
+ },
234
+ bun: {
235
+ bangs: "bob",
236
+ sideHair: "bun",
237
+ backHair: "bun"
238
+ },
239
+ pigtails: {
240
+ bangs: "bob",
241
+ sideHair: "bun",
242
+ backHair: "pigtails"
243
+ },
244
+ afro: {
245
+ bangs: "buzz",
246
+ sideHair: "none",
247
+ backHair: "afro"
248
+ },
249
+ twintails: {
250
+ bangs: "bob",
251
+ sideHair: "bob",
252
+ backHair: "twintails"
253
+ },
254
+ drills: {
255
+ bangs: "bob",
256
+ sideHair: "bob",
257
+ backHair: "drills"
258
+ },
259
+ ponytail: {
260
+ bangs: "bob",
261
+ sideHair: "bun",
262
+ backHair: "ponytail"
263
+ },
264
+ sidetail: {
265
+ bangs: "sweep",
266
+ sideHair: "bun",
267
+ backHair: "sidetail"
268
+ },
269
+ hime: {
270
+ bangs: "hime",
271
+ sideHair: "hime",
272
+ backHair: "long"
273
+ },
274
+ odango: {
275
+ bangs: "bob",
276
+ sideHair: "bun",
277
+ backHair: "odango"
278
+ },
279
+ messy: {
280
+ bangs: "messy",
281
+ sideHair: "bun",
282
+ backHair: "none"
283
+ },
284
+ wavy: {
285
+ bangs: "wavy",
286
+ sideHair: "long",
287
+ backHair: "wavy"
288
+ },
289
+ braids: {
290
+ bangs: "bob",
291
+ sideHair: "braids",
292
+ backHair: "none"
293
+ },
294
+ wolf: {
295
+ bangs: "wolf",
296
+ sideHair: "bun",
297
+ backHair: "wolf"
298
+ },
299
+ ahoge: {
300
+ bangs: "ahoge",
301
+ sideHair: "bob",
302
+ backHair: "bob"
303
+ },
304
+ none: {
305
+ bangs: "none",
306
+ sideHair: "none",
307
+ backHair: "none"
308
+ }
309
+ };
310
+ const AVATAR_RANGES = {
311
+ spacing: [-1, 1],
312
+ height: [-1, 1],
313
+ hairLength: [-2, 2]
314
+ };
315
+ const DETAIL_KEYS = [...Object.keys(AVATAR_HAIR_PARTS), ...Object.keys(AVATAR_RANGES)];
316
+ const AVATAR_COLORS = [
317
+ "skinColor",
318
+ "hairColor",
319
+ "eyeColor",
320
+ "shirtColor"
321
+ ];
322
+ /**
323
+ * Avatar Species: a base on the pixel bust rig. Species share motion and anchors; each sets its
324
+ * own ears, face details and suggested body colors. A recipe with a species is asset version 2.
325
+ */
326
+ const AVATAR_SPECIES = ["human", "goblin"];
327
+ /** Optional per-piece hair colors (asset version 2); an absent piece uses `hairColor`. */
328
+ const AVATAR_PIECE_COLORS = ["leftSideHairColor", "rightSideHairColor"];
329
+ const DEFAULT_RECIPE = {
330
+ schemaVersion: 1,
331
+ family: "illustrated",
332
+ assetVersion: 1,
333
+ rigVersion: 1,
334
+ head: "round",
335
+ pose: "front",
336
+ hair: "crop",
337
+ eyes: "round",
338
+ brows: "soft",
339
+ nose: "button",
340
+ mouth: "smile",
341
+ cheeks: "blush",
342
+ glasses: "none",
343
+ outfit: "tee",
344
+ backdrop: "sparkles",
345
+ accessory: "none",
346
+ skinColor: "#f2c9a8",
347
+ hairColor: "#5a3a2a",
348
+ eyeColor: "#3f7fbf",
349
+ shirtColor: "#5b8bd6"
350
+ };
351
+ const PART_KEYS = Object.keys(AVATAR_PARTS);
352
+ function isPixelAvatarRecipe(value) {
353
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
354
+ const r = value;
355
+ const detailed = DETAIL_KEYS.filter((key) => Object.hasOwn(r, key)).length;
356
+ if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false;
357
+ const v2 = r["assetVersion"] === 2;
358
+ const pieces = AVATAR_PIECE_COLORS.filter((key) => Object.hasOwn(r, key));
359
+ const v2Keys = v2 ? 2 + pieces.length : 0;
360
+ if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed + v2Keys) return false;
361
+ if (v2 && (detailed === 0 || !AVATAR_SPECIES.includes(r["species"]) || !AVATAR_HAIR_PARTS.sideHair.includes(r["rightSideHair"]) || !pieces.every((key) => typeof r[key] === "string" && /^#[\da-f]{6}$/iu.test(r[key])))) return false;
362
+ return (detailed === 0 || Object.keys(AVATAR_HAIR_PARTS).every((key) => typeof r[key] === "string" && AVATAR_HAIR_PARTS[key].includes(r[key])) && Object.keys(AVATAR_RANGES).every((key) => {
363
+ const v = r[key];
364
+ const [min, max] = AVATAR_RANGES[key];
365
+ return typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
366
+ })) && r["schemaVersion"] === 1 && r["family"] === "illustrated" && (r["assetVersion"] === 1 || v2) && r["rigVersion"] === 1 && PART_KEYS.every((key) => typeof r[key] === "string" && AVATAR_PARTS[key].includes(r[key])) && AVATAR_COLORS.every((key) => typeof r[key] === "string" && /^#[\da-f]{6}$/iu.test(r[key]));
367
+ }
368
+ function canonicalRecipe(recipe) {
369
+ const canonical = {
370
+ schemaVersion: 1,
371
+ family: "illustrated",
372
+ assetVersion: recipe.assetVersion,
373
+ rigVersion: 1
374
+ };
375
+ for (const key of PART_KEYS) canonical[key] = recipe[key];
376
+ for (const key of AVATAR_COLORS) canonical[key] = recipe[key].toLowerCase();
377
+ if (recipe.bangs !== void 0) for (const key of DETAIL_KEYS) canonical[key] = recipe[key];
378
+ if (recipe.assetVersion === 2) {
379
+ canonical["species"] = recipe.species;
380
+ canonical["rightSideHair"] = recipe.rightSideHair;
381
+ for (const key of AVATAR_PIECE_COLORS) if (recipe[key] !== void 0) canonical[key] = recipe[key].toLowerCase();
638
382
  }
383
+ return canonical;
639
384
  }
640
- function pixelTileColor(hair) {
641
- const [r, g, b] = channels$1(hair).map((v) => v / 255);
642
- const max = Math.max(r, g, b);
643
- const min = Math.min(r, g, b);
644
- const lightness = (max + min) / 2;
645
- const chroma = max - min;
646
- const saturation = chroma === 0 ? 0 : chroma / (1 - Math.abs(2 * lightness - 1));
647
- if (saturation < .18 || lightness > .86 || lightness < .16) return "#ece8e1";
648
- const hue = max === r ? ((g - b) / chroma + 6) % 6 : max === g ? (b - r) / chroma + 2 : (r - g) / chroma + 4;
649
- const s = Math.min(.55, Math.max(.35, saturation * .6));
650
- const l = .88;
651
- const f = (n) => {
652
- const k = (n + hue * 2) % 12;
653
- return l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1));
385
+ /**
386
+ * Returns the recipe as asset version 2 with the given species, keeping every other choice.
387
+ * Side hair splits into left (`sideHair`) and right (`rightSideHair`) pieces that start equal.
388
+ * When the skin color is one of the previous species' suggested colors, it moves to the new
389
+ * species' first suggestion; a custom color is kept.
390
+ */
391
+ function withSpecies(recipe, species) {
392
+ const detailed = detailedRecipe(recipe);
393
+ const previous = recipe.species ?? "human";
394
+ const suggested = AVATAR_SPECIES_SWATCHES[previous].includes(recipe.skinColor.toLowerCase());
395
+ return {
396
+ ...detailed,
397
+ assetVersion: 2,
398
+ species,
399
+ rightSideHair: detailed.rightSideHair ?? detailed.sideHair,
400
+ skinColor: suggested && previous !== species ? AVATAR_SPECIES_SWATCHES[species][0] : recipe.skinColor
401
+ };
402
+ }
403
+ function detailedRecipe(recipe) {
404
+ if (recipe.bangs !== void 0) return recipe;
405
+ const pick = (part) => AVATAR_HAIR_PARTS[part].includes(recipe.hair) ? recipe.hair : HAIR_SPLIT[recipe.hair][part];
406
+ return {
407
+ ...recipe,
408
+ bangs: pick("bangs"),
409
+ sideHair: pick("sideHair"),
410
+ backHair: pick("backHair"),
411
+ spacing: 0,
412
+ height: 0,
413
+ hairLength: 0
414
+ };
415
+ }
416
+ const AVATAR_SWATCHES = {
417
+ skinColor: [
418
+ "#ffe3cf",
419
+ "#f2c9a8",
420
+ "#e0a87e",
421
+ "#c68863",
422
+ "#9a6142",
423
+ "#6e4129",
424
+ "#4a2c1c",
425
+ "#f4f1ec"
426
+ ],
427
+ hairColor: [
428
+ "#1d1b22",
429
+ "#5a3a2a",
430
+ "#8a5a36",
431
+ "#e2b04a",
432
+ "#c4452f",
433
+ "#d9475a",
434
+ "#f06292",
435
+ "#3fc1b8",
436
+ "#5a7be0",
437
+ "#9aa3ad",
438
+ "#f4f1ec"
439
+ ],
440
+ eyeColor: [
441
+ "#3f7fbf",
442
+ "#5a3a2a",
443
+ "#2f9e8f",
444
+ "#4c8a3c",
445
+ "#8a5ad0",
446
+ "#d0533f",
447
+ "#d9a13a",
448
+ "#2a2230"
449
+ ],
450
+ shirtColor: [
451
+ "#5b8bd6",
452
+ "#e07a5f",
453
+ "#3d9970",
454
+ "#7a5cc7",
455
+ "#f2c14e",
456
+ "#2f3a4a",
457
+ "#e2565f",
458
+ "#9ad0c2"
459
+ ]
460
+ };
461
+ const SEEDED_CHOICES = {
462
+ head: [
463
+ "round",
464
+ "oval",
465
+ "square",
466
+ "long",
467
+ "heart",
468
+ "vchin",
469
+ "chubby",
470
+ "diamond"
471
+ ],
472
+ pose: ["front"],
473
+ hair: AVATAR_PARTS.hair.filter((hair) => hair !== "none"),
474
+ eyes: [
475
+ "round",
476
+ "round",
477
+ "sparkle",
478
+ "lashes",
479
+ "dot",
480
+ "happy",
481
+ "sharp"
482
+ ],
483
+ brows: [
484
+ "soft",
485
+ "soft",
486
+ "thick",
487
+ "raised"
488
+ ],
489
+ nose: [
490
+ "button",
491
+ "dot",
492
+ "none"
493
+ ],
494
+ mouth: [
495
+ "smile",
496
+ "smile",
497
+ "grin",
498
+ "open",
499
+ "cat"
500
+ ],
501
+ cheeks: [
502
+ "blush",
503
+ "blush",
504
+ "freckles",
505
+ "none"
506
+ ],
507
+ glasses: [
508
+ "none",
509
+ "none",
510
+ "none",
511
+ "none",
512
+ "round",
513
+ "square"
514
+ ],
515
+ accessory: [
516
+ "none",
517
+ "none",
518
+ "none",
519
+ "flower",
520
+ "bow",
521
+ "hairclip",
522
+ "catears",
523
+ "headphones",
524
+ "beanie",
525
+ "cap"
526
+ ],
527
+ outfit: AVATAR_PARTS.outfit,
528
+ backdrop: [
529
+ "sparkles",
530
+ "hearts",
531
+ "stars",
532
+ "dots"
533
+ ]
534
+ };
535
+ /**
536
+ * Returns a name-seeded recipe factory. Different namespaces give different faces for the same
537
+ * name; the factory takes the name only, so it is safe to pass straight to `Array.map`.
538
+ */
539
+ function createSeededRecipe(namespace) {
540
+ return (seed) => {
541
+ const random = seededRandom(`${namespace}:${seed.trim().toLowerCase()}`);
542
+ const pick = (values) => values[Math.floor(random() * values.length)];
543
+ const recipe = {
544
+ schemaVersion: 1,
545
+ family: "illustrated",
546
+ assetVersion: 1,
547
+ rigVersion: 1
548
+ };
549
+ for (const part of Object.keys(AVATAR_PARTS)) recipe[part] = pick(SEEDED_CHOICES[part]);
550
+ for (const color of AVATAR_COLORS) recipe[color] = pick(AVATAR_SWATCHES[color].slice(0, 7));
551
+ return recipe;
654
552
  };
655
- return hex([
656
- f(0),
657
- f(8),
658
- f(4)
659
- ].map((v) => v * 255));
660
553
  }
661
- function pixelFigure(recipe, yawDeg) {
662
- const yaw = yawDeg * Math.PI / 180;
663
- const sinY = Math.sin(yaw);
664
- const s = Math.round(sinY * 3);
665
- const fs = Math.round(sinY * 10);
666
- const b = -Math.round(sinY * 3.5);
667
- const turned = Math.abs(sinY) >= .3;
668
- const d = Math.sign(sinY);
669
- const skin = recipe.skinColor;
670
- const hairColor = recipe.hairColor;
671
- const front = FACE[recipe.head](s * .5);
672
- const face = turned ? (x, y) => {
673
- const dx = x + .5 - C;
674
- const tuck = y >= 17 && d * dx > 4 ? Math.min(2, (y - 16) * .35) : 0;
675
- return front(x - Math.round(d * (1 + Math.max(0, y - 20) * .4) - d * tuck), y);
676
- } : front;
677
- const masks = recipe.bangs === void 0 ? hair(recipe, s, b) : detailedHair(recipe, s, b);
678
- const body = blank();
679
- outfit(recipe, body, s);
680
- const back = blank();
681
- paint(back, masks.back, shade(hairColor, .82));
682
- if (masks.bands) paint(back, (x, y) => masks.back(x, y) && (y + Math.floor(x / 2)) % 4 === 0, shade(hairColor, .68));
683
- const head = blank();
684
- paint(head, face, skin);
685
- const earY = 15;
686
- const edge = (side) => {
687
- const xs = [...Array(N).keys()].filter((x) => face(x, earY));
688
- return side < 0 ? xs[0] - 1 : xs.at(-1) + 1;
689
- };
690
- if (!turned) for (const ex of [-1, 1]) sprite(head, [
691
- "E",
692
- "E",
693
- "e"
694
- ], edge(ex), earY, {
695
- E: skin,
696
- e: shade(skin, .86)
697
- });
698
- else sprite(head, [
699
- "EE",
700
- "Ee",
701
- "Ee",
702
- "E."
703
- ], d > 0 ? edge(-d) - 1 : edge(-d), earY, {
704
- E: skin,
705
- e: shade(skin, .8)
706
- });
707
- paint(head, masks.front, hairColor);
708
- for (const [x, y] of masks.ties ?? []) sprite(back, ["T"], Math.round(x), y, { T: shade(recipe.shirtColor, .8) });
709
- accessory(recipe, head, s, face);
710
- const shadeHair = (g) => {
711
- const out = g.map((r) => [...r]);
712
- const cells = g.flatMap((row, y) => row.flatMap((c, x) => c === hairColor ? [[x, y]] : []));
713
- const xs = cells.map(([x]) => x);
714
- const ys = cells.map(([, y]) => y);
715
- const left = Math.min(...xs);
716
- const right = Math.max(...xs);
717
- const top = Math.min(...ys);
718
- const mid = (left + right) / 2;
719
- const span = Math.max(1, (right - left) / 2);
720
- const light = mix$1(hairColor, WHITE, .2);
721
- const dark = shade(hairColor, .8);
722
- const middle = shade(hairColor, .9);
723
- for (const [x, y] of cells) {
724
- const side = (x + .5 - mid) / span + (y - top) * .025;
725
- if (side > .72) out[y][x] = dark;
726
- else if (side > .42) out[y][x] = middle;
727
- else if (side < -.2 && y <= top + 4) out[y][x] = light;
728
- }
729
- for (const [x, y] of cells) if ((g[y][x - 1] === skin || g[y][x + 1] === skin) && y >= 14) out[y][x] = shade(hairColor, .74);
730
- else if (Math.abs(x + .5 - C - s) >= 7.5 && y >= 14 && (x + 40 - s) % 3 === 0) out[y][x] = shade(out[y][x], .86);
731
- for (let y = 1; y < N; y++) for (let x = 0; x < N; x++) {
732
- if (g[y][x] !== skin) continue;
733
- if (g[y - 1][x] === hairColor) out[y][x] = mix$1(shade(skin, .84), BLUSH, .12);
734
- else if (x + 1 < N && g[y][x + 1] !== skin && g[y][x + 1] !== void 0 && x > C + s) out[y][x] = shade(skin, .9);
735
- else if (x + 1 < N && g[y][x + 1] === void 0 && x > C + s) out[y][x] = shade(skin, .9);
736
- }
737
- for (let y = 0; y < N - 1; y++) for (let x = 0; x < N; x++) if (g[y][x] === hairColor && g[y + 1][x] === skin) out[y][x] = shade(hairColor, .62);
738
- for (let x = 0; x < N; x++) for (let y = 3; y < N; y++) if (g[y][x] === hairColor && g[y + 1]?.[x] === skin && (x + 40 - s) % 4 === 0) {
739
- for (let k = 1; k <= 3; k++) if (g[y - k]?.[x] === hairColor) out[y - k][x] = shade(hairColor, .72);
740
- }
741
- const ring = mix$1(hairColor, WHITE, .45);
742
- for (let x = Math.round(C + s) - 7; x <= Math.round(C + s) + 3; x++) {
743
- const first = g.findIndex((row) => row[x] === hairColor);
744
- if (first < 0) continue;
745
- const y = first + 2 + Math.round(Math.abs(x + .5 - (C + s - 2)) / 4);
746
- if (g[y]?.[x] === hairColor && x % 3 !== 0) out[y][x] = ring;
747
- }
748
- return out;
749
- };
750
- const figure = blank();
751
- for (const layer of [
752
- back,
753
- body,
754
- shadeHair(head)
755
- ]) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (layer[y][x]) figure[y][x] = layer[y][x];
756
- const features = blank();
757
- const eyes = blank();
758
- const closed = blank();
759
- const glasses = blank();
760
- const sp = recipe.spacing ?? 0;
761
- const h = recipe.height ?? 0;
762
- const eyeTop = 15 + h;
763
- const eyeW = (side) => turned && Math.sign(fs) === side ? 3 : 4;
764
- const eyeX = (side) => {
765
- const w = eyeW(side);
766
- return side < 0 ? Math.round(C + fs * .6) - 6 - sp - (w === 3 ? -1 : 0) : Math.round(C + fs * .6) + 2 + sp;
767
- };
768
- const left = {
769
- x: eyeX(-1),
770
- w: eyeW(-1)
771
- };
772
- const right = {
773
- x: eyeX(1),
774
- w: eyeW(1)
775
- };
776
- const fit = (rows, w) => rows.map((r) => r.slice(Math.max(0, r.length - w)));
777
- const pal = {
778
- K: INK$1,
779
- W: WHITE,
780
- D: shade(recipe.eyeColor, .55),
781
- I: recipe.eyeColor,
782
- L: mix$1(recipe.eyeColor, WHITE, .45)
783
- };
784
- const style = EYES[recipe.eyes];
785
- const extra = style[0].length - 4;
786
- sprite(eyes, fit(style, left.w + extra), left.x - extra, eyeTop, pal);
787
- sprite(eyes, recipe.eyes === "wink" ? fit(CLOSED, right.w) : fit(style, right.w + extra), right.x, eyeTop, pal, true);
788
- sprite(closed, fit(CLOSED, left.w), left.x, eyeTop, pal);
789
- sprite(closed, fit(CLOSED, right.w), right.x, eyeTop, pal, true);
790
- const browRows = BROWS[recipe.brows];
791
- if (browRows.length) {
792
- sprite(features, fit(browRows, left.w), left.x, eyeTop - 2, { B: shade(hairColor, .55) });
793
- sprite(features, fit(browRows, right.w), right.x, eyeTop - 2, { B: shade(hairColor, .55) }, true);
794
- }
795
- const cx = Math.round(C + fs * (turned ? .85 : .6));
796
- if (recipe.nose === "dot") sprite(features, ["N"], cx, 18 + h, { N: shade(skin, .78) });
797
- if (recipe.nose === "button") sprite(features, ["N"], cx, 18 + h, { N: shade(skin, .88) });
798
- if (recipe.nose === "line") sprite(features, ["N", "N"], cx, 17 + h, { N: shade(skin, .82) });
799
- const mouth = MOUTHS[recipe.mouth];
800
- const mx = C - .5 + fs * .6;
801
- sprite(features, mouth, Math.round(mx - (mouth[0].length - 1) / 2), 20 + h, {
802
- K: MOUTH,
803
- M: MOUTH_INSIDE,
804
- T: TONGUE,
805
- W: WHITE
806
- });
807
- if (recipe.cheeks === "blush") {
808
- sprite(features, ["PP"], left.x - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
809
- sprite(features, ["PP"], right.x + right.w - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
810
- }
811
- if (recipe.cheeks === "freckles") {
812
- sprite(features, ["F.F"], left.x, 19 + h, { F: shade(skin, .7) });
813
- sprite(features, ["F.F"], right.x + 1, 19 + h, { F: shade(skin, .7) });
814
- }
815
- const frame = (fx, w, round) => paint(glasses, (x, y) => {
816
- const inside = x >= fx && x < fx + w && y >= eyeTop && y <= eyeTop + 3;
817
- const box = x >= fx - 1 && x <= fx + w && y >= eyeTop - 1 && y <= eyeTop + 4;
818
- const corner = (x === fx - 1 || x === fx + w) && (y === eyeTop - 1 || y === eyeTop + 4);
819
- return box && !inside && !(round && corner);
820
- }, INK$1);
821
- if (recipe.glasses === "round" || recipe.glasses === "square") {
822
- frame(left.x, left.w, recipe.glasses === "round");
823
- frame(right.x, right.w, recipe.glasses === "round");
824
- paint(glasses, (x, y) => y === eyeTop + 1 && x > left.x + left.w && x < right.x - 1, INK$1);
825
- }
826
- if (recipe.glasses === "shades") {
827
- for (const e of [left, right]) paint(glasses, (x, y) => x >= e.x - 1 && x <= e.x + e.w && y >= eyeTop && y <= eyeTop + 2, INK$1);
828
- paint(glasses, (x, y) => y === eyeTop && x > left.x + left.w && x < right.x - 1, INK$1);
829
- for (const e of [left, right]) sprite(glasses, ["W"], e.x, eyeTop, { W: "#7d7b88" });
830
- }
831
- if (recipe.glasses === "monocle") {
832
- frame(right.x, right.w, true);
833
- sprite(glasses, [
834
- "G",
835
- "G",
836
- "G"
837
- ], right.x + right.w, eyeTop + 5, { G: GOLD });
838
- }
839
- const full = clip(outline(figure));
840
- const bodyCells = blank();
841
- const headCells = blank();
842
- for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
843
- const c = full[y][x];
844
- if (!c) continue;
845
- if (y >= 22 && !head[y][x] && !back[y][x]) bodyCells[y][x] = c;
846
- else headCells[y][x] = c;
847
- }
848
- const cells = [];
554
+ /** The same name always gives the same face (BotHarness's namespace). */
555
+ const seededRecipe = createSeededRecipe("botharness-avatar");
556
+ /** Suggested body colors per species; any color remains allowed. */
557
+ const AVATAR_SPECIES_SWATCHES = {
558
+ human: AVATAR_SWATCHES.skinColor,
559
+ goblin: [
560
+ "#9cc464",
561
+ "#7fae4f",
562
+ "#6a9a45",
563
+ "#b3cf7a",
564
+ "#5c8a4a",
565
+ "#8fa86a"
566
+ ]
567
+ };
568
+ const preset = (parts) => ({
569
+ ...DEFAULT_RECIPE,
570
+ ...parts
571
+ });
572
+ const AVATAR_PRESETS = [
573
+ preset({
574
+ hair: "wavy",
575
+ eyes: "lashes",
576
+ accessory: "beret",
577
+ outfit: "dress",
578
+ skinColor: "#ffe3cf",
579
+ hairColor: "#e2b04a",
580
+ eyeColor: "#3f7fbf",
581
+ shirtColor: "#e2565f",
582
+ backdrop: "stars"
583
+ }),
584
+ preset({
585
+ hair: "braids",
586
+ accessory: "flowercrown",
587
+ outfit: "cardigan",
588
+ skinColor: "#ffe3cf",
589
+ hairColor: "#8a5a36",
590
+ eyeColor: "#4c8a3c",
591
+ shirtColor: "#9ad0c2",
592
+ backdrop: "hearts"
593
+ }),
594
+ preset({
595
+ hair: "twintails",
596
+ eyes: "sparkle",
597
+ accessory: "ribbon",
598
+ outfit: "maid",
599
+ skinColor: "#ffe3cf",
600
+ hairColor: "#3fc1b8",
601
+ eyeColor: "#2f9e8f",
602
+ shirtColor: "#e2565f",
603
+ backdrop: "stars"
604
+ }),
605
+ preset({
606
+ hair: "long",
607
+ accessory: "horseears",
608
+ outfit: "jacket",
609
+ skinColor: "#ffe3cf",
610
+ hairColor: "#8a5a36",
611
+ eyeColor: "#d0533f",
612
+ shirtColor: "#5b8bd6",
613
+ backdrop: "dots"
614
+ }),
615
+ preset({
616
+ hair: "hime",
617
+ eyes: "sleepy",
618
+ accessory: "pins",
619
+ outfit: "kimono",
620
+ skinColor: "#f4f1ec",
621
+ hairColor: "#1d1b22",
622
+ eyeColor: "#d0533f",
623
+ shirtColor: "#7a5cc7",
624
+ backdrop: "none"
625
+ }),
626
+ preset({
627
+ hair: "ahoge",
628
+ accessory: "headband",
629
+ outfit: "sailor",
630
+ skinColor: "#ffe3cf",
631
+ hairColor: "#f06292",
632
+ eyeColor: "#2f9e8f",
633
+ shirtColor: "#f4f1ec",
634
+ backdrop: "hearts"
635
+ }),
636
+ preset({
637
+ hair: "wolf",
638
+ eyes: "sharp",
639
+ outfit: "jacket",
640
+ skinColor: "#e0a87e",
641
+ hairColor: "#9aa3ad",
642
+ eyeColor: "#5a7be0",
643
+ shirtColor: "#2f3a4a",
644
+ backdrop: "stars"
645
+ }),
646
+ preset({
647
+ hair: "wavy",
648
+ eyes: "sparkle",
649
+ accessory: "witch",
650
+ outfit: "dress",
651
+ skinColor: "#ffe3cf",
652
+ hairColor: "#8a5ad0",
653
+ eyeColor: "#d9a13a",
654
+ shirtColor: "#2f3a4a",
655
+ backdrop: "sparkles"
656
+ }),
657
+ preset({
658
+ hair: "bob",
659
+ eyes: "happy",
660
+ accessory: "bunnyears",
661
+ outfit: "cardigan",
662
+ skinColor: "#ffe3cf",
663
+ hairColor: "#f4f1ec",
664
+ eyeColor: "#d9475a",
665
+ shirtColor: "#f2c14e",
666
+ backdrop: "hearts"
667
+ }),
668
+ preset({
669
+ hair: "spiky",
670
+ glasses: "round",
671
+ accessory: "headphones",
672
+ outfit: "hoodie",
673
+ backdrop: "dots"
674
+ }),
675
+ preset({
676
+ hair: "odango",
677
+ eyes: "lashes",
678
+ accessory: "bow",
679
+ outfit: "kimono",
680
+ hairColor: "#e2b04a",
681
+ shirtColor: "#5b8bd6",
682
+ backdrop: "sparkles"
683
+ }),
684
+ preset({
685
+ hair: "crop",
686
+ accessory: "cap",
687
+ outfit: "shirttie",
688
+ skinColor: "#9a6142",
689
+ hairColor: "#1d1b22",
690
+ shirtColor: "#3d9970",
691
+ backdrop: "dots"
692
+ })
693
+ ];
694
+
695
+ //#endregion
696
+ //#region src/figure.ts
697
+ const N = 32;
698
+ const C = 16;
699
+ const INK$1 = "#2a2230";
700
+ const WHITE = "#ffffff";
701
+ const BLUSH = "#f4879a";
702
+ const MOUTH = "#7a2a38";
703
+ const MOUTH_INSIDE = "#b8415a";
704
+ const TONGUE = "#ef6f84";
705
+ const GOLD = "#efb93f";
706
+ const TUSK = "#f6f0d8";
707
+ const blank = () => Array.from({ length: N }, () => Array(N).fill(void 0));
708
+ const ellipse = (cx, cy, rx, ry) => (x, y) => ((x + .5 - cx) / rx) ** 2 + ((y + .5 - cy) / ry) ** 2 <= 1;
709
+ function channels$1(color) {
710
+ const n = Number.parseInt(color.slice(1), 16);
711
+ return [
712
+ 16,
713
+ 8,
714
+ 0
715
+ ].map((bit) => n >> bit & 255);
716
+ }
717
+ function hex(values) {
718
+ return `#${values.map((v) => Math.min(255, Math.max(0, Math.round(v))).toString(16).padStart(2, "0")).join("")}`;
719
+ }
720
+ const shade = (color, k) => hex(channels$1(color).map((v) => v * k));
721
+ const mix$1 = (base, tint, k) => {
722
+ const t = channels$1(tint);
723
+ return hex(channels$1(base).map((v, i) => v * (1 - k) + t[i] * k));
724
+ };
725
+ function paint(grid, test, color) {
726
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (test(x, y)) grid[y][x] = color;
727
+ }
728
+ function sprite(grid, rows, ox, oy, pal, flip = false) {
729
+ rows.forEach((row, y) => [...row].forEach((code, i) => {
730
+ const c = pal[code];
731
+ const x = ox + (flip ? row.length - 1 - i : i);
732
+ if (c && x >= 0 && x < N && oy + y >= 0 && oy + y < N) grid[oy + y][x] = c;
733
+ }));
734
+ }
735
+ function outline(grid) {
736
+ const out = grid.map((row) => [...row]);
849
737
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
850
- const c = glasses[y][x] ?? eyes[y][x] ?? features[y][x] ?? full[y][x];
851
- if (c) cells.push({
852
- x,
853
- y,
854
- c
855
- });
738
+ if (grid[y][x]) continue;
739
+ const n = [
740
+ grid[y]?.[x - 1],
741
+ grid[y]?.[x + 1],
742
+ grid[y - 1]?.[x],
743
+ grid[y + 1]?.[x]
744
+ ].find(Boolean);
745
+ if (n) out[y][x] = mix$1(shade(n, .3), INK$1, .62);
856
746
  }
857
- return {
858
- cells,
859
- tile: `<rect width="32" height="32" rx="6" fill="${pixelTileColor(recipe.hairColor)}"/>`,
860
- body: `<g class="bh-illustrated-body">${rects(bodyCells)}</g>`,
861
- head: [
862
- `<g class="bh-illustrated-head">${rects(headCells)}`,
863
- `<g class="bh-illustrated-face">${rects(features)}`,
864
- `<g class="bh-illustrated-gaze">${rects(eyes)}</g>`,
865
- `<g class="bh-illustrated-blink" opacity="0">${rects(closed)}</g>`,
866
- `${rects(glasses)}</g></g>`
867
- ].join("")
868
- };
747
+ return out;
869
748
  }
870
-
871
- //#endregion
872
- //#region src/random.ts
873
- function seededRandom(seed) {
874
- let h = 2166136261;
875
- for (const char of seed) {
876
- h ^= char.codePointAt(0);
877
- h = Math.imul(h, 16777619);
749
+ const R = 6;
750
+ const inTile = (x, y) => {
751
+ const clamp = (v) => Math.min(Math.max(v, R), N - R);
752
+ return (x + .5 - clamp(x + .5)) ** 2 + (y + .5 - clamp(y + .5)) ** 2 <= R * R;
753
+ };
754
+ const clip = (g) => g.map((row, y) => row.map((c, x) => inTile(x, y) ? c : void 0));
755
+ function rects(grid) {
756
+ let m = "";
757
+ for (let y = 0; y < N; y++) {
758
+ let x = 0;
759
+ while (x < N) {
760
+ const c = grid[y][x];
761
+ if (!c) {
762
+ x++;
763
+ continue;
764
+ }
765
+ let w = 1;
766
+ while (x + w < N && grid[y][x + w] === c) w++;
767
+ m += `<rect x="${x}" y="${y}" width="${w}" height="1" fill="${escapeAttribute(c)}"/>`;
768
+ x += w;
769
+ }
878
770
  }
879
- return () => {
880
- h = Math.imul(h ^ h >>> 15, 2246822507);
881
- h = Math.imul(h ^ h >>> 13, 3266489909);
882
- h ^= h >>> 16;
883
- return (h >>> 0) / 4294967296;
884
- };
771
+ return m;
885
772
  }
886
-
887
- //#endregion
888
- //#region src/recipe.ts
889
- const AVATAR_PARTS = {
890
- head: [
891
- "round",
892
- "oval",
893
- "square",
894
- "long",
895
- "heart",
896
- "vchin",
897
- "chubby",
898
- "diamond"
899
- ],
900
- pose: [
901
- "front",
902
- "left",
903
- "right"
904
- ],
905
- hair: [
906
- "crop",
907
- "sweep",
908
- "spiky",
909
- "buzz",
910
- "curly",
911
- "mohawk",
912
- "bob",
913
- "long",
914
- "bun",
915
- "pigtails",
916
- "afro",
917
- "twintails",
918
- "drills",
919
- "ponytail",
920
- "sidetail",
921
- "hime",
922
- "odango",
923
- "messy",
924
- "wavy",
925
- "braids",
926
- "wolf",
927
- "ahoge",
928
- "none"
929
- ],
930
- eyes: [
931
- "round",
932
- "dot",
933
- "sparkle",
934
- "lashes",
935
- "sleepy",
936
- "happy",
937
- "wink",
938
- "sharp"
939
- ],
940
- brows: [
941
- "soft",
942
- "thick",
943
- "raised",
944
- "angry",
945
- "worried",
946
- "none"
947
- ],
948
- nose: [
949
- "button",
950
- "dot",
951
- "line",
952
- "none"
953
- ],
954
- mouth: [
955
- "smile",
956
- "grin",
957
- "open",
958
- "flat",
959
- "smirk",
960
- "cat",
961
- "tongue",
962
- "o"
773
+ const FACE = {
774
+ round: (s) => ellipse(C + s, 16.6, 8.6, 7.6),
775
+ oval: (s) => ellipse(C + s, 16.6, 8, 8),
776
+ chubby: (s) => ellipse(C + s, 17, 9.4, 7.2),
777
+ long: (s) => ellipse(C + s, 16.6, 7.8, 8.4),
778
+ square: (s) => (x, y) => {
779
+ if (y < 8 || y > 23) return false;
780
+ const dx = Math.abs(x + .5 - C - s);
781
+ const r = 3;
782
+ if (y <= 20) return dx <= 8.5;
783
+ return dx <= 8.5 - (y - 20) ** 1.3 * 1.1 + .3 - (y === 23 ? 1 : 0) || dx <= 8.5 - r;
784
+ },
785
+ heart: (s) => taper(ellipse(C + s, 15.8, 9, 7.4), 16.5, 9, 2.6, 24.4, s),
786
+ vchin: (s) => taper(ellipse(C + s, 15.8, 8.6, 7.4), 17.5, 8.6, 2.2, 24.4, s),
787
+ diamond: (s) => taper((x, y) => y >= 9 && Math.abs(x + .5 - C - s) <= 6.4 + (y - 9) * .45, 15.8, 9.2, 2.6, 24.4, s)
788
+ };
789
+ function taper(top, from, width, end, chin, s) {
790
+ return (x, y) => {
791
+ const cy = y + .5;
792
+ if (cy <= from) return top(x, y);
793
+ if (cy > chin) return false;
794
+ const t = (cy - from) / (chin - from);
795
+ return Math.abs(x + .5 - C - s) <= width - (width - end) * t ** 1.5;
796
+ };
797
+ }
798
+ const EYES = {
799
+ round: [
800
+ "KKKK",
801
+ "DWDD",
802
+ "IDDI",
803
+ "LIIL"
963
804
  ],
964
- cheeks: [
965
- "blush",
966
- "freckles",
967
- "none"
805
+ dot: [
806
+ "....",
807
+ ".KK.",
808
+ ".KK.",
809
+ "...."
968
810
  ],
969
- glasses: [
970
- "none",
971
- "round",
972
- "square",
973
- "shades",
974
- "monocle"
811
+ sparkle: [
812
+ "KKKK",
813
+ "DWDW",
814
+ "IDDI",
815
+ "LIWL"
975
816
  ],
976
- outfit: [
977
- "tee",
978
- "shirttie",
979
- "hoodie",
980
- "turtleneck",
981
- "sailor",
982
- "blazer",
983
- "overalls",
984
- "dress",
985
- "kimono",
986
- "cardigan",
987
- "maid",
988
- "jacket"
817
+ lashes: [
818
+ "KKKKK",
819
+ "K.DWD",
820
+ "..IDD",
821
+ "..LII"
989
822
  ],
990
- backdrop: [
991
- "sparkles",
992
- "hearts",
993
- "stars",
994
- "dots",
995
- "none"
823
+ sleepy: [
824
+ "....",
825
+ "KKKK",
826
+ "IDDI",
827
+ "LIIL"
996
828
  ],
997
- accessory: [
998
- "none",
999
- "beanie",
1000
- "cap",
1001
- "headphones",
1002
- "flower",
1003
- "bow",
1004
- "earring",
1005
- "crown",
1006
- "halo",
1007
- "catears",
1008
- "hairclip",
1009
- "horns",
1010
- "beret",
1011
- "ribbon",
1012
- "headband",
1013
- "bunnyears",
1014
- "horseears",
1015
- "flowercrown",
1016
- "witch",
1017
- "pins"
1018
- ]
1019
- };
1020
- const AVATAR_HAIR_PARTS = {
1021
- bangs: [
1022
- "crop",
1023
- "bob",
1024
- "sweep",
1025
- "spiky",
1026
- "curly",
1027
- "messy",
1028
- "wolf",
1029
- "wavy",
1030
- "ahoge",
1031
- "hime",
1032
- "buzz",
1033
- "mohawk",
1034
- "none"
829
+ happy: [
830
+ "....",
831
+ ".KK.",
832
+ "K..K",
833
+ "...."
1035
834
  ],
1036
- sideHair: [
1037
- "crop",
1038
- "bun",
1039
- "bob",
1040
- "long",
1041
- "hime",
1042
- "curly",
1043
- "braids",
1044
- "none"
835
+ wink: [
836
+ "KKKK",
837
+ "DWDD",
838
+ "IDDI",
839
+ "LIIL"
1045
840
  ],
1046
- backHair: [
1047
- "none",
1048
- "bob",
1049
- "long",
1050
- "wavy",
1051
- "wolf",
1052
- "bun",
1053
- "odango",
1054
- "pigtails",
1055
- "twintails",
1056
- "drills",
1057
- "ponytail",
1058
- "sidetail",
1059
- "afro"
841
+ sharp: [
842
+ "KK..",
843
+ ".KKK",
844
+ ".DWD",
845
+ "..LI"
1060
846
  ]
1061
- };
1062
- const HAIR_SPLIT = {
1063
- crop: {
1064
- bangs: "crop",
1065
- sideHair: "crop",
1066
- backHair: "none"
1067
- },
1068
- sweep: {
1069
- bangs: "sweep",
1070
- sideHair: "crop",
1071
- backHair: "none"
1072
- },
1073
- spiky: {
1074
- bangs: "spiky",
1075
- sideHair: "crop",
1076
- backHair: "none"
1077
- },
1078
- buzz: {
1079
- bangs: "buzz",
1080
- sideHair: "none",
1081
- backHair: "none"
1082
- },
1083
- curly: {
1084
- bangs: "curly",
1085
- sideHair: "curly",
1086
- backHair: "none"
1087
- },
1088
- mohawk: {
1089
- bangs: "mohawk",
1090
- sideHair: "none",
1091
- backHair: "none"
1092
- },
1093
- bob: {
1094
- bangs: "bob",
1095
- sideHair: "bob",
1096
- backHair: "bob"
1097
- },
1098
- long: {
1099
- bangs: "bob",
1100
- sideHair: "long",
1101
- backHair: "long"
1102
- },
1103
- bun: {
1104
- bangs: "bob",
1105
- sideHair: "bun",
1106
- backHair: "bun"
1107
- },
1108
- pigtails: {
1109
- bangs: "bob",
1110
- sideHair: "bun",
1111
- backHair: "pigtails"
1112
- },
1113
- afro: {
1114
- bangs: "buzz",
1115
- sideHair: "none",
1116
- backHair: "afro"
1117
- },
1118
- twintails: {
1119
- bangs: "bob",
1120
- sideHair: "bob",
1121
- backHair: "twintails"
1122
- },
1123
- drills: {
1124
- bangs: "bob",
1125
- sideHair: "bob",
1126
- backHair: "drills"
1127
- },
1128
- ponytail: {
1129
- bangs: "bob",
1130
- sideHair: "bun",
1131
- backHair: "ponytail"
1132
- },
1133
- sidetail: {
1134
- bangs: "sweep",
1135
- sideHair: "bun",
1136
- backHair: "sidetail"
1137
- },
1138
- hime: {
1139
- bangs: "hime",
1140
- sideHair: "hime",
1141
- backHair: "long"
1142
- },
1143
- odango: {
1144
- bangs: "bob",
1145
- sideHair: "bun",
1146
- backHair: "odango"
1147
- },
1148
- messy: {
1149
- bangs: "messy",
1150
- sideHair: "bun",
1151
- backHair: "none"
1152
- },
1153
- wavy: {
1154
- bangs: "wavy",
1155
- sideHair: "long",
1156
- backHair: "wavy"
1157
- },
1158
- braids: {
1159
- bangs: "bob",
1160
- sideHair: "braids",
1161
- backHair: "none"
1162
- },
1163
- wolf: {
1164
- bangs: "wolf",
1165
- sideHair: "bun",
1166
- backHair: "wolf"
1167
- },
1168
- ahoge: {
1169
- bangs: "ahoge",
1170
- sideHair: "bob",
1171
- backHair: "bob"
1172
- },
1173
- none: {
1174
- bangs: "none",
1175
- sideHair: "none",
1176
- backHair: "none"
847
+ };
848
+ const CLOSED = [
849
+ "....",
850
+ "....",
851
+ "KKKK",
852
+ "...."
853
+ ];
854
+ const BROWS = {
855
+ soft: [".BBB"],
856
+ thick: ["BBBB"],
857
+ raised: [".BB."],
858
+ angry: ["BB..", "..BB"],
859
+ worried: ["..BB", "BB.."],
860
+ none: []
861
+ };
862
+ const MOUTHS = {
863
+ smile: ["K..K", ".KK."],
864
+ grin: ["KWWK", ".KK."],
865
+ open: ["KKK", "MTM"],
866
+ flat: [".KK."],
867
+ smirk: ["...K", ".KK."],
868
+ cat: ["K.K.K", ".K.K."],
869
+ tongue: ["KKKK", ".TT."],
870
+ o: [
871
+ ".K.",
872
+ "KMK",
873
+ ".K."
874
+ ]
875
+ };
876
+ function hair(recipe, s, b) {
877
+ const none = () => false;
878
+ const cap = ellipse(C + s, 14, 11, 10.6);
879
+ const tip = (x, depth = 1) => [
880
+ 0,
881
+ depth,
882
+ depth + 1,
883
+ depth
884
+ ][(x + 40 - s) % 4];
885
+ const bangs = (line, depth = 1, sides = 22) => (x, y) => {
886
+ if (!cap(x, Math.min(y, 13))) return false;
887
+ const dx = x + .5 - C - s;
888
+ if (Math.abs(dx) >= 7) return y <= sides - [
889
+ 0,
890
+ 1,
891
+ 0,
892
+ 2
893
+ ][(x + 40 - s) % 4];
894
+ return y <= line + tip(x, depth) - (Math.abs(dx) > 6 ? 0 : 0);
895
+ };
896
+ const longBack = (bottom, width = 11.4) => (x, y) => ellipse(C + b, 14, width + .4, 11)(x, y) && y <= 16 || y > 15 && y <= bottom && Math.abs(x + .5 - C - b) <= width - (y > bottom - 2 ? 1 : 0);
897
+ switch (recipe.hair) {
898
+ case "none": return {
899
+ back: none,
900
+ front: none
901
+ };
902
+ case "buzz": return {
903
+ back: none,
904
+ front: (x, y) => cap(x, y) && y <= 7
905
+ };
906
+ case "crop": return {
907
+ back: none,
908
+ front: bangs(10, 1, 15)
909
+ };
910
+ case "sweep": return {
911
+ back: none,
912
+ front: (x, y) => cap(x, Math.min(y, 13)) && (y <= 9 + Math.max(0, (x - 6 - s) * .45) || Math.abs(x + .5 - C - s) >= 7 && y <= 16)
913
+ };
914
+ case "spiky": return {
915
+ back: none,
916
+ front: (x, y) => cap(x, Math.max(y, 6)) && y >= [
917
+ 1,
918
+ 3,
919
+ 5,
920
+ 3
921
+ ][(x + 40 - s) % 4] && bangs(11, 2, 15)(x, Math.max(y, 6))
922
+ };
923
+ case "curly": {
924
+ const bumps = Array.from({ length: 9 }, (_, i) => {
925
+ const a = Math.PI * (1.02 + i * .96 / 8);
926
+ return [C + s + 11.5 * Math.cos(a), 14 + 11 * Math.sin(a)];
927
+ });
928
+ return {
929
+ back: none,
930
+ front: (x, y) => bangs(11, 1, 18)(x, y) || y <= 18 && bumps.some(([bx, by]) => (x + .5 - bx) ** 2 + (y + .5 - by) ** 2 <= 6.5)
931
+ };
932
+ }
933
+ case "mohawk": return {
934
+ back: none,
935
+ front: (x, y) => Math.abs(x + .5 - C - s) <= 2 && y >= 1 && y <= 9
936
+ };
937
+ case "bob": return {
938
+ back: longBack(25),
939
+ front: bangs(13, 1, 24)
940
+ };
941
+ case "long": return {
942
+ back: longBack(31),
943
+ front: bangs(13, 1, 26)
944
+ };
945
+ case "bun": return {
946
+ back: none,
947
+ front: (x, y) => bangs(12, 1, 18)(x, y) || ellipse(C + s, 2.6, 4, 2.6)(x, y)
948
+ };
949
+ case "pigtails": return {
950
+ back: (x, y) => ellipse(C + s - 11.5, 21, 3, 4.4)(x, y) || ellipse(C + s + 11.5, 21, 3, 4.4)(x, y),
951
+ front: bangs(13, 1, 21),
952
+ ties: [[C + s - 12, 16], [C + s + 11, 16]]
953
+ };
954
+ case "afro": return {
955
+ back: ellipse(C + b, 12, 15, 11.5),
956
+ front: (x, y) => cap(x, y) && y <= 8
957
+ };
958
+ case "twintails":
959
+ case "drills": return {
960
+ back: (x, y) => y >= 8 && (ellipse(C + s - 13.2, 20, 2.3, 12)(x, y) || ellipse(C + s + 13.2, 20, 2.3, 12)(x, y)),
961
+ front: bangs(13, 1, 24),
962
+ ties: [
963
+ [C + s - 13, 9],
964
+ [C + s - 12, 9],
965
+ [C + s + 11, 9],
966
+ [C + s + 12, 9]
967
+ ],
968
+ bands: recipe.hair === "drills"
969
+ };
970
+ case "ponytail": return {
971
+ back: (x, y) => ellipse(C + s + 11.5, 19, 3, 9)(x, y) && y >= 8,
972
+ front: bangs(12, 1, 20),
973
+ ties: [[C + s + 10, 9], [C + s + 11, 9]]
974
+ };
975
+ case "sidetail": return {
976
+ back: (x, y) => ellipse(C + s - 11.5, 21, 3, 8)(x, y) && y >= 12,
977
+ front: (x, y) => cap(x, Math.min(y, 13)) && (y <= 9 + Math.max(0, (C + s - x) * .45) || Math.abs(x + .5 - C - s) >= 7 && y <= 18),
978
+ ties: [[C + s - 13, 13], [C + s - 12, 13]]
979
+ };
980
+ case "hime": return {
981
+ back: longBack(31),
982
+ front: (x, y) => cap(x, Math.min(y, 13)) && (y <= 12 || Math.abs(x + .5 - C - s) >= 6.5 && y <= 24)
983
+ };
984
+ case "odango": return {
985
+ back: none,
986
+ front: (x, y) => bangs(12, 1, 18)(x, y) || ellipse(C + s - 8, 3.5, 3.3, 3)(x, y) || ellipse(C + s + 8, 3.5, 3.3, 3)(x, y)
987
+ };
988
+ case "messy": return {
989
+ back: none,
990
+ front: (x, y) => cap(x, Math.max(y, 4)) && y >= [
991
+ 2,
992
+ 0,
993
+ 3,
994
+ 1,
995
+ 2
996
+ ][(x + 50 - s) % 5] && bangs(11, 2, 17)(x, Math.max(y, 4))
997
+ };
998
+ case "wavy": return {
999
+ back: (x, y) => longBack(30)(x, y) || y >= 16 && y <= 30 && Math.abs(x + .5 - C - b) <= 12.4 + Math.sin(y * .9) * .9,
1000
+ front: bangs(12, 2, 25)
1001
+ };
1002
+ case "braids": return {
1003
+ back: none,
1004
+ front: (x, y) => bangs(13, 1, 20)(x, y) || [C + s - 9.5, C + s + 9.5].some((bx) => y >= 20 && y <= 30 && Math.abs(x + .5 - bx) <= 1.6 - ((y + 1) % 3 === 0 ? .6 : 0)),
1005
+ ties: [[C + s - 10, 30], [C + s + 9, 30]]
1006
+ };
1007
+ case "wolf": return {
1008
+ back: (x, y) => longBack(24, 11.8)(x, y) && (y < 20 || (x + y) % 3 !== 0),
1009
+ front: (x, y) => cap(x, Math.max(y, 5)) && y >= [
1010
+ 1,
1011
+ 2,
1012
+ 0,
1013
+ 2
1014
+ ][(x + 40 - s) % 4] && bangs(12, 2, 21)(x, Math.max(y, 5))
1015
+ };
1016
+ case "ahoge": return {
1017
+ back: longBack(23),
1018
+ front: (x, y) => bangs(13, 1, 22)(x, y) || [
1019
+ [0, 0],
1020
+ [1, 0],
1021
+ [1, 1],
1022
+ [2, 1],
1023
+ [2, 2],
1024
+ [1, 2]
1025
+ ].some(([ax, ay]) => x === Math.round(C + s) + ax && y === ay + 1)
1026
+ };
1027
+ }
1028
+ }
1029
+ function detailedHair(recipe, s, b) {
1030
+ const of = (style) => hair({
1031
+ ...recipe,
1032
+ hair: style ?? "none"
1033
+ }, s, b);
1034
+ const bangs = of(recipe.bangs);
1035
+ const side = of(recipe.sideHair);
1036
+ const right = recipe.rightSideHair === void 0 ? side : of(recipe.rightSideHair);
1037
+ const back = of(recipe.backHair);
1038
+ const length = (recipe.hairLength ?? 0) * 2;
1039
+ const lengthen = (mask) => (x, y) => mask(x, y > 18 ? Math.round(y - length) : y);
1040
+ const sideOf = (sign) => (x, y) => y > 7 && sign * (x + .5 - C - s) >= 7 && lengthen((sign < 0 ? side : right).front)(x, y);
1041
+ return {
1042
+ front: (x, y) => y <= 7 ? bangs.front(x, y) || back.front(x, y) : Math.abs(x + .5 - C - s) < 7 ? bangs.front(x, y) : lengthen((x + .5 - C - s < 0 ? side : right).front)(x, y),
1043
+ sides: [sideOf(-1), sideOf(1)],
1044
+ back: length === 0 ? back.back : lengthen(back.back),
1045
+ ...back.ties ? { ties: back.ties } : {},
1046
+ ...back.bands ? { bands: back.bands } : {}
1047
+ };
1048
+ }
1049
+ function outfit(recipe, g, s) {
1050
+ const shirt = recipe.shirtColor;
1051
+ const skin = recipe.skinColor;
1052
+ const dark = shade(shirt, .75);
1053
+ const light = mix$1(shirt, WHITE, .8);
1054
+ const half = (y) => 6.5 + (y - 24) * 1.15;
1055
+ const torso = (x, y) => y >= 24 && Math.abs(x + .5 - C - s * .5) <= half(y);
1056
+ const dx = (x) => x + .5 - C - s * .5;
1057
+ paint(g, (x, y) => y >= 22 && y <= 25 && Math.abs(dx(x)) <= 2.5, shade(skin, .85));
1058
+ paint(g, torso, shirt);
1059
+ const v = (depth, w) => (x, y) => y >= 24 && y <= 24 + depth && Math.abs(dx(x)) <= w - (y - 24) * (w / (depth + 1));
1060
+ switch (recipe.outfit) {
1061
+ case "tee":
1062
+ paint(g, v(1, 2.5), shade(skin, .85));
1063
+ break;
1064
+ case "shirttie":
1065
+ paint(g, v(3, 4), light);
1066
+ paint(g, (x, y) => y >= 24 && Math.abs(dx(x)) <= .5 && y <= 28, "#d22a5a");
1067
+ break;
1068
+ case "hoodie":
1069
+ paint(g, (x, y) => torso(x, y) && y === 24 && Math.abs(dx(x)) >= 3, dark);
1070
+ paint(g, (x, y) => y >= 25 && y <= 27 && (Math.abs(dx(x) + 1.5) <= .5 || Math.abs(dx(x) - 1.5) <= .5), WHITE);
1071
+ break;
1072
+ case "turtleneck":
1073
+ paint(g, (x, y) => y >= 22 && y <= 25 && Math.abs(dx(x)) <= 3, shade(shirt, .88));
1074
+ break;
1075
+ case "sailor":
1076
+ paint(g, (x, y) => torso(x, y) && y <= 26 && Math.abs(dx(x)) >= 2, "#2f3a5a");
1077
+ paint(g, (x, y) => torso(x, y) && y === 26 && Math.abs(dx(x)) >= 3, WHITE);
1078
+ sprite(g, ["R.R", ".R."], Math.round(C + s * .5) - 2, 26, { R: "#e2565f" });
1079
+ break;
1080
+ case "blazer":
1081
+ paint(g, v(4, 4), light);
1082
+ paint(g, (x, y) => torso(x, y) && y >= 24 && Math.abs(Math.abs(dx(x)) - (4 - (y - 24) * .8)) <= .5, dark);
1083
+ sprite(g, ["G"], Math.round(C + s * .5), 28, { G: GOLD });
1084
+ break;
1085
+ case "overalls":
1086
+ paint(g, torso, light);
1087
+ paint(g, (x, y) => torso(x, y) && (y >= 27 || Math.abs(Math.abs(dx(x)) - 4) <= .6), shirt);
1088
+ sprite(g, ["G....G"], Math.round(C + s * .5) - 3, 27, { G: GOLD });
1089
+ break;
1090
+ case "dress":
1091
+ paint(g, (x, y) => torso(x, y) && y <= 25 && Math.abs(dx(x)) <= 4.5, WHITE);
1092
+ paint(g, (x, y) => torso(x, y) && y === 25 && Math.abs(dx(x)) <= 4.5 && x % 2 === 0, shade(WHITE, .86));
1093
+ sprite(g, [
1094
+ "RR.RR",
1095
+ ".RRR.",
1096
+ "R...R"
1097
+ ], Math.round(C + s * .5) - 2, 25, { R: "#e5566a" });
1098
+ paint(g, (x, y) => torso(x, y) && y === 29, mix$1(shirt, WHITE, .5));
1099
+ break;
1100
+ case "kimono":
1101
+ paint(g, (x, y) => torso(x, y) && y <= 28 && Math.abs(Math.abs(dx(x)) - (y - 23.5)) <= .6, WHITE);
1102
+ paint(g, (x, y) => torso(x, y) && y >= 28, "#e2565f");
1103
+ paint(g, (x, y) => torso(x, y) && y === 28 && x % 3 === 0, GOLD);
1104
+ paint(g, (x, y) => torso(x, y) && y <= 27 && (x * 3 + y * 2) % 7 === 0, mix$1(shirt, WHITE, .45));
1105
+ break;
1106
+ case "cardigan":
1107
+ paint(g, v(5, 3.5), light);
1108
+ paint(g, (x, y) => torso(x, y) && Math.abs(Math.abs(dx(x)) - 2) <= .5 && y >= 25, dark);
1109
+ sprite(g, [
1110
+ "W",
1111
+ ".",
1112
+ "W",
1113
+ ".",
1114
+ "W"
1115
+ ], Math.round(C + s * .5) - 3, 25, { W: WHITE });
1116
+ paint(g, (x, y) => torso(x, y) && y === 29, dark);
1117
+ break;
1118
+ case "maid":
1119
+ paint(g, (x, y) => torso(x, y), "#2f2a3a");
1120
+ paint(g, (x, y) => torso(x, y) && y >= 25 && Math.abs(dx(x)) <= 4 - (y - 25) * .2, WHITE);
1121
+ paint(g, (x, y) => y === 24 && Math.abs(dx(x)) <= 4.5, WHITE);
1122
+ sprite(g, ["RR.RR", ".RRR."], Math.round(C + s * .5) - 2, 24, { R: shirt });
1123
+ break;
1124
+ case "jacket":
1125
+ paint(g, v(6, 4.5), WHITE);
1126
+ paint(g, (x, y) => torso(x, y) && Math.abs(Math.abs(dx(x)) - (4.5 - (y - 24) * .6)) <= .6, dark);
1127
+ paint(g, (x, y) => torso(x, y) && y >= 27 && Math.abs(dx(x)) <= .5, "#9aa3ad");
1128
+ paint(g, (x, y) => torso(x, y) && y === 28 && Math.abs(dx(x)) >= half(y) - 2.5, mix$1(shirt, WHITE, .6));
1129
+ break;
1130
+ }
1131
+ const neck = shade(skin, .85);
1132
+ for (let y = 24; y < N; y++) for (let x = 0; x < N; x++) {
1133
+ const cell = g[y][x];
1134
+ if (!cell || cell === neck || !torso(x, y)) continue;
1135
+ const d = dx(x);
1136
+ const edge = half(y) - Math.abs(d);
1137
+ if (y === 24 && Math.abs(d) <= 4.5) g[y][x] = shade(cell, .78);
1138
+ else if (y >= 26 && edge >= 1.6 && edge < 2.6) g[y][x] = shade(cell, .82);
1139
+ else if (d > 0 && edge < 1.6) g[y][x] = shade(cell, .84);
1140
+ else if (d < 0 && edge < 1.6 && y <= 26) g[y][x] = mix$1(cell, WHITE, .28);
1141
+ else if (d < -2 && y <= 25) g[y][x] = mix$1(cell, WHITE, .14);
1142
+ }
1143
+ }
1144
+ function accessory(recipe, g, s, face) {
1145
+ const shirt = recipe.shirtColor;
1146
+ const cap = ellipse(C + s, 14, 11.6, 11.2);
1147
+ switch (recipe.accessory) {
1148
+ case "none": return;
1149
+ case "beanie":
1150
+ paint(g, (x, y) => cap(x, y) && y <= 8, shirt);
1151
+ paint(g, (x, y) => cap(x, y) && (y === 8 || y === 9), shade(shirt, .78));
1152
+ paint(g, ellipse(C + s, 1.6, 2.2, 1.8), WHITE);
1153
+ return;
1154
+ case "cap":
1155
+ paint(g, (x, y) => cap(x, y) && y <= 8, shirt);
1156
+ paint(g, (x, y) => y === 9 && x + .5 >= C + s - 10 && x + .5 <= C + s + 13, shade(shirt, .72));
1157
+ return;
1158
+ case "headphones": {
1159
+ const band = (x, y) => ellipse(C + s, 14, 12, 11.6)(x, y) && !ellipse(C + s, 14, 10.8, 10.4)(x, y) && y <= 13;
1160
+ paint(g, band, "#5d5b66");
1161
+ paint(g, (x, y) => y >= 12 && y <= 19 && (Math.abs(x + .5 - (C + s - 11)) <= 1.6 || Math.abs(x + .5 - (C + s + 11)) <= 1.6), "#e25d6a");
1162
+ return;
1163
+ }
1164
+ case "flower":
1165
+ sprite(g, [
1166
+ ".P.P.",
1167
+ "PPYPP",
1168
+ ".PPP."
1169
+ ], Math.round(C + s) + 6, 3, {
1170
+ P: "#f59fba",
1171
+ Y: GOLD
1172
+ });
1173
+ return;
1174
+ case "bow":
1175
+ sprite(g, [
1176
+ "RR.RR",
1177
+ "RRrRR",
1178
+ "RR.RR"
1179
+ ], Math.round(C + s) + 6, 3, {
1180
+ R: "#e5566a",
1181
+ r: "#b83a50"
1182
+ });
1183
+ return;
1184
+ case "earring": {
1185
+ const xs = [...Array(N).keys()].filter((x) => face(x, 19));
1186
+ if (xs.length) {
1187
+ sprite(g, ["G", "G"], xs[0] - 1, 20, { G: GOLD });
1188
+ sprite(g, ["G", "G"], xs.at(-1) + 1, 20, { G: GOLD });
1189
+ }
1190
+ return;
1191
+ }
1192
+ case "crown":
1193
+ sprite(g, [
1194
+ "G.G.G.G",
1195
+ "GGGGGGG",
1196
+ "GrGbGrG"
1197
+ ], Math.round(C + s) - 3, 0, {
1198
+ G: GOLD,
1199
+ r: "#e5566a",
1200
+ b: "#5a9be0"
1201
+ });
1202
+ return;
1203
+ case "halo":
1204
+ paint(g, (x, y) => ellipse(C + s, 1.6, 6.5, 1.8)(x, y) && !ellipse(C + s, 1.6, 4.4, .8)(x, y), GOLD);
1205
+ return;
1206
+ case "catears": {
1207
+ const ear = [
1208
+ "X...",
1209
+ "XX..",
1210
+ "XPX.",
1211
+ "XXXX"
1212
+ ];
1213
+ sprite(g, ear, Math.round(C + s) - 9, 2, {
1214
+ X: recipe.hairColor,
1215
+ P: "#f4a3b5"
1216
+ });
1217
+ sprite(g, ear, Math.round(C + s) + 5, 2, {
1218
+ X: recipe.hairColor,
1219
+ P: "#f4a3b5"
1220
+ }, true);
1221
+ return;
1222
+ }
1223
+ case "hairclip":
1224
+ sprite(g, ["CC.CC", ".CCC."], Math.round(C + s) + 3, 9, { C: "#e5566a" });
1225
+ return;
1226
+ case "horns": {
1227
+ const horn = [
1228
+ ".H",
1229
+ "HH",
1230
+ "Hh"
1231
+ ];
1232
+ sprite(g, horn, Math.round(C + s) - 8, 1, {
1233
+ H: "#5b3a6e",
1234
+ h: "#7d5694"
1235
+ });
1236
+ sprite(g, horn, Math.round(C + s) + 6, 1, {
1237
+ H: "#5b3a6e",
1238
+ h: "#7d5694"
1239
+ }, true);
1240
+ return;
1241
+ }
1242
+ case "beret":
1243
+ paint(g, (x, y) => ellipse(C + s + 1.5, 4.6, 10.5, 3.6)(x, y), shirt);
1244
+ paint(g, (x, y) => ellipse(C + s + 1.5, 4.6, 10.5, 3.6)(x, y) && y >= 6, shade(shirt, .78));
1245
+ paint(g, (x, y) => ellipse(C + s + 1.5, 4.6, 10.5, 3.6)(x, y) && y <= 2 && x + .5 < C + s, mix$1(shirt, WHITE, .3));
1246
+ sprite(g, ["S"], Math.round(C + s) + 1, 0, { S: shade(shirt, .6) });
1247
+ return;
1248
+ case "ribbon":
1249
+ sprite(g, [
1250
+ "RRR.RRR",
1251
+ "RrRRRrR",
1252
+ "RRR.RRR",
1253
+ ".R...R."
1254
+ ], Math.round(C + s) - 3, 0, {
1255
+ R: "#e5566a",
1256
+ r: "#f49aa8"
1257
+ });
1258
+ return;
1259
+ case "headband":
1260
+ paint(g, (x, y) => ellipse(C + s, 14, 11.2, 10.8)(x, y) && !ellipse(C + s, 14.2, 10.2, 9.8)(x, y) && y <= 8, shirt);
1261
+ paint(g, (x, y) => ellipse(C + s, 14, 11.2, 10.8)(x, y) && !ellipse(C + s, 14.2, 10.2, 9.8)(x, y) && y <= 5, mix$1(shirt, WHITE, .35));
1262
+ return;
1263
+ case "bunnyears": {
1264
+ const ear = [
1265
+ "WW",
1266
+ "WP",
1267
+ "WP",
1268
+ "WP",
1269
+ "WW"
1270
+ ];
1271
+ sprite(g, ear, Math.round(C + s) - 6, 0, {
1272
+ W: "#f4f1ec",
1273
+ P: "#f4a3b5"
1274
+ });
1275
+ sprite(g, ear, Math.round(C + s) + 4, 0, {
1276
+ W: "#f4f1ec",
1277
+ P: "#f4a3b5"
1278
+ }, true);
1279
+ return;
1280
+ }
1281
+ case "horseears": {
1282
+ const ear = [
1283
+ ".X.",
1284
+ "XPX",
1285
+ "XPX",
1286
+ "XXX"
1287
+ ];
1288
+ sprite(g, ear, Math.round(C + s) - 9, 1, {
1289
+ X: shade(recipe.hairColor, .9),
1290
+ P: "#f4a3b5"
1291
+ });
1292
+ sprite(g, ear, Math.round(C + s) + 6, 1, {
1293
+ X: shade(recipe.hairColor, .9),
1294
+ P: "#f4a3b5"
1295
+ });
1296
+ sprite(g, ["BB", "BB"], Math.round(C + s) + 3, 7, { B: "#5a9be0" });
1297
+ return;
1298
+ }
1299
+ case "flowercrown":
1300
+ for (const [fx, color] of [
1301
+ [-8, "#f59fba"],
1302
+ [-4, GOLD],
1303
+ [0, "#f4f1ec"],
1304
+ [4, "#f59fba"],
1305
+ [8, GOLD]
1306
+ ]) sprite(g, [
1307
+ ".F.",
1308
+ "FCF",
1309
+ ".F."
1310
+ ], Math.round(C + s) + fx - 1, 4 + Math.round(Math.abs(fx) / 4), {
1311
+ F: color,
1312
+ C: color === GOLD ? "#e2565f" : GOLD
1313
+ });
1314
+ paint(g, (x, y) => y === 6 + Math.round(Math.abs(x + .5 - C - s) / 4) && Math.abs(x + .5 - C - s) <= 9 && x % 2 === 1, "#5aa36b");
1315
+ return;
1316
+ case "witch":
1317
+ paint(g, (x, y) => y >= 6 && y <= 7 && Math.abs(x + .5 - C - s) <= 13, "#3a2f52");
1318
+ paint(g, (x, y) => y <= 6 && Math.abs(x + .5 - C - s - (6 - y) * .5) <= 1 + y * .9, "#4b3d6b");
1319
+ paint(g, (x, y) => y === 5 && Math.abs(x + .5 - C - s - .5) <= 5.2, "#efb93f");
1320
+ return;
1321
+ case "pins":
1322
+ sprite(g, [
1323
+ "Y.Y",
1324
+ ".Y.",
1325
+ "Y.Y",
1326
+ "...",
1327
+ "B.B",
1328
+ ".B.",
1329
+ "B.B"
1330
+ ], Math.round(C + s) + 6, 7, {
1331
+ Y: GOLD,
1332
+ B: "#5a9be0"
1333
+ });
1334
+ return;
1177
1335
  }
1178
- };
1179
- const AVATAR_RANGES = {
1180
- spacing: [-1, 1],
1181
- height: [-1, 1],
1182
- hairLength: [-2, 2]
1183
- };
1184
- const DETAIL_KEYS = [...Object.keys(AVATAR_HAIR_PARTS), ...Object.keys(AVATAR_RANGES)];
1185
- const AVATAR_COLORS = [
1186
- "skinColor",
1187
- "hairColor",
1188
- "eyeColor",
1189
- "shirtColor"
1190
- ];
1191
- const DEFAULT_RECIPE = {
1192
- schemaVersion: 1,
1193
- family: "illustrated",
1194
- assetVersion: 1,
1195
- rigVersion: 1,
1196
- head: "round",
1197
- pose: "front",
1198
- hair: "crop",
1199
- eyes: "round",
1200
- brows: "soft",
1201
- nose: "button",
1202
- mouth: "smile",
1203
- cheeks: "blush",
1204
- glasses: "none",
1205
- outfit: "tee",
1206
- backdrop: "sparkles",
1207
- accessory: "none",
1208
- skinColor: "#f2c9a8",
1209
- hairColor: "#5a3a2a",
1210
- eyeColor: "#3f7fbf",
1211
- shirtColor: "#5b8bd6"
1212
- };
1213
- const PART_KEYS = Object.keys(AVATAR_PARTS);
1214
- function isPixelAvatarRecipe(value) {
1215
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1216
- const r = value;
1217
- const detailed = DETAIL_KEYS.filter((key) => Object.hasOwn(r, key)).length;
1218
- if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false;
1219
- if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed) return false;
1220
- return (detailed === 0 || Object.keys(AVATAR_HAIR_PARTS).every((key) => typeof r[key] === "string" && AVATAR_HAIR_PARTS[key].includes(r[key])) && Object.keys(AVATAR_RANGES).every((key) => {
1221
- const v = r[key];
1222
- const [min, max] = AVATAR_RANGES[key];
1223
- return typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
1224
- })) && r["schemaVersion"] === 1 && r["family"] === "illustrated" && r["assetVersion"] === 1 && r["rigVersion"] === 1 && PART_KEYS.every((key) => typeof r[key] === "string" && AVATAR_PARTS[key].includes(r[key])) && AVATAR_COLORS.every((key) => typeof r[key] === "string" && /^#[\da-f]{6}$/iu.test(r[key]));
1225
1336
  }
1226
- function canonicalRecipe(recipe) {
1227
- const canonical = {
1228
- schemaVersion: 1,
1229
- family: "illustrated",
1230
- assetVersion: 1,
1231
- rigVersion: 1
1337
+ function pixelTileColor(hair) {
1338
+ const [r, g, b] = channels$1(hair).map((v) => v / 255);
1339
+ const max = Math.max(r, g, b);
1340
+ const min = Math.min(r, g, b);
1341
+ const lightness = (max + min) / 2;
1342
+ const chroma = max - min;
1343
+ const saturation = chroma === 0 ? 0 : chroma / (1 - Math.abs(2 * lightness - 1));
1344
+ if (saturation < .18 || lightness > .86 || lightness < .16) return "#ece8e1";
1345
+ const hue = max === r ? ((g - b) / chroma + 6) % 6 : max === g ? (b - r) / chroma + 2 : (r - g) / chroma + 4;
1346
+ const s = Math.min(.55, Math.max(.35, saturation * .6));
1347
+ const l = .88;
1348
+ const f = (n) => {
1349
+ const k = (n + hue * 2) % 12;
1350
+ return l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1));
1232
1351
  };
1233
- for (const key of PART_KEYS) canonical[key] = recipe[key];
1234
- for (const key of AVATAR_COLORS) canonical[key] = recipe[key].toLowerCase();
1235
- if (recipe.bangs !== void 0) for (const key of DETAIL_KEYS) canonical[key] = recipe[key];
1236
- return canonical;
1352
+ return hex([
1353
+ f(0),
1354
+ f(8),
1355
+ f(4)
1356
+ ].map((v) => v * 255));
1237
1357
  }
1238
- function detailedRecipe(recipe) {
1239
- if (recipe.bangs !== void 0) return recipe;
1240
- const pick = (part) => AVATAR_HAIR_PARTS[part].includes(recipe.hair) ? recipe.hair : HAIR_SPLIT[recipe.hair][part];
1241
- return {
1242
- ...recipe,
1243
- bangs: pick("bangs"),
1244
- sideHair: pick("sideHair"),
1245
- backHair: pick("backHair"),
1246
- spacing: 0,
1247
- height: 0,
1248
- hairLength: 0
1358
+ function pixelFigure(recipe, yawDeg, options = {}) {
1359
+ const yaw = yawDeg * Math.PI / 180;
1360
+ const sinY = Math.sin(yaw);
1361
+ const s = Math.round(sinY * 3);
1362
+ const fs = Math.round(sinY * 10);
1363
+ const b = -Math.round(sinY * 3.5);
1364
+ const turned = Math.abs(sinY) >= .3;
1365
+ const d = Math.sign(sinY);
1366
+ const skin = recipe.skinColor;
1367
+ const hairColor = recipe.hairColor;
1368
+ const front = FACE[recipe.head](s * .5);
1369
+ const face = turned ? (x, y) => {
1370
+ const dx = x + .5 - C;
1371
+ const tuck = y >= 17 && d * dx > 4 ? Math.min(2, (y - 16) * .35) : 0;
1372
+ return front(x - Math.round(d * (1 + Math.max(0, y - 20) * .4) - d * tuck), y);
1373
+ } : front;
1374
+ const masks = recipe.bangs === void 0 ? hair(recipe, s, b) : detailedHair(recipe, s, b);
1375
+ const body = blank();
1376
+ outfit(recipe, body, s);
1377
+ const back = blank();
1378
+ paint(back, masks.back, shade(hairColor, .82));
1379
+ if (masks.bands) paint(back, (x, y) => masks.back(x, y) && (y + Math.floor(x / 2)) % 4 === 0, shade(hairColor, .68));
1380
+ const head = blank();
1381
+ paint(head, face, skin);
1382
+ const earY = 15;
1383
+ const edge = (side) => {
1384
+ const xs = [...Array(N).keys()].filter((x) => face(x, earY));
1385
+ return side < 0 ? xs[0] - 1 : xs.at(-1) + 1;
1249
1386
  };
1250
- }
1251
- const AVATAR_SWATCHES = {
1252
- skinColor: [
1253
- "#ffe3cf",
1254
- "#f2c9a8",
1255
- "#e0a87e",
1256
- "#c68863",
1257
- "#9a6142",
1258
- "#6e4129",
1259
- "#4a2c1c",
1260
- "#f4f1ec"
1261
- ],
1262
- hairColor: [
1263
- "#1d1b22",
1264
- "#5a3a2a",
1265
- "#8a5a36",
1266
- "#e2b04a",
1267
- "#c4452f",
1268
- "#d9475a",
1269
- "#f06292",
1270
- "#3fc1b8",
1271
- "#5a7be0",
1272
- "#9aa3ad",
1273
- "#f4f1ec"
1274
- ],
1275
- eyeColor: [
1276
- "#3f7fbf",
1277
- "#5a3a2a",
1278
- "#2f9e8f",
1279
- "#4c8a3c",
1280
- "#8a5ad0",
1281
- "#d0533f",
1282
- "#d9a13a",
1283
- "#2a2230"
1284
- ],
1285
- shirtColor: [
1286
- "#5b8bd6",
1287
- "#e07a5f",
1288
- "#3d9970",
1289
- "#7a5cc7",
1290
- "#f2c14e",
1291
- "#2f3a4a",
1292
- "#e2565f",
1293
- "#9ad0c2"
1294
- ]
1295
- };
1296
- const SEEDED_CHOICES = {
1297
- head: [
1298
- "round",
1299
- "oval",
1300
- "square",
1301
- "long",
1302
- "heart",
1303
- "vchin",
1304
- "chubby",
1305
- "diamond"
1306
- ],
1307
- pose: ["front"],
1308
- hair: AVATAR_PARTS.hair.filter((hair) => hair !== "none"),
1309
- eyes: [
1310
- "round",
1311
- "round",
1312
- "sparkle",
1313
- "lashes",
1314
- "dot",
1315
- "happy",
1316
- "sharp"
1317
- ],
1318
- brows: [
1319
- "soft",
1320
- "soft",
1321
- "thick",
1322
- "raised"
1323
- ],
1324
- nose: [
1325
- "button",
1326
- "dot",
1327
- "none"
1328
- ],
1329
- mouth: [
1330
- "smile",
1331
- "smile",
1332
- "grin",
1333
- "open",
1334
- "cat"
1335
- ],
1336
- cheeks: [
1337
- "blush",
1338
- "blush",
1339
- "freckles",
1340
- "none"
1341
- ],
1342
- glasses: [
1343
- "none",
1344
- "none",
1345
- "none",
1346
- "none",
1347
- "round",
1348
- "square"
1349
- ],
1350
- accessory: [
1351
- "none",
1352
- "none",
1353
- "none",
1354
- "flower",
1355
- "bow",
1356
- "hairclip",
1357
- "catears",
1358
- "headphones",
1359
- "beanie",
1360
- "cap"
1361
- ],
1362
- outfit: AVATAR_PARTS.outfit,
1363
- backdrop: [
1364
- "sparkles",
1365
- "hearts",
1366
- "stars",
1367
- "dots"
1368
- ]
1369
- };
1370
- /**
1371
- * Returns a name-seeded recipe factory. Different namespaces give different faces for the same
1372
- * name; the factory takes the name only, so it is safe to pass straight to `Array.map`.
1373
- */
1374
- function createSeededRecipe(namespace) {
1375
- return (seed) => {
1376
- const random = seededRandom(`${namespace}:${seed.trim().toLowerCase()}`);
1377
- const pick = (values) => values[Math.floor(random() * values.length)];
1378
- const recipe = {
1379
- schemaVersion: 1,
1380
- family: "illustrated",
1381
- assetVersion: 1,
1382
- rigVersion: 1
1387
+ const goblin = recipe.species === "goblin";
1388
+ if (goblin) {
1389
+ const ear = [
1390
+ "E...",
1391
+ "EE..",
1392
+ ".EiE",
1393
+ "..Ee"
1394
+ ];
1395
+ const pal = {
1396
+ E: skin,
1397
+ e: shade(skin, .86),
1398
+ i: shade(skin, .74)
1399
+ };
1400
+ paint(head, masks.front, hairColor);
1401
+ for (const ex of turned ? [-d] : [-1, 1]) sprite(head, ear, ex < 0 ? edge(ex) - 3 : edge(ex), earY - 2, pal, ex > 0);
1402
+ } else if (!turned) for (const ex of [-1, 1]) sprite(head, [
1403
+ "E",
1404
+ "E",
1405
+ "e"
1406
+ ], edge(ex), earY, {
1407
+ E: skin,
1408
+ e: shade(skin, .86)
1409
+ });
1410
+ else sprite(head, [
1411
+ "EE",
1412
+ "Ee",
1413
+ "Ee",
1414
+ "E."
1415
+ ], d > 0 ? edge(-d) - 1 : edge(-d), earY, {
1416
+ E: skin,
1417
+ e: shade(skin, .8)
1418
+ });
1419
+ if (!goblin) paint(head, masks.front, hairColor);
1420
+ for (const [x, y] of masks.ties ?? []) sprite(back, ["T"], Math.round(x), y, { T: shade(recipe.shirtColor, .8) });
1421
+ accessory(recipe, head, s, face);
1422
+ const shadeHair = (g) => {
1423
+ const out = g.map((r) => [...r]);
1424
+ const cells = g.flatMap((row, y) => row.flatMap((c, x) => c === hairColor ? [[x, y]] : []));
1425
+ const xs = cells.map(([x]) => x);
1426
+ const ys = cells.map(([, y]) => y);
1427
+ const left = Math.min(...xs);
1428
+ const right = Math.max(...xs);
1429
+ const top = Math.min(...ys);
1430
+ const mid = (left + right) / 2;
1431
+ const span = Math.max(1, (right - left) / 2);
1432
+ const light = mix$1(hairColor, WHITE, .2);
1433
+ const dark = shade(hairColor, .8);
1434
+ const middle = shade(hairColor, .9);
1435
+ for (const [x, y] of cells) {
1436
+ const side = (x + .5 - mid) / span + (y - top) * .025;
1437
+ if (side > .72) out[y][x] = dark;
1438
+ else if (side > .42) out[y][x] = middle;
1439
+ else if (side < -.2 && y <= top + 4) out[y][x] = light;
1440
+ }
1441
+ for (const [x, y] of cells) if ((g[y][x - 1] === skin || g[y][x + 1] === skin) && y >= 14) out[y][x] = shade(hairColor, .74);
1442
+ else if (Math.abs(x + .5 - C - s) >= 7.5 && y >= 14 && (x + 40 - s) % 3 === 0) out[y][x] = shade(out[y][x], .86);
1443
+ for (let y = 1; y < N; y++) for (let x = 0; x < N; x++) {
1444
+ if (g[y][x] !== skin) continue;
1445
+ if (g[y - 1][x] === hairColor) out[y][x] = mix$1(shade(skin, .84), BLUSH, .12);
1446
+ else if (x + 1 < N && g[y][x + 1] !== skin && g[y][x + 1] !== void 0 && x > C + s) out[y][x] = shade(skin, .9);
1447
+ else if (x + 1 < N && g[y][x + 1] === void 0 && x > C + s) out[y][x] = shade(skin, .9);
1448
+ }
1449
+ for (let y = 0; y < N - 1; y++) for (let x = 0; x < N; x++) if (g[y][x] === hairColor && g[y + 1][x] === skin) out[y][x] = shade(hairColor, .62);
1450
+ for (let x = 0; x < N; x++) for (let y = 3; y < N; y++) if (g[y][x] === hairColor && g[y + 1]?.[x] === skin && (x + 40 - s) % 4 === 0) {
1451
+ for (let k = 1; k <= 3; k++) if (g[y - k]?.[x] === hairColor) out[y - k][x] = shade(hairColor, .72);
1452
+ }
1453
+ const ring = mix$1(hairColor, WHITE, .45);
1454
+ for (let x = Math.round(C + s) - 7; x <= Math.round(C + s) + 3; x++) {
1455
+ const first = g.findIndex((row) => row[x] === hairColor);
1456
+ if (first < 0) continue;
1457
+ const y = first + 2 + Math.round(Math.abs(x + .5 - (C + s - 2)) / 4);
1458
+ if (g[y]?.[x] === hairColor && x % 3 !== 0) out[y][x] = ring;
1459
+ }
1460
+ return out;
1461
+ };
1462
+ const figure = blank();
1463
+ for (const layer of [
1464
+ back,
1465
+ body,
1466
+ shadeHair(head)
1467
+ ]) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (layer[y][x]) figure[y][x] = layer[y][x];
1468
+ const features = blank();
1469
+ const eyes = blank();
1470
+ const closed = blank();
1471
+ const glasses = blank();
1472
+ const sp = recipe.spacing ?? 0;
1473
+ const h = recipe.height ?? 0;
1474
+ const eyeTop = 15 + h;
1475
+ const eyeW = (side) => turned && Math.sign(fs) === side ? 3 : 4;
1476
+ const eyeX = (side) => {
1477
+ const w = eyeW(side);
1478
+ return side < 0 ? Math.round(C + fs * .6) - 6 - sp - (w === 3 ? -1 : 0) : Math.round(C + fs * .6) + 2 + sp;
1479
+ };
1480
+ const left = {
1481
+ x: eyeX(-1),
1482
+ w: eyeW(-1)
1483
+ };
1484
+ const right = {
1485
+ x: eyeX(1),
1486
+ w: eyeW(1)
1487
+ };
1488
+ const fit = (rows, w) => rows.map((r) => r.slice(Math.max(0, r.length - w)));
1489
+ const pal = {
1490
+ K: INK$1,
1491
+ W: WHITE,
1492
+ D: shade(recipe.eyeColor, .55),
1493
+ I: recipe.eyeColor,
1494
+ L: mix$1(recipe.eyeColor, WHITE, .45)
1495
+ };
1496
+ const style = EYES[recipe.eyes];
1497
+ const extra = style[0].length - 4;
1498
+ sprite(eyes, fit(style, left.w + extra), left.x - extra, eyeTop, pal);
1499
+ sprite(eyes, recipe.eyes === "wink" ? fit(CLOSED, right.w) : fit(style, right.w + extra), right.x, eyeTop, pal, true);
1500
+ sprite(closed, fit(CLOSED, left.w), left.x, eyeTop, pal);
1501
+ sprite(closed, fit(CLOSED, right.w), right.x, eyeTop, pal, true);
1502
+ const browRows = BROWS[recipe.brows];
1503
+ if (browRows.length) {
1504
+ sprite(features, fit(browRows, left.w), left.x, eyeTop - 2, { B: shade(hairColor, .55) });
1505
+ sprite(features, fit(browRows, right.w), right.x, eyeTop - 2, { B: shade(hairColor, .55) }, true);
1506
+ }
1507
+ const cx = Math.round(C + fs * (turned ? .85 : .6));
1508
+ if (recipe.nose === "dot") sprite(features, ["N"], cx, 18 + h, { N: shade(skin, .78) });
1509
+ if (recipe.nose === "button") sprite(features, ["N"], cx, 18 + h, { N: shade(skin, .88) });
1510
+ if (recipe.nose === "line") sprite(features, ["N", "N"], cx, 17 + h, { N: shade(skin, .82) });
1511
+ const mx = C - .5 + fs * .6;
1512
+ const withMouth = (mouth) => {
1513
+ const layer = features.map((row) => [...row]);
1514
+ sprite(layer, mouth, Math.round(mx - (mouth[0].length - 1) / 2), 20 + h, {
1515
+ K: MOUTH,
1516
+ M: MOUTH_INSIDE,
1517
+ T: TONGUE,
1518
+ W: WHITE
1519
+ });
1520
+ if (recipe.species === "goblin") for (const tx of [Math.round(mx - 2.5), Math.round(mx + 2.5)]) sprite(layer, ["T"], tx, 21 + h, { T: TUSK });
1521
+ if (recipe.cheeks === "blush") {
1522
+ sprite(layer, ["PP"], left.x - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
1523
+ sprite(layer, ["PP"], right.x + right.w - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
1524
+ }
1525
+ if (recipe.cheeks === "freckles") {
1526
+ sprite(layer, ["F.F"], left.x, 19 + h, { F: shade(skin, .7) });
1527
+ sprite(layer, ["F.F"], right.x + 1, 19 + h, { F: shade(skin, .7) });
1528
+ }
1529
+ return layer;
1530
+ };
1531
+ const savedFeatures = withMouth(MOUTHS[recipe.mouth]);
1532
+ const featureMarkup = options.mouthLayers ? [
1533
+ ["saved", MOUTHS[recipe.mouth]],
1534
+ ["closed", ["KKK"]],
1535
+ ["half-open", ["KKK", "MMM"]],
1536
+ ["open", [
1537
+ ".K.",
1538
+ "KMK",
1539
+ ".K."
1540
+ ]]
1541
+ ].map(([state, rows]) => `<g data-avatar-mouth="${state}" opacity="${state === "saved" ? 1 : 0}">${rects(withMouth(rows))}</g>`).join("") : rects(savedFeatures);
1542
+ const frame = (fx, w, round) => paint(glasses, (x, y) => {
1543
+ const inside = x >= fx && x < fx + w && y >= eyeTop && y <= eyeTop + 3;
1544
+ const box = x >= fx - 1 && x <= fx + w && y >= eyeTop - 1 && y <= eyeTop + 4;
1545
+ const corner = (x === fx - 1 || x === fx + w) && (y === eyeTop - 1 || y === eyeTop + 4);
1546
+ return box && !inside && !(round && corner);
1547
+ }, INK$1);
1548
+ if (recipe.glasses === "round" || recipe.glasses === "square") {
1549
+ frame(left.x, left.w, recipe.glasses === "round");
1550
+ frame(right.x, right.w, recipe.glasses === "round");
1551
+ paint(glasses, (x, y) => y === eyeTop + 1 && x > left.x + left.w && x < right.x - 1, INK$1);
1552
+ }
1553
+ if (recipe.glasses === "shades") {
1554
+ for (const e of [left, right]) paint(glasses, (x, y) => x >= e.x - 1 && x <= e.x + e.w && y >= eyeTop && y <= eyeTop + 2, INK$1);
1555
+ paint(glasses, (x, y) => y === eyeTop && x > left.x + left.w && x < right.x - 1, INK$1);
1556
+ for (const e of [left, right]) sprite(glasses, ["W"], e.x, eyeTop, { W: "#7d7b88" });
1557
+ }
1558
+ if (recipe.glasses === "monocle") {
1559
+ frame(right.x, right.w, true);
1560
+ sprite(glasses, [
1561
+ "G",
1562
+ "G",
1563
+ "G"
1564
+ ], right.x + right.w, eyeTop + 5, { G: GOLD });
1565
+ }
1566
+ const full = clip(outline(figure));
1567
+ AVATAR_PIECE_COLORS.forEach((key, i) => {
1568
+ const color = recipe[key];
1569
+ const mask = masks.sides?.[i];
1570
+ if (color === void 0 || !mask || color.toLowerCase() === hairColor.toLowerCase()) return;
1571
+ const variant = blank();
1572
+ const plain = {
1573
+ ...recipe,
1574
+ hairColor: color
1383
1575
  };
1384
- for (const part of Object.keys(AVATAR_PARTS)) recipe[part] = pick(SEEDED_CHOICES[part]);
1385
- for (const color of AVATAR_COLORS) recipe[color] = pick(AVATAR_SWATCHES[color].slice(0, 7));
1386
- return recipe;
1576
+ for (const k of AVATAR_PIECE_COLORS) delete plain[k];
1577
+ for (const cell of pixelFigure(plain, yawDeg).cells) variant[cell.y][cell.x] = cell.c;
1578
+ const near = (x, y) => mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1);
1579
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (full[y][x] && near(x, y) && !eyes[y][x] && !glasses[y][x]) full[y][x] = variant[y][x];
1580
+ });
1581
+ const bodyCells = blank();
1582
+ const headCells = blank();
1583
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
1584
+ const c = full[y][x];
1585
+ if (!c) continue;
1586
+ if (y >= 22 && !head[y][x] && !back[y][x]) bodyCells[y][x] = c;
1587
+ else headCells[y][x] = c;
1588
+ }
1589
+ const cells = [];
1590
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
1591
+ const c = glasses[y][x] ?? eyes[y][x] ?? savedFeatures[y][x] ?? full[y][x];
1592
+ if (c) cells.push({
1593
+ x,
1594
+ y,
1595
+ c
1596
+ });
1597
+ }
1598
+ return {
1599
+ cells,
1600
+ tile: `<rect width="32" height="32" rx="6" fill="${pixelTileColor(recipe.hairColor)}"/>`,
1601
+ body: `<g class="bh-illustrated-body">${rects(bodyCells)}</g>`,
1602
+ head: [
1603
+ `<g class="bh-illustrated-head">${rects(headCells)}`,
1604
+ `<g class="bh-illustrated-face">${featureMarkup}`,
1605
+ `<g class="bh-illustrated-gaze">${rects(eyes)}</g>`,
1606
+ `<g class="bh-illustrated-blink" opacity="0">${rects(closed)}</g>`,
1607
+ `${rects(glasses)}</g></g>`
1608
+ ].join("")
1387
1609
  };
1388
1610
  }
1389
- /** The same name always gives the same face (BotHarness's namespace). */
1390
- const seededRecipe = createSeededRecipe("botharness-avatar");
1391
- const preset = (parts) => ({
1392
- ...DEFAULT_RECIPE,
1393
- ...parts
1394
- });
1395
- const AVATAR_PRESETS = [
1396
- preset({
1397
- hair: "wavy",
1398
- eyes: "lashes",
1399
- accessory: "beret",
1400
- outfit: "dress",
1401
- skinColor: "#ffe3cf",
1402
- hairColor: "#e2b04a",
1403
- eyeColor: "#3f7fbf",
1404
- shirtColor: "#e2565f",
1405
- backdrop: "stars"
1406
- }),
1407
- preset({
1408
- hair: "braids",
1409
- accessory: "flowercrown",
1410
- outfit: "cardigan",
1411
- skinColor: "#ffe3cf",
1412
- hairColor: "#8a5a36",
1413
- eyeColor: "#4c8a3c",
1414
- shirtColor: "#9ad0c2",
1415
- backdrop: "hearts"
1416
- }),
1417
- preset({
1418
- hair: "twintails",
1419
- eyes: "sparkle",
1420
- accessory: "ribbon",
1421
- outfit: "maid",
1422
- skinColor: "#ffe3cf",
1423
- hairColor: "#3fc1b8",
1424
- eyeColor: "#2f9e8f",
1425
- shirtColor: "#e2565f",
1426
- backdrop: "stars"
1427
- }),
1428
- preset({
1429
- hair: "long",
1430
- accessory: "horseears",
1431
- outfit: "jacket",
1432
- skinColor: "#ffe3cf",
1433
- hairColor: "#8a5a36",
1434
- eyeColor: "#d0533f",
1435
- shirtColor: "#5b8bd6",
1436
- backdrop: "dots"
1437
- }),
1438
- preset({
1439
- hair: "hime",
1440
- eyes: "sleepy",
1441
- accessory: "pins",
1442
- outfit: "kimono",
1443
- skinColor: "#f4f1ec",
1444
- hairColor: "#1d1b22",
1445
- eyeColor: "#d0533f",
1446
- shirtColor: "#7a5cc7",
1447
- backdrop: "none"
1448
- }),
1449
- preset({
1450
- hair: "ahoge",
1451
- accessory: "headband",
1452
- outfit: "sailor",
1453
- skinColor: "#ffe3cf",
1454
- hairColor: "#f06292",
1455
- eyeColor: "#2f9e8f",
1456
- shirtColor: "#f4f1ec",
1457
- backdrop: "hearts"
1458
- }),
1459
- preset({
1460
- hair: "wolf",
1461
- eyes: "sharp",
1462
- outfit: "jacket",
1463
- skinColor: "#e0a87e",
1464
- hairColor: "#9aa3ad",
1465
- eyeColor: "#5a7be0",
1466
- shirtColor: "#2f3a4a",
1467
- backdrop: "stars"
1468
- }),
1469
- preset({
1470
- hair: "wavy",
1471
- eyes: "sparkle",
1472
- accessory: "witch",
1473
- outfit: "dress",
1474
- skinColor: "#ffe3cf",
1475
- hairColor: "#8a5ad0",
1476
- eyeColor: "#d9a13a",
1477
- shirtColor: "#2f3a4a",
1478
- backdrop: "sparkles"
1479
- }),
1480
- preset({
1481
- hair: "bob",
1482
- eyes: "happy",
1483
- accessory: "bunnyears",
1484
- outfit: "cardigan",
1485
- skinColor: "#ffe3cf",
1486
- hairColor: "#f4f1ec",
1487
- eyeColor: "#d9475a",
1488
- shirtColor: "#f2c14e",
1489
- backdrop: "hearts"
1490
- }),
1491
- preset({
1492
- hair: "spiky",
1493
- glasses: "round",
1494
- accessory: "headphones",
1495
- outfit: "hoodie",
1496
- backdrop: "dots"
1497
- }),
1498
- preset({
1499
- hair: "odango",
1500
- eyes: "lashes",
1501
- accessory: "bow",
1502
- outfit: "kimono",
1503
- hairColor: "#e2b04a",
1504
- shirtColor: "#5b8bd6",
1505
- backdrop: "sparkles"
1506
- }),
1507
- preset({
1508
- hair: "crop",
1509
- accessory: "cap",
1510
- outfit: "shirttie",
1511
- skinColor: "#9a6142",
1512
- hairColor: "#1d1b22",
1513
- shirtColor: "#3d9970",
1514
- backdrop: "dots"
1515
- })
1516
- ];
1517
1611
 
1518
1612
  //#endregion
1519
1613
  //#region src/svg.ts
@@ -1536,9 +1630,9 @@ function faceCells(recipe) {
1536
1630
  /** A 32×32 SVG of the avatar: tile, body, head rig, optional turn layers and an empty morph layer. */
1537
1631
  function pixelAvatarSvg(recipe, options = {}) {
1538
1632
  if (!isPixelAvatarRecipe(recipe)) throw new Error("invalid Avatar recipe");
1539
- const base = pixelFigure(recipe, YAW[recipe.pose]);
1633
+ const base = pixelFigure(recipe, YAW[recipe.pose], options);
1540
1634
  const turns = (options.turns ?? []).map((delta) => {
1541
- const figure = pixelFigure(recipe, YAW[recipe.pose] + delta);
1635
+ const figure = pixelFigure(recipe, YAW[recipe.pose] + delta, options);
1542
1636
  return `<g data-avatar-turn="${delta}" opacity="0">${`${figure.body}${figure.head}`.replaceAll("class=\"bh-illustrated-", "data-turn-part=\"")}</g>`;
1543
1637
  }).join("");
1544
1638
  const svg = [
@@ -2037,5 +2131,5 @@ function symbolArtCells(rows, color) {
2037
2131
  }
2038
2132
 
2039
2133
  //#endregion
2040
- export { AVATAR_COLORS, AVATAR_HAIR_PARTS, AVATAR_PARTS, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SWATCHES, AVATAR_TURNS, DEFAULT_RECIPE, PIXEL_SYMBOLS, canonicalRecipe, createSeededRecipe, detailedRecipe, faceCells, isPixelAvatarRecipe, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, seededRandom, seededRecipe, symbolArtCells };
2134
+ export { AVATAR_COLORS, AVATAR_HAIR_PARTS, AVATAR_PARTS, AVATAR_PIECE_COLORS, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, AVATAR_SPECIES_SWATCHES, AVATAR_SWATCHES, AVATAR_TURNS, DEFAULT_RECIPE, PIXEL_SYMBOLS, canonicalRecipe, createSeededRecipe, detailedRecipe, faceCells, isPixelAvatarRecipe, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, seededRandom, seededRecipe, symbolArtCells, withSpecies };
2041
2135
  //# sourceMappingURL=index.js.map