/* ===================================================================== ClientAvatar — 가상 내담자 아바타 (반구상 페르소나 카드) 출처: docs/DESIGN_CONCEPT.md §4 전체 (4.1~4.7) "사실성 35~45% 양식화 반구상 흉상 + 호흡하는 광배." 학습자 시선이 30~50분 머무는 단 하나의 오브젝트 → 산만하지 않게(동시 채널 ≤2), 실존 인물로 오인되지 않게(코·주름·모공 없음, 상단 라벨 상시), 정서는 "감지되되 단언되지 않게". 기술(§4.7): SVG/CSS/Web Audio 기반 런타임. - 단일 rAF 루프 useAvatarMotion: 호흡 + 깜빡임 + 립싱크(RMS) + saccade - 6파라미터 정서 상태머신(persona.ts): eyelidDrop/gazeAvert/shoulderTurn/ breathRate/auraHue/blinkInterval. 라포 상승 시 저항 8°→0° 서서히(교육 핵심). - transform/opacity 만 애니메이션(layout 금지). 동시 움직임 ≤2 채널. - prefers-reduced-motion: 호흡/광배 정지 + 상태 텍스트. - analyser=null: 음성 분석이 없으면 립싱크 없이 상태 모션만 유지. ===================================================================== */ import { useEffect, useMemo, useState } from "react"; import { ageLookFor, baseResistanceOf, expressionLabelFor, resolveAffectParams, type AvatarAffect, type AvatarHairStyle, type AvatarPersona, type AvatarState, } from "./persona"; import { useAvatarMotion } from "./useAvatarMotion"; import { AuraLayer } from "./AuraLayer"; import { BodySilhouette } from "./BodySilhouette"; import { Eyes } from "./Eyes"; import { Brows } from "./Brows"; import { Mouth } from "./Mouth"; import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel"; import { useExpressionTransition } from "./useExpressionTransition"; import { RasterBust } from "./RasterBust"; import "./client-avatar.css"; /* ── 공개 타입 재노출 (기존 import 경로 호환) ────────────────────────── Session.tsx 등이 ClientAvatar 모듈에서 타입을 가져갈 수 있으므로 유지. */ export { AVATAR_EXPRESSION_LIBRARY, expressionLabelFor, type AvatarState, type AvatarAffect, type AvatarExpression, type AvatarPersona, } from "./persona"; export type { Live2DExpressionMotion, Live2DPersonaModel } from "./live2dModel"; export interface ClientAvatarProps { persona: AvatarPersona; state: AvatarState; affect: AvatarAffect; /** speaking 립싱크용 Web Audio AnalyserNode (없으면 텍스트/타이핑 폴백) */ analyser?: AnalyserNode | null; /** * 라포 진척 0~1 (§4.5 교육 핵심). 세션이 진행될수록 올린다. * 저항 페르소나의 shoulderTurn 8°→0°, gazeAvert→눈맞춤으로 서서히 완화. */ rapport?: number; /** * analyser 없을 때 선택적으로 전달하는 타이핑 진행도 0~1. * null/미지정이면 speaking 동안 차분한 의사 발화 모션. */ speakingProgress?: number | null; /** px 지름 (기본 220 — §5.2 아바타 220px) */ size?: number; className?: string; /** Disable rAF-driven breathing/blinking for dense QA grids. */ animated?: boolean; /** Hide the top teaching label when the avatar is used as a thumbnail. */ showCaption?: boolean; /** Hide persona/state metadata when the avatar is used as a thumbnail. */ showMeta?: boolean; } function prefersReducedMotion(): boolean { return ( typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true ); } /** prefers-reduced-motion 을 반응형으로 구독 */ function useReducedMotion(): boolean { const [reduced, setReduced] = useState(prefersReducedMotion); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) return; const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); const onChange = () => setReduced(mq.matches); mq.addEventListener?.("change", onChange); return () => mq.removeEventListener?.("change", onChange); }, []); return reduced; } const STATE_TEXT: Record = { idle: "대기 중", listening: "당신의 말을 듣는 중", thinking: "잠시 생각하는 중", speaking: "이야기하는 중", }; function HairBack({ style, color, jawWidth, }: { style: AvatarHairStyle; color: string; jawWidth: number; }) { switch (style) { case "long-straight": return ( ); case "soft-wave": return ( ); case "bob": return ( ); default: return null; } } function HairFront({ style, color }: { style: AvatarHairStyle; color: string }) { switch (style) { case "messy": return ( ); case "side-part": return ( ); case "bob": return ( ); case "long-straight": return ( ); case "soft-wave": return ( ); case "short": default: return ( ); } } function NeckBridge({ skin }: { skin: string }) { return ( ); } export function ClientAvatar({ persona, state, affect, analyser = null, rapport = 0, speakingProgress = null, size = 220, className, animated = true, showCaption = true, showMeta = true, }: ClientAvatarProps) { const reduced = useReducedMotion(); const motionEnabled = animated && !reduced; // 외형 안전값 const skin = persona.skinTone; const hairColor = persona.hair.color; const hairStyle = persona.hair.style ?? "short"; const outfitColor = persona.outfitColor ?? hairColor; const irisColor = persona.eyeColor ?? "#2B2B2B"; const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프 const useRaster = Boolean(persona.rasterArtSet); const age = ageLookFor(persona.ageBand); const expressionLabel = expressionLabelFor(affect); const live2dModel = useMemo(() => live2dModelForPersonaCode(persona.code), [persona.code]); const live2dMotion = useMemo( () => live2dMotionForExpression(live2dModel, affect), [affect, live2dModel], ); const live2dModelUrl = useMemo(() => live2dModel3Path(live2dModel), [live2dModel]); // 6파라미터 정서 → 라포/상태 반영 (라포는 baseResistance 와 합성) const baseRes = baseResistanceOf(persona, affect); // 라포가 저항을 푼다: 실효 라포 = rapport (저항 페르소나일 때 더 큰 시각 효과) const effectiveRapport = baseRes > 0 ? Math.min(1, rapport) : rapport * 0.4; const targetParams = useMemo( () => resolveAffectParams(affect, state, effectiveRapport), [affect, state, effectiveRapport], ); const transition = useExpressionTransition(targetParams, live2dMotion.fadeInMs, motionEnabled); const params = motionEnabled ? transition.params : targetParams; // 모션 루프 (reduced 면 정지) const frame = useAvatarMotion({ state, params, analyser, speakingProgress, enabled: motionEnabled, }); // reduced-motion: 정적 프레임(호흡 0, 눈 뜸, 입 닫힘) const breath = motionEnabled ? frame.breath : 0; const blink = motionEnabled ? frame.blink : 1; const mouth = motionEnabled ? frame.mouth : 0; const saccadeX = motionEnabled ? frame.saccadeX : 0; const saccadeY = motionEnabled ? frame.saccadeY : 0; // 시선 = 정서 회피(gazeAvert) + saccade 미세 이동 const gazeX = -params.gazeAvert + saccadeX; const gazeY = saccadeY; // 흉상 회전: 어깨 돌아선 각도(저항). 회전 중심 = 어깨 위. const shoulderRotate = params.shoulderTurn * 0.4; const mouthOpen = Math.max(mouth, params.mouthOpen); return (
{/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */} {showCaption ? (
) : null}
{/* 호흡하는 광배 */} {useRaster ? ( ) : ( )}
{/* 페르소나 메타 + 상태 텍스트 */} {showMeta ? (
{persona.label} 지금: {STATE_TEXT[state]} · {expressionLabel}
) : null}
); }