@botharness/pixel-avatar 0.2.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,1533 +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"
638
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]));
639
367
  }
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));
368
+ function canonicalRecipe(recipe) {
369
+ const canonical = {
370
+ schemaVersion: 1,
371
+ family: "illustrated",
372
+ assetVersion: recipe.assetVersion,
373
+ rigVersion: 1
654
374
  };
655
- return hex([
656
- f(0),
657
- f(8),
658
- f(4)
659
- ].map((v) => v * 255));
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();
382
+ }
383
+ return canonical;
660
384
  }
661
- function pixelFigure(recipe, yawDeg, options = {}) {
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)
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
775
401
  };
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)
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
783
414
  };
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 mx = C - .5 + fs * .6;
800
- const withMouth = (mouth) => {
801
- const layer = features.map((row) => [...row]);
802
- sprite(layer, mouth, Math.round(mx - (mouth[0].length - 1) / 2), 20 + h, {
803
- K: MOUTH,
804
- M: MOUTH_INSIDE,
805
- T: TONGUE,
806
- W: WHITE
807
- });
808
- if (recipe.cheeks === "blush") {
809
- sprite(layer, ["PP"], left.x - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
810
- sprite(layer, ["PP"], right.x + right.w - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
811
- }
812
- if (recipe.cheeks === "freckles") {
813
- sprite(layer, ["F.F"], left.x, 19 + h, { F: shade(skin, .7) });
814
- sprite(layer, ["F.F"], right.x + 1, 19 + h, { F: shade(skin, .7) });
815
- }
816
- return layer;
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;
817
552
  };
818
- const savedFeatures = withMouth(MOUTHS[recipe.mouth]);
819
- const featureMarkup = options.mouthLayers ? [
820
- ["saved", MOUTHS[recipe.mouth]],
821
- ["closed", ["KKK"]],
822
- ["half-open", ["KKK", "MMM"]],
823
- ["open", [
824
- ".K.",
825
- "KMK",
826
- ".K."
827
- ]]
828
- ].map(([state, rows]) => `<g data-avatar-mouth="${state}" opacity="${state === "saved" ? 1 : 0}">${rects(withMouth(rows))}</g>`).join("") : rects(savedFeatures);
829
- const frame = (fx, w, round) => paint(glasses, (x, y) => {
830
- const inside = x >= fx && x < fx + w && y >= eyeTop && y <= eyeTop + 3;
831
- const box = x >= fx - 1 && x <= fx + w && y >= eyeTop - 1 && y <= eyeTop + 4;
832
- const corner = (x === fx - 1 || x === fx + w) && (y === eyeTop - 1 || y === eyeTop + 4);
833
- return box && !inside && !(round && corner);
834
- }, INK$1);
835
- if (recipe.glasses === "round" || recipe.glasses === "square") {
836
- frame(left.x, left.w, recipe.glasses === "round");
837
- frame(right.x, right.w, recipe.glasses === "round");
838
- paint(glasses, (x, y) => y === eyeTop + 1 && x > left.x + left.w && x < right.x - 1, INK$1);
839
- }
840
- if (recipe.glasses === "shades") {
841
- 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);
842
- paint(glasses, (x, y) => y === eyeTop && x > left.x + left.w && x < right.x - 1, INK$1);
843
- for (const e of [left, right]) sprite(glasses, ["W"], e.x, eyeTop, { W: "#7d7b88" });
844
- }
845
- if (recipe.glasses === "monocle") {
846
- frame(right.x, right.w, true);
847
- sprite(glasses, [
848
- "G",
849
- "G",
850
- "G"
851
- ], right.x + right.w, eyeTop + 5, { G: GOLD });
852
- }
853
- const full = clip(outline(figure));
854
- const bodyCells = blank();
855
- const headCells = blank();
856
- for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
857
- const c = full[y][x];
858
- if (!c) continue;
859
- if (y >= 22 && !head[y][x] && !back[y][x]) bodyCells[y][x] = c;
860
- else headCells[y][x] = c;
861
- }
862
- const cells = [];
553
+ }
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]);
863
737
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
864
- const c = glasses[y][x] ?? eyes[y][x] ?? savedFeatures[y][x] ?? full[y][x];
865
- if (c) cells.push({
866
- x,
867
- y,
868
- c
869
- });
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);
870
746
  }
