527 lines
15 KiB
Dart
527 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
|
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
|
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
|
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
|
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
|
|
|
/// 장비 스탯 표시 패널
|
|
///
|
|
/// 각 장비 슬롯의 아이템과 스탯을 확장 가능한 형태로 표시.
|
|
/// 접힌 상태: 슬롯명 + 아이템명
|
|
/// 펼친 상태: 전체 스탯 및 점수
|
|
class EquipmentStatsPanel extends StatelessWidget {
|
|
const EquipmentStatsPanel({
|
|
super.key,
|
|
required this.equipment,
|
|
this.initiallyExpanded = false,
|
|
});
|
|
|
|
final Equipment equipment;
|
|
final bool initiallyExpanded;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final totalScore = _calculateTotalScore();
|
|
final equippedCount = equipment.items.where((e) => e.isNotEmpty).length;
|
|
|
|
return ListView.builder(
|
|
// +1 for header
|
|
itemCount: equipment.items.length + 1,
|
|
padding: const EdgeInsets.all(4),
|
|
itemBuilder: (context, index) {
|
|
// 첫 번째 아이템은 총합 헤더
|
|
if (index == 0) {
|
|
return _TotalScoreHeader(
|
|
totalScore: totalScore,
|
|
equippedCount: equippedCount,
|
|
totalSlots: equipment.items.length,
|
|
);
|
|
}
|
|
|
|
final item = equipment.items[index - 1];
|
|
return _EquipmentSlotTile(
|
|
item: item,
|
|
initiallyExpanded: initiallyExpanded,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 모든 장비의 점수 합산
|
|
int _calculateTotalScore() {
|
|
var total = 0;
|
|
for (final item in equipment.items) {
|
|
if (item.isNotEmpty) {
|
|
total += ItemService.calculateEquipmentScore(item);
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
}
|
|
|
|
/// 개별 장비 슬롯 타일
|
|
class _EquipmentSlotTile extends StatelessWidget {
|
|
const _EquipmentSlotTile({
|
|
required this.item,
|
|
this.initiallyExpanded = false,
|
|
});
|
|
|
|
final EquipmentItem item;
|
|
final bool initiallyExpanded;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (item.isEmpty) {
|
|
return _EmptySlotTile(slot: item.slot);
|
|
}
|
|
|
|
final score = ItemService.calculateEquipmentScore(item);
|
|
final rarityColor = _getRarityColor(item.rarity);
|
|
// 슬롯 인덱스로 아이템 이름 번역 (0: weapon, 1: shield, 2+: armor)
|
|
final translatedName = GameDataL10n.translateEquipString(
|
|
context,
|
|
item.name,
|
|
item.slot.index,
|
|
);
|
|
|
|
return ExpansionTile(
|
|
initiallyExpanded: initiallyExpanded,
|
|
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
|
|
childrenPadding: const EdgeInsets.only(left: 16, right: 8, bottom: 8),
|
|
dense: true,
|
|
title: Row(
|
|
children: [
|
|
_SlotIcon(slot: item.slot),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
translatedName,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: rarityColor,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
_ScoreBadge(score: score),
|
|
],
|
|
),
|
|
children: [
|
|
_StatsGrid(stats: item.stats, slot: item.slot),
|
|
const SizedBox(height: 4),
|
|
_ItemMetaRow(item: item),
|
|
],
|
|
);
|
|
}
|
|
|
|
Color _getRarityColor(ItemRarity rarity) {
|
|
return switch (rarity) {
|
|
ItemRarity.common => Colors.grey,
|
|
ItemRarity.uncommon => Colors.green,
|
|
ItemRarity.rare => Colors.blue,
|
|
ItemRarity.epic => Colors.purple,
|
|
ItemRarity.legendary => Colors.orange,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// 빈 슬롯 타일 (레트로 스타일)
|
|
class _EmptySlotTile extends StatelessWidget {
|
|
const _EmptySlotTile({required this.slot});
|
|
|
|
final EquipmentSlot slot;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final textMuted = RetroColors.textMutedOf(context);
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
|
leading: _SlotIcon(slot: slot, isEmpty: true),
|
|
title: Text(
|
|
'[${_getSlotName(slot)}] ${l10n.uiEmpty}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: textMuted,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 슬롯 아이콘 (레트로 스타일)
|
|
class _SlotIcon extends StatelessWidget {
|
|
const _SlotIcon({required this.slot, this.isEmpty = false});
|
|
|
|
final EquipmentSlot slot;
|
|
final bool isEmpty;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final icon = switch (slot) {
|
|
EquipmentSlot.weapon => Icons.gavel,
|
|
EquipmentSlot.shield => Icons.shield,
|
|
EquipmentSlot.helm => Icons.sports_martial_arts,
|
|
EquipmentSlot.hauberk => Icons.checkroom,
|
|
EquipmentSlot.brassairts => Icons.back_hand,
|
|
EquipmentSlot.vambraces => Icons.front_hand,
|
|
EquipmentSlot.gauntlets => Icons.pan_tool,
|
|
EquipmentSlot.gambeson => Icons.dry_cleaning,
|
|
EquipmentSlot.cuisses => Icons.airline_seat_legroom_normal,
|
|
EquipmentSlot.greaves => Icons.snowshoeing,
|
|
EquipmentSlot.sollerets => Icons.do_not_step,
|
|
};
|
|
|
|
final color = isEmpty
|
|
? RetroColors.textMutedOf(context)
|
|
: RetroColors.textSecondaryOf(context);
|
|
|
|
return Icon(icon, size: 16, color: color);
|
|
}
|
|
}
|
|
|
|
/// 점수 배지 (레트로 스타일)
|
|
class _ScoreBadge extends StatelessWidget {
|
|
const _ScoreBadge({required this.score});
|
|
|
|
final int score;
|
|
|
|
@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(
|
|
'$score',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
color: gold,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 장비 점수 총합 헤더 (레트로 스타일)
|
|
class _TotalScoreHeader extends StatelessWidget {
|
|
const _TotalScoreHeader({
|
|
required this.totalScore,
|
|
required this.equippedCount,
|
|
required this.totalSlots,
|
|
});
|
|
|
|
final int totalScore;
|
|
final int equippedCount;
|
|
final int totalSlots;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final gold = RetroColors.goldOf(context);
|
|
final goldDark = RetroColors.goldDarkOf(context);
|
|
final panelBg = RetroColors.panelBgOf(context);
|
|
final border = RetroColors.borderOf(context);
|
|
final textPrimary = RetroColors.textPrimaryOf(context);
|
|
final textSecondary = RetroColors.textSecondaryOf(context);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: panelBg,
|
|
border: Border(
|
|
top: BorderSide(color: gold, width: 2),
|
|
left: BorderSide(color: gold, width: 2),
|
|
bottom: BorderSide(color: goldDark, width: 2),
|
|
right: BorderSide(color: goldDark, width: 2),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// 장비 아이콘
|
|
Icon(Icons.shield, size: 20, color: gold),
|
|
const SizedBox(width: 8),
|
|
|
|
// 총합 점수
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.uiEquipmentScore,
|
|
style: TextStyle(fontSize: 10, color: textSecondary),
|
|
),
|
|
Text(
|
|
'$totalScore',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: gold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 장착 현황
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: border,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
'$equippedCount / $totalSlots',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
color: textPrimary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 스탯 그리드
|
|
class _StatsGrid extends StatelessWidget {
|
|
const _StatsGrid({required this.stats, required this.slot});
|
|
|
|
final ItemStats stats;
|
|
final EquipmentSlot slot;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final entries = <_StatEntry>[];
|
|
|
|
// 공격 스탯
|
|
if (stats.atk > 0) entries.add(_StatEntry(l10n.statAtk, '+${stats.atk}'));
|
|
if (stats.magAtk > 0) {
|
|
entries.add(_StatEntry(l10n.statMAtk, '+${stats.magAtk}'));
|
|
}
|
|
if (stats.criRate > 0) {
|
|
entries.add(
|
|
_StatEntry(
|
|
l10n.statCri,
|
|
'${(stats.criRate * 100).toStringAsFixed(1)}%',
|
|
),
|
|
);
|
|
}
|
|
if (stats.parryRate > 0) {
|
|
entries.add(
|
|
_StatEntry(
|
|
l10n.statParry,
|
|
'${(stats.parryRate * 100).toStringAsFixed(1)}%',
|
|
),
|
|
);
|
|
}
|
|
|
|
// 방어 스탯
|
|
if (stats.def > 0) entries.add(_StatEntry(l10n.statDef, '+${stats.def}'));
|
|
if (stats.magDef > 0) {
|
|
entries.add(_StatEntry(l10n.statMDef, '+${stats.magDef}'));
|
|
}
|
|
if (stats.blockRate > 0) {
|
|
entries.add(
|
|
_StatEntry(
|
|
l10n.statBlock,
|
|
'${(stats.blockRate * 100).toStringAsFixed(1)}%',
|
|
),
|
|
);
|
|
}
|
|
if (stats.evasion > 0) {
|
|
entries.add(
|
|
_StatEntry(
|
|
l10n.statEva,
|
|
'${(stats.evasion * 100).toStringAsFixed(1)}%',
|
|
),
|
|
);
|
|
}
|
|
|
|
// 자원 스탯
|
|
if (stats.hpBonus > 0) {
|
|
entries.add(_StatEntry(l10n.statHp, '+${stats.hpBonus}'));
|
|
}
|
|
if (stats.mpBonus > 0) {
|
|
entries.add(_StatEntry(l10n.statMp, '+${stats.mpBonus}'));
|
|
}
|
|
|
|
// 능력치 보너스
|
|
if (stats.strBonus > 0) {
|
|
entries.add(_StatEntry(l10n.statStr, '+${stats.strBonus}'));
|
|
}
|
|
if (stats.conBonus > 0) {
|
|
entries.add(_StatEntry(l10n.statCon, '+${stats.conBonus}'));
|
|
}
|
|
if (stats.dexBonus > 0) {
|
|
entries.add(_StatEntry(l10n.statDex, '+${stats.dexBonus}'));
|
|
}
|
|
if (stats.intBonus > 0) {
|
|
entries.add(_StatEntry(l10n.statInt, '+${stats.intBonus}'));
|
|
}
|
|
if (stats.wisBonus > 0) {
|
|
entries.add(_StatEntry(l10n.statWis, '+${stats.wisBonus}'));
|
|
}
|
|
if (stats.chaBonus > 0) {
|
|
entries.add(_StatEntry(l10n.statCha, '+${stats.chaBonus}'));
|
|
}
|
|
|
|
// 무기 공속
|
|
if (slot == EquipmentSlot.weapon && stats.attackSpeed > 0) {
|
|
entries.add(_StatEntry(l10n.statSpeed, '${stats.attackSpeed}ms'));
|
|
}
|
|
|
|
if (entries.isEmpty) {
|
|
return Text(
|
|
l10n.uiNoBonusStats,
|
|
style: TextStyle(fontSize: 10, color: RetroColors.textMutedOf(context)),
|
|
);
|
|
}
|
|
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children: entries.map((e) => _StatChip(entry: e)).toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 스탯 엔트리
|
|
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: 9, color: textMuted),
|
|
),
|
|
Text(
|
|
entry.value,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.bold,
|
|
color: textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 아이템 메타 정보 행 (레트로 스타일)
|
|
class _ItemMetaRow extends StatelessWidget {
|
|
const _ItemMetaRow({required this.item});
|
|
|
|
final EquipmentItem item;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final rarityName = _getTranslatedRarity(item.rarity);
|
|
final textMuted = RetroColors.textMutedOf(context);
|
|
|
|
return Row(
|
|
children: [
|
|
Text(
|
|
l10n.uiLevel(item.level),
|
|
style: TextStyle(fontSize: 9, color: textMuted),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
rarityName,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: _getRarityColor(item.rarity),
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.uiWeight(item.weight),
|
|
style: TextStyle(fontSize: 9, color: textMuted),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Color _getRarityColor(ItemRarity rarity) {
|
|
return switch (rarity) {
|
|
ItemRarity.common => Colors.grey,
|
|
ItemRarity.uncommon => Colors.green,
|
|
ItemRarity.rare => Colors.blue,
|
|
ItemRarity.epic => Colors.purple,
|
|
ItemRarity.legendary => Colors.orange,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// 슬롯 이름 반환
|
|
String _getSlotName(EquipmentSlot slot) {
|
|
return switch (slot) {
|
|
EquipmentSlot.weapon => l10n.slotWeapon,
|
|
EquipmentSlot.shield => l10n.slotShield,
|
|
EquipmentSlot.helm => l10n.slotHelm,
|
|
EquipmentSlot.hauberk => l10n.slotHauberk,
|
|
EquipmentSlot.brassairts => l10n.slotBrassairts,
|
|
EquipmentSlot.vambraces => l10n.slotVambraces,
|
|
EquipmentSlot.gauntlets => l10n.slotGauntlets,
|
|
EquipmentSlot.gambeson => l10n.slotGambeson,
|
|
EquipmentSlot.cuisses => l10n.slotCuisses,
|
|
EquipmentSlot.greaves => l10n.slotGreaves,
|
|
EquipmentSlot.sollerets => l10n.slotSollerets,
|
|
};
|
|
}
|
|
|
|
/// 희귀도 번역 반환
|
|
String _getTranslatedRarity(ItemRarity rarity) {
|
|
return switch (rarity) {
|
|
ItemRarity.common => l10n.rarityCommon,
|
|
ItemRarity.uncommon => l10n.rarityUncommon,
|
|
ItemRarity.rare => l10n.rarityRare,
|
|
ItemRarity.epic => l10n.rarityEpic,
|
|
ItemRarity.legendary => l10n.rarityLegendary,
|
|
};
|
|
}
|