@botharness/pixel-avatar 0.8.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/README.md CHANGED
@@ -25,5 +25,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc
25
25
  - `REPLACE_PART_SLOTS`, `replacePartStart(recipe, slot)`: drawn `outfit`, `accessory`, `beard`, `glasses`, `nose`, `cheeks`, `petals` and `flowerBase` parts (front layer only). A drawn part replaces the built-in part pixel for pixel in the layer that part paints into, while the built-in choice stays saved underneath. `replacePartStart` flattens the worn built-in part for this recipe: pixels that are exactly an appearance color at a tone become that color slot, and the rest become fixed colors, so an unchanged copy renders identically. Drawn face parts never cover the speaking mouth, and drawn parts are hidden wherever the species or headwear hides the built-in part. A drawn accessory replacing a helmet or hood no longer hides the hair.
26
26
  - `emptyPartLayer`, `paintPartLayer`, `fillPartLayer`, `mirrorPartX`, `partLayer`, `partCells`, `createCustomPart`: drawing on dense layers, with pencil and eraser strokes, 4-connected flood fill and mirroring across the Avatar centerline.
27
27
  - `partLinePoints`, `partRectPoints`, `gradientPartLayer`, `ditherThreshold`, `noisePartLayer`, `shadePartLayer`: line (with 0°/45°/90° snap), rectangle (with square), tone gradient with 4×4 or 2×2 ordered dithering inside the start region, seeded ±1 tone noise, and a shade stroke that moves each colored cell one tone. All mirror, and all produce ordinary cells: the seed and the tool are not part of the part.
28
+ - `AVATAR_PIECE_COLORS_V4`, `AVATAR_STRANDS`, `AVATAR_HEADPIECES`, `withPieces`, `withBuiltInHeadpiece`, `builtInHeadpiece`, `headpieceStart`: asset version 4. Bangs, back hair and a single `strand` each take an optional color (`bangsColor`, `backHairColor`, `strandColor`) that defaults to `hairColor`, like the side hair colors. The headpiece slot holds a built-in headpiece (ears, horns, halo or small head wings) or a Custom Part, worn together with any accessory. The base sits behind the hair and the rest in front, and the far side hides when the head turns. `withPieces` upgrades a recipe when the Human edits and moves an accessory that is now a headpiece into the empty headpiece slot. Saved recipes are never migrated on their own, so they keep rendering as before. `headpieceStart` flattens the worn headpiece into a Custom Part to start drawing from.
28
29
  - `faceCells`, `pixelSymbolCells`, `symbolArtCells`: pixels for [`@botharness/pixel-morph`](../morph).
29
30
  - `pixelAvatarSvg(recipe, { turns, classPrefix })`: optional pre-rendered head turns, and rig layer classes (`<prefix>-body`, `-head`, `-face`, `-gaze`, `-blink`) for CSS animation.
package/dist/index.d.ts CHANGED
@@ -39,6 +39,22 @@ type AvatarSpecies = (typeof AVATAR_SPECIES)[number];
39
39
  /** Optional per-piece hair colors (asset version 2); an absent piece uses `hairColor`. */
40
40
  declare const AVATAR_PIECE_COLORS: readonly ["leftSideHairColor", "rightSideHairColor"];
41
41
  type AvatarPieceColor = (typeof AVATAR_PIECE_COLORS)[number];
42
+ /**
43
+ * Asset version 4 adds a color for every other hair piece; an absent piece uses `hairColor`.
44
+ * `strandColor` colors the single strand (`strand`).
45
+ */
46
+ declare const AVATAR_PIECE_COLORS_V4: readonly ["bangsColor", "backHairColor", "strandColor"];
47
+ type AvatarPieceColorV4 = (typeof AVATAR_PIECE_COLORS_V4)[number];
48
+ /** A single strand of hair standing up from the crown (asset version 4). */
49
+ declare const AVATAR_STRANDS: readonly ["ahoge", "curl", "double"];
50
+ type AvatarStrand = (typeof AVATAR_STRANDS)[number];
51
+ /**
52
+ * Built-in headpieces (asset version 4), worn in the headpiece slot alongside an accessory and
53
+ * drawn partly behind the hair. The first five were accessories in earlier versions.
54
+ */
55
+ declare const AVATAR_HEADPIECES: readonly ["catears", "bunnyears", "horseears", "horns", "halo", "wings"];
56
+ type AvatarHeadpiece = (typeof AVATAR_HEADPIECES)[number];
57
+ type V4Key = AvatarPieceColorV4 | 'strand';
42
58
  /** Part choices that exist only in asset version 2, added after every version 1 choice. */
