feat(hall): Phase 10 명예의 전당 시스템 구현

- HallOfFameEntry 모델 및 HallOfFame 컬렉션 추가
- HallOfFameStorage 저장소 (JSON 파일 기반)
- HallOfFameScreen UI (순위별 색상/아이콘)
- 게임 클리어 시 명예의 전당 등록 처리
- FrontScreen에 명예의 전당 버튼 추가
- 클리어 축하 다이얼로그 구현
This commit is contained in:
JiWoong Sul
2025-12-17 18:57:26 +09:00
parent 7c7f3b0d9e
commit 9af5c4dc13
6 changed files with 814 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import 'package:askiineverdie/src/features/front/front_screen.dart';
import 'package:askiineverdie/src/features/front/save_picker_dialog.dart';
import 'package:askiineverdie/src/features/game/game_play_screen.dart';
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:askiineverdie/src/features/new_character/new_character_screen.dart';
class AskiiNeverDieApp extends StatefulWidget {
@@ -69,6 +70,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
home: FrontScreen(
onNewCharacter: _navigateToNewCharacter,
onLoadSave: _loadSave,
onHallOfFame: _navigateToHallOfFame,
),
);
}
@@ -152,4 +154,13 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
),
);
}
/// Phase 10: 명예의 전당 화면으로 이동
void _navigateToHallOfFame(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => const HallOfFameScreen(),
),
);
}
}

View File

