대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -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;
}