From 6e323680d5c9b1e2a410bd16727ef6ef42844720 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 1 Apr 2026 22:43:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CharacterSprite=20SVG=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8,=20FloatingOverlay,=20useFloatingIt?= =?UTF-8?q?ems=20=ED=9B=85=20=EC=B6=94=EA=B0=80=20(JSA-47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterSprite: 원소별 카와이 치비 스타일 SVG 캐릭터 (Tier/파티클 대응) - FloatingOverlay: 골드/아이템 획득 시 플로팅 텍스트 애니메이션 - useFloatingItems: 플로팅 아이템 상태 관리 훅 Co-Authored-By: Paperclip --- src/components/CharacterSprite.tsx | 607 +++++++++++++++++++++++++++++ src/components/FloatingOverlay.tsx | 35 ++ src/hooks/useFloatingItems.ts | 30 ++ 3 files changed, 672 insertions(+) create mode 100644 src/components/CharacterSprite.tsx create mode 100644 src/components/FloatingOverlay.tsx create mode 100644 src/hooks/useFloatingItems.ts diff --git a/src/components/CharacterSprite.tsx b/src/components/CharacterSprite.tsx new file mode 100644 index 0000000..541f309 --- /dev/null +++ b/src/components/CharacterSprite.tsx @@ -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 = { + 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 ( + + ); + case 'water': + return ; + case 'wind': + return ( + + ); + case 'earth': + return ; + case 'crystal': + return ( + + ); + case 'spark': { + const p = 5 * s; + return ( + + ); + } + case 'star': + return ( + + ); + case 'smoke': + return ; + case 'rainbow': + return ( + + ); + case 'leaf': + return ( + + ); + default: + return ; + } +} + +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 , static position on inner + // 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 ( + + + + {renderParticleShape(particleType, color)} + + + ); + }); +} + +// ─── 몸체 패턴 ─────────────────────────────────────────────── + +function renderBodyPattern( + patternType: PatternType, + patternColor: string, + cx: number, + cy: number, +): ReactElement | null { + const opacity = 0.22; + switch (patternType) { + case 'swirl': + return ( + + ); + case 'wave': + return ( + + + + + ); + case 'crack': + return ( + + + + + ); + case 'spiral': + return ( + + ); + case 'crystal': + return ( + + + + + ); + 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 = { + 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 ( + + {/* 왼쪽 눈 */} + + + + + {/* 오른쪽 눈 */} + + + + + {/* radiant: 눈 주변 별 반짝임 */} + {eyeStyle === 'radiant' && ( + + + + + + + )} + + ); +} + +// ─── 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 ( + + + {/* 몸체 그라디언트 */} + + + + + + + {/* 글로우 필터 (T3+) */} + {showGlow && ( + + = 4 ? 3 : 2} result="blur" /> + + + + + + )} + + {/* 레인보우 그라디언트 (T5) */} + {showRainbow && ( + + + + + + + + + )} + + + {/* ① 배경 글로우 헤일로 (T3+) */} + {showGlow && ( + = 5 ? 0.2 : tier >= 4 ? 0.15 : 0.1} + /> + )} + + {/* ② 에너지 링 (T4) */} + {showRing && !showRainbow && ( + + + + + + )} + + {/* ③ 레인보우 링 (T5) */} + {showRainbow && ( + + + + )} + + {/* ④ 파티클 (뒤편) */} + {showDetails && + renderParticles(config.particleType, config.particleColors, tier, CX, CY)} + + {/* ⑤ 몸체 */} + + {/* idle 호버링 애니메이션 */} + {state === 'obtained' && ( + + )} + + + {/* ⑥ 몸체 패턴 */} + {showDetails && + renderBodyPattern(config.patternType, colors.pattern, CX, CY)} + + {/* ⑦ 눈 */} + {state !== 'undiscovered' && + renderEyes( + showDetails ? config.eyeStyle : 'steady', + CX, + CY, + bodyRy, + colors.outline, + )} + + {/* ⑧ 잠금 눈 가리개 */} + {state === 'locked' && ( + + )} + + {/* ⑨ 미소 */} + {state !== 'undiscovered' && ( + + )} + + {/* ⑩ 미발견 ? */} + {state === 'undiscovered' && ( + + ? + + )} + + ); +} diff --git a/src/components/FloatingOverlay.tsx b/src/components/FloatingOverlay.tsx new file mode 100644 index 0000000..fc7fea9 --- /dev/null +++ b/src/components/FloatingOverlay.tsx @@ -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) => ( + + {item.text} + + ))} + + ); +} diff --git a/src/hooks/useFloatingItems.ts b/src/hooks/useFloatingItems.ts new file mode 100644 index 0000000..41dc1e5 --- /dev/null +++ b/src/hooks/useFloatingItems.ts @@ -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([]); + + const add = useCallback( + (partial: Omit) => { + const id = _seq++; + setItems((prev) => [...prev, { id, ...partial }]); + setTimeout( + () => setItems((prev) => prev.filter((it) => it.id !== id)), + durationMs + ); + }, + [durationMs] + ); + + return { items, add }; +}