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.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.
Expand Down
1 change: 1 addition & 0 deletions packages/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<prefix>-body`, `-head`, `-face`, `-gaze`, `-blink`) for CSS animation.
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.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",
Expand Down
99 changes: 94 additions & 5 deletions packages/avatar/src/figure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
AVATAR_PIECE_COLORS_V4,
builtInHeadpiece,
detailedRecipe,
isAnimalSpecies,
hiddenChoices,
wornPart,
type AvatarAnimalSpecies,
type PixelAvatarRecipe,
} from './recipe.js';

Expand Down Expand Up @@ -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<NonNullable<Recipe['species']>, { rows: string[]; rise: number }>
> = {
Expand Down Expand Up @@ -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] : [];
}),
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) };
Expand All @@ -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]);
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 6 additions & 0 deletions packages/avatar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/avatar/src/part.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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 =>
Expand Down
Loading
Loading