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: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/arena_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/arena_match.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/combat_event.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.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/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_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/features/arena/widgets/arena_result_panel.dart';
|
||||||
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.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/ascii_animation_card.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
// 임시 문자열 (추후 l10n으로 이동)
|
|
||||||
const _battleTitle = 'ARENA BATTLE';
|
|
||||||
const _hpLabel = 'HP';
|
|
||||||
|
|
||||||
/// 아레나 전투 화면
|
/// 아레나 전투 화면
|
||||||
///
|
///
|
||||||
/// ASCII 애니메이션 기반 턴제 전투 표시
|
/// ASCII 애니메이션 기반 턴제 전투 표시
|
||||||
@@ -438,7 +436,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
backgroundColor: RetroColors.backgroundOf(context),
|
backgroundColor: RetroColors.backgroundOf(context),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
_battleTitle,
|
L10n.of(context).arenaBattleTitle,
|
||||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
@@ -451,7 +449,18 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
// 턴 표시
|
// 턴 표시
|
||||||
_buildTurnIndicator(),
|
_buildTurnIndicator(),
|
||||||
// HP 바 (레트로 세그먼트 스타일)
|
// 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 바와 애니메이션 사이)
|
// 전투 이벤트 아이콘 (HP 바와 애니메이션 사이)
|
||||||
_buildCombatEventIcons(),
|
_buildCombatEventIcons(),
|
||||||
// ASCII 애니메이션 (전투 중 / 종료 분기)
|
// 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() {
|
Widget _buildBattleLog() {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.all(12),
|
margin: const EdgeInsets.all(12),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/model/hall_of_fame.dart';
|
||||||
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||||
import 'package:asciineverdie/src/features/arena/arena_setup_screen.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/retro_colors.dart';
|
||||||
import 'package:asciineverdie/src/shared/widgets/retro_panel.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: RetroColors.backgroundOf(context),
|
backgroundColor: RetroColors.backgroundOf(context),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
_arenaTitle,
|
l10n.arenaTitle,
|
||||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
@@ -101,6 +97,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
Widget _buildEmptyState() {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
return Center(
|
return Center(
|
||||||
child: RetroPanel(
|
child: RetroPanel(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -114,7 +111,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_arenaEmpty,
|
l10n.arenaEmptyTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -123,7 +120,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
_arenaEmptyHint,
|
l10n.arenaEmptyHint,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -143,7 +140,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: RetroGoldPanel(
|
child: RetroGoldPanel(
|
||||||
title: _arenaSubtitle,
|
title: L10n.of(context).arenaSelectFighter,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: rankedEntries.length,
|
itemCount: rankedEntries.length,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/arena_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/item_service.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/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/features/arena/widgets/arena_rank_card.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: RetroColors.backgroundOf(context),
|
backgroundColor: RetroColors.backgroundOf(context),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
_setupTitle,
|
l10n.arenaSetupTitle,
|
||||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
@@ -153,7 +150,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
_selectCharacter,
|
L10n.of(context).arenaSelectFighter,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -371,7 +368,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
_startBattleLabel,
|
L10n.of(context).arenaStartBattle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/engine/item_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_item.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/equipment_slot.dart';
|
||||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.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) {
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// 내 장비 타이틀
|
// 내 장비 타이틀
|
||||||
@@ -125,7 +120,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_myEquipmentTitle,
|
l10n.arenaMyEquipment,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -146,7 +141,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_enemyEquipmentTitle,
|
l10n.arenaEnemyEquipment,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -402,7 +397,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
// 잠금 표시 또는 점수 변화
|
// 잠금 표시 또는 점수 변화
|
||||||
if (isLocked)
|
if (isLocked)
|
||||||
Text(
|
Text(
|
||||||
_weaponLockedLabel,
|
L10n.of(context).arenaWeaponLocked,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -441,7 +436,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
children: [
|
children: [
|
||||||
if (isRecommended) ...[
|
if (isRecommended) ...[
|
||||||
Text(
|
Text(
|
||||||
_recommendedLabel,
|
L10n.of(context).arenaRecommended,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -471,21 +466,22 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
EquipmentItem? enemyItem,
|
EquipmentItem? enemyItem,
|
||||||
int scoreDiff,
|
int scoreDiff,
|
||||||
) {
|
) {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
final Color resultColor;
|
final Color resultColor;
|
||||||
final String resultText;
|
final String resultText;
|
||||||
final IconData resultIcon;
|
final IconData resultIcon;
|
||||||
|
|
||||||
if (scoreDiff > 0) {
|
if (scoreDiff > 0) {
|
||||||
resultColor = Colors.green;
|
resultColor = Colors.green;
|
||||||
resultText = 'You will GAIN +$scoreDiff';
|
resultText = l10n.arenaScoreGain(scoreDiff);
|
||||||
resultIcon = Icons.arrow_upward;
|
resultIcon = Icons.arrow_upward;
|
||||||
} else if (scoreDiff < 0) {
|
} else if (scoreDiff < 0) {
|
||||||
resultColor = Colors.red;
|
resultColor = Colors.red;
|
||||||
resultText = 'You will LOSE $scoreDiff';
|
resultText = l10n.arenaScoreLose(scoreDiff);
|
||||||
resultIcon = Icons.arrow_downward;
|
resultIcon = Icons.arrow_downward;
|
||||||
} else {
|
} else {
|
||||||
resultColor = RetroColors.textMutedOf(context);
|
resultColor = RetroColors.textMutedOf(context);
|
||||||
resultText = 'Even trade';
|
resultText = l10n.arenaEvenTrade;
|
||||||
resultIcon = Icons.swap_horiz;
|
resultIcon = Icons.swap_horiz;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +559,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
_selectedLabel,
|
L10n.of(context).arenaSelected,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 12,
|
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 'dart:async';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/ascii_canvas_widget.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/character_frames.dart';
|
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 아레나 idle 상태 캐릭터 미리보기 위젯
|
/// 아레나 idle 상태 캐릭터 미리보기 위젯
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/core/model/hall_of_fame.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@ class ArenaRankCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'SCORE',
|
L10n.of(context).arenaScore,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/engine/item_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_item.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/core/model/item_stats.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.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,
|
onPressed: onClose,
|
||||||
style: FilledButton.styleFrom(backgroundColor: resultColor),
|
style: FilledButton.styleFrom(backgroundColor: resultColor),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.buttonConfirm,
|
game_l10n.buttonConfirm,
|
||||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 13),
|
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -74,6 +70,7 @@ class ArenaResultDialog extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
|
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -84,7 +81,7 @@ class ArenaResultDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
isVictory ? _arenaVictory : _arenaDefeat,
|
isVictory ? l10n.arenaVictory : l10n.arenaDefeat,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -152,7 +149,7 @@ class ArenaResultDialog extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
isWinner ? 'WINNER' : 'LOSER',
|
isWinner ? L10n.of(context).arenaWinner : L10n.of(context).arenaLoser,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -196,7 +193,7 @@ class ArenaResultDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
_arenaExchange,
|
L10n.of(context).arenaEquipmentExchange,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -380,17 +377,17 @@ class ArenaResultDialog extends StatelessWidget {
|
|||||||
|
|
||||||
String _getSlotLabel(EquipmentSlot slot) {
|
String _getSlotLabel(EquipmentSlot slot) {
|
||||||
return switch (slot) {
|
return switch (slot) {
|
||||||
EquipmentSlot.weapon => l10n.slotWeapon,
|
EquipmentSlot.weapon => game_l10n.slotWeapon,
|
||||||
EquipmentSlot.shield => l10n.slotShield,
|
EquipmentSlot.shield => game_l10n.slotShield,
|
||||||
EquipmentSlot.helm => l10n.slotHelm,
|
EquipmentSlot.helm => game_l10n.slotHelm,
|
||||||
EquipmentSlot.hauberk => l10n.slotHauberk,
|
EquipmentSlot.hauberk => game_l10n.slotHauberk,
|
||||||
EquipmentSlot.brassairts => l10n.slotBrassairts,
|
EquipmentSlot.brassairts => game_l10n.slotBrassairts,
|
||||||
EquipmentSlot.vambraces => l10n.slotVambraces,
|
EquipmentSlot.vambraces => game_l10n.slotVambraces,
|
||||||
EquipmentSlot.gauntlets => l10n.slotGauntlets,
|
EquipmentSlot.gauntlets => game_l10n.slotGauntlets,
|
||||||
EquipmentSlot.gambeson => l10n.slotGambeson,
|
EquipmentSlot.gambeson => game_l10n.slotGambeson,
|
||||||
EquipmentSlot.cuisses => l10n.slotCuisses,
|
EquipmentSlot.cuisses => game_l10n.slotCuisses,
|
||||||
EquipmentSlot.greaves => l10n.slotGreaves,
|
EquipmentSlot.greaves => game_l10n.slotGreaves,
|
||||||
EquipmentSlot.sollerets => l10n.slotSollerets,
|
EquipmentSlot.sollerets => game_l10n.slotSollerets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path_provider/path_provider.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/engine/item_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_item.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/features/game/widgets/combat_log.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'${l10n.uiSaved}: $fileName',
|
'${game_l10n.uiSaved}: $fileName',
|
||||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
||||||
),
|
),
|
||||||
backgroundColor: RetroColors.mpOf(context),
|
backgroundColor: RetroColors.mpOf(context),
|
||||||
@@ -145,7 +140,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'${l10n.uiError}: $e',
|
'${game_l10n.uiError}: $e',
|
||||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
||||||
),
|
),
|
||||||
backgroundColor: RetroColors.hpOf(context),
|
backgroundColor: RetroColors.hpOf(context),
|
||||||
@@ -353,6 +348,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
|
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -363,7 +359,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
isVictory ? _victory : _defeat,
|
isVictory ? l10n.arenaVictory : l10n.arenaDefeat,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -381,67 +377,26 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBattleSummary(BuildContext context) {
|
Widget _buildBattleSummary(BuildContext context) {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
final winner = widget.result.isVictory
|
final winner = widget.result.isVictory
|
||||||
? widget.result.match.challenger.characterName
|
? widget.result.match.challenger.characterName
|
||||||
: widget.result.match.opponent.characterName;
|
: widget.result.match.opponent.characterName;
|
||||||
final loser = widget.result.isVictory
|
final loser = widget.result.isVictory
|
||||||
? widget.result.match.opponent.characterName
|
? widget.result.match.opponent.characterName
|
||||||
: widget.result.match.challenger.characterName;
|
: widget.result.match.challenger.characterName;
|
||||||
|
final summaryText = l10n.arenaDefeatedIn(winner, loser, widget.turnCount);
|
||||||
|
|
||||||
return Row(
|
return Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
children: [
|
child: Text(
|
||||||
// 승자
|
summaryText,
|
||||||
Text(
|
style: TextStyle(
|
||||||
winner,
|
fontFamily: 'PressStart2P',
|
||||||
style: TextStyle(
|
fontSize: 11,
|
||||||
fontFamily: 'PressStart2P',
|
color: RetroColors.textSecondaryOf(context),
|
||||||
fontSize: 11,
|
|
||||||
color: RetroColors.goldOf(context),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
textAlign: TextAlign.center,
|
||||||
' 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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +454,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
_exchange,
|
L10n.of(context).arenaEquipmentExchange,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -639,7 +594,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
|||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.buttonConfirm,
|
game_l10n.buttonConfirm,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -658,7 +613,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
|||||||
onPressed: _saveBattleLog,
|
onPressed: _saveBattleLog,
|
||||||
icon: const Icon(Icons.save_alt, size: 14),
|
icon: const Icon(Icons.save_alt, size: 14),
|
||||||
label: Text(
|
label: Text(
|
||||||
l10n.uiSaveBattleLog,
|
game_l10n.uiSaveBattleLog,
|
||||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
||||||
),
|
),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
@@ -681,17 +636,17 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
|||||||
|
|
||||||
String _getSlotLabel(EquipmentSlot slot) {
|
String _getSlotLabel(EquipmentSlot slot) {
|
||||||
return switch (slot) {
|
return switch (slot) {
|
||||||
EquipmentSlot.weapon => l10n.slotWeapon,
|
EquipmentSlot.weapon => game_l10n.slotWeapon,
|
||||||
EquipmentSlot.shield => l10n.slotShield,
|
EquipmentSlot.shield => game_l10n.slotShield,
|
||||||
EquipmentSlot.helm => l10n.slotHelm,
|
EquipmentSlot.helm => game_l10n.slotHelm,
|
||||||
EquipmentSlot.hauberk => l10n.slotHauberk,
|
EquipmentSlot.hauberk => game_l10n.slotHauberk,
|
||||||
EquipmentSlot.brassairts => l10n.slotBrassairts,
|
EquipmentSlot.brassairts => game_l10n.slotBrassairts,
|
||||||
EquipmentSlot.vambraces => l10n.slotVambraces,
|
EquipmentSlot.vambraces => game_l10n.slotVambraces,
|
||||||
EquipmentSlot.gauntlets => l10n.slotGauntlets,
|
EquipmentSlot.gauntlets => game_l10n.slotGauntlets,
|
||||||
EquipmentSlot.gambeson => l10n.slotGambeson,
|
EquipmentSlot.gambeson => game_l10n.slotGambeson,
|
||||||
EquipmentSlot.cuisses => l10n.slotCuisses,
|
EquipmentSlot.cuisses => game_l10n.slotCuisses,
|
||||||
EquipmentSlot.greaves => l10n.slotGreaves,
|
EquipmentSlot.greaves => game_l10n.slotGreaves,
|
||||||
EquipmentSlot.sollerets => l10n.slotSollerets,
|
EquipmentSlot.sollerets => game_l10n.slotSollerets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/data/race_data.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 애니메이션 위젯
|
/// 프론트 화면용 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:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
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/engine/iap_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
||||||
import 'package:asciineverdie/data/story_data.dart';
|
import 'package:asciineverdie/data/story_data.dart';
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/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/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/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/game/game_session_controller.dart';
|
||||||
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.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/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/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/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/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/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/widgets/return_rewards_dialog.dart';
|
||||||
import 'package:asciineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
|
import 'package:asciineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
|
||||||
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
|
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
|
||||||
@@ -796,9 +789,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 2, child: _buildCharacterPanel(state)),
|
Expanded(
|
||||||
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
|
flex: 2,
|
||||||
Expanded(flex: 2, child: _buildQuestPanel(state)),
|
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;
|
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:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/model/game_state.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/character_sheet_page.dart';
|
||||||
import 'package:asciineverdie/src/features/game/pages/combat_log_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/pages/story_page.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/carousel_nav_bar.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/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/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/retro_colors.dart';
|
||||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
|
||||||
|
|
||||||
/// 모바일 캐로셀 레이아웃
|
/// 모바일 캐로셀 레이아웃
|
||||||
///
|
///
|
||||||
@@ -169,408 +164,39 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 현재 언어명 가져오기
|
void _openOptionsMenu(BuildContext context) {
|
||||||
String _getCurrentLanguageName() {
|
showMobileOptionsMenu(
|
||||||
final locale = l10n.currentGameLocale;
|
context,
|
||||||
if (locale == 'ko') return l10n.languageKorean;
|
MobileOptionsConfig(
|
||||||
if (locale == 'ja') return l10n.languageJapanese;
|
isPaused: widget.isPaused,
|
||||||
return l10n.languageEnglish;
|
speedMultiplier: widget.speedMultiplier,
|
||||||
}
|
bgmVolume: widget.bgmVolume,
|
||||||
|
sfxVolume: widget.sfxVolume,
|
||||||
/// 언어 선택 다이얼로그 표시
|
cheatsEnabled: widget.cheatsEnabled,
|
||||||
void _showLanguageDialog(BuildContext context) {
|
isPaidUser: widget.isPaidUser,
|
||||||
showDialog<void>(
|
isSpeedBoostActive: widget.isSpeedBoostActive,
|
||||||
context: context,
|
adSpeedMultiplier: widget.adSpeedMultiplier,
|
||||||
builder: (context) => RetroSelectDialog(
|
notificationService: widget.notificationService,
|
||||||
title: l10n.menuLanguage.toUpperCase(),
|
onPauseToggle: widget.onPauseToggle,
|
||||||
children: [
|
onSpeedCycle: widget.onSpeedCycle,
|
||||||
_buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'),
|
onSave: widget.onSave,
|
||||||
_buildLanguageOption(context, 'ko', l10n.languageKorean, '🇰🇷'),
|
onExit: widget.onExit,
|
||||||
_buildLanguageOption(context, 'ja', l10n.languageJapanese, '🇯🇵'),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = widget.state;
|
final state = widget.state;
|
||||||
@@ -594,7 +220,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
// 옵션 버튼
|
// 옵션 버튼
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.settings, color: gold),
|
icon: Icon(Icons.settings, color: gold),
|
||||||
onPressed: () => _showOptionsMenu(context),
|
onPressed: () => _openOptionsMenu(context),
|
||||||
tooltip: l10n.menuOptions,
|
tooltip: l10n.menuOptions,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/game_state.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/stats_panel.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/data/game_text_l10n.dart' as l10n;
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/potion.dart';
|
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.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/game_text_l10n.dart' as l10n;
|
||||||
import 'package:asciineverdie/data/skill_data.dart';
|
import 'package:asciineverdie/data/skill_data.dart';
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.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/l10n/app_localizations.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.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 isCompleted = index < plotStageCount - 1;
|
||||||
final label = index == 0
|
final label = index == 0
|
||||||
? localizations.prologue
|
? localizations.prologue
|
||||||
: localizations.actNumber(_toRoman(index));
|
: localizations.actNumber(intToRoman(index));
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
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: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/shared/widgets/ascii_disintegrate_widget.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/animation/background_layer.dart';
|
import 'package:asciineverdie/src/shared/animation/background_layer.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/ascii_canvas_widget.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/canvas_battle_composer.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/canvas_battle_composer.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/canvas_special_composer.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/canvas_special_composer.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/canvas_town_composer.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/canvas_town_composer.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/canvas/canvas_walking_composer.dart';
|
import 'package:asciineverdie/src/shared/animation/canvas/canvas_walking_composer.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/character_frames.dart';
|
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/weapon_category.dart';
|
import 'package:asciineverdie/src/shared/animation/weapon_category.dart';
|
||||||
import 'package:asciineverdie/src/core/constants/ascii_colors.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/combat_event.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_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/item_stats.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||||
|
import 'package:asciineverdie/src/features/game/widgets/combat_event_mapping.dart';
|
||||||
|
|
||||||
/// 애니메이션 모드
|
/// 애니메이션 모드
|
||||||
enum AnimationMode {
|
enum AnimationMode {
|
||||||
@@ -284,198 +285,25 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
// 전투 모드가 아니면 무시
|
// 전투 모드가 아니면 무시
|
||||||
if (_animationMode != AnimationMode.battle) return;
|
if (_animationMode != AnimationMode.battle) return;
|
||||||
|
|
||||||
// 이벤트 타입에 따라 페이즈 및 효과 결정
|
final effects = mapCombatEventToEffects(event);
|
||||||
// (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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_battlePhase = targetPhase;
|
_battlePhase = effects.targetPhase;
|
||||||
_battleSubFrame = 0;
|
_battleSubFrame = 0;
|
||||||
_phaseFrameCount = 0;
|
_phaseFrameCount = 0;
|
||||||
_showCriticalEffect = isCritical;
|
_showCriticalEffect = effects.isCritical;
|
||||||
_showBlockEffect = isBlock;
|
_showBlockEffect = effects.isBlock;
|
||||||
_showParryEffect = isParry;
|
_showParryEffect = effects.isParry;
|
||||||
_showSkillEffect = isSkill;
|
_showSkillEffect = effects.isSkill;
|
||||||
_showEvadeEffect = isEvade;
|
_showEvadeEffect = effects.isEvade;
|
||||||
_showMissEffect = isMiss;
|
_showMissEffect = effects.isMiss;
|
||||||
_showDebuffEffect = isDebuff;
|
_showDebuffEffect = effects.isDebuff;
|
||||||
_showDotEffect = isDot;
|
_showDotEffect = effects.isDot;
|
||||||
|
|
||||||
// 페이즈 인덱스 동기화
|
// 페이즈 인덱스 동기화
|
||||||
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
|
_phaseIndex = _battlePhaseSequence.indexWhere(
|
||||||
|
(p) => p.$1 == effects.targetPhase,
|
||||||
|
);
|
||||||
if (_phaseIndex < 0) _phaseIndex = 0;
|
if (_phaseIndex < 0) _phaseIndex = 0;
|
||||||
|
|
||||||
// 공격 속도에 따른 동적 페이즈 프레임 수 계산 (Phase 6)
|
// 공격 속도에 따른 동적 페이즈 프레임 수 계산 (Phase 6)
|
||||||
@@ -488,12 +316,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 공격자 타입 결정 (Phase 7: 공격자별 위치 분리)
|
// 공격자 타입 결정 (Phase 7: 공격자별 위치 분리)
|
||||||
_currentAttacker = switch (event.type) {
|
_currentAttacker = getAttackerType(event.type);
|
||||||
CombatEventType.playerAttack ||
|
|
||||||
CombatEventType.playerSkill => AttackerType.player,
|
|
||||||
CombatEventType.monsterAttack => AttackerType.monster,
|
|
||||||
_ => AttackerType.none,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
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/combat_event.dart';
|
|
||||||
import 'package:asciineverdie/src/core/model/equipment_slot.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/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/item_stats.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';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
/// 사망 오버레이 위젯
|
/// 사망 오버레이 위젯
|
||||||
@@ -133,18 +134,22 @@ class DeathOverlay extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildRetroDivider(hpColor, hpDark),
|
_buildRetroDivider(hpColor, hpDark),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildCombatLog(context),
|
DeathCombatLog(events: deathInfo.lastCombatEvents),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 일반 부활 버튼 (HP 50%, 아이템 희생)
|
// 일반 부활 버튼 (HP 50%, 아이템 희생)
|
||||||
_buildResurrectButton(context),
|
DeathResurrectButton(onResurrect: onResurrect),
|
||||||
|
|
||||||
// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
|
// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||||
if (onAdRevive != null) ...[
|
if (onAdRevive != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildAdReviveButton(context),
|
DeathAdReviveButton(
|
||||||
|
onAdRevive: onAdRevive!,
|
||||||
|
deathInfo: deathInfo,
|
||||||
|
isPaidUser: isPaidUser,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -423,347 +428,6 @@ class DeathOverlay extends StatelessWidget {
|
|||||||
return gold.toString();
|
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) {
|
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:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||||
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/animation/monster_size.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_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/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_grade.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/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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _buildCompactHpBar()),
|
Expanded(
|
||||||
|
child: CompactHpBar(
|
||||||
|
current: _currentHp,
|
||||||
|
max: _currentHpMax,
|
||||||
|
flashAnimation: _hpFlashAnimation,
|
||||||
|
hpChange: _hpChange,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
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(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: switch ((shouldShowMonsterHp, combat)) {
|
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(),
|
_ => 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 토글 버튼 하나만 표시
|
/// - 5x/20x 토글 버튼 하나만 표시
|
||||||
|
|||||||
@@ -2,7 +2,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 l10n;
|
||||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
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_item.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_slot.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/game_state.dart';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
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';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
/// HP/MP 바 위젯 (레트로 RPG 스타일)
|
/// HP/MP 바 위젯 (레트로 RPG 스타일)
|
||||||
@@ -201,7 +202,17 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// 몬스터 HP 바 (전투 중일 때만)
|
// 몬스터 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:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
@@ -172,7 +173,7 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
// 타입 표시
|
// 타입 표시
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_getTypeLabel(notification.type),
|
_getTypeLabel(context, notification.type),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PressStart2P',
|
fontFamily: 'PressStart2P',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -280,18 +281,19 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 알림 타입 라벨
|
/// 알림 타입 라벨 (l10n)
|
||||||
String _getTypeLabel(NotificationType type) {
|
String _getTypeLabel(BuildContext context, NotificationType type) {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
NotificationType.levelUp => 'LEVEL UP',
|
NotificationType.levelUp => l10n.notifyLevelUpLabel,
|
||||||
NotificationType.questComplete => 'QUEST DONE',
|
NotificationType.questComplete => l10n.notifyQuestDoneLabel,
|
||||||
NotificationType.actComplete => 'ACT CLEAR',
|
NotificationType.actComplete => l10n.notifyActClearLabel,
|
||||||
NotificationType.newSpell => 'NEW SPELL',
|
NotificationType.newSpell => l10n.notifyNewSpellLabel,
|
||||||
NotificationType.newEquipment => 'NEW ITEM',
|
NotificationType.newEquipment => l10n.notifyNewItemLabel,
|
||||||
NotificationType.bossDefeat => 'BOSS SLAIN',
|
NotificationType.bossDefeat => l10n.notifyBossSlainLabel,
|
||||||
NotificationType.gameSaved => 'SAVED',
|
NotificationType.gameSaved => l10n.notifySavedLabel,
|
||||||
NotificationType.info => 'INFO',
|
NotificationType.info => l10n.notifyInfoLabel,
|
||||||
NotificationType.warning => 'WARNING',
|
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:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_statistics.dart';
|
import 'package:asciineverdie/src/core/model/game_statistics.dart';
|
||||||
import 'package:asciineverdie/src/shared/widgets/retro_dialog.dart';
|
import 'package:asciineverdie/src/shared/widgets/retro_dialog.dart';
|
||||||
|
|
||||||
@@ -52,34 +53,19 @@ class _StatisticsDialogState extends State<StatisticsDialog>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
final l10n = L10n.of(context);
|
||||||
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
|
||||||
|
|
||||||
final title = isKorean
|
|
||||||
? '통계'
|
|
||||||
: isJapanese
|
|
||||||
? '統計'
|
|
||||||
: 'Statistics';
|
|
||||||
|
|
||||||
final tabs = isKorean
|
|
||||||
? ['세션', '누적']
|
|
||||||
: isJapanese
|
|
||||||
? ['セッション', '累積']
|
|
||||||
: ['Session', 'Total'];
|
|
||||||
|
|
||||||
return RetroDialog(
|
return RetroDialog(
|
||||||
title: title,
|
title: l10n.statsStatistics,
|
||||||
titleIcon: '📊',
|
titleIcon: '📊',
|
||||||
maxWidth: 420,
|
maxWidth: 420,
|
||||||
maxHeight: 520,
|
maxHeight: 520,
|
||||||
// accentColor: 테마에서 자동 결정 (goldOf)
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 탭 바
|
// 탭 바
|
||||||
RetroTabBar(
|
RetroTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: tabs,
|
tabs: [l10n.statsSession, l10n.statsAccumulated],
|
||||||
// accentColor: 테마에서 자동 결정 (goldOf)
|
|
||||||
),
|
),
|
||||||
// 탭 내용
|
// 탭 내용
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -105,198 +91,109 @@ class _SessionStatisticsView extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
final l10n = L10n.of(context);
|
||||||
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsCombat,
|
||||||
? '전투'
|
|
||||||
: isJapanese
|
|
||||||
? '戦闘'
|
|
||||||
: 'Combat',
|
|
||||||
icon: '⚔',
|
icon: '⚔',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsPlayTime,
|
||||||
? '플레이 시간'
|
|
||||||
: isJapanese
|
|
||||||
? 'プレイ時間'
|
|
||||||
: 'Play Time',
|
|
||||||
value: stats.formattedPlayTime,
|
value: stats.formattedPlayTime,
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsMonstersKilled,
|
||||||
? '처치한 몬스터'
|
|
||||||
: isJapanese
|
|
||||||
? '倒したモンスター'
|
|
||||||
: 'Monsters Killed',
|
|
||||||
value: _formatNumber(stats.monstersKilled),
|
value: _formatNumber(stats.monstersKilled),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsBossesDefeated,
|
||||||
? '보스 처치'
|
|
||||||
: isJapanese
|
|
||||||
? 'ボス討伐'
|
|
||||||
: 'Bosses Defeated',
|
|
||||||
value: _formatNumber(stats.bossesDefeated),
|
value: _formatNumber(stats.bossesDefeated),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsDeaths,
|
||||||
? '사망 횟수'
|
|
||||||
: isJapanese
|
|
||||||
? '死亡回数'
|
|
||||||
: 'Deaths',
|
|
||||||
value: _formatNumber(stats.deathCount),
|
value: _formatNumber(stats.deathCount),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsDamage,
|
||||||
? '데미지'
|
|
||||||
: isJapanese
|
|
||||||
? 'ダメージ'
|
|
||||||
: 'Damage',
|
|
||||||
icon: '⚡',
|
icon: '⚡',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsDamageDealt,
|
||||||
? '입힌 데미지'
|
|
||||||
: isJapanese
|
|
||||||
? '与えたダメージ'
|
|
||||||
: 'Damage Dealt',
|
|
||||||
value: _formatNumber(stats.totalDamageDealt),
|
value: _formatNumber(stats.totalDamageDealt),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsDamageTaken,
|
||||||
? '받은 데미지'
|
|
||||||
: isJapanese
|
|
||||||
? '受けたダメージ'
|
|
||||||
: 'Damage Taken',
|
|
||||||
value: _formatNumber(stats.totalDamageTaken),
|
value: _formatNumber(stats.totalDamageTaken),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsAverageDps,
|
||||||
? '평균 DPS'
|
|
||||||
: isJapanese
|
|
||||||
? '平均DPS'
|
|
||||||
: 'Average DPS',
|
|
||||||
value: stats.averageDps.toStringAsFixed(1),
|
value: stats.averageDps.toStringAsFixed(1),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsSkills,
|
||||||
? '스킬'
|
|
||||||
: isJapanese
|
|
||||||
? 'スキル'
|
|
||||||
: 'Skills',
|
|
||||||
icon: '✧',
|
icon: '✧',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsSkillsUsed,
|
||||||
? '스킬 사용'
|
|
||||||
: isJapanese
|
|
||||||
? 'スキル使用'
|
|
||||||
: 'Skills Used',
|
|
||||||
value: _formatNumber(stats.skillsUsed),
|
value: _formatNumber(stats.skillsUsed),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsCriticalHits,
|
||||||
? '크리티컬 히트'
|
|
||||||
: isJapanese
|
|
||||||
? 'クリティカルヒット'
|
|
||||||
: 'Critical Hits',
|
|
||||||
value: _formatNumber(stats.criticalHits),
|
value: _formatNumber(stats.criticalHits),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsMaxCriticalStreak,
|
||||||
? '최대 연속 크리티컬'
|
|
||||||
: isJapanese
|
|
||||||
? '最大連続クリティカル'
|
|
||||||
: 'Max Critical Streak',
|
|
||||||
value: _formatNumber(stats.maxCriticalStreak),
|
value: _formatNumber(stats.maxCriticalStreak),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsCriticalRate,
|
||||||
? '크리티컬 비율'
|
|
||||||
: isJapanese
|
|
||||||
? 'クリティカル率'
|
|
||||||
: 'Critical Rate',
|
|
||||||
value: '${(stats.criticalRate * 100).toStringAsFixed(1)}%',
|
value: '${(stats.criticalRate * 100).toStringAsFixed(1)}%',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsEconomy,
|
||||||
? '경제'
|
|
||||||
: isJapanese
|
|
||||||
? '経済'
|
|
||||||
: 'Economy',
|
|
||||||
icon: '💰',
|
icon: '💰',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsGoldEarned,
|
||||||
? '획득 골드'
|
|
||||||
: isJapanese
|
|
||||||
? '獲得ゴールド'
|
|
||||||
: 'Gold Earned',
|
|
||||||
value: _formatNumber(stats.goldEarned),
|
value: _formatNumber(stats.goldEarned),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsGoldSpent,
|
||||||
? '소비 골드'
|
|
||||||
: isJapanese
|
|
||||||
? '消費ゴールド'
|
|
||||||
: 'Gold Spent',
|
|
||||||
value: _formatNumber(stats.goldSpent),
|
value: _formatNumber(stats.goldSpent),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsItemsSold,
|
||||||
? '판매 아이템'
|
|
||||||
: isJapanese
|
|
||||||
? '売却アイテム'
|
|
||||||
: 'Items Sold',
|
|
||||||
value: _formatNumber(stats.itemsSold),
|
value: _formatNumber(stats.itemsSold),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsPotionsUsed,
|
||||||
? '물약 사용'
|
|
||||||
: isJapanese
|
|
||||||
? 'ポーション使用'
|
|
||||||
: 'Potions Used',
|
|
||||||
value: _formatNumber(stats.potionsUsed),
|
value: _formatNumber(stats.potionsUsed),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsProgress,
|
||||||
? '진행'
|
|
||||||
: isJapanese
|
|
||||||
? '進行'
|
|
||||||
: 'Progress',
|
|
||||||
icon: '↑',
|
icon: '↑',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsLevelUps,
|
||||||
? '레벨업'
|
|
||||||
: isJapanese
|
|
||||||
? 'レベルアップ'
|
|
||||||
: 'Level Ups',
|
|
||||||
value: _formatNumber(stats.levelUps),
|
value: _formatNumber(stats.levelUps),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsQuestsCompleted,
|
||||||
? '완료한 퀘스트'
|
|
||||||
: isJapanese
|
|
||||||
? '完了したクエスト'
|
|
||||||
: 'Quests Completed',
|
|
||||||
value: _formatNumber(stats.questsCompleted),
|
value: _formatNumber(stats.questsCompleted),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -314,44 +211,27 @@ class _CumulativeStatisticsView extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
final l10n = L10n.of(context);
|
||||||
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsRecords,
|
||||||
? '기록'
|
|
||||||
: isJapanese
|
|
||||||
? '記録'
|
|
||||||
: 'Records',
|
|
||||||
icon: '🏆',
|
icon: '🏆',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsHighestLevel,
|
||||||
? '최고 레벨'
|
|
||||||
: isJapanese
|
|
||||||
? '最高レベル'
|
|
||||||
: 'Highest Level',
|
|
||||||
value: _formatNumber(stats.highestLevel),
|
value: _formatNumber(stats.highestLevel),
|
||||||
highlight: true,
|
highlight: true,
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsHighestGoldHeld,
|
||||||
? '최대 보유 골드'
|
|
||||||
: isJapanese
|
|
||||||
? '最大所持ゴールド'
|
|
||||||
: 'Highest Gold Held',
|
|
||||||
value: _formatNumber(stats.highestGoldHeld),
|
value: _formatNumber(stats.highestGoldHeld),
|
||||||
highlight: true,
|
highlight: true,
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsBestCriticalStreak,
|
||||||
? '최고 연속 크리티컬'
|
|
||||||
: isJapanese
|
|
||||||
? '最高連続クリティカル'
|
|
||||||
: 'Best Critical Streak',
|
|
||||||
value: _formatNumber(stats.bestCriticalStreak),
|
value: _formatNumber(stats.bestCriticalStreak),
|
||||||
highlight: true,
|
highlight: true,
|
||||||
),
|
),
|
||||||
@@ -359,191 +239,103 @@ class _CumulativeStatisticsView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsTotalPlay,
|
||||||
? '총 플레이'
|
|
||||||
: isJapanese
|
|
||||||
? '総プレイ'
|
|
||||||
: 'Total Play',
|
|
||||||
icon: '⏱',
|
icon: '⏱',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsTotalPlayTime,
|
||||||
? '총 플레이 시간'
|
|
||||||
: isJapanese
|
|
||||||
? '総プレイ時間'
|
|
||||||
: 'Total Play Time',
|
|
||||||
value: stats.formattedTotalPlayTime,
|
value: stats.formattedTotalPlayTime,
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsGamesStarted,
|
||||||
? '시작한 게임'
|
|
||||||
: isJapanese
|
|
||||||
? '開始したゲーム'
|
|
||||||
: 'Games Started',
|
|
||||||
value: _formatNumber(stats.gamesStarted),
|
value: _formatNumber(stats.gamesStarted),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsGamesCompleted,
|
||||||
? '클리어한 게임'
|
|
||||||
: isJapanese
|
|
||||||
? 'クリアしたゲーム'
|
|
||||||
: 'Games Completed',
|
|
||||||
value: _formatNumber(stats.gamesCompleted),
|
value: _formatNumber(stats.gamesCompleted),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsCompletionRate,
|
||||||
? '클리어율'
|
|
||||||
: isJapanese
|
|
||||||
? 'クリア率'
|
|
||||||
: 'Completion Rate',
|
|
||||||
value: '${(stats.completionRate * 100).toStringAsFixed(1)}%',
|
value: '${(stats.completionRate * 100).toStringAsFixed(1)}%',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsTotalCombat,
|
||||||
? '총 전투'
|
|
||||||
: isJapanese
|
|
||||||
? '総戦闘'
|
|
||||||
: 'Total Combat',
|
|
||||||
icon: '⚔',
|
icon: '⚔',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsMonstersKilled,
|
||||||
? '처치한 몬스터'
|
|
||||||
: isJapanese
|
|
||||||
? '倒したモンスター'
|
|
||||||
: 'Monsters Killed',
|
|
||||||
value: _formatNumber(stats.totalMonstersKilled),
|
value: _formatNumber(stats.totalMonstersKilled),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsBossesDefeated,
|
||||||
? '보스 처치'
|
|
||||||
: isJapanese
|
|
||||||
? 'ボス討伐'
|
|
||||||
: 'Bosses Defeated',
|
|
||||||
value: _formatNumber(stats.totalBossesDefeated),
|
value: _formatNumber(stats.totalBossesDefeated),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsTotalDeaths,
|
||||||
? '총 사망'
|
|
||||||
: isJapanese
|
|
||||||
? '総死亡'
|
|
||||||
: 'Total Deaths',
|
|
||||||
value: _formatNumber(stats.totalDeaths),
|
value: _formatNumber(stats.totalDeaths),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsTotalLevelUps,
|
||||||
? '총 레벨업'
|
|
||||||
: isJapanese
|
|
||||||
? '総レベルアップ'
|
|
||||||
: 'Total Level Ups',
|
|
||||||
value: _formatNumber(stats.totalLevelUps),
|
value: _formatNumber(stats.totalLevelUps),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsTotalDamage,
|
||||||
? '총 데미지'
|
|
||||||
: isJapanese
|
|
||||||
? '総ダメージ'
|
|
||||||
: 'Total Damage',
|
|
||||||
icon: '⚡',
|
icon: '⚡',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsDamageDealt,
|
||||||
? '입힌 데미지'
|
|
||||||
: isJapanese
|
|
||||||
? '与えたダメージ'
|
|
||||||
: 'Damage Dealt',
|
|
||||||
value: _formatNumber(stats.totalDamageDealt),
|
value: _formatNumber(stats.totalDamageDealt),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsDamageTaken,
|
||||||
? '받은 데미지'
|
|
||||||
: isJapanese
|
|
||||||
? '受けたダメージ'
|
|
||||||
: 'Damage Taken',
|
|
||||||
value: _formatNumber(stats.totalDamageTaken),
|
value: _formatNumber(stats.totalDamageTaken),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsTotalSkills,
|
||||||
? '총 스킬'
|
|
||||||
: isJapanese
|
|
||||||
? '総スキル'
|
|
||||||
: 'Total Skills',
|
|
||||||
icon: '✧',
|
icon: '✧',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsSkillsUsed,
|
||||||
? '스킬 사용'
|
|
||||||
: isJapanese
|
|
||||||
? 'スキル使用'
|
|
||||||
: 'Skills Used',
|
|
||||||
value: _formatNumber(stats.totalSkillsUsed),
|
value: _formatNumber(stats.totalSkillsUsed),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsCriticalHits,
|
||||||
? '크리티컬 히트'
|
|
||||||
: isJapanese
|
|
||||||
? 'クリティカルヒット'
|
|
||||||
: 'Critical Hits',
|
|
||||||
value: _formatNumber(stats.totalCriticalHits),
|
value: _formatNumber(stats.totalCriticalHits),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_StatSection(
|
_StatSection(
|
||||||
title: isKorean
|
title: l10n.statsTotalEconomy,
|
||||||
? '총 경제'
|
|
||||||
: isJapanese
|
|
||||||
? '総経済'
|
|
||||||
: 'Total Economy',
|
|
||||||
icon: '💰',
|
icon: '💰',
|
||||||
items: [
|
items: [
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsGoldEarned,
|
||||||
? '획득 골드'
|
|
||||||
: isJapanese
|
|
||||||
? '獲得ゴールド'
|
|
||||||
: 'Gold Earned',
|
|
||||||
value: _formatNumber(stats.totalGoldEarned),
|
value: _formatNumber(stats.totalGoldEarned),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsGoldSpent,
|
||||||
? '소비 골드'
|
|
||||||
: isJapanese
|
|
||||||
? '消費ゴールド'
|
|
||||||
: 'Gold Spent',
|
|
||||||
value: _formatNumber(stats.totalGoldSpent),
|
value: _formatNumber(stats.totalGoldSpent),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsItemsSold,
|
||||||
? '판매 아이템'
|
|
||||||
: isJapanese
|
|
||||||
? '売却アイテム'
|
|
||||||
: 'Items Sold',
|
|
||||||
value: _formatNumber(stats.totalItemsSold),
|
value: _formatNumber(stats.totalItemsSold),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsPotionsUsed,
|
||||||
? '물약 사용'
|
|
||||||
: isJapanese
|
|
||||||
? 'ポーション使用'
|
|
||||||
: 'Potions Used',
|
|
||||||
value: _formatNumber(stats.totalPotionsUsed),
|
value: _formatNumber(stats.totalPotionsUsed),
|
||||||
),
|
),
|
||||||
_StatItem(
|
_StatItem(
|
||||||
label: isKorean
|
label: l10n.statsQuestsCompleted,
|
||||||
? '완료 퀘스트'
|
|
||||||
: isJapanese
|
|
||||||
? '完了クエスト'
|
|
||||||
: 'Quests Completed',
|
|
||||||
value: _formatNumber(stats.totalQuestsCompleted),
|
value: _formatNumber(stats.totalQuestsCompleted),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -593,7 +385,6 @@ class _StatItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// highlightColor: 테마에서 자동 결정 (goldOf)
|
|
||||||
return RetroStatRow(label: label, value: value, highlight: highlight);
|
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/data/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/animation/monster_size.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_event.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/game_state.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
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/core/model/hall_of_fame.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.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/core/model/hall_of_fame.dart';
|
||||||
import 'package:asciineverdie/src/features/hall_of_fame/hero_detail_dialog.dart';
|
import 'package:asciineverdie/src/features/hall_of_fame/hero_detail_dialog.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
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/equipment_slot.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.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/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/data/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/class_traits.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/core/model/race_traits.dart' show StatType;
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/animation/character_frames.dart';
|
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||||
import 'package:asciineverdie/src/core/animation/race_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/data/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:asciineverdie/l10n/app_localizations.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/race_traits.dart';
|
import 'package:asciineverdie/src/core/model/race_traits.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.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/core/storage/settings_repository.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
||||||
|
import 'package:asciineverdie/src/features/settings/retro_settings_widgets.dart';
|
||||||
|
|
||||||
/// 통합 설정 화면 (레트로 스타일)
|
/// 통합 설정 화면 (레트로 스타일)
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
@@ -133,20 +134,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
// 언어 설정
|
// 언어 설정
|
||||||
_RetroSectionTitle(title: game_l10n.uiLanguage),
|
RetroSectionTitle(title: game_l10n.uiLanguage),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildLanguageSelector(context),
|
_buildLanguageSelector(context),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 사운드 설정
|
// 사운드 설정
|
||||||
_RetroSectionTitle(title: game_l10n.uiSound),
|
RetroSectionTitle(title: game_l10n.uiSound),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildSoundSettings(context),
|
_buildSoundSettings(context),
|
||||||
|
|
||||||
// 디버그 섹션 (디버그 모드에서만 표시)
|
// 디버그 섹션 (디버그 모드에서만 표시)
|
||||||
if (kDebugMode) ...[
|
if (kDebugMode) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_RetroSectionTitle(title: L10n.of(context).debugTitle),
|
RetroSectionTitle(title: L10n.of(context).debugTitle),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildDebugSection(context),
|
_buildDebugSection(context),
|
||||||
],
|
],
|
||||||
@@ -223,7 +224,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final isSelected = currentLocale == lang.$1;
|
final isSelected = currentLocale == lang.$1;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
child: _RetroSelectableItem(
|
child: RetroSelectableItem(
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
game_l10n.setGameLocale(lang.$1);
|
game_l10n.setGameLocale(lang.$1);
|
||||||
@@ -266,7 +267,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_RetroVolumeSlider(
|
RetroVolumeSlider(
|
||||||
label: game_l10n.uiBgmVolume,
|
label: game_l10n.uiBgmVolume,
|
||||||
icon: Icons.music_note,
|
icon: Icons.music_note,
|
||||||
value: _bgmVolume,
|
value: _bgmVolume,
|
||||||
@@ -277,7 +278,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_RetroVolumeSlider(
|
RetroVolumeSlider(
|
||||||
label: game_l10n.uiSfxVolume,
|
label: game_l10n.uiSfxVolume,
|
||||||
icon: Icons.volume_up,
|
icon: Icons.volume_up,
|
||||||
value: _sfxVolume,
|
value: _sfxVolume,
|
||||||
@@ -321,7 +322,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// IAP 시뮬레이션 토글
|
// IAP 시뮬레이션 토글
|
||||||
_RetroDebugToggle(
|
RetroDebugToggle(
|
||||||
icon: Icons.shopping_cart,
|
icon: Icons.shopping_cart,
|
||||||
label: L10n.of(context).debugIapPurchased,
|
label: L10n.of(context).debugIapPurchased,
|
||||||
description: L10n.of(context).debugIapPurchasedDesc,
|
description: L10n.of(context).debugIapPurchasedDesc,
|
||||||
@@ -418,7 +419,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final isSelected = _debugOfflineHours == hours;
|
final isSelected = _debugOfflineHours == hours;
|
||||||
final label = hours == 0 ? 'OFF' : '${hours}H';
|
final label = hours == 0 ? 'OFF' : '${hours}H';
|
||||||
|
|
||||||
return _RetroChip(
|
return RetroChip(
|
||||||
label: label,
|
label: label,
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@@ -435,7 +436,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Future<void> _handleCreateTestCharacter() async {
|
Future<void> _handleCreateTestCharacter() async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => _RetroConfirmDialog(
|
builder: (context) => RetroConfirmDialog(
|
||||||
title: L10n.of(context).debugCreateTestCharacterTitle,
|
title: L10n.of(context).debugCreateTestCharacterTitle,
|
||||||
message: L10n.of(context).debugCreateTestCharacterMessage,
|
message: L10n.of(context).debugCreateTestCharacterMessage,
|
||||||
confirmText: L10n.of(context).createButton,
|
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