feat(character): 캐릭터 롤 서비스 추가

- 굴리기 횟수 제한 및 충전
- 스탯 히스토리 기반 되돌리기
- 광고 시청으로 굴리기 충전
This commit is contained in:
JiWoong Sul
2026-01-16 20:09:16 +09:00
parent 28d3e53bab
commit 37c118b0f8

View File

@@ -0,0 +1,282 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
/// 캐릭터 생성 굴리기/되돌리기 서비스
///
/// 굴리기 횟수 제한과 되돌리기 기능을 관리합니다.
/// - 굴리기: 5회 (0회 시 광고 시청으로 충전)
/// - 되돌리기: 무료 유저 1회(광고), 유료 유저 3회(무료)
class CharacterRollService {
CharacterRollService._();
static CharacterRollService? _instance;
/// 싱글톤 인스턴스
static CharacterRollService get instance {
_instance ??= CharacterRollService._();
return _instance!;
}
// ===========================================================================
// 상수
// ===========================================================================
/// 저장 키
static const String _rollsRemainingKey = 'char_rolls_remaining';
/// 최대 굴리기 횟수
static const int maxRolls = 5;
/// 최대 되돌리기 횟수 (무료 유저)
static const int maxUndoFreeUser = 1;
/// 최대 되돌리기 횟수 (유료 유저)
static const int maxUndoPaidUser = 3;
// ===========================================================================
// 상태
// ===========================================================================
bool _isInitialized = false;
/// 남은 굴리기 횟수
int _rollsRemaining = maxRolls;
/// 남은 되돌리기 횟수
int _undoRemaining = maxUndoFreeUser;
/// 되돌리기용 스탯 히스토리
final List<RollSnapshot> _rollHistory = [];
// ===========================================================================
// 초기화
// ===========================================================================
/// 서비스 초기화
Future<void> initialize() async {
if (_isInitialized) return;
await _loadState();
_resetUndoForNewSession();
_isInitialized = true;
debugPrint('[CharacterRollService] Initialized: '
'rolls=$_rollsRemaining, undo=$_undoRemaining');
}
/// 저장된 상태 로드
Future<void> _loadState() async {
final prefs = await SharedPreferences.getInstance();
_rollsRemaining = prefs.getInt(_rollsRemainingKey) ?? maxRolls;
}
/// 굴리기 횟수 저장
Future<void> _saveRollsRemaining() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_rollsRemainingKey, _rollsRemaining);
debugPrint('[CharacterRollService] Saved rolls: $_rollsRemaining');
}
/// 새 세션 시작 시 되돌리기 초기화
void _resetUndoForNewSession() {
_undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
_rollHistory.clear();
}
// ===========================================================================
// 유료 사용자 확인
// ===========================================================================
/// 유료 사용자 여부
bool get _isPaidUser => IAPService.instance.isAdRemovalPurchased;
// ===========================================================================
// 굴리기
// ===========================================================================
/// 남은 굴리기 횟수
int get rollsRemaining => _rollsRemaining;
/// 굴리기 가능 여부
bool get canRoll => _rollsRemaining > 0;
/// 굴리기 실행
///
/// [currentStats] 현재 스탯 (되돌리기용 저장)
/// [currentRaceIndex] 현재 종족 인덱스
/// [currentKlassIndex] 현재 직업 인덱스
/// [currentSeed] 현재 RNG 시드
/// Returns: 굴리기 성공 여부
bool roll({
required Stats currentStats,
required int currentRaceIndex,
required int currentKlassIndex,
required int currentSeed,
}) {
if (!canRoll) {
debugPrint('[CharacterRollService] Cannot roll: no rolls remaining');
return false;
}
// 현재 상태를 히스토리에 저장
_rollHistory.insert(
0,
RollSnapshot(
stats: currentStats,
raceIndex: currentRaceIndex,
klassIndex: currentKlassIndex,
seed: currentSeed,
),
);
// 최대 히스토리 개수 제한 (되돌리기 가능 횟수만큼)
final maxHistory = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
while (_rollHistory.length > maxHistory) {
_rollHistory.removeLast();
}
// 굴리기 횟수 감소
_rollsRemaining--;
_saveRollsRemaining();
debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, '
'history=${_rollHistory.length}');
return true;
}
/// 광고 시청 후 굴리기 충전
Future<bool> rechargeRollsWithAd() async {
// 유료 사용자는 광고 없이 충전
if (_isPaidUser) {
_rollsRemaining = maxRolls;
await _saveRollsRemaining();
debugPrint('[CharacterRollService] Recharged (paid user): $maxRolls');
return true;
}
// 인터스티셜 광고 표시
final result = await AdService.instance.showInterstitialAd(
adType: AdType.interstitialRoll,
onComplete: () {
_rollsRemaining = maxRolls;
_saveRollsRemaining();
},
);
if (result == AdResult.completed || result == AdResult.debugSkipped) {
debugPrint('[CharacterRollService] Recharged with ad: $maxRolls');
return true;
}
debugPrint('[CharacterRollService] Recharge failed: $result');
return false;
}
// ===========================================================================
// 되돌리기
// ===========================================================================
/// 남은 되돌리기 횟수
int get undoRemaining => _undoRemaining;
/// 되돌리기 히스토리 길이
int get historyLength => _rollHistory.length;
/// 실제 사용 가능한 되돌리기 횟수
/// min(undoRemaining, historyLength)
int get availableUndos {
final available = _undoRemaining < _rollHistory.length
? _undoRemaining
: _rollHistory.length;
return available;
}
/// 되돌리기 가능 여부
bool get canUndo => availableUndos > 0;
/// 되돌리기 실행 (유료 사용자)
///
/// Returns: 복원된 스냅샷 (null이면 실패)
RollSnapshot? undoPaidUser() {
if (!_isPaidUser) return null;
if (!canUndo) return null;
final snapshot = _rollHistory.removeAt(0);
_undoRemaining--;
debugPrint('[CharacterRollService] Undo (paid): '
'remaining=$_undoRemaining, history=${_rollHistory.length}');
return snapshot;
}
/// 되돌리기 실행 (무료 사용자 - 광고 필요)
///
/// [onSuccess] 광고 시청 완료 후 콜백
Future<RollSnapshot?> undoFreeUser() async {
if (_isPaidUser) return undoPaidUser();
if (!canUndo) return null;
// 리워드 광고 표시
RollSnapshot? result;
final adResult = await AdService.instance.showRewardedAd(
adType: AdType.rewardUndo,
onRewarded: () {
if (_rollHistory.isNotEmpty) {
result = _rollHistory.removeAt(0);
_undoRemaining--;
}
},
);
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint('[CharacterRollService] Undo (free with ad): '
'remaining=$_undoRemaining, history=${_rollHistory.length}');
return result;
}
debugPrint('[CharacterRollService] Undo failed: $adResult');
return null;
}
// ===========================================================================
// 캐릭터 생성 완료
// ===========================================================================
/// 캐릭터 생성 완료 시 호출
///
/// 되돌리기 상태만 초기화 (굴리기 횟수는 유지)
void onCharacterCreated() {
_resetUndoForNewSession();
debugPrint('[CharacterRollService] Character created, undo reset');
}
/// 굴리기 횟수 완전 초기화 (디버그용)
Future<void> resetRolls() async {
_rollsRemaining = maxRolls;
await _saveRollsRemaining();
_resetUndoForNewSession();
debugPrint('[CharacterRollService] Reset all');
}
}
/// 굴리기 스냅샷 (되돌리기용)
class RollSnapshot {
const RollSnapshot({
required this.stats,
required this.raceIndex,
required this.klassIndex,
required this.seed,
});
final Stats stats;
final int raceIndex;
final int klassIndex;
final int seed;
}