feat(game): 포션 시스템 및 UI 패널 추가
- 포션 시스템 구현 (PotionService, Potion 모델) - 포션 인벤토리 패널 위젯 - 활성 버프 패널 위젯 - 장비 스탯 패널 위젯 - 스킬 시스템 확장 - 일본어 번역 추가 - 전투 이벤트/상태 모델 개선
This commit is contained in:
456
lib/src/features/game/widgets/equipment_stats_panel.dart
Normal file
456
lib/src/features/game/widgets/equipment_stats_panel.dart
Normal file
@@ -0,0 +1,456 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:askiineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
import 'package:askiineverdie/src/core/model/item_stats.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);
|
||||
|
||||
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(
|
||||
item.name,
|
||||
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) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: _SlotIcon(slot: slot, isEmpty: true),
|
||||
title: Text(
|
||||
'[${_getSlotName(slot)}] (empty)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey.shade600,
|
||||
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,
|
||||
};
|
||||
|
||||
return Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isEmpty ? Colors.grey.shade400 : Colors.grey.shade700,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 점수 배지
|
||||
class _ScoreBadge extends StatelessWidget {
|
||||
const _ScoreBadge({required this.score});
|
||||
|
||||
final int score;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueGrey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$score',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blueGrey.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 장비 점수 총합 헤더
|
||||
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) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.blueGrey.shade700,
|
||||
Colors.blueGrey.shade600,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 장비 아이콘
|
||||
const Icon(
|
||||
Icons.shield,
|
||||
size: 20,
|
||||
color: Colors.white70,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 총합 점수
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Equipment Score',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalScore',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 장착 현황
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$equippedCount / $totalSlots',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 스탯 그리드
|
||||
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('ATK', '+${stats.atk}'));
|
||||
if (stats.magAtk > 0) entries.add(_StatEntry('MATK', '+${stats.magAtk}'));
|
||||
if (stats.criRate > 0) {
|
||||
entries.add(_StatEntry('CRI', '${(stats.criRate * 100).toStringAsFixed(1)}%'));
|
||||
}
|
||||
if (stats.parryRate > 0) {
|
||||
entries.add(_StatEntry('PARRY', '${(stats.parryRate * 100).toStringAsFixed(1)}%'));
|
||||
}
|
||||
|
||||
// 방어 스탯
|
||||
if (stats.def > 0) entries.add(_StatEntry('DEF', '+${stats.def}'));
|
||||
if (stats.magDef > 0) entries.add(_StatEntry('MDEF', '+${stats.magDef}'));
|
||||
if (stats.blockRate > 0) {
|
||||
entries.add(_StatEntry('BLOCK', '${(stats.blockRate * 100).toStringAsFixed(1)}%'));
|
||||
}
|
||||
if (stats.evasion > 0) {
|
||||
entries.add(_StatEntry('EVA', '${(stats.evasion * 100).toStringAsFixed(1)}%'));
|
||||
}
|
||||
|
||||
// 자원 스탯
|
||||
if (stats.hpBonus > 0) entries.add(_StatEntry('HP', '+${stats.hpBonus}'));
|
||||
if (stats.mpBonus > 0) entries.add(_StatEntry('MP', '+${stats.mpBonus}'));
|
||||
|
||||
// 능력치 보너스
|
||||
if (stats.strBonus > 0) entries.add(_StatEntry('STR', '+${stats.strBonus}'));
|
||||
if (stats.conBonus > 0) entries.add(_StatEntry('CON', '+${stats.conBonus}'));
|
||||
if (stats.dexBonus > 0) entries.add(_StatEntry('DEX', '+${stats.dexBonus}'));
|
||||
if (stats.intBonus > 0) entries.add(_StatEntry('INT', '+${stats.intBonus}'));
|
||||
if (stats.wisBonus > 0) entries.add(_StatEntry('WIS', '+${stats.wisBonus}'));
|
||||
if (stats.chaBonus > 0) entries.add(_StatEntry('CHA', '+${stats.chaBonus}'));
|
||||
|
||||
// 무기 공속
|
||||
if (slot == EquipmentSlot.weapon && stats.attackSpeed > 0) {
|
||||
entries.add(_StatEntry('SPEED', '${stats.attackSpeed}ms'));
|
||||
}
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return const Text(
|
||||
'No bonus stats',
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${entry.label}: ',
|
||||
style: TextStyle(fontSize: 9, color: Colors.grey.shade600),
|
||||
),
|
||||
Text(
|
||||
entry.value,
|
||||
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 아이템 메타 정보 행
|
||||
class _ItemMetaRow extends StatelessWidget {
|
||||
const _ItemMetaRow({required this.item});
|
||||
|
||||
final EquipmentItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rarityName = item.rarity.name.toUpperCase();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
'Lv.${item.level}',
|
||||
style: const TextStyle(fontSize: 9, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
rarityName,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: _getRarityColor(item.rarity),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Wt.${item.weight}',
|
||||
style: const TextStyle(fontSize: 9, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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 => 'Weapon',
|
||||
EquipmentSlot.shield => 'Shield',
|
||||
EquipmentSlot.helm => 'Helm',
|
||||
EquipmentSlot.hauberk => 'Hauberk',
|
||||
EquipmentSlot.brassairts => 'Brassairts',
|
||||
EquipmentSlot.vambraces => 'Vambraces',
|
||||
EquipmentSlot.gauntlets => 'Gauntlets',
|
||||
EquipmentSlot.gambeson => 'Gambeson',
|
||||
EquipmentSlot.cuisses => 'Cuisses',
|
||||
EquipmentSlot.greaves => 'Greaves',
|
||||
EquipmentSlot.sollerets => 'Sollerets',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user