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:
2026-04-01 00:41:36 +09:00
parent e1cc6b2ea8
commit c9ab987e02
4 changed files with 448 additions and 2 deletions

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

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

View File

@@ -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에서 복원된 만료 부스트를 자동 정리