feat(ui): 화면 및 컨트롤러 수익화 연동

- 앱 초기화에 광고/IAP 서비스 추가
- 게임 세션 컨트롤러 수익화 상태 관리
- 캐릭터 생성 화면 굴리기 제한 UI
- 설정 화면 광고 제거 구매 UI
- 애니메이션 패널 개선
This commit is contained in:
JiWoong Sul
2026-01-16 20:10:43 +09:00
parent c95e4de5a4
commit 748160d543
8 changed files with 1288 additions and 373 deletions

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

View File

@@ -17,6 +17,7 @@ class FrontScreen extends StatefulWidget {
this.onLoadSave, this.onLoadSave,
this.onHallOfFame, this.onHallOfFame,
this.onLocalArena, this.onLocalArena,
this.onSettings,
this.hasSaveFile = false, this.hasSaveFile = false,
this.savedGamePreview, this.savedGamePreview,
this.hallOfFameCount = 0, this.hallOfFameCount = 0,
@@ -36,6 +37,9 @@ class FrontScreen extends StatefulWidget {
/// "Local Arena" 버튼 클릭 시 호출 /// "Local Arena" 버튼 클릭 시 호출
final void Function(BuildContext context)? onLocalArena; final void Function(BuildContext context)? onLocalArena;
/// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드)
final void Function(BuildContext context)? onSettings;
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용) /// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
final bool hasSaveFile; final bool hasSaveFile;
@@ -147,6 +151,9 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
widget.hallOfFameCount >= 2 widget.hallOfFameCount >= 2
? () => widget.onLocalArena!(context) ? () => widget.onLocalArena!(context)
: null, : null,
onSettings: widget.onSettings != null
? () => widget.onSettings!(context)
: null,
savedGamePreview: widget.savedGamePreview, savedGamePreview: widget.savedGamePreview,
hallOfFameCount: widget.hallOfFameCount, hallOfFameCount: widget.hallOfFameCount,
), ),
@@ -249,6 +256,7 @@ class _ActionButtons extends StatelessWidget {
this.onLoadSave, this.onLoadSave,
this.onHallOfFame, this.onHallOfFame,
this.onLocalArena, this.onLocalArena,
this.onSettings,
this.savedGamePreview, this.savedGamePreview,
this.hallOfFameCount = 0, this.hallOfFameCount = 0,
}); });
@@ -257,6 +265,7 @@ class _ActionButtons extends StatelessWidget {
final VoidCallback? onLoadSave; final VoidCallback? onLoadSave;
final VoidCallback? onHallOfFame; final VoidCallback? onHallOfFame;
final VoidCallback? onLocalArena; final VoidCallback? onLocalArena;
final VoidCallback? onSettings;
final SavedGamePreview? savedGamePreview; final SavedGamePreview? savedGamePreview;
final int hallOfFameCount; final int hallOfFameCount;
@@ -306,6 +315,14 @@ class _ActionButtons extends StatelessWidget {
onPressed: hallOfFameCount >= 2 ? onLocalArena : null, onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
isPrimary: false, 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/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/data/skill_data.dart'; 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/data/story_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.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/potion_inventory_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/task_progress_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/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/game/layouts/mobile_carousel_layout.dart';
import 'package:asciineverdie/src/features/settings/settings_screen.dart'; import 'package:asciineverdie/src/features/settings/settings_screen.dart';
import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart'; import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart';
@@ -246,6 +249,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 오디오 볼륨 초기화 // 오디오 볼륨 초기화
_audioController.initVolumes(); _audioController.initVolumes();
// Phase 7: 복귀 보상 콜백 설정
widget.controller.onReturnRewardAvailable = _showReturnRewardsDialog;
} }
@override @override
@@ -262,6 +268,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_storyService.dispose(); _storyService.dispose();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
widget.controller.removeListener(_onControllerChanged); widget.controller.removeListener(_onControllerChanged);
widget.controller.onReturnRewardAvailable = null; // Phase 7: 콜백 정리
super.dispose(); super.dispose();
} }
@@ -399,6 +406,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
return platform == TargetPlatform.iOS || platform == TargetPlatform.android; 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) { void _showStatisticsDialog(BuildContext context) {
StatisticsDialog.show( 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = widget.controller.state; final state = widget.controller.state;
@@ -603,6 +666,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
navigator.popUntil((route) => route.isFirst); 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) if (state.isDead && state.deathInfo != null)
@@ -610,12 +678,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
deathInfo: state.deathInfo!, deathInfo: state.deathInfo!,
traits: state.traits, traits: state.traits,
onResurrect: _handleResurrect, onResurrect: _handleResurrect,
isAutoResurrectEnabled: widget.controller.autoResurrect, onAdRevive: _handleAdRevive,
onToggleAutoResurrect: () { isPaidUser: IAPService.instance.isAdRemovalPurchased,
widget.controller.setAutoResurrect(
!widget.controller.autoResurrect,
);
},
), ),
// 승리 오버레이 (게임 클리어) // 승리 오버레이 (게임 클리어)
if (widget.controller.isComplete) if (widget.controller.isComplete)
@@ -759,18 +823,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
], ],
), ),
// Phase 4: 사망 오버레이 (Death Overlay) // 사망 오버레이
if (state.isDead && state.deathInfo != null) if (state.isDead && state.deathInfo != null)
DeathOverlay( DeathOverlay(
deathInfo: state.deathInfo!, deathInfo: state.deathInfo!,
traits: state.traits, traits: state.traits,
onResurrect: _handleResurrect, onResurrect: _handleResurrect,
isAutoResurrectEnabled: widget.controller.autoResurrect, onAdRevive: _handleAdRevive,
onToggleAutoResurrect: () { isPaidUser: IAPService.instance.isAdRemovalPurchased,
widget.controller.setAutoResurrect(
!widget.controller.autoResurrect,
);
},
), ),
// 승리 오버레이 (게임 클리어) // 승리 오버레이 (게임 클리어)
if (widget.controller.isComplete) if (widget.controller.isComplete)

