diff --git a/src/components/tutorial/TutorialOverlay.tsx b/src/components/tutorial/TutorialOverlay.tsx new file mode 100644 index 0000000..4c4d9e1 --- /dev/null +++ b/src/components/tutorial/TutorialOverlay.tsx @@ -0,0 +1,223 @@ +import { css } from '@emotion/react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useGameStore } from '../../store/useGameStore'; +import { TutorialProgressBar } from './TutorialProgressBar'; +import { TutorialTooltip } from './TutorialTooltip'; +import { TutorialWelcomeCard } from './TutorialWelcomeCard'; + +// 각 스텝별 data-tutorial 키와 메시지 정의 +const STEP_CONFIG: Array<{ + dataKey: string; + tab: 'elements' | 'evolution' | 'fusion'; + message: string; + tooltipPosition: 'above' | 'below'; + nextLabel?: string; +}> = [ + // step 0: WelcomeCard (별도 처리) + // step 1 (index 0) + { + dataKey: 'elements-first-card', + tab: 'elements', + message: '원소는 시간이 지날수록 자동으로 쌓여요. 강화하면 더 빨리 모여요!', + tooltipPosition: 'below', + }, + // step 2 (index 1) + { + dataKey: 'fusion-slot1', + tab: 'fusion', + message: '슬롯을 탭해서 원소를 선택하세요. 불 🔥 + 물 💧 조합을 해보세요!', + tooltipPosition: 'above', + }, + // step 3 (index 2) + { + dataKey: 'fusion-result', + tab: 'fusion', + message: '새로운 원소 발견! 42가지 조합이 더 있어요. 계속 탐험해보세요!', + tooltipPosition: 'above', + nextLabel: '좋아!', + }, + // step 4 (index 3) + { + dataKey: 'elements-enhance-button', + tab: 'evolution', + message: '원소를 강화하면 자동 수집 속도가 빨라져요! 골드를 모아 강화해보세요.', + tooltipPosition: 'above', + nextLabel: '완료', + }, +]; + +const spotlightStyle = (rect: DOMRect) => css` + position: fixed; + top: ${rect.top - 8}px; + left: ${rect.left - 8}px; + width: ${rect.width + 16}px; + height: ${rect.height + 16}px; + border-radius: 12px; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.65); + z-index: 1000; + pointer-events: none; + + /* 스포트라이트 pulse 애니메이션 */ + @keyframes spotlight-pulse { + 0%, 100% { box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.65), 0 0 0 2px #3182F6; } + 50% { box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.65), 0 0 0 4px #3182F6; } + } + animation: spotlight-pulse 1.5s ease-in-out infinite; +`; + +const progressBarContainerStyle = css` + position: fixed; + top: 16px; + left: 0; + right: 0; + display: flex; + justify-content: center; + z-index: 1003; + pointer-events: none; +`; + +const skipButtonStyle = css` + position: fixed; + top: 16px; + right: 16px; + z-index: 1004; + background: none; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 20px; + color: rgba(255, 255, 255, 0.8); + font-size: 12px; + padding: 6px 14px; + cursor: pointer; +`; + +const completionBadgeStyle = css` + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: #3182f6; + color: #ffffff; + border-radius: 20px; + padding: 12px 24px; + font-size: 15px; + font-weight: 700; + z-index: 1005; + white-space: nowrap; + animation: badge-appear 0.4s ease-out; + + @keyframes badge-appear { + from { opacity: 0; transform: translateX(-50%) scale(0.8); } + to { opacity: 1; transform: translateX(-50%) scale(1); } + } +`; + +export function TutorialOverlay() { + const { tutorialStep, tutorialCompleted, advanceTutorial, skipTutorial, setActiveTab } = + useGameStore(); + + const [targetRect, setTargetRect] = useState(null); + const [showCompletionBadge, setShowCompletionBadge] = useState(false); + const observerRef = useRef(null); + + // 튜토리얼 완료 배지 표시 + useEffect(() => { + if (tutorialCompleted && tutorialStep === 5) { + setShowCompletionBadge(true); + const timer = setTimeout(() => setShowCompletionBadge(false), 3000); + return () => clearTimeout(timer); + } + }, [tutorialCompleted, tutorialStep]); + + // data-tutorial 타겟 요소를 찾아 rect 업데이트 + const updateTargetRect = () => { + if (tutorialStep === 0 || tutorialCompleted) return; + const configIndex = tutorialStep - 1; + const config = STEP_CONFIG[configIndex]; + if (!config) return; + + const el = document.querySelector(`[data-tutorial="${config.dataKey}"]`); + if (el) { + setTargetRect(el.getBoundingClientRect()); + } else { + setTargetRect(null); + } + }; + + useLayoutEffect(() => { + if (tutorialStep === 0 || tutorialCompleted) return; + const configIndex = tutorialStep - 1; + const config = STEP_CONFIG[configIndex]; + if (!config) return; + + // 탭 강제 이동 + setActiveTab(config.tab); + + // DOM 변경 감지해서 타겟 rect 갱신 + updateTargetRect(); + observerRef.current?.disconnect(); + observerRef.current = new MutationObserver(updateTargetRect); + observerRef.current.observe(document.body, { childList: true, subtree: true }); + + const resizeHandler = () => updateTargetRect(); + window.addEventListener('resize', resizeHandler); + + return () => { + observerRef.current?.disconnect(); + window.removeEventListener('resize', resizeHandler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tutorialStep, tutorialCompleted]); + + // 완전히 완료된 경우 렌더링하지 않음 + if (tutorialCompleted && !showCompletionBadge) return null; + if (tutorialCompleted && showCompletionBadge) { + return
🎉 튜토리얼 완료!
; + } + + // Step 0: 환영 카드 + if (tutorialStep === 0) { + return ; + } + + const configIndex = tutorialStep - 1; + const config = STEP_CONFIG[configIndex]; + if (!config) return null; + + const isLastStep = tutorialStep === 4; + + const handleNext = () => { + if (isLastStep) { + setShowCompletionBadge(true); + } + advanceTutorial(); + }; + + return ( + <> + {/* 스포트라이트 */} + {targetRect &&
} + + {/* 진행 표시 바 */} +
+ +
+ + {/* 건너뛰기 버튼 (Step 2 이상) */} + {tutorialStep >= 1 && ( + + )} + + {/* 툴팁 */} + = 1 ? skipTutorial : undefined} + nextLabel={config.nextLabel} + position={config.tooltipPosition} + targetRect={targetRect} + /> + + ); +} diff --git a/src/components/tutorial/TutorialProgressBar.tsx b/src/components/tutorial/TutorialProgressBar.tsx new file mode 100644 index 0000000..eb61e35 --- /dev/null +++ b/src/components/tutorial/TutorialProgressBar.tsx @@ -0,0 +1,54 @@ +import { css } from '@emotion/react'; + +const TOTAL_STEPS = 5; +const TDS_BLUE = '#3182F6'; +const STEP_ORANGE = '#FF6B35'; +const STEP_GRAY = 'rgba(255, 255, 255, 0.3)'; + +interface TutorialProgressBarProps { + step: number; // 0-based, 0 = Step 1 welcome card +} + +const containerStyle = css` + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +`; + +const textStyle = css` + color: rgba(255, 255, 255, 0.8); + font-size: 12px; + font-weight: 500; +`; + +const dotsStyle = css` + display: flex; + gap: 6px; + align-items: center; +`; + +const dotStyle = (state: 'done' | 'current' | 'todo') => css` + width: ${state === 'current' ? 10 : 8}px; + height: ${state === 'current' ? 10 : 8}px; + border-radius: 50%; + background: ${state === 'done' ? TDS_BLUE : state === 'current' ? STEP_ORANGE : STEP_GRAY}; + transition: all 0.2s ease; +`; + +export function TutorialProgressBar({ step }: TutorialProgressBarProps) { + // step 0 = welcome card (Step 1), step 1 = Step 2, etc. + const displayStep = step + 1; + + return ( +
+ {displayStep} / {TOTAL_STEPS} +
+ {Array.from({ length: TOTAL_STEPS }, (_, i) => { + const state = i < step ? 'done' : i === step ? 'current' : 'todo'; + return
; + })} +
+
+ ); +} diff --git a/src/components/tutorial/TutorialTooltip.tsx b/src/components/tutorial/TutorialTooltip.tsx new file mode 100644 index 0000000..d2abd50 --- /dev/null +++ b/src/components/tutorial/TutorialTooltip.tsx @@ -0,0 +1,129 @@ +import { css } from '@emotion/react'; + +interface TutorialTooltipProps { + message: string; + onNext: () => void; + onSkip?: () => void; + nextLabel?: string; + // 툴팁 위치: 타겟 요소의 위 or 아래 + position?: 'above' | 'below'; + targetRect?: DOMRect | null; +} + +const TOOLTIP_WIDTH = 280; + +const overlayStyle = css` + position: fixed; + inset: 0; + z-index: 1001; + pointer-events: none; +`; + +const tooltipStyle = (position: 'above' | 'below', left: number, top: number) => css` + position: fixed; + left: ${left}px; + top: ${top}px; + width: ${TOOLTIP_WIDTH}px; + background: #ffffff; + border-radius: 14px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); + padding: 16px; + pointer-events: all; + z-index: 1002; + + /* CSS triangle arrow */ + &::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + border: 8px solid transparent; + ${position === 'below' + ? ` + top: -16px; + border-bottom-color: #ffffff; + ` + : ` + bottom: -16px; + border-top-color: #ffffff; + `} + } +`; + +const messageStyle = css` + font-size: 14px; + line-height: 1.5; + color: #191919; + margin-bottom: 14px; +`; + +const buttonRowStyle = css` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const nextButtonStyle = css` + background: #3182f6; + color: #ffffff; + border: none; + border-radius: 8px; + padding: 8px 20px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + pointer-events: all; +`; + +const skipButtonStyle = css` + background: none; + border: none; + color: rgba(25, 25, 25, 0.4); + font-size: 12px; + cursor: pointer; + padding: 4px; + pointer-events: all; +`; + +export function TutorialTooltip({ + message, + onNext, + onSkip, + nextLabel = '다음', + position = 'below', + targetRect, +}: TutorialTooltipProps) { + const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 375; + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 812; + + let left = (viewportWidth - TOOLTIP_WIDTH) / 2; + let top = viewportHeight / 2; + + if (targetRect) { + left = Math.max(8, Math.min(viewportWidth - TOOLTIP_WIDTH - 8, targetRect.left + targetRect.width / 2 - TOOLTIP_WIDTH / 2)); + if (position === 'below') { + top = targetRect.bottom + 20; + } else { + top = targetRect.top - 20 - 120; // approximate tooltip height + } + } + + // Clamp to viewport + top = Math.max(8, Math.min(viewportHeight - 160, top)); + + return ( +
+
+

{message}

+
+ {onSkip ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/src/components/tutorial/TutorialWelcomeCard.tsx b/src/components/tutorial/TutorialWelcomeCard.tsx new file mode 100644 index 0000000..a6fd9f1 --- /dev/null +++ b/src/components/tutorial/TutorialWelcomeCard.tsx @@ -0,0 +1,113 @@ +import { css } from '@emotion/react'; + +interface TutorialWelcomeCardProps { + onStart: () => void; +} + +const overlayStyle = css` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +`; + +const cardStyle = css` + background: #ffffff; + border-radius: 24px; + padding: 36px 28px; + width: 100%; + max-width: 320px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3); +`; + +const iconStyle = css` + font-size: 56px; + line-height: 1; +`; + +const titleStyle = css` + font-size: 22px; + font-weight: 700; + color: #191919; + text-align: center; +`; + +const rulesContainerStyle = css` + background: #f7f8fa; + border-radius: 14px; + padding: 16px; + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; +`; + +const copyStyle = css` + font-size: 15px; + color: #555; + text-align: center; + line-height: 1.5; +`; + +const ruleItemStyle = css` + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: #333; + line-height: 1.4; +`; + +const ruleIconStyle = css` + font-size: 20px; + flex-shrink: 0; +`; + +const startButtonStyle = css` + background: #3182f6; + color: #ffffff; + border: none; + border-radius: 14px; + padding: 16px 0; + width: 100%; + font-size: 16px; + font-weight: 700; + cursor: pointer; + margin-top: 4px; + letter-spacing: -0.3px; +`; + +export function TutorialWelcomeCard({ onStart }: TutorialWelcomeCardProps) { + return ( +
+
+
+

Archetype: FirstSpark

+

원소를 합성해서 새로운 세계를 만들어보세요

+
+
+ ⚗️ + 원소는 시간이 지나면 자동으로 수집돼요 +
+
+ 🔗 + 두 원소를 합성해 새로운 원소를 발견하세요 +
+
+ ⬆️ + 강화하면 수집 속도가 빨라져요 +
+
+ +
+
+ ); +}