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.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.
- `@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.
Expand Down
1 change: 1 addition & 0 deletions packages/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc
- `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.
- `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.
- `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.6.0",
"version": "0.7.0",
"description": "Seeded, editable 32×32 chibi pixel avatars with state symbols that morph in and out",
"keywords": [
"agent-ui",
Expand Down
147 changes: 133 additions & 14 deletions packages/avatar/src/figure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { escapeAttribute, type PixelCell } from '@botharness/pixel-morph';
import { HAIR_PART_SLOTS, type HairPartSlot, type PartCell, type PartTone } from './part.js';
import { AVATAR_PIECE_COLORS, detailedRecipe, wornPart, type PixelAvatarRecipe } from './recipe.js';
import {
HAIR_PART_SLOTS,
REPLACE_PART_SLOTS,
type HairPartSlot,
type PartCell,
type PartTone,
type ReplacePartSlot,
} from './part.js';
import {
AVATAR_COLORS,
AVATAR_PIECE_COLORS,
detailedRecipe,
hiddenChoices,
wornPart,
type PixelAvatarRecipe,
} from './recipe.js';

type Cell = string | undefined;
type Grid = Cell[][];
Expand Down Expand Up @@ -862,6 +876,24 @@ export function pixelFigure(
yawDeg: number,
options: PixelFigureOptions = {},
): { tile: string; body: string; head: string; cells: PixelCell[] } {
const { grids: _, ...out } = renderFigure(recipe, yawDeg, options, new Set());
return out;
}

const FACE_SLOTS: readonly ReplacePartSlot[] = ['nose', 'cheeks', 'glasses'];

function renderFigure(
recipe: Recipe,
yawDeg: number,
options: PixelFigureOptions,
omit: ReadonlySet<ReplacePartSlot>,
): {
tile: string;
body: string;
head: string;
cells: PixelCell[];
grids: { figure: Grid; features: Grid; glasses: Grid; back: Grid; body: Grid };
} {
const yaw = (yawDeg * Math.PI) / 180;
const sinY = Math.sin(yaw);
const s = Math.round(sinY * 3);
Expand All @@ -880,9 +912,41 @@ export function pixelFigure(
}
: front;
const flower = recipe.species === 'flower';
const covered = recipe.accessory === 'helmet' || recipe.accessory === 'hood';
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);
return part && shown ? [[slot, part.front] as const] : [];
}),
);
const skip = (slot: ReplacePartSlot) => omit.has(slot) || drawnParts.has(slot);
const covered =
!skip('accessory') && (recipe.accessory === 'helmet' || recipe.accessory === 'hood');
const bare: Mask = () => false;
const petalRing = flower ? petals(recipe.petals ?? 'trumpet', s) : bare;
const petalRing = flower && !skip('petals') ? petals(recipe.petals ?? 'trumpet', s) : bare;
const faceShift = Math.round(fs * 0.6);
const mouthCells = new Set<string>();
for (const rows of [...Object.values(MOUTHS), ['.K.', 'KMK', '.K.']]) {
const left = Math.round(C - 0.5 + fs * 0.6 - (rows[0]!.length - 1) / 2);
rows.forEach((row, dy) =>
[...row].forEach((code, dx) => {
if (code !== '.') mouthCells.add(`${left + dx},${20 + (recipe.height ?? 0) + dy}`);
}),
);
}
const mouthBox = (x: number, y: number) => mouthCells.has(`${x},${y}`);
const overlay = (grid: Grid, slot: ReplacePartSlot, keepOutMouth = false) => {
const cells = drawnParts.get(slot);
if (!cells) return;
const shift = FACE_SLOTS.includes(slot) ? faceShift : s;
for (const [x, y, color, tone] of cells) {
const px = x + shift;
if (px < 0 || px >= N || (keepOutMouth && mouthBox(px, y))) continue;
const base = color.startsWith('#') ? color.toLowerCase() : recipe[color as 'hairColor'];
grid[y]![px] = TONES[tone](base);
}
};
const builtin: ReturnType<typeof detailedHair> = flower
? {
back: petalRing,
Expand Down Expand Up @@ -945,8 +1009,13 @@ export function pixelFigure(
);

const body = blank();
if (flower) flowerBase(recipe, body, s);
else outfit(recipe, body, s);
if (flower) {
if (!skip('flowerBase')) flowerBase(recipe, body, s);
overlay(body, 'flowerBase');
} else {
if (!skip('outfit')) outfit(recipe, body, s);
overlay(body, 'outfit');
}

const back = blank();
if (flower) {
Expand Down Expand Up @@ -981,12 +1050,13 @@ export function pixelFigure(
if (cell.x >= 0 && cell.x < N) grid[cell.y]![cell.x] = cell.c;
};
placeCells(back, inked('backHair'), false);
overlay(back, 'petals');
if (headpiece) placeCells(back, headpiece.back);

const head = blank();
paint(head, face, skin);
const h = recipe.height ?? 0;
if (recipe.beard && !flower) {
if (recipe.beard && !flower && !skip('beard')) {
const centre = C + fs * 0.6;
const mouthX = Math.round(C - 0.5 + fs * 0.6);
const chin: Mask = (x, y) => face(x, y) || face(x, y - 2);
Expand Down Expand Up @@ -1039,7 +1109,7 @@ export function pixelFigure(
if (!pointed && !flower) 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) accessory(recipe, head, s, face);
if (!flower && !skip('accessory')) accessory(recipe, head, s, face);

const shadeHair = (g: Grid) => {
const out = g.map((r) => [...r]);
Expand Down Expand Up @@ -1093,11 +1163,14 @@ export function pixelFigure(
return out;
};

const drawnOver = blank();
overlay(drawnOver, 'beard', true);
overlay(drawnOver, 'accessory');
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])
for (const layer of [back, body, shadeHair(head), drawnOver, crown])
for (let y = 0; y < N; y++)
for (let x = 0; x < N; x++) if (layer[y]![x]) figure[y]![x] = layer[y]![x];

Expand Down Expand Up @@ -1167,6 +1240,7 @@ export function pixelFigure(
N: shade(skin, 0.84),
n: shade(skin, 0.72),
});
else if (skip('nose')) overlay(features, 'nose', true);
else if (recipe.nose === 'dot') sprite(features, ['N'], cx, 18 + h, { N: shade(skin, 0.78) });
else if (recipe.nose === 'button') sprite(features, ['N'], cx, 18 + h, { N: shade(skin, 0.88) });
else if (recipe.nose === 'line')
Expand All @@ -1186,11 +1260,12 @@ export function pixelFigure(
if (recipe.species === 'orc')
for (const tx of [Math.round(mx - 2.5), Math.round(mx + 2.5)])
sprite(layer, ['T', 'T'], tx, 20 + h, { T: TUSK });
if (recipe.cheeks === 'blush' && !flower) {
if (skip('cheeks')) overlay(layer, 'cheeks', true);
if (recipe.cheeks === 'blush' && !flower && !skip('cheeks')) {
sprite(layer, ['PP'], left.x - 1, 19 + h, { P: mix(BLUSH, skin, 0.25) });
sprite(layer, ['PP'], right.x + right.w - 1, 19 + h, { P: mix(BLUSH, skin, 0.25) });
}
if (recipe.cheeks === 'freckles' && !flower) {
if (recipe.cheeks === 'freckles' && !flower && !skip('cheeks')) {
sprite(layer, ['F.F'], left.x, 19 + h, { F: shade(skin, 0.7) });
sprite(layer, ['F.F'], right.x + 1, 19 + h, { F: shade(skin, 0.7) });
}
Expand Down Expand Up @@ -1223,7 +1298,8 @@ export function pixelFigure(
},
INK,
);
const lenses = flower ? 'none' : recipe.glasses;
const lenses = flower || skip('glasses') ? 'none' : recipe.glasses;
overlay(glasses, 'glasses', true);
if (lenses === 'round' || lenses === 'square') {
frame(left.x, left.w, lenses === 'round');
frame(right.x, right.w, lenses === 'round');
Expand Down Expand Up @@ -1252,7 +1328,8 @@ export function pixelFigure(
const variant = blank();
const plain = { ...recipe, hairColor: color };
for (const k of AVATAR_PIECE_COLORS) delete plain[k];
for (const cell of pixelFigure(plain, yawDeg).cells) variant[cell.y]![cell.x] = cell.c;
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++)
Expand All @@ -1266,7 +1343,8 @@ export function pixelFigure(
for (let x = 0; x < N; x++) {
const c = full[y]![x];
if (!c) continue;
if (y >= 22 && !head[y]![x] && !back[y]![x] && !crown[y]![x]) bodyCells[y]![x] = c;
if (y >= 22 && !head[y]![x] && !back[y]![x] && !crown[y]![x] && !drawnOver[y]![x])
bodyCells[y]![x] = c;
else headCells[y]![x] = c;
}
const cells: PixelCell[] = [];
Expand All @@ -1276,6 +1354,7 @@ export function pixelFigure(
if (c) cells.push({ x, y, c });
}
return {
grids: { figure, features: savedFeatures, glasses, back, body },
cells,
tile: `<rect width="32" height="32" rx="6" fill="${pixelTileColor(recipe.hairColor)}"/>`,
body: `<g class="bh-illustrated-body">${rects(bodyCells)}</g>`,
Expand Down Expand Up @@ -1312,3 +1391,43 @@ export function hairPieceStart(
else if (mask(x, y)) front.push([x, y, 'hairColor', 0]);
return { slot, 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
* color slot and tone when it is exactly one, so it recolors with the Avatar, and a fixed color
* otherwise; an unchanged copy renders identically. A flattened helmet or hood no longer hides
* the hair: only the built-in ones do.
*/
export function replacePartStart(
recipe: PixelAvatarRecipe,
slot: ReplacePartSlot,
): { slot: ReplacePartSlot; front: PartCell[]; back: PartCell[] } {
const worn = wornPart(recipe, slot);
if (worn) return { slot, front: [...worn.front], back: [] };
const layer =
slot === 'glasses'
? 'glasses'
: slot === 'nose' || slot === 'cheeks'
? 'features'
: slot === 'petals'
? 'back'
: slot === 'outfit' || slot === 'flowerBase'
? 'body'
: '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 front: PartCell[] = [];
for (let y = 0; y < N; y++)
for (let x = 0; x < N; x++) {
const c = shown[y]![x];
if (c && c !== without[y]![x]) front.push([x, y, ...source(c)]);
}
return { slot, front, back: [] };
}
4 changes: 4 additions & 0 deletions packages/avatar/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type { PixelCell } from '@botharness/pixel-morph';
export {
hairPieceStart,
replacePartStart,
partToneColor,
pixelFigure,
pixelTileColor,
Expand Down Expand Up @@ -53,7 +54,9 @@ export {
PART_SLOTS,
PART_TONES,
HAIR_PART_SLOTS,
REPLACE_PART_SLOTS,
isHairPartSlot,
isReplacePartSlot,
canonicalCustomPart,
createCustomPart,
customPartId,
Expand All @@ -72,6 +75,7 @@ export {
type PartSlot,
type PartTone,
type HairPartSlot,
type ReplacePartSlot,
type PixelCustomPart,
} from './part.js';
export { seededRandom } from './random.js';
Expand Down
30 changes: 27 additions & 3 deletions packages/avatar/src/part.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export const PART_SLOTS = {
leftSideHair: { width: 32, height: 32 },
rightSideHair: { width: 32, height: 32 },
backHair: { width: 32, height: 32 },
outfit: { width: 32, height: 32 },
accessory: { width: 32, height: 32 },
beard: { width: 32, height: 32 },
glasses: { width: 32, height: 32 },
nose: { width: 32, height: 32 },
cheeks: { width: 32, height: 32 },
petals: { width: 32, height: 32 },
flowerBase: { width: 32, height: 32 },
} as const;
export type PartSlot = keyof typeof PART_SLOTS;
/**
Expand All @@ -22,6 +30,23 @@ export const HAIR_PART_SLOTS = ['bangs', 'leftSideHair', 'rightSideHair', 'backH
export type HairPartSlot = (typeof HAIR_PART_SLOTS)[number];
export const isHairPartSlot = (slot: PartSlot): slot is HairPartSlot =>
(HAIR_PART_SLOTS as readonly string[]).includes(slot);
/**
* Slots whose drawn part replaces a built-in part pixel for pixel: the part's cells are painted
* where the built-in part would be, and every cell shows its own color and tone.
*/
export const REPLACE_PART_SLOTS = [
'outfit',
'accessory',
'beard',
'glasses',
'nose',
'cheeks',
'petals',
'flowerBase',
] as const;
export type ReplacePartSlot = (typeof REPLACE_PART_SLOTS)[number];
export const isReplacePartSlot = (slot: PartSlot): slot is ReplacePartSlot =>
(REPLACE_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 All @@ -32,7 +57,7 @@ export type PartCell = readonly [x: number, y: number, color: PartColor, tone: P
export const PART_LAYERS = ['front', 'back'] as const;
export type PartLayerName = (typeof PART_LAYERS)[number];
/** Most fixed colors one part may use. */
export const MAX_PART_FIXED_COLORS = 16;
export const MAX_PART_FIXED_COLORS = 32;

/**
* A Human-drawn Custom Part. Its content is immutable and its identity is `customPartId`;
Expand All @@ -58,8 +83,7 @@ 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;
if (r['slot'] !== 'headpiece' && (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