feat(ui): Phase 8 실시간 피드백 시스템 구현
- StatsPanel: 스탯 변화 애니메이션 (증감 표시) - CombatLog: 전투 이벤트 로그 위젯 - NotificationService: 큐 기반 알림 관리 - NotificationOverlay: 레벨업/퀘스트 완료 팝업 알림 - GamePlayScreen: 새 위젯 통합
This commit is contained in:
209
lib/src/features/game/widgets/stats_panel.dart
Normal file
209
lib/src/features/game/widgets/stats_panel.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:askiineverdie/l10n/app_localizations.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
|
||||
/// 스탯 표시 패널 (Phase 8: 실시간 변화 표시)
|
||||
///
|
||||
/// 장비 변경이나 버프 시 스탯 변화량을 애니메이션으로 표시
|
||||
class StatsPanel extends StatefulWidget {
|
||||
const StatsPanel({super.key, required this.stats});
|
||||
|
||||
final Stats stats;
|
||||
|
||||
@override
|
||||
State<StatsPanel> createState() => _StatsPanelState();
|
||||
}
|
||||
|
||||
class _StatsPanelState extends State<StatsPanel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// 변화량 맵 (스탯 이름 -> 변화량)
|
||||
final Map<String, int> _statChanges = {};
|
||||
|
||||
// 변화 애니메이션 컨트롤러
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(StatsPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 스탯 변화 감지
|
||||
_detectChanges(oldWidget.stats, widget.stats);
|
||||
}
|
||||
|
||||
void _detectChanges(Stats oldStats, Stats newStats) {
|
||||
final changes = <String, int>{};
|
||||
|
||||
if (newStats.str != oldStats.str) {
|
||||
changes['str'] = newStats.str - oldStats.str;
|
||||
}
|
||||
if (newStats.con != oldStats.con) {
|
||||
changes['con'] = newStats.con - oldStats.con;
|
||||
}
|
||||
if (newStats.dex != oldStats.dex) {
|
||||
changes['dex'] = newStats.dex - oldStats.dex;
|
||||
}
|
||||
if (newStats.intelligence != oldStats.intelligence) {
|
||||
changes['int'] = newStats.intelligence - oldStats.intelligence;
|
||||
}
|
||||
if (newStats.wis != oldStats.wis) {
|
||||
changes['wis'] = newStats.wis - oldStats.wis;
|
||||
}
|
||||
if (newStats.cha != oldStats.cha) {
|
||||
changes['cha'] = newStats.cha - oldStats.cha;
|
||||
}
|
||||
if (newStats.hpMax != oldStats.hpMax) {
|
||||
changes['hpMax'] = newStats.hpMax - oldStats.hpMax;
|
||||
}
|
||||
if (newStats.mpMax != oldStats.mpMax) {
|
||||
changes['mpMax'] = newStats.mpMax - oldStats.mpMax;
|
||||
}
|
||||
|
||||
if (changes.isNotEmpty) {
|
||||
setState(() {
|
||||
_statChanges
|
||||
..clear()
|
||||
..addAll(changes);
|
||||
});
|
||||
|
||||
// 애니메이션 재시작
|
||||
_animationController
|
||||
..reset()
|
||||
..forward().then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statChanges.clear();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
final stats = [
|
||||
('str', l10n.statStr, widget.stats.str),
|
||||
('con', l10n.statCon, widget.stats.con),
|
||||
('dex', l10n.statDex, widget.stats.dex),
|
||||
('int', l10n.statInt, widget.stats.intelligence),
|
||||
('wis', l10n.statWis, widget.stats.wis),
|
||||
('cha', l10n.statCha, widget.stats.cha),
|
||||
('hpMax', l10n.statHpMax, widget.stats.hpMax),
|
||||
('mpMax', l10n.statMpMax, widget.stats.mpMax),
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: stats.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final stat = stats[index];
|
||||
final change = _statChanges[stat.$1];
|
||||
|
||||
return _StatRow(
|
||||
label: stat.$2,
|
||||
value: stat.$3,
|
||||
change: change,
|
||||
fadeAnimation: _fadeAnimation,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개별 스탯 행 위젯
|
||||
class _StatRow extends StatelessWidget {
|
||||
const _StatRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.change,
|
||||
required this.fadeAnimation,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int value;
|
||||
final int? change;
|
||||
final Animation<double> fadeAnimation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(label, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
Text(
|
||||
'$value',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (change != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
AnimatedBuilder(
|
||||
animation: fadeAnimation,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: fadeAnimation.value,
|
||||
child: _ChangeIndicator(change: change!),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 변화량 표시 위젯
|
||||
class _ChangeIndicator extends StatelessWidget {
|
||||
const _ChangeIndicator({required this.change});
|
||||
|
||||
final int change;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPositive = change > 0;
|
||||
final color = isPositive ? Colors.green : Colors.red;
|
||||
final icon = isPositive ? Icons.arrow_upward : Icons.arrow_downward;
|
||||
final text = isPositive ? '+$change' : '$change';
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user