import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show FilteringTextInputFormatter; import 'package:asciineverdie/data/class_data.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/data/race_data.dart'; import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/model/class_traits.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/race_traits.dart'; import 'package:asciineverdie/src/core/util/deterministic_random.dart'; import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart'; import 'package:asciineverdie/src/core/util/pq_logic.dart'; import 'package:asciineverdie/src/features/new_character/widgets/race_preview.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/widgets/retro_widgets.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; // 치트 모드 (디버그 전용: 100x 터보 배속 활성화) bool _cheatsEnabled = false; // 굴리기 버튼 연속 클릭 방지 bool _isRolling = 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 (!mounted) return; // 스크롤 애니메이션 대신 즉시 점프 (WASM 모드 안정성) if (_raceScrollController.hasClients) { final raceOffset = _selectedRaceIndex * itemHeight; _raceScrollController.jumpTo( raceOffset.clamp(0.0, _raceScrollController.position.maxScrollExtent), ); } if (_klassScrollController.hasClients) { final klassOffset = _selectedKlassIndex * itemHeight; _klassScrollController.jumpTo( klassOffset.clamp( 0.0, _klassScrollController.position.maxScrollExtent, ), ); } }); } /// 스탯 굴림 (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 버튼 클릭 /// 원본 NewGuy.pas RerollClick: 스탯, 종족, 클래스 모두 랜덤화 void _onReroll() { // 연속 클릭 방지 if (_isRolling) return; _isRolling = true; // 현재 시드를 이력에 저장 _rollHistory.insert(0, _currentSeed); // 최대 개수 초과 시 가장 오래된 항목 제거 if (_rollHistory.length > _maxRollHistory) { _rollHistory.removeLast(); } // 새 시드로 굴림 final random = math.Random(); _currentSeed = random.nextInt(0x7FFFFFFF); // 종족/클래스도 랜덤 선택 setState(() { _selectedRaceIndex = random.nextInt(_races.length); _selectedKlassIndex = random.nextInt(_klasses.length); }); _rollStats(); // 선택된 종족/직업으로 스크롤 _scrollToSelectedItems(); // 짧은 딜레이 후 다시 클릭 가능 Future.delayed(const Duration(milliseconds: 100), () { if (mounted) _isRolling = false; }); } /// 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 (너무 낮음) // 수정 공식: 65 + Random(8) + CON → 약 68~91 HP (사망률 ~10% 목표) // 이유: 원본 PQ는 "항상 승리"하지만 이 게임은 실제 전투로 사망 가능 final hpMax = 65 + 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: Inventory.empty(), equipment: Equipment.empty(), skillBook: SkillBook.empty(), progress: ProgressState.empty(), queue: QueueState.empty(), ); widget.onCharacterCreated?.call(initialState, testMode: _cheatsEnabled); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: RetroColors.deepBrown, appBar: AppBar( backgroundColor: RetroColors.darkBrown, title: Text( L10n.of(context).newCharacterTitle.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 12, color: RetroColors.gold, ), ), centerTitle: true, iconTheme: const IconThemeData(color: RetroColors.gold), ), body: SafeArea( top: false, // AppBar가 상단 처리 child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 이름 입력 섹션 _buildNameSection(), const SizedBox(height: 16), // 능력치 섹션 _buildStatsSection(), const SizedBox(height: 16), // 종족 미리보기 (Phase 5: 종족별 캐릭터 애니메이션) RetroPanel( title: 'PREVIEW', padding: const EdgeInsets.all(8), child: 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), // Sold! 버튼 RetroTextButton( text: L10n.of(context).soldButton, icon: Icons.check, onPressed: _onSold, ), // 디버그 전용: 치트 모드 토글 (100x 터보 배속) if (kDebugMode) ...[ const SizedBox(height: 16), GestureDetector( onTap: () => setState(() => _cheatsEnabled = !_cheatsEnabled), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), decoration: BoxDecoration( color: _cheatsEnabled ? RetroColors.hpRed.withValues(alpha: 0.3) : RetroColors.panelBg, border: Border.all( color: _cheatsEnabled ? RetroColors.hpRed : RetroColors.panelBorderInner, width: 2, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( _cheatsEnabled ? Icons.bug_report : Icons.bug_report_outlined, size: 16, color: _cheatsEnabled ? RetroColors.hpRed : RetroColors.textDisabled, ), const SizedBox(width: 8), Text( 'DEBUG: TURBO MODE (20x)', style: TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: _cheatsEnabled ? RetroColors.hpRed : RetroColors.textDisabled, ), ), ], ), ), ), ], ], ), ), ), ); } Widget _buildNameSection() { final l10n = L10n.of(context); return RetroPanel( title: 'NAME', child: Row( children: [ Expanded( child: TextField( controller: _nameController, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 10, color: RetroColors.textLight, ), // 영문 알파벳만 허용 (공백 불가) inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')), ], decoration: InputDecoration( labelText: l10n.name, labelStyle: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: RetroColors.gold, ), border: const OutlineInputBorder( borderSide: BorderSide(color: RetroColors.panelBorderInner), ), enabledBorder: const OutlineInputBorder( borderSide: BorderSide(color: RetroColors.panelBorderInner), ), focusedBorder: const OutlineInputBorder( borderSide: BorderSide(color: RetroColors.gold, width: 2), ), counterStyle: const TextStyle(color: RetroColors.textDisabled), ), maxLength: 30, ), ), const SizedBox(width: 8), RetroIconButton(icon: Icons.casino, onPressed: _onGenerateName), ], ), ); } Widget _buildStatsSection() { final l10n = L10n.of(context); return RetroPanel( title: 'STATS', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 스탯 그리드 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.2), border: Border.all(color: _getTotalColor(), width: 2), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.total.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, fontWeight: FontWeight.bold, color: RetroColors.textLight, ), ), Text( '$_total', style: TextStyle( fontFamily: 'PressStart2P', fontSize: 14, fontWeight: FontWeight.bold, color: _getTotalColor(), ), ), ], ), ), const SizedBox(height: 12), // Roll 버튼들 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ RetroTextButton( text: l10n.unroll, icon: Icons.undo, onPressed: _rollHistory.isEmpty ? null : _onUnroll, isPrimary: false, ), const SizedBox(width: 16), RetroTextButton( text: l10n.roll, icon: Icons.casino, onPressed: _onReroll, ), ], ), if (_rollHistory.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: Center( child: Text( game_l10n.uiRollHistory(_rollHistory.length), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: RetroColors.textDisabled, ), ), ), ), ], ), ); } Widget _buildStatTile(String label, int value) { return Container( margin: const EdgeInsets.all(4), padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: RetroColors.panelBgLight, border: Border.all(color: RetroColors.panelBorderInner), ), child: Column( children: [ Text( label.toUpperCase(), style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: RetroColors.gold, ), ), const SizedBox(height: 4), Text( '$value', style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 12, fontWeight: FontWeight.bold, color: RetroColors.textLight, ), ), ], ), ); } Widget _buildRaceSection() { return RetroPanel( title: 'RACE', child: SizedBox( height: 300, child: ListView.builder( controller: _raceScrollController, itemCount: _races.length, itemBuilder: (context, index) { final isSelected = index == _selectedRaceIndex; final race = _races[index]; return GestureDetector( onTap: () => setState(() => _selectedRaceIndex = index), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: isSelected ? RetroColors.panelBgLight : null, border: isSelected ? Border.all(color: RetroColors.gold, width: 1) : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( isSelected ? Icons.arrow_right : Icons.remove, size: 12, color: isSelected ? RetroColors.gold : RetroColors.textDisabled, ), const SizedBox(width: 4), Expanded( child: Text( GameDataL10n.getRaceName(context, race.name), style: TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: isSelected ? RetroColors.gold : RetroColors.textLight, ), ), ), ], ), if (isSelected) _buildRaceInfo(race), ], ), ), ); }, ), ), ); } /// 종족 정보 표시 (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 Padding( padding: const EdgeInsets.only(left: 16, top: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (statMods.isNotEmpty) Text( statMods.join(', '), style: const TextStyle(fontSize: 9, color: RetroColors.textLight), ), if (passiveDesc.isNotEmpty) Text( passiveDesc, style: const TextStyle(fontSize: 9, color: RetroColors.expGreen), ), ], ), ); } /// 종족 패시브 설명 번역 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 RetroPanel( title: 'CLASS', child: SizedBox( height: 300, child: ListView.builder( controller: _klassScrollController, itemCount: _klasses.length, itemBuilder: (context, index) { final isSelected = index == _selectedKlassIndex; final klass = _klasses[index]; return GestureDetector( onTap: () => setState(() => _selectedKlassIndex = index), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: isSelected ? RetroColors.panelBgLight : null, border: isSelected ? Border.all(color: RetroColors.gold, width: 1) : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( isSelected ? Icons.arrow_right : Icons.remove, size: 12, color: isSelected ? RetroColors.gold : RetroColors.textDisabled, ), const SizedBox(width: 4), Expanded( child: Text( GameDataL10n.getKlassName(context, klass.name), style: TextStyle( fontFamily: 'PressStart2P', fontSize: 8, color: isSelected ? RetroColors.gold : RetroColors.textLight, ), ), ), ], ), if (isSelected) _buildClassInfo(klass), ], ), ), ); }, ), ), ); } /// 클래스 정보 표시 (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 Padding( padding: const EdgeInsets.only(left: 16, top: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (statMods.isNotEmpty) Text( statMods.join(', '), style: const TextStyle(fontSize: 9, color: RetroColors.textLight), ), if (passiveDesc.isNotEmpty) Text( passiveDesc, style: const TextStyle(fontSize: 9, color: RetroColors.expGreen), ), ], ), ); } /// 클래스 패시브 설명 번역 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, }; } }