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:
2026-04-01 00:29:14 +09:00
commit 56bc71a71a
8 changed files with 1623 additions and 0 deletions

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "archetype-firstspark",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "chcp 65001 >nul 2>&1 && node node_modules/@granite-js/react-native/bin/cli.js dev",
"build": "chcp 65001 >nul 2>&1 && ait build",
"deploy": "chcp 65001 >nul 2>&1 && ait deploy"
},
"dependencies": {
"@apps-in-toss/analytics": "^2.3.0",
"@apps-in-toss/web-framework": "^2.3.0",
"@emotion/react": "^11.14.0",
"@toss/tds-colors": "^0.1.0",
"@toss/tds-mobile": "^2.3.0",
"@toss/tds-mobile-ait": "^2.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-native": "^0.84.1",
"zustand": "^5.0.12"
}
}

61
pages/index.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { css } from '@emotion/react';
import { useEffect } from 'react';
import { BottomTabBar } from '../src/components/BottomTabBar';
import { OfflineRewardModal } from '../src/components/OfflineRewardModal';
import { ElementsScreen } from '../src/components/screens/ElementsScreen';
import { EvolutionScreen } from '../src/components/screens/EvolutionScreen';
import { FusionScreen } from '../src/components/screens/FusionScreen';
import { ShopScreen } from '../src/components/screens/ShopScreen';
import { useIdleTick } from '../src/hooks/useIdleTick';
import { useGameStore } from '../src/store/useGameStore';
import { trackGameEvent } from '../src/analytics';
const rootStyle = css`
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f7f8fa;
overflow: hidden;
font-family:
'Pretendard',
-apple-system,
BlinkMacSystemFont,
sans-serif;
`;
const contentStyle = css`
flex: 1;
overflow-y: auto;
padding-bottom: 72px;
`;
export default function IndexPage() {
const { activeTab, setActiveTab, elements, gold, elementLevels } = useGameStore();
useIdleTick();
useEffect(() => {
const ownedCount = Object.values(elements).filter((c) => c > 0).length;
const totalLevel = Object.values(elementLevels).reduce((sum, lv) => sum + lv, 0);
trackGameEvent('app_open', {
platform: 'web',
platform_time: new Date().toISOString(),
owned_element_count: ownedCount,
gold,
total_enhance_level: totalLevel,
});
}, []);
return (
<div css={rootStyle}>
<OfflineRewardModal />
<div css={contentStyle}>
{activeTab === 'elements' && <ElementsScreen />}
{activeTab === 'evolution' && <EvolutionScreen />}
{activeTab === 'fusion' && <FusionScreen />}
{activeTab === 'shop' && <ShopScreen />}
</div>
<BottomTabBar activeTab={activeTab} onTabChange={setActiveTab} />
</div>
);
}

18
src/_app.tsx Normal file
View 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
View 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 });
}

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

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

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

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