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

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