View File

@@ -1,14 +1,19 @@
import 'dart:async'; 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_loop.dart';
import 'package:asciineverdie/src/core/engine/progress_service.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/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/shop_service.dart';
import 'package:asciineverdie/src/core/engine/test_character_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/combat_stats.dart';
import 'package:asciineverdie/src/core/model/game_state.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/game_statistics.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.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/hall_of_fame_storage.dart';
import 'package:asciineverdie/src/core/storage/save_manager.dart'; import 'package:asciineverdie/src/core/storage/save_manager.dart';
import 'package:asciineverdie/src/core/storage/statistics_storage.dart'; import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
@@ -54,6 +59,20 @@ class GameSessionController extends ChangeNotifier {
// 자동 부활 (Auto-Resurrection) 상태 // 자동 부활 (Auto-Resurrection) 상태
bool _autoResurrect = false; 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(); SessionStatistics _sessionStats = SessionStatistics.empty();
CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty(); CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty();
@@ -152,18 +171,14 @@ class GameSessionController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 명예의 전당 상태에 따른 가용 배속 목록 반환 /// 가용 배속 목록 반환
/// - 디버그 모드(치트 활성화): [1, 5, 20] (터보 모드) /// - 디버그 모드(치트 활성화): [1, 2, 20] (터보 모드 포함)
/// - 명예의 전당에 캐릭터 없음: [1, 5] /// - 일반 모드: [1, 2] (5x는 광고 버프로만 활성화)
/// - 명예의 전당에 캐릭터 있음: [1, 2, 5]
Future<List<int>> _getAvailableSpeeds() async { Future<List<int>> _getAvailableSpeeds() async {
// 디버그 모드면 터보(20x) 추가
if (_cheatsEnabled) { if (_cheatsEnabled) {
return [1, 5, 20]; return [1, 2, 20];
} }
return [1, 2];
final hallOfFame = await _hallOfFameStorage.load();
return hallOfFame.isEmpty ? [1, 5] : [1, 2, 5];
} }
/// 이전 값 초기화 (통계 변화 추적용) /// 이전 값 초기화 (통계 변화 추적용)
@@ -241,9 +256,8 @@ class GameSessionController extends ChangeNotifier {
_error = null; _error = null;
notifyListeners(); notifyListeners();
final (outcome, loaded, savedCheatsEnabled) = await saveManager.loadState( final (outcome, loaded, savedCheatsEnabled, savedMonetization) =
fileName: fileName, await saveManager.loadState(fileName: fileName);
);
if (!outcome.success || loaded == null) { if (!outcome.success || loaded == null) {
_status = GameSessionStatus.error; _status = GameSessionStatus.error;
_error = outcome.error ?? 'Unknown error'; _error = outcome.error ?? 'Unknown error';
@@ -251,6 +265,12 @@ class GameSessionController extends ChangeNotifier {
return; return;
} }
// 저장된 수익화 상태 복원
_monetization = savedMonetization ?? MonetizationState.initial();
// 복귀 보상 체크 (Phase 7)
_checkReturnRewards(loaded);
// 저장된 치트 모드 상태 복원 // 저장된 치트 모드 상태 복원
await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false); await startNew(loaded, cheatsEnabled: savedCheatsEnabled, isNewGame: false);
} }
@@ -312,8 +332,16 @@ class GameSessionController extends ChangeNotifier {
_status = GameSessionStatus.dead; _status = GameSessionStatus.dead;
notifyListeners(); 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(); _scheduleAutoResurrect();
} }
} }
@@ -323,8 +351,15 @@ class GameSessionController extends ChangeNotifier {
/// 사망 오버레이를 잠시 표시한 후 자동으로 부활 처리 /// 사망 오버레이를 잠시 표시한 후 자동으로 부활 처리
void _scheduleAutoResurrect() { void _scheduleAutoResurrect() {
Future.delayed(const Duration(milliseconds: 800), () async { Future.delayed(const Duration(milliseconds: 800), () async {
// 상태가 여전히 dead이고, 자동 부활이 활성화된 경우에만 부활 if (_status != GameSessionStatus.dead) return;
if (_status == GameSessionStatus.dead && _autoResurrect) {
// 자동 부활 조건 재확인
final elapsedMs = _state?.skillSystem.elapsedMs ?? 0;
final shouldAutoResurrect = _autoResurrect ||
IAPService.instance.isAdRemovalPurchased ||
_monetization.isAutoReviveActive(elapsedMs);
if (shouldAutoResurrect) {
await resurrect(); await resurrect();
await resumeAfterResurrection(); await resumeAfterResurrection();
} }
@@ -456,6 +491,7 @@ class GameSessionController extends ChangeNotifier {
await saveManager.saveState( await saveManager.saveState(
resurrectedState, resurrectedState,
cheatsEnabled: _cheatsEnabled, cheatsEnabled: _cheatsEnabled,
monetization: _monetization,
); );
notifyListeners(); notifyListeners();
@@ -471,10 +507,266 @@ class GameSessionController extends ChangeNotifier {
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false); 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 => bool get isDead =>
_status == GameSessionStatus.dead || (_state?.isDead ?? false); _status == GameSessionStatus.dead || (_state?.isDead ?? false);
/// 게임 클리어 여부 /// 게임 클리어 여부
bool get isComplete => _status == GameSessionStatus.complete; 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.onCheatQuest,
this.onCheatPlot, this.onCheatPlot,
this.onCreateTestCharacter, this.onCreateTestCharacter,
this.autoReviveEndMs,
this.speedBoostEndMs,
this.isPaidUser = false,
this.onSpeedBoostActivate,
}); });
final GameState state; final GameState state;
@@ -102,6 +106,18 @@ class MobileCarouselLayout extends StatefulWidget {
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용) /// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
final Future<void> Function()? onCreateTestCharacter; final Future<void> Function()? onCreateTestCharacter;
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
final int? autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
final int? speedBoostEndMs;
/// 유료 유저 여부
final bool isPaidUser;
/// 5배속 버프 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate;
@override @override
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState(); State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
} }
@@ -456,27 +472,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
ListTile( ListTile(
leading: const Icon(Icons.speed), leading: const Icon(Icons.speed),
title: Text(l10n.menuSpeed), title: Text(l10n.menuSpeed),
trailing: Container( trailing: _buildSpeedSelector(context),
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);
},
), ),
const Divider(), const Divider(),
@@ -735,6 +731,10 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
state.progress.currentCombat?.recentEvents.lastOrNull, state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId, raceId: state.traits.raceId,
weaponRarity: state.equipment.weaponItem.rarity, weaponRarity: state.equipment.weaponItem.rarity,
autoReviveEndMs: widget.autoReviveEndMs,
speedBoostEndMs: widget.speedBoostEndMs,
isPaidUser: widget.isPaidUser,
onSpeedBoostActivate: widget.onSpeedBoostActivate,
), ),
// 중앙: 캐로셀 (PageView) // 중앙: 캐로셀 (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

