Compare commits

...

12 Commits

Author SHA1 Message Date
JiWoong Sul
d41dd0fb90 docs: 수익화 시스템 문서 추가
- app-ads.txt 광고 인증 파일
- 수익화 시스템 계획 문서
2026-01-16 20:11:13 +09:00
JiWoong Sul
9f077d74a1 chore: 플랫폼 설정 및 테스트 업데이트
- Android 광고 권한 추가
- macOS 플러그인 등록
- 테스트 mock 업데이트
2026-01-16 20:11:00 +09:00
JiWoong Sul
748160d543 feat(ui): 화면 및 컨트롤러 수익화 연동
- 앱 초기화에 광고/IAP 서비스 추가
- 게임 세션 컨트롤러 수익화 상태 관리
- 캐릭터 생성 화면 굴리기 제한 UI
- 설정 화면 광고 제거 구매 UI
- 애니메이션 패널 개선
2026-01-16 20:10:43 +09:00
JiWoong Sul
c95e4de5a4 feat(core): i18n 및 핵심 로직 개선
- 수익화 관련 텍스트 추가
- item_service 수정
- progress_service 수정
2026-01-16 20:10:36 +09:00
JiWoong Sul
c95fb7f4b4 feat(ui): 스피드 부스트 버튼 위젯 추가
- 5배속 버프 활성화 버튼
- 광고 시청으로 버프 획득
- 남은 시간 표시
2026-01-16 20:10:08 +09:00
JiWoong Sul
b6d5cd2abd feat(death): 사망/부활 시스템 개선
- DeathInfo에 lostItem 필드 추가 (광고 부활 시 복구용)
- 세이브 데이터 v4: MonetizationState 포함
- 사망 오버레이 UI 개선
- 부활 서비스 광고 연동
2026-01-16 20:09:52 +09:00
JiWoong Sul
b272ef8f08 feat(rewards): 복귀 보상 시스템 추가
- 시간 경과에 따른 골드 보상 계산
- 광고 시청 시 2배 보너스
- 복귀 보상 다이얼로그 UI
2026-01-16 20:09:32 +09:00
JiWoong Sul
37c118b0f8 feat(character): 캐릭터 롤 서비스 추가
- 굴리기 횟수 제한 및 충전
- 스탯 히스토리 기반 되돌리기
- 광고 시청으로 굴리기 충전
2026-01-16 20:09:16 +09:00
JiWoong Sul
28d3e53bab feat(debug): 디버그 설정 서비스 추가
- 광고/IAP/무적 모드 토글
- 시간 스케일 조절
- SharedPreferences 기반 영속화
2026-01-16 20:08:59 +09:00
JiWoong Sul
77f3f1d46b feat(iap): 인앱 결제 서비스 추가
- 광고 제거 상품 구매 처리
- 구매 복원 기능
- 결제 상태 스트림 지원
2026-01-16 20:08:43 +09:00
JiWoong Sul
6662a5dcfb feat(ads): AdMob 광고 서비스 추가
- 리워드/인터스티셜 광고 로드 및 표시
- 디버그 모드 광고 토글 지원
- 비모바일 플랫폼 자동 스킵
2026-01-16 20:08:27 +09:00
JiWoong Sul
724f08f56d feat(monetization): 수익화 시스템 기반 모델 추가
- MonetizationState freezed 모델 추가
- google_mobile_ads, in_app_purchase 의존성 추가
- IAP 구매 상태, 버프 종료 시점, 복귀 보상 데이터 관리
2026-01-16 20:08:10 +09:00
35 changed files with 4694 additions and 463 deletions

View File

@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- IAP 결제 권한 -->
<uses-permission android:name="com.android.vending.BILLING" />
<application
android:label="asciineverdie"
android:name="${applicationName}"
@@ -29,6 +32,10 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- AdMob App ID -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6691216385521068~8216990571"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

1
docs/app-ads.txt Normal file
View File

@@ -0,0 +1 @@
google.com, pub-6691216385521068, DIRECT, f08c47fec0942fa0

View File

@@ -0,0 +1,395 @@
# 수익화 시스템 설계
## 메타 정보
- **문서 버전**: 1.0
- **최종 수정**: 2026-01-16
- **상태**: 계획 단계
- **플랫폼**: Android/iOS (모바일 전용)
- **광고 SDK**: AdMob
---
## Task 리스트
### Phase 1: 데이터 구조 (선행 작업)
- [ ] `MonetizationState` 모델 생성
- [ ] `DeathInfo.lostItem: EquipmentItem?` 필드 추가
- [ ] `GameSave` v3 → v4 마이그레이션
- [ ] 장비 손실 확률 공식 변경: `20% + (level-1) * 8.89%`
- [ ] `ItemService.determineRarity()` 확률 수정: 34/40/20/5/1
### Phase 2: AdMob 연동
- [ ] `google_mobile_ads` 패키지 추가
- [ ] `AdService` 클래스 생성
- [ ] 리워드 광고 로드/표시/콜백 구현
- [ ] 인터스티셜 광고 로드/표시/콜백 구현
- [ ] 디버그 빌드 광고 ON/OFF 토글 구현
### Phase 3: IAP 연동
- [ ] `in_app_purchase` 패키지 추가
- [ ] 광고 제거 상품 등록 (Google Play / App Store)
- [ ] `IAPService` 클래스 생성
- [ ] 구매 처리 로직 구현
- [ ] 구매 복원 로직 구현
- [ ] 구매 상태 영구 저장
### Phase 4: 캐릭터 생성 광고
- [ ] 굴리기 횟수 상태 관리 (rollsRemaining)
- [ ] 굴리기 횟수 저장/로드 구현
- [ ] 되돌리기 히스토리 관리
- [ ] 되돌리기 횟수 상태 관리 (undoRemaining)
- [ ] 캐릭터 생성 UI 수정: 굴리기 버튼
- [ ] 캐릭터 생성 UI 수정: 되돌리기 버튼
### Phase 5: 부활 시스템
- [ ] 사망 시 `DeathInfo.lostItem` 저장 로직
- [ ] `reviveWithAdReward()` 함수 구현
- [ ] `reviveWithoutAd()` 함수 구현
- [ ] 자동부활 버프 상태 관리 (autoReviveEndMs)
- [ ] 자동부활 버프 중 사망 처리 로직
- [ ] 사망 화면 UI: 광고 부활 버튼
- [ ] 사망 화면 UI: 일반 부활 버튼
### Phase 6: 속도업
- [ ] 속도 상태 관리 (1x/2x/5x)
- [ ] 명예의 전당 캐릭터 존재 여부 확인 로직
- [ ] 5배속 버프 상태 관리 (speedBoostEndMs)
- [ ] 속도 UI: 버튼 표시 로직
- [ ] 속도 UI: 남은 시간 표시
### Phase 7: 복귀 보상
- [ ] `lastPlayTime` 저장/로드 구현
- [ ] 오프라인 시간 계산 로직
- [ ] 오프라인 진행 시뮬레이션 (1배/2배)
- [ ] 보물 상자 축적 로직
- [ ] 보물 상자 내용물 생성 로직
- [ ] 행운의 부적 버프 발동 로직
- [ ] 복귀 보상 UI: 환영 다이얼로그
- [ ] 복귀 보상 UI: 상자 오픈
### Phase 8: 디버그 기능
- [ ] 메인 메뉴: 디버그 옵션 섹션 (kDebugMode)
- [ ] 스타트 화면: 디버그 옵션 섹션
- [ ] 광고 ON/OFF 토글
- [ ] IAP 구매 시뮬레이션 토글
- [ ] 오프라인 시간 시뮬레이션
---
## 스펙 요약
### 광고 유형
| ID | 유형 | 길이 | 사용처 |
| -- | ---- | ---- | ------ |
| AD_REWARD_REVIVE | 리워드 | 30초 | 부활 |
| AD_REWARD_UNDO | 리워드 | 30초 | 캐릭터 생성 되돌리기 |
| AD_INTERSTITIAL_ROLL | 인터스티셜 | 6초 | 굴리기 횟수 충전 |
| AD_INTERSTITIAL_SPEED | 인터스티셜 | 6초 | 게임 속도업 |
### IAP 상품
| ID | 가격 | 유형 |
| -- | ---- | ---- |
| remove_ads | $9.99 | 비소모성 (1회 구매) |
### 무료 vs 구매 유저 비교
| 기능 | 무료 유저 | 구매 유저 |
| ---- | --------- | --------- |
| 광고 | 표시 | 제거 (버튼 클릭 시 바로 활성화) |
| 복귀 상자 최대 | 5개 | 10개 |
| 오프라인 진행 속도 | 1배 | 2배 |
| 행운 버프 발동 | 1시간당 5분 | 30분당 5분 |
| 캐릭터 되돌리기 | 1회 (광고) | 3회 (무료) |
| 굴리기 충전 | 광고 필요 | 무제한 |
| 속도업 | 광고 필요 | 무제한 |
---
## 상세 스펙
### 1. 캐릭터 생성 - 굴리기
```yaml
rollsRemaining:
default: 5
min: 0
max: 5
recharge: +5 (인터스티셜 광고 시청 후)
persistence: 저장됨 (0회로 종료 시 재시작해도 0회)
```
### 2. 캐릭터 생성 - 되돌리기
```yaml
undoRemaining:
free_user: 1
paid_user: 3
ad_required:
free_user: true (30초 리워드)
paid_user: false
range: 1단계 전만
reset: 새로 굴리기 시작 시 초기화
constraint: min(undoRemaining, rollHistory.length)
```
### 3. 장비 손실 확률
```yaml
formula: 20 + (level - 1) * 80 / 9
level_1: 20%
level_5: 56%
level_10_plus: 100%
code: |
int calculateEquipmentLossChance(int level) {
if (level >= 10) return 100;
return 20 + ((level - 1) * 80 ~/ 9);
}
```
### 4. 부활 시스템
```yaml
normal_revive:
ad: false
hp_recovery: 50%
equipment_loss: confirmed
sacrifice: required
ad_revive:
ad: true (30초 리워드)
hp_recovery: 100%
equipment_loss: cancelled (lostItem 복구)
sacrifice: none
auto_revive_buff:
duration: 600000ms (10분)
effect: 버프 중 사망 시 자동부활 + 장비 손실 없음
```
### 5. 게임 속도
```yaml
speed_levels:
- 1x: 기본
- 2x: 명예의 전당 캐릭터 1명 이상 시 해금
- 5x: 광고 시청 (5분간) 또는 IAP 구매 시 무제한
speed_boost:
duration: 300000ms (5분)
ad: 인터스티셜 6초
paid_user: 광고 없이 무제한
```
### 6. 복귀 보상
```yaml
offline_progress:
free_user: 1x
paid_user: 2x
treasure_chest:
rate: 1개/시간
max_free: 5개
max_paid: 10개
contents:
equipment: 50%
gold: 30%
potion: 15%
bonus_equipment: 5%
lucky_charm_buff:
free_user: 5분/오프라인1시간
paid_user: 5분/오프라인30분
max_duration: 30분
effect:
common: 34% → 28%
uncommon: 40% → 40%
rare: 20% → 20%
epic: 5% → 10%
legendary: 1% → 2%
```
### 7. 아이템 희귀도 (목표)
```yaml
rarity_distribution:
common: 34%
uncommon: 40%
rare: 20%
epic: 5%
legendary: 1%
note: 현재 코드와 다름. ItemService.determineRarity() 수정 필요
```
---
## 데이터 모델
### MonetizationState
```dart
class MonetizationState {
final bool adRemovalPurchased; // IAP 구매 여부
final int rollsRemaining; // 굴리기 남은 횟수 (0-5)
final int undoRemaining; // 되돌리기 남은 횟수
final List<Stats>? rollHistory; // 되돌리기용 히스토리
final int? autoReviveEndMs; // 자동부활 버프 종료 시점
final int? speedBoostEndMs; // 5배속 종료 시점
final DateTime? lastPlayTime; // 마지막 플레이 시각
final int pendingChests; // 미개봉 상자 개수
final int? luckyCharmEndMs; // 행운 버프 종료 시점
}
```
### DeathInfo 확장
```dart
class DeathInfo {
// 기존 필드
final String? lostItemName;
final EquipmentSlot? lostItemSlot;
final ItemRarity? lostItemRarity;
// 신규 필드
final EquipmentItem? lostItem; // 복구용 전체 장비 정보
}
```
### GameSave 버전
```yaml
current_version: 3
next_version: 4
migration:
- add: MonetizationState monetization
- add: DeathInfo.lostItem
```
---
## 엣지 케이스
| 케이스 | 조건 | 처리 |
| ------ | ---- | ---- |
| 광고 로드 실패 | 네트워크 오류 | 재시도 버튼 표시 |
| 오프라인 상태 | 네트워크 없음 | 광고 버튼 비활성화 |
| 광고 중 앱 종료 | 광고 미완료 | 보상 미지급 |
| IAP 복원 | 앱 재설치 | 구글/애플 구매기록 확인 |
| 자동부활 중 종료 | 버프 활성 중 | 남은 시간 저장 |
| 시간 조작 | lastPlayTime > now | 복귀 보상 없음 |
| 굴리기 0회 종료 | rollsRemaining == 0 | 0회 유지 |
| 되돌리기 초과 | undoRemaining > historyLength | min 적용 |
---
## 디버그 옵션 (kDebugMode 전용)
```yaml
debug_options:
ad_enabled:
type: bool
default: true
off_behavior: 광고 버튼 클릭 시 바로 보상
on_behavior: 실제 광고 재생
iap_simulated:
type: bool
default: false
off_behavior: 무료 유저로 동작
on_behavior: 구매 유저로 동작
offline_hours:
type: int
options: [0, 1, 5, 10]
default: 0
purpose: 복귀 보상 테스트
locations:
- 메인 메뉴 (광고 제거 버튼 아래)
- 스타트 화면
```
---
## UI 요약
### 메인 메뉴
```
[ 새 게임 ]
[ 불러오기 ]
[ 설정 ]
[ 명예의 전당 ]
────────────
[ 광고 제거 - $9.99 ] ← 구매 후 비활성화
── Debug Only ──
[ 광고: ON/OFF ]
[ IAP: 미구매/구매 ]
```
### 캐릭터 생성
```
STR: 14 CON: 12 DEX: 10
INT: 16 WIS: 8 CHA: 11
Total: 71
[ 굴리기 (N/5) ] ← 0회 시 🎬 표시
[ 🎬 이전으로 (N/M) ] ← 무료:1회, 구매:3회
```
### 게임 화면 - 속도
```
명예의 전당 없음: [ 1x ] [ 🎬 5x ]
명예의 전당 있음: [ 1x ] [ 2x ] [ 🎬 5x ]
5배속 중: [ 5x ⏱️ 4:32 ]
```
### 사망 화면
```
☠️ You Died
Killed by: {monster}
Lost: {item}
[ 🎬 광고 부활 (30초) ]
- 장비 복구
- 제물 없음
- HP 100%
- 10분 자동부활
[ 일반 부활 ]
- 장비 손실
- 제물 소모
- HP 50%
```
---
## 참고
### 관련 파일
- `lib/src/core/model/game_state.dart`: GameState 모델
- `lib/src/core/engine/progress_service.dart`: 사망 처리 로직
- `lib/src/core/storage/`: 저장/로드 시스템
- `lib/src/core/engine/item_service.dart`: 아이템 희귀도 결정
### 의존성 패키지
```yaml
dependencies:
google_mobile_ads: ^5.0.0 # AdMob
in_app_purchase: ^3.1.0 # IAP
```

View File

