feat: CharacterSprite SVG 컴포넌트, FloatingOverlay, useFloatingItems 훅 추가 (JSA-47)
- CharacterSprite: 원소별 카와이 치비 스타일 SVG 캐릭터 (Tier/파티클 대응) - FloatingOverlay: 골드/아이템 획득 시 플로팅 텍스트 애니메이션 - useFloatingItems: 플로팅 아이템 상태 관리 훅 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
607
src/components/CharacterSprite.tsx
Normal file
607
src/components/CharacterSprite.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/FloatingOverlay.tsx
Normal file
35
src/components/FloatingOverlay.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
src/hooks/useFloatingItems.ts
Normal file
30
src/hooks/useFloatingItems.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user