From 75bc39528f69b066c33b6b1f06a18d1ee1e2e889 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 21 Jan 2026 17:33:59 +0900 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20new=5Fcharacter=5Fscreen.dart?= =?UTF-8?q?=20=EB=B6=84=ED=95=A0=20(1016=E2=86=92544=20LOC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NameInputSection: 이름 입력 섹션 - StatsSection: 능력치 섹션 (스탯 타일, 롤/언두 버튼) - RaceSelectionSection: 종족 선택 섹션 - ClassSelectionSection: 직업 선택 섹션 --- .../new_character/new_character_screen.dart | 640 +++--------------- .../widgets/class_selection_section.dart | 155 +++++ .../widgets/name_input_section.dart | 65 ++ .../widgets/race_selection_section.dart | 149 ++++ .../new_character/widgets/stats_section.dart | 302 +++++++++ 5 files changed, 751 insertions(+), 560 deletions(-) create mode 100644 lib/src/features/new_character/widgets/class_selection_section.dart create mode 100644 lib/src/features/new_character/widgets/name_input_section.dart create mode 100644 lib/src/features/new_character/widgets/race_selection_section.dart create mode 100644 lib/src/features/new_character/widgets/stats_section.dart diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index 117a202..560fff1 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -2,7 +2,6 @@ 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; @@ -14,9 +13,12 @@ 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/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'; @@ -69,9 +71,6 @@ class _NewCharacterScreenState extends State { // 굴리기/되돌리기 서비스 final CharacterRollService _rollService = CharacterRollService.instance; - // 서비스 초기화 완료 여부 - bool _isServiceInitialized = false; - @override void initState() { super.initState(); @@ -99,11 +98,6 @@ class _NewCharacterScreenState extends State { /// 서비스 초기화 Future _initializeService() async { await _rollService.initialize(); - if (mounted) { - setState(() { - _isServiceInitialized = true; - }); - } } @override @@ -227,9 +221,9 @@ class _NewCharacterScreenState extends State { context: context, builder: (context) => AlertDialog( backgroundColor: RetroColors.panelBg, - title: const Text( - 'RECHARGE ROLLS', - style: TextStyle( + title: Text( + L10n.of(context).rechargeRollsTitle, + style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 14, color: RetroColors.gold, @@ -237,8 +231,8 @@ class _NewCharacterScreenState extends State { ), content: Text( isPaidUser - ? 'Recharge 5 rolls for free?' - : 'Watch an ad to recharge 5 rolls?', + ? L10n.of(context).rechargeRollsFree + : L10n.of(context).rechargeRollsAd, style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 12, @@ -248,9 +242,9 @@ class _NewCharacterScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text( - 'CANCEL', - style: TextStyle( + child: Text( + L10n.of(context).cancel.toUpperCase(), + style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 11, color: RetroColors.textDisabled, @@ -266,9 +260,9 @@ class _NewCharacterScreenState extends State { const Icon(Icons.play_circle, size: 14, color: RetroColors.gold), const SizedBox(width: 4), ], - const Text( - 'RECHARGE', - style: TextStyle( + Text( + L10n.of(context).rechargeButton, + style: const TextStyle( fontFamily: 'PressStart2P', fontSize: 11, color: RetroColors.gold, @@ -331,22 +325,6 @@ class _NewCharacterScreenState extends State { }); } - /// 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() { @@ -438,16 +416,32 @@ class _NewCharacterScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 이름 입력 섹션 - _buildNameSection(), + NameInputSection( + controller: _nameController, + onGenerateName: _onGenerateName, + ), const SizedBox(height: 16), // 능력치 섹션 - _buildStatsSection(), + 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: 'PREVIEW', + title: L10n.of(context).previewTitle, padding: const EdgeInsets.all(8), child: Center( child: RacePreview(raceId: _races[_selectedRaceIndex].raceId), @@ -459,9 +453,25 @@ class _NewCharacterScreenState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _buildRaceSection()), + Expanded( + child: RaceSelectionSection( + races: _races, + selectedIndex: _selectedRaceIndex, + scrollController: _raceScrollController, + onSelected: (index) => + setState(() => _selectedRaceIndex = index), + ), + ), const SizedBox(width: 16), - Expanded(child: _buildKlassSection()), + Expanded( + child: ClassSelectionSection( + klasses: _klasses, + selectedIndex: _selectedKlassIndex, + scrollController: _klassScrollController, + onSelected: (index) => + setState(() => _selectedKlassIndex = index), + ), + ), ], ), const SizedBox(height: 16), @@ -476,54 +486,7 @@ class _NewCharacterScreenState extends State { // 디버그 전용: 치트 모드 토글 (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, - ), - ), - ), - ], - ), - ), - ), + _buildDebugCheatToggle(context), ], ], ), @@ -532,488 +495,45 @@ class _NewCharacterScreenState extends State { ); } - 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 = []; - 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, - }; - } - - 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 = []; - 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; - + /// 디버그 치트 토글 위젯 + Widget _buildDebugCheatToggle(BuildContext context) { return GestureDetector( - onTap: canUndo ? _onUnroll : null, + onTap: () => setState(() => _cheatsEnabled = !_cheatsEnabled), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: canUndo - ? RetroColors.panelBgLight - : RetroColors.panelBg.withValues(alpha: 0.5), + color: _cheatsEnabled + ? RetroColors.hpRed.withValues(alpha: 0.3) + : RetroColors.panelBg, border: Border.all( - color: canUndo ? RetroColors.panelBorderInner : RetroColors.panelBg, + color: _cheatsEnabled + ? RetroColors.hpRed + : RetroColors.panelBorderInner, width: 2, ), ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, 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, + _cheatsEnabled ? Icons.bug_report : Icons.bug_report_outlined, + size: 16, + color: _cheatsEnabled + ? RetroColors.hpRed + : 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, + 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, + ), ), ), ], diff --git a/lib/src/features/new_character/widgets/class_selection_section.dart b/lib/src/features/new_character/widgets/class_selection_section.dart new file mode 100644 index 0000000..5884fcb --- /dev/null +++ b/lib/src/features/new_character/widgets/class_selection_section.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; +import 'package:asciineverdie/l10n/app_localizations.dart'; +import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart'; +import 'package:asciineverdie/src/core/model/class_traits.dart'; +import 'package:asciineverdie/src/core/model/race_traits.dart' show StatType; +import 'package:asciineverdie/src/shared/retro_colors.dart'; +import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; + +/// 직업 선택 섹션 +class ClassSelectionSection extends StatelessWidget { + const ClassSelectionSection({ + super.key, + required this.klasses, + required this.selectedIndex, + required this.scrollController, + required this.onSelected, + }); + + final List klasses; + final int selectedIndex; + final ScrollController scrollController; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return RetroPanel( + title: L10n.of(context).classSection, + child: SizedBox( + height: 300, + child: ListView.builder( + controller: scrollController, + itemCount: klasses.length, + itemBuilder: (context, index) { + final isSelected = index == selectedIndex; + final klass = klasses[index]; + return GestureDetector( + onTap: () => onSelected(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) _ClassInfo(klass: klass), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +/// 클래스 정보 표시 위젯 +class _ClassInfo extends StatelessWidget { + const _ClassInfo({required this.klass}); + + final ClassTraits klass; + + 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, + }; + } + + String _translatePassive(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, + }; + } + + @override + Widget build(BuildContext context) { + 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) => _translatePassive(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), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/new_character/widgets/name_input_section.dart b/lib/src/features/new_character/widgets/name_input_section.dart new file mode 100644 index 0000000..1d5aa6c --- /dev/null +++ b/lib/src/features/new_character/widgets/name_input_section.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show FilteringTextInputFormatter; + +import 'package:asciineverdie/l10n/app_localizations.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; +import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; + +/// 캐릭터 이름 입력 섹션 +class NameInputSection extends StatelessWidget { + const NameInputSection({ + super.key, + required this.controller, + required this.onGenerateName, + }); + + final TextEditingController controller; + final VoidCallback onGenerateName; + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + return RetroPanel( + title: l10n.nameTitle, + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + 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), + ], + ), + ); + } +} diff --git a/lib/src/features/new_character/widgets/race_selection_section.dart b/lib/src/features/new_character/widgets/race_selection_section.dart new file mode 100644 index 0000000..7c1f36a --- /dev/null +++ b/lib/src/features/new_character/widgets/race_selection_section.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; +import 'package:asciineverdie/l10n/app_localizations.dart'; +import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart'; +import 'package:asciineverdie/src/core/model/race_traits.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; +import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; + +/// 종족 선택 섹션 +class RaceSelectionSection extends StatelessWidget { + const RaceSelectionSection({ + super.key, + required this.races, + required this.selectedIndex, + required this.scrollController, + required this.onSelected, + }); + + final List races; + final int selectedIndex; + final ScrollController scrollController; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return RetroPanel( + title: L10n.of(context).raceTitle, + child: SizedBox( + height: 300, + child: ListView.builder( + controller: scrollController, + itemCount: races.length, + itemBuilder: (context, index) { + final isSelected = index == selectedIndex; + final race = races[index]; + return GestureDetector( + onTap: () => onSelected(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) _RaceInfo(race: race), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +/// 종족 정보 표시 위젯 +class _RaceInfo extends StatelessWidget { + const _RaceInfo({required this.race}); + + final RaceTraits race; + + 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, + }; + } + + String _translatePassive(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, + }; + } + + @override + Widget build(BuildContext context) { + 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) => _translatePassive(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), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/new_character/widgets/stats_section.dart b/lib/src/features/new_character/widgets/stats_section.dart new file mode 100644 index 0000000..f779f5c --- /dev/null +++ b/lib/src/features/new_character/widgets/stats_section.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; + +import 'package:asciineverdie/l10n/app_localizations.dart'; +import 'package:asciineverdie/src/core/engine/iap_service.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; +import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; + +/// 능력치 표시 섹션 +class StatsSection extends StatelessWidget { + const StatsSection({ + super.key, + required this.str, + required this.con, + required this.dex, + required this.intelligence, + required this.wis, + required this.cha, + required this.canRoll, + required this.canUndo, + required this.rollsRemaining, + required this.availableUndos, + required this.onRoll, + required this.onUndo, + }); + + final int str; + final int con; + final int dex; + final int intelligence; + final int wis; + final int cha; + final bool canRoll; + final bool canUndo; + final int rollsRemaining; + final int availableUndos; + final VoidCallback onRoll; + final VoidCallback onUndo; + + int get _total => str + con + dex + intelligence + 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; + } + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + return RetroPanel( + title: l10n.statsTitle, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 스탯 그리드 + Row( + children: [ + Expanded(child: _StatTile(label: l10n.statStr, value: str)), + Expanded(child: _StatTile(label: l10n.statCon, value: con)), + Expanded(child: _StatTile(label: l10n.statDex, value: dex)), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _StatTile(label: l10n.statInt, value: intelligence), + ), + Expanded(child: _StatTile(label: l10n.statWis, value: wis)), + Expanded(child: _StatTile(label: l10n.statCha, value: 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: [ + _UndoButton( + canUndo: canUndo, + onPressed: onUndo, + ), + const SizedBox(width: 16), + _RollButton( + canRoll: canRoll, + rollsRemaining: rollsRemaining, + onPressed: onRoll, + ), + ], + ), + // 남은 횟수 표시 + Padding( + padding: const EdgeInsets.only(top: 8), + child: Center( + child: Text( + canUndo + ? 'Undo: $availableUndos | Rolls: $rollsRemaining/5' + : 'Rolls: $rollsRemaining/5', + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.textDisabled, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 스탯 타일 위젯 +class _StatTile extends StatelessWidget { + const _StatTile({required this.label, required this.value}); + + final String label; + final int value; + + @override + Widget build(BuildContext context) { + 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, + ), + ), + ], + ), + ); + } +} + +/// 되돌리기 버튼 +class _UndoButton extends StatelessWidget { + const _UndoButton({required this.canUndo, required this.onPressed}); + + final bool canUndo; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + final isPaidUser = IAPService.instance.isAdRemovalPurchased; + + return GestureDetector( + onTap: canUndo ? onPressed : 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, + ), + ), + ], + ), + ), + ); + } +} + +/// 굴리기 버튼 +class _RollButton extends StatelessWidget { + const _RollButton({ + required this.canRoll, + required this.rollsRemaining, + required this.onPressed, + }); + + final bool canRoll; + final int rollsRemaining; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + + return GestureDetector( + onTap: onPressed, + 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()} ($rollsRemaining)' + : l10n.roll.toUpperCase(), + style: const TextStyle( + fontFamily: 'PressStart2P', + fontSize: 11, + color: RetroColors.gold, + ), + ), + ], + ), + ), + ); + } +}