@@ -141,14 +141,56 @@ String get deathNoSacrificeNeeded =>
_l('No sacrifice needed', '희생 없이 부활', '犠牲なしで復活');
String get deathCoinRemaining => _l('Coin Remaining', '남은 코인', '残りコイン');
String get deathResurrect => _l('Resurrect', '부활', '復活');
String get deathAutoResurrect => _l('Auto Resurrect', '자동 부활', '自動復活');
String get deathCombatLog => _l('Combat Log', '전투 기록', '戦闘ログ');
// 광고 부활 (통합)
String get deathAdRevive => _l('Watch Ad & Revive', '광고보고 부활', '広告視聴で復活');
String get deathAdReviveHp => _l('HP 100% Recovery', 'HP 100% 회복', 'HP 100%回復');
String get deathAdReviveItem => _l('Item Recovery', '아이템 복구', 'アイテム回収');
String get deathAdReviveAuto =>
_l('10min Auto-Revive Buff', '10분간 자동부활 버프', '10分間自動復活バフ');
String get deathAdRevivePaidDesc =>
_l('Premium: No ads required', '프리미엄: 광고 없이 이용', 'プレミアム: 広告不要');
String deathKilledBy(String killerName) =>
_l('Killed by $killerName', '$killerName에게 사망', '$killerNameに倒された');
String get deathEnvironmentalHazard =>
_l('Environmental hazard', '환경 피해로 사망', '環境ダメージで死亡');
// ============================================================================
// 속도 부스트 (Phase 6)
// ============================================================================
String get speedBoostTitle =>
_l('Speed Boost', '속도 부스트', 'スピードブースト');
String get speedBoostActivate =>
_l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化');
String speedBoostRemaining(int seconds) =>
_l('${seconds}s remaining', '${seconds}초 남음', '残り${seconds}');
String get speedBoostActive =>
_l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
// ============================================================================
// 복귀 보상 (Phase 7)
// ============================================================================
String get returnRewardTitle =>
_l('Welcome Back!', '돌아오셨군요!', 'おかえりなさい!');
String returnRewardHoursAway(String time) =>
_l('You were away for $time', '$time 동안 떠나있었습니다', '$time 離れていました');
String get returnRewardBasic =>
_l('Basic Reward', '기본 보상', '基本報酬');
String get returnRewardBonus =>
_l('Bonus Reward', '보너스 보상', 'ボーナス報酬');
String returnRewardGold(int gold) =>
_l('+$gold Gold', '+$gold 골드', '+$gold ゴールド');
String get returnRewardClaim =>
_l('Claim', '받기', '受け取る');
String get returnRewardClaimBonus =>
_l('Claim Bonus', '보너스 받기', 'ボーナス受取');
String get returnRewardSkip =>
_l('Skip', '건너뛰기', 'スキップ');
// ============================================================================
// UI 일반 메시지
// ============================================================================

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/audio/audio_service.dart';
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
import 'package:asciineverdie/src/core/engine/progress_service.dart';
@@ -23,6 +24,7 @@ import 'package:asciineverdie/src/features/game/game_session_controller.dart';
import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart';
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:asciineverdie/src/features/new_character/new_character_screen.dart';
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
class AskiiNeverDieApp extends StatefulWidget {
const AskiiNeverDieApp({super.key});
@@ -81,6 +83,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
// 초기 설정 및 오디오 서비스 로드
_loadSettings();
_audioService.init();
// 디버그 설정 서비스 초기화 (Phase 8)
DebugSettingsService.instance.initialize();
// 세이브 파일 존재 여부 확인
_checkForExistingSave();
// 명예의 전당 로드
@@ -118,7 +122,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
if (exists) {
// 세이브 파일에서 미리보기 정보 추출
final (outcome, state, _) = await _controller.saveManager.loadState();
final (outcome, state, _, _) = await _controller.saveManager.loadState();
if (outcome.success && state != null) {
final actName = _getActName(state.progress.plotStageCount);
preview = SavedGamePreview(
@@ -465,6 +469,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
onLoadSave: _loadSave,
onHallOfFame: _navigateToHallOfFame,
onLocalArena: _navigateToArena,
onSettings: _showSettings,
hasSaveFile: _hasSave,
savedGamePreview: _savedGamePreview,
hallOfFameCount: _hallOfFame.count,
@@ -602,6 +607,18 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
_audioService.playBgm('title');
});
}
/// 설정 화면 표시 (모달 바텀시트)
void _showSettings(BuildContext context) {
SettingsScreen.show(
context,
settingsRepository: _settingsRepository,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
onBgmVolumeChange: _audioService.setBgmVolume,
onSfxVolumeChange: _audioService.setSfxVolume,
);
}
}
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일

View File

@@ -0,0 +1,359 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// 광고 타입
enum AdType {
/// 부활용 리워드 광고 (30초)
rewardRevive,
/// 캐릭터 생성 되돌리기용 리워드 광고 (30초)
rewardUndo,
/// 굴리기 충전용 인터스티셜 광고 (6초)
interstitialRoll,
/// 속도업용 인터스티셜 광고 (6초)
interstitialSpeed,
}
/// 광고 결과
enum AdResult {
/// 광고 시청 완료 (보상 지급)
completed,
/// 광고 시청 취소/스킵
cancelled,
/// 광고 로드 실패
failed,
/// 디버그 모드에서 광고 스킵
debugSkipped,
}
/// 광고 서비스
///
/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다.
/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다.
class AdService {
AdService._();
static AdService? _instance;
/// 싱글톤 인스턴스
static AdService get instance {
_instance ??= AdService._();
return _instance!;
}
// ===========================================================================
// 광고 단위 ID
// ===========================================================================
// ─────────────────────────────────────────────────────────────────────────
// 테스트 광고 ID (Google 공식 테스트 ID)
// ─────────────────────────────────────────────────────────────────────────
static const String _testRewardedAndroid =
'ca-app-pub-3940256099942544/5224354917';
static const String _testRewardedIos =
'ca-app-pub-3940256099942544/1712485313';
static const String _testInterstitialAndroid =
'ca-app-pub-3940256099942544/1033173712';
static const String _testInterstitialIos =
'ca-app-pub-3940256099942544/4411468910';
// ─────────────────────────────────────────────────────────────────────────
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
// ─────────────────────────────────────────────────────────────────────────
// TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체
static const String _prodRewardedAndroid =
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 리워드 광고
static const String _prodRewardedIos =
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 리워드 광고
static const String _prodInterstitialAndroid =
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 인터스티셜 광고
static const String _prodInterstitialIos =
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 인터스티셜 광고
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
String get _rewardAdUnitId {
if (Platform.isAndroid) {
return kReleaseMode ? _prodRewardedAndroid : _testRewardedAndroid;
} else if (Platform.isIOS) {
return kReleaseMode ? _prodRewardedIos : _testRewardedIos;
}
return '';
}
/// 인터스티셜 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
String get _interstitialAdUnitId {
if (Platform.isAndroid) {
return kReleaseMode ? _prodInterstitialAndroid : _testInterstitialAndroid;
} else if (Platform.isIOS) {
return kReleaseMode ? _prodInterstitialIos : _testInterstitialIos;
}
return '';
}
// ===========================================================================
// 상태
// ===========================================================================
bool _isInitialized = false;
/// 디버그 모드에서 광고 활성화 여부
bool _debugAdEnabled = true;
/// 로드된 리워드 광고
RewardedAd? _rewardedAd;
/// 로드된 인터스티셜 광고
InterstitialAd? _interstitialAd;
/// 리워드 광고 로딩 중 여부
bool _isLoadingRewardedAd = false;
/// 인터스티셜 광고 로딩 중 여부
bool _isLoadingInterstitialAd = false;
// ===========================================================================
// 초기화
// ===========================================================================
/// AdMob SDK 초기화
Future<void> initialize() async {
if (_isInitialized) return;
// 모바일 플랫폼에서만 초기화
if (!Platform.isAndroid && !Platform.isIOS) {
debugPrint('[AdService] Non-mobile platform, skipping initialization');
return;
}
await MobileAds.instance.initialize();
_isInitialized = true;
debugPrint('[AdService] Initialized');
// 초기 광고 로드
_loadRewardedAd();
_loadInterstitialAd();
}
// ===========================================================================
// 디버그 설정
// ===========================================================================
/// 디버그 모드 광고 활성화 여부
bool get debugAdEnabled => _debugAdEnabled;
/// 디버그 모드 광고 토글
set debugAdEnabled(bool value) {
_debugAdEnabled = value;
debugPrint('[AdService] Debug ad enabled: $value');
}
/// 광고를 스킵할지 여부
///
/// 스킵 조건:
/// - 비모바일 플랫폼 (macOS, Windows, Linux, Web)
/// - 디버그 모드에서 광고 비활성화
bool get _shouldSkipAd {
// 웹에서는 항상 스킵
if (kIsWeb) return true;
// 비모바일 플랫폼(데스크톱)에서는 항상 스킵
if (!Platform.isAndroid && !Platform.isIOS) {
return true;
}
// 디버그 모드에서 광고 비활성화 시 스킵
return kDebugMode && !_debugAdEnabled;
}
// ===========================================================================
// 리워드 광고
// ===========================================================================
/// 리워드 광고 로드
void _loadRewardedAd() {
if (_isLoadingRewardedAd || _rewardedAd != null) return;
if (!_isInitialized) return;
_isLoadingRewardedAd = true;
debugPrint('[AdService] Loading rewarded ad...');
RewardedAd.load(
adUnitId: _rewardAdUnitId,
request: const AdRequest(),
rewardedAdLoadCallback: RewardedAdLoadCallback(
onAdLoaded: (ad) {
_rewardedAd = ad;
_isLoadingRewardedAd = false;
debugPrint('[AdService] Rewarded ad loaded');
},
onAdFailedToLoad: (error) {
_isLoadingRewardedAd = false;
debugPrint('[AdService] Rewarded ad failed to load: ${error.message}');
},
),
);
}
/// 리워드 광고 준비 여부
bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd;
/// 리워드 광고 표시
///
/// [adType] 광고 타입 (로깅용)
/// [onRewarded] 보상 지급 콜백
/// Returns: 광고 결과
Future<AdResult> showRewardedAd({
required AdType adType,
required void Function() onRewarded,
}) async {
// 디버그 모드에서 광고 스킵
if (_shouldSkipAd) {
debugPrint('[AdService] Debug: Skipping $adType ad');
onRewarded();
return AdResult.debugSkipped;
}
// 광고가 로드되지 않은 경우
if (_rewardedAd == null) {
debugPrint('[AdService] Rewarded ad not ready');
_loadRewardedAd();
return AdResult.failed;
}
final ad = _rewardedAd!;
_rewardedAd = null;
// 결과 추적용
var result = AdResult.cancelled;
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
debugPrint('[AdService] Rewarded ad dismissed');
ad.dispose();
_loadRewardedAd(); // 다음 광고 미리 로드
},
onAdFailedToShowFullScreenContent: (ad, error) {
debugPrint('[AdService] Rewarded ad failed to show: ${error.message}');
ad.dispose();
result = AdResult.failed;
_loadRewardedAd();
},
);
await ad.show(
onUserEarnedReward: (ad, reward) {
debugPrint('[AdService] User earned reward: ${reward.amount}');
result = AdResult.completed;
onRewarded();
},
);
return result;
}
// ===========================================================================
// 인터스티셜 광고
// ===========================================================================
/// 인터스티셜 광고 로드
void _loadInterstitialAd() {
if (_isLoadingInterstitialAd || _interstitialAd != null) return;
if (!_isInitialized) return;
_isLoadingInterstitialAd = true;
debugPrint('[AdService] Loading interstitial ad...');
InterstitialAd.load(
adUnitId: _interstitialAdUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
_interstitialAd = ad;
_isLoadingInterstitialAd = false;
debugPrint('[AdService] Interstitial ad loaded');
},
onAdFailedToLoad: (error) {
_isLoadingInterstitialAd = false;
debugPrint(
'[AdService] Interstitial ad failed to load: ${error.message}',
);
},
),
);
}
/// 인터스티셜 광고 준비 여부
bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd;
/// 인터스티셜 광고 표시
///
/// [adType] 광고 타입 (로깅용)
/// [onComplete] 광고 완료 콜백 (보상 지급)
/// Returns: 광고 결과
Future<AdResult> showInterstitialAd({
required AdType adType,
required void Function() onComplete,
}) async {
// 디버그 모드에서 광고 스킵
if (_shouldSkipAd) {
debugPrint('[AdService] Debug: Skipping $adType ad');
onComplete();
return AdResult.debugSkipped;
}
// 광고가 로드되지 않은 경우
if (_interstitialAd == null) {
debugPrint('[AdService] Interstitial ad not ready');
_loadInterstitialAd();
return AdResult.failed;
}
final ad = _interstitialAd!;
_interstitialAd = null;
// 결과 추적용
var result = AdResult.cancelled;
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
debugPrint('[AdService] Interstitial ad dismissed');
ad.dispose();
result = AdResult.completed;
onComplete();
_loadInterstitialAd(); // 다음 광고 미리 로드
},
onAdFailedToShowFullScreenContent: (ad, error) {
debugPrint(
'[AdService] Interstitial ad failed to show: ${error.message}',
);
ad.dispose();
result = AdResult.failed;
_loadInterstitialAd();
},
);
await ad.show();
return result;
}
// ===========================================================================
// 정리
// ===========================================================================
/// 리소스 해제
void dispose() {
_rewardedAd?.dispose();
_rewardedAd = null;
_interstitialAd?.dispose();
_interstitialAd = null;
debugPrint('[AdService] Disposed');
}
}

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;
}

View File

@@ -0,0 +1,180 @@
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';
/// 디버그 설정 서비스 (Phase 8)
///
/// 개발/테스트 중 사용하는 디버그 옵션을 통합 관리합니다.
/// kDebugMode에서만 활성화됩니다.
class DebugSettingsService {
DebugSettingsService._();
static DebugSettingsService? _instance;
/// 싱글톤 인스턴스
static DebugSettingsService get instance {
_instance ??= DebugSettingsService._();
return _instance!;
}
// ===========================================================================
// 상수
// ===========================================================================
static const String _keyAdEnabled = 'debug_ad_enabled';
static const String _keyIapSimulated = 'debug_iap_simulated';
static const String _keyOfflineHours = 'debug_offline_hours';
/// 오프라인 시간 시뮬레이션 옵션
static const List<int> offlineHoursOptions = [0, 1, 5, 10, 24];
// ===========================================================================
// 상태
// ===========================================================================
bool _isInitialized = false;
/// 광고 활성화 여부 (디버그 모드 전용)
bool _adEnabled = true;
/// IAP 구매 시뮬레이션 여부 (디버그 모드 전용)
bool _iapSimulated = false;
/// 오프라인 시간 시뮬레이션 (시간 단위)
int _offlineHours = 0;
// ===========================================================================
// 초기화
// ===========================================================================
/// 서비스 초기화 (저장된 설정 로드)
Future<void> initialize() async {
if (_isInitialized) return;
if (!kDebugMode) {
_isInitialized = true;
return;
}
final prefs = await SharedPreferences.getInstance();
_adEnabled = prefs.getBool(_keyAdEnabled) ?? true;
_iapSimulated = prefs.getBool(_keyIapSimulated) ?? false;
_offlineHours = prefs.getInt(_keyOfflineHours) ?? 0;
// 다른 서비스에 설정 동기화
_syncToServices();
_isInitialized = true;
debugPrint('[DebugSettings] Initialized: ad=$_adEnabled, '
'iap=$_iapSimulated, offline=$_offlineHours');
}
/// 설정을 다른 서비스에 동기화
void _syncToServices() {
AdService.instance.debugAdEnabled = _adEnabled;
IAPService.instance.debugIAPSimulated = _iapSimulated;
}
// ===========================================================================
// 디버그 모드 확인
// ===========================================================================
/// 디버그 모드 활성화 여부
bool get isDebugMode => kDebugMode;
// ===========================================================================
// 광고 설정
// ===========================================================================
/// 광고 활성화 여부
///
/// - true: 실제 광고 표시
/// - false: 광고 버튼 클릭 시 바로 보상 지급
bool get adEnabled => _adEnabled;
/// 광고 활성화 토글
Future<void> setAdEnabled(bool value) async {
if (!kDebugMode) return;
_adEnabled = value;
AdService.instance.debugAdEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAdEnabled, value);
debugPrint('[DebugSettings] Ad enabled: $value');
}
// ===========================================================================
// IAP 설정
// ===========================================================================
/// IAP 구매 시뮬레이션 여부
///
/// - true: 유료 유저로 동작 (광고 제거됨)
/// - false: 무료 유저로 동작
bool get iapSimulated => _iapSimulated;
/// IAP 구매 시뮬레이션 토글
Future<void> setIapSimulated(bool value) async {
if (!kDebugMode) return;
_iapSimulated = value;
IAPService.instance.debugIAPSimulated = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyIapSimulated, value);
debugPrint('[DebugSettings] IAP simulated: $value');
}
// ===========================================================================
// 오프라인 시간 시뮬레이션
// ===========================================================================
/// 오프라인 시간 시뮬레이션 (시간 단위)
///
/// 복귀 보상 테스트용. 0이면 시뮬레이션 비활성화.
int get offlineHours => _offlineHours;
/// 오프라인 시간 시뮬레이션 설정
Future<void> setOfflineHours(int hours) async {
if (!kDebugMode) return;
_offlineHours = hours;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyOfflineHours, hours);
debugPrint('[DebugSettings] Offline hours: $hours');
}
/// 시뮬레이션된 마지막 플레이 시간 계산
///
/// [actualLastPlayTime] 실제 마지막 플레이 시간
/// Returns: 시뮬레이션 적용된 마지막 플레이 시간
DateTime? getSimulatedLastPlayTime(DateTime? actualLastPlayTime) {
if (!kDebugMode || _offlineHours == 0) {
return actualLastPlayTime;
}
// 시뮬레이션: 현재 시간에서 offlineHours만큼 뺀 시간을 마지막 플레이 시간으로
return DateTime.now().subtract(Duration(hours: _offlineHours));
}
// ===========================================================================
// 전체 초기화
// ===========================================================================
/// 모든 디버그 설정 초기화
Future<void> resetAll() async {
if (!kDebugMode) return;
await setAdEnabled(true);
await setIapSimulated(false);
await setOfflineHours(0);
debugPrint('[DebugSettings] All settings reset');
}
}

