feat(hall): Phase 10 명예의 전당 시스템 구현
- HallOfFameEntry 모델 및 HallOfFame 컬렉션 추가 - HallOfFameStorage 저장소 (JSON 파일 기반) - HallOfFameScreen UI (순위별 색상/아이콘) - 게임 클리어 시 명예의 전당 등록 처리 - FrontScreen에 명예의 전당 버튼 추가 - 클리어 축하 다이얼로그 구현
This commit is contained in:
@@ -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/front/save_picker_dialog.dart';
|
||||||
import 'package:askiineverdie/src/features/game/game_play_screen.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/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';
|
import 'package:askiineverdie/src/features/new_character/new_character_screen.dart';
|
||||||
|
|
||||||
class AskiiNeverDieApp extends StatefulWidget {
|
class AskiiNeverDieApp extends StatefulWidget {
|
||||||
@@ -69,6 +70,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
home: FrontScreen(
|
home: FrontScreen(
|
||||||
onNewCharacter: _navigateToNewCharacter,
|
onNewCharacter: _navigateToNewCharacter,
|
||||||
onLoadSave: _loadSave,
|
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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
192
lib/src/core/model/hall_of_fame.dart
Normal file
192
lib/src/core/model/hall_of_fame.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
lib/src/core/storage/hall_of_fame_storage.dart
Normal file
81
lib/src/core/storage/hall_of_fame_storage.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:askiineverdie/l10n/app_localizations.dart';
|
import 'package:askiineverdie/l10n/app_localizations.dart';
|
||||||
|
|
||||||
class FrontScreen extends StatelessWidget {
|
class FrontScreen extends StatelessWidget {
|
||||||
const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave});
|
const FrontScreen({
|
||||||
|
super.key,
|
||||||
|
this.onNewCharacter,
|
||||||
|
this.onLoadSave,
|
||||||
|
this.onHallOfFame,
|
||||||
|
});
|
||||||
|
|
||||||
/// "New character" 버튼 클릭 시 호출
|
/// "New character" 버튼 클릭 시 호출
|
||||||
final void Function(BuildContext context)? onNewCharacter;
|
final void Function(BuildContext context)? onNewCharacter;
|
||||||
@@ -11,6 +16,9 @@ class FrontScreen extends StatelessWidget {
|
|||||||
/// "Load save" 버튼 클릭 시 호출
|
/// "Load save" 버튼 클릭 시 호출
|
||||||
final Future<void> Function(BuildContext context)? onLoadSave;
|
final Future<void> Function(BuildContext context)? onLoadSave;
|
||||||
|
|
||||||
|
/// "Hall of Fame" 버튼 클릭 시 호출 (Phase 10)
|
||||||
|
final void Function(BuildContext context)? onHallOfFame;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -43,6 +51,9 @@ class FrontScreen extends StatelessWidget {
|
|||||||
onLoadSave: onLoadSave != null
|
onLoadSave: onLoadSave != null
|
||||||
? () => onLoadSave!(context)
|
? () => onLoadSave!(context)
|
||||||
: () => _showPlaceholder(context),
|
: () => _showPlaceholder(context),
|
||||||
|
onHallOfFame: onHallOfFame != null
|
||||||
|
? () => onHallOfFame!(context)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const _StatusCards(),
|
const _StatusCards(),
|
||||||
@@ -150,10 +161,15 @@ class _HeroHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ActionRow 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 onNewCharacter;
|
||||||
final VoidCallback onLoadSave;
|
final VoidCallback onLoadSave;
|
||||||
|
final VoidCallback? onHallOfFame;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -187,6 +203,13 @@ class _ActionRow extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.menu_book_outlined),
|
icon: const Icon(Icons.menu_book_outlined),
|
||||||
label: Text(l10n.viewBuildPlan),
|
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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/engine/story_service.dart';
|
||||||
import 'package:askiineverdie/src/core/l10n/game_data_l10n.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/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/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/core/util/pq_logic.dart' as pq_logic;
|
||||||
import 'package:askiineverdie/src/features/game/game_session_controller.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/game/widgets/cinematic_view.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/combat_log.dart';
|
||||||
import 'package:askiineverdie/src/features/game/widgets/hp_mp_bar.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) {
|
if (newAct != _lastAct && !_showingCinematic) {
|
||||||
_lastAct = newAct;
|
_lastAct = newAct;
|
||||||
_showCinematicForAct(newAct);
|
_showCinematicForAct(newAct);
|
||||||
|
|
||||||
|
// Phase 10: 엔딩 도달 시 클리어 처리
|
||||||
|
if (newAct == StoryAct.ending && state.traits.level >= 100) {
|
||||||
|
_handleGameClear(state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_lastLevel = state.traits.level;
|
_lastLevel = state.traits.level;
|
||||||
@@ -131,6 +139,43 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
_showingCinematic = false;
|
_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() {
|
void _resetSpecialAnimationAfterFrame() {
|
||||||
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
|
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|||||||
460
lib/src/features/hall_of_fame/hall_of_fame_screen.dart
Normal file
460
lib/src/features/hall_of_fame/hall_of_fame_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user