/* ===================================================================== ClientAvatar — 페르소나 / 정서 파라미터 타입 + 6파라미터 상태머신 테이블 출처: docs/DESIGN_CONCEPT.md §4.5(6파라미터 정서 상태머신) / §4.6(페르소나 시스템) ── Rive 2차 교체 지점 ───────────────────────────────────────────── SVG 1차안은 아래 6파라미터를 매 프레임 transform 으로 인코딩한다. Rive 전환 시: 이 파일의 (state, affect, resistance, rapport) 입력 계약은 그대로 두고, AffectParams → .riv state-machine input 으로만 다시 wiring 한다. 컴포넌트(ClientAvatar/useAvatarMotion)는 AffectParams 만 소비하므로 교체 표면이 좁다. ===================================================================== */ /* ── 음성 UI 4-state (§4.2) ─────────────────────────────────────────── */ export type AvatarState = "idle" | "listening" | "thinking" | "speaking"; /* ── 내담자 정서 4종 (§4.5) ────────────────────────────────────────── */ export type AvatarAffect = "neutral" | "depressed" | "anxious" | "resistant"; /* ── 연령대 (§4.6 외형 규칙) ───────────────────────────────────────── */ export type AvatarAgeBand = "teen" | "youngAdult" | "adult" | "senior"; export interface AvatarPersona { /** "서연 · 17세 · 고2" 같은 메타 1줄 라벨 */ label: string; /** 연령대 (외형 규칙 §4.6) */ ageBand: AvatarAgeBand; /** 피부톤 hex */ skinTone: string; /** 헤어 (스타일 + 색) — 1차는 색만 사용 */ hair: { style?: string; color: string }; /** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */ realism: number; /** * 저항도 0~1 (§4.6). 라포가 쌓이면 감소한다. * 미지정 시 affect="resistant" → 0.6, 그 외 0 으로 본다. */ resistance?: number; } /* ── 6파라미터 상태머신 (§4.5) ────────────────────────────────────── 상태머신이 이 6개 숫자만 바꾸면 정서가 전환된다: (eyelidDrop, gazeAvert, shoulderTurn, breathRate, auraHue, blinkInterval) 여기서는 모션 루프가 바로 쓰도록 호흡 주기/진폭, 깜빡임 min/max 까지 펼친다. */ export interface AffectParams { /** 눈꺼풀 하강 px (우울 1~2px) */ eyelidDrop: number; /** 시선 회피 x 오프셋 px (저항/사고 시) */ gazeAvert: 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; /** 입꼬리 각도 deg (양수=상향, 음수=하향). 미세값만. */ mouthCurve: number; /** 눈썹 안쪽 끝 기울기 px (우울=안쪽 살짝 올라/처짐) */ browTilt: number; } /* ── 정서별 기준 파라미터 표 (§4.5) ────────────────────────────────── 과장 절대 금지: 눈물/홍조/땀 없음. "감지되되 단언되지 않는" 수준. */ const AFFECT_TABLE: Record = { // 중립/라포 — 세이지 4%, 안정 4s neutral: { eyelidDrop: 0, gazeAvert: 0, shoulderTurn: 0, breathPeriod: 4, breathAmp: 2, auraHue: "#3E7A6E", // --accent (세이지-틸) auraOpacity: 0.04, blinkMin: 4, blinkMax: 7, mouthCurve: 1.2, // 미세 상향 browTilt: 0, }, // 우울(저각성·부정) — 청회색 5%, 느리고 얕게 5.5s depressed: { eyelidDrop: 1.5, gazeAvert: 1.5, // 시선 아래(주로 y, x는 미세) shoulderTurn: 1, breathPeriod: 5.5, breathAmp: 1.5, auraHue: "#5B6B73", // 청회색 auraOpacity: 0.05, blinkMin: 5, blinkMax: 8, mouthCurve: -0.6, // 수평~미세 하향 browTilt: 1.2, // 안쪽 끝 살짝 처짐 }, // 불안(고각성·부정) — 차가운 청 5% 빠른 펄스, 깜빡임↑, 빠르고 얕게 3s anxious: { eyelidDrop: 0, gazeAvert: 2, shoulderTurn: 2, breathPeriod: 3, breathAmp: 1.5, auraHue: "#3B6E8F", // 차가운 청 auraOpacity: 0.05, blinkMin: 2, blinkMax: 4, mouthCurve: 0, // 다묾, 긴장 browTilt: 0.4, }, // 저항/방어 — 중립 회색 무펄스, 반쯤 돌아선 8°, 정지에 가깝게 resistant: { eyelidDrop: 0.5, gazeAvert: 4, // 시선 회피(옆/아래 고정) shoulderTurn: 8, // ★ 교육적 핵심: 라포로 0° 까지 완화 breathPeriod: 5, breathAmp: 1, auraHue: "#93A09C", // --ink-3 중립 회색 auraOpacity: 0.03, blinkMin: 4, blinkMax: 7, mouthCurve: -0.2, // 굳게 닫힘 browTilt: -0.4, }, }; /* ── 연령대 외형 규칙 (§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: 세션 진행으로 쌓이는 라포. 저항 파라미터를 서서히 푼다. "라포가 쌓이면 shoulderTurn 8°→0°, gazeAvert→눈맞춤" 이게 이 도구의 교육적 핵심(§4.5). */ export function resolveAffectParams( affect: AvatarAffect, state: AvatarState, rapport: number, ): AffectParams { // 표를 복사(불변) const out: AffectParams = { ...AFFECT_TABLE[affect] }; // 라포 완화 (0~1). 저항/회피 성격 파라미터를 비례 감쇠. const r = Math.min(1, Math.max(0, rapport)); if (r > 0) { out.shoulderTurn = out.shoulderTurn * (1 - r); // 8° → 0° out.gazeAvert = out.gazeAvert * (1 - 0.85 * r); // 회피 → 눈맞춤 out.eyelidDrop = out.eyelidDrop * (1 - 0.5 * r); // 라포가 쌓이면 광배가 중립 회색 → 세이지 쪽으로 살짝 온기 if (affect === "resistant" && r > 0.5) out.auraHue = "#6E8B82"; } // 상태 보정 (§4.2) switch (state) { case "thinking": // 시선 살짝 아래/옆, 깜빡임 느려짐, 광배 안쪽 수축(채도 -8%) out.gazeAvert = Math.max(out.gazeAvert, 2); out.blinkMin = out.blinkMin * 1.3; out.blinkMax = out.blinkMax * 1.3; out.auraOpacity *= 0.85; out.breathAmp *= 0.6; // 정지에 가깝게 break; case "listening": // 학습자 쪽 응시(회피 해제), 차분 펄스 out.gazeAvert = out.gazeAvert * (1 - 0.6); // 응시로 당김(완전 0은 아님) break; case "speaking": // 정면 복귀 out.gazeAvert = out.gazeAvert * 0.4; break; case "idle": // 느린 호흡 3.5s 하한 out.breathPeriod = Math.max(out.breathPeriod, 3.5); break; } return out; } /** persona.resistance → 초기 저항도. 미지정 시 affect 로 추정. */ export function baseResistanceOf(persona: AvatarPersona, affect: AvatarAffect): number { if (typeof persona.resistance === "number") return persona.resistance; return affect === "resistant" ? 0.6 : 0; }