Files
asciinevrdie/lib/src/features/new_character/new_character_screen.dart
JiWoong Sul 1da377c127 feat(ui): 화면 및 공통 위젯 개선
- FrontScreen 개선
- GamePlayScreen, GameSessionController 업데이트
- ArenaBattleScreen, NewCharacterScreen 정리
- AsciiDisintegrateWidget 추가
2026-01-14 00:18:16 +09:00

788 lines
26 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/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<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;
// 치트 모드 (디버그 전용: 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 = <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 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 = <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 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,
};
}
}