diff --git a/pages/index.tsx b/pages/index.tsx index f2b621f..c2e0061 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -6,6 +6,7 @@ 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 { SettingsScreen } from '../src/components/screens/SettingsScreen'; import { useIdleTick } from '../src/hooks/useIdleTick'; import { useGameStore } from '../src/store/useGameStore'; import { trackGameEvent } from '../src/analytics'; @@ -54,6 +55,7 @@ export default function IndexPage() { {activeTab === 'evolution' && } {activeTab === 'fusion' && } {activeTab === 'shop' && } + {activeTab === 'settings' && } diff --git a/src/components/BottomTabBar.tsx b/src/components/BottomTabBar.tsx new file mode 100644 index 0000000..84acef2 --- /dev/null +++ b/src/components/BottomTabBar.tsx @@ -0,0 +1,71 @@ +import { css } from '@emotion/react'; +import type { TabName } from '../store/useGameStore'; + +interface TabItem { + key: TabName; + label: string; + icon: string; +} + +const TABS: TabItem[] = [ + { key: 'elements', label: '원소', icon: '⚗️' }, + { key: 'evolution', label: '강화', icon: '⬆️' }, + { key: 'fusion', label: '합성', icon: '✨' }, + { key: 'shop', label: '상점', icon: '🛒' }, + { key: 'settings', label: '설정', icon: '⚙️' }, +]; + +interface BottomTabBarProps { + activeTab: TabName; + onTabChange: (tab: TabName) => void; +} + +const containerStyle = css` + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + background-color: #ffffff; + border-top: 1px solid #e8e8e8; + padding-bottom: env(safe-area-inset-bottom, 0px); + z-index: 100; +`; + +const tabItemStyle = css` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 0 10px; + cursor: pointer; + background: none; + border: none; + gap: 4px; +`; + +const iconStyle = css` + font-size: 22px; + line-height: 1; +`; + +const labelStyle = (active: boolean) => css` + font-size: 10px; + font-weight: ${active ? 600 : 400}; + color: ${active ? '#3182F6' : '#8b8b8b'}; + letter-spacing: -0.2px; +`; + +export function BottomTabBar({ activeTab, onTabChange }: BottomTabBarProps) { + return ( + + ); +} diff --git a/src/components/screens/SettingsScreen.tsx b/src/components/screens/SettingsScreen.tsx new file mode 100644 index 0000000..9d55836 --- /dev/null +++ b/src/components/screens/SettingsScreen.tsx @@ -0,0 +1,344 @@ +import { css } from '@emotion/react'; +import { useState } from 'react'; +import { adaptive } from '@toss/tds-colors'; +import { useGameStore, type Language } from '../../store/useGameStore'; + +const APP_VERSION = '1.0.0'; +const MAX_OFFLINE_HOURS = 24; + +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 24px; +`; + +const sectionStyle = css` + margin-bottom: 24px; +`; + +const sectionTitleStyle = css` + font-size: 12px; + font-weight: 600; + color: ${adaptive.grey500}; + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 10px; +`; + +const cardStyle = css` + background: ${adaptive.background}; + border: 1px solid ${adaptive.grey200}; + border-radius: 16px; + overflow: hidden; +`; + +const rowStyle = css` + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid ${adaptive.grey100}; + &:last-child { + border-bottom: none; + } +`; + +const rowLabelStyle = css` + font-size: 15px; + font-weight: 500; + color: ${adaptive.grey900}; +`; + +const rowValueStyle = css` + font-size: 14px; + color: ${adaptive.grey500}; +`; + +const segmentContainerStyle = css` + display: flex; + gap: 6px; +`; + +const segmentButtonStyle = (active: boolean) => css` + padding: 6px 14px; + border-radius: 8px; + border: 1.5px solid ${active ? adaptive.blue500 : adaptive.grey200}; + background: ${active ? adaptive.blue500 : 'transparent'}; + color: ${active ? '#ffffff' : adaptive.grey700}; + font-size: 13px; + font-weight: ${active ? 600 : 400}; + cursor: pointer; + transition: all 0.15s; +`; + +const toggleContainerStyle = css` + position: relative; + width: 48px; + height: 28px; + cursor: pointer; +`; + +const toggleTrackStyle = (enabled: boolean) => css` + width: 100%; + height: 100%; + border-radius: 14px; + background: ${enabled ? adaptive.blue500 : adaptive.grey300}; + transition: background 0.2s; + border: none; + cursor: pointer; +`; + +const toggleThumbStyle = (enabled: boolean) => css` + position: absolute; + top: 3px; + left: ${enabled ? '23px' : '3px'}; + width: 22px; + height: 22px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); + transition: left 0.2s; + pointer-events: none; +`; + +const resetButtonStyle = css` + width: 100%; + padding: 14px; + border-radius: 12px; + border: 1.5px solid #ef5350; + background: transparent; + color: #ef5350; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + &:active { + background: rgba(239, 83, 80, 0.08); + } +`; + +const versionStyle = css` + font-size: 13px; + color: ${adaptive.grey400}; + text-align: center; + margin-top: 32px; +`; + +const offlineNoticeStyle = css` + font-size: 12px; + color: ${adaptive.grey400}; + margin: 4px 0 0; +`; + +// 2단계 확인 모달 +const overlayStyle = css` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 300; +`; + +const modalStyle = css` + background: ${adaptive.background}; + border-radius: 20px 20px 0 0; + padding: 28px 24px calc(28px + env(safe-area-inset-bottom, 0px)); + width: 100%; + max-width: 480px; +`; + +const modalTitleStyle = css` + font-size: 18px; + font-weight: 700; + color: ${adaptive.grey900}; + margin: 0 0 8px; +`; + +const modalDescStyle = css` + font-size: 14px; + color: ${adaptive.grey500}; + margin: 0 0 24px; + line-height: 1.5; +`; + +const modalButtonsStyle = css` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const modalConfirmStyle = css` + padding: 15px; + border-radius: 12px; + border: none; + background: #ef5350; + color: #ffffff; + font-size: 16px; + font-weight: 700; + cursor: pointer; +`; + +const modalCancelStyle = css` + padding: 15px; + border-radius: 12px; + border: 1px solid ${adaptive.grey200}; + background: transparent; + color: ${adaptive.grey700}; + font-size: 16px; + font-weight: 500; + cursor: pointer; +`; + +const LANGUAGE_OPTIONS: { value: Language; label: string }[] = [ + { value: 'ko', label: '한국어' }, + { value: 'en', label: 'English' }, +]; + +function Toggle({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) { + return ( +
+ + ))} +
+ + + + + {/* 사운드 설정 */} +
+