43
59
  declare const AVATAR_PARTS_V2: {
44
60
  readonly outfit: readonly ["tee", "shirttie", "hoodie", "turtleneck", "sailor", "blazer", "overalls", "dress", "kimono", "cardigan", "maid", "jacket", "armor", "robe", "tunic", "cloak"];
@@ -72,7 +88,7 @@ type PixelAvatarRecipeV1 = Meta & {
72
88
  } & { [P in AvatarPart]: (typeof AVATAR_PARTS)[P][number] } & { [P in AvatarHairPart]?: (typeof AVATAR_PARTS)['hair'][number] } & { [P in AvatarRange]?: number } & {
73
89
  species?: never;
74
90
  rightSideHair?: never;
75
- } & { [P in AvatarPieceColor | AvatarExtraPart]?: never } & { [P in CustomPartKey]?: never };
91
+ } & { [P in AvatarPieceColor | AvatarExtraPart]?: never } & { [P in CustomPartKey]?: never } & { [P in V4Key]?: never };
76
92
  /**
77
93
  * Asset version 2: a species, the full split hair and geometry, a separate right side hair
78
94
  * (`sideHair` is then the left side) and optional per-side hair colors.
@@ -82,7 +98,7 @@ type PixelAvatarRecipeV2 = Meta & {
82
98
  } & { [P in AvatarPart]: (typeof AVATAR_PARTS_V2)[P][number] } & { [P in AvatarHairPart]: (typeof AVATAR_PARTS)['hair'][number] } & { [P in AvatarRange]: number } & {
83
99
  species: AvatarSpecies;
84
100
  rightSideHair: (typeof AVATAR_HAIR_PARTS)['sideHair'][number];
85
- } & { [P in AvatarPieceColor]?: string } & { [P in AvatarExtraPart]?: (typeof AVATAR_EXTRA_PARTS)[P][number] } & { [P in CustomPartKey]?: never };
101
+ } & { [P in AvatarPieceColor]?: string } & { [P in AvatarExtraPart]?: (typeof AVATAR_EXTRA_PARTS)[P][number] } & { [P in CustomPartKey]?: never } & { [P in V4Key]?: never };
86
102
  /** The recipe key that embeds the Custom Part worn in each slot. */
