feat: CharacterSprite SVG 컴포넌트, FloatingOverlay, useFloatingItems 훅 추가 (JSA-47)

- CharacterSprite: 원소별 카와이 치비 스타일 SVG 캐릭터 (Tier/파티클 대응)
- FloatingOverlay: 골드/아이템 획득 시 플로팅 텍스트 애니메이션
- useFloatingItems: 플로팅 아이템 상태 관리 훅

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-01 22:43:55 +09:00
parent eacc91b7da
commit 6e323680d5
3 changed files with 672 additions and 0 deletions

View File

@@ -0,0 +1,607 @@
/**
* CharacterSprite
*
* docs/character-design-guide.md 에 정의된 카와이 치비 스타일을 SVG로 구현.
* 각 원소의 색상·Tier·파티클 유형에 따라 고유한 캐릭터를 생성합니다.
*/
import { Fragment, useMemo, type ReactElement } from 'react';
import {
CHARACTER_VISUAL,
DEFAULT_VISUAL,
type EyeStyle,
type ParticleType,
type PatternType,
} from '../data/characterVisual';
// ─── 색상 유틸 ───────────────────────────────────────────────
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '');
return [
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16),
];
}
function rgbToHex(r: number, g: number, b: number): string {
return (
'#' +
[r, g, b]
.map((x) => Math.min(255, Math.max(0, Math.round(x))).toString(16).padStart(2, '0'))
.join('')
);
}
function lighten(hex: string, amt: number): string {
const [r, g, b] = hexToRgb(hex);
return rgbToHex(r + amt, g + amt, b + amt);
}
function darken(hex: string, amt: number): string {
const [r, g, b] = hexToRgb(hex);
return rgbToHex(r - amt, g - amt, b - amt);
}
// ─── 파티클 위치 ──────────────────────────────────────────────
interface ParticlePos {
angle: number; // 도 (degree)
r: number; // 궤도 반지름
dur: number; // 애니메이션 주기 (초)
delay: number; // 시작 딜레이 (초)
}
const PARTICLE_POSITIONS: Record<number, ParticlePos[]> = {
1: [
{ angle: -70, r: 42, dur: 2.0, delay: 0 },
{ angle: 50, r: 44, dur: 2.3, delay: 0.8 },
{ angle: 170, r: 41, dur: 1.9, delay: 1.5 },
],
2: [
{ angle: -60, r: 43, dur: 2.0, delay: 0 },
{ angle: 0, r: 44, dur: 2.3, delay: 0.4 },
{ angle: 60, r: 42, dur: 2.1, delay: 0.8 },
{ angle: 130, r: 44, dur: 2.4, delay: 1.2 },
{ angle: 200, r: 41, dur: 1.9, delay: 1.6 },
{ angle: 270, r: 43, dur: 2.2, delay: 2.0 },
],
3: [
{ angle: -80, r: 44, dur: 2.0, delay: 0 },
{ angle: -35, r: 46, dur: 2.3, delay: 0.3 },
{ angle: 10, r: 44, dur: 2.1, delay: 0.6 },
{ angle: 55, r: 46, dur: 2.4, delay: 0.9 },
{ angle: 100, r: 43, dur: 1.9, delay: 1.2 },
{ angle: 150, r: 45, dur: 2.2, delay: 1.5 },
{ angle: 200, r: 44, dur: 2.0, delay: 1.8 },
{ angle: 255, r: 46, dur: 2.3, delay: 2.1 },
],
4: [
{ angle: -80, r: 45, dur: 2.0, delay: 0 },
{ angle: -35, r: 47, dur: 2.2, delay: 0.25 },
{ angle: 10, r: 45, dur: 2.1, delay: 0.5 },
{ angle: 55, r: 47, dur: 2.3, delay: 0.75 },
{ angle: 100, r: 44, dur: 1.9, delay: 1.0 },
{ angle: 150, r: 46, dur: 2.1, delay: 1.25 },
{ angle: 200, r: 45, dur: 2.0, delay: 1.5 },
{ angle: 255, r: 47, dur: 2.2, delay: 1.75 },
],
5: [
{ angle: -80, r: 45, dur: 1.8, delay: 0 },
{ angle: -50, r: 47, dur: 2.0, delay: 0.2 },
{ angle: -20, r: 45, dur: 1.9, delay: 0.4 },
{ angle: 10, r: 47, dur: 2.1, delay: 0.6 },
{ angle: 40, r: 45, dur: 1.8, delay: 0.8 },
{ angle: 70, r: 47, dur: 2.0, delay: 1.0 },
{ angle: 100, r: 44, dur: 1.9, delay: 1.2 },
{ angle: 130, r: 47, dur: 2.1, delay: 1.4 },
{ angle: 160, r: 45, dur: 1.8, delay: 1.6 },
{ angle: 195, r: 47, dur: 2.0, delay: 1.8 },
{ angle: 230, r: 45, dur: 1.9, delay: 2.0 },
{ angle: 265, r: 47, dur: 2.1, delay: 2.2 },
],
};
function toRad(deg: number) {
return (deg * Math.PI) / 180;
}
// ─── 파티클 SVG 모양 ──────────────────────────────────────────
function renderParticleShape(type: ParticleType, color: string, size = 1): ReactElement {
const s = size;
switch (type) {
case 'flame':
return (
<polygon
points={`0,${-6 * s} ${-4 * s},${3 * s} ${4 * s},${3 * s}`}
fill={color}
opacity="0.85"
/>
);
case 'water':
return <ellipse rx={3 * s} ry={4 * s} fill={color} opacity="0.8" />;
case 'wind':
return (
<path
d={`M${-5 * s},0 C${-2 * s},${-3 * s} ${2 * s},${-3 * s} ${5 * s},0`}
fill="none"
stroke={color}
strokeWidth={1.5 * s}
strokeLinecap="round"
opacity="0.8"
/>
);
case 'earth':
return <circle r={3 * s} fill={color} opacity="0.75" />;
case 'crystal':
return (
<polygon
points={`0,${-5 * s} ${3.5 * s},0 0,${5 * s} ${-3.5 * s},0`}
fill={color}
opacity="0.8"
/>
);
case 'spark': {
const p = 5 * s;
return (
<path
d={`M0,${-p} L${2 * s},${-1 * s} L${1 * s},${-1 * s} L${3 * s},${p} L${-2 * s},0 L${-1 * s},0 Z`}
fill={color}
opacity="0.85"
/>
);
}
case 'star':
return (
<polygon
points={`0,${-5 * s} ${1.5 * s},${-1.8 * s} ${5 * s},${-1.6 * s} ${2.2 * s},${1.0 * s} ${3.1 * s},${4.8 * s} 0,${2.8 * s} ${-3.1 * s},${4.8 * s} ${-2.2 * s},${1.0 * s} ${-5 * s},${-1.6 * s} ${-1.5 * s},${-1.8 * s}`}
fill={color}
opacity="0.85"
/>
);
case 'smoke':
return <circle r={4 * s} fill={color} opacity="0.4" />;
case 'rainbow':
return (
<circle r={3 * s} fill={color} opacity="0.85" />
);
case 'leaf':
return (
<ellipse rx={4 * s} ry={2 * s} fill={color} opacity="0.8" transform="rotate(35)" />
);
default:
return <circle r={3 * s} fill={color} opacity="0.6" />;
}
}
function renderParticles(
particleType: ParticleType,
particleColors: string[],
tier: number,
cx: number,
cy: number,
): ReactElement[] {
const clampedTier = Math.min(tier, 5) as 1 | 2 | 3 | 4 | 5;
const positions = PARTICLE_POSITIONS[clampedTier] ?? PARTICLE_POSITIONS[1];
return positions.map((pos, i) => {
const rad = toRad(pos.angle);
const px = Number((cx + pos.r * Math.cos(rad)).toFixed(1));
const py = Number((cy + pos.r * Math.sin(rad)).toFixed(1));
const color = particleColors[i % particleColors.length];
const floatAmt = tier >= 4 ? 5 : 4;
const dx = Number((Math.cos(rad + Math.PI / 2) * floatAmt).toFixed(1));
const dy = Number((-Math.abs(Math.sin(rad)) * floatAmt - 2).toFixed(1));
// animateTransform on outer <g>, static position on inner <g>
// outer: animates translate(0,0) → translate(dx,dy) → translate(0,0)
// inner: static translate(px,py)
// net particle position: (px,py) ↔ (px+dx, py+dy)
return (
<g key={i}>
<animateTransform
attributeName="transform"
type="translate"
values={`0,0; ${dx},${dy}; 0,0`}
dur={`${pos.dur}s`}
begin={`${pos.delay}s`}
repeatCount="indefinite"
/>
<g transform={`translate(${px},${py})`}>
{renderParticleShape(particleType, color)}
</g>
</g>
);
});
}
// ─── 몸체 패턴 ───────────────────────────────────────────────
function renderBodyPattern(
patternType: PatternType,
patternColor: string,
cx: number,
cy: number,
): ReactElement | null {
const opacity = 0.22;
switch (patternType) {
case 'swirl':
return (
<path
d={`M${cx - 2},${cy - 6} C${cx - 2},${cy - 10} ${cx + 5},${cy - 10} ${cx + 6},${cy - 5} C${cx + 7},${cy} ${cx + 3},${cy + 5} ${cx - 1},${cy + 4}`}
fill="none"
stroke={patternColor}
strokeWidth="1.8"
opacity={opacity}
strokeLinecap="round"
/>
);
case 'wave':
return (
<Fragment>
<path
d={`M${cx - 10},${cy + 1} C${cx - 6},${cy - 2} ${cx - 2},${cy + 4} ${cx + 2},${cy + 1} C${cx + 6},${cy - 2} ${cx + 10},${cy + 4} ${cx + 14},${cy + 1}`}
fill="none"
stroke={patternColor}
strokeWidth="1.5"
opacity={opacity}
/>
<path
d={`M${cx - 10},${cy + 7} C${cx - 6},${cy + 4} ${cx - 2},${cy + 10} ${cx + 2},${cy + 7} C${cx + 6},${cy + 4} ${cx + 10},${cy + 10} ${cx + 14},${cy + 7}`}
fill="none"
stroke={patternColor}
strokeWidth="1.2"
opacity={opacity * 0.85}
/>
</Fragment>
);
case 'crack':
return (
<Fragment>
<path
d={`M${cx - 2},${cy - 8} L${cx},${cy - 1} L${cx - 4},${cy + 6}`}
fill="none"
stroke={patternColor}
strokeWidth="1.2"
opacity={opacity}
/>
<path
d={`M${cx + 4},${cy - 6} L${cx + 3},${cy + 1} L${cx + 6},${cy + 7}`}
fill="none"
stroke={patternColor}
strokeWidth="1.0"
opacity={opacity * 0.8}
/>
</Fragment>
);
case 'spiral':
return (
<path
d={`M${cx},${cy} C${cx},${cy - 5} ${cx + 5},${cy - 5} ${cx + 5},${cy} C${cx + 5},${cy + 5} ${cx - 5},${cy + 5} ${cx - 5},${cy} C${cx - 5},${cy - 7} ${cx + 2},${cy - 8} ${cx + 6},${cy - 4}`}
fill="none"
stroke={patternColor}
strokeWidth="1.5"
opacity={opacity}
strokeLinecap="round"
/>
);
case 'crystal':
return (
<Fragment>
<polygon
points={`${cx},${cy - 7} ${cx + 6},${cy} ${cx},${cy + 7} ${cx - 6},${cy}`}
fill="none"
stroke={patternColor}
strokeWidth="1.5"
opacity={opacity}
/>
<polygon
points={`${cx},${cy - 7} ${cx + 6},${cy} ${cx},${cy + 7} ${cx - 6},${cy}`}
fill={patternColor}
opacity={opacity * 0.5}
/>
</Fragment>
);
case 'none':
default:
return null;
}
}
// ─── 눈 렌더링 ────────────────────────────────────────────────
function renderEyes(
eyeStyle: EyeStyle,
cx: number,
cy: number,
bodyRy: number,
outlineColor: string,
): ReactElement {
const eyeY = cy - bodyRy * 0.18;
const lx = cx - 12;
const rx = cx + 12;
// 눈 스타일별 동공 오프셋 및 강조
const offsets: Record<EyeStyle, { lpo: [number, number]; rpo: [number, number]; hl: [number, number] }> = {
energetic: { lpo: [1, -1], rpo: [-1, -1], hl: [2, -1] },
calm: { lpo: [0, 1], rpo: [0, 1], hl: [2, -1] },
playful: { lpo: [0, -1], rpo: [0, 2], hl: [2, -1] },
steady: { lpo: [0, 0], rpo: [0, 0], hl: [2, -2] },
mysterious: { lpo: [-1, 0], rpo: [1, 0], hl: [1, -2] },
radiant: { lpo: [0, -1], rpo: [0, -1], hl: [2, -2] },
};
const { lpo, rpo, hl } = offsets[eyeStyle] ?? offsets.steady;
const eyeR = eyeStyle === 'radiant' ? 7.5 : 7;
const pupilR = eyeStyle === 'radiant' ? 4.5 : 4;
return (
<Fragment>
{/* 왼쪽 눈 */}
<circle cx={lx} cy={eyeY} r={eyeR} fill="white" />
<circle cx={lx + lpo[0]} cy={eyeY + lpo[1]} r={pupilR} fill="#1A1A1A" />
<circle cx={lx + hl[0]} cy={eyeY + hl[1]} r={2} fill="white" />
{/* 오른쪽 눈 */}
<circle cx={rx} cy={eyeY} r={eyeR} fill="white" />
<circle cx={rx + rpo[0]} cy={eyeY + rpo[1]} r={pupilR} fill="#1A1A1A" />
<circle cx={rx + hl[0]} cy={eyeY + hl[1]} r={2} fill="white" />
{/* radiant: 눈 주변 별 반짝임 */}
{eyeStyle === 'radiant' && (
<Fragment>
<path d={`M${lx - 10},${eyeY - 10} L${lx - 8},${eyeY - 8}`} stroke={outlineColor} strokeWidth="1" opacity="0.6" />
<path d={`M${lx - 11},${eyeY} L${lx - 9},${eyeY}`} stroke={outlineColor} strokeWidth="1" opacity="0.6" />
<path d={`M${rx + 8},${eyeY - 10} L${rx + 10},${eyeY - 8}`} stroke={outlineColor} strokeWidth="1" opacity="0.6" />
<path d={`M${rx + 9},${eyeY} L${rx + 11},${eyeY}`} stroke={outlineColor} strokeWidth="1" opacity="0.6" />
</Fragment>
)}
</Fragment>
);
}
// ─── Props 타입 ──────────────────────────────────────────────
export interface CharacterSpriteProps {
elementId: string;
elementColor: string;
tier: number;
/** 렌더 크기 (정사각형 px). 기본 120 */
size?: number;
/** obtained / locked / undiscovered */
state?: 'obtained' | 'locked' | 'undiscovered';
}
// ─── 메인 컴포넌트 ────────────────────────────────────────────
export function CharacterSprite({
elementId,
elementColor,
tier,
size = 120,
state = 'obtained',
}: CharacterSpriteProps) {
const config = CHARACTER_VISUAL[elementId] ?? DEFAULT_VISUAL;
const [bodyRx, bodyRy] = config.bodyShape ?? [33, 31];
const colors = useMemo(() => {
if (state === 'undiscovered') {
return {
body1: '#C8C8C8',
body2: '#A0A0A0',
body3: '#808080',
outline: '#606060',
pattern: '#888888',
glow: '#AAAAAA',
};
}
const isLocked = state === 'locked';
const base = isLocked ? '#9E9E9E' : elementColor;
return {
body1: lighten(base, 45),
body2: base,
body3: darken(base, 20),
outline: darken(base, 35),
pattern: darken(base, 15),
glow: isLocked ? '#CCCCCC' : config.glowColor,
};
}, [elementColor, state, config.glowColor]);
// locked/undiscovered는 간소화 렌더
const showDetails = state === 'obtained';
const showGlow = showDetails && tier >= 3;
const showRing = showDetails && tier >= 4;
const showRainbow = showDetails && tier >= 5;
const uid = `cs-${elementId}`;
const CX = 60;
const CY = 64;
return (
<svg
viewBox="0 0 120 120"
width={size}
height={size}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
<defs>
{/* 몸체 그라디언트 */}
<radialGradient id={`${uid}-grad`} cx="38%" cy="32%" r="65%">
<stop offset="0%" stopColor={colors.body1} />
<stop offset="65%" stopColor={colors.body2} />
<stop offset="100%" stopColor={colors.body3} />
</radialGradient>
{/* 글로우 필터 (T3+) */}
{showGlow && (
<filter id={`${uid}-glow`} x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation={tier >= 4 ? 3 : 2} result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
)}
{/* 레인보우 그라디언트 (T5) */}
{showRainbow && (
<linearGradient
id={`${uid}-rainbow`}
x1="0"
y1="0"
x2="120"
y2="0"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#FF0000" />
<stop offset="20%" stopColor="#FF9900" />
<stop offset="40%" stopColor="#FFFF00" />
<stop offset="60%" stopColor="#00FF00" />
<stop offset="80%" stopColor="#0066FF" />
<stop offset="100%" stopColor="#CC00FF" />
</linearGradient>
)}
</defs>
{/* ① 배경 글로우 헤일로 (T3+) */}
{showGlow && (
<ellipse
cx={CX}
cy={CY}
rx={bodyRx + 12}
ry={bodyRy + 12}
fill={colors.glow}
opacity={tier >= 5 ? 0.2 : tier >= 4 ? 0.15 : 0.1}
/>
)}
{/* ② 에너지 링 (T4) */}
{showRing && !showRainbow && (
<ellipse
cx={CX}
cy={CY}
rx={bodyRx + 14}
ry={bodyRy + 14}
fill="none"
stroke={elementColor}
strokeWidth="1.5"
opacity="0.4"
>
<animate
attributeName="rx"
values={`${bodyRx + 12};${bodyRx + 16};${bodyRx + 12}`}
dur="2.4s"
repeatCount="indefinite"
/>
<animate
attributeName="ry"
values={`${bodyRy + 12};${bodyRy + 16};${bodyRy + 12}`}
dur="2.4s"
repeatCount="indefinite"
/>
<animate attributeName="opacity" values="0.3;0.55;0.3" dur="2.4s" repeatCount="indefinite" />
</ellipse>
)}
{/* ③ 레인보우 링 (T5) */}
{showRainbow && (
<ellipse
cx={CX}
cy={CY}
rx={bodyRx + 14}
ry={bodyRy + 14}
fill="none"
stroke={`url(#${uid}-rainbow)`}
strokeWidth="2.5"
opacity="0.75"
>
<animateTransform
attributeName="transform"
type="rotate"
values={`0 ${CX} ${CY}; 360 ${CX} ${CY}`}
dur="4s"
repeatCount="indefinite"
/>
</ellipse>
)}
{/* ④ 파티클 (뒤편) */}
{showDetails &&
renderParticles(config.particleType, config.particleColors, tier, CX, CY)}
{/* ⑤ 몸체 */}
<ellipse
cx={CX}
cy={CY}
rx={bodyRx}
ry={bodyRy}
fill={`url(#${uid}-grad)`}
stroke={colors.outline}
strokeWidth={showGlow ? 2.5 : 2}
filter={showGlow ? `url(#${uid}-glow)` : undefined}
>
{/* idle 호버링 애니메이션 */}
{state === 'obtained' && (
<animateTransform
attributeName="transform"
type="translate"
values={`0,0; 0,-3; 0,0`}
dur="2.2s"
repeatCount="indefinite"
/>
)}
</ellipse>
{/* ⑥ 몸체 패턴 */}
{showDetails &&
renderBodyPattern(config.patternType, colors.pattern, CX, CY)}
{/* ⑦ 눈 */}
{state !== 'undiscovered' &&
renderEyes(
showDetails ? config.eyeStyle : 'steady',
CX,
CY,
bodyRy,
colors.outline,
)}
{/* ⑧ 잠금 눈 가리개 */}
{state === 'locked' && (
<path
d={`M${CX - 14},${CY - bodyRy * 0.18 - 2} Q${CX},${CY - bodyRy * 0.18 + 6} ${CX + 14},${CY - bodyRy * 0.18 - 2}`}
fill={darken(colors.body2, 25)}
opacity="0.5"
/>
)}
{/* ⑨ 미소 */}
{state !== 'undiscovered' && (
<path
d={`M${CX - 8},${CY + bodyRy * 0.22} Q${CX},${CY + bodyRy * 0.22 + 6} ${CX + 8},${CY + bodyRy * 0.22}`}
fill="none"
stroke={colors.outline}
strokeWidth="1.5"
strokeLinecap="round"
/>
)}
{/* ⑩ 미발견 ? */}
{state === 'undiscovered' && (
<text
x={CX}
y={CY + 6}
textAnchor="middle"
fontSize="22"
fill="#888888"
fontWeight="bold"
fontFamily="sans-serif"
>
?
</text>
)}
</svg>
);
}

