refactor(ui): 위젯 분리 및 화면 개선

- game_play_screen에서 desktop 패널 위젯 분리
- death_overlay에서 death_buttons, death_combat_log 분리
- mobile_carousel_layout에서 mobile_options_menu 분리
- 아레나 위젯 개선 (arena_hp_bar, result_panel 등)
- settings_screen에서 retro_settings_widgets 분리
- 기타 위젯 리팩토링 및 import 경로 업데이트
This commit is contained in:
JiWoong Sul
2026-02-23 15:49:38 +09:00
parent 6ddbf23816
commit 864a866039
43 changed files with 3338 additions and 3184 deletions

View File

@@ -5,31 +5,24 @@ import 'package:flutter/scheduler.dart' show SchedulerBinding, SchedulerPhase;
import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
import 'package:asciineverdie/data/story_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/engine/story_service.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:asciineverdie/src/features/game/widgets/cinematic_view.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/features/game/widgets/death_overlay.dart';
import 'package:asciineverdie/src/features/game/widgets/desktop_character_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/desktop_equipment_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/desktop_quest_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/victory_overlay.dart';
import 'package:asciineverdie/src/features/game/widgets/hp_mp_bar.dart';
import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart';
import 'package:asciineverdie/src/features/game/widgets/stats_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/equipment_stats_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/task_progress_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/return_rewards_dialog.dart';
import 'package:asciineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
@@ -796,9 +789,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 2, child: _buildCharacterPanel(state)),
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
Expanded(flex: 2, child: _buildQuestPanel(state)),
Expanded(
flex: 2,
child: DesktopCharacterPanel(state: state),
),
Expanded(
flex: 3,
child: DesktopEquipmentPanel(
state: state,
combatLogEntries: _combatLogController.entries,
),
),
Expanded(
flex: 2,
child: DesktopQuestPanel(state: state),
),
],
),
),
@@ -871,667 +876,4 @@ class _GamePlayScreenState extends State<GamePlayScreen>
return KeyEventResult.ignored;
}
/// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells)
Widget _buildCharacterPanel(GameState state) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
color: RetroColors.panelBg,
shape: RoundedRectangleBorder(
side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2),
borderRadius: BorderRadius.circular(0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader(l10n.characterSheet),
// Traits 목록
_buildSectionHeader(l10n.traits),
_buildTraitsList(state),
// Stats 목록 (Phase 8: 애니메이션 변화 표시)
_buildSectionHeader(l10n.stats),
Expanded(flex: 2, child: StatsPanel(stats: state.stats)),
// Phase 8: HP/MP 바 (사망 위험 시 깜빡임, 전투 중 몬스터 HP 표시)
// 전투 중에는 전투 스탯의 HP/MP 사용, 비전투 시 기본 스탯 사용
HpMpBar(
hpCurrent:
state.progress.currentCombat?.playerStats.hpCurrent ??
state.stats.hp,
hpMax:
state.progress.currentCombat?.playerStats.hpMax ??
state.stats.hpMax,
mpCurrent:
state.progress.currentCombat?.playerStats.mpCurrent ??
state.stats.mp,
mpMax:
state.progress.currentCombat?.playerStats.mpMax ??
state.stats.mpMax,
// 전투 중일 때 몬스터 HP 정보 전달
monsterHpCurrent:
state.progress.currentCombat?.monsterStats.hpCurrent,
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
monsterName: state.progress.currentCombat?.monsterStats.name,
monsterLevel: state.progress.currentCombat?.monsterStats.level,
),
// Experience 바
_buildSectionHeader(l10n.experience),
_buildProgressBar(
state.progress.exp.position,
state.progress.exp.max,
Colors.blue,
tooltip:
'${state.progress.exp.position} / ${state.progress.exp.max}',
),
// 스킬 (Skills - SpellBook 기반)
_buildSectionHeader(l10n.spellBook),
Expanded(flex: 3, child: _buildSkillsList(state)),
// 활성 버프 (Active Buffs)
_buildSectionHeader(game_l10n.uiBuffs),
Expanded(
child: ActiveBuffPanel(
activeBuffs: state.skillSystem.activeBuffs,
currentMs: state.skillSystem.elapsedMs,
),
),
],
),
);
}
/// 중앙 패널: Equipment/Inventory
Widget _buildEquipmentPanel(GameState state) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
color: RetroColors.panelBg,
shape: RoundedRectangleBorder(
side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2),
borderRadius: BorderRadius.circular(0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader(l10n.equipment),
// Equipment 목록 (확장 가능 스탯 패널)
Expanded(
flex: 2,
child: EquipmentStatsPanel(equipment: state.equipment),
),
// Inventory
_buildPanelHeader(l10n.inventory),
Expanded(child: _buildInventoryList(state)),
// Potions (물약 인벤토리)
_buildSectionHeader(game_l10n.uiPotions),
Expanded(
child: PotionInventoryPanel(inventory: state.potionInventory),
),
// Encumbrance 바
_buildSectionHeader(l10n.encumbrance),
_buildProgressBar(
state.progress.encumbrance.position,
state.progress.encumbrance.max,
Colors.orange,
),
// Phase 8: 전투 로그 (Combat Log)
_buildPanelHeader(l10n.combatLog),
Expanded(
flex: 2,
child: CombatLog(entries: _combatLogController.entries),
),
],
),
);
}
/// 우측 패널: Plot/Quest
Widget _buildQuestPanel(GameState state) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
color: RetroColors.panelBg,
shape: RoundedRectangleBorder(
side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2),
borderRadius: BorderRadius.circular(0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader(l10n.plotDevelopment),
// Plot 목록
Expanded(child: _buildPlotList(state)),
// Plot 바
_buildProgressBar(
state.progress.plot.position,
state.progress.plot.max,
Colors.purple,
tooltip: state.progress.plot.max > 0
? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining'
: null,
),
_buildPanelHeader(l10n.quests),
// Quest 목록
Expanded(child: _buildQuestList(state)),
// Quest 바
_buildProgressBar(
state.progress.quest.position,
state.progress.quest.max,
Colors.green,
tooltip: state.progress.quest.max > 0
? l10n.percentComplete(
100 *
state.progress.quest.position ~/
state.progress.quest.max,
)
: null,
),
],
),
);
}
Widget _buildPanelHeader(String title) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: const BoxDecoration(
color: RetroColors.darkBrown,
border: Border(bottom: BorderSide(color: RetroColors.gold, width: 2)),
),
child: Text(
title.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
fontWeight: FontWeight.bold,
color: RetroColors.gold,
),
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
title.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textDisabled,
),
),
);
}
/// 레트로 스타일 세그먼트 프로그레스 바
Widget _buildProgressBar(
int position,
int max,
Color color, {
String? tooltip,
}) {
final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0;
const segmentCount = 20;
final filledSegments = (progress * segmentCount).round();
final bar = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Container(
height: 12,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
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 ? color : color.withValues(alpha: 0.1),
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.panelBorderOuter.withValues(
alpha: 0.3,
),
width: 1,
)
: BorderSide.none,
),
),
),
);
}),
),
),
);
if (tooltip != null && tooltip.isNotEmpty) {
return Tooltip(message: tooltip, child: bar);
}
return bar;
}
Widget _buildTraitsList(GameState state) {
final l10n = L10n.of(context);
final traits = [
(l10n.traitName, state.traits.name),
(l10n.traitRace, GameDataL10n.getRaceName(context, state.traits.race)),
(l10n.traitClass, GameDataL10n.getKlassName(context, state.traits.klass)),
(l10n.traitLevel, '${state.traits.level}'),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Column(
children: traits.map((t) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
SizedBox(
width: 50,
child: Text(
t.$1.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textDisabled,
),
),
),
Expanded(
child: Text(
t.$2,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textLight,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
),
);
}
/// 통합 스킬 목록 (SkillBook 기반)
///
/// 스킬 이름, 랭크, 스킬 타입, 쿨타임 표시
Widget _buildSkillsList(GameState state) {
if (state.skillBook.skills.isEmpty) {
return Center(
child: Text(
L10n.of(context).noSpellsYet,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textDisabled,
),
),
);
}
return ListView.builder(
itemCount: state.skillBook.skills.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final skillEntry = state.skillBook.skills[index];
final skill = SkillData.getSkillBySpellName(skillEntry.name);
final skillName = GameDataL10n.getSpellName(context, skillEntry.name);
// 쿨타임 상태 확인
final skillState = skill != null
? state.skillSystem.getSkillState(skill.id)
: null;
final isOnCooldown =
skillState != null &&
!skillState.isReady(state.skillSystem.elapsedMs, skill!.cooldownMs);
return _SkillRow(
skillName: skillName,
rank: skillEntry.rank,
skill: skill,
isOnCooldown: isOnCooldown,
);
},
);
}
Widget _buildInventoryList(GameState state) {
final l10n = L10n.of(context);
if (state.inventory.items.isEmpty) {
return Center(
child: Text(
l10n.goldAmount(state.inventory.gold),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.gold,
),
),
);
}
return ListView.builder(
itemCount: state.inventory.items.length + 1, // +1 for gold
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
const Icon(
Icons.monetization_on,
size: 10,
color: RetroColors.gold,
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.gold.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.gold,
),
),
),
Text(
'${state.inventory.gold}',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.gold,
),
),
],
),
);
}
final item = state.inventory.items[index - 1];
// 아이템 이름 번역
final translatedName = GameDataL10n.translateItemString(
context,
item.name,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Expanded(
child: Text(
translatedName,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textLight,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
'${item.count}',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.cream,
),
),
],
),
);
},
);
}
Widget _buildPlotList(GameState state) {
// 플롯 단계를 표시 (Act I, Act II, ...)
final l10n = L10n.of(context);
final plotCount = state.progress.plotStageCount;
if (plotCount == 0) {
return Center(
child: Text(
l10n.prologue.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textDisabled,
),
),
);
}
return ListView.builder(
itemCount: plotCount,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final isCompleted = index < plotCount - 1;
final isCurrent = index == plotCount - 1;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Icon(
isCompleted
? Icons.check_box
: (isCurrent
? Icons.arrow_right
: Icons.check_box_outline_blank),
size: 12,
color: isCompleted
? RetroColors.expGreen
: (isCurrent ? RetroColors.gold : RetroColors.textDisabled),
),
const SizedBox(width: 4),
Expanded(
child: Text(
index == 0 ? l10n.prologue : l10n.actNumber(_toRoman(index)),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: isCompleted
? RetroColors.textDisabled
: (isCurrent
? RetroColors.gold
: RetroColors.textLight),
decoration: isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
),
],
),
);
},
);
}
Widget _buildQuestList(GameState state) {
final l10n = L10n.of(context);
final questHistory = state.progress.questHistory;
if (questHistory.isEmpty) {
return Center(
child: Text(
l10n.noActiveQuests.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textDisabled,
),
),
);
}
// 원본처럼 퀘스트 히스토리를 리스트로 표시
// 완료된 퀘스트는 체크박스, 현재 퀘스트는 화살표
return ListView.builder(
itemCount: questHistory.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final quest = questHistory[index];
final isCurrentQuest =
index == questHistory.length - 1 && !quest.isComplete;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Icon(
isCurrentQuest
? Icons.arrow_right
: (quest.isComplete
? Icons.check_box
: Icons.check_box_outline_blank),
size: 12,
color: isCurrentQuest
? RetroColors.gold
: (quest.isComplete
? RetroColors.expGreen
: RetroColors.textDisabled),
),
const SizedBox(width: 4),
Expanded(
child: Text(
quest.caption,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: isCurrentQuest
? RetroColors.gold
: (quest.isComplete
? RetroColors.textDisabled
: RetroColors.textLight),
decoration: quest.isComplete
? TextDecoration.lineThrough
: TextDecoration.none,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
);
}
/// 로마 숫자 변환 (간단 버전)
String _toRoman(int number) {
const romanNumerals = [
(1000, 'M'),
(900, 'CM'),
(500, 'D'),
(400, 'CD'),
(100, 'C'),
(90, 'XC'),
(50, 'L'),
(40, 'XL'),
(10, 'X'),
(9, 'IX'),
(5, 'V'),
(4, 'IV'),
(1, 'I'),
];
var result = '';
var remaining = number;
for (final (value, numeral) in romanNumerals) {
while (remaining >= value) {
result += numeral;
remaining -= value;
}
}
return result;
}
}
/// 스킬 행 위젯
///
/// 스킬 이름, 랭크, 스킬 타입 아이콘, 쿨타임 상태 표시
class _SkillRow extends StatelessWidget {
const _SkillRow({
required this.skillName,
required this.rank,
required this.skill,
required this.isOnCooldown,
});
final String skillName;
final String rank;
final Skill? skill;
final bool isOnCooldown;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
// 스킬 타입 아이콘
_buildTypeIcon(),
const SizedBox(width: 4),
// 스킬 이름
Expanded(
child: Text(
skillName,
style: TextStyle(
fontSize: 16,
color: isOnCooldown ? Colors.grey : null,
),
overflow: TextOverflow.ellipsis,
),
),
// 쿨타임 표시
if (isOnCooldown)
const Icon(Icons.hourglass_empty, size: 10, color: Colors.orange),
const SizedBox(width: 4),
// 랭크
Text(
rank,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
);
}
/// 스킬 타입별 아이콘
Widget _buildTypeIcon() {
if (skill == null) {
return const SizedBox(width: 12);
}
final (IconData icon, Color color) = switch (skill!.type) {
SkillType.attack => (Icons.flash_on, Colors.red),
SkillType.heal => (Icons.favorite, Colors.green),
SkillType.buff => (Icons.arrow_upward, Colors.blue),
SkillType.debuff => (Icons.arrow_downward, Colors.purple),
};
return Icon(icon, size: 12, color: color);
}
}

