diff --git a/lib/src/core/engine/progress_loop.dart b/lib/src/core/engine/progress_loop.dart index d17c332..2600666 100644 --- a/lib/src/core/engine/progress_loop.dart +++ b/lib/src/core/engine/progress_loop.dart @@ -10,6 +10,7 @@ class AutoSaveConfig { this.onQuestComplete = true, this.onActComplete = true, this.onStop = true, + this.onDeath = true, }); final bool onLevelUp; @@ -17,10 +18,14 @@ class AutoSaveConfig { final bool onActComplete; final bool onStop; + /// 사망 시 자동 저장 (Phase 4) + final bool onDeath; + bool shouldSave(ProgressTickResult result) { return (onLevelUp && result.leveledUp) || (onQuestComplete && result.completedQuest) || - (onActComplete && result.completedAct); + (onActComplete && result.completedAct) || + (onDeath && result.playerDied); } } @@ -34,6 +39,7 @@ class ProgressLoop { AutoSaveConfig autoSaveConfig = const AutoSaveConfig(), DateTime Function()? now, this.cheatsEnabled = false, + this.onPlayerDied, }) : _state = initialState, _tickInterval = tickInterval, _autoSaveConfig = autoSaveConfig, @@ -43,6 +49,9 @@ class ProgressLoop { final ProgressService progressService; final SaveManager? saveManager; final Duration _tickInterval; + + /// 플레이어 사망 시 콜백 (Phase 4) + final void Function()? onPlayerDied; final AutoSaveConfig _autoSaveConfig; final DateTime Function() _now; final StreamController _stateController; @@ -88,6 +97,11 @@ class ProgressLoop { /// Run one iteration of the loop (used by Timer or manual stepping). GameState tickOnce({int? deltaMillis}) { + // 사망 상태면 틱 진행 안 함 (Phase 4) + if (_state.isDead) { + return _state; + } + final baseDelta = deltaMillis ?? _computeDelta(); final delta = baseDelta * _speedMultiplier; final result = progressService.tick(_state, delta); @@ -97,6 +111,14 @@ class ProgressLoop { if (saveManager != null && _autoSaveConfig.shouldSave(result)) { saveManager!.saveState(_state); } + + // 사망 시 루프 정지 및 콜백 호출 (Phase 4) + if (result.playerDied) { + _timer?.cancel(); + _timer = null; + onPlayerDied?.call(); + } + return _state; } diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 3624387..a7137c5 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -8,6 +8,8 @@ import 'package:askiineverdie/src/core/engine/reward_service.dart'; import 'package:askiineverdie/src/core/engine/skill_service.dart'; import 'package:askiineverdie/src/core/model/combat_state.dart'; import 'package:askiineverdie/src/core/model/combat_stats.dart'; +import 'package:askiineverdie/src/core/model/equipment_item.dart'; +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/monster_combat_stats.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart'; @@ -19,6 +21,7 @@ class ProgressTickResult { this.leveledUp = false, this.completedQuest = false, this.completedAct = false, + this.playerDied = false, }); final GameState state; @@ -26,7 +29,11 @@ class ProgressTickResult { final bool completedQuest; final bool completedAct; - bool get shouldAutosave => leveledUp || completedQuest || completedAct; + /// 플레이어 사망 여부 (Phase 4) + final bool playerDied; + + bool get shouldAutosave => + leveledUp || completedQuest || completedAct || playerDied; } /// Drives quest/plot/task progression by applying queued actions and rewards. @@ -190,6 +197,17 @@ class ProgressService { ); updatedCombat = combatResult.combat; updatedSkillSystem = combatResult.skillSystem; + + // Phase 4: 플레이어 사망 체크 + if (!updatedCombat.playerStats.isAlive) { + final monsterName = updatedCombat.monsterStats.name; + nextState = _processPlayerDeath( + nextState, + killerName: monsterName, + cause: DeathCause.monster, + ); + return ProgressTickResult(state: nextState, playerDied: true); + } } progress = progress.copyWith( @@ -947,4 +965,55 @@ class ProgressService { skillSystem: updatedSkillSystem, ); } + + /// 플레이어 사망 처리 (Phase 4) + /// + /// 모든 장비 상실 및 사망 정보 기록 + GameState _processPlayerDeath( + GameState state, { + required String killerName, + required DeathCause cause, + }) { + // 상실할 장비 개수 계산 + final lostCount = state.equipment.equippedItems.length; + + // 빈 장비 생성 (기본 무기만 유지) + final emptyEquipment = Equipment( + items: [ + EquipmentItem.defaultWeapon(), + EquipmentItem.empty(EquipmentSlot.shield), + EquipmentItem.empty(EquipmentSlot.helm), + EquipmentItem.empty(EquipmentSlot.hauberk), + EquipmentItem.empty(EquipmentSlot.brassairts), + EquipmentItem.empty(EquipmentSlot.vambraces), + EquipmentItem.empty(EquipmentSlot.gauntlets), + EquipmentItem.empty(EquipmentSlot.gambeson), + EquipmentItem.empty(EquipmentSlot.cuisses), + EquipmentItem.empty(EquipmentSlot.greaves), + EquipmentItem.empty(EquipmentSlot.sollerets), + ], + bestIndex: 0, + ); + + // 사망 정보 생성 + final deathInfo = DeathInfo( + cause: cause, + killerName: killerName, + lostEquipmentCount: lostCount, + goldAtDeath: state.inventory.gold, + levelAtDeath: state.traits.level, + timestamp: state.skillSystem.elapsedMs, + ); + + // 전투 상태 초기화 + final progress = state.progress.copyWith( + currentCombat: null, + ); + + return state.copyWith( + equipment: emptyEquipment, + progress: progress, + deathInfo: deathInfo, + ); + } } diff --git a/lib/src/core/engine/resurrection_service.dart b/lib/src/core/engine/resurrection_service.dart new file mode 100644 index 0000000..985a01d --- /dev/null +++ b/lib/src/core/engine/resurrection_service.dart @@ -0,0 +1,182 @@ +import 'package:askiineverdie/src/core/engine/shop_service.dart'; +import 'package:askiineverdie/src/core/model/equipment_item.dart'; +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; + +/// 부활 시스템 서비스 (Phase 4) +/// +/// 사망 처리, 부활 처리, 장비 상실 등을 담당 +class ResurrectionService { + const ResurrectionService({required this.shopService}); + + final ShopService shopService; + + // ============================================================================ + // 사망 처리 + // ============================================================================ + + /// 플레이어 사망 처리 + /// + /// 1. 모든 장비 제거 (인벤토리로 이동하지 않음 - 상실) + /// 2. 전투 상태 초기화 + /// 3. 사망 정보 기록 + GameState processDeath({ + required GameState state, + required String killerName, + required DeathCause cause, + }) { + // 상실할 장비 개수 계산 + final lostCount = state.equipment.equippedItems.length; + + // 빈 장비 생성 (기본 무기만 유지) + final emptyEquipment = Equipment( + items: [ + EquipmentItem.defaultWeapon(), // 무기 슬롯에 기본 Keyboard + EquipmentItem.empty(EquipmentSlot.shield), + EquipmentItem.empty(EquipmentSlot.helm), + EquipmentItem.empty(EquipmentSlot.hauberk), + EquipmentItem.empty(EquipmentSlot.brassairts), + EquipmentItem.empty(EquipmentSlot.vambraces), + EquipmentItem.empty(EquipmentSlot.gauntlets), + EquipmentItem.empty(EquipmentSlot.gambeson), + EquipmentItem.empty(EquipmentSlot.cuisses), + EquipmentItem.empty(EquipmentSlot.greaves), + EquipmentItem.empty(EquipmentSlot.sollerets), + ], + bestIndex: 0, + ); + + // 사망 정보 생성 + final deathInfo = DeathInfo( + cause: cause, + killerName: killerName, + lostEquipmentCount: lostCount, + goldAtDeath: state.inventory.gold, + levelAtDeath: state.traits.level, + timestamp: state.skillSystem.elapsedMs, + ); + + // 전투 상태 초기화 + final progress = state.progress.copyWith( + currentCombat: null, + ); + + return state.copyWith( + equipment: emptyEquipment, + progress: progress, + deathInfo: deathInfo, + ); + } + + // ============================================================================ + // 부활 처리 + // ============================================================================ + + /// 플레이어 부활 처리 + /// + /// 1. HP/MP 전체 회복 + /// 2. 골드로 구매 가능한 장비 자동 구매 + /// 3. 사망 상태 해제 + /// 4. 안전 지역으로 이동 태스크 설정 + GameState processResurrection(GameState state) { + if (!state.isDead) return state; + + // HP/MP 전체 회복 + var nextState = state.copyWith( + stats: state.stats.copyWith( + hpCurrent: state.stats.hpMax, + mpCurrent: state.stats.mpMax, + ), + ); + + // 빈 슬롯에 자동 장비 구매 + final autoBuyResult = shopService.autoBuyForEmptySlots( + playerLevel: nextState.traits.level, + currentGold: nextState.inventory.gold, + currentEquipment: nextState.equipment, + ); + + // 결과 적용 + nextState = nextState.copyWith( + equipment: autoBuyResult.updatedEquipment, + inventory: nextState.inventory.copyWith( + gold: autoBuyResult.remainingGold, + ), + clearDeathInfo: true, // 사망 상태 해제 + ); + + // 스킬 쿨타임 초기화 + nextState = nextState.copyWith( + skillSystem: SkillSystemState.empty().copyWith( + elapsedMs: nextState.skillSystem.elapsedMs, + ), + ); + + return nextState; + } + + // ============================================================================ + // 유틸리티 + // ============================================================================ + + /// 부활 비용 계산 (향후 확장용) + /// + /// 현재는 무료 부활, 향후 비용 도입 가능 + int calculateResurrectionCost(int playerLevel) { + // 기본: 무료 + return 0; + } + + /// 부활 가능 여부 확인 + bool canResurrect(GameState state) { + if (!state.isDead) return false; + + final cost = calculateResurrectionCost(state.traits.level); + return state.inventory.gold >= cost; + } + + /// 장비 보존 아이템 적용 (향후 확장용) + /// + /// [protectedSlots] 보존할 슬롯 인덱스 목록 + GameState processDeathWithProtection({ + required GameState state, + required String killerName, + required DeathCause cause, + required List protectedSlots, + }) { + // 보존할 아이템 추출 + final protectedItems = {}; + for (final slotIndex in protectedSlots) { + if (slotIndex >= 0 && slotIndex < Equipment.slotCount) { + final item = state.equipment.getItemByIndex(slotIndex); + if (item.isNotEmpty) { + protectedItems[slotIndex] = item; + } + } + } + + // 기본 사망 처리 + var nextState = processDeath( + state: state, + killerName: killerName, + cause: cause, + ); + + // 보존된 아이템 복원 + var equipment = nextState.equipment; + for (final entry in protectedItems.entries) { + equipment = equipment.setItemByIndex(entry.key, entry.value); + } + + // 상실 개수 재계산 + final actualLostCount = + state.equipment.equippedItems.length - protectedItems.length; + + return nextState.copyWith( + equipment: equipment, + deathInfo: nextState.deathInfo?.copyWith( + lostEquipmentCount: actualLostCount, + ), + ); + } +} diff --git a/lib/src/core/engine/shop_service.dart b/lib/src/core/engine/shop_service.dart new file mode 100644 index 0000000..557ff55 --- /dev/null +++ b/lib/src/core/engine/shop_service.dart @@ -0,0 +1,333 @@ +import 'package:askiineverdie/src/core/model/equipment_item.dart'; +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/item_stats.dart'; +import 'package:askiineverdie/src/core/util/deterministic_random.dart'; + +/// 샵 시스템 서비스 (Phase 4) +/// +/// 장비 구매, 판매, 자동 장착 등을 담당 +class ShopService { + const ShopService({required this.rng}); + + final DeterministicRandom rng; + + // ============================================================================ + // 가격 계산 + // ============================================================================ + + /// 장비 구매 가격 계산 + /// + /// 가격 = 아이템 레벨 * 50 * 희귀도 배율 + int calculateBuyPrice(EquipmentItem item) { + if (item.isEmpty) return 0; + return (item.level * 50 * _getRarityPriceMultiplier(item.rarity)).round(); + } + + /// 장비 판매 가격 계산 + /// + /// 판매 가격 = 구매 가격 * 0.3 + int calculateSellPrice(EquipmentItem item) { + return (calculateBuyPrice(item) * 0.3).round(); + } + + /// 희귀도별 가격 배율 (Phase 4 문서 기준) + double _getRarityPriceMultiplier(ItemRarity rarity) { + return switch (rarity) { + ItemRarity.common => 1.0, + ItemRarity.uncommon => 2.0, + ItemRarity.rare => 5.0, + ItemRarity.epic => 15.0, + ItemRarity.legendary => 50.0, + }; + } + + // ============================================================================ + // 장비 생성 + // ============================================================================ + + /// 특정 슬롯의 샵 장비 생성 + /// + /// [playerLevel] 플레이어 레벨 (장비 레벨 범위 결정) + /// [slot] 장비 슬롯 + /// [targetRarity] 목표 희귀도 (null이면 랜덤) + EquipmentItem generateShopItem({ + required int playerLevel, + required EquipmentSlot slot, + ItemRarity? targetRarity, + }) { + // 아이템 레벨: 플레이어 레벨 기준 ±2 + final minLevel = (playerLevel - 2).clamp(1, 999); + final maxLevel = playerLevel + 2; + final itemLevel = minLevel + rng.nextInt(maxLevel - minLevel + 1); + + // 희귀도 결정 (샵 아이템은 Common~Rare만) + final rarity = targetRarity ?? _rollShopRarity(); + + // 아이템 이름 생성 + final name = _generateItemName(slot, rarity, itemLevel); + + // 스탯 생성 + final stats = _generateItemStats(slot, itemLevel, rarity); + + // 무게 계산 + final weight = _calculateWeight(slot, itemLevel); + + return EquipmentItem( + name: name, + slot: slot, + level: itemLevel, + weight: weight, + stats: stats, + rarity: rarity, + ); + } + + /// 샵 희귀도 롤 (Common 70%, Uncommon 25%, Rare 5%) + ItemRarity _rollShopRarity() { + final roll = rng.nextInt(100); + if (roll < 70) return ItemRarity.common; + if (roll < 95) return ItemRarity.uncommon; + return ItemRarity.rare; + } + + /// 슬롯별 아이템 이름 생성 + String _generateItemName(EquipmentSlot slot, ItemRarity rarity, int level) { + final prefix = _getRarityPrefix(rarity); + final baseName = _getSlotBaseName(slot); + final suffix = level > 10 ? ' +${level ~/ 10}' : ''; + return '$prefix$baseName$suffix'.trim(); + } + + String _getRarityPrefix(ItemRarity rarity) { + return switch (rarity) { + ItemRarity.common => '', + ItemRarity.uncommon => 'Fine ', + ItemRarity.rare => 'Superior ', + ItemRarity.epic => 'Epic ', + ItemRarity.legendary => 'Legendary ', + }; + } + + String _getSlotBaseName(EquipmentSlot slot) { + return switch (slot) { + EquipmentSlot.weapon => 'Keyboard', + EquipmentSlot.shield => 'Firewall Shield', + EquipmentSlot.helm => 'Neural Headset', + EquipmentSlot.hauberk => 'Server Rack Armor', + EquipmentSlot.brassairts => 'Cable Brassairts', + EquipmentSlot.vambraces => 'USB Vambraces', + EquipmentSlot.gauntlets => 'Typing Gauntlets', + EquipmentSlot.gambeson => 'Padded Gambeson', + EquipmentSlot.cuisses => 'Circuit Cuisses', + EquipmentSlot.greaves => 'Copper Greaves', + EquipmentSlot.sollerets => 'Static Boots', + }; + } + + /// 슬롯과 레벨에 따른 스탯 생성 + ItemStats _generateItemStats(EquipmentSlot slot, int level, ItemRarity rarity) { + final multiplier = rarity.multiplier; + final baseValue = (level * multiplier).round(); + + return switch (slot) { + EquipmentSlot.weapon => ItemStats( + atk: baseValue * 2, + criRate: 0.01 * (level ~/ 5), + parryRate: 0.005 * level, + ), + EquipmentSlot.shield => ItemStats( + def: baseValue, + blockRate: 0.02 * (level ~/ 3).clamp(1, 10), + ), + EquipmentSlot.helm => ItemStats( + def: baseValue ~/ 2, + magDef: baseValue ~/ 2, + intBonus: level ~/ 10, + ), + EquipmentSlot.hauberk => ItemStats( + def: baseValue, + hpBonus: level * 2, + ), + EquipmentSlot.brassairts => ItemStats( + def: baseValue ~/ 2, + strBonus: level ~/ 15, + ), + EquipmentSlot.vambraces => ItemStats( + def: baseValue ~/ 2, + dexBonus: level ~/ 15, + ), + EquipmentSlot.gauntlets => ItemStats( + atk: baseValue ~/ 2, + def: baseValue ~/ 4, + ), + EquipmentSlot.gambeson => ItemStats( + def: baseValue ~/ 2, + conBonus: level ~/ 15, + ), + EquipmentSlot.cuisses => ItemStats( + def: baseValue ~/ 2, + evasion: 0.005 * level, + ), + EquipmentSlot.greaves => ItemStats( + def: baseValue ~/ 2, + evasion: 0.003 * level, + ), + EquipmentSlot.sollerets => ItemStats( + def: baseValue ~/ 3, + evasion: 0.002 * level, + dexBonus: level ~/ 20, + ), + }; + } + + /// 무게 계산 + int _calculateWeight(EquipmentSlot slot, int level) { + final baseWeight = switch (slot) { + EquipmentSlot.weapon => 5, + EquipmentSlot.shield => 8, + EquipmentSlot.helm => 4, + EquipmentSlot.hauberk => 15, + EquipmentSlot.brassairts => 3, + EquipmentSlot.vambraces => 3, + EquipmentSlot.gauntlets => 2, + EquipmentSlot.gambeson => 6, + EquipmentSlot.cuisses => 5, + EquipmentSlot.greaves => 4, + EquipmentSlot.sollerets => 3, + }; + return baseWeight + (level ~/ 10); + } + + // ============================================================================ + // 자동 구매 + // ============================================================================ + + /// 빈 슬롯에 대해 자동으로 장비 구매 + /// + /// Returns: 구매 결과 + AutoBuyResult autoBuyForEmptySlots({ + required int playerLevel, + required int currentGold, + required Equipment currentEquipment, + }) { + var remainingGold = currentGold; + final purchasedItems = []; + var updatedEquipment = currentEquipment; + + // 각 슬롯 순회하며 빈 슬롯에 구매 + for (var i = 0; i < Equipment.slotCount; i++) { + final currentItem = currentEquipment.getItemByIndex(i); + if (currentItem.isNotEmpty) continue; + + final slot = EquipmentSlot.values[i]; + + // 구매 가능한 아이템 생성 시도 + final shopItem = generateShopItem( + playerLevel: playerLevel, + slot: slot, + targetRarity: ItemRarity.common, // 부활 시 Common만 구매 + ); + + final price = calculateBuyPrice(shopItem); + if (price <= remainingGold) { + remainingGold -= price; + purchasedItems.add(shopItem); + updatedEquipment = updatedEquipment.setItemByIndex(i, shopItem); + } + } + + return AutoBuyResult( + purchasedItems: purchasedItems, + totalCost: currentGold - remainingGold, + remainingGold: remainingGold, + updatedEquipment: updatedEquipment, + ); + } + + /// 특정 슬롯에 장비 구매 + /// + /// Returns: 구매 성공 시 결과, 실패 시 null + PurchaseResult? buyItem({ + required int playerLevel, + required int currentGold, + required EquipmentSlot slot, + ItemRarity? preferredRarity, + }) { + final item = generateShopItem( + playerLevel: playerLevel, + slot: slot, + targetRarity: preferredRarity, + ); + + final price = calculateBuyPrice(item); + if (price > currentGold) return null; + + return PurchaseResult( + item: item, + price: price, + remainingGold: currentGold - price, + ); + } + + /// 장비 판매 + SellResult sellItem(EquipmentItem item, int currentGold) { + final price = calculateSellPrice(item); + return SellResult( + item: item, + price: price, + newGold: currentGold + price, + ); + } +} + +/// 자동 구매 결과 +class AutoBuyResult { + const AutoBuyResult({ + required this.purchasedItems, + required this.totalCost, + required this.remainingGold, + required this.updatedEquipment, + }); + + /// 구매한 아이템 목록 + final List purchasedItems; + + /// 총 비용 + final int totalCost; + + /// 남은 골드 + final int remainingGold; + + /// 업데이트된 장비 + final Equipment updatedEquipment; + + /// 구매한 아이템 개수 + int get purchasedCount => purchasedItems.length; +} + +/// 단일 구매 결과 +class PurchaseResult { + const PurchaseResult({ + required this.item, + required this.price, + required this.remainingGold, + }); + + final EquipmentItem item; + final int price; + final int remainingGold; +} + +/// 판매 결과 +class SellResult { + const SellResult({ + required this.item, + required this.price, + required this.newGold, + }); + + final EquipmentItem item; + final int price; + final int newGold; +} diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart index b250119..e164faa 100644 --- a/lib/src/core/model/game_state.dart +++ b/lib/src/core/model/game_state.dart @@ -22,6 +22,7 @@ class GameState { ProgressState? progress, QueueState? queue, SkillSystemState? skillSystem, + this.deathInfo, }) : rng = DeterministicRandom.clone(rng), traits = traits ?? Traits.empty(), stats = stats ?? Stats.empty(), @@ -42,6 +43,7 @@ class GameState { ProgressState? progress, QueueState? queue, SkillSystemState? skillSystem, + DeathInfo? deathInfo, }) { return GameState( rng: DeterministicRandom(seed), @@ -53,6 +55,7 @@ class GameState { progress: progress, queue: queue, skillSystem: skillSystem, + deathInfo: deathInfo, ); } @@ -68,6 +71,12 @@ class GameState { /// 스킬 시스템 상태 (Phase 3) final SkillSystemState skillSystem; + /// 사망 정보 (Phase 4, null이면 생존 중) + final DeathInfo? deathInfo; + + /// 사망 여부 + bool get isDead => deathInfo != null; + GameState copyWith({ DeterministicRandom? rng, Traits? traits, @@ -78,6 +87,8 @@ class GameState { ProgressState? progress, QueueState? queue, SkillSystemState? skillSystem, + DeathInfo? deathInfo, + bool clearDeathInfo = false, }) { return GameState( rng: rng ?? DeterministicRandom.clone(this.rng), @@ -89,10 +100,73 @@ class GameState { progress: progress ?? this.progress, queue: queue ?? this.queue, skillSystem: skillSystem ?? this.skillSystem, + deathInfo: clearDeathInfo ? null : (deathInfo ?? this.deathInfo), ); } } +/// 사망 정보 (Phase 4) +/// +/// 사망 시점의 정보와 상실한 장비 목록을 기록 +class DeathInfo { + const DeathInfo({ + required this.cause, + required this.killerName, + required this.lostEquipmentCount, + required this.goldAtDeath, + required this.levelAtDeath, + required this.timestamp, + }); + + /// 사망 원인 + final DeathCause cause; + + /// 사망시킨 몬스터/원인 이름 + final String killerName; + + /// 상실한 장비 개수 + final int lostEquipmentCount; + + /// 사망 시점 골드 + final int goldAtDeath; + + /// 사망 시점 레벨 + final int levelAtDeath; + + /// 사망 시각 (밀리초) + final int timestamp; + + DeathInfo copyWith({ + DeathCause? cause, + String? killerName, + int? lostEquipmentCount, + int? goldAtDeath, + int? levelAtDeath, + int? timestamp, + }) { + return DeathInfo( + cause: cause ?? this.cause, + killerName: killerName ?? this.killerName, + lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount, + goldAtDeath: goldAtDeath ?? this.goldAtDeath, + levelAtDeath: levelAtDeath ?? this.levelAtDeath, + timestamp: timestamp ?? this.timestamp, + ); + } +} + +/// 사망 원인 +enum DeathCause { + /// 몬스터에 의한 사망 + monster, + + /// 자해 스킬에 의한 사망 + selfDamage, + + /// 환경 피해에 의한 사망 + environment, +} + /// 스킬 시스템 상태 (Phase 3) /// /// 스킬 쿨타임, 활성 버프, 게임 경과 시간 등을 관리 diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index 1c6e06b..00f6f30 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:askiineverdie/src/core/engine/progress_loop.dart'; import 'package:askiineverdie/src/core/engine/progress_service.dart'; +import 'package:askiineverdie/src/core/engine/resurrection_service.dart'; +import 'package:askiineverdie/src/core/engine/shop_service.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/storage/save_manager.dart'; import 'package:flutter/foundation.dart'; -enum GameSessionStatus { idle, loading, running, error } +enum GameSessionStatus { idle, loading, running, error, dead } /// Presentation-friendly wrapper that owns ProgressLoop and SaveManager. class GameSessionController extends ChangeNotifier { @@ -68,6 +70,7 @@ class GameSessionController extends ChangeNotifier { tickInterval: _tickInterval, now: _now, cheatsEnabled: cheatsEnabled, + onPlayerDied: _onPlayerDied, ); _subscription = _loop!.stream.listen((next) { @@ -138,4 +141,36 @@ class GameSessionController extends ChangeNotifier { if (loop == null) return null; return loop.stop(saveOnStop: saveOnStop); } + + // ============================================================================ + // Phase 4: 사망/부활 처리 + // ============================================================================ + + /// 플레이어 사망 콜백 (ProgressLoop에서 호출) + void _onPlayerDied() { + _status = GameSessionStatus.dead; + notifyListeners(); + } + + /// 플레이어 부활 처리 + /// + /// HP/MP 회복, 빈 슬롯에 장비 자동 구매, 게임 재개 + Future resurrect() async { + if (_state == null || !_state!.isDead) return; + + // ResurrectionService를 사용하여 부활 처리 + final shopService = ShopService(rng: _state!.rng); + final resurrectionService = ResurrectionService(shopService: shopService); + + final resurrectedState = resurrectionService.processResurrection(_state!); + + // 저장 + await saveManager.saveState(resurrectedState); + + // 게임 재개 + await startNew(resurrectedState, cheatsEnabled: _cheatsEnabled, isNewGame: false); + } + + /// 사망 상태 여부 + bool get isDead => _status == GameSessionStatus.dead || (_state?.isDead ?? false); } diff --git a/lib/src/features/game/widgets/death_overlay.dart b/lib/src/features/game/widgets/death_overlay.dart new file mode 100644 index 0000000..c83434c --- /dev/null +++ b/lib/src/features/game/widgets/death_overlay.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/src/core/model/game_state.dart'; + +/// 사망 오버레이 위젯 (Phase 4) +/// +/// 플레이어 사망 시 표시되는 전체 화면 오버레이 +class DeathOverlay extends StatelessWidget { + const DeathOverlay({ + super.key, + required this.deathInfo, + required this.traits, + required this.onResurrect, + }); + + /// 사망 정보 + final DeathInfo deathInfo; + + /// 캐릭터 특성 (이름, 직업 등) + final Traits traits; + + /// 부활 버튼 콜백 + final VoidCallback onResurrect; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Material( + color: Colors.black87, + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + margin: const EdgeInsets.all(24), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.error.withValues(alpha: 0.5), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: colorScheme.error.withValues(alpha: 0.3), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 사망 타이틀 + _buildDeathTitle(context), + const SizedBox(height: 16), + + // 캐릭터 정보 + _buildCharacterInfo(context), + const SizedBox(height: 16), + + // 사망 원인 + _buildDeathCause(context), + const SizedBox(height: 24), + + // 구분선 + Divider(color: colorScheme.outlineVariant), + const SizedBox(height: 16), + + // 상실 정보 + _buildLossInfo(context), + const SizedBox(height: 24), + + // 부활 버튼 + _buildResurrectButton(context), + ], + ), + ), + ), + ); + } + + Widget _buildDeathTitle(BuildContext context) { + return Column( + children: [ + // ASCII 스컬 + Text( + ' _____\n / \\\n| () () |\n \\ ^ /\n |||||', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 14, + color: Theme.of(context).colorScheme.error, + height: 1.0, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'YOU DIED', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.error, + letterSpacing: 4, + ), + ), + ], + ); + } + + Widget _buildCharacterInfo(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + Text( + traits.name, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Level ${deathInfo.levelAtDeath} ${traits.klass}', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + Widget _buildDeathCause(BuildContext context) { + final theme = Theme.of(context); + final causeText = _getDeathCauseText(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.dangerous_outlined, + size: 20, + color: theme.colorScheme.error, + ), + const SizedBox(width: 8), + Text( + causeText, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ), + ); + } + + String _getDeathCauseText() { + return switch (deathInfo.cause) { + DeathCause.monster => 'Killed by ${deathInfo.killerName}', + DeathCause.selfDamage => 'Self-inflicted damage', + DeathCause.environment => 'Environmental hazard', + }; + } + + Widget _buildLossInfo(BuildContext context) { + return Column( + children: [ + _buildInfoRow( + context, + icon: Icons.shield_outlined, + label: 'Equipment Lost', + value: '${deathInfo.lostEquipmentCount} items', + isNegative: true, + ), + const SizedBox(height: 8), + _buildInfoRow( + context, + icon: Icons.monetization_on_outlined, + label: 'Gold Remaining', + value: _formatGold(deathInfo.goldAtDeath), + isNegative: false, + ), + ], + ); + } + + Widget _buildInfoRow( + BuildContext context, { + required IconData icon, + required String label, + required String value, + required bool isNegative, + }) { + final theme = Theme.of(context); + final valueColor = isNegative + ? theme.colorScheme.error + : theme.colorScheme.primary; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + color: valueColor, + ), + ), + ], + ); + } + + String _formatGold(int gold) { + if (gold >= 1000000) { + return '${(gold / 1000000).toStringAsFixed(1)}M'; + } else if (gold >= 1000) { + return '${(gold / 1000).toStringAsFixed(1)}K'; + } + return gold.toString(); + } + + Widget _buildResurrectButton(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onResurrect, + icon: const Icon(Icons.replay), + label: const Text('Resurrect'), + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ); + } +}