View File

@@ -0,0 +1,35 @@
import { css, keyframes } from '@emotion/react';
import type { FloatingItem } from '../hooks/useFloatingItems';
const floatUp = keyframes`
0% { transform: translateY(0) scale(1); opacity: 1; }
70% { opacity: 1; }
100% { transform: translateY(-70px) scale(0.8); opacity: 0; }
`;
export function FloatingOverlay({ items }: { items: FloatingItem[] }) {
return (
<>
{items.map((item) => (
<span
key={item.id}
css={css`
position: fixed;
left: ${item.x}px;
top: ${item.y}px;
color: ${item.color};
font-size: ${item.fontSize ?? 13}px;
font-weight: 700;
pointer-events: none;
z-index: 500;
animation: ${floatUp} 1.2s ease-out forwards;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
`}
>
{item.text}
</span>
))}
</>
);
}

View File

@@ -0,0 +1,30 @@
import { useState, useCallback } from 'react';
export interface FloatingItem {
id: number;
text: string;
x: number;
y: number;
color: string;
fontSize?: number;
}
let _seq = 0;
export function useFloatingItems(durationMs = 1200) {
const [items, setItems] = useState<FloatingItem[]>([]);
const add = useCallback(
(partial: Omit<FloatingItem, 'id'>) => {
const id = _seq++;
setItems((prev) => [...prev, { id, ...partial }]);
setTimeout(
() => setItems((prev) => prev.filter((it) => it.id !== id)),
durationMs
);
},
[durationMs]
);
return { items, add };
}