feat(death): 사망/부활 시스템 개선

- DeathInfo에 lostItem 필드 추가 (광고 부활 시 복구용)
- 세이브 데이터 v4: MonetizationState 포함
- 사망 오버레이 UI 개선
- 부활 서비스 광고 연동
This commit is contained in:
JiWoong Sul
2026-01-16 20:09:52 +09:00
parent b272ef8f08
commit b6d5cd2abd
5 changed files with 309 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

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