87
103
  declare const CUSTOM_PART_KEYS: {
88
104
  readonly headpiece: "headpiece";
@@ -107,7 +123,18 @@ type CustomPartKey = (typeof CUSTOM_PART_KEYS)[PartSlot];
107
123
  type PixelAvatarRecipeV3 = Omit<PixelAvatarRecipeV2, 'assetVersion' | CustomPartKey> & {
108
124
  assetVersion: 3;
109
125
  } & { [P in CustomPartKey]?: PixelCustomPart };
110
- type PixelAvatarRecipe = PixelAvatarRecipeV1 | PixelAvatarRecipeV2 | PixelAvatarRecipeV3;
126
+ /**
127
+ * Asset version 4: version 2 with a color for every hair piece, a single `strand`, and the
128
+ * headpiece slot holding either a built-in headpiece or a Custom Part, with any number of
129
+ * Custom Parts worn (none included).
130
+ */
131
+ type PixelAvatarRecipeV4 = Omit<PixelAvatarRecipeV2, 'assetVersion' | CustomPartKey | V4Key> & {
132
+ assetVersion: 4;
133
+ } & { [P in Exclude<CustomPartKey, 'headpiece'>]?: PixelCustomPart } & {
134
+ headpiece?: PixelCustomPart | AvatarHeadpiece;
135
+ strand?: AvatarStrand;
136
+ } & { [P in AvatarPieceColorV4]?: string };
137
+ type PixelAvatarRecipe = PixelAvatarRecipeV1 | PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4;
111
138
  declare const DEFAULT_RECIPE: PixelAvatarRecipeV1;
112
139
  declare function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe;
113
140
  declare function canonicalRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe;
@@ -123,16 +150,29 @@ declare function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[];
123
150
  * When the skin color is one of the previous species' suggested colors, it moves to the new
124
151
  * species' first suggestion; a custom color is kept.
125
152
  */
153
+ declare function withSpecies(recipe: PixelAvatarRecipeV4, species: AvatarSpecies): PixelAvatarRecipeV4;
126
154
  declare function withSpecies(recipe: PixelAvatarRecipeV3, species: AvatarSpecies): PixelAvatarRecipeV3;
127
155
  declare function withSpecies(recipe: PixelAvatarRecipeV1 | PixelAvatarRecipeV2, species: AvatarSpecies): PixelAvatarRecipeV2;
128
- declare function withSpecies(recipe: PixelAvatarRecipe, species: AvatarSpecies): PixelAvatarRecipeV2 | PixelAvatarRecipeV3;
156
+ declare function withSpecies(recipe: PixelAvatarRecipe, species: AvatarSpecies): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4;
129
157
  /**
130
158
  * Returns the recipe wearing `part` in `slot`, or with that slot's part taken off. The recipe
131
- * embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise.
159
+ * embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise,
160
+ * and a version 4 recipe stays version 4.
132
161
  */
133
- declare function withCustomPart(recipe: PixelAvatarRecipe, slot: PartSlot, part: PixelCustomPart | undefined): PixelAvatarRecipeV2 | PixelAvatarRecipeV3;
162
+ declare function withCustomPart(recipe: PixelAvatarRecipeV4, slot: PartSlot, part: PixelCustomPart | undefined): PixelAvatarRecipeV4;
163
+ declare function withCustomPart(recipe: PixelAvatarRecipe, slot: PartSlot, part: PixelCustomPart | undefined): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4;
164
+ /**
165
+ * Returns the recipe as asset version 4, keeping every choice. An accessory that is now a
166
+ * headpiece moves into the empty headpiece slot, freeing the accessory slot; call this only when
167
+ * the Human edits, so saved recipes keep rendering as they were.
168
+ */
169
+ declare function withPieces(recipe: PixelAvatarRecipe): PixelAvatarRecipeV4;
170
+ /** Returns the recipe as asset version 4 wearing a built-in headpiece, or none. */
171
+ declare function withBuiltInHeadpiece(recipe: PixelAvatarRecipe, headpiece: AvatarHeadpiece | undefined): PixelAvatarRecipeV4;
172
+ /** The built-in headpiece worn, if any. */
173
+ declare function builtInHeadpiece(recipe: PixelAvatarRecipe): AvatarHeadpiece | undefined;
134
174
  /** `withCustomPart` for the headpiece slot. */
135
- declare function withHeadpiece(recipe: PixelAvatarRecipe, part: PixelCustomPart | undefined): PixelAvatarRecipeV2 | PixelAvatarRecipeV3;
175
+ declare function withHeadpiece(recipe: PixelAvatarRecipe, part: PixelCustomPart | undefined): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4;
136
176
  /** The Custom Part worn in a slot, if any. */
137
177
  declare function wornPart(recipe: PixelAvatarRecipe, slot: PartSlot): PixelCustomPart | undefined;
138
178
  declare function detailedRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe;
@@ -330,6 +370,16 @@ declare function hairPieceStart(recipe: PixelAvatarRecipe, slot: HairPartSlot):
330
370
  front: PartCell[];
331
371
  back: PartCell[];
332
372
  };
