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.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.
- `@botharness/pixel-avatar` 0.5.0: Custom Parts. A `PixelCustomPart` in the `headpiece` slot has a back layer drawn behind the hair and a front layer over it. Its cells reference an appearance color slot or a fixed color plus a tone step, so a part recolors with the Avatar, and it follows the head through turns. `withHeadpiece` embeds a copy in the recipe as asset version 3, which older consumers reject so they can show a saved snapshot. `customPartId` is a SHA-256 of the canonical content, and `paintPartLayer`, `fillPartLayer` (4-connected) and `mirrorPartX` provide the drawing algorithms. Every earlier recipe renders unchanged.
- `@botharness/pixel-avatar` 0.4.0: elf, dwarf, orc and flower species, an optional `beard` (`short`, `full`, `braided`) that always leaves the speaking mouth visible, armor/robe/tunic/cloak outfits and helmet/hood headwear (version 2 only, in `AVATAR_PARTS_V2`), and the flower's `petals` (`trumpet`, `daisy`, `sunflower`, `tulip`, `sakura`) and `flowerBase` (`leaves`, `pot`) in `AVATAR_EXTRA_PARTS`. A flower has simple bead eyes and its own mouth instead of a person's face. Petals take hair and per-side hair colors; a pot takes the shirt color. `hiddenChoices(recipe)` lists saved choices a species or headwear keeps but does not draw (a flower's hair, outfit, headwear, beard, eyes, brows, nose, cheeks and glasses, hair under a helmet or hood, a dwarf's nose part). `createSeededRecipeV2` / `seededRecipeV2` seed a version 2 face from a name across every species, with optional beards, version 2 outfits and flower parts; version 1 seeds are unchanged, so a consumer picks the seed version per identity. Version 1 recipes and every 0.3.0 goblin recipe render unchanged.
- `@botharness/pixel-avatar` 0.3.0: Avatar Species and split side hair as asset version 2. `withSpecies(recipe, species)` adds a `species` (`human` or `goblin`), a separate `rightSideHair` and optional `leftSideHairColor`/`rightSideHairColor`, moving a suggested skin color to the new species' `AVATAR_SPECIES_SWATCHES`. The goblin has pointed ears drawn over hair and small tusks, and keeps every head turn and speech mouth layer. Version 1 recipes, seeded faces and the existing golden output are unchanged, and a human version 2 recipe renders exactly like its version 1 source. Older consumers reject version 2 recipes through `isPixelAvatarRecipe`, so they can fall back to a saved snapshot.
Expand Down
1 change: 1 addition & 0 deletions packages/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc
- `AVATAR_SPECIES`, `AVATAR_SPECIES_SWATCHES`, `AVATAR_PIECE_COLORS`, `withSpecies(recipe, species)`: Avatar Species (`human`, `goblin`) on the same rig, as asset version 2. `withSpecies` keeps every choice, splits side hair into `sideHair` (left) and `rightSideHair`, and accepts optional `leftSideHairColor`/`rightSideHairColor`, where left and right are as seen on screen. Consumers built before version 2 reject these recipes with `isPixelAvatarRecipe` and can show a saved snapshot instead.
- `AVATAR_PARTS_V2`, `AVATAR_EXTRA_PARTS`, `hiddenChoices(recipe)`: version 2 adds elf, dwarf, orc and flower species, armor/robe/tunic/cloak outfits, helmet/hood headwear, an optional `beard`, and a flower's `petals` and `flowerBase`. `hiddenChoices` names saved choices the current species or headwear keeps but does not draw, so an editor can say so instead of discarding them.
- `PixelCustomPart`, `withHeadpiece(recipe, part)`, `customPartId`, `isPixelCustomPart`: a Human-drawn Custom Part in the `headpiece` slot (the top 32×16 of the tile). Its back layer is drawn behind the hair and its front layer over it, and both follow the head through turns. Each cell is `[x, y, color, tone]`, where `color` is an appearance color slot (`hairColor`, `skinColor`, `eyeColor`, `shirtColor`), so the part recolors with the Avatar, or a fixed `#rrggbb`, and `tone` is a step from −2 to 2 on the rig's shade ramp (`partToneColor`). A recipe wearing a part embeds its own copy as asset version 3. `customPartId` is the SHA-256 of the canonical content; names, authors and origins belong to the consumer's library.
- `HAIR_PART_SLOTS`, `hairPieceStart(recipe, slot)`, `withCustomPart(recipe, slot, part)`, `wornPart`: drawn hair pieces. `bangs`, `leftSideHair`, `rightSideHair` and `backHair` each take a Custom Part (front layer only) that replaces the built-in piece while it stays saved underneath. In a hair part, a `hairColor` cell at tone 0 is live hair: it is shaded with the rest of the hair exactly like the built-in piece, so `hairPieceStart` flattens the worn built-in piece into such cells and an unchanged copy renders identically. Other cells show their own color. Drawn hair follows turns and is hidden under a helmet or hood and on a flower, like built-in 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.
- `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.5.0",
"version": "0.6.0",
"description": "Seeded, editable 32×32 chibi pixel avatars with state symbols that morph in and out",
"keywords": [
"agent-ui",
Expand Down
108 changes: 94 additions & 14 deletions packages/avatar/src/figure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { escapeAttribute, type PixelCell } from '@botharness/pixel-morph';
import type { PartCell, PartTone } from './part.js';
import { AVATAR_PIECE_COLORS, type PixelAvatarRecipe } from './recipe.js';
import { HAIR_PART_SLOTS, type HairPartSlot, type PartCell, type PartTone } from './part.js';
import { AVATAR_PIECE_COLORS, detailedRecipe, wornPart, type PixelAvatarRecipe } from './recipe.js';

type Cell = string | undefined;
type Grid = Cell[][];
Expand Down Expand Up @@ -366,7 +366,14 @@ function detailedHair(
recipe: Recipe,
s: number,
b: number,
): { back: Mask; front: Mask; ties?: Point[]; bands?: boolean; sides?: [Mask, Mask] } {
): {
back: Mask;
front: Mask;
ties?: Point[];
bands?: boolean;
sides?: [Mask, Mask];
pieces?: Record<HairPartSlot, Mask>;
} {
const of = (style: Recipe['hair'] | undefined) =>
hair({ ...recipe, hair: style ?? 'none' }, s, b);
const bangs = of(recipe.bangs);
Expand All @@ -383,15 +390,16 @@ function detailedHair(
(sign: -1 | 1): Mask =>
(x, y) =>
y > 7 && sign * (x + 0.5 - C - s) >= 7 && lengthen((sign < 0 ? side : right).front)(x, y);
const fringe: Mask = (x, y) =>
y <= 7
? 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);
return {
front: (x, y) =>
y <= 7
? bangs.front(x, y) || back.front(x, y)
: Math.abs(x + 0.5 - C - s) < 7
? bangs.front(x, y)
: lengthen((x + 0.5 - C - s < 0 ? side : right).front)(x, y),
front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y),
sides: [sideOf(-1), sideOf(1)],
back: length === 0 ? back.back : lengthen(back.back),
pieces: { bangs: fringe, leftSideHair: sideOf(-1), rightSideHair: sideOf(1), backHair: behind },
back: behind,
...(back.ties ? { ties: back.ties } : {}),
...(back.bands ? { bands: back.bands } : {}),
};
Expand Down Expand Up @@ -875,7 +883,7 @@ export function pixelFigure(
const covered = recipe.accessory === 'helmet' || recipe.accessory === 'hood';
const bare: Mask = () => false;
const petalRing = flower ? petals(recipe.petals ?? 'trumpet', s) : bare;
const masks: ReturnType<typeof detailedHair> = flower
const builtin: ReturnType<typeof detailedHair> = flower
? {
back: petalRing,
front: bare,
Expand All @@ -889,6 +897,52 @@ export function pixelFigure(
: recipe.bangs === undefined
? hair(recipe, s, b)
: detailedHair(recipe, s, b);
const drawnHair = HAIR_PART_SLOTS.flatMap((slot) => {
const part = builtin.pieces ? wornPart(recipe, slot) : undefined;
return part ? [{ slot, part }] : [];
});
const live = (cells: readonly PartCell[], shift: number): Mask => {
const keys = new Set(
cells.flatMap(([x, y, color, tone]) =>
color === 'hairColor' && tone === 0 ? [`${x + shift},${y}`] : [],
),
);
return (x, y) => keys.has(`${x},${y}`);
};
const backShift = (): number => {
if (s === b || !builtin.pieces) return s;
const front = detailedHair(detailedRecipe(recipe), 0, 0).pieces!.backHair;
const misses = (shift: number) => {
let count = 0;
for (let y = 0; y < N; y++)
for (let x = -4; x < N + 4; x++)
if (front(x, y) !== builtin.pieces!.backHair(x + shift, y)) count++;
return count;
};
return misses(b) < misses(s) ? b : s;
};
const piece = (slot: HairPartSlot): Mask => {
const part = drawnHair.find((drawn) => drawn.slot === slot)?.part;
if (!part) return builtin.pieces![slot];
return live(part.front, slot === 'backHair' ? backShift() : s);
};
const masks: ReturnType<typeof detailedHair> =
drawnHair.length === 0
? builtin
: {
...builtin,
front: (x, y) =>
piece('bangs')(x, y) || piece('leftSideHair')(x, y) || piece('rightSideHair')(x, y),
sides: [piece('leftSideHair'), piece('rightSideHair')],
back: piece('backHair'),
...(drawnHair.some((drawn) => drawn.slot === 'backHair') ? { ties: [] } : {}),
};
const inked = (slot: HairPartSlot | 'all-front') =>
drawnHair
.filter((drawn) => (slot === 'all-front' ? drawn.slot !== 'backHair' : drawn.slot === slot))
.flatMap((drawn) =>
drawn.part.front.filter(([, , color, tone]) => !(color === 'hairColor' && tone === 0)),
);

const body = blank();
if (flower) flowerBase(recipe, body, s);
Expand All @@ -915,17 +969,18 @@ export function pixelFigure(
shade(hairColor, 0.68),
);

const headpiece = recipe.assetVersion === 3 ? recipe.headpiece : undefined;
const placeCells = (grid: Grid, cells: readonly PartCell[]) => {
const headpiece = wornPart(recipe, 'headpiece');
const placeCells = (grid: Grid, cells: readonly PartCell[], compress = true) => {
const placed = cells.map(([x, y, color, tone]) => {
const dx = x + 0.5 - C;
const far = turned && d * dx < 0 && Math.abs(dx) >= 5;
const far = compress && turned && d * dx < 0 && Math.abs(dx) >= 5;
const base = color.startsWith('#') ? color.toLowerCase() : recipe[color as 'hairColor'];
return { x: x + s + (far ? d : 0), y, c: TONES[tone](base), far };
});
for (const cell of [...placed.filter((p) => p.far), ...placed.filter((p) => !p.far)])
if (cell.x >= 0 && cell.x < N) grid[cell.y]![cell.x] = cell.c;
};
placeCells(back, inked('backHair'), false);
if (headpiece) placeCells(back, headpiece.back);

const head = blank();
Expand Down Expand Up @@ -1039,6 +1094,7 @@ export function pixelFigure(
};

const crown = blank();
placeCells(crown, inked('all-front'), false);
if (headpiece) placeCells(crown, headpiece.front);
const figure = blank();
for (const layer of [back, body, shadeHair(head), crown])
Expand Down Expand Up @@ -1232,3 +1288,27 @@ export function pixelFigure(
].join(''),
};
}

/**
* The hair piece in `slot` as a Custom Part to start drawing from: the drawn part worn there,
* or the built-in piece flattened for this recipe in the front pose as live `hairColor` cells.
* A flattened piece keeps this shape and no longer follows face shape or hair length.
*/
export function hairPieceStart(
recipe: PixelAvatarRecipe,
slot: HairPartSlot,
): { slot: HairPartSlot; front: PartCell[]; back: PartCell[] } {
const worn = wornPart(recipe, slot);
if (worn) return { slot, front: [...worn.front], back: [] };
const built = detailedHair(detailedRecipe(recipe), 0, 0);
const mask = built.pieces![slot];
const ties = new Set(
slot === 'backHair' ? (built.ties ?? []).map(([x, y]) => `${Math.round(x)},${y}`) : [],
);
const front: PartCell[] = [];
for (let y = 0; y < N; y++)
for (let x = 0; x < N; x++)
if (ties.has(`${x},${y}`)) front.push([x, y, 'shirtColor', -1]);
else if (mask(x, y)) front.push([x, y, 'hairColor', 0]);
return { slot, front, back: [] };
}
8 changes: 8 additions & 0 deletions packages/avatar/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type { PixelCell } from '@botharness/pixel-morph';
export {
hairPieceStart,
partToneColor,
pixelFigure,
pixelTileColor,
Expand Down Expand Up @@ -41,12 +42,18 @@ export {
type PixelAvatarRecipeV2,
type PixelAvatarRecipeV3,
withHeadpiece,
withCustomPart,
wornPart,
CUSTOM_PART_KEYS,
type CustomPartKey,
} from './recipe.js';
export {
MAX_PART_FIXED_COLORS,
PART_LAYERS,
PART_SLOTS,
PART_TONES,
HAIR_PART_SLOTS,
isHairPartSlot,
canonicalCustomPart,
createCustomPart,
customPartId,
Expand All @@ -64,6 +71,7 @@ export {
type PartLayerName,
type PartSlot,
type PartTone,
type HairPartSlot,
type PixelCustomPart,
} from './part.js';
export { seededRandom } from './random.js';
Expand Down
19 changes: 17 additions & 2 deletions packages/avatar/src/part.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,26 @@ import { AVATAR_COLORS, type AvatarColor } from './recipe.js';

/**
* Custom Part slots. A headpiece covers the top of the tile, around the hair: its back layer is
* drawn behind the hair and its front layer over it. Cells are in tile coordinates, so the
* Avatar centerline sits between x = 15 and x = 16.
* drawn behind the hair and its front layer over it. Hair slots replace one built-in hair piece
* and use only the front layer. Cells are in tile coordinates, so the Avatar centerline sits
* between x = 15 and x = 16.
*/
export const PART_SLOTS = {
headpiece: { width: 32, height: 16 },
bangs: { width: 32, height: 32 },
leftSideHair: { width: 32, height: 32 },
rightSideHair: { width: 32, height: 32 },
backHair: { width: 32, height: 32 },
} as const;
export type PartSlot = keyof typeof PART_SLOTS;
/**
* Hair slots. In a hair part, a `hairColor` cell at tone 0 is live hair: it is shaded with the
* rest of the hair, exactly like a built-in piece. Every other cell shows its own color.
*/
export const HAIR_PART_SLOTS = ['bangs', 'leftSideHair', 'rightSideHair', 'backHair'] as const;
export type HairPartSlot = (typeof HAIR_PART_SLOTS)[number];
export const isHairPartSlot = (slot: PartSlot): slot is HairPartSlot =>
(HAIR_PART_SLOTS as readonly string[]).includes(slot);
/** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */
export const PART_TONES = [-2, -1, 0, 1, 2] as const;
export type PartTone = (typeof PART_TONES)[number];
Expand Down Expand Up @@ -45,6 +58,8 @@ export function isPixelCustomPart(value: unknown): value is PixelCustomPart {
const r = value as Record<string, unknown>;
if (Object.keys(r).length !== 3 || !Object.hasOwn(PART_SLOTS, r['slot'] as string)) return false;
const { width, height } = PART_SLOTS[r['slot'] as PartSlot];
if (isHairPartSlot(r['slot'] as PartSlot) && (r['back'] as unknown[] | undefined)?.length !== 0)
return false;
const fixed = new Set<string>();
for (const name of PART_LAYERS) {
const cells = r[name];
Expand Down
Loading
Loading