From df5fdbaac2f79dda6064dc208d7293967f9cc288 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 24 Dec 2025 17:20:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(hall-of-fame):=20=EB=AA=85=EC=98=88?= =?UTF-8?q?=EC=9D=98=20=EC=A0=84=EB=8B=B9=20=EC=83=81=EC=84=B8=20UI=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=84=ED=88=AC=20=EC=8A=A4=ED=83=AF=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CombatStats에 toJson/fromJson 직렬화 메서드 추가 - HallOfFameEntry에 finalStats(CombatStats) 필드 추가 - 명예의 전당 상세 다이얼로그에서 전투 스탯, 장비, 스펠 표시 - GameState에 combatStats 접근자 추가 - game_text_l10n에 명예의 전당 관련 텍스트 추가 --- lib/data/game_text_l10n.dart | 36 ++++ lib/src/core/engine/progress_service.dart | 14 +- lib/src/core/model/combat_stats.dart | 54 +++++ lib/src/core/model/game_state.dart | 12 ++ lib/src/core/model/hall_of_fame.dart | 4 + lib/src/core/model/save_data.dart | 4 + lib/src/features/game/game_play_screen.dart | 13 +- .../hall_of_fame/hall_of_fame_screen.dart | 194 +++++++++++++++--- 8 files changed, 296 insertions(+), 35 deletions(-) diff --git a/lib/data/game_text_l10n.dart b/lib/data/game_text_l10n.dart index 0427a58..158f75e 100644 --- a/lib/data/game_text_l10n.dart +++ b/lib/data/game_text_l10n.dart @@ -1254,6 +1254,42 @@ String get hofQuests { 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) { if (isKoreanLocale) return 'Lv.$level'; if (isJapaneseLocale) return 'Lv.$level'; diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 0170c90..52b9383 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -287,8 +287,11 @@ class ProgressService { progress = progress.copyWith(currentCombat: combatForReset); } - // 전투 상태 초기화 및 물약 사용 기록 초기화 - progress = progress.copyWith(currentCombat: null); + // 전투 상태 초기화, 몬스터 처치 수 증가 및 물약 사용 기록 초기화 + progress = progress.copyWith( + currentCombat: null, + monstersKilled: progress.monstersKilled + 1, + ); final resetPotionInventory = nextState.potionInventory.resetBattleUsage(); nextState = nextState.copyWith( progress: progress, @@ -1331,8 +1334,11 @@ class ProgressService { lastCombatEvents: lastCombatEvents, ); - // 전투 상태 초기화 - final progress = state.progress.copyWith(currentCombat: null); + // 전투 상태 초기화 및 사망 횟수 증가 + final progress = state.progress.copyWith( + currentCombat: null, + deathCount: state.progress.deathCount + 1, + ); return state.copyWith( equipment: emptyEquipment, diff --git a/lib/src/core/model/combat_stats.dart b/lib/src/core/model/combat_stats.dart index bb8b602..912757f 100644 --- a/lib/src/core/model/combat_stats.dart +++ b/lib/src/core/model/combat_stats.dart @@ -391,6 +391,60 @@ class CombatStats { ); } + /// JSON으로 직렬화 + Map 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 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( str: 10, diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart index cf258c1..67be022 100644 --- a/lib/src/core/model/game_state.dart +++ b/lib/src/core/model/game_state.dart @@ -741,6 +741,8 @@ class ProgressState { this.questHistory = const [], this.currentQuestMonster, this.currentCombat, + this.monstersKilled = 0, + this.deathCount = 0, }); final ProgressBarState task; @@ -764,6 +766,12 @@ class ProgressState { /// 현재 전투 상태 (킬 태스크 진행 중) final CombatState? currentCombat; + /// 처치한 몬스터 수 + final int monstersKilled; + + /// 사망 횟수 + final int deathCount; + factory ProgressState.empty() => ProgressState( task: ProgressBarState.empty(), quest: ProgressBarState.empty(), @@ -792,6 +800,8 @@ class ProgressState { List? questHistory, QuestMonsterInfo? currentQuestMonster, CombatState? currentCombat, + int? monstersKilled, + int? deathCount, }) { return ProgressState( task: task ?? this.task, @@ -806,6 +816,8 @@ class ProgressState { questHistory: questHistory ?? this.questHistory, currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster, currentCombat: currentCombat ?? this.currentCombat, + monstersKilled: monstersKilled ?? this.monstersKilled, + deathCount: deathCount ?? this.deathCount, ); } } diff --git a/lib/src/core/model/hall_of_fame.dart b/lib/src/core/model/hall_of_fame.dart index f8f37bd..67d1efc 100644 --- a/lib/src/core/model/hall_of_fame.dart +++ b/lib/src/core/model/hall_of_fame.dart @@ -130,6 +130,7 @@ class HallOfFameEntry { 'monstersKilled': monstersKilled, 'questsCompleted': questsCompleted, 'clearedAt': clearedAt.toIso8601String(), + 'finalStats': finalStats?.toJson(), 'finalEquipment': finalEquipment, 'finalSpells': finalSpells, }; @@ -148,6 +149,9 @@ class HallOfFameEntry { monstersKilled: json['monstersKilled'] as int? ?? 0, questsCompleted: json['questsCompleted'] as int? ?? 0, clearedAt: DateTime.parse(json['clearedAt'] as String), + finalStats: json['finalStats'] != null + ? CombatStats.fromJson(json['finalStats'] as Map) + : null, finalEquipment: json['finalEquipment'] != null ? Map.from(json['finalEquipment'] as Map) : null, diff --git a/lib/src/core/model/save_data.dart b/lib/src/core/model/save_data.dart index a8aa1b6..cd6730e 100644 --- a/lib/src/core/model/save_data.dart +++ b/lib/src/core/model/save_data.dart @@ -117,6 +117,8 @@ class GameSave { 'index': progress.currentQuestMonster!.monsterIndex, } : null, + 'monstersKilled': progress.monstersKilled, + 'deathCount': progress.deathCount, }, 'queue': queue.entries .map( @@ -225,6 +227,8 @@ class GameSave { currentQuestMonster: _questMonsterFromJson( progressJson['questMonster'] as Map?, ), + monstersKilled: progressJson['monstersKilled'] as int? ?? 0, + deathCount: progressJson['deathCount'] as int? ?? 0, ), queue: QueueState( entries: Queue.from( diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index a8b5dd5..92121ed 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -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/model/combat_event.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/hall_of_fame.dart'; import 'package:askiineverdie/src/core/model/skill.dart'; @@ -291,11 +292,19 @@ class _GamePlayScreenState extends State // 게임 일시 정지 await widget.controller.pause(saveOnStop: true); + // 최종 전투 스탯 계산 + final combatStats = CombatStats.fromStats( + stats: state.stats, + equipment: state.equipment, + level: state.traits.level, + ); + // 명예의 전당 엔트리 생성 final entry = HallOfFameEntry.fromGameState( state: state, - totalDeaths: 0, // TODO: 사망 횟수 추적 구현 시 연결 - monstersKilled: 0, // TODO: 처치 수 추적 구현 시 연결 + totalDeaths: state.progress.deathCount, + monstersKilled: state.progress.monstersKilled, + combatStats: combatStats, ); // 명예의 전당에 저장 diff --git a/lib/src/features/hall_of_fame/hall_of_fame_screen.dart b/lib/src/features/hall_of_fame/hall_of_fame_screen.dart index a5d880c..885b371 100644 --- a/lib/src/features/hall_of_fame/hall_of_fame_screen.dart +++ b/lib/src/features/hall_of_fame/hall_of_fame_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; 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/model/combat_stats.dart'; import 'package:askiineverdie/src/core/model/hall_of_fame.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': '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, mainAxisSize: MainAxisSize.min, children: [ - // 통계 섹션 + // 통계 섹션 (Statistics Section) _buildSection( icon: Icons.analytics, - title: 'Statistics', + title: l10n.hofStats, child: _buildStatsGrid(), ), const SizedBox(height: 16), - // 장비 섹션 - if (entry.finalEquipment != null) ...[ + // 전투 스탯 섹션 (Combat Stats Section) + if (entry.finalStats != null) ...[ _buildSection( - icon: Icons.shield, - title: 'Equipment', - child: _buildEquipmentList(), + icon: Icons.sports_mma, + title: l10n.hofCombatStats, + child: _buildCombatStatsGrid(), ), 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) _buildSection( icon: Icons.auto_fix_high, - title: 'Spells', - child: _buildSpellList(), + title: l10n.hofSpells, + child: _buildSpellList(context), ), ], ), @@ -550,7 +583,7 @@ class _HallOfFameDetailDialog extends StatelessWidget { actions: [ TextButton( 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.pest_control, - 'Monsters', + l10n.hofMonsters, '${entry.monstersKilled}', ), _buildStatItem( @@ -607,13 +640,109 @@ class _HallOfFameDetailDialog extends StatelessWidget { ), _buildStatItem( Icons.calendar_today, - 'Cleared', + l10n.hofCleared, 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) { return Container( 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!; + // 슬롯 키, 아이콘, l10n 슬롯 이름 final slots = [ - ('weapon', Icons.gavel, 'Weapon'), - ('shield', Icons.shield, 'Shield'), - ('helm', Icons.sports_mma, 'Helm'), - ('hauberk', Icons.checkroom, 'Hauberk'), - ('brassairts', Icons.front_hand, 'Brassairts'), - ('vambraces', Icons.back_hand, 'Vambraces'), - ('gauntlets', Icons.sports_handball, 'Gauntlets'), - ('gambeson', Icons.dry_cleaning, 'Gambeson'), - ('cuisses', Icons.airline_seat_legroom_normal, 'Cuisses'), - ('greaves', Icons.snowshoeing, 'Greaves'), - ('sollerets', Icons.do_not_step, 'Sollerets'), + ('weapon', Icons.gavel, l10n.slotWeapon, 0), + ('shield', Icons.shield, l10n.slotShield, 1), + ('helm', Icons.sports_mma, l10n.slotHelm, 2), + ('hauberk', Icons.checkroom, l10n.slotHauberk, 2), + ('brassairts', Icons.front_hand, l10n.slotBrassairts, 2), + ('vambraces', Icons.back_hand, l10n.slotVambraces, 2), + ('gauntlets', Icons.sports_handball, l10n.slotGauntlets, 2), + ('gambeson', Icons.dry_cleaning, l10n.slotGambeson, 2), + ('cuisses', Icons.airline_seat_legroom_normal, l10n.slotCuisses, 2), + ('greaves', Icons.snowshoeing, l10n.slotGreaves, 2), + ('sollerets', Icons.do_not_step, l10n.slotSollerets, 2), ]; return Column( children: slots.map((slot) { - final (key, icon, label) = slot; - final value = equipment[key] ?? '-'; + final (key, icon, label, slotIndex) = slot; + final rawValue = equipment[key] ?? ''; + // 장비 이름 번역 적용 + final value = rawValue.isEmpty + ? l10n.uiEmpty + : GameDataL10n.translateEquipString(context, rawValue, slotIndex); return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( @@ -694,7 +828,7 @@ class _HallOfFameDetailDialog extends StatelessWidget { ); } - Widget _buildSpellList() { + Widget _buildSpellList(BuildContext context) { final spells = entry.finalSpells!; return Wrap( spacing: 8, @@ -702,6 +836,8 @@ class _HallOfFameDetailDialog extends StatelessWidget { children: spells.map((spell) { final name = spell['name'] ?? ''; final rank = spell['rank'] ?? ''; + // 스펠 이름 번역 적용 + final translatedName = GameDataL10n.getSpellName(context, name); return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( @@ -710,7 +846,7 @@ class _HallOfFameDetailDialog extends StatelessWidget { border: Border.all(color: Colors.purple.shade200), ), child: Text( - '$name $rank', + '$translatedName $rank', style: TextStyle(fontSize: 12, color: Colors.purple.shade700), ), );