SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
705 lines
18 KiB
TypeScript
705 lines
18 KiB
TypeScript
/* =====================================================================
|
|
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<AvatarExpression, string> = Object.fromEntries(
|
|
AVATAR_EXPRESSION_LIBRARY.map((expression) => [expression.id, expression.label]),
|
|
) as Record<AvatarExpression, string>;
|
|
|
|
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/<artSet>/<variant>.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<AvatarExpression, Partial<AffectParams>> = {
|
|
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<AvatarAgeBand, AgeBandLook> = {
|
|
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;
|
|
}
|