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 createState() => _NewCharacterScreenState(); } class _NewCharacterScreenState extends State { final TextEditingController _nameController = TextEditingController(); final ScrollController _raceScrollController = ScrollController(); final ScrollController _klassScrollController = ScrollController(); // 종족(races)과 직업(klasses) 목록 (Phase 5) final List _races = RaceData.all; final List _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 _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 = []; 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 = []; 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), ), ); } }