/* ===================================================================== ClientAvatar — 페르소나 / 표정 파라미터 타입 + Live2D식 상태머신 테이블 출처: docs/DESIGN_CONCEPT.md §4.5(6파라미터 정서 상태머신) / §4.6(페르소나 시스템) ── Rive/Live2D 2차 교체 지점 ───────────────────────────────────── SVG 1차안은 아래 파라미터를 매 프레임 transform 으로 인코딩한다. Cubism/Rive 전환 시: 이 파일의 (state, expression, resistance, rapport) 입력 계약은 그대로 두고, AffectParams → 모델 파라미터 입력으로 wiring 한다. ===================================================================== */ /* ── 음성 UI 4-state (§4.2) ─────────────────────────────────────────── */ export type AvatarState = "idle" | "listening" | "thinking" | "speaking"; /* ── 내담자 표정 프리셋: 모든 캐릭터가 최소 20개 이상 사용 가능 ─────── */ export type AvatarExpression = | "neutral" | "calm" | "warm" | "joy" | "delight" | "relief" | "hopeful" | "sad" | "grief" | "lonely" | "anxious" | "panic" | "confused" | "conflicted" | "startled" | "embarrassed" | "ashamed" | "guilty" | "resistant" | "guarded" | "skeptical" | "irritated" | "angry" | "rage" | "tired" | "bored" | "overwhelmed" | "determined"; /* 기존 import 표면 호환용 이름. */ export type AvatarAffect = AvatarExpression; export interface AvatarExpressionMeta { id: AvatarExpression; label: string; group: "positive" | "negative" | "defensive" | "cognitive" | "energy"; } export const AVATAR_EXPRESSION_LIBRARY: readonly AvatarExpressionMeta[] = [ { id: "neutral", label: "중립", group: "cognitive" }, { id: "calm", label: "차분함", group: "positive" }, { id: "warm", label: "온화함", group: "positive" }, { id: "joy", label: "기쁨", group: "positive" }, { id: "delight", label: "반가움", group: "positive" }, { id: "relief", label: "안도", group: "positive" }, { id: "hopeful", label: "희망", group: "positive" }, { id: "sad", label: "슬픔", group: "negative" }, { id: "grief", label: "비탄", group: "negative" }, { id: "lonely", label: "외로움", group: "negative" }, { id: "anxious", label: "불안", group: "negative" }, { id: "panic", label: "공황", group: "negative" }, { id: "confused", label: "혼란", group: "cognitive" }, { id: "conflicted", label: "갈등", group: "cognitive" }, { id: "startled", label: "놀람", group: "negative" }, { id: "embarrassed", label: "당황", group: "negative" }, { id: "ashamed", label: "수치심", group: "negative" }, { id: "guilty", label: "죄책감", group: "negative" }, { id: "resistant", label: "저항", group: "defensive" }, { id: "guarded", label: "경계", group: "defensive" }, { id: "skeptical", label: "회의", group: "defensive" }, { id: "irritated", label: "짜증", group: "defensive" }, { id: "angry", label: "분노", group: "defensive" }, { id: "rage", label: "격노", group: "defensive" }, { id: "tired", label: "피곤", group: "energy" }, { id: "bored", label: "무심함", group: "energy" }, { id: "overwhelmed", label: "압도감", group: "negative" }, { id: "determined", label: "결심", group: "positive" }, ]; const EXPRESSION_LABELS: Record = Object.fromEntries( AVATAR_EXPRESSION_LIBRARY.map((expression) => [expression.id, expression.label]), ) as Record; export function expressionLabelFor(expression: AvatarExpression): string { return EXPRESSION_LABELS[expression]; } /* ── 연령대 (§4.6 외형 규칙) ───────────────────────────────────────── */ export type AvatarAgeBand = "teen" | "youngAdult" | "adult" | "senior"; export type AvatarHairStyle = | "short" | "bob" | "long-straight" | "soft-wave" | "messy" | "side-part"; export interface AvatarPersona { /** "서연 · 17세 · 고2" 같은 메타 1줄 라벨 */ label: string; /** DB persona code. 외형 시드와 테스트 식별에 사용 */ code?: string; /** 연령대 (외형 규칙 §4.6) */ ageBand: AvatarAgeBand; /** 피부톤 hex */ skinTone: string; /** 헤어 스타일/색 */ hair: { style?: AvatarHairStyle; color: string }; /** 의상/상반신 실루엣 색 */ outfitColor?: string; /** 눈동자 색 */ eyeColor?: string; /** 캐릭터 고유 보조색 */ accentColor?: string; /** * 래스터(비트맵) 아트셋 코드. 지정하면 SVG 파라미터 리그 대신 * `/avatar//.png` 레이어 합성(Live2D식)으로 렌더링한다. * 미지정 시 기존 SVG 리그. asset 은 imagegen(gpt-image-2)+BiRefNet 누끼로 생성. */ rasterArtSet?: string; /** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */ realism: number; /** * 저항도 0~1 (§4.6). 라포가 쌓이면 감소한다. * 미지정 시 defensive expression → 0.6, 그 외 0 으로 본다. */ resistance?: number; /** 해당 페르소나의 기본 정서 경향 */ expressionBias?: AvatarExpression; } /* ── 표정 파라미터 상태머신 (§4.5) ────────────────────────────────── Live2D/Cubism 파라미터처럼 작은 수치들을 조합한다. 과장 금지: 눈물/홍조/땀/만화식 효과 없이 "감지되되 단언되지 않는" 수준. */ export interface AffectParams { /** 눈꺼풀 하강 px (우울 1~2px, 놀람은 음수) */ eyelidDrop: number; /** 눈 세로 개방 배율 */ eyeOpen: number; /** 동공/홍채 배율 */ pupilScale: number; /** 시선 회피 x 오프셋 px */ gazeAvert: number; /** 머리 기울기 deg */ headTilt: number; /** 어깨 돌아선 각도 deg (저항 8° → 라포로 0°) */ shoulderTurn: number; /** 호흡 주기 초 (= breathRate 역수 표현) */ breathPeriod: number; /** 호흡 진폭 px (translateY 1.5~2.5) */ breathAmp: number; /** 광배 색조 (CSS 색/변수) */ auraHue: string; /** 광배 기본 opacity */ auraOpacity: number; /** 깜빡임 최소 간격 초 */ blinkMin: number; /** 깜빡임 최대 간격 초 */ blinkMax: number; /** 입꼬리 곡률 (양수=미소, 음수=처짐) */ mouthCurve: number; /** 기본 입 열림 0~1. 발화 립싱크와 max 처리 */ mouthOpen: number; /** 입 너비 px 보정 */ mouthWidth: number; /** 입술 긴장도 0~1 */ mouthTension: number; /** 눈썹 안쪽 끝 기울기 px (양수=안쪽 처짐, 음수=안쪽 올라감) */ browTilt: number; /** 눈썹 전체 들어올림 px (양수=위로) */ browLift: number; /** 눈썹 안쪽 모임 px */ browPinch: number; } const DEFAULT_PARAMS: AffectParams = { eyelidDrop: 0, eyeOpen: 1, pupilScale: 1, gazeAvert: 0, headTilt: 0, shoulderTurn: 0, breathPeriod: 4, breathAmp: 2, auraHue: "#3E7A6E", auraOpacity: 0.04, blinkMin: 4, blinkMax: 7, mouthCurve: 1.2, mouthOpen: 0, mouthWidth: 0, mouthTension: 0.2, browTilt: 0, browLift: 0, browPinch: 0, }; const AFFECT_TABLE: Record> = { neutral: {}, calm: { eyelidDrop: 0.4, eyeOpen: 0.96, breathPeriod: 4.8, breathAmp: 1.7, auraHue: "#4F837A", mouthCurve: 0.8, blinkMin: 4.8, blinkMax: 7.5, }, warm: { eyeOpen: 1.02, breathPeriod: 4.2, auraHue: "#6E8B82", auraOpacity: 0.055, mouthCurve: 2.2, mouthWidth: 1, browLift: 0.4, }, joy: { eyeOpen: 1.06, pupilScale: 1.04, breathPeriod: 3.7, breathAmp: 2.3, auraHue: "#5F968B", auraOpacity: 0.07, mouthCurve: 3.2, mouthOpen: 0.08, mouthWidth: 1.5, browLift: 0.9, }, delight: { eyeOpen: 1.12, pupilScale: 1.08, breathPeriod: 3.3, breathAmp: 2.4, auraHue: "#7A9464", auraOpacity: 0.075, blinkMin: 3.3, blinkMax: 5.5, mouthCurve: 3.8, mouthOpen: 0.16, mouthWidth: 2, browLift: 1.3, }, relief: { eyelidDrop: 0.7, eyeOpen: 0.94, headTilt: 1, breathPeriod: 5.2, breathAmp: 1.9, auraHue: "#6E8B82", auraOpacity: 0.06, mouthCurve: 1.7, browLift: 0.2, }, hopeful: { eyeOpen: 1.08, pupilScale: 1.04, gazeAvert: -0.8, breathPeriod: 4, auraHue: "#4F8C78", auraOpacity: 0.065, mouthCurve: 2.3, mouthWidth: 1, browLift: 1, }, sad: { eyelidDrop: 1.5, eyeOpen: 0.88, gazeAvert: 1.5, headTilt: -1, shoulderTurn: 1, breathPeriod: 5.5, breathAmp: 1.5, auraHue: "#5B6B73", auraOpacity: 0.05, blinkMin: 5, blinkMax: 8, mouthCurve: -1.2, browTilt: 1.2, browPinch: 0.5, }, grief: { eyelidDrop: 2.2, eyeOpen: 0.82, gazeAvert: 2, headTilt: -1.6, shoulderTurn: 1.5, breathPeriod: 6.2, breathAmp: 1.25, auraHue: "#56636C", auraOpacity: 0.055, blinkMin: 5.5, blinkMax: 8.5, mouthCurve: -2.2, mouthWidth: -1, browTilt: 1.8, browPinch: 1, }, lonely: { eyelidDrop: 1.4, eyeOpen: 0.9, gazeAvert: 2.8, headTilt: -2, shoulderTurn: 2.5, breathPeriod: 5.8, breathAmp: 1.35, auraHue: "#66727A", auraOpacity: 0.045, mouthCurve: -0.8, mouthWidth: -0.8, browTilt: 1, }, anxious: { eyeOpen: 1.1, pupilScale: 0.96, gazeAvert: 2, headTilt: 0.8, shoulderTurn: 2, breathPeriod: 3, breathAmp: 1.5, auraHue: "#3B6E8F", auraOpacity: 0.05, blinkMin: 2, blinkMax: 4, mouthCurve: -0.1, mouthTension: 0.6, browTilt: 0.4, browLift: 0.5, browPinch: 1, }, panic: { eyelidDrop: -0.8, eyeOpen: 1.24, pupilScale: 0.9, gazeAvert: 3, headTilt: 1.6, shoulderTurn: 3.5, breathPeriod: 2.35, breathAmp: 1.35, auraHue: "#2E5A7A", auraOpacity: 0.075, blinkMin: 1.6, blinkMax: 3, mouthCurve: -0.8, mouthOpen: 0.2, mouthWidth: 0.5, mouthTension: 0.85, browTilt: -0.8, browLift: 2.4, browPinch: 1.4, }, confused: { eyeOpen: 1.02, gazeAvert: 2.4, headTilt: 3, shoulderTurn: 1.5, breathPeriod: 4.2, auraHue: "#5B6B73", auraOpacity: 0.045, blinkMin: 4.5, blinkMax: 7, mouthCurve: -0.2, mouthOpen: 0.04, browTilt: -0.8, browLift: 0.8, browPinch: 0.8, }, conflicted: { eyelidDrop: 0.6, eyeOpen: 0.96, gazeAvert: 2.2, headTilt: -2.6, shoulderTurn: 2, breathPeriod: 4.8, breathAmp: 1.45, auraHue: "#776F85", auraOpacity: 0.045, mouthCurve: -0.4, mouthWidth: -0.4, mouthTension: 0.55, browTilt: 0.6, browPinch: 1.2, }, startled: { eyelidDrop: -1.2, eyeOpen: 1.22, pupilScale: 0.95, headTilt: 1.2, shoulderTurn: 1, breathPeriod: 2.8, breathAmp: 1.7, auraHue: "#8A6B42", auraOpacity: 0.07, blinkMin: 2.2, blinkMax: 4.2, mouthCurve: -0.2, mouthOpen: 0.24, browLift: 2.2, }, embarrassed: { eyelidDrop: 0.9, eyeOpen: 0.9, gazeAvert: 3.2, headTilt: 2.2, shoulderTurn: 2.4, breathPeriod: 3.4, auraHue: "#8B6F6B", auraOpacity: 0.05, blinkMin: 2.7, blinkMax: 4.6, mouthCurve: 0.6, mouthWidth: -0.5, browTilt: 0.5, browLift: -0.2, }, ashamed: { eyelidDrop: 1.8, eyeOpen: 0.82, gazeAvert: 4, headTilt: -2.8, shoulderTurn: 4, breathPeriod: 5.5, breathAmp: 1.2, auraHue: "#716873", auraOpacity: 0.04, mouthCurve: -1.5, mouthWidth: -1, mouthTension: 0.5, browTilt: 1.4, browPinch: 0.9, }, guilty: { eyelidDrop: 1.2, eyeOpen: 0.88, gazeAvert: 3.5, headTilt: -1.8, shoulderTurn: 3, breathPeriod: 5.2, breathAmp: 1.25, auraHue: "#6D7074", auraOpacity: 0.045, mouthCurve: -1.1, mouthWidth: -0.6, browTilt: 1, browPinch: 1.1, }, resistant: { eyelidDrop: 0.5, eyeOpen: 0.96, gazeAvert: 4, headTilt: 0.6, shoulderTurn: 8, breathPeriod: 5, breathAmp: 1, auraHue: "#93A09C", auraOpacity: 0.03, blinkMin: 4, blinkMax: 7, mouthCurve: -0.4, mouthWidth: -0.8, mouthTension: 0.65, browTilt: -0.4, browPinch: 0.6, }, guarded: { eyelidDrop: 0.3, eyeOpen: 0.92, gazeAvert: 3.4, headTilt: 1.5, shoulderTurn: 6, breathPeriod: 4.8, breathAmp: 1.1, auraHue: "#7D8784", auraOpacity: 0.035, mouthCurve: -0.3, mouthWidth: -1.2, mouthTension: 0.75, browTilt: -0.2, browPinch: 1, }, skeptical: { eyelidDrop: 0.6, eyeOpen: 0.9, gazeAvert: 1.8, headTilt: 2.8, shoulderTurn: 3, breathPeriod: 4.6, auraHue: "#7E8176", auraOpacity: 0.035, mouthCurve: -0.6, mouthWidth: -0.3, mouthTension: 0.7, browTilt: -1.2, browLift: -0.5, browPinch: 0.8, }, irritated: { eyelidDrop: 0.4, eyeOpen: 0.95, gazeAvert: 1.2, headTilt: -1.2, shoulderTurn: 3.5, breathPeriod: 3.6, breathAmp: 1.25, auraHue: "#8A5A00", auraOpacity: 0.055, blinkMin: 3, blinkMax: 5.5, mouthCurve: -0.9, mouthWidth: -0.5, mouthTension: 0.85, browTilt: -1.4, browPinch: 1.4, }, angry: { eyelidDrop: 0.1, eyeOpen: 1.0, pupilScale: 0.95, gazeAvert: 0.6, headTilt: -1, shoulderTurn: 4, breathPeriod: 3.1, breathAmp: 1.6, auraHue: "#9A5A3D", auraOpacity: 0.07, blinkMin: 3.2, blinkMax: 5, mouthCurve: -1.2, mouthOpen: 0.06, mouthWidth: 0.6, mouthTension: 0.9, browTilt: -2, browPinch: 1.8, }, rage: { eyelidDrop: -0.2, eyeOpen: 1.12, pupilScale: 0.9, gazeAvert: 0.2, headTilt: -1.4, shoulderTurn: 5, breathPeriod: 2.7, breathAmp: 1.9, auraHue: "#A8352C", auraOpacity: 0.09, blinkMin: 2.8, blinkMax: 4.5, mouthCurve: -1.8, mouthOpen: 0.22, mouthWidth: 1.2, mouthTension: 1, browTilt: -2.4, browPinch: 2, }, tired: { eyelidDrop: 2, eyeOpen: 0.78, gazeAvert: 1, headTilt: -1.8, shoulderTurn: 1, breathPeriod: 6, breathAmp: 1, auraHue: "#6F7777", auraOpacity: 0.035, blinkMin: 5.8, blinkMax: 9, mouthCurve: -0.7, mouthWidth: -0.8, browTilt: 0.3, browLift: -0.6, }, bored: { eyelidDrop: 1.6, eyeOpen: 0.82, gazeAvert: 2, headTilt: 2, shoulderTurn: 2, breathPeriod: 5.4, breathAmp: 1, auraHue: "#7B827C", auraOpacity: 0.03, blinkMin: 5, blinkMax: 8.5, mouthCurve: -0.2, mouthWidth: -1, mouthTension: 0.5, browLift: -0.8, }, overwhelmed: { eyelidDrop: 1, eyeOpen: 0.94, gazeAvert: 3, headTilt: -2.4, shoulderTurn: 3.5, breathPeriod: 3.2, breathAmp: 1.15, auraHue: "#6B5E7D", auraOpacity: 0.06, blinkMin: 2.4, blinkMax: 4, mouthCurve: -1.1, mouthOpen: 0.05, mouthTension: 0.65, browTilt: 1.1, browPinch: 1.5, }, determined: { eyeOpen: 1.03, gazeAvert: 0, headTilt: 0, shoulderTurn: 0.4, breathPeriod: 3.8, breathAmp: 1.8, auraHue: "#3E7A6E", auraOpacity: 0.06, mouthCurve: 0.4, mouthWidth: 0.2, mouthTension: 0.75, browTilt: -0.8, browPinch: 0.8, }, }; /* ── 연령대 외형 규칙 (§4.6) ────────────────────────────────────────── */ export interface AgeBandLook { /** 눈 크기 배율 */ eyeSize: number; /** 턱 폭 배율 (작을수록 좁은 턱) */ jawWidth: number; /** 광배 채도 가중(생기) */ auraSaturation: number; } const AGE_TABLE: Record = { teen: { eyeSize: 1.15, jawWidth: 0.92, auraSaturation: 1.1 }, youngAdult: { eyeSize: 1.0, jawWidth: 1.0, auraSaturation: 1.0 }, adult: { eyeSize: 0.95, jawWidth: 1.06, auraSaturation: 0.9 }, senior: { eyeSize: 0.92, jawWidth: 1.0, auraSaturation: 0.85 }, }; export function ageLookFor(ageBand: AvatarAgeBand): AgeBandLook { return AGE_TABLE[ageBand]; } /* ── 표정 → 파라미터 해석 (라포 완화 + 상태 보정 포함) ───────────────── rapport 0~1: 세션 진행으로 쌓이는 라포. 저항 파라미터를 서서히 푼다. */ export function resolveAffectParams( affect: AvatarAffect, state: AvatarState, rapport: number, ): AffectParams { const out: AffectParams = { ...DEFAULT_PARAMS, ...AFFECT_TABLE[affect] }; const r = Math.min(1, Math.max(0, rapport)); if (r > 0) { out.shoulderTurn = out.shoulderTurn * (1 - r); out.gazeAvert = out.gazeAvert * (1 - 0.85 * r); out.eyelidDrop = out.eyelidDrop * (1 - 0.35 * r); out.browPinch = out.browPinch * (1 - 0.45 * r); if (affect === "resistant" || affect === "guarded" || affect === "skeptical") { out.auraHue = r > 0.5 ? "#6E8B82" : out.auraHue; out.mouthCurve += r * 1.2; } } switch (state) { case "thinking": // 생각 중 표현을 또렷하게: 시선 회피(위/옆 응시) + 고개 기울임 + 사색 눈썹. out.gazeAvert = Math.max(out.gazeAvert, 3.5); out.blinkMin = out.blinkMin * 1.35; out.blinkMax = out.blinkMax * 1.35; out.auraOpacity *= 0.85; out.breathAmp *= 0.6; out.headTilt += out.headTilt >= 0 ? 1.6 : -1.6; out.browLift += 0.3; break; case "listening": out.gazeAvert = out.gazeAvert * 0.4; out.browLift += 0.25; break; case "speaking": out.gazeAvert = out.gazeAvert * 0.35; out.mouthOpen = Math.max(out.mouthOpen, 0.02); break; case "idle": out.breathPeriod = Math.max(out.breathPeriod, 3.5); break; } return out; } /** persona.resistance → 초기 저항도. 미지정 시 defensive expression 으로 추정. */ export function baseResistanceOf(persona: AvatarPersona, affect: AvatarAffect): number { if (typeof persona.resistance === "number") return persona.resistance; const expression = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === affect); return expression?.group === "defensive" ? 0.6 : 0; }