@@ -37,6 +37,10 @@ class EnhancedAnimationPanel extends StatefulWidget {
this.latestCombatEvent, this.latestCombatEvent,
this.raceId, this.raceId,
this.weaponRarity, this.weaponRarity,
this.autoReviveEndMs,
this.speedBoostEndMs,
this.isPaidUser = false,
this.onSpeedBoostActivate,
}); });
final ProgressState progress; final ProgressState progress;
@@ -65,6 +69,18 @@ class EnhancedAnimationPanel extends StatefulWidget {
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상) /// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
final ItemRarity? weaponRarity; final ItemRarity? weaponRarity;
/// 자동부활 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
final int? autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준, null이면 비활성)
final int? speedBoostEndMs;
/// 유료 유저 여부 (5배속 항상 활성)
final bool isPaidUser;
/// 5배속 버프 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate;
@override @override
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState(); State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
} }
@@ -190,6 +206,22 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
int? get _currentMonsterHpMax => int? get _currentMonsterHpMax =>
widget.progress.currentCombat?.monsterStats.hpMax; 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 @override
void dispose() { void dispose() {
_hpFlashController.dispose(); _hpFlashController.dispose();
@@ -218,10 +250,13 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// ASCII 애니메이션 (기존 높이 120 유지) // ASCII 애니메이션 (기존 높이 120 유지) + 버프 오버레이
SizedBox( SizedBox(
height: 120, height: 120,
child: AsciiAnimationCard( child: Stack(
children: [
// ASCII 애니메이션
AsciiAnimationCard(
taskType: widget.progress.currentTask.type, taskType: widget.progress.currentTask.type,
monsterBaseName: widget.progress.currentTask.monsterBaseName, monsterBaseName: widget.progress.currentTask.monsterBaseName,
specialAnimation: widget.specialAnimation, specialAnimation: widget.specialAnimation,
@@ -238,43 +273,72 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
raceId: widget.raceId, raceId: widget.raceId,
weaponRarity: widget.weaponRarity, 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), const SizedBox(height: 8),
// 상태 바 영역: HP/MP + 버프 아이콘 + 몬스터 HP // 상태 바 영역: HP/MP (40%) + 컨트롤 (20%) + 몬스터 HP (40%)
Row( SizedBox(
crossAxisAlignment: CrossAxisAlignment.start, height: 48,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 좌측: HP/MP 바 // 좌측: HP/MP 바 (40%)
Expanded( Expanded(
flex: 3, flex: 2,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildCompactHpBar(), Expanded(child: _buildCompactHpBar()),
const SizedBox(height: 4), const SizedBox(height: 4),
_buildCompactMpBar(), Expanded(child: _buildCompactMpBar()),
], ],
), ),
), ),
const SizedBox(width: 8), // 중앙: 컨트롤 버튼 (20%)
Expanded(
flex: 1,
child: _buildControlButtons(),
),
// 중앙: 활성 버프 아이콘 (최대 3개) // 우측: 몬스터 HP (전투 중) 또는 빈 공간 (40%)
_buildBuffIcons(),
const SizedBox(width: 8),
// 우측: 몬스터 HP (전투 중) 또는 컨트롤 버튼
Expanded( Expanded(
flex: 2, flex: 2,
child: switch ((shouldShowMonsterHp, combat)) { child: switch ((shouldShowMonsterHp, combat)) {
(true, final c?) => _buildMonsterHpBar(c), (true, final c?) => _buildMonsterHpBar(c),
_ => _buildControlButtons(), _ => const SizedBox.shrink(),
}, },
), ),
], ],
), ),
),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -298,7 +362,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
children: [ children: [
// HP 바 // HP 바
Container( Container(
height: 20,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isLow color: isLow
? Colors.red.withValues(alpha: 0.2) ? Colors.red.withValues(alpha: 0.2)
@@ -330,13 +393,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
borderRadius: const BorderRadius.horizontal( borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3), right: Radius.circular(3),
), ),
child: SizedBox.expand(
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0), value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.red.withValues(alpha: 0.2), backgroundColor: Colors.red.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation( valueColor: AlwaysStoppedAnimation(
isLow ? Colors.red : Colors.red.shade600, isLow ? Colors.red : Colors.red.shade600,
), ),
minHeight: 20, ),
), ),
), ),
// 숫자 오버레이 (바 중앙) // 숫자 오버레이 (바 중앙)
@@ -402,7 +466,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Container( Container(
height: 20,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade800, color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -431,13 +494,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
borderRadius: const BorderRadius.horizontal( borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3), right: Radius.circular(3),
), ),
child: SizedBox.expand(
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0), value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.blue.withValues(alpha: 0.2), backgroundColor: Colors.blue.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation( valueColor: AlwaysStoppedAnimation(
Colors.blue.shade600, 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바 중앙에 HP% 오버레이 /// - HP바 중앙에 HP% 오버레이
/// - 하단에 레벨.이름 표시 /// - 하단에 레벨.이름 표시
@@ -562,7 +572,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Container( Container(
height: 52,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1), color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -572,14 +581,16 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// HP 바 (HP% 중앙 오버레이) // HP 바 (HP% 중앙 오버레이)
Padding( Expanded(
padding: const EdgeInsets.symmetric(horizontal: 4), child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
// HP 바 // HP 바
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
child: SizedBox.expand(
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0), value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues( backgroundColor: Colors.orange.withValues(
@@ -588,7 +599,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
valueColor: const AlwaysStoppedAnimation( valueColor: const AlwaysStoppedAnimation(
Colors.orange, Colors.orange,
), ),
minHeight: 16, ),
), ),
), ),
// HP% 중앙 오버레이 // HP% 중앙 오버레이
@@ -610,14 +621,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
], ],
), ),
), ),
const SizedBox(height: 2), ),
// 레벨.이름 표시 // 레벨.이름 표시
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
child: Text( child: Text(
'Lv.$monsterLevel $monsterName', 'Lv.$monsterLevel $monsterName',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 11,
color: Colors.orange, color: Colors.orange,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -662,64 +673,92 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
); );
} }
/// 컨트롤 버튼 (비전투 시) /// 컨트롤 버튼 (중앙 영역)
Widget _buildControlButtons() { Widget _buildControlButtons() {
return SizedBox( return Column(
height: 40,
child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// 일시정지 버튼 // 상단: 속도 버튼 (1x ↔ 2x)
SizedBox( _buildCompactSpeedButton(),
width: 40, const SizedBox(height: 2),
height: 36, // 하단: 5x 광고 버튼 (2x일 때만 표시)
child: OutlinedButton( _buildAdSpeedButton(),
onPressed: widget.onPauseToggle, ],
style: OutlinedButton.styleFrom( );
padding: EdgeInsets.zero, }
visualDensity: VisualDensity.compact,
side: BorderSide( /// 컴팩트 속도 버튼 (1x ↔ 2x 사이클)
color: widget.isPaused Widget _buildCompactSpeedButton() {
? Colors.orange.withValues(alpha: 0.7) final isSpeedBoostActive = _speedBoostRemainingMs > 0 || widget.isPaidUser;
: Theme.of(
context, return SizedBox(
).colorScheme.outline.withValues(alpha: 0.5), width: 32,
), height: 22,
),
child: Icon(
widget.isPaused ? Icons.play_arrow : Icons.pause,
size: 18,
color: widget.isPaused ? Colors.orange : null,
),
),
),
const SizedBox(width: 4),
// 속도 버튼
SizedBox(
width: 44,
height: 36,
child: OutlinedButton( child: OutlinedButton(
onPressed: widget.onSpeedCycle, onPressed: widget.onSpeedCycle,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact, 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( child: Text(
'${widget.speedMultiplier}x', isSpeedBoostActive ? '5x' : '${widget.speedMultiplier}x',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 10,
fontWeight: widget.speedMultiplier > 1 fontWeight: FontWeight.bold,
? FontWeight.bold color: isSpeedBoostActive
: FontWeight.normal, ? Colors.orange
color: widget.speedMultiplier > 1 : widget.speedMultiplier > 1
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: null, : 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,
),
), ),
], ],
), ),
),
); );
} }
@@ -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

