feat(ui): Phase 8 실시간 피드백 시스템 구현
- StatsPanel: 스탯 변화 애니메이션 (증감 표시) - CombatLog: 전투 이벤트 로그 위젯 - NotificationService: 큐 기반 알림 관리 - NotificationOverlay: 레벨업/퀘스트 완료 팝업 알림 - GamePlayScreen: 새 위젯 통합
This commit is contained in:
@@ -4,8 +4,11 @@ import 'package:askiineverdie/l10n/app_localizations.dart';
|
||||
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
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/notification_overlay.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart';
|
||||
|
||||
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
||||
@@ -24,6 +27,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
with WidgetsBindingObserver {
|
||||
AsciiAnimationType? _specialAnimation;
|
||||
|
||||
// Phase 8: 알림 서비스 (Notification Service)
|
||||
late final NotificationService _notificationService;
|
||||
|
||||
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
|
||||
int _lastLevel = 0;
|
||||
int _lastQuestCount = 0;
|
||||
@@ -33,6 +39,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
// 레벨업 감지
|
||||
if (state.traits.level > _lastLevel && _lastLevel > 0) {
|
||||
_specialAnimation = AsciiAnimationType.levelUp;
|
||||
_notificationService.showLevelUp(state.traits.level);
|
||||
_resetSpecialAnimationAfterFrame();
|
||||
}
|
||||
_lastLevel = state.traits.level;
|
||||
@@ -40,6 +47,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
// 퀘스트 완료 감지
|
||||
if (state.progress.questCount > _lastQuestCount && _lastQuestCount > 0) {
|
||||
_specialAnimation = AsciiAnimationType.questComplete;
|
||||
// 완료된 퀘스트 이름 가져오기
|
||||
final completedQuest = state.progress.questHistory
|
||||
.where((q) => q.isComplete)
|
||||
.lastOrNull;
|
||||
if (completedQuest != null) {
|
||||
_notificationService.showQuestComplete(completedQuest.caption);
|
||||
}
|
||||
_resetSpecialAnimationAfterFrame();
|
||||
}
|
||||
_lastQuestCount = state.progress.questCount;
|
||||
@@ -48,6 +62,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
if (state.progress.plotStageCount > _lastPlotStageCount &&
|
||||
_lastPlotStageCount > 0) {
|
||||
_specialAnimation = AsciiAnimationType.actComplete;
|
||||
_notificationService.showActComplete(state.progress.plotStageCount - 1);
|
||||
_resetSpecialAnimationAfterFrame();
|
||||
}
|
||||
_lastPlotStageCount = state.progress.plotStageCount;
|
||||
@@ -67,6 +82,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_notificationService = NotificationService();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
@@ -81,6 +97,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notificationService.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
super.dispose();
|
||||
@@ -154,19 +171,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _onPopInvoked();
|
||||
if (shouldPop && context.mounted) {
|
||||
await widget.controller.pause(saveOnStop: false);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
return NotificationOverlay(
|
||||
notificationService: _notificationService,
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _onPopInvoked();
|
||||
if (shouldPop && context.mounted) {
|
||||
await widget.controller.pause(saveOnStop: false);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(L10n.of(context).progressQuestTitle(state.traits.name)),
|
||||
actions: [
|
||||
@@ -230,6 +249,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -248,9 +268,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
_buildSectionHeader(l10n.traits),
|
||||
_buildTraitsList(state),
|
||||
|
||||
// Stats 목록
|
||||
// Stats 목록 (Phase 8: 애니메이션 변화 표시)
|
||||
_buildSectionHeader(l10n.stats),
|
||||
Expanded(flex: 2, child: _buildStatsList(state)),
|
||||
Expanded(flex: 2, child: StatsPanel(stats: state.stats)),
|
||||
|
||||
// Experience 바
|
||||
_buildSectionHeader(l10n.experience),
|
||||
@@ -425,40 +445,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsList(GameState state) {
|
||||
final l10n = L10n.of(context);
|
||||
final stats = [
|
||||
(l10n.statStr, state.stats.str),
|
||||
(l10n.statCon, state.stats.con),
|
||||
(l10n.statDex, state.stats.dex),
|
||||
(l10n.statInt, state.stats.intelligence),
|
||||
(l10n.statWis, state.stats.wis),
|
||||
(l10n.statCha, state.stats.cha),
|
||||
(l10n.statHpMax, state.stats.hpMax),
|
||||
(l10n.statMpMax, state.stats.mpMax),
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: stats.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final stat = stats[index];
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(stat.$1, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
Text(
|
||||
'${stat.$2}',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpellsList(GameState state) {
|
||||
if (state.spellBook.spells.isEmpty) {
|
||||
return Center(
|
||||
|
||||
150
lib/src/features/game/widgets/combat_log.dart
Normal file
150
lib/src/features/game/widgets/combat_log.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 전투 로그 엔트리 (Combat Log Entry)
|
||||
class CombatLogEntry {
|
||||
const CombatLogEntry({
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
this.type = CombatLogType.normal,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final DateTime timestamp;
|
||||
final CombatLogType type;
|
||||
}
|
||||
|
||||
/// 로그 타입에 따른 스타일 구분
|
||||
enum CombatLogType {
|
||||
normal, // 일반 메시지
|
||||
damage, // 피해 입힘
|
||||
heal, // 회복
|
||||
levelUp, // 레벨업
|
||||
questComplete, // 퀘스트 완료
|
||||
loot, // 전리품 획득
|
||||
spell, // 주문 습득
|
||||
}
|
||||
|
||||
/// 전투 로그 위젯 (Phase 8: 실시간 전투 이벤트 표시)
|
||||
///
|
||||
/// 최근 전투 이벤트를 스크롤 가능한 리스트로 표시
|
||||
class CombatLog extends StatefulWidget {
|
||||
const CombatLog({super.key, required this.entries, this.maxEntries = 50});
|
||||
|
||||
final List<CombatLogEntry> entries;
|
||||
final int maxEntries;
|
||||
|
||||
@override
|
||||
State<CombatLog> createState() => _CombatLogState();
|
||||
}
|
||||
|
||||
class _CombatLogState extends State<CombatLog> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
int _previousLength = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CombatLog oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 새 로그 추가 시 자동 스크롤
|
||||
if (widget.entries.length > _previousLength) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
_previousLength = widget.entries.length;
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: widget.entries.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = widget.entries[index];
|
||||
return _LogEntryTile(entry: entry);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개별 로그 엔트리 타일
|
||||
class _LogEntryTile extends StatelessWidget {
|
||||
const _LogEntryTile({required this.entry});
|
||||
|
||||
final CombatLogEntry entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, icon) = _getStyleForType(entry.type);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 타임스탬프(timestamp)
|
||||
Text(
|
||||
_formatTime(entry.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 아이콘
|
||||
if (icon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(icon, size: 12, color: color),
|
||||
),
|
||||
// 메시지
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.message,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color ?? Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:'
|
||||
'${time.minute.toString().padLeft(2, '0')}:'
|
||||
'${time.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
(Color?, IconData?) _getStyleForType(CombatLogType type) {
|
||||
return switch (type) {
|
||||
CombatLogType.normal => (null, null),
|
||||
CombatLogType.damage => (Colors.red.shade300, Icons.local_fire_department),
|
||||
CombatLogType.heal => (Colors.green.shade300, Icons.healing),
|
||||
CombatLogType.levelUp => (Colors.amber, Icons.arrow_upward),
|
||||
CombatLogType.questComplete => (Colors.blue.shade300, Icons.check_circle),
|
||||
CombatLogType.loot => (Colors.orange.shade300, Icons.inventory_2),
|
||||
CombatLogType.spell => (Colors.purple.shade300, Icons.auto_fix_high),
|
||||
};
|
||||
}
|
||||
}
|
||||
218
lib/src/features/game/widgets/notification_overlay.dart
Normal file
218
lib/src/features/game/widgets/notification_overlay.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:askiineverdie/src/core/notification/notification_service.dart';
|
||||
|
||||
/// 알림 오버레이 위젯 (Phase 8: 팝업/토스트 알림)
|
||||
///
|
||||
/// 화면 상단에 알림을 슬라이드 인/아웃 애니메이션으로 표시
|
||||
class NotificationOverlay extends StatefulWidget {
|
||||
const NotificationOverlay({
|
||||
super.key,
|
||||
required this.notificationService,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final NotificationService notificationService;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<NotificationOverlay> createState() => _NotificationOverlayState();
|
||||
}
|
||||
|
||||
class _NotificationOverlayState extends State<NotificationOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
GameNotification? _currentNotification;
|
||||
late AnimationController _animationController;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
StreamSubscription<GameNotification>? _notificationSub;
|
||||
StreamSubscription<void>? _dismissSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, -1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
));
|
||||
|
||||
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
_notificationSub =
|
||||
widget.notificationService.notifications.listen(_onNotification);
|
||||
_dismissSub = widget.notificationService.dismissals.listen(_onDismiss);
|
||||
}
|
||||
|
||||
void _onNotification(GameNotification notification) {
|
||||
setState(() => _currentNotification = notification);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
void _onDismiss(void _) {
|
||||
_animationController.reverse().then((_) {
|
||||
if (mounted) {
|
||||
setState(() => _currentNotification = null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notificationSub?.cancel();
|
||||
_dismissSub?.cancel();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
if (_currentNotification != null)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: _NotificationCard(
|
||||
notification: _currentNotification!,
|
||||
onDismiss: widget.notificationService.dismiss,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 알림 카드 위젯
|
||||
class _NotificationCard extends StatelessWidget {
|
||||
const _NotificationCard({
|
||||
required this.notification,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final GameNotification notification;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (bgColor, icon, iconColor) = _getStyleForType(notification.type);
|
||||
|
||||
return Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: bgColor,
|
||||
child: InkWell(
|
||||
onTap: onDismiss,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// 아이콘
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 텍스트
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
notification.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (notification.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
notification.subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// 닫기 버튼
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
|
||||
onPressed: onDismiss,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(Color, IconData, Color) _getStyleForType(NotificationType type) {
|
||||
return switch (type) {
|
||||
NotificationType.levelUp => (
|
||||
const Color(0xFF1565C0),
|
||||
Icons.trending_up,
|
||||
Colors.amber,
|
||||
),
|
||||
NotificationType.questComplete => (
|
||||
const Color(0xFF2E7D32),
|
||||
Icons.check_circle,
|
||||
Colors.lightGreen,
|
||||
),
|
||||
NotificationType.actComplete => (
|
||||
const Color(0xFF6A1B9A),
|
||||
Icons.flag,
|
||||
Colors.purpleAccent,
|
||||
),
|
||||
NotificationType.newSpell => (
|
||||
const Color(0xFF4527A0),
|
||||
Icons.auto_fix_high,
|
||||
Colors.deepPurpleAccent,
|
||||
),
|
||||
NotificationType.newEquipment => (
|
||||
const Color(0xFFE65100),
|
||||
Icons.shield,
|
||||
Colors.orange,
|
||||
),
|
||||
NotificationType.bossDefeat => (
|
||||
const Color(0xFFC62828),
|
||||
Icons.whatshot,
|
||||
Colors.redAccent,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
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