- NameInputSection: 이름 입력 섹션 - StatsSection: 능력치 섹션 (스탯 타일, 롤/언두 버튼) - RaceSelectionSection: 종족 선택 섹션 - ClassSelectionSection: 직업 선택 섹션
545 lines
17 KiB
Dart
545 lines
17 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
|
||
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/engine/character_roll_service.dart';
|
||
import 'package:asciineverdie/src/core/engine/iap_service.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/util/pq_logic.dart';
|
||
import 'package:asciineverdie/src/features/new_character/widgets/class_selection_section.dart';
|
||
import 'package:asciineverdie/src/features/new_character/widgets/name_input_section.dart';
|
||
import 'package:asciineverdie/src/features/new_character/widgets/race_preview.dart';
|
||
import 'package:asciineverdie/src/features/new_character/widgets/race_selection_section.dart';
|
||
import 'package:asciineverdie/src/features/new_character/widgets/stats_section.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;
|
||
|
||
// 현재 RNG 시드 (Re-Roll 전 저장)
|
||
int _currentSeed = 0;
|
||
|
||
// 이름 생성용 RNG
|
||
late DeterministicRandom _nameRng;
|
||
|
||
// 치트 모드 (디버그 전용: 100x 터보 배속 활성화)
|
||
bool _cheatsEnabled = false;
|
||
|
||
// 굴리기 버튼 연속 클릭 방지
|
||
bool _isRolling = false;
|
||
|
||
// 굴리기/되돌리기 서비스
|
||
final CharacterRollService _rollService = CharacterRollService.instance;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
// 서비스 초기화
|
||
_initializeService();
|
||
|
||
// 초기 랜덤화
|
||
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();
|
||
}
|
||
|
||
/// 서비스 초기화
|
||
Future<void> _initializeService() async {
|
||
await _rollService.initialize();
|
||
}
|
||
|
||
@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;
|
||
|
||
// 굴리기 가능 여부 확인
|
||
if (!_rollService.canRoll) {
|
||
_isRolling = false;
|
||
_showRechargeDialog();
|
||
return;
|
||
}
|
||
|
||
// 현재 상태를 서비스에 저장
|
||
final currentStats = Stats(
|
||
str: _str,
|
||
con: _con,
|
||
dex: _dex,
|
||
intelligence: _int,
|
||
wis: _wis,
|
||
cha: _cha,
|
||
hpMax: 0,
|
||
mpMax: 0,
|
||
);
|
||
|
||
final success = _rollService.roll(
|
||
currentStats: currentStats,
|
||
currentRaceIndex: _selectedRaceIndex,
|
||
currentKlassIndex: _selectedKlassIndex,
|
||
currentSeed: _currentSeed,
|
||
);
|
||
|
||
if (!success) {
|
||
_isRolling = false;
|
||
return;
|
||
}
|
||
|
||
// 새 시드로 굴림
|
||
final random = math.Random();
|
||
_currentSeed = random.nextInt(0x7FFFFFFF);
|
||
|
||
// 종족/클래스 랜덤 선택 및 스탯 굴림
|
||
setState(() {
|
||
_selectedRaceIndex = random.nextInt(_races.length);
|
||
_selectedKlassIndex = random.nextInt(_klasses.length);
|
||
// 스탯 굴림 (setState 내에서 실행하여 UI 갱신 보장)
|
||
final rng = DeterministicRandom(_currentSeed);
|
||
_str = rollStat(rng);
|
||
_con = rollStat(rng);
|
||
_dex = rollStat(rng);
|
||
_int = rollStat(rng);
|
||
_wis = rollStat(rng);
|
||
_cha = rollStat(rng);
|
||
});
|
||
|
||
// 선택된 종족/직업으로 스크롤
|
||
_scrollToSelectedItems();
|
||
|
||
// 짧은 딜레이 후 다시 클릭 가능
|
||
Future.delayed(const Duration(milliseconds: 100), () {
|
||
if (mounted) _isRolling = false;
|
||
});
|
||
}
|
||
|
||
/// 굴리기 충전 다이얼로그
|
||
Future<void> _showRechargeDialog() async {
|
||
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
|
||
|
||
final result = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
backgroundColor: RetroColors.panelBg,
|
||
title: Text(
|
||
L10n.of(context).rechargeRollsTitle,
|
||
style: const TextStyle(
|
||
fontFamily: 'PressStart2P',
|
||
fontSize: 14,
|
||
color: RetroColors.gold,
|
||
),
|
||
),
|
||
content: Text(
|
||
isPaidUser
|
||
? L10n.of(context).rechargeRollsFree
|
||
: L10n.of(context).rechargeRollsAd,
|
||
style: const TextStyle(
|
||
fontFamily: 'PressStart2P',
|
||
fontSize: 12,
|
||
color: RetroColors.textLight,
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: Text(
|
||
L10n.of(context).cancel.toUpperCase(),
|
||
style: const TextStyle(
|
||
fontFamily: 'PressStart2P',
|
||
fontSize: 11,
|
||
color: RetroColors.textDisabled,
|
||
),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (!isPaidUser) ...[
|
||
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
|
||
const SizedBox(width: 4),
|
||
],
|
||
Text(
|
||
L10n.of(context).rechargeButton,
|
||
style: const TextStyle(
|
||
fontFamily: 'PressStart2P',
|
||
fontSize: 11,
|
||
color: RetroColors.gold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (result == true && mounted) {
|
||
final success = await _rollService.rechargeRollsWithAd();
|
||
if (success && mounted) {
|
||
setState(() {});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Unroll 버튼 클릭 (이전 롤로 복원)
|
||
Future<void> _onUnroll() async {
|
||
if (!_rollService.canUndo) return;
|
||
|
||
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
|
||
RollSnapshot? snapshot;
|
||
|
||
if (isPaidUser) {
|
||
snapshot = _rollService.undoPaidUser();
|
||
} else {
|
||
snapshot = await _rollService.undoFreeUser();
|
||
}
|
||
|
||
// UI 상태 갱신 (성공/실패 여부와 관계없이 버튼 상태 업데이트)
|
||
if (!mounted) return;
|
||
|
||
if (snapshot != null) {
|
||
setState(() {
|
||
_str = snapshot!.stats.str;
|
||
_con = snapshot.stats.con;
|
||
_dex = snapshot.stats.dex;
|
||
_int = snapshot.stats.intelligence;
|
||
_wis = snapshot.stats.wis;
|
||
_cha = snapshot.stats.cha;
|
||
_selectedRaceIndex = snapshot.raceIndex;
|
||
_selectedKlassIndex = snapshot.klassIndex;
|
||
_currentSeed = snapshot.seed;
|
||
});
|
||
_scrollToSelectedItems();
|
||
} else {
|
||
// 광고 취소/실패 시에도 버튼 상태 갱신
|
||
setState(() {});
|
||
}
|
||
}
|
||
|
||
/// 이름 생성 버튼 클릭
|
||
void _onGenerateName() {
|
||
setState(() {
|
||
_nameController.text = generateName(_nameRng);
|
||
});
|
||
}
|
||
|
||
/// 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(),
|
||
);
|
||
|
||
// 캐릭터 생성 완료 알림 (되돌리기 상태 초기화)
|
||
_rollService.onCharacterCreated();
|
||
|
||
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: 15,
|
||
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: [
|
||
// 이름 입력 섹션
|
||
NameInputSection(
|
||
controller: _nameController,
|
||
onGenerateName: _onGenerateName,
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// 능력치 섹션
|
||
StatsSection(
|
||
str: _str,
|
||
con: _con,
|
||
dex: _dex,
|
||
intelligence: _int,
|
||
wis: _wis,
|
||
cha: _cha,
|
||
canRoll: _rollService.canRoll,
|
||
canUndo: _rollService.canUndo,
|
||
rollsRemaining: _rollService.rollsRemaining,
|
||
availableUndos: _rollService.availableUndos,
|
||
onRoll: _onReroll,
|
||
onUndo: _onUnroll,
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// 종족 미리보기 (Phase 5: 종족별 캐릭터 애니메이션)
|
||
RetroPanel(
|
||
title: L10n.of(context).previewTitle,
|
||
padding: const EdgeInsets.all(8),
|
||
child: Center(
|
||
child: RacePreview(raceId: _races[_selectedRaceIndex].raceId),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// 종족/직업 선택 섹션
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: RaceSelectionSection(
|
||
races: _races,
|
||
selectedIndex: _selectedRaceIndex,
|
||
scrollController: _raceScrollController,
|
||
onSelected: (index) =>
|
||
setState(() => _selectedRaceIndex = index),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: ClassSelectionSection(
|
||
klasses: _klasses,
|
||
selectedIndex: _selectedKlassIndex,
|
||
scrollController: _klassScrollController,
|
||
onSelected: (index) =>
|
||
setState(() => _selectedKlassIndex = index),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Sold! 버튼
|
||
RetroTextButton(
|
||
text: L10n.of(context).soldButton,
|
||
icon: Icons.check,
|
||
onPressed: _onSold,
|
||
),
|
||
|
||
// 디버그 전용: 치트 모드 토글 (100x 터보 배속)
|
||
if (kDebugMode) ...[
|
||
const SizedBox(height: 16),
|
||
_buildDebugCheatToggle(context),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 디버그 치트 토글 위젯
|
||
Widget _buildDebugCheatToggle(BuildContext context) {
|
||
return 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),
|
||
Flexible(
|
||
child: Text(
|
||
L10n.of(context).debugTurbo,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontFamily: 'PressStart2P',
|
||
fontSize: 11,
|
||
color: _cheatsEnabled
|
||
? RetroColors.hpRed
|
||
: RetroColors.textDisabled,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|