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

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