feat(death): Phase 4 사망/부활 시스템 구현

- DeathInfo, DeathCause 클래스 정의 (game_state.dart)
  - 사망 원인, 상실 장비 수, 사망 시점 정보 기록
- ShopService 구현 (shop_service.dart)
  - 장비 가격 계산 (레벨 * 50 * 희귀도 배율)
  - 슬롯별 장비 생성 (프로그래밍 테마)
  - 자동 구매 (빈 슬롯에 Common 장비)
- ResurrectionService 구현 (resurrection_service.dart)
  - 사망 처리: 모든 장비 상실, 기본 무기만 유지
  - 부활 처리: HP/MP 회복, 자동 장비 구매
- progress_service.dart 사망 판정 로직 추가
  - 전투 중 HP <= 0 시 사망 처리
  - ProgressTickResult에 playerDied 플래그 추가
- progress_loop.dart 사망 시 루프 정지
  - onPlayerDied 콜백 추가
  - 사망 상태에서 틱 진행 방지
- DeathOverlay 위젯 구현 (death_overlay.dart)
  - ASCII 스컬 아트, 사망 원인, 상실 정보 표시
  - 부활 버튼
- GameSessionController 사망/부활 상태 관리
  - GameSessionStatus.dead 상태 추가
  - resurrect() 메서드로 부활 처리
This commit is contained in:
JiWoong Sul
2025-12-17 17:15:22 +09:00
parent 517bf54a56
commit 21bf057cfc
7 changed files with 974 additions and 3 deletions

View File

@@ -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<GameState> _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;
}

View File

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

View File

@@ -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<int> protectedSlots,
}) {
// 보존할 아이템 추출
final protectedItems = <int, EquipmentItem>{};
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,
),
);
}
}

View File

@@ -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 = <EquipmentItem>[];
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<EquipmentItem> 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;
}

View File

@@ -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)
///
/// 스킬 쿨타임, 활성 버프, 게임 경과 시간 등을 관리

View File

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

View File

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