From 0dcb0c2430466d16b79ce592e7cd2cb6ae6f54c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:20:47 +0000 Subject: [PATCH 1/3] feat(avatar): every hair piece colored and built-in headpieces Asset version 4 adds bangs, back hair and strand colors that default to the hair color, a single strand piece, and built-in ears, horns, halo and head wings in the headpiece slot, worn alongside an accessory, with the base behind the hair and the far side hidden when turned. withPieces upgrades only on edit and moves a headpiece accessory into the slot, so saved recipes render unchanged. Released as 0.9.0. Refs: BotHarness/DeepSeekBot#1217 Agent-Task: claude/cloud/session_01MozvQ2eMvqX1MzSqm9G2o8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MozvQ2eMvqX1MzSqm9G2o8 --- CHANGELOG.md | 1 + packages/avatar/README.md | 1 + packages/avatar/package.json | 2 +- packages/avatar/src/figure.ts | 156 +++++++++++++-- packages/avatar/src/index.ts | 10 + packages/avatar/src/recipe.ts | 161 +++++++++++++-- .../avatar/test/fixtures/pieces-golden.json | 82 ++++++++ packages/avatar/test/pieces.test.ts | 183 ++++++++++++++++++ 8 files changed, 562 insertions(+), 34 deletions(-) create mode 100644 packages/avatar/test/fixtures/pieces-golden.json create mode 100644 packages/avatar/test/pieces.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 977ad17..0b4b296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `@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. 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. - `@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. diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 5faff0d..a38630c 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -25,5 +25,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc - `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. - `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`: 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. - `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 166dae6..ecfeb3d 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@botharness/pixel-avatar", - "version": "0.8.0", + "version": "0.9.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 05ab2a4..450d281 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -10,6 +10,8 @@ import { import { AVATAR_COLORS, AVATAR_PIECE_COLORS, + AVATAR_PIECE_COLORS_V4, + builtInHeadpiece, detailedRecipe, hiddenChoices, wornPart, @@ -376,6 +378,19 @@ function hair( } } +const STRANDS: Record, { rows: string[]; dx: number }> = { + ahoge: { rows: ['XX.', '.XX', '.XX'], dx: 0 }, + curl: { rows: ['.XX.', 'X..X', '..XX', '.X..'], dx: -1 }, + double: { rows: ['X...X', '.X.X.', '.X.X.'], dx: -2 }, +}; + +function strandMask(style: NonNullable, s: number): Mask { + const { rows, dx } = STRANDS[style]; + const top = 4 - rows.length; + const left = Math.round(C + s) + dx; + return (x, y) => rows[y - top]?.[x - left] === 'X'; +} + function detailedHair( recipe: Recipe, s: number, @@ -387,6 +402,7 @@ function detailedHair( bands?: boolean; sides?: [Mask, Mask]; pieces?: Record; + strand?: Mask; } { const of = (style: Recipe['hair'] | undefined) => hair({ ...recipe, hair: style ?? 'none' }, s, b); @@ -409,8 +425,11 @@ function detailedHair( ? 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); + const strand = recipe.strand ? strandMask(recipe.strand, s) : undefined; return { - front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y), + front: (x, y) => + fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y) || (strand?.(x, y) ?? false), + ...(strand ? { strand } : {}), sides: [sideOf(-1), sideOf(1)], pieces: { bangs: fringe, leftSideHair: sideOf(-1), rightSideHair: sideOf(1), backHair: behind }, back: behind, @@ -778,6 +797,70 @@ function accessory(recipe: Recipe, g: Grid, s: number, face: Mask): void { } } +/** + * A built-in headpiece: the row that meets the head goes behind the hair, the rest in front. + * Turned, the far side of a pair is hidden, like a turned ear. + */ +function drawHeadpiece( + style: NonNullable>, + recipe: Recipe, + behind: Grid, + front: Grid, + s: number, + far: number, +): void { + const cx = Math.round(C + s); + const pair = ( + rows: readonly string[], + left: number, + right: number, + y: number, + pal: Record, + mirror = true, + ) => { + const tips = rows.slice(0, -1); + if (far >= 0) { + sprite(behind, rows, cx + left, y, pal); + sprite(front, tips, cx + left, y, pal); + } + if (far <= 0) { + sprite(behind, rows, cx + right, y, pal, mirror); + sprite(front, tips, cx + right, y, pal, mirror); + } + }; + switch (style) { + case 'catears': + pair(['X...', 'XX..', 'XPX.', 'XXXX'], -9, 5, 2, { X: recipe.hairColor, P: '#f4a3b5' }); + return; + case 'bunnyears': + pair(['WW', 'WP', 'WP', 'WP', 'WW'], -6, 4, 0, { W: '#f4f1ec', P: '#f4a3b5' }); + return; + case 'horseears': + pair( + ['.X.', 'XPX', 'XPX', 'XXX'], + -9, + 6, + 1, + { X: shade(recipe.hairColor, 0.9), P: '#f4a3b5' }, + false, + ); + return; + case 'horns': + pair(['.H', 'HH', 'Hh'], -8, 6, 1, { H: '#5b3a6e', h: '#7d5694' }); + return; + case 'halo': { + const ring = (x: number, y: number) => + ellipse(C + s, 1.6, 6.5, 1.8)(x, y) && !ellipse(C + s, 1.6, 4.4, 0.8)(x, y); + paint(behind, (x, y) => ring(x, y) && y < 2, GOLD); + paint(front, (x, y) => ring(x, y) && y >= 2, GOLD); + return; + } + case 'wings': + pair(['W..', 'WW.', 'WwW', '.ww'], -13, 10, 7, { W: '#f4f1ec', w: '#cfd6e2' }); + return; + } +} + const POINTED_EARS: Partial< Record, { rows: string[]; rise: number }> > = { @@ -996,7 +1079,10 @@ function renderFigure( : { ...builtin, front: (x, y) => - piece('bangs')(x, y) || piece('leftSideHair')(x, y) || piece('rightSideHair')(x, y), + piece('bangs')(x, y) || + piece('leftSideHair')(x, y) || + piece('rightSideHair')(x, y) || + (builtin.strand?.(x, y) ?? false), sides: [piece('leftSideHair'), piece('rightSideHair')], back: piece('backHair'), ...(drawnHair.some((drawn) => drawn.slot === 'backHair') ? { ties: [] } : {}), @@ -1052,6 +1138,9 @@ function renderFigure( placeCells(back, inked('backHair'), false); overlay(back, 'petals'); if (headpiece) placeCells(back, headpiece.back); + const wornHead = flower ? undefined : builtInHeadpiece(recipe); + const headFront = blank(); + if (wornHead) drawHeadpiece(wornHead, recipe, back, headFront, s, turned ? d : 0); const head = blank(); paint(head, face, skin); @@ -1169,6 +1258,8 @@ function renderFigure( const crown = blank(); placeCells(crown, inked('all-front'), false); if (headpiece) placeCells(crown, headpiece.front); + for (let y = 0; y < N; y++) + for (let x = 0; x < N; x++) if (headFront[y]![x]) crown[y]![x] = headFront[y]![x]; const figure = blank(); for (const layer of [back, body, shadeHair(head), drawnOver, crown]) for (let y = 0; y < N; y++) @@ -1321,22 +1412,57 @@ function renderFigure( } const full = clip(outline(figure)); - AVATAR_PIECE_COLORS.forEach((key, i) => { - const color = recipe[key]; - const mask = masks.sides?.[i]; - if (color === undefined || !mask || color.toLowerCase() === hairColor.toLowerCase()) return; + const recolor = (color: string) => { const variant = blank(); - const plain = { ...recipe, hairColor: color }; - for (const k of AVATAR_PIECE_COLORS) delete plain[k]; + const plain: Recipe = { ...recipe, hairColor: color }; + for (const k of [...AVATAR_PIECE_COLORS, ...AVATAR_PIECE_COLORS_V4]) delete plain[k]; 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++) - 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]; - }); + return variant; + }; + if (AVATAR_PIECE_COLORS_V4.some((key) => recipe[key] !== undefined)) { + const hairAt = (x: number, y: number) => head[y]?.[x] === hairColor; + const backAt = (x: number, y: number) => + back[y]?.[x] !== undefined && figure[y]![x] === back[y]![x]; + const owners: [string | undefined, Mask][] = [ + [recipe.backHairColor, (x, y) => masks.back(x, y) && backAt(x, y)], + [ + recipe.leftSideHairColor, + (x, y) => !!masks.sides?.[0](x, y) && (hairAt(x, y) || backAt(x, y)), + ], + [ + recipe.rightSideHairColor, + (x, y) => !!masks.sides?.[1](x, y) && (hairAt(x, y) || backAt(x, y)), + ], + [ + recipe.bangsColor, + (x, y) => (builtin.pieces ? piece('bangs')(x, y) : false) && hairAt(x, y), + ], + [recipe.strandColor, (x, y) => (builtin.strand?.(x, y) ?? false) && hairAt(x, y)], + ]; + for (const [color, owns] of owners) { + if (color === undefined || color.toLowerCase() === hairColor.toLowerCase()) continue; + const variant = recolor(color); + const near = (x: number, y: number) => + owns(x - 1, y) || owns(x + 1, y) || owns(x, y - 1) || owns(x, y + 1); + for (let y = 0; y < N; y++) + for (let x = 0; x < N; x++) + if (full[y]![x] && (owns(x, y) || (!figure[y]![x] && near(x, y)))) + full[y]![x] = variant[y]![x]; + } + } else + AVATAR_PIECE_COLORS.forEach((key, i) => { + const color = recipe[key]; + const mask = masks.sides?.[i]; + if (color === undefined || !mask || color.toLowerCase() === hairColor.toLowerCase()) return; + const variant = recolor(color); + 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++) + 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]; + }); const bodyCells = blank(); const headCells = blank(); for (let y = 0; y < N; y++) diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index 5962903..d8176d3 100644 --- a/packages/avatar/src/index.ts +++ b/packages/avatar/src/index.ts @@ -16,6 +16,9 @@ export { AVATAR_PARTS, AVATAR_PARTS_V2, AVATAR_PIECE_COLORS, + AVATAR_PIECE_COLORS_V4, + AVATAR_STRANDS, + AVATAR_HEADPIECES, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, @@ -42,6 +45,13 @@ export { type PixelAvatarRecipeV1, type PixelAvatarRecipeV2, type PixelAvatarRecipeV3, + type PixelAvatarRecipeV4, + type AvatarPieceColorV4, + type AvatarStrand, + type AvatarHeadpiece, + withPieces, + withBuiltInHeadpiece, + builtInHeadpiece, withHeadpiece, withCustomPart, wornPart, diff --git a/packages/avatar/src/recipe.ts b/packages/avatar/src/recipe.ts index 5905978..1d180a7 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -165,6 +165,29 @@ export type AvatarSpecies = (typeof AVATAR_SPECIES)[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]; +/** + * Asset version 4 adds a color for every other hair piece; an absent piece uses `hairColor`. + * `strandColor` colors the single strand (`strand`). + */ +export const AVATAR_PIECE_COLORS_V4 = ['bangsColor', 'backHairColor', 'strandColor'] as const; +export type AvatarPieceColorV4 = (typeof AVATAR_PIECE_COLORS_V4)[number]; +/** A single strand of hair standing up from the crown (asset version 4). */ +export const AVATAR_STRANDS = ['ahoge', 'curl', 'double'] as const; +export type AvatarStrand = (typeof AVATAR_STRANDS)[number]; +/** + * Built-in headpieces (asset version 4), worn in the headpiece slot alongside an accessory and + * drawn partly behind the hair. The first five were accessories in earlier versions. + */ +export const AVATAR_HEADPIECES = [ + 'catears', + 'bunnyears', + 'horseears', + 'horns', + 'halo', + 'wings', +] as const; +export type AvatarHeadpiece = (typeof AVATAR_HEADPIECES)[number]; +type V4Key = AvatarPieceColorV4 | 'strand'; /** Part choices that exist only in asset version 2, added after every version 1 choice. */ export const AVATAR_PARTS_V2 = { @@ -193,7 +216,9 @@ export type PixelAvatarRecipeV1 = Meta & { assetVersion: 1 } & { } & { [P in AvatarRange]?: number } & { species?: never; rightSideHair?: never; -} & { [P in AvatarPieceColor | AvatarExtraPart]?: never } & { [P in CustomPartKey]?: never }; +} & { [P in AvatarPieceColor | AvatarExtraPart]?: never } & { [P in CustomPartKey]?: never } & { + [P in V4Key]?: never; +}; /** * Asset version 2: a species, the full split hair and geometry, a separate right side hair @@ -208,7 +233,7 @@ 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]; -} & { [P in CustomPartKey]?: never }; +} & { [P in CustomPartKey]?: never } & { [P in V4Key]?: never }; /** The recipe key that embeds the Custom Part worn in each slot. */ export const CUSTOM_PART_KEYS = { @@ -237,7 +262,26 @@ export type PixelAvatarRecipeV3 = Omit & { + assetVersion: 4; +} & { [P in Exclude]?: PixelCustomPart } & { + headpiece?: PixelCustomPart | AvatarHeadpiece; + strand?: AvatarStrand; +} & { [P in AvatarPieceColorV4]?: string }; + +export type PixelAvatarRecipe = + | PixelAvatarRecipeV1 + | PixelAvatarRecipeV2 + | PixelAvatarRecipeV3 + | PixelAvatarRecipeV4; export const DEFAULT_RECIPE: PixelAvatarRecipeV1 = { schemaVersion: 1, @@ -269,10 +313,27 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe const r = value as Record; const detailed = DETAIL_KEYS.filter((key) => Object.hasOwn(r, key)).length; if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false; + const v4 = r['assetVersion'] === 4; const v3 = r['assetVersion'] === 3; - const v2 = r['assetVersion'] === 2 || v3; - const worn = PART_KEY_ENTRIES.filter(([, key]) => Object.hasOwn(r, key)); - if (v3 ? worn.length === 0 : worn.length !== 0) return false; + const v2 = r['assetVersion'] === 2 || v3 || v4; + const builtInHead = v4 && typeof r['headpiece'] === 'string'; + if (builtInHead && !(AVATAR_HEADPIECES as readonly unknown[]).includes(r['headpiece'])) + return false; + const worn = PART_KEY_ENTRIES.filter( + ([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 ( + 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 as readonly unknown[]).includes(r['strand']))) + ) + return false; for (const [slot, key] of worn) { const part = r[key]; if (!isPixelCustomPart(part) || part.slot !== slot) return false; @@ -281,7 +342,9 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe const extras = (Object.keys(AVATAR_EXTRA_PARTS) as AvatarExtraPart[]).filter((key) => Object.hasOwn(r, key), ); - const v2Keys = v2 ? 2 + pieces.length + extras.length + worn.length : 0; + const v2Keys = v2 + ? 2 + pieces.length + extras.length + worn.length + newer.length + (builtInHead ? 1 : 0) + : 0; if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed + v2Keys) return false; if ( @@ -332,11 +395,17 @@ 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) + if (recipe.assetVersion === 3 || recipe.assetVersion === 4) for (const [, key] of PART_KEY_ENTRIES) { const part = recipe[key]; - if (part !== undefined) canonical[key] = canonicalCustomPart(part); + if (part !== undefined) + canonical[key] = typeof part === 'string' ? part : canonicalCustomPart(part); } + if (recipe.assetVersion === 4) { + if (recipe.strand !== undefined) canonical['strand'] = recipe.strand; + for (const key of AVATAR_PIECE_COLORS_V4) + if (recipe[key] !== undefined) canonical[key] = recipe[key].toLowerCase(); + } } return canonical as PixelAvatarRecipe; } @@ -349,9 +418,12 @@ const HAIR_PIECES = ['bangs', 'sideHair', 'rightSideHair', 'backHair'] as const; * the species or headwear changes back. */ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { + const strand = recipe.strand ? ['strand'] : []; if (recipe.species === 'flower') return [ ...HAIR_PIECES, + ...strand, + ...(typeof recipe.headpiece === 'string' ? ['headpiece'] : []), 'outfit', 'accessory', 'eyes', @@ -366,7 +438,7 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { (recipe.accessory === 'helmet' || recipe.accessory === 'hood') && !wornPart(recipe, 'accessory') ) - hidden.push(...HAIR_PIECES); + hidden.push(...HAIR_PIECES, ...strand); if (recipe.species === 'dwarf') hidden.push('nose'); return hidden; } @@ -377,6 +449,10 @@ 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: PixelAvatarRecipeV4, + species: AvatarSpecies, +): PixelAvatarRecipeV4; export function withSpecies( recipe: PixelAvatarRecipeV3, species: AvatarSpecies, @@ -388,54 +464,103 @@ export function withSpecies( export function withSpecies( recipe: PixelAvatarRecipe, species: AvatarSpecies, -): PixelAvatarRecipeV2 | PixelAvatarRecipeV3; +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4; export function withSpecies( recipe: PixelAvatarRecipe, species: AvatarSpecies, -): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 { +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { const detailed = detailedRecipe(recipe); const previous = recipe.species ?? 'human'; const suggested = AVATAR_SPECIES_SWATCHES[previous].includes(recipe.skinColor.toLowerCase()); return { ...detailed, - assetVersion: recipe.assetVersion === 3 ? 3 : 2, + assetVersion: recipe.assetVersion === 1 ? 2 : recipe.assetVersion, species, rightSideHair: detailed.rightSideHair ?? detailed.sideHair!, skinColor: suggested && previous !== species ? AVATAR_SPECIES_SWATCHES[species][0]! : recipe.skinColor, - } as PixelAvatarRecipeV2 | PixelAvatarRecipeV3; + } as PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4; } /** * 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. + * embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise, + * and a version 4 recipe stays version 4. */ +export function withCustomPart( + recipe: PixelAvatarRecipeV4, + slot: PartSlot, + part: PixelCustomPart | undefined, +): PixelAvatarRecipeV4; +export function withCustomPart( + recipe: PixelAvatarRecipe, + slot: PartSlot, + part: PixelCustomPart | undefined, +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4; export function withCustomPart( recipe: PixelAvatarRecipe, slot: PartSlot, part: PixelCustomPart | undefined, -): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 { +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { 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 }); + if (recipe.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; } +const MOVED_HEADPIECES: readonly string[] = AVATAR_HEADPIECES; + +/** + * Returns the recipe as asset version 4, keeping every choice. An accessory that is now a + * headpiece moves into the empty headpiece slot, freeing the accessory slot; call this only when + * the Human edits, so saved recipes keep rendering as they were. + */ +export function withPieces(recipe: PixelAvatarRecipe): PixelAvatarRecipeV4 { + const base: Record = { + ...(recipe.assetVersion === 1 ? withSpecies(recipe, 'human') : recipe), + assetVersion: 4, + }; + if (base['headpiece'] === undefined && MOVED_HEADPIECES.includes(recipe.accessory)) { + base['headpiece'] = recipe.accessory; + base['accessory'] = 'none'; + } + return base as PixelAvatarRecipeV4; +} + +/** Returns the recipe as asset version 4 wearing a built-in headpiece, or none. */ +export function withBuiltInHeadpiece( + recipe: PixelAvatarRecipe, + headpiece: AvatarHeadpiece | undefined, +): PixelAvatarRecipeV4 { + const base: Record = { ...withPieces(recipe) }; + delete base['headpiece']; + if (headpiece !== undefined) base['headpiece'] = headpiece; + return base as PixelAvatarRecipeV4; +} + +/** The built-in headpiece worn, if any. */ +export function builtInHeadpiece(recipe: PixelAvatarRecipe): AvatarHeadpiece | undefined { + return typeof recipe.headpiece === 'string' ? recipe.headpiece : undefined; +} + /** `withCustomPart` for the headpiece slot. */ export function withHeadpiece( recipe: PixelAvatarRecipe, part: PixelCustomPart | undefined, -): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 { +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { 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; + if (recipe.assetVersion !== 3 && recipe.assetVersion !== 4) return undefined; + const part = recipe[CUSTOM_PART_KEYS[slot]]; + return typeof part === 'string' ? undefined : part; } export function detailedRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { diff --git a/packages/avatar/test/fixtures/pieces-golden.json b/packages/avatar/test/fixtures/pieces-golden.json new file mode 100644 index 0000000..b0ecbe1 --- /dev/null +++ b/packages/avatar/test/fixtures/pieces-golden.json @@ -0,0 +1,82 @@ +{ + "headpiece-catears": { + "front": "9452c7b6cbba8a2e", + "turns": "d675a75f2aafb1c2", + "mouths": "9ed97f6b094b2333" + }, + "headpiece-bunnyears": { + "front": "13ec82d7ed4baeca", + "turns": "9d19fbd1b2718d3f", + "mouths": "6b0c082f0e540fdc" + }, + "headpiece-horseears": { + "front": "101cf7fad8ac9ef9", + "turns": "4d94765de79cab1e", + "mouths": "d9d03b7dd9f2b78b" + }, + "headpiece-horns": { + "front": "a22a9b6e8f4c86b2", + "turns": "0ebce5c641dfd4a7", + "mouths": "28fd225f08656ede" + }, + "headpiece-halo": { + "front": "954f158bc99d18ff", + "turns": "4b639936dfc72bc3", + "mouths": "8dd6aae7c1376d78" + }, + "headpiece-wings": { + "front": "63f0f10473e8bddb", + "turns": "77574a767656e3fc", + "mouths": "c5202a98f51fc4dd" + }, + "strand-ahoge": { + "front": "f92a345a8553a266", + "turns": "0c1f96383a3ffb14", + "mouths": "044cb02763e40dba" + }, + "strand-ahoge-colored": { + "front": "7eb9af9f066cf278", + "turns": "9b5440d78ed4fced", + "mouths": "bb6af3d10c286846" + }, + "strand-curl": { + "front": "e7468dd9a14aff48", + "turns": "559cb0c499b60a1f", + "mouths": "24e4e5dd20b4bd1a" + }, + "strand-curl-colored": { + "front": "b1bafabdfeea2332", + "turns": "fe039784a61bb501", + "mouths": "33b32bdf2f15c2d9" + }, + "strand-double": { + "front": "fa1d97f32bad5800", + "turns": "36566e4232c9a4fe", + "mouths": "203c6ce920b1117b" + }, + "strand-double-colored": { + "front": "82c568d0b4aae1ef", + "turns": "1d794574d4baf713", + "mouths": "f36b87fff4c24abd" + }, + "pieces-all": { + "front": "212717de93d84697", + "turns": "77e1a750921e0fb5", + "mouths": "1d3ae4eec2723d33" + }, + "pieces-bangs": { + "front": "ce756ab6c4b374ff", + "turns": "f3c3fc5f17989372", + "mouths": "d160088cad9b0011" + }, + "pieces-back": { + "front": "667008479e90c145", + "turns": "768506047c44fc4d", + "mouths": "a78f2efd0d59344e" + }, + "migrated-ahoge-catears": { + "front": "b89b936c9253c4d3", + "turns": "eba896bae3422913", + "mouths": "efdcdcf1ad1cd4f4" + } +} diff --git a/packages/avatar/test/pieces.test.ts b/packages/avatar/test/pieces.test.ts new file mode 100644 index 0000000..7053c84 --- /dev/null +++ b/packages/avatar/test/pieces.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_HEADPIECES, + AVATAR_STRANDS, + AVATAR_TURNS, + DEFAULT_RECIPE, + builtInHeadpiece, + canonicalRecipe, + hiddenChoices, + isPixelAvatarRecipe, + pixelAvatarSvg, + pixelFigure, + withBuiltInHeadpiece, + withCustomPart, + withPieces, + withSpecies, + wornPart, + type PixelAvatarRecipe, + type PixelCustomPart, +} 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 human = withPieces(DEFAULT_RECIPE); +const long = { + ...human, + bangs: 'bob', + sideHair: 'long', + rightSideHair: 'long', + backHair: 'long', +} as const; +const crown: PixelCustomPart = { slot: 'headpiece', front: [[16, 1, '#efb93f', 0]], back: [] }; + +describe('every hair piece colored and built-in headpieces (asset version 4)', () => { + it('accepts version 4 fields only in version 4 and keeps earlier recipes valid', () => { + expect(human.assetVersion).toBe(4); + expect(isPixelAvatarRecipe(human)).toBe(true); + expect( + isPixelAvatarRecipe({ + ...long, + bangsColor: '#F06292', + backHairColor: '#3fc1b8', + strand: 'curl', + strandColor: '#e2b04a', + headpiece: 'wings', + accessory: 'bow', + }), + ).toBe(true); + expect(isPixelAvatarRecipe(withCustomPart(human, 'headpiece', crown))).toBe(true); + expect(isPixelAvatarRecipe({ ...human, headpiece: 'tiara' })).toBe(false); + expect(isPixelAvatarRecipe({ ...human, strand: 'mohawk' })).toBe(false); + expect(isPixelAvatarRecipe({ ...human, bangsColor: 'pink' })).toBe(false); + const v2 = withSpecies(DEFAULT_RECIPE, 'human'); + expect(isPixelAvatarRecipe({ ...v2, bangsColor: '#f06292' })).toBe(false); + expect(isPixelAvatarRecipe({ ...v2, strand: 'ahoge' })).toBe(false); + expect( + isPixelAvatarRecipe({ ...withCustomPart(v2, 'headpiece', crown), headpiece: 'halo' }), + ).toBe(false); + expect(canonicalRecipe({ ...human, bangsColor: '#F06292', headpiece: 'halo' })).toEqual({ + ...human, + bangsColor: '#f06292', + headpiece: 'halo', + }); + }); + + it('moves a headpiece accessory into the headpiece slot only through withPieces', () => { + const saved = { ...DEFAULT_RECIPE, accessory: 'catears' } as const; + expect(pixelAvatarSvg(saved)).toBe(pixelAvatarSvg({ ...DEFAULT_RECIPE, accessory: 'catears' })); + const edited = withPieces(saved); + expect(edited).toMatchObject({ assetVersion: 4, accessory: 'none', headpiece: 'catears' }); + expect(builtInHeadpiece(edited)).toBe('catears'); + expect(wornPart(edited, 'headpiece')).toBeUndefined(); + const both = { ...edited, accessory: 'bow' } as const; + expect(isPixelAvatarRecipe(both)).toBe(true); + expect(changed(withBuiltInHeadpiece(both, undefined), both).length).toBeGreaterThan(0); + expect(changed({ ...edited, accessory: 'none' }, both).length).toBeGreaterThan(0); + const drawn = withPieces({ + ...withCustomPart(DEFAULT_RECIPE, 'headpiece', crown), + accessory: 'horns', + }); + expect(drawn.accessory).toBe('horns'); + expect(wornPart(drawn, 'headpiece')).toEqual(crown); + expect(withCustomPart(both, 'headpiece', crown)).toMatchObject({ + assetVersion: 4, + headpiece: crown, + }); + expect(withCustomPart(both, 'headpiece', undefined).headpiece).toBeUndefined(); + expect(withCustomPart(human, 'bangs', undefined).assetVersion).toBe(4); + }); + + it('draws a headpiece base behind the hair and hides the far side when turned', () => { + const ears = withBuiltInHeadpiece(human, 'catears'); + const asAccessory = { ...human, accessory: 'catears' } as const; + const behind = changed(ears, asAccessory); + expect(behind.length).toBeGreaterThan(0); + expect(behind.every((key) => Number(key.split(',')[1]) <= 8)).toBe(true); + for (const yaw of [-30, 30]) { + const diff = changed(ears, human, yaw).map((key) => Number(key.split(',')[0])); + const sides = new Set(diff.map((x) => (x < 16 ? 'left' : 'right'))); + expect(sides.size).toBe(1); + expect([...sides][0]).toBe(yaw > 0 ? 'left' : 'right'); + } + expect( + new Set( + changed(withBuiltInHeadpiece(human, 'halo'), human, 30).map( + (k) => Number(k.split(',')[0]!) < 16, + ), + ), + ).toEqual(new Set([true, false])); + }); + + it('colors each hair piece on its own and leaves the rest of the face alone', () => { + const plain = cells(long); + for (const [key, rows] of [ + ['bangsColor', [0, 17]], + ['backHairColor', [0, 32]], + ['leftSideHairColor', [6, 32]], + ] as const) { + const recolored = { ...long, [key]: '#3fc1b8' } as PixelAvatarRecipe; + const diff = changed(long, recolored); + expect(diff.length).toBeGreaterThan(4); + for (const k of diff) { + const y = Number(k.split(',')[1]); + expect(y).toBeGreaterThanOrEqual(rows[0]); + expect(y).toBeLessThan(rows[1]); + } + if (key === 'leftSideHairColor') + expect(diff.every((k) => Number(k.split(',')[0]) < 16)).toBe(true); + expect(changed({ ...long, [key]: long.hairColor } as PixelAvatarRecipe, long)).toEqual([]); + } + expect(plain.size).toBeGreaterThan(0); + const strand = { ...human, strand: 'ahoge' } as const; + const tinted = changed(strand, { ...strand, strandColor: '#e2b04a' }); + expect(tinted.length).toBeGreaterThan(0); + expect(tinted.every((k) => Number(k.split(',')[1]) <= 5)).toBe(true); + expect(hiddenChoices({ ...withSpecies(strand, 'flower') })).toContain('strand'); + }); + + it('matches the version 4 golden output', () => { + const recipes: Record = {}; + for (const headpiece of AVATAR_HEADPIECES) + recipes[`headpiece-${headpiece}`] = withBuiltInHeadpiece( + { ...human, accessory: 'hairclip' }, + headpiece, + ); + for (const strand of AVATAR_STRANDS) { + recipes[`strand-${strand}`] = { ...human, strand }; + recipes[`strand-${strand}-colored`] = { ...human, strand, strandColor: '#e2b04a' }; + } + recipes['pieces-all'] = { + ...long, + bangsColor: '#f06292', + backHairColor: '#3fc1b8', + leftSideHairColor: '#e2b04a', + rightSideHairColor: '#5a7be0', + }; + recipes['pieces-bangs'] = { ...long, bangsColor: '#f06292' }; + recipes['pieces-back'] = { ...long, backHairColor: '#3fc1b8' }; + recipes['migrated-ahoge-catears'] = withPieces({ + ...DEFAULT_RECIPE, + hair: 'ahoge', + accessory: 'catears', + }); + const actual = Object.fromEntries(Object.entries(recipes).map(([k, r]) => [k, render(r)])); + const file = new URL('./fixtures/pieces-golden.json', import.meta.url); + if (process.env['BOTPIXEL_WRITE_PIECES_GOLDEN'] === '1') + writeFileSync(file, `${JSON.stringify(actual, null, 2)}\n`); + expect(actual).toEqual(JSON.parse(readFileSync(file, 'utf8'))); + }); +}); From 89ce0d0567d87499ce2388278636097abbb965b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:45:02 +0000 Subject: [PATCH 2/3] feat(avatar): flatten a built-in headpiece into a Custom Part Refs: BotHarness/DeepSeekBot#1219 Agent-Task: claude/cloud/session_01MozvQ2eMvqX1MzSqm9G2o8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MozvQ2eMvqX1MzSqm9G2o8 --- CHANGELOG.md | 2 +- packages/avatar/README.md | 2 +- packages/avatar/src/figure.ts | 45 +++++++++++++++++++++++++---- packages/avatar/src/index.ts | 1 + packages/avatar/test/pieces.test.ts | 16 ++++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4b296..83d75ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- `@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. Every earlier recipe renders unchanged. +- `@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. - `@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. diff --git a/packages/avatar/README.md b/packages/avatar/README.md index a38630c..510f98e 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -25,6 +25,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc - `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. - `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`: 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. +- `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. - `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/src/figure.ts b/packages/avatar/src/figure.ts index 450d281..64e7bf5 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -1518,6 +1518,44 @@ export function hairPieceStart( return { slot, front, back: [] }; } +function cellSource(recipe: PixelAvatarRecipe): (c: string) => readonly [PartCell[2], PartTone] { + return (c) => { + 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]; + }; +} + +/** + * The headpiece as a Custom Part to start drawing from: the drawn headpiece worn, or the + * built-in headpiece flattened for this recipe in the front pose, its base on the back layer and + * the rest on the front layer, so an unchanged copy renders identically facing front. + */ +export function headpieceStart(recipe: PixelAvatarRecipe): { + slot: 'headpiece'; + front: PartCell[]; + back: PartCell[]; +} { + const worn = wornPart(recipe, 'headpiece'); + if (worn) return { slot: 'headpiece', front: [...worn.front], back: [...worn.back] }; + const style = builtInHeadpiece(recipe); + const behind = blank(); + const over = blank(); + if (style) drawHeadpiece(style, recipe, behind, over, 0, 0); + const source = cellSource(recipe); + const front: PartCell[] = []; + const back: PartCell[] = []; + for (let y = 0; y < 16; y++) + for (let x = 0; x < N; x++) { + const c = over[y]![x]; + const b = behind[y]![x]; + if (c) front.push([x, y, ...source(c)]); + if (b && b !== c) back.push([x, y, ...source(b)]); + } + return { slot: 'headpiece', 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 @@ -1543,12 +1581,7 @@ export function replacePartStart( : '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 source = cellSource(recipe); const front: PartCell[] = []; for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index d8176d3..8020bb5 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, + headpieceStart, replacePartStart, partToneColor, pixelFigure, diff --git a/packages/avatar/test/pieces.test.ts b/packages/avatar/test/pieces.test.ts index 7053c84..2ab7669 100644 --- a/packages/avatar/test/pieces.test.ts +++ b/packages/avatar/test/pieces.test.ts @@ -7,6 +7,7 @@ import { AVATAR_TURNS, DEFAULT_RECIPE, builtInHeadpiece, + headpieceStart, canonicalRecipe, hiddenChoices, isPixelAvatarRecipe, @@ -149,6 +150,21 @@ describe('every hair piece colored and built-in headpieces (asset version 4)', ( expect(hiddenChoices({ ...withSpecies(strand, 'flower') })).toContain('strand'); }); + it('flattens a built-in headpiece into a Custom Part that renders identically facing front', () => { + for (const headpiece of AVATAR_HEADPIECES) { + const wearing = withBuiltInHeadpiece({ ...human, accessory: 'hairclip' }, headpiece); + const start = headpieceStart(wearing); + expect(start.front.length + start.back.length).toBeGreaterThan(0); + const copy = withCustomPart(wearing, 'headpiece', start); + expect(builtInHeadpiece(copy)).toBeUndefined(); + expect(changed(copy, wearing)).toEqual([]); + } + const ears = headpieceStart(withBuiltInHeadpiece(human, 'catears')); + expect(ears.front.some(([, , color]) => color === 'hairColor')).toBe(true); + expect(headpieceStart(withCustomPart(human, 'headpiece', crown))).toEqual(crown); + expect(headpieceStart(human)).toEqual({ slot: 'headpiece', front: [], back: [] }); + }); + it('matches the version 4 golden output', () => { const recipes: Record = {}; for (const headpiece of AVATAR_HEADPIECES) From c961f220a39b6c2530b5cb495baea8479b406aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:16:42 +0000 Subject: [PATCH 3/3] fix(avatar): keep piece colors to hair pixels and make withPieces idempotent Version 4 always uses the owner-based piece recolor, so a no-op color changes nothing; owned pixels exclude headpiece, drawn-over, eye and glasses pixels, and a flower's petals ignore the back hair color. Variants are rendered once per color. A strand is hidden without bangs, never covers bangs or side hair, and no longer moves the hair shading. withPieces returns a version 4 recipe unchanged. Refs: BotHarness/DeepSeekBot#1217 Agent-Task: claude/cloud/session_01MozvQ2eMvqX1MzSqm9G2o8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MozvQ2eMvqX1MzSqm9G2o8 --- packages/avatar/src/figure.ts | 27 +++++++---- packages/avatar/src/recipe.ts | 6 ++- .../avatar/test/fixtures/pieces-golden.json | 48 +++++++++---------- packages/avatar/test/pieces.test.ts | 41 ++++++++++++++++ 4 files changed, 89 insertions(+), 33 deletions(-) diff --git a/packages/avatar/src/figure.ts b/packages/avatar/src/figure.ts index 64e7bf5..d71285e 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -425,7 +425,10 @@ function detailedHair( ? 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); - const strand = recipe.strand ? strandMask(recipe.strand, s) : undefined; + const tuft = recipe.strand && recipe.bangs !== 'none' ? strandMask(recipe.strand, s) : undefined; + const strand: Mask | undefined = tuft + ? (x, y) => tuft(x, y) && !fringe(x, y) && !sideOf(-1)(x, y) && !sideOf(1)(x, y) + : undefined; return { front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y) || (strand?.(x, y) ?? false), @@ -1203,8 +1206,10 @@ function renderFigure( const shadeHair = (g: Grid) => { const out = g.map((r) => [...r]); const cells = g.flatMap((row, y) => row.flatMap((c, x) => (c === hairColor ? [[x, y]] : []))); - const xs = cells.map(([x]) => x!); - const ys = cells.map(([, y]) => y!); + const framed = cells.filter(([x, y]) => !builtin.strand?.(x!, y!)); + const bounds = framed.length ? framed : cells; + const xs = bounds.map(([x]) => x!); + const ys = bounds.map(([, y]) => y!); const left = Math.min(...xs); const right = Math.max(...xs); const top = Math.min(...ys); @@ -1244,7 +1249,7 @@ function renderFigure( if (g[y - k]?.[x] === hairColor) out[y - k]![x] = shade(hairColor, 0.72); const ring = mix(hairColor, WHITE, 0.45); for (let x = Math.round(C + s) - 7; x <= Math.round(C + s) + 3; x++) { - const first = g.findIndex((row) => row[x] === hairColor); + const first = g.findIndex((row, y) => row[x] === hairColor && !builtin.strand?.(x, y)); if (first < 0) continue; const y = first + 2 + Math.round(Math.abs(x + 0.5 - (C + s - 2)) / 4); if (g[y]?.[x] === hairColor && x % 3 !== 0) out[y]![x] = ring; @@ -1412,20 +1417,26 @@ function renderFigure( } const full = clip(outline(figure)); + const variants = new Map(); const recolor = (color: string) => { + const cached = variants.get(color.toLowerCase()); + if (cached) return cached; const variant = blank(); + variants.set(color.toLowerCase(), variant); const plain: Recipe = { ...recipe, hairColor: color }; for (const k of [...AVATAR_PIECE_COLORS, ...AVATAR_PIECE_COLORS_V4]) delete plain[k]; for (const cell of renderFigure(plain, yawDeg, {}, omit).cells) variant[cell.y]![cell.x] = cell.c; return variant; }; - if (AVATAR_PIECE_COLORS_V4.some((key) => recipe[key] !== undefined)) { - const hairAt = (x: number, y: number) => head[y]?.[x] === hairColor; + if (recipe.assetVersion === 4) { + const over = (x: number, y: number) => + !!crown[y]?.[x] || !!drawnOver[y]?.[x] || !!eyes[y]?.[x] || !!glasses[y]?.[x]; + const hairAt = (x: number, y: number) => head[y]?.[x] === hairColor && !over(x, y); const backAt = (x: number, y: number) => back[y]?.[x] !== undefined && figure[y]![x] === back[y]![x]; const owners: [string | undefined, Mask][] = [ - [recipe.backHairColor, (x, y) => masks.back(x, y) && backAt(x, y)], + [flower ? undefined : recipe.backHairColor, (x, y) => masks.back(x, y) && backAt(x, y)], [ recipe.leftSideHairColor, (x, y) => !!masks.sides?.[0](x, y) && (hairAt(x, y) || backAt(x, y)), @@ -1447,7 +1458,7 @@ function renderFigure( owns(x - 1, y) || owns(x + 1, y) || owns(x, y - 1) || owns(x, y + 1); for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) - if (full[y]![x] && (owns(x, y) || (!figure[y]![x] && near(x, y)))) + if (full[y]![x] && !over(x, y) && (owns(x, y) || (!figure[y]![x] && near(x, y)))) full[y]![x] = variant[y]![x]; } } else diff --git a/packages/avatar/src/recipe.ts b/packages/avatar/src/recipe.ts index 1d180a7..af759be 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -418,7 +418,9 @@ const HAIR_PIECES = ['bangs', 'sideHair', 'rightSideHair', 'backHair'] as const; * the species or headwear changes back. */ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { - const strand = recipe.strand ? ['strand'] : []; + const strand = + recipe.strand && (recipe.species === 'flower' || recipe.bangs !== 'none') ? ['strand'] : []; + const bald = recipe.strand && recipe.bangs === 'none' ? ['strand'] : []; if (recipe.species === 'flower') return [ ...HAIR_PIECES, @@ -440,6 +442,7 @@ 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); return hidden; } @@ -521,6 +524,7 @@ const MOVED_HEADPIECES: readonly string[] = AVATAR_HEADPIECES; * the Human edits, so saved recipes keep rendering as they were. */ export function withPieces(recipe: PixelAvatarRecipe): PixelAvatarRecipeV4 { + if (recipe.assetVersion === 4) return recipe; const base: Record = { ...(recipe.assetVersion === 1 ? withSpecies(recipe, 'human') : recipe), assetVersion: 4, diff --git a/packages/avatar/test/fixtures/pieces-golden.json b/packages/avatar/test/fixtures/pieces-golden.json index b0ecbe1..c2fe7c2 100644 --- a/packages/avatar/test/fixtures/pieces-golden.json +++ b/packages/avatar/test/fixtures/pieces-golden.json @@ -30,44 +30,44 @@ "mouths": "c5202a98f51fc4dd" }, "strand-ahoge": { - "front": "f92a345a8553a266", - "turns": "0c1f96383a3ffb14", - "mouths": "044cb02763e40dba" + "front": "ee2fac34ba9d6c4d", + "turns": "af44e57eafef32a3", + "mouths": "4827a671aa7dfb51" }, "strand-ahoge-colored": { - "front": "7eb9af9f066cf278", - "turns": "9b5440d78ed4fced", - "mouths": "bb6af3d10c286846" + "front": "a951e535b5043670", + "turns": "c27ead73fbd610a2", + "mouths": "dd96dad23ca750ae" }, "strand-curl": { - "front": "e7468dd9a14aff48", - "turns": "559cb0c499b60a1f", - "mouths": "24e4e5dd20b4bd1a" + "front": "7dbaafca7c446eb2", + "turns": "3ecd3d16ae16f163", + "mouths": "81e738cb55a9869b" }, "strand-curl-colored": { - "front": "b1bafabdfeea2332", - "turns": "fe039784a61bb501", - "mouths": "33b32bdf2f15c2d9" + "front": "685d6f59cf552b73", + "turns": "c5fa19f6f0d5a2fa", + "mouths": "b073fb707a52afdb" }, "strand-double": { - "front": "fa1d97f32bad5800", - "turns": "36566e4232c9a4fe", - "mouths": "203c6ce920b1117b" + "front": "644432ae0b738f7c", + "turns": "26a7e3608b524012", + "mouths": "8298ff3b447162fe" }, "strand-double-colored": { - "front": "82c568d0b4aae1ef", - "turns": "1d794574d4baf713", - "mouths": "f36b87fff4c24abd" + "front": "9f74b6b27f2d5b5f", + "turns": "11b3672bc4e76e76", + "mouths": "0f918776fc684095" }, "pieces-all": { - "front": "212717de93d84697", - "turns": "77e1a750921e0fb5", - "mouths": "1d3ae4eec2723d33" + "front": "eceba36d90e9a66d", + "turns": "3dbd9c63d96af99b", + "mouths": "4a0d9247130dfda9" }, "pieces-bangs": { - "front": "ce756ab6c4b374ff", - "turns": "f3c3fc5f17989372", - "mouths": "d160088cad9b0011" + "front": "4c3d64e4fdd04102", + "turns": "5945eabddfdfbc39", + "mouths": "eb768b87bf6fea99" }, "pieces-back": { "front": "667008479e90c145", diff --git a/packages/avatar/test/pieces.test.ts b/packages/avatar/test/pieces.test.ts index 2ab7669..7dcf7f5 100644 --- a/packages/avatar/test/pieces.test.ts +++ b/packages/avatar/test/pieces.test.ts @@ -165,6 +165,47 @@ describe('every hair piece colored and built-in headpieces (asset version 4)', ( expect(headpieceStart(human)).toEqual({ slot: 'headpiece', front: [], back: [] }); }); + it('keeps piece colors to hair pixels: no-ops, headpieces, eyes and petals stay put', () => { + const sided = { ...long, leftSideHairColor: '#3fc1b8' } as const; + for (const yaw of [0, -30, 30]) + expect(changed(sided, { ...sided, bangsColor: sided.hairColor }, yaw)).toEqual([]); + const ears = withBuiltInHeadpiece({ ...human, bangs: 'curly' }, 'catears'); + const tinted = { ...ears, bangsColor: '#3fc1b8' }; + for (const key of ['8,3', '23,3', '7,4', '9,4', '22,4', '24,4']) + expect(cells(tinted).get(key)).toBe(cells(ears).get(key)); + const eyed = { + ...human, + bangs: 'hime', + sideHair: 'crop', + rightSideHair: 'crop', + backHair: 'long', + eyeColor: '#123456', + } as const; + for (const yaw of [-30, 30]) { + const head = pixelFigure({ ...eyed, bangsColor: '#3fc1b8' }, yaw).head; + const face = head.indexOf('bh-illustrated-face'); + expect(head.slice(0, face < 0 ? head.length : face)).not.toContain('#123456'); + } + const flower = withSpecies(human, 'flower'); + expect(changed(flower, { ...flower, backHairColor: '#3fc1b8' })).toEqual([]); + }); + + it('keeps withPieces idempotent and hides a strand without bangs', () => { + const picked = { ...human, accessory: 'catears' } as const; + expect(withPieces(picked)).toBe(picked); + const bald = { ...human, bangs: 'none', strand: 'ahoge' } as const; + expect(hiddenChoices(bald)).toContain('strand'); + expect(changed(bald, { ...bald, strand: undefined } as PixelAvatarRecipe)).toEqual([]); + const plain = { ...human, bangs: 'crop' } as const; + const tufted = { ...plain, strand: 'double' } as const; + expect( + changed(plain, tufted).every((key) => { + const [x, y] = key.split(',').map(Number); + return y! <= 4 && Math.abs(x! - 16) <= 4; + }), + ).toBe(true); + }); + it('matches the version 4 golden output', () => { const recipes: Record = {}; for (const headpiece of AVATAR_HEADPIECES)