Files
asciinevrdie/lib/src/app.dart
JiWoong Sul 748160d543 feat(ui): 화면 및 컨트롤러 수익화 연동
- 앱 초기화에 광고/IAP 서비스 추가
- 게임 세션 컨트롤러 수익화 상태 관리
- 캐릭터 생성 화면 굴리기 제한 UI
- 설정 화면 광고 제거 구매 UI
- 애니메이션 패널 개선
2026-01-16 20:10:43 +09:00

754 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/audio/audio_service.dart';
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
import 'package:asciineverdie/src/core/engine/progress_service.dart';
import 'package:asciineverdie/src/core/engine/reward_service.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart';
import 'package:asciineverdie/src/core/storage/save_manager.dart';
import 'package:asciineverdie/src/core/storage/save_repository.dart';
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/features/arena/arena_screen.dart';
import 'package:asciineverdie/src/features/front/front_screen.dart';
import 'package:asciineverdie/src/features/front/save_picker_dialog.dart';
import 'package:asciineverdie/src/features/game/game_play_screen.dart';
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart';
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:asciineverdie/src/features/new_character/new_character_screen.dart';
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
class AskiiNeverDieApp extends StatefulWidget {
const AskiiNeverDieApp({super.key});
@override
State<AskiiNeverDieApp> createState() => _AskiiNeverDieAppState();
}
/// 저장된 게임 미리보기 정보
class SavedGamePreview {
const SavedGamePreview({
required this.characterName,
required this.level,
required this.actName,
});
final String characterName;
final int level;
final String actName;
}
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
late final GameSessionController _controller;
late final NotificationService _notificationService;
late final SettingsRepository _settingsRepository;
late final AudioService _audioService;
late final HallOfFameStorage _hallOfFameStorage;
final RouteObserver<ModalRoute<void>> _routeObserver =
RouteObserver<ModalRoute<void>>();
bool _isCheckingSave = true;
bool _hasSave = false;
SavedGamePreview? _savedGamePreview;
ThemeMode _themeMode = ThemeMode.system;
HallOfFame _hallOfFame = HallOfFame.empty();
@override
void initState() {
super.initState();
const config = PqConfig();
final mutations = GameMutations(config);
final rewards = RewardService(mutations, config);
_controller = GameSessionController(
progressService: ProgressService(
config: config,
mutations: mutations,
rewards: rewards,
),
saveManager: SaveManager(SaveRepository()),
);
_notificationService = NotificationService();
_settingsRepository = SettingsRepository();
_audioService = AudioService(settingsRepository: _settingsRepository);
_hallOfFameStorage = HallOfFameStorage();
// 초기 설정 및 오디오 서비스 로드
_loadSettings();
_audioService.init();
// 디버그 설정 서비스 초기화 (Phase 8)
DebugSettingsService.instance.initialize();
// 세이브 파일 존재 여부 확인
_checkForExistingSave();
// 명예의 전당 로드
_loadHallOfFame();
}
/// 명예의 전당 로드
Future<void> _loadHallOfFame() async {
final hallOfFame = await _hallOfFameStorage.load();
if (mounted) {
setState(() {
_hallOfFame = hallOfFame;
});
}
}
/// 저장된 설정 불러오기
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 {
final exists = await _controller.saveManager.saveExists();
SavedGamePreview? preview;
if (exists) {
// 세이브 파일에서 미리보기 정보 추출
final (outcome, state, _, _) = await _controller.saveManager.loadState();
if (outcome.success && state != null) {
final actName = _getActName(state.progress.plotStageCount);
preview = SavedGamePreview(
characterName: state.traits.name,
level: state.traits.level,
actName: actName,
);
}
}
if (mounted) {
setState(() {
_hasSave = exists;
_savedGamePreview = preview;
_isCheckingSave = false;
});
// 세이브 확인 완료 후 타이틀 BGM 재생
_audioService.playBgm('title');
}
}
/// plotStageCount를 Act 이름으로 변환
String _getActName(int plotStageCount) {
return switch (plotStageCount) {
1 => 'Prologue',
2 => 'Act I',
3 => 'Act II',
4 => 'Act III',
5 => 'Act IV',
6 => 'Act V',
_ => 'Act V',
};
}
@override
void dispose() {
_controller.dispose();
_notificationService.dispose();
_audioService.dispose();
super.dispose();
}
/// 라이트 테마 (Classic Parchment 스타일)
ThemeData get _lightTheme => ThemeData(
colorScheme: RetroColors.lightColorScheme,
scaffoldBackgroundColor: const Color(0xFFFAF4ED),
useMaterial3: true,
// 카드/다이얼로그 레트로 배경
cardColor: const Color(0xFFF2E8DC),
dialogTheme: const DialogThemeData(
backgroundColor: Color(0xFFF2E8DC),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFB8860B),
),
),
// 앱바 레트로 스타일
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFFF2E8DC),
foregroundColor: Color(0xFF1F1F28),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFB8860B),
),
),
// 버튼 테마
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFE8DDD0),
foregroundColor: const Color(0xFF1F1F28),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFB8860B),
side: const BorderSide(color: Color(0xFFB8860B), width: 2),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFB8860B),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF4A4458),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF4A4458),
),
),
),
// 텍스트 테마
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 20,
color: Color(0xFFB8860B),
),
headlineMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: Color(0xFFB8860B),
),
headlineSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFB8860B),
),
titleLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFF1F1F28),
),
titleMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
titleSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFF1F1F28)),
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFF1F1F28)),
bodySmall: TextStyle(fontSize: 15, color: Color(0xFF1F1F28)),
labelLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
labelMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
labelSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: Color(0xFF1F1F28),
),
),
// 칩 테마
chipTheme: const ChipThemeData(
backgroundColor: Color(0xFFE8DDD0),
labelStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
side: BorderSide(color: Color(0xFF8B7355)),
),
// 리스트 타일 테마
listTileTheme: const ListTileThemeData(
textColor: Color(0xFF1F1F28),
iconColor: Color(0xFFB8860B),
),
// 프로그레스 인디케이터
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: Color(0xFFB8860B),
linearTrackColor: Color(0xFFD4C4B0),
),
);
/// 다크 테마 (Dark Fantasy 스타일)
ThemeData get _darkTheme => ThemeData(
colorScheme: RetroColors.darkColorScheme,
scaffoldBackgroundColor: RetroColors.deepBrown,
useMaterial3: true,
// 카드/다이얼로그 레트로 배경
cardColor: RetroColors.darkBrown,
dialogTheme: const DialogThemeData(
backgroundColor: Color(0xFF24283B),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
),
// 앱바 레트로 스타일
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF24283B),
foregroundColor: Color(0xFFC0CAF5),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
),
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3D4260),
foregroundColor: const Color(0xFFC0CAF5),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFE0AF68),
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFE0AF68),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFC0CAF5),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
),
),
// 텍스트 테마
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 20,
color: Color(0xFFE0AF68),
),
headlineMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: Color(0xFFE0AF68),
),
headlineSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
titleLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFC0CAF5),
),
titleMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
titleSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
labelLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
labelMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
labelSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: Color(0xFFC0CAF5),
),
),
// 칩 테마
chipTheme: const ChipThemeData(
backgroundColor: Color(0xFF2A2E3F),
labelStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
side: BorderSide(color: Color(0xFF545C7E)),
),
// 리스트 타일 테마
listTileTheme: const ListTileThemeData(
textColor: Color(0xFFC0CAF5),
iconColor: Color(0xFFE0AF68),
),
// 프로그레스 인디케이터
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: Color(0xFFE0AF68),
linearTrackColor: Color(0xFF3B4261),
),
);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ASCII NEVER DIE',
debugShowCheckedModeBanner: false,
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
theme: _lightTheme,
darkTheme: _darkTheme,
themeMode: _themeMode,
navigatorObservers: [_routeObserver],
builder: (context, child) {
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
final locale = Localizations.localeOf(context);
game_l10n.setGameLocale(locale.languageCode);
return child ?? const SizedBox.shrink();
},
home: NotificationOverlay(
notificationService: _notificationService,
child: _buildHomeScreen(),
),
);
}
/// 홈 화면 결정: 세이브 확인 중 → 스플래시, 그 외 → 프론트
Widget _buildHomeScreen() {
// 세이브 확인 중이면 로딩 스플래시 표시
if (_isCheckingSave) {
return const _SplashScreen();
}
return FrontScreen(
onNewCharacter: _navigateToNewCharacter,
onLoadSave: _loadSave,
onHallOfFame: _navigateToHallOfFame,
onLocalArena: _navigateToArena,
onSettings: _showSettings,
hasSaveFile: _hasSave,
savedGamePreview: _savedGamePreview,
hallOfFameCount: _hallOfFame.count,
routeObserver: _routeObserver,
onRefresh: () {
_checkForExistingSave();
_loadHallOfFame();
},
);
}
void _navigateToNewCharacter(BuildContext context) {
Navigator.of(context)
.push(
MaterialPageRoute<void>(
builder: (context) => NewCharacterScreen(
onCharacterCreated: (initialState, {bool testMode = false}) {
_startGame(context, initialState, testMode: testMode);
},
),
),
)
.then((_) {
// 새 게임 후 돌아오면 세이브 정보 및 명예의 전당 갱신
_checkForExistingSave();
_loadHallOfFame();
});
}
Future<void> _loadSave(BuildContext context) async {
// 저장 파일 목록 조회
final saves = await _controller.saveManager.listSaves();
if (!context.mounted) return;
String? selectedFileName;
if (saves.isEmpty) {
// 저장 파일이 없으면 안내 메시지
_notificationService.showInfo(L10n.of(context).noSavedGames);
return;
} else if (saves.length == 1) {
// 파일이 하나면 바로 선택
selectedFileName = saves.first.fileName;
} else {
// 여러 개면 다이얼로그 표시
selectedFileName = await SavePickerDialog.show(context, saves);
}
if (selectedFileName == null || !context.mounted) return;
// 선택된 파일 로드 (치트 모드는 저장된 상태에서 복원)
await _controller.loadAndStart(fileName: selectedFileName);
if (_controller.status == GameSessionStatus.running) {
if (context.mounted) {
_navigateToGame(context);
}
} else if (_controller.status == GameSessionStatus.error &&
context.mounted) {
_notificationService.showWarning(
L10n.of(context).loadError(_controller.error ?? 'Unknown error'),
);
}
}
Future<void> _startGame(
BuildContext context,
GameState initialState, {
bool testMode = false,
}) async {
await _controller.startNew(initialState, cheatsEnabled: testMode);
if (context.mounted) {
// NewCharacterScreen을 pop하고 GamePlayScreen으로 이동
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(
controller: _controller,
audioService: _audioService,
forceCarouselLayout: testMode,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
),
),
);
}
}
void _navigateToGame(BuildContext context) {
Navigator.of(context)
.push(
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(
controller: _controller,
audioService: _audioService,
// 디버그 모드로 저장된 게임 로드 시 캐로셀 레이아웃 강제
forceCarouselLayout: _controller.cheatsEnabled,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
),
),
)
.then((_) {
// 게임에서 돌아오면 세이브 정보 및 명예의 전당 갱신
_checkForExistingSave();
_loadHallOfFame();
});
}
/// Phase 10: 명예의 전당 화면으로 이동
void _navigateToHallOfFame(BuildContext context) {
Navigator.of(context)
.push(
MaterialPageRoute<void>(
builder: (context) => const HallOfFameScreen(),
),
)
.then((_) {
// 명예의 전당에서 돌아오면 명예의 전당 갱신 및 타이틀 BGM 재생
_loadHallOfFame();
_audioService.playBgm('title');
});
}
/// 로컬 아레나 화면으로 이동
void _navigateToArena(BuildContext context) {
Navigator.of(context)
.push(
MaterialPageRoute<void>(builder: (context) => const ArenaScreen()),
)
.then((_) {
// 아레나에서 돌아오면 명예의 전당 다시 로드 및 타이틀 BGM 재생
_loadHallOfFame();
_audioService.playBgm('title');
});
}
/// 설정 화면 표시 (모달 바텀시트)
void _showSettings(BuildContext context) {
SettingsScreen.show(
context,
settingsRepository: _settingsRepository,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
onBgmVolumeChange: _audioService.setBgmVolume,
onSfxVolumeChange: _audioService.setSfxVolume,
);
}
}
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: RetroColors.deepBrown,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 타이틀 로고
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: RetroColors.panelBg,
border: Border.all(color: RetroColors.gold, width: 3),
),
child: Column(
children: [
// 아이콘
const Icon(
Icons.auto_awesome,
size: 32,
color: RetroColors.gold,
),
const SizedBox(height: 12),
// 타이틀
const Text(
'ASCII',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 22,
color: RetroColors.gold,
shadows: [
Shadow(
color: RetroColors.goldDark,
offset: Offset(2, 2),
),
],
),
),
const SizedBox(height: 4),
const Text(
'NEVER DIE',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.cream,
shadows: [
Shadow(color: RetroColors.brown, offset: Offset(1, 1)),
],
),
),
],
),
),
const SizedBox(height: 32),
// 레트로 로딩 바
SizedBox(width: 160, child: _RetroLoadingBar()),
],
),
),
);
}
}
/// 레트로 스타일 로딩 바 (애니메이션)
class _RetroLoadingBar extends StatefulWidget {
@override
State<_RetroLoadingBar> createState() => _RetroLoadingBarState();
}
class _RetroLoadingBarState extends State<_RetroLoadingBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const segmentCount = 10;
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// 웨이브 효과: 각 세그먼트가 순차적으로 켜지고 꺼짐
return Container(
height: 16,
decoration: BoxDecoration(
color: RetroColors.panelBg,
border: Border.all(color: RetroColors.panelBorderOuter, width: 2),
),
child: Row(
children: List.generate(segmentCount, (index) {
// 웨이브 패턴 계산
final progress = _controller.value * segmentCount;
final distance = (index - progress).abs();
final isLit = distance < 2 || (segmentCount - distance) < 2;
final opacity = isLit ? 1.0 : 0.2;
return Expanded(
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: RetroColors.gold.withValues(alpha: opacity),
),
),
);
}),
),
);
},
);
}
}