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