View File

@@ -1,10 +1,9 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/features/game/pages/character_sheet_page.dart';
import 'package:asciineverdie/src/features/game/pages/combat_log_page.dart';
@@ -15,13 +14,9 @@ import 'package:asciineverdie/src/features/game/pages/skills_page.dart';
import 'package:asciineverdie/src/features/game/pages/story_page.dart';
import 'package:asciineverdie/src/features/game/widgets/carousel_nav_bar.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_confirm_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_select_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_sound_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/enhanced_animation_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/menu/retro_menu_widgets.dart';
import 'package:asciineverdie/src/features/game/widgets/mobile_options_menu.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 모바일 캐로셀 레이아웃
///
@@ -169,408 +164,39 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
);
}
/// 현재 언어명 가져오기
String _getCurrentLanguageName() {
final locale = l10n.currentGameLocale;
if (locale == 'ko') return l10n.languageKorean;
if (locale == 'ja') return l10n.languageJapanese;
return l10n.languageEnglish;
}
/// 언어 선택 다이얼로그 표시
void _showLanguageDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => RetroSelectDialog(
title: l10n.menuLanguage.toUpperCase(),
children: [
_buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'),
_buildLanguageOption(context, 'ko', l10n.languageKorean, '🇰🇷'),
_buildLanguageOption(context, 'ja', l10n.languageJapanese, '🇯🇵'),
],
void _openOptionsMenu(BuildContext context) {
showMobileOptionsMenu(
context,
MobileOptionsConfig(
isPaused: widget.isPaused,
speedMultiplier: widget.speedMultiplier,
bgmVolume: widget.bgmVolume,
sfxVolume: widget.sfxVolume,
cheatsEnabled: widget.cheatsEnabled,
isPaidUser: widget.isPaidUser,
isSpeedBoostActive: widget.isSpeedBoostActive,
adSpeedMultiplier: widget.adSpeedMultiplier,
notificationService: widget.notificationService,
onPauseToggle: widget.onPauseToggle,
onSpeedCycle: widget.onSpeedCycle,
onSave: widget.onSave,
onExit: widget.onExit,
onLanguageChange: widget.onLanguageChange,
onDeleteSaveAndNewGame: widget.onDeleteSaveAndNewGame,
onBgmVolumeChange: widget.onBgmVolumeChange,
onSfxVolumeChange: widget.onSfxVolumeChange,
onShowStatistics: widget.onShowStatistics,
onShowHelp: widget.onShowHelp,
onCheatTask: widget.onCheatTask,
onCheatQuest: widget.onCheatQuest,
onCheatPlot: widget.onCheatPlot,
onCreateTestCharacter: widget.onCreateTestCharacter,
onSpeedBoostActivate: widget.onSpeedBoostActivate,
onSetSpeed: widget.onSetSpeed,
),
);
}
Widget _buildLanguageOption(
BuildContext context,
String locale,
String label,
String flag,
) {
final isSelected = l10n.currentGameLocale == locale;
return RetroOptionItem(
label: label.toUpperCase(),
prefix: flag,
isSelected: isSelected,
onTap: () {
Navigator.pop(context);
widget.onLanguageChange(locale);
},
);
}
/// 사운드 상태 텍스트 가져오기
String _getSoundStatus() {
final bgmPercent = (widget.bgmVolume * 100).round();
final sfxPercent = (widget.sfxVolume * 100).round();
if (bgmPercent == 0 && sfxPercent == 0) {
return l10n.uiSoundOff;
}
return 'BGM $bgmPercent% / SFX $sfxPercent%';
}
/// 사운드 설정 다이얼로그 표시
void _showSoundDialog(BuildContext context) {
var bgmVolume = widget.bgmVolume;
var sfxVolume = widget.sfxVolume;
showDialog<void>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => RetroSoundDialog(
bgmVolume: bgmVolume,
sfxVolume: sfxVolume,
onBgmChanged: (double value) {
setDialogState(() => bgmVolume = value);
widget.onBgmVolumeChange?.call(value);
},
onSfxChanged: (double value) {
setDialogState(() => sfxVolume = value);
widget.onSfxVolumeChange?.call(value);
},
),
),
);
}
/// 세이브 삭제 확인 다이얼로그 표시
void _showDeleteConfirmDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => RetroConfirmDialog(
title: l10n.confirmDeleteTitle.toUpperCase(),
message: l10n.confirmDeleteMessage,
confirmText: l10n.buttonConfirm.toUpperCase(),
cancelText: l10n.buttonCancel.toUpperCase(),
onConfirm: () {
Navigator.pop(context);
widget.onDeleteSaveAndNewGame();
},
onCancel: () => Navigator.pop(context),
),
);
}
/// 테스트 캐릭터 생성 확인 다이얼로그
Future<void> _showTestCharacterDialog(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => RetroConfirmDialog(
title: L10n.of(context).debugCreateTestCharacterTitle,
message: L10n.of(context).debugCreateTestCharacterMessage,
confirmText: L10n.of(context).createButton,
cancelText: L10n.of(context).cancel.toUpperCase(),
onConfirm: () => Navigator.of(context).pop(true),
onCancel: () => Navigator.of(context).pop(false),
),
);
if (confirmed == true && mounted) {
await widget.onCreateTestCharacter?.call();
}
}
/// 옵션 메뉴 표시
void _showOptionsMenu(BuildContext context) {
final localizations = L10n.of(context);
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
final border = RetroColors.borderOf(context);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
builder: (context) => Container(
decoration: BoxDecoration(
color: background,
border: Border.all(color: border, width: 2),
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 핸들 바
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Container(width: 60, height: 4, color: border),
),
// 헤더
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
width: double.infinity,
decoration: BoxDecoration(
color: RetroColors.panelBgOf(context),
border: Border(bottom: BorderSide(color: gold, width: 2)),
),
child: Row(
children: [
Icon(Icons.settings, color: gold, size: 18),
const SizedBox(width: 8),
Text(
L10n.of(context).optionsTitle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
),
),
const Spacer(),
RetroIconButton(
icon: Icons.close,
onPressed: () => Navigator.pop(context),
size: 28,
),
],
),
),
// 메뉴 목록
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// === 게임 제어 ===
RetroMenuSection(title: L10n.of(context).controlSection),
const SizedBox(height: 8),
// 일시정지/재개
RetroMenuItem(
icon: widget.isPaused ? Icons.play_arrow : Icons.pause,
iconColor: widget.isPaused
? RetroColors.expOf(context)
: RetroColors.warningOf(context),
label: widget.isPaused
? l10n.menuResume.toUpperCase()
: l10n.menuPause.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onPauseToggle();
},
),
const SizedBox(height: 8),
// 속도 조절
RetroMenuItem(
icon: Icons.speed,
iconColor: gold,
label: l10n.menuSpeed.toUpperCase(),
trailing: _buildRetroSpeedSelector(context),
),
const SizedBox(height: 16),
// === 정보 ===
RetroMenuSection(title: L10n.of(context).infoSection),
const SizedBox(height: 8),
if (widget.onShowStatistics != null)
RetroMenuItem(
icon: Icons.bar_chart,
iconColor: RetroColors.mpOf(context),
label: l10n.uiStatistics.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onShowStatistics?.call();
},
),
if (widget.onShowHelp != null) ...[
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.help_outline,
iconColor: RetroColors.expOf(context),
label: l10n.uiHelp.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onShowHelp?.call();
},
),
],
const SizedBox(height: 16),
// === 설정 ===
RetroMenuSection(title: L10n.of(context).settingsSection),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.language,
iconColor: RetroColors.mpOf(context),
label: l10n.menuLanguage.toUpperCase(),
value: _getCurrentLanguageName(),
onTap: () {
Navigator.pop(context);
_showLanguageDialog(context);
},
),
if (widget.onBgmVolumeChange != null ||
widget.onSfxVolumeChange != null) ...[
const SizedBox(height: 8),
RetroMenuItem(
icon: widget.bgmVolume == 0 && widget.sfxVolume == 0
? Icons.volume_off
: Icons.volume_up,
iconColor: RetroColors.textMutedOf(context),
label: l10n.uiSound.toUpperCase(),
value: _getSoundStatus(),
onTap: () {
Navigator.pop(context);
_showSoundDialog(context);
},
),
],
const SizedBox(height: 16),
// === 저장/종료 ===
RetroMenuSection(title: L10n.of(context).saveExitSection),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.save,
iconColor: RetroColors.mpOf(context),
label: l10n.menuSave.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onSave();
widget.notificationService.showGameSaved(
l10n.menuSaved,
);
},
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.refresh,
iconColor: RetroColors.warningOf(context),
label: l10n.menuNewGame.toUpperCase(),
subtitle: l10n.menuDeleteSave,
onTap: () {
Navigator.pop(context);
_showDeleteConfirmDialog(context);
},
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.exit_to_app,
iconColor: RetroColors.hpOf(context),
label: localizations.exitGame.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onExit();
},
),
// === 치트 섹션 (디버그 모드에서만) ===
if (widget.cheatsEnabled) ...[
const SizedBox(height: 16),
RetroMenuSection(
title: L10n.of(context).debugCheatsTitle,
color: RetroColors.hpOf(context),
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.fast_forward,
iconColor: RetroColors.hpOf(context),
label: L10n.of(context).debugSkipTask,
subtitle: L10n.of(context).debugSkipTaskDesc,
onTap: () {
Navigator.pop(context);
widget.onCheatTask?.call();
},
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.skip_next,
iconColor: RetroColors.hpOf(context),
label: L10n.of(context).debugSkipQuest,
subtitle: L10n.of(context).debugSkipQuestDesc,
onTap: () {
Navigator.pop(context);
widget.onCheatQuest?.call();
},
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.double_arrow,
iconColor: RetroColors.hpOf(context),
label: L10n.of(context).debugSkipAct,
subtitle: L10n.of(context).debugSkipActDesc,
onTap: () {
Navigator.pop(context);
widget.onCheatPlot?.call();
},
),
],
// === 디버그 도구 섹션 ===
if (kDebugMode &&
widget.onCreateTestCharacter != null) ...[
const SizedBox(height: 16),
RetroMenuSection(
title: L10n.of(context).debugToolsTitle,
color: RetroColors.warningOf(context),
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.science,
iconColor: RetroColors.warningOf(context),
label: L10n.of(context).debugCreateTestCharacter,
subtitle: L10n.of(
context,
).debugCreateTestCharacterDesc,
onTap: () {
Navigator.pop(context);
_showTestCharacterDialog(context);
},
),
],
const SizedBox(height: 16),
],
),
),
),
],
),
),
),
);
}
/// 레트로 스타일 속도 선택기
///
/// - 5x/20x 토글 버튼 하나만 표시
/// - 부스트 활성화 중: 반투명, 비활성 (누를 수 없음)
/// - 부스트 비활성화: 불투명, 활성 (누를 수 있음)
Widget _buildRetroSpeedSelector(BuildContext context) {
final isSpeedBoostActive = widget.isSpeedBoostActive;
final adSpeed = widget.adSpeedMultiplier;
return RetroSpeedChip(
speed: adSpeed,
isSelected: isSpeedBoostActive,
isAdBased: !isSpeedBoostActive && !widget.isPaidUser,
// 부스트 활성화 중이면 비활성 (반투명)
isDisabled: isSpeedBoostActive,
onTap: () {
if (!isSpeedBoostActive) {
widget.onSpeedBoostActivate?.call();
}
Navigator.pop(context);
},
);
}
@override
Widget build(BuildContext context) {
final state = widget.state;
@@ -594,7 +220,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
// 옵션 버튼
IconButton(
icon: Icon(Icons.settings, color: gold),
onPressed: () => _showOptionsMenu(context),
onPressed: () => _openOptionsMenu(context),
tooltip: l10n.menuOptions,
),
],

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/features/game/widgets/stats_panel.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart';

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart';

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/util/roman.dart' show intToRoman;
/// 스토리 페이지 (캐로셀)
///
@@ -69,7 +70,7 @@ class StoryPage extends StatelessWidget {
final isCompleted = index < plotStageCount - 1;
final label = index == 0
? localizations.prologue
: localizations.actNumber(_toRoman(index));
: localizations.actNumber(intToRoman(index));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -113,32 +114,4 @@ class StoryPage extends StatelessWidget {
),
);
}
String _toRoman(int number) {
const romanNumerals = [
(1000, 'M'),
(900, 'CM'),
(500, 'D'),
(400, 'CD'),
(100, 'C'),
(90, 'XC'),
(50, 'L'),
(40, 'XL'),
(10, 'X'),
(9, 'IX'),
(5, 'V'),
(4, 'IV'),
(1, 'I'),
];
var result = '';
var remaining = number;
for (final (value, numeral) in romanNumerals) {
while (remaining >= value) {
result += numeral;
remaining -= value;
}
}
return result;
}
}

