feat(ui): 게임 위젯들 레트로 UI 적용

- death_overlay: 사망 화면 레트로 스타일로 재디자인
- help_dialog: RetroDialog 사용으로 통일
- hp_mp_bar: 레트로 프로그레스 바 스타일 적용
- notification_overlay: 레트로 패널 스타일 적용
- statistics_dialog: RetroDialog로 변경
This commit is contained in:
JiWoong Sul
2025-12-30 19:03:52 +09:00
parent af837fde8a
commit 27e05fb3c1
5 changed files with 742 additions and 605 deletions

View File

@@ -4,6 +4,7 @@ import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart'; import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart'; import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
/// 사망 오버레이 위젯 (Phase 4) /// 사망 오버레이 위젯 (Phase 4)
/// ///
@@ -27,96 +28,161 @@ class DeathOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Material( return Material(
color: Colors.black87, color: Colors.black.withValues(alpha: 0.9),
child: Center( child: Center(
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 420),
margin: const EdgeInsets.all(24), margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: RetroColors.panelBg,
borderRadius: BorderRadius.circular(16), border: const Border(
border: Border.all( top: BorderSide(color: RetroColors.hpRed, width: 4),
color: colorScheme.error.withValues(alpha: 0.5), left: BorderSide(color: RetroColors.hpRed, width: 4),
width: 2, bottom: BorderSide(color: RetroColors.panelBorderOuter, width: 4),
right: BorderSide(color: RetroColors.panelBorderOuter, width: 4),
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: colorScheme.error.withValues(alpha: 0.3), color: RetroColors.hpRed.withValues(alpha: 0.5),
blurRadius: 20, blurRadius: 30,
spreadRadius: 5, spreadRadius: 5,
), ),
], ],
), ),
child: SingleChildScrollView( child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ // 헤더 바
// 사망 타이틀 Container(
_buildDeathTitle(context), width: double.infinity,
const SizedBox(height: 16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: RetroColors.hpRed.withValues(alpha: 0.3),
border: const Border(
bottom: BorderSide(color: RetroColors.hpRed, width: 2),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'',
style: TextStyle(fontSize: 16, color: RetroColors.hpRed),
),
SizedBox(width: 8),
Text(
'GAME OVER',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.hpRed,
letterSpacing: 2,
),
),
SizedBox(width: 8),
Text(
'',
style: TextStyle(fontSize: 16, color: RetroColors.hpRed),
),
],
),
),
// 본문
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 사망 타이틀
_buildDeathTitle(context),
const SizedBox(height: 16),
// 캐릭터 정보 // 캐릭터 정보
_buildCharacterInfo(context), _buildCharacterInfo(context),
const SizedBox(height: 16), const SizedBox(height: 16),
// 사망 원인 // 사망 원인
_buildDeathCause(context), _buildDeathCause(context),
const SizedBox(height: 24), const SizedBox(height: 20),
// 구분선 // 구분선
Divider(color: colorScheme.outlineVariant), _buildRetroDivider(),
const SizedBox(height: 16), const SizedBox(height: 16),
// 상실 정보 // 상실 정보
_buildLossInfo(context), _buildLossInfo(context),
// 전투 로그 (있는 경우만 표시) // 전투 로그 (있는 경우만 표시)
if (deathInfo.lastCombatEvents.isNotEmpty) ...[ if (deathInfo.lastCombatEvents.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Divider(color: colorScheme.outlineVariant), _buildRetroDivider(),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildCombatLog(context), _buildCombatLog(context),
], ],
const SizedBox(height: 24), const SizedBox(height: 24),
// 부활 버튼 // 부활 버튼
_buildResurrectButton(context), _buildResurrectButton(context),
], ],
), ),
),
],
), ),
), ),
), ),
); );
} }
/// 레트로 스타일 구분선
Widget _buildRetroDivider() {
return Container(
height: 2,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
RetroColors.hpRedDark,
RetroColors.hpRed,
RetroColors.hpRedDark,
Colors.transparent,
],
),
),
);
}
Widget _buildDeathTitle(BuildContext context) { Widget _buildDeathTitle(BuildContext context) {
return Column( return Column(
children: [ children: [
// ASCII 스컬 // ASCII 스컬 (더 큰 버전)
Text( const Text(
' _____\n / \\\n| () () |\n \\ ^ /\n |||||', ' _____ \n'
' / \\\n'
' | () () |\n'
' \\ ^ /\n'
' ||||| ',
style: TextStyle( style: TextStyle(
fontFamily: 'JetBrainsMono', fontFamily: 'JetBrainsMono',
fontSize: 14, fontSize: 12,
color: Theme.of(context).colorScheme.error, color: RetroColors.hpRed,
height: 1.0, height: 1.0,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
Text( Text(
l10n.deathYouDied, l10n.deathYouDied.toUpperCase(),
style: TextStyle( style: const TextStyle(
fontSize: 32, fontFamily: 'PressStart2P',
fontWeight: FontWeight.bold, fontSize: 14,
color: Theme.of(context).colorScheme.error, color: RetroColors.hpRed,
letterSpacing: 4, letterSpacing: 2,
shadows: [
Shadow(color: Colors.black, blurRadius: 4),
Shadow(color: RetroColors.hpRedDark, blurRadius: 8),
],
), ),
), ),
], ],
@@ -124,49 +190,62 @@ class DeathOverlay extends StatelessWidget {
} }
Widget _buildCharacterInfo(BuildContext context) { Widget _buildCharacterInfo(BuildContext context) {
final theme = Theme.of(context); return Container(
return Column( padding: const EdgeInsets.all(12),
children: [ decoration: BoxDecoration(
Text( color: RetroColors.panelBgLight.withValues(alpha: 0.5),
traits.name, border: Border.all(color: RetroColors.panelBorderInner, width: 1),
style: theme.textTheme.titleLarge?.copyWith( ),
fontWeight: FontWeight.bold, child: Column(
children: [
Text(
traits.name,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.gold,
),
), ),
), const SizedBox(height: 6),
const SizedBox(height: 4), Text(
Text( 'Lv.${deathInfo.levelAtDeath} ${GameDataL10n.getKlassName(context, traits.klass)}',
'Level ${deathInfo.levelAtDeath} ${GameDataL10n.getKlassName(context, traits.klass)}', style: const TextStyle(
style: theme.textTheme.bodyLarge?.copyWith( fontFamily: 'PressStart2P',
color: theme.colorScheme.onSurfaceVariant, fontSize: 8,
color: RetroColors.textLight,
),
), ),
), ],
], ),
); );
} }
Widget _buildDeathCause(BuildContext context) { Widget _buildDeathCause(BuildContext context) {
final theme = Theme.of(context);
final causeText = _getDeathCauseText(); final causeText = _getDeathCauseText();
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.3), color: RetroColors.hpRedDark.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8), border: Border.all(color: RetroColors.hpRed.withValues(alpha: 0.5)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( const Text(
Icons.dangerous_outlined, '',
size: 20, style: TextStyle(fontSize: 14, color: RetroColors.hpRed),
color: theme.colorScheme.error,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Flexible(
causeText, child: Text(
style: theme.textTheme.bodyMedium?.copyWith( causeText,
color: theme.colorScheme.error, style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.hpRed,
),
textAlign: TextAlign.center,
), ),
), ),
], ],
@@ -183,7 +262,6 @@ class DeathOverlay extends StatelessWidget {
} }
Widget _buildLossInfo(BuildContext context) { Widget _buildLossInfo(BuildContext context) {
final theme = Theme.of(context);
final hasLostItem = deathInfo.lostItemName != null; final hasLostItem = deathInfo.lostItemName != null;
return Column( return Column(
@@ -191,20 +269,18 @@ class DeathOverlay extends StatelessWidget {
// 제물로 바친 아이템 표시 // 제물로 바친 아이템 표시
if (hasLostItem) ...[ if (hasLostItem) ...[
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.2), color: RetroColors.hpRedDark.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3), color: RetroColors.hpRed.withValues(alpha: 0.4),
), ),
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Text(
Icons.local_fire_department, '🔥',
size: 20, style: TextStyle(fontSize: 16),
color: theme.colorScheme.error,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -212,17 +288,20 @@ class DeathOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
l10n.deathSacrificedToResurrect, l10n.deathSacrificedToResurrect.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith( style: const TextStyle(
color: theme.colorScheme.onSurfaceVariant, fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textDisabled,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 4),
Text( Text(
deathInfo.lostItemName!, deathInfo.lostItemName!,
style: theme.textTheme.bodyMedium?.copyWith( style: const TextStyle(
fontWeight: FontWeight.bold, fontFamily: 'PressStart2P',
color: theme.colorScheme.error, fontSize: 7,
color: RetroColors.hpRed,
), ),
), ),
], ],
@@ -235,19 +314,19 @@ class DeathOverlay extends StatelessWidget {
] else ...[ ] else ...[
_buildInfoRow( _buildInfoRow(
context, context,
icon: Icons.check_circle_outline, asciiIcon: '',
label: l10n.deathEquipment, label: l10n.deathEquipment,
value: l10n.deathNoSacrificeNeeded, value: l10n.deathNoSacrificeNeeded,
isNegative: false, valueColor: RetroColors.expGreen,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
_buildInfoRow( _buildInfoRow(
context, context,
icon: Icons.monetization_on_outlined, asciiIcon: '💰',
label: l10n.deathCoinRemaining, label: l10n.deathCoinRemaining,
value: _formatGold(deathInfo.goldAtDeath), value: _formatGold(deathInfo.goldAtDeath),
isNegative: false, valueColor: RetroColors.gold,
), ),
], ],
); );
@@ -255,35 +334,36 @@ class DeathOverlay extends StatelessWidget {
Widget _buildInfoRow( Widget _buildInfoRow(
BuildContext context, { BuildContext context, {
required IconData icon, required String asciiIcon,
required String label, required String label,
required String value, required String value,
required bool isNegative, required Color valueColor,
}) { }) {
final theme = Theme.of(context);
final valueColor = isNegative
? theme.colorScheme.error
: theme.colorScheme.primary;
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Row(
children: [ children: [
Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant), Text(
asciiIcon,
style: TextStyle(fontSize: 14, color: valueColor),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
label, label,
style: theme.textTheme.bodyMedium?.copyWith( style: const TextStyle(
color: theme.colorScheme.onSurfaceVariant, fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textDisabled,
), ),
), ),
], ],
), ),
Text( Text(
value, value,
style: theme.textTheme.bodyMedium?.copyWith( style: TextStyle(
fontWeight: FontWeight.bold, fontFamily: 'PressStart2P',
fontSize: 7,
color: valueColor, color: valueColor,
), ),
), ),
@@ -301,17 +381,48 @@ class DeathOverlay extends StatelessWidget {
} }
Widget _buildResurrectButton(BuildContext context) { Widget _buildResurrectButton(BuildContext context) {
final theme = Theme.of(context); return GestureDetector(
onTap: onResurrect,
return SizedBox( child: Container(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( padding: const EdgeInsets.symmetric(vertical: 12),
onPressed: onResurrect, decoration: BoxDecoration(
icon: const Icon(Icons.replay), color: RetroColors.expGreen.withValues(alpha: 0.2),
label: Text(l10n.deathResurrect), border: Border(
style: FilledButton.styleFrom( top: const BorderSide(color: RetroColors.expGreen, width: 3),
backgroundColor: theme.colorScheme.primary, left: const BorderSide(color: RetroColors.expGreen, width: 3),
padding: const EdgeInsets.symmetric(vertical: 16), bottom: BorderSide(
color: RetroColors.expGreenDark.withValues(alpha: 0.8),
width: 3,
),
right: BorderSide(
color: RetroColors.expGreenDark.withValues(alpha: 0.8),
width: 3,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'',
style: TextStyle(
fontSize: 16,
color: RetroColors.expGreen,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Text(
l10n.deathResurrect.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: RetroColors.expGreen,
letterSpacing: 1,
),
),
],
), ),
), ),
); );
@@ -319,29 +430,38 @@ class DeathOverlay extends StatelessWidget {
/// 사망 직전 전투 로그 표시 /// 사망 직전 전투 로그 표시
Widget _buildCombatLog(BuildContext context) { Widget _buildCombatLog(BuildContext context) {
final theme = Theme.of(context);
final events = deathInfo.lastCombatEvents; final events = deathInfo.lastCombatEvents;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
l10n.deathCombatLog, children: [
style: theme.textTheme.labelMedium?.copyWith( const Text(
color: theme.colorScheme.onSurfaceVariant, '📜',
fontWeight: FontWeight.bold, style: TextStyle(fontSize: 12),
), ),
const SizedBox(width: 6),
Text(
l10n.deathCombatLog.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.gold,
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
constraints: const BoxConstraints(maxHeight: 120), constraints: const BoxConstraints(maxHeight: 100),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest, color: RetroColors.deepBrown,
borderRadius: BorderRadius.circular(8), border: Border.all(color: RetroColors.panelBorderOuter, width: 2),
), ),
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(6),
itemCount: events.length, itemCount: events.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final event = events[index]; final event = events[index];
@@ -355,22 +475,26 @@ class DeathOverlay extends StatelessWidget {
/// 개별 전투 이벤트 타일 /// 개별 전투 이벤트 타일
Widget _buildCombatEventTile(BuildContext context, CombatEvent event) { Widget _buildCombatEventTile(BuildContext context, CombatEvent event) {
final (icon, color, message) = _formatCombatEvent(event); final (asciiIcon, color, message) = _formatCombatEvent(event);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(vertical: 1),
child: Row( child: Row(
children: [ children: [
Icon(icon, size: 12, color: color), Text(
const SizedBox(width: 6), asciiIcon,
style: TextStyle(fontSize: 10, color: color),
),
const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
message, message,
style: TextStyle( style: TextStyle(
fontSize: 11,
color: color,
fontFamily: 'JetBrainsMono', fontFamily: 'JetBrainsMono',
fontSize: 8,
color: color,
), ),
overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
@@ -378,75 +502,75 @@ class DeathOverlay extends StatelessWidget {
); );
} }
/// 전투 이벤트를 아이콘, 색상, 메시지로 포맷 /// 전투 이벤트를 ASCII 아이콘, 색상, 메시지로 포맷
(IconData, Color, String) _formatCombatEvent(CombatEvent event) { (String, Color, String) _formatCombatEvent(CombatEvent event) {
final target = event.targetName ?? ''; final target = event.targetName ?? '';
return switch (event.type) { return switch (event.type) {
CombatEventType.playerAttack => ( CombatEventType.playerAttack => (
event.isCritical ? Icons.flash_on : Icons.local_fire_department, event.isCritical ? '' : '',
event.isCritical ? Colors.yellow.shade300 : Colors.green.shade300, event.isCritical ? RetroColors.gold : RetroColors.expGreen,
event.isCritical event.isCritical
? l10n.combatCritical(event.damage, target) ? l10n.combatCritical(event.damage, target)
: l10n.combatYouHit(target, event.damage), : l10n.combatYouHit(target, event.damage),
), ),
CombatEventType.monsterAttack => ( CombatEventType.monsterAttack => (
Icons.dangerous, '💀',
Colors.red.shade300, RetroColors.hpRed,
l10n.combatMonsterHitsYou(target, event.damage), l10n.combatMonsterHitsYou(target, event.damage),
), ),
CombatEventType.playerEvade => ( CombatEventType.playerEvade => (
Icons.directions_run, '',
Colors.cyan.shade300, RetroColors.asciiCyan,
l10n.combatEvadedAttackFrom(target), l10n.combatEvadedAttackFrom(target),
), ),
CombatEventType.monsterEvade => ( CombatEventType.monsterEvade => (
Icons.directions_run, '',
Colors.orange.shade300, const Color(0xFFFF9933),
l10n.combatMonsterEvaded(target), l10n.combatMonsterEvaded(target),
), ),
CombatEventType.playerBlock => ( CombatEventType.playerBlock => (
Icons.shield, '🛡',
Colors.blueGrey.shade300, RetroColors.mpBlue,
l10n.combatBlockedAttack(target, event.damage), l10n.combatBlockedAttack(target, event.damage),
), ),
CombatEventType.playerParry => ( CombatEventType.playerParry => (
Icons.sports_kabaddi, '',
Colors.teal.shade300, const Color(0xFF00CCCC),
l10n.combatParriedAttack(target, event.damage), l10n.combatParriedAttack(target, event.damage),
), ),
CombatEventType.playerSkill => ( CombatEventType.playerSkill => (
Icons.auto_fix_high, '',
Colors.purple.shade300, const Color(0xFF9966FF),
l10n.combatSkillDamage(event.skillName ?? '', event.damage), l10n.combatSkillDamage(event.skillName ?? '', event.damage),
), ),
CombatEventType.playerHeal => ( CombatEventType.playerHeal => (
Icons.healing, '',
Colors.green.shade300, RetroColors.expGreen,
l10n.combatHealedFor(event.healAmount), l10n.combatHealedFor(event.healAmount),
), ),
CombatEventType.playerBuff => ( CombatEventType.playerBuff => (
Icons.trending_up, '',
Colors.lightBlue.shade300, RetroColors.mpBlue,
l10n.combatBuffActivated(event.skillName ?? ''), l10n.combatBuffActivated(event.skillName ?? ''),
), ),
CombatEventType.playerDebuff => ( CombatEventType.playerDebuff => (
Icons.trending_down, '',
Colors.deepOrange.shade300, const Color(0xFFFF6633),
l10n.combatDebuffApplied(event.skillName ?? '', target), l10n.combatDebuffApplied(event.skillName ?? '', target),
), ),
CombatEventType.dotTick => ( CombatEventType.dotTick => (
Icons.whatshot, '🔥',
Colors.deepOrange.shade300, const Color(0xFFFF6633),
l10n.combatDotTick(event.skillName ?? '', event.damage), l10n.combatDotTick(event.skillName ?? '', event.damage),
), ),
CombatEventType.playerPotion => ( CombatEventType.playerPotion => (
Icons.local_drink, '🧪',
Colors.lightGreen.shade300, RetroColors.expGreen,
l10n.combatPotionUsed(event.skillName ?? '', event.healAmount, target), l10n.combatPotionUsed(event.skillName ?? '', event.healAmount, target),
), ),
CombatEventType.potionDrop => ( CombatEventType.potionDrop => (
Icons.card_giftcard, '🎁',
Colors.lime.shade300, RetroColors.gold,
l10n.combatPotionDrop(event.skillName ?? ''), l10n.combatPotionDrop(event.skillName ?? ''),
), ),
}; };

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
import 'package:askiineverdie/src/shared/widgets/retro_dialog.dart';
/// 도움말 다이얼로그 (Help Dialog) /// 도움말 다이얼로그 (Help Dialog)
/// ///
/// 게임 메카닉과 UI 설명을 제공 /// 게임 메카닉과 UI 설명을 제공
@@ -10,6 +13,7 @@ class HelpDialog extends StatefulWidget {
static Future<void> show(BuildContext context) { static Future<void> show(BuildContext context) {
return showDialog( return showDialog(
context: context, context: context,
barrierColor: Colors.black87,
builder: (_) => const HelpDialog(), builder: (_) => const HelpDialog(),
); );
} }
@@ -36,113 +40,58 @@ class _HelpDialogState extends State<HelpDialog>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isKorean = Localizations.localeOf(context).languageCode == 'ko'; final isKorean = Localizations.localeOf(context).languageCode == 'ko';
final isJapanese = Localizations.localeOf(context).languageCode == 'ja'; final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
return Dialog( final title = isKorean
child: Container( ? '도움말'
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 600), : isJapanese
child: Column( ? 'ヘルプ'
mainAxisSize: MainAxisSize.min, : 'Help';
children: [
// 헤더 final tabs = isKorean
Container( ? ['기본', '전투', '스킬', 'UI']
padding: const EdgeInsets.all(16), : isJapanese
decoration: BoxDecoration( ? ['基本', '戦闘', 'スキル', 'UI']
color: theme.colorScheme.primaryContainer, : ['Basics', 'Combat', 'Skills', 'UI'];
borderRadius: const BorderRadius.vertical(
top: Radius.circular(28), return RetroDialog(
), title: title,
), titleIcon: '',
child: Row( accentColor: RetroColors.mpBlue,
children: [ child: Column(
Icon( children: [
Icons.help_outline, // 탭 바
color: theme.colorScheme.onPrimaryContainer, RetroTabBar(
), controller: _tabController,
const SizedBox(width: 12), tabs: tabs,
Expanded( accentColor: RetroColors.mpBlue,
child: Text( ),
isKorean // 탭 내용
? '게임 도움말' Expanded(
: isJapanese child: TabBarView(
? 'ゲームヘルプ'
: 'Game Help',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
color: theme.colorScheme.onPrimaryContainer,
),
],
),
),
// 탭 바
TabBar(
controller: _tabController, controller: _tabController,
isScrollable: true, children: [
tabs: [ _BasicsHelpView(
Tab( isKorean: isKorean,
text: isKorean isJapanese: isJapanese,
? '기본'
: isJapanese
? '基本'
: 'Basics',
), ),
Tab( _CombatHelpView(
text: isKorean isKorean: isKorean,
? '전투' isJapanese: isJapanese,
: isJapanese
? '戦闘'
: 'Combat',
), ),
Tab( _SkillsHelpView(
text: isKorean isKorean: isKorean,
? '스킬' isJapanese: isJapanese,
: isJapanese
? 'スキル'
: 'Skills',
), ),
Tab( _UIHelpView(
text: isKorean isKorean: isKorean,
? 'UI' isJapanese: isJapanese,
: isJapanese
? 'UI'
: 'UI',
), ),
], ],
), ),
// 탭 내용 ),
Expanded( ],
child: TabBarView(
controller: _tabController,
children: [
_BasicsHelpView(
isKorean: isKorean,
isJapanese: isJapanese,
),
_CombatHelpView(
isKorean: isKorean,
isJapanese: isJapanese,
),
_SkillsHelpView(
isKorean: isKorean,
isJapanese: isJapanese,
),
_UIHelpView(
isKorean: isKorean,
isJapanese: isJapanese,
),
],
),
),
],
),
), ),
); );
} }
@@ -161,10 +110,10 @@ class _BasicsHelpView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
children: [ children: [
_HelpSection( _HelpSection(
icon: Icons.info_outline, icon: '',
title: isKorean title: isKorean
? '게임 소개' ? '게임 소개'
: isJapanese : isJapanese
@@ -179,9 +128,9 @@ class _BasicsHelpView extends StatelessWidget {
: 'Askii Never Die is an idle RPG. Your character automatically fights monsters, ' : 'Askii Never Die is an idle RPG. Your character automatically fights monsters, '
'completes quests, and levels up. You manage equipment and skills.', 'completes quests, and levels up. You manage equipment and skills.',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.trending_up, icon: '',
title: isKorean title: isKorean
? '진행 방식' ? '진행 방식'
: isJapanese : isJapanese
@@ -202,9 +151,9 @@ class _BasicsHelpView extends StatelessWidget {
'• Complete quests → Get rewards\n' '• Complete quests → Get rewards\n'
'• Progress plot → Unlock new Acts', '• Progress plot → Unlock new Acts',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.save, icon: '💾',
title: isKorean title: isKorean
? '저장' ? '저장'
: isJapanese : isJapanese
@@ -237,10 +186,10 @@ class _CombatHelpView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
children: [ children: [
_HelpSection( _HelpSection(
icon: Icons.sports_mma, icon: '',
title: isKorean title: isKorean
? '전투 시스템' ? '전투 시스템'
: isJapanese : isJapanese
@@ -255,9 +204,9 @@ class _CombatHelpView extends StatelessWidget {
: 'Combat is automatic. Player and monster take turns attacking, ' : 'Combat is automatic. Player and monster take turns attacking, '
'with attack frequency based on Attack Speed.', 'with attack frequency based on Attack Speed.',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.shield, icon: '🛡',
title: isKorean title: isKorean
? '방어 메카닉' ? '방어 메카닉'
: isJapanese : isJapanese
@@ -278,9 +227,9 @@ class _CombatHelpView extends StatelessWidget {
'• Parry: Deflect some damage with weapon\n' '• Parry: Deflect some damage with weapon\n'
'• DEF: Subtracted from all damage', '• DEF: Subtracted from all damage',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.favorite, icon: '',
title: isKorean title: isKorean
? '사망과 부활' ? '사망과 부활'
: isJapanese : isJapanese
@@ -313,10 +262,10 @@ class _SkillsHelpView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
children: [ children: [
_HelpSection( _HelpSection(
icon: Icons.auto_awesome, icon: '',
title: isKorean title: isKorean
? '스킬 종류' ? '스킬 종류'
: isJapanese : isJapanese
@@ -340,9 +289,9 @@ class _SkillsHelpView extends StatelessWidget {
'• Debuff: Harmful effects on enemies\n' '• Debuff: Harmful effects on enemies\n'
'• DOT: Damage over time', '• DOT: Damage over time',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.psychology, icon: '🤖',
title: isKorean title: isKorean
? '자동 스킬 선택' ? '자동 스킬 선택'
: isJapanese : isJapanese
@@ -366,9 +315,9 @@ class _SkillsHelpView extends StatelessWidget {
'3. Monster HP high → Apply debuffs\n' '3. Monster HP high → Apply debuffs\n'
'4. Finish with attack skills', '4. Finish with attack skills',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.upgrade, icon: '',
title: isKorean title: isKorean
? '스킬 랭크' ? '스킬 랭크'
: isJapanese : isJapanese
@@ -410,10 +359,10 @@ class _UIHelpView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
children: [ children: [
_HelpSection( _HelpSection(
icon: Icons.view_column, icon: '📺',
title: isKorean title: isKorean
? '화면 구성' ? '화면 구성'
: isJapanese : isJapanese
@@ -434,9 +383,9 @@ class _UIHelpView extends StatelessWidget {
'• Center: Equipment, inventory\n' '• Center: Equipment, inventory\n'
'• Right: Plot/quest progress, spellbook', '• Right: Plot/quest progress, spellbook',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.speed, icon: '',
title: isKorean title: isKorean
? '속도 조절' ? '속도 조절'
: isJapanese : isJapanese
@@ -460,9 +409,9 @@ class _UIHelpView extends StatelessWidget {
'• 5x: 5x speed\n' '• 5x: 5x speed\n'
'• 10x: 10x speed', '• 10x: 10x speed',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.pause, icon: '',
title: isKorean title: isKorean
? '일시정지' ? '일시정지'
: isJapanese : isJapanese
@@ -477,9 +426,9 @@ class _UIHelpView extends StatelessWidget {
: 'Use the pause button to stop the game. ' : 'Use the pause button to stop the game. '
'You can still view UI and change settings while paused.', 'You can still view UI and change settings while paused.',
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_HelpSection( _HelpSection(
icon: Icons.bar_chart, icon: '📊',
title: isKorean title: isKorean
? '통계' ? '통계'
: isJapanese : isJapanese
@@ -499,7 +448,7 @@ class _UIHelpView extends StatelessWidget {
} }
} }
/// 도움말 섹션 위젯 /// 레트로 스타일 도움말 섹션 위젯
class _HelpSection extends StatelessWidget { class _HelpSection extends StatelessWidget {
const _HelpSection({ const _HelpSection({
required this.icon, required this.icon,
@@ -507,46 +456,23 @@ class _HelpSection extends StatelessWidget {
required this.content, required this.content,
}); });
final IconData icon; final String icon;
final String title; final String title;
final String content; final String content;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 섹션 헤더 // 섹션 헤더
Row( RetroSectionHeader(
children: [ title: title,
Icon(icon, size: 20, color: theme.colorScheme.primary), icon: icon,
const SizedBox(width: 8), accentColor: RetroColors.mpBlue,
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
), ),
const SizedBox(height: 8),
// 내용 // 내용
Container( RetroInfoBox(content: content),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Text(
content,
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.5,
),
),
),
], ],
); );
} }

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/shared/retro_colors.dart';
/// HP/MP 바 위젯 (Phase 8: 변화 시 시각 효과) /// HP/MP 바 위젯 (레트로 RPG 스타일)
/// ///
/// - 세그먼트 스타일의 8-bit 프로그레스 바
/// - HP가 20% 미만일 때 빨간색 깜빡임 /// - HP가 20% 미만일 때 빨간색 깜빡임
/// - HP/MP 변화 시 색상 플래시 + 변화량 표시 /// - HP/MP 변화 시 색상 플래시 + 변화량 표시
/// - 전투 중 몬스터 HP 바 표시 /// - 전투 중 몬스터 HP 바 표시
@@ -158,8 +160,12 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
widget.monsterHpMax != null && widget.monsterHpMax != null &&
widget.monsterHpMax! > 0; widget.monsterHpMax! > 0;
return Padding( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: RetroColors.panelBg,
border: Border.all(color: RetroColors.panelBorderOuter, width: 2),
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -169,13 +175,14 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
current: widget.hpCurrent, current: widget.hpCurrent,
max: widget.hpMax, max: widget.hpMax,
ratio: hpRatio, ratio: hpRatio,
color: Colors.red, fillColor: RetroColors.hpRed,
emptyColor: RetroColors.hpRedDark,
isLow: hpRatio < 0.2 && hpRatio > 0, isLow: hpRatio < 0.2 && hpRatio > 0,
flashController: _hpFlashAnimation, flashController: _hpFlashAnimation,
change: _hpChange, change: _hpChange,
isDamage: _hpDamage, isDamage: _hpDamage,
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
// MP 바 (플래시 효과 포함) // MP 바 (플래시 효과 포함)
_buildAnimatedBar( _buildAnimatedBar(
@@ -183,7 +190,8 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
current: widget.mpCurrent, current: widget.mpCurrent,
max: widget.mpMax, max: widget.mpMax,
ratio: mpRatio, ratio: mpRatio,
color: Colors.blue, fillColor: RetroColors.mpBlue,
emptyColor: RetroColors.mpBlueDark,
isLow: false, isLow: false,
flashController: _mpFlashAnimation, flashController: _mpFlashAnimation,
change: _mpChange, change: _mpChange,
@@ -202,7 +210,8 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
required int current, required int current,
required int max, required int max,
required double ratio, required double ratio,
required Color color, required Color fillColor,
required Color emptyColor,
required bool isLow, required bool isLow,
required Animation<double> flashController, required Animation<double> flashController,
required int change, required int change,
@@ -213,27 +222,28 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
builder: (context, child) { builder: (context, child) {
// 플래시 색상 (데미지=빨강, 회복=녹색) // 플래시 색상 (데미지=빨강, 회복=녹색)
final flashColor = isDamage final flashColor = isDamage
? Colors.red.withValues(alpha: flashController.value * 0.4) ? RetroColors.hpRed.withValues(alpha: flashController.value * 0.4)
: Colors.green.withValues(alpha: flashController.value * 0.4); : RetroColors.expGreen.withValues(alpha: flashController.value * 0.4);
// 위험 깜빡임 배경 // 위험 깜빡임 배경
final lowBgColor = isLow final lowBgColor = isLow
? Colors.red.withValues(alpha: (1 - _blinkAnimation.value) * 0.3) ? RetroColors.hpRed.withValues(alpha: (1 - _blinkAnimation.value) * 0.3)
: Colors.transparent; : Colors.transparent;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: flashController.value > 0.1 ? flashColor : lowBgColor, color: flashController.value > 0.1 ? flashColor : lowBgColor,
borderRadius: BorderRadius.circular(4),
), ),
child: Stack( child: Stack(
children: [ children: [
_buildBar( _buildRetroBar(
label: label, label: label,
current: current, current: current,
max: max, max: max,
ratio: ratio, ratio: ratio,
color: color, fillColor: fillColor,
emptyColor: emptyColor,
blinkOpacity: isLow ? _blinkAnimation.value : 1.0,
), ),
// 플로팅 변화량 텍스트 (위로 떠오르며 사라짐) // 플로팅 변화량 텍스트 (위로 떠오르며 사라짐)
@@ -250,9 +260,10 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
child: Text( child: Text(
change > 0 ? '+$change' : '$change', change > 0 ? '+$change' : '$change',
style: TextStyle( style: TextStyle(
fontSize: 12, fontFamily: 'PressStart2P',
fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isDamage ? Colors.red : Colors.green, color: isDamage ? RetroColors.hpRed : RetroColors.expGreen,
shadows: const [ shadows: const [
Shadow(color: Colors.black, blurRadius: 3), Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6), Shadow(color: Colors.black, blurRadius: 6),
@@ -269,40 +280,81 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
); );
} }
Widget _buildBar({ /// 레트로 스타일 세그먼트 바
Widget _buildRetroBar({
required String label, required String label,
required int current, required int current,
required int max, required int max,
required double ratio, required double ratio,
required Color color, required Color fillColor,
required Color emptyColor,
required double blinkOpacity,
}) { }) {
const segmentCount = 15;
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
return Row( return Row(
children: [ children: [
// 레이블 (HP/MP)
SizedBox( SizedBox(
width: 24, width: 28,
child: Text( child: Text(
label, label,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold), style: TextStyle(
), fontFamily: 'PressStart2P',
), fontSize: 7,
Expanded( fontWeight: FontWeight.bold,
child: ClipRRect( color: RetroColors.gold.withValues(alpha: blinkOpacity),
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), // 세그먼트 바
// Flexible로 오버플로우 방지 Expanded(
Flexible( child: Container(
flex: 0, height: 12,
decoration: BoxDecoration(
color: emptyColor.withValues(alpha: 0.3),
border: Border.all(
color: RetroColors.panelBorderOuter,
width: 1,
),
),
child: Row(
children: List.generate(segmentCount, (index) {
final isFilled = index < filledSegments;
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isFilled
? fillColor.withValues(alpha: blinkOpacity)
: emptyColor.withValues(alpha: 0.2),
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.panelBorderOuter
.withValues(alpha: 0.3),
width: 1,
)
: BorderSide.none,
),
),
),
);
}),
),
),
),
const SizedBox(width: 6),
// 수치 표시
SizedBox(
width: 60,
child: Text( child: Text(
'$current/$max', '$current/$max',
style: const TextStyle(fontSize: 9), style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textLight,
),
textAlign: TextAlign.right, textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -311,53 +363,90 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
); );
} }
/// 몬스터 HP 바 /// 몬스터 HP 바 (레트로 스타일)
Widget _buildMonsterBar() { Widget _buildMonsterBar() {
final max = widget.monsterHpMax!; final max = widget.monsterHpMax!;
final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0; final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0;
const segmentCount = 10;
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
return AnimatedBuilder( return AnimatedBuilder(
animation: _monsterFlashAnimation, animation: _monsterFlashAnimation,
builder: (context, child) { builder: (context, child) {
// 데미지 플래시 (몬스터는 항상 데미지를 받음) // 데미지 플래시 (몬스터는 항상 데미지를 받음)
final flashColor = Colors.yellow.withValues( final flashColor = RetroColors.gold.withValues(
alpha: _monsterFlashAnimation.value * 0.3, alpha: _monsterFlashAnimation.value * 0.3,
); );
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _monsterFlashAnimation.value > 0.1 color: _monsterFlashAnimation.value > 0.1
? flashColor ? flashColor
: Colors.orange.withValues(alpha: 0.1), : RetroColors.panelBgLight.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(4), border: Border.all(
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), color: RetroColors.gold.withValues(alpha: 0.6),
width: 1,
),
), ),
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
// HP 바만 표시 (공간 제약으로 아이콘/이름 생략)
Row( Row(
children: [ children: [
// HP 바 // 몬스터 아이콘
const Icon(
Icons.pest_control,
size: 12,
color: RetroColors.gold,
),
const SizedBox(width: 6),
// 세그먼트 HP 바
Expanded( Expanded(
child: ClipRRect( child: Container(
borderRadius: BorderRadius.circular(2), height: 10,
child: LinearProgressIndicator( decoration: BoxDecoration(
value: ratio.clamp(0.0, 1.0), color: RetroColors.hpRedDark.withValues(alpha: 0.3),
backgroundColor: Colors.orange.withValues(alpha: 0.2), border: Border.all(
valueColor: const AlwaysStoppedAnimation<Color>( color: RetroColors.panelBorderOuter,
Colors.orange, width: 1,
), ),
minHeight: 8, ),
child: Row(
children: List.generate(segmentCount, (index) {
final isFilled = index < filledSegments;
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isFilled
? RetroColors.gold
: RetroColors.panelBorderOuter
.withValues(alpha: 0.3),
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.panelBorderOuter
.withValues(alpha: 0.3),
width: 1,
)
: BorderSide.none,
),
),
),
);
}),
), ),
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 6),
// HP 퍼센트 // HP 퍼센트
Text( Text(
'${(ratio * 100).toInt()}%', '${(ratio * 100).toInt()}%',
style: const TextStyle(fontSize: 8, color: Colors.orange), style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.gold,
),
), ),
], ],
), ),
@@ -365,8 +454,8 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
// 플로팅 데미지 텍스트 // 플로팅 데미지 텍스트
if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05) if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05)
Positioned( Positioned(
right: 60, right: 50,
top: -5, top: -8,
child: Transform.translate( child: Transform.translate(
offset: Offset(0, -12 * (1 - _monsterFlashAnimation.value)), offset: Offset(0, -12 * (1 - _monsterFlashAnimation.value)),
child: Opacity( child: Opacity(
@@ -376,11 +465,12 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
? '+$_monsterHpChange' ? '+$_monsterHpChange'
: '$_monsterHpChange', : '$_monsterHpChange',
style: TextStyle( style: TextStyle(
fontSize: 11, fontFamily: 'PressStart2P',
fontSize: 7,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _monsterHpChange < 0 color: _monsterHpChange < 0
? Colors.yellow ? RetroColors.gold
: Colors.green, : RetroColors.expGreen,
shadows: const [ shadows: const [
Shadow(color: Colors.black, blurRadius: 3), Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6), Shadow(color: Colors.black, blurRadius: 6),

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/notification/notification_service.dart'; import 'package:askiineverdie/src/core/notification/notification_service.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
/// 알림 오버레이 위젯 (Phase 8: 팝업/토스트 알림) /// 알림 오버레이 위젯 (Phase 8: 팝업/토스트 알림)
/// ///
@@ -106,7 +107,7 @@ class _NotificationOverlayState extends State<NotificationOverlay>
} }
} }
/// 알림 카드 위젯 /// 레트로 스타일 알림 카드 위젯
class _NotificationCard extends StatelessWidget { class _NotificationCard extends StatelessWidget {
const _NotificationCard({ const _NotificationCard({
required this.notification, required this.notification,
@@ -118,118 +119,191 @@ class _NotificationCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (bgColor, icon, iconColor) = _getStyleForType(notification.type); final (accentColor, icon, asciiIcon) = _getStyleForType(notification.type);
return Material( return GestureDetector(
elevation: 8, onTap: onDismiss,
borderRadius: BorderRadius.circular(12), child: Container(
color: bgColor, decoration: BoxDecoration(
child: InkWell( color: RetroColors.panelBg,
onTap: onDismiss, border: Border(
borderRadius: BorderRadius.circular(12), top: BorderSide(color: accentColor, width: 3),
child: Container( left: BorderSide(color: accentColor, width: 3),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), bottom: const BorderSide(color: RetroColors.panelBorderOuter, width: 3),
child: Row( right: const BorderSide(color: RetroColors.panelBorderOuter, width: 3),
children: [ ),
// 아이콘 boxShadow: [
Container( BoxShadow(
width: 40, color: accentColor.withValues(alpha: 0.4),
height: 40, blurRadius: 12,
decoration: BoxDecoration( spreadRadius: 2,
color: iconColor.withValues(alpha: 0.2), ),
shape: BoxShape.circle, ],
), ),
child: Icon(icon, color: iconColor, size: 24), child: Column(
), mainAxisSize: MainAxisSize.min,
const SizedBox(width: 12), children: [
// 텍스트 // 헤더 바
Expanded( Container(
child: Column( width: double.infinity,
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
mainAxisSize: MainAxisSize.min, color: accentColor.withValues(alpha: 0.3),
children: [ child: Row(
Text( children: [
notification.title, // ASCII 아이콘
style: const TextStyle( Text(
fontSize: 16, asciiIcon,
fontWeight: FontWeight.bold, style: TextStyle(
color: Colors.white, fontFamily: 'JetBrainsMono',
fontSize: 12,
color: accentColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
// 타입 표시
Expanded(
child: Text(
_getTypeLabel(notification.type),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: accentColor,
letterSpacing: 1,
), ),
), ),
if (notification.subtitle != null) ...[ ),
const SizedBox(height: 2), // 닫기 버튼
Text( GestureDetector(
notification.subtitle!, onTap: onDismiss,
style: TextStyle( child: const Text(
fontSize: 13, '[X]',
color: Colors.white.withValues(alpha: 0.8), style: TextStyle(
), fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textDisabled,
), ),
], ),
], ),
), ],
), ),
// 닫기 버튼 ),
IconButton( // 본문
icon: const Icon(Icons.close, color: Colors.white70, size: 20), Padding(
onPressed: onDismiss, padding: const EdgeInsets.all(12),
padding: EdgeInsets.zero, child: Row(
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), children: [
// 아이콘 박스
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: accentColor, width: 2),
),
child: Icon(icon, color: accentColor, size: 20),
),
const SizedBox(width: 12),
// 텍스트
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
notification.title,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: RetroColors.textLight,
),
),
if (notification.subtitle != null) ...[
const SizedBox(height: 4),
Text(
notification.subtitle!,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textDisabled,
),
),
],
],
),
),
],
), ),
], ),
), ],
), ),
), ),
); );
} }
(Color, IconData, Color) _getStyleForType(NotificationType type) { /// 알림 타입별 레트로 스타일 (강조 색상, 아이콘, ASCII 아이콘)
(Color, IconData, String) _getStyleForType(NotificationType type) {
return switch (type) { return switch (type) {
NotificationType.levelUp => ( NotificationType.levelUp => (
const Color(0xFF1565C0), RetroColors.gold,
Icons.trending_up, Icons.arrow_upward,
Colors.amber, '',
), ),
NotificationType.questComplete => ( NotificationType.questComplete => (
const Color(0xFF2E7D32), RetroColors.expGreen,
Icons.check_circle, Icons.check,
Colors.lightGreen, '',
), ),
NotificationType.actComplete => ( NotificationType.actComplete => (
const Color(0xFF6A1B9A), RetroColors.mpBlue,
Icons.flag, Icons.flag,
Colors.purpleAccent, '',
), ),
NotificationType.newSpell => ( NotificationType.newSpell => (
const Color(0xFF4527A0), const Color(0xFF9966FF),
Icons.auto_fix_high, Icons.auto_fix_high,
Colors.deepPurpleAccent, '',
), ),
NotificationType.newEquipment => ( NotificationType.newEquipment => (
const Color(0xFFE65100), const Color(0xFFFF9933),
Icons.shield, Icons.shield,
Colors.orange, '',
), ),
NotificationType.bossDefeat => ( NotificationType.bossDefeat => (
const Color(0xFFC62828), RetroColors.hpRed,
Icons.whatshot, Icons.whatshot,
Colors.redAccent, '',
), ),
NotificationType.gameSaved => ( NotificationType.gameSaved => (
const Color(0xFF00695C), RetroColors.expGreen,
Icons.save, Icons.save,
Colors.tealAccent, '💾',
), ),
NotificationType.info => ( NotificationType.info => (
const Color(0xFF0277BD), RetroColors.mpBlue,
Icons.info_outline, Icons.info_outline,
Colors.lightBlueAccent, '',
), ),
NotificationType.warning => ( NotificationType.warning => (
const Color(0xFFF57C00), const Color(0xFFFFCC00),
Icons.warning_amber, Icons.warning,
Colors.amber, '',
), ),
}; };
} }
/// 알림 타입 라벨
String _getTypeLabel(NotificationType type) {
return switch (type) {
NotificationType.levelUp => 'LEVEL UP',
NotificationType.questComplete => 'QUEST DONE',
NotificationType.actComplete => 'ACT CLEAR',
NotificationType.newSpell => 'NEW SPELL',
NotificationType.newEquipment => 'NEW ITEM',
NotificationType.bossDefeat => 'BOSS SLAIN',
NotificationType.gameSaved => 'SAVED',
NotificationType.info => 'INFO',
NotificationType.warning => 'WARNING',
};
}
} }

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/model/game_statistics.dart'; import 'package:askiineverdie/src/core/model/game_statistics.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
import 'package:askiineverdie/src/shared/widgets/retro_dialog.dart';
/// 게임 통계 다이얼로그 (Statistics Dialog) /// 게임 통계 다이얼로그 (Statistics Dialog)
/// ///
@@ -23,6 +25,7 @@ class StatisticsDialog extends StatefulWidget {
}) { }) {
return showDialog( return showDialog(
context: context, context: context,
barrierColor: Colors.black87,
builder: (_) => StatisticsDialog( builder: (_) => StatisticsDialog(
session: session, session: session,
cumulative: cumulative, cumulative: cumulative,
@@ -52,84 +55,46 @@ class _StatisticsDialogState extends State<StatisticsDialog>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isKorean = Localizations.localeOf(context).languageCode == 'ko'; final isKorean = Localizations.localeOf(context).languageCode == 'ko';
final isJapanese = Localizations.localeOf(context).languageCode == 'ja'; final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
return Dialog( final title = isKorean
child: Container( ? '통계'
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 500), : isJapanese
child: Column( ? '統計'
mainAxisSize: MainAxisSize.min, : 'Statistics';
children: [
// 헤더 final tabs = isKorean
Container( ? ['세션', '누적']
padding: const EdgeInsets.all(16), : isJapanese
decoration: BoxDecoration( ? ['セッション', '累積']
color: theme.colorScheme.primaryContainer, : ['Session', 'Total'];
borderRadius: const BorderRadius.vertical(
top: Radius.circular(28), return RetroDialog(
), title: title,
), titleIcon: '📊',
child: Row( maxWidth: 420,
children: [ maxHeight: 520,
Icon( accentColor: RetroColors.gold,
Icons.bar_chart, child: Column(
color: theme.colorScheme.onPrimaryContainer, children: [
), // 탭 바
const SizedBox(width: 12), RetroTabBar(
Expanded( controller: _tabController,
child: Text( tabs: tabs,
isKorean accentColor: RetroColors.gold,
? '게임 통계' ),
: isJapanese // 탭 내용
? 'ゲーム統計' Expanded(
: 'Game Statistics', child: TabBarView(
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
color: theme.colorScheme.onPrimaryContainer,
),
],
),
),
// 탭 바
TabBar(
controller: _tabController, controller: _tabController,
tabs: [ children: [
Tab( _SessionStatisticsView(stats: widget.session),
text: isKorean _CumulativeStatisticsView(stats: widget.cumulative),
? '현재 세션'
: isJapanese
? '現在のセッション'
: 'Session',
),
Tab(
text: isKorean
? '누적 통계'
: isJapanese
? '累積統計'
: 'Cumulative',
),
], ],
), ),
// 탭 내용 ),
Expanded( ],
child: TabBarView(
controller: _tabController,
children: [
_SessionStatisticsView(stats: widget.session),
_CumulativeStatisticsView(stats: widget.cumulative),
],
),
),
],
),
), ),
); );
} }
@@ -147,7 +112,7 @@ class _SessionStatisticsView extends StatelessWidget {
final isJapanese = Localizations.localeOf(context).languageCode == 'ja'; final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
children: [ children: [
_StatSection( _StatSection(
title: isKorean title: isKorean
@@ -155,7 +120,7 @@ class _SessionStatisticsView extends StatelessWidget {
: isJapanese : isJapanese
? '戦闘' ? '戦闘'
: 'Combat', : 'Combat',
icon: Icons.sports_mma, icon: '',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -191,14 +156,14 @@ class _SessionStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '데미지' ? '데미지'
: isJapanese : isJapanese
? 'ダメージ' ? 'ダメージ'
: 'Damage', : 'Damage',
icon: Icons.flash_on, icon: '',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -226,14 +191,14 @@ class _SessionStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '스킬' ? '스킬'
: isJapanese : isJapanese
? 'スキル' ? 'スキル'
: 'Skills', : 'Skills',
icon: Icons.auto_awesome, icon: '',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -269,14 +234,14 @@ class _SessionStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '경제' ? '경제'
: isJapanese : isJapanese
? '経済' ? '経済'
: 'Economy', : 'Economy',
icon: Icons.monetization_on, icon: '💰',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -312,14 +277,14 @@ class _SessionStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '진행' ? '진행'
: isJapanese : isJapanese
? '進行' ? '進行'
: 'Progress', : 'Progress',
icon: Icons.trending_up, icon: '',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -356,7 +321,7 @@ class _CumulativeStatisticsView extends StatelessWidget {
final isJapanese = Localizations.localeOf(context).languageCode == 'ja'; final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
children: [ children: [
_StatSection( _StatSection(
title: isKorean title: isKorean
@@ -364,7 +329,7 @@ class _CumulativeStatisticsView extends StatelessWidget {
: isJapanese : isJapanese
? '記録' ? '記録'
: 'Records', : 'Records',
icon: Icons.emoji_events, icon: '🏆',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -395,14 +360,14 @@ class _CumulativeStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '총 플레이' ? '총 플레이'
: isJapanese : isJapanese
? '総プレイ' ? '総プレイ'
: 'Total Play', : 'Total Play',
icon: Icons.access_time, icon: '',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -438,14 +403,14 @@ class _CumulativeStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '총 전투' ? '총 전투'
: isJapanese : isJapanese
? '総戦闘' ? '総戦闘'
: 'Total Combat', : 'Total Combat',
icon: Icons.sports_mma, icon: '',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -481,14 +446,14 @@ class _CumulativeStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '총 데미지' ? '총 데미지'
: isJapanese : isJapanese
? '総ダメージ' ? '総ダメージ'
: 'Total Damage', : 'Total Damage',
icon: Icons.flash_on, icon: '',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -508,14 +473,14 @@ class _CumulativeStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '총 스킬' ? '총 스킬'
: isJapanese : isJapanese
? '総スキル' ? '総スキル'
: 'Total Skills', : 'Total Skills',
icon: Icons.auto_awesome, icon: '',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -535,14 +500,14 @@ class _CumulativeStatisticsView extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_StatSection( _StatSection(
title: isKorean title: isKorean
? '총 경제' ? '총 경제'
: isJapanese : isJapanese
? '総経済' ? '総経済'
: 'Total Economy', : 'Total Economy',
icon: Icons.monetization_on, icon: '💰',
items: [ items: [
_StatItem( _StatItem(
label: isKorean label: isKorean
@@ -591,7 +556,7 @@ class _CumulativeStatisticsView extends StatelessWidget {
} }
} }
/// 통계 섹션 위젯 /// 레트로 스타일 통계 섹션 위젯
class _StatSection extends StatelessWidget { class _StatSection extends StatelessWidget {
const _StatSection({ const _StatSection({
required this.title, required this.title,
@@ -600,31 +565,20 @@ class _StatSection extends StatelessWidget {
}); });
final String title; final String title;
final IconData icon; final String icon;
final List<_StatItem> items; final List<_StatItem> items;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 섹션 헤더 // 섹션 헤더
Row( RetroSectionHeader(
children: [ title: title,
Icon(icon, size: 18, color: theme.colorScheme.primary), icon: icon,
const SizedBox(width: 8), accentColor: RetroColors.gold,
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
), ),
const Divider(height: 8),
// 통계 항목들 // 통계 항목들
...items, ...items,
], ],
@@ -632,7 +586,7 @@ class _StatSection extends StatelessWidget {
} }
} }
/// 개별 통계 항목 위젯 /// 레트로 스타일 개별 통계 항목 위젯
class _StatItem extends StatelessWidget { class _StatItem extends StatelessWidget {
const _StatItem({ const _StatItem({
required this.label, required this.label,
@@ -646,42 +600,11 @@ class _StatItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); return RetroStatRow(
label: label,
return Padding( value: value,
padding: const EdgeInsets.symmetric(vertical: 4), highlight: highlight,
child: Row( highlightColor: RetroColors.gold,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Container(
padding: highlight
? const EdgeInsets.symmetric(horizontal: 8, vertical: 2)
: null,
decoration: highlight
? BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
)
: null,
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
color: highlight
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
),
),
),
],
),
); );
} }
} }