diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9e4b308..4e5bbd1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -93,6 +93,9 @@ "encumbrance": "Encumbrance", "@encumbrance": { "description": "Encumbrance section title" }, + "combatLog": "Combat Log", + "@combatLog": { "description": "Combat log panel title" }, + "plotDevelopment": "Plot Development", "@plotDevelopment": { "description": "Plot development panel title" }, diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b88919a..7a9b921 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -30,6 +30,7 @@ "equipment": "Equipment", "inventory": "Inventory", "encumbrance": "Encumbrance", + "combatLog": "戦闘ログ", "plotDevelopment": "Plot Development", "quests": "Quests", "traitName": "Name", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 56ef129..2ae2a7b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -30,6 +30,7 @@ "equipment": "장비", "inventory": "인벤토리", "encumbrance": "적재량", + "combatLog": "전투 로그", "plotDevelopment": "스토리 진행", "quests": "퀘스트", "traitName": "이름", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b18da1e..0c1f9d5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -275,6 +275,12 @@ abstract class L10n { /// **'Encumbrance'** String get encumbrance; + /// Combat log panel title + /// + /// In en, this message translates to: + /// **'Combat Log'** + String get combatLog; + /// Plot development panel title /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7944e3f..973fdc0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -97,6 +97,9 @@ class L10nEn extends L10n { @override String get encumbrance => 'Encumbrance'; + @override + String get combatLog => 'Combat Log'; + @override String get plotDevelopment => 'Plot Development'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 6e27da9..90a1353 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -97,6 +97,9 @@ class L10nJa extends L10n { @override String get encumbrance => 'Encumbrance'; + @override + String get combatLog => '戦闘ログ'; + @override String get plotDevelopment => 'Plot Development'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index c6aca6d..3f22f17 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -97,6 +97,9 @@ class L10nKo extends L10n { @override String get encumbrance => '적재량'; + @override + String get combatLog => '전투 로그'; + @override String get plotDevelopment => '스토리 진행'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 32ecf06..776a0e4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -97,6 +97,9 @@ class L10nZh extends L10n { @override String get encumbrance => 'Encumbrance'; + @override + String get combatLog => '战斗日志'; + @override String get plotDevelopment => 'Plot Development'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 44e39b6..7330804 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -30,6 +30,7 @@ "equipment": "Equipment", "inventory": "Inventory", "encumbrance": "Encumbrance", + "combatLog": "战斗日志", "plotDevelopment": "Plot Development", "quests": "Quests", "traitName": "Name", diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 8a0ec74..9306f77 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -10,7 +10,10 @@ import 'package:askiineverdie/src/core/notification/notification_service.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/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'; import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart'; +import 'package:askiineverdie/src/features/game/widgets/skill_panel.dart'; import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart'; import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart'; @@ -38,16 +41,28 @@ class _GamePlayScreenState extends State StoryAct _lastAct = StoryAct.prologue; bool _showingCinematic = false; + // Phase 8: 전투 로그 (Combat Log) + final List _combatLogEntries = []; + String _lastTaskCaption = ''; + // 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용) int _lastLevel = 0; int _lastQuestCount = 0; int _lastPlotStageCount = 0; void _checkSpecialEvents(GameState state) { + // Phase 8: 태스크 변경 시 로그 추가 + final currentCaption = state.progress.currentTask.caption; + if (currentCaption.isNotEmpty && currentCaption != _lastTaskCaption) { + _addCombatLog(currentCaption, CombatLogType.normal); + _lastTaskCaption = currentCaption; + } + // 레벨업 감지 if (state.traits.level > _lastLevel && _lastLevel > 0) { _specialAnimation = AsciiAnimationType.levelUp; _notificationService.showLevelUp(state.traits.level); + _addCombatLog('Level Up! Now level ${state.traits.level}', CombatLogType.levelUp); _resetSpecialAnimationAfterFrame(); // Phase 9: Act 변경 감지 (레벨 기반) @@ -68,6 +83,7 @@ class _GamePlayScreenState extends State .lastOrNull; if (completedQuest != null) { _notificationService.showQuestComplete(completedQuest.caption); + _addCombatLog('Quest Complete: ${completedQuest.caption}', CombatLogType.questComplete); } _resetSpecialAnimationAfterFrame(); } @@ -83,6 +99,19 @@ class _GamePlayScreenState extends State _lastPlotStageCount = state.progress.plotStageCount; } + /// Phase 8: 전투 로그 추가 (Add Combat Log Entry) + void _addCombatLog(String message, CombatLogType type) { + _combatLogEntries.add(CombatLogEntry( + message: message, + timestamp: DateTime.now(), + type: type, + )); + // 최대 50개 유지 + if (_combatLogEntries.length > 50) { + _combatLogEntries.removeAt(0); + } + } + /// Phase 9: Act 시네마틱 표시 (Show Act Cinematic) Future _showCinematicForAct(StoryAct act) async { if (_showingCinematic) return; @@ -309,6 +338,14 @@ class _GamePlayScreenState extends State _buildSectionHeader(l10n.stats), Expanded(flex: 2, child: StatsPanel(stats: state.stats)), + // Phase 8: HP/MP 바 (사망 위험 시 깜빡임) + HpMpBar( + hpCurrent: state.stats.hp, + hpMax: state.stats.hpMax, + mpCurrent: state.stats.mp, + mpMax: state.stats.mpMax, + ), + // Experience 바 _buildSectionHeader(l10n.experience), _buildProgressBar( @@ -323,6 +360,10 @@ class _GamePlayScreenState extends State // Spell Book _buildSectionHeader(l10n.spellBook), Expanded(flex: 2, child: _buildSpellsList(state)), + + // Phase 8: 스킬 (Skills with cooldown glow) + _buildSectionHeader('Skills'), + Expanded(flex: 2, child: SkillPanel(skillSystem: state.skillSystem)), ], ), ); @@ -343,7 +384,7 @@ class _GamePlayScreenState extends State // Inventory _buildPanelHeader(l10n.inventory), - Expanded(flex: 3, child: _buildInventoryList(state)), + Expanded(flex: 2, child: _buildInventoryList(state)), // Encumbrance 바 _buildSectionHeader(l10n.encumbrance), @@ -352,6 +393,10 @@ class _GamePlayScreenState extends State state.progress.encumbrance.max, Colors.orange, ), + + // Phase 8: 전투 로그 (Combat Log) + _buildPanelHeader(l10n.combatLog), + Expanded(flex: 2, child: CombatLog(entries: _combatLogEntries)), ], ), ); diff --git a/lib/src/features/game/widgets/hp_mp_bar.dart b/lib/src/features/game/widgets/hp_mp_bar.dart new file mode 100644 index 0000000..524d843 --- /dev/null +++ b/lib/src/features/game/widgets/hp_mp_bar.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; + +/// HP/MP 바 위젯 (Phase 8: 사망 위험 시 깜빡임) +/// +/// HP가 20% 미만일 때 빨간색 깜빡임 효과 표시 +class HpMpBar extends StatefulWidget { + const HpMpBar({ + super.key, + required this.hpCurrent, + required this.hpMax, + required this.mpCurrent, + required this.mpMax, + }); + + final int hpCurrent; + final int hpMax; + final int mpCurrent; + final int mpMax; + + @override + State createState() => _HpMpBarState(); +} + +class _HpMpBarState extends State with SingleTickerProviderStateMixin { + late AnimationController _blinkController; + late Animation _blinkAnimation; + + @override + void initState() { + super.initState(); + + _blinkController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + + _blinkAnimation = Tween(begin: 1.0, end: 0.3).animate( + CurvedAnimation(parent: _blinkController, curve: Curves.easeInOut), + ); + + _updateBlinkState(); + } + + @override + void didUpdateWidget(HpMpBar oldWidget) { + super.didUpdateWidget(oldWidget); + _updateBlinkState(); + } + + void _updateBlinkState() { + final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 1.0; + + // HP < 20% 시 깜박임 시작 + if (hpRatio < 0.2 && hpRatio > 0) { + if (!_blinkController.isAnimating) { + _blinkController.repeat(reverse: true); + } + } else { + _blinkController.stop(); + _blinkController.reset(); + } + } + + @override + void dispose() { + _blinkController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 0.0; + final mpRatio = widget.mpMax > 0 ? widget.mpCurrent / widget.mpMax : 0.0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // HP 바 + _buildBar( + label: 'HP', + current: widget.hpCurrent, + max: widget.hpMax, + ratio: hpRatio, + color: Colors.red, + isLow: hpRatio < 0.2 && hpRatio > 0, + ), + const SizedBox(height: 4), + // MP 바 + _buildBar( + label: 'MP', + current: widget.mpCurrent, + max: widget.mpMax, + ratio: mpRatio, + color: Colors.blue, + isLow: false, + ), + ], + ), + ); + } + + Widget _buildBar({ + required String label, + required int current, + required int max, + required double ratio, + required Color color, + required bool isLow, + }) { + final bar = Row( + children: [ + SizedBox( + width: 24, + child: Text( + label, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: color.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(color), + minHeight: 10, + ), + ), + ), + const SizedBox(width: 4), + SizedBox( + width: 60, + child: Text( + '$current/$max', + style: const TextStyle(fontSize: 9), + textAlign: TextAlign.right, + ), + ), + ], + ); + + // HP < 20% 시 깜박임 효과 적용 + if (isLow) { + return AnimatedBuilder( + animation: _blinkAnimation, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: (1 - _blinkAnimation.value) * 0.3), + borderRadius: BorderRadius.circular(4), + ), + child: child, + ); + }, + child: bar, + ); + } + + return bar; + } +} diff --git a/lib/src/features/game/widgets/skill_panel.dart b/lib/src/features/game/widgets/skill_panel.dart new file mode 100644 index 0000000..1e86d67 --- /dev/null +++ b/lib/src/features/game/widgets/skill_panel.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/data/skill_data.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/skill.dart'; + +/// 스킬 패널 위젯 (Phase 8: 쿨타임 완료 시 빛남 효과) +/// +/// 스킬 목록과 쿨타임 상태를 표시 +class SkillPanel extends StatefulWidget { + const SkillPanel({super.key, required this.skillSystem}); + + final SkillSystemState skillSystem; + + @override + State createState() => _SkillPanelState(); +} + +class _SkillPanelState extends State + with SingleTickerProviderStateMixin { + late AnimationController _glowController; + late Animation _glowAnimation; + + // 이전 쿨타임 완료 상태 추적 + final Map _previousReadyState = {}; + + @override + void initState() { + super.initState(); + + _glowController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _glowAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _glowController, curve: Curves.easeInOut), + ); + } + + @override + void didUpdateWidget(SkillPanel oldWidget) { + super.didUpdateWidget(oldWidget); + _checkCooldownCompletion(); + } + + void _checkCooldownCompletion() { + // 쿨타임 완료된 스킬이 있으면 glow 애니메이션 시작 + for (final skillState in widget.skillSystem.skillStates) { + final skill = _getSkillById(skillState.skillId); + if (skill == null) continue; + + final isReady = skillState.isReady( + widget.skillSystem.elapsedMs, + skill.cooldownMs, + ); + final wasReady = _previousReadyState[skillState.skillId] ?? true; + + // 쿨타임 완료 전환 감지 + if (isReady && !wasReady) { + _glowController + ..reset() + ..repeat(reverse: true); + + // 2초 후 glow 중지 + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + _glowController.stop(); + _glowController.reset(); + } + }); + } + + _previousReadyState[skillState.skillId] = isReady; + } + } + + Skill? _getSkillById(String id) { + return SkillData.allSkills.where((s) => s.id == id).firstOrNull; + } + + @override + void dispose() { + _glowController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final skillStates = widget.skillSystem.skillStates; + + if (skillStates.isEmpty) { + return const Center( + child: Text('No skills', style: TextStyle(fontSize: 11)), + ); + } + + return ListView.builder( + itemCount: skillStates.length, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + itemBuilder: (context, index) { + final skillState = skillStates[index]; + final skill = _getSkillById(skillState.skillId); + if (skill == null) return const SizedBox.shrink(); + + final isReady = skillState.isReady( + widget.skillSystem.elapsedMs, + skill.cooldownMs, + ); + final remainingMs = skillState.remainingCooldown( + widget.skillSystem.elapsedMs, + skill.cooldownMs, + ); + + return _SkillRow( + skill: skill, + rank: skillState.rank, + isReady: isReady, + remainingMs: remainingMs, + glowAnimation: _glowAnimation, + ); + }, + ); + } +} + +/// 개별 스킬 행 위젯 +class _SkillRow extends StatelessWidget { + const _SkillRow({ + required this.skill, + required this.rank, + required this.isReady, + required this.remainingMs, + required this.glowAnimation, + }); + + final Skill skill; + final int rank; + final bool isReady; + final int remainingMs; + final Animation glowAnimation; + + @override + Widget build(BuildContext context) { + final cooldownText = isReady + ? 'Ready' + : '${(remainingMs / 1000).toStringAsFixed(1)}s'; + + final skillIcon = _getSkillIcon(skill.type); + final skillColor = _getSkillColor(skill.type); + + Widget row = Container( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + // 스킬 아이콘 + Icon(skillIcon, size: 14, color: skillColor), + const SizedBox(width: 4), + // 스킬 이름 + Expanded( + child: Text( + skill.name, + style: TextStyle( + fontSize: 10, + color: isReady ? Colors.white : Colors.grey, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // 랭크 + Text( + 'Lv.$rank', + style: const TextStyle(fontSize: 9, color: Colors.grey), + ), + const SizedBox(width: 4), + // 쿨타임 상태 + SizedBox( + width: 40, + child: Text( + cooldownText, + style: TextStyle( + fontSize: 9, + color: isReady ? Colors.green : Colors.orange, + fontWeight: isReady ? FontWeight.bold : FontWeight.normal, + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + + // 쿨타임 완료 시 glow 효과 + if (isReady) { + return AnimatedBuilder( + animation: glowAnimation, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: skillColor.withValues(alpha: glowAnimation.value * 0.5), + blurRadius: 8 * glowAnimation.value, + spreadRadius: 2 * glowAnimation.value, + ), + ], + ), + child: child, + ); + }, + child: row, + ); + } + + return row; + } + + IconData _getSkillIcon(SkillType type) { + switch (type) { + case SkillType.attack: + return Icons.flash_on; + case SkillType.heal: + return Icons.healing; + case SkillType.buff: + return Icons.arrow_upward; + case SkillType.debuff: + return Icons.arrow_downward; + } + } + + Color _getSkillColor(SkillType type) { + switch (type) { + case SkillType.attack: + return Colors.red; + case SkillType.heal: + return Colors.green; + case SkillType.buff: + return Colors.blue; + case SkillType.debuff: + return Colors.purple; + } + } +} diff --git a/test/features/game_play_screen_test.dart b/test/features/game_play_screen_test.dart index f3ddae9..749e548 100644 --- a/test/features/game_play_screen_test.dart +++ b/test/features/game_play_screen_test.dart @@ -146,10 +146,10 @@ void main() { _buildTestApp(GamePlayScreen(controller: controller)), ); - // Stats 섹션 확인 + // Stats 섹션 확인 (스크롤로 인해 화면 밖에 있을 수 있음) expect(find.text('Stats'), findsOneWidget); - expect(find.text('STR'), findsOneWidget); - expect(find.text('CON'), findsOneWidget); + expect(find.text('STR', skipOffstage: false), findsOneWidget); + expect(find.text('CON', skipOffstage: false), findsOneWidget); await controller.pause(saveOnStop: false); });