diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d75ac..392da3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `@botharness/pixel-avatar` 0.10.0: animal species. `cat`, `dog`, `fox`, `rabbit` and `bear` (`AVATAR_ANIMAL_SPECIES`, asset version 4 only) draw a muzzle in a light fur tone, an animal nose instead of the human nose, and their own ears, with suggested fur colors in `AVATAR_SPECIES_SWATCHES`. The skin color is the fur color. An optional `pattern` (`solid`, `tabby`, `spots`, `patches`, `colorpoint`) is drawn in fur tones, so it follows recoloring, and the new `pattern` Custom Part slot (`patternPart`, asset version 4 only, so `PART_SLOTS` and `REPLACE_PART_SLOTS` gain `pattern`) replaces it with a drawn one. `pattern: 'solid'` canonicalizes to no pattern. On an animal, ear headpieces and ear accessories stay saved but are hidden (`hiddenChoices`). Version 2 seeds never pick an animal, so they are unchanged, as is every earlier recipe. - `@botharness/pixel-avatar` 0.9.0: asset version 4, every hair piece colored and built-in headpieces. `bangsColor`, `backHairColor` and `strandColor` join the side hair colors, each defaulting to `hairColor`. A new single `strand` (`ahoge`, `curl`, `double`) stands up from the crown. The headpiece slot holds a built-in headpiece (`catears`, `bunnyears`, `horseears`, `horns`, `halo`, `wings`) or a Custom Part, alongside any accessory. A headpiece's base sits behind the hair, the rest in front, and the far side hides when turned. `withPieces` raises a recipe to version 4 and moves a headpiece accessory into the empty headpiece slot; call it only when the Human edits. `withBuiltInHeadpiece` and `builtInHeadpiece` set and read the built-in headpiece, and `headpieceStart` flattens the worn headpiece into a Custom Part (base on the back layer) that renders identically facing front. Every earlier recipe renders unchanged. - `@botharness/pixel-avatar` 0.8.0: drawing tool algorithms for Custom Parts. `partLinePoints` (Bresenham, with 0°/45°/90° snapping), `partRectPoints` (outline, with a square option), `gradientPartLayer` (a linear gradient over the start cell's 4-connected region that steps through tones with 4×4 or 2×2 ordered dithering via `ditherThreshold`), `noisePartLayer` (seeded ±1 tone texture) and `shadePartLayer` (moves each colored cell on a stroke one tone, once). Every tool mirrors across the centerline, and each produces ordinary cells, so a part's content and identity never depend on the tool or the seed. Rendering is unchanged. - `@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. diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 510f98e..8f99312 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -26,5 +26,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc - `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. - `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. - `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. +- `AVATAR_ANIMAL_SPECIES`, `AVATAR_PATTERNS`, `isAnimalSpecies`: animal species in asset version 4. Each has a muzzle, an animal nose and its own ears, uses the skin color as fur, and takes an optional fur `pattern` drawn in fur tones. A drawn `pattern` Custom Part replaces the built-in one. Ear headpieces and ear accessories are kept but hidden on an animal. - `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 ecfeb3d..3dbda7c 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@botharness/pixel-avatar", - "version": "0.9.0", + "version": "0.10.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 d71285e..d80dcfa 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -13,8 +13,10 @@ import { AVATAR_PIECE_COLORS_V4, builtInHeadpiece, detailedRecipe, + isAnimalSpecies, hiddenChoices, wornPart, + type AvatarAnimalSpecies, type PixelAvatarRecipe, } from './recipe.js'; @@ -864,6 +866,52 @@ function drawHeadpiece( } } +const ANIMALS: Record< + AvatarAnimalSpecies, + { ears: string[]; x: number; y: number; inner: 'pink' | 'fur'; side?: boolean } +> = { + cat: { ears: ['E...', 'EE..', 'EiE.', 'EEEE'], x: -9, y: 2, inner: 'pink' }, + fox: { ears: ['D....', 'EE...', 'EiE..', 'EiiE.', 'EEEEE'], x: -10, y: 1, inner: 'fur' }, + rabbit: { ears: ['EE', 'Ei', 'Ei', 'Ei', 'Ei', 'EE'], x: -6, y: 0, inner: 'pink' }, + bear: { ears: ['.EE.', 'EiiE', 'EEEE'], x: -10, y: 3, inner: 'fur' }, + dog: { ears: ['EEE.', 'EEEE', 'EEEE', '.EEE', '..EE'], x: -13, y: 7, inner: 'fur', side: true }, +}; + +function patternMask(recipe: Recipe, s: number, fs: number, face: Mask): Mask { + const cx = C + s; + const mid = C + fs * 0.6; + switch (recipe.pattern) { + case 'tabby': { + let top = 0; + while (top < N && !face(Math.round(cx), top)) top++; + return (x, y) => + (y >= top && + y <= top + 3 && + Math.abs(x + 0.5 - cx) <= 3 && + (x + 40 - Math.round(cx)) % 2 === 0) || + ((y === 17 || y === 19) && Math.abs(x + 0.5 - mid) >= 6.5); + } + case 'spots': + return (x, y) => + [ + [-7, 21], + [-6, 22], + [-5, 21], + [5, 21], + [6, 22], + [7, 21], + [-2, 23], + [2, 23], + ].some(([dx, dy]) => x === Math.round(cx) + dx! && y === dy); + case 'patches': + return (x, y) => ellipse(cx - 4, 14, 3.6, 3.2)(x, y) || ellipse(cx + 5, 9, 2.6, 2)(x, y); + case 'colorpoint': + return (x, y) => (x + 0.5 - cx) ** 2 / 64 + (y + 0.5 - 16.6) ** 2 / 49 > 0.62; + default: + return () => false; + } +} + const POINTED_EARS: Partial< Record, { rows: string[]; rise: number }> > = { @@ -998,11 +1046,17 @@ function renderFigure( } : front; const flower = recipe.species === 'flower'; + const animal = isAnimalSpecies(recipe.species) ? ANIMALS[recipe.species] : undefined; 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); + const shown = + slot === 'petals' || slot === 'flowerBase' + ? flower + : slot === 'pattern' + ? animal !== undefined + : !hiddenNow.includes(slot); return part && shown ? [[slot, part.front] as const] : []; }), ); @@ -1141,12 +1195,24 @@ function renderFigure( placeCells(back, inked('backHair'), false); overlay(back, 'petals'); if (headpiece) placeCells(back, headpiece.back); - const wornHead = flower ? undefined : builtInHeadpiece(recipe); + const wornHead = flower || hiddenNow.includes('headpiece') ? undefined : builtInHeadpiece(recipe); const headFront = blank(); if (wornHead) drawHeadpiece(wornHead, recipe, back, headFront, s, turned ? d : 0); const head = blank(); paint(head, face, skin); + if (animal) { + const pattern = patternMask(recipe, s, fs, face); + if (!skip('pattern')) + paint( + head, + (x, y) => face(x, y) && pattern(x, y), + TONES[recipe.pattern === 'patches' ? -2 : -1](skin), + ); + overlay(head, 'pattern'); + const muzzle = ellipse(C + fs * 0.6, 20.6 + (recipe.height ?? 0), 3.9, 2.6); + paint(head, (x, y) => face(x, y) && muzzle(x, y), TONES[2](skin)); + } const h = recipe.height ?? 0; if (recipe.beard && !flower && !skip('beard')) { const centre = C + fs * 0.6; @@ -1176,6 +1242,24 @@ function renderFigure( const pointed = recipe.species ? POINTED_EARS[recipe.species] : undefined; if (flower) { // A flower has petals instead of ears. + } else if (animal) { + paint(head, masks.front, hairColor); + const pal = { + E: recipe.pattern === 'colorpoint' || animal.side ? shade(skin, 0.72) : skin, + i: animal.inner === 'pink' ? '#f4a3b5' : shade(skin, 0.72), + D: shade(skin, 0.45), + }; + const cx = Math.round(C + s); + const sides = animal.side && turned ? [-d] : [-1, 1]; + for (const side of sides) + sprite( + head, + animal.ears, + side < 0 ? cx + animal.x : cx - animal.x - animal.ears[0]!.length, + animal.y, + pal, + side > 0, + ); } else if (pointed) { // Pointed ears are drawn over hair: the outer tip rises above the ear line. const pal = { E: skin, e: shade(skin, 0.86), i: shade(skin, 0.74) }; @@ -1198,10 +1282,11 @@ function renderFigure( E: skin, e: shade(skin, 0.8), }); - if (!pointed && !flower) paint(head, masks.front, hairColor); + if (!pointed && !flower && !animal) 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 && !skip('accessory')) accessory(recipe, head, s, face); + if (!flower && !skip('accessory') && !hiddenNow.includes('accessory')) + accessory(recipe, head, s, face); const shadeHair = (g: Grid) => { const out = g.map((r) => [...r]); @@ -1331,7 +1416,11 @@ function renderFigure( const cx = Math.round(C + fs * (turned ? 0.85 : 0.6)); if (flower) { // A flower's face is only its bead eyes and its mouth. - } else if (recipe.species === 'dwarf') + } else if (animal) + sprite(features, ['NNN', '.N.'], Math.round(C + fs * 0.6) - 1, 18 + h, { + N: mix(INK, skin, 0.2), + }); + else if (recipe.species === 'dwarf') sprite(features, ['.N.', 'NnN'], cx - 1, 17 + h, { N: shade(skin, 0.84), n: shade(skin, 0.72), diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index 8020bb5..1d0361e 100644 --- a/packages/avatar/src/index.ts +++ b/packages/avatar/src/index.ts @@ -20,6 +20,12 @@ export { AVATAR_PIECE_COLORS_V4, AVATAR_STRANDS, AVATAR_HEADPIECES, + AVATAR_ANIMAL_SPECIES, + AVATAR_PATTERNS, + isAnimalSpecies, + type AvatarAnimalSpecies, + type AvatarSpeciesV4, + type AvatarPattern, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, diff --git a/packages/avatar/src/part.ts b/packages/avatar/src/part.ts index db8fff2..2faf292 100644 --- a/packages/avatar/src/part.ts +++ b/packages/avatar/src/part.ts @@ -21,6 +21,7 @@ export const PART_SLOTS = { cheeks: { width: 32, height: 32 }, petals: { width: 32, height: 32 }, flowerBase: { width: 32, height: 32 }, + pattern: { width: 32, height: 32 }, } as const; export type PartSlot = keyof typeof PART_SLOTS; /** @@ -44,6 +45,7 @@ export const REPLACE_PART_SLOTS = [ 'cheeks', 'petals', 'flowerBase', + 'pattern', ] as const; export type ReplacePartSlot = (typeof REPLACE_PART_SLOTS)[number]; export const isReplacePartSlot = (slot: PartSlot): slot is ReplacePartSlot => diff --git a/packages/avatar/src/recipe.ts b/packages/avatar/src/recipe.ts index af759be..c33b32a 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -162,6 +162,18 @@ export type AvatarColor = (typeof AVATAR_COLORS)[number]; */ export const AVATAR_SPECIES = ['human', 'goblin', 'elf', 'dwarf', 'orc', 'flower'] as const; export type AvatarSpecies = (typeof AVATAR_SPECIES)[number]; +/** + * Anthropomorphic animal species (asset version 4): a muzzle, animal ears and fur colors. The + * skin color is the fur color, and an optional `pattern` is drawn in its tones. + */ +export const AVATAR_ANIMAL_SPECIES = ['cat', 'dog', 'fox', 'rabbit', 'bear'] as const; +export type AvatarAnimalSpecies = (typeof AVATAR_ANIMAL_SPECIES)[number]; +export type AvatarSpeciesV4 = AvatarSpecies | AvatarAnimalSpecies; +export const isAnimalSpecies = (species: unknown): species is AvatarAnimalSpecies => + (AVATAR_ANIMAL_SPECIES as readonly unknown[]).includes(species); +/** Fur patterns for animal species, drawn in skin-color tones so they survive recoloring. */ +export const AVATAR_PATTERNS = ['solid', 'tabby', 'spots', 'patches', 'colorpoint'] as const; +export type AvatarPattern = (typeof AVATAR_PATTERNS)[number]; /** Optional per-piece hair colors (asset version 2); an absent piece uses `hairColor`. */ export const AVATAR_PIECE_COLORS = ['leftSideHairColor', 'rightSideHairColor'] as const; export type AvatarPieceColor = (typeof AVATAR_PIECE_COLORS)[number]; @@ -187,7 +199,7 @@ export const AVATAR_HEADPIECES = [ 'wings', ] as const; export type AvatarHeadpiece = (typeof AVATAR_HEADPIECES)[number]; -type V4Key = AvatarPieceColorV4 | 'strand'; +type V4Key = AvatarPieceColorV4 | 'strand' | 'pattern'; /** Part choices that exist only in asset version 2, added after every version 1 choice. */ export const AVATAR_PARTS_V2 = { @@ -250,6 +262,7 @@ export const CUSTOM_PART_KEYS = { cheeks: 'cheeksPart', petals: 'petalsPart', flowerBase: 'flowerBasePart', + pattern: 'patternPart', } as const satisfies Record; export type CustomPartKey = (typeof CUSTOM_PART_KEYS)[PartSlot]; const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS) as [PartSlot, CustomPartKey][]; @@ -260,7 +273,7 @@ const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS) as [PartSlot, CustomPa */ export type PixelAvatarRecipeV3 = Omit & { assetVersion: 3; -} & { [P in CustomPartKey]?: PixelCustomPart }; +} & { [P in Exclude]?: PixelCustomPart } & { patternPart?: never }; /** * Asset version 4: version 2 with a color for every hair piece, a single `strand`, and the @@ -269,12 +282,14 @@ export type PixelAvatarRecipeV3 = Omit & { assetVersion: 4; } & { [P in Exclude]?: PixelCustomPart } & { headpiece?: PixelCustomPart | AvatarHeadpiece; strand?: AvatarStrand; + species: AvatarSpeciesV4; + pattern?: AvatarPattern; } & { [P in AvatarPieceColorV4]?: string }; export type PixelAvatarRecipe = @@ -323,7 +338,10 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe ([slot, key]) => Object.hasOwn(r, key) && !(slot === 'headpiece' && builtInHead), ); if (v3 ? worn.length === 0 : !v4 && worn.length !== 0) return false; - const newer = [...AVATAR_PIECE_COLORS_V4, 'strand'].filter((key) => Object.hasOwn(r, key)); + if (!v4 && Object.hasOwn(r, CUSTOM_PART_KEYS.pattern)) return false; + const newer = [...AVATAR_PIECE_COLORS_V4, 'strand', 'pattern'].filter((key) => + Object.hasOwn(r, key), + ); if ( newer.length !== 0 && (!v4 || @@ -331,7 +349,10 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe (key) => !Object.hasOwn(r, key) || (typeof r[key] === 'string' && /^#[\da-f]{6}$/iu.test(r[key])), ) || - (Object.hasOwn(r, 'strand') && !(AVATAR_STRANDS as readonly unknown[]).includes(r['strand']))) + (Object.hasOwn(r, 'strand') && + !(AVATAR_STRANDS as readonly unknown[]).includes(r['strand'])) || + (Object.hasOwn(r, 'pattern') && + !(AVATAR_PATTERNS as readonly unknown[]).includes(r['pattern']))) ) return false; for (const [slot, key] of worn) { @@ -350,7 +371,10 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe if ( v2 && (detailed === 0 || - !(AVATAR_SPECIES as readonly unknown[]).includes(r['species']) || + !( + (AVATAR_SPECIES as readonly unknown[]).includes(r['species']) || + (v4 && isAnimalSpecies(r['species'])) + ) || !(AVATAR_HAIR_PARTS.sideHair as readonly unknown[]).includes(r['rightSideHair']) || !pieces.every((key) => typeof r[key] === 'string' && /^#[\da-f]{6}$/iu.test(r[key])) || !extras.every((key) => (AVATAR_EXTRA_PARTS[key] as readonly unknown[]).includes(r[key]))) @@ -403,6 +427,8 @@ export function canonicalRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { } if (recipe.assetVersion === 4) { if (recipe.strand !== undefined) canonical['strand'] = recipe.strand; + if (recipe.pattern !== undefined && recipe.pattern !== 'solid') + canonical['pattern'] = recipe.pattern; for (const key of AVATAR_PIECE_COLORS_V4) if (recipe[key] !== undefined) canonical[key] = recipe[key].toLowerCase(); } @@ -411,6 +437,7 @@ export function canonicalRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { } const HAIR_PIECES = ['bangs', 'sideHair', 'rightSideHair', 'backHair'] as const; +const EAR_PIECES: readonly string[] = ['catears', 'bunnyears', 'horseears']; /** * Saved choices the recipe keeps but does not draw: a flower shows petals and a stem instead of @@ -434,6 +461,7 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { 'cheeks', 'glasses', ...(recipe.beard || wornPart(recipe, 'beard') ? ['beard'] : []), + ...(recipe.pattern || wornPart(recipe, 'pattern') ? ['pattern'] : []), ]; const hidden: string[] = []; if ( @@ -443,6 +471,12 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { hidden.push(...HAIR_PIECES, ...strand); if (recipe.species === 'dwarf') hidden.push('nose'); if (!hidden.includes('strand')) hidden.push(...bald); + if (isAnimalSpecies(recipe.species)) { + hidden.push('nose'); + if (EAR_PIECES.includes(builtInHeadpiece(recipe) ?? '')) hidden.push('headpiece'); + if (EAR_PIECES.includes(recipe.accessory) && !wornPart(recipe, 'accessory')) + hidden.push('accessory'); + } else if (recipe.pattern || wornPart(recipe, 'pattern')) hidden.push('pattern'); return hidden; } @@ -452,9 +486,13 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { * When the skin color is one of the previous species' suggested colors, it moves to the new * species' first suggestion; a custom color is kept. */ +export function withSpecies( + recipe: PixelAvatarRecipe, + species: AvatarAnimalSpecies, +): PixelAvatarRecipeV4; export function withSpecies( recipe: PixelAvatarRecipeV4, - species: AvatarSpecies, + species: AvatarSpeciesV4, ): PixelAvatarRecipeV4; export function withSpecies( recipe: PixelAvatarRecipeV3, @@ -466,12 +504,14 @@ export function withSpecies( ): PixelAvatarRecipeV2; export function withSpecies( recipe: PixelAvatarRecipe, - species: AvatarSpecies, + species: AvatarSpeciesV4, ): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4; export function withSpecies( - recipe: PixelAvatarRecipe, - species: AvatarSpecies, + source: PixelAvatarRecipe, + species: AvatarSpeciesV4, ): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { + const recipe: PixelAvatarRecipe = + isAnimalSpecies(species) && source.assetVersion !== 4 ? withPieces(source) : source; const detailed = detailedRecipe(recipe); const previous = recipe.species ?? 'human'; const suggested = AVATAR_SPECIES_SWATCHES[previous].includes(recipe.skinColor.toLowerCase()); @@ -506,12 +546,16 @@ export function withCustomPart( part: PixelCustomPart | undefined, ): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { const base: Record = { - ...(recipe.assetVersion === 1 ? withSpecies(recipe, 'human') : recipe), + ...(slot === 'pattern' && part !== undefined + ? withPieces(recipe) + : recipe.assetVersion === 1 + ? withSpecies(recipe, 'human') + : recipe), }; const key = CUSTOM_PART_KEYS[slot]; delete base[key]; if (part !== undefined) base[key] = canonicalCustomPart({ ...part, slot }); - if (recipe.assetVersion === 4) return base as PixelAvatarRecipeV4; + if (base['assetVersion'] === 4) return base as PixelAvatarRecipeV4; const wearing = PART_KEY_ENTRIES.some(([, k]) => base[k] !== undefined); return { ...base, assetVersion: wearing ? 3 : 2 } as PixelAvatarRecipeV2 | PixelAvatarRecipeV3; } @@ -714,13 +758,18 @@ export const seededRecipe: (seed: string) => PixelAvatarRecipe = createSeededRecipe('botharness-avatar'); /** Suggested body colors per species; any color remains allowed. */ -export const AVATAR_SPECIES_SWATCHES: Record = { +export const AVATAR_SPECIES_SWATCHES: Record = { human: AVATAR_SWATCHES.skinColor, goblin: ['#9cc464', '#7fae4f', '#6a9a45', '#b3cf7a', '#5c8a4a', '#8fa86a'], elf: ['#fbe7d6', '#f2d3c0', '#e2c9b0', '#d9c7e8', '#b9c6dd', '#8f7fa8'], dwarf: ['#f2c4a0', '#e9b08a', '#e0a87e', '#c68863', '#f0b8a0', '#9a6142'], orc: ['#8a9a5b', '#6f8a4e', '#7d8c6a', '#5f6f4a', '#9aa070', '#6a7a7a'], flower: ['#f6d36b', '#f2b84b', '#ffe3cf', '#e8a85a', '#c98b4a', '#f4f1ec'], + cat: ['#f2a65a', '#9aa3ad', '#3a3438', '#f4f1ec', '#c98b4a', '#e8d2b0'], + dog: ['#c98b4a', '#e8d2b0', '#8a5a36', '#f4f1ec', '#3a3438', '#d9a066'], + fox: ['#e0763a', '#c4452f', '#f2a65a', '#f4f1ec', '#8a5a36', '#9aa3ad'], + rabbit: ['#f4f1ec', '#d9c7b8', '#9aa3ad', '#8a5a36', '#e8d2b0', '#3a3438'], + bear: ['#8a5a36', '#5a3a2a', '#c98b4a', '#3a3438', '#f4f1ec', '#d9a066'], }; const preset = (parts: Partial): PixelAvatarRecipeV1 => ({ diff --git a/packages/avatar/test/animals.test.ts b/packages/avatar/test/animals.test.ts new file mode 100644 index 0000000..5a3fc00 --- /dev/null +++ b/packages/avatar/test/animals.test.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + AVATAR_ANIMAL_SPECIES, + AVATAR_PATTERNS, + AVATAR_SPECIES_SWATCHES, + AVATAR_TURNS, + DEFAULT_RECIPE, + canonicalRecipe, + hiddenChoices, + isPixelAvatarRecipe, + pixelAvatarSvg, + pixelFigure, + replacePartStart, + seededRecipeV2, + withBuiltInHeadpiece, + withCustomPart, + withSpecies, + type PixelAvatarRecipe, +} from '../src/index.js'; + +const hash = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16); +const render = (recipe: PixelAvatarRecipe) => ({ + front: hash(pixelAvatarSvg(recipe)), + turns: hash(pixelAvatarSvg(recipe, { turns: AVATAR_TURNS })), + mouths: hash(pixelFigure(recipe, 0, { mouthLayers: true }).head), +}); +const cells = (recipe: PixelAvatarRecipe, yaw = 0) => + new Map(pixelFigure(recipe, yaw).cells.map(({ x, y, c }) => [`${x},${y}`, c])); +const changed = (a: PixelAvatarRecipe, b: PixelAvatarRecipe, yaw = 0) => { + const [ca, cb] = [cells(a, yaw), cells(b, yaw)]; + return [...new Set([...ca.keys(), ...cb.keys()])].filter((key) => ca.get(key) !== cb.get(key)); +}; + +const cat = withSpecies(DEFAULT_RECIPE, 'cat'); + +describe('animal species with patterns (asset version 4)', () => { + it('accepts animal species and patterns only in version 4, with fur swatches', () => { + for (const species of AVATAR_ANIMAL_SPECIES) { + const animal = withSpecies(DEFAULT_RECIPE, species); + expect(animal).toMatchObject({ assetVersion: 4, species }); + expect(isPixelAvatarRecipe(animal)).toBe(true); + expect(animal.skinColor).toBe(AVATAR_SPECIES_SWATCHES[species][0]); + } + expect(isPixelAvatarRecipe({ ...cat, pattern: 'tabby' })).toBe(true); + expect(isPixelAvatarRecipe({ ...cat, pattern: 'stripes' })).toBe(false); + const v2 = withSpecies(DEFAULT_RECIPE, 'human'); + expect(isPixelAvatarRecipe({ ...v2, species: 'cat' })).toBe(false); + expect(isPixelAvatarRecipe({ ...v2, pattern: 'spots' })).toBe(false); + expect(withSpecies(cat, 'human').assetVersion).toBe(4); + const custom = { ...cat, skinColor: '#123456' }; + expect(withSpecies(custom, 'fox').skinColor).toBe('#123456'); + }); + + it('keeps version 2 seeds human-world and unchanged', () => { + for (const name of ['Ada', 'Grace', 'Linus', 'DeepSeekBot', 'Gimli', 'Daisy']) + expect(AVATAR_ANIMAL_SPECIES as readonly string[]).not.toContain( + seededRecipeV2(name).species, + ); + }); + + it('draws a muzzle and animal nose instead of the human nose, keeping every mouth layer', () => { + expect(hiddenChoices(cat)).toContain('nose'); + expect(changed({ ...cat, nose: 'dot' }, { ...cat, nose: 'line' })).toEqual([]); + const mouths = pixelFigure(cat, 0, { mouthLayers: true }).head; + for (const state of ['saved', 'closed', 'half-open', 'open']) + expect(mouths).toContain(`data-avatar-mouth="${state}"`); + for (const species of AVATAR_ANIMAL_SPECIES) + expect( + changed(withSpecies(DEFAULT_RECIPE, species), withSpecies(DEFAULT_RECIPE, 'human')).length, + ).toBeGreaterThan(10); + }); + + it('draws patterns in fur tones that follow recoloring', () => { + for (const pattern of AVATAR_PATTERNS.slice(1)) { + const patterned = { ...cat, bangs: 'none', pattern } as const; + const diff = changed(patterned, { ...patterned, pattern: 'solid' }); + expect(diff.length, pattern).toBeGreaterThan(0); + const recolored = { ...patterned, skinColor: '#9aa3ad' }; + const shown = cells(recolored); + expect(diff.some((key) => shown.get(key) !== cells(patterned).get(key))).toBe(true); + } + const drawn = withCustomPart({ ...cat, pattern: 'spots' }, 'pattern', { + slot: 'pattern', + front: [[10, 12, 'skinColor', -2]], + back: [], + }); + expect(changed(drawn, { ...cat, pattern: 'spots' }).length).toBeGreaterThan(0); + const start = replacePartStart({ ...cat, bangs: 'none', pattern: 'patches' }, 'pattern'); + expect(start.front.length).toBeGreaterThan(0); + const fur = start.front.filter(([, , color]) => color === 'skinColor'); + expect(fur.length / start.front.length).toBeGreaterThan(0.9); + const copy = withCustomPart({ ...cat, bangs: 'none', pattern: 'patches' }, 'pattern', start); + expect(changed(copy, { ...cat, bangs: 'none', pattern: 'patches' }).length).toBeLessThanOrEqual( + 2, + ); + const human = withSpecies(DEFAULT_RECIPE, 'human'); + expect(hiddenChoices({ ...withSpecies(cat, 'human'), pattern: 'tabby' })).toContain('pattern'); + expect(human.assetVersion).toBe(2); + }); + + it('hides animal-ear headpieces and accessories on an animal and keeps them saved', () => { + const eared = withBuiltInHeadpiece(cat, 'bunnyears'); + expect(hiddenChoices(eared)).toContain('headpiece'); + expect(changed(eared, cat)).toEqual([]); + expect(hiddenChoices(withBuiltInHeadpiece(cat, 'halo'))).not.toContain('headpiece'); + const accessory = { ...cat, accessory: 'catears' } as const; + expect(hiddenChoices(accessory)).toContain('accessory'); + expect(changed(accessory, cat)).toEqual([]); + expect(changed(withSpecies(eared, 'human'), withSpecies(cat, 'human')).length).toBeGreaterThan( + 0, + ); + }); + + it('keeps the pattern slot out of version 3 and reports a hidden pattern on a flower', () => { + const v3 = withCustomPart(withSpecies(DEFAULT_RECIPE, 'human'), 'bangs', { + slot: 'bangs', + front: [[16, 4, 'hairColor', 0]], + back: [], + }); + expect(v3.assetVersion).toBe(3); + const pattern = { + slot: 'pattern' as const, + front: [[10, 12, 'skinColor', -1] as const], + back: [], + }; + expect(isPixelAvatarRecipe({ ...v3, patternPart: pattern })).toBe(false); + expect(withCustomPart(v3, 'pattern', pattern).assetVersion).toBe(4); + const flower = withSpecies({ ...cat, pattern: 'spots' }, 'flower'); + expect(hiddenChoices(flower)).toContain('pattern'); + }); + + it('centers the nose on the muzzle when turned and shows tabby on every head', () => { + for (const pose of ['left', 'right'] as const) { + const turned = { ...cat, pose } as const; + expect(changed(turned, { ...turned, nose: 'line' })).toEqual([]); + } + for (const head of ['round', 'oval', 'chubby', 'diamond'] as const) { + const bare = { ...cat, head, bangs: 'none' } as const; + const stripes = changed({ ...bare, pattern: 'tabby' }, bare).filter( + (key) => Number(key.split(',')[1]) <= 12, + ); + expect(stripes.length, head).toBeGreaterThan(0); + } + expect(canonicalRecipe({ ...cat, pattern: 'solid' })).toEqual(canonicalRecipe(cat)); + }); + + it('matches the animal golden output', () => { + const recipes: Record = {}; + for (const species of AVATAR_ANIMAL_SPECIES) { + const animal = withSpecies(DEFAULT_RECIPE, species); + recipes[species] = animal; + recipes[`${species}-extremes`] = { + ...animal, + spacing: 1, + height: 1, + hairLength: 2, + head: 'long', + }; + recipes[`${species}-right`] = { ...animal, pose: 'right' }; + recipes[`${species}-low`] = { + ...animal, + spacing: -1, + height: -1, + head: 'chubby', + bangs: 'none', + }; + } + for (const pattern of AVATAR_PATTERNS) + recipes[`cat-${pattern}`] = { ...cat, bangs: 'none', pattern }; + recipes['dog-patches-white'] = { + ...withSpecies(DEFAULT_RECIPE, 'dog'), + pattern: 'patches', + skinColor: '#f4f1ec', + }; + const actual = Object.fromEntries(Object.entries(recipes).map(([k, r]) => [k, render(r)])); + const file = new URL('./fixtures/animals-golden.json', import.meta.url); + if (process.env['BOTPIXEL_WRITE_ANIMALS_GOLDEN'] === '1') + writeFileSync(file, `${JSON.stringify(actual, null, 2)}\n`); + expect(actual).toEqual(JSON.parse(readFileSync(file, 'utf8'))); + }); +}); diff --git a/packages/avatar/test/fixtures/animals-golden.json b/packages/avatar/test/fixtures/animals-golden.json new file mode 100644 index 0000000..3b9599a --- /dev/null +++ b/packages/avatar/test/fixtures/animals-golden.json @@ -0,0 +1,132 @@ +{ + "cat": { + "front": "1746196b257e0c26", + "turns": "082f84469b7efdce", + "mouths": "974cc7fe6e5f014f" + }, + "cat-extremes": { + "front": "1aa86d921f870f33", + "turns": "dc32e69c6380f9cb", + "mouths": "1c0511b09403446f" + }, + "cat-right": { + "front": "fa9da07c1c6cab67", + "turns": "f42d3fc8063e2dc7", + "mouths": "974cc7fe6e5f014f" + }, + "cat-low": { + "front": "90ff246d12a67016", + "turns": "9a87b7d4a2d379ce", + "mouths": "9392b113e29c7eb7" + }, + "dog": { + "front": "40679d835f6df0d2", + "turns": "2622a6c4512cc5f2", + "mouths": "8d4f1ea52a8ccadd" + }, + "dog-extremes": { + "front": "f32d52fd83765cd6", + "turns": "21ae0d8b2f2e4df5", + "mouths": "e7360fe20ba78b0f" + }, + "dog-right": { + "front": "6436103b1c8482aa", + "turns": "fc20576ebc062f27", + "mouths": "8d4f1ea52a8ccadd" + }, + "dog-low": { + "front": "65855abc301db520", + "turns": "38f49f8f598fb9ac", + "mouths": "29a57cf8f002bc3b" + }, + "fox": { + "front": "8caee530e75218b8", + "turns": "67b8e0f47d0cac76", + "mouths": "9f5f55f450a3101a" + }, + "fox-extremes": { + "front": "ee51b9aef1b27ca6", + "turns": "7a074ef35a8773f5", + "mouths": "535cfa43d0be61b5" + }, + "fox-right": { + "front": "e332d5f08cefde9f", + "turns": "b22f4518779a8715", + "mouths": "9f5f55f450a3101a" + }, + "fox-low": { + "front": "db923153d803e1b1", + "turns": "88da418d66f3dcc0", + "mouths": "f890ba290476f7e5" + }, + "rabbit": { + "front": "7c93fdfff6a87732", + "turns": "337df3e0d2048b36", + "mouths": "2531a70f7d6ca168" + }, + "rabbit-extremes": { + "front": "1a714e135a841398", + "turns": "b2f870a714773c93", + "mouths": "787517d264da774c" + }, + "rabbit-right": { + "front": "e704daacaa34cebf", + "turns": "9aa0ab07e9ea330f", + "mouths": "2531a70f7d6ca168" + }, + "rabbit-low": { + "front": "4af7021a27517af6", + "turns": "3d4c426e59a63cc8", + "mouths": "b3eb0e432d3c85ce" + }, + "bear": { + "front": "342c580fbc5c620c", + "turns": "b4c799a9f62032e3", + "mouths": "f44dc41e1e81bc19" + }, + "bear-extremes": { + "front": "ef5724370412be82", + "turns": "9166eb9d47a4f5c7", + "mouths": "aa0bbe7f5c331386" + }, + "bear-right": { + "front": "eee130ab7857dea6", + "turns": "3421dc2ecc328618", + "mouths": "f44dc41e1e81bc19" + }, + "bear-low": { + "front": "499221aea0371504", + "turns": "8c3fdca863c5a658", + "mouths": "ddd0a7cd753a449f" + }, + "cat-solid": { + "front": "2257574c59b69e17", + "turns": "023a29490766c341", + "mouths": "07ab85334c92a9e4" + }, + "cat-tabby": { + "front": "8e46b43d7422fa91", + "turns": "c7a5dd1ff13a271d", + "mouths": "9898ff9db62607b7" + }, + "cat-spots": { + "front": "2ccfb1a68b674e08", + "turns": "f8a4f892efce348f", + "mouths": "36330505f79d558d" + }, + "cat-patches": { + "front": "c54c0776f27fe489", + "turns": "008f51832b005fd3", + "mouths": "e592c0a67450eeb6" + }, + "cat-colorpoint": { + "front": "edab60cb2c002b97", + "turns": "eff2abfeeccb2944", + "mouths": "907f5adf508478a6" + }, + "dog-patches-white": { + "front": "ae5434da8e55f0cf", + "turns": "88d9d95269c54f9f", + "mouths": "1c69131bd84142a4" + } +}