feat(ui): Phase 8 실시간 피드백 시스템 구현

- StatsPanel: 스탯 변화 애니메이션 (증감 표시)
- CombatLog: 전투 이벤트 로그 위젯
- NotificationService: 큐 기반 알림 관리
- NotificationOverlay: 레벨업/퀘스트 완료 팝업 알림
- GamePlayScreen: 새 위젯 통합
This commit is contained in:
JiWoong Sul
2025-12-17 18:33:21 +09:00
parent bfcec44ac7
commit 8cbef3475b
5 changed files with 760 additions and 48 deletions

View File

@@ -0,0 +1,149 @@
import 'dart:async';
/// 알림 타입 (Notification Type)
enum NotificationType {
levelUp, // 레벨업
questComplete, // 퀘스트 완료
actComplete, // 막(Act) 완료
newSpell, // 새 주문 습득
newEquipment, // 새 장비 획득
bossDefeat, // 보스 처치
}
/// 게임 알림 데이터 (Game Notification)
class GameNotification {
const GameNotification({
required this.type,
required this.title,
this.subtitle,
this.data,
this.duration = const Duration(seconds: 3),
});
final NotificationType type;
final String title;
final String? subtitle;
final Map<String, dynamic>? data;
final Duration duration;
}
/// 알림 서비스 (Phase 8: 이벤트 기반 알림 관리)
///
/// 게임 이벤트(레벨업, 퀘스트 완료 등)를 큐에 추가하고
/// 순차적으로 UI에 표시
class NotificationService {
NotificationService();
final _notificationController =
StreamController<GameNotification>.broadcast();
final _dismissController = StreamController<void>.broadcast();
/// 알림 스트림 (Notification Stream)
Stream<GameNotification> get notifications => _notificationController.stream;
/// 알림 닫기 스트림
Stream<void> get dismissals => _dismissController.stream;
/// 알림 큐 (대기 중인 알림)
final List<GameNotification> _queue = [];
bool _isShowing = false;
/// 알림 추가 (Add Notification)
void show(GameNotification notification) {
_queue.add(notification);
_processQueue();
}
/// 레벨업 알림 (Level Up Notification)
void showLevelUp(int newLevel) {
show(GameNotification(
type: NotificationType.levelUp,
title: 'LEVEL UP!',
subtitle: 'Level $newLevel',
data: {'level': newLevel},
duration: const Duration(seconds: 2),
));
}
/// 퀘스트 완료 알림
void showQuestComplete(String questName) {
show(GameNotification(
type: NotificationType.questComplete,
title: 'QUEST COMPLETE!',
subtitle: questName,
data: {'quest': questName},
duration: const Duration(seconds: 2),
));
}
/// 막 완료 알림 (Act Complete)
void showActComplete(int actNumber) {
show(GameNotification(
type: NotificationType.actComplete,
title: 'ACT $actNumber COMPLETE!',
duration: const Duration(seconds: 3),
));
}
/// 새 주문 알림
void showNewSpell(String spellName) {
show(GameNotification(
type: NotificationType.newSpell,
title: 'NEW SPELL!',
subtitle: spellName,
data: {'spell': spellName},
duration: const Duration(seconds: 2),
));
}
/// 새 장비 알림
void showNewEquipment(String equipmentName, String slot) {
show(GameNotification(
type: NotificationType.newEquipment,
title: 'NEW EQUIPMENT!',
subtitle: equipmentName,
data: {'equipment': equipmentName, 'slot': slot},
duration: const Duration(seconds: 2),
));
}
/// 보스 처치 알림
void showBossDefeat(String bossName) {
show(GameNotification(
type: NotificationType.bossDefeat,
title: 'BOSS DEFEATED!',
subtitle: bossName,
data: {'boss': bossName},
duration: const Duration(seconds: 3),
));
}
/// 큐 처리 (Process Queue)
void _processQueue() {
if (_isShowing || _queue.isEmpty) return;
_isShowing = true;
final notification = _queue.removeAt(0);
_notificationController.add(notification);
// 지정된 시간 후 자동 닫기
Future.delayed(notification.duration, () {
_dismissController.add(null);
_isShowing = false;
_processQueue(); // 다음 알림 처리
});
}
/// 현재 알림 즉시 닫기
void dismiss() {
_dismissController.add(null);
_isShowing = false;
_processQueue();
}
/// 서비스 정리 (Dispose)
void dispose() {
_notificationController.close();
_dismissController.close();
}
}

View File

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

View 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),
};
}
}

View 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,
),
};
}
}

View 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,
),
),
],
);
}
}