Display
PixelAvatar
Seeded 8x8 pixel avatars: random creatures or ghosts, cats and bears that hop.
npx bezel-add add pixel-avatarpackages/ui/src/display/PixelAvatar.tsx · 426 lines
"use client";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
/**
* One cell of a creature. Also the alphabet of its text map:
* . empty, O outline, B body, L highlight, E eye, M mouth, K cheek.
*/
export type PixelCell = "." | "O" | "B" | "L" | "E" | "M" | "K";
export type PixelCreature = {
/** 8 rows of 8 cells, row 0 at the top. */
cells: PixelCell[][];
hasMouth: boolean;
};
export type PixelAvatarPalette = {
background: string;
outline: string;
body: string;
highlight: string;
eye: string;
/** Cheek colour for species with blush. Falls back to the body tone. */
blush?: string;
};
/**
* Ten 3-tone ramps, each with a pale frame, a near-black face and a cheek
* tone. Outline on frame clears 3:1 and the face clears 3:1 on the body in
* every one, so the silhouette and the eyes hold at 24px. "dmg" is the
* four-green Game Boy screen; "spirit" is the ghost's near-white.
*/
export const PIXEL_AVATAR_PALETTES = {
marigold: { background: "#FDF1DD", outline: "#B45309", body: "#F4A522", highlight: "#FBBF24", eye: "#0F172A", blush: "#F87171" },
jade: { background: "#E3F5EC", outline: "#047857", body: "#34D399", highlight: "#6EE7B7", eye: "#0F172A", blush: "#FDA4AF" },
lilac: { background: "#F1EAFE", outline: "#6D28D9", body: "#A78BFA", highlight: "#C4B5FD", eye: "#0F172A", blush: "#F9A8D4" },
rose: { background: "#FDE8EC", outline: "#BE123C", body: "#FB7185", highlight: "#FDA4AF", eye: "#0F172A", blush: "#F43F5E" },
sky: { background: "#E3F1FD", outline: "#1D4ED8", body: "#60A5FA", highlight: "#93C5FD", eye: "#0F172A", blush: "#F9A8D4" },
dmg: { background: "#E2F0C4", outline: "#306230", body: "#8BAC0F", highlight: "#9BBC0F", eye: "#0F380F", blush: "#306230" },
spirit: { background: "#EEF2FF", outline: "#4338CA", body: "#E0E7FF", highlight: "#F8FAFF", eye: "#1E1B4B", blush: "#F9A8D4" },
peach: { background: "#FFF1E6", outline: "#C2410C", body: "#FDBA74", highlight: "#FED7AA", eye: "#0F172A", blush: "#FB7185" },
mint: { background: "#E6FFFA", outline: "#0F766E", body: "#5EEAD4", highlight: "#99F6E4", eye: "#0F172A", blush: "#FDA4AF" },
mocha: { background: "#F5EBE0", outline: "#7C4A2D", body: "#B7825A", highlight: "#D6A77A", eye: "#1C0F08", blush: "#F2A0A0" },
} as const satisfies Record<string, PixelAvatarPalette>;
export type PixelAvatarPaletteName = keyof typeof PIXEL_AVATAR_PALETTES;
/**
* Hand-drawn silhouettes. Each is symmetric, stays inside the round mask and
* puts its eyes on row 3, columns 2 and 5, so blink, hop and glance work on
* every one. Shading, highlight, mouth and cheeks are worked out the same way
* as for the random creature.
*/
const SPECIES_SHAPES = {
ghost: ["..####..", ".######.", "########", "########", "########", "########", ".##..##.", "..#..#.."],
cat: ["..#..#..", ".##..##.", ".######.", "########", "########", ".######.", "..####..", "........"],
bunny: ["..#..#..", "..#..#..", ".######.", "########", "########", ".######.", "..####..", "........"],
frog: ["........", ".##..##.", "########", "########", "########", "########", ".######.", "........"],
bear: ["........", ".#....#.", ".######.", ".######.", "########", "########", ".######.", "........"],
slime: ["........", "...##...", "..####..", ".######.", "########", "########", ".######.", "........"],
robot: ["...##...", ".######.", ".######.", "########", "########", ".######.", ".######.", "........"],
heart: ["........", ".##..##.", "########", "########", "########", ".######.", "..####..", "...##..."],
octopus: ["..####..", ".######.", "########", "########", "########", ".######.", ".#.##.#.", "........"],
chick: ["...##...", "..####..", ".######.", ".######.", "########", "########", ".######.", "..#..#.."],
} as const;
export type PixelAvatarSpecies = "creature" | keyof typeof SPECIES_SHAPES;
/** Every species, the random creature first. */
export const PIXEL_AVATAR_SPECIES = ["creature", ...Object.keys(SPECIES_SHAPES)] as PixelAvatarSpecies[];
const SPECIES_PALETTE: Record<PixelAvatarSpecies, PixelAvatarPaletteName> = {
creature: "marigold",
ghost: "spirit",
cat: "peach",
bunny: "lilac",
frog: "jade",
bear: "mocha",
slime: "mint",
robot: "sky",
heart: "rose",
octopus: "lilac",
chick: "marigold",
};
const GRID = 8;
const EYES: ReadonlyArray<readonly [number, number]> = [[3, 2], [3, 5]];
const MOUTH: ReadonlyArray<readonly [number, number]> = [[5, 3], [5, 4]];
const CHEEKS: ReadonlyArray<readonly [number, number]> = [[4, 1], [4, 6]];
const BLINK_MS = 140;
/** xmur3: folds a string into a 32-bit seed. */
function hashSeed(seed: string) {
let h = 1779033703 ^ seed.length;
for (let i = 0; i < seed.length; i++) {
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^ (h >>> 16)) >>> 0;
}
/** mulberry32: small, fast, and the same sequence on server and client. */
function mulberry32(a: number) {
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// A cell may fill only if its centre sits within 4 cells of the grid centre,
// which empties the corners and keeps the creature inside a round frame.
const inCircle = (r: number, c: number) => Math.hypot(c + 0.5 - 4, r + 0.5 - 4) <= 4;
const inCore = (r: number, c: number) => r >= 2 && r <= 5 && c >= 1 && c <= 6;
const NEIGHBOURS: ReadonlyArray<readonly [number, number]> = [[-1, 0], [1, 0], [0, -1], [0, 1]];
/**
* The same seed always builds the same creature. "creature" grows a random
* mirrored body around a fixed core; the other species start from a drawn
* silhouette and let the seed decide the mouth.
*/
export function buildCreature(
seed: string,
species: PixelAvatarSpecies = "creature",
{ blush = species !== "creature" }: { blush?: boolean } = {},
): PixelCreature {
const rand = mulberry32(hashSeed(seed));
const filled = Array.from({ length: GRID }, () => Array<boolean>(GRID).fill(false));
if (species === "creature") {
// Decide the left half and mirror it, so the creature is symmetric.
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID / 2; c++) {
if (!inCircle(r, c)) continue;
const on = inCore(r, c) || rand() < 0.5;
filled[r][c] = on;
filled[r][GRID - 1 - c] = on;
}
}
} else {
SPECIES_SHAPES[species].forEach((row, r) => {
for (let c = 0; c < GRID; c++) filled[r][c] = row[c] === "#";
});
}
// Keep only cells joined to the core through an edge: no floating dust.
const joined = Array.from({ length: GRID }, () => Array<boolean>(GRID).fill(false));
const queue: Array<[number, number]> = [];
for (let r = 2; r <= 5; r++) {
for (let c = 1; c <= 6; c++) {
if (!filled[r][c]) continue;
joined[r][c] = true;
queue.push([r, c]);
}
}
while (queue.length) {
const [r, c] = queue.pop()!;
for (const [dr, dc] of NEIGHBOURS) {
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue;
if (filled[nr][nc] && !joined[nr][nc]) {
joined[nr][nc] = true;
queue.push([nr, nc]);
}
}
}
const hasMouth = rand() < 0.5;
const cells = joined.map((row, r) =>
row.map((on, c): PixelCell => {
if (!on) return ".";
const edge = NEIGHBOURS.some(([dr, dc]) => {
const nr = r + dr;
const nc = c + dc;
return nr < 0 || nr >= GRID || nc < 0 || nc >= GRID || !joined[nr][nc];
});
return edge ? "O" : "B";
}),
);
for (const [r, c] of EYES) cells[r][c] = "E";
if (hasMouth) for (const [r, c] of MOUTH) if (cells[r][c] !== ".") cells[r][c] = "M";
if (blush) for (const [r, c] of CHEEKS) if (cells[r][c] === "B") cells[r][c] = "K";
// Light from the top left: the first body cell in the left half, reading down.
highlight: for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID / 2; c++) {
if (cells[r][c] === "B") {
cells[r][c] = "L";
break highlight;
}
}
}
return { cells, hasMouth };
}
/** The creature as eight lines of `.OBLEMK`, for tests, logs and docs. */
export function creatureMap(creature: PixelCreature) {
return creature.cells.map((row) => row.join("")).join("\n");
}
type Pose = "rest" | "blink" | "hop" | "bob" | "look-left" | "look-right";
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
return reduced;
}
export type PixelAvatarProps = {
/** Any string: an email, a name, a user id. Same seed, same creature. */
seed: string;
/**
* "creature" is the random mirrored body; the rest are drawn silhouettes.
* "seed" picks one from the seed, so a list of people gets a mixed cast.
*/
species?: PixelAvatarSpecies | "seed";
/** Rosy cheek cells. On by default for every species except "creature". */
blush?: boolean;
/**
* Rendered size in px, rounded down to a multiple of 8 so every cell lands
* on whole pixels. 24, 32, 48, 96 and 128 all stay crisp.
*/
size?: number;
/**
* A named ramp, "seed" to pick one from the seed, or your own colours.
* Defaults to the species ramp: spirit for the ghost, mocha for the bear.
*/
palette?: PixelAvatarPaletteName | "seed" | PixelAvatarPalette;
/** The pale shape behind the sprite. */
frame?: "circle" | "square" | "none";
/** On pointer enter: "hop" blinks and jumps one cell, "blink" only blinks. */
hover?: "hop" | "blink" | "none";
/** A loop while nobody is pointing at it. Off under reduced motion. */
idle?: "none" | "blink" | "bob" | "glance";
/** Change this number to play the hover animation from outside, e.g. from a parent row's hover. */
playKey?: number;
/** Accessible name. Leave it out when a visible name sits next to the avatar. */
label?: string;
className?: string;
style?: CSSProperties;
};
/**
* A seeded 8×8 pixel creature. Mirrored, round, drawn as one 1×1 rect per cell
* with crisp edges, and animated in whole frames: nothing is tweened, so the
* sprite only ever sits on the pixel grid.
*/
export function PixelAvatar({
seed,
species = "creature",
blush,
size = 48,
palette,
frame = "circle",
hover = "hop",
idle = "none",
playKey,
label,
className,
style,
}: PixelAvatarProps) {
const kind: PixelAvatarSpecies =
species === "seed"
? PIXEL_AVATAR_SPECIES[hashSeed(`${seed}:species`) % PIXEL_AVATAR_SPECIES.length]
: species;
const creature = useMemo(
() => buildCreature(seed, kind, blush === undefined ? undefined : { blush }),
[seed, kind, blush],
);
const colours = useMemo<PixelAvatarPalette>(() => {
if (typeof palette === "object") return palette;
if (palette === "seed") {
const names = Object.keys(PIXEL_AVATAR_PALETTES) as PixelAvatarPaletteName[];
return PIXEL_AVATAR_PALETTES[names[hashSeed(`${seed}:palette`) % names.length]];
}
return PIXEL_AVATAR_PALETTES[palette ?? SPECIES_PALETTE[kind]];
}, [palette, seed, kind]);
const px = Math.max(GRID, Math.floor(size / GRID) * GRID);
const reduced = useReducedMotion();
const [pose, setPose] = useState<Pose>("rest");
const pointerInside = useRef(false);
const timers = useRef<number[]>([]);
const clearTimers = () => {
timers.current.forEach((id) => window.clearTimeout(id));
timers.current = [];
};
const later = (ms: number, next: Pose) => {
timers.current.push(window.setTimeout(() => setPose(next), ms));
};
const play = () => {
if (hover === "none") return;
clearTimers();
// Reduced motion keeps the blink and drops the jump.
setPose(hover === "hop" && !reduced ? "hop" : "blink");
later(BLINK_MS, "rest");
};
useEffect(() => clearTimers, []);
const firstKey = useRef(playKey);
useEffect(() => {
if (playKey === undefined || playKey === firstKey.current) return;
play();
// play reads the latest props; only a new key should trigger it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playKey]);
useEffect(() => {
if (idle === "none" || reduced) return;
const offset = hashSeed(`${seed}:idle`) % 900;
const period = idle === "bob" ? 1200 : idle === "glance" ? 4200 + offset : 2800 + offset;
const id = window.setInterval(() => {
if (document.visibilityState !== "visible" || pointerInside.current) return;
clearTimers();
if (idle === "blink") {
setPose("blink");
later(BLINK_MS, "rest");
} else if (idle === "bob") {
setPose("bob");
later(400, "rest");
} else {
setPose("look-left");
later(520, "look-right");
later(1040, "rest");
}
}, period);
return () => {
window.clearInterval(id);
clearTimers();
setPose("rest");
};
}, [idle, reduced, seed]);
const lifted = pose === "hop" || pose === "bob";
const eyesShut = pose === "blink" || pose === "hop";
const look = pose === "look-left" ? -1 : pose === "look-right" ? 1 : 0;
const tone = (cell: PixelCell) =>
cell === "O" ? colours.outline
: cell === "B" ? colours.body
: cell === "L" ? colours.highlight
: cell === "K" ? colours.blush ?? colours.body
: colours.eye;
const rects: JSX.Element[] = [];
creature.cells.forEach((row, r) =>
row.forEach((cell, c) => {
if (cell === ".") return;
// A glancing eye leaves body behind it and lands on the next cell over.
let fill = tone(cell);
if (cell === "E" && (eyesShut || look !== 0)) fill = eyesShut ? colours.outline : colours.body;
rects.push(<rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill={fill} />);
}),
);
if (look !== 0) {
for (const [r, c] of EYES) {
rects.push(<rect key={`look-${c}`} x={c + look} y={r} width={1} height={1} fill={colours.eye} />);
}
}
const radius = frame === "circle" ? "50%" : frame === "square" ? `${px / 4}px` : undefined;
return (
<span
className={className}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
onPointerEnter={() => {
pointerInside.current = true;
play();
}}
onPointerLeave={() => {
pointerInside.current = false;
}}
style={{
position: "relative",
display: "inline-block",
flexShrink: 0,
width: px,
height: px,
verticalAlign: "middle",
...style,
}}
>
{frame !== "none" ? (
<span
aria-hidden
style={{ position: "absolute", inset: 0, borderRadius: radius, background: colours.background }}
/>
) : null}
{/* overflow is visible so the one-cell hop clears the frame instead of being cut by it */}
<svg
width={px}
height={px}
viewBox="0 0 8 8"
shapeRendering="crispEdges"
aria-hidden
style={{ position: "relative", display: "block", overflow: "visible" }}
>
<g transform={lifted ? "translate(0 -1)" : undefined}>{rects}</g>
</svg>
</span>
);
}