@botharness/pixel-avatar 0.7.0 → 0.9.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
@@ -333,6 +333,33 @@ const AVATAR_SPECIES = [
333
333
  ];
334
334
  /** Optional per-piece hair colors (asset version 2); an absent piece uses `hairColor`. */
335
335
  const AVATAR_PIECE_COLORS = ["leftSideHairColor", "rightSideHairColor"];
336
+ /**
337
+ * Asset version 4 adds a color for every other hair piece; an absent piece uses `hairColor`.
338
+ * `strandColor` colors the single strand (`strand`).
339
+ */
340
+ const AVATAR_PIECE_COLORS_V4 = [
341
+ "bangsColor",
342
+ "backHairColor",
343
+ "strandColor"
344
+ ];
345
+ /** A single strand of hair standing up from the crown (asset version 4). */
346
+ const AVATAR_STRANDS = [
347
+ "ahoge",
348
+ "curl",
349
+ "double"
350
+ ];
351
+ /**
352
+ * Built-in headpieces (asset version 4), worn in the headpiece slot alongside an accessory and
353
+ * drawn partly behind the hair. The first five were accessories in earlier versions.
354
+ */
355
+ const AVATAR_HEADPIECES = [
356
+ "catears",
357
+ "bunnyears",
358
+ "horseears",
359
+ "horns",
360
+ "halo",
361
+ "wings"
362
+ ];
336
363
  /** Part choices that exist only in asset version 2, added after every version 1 choice. */
