feat: 초기 커밋
- Progress Quest 6.4 Flutter 포팅 프로젝트 - 게임 루프, 상태 관리, UI 구현 - 캐릭터 생성, 인벤토리, 장비, 주문 시스템 - 시장/판매/구매 메커니즘
This commit is contained in:
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