871
- return {
872
- cells,
873
- tile: `<rect width="32" height="32" rx="6" fill="${pixelTileColor(recipe.hairColor)}"/>`,
874
- body: `<g class="bh-illustrated-body">${rects(bodyCells)}</g>`,
875
- head: [
876
- `<g class="bh-illustrated-head">${rects(headCells)}`,
877
- `<g class="bh-illustrated-face">${featureMarkup}`,
878
- `<g class="bh-illustrated-gaze">${rects(eyes)}</g>`,
879
- `<g class="bh-illustrated-blink" opacity="0">${rects(closed)}</g>`,
880
- `${rects(glasses)}</g></g>`
881
- ].join("")
882
- };
747
+ return out;
883
748
  }
884
-
885
- //#endregion
886
- //#region src/random.ts
887
- function seededRandom(seed) {
888
- let h = 2166136261;
889
- for (const char of seed) {
890
- h ^= char.codePointAt(0);
891
- 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
+ }
892
770
  }
893
- return () => {
894
- h = Math.imul(h ^ h >>> 15, 2246822507);
895
- h = Math.imul(h ^ h >>> 13, 3266489909);
896
- h ^= h >>> 16;
897
- return (h >>> 0) / 4294967296;
771
+ return m;
772
+ }
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;
898
796
  };
899
797
  }
