import { Blobatar } from "@blobatar/react"; import { useGaze } from "@blobatar/react/gaze"; import { happy, idle, love, mad, sad, scared, shy, sick, sleepy, smug, surprised, thinking as thinkingPose, unsure, wink, type Expression, } from "blobatar/expression"; import { createContext, useContext, type CSSProperties, type ReactNode } from "react"; import type { AvatarShape, RoomMember } from "./types"; export const BLOBATAR_SHAPES = [ "round", "organic", "boxy", "capsule", "nub", "cloud", "droplet", "hexagon", "sun", "triangle", ] as const; export type BlobatarShape = (typeof BLOBATAR_SHAPES)[number]; export const BLOBATAR_EXPRESSIONS = [ "idle", "happy", "sad", "mad", "surprised", "wink", "sleepy", "smug", "unsure", "scared", "love", "shy", "sick", "thinking", ] as const; export type AvatarExpression = (typeof BLOBATAR_EXPRESSIONS)[number]; export const BLOBATAR_BACKGROUNDS = ["none", "circle", "squircle", "square"] as const; export type AvatarBackground = (typeof BLOBATAR_BACKGROUNDS)[number]; export interface AvatarLook { expression: AvatarExpression; background: AvatarBackground; } export const DEFAULT_LOOK: AvatarLook = { expression: "idle", background: "none" }; const EXPRESSION_POSE: Record = { idle, happy, sad, mad, surprised, wink, sleepy, smug, unsure, scared, love, shy, sick, thinking: thinkingPose, }; /** Midpoints of blobatar gen2 shape bands in styles/blob.ts. */ const SHAPE_TRAIT: Record = { round: 0.11, organic: 0.35, boxy: 0.54, capsule: 0.65, nub: 0.745, cloud: 0.825, droplet: 0.887, hexagon: 0.932, sun: 0.965, triangle: 0.99, }; const SHAPE_ALIAS: Record = { blob: "organic", squircle: "boxy", diamond: "boxy", drop: "droplet", cat: "nub", bunny: "nub", star: "sun", heart: "organic", egg: "round", ghost: "cloud", sprout: "nub", cactus: "nub", mushroom: "nub", paw: "nub", }; const FALLBACK_COLORS = ["#3ec5a8", "#f5a03c", "#6a6bf5", "#9b5cf6", "#3b82f6", "#d9508a"]; const LOOK_STORE = "lazyboy.avatarLook"; const AvatarLookContext = createContext>({}); export function AvatarLookProvider({ value, children, }: { value: Record; children: ReactNode; }) { return {children}; } export function readAvatarLooks(): Record { try { const raw = localStorage.getItem(LOOK_STORE); if (!raw) return {}; const parsed = JSON.parse(raw) as Record>; const out: Record = {}; for (const [id, look] of Object.entries(parsed || {})) { out[id] = { expression: isExpression(look.expression) ? look.expression : "idle", background: isBackground(look.background) ? look.background : "none", }; } return out; } catch { return {}; } } export function writeAvatarLook(id: string, look: AvatarLook) { const next = { ...readAvatarLooks(), [id]: look }; localStorage.setItem(LOOK_STORE, JSON.stringify(next)); } export function resolveBlobatarShape(shape: string | undefined | null): BlobatarShape { if (shape && (BLOBATAR_SHAPES as readonly string[]).includes(shape)) return shape as BlobatarShape; if (shape && SHAPE_ALIAS[shape]) return SHAPE_ALIAS[shape]; if (shape?.startsWith("organic-")) return "organic"; return "organic"; } /** Persist using names the existing API already accepts. */ const STORE_SHAPE: Record = { round: "round", organic: "blob", boxy: "squircle", capsule: "capsule", nub: "paw", cloud: "cloud", droplet: "drop", hexagon: "hexagon", sun: "star", triangle: "triangle", }; export function persistBlobatarShape(shape: string | undefined | null): AvatarShape { return STORE_SHAPE[resolveBlobatarShape(shape)]; } function isExpression(value: unknown): value is AvatarExpression { return typeof value === "string" && (BLOBATAR_EXPRESSIONS as readonly string[]).includes(value); } function isBackground(value: unknown): value is AvatarBackground { return typeof value === "string" && (BLOBATAR_BACKGROUNDS as readonly string[]).includes(value); } function fallbackColor(name: string): string { const hash = [...name].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7); return FALLBACK_COLORS[hash % FALLBACK_COLORS.length]; } /** Dark eyes on light bodies, light eyes on dark ones — same polarity blobatar uses. */ export function contrastEye(hex: string): string { const value = /^#([0-9a-f]{6})$/i.test(hex) ? hex : "#8B5CF6"; const n = parseInt(value.slice(1), 16); const channel = (shift: number) => { const s = ((n >> shift) & 255) / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; const luminance = 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0); return luminance >= 0.5 ? "#1b1b22" : "#f3f3f6"; } function blobBackground(value: AvatarBackground | undefined): false | "circle" | "squircle" | "square" { return !value || value === "none" ? false : value; } export function Avatar({ name, color, shape = "blob", active = false, thinking = false, online = false, size = 32, lookId, expression, background, gaze = true, }: { name: string; color?: string; shape?: AvatarShape; active?: boolean; thinking?: boolean; online?: boolean; size?: number; lookId?: string; expression?: AvatarExpression; background?: AvatarBackground; gaze?: boolean; }) { const stored = useContext(AvatarLookContext)[lookId || ""] || DEFAULT_LOOK; const resolved = color || fallbackColor(name); const silhouette = resolveBlobatarShape(shape); const poseName = thinking ? "thinking" : expression || stored.expression; const pose = poseName === "idle" ? undefined : EXPRESSION_POSE[poseName]; const plate = blobBackground(background || stored.background); const travel = Math.max(1.8, Math.min(4, size * 0.045)); const { ref } = useGaze({ travel, lookAt: gaze ? "pointer" : null }); return ( {(online || active) &&