feat(hall-of-fame): 명예의 전당 상세 UI 및 전투 스탯 저장 추가

- CombatStats에 toJson/fromJson 직렬화 메서드 추가
- HallOfFameEntry에 finalStats(CombatStats) 필드 추가
- 명예의 전당 상세 다이얼로그에서 전투 스탯, 장비, 스펠 표시
- GameState에 combatStats 접근자 추가
- game_text_l10n에 명예의 전당 관련 텍스트 추가
This commit is contained in:
JiWoong Sul
2025-12-24 17:20:52 +09:00
parent c1db1fd5d3
commit df5fdbaac2
8 changed files with 296 additions and 35 deletions

View File

@@ -1254,6 +1254,42 @@ String get hofQuests {
return 'Quests'; return 'Quests';
} }
String get hofStats {
if (isKoreanLocale) return '통계';
if (isJapaneseLocale) return '統計';
return 'Statistics';
}
String get hofMonsters {
if (isKoreanLocale) return '몬스터';
if (isJapaneseLocale) return 'モンスター';
return 'Monsters';
}
String get hofCleared {
if (isKoreanLocale) return '클리어';
if (isJapaneseLocale) return 'クリア';
return 'Cleared';
}
String get hofSpells {
if (isKoreanLocale) return '스펠';
if (isJapaneseLocale) return 'スペル';
return 'Spells';
}
String get hofCombatStats {
if (isKoreanLocale) return '전투 스탯';
if (isJapaneseLocale) return '戦闘ステータス';
return 'Combat Stats';
}
String get buttonClose {
if (isKoreanLocale) return '닫기';
if (isJapaneseLocale) return '閉じる';
return 'Close';
}
String uiLevel(int level) { String uiLevel(int level) {
if (isKoreanLocale) return 'Lv.$level'; if (isKoreanLocale) return 'Lv.$level';
if (isJapaneseLocale) return 'Lv.$level'; if (isJapaneseLocale) return 'Lv.$level';

View File

@@ -287,8 +287,11 @@ class ProgressService {
progress = progress.copyWith(currentCombat: combatForReset); progress = progress.copyWith(currentCombat: combatForReset);
} }
// 전투 상태 초기화 및 물약 사용 기록 초기화 // 전투 상태 초기화, 몬스터 처치 수 증가 및 물약 사용 기록 초기화
progress = progress.copyWith(currentCombat: null); progress = progress.copyWith(
currentCombat: null,
monstersKilled: progress.monstersKilled + 1,
);
final resetPotionInventory = nextState.potionInventory.resetBattleUsage(); final resetPotionInventory = nextState.potionInventory.resetBattleUsage();
nextState = nextState.copyWith( nextState = nextState.copyWith(
progress: progress, progress: progress,
@@ -1331,8 +1334,11 @@ class ProgressService {
lastCombatEvents: lastCombatEvents, lastCombatEvents: lastCombatEvents,
); );
// 전투 상태 초기화 // 전투 상태 초기화 및 사망 횟수 증가
final progress = state.progress.copyWith(currentCombat: null); final progress = state.progress.copyWith(
currentCombat: null,
deathCount: state.progress.deathCount + 1,
);
return state.copyWith( return state.copyWith(
equipment: emptyEquipment, equipment: emptyEquipment,

View File

@@ -391,6 +391,60 @@ class CombatStats {
); );
} }
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'str': str,
'con': con,
'dex': dex,
'intelligence': intelligence,
'wis': wis,
'cha': cha,
'atk': atk,
'def': def,
'magAtk': magAtk,
'magDef': magDef,
'criRate': criRate,
'criDamage': criDamage,
'evasion': evasion,
'accuracy': accuracy,
'blockRate': blockRate,
'parryRate': parryRate,
'attackDelayMs': attackDelayMs,
'hpMax': hpMax,
'hpCurrent': hpCurrent,
'mpMax': mpMax,
'mpCurrent': mpCurrent,
};
}
/// JSON에서 역직렬화
factory CombatStats.fromJson(Map<String, dynamic> json) {
return CombatStats(
str: json['str'] as int,
con: json['con'] as int,
dex: json['dex'] as int,
intelligence: json['intelligence'] as int,
wis: json['wis'] as int,
cha: json['cha'] as int,
atk: json['atk'] as int,
def: json['def'] as int,
magAtk: json['magAtk'] as int,
magDef: json['magDef'] as int,
criRate: (json['criRate'] as num).toDouble(),
criDamage: (json['criDamage'] as num).toDouble(),
evasion: (json['evasion'] as num).toDouble(),
accuracy: (json['accuracy'] as num).toDouble(),
blockRate: (json['blockRate'] as num).toDouble(),
parryRate: (json['parryRate'] as num).toDouble(),
attackDelayMs: json['attackDelayMs'] as int,
hpMax: json['hpMax'] as int,
hpCurrent: json['hpCurrent'] as int,
mpMax: json['mpMax'] as int,
mpCurrent: json['mpCurrent'] as int,
);
}
/// 테스트/디버그용 기본값 /// 테스트/디버그용 기본값
factory CombatStats.empty() => const CombatStats( factory CombatStats.empty() => const CombatStats(
str: 10, str: 10,

View File

@@ -741,6 +741,8 @@ class ProgressState {
this.questHistory = const [], this.questHistory = const [],
this.currentQuestMonster, this.currentQuestMonster,
this.currentCombat, this.currentCombat,
this.monstersKilled = 0,
this.deathCount = 0,
}); });
final ProgressBarState task; final ProgressBarState task;
@@ -764,6 +766,12 @@ class ProgressState {
/// 현재 전투 상태 (킬 태스크 진행 중) /// 현재 전투 상태 (킬 태스크 진행 중)
final CombatState? currentCombat; final CombatState? currentCombat;
/// 처치한 몬스터 수
final int monstersKilled;
/// 사망 횟수
final int deathCount;
factory ProgressState.empty() => ProgressState( factory ProgressState.empty() => ProgressState(
task: ProgressBarState.empty(), task: ProgressBarState.empty(),
quest: ProgressBarState.empty(), quest: ProgressBarState.empty(),
@@ -792,6 +800,8 @@ class ProgressState {
List<HistoryEntry>? questHistory, List<HistoryEntry>? questHistory,
QuestMonsterInfo? currentQuestMonster, QuestMonsterInfo? currentQuestMonster,
CombatState? currentCombat, CombatState? currentCombat,
int? monstersKilled,
int? deathCount,
}) { }) {
return ProgressState( return ProgressState(
task: task ?? this.task, task: task ?? this.task,
@@ -806,6 +816,8 @@ class ProgressState {
questHistory: questHistory ?? this.questHistory, questHistory: questHistory ?? this.questHistory,
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster, currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
currentCombat: currentCombat ?? this.currentCombat, currentCombat: currentCombat ?? this.currentCombat,
monstersKilled: monstersKilled ?? this.monstersKilled,
deathCount: deathCount ?? this.deathCount,
); );
} }
} }