337
364
  const AVATAR_PARTS_V2 = {
338
365
  ...AVATAR_PARTS,
@@ -410,17 +437,22 @@ function isPixelAvatarRecipe(value) {
410
437
  const r = value;
411
438
  const detailed = DETAIL_KEYS.filter((key) => Object.hasOwn(r, key)).length;
412
439
  if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false;
440
+ const v4 = r["assetVersion"] === 4;
413
441
  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;
442
+ const v2 = r["assetVersion"] === 2 || v3 || v4;
443
+ const builtInHead = v4 && typeof r["headpiece"] === "string";
444
+ if (builtInHead && !AVATAR_HEADPIECES.includes(r["headpiece"])) return false;
445
+ const worn = PART_KEY_ENTRIES.filter(([slot, key]) => Object.hasOwn(r, key) && !(slot === "headpiece" && builtInHead));
446
+ if (v3 ? worn.length === 0 : !v4 && worn.length !== 0) return false;
447
+ const newer = [...AVATAR_PIECE_COLORS_V4, "strand"].filter((key) => Object.hasOwn(r, key));
448
+ 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"]))) return false;
417
449
  for (const [slot, key] of worn) {
418
450
  const part = r[key];
419
451
  if (!isPixelCustomPart(part) || part.slot !== slot) return false;
420
452
  }
421
453
  const pieces = AVATAR_PIECE_COLORS.filter((key) => Object.hasOwn(r, key));
422
454
  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;
455
+ const v2Keys = v2 ? 2 + pieces.length + extras.length + worn.length + newer.length + (builtInHead ? 1 : 0) : 0;
424
456
  if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed + v2Keys) return false;
425
457
  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;
426
458
  const catalog = v2 ? AVATAR_PARTS_V2 : AVATAR_PARTS;
@@ -445,9 +477,13 @@ function canonicalRecipe(recipe) {
445
477
  canonical["rightSideHair"] = recipe.rightSideHair;
446
478
  for (const key of AVATAR_PIECE_COLORS) if (recipe[key] !== void 0) canonical[key] = recipe[key].toLowerCase();
447
479
  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) {
480
+ if (recipe.assetVersion === 3 || recipe.assetVersion === 4) for (const [, key] of PART_KEY_ENTRIES) {
449
481
  const part = recipe[key];
450
- if (part !== void 0) canonical[key] = canonicalCustomPart(part);
482
+ if (part !== void 0) canonical[key] = typeof part === "string" ? part : canonicalCustomPart(part);
483
+ }
484
+ if (recipe.assetVersion === 4) {
485
+ if (recipe.strand !== void 0) canonical["strand"] = recipe.strand;
486
+ for (const key of AVATAR_PIECE_COLORS_V4) if (recipe[key] !== void 0) canonical[key] = recipe[key].toLowerCase();
451
487
  }
452
488
  }
453
489
  return canonical;
@@ -464,8 +500,12 @@ const HAIR_PIECES = [
464
500
  * the species or headwear changes back.
465
501
  */
466
502
  function hiddenChoices(recipe) {
503
+ const strand = recipe.strand && (recipe.species === "flower" || recipe.bangs !== "none") ? ["strand"] : [];
504
+ const bald = recipe.strand && recipe.bangs === "none" ? ["strand"] : [];
467
505
  if (recipe.species === "flower") return [
468
506
  ...HAIR_PIECES,
507
+ ...strand,
508
+ ...typeof recipe.headpiece === "string" ? ["headpiece"] : [],
469
509
  "outfit",
470
510
  "accessory",
471
511
  "eyes",
@@ -476,8 +516,9 @@ function hiddenChoices(recipe) {
476
516
  ...recipe.beard || wornPart(recipe, "beard") ? ["beard"] : []
477
517
  ];
478
518
  const hidden = [];
479
- if ((recipe.accessory === "helmet" || recipe.accessory === "hood") && !wornPart(recipe, "accessory")) hidden.push(...HAIR_PIECES);
519
+ if ((recipe.accessory === "helmet" || recipe.accessory === "hood") && !wornPart(recipe, "accessory")) hidden.push(...HAIR_PIECES, ...strand);
480
520
  if (recipe.species === "dwarf") hidden.push("nose");
521
+ if (!hidden.includes("strand")) hidden.push(...bald);
481
522
  return hidden;
482
523
  }
483
524
  function withSpecies(recipe, species) {
@@ -486,16 +527,12 @@ function withSpecies(recipe, species) {
486
527
  const suggested = AVATAR_SPECIES_SWATCHES[previous].includes(recipe.skinColor.toLowerCase());
487
528
  return {
488
529
  ...detailed,
489
- assetVersion: recipe.assetVersion === 3 ? 3 : 2,
530
+ assetVersion: recipe.assetVersion === 1 ? 2 : recipe.assetVersion,
490
531
  species,
491
532
  rightSideHair: detailed.rightSideHair ?? detailed.sideHair,
492
533
  skinColor: suggested && previous !== species ? AVATAR_SPECIES_SWATCHES[species][0] : recipe.skinColor
493
534
  };
494
535
  }
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
536
  function withCustomPart(recipe, slot, part) {
500
537
  const base = { ...recipe.assetVersion === 1 ? withSpecies(recipe, "human") : recipe };
501
538
  const key = CUSTOM_PART_KEYS[slot];
@@ -504,19 +541,51 @@ function withCustomPart(recipe, slot, part) {
504
541
  ...part,
505
542
  slot
506
543
  });
544
+ if (recipe.assetVersion === 4) return base;
507
545
  const wearing = PART_KEY_ENTRIES.some(([, k]) => base[k] !== void 0);
508
546
  return {
509
547
  ...base,
510
548
  assetVersion: wearing ? 3 : 2
511
549
  };
512
550
  }
551
+ const MOVED_HEADPIECES = AVATAR_HEADPIECES;
552
+ /**
553
+ * Returns the recipe as asset version 4, keeping every choice. An accessory that is now a
554
+ * headpiece moves into the empty headpiece slot, freeing the accessory slot; call this only when
555
+ * the Human edits, so saved recipes keep rendering as they were.
556
+ */
557
+ function withPieces(recipe) {
558
+ if (recipe.assetVersion === 4) return recipe;
559
+ const base = {
560
+ ...recipe.assetVersion === 1 ? withSpecies(recipe, "human") : recipe,
561
+ assetVersion: 4
562
+ };
563
+ if (base["headpiece"] === void 0 && MOVED_HEADPIECES.includes(recipe.accessory)) {
564
+ base["headpiece"] = recipe.accessory;
565
+ base["accessory"] = "none";
566
+ }
567
+ return base;
568
+ }
569
+ /** Returns the recipe as asset version 4 wearing a built-in headpiece, or none. */
570
+ function withBuiltInHeadpiece(recipe, headpiece) {
571
+ const base = { ...withPieces(recipe) };
572
+ delete base["headpiece"];
573
+ if (headpiece !== void 0) base["headpiece"] = headpiece;
574
+ return base;
575
+ }
576
+ /** The built-in headpiece worn, if any. */
577
+ function builtInHeadpiece(recipe) {
578
+ return typeof recipe.headpiece === "string" ? recipe.headpiece : void 0;
579
+ }
513
580
  /** `withCustomPart` for the headpiece slot. */
514
581
  function withHeadpiece(recipe, part) {
515
582
  return withCustomPart(recipe, "headpiece", part);
516
583
  }
517
584
  /** The Custom Part worn in a slot, if any. */
518
585
  function wornPart(recipe, slot) {
519
- return recipe.assetVersion === 3 ? recipe[CUSTOM_PART_KEYS[slot]] : void 0;
586
+ if (recipe.assetVersion !== 3 && recipe.assetVersion !== 4) return void 0;
587
+ const part = recipe[CUSTOM_PART_KEYS[slot]];
588
+ return typeof part === "string" ? void 0 : part;
520
589
  }
521
590
  function detailedRecipe(recipe) {
522
591
  if (recipe.bangs !== void 0) return recipe;
@@ -1080,6 +1149,182 @@ function fillPartLayer(slot, layer, x, y, ink, mirror = false) {
1080
1149
  }
1081
1150
  return out;
1082
1151
  }
1152
+ /** Pixel-perfect line points (Bresenham). With `snap`, the end snaps to 0°, 45° or 90°. */
1153
+ function partLinePoints(from, to, snap = false) {
1154
+ let [x1, y1] = to;
1155
+ const [x0, y0] = from;
1156
+ if (snap) {
1157
+ const dx = x1 - x0;
1158
+ const dy = y1 - y0;
1159
+ const ax = Math.abs(dx);
1160
+ const ay = Math.abs(dy);
1161
+ if (ax > ay * 2) y1 = y0;
1162
+ else if (ay > ax * 2) x1 = x0;
1163
+ else {
1164
+ const m = Math.max(ax, ay);
1165
+ x1 = x0 + Math.sign(dx) * m;
1166
+ y1 = y0 + Math.sign(dy) * m;
1167
+ }
1168
+ }
1169
+ const points = [];
1170
+ const dx = Math.abs(x1 - x0);
1171
+ const dy = -Math.abs(y1 - y0);
1172
+ const sx = x0 < x1 ? 1 : -1;
1173
+ const sy = y0 < y1 ? 1 : -1;
1174
+ let error = dx + dy;
1175
+ let [x, y] = [x0, y0];
1176
+ for (;;) {
1177
+ points.push([x, y]);
1178
+ if (x === x1 && y === y1) break;
1179
+ const e2 = 2 * error;
1180
+ if (e2 >= dy) {
1181
+ error += dy;
1182
+ x += sx;
1183
+ }
1184
+ if (e2 <= dx) {
1185
+ error += dx;
1186
+ y += sy;
1187
+ }
1188
+ }
1189
+ return points;
1190
+ }
1191
+ /** Rectangle outline points between two corners. With `square`, the far corner makes a square. */
1192
+ function partRectPoints(from, to, square = false) {
1193
+ const [x0, y0] = from;
1194
+ let [x1, y1] = to;
1195
+ if (square) {
1196
+ const m = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
1197
+ x1 = x0 + (x1 < x0 ? -m : m);
1198
+ y1 = y0 + (y1 < y0 ? -m : m);
1199
+ }
1200
+ const [left, right] = [Math.min(x0, x1), Math.max(x0, x1)];
1201
+ const [top, bottom] = [Math.min(y0, y1), Math.max(y0, y1)];
1202
+ const points = /* @__PURE__ */ new Map();
1203
+ for (let x = left; x <= right; x++) for (const y of [top, bottom]) points.set(`${x},${y}`, [x, y]);
1204
+ for (let y = top; y <= bottom; y++) for (const x of [left, right]) points.set(`${x},${y}`, [x, y]);
1205
+ return [...points.values()];
1206
+ }
1207
+ const BAYER4 = [
1208
+ [
1209
+ 0,
1210
+ 8,
1211
+ 2,
1212
+ 10
1213
+ ],
1214
+ [
1215
+ 12,
1216
+ 4,
1217
+ 14,
1218
+ 6
1219
+ ],
1220
+ [
1221
+ 3,
1222
+ 11,
1223
+ 1,
1224
+ 9
1225
+ ],
1226
+ [
1227
+ 15,
1228
+ 7,
1229
+ 13,
1230
+ 5
1231
+ ]
1232
+ ];
1233
+ const BAYER2 = [[0, 2], [3, 1]];
1234
+ /** The ordered-dither threshold in [0, 1) for a cell. */
1235
+ function ditherThreshold(x, y, size = 4) {
1236
+ return size === 4 ? (BAYER4[y & 3][x & 3] + .5) / 16 : (BAYER2[y & 1][x & 1] + .5) / 4;
1237
+ }
1238
+ /**
1239
+ * A linear gradient over the 4-connected region of the start cell: each cell's position along
1240
+ * `from`→`to` steps through the tones between `fromTone` and `toTone`, with ordered dithering
1241
+ * between neighboring tones. No RGB is blended; the result is ordinary cells.
1242
+ */
1243
+ function gradientPartLayer(slot, layer, from, to, color, fromTone, toTone, options = {}) {
1244
+ const { width, height } = PART_SLOTS[slot];
1245
+ const out = layer.map((row) => [...row]);
1246
+ const [fx, fy] = from;
1247
+ const region = (sx, sy) => {
1248
+ if (sx < 0 || sx >= width || sy < 0 || sy >= height) return [];
1249
+ const target = layer[sy][sx];
1250
+ const seen = /* @__PURE__ */ new Set();
1251
+ const cells = [];
1252
+ const stack = [[sx, sy]];
1253
+ while (stack.length) {
1254
+ const [x, y] = stack.pop();
1255
+ if (x < 0 || x >= width || y < 0 || y >= height || seen.has(y * width + x)) continue;
1256
+ if (!same(layer[y][x], target)) continue;
1257
+ seen.add(y * width + x);
1258
+ cells.push([x, y]);
1259
+ stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
1260
+ }
1261
+ return cells;
1262
+ };
1263
+ const paint = (cells, start, end, ditherX) => {
1264
+ const vx = end[0] - start[0];
1265
+ const vy = end[1] - start[1];
1266
+ const length = vx * vx + vy * vy;
1267
+ const steps = toTone - fromTone;
1268
+ for (const [x, y] of cells) {
1269
+ const position = (length === 0 ? 0 : Math.min(1, Math.max(0, ((x - start[0]) * vx + (y - start[1]) * vy) / length))) * Math.abs(steps);
1270
+ const base = Math.floor(position);
1271
+ const up = position - base > ditherThreshold(ditherX(x), y, options.dither ?? 4) ? 1 : 0;
1272
+ const tone = fromTone + Math.sign(steps) * Math.min(Math.abs(steps), base + up);
1273
+ out[y][x] = {
1274
+ color,
1275
+ tone
1276
+ };
1277
+ }
1278
+ };
1279
+ const own = region(fx, fy);
1280
+ if (!options.mirror) {
1281
+ paint(own, from, to, (x) => x);
1282
+ return out;
1283
+ }
1284
+ const mx = mirrorPartX(slot, fx);
1285
+ const mirroredCells = region(mx, fy);
1286
+ const inOwn = new Set(own.map(([x, y]) => y * width + x));
1287
+ const inMirrored = new Set(mirroredCells.map(([x, y]) => y * width + x));
1288
+ const startSide = (x) => x < width / 2 === fx < width / 2;
1289
+ paint(own.filter(([x, y]) => startSide(x) || !inMirrored.has(y * width + x)), from, to, (x) => x);
1290
+ paint(mirroredCells.filter(([x, y]) => !startSide(x) || !inOwn.has(y * width + x)), [mx, fy], [mirrorPartX(slot, to[0]), to[1]], (x) => mirrorPartX(slot, x));
1291
+ return out;
1292
+ }
1293
+ /**
1294
+ * Moves the tone of colored cells by ±1 at random, for texture. The same seed gives the same
1295
+ * result; the seed is used only now, and the result is ordinary cells. With `mirror`, each cell
1296
+ * and its mirror get the same change.
1297
+ */
1298
+ function noisePartLayer(slot, layer, amount, seed, mirror = false) {
1299
+ const random = seededRandom(seed);
1300
+ const rolls = layer.map((row) => row.map(() => [random(), random() < .5 ? -1 : 1]));
1301
+ return layer.map((row, y) => row.map((ink, x) => {
1302
+ const source = mirror ? Math.min(x, mirrorPartX(slot, x)) : x;
1303
+ const [roll, direction] = rolls[y][source];
1304
+ if (!ink || roll >= amount) return ink;
1305
+ return {
1306
+ color: ink.color,
1307
+ tone: Math.max(-2, Math.min(2, ink.tone + direction))
1308
+ };
1309
+ }));
1310
+ }
1311
+ /** Moves the tone of each colored cell on the points by `delta` once; empty cells are skipped. */
1312
+ function shadePartLayer(slot, layer, points, delta, mirror = false) {
1313
+ const { width, height } = PART_SLOTS[slot];
1314
+ const out = layer.map((row) => [...row]);
1315
+ const done = /* @__PURE__ */ new Set();
1316
+ for (const [x, y] of points) for (const px of mirror ? [x, mirrorPartX(slot, x)] : [x]) {
1317
+ const key = `${px},${y}`;
1318
+ if (done.has(key) || px < 0 || px >= width || y < 0 || y >= height) continue;
1319
+ done.add(key);
1320
+ const ink = layer[y][px];
1321
+ if (ink) out[y][px] = {
1322
+ color: ink.color,
1323
+ tone: Math.max(-2, Math.min(2, ink.tone + delta))
1324
+ };
1325
+ }
1326
+ return out;
1327
+ }
1083
1328
  const K = Uint32Array.from("428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 d807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da 983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2".split(" ").map((h) => Number.parseInt(h, 16)));
1084
1329
  function sha256(text) {
1085
1330
  const bytes = new TextEncoder().encode(text);
@@ -1483,6 +1728,39 @@ function hair(recipe, s, b) {
1483
1728
  };
1484
1729
  }
1485
1730
  }
1731
+ const STRANDS = {
1732
+ ahoge: {
1733
+ rows: [
1734
+ "XX.",
1735
+ ".XX",
1736
+ ".XX"
1737
+ ],
1738
+ dx: 0
1739
+ },
1740
+ curl: {
1741
+ rows: [
1742
+ ".XX.",
1743
+ "X..X",
1744
+ "..XX",
1745
+ ".X.."
1746
+ ],
1747
+ dx: -1
1748
+ },
1749
+ double: {
1750
+ rows: [
1751
+ "X...X",
1752
+ ".X.X.",
1753
+ ".X.X."
1754
+ ],
1755
+ dx: -2
1756
+ }
1757
+ };
1758
+ function strandMask(style, s) {
1759
+ const { rows, dx } = STRANDS[style];
1760
+ const top = 4 - rows.length;
1761
+ const left = Math.round(C + s) + dx;
1762
+ return (x, y) => rows[y - top]?.[x - left] === "X";
1763
+ }
1486
1764
  function detailedHair(recipe, s, b) {
1487
1765
  const of = (style) => hair({
1488
1766
  ...recipe,
@@ -1497,8 +1775,11 @@ function detailedHair(recipe, s, b) {
1497
1775
  const sideOf = (sign) => (x, y) => y > 7 && sign * (x + .5 - C - s) >= 7 && lengthen((sign < 0 ? side : right).front)(x, y);
1498
1776
  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);
1499
1777
  const behind = length === 0 ? back.back : lengthen(back.back);
1778
+ const tuft = recipe.strand && recipe.bangs !== "none" ? strandMask(recipe.strand, s) : void 0;
1779
+ const strand = tuft ? (x, y) => tuft(x, y) && !fringe(x, y) && !sideOf(-1)(x, y) && !sideOf(1)(x, y) : void 0;
1500
1780
  return {
1501
- front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y),
1781
+ front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y) || (strand?.(x, y) ?? false),
1782
+ ...strand ? { strand } : {},
1502
1783
  sides: [sideOf(-1), sideOf(1)],
1503
1784
  pieces: {
1504
1785
  bangs: fringe,
@@ -1843,6 +2124,87 @@ function accessory(recipe, g, s, face) {
1843
2124
  return;
1844
2125
  }
1845
2126
  }
2127
+ /**
2128
+ * A built-in headpiece: the row that meets the head goes behind the hair, the rest in front.
2129
+ * Turned, the far side of a pair is hidden, like a turned ear.
2130
+ */
2131
+ function drawHeadpiece(style, recipe, behind, front, s, far) {
2132
+ const cx = Math.round(C + s);
2133
+ const pair = (rows, left, right, y, pal, mirror = true) => {
2134
+ const tips = rows.slice(0, -1);
2135
+ if (far >= 0) {
2136
+ sprite(behind, rows, cx + left, y, pal);
2137
+ sprite(front, tips, cx + left, y, pal);
2138
+ }
2139
+ if (far <= 0) {
2140
+ sprite(behind, rows, cx + right, y, pal, mirror);
2141
+ sprite(front, tips, cx + right, y, pal, mirror);
2142
+ }
2143
+ };
2144
+ switch (style) {
2145
+ case "catears":
2146
+ pair([
2147
+ "X...",
2148
+ "XX..",
2149
+ "XPX.",
2150
+ "XXXX"
2151
+ ], -9, 5, 2, {
2152
+ X: recipe.hairColor,
2153
+ P: "#f4a3b5"
2154
+ });
2155
+ return;
2156
+ case "bunnyears":
2157
+ pair([
2158
+ "WW",
2159
+ "WP",
2160
+ "WP",
2161
+ "WP",
2162
+ "WW"
2163
+ ], -6, 4, 0, {
2164
+ W: "#f4f1ec",
2165
+ P: "#f4a3b5"
2166
+ });
2167
+ return;
2168
+ case "horseears":
2169
+ pair([
2170
+ ".X.",
2171
+ "XPX",
2172
+ "XPX",
2173
+ "XXX"
2174
+ ], -9, 6, 1, {
2175
+ X: shade(recipe.hairColor, .9),
2176
+ P: "#f4a3b5"
2177
+ }, false);
2178
+ return;
2179
+ case "horns":
2180
+ pair([
2181
+ ".H",
2182
+ "HH",
2183
+ "Hh"
2184
+ ], -8, 6, 1, {
2185
+ H: "#5b3a6e",
2186
+ h: "#7d5694"
2187
+ });
2188
+ return;
2189
+ case "halo": {
2190
+ 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);
2191
+ paint(behind, (x, y) => ring(x, y) && y < 2, GOLD);
2192
+ paint(front, (x, y) => ring(x, y) && y >= 2, GOLD);
2193
+ return;
2194
+ }
2195
+ case "wings":
2196
+ pair([
2197
+ "W..",
2198
+ "WW.",
2199
+ "WwW",
2200
+ ".ww"
2201
+ ], -13, 10, 7, {
2202
+ W: "#f4f1ec",
2203
+ w: "#cfd6e2"
2204
+ });
2205
+ return;
2206
+ }
2207
+ }
1846
2208
  const POINTED_EARS = {
1847
2209
  goblin: {
1848
2210
  rows: [
@@ -2029,7 +2391,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2029
2391
  };
2030
2392
  const masks = drawnHair.length === 0 ? builtin : {
2031
2393
  ...builtin,
2032
- front: (x, y) => piece("bangs")(x, y) || piece("leftSideHair")(x, y) || piece("rightSideHair")(x, y),
2394
+ front: (x, y) => piece("bangs")(x, y) || piece("leftSideHair")(x, y) || piece("rightSideHair")(x, y) || (builtin.strand?.(x, y) ?? false),
2033
2395
  sides: [piece("leftSideHair"), piece("rightSideHair")],
2034
2396
  back: piece("backHair"),
2035
2397
  ...drawnHair.some((drawn) => drawn.slot === "backHair") ? { ties: [] } : {}
@@ -2068,6 +2430,9 @@ function renderFigure(recipe, yawDeg, options, omit) {
2068
2430
  placeCells(back, inked("backHair"), false);
2069
2431
  overlay(back, "petals");
2070
2432
  if (headpiece) placeCells(back, headpiece.back);
2433
+ const wornHead = flower ? void 0 : builtInHeadpiece(recipe);
2434
+ const headFront = blank();
2435
+ if (wornHead) drawHeadpiece(wornHead, recipe, back, headFront, s, turned ? d : 0);
2071
2436
  const head = blank();
2072
2437
  paint(head, face, skin);
2073
2438
  const h = recipe.height ?? 0;
@@ -2119,8 +2484,10 @@ function renderFigure(recipe, yawDeg, options, omit) {
2119
2484
  const shadeHair = (g) => {
2120
2485
  const out = g.map((r) => [...r]);
2121
2486
  const cells = g.flatMap((row, y) => row.flatMap((c, x) => c === hairColor ? [[x, y]] : []));
2122
- const xs = cells.map(([x]) => x);
2123
- const ys = cells.map(([, y]) => y);
2487
+ const framed = cells.filter(([x, y]) => !builtin.strand?.(x, y));
2488
+ const bounds = framed.length ? framed : cells;
2489
+ const xs = bounds.map(([x]) => x);
2490
+ const ys = bounds.map(([, y]) => y);
2124
2491
  const left = Math.min(...xs);
2125
2492
  const right = Math.max(...xs);
2126
2493
  const top = Math.min(...ys);
@@ -2149,7 +2516,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2149
2516
  }
2150
2517
  const ring = mix$1(hairColor, WHITE, .45);
2151
2518
  for (let x = Math.round(C + s) - 7; x <= Math.round(C + s) + 3; x++) {
2152
- const first = g.findIndex((row) => row[x] === hairColor);
2519
+ const first = g.findIndex((row, y) => row[x] === hairColor && !builtin.strand?.(x, y));
2153
2520
  if (first < 0) continue;
2154
2521
  const y = first + 2 + Math.round(Math.abs(x + .5 - (C + s - 2)) / 4);
2155
2522
  if (g[y]?.[x] === hairColor && x % 3 !== 0) out[y][x] = ring;
@@ -2162,6 +2529,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2162
2529
  const crown = blank();
2163
2530
  placeCells(crown, inked("all-front"), false);
2164
2531
  if (headpiece) placeCells(crown, headpiece.front);
2532
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (headFront[y][x]) crown[y][x] = headFront[y][x];
2165
2533
  const figure = blank();
2166
2534
  for (const layer of [
2167
2535
  back,
@@ -2299,17 +2667,42 @@ function renderFigure(recipe, yawDeg, options, omit) {
2299
2667
  ], right.x + right.w, eyeTop + 5, { G: GOLD });
2300
2668
  }
2301
2669
  const full = clip(outline(figure));
2302
- AVATAR_PIECE_COLORS.forEach((key, i) => {
2303
- const color = recipe[key];
2304
- const mask = masks.sides?.[i];
2305
- if (color === void 0 || !mask || color.toLowerCase() === hairColor.toLowerCase()) return;
2670
+ const variants = /* @__PURE__ */ new Map();
2671
+ const recolor = (color) => {
2672
+ const cached = variants.get(color.toLowerCase());
2673
+ if (cached) return cached;
2306
2674
  const variant = blank();
2675
+ variants.set(color.toLowerCase(), variant);
2307
2676
  const plain = {
2308
2677
  ...recipe,
2309
2678
  hairColor: color
2310
2679
  };
2311
- for (const k of AVATAR_PIECE_COLORS) delete plain[k];
2680
+ for (const k of [...AVATAR_PIECE_COLORS, ...AVATAR_PIECE_COLORS_V4]) delete plain[k];
2312
2681
  for (const cell of renderFigure(plain, yawDeg, {}, omit).cells) variant[cell.y][cell.x] = cell.c;
2682
+ return variant;
2683
+ };
2684
+ if (recipe.assetVersion === 4) {
2685
+ const over = (x, y) => !!crown[y]?.[x] || !!drawnOver[y]?.[x] || !!eyes[y]?.[x] || !!glasses[y]?.[x];
2686
+ const hairAt = (x, y) => head[y]?.[x] === hairColor && !over(x, y);
2687
+ const backAt = (x, y) => back[y]?.[x] !== void 0 && figure[y][x] === back[y][x];
2688
+ const owners = [
2689
+ [flower ? void 0 : recipe.backHairColor, (x, y) => masks.back(x, y) && backAt(x, y)],
2690
+ [recipe.leftSideHairColor, (x, y) => !!masks.sides?.[0](x, y) && (hairAt(x, y) || backAt(x, y))],
2691
+ [recipe.rightSideHairColor, (x, y) => !!masks.sides?.[1](x, y) && (hairAt(x, y) || backAt(x, y))],
2692
+ [recipe.bangsColor, (x, y) => (builtin.pieces ? piece("bangs")(x, y) : false) && hairAt(x, y)],
2693
+ [recipe.strandColor, (x, y) => (builtin.strand?.(x, y) ?? false) && hairAt(x, y)]
2694
+ ];
2695
+ for (const [color, owns] of owners) {
2696
+ if (color === void 0 || color.toLowerCase() === hairColor.toLowerCase()) continue;
2697
+ const variant = recolor(color);
2698
+ const near = (x, y) => owns(x - 1, y) || owns(x + 1, y) || owns(x, y - 1) || owns(x, y + 1);
2699
+ 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];
2700
+ }
2701
+ } else AVATAR_PIECE_COLORS.forEach((key, i) => {
2702
+ const color = recipe[key];
2703
+ const mask = masks.sides?.[i];
2704
+ if (color === void 0 || !mask || color.toLowerCase() === hairColor.toLowerCase()) return;
2705
+ const variant = recolor(color);
2313
2706
  const near = (x, y) => mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1);
2314
2707
  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];
2315
2708
  });
@@ -2384,6 +2777,57 @@ function hairPieceStart(recipe, slot) {
2384
2777
  back: []
2385
2778
  };
2386
2779
  }
2780
+ function cellSource(recipe) {
2781
+ return (c) => {
2782
+ for (const key of AVATAR_COLORS) for (const tone of [
2783
+ 0,
2784
+ -1,
2785
+ 1,
2786
+ -2,
2787
+ 2
2788
+ ]) if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone];
2789
+ return [c, 0];
2790
+ };
2791
+ }
2792
+ /**
2793
+ * The headpiece as a Custom Part to start drawing from: the drawn headpiece worn, or the
2794
+ * built-in headpiece flattened for this recipe in the front pose, its base on the back layer and
2795
+ * the rest on the front layer, so an unchanged copy renders identically facing front.
2796
+ */
2797
+ function headpieceStart(recipe) {
2798
+ const worn = wornPart(recipe, "headpiece");
2799
+ if (worn) return {
2800
+ slot: "headpiece",
2801
+ front: [...worn.front],
2802
+ back: [...worn.back]
2803
+ };
2804
+ const style = builtInHeadpiece(recipe);
2805
+ const behind = blank();
2806
+ const over = blank();
2807
+ if (style) drawHeadpiece(style, recipe, behind, over, 0, 0);
2808
+ const source = cellSource(recipe);
2809
+ const front = [];
2810
+ const back = [];
2811
+ for (let y = 0; y < 16; y++) for (let x = 0; x < N; x++) {
2812
+ const c = over[y][x];
2813
+ const b = behind[y][x];
2814
+ if (c) front.push([
2815
+ x,
2816
+ y,
2817
+ ...source(c)
2818
+ ]);
2819
+ if (b && b !== c) back.push([
2820
+ x,
2821
+ y,
2822
+ ...source(b)
2823
+ ]);
2824
+ }
2825
+ return {
2826
+ slot: "headpiece",
2827
+ front,
2828
+ back
2829
+ };
2830
+ }
2387
2831
  /**
2388
2832
  * The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn
2389
2833
  * there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a
@@ -2401,16 +2845,7 @@ function replacePartStart(recipe, slot) {
2401
2845
  const layer = slot === "glasses" ? "glasses" : slot === "nose" || slot === "cheeks" ? "features" : slot === "petals" ? "back" : slot === "outfit" || slot === "flowerBase" ? "body" : "figure";
2402
2846
  const shown = renderFigure(recipe, 0, {}, /* @__PURE__ */ new Set()).grids[layer];
2403
2847
  const without = renderFigure(recipe, 0, {}, new Set([slot])).grids[layer];
2404
- const source = (c) => {
2405
- for (const key of AVATAR_COLORS) for (const tone of [
2406
- 0,
2407
- -1,
2408
- 1,
2409
- -2,
2410
- 2
2411
- ]) if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone];
2412
- return [c, 0];
2413
- };
2848
+ const source = cellSource(recipe);
2414
2849
  const front = [];
2415
2850
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
2416
2851
  const c = shown[y][x];
@@ -2949,5 +3384,5 @@ function symbolArtCells(rows, color) {
2949
3384
  }
2950
3385
 
2951
3386
  //#endregion
2952
- 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, emptyPartLayer, faceCells, fillPartLayer, hairPieceStart, hiddenChoices, isHairPartSlot, isPixelAvatarRecipe, isPixelCustomPart, isReplacePartSlot, mirrorPartX, paintPartLayer, partCells, partLayer, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, replacePartStart, seededRandom, seededRecipe, seededRecipeV2, symbolArtCells, withCustomPart, withHeadpiece, withSpecies, wornPart };
3387
+ export { AVATAR_COLORS, AVATAR_EXTRA_PARTS, AVATAR_HAIR_PARTS, AVATAR_HEADPIECES, AVATAR_PARTS, AVATAR_PARTS_V2, 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, 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 };
2953
3388
  //# sourceMappingURL=index.js.map