View File

@@ -0,0 +1,341 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// IAP 상품 ID
class IAPProductIds {
IAPProductIds._();
/// 광고 제거 상품 ID (비소모성)
/// TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체
static const String removeAds = 'remove_ads';
/// 모든 상품 ID 목록
static const Set<String> all = {removeAds};
}
/// IAP 구매 결과
enum IAPResult {
/// 구매 성공
success,
/// 구매 취소
cancelled,
/// 구매 실패
failed,
/// 이미 구매됨
alreadyPurchased,
/// 상품을 찾을 수 없음
productNotFound,
/// 스토어 사용 불가
storeUnavailable,
/// 디버그 모드에서 시뮬레이션
debugSimulated,
}
/// IAP 서비스
///
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
/// shared_preferences를 사용하여 구매 상태를 영구 저장합니다.
class IAPService {
IAPService._();
static IAPService? _instance;
/// 싱글톤 인스턴스
static IAPService get instance {
_instance ??= IAPService._();
return _instance!;
}
// ===========================================================================
// 상수
// ===========================================================================
/// 구매 상태 저장 키
static const String _purchaseKey = 'iap_remove_ads_purchased';
// ===========================================================================
// 상태
// ===========================================================================
final InAppPurchase _iap = InAppPurchase.instance;
bool _isInitialized = false;
bool _isAvailable = false;
/// 상품 정보
ProductDetails? _removeAdsProduct;
/// 구매 스트림 구독
StreamSubscription<List<PurchaseDetails>>? _subscription;
/// 광고 제거 구매 여부 (캐시)
bool _adRemovalPurchased = false;
/// 디버그 모드에서 IAP 시뮬레이션 활성화 여부
bool _debugIAPSimulated = false;
// ===========================================================================
// 초기화
// ===========================================================================
/// IAP 서비스 초기화
Future<void> initialize() async {
if (_isInitialized) return;
// 모바일 플랫폼에서만 초기화
if (!Platform.isAndroid && !Platform.isIOS) {
debugPrint('[IAPService] Non-mobile platform, skipping initialization');
return;
}
// 저장된 구매 상태 로드
await _loadPurchaseState();
// 스토어 가용성 확인
_isAvailable = await _iap.isAvailable();
if (!_isAvailable) {
debugPrint('[IAPService] Store not available');
_isInitialized = true;
return;
}
// 구매 스트림 구독
_subscription = _iap.purchaseStream.listen(
_onPurchaseUpdate,
onError: (Object error) {
debugPrint('[IAPService] Purchase stream error: $error');
},
);
// 상품 정보 로드
await _loadProducts();
_isInitialized = true;
debugPrint('[IAPService] Initialized');
}
/// 상품 정보 로드
Future<void> _loadProducts() async {
final response = await _iap.queryProductDetails(IAPProductIds.all);
if (response.notFoundIDs.isNotEmpty) {
debugPrint(
'[IAPService] Products not found: ${response.notFoundIDs}',
);
}
for (final product in response.productDetails) {
if (product.id == IAPProductIds.removeAds) {
_removeAdsProduct = product;
debugPrint(
'[IAPService] Product loaded: ${product.id} - ${product.price}',
);
}
}
}
/// 저장된 구매 상태 로드
Future<void> _loadPurchaseState() async {
final prefs = await SharedPreferences.getInstance();
_adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false;
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
}
/// 구매 상태 저장
Future<void> _savePurchaseState(bool purchased) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_purchaseKey, purchased);
_adRemovalPurchased = purchased;
debugPrint('[IAPService] Saved purchase state: $purchased');
}
// ===========================================================================
// 디버그 설정
// ===========================================================================
/// 디버그 모드 IAP 시뮬레이션 활성화 여부
bool get debugIAPSimulated => _debugIAPSimulated;
/// 디버그 모드 IAP 시뮬레이션 토글
set debugIAPSimulated(bool value) {
_debugIAPSimulated = value;
if (kDebugMode) {
_adRemovalPurchased = value;
debugPrint('[IAPService] Debug IAP simulated: $value');
}
}
// ===========================================================================
// 구매 상태
// ===========================================================================
/// 광고 제거 구매 여부
bool get isAdRemovalPurchased {
if (kDebugMode && _debugIAPSimulated) return true;
return _adRemovalPurchased;
}
/// 스토어 가용성
bool get isStoreAvailable => _isAvailable;
/// 광고 제거 상품 정보
ProductDetails? get removeAdsProduct => _removeAdsProduct;
/// 광고 제거 상품 가격 문자열
String get removeAdsPrice {
return _removeAdsProduct?.price ?? '\$9.99';
}
// ===========================================================================
// 구매
// ===========================================================================
/// 광고 제거 구매
Future<IAPResult> purchaseRemoveAds() async {
// 디버그 모드 시뮬레이션
if (kDebugMode && _debugIAPSimulated) {
debugPrint('[IAPService] Debug: Simulating purchase');
await _savePurchaseState(true);
return IAPResult.debugSimulated;
}
// 이미 구매됨
if (_adRemovalPurchased) {
debugPrint('[IAPService] Already purchased');
return IAPResult.alreadyPurchased;
}
// 스토어 사용 불가
if (!_isAvailable) {
debugPrint('[IAPService] Store not available');
return IAPResult.storeUnavailable;
}
// 상품을 찾을 수 없음
if (_removeAdsProduct == null) {
debugPrint('[IAPService] Product not found');
return IAPResult.productNotFound;
}
// 구매 요청
final purchaseParam = PurchaseParam(
productDetails: _removeAdsProduct!,
);
try {
final success = await _iap.buyNonConsumable(
purchaseParam: purchaseParam,
);
debugPrint('[IAPService] Purchase initiated: $success');
return success ? IAPResult.success : IAPResult.failed;
} catch (e) {
debugPrint('[IAPService] Purchase error: $e');
return IAPResult.failed;
}
}
// ===========================================================================
// 구매 복원
// ===========================================================================
/// 구매 복원
Future<IAPResult> restorePurchases() async {
// 디버그 모드 시뮬레이션
if (kDebugMode && _debugIAPSimulated) {
debugPrint('[IAPService] Debug: Simulating restore');
await _savePurchaseState(true);
return IAPResult.debugSimulated;
}
// 스토어 사용 불가
if (!_isAvailable) {
debugPrint('[IAPService] Store not available');
return IAPResult.storeUnavailable;
}
try {
await _iap.restorePurchases();
debugPrint('[IAPService] Restore initiated');
return IAPResult.success;
} catch (e) {
debugPrint('[IAPService] Restore error: $e');
return IAPResult.failed;
}
}
// ===========================================================================
// 구매 처리
// ===========================================================================
/// 구매 업데이트 처리
void _onPurchaseUpdate(List<PurchaseDetails> purchases) {
for (final purchase in purchases) {
debugPrint(
'[IAPService] Purchase update: ${purchase.productID} - '
'${purchase.status}',
);
switch (purchase.status) {
case PurchaseStatus.pending:
// 구매 대기 중
debugPrint('[IAPService] Purchase pending');
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
// 구매 완료 또는 복원
_handleSuccessfulPurchase(purchase);
case PurchaseStatus.error:
// 구매 실패
debugPrint('[IAPService] Purchase error: ${purchase.error}');
_completePurchase(purchase);
case PurchaseStatus.canceled:
// 구매 취소
debugPrint('[IAPService] Purchase canceled');
_completePurchase(purchase);
}
}
}
/// 구매 성공 처리
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
if (purchase.productID == IAPProductIds.removeAds) {
// 구매 검증 (서버 검증 생략, 로컬 저장)
await _savePurchaseState(true);
debugPrint('[IAPService] Ad removal purchased successfully');
}
// 구매 완료 처리
await _completePurchase(purchase);
}
/// 구매 완료 처리 (필수)
Future<void> _completePurchase(PurchaseDetails purchase) async {
if (purchase.pendingCompletePurchase) {
await _iap.completePurchase(purchase);
debugPrint('[IAPService] Purchase completed: ${purchase.productID}');
}
}
// ===========================================================================
// 정리
// ===========================================================================
/// 리소스 해제
void dispose() {
_subscription?.cancel();
_subscription = null;
debugPrint('[IAPService] Disposed');
}
}

View File