900
-
901
- //#endregion
902
- //#region src/recipe.ts
903
- const AVATAR_PARTS = {
904
- head: [
905
- "round",
906
- "oval",
907
- "square",
908
- "long",
909
- "heart",
910
- "vchin",
911
- "chubby",
912
- "diamond"
913
- ],
914
- pose: [
915
- "front",
916
- "left",
917
- "right"
918
- ],
919
- hair: [
920
- "crop",
921
- "sweep",
922
- "spiky",
923
- "buzz",
924
- "curly",
925
- "mohawk",
926
- "bob",
927
- "long",
928
- "bun",
929
- "pigtails",
930
- "afro",
931
- "twintails",
932
- "drills",
933
- "ponytail",
934
- "sidetail",
935
- "hime",
936
- "odango",
937
- "messy",
938
- "wavy",
939
- "braids",
940
- "wolf",
941
- "ahoge",
942
- "none"
943
- ],
944
- eyes: [
945
- "round",
946
- "dot",
947
- "sparkle",
948
- "lashes",
949
- "sleepy",
950
- "happy",
951
- "wink",
952
- "sharp"
953
- ],
954
- brows: [
955
- "soft",
956
- "thick",
957
- "raised",
958
- "angry",
959
- "worried",
960
- "none"
961
- ],
962
- nose: [
963
- "button",
964
- "dot",
965
- "line",
966
- "none"
967
- ],
968
- mouth: [
969
- "smile",
970
- "grin",
971
- "open",
972
- "flat",
973
- "smirk",
974
- "cat",
975
- "tongue",
976
- "o"
977
- ],
978
- cheeks: [
979
- "blush",
980
- "freckles",
981
- "none"
798
+ const EYES = {
799
+ round: [
800
+ "KKKK",
801
+ "DWDD",
802
+ "IDDI",
803
+ "LIIL"
982
804
  ],
983
- glasses: [
984
- "none",
985
- "round",
986
- "square",
987
- "shades",
988
- "monocle"
805
+ dot: [
806
+ "....",
807
+ ".KK.",
808
+ ".KK.",
809
+ "...."
989
810
  ],
990
- outfit: [
991
- "tee",
992
- "shirttie",
993
- "hoodie",
994
- "turtleneck",
995
- "sailor",
996
- "blazer",
997
- "overalls",
998
- "dress",
999
- "kimono",
1000
- "cardigan",
1001
- "maid",
1002
- "jacket"
811
+ sparkle: [
812
+ "KKKK",
813
+ "DWDW",
814
+ "IDDI",
815
+ "LIWL"
1003
816
  ],
1004
- backdrop: [
1005
- "sparkles",
1006
- "hearts",
1007
- "stars",
1008
- "dots",
1009
- "none"
817
+ lashes: [
818
+ "KKKKK",
819
+ "K.DWD",
820
+ "..IDD",
821
+ "..LII"
1010
822
  ],
1011
- accessory: [
1012
- "none",
1013
- "beanie",
1014
- "cap",
1015
- "headphones",
1016
- "flower",
1017
- "bow",
1018
- "earring",
1019
- "crown",
1020
- "halo",
1021
- "catears",
1022
- "hairclip",
1023
- "horns",
1024
- "beret",
1025
- "ribbon",
1026
- "headband",
1027
- "bunnyears",
1028
- "horseears",
1029
- "flowercrown",
1030
- "witch",
1031
- "pins"
1032
- ]
1033
- };
1034
- const AVATAR_HAIR_PARTS = {
1035
- bangs: [
1036
- "crop",
1037
- "bob",
1038
- "sweep",
1039
- "spiky",
1040
- "curly",
1041
- "messy",
1042
- "wolf",
1043
- "wavy",
1044
- "ahoge",
1045
- "hime",
1046
- "buzz",
1047
- "mohawk",
1048
- "none"
823
+ sleepy: [
824
+ "....",
825
+ "KKKK",
826
+ "IDDI",
827
+ "LIIL"
1049
828
  ],
1050
- sideHair: [
1051
- "crop",
1052
- "bun",
1053
- "bob",
1054
- "long",
1055
- "hime",
1056
- "curly",
1057
- "braids",
1058
- "none"
829
+ happy: [
830
+ "....",
831
+ ".KK.",
832
+ "K..K",
833
+ "...."
1059
834
  ],
1060
- backHair: [
1061
- "none",
1062
- "bob",
1063
- "long",
1064
- "wavy",
1065
- "wolf",
1066
- "bun",
1067
- "odango",
1068
- "pigtails",
1069
- "twintails",
1070
- "drills",
1071
- "ponytail",
1072
- "sidetail",
1073
- "afro"
835
+ wink: [
836
+ "KKKK",
837
+ "DWDD",
838
+ "IDDI",
839
+ "LIIL"
840
+ ],
841
+ sharp: [
842
+ "KK..",
843
+ ".KKK",
844
+ ".DWD",
845
+ "..LI"
1074
846
  ]
1075
847
  };
1076
- const HAIR_SPLIT = {
1077
- crop: {
1078
- bangs: "crop",
1079
- sideHair: "crop",
1080
- backHair: "none"
1081
- },
1082
- sweep: {
1083
- bangs: "sweep",
1084
- sideHair: "crop",
1085
- backHair: "none"
1086
- },
1087
- spiky: {
1088
- bangs: "spiky",
1089
- sideHair: "crop",
1090
- backHair: "none"
1091
- },
1092
- buzz: {
1093
- bangs: "buzz",
1094
- sideHair: "none",
1095
- backHair: "none"
1096
- },
1097
- curly: {
1098
- bangs: "curly",
1099
- sideHair: "curly",
1100
- backHair: "none"
1101
- },
1102
- mohawk: {
1103
- bangs: "mohawk",
1104
- sideHair: "none",
1105
- backHair: "none"
1106
- },
1107
- bob: {
1108
- bangs: "bob",
1109
- sideHair: "bob",
1110
- backHair: "bob"
1111
- },
1112
- long: {
1113
- bangs: "bob",
1114
- sideHair: "long",
1115
- backHair: "long"
1116
- },
1117
- bun: {
1118
- bangs: "bob",
1119
- sideHair: "bun",
1120
- backHair: "bun"
1121
- },
1122
- pigtails: {
1123
- bangs: "bob",
1124
- sideHair: "bun",
1125
- backHair: "pigtails"
1126
- },
1127
- afro: {
1128
- bangs: "buzz",
1129
- sideHair: "none",
1130
- backHair: "afro"
1131
- },
1132
- twintails: {
1133
- bangs: "bob",
1134
- sideHair: "bob",
1135
- backHair: "twintails"
1136
- },
1137
- drills: {
1138
- bangs: "bob",
1139
- sideHair: "bob",
1140
- backHair: "drills"
1141
- },
1142
- ponytail: {
1143
- bangs: "bob",
1144
- sideHair: "bun",
1145
- backHair: "ponytail"
1146
- },
1147
- sidetail: {
1148
- bangs: "sweep",
1149
- sideHair: "bun",
1150
- backHair: "sidetail"
1151
- },
1152
- hime: {
1153
- bangs: "hime",
1154
- sideHair: "hime",
1155
- backHair: "long"
1156
- },
1157
- odango: {
1158
- bangs: "bob",
1159
- sideHair: "bun",
1160
- backHair: "odango"
1161
- },
1162
- messy: {
1163
- bangs: "messy",
1164
- sideHair: "bun",
1165
- backHair: "none"
1166
- },
1167
- wavy: {
1168
- bangs: "wavy",
1169
- sideHair: "long",
1170
- backHair: "wavy"
1171
- },
1172
- braids: {
1173
- bangs: "bob",
1174
- sideHair: "braids",
1175
- backHair: "none"
1176
- },
1177
- wolf: {
1178
- bangs: "wolf",
1179
- sideHair: "bun",
1180
- backHair: "wolf"
1181
- },
1182
- ahoge: {
1183
- bangs: "ahoge",
1184
- sideHair: "bob",
1185
- backHair: "bob"
1186
- },
1187
- none: {
1188
- bangs: "none",
1189
- sideHair: "none",
1190
- backHair: "none"
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);
1191
1142
  }
1192
- };
1193
- const AVATAR_RANGES = {
1194
- spacing: [-1, 1],
1195
- height: [-1, 1],
1196
- hairLength: [-2, 2]
1197
- };
1198
- const DETAIL_KEYS = [...Object.keys(AVATAR_HAIR_PARTS), ...Object.keys(AVATAR_RANGES)];
1199
- const AVATAR_COLORS = [
1200
- "skinColor",
1201
- "hairColor",
1202
- "eyeColor",
1203
- "shirtColor"
1204
- ];
1205
- const DEFAULT_RECIPE = {
1206
- schemaVersion: 1,
1207
- family: "illustrated",
1208
- assetVersion: 1,
1209
- rigVersion: 1,
1210
- head: "round",
1211
- pose: "front",
1212
- hair: "crop",
1213
- eyes: "round",
1214
- brows: "soft",
1215
- nose: "button",
1216
- mouth: "smile",
1217
- cheeks: "blush",
1218
- glasses: "none",
1219
- outfit: "tee",
1220
- backdrop: "sparkles",
1221
- accessory: "none",
1222
- skinColor: "#f2c9a8",
1223
- hairColor: "#5a3a2a",
1224
- eyeColor: "#3f7fbf",
1225
- shirtColor: "#5b8bd6"
1226
- };
1227
- const PART_KEYS = Object.keys(AVATAR_PARTS);
1228
- function isPixelAvatarRecipe(value) {
1229
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1230
- const r = value;
1231
- const detailed = DETAIL_KEYS.filter((key) => Object.hasOwn(r, key)).length;
1232
- if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false;
1233
- if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed) return false;
1234
- 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) => {
1235
- const v = r[key];
1236
- const [min, max] = AVATAR_RANGES[key];
1237
- return typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
1238
- })) && 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]));
1239
1143
  }