View File

@@ -130,6 +130,7 @@ class HallOfFameEntry {
'monstersKilled': monstersKilled, 'monstersKilled': monstersKilled,
'questsCompleted': questsCompleted, 'questsCompleted': questsCompleted,
'clearedAt': clearedAt.toIso8601String(), 'clearedAt': clearedAt.toIso8601String(),
'finalStats': finalStats?.toJson(),
'finalEquipment': finalEquipment, 'finalEquipment': finalEquipment,
'finalSpells': finalSpells, 'finalSpells': finalSpells,
}; };
@@ -148,6 +149,9 @@ class HallOfFameEntry {
monstersKilled: json['monstersKilled'] as int? ?? 0, monstersKilled: json['monstersKilled'] as int? ?? 0,
questsCompleted: json['questsCompleted'] as int? ?? 0, questsCompleted: json['questsCompleted'] as int? ?? 0,
clearedAt: DateTime.parse(json['clearedAt'] as String), clearedAt: DateTime.parse(json['clearedAt'] as String),
finalStats: json['finalStats'] != null
? CombatStats.fromJson(json['finalStats'] as Map<String, dynamic>)
: null,
finalEquipment: json['finalEquipment'] != null finalEquipment: json['finalEquipment'] != null
? Map<String, String>.from(json['finalEquipment'] as Map) ? Map<String, String>.from(json['finalEquipment'] as Map)
: null, : null,

View File

@@ -117,6 +117,8 @@ class GameSave {
'index': progress.currentQuestMonster!.monsterIndex, 'index': progress.currentQuestMonster!.monsterIndex,
} }
: null, : null,
'monstersKilled': progress.monstersKilled,
'deathCount': progress.deathCount,
}, },
'queue': queue.entries 'queue': queue.entries
.map( .map(
@@ -225,6 +227,8 @@ class GameSave {
currentQuestMonster: _questMonsterFromJson( currentQuestMonster: _questMonsterFromJson(
progressJson['questMonster'] as Map<String, dynamic>?, progressJson['questMonster'] as Map<String, dynamic>?,
), ),
monstersKilled: progressJson['monstersKilled'] as int? ?? 0,
deathCount: progressJson['deathCount'] as int? ?? 0,
), ),
queue: QueueState( queue: QueueState(
entries: Queue<QueueEntry>.from( entries: Queue<QueueEntry>.from(

View File

@@ -10,6 +10,7 @@ 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/model/combat_event.dart'; import 'package:askiineverdie/src/core/model/combat_event.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/combat_stats.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/model/hall_of_fame.dart';
import 'package:askiineverdie/src/core/model/skill.dart'; import 'package:askiineverdie/src/core/model/skill.dart';
@@ -291,11 +292,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 게임 일시 정지 // 게임 일시 정지
await widget.controller.pause(saveOnStop: true); await widget.controller.pause(saveOnStop: true);
// 최종 전투 스탯 계산
final combatStats = CombatStats.fromStats(
stats: state.stats,
equipment: state.equipment,
level: state.traits.level,
);
// 명예의 전당 엔트리 생성 // 명예의 전당 엔트리 생성
final entry = HallOfFameEntry.fromGameState( final entry = HallOfFameEntry.fromGameState(
state: state, state: state,
totalDeaths: 0, // TODO: 사망 횟수 추적 구현 시 연결 totalDeaths: state.progress.deathCount,
monstersKilled: 0, // TODO: 처치 수 추적 구현 시 연결 monstersKilled: state.progress.monstersKilled,
combatStats: combatStats,
); );
// 명예의 전당에 저장 // 명예의 전당에 저장

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
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/combat_stats.dart';
import 'package:askiineverdie/src/core/model/hall_of_fame.dart'; import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
import 'package:askiineverdie/src/core/storage/hall_of_fame_storage.dart'; import 'package:askiineverdie/src/core/storage/hall_of_fame_storage.dart';
@@ -175,6 +176,29 @@ HallOfFameEntry _createDebugSampleEntry() {
{'name': 'Null Pointer Strike', 'rank': 'IX'}, {'name': 'Null Pointer Strike', 'rank': 'IX'},
{'name': 'Thread Lock', 'rank': 'VII'}, {'name': 'Thread Lock', 'rank': 'VII'},
], ],
finalStats: const CombatStats(
str: 85,
con: 72,
dex: 68,
intelligence: 90,
wis: 65,
cha: 55,
atk: 450,
def: 280,
magAtk: 520,
magDef: 195,
criRate: 0.35,
criDamage: 2.2,
evasion: 0.18,
accuracy: 0.95,
blockRate: 0.25,
parryRate: 0.15,
attackDelayMs: 650,
hpMax: 2500,
hpCurrent: 2500,
mpMax: 1800,
mpCurrent: 1800,
),
); );
} }
@@ -520,28 +544,37 @@ class _HallOfFameDetailDialog extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 통계 섹션 // 통계 섹션 (Statistics Section)
_buildSection( _buildSection(
icon: Icons.analytics, icon: Icons.analytics,
title: 'Statistics', title: l10n.hofStats,
child: _buildStatsGrid(), child: _buildStatsGrid(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 장비 섹션 // 전투 스탯 섹션 (Combat Stats Section)
if (entry.finalEquipment != null) ...[ if (entry.finalStats != null) ...[
_buildSection( _buildSection(
icon: Icons.shield, icon: Icons.sports_mma,
title: 'Equipment', title: l10n.hofCombatStats,
child: _buildEquipmentList(), child: _buildCombatStatsGrid(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// 스펠 섹션 // 장비 섹션 (Equipment Section)
if (entry.finalEquipment != null) ...[
_buildSection(
icon: Icons.shield,
title: l10n.navEquipment,
child: _buildEquipmentList(context),
),
const SizedBox(height: 16),
],
// 스펠 섹션 (Spells Section)
if (entry.finalSpells != null && entry.finalSpells!.isNotEmpty) if (entry.finalSpells != null && entry.finalSpells!.isNotEmpty)
_buildSection( _buildSection(
icon: Icons.auto_fix_high, icon: Icons.auto_fix_high,
title: 'Spells', title: l10n.hofSpells,
child: _buildSpellList(), child: _buildSpellList(context),
), ),
], ],
), ),
@@ -550,7 +583,7 @@ class _HallOfFameDetailDialog extends StatelessWidget {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'), child: Text(l10n.buttonClose),
), ),
], ],
); );
@@ -592,7 +625,7 @@ class _HallOfFameDetailDialog extends StatelessWidget {
_buildStatItem(Icons.timer, l10n.hofTime, entry.formattedPlayTime), _buildStatItem(Icons.timer, l10n.hofTime, entry.formattedPlayTime),
_buildStatItem( _buildStatItem(
Icons.pest_control, Icons.pest_control,
'Monsters', l10n.hofMonsters,
'${entry.monstersKilled}', '${entry.monstersKilled}',
), ),
_buildStatItem( _buildStatItem(
@@ -607,13 +640,109 @@ class _HallOfFameDetailDialog extends StatelessWidget {
), ),
_buildStatItem( _buildStatItem(
Icons.calendar_today, Icons.calendar_today,
'Cleared', l10n.hofCleared,
entry.formattedClearedDate, entry.formattedClearedDate,
), ),
], ],
); );
} }
Widget _buildCombatStatsGrid() {
final stats = entry.finalStats!;
return Column(
children: [
// 기본 스탯 행 (Basic Stats Row)
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildCombatStatChip(l10n.statStr, '${stats.str}', Colors.red),
_buildCombatStatChip(l10n.statCon, '${stats.con}', Colors.orange),
_buildCombatStatChip(l10n.statDex, '${stats.dex}', Colors.green),
_buildCombatStatChip(l10n.statInt, '${stats.intelligence}', Colors.blue),
_buildCombatStatChip(l10n.statWis, '${stats.wis}', Colors.purple),
_buildCombatStatChip(l10n.statCha, '${stats.cha}', Colors.pink),
],
),
const SizedBox(height: 8),
const Divider(height: 1),
const SizedBox(height: 8),
// 공격 스탯 행 (Attack Stats Row)
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildCombatStatChip(l10n.statAtk, '${stats.atk}', Colors.red.shade700),
_buildCombatStatChip(l10n.statMAtk, '${stats.magAtk}', Colors.blue.shade700),
_buildCombatStatChip(
l10n.statCri,
'${(stats.criRate * 100).toStringAsFixed(1)}%',
Colors.amber.shade700,
),
],
),
const SizedBox(height: 8),
// 방어 스탯 행 (Defense Stats Row)
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildCombatStatChip(l10n.statDef, '${stats.def}', Colors.brown),
_buildCombatStatChip(l10n.statMDef, '${stats.magDef}', Colors.indigo),
_buildCombatStatChip(
l10n.statEva,
'${(stats.evasion * 100).toStringAsFixed(1)}%',
Colors.teal,
),
_buildCombatStatChip(
l10n.statBlock,
'${(stats.blockRate * 100).toStringAsFixed(1)}%',
Colors.blueGrey,
),
],
),
const SizedBox(height: 8),
// HP/MP 행 (Resource Stats Row)
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildCombatStatChip(l10n.statHp, '${stats.hpMax}', Colors.red.shade400),
_buildCombatStatChip(l10n.statMp, '${stats.mpMax}', Colors.blue.shade400),
],
),
],
);
}
Widget _buildCombatStatChip(String label, String value, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label ',
style: TextStyle(fontSize: 11, color: color),
),
Text(
value,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
Widget _buildStatItem(IconData icon, String label, String value) { Widget _buildStatItem(IconData icon, String label, String value) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -647,26 +776,31 @@ class _HallOfFameDetailDialog extends StatelessWidget {
); );
} }
Widget _buildEquipmentList() { Widget _buildEquipmentList(BuildContext context) {
final equipment = entry.finalEquipment!; final equipment = entry.finalEquipment!;
// 슬롯 키, 아이콘, l10n 슬롯 이름
final slots = [ final slots = [
('weapon', Icons.gavel, 'Weapon'), ('weapon', Icons.gavel, l10n.slotWeapon, 0),
('shield', Icons.shield, 'Shield'), ('shield', Icons.shield, l10n.slotShield, 1),
('helm', Icons.sports_mma, 'Helm'), ('helm', Icons.sports_mma, l10n.slotHelm, 2),
('hauberk', Icons.checkroom, 'Hauberk'), ('hauberk', Icons.checkroom, l10n.slotHauberk, 2),
('brassairts', Icons.front_hand, 'Brassairts'), ('brassairts', Icons.front_hand, l10n.slotBrassairts, 2),
('vambraces', Icons.back_hand, 'Vambraces'), ('vambraces', Icons.back_hand, l10n.slotVambraces, 2),
('gauntlets', Icons.sports_handball, 'Gauntlets'), ('gauntlets', Icons.sports_handball, l10n.slotGauntlets, 2),
('gambeson', Icons.dry_cleaning, 'Gambeson'), ('gambeson', Icons.dry_cleaning, l10n.slotGambeson, 2),
('cuisses', Icons.airline_seat_legroom_normal, 'Cuisses'), ('cuisses', Icons.airline_seat_legroom_normal, l10n.slotCuisses, 2),
('greaves', Icons.snowshoeing, 'Greaves'), ('greaves', Icons.snowshoeing, l10n.slotGreaves, 2),
('sollerets', Icons.do_not_step, 'Sollerets'), ('sollerets', Icons.do_not_step, l10n.slotSollerets, 2),
]; ];
return Column( return Column(
children: slots.map((slot) { children: slots.map((slot) {
final (key, icon, label) = slot; final (key, icon, label, slotIndex) = slot;
final value = equipment[key] ?? '-'; final rawValue = equipment[key] ?? '';
// 장비 이름 번역 적용
final value = rawValue.isEmpty
? l10n.uiEmpty
: GameDataL10n.translateEquipString(context, rawValue, slotIndex);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(vertical: 2),
child: Row( child: Row(
@@ -694,7 +828,7 @@ class _HallOfFameDetailDialog extends StatelessWidget {
); );
} }
Widget _buildSpellList() { Widget _buildSpellList(BuildContext context) {
final spells = entry.finalSpells!; final spells = entry.finalSpells!;
return Wrap( return Wrap(
spacing: 8, spacing: 8,
@@ -702,6 +836,8 @@ class _HallOfFameDetailDialog extends StatelessWidget {
children: spells.map((spell) { children: spells.map((spell) {
final name = spell['name'] ?? ''; final name = spell['name'] ?? '';
final rank = spell['rank'] ?? ''; final rank = spell['rank'] ?? '';
// 스펠 이름 번역 적용
final translatedName = GameDataL10n.getSpellName(context, name);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -710,7 +846,7 @@ class _HallOfFameDetailDialog extends StatelessWidget {
border: Border.all(color: Colors.purple.shade200), border: Border.all(color: Colors.purple.shade200),
), ),
child: Text( child: Text(
'$name $rank', '$translatedName $rank',
style: TextStyle(fontSize: 12, color: Colors.purple.shade700), style: TextStyle(fontSize: 12, color: Colors.purple.shade700),
), ),
); );