diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a79563..68c2cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `@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. - `@botharness/pixel-avatar` 0.3.0: Avatar Species and split side hair as asset version 2. `withSpecies(recipe, species)` adds a `species` (`human` or `goblin`), a separate `rightSideHair` and optional `leftSideHairColor`/`rightSideHairColor`, moving a suggested skin color to the new species' `AVATAR_SPECIES_SWATCHES`. The goblin has pointed ears drawn over hair and small tusks, and keeps every head turn and speech mouth layer. Version 1 recipes, seeded faces and the existing golden output are unchanged, and a human version 2 recipe renders exactly like its version 1 source. Older consumers reject version 2 recipes through `isPixelAvatarRecipe`, so they can fall back to a saved snapshot. diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 175178f..5466332 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -21,6 +21,7 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc - `AVATAR_SPECIES`, `AVATAR_SPECIES_SWATCHES`, `AVATAR_PIECE_COLORS`, `withSpecies(recipe, species)`: Avatar Species (`human`, `goblin`) on the same rig, as asset version 2. `withSpecies` keeps every choice, splits side hair into `sideHair` (left) and `rightSideHair`, and accepts optional `leftSideHairColor`/`rightSideHairColor`, where left and right are as seen on screen. Consumers built before version 2 reject these recipes with `isPixelAvatarRecipe` and can show a saved snapshot instead. - `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. - `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 d5878a4..a3091d3 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@botharness/pixel-avatar", - "version": "0.5.0", + "version": "0.6.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 f1b18d6..89e8d17 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -1,6 +1,6 @@ import { escapeAttribute, type PixelCell } from '@botharness/pixel-morph'; -import type { PartCell, PartTone } from './part.js'; -import { AVATAR_PIECE_COLORS, type PixelAvatarRecipe } from './recipe.js'; +import { HAIR_PART_SLOTS, type HairPartSlot, type PartCell, type PartTone } from './part.js'; +import { AVATAR_PIECE_COLORS, detailedRecipe, wornPart, type PixelAvatarRecipe } from './recipe.js'; type Cell = string | undefined; type Grid = Cell[][]; @@ -366,7 +366,14 @@ function detailedHair( recipe: Recipe, s: number, b: number, -): { back: Mask; front: Mask; ties?: Point[]; bands?: boolean; sides?: [Mask, Mask] } { +): { + back: Mask; + front: Mask; + ties?: Point[]; + bands?: boolean; + sides?: [Mask, Mask]; + pieces?: Record; +} { const of = (style: Recipe['hair'] | undefined) => hair({ ...recipe, hair: style ?? 'none' }, s, b); const bangs = of(recipe.bangs); @@ -383,15 +390,16 @@ function detailedHair( (sign: -1 | 1): Mask => (x, y) => y > 7 && sign * (x + 0.5 - C - s) >= 7 && lengthen((sign < 0 ? side : right).front)(x, y); + const fringe: Mask = (x, y) => + y <= 7 + ? bangs.front(x, y) || back.front(x, y) + : Math.abs(x + 0.5 - C - s) < 7 && bangs.front(x, y); + const behind = length === 0 ? back.back : lengthen(back.back); return { - front: (x, y) => - y <= 7 - ? bangs.front(x, y) || back.front(x, y) - : Math.abs(x + 0.5 - C - s) < 7 - ? bangs.front(x, y) - : lengthen((x + 0.5 - C - s < 0 ? side : right).front)(x, y), + front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y), sides: [sideOf(-1), sideOf(1)], - back: length === 0 ? back.back : lengthen(back.back), + pieces: { bangs: fringe, leftSideHair: sideOf(-1), rightSideHair: sideOf(1), backHair: behind }, + back: behind, ...(back.ties ? { ties: back.ties } : {}), ...(back.bands ? { bands: back.bands } : {}), }; @@ -875,7 +883,7 @@ export function pixelFigure( const covered = recipe.accessory === 'helmet' || recipe.accessory === 'hood'; const bare: Mask = () => false; const petalRing = flower ? petals(recipe.petals ?? 'trumpet', s) : bare; - const masks: ReturnType = flower + const builtin: ReturnType = flower ? { back: petalRing, front: bare, @@ -889,6 +897,52 @@ export function pixelFigure( : recipe.bangs === undefined ? hair(recipe, s, b) : detailedHair(recipe, s, b); + const drawnHair = HAIR_PART_SLOTS.flatMap((slot) => { + const part = builtin.pieces ? wornPart(recipe, slot) : undefined; + return part ? [{ slot, part }] : []; + }); + const live = (cells: readonly PartCell[], shift: number): Mask => { + const keys = new Set( + cells.flatMap(([x, y, color, tone]) => + color === 'hairColor' && tone === 0 ? [`${x + shift},${y}`] : [], + ), + ); + return (x, y) => keys.has(`${x},${y}`); + }; + const backShift = (): number => { + if (s === b || !builtin.pieces) return s; + const front = detailedHair(detailedRecipe(recipe), 0, 0).pieces!.backHair; + const misses = (shift: number) => { + let count = 0; + for (let y = 0; y < N; y++) + for (let x = -4; x < N + 4; x++) + if (front(x, y) !== builtin.pieces!.backHair(x + shift, y)) count++; + return count; + }; + return misses(b) < misses(s) ? b : s; + }; + const piece = (slot: HairPartSlot): Mask => { + const part = drawnHair.find((drawn) => drawn.slot === slot)?.part; + if (!part) return builtin.pieces![slot]; + return live(part.front, slot === 'backHair' ? backShift() : s); + }; + const masks: ReturnType = + drawnHair.length === 0 + ? builtin + : { + ...builtin, + front: (x, y) => + piece('bangs')(x, y) || piece('leftSideHair')(x, y) || piece('rightSideHair')(x, y), + sides: [piece('leftSideHair'), piece('rightSideHair')], + back: piece('backHair'), + ...(drawnHair.some((drawn) => drawn.slot === 'backHair') ? { ties: [] } : {}), + }; + const inked = (slot: HairPartSlot | 'all-front') => + drawnHair + .filter((drawn) => (slot === 'all-front' ? drawn.slot !== 'backHair' : drawn.slot === slot)) + .flatMap((drawn) => + drawn.part.front.filter(([, , color, tone]) => !(color === 'hairColor' && tone === 0)), + ); const body = blank(); if (flower) flowerBase(recipe, body, s); @@ -915,17 +969,18 @@ export function pixelFigure( shade(hairColor, 0.68), ); - const headpiece = recipe.assetVersion === 3 ? recipe.headpiece : undefined; - const placeCells = (grid: Grid, cells: readonly PartCell[]) => { + const headpiece = wornPart(recipe, 'headpiece'); + const placeCells = (grid: Grid, cells: readonly PartCell[], compress = true) => { const placed = cells.map(([x, y, color, tone]) => { const dx = x + 0.5 - C; - const far = turned && d * dx < 0 && Math.abs(dx) >= 5; + const far = compress && turned && d * dx < 0 && Math.abs(dx) >= 5; const base = color.startsWith('#') ? color.toLowerCase() : recipe[color as 'hairColor']; return { x: x + s + (far ? d : 0), y, c: TONES[tone](base), far }; }); for (const cell of [...placed.filter((p) => p.far), ...placed.filter((p) => !p.far)]) if (cell.x >= 0 && cell.x < N) grid[cell.y]![cell.x] = cell.c; }; + placeCells(back, inked('backHair'), false); if (headpiece) placeCells(back, headpiece.back); const head = blank(); @@ -1039,6 +1094,7 @@ export function pixelFigure( }; 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]) @@ -1232,3 +1288,27 @@ export function pixelFigure( ].join(''), }; } + +/** + * The hair piece in `slot` as a Custom Part to start drawing from: the drawn part worn there, + * or the built-in piece flattened for this recipe in the front pose as live `hairColor` cells. + * A flattened piece keeps this shape and no longer follows face shape or hair length. + */ +export function hairPieceStart( + recipe: PixelAvatarRecipe, + slot: HairPartSlot, +): { slot: HairPartSlot; front: PartCell[]; back: PartCell[] } { + const worn = wornPart(recipe, slot); + if (worn) return { slot, front: [...worn.front], back: [] }; + const built = detailedHair(detailedRecipe(recipe), 0, 0); + const mask = built.pieces![slot]; + const ties = new Set( + slot === 'backHair' ? (built.ties ?? []).map(([x, y]) => `${Math.round(x)},${y}`) : [], + ); + const front: PartCell[] = []; + for (let y = 0; y < N; y++) + for (let x = 0; x < N; x++) + if (ties.has(`${x},${y}`)) front.push([x, y, 'shirtColor', -1]); + else if (mask(x, y)) front.push([x, y, 'hairColor', 0]); + return { slot, front, back: [] }; +} diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index 2309504..02271b3 100644 --- a/packages/avatar/src/index.ts +++ b/packages/avatar/src/index.ts @@ -1,5 +1,6 @@ export type { PixelCell } from '@botharness/pixel-morph'; export { + hairPieceStart, partToneColor, pixelFigure, pixelTileColor, @@ -41,12 +42,18 @@ export { type PixelAvatarRecipeV2, type PixelAvatarRecipeV3, withHeadpiece, + withCustomPart, + wornPart, + CUSTOM_PART_KEYS, + type CustomPartKey, } from './recipe.js'; export { MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, + HAIR_PART_SLOTS, + isHairPartSlot, canonicalCustomPart, createCustomPart, customPartId, @@ -64,6 +71,7 @@ export { type PartLayerName, type PartSlot, type PartTone, + type HairPartSlot, 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 fe1d7d9..c7c8c7b 100644 --- a/packages/avatar/src/part.ts +++ b/packages/avatar/src/part.ts @@ -2,13 +2,26 @@ import { AVATAR_COLORS, type AvatarColor } from './recipe.js'; /** * Custom Part slots. A headpiece covers the top of the tile, around the hair: its back layer is - * drawn behind the hair and its front layer over it. Cells are in tile coordinates, so the - * Avatar centerline sits between x = 15 and x = 16. + * drawn behind the hair and its front layer over it. Hair slots replace one built-in hair piece + * and use only the front layer. Cells are in tile coordinates, so the Avatar centerline sits + * between x = 15 and x = 16. */ export const PART_SLOTS = { headpiece: { width: 32, height: 16 }, + bangs: { width: 32, height: 32 }, + leftSideHair: { width: 32, height: 32 }, + rightSideHair: { width: 32, height: 32 }, + backHair: { width: 32, height: 32 }, } as const; export type PartSlot = keyof typeof PART_SLOTS; +/** + * Hair slots. In a hair part, a `hairColor` cell at tone 0 is live hair: it is shaded with the + * rest of the hair, exactly like a built-in piece. Every other cell shows its own color. + */ +export const HAIR_PART_SLOTS = ['bangs', 'leftSideHair', 'rightSideHair', 'backHair'] as const; +export type HairPartSlot = (typeof HAIR_PART_SLOTS)[number]; +export const isHairPartSlot = (slot: PartSlot): slot is HairPartSlot => + (HAIR_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]; @@ -45,6 +58,8 @@ 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; 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 6e2aadc..674424b 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -1,4 +1,9 @@ -import { canonicalCustomPart, isPixelCustomPart, type PixelCustomPart } from './part.js'; +import { + canonicalCustomPart, + isPixelCustomPart, + type PartSlot, + type PixelCustomPart, +} from './part.js'; import { seededRandom } from './random.js'; export const AVATAR_PARTS = { @@ -188,7 +193,7 @@ export type PixelAvatarRecipeV1 = Meta & { assetVersion: 1 } & { } & { [P in AvatarRange]?: number } & { species?: never; rightSideHair?: never; -} & { [P in AvatarPieceColor | AvatarExtraPart]?: never } & { headpiece?: never }; +} & { [P in AvatarPieceColor | AvatarExtraPart]?: never } & { [P in CustomPartKey]?: never }; /** * Asset version 2: a species, the full split hair and geometry, a separate right side hair @@ -203,13 +208,26 @@ export type PixelAvatarRecipeV2 = Meta & { assetVersion: 2 } & { rightSideHair: (typeof AVATAR_HAIR_PARTS)['sideHair'][number]; } & { [P in AvatarPieceColor]?: string } & { [P in AvatarExtraPart]?: (typeof AVATAR_EXTRA_PARTS)[P][number]; -} & { headpiece?: never }; +} & { [P in CustomPartKey]?: never }; -/** Asset version 3: version 2 with an embedded Custom Part in the headpiece slot. */ -export type PixelAvatarRecipeV3 = Omit & { +/** The recipe key that embeds the Custom Part worn in each slot. */ +export const CUSTOM_PART_KEYS = { + headpiece: 'headpiece', + bangs: 'bangsPart', + leftSideHair: 'leftSideHairPart', + rightSideHair: 'rightSideHairPart', + backHair: 'backHairPart', +} as const satisfies Record; +export type CustomPartKey = (typeof CUSTOM_PART_KEYS)[PartSlot]; +const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS) as [PartSlot, CustomPartKey][]; + +/** + * Asset version 3: version 2 wearing at least one embedded Custom Part. A drawn hair piece + * replaces the built-in piece, which stays saved and returns when the part is taken off. + */ +export type PixelAvatarRecipeV3 = Omit & { assetVersion: 3; - headpiece: PixelCustomPart; -}; +} & { [P in CustomPartKey]?: PixelCustomPart }; export type PixelAvatarRecipe = PixelAvatarRecipeV1 | PixelAvatarRecipeV2 | PixelAvatarRecipeV3; @@ -245,12 +263,17 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false; const v3 = r['assetVersion'] === 3; const v2 = r['assetVersion'] === 2 || v3; - if (v3 ? !isPixelCustomPart(r['headpiece']) : Object.hasOwn(r, 'headpiece')) return false; + const worn = PART_KEY_ENTRIES.filter(([, key]) => Object.hasOwn(r, key)); + if (v3 ? worn.length === 0 : worn.length !== 0) return false; + for (const [slot, key] of worn) { + const part = r[key]; + if (!isPixelCustomPart(part) || part.slot !== slot) return false; + } const pieces = AVATAR_PIECE_COLORS.filter((key) => Object.hasOwn(r, key)); const extras = (Object.keys(AVATAR_EXTRA_PARTS) as AvatarExtraPart[]).filter((key) => Object.hasOwn(r, key), ); - const v2Keys = v2 ? 2 + pieces.length + extras.length + (v3 ? 1 : 0) : 0; + const v2Keys = v2 ? 2 + pieces.length + extras.length + worn.length : 0; if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed + v2Keys) return false; if ( @@ -301,7 +324,11 @@ export function canonicalRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { if (recipe[key] !== undefined) canonical[key] = recipe[key].toLowerCase(); for (const key of Object.keys(AVATAR_EXTRA_PARTS) as AvatarExtraPart[]) if (recipe[key] !== undefined) canonical[key] = recipe[key]; - if (recipe.assetVersion === 3) canonical['headpiece'] = canonicalCustomPart(recipe.headpiece); + if (recipe.assetVersion === 3) + for (const [, key] of PART_KEY_ENTRIES) { + const part = recipe[key]; + if (part !== undefined) canonical[key] = canonicalCustomPart(part); + } } return canonical as PixelAvatarRecipe; } @@ -368,21 +395,35 @@ export function withSpecies( } /** - * Returns the recipe wearing a Custom Part in the headpiece slot (asset version 3), or without - * one (asset version 2). The recipe embeds its own copy of the part. + * Returns the recipe wearing `part` in `slot`, or with that slot's part taken off. The recipe + * embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise. */ +export function withCustomPart( + recipe: PixelAvatarRecipe, + slot: PartSlot, + part: PixelCustomPart | undefined, +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 { + const base: Record = { + ...(recipe.assetVersion === 1 ? withSpecies(recipe, 'human') : recipe), + }; + const key = CUSTOM_PART_KEYS[slot]; + delete base[key]; + if (part !== undefined) base[key] = canonicalCustomPart({ ...part, slot }); + const wearing = PART_KEY_ENTRIES.some(([, k]) => base[k] !== undefined); + return { ...base, assetVersion: wearing ? 3 : 2 } as PixelAvatarRecipeV2 | PixelAvatarRecipeV3; +} + +/** `withCustomPart` for the headpiece slot. */ export function withHeadpiece( recipe: PixelAvatarRecipe, part: PixelCustomPart | undefined, ): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 { - const base = recipe.assetVersion === 1 ? withSpecies(recipe, 'human') : recipe; - const { headpiece: _, ...rest } = base; - if (part === undefined) return { ...rest, assetVersion: 2 } as PixelAvatarRecipeV2; - return { - ...rest, - assetVersion: 3, - headpiece: canonicalCustomPart(part), - } as PixelAvatarRecipeV3; + return withCustomPart(recipe, 'headpiece', part); +} + +/** The Custom Part worn in a slot, if any. */ +export function wornPart(recipe: PixelAvatarRecipe, slot: PartSlot): PixelCustomPart | undefined { + return recipe.assetVersion === 3 ? recipe[CUSTOM_PART_KEYS[slot]] : undefined; } export function detailedRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { diff --git a/packages/avatar/test/hair-part.test.ts b/packages/avatar/test/hair-part.test.ts new file mode 100644 index 0000000..0cbfb9a --- /dev/null +++ b/packages/avatar/test/hair-part.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { + AVATAR_HAIR_PARTS, + AVATAR_TURNS, + DEFAULT_RECIPE, + HAIR_PART_SLOTS, + canonicalRecipe, + hairPieceStart, + isPixelAvatarRecipe, + isPixelCustomPart, + pixelAvatarSvg, + pixelFigure, + withCustomPart, + withHeadpiece, + withSpecies, + wornPart, + type HairPartSlot, + type PartCell, + type PixelAvatarRecipe, + type PixelCustomPart, +} from '../src/index.js'; + +const base = withSpecies(DEFAULT_RECIPE, 'human'); +const wearStart = (recipe: PixelAvatarRecipe, slots: readonly HairPartSlot[] = HAIR_PART_SLOTS) => + slots.reduce( + (current, slot) => withCustomPart(current, slot, hairPieceStart(recipe, slot)), + recipe, + ); +const cellAt = (recipe: PixelAvatarRecipe, x: number, y: number) => + pixelFigure(recipe, 0).cells.find((cell) => cell.x === x && cell.y === y)?.c; + +describe('drawn hair pieces', () => { + it( + 'flattens every built-in piece and renders an unchanged copy identically', + { timeout: 30_000 }, + () => { + const variants = [ + ...AVATAR_HAIR_PARTS.bangs.map((bangs) => ({ bangs })), + ...AVATAR_HAIR_PARTS.sideHair.map((sideHair) => ({ sideHair, rightSideHair: sideHair })), + ...AVATAR_HAIR_PARTS.backHair.map((backHair) => ({ backHair })), + { sideHair: 'long' as const, rightSideHair: 'bob' as const }, + ]; + for (const variant of variants) + for (const hairLength of [0, 2]) { + const recipe = { ...base, ...variant, hairLength } as PixelAvatarRecipe; + const label = `${JSON.stringify(variant)}/${hairLength}`; + const worn = wearStart(recipe); + expect(worn.assetVersion, label).toBe(3); + expect(isPixelAvatarRecipe(worn), label).toBe(true); + expect(pixelAvatarSvg(worn), label).toBe(pixelAvatarSvg(recipe)); + } + }, + ); + + it('turns an unchanged copy exactly like the built-in piece', () => { + const turned = (recipe: PixelAvatarRecipe) => pixelAvatarSvg(recipe, { turns: AVATAR_TURNS }); + // Wolf back hair is notched on a tile-fixed pattern, so its turned mask is no pure shift; a + // drawn copy keeps the notches it was drawn with and moves with the back-hair offset. + for (const backHair of AVATAR_HAIR_PARTS.backHair.filter((style) => style !== 'wolf')) { + const recipe = { ...base, backHair } as PixelAvatarRecipe; + expect(turned(wearStart(recipe, ['backHair'])), backHair).toBe(turned(recipe)); + } + for (const bangs of AVATAR_HAIR_PARTS.bangs) { + const recipe = { ...base, bangs } as PixelAvatarRecipe; + expect(turned(wearStart(recipe, ['bangs'])), bangs).toBe(turned(recipe)); + } + for (const sideHair of AVATAR_HAIR_PARTS.sideHair) { + const recipe = { ...base, sideHair, rightSideHair: sideHair } as PixelAvatarRecipe; + expect(turned(wearStart(recipe, ['leftSideHair', 'rightSideHair'])), sideHair).toBe( + turned(recipe), + ); + } + }); + + it('keeps per-side hair colors on drawn side pieces', () => { + const recipe = { ...base, sideHair: 'long' as const, leftSideHairColor: '#3fc1b8' }; + expect(pixelAvatarSvg(wearStart(recipe))).toBe(pixelAvatarSvg(recipe)); + }); + + it('starts from the worn drawn piece once one is worn', () => { + const start = hairPieceStart(base, 'bangs'); + const trimmed: PixelCustomPart = { ...start, front: start.front.slice(3) }; + const worn = withCustomPart(base, 'bangs', trimmed); + expect(hairPieceStart(worn, 'bangs').front).toEqual(wornPart(worn, 'bangs')!.front); + }); + + it('reshapes, recolors with the hair and shows literal cells as drawn', () => { + const start = hairPieceStart(base, 'bangs'); + const extra: PartCell[] = [ + [15, 0, 'hairColor', 0], + [16, 0, '#efb93f', 0], + ]; + const worn = withCustomPart(base, 'bangs', { ...start, front: [...start.front, ...extra] }); + expect(cellAt(base, 15, 0)).toBeUndefined(); + expect(cellAt(worn, 15, 0)).not.toBeUndefined(); + expect(cellAt(worn, 16, 0)).toBe('#efb93f'); + const pink = { ...worn, hairColor: '#f06292' }; + expect(pixelAvatarSvg(pink)).not.toBe(pixelAvatarSvg(worn)); + expect(cellAt(pink, 16, 0)).toBe('#efb93f'); + }); + + it('hides drawn hair under a helmet or hood and on a flower', () => { + const worn = wearStart(base); + for (const accessory of ['helmet', 'hood'] as const) + expect(pixelAvatarSvg({ ...worn, accessory } as PixelAvatarRecipe)).toBe( + pixelAvatarSvg({ ...base, accessory }), + ); + expect(pixelAvatarSvg(withSpecies(worn, 'flower'))).toBe( + pixelAvatarSvg(withSpecies(base, 'flower')), + ); + }); + + it('turns drawn hair with the head and keeps every speech mouth', () => { + const worn = wearStart(base); + for (const turn of AVATAR_TURNS) + expect(pixelFigure(worn, turn).cells.length).toBeGreaterThan(0); + const head = pixelFigure(worn, 0, { mouthLayers: true }).head; + for (const state of ['closed', 'half-open', 'open']) + expect(head).toContain(`data-avatar-mouth="${state}"`); + }); + + it('restores the built-in piece when the drawn piece is taken off', () => { + const worn = withCustomPart(base, 'backHair', hairPieceStart(base, 'backHair')); + expect(withCustomPart(worn, 'backHair', undefined)).toEqual(base); + const both = withHeadpiece(worn, { + slot: 'headpiece', + front: [[3, 3, 'hairColor', 0]], + back: [], + }); + expect(withCustomPart(both, 'backHair', undefined).assetVersion).toBe(3); + expect(canonicalRecipe(both)).toEqual(both); + }); + + it('validates hair slots and the slot each recipe key holds', () => { + const start = hairPieceStart(base, 'bangs'); + expect(isPixelCustomPart(start)).toBe(true); + expect(isPixelCustomPart({ ...start, back: [[0, 0, 'hairColor', 0]] })).toBe(false); + expect(isPixelCustomPart({ ...start, front: [[0, 32, 'hairColor', 0]] })).toBe(false); + const worn = withCustomPart(base, 'bangs', start); + expect(isPixelAvatarRecipe({ ...worn, bangsPart: { ...start, slot: 'backHair' } })).toBe(false); + const { bangsPart: _, ...empty } = worn as Record; + expect(isPixelAvatarRecipe(empty)).toBe(false); + expect(isPixelAvatarRecipe({ ...base, bangsPart: start })).toBe(false); + }); +});