import { useMemo } from "react"; import type { AvatarExpression, AvatarState } from "./persona"; export type RasterVariant = "neutral" | "sad" | "tired" | "anxious" | "warm" | "startled"; const VARIANT_FOR_EXPRESSION: Record = { neutral: "neutral", calm: "neutral", bored: "neutral", determined: "neutral", warm: "warm", joy: "warm", delight: "warm", relief: "warm", hopeful: "warm", sad: "sad", grief: "sad", lonely: "sad", ashamed: "sad", guilty: "sad", overwhelmed: "sad", anxious: "anxious", confused: "anxious", conflicted: "anxious", embarrassed: "anxious", resistant: "anxious", guarded: "anxious", skeptical: "anxious", irritated: "anxious", angry: "anxious", rage: "anxious", startled: "startled", panic: "startled", tired: "tired", }; export function rasterVariantFor(expression: AvatarExpression): RasterVariant { return VARIANT_FOR_EXPRESSION[expression]; } export interface RasterBustProps { artSet: string; affect: AvatarExpression; state: AvatarState; breath: number; blink: number; mouth: number; gazeX: number; gazeY: number; headTilt: number; reduced: boolean; } const TX = "opacity 220ms cubic-bezier(0.22,0.61,0.36,1), transform 220ms cubic-bezier(0.22,0.61,0.36,1)"; function clamp01(value: number): number { return Math.min(1, Math.max(0, value)); } function layerTransform(extra = ""): string { return `translateX(-50%)${extra ? ` ${extra}` : ""}`; } function mouthPartFor(variant: RasterVariant): string { return variant === "startled" ? "mouth-startled" : `mouth-${variant}`; } function browPoseFor(variant: RasterVariant): { y: number; rotate: number } { switch (variant) { case "sad": return { y: 1.4, rotate: -0.35 }; case "tired": return { y: 1.8, rotate: -0.2 }; case "anxious": return { y: -1.1, rotate: 0.25 }; case "warm": return { y: -0.7, rotate: 0.12 }; case "startled": return { y: -3.2, rotate: 0.1 }; default: return { y: 0, rotate: 0 }; } } export function RasterBust({ artSet, affect, state, breath, blink, mouth, gazeX, gazeY, headTilt, reduced, }: RasterBustProps) { const variant = rasterVariantFor(affect); const speaking = state === "speaking" && !reduced; const blinkClosed = reduced ? 0 : clamp01((1 - blink) / 0.92); const speakOverlay = speaking ? clamp01(mouth) : 0; const browPose = browPoseFor(variant); const stackTransform = `translate(${gazeX.toFixed(2)}px, ${(gazeY - breath).toFixed( 2, )}px) rotate(${headTilt.toFixed(2)}deg)`; const part = useMemo( () => (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`, [artSet], ); const hairPhase = reduced ? 0 : breath; const hairLeftTransform = layerTransform( `translate(${(hairPhase * 0.36).toFixed(2)}px, ${(hairPhase * -0.05).toFixed( 2, )}px) rotate(${(hairPhase * -0.16).toFixed(2)}deg)`, ); const hairRightTransform = layerTransform( `translate(${(hairPhase * -0.32).toFixed(2)}px, ${(hairPhase * -0.04).toFixed( 2, )}px) rotate(${(hairPhase * 0.15).toFixed(2)}deg)`, ); const bangsTransform = layerTransform( `translate(${(hairPhase * 0.12).toFixed(2)}px, ${(hairPhase * 0.08).toFixed( 2, )}px) rotate(${(hairPhase * 0.08).toFixed(2)}deg)`, ); const eyesTransform = layerTransform( `translate(${(gazeX * 0.22).toFixed(2)}px, ${(gazeY * 0.16).toFixed(2)}px)`, ); const browTransform = layerTransform( `translate(${(gazeX * 0.08).toFixed(2)}px, ${(browPose.y + gazeY * 0.05).toFixed( 2, )}px) rotate(${browPose.rotate.toFixed(2)}deg)`, ); return ( ); }