feat: 설정 화면 구현 (JSA-37)
- SettingsScreen 컴포넌트 추가 (언어선택, BGM 토글, 앱 버전, 게임 초기화) - 게임 데이터 초기화: 2단계 확인 다이얼로그 (localStorage 전체 삭제 후 리로드) - 언어 설정: 한국어/English 세그먼트 버튼 (language 상태 persist) - BGM 토글 스위치 (bgmEnabled 상태 persist) - 마지막 저장 시각 및 오프라인 보상 최대 24시간 안내 텍스트 - BottomTabBar에 ⚙️ 설정 탭 추가 - useGameStore에 TabName 'settings', Language 타입 및 관련 액션 추가 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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' && <EvolutionScreen />}
|
||||
{activeTab === 'fusion' && <FusionScreen />}
|
||||
{activeTab === 'shop' && <ShopScreen />}
|
||||
{activeTab === 'settings' && <SettingsScreen />}
|
||||
</div>
|
||||
<BottomTabBar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
</div>
|
||||
|
||||
71
src/components/BottomTabBar.tsx
Normal file
71
src/components/BottomTabBar.tsx
Normal file
@@ -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 (
|
||||
<nav css={containerStyle}>
|
||||
{TABS.map((tab) => (
|
||||
<button key={tab.key} css={tabItemStyle} onClick={() => onTabChange(tab.key)}>
|
||||
<span css={iconStyle}>{tab.icon}</span>
|
||||
<span css={labelStyle(activeTab === tab.key)}>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
344
src/components/screens/SettingsScreen.tsx
Normal file
344
src/components/screens/SettingsScreen.tsx
Normal file
@@ -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 (
|
||||
<div css={toggleContainerStyle} onClick={onToggle}>
|
||||
<button css={toggleTrackStyle(enabled)} aria-label="토글" />
|
||||
<div css={toggleThumbStyle(enabled)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ResetStep = 'idle' | 'confirm1' | 'confirm2';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { language, bgmEnabled, lastTickAt, setLanguage, setBgmEnabled, resetGame } = useGameStore();
|
||||
const [resetStep, setResetStep] = useState<ResetStep>('idle');
|
||||
|
||||
const lastSavedDate = new Date(lastTickAt);
|
||||
const lastSavedText = lastSavedDate.toLocaleString(language === 'ko' ? 'ko-KR' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const handleResetPress = () => setResetStep('confirm1');
|
||||
const handleConfirm1 = () => setResetStep('confirm2');
|
||||
const handleCancel = () => setResetStep('idle');
|
||||
const handleFinalConfirm = () => {
|
||||
setResetStep('idle');
|
||||
resetGame();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div css={containerStyle}>
|
||||
<h1 css={titleStyle}>설정</h1>
|
||||
|
||||
{/* 언어 설정 */}
|
||||
<div css={sectionStyle}>
|
||||
<p css={sectionTitleStyle}>언어</p>
|
||||
<div css={cardStyle}>
|
||||
<div css={rowStyle}>
|
||||
<span css={rowLabelStyle}>언어 선택</span>
|
||||
<div css={segmentContainerStyle}>
|
||||
{LANGUAGE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
css={segmentButtonStyle(language === opt.value)}
|
||||
onClick={() => setLanguage(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사운드 설정 */}
|
||||
<div css={sectionStyle}>
|
||||
<p css={sectionTitleStyle}>사운드</p>
|
||||
<div css={cardStyle}>
|
||||
<div css={rowStyle}>
|
||||
<span css={rowLabelStyle}>BGM</span>
|
||||
<Toggle enabled={bgmEnabled} onToggle={() => setBgmEnabled(!bgmEnabled)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 앱 정보 */}
|
||||
<div css={sectionStyle}>
|
||||
<p css={sectionTitleStyle}>앱 정보</p>
|
||||
<div css={cardStyle}>
|
||||
<div css={rowStyle}>
|
||||
<span css={rowLabelStyle}>앱 버전</span>
|
||||
<span css={rowValueStyle}>v{APP_VERSION}</span>
|
||||
</div>
|
||||
<div css={rowStyle}>
|
||||
<div>
|
||||
<div css={rowLabelStyle}>마지막 저장</div>
|
||||
<div css={offlineNoticeStyle}>오프라인 보상은 최대 {MAX_OFFLINE_HOURS}시간까지 적용됩니다</div>
|
||||
</div>
|
||||
<span css={rowValueStyle}>{lastSavedText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 게임 데이터 초기화 */}
|
||||
<div css={sectionStyle}>
|
||||
<p css={sectionTitleStyle}>데이터</p>
|
||||
<button css={resetButtonStyle} onClick={handleResetPress}>
|
||||
게임 데이터 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p css={versionStyle}>Archetype: FirstSpark v{APP_VERSION}</p>
|
||||
</div>
|
||||
|
||||
{/* 1단계 확인 모달 */}
|
||||
{resetStep === 'confirm1' && (
|
||||
<div css={overlayStyle} onClick={handleCancel}>
|
||||
<div css={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<p css={modalTitleStyle}>게임 데이터를 초기화할까요?</p>
|
||||
<p css={modalDescStyle}>
|
||||
모든 원소, 골드, 강화 레벨이 삭제됩니다.{'\n'}이 작업은 되돌릴 수 없습니다.
|
||||
</p>
|
||||
<div css={modalButtonsStyle}>
|
||||
<button css={modalConfirmStyle} onClick={handleConfirm1}>
|
||||
계속하기
|
||||
</button>
|
||||
<button css={modalCancelStyle} onClick={handleCancel}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2단계 최종 확인 모달 */}
|
||||
{resetStep === 'confirm2' && (
|
||||
<div css={overlayStyle} onClick={handleCancel}>
|
||||
<div css={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<p css={modalTitleStyle}>정말로 초기화하시겠습니까?</p>
|
||||
<p css={modalDescStyle}>
|
||||
예, 모두 삭제합니다{'\n'}앱이 재시작되고 처음부터 다시 시작됩니다.
|
||||
</p>
|
||||
<div css={modalButtonsStyle}>
|
||||
<button css={modalConfirmStyle} onClick={handleFinalConfirm}>
|
||||
예, 모두 삭제합니다
|
||||
</button>
|
||||
<button css={modalCancelStyle} onClick={handleCancel}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
fire: 5,
|
||||
@@ -131,11 +132,26 @@ interface GameState {
|
||||
// 상점 버프 시스템 (boostId -> 만료 시각 ms)
|
||||
activeBoosts: Record<string, number>;
|
||||
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<GameState>()(
|
||||
@@ -219,6 +235,17 @@ export const useGameStore = create<GameState>()(
|
||||
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<GameState>()(
|
||||
lastTickAt: state.lastTickAt,
|
||||
activeBoosts: state.activeBoosts,
|
||||
spawnAccumulators: state.spawnAccumulators,
|
||||
language: state.language,
|
||||
bgmEnabled: state.bgmEnabled,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// 앱 재시작 시 localStorage에서 복원된 만료 부스트를 자동 정리
|
||||
|
||||
Reference in New Issue
Block a user