feat(ui): 화면 및 컨트롤러 수익화 연동
- 앱 초기화에 광고/IAP 서비스 추가 - 게임 세션 컨트롤러 수익화 상태 관리 - 캐릭터 생성 화면 굴리기 제한 UI - 설정 화면 광고 제거 구매 UI - 애니메이션 패널 개선
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
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/storage/hall_of_fame_storage.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
|
||||
@@ -54,6 +59,20 @@ class GameSessionController extends ChangeNotifier {
|
||||
// 자동 부활 (Auto-Resurrection) 상태
|
||||
bool _autoResurrect = false;
|
||||
|
||||
// 속도 부스트 상태 (Phase 6)
|
||||
bool _isSpeedBoostActive = false;
|
||||
Timer? _speedBoostTimer;
|
||||
int _speedBoostRemainingSeconds = 0;
|
||||
static const int _speedBoostDuration = 300; // 5분
|
||||
static const int _speedBoostMultiplier = 5; // 5x 속도
|
||||
|
||||
// 복귀 보상 상태 (Phase 7)
|
||||
MonetizationState _monetization = MonetizationState.initial();
|
||||
ReturnReward? _pendingReturnReward;
|
||||
|
||||
/// 복귀 보상 콜백 (UI에서 다이얼로그 표시용)
|
||||
void Function(ReturnReward reward)? onReturnRewardAvailable;
|
||||
|
||||
// 통계 관련 필드
|
||||
SessionStatistics _sessionStats = SessionStatistics.empty();
|
||||
CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty();
|
||||
@@ -152,18 +171,14 @@ class GameSessionController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 명예의 전당 상태에 따른 가용 배속 목록 반환
|
||||
/// - 디버그 모드(치트 활성화): [1, 5, 20] (터보 모드)
|
||||
/// - 명예의 전당에 캐릭터 없음: [1, 5]
|
||||
/// - 명예의 전당에 캐릭터 있음: [1, 2, 5]
|
||||
/// 가용 배속 목록 반환
|
||||
/// - 디버그 모드(치트 활성화): [1, 2, 20] (터보 모드 포함)
|
||||
/// - 일반 모드: [1, 2] (5x는 광고 버프로만 활성화)
|
||||
Future<List<int>> _getAvailableSpeeds() async {
|
||||
// 디버그 모드면 터보(20x) 추가
|
||||
if (_cheatsEnabled) {
|
||||
return [1, 5, 20];
|
||||
return [1, 2, 20];
|
||||
}
|
||||
|
||||
final hallOfFame = await _hallOfFameStorage.load();
|
||||
return hallOfFame.isEmpty ? [1, 5] : [1, 2, 5];
|
||||
return [1, 2];
|
||||
}
|
||||
|
||||
/// 이전 값 초기화 (통계 변화 추적용)
|
||||
@@ -241,9 +256,8 @@ class GameSessionController extends ChangeNotifier {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final (outcome, loaded, savedCheatsEnabled) = await saveManager.loadState(
|
||||
fileName: fileName,
|
||||
);
|
||||
final (outcome, loaded, savedCheatsEnabled, savedMonetization) =
|
||||
await saveManager.loadState(fileName: fileName);
|
||||
if (!outcome.success || loaded == null) {
|
||||
_status = GameSessionStatus.error;
|
||||
_error = outcome.error ?? 'Unknown error';
|
||||
@@ -251,6 +265,12 @@ class GameSessionController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// 저장된 수익화 상태 복원
|
||||
_monetization = savedMonetization ?? MonetizationState.initial();
|
||||
|
||||
// 복귀 보상 체크 (Phase 7)
|
||||
_checkReturnRewards(loaded);
|
||||
|
||||
// 저장된 치트 모드 상태 복원
|
||||
await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false);
|
||||
}
|
||||
@@ -312,8 +332,16 @@ class GameSessionController extends ChangeNotifier {
|
||||
_status = GameSessionStatus.dead;
|
||||
notifyListeners();
|
||||
|
||||
// 자동 부활이 활성화된 경우 잠시 후 자동으로 부활
|
||||
if (_autoResurrect) {
|
||||
// 자동 부활 조건 확인:
|
||||
// 1. 수동 토글 자동부활 (_autoResurrect)
|
||||
// 2. 유료 유저 (항상 자동부활)
|
||||
// 3. 광고 부활 버프 활성 (10분간)
|
||||
final elapsedMs = _state?.skillSystem.elapsedMs ?? 0;
|
||||
final shouldAutoResurrect = _autoResurrect ||
|
||||
IAPService.instance.isAdRemovalPurchased ||
|
||||
_monetization.isAutoReviveActive(elapsedMs);
|
||||
|
||||
if (shouldAutoResurrect) {
|
||||
_scheduleAutoResurrect();
|
||||
}
|
||||
}
|
||||
@@ -323,8 +351,15 @@ class GameSessionController extends ChangeNotifier {
|
||||
/// 사망 오버레이를 잠시 표시한 후 자동으로 부활 처리
|
||||
void _scheduleAutoResurrect() {
|
||||
Future.delayed(const Duration(milliseconds: 800), () async {
|
||||
// 상태가 여전히 dead이고, 자동 부활이 활성화된 경우에만 부활
|
||||
if (_status == GameSessionStatus.dead && _autoResurrect) {
|
||||
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();
|
||||
}
|
||||
@@ -456,6 +491,7 @@ class GameSessionController extends ChangeNotifier {
|
||||
await saveManager.saveState(
|
||||
resurrectedState,
|
||||
cheatsEnabled: _cheatsEnabled,
|
||||
monetization: _monetization,
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
@@ -471,10 +507,266 @@ class GameSessionController extends ChangeNotifier {
|
||||
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;
|
||||
|
||||
/// 속도 부스트 남은 시간 (초)
|
||||
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;
|
||||
final adResult = await AdService.instance.showInterstitialAd(
|
||||
adType: AdType.interstitialSpeed,
|
||||
onComplete: () {
|
||||
_startSpeedBoost();
|
||||
activated = true;
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
debugPrint('[GameSession] Speed boost ended');
|
||||
}
|
||||
|
||||
/// 속도 부스트 수동 취소
|
||||
void cancelSpeedBoost() {
|
||||
if (_isSpeedBoostActive) {
|
||||
_endSpeedBoost();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 복귀 보상 (Phase 7)
|
||||
// ===========================================================================
|
||||
|
||||
/// 현재 수익화 상태
|
||||
MonetizationState get monetization => _monetization;
|
||||
|
||||
/// 대기 중인 복귀 보상
|
||||
ReturnReward? 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(),
|
||||
playerLevel: loaded.traits.level,
|
||||
);
|
||||
|
||||
if (reward.hasReward) {
|
||||
_pendingReturnReward = reward;
|
||||
debugPrint('[ReturnRewards] Reward available: ${reward.goldReward} gold, '
|
||||
'${reward.hoursAway} hours away');
|
||||
|
||||
// UI에서 다이얼로그 표시를 위해 콜백 호출
|
||||
// startNew 후에 호출하도록 딜레이
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (_pendingReturnReward != null) {
|
||||
onReturnRewardAvailable?.call(_pendingReturnReward!);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 복귀 보상 수령 완료 (골드 적용)
|
||||
///
|
||||
/// [totalGold] 수령한 총 골드 (기본 + 보너스)
|
||||
void applyReturnReward(int totalGold) {
|
||||
if (_state == null) return;
|
||||
if (totalGold <= 0) {
|
||||
// 보상 없이 건너뛴 경우
|
||||
_pendingReturnReward = null;
|
||||
debugPrint('[ReturnRewards] Reward skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
// 골드 추가
|
||||
final updatedInventory = _state!.inventory.copyWith(
|
||||
gold: _state!.inventory.gold + totalGold,
|
||||
);
|
||||
_state = _state!.copyWith(inventory: updatedInventory);
|
||||
|
||||
// 저장
|
||||
unawaited(saveManager.saveState(
|
||||
_state!,
|
||||
cheatsEnabled: _cheatsEnabled,
|
||||
monetization: _monetization,
|
||||
));
|
||||
|
||||
_pendingReturnReward = null;
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[ReturnRewards] Reward applied: $totalGold gold');
|
||||
}
|
||||
|
||||
/// 복귀 보상 건너뛰기
|
||||
void skipReturnReward() {
|
||||
_pendingReturnReward = null;
|
||||
debugPrint('[ReturnRewards] Reward skipped by user');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user