feat(front): 프론트 화면 개선 및 설정 저장소 추가
- front_screen_animation.dart: 프론트 화면 애니메이션 추가 - settings_repository.dart: 설정 저장소 구현 - front/widgets/: 프론트 화면 위젯 분리 - mobile_carousel_layout.dart: 모바일 레이아웃 개선 - app.dart: 앱 설정 개선 - game_text_l10n.dart: 텍스트 추가
This commit is contained in:
@@ -1659,3 +1659,62 @@ String get buttonCancel {
|
|||||||
if (isJapaneseLocale) return 'キャンセル';
|
if (isJapaneseLocale) return 'キャンセル';
|
||||||
return 'Cancel';
|
return 'Cancel';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 프론트 화면 경고/푸터 텍스트
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
String get uiWarning {
|
||||||
|
if (isKoreanLocale) return '경고';
|
||||||
|
if (isJapaneseLocale) return '警告';
|
||||||
|
return 'Warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get warningDeleteSave {
|
||||||
|
if (isKoreanLocale) return '기존 저장 파일이 삭제됩니다. 계속하시겠습니까?';
|
||||||
|
if (isJapaneseLocale) return '既存のセーブファイルが削除されます。続行しますか?';
|
||||||
|
return 'Existing save file will be deleted. Continue?';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get copyrightText {
|
||||||
|
// 카피라이트 텍스트는 언어에 따라 변하지 않음
|
||||||
|
return '© 2025 naturebridgeai & cclabs all rights reserved';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 테마 설정 텍스트
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
String get menuTheme {
|
||||||
|
if (isKoreanLocale) return '테마';
|
||||||
|
if (isJapaneseLocale) return 'テーマ';
|
||||||
|
return 'Theme';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get themeLight {
|
||||||
|
if (isKoreanLocale) return '라이트';
|
||||||
|
if (isJapaneseLocale) return 'ライト';
|
||||||
|
return 'Light';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get themeDark {
|
||||||
|
if (isKoreanLocale) return '다크';
|
||||||
|
if (isJapaneseLocale) return 'ダーク';
|
||||||
|
return 'Dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get themeSystem {
|
||||||
|
if (isKoreanLocale) return '시스템';
|
||||||
|
if (isJapaneseLocale) return 'システム';
|
||||||
|
return 'System';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 로딩 텍스트
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
String get uiLoading {
|
||||||
|
if (isKoreanLocale) return '불러오는 중...';
|
||||||
|
if (isJapaneseLocale) return '読み込み中...';
|
||||||
|
return 'Loading...';
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:askiineverdie/src/core/model/pq_config.dart';
|
|||||||
import 'package:askiineverdie/src/core/notification/notification_service.dart';
|
import 'package:askiineverdie/src/core/notification/notification_service.dart';
|
||||||
import 'package:askiineverdie/src/core/storage/save_manager.dart';
|
import 'package:askiineverdie/src/core/storage/save_manager.dart';
|
||||||
import 'package:askiineverdie/src/core/storage/save_repository.dart';
|
import 'package:askiineverdie/src/core/storage/save_repository.dart';
|
||||||
|
import 'package:askiineverdie/src/core/storage/settings_repository.dart';
|
||||||
import 'package:askiineverdie/src/features/front/front_screen.dart';
|
import 'package:askiineverdie/src/features/front/front_screen.dart';
|
||||||
import 'package:askiineverdie/src/features/front/save_picker_dialog.dart';
|
import 'package:askiineverdie/src/features/front/save_picker_dialog.dart';
|
||||||
import 'package:askiineverdie/src/features/game/game_play_screen.dart';
|
import 'package:askiineverdie/src/features/game/game_play_screen.dart';
|
||||||
@@ -28,8 +29,10 @@ class AskiiNeverDieApp extends StatefulWidget {
|
|||||||
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||||
late final GameSessionController _controller;
|
late final GameSessionController _controller;
|
||||||
late final NotificationService _notificationService;
|
late final NotificationService _notificationService;
|
||||||
|
late final SettingsRepository _settingsRepository;
|
||||||
bool _isCheckingSave = true;
|
bool _isCheckingSave = true;
|
||||||
bool _hasSave = false;
|
bool _hasSave = false;
|
||||||
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -47,11 +50,28 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
saveManager: SaveManager(SaveRepository()),
|
saveManager: SaveManager(SaveRepository()),
|
||||||
);
|
);
|
||||||
_notificationService = NotificationService();
|
_notificationService = NotificationService();
|
||||||
|
_settingsRepository = SettingsRepository();
|
||||||
|
|
||||||
|
// 초기 설정 로드
|
||||||
|
_loadSettings();
|
||||||
// 세이브 파일 존재 여부 확인
|
// 세이브 파일 존재 여부 확인
|
||||||
_checkForExistingSave();
|
_checkForExistingSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 저장된 설정 불러오기
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
final themeMode = await _settingsRepository.loadThemeMode();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _themeMode = themeMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 모드 변경
|
||||||
|
void _changeThemeMode(ThemeMode mode) {
|
||||||
|
setState(() => _themeMode = mode);
|
||||||
|
_settingsRepository.saveThemeMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
/// 세이브 파일 존재 여부 확인 후 자동 로드
|
/// 세이브 파일 존재 여부 확인 후 자동 로드
|
||||||
Future<void> _checkForExistingSave() async {
|
Future<void> _checkForExistingSave() async {
|
||||||
final exists = await _controller.saveManager.saveExists();
|
final exists = await _controller.saveManager.saveExists();
|
||||||
@@ -70,6 +90,32 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 라이트 테마
|
||||||
|
ThemeData get _lightTheme => ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFFF4F5F7),
|
||||||
|
useMaterial3: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 다크 테마 (OLED 저전력 모드 - 순수 검정)
|
||||||
|
ThemeData get _darkTheme => ThemeData(
|
||||||
|
colorScheme: ColorScheme.dark(
|
||||||
|
surface: Colors.black,
|
||||||
|
primary: const Color(0xFF4FC3F7), // 시안
|
||||||
|
secondary: const Color(0xFFFF4081), // 마젠타
|
||||||
|
onSurface: Colors.white70,
|
||||||
|
primaryContainer: const Color(0xFF1A3A4A),
|
||||||
|
onPrimaryContainer: Colors.white,
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: Colors.black,
|
||||||
|
useMaterial3: true,
|
||||||
|
// 카드/다이얼로그도 검정 배경 사용
|
||||||
|
cardColor: const Color(0xFF121212),
|
||||||
|
dialogTheme: const DialogThemeData(
|
||||||
|
backgroundColor: Color(0xFF121212),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
@@ -77,11 +123,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
localizationsDelegates: L10n.localizationsDelegates,
|
localizationsDelegates: L10n.localizationsDelegates,
|
||||||
supportedLocales: L10n.supportedLocales,
|
supportedLocales: L10n.supportedLocales,
|
||||||
theme: ThemeData(
|
theme: _lightTheme,
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)),
|
darkTheme: _darkTheme,
|
||||||
scaffoldBackgroundColor: const Color(0xFFF4F5F7),
|
themeMode: _themeMode,
|
||||||
useMaterial3: true,
|
|
||||||
),
|
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
||||||
final locale = Localizations.localeOf(context);
|
final locale = Localizations.localeOf(context);
|
||||||
@@ -110,6 +154,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
// 로드 실패 시 프론트 화면으로
|
// 로드 실패 시 프론트 화면으로
|
||||||
setState(() => _hasSave = false);
|
setState(() => _hasSave = false);
|
||||||
},
|
},
|
||||||
|
currentThemeMode: _themeMode,
|
||||||
|
onThemeModeChange: _changeThemeMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +164,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
onNewCharacter: _navigateToNewCharacter,
|
onNewCharacter: _navigateToNewCharacter,
|
||||||
onLoadSave: _loadSave,
|
onLoadSave: _loadSave,
|
||||||
onHallOfFame: _navigateToHallOfFame,
|
onHallOfFame: _navigateToHallOfFame,
|
||||||
|
hasSaveFile: _hasSave,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +234,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
builder: (context) => GamePlayScreen(
|
builder: (context) => GamePlayScreen(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
forceCarouselLayout: testMode,
|
forceCarouselLayout: testMode,
|
||||||
|
currentThemeMode: _themeMode,
|
||||||
|
onThemeModeChange: _changeThemeMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -196,7 +245,11 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
void _navigateToGame(BuildContext context) {
|
void _navigateToGame(BuildContext context) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => GamePlayScreen(controller: _controller),
|
builder: (context) => GamePlayScreen(
|
||||||
|
controller: _controller,
|
||||||
|
currentThemeMode: _themeMode,
|
||||||
|
onThemeModeChange: _changeThemeMode,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -235,10 +288,17 @@ class _SplashScreen extends StatelessWidget {
|
|||||||
|
|
||||||
/// 자동 로드 화면 (세이브 파일 자동 로드)
|
/// 자동 로드 화면 (세이브 파일 자동 로드)
|
||||||
class _AutoLoadScreen extends StatefulWidget {
|
class _AutoLoadScreen extends StatefulWidget {
|
||||||
const _AutoLoadScreen({required this.controller, required this.onLoadFailed});
|
const _AutoLoadScreen({
|
||||||
|
required this.controller,
|
||||||
|
required this.onLoadFailed,
|
||||||
|
required this.currentThemeMode,
|
||||||
|
required this.onThemeModeChange,
|
||||||
|
});
|
||||||
|
|
||||||
final GameSessionController controller;
|
final GameSessionController controller;
|
||||||
final VoidCallback onLoadFailed;
|
final VoidCallback onLoadFailed;
|
||||||
|
final ThemeMode currentThemeMode;
|
||||||
|
final void Function(ThemeMode mode) onThemeModeChange;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_AutoLoadScreen> createState() => _AutoLoadScreenState();
|
State<_AutoLoadScreen> createState() => _AutoLoadScreenState();
|
||||||
@@ -262,7 +322,8 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> {
|
|||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => GamePlayScreen(
|
builder: (context) => GamePlayScreen(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
// 자동 로드 시에는 플랫폼 기본값 사용 (모바일만 캐로셀)
|
currentThemeMode: widget.currentThemeMode,
|
||||||
|
onThemeModeChange: widget.onThemeModeChange,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -274,19 +335,19 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const Text(
|
||||||
'ASCII NEVER DIE',
|
'ASCII NEVER DIE',
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
CircularProgressIndicator(),
|
const CircularProgressIndicator(),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Loading...'),
|
Text(game_l10n.uiLoading),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
89
lib/src/core/animation/front_screen_animation.dart
Normal file
89
lib/src/core/animation/front_screen_animation.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 데이터
|
||||||
|
// 작은 용사가 거대한 Glitch God에 맞서는 장면을 표현
|
||||||
|
|
||||||
|
/// 애니메이션 프레임 (10줄, 6프레임 루프)
|
||||||
|
const frontScreenAnimationFrames = [
|
||||||
|
// 프레임 0: 대치 상태 (방패 들고 대기)
|
||||||
|
'''
|
||||||
|
░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░
|
||||||
|
░▓▓ G L I T C H ▓▓░
|
||||||
|
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
|
||||||
|
░▓▓▓ ◈◈ ◈◈ ▓▓▓░
|
||||||
|
░▓▓▓▓ ▼▼▼ ▓▓▓▓░
|
||||||
|
░▓▓▓▓▓ ████████ ▓▓▓▓▓░
|
||||||
|
o ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
|
||||||
|
[]|- ░▓▓▓ G O D ▓▓▓░
|
||||||
|
/ \\ ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
|
||||||
|
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
|
||||||
|
|
||||||
|
// 프레임 1: 용사 전진 (방패 앞으로)
|
||||||
|
'''
|
||||||
|
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
|
▓▓ G L I T C H ▓▓
|
||||||
|
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
|
▓▓▓▓ ◉◉ ◉◉ ▓▓▓▓
|
||||||
|
▓▓▓▓▓ ~~~ ▓▓▓▓▓
|
||||||
|
o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓
|
||||||
|
[]|> ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
|
/ \\ ▓▓▓▓ G O D ▓▓▓▓
|
||||||
|
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
|
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
|
||||||
|
|
||||||
|
// 프레임 2: 용사 공격 준비 (방패 방어 자세)
|
||||||
|
'''
|
||||||
|
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
|
||||||
|
▒▓ G L I T C H ▓▒
|
||||||
|
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
|
||||||
|
▒▓▓▓ ◈◈ ◈◈ ▓▓▓▒
|
||||||
|
▒▓▓▓▓ ▼▼▼ ▓▓▓▓▒
|
||||||
|
o\\ ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒
|
||||||
|
[]=|== ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
|
||||||
|
/ \\ ▒▓▓▓ G O D ▓▓▓▒
|
||||||
|
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
|
||||||
|
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
|
||||||
|
|
||||||
|
// 프레임 3: 용사 공격 (글리치 갓 데미지)
|
||||||
|
'''
|
||||||
|
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
|
||||||
|
░▓ G#L@I*T&C!H ▓░
|
||||||
|
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
|
||||||
|
░▓▓▓ X X X X ▓▓▓░
|
||||||
|
o\\ ░▓▓▓▓ !!! ▓▓▓▓░
|
||||||
|
[]=|===> ░▓▓▓▓▓ ████████ ▓▓▓▓▓░
|
||||||
|
/ \\ ░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
|
||||||
|
░▓▓▓ G O D ▓▓▓░
|
||||||
|
░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░
|
||||||
|
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
|
||||||
|
|
||||||
|
// 프레임 4: 글리치 갓 반격 준비 (방패로 방어)
|
||||||
|
'''
|
||||||
|
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
|
▓▓ G L I T C H ▓▓
|
||||||
|
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
|
▓▓▓▓ @@ @@ ▓▓▓▓
|
||||||
|
▓▓▓▓▓ <=== ▓▓▓▓▓
|
||||||
|
[]o ▓▓▓▓▓▓ ████████ ▓▓▓▓▓▓
|
||||||
|
|\\ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
|
/ \\ ▓▓▓▓ G O D ▓▓▓▓
|
||||||
|
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
|
||||||
|
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
|
||||||
|
|
||||||
|
// 프레임 5: 글리치 갓 공격 (용사 방패로 막기)
|
||||||
|
'''
|
||||||
|
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
|
||||||
|
▒▓ G L I T C H ▓▒
|
||||||
|
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
|
||||||
|
▒▓▓▓ ◉◉ ◉◉ ▓▓▓▒
|
||||||
|
▒▓▓▓▓ <====== ▓▓▓▓▒
|
||||||
|
[]o * ▒▓▓▓▓▓ ████████ ▓▓▓▓▓▒
|
||||||
|
|/ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
|
||||||
|
|\\ ▒▓▓▓ G O D ▓▓▓▒
|
||||||
|
▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
|
||||||
|
~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~''',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 애니메이션 프레임 간격 (밀리초)
|
||||||
|
const frontScreenAnimationIntervalMs = 400;
|
||||||
|
|
||||||
|
/// 애니메이션 총 프레임 수
|
||||||
|
const frontScreenAnimationFrameCount = 6;
|
||||||
52
lib/src/core/storage/settings_repository.dart
Normal file
52
lib/src/core/storage/settings_repository.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// 앱 설정 저장소 (SharedPreferences 기반)
|
||||||
|
///
|
||||||
|
/// 테마, 언어 등 사용자 설정을 로컬에 저장
|
||||||
|
class SettingsRepository {
|
||||||
|
static const _keyThemeMode = 'theme_mode';
|
||||||
|
static const _keyLocale = 'locale';
|
||||||
|
|
||||||
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
/// SharedPreferences 초기화
|
||||||
|
Future<void> init() async {
|
||||||
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 모드 저장
|
||||||
|
Future<void> saveThemeMode(ThemeMode mode) async {
|
||||||
|
await init();
|
||||||
|
final value = switch (mode) {
|
||||||
|
ThemeMode.light => 'light',
|
||||||
|
ThemeMode.dark => 'dark',
|
||||||
|
ThemeMode.system => 'system',
|
||||||
|
};
|
||||||
|
await _prefs!.setString(_keyThemeMode, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 모드 불러오기
|
||||||
|
Future<ThemeMode> loadThemeMode() async {
|
||||||
|
await init();
|
||||||
|
final value = _prefs!.getString(_keyThemeMode);
|
||||||
|
return switch (value) {
|
||||||
|
'light' => ThemeMode.light,
|
||||||
|
'dark' => ThemeMode.dark,
|
||||||
|
'system' => ThemeMode.system,
|
||||||
|
_ => ThemeMode.system, // 기본값
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 언어 설정 저장
|
||||||
|
Future<void> saveLocale(String locale) async {
|
||||||
|
await init();
|
||||||
|
await _prefs!.setString(_keyLocale, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 언어 설정 불러오기
|
||||||
|
Future<String?> loadLocale() async {
|
||||||
|
await init();
|
||||||
|
return _prefs!.getString(_keyLocale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n;
|
import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:askiineverdie/l10n/app_localizations.dart';
|
import 'package:askiineverdie/l10n/app_localizations.dart';
|
||||||
|
import 'package:askiineverdie/src/features/front/widgets/hero_vs_boss_animation.dart';
|
||||||
|
|
||||||
class FrontScreen extends StatelessWidget {
|
class FrontScreen extends StatelessWidget {
|
||||||
const FrontScreen({
|
const FrontScreen({
|
||||||
@@ -9,6 +10,7 @@ class FrontScreen extends StatelessWidget {
|
|||||||
this.onNewCharacter,
|
this.onNewCharacter,
|
||||||
this.onLoadSave,
|
this.onLoadSave,
|
||||||
this.onHallOfFame,
|
this.onHallOfFame,
|
||||||
|
this.hasSaveFile = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// "New character" 버튼 클릭 시 호출
|
/// "New character" 버튼 클릭 시 호출
|
||||||
@@ -17,9 +19,45 @@ class FrontScreen extends StatelessWidget {
|
|||||||
/// "Load save" 버튼 클릭 시 호출
|
/// "Load save" 버튼 클릭 시 호출
|
||||||
final Future<void> Function(BuildContext context)? onLoadSave;
|
final Future<void> Function(BuildContext context)? onLoadSave;
|
||||||
|
|
||||||
/// "Hall of Fame" 버튼 클릭 시 호출 (Phase 10)
|
/// "Hall of Fame" 버튼 클릭 시 호출
|
||||||
final void Function(BuildContext context)? onHallOfFame;
|
final void Function(BuildContext context)? onHallOfFame;
|
||||||
|
|
||||||
|
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
|
||||||
|
final bool hasSaveFile;
|
||||||
|
|
||||||
|
/// 새 캐릭터 생성 시 세이브 파일 존재하면 경고 표시
|
||||||
|
void _handleNewCharacter(BuildContext context) {
|
||||||
|
if (hasSaveFile) {
|
||||||
|
_showDeleteWarningDialog(context);
|
||||||
|
} else {
|
||||||
|
onNewCharacter?.call(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세이브 삭제 경고 다이얼로그
|
||||||
|
void _showDeleteWarningDialog(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(game_l10n.uiWarning),
|
||||||
|
content: Text(game_l10n.warningDeleteSave),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: Text(game_l10n.buttonCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
onNewCharacter?.call(context);
|
||||||
|
},
|
||||||
|
child: Text(game_l10n.buttonConfirm),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -35,6 +73,10 @@ class FrontScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 스크롤 영역 (헤더, 애니메이션, 버튼)
|
||||||
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 960),
|
constraints: const BoxConstraints(maxWidth: 960),
|
||||||
@@ -44,41 +86,37 @@ class FrontScreen extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_HeroHeader(theme: theme, colorScheme: colorScheme),
|
_HeroHeader(theme: theme, colorScheme: colorScheme),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const HeroVsBossAnimation(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_ActionRow(
|
_ActionButtons(
|
||||||
onNewCharacter: onNewCharacter != null
|
onNewCharacter: onNewCharacter != null
|
||||||
? () => onNewCharacter!(context)
|
? () => _handleNewCharacter(context)
|
||||||
: () => _showPlaceholder(context),
|
: null,
|
||||||
onLoadSave: onLoadSave != null
|
onLoadSave: onLoadSave != null
|
||||||
? () => onLoadSave!(context)
|
? () => onLoadSave!(context)
|
||||||
: () => _showPlaceholder(context),
|
: null,
|
||||||
onHallOfFame: onHallOfFame != null
|
onHallOfFame: onHallOfFame != null
|
||||||
? () => onHallOfFame!(context)
|
? () => onHallOfFame!(context)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
const _StatusCards(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 카피라이트 푸터 (하단 고정)
|
||||||
|
const _CopyrightFooter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPlaceholder(BuildContext context) {
|
/// 헤더 (타이틀 + 태그) - 중앙 정렬
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Core gameplay loop is coming next. See doc/progress-quest-flutter-plan.md for milestones.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HeroHeader extends StatelessWidget {
|
class _HeroHeader extends StatelessWidget {
|
||||||
const _HeroHeader({required this.theme, required this.colorScheme});
|
const _HeroHeader({required this.theme, required this.colorScheme});
|
||||||
|
|
||||||
@@ -109,17 +147,15 @@ class _HeroHeader extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
// 타이틀 (중앙 정렬)
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.auto_awesome, color: colorScheme.onPrimary),
|
Icon(Icons.auto_awesome, color: colorScheme.onPrimary),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
Text(
|
||||||
L10n.of(context).appTitle,
|
L10n.of(context).appTitle,
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
@@ -127,23 +163,15 @@ class _HeroHeader extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
game_l10n.frontDescription,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onPrimary.withValues(alpha: 0.9),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
// 태그 (중앙 정렬)
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final l10n = L10n.of(context);
|
final l10n = L10n.of(context);
|
||||||
return Wrap(
|
return Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
@@ -167,15 +195,16 @@ class _HeroHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActionRow extends StatelessWidget {
|
/// 액션 버튼 (세로 배치)
|
||||||
const _ActionRow({
|
class _ActionButtons extends StatelessWidget {
|
||||||
required this.onNewCharacter,
|
const _ActionButtons({
|
||||||
required this.onLoadSave,
|
this.onNewCharacter,
|
||||||
|
this.onLoadSave,
|
||||||
this.onHallOfFame,
|
this.onHallOfFame,
|
||||||
});
|
});
|
||||||
|
|
||||||
final VoidCallback onNewCharacter;
|
final VoidCallback? onNewCharacter;
|
||||||
final VoidCallback onLoadSave;
|
final VoidCallback? onLoadSave;
|
||||||
final VoidCallback? onHallOfFame;
|
final VoidCallback? onHallOfFame;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -183,148 +212,70 @@ class _ActionRow extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l10n = L10n.of(context);
|
final l10n = L10n.of(context);
|
||||||
|
|
||||||
return Wrap(
|
return Column(
|
||||||
spacing: 12,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
runSpacing: 12,
|
|
||||||
children: [
|
children: [
|
||||||
|
// 새 캐릭터 (Primary)
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: onNewCharacter,
|
onPressed: onNewCharacter,
|
||||||
icon: const Icon(Icons.casino_outlined),
|
icon: const Icon(Icons.casino_outlined),
|
||||||
label: Text(l10n.newCharacter),
|
label: Text(l10n.newCharacter),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
textStyle: theme.textTheme.titleMedium,
|
textStyle: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 불러오기 (Secondary)
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: onLoadSave,
|
onPressed: onLoadSave,
|
||||||
icon: const Icon(Icons.folder_open),
|
icon: const Icon(Icons.folder_open),
|
||||||
label: Text(l10n.loadSave),
|
label: Text(l10n.loadSave),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
textStyle: theme.textTheme.titleMedium,
|
textStyle: theme.textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
const SizedBox(height: 12),
|
||||||
onPressed: () => _showPlaceholder(context),
|
// 명예의 전당 (Tertiary)
|
||||||
icon: const Icon(Icons.menu_book_outlined),
|
|
||||||
label: Text(l10n.viewBuildPlan),
|
|
||||||
),
|
|
||||||
// Phase 10: 명예의 전당 버튼
|
|
||||||
if (onHallOfFame != null)
|
if (onHallOfFame != null)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: onHallOfFame,
|
onPressed: onHallOfFame,
|
||||||
icon: const Icon(Icons.emoji_events_outlined),
|
icon: const Icon(Icons.emoji_events_outlined),
|
||||||
label: Text(game_l10n.uiHallOfFame),
|
label: Text(game_l10n.uiHallOfFame),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
textStyle: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatusCards extends StatelessWidget {
|
/// 카피라이트 푸터
|
||||||
const _StatusCards();
|
class _CopyrightFooter extends StatelessWidget {
|
||||||
|
const _CopyrightFooter();
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = L10n.of(context);
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
_InfoCard(
|
|
||||||
icon: Icons.route_outlined,
|
|
||||||
title: l10n.buildRoadmap,
|
|
||||||
points: [
|
|
||||||
'Port PQ 6.4 data set (Config.dfm) into Dart constants.',
|
|
||||||
'Recreate quest/task loop with deterministic RNG + saves.',
|
|
||||||
'Deliver offline-first storage (GZip JSON) across platforms.',
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
_InfoCard(
|
|
||||||
icon: Icons.auto_fix_high_outlined,
|
|
||||||
title: l10n.techStack,
|
|
||||||
points: [
|
|
||||||
'Flutter (Material 3) with multiplatform targets enabled.',
|
|
||||||
'path_provider + shared_preferences for local storage hooks.',
|
|
||||||
'Strict lints with package imports enforced from day one.',
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
_InfoCard(
|
|
||||||
icon: Icons.checklist_rtl,
|
|
||||||
title: game_l10n.frontTodayFocus,
|
|
||||||
points: [
|
|
||||||
'Set up scaffold + lints.',
|
|
||||||
'Wire seed theme and initial navigation shell.',
|
|
||||||
'Keep reference assets under example/pq for parity.',
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoCard extends StatelessWidget {
|
|
||||||
const _InfoCard({required this.title, required this.points, this.icon});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
final List<String> points;
|
|
||||||
final IconData? icon;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
return Card(
|
child: Text(
|
||||||
elevation: 3,
|
game_l10n.copyrightText,
|
||||||
shadowColor: colorScheme.shadow.withValues(alpha: 0.2),
|
textAlign: TextAlign.center,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
child: Padding(
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
padding: const EdgeInsets.all(18),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (icon != null) ...[
|
|
||||||
Icon(icon, color: colorScheme.primary),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
],
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
...points.map(
|
|
||||||
(point) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 3),
|
|
||||||
child: Icon(Icons.check_circle_outline, size: 18),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(point, style: theme.textTheme.bodyMedium),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 태그 칩
|
||||||
class _Tag extends StatelessWidget {
|
class _Tag extends StatelessWidget {
|
||||||
const _Tag({required this.icon, required this.label});
|
const _Tag({required this.icon, required this.label});
|
||||||
|
|
||||||
|
|||||||
169
lib/src/features/front/widgets/hero_vs_boss_animation.dart
Normal file
169
lib/src/features/front/widgets/hero_vs_boss_animation.dart
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:askiineverdie/src/core/animation/front_screen_animation.dart';
|
||||||
|
|
||||||
|
/// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 위젯
|
||||||
|
///
|
||||||
|
/// 작은 용사가 거대한 Glitch God에 맞서는 루프 애니메이션
|
||||||
|
/// 항상 검은 배경에 흰색 텍스트, 특수 효과만 컬러로 표시
|
||||||
|
class HeroVsBossAnimation extends StatefulWidget {
|
||||||
|
const HeroVsBossAnimation({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HeroVsBossAnimation> createState() => _HeroVsBossAnimationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeroVsBossAnimationState extends State<HeroVsBossAnimation> {
|
||||||
|
int _currentFrame = 0;
|
||||||
|
Timer? _timer;
|
||||||
|
final Random _random = Random();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_startAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startAnimation() {
|
||||||
|
_timer = Timer.periodic(
|
||||||
|
const Duration(milliseconds: frontScreenAnimationIntervalMs),
|
||||||
|
(_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentFrame =
|
||||||
|
(_currentFrame + 1) % frontScreenAnimationFrameCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 글리치 효과: 랜덤 문자 대체
|
||||||
|
String _applyGlitchEffect(String frame) {
|
||||||
|
// 10% 확률로 글리치 효과 적용
|
||||||
|
if (_random.nextDouble() > 0.1) return frame;
|
||||||
|
|
||||||
|
const glitchChars = '@#\$%&*!?~';
|
||||||
|
final chars = frame.split('');
|
||||||
|
final glitchCount = _random.nextInt(5) + 1;
|
||||||
|
|
||||||
|
for (var i = 0; i < glitchCount; i++) {
|
||||||
|
final pos = _random.nextInt(chars.length);
|
||||||
|
if (chars[pos] != ' ' && chars[pos] != '\n') {
|
||||||
|
chars[pos] = glitchChars[_random.nextInt(glitchChars.length)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chars.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final frame = _applyGlitchEffect(
|
||||||
|
frontScreenAnimationFrames[_currentFrame],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
// 항상 검은 배경
|
||||||
|
color: Colors.black,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white24,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
// 은은한 글로우 효과
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.cyan.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// ASCII 애니메이션 (흰색 텍스트)
|
||||||
|
FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
frame,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'JetBrainsMono',
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.1,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 하단 효과 바 (컬러)
|
||||||
|
_buildEffectBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 하단 효과 바: 글리치/전투 효과 시각화
|
||||||
|
Widget _buildEffectBar() {
|
||||||
|
// 프레임에 따라 색상 변화
|
||||||
|
final colors = [
|
||||||
|
Colors.cyan,
|
||||||
|
Colors.purple,
|
||||||
|
Colors.red,
|
||||||
|
Colors.cyan,
|
||||||
|
Colors.yellow,
|
||||||
|
Colors.purple,
|
||||||
|
];
|
||||||
|
final currentColor = colors[_currentFrame % colors.length];
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 왼쪽 검 아이콘 (용사)
|
||||||
|
Icon(
|
||||||
|
Icons.flash_on,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.yellow.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// 중앙 효과 바
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 3,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
currentColor.withValues(alpha: 0.7),
|
||||||
|
Colors.white.withValues(alpha: 0.9),
|
||||||
|
currentColor.withValues(alpha: 0.7),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// 오른쪽 보스 아이콘
|
||||||
|
Icon(
|
||||||
|
Icons.whatshot,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.red.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ class GamePlayScreen extends StatefulWidget {
|
|||||||
required this.controller,
|
required this.controller,
|
||||||
this.forceCarouselLayout = false,
|
this.forceCarouselLayout = false,
|
||||||
this.forceDesktopLayout = false,
|
this.forceDesktopLayout = false,
|
||||||
|
this.onThemeModeChange,
|
||||||
|
this.currentThemeMode = ThemeMode.system,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GameSessionController controller;
|
final GameSessionController controller;
|
||||||
@@ -49,6 +51,12 @@ class GamePlayScreen extends StatefulWidget {
|
|||||||
/// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용
|
/// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용
|
||||||
final bool forceDesktopLayout;
|
final bool forceDesktopLayout;
|
||||||
|
|
||||||
|
/// 테마 모드 변경 콜백
|
||||||
|
final void Function(ThemeMode mode)? onThemeModeChange;
|
||||||
|
|
||||||
|
/// 현재 테마 모드
|
||||||
|
final ThemeMode currentThemeMode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GamePlayScreen> createState() => _GamePlayScreenState();
|
State<GamePlayScreen> createState() => _GamePlayScreenState();
|
||||||
}
|
}
|
||||||
@@ -564,9 +572,24 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
},
|
},
|
||||||
notificationService: _notificationService,
|
notificationService: _notificationService,
|
||||||
specialAnimation: _specialAnimation,
|
specialAnimation: _specialAnimation,
|
||||||
onLanguageChange: (locale) {
|
onLanguageChange: (locale) async {
|
||||||
|
// 1. 현재 상태 저장
|
||||||
|
await widget.controller.pause(saveOnStop: true);
|
||||||
|
// 2. 로케일 변경
|
||||||
game_l10n.setGameLocale(locale);
|
game_l10n.setGameLocale(locale);
|
||||||
setState(() {});
|
// 3. 화면 재생성 (전체 UI 재구성)
|
||||||
|
if (context.mounted) {
|
||||||
|
await widget.controller.resume();
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => GamePlayScreen(
|
||||||
|
controller: widget.controller,
|
||||||
|
currentThemeMode: widget.currentThemeMode,
|
||||||
|
onThemeModeChange: widget.onThemeModeChange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDeleteSaveAndNewGame: () async {
|
onDeleteSaveAndNewGame: () async {
|
||||||
// 게임 루프 중지
|
// 게임 루프 중지
|
||||||
@@ -578,6 +601,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
currentThemeMode: widget.currentThemeMode,
|
||||||
|
onThemeModeChange: widget.onThemeModeChange,
|
||||||
),
|
),
|
||||||
// 사망 오버레이
|
// 사망 오버레이
|
||||||
if (state.isDead && state.deathInfo != null)
|
if (state.isDead && state.deathInfo != null)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class MobileCarouselLayout extends StatefulWidget {
|
|||||||
required this.onLanguageChange,
|
required this.onLanguageChange,
|
||||||
required this.onDeleteSaveAndNewGame,
|
required this.onDeleteSaveAndNewGame,
|
||||||
this.specialAnimation,
|
this.specialAnimation,
|
||||||
|
this.currentThemeMode = ThemeMode.system,
|
||||||
|
this.onThemeModeChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GameState state;
|
final GameState state;
|
||||||
@@ -51,6 +53,8 @@ class MobileCarouselLayout extends StatefulWidget {
|
|||||||
final void Function(String locale) onLanguageChange;
|
final void Function(String locale) onLanguageChange;
|
||||||
final VoidCallback onDeleteSaveAndNewGame;
|
final VoidCallback onDeleteSaveAndNewGame;
|
||||||
final AsciiAnimationType? specialAnimation;
|
final AsciiAnimationType? specialAnimation;
|
||||||
|
final ThemeMode currentThemeMode;
|
||||||
|
final void Function(ThemeMode mode)? onThemeModeChange;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
||||||
@@ -94,6 +98,66 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
return l10n.languageEnglish;
|
return l10n.languageEnglish;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 현재 테마명 가져오기
|
||||||
|
String _getCurrentThemeName() {
|
||||||
|
return switch (widget.currentThemeMode) {
|
||||||
|
ThemeMode.light => l10n.themeLight,
|
||||||
|
ThemeMode.dark => l10n.themeDark,
|
||||||
|
ThemeMode.system => l10n.themeSystem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 아이콘 가져오기
|
||||||
|
IconData _getThemeIcon() {
|
||||||
|
return switch (widget.currentThemeMode) {
|
||||||
|
ThemeMode.light => Icons.light_mode,
|
||||||
|
ThemeMode.dark => Icons.dark_mode,
|
||||||
|
ThemeMode.system => Icons.brightness_auto,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 선택 다이얼로그 표시
|
||||||
|
void _showThemeDialog(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(l10n.menuTheme),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildThemeOption(context, ThemeMode.system, l10n.themeSystem),
|
||||||
|
_buildThemeOption(context, ThemeMode.light, l10n.themeLight),
|
||||||
|
_buildThemeOption(context, ThemeMode.dark, l10n.themeDark),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThemeOption(
|
||||||
|
BuildContext context,
|
||||||
|
ThemeMode mode,
|
||||||
|
String label,
|
||||||
|
) {
|
||||||
|
final isSelected = widget.currentThemeMode == mode;
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||||
|
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context); // 다이얼로그 닫기
|
||||||
|
widget.onThemeModeChange?.call(mode);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 언어 선택 다이얼로그 표시
|
/// 언어 선택 다이얼로그 표시
|
||||||
void _showLanguageDialog(BuildContext context) {
|
void _showLanguageDialog(BuildContext context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
@@ -242,6 +306,24 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 테마 변경
|
||||||
|
if (widget.onThemeModeChange != null)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
_getThemeIcon(),
|
||||||
|
color: Colors.purple,
|
||||||
|
),
|
||||||
|
title: Text(l10n.menuTheme),
|
||||||
|
trailing: Text(
|
||||||
|
_getCurrentThemeName(),
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showThemeDialog(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
// 저장
|
// 저장
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class NewCharacterScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||||
final TextEditingController _nameController = TextEditingController();
|
final TextEditingController _nameController = TextEditingController();
|
||||||
|
final ScrollController _raceScrollController = ScrollController();
|
||||||
|
final ScrollController _klassScrollController = ScrollController();
|
||||||
|
|
||||||
// 종족(races)과 직업(klasses) 목록 (Phase 5)
|
// 종족(races)과 직업(klasses) 목록 (Phase 5)
|
||||||
final List<RaceTraits> _races = RaceData.all;
|
final List<RaceTraits> _races = RaceData.all;
|
||||||
@@ -74,14 +76,47 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
|
|
||||||
// 초기 이름 생성
|
// 초기 이름 생성
|
||||||
_nameController.text = generateName(_nameRng);
|
_nameController.text = generateName(_nameRng);
|
||||||
|
|
||||||
|
// 선택된 종족/직업으로 스크롤
|
||||||
|
_scrollToSelectedItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
|
_raceScrollController.dispose();
|
||||||
|
_klassScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 선택된 종족/직업 위치로 스크롤
|
||||||
|
void _scrollToSelectedItems() {
|
||||||
|
// ListTile 높이 약 48px (dense 모드)
|
||||||
|
const itemHeight = 48.0;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_raceScrollController.hasClients) {
|
||||||
|
final raceOffset = _selectedRaceIndex * itemHeight;
|
||||||
|
_raceScrollController.animateTo(
|
||||||
|
raceOffset.clamp(0.0, _raceScrollController.position.maxScrollExtent),
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_klassScrollController.hasClients) {
|
||||||
|
final klassOffset = _selectedKlassIndex * itemHeight;
|
||||||
|
_klassScrollController.animateTo(
|
||||||
|
klassOffset.clamp(
|
||||||
|
0.0,
|
||||||
|
_klassScrollController.position.maxScrollExtent,
|
||||||
|
),
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// 스탯 굴림 (3d6 × 6)
|
/// 스탯 굴림 (3d6 × 6)
|
||||||
void _rollStats() {
|
void _rollStats() {
|
||||||
final rng = DeterministicRandom(_currentSeed);
|
final rng = DeterministicRandom(_currentSeed);
|
||||||
@@ -108,6 +143,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
// 새 시드로 굴림
|
// 새 시드로 굴림
|
||||||
_currentSeed = math.Random().nextInt(0x7FFFFFFF);
|
_currentSeed = math.Random().nextInt(0x7FFFFFFF);
|
||||||
_rollStats();
|
_rollStats();
|
||||||
|
|
||||||
|
// 선택된 종족/직업으로 스크롤
|
||||||
|
_scrollToSelectedItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unroll 버튼 클릭 (이전 롤로 복원)
|
/// Unroll 버튼 클릭 (이전 롤로 복원)
|
||||||
@@ -415,6 +453,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 300,
|
height: 300,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
controller: _raceScrollController,
|
||||||
itemCount: _races.length,
|
itemCount: _races.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final isSelected = index == _selectedRaceIndex;
|
final isSelected = index == _selectedRaceIndex;
|
||||||
@@ -521,6 +560,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 300,
|
height: 300,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
controller: _klassScrollController,
|
||||||
itemCount: _klasses.length,
|
itemCount: _klasses.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final isSelected = index == _selectedKlassIndex;
|
final isSelected = index == _selectedKlassIndex;
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import 'package:askiineverdie/src/app.dart';
|
import 'package:askiineverdie/src/app.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
// SharedPreferences 모킹
|
||||||
|
setUpAll(() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
const MethodChannel('plugins.flutter.io/shared_preferences')
|
||||||
|
.setMockMethodCallHandler((call) async {
|
||||||
|
if (call.method == 'getAll') return <String, Object>{};
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Front screen renders and navigates to new character', (
|
testWidgets('Front screen renders and navigates to new character', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user