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.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.
- `@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.
Expand Down
1 change: 1 addition & 0 deletions packages/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc
- `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.
- `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.
- `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.7.0",
"version": "0.8.0",
"description": "Seeded, editable 32×32 chibi pixel avatars with state symbols that morph in and out",
"keywords": [
"agent-ui",
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 @@ -61,7 +61,13 @@ export {
createCustomPart,
customPartId,
emptyPartLayer,
ditherThreshold,
fillPartLayer,
gradientPartLayer,
noisePartLayer,
partLinePoints,
partRectPoints,
shadePartLayer,
isPixelCustomPart,
mirrorPartX,
paintPartLayer,
Expand Down
218 changes: 218 additions & 0 deletions packages/avatar/src/part.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { seededRandom } from './random.js';
import { AVATAR_COLORS, type AvatarColor } from './recipe.js';

/**
Expand Down Expand Up @@ -215,6 +216,223 @@ export function fillPartLayer(
return out;
}

/** Pixel-perfect line points (Bresenham). With `snap`, the end snaps to 0°, 45° or 90°. */
export function partLinePoints(
from: readonly [number, number],
to: readonly [number, number],
snap = false,
): [number, number][] {
let [x1, y1] = to;
const [x0, y0] = from;
if (snap) {
const dx = x1 - x0;
const dy = y1 - y0;
const ax = Math.abs(dx);
const ay = Math.abs(dy);
if (ax > ay * 2) y1 = y0;
else if (ay > ax * 2) x1 = x0;
else {
const m = Math.max(ax, ay);
x1 = x0 + Math.sign(dx) * m;
y1 = y0 + Math.sign(dy) * m;
}
}
const points: [number, number][] = [];
const dx = Math.abs(x1 - x0);
const dy = -Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let error = dx + dy;
let [x, y] = [x0, y0];
for (;;) {
points.push([x, y]);
if (x === x1 && y === y1) break;
const e2 = 2 * error;
if (e2 >= dy) {
error += dy;
x += sx;
}
if (e2 <= dx) {
error += dx;
y += sy;
}
}
return points;
}

/** Rectangle outline points between two corners. With `square`, the far corner makes a square. */
export function partRectPoints(
from: readonly [number, number],
to: readonly [number, number],
square = false,
): [number, number][] {
const [x0, y0] = from;
let [x1, y1] = to;
if (square) {
const m = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
x1 = x0 + (x1 < x0 ? -m : m);
y1 = y0 + (y1 < y0 ? -m : m);
}
const [left, right] = [Math.min(x0, x1), Math.max(x0, x1)];
const [top, bottom] = [Math.min(y0, y1), Math.max(y0, y1)];
const points = new Map<string, [number, number]>();
for (let x = left; x <= right; x++)
for (const y of [top, bottom]) points.set(`${x},${y}`, [x, y]);
for (let y = top; y <= bottom; y++)
for (const x of [left, right]) points.set(`${x},${y}`, [x, y]);
return [...points.values()];
}

const BAYER4 = [
[0, 8, 2, 10],
[12, 4, 14, 6],
[3, 11, 1, 9],
[15, 7, 13, 5],
] as const;
const BAYER2 = [
[0, 2],
[3, 1],
] as const;

/** The ordered-dither threshold in [0, 1) for a cell. */
export function ditherThreshold(x: number, y: number, size: 2 | 4 = 4): number {
return size === 4 ? (BAYER4[y & 3]![x & 3]! + 0.5) / 16 : (BAYER2[y & 1]![x & 1]! + 0.5) / 4;
}

/**
* A linear gradient over the 4-connected region of the start cell: each cell's position along
* `from`→`to` steps through the tones between `fromTone` and `toTone`, with ordered dithering
* between neighboring tones. No RGB is blended; the result is ordinary cells.
*/
export function gradientPartLayer(
slot: PartSlot,
layer: PartLayer,
from: readonly [number, number],
to: readonly [number, number],
color: PartColor,
fromTone: PartTone,
toTone: PartTone,
options: { dither?: 2 | 4; mirror?: boolean } = {},
): PartLayer {
const { width, height } = PART_SLOTS[slot];
const out = layer.map((row) => [...row]);
const [fx, fy] = from;
const region = (sx: number, sy: number): [number, number][] => {
if (sx < 0 || sx >= width || sy < 0 || sy >= height) return [];
const target = layer[sy]![sx]!;
const seen = new Set<number>();
const cells: [number, number][] = [];
const stack: [number, number][] = [[sx, sy]];
while (stack.length) {
const [x, y] = stack.pop()!;
if (x < 0 || x >= width || y < 0 || y >= height || seen.has(y * width + x)) continue;
if (!same(layer[y]![x]!, target)) continue;
seen.add(y * width + x);
cells.push([x, y]);
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
}
return cells;
};
const paint = (
cells: [number, number][],
start: readonly [number, number],
end: readonly [number, number],
ditherX: (x: number) => number,
) => {
const vx = end[0] - start[0];
const vy = end[1] - start[1];
const length = vx * vx + vy * vy;
const steps = toTone - fromTone;
for (const [x, y] of cells) {
const t =
length === 0
? 0
: Math.min(1, Math.max(0, ((x - start[0]) * vx + (y - start[1]) * vy) / length));
const position = t * Math.abs(steps);
const base = Math.floor(position);
const up = position - base > ditherThreshold(ditherX(x), y, options.dither ?? 4) ? 1 : 0;
const tone = (fromTone + Math.sign(steps) * Math.min(Math.abs(steps), base + up)) as PartTone;
out[y]![x] = { color, tone };
}
};
const own = region(fx, fy);
if (!options.mirror) {
paint(own, from, to, (x) => x);
return out;
}
const mx = mirrorPartX(slot, fx);
const mirroredCells = region(mx, fy);
const inOwn = new Set(own.map(([x, y]) => y * width + x));
const inMirrored = new Set(mirroredCells.map(([x, y]) => y * width + x));
const startSide = (x: number) => x < width / 2 === fx < width / 2;
paint(
own.filter(([x, y]) => startSide(x) || !inMirrored.has(y * width + x)),
from,
to,
(x) => x,
);
paint(
mirroredCells.filter(([x, y]) => !startSide(x) || !inOwn.has(y * width + x)),
[mx, fy],
[mirrorPartX(slot, to[0]), to[1]],
(x) => mirrorPartX(slot, x),
);
return out;
}

/**
* Moves the tone of colored cells by ±1 at random, for texture. The same seed gives the same
* result; the seed is used only now, and the result is ordinary cells. With `mirror`, each cell
* and its mirror get the same change.
*/
export function noisePartLayer(
slot: PartSlot,
layer: PartLayer,
amount: number,
seed: string,
mirror = false,
): PartLayer {
const random = seededRandom(seed);
const rolls = layer.map((row) => row.map(() => [random(), random() < 0.5 ? -1 : 1] as const));
return layer.map((row, y) =>
row.map((ink, x) => {
const source = mirror ? Math.min(x, mirrorPartX(slot, x)) : x;
const [roll, direction] = rolls[y]![source]!;
if (!ink || roll >= amount) return ink;
return {
color: ink.color,
tone: Math.max(-2, Math.min(2, ink.tone + direction)) as PartTone,
};
}),
);
}

/** Moves the tone of each colored cell on the points by `delta` once; empty cells are skipped. */
export function shadePartLayer(
slot: PartSlot,
layer: PartLayer,
points: readonly (readonly [number, number])[],
delta: 1 | -1,
mirror = false,
): PartLayer {
const { width, height } = PART_SLOTS[slot];
const out = layer.map((row) => [...row]);
const done = new Set<string>();
for (const [x, y] of points)
for (const px of mirror ? [x, mirrorPartX(slot, x)] : [x]) {
const key = `${px},${y}`;
if (done.has(key) || px < 0 || px >= width || y < 0 || y >= height) continue;
done.add(key);
const ink = layer[y]![px];
if (ink)
out[y]![px] = {
color: ink.color,
tone: Math.max(-2, Math.min(2, ink.tone + delta)) as PartTone,
};
}
return out;
}

const K = Uint32Array.from(
'428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 d807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da 983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2'
.split(' ')
Expand Down
Loading
Loading