@@ -60,23 +60,26 @@ class ItemService {
// 희귀도 결정
// ============================================================================
/// 희귀도 결정 (레벨 기반 확률)
/// 희귀도 결정 (고정 확률)
///
/// 레벨이 높을수록 희귀한 아이템 확률 증가
/// 확률 분포:
/// - Common: 34%
/// - Uncommon: 40%
/// - Rare: 20%
/// - Epic: 5%
/// - Legendary: 1%
ItemRarity determineRarity(int level) {
final roll = rng.nextInt(100);
final legendaryChance = (level * 0.5).clamp(0, 5).toInt(); // 최대 5%
final epicChance = (level * 1.0).clamp(0, 10).toInt(); // 최대 10%
final rareChance = (level * 2.0).clamp(0, 20).toInt(); // 최대 20%
final uncommonChance = (level * 3.0).clamp(0, 30).toInt(); // 최대 30%
if (roll < legendaryChance) return ItemRarity.legendary;
if (roll < legendaryChance + epicChance) return ItemRarity.epic;
if (roll < legendaryChance + epicChance + rareChance)
return ItemRarity.rare;
if (roll < legendaryChance + epicChance + rareChance + uncommonChance) {
return ItemRarity.uncommon;
}
// Legendary: 0-0 (1%)
if (roll < 1) return ItemRarity.legendary;
// Epic: 1-5 (5%)
if (roll < 6) return ItemRarity.epic;
// Rare: 6-25 (20%)
if (roll < 26) return ItemRarity.rare;
// Uncommon: 26-65 (40%)
if (roll < 66) return ItemRarity.uncommon;
// Common: 66-99 (34%)
return ItemRarity.common;
}

View File

@@ -972,12 +972,16 @@ class ProgressService {
String? lostItemName;
EquipmentSlot? lostItemSlot;
ItemRarity? lostItemRarity;
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
if (!isBossDeath) {
// 레벨 기반 장비 손실 확률 계산
// Lv 1~5: 0%, Lv 6: 10%, Lv 10: 50%, Lv 15+: 100%
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
// 공식: 20 + (level - 1) * 80 / 9
final level = state.traits.level;
final lossChancePercent = ((level - 5) * 10).clamp(0, 100);
final lossChancePercent = level >= 10
? 100
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
final roll = state.rng.nextInt(100); // 0~99
final shouldLoseEquipment = roll < lossChancePercent;
@@ -1004,10 +1008,10 @@ class ProgressService {
)];
// 제물로 바칠 아이템 정보 저장
final lostItem = state.equipment.getItemByIndex(sacrificeIndex);
lostItemName = lostItem.name;
lostEquipmentItem = state.equipment.getItemByIndex(sacrificeIndex);
lostItemName = lostEquipmentItem.name;
lostItemSlot = EquipmentSlot.values[sacrificeIndex];
lostItemRarity = lostItem.rarity;
lostItemRarity = lostEquipmentItem.rarity;
// 해당 슬롯을 빈 장비로 교체
newEquipment = newEquipment.setItemByIndex(
@@ -1029,6 +1033,7 @@ class ProgressService {
lostItemName: lostItemName,
lostItemSlot: lostItemSlot,
lostItemRarity: lostItemRarity,
lostItem: lostEquipmentItem, // 광고 부활 시 복구용
goldAtDeath: state.inventory.gold,
levelAtDeath: state.traits.level,
timestamp: state.skillSystem.elapsedMs,

View File

@@ -240,6 +240,129 @@ class ResurrectionService {
return state.inventory.gold >= cost;
}
// ============================================================================
// 광고 부활 (HP 100% + 아이템 복구)
// ============================================================================
/// 광고 부활 처리
///
/// 1. 상실한 아이템 복구 (있는 경우)
/// 2. HP/MP 100% 회복
/// 3. 사망 상태 해제
/// 4. 안전 지역으로 이동 태스크 설정
///
/// Note: 10분 자동부활 버프는 GameSessionController에서 처리
GameState processAdRevive(GameState state) {
if (!state.isDead) return state;
var nextState = state;
// 1. 상실한 아이템 복구 (있는 경우)
if (canRecoverLostItem(state)) {
nextState = processItemRecovery(nextState);
}
// 2. 전체 HP/MP 계산 (장비 + 종족 + 클래스 보너스 포함)
final totalHpMax = _calculateTotalHpMax(nextState);
final totalMpMax = _calculateTotalMpMax(nextState);
// HP/MP 100% 회복 (장비 구매 없이)
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(
hpCurrent: totalHpMax,
mpCurrent: totalMpMax,
),
clearDeathInfo: true, // 사망 상태 해제
);
// 스킬 쿨타임 초기화
nextState = nextState.copyWith(
skillSystem: SkillSystemState.empty().copyWith(
elapsedMs: nextState.skillSystem.elapsedMs,
),
);
// 4. 부활 후 태스크 시퀀스 설정
final resurrectionQueue = <QueueEntry>[
QueueEntry(
kind: QueueKind.task,
durationMillis: 3000,
caption: l10n.taskReturningToTown,
taskType: TaskType.neutral,
),
QueueEntry(
kind: QueueKind.task,
durationMillis: 3000,
caption: l10n.taskRestockingAtShop,
taskType: TaskType.market,
),
QueueEntry(
kind: QueueKind.task,
durationMillis: 2000,
caption: l10n.taskHeadingToHuntingGrounds,
taskType: TaskType.neutral,
),
];
final firstTask = resurrectionQueue.removeAt(0);
nextState = nextState.copyWith(
queue: QueueState(entries: resurrectionQueue),
progress: nextState.progress.copyWith(
currentTask: TaskInfo(
caption: firstTask.caption,
type: firstTask.taskType,
),
task: ProgressBarState(position: 0, max: firstTask.durationMillis),
currentCombat: null,
),
);
return nextState;
}
// ============================================================================
// 아이템 복구 (Phase 5)
// ============================================================================
/// 상실한 아이템 복구 가능 여부 확인
///
/// 사망 상태이고 상실한 아이템이 있을 때만 true
bool canRecoverLostItem(GameState state) {
if (!state.isDead) return false;
if (state.deathInfo == null) return false;
return state.deathInfo!.lostItem != null;
}
/// 상실한 아이템 복구 처리
///
/// 광고 시청 후 호출되며 상실한 아이템을 장비에 복원합니다.
/// Returns: 복구된 상태 (복구 불가 시 원본 상태 반환)
GameState processItemRecovery(GameState state) {
if (!canRecoverLostItem(state)) return state;
final deathInfo = state.deathInfo!;
final lostItem = deathInfo.lostItem!;
final lostSlot = deathInfo.lostItemSlot!;
// 해당 슬롯에 아이템 복원
final slotIndex = lostSlot.index;
final updatedEquipment = state.equipment.setItemByIndex(slotIndex, lostItem);
// DeathInfo에서 상실 아이템 정보 제거 (복구 완료)
final updatedDeathInfo = deathInfo.copyWith(
lostEquipmentCount: 0,
lostItemName: null,
lostItemSlot: null,
lostItemRarity: null,
lostItem: null,
);
return state.copyWith(
equipment: updatedEquipment,
deathInfo: updatedDeathInfo,
);
}
/// 장비 보존 아이템 적용 (향후 확장용)
///
/// [protectedSlots] 보존할 슬롯 인덱스 목록

View File

@@ -0,0 +1,178 @@
import 'package:flutter/foundation.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
/// 복귀 보상 데이터 (Phase 7)
class ReturnReward {
const ReturnReward({
required this.hoursAway,
required this.goldReward,
required this.bonusGold,
});
/// 떠나있던 시간 (시간 단위)
final int hoursAway;
/// 기본 골드 보상
final int goldReward;
/// 보너스 골드 (광고 시청 시 추가)
final int bonusGold;
/// 총 보상 (광고 포함)
int get totalGold => goldReward + bonusGold;
/// 보상이 있는지 여부
bool get hasReward => goldReward > 0;
}
/// 복귀 보상 서비스 (Phase 7)
///
/// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보상을 제공합니다.
/// - 최소 복귀 시간: 1시간
/// - 최대 복귀 시간: 24시간 (그 이상은 24시간으로 계산)
/// - 기본 보상: 시간당 100골드
/// - 보너스 보상: 광고 시청 시 2배
class ReturnRewardsService {
ReturnRewardsService._();
static ReturnRewardsService? _instance;
/// 싱글톤 인스턴스
static ReturnRewardsService get instance {
_instance ??= ReturnRewardsService._();
return _instance!;
}
// ===========================================================================
// 상수
// ===========================================================================
/// 최소 복귀 시간 (시간)
static const int minHoursAway = 1;
/// 최대 복귀 시간 (시간) - 이 이상은 동일 보상
static const int maxHoursAway = 24;
/// 시간당 골드 보상
static const int goldPerHour = 100;
/// 레벨당 골드 보상 배수
static const double goldPerLevelMultiplier = 0.1;
// ===========================================================================
// 보상 계산
// ===========================================================================
/// 복귀 보상 계산
///
/// [lastPlayTime] 마지막 플레이 시각 (null이면 보상 없음)
/// [currentTime] 현재 시각
/// [playerLevel] 플레이어 레벨 (레벨 보너스 계산용)
/// Returns: 복귀 보상 데이터 (시간이 부족하면 hasReward = false)
ReturnReward calculateReward({
required DateTime? lastPlayTime,
required DateTime currentTime,
required int playerLevel,
}) {
// 마지막 플레이 시간이 없으면 보상 없음
if (lastPlayTime == null) {
debugPrint('[ReturnRewards] No lastPlayTime, no reward');
return const ReturnReward(hoursAway: 0, goldReward: 0, bonusGold: 0);
}
// 경과 시간 계산
final difference = currentTime.difference(lastPlayTime);
final hoursAway = difference.inHours;
// 최소 시간 미만이면 보상 없음
if (hoursAway < minHoursAway) {
debugPrint('[ReturnRewards] Only $hoursAway hours, need $minHoursAway');
return ReturnReward(hoursAway: hoursAway, goldReward: 0, bonusGold: 0);
}
// 최대 시간 초과 시 최대로 제한
final effectiveHours = hoursAway > maxHoursAway ? maxHoursAway : hoursAway;
// 골드 보상 계산 (레벨 보너스 포함)
final levelMultiplier = 1.0 + (playerLevel * goldPerLevelMultiplier);
final baseGold = (effectiveHours * goldPerHour * levelMultiplier).round();
// 보너스 골드 (광고 시청 시 100% 추가)
final bonusGold = baseGold;
debugPrint('[ReturnRewards] $hoursAway hours away, '
'base=$baseGold, bonus=$bonusGold, level=$playerLevel');
return ReturnReward(
hoursAway: hoursAway,
goldReward: baseGold,
bonusGold: bonusGold,
);
}
// ===========================================================================
// 보상 수령
// ===========================================================================
/// 기본 보상 수령 (광고 없이)
///
/// [reward] 복귀 보상 데이터
/// Returns: 수령한 골드 양
int claimBasicReward(ReturnReward reward) {
if (!reward.hasReward) return 0;
debugPrint('[ReturnRewards] Basic reward claimed: ${reward.goldReward}');
return reward.goldReward;
}
/// 보너스 보상 수령 (광고 시청 후)
///
/// 유료 유저: 무료 보너스
/// 무료 유저: 리워드 광고 시청 후 보너스
/// [reward] 복귀 보상 데이터
/// Returns: 수령한 보너스 골드 양 (광고 실패 시 0)
Future<int> claimBonusReward(ReturnReward reward) async {
if (!reward.hasReward || reward.bonusGold <= 0) return 0;
// 유료 유저는 무료 보너스
if (IAPService.instance.isAdRemovalPurchased) {
debugPrint('[ReturnRewards] Bonus claimed (paid user): ${reward.bonusGold}');
return reward.bonusGold;
}
// 무료 유저는 리워드 광고 필요
int bonus = 0;
final adResult = await AdService.instance.showRewardedAd(
adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고
onRewarded: () {
bonus = reward.bonusGold;
},
);
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint('[ReturnRewards] Bonus claimed (free user with ad): $bonus');
return bonus;
}
debugPrint('[ReturnRewards] Bonus claim failed: $adResult');
return 0;
}
// ===========================================================================
// 시간 포맷팅
// ===========================================================================
/// 복귀 시간을 표시용 문자열로 변환
String formatHoursAway(int hours) {
if (hours < 24) {
return '${hours}h';
}
final days = hours ~/ 24;
final remainingHours = hours % 24;
if (remainingHours == 0) {
return '${days}d';
}
return '${days}d ${remainingHours}h';
}
}

View File

@@ -133,6 +133,7 @@ class DeathInfo {
this.lostItemName,
this.lostItemSlot,
this.lostItemRarity,
this.lostItem,
this.lastCombatEvents = const [],
});
@@ -154,6 +155,9 @@ class DeathInfo {
/// 제물로 바친 아이템 희귀도 (null이면 없음)
final ItemRarity? lostItemRarity;
/// 상실한 장비 전체 정보 (광고 부활 시 복구용)
final EquipmentItem? lostItem;
/// 사망 시점 골드
final int goldAtDeath;
@@ -173,6 +177,7 @@ class DeathInfo {
String? lostItemName,
EquipmentSlot? lostItemSlot,
ItemRarity? lostItemRarity,
EquipmentItem? lostItem,
int? goldAtDeath,
int? levelAtDeath,
int? timestamp,
@@ -185,6 +190,7 @@ class DeathInfo {
lostItemName: lostItemName ?? this.lostItemName,
lostItemSlot: lostItemSlot ?? this.lostItemSlot,
lostItemRarity: lostItemRarity ?? this.lostItemRarity,
lostItem: lostItem ?? this.lostItem,
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
timestamp: timestamp ?? this.timestamp,

View File

@@ -0,0 +1,162 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
part 'monetization_state.freezed.dart';
part 'monetization_state.g.dart';
/// 수익화 시스템 상태
///
/// IAP 구매 여부, 광고 관련 버프, 복귀 보상 등을 관리
@freezed
class MonetizationState with _$MonetizationState {
const MonetizationState._();
const factory MonetizationState({
/// IAP 광고 제거 구매 여부
@Default(false) bool adRemovalPurchased,
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
@Default(5) int rollsRemaining,
/// 되돌리기 남은 횟수
@Default(1) int undoRemaining,
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? rollHistory,
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
int? autoReviveEndMs,
/// 5배속 버프 종료 시점 (elapsedMs 기준)
int? speedBoostEndMs,
/// 마지막 플레이 시각 (복귀 보상 계산용)
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? lastPlayTime,
/// 미개봉 보물 상자 개수
@Default(0) int pendingChests,
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
int? luckyCharmEndMs,
}) = _MonetizationState;
factory MonetizationState.fromJson(Map<String, dynamic> json) =>
_$MonetizationStateFromJson(json);
/// 기본 상태 생성 (신규 게임)
factory MonetizationState.initial({bool isPaidUser = false}) {
return MonetizationState(
adRemovalPurchased: isPaidUser,
rollsRemaining: 5,
undoRemaining: isPaidUser ? 3 : 1,
rollHistory: null,
pendingChests: 0,
);
}
// ===========================================================================
// 유틸리티 메서드
// ===========================================================================
/// 유료 사용자 여부
bool get isPaidUser => adRemovalPurchased;
/// 무료 사용자 여부
bool get isFreeUser => !adRemovalPurchased;
/// 자동부활 버프 활성 여부 (elapsedMs 기준)
bool isAutoReviveActive(int elapsedMs) {
if (autoReviveEndMs == null) return false;
return elapsedMs < autoReviveEndMs!;
}
/// 5배속 버프 활성 여부 (elapsedMs 기준)
/// 유료 사용자는 항상 활성
bool isSpeedBoostActive(int elapsedMs) {
if (isPaidUser) return true;
if (speedBoostEndMs == null) return false;
return elapsedMs < speedBoostEndMs!;
}
/// 행운의 부적 버프 활성 여부 (elapsedMs 기준)
bool isLuckyCharmActive(int elapsedMs) {
if (luckyCharmEndMs == null) return false;
return elapsedMs < luckyCharmEndMs!;
}
/// 굴리기 가능 여부
bool get canRoll => rollsRemaining > 0;
/// 되돌리기 가능 여부
bool canUndo(int historyLength) {
if (undoRemaining <= 0) return false;
if (rollHistory == null || rollHistory!.isEmpty) return false;
return historyLength > 0;
}
/// 실제 사용 가능한 되돌리기 횟수
int availableUndos(int historyLength) {
if (rollHistory == null) return 0;
final historyAvailable = rollHistory!.length;
return undoRemaining < historyAvailable ? undoRemaining : historyAvailable;
}
/// 최대 보물 상자 개수
int get maxChests => isPaidUser ? 10 : 5;
/// 보물 상자가 가득 찼는지 여부
bool get isChestsFull => pendingChests >= maxChests;
}
// =============================================================================
// JSON 변환 헬퍼
// =============================================================================
/// Stats 리스트 → JSON
List<Map<String, dynamic>>? _statsListToJson(List<Stats>? stats) {
if (stats == null) return null;
return stats
.map((s) => {
'str': s.str,
'con': s.con,
'dex': s.dex,
'int': s.intelligence,
'wis': s.wis,
'cha': s.cha,
'hpMax': s.hpMax,
'mpMax': s.mpMax,
})
.toList();
}
/// JSON → Stats 리스트
List<Stats>? _statsListFromJson(List<dynamic>? json) {
if (json == null) return null;
return json.map((e) {
final m = e as Map<String, dynamic>;
return Stats(
str: m['str'] as int? ?? 0,
con: m['con'] as int? ?? 0,
dex: m['dex'] as int? ?? 0,
intelligence: m['int'] as int? ?? 0,
wis: m['wis'] as int? ?? 0,
cha: m['cha'] as int? ?? 0,
hpMax: m['hpMax'] as int? ?? 0,
mpMax: m['mpMax'] as int? ?? 0,
);
}).toList();
}
/// DateTime → JSON (밀리초 타임스탬프)
int? _dateTimeToJson(DateTime? dateTime) {
return dateTime?.millisecondsSinceEpoch;
}
/// JSON → DateTime
DateTime? _dateTimeFromJson(int? timestamp) {
if (timestamp == null) return null;
return DateTime.fromMillisecondsSinceEpoch(timestamp);
}

View File

@@ -0,0 +1,444 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'monetization_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
MonetizationState _$MonetizationStateFromJson(Map<String, dynamic> json) {
return _MonetizationState.fromJson(json);
}
/// @nodoc
mixin _$MonetizationState {
/// IAP 광고 제거 구매 여부
bool get adRemovalPurchased => throw _privateConstructorUsedError;
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
int get rollsRemaining => throw _privateConstructorUsedError;
/// 되돌리기 남은 횟수
int get undoRemaining => throw _privateConstructorUsedError;
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? get rollHistory => throw _privateConstructorUsedError;
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
int? get autoReviveEndMs => throw _privateConstructorUsedError;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
int? get speedBoostEndMs => throw _privateConstructorUsedError;
/// 마지막 플레이 시각 (복귀 보상 계산용)
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? get lastPlayTime => throw _privateConstructorUsedError;
/// 미개봉 보물 상자 개수
int get pendingChests => throw _privateConstructorUsedError;
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
int? get luckyCharmEndMs => throw _privateConstructorUsedError;
/// Serializes this MonetizationState to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$MonetizationStateCopyWith<MonetizationState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $MonetizationStateCopyWith<$Res> {
factory $MonetizationStateCopyWith(
MonetizationState value,
$Res Function(MonetizationState) then,
) = _$MonetizationStateCopyWithImpl<$Res, MonetizationState>;
@useResult
$Res call({
bool adRemovalPurchased,
int rollsRemaining,
int undoRemaining,
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? rollHistory,
int? autoReviveEndMs,
int? speedBoostEndMs,
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? lastPlayTime,
int pendingChests,
int? luckyCharmEndMs,
});
}
/// @nodoc
class _$MonetizationStateCopyWithImpl<$Res, $Val extends MonetizationState>
implements $MonetizationStateCopyWith<$Res> {
_$MonetizationStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? adRemovalPurchased = null,
Object? rollsRemaining = null,
Object? undoRemaining = null,
Object? rollHistory = freezed,
Object? autoReviveEndMs = freezed,
Object? speedBoostEndMs = freezed,
Object? lastPlayTime = freezed,
Object? pendingChests = null,
Object? luckyCharmEndMs = freezed,
}) {
return _then(
_value.copyWith(
adRemovalPurchased: null == adRemovalPurchased
? _value.adRemovalPurchased
: adRemovalPurchased // ignore: cast_nullable_to_non_nullable
as bool,
rollsRemaining: null == rollsRemaining
? _value.rollsRemaining
: rollsRemaining // ignore: cast_nullable_to_non_nullable
as int,
undoRemaining: null == undoRemaining
? _value.undoRemaining
: undoRemaining // ignore: cast_nullable_to_non_nullable
as int,
rollHistory: freezed == rollHistory
? _value.rollHistory
: rollHistory // ignore: cast_nullable_to_non_nullable
as List<Stats>?,
autoReviveEndMs: freezed == autoReviveEndMs
? _value.autoReviveEndMs
: autoReviveEndMs // ignore: cast_nullable_to_non_nullable
as int?,
speedBoostEndMs: freezed == speedBoostEndMs
? _value.speedBoostEndMs
: speedBoostEndMs // ignore: cast_nullable_to_non_nullable
as int?,
lastPlayTime: freezed == lastPlayTime
? _value.lastPlayTime
: lastPlayTime // ignore: cast_nullable_to_non_nullable
as DateTime?,
pendingChests: null == pendingChests
? _value.pendingChests
: pendingChests // ignore: cast_nullable_to_non_nullable
as int,
luckyCharmEndMs: freezed == luckyCharmEndMs
? _value.luckyCharmEndMs
: luckyCharmEndMs // ignore: cast_nullable_to_non_nullable
as int?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$MonetizationStateImplCopyWith<$Res>
implements $MonetizationStateCopyWith<$Res> {
factory _$$MonetizationStateImplCopyWith(
_$MonetizationStateImpl value,
$Res Function(_$MonetizationStateImpl) then,
) = __$$MonetizationStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
bool adRemovalPurchased,
int rollsRemaining,
int undoRemaining,
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? rollHistory,
int? autoReviveEndMs,
int? speedBoostEndMs,
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? lastPlayTime,
int pendingChests,
int? luckyCharmEndMs,
});
}
/// @nodoc
class __$$MonetizationStateImplCopyWithImpl<$Res>
extends _$MonetizationStateCopyWithImpl<$Res, _$MonetizationStateImpl>
implements _$$MonetizationStateImplCopyWith<$Res> {
__$$MonetizationStateImplCopyWithImpl(
_$MonetizationStateImpl _value,
$Res Function(_$MonetizationStateImpl) _then,
) : super(_value, _then);
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? adRemovalPurchased = null,
Object? rollsRemaining = null,
Object? undoRemaining = null,
Object? rollHistory = freezed,
Object? autoReviveEndMs = freezed,
Object? speedBoostEndMs = freezed,
Object? lastPlayTime = freezed,
Object? pendingChests = null,
Object? luckyCharmEndMs = freezed,
}) {
return _then(
_$MonetizationStateImpl(
adRemovalPurchased: null == adRemovalPurchased
? _value.adRemovalPurchased
: adRemovalPurchased // ignore: cast_nullable_to_non_nullable
as bool,
rollsRemaining: null == rollsRemaining
? _value.rollsRemaining
: rollsRemaining // ignore: cast_nullable_to_non_nullable
as int,
undoRemaining: null == undoRemaining
? _value.undoRemaining
: undoRemaining // ignore: cast_nullable_to_non_nullable
as int,
rollHistory: freezed == rollHistory
? _value._rollHistory
: rollHistory // ignore: cast_nullable_to_non_nullable
as List<Stats>?,
autoReviveEndMs: freezed == autoReviveEndMs
? _value.autoReviveEndMs
: autoReviveEndMs // ignore: cast_nullable_to_non_nullable
as int?,
speedBoostEndMs: freezed == speedBoostEndMs
? _value.speedBoostEndMs
: speedBoostEndMs // ignore: cast_nullable_to_non_nullable
as int?,
lastPlayTime: freezed == lastPlayTime
? _value.lastPlayTime
: lastPlayTime // ignore: cast_nullable_to_non_nullable
as DateTime?,
pendingChests: null == pendingChests
? _value.pendingChests
: pendingChests // ignore: cast_nullable_to_non_nullable
as int,
luckyCharmEndMs: freezed == luckyCharmEndMs
? _value.luckyCharmEndMs
: luckyCharmEndMs // ignore: cast_nullable_to_non_nullable
as int?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$MonetizationStateImpl extends _MonetizationState {
const _$MonetizationStateImpl({
this.adRemovalPurchased = false,
this.rollsRemaining = 5,
this.undoRemaining = 1,
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
final List<Stats>? rollHistory,
this.autoReviveEndMs,
this.speedBoostEndMs,
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
this.lastPlayTime,
this.pendingChests = 0,
this.luckyCharmEndMs,
}) : _rollHistory = rollHistory,
super._();
factory _$MonetizationStateImpl.fromJson(Map<String, dynamic> json) =>
_$$MonetizationStateImplFromJson(json);
/// IAP 광고 제거 구매 여부
@override
@JsonKey()
final bool adRemovalPurchased;
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
@override
@JsonKey()
final int rollsRemaining;
/// 되돌리기 남은 횟수
@override
@JsonKey()
final int undoRemaining;
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
final List<Stats>? _rollHistory;
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
@override
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? get rollHistory {
final value = _rollHistory;
if (value == null) return null;
if (_rollHistory is EqualUnmodifiableListView) return _rollHistory;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
@override
final int? autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
@override
final int? speedBoostEndMs;
/// 마지막 플레이 시각 (복귀 보상 계산용)
@override
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
final DateTime? lastPlayTime;
/// 미개봉 보물 상자 개수
@override
@JsonKey()
final int pendingChests;
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
@override
final int? luckyCharmEndMs;
@override
String toString() {
return 'MonetizationState(adRemovalPurchased: $adRemovalPurchased, rollsRemaining: $rollsRemaining, undoRemaining: $undoRemaining, rollHistory: $rollHistory, autoReviveEndMs: $autoReviveEndMs, speedBoostEndMs: $speedBoostEndMs, lastPlayTime: $lastPlayTime, pendingChests: $pendingChests, luckyCharmEndMs: $luckyCharmEndMs)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$MonetizationStateImpl &&
(identical(other.adRemovalPurchased, adRemovalPurchased) ||
other.adRemovalPurchased == adRemovalPurchased) &&
(identical(other.rollsRemaining, rollsRemaining) ||
other.rollsRemaining == rollsRemaining) &&
(identical(other.undoRemaining, undoRemaining) ||
other.undoRemaining == undoRemaining) &&
const DeepCollectionEquality().equals(
other._rollHistory,
_rollHistory,
) &&
(identical(other.autoReviveEndMs, autoReviveEndMs) ||
other.autoReviveEndMs == autoReviveEndMs) &&
(identical(other.speedBoostEndMs, speedBoostEndMs) ||
other.speedBoostEndMs == speedBoostEndMs) &&
(identical(other.lastPlayTime, lastPlayTime) ||
other.lastPlayTime == lastPlayTime) &&
(identical(other.pendingChests, pendingChests) ||
other.pendingChests == pendingChests) &&
(identical(other.luckyCharmEndMs, luckyCharmEndMs) ||
other.luckyCharmEndMs == luckyCharmEndMs));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
adRemovalPurchased,
rollsRemaining,
undoRemaining,
const DeepCollectionEquality().hash(_rollHistory),
autoReviveEndMs,
speedBoostEndMs,
lastPlayTime,
pendingChests,
luckyCharmEndMs,
);
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$MonetizationStateImplCopyWith<_$MonetizationStateImpl> get copyWith =>
__$$MonetizationStateImplCopyWithImpl<_$MonetizationStateImpl>(
this,
_$identity,
);
@override
Map<String, dynamic> toJson() {
return _$$MonetizationStateImplToJson(this);
}
}
abstract class _MonetizationState extends MonetizationState {
const factory _MonetizationState({
final bool adRemovalPurchased,
final int rollsRemaining,
final int undoRemaining,
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
final List<Stats>? rollHistory,
final int? autoReviveEndMs,
final int? speedBoostEndMs,
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
final DateTime? lastPlayTime,
final int pendingChests,
final int? luckyCharmEndMs,
}) = _$MonetizationStateImpl;
const _MonetizationState._() : super._();
factory _MonetizationState.fromJson(Map<String, dynamic> json) =
_$MonetizationStateImpl.fromJson;
/// IAP 광고 제거 구매 여부
@override
bool get adRemovalPurchased;
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
@override
int get rollsRemaining;
/// 되돌리기 남은 횟수
@override
int get undoRemaining;
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
@override
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? get rollHistory;
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
@override
int? get autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
@override
int? get speedBoostEndMs;
/// 마지막 플레이 시각 (복귀 보상 계산용)
@override
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? get lastPlayTime;
/// 미개봉 보물 상자 개수
@override
int get pendingChests;
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
@override
int? get luckyCharmEndMs;
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$MonetizationStateImplCopyWith<_$MonetizationStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,35 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'monetization_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$MonetizationStateImpl _$$MonetizationStateImplFromJson(
Map<String, dynamic> json,
) => _$MonetizationStateImpl(
adRemovalPurchased: json['adRemovalPurchased'] as bool? ?? false,
rollsRemaining: (json['rollsRemaining'] as num?)?.toInt() ?? 5,
undoRemaining: (json['undoRemaining'] as num?)?.toInt() ?? 1,
rollHistory: _statsListFromJson(json['rollHistory'] as List?),
autoReviveEndMs: (json['autoReviveEndMs'] as num?)?.toInt(),
speedBoostEndMs: (json['speedBoostEndMs'] as num?)?.toInt(),
lastPlayTime: _dateTimeFromJson((json['lastPlayTime'] as num?)?.toInt()),
pendingChests: (json['pendingChests'] as num?)?.toInt() ?? 0,
luckyCharmEndMs: (json['luckyCharmEndMs'] as num?)?.toInt(),
);
Map<String, dynamic> _$$MonetizationStateImplToJson(
_$MonetizationStateImpl instance,
) => <String, dynamic>{
'adRemovalPurchased': instance.adRemovalPurchased,
'rollsRemaining': instance.rollsRemaining,
'undoRemaining': instance.undoRemaining,
'rollHistory': _statsListToJson(instance.rollHistory),
'autoReviveEndMs': instance.autoReviveEndMs,
'speedBoostEndMs': instance.speedBoostEndMs,
'lastPlayTime': _dateTimeToJson(instance.lastPlayTime),
'pendingChests': instance.pendingChests,
'luckyCharmEndMs': instance.luckyCharmEndMs,
};

View File

@@ -2,6 +2,7 @@ import 'dart:collection';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/monetization_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
@@ -9,7 +10,8 @@ import 'package:asciineverdie/src/core/model/game_state.dart';
/// 세이브 파일 버전
/// - v2: 장비 이름만 저장 (레거시)
/// - v3: 장비 전체 정보 저장 (level, rarity, stats 포함)
const int kSaveVersion = 3;
/// - v4: MonetizationState 추가, DeathInfo.lostItem 추가
const int kSaveVersion = 4;
class GameSave {
GameSave({
@@ -23,9 +25,14 @@ class GameSave {
required this.progress,
required this.queue,
this.cheatsEnabled = false,
this.monetization,
});
factory GameSave.fromState(GameState state, {bool cheatsEnabled = false}) {
factory GameSave.fromState(
GameState state, {
bool cheatsEnabled = false,
MonetizationState? monetization,
}) {
return GameSave(
version: kSaveVersion,
rngState: state.rng.state,
@@ -37,6 +44,7 @@ class GameSave {
progress: state.progress,
queue: state.queue,
cheatsEnabled: cheatsEnabled,
monetization: monetization,
);
}
@@ -51,6 +59,9 @@ class GameSave {
final QueueState queue;
final bool cheatsEnabled;
/// 수익화 시스템 상태 (v4+)
final MonetizationState? monetization;
Map<String, dynamic> toJson() {
return {
'version': version,
@@ -132,6 +143,7 @@ class GameSave {
},
)
.toList(),
if (monetization != null) 'monetization': monetization!.toJson(),
};
}
@@ -244,6 +256,9 @@ class GameSave {
}),
),
),
monetization: _monetizationFromJson(
json['monetization'] as Map<String, dynamic>?,
),
);
}
@@ -355,3 +370,12 @@ Equipment _equipmentFromJson(Map<String, dynamic> json, int version) {
bestIndex: json['bestIndex'] as int? ?? 0,
);
}
/// MonetizationState 역직렬화 (v4+ 마이그레이션)
///
/// - v3 이하: null (기본값 사용)
/// - v4 이상: 저장된 상태 로드
MonetizationState? _monetizationFromJson(Map<String, dynamic>? json) {
if (json == null) return null;
return MonetizationState.fromJson(json);
}

View File

@@ -1,4 +1,5 @@
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monetization_state.dart';
import 'package:asciineverdie/src/core/model/save_data.dart';
import 'package:asciineverdie/src/core/storage/save_repository.dart';
import 'package:asciineverdie/src/core/storage/save_service.dart'
@@ -13,23 +14,36 @@ class SaveManager {
/// Save current game state to disk. [fileName] may be absolute or relative.
/// Returns outcome with error on failure.
///
/// [monetization] 저장 시 lastPlayTime을 현재 시간으로 자동 업데이트
Future<SaveOutcome> saveState(
GameState state, {
String? fileName,
bool cheatsEnabled = false,
MonetizationState? monetization,
}) {
final save = GameSave.fromState(state, cheatsEnabled: cheatsEnabled);
// lastPlayTime을 현재 시간으로 업데이트
final updatedMonetization = (monetization ?? MonetizationState.initial())
.copyWith(lastPlayTime: DateTime.now());
final save = GameSave.fromState(
state,
cheatsEnabled: cheatsEnabled,
monetization: updatedMonetization,
);
return _repo.save(save, fileName ?? defaultFileName);
}
/// Load game state from disk. [fileName] may be absolute (e.g., file picker).
/// Returns outcome + optional state + cheatsEnabled flag.
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
/// Returns outcome + optional state + cheatsEnabled flag + monetization state.
Future<(SaveOutcome, GameState?, bool, MonetizationState?)> loadState({
String? fileName,
}) async {
final (outcome, save) = await _repo.load(fileName ?? defaultFileName);
if (!outcome.success || save == null) {
return (outcome, null, false);
return (outcome, null, false, null);
}
return (outcome, save.toState(), save.cheatsEnabled);
return (outcome, save.toState(), save.cheatsEnabled, save.monetization);
}
/// 저장 파일 목록 조회

View File

@@ -17,6 +17,7 @@ class FrontScreen extends StatefulWidget {
this.onLoadSave,
this.onHallOfFame,
this.onLocalArena,
this.onSettings,
this.hasSaveFile = false,
this.savedGamePreview,
this.hallOfFameCount = 0,
@@ -36,6 +37,9 @@ class FrontScreen extends StatefulWidget {
/// "Local Arena" 버튼 클릭 시 호출
final void Function(BuildContext context)? onLocalArena;
/// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드)
final void Function(BuildContext context)? onSettings;
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
final bool hasSaveFile;
@@ -147,6 +151,9 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
widget.hallOfFameCount >= 2
? () => widget.onLocalArena!(context)
: null,
onSettings: widget.onSettings != null
? () => widget.onSettings!(context)
: null,
savedGamePreview: widget.savedGamePreview,
hallOfFameCount: widget.hallOfFameCount,
),
@@ -249,6 +256,7 @@ class _ActionButtons extends StatelessWidget {
this.onLoadSave,
this.onHallOfFame,
this.onLocalArena,
this.onSettings,
this.savedGamePreview,
this.hallOfFameCount = 0,
});
@@ -257,6 +265,7 @@ class _ActionButtons extends StatelessWidget {
final VoidCallback? onLoadSave;
final VoidCallback? onHallOfFame;
final VoidCallback? onLocalArena;
final VoidCallback? onSettings;
final SavedGamePreview? savedGamePreview;
final int hallOfFameCount;
@@ -306,6 +315,14 @@ class _ActionButtons extends StatelessWidget {
onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
isPrimary: false,
),
// 설정
const SizedBox(height: 12),
RetroTextButton(
text: game_l10n.uiSettings,
icon: Icons.settings,
onPressed: onSettings,
isPrimary: false,
),
],
),
);
@@ -399,3 +416,4 @@ class _RetroTag extends StatelessWidget {
);
}
}

View File

@@ -6,6 +6,8 @@ import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/engine/return_rewards_service.dart';
import 'package:asciineverdie/data/story_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
@@ -28,6 +30,7 @@ import 'package:asciineverdie/src/features/game/widgets/equipment_stats_panel.da
import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/task_progress_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/return_rewards_dialog.dart';
import 'package:asciineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart';
@@ -246,6 +249,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 오디오 볼륨 초기화
_audioController.initVolumes();
// Phase 7: 복귀 보상 콜백 설정
widget.controller.onReturnRewardAvailable = _showReturnRewardsDialog;
}
@override
@@ -262,6 +268,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_storyService.dispose();
WidgetsBinding.instance.removeObserver(this);
widget.controller.removeListener(_onControllerChanged);
widget.controller.onReturnRewardAvailable = null; // Phase 7: 콜백 정리
super.dispose();
}
@@ -399,6 +406,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
}
/// 복귀 보상 다이얼로그 표시 (Phase 7)
void _showReturnRewardsDialog(ReturnReward reward) {
// 잠시 후 다이얼로그 표시 (게임 시작 후)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ReturnRewardsDialog.show(
context,
reward: reward,
onClaim: (totalGold) {
widget.controller.applyReturnReward(totalGold);
},
);
});
}
/// 통계 다이얼로그 표시
void _showStatisticsDialog(BuildContext context) {
StatisticsDialog.show(
@@ -486,6 +508,47 @@ class _GamePlayScreenState extends State<GamePlayScreen>
});
}
/// 광고 부활 핸들러 (HP 100% + 아이템 복구 + 10분 자동부활)
Future<void> _handleAdRevive() async {
// 1. 부활 애니메이션 먼저 설정 (DeathOverlay 사라지기 전에)
setState(() {
_specialAnimation = AsciiAnimationType.resurrection;
});
// 2. 광고 부활 처리 (HP 100%, 아이템 복구, 10분 자동부활 버프)
await widget.controller.adRevive();
// 3. 애니메이션 종료 후 게임 재개
final duration = getSpecialAnimationDuration(
AsciiAnimationType.resurrection,
);
Future.delayed(Duration(milliseconds: duration), () async {
if (mounted) {
await widget.controller.resumeAfterResurrection();
if (mounted) {
setState(() {
_specialAnimation = null;
});
}
}
});
}
/// 속도 부스트 활성화 핸들러 (Phase 6)
Future<void> _handleSpeedBoost() async {
final activated = await widget.controller.activateSpeedBoost();
if (activated && mounted) {
_notificationService.show(
GameNotification(
type: NotificationType.info,
title: game_l10n.speedBoostActive,
duration: const Duration(seconds: 2),
),
);
setState(() {});
}
}
@override
Widget build(BuildContext context) {
final state = widget.controller.state;
@@ -603,6 +666,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
navigator.popUntil((route) => route.isFirst);
}
},
// 수익화 버프 (자동부활, 5배속)
autoReviveEndMs: widget.controller.monetization.autoReviveEndMs,
speedBoostEndMs: widget.controller.monetization.speedBoostEndMs,
isPaidUser: widget.controller.monetization.isPaidUser,
onSpeedBoostActivate: _handleSpeedBoost,
),
// 사망 오버레이
if (state.isDead && state.deathInfo != null)
@@ -610,12 +678,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
deathInfo: state.deathInfo!,
traits: state.traits,
onResurrect: _handleResurrect,
isAutoResurrectEnabled: widget.controller.autoResurrect,
onToggleAutoResurrect: () {
widget.controller.setAutoResurrect(
!widget.controller.autoResurrect,
);
},
onAdRevive: _handleAdRevive,
isPaidUser: IAPService.instance.isAdRemovalPurchased,
),
// 승리 오버레이 (게임 클리어)
if (widget.controller.isComplete)
@@ -759,18 +823,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
],
),
// Phase 4: 사망 오버레이 (Death Overlay)
// 사망 오버레이
if (state.isDead && state.deathInfo != null)
DeathOverlay(
deathInfo: state.deathInfo!,
traits: state.traits,
onResurrect: _handleResurrect,
isAutoResurrectEnabled: widget.controller.autoResurrect,
onToggleAutoResurrect: () {
widget.controller.setAutoResurrect(
!widget.controller.autoResurrect,
);
},
onAdRevive: _handleAdRevive,
isPaidUser: IAPService.instance.isAdRemovalPurchased,
),
// 승리 오버레이 (게임 클리어)
if (widget.controller.isComplete)