373
+ /**
374
+ * The headpiece as a Custom Part to start drawing from: the drawn headpiece worn, or the
375
+ * built-in headpiece flattened for this recipe in the front pose, its base on the back layer and
376
+ * the rest on the front layer, so an unchanged copy renders identically facing front.
377
+ */
378
+ declare function headpieceStart(recipe: PixelAvatarRecipe): {
379
+ slot: 'headpiece';
380
+ front: PartCell[];
381
+ back: PartCell[];
382
+ };
333
383
  /**
334
384
  * The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn
335
385
  * there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a
@@ -391,5 +441,5 @@ declare function pixelSymbolCells(symbol: PixelSymbol, color: string): PixelCell
391
441
  */
392
442
  declare function symbolArtCells(rows: readonly string[], color: string): PixelCell$1[];
393
443
  //#endregion
394
- 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, type AvatarColor, type AvatarExtraPart, type AvatarHairPart, type AvatarPart, type AvatarPieceColor, type AvatarRange, type AvatarSpecies, CUSTOM_PART_KEYS, type CustomPartKey, DEFAULT_RECIPE, HAIR_PART_SLOTS, type HairPartSlot, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, type PartCell, type PartColor, type PartInk, type PartLayer, type PartLayerName, type PartSlot, type PartTone, type PixelAvatarRecipe, type PixelAvatarRecipeV1, type PixelAvatarRecipeV2, type PixelAvatarRecipeV3, type PixelAvatarSvgOptions, type PixelCell, type PixelCustomPart, type PixelFigureOptions, type PixelGrid, type PixelMouthState, type PixelSymbol, REPLACE_PART_SLOTS, type ReplacePartSlot, 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 };
444
+ 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, type AvatarColor, type AvatarExtraPart, type AvatarHairPart, type AvatarHeadpiece, type AvatarPart, type AvatarPieceColor, type AvatarPieceColorV4, type AvatarRange, type AvatarSpecies, type AvatarStrand, CUSTOM_PART_KEYS, type CustomPartKey, DEFAULT_RECIPE, HAIR_PART_SLOTS, type HairPartSlot, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, type PartCell, type PartColor, type PartInk, type PartLayer, type PartLayerName, type PartSlot, type PartTone, type PixelAvatarRecipe, type PixelAvatarRecipeV1, type PixelAvatarRecipeV2, type PixelAvatarRecipeV3, type PixelAvatarRecipeV4, type PixelAvatarSvgOptions, type PixelCell, type PixelCustomPart, type PixelFigureOptions, type PixelGrid, type PixelMouthState, type PixelSymbol, REPLACE_PART_SLOTS, type ReplacePartSlot, 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 };
395
445
  //# sourceMappingURL=index.d.ts.map
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;
@@ -1659,6 +1728,39 @@ function hair(recipe, s, b) {
1659
1728
  };
1660
1729
  }
