// Avatar look: the shared vocabulary for the customization enums (SOCIAL_RESET // brief, Feature 1). Deliberately palette-only — every option is a bounded int, // so there is no free-form content and nothing to moderate. // // Used by BOTH the 3D peer avatars (src/net/Avatars.ts) and the Menu's 2D // preview (src/ui/Menu.ts), so the mirror always matches what peers see. export interface AvatarLook { hue: number; // 0..11 swatch index (0 = "auto": deterministic per-peer hue) gear: number; // 0..4 headgear face: number; // 0..3 face } export const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 } as const; export const GEAR_NAMES = ['phones', 'cap', 'beanie', 'crown', 'none'] as const; export const FACE_NAMES = ['dots', 'shades', 'visor', 'happy'] as const; export const HUE_NAMES = [ 'auto', 'red', 'orange', 'amber', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'violet', 'pink', ] as const; export const DEFAULT_LOOK: AvatarLook = { hue: 0, gear: 0, face: 0 }; /** Clamp anything to a valid look (defaults on garbage) — mirrors the relay. */ export function validLook(a: unknown): AvatarLook { const o = (a ?? {}) as Partial>; const pick = (v: unknown, n: number) => (Number.isInteger(v) && (v as number) >= 0 && (v as number) < n ? v as number : 0); return { hue: pick(o.hue, AVATAR_RANGES.hue), gear: pick(o.gear, AVATAR_RANGES.gear), face: pick(o.face, AVATAR_RANGES.face), }; } /** * Body colour as [h,s,l]. hue 0 = "auto" keeps the original golden-ratio * per-peer hue so untouched avatars look exactly as they did before. */ export function bodyHSL(look: AvatarLook, peerId: number): [number, number, number] { if (look.hue === 0) return [(peerId * 0.6180339887) % 1, 0.55, 0.55]; return [(look.hue - 1) / (AVATAR_RANGES.hue - 1), 0.6, 0.55]; } /** CSS colour for the same look — used by the Menu preview + who's-here swatches. */ export function bodyCss(look: AvatarLook, peerId: number): string { const [h, s, l] = bodyHSL(look, peerId); return `hsl(${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)`; } /** * Paint a face onto a canvas 2D context in a 0..1 normalized box. Shared by the * 3D head texture and the Menu preview so they can never drift apart. */ export function paintFace( ctx: CanvasRenderingContext2D, face: number, x: number, y: number, w: number, h: number, ): void { const px = (u: number, v: number, uw: number, vh: number) => ctx.fillRect(x + u * w, y + v * h, uw * w, vh * h); ctx.save(); switch (FACE_NAMES[face] ?? 'dots') { case 'dots': ctx.fillStyle = '#141414'; px(0.24, 0.38, 0.14, 0.16); px(0.62, 0.38, 0.14, 0.16); break; case 'shades': ctx.fillStyle = '#0d0d0f'; px(0.14, 0.36, 0.72, 0.2); // lens bar ctx.fillStyle = '#3d4657'; px(0.19, 0.4, 0.24, 0.1); px(0.57, 0.4, 0.24, 0.1); // glints break; case 'visor': ctx.fillStyle = '#123b2c'; px(0.1, 0.32, 0.8, 0.26); ctx.fillStyle = '#39d98a'; px(0.14, 0.36, 0.72, 0.06); // scan line break; case 'happy': ctx.fillStyle = '#141414'; px(0.24, 0.34, 0.13, 0.13); px(0.63, 0.34, 0.13, 0.13); px(0.28, 0.62, 0.44, 0.08); // smile px(0.24, 0.56, 0.06, 0.08); px(0.7, 0.56, 0.06, 0.08); break; } ctx.restore(); }