From 56bc71a71adc7c22c438e5468ff08708bca23bc0 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 1 Apr 2026 00:29:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20@apps-in-toss/analytics=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=ED=8A=B8=EB=9E=98=ED=82=B9=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(JSA-32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 22 + pages/index.tsx | 61 ++ src/_app.tsx | 18 + src/analytics.ts | 21 + src/components/OfflineRewardModal.tsx | 269 +++++++++ src/components/screens/EvolutionScreen.tsx | 276 +++++++++ src/components/screens/FusionScreen.tsx | 658 +++++++++++++++++++++ src/components/screens/ShopScreen.tsx | 298 ++++++++++ 8 files changed, 1623 insertions(+) create mode 100644 package.json create mode 100644 pages/index.tsx create mode 100644 src/_app.tsx create mode 100644 src/analytics.ts create mode 100644 src/components/OfflineRewardModal.tsx create mode 100644 src/components/screens/EvolutionScreen.tsx create mode 100644 src/components/screens/FusionScreen.tsx create mode 100644 src/components/screens/ShopScreen.tsx diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a41e46 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pages/index.tsx b/pages/index.tsx new file mode 100644 index 0000000..f2b621f --- /dev/null +++ b/pages/index.tsx @@ -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 ( +
+ +
+ {activeTab === 'elements' && } + {activeTab === 'evolution' && } + {activeTab === 'fusion' && } + {activeTab === 'shop' && } +
+ +
+ ); +} diff --git a/src/_app.tsx b/src/_app.tsx new file mode 100644 index 0000000..1437b51 --- /dev/null +++ b/src/_app.tsx @@ -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) { + return ( + + {children} + + ); +} + +export default AppsInToss.registerApp(AppContainer, { context }); diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..576be26 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,21 @@ +import { Analytics, type AnalyticsConfig } from '@apps-in-toss/analytics'; + +type LogParams = Record; + +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 }); +} diff --git a/src/components/OfflineRewardModal.tsx b/src/components/OfflineRewardModal.tsx new file mode 100644 index 0000000..3d49807 --- /dev/null +++ b/src/components/OfflineRewardModal.tsx @@ -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 ( +
+
+
+ + 🌙 +

오프라인 보상

+

+ {formatDuration(pendingOfflineReward.offlineSec)} 동안
+ 원소가 자동 수집되었습니다! +

+
+ + {hasRewards ? ( + <> +
+ {elementRewards.map(({ id, count, emoji, name }) => ( +
+ {emoji} + {name} + +{count} +
+ ))} +
+ {pendingOfflineReward.gold > 0 && ( +
+ 💰 + 골드 + +{pendingOfflineReward.gold} +
+ )} + + ) : ( +

아직 수집된 원소가 없습니다.

+ )} + + +
+
+ ); +} diff --git a/src/components/screens/EvolutionScreen.tsx b/src/components/screens/EvolutionScreen.tsx new file mode 100644 index 0000000..7e5ccc3 --- /dev/null +++ b/src/components/screens/EvolutionScreen.tsx @@ -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(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 ( +
+
+

강화

+
💰 {gold}
+
+
+
⬆️
+

원소 탭에서 원소를 먼저 수집하세요.

+
+
+ ); + } + + return ( +
+
+

강화

+
💰 {gold}
+
+ + {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 ( +
+
+ {el.emoji} +
+

{el.name}

+

{LEVEL_LABELS[level]}

+
+ + Lv.{level}/{ENHANCE_MAX_LEVEL} + +
+ +
+
+
+ +
+ 자동 수집 효율 + + ×{effectiveRate.toFixed(1)}{level > 0 ? ` (+${((effectiveRate / el.idleIncomeRate - 1) * 100).toFixed(0)}%)` : ''} + +
+ +
+ +
+
+ ); + })} + +
+ ); +} diff --git a/src/components/screens/FusionScreen.tsx b/src/components/screens/FusionScreen.tsx new file mode 100644 index 0000000..0d311f5 --- /dev/null +++ b/src/components/screens/FusionScreen.tsx @@ -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> = { + common: popIn, + uncommon: popIn, + rare: popInBlue, + epic: popInPurple, + legendary: legendaryBurst, +}; + +const RARITY_GLOW: Record = { + 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 = { + 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(null); + const [slot2, setSlot2] = useState(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('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 ( +
+ {/* ── 상단 헤더 ── */} +
+

합성

+

두 원소를 조합하여 새로운 원소를 만드세요

+
+ + {/* ── 합성 패널 ── */} +
+ {/* 슬롯 */} +
+
handleSlotClick(1)} + > + {slot1 ? ( + <> + {elementMap[slot1]?.emoji} + {elementMap[slot1]?.name} + + ) : ( + + {selectingSlot === 1 ? '아래서 선택' : '원소 선택'} + + )} +
+ + +
handleSlotClick(2)} + > + {slot2 ? ( + <> + {elementMap[slot2]?.emoji} + {elementMap[slot2]?.name} + + ) : ( + + {selectingSlot === 2 ? '아래서 선택' : '원소 선택'} + + )} +
+
+ + {/* 레시피 미리보기 */} + {matchingRecipe && ( +
+ {elementMap[slot1 ?? '']?.emoji} + + + {elementMap[slot2 ?? '']?.emoji} + = + {elementMap[matchingRecipe.result]?.emoji} + {elementMap[matchingRecipe.result]?.name} +
+ )} + + {/* 합성 결과 피드백 */} + {lastResult?.type === 'success' && lastResult.resultId && ( +
+ + {elementMap[lastResult.resultId]?.emoji} + +
+
+ {elementMap[lastResult.resultId]?.name} 합성 성공! +
+
+{lastResult.goldGained} Gold 획득
+
+ NEW +
+ )} + {lastResult?.type === 'error' && ( +
{lastResult.message}
+ )} + + {/* 합성 버튼 + 파티클 버스트 */} +
+ {isFusing && PARTICLE_DIRS.map((dir, i) => ( + + ))} + +
+
+ + {/* 플로팅 텍스트 오버레이 */} + + + {/* ── 인벤토리 + 발견한 레시피 ── */} +
+ {/* 인벤토리 그리드 */} +
+ + {selectMode ? `슬롯 ${selectingSlot}에 넣을 원소를 선택하세요` : '보유 원소'} + + + {ownedElements.length} / {elementsData.length}종 + +
+ +
+ {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 ( +
owned && selectMode && handleSelectElement(el.id)} + > + {el.emoji} + {el.name} + {owned && ( + ×{count} + )} +
+ ); + })} +
+ + {/* 발견한 레시피 */} + {discoveredRecipes.length > 0 && ( +
+
+ 발견한 레시피 + + {discoveredRecipes.length} / {recipesData.length} + +
+ {discoveredRecipes.map((r) => ( +
+ {elementMap[r.ingredients[0]]?.emoji} + + + {elementMap[r.ingredients[1]]?.emoji} + + {elementMap[r.result]?.emoji} + {elementMap[r.result]?.name} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx new file mode 100644 index 0000000..83314a5 --- /dev/null +++ b/src/components/screens/ShopScreen.tsx @@ -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(null); + const [noGoldId, setNoGoldId] = useState(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 ( +
+

상점

+
+ 💰 + 보유 골드: {gold.toLocaleString()} +
+
+ {SHOP_ITEMS.map((item) => { + const remaining = getRemainingSeconds(item.id); + const isActive = remaining > 0; + const progress = (remaining / BOOST_DURATION_SEC) * 100; + + return ( +
+
{item.icon}
+
+

{item.name}

+ {isActive ? ( + <> +

✨ 활성 중 — {remaining}초 남음

+
+
+
+ + ) : ( +

{item.desc}

+ )} +
+ +
+ ); + })} +
+
+ ); +}