- _SkillRow → _SkillTile (ExpansionTile 기반) - 타입별 스탯 그리드 표시 (공격/회복/버프/디버프) - 메타 행: 티어(로마숫자), 타입명, 속성명 - RetroColors 적용, 타입/속성별 컬러 구분 - 한/영/일 3개 언어 지원
585 lines
16 KiB
Dart
585 lines
16 KiB
Dart
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,
|
||
};
|
||
}
|
||
}
|