View File

@@ -2,24 +2,25 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:asciineverdie/src/shared/animation/ascii_animation_data.dart';
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/animation/background_layer.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
import 'package:asciineverdie/src/core/animation/canvas/canvas_battle_composer.dart';
import 'package:asciineverdie/src/core/animation/canvas/canvas_special_composer.dart';
import 'package:asciineverdie/src/core/animation/canvas/canvas_town_composer.dart';
import 'package:asciineverdie/src/core/animation/canvas/canvas_walking_composer.dart';
import 'package:asciineverdie/src/core/animation/character_frames.dart';
import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/core/animation/weapon_category.dart';
import 'package:asciineverdie/src/core/constants/ascii_colors.dart';
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/shared/animation/background_layer.dart';
import 'package:asciineverdie/src/shared/animation/canvas/ascii_canvas_widget.dart';
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
import 'package:asciineverdie/src/shared/animation/canvas/canvas_battle_composer.dart';
import 'package:asciineverdie/src/shared/animation/canvas/canvas_special_composer.dart';
import 'package:asciineverdie/src/shared/animation/canvas/canvas_town_composer.dart';
import 'package:asciineverdie/src/shared/animation/canvas/canvas_walking_composer.dart';
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
import 'package:asciineverdie/src/shared/animation/weapon_category.dart';
import 'package:asciineverdie/src/shared/theme/ascii_colors.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_event_mapping.dart';
/// 애니메이션 모드
enum AnimationMode {
@@ -284,198 +285,25 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
// 전투 모드가 아니면 무시
if (_animationMode != AnimationMode.battle) return;
// 이벤트 타입에 따라 페이즈 및 효과 결정
// (targetPhase, isCritical, isBlock, isParry, isSkill, isEvade, isMiss, isDebuff, isDot)
final (
targetPhase,
isCritical,
isBlock,
isParry,
isSkill,
isEvade,
isMiss,
isDebuff,
isDot,
) = switch (event.type) {
// 플레이어 공격 → prepare 페이즈부터 시작 (준비 동작 표시)
CombatEventType.playerAttack => (
BattlePhase.prepare,
event.isCritical,
false,
false,
false,
false,
false,
false,
false,
),
// 스킬 사용 → prepare 페이즈부터 시작 + 스킬 이펙트
CombatEventType.playerSkill => (
BattlePhase.prepare,
event.isCritical,
false,
false,
true,
false,
false,
false,
false,
),
// 몬스터 공격 → prepare 페이즈부터 시작
CombatEventType.monsterAttack => (
BattlePhase.prepare,
false,
false,
false,
false,
false,
false,
false,
false,
),
// 블록 → hit 페이즈 + 블록 이펙트 + 텍스트
CombatEventType.playerBlock => (
BattlePhase.hit,
false,
true,
false,
false,
false,
false,
false,
false,
),
// 패리 → hit 페이즈 + 패리 이펙트 + 텍스트
CombatEventType.playerParry => (
BattlePhase.hit,
false,
false,
true,
false,
false,
false,
false,
false,
),
// 플레이어 회피 → recover 페이즈 + 회피 텍스트
CombatEventType.playerEvade => (
BattlePhase.recover,
false,
false,
false,
false,
true,
false,
false,
false,
),
// 몬스터 회피 → idle 페이즈 + 미스 텍스트
CombatEventType.monsterEvade => (
BattlePhase.idle,
false,
false,
false,
false,
false,
true,
false,
false,
),
// 회복/버프 → idle 페이즈 유지
CombatEventType.playerHeal => (
BattlePhase.idle,
false,
false,
false,
false,
false,
false,
false,
false,
),
CombatEventType.playerBuff => (
BattlePhase.idle,
false,
false,
false,
false,
false,
false,
false,
false,
),
// 디버프 적용 → idle 페이즈 + 디버프 텍스트
CombatEventType.playerDebuff => (
BattlePhase.idle,
false,
false,
false,
false,
false,
false,
true,
false,
),
// DOT 틱 → attack 페이즈 + DOT 텍스트
CombatEventType.dotTick => (
BattlePhase.attack,
false,
false,
false,
false,
false,
false,
false,
true,
),
// 물약 사용 → idle 페이즈 유지
CombatEventType.playerPotion => (
BattlePhase.idle,
false,
false,
false,
false,
false,
false,
false,
false,
),
// 물약 드랍 → idle 페이즈 유지
CombatEventType.potionDrop => (
BattlePhase.idle,
false,
false,
false,
false,
false,
false,
false,
false,
),
};
final effects = mapCombatEventToEffects(event);
setState(() {
_battlePhase = targetPhase;
_battlePhase = effects.targetPhase;
_battleSubFrame = 0;
_phaseFrameCount = 0;
_showCriticalEffect = isCritical;
_showBlockEffect = isBlock;
_showParryEffect = isParry;
_showSkillEffect = isSkill;
_showEvadeEffect = isEvade;
_showMissEffect = isMiss;
_showDebuffEffect = isDebuff;
_showDotEffect = isDot;
_showCriticalEffect = effects.isCritical;
_showBlockEffect = effects.isBlock;
_showParryEffect = effects.isParry;
_showSkillEffect = effects.isSkill;
_showEvadeEffect = effects.isEvade;
_showMissEffect = effects.isMiss;
_showDebuffEffect = effects.isDebuff;
_showDotEffect = effects.isDot;
// 페이즈 인덱스 동기화
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
_phaseIndex = _battlePhaseSequence.indexWhere(
(p) => p.$1 == effects.targetPhase,
);
if (_phaseIndex < 0) _phaseIndex = 0;
// 공격 속도에 따른 동적 페이즈 프레임 수 계산 (Phase 6)
@@ -488,12 +316,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
}
// 공격자 타입 결정 (Phase 7: 공격자별 위치 분리)
_currentAttacker = switch (event.type) {
CombatEventType.playerAttack ||
CombatEventType.playerSkill => AttackerType.player,
CombatEventType.monsterAttack => AttackerType.monster,
_ => AttackerType.none,
};
_currentAttacker = getAttackerType(event.type);
});
}

View File

@@ -0,0 +1,165 @@
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
/// 전투 이벤트 → 애니메이션 효과 매핑 결과
typedef CombatEffects = ({
BattlePhase targetPhase,
bool isCritical,
bool isBlock,
bool isParry,
bool isSkill,
bool isEvade,
bool isMiss,
bool isDebuff,
bool isDot,
});
/// 전투 이벤트에 따른 애니메이션 효과 결정
///
/// CombatEvent 타입을 분석하여 대응하는 BattlePhase와 이펙트 플래그를 반환합니다.
CombatEffects mapCombatEventToEffects(CombatEvent event) {
return switch (event.type) {
// 플레이어 공격 -> prepare 페이즈부터 시작 (준비 동작 표시)
CombatEventType.playerAttack => (
targetPhase: BattlePhase.prepare,
isCritical: event.isCritical,
isBlock: false,
isParry: false,
isSkill: false,
isEvade: false,
isMiss: false,
isDebuff: false,
isDot: false,
),
// 스킬 사용 -> prepare 페이즈부터 시작 + 스킬 이펙트
CombatEventType.playerSkill => (
targetPhase: BattlePhase.prepare,
isCritical: event.isCritical,
isBlock: false,
isParry: false,
isSkill: true,
isEvade: false,
isMiss: false,
isDebuff: false,
isDot: false,
),
// 몬스터 공격 -> prepare 페이즈부터 시작
CombatEventType.monsterAttack => (
targetPhase: BattlePhase.prepare,
isCritical: false,
isBlock: false,
isParry: false,
isSkill: false,
isEvade: false,
isMiss: false,
isDebuff: false,
isDot: false,
),
// 블록 -> hit 페이즈 + 블록 이펙트
CombatEventType.playerBlock => (
targetPhase: BattlePhase.hit,
isCritical: false,
isBlock: true,
isParry: false,
isSkill: false,
isEvade: false,
isMiss: false,
isDebuff: false,
isDot: false,
),
// 패리 -> hit 페이즈 + 패리 이펙트
CombatEventType.playerParry => (
targetPhase: BattlePhase.hit,
isCritical: false,
isBlock: false,
isParry: true,
isSkill: false,
isEvade: false,
isMiss: false,
isDebuff: false,
isDot: false,
),
// 플레이어 회피 -> recover 페이즈 + 회피 텍스트
CombatEventType.playerEvade => (
targetPhase: BattlePhase.recover,
isCritical: false,
isBlock: false,
isParry: false,
isSkill: false,
isEvade: true,
isMiss: false,
isDebuff: false,
isDot: false,
),
// 몬스터 회피 -> idle 페이즈 + 미스 텍스트
CombatEventType.monsterEvade => (
targetPhase: BattlePhase.idle,
isCritical: false,
isBlock: false,
isParry: false,
isSkill: false,
isEvade: false,
isMiss: true,
isDebuff: false,
isDot: false,
),
// 회복/버프 -> idle 페이즈 유지
CombatEventType.playerHeal || CombatEventType.playerBuff => (
targetPhase: BattlePhase.idle,
isCritical: false,
isBlock: false,
isParry: false,
isSkill: false,
isEvade: false,
isMiss: false,
isDebuff: false,
isDot: false,
),
// 디버프 적용 -> idle 페이즈 + 디버프 텍스트
CombatEventType.playerDebuff => (
targetPhase: BattlePhase.idle,
isCritical: false,
isBlock: false,
isParry: false,
isSkill: false,
isEvade: false,
isMiss: false,
isDebuff: true,
isDot: false,
),
// DOT 틱 -> attack 페이즈 + DOT 텍스트
CombatEventType.dotTick => (
targetPhase: BattlePhase.attack,
isCritical: false,
isBlock: false,
isParry: false,
isSkill: false,
isEvade: false,
isMiss: false,
isDebuff: false,
isDot: true,
),
// 물약 사용/드랍 -> idle 페이즈 유지
CombatEventType.playerPotion || CombatEventType.potionDrop => (
targetPhase: BattlePhase.idle,
isCritical: false,
isBlock: false,
isParry: false,
isSkill: false,
isEvade: false,
isMiss: false,
isDebuff: false,
isDot: false,
),
};
}
/// 전투 이벤트에서 공격자 타입 결정 (Phase 7)
AttackerType getAttackerType(CombatEventType type) {
return switch (type) {
CombatEventType.playerAttack ||
CombatEventType.playerSkill => AttackerType.player,
CombatEventType.monsterAttack => AttackerType.monster,
_ => AttackerType.none,
};
}

View File

