feat: 초기 커밋
- Progress Quest 6.4 Flutter 포팅 프로젝트 - 게임 루프, 상태 관리, UI 구현 - 캐릭터 생성, 인벤토리, 장비, 주문 시스템 - 시장/판매/구매 메커니즘
This commit is contained in:
654
lib/src/features/game/game_play_screen.dart
Normal file
654
lib/src/features/game/game_play_screen.dart
Normal file
@@ -0,0 +1,654 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
|
||||
|
||||
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
||||
class GamePlayScreen extends StatefulWidget {
|
||||
const GamePlayScreen({super.key, required this.controller});
|
||||
|
||||
final GameSessionController controller;
|
||||
|
||||
@override
|
||||
State<GamePlayScreen> createState() => _GamePlayScreenState();
|
||||
}
|
||||
|
||||
class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
|
||||
// 앱이 백그라운드로 가거나 비활성화될 때 자동 저장
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_saveGameState();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveGameState() async {
|
||||
final currentState = widget.controller.state;
|
||||
if (currentState == null || !widget.controller.isRunning) return;
|
||||
|
||||
await widget.controller.saveManager.saveState(currentState);
|
||||
}
|
||||
|
||||
/// 뒤로가기 시 저장 확인 다이얼로그
|
||||
Future<bool> _onPopInvoked() async {
|
||||
final shouldPop = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Exit Game'),
|
||||
content: const Text('Save your progress before leaving?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Exit without saving'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await _saveGameState();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
},
|
||||
child: const Text('Save and Exit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return shouldPop ?? false;
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = widget.controller.state;
|
||||
if (state == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _onPopInvoked();
|
||||
if (shouldPop && context.mounted) {
|
||||
await widget.controller.pause(saveOnStop: false);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Progress Quest - ${state.traits.name}'),
|
||||
actions: [
|
||||
// 치트 버튼 (디버그용)
|
||||
if (widget.controller.cheatsEnabled) ...[
|
||||
IconButton(
|
||||
icon: const Text('L+1'),
|
||||
tooltip: 'Level Up',
|
||||
onPressed: () => widget.controller.loop?.cheatCompleteTask(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Text('Q!'),
|
||||
tooltip: 'Complete Quest',
|
||||
onPressed: () => widget.controller.loop?.cheatCompleteQuest(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Text('P!'),
|
||||
tooltip: 'Complete Plot',
|
||||
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 메인 3패널 영역
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 좌측 패널: Character Sheet
|
||||
Expanded(flex: 2, child: _buildCharacterPanel(state)),
|
||||
|
||||
// 중앙 패널: Equipment/Inventory
|
||||
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
|
||||
|
||||
// 우측 패널: Plot/Quest
|
||||
Expanded(flex: 2, child: _buildQuestPanel(state)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 하단: Task Progress
|
||||
_buildBottomPanel(state),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells)
|
||||
Widget _buildCharacterPanel(GameState state) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPanelHeader('Character Sheet'),
|
||||
|
||||
// Traits 목록
|
||||
_buildSectionHeader('Traits'),
|
||||
_buildTraitsList(state),
|
||||
|
||||
// Stats 목록
|
||||
_buildSectionHeader('Stats'),
|
||||
Expanded(flex: 2, child: _buildStatsList(state)),
|
||||
|
||||
// Experience 바
|
||||
_buildSectionHeader('Experience'),
|
||||
_buildProgressBar(
|
||||
state.progress.exp.position,
|
||||
state.progress.exp.max,
|
||||
Colors.blue,
|
||||
tooltip:
|
||||
'${state.progress.exp.max - state.progress.exp.position} '
|
||||
'XP needed for next level',
|
||||
),
|
||||
|
||||
// Spell Book
|
||||
_buildSectionHeader('Spell Book'),
|
||||
Expanded(flex: 2, child: _buildSpellsList(state)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 중앙 패널: Equipment/Inventory
|
||||
Widget _buildEquipmentPanel(GameState state) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPanelHeader('Equipment'),
|
||||
|
||||
// Equipment 목록
|
||||
Expanded(flex: 2, child: _buildEquipmentList(state)),
|
||||
|
||||
// Inventory
|
||||
_buildPanelHeader('Inventory'),
|
||||
Expanded(flex: 3, child: _buildInventoryList(state)),
|
||||
|
||||
// Encumbrance 바
|
||||
_buildSectionHeader('Encumbrance'),
|
||||
_buildProgressBar(
|
||||
state.progress.encumbrance.position,
|
||||
state.progress.encumbrance.max,
|
||||
Colors.orange,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 우측 패널: Plot/Quest
|
||||
Widget _buildQuestPanel(GameState state) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPanelHeader('Plot Development'),
|
||||
|
||||
// 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('Quests'),
|
||||
|
||||
// Quest 목록
|
||||
Expanded(child: _buildQuestList(state)),
|
||||
|
||||
// Quest 바
|
||||
_buildProgressBar(
|
||||
state.progress.quest.position,
|
||||
state.progress.quest.max,
|
||||
Colors.green,
|
||||
tooltip: state.progress.quest.max > 0
|
||||
? '${(100 * state.progress.quest.position ~/ state.progress.quest.max)}% complete'
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 하단 패널: Task Progress + Status
|
||||
Widget _buildBottomPanel(GameState state) {
|
||||
final speed = widget.controller.loop?.speedMultiplier ?? 1;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 상태 메시지 + 배속 버튼
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.progress.currentTask.caption.isNotEmpty
|
||||
? state.progress.currentTask.caption
|
||||
: 'Welcome to Progress Quest!',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// 배속 버튼
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
widget.controller.loop?.cycleSpeed();
|
||||
setState(() {});
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: Text(
|
||||
'${speed}x',
|
||||
style: TextStyle(
|
||||
fontWeight: speed > 1 ? FontWeight.bold : FontWeight.normal,
|
||||
color: speed > 1
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Task Progress 바
|
||||
_buildProgressBar(
|
||||
state.progress.task.position,
|
||||
state.progress.task.max,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPanelHeader(String title) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(title, style: Theme.of(context).textTheme.labelSmall),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressBar(
|
||||
int position,
|
||||
int max,
|
||||
Color color, {
|
||||
String? tooltip,
|
||||
}) {
|
||||
final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0;
|
||||
final bar = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: color.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 12,
|
||||
),
|
||||
);
|
||||
|
||||
if (tooltip != null && tooltip.isNotEmpty) {
|
||||
return Tooltip(message: tooltip, child: bar);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
|
||||
Widget _buildTraitsList(GameState state) {
|
||||
final traits = [
|
||||
('Name', state.traits.name),
|
||||
('Race', state.traits.race),
|
||||
('Class', state.traits.klass),
|
||||
('Level', '${state.traits.level}'),
|
||||
];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
children: traits.map((t) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(t.$1, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.$2,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsList(GameState state) {
|
||||
final stats = [
|
||||
('STR', state.stats.str),
|
||||
('CON', state.stats.con),
|
||||
('DEX', state.stats.dex),
|
||||
('INT', state.stats.intelligence),
|
||||
('WIS', state.stats.wis),
|
||||
('CHA', state.stats.cha),
|
||||
('HP Max', state.stats.hpMax),
|
||||
('MP Max', state.stats.mpMax),
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: stats.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final stat = stats[index];
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(stat.$1, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
Text(
|
||||
'${stat.$2}',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpellsList(GameState state) {
|
||||
if (state.spellBook.spells.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No spells yet', style: TextStyle(fontSize: 11)),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.spellBook.spells.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final spell = state.spellBook.spells[index];
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
spell.name,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
spell.rank,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEquipmentList(GameState state) {
|
||||
// 원본에는 11개 슬롯이 있지만, 현재 모델은 3개만 구현
|
||||
final equipment = [
|
||||
('Weapon', state.equipment.weapon),
|
||||
('Shield', state.equipment.shield),
|
||||
('Armor', state.equipment.armor),
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: equipment.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final equip = equipment[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(equip.$1, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
equip.$2.isNotEmpty ? equip.$2 : '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInventoryList(GameState state) {
|
||||
if (state.inventory.items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Gold: ${state.inventory.gold}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text('Gold', style: TextStyle(fontSize: 11)),
|
||||
),
|
||||
Text(
|
||||
'${state.inventory.gold}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final item = state.inventory.items[index - 1];
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.name,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.count}',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlotList(GameState state) {
|
||||
// 플롯 단계를 표시 (Act I, Act II, ...)
|
||||
final plotCount = state.progress.plotStageCount;
|
||||
if (plotCount == 0) {
|
||||
return const Center(
|
||||
child: Text('Prologue', style: TextStyle(fontSize: 11)),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: plotCount,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final isCompleted = index < plotCount - 1;
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCompleted ? Icons.check_box : Icons.check_box_outline_blank,
|
||||
size: 14,
|
||||
color: isCompleted ? Colors.green : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
index == 0 ? 'Prologue' : 'Act ${_toRoman(index)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
decoration: isCompleted
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuestList(GameState state) {
|
||||
final questCount = state.progress.questCount;
|
||||
if (questCount == 0) {
|
||||
return const Center(
|
||||
child: Text('No active quests', style: TextStyle(fontSize: 11)),
|
||||
);
|
||||
}
|
||||
|
||||
// 현재 퀘스트 캡션이 있으면 표시
|
||||
final currentTask = state.progress.currentTask;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.arrow_right, size: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentTask.caption.isNotEmpty
|
||||
? currentTask.caption
|
||||
: 'Quest #$questCount',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user