대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
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
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -1,50 +1,154 @@
|
|||
/* =====================================================================
|
||||
ClientAvatar — 페르소나 / 정서 파라미터 타입 + 6파라미터 상태머신 테이블
|
||||
ClientAvatar — 페르소나 / 표정 파라미터 타입 + Live2D식 상태머신 테이블
|
||||
출처: 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 만 소비하므로 교체 표면이 좁다.
|
||||
── 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";
|
||||
|
||||
/* ── 내담자 정서 4종 (§4.5) ────────────────────────────────────────── */
|
||||
export type AvatarAffect = "neutral" | "depressed" | "anxious" | "resistant";
|
||||
/* ── 내담자 표정 프리셋: 모든 캐릭터가 최소 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;
|
||||
/** 헤어 (스타일 + 색) — 1차는 색만 사용 */
|
||||
hair: { style?: string; color: 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). 라포가 쌓이면 감소한다.
|
||||
* 미지정 시 affect="resistant" → 0.6, 그 외 0 으로 본다.
|
||||
* 미지정 시 defensive expression → 0.6, 그 외 0 으로 본다.
|
||||
*/
|
||||
resistance?: number;
|
||||
/** 해당 페르소나의 기본 정서 경향 */
|
||||
expressionBias?: AvatarExpression;
|
||||
}
|
||||
|
||||
/* ── 6파라미터 상태머신 (§4.5) ──────────────────────────────────────
|
||||
상태머신이 이 6개 숫자만 바꾸면 정서가 전환된다:
|
||||
(eyelidDrop, gazeAvert, shoulderTurn, breathRate, auraHue, blinkInterval)
|
||||
여기서는 모션 루프가 바로 쓰도록 호흡 주기/진폭, 깜빡임 min/max 까지 펼친다. */
|
||||
/* ── 표정 파라미터 상태머신 (§4.5) ──────────────────────────────────
|
||||
Live2D/Cubism 파라미터처럼 작은 수치들을 조합한다. 과장 금지:
|
||||
눈물/홍조/땀/만화식 효과 없이 "감지되되 단언되지 않는" 수준. */
|
||||
export interface AffectParams {
|
||||
/** 눈꺼풀 하강 px (우울 1~2px) */
|
||||
/** 눈꺼풀 하강 px (우울 1~2px, 놀람은 음수) */
|
||||
eyelidDrop: number;
|
||||
/** 시선 회피 x 오프셋 px (저항/사고 시) */
|
||||
/** 눈 세로 개방 배율 */
|
||||
eyeOpen: number;
|
||||
/** 동공/홍채 배율 */
|
||||
pupilScale: number;
|
||||
/** 시선 회피 x 오프셋 px */
|
||||
gazeAvert: number;
|
||||
/** 머리 기울기 deg */
|
||||
headTilt: number;
|
||||
/** 어깨 돌아선 각도 deg (저항 8° → 라포로 0°) */
|
||||
shoulderTurn: number;
|
||||
/** 호흡 주기 초 (= breathRate 역수 표현) */
|
||||
|
|
@ -59,70 +163,468 @@ export interface AffectParams {
|
|||
blinkMin: number;
|
||||
/** 깜빡임 최대 간격 초 */
|
||||
blinkMax: number;
|
||||
/** 입꼬리 각도 deg (양수=상향, 음수=하향). 미세값만. */
|
||||
/** 입꼬리 곡률 (양수=미소, 음수=처짐) */
|
||||
mouthCurve: number;
|
||||
/** 눈썹 안쪽 끝 기울기 px (우울=안쪽 살짝 올라/처짐) */
|
||||
/** 기본 입 열림 0~1. 발화 립싱크와 max 처리 */
|
||||
mouthOpen: number;
|
||||
/** 입 너비 px 보정 */
|
||||
mouthWidth: number;
|
||||
/** 입술 긴장도 0~1 */
|
||||
mouthTension: number;
|
||||
/** 눈썹 안쪽 끝 기울기 px (양수=안쪽 처짐, 음수=안쪽 올라감) */
|
||||
browTilt: number;
|
||||
/** 눈썹 전체 들어올림 px (양수=위로) */
|
||||
browLift: number;
|
||||
/** 눈썹 안쪽 모임 px */
|
||||
browPinch: number;
|
||||
}
|
||||
|
||||
/* ── 정서별 기준 파라미터 표 (§4.5) ──────────────────────────────────
|
||||
과장 절대 금지: 눈물/홍조/땀 없음. "감지되되 단언되지 않는" 수준. */
|
||||
const AFFECT_TABLE: Record<AvatarAffect, AffectParams> = {
|
||||
// 중립/라포 — 세이지 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,
|
||||
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,
|
||||
},
|
||||
// 우울(저각성·부정) — 청회색 5%, 느리고 얕게 5.5s
|
||||
depressed: {
|
||||
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,
|
||||
gazeAvert: 1.5, // 시선 아래(주로 y, x는 미세)
|
||||
eyeOpen: 0.88,
|
||||
gazeAvert: 1.5,
|
||||
headTilt: -1,
|
||||
shoulderTurn: 1,
|
||||
breathPeriod: 5.5,
|
||||
breathAmp: 1.5,
|
||||
auraHue: "#5B6B73", // 청회색
|
||||
auraHue: "#5B6B73",
|
||||
auraOpacity: 0.05,
|
||||
blinkMin: 5,
|
||||
blinkMax: 8,
|
||||
mouthCurve: -0.6, // 수평~미세 하향
|
||||
browTilt: 1.2, // 안쪽 끝 살짝 처짐
|
||||
mouthCurve: -1.2,
|
||||
browTilt: 1.2,
|
||||
browPinch: 0.5,
|
||||
},
|
||||
// 불안(고각성·부정) — 차가운 청 5% 빠른 펄스, 깜빡임↑, 빠르고 얕게 3s
|
||||
anxious: {
|
||||
eyelidDrop: 0,
|
||||
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", // 차가운 청
|
||||
auraHue: "#3B6E8F",
|
||||
auraOpacity: 0.05,
|
||||
blinkMin: 2,
|
||||
blinkMax: 4,
|
||||
mouthCurve: 0, // 다묾, 긴장
|
||||
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,
|
||||
},
|
||||
// 저항/방어 — 중립 회색 무펄스, 반쯤 돌아선 8°, 정지에 가깝게
|
||||
resistant: {
|
||||
eyelidDrop: 0.5,
|
||||
gazeAvert: 4, // 시선 회피(옆/아래 고정)
|
||||
shoulderTurn: 8, // ★ 교육적 핵심: 라포로 0° 까지 완화
|
||||
eyeOpen: 0.96,
|
||||
gazeAvert: 4,
|
||||
headTilt: 0.6,
|
||||
shoulderTurn: 8,
|
||||
breathPeriod: 5,
|
||||
breathAmp: 1,
|
||||
auraHue: "#93A09C", // --ink-3 중립 회색
|
||||
auraHue: "#93A09C",
|
||||
auraOpacity: 0.03,
|
||||
blinkMin: 4,
|
||||
blinkMax: 7,
|
||||
mouthCurve: -0.2, // 굳게 닫힘
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -147,47 +649,47 @@ export function ageLookFor(ageBand: AvatarAgeBand): AgeBandLook {
|
|||
return AGE_TABLE[ageBand];
|
||||
}
|
||||
|
||||
/* ── 정서 → 파라미터 해석 (라포 완화 + 상태 보정 포함) ──────────────────
|
||||
rapport 0~1: 세션 진행으로 쌓이는 라포. 저항 파라미터를 서서히 푼다.
|
||||
"라포가 쌓이면 shoulderTurn 8°→0°, gazeAvert→눈맞춤" 이게 이 도구의 교육적 핵심(§4.5). */
|
||||
/* ── 표정 → 파라미터 해석 (라포 완화 + 상태 보정 포함) ─────────────────
|
||||
rapport 0~1: 세션 진행으로 쌓이는 라포. 저항 파라미터를 서서히 푼다. */
|
||||
export function resolveAffectParams(
|
||||
affect: AvatarAffect,
|
||||
state: AvatarState,
|
||||
rapport: number,
|
||||
): AffectParams {
|
||||
// 표를 복사(불변)
|
||||
const out: AffectParams = { ...AFFECT_TABLE[affect] };
|
||||
const out: AffectParams = { ...DEFAULT_PARAMS, ...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";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 보정 (§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.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.breathAmp *= 0.6;
|
||||
out.headTilt += out.headTilt >= 0 ? 1.6 : -1.6;
|
||||
out.browLift += 0.3;
|
||||
break;
|
||||
case "listening":
|
||||
// 학습자 쪽 응시(회피 해제), 차분 펄스
|
||||
out.gazeAvert = out.gazeAvert * (1 - 0.6); // 응시로 당김(완전 0은 아님)
|
||||
out.gazeAvert = out.gazeAvert * 0.4;
|
||||
out.browLift += 0.25;
|
||||
break;
|
||||
case "speaking":
|
||||
// 정면 복귀
|
||||
out.gazeAvert = out.gazeAvert * 0.4;
|
||||
out.gazeAvert = out.gazeAvert * 0.35;
|
||||
out.mouthOpen = Math.max(out.mouthOpen, 0.02);
|
||||
break;
|
||||
case "idle":
|
||||
// 느린 호흡 3.5s 하한
|
||||
out.breathPeriod = Math.max(out.breathPeriod, 3.5);
|
||||
break;
|
||||
}
|
||||
|
|
@ -195,8 +697,9 @@ export function resolveAffectParams(
|
|||
return out;
|
||||
}
|
||||
|
||||
/** persona.resistance → 초기 저항도. 미지정 시 affect 로 추정. */
|
||||
/** persona.resistance → 초기 저항도. 미지정 시 defensive expression 으로 추정. */
|
||||
export function baseResistanceOf(persona: AvatarPersona, affect: AvatarAffect): number {
|
||||
if (typeof persona.resistance === "number") return persona.resistance;
|
||||
return affect === "resistant" ? 0.6 : 0;
|
||||
const expression = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === affect);
|
||||
return expression?.group === "defensive" ? 0.6 : 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue