diff --git a/lib/src/core/engine/progress_loop.dart b/lib/src/core/engine/progress_loop.dart index 2600666..6246790 100644 --- a/lib/src/core/engine/progress_loop.dart +++ b/lib/src/core/engine/progress_loop.dart @@ -65,14 +65,14 @@ class ProgressLoop { Stream get stream => _stateController.stream; GameState _state; - /// 현재 배속 (1x, 2x, 5x) + /// 현재 배속 (1x, 3x, 10x) int get speedMultiplier => _speedMultiplier; - /// 배속 순환: 1 -> 2 -> 5 -> 1 + /// 배속 순환: 1 -> 3 -> 10 -> 1 void cycleSpeed() { _speedMultiplier = switch (_speedMultiplier) { - 1 => 2, - 2 => 5, + 1 => 3, + 3 => 10, _ => 1, }; } diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 579f692..0170c90 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -100,8 +100,8 @@ class ProgressService { // ExpBar 초기화 (원본 743-746줄) final expBar = ProgressBarState(position: 0, max: pq_logic.levelUpTime(1)); - // PlotBar 초기화 (원본 759줄) - final plotBar = const ProgressBarState(position: 0, max: 26 * 1000); + // PlotBar 초기화 - Prologue 5분 (300초) + final plotBar = const ProgressBarState(position: 0, max: 300); final progress = taskResult.progress.copyWith( exp: expBar, diff --git a/lib/src/core/model/hall_of_fame.dart b/lib/src/core/model/hall_of_fame.dart index 61257c6..f8f37bd 100644 --- a/lib/src/core/model/hall_of_fame.dart +++ b/lib/src/core/model/hall_of_fame.dart @@ -18,6 +18,7 @@ class HallOfFameEntry { required this.clearedAt, this.finalStats, this.finalEquipment, + this.finalSpells, }); /// 고유 ID (UUID) @@ -56,6 +57,9 @@ class HallOfFameEntry { /// 최종 장비 목록 (향후 아스키 아레나용) final Map? finalEquipment; + /// 최종 스펠북 (스펠 이름 + 랭크) + final List>? finalSpells; + /// 플레이 시간을 Duration으로 변환 Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs); @@ -107,6 +111,9 @@ class HallOfFameEntry { 'greaves': state.equipment.greaves, 'sollerets': state.equipment.sollerets, }, + finalSpells: state.spellBook.spells + .map((s) => {'name': s.name, 'rank': s.rank}) + .toList(), ); } @@ -124,6 +131,7 @@ class HallOfFameEntry { 'questsCompleted': questsCompleted, 'clearedAt': clearedAt.toIso8601String(), 'finalEquipment': finalEquipment, + 'finalSpells': finalSpells, }; } @@ -143,6 +151,11 @@ class HallOfFameEntry { finalEquipment: json['finalEquipment'] != null ? Map.from(json['finalEquipment'] as Map) : null, + finalSpells: json['finalSpells'] != null + ? (json['finalSpells'] as List) + .map((s) => Map.from(s as Map)) + .toList() + : null, ); } } diff --git a/lib/src/core/util/pq_logic.dart b/lib/src/core/util/pq_logic.dart index 4c803ff..1269895 100644 --- a/lib/src/core/util/pq_logic.dart +++ b/lib/src/core/util/pq_logic.dart @@ -70,9 +70,10 @@ class ItemResult { } int levelUpTimeSeconds(int level) { - // ~20 minutes for level 1, then exponential growth (same as LevelUpTime in Main.pas). - final seconds = (20.0 + math.pow(1.15, level)) * 60.0; - return seconds.round(); + // 10시간 내 레벨 100 도달 목표 (선형 성장) + // 레벨 1: ~2분, 레벨 100: ~7분 + final seconds = 120 + (level * 3); + return seconds; } /// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271) @@ -736,10 +737,22 @@ class ActResult { final List rewards; } +/// Act별 Plot Bar 최대값 (초) - 10시간 완주 목표 +const _actPlotBarSeconds = [ + 300, // Prologue: 5분 + 7200, // Act I: 2시간 + 10800, // Act II: 3시간 + 10800, // Act III: 3시간 + 5400, // Act IV: 1.5시간 + 1800, // Act V: 30분 +]; + ActResult completeAct(int existingActCount) { final nextActIndex = existingActCount; final title = l10n.actTitle(intToRoman(nextActIndex)); - final plotBarMax = 60 * 60 * (1 + 5 * existingActCount); + final plotBarMax = existingActCount < _actPlotBarSeconds.length + ? _actPlotBarSeconds[existingActCount] + : 3600; final rewards = []; if (existingActCount > 1) { 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 f5e25b8..a5d880c 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 @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; @@ -25,7 +26,13 @@ class _HallOfFameScreenState extends State { } Future _loadHallOfFame() async { - final hallOfFame = await _storage.load(); + var hallOfFame = await _storage.load(); + + // 디버그 모드일 때 샘플 엔트리 추가 (빈 경우에만) + if (kDebugMode && hallOfFame.isEmpty) { + hallOfFame = hallOfFame.addEntry(_createDebugSampleEntry()); + } + if (mounted) { setState(() { _hallOfFame = hallOfFame; @@ -134,6 +141,43 @@ class _HallOfFameScreenState extends State { } } +/// 디버그 모드 샘플 엔트리 생성 (kDebugMode에서만 사용) +HallOfFameEntry _createDebugSampleEntry() { + return HallOfFameEntry( + id: 'debug_sample_001', + characterName: 'Debug Hero', + race: 'byte_human', + klass: 'loop_wizard', + level: 100, + totalPlayTimeMs: 10 * 60 * 60 * 1000, // 10시간 + totalDeaths: 3, + monstersKilled: 1234, + questsCompleted: 42, + clearedAt: DateTime.now(), + finalEquipment: { + 'weapon': '+15 Legendary Debugger', + 'shield': '+10 Exception Shield', + 'helm': '+8 Null Pointer Helm', + 'hauberk': '+12 Thread-Safe Armor', + 'brassairts': '+6 Memory Guard', + 'vambraces': '+5 Stack Overflow Band', + 'gauntlets': '+7 Syntax Checker Gloves', + 'gambeson': '+9 Buffer Padding', + 'cuisses': '+4 Runtime Protector', + 'greaves': '+6 Compile Time Shin', + 'sollerets': '+5 Binary Boots', + }, + finalSpells: [ + {'name': 'Recursive Thunder', 'rank': 'XII'}, + {'name': 'Async Heal', 'rank': 'VIII'}, + {'name': 'Memory Leak Curse', 'rank': 'X'}, + {'name': 'Stack Overflow', 'rank': 'VI'}, + {'name': 'Null Pointer Strike', 'rank': 'IX'}, + {'name': 'Thread Lock', 'rank': 'VII'}, + ], + ); +} + /// 명예의 전당 엔트리 카드 class _HallOfFameEntryCard extends StatelessWidget { const _HallOfFameEntryCard({required this.entry, required this.rank}); @@ -141,6 +185,13 @@ class _HallOfFameEntryCard extends StatelessWidget { final HallOfFameEntry entry; final int rank; + void _showDetailDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => _HallOfFameDetailDialog(entry: entry), + ); + } + @override Widget build(BuildContext context) { final rankColor = _getRankColor(rank); @@ -148,10 +199,13 @@ class _HallOfFameEntryCard extends StatelessWidget { return Card( margin: const EdgeInsets.symmetric(vertical: 4), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ + child: InkWell( + onTap: () => _showDetailDialog(context), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ // 순위 표시 Container( width: 40, @@ -265,7 +319,8 @@ class _HallOfFameEntryCard extends StatelessWidget { ], ), ), - ); + ), + ); } Widget _buildStatChip(IconData icon, String value, Color color) { @@ -433,3 +488,233 @@ class _GameClearDialog extends StatelessWidget { ); } } + +/// 명예의 전당 상세 정보 다이얼로그 +class _HallOfFameDetailDialog extends StatelessWidget { + const _HallOfFameDetailDialog({required this.entry}); + + final HallOfFameEntry entry; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Column( + children: [ + Text( + '"${entry.characterName}"', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + '${GameDataL10n.getRaceName(context, entry.race)} ' + '${GameDataL10n.getKlassName(context, entry.klass)} - ' + '${l10n.uiLevel(entry.level)}', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + ], + ), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 통계 섹션 + _buildSection( + icon: Icons.analytics, + title: 'Statistics', + child: _buildStatsGrid(), + ), + const SizedBox(height: 16), + // 장비 섹션 + if (entry.finalEquipment != null) ...[ + _buildSection( + icon: Icons.shield, + title: 'Equipment', + child: _buildEquipmentList(), + ), + const SizedBox(height: 16), + ], + // 스펠 섹션 + if (entry.finalSpells != null && entry.finalSpells!.isNotEmpty) + _buildSection( + icon: Icons.auto_fix_high, + title: 'Spells', + child: _buildSpellList(), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + } + + Widget _buildSection({ + required IconData icon, + required String title, + required Widget child, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: Colors.amber.shade700), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.amber.shade700, + ), + ), + ], + ), + const SizedBox(height: 8), + child, + ], + ); + } + + Widget _buildStatsGrid() { + return Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _buildStatItem(Icons.timer, l10n.hofTime, entry.formattedPlayTime), + _buildStatItem( + Icons.pest_control, + 'Monsters', + '${entry.monstersKilled}', + ), + _buildStatItem( + Icons.heart_broken, + l10n.hofDeaths, + '${entry.totalDeaths}', + ), + _buildStatItem( + Icons.check_circle, + l10n.hofQuests, + '${entry.questsCompleted}', + ), + _buildStatItem( + Icons.calendar_today, + 'Cleared', + entry.formattedClearedDate, + ), + ], + ); + } + + Widget _buildStatItem(IconData icon, String label, String value) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: Colors.grey.shade600), + const SizedBox(width: 6), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + Text( + label, + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + ], + ), + ], + ), + ); + } + + Widget _buildEquipmentList() { + final equipment = entry.finalEquipment!; + 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'), + ]; + + return Column( + children: slots.map((slot) { + final (key, icon, label) = slot; + final value = equipment[key] ?? '-'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade500), + const SizedBox(width: 8), + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildSpellList() { + final spells = entry.finalSpells!; + return Wrap( + spacing: 8, + runSpacing: 4, + children: spells.map((spell) { + final name = spell['name'] ?? ''; + final rank = spell['rank'] ?? ''; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.purple.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.purple.shade200), + ), + child: Text( + '$name $rank', + style: TextStyle(fontSize: 12, color: Colors.purple.shade700), + ), + ); + }).toList(), + ); + } +} diff --git a/test/core/util/pq_logic_test.dart b/test/core/util/pq_logic_test.dart index 4418b3a..63af9d3 100644 --- a/test/core/util/pq_logic_test.dart +++ b/test/core/util/pq_logic_test.dart @@ -8,8 +8,10 @@ void main() { const config = PqConfig(); test('levelUpTime grows with level and matches expected seconds', () { - expect(pq_logic.levelUpTime(1), 1269); - expect(pq_logic.levelUpTime(10), 1443); + // 새 공식: 120 + (level * 3) - 10시간 내 레벨 100 도달 목표 + expect(pq_logic.levelUpTime(1), 123); // 120 + 3 = 123초 (~2분) + expect(pq_logic.levelUpTime(10), 150); // 120 + 30 = 150초 (~2.5분) + expect(pq_logic.levelUpTime(100), 420); // 120 + 300 = 420초 (~7분) }); test('roughTime formats seconds into human-readable strings', () { @@ -119,7 +121,8 @@ void main() { final act2 = pq_logic.completeAct(2); expect(act2.actTitle, 'Act II'); - expect(act2.plotBarMaxSeconds, 39600); + // 새 배열 기반: Act II = 10800초 (3시간) - 10시간 완주 목표 + expect(act2.plotBarMaxSeconds, 10800); expect(act2.rewards, contains(pq_logic.RewardKind.item)); expect(act2.rewards, isNot(contains(pq_logic.RewardKind.equip)));