feat: 튜토리얼 오버레이 컴포넌트 추가 (TutorialOverlay, ProgressBar, Tooltip, WelcomeCard) (JSA-47)
- TutorialWelcomeCard: 첫 실행 시 게임 소개 카드 - TutorialProgressBar: 스텝 진행 상태 표시 - TutorialTooltip: 대상 요소 위/아래 스포트라이트 설명 툴팁 - TutorialOverlay: 전체 튜토리얼 흐름 조율 (4단계: 원소→합성슬롯→합성결과→강화) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
223
src/components/tutorial/TutorialOverlay.tsx
Normal file
223
src/components/tutorial/TutorialOverlay.tsx
Normal file
@@ -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<DOMRect | null>(null);
|
||||
const [showCompletionBadge, setShowCompletionBadge] = useState(false);
|
||||
const observerRef = useRef<MutationObserver | null>(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 <div css={completionBadgeStyle}>🎉 튜토리얼 완료!</div>;
|
||||
}
|
||||
|
||||
// Step 0: 환영 카드
|
||||
if (tutorialStep === 0) {
|
||||
return <TutorialWelcomeCard onStart={advanceTutorial} />;
|
||||
}
|
||||
|
||||
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 && <div css={spotlightStyle(targetRect)} />}
|
||||
|
||||
{/* 진행 표시 바 */}
|
||||
<div css={progressBarContainerStyle}>
|
||||
<TutorialProgressBar step={tutorialStep} />
|
||||
</div>
|
||||
|
||||
{/* 건너뛰기 버튼 (Step 2 이상) */}
|
||||
{tutorialStep >= 1 && (
|
||||
<button css={skipButtonStyle} onClick={skipTutorial}>
|
||||
건너뛰기
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 툴팁 */}
|
||||
<TutorialTooltip
|
||||
message={config.message}
|
||||
onNext={handleNext}
|
||||
onSkip={tutorialStep >= 1 ? skipTutorial : undefined}
|
||||
nextLabel={config.nextLabel}
|
||||
position={config.tooltipPosition}
|
||||
targetRect={targetRect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
src/components/tutorial/TutorialProgressBar.tsx
Normal file
54
src/components/tutorial/TutorialProgressBar.tsx
Normal file
@@ -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 (
|
||||
<div css={containerStyle}>
|
||||
<span css={textStyle}>{displayStep} / {TOTAL_STEPS}</span>
|
||||
<div css={dotsStyle}>
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
|
||||
const state = i < step ? 'done' : i === step ? 'current' : 'todo';
|
||||
return <div key={i} css={dotStyle(state)} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
src/components/tutorial/TutorialTooltip.tsx
Normal file
129
src/components/tutorial/TutorialTooltip.tsx
Normal file
@@ -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 (
|
||||
<div css={overlayStyle}>
|
||||
<div css={tooltipStyle(position, left, top)}>
|
||||
<p css={messageStyle}>{message}</p>
|
||||
<div css={buttonRowStyle}>
|
||||
{onSkip ? (
|
||||
<button css={skipButtonStyle} onClick={onSkip}>건너뛰기</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button css={nextButtonStyle} onClick={onNext}>{nextLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/components/tutorial/TutorialWelcomeCard.tsx
Normal file
113
src/components/tutorial/TutorialWelcomeCard.tsx
Normal file
@@ -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 (
|
||||
<div css={overlayStyle}>
|
||||
<div css={cardStyle}>
|
||||
<div css={iconStyle}>✨</div>
|
||||
<h2 css={titleStyle}>Archetype: FirstSpark</h2>
|
||||
<p css={copyStyle}>원소를 합성해서 새로운 세계를 만들어보세요</p>
|
||||
<div css={rulesContainerStyle}>
|
||||
<div css={ruleItemStyle}>
|
||||
<span css={ruleIconStyle}>⚗️</span>
|
||||
<span>원소는 시간이 지나면 <strong>자동으로 수집</strong>돼요</span>
|
||||
</div>
|
||||
<div css={ruleItemStyle}>
|
||||
<span css={ruleIconStyle}>🔗</span>
|
||||
<span>두 원소를 <strong>합성</strong>해 새로운 원소를 발견하세요</span>
|
||||
</div>
|
||||
<div css={ruleItemStyle}>
|
||||
<span css={ruleIconStyle}>⬆️</span>
|
||||
<span><strong>강화</strong>하면 수집 속도가 빨라져요</span>
|
||||
</div>
|
||||
</div>
|
||||
<button css={startButtonStyle} onClick={onStart}>시작하기</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user