diff --git a/lib/src/app.dart b/lib/src/app.dart index da0b10b..59edb6d 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -50,6 +50,8 @@ class _AskiiNeverDieAppState extends State { late final SettingsRepository _settingsRepository; late final AudioService _audioService; late final HallOfFameStorage _hallOfFameStorage; + final RouteObserver> _routeObserver = + RouteObserver>(); bool _isCheckingSave = true; bool _hasSave = false; SavedGamePreview? _savedGamePreview; @@ -437,6 +439,7 @@ class _AskiiNeverDieAppState extends State { theme: _lightTheme, darkTheme: _darkTheme, themeMode: _themeMode, + navigatorObservers: [_routeObserver], builder: (context, child) { // 현재 로케일을 게임 텍스트 l10n 시스템에 동기화 final locale = Localizations.localeOf(context); @@ -465,6 +468,11 @@ class _AskiiNeverDieAppState extends State { hasSaveFile: _hasSave, savedGamePreview: _savedGamePreview, hallOfFameCount: _hallOfFame.count, + routeObserver: _routeObserver, + onRefresh: () { + _checkForExistingSave(); + _loadHallOfFame(); + }, ); } @@ -480,8 +488,9 @@ class _AskiiNeverDieAppState extends State { ), ) .then((_) { - // 새 게임 후 돌아오면 세이브 정보 갱신 (BGM은 _checkForExistingSave에서 재생) + // 새 게임 후 돌아오면 세이브 정보 및 명예의 전당 갱신 _checkForExistingSave(); + _loadHallOfFame(); }); } @@ -560,8 +569,9 @@ class _AskiiNeverDieAppState extends State { ), ) .then((_) { - // 게임에서 돌아오면 세이브 정보 갱신 (BGM은 _checkForExistingSave에서 재생) + // 게임에서 돌아오면 세이브 정보 및 명예의 전당 갱신 _checkForExistingSave(); + _loadHallOfFame(); }); } @@ -574,7 +584,8 @@ class _AskiiNeverDieAppState extends State { ), ) .then((_) { - // 명예의 전당에서 돌아오면 타이틀 BGM 재생 + // 명예의 전당에서 돌아오면 명예의 전당 갱신 및 타이틀 BGM 재생 + _loadHallOfFame(); _audioService.playBgm('title'); }); } diff --git a/lib/src/features/arena/arena_battle_screen.dart b/lib/src/features/arena/arena_battle_screen.dart index 110ff93..d6d1764 100644 --- a/lib/src/features/arena/arena_battle_screen.dart +++ b/lib/src/features/arena/arena_battle_screen.dart @@ -10,7 +10,7 @@ import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/core/animation/race_character_frames.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart'; -import 'package:asciineverdie/src/features/arena/widgets/ascii_disintegrate_widget.dart'; +import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; diff --git a/lib/src/features/front/front_screen.dart b/lib/src/features/front/front_screen.dart index 43db9f2..673a4ed 100644 --- a/lib/src/features/front/front_screen.dart +++ b/lib/src/features/front/front_screen.dart @@ -10,7 +10,7 @@ import 'package:asciineverdie/src/features/front/widgets/hero_vs_boss_animation. import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; -class FrontScreen extends StatelessWidget { +class FrontScreen extends StatefulWidget { const FrontScreen({ super.key, this.onNewCharacter, @@ -20,6 +20,8 @@ class FrontScreen extends StatelessWidget { this.hasSaveFile = false, this.savedGamePreview, this.hallOfFameCount = 0, + this.routeObserver, + this.onRefresh, }); /// "New character" 버튼 클릭 시 호출 @@ -43,12 +45,45 @@ class FrontScreen extends StatelessWidget { /// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상) final int hallOfFameCount; + /// RouteObserver (화면 복귀 시 갱신용) + final RouteObserver>? routeObserver; + + /// 화면 복귀 시 호출할 콜백 + final VoidCallback? onRefresh; + + @override + State createState() => _FrontScreenState(); +} + +class _FrontScreenState extends State with RouteAware { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // RouteObserver 구독 + final route = ModalRoute.of(context); + if (route != null) { + widget.routeObserver?.subscribe(this, route); + } + } + + @override + void dispose() { + widget.routeObserver?.unsubscribe(this); + super.dispose(); + } + + @override + void didPopNext() { + // 다른 화면에서 돌아왔을 때 갱신 + widget.onRefresh?.call(); + } + /// 새 캐릭터 생성 시 세이브 파일 존재하면 경고 표시 void _handleNewCharacter(BuildContext context) { - if (hasSaveFile) { + if (widget.hasSaveFile) { _showDeleteWarningDialog(context); } else { - onNewCharacter?.call(context); + widget.onNewCharacter?.call(context); } } @@ -67,7 +102,7 @@ class FrontScreen extends StatelessWidget { FilledButton( onPressed: () { Navigator.pop(dialogContext); - onNewCharacter?.call(context); + widget.onNewCharacter?.call(context); }, child: Text(game_l10n.buttonConfirm), ), @@ -98,21 +133,21 @@ class FrontScreen extends StatelessWidget { const _AnimationPanel(), const SizedBox(height: 16), _ActionButtons( - onNewCharacter: onNewCharacter != null + onNewCharacter: widget.onNewCharacter != null ? () => _handleNewCharacter(context) : null, - onLoadSave: onLoadSave != null - ? () => onLoadSave!(context) + onLoadSave: widget.onLoadSave != null + ? () => widget.onLoadSave!(context) : null, - onHallOfFame: onHallOfFame != null - ? () => onHallOfFame!(context) + onHallOfFame: widget.onHallOfFame != null + ? () => widget.onHallOfFame!(context) : null, - onLocalArena: - onLocalArena != null && hallOfFameCount >= 2 - ? () => onLocalArena!(context) + onLocalArena: widget.onLocalArena != null && + widget.hallOfFameCount >= 2 + ? () => widget.onLocalArena!(context) : null, - savedGamePreview: savedGamePreview, - hallOfFameCount: hallOfFameCount, + savedGamePreview: widget.savedGamePreview, + hallOfFameCount: widget.hallOfFameCount, ), ], ), @@ -262,16 +297,14 @@ class _ActionButtons extends StatelessWidget { onPressed: onHallOfFame, isPrimary: false, ), - // 로컬 아레나 (2명 이상일 때만 활성화) - if (hallOfFameCount >= 2) ...[ - const SizedBox(height: 12), - RetroTextButton( - text: game_l10n.uiLocalArena, - icon: Icons.sports_kabaddi, - onPressed: onLocalArena, - isPrimary: false, - ), - ], + // 로컬 아레나 (항상 표시, 2명 이상일 때만 활성화) + const SizedBox(height: 12), + RetroTextButton( + text: game_l10n.uiLocalArena, + icon: Icons.sports_kabaddi, + onPressed: hallOfFameCount >= 2 ? onLocalArena : null, + isPrimary: false, + ), ], ), ); diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index e555679..11c36e4 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -1181,6 +1181,7 @@ class _GamePlayScreenState extends State state.progress.currentCombat?.monsterStats.hpCurrent, monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax, monsterName: state.progress.currentCombat?.monsterStats.name, + monsterLevel: state.progress.currentCombat?.monsterStats.level, ), // Experience 바 diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index e525794..a83fff4 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -107,6 +107,14 @@ class GameSessionController extends ChangeNotifier { if (isNewGame) { _sessionStats = SessionStatistics.empty(); await _statisticsStorage.recordGameStart(); + } else { + // 게임 로드 시 저장된 사망 횟수 복원 + _sessionStats = _sessionStats.copyWith( + deathCount: state.progress.deathCount, + questsCompleted: state.progress.questCount, + monstersKilled: state.progress.monstersKilled, + playTimeMs: state.skillSystem.elapsedMs, + ); } _initPreviousValues(state); @@ -341,7 +349,7 @@ class GameSessionController extends ChangeNotifier { final entry = HallOfFameEntry.fromGameState( state: _state!, - totalDeaths: _sessionStats.deathCount, + totalDeaths: _state!.progress.deathCount, // GameState에 저장된 값 사용 monstersKilled: _state!.progress.monstersKilled, combatStats: combatStats, ); diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index 17e83bd..f8b7f79 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -259,7 +259,7 @@ class _NewCharacterScreenState extends State { seed: gameSeed, traits: traits, stats: finalStats, - inventory: const Inventory(gold: 0, items: []), + inventory: Inventory.empty(), equipment: Equipment.empty(), skillBook: SkillBook.empty(), progress: ProgressState.empty(), diff --git a/lib/src/shared/widgets/ascii_disintegrate_widget.dart b/lib/src/shared/widgets/ascii_disintegrate_widget.dart new file mode 100644 index 0000000..2073a85 --- /dev/null +++ b/lib/src/shared/widgets/ascii_disintegrate_widget.dart @@ -0,0 +1,219 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +/// ASCII 문자 분해 파티클 +class AsciiParticle { + AsciiParticle({ + required this.char, + required this.initialX, + required this.initialY, + required this.vx, + required this.vy, + required this.delay, + }) : x = initialX, + y = initialY, + opacity = 1.0; + + final String char; + final double initialX; + final double initialY; + final double vx; // X 속도 + final double vy; // Y 속도 + final double delay; // 분해 시작 지연 (0.0 ~ 0.3) + + double x; + double y; + double opacity; + + /// 진행도에 따라 파티클 상태 업데이트 + void update(double progress) { + // 지연 적용 + final adjustedProgress = ((progress - delay) / (1.0 - delay)).clamp( + 0.0, + 1.0, + ); + + if (adjustedProgress <= 0) { + // 아직 분해 시작 전 + x = initialX; + y = initialY; + opacity = 1.0; + return; + } + + // 이징 적용 (가속) + final easedProgress = Curves.easeOutQuad.transform(adjustedProgress); + + // 위치 업데이트 (초기 위치에서 이동) + x = initialX + vx * easedProgress * 3.0; + y = initialY + vy * easedProgress * 3.0; + + // 중력 효과 + y += easedProgress * easedProgress * 2.0; + + // 페이드 아웃 (후반부에 급격히) + opacity = (1.0 - easedProgress * easedProgress).clamp(0.0, 1.0); + } +} + +/// ASCII 캐릭터 분해 애니메이션 위젯 +/// +/// 캐릭터의 각 ASCII 문자가 파티클로 분해되어 흩어지는 효과 +class AsciiDisintegrateWidget extends StatefulWidget { + const AsciiDisintegrateWidget({ + super.key, + required this.characterLines, + this.charWidth = 8.0, + this.charHeight = 12.0, + this.duration = const Duration(milliseconds: 1500), + this.textColor, + this.onComplete, + }); + + /// ASCII 캐릭터 문자열 (줄 단위) + final List characterLines; + + /// 문자 너비 (픽셀) + final double charWidth; + + /// 문자 높이 (픽셀) + final double charHeight; + + /// 애니메이션 지속 시간 + final Duration duration; + + /// 텍스트 색상 (null이면 테마 색상) + final Color? textColor; + + /// 완료 콜백 + final VoidCallback? onComplete; + + @override + State createState() => + _AsciiDisintegrateWidgetState(); +} + +class _AsciiDisintegrateWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late List _particles; + final Random _random = Random(); + + @override + void initState() { + super.initState(); + _initParticles(); + _controller = AnimationController(duration: widget.duration, vsync: this) + ..addListener(() => setState(() {})) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + widget.onComplete?.call(); + } + }) + ..forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _initParticles() { + _particles = []; + + for (int y = 0; y < widget.characterLines.length; y++) { + final line = widget.characterLines[y]; + for (int x = 0; x < line.length; x++) { + final char = line[x]; + // 공백은 파티클로 변환하지 않음 + if (char != ' ') { + _particles.add( + AsciiParticle( + char: char, + initialX: x.toDouble(), + initialY: y.toDouble(), + // 랜덤 속도 (위쪽 + 좌우로 퍼짐) + vx: (_random.nextDouble() - 0.5) * 4.0, + vy: -_random.nextDouble() * 2.0 - 0.5, // 위쪽으로 + // 랜덤 지연 (안쪽에서 바깥쪽으로 분해) + delay: _random.nextDouble() * 0.3, + ), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + // 파티클 상태 업데이트 + for (final particle in _particles) { + particle.update(_controller.value); + } + + final textColor = + widget.textColor ?? Theme.of(context).textTheme.bodyMedium?.color; + + return CustomPaint( + size: Size( + widget.characterLines.isNotEmpty + ? widget.characterLines + .map((l) => l.length) + .reduce((a, b) => a > b ? a : b) * + widget.charWidth + : 0, + widget.characterLines.length * widget.charHeight, + ), + painter: _DisintegratePainter( + particles: _particles, + charWidth: widget.charWidth, + charHeight: widget.charHeight, + textColor: textColor ?? Colors.white, + ), + ); + } +} + +/// 분해 파티클 페인터 +class _DisintegratePainter extends CustomPainter { + _DisintegratePainter({ + required this.particles, + required this.charWidth, + required this.charHeight, + required this.textColor, + }); + + final List particles; + final double charWidth; + final double charHeight; + final Color textColor; + + @override + void paint(Canvas canvas, Size size) { + for (final particle in particles) { + if (particle.opacity <= 0) continue; + + final textPainter = TextPainter( + text: TextSpan( + text: particle.char, + style: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: charHeight * 0.9, + color: textColor.withValues(alpha: particle.opacity), + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final x = particle.x * charWidth; + final y = particle.y * charHeight; + + textPainter.paint(canvas, Offset(x, y)); + } + } + + @override + bool shouldRepaint(covariant _DisintegratePainter oldDelegate) => true; +}