@botharness/pixel-avatar 0.8.0 → 0.10.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
@@ -331,8 +331,55 @@ const AVATAR_SPECIES = [
331
331
  "orc",
332
332
  "flower"
333
333
  ];
334
+ /**
335
+ * Anthropomorphic animal species (asset version 4): a muzzle, animal ears and fur colors. The
336
+ * skin color is the fur color, and an optional `pattern` is drawn in its tones.
337
+ */
338
+ const AVATAR_ANIMAL_SPECIES = [
339
+ "cat",
340
+ "dog",
341
+ "fox",
342
+ "rabbit",
343
+ "bear"
344
+ ];
345
+ const isAnimalSpecies = (species) => AVATAR_ANIMAL_SPECIES.includes(species);
346
+ /** Fur patterns for animal species, drawn in skin-color tones so they survive recoloring. */
347
+ const AVATAR_PATTERNS = [
348
+ "solid",
349
+ "tabby",
350
+ "spots",
351
+ "patches",
352
+ "colorpoint"
353
+ ];
334
354
  /** Optional per-piece hair colors (asset version 2); an absent piece uses `hairColor`. */
335
355
  const AVATAR_PIECE_COLORS = ["leftSideHairColor", "rightSideHairColor"];
356
+ /**
357
+ * Asset version 4 adds a color for every other hair piece; an absent piece uses `hairColor`.
358
+ * `strandColor` colors the single strand (`strand`).
359
+ */
360
+ const AVATAR_PIECE_COLORS_V4 = [
361
+ "bangsColor",
362
+ "backHairColor",
363
+ "strandColor"
364
+ ];
365
+ /** A single strand of hair standing up from the crown (asset version 4). */
366
+ const AVATAR_STRANDS = [
367
+ "ahoge",
368
+ "curl",
369
+ "double"
370
+ ];
371
+ /**
372
+ * Built-in headpieces (asset version 4), worn in the headpiece slot alongside an accessory and
373
+ * drawn partly behind the hair. The first five were accessories in earlier versions.
374
+ */
375
+ const AVATAR_HEADPIECES = [
376
+ "catears",
377
+ "bunnyears",
378
+ "horseears",
379
+ "horns",
380
+ "halo",
381
+ "wings"
382
+ ];
336
383
  /** Part choices that exist only in asset version 2, added after every version 1 choice. */
337
384
  const AVATAR_PARTS_V2 = {
338
385
  ...AVATAR_PARTS,
@@ -379,7 +426,8 @@ const CUSTOM_PART_KEYS = {
379
426
  nose: "nosePart",
380
427
  cheeks: "cheeksPart",
381
428
  petals: "petalsPart",
382
- flowerBase: "flowerBasePart"
429
+ flowerBase: "flowerBasePart",
430
+ pattern: "patternPart"
383
431
  };
384
432
  const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS);
