Files
asciinevrdie/lib/src/features/game/pages/skills_page.dart
JiWoong Sul 58cc1fddb5 feat(ui): 스킬 상세 정보 ExpansionTile 구현
- _SkillRow → _SkillTile (ExpansionTile 기반)
- 타입별 스탯 그리드 표시 (공격/회복/버프/디버프)
- 메타 행: 티어(로마숫자), 타입명, 속성명
- RetroColors 적용, 타입/속성별 컬러 구분
- 한/영/일 3개 언어 지원
2026-01-15 21:34:24 +09:00

585 lines
16 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 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
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';
/// 스킬 페이지 (캐로셀)
///
/// SkillBook 기반 스킬 목록과 활성 버프 표시.
class SkillsPage extends StatelessWidget {
const SkillsPage({
super.key,
required this.skillBook,
required this.skillSystem,
});
final SkillBook skillBook;
final SkillSystemState skillSystem;
@override
Widget build(BuildContext context) {
final localizations = L10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 스킬 목록
_buildSectionHeader(context, localizations.spellBook),
Expanded(flex: 3, child: _buildSkillsList(context)),
// 활성 버프
_buildSectionHeader(context, l10n.uiBuffs),
Expanded(
flex: 2,
child: ActiveBuffPanel(
activeBuffs: skillSystem.activeBuffs,
currentMs: skillSystem.elapsedMs,
),
),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
color: Theme.of(context).colorScheme.primaryContainer,
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
);
}
Widget _buildSkillsList(BuildContext context) {
if (skillBook.skills.isEmpty) {
return Center(
child: Text(
L10n.of(context).noSpellsYet,
style: const TextStyle(fontSize: 17, color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: skillBook.skills.length,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
itemBuilder: (context, index) {
final skillEntry = skillBook.skills[index];
final skill = SkillData.getSkillBySpellName(skillEntry.name);
final skillName = GameDataL10n.getSpellName(context, skillEntry.name);
// 쿨타임 상태 확인
final skillState = skill != null
? skillSystem.getSkillState(skill.id)
: null;
final isOnCooldown =
skillState != null &&
!skillState.isReady(skillSystem.elapsedMs, skill!.cooldownMs);
return _SkillTile(
skillName: skillName,
rank: skillEntry.rank,
skill: skill,
isOnCooldown: isOnCooldown,
);
},
);
}
}
/// 스킬 타일 위젯 (ExpansionTile 기반)
class _SkillTile extends StatelessWidget {
const _SkillTile({
required this.skillName,
required this.rank,
required this.skill,
required this.isOnCooldown,
});
final String skillName;
final String rank;
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, horizontal: 8),
child: Row(
children: [
const SizedBox(width: 24),
Expanded(
child: Text(
skillName,
style: TextStyle(
fontSize: 18,
color: isOnCooldown ? Colors.grey : null,
),
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),
),
],
),
);
}
}
/// 스킬 타입 아이콘
class _SkillTypeIcon extends StatelessWidget {
const _SkillTypeIcon({required this.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: 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,
};
}
}