diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c2cd0..f87b262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `@botharness/pixel-avatar` 0.7.0: drawn replacement parts. The `outfit`, `accessory`, `beard`, `glasses`, `nose`, `cheeks`, `petals` and `flowerBase` Custom Part slots replace the built-in part in the same layer, and the built-in choice stays saved. `replacePartStart` flattens the worn part to appearance-color cells where exact and fixed colors elsewhere, so an unchanged copy renders identically. Drawn face parts never cover the pixels any speaking mouth uses, and hiding follows `hiddenChoices`. `MAX_PART_FIXED_COLORS` rises to 32, because a flattened outfit can use up to 17. Every earlier recipe renders unchanged. - `@botharness/pixel-avatar` 0.6.0: drawn hair pieces. The `bangs`, `leftSideHair`, `rightSideHair` and `backHair` Custom Part slots replace one built-in hair piece each, with the built-in choice kept underneath. A `hairColor` cell at tone 0 is live hair, shaded like built-in hair, so `hairPieceStart` flattens the worn piece (including hair ties) and an unchanged copy renders identically. `withCustomPart` and `wornPart` generalise the headpiece helpers; asset version 3 now means a recipe wearing at least one Custom Part. Every earlier recipe renders unchanged. - `@botharness/pixel-avatar` 0.5.0: Custom Parts. A `PixelCustomPart` in the `headpiece` slot has a back layer drawn behind the hair and a front layer over it. Its cells reference an appearance color slot or a fixed color plus a tone step, so a part recolors with the Avatar, and it follows the head through turns. `withHeadpiece` embeds a copy in the recipe as asset version 3, which older consumers reject so they can show a saved snapshot. `customPartId` is a SHA-256 of the canonical content, and `paintPartLayer`, `fillPartLayer` (4-connected) and `mirrorPartX` provide the drawing algorithms. Every earlier recipe renders unchanged. - `@botharness/pixel-avatar` 0.4.0: elf, dwarf, orc and flower species, an optional `beard` (`short`, `full`, `braided`) that always leaves the speaking mouth visible, armor/robe/tunic/cloak outfits and helmet/hood headwear (version 2 only, in `AVATAR_PARTS_V2`), and the flower's `petals` (`trumpet`, `daisy`, `sunflower`, `tulip`, `sakura`) and `flowerBase` (`leaves`, `pot`) in `AVATAR_EXTRA_PARTS`. A flower has simple bead eyes and its own mouth instead of a person's face. Petals take hair and per-side hair colors; a pot takes the shirt color. `hiddenChoices(recipe)` lists saved choices a species or headwear keeps but does not draw (a flower's hair, outfit, headwear, beard, eyes, brows, nose, cheeks and glasses, hair under a helmet or hood, a dwarf's nose part). `createSeededRecipeV2` / `seededRecipeV2` seed a version 2 face from a name across every species, with optional beards, version 2 outfits and flower parts; version 1 seeds are unchanged, so a consumer picks the seed version per identity. Version 1 recipes and every 0.3.0 goblin recipe render unchanged. diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 5466332..a511f27 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -22,6 +22,7 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc - `AVATAR_PARTS_V2`, `AVATAR_EXTRA_PARTS`, `hiddenChoices(recipe)`: version 2 adds elf, dwarf, orc and flower species, armor/robe/tunic/cloak outfits, helmet/hood headwear, an optional `beard`, and a flower's `petals` and `flowerBase`. `hiddenChoices` names saved choices the current species or headwear keeps but does not draw, so an editor can say so instead of discarding them. - `PixelCustomPart`, `withHeadpiece(recipe, part)`, `customPartId`, `isPixelCustomPart`: a Human-drawn Custom Part in the `headpiece` slot (the top 32×16 of the tile). Its back layer is drawn behind the hair and its front layer over it, and both follow the head through turns. Each cell is `[x, y, color, tone]`, where `color` is an appearance color slot (`hairColor`, `skinColor`, `eyeColor`, `shirtColor`), so the part recolors with the Avatar, or a fixed `#rrggbb`, and `tone` is a step from −2 to 2 on the rig's shade ramp (`partToneColor`). A recipe wearing a part embeds its own copy as asset version 3. `customPartId` is the SHA-256 of the canonical content; names, authors and origins belong to the consumer's library. - `HAIR_PART_SLOTS`, `hairPieceStart(recipe, slot)`, `withCustomPart(recipe, slot, part)`, `wornPart`: drawn hair pieces. `bangs`, `leftSideHair`, `rightSideHair` and `backHair` each take a Custom Part (front layer only) that replaces the built-in piece while it stays saved underneath. In a hair part, a `hairColor` cell at tone 0 is live hair: it is shaded with the rest of the hair exactly like the built-in piece, so `hairPieceStart` flattens the worn built-in piece into such cells and an unchanged copy renders identically. Other cells show their own color. Drawn hair follows turns and is hidden under a helmet or hood and on a flower, like built-in hair. +- `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. - `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. - `faceCells`, `pixelSymbolCells`, `symbolArtCells`: pixels for [`@botharness/pixel-morph`](../morph). - `pixelAvatarSvg(recipe, { turns, classPrefix })`: optional pre-rendered head turns, and rig layer classes (`-body`, `-head`, `-face`, `-gaze`, `-blink`) for CSS animation. diff --git a/packages/avatar/package.json b/packages/avatar/package.json index a3091d3..422f801 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@botharness/pixel-avatar", - "version": "0.6.0", + "version": "0.7.0", "description": "Seeded, editable 32×32 chibi pixel avatars with state symbols that morph in and out", "keywords": [ "agent-ui", diff --git a/packages/avatar/src/figure.ts b/packages/avatar/src/figure.ts index 89e8d17..05ab2a4 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -1,6 +1,20 @@ import { escapeAttribute, type PixelCell } from '@botharness/pixel-morph'; -import { HAIR_PART_SLOTS, type HairPartSlot, type PartCell, type PartTone } from './part.js'; -import { AVATAR_PIECE_COLORS, detailedRecipe, wornPart, type PixelAvatarRecipe } from './recipe.js'; +import { + HAIR_PART_SLOTS, + REPLACE_PART_SLOTS, + type HairPartSlot, + type PartCell, + type PartTone, + type ReplacePartSlot, +} from './part.js'; +import { + AVATAR_COLORS, + AVATAR_PIECE_COLORS, + detailedRecipe, + hiddenChoices, + wornPart, + type PixelAvatarRecipe, +} from './recipe.js'; type Cell = string | undefined; type Grid = Cell[][]; @@ -862,6 +876,24 @@ export function pixelFigure( yawDeg: number, options: PixelFigureOptions = {}, ): { tile: string; body: string; head: string; cells: PixelCell[] } { + const { grids: _, ...out } = renderFigure(recipe, yawDeg, options, new Set()); + return out; +} + +const FACE_SLOTS: readonly ReplacePartSlot[] = ['nose', 'cheeks', 'glasses']; + +function renderFigure( + recipe: Recipe, + yawDeg: number, + options: PixelFigureOptions, + omit: ReadonlySet, +): { + tile: string; + body: string; + head: string; + cells: PixelCell[]; + grids: { figure: Grid; features: Grid; glasses: Grid; back: Grid; body: Grid }; +} { const yaw = (yawDeg * Math.PI) / 180; const sinY = Math.sin(yaw); const s = Math.round(sinY * 3); @@ -880,9 +912,41 @@ export function pixelFigure( } : front; const flower = recipe.species === 'flower'; - const covered = recipe.accessory === 'helmet' || recipe.accessory === 'hood'; + const hiddenNow = hiddenChoices(recipe); + const drawnParts = new Map( + REPLACE_PART_SLOTS.flatMap((slot) => { + const part = wornPart(recipe, slot); + const shown = slot === 'petals' || slot === 'flowerBase' ? flower : !hiddenNow.includes(slot); + return part && shown ? [[slot, part.front] as const] : []; + }), + ); + const skip = (slot: ReplacePartSlot) => omit.has(slot) || drawnParts.has(slot); + const covered = + !skip('accessory') && (recipe.accessory === 'helmet' || recipe.accessory === 'hood'); const bare: Mask = () => false; - const petalRing = flower ? petals(recipe.petals ?? 'trumpet', s) : bare; + const petalRing = flower && !skip('petals') ? petals(recipe.petals ?? 'trumpet', s) : bare; + const faceShift = Math.round(fs * 0.6); + const mouthCells = new Set(); + for (const rows of [...Object.values(MOUTHS), ['.K.', 'KMK', '.K.']]) { + const left = Math.round(C - 0.5 + fs * 0.6 - (rows[0]!.length - 1) / 2); + rows.forEach((row, dy) => + [...row].forEach((code, dx) => { + if (code !== '.') mouthCells.add(`${left + dx},${20 + (recipe.height ?? 0) + dy}`); + }), + ); + } + const mouthBox = (x: number, y: number) => mouthCells.has(`${x},${y}`); + const overlay = (grid: Grid, slot: ReplacePartSlot, keepOutMouth = false) => { + const cells = drawnParts.get(slot); + if (!cells) return; + const shift = FACE_SLOTS.includes(slot) ? faceShift : s; + for (const [x, y, color, tone] of cells) { + const px = x + shift; + if (px < 0 || px >= N || (keepOutMouth && mouthBox(px, y))) continue; + const base = color.startsWith('#') ? color.toLowerCase() : recipe[color as 'hairColor']; + grid[y]![px] = TONES[tone](base); + } + }; const builtin: ReturnType = flower ? { back: petalRing, @@ -945,8 +1009,13 @@ export function pixelFigure( ); const body = blank(); - if (flower) flowerBase(recipe, body, s); - else outfit(recipe, body, s); + if (flower) { + if (!skip('flowerBase')) flowerBase(recipe, body, s); + overlay(body, 'flowerBase'); + } else { + if (!skip('outfit')) outfit(recipe, body, s); + overlay(body, 'outfit'); + } const back = blank(); if (flower) { @@ -981,12 +1050,13 @@ export function pixelFigure( if (cell.x >= 0 && cell.x < N) grid[cell.y]![cell.x] = cell.c; }; placeCells(back, inked('backHair'), false); + overlay(back, 'petals'); if (headpiece) placeCells(back, headpiece.back); const head = blank(); paint(head, face, skin); const h = recipe.height ?? 0; - if (recipe.beard && !flower) { + if (recipe.beard && !flower && !skip('beard')) { const centre = C + fs * 0.6; const mouthX = Math.round(C - 0.5 + fs * 0.6); const chin: Mask = (x, y) => face(x, y) || face(x, y - 2); @@ -1039,7 +1109,7 @@ export function pixelFigure( if (!pointed && !flower) paint(head, masks.front, hairColor); for (const [x, y] of masks.ties ?? []) sprite(back, ['T'], Math.round(x), y, { T: shade(recipe.shirtColor, 0.8) }); - if (!flower) accessory(recipe, head, s, face); + if (!flower && !skip('accessory')) accessory(recipe, head, s, face); const shadeHair = (g: Grid) => { const out = g.map((r) => [...r]); @@ -1093,11 +1163,14 @@ export function pixelFigure( return out; }; + const drawnOver = blank(); + overlay(drawnOver, 'beard', true); + overlay(drawnOver, 'accessory'); const crown = blank(); placeCells(crown, inked('all-front'), false); if (headpiece) placeCells(crown, headpiece.front); const figure = blank(); - for (const layer of [back, body, shadeHair(head), crown]) + for (const layer of [back, body, shadeHair(head), drawnOver, crown]) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (layer[y]![x]) figure[y]![x] = layer[y]![x]; @@ -1167,6 +1240,7 @@ export function pixelFigure( N: shade(skin, 0.84), n: shade(skin, 0.72), }); + else if (skip('nose')) overlay(features, 'nose', true); else if (recipe.nose === 'dot') sprite(features, ['N'], cx, 18 + h, { N: shade(skin, 0.78) }); else if (recipe.nose === 'button') sprite(features, ['N'], cx, 18 + h, { N: shade(skin, 0.88) }); else if (recipe.nose === 'line') @@ -1186,11 +1260,12 @@ export function pixelFigure( if (recipe.species === 'orc') for (const tx of [Math.round(mx - 2.5), Math.round(mx + 2.5)]) sprite(layer, ['T', 'T'], tx, 20 + h, { T: TUSK }); - if (recipe.cheeks === 'blush' && !flower) { + if (skip('cheeks')) overlay(layer, 'cheeks', true); + if (recipe.cheeks === 'blush' && !flower && !skip('cheeks')) { sprite(layer, ['PP'], left.x - 1, 19 + h, { P: mix(BLUSH, skin, 0.25) }); sprite(layer, ['PP'], right.x + right.w - 1, 19 + h, { P: mix(BLUSH, skin, 0.25) }); } - if (recipe.cheeks === 'freckles' && !flower) { + if (recipe.cheeks === 'freckles' && !flower && !skip('cheeks')) { sprite(layer, ['F.F'], left.x, 19 + h, { F: shade(skin, 0.7) }); sprite(layer, ['F.F'], right.x + 1, 19 + h, { F: shade(skin, 0.7) }); } @@ -1223,7 +1298,8 @@ export function pixelFigure( }, INK, ); - const lenses = flower ? 'none' : recipe.glasses; + const lenses = flower || skip('glasses') ? 'none' : recipe.glasses; + overlay(glasses, 'glasses', true); if (lenses === 'round' || lenses === 'square') { frame(left.x, left.w, lenses === 'round'); frame(right.x, right.w, lenses === 'round'); @@ -1252,7 +1328,8 @@ export function pixelFigure( const variant = blank(); const plain = { ...recipe, hairColor: color }; for (const k of AVATAR_PIECE_COLORS) delete plain[k]; - for (const cell of pixelFigure(plain, yawDeg).cells) variant[cell.y]![cell.x] = cell.c; + for (const cell of renderFigure(plain, yawDeg, {}, omit).cells) + variant[cell.y]![cell.x] = cell.c; const near = (x: number, y: number) => mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1); for (let y = 0; y < N; y++) @@ -1266,7 +1343,8 @@ export function pixelFigure( for (let x = 0; x < N; x++) { const c = full[y]![x]; if (!c) continue; - if (y >= 22 && !head[y]![x] && !back[y]![x] && !crown[y]![x]) bodyCells[y]![x] = c; + if (y >= 22 && !head[y]![x] && !back[y]![x] && !crown[y]![x] && !drawnOver[y]![x]) + bodyCells[y]![x] = c; else headCells[y]![x] = c; } const cells: PixelCell[] = []; @@ -1276,6 +1354,7 @@ export function pixelFigure( if (c) cells.push({ x, y, c }); } return { + grids: { figure, features: savedFeatures, glasses, back, body }, cells, tile: ``, body: `${rects(bodyCells)}`, @@ -1312,3 +1391,43 @@ export function hairPieceStart( else if (mask(x, y)) front.push([x, y, 'hairColor', 0]); return { slot, front, back: [] }; } + +/** + * The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn + * there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a + * color slot and tone when it is exactly one, so it recolors with the Avatar, and a fixed color + * otherwise; an unchanged copy renders identically. A flattened helmet or hood no longer hides + * the hair: only the built-in ones do. + */ +export function replacePartStart( + recipe: PixelAvatarRecipe, + slot: ReplacePartSlot, +): { slot: ReplacePartSlot; front: PartCell[]; back: PartCell[] } { + const worn = wornPart(recipe, slot); + if (worn) return { slot, front: [...worn.front], back: [] }; + const layer = + slot === 'glasses' + ? 'glasses' + : slot === 'nose' || slot === 'cheeks' + ? 'features' + : slot === 'petals' + ? 'back' + : slot === 'outfit' || slot === 'flowerBase' + ? 'body' + : 'figure'; + const shown = renderFigure(recipe, 0, {}, new Set()).grids[layer]; + const without = renderFigure(recipe, 0, {}, new Set([slot])).grids[layer]; + const source = (c: string): readonly [PartCell[2], PartTone] => { + for (const key of AVATAR_COLORS) + for (const tone of [0, -1, 1, -2, 2] as const) + if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone]; + return [c as PartCell[2], 0]; + }; + const front: PartCell[] = []; + for (let y = 0; y < N; y++) + for (let x = 0; x < N; x++) { + const c = shown[y]![x]; + if (c && c !== without[y]![x]) front.push([x, y, ...source(c)]); + } + return { slot, front, back: [] }; +} diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index 02271b3..ec850e9 100644 --- a/packages/avatar/src/index.ts +++ b/packages/avatar/src/index.ts @@ -1,6 +1,7 @@ export type { PixelCell } from '@botharness/pixel-morph'; export { hairPieceStart, + replacePartStart, partToneColor, pixelFigure, pixelTileColor, @@ -53,7 +54,9 @@ export { PART_SLOTS, PART_TONES, HAIR_PART_SLOTS, + REPLACE_PART_SLOTS, isHairPartSlot, + isReplacePartSlot, canonicalCustomPart, createCustomPart, customPartId, @@ -72,6 +75,7 @@ export { type PartSlot, type PartTone, type HairPartSlot, + type ReplacePartSlot, type PixelCustomPart, } from './part.js'; export { seededRandom } from './random.js'; diff --git a/packages/avatar/src/part.ts b/packages/avatar/src/part.ts index c7c8c7b..581525f 100644 --- a/packages/avatar/src/part.ts +++ b/packages/avatar/src/part.ts @@ -12,6 +12,14 @@ export const PART_SLOTS = { leftSideHair: { width: 32, height: 32 }, rightSideHair: { width: 32, height: 32 }, backHair: { width: 32, height: 32 }, + outfit: { width: 32, height: 32 }, + accessory: { width: 32, height: 32 }, + beard: { width: 32, height: 32 }, + glasses: { width: 32, height: 32 }, + nose: { width: 32, height: 32 }, + cheeks: { width: 32, height: 32 }, + petals: { width: 32, height: 32 }, + flowerBase: { width: 32, height: 32 }, } as const; export type PartSlot = keyof typeof PART_SLOTS; /** @@ -22,6 +30,23 @@ export const HAIR_PART_SLOTS = ['bangs', 'leftSideHair', 'rightSideHair', 'backH export type HairPartSlot = (typeof HAIR_PART_SLOTS)[number]; export const isHairPartSlot = (slot: PartSlot): slot is HairPartSlot => (HAIR_PART_SLOTS as readonly string[]).includes(slot); +/** + * Slots whose drawn part replaces a built-in part pixel for pixel: the part's cells are painted + * where the built-in part would be, and every cell shows its own color and tone. + */ +export const REPLACE_PART_SLOTS = [ + 'outfit', + 'accessory', + 'beard', + 'glasses', + 'nose', + 'cheeks', + 'petals', + 'flowerBase', +] as const; +export type ReplacePartSlot = (typeof REPLACE_PART_SLOTS)[number]; +export const isReplacePartSlot = (slot: PartSlot): slot is ReplacePartSlot => + (REPLACE_PART_SLOTS as readonly string[]).includes(slot); /** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */ export const PART_TONES = [-2, -1, 0, 1, 2] as const; export type PartTone = (typeof PART_TONES)[number]; @@ -32,7 +57,7 @@ export type PartCell = readonly [x: number, y: number, color: PartColor, tone: P export const PART_LAYERS = ['front', 'back'] as const; export type PartLayerName = (typeof PART_LAYERS)[number]; /** Most fixed colors one part may use. */ -export const MAX_PART_FIXED_COLORS = 16; +export const MAX_PART_FIXED_COLORS = 32; /** * A Human-drawn Custom Part. Its content is immutable and its identity is `customPartId`; @@ -58,8 +83,7 @@ export function isPixelCustomPart(value: unknown): value is PixelCustomPart { const r = value as Record; if (Object.keys(r).length !== 3 || !Object.hasOwn(PART_SLOTS, r['slot'] as string)) return false; const { width, height } = PART_SLOTS[r['slot'] as PartSlot]; - if (isHairPartSlot(r['slot'] as PartSlot) && (r['back'] as unknown[] | undefined)?.length !== 0) - return false; + if (r['slot'] !== 'headpiece' && (r['back'] as unknown[] | undefined)?.length !== 0) return false; const fixed = new Set(); for (const name of PART_LAYERS) { const cells = r[name]; diff --git a/packages/avatar/src/recipe.ts b/packages/avatar/src/recipe.ts index 674424b..5905978 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -217,6 +217,14 @@ export const CUSTOM_PART_KEYS = { leftSideHair: 'leftSideHairPart', rightSideHair: 'rightSideHairPart', backHair: 'backHairPart', + outfit: 'outfitPart', + accessory: 'accessoryPart', + beard: 'beardPart', + glasses: 'glassesPart', + nose: 'nosePart', + cheeks: 'cheeksPart', + petals: 'petalsPart', + flowerBase: 'flowerBasePart', } as const satisfies Record; export type CustomPartKey = (typeof CUSTOM_PART_KEYS)[PartSlot]; const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS) as [PartSlot, CustomPartKey][]; @@ -351,10 +359,14 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { 'nose', 'cheeks', 'glasses', - ...(recipe.beard ? ['beard'] : []), + ...(recipe.beard || wornPart(recipe, 'beard') ? ['beard'] : []), ]; const hidden: string[] = []; - if (recipe.accessory === 'helmet' || recipe.accessory === 'hood') hidden.push(...HAIR_PIECES); + if ( + (recipe.accessory === 'helmet' || recipe.accessory === 'hood') && + !wornPart(recipe, 'accessory') + ) + hidden.push(...HAIR_PIECES); if (recipe.species === 'dwarf') hidden.push('nose'); return hidden; } diff --git a/packages/avatar/test/replace-part.test.ts b/packages/avatar/test/replace-part.test.ts new file mode 100644 index 0000000..e8cb5f3 --- /dev/null +++ b/packages/avatar/test/replace-part.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { + AVATAR_EXTRA_PARTS, + AVATAR_PARTS_V2, + AVATAR_TURNS, + DEFAULT_RECIPE, + REPLACE_PART_SLOTS, + isPixelAvatarRecipe, + isPixelCustomPart, + pixelAvatarSvg, + pixelFigure, + replacePartStart, + withCustomPart, + withSpecies, + type PartCell, + type PixelAvatarRecipe, + type ReplacePartSlot, +} from '../src/index.js'; + +const human = withSpecies(DEFAULT_RECIPE, 'human'); +const dwarf = withSpecies(DEFAULT_RECIPE, 'dwarf'); +const flower = withSpecies(DEFAULT_RECIPE, 'flower'); +const wear = (recipe: PixelAvatarRecipe, slot: ReplacePartSlot) => + withCustomPart(recipe, slot, replacePartStart(recipe, slot)); +const svg = (recipe: PixelAvatarRecipe) => pixelAvatarSvg(recipe); +const mouths = (recipe: PixelAvatarRecipe) => pixelFigure(recipe, 0, { mouthLayers: true }).head; + +const variants: [ReplacePartSlot, PixelAvatarRecipe[]][] = [ + ['outfit', AVATAR_PARTS_V2.outfit.map((outfit) => ({ ...dwarf, outfit }))], + [ + 'accessory', + AVATAR_PARTS_V2.accessory + .filter((accessory) => accessory !== 'none' && accessory !== 'helmet' && accessory !== 'hood') + .map((accessory) => ({ ...human, accessory })), + ], + ['beard', AVATAR_EXTRA_PARTS.beard.map((beard) => ({ ...dwarf, beard }))], + [ + 'glasses', + AVATAR_PARTS_V2.glasses.filter((g) => g !== 'none').map((glasses) => ({ ...human, glasses })), + ], + ['nose', AVATAR_PARTS_V2.nose.filter((n) => n !== 'none').map((nose) => ({ ...human, nose }))], + [ + 'cheeks', + AVATAR_PARTS_V2.cheeks.filter((c) => c !== 'none').map((cheeks) => ({ ...human, cheeks })), + ], + ['petals', AVATAR_EXTRA_PARTS.petals.map((petals) => ({ ...flower, petals }))], + ['flowerBase', AVATAR_EXTRA_PARTS.flowerBase.map((flowerBase) => ({ ...flower, flowerBase }))], +]; + +describe('drawn replacement parts', () => { + it('flattens every built-in part and renders an unchanged copy identically', () => { + for (const [slot, recipes] of variants) + for (const recipe of recipes) { + const start = replacePartStart(recipe, slot); + const label = `${slot} ${JSON.stringify(recipe[slot as keyof PixelAvatarRecipe])}`; + expect(start.front.length, label).toBeGreaterThan(0); + expect(isPixelCustomPart(start), label).toBe(true); + const worn = wear(recipe, slot); + expect(isPixelAvatarRecipe(worn), label).toBe(true); + expect(svg(worn), label).toBe(svg(recipe)); + expect(mouths(worn), label).toBe(mouths(recipe)); + } + }); + + it('keeps flattened cells on appearance colors so they recolor', () => { + const recipe = { ...dwarf, outfit: 'tee' as const }; + const start = replacePartStart(recipe, 'outfit'); + expect(start.front.some(([, , color]) => color === 'shirtColor')).toBe(true); + const worn = wear(recipe, 'outfit'); + expect(svg({ ...worn, shirtColor: '#3d9970' })).not.toBe(svg(worn)); + }); + + it('never covers the speaking mouth with a drawn face part', () => { + const mouthCells: PartCell[] = []; + for (let x = 10; x <= 21; x++) + for (let y = 19; y <= 23; y++) mouthCells.push([x, y, '#1d1b22', 0]); + for (const slot of ['beard', 'nose', 'cheeks', 'glasses'] as const) { + const worn = withCustomPart(human, slot, { slot, front: mouthCells, back: [] }); + for (const state of ['closed', 'half-open', 'open']) { + const layer = mouths(worn).split(`data-avatar-mouth="${state}"`)[1]!.split('')[0]!; + expect(layer, `${slot} ${state}`).toContain('#7a2a38'); + } + const cells = pixelFigure(worn, 0).cells; + expect(cells.find((c) => c.x === 15 && c.y === 21)?.c, slot).not.toBe('#1d1b22'); + } + }); + + it('hides drawn parts where the species or headwear hides the built-in part', () => { + const blob: PartCell[] = [[16, 26, '#1d1b22', 0]]; + const onFlower = withCustomPart(flower, 'outfit', { slot: 'outfit', front: blob, back: [] }); + expect(svg(onFlower)).toBe(svg(flower)); + const nose: PartCell[] = [[15, 18, '#1d1b22', 0]]; + const dwarfNose = withCustomPart(dwarf, 'nose', { slot: 'nose', front: nose, back: [] }); + expect(svg(dwarfNose)).toBe(svg(dwarf)); + const petals = withCustomPart(human, 'petals', { slot: 'petals', front: blob, back: [] }); + expect(svg(petals)).toBe(svg(human)); + }); + + it('shows the hair once a helmet is replaced by a drawn accessory', () => { + const helmet = { ...dwarf, accessory: 'helmet' as const, backHair: 'long' as const }; + const worn = wear(helmet, 'accessory'); + expect(svg(worn)).not.toBe(svg(helmet)); + expect(svg(withCustomPart(worn, 'accessory', undefined))).toBe(svg(helmet)); + }); + + it('turns drawn parts with the head', () => { + for (const slot of REPLACE_PART_SLOTS) { + const recipe = + slot === 'petals' || slot === 'flowerBase' + ? flower + : { ...dwarf, beard: 'full' as const, glasses: 'round' as const }; + const worn = wear(recipe, slot); + for (const turn of AVATAR_TURNS) + expect(pixelFigure(worn, turn).cells.length).toBeGreaterThan(0); + } + }); +});