@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/model/combat_state.dart';
/// 컴팩트 HP 바 (숫자 오버레이 포함)
class CompactHpBar extends StatelessWidget {
const CompactHpBar({
super.key,
required this.current,
required this.max,
required this.flashAnimation,
required this.hpChange,
});
final int current;
final int max;
final Animation<double> flashAnimation;
final int hpChange;
@override
Widget build(BuildContext context) {
final ratio = max > 0 ? current / max : 0.0;
final isLow = ratio < 0.2 && ratio > 0;
return AnimatedBuilder(
animation: flashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
decoration: BoxDecoration(
color: isLow
? Colors.red.withValues(alpha: 0.2)
: Colors.grey.shade800,
borderRadius: BorderRadius.circular(3),
),
child: Row(
children: [
Container(
width: 32,
alignment: Alignment.center,
child: Text(
l10n.statHp,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3),
),
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.red.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation(
isLow ? Colors.red : Colors.red.shade600,
),
),
),
),
Text(
'$current/$max',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.9),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
),
],
),
),
if (hpChange != 0 && flashAnimation.value > 0.05)
Positioned(
right: 20,
top: -8,
child: Transform.translate(
offset: Offset(0, -10 * (1 - flashAnimation.value)),
child: Opacity(
opacity: flashAnimation.value,
child: Text(
hpChange > 0 ? '+$hpChange' : '$hpChange',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: hpChange < 0 ? Colors.red : Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
}
/// 컴팩트 MP 바 (숫자 오버레이 포함)
class CompactMpBar extends StatelessWidget {
const CompactMpBar({
super.key,
required this.current,
required this.max,
required this.flashAnimation,
required this.mpChange,
});
final int current;
final int max;
final Animation<double> flashAnimation;
final int mpChange;
@override
Widget build(BuildContext context) {
final ratio = max > 0 ? current / max : 0.0;
return AnimatedBuilder(
animation: flashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(3),
),
child: Row(
children: [
Container(
width: 32,
alignment: Alignment.center,
child: Text(
l10n.statMp,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3),
),
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.blue.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation(
Colors.blue.shade600,
),
),
),
),
Text(
'$current/$max',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.9),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
),
],
),
),
if (mpChange != 0 && flashAnimation.value > 0.05)
Positioned(
right: 20,
top: -8,
child: Transform.translate(
offset: Offset(0, -10 * (1 - flashAnimation.value)),
child: Opacity(
opacity: flashAnimation.value,
child: Text(
mpChange > 0 ? '+$mpChange' : '$mpChange',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: mpChange < 0 ? Colors.orange : Colors.cyan,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
}
/// 몬스터 HP 바 (전투 중)
class CompactMonsterHpBar extends StatelessWidget {
const CompactMonsterHpBar({
super.key,
required this.combat,
required this.monsterHpCurrent,
required this.monsterHpMax,
required this.monsterLevel,
required this.flashAnimation,
required this.monsterHpChange,
});
final CombatState combat;
final int? monsterHpCurrent;
final int? monsterHpMax;
final int? monsterLevel;
final Animation<double> flashAnimation;
final int monsterHpChange;
@override
Widget build(BuildContext context) {
final max = monsterHpMax ?? 1;
final current = monsterHpCurrent ?? 0;
final ratio = max > 0 ? current / max : 0.0;
final monsterName = combat.monsterStats.name;
final level = monsterLevel ?? combat.monsterStats.level;
return AnimatedBuilder(
animation: flashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.orange.withValues(alpha: 0.3),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(
alpha: 0.2,
),
valueColor: const AlwaysStoppedAnimation(
Colors.orange,
),
),
),
),
Text(
'${(ratio * 100).toInt()}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
),
const Shadow(
color: Colors.black,
blurRadius: 4,
),
],
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
child: Text(
'Lv.$level $monsterName',
style: const TextStyle(
fontSize: 11,
color: Colors.orange,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
),
if (monsterHpChange != 0 && flashAnimation.value > 0.05)
Positioned(
right: 10,
top: -10,
child: Transform.translate(
offset: Offset(0, -10 * (1 - flashAnimation.value)),
child: Opacity(
opacity: flashAnimation.value,
child: Text(
monsterHpChange > 0
? '+$monsterHpChange'
: '$monsterHpChange',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: monsterHpChange < 0
? Colors.yellow
: Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 일반 부활 버튼 (HP 50%, 아이템 희생)
class DeathResurrectButton extends StatelessWidget {
const DeathResurrectButton({super.key, required this.onResurrect});
final VoidCallback onResurrect;
@override
Widget build(BuildContext context) {
final expColor = RetroColors.expOf(context);
final expDark = RetroColors.expDarkOf(context);
return GestureDetector(
onTap: onResurrect,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: expColor.withValues(alpha: 0.2),
border: Border(
top: BorderSide(color: expColor, width: 3),
left: BorderSide(color: expColor, width: 3),
bottom: BorderSide(color: expDark.withValues(alpha: 0.8), width: 3),
right: BorderSide(color: expDark.withValues(alpha: 0.8), width: 3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'\u21BA',
style: TextStyle(
fontSize: 20,
color: expColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Text(
l10n.deathResurrect.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: expColor,
letterSpacing: 1,
),
),
],
),
),
);
}
}
/// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
class DeathAdReviveButton extends StatelessWidget {
const DeathAdReviveButton({
super.key,
required this.onAdRevive,
required this.deathInfo,
required this.isPaidUser,
});
final VoidCallback onAdRevive;
final DeathInfo deathInfo;
final bool isPaidUser;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context);
final muted = RetroColors.textMutedOf(context);
final hasLostItem = deathInfo.lostItemName != null;
final itemRarityColor = _getRarityColor(deathInfo.lostItemRarity);
return GestureDetector(
onTap: onAdRevive,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
color: gold.withValues(alpha: 0.2),
border: Border(
top: BorderSide(color: gold, width: 3),
left: BorderSide(color: gold, width: 3),
bottom: BorderSide(
color: goldDark.withValues(alpha: 0.8),
width: 3,
),
right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
),
),
child: Column(
children: [
// 메인 버튼 텍스트
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('\u2728', style: TextStyle(fontSize: 20, color: gold)),
const SizedBox(width: 8),
Text(
l10n.deathAdRevive.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
letterSpacing: 1,
),
),
// 광고 뱃지 (무료 유저만)
if (!isPaidUser) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'\u25B6 AD',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: Colors.white,
),
),
),
],
],
),
const SizedBox(height: 8),
// 혜택 목록
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BenefitRow(
icon: '\u2665',
text: l10n.deathAdReviveHp,
color: RetroColors.hpOf(context),
),
const SizedBox(height: 4),
if (hasLostItem) ...[
_BenefitRow(
icon: '\u{1F504}',
text:
'${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}',
color: itemRarityColor,
),
const SizedBox(height: 4),
],
_BenefitRow(
icon: '\u23F1',
text: l10n.deathAdReviveAuto,
color: RetroColors.mpOf(context),
),
],
),
const SizedBox(height: 6),
if (isPaidUser)
Text(
l10n.deathAdRevivePaidDesc,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: muted,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
Color _getRarityColor(ItemRarity? rarity) {
if (rarity == null) return Colors.grey;
return switch (rarity) {
ItemRarity.common => Colors.grey,
ItemRarity.uncommon => Colors.green,
ItemRarity.rare => Colors.blue,
ItemRarity.epic => Colors.purple,
ItemRarity.legendary => Colors.orange,
};
}
}
/// 혜택 항목 행
class _BenefitRow extends StatelessWidget {
const _BenefitRow({
required this.icon,
required this.text,
required this.color,
});
final String icon;
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: TextStyle(fontSize: 14, color: color)),
const SizedBox(width: 6),
Flexible(
child: Text(
text,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: color,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 사망 화면 전투 로그 위젯
class DeathCombatLog extends StatelessWidget {
const DeathCombatLog({super.key, required this.events});
final List<CombatEvent> events;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final background = RetroColors.backgroundOf(context);
final borderColor = RetroColors.borderOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('\u{1F4DC}', style: TextStyle(fontSize: 17)),
const SizedBox(width: 6),
Text(
l10n.deathCombatLog.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
),
),
],
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 100),
decoration: BoxDecoration(
color: background,
border: Border.all(color: borderColor, width: 2),
),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.all(6),
itemCount: events.length,
itemBuilder: (context, index) {
final event = events[index];
return _CombatEventTile(event: event);
},
),
),
],
);
}
}
/// 개별 전투 이벤트 타일
class _CombatEventTile extends StatelessWidget {
const _CombatEventTile({required this.event});
final CombatEvent event;
@override
Widget build(BuildContext context) {
final (asciiIcon, color, message) = _formatCombatEvent(context, event);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Text(asciiIcon, style: TextStyle(fontSize: 15, color: color)),
const SizedBox(width: 4),
Expanded(
child: Text(
message,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 14,
color: color,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
/// 전투 이벤트를 ASCII 아이콘, 색상, 메시지로 포맷
(String, Color, String) _formatCombatEvent(
BuildContext context,
CombatEvent event,
) {
final target = event.targetName ?? '';
final gold = RetroColors.goldOf(context);
final exp = RetroColors.expOf(context);
final hp = RetroColors.hpOf(context);
final mp = RetroColors.mpOf(context);
return switch (event.type) {
CombatEventType.playerAttack => (
event.isCritical ? '\u26A1' : '\u2694',
event.isCritical ? gold : exp,
event.isCritical
? l10n.combatCritical(event.damage, target)
: l10n.combatYouHit(target, event.damage),
),
CombatEventType.monsterAttack => (
'\u{1F480}',
hp,
l10n.combatMonsterHitsYou(target, event.damage),
),
CombatEventType.playerEvade => (
'\u27A4',
RetroColors.asciiCyan,
l10n.combatEvadedAttackFrom(target),
),
CombatEventType.monsterEvade => (
'\u27A4',
const Color(0xFFFF9933),
l10n.combatMonsterEvaded(target),
),
CombatEventType.playerBlock => (
'\u{1F6E1}',
mp,
l10n.combatBlockedAttack(target, event.damage),
),
CombatEventType.playerParry => (
'\u2694',
const Color(0xFF00CCCC),
l10n.combatParriedAttack(target, event.damage),
),
CombatEventType.playerSkill => (
'\u2727',
const Color(0xFF9966FF),
l10n.combatSkillDamage(event.skillName ?? '', event.damage),
),
CombatEventType.playerHeal => (
'\u2665',
exp,
l10n.combatHealedFor(event.healAmount),
),
CombatEventType.playerBuff => (
'\u2191',
mp,
l10n.combatBuffActivated(event.skillName ?? ''),
),
CombatEventType.playerDebuff => (
'\u2193',
const Color(0xFFFF6633),
l10n.combatDebuffApplied(event.skillName ?? '', target),
),
CombatEventType.dotTick => (
'\u{1F525}',
const Color(0xFFFF6633),
l10n.combatDotTick(event.skillName ?? '', event.damage),
),
CombatEventType.playerPotion => (
'\u{1F9EA}',
exp,
l10n.combatPotionUsed(event.skillName ?? '', event.healAmount, target),
),
CombatEventType.potionDrop => (
'\u{1F381}',
gold,
l10n.combatPotionDrop(event.skillName ?? ''),
),
};
}
}

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/features/game/widgets/death_buttons.dart';
import 'package:asciineverdie/src/features/game/widgets/death_combat_log.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 사망 오버레이 위젯
@@ -133,18 +134,22 @@ class DeathOverlay extends StatelessWidget {
const SizedBox(height: 16),
_buildRetroDivider(hpColor, hpDark),
const SizedBox(height: 8),
_buildCombatLog(context),
DeathCombatLog(events: deathInfo.lastCombatEvents),
],
const SizedBox(height: 24),
// 일반 부활 버튼 (HP 50%, 아이템 희생)
_buildResurrectButton(context),
DeathResurrectButton(onResurrect: onResurrect),
// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
if (onAdRevive != null) ...[
const SizedBox(height: 12),
_buildAdReviveButton(context),
DeathAdReviveButton(
onAdRevive: onAdRevive!,
deathInfo: deathInfo,
isPaidUser: isPaidUser,
),
],
],
),
@@ -423,347 +428,6 @@ class DeathOverlay extends StatelessWidget {
return gold.toString();
}
Widget _buildResurrectButton(BuildContext context) {
final expColor = RetroColors.expOf(context);
final expDark = RetroColors.expDarkOf(context);
return GestureDetector(
onTap: onResurrect,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: expColor.withValues(alpha: 0.2),
border: Border(
top: BorderSide(color: expColor, width: 3),
left: BorderSide(color: expColor, width: 3),
bottom: BorderSide(color: expDark.withValues(alpha: 0.8), width: 3),
right: BorderSide(color: expDark.withValues(alpha: 0.8), width: 3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'',
style: TextStyle(
fontSize: 20,
color: expColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Text(
l10n.deathResurrect.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: expColor,
letterSpacing: 1,
),
),
],
),
),
);
}
/// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
Widget _buildAdReviveButton(BuildContext context) {
final gold = RetroColors.goldOf(context);
final goldDark = RetroColors.goldDarkOf(context);
final muted = RetroColors.textMutedOf(context);
final hasLostItem = deathInfo.lostItemName != null;
final itemRarityColor = _getRarityColor(deathInfo.lostItemRarity);
return GestureDetector(
onTap: onAdRevive,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
color: gold.withValues(alpha: 0.2),
border: Border(
top: BorderSide(color: gold, width: 3),
left: BorderSide(color: gold, width: 3),
bottom: BorderSide(
color: goldDark.withValues(alpha: 0.8),
width: 3,
),
right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
),
),
child: Column(
children: [
// 메인 버튼 텍스트
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('', style: TextStyle(fontSize: 20, color: gold)),
const SizedBox(width: 8),
Text(
l10n.deathAdRevive.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
letterSpacing: 1,
),
),
// 광고 뱃지 (무료 유저만)
if (!isPaidUser) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'▶ AD',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: Colors.white,
),
),
),
],
],
),
const SizedBox(height: 8),
// 혜택 목록
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// HP 100% 회복
_buildBenefitRow(
context,
icon: '',
text: l10n.deathAdReviveHp,
color: RetroColors.hpOf(context),
),
const SizedBox(height: 4),
// 아이템 복구 (잃은 아이템이 있을 때만)
if (hasLostItem) ...[
_buildBenefitRow(
context,
icon: '🔄',
text:
'${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}',
color: itemRarityColor,
),
const SizedBox(height: 4),
],
// 10분 자동부활
_buildBenefitRow(
context,
icon: '',
text: l10n.deathAdReviveAuto,
color: RetroColors.mpOf(context),
),
],
),
const SizedBox(height: 6),
// 유료 유저 설명
if (isPaidUser)
Text(
l10n.deathAdRevivePaidDesc,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: muted,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// 혜택 항목 행
Widget _buildBenefitRow(
BuildContext context, {
required String icon,
required String text,
required Color color,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: TextStyle(fontSize: 14, color: color)),
const SizedBox(width: 6),
Flexible(
child: Text(
text,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: color,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
/// 사망 직전 전투 로그 표시
Widget _buildCombatLog(BuildContext context) {
final events = deathInfo.lastCombatEvents;
final gold = RetroColors.goldOf(context);
final background = RetroColors.backgroundOf(context);
final borderColor = RetroColors.borderOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('📜', style: TextStyle(fontSize: 17)),
const SizedBox(width: 6),
Text(
l10n.deathCombatLog.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
),
),
],
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 100),
decoration: BoxDecoration(
color: background,
border: Border.all(color: borderColor, width: 2),
),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.all(6),
itemCount: events.length,
itemBuilder: (context, index) {
final event = events[index];
return _buildCombatEventTile(context, event);
},
),
),
],
);
}
/// 개별 전투 이벤트 타일
Widget _buildCombatEventTile(BuildContext context, CombatEvent event) {
final (asciiIcon, color, message) = _formatCombatEvent(context, event);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Text(asciiIcon, style: TextStyle(fontSize: 15, color: color)),
const SizedBox(width: 4),
Expanded(
child: Text(
message,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 14,
color: color,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
/// 전투 이벤트를 ASCII 아이콘, 색상, 메시지로 포맷
(String, Color, String) _formatCombatEvent(
BuildContext context,
CombatEvent event,
) {
final target = event.targetName ?? '';
final gold = RetroColors.goldOf(context);
final exp = RetroColors.expOf(context);
final hp = RetroColors.hpOf(context);
final mp = RetroColors.mpOf(context);
return switch (event.type) {
CombatEventType.playerAttack => (
event.isCritical ? '' : '',
event.isCritical ? gold : exp,
event.isCritical
? l10n.combatCritical(event.damage, target)
: l10n.combatYouHit(target, event.damage),
),
CombatEventType.monsterAttack => (
'💀',
hp,
l10n.combatMonsterHitsYou(target, event.damage),
),
CombatEventType.playerEvade => (
'',
RetroColors.asciiCyan,
l10n.combatEvadedAttackFrom(target),
),
CombatEventType.monsterEvade => (
'',
const Color(0xFFFF9933),
l10n.combatMonsterEvaded(target),
),
CombatEventType.playerBlock => (
'🛡',
mp,
l10n.combatBlockedAttack(target, event.damage),
),
CombatEventType.playerParry => (
'',
const Color(0xFF00CCCC),
l10n.combatParriedAttack(target, event.damage),
),
CombatEventType.playerSkill => (
'',
const Color(0xFF9966FF),
l10n.combatSkillDamage(event.skillName ?? '', event.damage),
),
CombatEventType.playerHeal => (
'',
exp,
l10n.combatHealedFor(event.healAmount),
),
CombatEventType.playerBuff => (
'',
mp,
l10n.combatBuffActivated(event.skillName ?? ''),
),
CombatEventType.playerDebuff => (
'',
const Color(0xFFFF6633),
l10n.combatDebuffApplied(event.skillName ?? '', target),
),
CombatEventType.dotTick => (
'🔥',
const Color(0xFFFF6633),
l10n.combatDotTick(event.skillName ?? '', event.damage),
),
CombatEventType.playerPotion => (
'🧪',
exp,
l10n.combatPotionUsed(event.skillName ?? '', event.healAmount, target),
),
CombatEventType.potionDrop => (
'🎁',
gold,
l10n.combatPotionDrop(event.skillName ?? ''),
),
};
}
/// 장비 슬롯 이름 반환
String _getSlotName(EquipmentSlot? slot) {

View File

@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/features/game/widgets/desktop_panel_widgets.dart';
import 'package:asciineverdie/src/features/game/widgets/hp_mp_bar.dart';
import 'package:asciineverdie/src/features/game/widgets/stats_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 데스크톱 좌측 패널: Character Sheet
///
/// Traits, Stats, HP/MP, Experience, SpellBook, Buffs 표시
class DesktopCharacterPanel extends StatelessWidget {
const DesktopCharacterPanel({super.key, required this.state});
final GameState state;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
color: RetroColors.panelBg,
shape: RoundedRectangleBorder(
side: const BorderSide(
color: RetroColors.panelBorderOuter,
width: 2,
),
borderRadius: BorderRadius.circular(0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DesktopPanelHeader(title: l10n.characterSheet),
DesktopSectionHeader(title: l10n.traits),
_TraitsList(state: state),
DesktopSectionHeader(title: l10n.stats),
Expanded(flex: 2, child: StatsPanel(stats: state.stats)),
HpMpBar(
hpCurrent:
state.progress.currentCombat?.playerStats.hpCurrent ??
state.stats.hp,
hpMax:
state.progress.currentCombat?.playerStats.hpMax ??
state.stats.hpMax,
mpCurrent:
state.progress.currentCombat?.playerStats.mpCurrent ??
state.stats.mp,
mpMax:
state.progress.currentCombat?.playerStats.mpMax ??
state.stats.mpMax,
monsterHpCurrent:
state.progress.currentCombat?.monsterStats.hpCurrent,
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
monsterName: state.progress.currentCombat?.monsterStats.name,
monsterLevel: state.progress.currentCombat?.monsterStats.level,
),
DesktopSectionHeader(title: l10n.experience),
DesktopSegmentProgressBar(
position: state.progress.exp.position,
max: state.progress.exp.max,
color: Colors.blue,
tooltip:
'${state.progress.exp.position} / ${state.progress.exp.max}',
),
DesktopSectionHeader(title: l10n.spellBook),
Expanded(flex: 3, child: _SkillsList(state: state)),
DesktopSectionHeader(title: game_l10n.uiBuffs),
Expanded(
child: ActiveBuffPanel(
activeBuffs: state.skillSystem.activeBuffs,
currentMs: state.skillSystem.elapsedMs,
),
),
],
),
);
}
}
/// Traits 목록 위젯
class _TraitsList extends StatelessWidget {
const _TraitsList({required this.state});
final GameState state;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
final traits = [
(l10n.traitName, state.traits.name),
(l10n.traitRace, GameDataL10n.getRaceName(context, state.traits.race)),
(
l10n.traitClass,
GameDataL10n.getKlassName(context, state.traits.klass),
),
(l10n.traitLevel, '${state.traits.level}'),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Column(
children: traits.map((t) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
SizedBox(
width: 50,
child: Text(
t.$1.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textDisabled,
),
),
),
Expanded(
child: Text(
t.$2,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textLight,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
),
);
}
}
/// 통합 스킬 목록 (SkillBook 기반)
class _SkillsList extends StatelessWidget {
const _SkillsList({required this.state});
final GameState state;
@override
Widget build(BuildContext context) {
if (state.skillBook.skills.isEmpty) {
return Center(
child: Text(
L10n.of(context).noSpellsYet,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textDisabled,
),
),
);
}
return ListView.builder(
itemCount: state.skillBook.skills.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final skillEntry = state.skillBook.skills[index];
final skill = SkillData.getSkillBySpellName(skillEntry.name);
final skillName = GameDataL10n.getSpellName(context, skillEntry.name);
final skillState = skill != null
? state.skillSystem.getSkillState(skill.id)
: null;
final isOnCooldown =
skillState != null &&
!skillState.isReady(
state.skillSystem.elapsedMs,
skill!.cooldownMs,
);
return _SkillRow(
skillName: skillName,
rank: skillEntry.rank,
skill: skill,
isOnCooldown: isOnCooldown,
);
},
);
}
}
/// 스킬 행 위젯
class _SkillRow extends StatelessWidget {
const _SkillRow({
required this.skillName,
required this.rank,
required this.skill,
required this.isOnCooldown,
});
final String skillName;
final String rank;
final Skill? skill;
final bool isOnCooldown;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
_buildTypeIcon(),
const SizedBox(width: 4),
Expanded(
child: Text(
skillName,
style: TextStyle(
fontSize: 16,
color: isOnCooldown ? Colors.grey : null,
),
overflow: TextOverflow.ellipsis,
),
),
if (isOnCooldown)
const Icon(
Icons.hourglass_empty,
size: 10,
color: Colors.orange,
),
const SizedBox(width: 4),
Text(
rank,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildTypeIcon() {
if (skill == null) {
return const SizedBox(width: 12);
}
final (IconData icon, Color color) = switch (skill!.type) {
SkillType.attack => (Icons.flash_on, Colors.red),
SkillType.heal => (Icons.favorite, Colors.green),
SkillType.buff => (Icons.arrow_upward, Colors.blue),
SkillType.debuff => (Icons.arrow_downward, Colors.purple),
};
return Icon(icon, size: 12, color: color);
}
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/inventory.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/features/game/widgets/desktop_panel_widgets.dart';
import 'package:asciineverdie/src/features/game/widgets/equipment_stats_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 데스크톱 중앙 패널: Equipment/Inventory
///
/// Equipment, Inventory, Potions, Encumbrance, Combat Log 표시
class DesktopEquipmentPanel extends StatelessWidget {
const DesktopEquipmentPanel({
super.key,
required this.state,
required this.combatLogEntries,
});
final GameState state;
final List<CombatLogEntry> combatLogEntries;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
color: RetroColors.panelBg,
shape: RoundedRectangleBorder(
side: const BorderSide(
color: RetroColors.panelBorderOuter,
width: 2,
),
borderRadius: BorderRadius.circular(0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DesktopPanelHeader(title: l10n.equipment),
Expanded(
flex: 2,
child: EquipmentStatsPanel(equipment: state.equipment),
),
DesktopPanelHeader(title: l10n.inventory),
Expanded(child: _InventoryList(state: state)),
DesktopSectionHeader(title: game_l10n.uiPotions),
Expanded(
child: PotionInventoryPanel(inventory: state.potionInventory),
),
DesktopSectionHeader(title: l10n.encumbrance),
DesktopSegmentProgressBar(
position: state.progress.encumbrance.position,
max: state.progress.encumbrance.max,
color: Colors.orange,
),
DesktopPanelHeader(title: l10n.combatLog),
Expanded(flex: 2, child: CombatLog(entries: combatLogEntries)),
],
),
);
}
}
/// 인벤토리 목록 위젯
class _InventoryList extends StatelessWidget {
const _InventoryList({required this.state});
final GameState state;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
if (state.inventory.items.isEmpty) {
return Center(
child: Text(
l10n.goldAmount(state.inventory.gold),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.gold,
),
),
);
}
return ListView.builder(
itemCount: state.inventory.items.length + 1,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
if (index == 0) {
return _buildGoldRow(l10n);
}
return _buildItemRow(context, state.inventory.items[index - 1]);
},
);
}
Widget _buildGoldRow(L10n l10n) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
const Icon(
Icons.monetization_on,
size: 10,
color: RetroColors.gold,
),
const SizedBox(width: 4),
Expanded(
child: Text(
l10n.gold.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.gold,
),
),
),
Text(
'${state.inventory.gold}',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.gold,
),
),
],
),
);
}
Widget _buildItemRow(BuildContext context, InventoryEntry item) {
final translatedName = GameDataL10n.translateItemString(
context,
item.name,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Expanded(
child: Text(
translatedName,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textLight,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
'${item.count}',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.cream,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 데스크톱 3패널 레이아웃에서 사용하는 공통 위젯들
///
/// - 패널 헤더 (금색 테두리)
/// - 섹션 헤더 (비활성 텍스트)
/// - 세그먼트 프로그레스 바
/// 패널 헤더 (Panel Header)
class DesktopPanelHeader extends StatelessWidget {
const DesktopPanelHeader({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: const BoxDecoration(
color: RetroColors.darkBrown,
border: Border(
bottom: BorderSide(color: RetroColors.gold, width: 2),
),
),
child: Text(
title.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
fontWeight: FontWeight.bold,
color: RetroColors.gold,
),
),
);
}
}
/// 섹션 헤더 (Section Header)
class DesktopSectionHeader extends StatelessWidget {
const DesktopSectionHeader({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
title.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textDisabled,
),
),
);
}
}
/// 레트로 스타일 세그먼트 프로그레스 바 (Segment Progress Bar)
class DesktopSegmentProgressBar extends StatelessWidget {
const DesktopSegmentProgressBar({
super.key,
required this.position,
required this.max,
required this.color,
this.tooltip,
});
final int position;
final int max;
final Color color;
final String? tooltip;
@override
Widget build(BuildContext context) {
final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0;
const segmentCount = 20;
final filledSegments = (progress * segmentCount).round();
final bar = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Container(
height: 12,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
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 ? color : color.withValues(alpha: 0.1),
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.panelBorderOuter.withValues(
alpha: 0.3,
),
width: 1,
)
: BorderSide.none,
),
),
),
);
}),
),
),
);
if (tooltip != null && tooltip!.isNotEmpty) {
return Tooltip(message: tooltip!, child: bar);
}
return bar;
}
}

