diff --git a/CHANGELOG.md b/CHANGELOG.md index da04dd2..58090a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `@botharness/pixel-avatar` 0.10.2: bun and odango back hair take `backHairColor`. Their knots are drawn with the fringe, so in asset version 4 the bangs color painted them and the back hair color had nothing to paint. The knots now belong to the back hair piece. Only version 4 recipes that set `bangsColor` or `backHairColor` with `backHair: 'bun'` or `'odango'` render differently; every golden fixture is unchanged. - `@botharness/pixel-avatar` 0.10.1: opt-in `speechMouthVersion: 2` replaces the round fully open speech mouth with a flatter, gently curved opening. Half-open, closed and saved layers stay identical; default/version-1 speech output, recipes and golden fixtures are unchanged. - `@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. diff --git a/packages/avatar/package.json b/packages/avatar/package.json index 0e3239c..9165002 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@botharness/pixel-avatar", - "version": "0.10.1", + "version": "0.10.2", "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 aa42139..d1f3946 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -204,7 +204,7 @@ function hair( recipe: Recipe, s: number, b: number, -): { back: Mask; front: Mask; ties?: Point[]; bands?: boolean } { +): { back: Mask; front: Mask; ties?: Point[]; bands?: boolean; knots?: Mask } { const none: Mask = () => false; const cap = ellipse(C + s, 14, 11, 10.6); const tip = (x: number, depth = 1) => [0, depth, depth + 1, depth][(x + 40 - s) % 4]!; @@ -261,11 +261,10 @@ function hair( return { back: longBack(25), front: bangs(13, 1, 24) }; case 'long': return { back: longBack(31), front: bangs(13, 1, 26) }; - case 'bun': - return { - back: none, - front: (x, y) => bangs(12, 1, 18)(x, y) || ellipse(C + s, 2.6, 4, 2.6)(x, y), - }; + case 'bun': { + const knot = ellipse(C + s, 2.6, 4, 2.6); + return { back: none, front: (x, y) => bangs(12, 1, 18)(x, y) || knot(x, y), knots: knot }; + } case 'pigtails': return { back: (x, y) => @@ -319,14 +318,11 @@ function hair( front: (x, y) => cap(x, Math.min(y, 13)) && (y <= 12 || (Math.abs(x + 0.5 - C - s) >= 6.5 && y <= 24)), }; - case 'odango': - return { - back: none, - front: (x, y) => - bangs(12, 1, 18)(x, y) || - ellipse(C + s - 8, 3.5, 3.3, 3)(x, y) || - ellipse(C + s + 8, 3.5, 3.3, 3)(x, y), - }; + case 'odango': { + const knots: Mask = (x, y) => + ellipse(C + s - 8, 3.5, 3.3, 3)(x, y) || ellipse(C + s + 8, 3.5, 3.3, 3)(x, y); + return { back: none, front: (x, y) => bangs(12, 1, 18)(x, y) || knots(x, y), knots }; + } case 'messy': return { back: none, @@ -406,6 +402,11 @@ function detailedHair( sides?: [Mask, Mask]; pieces?: Record; strand?: Mask; + /** + * Bun and odango knots on top of the head. They are drawn with the fringe, but belong to the back + * hair style, so the back hair color owns them. + */ + knots?: Mask; } { const of = (style: Recipe['hair'] | undefined) => hair({ ...recipe, hair: style ?? 'none' }, s, b); @@ -441,6 +442,9 @@ function detailedHair( back: behind, ...(back.ties ? { ties: back.ties } : {}), ...(back.bands ? { bands: back.bands } : {}), + ...(back.knots + ? { knots: (x: number, y: number) => back.knots!(x, y) && !bangs.front(x, y) } + : {}), }; } @@ -1525,8 +1529,14 @@ function renderFigure( 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]; + // knots drawn with a built-in fringe; a drawn bangs part replaces that fringe + const knotAt = (x: number, y: number) => + !drawnHair.some((drawn) => drawn.slot === 'bangs') && !!masks.knots?.(x, y) && hairAt(x, y); const owners: [string | undefined, Mask][] = [ - [flower ? undefined : recipe.backHairColor, (x, y) => masks.back(x, y) && backAt(x, y)], + [ + flower ? undefined : recipe.backHairColor, + (x, y) => (masks.back(x, y) && backAt(x, y)) || knotAt(x, y), + ], [ recipe.leftSideHairColor, (x, y) => !!masks.sides?.[0](x, y) && (hairAt(x, y) || backAt(x, y)), @@ -1537,7 +1547,7 @@ function renderFigure( ], [ recipe.bangsColor, - (x, y) => (builtin.pieces ? piece('bangs')(x, y) : false) && hairAt(x, y), + (x, y) => (builtin.pieces ? piece('bangs')(x, y) : false) && hairAt(x, y) && !knotAt(x, y), ], [recipe.strandColor, (x, y) => (builtin.strand?.(x, y) ?? false) && hairAt(x, y)], ]; diff --git a/packages/avatar/test/pieces.test.ts b/packages/avatar/test/pieces.test.ts index 7dcf7f5..1a4329f 100644 --- a/packages/avatar/test/pieces.test.ts +++ b/packages/avatar/test/pieces.test.ts @@ -150,6 +150,29 @@ describe('every hair piece colored and built-in headpieces (asset version 4)', ( expect(hiddenChoices({ ...withSpecies(strand, 'flower') })).toContain('strand'); }); + it('colors bun and odango knots with the back hair color, not the bangs color', () => { + for (const backHair of ['bun', 'odango'] as const) { + const knotted = { ...long, backHair, bangs: 'crop' } as const; + const back = changed(knotted, { ...knotted, backHairColor: '#3fc1b8' }); + expect(back.length).toBeGreaterThan(4); + expect(back.every((key) => Number(key.split(',')[1]) <= 7)).toBe(true); + const bangs = changed(knotted, { ...knotted, bangsColor: '#f06292' }); + // only the dark outline on the seam between knot and fringe may take both colors + const base = cells(knotted); + const shared = bangs.filter((key) => back.includes(key)); + expect(shared.length).toBeLessThanOrEqual(4); + for (const key of shared) + expect( + Math.max(...[1, 3, 5].map((i) => parseInt(base.get(key)!.slice(i, i + 2), 16))), + ).toBeLessThan(0x40); + expect(changed({ ...knotted, backHairColor: knotted.hairColor }, knotted)).toEqual([]); + for (const yaw of [-30, 30]) + expect( + changed(knotted, { ...knotted, backHairColor: '#3fc1b8' }, yaw).length, + ).toBeGreaterThan(4); + } + }); + 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);