Files
asciinevrdie/lib/src/features/new_character/new_character_screen.dart
JiWoong Sul 19faa9ea39 feat(ui): 게임 화면 및 UI 컴포넌트 개선
- front_screen: 프론트 화면 UI 업데이트
- game_play_screen: 게임 플레이 화면 수정
- game_session_controller: 세션 관리 로직 개선
- mobile_carousel_layout: 모바일 캐러셀 레이아웃 개선
- enhanced_animation_panel: 애니메이션 패널 업데이트
- help_dialog: 도움말 다이얼로그 수정
- return_rewards_dialog: 복귀 보상 다이얼로그 개선
- new_character_screen: 새 캐릭터 화면 수정
- settings_screen: 설정 화면 업데이트
2026-01-19 15:50:35 +09:00

1026 lines
33 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/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/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;
// 현재 RNG 시드 (Re-Roll 전 저장)
int _currentSeed = 0;
// 이름 생성용 RNG
late DeterministicRandom _nameRng;
// 치트 모드 (디버그 전용: 100x 터보 배속 활성화)
bool _cheatsEnabled = false;
// 굴리기 버튼 연속 클릭 방지
bool _isRolling = false;
// 굴리기/되돌리기 서비스
final CharacterRollService _rollService = CharacterRollService.instance;
// 서비스 초기화 완료 여부
bool _isServiceInitialized = false;
@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();
if (mounted) {
setState(() {
_isServiceInitialized = true;
});
}
}
@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: const Text(
'RECHARGE ROLLS',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.gold,
),
),
content: Text(
isPaidUser
? 'Recharge 5 rolls for free?'
: 'Watch an ad to recharge 5 rolls?',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.textLight,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'CANCEL',
style: 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),
],
const Text(
'RECHARGE',
style: 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);
});
}
/// 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(),
);
// 캐릭터 생성 완료 알림 (되돌리기 상태 초기화)
_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: [
// 이름 입력 섹션
_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),
Flexible(
child: Text(
'DEBUG: TURBO (20x)',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
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: 14,
color: RetroColors.textLight,
),
// 영문 알파벳만 허용 (공백 불가)
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')),
],
decoration: InputDecoration(
labelText: l10n.name,
labelStyle: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
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: 13,
fontWeight: FontWeight.bold,
color: RetroColors.textLight,
),
),
Text(
'$_total',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
fontWeight: FontWeight.bold,
color: _getTotalColor(),
),
),
],
),
),
const SizedBox(height: 12),
// Roll 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildUndoButton(l10n),
const SizedBox(width: 16),
_buildRollButton(l10n),
],
),
// 남은 횟수 표시
Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: Text(
_rollService.canUndo
? 'Undo: ${_rollService.availableUndos} | Rolls: ${_rollService.rollsRemaining}/5'
: 'Rolls: ${_rollService.rollsRemaining}/5',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
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: 13,
color: RetroColors.gold,
),
),
const SizedBox(height: 4),
Text(
'$value',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 17,
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: 14,
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: 15,
color: RetroColors.textLight,
),
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: const TextStyle(fontSize: 15, 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: 14,
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: 15,
color: RetroColors.textLight,
),
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: const TextStyle(fontSize: 15, 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,
};
}
// ===========================================================================
// 굴리기/되돌리기 버튼 위젯
// ===========================================================================
/// 되돌리기 버튼
Widget _buildUndoButton(L10n l10n) {
final canUndo = _rollService.canUndo;
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
return GestureDetector(
onTap: canUndo ? _onUnroll : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: canUndo
? RetroColors.panelBgLight
: RetroColors.panelBg.withValues(alpha: 0.5),
border: Border.all(
color: canUndo ? RetroColors.panelBorderInner : RetroColors.panelBg,
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 무료 유저는 광고 아이콘 표시
if (!isPaidUser && canUndo) ...[
const Icon(
Icons.play_circle,
size: 14,
color: RetroColors.gold,
),
const SizedBox(width: 4),
],
Icon(
Icons.undo,
size: 14,
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled,
),
const SizedBox(width: 4),
Text(
l10n.unroll.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled,
),
),
],
),
),
);
}
/// 굴리기 버튼
Widget _buildRollButton(L10n l10n) {
final canRoll = _rollService.canRoll;
return GestureDetector(
onTap: _onReroll,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.gold, width: 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 0회일 때 광고 아이콘 표시
if (!canRoll) ...[
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
],
const Icon(Icons.casino, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
Text(
canRoll
? '${l10n.roll.toUpperCase()} (${_rollService.rollsRemaining})'
: l10n.roll.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.gold,
),
),
],
),
),
);
}
}