Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/avatar/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
42 changes: 26 additions & 16 deletions packages/avatar/src/figure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]!;
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -406,6 +402,11 @@ function detailedHair(
sides?: [Mask, Mask];
pieces?: Record<HairPartSlot, Mask>;
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);
Expand Down Expand Up @@ -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) }
: {}),
};
}

Expand Down Expand Up @@ -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)),
Expand All @@ -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)],
];
Expand Down
23 changes: 23 additions & 0 deletions packages/avatar/test/pieces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading