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.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.
- `@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.
Expand Down
1 change: 1 addition & 0 deletions packages/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc
- `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.
- `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.
- `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.8.0",
"version": "0.9.0",
"description": "Seeded, editable 32×32 chibi pixel avatars with state symbols that morph in and out",
"keywords": [
"agent-ui",
Expand Down
218 changes: 194 additions & 24 deletions packages/avatar/src/figure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
import {
AVATAR_COLORS,
AVATAR_PIECE_COLORS,
AVATAR_PIECE_COLORS_V4,
builtInHeadpiece,
detailedRecipe,
hiddenChoices,
wornPart,
Expand Down Expand Up @@ -376,6 +378,19 @@ function hair(
}
}

const STRANDS: Record<NonNullable<Recipe['strand']>, { rows: string[]; dx: number }> = {
ahoge: { rows: ['XX.', '.XX', '.XX'], dx: 0 },
curl: { rows: ['.XX.', 'X..X', '..XX', '.X..'], dx: -1 },
double: { rows: ['X...X', '.X.X.', '.X.X.'], dx: -2 },
};

function strandMask(style: NonNullable<Recipe['strand']>, s: number): Mask {
const { rows, dx } = STRANDS[style];
const top = 4 - rows.length;
const left = Math.round(C + s) + dx;
return (x, y) => rows[y - top]?.[x - left] === 'X';
}

function detailedHair(
recipe: Recipe,
s: number,
Expand All @@ -387,6 +402,7 @@ function detailedHair(
bands?: boolean;
sides?: [Mask, Mask];
pieces?: Record<HairPartSlot, Mask>;
strand?: Mask;
} {
const of = (style: Recipe['hair'] | undefined) =>
hair({ ...recipe, hair: style ?? 'none' }, s, b);
Expand All @@ -409,8 +425,14 @@ function detailedHair(
? 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);
const tuft = recipe.strand && recipe.bangs !== 'none' ? strandMask(recipe.strand, s) : undefined;
const strand: Mask | undefined = tuft
? (x, y) => tuft(x, y) && !fringe(x, y) && !sideOf(-1)(x, y) && !sideOf(1)(x, y)
: undefined;
return {
front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y),
front: (x, y) =>
fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y) || (strand?.(x, y) ?? false),
...(strand ? { strand } : {}),
sides: [sideOf(-1), sideOf(1)],
pieces: { bangs: fringe, leftSideHair: sideOf(-1), rightSideHair: sideOf(1), backHair: behind },
back: behind,
Expand Down Expand Up @@ -778,6 +800,70 @@ function accessory(recipe: Recipe, g: Grid, s: number, face: Mask): void {
}
}

/**
* A built-in headpiece: the row that meets the head goes behind the hair, the rest in front.
* Turned, the far side of a pair is hidden, like a turned ear.
*/
function drawHeadpiece(
style: NonNullable<ReturnType<typeof builtInHeadpiece>>,
recipe: Recipe,
behind: Grid,
front: Grid,
s: number,
far: number,
): void {
const cx = Math.round(C + s);
const pair = (
rows: readonly string[],
left: number,
right: number,
y: number,
pal: Record<string, string>,
mirror = true,
) => {
const tips = rows.slice(0, -1);
if (far >= 0) {
sprite(behind, rows, cx + left, y, pal);
sprite(front, tips, cx + left, y, pal);
}
if (far <= 0) {
sprite(behind, rows, cx + right, y, pal, mirror);
sprite(front, tips, cx + right, y, pal, mirror);
}
};
switch (style) {
case 'catears':
pair(['X...', 'XX..', 'XPX.', 'XXXX'], -9, 5, 2, { X: recipe.hairColor, P: '#f4a3b5' });
return;
case 'bunnyears':
pair(['WW', 'WP', 'WP', 'WP', 'WW'], -6, 4, 0, { W: '#f4f1ec', P: '#f4a3b5' });
return;
case 'horseears':
pair(
['.X.', 'XPX', 'XPX', 'XXX'],
-9,
6,
1,
{ X: shade(recipe.hairColor, 0.9), P: '#f4a3b5' },
false,
);
return;
case 'horns':
pair(['.H', 'HH', 'Hh'], -8, 6, 1, { H: '#5b3a6e', h: '#7d5694' });
return;
case 'halo': {
const ring = (x: number, y: number) =>
ellipse(C + s, 1.6, 6.5, 1.8)(x, y) && !ellipse(C + s, 1.6, 4.4, 0.8)(x, y);
paint(behind, (x, y) => ring(x, y) && y < 2, GOLD);
paint(front, (x, y) => ring(x, y) && y >= 2, GOLD);
return;
}
case 'wings':
pair(['W..', 'WW.', 'WwW', '.ww'], -13, 10, 7, { W: '#f4f1ec', w: '#cfd6e2' });
return;
}
}

const POINTED_EARS: Partial<
Record<NonNullable<Recipe['species']>, { rows: string[]; rise: number }>
> = {
Expand Down Expand Up @@ -996,7 +1082,10 @@ function renderFigure(
: {
...builtin,
front: (x, y) =>
piece('bangs')(x, y) || piece('leftSideHair')(x, y) || piece('rightSideHair')(x, y),
piece('bangs')(x, y) ||
piece('leftSideHair')(x, y) ||
piece('rightSideHair')(x, y) ||
(builtin.strand?.(x, y) ?? false),
sides: [piece('leftSideHair'), piece('rightSideHair')],
back: piece('backHair'),
...(drawnHair.some((drawn) => drawn.slot === 'backHair') ? { ties: [] } : {}),
Expand Down Expand Up @@ -1052,6 +1141,9 @@ function renderFigure(
placeCells(back, inked('backHair'), false);
overlay(back, 'petals');
if (headpiece) placeCells(back, headpiece.back);
const wornHead = flower ? undefined : builtInHeadpiece(recipe);
const headFront = blank();
if (wornHead) drawHeadpiece(wornHead, recipe, back, headFront, s, turned ? d : 0);

const head = blank();
paint(head, face, skin);
Expand Down Expand Up @@ -1114,8 +1206,10 @@ function renderFigure(
const shadeHair = (g: Grid) => {
const out = g.map((r) => [...r]);
const cells = g.flatMap((row, y) => row.flatMap((c, x) => (c === hairColor ? [[x, y]] : [])));
const xs = cells.map(([x]) => x!);
const ys = cells.map(([, y]) => y!);
const framed = cells.filter(([x, y]) => !builtin.strand?.(x!, y!));
const bounds = framed.length ? framed : cells;
const xs = bounds.map(([x]) => x!);
const ys = bounds.map(([, y]) => y!);
const left = Math.min(...xs);
const right = Math.max(...xs);
const top = Math.min(...ys);
Expand Down Expand Up @@ -1155,7 +1249,7 @@ function renderFigure(
if (g[y - k]?.[x] === hairColor) out[y - k]![x] = shade(hairColor, 0.72);
const ring = mix(hairColor, WHITE, 0.45);
for (let x = Math.round(C + s) - 7; x <= Math.round(C + s) + 3; x++) {
const first = g.findIndex((row) => row[x] === hairColor);
const first = g.findIndex((row, y) => row[x] === hairColor && !builtin.strand?.(x, y));
if (first < 0) continue;
const y = first + 2 + Math.round(Math.abs(x + 0.5 - (C + s - 2)) / 4);
if (g[y]?.[x] === hairColor && x % 3 !== 0) out[y]![x] = ring;
Expand All @@ -1169,6 +1263,8 @@ function renderFigure(
const crown = blank();
placeCells(crown, inked('all-front'), false);
if (headpiece) placeCells(crown, headpiece.front);
for (let y = 0; y < N; y++)
for (let x = 0; x < N; x++) if (headFront[y]![x]) crown[y]![x] = headFront[y]![x];
const figure = blank();
for (const layer of [back, body, shadeHair(head), drawnOver, crown])
for (let y = 0; y < N; y++)
Expand Down Expand Up @@ -1321,22 +1417,63 @@ function renderFigure(
}

const full = clip(outline(figure));
AVATAR_PIECE_COLORS.forEach((key, i) => {
const color = recipe[key];
const mask = masks.sides?.[i];
if (color === undefined || !mask || color.toLowerCase() === hairColor.toLowerCase()) return;
const variants = new Map<string, Grid>();
const recolor = (color: string) => {
const cached = variants.get(color.toLowerCase());
if (cached) return cached;
const variant = blank();
const plain = { ...recipe, hairColor: color };
for (const k of AVATAR_PIECE_COLORS) delete plain[k];
variants.set(color.toLowerCase(), variant);
const plain: Recipe = { ...recipe, hairColor: color };
for (const k of [...AVATAR_PIECE_COLORS, ...AVATAR_PIECE_COLORS_V4]) delete plain[k];
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++)
for (let x = 0; x < N; x++)
if (full[y]![x] && near(x, y) && !eyes[y]![x] && !glasses[y]![x])
full[y]![x] = variant[y]![x];
});
return variant;
};
if (recipe.assetVersion === 4) {
const over = (x: number, y: number) =>
!!crown[y]?.[x] || !!drawnOver[y]?.[x] || !!eyes[y]?.[x] || !!glasses[y]?.[x];
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];
const owners: [string | undefined, Mask][] = [
[flower ? undefined : recipe.backHairColor, (x, y) => masks.back(x, y) && backAt(x, y)],
[
recipe.leftSideHairColor,
(x, y) => !!masks.sides?.[0](x, y) && (hairAt(x, y) || backAt(x, y)),
],
[
recipe.rightSideHairColor,
(x, y) => !!masks.sides?.[1](x, y) && (hairAt(x, y) || backAt(x, y)),
],
[
recipe.bangsColor,
(x, y) => (builtin.pieces ? piece('bangs')(x, y) : false) && hairAt(x, y),
],
[recipe.strandColor, (x, y) => (builtin.strand?.(x, y) ?? false) && hairAt(x, y)],
];
for (const [color, owns] of owners) {
if (color === undefined || color.toLowerCase() === hairColor.toLowerCase()) continue;
const variant = recolor(color);
const near = (x: number, y: number) =>
owns(x - 1, y) || owns(x + 1, y) || owns(x, y - 1) || owns(x, y + 1);
for (let y = 0; y < N; y++)
for (let x = 0; x < N; x++)
if (full[y]![x] && !over(x, y) && (owns(x, y) || (!figure[y]![x] && near(x, y))))
full[y]![x] = variant[y]![x];
}
} else
AVATAR_PIECE_COLORS.forEach((key, i) => {
const color = recipe[key];
const mask = masks.sides?.[i];
if (color === undefined || !mask || color.toLowerCase() === hairColor.toLowerCase()) return;
const variant = recolor(color);
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++)
for (let x = 0; x < N; x++)
if (full[y]![x] && near(x, y) && !eyes[y]![x] && !glasses[y]![x])
full[y]![x] = variant[y]![x];
});
const bodyCells = blank();
const headCells = blank();
for (let y = 0; y < N; y++)
Expand Down Expand Up @@ -1392,6 +1529,44 @@ export function hairPieceStart(
return { slot, front, back: [] };
}

function cellSource(recipe: PixelAvatarRecipe): (c: string) => readonly [PartCell[2], PartTone] {
return (c) => {
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];
};
}

/**
* The headpiece as a Custom Part to start drawing from: the drawn headpiece worn, or the
* built-in headpiece flattened for this recipe in the front pose, its base on the back layer and
* the rest on the front layer, so an unchanged copy renders identically facing front.
*/
export function headpieceStart(recipe: PixelAvatarRecipe): {
slot: 'headpiece';
front: PartCell[];
back: PartCell[];
} {
const worn = wornPart(recipe, 'headpiece');
if (worn) return { slot: 'headpiece', front: [...worn.front], back: [...worn.back] };
const style = builtInHeadpiece(recipe);
const behind = blank();
const over = blank();
if (style) drawHeadpiece(style, recipe, behind, over, 0, 0);
const source = cellSource(recipe);
const front: PartCell[] = [];
const back: PartCell[] = [];
for (let y = 0; y < 16; y++)
for (let x = 0; x < N; x++) {
const c = over[y]![x];
const b = behind[y]![x];
if (c) front.push([x, y, ...source(c)]);
if (b && b !== c) back.push([x, y, ...source(b)]);
}
return { slot: 'headpiece', 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
Expand All @@ -1417,12 +1592,7 @@ export function replacePartStart(
: '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 source = cellSource(recipe);
const front: PartCell[] = [];
for (let y = 0; y < N; y++)
for (let x = 0; x < N; x++) {
Expand Down
11 changes: 11 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,
headpieceStart,
replacePartStart,
partToneColor,
pixelFigure,
Expand All @@ -16,6 +17,9 @@ export {
AVATAR_PARTS,
AVATAR_PARTS_V2,
AVATAR_PIECE_COLORS,
AVATAR_PIECE_COLORS_V4,
AVATAR_STRANDS,
AVATAR_HEADPIECES,
AVATAR_PRESETS,
AVATAR_RANGES,
AVATAR_SPECIES,
Expand All @@ -42,6 +46,13 @@ export {
type PixelAvatarRecipeV1,
type PixelAvatarRecipeV2,
type PixelAvatarRecipeV3,
type PixelAvatarRecipeV4,
type AvatarPieceColorV4,
type AvatarStrand,
type AvatarHeadpiece,
withPieces,
withBuiltInHeadpiece,
builtInHeadpiece,
withHeadpiece,
withCustomPart,
wornPart,
Expand Down
Loading
Loading