View File

@@ -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');
}
}

View File

@@ -52,6 +52,10 @@ class MobileCarouselLayout extends StatefulWidget {
this.onCheatQuest,
this.onCheatPlot,
this.onCreateTestCharacter,
this.autoReviveEndMs,
this.speedBoostEndMs,
this.isPaidUser = false,
this.onSpeedBoostActivate,
});
final GameState state;
@@ -102,6 +106,18 @@ class MobileCarouselLayout extends StatefulWidget {
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
final Future<void> Function()? onCreateTestCharacter;
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
final int? autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
final int? speedBoostEndMs;
/// 유료 유저 여부
final bool isPaidUser;
/// 5배속 버프 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate;
@override
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
}
@@ -456,27 +472,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
ListTile(
leading: const Icon(Icons.speed),
title: Text(l10n.menuSpeed),
trailing: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${widget.speedMultiplier}x',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
onTap: () {
widget.onSpeedCycle();
Navigator.pop(context);
},
trailing: _buildSpeedSelector(context),
),
const Divider(),
@@ -735,6 +731,10 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId,
weaponRarity: state.equipment.weaponItem.rarity,
autoReviveEndMs: widget.autoReviveEndMs,
speedBoostEndMs: widget.speedBoostEndMs,
isPaidUser: widget.isPaidUser,
onSpeedBoostActivate: widget.onSpeedBoostActivate,
),
// 중앙: 캐로셀 (PageView)
@@ -794,4 +794,135 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
),
);
}
/// 속도 선택기 빌드 (옵션 메뉴용)
///
/// - 1x, 2x: 무료 사이클
/// - ▶5x: 광고 시청 후 버프 (또는 버프 활성 시)
/// - 20x: 디버그 모드 전용
Widget _buildSpeedSelector(BuildContext context) {
final currentElapsedMs = widget.state.skillSystem.elapsedMs;
final speedBoostEndMs = widget.speedBoostEndMs ?? 0;
final isSpeedBoostActive =
speedBoostEndMs > currentElapsedMs || widget.isPaidUser;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// 1x 버튼
_buildSpeedChip(
context,
speed: 1,
isSelected: widget.speedMultiplier == 1 && !isSpeedBoostActive,
onTap: () {
widget.onSpeedCycle();
Navigator.pop(context);
},
),
const SizedBox(width: 4),
// 2x 버튼
_buildSpeedChip(
context,
speed: 2,
isSelected: widget.speedMultiplier == 2 && !isSpeedBoostActive,
onTap: () {
widget.onSpeedCycle();
Navigator.pop(context);
},
),
const SizedBox(width: 4),
// 5x 버튼 (광고 또는 버프 활성)
_buildSpeedChip(
context,
speed: 5,
isSelected: isSpeedBoostActive,
isAdBased: !isSpeedBoostActive && !widget.isPaidUser,
onTap: () {
if (!isSpeedBoostActive) {
widget.onSpeedBoostActivate?.call();
}
Navigator.pop(context);
},
),
// 20x 버튼 (디버그 전용)
if (widget.cheatsEnabled) ...[
const SizedBox(width: 4),
_buildSpeedChip(
context,
speed: 20,
isSelected: widget.speedMultiplier == 20,
isDebug: true,
onTap: () {
widget.onSpeedCycle();
Navigator.pop(context);
},
),
],
],
);
}
/// 속도 칩 빌드
Widget _buildSpeedChip(
BuildContext context, {
required int speed,
required bool isSelected,
required VoidCallback onTap,
bool isAdBased = false,
bool isDebug = false,
}) {
final Color bgColor;
final Color textColor;
if (isSelected) {
bgColor = isDebug
? Colors.red
: speed == 5
? Colors.orange
: Theme.of(context).colorScheme.primary;
textColor = Colors.white;
} else {
bgColor = Theme.of(context).colorScheme.surfaceContainerHighest;
textColor = isAdBased
? Colors.orange
: isDebug
? Colors.red.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface;
}
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
border: isAdBased && !isSelected
? Border.all(color: Colors.orange)
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isAdBased && !isSelected)
const Padding(
padding: EdgeInsets.only(right: 2),
child: Text(
'',
style: TextStyle(fontSize: 8, color: Colors.orange),
),
),
Text(
'${speed}x',
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: textColor,
),
),
],
),
),
);
}
}

