feat: @apps-in-toss/analytics 이벤트 트래킹 구현 (JSA-32)
- src/analytics.ts: initAnalytics / trackGameEvent 유틸 추가 - _app.tsx: 앱 시작 시 Analytics.init 호출 - pages/index.tsx: app_open 이벤트 (플랫폼 시간, 보유 원소 수, 골드, 강화 레벨 합계) - FusionScreen: fusion_completed (결과 tier/name, 골드 획득, 재료 ID) - EvolutionScreen: enhancement_completed + level_up (원소 ID/이름, 새 레벨, 비용) - ShopScreen: item_purchased (아이템 ID/이름, 가격, 희귀도) - OfflineRewardModal: offline_reward_claimed (오프라인 시간, 골드, 원소 종류 수) - package.json: @apps-in-toss/analytics ^2.3.0 명시적 추가 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
18
src/_app.tsx
Normal file
18
src/_app.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AppsInToss } from '@apps-in-toss/framework';
|
||||
import { TDSMobileAITProvider } from '@toss/tds-mobile-ait';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { InitialProps } from '@granite-js/react-native';
|
||||
import { context } from '../require.context';
|
||||
import { initAnalytics } from './analytics';
|
||||
|
||||
initAnalytics(process.env.NODE_ENV !== 'production');
|
||||
|
||||
function AppContainer({ children }: PropsWithChildren<InitialProps>) {
|
||||
return (
|
||||
<TDSMobileAITProvider brandPrimaryColor="#FF6B35">
|
||||
{children}
|
||||
</TDSMobileAITProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppsInToss.registerApp(AppContainer, { context });
|
||||
21
src/analytics.ts
Normal file
21
src/analytics.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Analytics, type AnalyticsConfig } from '@apps-in-toss/analytics';
|
||||
|
||||
type LogParams = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
let _logger: AnalyticsConfig['logger'] | null = null;
|
||||
|
||||
export function initAnalytics(debug = false) {
|
||||
const logger: AnalyticsConfig['logger'] = ({ log_name, log_type, params }) => {
|
||||
if (debug) {
|
||||
console.log(`[Analytics] ${log_type}:${log_name}`, params);
|
||||
}
|
||||
// 프로덕션에서는 여기서 서버로 이벤트 전송
|
||||
};
|
||||
|
||||
Analytics.init({ logger, debug });
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
export function trackGameEvent(logName: string, params: LogParams = {}) {
|
||||
_logger?.({ log_name: logName, log_type: 'game_event', params });
|
||||
}
|
||||
269
src/components/OfflineRewardModal.tsx
Normal file
269
src/components/OfflineRewardModal.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { adaptive } from '@toss/tds-colors';
|
||||
import elementsData from '../data/elements.json';
|
||||
import { useGameStore } from '../store/useGameStore';
|
||||
import { trackGameEvent } from '../analytics';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds >= 3600) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}시간 ${m}분`;
|
||||
}
|
||||
if (seconds >= 60) {
|
||||
return `${Math.floor(seconds / 60)}분`;
|
||||
}
|
||||
return `${seconds}초`;
|
||||
}
|
||||
|
||||
const starTwinkle = keyframes`
|
||||
from { opacity: 0.3; transform: scale(0.8); }
|
||||
to { opacity: 1.0; transform: scale(1.2); }
|
||||
`;
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% { transform: translateX(-150%) rotate(25deg); }
|
||||
100% { transform: translateX(150%) rotate(25deg); }
|
||||
`;
|
||||
|
||||
const overlayStyle = css`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const modalStyle = css`
|
||||
background: ${adaptive.background};
|
||||
border-radius: 24px;
|
||||
padding: 28px 24px 24px;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);
|
||||
background-image: radial-gradient(
|
||||
circle at 50% 0%,
|
||||
rgba(124, 77, 255, 0.07) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
`;
|
||||
|
||||
const headerStyle = css`
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const closeButtonStyle = css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${adaptive.greyBackground};
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
color: ${adaptive.grey500};
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
&:active { transform: scale(0.92); }
|
||||
`;
|
||||
|
||||
const moonIconStyle = css`
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '✦';
|
||||
position: absolute;
|
||||
font-size: 13px;
|
||||
color: #FFD700;
|
||||
top: 2px;
|
||||
left: 22%;
|
||||
animation: ${starTwinkle} 2s ease-in-out infinite alternate;
|
||||
}
|
||||
&::after {
|
||||
content: '✦';
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
color: #FFD700;
|
||||
top: 8px;
|
||||
right: 18%;
|
||||
animation: ${starTwinkle} 2s ease-in-out infinite alternate;
|
||||
animation-delay: 0.7s;
|
||||
}
|
||||
`;
|
||||
|
||||
const titleStyle = css`
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: ${adaptive.grey900};
|
||||
margin: 0 0 6px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const subtitleStyle = css`
|
||||
font-size: 13px;
|
||||
color: ${adaptive.grey500};
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const rewardListStyle = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const rewardItemStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: ${adaptive.greyBackground};
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
`;
|
||||
|
||||
const rewardEmojiStyle = css`
|
||||
font-size: 22px;
|
||||
`;
|
||||
|
||||
const rewardNameStyle = css`
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${adaptive.grey900};
|
||||
`;
|
||||
|
||||
const rewardCountStyle = css`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: ${adaptive.blue500};
|
||||
`;
|
||||
|
||||
const goldRowStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: linear-gradient(135deg, #fff8e1, #fff3cd);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const claimButtonStyle = css`
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #3182f6, #7c4dff);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
animation: ${shimmer} 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
`;
|
||||
|
||||
export function OfflineRewardModal() {
|
||||
const pendingOfflineReward = useGameStore((s) => s.pendingOfflineReward);
|
||||
const claimOfflineReward = useGameStore((s) => s.claimOfflineReward);
|
||||
const set = useGameStore.setState;
|
||||
|
||||
if (!pendingOfflineReward) return null;
|
||||
|
||||
const handleClaim = () => {
|
||||
trackGameEvent('offline_reward_claimed', {
|
||||
offline_sec: pendingOfflineReward.offlineSec,
|
||||
gold_reward: pendingOfflineReward.gold,
|
||||
element_types_count: Object.values(pendingOfflineReward.elements).filter((c) => c > 0).length,
|
||||
});
|
||||
claimOfflineReward();
|
||||
};
|
||||
|
||||
const elementRewards = Object.entries(pendingOfflineReward.elements)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([id, count]) => {
|
||||
const el = elementsData.find((e) => e.id === id);
|
||||
return { id, count, emoji: el?.emoji ?? '?', name: el?.name ?? id };
|
||||
});
|
||||
|
||||
const hasRewards = elementRewards.length > 0 || pendingOfflineReward.gold > 0;
|
||||
|
||||
return (
|
||||
<div css={overlayStyle}>
|
||||
<div css={modalStyle}>
|
||||
<div css={headerStyle}>
|
||||
<button css={closeButtonStyle} onClick={() => set({ pendingOfflineReward: null })} aria-label="닫기">✕</button>
|
||||
<span css={moonIconStyle}>🌙</span>
|
||||
<h2 css={titleStyle}>오프라인 보상</h2>
|
||||
<p css={subtitleStyle}>
|
||||
{formatDuration(pendingOfflineReward.offlineSec)} 동안<br />
|
||||
원소가 자동 수집되었습니다!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasRewards ? (
|
||||
<>
|
||||
<div css={rewardListStyle}>
|
||||
{elementRewards.map(({ id, count, emoji, name }) => (
|
||||
<div key={id} css={rewardItemStyle}>
|
||||
<span css={rewardEmojiStyle}>{emoji}</span>
|
||||
<span css={rewardNameStyle}>{name}</span>
|
||||
<span css={rewardCountStyle}>+{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pendingOfflineReward.gold > 0 && (
|
||||
<div css={goldRowStyle}>
|
||||
<span css={rewardEmojiStyle}>💰</span>
|
||||
<span css={rewardNameStyle}>골드</span>
|
||||
<span css={rewardCountStyle}>+{pendingOfflineReward.gold}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p css={subtitleStyle}>아직 수집된 원소가 없습니다.</p>
|
||||
)}
|
||||
|
||||
<button css={claimButtonStyle} onClick={handleClaim}>
|
||||
수령하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
src/components/screens/EvolutionScreen.tsx
Normal file
276
src/components/screens/EvolutionScreen.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { useState } from 'react';
|
||||
import elementsData from '../../data/elements.json';
|
||||
import {
|
||||
calcEnhanceCost,
|
||||
calcEffectiveIdleRate,
|
||||
ENHANCE_MAX_LEVEL,
|
||||
useGameStore,
|
||||
} from '../../store/useGameStore';
|
||||
import { FloatingOverlay } from '../FloatingOverlay';
|
||||
import { trackGameEvent } from '../../analytics';
|
||||
import { useFloatingItems } from '../../hooks/useFloatingItems';
|
||||
|
||||
const containerStyle = css`
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const headerStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const titleStyle = css`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #191919;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const goldBadgeStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #5a3200;
|
||||
`;
|
||||
|
||||
const cardStyle = css`
|
||||
background: #ffffff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const flashAnim = keyframes`
|
||||
0% { background: #e8f4ff; box-shadow: 0 0 0 3px #3182F6, 0 0 20px rgba(49,130,246,0.5); }
|
||||
50% { box-shadow: 0 0 0 2px #6C4DE6, 0 0 30px rgba(108,77,230,0.4); }
|
||||
100% { background: #ffffff; box-shadow: none; }
|
||||
`;
|
||||
|
||||
const cardFlashStyle = css`
|
||||
background: #ffffff;
|
||||
border: 1px solid #3182F6;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
animation: ${flashAnim} 0.9s ease-out;
|
||||
`;
|
||||
|
||||
const levelUpPopAnim = keyframes`
|
||||
0% { transform: translateY(0) scale(0.6); opacity: 0; }
|
||||
30% { transform: translateY(-10px) scale(1.2); opacity: 1; }
|
||||
70% { opacity: 1; }
|
||||
100% { transform: translateY(-50px) scale(0.9); opacity: 0; }
|
||||
`;
|
||||
|
||||
const enhanceButtonWrapStyle = css`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const cardHeaderStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const elementIconStyle = css`
|
||||
font-size: 32px;
|
||||
`;
|
||||
|
||||
const elementInfoStyle = css`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const elementNameStyle = css`
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #191919;
|
||||
margin: 0 0 2px;
|
||||
`;
|
||||
|
||||
const elementSubStyle = css`
|
||||
font-size: 12px;
|
||||
color: #8b8b8b;
|
||||
`;
|
||||
|
||||
const levelBarContainerStyle = css`
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const levelBarStyle = (progress: number) => css`
|
||||
background: linear-gradient(90deg, #3182f6, #6c4de6);
|
||||
height: 100%;
|
||||
width: ${progress}%;
|
||||
border-radius: 6px;
|
||||
transition: width 0.3s;
|
||||
`;
|
||||
|
||||
const levelInfoRowStyle = css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8b8b8b;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const rateChipStyle = css`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #3182f6;
|
||||
background: #eef4ff;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
`;
|
||||
|
||||
const enhanceButtonStyle = (canEnhance: boolean) => css`
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: ${canEnhance ? 'linear-gradient(135deg, #3182F6, #6C4DE6)' : '#f0f0f0'};
|
||||
color: ${canEnhance ? '#ffffff' : '#c0c0c0'};
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: ${canEnhance ? 'pointer' : 'not-allowed'};
|
||||
transition: opacity 0.15s;
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
const emptyStyle = css`
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #8b8b8b;
|
||||
`;
|
||||
|
||||
const LEVEL_LABELS = ['기본', '강화 I', '강화 II', '강화 III', '강화 IV', '강화 V (최대)'];
|
||||
|
||||
export function EvolutionScreen() {
|
||||
const { gold, elements, elementLevels, enhance } = useGameStore();
|
||||
const [flashedId, setFlashedId] = useState<string | null>(null);
|
||||
const { items: floatItems, add: addFloat } = useFloatingItems(1000);
|
||||
|
||||
const ownedElements = elementsData.filter((el) => (elements[el.id] ?? 0) > 0);
|
||||
|
||||
const handleEnhance = (elementId: string, buttonEl?: HTMLButtonElement | null) => {
|
||||
const result = enhance(elementId);
|
||||
if (result.success) {
|
||||
setFlashedId(elementId);
|
||||
setTimeout(() => setFlashedId(null), 900);
|
||||
|
||||
// 레벨업 플로팅 텍스트
|
||||
const rect = buttonEl?.getBoundingClientRect();
|
||||
const x = rect ? rect.left + rect.width / 2 - 30 : window.innerWidth / 2 - 30;
|
||||
const y = rect ? rect.top - 10 : window.innerHeight * 0.5;
|
||||
addFloat({
|
||||
text: `⬆️ Lv.${result.newLevel}`,
|
||||
x,
|
||||
y,
|
||||
color: '#3182F6',
|
||||
fontSize: 15,
|
||||
});
|
||||
|
||||
const el = elementsData.find((e) => e.id === elementId);
|
||||
trackGameEvent('enhancement_completed', {
|
||||
element_id: elementId,
|
||||
element_name: el?.name ?? '',
|
||||
new_level: result.newLevel ?? 0,
|
||||
cost: result.cost ?? 0,
|
||||
});
|
||||
trackGameEvent('level_up', {
|
||||
level_id: elementId,
|
||||
level: result.newLevel ?? 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (ownedElements.length === 0) {
|
||||
return (
|
||||
<div css={containerStyle}>
|
||||
<div css={headerStyle}>
|
||||
<h1 css={titleStyle}>강화</h1>
|
||||
<div css={goldBadgeStyle}>💰 {gold}</div>
|
||||
</div>
|
||||
<div css={emptyStyle}>
|
||||
<div css={css`font-size: 48px; margin-bottom: 12px;`}>⬆️</div>
|
||||
<p>원소 탭에서 원소를 먼저 수집하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div css={containerStyle}>
|
||||
<div css={headerStyle}>
|
||||
<h1 css={titleStyle}>강화</h1>
|
||||
<div css={goldBadgeStyle}>💰 {gold}</div>
|
||||
</div>
|
||||
|
||||
{ownedElements.map((el) => {
|
||||
const level = elementLevels[el.id] ?? 0;
|
||||
const isMax = level >= ENHANCE_MAX_LEVEL;
|
||||
const cost = isMax ? 0 : calcEnhanceCost(level);
|
||||
const canEnhance = !isMax && gold >= cost;
|
||||
const effectiveRate = calcEffectiveIdleRate(el.id, level);
|
||||
const progress = (level / ENHANCE_MAX_LEVEL) * 100;
|
||||
|
||||
return (
|
||||
<div key={el.id} css={flashedId === el.id ? cardFlashStyle : cardStyle}>
|
||||
<div css={cardHeaderStyle}>
|
||||
<span css={elementIconStyle}>{el.emoji}</span>
|
||||
<div css={elementInfoStyle}>
|
||||
<p css={elementNameStyle}>{el.name}</p>
|
||||
<p css={elementSubStyle}>{LEVEL_LABELS[level]}</p>
|
||||
</div>
|
||||
<span css={css`font-size: 13px; font-weight: 700; color: #3182F6;`}>
|
||||
Lv.{level}/{ENHANCE_MAX_LEVEL}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div css={levelBarContainerStyle}>
|
||||
<div css={levelBarStyle(progress)} />
|
||||
</div>
|
||||
|
||||
<div css={levelInfoRowStyle}>
|
||||
<span>자동 수집 효율</span>
|
||||
<span css={rateChipStyle}>
|
||||
×{effectiveRate.toFixed(1)}{level > 0 ? ` (+${((effectiveRate / el.idleIncomeRate - 1) * 100).toFixed(0)}%)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div css={enhanceButtonWrapStyle}>
|
||||
<button
|
||||
css={enhanceButtonStyle(canEnhance)}
|
||||
onClick={(e) => handleEnhance(el.id, e.currentTarget)}
|
||||
disabled={!canEnhance}
|
||||
>
|
||||
{isMax
|
||||
? '✅ 최대 레벨 달성'
|
||||
: canEnhance
|
||||
? `⬆️ 강화 (💰 ${cost} 골드)`
|
||||
: gold < cost
|
||||
? `🔒 골드 부족 (${cost - gold}G 필요)`
|
||||
: `🔒 강화 불가`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<FloatingOverlay items={floatItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
658
src/components/screens/FusionScreen.tsx
Normal file
658
src/components/screens/FusionScreen.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { useState } from 'react';
|
||||
import elementsData from '../../data/elements.json';
|
||||
import recipesData from '../../data/recipes.json';
|
||||
import { useGameStore } from '../../store/useGameStore';
|
||||
import { FloatingOverlay } from '../FloatingOverlay';
|
||||
import { useFloatingItems } from '../../hooks/useFloatingItems';
|
||||
import { trackGameEvent } from '../../analytics';
|
||||
|
||||
// TDS 색상 팔레트
|
||||
const tds = {
|
||||
blue: '#3182F6',
|
||||
blueLight: '#EEF4FF',
|
||||
blueDark: '#1B64DA',
|
||||
orange: '#FF6B35',
|
||||
orangeLight: '#FFF3EE',
|
||||
gold: '#F7C12A',
|
||||
goldLight: '#FFFBEA',
|
||||
green: '#00C853',
|
||||
greenLight: '#E8FAF0',
|
||||
red: '#F04452',
|
||||
gray100: '#F8F8F8',
|
||||
gray200: '#F0F0F0',
|
||||
gray300: '#E0E0E0',
|
||||
gray400: '#C0C0C0',
|
||||
gray500: '#8B8B8B',
|
||||
gray700: '#4A4A4A',
|
||||
gray900: '#191919',
|
||||
white: '#FFFFFF',
|
||||
border: '#F0F0F0',
|
||||
};
|
||||
|
||||
const popIn = keyframes`
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
70% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
`;
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
`;
|
||||
|
||||
// 합성 이펙트 keyframes
|
||||
const popInBlue = keyframes`
|
||||
0% { transform: scale(0.3); opacity: 0; filter: drop-shadow(0 0 12px #3182F6); }
|
||||
60% { transform: scale(1.25); filter: drop-shadow(0 0 16px #3182F6); }
|
||||
100% { transform: scale(1); opacity: 1; filter: drop-shadow(0 0 0px #3182F6); }
|
||||
`;
|
||||
|
||||
const popInPurple = keyframes`
|
||||
0% { transform: scale(0.3) rotate(-15deg); opacity: 0; filter: drop-shadow(0 0 14px #9C27B0); }
|
||||
55% { transform: scale(1.3) rotate(5deg); filter: drop-shadow(0 0 20px #9C27B0); }
|
||||
100% { transform: scale(1) rotate(0deg); opacity: 1; filter: drop-shadow(0 0 4px #9C27B0); }
|
||||
`;
|
||||
|
||||
const legendaryBurst = keyframes`
|
||||
0% { transform: scale(0) rotate(-20deg); opacity: 0; filter: brightness(3) drop-shadow(0 0 16px #F7C12A); }
|
||||
30% { transform: scale(1.5) rotate(8deg); filter: brightness(2) drop-shadow(0 0 24px #F7C12A); }
|
||||
60% { transform: scale(0.92) rotate(-3deg); filter: brightness(1.4) drop-shadow(0 0 12px #FF6B35); }
|
||||
100% { transform: scale(1) rotate(0deg); opacity: 1; filter: drop-shadow(0 0 6px #F7C12A); }
|
||||
`;
|
||||
|
||||
const particleAnim = (dx: number, dy: number) => keyframes`
|
||||
0% { transform: translate(0, 0) scale(1); opacity: 1; }
|
||||
100% { transform: translate(${dx}px, ${dy}px) scale(0); opacity: 0; }
|
||||
`;
|
||||
|
||||
const PARTICLE_DIRS = [
|
||||
{ dx: 0, dy: -55 },
|
||||
{ dx: 39, dy: -39 },
|
||||
{ dx: 55, dy: 0 },
|
||||
{ dx: 39, dy: 39 },
|
||||
{ dx: 0, dy: 55 },
|
||||
{ dx: -39, dy: 39 },
|
||||
{ dx: -55, dy: 0 },
|
||||
{ dx: -39, dy: -39 },
|
||||
];
|
||||
|
||||
const RARITY_ANIM: Record<string, ReturnType<typeof keyframes>> = {
|
||||
common: popIn,
|
||||
uncommon: popIn,
|
||||
rare: popInBlue,
|
||||
epic: popInPurple,
|
||||
legendary: legendaryBurst,
|
||||
};
|
||||
|
||||
const RARITY_GLOW: Record<string, string> = {
|
||||
common: 'none',
|
||||
uncommon: '0 0 12px #00C853',
|
||||
rare: '0 0 16px #3182F6',
|
||||
epic: '0 0 20px #9C27B0',
|
||||
legendary: '0 0 28px #F7C12A, 0 0 48px #FF6B35',
|
||||
};
|
||||
|
||||
const elementMap = Object.fromEntries(elementsData.map((el) => [el.id, el]));
|
||||
|
||||
const RARITY_COLOR: Record<string, string> = {
|
||||
common: tds.gray500,
|
||||
uncommon: tds.green,
|
||||
rare: tds.blue,
|
||||
epic: '#9C27B0',
|
||||
legendary: tds.gold,
|
||||
};
|
||||
|
||||
// ─── 스타일 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const containerStyle = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: ${tds.gray100};
|
||||
`;
|
||||
|
||||
const headerStyle = css`
|
||||
padding: 20px 20px 0;
|
||||
background: ${tds.white};
|
||||
`;
|
||||
|
||||
const titleStyle = css`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: ${tds.gray900};
|
||||
margin: 0 0 2px;
|
||||
`;
|
||||
|
||||
const subtitleStyle = css`
|
||||
font-size: 13px;
|
||||
color: ${tds.gray500};
|
||||
margin: 0 0 16px;
|
||||
`;
|
||||
|
||||
const fusionPanelStyle = css`
|
||||
padding: 16px 20px;
|
||||
background: ${tds.white};
|
||||
border-bottom: 1px solid ${tds.border};
|
||||
`;
|
||||
|
||||
const slotsRowStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const slotStyle = (filled: boolean, active: boolean) => css`
|
||||
flex: 1;
|
||||
height: 76px;
|
||||
border: 2px ${filled ? 'solid' : 'dashed'} ${active ? tds.orange : filled ? tds.blue : tds.gray300};
|
||||
border-radius: 16px;
|
||||
background: ${active ? tds.orangeLight : filled ? tds.blueLight : tds.gray100};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
gap: 2px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
`;
|
||||
|
||||
const slotEmojiStyle = css`
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const slotLabelStyle = css`
|
||||
font-size: 11px;
|
||||
color: ${tds.gray500};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const plusStyle = css`
|
||||
font-size: 22px;
|
||||
color: ${tds.gray400};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const previewBannerStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: ${tds.blueLight};
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${tds.blue};
|
||||
`;
|
||||
|
||||
const fuseButtonStyle = (canFuse: boolean) => css`
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: ${canFuse
|
||||
? `linear-gradient(135deg, ${tds.orange}, ${tds.gold})`
|
||||
: tds.gray200};
|
||||
color: ${canFuse ? tds.white : tds.gray400};
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: ${canFuse ? 'pointer' : 'not-allowed'};
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:active {
|
||||
opacity: ${canFuse ? 0.85 : 1};
|
||||
}
|
||||
`;
|
||||
|
||||
const resultBannerStyle = (rarity: string) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: ${tds.greenLight};
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid ${tds.green};
|
||||
box-shadow: ${RARITY_GLOW[rarity] ?? 'none'};
|
||||
transition: box-shadow 0.3s;
|
||||
`;
|
||||
|
||||
const resultEmojiStyle = (rarity: string) => css`
|
||||
font-size: 30px;
|
||||
animation: ${RARITY_ANIM[rarity] ?? popIn} ${rarity === 'legendary' ? '0.7s' : '0.45s'} ease-out;
|
||||
`;
|
||||
|
||||
const fuseButtonWrapStyle = css`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const particleStyle = (dx: number, dy: number, color: string, delay: number) => css`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: ${color};
|
||||
pointer-events: none;
|
||||
margin: -4px 0 0 -4px;
|
||||
animation: ${particleAnim(dx, dy)} 0.7s ${delay}ms ease-out forwards;
|
||||
`;
|
||||
|
||||
const resultTextStyle = css`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const resultNameStyle = css`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: ${tds.gray900};
|
||||
`;
|
||||
|
||||
const resultGoldStyle = css`
|
||||
font-size: 12px;
|
||||
color: ${tds.gray500};
|
||||
margin-top: 1px;
|
||||
`;
|
||||
|
||||
const errorBannerStyle = css`
|
||||
background: #FFF1F1;
|
||||
border: 1px solid ${tds.red};
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: ${tds.red};
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
// ─── 인벤토리 섹션 ──────────────────────────────────────────────────────────
|
||||
|
||||
const inventorySectionStyle = css`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
`;
|
||||
|
||||
const sectionHeaderStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const sectionTitleStyle = css`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: ${tds.gray900};
|
||||
`;
|
||||
|
||||
const sectionCountStyle = css`
|
||||
font-size: 12px;
|
||||
color: ${tds.gray500};
|
||||
`;
|
||||
|
||||
const inventoryGridStyle = css`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const inventoryCardStyle = (
|
||||
owned: boolean,
|
||||
selected: boolean,
|
||||
selectMode: boolean
|
||||
) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: ${selected ? tds.orangeLight : tds.white};
|
||||
border: 2px solid ${selected ? tds.orange : owned ? tds.gray300 : tds.gray200};
|
||||
border-radius: 14px;
|
||||
padding: 10px 4px 8px;
|
||||
gap: 4px;
|
||||
cursor: ${owned && selectMode ? 'pointer' : 'default'};
|
||||
opacity: ${owned ? 1 : 0.4};
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
|
||||
${owned && selectMode && !selected
|
||||
? `&:hover { border-color: ${tds.orange}; background: ${tds.orangeLight}; }`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
const cardEmojiStyle = css`
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const cardNameStyle = css`
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: ${tds.gray900};
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
`;
|
||||
|
||||
const cardCountStyle = (rarity: string) => css`
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: ${RARITY_COLOR[rarity] ?? tds.gray500};
|
||||
`;
|
||||
|
||||
const discoveredSectionStyle = css`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const recipeRowStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: ${tds.white};
|
||||
border: 1px solid ${tds.border};
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const recipeEmojiStyle = css`
|
||||
font-size: 18px;
|
||||
`;
|
||||
|
||||
const recipeArrowStyle = css`
|
||||
font-size: 11px;
|
||||
color: ${tds.gray400};
|
||||
`;
|
||||
|
||||
const recipeNameStyle = css`
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: ${tds.gray900};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const shineBadgeStyle = css`
|
||||
font-size: 11px;
|
||||
background: linear-gradient(90deg, ${tds.gold}, ${tds.orange}, ${tds.gold});
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: ${shimmer} 2s linear infinite;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
// ─── 컴포넌트 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FusionScreen() {
|
||||
const { elements, fuse } = useGameStore();
|
||||
const [slot1, setSlot1] = useState<string | null>(null);
|
||||
const [slot2, setSlot2] = useState<string | null>(null);
|
||||
const [selectingSlot, setSelectingSlot] = useState<1 | 2 | null>(null);
|
||||
const [lastResult, setLastResult] = useState<{
|
||||
type: 'success' | 'error';
|
||||
resultId?: string;
|
||||
goldGained?: number;
|
||||
message?: string;
|
||||
} | null>(null);
|
||||
const [isFusing, setIsFusing] = useState(false);
|
||||
const [fuseRarity, setFuseRarity] = useState<string>('common');
|
||||
const { items: floatItems, add: addFloat } = useFloatingItems(1100);
|
||||
|
||||
// 보유 중인 원소 목록 (보유량 > 0)
|
||||
const ownedElements = elementsData.filter((el) => (elements[el.id] ?? 0) > 0);
|
||||
|
||||
const getMatchingRecipe = () => {
|
||||
if (!slot1 || !slot2) return null;
|
||||
return recipesData.find(
|
||||
(r) =>
|
||||
(r.ingredients[0] === slot1 && r.ingredients[1] === slot2) ||
|
||||
(r.ingredients[0] === slot2 && r.ingredients[1] === slot1)
|
||||
);
|
||||
};
|
||||
|
||||
const matchingRecipe = getMatchingRecipe();
|
||||
const canFuse =
|
||||
!!matchingRecipe &&
|
||||
(elements[slot1 ?? ''] ?? 0) > 0 &&
|
||||
(elements[slot2 ?? ''] ?? 0) > 0;
|
||||
|
||||
const handleFuse = () => {
|
||||
if (!slot1 || !slot2) return;
|
||||
const result = fuse(slot1, slot2);
|
||||
if (result.success && result.resultId) {
|
||||
const resultEl = elementMap[result.resultId];
|
||||
const rarity = resultEl?.rarity ?? 'common';
|
||||
setFuseRarity(rarity);
|
||||
setIsFusing(true);
|
||||
setTimeout(() => setIsFusing(false), 750);
|
||||
|
||||
// 골드 획득 플로팅 텍스트
|
||||
const cx = window.innerWidth / 2 - 30;
|
||||
const cy = window.innerHeight * 0.45;
|
||||
addFloat({ text: `+${result.goldGained} 💰`, x: cx + (Math.random() - 0.5) * 60, y: cy, color: tds.gold, fontSize: 14 });
|
||||
|
||||
trackGameEvent('fusion_completed', {
|
||||
result_id: result.resultId,
|
||||
result_name: resultEl?.name ?? '',
|
||||
result_tier: rarity,
|
||||
gold_gained: result.goldGained ?? 0,
|
||||
ingredient_1: slot1,
|
||||
ingredient_2: slot2,
|
||||
});
|
||||
|
||||
setLastResult({
|
||||
type: 'success',
|
||||
resultId: result.resultId,
|
||||
goldGained: result.goldGained,
|
||||
});
|
||||
setSlot1(null);
|
||||
setSlot2(null);
|
||||
setSelectingSlot(null);
|
||||
} else {
|
||||
setLastResult({
|
||||
type: 'error',
|
||||
message:
|
||||
result.error === 'no_recipe'
|
||||
? '⚠️ 알 수 없는 조합입니다'
|
||||
: '⚠️ 원소가 부족합니다',
|
||||
});
|
||||
}
|
||||
// 3초 후 피드백 초기화
|
||||
setTimeout(() => setLastResult(null), 3000);
|
||||
};
|
||||
|
||||
const handleSlotClick = (slot: 1 | 2) => {
|
||||
if (selectingSlot === slot) {
|
||||
setSelectingSlot(null);
|
||||
} else {
|
||||
setSelectingSlot(slot);
|
||||
}
|
||||
setLastResult(null);
|
||||
};
|
||||
|
||||
const handleSelectElement = (id: string) => {
|
||||
if (selectingSlot === 1) setSlot1(id);
|
||||
else if (selectingSlot === 2) setSlot2(id);
|
||||
setSelectingSlot(null);
|
||||
};
|
||||
|
||||
const discoveredRecipes = recipesData.filter(
|
||||
(r) => (elements[r.result] ?? 0) > 0
|
||||
);
|
||||
|
||||
const selectMode = selectingSlot !== null;
|
||||
|
||||
return (
|
||||
<div css={containerStyle}>
|
||||
{/* ── 상단 헤더 ── */}
|
||||
<div css={headerStyle}>
|
||||
<h1 css={titleStyle}>합성</h1>
|
||||
<p css={subtitleStyle}>두 원소를 조합하여 새로운 원소를 만드세요</p>
|
||||
</div>
|
||||
|
||||
{/* ── 합성 패널 ── */}
|
||||
<div css={fusionPanelStyle}>
|
||||
{/* 슬롯 */}
|
||||
<div css={slotsRowStyle}>
|
||||
<div
|
||||
css={slotStyle(!!slot1, selectingSlot === 1)}
|
||||
onClick={() => handleSlotClick(1)}
|
||||
>
|
||||
{slot1 ? (
|
||||
<>
|
||||
<span css={slotEmojiStyle}>{elementMap[slot1]?.emoji}</span>
|
||||
<span css={slotLabelStyle}>{elementMap[slot1]?.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span css={slotLabelStyle}>
|
||||
{selectingSlot === 1 ? '아래서 선택' : '원소 선택'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span css={plusStyle}>+</span>
|
||||
<div
|
||||
css={slotStyle(!!slot2, selectingSlot === 2)}
|
||||
onClick={() => handleSlotClick(2)}
|
||||
>
|
||||
{slot2 ? (
|
||||
<>
|
||||
<span css={slotEmojiStyle}>{elementMap[slot2]?.emoji}</span>
|
||||
<span css={slotLabelStyle}>{elementMap[slot2]?.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span css={slotLabelStyle}>
|
||||
{selectingSlot === 2 ? '아래서 선택' : '원소 선택'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레시피 미리보기 */}
|
||||
{matchingRecipe && (
|
||||
<div css={previewBannerStyle}>
|
||||
<span>{elementMap[slot1 ?? '']?.emoji}</span>
|
||||
<span>+</span>
|
||||
<span>{elementMap[slot2 ?? '']?.emoji}</span>
|
||||
<span>=</span>
|
||||
<span>{elementMap[matchingRecipe.result]?.emoji}</span>
|
||||
<span>{elementMap[matchingRecipe.result]?.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 합성 결과 피드백 */}
|
||||
{lastResult?.type === 'success' && lastResult.resultId && (
|
||||
<div css={resultBannerStyle(elementMap[lastResult.resultId]?.rarity ?? 'common')}>
|
||||
<span css={resultEmojiStyle(elementMap[lastResult.resultId]?.rarity ?? 'common')}>
|
||||
{elementMap[lastResult.resultId]?.emoji}
|
||||
</span>
|
||||
<div css={resultTextStyle}>
|
||||
<div css={resultNameStyle}>
|
||||
{elementMap[lastResult.resultId]?.name} 합성 성공!
|
||||
</div>
|
||||
<div css={resultGoldStyle}>+{lastResult.goldGained} Gold 획득</div>
|
||||
</div>
|
||||
<span css={shineBadgeStyle}>NEW</span>
|
||||
</div>
|
||||
)}
|
||||
{lastResult?.type === 'error' && (
|
||||
<div css={errorBannerStyle}>{lastResult.message}</div>
|
||||
)}
|
||||
|
||||
{/* 합성 버튼 + 파티클 버스트 */}
|
||||
<div css={fuseButtonWrapStyle}>
|
||||
{isFusing && PARTICLE_DIRS.map((dir, i) => (
|
||||
<span
|
||||
key={i}
|
||||
css={particleStyle(
|
||||
dir.dx,
|
||||
dir.dy,
|
||||
RARITY_COLOR[fuseRarity] ?? tds.gold,
|
||||
i * 30
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
css={fuseButtonStyle(canFuse)}
|
||||
onClick={handleFuse}
|
||||
disabled={!canFuse}
|
||||
>
|
||||
{isFusing
|
||||
? '✨ 합성 중...'
|
||||
: !slot1 || !slot2
|
||||
? '원소를 두 개 선택하세요'
|
||||
: !matchingRecipe
|
||||
? '⚠️ 알 수 없는 조합'
|
||||
: '✨ 합성하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플로팅 텍스트 오버레이 */}
|
||||
<FloatingOverlay items={floatItems} />
|
||||
|
||||
{/* ── 인벤토리 + 발견한 레시피 ── */}
|
||||
<div css={inventorySectionStyle}>
|
||||
{/* 인벤토리 그리드 */}
|
||||
<div css={sectionHeaderStyle}>
|
||||
<span css={sectionTitleStyle}>
|
||||
{selectMode ? `슬롯 ${selectingSlot}에 넣을 원소를 선택하세요` : '보유 원소'}
|
||||
</span>
|
||||
<span css={sectionCountStyle}>
|
||||
{ownedElements.length} / {elementsData.length}종
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div css={inventoryGridStyle}>
|
||||
{elementsData.map((el) => {
|
||||
const count = elements[el.id] ?? 0;
|
||||
const owned = count > 0;
|
||||
const selectedInSlot =
|
||||
(selectingSlot === 1 && slot1 === el.id) ||
|
||||
(selectingSlot === 2 && slot2 === el.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
css={inventoryCardStyle(owned, selectedInSlot, selectMode)}
|
||||
onClick={() => owned && selectMode && handleSelectElement(el.id)}
|
||||
>
|
||||
<span css={cardEmojiStyle}>{el.emoji}</span>
|
||||
<span css={cardNameStyle}>{el.name}</span>
|
||||
{owned && (
|
||||
<span css={cardCountStyle(el.rarity)}>×{count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 발견한 레시피 */}
|
||||
{discoveredRecipes.length > 0 && (
|
||||
<div css={discoveredSectionStyle}>
|
||||
<div css={sectionHeaderStyle}>
|
||||
<span css={sectionTitleStyle}>발견한 레시피</span>
|
||||
<span css={sectionCountStyle}>
|
||||
{discoveredRecipes.length} / {recipesData.length}
|
||||
</span>
|
||||
</div>
|
||||
{discoveredRecipes.map((r) => (
|
||||
<div key={r.id} css={recipeRowStyle}>
|
||||
<span css={recipeEmojiStyle}>{elementMap[r.ingredients[0]]?.emoji}</span>
|
||||
<span css={recipeArrowStyle}>+</span>
|
||||
<span css={recipeEmojiStyle}>{elementMap[r.ingredients[1]]?.emoji}</span>
|
||||
<span css={recipeArrowStyle}>→</span>
|
||||
<span css={recipeEmojiStyle}>{elementMap[r.result]?.emoji}</span>
|
||||
<span css={recipeNameStyle}>{elementMap[r.result]?.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
src/components/screens/ShopScreen.tsx
Normal file
298
src/components/screens/ShopScreen.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { adaptive } from '@toss/tds-colors';
|
||||
import { rarityGradient } from '../../styles/gameColors';
|
||||
import { useGameStore, isElementUnlocked } from '../../store/useGameStore';
|
||||
import { trackGameEvent } from '../../analytics';
|
||||
import elementsData from '../../data/elements.json';
|
||||
|
||||
const BOOST_DURATION_SEC = 30;
|
||||
|
||||
const containerStyle = css`
|
||||
padding: 24px 20px;
|
||||
background: ${adaptive.background};
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const titleStyle = css`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: ${adaptive.grey900};
|
||||
margin: 0 0 20px;
|
||||
`;
|
||||
|
||||
const goldRowStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(135deg, #fff8e1, #fff3cd);
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #5a3200;
|
||||
`;
|
||||
|
||||
const shopGridStyle = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const shopItemStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: ${adaptive.background};
|
||||
border: 1px solid ${adaptive.grey200};
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
`;
|
||||
|
||||
const activeBoostPulse = keyframes`
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 180, 0, 0.4); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(255, 180, 0, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 180, 0, 0); }
|
||||
`;
|
||||
|
||||
const shopItemActiveStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: linear-gradient(135deg, #fffde7, #fff9c4);
|
||||
border: 1.5px solid #ffc107;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
animation: ${activeBoostPulse} 2s infinite;
|
||||
`;
|
||||
|
||||
const buyFlashAnim = keyframes`
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(0.92); }
|
||||
70% { transform: scale(1.04); }
|
||||
100% { transform: scale(1); }
|
||||
`;
|
||||
|
||||
const noGoldShake = keyframes`
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
40% { transform: translateX(6px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
`;
|
||||
|
||||
const shopItemNoGoldStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: ${adaptive.background};
|
||||
border: 1.5px solid #ef5350;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
animation: ${noGoldShake} 0.5s ease-out;
|
||||
`;
|
||||
|
||||
const itemIconBoxStyle = (rarity: string) => css`
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
flex-shrink: 0;
|
||||
background: ${rarityGradient(rarity)};
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
`;
|
||||
|
||||
const itemInfoStyle = css`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const itemNameStyle = css`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${adaptive.grey900};
|
||||
margin: 0 0 4px;
|
||||
`;
|
||||
|
||||
const itemDescStyle = css`
|
||||
font-size: 12px;
|
||||
color: ${adaptive.grey500};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const activeDescStyle = css`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #b8860b;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const timerBarContainerStyle = css`
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 4px;
|
||||
height: 4px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const timerBarStyle = (progress: number) => css`
|
||||
background: linear-gradient(90deg, #ffc107, #ff9800);
|
||||
height: 100%;
|
||||
width: ${progress}%;
|
||||
border-radius: 4px;
|
||||
transition: width 1s linear;
|
||||
`;
|
||||
|
||||
const buyButtonStyle = (canBuy: boolean, justBought: boolean) => css`
|
||||
background: ${justBought ? '#4caf50' : canBuy ? adaptive.blue500 : adaptive.grey200};
|
||||
color: ${canBuy || justBought ? '#ffffff' : adaptive.grey500};
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: ${canBuy ? 'pointer' : 'not-allowed'};
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s;
|
||||
animation: ${justBought ? css`${buyFlashAnim} 0.4s ease-out` : 'none'};
|
||||
`;
|
||||
|
||||
const SHOP_ITEMS = [
|
||||
{
|
||||
id: 'fire_boost',
|
||||
name: '불꽃 강화석',
|
||||
desc: `불꽃 원소 획득량 2배 (${BOOST_DURATION_SEC}초)`,
|
||||
icon: '🔥',
|
||||
price: 50,
|
||||
rarity: 'uncommon',
|
||||
},
|
||||
{
|
||||
id: 'water_boost',
|
||||
name: '물방울 강화석',
|
||||
desc: `물방울 원소 획득량 2배 (${BOOST_DURATION_SEC}초)`,
|
||||
icon: '💧',
|
||||
price: 50,
|
||||
rarity: 'uncommon',
|
||||
},
|
||||
{
|
||||
id: 'fusion_scroll',
|
||||
name: '합성 두루마리',
|
||||
desc: '랜덤 원소 합성 시도',
|
||||
icon: '📜',
|
||||
price: 80,
|
||||
rarity: 'rare',
|
||||
},
|
||||
{
|
||||
id: 'gold_bag',
|
||||
name: '골드 주머니',
|
||||
desc: '+50 골드 즉시 획득',
|
||||
icon: '👝',
|
||||
price: 100,
|
||||
rarity: 'epic',
|
||||
},
|
||||
];
|
||||
|
||||
export function ShopScreen() {
|
||||
const { gold, addGold, addElement, activeBoosts, activateBoost } = useGameStore();
|
||||
const [boughtId, setBoughtId] = useState<string | null>(null);
|
||||
const [noGoldId, setNoGoldId] = useState<string | null>(null);
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// 버프 타이머 갱신 (1초마다 re-render)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick((t) => t + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getRemainingSeconds = (boostId: string): number => {
|
||||
const expiresAt = activeBoosts[boostId];
|
||||
if (!expiresAt) return 0;
|
||||
return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
|
||||
};
|
||||
|
||||
const handleBuy = (item: (typeof SHOP_ITEMS)[0]) => {
|
||||
// 스토어에서 최신 골드 값을 직접 읽어 스테일 클로저로 인한 음수 골드 버그 방지
|
||||
const currentGold = useGameStore.getState().gold;
|
||||
if (currentGold < item.price) {
|
||||
setNoGoldId(item.id);
|
||||
setTimeout(() => setNoGoldId(null), 600);
|
||||
return;
|
||||
}
|
||||
addGold(-item.price);
|
||||
|
||||
if (item.id === 'gold_bag') {
|
||||
addGold(50);
|
||||
} else if (item.id === 'fusion_scroll') {
|
||||
// 미보유 원소 우선 제공, 없으면 보유 중 랜덤
|
||||
const currentElements = useGameStore.getState().elements;
|
||||
const unlockedIds = elementsData
|
||||
.filter((el) => isElementUnlocked(el.id, currentElements))
|
||||
.map((el) => el.id);
|
||||
const unownedIds = unlockedIds.filter((id) => (currentElements[id] ?? 0) === 0);
|
||||
const pool = unownedIds.length > 0 ? unownedIds : unlockedIds;
|
||||
if (pool.length > 0) {
|
||||
addElement(pool[Math.floor(Math.random() * pool.length)]);
|
||||
}
|
||||
} else if (item.id === 'fire_boost' || item.id === 'water_boost') {
|
||||
activateBoost(item.id, BOOST_DURATION_SEC);
|
||||
}
|
||||
|
||||
trackGameEvent('item_purchased', {
|
||||
item_id: item.id,
|
||||
item_name: item.name,
|
||||
price: item.price,
|
||||
rarity: item.rarity,
|
||||
});
|
||||
|
||||
setBoughtId(item.id);
|
||||
setTimeout(() => setBoughtId(null), 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<div css={containerStyle}>
|
||||
<h1 css={titleStyle}>상점</h1>
|
||||
<div css={goldRowStyle}>
|
||||
<span>💰</span>
|
||||
<span>보유 골드: {gold.toLocaleString()}</span>
|
||||
</div>
|
||||
<div css={shopGridStyle}>
|
||||
{SHOP_ITEMS.map((item) => {
|
||||
const remaining = getRemainingSeconds(item.id);
|
||||
const isActive = remaining > 0;
|
||||
const progress = (remaining / BOOST_DURATION_SEC) * 100;
|
||||
|
||||
return (
|
||||
<div key={item.id} css={noGoldId === item.id ? shopItemNoGoldStyle : isActive ? shopItemActiveStyle : shopItemStyle}>
|
||||
<div css={itemIconBoxStyle(item.rarity)}>{item.icon}</div>
|
||||
<div css={itemInfoStyle}>
|
||||
<p css={itemNameStyle}>{item.name}</p>
|
||||
{isActive ? (
|
||||
<>
|
||||
<p css={activeDescStyle}>✨ 활성 중 — {remaining}초 남음</p>
|
||||
<div css={timerBarContainerStyle}>
|
||||
<div css={timerBarStyle(progress)} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p css={itemDescStyle}>{item.desc}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
css={buyButtonStyle(gold >= item.price, boughtId === item.id)}
|
||||
onClick={() => handleBuy(item)}
|
||||
disabled={gold < item.price}
|
||||
>
|
||||
{boughtId === item.id ? '✓' : `${item.price}G`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user