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

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

View File

@@ -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<ArenaBattleScreen>
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<ArenaBattleScreen>
// 턴 표시
_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<ArenaBattleScreen>
);
}
/// 레트로 스타일 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<double> 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),

View File

@@ -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<ArenaScreen> {
@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<ArenaScreen> {
}
Widget _buildEmptyState() {
final l10n = L10n.of(context);
return Center(
child: RetroPanel(
padding: const EdgeInsets.all(24),
@@ -114,7 +111,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
),
const SizedBox(height: 16),
Text(
_arenaEmpty,
l10n.arenaEmptyTitle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
@@ -123,7 +120,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
),
const SizedBox(height: 8),
Text(
_arenaEmptyHint,
l10n.arenaEmptyHint,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
@@ -143,7 +140,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
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,

View File

@@ -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<ArenaSetupScreen> {
@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<ArenaSetupScreen> {
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<ArenaSetupScreen> {
),
const SizedBox(width: 8),
Text(
_startBattleLabel,
L10n.of(context).arenaStartBattle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,

View File

@@ -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<ArenaEquipmentCompareList> {
}
Widget _buildHeader(BuildContext context) {
final l10n = L10n.of(context);
return Row(
children: [
// 내 장비 타이틀
@@ -125,7 +120,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
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<ArenaEquipmentCompareList> {
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<ArenaEquipmentCompareList> {
// 잠금 표시 또는 점수 변화
if (isLocked)
Text(
_weaponLockedLabel,
L10n.of(context).arenaWeaponLocked,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
@@ -441,7 +436,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
children: [
if (isRecommended) ...[
Text(
_recommendedLabel,
L10n.of(context).arenaRecommended,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
@@ -471,21 +466,22 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
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<ArenaEquipmentCompareList> {
),
const SizedBox(width: 6),
Text(
_selectedLabel,
L10n.of(context).arenaSelected,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,

View File

@@ -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<double> challengerFlashAnimation;
final int challengerHpChange;
final String opponentName;
final int opponentHp;
final int opponentHpMax;
final Animation<double> 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<double> 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),
);
}
}

View File

@@ -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 상태 캐릭터 미리보기 위젯

View File

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

View File

@@ -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,
};
}

View File

@@ -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<ArenaResultPanel>
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<ArenaResultPanel>
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<ArenaResultPanel>
}
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<ArenaResultPanel>
),
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<ArenaResultPanel>
}
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,
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
summaryText,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.goldOf(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),
),
textAlign: TextAlign.center,
),
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),
),
),
),
],
);
}
@@ -499,7 +454,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
),
const SizedBox(width: 4),
Text(
_exchange,
L10n.of(context).arenaEquipmentExchange,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
@@ -639,7 +594,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
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<ArenaResultPanel>
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<ArenaResultPanel>
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,
};
}

View File

@@ -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 애니메이션 위젯
///

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';
/// 종족 미리보기 위젯
///

View File

@@ -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';

View File

@@ -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<double> 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<double> 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<bool> 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<bool> 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),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
},
),
const SizedBox(height: 12),
_RetroVolumeSlider(
RetroVolumeSlider(
label: game_l10n.uiSfxVolume,
icon: Icons.volume_up,
value: _sfxVolume,
@@ -321,7 +322,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
Future<void> _handleCreateTestCharacter() async {
final confirmed = await showDialog<bool>(
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<SettingsScreen> {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 레트로 스타일 서브 위젯들
// ═══════════════════════════════════════════════════════════════════════════
/// 섹션 타이틀
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<double> 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<double> 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<bool> 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<bool> 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),
),
),
],
),
),
],
),
),
);
}
}