View File

@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
import 'package:asciineverdie/src/core/util/roman.dart' show intToRoman;
import 'package:asciineverdie/src/features/game/widgets/desktop_panel_widgets.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 데스크톱 우측 패널: Plot/Quest
///
/// Plot Development, Quests 목록 및 프로그레스 바 표시
class DesktopQuestPanel extends StatelessWidget {
const DesktopQuestPanel({super.key, required this.state});
final GameState state;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
color: RetroColors.panelBg,
shape: RoundedRectangleBorder(
side: const BorderSide(
color: RetroColors.panelBorderOuter,
width: 2,
),
borderRadius: BorderRadius.circular(0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DesktopPanelHeader(title: l10n.plotDevelopment),
Expanded(child: _PlotList(state: state)),
DesktopSegmentProgressBar(
position: state.progress.plot.position,
max: state.progress.plot.max,
color: Colors.purple,
tooltip: state.progress.plot.max > 0
? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining'
: null,
),
DesktopPanelHeader(title: l10n.quests),
Expanded(child: _QuestList(state: state)),
DesktopSegmentProgressBar(
position: state.progress.quest.position,
max: state.progress.quest.max,
color: Colors.green,
tooltip: state.progress.quest.max > 0
? l10n.percentComplete(
100 *
state.progress.quest.position ~/
state.progress.quest.max,
)
: null,
),
],
),
);
}
}
/// Plot 목록 위젯
class _PlotList extends StatelessWidget {
const _PlotList({required this.state});
final GameState state;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
final plotCount = state.progress.plotStageCount;
if (plotCount == 0) {
return Center(
child: Text(
l10n.prologue.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textDisabled,
),
),
);
}
return ListView.builder(
itemCount: plotCount,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final isCompleted = index < plotCount - 1;
final isCurrent = index == plotCount - 1;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Icon(
isCompleted
? Icons.check_box
: (isCurrent
? Icons.arrow_right
: Icons.check_box_outline_blank),
size: 12,
color: isCompleted
? RetroColors.expGreen
: (isCurrent
? RetroColors.gold
: RetroColors.textDisabled),
),
const SizedBox(width: 4),
Expanded(
child: Text(
index == 0
? l10n.prologue
: l10n.actNumber(intToRoman(index)),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: isCompleted
? RetroColors.textDisabled
: (isCurrent
? RetroColors.gold
: RetroColors.textLight),
decoration: isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
),
],
),
);
},
);
}
}
/// Quest 목록 위젯
class _QuestList extends StatelessWidget {
const _QuestList({required this.state});
final GameState state;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
final questHistory = state.progress.questHistory;
if (questHistory.isEmpty) {
return Center(
child: Text(
l10n.noActiveQuests.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textDisabled,
),
),
);
}
return ListView.builder(
itemCount: questHistory.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final quest = questHistory[index];
final isCurrentQuest =
index == questHistory.length - 1 && !quest.isComplete;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Icon(
isCurrentQuest
? Icons.arrow_right
: (quest.isComplete
? Icons.check_box
: Icons.check_box_outline_blank),
size: 12,
color: isCurrentQuest
? RetroColors.gold
: (quest.isComplete
? RetroColors.expGreen
: RetroColors.textDisabled),
),
const SizedBox(width: 4),
Expanded(
child: Text(
quest.caption,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: isCurrentQuest
? RetroColors.gold
: (quest.isComplete
? RetroColors.textDisabled
: RetroColors.textLight),
decoration: quest.isComplete
? TextDecoration.lineThrough
: TextDecoration.none,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
);
}
}