1661
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
+ }
1662
1764
  function detailedHair(recipe, s, b) {
1663
1765
  const of = (style) => hair({
1664
1766
  ...recipe,
@@ -1673,8 +1775,11 @@ function detailedHair(recipe, s, b) {
1673
1775
  const sideOf = (sign) => (x, y) => y > 7 && sign * (x + .5 - C - s) >= 7 && lengthen((sign < 0 ? side : right).front)(x, y);
1674
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);
1675
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;
1676
1780
  return {
1677
- 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 } : {},
1678
1783
  sides: [sideOf(-1), sideOf(1)],
1679
1784
  pieces: {
1680
1785
  bangs: fringe,
@@ -2019,6 +2124,87 @@ function accessory(recipe, g, s, face) {
2019
2124
  return;
2020
2125
  }
2021
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
+ }
2022
2208
  const POINTED_EARS = {
2023
2209
  goblin: {
2024
2210
  rows: [
@@ -2205,7 +2391,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2205
2391
  };
2206
2392
  const masks = drawnHair.length === 0 ? builtin : {
2207
2393
  ...builtin,
2208
- 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),
2209
2395
  sides: [piece("leftSideHair"), piece("rightSideHair")],
2210
2396
  back: piece("backHair"),
2211
2397
  ...drawnHair.some((drawn) => drawn.slot === "backHair") ? { ties: [] } : {}
@@ -2244,6 +2430,9 @@ function renderFigure(recipe, yawDeg, options, omit) {
2244
2430
  placeCells(back, inked("backHair"), false);
2245
2431
  overlay(back, "petals");
2246
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);
2247
2436
  const head = blank();
2248
2437
  paint(head, face, skin);
2249
2438
  const h = recipe.height ?? 0;
@@ -2295,8 +2484,10 @@ function renderFigure(recipe, yawDeg, options, omit) {
2295
2484
  const shadeHair = (g) => {
2296
2485
  const out = g.map((r) => [...r]);
2297
2486
  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);
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);
2300
2491
  const left = Math.min(...xs);
2301
2492
  const right = Math.max(...xs);
2302
2493
  const top = Math.min(...ys);
@@ -2325,7 +2516,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2325
2516
  }
2326
2517
  const ring = mix$1(hairColor, WHITE, .45);
2327
2518
  for (let x = Math.round(C + s) - 7; x <= Math.round(C + s) + 3; x++) {
2328
- const first = g.findIndex((row) => row[x] === hairColor);
2519
+ const first = g.findIndex((row, y) => row[x] === hairColor && !builtin.strand?.(x, y));
2329
2520
  if (first < 0) continue;
2330
2521
  const y = first + 2 + Math.round(Math.abs(x + .5 - (C + s - 2)) / 4);
2331
2522
  if (g[y]?.[x] === hairColor && x % 3 !== 0) out[y][x] = ring;
@@ -2338,6 +2529,7 @@ function renderFigure(recipe, yawDeg, options, omit) {
2338
2529
  const crown = blank();
2339
2530
  placeCells(crown, inked("all-front"), false);
2340
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];
2341
2533
  const figure = blank();
2342
2534
  for (const layer of [
2343
2535
  back,
@@ -2475,17 +2667,42 @@ function renderFigure(recipe, yawDeg, options, omit) {
2475
2667
  ], right.x + right.w, eyeTop + 5, { G: GOLD });
2476
2668
  }
2477
2669
  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;
2670
+ const variants = /* @__PURE__ */ new Map();
2671
+ const recolor = (color) => {
2672
+ const cached = variants.get(color.toLowerCase());
2673
+ if (cached) return cached;
2482
2674
  const variant = blank();
2675
+ variants.set(color.toLowerCase(), variant);
2483
2676
  const plain = {
2484
2677
  ...recipe,
2485
2678
  hairColor: color
2486
2679
  };
2487
- 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];
2488
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);
2489
2706
  const near = (x, y) => mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1);
2490
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];
2491
2708
  });
@@ -2560,6 +2777,57 @@ function hairPieceStart(recipe, slot) {
2560
2777
  back: []
2561
2778
  };
2562
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
+ }
2563
2831
  /**
2564
2832
  * The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn
2565
2833
  * there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a
@@ -2577,16 +2845,7 @@ function replacePartStart(recipe, slot) {
2577
2845
  const layer = slot === "glasses" ? "glasses" : slot === "nose" || slot === "cheeks" ? "features" : slot === "petals" ? "back" : slot === "outfit" || slot === "flowerBase" ? "body" : "figure";
2578
2846
  const shown = renderFigure(recipe, 0, {}, /* @__PURE__ */ new Set()).grids[layer];
2579
2847
  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
- };
2848
+ const source = cellSource(recipe);
2590
2849
  const front = [];
2591
2850
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
2592
2851
  const c = shown[y][x];
@@ -3125,5 +3384,5 @@ function symbolArtCells(rows, color) {
3125
3384
  }
3126
3385
 
3127
3386
  //#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 };
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 };
3129
3388
  //# sourceMappingURL=index.js.map