refactor(controller): GameSessionController 분할 (920→526 LOC)
- GameStatisticsManager: 세션/누적 통계 추적 - SpeedBoostManager: 광고 배속 부스트 기능 - ReturnRewardsManager: 복귀 보상 기능 - ResurrectionManager: 사망/부활 처리 - HallOfFameManager: 명예의 전당 관리
This commit is contained in:
File diff suppressed because it is too large
Load Diff
151
lib/src/features/game/managers/game_statistics_manager.dart
Normal file
151
lib/src/features/game/managers/game_statistics_manager.dart
Normal file
@@ -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<void> 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<void> recordGameComplete() async {
|
||||||
|
await _statisticsStorage.recordGameComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 누적 통계 로드
|
||||||
|
Future<void> loadCumulativeStats() async {
|
||||||
|
_cumulativeStats = await _statisticsStorage.loadCumulative();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세션 통계를 누적 통계에 병합
|
||||||
|
Future<void> mergeSessionStats() async {
|
||||||
|
await _statisticsStorage.mergeSession(_sessionStats);
|
||||||
|
_cumulativeStats = await _statisticsStorage.loadCumulative();
|
||||||
|
}
|
||||||
|
}
|
||||||
135
lib/src/features/game/managers/hall_of_fame_manager.dart
Normal file
135
lib/src/features/game/managers/hall_of_fame_manager.dart
Normal file
@@ -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<HallOfFame> load() async {
|
||||||
|
return _hallOfFameStorage.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 명예의 전당에 캐릭터가 있는지 확인
|
||||||
|
Future<bool> hasEntries() async {
|
||||||
|
final hallOfFame = await _hallOfFameStorage.load();
|
||||||
|
return hallOfFame.entries.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가용 배속 목록 반환
|
||||||
|
///
|
||||||
|
/// - 기본: [1] (1x만)
|
||||||
|
/// - 명예의 전당에 캐릭터 있으면: [1, 2] (2x 해금)
|
||||||
|
Future<List<int>> getAvailableSpeeds() async {
|
||||||
|
final hasCharacters = await hasEntries();
|
||||||
|
if (hasCharacters) {
|
||||||
|
return [1, 2]; // 명예의 전당 캐릭터 있으면 2x 해금
|
||||||
|
}
|
||||||
|
return [1]; // 기본: 1x만
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 명예의 전당 등록
|
||||||
|
///
|
||||||
|
/// 게임 클리어 시 호출되어 캐릭터 정보를 명예의 전당에 등록합니다.
|
||||||
|
/// 등록 성공 시 세이브 파일을 삭제합니다.
|
||||||
|
///
|
||||||
|
/// Returns: 등록 성공 여부
|
||||||
|
Future<bool> 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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
lib/src/features/game/managers/resurrection_manager.dart
Normal file
156
lib/src/features/game/managers/resurrection_manager.dart
Normal file
@@ -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<GameState?> 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<ResurrectionResult> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
lib/src/features/game/managers/return_rewards_manager.dart
Normal file
196
lib/src/features/game/managers/return_rewards_manager.dart
Normal file
@@ -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<void>.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (_pendingReturnReward != null) {
|
||||||
|
onReturnRewardAvailable?.call(_pendingReturnReward!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 복귀 보상 수령 완료 (상자 보상 적용)
|
||||||
|
///
|
||||||
|
/// [rewards] 오픈된 상자 보상 목록
|
||||||
|
/// Returns: 보상이 적용된 새로운 GameState
|
||||||
|
Future<GameState?> applyReturnReward({
|
||||||
|
required List<ChestReward> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
211
lib/src/features/game/managers/speed_boost_manager.dart
Normal file
211
lib/src/features/game/managers/speed_boost_manager.dart
Normal file
@@ -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<List<int>> Function() getAvailableSpeeds,
|
||||||
|
}) : _cheatsEnabledGetter = cheatsEnabledGetter,
|
||||||
|
_getAvailableSpeeds = getAvailableSpeeds;
|
||||||
|
|
||||||
|
final bool Function() _cheatsEnabledGetter;
|
||||||
|
final Future<List<int>> 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<int> speeds, int initialSpeed}) calculateInitialSpeeds({
|
||||||
|
required List<int> baseAvailableSpeeds,
|
||||||
|
required int baseSpeed,
|
||||||
|
}) {
|
||||||
|
if (_isSpeedBoostActive) {
|
||||||
|
// 부스트 상태: 부스트 배속만 사용, 기본 배속 저장
|
||||||
|
savedSpeedMultiplier = baseSpeed;
|
||||||
|
return (speeds: [speedBoostMultiplier], initialSpeed: speedBoostMultiplier);
|
||||||
|
}
|
||||||
|
// 일반 상태: 기본 배속 사용
|
||||||
|
return (speeds: baseAvailableSpeeds, initialSpeed: baseSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user