1240
- function canonicalRecipe(recipe) {
1241
- const canonical = {
1242
- schemaVersion: 1,
1243
- family: "illustrated",
1244
- assetVersion: 1,
1245
- rigVersion: 1
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;
1335
+ }
1336
+ }
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));
1246
1351
  };
1247
- for (const key of PART_KEYS) canonical[key] = recipe[key];
1248
- for (const key of AVATAR_COLORS) canonical[key] = recipe[key].toLowerCase();
1249
- if (recipe.bangs !== void 0) for (const key of DETAIL_KEYS) canonical[key] = recipe[key];
1250
- return canonical;
1352
+ return hex([
1353
+ f(0),
1354
+ f(8),
1355
+ f(4)
1356
+ ].map((v) => v * 255));
1251
1357
  }
1252
- function detailedRecipe(recipe) {
1253
- if (recipe.bangs !== void 0) return recipe;
1254
- const pick = (part) => AVATAR_HAIR_PARTS[part].includes(recipe.hair) ? recipe.hair : HAIR_SPLIT[recipe.hair][part];
1255
- return {
1256
- ...recipe,
1257
- bangs: pick("bangs"),
1258
- sideHair: pick("sideHair"),
1259
- backHair: pick("backHair"),
1260
- spacing: 0,
1261
- height: 0,
1262
- 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;
1263
1386
  };
1264
- }
1265
- const AVATAR_SWATCHES = {
1266
- skinColor: [
1267
- "#ffe3cf",
1268
- "#f2c9a8",
1269
- "#e0a87e",
1270
- "#c68863",
1271
- "#9a6142",
1272
- "#6e4129",
1273
- "#4a2c1c",
1274
- "#f4f1ec"
1275
- ],
1276
- hairColor: [
1277
- "#1d1b22",
1278
- "#5a3a2a",
1279
- "#8a5a36",
1280
- "#e2b04a",
1281
- "#c4452f",
1282
- "#d9475a",
1283
- "#f06292",
1284
- "#3fc1b8",
1285
- "#5a7be0",
1286
- "#9aa3ad",
1287
- "#f4f1ec"
1288
- ],
1289
- eyeColor: [
1290
- "#3f7fbf",
1291
- "#5a3a2a",
1292
- "#2f9e8f",
1293
- "#4c8a3c",
1294
- "#8a5ad0",
1295
- "#d0533f",
1296
- "#d9a13a",
1297
- "#2a2230"
1298
- ],
1299
- shirtColor: [
1300
- "#5b8bd6",
1301
- "#e07a5f",
1302
- "#3d9970",
1303
- "#7a5cc7",
1304
- "#f2c14e",
1305
- "#2f3a4a",
1306
- "#e2565f",
1307
- "#9ad0c2"
1308
- ]
1309
- };
1310
- const SEEDED_CHOICES = {
1311
- head: [
1312
- "round",
1313
- "oval",
1314
- "square",
1315
- "long",
1316
- "heart",
1317
- "vchin",
1318
- "chubby",
1319
- "diamond"
1320
- ],
1321
- pose: ["front"],
1322
- hair: AVATAR_PARTS.hair.filter((hair) => hair !== "none"),
1323
- eyes: [
1324
- "round",
1325
- "round",
1326
- "sparkle",
1327
- "lashes",
1328
- "dot",
1329
- "happy",
1330
- "sharp"
1331
- ],
1332
- brows: [
1333
- "soft",
1334
- "soft",
1335
- "thick",
1336
- "raised"
1337
- ],
1338
- nose: [
1339
- "button",
1340
- "dot",
1341
- "none"
1342
- ],
1343
- mouth: [
1344
- "smile",
1345
- "smile",
1346
- "grin",
1347
- "open",
1348
- "cat"
1349
- ],
1350
- cheeks: [
1351
- "blush",
1352
- "blush",
1353
- "freckles",
1354
- "none"
1355
- ],
1356
- glasses: [
1357
- "none",
1358
- "none",
1359
- "none",
1360
- "none",
1361
- "round",
1362
- "square"
1363
- ],
1364
- accessory: [
1365
- "none",
1366
- "none",
1367
- "none",
1368
- "flower",
1369
- "bow",
1370
- "hairclip",
1371
- "catears",
1372
- "headphones",
1373
- "beanie",
1374
- "cap"
1375
- ],
1376
- outfit: AVATAR_PARTS.outfit,
1377
- backdrop: [
1378
- "sparkles",
1379
- "hearts",
1380
- "stars",
1381
- "dots"
1382
- ]
1383
- };
1384
- /**
1385
- * Returns a name-seeded recipe factory. Different namespaces give different faces for the same
1386
- * name; the factory takes the name only, so it is safe to pass straight to `Array.map`.
1387
- */
1388
- function createSeededRecipe(namespace) {
1389
- return (seed) => {
1390
- const random = seededRandom(`${namespace}:${seed.trim().toLowerCase()}`);
1391
- const pick = (values) => values[Math.floor(random() * values.length)];
1392
- const recipe = {
1393
- schemaVersion: 1,
1394
- family: "illustrated",
1395
- assetVersion: 1,
1396
- 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
1397
1575
  };
1398
- for (const part of Object.keys(AVATAR_PARTS)) recipe[part] = pick(SEEDED_CHOICES[part]);
1399
- for (const color of AVATAR_COLORS) recipe[color] = pick(AVATAR_SWATCHES[color].slice(0, 7));
1400
- 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("")
1401
1609
  };
1402
1610
  }
