diff --git a/lib/src/features/arena/arena_battle_screen.dart b/lib/src/features/arena/arena_battle_screen.dart index 340d592..8e257dd 100644 --- a/lib/src/features/arena/arena_battle_screen.dart +++ b/lib/src/features/arena/arena_battle_screen.dart @@ -2,23 +2,21 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/engine/arena_service.dart'; import 'package:asciineverdie/src/core/model/arena_match.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/hall_of_fame.dart'; -import 'package:asciineverdie/src/core/animation/race_character_frames.dart'; +import 'package:asciineverdie/src/shared/animation/race_character_frames.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart'; +import 'package:asciineverdie/src/features/arena/widgets/arena_hp_bar.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart'; import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; -// 임시 문자열 (추후 l10n으로 이동) -const _battleTitle = 'ARENA BATTLE'; -const _hpLabel = 'HP'; - /// 아레나 전투 화면 /// /// ASCII 애니메이션 기반 턴제 전투 표시 @@ -438,7 +436,7 @@ class _ArenaBattleScreenState extends State backgroundColor: RetroColors.backgroundOf(context), appBar: AppBar( title: Text( - _battleTitle, + L10n.of(context).arenaBattleTitle, style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15), ), centerTitle: true, @@ -451,7 +449,18 @@ class _ArenaBattleScreenState extends State // 턴 표시 _buildTurnIndicator(), // HP 바 (레트로 세그먼트 스타일) - _buildRetroHpBars(), + ArenaHpBars( + challengerName: widget.match.challenger.characterName, + challengerHp: _challengerHp, + challengerHpMax: _challengerHpMax, + challengerFlashAnimation: _challengerFlashAnimation, + challengerHpChange: _challengerHpChange, + opponentName: widget.match.opponent.characterName, + opponentHp: _opponentHp, + opponentHpMax: _opponentHpMax, + opponentFlashAnimation: _opponentFlashAnimation, + opponentHpChange: _opponentHpChange, + ), // 전투 이벤트 아이콘 (HP 바와 애니메이션 사이) _buildCombatEventIcons(), // ASCII 애니메이션 (전투 중 / 종료 분기) @@ -649,232 +658,6 @@ class _ArenaBattleScreenState extends State ); } - /// 레트로 스타일 HP 바 (좌우 대칭) - Widget _buildRetroHpBars() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: RetroColors.panelBgOf(context), - border: Border( - bottom: BorderSide(color: RetroColors.borderOf(context), width: 2), - ), - ), - child: Row( - children: [ - // 도전자 HP (좌측, 파란색) - Expanded( - child: _buildRetroHpBar( - name: widget.match.challenger.characterName, - hp: _challengerHp, - hpMax: _challengerHpMax, - fillColor: RetroColors.mpBlue, - accentColor: Colors.blue, - flashAnimation: _challengerFlashAnimation, - hpChange: _challengerHpChange, - isReversed: false, - ), - ), - // VS 구분자 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - 'VS', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - color: RetroColors.goldOf(context), - fontWeight: FontWeight.bold, - ), - ), - ), - // 상대 HP (우측, 빨간색) - Expanded( - child: _buildRetroHpBar( - name: widget.match.opponent.characterName, - hp: _opponentHp, - hpMax: _opponentHpMax, - fillColor: RetroColors.hpRed, - accentColor: Colors.red, - flashAnimation: _opponentFlashAnimation, - hpChange: _opponentHpChange, - isReversed: true, - ), - ), - ], - ), - ); - } - - /// 레트로 세그먼트 HP 바 - Widget _buildRetroHpBar({ - required String name, - required int hp, - required int hpMax, - required Color fillColor, - required Color accentColor, - required Animation flashAnimation, - required int hpChange, - required bool isReversed, - }) { - final hpRatio = hpMax > 0 ? hp / hpMax : 0.0; - final isLow = hpRatio < 0.2 && hpRatio > 0; - - return AnimatedBuilder( - animation: flashAnimation, - builder: (context, child) { - // 플래시 색상 (데미지=빨강) - final isDamage = hpChange < 0; - final flashColor = isDamage - ? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4) - : RetroColors.expGreen.withValues( - alpha: flashAnimation.value * 0.4, - ); - - return Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: flashAnimation.value > 0.1 - ? flashColor - : accentColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: accentColor, width: 2), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - Column( - crossAxisAlignment: isReversed - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - // 이름 - Text( - name, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 11, - color: RetroColors.textPrimaryOf(context), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - // HP 세그먼트 바 - _buildSegmentBar( - ratio: hpRatio, - fillColor: fillColor, - isLow: isLow, - isReversed: isReversed, - ), - const SizedBox(height: 2), - // HP 수치 - Row( - mainAxisAlignment: isReversed - ? MainAxisAlignment.end - : MainAxisAlignment.start, - children: [ - Text( - _hpLabel, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 12, - color: accentColor.withValues(alpha: 0.8), - ), - ), - const SizedBox(width: 4), - Text( - '$hp/$hpMax', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 12, - color: isLow ? RetroColors.hpRed : fillColor, - ), - ), - ], - ), - ], - ), - - // 플로팅 데미지 텍스트 - if (hpChange != 0 && flashAnimation.value > 0.05) - Positioned( - left: isReversed ? null : 0, - right: isReversed ? 0 : null, - top: -12, - child: Transform.translate( - offset: Offset(0, -12 * (1 - flashAnimation.value)), - child: Opacity( - opacity: flashAnimation.value, - child: Text( - hpChange > 0 ? '+$hpChange' : '$hpChange', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - fontWeight: FontWeight.bold, - color: isDamage - ? RetroColors.hpRed - : RetroColors.expGreen, - shadows: const [ - Shadow(color: Colors.black, blurRadius: 3), - Shadow(color: Colors.black, blurRadius: 6), - ], - ), - ), - ), - ), - ), - ], - ), - ); - }, - ); - } - - /// 세그먼트 바 (8-bit 스타일) - Widget _buildSegmentBar({ - required double ratio, - required Color fillColor, - required bool isLow, - required bool isReversed, - }) { - const segmentCount = 10; - final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round(); - - final segments = List.generate(segmentCount, (index) { - final isFilled = isReversed - ? index >= segmentCount - filledSegments - : index < filledSegments; - - return Expanded( - child: Container( - height: 8, - decoration: BoxDecoration( - color: isFilled - ? (isLow ? RetroColors.hpRed : fillColor) - : fillColor.withValues(alpha: 0.2), - border: Border( - right: index < segmentCount - 1 - ? BorderSide( - color: RetroColors.borderOf( - context, - ).withValues(alpha: 0.3), - width: 1, - ) - : BorderSide.none, - ), - ), - ), - ); - }); - - return Container( - decoration: BoxDecoration( - border: Border.all(color: RetroColors.borderOf(context), width: 1), - ), - child: Row(children: isReversed ? segments.reversed.toList() : segments), - ); - } - Widget _buildBattleLog() { return Container( margin: const EdgeInsets.all(12), diff --git a/lib/src/features/arena/arena_screen.dart b/lib/src/features/arena/arena_screen.dart index d1e6a88..9e4104d 100644 --- a/lib/src/features/arena/arena_screen.dart +++ b/lib/src/features/arena/arena_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart'; import 'package:asciineverdie/src/features/arena/arena_setup_screen.dart'; @@ -7,12 +8,6 @@ import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/widgets/retro_panel.dart'; -// 임시 문자열 (추후 l10n으로 이동) -const _arenaTitle = 'LOCAL ARENA'; -const _arenaSubtitle = 'SELECT YOUR FIGHTER'; -const _arenaEmpty = 'Not enough heroes'; -const _arenaEmptyHint = 'Clear the game with 2+ characters'; - /// 로컬 아레나 메인 화면 /// /// 순위표 표시 및 도전하기 버튼 @@ -68,11 +63,12 @@ class _ArenaScreenState extends State { @override Widget build(BuildContext context) { + final l10n = L10n.of(context); return Scaffold( backgroundColor: RetroColors.backgroundOf(context), appBar: AppBar( title: Text( - _arenaTitle, + l10n.arenaTitle, style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15), ), centerTitle: true, @@ -101,6 +97,7 @@ class _ArenaScreenState extends State { } Widget _buildEmptyState() { + final l10n = L10n.of(context); return Center( child: RetroPanel( padding: const EdgeInsets.all(24), @@ -114,7 +111,7 @@ class _ArenaScreenState extends State { ), const SizedBox(height: 16), Text( - _arenaEmpty, + l10n.arenaEmptyTitle, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 14, @@ -123,7 +120,7 @@ class _ArenaScreenState extends State { ), const SizedBox(height: 8), Text( - _arenaEmptyHint, + l10n.arenaEmptyHint, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 12, @@ -143,7 +140,7 @@ class _ArenaScreenState extends State { return Padding( padding: const EdgeInsets.all(12), child: RetroGoldPanel( - title: _arenaSubtitle, + title: L10n.of(context).arenaSelectFighter, padding: const EdgeInsets.all(8), child: ListView.builder( itemCount: rankedEntries.length, diff --git a/lib/src/features/arena/arena_setup_screen.dart b/lib/src/features/arena/arena_setup_screen.dart index 1c50e44..a9dc7d6 100644 --- a/lib/src/features/arena/arena_setup_screen.dart +++ b/lib/src/features/arena/arena_setup_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/engine/arena_service.dart'; import 'package:asciineverdie/src/core/engine/item_service.dart'; import 'package:asciineverdie/src/core/model/arena_match.dart'; @@ -13,11 +14,6 @@ import 'package:asciineverdie/src/features/arena/widgets/arena_idle_preview.dart import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; -// 임시 문자열 (추후 l10n으로 이동) -const _setupTitle = 'ARENA SETUP'; -const _selectCharacter = 'SELECT YOUR FIGHTER'; -const _startBattleLabel = 'START BATTLE'; - /// 아레나 설정 화면 /// /// 캐릭터 선택 및 슬롯 선택 @@ -128,11 +124,12 @@ class _ArenaSetupScreenState extends State { @override Widget build(BuildContext context) { + final l10n = L10n.of(context); return Scaffold( backgroundColor: RetroColors.backgroundOf(context), appBar: AppBar( title: Text( - _setupTitle, + l10n.arenaSetupTitle, style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15), ), centerTitle: true, @@ -153,7 +150,7 @@ class _ArenaSetupScreenState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - _selectCharacter, + L10n.of(context).arenaSelectFighter, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 14, @@ -371,7 +368,7 @@ class _ArenaSetupScreenState extends State { ), const SizedBox(width: 8), Text( - _startBattleLabel, + L10n.of(context).arenaStartBattle, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 14, diff --git a/lib/src/features/arena/widgets/arena_equipment_compare_list.dart b/lib/src/features/arena/widgets/arena_equipment_compare_list.dart index 4fef0f6..1f7155e 100644 --- a/lib/src/features/arena/widgets/arena_equipment_compare_list.dart +++ b/lib/src/features/arena/widgets/arena_equipment_compare_list.dart @@ -1,18 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/engine/item_service.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/item_stats.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; -// 임시 문자열 (추후 l10n으로 이동) -const _myEquipmentTitle = 'MY EQUIPMENT'; -const _enemyEquipmentTitle = 'ENEMY EQUIPMENT'; -const _selectedLabel = 'SELECTED'; -const _recommendedLabel = 'BEST'; -const _weaponLockedLabel = 'LOCKED'; - /// 좌우 대칭 장비 비교 리스트 /// /// 내 장비와 상대 장비를 나란히 표시하고, @@ -113,6 +107,7 @@ class _ArenaEquipmentCompareListState extends State { } Widget _buildHeader(BuildContext context) { + final l10n = L10n.of(context); return Row( children: [ // 내 장비 타이틀 @@ -125,7 +120,7 @@ class _ArenaEquipmentCompareListState extends State { border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), ), child: Text( - _myEquipmentTitle, + l10n.arenaMyEquipment, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 11, @@ -146,7 +141,7 @@ class _ArenaEquipmentCompareListState extends State { border: Border.all(color: Colors.red.withValues(alpha: 0.3)), ), child: Text( - _enemyEquipmentTitle, + l10n.arenaEnemyEquipment, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 11, @@ -402,7 +397,7 @@ class _ArenaEquipmentCompareListState extends State { // 잠금 표시 또는 점수 변화 if (isLocked) Text( - _weaponLockedLabel, + L10n.of(context).arenaWeaponLocked, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 11, @@ -441,7 +436,7 @@ class _ArenaEquipmentCompareListState extends State { children: [ if (isRecommended) ...[ Text( - _recommendedLabel, + L10n.of(context).arenaRecommended, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 11, @@ -471,21 +466,22 @@ class _ArenaEquipmentCompareListState extends State { EquipmentItem? enemyItem, int scoreDiff, ) { + final l10n = L10n.of(context); final Color resultColor; final String resultText; final IconData resultIcon; if (scoreDiff > 0) { resultColor = Colors.green; - resultText = 'You will GAIN +$scoreDiff'; + resultText = l10n.arenaScoreGain(scoreDiff); resultIcon = Icons.arrow_upward; } else if (scoreDiff < 0) { resultColor = Colors.red; - resultText = 'You will LOSE $scoreDiff'; + resultText = l10n.arenaScoreLose(scoreDiff); resultIcon = Icons.arrow_downward; } else { resultColor = RetroColors.textMutedOf(context); - resultText = 'Even trade'; + resultText = l10n.arenaEvenTrade; resultIcon = Icons.swap_horiz; } @@ -563,7 +559,7 @@ class _ArenaEquipmentCompareListState extends State { ), const SizedBox(width: 6), Text( - _selectedLabel, + L10n.of(context).arenaSelected, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 12, diff --git a/lib/src/features/arena/widgets/arena_hp_bar.dart b/lib/src/features/arena/widgets/arena_hp_bar.dart new file mode 100644 index 0000000..5a560a4 --- /dev/null +++ b/lib/src/features/arena/widgets/arena_hp_bar.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/l10n/app_localizations.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; + +/// 아레나 전투 HP 바 (좌우 대칭 레이아웃) +class ArenaHpBars extends StatelessWidget { + const ArenaHpBars({ + super.key, + required this.challengerName, + required this.challengerHp, + required this.challengerHpMax, + required this.challengerFlashAnimation, + required this.challengerHpChange, + required this.opponentName, + required this.opponentHp, + required this.opponentHpMax, + required this.opponentFlashAnimation, + required this.opponentHpChange, + }); + + final String challengerName; + final int challengerHp; + final int challengerHpMax; + final Animation challengerFlashAnimation; + final int challengerHpChange; + + final String opponentName; + final int opponentHp; + final int opponentHpMax; + final Animation opponentFlashAnimation; + final int opponentHpChange; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: RetroColors.panelBgOf(context), + border: Border( + bottom: BorderSide(color: RetroColors.borderOf(context), width: 2), + ), + ), + child: Row( + children: [ + Expanded( + child: _ArenaHpBar( + name: challengerName, + hp: challengerHp, + hpMax: challengerHpMax, + fillColor: RetroColors.mpBlue, + accentColor: Colors.blue, + flashAnimation: challengerFlashAnimation, + hpChange: challengerHpChange, + isReversed: false, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'VS', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 13, + color: RetroColors.goldOf(context), + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: _ArenaHpBar( + name: opponentName, + hp: opponentHp, + hpMax: opponentHpMax, + fillColor: RetroColors.hpRed, + accentColor: Colors.red, + flashAnimation: opponentFlashAnimation, + hpChange: opponentHpChange, + isReversed: true, + ), + ), + ], + ), + ); + } +} + +/// 레트로 세그먼트 HP 바 (개별) +class _ArenaHpBar extends StatelessWidget { + const _ArenaHpBar({ + required this.name, + required this.hp, + required this.hpMax, + required this.fillColor, + required this.accentColor, + required this.flashAnimation, + required this.hpChange, + required this.isReversed, + }); + + final String name; + final int hp; + final int hpMax; + final Color fillColor; + final Color accentColor; + final Animation flashAnimation; + final int hpChange; + final bool isReversed; + + @override + Widget build(BuildContext context) { + final hpRatio = hpMax > 0 ? hp / hpMax : 0.0; + final isLow = hpRatio < 0.2 && hpRatio > 0; + + return AnimatedBuilder( + animation: flashAnimation, + builder: (context, child) { + final isDamage = hpChange < 0; + final flashColor = isDamage + ? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4) + : RetroColors.expGreen.withValues( + alpha: flashAnimation.value * 0.4, + ); + + return Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: flashAnimation.value > 0.1 + ? flashColor + : accentColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: accentColor, width: 2), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + crossAxisAlignment: isReversed + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.textPrimaryOf(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + _buildSegmentBar(context, hpRatio, isLow), + const SizedBox(height: 2), + Row( + mainAxisAlignment: isReversed + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Text( + L10n.of(context).hpLabel, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: accentColor.withValues(alpha: 0.8), + ), + ), + const SizedBox(width: 4), + Text( + '$hp/$hpMax', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 12, + color: isLow ? RetroColors.hpRed : fillColor, + ), + ), + ], + ), + ], + ), + if (hpChange != 0 && flashAnimation.value > 0.05) + Positioned( + left: isReversed ? null : 0, + right: isReversed ? 0 : null, + top: -12, + child: Transform.translate( + offset: Offset(0, -12 * (1 - flashAnimation.value)), + child: Opacity( + opacity: flashAnimation.value, + child: Text( + hpChange > 0 ? '+$hpChange' : '$hpChange', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 13, + fontWeight: FontWeight.bold, + color: isDamage + ? RetroColors.hpRed + : RetroColors.expGreen, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 3), + Shadow(color: Colors.black, blurRadius: 6), + ], + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + /// 세그먼트 바 (8-bit 스타일) + Widget _buildSegmentBar(BuildContext context, double ratio, bool isLow) { + const segmentCount = 10; + final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round(); + + final segments = List.generate(segmentCount, (index) { + final isFilled = isReversed + ? index >= segmentCount - filledSegments + : index < filledSegments; + + return Expanded( + child: Container( + height: 8, + decoration: BoxDecoration( + color: isFilled + ? (isLow ? RetroColors.hpRed : fillColor) + : fillColor.withValues(alpha: 0.2), + border: Border( + right: index < segmentCount - 1 + ? BorderSide( + color: RetroColors.borderOf( + context, + ).withValues(alpha: 0.3), + width: 1, + ) + : BorderSide.none, + ), + ), + ), + ); + }); + + return Container( + decoration: BoxDecoration( + border: Border.all(color: RetroColors.borderOf(context), width: 1), + ), + child: Row(children: isReversed ? segments.reversed.toList() : segments), + ); + } +} diff --git a/lib/src/features/arena/widgets/arena_idle_preview.dart b/lib/src/features/arena/widgets/arena_idle_preview.dart index ccba80f..121372c 100644 --- a/lib/src/features/arena/widgets/arena_idle_preview.dart +++ b/lib/src/features/arena/widgets/arena_idle_preview.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.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/character_frames.dart'; -import 'package:asciineverdie/src/core/animation/race_character_frames.dart'; +import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.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/character_frames.dart'; +import 'package:asciineverdie/src/shared/animation/race_character_frames.dart'; import 'package:flutter/material.dart'; /// 아레나 idle 상태 캐릭터 미리보기 위젯 diff --git a/lib/src/features/arena/widgets/arena_rank_card.dart b/lib/src/features/arena/widgets/arena_rank_card.dart index 10f735b..3a6efe9 100644 --- a/lib/src/features/arena/widgets/arena_rank_card.dart +++ b/lib/src/features/arena/widgets/arena_rank_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart'; +import 'package:asciineverdie/l10n/app_localizations.dart'; +import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart'; import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; @@ -169,7 +170,7 @@ class ArenaRankCard extends StatelessWidget { ), ), Text( - 'SCORE', + L10n.of(context).arenaScore, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 11, diff --git a/lib/src/features/arena/widgets/arena_result_dialog.dart b/lib/src/features/arena/widgets/arena_result_dialog.dart index 8c12712..acc780e 100644 --- a/lib/src/features/arena/widgets/arena_result_dialog.dart +++ b/lib/src/features/arena/widgets/arena_result_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; +import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/engine/item_service.dart'; import 'package:asciineverdie/src/core/model/arena_match.dart'; import 'package:asciineverdie/src/core/model/equipment_item.dart'; @@ -8,11 +9,6 @@ import 'package:asciineverdie/src/core/model/equipment_slot.dart'; import 'package:asciineverdie/src/core/model/item_stats.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; -// 아레나 관련 임시 문자열 (추후 l10n으로 이동) -const _arenaVictory = 'VICTORY!'; -const _arenaDefeat = 'DEFEAT...'; -const _arenaExchange = 'EQUIPMENT EXCHANGE'; - /// 아레나 결과 다이얼로그 /// /// 전투 승패 및 장비 교환 결과 표시 @@ -65,7 +61,7 @@ class ArenaResultDialog extends StatelessWidget { onPressed: onClose, style: FilledButton.styleFrom(backgroundColor: resultColor), child: Text( - l10n.buttonConfirm, + game_l10n.buttonConfirm, style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 13), ), ), @@ -74,6 +70,7 @@ class ArenaResultDialog extends StatelessWidget { } Widget _buildTitle(BuildContext context, bool isVictory, Color color) { + final l10n = L10n.of(context); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -84,7 +81,7 @@ class ArenaResultDialog extends StatelessWidget { ), const SizedBox(width: 8), Text( - isVictory ? _arenaVictory : _arenaDefeat, + isVictory ? l10n.arenaVictory : l10n.arenaDefeat, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 15, @@ -152,7 +149,7 @@ class ArenaResultDialog extends StatelessWidget { overflow: TextOverflow.ellipsis, ), Text( - isWinner ? 'WINNER' : 'LOSER', + isWinner ? L10n.of(context).arenaWinner : L10n.of(context).arenaLoser, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 11, @@ -196,7 +193,7 @@ class ArenaResultDialog extends StatelessWidget { ), const SizedBox(width: 8), Text( - _arenaExchange, + L10n.of(context).arenaEquipmentExchange, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 14, @@ -380,17 +377,17 @@ class ArenaResultDialog extends StatelessWidget { String _getSlotLabel(EquipmentSlot slot) { return switch (slot) { - EquipmentSlot.weapon => l10n.slotWeapon, - EquipmentSlot.shield => l10n.slotShield, - EquipmentSlot.helm => l10n.slotHelm, - EquipmentSlot.hauberk => l10n.slotHauberk, - EquipmentSlot.brassairts => l10n.slotBrassairts, - EquipmentSlot.vambraces => l10n.slotVambraces, - EquipmentSlot.gauntlets => l10n.slotGauntlets, - EquipmentSlot.gambeson => l10n.slotGambeson, - EquipmentSlot.cuisses => l10n.slotCuisses, - EquipmentSlot.greaves => l10n.slotGreaves, - EquipmentSlot.sollerets => l10n.slotSollerets, + EquipmentSlot.weapon => game_l10n.slotWeapon, + EquipmentSlot.shield => game_l10n.slotShield, + EquipmentSlot.helm => game_l10n.slotHelm, + EquipmentSlot.hauberk => game_l10n.slotHauberk, + EquipmentSlot.brassairts => game_l10n.slotBrassairts, + EquipmentSlot.vambraces => game_l10n.slotVambraces, + EquipmentSlot.gauntlets => game_l10n.slotGauntlets, + EquipmentSlot.gambeson => game_l10n.slotGambeson, + EquipmentSlot.cuisses => game_l10n.slotCuisses, + EquipmentSlot.greaves => game_l10n.slotGreaves, + EquipmentSlot.sollerets => game_l10n.slotSollerets, }; } diff --git a/lib/src/features/arena/widgets/arena_result_panel.dart b/lib/src/features/arena/widgets/arena_result_panel.dart index 4197ae6..aa61a40 100644 --- a/lib/src/features/arena/widgets/arena_result_panel.dart +++ b/lib/src/features/arena/widgets/arena_result_panel.dart @@ -5,7 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; +import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/engine/item_service.dart'; import 'package:asciineverdie/src/core/model/arena_match.dart'; import 'package:asciineverdie/src/core/model/equipment_item.dart'; @@ -15,12 +16,6 @@ import 'package:asciineverdie/src/core/model/item_stats.dart'; import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; -// 임시 문자열 -const _victory = 'VICTORY!'; -const _defeat = 'DEFEAT...'; -const _exchange = 'EQUIPMENT EXCHANGE'; -const _turns = 'TURNS'; - /// 아레나 결과 패널 (인라인) /// /// 전투 로그 하단에 표시되는 플로팅 결과 패널 @@ -132,7 +127,7 @@ class _ArenaResultPanelState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - '${l10n.uiSaved}: $fileName', + '${game_l10n.uiSaved}: $fileName', style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11), ), backgroundColor: RetroColors.mpOf(context), @@ -145,7 +140,7 @@ class _ArenaResultPanelState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - '${l10n.uiError}: $e', + '${game_l10n.uiError}: $e', style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11), ), backgroundColor: RetroColors.hpOf(context), @@ -353,6 +348,7 @@ class _ArenaResultPanelState extends State } Widget _buildTitle(BuildContext context, bool isVictory, Color color) { + final l10n = L10n.of(context); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -363,7 +359,7 @@ class _ArenaResultPanelState extends State ), const SizedBox(width: 8), Text( - isVictory ? _victory : _defeat, + isVictory ? l10n.arenaVictory : l10n.arenaDefeat, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 14, @@ -381,67 +377,26 @@ class _ArenaResultPanelState extends State } Widget _buildBattleSummary(BuildContext context) { + final l10n = L10n.of(context); final winner = widget.result.isVictory ? widget.result.match.challenger.characterName : widget.result.match.opponent.characterName; final loser = widget.result.isVictory ? widget.result.match.opponent.characterName : widget.result.match.challenger.characterName; + final summaryText = l10n.arenaDefeatedIn(winner, loser, widget.turnCount); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 승자 - Text( - winner, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 11, - color: RetroColors.goldOf(context), - ), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + summaryText, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.textSecondaryOf(context), ), - Text( - ' defeated ', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 11, - color: RetroColors.textMutedOf(context), - ), - ), - // 패자 - Text( - loser, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 12, - color: RetroColors.textSecondaryOf(context), - ), - ), - Text( - ' in ', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 11, - color: RetroColors.textMutedOf(context), - ), - ), - // 턴 수 - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: RetroColors.goldOf(context).withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${widget.turnCount} $_turns', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 12, - color: RetroColors.goldOf(context), - ), - ), - ), - ], + textAlign: TextAlign.center, + ), ); } @@ -499,7 +454,7 @@ class _ArenaResultPanelState extends State ), const SizedBox(width: 4), Text( - _exchange, + L10n.of(context).arenaEquipmentExchange, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 12, @@ -639,7 +594,7 @@ class _ArenaResultPanelState extends State shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), child: Text( - l10n.buttonConfirm, + game_l10n.buttonConfirm, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 13, @@ -658,7 +613,7 @@ class _ArenaResultPanelState extends State onPressed: _saveBattleLog, icon: const Icon(Icons.save_alt, size: 14), label: Text( - l10n.uiSaveBattleLog, + game_l10n.uiSaveBattleLog, style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11), ), style: OutlinedButton.styleFrom( @@ -681,17 +636,17 @@ class _ArenaResultPanelState extends State String _getSlotLabel(EquipmentSlot slot) { return switch (slot) { - EquipmentSlot.weapon => l10n.slotWeapon, - EquipmentSlot.shield => l10n.slotShield, - EquipmentSlot.helm => l10n.slotHelm, - EquipmentSlot.hauberk => l10n.slotHauberk, - EquipmentSlot.brassairts => l10n.slotBrassairts, - EquipmentSlot.vambraces => l10n.slotVambraces, - EquipmentSlot.gauntlets => l10n.slotGauntlets, - EquipmentSlot.gambeson => l10n.slotGambeson, - EquipmentSlot.cuisses => l10n.slotCuisses, - EquipmentSlot.greaves => l10n.slotGreaves, - EquipmentSlot.sollerets => l10n.slotSollerets, + EquipmentSlot.weapon => game_l10n.slotWeapon, + EquipmentSlot.shield => game_l10n.slotShield, + EquipmentSlot.helm => game_l10n.slotHelm, + EquipmentSlot.hauberk => game_l10n.slotHauberk, + EquipmentSlot.brassairts => game_l10n.slotBrassairts, + EquipmentSlot.vambraces => game_l10n.slotVambraces, + EquipmentSlot.gauntlets => game_l10n.slotGauntlets, + EquipmentSlot.gambeson => game_l10n.slotGambeson, + EquipmentSlot.cuisses => game_l10n.slotCuisses, + EquipmentSlot.greaves => game_l10n.slotGreaves, + EquipmentSlot.sollerets => game_l10n.slotSollerets, }; } diff --git a/lib/src/features/front/widgets/hero_vs_boss_animation.dart b/lib/src/features/front/widgets/hero_vs_boss_animation.dart index ed7ccb3..be4dd11 100644 --- a/lib/src/features/front/widgets/hero_vs_boss_animation.dart +++ b/lib/src/features/front/widgets/hero_vs_boss_animation.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:asciineverdie/data/race_data.dart'; -import 'package:asciineverdie/src/core/animation/front_screen_animation.dart'; +import 'package:asciineverdie/src/shared/animation/front_screen_animation.dart'; /// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 위젯 /// diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 58f8cf3..2086b5e 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -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 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 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); - } } diff --git a/lib/src/features/game/layouts/mobile_carousel_layout.dart b/lib/src/features/game/layouts/mobile_carousel_layout.dart index 8cccc7a..1618bc5 100644 --- a/lib/src/features/game/layouts/mobile_carousel_layout.dart +++ b/lib/src/features/game/layouts/mobile_carousel_layout.dart @@ -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 { ); } - /// 현재 언어명 가져오기 - 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( - 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( - 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( - 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 _showTestCharacterDialog(BuildContext context) async { - final confirmed = await showDialog( - 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( - 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 { // 옵션 버튼 IconButton( icon: Icon(Icons.settings, color: gold), - onPressed: () => _showOptionsMenu(context), + onPressed: () => _openOptionsMenu(context), tooltip: l10n.menuOptions, ), ], diff --git a/lib/src/features/game/pages/character_sheet_page.dart b/lib/src/features/game/pages/character_sheet_page.dart index 457a6da..a8a07de 100644 --- a/lib/src/features/game/pages/character_sheet_page.dart +++ b/lib/src/features/game/pages/character_sheet_page.dart @@ -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'; diff --git a/lib/src/features/game/pages/inventory_page.dart b/lib/src/features/game/pages/inventory_page.dart index 6a6c4e0..4641b12 100644 --- a/lib/src/features/game/pages/inventory_page.dart +++ b/lib/src/features/game/pages/inventory_page.dart @@ -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'; diff --git a/lib/src/features/game/pages/skills_page.dart b/lib/src/features/game/pages/skills_page.dart index da0c25e..685446e 100644 --- a/lib/src/features/game/pages/skills_page.dart +++ b/lib/src/features/game/pages/skills_page.dart @@ -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'; diff --git a/lib/src/features/game/pages/story_page.dart b/lib/src/features/game/pages/story_page.dart index 623ad77..31e7c17 100644 --- a/lib/src/features/game/pages/story_page.dart +++ b/lib/src/features/game/pages/story_page.dart @@ -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; - } } diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 46f114c..49c4dbc 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -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 { // 전투 모드가 아니면 무시 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 { } // 공격자 타입 결정 (Phase 7: 공격자별 위치 분리) - _currentAttacker = switch (event.type) { - CombatEventType.playerAttack || - CombatEventType.playerSkill => AttackerType.player, - CombatEventType.monsterAttack => AttackerType.monster, - _ => AttackerType.none, - }; + _currentAttacker = getAttackerType(event.type); }); } diff --git a/lib/src/features/game/widgets/combat_event_mapping.dart b/lib/src/features/game/widgets/combat_event_mapping.dart new file mode 100644 index 0000000..45a3b03 --- /dev/null +++ b/lib/src/features/game/widgets/combat_event_mapping.dart @@ -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, + }; +} diff --git a/lib/src/features/game/widgets/compact_status_bars.dart b/lib/src/features/game/widgets/compact_status_bars.dart new file mode 100644 index 0000000..7bf9bb0 --- /dev/null +++ b/lib/src/features/game/widgets/compact_status_bars.dart @@ -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 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 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 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), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/src/features/game/widgets/death_buttons.dart b/lib/src/features/game/widgets/death_buttons.dart new file mode 100644 index 0000000..d143dde --- /dev/null +++ b/lib/src/features/game/widgets/death_buttons.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/lib/src/features/game/widgets/death_combat_log.dart b/lib/src/features/game/widgets/death_combat_log.dart new file mode 100644 index 0000000..650ec30 --- /dev/null +++ b/lib/src/features/game/widgets/death_combat_log.dart @@ -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 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 ?? ''), + ), + }; + } +} diff --git a/lib/src/features/game/widgets/death_overlay.dart b/lib/src/features/game/widgets/death_overlay.dart index 0f589e6..5dca369 100644 --- a/lib/src/features/game/widgets/death_overlay.dart +++ b/lib/src/features/game/widgets/death_overlay.dart @@ -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) { diff --git a/lib/src/features/game/widgets/desktop_character_panel.dart b/lib/src/features/game/widgets/desktop_character_panel.dart new file mode 100644 index 0000000..3e9eafc --- /dev/null +++ b/lib/src/features/game/widgets/desktop_character_panel.dart @@ -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); + } +} diff --git a/lib/src/features/game/widgets/desktop_equipment_panel.dart b/lib/src/features/game/widgets/desktop_equipment_panel.dart new file mode 100644 index 0000000..ace5bdb --- /dev/null +++ b/lib/src/features/game/widgets/desktop_equipment_panel.dart @@ -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 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/game/widgets/desktop_panel_widgets.dart b/lib/src/features/game/widgets/desktop_panel_widgets.dart new file mode 100644 index 0000000..045049b --- /dev/null +++ b/lib/src/features/game/widgets/desktop_panel_widgets.dart @@ -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; + } +} diff --git a/lib/src/features/game/widgets/desktop_quest_panel.dart b/lib/src/features/game/widgets/desktop_quest_panel.dart new file mode 100644 index 0000000..5d71988 --- /dev/null +++ b/lib/src/features/game/widgets/desktop_quest_panel.dart @@ -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, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/src/features/game/widgets/enhanced_animation_panel.dart b/lib/src/features/game/widgets/enhanced_animation_panel.dart index 37261e8..b1d209b 100644 --- a/lib/src/features/game/widgets/enhanced_animation_panel.dart +++ b/lib/src/features/game/widgets/enhanced_animation_panel.dart @@ -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 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 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 ); } - /// 컴팩트 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 토글 버튼 하나만 표시 diff --git a/lib/src/features/game/widgets/equipment_stats_panel.dart b/lib/src/features/game/widgets/equipment_stats_panel.dart index 39e9294..e50f28d 100644 --- a/lib/src/features/game/widgets/equipment_stats_panel.dart +++ b/lib/src/features/game/widgets/equipment_stats_panel.dart @@ -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'; diff --git a/lib/src/features/game/widgets/hp_mp_bar.dart b/lib/src/features/game/widgets/hp_mp_bar.dart index 0ea3adf..efd5814 100644 --- a/lib/src/features/game/widgets/hp_mp_bar.dart +++ b/lib/src/features/game/widgets/hp_mp_bar.dart @@ -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 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 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), - ], - ), - ), - ), - ), - ), - ], - ), - ); - }, - ); - } } diff --git a/lib/src/features/game/widgets/mobile_options_menu.dart b/lib/src/features/game/widgets/mobile_options_menu.dart new file mode 100644 index 0000000..c47e8d2 --- /dev/null +++ b/lib/src/features/game/widgets/mobile_options_menu.dart @@ -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 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( + 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( + 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( + 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( + 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( + 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), + ), + ); + } +} diff --git a/lib/src/features/game/widgets/notification_overlay.dart b/lib/src/features/game/widgets/notification_overlay.dart index 558bbef..8abd57e 100644 --- a/lib/src/features/game/widgets/notification_overlay.dart +++ b/lib/src/features/game/widgets/notification_overlay.dart @@ -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, }; } } diff --git a/lib/src/features/game/widgets/retro_monster_hp_bar.dart b/lib/src/features/game/widgets/retro_monster_hp_bar.dart new file mode 100644 index 0000000..1e068a7 --- /dev/null +++ b/lib/src/features/game/widgets/retro_monster_hp_bar.dart @@ -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 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, + ), + ), + ), + ); + }), + ), + ); + } +} diff --git a/lib/src/features/game/widgets/statistics_dialog.dart b/lib/src/features/game/widgets/statistics_dialog.dart index dabd1f0..0fc70fe 100644 --- a/lib/src/features/game/widgets/statistics_dialog.dart +++ b/lib/src/features/game/widgets/statistics_dialog.dart @@ -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 @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); } } diff --git a/lib/src/features/game/widgets/task_progress_panel.dart b/lib/src/features/game/widgets/task_progress_panel.dart index 0cc56ab..f2a2c04 100644 --- a/lib/src/features/game/widgets/task_progress_panel.dart +++ b/lib/src/features/game/widgets/task_progress_panel.dart @@ -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'; diff --git a/lib/src/features/game/widgets/victory_overlay.dart b/lib/src/features/game/widgets/victory_overlay.dart index e13419b..2d0e9de 100644 --- a/lib/src/features/game/widgets/victory_overlay.dart +++ b/lib/src/features/game/widgets/victory_overlay.dart @@ -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'; diff --git a/lib/src/features/hall_of_fame/game_clear_dialog.dart b/lib/src/features/hall_of_fame/game_clear_dialog.dart index 9839f5a..f0abbc4 100644 --- a/lib/src/features/hall_of_fame/game_clear_dialog.dart +++ b/lib/src/features/hall_of_fame/game_clear_dialog.dart @@ -1,7 +1,7 @@ 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/shared/l10n/game_data_l10n.dart'; import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; diff --git a/lib/src/features/hall_of_fame/hall_of_fame_entry_card.dart b/lib/src/features/hall_of_fame/hall_of_fame_entry_card.dart index 8ea850a..a4f77f1 100644 --- a/lib/src/features/hall_of_fame/hall_of_fame_entry_card.dart +++ b/lib/src/features/hall_of_fame/hall_of_fame_entry_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/hall_of_fame.dart'; import 'package:asciineverdie/src/features/hall_of_fame/hero_detail_dialog.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; diff --git a/lib/src/features/hall_of_fame/hero_detail_dialog.dart b/lib/src/features/hall_of_fame/hero_detail_dialog.dart index 79b010d..7d28411 100644 --- a/lib/src/features/hall_of_fame/hero_detail_dialog.dart +++ b/lib/src/features/hall_of_fame/hero_detail_dialog.dart @@ -1,7 +1,7 @@ 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/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/hall_of_fame.dart'; diff --git a/lib/src/features/new_character/widgets/class_selection_section.dart b/lib/src/features/new_character/widgets/class_selection_section.dart index a6beb5d..93819c2 100644 --- a/lib/src/features/new_character/widgets/class_selection_section.dart +++ b/lib/src/features/new_character/widgets/class_selection_section.dart @@ -2,7 +2,7 @@ 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/l10n/game_data_l10n.dart'; +import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart'; import 'package:asciineverdie/src/core/model/class_traits.dart'; import 'package:asciineverdie/src/core/model/race_traits.dart' show StatType; import 'package:asciineverdie/src/shared/retro_colors.dart'; diff --git a/lib/src/features/new_character/widgets/race_preview.dart b/lib/src/features/new_character/widgets/race_preview.dart index 16a9200..a9e5dc4 100644 --- a/lib/src/features/new_character/widgets/race_preview.dart +++ b/lib/src/features/new_character/widgets/race_preview.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:asciineverdie/src/core/animation/character_frames.dart'; -import 'package:asciineverdie/src/core/animation/race_character_frames.dart'; +import 'package:asciineverdie/src/shared/animation/character_frames.dart'; +import 'package:asciineverdie/src/shared/animation/race_character_frames.dart'; /// 종족 미리보기 위젯 /// diff --git a/lib/src/features/new_character/widgets/race_selection_section.dart b/lib/src/features/new_character/widgets/race_selection_section.dart index 7c1f36a..9e6cb6c 100644 --- a/lib/src/features/new_character/widgets/race_selection_section.dart +++ b/lib/src/features/new_character/widgets/race_selection_section.dart @@ -2,7 +2,7 @@ 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/l10n/game_data_l10n.dart'; +import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart'; import 'package:asciineverdie/src/core/model/race_traits.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; diff --git a/lib/src/features/settings/retro_settings_widgets.dart b/lib/src/features/settings/retro_settings_widgets.dart new file mode 100644 index 0000000..384ff96 --- /dev/null +++ b/lib/src/features/settings/retro_settings_widgets.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/src/shared/retro_colors.dart'; +import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; + +/// 설정 화면에서 사용하는 레트로 스타일 서브 위젯들 + +/// 섹션 타이틀 +class RetroSectionTitle extends StatelessWidget { + const RetroSectionTitle({super.key, required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container(width: 4, height: 14, color: RetroColors.goldOf(context)), + const SizedBox(width: 8), + Text( + title.toUpperCase(), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.goldOf(context), + letterSpacing: 1, + ), + ), + ], + ); + } +} + +/// 선택 가능한 아이템 +class RetroSelectableItem extends StatelessWidget { + const RetroSelectableItem({ + super.key, + required this.child, + required this.isSelected, + required this.onTap, + }); + + final Widget child; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? RetroColors.goldOf(context).withValues(alpha: 0.15) + : Colors.transparent, + border: Border.all( + color: isSelected + ? RetroColors.goldOf(context) + : RetroColors.borderOf(context), + width: isSelected ? 2 : 1, + ), + ), + child: child, + ), + ); + } +} + +/// 볼륨 슬라이더 +class RetroVolumeSlider extends StatelessWidget { + const RetroVolumeSlider({ + super.key, + required this.label, + required this.icon, + required this.value, + required this.onChanged, + }); + + final String label; + final IconData icon; + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final percentage = (value * 100).round(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + value == 0 ? Icons.volume_off : icon, + size: 14, + color: RetroColors.goldOf(context), + ), + const SizedBox(width: 8), + Text( + label.toUpperCase(), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.textPrimaryOf(context), + ), + ), + const Spacer(), + Text( + '$percentage%', + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.goldOf(context), + ), + ), + ], + ), + const SizedBox(height: 8), + RetroSlider(value: value, onChanged: onChanged), + ], + ); + } +} + +/// 레트로 스타일 슬라이더 +class RetroSlider extends StatelessWidget { + const RetroSlider({ + super.key, + required this.value, + required this.onChanged, + }); + + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SliderTheme( + data: SliderThemeData( + trackHeight: 8, + activeTrackColor: RetroColors.goldOf(context), + inactiveTrackColor: RetroColors.borderOf(context), + thumbColor: RetroColors.goldLightOf(context), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + overlayColor: RetroColors.goldOf(context).withValues(alpha: 0.2), + trackShape: const RectangularSliderTrackShape(), + ), + child: Slider(value: value, onChanged: onChanged, divisions: 10), + ); + } +} + +/// 디버그 토글 +class RetroDebugToggle extends StatelessWidget { + const RetroDebugToggle({ + super.key, + required this.icon, + required this.label, + required this.description, + required this.value, + required this.onChanged, + }); + + final IconData icon; + final String label; + final String description; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 14, color: RetroColors.textPrimaryOf(context)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.textPrimaryOf(context), + ), + ), + Text( + description, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 6, + color: RetroColors.textMutedOf(context), + ), + ), + ], + ), + ), + RetroToggle(value: value, onChanged: onChanged), + ], + ); + } +} + +/// 레트로 스타일 토글 +class RetroToggle extends StatelessWidget { + const RetroToggle({ + super.key, + required this.value, + required this.onChanged, + }); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(!value), + child: Container( + width: 44, + height: 24, + decoration: BoxDecoration( + color: value + ? RetroColors.goldOf(context).withValues(alpha: 0.3) + : RetroColors.borderOf(context).withValues(alpha: 0.3), + border: Border.all( + color: value + ? RetroColors.goldOf(context) + : RetroColors.borderOf(context), + width: 2, + ), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 150), + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 18, + height: 18, + margin: const EdgeInsets.all(1), + color: value + ? RetroColors.goldOf(context) + : RetroColors.textMutedOf(context), + ), + ), + ), + ); + } +} + +/// 레트로 스타일 칩 +class RetroChip extends StatelessWidget { + const RetroChip({ + super.key, + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? RetroColors.goldOf(context).withValues(alpha: 0.2) + : Colors.transparent, + border: Border.all( + color: isSelected + ? RetroColors.goldOf(context) + : RetroColors.borderOf(context), + width: isSelected ? 2 : 1, + ), + ), + child: Text( + label, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: isSelected + ? RetroColors.goldOf(context) + : RetroColors.textMutedOf(context), + ), + ), + ), + ); + } +} + +/// 레트로 스타일 확인 다이얼로그 +class RetroConfirmDialog extends StatelessWidget { + const RetroConfirmDialog({ + super.key, + required this.title, + required this.message, + required this.confirmText, + required this.cancelText, + }); + + final String title; + final String message; + final String confirmText; + final String cancelText; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + constraints: const BoxConstraints(maxWidth: 360), + decoration: BoxDecoration( + color: RetroColors.backgroundOf(context), + border: Border.all(color: RetroColors.goldOf(context), width: 3), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 타이틀 + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: RetroColors.goldOf(context).withValues(alpha: 0.2), + child: Text( + title, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 10, + color: RetroColors.goldOf(context), + ), + textAlign: TextAlign.center, + ), + ), + // 메시지 + Padding( + padding: const EdgeInsets.all(16), + child: Text( + message, + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 8, + color: RetroColors.textPrimaryOf(context), + height: 1.8, + ), + textAlign: TextAlign.center, + ), + ), + // 버튼 + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Row( + children: [ + Expanded( + child: RetroTextButton( + text: cancelText, + isPrimary: false, + onPressed: () => Navigator.of(context).pop(false), + ), + ), + const SizedBox(width: 8), + Expanded( + child: RetroTextButton( + text: confirmText, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/settings/settings_screen.dart b/lib/src/features/settings/settings_screen.dart index 3bfe1e9..641e590 100644 --- a/lib/src/features/settings/settings_screen.dart +++ b/lib/src/features/settings/settings_screen.dart @@ -7,6 +7,7 @@ import 'package:asciineverdie/src/core/engine/debug_settings_service.dart'; import 'package:asciineverdie/src/core/storage/settings_repository.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; +import 'package:asciineverdie/src/features/settings/retro_settings_widgets.dart'; /// 통합 설정 화면 (레트로 스타일) class SettingsScreen extends StatefulWidget { @@ -133,20 +134,20 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(12), children: [ // 언어 설정 - _RetroSectionTitle(title: game_l10n.uiLanguage), + RetroSectionTitle(title: game_l10n.uiLanguage), const SizedBox(height: 8), _buildLanguageSelector(context), const SizedBox(height: 16), // 사운드 설정 - _RetroSectionTitle(title: game_l10n.uiSound), + RetroSectionTitle(title: game_l10n.uiSound), const SizedBox(height: 8), _buildSoundSettings(context), // 디버그 섹션 (디버그 모드에서만 표시) if (kDebugMode) ...[ const SizedBox(height: 16), - _RetroSectionTitle(title: L10n.of(context).debugTitle), + RetroSectionTitle(title: L10n.of(context).debugTitle), const SizedBox(height: 8), _buildDebugSection(context), ], @@ -223,7 +224,7 @@ class _SettingsScreenState extends State { final isSelected = currentLocale == lang.$1; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), - child: _RetroSelectableItem( + child: RetroSelectableItem( isSelected: isSelected, onTap: () { game_l10n.setGameLocale(lang.$1); @@ -266,7 +267,7 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(12), child: Column( children: [ - _RetroVolumeSlider( + RetroVolumeSlider( label: game_l10n.uiBgmVolume, icon: Icons.music_note, value: _bgmVolume, @@ -277,7 +278,7 @@ class _SettingsScreenState extends State { }, ), const SizedBox(height: 12), - _RetroVolumeSlider( + RetroVolumeSlider( label: game_l10n.uiSfxVolume, icon: Icons.volume_up, value: _sfxVolume, @@ -321,7 +322,7 @@ class _SettingsScreenState extends State { const SizedBox(height: 16), // IAP 시뮬레이션 토글 - _RetroDebugToggle( + RetroDebugToggle( icon: Icons.shopping_cart, label: L10n.of(context).debugIapPurchased, description: L10n.of(context).debugIapPurchasedDesc, @@ -418,7 +419,7 @@ class _SettingsScreenState extends State { final isSelected = _debugOfflineHours == hours; final label = hours == 0 ? 'OFF' : '${hours}H'; - return _RetroChip( + return RetroChip( label: label, isSelected: isSelected, onTap: () async { @@ -435,7 +436,7 @@ class _SettingsScreenState extends State { Future _handleCreateTestCharacter() async { final confirmed = await showDialog( context: context, - builder: (context) => _RetroConfirmDialog( + builder: (context) => RetroConfirmDialog( title: L10n.of(context).debugCreateTestCharacterTitle, message: L10n.of(context).debugCreateTestCharacterMessage, confirmText: L10n.of(context).createButton, @@ -452,370 +453,3 @@ class _SettingsScreenState extends State { } } -// ═══════════════════════════════════════════════════════════════════════════ -// 레트로 스타일 서브 위젯들 -// ═══════════════════════════════════════════════════════════════════════════ - -/// 섹션 타이틀 -class _RetroSectionTitle extends StatelessWidget { - const _RetroSectionTitle({required this.title}); - - final String title; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Container(width: 4, height: 14, color: RetroColors.goldOf(context)), - const SizedBox(width: 8), - Text( - title.toUpperCase(), - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 10, - color: RetroColors.goldOf(context), - letterSpacing: 1, - ), - ), - ], - ); - } -} - -/// 선택 가능한 아이템 -class _RetroSelectableItem extends StatelessWidget { - const _RetroSelectableItem({ - required this.child, - required this.isSelected, - required this.onTap, - }); - - final Widget child; - final bool isSelected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: isSelected - ? RetroColors.goldOf(context).withValues(alpha: 0.15) - : Colors.transparent, - border: Border.all( - color: isSelected - ? RetroColors.goldOf(context) - : RetroColors.borderOf(context), - width: isSelected ? 2 : 1, - ), - ), - child: child, - ), - ); - } -} - -/// 볼륨 슬라이더 -class _RetroVolumeSlider extends StatelessWidget { - const _RetroVolumeSlider({ - required this.label, - required this.icon, - required this.value, - required this.onChanged, - }); - - final String label; - final IconData icon; - final double value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final percentage = (value * 100).round(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - value == 0 ? Icons.volume_off : icon, - size: 14, - color: RetroColors.goldOf(context), - ), - const SizedBox(width: 8), - Text( - label.toUpperCase(), - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 8, - color: RetroColors.textPrimaryOf(context), - ), - ), - const Spacer(), - Text( - '$percentage%', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 8, - color: RetroColors.goldOf(context), - ), - ), - ], - ), - const SizedBox(height: 8), - // 레트로 스타일 슬라이더 - _RetroSlider(value: value, onChanged: onChanged), - ], - ); - } -} - -/// 레트로 스타일 슬라이더 -class _RetroSlider extends StatelessWidget { - const _RetroSlider({required this.value, required this.onChanged}); - - final double value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return SliderTheme( - data: SliderThemeData( - trackHeight: 8, - activeTrackColor: RetroColors.goldOf(context), - inactiveTrackColor: RetroColors.borderOf(context), - thumbColor: RetroColors.goldLightOf(context), - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), - overlayColor: RetroColors.goldOf(context).withValues(alpha: 0.2), - trackShape: const RectangularSliderTrackShape(), - ), - child: Slider(value: value, onChanged: onChanged, divisions: 10), - ); - } -} - -/// 디버그 토글 -class _RetroDebugToggle extends StatelessWidget { - const _RetroDebugToggle({ - required this.icon, - required this.label, - required this.description, - required this.value, - required this.onChanged, - }); - - final IconData icon; - final String label; - final String description; - final bool value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Icon(icon, size: 14, color: RetroColors.textPrimaryOf(context)), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 8, - color: RetroColors.textPrimaryOf(context), - ), - ), - Text( - description, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 6, - color: RetroColors.textMutedOf(context), - ), - ), - ], - ), - ), - // 레트로 스타일 토글 - _RetroToggle(value: value, onChanged: onChanged), - ], - ); - } -} - -/// 레트로 스타일 토글 -class _RetroToggle extends StatelessWidget { - const _RetroToggle({required this.value, required this.onChanged}); - - final bool value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => onChanged(!value), - child: Container( - width: 44, - height: 24, - decoration: BoxDecoration( - color: value - ? RetroColors.goldOf(context).withValues(alpha: 0.3) - : RetroColors.borderOf(context).withValues(alpha: 0.3), - border: Border.all( - color: value - ? RetroColors.goldOf(context) - : RetroColors.borderOf(context), - width: 2, - ), - ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 150), - alignment: value ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - width: 18, - height: 18, - margin: const EdgeInsets.all(1), - color: value - ? RetroColors.goldOf(context) - : RetroColors.textMutedOf(context), - ), - ), - ), - ); - } -} - -/// 레트로 스타일 칩 -class _RetroChip extends StatelessWidget { - const _RetroChip({ - required this.label, - required this.isSelected, - required this.onTap, - }); - - final String label; - final bool isSelected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: isSelected - ? RetroColors.goldOf(context).withValues(alpha: 0.2) - : Colors.transparent, - border: Border.all( - color: isSelected - ? RetroColors.goldOf(context) - : RetroColors.borderOf(context), - width: isSelected ? 2 : 1, - ), - ), - child: Text( - label, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 8, - color: isSelected - ? RetroColors.goldOf(context) - : RetroColors.textMutedOf(context), - ), - ), - ), - ); - } -} - -/// 레트로 스타일 확인 다이얼로그 -class _RetroConfirmDialog extends StatelessWidget { - const _RetroConfirmDialog({ - required this.title, - required this.message, - required this.confirmText, - required this.cancelText, - }); - - final String title; - final String message; - final String confirmText; - final String cancelText; - - @override - Widget build(BuildContext context) { - return Dialog( - backgroundColor: Colors.transparent, - child: Container( - constraints: const BoxConstraints(maxWidth: 360), - decoration: BoxDecoration( - color: RetroColors.backgroundOf(context), - border: Border.all(color: RetroColors.goldOf(context), width: 3), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 타이틀 - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - color: RetroColors.goldOf(context).withValues(alpha: 0.2), - child: Text( - title, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 10, - color: RetroColors.goldOf(context), - ), - textAlign: TextAlign.center, - ), - ), - // 메시지 - Padding( - padding: const EdgeInsets.all(16), - child: Text( - message, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 8, - color: RetroColors.textPrimaryOf(context), - height: 1.8, - ), - textAlign: TextAlign.center, - ), - ), - // 버튼 - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - child: Row( - children: [ - Expanded( - child: RetroTextButton( - text: cancelText, - isPrimary: false, - onPressed: () => Navigator.of(context).pop(false), - ), - ), - const SizedBox(width: 8), - Expanded( - child: RetroTextButton( - text: confirmText, - onPressed: () => Navigator.of(context).pop(true), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -}