From 58cc1fddb5fd10ecf00600f33efb21c887341ed7 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 15 Jan 2026 21:34:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EC=8A=A4=ED=82=AC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20ExpansionTile=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _SkillRow → _SkillTile (ExpansionTile 기반) - 타입별 스탯 그리드 표시 (공격/회복/버프/디버프) - 메타 행: 티어(로마숫자), 타입명, 속성명 - RetroColors 적용, 타입/속성별 컬러 구분 - 한/영/일 3개 언어 지원 --- lib/src/features/game/pages/skills_page.dart | 458 ++++++++++++++++++- 1 file changed, 440 insertions(+), 18 deletions(-) diff --git a/lib/src/features/game/pages/skills_page.dart b/lib/src/features/game/pages/skills_page.dart index 04a8e0f..ac1567f 100644 --- a/lib/src/features/game/pages/skills_page.dart +++ b/lib/src/features/game/pages/skills_page.dart @@ -7,6 +7,7 @@ import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/skill.dart'; import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart'; +import 'package:asciineverdie/src/shared/retro_colors.dart'; /// 스킬 페이지 (캐로셀) /// @@ -72,7 +73,7 @@ class SkillsPage extends StatelessWidget { return ListView.builder( itemCount: skillBook.skills.length, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), itemBuilder: (context, index) { final skillEntry = skillBook.skills[index]; final skill = SkillData.getSkillBySpellName(skillEntry.name); @@ -86,7 +87,7 @@ class SkillsPage extends StatelessWidget { skillState != null && !skillState.isReady(skillSystem.elapsedMs, skill!.cooldownMs); - return _SkillRow( + return _SkillTile( skillName: skillName, rank: skillEntry.rank, skill: skill, @@ -97,9 +98,9 @@ class SkillsPage extends StatelessWidget { } } -/// 스킬 행 위젯 -class _SkillRow extends StatelessWidget { - const _SkillRow({ +/// 스킬 타일 위젯 (ExpansionTile 기반) +class _SkillTile extends StatelessWidget { + const _SkillTile({ required this.skillName, required this.rank, required this.skill, @@ -111,16 +112,83 @@ class _SkillRow extends StatelessWidget { final Skill? skill; final bool isOnCooldown; + @override + Widget build(BuildContext context) { + // 스킬 데이터가 없으면 단순 행으로 표시 + if (skill == null) { + return _SimpleSkillRow( + skillName: skillName, + rank: rank, + isOnCooldown: isOnCooldown, + ); + } + + final typeColor = _getTypeColor(skill!.type); + + return ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 8), + childrenPadding: const EdgeInsets.only(left: 16, right: 8, bottom: 8), + dense: true, + title: Row( + children: [ + _SkillTypeIcon(type: skill!.type), + const SizedBox(width: 8), + Expanded( + child: Text( + skillName, + style: TextStyle( + fontSize: 18, + color: isOnCooldown ? Colors.grey : typeColor, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isOnCooldown) + const Padding( + padding: EdgeInsets.only(right: 8), + child: Icon(Icons.hourglass_empty, size: 14, color: Colors.orange), + ), + _RankBadge(rank: rank), + ], + ), + children: [ + _SkillStatsGrid(skill: skill!), + const SizedBox(height: 4), + _SkillMetaRow(skill: skill!), + ], + ); + } + + Color _getTypeColor(SkillType type) { + return switch (type) { + SkillType.attack => Colors.red.shade300, + SkillType.heal => Colors.green.shade300, + SkillType.buff => Colors.blue.shade300, + SkillType.debuff => Colors.purple.shade300, + }; + } +} + +/// 스킬 데이터가 없는 경우의 단순 행 +class _SimpleSkillRow extends StatelessWidget { + const _SimpleSkillRow({ + required this.skillName, + required this.rank, + required this.isOnCooldown, + }); + + final String skillName; + final String rank; + final bool isOnCooldown; + @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), + padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8), child: Row( children: [ - // 스킬 타입 아이콘 - _buildTypeIcon(), - const SizedBox(width: 8), - // 스킬 이름 + const SizedBox(width: 24), Expanded( child: Text( skillName, @@ -131,11 +199,9 @@ class _SkillRow extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - // 쿨타임 표시 if (isOnCooldown) const Icon(Icons.hourglass_empty, size: 14, color: Colors.orange), const SizedBox(width: 8), - // 랭크 Text( rank, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), @@ -144,19 +210,375 @@ class _SkillRow extends StatelessWidget { ), ); } +} - Widget _buildTypeIcon() { - if (skill == null) { - return const SizedBox(width: 16); - } +/// 스킬 타입 아이콘 +class _SkillTypeIcon extends StatelessWidget { + const _SkillTypeIcon({required this.type}); - final (IconData icon, Color color) = switch (skill!.type) { + final SkillType type; + + @override + Widget build(BuildContext context) { + final (IconData icon, Color color) = switch (type) { SkillType.attack => (Icons.flash_on, Colors.red), SkillType.heal => (Icons.favorite, Colors.green), SkillType.buff => (Icons.arrow_upward, Colors.blue), SkillType.debuff => (Icons.arrow_downward, Colors.purple), }; - return Icon(icon, size: 16, color: color); + return Icon(icon, size: 18, color: color); + } +} + +/// 랭크 배지 +class _RankBadge extends StatelessWidget { + const _RankBadge({required this.rank}); + + final String rank; + + @override + Widget build(BuildContext context) { + final gold = RetroColors.goldOf(context); + final surface = RetroColors.surfaceOf(context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: surface, + border: Border.all(color: gold, width: 1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + rank, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: gold, + ), + ), + ); + } +} + +/// 스킬 스탯 그리드 (타입별 분기) +class _SkillStatsGrid extends StatelessWidget { + const _SkillStatsGrid({required this.skill}); + + final Skill skill; + + @override + Widget build(BuildContext context) { + final entries = <_StatEntry>[]; + + // 공통: MP, 쿨타임 + entries.add(_StatEntry(l10n.skillMpCost, '${skill.mpCost}')); + entries.add(_StatEntry( + l10n.skillCooldown, + '${(skill.cooldownMs / 1000).toStringAsFixed(1)}${l10n.skillSeconds}', + )); + + // 타입별 스탯 추가 + switch (skill.type) { + case SkillType.attack: + _addAttackStats(entries); + case SkillType.heal: + _addHealStats(entries); + case SkillType.buff: + _addBuffStats(entries); + case SkillType.debuff: + _addDebuffStats(entries); + } + + return Wrap( + spacing: 8, + runSpacing: 4, + children: entries.map((e) => _StatChip(entry: e)).toList(), + ); + } + + void _addAttackStats(List<_StatEntry> entries) { + // 위력 (power × multiplier) + final power = (skill.power * skill.damageMultiplier).round(); + entries.add(_StatEntry(l10n.skillPower, '$power')); + + // 타격 횟수 (1보다 클 때만) + if (skill.hitCount > 1) { + entries.add(_StatEntry(l10n.skillHits, '${skill.hitCount}')); + } + + // DOT 정보 + if (skill.isDot && skill.baseDotDamage != null) { + final dotDps = skill.baseDotDamage! * + (skill.baseDotDurationMs! / skill.baseDotTickMs!); + entries.add(_StatEntry(l10n.skillDot, '${dotDps.round()}')); + } + + // HP 흡수 + if (skill.lifestealPercent > 0) { + entries.add(_StatEntry( + l10n.skillLifesteal, + '${(skill.lifestealPercent * 100).round()}%', + )); + } + + // 방어 무시 + if (skill.targetDefReduction > 0) { + entries.add(_StatEntry( + l10n.skillDefPen, + '${(skill.targetDefReduction * 100).round()}%', + )); + } + + // 자해 데미지 + if (skill.selfDamagePercent > 0) { + entries.add(_StatEntry( + l10n.skillSelfDmg, + '${(skill.selfDamagePercent * 100).round()}%', + )); + } + } + + void _addHealStats(List<_StatEntry> entries) { + // 고정 회복 + if (skill.healAmount > 0) { + entries.add(_StatEntry(l10n.skillHealFixed, '+${skill.healAmount}')); + } + + // % 회복 + if (skill.healPercent > 0) { + entries.add(_StatEntry( + l10n.skillHealPercent, + '${(skill.healPercent * 100).round()}%', + )); + } + + // MP 회복 + if (skill.mpHealAmount > 0) { + entries.add(_StatEntry(l10n.skillMpHeal, '+${skill.mpHealAmount}')); + } + + // 부가 버프 + if (skill.buff != null) { + final buff = skill.buff!; + entries.add(_StatEntry( + l10n.skillBuffDuration, + '${(buff.durationMs / 1000).round()}${l10n.skillSeconds}', + )); + } + } + + void _addBuffStats(List<_StatEntry> entries) { + if (skill.buff == null) return; + final buff = skill.buff!; + + // 지속시간 + entries.add(_StatEntry( + l10n.skillBuffDuration, + '${(buff.durationMs / 1000).round()}${l10n.skillSeconds}', + )); + + // 각 보정치 + if (buff.atkModifier != 0) { + final sign = buff.atkModifier > 0 ? '+' : ''; + entries.add(_StatEntry( + l10n.skillAtkMod, + '$sign${(buff.atkModifier * 100).round()}%', + )); + } + if (buff.defModifier != 0) { + final sign = buff.defModifier > 0 ? '+' : ''; + entries.add(_StatEntry( + l10n.skillDefMod, + '$sign${(buff.defModifier * 100).round()}%', + )); + } + if (buff.criRateModifier != 0) { + final sign = buff.criRateModifier > 0 ? '+' : ''; + entries.add(_StatEntry( + l10n.skillCriMod, + '$sign${(buff.criRateModifier * 100).round()}%', + )); + } + if (buff.evasionModifier != 0) { + final sign = buff.evasionModifier > 0 ? '+' : ''; + entries.add(_StatEntry( + l10n.skillEvaMod, + '$sign${(buff.evasionModifier * 100).round()}%', + )); + } + } + + void _addDebuffStats(List<_StatEntry> entries) { + if (skill.buff == null) return; + final buff = skill.buff!; + + // 지속시간 + entries.add(_StatEntry( + l10n.skillBuffDuration, + '${(buff.durationMs / 1000).round()}${l10n.skillSeconds}', + )); + + // 디버프 효과 (보통 음수) + if (buff.atkModifier != 0) { + entries.add(_StatEntry( + l10n.skillAtkMod, + '${(buff.atkModifier * 100).round()}%', + )); + } + if (buff.defModifier != 0) { + entries.add(_StatEntry( + l10n.skillDefMod, + '${(buff.defModifier * 100).round()}%', + )); + } + } +} + +/// 스탯 엔트리 +class _StatEntry { + const _StatEntry(this.label, this.value); + final String label; + final String value; +} + +/// 스탯 칩 (레트로 스타일) +class _StatChip extends StatelessWidget { + const _StatChip({required this.entry}); + + final _StatEntry entry; + + @override + Widget build(BuildContext context) { + final surface = RetroColors.surfaceOf(context); + final border = RetroColors.borderOf(context); + final textMuted = RetroColors.textMutedOf(context); + final textPrimary = RetroColors.textPrimaryOf(context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: surface, + border: Border.all(color: border, width: 1), + borderRadius: BorderRadius.circular(2), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${entry.label}: ', + style: TextStyle(fontSize: 15, color: textMuted), + ), + Text( + entry.value, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: textPrimary, + ), + ), + ], + ), + ); + } +} + +/// 스킬 메타 정보 행 +class _SkillMetaRow extends StatelessWidget { + const _SkillMetaRow({required this.skill}); + + final Skill skill; + + @override + Widget build(BuildContext context) { + final textMuted = RetroColors.textMutedOf(context); + final typeColor = _getTypeColor(skill.type); + + return Row( + children: [ + // 티어 + Text( + '${l10n.skillTier} ${_tierToRoman(skill.tier)}', + style: TextStyle(fontSize: 15, color: textMuted), + ), + const SizedBox(width: 8), + // 타입 + Text( + _getTypeName(skill.type), + style: TextStyle( + fontSize: 15, + color: typeColor, + fontWeight: FontWeight.bold, + ), + ), + // 속성 (있는 경우) + if (skill.element != null) ...[ + const SizedBox(width: 8), + Text( + _getElementName(skill.element!), + style: TextStyle( + fontSize: 15, + color: _getElementColor(skill.element!), + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ); + } + + Color _getTypeColor(SkillType type) { + return switch (type) { + SkillType.attack => Colors.red, + SkillType.heal => Colors.green, + SkillType.buff => Colors.blue, + SkillType.debuff => Colors.purple, + }; + } + + Color _getElementColor(SkillElement element) { + return switch (element) { + SkillElement.logic => Colors.cyan, + SkillElement.memory => Colors.teal, + SkillElement.network => Colors.indigo, + SkillElement.fire => Colors.orange, + SkillElement.ice => Colors.lightBlue, + SkillElement.lightning => Colors.yellow.shade700, + SkillElement.voidElement => Colors.grey, + SkillElement.chaos => Colors.pink, + }; + } + + String _tierToRoman(int tier) { + return switch (tier) { + 1 => 'I', + 2 => 'II', + 3 => 'III', + 4 => 'IV', + 5 => 'V', + _ => '$tier', + }; + } + + String _getTypeName(SkillType type) { + return switch (type) { + SkillType.attack => l10n.skillTypeAttack, + SkillType.heal => l10n.skillTypeHeal, + SkillType.buff => l10n.skillTypeBuff, + SkillType.debuff => l10n.skillTypeDebuff, + }; + } + + String _getElementName(SkillElement element) { + return switch (element) { + SkillElement.logic => l10n.elementLogic, + SkillElement.memory => l10n.elementMemory, + SkillElement.network => l10n.elementNetwork, + SkillElement.fire => l10n.elementFire, + SkillElement.ice => l10n.elementIce, + SkillElement.lightning => l10n.elementLightning, + SkillElement.voidElement => l10n.elementVoid, + SkillElement.chaos => l10n.elementChaos, + }; } }