From b6d5cd2abddba546a9d8f1c5c409faf33205f80c Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 16 Jan 2026 20:09:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(death):=20=EC=82=AC=EB=A7=9D/=EB=B6=80?= =?UTF-8?q?=ED=99=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeathInfo에 lostItem 필드 추가 (광고 부활 시 복구용) - 세이브 데이터 v4: MonetizationState 포함 - 사망 오버레이 UI 개선 - 부활 서비스 광고 연동 --- lib/src/core/engine/resurrection_service.dart | 123 +++++++++++ lib/src/core/model/game_state.dart | 6 + lib/src/core/model/save_data.dart | 28 ++- lib/src/core/storage/save_manager.dart | 24 ++- .../features/game/widgets/death_overlay.dart | 195 ++++++++++++------ 5 files changed, 309 insertions(+), 67 deletions(-) diff --git a/lib/src/core/engine/resurrection_service.dart b/lib/src/core/engine/resurrection_service.dart index 0d555d8..3107c12 100644 --- a/lib/src/core/engine/resurrection_service.dart +++ b/lib/src/core/engine/resurrection_service.dart @@ -240,6 +240,129 @@ class ResurrectionService { return state.inventory.gold >= cost; } + // ============================================================================ + // 광고 부활 (HP 100% + 아이템 복구) + // ============================================================================ + + /// 광고 부활 처리 + /// + /// 1. 상실한 아이템 복구 (있는 경우) + /// 2. HP/MP 100% 회복 + /// 3. 사망 상태 해제 + /// 4. 안전 지역으로 이동 태스크 설정 + /// + /// Note: 10분 자동부활 버프는 GameSessionController에서 처리 + GameState processAdRevive(GameState state) { + if (!state.isDead) return state; + + var nextState = state; + + // 1. 상실한 아이템 복구 (있는 경우) + if (canRecoverLostItem(state)) { + nextState = processItemRecovery(nextState); + } + + // 2. 전체 HP/MP 계산 (장비 + 종족 + 클래스 보너스 포함) + final totalHpMax = _calculateTotalHpMax(nextState); + final totalMpMax = _calculateTotalMpMax(nextState); + + // HP/MP 100% 회복 (장비 구매 없이) + nextState = nextState.copyWith( + stats: nextState.stats.copyWith( + hpCurrent: totalHpMax, + mpCurrent: totalMpMax, + ), + clearDeathInfo: true, // 사망 상태 해제 + ); + + // 스킬 쿨타임 초기화 + nextState = nextState.copyWith( + skillSystem: SkillSystemState.empty().copyWith( + elapsedMs: nextState.skillSystem.elapsedMs, + ), + ); + + // 4. 부활 후 태스크 시퀀스 설정 + final resurrectionQueue = [ + QueueEntry( + kind: QueueKind.task, + durationMillis: 3000, + caption: l10n.taskReturningToTown, + taskType: TaskType.neutral, + ), + QueueEntry( + kind: QueueKind.task, + durationMillis: 3000, + caption: l10n.taskRestockingAtShop, + taskType: TaskType.market, + ), + QueueEntry( + kind: QueueKind.task, + durationMillis: 2000, + caption: l10n.taskHeadingToHuntingGrounds, + taskType: TaskType.neutral, + ), + ]; + + final firstTask = resurrectionQueue.removeAt(0); + nextState = nextState.copyWith( + queue: QueueState(entries: resurrectionQueue), + progress: nextState.progress.copyWith( + currentTask: TaskInfo( + caption: firstTask.caption, + type: firstTask.taskType, + ), + task: ProgressBarState(position: 0, max: firstTask.durationMillis), + currentCombat: null, + ), + ); + + return nextState; + } + + // ============================================================================ + // 아이템 복구 (Phase 5) + // ============================================================================ + + /// 상실한 아이템 복구 가능 여부 확인 + /// + /// 사망 상태이고 상실한 아이템이 있을 때만 true + bool canRecoverLostItem(GameState state) { + if (!state.isDead) return false; + if (state.deathInfo == null) return false; + return state.deathInfo!.lostItem != null; + } + + /// 상실한 아이템 복구 처리 + /// + /// 광고 시청 후 호출되며 상실한 아이템을 장비에 복원합니다. + /// Returns: 복구된 상태 (복구 불가 시 원본 상태 반환) + GameState processItemRecovery(GameState state) { + if (!canRecoverLostItem(state)) return state; + + final deathInfo = state.deathInfo!; + final lostItem = deathInfo.lostItem!; + final lostSlot = deathInfo.lostItemSlot!; + + // 해당 슬롯에 아이템 복원 + final slotIndex = lostSlot.index; + final updatedEquipment = state.equipment.setItemByIndex(slotIndex, lostItem); + + // DeathInfo에서 상실 아이템 정보 제거 (복구 완료) + final updatedDeathInfo = deathInfo.copyWith( + lostEquipmentCount: 0, + lostItemName: null, + lostItemSlot: null, + lostItemRarity: null, + lostItem: null, + ); + + return state.copyWith( + equipment: updatedEquipment, + deathInfo: updatedDeathInfo, + ); + } + /// 장비 보존 아이템 적용 (향후 확장용) /// /// [protectedSlots] 보존할 슬롯 인덱스 목록 diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart index 5f416f6..d3acd71 100644 --- a/lib/src/core/model/game_state.dart +++ b/lib/src/core/model/game_state.dart @@ -133,6 +133,7 @@ class DeathInfo { this.lostItemName, this.lostItemSlot, this.lostItemRarity, + this.lostItem, this.lastCombatEvents = const [], }); @@ -154,6 +155,9 @@ class DeathInfo { /// 제물로 바친 아이템 희귀도 (null이면 없음) final ItemRarity? lostItemRarity; + /// 상실한 장비 전체 정보 (광고 부활 시 복구용) + final EquipmentItem? lostItem; + /// 사망 시점 골드 final int goldAtDeath; @@ -173,6 +177,7 @@ class DeathInfo { String? lostItemName, EquipmentSlot? lostItemSlot, ItemRarity? lostItemRarity, + EquipmentItem? lostItem, int? goldAtDeath, int? levelAtDeath, int? timestamp, @@ -185,6 +190,7 @@ class DeathInfo { lostItemName: lostItemName ?? this.lostItemName, lostItemSlot: lostItemSlot ?? this.lostItemSlot, lostItemRarity: lostItemRarity ?? this.lostItemRarity, + lostItem: lostItem ?? this.lostItem, goldAtDeath: goldAtDeath ?? this.goldAtDeath, levelAtDeath: levelAtDeath ?? this.levelAtDeath, timestamp: timestamp ?? this.timestamp, diff --git a/lib/src/core/model/save_data.dart b/lib/src/core/model/save_data.dart index 56788fb..bf18fa3 100644 --- a/lib/src/core/model/save_data.dart +++ b/lib/src/core/model/save_data.dart @@ -2,6 +2,7 @@ import 'dart:collection'; import 'package:asciineverdie/src/core/model/equipment_item.dart'; import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/monetization_state.dart'; import 'package:asciineverdie/src/core/model/monster_grade.dart'; import 'package:asciineverdie/src/core/util/deterministic_random.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; @@ -9,7 +10,8 @@ import 'package:asciineverdie/src/core/model/game_state.dart'; /// 세이브 파일 버전 /// - v2: 장비 이름만 저장 (레거시) /// - v3: 장비 전체 정보 저장 (level, rarity, stats 포함) -const int kSaveVersion = 3; +/// - v4: MonetizationState 추가, DeathInfo.lostItem 추가 +const int kSaveVersion = 4; class GameSave { GameSave({ @@ -23,9 +25,14 @@ class GameSave { required this.progress, required this.queue, this.cheatsEnabled = false, + this.monetization, }); - factory GameSave.fromState(GameState state, {bool cheatsEnabled = false}) { + factory GameSave.fromState( + GameState state, { + bool cheatsEnabled = false, + MonetizationState? monetization, + }) { return GameSave( version: kSaveVersion, rngState: state.rng.state, @@ -37,6 +44,7 @@ class GameSave { progress: state.progress, queue: state.queue, cheatsEnabled: cheatsEnabled, + monetization: monetization, ); } @@ -51,6 +59,9 @@ class GameSave { final QueueState queue; final bool cheatsEnabled; + /// 수익화 시스템 상태 (v4+) + final MonetizationState? monetization; + Map toJson() { return { 'version': version, @@ -132,6 +143,7 @@ class GameSave { }, ) .toList(), + if (monetization != null) 'monetization': monetization!.toJson(), }; } @@ -244,6 +256,9 @@ class GameSave { }), ), ), + monetization: _monetizationFromJson( + json['monetization'] as Map?, + ), ); } @@ -355,3 +370,12 @@ Equipment _equipmentFromJson(Map json, int version) { bestIndex: json['bestIndex'] as int? ?? 0, ); } + +/// MonetizationState 역직렬화 (v4+ 마이그레이션) +/// +/// - v3 이하: null (기본값 사용) +/// - v4 이상: 저장된 상태 로드 +MonetizationState? _monetizationFromJson(Map? json) { + if (json == null) return null; + return MonetizationState.fromJson(json); +} diff --git a/lib/src/core/storage/save_manager.dart b/lib/src/core/storage/save_manager.dart index 937712d..d58ee30 100644 --- a/lib/src/core/storage/save_manager.dart +++ b/lib/src/core/storage/save_manager.dart @@ -1,4 +1,5 @@ import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/monetization_state.dart'; import 'package:asciineverdie/src/core/model/save_data.dart'; import 'package:asciineverdie/src/core/storage/save_repository.dart'; import 'package:asciineverdie/src/core/storage/save_service.dart' @@ -13,23 +14,36 @@ class SaveManager { /// Save current game state to disk. [fileName] may be absolute or relative. /// Returns outcome with error on failure. + /// + /// [monetization] 저장 시 lastPlayTime을 현재 시간으로 자동 업데이트 Future saveState( GameState state, { String? fileName, bool cheatsEnabled = false, + MonetizationState? monetization, }) { - final save = GameSave.fromState(state, cheatsEnabled: cheatsEnabled); + // lastPlayTime을 현재 시간으로 업데이트 + final updatedMonetization = (monetization ?? MonetizationState.initial()) + .copyWith(lastPlayTime: DateTime.now()); + + final save = GameSave.fromState( + state, + cheatsEnabled: cheatsEnabled, + monetization: updatedMonetization, + ); return _repo.save(save, fileName ?? defaultFileName); } /// Load game state from disk. [fileName] may be absolute (e.g., file picker). - /// Returns outcome + optional state + cheatsEnabled flag. - Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async { + /// Returns outcome + optional state + cheatsEnabled flag + monetization state. + Future<(SaveOutcome, GameState?, bool, MonetizationState?)> loadState({ + String? fileName, + }) async { final (outcome, save) = await _repo.load(fileName ?? defaultFileName); if (!outcome.success || save == null) { - return (outcome, null, false); + return (outcome, null, false, null); } - return (outcome, save.toState(), save.cheatsEnabled); + return (outcome, save.toState(), save.cheatsEnabled, save.monetization); } /// 저장 파일 목록 조회 diff --git a/lib/src/features/game/widgets/death_overlay.dart b/lib/src/features/game/widgets/death_overlay.dart index d792d23..0376f11 100644 --- a/lib/src/features/game/widgets/death_overlay.dart +++ b/lib/src/features/game/widgets/death_overlay.dart @@ -8,17 +8,19 @@ import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/item_stats.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; -/// 사망 오버레이 위젯 (Phase 4) +/// 사망 오버레이 위젯 /// /// 플레이어 사망 시 표시되는 전체 화면 오버레이 +/// - 무료 부활: HP 50%, 아이템 희생 +/// - 광고 부활: HP 100%, 아이템 복구, 10분 자동부활 버프 class DeathOverlay extends StatelessWidget { const DeathOverlay({ super.key, required this.deathInfo, required this.traits, required this.onResurrect, - this.isAutoResurrectEnabled = false, - this.onToggleAutoResurrect, + this.onAdRevive, + this.isPaidUser = false, }); /// 사망 정보 @@ -27,14 +29,15 @@ class DeathOverlay extends StatelessWidget { /// 캐릭터 특성 (이름, 직업 등) final Traits traits; - /// 부활 버튼 콜백 + /// 무료 부활 버튼 콜백 (HP 50%, 아이템 희생) final VoidCallback onResurrect; - /// 자동 부활 활성화 여부 - final bool isAutoResurrectEnabled; + /// 광고 부활 버튼 콜백 (HP 100% + 아이템 복구 + 10분 자동부활) + /// null이면 광고 부활 버튼 숨김 + final VoidCallback? onAdRevive; - /// 자동 부활 토글 콜백 - final VoidCallback? onToggleAutoResurrect; + /// 유료 유저 여부 (광고 아이콘 표시용) + final bool isPaidUser; @override Widget build(BuildContext context) { @@ -135,13 +138,13 @@ class DeathOverlay extends StatelessWidget { const SizedBox(height: 24), - // 부활 버튼 + // 일반 부활 버튼 (HP 50%, 아이템 희생) _buildResurrectButton(context), - // 자동 부활 버튼 - if (onToggleAutoResurrect != null) ...[ + // 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활) + if (onAdRevive != null) ...[ const SizedBox(height: 12), - _buildAutoResurrectButton(context), + _buildAdReviveButton(context), ], ], ), @@ -464,77 +467,149 @@ class DeathOverlay extends StatelessWidget { ); } - /// 자동 부활 토글 버튼 - Widget _buildAutoResurrectButton(BuildContext context) { - final mpColor = RetroColors.mpOf(context); - final mpDark = RetroColors.mpDarkOf(context); + /// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활) + Widget _buildAdReviveButton(BuildContext context) { + final gold = RetroColors.goldOf(context); + final goldDark = RetroColors.goldDarkOf(context); final muted = RetroColors.textMutedOf(context); - - // 활성화 상태에 따른 색상 - final buttonColor = isAutoResurrectEnabled ? mpColor : muted; - final buttonDark = isAutoResurrectEnabled - ? mpDark - : muted.withValues(alpha: 0.5); + final hasLostItem = deathInfo.lostItemName != null; + final itemRarityColor = _getRarityColor(deathInfo.lostItemRarity); return GestureDetector( - onTap: onToggleAutoResurrect, + onTap: onAdRevive, child: Container( width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), decoration: BoxDecoration( - color: buttonColor.withValues(alpha: 0.15), + color: gold.withValues(alpha: 0.2), border: Border( - top: BorderSide(color: buttonColor, width: 2), - left: BorderSide(color: buttonColor, width: 2), - bottom: BorderSide( - color: buttonDark.withValues(alpha: 0.8), - width: 2, - ), - right: BorderSide( - color: buttonDark.withValues(alpha: 0.8), - width: 2, - ), + top: BorderSide(color: gold, width: 3), + left: BorderSide(color: gold, width: 3), + bottom: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3), + right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3), ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + child: Column( children: [ - Text( - isAutoResurrectEnabled ? '◉' : '○', - style: TextStyle( - fontSize: 18, - color: buttonColor, - fontWeight: FontWeight.bold, - ), + // 메인 버튼 텍스트 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '✨', + style: TextStyle(fontSize: 20, color: gold), + ), + const SizedBox(width: 8), + Text( + l10n.deathAdRevive.toUpperCase(), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 14, + color: gold, + letterSpacing: 1, + ), + ), + // 광고 뱃지 (무료 유저만) + if (!isPaidUser) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '▶ AD', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: Colors.white, + ), + ), + ), + ], + ], ), - const SizedBox(width: 8), - Text( - l10n.deathAutoResurrect.toUpperCase(), - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - color: buttonColor, - letterSpacing: 1, - ), + const SizedBox(height: 8), + // 혜택 목록 + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // HP 100% 회복 + _buildBenefitRow( + context, + icon: '♥', + text: l10n.deathAdReviveHp, + color: RetroColors.hpOf(context), + ), + const SizedBox(height: 4), + // 아이템 복구 (잃은 아이템이 있을 때만) + if (hasLostItem) ...[ + _buildBenefitRow( + context, + icon: '🔄', + text: '${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}', + color: itemRarityColor, + ), + const SizedBox(height: 4), + ], + // 10분 자동부활 + _buildBenefitRow( + context, + icon: '⏱', + text: l10n.deathAdReviveAuto, + color: RetroColors.mpOf(context), + ), + ], ), - if (isAutoResurrectEnabled) ...[ - const SizedBox(width: 6), + const SizedBox(height: 6), + // 유료 유저 설명 + if (isPaidUser) Text( - 'ON', + l10n.deathAdRevivePaidDesc, style: TextStyle( fontFamily: 'PressStart2P', - fontSize: 13, - color: mpColor, - fontWeight: FontWeight.bold, + fontSize: 9, + color: muted, ), + textAlign: TextAlign.center, ), - ], ], ), ), ); } + /// 혜택 항목 행 + Widget _buildBenefitRow( + BuildContext context, { + required String icon, + required String text, + required Color color, + }) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(icon, style: TextStyle(fontSize: 14, color: color)), + const SizedBox(width: 6), + Flexible( + child: Text( + text, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: color, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + /// 사망 직전 전투 로그 표시 Widget _buildCombatLog(BuildContext context) { final events = deathInfo.lastCombatEvents;