- 광고 후 배속 적용 안됨: isShowingAd 플래그로 lifecycle reload 방지 - 배속 종료 후 복귀 안됨: setSpeed(_savedSpeedMultiplier) 추가 - 복귀 상자 장비 장착 안됨: _loop?.replaceState() 추가 - 세이브 로드 시 1배속 고정: 명예의 전당 해금 시 최소 2배속 보장
868 lines
29 KiB
Dart
868 lines
29 KiB
Dart
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:flutter/foundation.dart';
|
|
|
|
enum GameSessionStatus { idle, loading, running, error, dead, complete }
|
|
|
|
/// Presentation-friendly wrapper that owns ProgressLoop and SaveManager.
|
|
class GameSessionController extends ChangeNotifier {
|
|
GameSessionController({
|
|
required this.progressService,
|
|
required this.saveManager,
|
|
this.autoSaveConfig = const AutoSaveConfig(),
|
|
Duration tickInterval = const Duration(milliseconds: 50),
|
|
DateTime Function()? now,
|
|
StatisticsStorage? statisticsStorage,
|
|
HallOfFameStorage? hallOfFameStorage,
|
|
}) : _tickInterval = tickInterval,
|
|
_now = now ?? DateTime.now,
|
|
_statisticsStorage = statisticsStorage ?? StatisticsStorage(),
|
|
_hallOfFameStorage = hallOfFameStorage ?? HallOfFameStorage();
|
|
|
|
final ProgressService progressService;
|
|
final SaveManager saveManager;
|
|
final AutoSaveConfig autoSaveConfig;
|
|
final StatisticsStorage _statisticsStorage;
|
|
final HallOfFameStorage _hallOfFameStorage;
|
|
|
|
final Duration _tickInterval;
|
|
final DateTime Function() _now;
|
|
|
|
ProgressLoop? _loop;
|
|
StreamSubscription<GameState>? _subscription;
|
|
bool _cheatsEnabled = false;
|
|
|
|
GameSessionStatus _status = GameSessionStatus.idle;
|
|
GameState? _state;
|
|
String? _error;
|
|
|
|
// 배속 저장 (pause/resume 시 유지)
|
|
int _savedSpeedMultiplier = 1;
|
|
|
|
// 자동 부활 (Auto-Resurrection) 상태
|
|
bool _autoResurrect = false;
|
|
|
|
// 속도 부스트 상태 (Phase 6)
|
|
bool _isSpeedBoostActive = false;
|
|
Timer? _speedBoostTimer;
|
|
int _speedBoostRemainingSeconds = 0;
|
|
static const int _speedBoostDuration = 300; // 5분
|
|
|
|
// 광고 표시 중 플래그 (lifecycle reload 방지용)
|
|
bool _isShowingAd = false;
|
|
|
|
/// 광고 배속 배율 (릴리즈: 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;
|
|
|
|
GameSessionStatus get status => _status;
|
|
GameState? get state => _state;
|
|
String? get error => _error;
|
|
bool get isRunning => _status == GameSessionStatus.running;
|
|
bool get cheatsEnabled => _cheatsEnabled;
|
|
|
|
/// 자동 부활 활성화 여부
|
|
bool get autoResurrect => _autoResurrect;
|
|
|
|
/// 자동 부활 설정
|
|
void setAutoResurrect(bool value) {
|
|
_autoResurrect = value;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 현재 세션 통계
|
|
SessionStatistics get sessionStats => _sessionStats;
|
|
|
|
/// 누적 통계
|
|
CumulativeStatistics get cumulativeStats => _cumulativeStats;
|
|
|
|
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
|
|
ProgressLoop? get loop => _loop;
|
|
|
|
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
|
|
int get adSpeedMultiplier => _speedBoostMultiplier;
|
|
|
|
/// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true)
|
|
bool get has2xUnlocked => _loop?.availableSpeeds.contains(2) ?? false;
|
|
|
|
Future<void> startNew(
|
|
GameState initialState, {
|
|
bool cheatsEnabled = false,
|
|
bool isNewGame = true,
|
|
}) async {
|
|
// 기존 배속 보존 (부활/재개 시 유지)
|
|
// _loop가 있으면 현재 배속 사용, 없으면 저장된 배속 사용
|
|
final previousSpeed = _loop?.speedMultiplier ?? _savedSpeedMultiplier;
|
|
|
|
await _stopLoop(saveOnStop: false);
|
|
|
|
// 새 게임인 경우 초기화 (프롤로그 태스크 설정)
|
|
final state = isNewGame
|
|
? progressService.initializeNewGame(initialState)
|
|
: initialState;
|
|
|
|
_state = state;
|
|
_error = null;
|
|
_status = GameSessionStatus.running;
|
|
_cheatsEnabled = cheatsEnabled;
|
|
|
|
// 통계 초기화
|
|
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);
|
|
|
|
// 명예의 전당 체크 → 가용 배속 결정
|
|
final availableSpeeds = await _getAvailableSpeeds();
|
|
|
|
// 명예의 전당 해금 시 기본 2배속, 아니면 1배속
|
|
final hasHallOfFame = availableSpeeds.contains(2);
|
|
// 새 게임이면 기본 배속, 세이브 로드 시 명예의 전당 해금 시 최소 2배속 보장
|
|
final int initialSpeed;
|
|
if (isNewGame) {
|
|
initialSpeed = hasHallOfFame ? 2 : 1;
|
|
} else {
|
|
// 세이브 로드: 명예의 전당 해금 시 최소 2배속
|
|
initialSpeed = (hasHallOfFame && previousSpeed < 2) ? 2 : previousSpeed;
|
|
}
|
|
|
|
_loop = ProgressLoop(
|
|
initialState: state,
|
|
progressService: progressService,
|
|
saveManager: saveManager,
|
|
autoSaveConfig: autoSaveConfig,
|
|
tickInterval: _tickInterval,
|
|
now: _now,
|
|
cheatsEnabled: cheatsEnabled,
|
|
onPlayerDied: _onPlayerDied,
|
|
onGameComplete: _onGameComplete,
|
|
availableSpeeds: availableSpeeds,
|
|
initialSpeedMultiplier: initialSpeed,
|
|
);
|
|
|
|
_subscription = _loop!.stream.listen((next) {
|
|
_updateStatistics(next);
|
|
_state = next;
|
|
notifyListeners();
|
|
});
|
|
|
|
_loop!.start();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 가용 배속 목록 반환
|
|
///
|
|
/// - 기본: [1] (1x만)
|
|
/// - 명예의 전당에 캐릭터 있으면: [1, 2] (2x 해금)
|
|
/// - 광고 배속(5x/20x)은 별도 버프로만 활성화
|
|
Future<List<int>> _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));
|
|
}
|
|
|
|
// 골드 변화 감지
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// 누적 통계 로드
|
|
Future<void> loadCumulativeStats() async {
|
|
_cumulativeStats = await _statisticsStorage.loadCumulative();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 세션 통계를 누적 통계에 병합
|
|
Future<void> mergeSessionStats() async {
|
|
await _statisticsStorage.mergeSession(_sessionStats);
|
|
_cumulativeStats = await _statisticsStorage.loadCumulative();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> loadAndStart({String? fileName}) async {
|
|
_status = GameSessionStatus.loading;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
final (outcome, loaded, savedCheatsEnabled, savedMonetization) =
|
|
await saveManager.loadState(fileName: fileName);
|
|
if (!outcome.success || loaded == null) {
|
|
_status = GameSessionStatus.error;
|
|
_error = outcome.error ?? 'Unknown error';
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
// 저장된 수익화 상태 복원
|
|
_monetization = savedMonetization ?? MonetizationState.initial();
|
|
|
|
// 복귀 보상 체크 (Phase 7)
|
|
_checkReturnRewards(loaded);
|
|
|
|
// 저장된 치트 모드 상태 복원
|
|
await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false);
|
|
}
|
|
|
|
Future<void> pause({bool saveOnStop = false}) async {
|
|
await _stopLoop(saveOnStop: saveOnStop);
|
|
_status = GameSessionStatus.idle;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 일시 정지 상태에서 재개
|
|
Future<void> resume() async {
|
|
if (_state == null || _status != GameSessionStatus.idle) return;
|
|
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false);
|
|
}
|
|
|
|
/// 일시 정지/재개 토글
|
|
Future<void> togglePause() async {
|
|
if (isRunning) {
|
|
await pause(saveOnStop: true);
|
|
} else if (_state != null && _status == GameSessionStatus.idle) {
|
|
await resume();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
final stop = _stopLoop(saveOnStop: false);
|
|
if (stop != null) {
|
|
unawaited(stop);
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void>? _stopLoop({required bool saveOnStop}) {
|
|
final loop = _loop;
|
|
final sub = _subscription;
|
|
|
|
// 배속 저장 (resume 시 복원용)
|
|
if (loop != null) {
|
|
_savedSpeedMultiplier = loop.speedMultiplier;
|
|
}
|
|
|
|
_loop = null;
|
|
_subscription = null;
|
|
|
|
sub?.cancel();
|
|
if (loop == null) return null;
|
|
return loop.stop(saveOnStop: saveOnStop);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Phase 4: 사망/부활 처리
|
|
// ============================================================================
|
|
|
|
/// 플레이어 사망 콜백 (ProgressLoop에서 호출)
|
|
void _onPlayerDied() {
|
|
_sessionStats = _sessionStats.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);
|
|
|
|
if (shouldAutoResurrect) {
|
|
_scheduleAutoResurrect();
|
|
}
|
|
}
|
|
|
|
/// 자동 부활 예약 (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);
|
|
|
|
if (shouldAutoResurrect) {
|
|
await resurrect();
|
|
await resumeAfterResurrection();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 게임 클리어 콜백 (ProgressLoop에서 호출, Act V 완료 시)
|
|
void _onGameComplete() {
|
|
_status = GameSessionStatus.complete;
|
|
notifyListeners();
|
|
|
|
// Hall of Fame 등록 (비동기)
|
|
unawaited(_registerToHallOfFame());
|
|
}
|
|
|
|
/// 명예의 전당 등록
|
|
Future<void> _registerToHallOfFame() async {
|
|
if (_state == null) {
|
|
debugPrint('[HallOfFame] _state is null, skipping registration');
|
|
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');
|
|
}
|
|
}
|
|
|
|
/// 테스트 캐릭터 생성 (디버그 모드 전용)
|
|
///
|
|
/// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진
|
|
/// 캐릭터로 변환하여 명예의 전당에 등록하고 세이브를 삭제함.
|
|
Future<bool> createTestCharacter() async {
|
|
if (_state == null) {
|
|
debugPrint('[TestCharacter] _state is null');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
debugPrint('[TestCharacter] Creating test character...');
|
|
|
|
// 게임 일시정지
|
|
await _stopLoop(saveOnStop: false);
|
|
|
|
// 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}');
|
|
}
|
|
|
|
// 상태 초기화
|
|
_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<void> 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<void> resumeAfterResurrection() async {
|
|
if (_state == null) return;
|
|
|
|
// 게임 재개
|
|
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활)
|
|
// ===========================================================================
|
|
|
|
/// 광고 부활 (HP 100% + 아이템 복구 + 10분 자동부활 버프)
|
|
///
|
|
/// 유료 유저: 광고 없이 부활
|
|
/// 무료 유저: 리워드 광고 시청 후 부활
|
|
Future<void> 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');
|
|
}
|
|
}
|
|
|
|
/// 사망 상태 여부
|
|
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;
|
|
|
|
/// 속도 부스트 남은 시간 (초)
|
|
int get speedBoostRemainingSeconds => _speedBoostRemainingSeconds;
|
|
|
|
/// 속도 부스트 배율
|
|
int get speedBoostMultiplier => _speedBoostMultiplier;
|
|
|
|
/// 속도 부스트 지속 시간 (초)
|
|
int get speedBoostDuration => _speedBoostDuration;
|
|
|
|
/// 현재 실제 배속 (부스트 적용 포함)
|
|
int get currentSpeedMultiplier {
|
|
if (_isSpeedBoostActive) return _speedBoostMultiplier;
|
|
return _loop?.speedMultiplier ?? _savedSpeedMultiplier;
|
|
}
|
|
|
|
/// 속도 부스트 활성화 (광고 시청 후)
|
|
///
|
|
/// 유료 유저: 무료 활성화
|
|
/// 무료 유저: 인터스티셜 광고 시청 후 활성화
|
|
/// Returns: 활성화 성공 여부
|
|
Future<bool> 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;
|
|
},
|
|
);
|
|
|
|
_isShowingAd = false; // 광고 표시 종료
|
|
|
|
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;
|
|
}
|
|
|
|
/// 속도 부스트 시작 (내부)
|
|
void _startSpeedBoost() {
|
|
if (_loop == null) return;
|
|
|
|
// 현재 배속 저장
|
|
_savedSpeedMultiplier = _loop!.speedMultiplier;
|
|
|
|
// 부스트 배속 적용
|
|
_isSpeedBoostActive = true;
|
|
_speedBoostRemainingSeconds = _speedBoostDuration;
|
|
|
|
// monetization 상태에 종료 시점 저장 (UI 표시용)
|
|
final currentElapsedMs = _state?.skillSystem.elapsedMs ?? 0;
|
|
final endMs = currentElapsedMs + (_speedBoostDuration * 1000);
|
|
_monetization = _monetization.copyWith(speedBoostEndMs: endMs);
|
|
|
|
// ProgressLoop에 직접 배속 설정
|
|
_loop!.updateAvailableSpeeds([_speedBoostMultiplier]);
|
|
|
|
// 1초마다 남은 시간 감소
|
|
_speedBoostTimer?.cancel();
|
|
_speedBoostTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
_speedBoostRemainingSeconds--;
|
|
notifyListeners();
|
|
|
|
if (_speedBoostRemainingSeconds <= 0) {
|
|
_endSpeedBoost();
|
|
}
|
|
});
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 속도 부스트 종료 (내부)
|
|
void _endSpeedBoost() {
|
|
_speedBoostTimer?.cancel();
|
|
_speedBoostTimer = null;
|
|
_isSpeedBoostActive = false;
|
|
_speedBoostRemainingSeconds = 0;
|
|
|
|
// monetization 상태 초기화 (UI 표시 제거)
|
|
_monetization = _monetization.copyWith(speedBoostEndMs: null);
|
|
|
|
// 원래 배속 복원
|
|
if (_loop != null) {
|
|
_getAvailableSpeeds().then((speeds) {
|
|
_loop!.updateAvailableSpeeds(speeds);
|
|
_loop!.setSpeed(_savedSpeedMultiplier);
|
|
});
|
|
}
|
|
|
|
notifyListeners();
|
|
debugPrint('[GameSession] Speed boost ended');
|
|
}
|
|
|
|
/// 속도 부스트 수동 취소
|
|
void cancelSpeedBoost() {
|
|
if (_isSpeedBoostActive) {
|
|
_endSpeedBoost();
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 복귀 보상 (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<void>.delayed(const Duration(milliseconds: 500), () {
|
|
if (_pendingReturnReward != null) {
|
|
onReturnRewardAvailable?.call(_pendingReturnReward!);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 복귀 보상 수령 완료 (상자 보상 적용)
|
|
///
|
|
/// [rewards] 오픈된 상자 보상 목록
|
|
void applyReturnReward(List<ChestReward> rewards) {
|
|
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!,
|
|
cheatsEnabled: _cheatsEnabled,
|
|
monetization: _monetization,
|
|
));
|
|
|
|
_pendingReturnReward = null;
|
|
notifyListeners();
|
|
|
|
debugPrint('[ReturnRewards] Rewards applied: ${rewards.length} items');
|
|
}
|
|
|
|
/// 복귀 보상 건너뛰기
|
|
void skipReturnReward() {
|
|
_pendingReturnReward = null;
|
|
debugPrint('[ReturnRewards] Reward skipped by user');
|
|
}
|
|
}
|