View File

@@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
import 'package:asciineverdie/src/features/game/widgets/compact_status_bars.dart';
/// 모바일용 확장 애니메이션 패널
///
@@ -325,9 +325,23 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(child: _buildCompactHpBar()),
Expanded(
child: CompactHpBar(
current: _currentHp,
max: _currentHpMax,
flashAnimation: _hpFlashAnimation,
hpChange: _hpChange,
),
),
const SizedBox(height: 4),
Expanded(child: _buildCompactMpBar()),
Expanded(
child: CompactMpBar(
current: _currentMp,
max: _currentMpMax,
flashAnimation: _mpFlashAnimation,
mpChange: _mpChange,
),
),
],
),
),
@@ -339,7 +353,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
Expanded(
flex: 2,
child: switch ((shouldShowMonsterHp, combat)) {
(true, final c?) => _buildMonsterHpBar(c),
(true, final c?) => CompactMonsterHpBar(
combat: c,
monsterHpCurrent: _currentMonsterHp,
monsterHpMax: _currentMonsterHpMax,
monsterLevel: widget.monsterLevel,
flashAnimation: _monsterFlashAnimation,
monsterHpChange: _monsterHpChange,
),
_ => const SizedBox.shrink(),
},
),
@@ -356,337 +377,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
);
}
/// 컴팩트 HP 바 (숫자 오버레이)
Widget _buildCompactHpBar() {
final ratio = _currentHpMax > 0 ? _currentHp / _currentHpMax : 0.0;
final isLow = ratio < 0.2 && ratio > 0;
return AnimatedBuilder(
animation: _hpFlashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
// HP 바
Container(
decoration: BoxDecoration(
color: isLow
? Colors.red.withValues(alpha: 0.2)
: Colors.grey.shade800,
borderRadius: BorderRadius.circular(3),
),
child: Row(
children: [
// 라벨
Container(
width: 32,
alignment: Alignment.center,
child: Text(
l10n.statHp,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
),
// 프로그레스 바 + 숫자 오버레이
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// 프로그레스 바
ClipRRect(
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3),
),
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.red.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation(
isLow ? Colors.red : Colors.red.shade600,
),
),
),
),
// 숫자 오버레이 (바 중앙)
Text(
'$_currentHp/$_currentHpMax',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.9),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
),
],
),
),
// 플로팅 변화량
if (_hpChange != 0 && _hpFlashAnimation.value > 0.05)
Positioned(
right: 20,
top: -8,
child: Transform.translate(
offset: Offset(0, -10 * (1 - _hpFlashAnimation.value)),
child: Opacity(
opacity: _hpFlashAnimation.value,
child: Text(
_hpChange > 0 ? '+$_hpChange' : '$_hpChange',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: _hpChange < 0 ? Colors.red : Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
/// 컴팩트 MP 바 (숫자 오버레이)
Widget _buildCompactMpBar() {
final ratio = _currentMpMax > 0 ? _currentMp / _currentMpMax : 0.0;
return AnimatedBuilder(
animation: _mpFlashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(3),
),
child: Row(
children: [
Container(
width: 32,
alignment: Alignment.center,
child: Text(
l10n.statMp,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
),
// 프로그레스 바 + 숫자 오버레이
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// 프로그레스 바
ClipRRect(
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3),
),
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.blue.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation(
Colors.blue.shade600,
),
),
),
),
// 숫자 오버레이 (바 중앙)
Text(
'$_currentMp/$_currentMpMax',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.9),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
),
],
),
),
if (_mpChange != 0 && _mpFlashAnimation.value > 0.05)
Positioned(
right: 20,
top: -8,
child: Transform.translate(
offset: Offset(0, -10 * (1 - _mpFlashAnimation.value)),
child: Opacity(
opacity: _mpFlashAnimation.value,
child: Text(
_mpChange > 0 ? '+$_mpChange' : '$_mpChange',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: _mpChange < 0 ? Colors.orange : Colors.cyan,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
/// 몬스터 HP 바 (전투 중)
/// - HP바 중앙에 HP% 오버레이
/// - 하단에 레벨.이름 표시
Widget _buildMonsterHpBar(CombatState combat) {
final max = _currentMonsterHpMax ?? 1;
final current = _currentMonsterHp ?? 0;
final ratio = max > 0 ? current / max : 0.0;
final monsterName = combat.monsterStats.name;
final monsterLevel = widget.monsterLevel ?? combat.monsterStats.level;
return AnimatedBuilder(
animation: _monsterFlashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// HP 바 (HP% 중앙 오버레이)
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
child: Stack(
alignment: Alignment.center,
children: [
// HP 바
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: SizedBox.expand(
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(
alpha: 0.2,
),
valueColor: const AlwaysStoppedAnimation(
Colors.orange,
),
),
),
),
// HP% 중앙 오버레이
Text(
'${(ratio * 100).toInt()}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
),
const Shadow(
color: Colors.black,
blurRadius: 4,
),
],
),
),
],
),
),
),
// 레벨.이름 표시
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
child: Text(
'Lv.$monsterLevel $monsterName',
style: const TextStyle(
fontSize: 11,
color: Colors.orange,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
),
// 플로팅 데미지
if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05)
Positioned(
right: 10,
top: -10,
child: Transform.translate(
offset: Offset(0, -10 * (1 - _monsterFlashAnimation.value)),
child: Opacity(
opacity: _monsterFlashAnimation.value,
child: Text(
_monsterHpChange > 0
? '+$_monsterHpChange'
: '$_monsterHpChange',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: _monsterHpChange < 0
? Colors.yellow
: Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
/// 속도 컨트롤 버튼 (태스크 프로그레스 바 우측)
///
/// - 5x/20x 토글 버튼 하나만 표시

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/features/game/widgets/retro_monster_hp_bar.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// HP/MP 바 위젯 (레트로 RPG 스타일)
@@ -201,7 +202,17 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
),
// 몬스터 HP 바 (전투 중일 때만)
if (hasMonster) ...[const SizedBox(height: 8), _buildMonsterBar()],
if (hasMonster) ...[
const SizedBox(height: 8),
RetroMonsterHpBar(
monsterHpCurrent: widget.monsterHpCurrent!,
monsterHpMax: widget.monsterHpMax!,
monsterName: widget.monsterName,
monsterLevel: widget.monsterLevel,
flashAnimation: _monsterFlashAnimation,
monsterHpChange: _monsterHpChange,
),
],
],
),
);
@@ -378,150 +389,4 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
);
}
/// 몬스터 HP 바 (레트로 스타일)
/// - HP바 중앙에 HP% 오버레이
/// - 하단에 레벨.이름 표시
Widget _buildMonsterBar() {
final max = widget.monsterHpMax!;
final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0;
const segmentCount = 10;
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
final levelPrefix = widget.monsterLevel != null
? 'Lv.${widget.monsterLevel} '
: '';
final monsterName = widget.monsterName ?? '';
return AnimatedBuilder(
animation: _monsterFlashAnimation,
builder: (context, child) {
// 데미지 플래시 (몬스터는 항상 데미지를 받음)
final flashColor = RetroColors.gold.withValues(
alpha: _monsterFlashAnimation.value * 0.3,
);
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: _monsterFlashAnimation.value > 0.1
? flashColor
: RetroColors.panelBgLight.withValues(alpha: 0.5),
border: Border.all(
color: RetroColors.gold.withValues(alpha: 0.6),
width: 1,
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// HP 바 (HP% 중앙 오버레이)
Stack(
alignment: Alignment.center,
children: [
// 세그먼트 HP 바
Container(
height: 12,
decoration: BoxDecoration(
color: RetroColors.hpRedDark.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
? 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,
),
),
),
);
}),
),
),
// HP% 중앙 오버레이
Text(
'${(ratio * 100).toInt()}%',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textLight,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
const SizedBox(height: 4),
// 레벨.이름 표시
Text(
'$levelPrefix$monsterName',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.gold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
// 플로팅 데미지 텍스트
if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05)
Positioned(
right: 50,
top: -8,
child: Transform.translate(
offset: Offset(0, -12 * (1 - _monsterFlashAnimation.value)),
child: Opacity(
opacity: _monsterFlashAnimation.value,
child: Text(
_monsterHpChange > 0
? '+$_monsterHpChange'
: '$_monsterHpChange',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
fontWeight: FontWeight.bold,
color: _monsterHpChange < 0
? RetroColors.gold
: RetroColors.expGreen,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,522 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_confirm_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_select_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_sound_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/menu/retro_menu_widgets.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 모바일 옵션 메뉴 설정
class MobileOptionsConfig {
const MobileOptionsConfig({
required this.isPaused,
required this.speedMultiplier,
required this.bgmVolume,
required this.sfxVolume,
required this.cheatsEnabled,
required this.isPaidUser,
required this.isSpeedBoostActive,
required this.adSpeedMultiplier,
required this.notificationService,
required this.onPauseToggle,
required this.onSpeedCycle,
required this.onSave,
required this.onExit,
required this.onLanguageChange,
required this.onDeleteSaveAndNewGame,
this.onBgmVolumeChange,
this.onSfxVolumeChange,
this.onShowStatistics,
this.onShowHelp,
this.onCheatTask,
this.onCheatQuest,
this.onCheatPlot,
this.onCreateTestCharacter,
this.onSpeedBoostActivate,
this.onSetSpeed,
});
final bool isPaused;
final int speedMultiplier;
final double bgmVolume;
final double sfxVolume;
final bool cheatsEnabled;
final bool isPaidUser;
final bool isSpeedBoostActive;
final int adSpeedMultiplier;
final NotificationService notificationService;
final VoidCallback onPauseToggle;
final VoidCallback onSpeedCycle;
final VoidCallback onSave;
final VoidCallback onExit;
final void Function(String locale) onLanguageChange;
final VoidCallback onDeleteSaveAndNewGame;
final void Function(double volume)? onBgmVolumeChange;
final void Function(double volume)? onSfxVolumeChange;
final VoidCallback? onShowStatistics;
final VoidCallback? onShowHelp;
final VoidCallback? onCheatTask;
final VoidCallback? onCheatQuest;
final VoidCallback? onCheatPlot;
final Future<void> Function()? onCreateTestCharacter;
final VoidCallback? onSpeedBoostActivate;
final void Function(int speed)? onSetSpeed;
}
/// 모바일 옵션 메뉴 표시
void showMobileOptionsMenu(BuildContext context, MobileOptionsConfig config) {
final background = RetroColors.backgroundOf(context);
final border = RetroColors.borderOf(context);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
builder: (context) => Container(
decoration: BoxDecoration(
color: background,
border: Border.all(color: border, width: 2),
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 핸들 바
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Container(width: 60, height: 4, color: border),
),
// 헤더
const _OptionsHeader(),
// 메뉴 목록
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: _OptionsMenuBody(config: config),
),
),
],
),
),
),
);
}
class _OptionsHeader extends StatelessWidget {
const _OptionsHeader();
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
width: double.infinity,
decoration: BoxDecoration(
color: RetroColors.panelBgOf(context),
border: Border(bottom: BorderSide(color: gold, width: 2)),
),
child: Row(
children: [
Icon(Icons.settings, color: gold, size: 18),
const SizedBox(width: 8),
Text(
L10n.of(context).optionsTitle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
),
),
const Spacer(),
RetroIconButton(
icon: Icons.close,
onPressed: () => Navigator.pop(context),
size: 28,
),
],
),
);
}
}
class _OptionsMenuBody extends StatelessWidget {
const _OptionsMenuBody({required this.config});
final MobileOptionsConfig config;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// === 게임 제어 ===
RetroMenuSection(title: L10n.of(context).controlSection),
const SizedBox(height: 8),
_buildPauseItem(context),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.speed,
iconColor: gold,
label: l10n.menuSpeed.toUpperCase(),
trailing: _buildSpeedSelector(context),
),
const SizedBox(height: 16),
// === 정보 ===
RetroMenuSection(title: L10n.of(context).infoSection),
const SizedBox(height: 8),
if (config.onShowStatistics != null)
RetroMenuItem(
icon: Icons.bar_chart,
iconColor: RetroColors.mpOf(context),
label: l10n.uiStatistics.toUpperCase(),
onTap: () {
Navigator.pop(context);
config.onShowStatistics?.call();
},
),
if (config.onShowHelp != null) ...[
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.help_outline,
iconColor: RetroColors.expOf(context),
label: l10n.uiHelp.toUpperCase(),
onTap: () {
Navigator.pop(context);
config.onShowHelp?.call();
},
),
],
const SizedBox(height: 16),
// === 설정 ===
RetroMenuSection(title: L10n.of(context).settingsSection),
const SizedBox(height: 8),
_buildLanguageItem(context),
if (config.onBgmVolumeChange != null ||
config.onSfxVolumeChange != null) ...[
const SizedBox(height: 8),
_buildSoundItem(context),
],
const SizedBox(height: 16),
// === 저장/종료 ===
RetroMenuSection(title: L10n.of(context).saveExitSection),
const SizedBox(height: 8),
_buildSaveItem(context),
const SizedBox(height: 8),
_buildNewGameItem(context),
const SizedBox(height: 8),
_buildExitItem(context),
// === 치트 섹션 ===
if (config.cheatsEnabled) ...[
const SizedBox(height: 16),
_buildCheatSection(context),
],
// === 디버그 도구 섹션 ===
if (kDebugMode && config.onCreateTestCharacter != null) ...[
const SizedBox(height: 16),
_buildDebugSection(context),
],
const SizedBox(height: 16),
],
);
}
Widget _buildPauseItem(BuildContext context) {
return RetroMenuItem(
icon: config.isPaused ? Icons.play_arrow : Icons.pause,
iconColor: config.isPaused
? RetroColors.expOf(context)
: RetroColors.warningOf(context),
label: config.isPaused
? l10n.menuResume.toUpperCase()
: l10n.menuPause.toUpperCase(),
onTap: () {
Navigator.pop(context);
config.onPauseToggle();
},
);
}
Widget _buildSpeedSelector(BuildContext context) {
return RetroSpeedChip(
speed: config.adSpeedMultiplier,
isSelected: config.isSpeedBoostActive,
isAdBased: !config.isSpeedBoostActive && !config.isPaidUser,
isDisabled: config.isSpeedBoostActive,
onTap: () {
if (!config.isSpeedBoostActive) {
config.onSpeedBoostActivate?.call();
}
Navigator.pop(context);
},
);
}
Widget _buildLanguageItem(BuildContext context) {
final currentLang = _getCurrentLanguageName();
return RetroMenuItem(
icon: Icons.language,
iconColor: RetroColors.mpOf(context),
label: l10n.menuLanguage.toUpperCase(),
value: currentLang,
onTap: () {
Navigator.pop(context);
_showLanguageDialog(context);
},
);
}
Widget _buildSoundItem(BuildContext context) {
final bgmPercent = (config.bgmVolume * 100).round();
final sfxPercent = (config.sfxVolume * 100).round();
final status = (bgmPercent == 0 && sfxPercent == 0)
? l10n.uiSoundOff
: 'BGM $bgmPercent% / SFX $sfxPercent%';
return RetroMenuItem(
icon: config.bgmVolume == 0 && config.sfxVolume == 0
? Icons.volume_off
: Icons.volume_up,
iconColor: RetroColors.textMutedOf(context),
label: l10n.uiSound.toUpperCase(),
value: status,
onTap: () {
Navigator.pop(context);
_showSoundDialog(context);
},
);
}
Widget _buildSaveItem(BuildContext context) {
return RetroMenuItem(
icon: Icons.save,
iconColor: RetroColors.mpOf(context),
label: l10n.menuSave.toUpperCase(),
onTap: () {
Navigator.pop(context);
config.onSave();
config.notificationService.showGameSaved(l10n.menuSaved);
},
);
}
Widget _buildNewGameItem(BuildContext context) {
return RetroMenuItem(
icon: Icons.refresh,
iconColor: RetroColors.warningOf(context),
label: l10n.menuNewGame.toUpperCase(),
subtitle: l10n.menuDeleteSave,
onTap: () {
Navigator.pop(context);
_showDeleteConfirmDialog(context);
},
);
}
Widget _buildExitItem(BuildContext context) {
return RetroMenuItem(
icon: Icons.exit_to_app,
iconColor: RetroColors.hpOf(context),
label: L10n.of(context).exitGame.toUpperCase(),
onTap: () {
Navigator.pop(context);
config.onExit();
},
);
}
Widget _buildCheatSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RetroMenuSection(
title: L10n.of(context).debugCheatsTitle,
color: RetroColors.hpOf(context),
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.fast_forward,
iconColor: RetroColors.hpOf(context),
label: L10n.of(context).debugSkipTask,
subtitle: L10n.of(context).debugSkipTaskDesc,
onTap: () {
Navigator.pop(context);
config.onCheatTask?.call();
},
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.skip_next,
iconColor: RetroColors.hpOf(context),
label: L10n.of(context).debugSkipQuest,
subtitle: L10n.of(context).debugSkipQuestDesc,
onTap: () {
Navigator.pop(context);
config.onCheatQuest?.call();
},
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.double_arrow,
iconColor: RetroColors.hpOf(context),
label: L10n.of(context).debugSkipAct,
subtitle: L10n.of(context).debugSkipActDesc,
onTap: () {
Navigator.pop(context);
config.onCheatPlot?.call();
},
),
],
);
}
Widget _buildDebugSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RetroMenuSection(
title: L10n.of(context).debugToolsTitle,
color: RetroColors.warningOf(context),
),
const SizedBox(height: 8),
RetroMenuItem(
icon: Icons.science,
iconColor: RetroColors.warningOf(context),
label: L10n.of(context).debugCreateTestCharacter,
subtitle: L10n.of(context).debugCreateTestCharacterDesc,
onTap: () {
Navigator.pop(context);
_showTestCharacterDialog(context);
},
),
],
);
}
String _getCurrentLanguageName() {
final locale = l10n.currentGameLocale;
if (locale == 'ko') return l10n.languageKorean;
if (locale == 'ja') return l10n.languageJapanese;
return l10n.languageEnglish;
}
void _showLanguageDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => RetroSelectDialog(
title: l10n.menuLanguage.toUpperCase(),
children: [
_buildLangOption(
context,
'en',
l10n.languageEnglish,
'\u{1F1FA}\u{1F1F8}',
),
_buildLangOption(
context,
'ko',
l10n.languageKorean,
'\u{1F1F0}\u{1F1F7}',
),
_buildLangOption(
context,
'ja',
l10n.languageJapanese,
'\u{1F1EF}\u{1F1F5}',
),
],
),
);
}
Widget _buildLangOption(
BuildContext context,
String locale,
String label,
String flag,
) {
final isSelected = l10n.currentGameLocale == locale;
return RetroOptionItem(
label: label.toUpperCase(),
prefix: flag,
isSelected: isSelected,
onTap: () {
Navigator.pop(context);
config.onLanguageChange(locale);
},
);
}
void _showSoundDialog(BuildContext context) {
var bgmVolume = config.bgmVolume;
var sfxVolume = config.sfxVolume;
showDialog<void>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => RetroSoundDialog(
bgmVolume: bgmVolume,
sfxVolume: sfxVolume,
onBgmChanged: (double value) {
setDialogState(() => bgmVolume = value);
config.onBgmVolumeChange?.call(value);
},
onSfxChanged: (double value) {
setDialogState(() => sfxVolume = value);
config.onSfxVolumeChange?.call(value);
},
),
),
);
}
void _showDeleteConfirmDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => RetroConfirmDialog(
title: l10n.confirmDeleteTitle.toUpperCase(),
message: l10n.confirmDeleteMessage,
confirmText: l10n.buttonConfirm.toUpperCase(),
cancelText: l10n.buttonCancel.toUpperCase(),
onConfirm: () {
Navigator.pop(context);
config.onDeleteSaveAndNewGame();
},
onCancel: () => Navigator.pop(context),
),
);
}
void _showTestCharacterDialog(BuildContext context) {
showDialog<bool>(
context: context,
builder: (context) => RetroConfirmDialog(
title: L10n.of(context).debugCreateTestCharacterTitle,
message: L10n.of(context).debugCreateTestCharacterMessage,
confirmText: L10n.of(context).createButton,
cancelText: L10n.of(context).cancel.toUpperCase(),
onConfirm: () {
Navigator.of(context).pop(true);
config.onCreateTestCharacter?.call();
},
onCancel: () => Navigator.of(context).pop(false),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
@@ -172,7 +173,7 @@ class _NotificationCard extends StatelessWidget {
// 타입 표시
Expanded(
child: Text(
_getTypeLabel(notification.type),
_getTypeLabel(context, notification.type),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
@@ -280,18 +281,19 @@ class _NotificationCard extends StatelessWidget {
};
}
/// 알림 타입 라벨
String _getTypeLabel(NotificationType type) {
/// 알림 타입 라벨 (l10n)
String _getTypeLabel(BuildContext context, NotificationType type) {
final l10n = L10n.of(context);
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',
NotificationType.levelUp => l10n.notifyLevelUpLabel,
NotificationType.questComplete => l10n.notifyQuestDoneLabel,
NotificationType.actComplete => l10n.notifyActClearLabel,
NotificationType.newSpell => l10n.notifyNewSpellLabel,
NotificationType.newEquipment => l10n.notifyNewItemLabel,
NotificationType.bossDefeat => l10n.notifyBossSlainLabel,
NotificationType.gameSaved => l10n.notifySavedLabel,
NotificationType.info => l10n.notifyInfoLabel,
NotificationType.warning => l10n.notifyWarningLabel,
};
}
}

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 몬스터 HP 바 (레트로 세그먼트 스타일)
///
/// 데스크탑 전용 세그먼트 바. HP% 중앙 오버레이 + 레벨.이름 표시.
class RetroMonsterHpBar extends StatelessWidget {
const RetroMonsterHpBar({
super.key,
required this.monsterHpCurrent,
required this.monsterHpMax,
required this.monsterName,
required this.monsterLevel,
required this.flashAnimation,
required this.monsterHpChange,
});
final int monsterHpCurrent;
final int monsterHpMax;
final String? monsterName;
final int? monsterLevel;
final Animation<double> flashAnimation;
final int monsterHpChange;
@override
Widget build(BuildContext context) {
final ratio = monsterHpMax > 0 ? monsterHpCurrent / monsterHpMax : 0.0;
const segmentCount = 10;
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
final levelPrefix = monsterLevel != null ? 'Lv.$monsterLevel ' : '';
final name = monsterName ?? '';
return AnimatedBuilder(
animation: flashAnimation,
builder: (context, child) {
final flashColor = RetroColors.gold.withValues(
alpha: flashAnimation.value * 0.3,
);
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: flashAnimation.value > 0.1
? flashColor
: RetroColors.panelBgLight.withValues(alpha: 0.5),
border: Border.all(
color: RetroColors.gold.withValues(alpha: 0.6),
width: 1,
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// HP 바 (HP% 중앙 오버레이)
Stack(
alignment: Alignment.center,
children: [
_buildSegmentBar(segmentCount, filledSegments),
// HP% 중앙 오버레이
Text(
'${(ratio * 100).toInt()}%',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.textLight,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
),
const Shadow(color: Colors.black, blurRadius: 4),
],
),
),
],
),
const SizedBox(height: 4),
// 레벨.이름 표시
Text(
'$levelPrefix$name',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.gold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
// 플로팅 데미지 텍스트
if (monsterHpChange != 0 && flashAnimation.value > 0.05)
Positioned(
right: 50,
top: -8,
child: Transform.translate(
offset: Offset(0, -12 * (1 - flashAnimation.value)),
child: Opacity(
opacity: flashAnimation.value,
child: Text(
monsterHpChange > 0
? '+$monsterHpChange'
: '$monsterHpChange',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
fontWeight: FontWeight.bold,
color: monsterHpChange < 0
? RetroColors.gold
: RetroColors.expGreen,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
),
),
],
),
);
},
);
}
/// 세그먼트 HP 바
Widget _buildSegmentBar(int segmentCount, int filledSegments) {
return Container(
height: 12,
decoration: BoxDecoration(
color: RetroColors.hpRedDark.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
? 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,
),
),
),
);
}),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/model/game_statistics.dart';
import 'package:asciineverdie/src/shared/widgets/retro_dialog.dart';
@@ -52,34 +53,19 @@ class _StatisticsDialogState extends State<StatisticsDialog>
@override
Widget build(BuildContext context) {
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
final title = isKorean
? '통계'
: isJapanese
? '統計'
: 'Statistics';
final tabs = isKorean
? ['세션', '누적']
: isJapanese
? ['セッション', '累積']
: ['Session', 'Total'];
final l10n = L10n.of(context);
return RetroDialog(
title: title,
title: l10n.statsStatistics,
titleIcon: '📊',
maxWidth: 420,
maxHeight: 520,
// accentColor: 테마에서 자동 결정 (goldOf)
child: Column(
children: [
// 탭 바
RetroTabBar(
controller: _tabController,
tabs: tabs,
// accentColor: 테마에서 자동 결정 (goldOf)
tabs: [l10n.statsSession, l10n.statsAccumulated],
),
// 탭 내용
Expanded(
@@ -105,198 +91,109 @@ class _SessionStatisticsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
final l10n = L10n.of(context);
return ListView(
padding: const EdgeInsets.all(12),
children: [
_StatSection(
title: isKorean
? '전투'
: isJapanese
? '戦闘'
: 'Combat',
title: l10n.statsCombat,
icon: '',
items: [
_StatItem(
label: isKorean
? '플레이 시간'
: isJapanese
? 'プレイ時間'
: 'Play Time',
label: l10n.statsPlayTime,
value: stats.formattedPlayTime,
),
_StatItem(
label: isKorean
? '처치한 몬스터'
: isJapanese
? '倒したモンスター'
: 'Monsters Killed',
label: l10n.statsMonstersKilled,
value: _formatNumber(stats.monstersKilled),
),
_StatItem(
label: isKorean
? '보스 처치'
: isJapanese
? 'ボス討伐'
: 'Bosses Defeated',
label: l10n.statsBossesDefeated,
value: _formatNumber(stats.bossesDefeated),
),
_StatItem(
label: isKorean
? '사망 횟수'
: isJapanese
? '死亡回数'
: 'Deaths',
label: l10n.statsDeaths,
value: _formatNumber(stats.deathCount),
),
],
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '데미지'
: isJapanese
? 'ダメージ'
: 'Damage',
title: l10n.statsDamage,
icon: '',
items: [
_StatItem(
label: isKorean
? '입힌 데미지'
: isJapanese
? '与えたダメージ'
: 'Damage Dealt',
label: l10n.statsDamageDealt,
value: _formatNumber(stats.totalDamageDealt),
),
_StatItem(
label: isKorean
? '받은 데미지'
: isJapanese
? '受けたダメージ'
: 'Damage Taken',
label: l10n.statsDamageTaken,
value: _formatNumber(stats.totalDamageTaken),
),
_StatItem(
label: isKorean
? '평균 DPS'
: isJapanese
? '平均DPS'
: 'Average DPS',
label: l10n.statsAverageDps,
value: stats.averageDps.toStringAsFixed(1),
),
],
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '스킬'
: isJapanese
? 'スキル'
: 'Skills',
title: l10n.statsSkills,
icon: '',
items: [
_StatItem(
label: isKorean
? '스킬 사용'
: isJapanese
? 'スキル使用'
: 'Skills Used',
label: l10n.statsSkillsUsed,
value: _formatNumber(stats.skillsUsed),
),
_StatItem(
label: isKorean
? '크리티컬 히트'
: isJapanese
? 'クリティカルヒット'
: 'Critical Hits',
label: l10n.statsCriticalHits,
value: _formatNumber(stats.criticalHits),
),
_StatItem(
label: isKorean
? '최대 연속 크리티컬'
: isJapanese
? '最大連続クリティカル'
: 'Max Critical Streak',
label: l10n.statsMaxCriticalStreak,
value: _formatNumber(stats.maxCriticalStreak),
),
_StatItem(
label: isKorean
? '크리티컬 비율'
: isJapanese
? 'クリティカル率'
: 'Critical Rate',
label: l10n.statsCriticalRate,
value: '${(stats.criticalRate * 100).toStringAsFixed(1)}%',
),
],
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '경제'
: isJapanese
? '経済'
: 'Economy',
title: l10n.statsEconomy,
icon: '💰',
items: [
_StatItem(
label: isKorean
? '획득 골드'
: isJapanese
? '獲得ゴールド'
: 'Gold Earned',
label: l10n.statsGoldEarned,
value: _formatNumber(stats.goldEarned),
),
_StatItem(
label: isKorean
? '소비 골드'
: isJapanese
? '消費ゴールド'
: 'Gold Spent',
label: l10n.statsGoldSpent,
value: _formatNumber(stats.goldSpent),
),
_StatItem(
label: isKorean
? '판매 아이템'
: isJapanese
? '売却アイテム'
: 'Items Sold',
label: l10n.statsItemsSold,
value: _formatNumber(stats.itemsSold),
),
_StatItem(
label: isKorean
? '물약 사용'
: isJapanese
? 'ポーション使用'
: 'Potions Used',
label: l10n.statsPotionsUsed,
value: _formatNumber(stats.potionsUsed),
),
],
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '진행'
: isJapanese
? '進行'
: 'Progress',
title: l10n.statsProgress,
icon: '',
items: [
_StatItem(
label: isKorean
? '레벨업'
: isJapanese
? 'レベルアップ'
: 'Level Ups',
label: l10n.statsLevelUps,
value: _formatNumber(stats.levelUps),
),
_StatItem(
label: isKorean
? '완료한 퀘스트'
: isJapanese
? '完了したクエスト'
: 'Quests Completed',
label: l10n.statsQuestsCompleted,
value: _formatNumber(stats.questsCompleted),
),
],
@@ -314,44 +211,27 @@ class _CumulativeStatisticsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
final l10n = L10n.of(context);
return ListView(
padding: const EdgeInsets.all(12),
children: [
_StatSection(
title: isKorean
? '기록'
: isJapanese
? '記録'
: 'Records',
title: l10n.statsRecords,
icon: '🏆',
items: [
_StatItem(
label: isKorean
? '최고 레벨'
: isJapanese
? '最高レベル'
: 'Highest Level',
label: l10n.statsHighestLevel,
value: _formatNumber(stats.highestLevel),
highlight: true,
),
_StatItem(
label: isKorean
? '최대 보유 골드'
: isJapanese
? '最大所持ゴールド'
: 'Highest Gold Held',
label: l10n.statsHighestGoldHeld,
value: _formatNumber(stats.highestGoldHeld),
highlight: true,
),
_StatItem(
label: isKorean
? '최고 연속 크리티컬'
: isJapanese
? '最高連続クリティカル'
: 'Best Critical Streak',
label: l10n.statsBestCriticalStreak,
value: _formatNumber(stats.bestCriticalStreak),
highlight: true,
),
@@ -359,191 +239,103 @@ class _CumulativeStatisticsView extends StatelessWidget {
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '총 플레이'
: isJapanese
? '総プレイ'
: 'Total Play',
title: l10n.statsTotalPlay,
icon: '',
items: [
_StatItem(
label: isKorean
? '총 플레이 시간'
: isJapanese
? '総プレイ時間'
: 'Total Play Time',
label: l10n.statsTotalPlayTime,
value: stats.formattedTotalPlayTime,
),
_StatItem(
label: isKorean
? '시작한 게임'
: isJapanese
? '開始したゲーム'
: 'Games Started',
label: l10n.statsGamesStarted,
value: _formatNumber(stats.gamesStarted),
),
_StatItem(
label: isKorean
? '클리어한 게임'
: isJapanese
? 'クリアしたゲーム'
: 'Games Completed',
label: l10n.statsGamesCompleted,
value: _formatNumber(stats.gamesCompleted),
),
_StatItem(
label: isKorean
? '클리어율'
: isJapanese
? 'クリア率'
: 'Completion Rate',
label: l10n.statsCompletionRate,
value: '${(stats.completionRate * 100).toStringAsFixed(1)}%',
),
],
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '총 전투'
: isJapanese
? '総戦闘'
: 'Total Combat',
title: l10n.statsTotalCombat,
icon: '',
items: [
_StatItem(
label: isKorean
? '처치한 몬스터'
: isJapanese
? '倒したモンスター'
: 'Monsters Killed',
label: l10n.statsMonstersKilled,
value: _formatNumber(stats.totalMonstersKilled),
),
_StatItem(
label: isKorean
? '보스 처치'
: isJapanese
? 'ボス討伐'
: 'Bosses Defeated',
label: l10n.statsBossesDefeated,
value: _formatNumber(stats.totalBossesDefeated),
),
_StatItem(
label: isKorean
? '총 사망'
: isJapanese
? '総死亡'
: 'Total Deaths',
label: l10n.statsTotalDeaths,
value: _formatNumber(stats.totalDeaths),
),
_StatItem(
label: isKorean
? '총 레벨업'
: isJapanese
? '総レベルアップ'
: 'Total Level Ups',
label: l10n.statsTotalLevelUps,
value: _formatNumber(stats.totalLevelUps),
),
],
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '총 데미지'
: isJapanese
? '総ダメージ'
: 'Total Damage',
title: l10n.statsTotalDamage,
icon: '',
items: [
_StatItem(
label: isKorean
? '입힌 데미지'
: isJapanese
? '与えたダメージ'
: 'Damage Dealt',
label: l10n.statsDamageDealt,
value: _formatNumber(stats.totalDamageDealt),
),
_StatItem(
label: isKorean
? '받은 데미지'
: isJapanese
? '受けたダメージ'
: 'Damage Taken',
label: l10n.statsDamageTaken,
value: _formatNumber(stats.totalDamageTaken),
),
],
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '총 스킬'
: isJapanese
? '総スキル'
: 'Total Skills',
title: l10n.statsTotalSkills,
icon: '',
items: [
_StatItem(
label: isKorean
? '스킬 사용'
: isJapanese
? 'スキル使用'
: 'Skills Used',
label: l10n.statsSkillsUsed,
value: _formatNumber(stats.totalSkillsUsed),
),
_StatItem(
label: isKorean
? '크리티컬 히트'
: isJapanese
? 'クリティカルヒット'
: 'Critical Hits',
label: l10n.statsCriticalHits,
value: _formatNumber(stats.totalCriticalHits),
),
],
),
const SizedBox(height: 12),
_StatSection(
title: isKorean
? '총 경제'
: isJapanese
? '総経済'
: 'Total Economy',
title: l10n.statsTotalEconomy,
icon: '💰',
items: [
_StatItem(
label: isKorean
? '획득 골드'
: isJapanese
? '獲得ゴールド'
: 'Gold Earned',
label: l10n.statsGoldEarned,
value: _formatNumber(stats.totalGoldEarned),
),
_StatItem(
label: isKorean
? '소비 골드'
: isJapanese
? '消費ゴールド'
: 'Gold Spent',
label: l10n.statsGoldSpent,
value: _formatNumber(stats.totalGoldSpent),
),
_StatItem(
label: isKorean
? '판매 아이템'
: isJapanese
? '売却アイテム'
: 'Items Sold',
label: l10n.statsItemsSold,
value: _formatNumber(stats.totalItemsSold),
),
_StatItem(
label: isKorean
? '물약 사용'
: isJapanese
? 'ポーション使用'
: 'Potions Used',
label: l10n.statsPotionsUsed,
value: _formatNumber(stats.totalPotionsUsed),
),
_StatItem(
label: isKorean
? '완료 퀘스트'
: isJapanese
? '完了クエスト'
: 'Quests Completed',
label: l10n.statsQuestsCompleted,
value: _formatNumber(stats.totalQuestsCompleted),
),
],
@@ -593,7 +385,6 @@ class _StatItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
// highlightColor: 테마에서 자동 결정 (goldOf)
return RetroStatRow(label: label, value: value, highlight: highlight);
}
}

View File

@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';