@@ -0,0 +1,192 @@
import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
/// 명예의 전당 엔트리 (Phase 10: Hall of Fame Entry)
///
/// 게임 클리어 시 저장되는 캐릭터 정보
class HallOfFameEntry {
const HallOfFameEntry({
required this.id,
required this.characterName,
required this.race,
required this.klass,
required this.level,
required this.totalPlayTimeMs,
required this.totalDeaths,
required this.monstersKilled,
required this.questsCompleted,
required this.clearedAt,
this.finalStats,
this.finalEquipment,
});
/// 고유 ID (UUID)
final String id;
/// 캐릭터 이름
final String characterName;
/// 종족
final String race;
/// 클래스
final String klass;
/// 최종 레벨
final int level;
/// 총 플레이 시간 (밀리초)
final int totalPlayTimeMs;
/// 총 사망 횟수
final int totalDeaths;
/// 처치한 몬스터 수
final int monstersKilled;
/// 완료한 퀘스트 수
final int questsCompleted;
/// 클리어 일시
final DateTime clearedAt;
/// 최종 전투 스탯 (향후 아스키 아레나용)
final CombatStats? finalStats;
/// 최종 장비 목록 (향후 아스키 아레나용)
final Map<String, String>? finalEquipment;
/// 플레이 시간을 Duration으로 변환
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
/// 플레이 시간 포맷팅 (HH:MM:SS)
String get formattedPlayTime {
final hours = totalPlayTime.inHours;
final minutes = totalPlayTime.inMinutes % 60;
final seconds = totalPlayTime.inSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// 클리어 날짜 포맷팅 (YYYY.MM.DD)
String get formattedClearedDate {
return '${clearedAt.year}.${clearedAt.month.toString().padLeft(2, '0')}.'
'${clearedAt.day.toString().padLeft(2, '0')}';
}
/// GameState에서 HallOfFameEntry 생성
factory HallOfFameEntry.fromGameState({
required GameState state,
required int totalDeaths,
required int monstersKilled,
CombatStats? combatStats,
}) {
return HallOfFameEntry(
id: DateTime.now().millisecondsSinceEpoch.toString(),
characterName: state.traits.name,
race: state.traits.race,
klass: state.traits.klass,
level: state.traits.level,
totalPlayTimeMs: state.skillSystem.elapsedMs,
totalDeaths: totalDeaths,
monstersKilled: monstersKilled,
questsCompleted: state.progress.questCount,
clearedAt: DateTime.now(),
finalStats: combatStats,
finalEquipment: {
'weapon': state.equipment.weapon,
'shield': state.equipment.shield,
'helm': state.equipment.helm,
'hauberk': state.equipment.hauberk,
'brassairts': state.equipment.brassairts,
'vambraces': state.equipment.vambraces,
'gauntlets': state.equipment.gauntlets,
'gambeson': state.equipment.gambeson,
'cuisses': state.equipment.cuisses,
'greaves': state.equipment.greaves,
'sollerets': state.equipment.sollerets,
},
);
}
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'id': id,
'characterName': characterName,
'race': race,
'klass': klass,
'level': level,
'totalPlayTimeMs': totalPlayTimeMs,
'totalDeaths': totalDeaths,
'monstersKilled': monstersKilled,
'questsCompleted': questsCompleted,
'clearedAt': clearedAt.toIso8601String(),
'finalEquipment': finalEquipment,
};
}
/// JSON에서 역직렬화
factory HallOfFameEntry.fromJson(Map<String, dynamic> json) {
return HallOfFameEntry(
id: json['id'] as String,
characterName: json['characterName'] as String,
race: json['race'] as String,
klass: json['klass'] as String,
level: json['level'] as int,
totalPlayTimeMs: json['totalPlayTimeMs'] as int,
totalDeaths: json['totalDeaths'] as int? ?? 0,
monstersKilled: json['monstersKilled'] as int? ?? 0,
questsCompleted: json['questsCompleted'] as int? ?? 0,
clearedAt: DateTime.parse(json['clearedAt'] as String),
finalEquipment: json['finalEquipment'] != null
? Map<String, String>.from(json['finalEquipment'] as Map)
: null,
);
}
}
/// 명예의 전당 (Hall of Fame)
///
/// 클리어한 캐릭터 목록 관리
class HallOfFame {
const HallOfFame({required this.entries});
/// 명예의 전당 엔트리 목록 (클리어 시간 역순)
final List<HallOfFameEntry> entries;
/// 빈 명예의 전당
factory HallOfFame.empty() => const HallOfFame(entries: []);
/// 새 엔트리 추가
HallOfFame addEntry(HallOfFameEntry entry) {
final newEntries = List<HallOfFameEntry>.from(entries)
..add(entry)
..sort((a, b) => b.clearedAt.compareTo(a.clearedAt));
return HallOfFame(entries: newEntries);
}
/// 엔트리 수
int get count => entries.length;
/// 비어있는지 확인
bool get isEmpty => entries.isEmpty;
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'entries': entries.map((e) => e.toJson()).toList(),
};
}
/// JSON에서 역직렬화
factory HallOfFame.fromJson(Map<String, dynamic> json) {
final entriesJson = json['entries'] as List<dynamic>? ?? [];
return HallOfFame(
entries: entriesJson
.map((e) => HallOfFameEntry.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'dart:convert';
import 'dart:io';
import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
import 'package:path_provider/path_provider.dart';
/// 명예의 전당 저장소 (Phase 10: Hall of Fame Storage)
///
/// 명예의 전당 데이터 저장/로드 관리
class HallOfFameStorage {
HallOfFameStorage();
static const String _fileName = 'hall_of_fame.json';
Directory? _storageDir;
Future<Directory> _getStorageDir() async {
if (_storageDir != null) return _storageDir!;
_storageDir = await getApplicationSupportDirectory();
return _storageDir!;
}
File _getFile(Directory dir) {
return File('${dir.path}/$_fileName');
}
/// 명예의 전당 로드
Future<HallOfFame> load() async {
try {
final dir = await _getStorageDir();
final file = _getFile(dir);
if (!await file.exists()) {
return HallOfFame.empty();
}
final content = await file.readAsString();
final json = jsonDecode(content) as Map<String, dynamic>;
return HallOfFame.fromJson(json);
} catch (e) {
// 파일이 없거나 손상된 경우 빈 명예의 전당 반환
return HallOfFame.empty();
}
}
/// 명예의 전당 저장
Future<bool> save(HallOfFame hallOfFame) async {
try {
final dir = await _getStorageDir();
final file = _getFile(dir);
final content = jsonEncode(hallOfFame.toJson());
await file.writeAsString(content);
return true;
} catch (e) {
return false;
}
}
/// 새 엔트리 추가 및 저장
Future<bool> addEntry(HallOfFameEntry entry) async {
final hallOfFame = await load();
final updated = hallOfFame.addEntry(entry);
return save(updated);
}
/// 명예의 전당 초기화 (테스트용)
Future<bool> clear() async {
try {
final dir = await _getStorageDir();
final file = _getFile(dir);
if (await file.exists()) {
await file.delete();
}
return true;
} catch (e) {
return false;
}
}
}

View File

@@ -3,7 +3,12 @@ import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
class FrontScreen extends StatelessWidget {
const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave});
const FrontScreen({
super.key,
this.onNewCharacter,
this.onLoadSave,
this.onHallOfFame,
});
/// "New character" 버튼 클릭 시 호출
final void Function(BuildContext context)? onNewCharacter;
@@ -11,6 +16,9 @@ class FrontScreen extends StatelessWidget {
/// "Load save" 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onLoadSave;
/// "Hall of Fame" 버튼 클릭 시 호출 (Phase 10)
final void Function(BuildContext context)? onHallOfFame;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -43,6 +51,9 @@ class FrontScreen extends StatelessWidget {
onLoadSave: onLoadSave != null
? () => onLoadSave!(context)
: () => _showPlaceholder(context),
onHallOfFame: onHallOfFame != null
? () => onHallOfFame!(context)
: null,
),
const SizedBox(height: 24),
const _StatusCards(),
@@ -150,10 +161,15 @@ class _HeroHeader extends StatelessWidget {
}
class _ActionRow extends StatelessWidget {
const _ActionRow({required this.onNewCharacter, required this.onLoadSave});
const _ActionRow({
required this.onNewCharacter,
required this.onLoadSave,
this.onHallOfFame,
});
final VoidCallback onNewCharacter;
final VoidCallback onLoadSave;
final VoidCallback? onHallOfFame;
@override
Widget build(BuildContext context) {
@@ -187,6 +203,13 @@ class _ActionRow extends StatelessWidget {
icon: const Icon(Icons.menu_book_outlined),
label: Text(l10n.viewBuildPlan),
),
// Phase 10: 명예의 전당 버튼
if (onHallOfFame != null)
TextButton.icon(
onPressed: onHallOfFame,
icon: const Icon(Icons.emoji_events_outlined),
label: const Text('Hall of Fame'),
),
],
);
}

View File

@@ -6,9 +6,12 @@ import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/engine/story_service.dart';
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
import 'package:askiineverdie/src/core/notification/notification_service.dart';
import 'package:askiineverdie/src/core/storage/hall_of_fame_storage.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:askiineverdie/src/features/game/widgets/cinematic_view.dart';
import 'package:askiineverdie/src/features/game/widgets/combat_log.dart';
import 'package:askiineverdie/src/features/game/widgets/hp_mp_bar.dart';
@@ -70,6 +73,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
if (newAct != _lastAct && !_showingCinematic) {
_lastAct = newAct;
_showCinematicForAct(newAct);
// Phase 10: 엔딩 도달 시 클리어 처리
if (newAct == StoryAct.ending && state.traits.level >= 100) {
_handleGameClear(state);
}
}
}
_lastLevel = state.traits.level;
@@ -131,6 +139,43 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_showingCinematic = false;
}
/// Phase 10: 게임 클리어 처리 (Handle Game Clear)
Future<void> _handleGameClear(GameState state) async {
// 게임 일시 정지
await widget.controller.pause(saveOnStop: true);
// 명예의 전당 엔트리 생성
final entry = HallOfFameEntry.fromGameState(
state: state,
totalDeaths: 0, // TODO: 사망 횟수 추적 구현 시 연결
monstersKilled: 0, // TODO: 처치 수 추적 구현 시 연결
);
// 명예의 전당에 저장
final storage = HallOfFameStorage();
await storage.addEntry(entry);
// 클리어 다이얼로그 표시
if (mounted) {
await showGameClearDialog(
context,
entry: entry,
onNewGame: () {
// 프론트 화면으로 돌아가기
Navigator.of(context).popUntil((route) => route.isFirst);
},
onViewHallOfFame: () {
// 명예의 전당 화면으로 이동
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (context) => const HallOfFameScreen(),
),
);
},
);
}
}
void _resetSpecialAnimationAfterFrame() {
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
WidgetsBinding.instance.addPostFrameCallback((_) {

View File

@@ -0,0 +1,460 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
import 'package:askiineverdie/src/core/storage/hall_of_fame_storage.dart';
/// 명예의 전당 화면 (Phase 10: Hall of Fame Screen)
class HallOfFameScreen extends StatefulWidget {
const HallOfFameScreen({super.key});
@override
State<HallOfFameScreen> createState() => _HallOfFameScreenState();
}
class _HallOfFameScreenState extends State<HallOfFameScreen> {
final HallOfFameStorage _storage = HallOfFameStorage();
HallOfFame? _hallOfFame;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadHallOfFame();
}
Future<void> _loadHallOfFame() async {
final hallOfFame = await _storage.load();
if (mounted) {
setState(() {
_hallOfFame = hallOfFame;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Hall of Fame'),
centerTitle: true,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _buildContent(),
);
}
Widget _buildContent() {
final hallOfFame = _hallOfFame;
if (hallOfFame == null || hallOfFame.isEmpty) {
return _buildEmptyState();
}
return _buildHallOfFameList(hallOfFame);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.emoji_events_outlined,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'No heroes yet',
style: TextStyle(
fontSize: 20,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Defeat the Glitch God to enshrine your legend!',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildHallOfFameList(HallOfFame hallOfFame) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.amber.shade700, width: 2),
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.all(16),
child: Column(
children: [
// 헤더
Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.amber.shade700,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.emoji_events, color: Colors.white),
SizedBox(width: 8),
Text(
'HALL OF FAME',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
SizedBox(width: 8),
Icon(Icons.emoji_events, color: Colors.white),
],
),
),
// 엔트리 목록
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: hallOfFame.entries.length,
itemBuilder: (context, index) {
final entry = hallOfFame.entries[index];
return _HallOfFameEntryCard(
entry: entry,
rank: index + 1,
);
},
),
),
],
),
);
}
}
/// 명예의 전당 엔트리 카드
class _HallOfFameEntryCard extends StatelessWidget {
const _HallOfFameEntryCard({
required this.entry,
required this.rank,
});
final HallOfFameEntry entry;
final int rank;
@override
Widget build(BuildContext context) {
final rankColor = _getRankColor(rank);
final rankIcon = _getRankIcon(rank);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// 순위 표시
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: rankColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: Border.all(color: rankColor, width: 2),
),
child: Center(
child: rankIcon != null
? Icon(rankIcon, color: rankColor, size: 20)
: Text(
'$rank',
style: TextStyle(
color: rankColor,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
const SizedBox(width: 12),
// 캐릭터 정보
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 이름과 레벨
Row(
children: [
Expanded(
child: Text(
'"${entry.characterName}"',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Lv.${entry.level}',
style: TextStyle(
color: Colors.blue.shade800,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 4),
// 종족/클래스
Text(
'${GameDataL10n.getRaceName(context, entry.race)} '
'${GameDataL10n.getKlassName(context, entry.klass)}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
// 통계
Row(
children: [
_buildStatChip(
Icons.timer,
entry.formattedPlayTime,
Colors.green,
),
const SizedBox(width: 8),
_buildStatChip(
Icons.heart_broken,
'${entry.totalDeaths}',
Colors.red,
),
const SizedBox(width: 8),
_buildStatChip(
Icons.check_circle,
'${entry.questsCompleted}Q',
Colors.orange,
),
],
),
],
),
),
// 클리어 날짜
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(Icons.calendar_today, size: 14, color: Colors.grey.shade500),
const SizedBox(height: 4),
Text(
entry.formattedClearedDate,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
],
),
),
);
}
Widget _buildStatChip(IconData icon, String value, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: color),
const SizedBox(width: 2),
Text(
value,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500,
),
),
],
);
}
Color _getRankColor(int rank) {
switch (rank) {
case 1:
return Colors.amber.shade700;
case 2:
return Colors.grey.shade500;
case 3:
return Colors.brown.shade400;
default:
return Colors.blue.shade400;
}
}
IconData? _getRankIcon(int rank) {
switch (rank) {
case 1:
return Icons.emoji_events;
case 2:
return Icons.workspace_premium;
case 3:
return Icons.military_tech;
default:
return null;
}
}
}
/// 게임 클리어 축하 다이얼로그
Future<void> showGameClearDialog(
BuildContext context, {
required HallOfFameEntry entry,
required VoidCallback onNewGame,
required VoidCallback onViewHallOfFame,
}) {
return showDialog(
context: context,
barrierDismissible: false,
builder: (context) => _GameClearDialog(
entry: entry,
onNewGame: onNewGame,
onViewHallOfFame: onViewHallOfFame,
),
);
}
/// 게임 클리어 다이얼로그 위젯
class _GameClearDialog extends StatelessWidget {
const _GameClearDialog({
required this.entry,
required this.onNewGame,
required this.onViewHallOfFame,
});
final HallOfFameEntry entry;
final VoidCallback onNewGame;
final VoidCallback onViewHallOfFame;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.emoji_events, color: Colors.amber, size: 32),
SizedBox(width: 8),
Text('VICTORY!'),
SizedBox(width: 8),
Icon(Icons.emoji_events, color: Colors.amber, size: 32),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'You have defeated the Glitch God!',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
// 캐릭터 정보
Text(
'"${entry.characterName}"',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${entry.race} ${entry.klass}',
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
// 통계
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStat('Level', '${entry.level}'),
_buildStat('Time', entry.formattedPlayTime),
_buildStat('Deaths', '${entry.totalDeaths}'),
_buildStat('Quests', '${entry.questsCompleted}'),
],
),
const SizedBox(height: 16),
const Text(
'Your legend has been enshrined in the Hall of Fame!',
style: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.amber,
),
textAlign: TextAlign.center,
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
onViewHallOfFame();
},
child: const Text('View Hall of Fame'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
onNewGame();
},
child: const Text('New Game'),
),
],
);
}
Widget _buildStat(String label, String value) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
);
}
}