View File

@@ -8,17 +8,19 @@ import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 사망 오버레이 위젯 (Phase 4)
/// 사망 오버레이 위젯
///
/// 플레이어 사망 시 표시되는 전체 화면 오버레이
/// - 무료 부활: HP 50%, 아이템 희생
/// - 광고 부활: HP 100%, 아이템 복구, 10분 자동부활 버프
class DeathOverlay extends StatelessWidget {
const DeathOverlay({
super.key,
required this.deathInfo,
required this.traits,
required this.onResurrect,
this.isAutoResurrectEnabled = false,
this.onToggleAutoResurrect,
this.onAdRevive,
this.isPaidUser = false,
});
/// 사망 정보
@@ -27,14 +29,15 @@ class DeathOverlay extends StatelessWidget {
/// 캐릭터 특성 (이름, 직업 등)
final Traits traits;
/// 부활 버튼 콜백
/// 무료 부활 버튼 콜백 (HP 50%, 아이템 희생)
final VoidCallback onResurrect;
/// 자동 부활 활성화 여부
final bool isAutoResurrectEnabled;
/// 광고 부활 버튼 콜백 (HP 100% + 아이템 복구 + 10분 자동부활)
/// null이면 광고 부활 버튼 숨김
final VoidCallback? onAdRevive;
/// 자동 부활 토글 콜백
final VoidCallback? onToggleAutoResurrect;
/// 유료 유저 여부 (광고 아이콘 표시용)
final bool isPaidUser;
@override
Widget build(BuildContext context) {
@@ -135,13 +138,13 @@ class DeathOverlay extends StatelessWidget {
const SizedBox(height: 24),
// 부활 버튼
// 일반 부활 버튼 (HP 50%, 아이템 희생)
_buildResurrectButton(context),
// 자동 부활 버튼
if (onToggleAutoResurrect != null) ...[
// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
if (onAdRevive != null) ...[
const SizedBox(height: 12),
_buildAutoResurrectButton(context),
_buildAdReviveButton(context),
],
],
),
@@ -464,77 +467,149 @@ class DeathOverlay extends StatelessWidget {
);
}
/// 자동 부활 토글 버튼
Widget _buildAutoResurrectButton(BuildContext context) {
final mpColor = RetroColors.mpOf(context);
final mpDark = RetroColors.mpDarkOf(context);
/// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
Widget _buildAdReviveButton(BuildContext context) {
final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context);
final muted = RetroColors.textMutedOf(context);
// 활성화 상태에 따른 색상
final buttonColor = isAutoResurrectEnabled ? mpColor : muted;
final buttonDark = isAutoResurrectEnabled
? mpDark
: muted.withValues(alpha: 0.5);
final hasLostItem = deathInfo.lostItemName != null;
final itemRarityColor = _getRarityColor(deathInfo.lostItemRarity);
return GestureDetector(
onTap: onToggleAutoResurrect,
onTap: onAdRevive,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
color: buttonColor.withValues(alpha: 0.15),
color: gold.withValues(alpha: 0.2),
border: Border(
top: BorderSide(color: buttonColor, width: 2),
left: BorderSide(color: buttonColor, width: 2),
bottom: BorderSide(
color: buttonDark.withValues(alpha: 0.8),
width: 2,
),
right: BorderSide(
color: buttonDark.withValues(alpha: 0.8),
width: 2,
),
top: BorderSide(color: gold, width: 3),
left: BorderSide(color: gold, width: 3),
bottom: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
child: Column(
children: [
Text(
isAutoResurrectEnabled ? '' : '',
style: TextStyle(
fontSize: 18,
color: buttonColor,
fontWeight: FontWeight.bold,
),
// 메인 버튼 텍스트
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'',
style: TextStyle(fontSize: 20, color: gold),
),
const SizedBox(width: 8),
Text(
l10n.deathAdRevive.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
letterSpacing: 1,
),
),
// 광고 뱃지 (무료 유저만)
if (!isPaidUser) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'▶ AD',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: Colors.white,
),
),
),
],
],
),
const SizedBox(width: 8),
Text(
l10n.deathAutoResurrect.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: buttonColor,
letterSpacing: 1,
),
const SizedBox(height: 8),
// 혜택 목록
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// HP 100% 회복
_buildBenefitRow(
context,
icon: '',
text: l10n.deathAdReviveHp,
color: RetroColors.hpOf(context),
),
const SizedBox(height: 4),
// 아이템 복구 (잃은 아이템이 있을 때만)
if (hasLostItem) ...[
_buildBenefitRow(
context,
icon: '🔄',
text: '${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}',
color: itemRarityColor,
),
const SizedBox(height: 4),
],
// 10분 자동부활
_buildBenefitRow(
context,
icon: '',
text: l10n.deathAdReviveAuto,
color: RetroColors.mpOf(context),
),
],
),
if (isAutoResurrectEnabled) ...[
const SizedBox(width: 6),
const SizedBox(height: 6),
// 유료 유저 설명
if (isPaidUser)
Text(
'ON',
l10n.deathAdRevivePaidDesc,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: mpColor,
fontWeight: FontWeight.bold,
fontSize: 9,
color: muted,
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
/// 혜택 항목 행
Widget _buildBenefitRow(
BuildContext context, {
required String icon,
required String text,
required Color color,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: TextStyle(fontSize: 14, color: color)),
const SizedBox(width: 6),
Flexible(
child: Text(
text,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: color,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
/// 사망 직전 전투 로그 표시
Widget _buildCombatLog(BuildContext context) {
final events = deathInfo.lastCombatEvents;

View File

@@ -37,6 +37,10 @@ class EnhancedAnimationPanel extends StatefulWidget {
this.latestCombatEvent,
this.raceId,
this.weaponRarity,
this.autoReviveEndMs,
this.speedBoostEndMs,
this.isPaidUser = false,
this.onSpeedBoostActivate,
});
final ProgressState progress;
@@ -65,6 +69,18 @@ class EnhancedAnimationPanel extends StatefulWidget {
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
final ItemRarity? weaponRarity;
/// 자동부활 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
final int? autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
final int? speedBoostEndMs;
/// 유료 유저 여부 (5배속 항상 활성)
final bool isPaidUser;
/// 5배속 버프 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate;
@override
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
}
@@ -190,6 +206,22 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
int? get _currentMonsterHpMax =>
widget.progress.currentCombat?.monsterStats.hpMax;
/// 자동부활 버프 남은 시간 (ms)
int get _autoReviveRemainingMs {
final endMs = widget.autoReviveEndMs;
if (endMs == null) return 0;
final remaining = endMs - widget.skillSystem.elapsedMs;
return remaining > 0 ? remaining : 0;
}
/// 5배속 버프 남은 시간 (ms)
int get _speedBoostRemainingMs {
final endMs = widget.speedBoostEndMs;
if (endMs == null) return 0;
final remaining = endMs - widget.skillSystem.elapsedMs;
return remaining > 0 ? remaining : 0;
}
@override
void dispose() {
_hpFlashController.dispose();
@@ -218,62 +250,94 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ASCII 애니메이션 (기존 높이 120 유지)
// ASCII 애니메이션 (기존 높이 120 유지) + 버프 오버레이
SizedBox(
height: 120,
child: AsciiAnimationCard(
taskType: widget.progress.currentTask.type,
monsterBaseName: widget.progress.currentTask.monsterBaseName,
specialAnimation: widget.specialAnimation,
weaponName: widget.weaponName,
shieldName: widget.shieldName,
characterLevel: widget.characterLevel,
monsterLevel: widget.monsterLevel,
monsterGrade: widget.monsterGrade,
monsterSize: widget.monsterSize,
isPaused: widget.isPaused,
isInCombat: isInCombat,
monsterDied: _monsterDied,
latestCombatEvent: widget.latestCombatEvent,
raceId: widget.raceId,
weaponRarity: widget.weaponRarity,
child: Stack(
children: [
// ASCII 애니메이션
AsciiAnimationCard(
taskType: widget.progress.currentTask.type,
monsterBaseName: widget.progress.currentTask.monsterBaseName,
specialAnimation: widget.specialAnimation,
weaponName: widget.weaponName,
shieldName: widget.shieldName,
characterLevel: widget.characterLevel,
monsterLevel: widget.monsterLevel,
monsterGrade: widget.monsterGrade,
monsterSize: widget.monsterSize,
isPaused: widget.isPaused,
isInCombat: isInCombat,
monsterDied: _monsterDied,
latestCombatEvent: widget.latestCombatEvent,
raceId: widget.raceId,
weaponRarity: widget.weaponRarity,
),
// 좌상단: 자동부활 버프
if (_autoReviveRemainingMs > 0)
Positioned(
left: 4,
top: 4,
child: _buildBuffChip(
icon: '',
remainingMs: _autoReviveRemainingMs,
color: Colors.green,
),
),
// 우상단: 5배속 버프
if (_speedBoostRemainingMs > 0 || widget.isPaidUser)
Positioned(
right: 4,
top: 4,
child: _buildBuffChip(
icon: '',
label: '5x',
remainingMs: widget.isPaidUser ? -1 : _speedBoostRemainingMs,
color: Colors.orange,
isPermanent: widget.isPaidUser,
),
),
],
),
),
const SizedBox(height: 8),
// 상태 바 영역: HP/MP + 버프 아이콘 + 몬스터 HP
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 좌측: HP/MP 바
Expanded(
flex: 3,
child: Column(
children: [
_buildCompactHpBar(),
const SizedBox(height: 4),
_buildCompactMpBar(),
],
// 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%)
SizedBox(
height: 48,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 좌측: HP/MP 바 (40%)
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(child: _buildCompactHpBar()),
const SizedBox(height: 4),
Expanded(child: _buildCompactMpBar()),
],
),
),
),
const SizedBox(width: 8),
// 중앙: 컨트롤 버튼 (20%)
Expanded(
flex: 1,
child: _buildControlButtons(),
),
// 중앙: 활성 버프 아이콘 (최대 3개)
_buildBuffIcons(),
const SizedBox(width: 8),
// 우측: 몬스터 HP (전투 중) 또는 컨트롤 버튼
Expanded(
flex: 2,
child: switch ((shouldShowMonsterHp, combat)) {
(true, final c?) => _buildMonsterHpBar(c),
_ => _buildControlButtons(),
},
),
],
// 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%)
Expanded(
flex: 2,
child: switch ((shouldShowMonsterHp, combat)) {
(true, final c?) => _buildMonsterHpBar(c),
_ => const SizedBox.shrink(),
},
),
],
),
),
const SizedBox(height: 6),
@@ -298,7 +362,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
children: [
// HP 바
Container(
height: 20,
decoration: BoxDecoration(
color: isLow
? Colors.red.withValues(alpha: 0.2)
@@ -330,13 +393,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3),
),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.red.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation(
isLow ? Colors.red : Colors.red.shade600,
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.red.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation(
isLow ? Colors.red : Colors.red.shade600,
),
),
minHeight: 20,
),
),
// 숫자 오버레이 (바 중앙)
@@ -402,7 +466,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
clipBehavior: Clip.none,
children: [
Container(
height: 20,
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(3),
@@ -431,13 +494,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3),
),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.blue.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation(
Colors.blue.shade600,
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.blue.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation(
Colors.blue.shade600,
),
),
minHeight: 20,
),
),
// 숫자 오버레이 (바 중앙)
@@ -491,60 +555,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
);
}
/// 활성 버프 아이콘 (최대 3개)
///
/// Wrap 위젯을 사용하여 공간 부족 시 자동 줄바꿈 처리
Widget _buildBuffIcons() {
final buffs = widget.skillSystem.activeBuffs;
final currentMs = widget.skillSystem.elapsedMs;
if (buffs.isEmpty) {
return const SizedBox(width: 60);
}
// 최대 3개만 표시
final displayBuffs = buffs.take(3).toList();
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 72, minWidth: 60),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 2,
runSpacing: 2,
children: displayBuffs.map((buff) {
final remainingMs = buff.remainingDuration(currentMs);
final progress = remainingMs / buff.effect.durationMs;
final isExpiring = remainingMs < 3000;
return Stack(
alignment: Alignment.center,
children: [
// 진행률 원형 표시
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
value: progress.clamp(0.0, 1.0),
strokeWidth: 2,
backgroundColor: Colors.grey.shade700,
valueColor: AlwaysStoppedAnimation(
isExpiring ? Colors.orange : Colors.lightBlue,
),
),
),
// 버프 아이콘
Icon(
Icons.trending_up,
size: 10,
color: isExpiring ? Colors.orange : Colors.lightBlue,
),
],
);
}).toList(),
),
);
}
/// 몬스터 HP 바 (전투 중)
/// - HP바 중앙에 HP% 오버레이
/// - 하단에 레벨.이름 표시
@@ -562,7 +572,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
clipBehavior: Clip.none,
children: [
Container(
height: 52,
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
@@ -572,52 +581,54 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
mainAxisAlignment: MainAxisAlignment.center,
children: [
// HP 바 (HP% 중앙 오버레이)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Stack(
alignment: Alignment.center,
children: [
// HP 바
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(
alpha: 0.2,
),
valueColor: const AlwaysStoppedAnimation(
Colors.orange,
),
minHeight: 16,
),
),
// HP% 중앙 오버레이
Text(
'${(ratio * 100).toInt()}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
child: Stack(
alignment: Alignment.center,
children: [
// HP 바
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(
alpha: 0.2,
),
valueColor: const AlwaysStoppedAnimation(
Colors.orange,
),
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
),
],
// HP% 중앙 오버레이
Text(
'${(ratio * 100).toInt()}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
),
),
const SizedBox(height: 2),
// 레벨.이름 표시
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
child: Text(
'Lv.$monsterLevel $monsterName',
style: const TextStyle(
fontSize: 12,
fontSize: 11,
color: Colors.orange,
fontWeight: FontWeight.bold,
),
@@ -662,63 +673,91 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
);
}
/// 컨트롤 버튼 (비전투 시)
/// 컨트롤 버튼 (중앙 영역)
Widget _buildControlButtons() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 상단: 속도 버튼 (1x ↔ 2x)
_buildCompactSpeedButton(),
const SizedBox(height: 2),
// 하단: 5x 광고 버튼 (2x일 때만 표시)
_buildAdSpeedButton(),
],
);
}
/// 컴팩트 속도 버튼 (1x ↔ 2x 사이클)
Widget _buildCompactSpeedButton() {
final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser;
return SizedBox(
height: 40,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 일시정지 버튼
SizedBox(
width: 40,
height: 36,
child: OutlinedButton(
onPressed: widget.onPauseToggle,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
side: BorderSide(
color: widget.isPaused
? Colors.orange.withValues(alpha: 0.7)
: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Icon(
widget.isPaused ? Icons.play_arrow : Icons.pause,
size: 18,
color: widget.isPaused ? Colors.orange : null,
width: 32,
height: 22,
child: OutlinedButton(
onPressed: widget.onSpeedCycle,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
side: BorderSide(
color: isSpeedBoostActive
? Colors.orange
: widget.speedMultiplier > 1
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Text(
isSpeedBoostActive ? '5x' : '${widget.speedMultiplier}x',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isSpeedBoostActive
? Colors.orange
: widget.speedMultiplier > 1
? Theme.of(context).colorScheme.primary
: null,
),
),
),
);
}
/// 5x 광고 버튼 (2x일 때만 표시)
Widget _buildAdSpeedButton() {
final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser;
// 2x이고 5배속 버프 비활성이고 무료유저일 때만 표시
final showAdButton =
widget.speedMultiplier == 2 && !isSpeedBoostActive && !widget.isPaidUser;
if (!showAdButton) {
return const SizedBox(height: 22);
}
return SizedBox(
height: 22,
child: OutlinedButton(
onPressed: widget.onSpeedBoostActivate,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
side: const BorderSide(color: Colors.orange),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('', style: TextStyle(fontSize: 8, color: Colors.orange)),
SizedBox(width: 2),
Text(
'5x',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
),
const SizedBox(width: 4),
// 속도 버튼
SizedBox(
width: 44,
height: 36,
child: OutlinedButton(
onPressed: widget.onSpeedCycle,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
child: Text(
'${widget.speedMultiplier}x',
style: TextStyle(
fontSize: 12,
fontWeight: widget.speedMultiplier > 1
? FontWeight.bold
: FontWeight.normal,
color: widget.speedMultiplier > 1
? Theme.of(context).colorScheme.primary
: null,
),
),
),
),
],
],
),
),
);
}
@@ -796,4 +835,64 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
],
);
}
/// 버프 칩 위젯 (좌상단/우상단 오버레이용)
Widget _buildBuffChip({
required String icon,
required int remainingMs,
required Color color,
String? label,
bool isPermanent = false,
}) {
// 남은 시간 포맷 (분:초)
String timeText;
if (isPermanent) {
timeText = '';
} else {
final seconds = (remainingMs / 1000).ceil();
final min = seconds ~/ 60;
final sec = seconds % 60;
timeText = '$min:${sec.toString().padLeft(2, '0')}';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withValues(alpha: 0.7), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
icon,
style: TextStyle(fontSize: 12, color: color),
),
if (label != null) ...[
const SizedBox(width: 2),
Text(
label,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 10,
color: color,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(width: 3),
Text(
timeText,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 10,
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,386 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/engine/return_rewards_service.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 복귀 보상 다이얼로그 (Phase 7)
///
/// 게임 복귀 시 보상을 표시하는 다이얼로그
class ReturnRewardsDialog extends StatefulWidget {
const ReturnRewardsDialog({
super.key,
required this.reward,
required this.onClaim,
});
/// 복귀 보상 데이터
final ReturnReward reward;
/// 보상 수령 콜백 (totalGold)
final void Function(int totalGold) onClaim;
/// 다이얼로그 표시
static Future<void> show(
BuildContext context, {
required ReturnReward reward,
required void Function(int totalGold) onClaim,
}) async {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => ReturnRewardsDialog(
reward: reward,
onClaim: onClaim,
),
);
}
@override
State<ReturnRewardsDialog> createState() => _ReturnRewardsDialogState();
}
class _ReturnRewardsDialogState extends State<ReturnRewardsDialog> {
bool _basicClaimed = false;
bool _bonusClaimed = false;
bool _isClaimingBonus = false;
int _totalClaimed = 0;
final _rewardsService = ReturnRewardsService.instance;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context);
final panelBg = RetroColors.panelBgOf(context);
final borderColor = RetroColors.borderOf(context);
final expColor = RetroColors.expOf(context);
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: panelBg,
border: Border(
top: BorderSide(color: gold, width: 4),
left: BorderSide(color: gold, width: 4),
bottom: BorderSide(color: borderColor, width: 4),
right: BorderSide(color: borderColor, width: 4),
),
boxShadow: [
BoxShadow(
color: gold.withValues(alpha: 0.4),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 헤더
_buildHeader(context, gold),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 떠나있던 시간
Text(
l10n.returnRewardHoursAway(
_rewardsService.formatHoursAway(widget.reward.hoursAway),
),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: gold.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// 기본 보상
_buildRewardSection(
context,
title: l10n.returnRewardBasic,
gold: widget.reward.goldReward,
color: gold,
colorDark: goldDark,
claimed: _basicClaimed,
onClaim: _claimBasic,
buttonText: l10n.returnRewardClaim,
),
const SizedBox(height: 16),
// 보너스 보상
_buildRewardSection(
context,
title: l10n.returnRewardBonus,
gold: widget.reward.bonusGold,
color: expColor,
colorDark: expColor.withValues(alpha: 0.6),
claimed: _bonusClaimed,
onClaim: _claimBonus,
buttonText: l10n.returnRewardClaimBonus,
showAdIcon: !isPaidUser,
isLoading: _isClaimingBonus,
enabled: _basicClaimed && !_bonusClaimed,
),
const SizedBox(height: 20),
// 완료/건너뛰기 버튼
_buildBottomButton(context),
],
),
),
],
),
),
);
}
Widget _buildHeader(BuildContext context, Color gold) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: gold.withValues(alpha: 0.3),
border: Border(bottom: BorderSide(color: gold, width: 2)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎁', style: TextStyle(fontSize: 20, color: gold)),
const SizedBox(width: 8),
Text(
l10n.returnRewardTitle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
letterSpacing: 1,
),
),
const SizedBox(width: 8),
Text('🎁', style: TextStyle(fontSize: 20, color: gold)),
],
),
);
}
Widget _buildRewardSection(
BuildContext context, {
required String title,
required int gold,
required Color color,
required Color colorDark,
required bool claimed,
required VoidCallback onClaim,
required String buttonText,
bool showAdIcon = false,
bool isLoading = false,
bool enabled = true,
}) {
final muted = RetroColors.textMutedOf(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
border: Border.all(color: color.withValues(alpha: 0.5), width: 2),
),
child: Column(
children: [
// 제목
Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: color,
),
),
const SizedBox(height: 8),
// 골드 표시
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('💰', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
l10n.returnRewardGold(gold),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: claimed ? muted : color,
decoration: claimed ? TextDecoration.lineThrough : null,
),
),
if (claimed) ...[
const SizedBox(width: 8),
Text(
'',
style: TextStyle(
fontSize: 18,
color: RetroColors.expOf(context),
fontWeight: FontWeight.bold,
),
),
],
],
),
if (!claimed) ...[
const SizedBox(height: 12),
// 수령 버튼
GestureDetector(
onTap: enabled && !isLoading ? onClaim : null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: enabled
? color.withValues(alpha: 0.3)
: muted.withValues(alpha: 0.2),
border: Border.all(
color: enabled ? color : muted,
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading) ...[
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: color,
),
),
const SizedBox(width: 8),
],
Text(
buttonText,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: enabled ? color : muted,
),
),
if (showAdIcon && !isLoading) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'AD',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: enabled ? Colors.white : muted,
),
),
),
],
],
),
),
),
],
],
),
);
}
Widget _buildBottomButton(BuildContext context) {
final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context);
final muted = RetroColors.textMutedOf(context);
final canComplete = _basicClaimed;
final buttonColor = canComplete ? gold : muted;
final buttonDark = canComplete ? goldDark : muted.withValues(alpha: 0.5);
return GestureDetector(
onTap: canComplete ? _complete : _skip,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: buttonColor.withValues(alpha: 0.2),
border: Border(
top: BorderSide(color: buttonColor, width: 2),
left: BorderSide(color: buttonColor, width: 2),
bottom: BorderSide(color: buttonDark, width: 2),
right: BorderSide(color: buttonDark, width: 2),
),
),
child: Text(
canComplete ? 'OK' : l10n.returnRewardSkip,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: buttonColor,
),
textAlign: TextAlign.center,
),
),
);
}
void _claimBasic() {
if (_basicClaimed) return;
final claimed = _rewardsService.claimBasicReward(widget.reward);
setState(() {
_basicClaimed = true;
_totalClaimed += claimed;
});
}
Future<void> _claimBonus() async {
if (_bonusClaimed || _isClaimingBonus) return;
setState(() {
_isClaimingBonus = true;
});
final bonus = await _rewardsService.claimBonusReward(widget.reward);
if (mounted) {
setState(() {
_isClaimingBonus = false;
if (bonus > 0) {
_bonusClaimed = true;
_totalClaimed += bonus;
}
});
}
}
void _complete() {
widget.onClaim(_totalClaimed);
Navigator.of(context).pop();
}
void _skip() {
widget.onClaim(0);
Navigator.of(context).pop();
}
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 속도 부스트 버튼 위젯 (Phase 6)
///
/// 게임 화면에 표시되는 속도 부스트 활성화 버튼
class SpeedBoostButton extends StatelessWidget {
const SpeedBoostButton({
super.key,
required this.isActive,
required this.remainingSeconds,
required this.onActivate,
this.boostMultiplier = 10,
});
/// 부스트 활성화 여부
final bool isActive;
/// 남은 시간 (초)
final int remainingSeconds;
/// 부스트 배율
final int boostMultiplier;
/// 활성화 콜백
final VoidCallback onActivate;
@override
Widget build(BuildContext context) {
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context);
final expColor = RetroColors.expOf(context);
// 부스트 활성화 중일 때
if (isActive) {
return _buildActiveButton(context, expColor);
}
// 부스트 비활성화 상태
return _buildInactiveButton(context, gold, goldDark, isPaidUser);
}
Widget _buildActiveButton(BuildContext context, Color expColor) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: expColor.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: expColor, width: 2),
boxShadow: [
BoxShadow(
color: expColor.withValues(alpha: 0.5),
blurRadius: 8,
spreadRadius: 1,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'',
style: TextStyle(fontSize: 18, color: expColor),
),
const SizedBox(width: 4),
Text(
'${boostMultiplier}x',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: expColor,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 4),
Text(
l10n.speedBoostRemaining(remainingSeconds),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: expColor.withValues(alpha: 0.8),
),
),
],
),
);
}
Widget _buildInactiveButton(
BuildContext context,
Color gold,
Color goldDark,
bool isPaidUser,
) {
return GestureDetector(
onTap: onActivate,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: gold.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: gold, width: 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'',
style: TextStyle(fontSize: 18, color: gold),
),
const SizedBox(width: 4),
Text(
'${boostMultiplier}x',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: gold,
),
),
// 광고 아이콘 (무료 유저만)
if (!isPaidUser) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'AD',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: Colors.white,
),
),
),
],
],
),
),
);
}
}

