feat(ui): Phase 8 UI/UX 개선 완료

- CombatLog 위젯 게임 화면에 통합
- HP/MP 바 추가 (HP < 20% 깜빡임 효과)
- SkillPanel 추가 (쿨타임 완료 시 glow 효과)
- combatLog 로컬라이제이션 (4개 언어)
- 테스트 수정 (skipOffstage 처리)
This commit is contained in:
JiWoong Sul
2025-12-17 18:52:24 +09:00
parent abcb89d334
commit 7c7f3b0d9e
13 changed files with 480 additions and 4 deletions

View File

@@ -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" },

View File

@@ -30,6 +30,7 @@
"equipment": "Equipment",
"inventory": "Inventory",
"encumbrance": "Encumbrance",
"combatLog": "戦闘ログ",
"plotDevelopment": "Plot Development",
"quests": "Quests",
"traitName": "Name",

View File

@@ -30,6 +30,7 @@
"equipment": "장비",
"inventory": "인벤토리",
"encumbrance": "적재량",
"combatLog": "전투 로그",
"plotDevelopment": "스토리 진행",
"quests": "퀘스트",
"traitName": "이름",

View File

@@ -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:

View File

@@ -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';

View File

@@ -97,6 +97,9 @@ class L10nJa extends L10n {
@override
String get encumbrance => 'Encumbrance';
@override
String get combatLog => '戦闘ログ';
@override
String get plotDevelopment => 'Plot Development';

View File

@@ -97,6 +97,9 @@ class L10nKo extends L10n {
@override
String get encumbrance => '적재량';
@override
String get combatLog => '전투 로그';
@override
String get plotDevelopment => '스토리 진행';

View File

@@ -97,6 +97,9 @@ class L10nZh extends L10n {
@override
String get encumbrance => 'Encumbrance';
@override
String get combatLog => '战斗日志';
@override
String get plotDevelopment => 'Plot Development';

View File

@@ -30,6 +30,7 @@
"equipment": "Equipment",
"inventory": "Inventory",
"encumbrance": "Encumbrance",
"combatLog": "战斗日志",
"plotDevelopment": "Plot Development",
"quests": "Quests",
"traitName": "Name",

View File

@@ -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<GamePlayScreen>
StoryAct _lastAct = StoryAct.prologue;
bool _showingCinematic = false;
// Phase 8: 전투 로그 (Combat Log)
final List<CombatLogEntry> _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<GamePlayScreen>
.lastOrNull;
if (completedQuest != null) {
_notificationService.showQuestComplete(completedQuest.caption);
_addCombatLog('Quest Complete: ${completedQuest.caption}', CombatLogType.questComplete);
}
_resetSpecialAnimationAfterFrame();
}
@@ -83,6 +99,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_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<void> _showCinematicForAct(StoryAct act) async {
if (_showingCinematic) return;
@@ -309,6 +338,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_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<GamePlayScreen>
// 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<GamePlayScreen>
// 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<GamePlayScreen>
state.progress.encumbrance.max,
Colors.orange,
),
// Phase 8: 전투 로그 (Combat Log)
_buildPanelHeader(l10n.combatLog),
Expanded(flex: 2, child: CombatLog(entries: _combatLogEntries)),
],
),
);

View File

@@ -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<HpMpBar> createState() => _HpMpBarState();
}
class _HpMpBarState extends State<HpMpBar> with SingleTickerProviderStateMixin {
late AnimationController _blinkController;
late Animation<double> _blinkAnimation;
@override
void initState() {
super.initState();
_blinkController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_blinkAnimation = Tween<double>(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>(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;
}
}

View File

@@ -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<SkillPanel> createState() => _SkillPanelState();
}
class _SkillPanelState extends State<SkillPanel>
with SingleTickerProviderStateMixin {
late AnimationController _glowController;
late Animation<double> _glowAnimation;
// 이전 쿨타임 완료 상태 추적
final Map<String, bool> _previousReadyState = {};
@override
void initState() {
super.initState();
_glowController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_glowAnimation = Tween<double>(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<double> 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;
}
}
}

View File

@@ -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);
});