1403
- /** The same name always gives the same face (BotHarness's namespace). */
1404
- const seededRecipe = createSeededRecipe("botharness-avatar");
1405
- const preset = (parts) => ({
1406
- ...DEFAULT_RECIPE,
1407
- ...parts
1408
- });
1409
- const AVATAR_PRESETS = [
1410
- preset({
1411
- hair: "wavy",
1412
- eyes: "lashes",
1413
- accessory: "beret",
1414
- outfit: "dress",
1415
- skinColor: "#ffe3cf",
1416
- hairColor: "#e2b04a",
1417
- eyeColor: "#3f7fbf",
1418
- shirtColor: "#e2565f",
1419
- backdrop: "stars"
1420
- }),
1421
- preset({
1422
- hair: "braids",
1423
- accessory: "flowercrown",
1424
- outfit: "cardigan",
1425
- skinColor: "#ffe3cf",
1426
- hairColor: "#8a5a36",
1427
- eyeColor: "#4c8a3c",
1428
- shirtColor: "#9ad0c2",
1429
- backdrop: "hearts"
1430
- }),
1431
- preset({
1432
- hair: "twintails",
1433
- eyes: "sparkle",
1434
- accessory: "ribbon",
1435
- outfit: "maid",
1436
- skinColor: "#ffe3cf",
1437
- hairColor: "#3fc1b8",
1438
- eyeColor: "#2f9e8f",
1439
- shirtColor: "#e2565f",
1440
- backdrop: "stars"
1441
- }),
1442
- preset({
1443
- hair: "long",
1444
- accessory: "horseears",
1445
- outfit: "jacket",
1446
- skinColor: "#ffe3cf",
1447
- hairColor: "#8a5a36",
1448
- eyeColor: "#d0533f",
1449
- shirtColor: "#5b8bd6",
1450
- backdrop: "dots"
1451
- }),
1452
- preset({
1453
- hair: "hime",
1454
- eyes: "sleepy",
1455
- accessory: "pins",
1456
- outfit: "kimono",
1457
- skinColor: "#f4f1ec",
1458
- hairColor: "#1d1b22",
1459
- eyeColor: "#d0533f",
1460
- shirtColor: "#7a5cc7",
1461
- backdrop: "none"
1462
- }),
1463
- preset({
1464
- hair: "ahoge",
1465
- accessory: "headband",
1466
- outfit: "sailor",
1467
- skinColor: "#ffe3cf",
1468
- hairColor: "#f06292",
1469
- eyeColor: "#2f9e8f",
1470
- shirtColor: "#f4f1ec",
1471
- backdrop: "hearts"
1472
- }),
1473
- preset({
1474
- hair: "wolf",
1475
- eyes: "sharp",
1476
- outfit: "jacket",
1477
- skinColor: "#e0a87e",
1478
- hairColor: "#9aa3ad",
1479
- eyeColor: "#5a7be0",
1480
- shirtColor: "#2f3a4a",
1481
- backdrop: "stars"
1482
- }),
1483
- preset({
1484
- hair: "wavy",
1485
- eyes: "sparkle",
1486
- accessory: "witch",
1487
- outfit: "dress",
1488
- skinColor: "#ffe3cf",
1489
- hairColor: "#8a5ad0",
1490
- eyeColor: "#d9a13a",
1491
- shirtColor: "#2f3a4a",
1492
- backdrop: "sparkles"
1493
- }),
1494
- preset({
1495
- hair: "bob",
1496
- eyes: "happy",
1497
- accessory: "bunnyears",
1498
- outfit: "cardigan",
1499
- skinColor: "#ffe3cf",
1500
- hairColor: "#f4f1ec",
1501
- eyeColor: "#d9475a",
1502
- shirtColor: "#f2c14e",
1503
- backdrop: "hearts"
1504
- }),
1505
- preset({
1506
- hair: "spiky",
1507
- glasses: "round",
1508
- accessory: "headphones",
1509
- outfit: "hoodie",
1510
- backdrop: "dots"
1511
- }),
1512
- preset({
1513
- hair: "odango",
1514
- eyes: "lashes",
1515
- accessory: "bow",
1516
- outfit: "kimono",
1517
- hairColor: "#e2b04a",
1518
- shirtColor: "#5b8bd6",
1519
- backdrop: "sparkles"
1520
- }),
1521
- preset({
1522
- hair: "crop",
1523
- accessory: "cap",
1524
- outfit: "shirttie",
1525
- skinColor: "#9a6142",
1526
- hairColor: "#1d1b22",
1527
- shirtColor: "#3d9970",
1528
- backdrop: "dots"
1529
- })
1530
- ];
1531
1611
 
1532
1612
  //#endregion
1533
1613
  //#region src/svg.ts
@@ -2051,5 +2131,5 @@ function symbolArtCells(rows, color) {
2051
2131
  }
2052
2132
 
2053
2133
  //#endregion
2054
- 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 };
2055
2135
  //# sourceMappingURL=index.js.map