feat(phase5): 종족/클래스 시스템 균형 및 UI 통합

- 21개 종족 균형 재설계 (스탯 합계 = 0)
- 18개 클래스 균형 재설계 (스탯 합계 = +3)
- Traits에 raceId, classId 필드 추가
- 저장/불러오기에 종족/클래스 ID 추가
- 캐릭터 생성 UI에서 RaceData/ClassData 사용
- 선택 시 스탯 보정 및 패시브 정보 표시
This commit is contained in:
JiWoong Sul
2025-12-17 17:42:27 +09:00
parent e451703161
commit ec27389e9b
5 changed files with 364 additions and 159 deletions

View File

@@ -2,10 +2,12 @@ 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/l10n/game_data_l10n.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/pq_config.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';
@@ -21,12 +23,11 @@ class NewCharacterScreen extends StatefulWidget {
}
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;
// 종족(races)과 직업(klasses) 목록 (Phase 5)
final List<RaceTraits> _races = RaceData.all;
final List<ClassTraits> _klasses = ClassData.all;
// 선택된 종족/직업 인덱스
int _selectedRaceIndex = 0;
@@ -54,10 +55,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
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);
@@ -150,6 +147,10 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
return;
}
// 선택된 종족/클래스 (Phase 5)
final selectedRace = _races[_selectedRaceIndex];
final selectedClass = _klasses[_selectedKlassIndex];
// 게임에 사용할 새 RNG 생성
final gameSeed = math.Random().nextInt(0x7FFFFFFF);
@@ -174,11 +175,13 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
final traits = Traits(
name: name,
race: _races[_selectedRaceIndex],
klass: _klasses[_selectedKlassIndex],
race: selectedRace.name,
klass: selectedClass.name,
level: 1,
motto: '',
guild: '',
raceId: selectedRace.raceId,
classId: selectedClass.classId,
);
// 초기 게임 상태 생성
@@ -401,10 +404,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
itemCount: _races.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedRaceIndex;
final raceName = GameDataL10n.getRaceName(
context,
_races[index],
);
final race = _races[index];
return ListTile(
leading: Icon(
isSelected
@@ -415,14 +415,15 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
: null,
),
title: Text(
raceName,
race.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
dense: true,
subtitle: isSelected ? _buildRaceInfo(race) : null,
dense: !isSelected,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedRaceIndex = index),
);
@@ -435,6 +436,48 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
);
}
/// 종족 정보 표시 (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(
@@ -450,10 +493,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
itemCount: _klasses.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedKlassIndex;
final klassName = GameDataL10n.getKlassName(
context,
_klasses[index],
);
final klass = _klasses[index];
return ListTile(
leading: Icon(
isSelected
@@ -464,14 +504,15 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
: null,
),
title: Text(
klassName,
klass.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
dense: true,
subtitle: isSelected ? _buildClassInfo(klass) : null,
dense: !isSelected,
visualDensity: VisualDensity.compact,
onTap: () => setState(() => _selectedKlassIndex = index),
);
@@ -483,4 +524,35 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
),
);
}
/// 클래스 정보 표시 (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,
),
),
],
);
}
}