feat: 튜토리얼 오버레이 컴포넌트 추가 (TutorialOverlay, ProgressBar, Tooltip, WelcomeCard) (JSA-47)

- TutorialWelcomeCard: 첫 실행 시 게임 소개 카드
- TutorialProgressBar: 스텝 진행 상태 표시
- TutorialTooltip: 대상 요소 위/아래 스포트라이트 설명 툴팁
- TutorialOverlay: 전체 튜토리얼 흐름 조율 (4단계: 원소→합성슬롯→합성결과→강화)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-01 22:44:02 +09:00
parent 6e323680d5
commit 466994da23
4 changed files with 519 additions and 0 deletions

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}