View File

@@ -8,6 +8,8 @@ import 'package:asciineverdie/data/class_data.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/data/race_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/engine/character_roll_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/model/class_traits.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/race_traits.dart';
@@ -52,10 +54,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
int _wis = 0;
int _cha = 0;
// 롤 이력 (Unroll 기능용) - 원본 OldRolls TListBox
static const int _maxRollHistory = 20; // 최대 저장 개수
final List<int> _rollHistory = [];
// 현재 RNG 시드 (Re-Roll 전 저장)
int _currentSeed = 0;
@@ -68,10 +66,19 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 굴리기 버튼 연속 클릭 방지
bool _isRolling = false;
// 굴리기/되돌리기 서비스
final CharacterRollService _rollService = CharacterRollService.instance;
// 서비스 초기화 완료 여부
bool _isServiceInitialized = false;
@override
void initState() {
super.initState();
// 서비스 초기화
_initializeService();
// 초기 랜덤화
final random = math.Random();
_selectedRaceIndex = random.nextInt(_races.length);
@@ -89,6 +96,16 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
_scrollToSelectedItems();
}
/// 서비스 초기화
Future<void> _initializeService() async {
await _rollService.initialize();
if (mounted) {
setState(() {
_isServiceInitialized = true;
});
}
}
@override
void dispose() {
_nameController.dispose();
@@ -144,12 +161,35 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
if (_isRolling) return;
_isRolling = true;
// 현재 시드를 이력에 저장
_rollHistory.insert(0, _currentSeed);
// 굴리기 가능 여부 확인
if (!_rollService.canRoll) {
_isRolling = false;
_showRechargeDialog();
return;
}
// 최대 개수 초과 시 가장 오래된 항목 제거
if (_rollHistory.length > _maxRollHistory) {
_rollHistory.removeLast();
// 현재 상태를 서비스에 저장
final currentStats = Stats(
str: _str,
con: _con,
dex: _dex,
intelligence: _int,
wis: _wis,
cha: _cha,
hpMax: 0,
mpMax: 0,
);
final success = _rollService.roll(
currentStats: currentStats,
currentRaceIndex: _selectedRaceIndex,
currentKlassIndex: _selectedKlassIndex,
currentSeed: _currentSeed,
);
if (!success) {
_isRolling = false;
return;
}
// 새 시드로 굴림
@@ -173,14 +213,103 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
});
}
/// Unroll 버튼 클릭 (이전 롤로 복원)
void _onUnroll() {
if (_rollHistory.isEmpty) return;
/// 굴리기 충전 다이얼로그
Future<void> _showRechargeDialog() async {
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
setState(() {
_currentSeed = _rollHistory.removeAt(0);
});
_rollStats();
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: RetroColors.panelBg,
title: const Text(
'RECHARGE ROLLS',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.gold,
),
),
content: Text(
isPaidUser
? 'Recharge 5 rolls for free?'
: 'Watch an ad to recharge 5 rolls?',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.textLight,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'CANCEL',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textDisabled,
),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isPaidUser) ...[
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
],
const Text(
'RECHARGE',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.gold,
),
),
],
),
),
],
),
);
if (result == true && mounted) {
final success = await _rollService.rechargeRollsWithAd();
if (success && mounted) {
setState(() {});
}
}
}
/// Unroll 버튼 클릭 (이전 롤로 복원)
Future<void> _onUnroll() async {
if (!_rollService.canUndo) return;
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
RollSnapshot? snapshot;
if (isPaidUser) {
snapshot = _rollService.undoPaidUser();
} else {
snapshot = await _rollService.undoFreeUser();
}
if (snapshot != null && mounted) {
setState(() {
_str = snapshot!.stats.str;
_con = snapshot.stats.con;
_dex = snapshot.stats.dex;
_int = snapshot.stats.intelligence;
_wis = snapshot.stats.wis;
_cha = snapshot.stats.cha;
_selectedRaceIndex = snapshot.raceIndex;
_selectedKlassIndex = snapshot.klassIndex;
_currentSeed = snapshot.seed;
});
_scrollToSelectedItems();
}
}
/// 이름 생성 버튼 클릭
@@ -266,6 +395,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
queue: QueueState.empty(),
);
// 캐릭터 생성 완료 알림 (되돌리기 상태 초기화)
_rollService.onCharacterCreated();
widget.onCharacterCreated?.call(initialState, testMode: _cheatsEnabled);
}
@@ -493,34 +625,27 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RetroTextButton(
text: l10n.unroll,
icon: Icons.undo,
onPressed: _rollHistory.isEmpty ? null : _onUnroll,
isPrimary: false,
),
_buildUndoButton(l10n),
const SizedBox(width: 16),
RetroTextButton(
text: l10n.roll,
icon: Icons.casino,
onPressed: _onReroll,
),
_buildRollButton(l10n),
],
),
if (_rollHistory.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: Text(
game_l10n.uiRollHistory(_rollHistory.length),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textDisabled,
),
// 남은 횟수 표시
Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: Text(
_rollService.canUndo
? 'Undo: ${_rollService.availableUndos} | Rolls: ${_rollService.rollsRemaining}/5'
: 'Rolls: ${_rollService.rollsRemaining}/5',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textDisabled,
),
),
),
),
],
),
);
@@ -790,4 +915,96 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
ClassPassiveType.firstStrikeBonus => passive.description,
};
}
// ===========================================================================
// 굴리기/되돌리기 버튼 위젯
// ===========================================================================
/// 되돌리기 버튼
Widget _buildUndoButton(L10n l10n) {
final canUndo = _rollService.canUndo;
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
return GestureDetector(
onTap: canUndo ? _onUnroll : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: canUndo
? RetroColors.panelBgLight
: RetroColors.panelBg.withValues(alpha: 0.5),
border: Border.all(
color: canUndo ? RetroColors.panelBorderInner : RetroColors.panelBg,
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 무료 유저는 광고 아이콘 표시
if (!isPaidUser && canUndo) ...[
const Icon(
Icons.play_circle,
size: 14,
color: RetroColors.gold,
),
const SizedBox(width: 4),
],
Icon(
Icons.undo,
size: 14,
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled,
),
const SizedBox(width: 4),
Text(
l10n.unroll.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled,
),
),
],
),
),
);
}
/// 굴리기 버튼
Widget _buildRollButton(L10n l10n) {
final canRoll = _rollService.canRoll;
return GestureDetector(
onTap: _onReroll,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.gold, width: 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 0회일 때 광고 아이콘 표시
if (!canRoll) ...[
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
],
const Icon(Icons.casino, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
Text(
canRoll
? '${l10n.roll.toUpperCase()} (${_rollService.rollsRemaining})'
: l10n.roll.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.gold,
),
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
/// 통합 설정 화면
@@ -75,9 +76,13 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
double _bgmVolume = 0.7;
double _sfxVolume = 0.8;
double _animationSpeed = 1.0;
bool _isLoading = true;
// 디버그 설정 상태 (Phase 8)
bool _debugAdEnabled = true;
bool _debugIapSimulated = false;
int _debugOfflineHours = 0;
@override
void initState() {
super.initState();
@@ -87,13 +92,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadSettings() async {
final bgm = await widget.settingsRepository.loadBgmVolume();
final sfx = await widget.settingsRepository.loadSfxVolume();
final speed = await widget.settingsRepository.loadAnimationSpeed();
// 디버그 설정 로드 (Phase 8)
final debugSettings = DebugSettingsService.instance;
final adEnabled = debugSettings.adEnabled;
final iapSimulated = debugSettings.iapSimulated;
final offlineHours = debugSettings.offlineHours;
if (mounted) {
setState(() {
_bgmVolume = bgm;
_sfxVolume = sfx;
_animationSpeed = speed;
_debugAdEnabled = adEnabled;
_debugIapSimulated = iapSimulated;
_debugOfflineHours = offlineHours;
_isLoading = false;
});
}
@@ -181,17 +193,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 24),
// 애니메이션 속도
_buildSectionTitle(game_l10n.uiAnimationSpeed),
_buildAnimationSpeedSlider(),
const SizedBox(height: 24),
// 정보
_buildSectionTitle(game_l10n.uiAbout),
_buildAboutCard(),
// 디버그 섹션 (디버그 모드에서만 표시)
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
if (kDebugMode) ...[
const SizedBox(height: 24),
_buildSectionTitle('Debug'),
_buildDebugSection(),
@@ -205,52 +212,171 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildDebugSection() {
final theme = Theme.of(context);
final errorColor = theme.colorScheme.error;
return Card(
color: Theme.of(
context,
).colorScheme.errorContainer.withValues(alpha: 0.3),
color: theme.colorScheme.errorContainer.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
children: [
Icon(
Icons.bug_report,
color: Theme.of(context).colorScheme.error,
),
Icon(Icons.bug_report, color: errorColor),
const SizedBox(width: 8),
Text(
'Developer Tools',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
style: theme.textTheme.titleSmall?.copyWith(color: errorColor),
),
],
),
const SizedBox(height: 12),
Text(
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
'등록 후 현재 세이브 파일이 삭제됩니다.',
style: Theme.of(context).textTheme.bodySmall,
const SizedBox(height: 16),
// 광고 ON/OFF 토글
_buildDebugToggle(
icon: Icons.ad_units,
label: 'Ads Enabled',
description: 'OFF: 광고 버튼 클릭 시 바로 보상',
value: _debugAdEnabled,
onChanged: (value) async {
await DebugSettingsService.instance.setAdEnabled(value);
setState(() => _debugAdEnabled = value);
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _handleCreateTestCharacter,
icon: const Icon(Icons.science),
label: const Text('Create Test Character'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
// IAP 시뮬레이션 토글
_buildDebugToggle(
icon: Icons.shopping_cart,
label: 'IAP Purchased',
description: 'ON: 유료 유저로 동작 (광고 제거)',
value: _debugIapSimulated,
onChanged: (value) async {
await DebugSettingsService.instance.setIapSimulated(value);
setState(() => _debugIapSimulated = value);
},
),
const SizedBox(height: 12),
// 오프라인 시간 시뮬레이션
_buildOfflineHoursSelector(),
const SizedBox(height: 16),
// 구분선
Divider(color: errorColor.withValues(alpha: 0.3)),
const SizedBox(height: 12),
// 테스트 캐릭터 생성
if (widget.onCreateTestCharacter != null) ...[
Text(
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
'등록 후 현재 세이브 파일이 삭제됩니다.',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _handleCreateTestCharacter,
icon: const Icon(Icons.science),
label: const Text('Create Test Character'),
style: ElevatedButton.styleFrom(
backgroundColor: errorColor,
foregroundColor: theme.colorScheme.onError,
),
),
),
],
],
),
),
);
}
/// 디버그 토글 위젯
Widget _buildDebugToggle({
required IconData icon,
required String label,
required String description,
required bool value,
required void Function(bool) onChanged,
}) {
final theme = Theme.of(context);
return Row(
children: [
Icon(icon, size: 20, color: theme.colorScheme.onSurface),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodyMedium),
Text(
description,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
),
],
),
),
Switch(value: value, onChanged: onChanged),
],
);
}
/// 오프라인 시간 시뮬레이션 선택기
Widget _buildOfflineHoursSelector() {
final theme = Theme.of(context);
final options = DebugSettingsService.offlineHoursOptions;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.timer, size: 20, color: theme.colorScheme.onSurface),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Offline Hours Simulation', style: theme.textTheme.bodyMedium),
Text(
'복귀 보상 테스트용 (게임 재시작 시 적용)',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
),
],
),
),
],
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: options.map((hours) {
final isSelected = _debugOfflineHours == hours;
final label = hours == 0 ? 'OFF' : '${hours}h';
return ChoiceChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) async {
if (selected) {
await DebugSettingsService.instance.setOfflineHours(hours);
setState(() => _debugOfflineHours = hours);
}
},
);
}).toList(),
),
],
);
}
@@ -445,51 +571,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildAnimationSpeedSlider() {
final theme = Theme.of(context);
final speedLabel = switch (_animationSpeed) {
<= 0.6 => game_l10n.uiSpeedSlow,
>= 1.4 => game_l10n.uiSpeedFast,
_ => game_l10n.uiSpeedNormal,
};
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.speed, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(game_l10n.uiAnimationSpeed),
Text(speedLabel),
],
),
Slider(
value: _animationSpeed,
min: 0.5,
max: 2.0,
divisions: 6,
onChanged: (value) {
setState(() => _animationSpeed = value);
widget.settingsRepository.saveAnimationSpeed(value);
},
),
],
),
),
],
),
),
);
}
Widget _buildAboutCard() {
return Card(
child: Padding(

View File

@@ -6,13 +6,17 @@ import FlutterMacOS
import Foundation
import audio_session
import in_app_purchase_storekit
import just_audio
import path_provider_foundation
import shared_preferences_foundation
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}

View File

@@ -2,6 +2,9 @@ PODS:
- audio_session (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- in_app_purchase_storekit (0.0.1):
- Flutter
- FlutterMacOS
- just_audio (0.0.1):
- Flutter
- FlutterMacOS
@@ -11,32 +14,43 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin`)
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
EXTERNAL SOURCES:
audio_session:
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
FlutterMacOS:
:path: Flutter/ephemeral
in_app_purchase_storekit:
:path: Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin
just_audio:
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
webview_flutter_wkwebview:
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
SPEC CHECKSUMS:
audio_session: 728ae3823d914f809c485d390274861a24b0904e
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009

View File

@@ -301,6 +301,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_mobile_ads:
dependency: "direct main"
description:
name: google_mobile_ads
sha256: "0d4a3744b5e8ed1b8be6a1b452d309f811688855a497c6113fc4400f922db603"
url: "https://pub.dev"
source: hosted
version: "5.3.1"
graphs:
dependency: transitive
description:
@@ -341,6 +349,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.7.2"
in_app_purchase:
dependency: "direct main"
description:
name: in_app_purchase
sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
in_app_purchase_android:
dependency: transitive
description:
name: in_app_purchase_android
sha256: abb254ae159a5a9d4f867795ecb076864faeba59ce015ab81d4cca380f23df45
url: "https://pub.dev"
source: hosted
version: "0.4.0+8"
in_app_purchase_platform_interface:
dependency: transitive
description:
name: in_app_purchase_platform_interface
sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
in_app_purchase_storekit:
dependency: transitive
description:
name: in_app_purchase_storekit
sha256: f7cbbd7fb47ab5a4fb736fc3f20ae81a4f6def0af9297b3c525ca727761e2589
url: "https://pub.dev"
source: hosted
version: "0.4.7"
intl:
dependency: "direct main"
description:
@@ -834,6 +874,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webview_flutter:
dependency: transitive
description:
name: webview_flutter
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
url: "https://pub.dev"
source: hosted
version: "4.13.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510
url: "https://pub.dev"
source: hosted
version: "4.10.11"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
url: "https://pub.dev"
source: hosted
version: "2.14.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: e49f378ed066efb13fc36186bbe0bd2425630d4ea0dbc71a18fdd0e4d8ed8ebc
url: "https://pub.dev"
source: hosted
version: "3.23.5"
xdg_directories:
dependency: transitive
description:

View File

@@ -41,6 +41,10 @@ dependencies:
# Code generation annotations
freezed_annotation: ^2.4.1
json_annotation: ^4.9.0
# AdMob 광고
google_mobile_ads: ^5.3.0
# IAP (인앱 결제)
in_app_purchase: ^3.2.0
dev_dependencies:
flutter_test:

View File

@@ -80,7 +80,7 @@ void main() {
test('loadAndStart surfaces save load errors', () {
fakeAsync((async) {
final saveManager = FakeSaveManager()
..onLoad = (_) => (const SaveOutcome.failure('boom'), null, false);
..onLoad = (_) => (const SaveOutcome.failure('boom'), null, false, null);
final controller = buildController(async, saveManager);
controller.loadAndStart(fileName: 'bad.pqf');

View File

@@ -4,6 +4,7 @@ import 'package:asciineverdie/src/core/engine/reward_service.dart';
import 'package:asciineverdie/src/core/model/combat_state.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/monetization_state.dart';
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/storage/save_manager.dart';
@@ -23,7 +24,7 @@ class FakeSaveManager implements SaveManager {
final List<GameState> savedStates = [];
/// 커스텀 로드 동작 설정
(SaveOutcome, GameState?, bool) Function(String?)? onLoad;
(SaveOutcome, GameState?, bool, MonetizationState?) Function(String?)? onLoad;
/// 저장 결과 설정 (기본: 성공)
SaveOutcome saveOutcome = const SaveOutcome.success();
@@ -33,17 +34,20 @@ class FakeSaveManager implements SaveManager {
GameState state, {
String? fileName,
bool cheatsEnabled = false,
MonetizationState? monetization,
}) async {
savedStates.add(state);
return saveOutcome;
}
@override
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
Future<(SaveOutcome, GameState?, bool, MonetizationState?)> loadState({
String? fileName,
}) async {
if (onLoad != null) {
return onLoad!(fileName);
}
return (const SaveOutcome.success(), null, false);
return (const SaveOutcome.success(), null, false, null);
}
@override