feat: 초기 커밋
- Progress Quest 6.4 Flutter 포팅 프로젝트 - 게임 루프, 상태 관리, UI 구현 - 캐릭터 생성, 인벤토리, 장비, 주문 시스템 - 시장/판매/구매 메커니즘
This commit is contained in:
314
lib/src/features/front/front_screen.dart
Normal file
314
lib/src/features/front/front_screen.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FrontScreen extends StatelessWidget {
|
||||
const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave});
|
||||
|
||||
/// "New character" 버튼 클릭 시 호출
|
||||
final void Function(BuildContext context)? onNewCharacter;
|
||||
|
||||
/// "Load save" 버튼 클릭 시 호출
|
||||
final Future<void> Function(BuildContext context)? onLoadSave;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [colorScheme.surfaceContainerHighest, colorScheme.surface],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 960),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_HeroHeader(theme: theme, colorScheme: colorScheme),
|
||||
const SizedBox(height: 24),
|
||||
_ActionRow(
|
||||
onNewCharacter: onNewCharacter != null
|
||||
? () => onNewCharacter!(context)
|
||||
: () => _showPlaceholder(context),
|
||||
onLoadSave: onLoadSave != null
|
||||
? () => onLoadSave!(context)
|
||||
: () => _showPlaceholder(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _StatusCards(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showPlaceholder(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Core gameplay loop is coming next. See doc/progress-quest-flutter-plan.md for milestones.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _HeroHeader extends StatelessWidget {
|
||||
const _HeroHeader({required this.theme, required this.colorScheme});
|
||||
|
||||
final ThemeData theme;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
colorScheme.primary.withValues(alpha: 0.9),
|
||||
colorScheme.primaryContainer,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.18),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, color: colorScheme.onPrimary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ascii Never Die',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Offline Progress Quest (PQ 6.4) rebuilt with Flutter.',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onPrimary.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
_Tag(icon: Icons.cloud_off_outlined, label: 'No network'),
|
||||
_Tag(icon: Icons.timer_outlined, label: 'Idle RPG loop'),
|
||||
_Tag(icon: Icons.storage_rounded, label: 'Local saves'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatelessWidget {
|
||||
const _ActionRow({required this.onNewCharacter, required this.onLoadSave});
|
||||
|
||||
final VoidCallback onNewCharacter;
|
||||
final VoidCallback onLoadSave;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onNewCharacter,
|
||||
icon: const Icon(Icons.casino_outlined),
|
||||
label: const Text('New character'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
textStyle: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onLoadSave,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: const Text('Load save'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
textStyle: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _showPlaceholder(context),
|
||||
icon: const Icon(Icons.menu_book_outlined),
|
||||
label: const Text('View build plan'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusCards extends StatelessWidget {
|
||||
const _StatusCards();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: const [
|
||||
_InfoCard(
|
||||
icon: Icons.route_outlined,
|
||||
title: 'Build roadmap',
|
||||
points: [
|
||||
'Port PQ 6.4 data set (Config.dfm) into Dart constants.',
|
||||
'Recreate quest/task loop with deterministic RNG + saves.',
|
||||
'Deliver offline-first storage (GZip JSON) across platforms.',
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
_InfoCard(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
title: 'Tech stack',
|
||||
points: [
|
||||
'Flutter (Material 3) with multiplatform targets enabled.',
|
||||
'path_provider + shared_preferences for local storage hooks.',
|
||||
'Strict lints with package imports enforced from day one.',
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
_InfoCard(
|
||||
icon: Icons.checklist_rtl,
|
||||
title: 'Today’s focus',
|
||||
points: [
|
||||
'Set up scaffold + lints.',
|
||||
'Wire seed theme and initial navigation shell.',
|
||||
'Keep reference assets under example/pq for parity.',
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.title, required this.points, this.icon});
|
||||
|
||||
final String title;
|
||||
final List<String> points;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 3,
|
||||
shadowColor: colorScheme.shadow.withValues(alpha: 0.2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...points.map(
|
||||
(point) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 3),
|
||||
child: Icon(Icons.check_circle_outline, size: 18),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(point, style: theme.textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tag extends StatelessWidget {
|
||||
const _Tag({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Chip(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
backgroundColor: colorScheme.onPrimary.withValues(alpha: 0.14),
|
||||
avatar: Icon(icon, color: colorScheme.onPrimary, size: 16),
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
side: BorderSide.none,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}
|
||||
}
|
||||
107
lib/src/features/front/save_picker_dialog.dart
Normal file
107
lib/src/features/front/save_picker_dialog.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:askiineverdie/src/core/storage/save_service.dart'
|
||||
show SaveFileInfo;
|
||||
|
||||
/// 저장 파일 선택 다이얼로그
|
||||
/// 선택된 파일명을 반환하거나, 취소 시 null 반환
|
||||
class SavePickerDialog extends StatelessWidget {
|
||||
const SavePickerDialog({super.key, required this.saves});
|
||||
|
||||
final List<SaveFileInfo> saves;
|
||||
|
||||
/// 다이얼로그 표시 및 결과 반환
|
||||
static Future<String?> show(
|
||||
BuildContext context,
|
||||
List<SaveFileInfo> saves,
|
||||
) async {
|
||||
if (saves.isEmpty) {
|
||||
// 저장 파일이 없으면 안내 메시지
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.')));
|
||||
return null;
|
||||
}
|
||||
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => SavePickerDialog(saves: saves),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.folder_open, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Load Game'),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: saves.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final save = saves[index];
|
||||
return _SaveListTile(
|
||||
save: save,
|
||||
onTap: () => Navigator.of(context).pop(save.fileName),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveListTile extends StatelessWidget {
|
||||
const _SaveListTile({required this.save, required this.onTap});
|
||||
|
||||
final SaveFileInfo save;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.save),
|
||||
title: Text(
|
||||
save.displayName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${dateFormat.format(save.modifiedAt)} · ${_formatSize(save.sizeBytes)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
126
lib/src/features/game/game_session_controller.dart
Normal file
126
lib/src/features/game/game_session_controller.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:askiineverdie/src/core/engine/progress_loop.dart';
|
||||
import 'package:askiineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum GameSessionStatus { idle, loading, running, error }
|
||||
|
||||
/// Presentation-friendly wrapper that owns ProgressLoop and SaveManager.
|
||||
class GameSessionController extends ChangeNotifier {
|
||||
GameSessionController({
|
||||
required this.progressService,
|
||||
required this.saveManager,
|
||||
this.autoSaveConfig = const AutoSaveConfig(),
|
||||
Duration tickInterval = const Duration(milliseconds: 50),
|
||||
DateTime Function()? now,
|
||||
}) : _tickInterval = tickInterval,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
final ProgressService progressService;
|
||||
final SaveManager saveManager;
|
||||
final AutoSaveConfig autoSaveConfig;
|
||||
|
||||
final Duration _tickInterval;
|
||||
final DateTime Function() _now;
|
||||
|
||||
ProgressLoop? _loop;
|
||||
StreamSubscription<GameState>? _subscription;
|
||||
bool _cheatsEnabled = false;
|
||||
|
||||
GameSessionStatus _status = GameSessionStatus.idle;
|
||||
GameState? _state;
|
||||
String? _error;
|
||||
|
||||
GameSessionStatus get status => _status;
|
||||
GameState? get state => _state;
|
||||
String? get error => _error;
|
||||
bool get isRunning => _status == GameSessionStatus.running;
|
||||
bool get cheatsEnabled => _cheatsEnabled;
|
||||
|
||||
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
|
||||
ProgressLoop? get loop => _loop;
|
||||
|
||||
Future<void> startNew(
|
||||
GameState initialState, {
|
||||
bool cheatsEnabled = false,
|
||||
bool isNewGame = true,
|
||||
}) async {
|
||||
await _stopLoop(saveOnStop: false);
|
||||
|
||||
// 새 게임인 경우 초기화 (프롤로그 태스크 설정)
|
||||
final state = isNewGame
|
||||
? progressService.initializeNewGame(initialState)
|
||||
: initialState;
|
||||
|
||||
_state = state;
|
||||
_error = null;
|
||||
_status = GameSessionStatus.running;
|
||||
_cheatsEnabled = cheatsEnabled;
|
||||
|
||||
_loop = ProgressLoop(
|
||||
initialState: state,
|
||||
progressService: progressService,
|
||||
saveManager: saveManager,
|
||||
autoSaveConfig: autoSaveConfig,
|
||||
tickInterval: _tickInterval,
|
||||
now: _now,
|
||||
cheatsEnabled: cheatsEnabled,
|
||||
);
|
||||
|
||||
_subscription = _loop!.stream.listen((next) {
|
||||
_state = next;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_loop!.start();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadAndStart({
|
||||
String? fileName,
|
||||
bool cheatsEnabled = false,
|
||||
}) async {
|
||||
_status = GameSessionStatus.loading;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final (outcome, loaded) = await saveManager.loadState(fileName: fileName);
|
||||
if (!outcome.success || loaded == null) {
|
||||
_status = GameSessionStatus.error;
|
||||
_error = outcome.error ?? 'Unknown error';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
await startNew(loaded, cheatsEnabled: cheatsEnabled, isNewGame: false);
|
||||
}
|
||||
|
||||
Future<void> pause({bool saveOnStop = false}) async {
|
||||
await _stopLoop(saveOnStop: saveOnStop);
|
||||
_status = GameSessionStatus.idle;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final stop = _stopLoop(saveOnStop: false);
|
||||
if (stop != null) {
|
||||
unawaited(stop);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void>? _stopLoop({required bool saveOnStop}) {
|
||||
final loop = _loop;
|
||||
final sub = _subscription;
|
||||
_loop = null;
|
||||
_subscription = null;
|
||||
|
||||
sub?.cancel();
|
||||
if (loop == null) return null;
|
||||
return loop.stop(saveOnStop: saveOnStop);
|
||||
}
|
||||
}
|
||||
484
lib/src/features/new_character/new_character_screen.dart
Normal file
484
lib/src/features/new_character/new_character_screen.dart
Normal file
@@ -0,0 +1,484 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
||||
import 'package:askiineverdie/src/core/util/pq_logic.dart';
|
||||
|
||||
/// 캐릭터 생성 화면 (NewGuy.pas 포팅)
|
||||
class NewCharacterScreen extends StatefulWidget {
|
||||
const NewCharacterScreen({super.key, this.onCharacterCreated});
|
||||
|
||||
/// 캐릭터 생성 완료 시 호출되는 콜백
|
||||
final void Function(GameState initialState)? onCharacterCreated;
|
||||
|
||||
@override
|
||||
State<NewCharacterScreen> createState() => _NewCharacterScreenState();
|
||||
}
|
||||
|
||||
class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
final PqConfig _config = const PqConfig();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
|
||||
// 종족(races)과 직업(klasses) 목록
|
||||
late final List<String> _races;
|
||||
late final List<String> _klasses;
|
||||
|
||||
// 선택된 종족/직업 인덱스
|
||||
int _selectedRaceIndex = 0;
|
||||
int _selectedKlassIndex = 0;
|
||||
|
||||
// 능력치(stats)
|
||||
int _str = 0;
|
||||
int _con = 0;
|
||||
int _dex = 0;
|
||||
int _int = 0;
|
||||
int _wis = 0;
|
||||
int _cha = 0;
|
||||
|
||||
// 롤 이력 (Unroll 기능용)
|
||||
final List<int> _rollHistory = [];
|
||||
|
||||
// 현재 RNG 시드 (Re-Roll 전 저장)
|
||||
int _currentSeed = 0;
|
||||
|
||||
// 이름 생성용 RNG
|
||||
late DeterministicRandom _nameRng;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 종족/직업 목록 로드 (name|attribute 형식에서 name만 추출)
|
||||
_races = _config.races.map((e) => e.split('|').first).toList();
|
||||
_klasses = _config.klasses.map((e) => e.split('|').first).toList();
|
||||
|
||||
// 초기 랜덤화
|
||||
final random = math.Random();
|
||||
_selectedRaceIndex = random.nextInt(_races.length);
|
||||
_selectedKlassIndex = random.nextInt(_klasses.length);
|
||||
|
||||
// 초기 스탯 굴림
|
||||
_currentSeed = random.nextInt(0x7FFFFFFF);
|
||||
_nameRng = DeterministicRandom(random.nextInt(0x7FFFFFFF));
|
||||
_rollStats();
|
||||
|
||||
// 초기 이름 생성
|
||||
_nameController.text = generateName(_nameRng);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 스탯 굴림 (3d6 × 6)
|
||||
void _rollStats() {
|
||||
final rng = DeterministicRandom(_currentSeed);
|
||||
setState(() {
|
||||
_str = rollStat(rng);
|
||||
_con = rollStat(rng);
|
||||
_dex = rollStat(rng);
|
||||
_int = rollStat(rng);
|
||||
_wis = rollStat(rng);
|
||||
_cha = rollStat(rng);
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-Roll 버튼 클릭
|
||||
void _onReroll() {
|
||||
// 현재 시드를 이력에 저장
|
||||
_rollHistory.insert(0, _currentSeed);
|
||||
|
||||
// 새 시드로 굴림
|
||||
_currentSeed = math.Random().nextInt(0x7FFFFFFF);
|
||||
_rollStats();
|
||||
}
|
||||
|
||||
/// Unroll 버튼 클릭 (이전 롤로 복원)
|
||||
void _onUnroll() {
|
||||
if (_rollHistory.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_currentSeed = _rollHistory.removeAt(0);
|
||||
});
|
||||
_rollStats();
|
||||
}
|
||||
|
||||
/// 이름 생성 버튼 클릭
|
||||
void _onGenerateName() {
|
||||
setState(() {
|
||||
_nameController.text = generateName(_nameRng);
|
||||
});
|
||||
}
|
||||
|
||||
/// Total 값 계산
|
||||
int get _total => _str + _con + _dex + _int + _wis + _cha;
|
||||
|
||||
/// Total 색상 결정 (원본 규칙)
|
||||
/// 63+18(81) 이상 = 빨강, 4*18(72) 초과 = 노랑
|
||||
/// 63-18(45) 이하 = 회색, 3*18(54) 미만 = 은색
|
||||
/// 그 외 = 흰색
|
||||
Color _getTotalColor() {
|
||||
final total = _total;
|
||||
if (total >= 81) return Colors.red;
|
||||
if (total > 72) return Colors.yellow;
|
||||
if (total <= 45) return Colors.grey;
|
||||
if (total < 54) return Colors.grey.shade400;
|
||||
return Colors.white;
|
||||
}
|
||||
|
||||
/// Sold! 버튼 클릭 - 캐릭터 생성 완료
|
||||
void _onSold() {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('이름을 입력해주세요.')));
|
||||
return;
|
||||
}
|
||||
|
||||
// 게임에 사용할 새 RNG 생성
|
||||
final gameSeed = math.Random().nextInt(0x7FFFFFFF);
|
||||
|
||||
// 종족/직업의 보너스 스탯 파싱
|
||||
final raceEntry = _config.races[_selectedRaceIndex];
|
||||
final klassEntry = _config.klasses[_selectedKlassIndex];
|
||||
final raceBonus = _parseStatBonus(raceEntry);
|
||||
final klassBonus = _parseStatBonus(klassEntry);
|
||||
|
||||
// 최종 스탯 계산 (기본 + 종족 보너스 + 직업 보너스)
|
||||
final finalStats = Stats(
|
||||
str: _str + (raceBonus['STR'] ?? 0) + (klassBonus['STR'] ?? 0),
|
||||
con: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0),
|
||||
dex: _dex + (raceBonus['DEX'] ?? 0) + (klassBonus['DEX'] ?? 0),
|
||||
intelligence: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0),
|
||||
wis: _wis + (raceBonus['WIS'] ?? 0) + (klassBonus['WIS'] ?? 0),
|
||||
cha: _cha + (raceBonus['CHA'] ?? 0) + (klassBonus['CHA'] ?? 0),
|
||||
hpMax: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0),
|
||||
mpMax: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0),
|
||||
);
|
||||
|
||||
final traits = Traits(
|
||||
name: name,
|
||||
race: _races[_selectedRaceIndex],
|
||||
klass: _klasses[_selectedKlassIndex],
|
||||
level: 1,
|
||||
motto: '',
|
||||
guild: '',
|
||||
);
|
||||
|
||||
// 초기 게임 상태 생성
|
||||
final initialState = GameState.withSeed(
|
||||
seed: gameSeed,
|
||||
traits: traits,
|
||||
stats: finalStats,
|
||||
inventory: const Inventory(gold: 0, items: []),
|
||||
equipment: Equipment.empty(),
|
||||
spellBook: SpellBook.empty(),
|
||||
progress: ProgressState.empty(),
|
||||
queue: QueueState.empty(),
|
||||
);
|
||||
|
||||
widget.onCharacterCreated?.call(initialState);
|
||||
}
|
||||
|
||||
/// 종족/직업 보너스 파싱 (예: "Half Orc|STR+2,INT-1")
|
||||
Map<String, int> _parseStatBonus(String entry) {
|
||||
final parts = entry.split('|');
|
||||
if (parts.length < 2) return {};
|
||||
|
||||
final bonuses = <String, int>{};
|
||||
final bonusPart = parts[1];
|
||||
|
||||
// STR+2,INT-1 형식 파싱
|
||||
final regex = RegExp(r'([A-Z]+)([+-]\d+)');
|
||||
for (final match in regex.allMatches(bonusPart)) {
|
||||
final stat = match.group(1)!;
|
||||
final value = int.parse(match.group(2)!);
|
||||
bonuses[stat] = value;
|
||||
}
|
||||
return bonuses;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Progress Quest - New Character'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 이름 입력 섹션
|
||||
_buildNameSection(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 능력치 섹션
|
||||
_buildStatsSection(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 종족/직업 선택 섹션
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildRaceSection()),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: _buildKlassSection()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Sold! 버튼
|
||||
FilledButton.icon(
|
||||
onPressed: _onSold,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Sold!'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNameSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLength: 30,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
onPressed: _onGenerateName,
|
||||
icon: const Icon(Icons.casino),
|
||||
tooltip: 'Generate Name',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Stats', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 스탯 그리드
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildStatTile('STR', _str)),
|
||||
Expanded(child: _buildStatTile('CON', _con)),
|
||||
Expanded(child: _buildStatTile('DEX', _dex)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildStatTile('INT', _int)),
|
||||
Expanded(child: _buildStatTile('WIS', _wis)),
|
||||
Expanded(child: _buildStatTile('CHA', _cha)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Total
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTotalColor().withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _getTotalColor()),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Total',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'$_total',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getTotalColor() == Colors.white
|
||||
? Colors.black
|
||||
: _getTotalColor(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Roll 버튼들
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _onUnroll,
|
||||
icon: const Icon(Icons.undo),
|
||||
label: const Text('Unroll'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _rollHistory.isEmpty ? Colors.grey : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _onReroll,
|
||||
icon: const Icon(Icons.casino),
|
||||
label: const Text('Roll'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_rollHistory.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'${_rollHistory.length} roll(s) in history',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatTile(String label, int value) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$value',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRaceSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Race', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
itemCount: _races.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isSelected = index == _selectedRaceIndex;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
isSelected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
_races[index],
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onTap: () => setState(() => _selectedRaceIndex = index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKlassSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Class', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
itemCount: _klasses.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isSelected = index == _selectedKlassIndex;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
isSelected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
_klasses[index],
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onTap: () => setState(() => _selectedKlassIndex = index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user