385
433
  const DEFAULT_RECIPE = {
@@ -410,19 +458,29 @@ function isPixelAvatarRecipe(value) {
410
458
  const r = value;
411
459
  const detailed = DETAIL_KEYS.filter((key) => Object.hasOwn(r, key)).length;
412
460
  if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false;
461
+ const v4 = r["assetVersion"] === 4;
413
462
  const v3 = r["assetVersion"] === 3;
414
- const v2 = r["assetVersion"] === 2 || v3;
415
- const worn = PART_KEY_ENTRIES.filter(([, key]) => Object.hasOwn(r, key));
416
- if (v3 ? worn.length === 0 : worn.length !== 0) return false;
463
+ const v2 = r["assetVersion"] === 2 || v3 || v4;
464
+ const builtInHead = v4 && typeof r["headpiece"] === "string";
465
+ if (builtInHead && !AVATAR_HEADPIECES.includes(r["headpiece"])) return false;
466
+ const worn = PART_KEY_ENTRIES.filter(([slot, key]) => Object.hasOwn(r, key) && !(slot === "headpiece" && builtInHead));
467
+ if (v3 ? worn.length === 0 : !v4 && worn.length !== 0) return false;
468
+ if (!v4 && Object.hasOwn(r, CUSTOM_PART_KEYS.pattern)) return false;
469
+ const newer = [
470
+ ...AVATAR_PIECE_COLORS_V4,
471
+ "strand",
472
+ "pattern"
473
+ ].filter((key) => Object.hasOwn(r, key));
474
+ if (newer.length !== 0 && (!v4 || !AVATAR_PIECE_COLORS_V4.every((key) => !Object.hasOwn(r, key) || typeof r[key] === "string" && /^#[\da-f]{6}$/iu.test(r[key])) || Object.hasOwn(r, "strand") && !AVATAR_STRANDS.includes(r["strand"]) || Object.hasOwn(r, "pattern") && !AVATAR_PATTERNS.includes(r["pattern"]))) return false;
417
475
  for (const [slot, key] of worn) {
418
476
  const part = r[key];
419
477
  if (!isPixelCustomPart(part) || part.slot !== slot) return false;
420
478
  }
421
479
  const pieces = AVATAR_PIECE_COLORS.filter((key) => Object.hasOwn(r, key));
422
480
  const extras = Object.keys(AVATAR_EXTRA_PARTS).filter((key) => Object.hasOwn(r, key));
423
- const v2Keys = v2 ? 2 + pieces.length + extras.length + worn.length : 0;
481
+ const v2Keys = v2 ? 2 + pieces.length + extras.length + worn.length + newer.length + (builtInHead ? 1 : 0) : 0;
424
482
  if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed + v2Keys) return false;
425
- 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])) || !extras.every((key) => AVATAR_EXTRA_PARTS[key].includes(r[key])))) return false;
483
+ if (v2 && (detailed === 0 || !(AVATAR_SPECIES.includes(r["species"]) || v4 && isAnimalSpecies(r["species"])) || !AVATAR_HAIR_PARTS.sideHair.includes(r["rightSideHair"]) || !pieces.every((key) => typeof r[key] === "string" && /^#[\da-f]{6}$/iu.test(r[key])) || !extras.every((key) => AVATAR_EXTRA_PARTS[key].includes(r[key])))) return false;
426
484
  const catalog = v2 ? AVATAR_PARTS_V2 : AVATAR_PARTS;
427
485
  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) => {
428
486
  const v = r[key];
@@ -445,9 +503,14 @@ function canonicalRecipe(recipe) {
445
503
  canonical["rightSideHair"] = recipe.rightSideHair;
446
504
  for (const key of AVATAR_PIECE_COLORS) if (recipe[key] !== void 0) canonical[key] = recipe[key].toLowerCase();
447
505
  for (const key of Object.keys(AVATAR_EXTRA_PARTS)) if (recipe[key] !== void 0) canonical[key] = recipe[key];
448
- if (recipe.assetVersion === 3) for (const [, key] of PART_KEY_ENTRIES) {
506
+ if (recipe.assetVersion === 3 || recipe.assetVersion === 4) for (const [, key] of PART_KEY_ENTRIES) {
449
507
  const part = recipe[key];
450
- if (part !== void 0) canonical[key] = canonicalCustomPart(part);
508
+ if (part !== void 0) canonical[key] = typeof part === "string" ? part : canonicalCustomPart(part);
509
+ }
510
+ if (recipe.assetVersion === 4) {
511
+ if (recipe.strand !== void 0) canonical["strand"] = recipe.strand;
512
+ if (recipe.pattern !== void 0 && recipe.pattern !== "solid") canonical["pattern"] = recipe.pattern;
513
+ for (const key of AVATAR_PIECE_COLORS_V4) if (recipe[key] !== void 0) canonical[key] = recipe[key].toLowerCase();
451
514
  }
452
515
  }
453
516
  return canonical;
@@ -458,14 +521,23 @@ const HAIR_PIECES = [
458
521
  "rightSideHair",
459
522
  "backHair"
460
523
  ];
524
+ const EAR_PIECES = [
525
+ "catears",
526
+ "bunnyears",
527
+ "horseears"
528
+ ];
461
529
  /**
462
530
  * Saved choices the recipe keeps but does not draw: a flower shows petals and a stem instead of
463
531
  * hair, outfit, beard and headwear, and a helmet or hood covers the hair. The choices return when
464
532
  * the species or headwear changes back.
465
533
  */
466
534
  function hiddenChoices(recipe) {
535
+ const strand = recipe.strand && (recipe.species === "flower" || recipe.bangs !== "none") ? ["strand"] : [];
536
+ const bald = recipe.strand && recipe.bangs === "none" ? ["strand"] : [];
467
537
  if (recipe.species === "flower") return [
468
538
  ...HAIR_PIECES,
539
+ ...strand,
540
+ ...typeof recipe.headpiece === "string" ? ["headpiece"] : [],
469
541
  "outfit",
470
542
  "accessory",
471
543
  "eyes",
@@ -473,50 +545,86 @@ function hiddenChoices(recipe) {
473
545
  "nose",
474
546
  "cheeks",
475
547
  "glasses",
476
- ...recipe.beard || wornPart(recipe, "beard") ? ["beard"] : []
548
+ ...recipe.beard || wornPart(recipe, "beard") ? ["beard"] : [],
549
+ ...recipe.pattern || wornPart(recipe, "pattern") ? ["pattern"] : []
477
550
  ];
478
551
  const hidden = [];
479
- if ((recipe.accessory === "helmet" || recipe.accessory === "hood") && !wornPart(recipe, "accessory")) hidden.push(...HAIR_PIECES);
552
+ if ((recipe.accessory === "helmet" || recipe.accessory === "hood") && !wornPart(recipe, "accessory")) hidden.push(...HAIR_PIECES, ...strand);
480
553
  if (recipe.species === "dwarf") hidden.push("nose");
554
+ if (!hidden.includes("strand")) hidden.push(...bald);
555
+ if (isAnimalSpecies(recipe.species)) {
556
+ hidden.push("nose");
557
+ if (EAR_PIECES.includes(builtInHeadpiece(recipe) ?? "")) hidden.push("headpiece");
558
+ if (EAR_PIECES.includes(recipe.accessory) && !wornPart(recipe, "accessory")) hidden.push("accessory");
559
+ } else if (recipe.pattern || wornPart(recipe, "pattern")) hidden.push("pattern");
481
560
  return hidden;
482
561
  }
483
- function withSpecies(recipe, species) {
562
+ function withSpecies(source, species) {
563
+ const recipe = isAnimalSpecies(species) && source.assetVersion !== 4 ? withPieces(source) : source;
484
564
  const detailed = detailedRecipe(recipe);
485
565
  const previous = recipe.species ?? "human";
486
566
  const suggested = AVATAR_SPECIES_SWATCHES[previous].includes(recipe.skinColor.toLowerCase());
487
567
  return {
488
568
  ...detailed,
489
- assetVersion: recipe.assetVersion === 3 ? 3 : 2,
569
+ assetVersion: recipe.assetVersion === 1 ? 2 : recipe.assetVersion,
490
570
  species,
491
571
  rightSideHair: detailed.rightSideHair ?? detailed.sideHair,
492
572
  skinColor: suggested && previous !== species ? AVATAR_SPECIES_SWATCHES[species][0] : recipe.skinColor
493
573
  };
494
574
  }
495
- /**
496
- * Returns the recipe wearing `part` in `slot`, or with that slot's part taken off. The recipe
497
- * embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise.
498
- */
499
575
  function withCustomPart(recipe, slot, part) {
500
- const base = { ...recipe.assetVersion === 1 ? withSpecies(recipe, "human") : recipe };
576
+ const base = { ...slot === "pattern" && part !== void 0 ? withPieces(recipe) : recipe.assetVersion === 1 ? withSpecies(recipe, "human") : recipe };
501
577
  const key = CUSTOM_PART_KEYS[slot];
502
578
  delete base[key];
503
579
  if (part !== void 0) base[key] = canonicalCustomPart({
504
580
  ...part,
505
581
  slot
506
582
  });
583
+ if (base["assetVersion"] === 4) return base;
507
584
  const wearing = PART_KEY_ENTRIES.some(([, k]) => base[k] !== void 0);
508
585
  return {
509
586
  ...base,
510
587
  assetVersion: wearing ? 3 : 2
511
588
  };
512
589
  }
590
+ const MOVED_HEADPIECES = AVATAR_HEADPIECES;
591
+ /**
592
+ * Returns the recipe as asset version 4, keeping every choice. An accessory that is now a
593
+ * headpiece moves into the empty headpiece slot, freeing the accessory slot; call this only when
594
+ * the Human edits, so saved recipes keep rendering as they were.
595
+ */
596
+ function withPieces(recipe) {
597
+ if (recipe.assetVersion === 4) return recipe;
598
+ const base = {
599
+ ...recipe.assetVersion === 1 ? withSpecies(recipe, "human") : recipe,
600
+ assetVersion: 4
601
+ };
602
+ if (base["headpiece"] === void 0 && MOVED_HEADPIECES.includes(recipe.accessory)) {
603
+ base["headpiece"] = recipe.accessory;
604
+ base["accessory"] = "none";
605
+ }
606
+ return base;
607
+ }
608
+ /** Returns the recipe as asset version 4 wearing a built-in headpiece, or none. */
609
+ function withBuiltInHeadpiece(recipe, headpiece) {
610
+ const base = { ...withPieces(recipe) };
611
+ delete base["headpiece"];
612
+ if (headpiece !== void 0) base["headpiece"] = headpiece;
613
+ return base;
614
+ }
615
+ /** The built-in headpiece worn, if any. */
616
+ function builtInHeadpiece(recipe) {
617
+ return typeof recipe.headpiece === "string" ? recipe.headpiece : void 0;
618
+ }
513
619
  /** `withCustomPart` for the headpiece slot. */
514
620
  function withHeadpiece(recipe, part) {
515
621
  return withCustomPart(recipe, "headpiece", part);
516
622
  }
517
623
  /** The Custom Part worn in a slot, if any. */
518
624
  function wornPart(recipe, slot) {
519
- return recipe.assetVersion === 3 ? recipe[CUSTOM_PART_KEYS[slot]] : void 0;
625
+ if (recipe.assetVersion !== 3 && recipe.assetVersion !== 4) return void 0;
626
+ const part = recipe[CUSTOM_PART_KEYS[slot]];
627
+ return typeof part === "string" ? void 0 : part;
520
628
  }
521
629
  function detailedRecipe(recipe) {
522
630
  if (recipe.bangs !== void 0) return recipe;
@@ -745,6 +853,46 @@ const AVATAR_SPECIES_SWATCHES = {
745
853
  "#e8a85a",
746
854
  "#c98b4a",
747
855
  "#f4f1ec"
856
+ ],
857
+ cat: [
858
+ "#f2a65a",
859
+ "#9aa3ad",
860
+ "#3a3438",
861
+ "#f4f1ec",
862
+ "#c98b4a",
863
+ "#e8d2b0"
864
+ ],
865
+ dog: [
866
+ "#c98b4a",
867
+ "#e8d2b0",
868
+ "#8a5a36",
869
+ "#f4f1ec",
870
+ "#3a3438",
871
+ "#d9a066"
872
+ ],
873
+ fox: [
874
+ "#e0763a",
875
+ "#c4452f",
876
+ "#f2a65a",
877
+ "#f4f1ec",
878
+ "#8a5a36",
879
+ "#9aa3ad"
880
+ ],
881
+ rabbit: [
882
+ "#f4f1ec",
883
+ "#d9c7b8",
884
+ "#9aa3ad",
885
+ "#8a5a36",
886
+ "#e8d2b0",
887
+ "#3a3438"
888
+ ],
889
+ bear: [
890
+ "#8a5a36",
891
+ "#5a3a2a",
892
+ "#c98b4a",
893
+ "#3a3438",
894
+ "#f4f1ec",
895
+ "#d9a066"
748
896
  ]
749
897
  };
750
898
  const preset = (parts) => ({
@@ -934,6 +1082,10 @@ const PART_SLOTS = {
934
1082
  flowerBase: {
935
1083
  width: 32,
936
1084
  height: 32
1085
+ },
1086
+ pattern: {
1087
+ width: 32,
1088
+ height: 32
937
1089
  }
938
1090
  };
939
1091
  /**
@@ -959,7 +1111,8 @@ const REPLACE_PART_SLOTS = [
959
1111
  "nose",
960
1112
  "cheeks",
961
1113
  "petals",
962
- "flowerBase"
1114
+ "flowerBase",
1115
+ "pattern"
963
1116
  ];
964
1117
  const isReplacePartSlot = (slot) => REPLACE_PART_SLOTS.includes(slot);
965
1118
  /** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */
@@ -1659,6 +1812,39 @@ function hair(recipe, s, b) {
1659
1812
  };
1660
1813
  }
1661
1814
  }
1815
+ const STRANDS = {
1816
+ ahoge: {
1817
+ rows: [
1818
+ "XX.",
1819
+ ".XX",
1820
+ ".XX"
1821
+ ],
1822
+ dx: 0
1823
+ },
1824
+ curl: {
1825
+ rows: [
1826
+ ".XX.",
1827
+ "X..X",
1828
+ "..XX",
1829
+ ".X.."
1830
+ ],
1831
+ dx: -1
1832
+ },
1833
+ double: {
1834
+ rows: [
1835
+ "X...X",
1836
+ ".X.X.",
1837
+ ".X.X."
1838
+ ],
1839
+ dx: -2
1840
+ }
1841
+ };
1842
+ function strandMask(style, s) {
1843
+ const { rows, dx } = STRANDS[style];
1844
+ const top = 4 - rows.length;
1845
+ const left = Math.round(C + s) + dx;
1846
+ return (x, y) => rows[y - top]?.[x - left] === "X";
1847
+ }
1662
1848
  function detailedHair(recipe, s, b) {
1663
1849
  const of = (style) => hair({
1664
1850
  ...recipe,
@@ -1673,8 +1859,11 @@ function detailedHair(recipe, s, b) {
1673
1859
  const sideOf = (sign) => (x, y) => y > 7 && sign * (x + .5 - C - s) >= 7 && lengthen((sign < 0 ? side : right).front)(x, y);
1674
1860
  const fringe = (x, y) => y <= 7 ? bangs.front(x, y) || back.front(x, y) : Math.abs(x + .5 - C - s) < 7 && bangs.front(x, y);
1675
1861
  const behind = length === 0 ? back.back : lengthen(back.back);
1862
+ const tuft = recipe.strand && recipe.bangs !== "none" ? strandMask(recipe.strand, s) : void 0;
1863
+ const strand = tuft ? (x, y) => tuft(x, y) && !fringe(x, y) && !sideOf(-1)(x, y) && !sideOf(1)(x, y) : void 0;
1676
1864
  return {
1677
- front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y),
1865
+ front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y) || (strand?.(x, y) ?? false),
1866
+ ...strand ? { strand } : {},
1678
1867
  sides: [sideOf(-1), sideOf(1)],
1679
1868
  pieces: {
1680
1869
  bangs: fringe,
@@ -2019,6 +2208,172 @@ function accessory(recipe, g, s, face) {
2019
2208
  return;
2020
2209
  }
2021
2210
  }
2211
+ /**
2212
+ * A built-in headpiece: the row that meets the head goes behind the hair, the rest in front.
2213
+ * Turned, the far side of a pair is hidden, like a turned ear.
2214
+ */
2215
+ function drawHeadpiece(style, recipe, behind, front, s, far) {
2216
+ const cx = Math.round(C + s);
2217
+ const pair = (rows, left, right, y, pal, mirror = true) => {
2218
+ const tips = rows.slice(0, -1);
2219
+ if (far >= 0) {
2220
+ sprite(behind, rows, cx + left, y, pal);
2221
+ sprite(front, tips, cx + left, y, pal);
2222
+ }
2223
+ if (far <= 0) {
2224
+ sprite(behind, rows, cx + right, y, pal, mirror);
2225
+ sprite(front, tips, cx + right, y, pal, mirror);
2226
+ }
2227
+ };
2228
+ switch (style) {
2229
+ case "catears":
2230
+ pair([
2231
+ "X...",
2232
+ "XX..",
2233
+ "XPX.",
2234
+ "XXXX"
2235
+ ], -9, 5, 2, {
2236
+ X: recipe.hairColor,
2237
+ P: "#f4a3b5"
2238
+ });
2239
+ return;
2240
+ case "bunnyears":
2241
+ pair([
2242
+ "WW",
2243
+ "WP",
2244
+ "WP",
2245
+ "WP",
2246
+ "WW"
2247
+ ], -6, 4, 0, {
2248
+ W: "#f4f1ec",
2249
+ P: "#f4a3b5"
2250
+ });
2251
+ return;
2252
+ case "horseears":
2253
+ pair([
2254
+ ".X.",
2255
+ "XPX",
2256
+ "XPX",
2257
+ "XXX"
2258
+ ], -9, 6, 1, {
2259
+ X: shade(recipe.hairColor, .9),
2260
+ P: "#f4a3b5"
2261
+ }, false);
2262
+ return;
2263
+ case "horns":
2264
+ pair([
2265
+ ".H",
2266
+ "HH",
2267
+ "Hh"
2268
+ ], -8, 6, 1, {
2269
+ H: "#5b3a6e",
2270
+ h: "#7d5694"
2271
+ });
2272
+ return;
2273
+ case "halo": {
2274
+ const ring = (x, y) => ellipse(C + s, 1.6, 6.5, 1.8)(x, y) && !ellipse(C + s, 1.6, 4.4, .8)(x, y);
2275
+ paint(behind, (x, y) => ring(x, y) && y < 2, GOLD);
2276
+ paint(front, (x, y) => ring(x, y) && y >= 2, GOLD);
2277
+ return;
2278
+ }
2279
+ case "wings":
2280
+ pair([
2281
+ "W..",
2282
+ "WW.",
2283
+ "WwW",
2284
+ ".ww"
2285
+ ], -13, 10, 7, {
2286
+ W: "#f4f1ec",
2287
+ w: "#cfd6e2"
2288
+ });
2289
+ return;
2290
+ }
2291
+ }
2292
+ const ANIMALS = {
2293
+ cat: {
2294
+ ears: [
2295
+ "E...",
2296
+ "EE..",
2297
+ "EiE.",
2298
+ "EEEE"
2299
+ ],
2300
+ x: -9,
2301
+ y: 2,
2302
+ inner: "pink"
2303
+ },
2304
+ fox: {
2305
+ ears: [
2306
+ "D....",
2307
+ "EE...",
2308
+ "EiE..",
2309
+ "EiiE.",
2310
+ "EEEEE"
2311
+ ],
2312
+ x: -10,
2313
+ y: 1,
2314
+ inner: "fur"
2315
+ },
2316
+ rabbit: {
2317
+ ears: [
2318
+ "EE",
2319
+ "Ei",
2320
+ "Ei",
2321
+ "Ei",
2322
+ "Ei",
2323
+ "EE"
2324
+ ],
2325
+ x: -6,
2326
+ y: 0,
2327
+ inner: "pink"
2328
+ },
2329
+ bear: {
2330
+ ears: [
2331
+ ".EE.",
2332
+ "EiiE",
2333
+ "EEEE"
2334
+ ],
2335
+ x: -10,
2336
+ y: 3,
2337
+ inner: "fur"
2338
+ },
2339
+ dog: {
2340
+ ears: [
2341
+ "EEE.",
2342
+ "EEEE",
2343
+ "EEEE",
2344
+ ".EEE",
2345
+ "..EE"
2346
+ ],
2347
+ x: -13,
2348
+ y: 7,
2349
+ inner: "fur",
2350
+ side: true
2351
+ }
2352
+ };
2353
+ function patternMask(recipe, s, fs, face) {
2354
+ const cx = C + s;
2355
+ const mid = C + fs * .6;
2356
+ switch (recipe.pattern) {
2357
+ case "tabby": {
2358
+ let top = 0;
2359
+ while (top < N && !face(Math.round(cx), top)) top++;
2360
+ return (x, y) => y >= top && y <= top + 3 && Math.abs(x + .5 - cx) <= 3 && (x + 40 - Math.round(cx)) % 2 === 0 || (y === 17 || y === 19) && Math.abs(x + .5 - mid) >= 6.5;
2361
+ }
2362
+ case "spots": return (x, y) => [
2363
+ [-7, 21],
2364
+ [-6, 22],
2365
+ [-5, 21],
2366
+ [5, 21],
2367
+ [6, 22],
2368
+ [7, 21],
2369
+ [-2, 23],
2370
+ [2, 23]
2371
+ ].some(([dx, dy]) => x === Math.round(cx) + dx && y === dy);
2372
+ case "patches": return (x, y) => ellipse(cx - 4, 14, 3.6, 3.2)(x, y) || ellipse(cx + 5, 9, 2.6, 2)(x, y);
2373
+ case "colorpoint": return (x, y) => (x + .5 - cx) ** 2 / 64 + (y + .5 - 16.6) ** 2 / 49 > .62;
2374
+ default: return () => false;
2375
+ }
2376
+ }
2022
2377
  const POINTED_EARS = {
2023
2378
  goblin: {
2024
2379
  rows: [
@@ -2135,10 +2490,11 @@ function renderFigure(recipe, yawDeg, options, omit) {
2135
2490
  return front(x - Math.round(d * (1 + Math.max(0, y - 20) * .4) - d * tuck), y);
2136
2491
  } : front;
2137
2492
  const flower = recipe.species === "flower";
2493
+ const animal = isAnimalSpecies(recipe.species) ? ANIMALS[recipe.species] : void 0;
2138
2494
  const hiddenNow = hiddenChoices(recipe);
2139
2495
  const drawnParts = new Map(REPLACE_PART_SLOTS.flatMap((slot) => {
2140
2496
  const part = wornPart(recipe, slot);
2141
- const shown = slot === "petals" || slot === "flowerBase" ? flower : !hiddenNow.includes(slot);
2497
+ const shown = slot === "petals" || slot === "flowerBase" ? flower : slot === "pattern" ? animal !== void 0 : !hiddenNow.includes(slot);
2142
2498
  return part && shown ? [[slot, part.front]] : [];
2143
2499
  }));
2144
2500
  const skip = (slot) => omit.has(slot) || drawnParts.has(slot);
@@ -2205,7 +2561,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2205
2561
  };
2206
2562
  const masks = drawnHair.length === 0 ? builtin : {
2207
2563
  ...builtin,
2208
- front: (x, y) => piece("bangs")(x, y) || piece("leftSideHair")(x, y) || piece("rightSideHair")(x, y),
2564
+ front: (x, y) => piece("bangs")(x, y) || piece("leftSideHair")(x, y) || piece("rightSideHair")(x, y) || (builtin.strand?.(x, y) ?? false),
2209
2565
  sides: [piece("leftSideHair"), piece("rightSideHair")],
2210
2566
  back: piece("backHair"),
2211
2567
  ...drawnHair.some((drawn) => drawn.slot === "backHair") ? { ties: [] } : {}
@@ -2244,8 +2600,18 @@ function renderFigure(recipe, yawDeg, options, omit) {
2244
2600
  placeCells(back, inked("backHair"), false);
2245
2601
  overlay(back, "petals");
2246
2602
  if (headpiece) placeCells(back, headpiece.back);
2603
+ const wornHead = flower || hiddenNow.includes("headpiece") ? void 0 : builtInHeadpiece(recipe);
2604
+ const headFront = blank();
2605
+ if (wornHead) drawHeadpiece(wornHead, recipe, back, headFront, s, turned ? d : 0);
2247
2606
  const head = blank();
2248
2607
  paint(head, face, skin);
2608
+ if (animal) {
2609
+ const pattern = patternMask(recipe, s, fs, face);
2610
+ if (!skip("pattern")) paint(head, (x, y) => face(x, y) && pattern(x, y), TONES[recipe.pattern === "patches" ? -2 : -1](skin));
2611
+ overlay(head, "pattern");
2612
+ const muzzle = ellipse(C + fs * .6, 20.6 + (recipe.height ?? 0), 3.9, 2.6);
2613
+ paint(head, (x, y) => face(x, y) && muzzle(x, y), TONES[2](skin));
2614
+ }
2249
2615
  const h = recipe.height ?? 0;
2250
2616
  if (recipe.beard && !flower && !skip("beard")) {
2251
2617
  const centre = C + fs * .6;
@@ -2263,7 +2629,17 @@ function renderFigure(recipe, yawDeg, options, omit) {
2263
2629
  return side < 0 ? xs[0] - 1 : xs.at(-1) + 1;
2264
2630
  };
2265
2631
  const pointed = recipe.species ? POINTED_EARS[recipe.species] : void 0;
2266
- if (flower) {} else if (pointed) {
2632
+ if (flower) {} else if (animal) {
2633
+ paint(head, masks.front, hairColor);
2634
+ const pal = {
2635
+ E: recipe.pattern === "colorpoint" || animal.side ? shade(skin, .72) : skin,
2636
+ i: animal.inner === "pink" ? "#f4a3b5" : shade(skin, .72),
2637
+ D: shade(skin, .45)
2638
+ };
2639
+ const cx = Math.round(C + s);
2640
+ const sides = animal.side && turned ? [-d] : [-1, 1];
2641
+ for (const side of sides) sprite(head, animal.ears, side < 0 ? cx + animal.x : cx - animal.x - animal.ears[0].length, animal.y, pal, side > 0);
2642
+ } else if (pointed) {
2267
2643
  const pal = {
2268
2644
  E: skin,
2269
2645
  e: shade(skin, .86),
@@ -2289,14 +2665,16 @@ function renderFigure(recipe, yawDeg, options, omit) {
2289
2665
  E: skin,
2290
2666
  e: shade(skin, .8)
2291
2667
  });
2292
- if (!pointed && !flower) paint(head, masks.front, hairColor);
2668
+ if (!pointed && !flower && !animal) paint(head, masks.front, hairColor);
2293
2669
  for (const [x, y] of masks.ties ?? []) sprite(back, ["T"], Math.round(x), y, { T: shade(recipe.shirtColor, .8) });
2294
- if (!flower && !skip("accessory")) accessory(recipe, head, s, face);
2670
+ if (!flower && !skip("accessory") && !hiddenNow.includes("accessory")) accessory(recipe, head, s, face);
2295
2671
  const shadeHair = (g) => {
2296
2672
  const out = g.map((r) => [...r]);
2297
2673
  const cells = g.flatMap((row, y) => row.flatMap((c, x) => c === hairColor ? [[x, y]] : []));
2298
- const xs = cells.map(([x]) => x);
2299
- const ys = cells.map(([, y]) => y);
2674
+ const framed = cells.filter(([x, y]) => !builtin.strand?.(x, y));
2675
+ const bounds = framed.length ? framed : cells;
2676
+ const xs = bounds.map(([x]) => x);
2677
+ const ys = bounds.map(([, y]) => y);
2300
2678
  const left = Math.min(...xs);
2301
2679
  const right = Math.max(...xs);
2302
2680
  const top = Math.min(...ys);
@@ -2325,7 +2703,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2325
2703
  }
2326
2704
  const ring = mix$1(hairColor, WHITE, .45);
2327
2705
  for (let x = Math.round(C + s) - 7; x <= Math.round(C + s) + 3; x++) {
2328
- const first = g.findIndex((row) => row[x] === hairColor);
2706
+ const first = g.findIndex((row, y) => row[x] === hairColor && !builtin.strand?.(x, y));
2329
2707
  if (first < 0) continue;
2330
2708
  const y = first + 2 + Math.round(Math.abs(x + .5 - (C + s - 2)) / 4);
2331
2709
  if (g[y]?.[x] === hairColor && x % 3 !== 0) out[y][x] = ring;
@@ -2338,6 +2716,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2338
2716
  const crown = blank();
2339
2717
  placeCells(crown, inked("all-front"), false);
2340
2718
  if (headpiece) placeCells(crown, headpiece.front);
2719
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (headFront[y][x]) crown[y][x] = headFront[y][x];
2341
2720
  const figure = blank();
2342
2721
  for (const layer of [
2343
2722
  back,
@@ -2407,7 +2786,8 @@ function renderFigure(recipe, yawDeg, options, omit) {
2407
2786
  sprite(features, fit(browRows, right.w), right.x, eyeTop - 2, { B: shade(hairColor, .55) }, true);
2408
2787
  }
2409
2788
  const cx = Math.round(C + fs * (turned ? .85 : .6));
2410
- if (flower) {} else if (recipe.species === "dwarf") sprite(features, [".N.", "NnN"], cx - 1, 17 + h, {
2789
+ if (flower) {} else if (animal) sprite(features, ["NNN", ".N."], Math.round(C + fs * .6) - 1, 18 + h, { N: mix$1(INK$1, skin, .2) });
2790
+ else if (recipe.species === "dwarf") sprite(features, [".N.", "NnN"], cx - 1, 17 + h, {
2411
2791
  N: shade(skin, .84),
2412
2792
  n: shade(skin, .72)
2413
2793
  });
@@ -2475,17 +2855,42 @@ function renderFigure(recipe, yawDeg, options, omit) {
2475
2855
  ], right.x + right.w, eyeTop + 5, { G: GOLD });
2476
2856
  }
2477
2857
  const full = clip(outline(figure));
2478
- AVATAR_PIECE_COLORS.forEach((key, i) => {
2479
- const color = recipe[key];
2480
- const mask = masks.sides?.[i];
2481
- if (color === void 0 || !mask || color.toLowerCase() === hairColor.toLowerCase()) return;
2858
+ const variants = /* @__PURE__ */ new Map();
2859
+ const recolor = (color) => {
2860
+ const cached = variants.get(color.toLowerCase());
2861
+ if (cached) return cached;
2482
2862
  const variant = blank();
2863
+ variants.set(color.toLowerCase(), variant);
2483
2864
  const plain = {
2484
2865
  ...recipe,
2485
2866
  hairColor: color
2486
2867
  };
2487
- for (const k of AVATAR_PIECE_COLORS) delete plain[k];
2868
+ for (const k of [...AVATAR_PIECE_COLORS, ...AVATAR_PIECE_COLORS_V4]) delete plain[k];
2488
2869
  for (const cell of renderFigure(plain, yawDeg, {}, omit).cells) variant[cell.y][cell.x] = cell.c;
2870
+ return variant;
2871
+ };
2872
+ if (recipe.assetVersion === 4) {
2873
+ const over = (x, y) => !!crown[y]?.[x] || !!drawnOver[y]?.[x] || !!eyes[y]?.[x] || !!glasses[y]?.[x];
2874
+ const hairAt = (x, y) => head[y]?.[x] === hairColor && !over(x, y);
2875
+ const backAt = (x, y) => back[y]?.[x] !== void 0 && figure[y][x] === back[y][x];
2876
+ const owners = [
2877
+ [flower ? void 0 : recipe.backHairColor, (x, y) => masks.back(x, y) && backAt(x, y)],
2878
+ [recipe.leftSideHairColor, (x, y) => !!masks.sides?.[0](x, y) && (hairAt(x, y) || backAt(x, y))],
2879
+ [recipe.rightSideHairColor, (x, y) => !!masks.sides?.[1](x, y) && (hairAt(x, y) || backAt(x, y))],
2880
+ [recipe.bangsColor, (x, y) => (builtin.pieces ? piece("bangs")(x, y) : false) && hairAt(x, y)],
2881
+ [recipe.strandColor, (x, y) => (builtin.strand?.(x, y) ?? false) && hairAt(x, y)]
2882
+ ];
2883
+ for (const [color, owns] of owners) {
2884
+ if (color === void 0 || color.toLowerCase() === hairColor.toLowerCase()) continue;
2885
+ const variant = recolor(color);
2886
+ const near = (x, y) => owns(x - 1, y) || owns(x + 1, y) || owns(x, y - 1) || owns(x, y + 1);
2887
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (full[y][x] && !over(x, y) && (owns(x, y) || !figure[y][x] && near(x, y))) full[y][x] = variant[y][x];
2888
+ }
2889
+ } else AVATAR_PIECE_COLORS.forEach((key, i) => {
2890
+ const color = recipe[key];
2891
+ const mask = masks.sides?.[i];
2892
+ if (color === void 0 || !mask || color.toLowerCase() === hairColor.toLowerCase()) return;
2893
+ const variant = recolor(color);
2489
2894
  const near = (x, y) => mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1);
2490
2895
  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];
2491
2896
  });
@@ -2560,6 +2965,57 @@ function hairPieceStart(recipe, slot) {
2560
2965
  back: []
2561
2966
  };
2562
2967
  }
2968
+ function cellSource(recipe) {
2969
+ return (c) => {
2970
+ for (const key of AVATAR_COLORS) for (const tone of [
2971
+ 0,
2972
+ -1,
2973
+ 1,
2974
+ -2,
2975
+ 2
2976
+ ]) if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone];
2977
+ return [c, 0];
2978
+ };
2979
+ }
2980
+ /**
2981
+ * The headpiece as a Custom Part to start drawing from: the drawn headpiece worn, or the
2982
+ * built-in headpiece flattened for this recipe in the front pose, its base on the back layer and
2983
+ * the rest on the front layer, so an unchanged copy renders identically facing front.
2984
+ */
2985
+ function headpieceStart(recipe) {
2986
+ const worn = wornPart(recipe, "headpiece");
2987
+ if (worn) return {
2988
+ slot: "headpiece",
2989
+ front: [...worn.front],
2990
+ back: [...worn.back]
2991
+ };
2992
+ const style = builtInHeadpiece(recipe);
2993
+ const behind = blank();
2994
+ const over = blank();
2995
+ if (style) drawHeadpiece(style, recipe, behind, over, 0, 0);
2996
+ const source = cellSource(recipe);
2997
+ const front = [];
2998
+ const back = [];
2999
+ for (let y = 0; y < 16; y++) for (let x = 0; x < N; x++) {
3000
+ const c = over[y][x];
3001
+ const b = behind[y][x];
3002
+ if (c) front.push([
3003
+ x,
3004
+ y,
3005
+ ...source(c)
3006
+ ]);
3007
+ if (b && b !== c) back.push([
3008
+ x,
3009
+ y,
3010
+ ...source(b)
3011
+ ]);
3012
+ }
3013
+ return {
3014
+ slot: "headpiece",
3015
+ front,
3016
+ back
3017
+ };
3018
+ }
2563
3019
  /**
2564
3020
  * The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn
2565
3021
  * there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a
@@ -2577,16 +3033,7 @@ function replacePartStart(recipe, slot) {
2577
3033
  const layer = slot === "glasses" ? "glasses" : slot === "nose" || slot === "cheeks" ? "features" : slot === "petals" ? "back" : slot === "outfit" || slot === "flowerBase" ? "body" : "figure";
2578
3034
  const shown = renderFigure(recipe, 0, {}, /* @__PURE__ */ new Set()).grids[layer];
2579
3035
  const without = renderFigure(recipe, 0, {}, new Set([slot])).grids[layer];
2580
- const source = (c) => {
2581
- for (const key of AVATAR_COLORS) for (const tone of [
2582
- 0,
2583
- -1,
2584
- 1,
2585
- -2,
2586
- 2
2587
- ]) if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone];
2588
- return [c, 0];
2589
- };
3036
+ const source = cellSource(recipe);
2590
3037
  const front = [];
2591
3038
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
2592
3039
  const c = shown[y][x];
@@ -3125,5 +3572,5 @@ function symbolArtCells(rows, color) {
3125
3572
  }
3126
3573
 
3127
3574
  //#endregion
3128
- export { AVATAR_COLORS, AVATAR_EXTRA_PARTS, AVATAR_HAIR_PARTS, AVATAR_PARTS, AVATAR_PARTS_V2, AVATAR_PIECE_COLORS, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, AVATAR_SPECIES_SWATCHES, AVATAR_SWATCHES, AVATAR_TURNS, CUSTOM_PART_KEYS, DEFAULT_RECIPE, HAIR_PART_SLOTS, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, REPLACE_PART_SLOTS, canonicalCustomPart, canonicalRecipe, createCustomPart, createSeededRecipe, createSeededRecipeV2, customPartId, detailedRecipe, ditherThreshold, emptyPartLayer, faceCells, fillPartLayer, gradientPartLayer, hairPieceStart, hiddenChoices, isHairPartSlot, isPixelAvatarRecipe, isPixelCustomPart, isReplacePartSlot, mirrorPartX, noisePartLayer, paintPartLayer, partCells, partLayer, partLinePoints, partRectPoints, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, replacePartStart, seededRandom, seededRecipe, seededRecipeV2, shadePartLayer, symbolArtCells, withCustomPart, withHeadpiece, withSpecies, wornPart };
3575
+ export { AVATAR_ANIMAL_SPECIES, AVATAR_COLORS, AVATAR_EXTRA_PARTS, AVATAR_HAIR_PARTS, AVATAR_HEADPIECES, AVATAR_PARTS, AVATAR_PARTS_V2, AVATAR_PATTERNS, AVATAR_PIECE_COLORS, AVATAR_PIECE_COLORS_V4, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, AVATAR_SPECIES_SWATCHES, AVATAR_STRANDS, AVATAR_SWATCHES, AVATAR_TURNS, CUSTOM_PART_KEYS, DEFAULT_RECIPE, HAIR_PART_SLOTS, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, REPLACE_PART_SLOTS, builtInHeadpiece, canonicalCustomPart, canonicalRecipe, createCustomPart, createSeededRecipe, createSeededRecipeV2, customPartId, detailedRecipe, ditherThreshold, emptyPartLayer, faceCells, fillPartLayer, gradientPartLayer, hairPieceStart, headpieceStart, hiddenChoices, isAnimalSpecies, isHairPartSlot, isPixelAvatarRecipe, isPixelCustomPart, isReplacePartSlot, mirrorPartX, noisePartLayer, paintPartLayer, partCells, partLayer, partLinePoints, partRectPoints, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, replacePartStart, seededRandom, seededRecipe, seededRecipeV2, shadePartLayer, symbolArtCells, withBuiltInHeadpiece, withCustomPart, withHeadpiece, withPieces, withSpecies, wornPart };
3129
3576
  //# sourceMappingURL=index.js.map