diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index 5dd8e03..f731775 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -1,28 +1,31 @@ import 'dart:async'; -import 'package:asciineverdie/src/core/engine/ad_service.dart'; -import 'package:asciineverdie/src/core/engine/debug_settings_service.dart'; -import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/core/engine/progress_loop.dart'; import 'package:asciineverdie/src/core/engine/progress_service.dart'; -import 'package:asciineverdie/src/core/engine/resurrection_service.dart'; -import 'package:asciineverdie/src/core/engine/return_rewards_service.dart'; -import 'package:asciineverdie/src/core/engine/shop_service.dart'; -import 'package:asciineverdie/src/core/engine/test_character_service.dart'; -import 'package:asciineverdie/src/core/model/combat_stats.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/game_statistics.dart'; -import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/core/model/monetization_state.dart'; import 'package:asciineverdie/src/core/model/treasure_chest.dart'; import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart'; import 'package:asciineverdie/src/core/storage/save_manager.dart'; import 'package:asciineverdie/src/core/storage/statistics_storage.dart'; +import 'package:asciineverdie/src/features/game/managers/game_statistics_manager.dart'; +import 'package:asciineverdie/src/features/game/managers/hall_of_fame_manager.dart'; +import 'package:asciineverdie/src/features/game/managers/resurrection_manager.dart'; +import 'package:asciineverdie/src/features/game/managers/return_rewards_manager.dart'; +import 'package:asciineverdie/src/features/game/managers/speed_boost_manager.dart'; import 'package:flutter/foundation.dart'; enum GameSessionStatus { idle, loading, running, error, dead, complete } /// Presentation-friendly wrapper that owns ProgressLoop and SaveManager. +/// +/// 게임 루프 관리를 담당하며, 대부분의 기능은 매니저에 위임합니다. +/// - 통계: GameStatisticsManager +/// - 속도 부스트: SpeedBoostManager +/// - 복귀 보상: ReturnRewardsManager +/// - 부활: ResurrectionManager +/// - 명예의 전당: HallOfFameManager class GameSessionController extends ChangeNotifier { GameSessionController({ required this.progressService, @@ -32,20 +35,43 @@ class GameSessionController extends ChangeNotifier { DateTime Function()? now, StatisticsStorage? statisticsStorage, HallOfFameStorage? hallOfFameStorage, - }) : _tickInterval = tickInterval, - _now = now ?? DateTime.now, - _statisticsStorage = statisticsStorage ?? StatisticsStorage(), - _hallOfFameStorage = hallOfFameStorage ?? HallOfFameStorage(); + }) : _tickInterval = tickInterval, + _now = now ?? DateTime.now { + // 매니저 초기화 + _statisticsManager = GameStatisticsManager( + statisticsStorage: statisticsStorage, + ); + _hallOfFameManager = HallOfFameManager( + hallOfFameStorage: hallOfFameStorage, + ); + _speedBoostManager = SpeedBoostManager( + cheatsEnabledGetter: () => _cheatsEnabled, + getAvailableSpeeds: _hallOfFameManager.getAvailableSpeeds, + ); + _returnRewardsManager = ReturnRewardsManager(); + _resurrectionManager = ResurrectionManager(); + + // 매니저 콜백 설정 + _speedBoostManager.onStateChanged = notifyListeners; + _returnRewardsManager.onReturnRewardAvailable = (reward) { + onReturnRewardAvailable?.call(reward); + }; + } final ProgressService progressService; final SaveManager saveManager; final AutoSaveConfig autoSaveConfig; - final StatisticsStorage _statisticsStorage; - final HallOfFameStorage _hallOfFameStorage; final Duration _tickInterval; final DateTime Function() _now; + // 매니저들 + late final GameStatisticsManager _statisticsManager; + late final HallOfFameManager _hallOfFameManager; + late final SpeedBoostManager _speedBoostManager; + late final ReturnRewardsManager _returnRewardsManager; + late final ResurrectionManager _resurrectionManager; + ProgressLoop? _loop; StreamSubscription? _subscription; bool _cheatsEnabled = false; @@ -54,38 +80,15 @@ class GameSessionController extends ChangeNotifier { GameState? _state; String? _error; - // 배속 저장 (pause/resume 시 유지) - int _savedSpeedMultiplier = 1; - - // 자동 부활 (Auto-Resurrection) 상태 - bool _autoResurrect = false; - - // 속도 부스트 상태 (Phase 6) - // 실시간 타이머 대신 게임 시간(elapsedMs) 기준으로 종료 판정 - bool _isSpeedBoostActive = false; - static const int _speedBoostDuration = 300; // 5분 (게임 시간 기준) - - // 광고 표시 중 플래그 (lifecycle reload 방지용) - bool _isShowingAd = false; - int _adEndTimeMs = 0; // 광고 종료 시점 (밀리초) - - /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) - int get _speedBoostMultiplier => (kDebugMode && _cheatsEnabled) ? 20 : 5; - // 복귀 보상 상태 (Phase 7) MonetizationState _monetization = MonetizationState.initial(); - ReturnChestReward? _pendingReturnReward; /// 복귀 보상 콜백 (UI에서 다이얼로그 표시용) void Function(ReturnChestReward reward)? onReturnRewardAvailable; - // 통계 관련 필드 - SessionStatistics _sessionStats = SessionStatistics.empty(); - CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty(); - int _previousLevel = 0; - int _previousGold = 0; - int _previousMonstersKilled = 0; - int _previousQuestsCompleted = 0; + // ========================================================================== + // Getters + // ========================================================================== GameSessionStatus get status => _status; GameState? get state => _state; @@ -94,37 +97,69 @@ class GameSessionController extends ChangeNotifier { bool get cheatsEnabled => _cheatsEnabled; /// 자동 부활 활성화 여부 - bool get autoResurrect => _autoResurrect; + bool get autoResurrect => _resurrectionManager.autoResurrect; /// 자동 부활 설정 void setAutoResurrect(bool value) { - _autoResurrect = value; + _resurrectionManager.autoResurrect = value; notifyListeners(); } /// 현재 세션 통계 - SessionStatistics get sessionStats => _sessionStats; + SessionStatistics get sessionStats => _statisticsManager.sessionStats; /// 누적 통계 - CumulativeStatistics get cumulativeStats => _cumulativeStats; + CumulativeStatistics get cumulativeStats => + _statisticsManager.cumulativeStats; /// 현재 ProgressLoop 인스턴스 (치트 기능용) ProgressLoop? get loop => _loop; /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) - int get adSpeedMultiplier => _speedBoostMultiplier; + int get adSpeedMultiplier => _speedBoostManager.speedBoostMultiplier; /// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true) bool get has2xUnlocked => _loop?.availableSpeeds.contains(2) ?? false; + /// 사망 상태 여부 + bool get isDead => + _status == GameSessionStatus.dead || (_state?.isDead ?? false); + + /// 게임 클리어 여부 + bool get isComplete => _status == GameSessionStatus.complete; + + // 속도 부스트 관련 getters (매니저 위임) + bool get isSpeedBoostActive => _speedBoostManager.isSpeedBoostActive; + bool get isShowingAd => _speedBoostManager.isShowingAd; + bool get isRecentlyShowedAd => _speedBoostManager.isRecentlyShowedAd; + int get speedBoostMultiplier => _speedBoostManager.speedBoostMultiplier; + int get speedBoostDuration => _speedBoostManager.speedBoostDuration; + + int get speedBoostRemainingSeconds => _speedBoostManager.getRemainingSeconds( + _monetization, + _state?.skillSystem.elapsedMs ?? 0, + ); + + int get currentSpeedMultiplier => + _speedBoostManager.getCurrentSpeedMultiplier(_loop); + + // 복귀 보상 관련 getters (매니저 위임) + MonetizationState get monetization => _monetization; + ReturnChestReward? get pendingReturnReward => + _returnRewardsManager.pendingReturnReward; + + // ========================================================================== + // 게임 루프 관리 + // ========================================================================== + Future startNew( GameState initialState, { bool cheatsEnabled = false, bool isNewGame = true, }) async { // 기존 배속 보존 (부활/재개 시 유지) - // _loop가 있으면 현재 배속 사용, 없으면 저장된 배속 사용 - final previousSpeed = _loop?.speedMultiplier ?? _savedSpeedMultiplier; + final previousSpeed = + _loop?.speedMultiplier ?? _speedBoostManager.savedSpeedMultiplier; await _stopLoop(saveOnStop: false); @@ -140,21 +175,14 @@ class GameSessionController extends ChangeNotifier { // 통계 초기화 if (isNewGame) { - _sessionStats = SessionStatistics.empty(); - await _statisticsStorage.recordGameStart(); + await _statisticsManager.initializeForNewGame(); } else { - // 게임 로드 시 저장된 사망 횟수 복원 - _sessionStats = _sessionStats.copyWith( - deathCount: state.progress.deathCount, - questsCompleted: state.progress.questCount, - monstersKilled: state.progress.monstersKilled, - playTimeMs: state.skillSystem.elapsedMs, - ); + _statisticsManager.restoreFromLoadedGame(state); } - _initPreviousValues(state); + _statisticsManager.initPreviousValues(state); // 명예의 전당 체크 → 기본 가용 배속 결정 - final baseAvailableSpeeds = await _getAvailableSpeeds(); + final baseAvailableSpeeds = await _hallOfFameManager.getAvailableSpeeds(); final hasHallOfFame = baseAvailableSpeeds.contains(2); // 기본 배속 결정 (부스트 미적용 시) @@ -166,19 +194,10 @@ class GameSessionController extends ChangeNotifier { } // 배속 부스트 활성화 상태면 부스트 배속 적용, 아니면 기본 배속 - final List finalAvailableSpeeds; - final int finalInitialSpeed; - - if (_isSpeedBoostActive) { - // 부스트 상태: 부스트 배속만 사용, 기본 배속 저장 - finalAvailableSpeeds = [_speedBoostMultiplier]; - finalInitialSpeed = _speedBoostMultiplier; - _savedSpeedMultiplier = baseSpeed; // 종료 시 복원할 배속 저장 - } else { - // 일반 상태: 기본 배속 사용 - finalAvailableSpeeds = baseAvailableSpeeds; - finalInitialSpeed = baseSpeed; - } + final speedConfig = _speedBoostManager.calculateInitialSpeeds( + baseAvailableSpeeds: baseAvailableSpeeds, + baseSpeed: baseSpeed, + ); _loop = ProgressLoop( initialState: state, @@ -190,16 +209,15 @@ class GameSessionController extends ChangeNotifier { cheatsEnabled: cheatsEnabled, onPlayerDied: _onPlayerDied, onGameComplete: _onGameComplete, - availableSpeeds: finalAvailableSpeeds, - initialSpeedMultiplier: finalInitialSpeed, + availableSpeeds: speedConfig.speeds, + initialSpeedMultiplier: speedConfig.initialSpeed, ); _subscription = _loop!.stream.listen((next) { final elapsedMs = next.skillSystem.elapsedMs; // 버프 만료 체크 (게임 시간 기준) - _checkSpeedBoostExpiry(elapsedMs); - _checkAutoReviveExpiry(elapsedMs); - _updateStatistics(next); + _checkBuffExpiries(elapsedMs); + _statisticsManager.updateStatistics(next); _state = next; notifyListeners(); }); @@ -208,86 +226,36 @@ class GameSessionController extends ChangeNotifier { notifyListeners(); } - /// 가용 배속 목록 반환 - /// - /// - 기본: [1] (1x만) - /// - 명예의 전당에 캐릭터 있으면: [1, 2] (2x 해금) - /// - 광고 배속(5x/20x)은 별도 버프로만 활성화 - Future> _getAvailableSpeeds() async { - final hallOfFame = await _hallOfFameStorage.load(); - if (hallOfFame.entries.isNotEmpty) { - return [1, 2]; // 명예의 전당 캐릭터 있으면 2x 해금 - } - return [1]; // 기본: 1x만 - } - - /// 이전 값 초기화 (통계 변화 추적용) - void _initPreviousValues(GameState state) { - _previousLevel = state.traits.level; - _previousGold = state.inventory.gold; - _previousMonstersKilled = state.progress.monstersKilled; - _previousQuestsCompleted = state.progress.questCount; - } - - /// 상태 변화에 따른 통계 업데이트 - void _updateStatistics(GameState next) { - // 플레이 시간 업데이트 - _sessionStats = _sessionStats.updatePlayTime(next.skillSystem.elapsedMs); - - // 레벨업 감지 - if (next.traits.level > _previousLevel) { - final levelUps = next.traits.level - _previousLevel; - for (var i = 0; i < levelUps; i++) { - _sessionStats = _sessionStats.recordLevelUp(); - } - _previousLevel = next.traits.level; - - // 최고 레벨 업데이트 - unawaited(_statisticsStorage.updateHighestLevel(next.traits.level)); + /// 매 틱마다 버프 만료 체크 + void _checkBuffExpiries(int elapsedMs) { + // 속도 부스트 만료 체크 + final boostEnded = _speedBoostManager.checkExpiry( + elapsedMs: elapsedMs, + monetization: _monetization, + loop: _loop, + ); + if (boostEnded) { + _monetization = _monetization.copyWith(speedBoostEndMs: null); } - // 골드 변화 감지 - if (next.inventory.gold > _previousGold) { - final earned = next.inventory.gold - _previousGold; - _sessionStats = _sessionStats.recordGoldEarned(earned); - - // 최대 골드 업데이트 - unawaited(_statisticsStorage.updateHighestGold(next.inventory.gold)); - } else if (next.inventory.gold < _previousGold) { - final spent = _previousGold - next.inventory.gold; - _sessionStats = _sessionStats.recordGoldSpent(spent); - } - _previousGold = next.inventory.gold; - - // 몬스터 처치 감지 - if (next.progress.monstersKilled > _previousMonstersKilled) { - final kills = next.progress.monstersKilled - _previousMonstersKilled; - for (var i = 0; i < kills; i++) { - _sessionStats = _sessionStats.recordKill(); - } - _previousMonstersKilled = next.progress.monstersKilled; - } - - // 퀘스트 완료 감지 - if (next.progress.questCount > _previousQuestsCompleted) { - final quests = next.progress.questCount - _previousQuestsCompleted; - for (var i = 0; i < quests; i++) { - _sessionStats = _sessionStats.recordQuestComplete(); - } - _previousQuestsCompleted = next.progress.questCount; + // 자동부활 버프 만료 체크 + final endMs = _monetization.autoReviveEndMs; + if (endMs != null && elapsedMs >= endMs) { + _monetization = _monetization.copyWith(autoReviveEndMs: null); + debugPrint('[GameSession] Auto-revive buff expired'); + notifyListeners(); } } /// 누적 통계 로드 Future loadCumulativeStats() async { - _cumulativeStats = await _statisticsStorage.loadCumulative(); + await _statisticsManager.loadCumulativeStats(); notifyListeners(); } /// 세션 통계를 누적 통계에 병합 Future mergeSessionStats() async { - await _statisticsStorage.mergeSession(_sessionStats); - _cumulativeStats = await _statisticsStorage.loadCumulative(); + await _statisticsManager.mergeSessionStats(); notifyListeners(); } @@ -309,7 +277,10 @@ class GameSessionController extends ChangeNotifier { _monetization = savedMonetization ?? MonetizationState.initial(); // 복귀 보상 체크 (Phase 7) - _checkReturnRewards(loaded); + _returnRewardsManager.checkReturnRewards( + monetization: _monetization, + loaded: loaded, + ); // 저장된 치트 모드 상태 복원 await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false); @@ -324,7 +295,6 @@ class GameSessionController extends ChangeNotifier { /// 일시 정지 상태에서 재개 Future resume() async { if (_state == null || _status != GameSessionStatus.idle) return; - // 배속 부스트 상태는 startNew() 내에서 자동 처리됨 await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false); } @@ -352,7 +322,7 @@ class GameSessionController extends ChangeNotifier { // 배속 저장 (resume 시 복원용) if (loop != null) { - _savedSpeedMultiplier = loop.speedMultiplier; + _speedBoostManager.savedSpeedMultiplier = loop.speedMultiplier; } _loop = null; @@ -363,24 +333,22 @@ class GameSessionController extends ChangeNotifier { return loop.stop(saveOnStop: saveOnStop); } - // ============================================================================ - // Phase 4: 사망/부활 처리 - // ============================================================================ + // ========================================================================== + // 사망/부활 처리 + // ========================================================================== /// 플레이어 사망 콜백 (ProgressLoop에서 호출) void _onPlayerDied() { - _sessionStats = _sessionStats.recordDeath(); + _statisticsManager.recordDeath(); _status = GameSessionStatus.dead; notifyListeners(); - // 자동 부활 조건 확인: - // 1. 수동 토글 자동부활 (_autoResurrect) - // 2. 유료 유저 (항상 자동부활) - // 3. 광고 부활 버프 활성 (10분간) + // 자동 부활 조건 확인 final elapsedMs = _state?.skillSystem.elapsedMs ?? 0; - final shouldAutoResurrect = _autoResurrect || - IAPService.instance.isAdRemovalPurchased || - _monetization.isAutoReviveActive(elapsedMs); + final shouldAutoResurrect = _resurrectionManager.shouldAutoResurrect( + monetization: _monetization, + elapsedMs: elapsedMs, + ); if (shouldAutoResurrect) { _scheduleAutoResurrect(); @@ -388,17 +356,16 @@ class GameSessionController extends ChangeNotifier { } /// 자동 부활 예약 (Auto-Resurrection Scheduler) - /// - /// 사망 오버레이를 잠시 표시한 후 자동으로 부활 처리 void _scheduleAutoResurrect() { Future.delayed(const Duration(milliseconds: 800), () async { if (_status != GameSessionStatus.dead) return; // 자동 부활 조건 재확인 final elapsedMs = _state?.skillSystem.elapsedMs ?? 0; - final shouldAutoResurrect = _autoResurrect || - IAPService.instance.isAdRemovalPurchased || - _monetization.isAutoReviveActive(elapsedMs); + final shouldAutoResurrect = _resurrectionManager.shouldAutoResurrect( + monetization: _monetization, + elapsedMs: elapsedMs, + ); if (shouldAutoResurrect) { await resurrect(); @@ -407,12 +374,58 @@ class GameSessionController extends ChangeNotifier { }); } + /// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로) + Future resurrect() async { + if (_state == null || !_state!.isDead) return; + + final resurrectedState = await _resurrectionManager.processResurrection( + state: _state!, + saveManager: saveManager, + cheatsEnabled: _cheatsEnabled, + monetization: _monetization, + ); + + if (resurrectedState != null) { + _state = resurrectedState; + _status = GameSessionStatus.idle; + notifyListeners(); + } + } + + /// 부활 후 게임 재개 + Future resumeAfterResurrection() async { + if (_state == null) return; + await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false); + } + + /// 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활 버프) + Future adRevive() async { + if (_state == null || !_state!.isDead) return; + + final result = await _resurrectionManager.processAdRevive( + state: _state!, + saveManager: saveManager, + cheatsEnabled: _cheatsEnabled, + monetization: _monetization, + ); + + if (result.success) { + _state = result.state; + _monetization = result.monetization; + _status = GameSessionStatus.idle; + notifyListeners(); + } + } + + // ========================================================================== + // 명예의 전당 + // ========================================================================== + /// 게임 클리어 콜백 (ProgressLoop에서 호출, Act V 완료 시) void _onGameComplete() { _status = GameSessionStatus.complete; notifyListeners(); - // Hall of Fame 등록 (비동기) unawaited(_registerToHallOfFame()); } @@ -423,497 +436,91 @@ class GameSessionController extends ChangeNotifier { return; } - try { - debugPrint('[HallOfFame] Starting registration...'); - - // 최종 전투 스탯 계산 (CombatStats) - final combatStats = CombatStats.fromStats( - stats: _state!.stats, - equipment: _state!.equipment, - level: _state!.traits.level, - ); - - final entry = HallOfFameEntry.fromGameState( - state: _state!, - totalDeaths: _state!.progress.deathCount, // GameState에 저장된 값 사용 - monstersKilled: _state!.progress.monstersKilled, - combatStats: combatStats, - ); - - debugPrint( - '[HallOfFame] Entry created: ${entry.characterName} Lv.${entry.level}', - ); - - final success = await _hallOfFameStorage.addEntry(entry); - debugPrint('[HallOfFame] Storage save result: $success'); - - // 통계 기록 - await _statisticsStorage.recordGameComplete(); - debugPrint('[HallOfFame] Registration complete'); - - // 클리어된 세이브 파일 삭제 (중복 등록 방지) - if (success) { - final deleteResult = await saveManager.deleteSave(); - debugPrint('[HallOfFame] Save file deleted: ${deleteResult.success}'); - } - } catch (e, st) { - debugPrint('[HallOfFame] ERROR: $e'); - debugPrint('[HallOfFame] StackTrace: $st'); - } + await _hallOfFameManager.registerCharacter( + state: _state!, + saveManager: saveManager, + statisticsManager: _statisticsManager, + ); } /// 테스트 캐릭터 생성 (디버그 모드 전용) - /// - /// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진 - /// 캐릭터로 변환하여 명예의 전당에 등록하고 세이브를 삭제함. Future createTestCharacter() async { if (_state == null) { debugPrint('[TestCharacter] _state is null'); return false; } - try { - debugPrint('[TestCharacter] Creating test character...'); + await _stopLoop(saveOnStop: false); - // 게임 일시정지 - await _stopLoop(saveOnStop: false); + final success = await _hallOfFameManager.createTestCharacter( + state: _state!, + saveManager: saveManager, + ); - // TestCharacterService로 테스트 캐릭터 생성 - final testService = TestCharacterService(rng: _state!.rng); - - final entry = testService.createTestCharacter(_state!); - - debugPrint( - '[TestCharacter] Entry created: ${entry.characterName} Lv.${entry.level}', - ); - - // 명예의 전당에 등록 - final success = await _hallOfFameStorage.addEntry(entry); - debugPrint('[TestCharacter] HallOfFame save result: $success'); - - if (success) { - // 세이브 파일 삭제 - final deleteResult = await saveManager.deleteSave(); - debugPrint('[TestCharacter] Save deleted: ${deleteResult.success}'); - } - - // 상태 초기화 + if (success) { _state = null; _status = GameSessionStatus.idle; notifyListeners(); - - debugPrint('[TestCharacter] Complete'); - return success; - } catch (e, st) { - debugPrint('[TestCharacter] ERROR: $e'); - debugPrint('[TestCharacter] StackTrace: $st'); - return false; - } - } - - /// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로) - /// - /// HP/MP 회복, 빈 슬롯에 장비 자동 구매 - /// 게임 재개는 resumeAfterResurrection()으로 별도 호출 필요 - Future resurrect() async { - if (_state == null || !_state!.isDead) return; - - // ResurrectionService를 사용하여 부활 처리 - final shopService = ShopService(rng: _state!.rng); - final resurrectionService = ResurrectionService(shopService: shopService); - - final resurrectedState = resurrectionService.processResurrection(_state!); - - // 상태 업데이트 (게임 재개 없이) - _state = resurrectedState; - _status = GameSessionStatus.idle; // 사망 상태 해제 - - // 저장 (치트 모드 상태 유지) - await saveManager.saveState( - resurrectedState, - cheatsEnabled: _cheatsEnabled, - monetization: _monetization, - ); - - notifyListeners(); - } - - /// 부활 후 게임 재개 - /// - /// resurrect() 호출 후 애니메이션이 끝난 뒤 호출 - Future resumeAfterResurrection() async { - if (_state == null) return; - - // 게임 재개 - await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false); - } - - // =========================================================================== - // 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활) - // =========================================================================== - - /// 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활 버프) - /// - /// 유료 유저: 광고 없이 부활 - /// 무료 유저: 리워드 광고 시청 후 부활 - Future adRevive() async { - if (_state == null || !_state!.isDead) return; - - final shopService = ShopService(rng: _state!.rng); - final resurrectionService = ResurrectionService(shopService: shopService); - - // 부활 처리 함수 - void processRevive() { - _state = resurrectionService.processAdRevive(_state!); - _status = GameSessionStatus.idle; - - // 10분 자동부활 버프 활성화 (elapsedMs 기준) - final buffEndMs = _state!.skillSystem.elapsedMs + 600000; // 10분 = 600,000ms - _monetization = _monetization.copyWith( - autoReviveEndMs: buffEndMs, - ); - - debugPrint('[GameSession] Ad revive complete, auto-revive buff until $buffEndMs ms'); } - // 유료 유저는 광고 없이 부활 - if (IAPService.instance.isAdRemovalPurchased) { - processRevive(); - await saveManager.saveState( - _state!, - cheatsEnabled: _cheatsEnabled, - monetization: _monetization, - ); - notifyListeners(); - debugPrint('[GameSession] Ad revive (paid user)'); - return; - } - - // 무료 유저는 리워드 광고 필요 - final adResult = await AdService.instance.showRewardedAd( - adType: AdType.rewardRevive, - onRewarded: processRevive, - ); - - if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { - await saveManager.saveState( - _state!, - cheatsEnabled: _cheatsEnabled, - monetization: _monetization, - ); - notifyListeners(); - debugPrint('[GameSession] Ad revive (free user with ad)'); - } else { - debugPrint('[GameSession] Ad revive failed: $adResult'); - } + return success; } - /// 사망 상태 여부 - bool get isDead => - _status == GameSessionStatus.dead || (_state?.isDead ?? false); - - /// 게임 클리어 여부 - bool get isComplete => _status == GameSessionStatus.complete; - - // =========================================================================== + // ========================================================================== // 속도 부스트 (Phase 6) - // =========================================================================== - - /// 속도 부스트 활성화 여부 - bool get isSpeedBoostActive => _isSpeedBoostActive; - - /// 광고 표시 중 여부 (lifecycle reload 방지용) - bool get isShowingAd => _isShowingAd; - - /// 최근 광고를 시청했는지 여부 (1초 이내) - /// 광고 종료 후 resumed 이벤트가 늦게 발생하는 경우를 처리 - bool get isRecentlyShowedAd { - if (_adEndTimeMs == 0) return false; - return DateTime.now().millisecondsSinceEpoch - _adEndTimeMs < 1000; - } - - /// 속도 부스트 남은 시간 (초) - 게임 시간(elapsedMs) 기준 계산 - int get speedBoostRemainingSeconds { - if (!_isSpeedBoostActive) return 0; - final endMs = _monetization.speedBoostEndMs; - if (endMs == null) return 0; - final currentMs = _state?.skillSystem.elapsedMs ?? 0; - final remainingMs = endMs - currentMs; - return remainingMs > 0 ? (remainingMs / 1000).ceil() : 0; - } - - /// 속도 부스트 배율 - int get speedBoostMultiplier => _speedBoostMultiplier; - - /// 속도 부스트 지속 시간 (초) - int get speedBoostDuration => _speedBoostDuration; - - /// 현재 실제 배속 (부스트 적용 포함) - int get currentSpeedMultiplier { - if (_isSpeedBoostActive) return _speedBoostMultiplier; - return _loop?.speedMultiplier ?? _savedSpeedMultiplier; - } + // ========================================================================== /// 속도 부스트 활성화 (광고 시청 후) - /// - /// 유료 유저: 무료 활성화 - /// 무료 유저: 인터스티셜 광고 시청 후 활성화 - /// Returns: 활성화 성공 여부 Future activateSpeedBoost() async { - if (_isSpeedBoostActive) return false; // 이미 활성화됨 - if (_loop == null) return false; - - // 유료 유저는 무료 활성화 - if (IAPService.instance.isAdRemovalPurchased) { - _startSpeedBoost(); - debugPrint('[GameSession] Speed boost activated (paid user)'); - return true; - } - - // 무료 유저는 인터스티셜 광고 필요 - bool activated = false; - _isShowingAd = true; // 광고 표시 시작 (lifecycle reload 방지) - - final adResult = await AdService.instance.showInterstitialAd( - adType: AdType.interstitialSpeed, - onComplete: () { - _startSpeedBoost(); - activated = true; - }, + final (success, updatedMonetization) = + await _speedBoostManager.activateSpeedBoost( + loop: _loop, + monetization: _monetization, + currentElapsedMs: _state?.skillSystem.elapsedMs ?? 0, ); - _isShowingAd = false; // 광고 표시 종료 - _adEndTimeMs = DateTime.now().millisecondsSinceEpoch; // 종료 시점 기록 - - if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { - debugPrint('[GameSession] Speed boost activated (free user with ad)'); - return activated; - } - - debugPrint('[GameSession] Speed boost activation failed: $adResult'); - return false; - } - - /// 속도 부스트 시작 (내부) - /// - /// 게임 시간(elapsedMs) 기준으로 종료 시점 설정. - /// 실시간 타이머 대신 매 틱에서 종료 여부 체크. - void _startSpeedBoost() { - _isSpeedBoostActive = true; - - // loop가 있으면 현재 배속 저장 및 즉시 적용 - if (_loop != null) { - _savedSpeedMultiplier = _loop!.speedMultiplier; - _loop!.updateAvailableSpeeds([_speedBoostMultiplier]); - } - - // 종료 시점 저장 (게임 시간 기준) - final currentElapsedMs = _state?.skillSystem.elapsedMs ?? 0; - final endMs = currentElapsedMs + (_speedBoostDuration * 1000); - _monetization = _monetization.copyWith(speedBoostEndMs: endMs); - - debugPrint('[GameSession] Speed boost started, ends at $endMs ms'); - notifyListeners(); - } - - /// 매 틱마다 부스트 만료 체크 - /// - /// 게임 시간(elapsedMs)이 종료 시점을 넘으면 자동 종료. - void _checkSpeedBoostExpiry(int elapsedMs) { - if (!_isSpeedBoostActive) return; - - final endMs = _monetization.speedBoostEndMs; - if (endMs != null && elapsedMs >= endMs) { - _endSpeedBoost(); - } - } - - /// 매 틱마다 자동부활 버프 만료 체크 - /// - /// 게임 시간(elapsedMs)이 종료 시점을 넘으면 버프 상태 초기화. - void _checkAutoReviveExpiry(int elapsedMs) { - final endMs = _monetization.autoReviveEndMs; - if (endMs == null) return; - - if (elapsedMs >= endMs) { - _monetization = _monetization.copyWith(autoReviveEndMs: null); - debugPrint('[GameSession] Auto-revive buff expired'); + if (success) { + _monetization = updatedMonetization; notifyListeners(); } - } - /// 속도 부스트 종료 (내부) - void _endSpeedBoost() { - _isSpeedBoostActive = false; - - // monetization 상태 초기화 - _monetization = _monetization.copyWith(speedBoostEndMs: null); - - // 원래 배속 복원 - final currentLoop = _loop; - final savedSpeed = _savedSpeedMultiplier; - - if (currentLoop != null) { - _getAvailableSpeeds().then((speeds) { - // 콜백 실행 시점에 loop가 변경되지 않았는지 확인 - if (_loop == currentLoop) { - currentLoop.updateAvailableSpeeds(speeds); - currentLoop.setSpeed(savedSpeed); - debugPrint('[GameSession] Speed restored to ${savedSpeed}x'); - } - }); - } - - notifyListeners(); - debugPrint('[GameSession] Speed boost ended'); + return success; } /// 속도 부스트 수동 취소 void cancelSpeedBoost() { - if (_isSpeedBoostActive) { - _endSpeedBoost(); - } + _monetization = _speedBoostManager.cancelSpeedBoost( + loop: _loop, + monetization: _monetization, + ); + notifyListeners(); } - // =========================================================================== + // ========================================================================== // 복귀 보상 (Phase 7) - // =========================================================================== - - /// 현재 수익화 상태 - MonetizationState get monetization => _monetization; - - /// 대기 중인 복귀 보상 - ReturnChestReward? get pendingReturnReward => _pendingReturnReward; - - /// 복귀 보상 체크 (로드 시 호출) - void _checkReturnRewards(GameState loaded) { - final rewardsService = ReturnRewardsService.instance; - final debugSettings = DebugSettingsService.instance; - - // 디버그 모드: 오프라인 시간 시뮬레이션 적용 - final lastPlayTime = debugSettings.getSimulatedLastPlayTime( - _monetization.lastPlayTime, - ); - - final reward = rewardsService.calculateReward( - lastPlayTime: lastPlayTime, - currentTime: DateTime.now(), - isPaidUser: _monetization.isPaidUser, - ); - - if (reward.hasReward) { - _pendingReturnReward = reward; - debugPrint('[ReturnRewards] Reward available: ${reward.chestCount} chests, ' - '${reward.hoursAway} hours away'); - - // UI에서 다이얼로그 표시를 위해 콜백 호출 - // startNew 후에 호출하도록 딜레이 - Future.delayed(const Duration(milliseconds: 500), () { - if (_pendingReturnReward != null) { - onReturnRewardAvailable?.call(_pendingReturnReward!); - } - }); - } - } + // ========================================================================== /// 복귀 보상 수령 완료 (상자 보상 적용) - /// - /// [rewards] 오픈된 상자 보상 목록 - void applyReturnReward(List rewards) { + Future applyReturnReward(List rewards) async { if (_state == null) return; - if (rewards.isEmpty) { - // 보상 없이 건너뛴 경우 - _pendingReturnReward = null; - debugPrint('[ReturnRewards] Reward skipped'); - return; - } - var updatedState = _state!; - - // 보상 적용 - for (final reward in rewards) { - switch (reward.type) { - case ChestRewardType.equipment: - if (reward.equipment != null) { - // 현재 장비와 비교하여 더 좋으면 자동 장착 - final slotIndex = reward.equipment!.slot.index; - final currentItem = updatedState.equipment.getItemByIndex(slotIndex); - if (currentItem.isEmpty || - reward.equipment!.itemWeight > currentItem.itemWeight) { - updatedState = updatedState.copyWith( - equipment: updatedState.equipment.setItemByIndex( - slotIndex, - reward.equipment!, - ), - ); - debugPrint('[ReturnRewards] Equipped: ${reward.equipment!.name}'); - } else { - // 더 좋지 않으면 판매 (골드로 변환) - final sellPrice = - (reward.equipment!.level * 50 * 0.3).round().clamp(1, 99999); - updatedState = updatedState.copyWith( - inventory: updatedState.inventory.copyWith( - gold: updatedState.inventory.gold + sellPrice, - ), - ); - debugPrint('[ReturnRewards] Sold: ${reward.equipment!.name} ' - 'for $sellPrice gold'); - } - } - case ChestRewardType.potion: - if (reward.potionId != null) { - updatedState = updatedState.copyWith( - potionInventory: updatedState.potionInventory.addPotion( - reward.potionId!, - reward.potionCount ?? 1, - ), - ); - debugPrint('[ReturnRewards] Added potion: ${reward.potionId} ' - 'x${reward.potionCount}'); - } - case ChestRewardType.gold: - if (reward.gold != null && reward.gold! > 0) { - updatedState = updatedState.copyWith( - inventory: updatedState.inventory.copyWith( - gold: updatedState.inventory.gold + reward.gold!, - ), - ); - debugPrint('[ReturnRewards] Added gold: ${reward.gold}'); - } - case ChestRewardType.experience: - if (reward.experience != null && reward.experience! > 0) { - updatedState = updatedState.copyWith( - progress: updatedState.progress.copyWith( - exp: updatedState.progress.exp.copyWith( - position: - updatedState.progress.exp.position + reward.experience!, - ), - ), - ); - debugPrint('[ReturnRewards] Added experience: ${reward.experience}'); - } - } - } - - _state = updatedState; - _loop?.replaceState(updatedState); // ProgressLoop 상태도 업데이트 - - // 저장 - unawaited(saveManager.saveState( - _state!, + final updatedState = await _returnRewardsManager.applyReturnReward( + rewards: rewards, + state: _state!, + loop: _loop, + saveManager: saveManager, cheatsEnabled: _cheatsEnabled, monetization: _monetization, - )); + ); - _pendingReturnReward = null; - notifyListeners(); - - debugPrint('[ReturnRewards] Rewards applied: ${rewards.length} items'); + if (updatedState != null) { + _state = updatedState; + notifyListeners(); + } } /// 복귀 보상 건너뛰기 void skipReturnReward() { - _pendingReturnReward = null; - debugPrint('[ReturnRewards] Reward skipped by user'); + _returnRewardsManager.skipReturnReward(); } } diff --git a/lib/src/features/game/managers/game_statistics_manager.dart b/lib/src/features/game/managers/game_statistics_manager.dart new file mode 100644 index 0000000..84ce0a4 --- /dev/null +++ b/lib/src/features/game/managers/game_statistics_manager.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/game_statistics.dart'; +import 'package:asciineverdie/src/core/storage/statistics_storage.dart'; + +/// 게임 통계 추적 및 관리를 담당하는 매니저 +/// +/// 세션 통계와 누적 통계를 관리하고, 게임 상태 변화에 따라 +/// 통계를 자동 업데이트합니다. +class GameStatisticsManager { + GameStatisticsManager({ + StatisticsStorage? statisticsStorage, + }) : _statisticsStorage = statisticsStorage ?? StatisticsStorage(); + + final StatisticsStorage _statisticsStorage; + + // 현재 세션 통계 + SessionStatistics _sessionStats = SessionStatistics.empty(); + + // 누적 통계 + CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty(); + + // 이전 값 (변화 감지용) + int _previousLevel = 0; + int _previousGold = 0; + int _previousMonstersKilled = 0; + int _previousQuestsCompleted = 0; + + /// 현재 세션 통계 + SessionStatistics get sessionStats => _sessionStats; + + /// 누적 통계 + CumulativeStatistics get cumulativeStats => _cumulativeStats; + + /// 새 게임 시작 시 통계 초기화 + Future initializeForNewGame() async { + _sessionStats = SessionStatistics.empty(); + await _statisticsStorage.recordGameStart(); + } + + /// 게임 로드 시 통계 복원 + void restoreFromLoadedGame(GameState state) { + _sessionStats = _sessionStats.copyWith( + deathCount: state.progress.deathCount, + questsCompleted: state.progress.questCount, + monstersKilled: state.progress.monstersKilled, + playTimeMs: state.skillSystem.elapsedMs, + ); + } + + /// 이전 값 초기화 (통계 변화 추적용) + void initPreviousValues(GameState state) { + _previousLevel = state.traits.level; + _previousGold = state.inventory.gold; + _previousMonstersKilled = state.progress.monstersKilled; + _previousQuestsCompleted = state.progress.questCount; + } + + /// 상태 변화에 따른 통계 업데이트 + /// + /// 매 틱마다 호출되어 레벨업, 골드, 몬스터 처치, 퀘스트 완료 등을 추적 + void updateStatistics(GameState next) { + // 플레이 시간 업데이트 + _sessionStats = _sessionStats.updatePlayTime(next.skillSystem.elapsedMs); + + // 레벨업 감지 + _detectLevelUps(next); + + // 골드 변화 감지 + _detectGoldChanges(next); + + // 몬스터 처치 감지 + _detectMonsterKills(next); + + // 퀘스트 완료 감지 + _detectQuestCompletions(next); + } + + /// 레벨업 감지 및 기록 + void _detectLevelUps(GameState next) { + if (next.traits.level > _previousLevel) { + final levelUps = next.traits.level - _previousLevel; + for (var i = 0; i < levelUps; i++) { + _sessionStats = _sessionStats.recordLevelUp(); + } + _previousLevel = next.traits.level; + + // 최고 레벨 업데이트 + unawaited(_statisticsStorage.updateHighestLevel(next.traits.level)); + } + } + + /// 골드 변화 감지 및 기록 + void _detectGoldChanges(GameState next) { + if (next.inventory.gold > _previousGold) { + final earned = next.inventory.gold - _previousGold; + _sessionStats = _sessionStats.recordGoldEarned(earned); + + // 최대 골드 업데이트 + unawaited(_statisticsStorage.updateHighestGold(next.inventory.gold)); + } else if (next.inventory.gold < _previousGold) { + final spent = _previousGold - next.inventory.gold; + _sessionStats = _sessionStats.recordGoldSpent(spent); + } + _previousGold = next.inventory.gold; + } + + /// 몬스터 처치 감지 및 기록 + void _detectMonsterKills(GameState next) { + if (next.progress.monstersKilled > _previousMonstersKilled) { + final kills = next.progress.monstersKilled - _previousMonstersKilled; + for (var i = 0; i < kills; i++) { + _sessionStats = _sessionStats.recordKill(); + } + _previousMonstersKilled = next.progress.monstersKilled; + } + } + + /// 퀘스트 완료 감지 및 기록 + void _detectQuestCompletions(GameState next) { + if (next.progress.questCount > _previousQuestsCompleted) { + final quests = next.progress.questCount - _previousQuestsCompleted; + for (var i = 0; i < quests; i++) { + _sessionStats = _sessionStats.recordQuestComplete(); + } + _previousQuestsCompleted = next.progress.questCount; + } + } + + /// 사망 기록 + void recordDeath() { + _sessionStats = _sessionStats.recordDeath(); + } + + /// 게임 클리어 기록 + Future recordGameComplete() async { + await _statisticsStorage.recordGameComplete(); + } + + /// 누적 통계 로드 + Future loadCumulativeStats() async { + _cumulativeStats = await _statisticsStorage.loadCumulative(); + } + + /// 세션 통계를 누적 통계에 병합 + Future mergeSessionStats() async { + await _statisticsStorage.mergeSession(_sessionStats); + _cumulativeStats = await _statisticsStorage.loadCumulative(); + } +} diff --git a/lib/src/features/game/managers/hall_of_fame_manager.dart b/lib/src/features/game/managers/hall_of_fame_manager.dart new file mode 100644 index 0000000..35033e3 --- /dev/null +++ b/lib/src/features/game/managers/hall_of_fame_manager.dart @@ -0,0 +1,135 @@ +import 'package:asciineverdie/src/core/engine/test_character_service.dart'; +import 'package:asciineverdie/src/core/model/combat_stats.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; +import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart'; +import 'package:asciineverdie/src/core/storage/save_manager.dart'; +import 'package:asciineverdie/src/features/game/managers/game_statistics_manager.dart'; +import 'package:flutter/foundation.dart'; + +/// 명예의 전당 관리를 담당하는 매니저 +/// +/// 게임 클리어 시 캐릭터 등록, 테스트 캐릭터 생성 등을 담당합니다. +class HallOfFameManager { + HallOfFameManager({ + HallOfFameStorage? hallOfFameStorage, + }) : _hallOfFameStorage = hallOfFameStorage ?? HallOfFameStorage(); + + final HallOfFameStorage _hallOfFameStorage; + + /// 명예의 전당 데이터 로드 + Future load() async { + return _hallOfFameStorage.load(); + } + + /// 명예의 전당에 캐릭터가 있는지 확인 + Future hasEntries() async { + final hallOfFame = await _hallOfFameStorage.load(); + return hallOfFame.entries.isNotEmpty; + } + + /// 가용 배속 목록 반환 + /// + /// - 기본: [1] (1x만) + /// - 명예의 전당에 캐릭터 있으면: [1, 2] (2x 해금) + Future> getAvailableSpeeds() async { + final hasCharacters = await hasEntries(); + if (hasCharacters) { + return [1, 2]; // 명예의 전당 캐릭터 있으면 2x 해금 + } + return [1]; // 기본: 1x만 + } + + /// 명예의 전당 등록 + /// + /// 게임 클리어 시 호출되어 캐릭터 정보를 명예의 전당에 등록합니다. + /// 등록 성공 시 세이브 파일을 삭제합니다. + /// + /// Returns: 등록 성공 여부 + Future registerCharacter({ + required GameState state, + required SaveManager saveManager, + required GameStatisticsManager statisticsManager, + }) async { + try { + debugPrint('[HallOfFame] Starting registration...'); + + // 최종 전투 스탯 계산 + final combatStats = CombatStats.fromStats( + stats: state.stats, + equipment: state.equipment, + level: state.traits.level, + ); + + final entry = HallOfFameEntry.fromGameState( + state: state, + totalDeaths: state.progress.deathCount, + monstersKilled: state.progress.monstersKilled, + combatStats: combatStats, + ); + + debugPrint( + '[HallOfFame] Entry created: ${entry.characterName} Lv.${entry.level}', + ); + + final success = await _hallOfFameStorage.addEntry(entry); + debugPrint('[HallOfFame] Storage save result: $success'); + + // 통계 기록 + await statisticsManager.recordGameComplete(); + debugPrint('[HallOfFame] Registration complete'); + + // 클리어된 세이브 파일 삭제 (중복 등록 방지) + if (success) { + final deleteResult = await saveManager.deleteSave(); + debugPrint('[HallOfFame] Save file deleted: ${deleteResult.success}'); + } + + return success; + } catch (e, st) { + debugPrint('[HallOfFame] ERROR: $e'); + debugPrint('[HallOfFame] StackTrace: $st'); + return false; + } + } + + /// 테스트 캐릭터 생성 (디버그 모드 전용) + /// + /// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진 + /// 캐릭터로 변환하여 명예의 전당에 등록하고 세이브를 삭제합니다. + /// + /// Returns: 등록 성공 여부 + Future createTestCharacter({ + required GameState state, + required SaveManager saveManager, + }) async { + try { + debugPrint('[TestCharacter] Creating test character...'); + + // TestCharacterService로 테스트 캐릭터 생성 + final testService = TestCharacterService(rng: state.rng); + final entry = testService.createTestCharacter(state); + + debugPrint( + '[TestCharacter] Entry created: ${entry.characterName} Lv.${entry.level}', + ); + + // 명예의 전당에 등록 + final success = await _hallOfFameStorage.addEntry(entry); + debugPrint('[TestCharacter] HallOfFame save result: $success'); + + if (success) { + // 세이브 파일 삭제 + final deleteResult = await saveManager.deleteSave(); + debugPrint('[TestCharacter] Save deleted: ${deleteResult.success}'); + } + + debugPrint('[TestCharacter] Complete'); + return success; + } catch (e, st) { + debugPrint('[TestCharacter] ERROR: $e'); + debugPrint('[TestCharacter] StackTrace: $st'); + return false; + } + } +} diff --git a/lib/src/features/game/managers/resurrection_manager.dart b/lib/src/features/game/managers/resurrection_manager.dart new file mode 100644 index 0000000..3e63c20 --- /dev/null +++ b/lib/src/features/game/managers/resurrection_manager.dart @@ -0,0 +1,156 @@ +import 'package:asciineverdie/src/core/engine/ad_service.dart'; +import 'package:asciineverdie/src/core/engine/iap_service.dart'; +import 'package:asciineverdie/src/core/engine/resurrection_service.dart'; +import 'package:asciineverdie/src/core/engine/shop_service.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/monetization_state.dart'; +import 'package:asciineverdie/src/core/storage/save_manager.dart'; +import 'package:flutter/foundation.dart'; + +/// 부활 결과 +class ResurrectionResult { + const ResurrectionResult({ + required this.state, + required this.monetization, + required this.success, + }); + + final GameState state; + final MonetizationState monetization; + final bool success; +} + +/// 사망 및 부활 처리를 담당하는 매니저 +/// +/// 일반 부활, 광고 부활, 자동 부활 등 모든 부활 관련 로직을 담당합니다. +class ResurrectionManager { + ResurrectionManager(); + + /// 자동 부활 활성화 여부 + bool autoResurrect = false; + + /// 자동 부활 조건 확인 + /// + /// 다음 조건 중 하나라도 만족하면 자동 부활: + /// 1. 수동 토글 자동부활 (autoResurrect) + /// 2. 유료 유저 (IAP 광고 제거 구매) + /// 3. 광고 부활 버프 활성 (10분간) + bool shouldAutoResurrect({ + required MonetizationState monetization, + required int elapsedMs, + }) { + return autoResurrect || + IAPService.instance.isAdRemovalPurchased || + monetization.isAutoReviveActive(elapsedMs); + } + + /// 일반 부활 처리 (HP 50% 회복) + /// + /// Returns: 부활된 GameState (부활 불가 시 null) + Future processResurrection({ + required GameState state, + required SaveManager saveManager, + required bool cheatsEnabled, + required MonetizationState monetization, + }) async { + if (!state.isDead) return null; + + final shopService = ShopService(rng: state.rng); + final resurrectionService = ResurrectionService(shopService: shopService); + + final resurrectedState = resurrectionService.processResurrection(state); + + // 저장 + await saveManager.saveState( + resurrectedState, + cheatsEnabled: cheatsEnabled, + monetization: monetization, + ); + + debugPrint('[Resurrection] Normal resurrection complete'); + return resurrectedState; + } + + /// 광고 부활 처리 (HP 100% + 아이템 복구 + 10분 자동부활 버프) + /// + /// 유료 유저: 광고 없이 부활 + /// 무료 유저: 리워드 광고 시청 후 부활 + /// + /// Returns: ResurrectionResult (부활 불가/실패 시 success=false) + Future processAdRevive({ + required GameState state, + required SaveManager saveManager, + required bool cheatsEnabled, + required MonetizationState monetization, + }) async { + if (!state.isDead) { + return ResurrectionResult( + state: state, + monetization: monetization, + success: false, + ); + } + + final shopService = ShopService(rng: state.rng); + final resurrectionService = ResurrectionService(shopService: shopService); + + // 부활 처리 결과 저장용 + GameState? revivedState; + MonetizationState updatedMonetization = monetization; + + void processRevive() { + revivedState = resurrectionService.processAdRevive(state); + + // 10분 자동부활 버프 활성화 (elapsedMs 기준) + final buffEndMs = + revivedState!.skillSystem.elapsedMs + 600000; // 10분 = 600,000ms + updatedMonetization = monetization.copyWith(autoReviveEndMs: buffEndMs); + + debugPrint( + '[Resurrection] Ad revive complete, auto-revive buff until $buffEndMs ms'); + } + + // 유료 유저는 광고 없이 부활 + if (IAPService.instance.isAdRemovalPurchased) { + processRevive(); + await saveManager.saveState( + revivedState!, + cheatsEnabled: cheatsEnabled, + monetization: updatedMonetization, + ); + debugPrint('[Resurrection] Ad revive (paid user)'); + return ResurrectionResult( + state: revivedState!, + monetization: updatedMonetization, + success: true, + ); + } + + // 무료 유저는 리워드 광고 필요 + final adResult = await AdService.instance.showRewardedAd( + adType: AdType.rewardRevive, + onRewarded: processRevive, + ); + + if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { + await saveManager.saveState( + revivedState!, + cheatsEnabled: cheatsEnabled, + monetization: updatedMonetization, + ); + debugPrint('[Resurrection] Ad revive (free user with ad)'); + return ResurrectionResult( + state: revivedState!, + monetization: updatedMonetization, + success: true, + ); + } + + debugPrint('[Resurrection] Ad revive failed: $adResult'); + return ResurrectionResult( + state: state, + monetization: monetization, + success: false, + ); + } +} diff --git a/lib/src/features/game/managers/return_rewards_manager.dart b/lib/src/features/game/managers/return_rewards_manager.dart new file mode 100644 index 0000000..eb4be2a --- /dev/null +++ b/lib/src/features/game/managers/return_rewards_manager.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'package:asciineverdie/src/core/engine/debug_settings_service.dart'; +import 'package:asciineverdie/src/core/engine/progress_loop.dart'; +import 'package:asciineverdie/src/core/engine/return_rewards_service.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/monetization_state.dart'; +import 'package:asciineverdie/src/core/model/treasure_chest.dart'; +import 'package:asciineverdie/src/core/storage/save_manager.dart'; +import 'package:flutter/foundation.dart'; + +/// 복귀 보상 기능 관리자 (Phase 7) +/// +/// 장시간 접속하지 않은 유저에게 복귀 보상(상자)을 지급하는 +/// 기능을 담당합니다. +class ReturnRewardsManager { + ReturnRewardsManager(); + + // 대기 중인 복귀 보상 + ReturnChestReward? _pendingReturnReward; + + /// 복귀 보상 콜백 (UI에서 다이얼로그 표시용) + void Function(ReturnChestReward reward)? onReturnRewardAvailable; + + /// 대기 중인 복귀 보상 + ReturnChestReward? get pendingReturnReward => _pendingReturnReward; + + /// 복귀 보상 체크 (로드 시 호출) + /// + /// 오프라인 시간에 따라 보상을 계산하고, 보상이 있으면 + /// UI에 알림을 예약합니다. + void checkReturnRewards({ + required MonetizationState monetization, + required GameState loaded, + }) { + final rewardsService = ReturnRewardsService.instance; + final debugSettings = DebugSettingsService.instance; + + // 디버그 모드: 오프라인 시간 시뮬레이션 적용 + final lastPlayTime = debugSettings.getSimulatedLastPlayTime( + monetization.lastPlayTime, + ); + + final reward = rewardsService.calculateReward( + lastPlayTime: lastPlayTime, + currentTime: DateTime.now(), + isPaidUser: monetization.isPaidUser, + ); + + if (reward.hasReward) { + _pendingReturnReward = reward; + debugPrint('[ReturnRewards] Reward available: ${reward.chestCount} chests, ' + '${reward.hoursAway} hours away'); + + // UI에서 다이얼로그 표시를 위해 콜백 호출 + // startNew 후에 호출하도록 딜레이 + Future.delayed(const Duration(milliseconds: 500), () { + if (_pendingReturnReward != null) { + onReturnRewardAvailable?.call(_pendingReturnReward!); + } + }); + } + } + + /// 복귀 보상 수령 완료 (상자 보상 적용) + /// + /// [rewards] 오픈된 상자 보상 목록 + /// Returns: 보상이 적용된 새로운 GameState + Future applyReturnReward({ + required List rewards, + required GameState state, + required ProgressLoop? loop, + required SaveManager saveManager, + required bool cheatsEnabled, + required MonetizationState monetization, + }) async { + if (rewards.isEmpty) { + // 보상 없이 건너뛴 경우 + _pendingReturnReward = null; + debugPrint('[ReturnRewards] Reward skipped'); + return null; + } + + var updatedState = state; + + // 보상 적용 + for (final reward in rewards) { + updatedState = _applySingleReward(updatedState, reward); + } + + loop?.replaceState(updatedState); // ProgressLoop 상태도 업데이트 + + // 저장 + unawaited(saveManager.saveState( + updatedState, + cheatsEnabled: cheatsEnabled, + monetization: monetization, + )); + + _pendingReturnReward = null; + + debugPrint('[ReturnRewards] Rewards applied: ${rewards.length} items'); + return updatedState; + } + + /// 단일 보상 적용 (내부) + GameState _applySingleReward(GameState state, ChestReward reward) { + switch (reward.type) { + case ChestRewardType.equipment: + return _applyEquipmentReward(state, reward); + case ChestRewardType.potion: + return _applyPotionReward(state, reward); + case ChestRewardType.gold: + return _applyGoldReward(state, reward); + case ChestRewardType.experience: + return _applyExperienceReward(state, reward); + } + } + + /// 장비 보상 적용 + GameState _applyEquipmentReward(GameState state, ChestReward reward) { + if (reward.equipment == null) return state; + + // 현재 장비와 비교하여 더 좋으면 자동 장착 + final slotIndex = reward.equipment!.slot.index; + final currentItem = state.equipment.getItemByIndex(slotIndex); + + if (currentItem.isEmpty || + reward.equipment!.itemWeight > currentItem.itemWeight) { + debugPrint('[ReturnRewards] Equipped: ${reward.equipment!.name}'); + return state.copyWith( + equipment: state.equipment.setItemByIndex( + slotIndex, + reward.equipment!, + ), + ); + } + + // 더 좋지 않으면 판매 (골드로 변환) + final sellPrice = + (reward.equipment!.level * 50 * 0.3).round().clamp(1, 99999); + debugPrint('[ReturnRewards] Sold: ${reward.equipment!.name} ' + 'for $sellPrice gold'); + return state.copyWith( + inventory: state.inventory.copyWith( + gold: state.inventory.gold + sellPrice, + ), + ); + } + + /// 포션 보상 적용 + GameState _applyPotionReward(GameState state, ChestReward reward) { + if (reward.potionId == null) return state; + + debugPrint('[ReturnRewards] Added potion: ${reward.potionId} ' + 'x${reward.potionCount}'); + return state.copyWith( + potionInventory: state.potionInventory.addPotion( + reward.potionId!, + reward.potionCount ?? 1, + ), + ); + } + + /// 골드 보상 적용 + GameState _applyGoldReward(GameState state, ChestReward reward) { + if (reward.gold == null || reward.gold! <= 0) return state; + + debugPrint('[ReturnRewards] Added gold: ${reward.gold}'); + return state.copyWith( + inventory: state.inventory.copyWith( + gold: state.inventory.gold + reward.gold!, + ), + ); + } + + /// 경험치 보상 적용 + GameState _applyExperienceReward(GameState state, ChestReward reward) { + if (reward.experience == null || reward.experience! <= 0) return state; + + debugPrint('[ReturnRewards] Added experience: ${reward.experience}'); + return state.copyWith( + progress: state.progress.copyWith( + exp: state.progress.exp.copyWith( + position: state.progress.exp.position + reward.experience!, + ), + ), + ); + } + + /// 복귀 보상 건너뛰기 + void skipReturnReward() { + _pendingReturnReward = null; + debugPrint('[ReturnRewards] Reward skipped by user'); + } +} diff --git a/lib/src/features/game/managers/speed_boost_manager.dart b/lib/src/features/game/managers/speed_boost_manager.dart new file mode 100644 index 0000000..8b97035 --- /dev/null +++ b/lib/src/features/game/managers/speed_boost_manager.dart @@ -0,0 +1,211 @@ +import 'package:asciineverdie/src/core/engine/ad_service.dart'; +import 'package:asciineverdie/src/core/engine/iap_service.dart'; +import 'package:asciineverdie/src/core/engine/progress_loop.dart'; +import 'package:asciineverdie/src/core/model/monetization_state.dart'; +import 'package:flutter/foundation.dart'; + +/// 속도 부스트(광고 배속) 기능 관리자 +/// +/// 광고 시청 후 일정 시간 동안 게임 속도를 높이는 기능을 담당합니다. +/// 게임 시간(elapsedMs) 기준으로 종료 시점을 판정합니다. +class SpeedBoostManager { + SpeedBoostManager({ + required bool Function() cheatsEnabledGetter, + required Future> Function() getAvailableSpeeds, + }) : _cheatsEnabledGetter = cheatsEnabledGetter, + _getAvailableSpeeds = getAvailableSpeeds; + + final bool Function() _cheatsEnabledGetter; + final Future> Function() _getAvailableSpeeds; + + // 속도 부스트 상태 + bool _isSpeedBoostActive = false; + static const int _speedBoostDuration = 300; // 5분 (게임 시간 기준, 초) + + // 광고 표시 중 플래그 (lifecycle reload 방지용) + bool _isShowingAd = false; + int _adEndTimeMs = 0; // 광고 종료 시점 (밀리초) + + /// 배속 저장 (pause/resume 시 유지) + int savedSpeedMultiplier = 1; + + /// 상태 변경 알림 콜백 + VoidCallback? onStateChanged; + + /// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x) + int get speedBoostMultiplier => + (kDebugMode && _cheatsEnabledGetter()) ? 20 : 5; + + /// 속도 부스트 활성화 여부 + bool get isSpeedBoostActive => _isSpeedBoostActive; + + /// 광고 표시 중 여부 (lifecycle reload 방지용) + bool get isShowingAd => _isShowingAd; + + /// 최근 광고를 시청했는지 여부 (1초 이내) + bool get isRecentlyShowedAd { + if (_adEndTimeMs == 0) return false; + return DateTime.now().millisecondsSinceEpoch - _adEndTimeMs < 1000; + } + + /// 속도 부스트 지속 시간 (초) + int get speedBoostDuration => _speedBoostDuration; + + /// 속도 부스트 남은 시간 (초) - 게임 시간(elapsedMs) 기준 계산 + int getRemainingSeconds(MonetizationState monetization, int currentElapsedMs) { + if (!_isSpeedBoostActive) return 0; + final endMs = monetization.speedBoostEndMs; + if (endMs == null) return 0; + final remainingMs = endMs - currentElapsedMs; + return remainingMs > 0 ? (remainingMs / 1000).ceil() : 0; + } + + /// 현재 실제 배속 (부스트 적용 포함) + int getCurrentSpeedMultiplier(ProgressLoop? loop) { + if (_isSpeedBoostActive) return speedBoostMultiplier; + return loop?.speedMultiplier ?? savedSpeedMultiplier; + } + + /// 속도 부스트 활성화 (광고 시청 후) + /// + /// 유료 유저: 무료 활성화 + /// 무료 유저: 인터스티셜 광고 시청 후 활성화 + /// Returns: (활성화 성공 여부, 업데이트된 monetization) + Future<(bool, MonetizationState)> activateSpeedBoost({ + required ProgressLoop? loop, + required MonetizationState monetization, + required int currentElapsedMs, + }) async { + if (_isSpeedBoostActive) return (false, monetization); + if (loop == null) return (false, monetization); + + // 유료 유저는 무료 활성화 + if (IAPService.instance.isAdRemovalPurchased) { + final updatedMonetization = _startSpeedBoost( + loop: loop, + monetization: monetization, + currentElapsedMs: currentElapsedMs, + ); + debugPrint('[SpeedBoost] Activated (paid user)'); + return (true, updatedMonetization); + } + + // 무료 유저는 인터스티셜 광고 필요 + MonetizationState updatedMonetization = monetization; + bool activated = false; + _isShowingAd = true; + + final adResult = await AdService.instance.showInterstitialAd( + adType: AdType.interstitialSpeed, + onComplete: () { + updatedMonetization = _startSpeedBoost( + loop: loop, + monetization: monetization, + currentElapsedMs: currentElapsedMs, + ); + activated = true; + }, + ); + + _isShowingAd = false; + _adEndTimeMs = DateTime.now().millisecondsSinceEpoch; + + if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { + debugPrint('[SpeedBoost] Activated (free user with ad)'); + return (activated, updatedMonetization); + } + + debugPrint('[SpeedBoost] Activation failed: $adResult'); + return (false, monetization); + } + + /// 속도 부스트 시작 (내부) + MonetizationState _startSpeedBoost({ + required ProgressLoop? loop, + required MonetizationState monetization, + required int currentElapsedMs, + }) { + _isSpeedBoostActive = true; + + // loop가 있으면 현재 배속 저장 및 즉시 적용 + if (loop != null) { + savedSpeedMultiplier = loop.speedMultiplier; + loop.updateAvailableSpeeds([speedBoostMultiplier]); + } + + // 종료 시점 저장 (게임 시간 기준) + final endMs = currentElapsedMs + (_speedBoostDuration * 1000); + final updatedMonetization = monetization.copyWith(speedBoostEndMs: endMs); + + debugPrint('[SpeedBoost] Started, ends at $endMs ms'); + onStateChanged?.call(); + + return updatedMonetization; + } + + /// 매 틱마다 부스트 만료 체크 + /// + /// Returns: 부스트가 종료되었으면 true + bool checkExpiry({ + required int elapsedMs, + required MonetizationState monetization, + required ProgressLoop? loop, + }) { + if (!_isSpeedBoostActive) return false; + + final endMs = monetization.speedBoostEndMs; + if (endMs != null && elapsedMs >= endMs) { + endSpeedBoost(loop: loop); + return true; + } + return false; + } + + /// 속도 부스트 종료 (외부 호출 가능) + void endSpeedBoost({required ProgressLoop? loop}) { + _isSpeedBoostActive = false; + + // 원래 배속 복원 + if (loop != null) { + final savedSpeed = savedSpeedMultiplier; + + _getAvailableSpeeds().then((speeds) { + loop.updateAvailableSpeeds(speeds); + loop.setSpeed(savedSpeed); + debugPrint('[SpeedBoost] Speed restored to ${savedSpeed}x'); + }); + } + + onStateChanged?.call(); + debugPrint('[SpeedBoost] Ended'); + } + + /// 속도 부스트 수동 취소 + /// + /// Returns: 업데이트된 monetization + MonetizationState cancelSpeedBoost({ + required ProgressLoop? loop, + required MonetizationState monetization, + }) { + if (_isSpeedBoostActive) { + endSpeedBoost(loop: loop); + } + return monetization.copyWith(speedBoostEndMs: null); + } + + /// 부스트 상태에 따른 초기 배속 설정 계산 + /// + /// startNew() 호출 시 사용 + ({List speeds, int initialSpeed}) calculateInitialSpeeds({ + required List baseAvailableSpeeds, + required int baseSpeed, + }) { + if (_isSpeedBoostActive) { + // 부스트 상태: 부스트 배속만 사용, 기본 배속 저장 + savedSpeedMultiplier = baseSpeed; + return (speeds: [speedBoostMultiplier], initialSpeed: speedBoostMultiplier); + } + // 일반 상태: 기본 배속 사용 + return (speeds: baseAvailableSpeeds, initialSpeed: baseSpeed); + } +}