Files
asciinevrdie/lib/src/features/new_character/new_character_screen.dart
JiWoong Sul ec27389e9b feat(phase5): 종족/클래스 시스템 균형 및 UI 통합
- 21개 종족 균형 재설계 (스탯 합계 = 0)
- 18개 클래스 균형 재설계 (스탯 합계 = +3)
- Traits에 raceId, classId 필드 추가
- 저장/불러오기에 종족/클래스 ID 추가
- 캐릭터 생성 UI에서 RaceData/ClassData 사용
- 선택 시 스탯 보정 및 패시브 정보 표시
2025-12-17 17:42:27 +09:00

559 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/class_data.dart';
import 'package:askiineverdie/data/race_data.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/model/class_traits.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/race_traits.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 TextEditingController _nameController = TextEditingController();
// 종족(races)과 직업(klasses) 목록 (Phase 5)
final List<RaceTraits> _races = RaceData.all;
final List<ClassTraits> _klasses = ClassData.all;
// 선택된 종족/직업 인덱스
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 기능용) - 원본 OldRolls TListBox
static const int _maxRollHistory = 20; // 최대 저장 개수
final List<int> _rollHistory = [];
// 현재 RNG 시드 (Re-Roll 전 저장)
int _currentSeed = 0;
// 이름 생성용 RNG
late DeterministicRandom _nameRng;
@override
void initState() {
super.initState();
// 초기 랜덤화
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);
// 최대 개수 초과 시 가장 오래된 항목 제거
if (_rollHistory.length > _maxRollHistory) {
_rollHistory.removeLast();
}
// 새 시드로 굴림
_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! 버튼 클릭 - 캐릭터 생성 완료
/// 원본 Main.pas:1371-1388 RollCharacter 로직
void _onSold() {
final name = _nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('이름을 입력해주세요.')));
return;
}
// 선택된 종족/클래스 (Phase 5)
final selectedRace = _races[_selectedRaceIndex];
final selectedClass = _klasses[_selectedKlassIndex];
// 게임에 사용할 새 RNG 생성
final gameSeed = math.Random().nextInt(0x7FFFFFFF);
// 원본 Main.pas:1380-1381 - 기본 롤 값(CON.Tag, INT.Tag)만 사용
// 종족/직업 보너스는 스탯에 적용되지 않음 (UI 힌트용)
// Put(Stats,'HP Max',Random(8) + CON.Tag div 6);
// Put(Stats,'MP Max',Random(8) + INT.Tag div 6);
final hpMax = math.Random().nextInt(8) + _con ~/ 6;
final mpMax = math.Random().nextInt(8) + _int ~/ 6;
// 원본 Main.pas:1375-1379 - 기본 롤 값 그대로 저장 (보너스 없음)
final finalStats = Stats(
str: _str,
con: _con,
dex: _dex,
intelligence: _int,
wis: _wis,
cha: _cha,
hpMax: hpMax,
mpMax: mpMax,
);
final traits = Traits(
name: name,
race: selectedRace.name,
klass: selectedClass.name,
level: 1,
motto: '',
guild: '',
raceId: selectedRace.raceId,
classId: selectedClass.classId,
);
// 초기 게임 상태 생성
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);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).newCharacterTitle),
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: Text(L10n.of(context).soldButton),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
),
),
);
}
Widget _buildNameSection() {
final l10n = L10n.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.name,
border: const OutlineInputBorder(),
),
maxLength: 30,
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _onGenerateName,
icon: const Icon(Icons.casino),
tooltip: l10n.generateName,
),
],
),
),
);
}
Widget _buildStatsSection() {
final l10n = L10n.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.stats, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
// 스탯 그리드
Row(
children: [
Expanded(child: _buildStatTile(l10n.statStr, _str)),
Expanded(child: _buildStatTile(l10n.statCon, _con)),
Expanded(child: _buildStatTile(l10n.statDex, _dex)),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _buildStatTile(l10n.statInt, _int)),
Expanded(child: _buildStatTile(l10n.statWis, _wis)),
Expanded(child: _buildStatTile(l10n.statCha, _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: [
Text(
l10n.total,
style: const 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: Text(l10n.unroll),
style: OutlinedButton.styleFrom(
foregroundColor: _rollHistory.isEmpty ? Colors.grey : null,
),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: _onReroll,
icon: const Icon(Icons.casino),
label: Text(l10n.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(L10n.of(context).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;
final race = _races[index];
return ListTile(
leading: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
race.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
subtitle: isSelected ? _buildRaceInfo(race) : null,
dense: !isSelected,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedRaceIndex = index),
);
},
),
),
],
),
),
);
}
/// 종족 정보 표시 (Phase 5)
Widget _buildRaceInfo(RaceTraits race) {
final statMods = <String>[];
for (final entry in race.statModifiers.entries) {
final sign = entry.value > 0 ? '+' : '';
statMods.add('${_statName(entry.key)} $sign${entry.value}');
}
final passiveDesc = race.passives.isNotEmpty
? race.passives.map((p) => p.description).join(', ')
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (statMods.isNotEmpty)
Text(
statMods.join(', '),
style: Theme.of(context).textTheme.bodySmall,
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
);
}
String _statName(StatType type) {
return switch (type) {
StatType.str => 'STR',
StatType.con => 'CON',
StatType.dex => 'DEX',
StatType.intelligence => 'INT',
StatType.wis => 'WIS',
StatType.cha => 'CHA',
};
}
Widget _buildKlassSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(L10n.of(context).classTitle, 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;
final klass = _klasses[index];
return ListTile(
leading: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
klass.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
subtitle: isSelected ? _buildClassInfo(klass) : null,
dense: !isSelected,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedKlassIndex = index),
);
},
),
),
],
),
),
);
}
/// 클래스 정보 표시 (Phase 5)
Widget _buildClassInfo(ClassTraits klass) {
final statMods = <String>[];
for (final entry in klass.statModifiers.entries) {
final sign = entry.value > 0 ? '+' : '';
statMods.add('${_statName(entry.key)} $sign${entry.value}');
}
final passiveDesc = klass.passives.isNotEmpty
? klass.passives.map((p) => p.description).join(', ')
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (statMods.isNotEmpty)
Text(
statMods.join(', '),
style: Theme.of(context).textTheme.bodySmall,
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
],
);
}
}