400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
/* =====================================================================
|
|
ClientAvatar — 가상 내담자 아바타 (반구상 페르소나 카드)
|
|
출처: docs/DESIGN_CONCEPT.md §4 전체 (4.1~4.7)
|
|
|
|
"사실성 35~45% 양식화 반구상 흉상 + 호흡하는 광배." 학습자 시선이 30~50분
|
|
머무는 단 하나의 오브젝트 → 산만하지 않게(동시 채널 ≤2), 실존 인물로 오인되지
|
|
않게(코·주름·모공 없음, 상단 라벨 상시), 정서는 "감지되되 단언되지 않게".
|
|
|
|
기술(§4.7): SVG/CSS/Web Audio 기반 런타임.
|
|
- 단일 rAF 루프 useAvatarMotion: 호흡 + 깜빡임 + 립싱크(RMS) + saccade
|
|
- 6파라미터 정서 상태머신(persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
|
|
breathRate/auraHue/blinkInterval. 라포 상승 시 저항 8°→0° 서서히(교육 핵심).
|
|
- transform/opacity 만 애니메이션(layout 금지). 동시 움직임 ≤2 채널.
|
|
- prefers-reduced-motion: 호흡/광배 정지 + 상태 텍스트.
|
|
- analyser=null: 음성 분석이 없으면 립싱크 없이 상태 모션만 유지.
|
|
|
|
===================================================================== */
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
ageLookFor,
|
|
baseResistanceOf,
|
|
expressionLabelFor,
|
|
resolveAffectParams,
|
|
type AvatarAffect,
|
|
type AvatarHairStyle,
|
|
type AvatarPersona,
|
|
type AvatarState,
|
|
} from "./persona";
|
|
import { useAvatarMotion } from "./useAvatarMotion";
|
|
import { AuraLayer } from "./AuraLayer";
|
|
import { BodySilhouette } from "./BodySilhouette";
|
|
import { Eyes } from "./Eyes";
|
|
import { Brows } from "./Brows";
|
|
import { Mouth } from "./Mouth";
|
|
import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel";
|
|
import { useExpressionTransition } from "./useExpressionTransition";
|
|
import { RasterBust } from "./RasterBust";
|
|
import "./client-avatar.css";
|
|
|
|
/* ── 공개 타입 재노출 (기존 import 경로 호환) ──────────────────────────
|
|
Session.tsx 등이 ClientAvatar 모듈에서 타입을 가져갈 수 있으므로 유지. */
|
|
export {
|
|
AVATAR_EXPRESSION_LIBRARY,
|
|
expressionLabelFor,
|
|
type AvatarState,
|
|
type AvatarAffect,
|
|
type AvatarExpression,
|
|
type AvatarPersona,
|
|
} from "./persona";
|
|
export type { Live2DExpressionMotion, Live2DPersonaModel } from "./live2dModel";
|
|
|
|
export interface ClientAvatarProps {
|
|
persona: AvatarPersona;
|
|
state: AvatarState;
|
|
affect: AvatarAffect;
|
|
/** speaking 립싱크용 Web Audio AnalyserNode (없으면 텍스트/타이핑 폴백) */
|
|
analyser?: AnalyserNode | null;
|
|
/**
|
|
* 라포 진척 0~1 (§4.5 교육 핵심). 세션이 진행될수록 올린다.
|
|
* 저항 페르소나의 shoulderTurn 8°→0°, gazeAvert→눈맞춤으로 서서히 완화.
|
|
*/
|
|
rapport?: number;
|
|
/**
|
|
* analyser 없을 때 선택적으로 전달하는 타이핑 진행도 0~1.
|
|
* null/미지정이면 speaking 동안 차분한 의사 발화 모션.
|
|
*/
|
|
speakingProgress?: number | null;
|
|
/** px 지름 (기본 220 — §5.2 아바타 220px) */
|
|
size?: number;
|
|
className?: string;
|
|
/** Disable rAF-driven breathing/blinking for dense QA grids. */
|
|
animated?: boolean;
|
|
/** Hide the top teaching label when the avatar is used as a thumbnail. */
|
|
showCaption?: boolean;
|
|
/** Hide persona/state metadata when the avatar is used as a thumbnail. */
|
|
showMeta?: boolean;
|
|
}
|
|
|
|
function prefersReducedMotion(): boolean {
|
|
return (
|
|
typeof window !== "undefined" &&
|
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
|
|
);
|
|
}
|
|
/** prefers-reduced-motion 을 반응형으로 구독 */
|
|
function useReducedMotion(): boolean {
|
|
const [reduced, setReduced] = useState<boolean>(prefersReducedMotion);
|
|
useEffect(() => {
|
|
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
const onChange = () => setReduced(mq.matches);
|
|
mq.addEventListener?.("change", onChange);
|
|
return () => mq.removeEventListener?.("change", onChange);
|
|
}, []);
|
|
return reduced;
|
|
}
|
|
|
|
const STATE_TEXT: Record<AvatarState, string> = {
|
|
idle: "대기 중",
|
|
listening: "당신의 말을 듣는 중",
|
|
thinking: "잠시 생각하는 중",
|
|
speaking: "이야기하는 중",
|
|
};
|
|
|
|
function HairBack({
|
|
style,
|
|
color,
|
|
jawWidth,
|
|
}: {
|
|
style: AvatarHairStyle;
|
|
color: string;
|
|
jawWidth: number;
|
|
}) {
|
|
switch (style) {
|
|
case "long-straight":
|
|
return (
|
|
<path
|
|
d={`M${55 - (1 - jawWidth) * 5} 94 C54 60 75 42 100 42 C125 42 146 60 145 94 L139 146 C128 153 72 153 61 146 Z`}
|
|
fill={color}
|
|
opacity={0.96}
|
|
/>
|
|
);
|
|
case "soft-wave":
|
|
return (
|
|
<path
|
|
d="M57 96 C53 65 73 43 100 42 C128 42 146 64 143 98 C146 121 137 145 121 151 C116 140 84 140 78 151 C62 145 54 121 57 96 Z"
|
|
fill={color}
|
|
opacity={0.96}
|
|
/>
|
|
);
|
|
case "bob":
|
|
return (
|
|
<path
|
|
d="M58 97 C56 64 76 45 100 45 C124 45 144 64 142 97 C141 121 132 136 118 141 C111 134 88 134 81 141 C67 136 59 121 58 97 Z"
|
|
fill={color}
|
|
opacity={0.96}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function HairFront({ style, color }: { style: AvatarHairStyle; color: string }) {
|
|
switch (style) {
|
|
case "messy":
|
|
return (
|
|
<path
|
|
d="M58 91 C58 59 78 44 101 44 C123 44 142 59 142 91 C134 78 126 73 116 70 L112 62 L104 72 L94 62 L88 72 L77 70 C68 74 62 81 58 91 Z"
|
|
fill={color}
|
|
/>
|
|
);
|
|
case "side-part":
|
|
return (
|
|
<path
|
|
d="M58 92 C58 59 78 44 101 44 C124 44 143 59 142 92 C134 77 119 69 99 68 C85 70 69 77 58 92 Z M98 46 C94 56 92 64 91 73"
|
|
fill={color}
|
|
/>
|
|
);
|
|
case "bob":
|
|
return (
|
|
<path
|
|
d="M58 92 C58 61 78 47 100 47 C122 47 142 61 142 92 C133 80 124 73 112 71 C103 76 89 74 76 72 C67 77 61 83 58 92 Z"
|
|
fill={color}
|
|
/>
|
|
);
|
|
case "long-straight":
|
|
return (
|
|
<path
|
|
d="M58 92 C58 58 78 44 100 44 C122 44 142 58 142 92 C135 78 124 71 109 69 C99 75 83 72 70 73 C63 78 59 84 58 92 Z"
|
|
fill={color}
|
|
/>
|
|
);
|
|
case "soft-wave":
|
|
return (
|
|
<path
|
|
d="M57 92 C58 58 78 44 100 44 C122 44 143 59 143 92 C134 78 123 71 111 69 C105 76 94 76 88 70 C74 72 63 79 57 92 Z"
|
|
fill={color}
|
|
/>
|
|
);
|
|
case "short":
|
|
default:
|
|
return (
|
|
<path
|
|
d="M58 92 C58 58 78 44 100 44 C122 44 142 58 142 92 C142 78 128 70 100 70 C72 70 58 78 58 92 Z"
|
|
fill={color}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
function NeckBridge({ skin }: { skin: string }) {
|
|
return (
|
|
<g data-avatar-neck="true">
|
|
<path
|
|
d="M88 121 C90 119 110 119 112 121 L114 154 C109 160 91 160 86 154 Z"
|
|
fill={skin}
|
|
/>
|
|
<path
|
|
d="M89 149 C94 154 106 154 111 149"
|
|
fill="none"
|
|
stroke="#8A6B5D"
|
|
strokeLinecap="round"
|
|
strokeWidth="1.2"
|
|
opacity="0.18"
|
|
/>
|
|
</g>
|
|
);
|
|
}
|
|
|
|
export function ClientAvatar({
|
|
persona,
|
|
state,
|
|
affect,
|
|
analyser = null,
|
|
rapport = 0,
|
|
speakingProgress = null,
|
|
size = 220,
|
|
className,
|
|
animated = true,
|
|
showCaption = true,
|
|
showMeta = true,
|
|
}: ClientAvatarProps) {
|
|
const reduced = useReducedMotion();
|
|
const motionEnabled = animated && !reduced;
|
|
|
|
// 외형 안전값
|
|
const skin = persona.skinTone;
|
|
const hairColor = persona.hair.color;
|
|
const hairStyle = persona.hair.style ?? "short";
|
|
const outfitColor = persona.outfitColor ?? hairColor;
|
|
const irisColor = persona.eyeColor ?? "#2B2B2B";
|
|
const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프
|
|
const useRaster = Boolean(persona.rasterArtSet);
|
|
const age = ageLookFor(persona.ageBand);
|
|
const expressionLabel = expressionLabelFor(affect);
|
|
const live2dModel = useMemo(() => live2dModelForPersonaCode(persona.code), [persona.code]);
|
|
const live2dMotion = useMemo(
|
|
() => live2dMotionForExpression(live2dModel, affect),
|
|
[affect, live2dModel],
|
|
);
|
|
const live2dModelUrl = useMemo(() => live2dModel3Path(live2dModel), [live2dModel]);
|
|
|
|
// 6파라미터 정서 → 라포/상태 반영 (라포는 baseResistance 와 합성)
|
|
const baseRes = baseResistanceOf(persona, affect);
|
|
// 라포가 저항을 푼다: 실효 라포 = rapport (저항 페르소나일 때 더 큰 시각 효과)
|
|
const effectiveRapport = baseRes > 0 ? Math.min(1, rapport) : rapport * 0.4;
|
|
const targetParams = useMemo(
|
|
() => resolveAffectParams(affect, state, effectiveRapport),
|
|
[affect, state, effectiveRapport],
|
|
);
|
|
const transition = useExpressionTransition(targetParams, live2dMotion.fadeInMs, motionEnabled);
|
|
const params = motionEnabled ? transition.params : targetParams;
|
|
|
|
// 모션 루프 (reduced 면 정지)
|
|
const frame = useAvatarMotion({
|
|
state,
|
|
params,
|
|
analyser,
|
|
speakingProgress,
|
|
enabled: motionEnabled,
|
|
});
|
|
|
|
// reduced-motion: 정적 프레임(호흡 0, 눈 뜸, 입 닫힘)
|
|
const breath = motionEnabled ? frame.breath : 0;
|
|
const blink = motionEnabled ? frame.blink : 1;
|
|
const mouth = motionEnabled ? frame.mouth : 0;
|
|
const saccadeX = motionEnabled ? frame.saccadeX : 0;
|
|
const saccadeY = motionEnabled ? frame.saccadeY : 0;
|
|
|
|
// 시선 = 정서 회피(gazeAvert) + saccade 미세 이동
|
|
const gazeX = -params.gazeAvert + saccadeX;
|
|
const gazeY = saccadeY;
|
|
|
|
// 흉상 회전: 어깨 돌아선 각도(저항). 회전 중심 = 어깨 위.
|
|
const shoulderRotate = params.shoulderTurn * 0.4;
|
|
const mouthOpen = Math.max(mouth, params.mouthOpen);
|
|
|
|
return (
|
|
<figure
|
|
className={"vg-avatar" + (className ? " " + className : "")}
|
|
style={{ width: size }}
|
|
data-state={state}
|
|
data-affect={affect}
|
|
data-expression-count={live2dModel.expressions.length}
|
|
data-live2d-schema={live2dModel.schemaVersion}
|
|
data-live2d-model={live2dModel.modelId}
|
|
data-live2d-model-url={live2dModelUrl}
|
|
data-live2d-motion={live2dMotion.name}
|
|
data-live2d-motion-file={live2dMotion.file}
|
|
data-live2d-fade-in-ms={live2dMotion.fadeInMs}
|
|
data-live2d-transition-active={transition.active ? "true" : "false"}
|
|
data-live2d-transition-progress={transition.progress.toFixed(2)}
|
|
data-live2d-expression-count={live2dModel.expressions.length}
|
|
data-persona-code={persona.code ?? ""}
|
|
data-avatar-animated={motionEnabled ? "true" : "false"}
|
|
data-render-mode={useRaster ? "raster" : "svg"}
|
|
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
|
|
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}, ${expressionLabel}`}
|
|
>
|
|
|
|
{/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */}
|
|
{showCaption ? (
|
|
<figcaption className="vg-avatar__label">
|
|
<span className="vg-avatar__label-dot" aria-hidden="true" />
|
|
교육용 가상 내담자
|
|
</figcaption>
|
|
) : null}
|
|
|
|
<div className="vg-avatar__stage" style={{ height: size }}>
|
|
{/* 호흡하는 광배 */}
|
|
<AuraLayer
|
|
hue={params.auraHue}
|
|
opacity={params.auraOpacity}
|
|
saturation={age.auraSaturation}
|
|
state={state}
|
|
mouth={mouth}
|
|
reduced={reduced}
|
|
/>
|
|
|
|
{useRaster ? (
|
|
<RasterBust
|
|
artSet={persona.rasterArtSet as string}
|
|
affect={affect}
|
|
state={state}
|
|
breath={breath}
|
|
blink={blink}
|
|
mouth={mouthOpen}
|
|
gazeX={gazeX}
|
|
gazeY={gazeY}
|
|
headTilt={params.headTilt}
|
|
reduced={reduced}
|
|
/>
|
|
) : (
|
|
<svg
|
|
className="vg-avatar__svg"
|
|
viewBox="0 0 200 200"
|
|
width={size}
|
|
height={size}
|
|
role="img"
|
|
aria-hidden="true"
|
|
>
|
|
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
|
|
<g transform={`translate(0 ${-breath}) rotate(${shoulderRotate} 100 150)`}>
|
|
{/* 어깨/상반신 실루엣 */}
|
|
<BodySilhouette color={outfitColor} shoulderTurn={params.shoulderTurn} />
|
|
|
|
<g transform={`rotate(${params.headTilt} 100 101)`}>
|
|
<HairBack style={hairStyle} color={hairColor} jawWidth={age.jawWidth} />
|
|
<NeckBridge skin={skin} />
|
|
|
|
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
|
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
|
|
|
<HairFront style={hairStyle} color={hairColor} />
|
|
|
|
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
|
|
<g transform={`translate(${gazeX} ${gazeY})`}>
|
|
<Brows
|
|
browTilt={params.browTilt}
|
|
browLift={params.browLift}
|
|
browPinch={params.browPinch}
|
|
color={hairColor}
|
|
/>
|
|
<Eyes
|
|
eyeSize={age.eyeSize}
|
|
blink={blink}
|
|
eyeOpen={params.eyeOpen}
|
|
eyelidDrop={params.eyelidDrop}
|
|
pupilScale={params.pupilScale}
|
|
irisColor={irisColor}
|
|
/>
|
|
<Mouth
|
|
open={mouthOpen}
|
|
curve={params.mouthCurve}
|
|
width={params.mouthWidth}
|
|
tension={params.mouthTension}
|
|
color="#9B5B52"
|
|
/>
|
|
</g>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
)}
|
|
|
|
</div>
|
|
|
|
{/* 페르소나 메타 + 상태 텍스트 */}
|
|
{showMeta ? (
|
|
<div className="vg-avatar__meta">
|
|
<span className="vg-avatar__persona">{persona.label}</span>
|
|
<span className="vg-avatar__state">
|
|
지금: <b>{STATE_TEXT[state]}</b> · {expressionLabel}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</figure>
|
|
);
|
|
}
|