@@ -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/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/data/race_data.dart'; import 'package:asciineverdie/data/race_data.dart';
import 'package:asciineverdie/l10n/app_localizations.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/class_traits.dart';
import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/race_traits.dart'; import 'package:asciineverdie/src/core/model/race_traits.dart';
@@ -52,10 +54,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
int _wis = 0; int _wis = 0;
int _cha = 0; int _cha = 0;
// 롤 이력 (Unroll 기능용) - 원본 OldRolls TListBox
static const int _maxRollHistory = 20; // 최대 저장 개수
final List<int> _rollHistory = [];
// 현재 RNG 시드 (Re-Roll 전 저장) // 현재 RNG 시드 (Re-Roll 전 저장)
int _currentSeed = 0; int _currentSeed = 0;
@@ -68,10 +66,19 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 굴리기 버튼 연속 클릭 방지 // 굴리기 버튼 연속 클릭 방지
bool _isRolling = false; bool _isRolling = false;
// 굴리기/되돌리기 서비스
final CharacterRollService _rollService = CharacterRollService.instance;
// 서비스 초기화 완료 여부
bool _isServiceInitialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 서비스 초기화
_initializeService();
// 초기 랜덤화 // 초기 랜덤화
final random = math.Random(); final random = math.Random();
_selectedRaceIndex = random.nextInt(_races.length); _selectedRaceIndex = random.nextInt(_races.length);
@@ -89,6 +96,16 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
_scrollToSelectedItems(); _scrollToSelectedItems();
} }
/// 서비스 초기화
Future<void> _initializeService() async {
await _rollService.initialize();
if (mounted) {
setState(() {
_isServiceInitialized = true;
});
}
}
@override @override
void dispose() { void dispose() {
_nameController.dispose(); _nameController.dispose();
@@ -144,12 +161,35 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
if (_isRolling) return; if (_isRolling) return;
_isRolling = true; _isRolling = true;
// 현재 시드를 이력에 저장 // 굴리기 가능 여부 확인
_rollHistory.insert(0, _currentSeed); if (!_rollService.canRoll) {
_isRolling = false;
_showRechargeDialog();
return;
}
// 최대 개수 초과 시 가장 오래된 항목 제거 // 현재 상태를 서비스에 저장
if (_rollHistory.length > _maxRollHistory) { final currentStats = Stats(
_rollHistory.removeLast(); 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() { Future<void> _showRechargeDialog() async {
if (_rollHistory.isEmpty) return; final isPaidUser = IAPService.instance.isAdRemovalPurchased;
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(() { setState(() {
_currentSeed = _rollHistory.removeAt(0); _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;
}); });
_rollStats(); _scrollToSelectedItems();
}
} }
/// 이름 생성 버튼 클릭 /// 이름 생성 버튼 클릭
@@ -266,6 +395,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
queue: QueueState.empty(), queue: QueueState.empty(),
); );
// 캐릭터 생성 완료 알림 (되돌리기 상태 초기화)
_rollService.onCharacterCreated();
widget.onCharacterCreated?.call(initialState, testMode: _cheatsEnabled); widget.onCharacterCreated?.call(initialState, testMode: _cheatsEnabled);
} }
@@ -493,29 +625,22 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
RetroTextButton( _buildUndoButton(l10n),
text: l10n.unroll,
icon: Icons.undo,
onPressed: _rollHistory.isEmpty ? null : _onUnroll,
isPrimary: false,
),
const SizedBox(width: 16), const SizedBox(width: 16),
RetroTextButton( _buildRollButton(l10n),
text: l10n.roll,
icon: Icons.casino,
onPressed: _onReroll,
),
], ],
), ),
if (_rollHistory.isNotEmpty) // 남은 횟수 표시
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Center( child: Center(
child: Text( child: Text(
game_l10n.uiRollHistory(_rollHistory.length), _rollService.canUndo
? 'Undo: ${_rollService.availableUndos} | Rolls: ${_rollService.rollsRemaining}/5'
: 'Rolls: ${_rollService.rollsRemaining}/5',
style: const TextStyle( style: const TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 13, fontSize: 11,
color: RetroColors.textDisabled, color: RetroColors.textDisabled,
), ),
), ),
@@ -790,4 +915,96 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
ClassPassiveType.firstStrikeBonus => passive.description, 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:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; 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'; import 'package:asciineverdie/src/core/storage/settings_repository.dart';
/// 통합 설정 화면 /// 통합 설정 화면
@@ -75,9 +76,13 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
double _bgmVolume = 0.7; double _bgmVolume = 0.7;
double _sfxVolume = 0.8; double _sfxVolume = 0.8;
double _animationSpeed = 1.0;
bool _isLoading = true; bool _isLoading = true;
// 디버그 설정 상태 (Phase 8)
bool _debugAdEnabled = true;
bool _debugIapSimulated = false;
int _debugOfflineHours = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -87,13 +92,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final bgm = await widget.settingsRepository.loadBgmVolume(); final bgm = await widget.settingsRepository.loadBgmVolume();
final sfx = await widget.settingsRepository.loadSfxVolume(); 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) { if (mounted) {
setState(() { setState(() {
_bgmVolume = bgm; _bgmVolume = bgm;
_sfxVolume = sfx; _sfxVolume = sfx;
_animationSpeed = speed; _debugAdEnabled = adEnabled;
_debugIapSimulated = iapSimulated;
_debugOfflineHours = offlineHours;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -181,17 +193,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// 애니메이션 속도
_buildSectionTitle(game_l10n.uiAnimationSpeed),
_buildAnimationSpeedSlider(),
const SizedBox(height: 24),
// 정보 // 정보
_buildSectionTitle(game_l10n.uiAbout), _buildSectionTitle(game_l10n.uiAbout),
_buildAboutCard(), _buildAboutCard(),
// 디버그 섹션 (디버그 모드에서만 표시) // 디버그 섹션 (디버그 모드에서만 표시)
if (kDebugMode && widget.onCreateTestCharacter != null) ...[ if (kDebugMode) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
_buildSectionTitle('Debug'), _buildSectionTitle('Debug'),
_buildDebugSection(), _buildDebugSection(),
@@ -205,35 +212,69 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Widget _buildDebugSection() { Widget _buildDebugSection() {
final theme = Theme.of(context);
final errorColor = theme.colorScheme.error;
return Card( return Card(
color: Theme.of( color: theme.colorScheme.errorContainer.withValues(alpha: 0.3),
context,
).colorScheme.errorContainer.withValues(alpha: 0.3),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 헤더
Row( Row(
children: [ children: [
Icon( Icon(Icons.bug_report, color: errorColor),
Icons.bug_report,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Developer Tools', 'Developer Tools',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(color: errorColor),
color: Theme.of(context).colorScheme.error,
),
), ),
], ],
), ),
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), const SizedBox(height: 12),
// 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( Text(
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. ' '현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
'등록 후 현재 세이브 파일이 삭제됩니다.', '등록 후 현재 세이브 파일이 삭제됩니다.',
style: Theme.of(context).textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( SizedBox(
@@ -243,14 +284,99 @@ class _SettingsScreenState extends State<SettingsScreen> {
icon: const Icon(Icons.science), icon: const Icon(Icons.science),
label: const Text('Create Test Character'), label: const Text('Create Test Character'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error, backgroundColor: errorColor,
foregroundColor: Theme.of(context).colorScheme.onError, 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() { Widget _buildAboutCard() {
return Card( return Card(
child: Padding( child: Padding(