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

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