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

@@ -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

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