Files
asciinevrdie/lib/src/features/new_character/new_character_screen.dart
JiWoong Sul 7219f58853 feat(animation): 종족별 캐릭터 애니메이션 시스템 추가
- 21개 종족별 고유 ASCII 캐릭터 프레임 데이터 추가
  - 각 종족당 5가지 상태 애니메이션: idle, prepare, attack, hit, recover
  - 종족 특성에 맞는 시각적 차별화 (마법사 ~, 기사 ♦, 언데드 ☠ 등)
- 캐릭터 생성 화면 종족 미리보기 위젯 추가
- 프론트 화면 Hero vs Boss 애니메이션 개선
- 게임 플레이 화면 애니메이션 패널 연동 강화
2025-12-23 20:00:41 +09:00

671 lines
21 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/game_text_l10n.dart' as game_l10n;
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/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart';
import 'package:askiineverdie/src/features/new_character/widgets/race_preview.dart';
/// 캐릭터 생성 화면 (NewGuy.pas 포팅)
class NewCharacterScreen extends StatefulWidget {
const NewCharacterScreen({super.key, this.onCharacterCreated});
/// 캐릭터 생성 완료 시 호출되는 콜백
/// testMode: 웹에서도 모바일 캐로셀 레이아웃 사용
final void Function(GameState initialState, {bool testMode})?
onCharacterCreated;
@override
State<NewCharacterScreen> createState() => _NewCharacterScreenState();
}
class _NewCharacterScreenState extends State<NewCharacterScreen> {
final TextEditingController _nameController = TextEditingController();
final ScrollController _raceScrollController = ScrollController();
final ScrollController _klassScrollController = ScrollController();
// 종족(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;
// 테스트 모드 (웹에서 모바일 캐로셀 레이아웃 활성화)
bool _testModeEnabled = false;
@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);
// 선택된 종족/직업으로 스크롤
_scrollToSelectedItems();
}
@override
void dispose() {
_nameController.dispose();
_raceScrollController.dispose();
_klassScrollController.dispose();
super.dispose();
}
/// 선택된 종족/직업 위치로 스크롤
void _scrollToSelectedItems() {
// ListTile 높이 약 48px (dense 모드)
const itemHeight = 48.0;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_raceScrollController.hasClients) {
final raceOffset = _selectedRaceIndex * itemHeight;
_raceScrollController.animateTo(
raceOffset.clamp(0.0, _raceScrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
if (_klassScrollController.hasClients) {
final klassOffset = _selectedKlassIndex * itemHeight;
_klassScrollController.animateTo(
klassOffset.clamp(
0.0,
_klassScrollController.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
/// 스탯 굴림 (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();
// 선택된 종족/직업으로 스크롤
_scrollToSelectedItems();
}
/// 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(SnackBar(content: Text(game_l10n.uiEnterName)));
return;
}
// 선택된 종족/클래스 (Phase 5)
final selectedRace = _races[_selectedRaceIndex];
final selectedClass = _klasses[_selectedKlassIndex];
// 게임에 사용할 새 RNG 생성
final gameSeed = math.Random().nextInt(0x7FFFFFFF);
// HP/MP 초기값 계산
// 원본 공식: Random(8) + CON/6 → 약 1~10 HP (너무 낮음)
// 수정 공식: 50 + Random(8) + CON → 약 60~76 HP (전투 생존 가능)
// 이유: 원본 PQ는 "항상 승리"하지만 이 게임은 실제 전투로 사망 가능
final hpMax = 50 + math.Random().nextInt(8) + _con;
final mpMax = 30 + math.Random().nextInt(8) + _int;
// 원본 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, testMode: _testModeEnabled);
}
@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),
// 종족 미리보기 (Phase 5: 종족별 캐릭터 애니메이션)
Center(
child: RacePreview(
raceId: _races[_selectedRaceIndex].raceId,
),
),
const SizedBox(height: 16),
// 종족/직업 선택 섹션
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildRaceSection()),
const SizedBox(width: 16),
Expanded(child: _buildKlassSection()),
],
),
const SizedBox(height: 16),
// 테스트 모드 토글 (웹에서 모바일 레이아웃 테스트)
_buildTestModeToggle(),
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(
game_l10n.uiRollHistory(_rollHistory.length),
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(
controller: _raceScrollController,
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(
GameDataL10n.getRaceName(context, 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) => _translateRacePassive(p)).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 _translateRacePassive(PassiveAbility passive) {
final percent = (passive.value * 100).round();
return switch (passive.type) {
PassiveType.hpBonus => game_l10n.passiveHpBonus(percent),
PassiveType.mpBonus => game_l10n.passiveMpBonus(percent),
PassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent),
PassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent),
PassiveType.criticalBonus => game_l10n.passiveCritBonus(percent),
PassiveType.expBonus => passive.description,
PassiveType.deathEquipmentPreserve => passive.description,
};
}
String _statName(StatType type) {
return switch (type) {
StatType.str => game_l10n.statStr,
StatType.con => game_l10n.statCon,
StatType.dex => game_l10n.statDex,
StatType.intelligence => game_l10n.statInt,
StatType.wis => game_l10n.statWis,
StatType.cha => game_l10n.statCha,
};
}
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(
controller: _klassScrollController,
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(
GameDataL10n.getKlassName(context, 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) => _translateClassPassive(p)).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,
),
),
],
);
}
/// 클래스 패시브 설명 번역
String _translateClassPassive(ClassPassive passive) {
final percent = (passive.value * 100).round();
return switch (passive.type) {
ClassPassiveType.hpBonus => game_l10n.passiveHpBonus(percent),
ClassPassiveType.physicalDamageBonus => game_l10n.passivePhysicalBonus(
percent,
),
ClassPassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent),
ClassPassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent),
ClassPassiveType.evasionBonus => game_l10n.passiveEvasionBonus(percent),
ClassPassiveType.criticalBonus => game_l10n.passiveCritBonus(percent),
ClassPassiveType.postCombatHeal => game_l10n.passiveHpRegen(percent),
ClassPassiveType.healingBonus => passive.description,
ClassPassiveType.multiAttack => passive.description,
ClassPassiveType.firstStrikeBonus => passive.description,
};
}
/// 테스트 모드 토글 위젯
Widget _buildTestModeToggle() {
return Card(
child: SwitchListTile(
title: Text(game_l10n.uiTestMode),
subtitle: Text(game_l10n.uiTestModeDesc),
value: _testModeEnabled,
onChanged: (value) => setState(() => _testModeEnabled = value),
secondary: const Icon(Icons.phone_android),
),
);
}
}