사운드

+
+
+ BGM + setBgmEnabled(!bgmEnabled)} /> +
+
+
+ + {/* 앱 정보 */} +
+

앱 정보

+
+
+ 앱 버전 + v{APP_VERSION} +
+
+
+
마지막 저장
+
오프라인 보상은 최대 {MAX_OFFLINE_HOURS}시간까지 적용됩니다
+
+ {lastSavedText} +
+
+
+ + {/* 게임 데이터 초기화 */} +
+

데이터

+ +
+ +

Archetype: FirstSpark v{APP_VERSION}

+ + + {/* 1단계 확인 모달 */} + {resetStep === 'confirm1' && ( +
+
e.stopPropagation()}> +

게임 데이터를 초기화할까요?

+

+ 모든 원소, 골드, 강화 레벨이 삭제됩니다.{'\n'}이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+ )} + + {/* 2단계 최종 확인 모달 */} + {resetStep === 'confirm2' && ( +
+
e.stopPropagation()}> +

정말로 초기화하시겠습니까?

+

+ 예, 모두 삭제합니다{'\n'}앱이 재시작되고 처음부터 다시 시작됩니다. +

+
+ + +
+
+
+ )} + + ); +} diff --git a/src/store/useGameStore.ts b/src/store/useGameStore.ts index f9357ef..682a246 100644 --- a/src/store/useGameStore.ts +++ b/src/store/useGameStore.ts @@ -41,7 +41,8 @@ const throttledStorage = createJSONStorage(() => ({ removeItem: (name: string): void => localStorage.removeItem(name), })); -export type TabName = 'elements' | 'evolution' | 'fusion' | 'shop'; +export type TabName = 'elements' | 'evolution' | 'fusion' | 'shop' | 'settings'; +export type Language = 'ko' | 'en'; const INITIAL_ELEMENTS: Record = { fire: 5, @@ -131,11 +132,26 @@ interface GameState { // 상점 버프 시스템 (boostId -> 만료 시각 ms) activeBoosts: Record; activateBoost: (boostId: string, durationSec: number) => void; + + // 설정 + language: Language; + bgmEnabled: boolean; + setLanguage: (lang: Language) => void; + setBgmEnabled: (enabled: boolean) => void; + resetGame: () => void; } type PersistedState = Pick< GameState, - 'activeTab' | 'elements' | 'gold' | 'elementLevels' | 'lastTickAt' | 'activeBoosts' | 'spawnAccumulators' + | 'activeTab' + | 'elements' + | 'gold' + | 'elementLevels' + | 'lastTickAt' + | 'activeBoosts' + | 'spawnAccumulators' + | 'language' + | 'bgmEnabled' >; export const useGameStore = create()( @@ -219,6 +235,17 @@ export const useGameStore = create()( return { success: true, resultId: recipe.result, goldGained }; }, + // 설정 + language: 'ko', + bgmEnabled: false, + setLanguage: (lang) => set({ language: lang }), + setBgmEnabled: (enabled) => set({ bgmEnabled: enabled }), + resetGame: () => { + flushGameState(); + localStorage.removeItem('archetype-game-state'); + window.location.reload(); + }, + // 상점 버프 시스템 activateBoost: (boostId, durationSec) => { set((state) => ({ @@ -336,6 +363,8 @@ export const useGameStore = create()( lastTickAt: state.lastTickAt, activeBoosts: state.activeBoosts, spawnAccumulators: state.spawnAccumulators, + language: state.language, + bgmEnabled: state.bgmEnabled, }), onRehydrateStorage: () => (state) => { // 앱 재시작 시 localStorage에서 복원된 만료 부스트를 자동 정리