feat(game): 포션 시스템 및 UI 패널 추가
- 포션 시스템 구현 (PotionService, Potion 모델) - 포션 인벤토리 패널 위젯 - 활성 버프 패널 위젯 - 장비 스탯 패널 위젯 - 스킬 시스템 확장 - 일본어 번역 추가 - 전투 이벤트/상태 모델 개선
This commit is contained in:
@@ -20,7 +20,10 @@ import 'package:askiineverdie/src/features/game/widgets/hp_mp_bar.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/skill_panel.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/equipment_stats_panel.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/potion_inventory_panel.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart';
|
||||
import 'package:askiineverdie/src/features/game/widgets/active_buff_panel.dart';
|
||||
|
||||
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
||||
///
|
||||
@@ -199,6 +202,18 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
'${event.skillName} activated!',
|
||||
CombatLogType.buff,
|
||||
),
|
||||
CombatEventType.dotTick => (
|
||||
'${event.skillName} ticks for ${event.damage} damage',
|
||||
CombatLogType.dotTick,
|
||||
),
|
||||
CombatEventType.playerPotion => (
|
||||
'${event.skillName}: +${event.healAmount} ${event.targetName}',
|
||||
CombatLogType.potion,
|
||||
),
|
||||
CombatEventType.potionDrop => (
|
||||
'Dropped: ${event.skillName}',
|
||||
CombatLogType.potionDrop,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -534,6 +549,15 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
// Phase 8: 스킬 (Skills with cooldown glow)
|
||||
_buildSectionHeader('Skills'),
|
||||
Expanded(flex: 2, child: SkillPanel(skillSystem: state.skillSystem)),
|
||||
|
||||
// 활성 버프 (Active Buffs)
|
||||
_buildSectionHeader('Buffs'),
|
||||
Expanded(
|
||||
child: ActiveBuffPanel(
|
||||
activeBuffs: state.skillSystem.activeBuffs,
|
||||
currentMs: state.skillSystem.elapsedMs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -549,12 +573,25 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
children: [
|
||||
_buildPanelHeader(l10n.equipment),
|
||||
|
||||
// Equipment 목록
|
||||
Expanded(flex: 2, child: _buildEquipmentList(state)),
|
||||
// Equipment 목록 (확장 가능 스탯 패널)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: EquipmentStatsPanel(equipment: state.equipment),
|
||||
),
|
||||
|
||||
// Inventory
|
||||
_buildPanelHeader(l10n.inventory),
|
||||
Expanded(flex: 2, child: _buildInventoryList(state)),
|
||||
Expanded(child: _buildInventoryList(state)),
|
||||
|
||||
// Potions (물약 인벤토리)
|
||||
_buildSectionHeader('Potions'),
|
||||
Expanded(
|
||||
child: PotionInventoryPanel(
|
||||
inventory: state.potionInventory,
|
||||
usedInBattle:
|
||||
state.progress.currentCombat?.usedPotionTypes ?? const {},
|
||||
),
|
||||
),
|
||||
|
||||
// Encumbrance 바
|
||||
_buildSectionHeader(l10n.encumbrance),
|
||||
@@ -729,58 +766,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEquipmentList(GameState state) {
|
||||
// 원본 Main.dfm Equips ListView - 11개 슬롯
|
||||
// (슬롯 레이블, 장비 이름, 슬롯 인덱스) 튜플
|
||||
final l10n = L10n.of(context);
|
||||
final equipment = [
|
||||
(l10n.equipWeapon, state.equipment.weapon, 0),
|
||||
(l10n.equipShield, state.equipment.shield, 1),
|
||||
(l10n.equipHelm, state.equipment.helm, 2),
|
||||
(l10n.equipHauberk, state.equipment.hauberk, 3),
|
||||
(l10n.equipBrassairts, state.equipment.brassairts, 4),
|
||||
(l10n.equipVambraces, state.equipment.vambraces, 5),
|
||||
(l10n.equipGauntlets, state.equipment.gauntlets, 6),
|
||||
(l10n.equipGambeson, state.equipment.gambeson, 7),
|
||||
(l10n.equipCuisses, state.equipment.cuisses, 8),
|
||||
(l10n.equipGreaves, state.equipment.greaves, 9),
|
||||
(l10n.equipSollerets, state.equipment.sollerets, 10),
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: equipment.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final equip = equipment[index];
|
||||
// 장비 이름 번역 (슬롯 인덱스 사용)
|
||||
final translatedName = equip.$2.isNotEmpty
|
||||
? GameDataL10n.translateEquipString(context, equip.$2, equip.$3)
|
||||
: '-';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(equip.$1, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translatedName,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInventoryList(GameState state) {
|
||||
final l10n = L10n.of(context);
|
||||
if (state.inventory.items.isEmpty) {
|
||||
|
||||
206
lib/src/features/game/widgets/active_buff_panel.dart
Normal file
206
lib/src/features/game/widgets/active_buff_panel.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:askiineverdie/src/core/model/skill.dart';
|
||||
|
||||
/// 활성 버프 패널 위젯
|
||||
///
|
||||
/// 현재 적용 중인 버프 목록과 남은 시간을 표시.
|
||||
class ActiveBuffPanel extends StatelessWidget {
|
||||
const ActiveBuffPanel({
|
||||
super.key,
|
||||
required this.activeBuffs,
|
||||
required this.currentMs,
|
||||
});
|
||||
|
||||
final List<ActiveBuff> activeBuffs;
|
||||
final int currentMs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (activeBuffs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No active buffs',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: activeBuffs.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final buff = activeBuffs[index];
|
||||
return _BuffRow(buff: buff, currentMs: currentMs);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개별 버프 행 위젯
|
||||
class _BuffRow extends StatelessWidget {
|
||||
const _BuffRow({
|
||||
required this.buff,
|
||||
required this.currentMs,
|
||||
});
|
||||
|
||||
final ActiveBuff buff;
|
||||
final int currentMs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remainingMs = buff.remainingDuration(currentMs);
|
||||
final remainingSec = (remainingMs / 1000).toStringAsFixed(1);
|
||||
final progress = remainingMs / buff.effect.durationMs;
|
||||
final modifiers = _buildModifierList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// 버프 아이콘
|
||||
const Icon(
|
||||
Icons.trending_up,
|
||||
size: 14,
|
||||
color: Colors.lightBlue,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// 버프 이름
|
||||
Expanded(
|
||||
child: Text(
|
||||
buff.effect.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.lightBlue,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// 남은 시간
|
||||
Text(
|
||||
'${remainingSec}s',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: remainingMs < 3000 ? Colors.orange : Colors.grey,
|
||||
fontWeight:
|
||||
remainingMs < 3000 ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 2),
|
||||
|
||||
// 남은 시간 프로그레스 바
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.clamp(0.0, 1.0),
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
progress > 0.3 ? Colors.lightBlue : Colors.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 효과 목록
|
||||
if (modifiers.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 2,
|
||||
children: modifiers,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 버프 효과 목록 생성
|
||||
List<Widget> _buildModifierList() {
|
||||
final modifiers = <Widget>[];
|
||||
final effect = buff.effect;
|
||||
|
||||
if (effect.atkModifier != 0) {
|
||||
modifiers.add(_ModifierChip(
|
||||
label: 'ATK',
|
||||
value: effect.atkModifier,
|
||||
isPositive: effect.atkModifier > 0,
|
||||
));
|
||||
}
|
||||
|
||||
if (effect.defModifier != 0) {
|
||||
modifiers.add(_ModifierChip(
|
||||
label: 'DEF',
|
||||
value: effect.defModifier,
|
||||
isPositive: effect.defModifier > 0,
|
||||
));
|
||||
}
|
||||
|
||||
if (effect.criRateModifier != 0) {
|
||||
modifiers.add(_ModifierChip(
|
||||
label: 'CRI',
|
||||
value: effect.criRateModifier,
|
||||
isPositive: effect.criRateModifier > 0,
|
||||
));
|
||||
}
|
||||
|
||||
if (effect.evasionModifier != 0) {
|
||||
modifiers.add(_ModifierChip(
|
||||
label: 'EVA',
|
||||
value: effect.evasionModifier,
|
||||
isPositive: effect.evasionModifier > 0,
|
||||
));
|
||||
}
|
||||
|
||||
return modifiers;
|
||||
}
|
||||
}
|
||||
|
||||
/// 효과 칩 위젯
|
||||
class _ModifierChip extends StatelessWidget {
|
||||
const _ModifierChip({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.isPositive,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final double value;
|
||||
final bool isPositive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = isPositive ? Colors.green : Colors.red;
|
||||
final sign = isPositive ? '+' : '';
|
||||
final percent = (value * 100).round();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'$label: $sign$percent%',
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,15 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
// 회복/버프 → idle 페이즈 유지
|
||||
CombatEventType.playerHeal => (BattlePhase.idle, false),
|
||||
CombatEventType.playerBuff => (BattlePhase.idle, false),
|
||||
|
||||
// DOT 틱 → attack 페이즈 (지속 피해)
|
||||
CombatEventType.dotTick => (BattlePhase.attack, false),
|
||||
|
||||
// 물약 사용 → idle 페이즈 유지
|
||||
CombatEventType.playerPotion => (BattlePhase.idle, false),
|
||||
|
||||
// 물약 드랍 → idle 페이즈 유지
|
||||
CombatEventType.potionDrop => (BattlePhase.idle, false),
|
||||
};
|
||||
|
||||
setState(() {
|
||||
|
||||
@@ -21,13 +21,16 @@ enum CombatLogType {
|
||||
levelUp, // 레벨업
|
||||
questComplete, // 퀘스트 완료
|
||||
loot, // 전리품 획득
|
||||
spell, // 주문 습득
|
||||
spell, // 스킬 사용
|
||||
critical, // 크리티컬 히트
|
||||
evade, // 회피
|
||||
block, // 방패 방어
|
||||
parry, // 무기 쳐내기
|
||||
monsterAttack, // 몬스터 공격
|
||||
buff, // 버프 활성화
|
||||
dotTick, // DOT 틱 데미지
|
||||
potion, // 물약 사용
|
||||
potionDrop, // 물약 드랍
|
||||
}
|
||||
|
||||
/// 전투 로그 위젯 (Phase 8: 실시간 전투 이벤트 표시)
|
||||
@@ -157,6 +160,9 @@ class _LogEntryTile extends StatelessWidget {
|
||||
CombatLogType.parry => (Colors.teal.shade300, Icons.sports_kabaddi),
|
||||
CombatLogType.monsterAttack => (Colors.deepOrange.shade300, Icons.dangerous),
|
||||
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
|
||||
CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot),
|
||||
CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink),
|
||||
CombatLogType.potionDrop => (Colors.lime.shade300, Icons.card_giftcard),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +426,21 @@ class DeathOverlay extends StatelessWidget {
|
||||
Colors.lightBlue.shade300,
|
||||
'${event.skillName} activated',
|
||||
),
|
||||
CombatEventType.dotTick => (
|
||||
Icons.whatshot,
|
||||
Colors.deepOrange.shade300,
|
||||
'${event.skillName} ticks for ${event.damage} damage',
|
||||
),
|
||||
CombatEventType.playerPotion => (
|
||||
Icons.local_drink,
|
||||
Colors.lightGreen.shade300,
|
||||
'${event.skillName}: +${event.healAmount} ${event.targetName}',
|
||||
),
|
||||
CombatEventType.potionDrop => (
|
||||
Icons.card_giftcard,
|
||||
Colors.lime.shade300,
|
||||
'Dropped: ${event.skillName}',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
238
lib/src/features/game/widgets/potion_inventory_panel.dart
Normal file
238
lib/src/features/game/widgets/potion_inventory_panel.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:askiineverdie/data/potion_data.dart';
|
||||
import 'package:askiineverdie/src/core/model/potion.dart';
|
||||
|
||||
/// 물약 인벤토리 패널
|
||||
///
|
||||
/// 보유 중인 물약 목록과 수량을 표시.
|
||||
/// HP 물약은 빨간색, MP 물약은 파란색으로 구분.
|
||||
class PotionInventoryPanel extends StatelessWidget {
|
||||
const PotionInventoryPanel({
|
||||
super.key,
|
||||
required this.inventory,
|
||||
this.usedInBattle = const {},
|
||||
});
|
||||
|
||||
final PotionInventory inventory;
|
||||
final Set<PotionType> usedInBattle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final potionEntries = _buildPotionEntries();
|
||||
|
||||
if (potionEntries.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No potions',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: potionEntries.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = potionEntries[index];
|
||||
return _PotionRow(
|
||||
potion: entry.potion,
|
||||
quantity: entry.quantity,
|
||||
isUsedThisBattle: usedInBattle.contains(entry.potion.type),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 물약 엔트리 목록 생성
|
||||
///
|
||||
/// HP 물약 먼저, MP 물약 나중에 정렬
|
||||
List<_PotionEntry> _buildPotionEntries() {
|
||||
final entries = <_PotionEntry>[];
|
||||
|
||||
for (final potionId in inventory.potions.keys) {
|
||||
final quantity = inventory.potions[potionId] ?? 0;
|
||||
if (quantity <= 0) continue;
|
||||
|
||||
final potion = PotionData.getById(potionId);
|
||||
if (potion == null) continue;
|
||||
|
||||
entries.add(_PotionEntry(potion: potion, quantity: quantity));
|
||||
}
|
||||
|
||||
// HP 물약 우선, 같은 타입 내에서는 티어순
|
||||
entries.sort((a, b) {
|
||||
final typeCompare = a.potion.type.index.compareTo(b.potion.type.index);
|
||||
if (typeCompare != 0) return typeCompare;
|
||||
return a.potion.tier.compareTo(b.potion.tier);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
/// 물약 엔트리
|
||||
class _PotionEntry {
|
||||
const _PotionEntry({required this.potion, required this.quantity});
|
||||
final Potion potion;
|
||||
final int quantity;
|
||||
}
|
||||
|
||||
/// 물약 행 위젯
|
||||
class _PotionRow extends StatelessWidget {
|
||||
const _PotionRow({
|
||||
required this.potion,
|
||||
required this.quantity,
|
||||
this.isUsedThisBattle = false,
|
||||
});
|
||||
|
||||
final Potion potion;
|
||||
final int quantity;
|
||||
final bool isUsedThisBattle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _getPotionColor();
|
||||
final opacity = isUsedThisBattle ? 0.5 : 1.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: Row(
|
||||
children: [
|
||||
// 물약 아이콘
|
||||
_PotionIcon(type: potion.type, tier: potion.tier),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// 물약 이름
|
||||
Expanded(
|
||||
child: Text(
|
||||
potion.name,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// 회복량 표시
|
||||
_HealBadge(potion: potion),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// 수량
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'x$quantity',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 전투 중 사용 불가 표시
|
||||
if (isUsedThisBattle) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.block,
|
||||
size: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getPotionColor() {
|
||||
return switch (potion.type) {
|
||||
PotionType.hp => Colors.red.shade700,
|
||||
PotionType.mp => Colors.blue.shade700,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 물약 아이콘
|
||||
class _PotionIcon extends StatelessWidget {
|
||||
const _PotionIcon({required this.type, required this.tier});
|
||||
|
||||
final PotionType type;
|
||||
final int tier;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = type == PotionType.hp
|
||||
? Colors.red.shade400
|
||||
: Colors.blue.shade400;
|
||||
|
||||
// 티어에 따른 아이콘 크기 조절
|
||||
final size = 12.0 + tier * 1.0;
|
||||
|
||||
return Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
type == PotionType.hp ? Icons.favorite : Icons.bolt,
|
||||
size: size.clamp(12, 18),
|
||||
color: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 회복량 배지
|
||||
class _HealBadge extends StatelessWidget {
|
||||
const _HealBadge({required this.potion});
|
||||
|
||||
final Potion potion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final healText = _buildHealText();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
healText,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildHealText() {
|
||||
final parts = <String>[];
|
||||
|
||||
if (potion.healAmount > 0) {
|
||||
parts.add('+${potion.healAmount}');
|
||||
}
|
||||
|
||||
if (potion.healPercent > 0) {
|
||||
final percent = (potion.healPercent * 100).round();
|
||||
parts.add('+$percent%');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
@@ -148,25 +148,58 @@ class _SkillRow extends StatelessWidget {
|
||||
|
||||
final skillIcon = _getSkillIcon(skill.type);
|
||||
final skillColor = _getSkillColor(skill.type);
|
||||
final elementColor = _getElementColor(skill.element);
|
||||
final elementIcon = _getElementIcon(skill.element);
|
||||
|
||||
Widget row = Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
// 스킬 아이콘
|
||||
// 스킬 타입 아이콘
|
||||
Icon(skillIcon, size: 14, color: skillColor),
|
||||
|
||||
// 속성 아이콘 (있는 경우)
|
||||
if (skill.element != null) ...[
|
||||
const SizedBox(width: 2),
|
||||
_ElementBadge(
|
||||
icon: elementIcon,
|
||||
color: elementColor,
|
||||
isDot: skill.isDot,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(width: 4),
|
||||
// 스킬 이름
|
||||
|
||||
// 스킬 이름 (속성 색상 적용)
|
||||
Expanded(
|
||||
child: Text(
|
||||
skill.name,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isReady ? Colors.white : Colors.grey,
|
||||
color: isReady
|
||||
? (skill.element != null ? elementColor : Colors.white)
|
||||
: Colors.grey,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// DOT 표시
|
||||
if (skill.isDot) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: elementColor.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: const Text(
|
||||
'DOT',
|
||||
style: TextStyle(fontSize: 7, color: Colors.white70),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
|
||||
// 랭크
|
||||
Text(
|
||||
'Lv.$rank',
|
||||
@@ -241,4 +274,69 @@ class _SkillRow extends StatelessWidget {
|
||||
return Colors.purple;
|
||||
}
|
||||
}
|
||||
|
||||
/// 속성별 색상
|
||||
Color _getElementColor(SkillElement? element) {
|
||||
if (element == null) return Colors.grey;
|
||||
|
||||
return switch (element) {
|
||||
SkillElement.logic => Colors.cyan,
|
||||
SkillElement.memory => Colors.purple.shade300,
|
||||
SkillElement.network => Colors.teal,
|
||||
SkillElement.fire => Colors.orange,
|
||||
SkillElement.ice => Colors.lightBlue.shade200,
|
||||
SkillElement.lightning => Colors.yellow.shade600,
|
||||
SkillElement.voidElement => Colors.deepPurple,
|
||||
SkillElement.chaos => Colors.pink,
|
||||
};
|
||||
}
|
||||
|
||||
/// 속성별 아이콘
|
||||
IconData _getElementIcon(SkillElement? element) {
|
||||
if (element == null) return Icons.circle;
|
||||
|
||||
return switch (element) {
|
||||
SkillElement.logic => Icons.code,
|
||||
SkillElement.memory => Icons.memory,
|
||||
SkillElement.network => Icons.lan,
|
||||
SkillElement.fire => Icons.local_fire_department,
|
||||
SkillElement.ice => Icons.ac_unit,
|
||||
SkillElement.lightning => Icons.bolt,
|
||||
SkillElement.voidElement => Icons.remove_circle_outline,
|
||||
SkillElement.chaos => Icons.shuffle,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 속성 배지 위젯
|
||||
class _ElementBadge extends StatelessWidget {
|
||||
const _ElementBadge({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.isDot = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool isDot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: isDot
|
||||
? Border.all(color: color.withValues(alpha: 0.7), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 10,
|
||||
color: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user