Compare commits
16 Commits
dcfb8ab9de
...
9540dd22a3
| Author | SHA1 | Date |
|---|---|---|
|
|
9540dd22a3 | |
|
|
135bf26332 | |
|
|
59ebb7cd98 | |
|
|
e1b000772e | |
|
|
fef803d064 | |
|
|
0b48aea16d | |
|
|
cb7e75064f | |
|
|
3030c09d55 | |
|
|
1720725826 | |
|
|
e6facc41f8 | |
|
|
78267b7e47 | |
|
|
9a7498af35 | |
|
|
c570d61563 | |
|
|
9a9022356a | |
|
|
32c77dd20a | |
|
|
46658b22c8 |
|
|
@ -0,0 +1,65 @@
|
|||
class AppStrings {
|
||||
// Main Menu
|
||||
static const String gameTitle = "Colosseum's Choice";
|
||||
static const String startGame = "Start Game";
|
||||
static const String continueGame = "Continue";
|
||||
static const String exitGame = "Exit Game";
|
||||
static const String credits = "Credits";
|
||||
|
||||
// Common Actions
|
||||
static const String confirm = "Confirm";
|
||||
static const String cancel = "Cancel";
|
||||
static const String back = "Back";
|
||||
static const String close = "Close";
|
||||
static const String equip = "Equip";
|
||||
static const String unequip = "Unequip";
|
||||
static const String discard = "Discard";
|
||||
static const String sell = "Sell";
|
||||
static const String buy = "Buy";
|
||||
|
||||
// Stats
|
||||
static const String hp = "HP";
|
||||
static const String atk = "ATK";
|
||||
static const String def = "DEF";
|
||||
static const String armor = "Armor";
|
||||
static const String luck = "Luck";
|
||||
static const String gold = "Gold";
|
||||
|
||||
// Battle
|
||||
static const String attack = "Attack";
|
||||
static const String defend = "Defend";
|
||||
static const String turn = "Turn";
|
||||
static const String playerTurn = "Player's Turn";
|
||||
static const String enemyTurn = "Enemy's Turn";
|
||||
static const String victory = "Victory!";
|
||||
static const String defeat = "Defeat";
|
||||
static const String reward = "Reward";
|
||||
static const String chooseReward = "Choose a Reward";
|
||||
static const String skip = "Skip";
|
||||
static const String nextStage = "Next Stage";
|
||||
static const String returnToMenu = "Return to Menu";
|
||||
static const String restart = "Restart";
|
||||
|
||||
// Inventory
|
||||
static const String inventory = "Inventory";
|
||||
static const String equipment = "Equipment";
|
||||
static const String bag = "Bag";
|
||||
static const String emptySlot = "Empty";
|
||||
static const String noItems = "No items in inventory";
|
||||
|
||||
// Shop
|
||||
static const String shopTitle = "Merchant";
|
||||
static const String shopWelcome = "Welcome, traveler!";
|
||||
static const String refreshShop = "Restock";
|
||||
static const String notEnoughGold = "Not enough gold!";
|
||||
static const String inventoryFull = "Inventory is full!";
|
||||
|
||||
// Risk Levels
|
||||
static const String riskSafe = "Safe";
|
||||
static const String riskNormal = "Normal";
|
||||
static const String riskRisky = "Risky";
|
||||
|
||||
// Settings
|
||||
static const String settings = "Settings";
|
||||
static const String enemyAnimations = "Enemy Animations";
|
||||
}
|
||||
|
|
@ -20,6 +20,20 @@ class BattleConfig {
|
|||
static const double sizeNormal = 60.0;
|
||||
static const double sizeSafe = 40.0;
|
||||
|
||||
// Logic Constants
|
||||
// Safe
|
||||
static const double safeBaseChance = 1.0; // 100%
|
||||
static const double safeEfficiency = 0.5; // 50%
|
||||
// Normal
|
||||
static const double normalBaseChance = 0.8; // 80%
|
||||
static const double normalEfficiency = 1.0; // 100%
|
||||
// Risky
|
||||
static const double riskyBaseChance = 0.4; // 40%
|
||||
static const double riskyEfficiency = 2.0; // 200%
|
||||
|
||||
// Enemy Logic
|
||||
static const double enemyAttackChance = 0.7; // 70% Attack, 30% Defend
|
||||
|
||||
static IconData getIcon(ActionType type) {
|
||||
switch (type) {
|
||||
case ActionType.attack:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class GameConfig {
|
|||
static const int shopRerollCost = 50;
|
||||
|
||||
// Stages
|
||||
static const int eliteStageInterval = 10;
|
||||
static const int eliteStageInterval = 12;
|
||||
static const int shopStageInterval = 5;
|
||||
static const int restStageInterval = 8;
|
||||
static const int tier1StageMax = 12;
|
||||
|
|
@ -18,11 +18,16 @@ class GameConfig {
|
|||
static const double stageHealRatio = 0.1;
|
||||
static const double vulnerableDamageMultiplier = 1.5;
|
||||
static const double armorDecayRate = 0.5;
|
||||
|
||||
// Rewards
|
||||
static const int baseGoldReward = 10;
|
||||
static const int goldRewardPerStage = 5;
|
||||
static const int goldRewardVariance = 10;
|
||||
|
||||
// Animations (Duration in milliseconds)
|
||||
static const int animDelaySafe = 500;
|
||||
static const int animDelayNormal = 400;
|
||||
static const int animDelayRisky = 1100;
|
||||
static const int animDelaySafe = 600; // 500 + 100 buffer
|
||||
static const int animDelayNormal = 500; // 400 + 100 buffer
|
||||
static const int animDelayRisky = 1200; // 1100 + 100 buffer
|
||||
static const int animDelayEnemyTurn = 1000;
|
||||
|
||||
// Save System
|
||||
|
|
|
|||
|
|
@ -11,4 +11,7 @@ class ItemConfig {
|
|||
ItemRarity.legendary: 4,
|
||||
ItemRarity.unique: 1,
|
||||
};
|
||||
|
||||
// Loot Generation
|
||||
static const double magicPrefixChance = 0.5; // 50%
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import 'package:flutter/services.dart';
|
|||
import '../model/item.dart';
|
||||
import '../enums.dart';
|
||||
import '../config/item_config.dart';
|
||||
import 'item_prefix_table.dart'; // Import prefix table
|
||||
import 'name_generator.dart'; // Import name generator
|
||||
// import 'item_prefix_table.dart'; // Logic moved to LootGenerator
|
||||
// import 'name_generator.dart'; // Logic moved to LootGenerator
|
||||
import '../logic/loot_generator.dart'; // Import LootGenerator
|
||||
import '../../utils/game_math.dart';
|
||||
|
||||
class ItemTemplate {
|
||||
|
|
@ -69,122 +70,9 @@ class ItemTemplate {
|
|||
}
|
||||
|
||||
Item createItem({int stage = 1}) {
|
||||
// Stage-based scaling is removed.
|
||||
// Apply Prefix Logic based on Rarity.
|
||||
|
||||
String finalName = name;
|
||||
int finalAtk = atkBonus;
|
||||
int finalHp = hpBonus;
|
||||
int finalArmor = armorBonus;
|
||||
int finalLuck = luck;
|
||||
|
||||
final random = Random();
|
||||
|
||||
// 0. Normal Rarity: Prefix logic for base stat variations
|
||||
if (rarity == ItemRarity.normal) {
|
||||
// Weighted Random Selection
|
||||
final prefixes = ItemPrefixTable.normalPrefixes;
|
||||
int totalWeight = prefixes.fold(0, (sum, p) => sum + p.weight);
|
||||
int roll = random.nextInt(totalWeight);
|
||||
|
||||
ItemModifier? selectedModifier;
|
||||
int currentSum = 0;
|
||||
for (var mod in prefixes) {
|
||||
currentSum += mod.weight;
|
||||
if (roll < currentSum) {
|
||||
selectedModifier = mod;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedModifier != null) {
|
||||
if (selectedModifier.prefix.isNotEmpty) {
|
||||
finalName = "${selectedModifier.prefix} $name";
|
||||
}
|
||||
|
||||
double mult = selectedModifier.multiplier;
|
||||
if (mult != 1.0) {
|
||||
finalAtk = (finalAtk * mult).floor();
|
||||
finalHp = (finalHp * mult).floor();
|
||||
finalArmor = (finalArmor * mult).floor();
|
||||
// Luck usually isn't scaled by small multipliers, but let's keep it consistent or skip.
|
||||
// Skipping luck scaling for normal prefixes to avoid 0.
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1. Magic Rarity: 50% chance to get a Magic Prefix (1 stat change)
|
||||
else if (rarity == ItemRarity.magic) {
|
||||
if (random.nextBool()) { // 50% chance
|
||||
// Filter valid prefixes for this slot
|
||||
final validPrefixes = ItemPrefixTable.magicPrefixes.where((p) {
|
||||
return p.allowedSlots == null || p.allowedSlots!.contains(slot);
|
||||
}).toList();
|
||||
|
||||
if (validPrefixes.isNotEmpty) {
|
||||
final modifier = validPrefixes[random.nextInt(validPrefixes.length)];
|
||||
finalName = "${modifier.prefix} $name";
|
||||
|
||||
modifier.statChanges.forEach((stat, value) {
|
||||
switch(stat) {
|
||||
case StatType.atk: finalAtk += value; break;
|
||||
case StatType.maxHp: finalHp += value; break;
|
||||
case StatType.defense: finalArmor += value; break;
|
||||
case StatType.luck: finalLuck += value; break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Rare Rarity: Always get a Rare Prefix (2 stat changes or stronger)
|
||||
else if (rarity == ItemRarity.rare) {
|
||||
bool nameChanged = false;
|
||||
|
||||
// Always generate a completely new cool name for Rare items
|
||||
finalName = NameGenerator.generateName(slot);
|
||||
nameChanged = true;
|
||||
|
||||
// Filter valid prefixes for this slot
|
||||
final validPrefixes = ItemPrefixTable.rarePrefixes.where((p) {
|
||||
return p.allowedSlots == null || p.allowedSlots!.contains(slot);
|
||||
}).toList();
|
||||
|
||||
if (validPrefixes.isNotEmpty) {
|
||||
final modifier = validPrefixes[random.nextInt(validPrefixes.length)];
|
||||
|
||||
// If name wasn't already changed by NameGenerator, apply prefix to name
|
||||
if (!nameChanged) {
|
||||
finalName = "${modifier.prefix} $name";
|
||||
}
|
||||
// Even if name changed, we STILL apply the stats from the prefix modifier!
|
||||
// Because NameGenerator is just visual flavor, stats come from the modifier.
|
||||
|
||||
modifier.statChanges.forEach((stat, value) {
|
||||
switch(stat) {
|
||||
case StatType.atk: finalAtk += value; break;
|
||||
case StatType.maxHp: finalHp += value; break;
|
||||
case StatType.defense: finalArmor += value; break;
|
||||
case StatType.luck: finalLuck += value; break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Legendary/Unique items usually keep their original names/stats as they are special.
|
||||
|
||||
return Item(
|
||||
id: id,
|
||||
name: finalName,
|
||||
description: description,
|
||||
atkBonus: finalAtk,
|
||||
hpBonus: finalHp,
|
||||
armorBonus: finalArmor,
|
||||
slot: slot,
|
||||
effects: effects,
|
||||
price: price,
|
||||
image: image,
|
||||
luck: finalLuck,
|
||||
rarity: rarity,
|
||||
tier: tier,
|
||||
);
|
||||
// Stage parameter kept for interface compatibility but unused here,
|
||||
// as scaling is now handled via Tier/Rarity in LootGenerator/Table logic.
|
||||
return LootGenerator.generate(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class BattleLogManager {
|
||||
final List<String> _logs = [];
|
||||
|
||||
List<String> get logs => List.unmodifiable(_logs);
|
||||
|
||||
void addLog(String message) {
|
||||
_logs.add(message);
|
||||
debugPrint("[BattleLog] $message"); // Optional: Console logging for debug
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_logs.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import 'dart:math';
|
||||
import '../model/entity.dart';
|
||||
import '../model/status_effect.dart';
|
||||
import '../enums.dart';
|
||||
import '../config/game_config.dart';
|
||||
import '../config/battle_config.dart'; // Import BattleConfig
|
||||
import '../model/damage_event.dart';
|
||||
|
||||
class CombatResult {
|
||||
final bool success;
|
||||
final int value;
|
||||
final double efficiency;
|
||||
final bool isCritical; // Future extension
|
||||
|
||||
CombatResult({
|
||||
required this.success,
|
||||
required this.value,
|
||||
required this.efficiency,
|
||||
this.isCritical = false,
|
||||
});
|
||||
}
|
||||
|
||||
class CombatCalculator {
|
||||
static final Random _random = Random();
|
||||
|
||||
/// Calculates success and efficiency based on Risk Level and Luck.
|
||||
static CombatResult calculateActionOutcome({
|
||||
required RiskLevel risk,
|
||||
required int luck,
|
||||
required int baseValue,
|
||||
}) {
|
||||
double efficiency = 1.0;
|
||||
double baseChance = 0.0;
|
||||
|
||||
switch (risk) {
|
||||
case RiskLevel.safe:
|
||||
baseChance = BattleConfig.safeBaseChance;
|
||||
efficiency = BattleConfig.safeEfficiency;
|
||||
break;
|
||||
case RiskLevel.normal:
|
||||
baseChance = BattleConfig.normalBaseChance;
|
||||
efficiency = BattleConfig.normalEfficiency;
|
||||
break;
|
||||
case RiskLevel.risky:
|
||||
baseChance = BattleConfig.riskyBaseChance;
|
||||
efficiency = BattleConfig.riskyEfficiency;
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply Luck (1 Luck = +1%)
|
||||
double chance = baseChance + (luck / 100.0);
|
||||
if (chance > 1.0) chance = 1.0;
|
||||
|
||||
bool success = _random.nextDouble() < chance;
|
||||
int finalValue = (baseValue * efficiency).toInt();
|
||||
if (finalValue < 1 && baseValue > 0) finalValue = 1;
|
||||
|
||||
return CombatResult(
|
||||
success: success,
|
||||
value: finalValue,
|
||||
efficiency: efficiency,
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculates actual damage to HP after applying armor and vulnerability.
|
||||
static int calculateDamageToHp({
|
||||
required int incomingDamage,
|
||||
required int currentArmor,
|
||||
required bool isVulnerable,
|
||||
}) {
|
||||
int damage = incomingDamage;
|
||||
|
||||
// 1. Vulnerability check
|
||||
if (isVulnerable) {
|
||||
damage = (damage * GameConfig.vulnerableDamageMultiplier).toInt();
|
||||
}
|
||||
|
||||
// 2. Armor absorption
|
||||
int damageToHp = 0;
|
||||
if (currentArmor > 0) {
|
||||
if (currentArmor >= damage) {
|
||||
// Fully absorbed
|
||||
damageToHp = 0;
|
||||
} else {
|
||||
damageToHp = damage - currentArmor;
|
||||
}
|
||||
} else {
|
||||
damageToHp = damage;
|
||||
}
|
||||
|
||||
return damageToHp;
|
||||
}
|
||||
|
||||
/// Calculates armor remaining after damage absorption.
|
||||
static int calculateRemainingArmor({
|
||||
required int incomingDamage,
|
||||
required int currentArmor,
|
||||
required bool isVulnerable,
|
||||
}) {
|
||||
int damage = incomingDamage;
|
||||
if (isVulnerable) {
|
||||
damage = (damage * GameConfig.vulnerableDamageMultiplier).toInt();
|
||||
}
|
||||
|
||||
if (currentArmor > 0) {
|
||||
if (currentArmor >= damage) {
|
||||
return currentArmor - damage;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Checks if status effects (Bleed, Stun) allow action and returns bleed damage.
|
||||
static Map<String, dynamic> processStartTurnEffects(Character character) {
|
||||
int totalBleedDamage = 0;
|
||||
bool isStunned = false;
|
||||
|
||||
// 1. Bleed Damage
|
||||
var bleedEffects = character.statusEffects
|
||||
.where((e) => e.type == StatusEffectType.bleed)
|
||||
.toList();
|
||||
|
||||
if (bleedEffects.isNotEmpty) {
|
||||
totalBleedDamage = bleedEffects.fold(0, (sum, e) => sum + e.value);
|
||||
}
|
||||
|
||||
// 2. Stun Check
|
||||
if (character.hasStatus(StatusEffectType.stun)) {
|
||||
isStunned = true;
|
||||
}
|
||||
|
||||
return {
|
||||
'bleedDamage': totalBleedDamage,
|
||||
'isStunned': isStunned,
|
||||
};
|
||||
}
|
||||
|
||||
/// Tries to apply status effects from attacker's equipment.
|
||||
/// Returns a list of applied effects.
|
||||
static List<StatusEffect> getAppliedEffects(Character attacker) {
|
||||
List<StatusEffect> appliedEffects = [];
|
||||
|
||||
for (var item in attacker.equipment.values) {
|
||||
for (var effect in item.effects) {
|
||||
if (_random.nextInt(100) < effect.probability) {
|
||||
appliedEffects.add(
|
||||
StatusEffect(
|
||||
type: effect.type,
|
||||
duration: effect.duration,
|
||||
value: effect.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return appliedEffects;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import 'dart:math';
|
||||
import '../model/item.dart';
|
||||
import '../data/item_table.dart'; // For ItemTemplate
|
||||
import '../data/item_prefix_table.dart';
|
||||
import '../data/name_generator.dart';
|
||||
import '../enums.dart';
|
||||
import '../config/item_config.dart'; // Import ItemConfig
|
||||
|
||||
class LootGenerator {
|
||||
static final Random _random = Random();
|
||||
|
||||
/// Generates an Item instance from a template, applying prefixes/suffixes based on rarity.
|
||||
static Item generate(ItemTemplate template) {
|
||||
String finalName = template.name;
|
||||
int finalAtk = template.atkBonus;
|
||||
int finalHp = template.hpBonus;
|
||||
int finalArmor = template.armorBonus;
|
||||
int finalLuck = template.luck;
|
||||
|
||||
// 0. Normal Rarity: Prefix logic for base stat variations
|
||||
if (template.rarity == ItemRarity.normal) {
|
||||
// Weighted Random Selection
|
||||
final prefixes = ItemPrefixTable.normalPrefixes;
|
||||
int totalWeight = prefixes.fold(0, (sum, p) => sum + p.weight);
|
||||
int roll = _random.nextInt(totalWeight);
|
||||
|
||||
ItemModifier? selectedModifier;
|
||||
int currentSum = 0;
|
||||
for (var mod in prefixes) {
|
||||
currentSum += mod.weight;
|
||||
if (roll < currentSum) {
|
||||
selectedModifier = mod;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedModifier != null) {
|
||||
if (selectedModifier.prefix.isNotEmpty) {
|
||||
finalName = "${selectedModifier.prefix} ${template.name}";
|
||||
}
|
||||
|
||||
double mult = selectedModifier.multiplier;
|
||||
if (mult != 1.0) {
|
||||
finalAtk = (finalAtk * mult).floor();
|
||||
finalHp = (finalHp * mult).floor();
|
||||
finalArmor = (finalArmor * mult).floor();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1. Magic Rarity: 50% chance to get a Magic Prefix (1 stat change)
|
||||
else if (template.rarity == ItemRarity.magic) {
|
||||
if (_random.nextDouble() < ItemConfig.magicPrefixChance) {
|
||||
// Use constant
|
||||
// Filter valid prefixes for this slot
|
||||
final validPrefixes = ItemPrefixTable.magicPrefixes.where((p) {
|
||||
return p.allowedSlots == null ||
|
||||
p.allowedSlots!.contains(template.slot);
|
||||
}).toList();
|
||||
|
||||
if (validPrefixes.isNotEmpty) {
|
||||
final modifier = validPrefixes[_random.nextInt(validPrefixes.length)];
|
||||
finalName = "${modifier.prefix} ${template.name}";
|
||||
|
||||
modifier.statChanges.forEach((stat, value) {
|
||||
switch (stat) {
|
||||
case StatType.atk:
|
||||
finalAtk += value;
|
||||
break;
|
||||
case StatType.maxHp:
|
||||
finalHp += value;
|
||||
break;
|
||||
case StatType.defense:
|
||||
finalArmor += value;
|
||||
break;
|
||||
case StatType.luck:
|
||||
finalLuck += value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Rare Rarity: Always get a Rare Prefix (2 stat changes or stronger)
|
||||
else if (template.rarity == ItemRarity.rare) {
|
||||
bool nameChanged = false;
|
||||
|
||||
// Always generate a completely new cool name for Rare items
|
||||
finalName = NameGenerator.generateName(template.slot);
|
||||
nameChanged = true;
|
||||
|
||||
// Filter valid prefixes for this slot
|
||||
final validPrefixes = ItemPrefixTable.rarePrefixes.where((p) {
|
||||
return p.allowedSlots == null ||
|
||||
p.allowedSlots!.contains(template.slot);
|
||||
}).toList();
|
||||
|
||||
if (validPrefixes.isNotEmpty) {
|
||||
final modifier = validPrefixes[_random.nextInt(validPrefixes.length)];
|
||||
|
||||
// If name wasn't already changed by NameGenerator, apply prefix to name
|
||||
if (!nameChanged) {
|
||||
finalName = "${modifier.prefix} ${template.name}";
|
||||
}
|
||||
// Even if name changed, we STILL apply the stats from the prefix modifier!
|
||||
|
||||
modifier.statChanges.forEach((stat, value) {
|
||||
switch (stat) {
|
||||
case StatType.atk:
|
||||
finalAtk += value;
|
||||
break;
|
||||
case StatType.maxHp:
|
||||
finalHp += value;
|
||||
break;
|
||||
case StatType.defense:
|
||||
finalArmor += value;
|
||||
break;
|
||||
case StatType.luck:
|
||||
finalLuck += value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Legendary/Unique items usually keep their original names/stats.
|
||||
|
||||
return Item(
|
||||
id: template.id,
|
||||
name: finalName,
|
||||
description: template.description,
|
||||
atkBonus: finalAtk,
|
||||
hpBonus: finalHp,
|
||||
armorBonus: finalArmor,
|
||||
slot: template.slot,
|
||||
effects: template.effects,
|
||||
price: template.price,
|
||||
image: template.image,
|
||||
luck: finalLuck,
|
||||
rarity: template.rarity,
|
||||
tier: template.tier,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import '../enums.dart';
|
||||
import 'entity.dart'; // Import Character entity
|
||||
|
||||
enum EffectTarget { player, enemy }
|
||||
|
||||
|
|
@ -9,11 +10,25 @@ class EffectEvent {
|
|||
final EffectTarget target; // 이펙트가 표시될 위치의 대상
|
||||
final BattleFeedbackType? feedbackType; // 새로운 피드백 타입
|
||||
|
||||
// New fields for impact logic
|
||||
final Character? attacker;
|
||||
final Character? targetEntity; // 실제 피해를 받는 캐릭터
|
||||
final int? damageValue; // 공격 시 데미지 값
|
||||
final bool? isSuccess; // 성공 여부 (Missed or Failed가 아닌 경우)
|
||||
final int? armorGained; // 방어 시 얻는 방어도
|
||||
final bool triggersTurnChange; // 턴 전환 트리거 여부
|
||||
|
||||
EffectEvent({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.risk,
|
||||
required this.target,
|
||||
this.feedbackType, // feedbackType 필드를 생성자에 추가
|
||||
this.attacker,
|
||||
this.targetEntity,
|
||||
this.damageValue,
|
||||
this.isSuccess,
|
||||
this.armorGained,
|
||||
this.triggersTurnChange = true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ class Character {
|
|||
'baseDefense': baseDefense,
|
||||
'gold': gold,
|
||||
'image': image,
|
||||
'equipment': equipment.map(
|
||||
(key, value) => MapEntry(key.name, value.id),
|
||||
),
|
||||
'equipment': equipment.map((key, value) => MapEntry(key.name, value.id)),
|
||||
'inventory': inventory.map((e) => e.id).toList(),
|
||||
'statusEffects': statusEffects.map((e) => e.toJson()).toList(),
|
||||
'permanentModifiers': permanentModifiers.map((e) => e.toJson()).toList(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'game/data/enemy_table.dart';
|
|||
import 'game/data/player_table.dart';
|
||||
import 'providers/battle_provider.dart';
|
||||
import 'providers/shop_provider.dart'; // Import ShopProvider
|
||||
import 'providers/settings_provider.dart'; // Import SettingsProvider
|
||||
import 'screens/main_menu_screen.dart';
|
||||
|
||||
void main() async {
|
||||
|
|
@ -22,6 +23,7 @@ class MyApp extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => SettingsProvider()), // Register SettingsProvider
|
||||
ChangeNotifierProvider(create: (_) => ShopProvider()),
|
||||
ChangeNotifierProxyProvider<ShopProvider, BattleProvider>(
|
||||
create: (context) => BattleProvider(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SettingsProvider with ChangeNotifier {
|
||||
static const String _keyEnemyAnim = 'settings_enemy_anim';
|
||||
|
||||
bool _enableEnemyAnimations = false; // Default: Disabled
|
||||
|
||||
bool get enableEnemyAnimations => _enableEnemyAnimations;
|
||||
|
||||
SettingsProvider() {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_enableEnemyAnimations = prefs.getBool(_keyEnemyAnim) ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleEnemyAnimations(bool value) async {
|
||||
_enableEnemyAnimations = value;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyEnemyAnim, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ import '../widgets/battle/explosion_widget.dart';
|
|||
import 'main_menu_screen.dart';
|
||||
import '../game/config/battle_config.dart';
|
||||
import '../game/config/theme_config.dart';
|
||||
import '../game/config/app_strings.dart';
|
||||
import '../providers/settings_provider.dart'; // Import SettingsProvider
|
||||
|
||||
class BattleScreen extends StatefulWidget {
|
||||
const BattleScreen({super.key});
|
||||
|
|
@ -41,14 +43,19 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
final GlobalKey<ShakeWidgetState> _shakeKey = GlobalKey<ShakeWidgetState>();
|
||||
final GlobalKey<BattleAnimationWidgetState> _playerAnimKey =
|
||||
GlobalKey<BattleAnimationWidgetState>();
|
||||
final GlobalKey<BattleAnimationWidgetState> _enemyAnimKey =
|
||||
GlobalKey<BattleAnimationWidgetState>(); // Added Enemy Anim Key
|
||||
final GlobalKey<ExplosionWidgetState> _explosionKey =
|
||||
GlobalKey<ExplosionWidgetState>();
|
||||
bool _showLogs = true;
|
||||
bool _showLogs = false;
|
||||
bool _isPlayerAttacking = false; // Player Attack Animation State
|
||||
bool _isEnemyAttacking = false; // Enemy Attack Animation State
|
||||
DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print("[UI Debug] BattleScreen initialized: ${hashCode}"); // Debug Log
|
||||
final battleProvider = context.read<BattleProvider>();
|
||||
_damageSubscription = battleProvider.damageStream.listen(
|
||||
_addFloatingDamageText,
|
||||
|
|
@ -66,54 +73,52 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
}
|
||||
|
||||
void _addFloatingDamageText(DamageEvent event) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
GlobalKey targetKey = event.target == DamageTarget.player
|
||||
? _playerKey
|
||||
: _enemyKey;
|
||||
GlobalKey targetKey = event.target == DamageTarget.player
|
||||
? _playerKey
|
||||
: _enemyKey;
|
||||
|
||||
if (targetKey.currentContext == null) return;
|
||||
RenderBox? renderBox =
|
||||
targetKey.currentContext!.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
if (targetKey.currentContext == null) return;
|
||||
RenderBox? renderBox =
|
||||
targetKey.currentContext!.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
Offset position = renderBox.localToGlobal(Offset.zero);
|
||||
Offset position = renderBox.localToGlobal(Offset.zero);
|
||||
|
||||
RenderBox? stackRenderBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackRenderBox != null) {
|
||||
Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero);
|
||||
position = position - stackOffset;
|
||||
}
|
||||
RenderBox? stackRenderBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackRenderBox != null) {
|
||||
Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero);
|
||||
position = position - stackOffset;
|
||||
}
|
||||
|
||||
position = position + Offset(renderBox.size.width / 2 - 20, -20);
|
||||
position = position + Offset(renderBox.size.width / 2 - 20, -20);
|
||||
|
||||
final String id = UniqueKey().toString();
|
||||
final String id = UniqueKey().toString();
|
||||
|
||||
setState(() {
|
||||
_floatingDamageTexts.add(
|
||||
DamageTextData(
|
||||
id: id,
|
||||
widget: Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingDamageText(
|
||||
key: ValueKey(id),
|
||||
damage: event.damage.toString(),
|
||||
color: event.color,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_floatingDamageTexts.removeWhere((e) => e.id == id);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
setState(() {
|
||||
_floatingDamageTexts.add(
|
||||
DamageTextData(
|
||||
id: id,
|
||||
widget: Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingDamageText(
|
||||
key: ValueKey(id),
|
||||
damage: event.damage.toString(),
|
||||
color: event.color,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_floatingDamageTexts.removeWhere((e) => e.id == id);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -123,108 +128,92 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
if (_processedEffectIds.contains(event.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_processedEffectIds.add(event.id);
|
||||
if (_processedEffectIds.length > 20) {
|
||||
_processedEffectIds.remove(_processedEffectIds.first);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
|
||||
// Feedback Text Cooldown
|
||||
if (event.feedbackType != null) {
|
||||
print("[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}");
|
||||
if (_lastFeedbackTime != null &&
|
||||
DateTime.now().difference(_lastFeedbackTime!).inMilliseconds < 300) {
|
||||
return; // Skip if too soon
|
||||
}
|
||||
_lastFeedbackTime = DateTime.now();
|
||||
}
|
||||
|
||||
GlobalKey targetKey = event.target == EffectTarget.player
|
||||
? _playerKey
|
||||
: _enemyKey;
|
||||
if (targetKey.currentContext == null) return;
|
||||
|
||||
RenderBox? renderBox =
|
||||
targetKey.currentContext!.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
Offset position = renderBox.localToGlobal(Offset.zero);
|
||||
|
||||
RenderBox? stackRenderBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackRenderBox != null) {
|
||||
Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero);
|
||||
position = position - stackOffset;
|
||||
}
|
||||
|
||||
position =
|
||||
position +
|
||||
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
|
||||
|
||||
// 0. Prepare Effect Function
|
||||
void showEffect() {
|
||||
if (!mounted) return;
|
||||
|
||||
GlobalKey targetKey = event.target == EffectTarget.player
|
||||
? _playerKey
|
||||
: _enemyKey;
|
||||
if (targetKey.currentContext == null) return;
|
||||
|
||||
RenderBox? renderBox =
|
||||
targetKey.currentContext!.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
Offset position = renderBox.localToGlobal(Offset.zero);
|
||||
|
||||
RenderBox? stackRenderBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackRenderBox != null) {
|
||||
Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero);
|
||||
position = position - stackOffset;
|
||||
}
|
||||
|
||||
position =
|
||||
position +
|
||||
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
|
||||
|
||||
// 0. Prepare Effect Function
|
||||
void showEffect() {
|
||||
if (!mounted) return;
|
||||
|
||||
// feedbackType이 존재하면 해당 텍스트를 표시하고 기존 이펙트 아이콘은 건너뜜
|
||||
if (event.feedbackType != null) {
|
||||
String feedbackText;
|
||||
Color feedbackColor;
|
||||
switch (event.feedbackType) {
|
||||
case BattleFeedbackType.miss:
|
||||
feedbackText = "MISS";
|
||||
feedbackColor = ThemeConfig.missText;
|
||||
break;
|
||||
case BattleFeedbackType.failed:
|
||||
feedbackText = "FAILED";
|
||||
feedbackColor = ThemeConfig.failedText;
|
||||
break;
|
||||
default:
|
||||
feedbackText = ""; // Should not happen with current enums
|
||||
feedbackColor = ThemeConfig.textColorWhite;
|
||||
}
|
||||
|
||||
final String id = UniqueKey().toString();
|
||||
setState(() {
|
||||
_floatingFeedbackTexts.add(
|
||||
FeedbackTextData(
|
||||
id: id,
|
||||
widget: Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingFeedbackText(
|
||||
key: ValueKey(id),
|
||||
feedback: feedbackText,
|
||||
color: feedbackColor,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_floatingFeedbackTexts.removeWhere((e) => e.id == id);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return; // feedbackType이 있으면 아이콘 이펙트는 표시하지 않음
|
||||
// Handle Feedback Text (MISS / FAILED)
|
||||
if (event.feedbackType != null) {
|
||||
String feedbackText;
|
||||
Color feedbackColor;
|
||||
switch (event.feedbackType) {
|
||||
case BattleFeedbackType.miss:
|
||||
feedbackText = "MISS";
|
||||
feedbackColor = ThemeConfig.missText;
|
||||
break;
|
||||
case BattleFeedbackType.failed:
|
||||
feedbackText = "FAILED";
|
||||
feedbackColor = ThemeConfig.failedText;
|
||||
break;
|
||||
default:
|
||||
feedbackText = "";
|
||||
feedbackColor = ThemeConfig.textColorWhite;
|
||||
}
|
||||
|
||||
// Use BattleConfig for Icon, Color, and Size
|
||||
IconData icon = BattleConfig.getIcon(event.type);
|
||||
Color color = BattleConfig.getColor(event.type, event.risk);
|
||||
double size = BattleConfig.getSize(event.risk);
|
||||
|
||||
final String id = UniqueKey().toString();
|
||||
|
||||
// Prevent duplicate feedback texts for the same event ID (UI Level)
|
||||
if (_floatingFeedbackTexts.any((e) => e.eventId == event.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_floatingEffects.add(
|
||||
FloatingEffectData(
|
||||
_floatingFeedbackTexts.clear(); // Clear previous texts
|
||||
_floatingFeedbackTexts.add(
|
||||
FeedbackTextData(
|
||||
id: id,
|
||||
eventId: event.id,
|
||||
widget: Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingEffect(
|
||||
child: FloatingFeedbackText(
|
||||
key: ValueKey(id),
|
||||
icon: icon,
|
||||
color: color,
|
||||
size: size,
|
||||
feedback: "$feedbackText (${event.id.substring(0, 4)})",
|
||||
color: feedbackColor,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_floatingEffects.removeWhere((e) => e.id == id);
|
||||
_floatingFeedbackTexts.removeWhere((e) => e.id == id);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -233,64 +222,179 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
),
|
||||
);
|
||||
});
|
||||
return; // Return early for feedback
|
||||
}
|
||||
|
||||
// 1. Attack Animation Trigger (All Risk Levels)
|
||||
if (event.type == ActionType.attack &&
|
||||
event.target == EffectTarget.enemy &&
|
||||
event.feedbackType == null) {
|
||||
// Calculate target position (Enemy) relative to Player
|
||||
final RenderBox? playerBox =
|
||||
_playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? enemyBox =
|
||||
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
// Handle Icon Effect
|
||||
IconData icon = BattleConfig.getIcon(event.type);
|
||||
Color color = BattleConfig.getColor(event.type, event.risk);
|
||||
double size = BattleConfig.getSize(event.risk);
|
||||
|
||||
if (playerBox != null && enemyBox != null) {
|
||||
final playerPos = playerBox.localToGlobal(Offset.zero);
|
||||
final enemyPos = enemyBox.localToGlobal(Offset.zero);
|
||||
final String id = UniqueKey().toString();
|
||||
|
||||
final offset = enemyPos - playerPos;
|
||||
|
||||
// Start Animation: Hide Stats
|
||||
setState(() {
|
||||
_isPlayerAttacking = true;
|
||||
});
|
||||
|
||||
_playerAnimKey.currentState
|
||||
?.animateAttack(offset, () {
|
||||
showEffect(); // Show Effect at Impact!
|
||||
// Shake and Explosion ONLY for Risky
|
||||
if (event.risk == RiskLevel.risky) {
|
||||
_shakeKey.currentState?.shake();
|
||||
|
||||
RenderBox? stackBox =
|
||||
_stackKey.currentContext?.findRenderObject()
|
||||
as RenderBox?;
|
||||
if (stackBox != null) {
|
||||
Offset localEnemyPos = stackBox.globalToLocal(enemyPos);
|
||||
// Center of the enemy card roughly
|
||||
localEnemyPos += Offset(
|
||||
enemyBox.size.width / 2,
|
||||
enemyBox.size.height / 2,
|
||||
);
|
||||
_explosionKey.currentState?.explode(localEnemyPos);
|
||||
setState(() {
|
||||
_floatingEffects.add(
|
||||
FloatingEffectData(
|
||||
id: id,
|
||||
widget: Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingEffect(
|
||||
key: ValueKey(id),
|
||||
icon: icon,
|
||||
color: color,
|
||||
size: size,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_floatingEffects.removeWhere((e) => e.id == id);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Player Attack Animation Trigger (Success or Miss)
|
||||
if (event.type == ActionType.attack &&
|
||||
event.target == EffectTarget.enemy) {
|
||||
|
||||
final RenderBox? playerBox =
|
||||
_playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? enemyBox =
|
||||
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (playerBox != null && enemyBox != null) {
|
||||
final playerPos = playerBox.localToGlobal(Offset.zero);
|
||||
final enemyPos = enemyBox.localToGlobal(Offset.zero);
|
||||
final offset = enemyPos - playerPos;
|
||||
|
||||
setState(() {
|
||||
_isPlayerAttacking = true;
|
||||
});
|
||||
|
||||
// Force SAFE animation for MISS, otherwise use event risk
|
||||
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
|
||||
|
||||
_playerAnimKey.currentState
|
||||
?.animateAttack(offset, () {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
|
||||
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
|
||||
_shakeKey.currentState?.shake();
|
||||
RenderBox? stackBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackBox != null) {
|
||||
Offset localEnemyPos = stackBox.globalToLocal(enemyPos);
|
||||
localEnemyPos += Offset(
|
||||
enemyBox.size.width / 2,
|
||||
enemyBox.size.height / 2,
|
||||
);
|
||||
_explosionKey.currentState?.explode(localEnemyPos);
|
||||
}
|
||||
}, event.risk)
|
||||
.then((_) {
|
||||
// End Animation: Show Stats
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPlayerAttacking = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Not a player attack, show immediately
|
||||
showEffect();
|
||||
}
|
||||
}, animRisk)
|
||||
.then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPlayerAttacking = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// 2. Enemy Attack Animation Trigger (Success or Miss)
|
||||
else if (event.type == ActionType.attack &&
|
||||
event.target == EffectTarget.player) {
|
||||
|
||||
bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations;
|
||||
|
||||
if (!enableAnim) {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
return;
|
||||
}
|
||||
|
||||
final RenderBox? playerBox =
|
||||
_playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? enemyBox =
|
||||
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (playerBox != null && enemyBox != null) {
|
||||
final playerPos = playerBox.localToGlobal(Offset.zero);
|
||||
final enemyPos = enemyBox.localToGlobal(Offset.zero);
|
||||
final offset = playerPos - enemyPos;
|
||||
|
||||
setState(() {
|
||||
_isEnemyAttacking = true;
|
||||
});
|
||||
|
||||
// Force SAFE animation for MISS
|
||||
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
|
||||
|
||||
_enemyAnimKey.currentState
|
||||
?.animateAttack(offset, () {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
|
||||
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
|
||||
_shakeKey.currentState?.shake();
|
||||
RenderBox? stackBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackBox != null) {
|
||||
Offset localPlayerPos = stackBox.globalToLocal(playerPos);
|
||||
localPlayerPos += Offset(
|
||||
playerBox.size.width / 2,
|
||||
playerBox.size.height / 2,
|
||||
);
|
||||
_explosionKey.currentState?.explode(localPlayerPos);
|
||||
}
|
||||
}
|
||||
}, animRisk)
|
||||
.then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isEnemyAttacking = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3. Defend Animation Trigger (Success OR Failure)
|
||||
else if (event.type == ActionType.defend) {
|
||||
if (event.target == EffectTarget.player) {
|
||||
_playerAnimKey.currentState?.animateDefense(() {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
});
|
||||
} else if (event.target == EffectTarget.enemy) {
|
||||
_enemyAnimKey.currentState?.animateDefense(() {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
});
|
||||
} else {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
}
|
||||
}
|
||||
// 4. Others (Feedback for attacks, Buffs, etc.)
|
||||
else {
|
||||
showEffect();
|
||||
|
||||
// If it's a feedback event (MISS/FAILED for attacks), wait 500ms.
|
||||
if (event.feedbackType != null) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) context.read<BattleProvider>().handleImpact(event);
|
||||
});
|
||||
} else {
|
||||
// Success events (Icon)
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showRiskLevelSelection(BuildContext context, ActionType actionType) {
|
||||
|
|
@ -378,6 +482,11 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sync animation setting to provider logic
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
context.read<BattleProvider>().skipAnimations =
|
||||
!settings.enableEnemyAnimations;
|
||||
|
||||
return ResponsiveContainer(
|
||||
child: Consumer<BattleProvider>(
|
||||
builder: (context, battleProvider, child) {
|
||||
|
|
@ -421,7 +530,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
"Turn ${battleProvider.turnCount}",
|
||||
"${AppStrings.turn} ${battleProvider.turnCount}",
|
||||
style: const TextStyle(
|
||||
color: ThemeConfig.textColorWhite,
|
||||
fontSize: ThemeConfig.fontSizeHeader,
|
||||
|
|
@ -448,6 +557,8 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
isPlayer: false,
|
||||
isTurn: !battleProvider.isPlayerTurn,
|
||||
key: _enemyKey,
|
||||
animationKey: _enemyAnimKey, // Direct Pass
|
||||
hideStats: _isEnemyAttacking,
|
||||
),
|
||||
),
|
||||
// Player (Bottom Left)
|
||||
|
|
@ -540,12 +651,18 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
child: SimpleDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Text("Victory! Choose a Reward"),
|
||||
const Text(
|
||||
"${AppStrings.victory} ${AppStrings.chooseReward}",
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.monetization_on, color: ThemeConfig.statGoldColor, size: 18),
|
||||
Icon(
|
||||
Icons.monetization_on,
|
||||
color: ThemeConfig.statGoldColor,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
"${battleProvider.lastGoldReward} G",
|
||||
|
|
@ -568,7 +685,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"Inventory is full! Cannot take item.",
|
||||
"${AppStrings.inventoryFull} Cannot take item.",
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
|
|
@ -586,10 +703,11 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
decoration: BoxDecoration(
|
||||
color: Colors.blueGrey[700],
|
||||
borderRadius: BorderRadius.circular(
|
||||
4),
|
||||
4,
|
||||
),
|
||||
border: Border.all(
|
||||
color: item.rarity !=
|
||||
ItemRarity.magic
|
||||
color:
|
||||
item.rarity != ItemRarity.magic
|
||||
? ItemUtils.getRarityColor(
|
||||
item.rarity,
|
||||
)
|
||||
|
|
@ -613,7 +731,8 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
color: isSkip
|
||||
? ThemeConfig.textColorGrey
|
||||
: ItemUtils.getRarityColor(
|
||||
item.rarity),
|
||||
item.rarity,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -651,7 +770,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"DEFEAT",
|
||||
AppStrings.defeat,
|
||||
style: TextStyle(
|
||||
color: ThemeConfig.statHpColor,
|
||||
fontSize: ThemeConfig.fontSizeHuge,
|
||||
|
|
@ -677,7 +796,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
);
|
||||
},
|
||||
child: const Text(
|
||||
"Return to Main Menu",
|
||||
AppStrings.returnToMenu,
|
||||
style: TextStyle(
|
||||
color: ThemeConfig.textColorWhite,
|
||||
fontSize: ThemeConfig.fontSizeHeader,
|
||||
|
|
@ -698,10 +817,10 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
|
||||
Widget _buildItemStatText(Item item) {
|
||||
List<String> stats = [];
|
||||
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK");
|
||||
if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP");
|
||||
if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF");
|
||||
if (item.luck > 0) stats.add("+${item.luck} Luck");
|
||||
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ${AppStrings.atk}");
|
||||
if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}");
|
||||
if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}");
|
||||
if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}");
|
||||
|
||||
List<String> effectTexts = item.effects.map((e) => e.description).toList();
|
||||
|
||||
|
|
@ -715,7 +834,10 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0),
|
||||
child: Text(
|
||||
stats.join(", "),
|
||||
style: const TextStyle(fontSize: ThemeConfig.fontSizeMedium, color: ThemeConfig.statAtkColor),
|
||||
style: const TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeMedium,
|
||||
color: ThemeConfig.statAtkColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (effectTexts.isNotEmpty)
|
||||
|
|
@ -723,7 +845,10 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(
|
||||
effectTexts.join(", "),
|
||||
style: const TextStyle(fontSize: 11, color: ThemeConfig.rarityLegendary), // 11 is custom, keep or change? Let's use Small
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: ThemeConfig.rarityLegendary,
|
||||
), // 11 is custom, keep or change? Let's use Small
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../game/data/player_table.dart';
|
|||
import 'main_wrapper.dart';
|
||||
import '../widgets/responsive_container.dart';
|
||||
import '../game/config/theme_config.dart';
|
||||
import '../game/config/app_strings.dart';
|
||||
|
||||
class CharacterSelectionScreen extends StatelessWidget {
|
||||
const CharacterSelectionScreen({super.key});
|
||||
|
|
@ -83,19 +84,19 @@ class CharacterSelectionScreen extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Text(
|
||||
"HP: ${warrior.baseHp}",
|
||||
"${AppStrings.hp}: ${warrior.baseHp}",
|
||||
style: const TextStyle(
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"ATK: ${warrior.baseAtk}",
|
||||
"${AppStrings.atk}: ${warrior.baseAtk}",
|
||||
style: const TextStyle(
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"DEF: ${warrior.baseDefense}",
|
||||
"${AppStrings.def}: ${warrior.baseDefense}",
|
||||
style: const TextStyle(
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../game/model/item.dart';
|
|||
import '../game/enums.dart';
|
||||
import '../utils/item_utils.dart';
|
||||
import '../game/config/theme_config.dart';
|
||||
import '../game/config/app_strings.dart';
|
||||
|
||||
class InventoryScreen extends StatelessWidget {
|
||||
const InventoryScreen({super.key});
|
||||
|
|
@ -37,28 +38,28 @@ class InventoryScreen extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildStatItem(
|
||||
"HP",
|
||||
AppStrings.hp,
|
||||
"${player.hp}/${player.totalMaxHp}",
|
||||
color: ThemeConfig.statHpColor,
|
||||
),
|
||||
_buildStatItem(
|
||||
"ATK",
|
||||
AppStrings.atk,
|
||||
"${player.totalAtk}",
|
||||
color: ThemeConfig.statAtkColor,
|
||||
),
|
||||
_buildStatItem(
|
||||
"DEF",
|
||||
AppStrings.def,
|
||||
"${player.totalDefense}",
|
||||
color: ThemeConfig.statDefColor,
|
||||
),
|
||||
_buildStatItem("Shield", "${player.armor}"),
|
||||
_buildStatItem(AppStrings.armor, "${player.armor}"),
|
||||
_buildStatItem(
|
||||
"Luck",
|
||||
AppStrings.luck,
|
||||
"${player.totalLuck}",
|
||||
color: ThemeConfig.statLuckColor,
|
||||
),
|
||||
_buildStatItem(
|
||||
"Gold",
|
||||
AppStrings.gold,
|
||||
"${player.gold} G",
|
||||
color: ThemeConfig.statGoldColor,
|
||||
),
|
||||
|
|
@ -162,7 +163,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
item?.name ?? "Empty",
|
||||
item?.name ?? AppStrings.emptySlot,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize:
|
||||
|
|
@ -208,7 +209,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Bag (${player.inventory.length}/${player.maxInventorySize})",
|
||||
"${AppStrings.bag} (${player.inventory.length}/${player.maxInventorySize})",
|
||||
style: const TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeHeader,
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
|
|
@ -371,7 +372,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
children: [
|
||||
Icon(Icons.shield, color: ThemeConfig.btnDefendActive),
|
||||
SizedBox(width: 10),
|
||||
Text("Equip"),
|
||||
Text(AppStrings.equip),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -391,7 +392,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
color: ThemeConfig.statGoldColor,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text("Sell (${item.price} G)"),
|
||||
Text("${AppStrings.sell} (${item.price} G)"),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -407,7 +408,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
children: [
|
||||
Icon(Icons.delete, color: ThemeConfig.btnActionActive),
|
||||
SizedBox(width: 10),
|
||||
Text("Discard"),
|
||||
Text(AppStrings.discard),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -430,7 +431,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Cancel"),
|
||||
child: const Text(AppStrings.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
|
@ -440,7 +441,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
provider.sellItem(item);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text("Sell", style: TextStyle(color: Colors.black)),
|
||||
child: const Text(AppStrings.sell, style: TextStyle(color: Colors.black)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -460,7 +461,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Cancel"),
|
||||
child: const Text(AppStrings.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
|
@ -470,7 +471,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
provider.discardItem(item);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text("Discard"),
|
||||
child: const Text(AppStrings.discard),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -510,7 +511,7 @@ class InventoryScreen extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"Equip ${newItem.name}?",
|
||||
"${AppStrings.equip} ${newItem.name}?",
|
||||
style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold),
|
||||
),
|
||||
if (oldItem != null)
|
||||
|
|
@ -524,8 +525,8 @@ class InventoryScreen extends StatelessWidget {
|
|||
const SizedBox(height: 16),
|
||||
_buildStatChangeRow("Max HP", currentMaxHp, newMaxHp),
|
||||
_buildStatChangeRow("Current HP", currentHp, newHp),
|
||||
_buildStatChangeRow("ATK", currentAtk, newAtk),
|
||||
_buildStatChangeRow("DEF", currentDef, newDef),
|
||||
_buildStatChangeRow(AppStrings.atk, currentAtk, newAtk),
|
||||
_buildStatChangeRow(AppStrings.def, currentDef, newDef),
|
||||
_buildStatChangeRow(
|
||||
"LUCK",
|
||||
player.totalLuck,
|
||||
|
|
@ -536,14 +537,14 @@ class InventoryScreen extends StatelessWidget {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Cancel"),
|
||||
child: const Text(AppStrings.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
provider.equipItem(newItem);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text("Confirm"),
|
||||
child: const Text(AppStrings.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -582,27 +583,27 @@ class InventoryScreen extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"Unequip ${itemToUnequip.name}?",
|
||||
"${AppStrings.unequip} ${itemToUnequip.name}?",
|
||||
style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildStatChangeRow("Max HP", currentMaxHp, newMaxHp),
|
||||
_buildStatChangeRow("Current HP", currentHp, newHp),
|
||||
_buildStatChangeRow("ATK", currentAtk, newAtk),
|
||||
_buildStatChangeRow("DEF", currentDef, newDef),
|
||||
_buildStatChangeRow(AppStrings.atk, currentAtk, newAtk),
|
||||
_buildStatChangeRow(AppStrings.def, currentDef, newDef),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Cancel"),
|
||||
child: const Text(AppStrings.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
provider.unequipItem(itemToUnequip);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text("Confirm"),
|
||||
child: const Text(AppStrings.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -657,10 +658,10 @@ class InventoryScreen extends StatelessWidget {
|
|||
|
||||
Widget _buildItemStatText(Item item) {
|
||||
List<String> stats = [];
|
||||
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK");
|
||||
if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP");
|
||||
if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF");
|
||||
if (item.luck > 0) stats.add("+${item.luck} Luck");
|
||||
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ${AppStrings.atk}");
|
||||
if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}");
|
||||
if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}");
|
||||
if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}");
|
||||
|
||||
// Include effects
|
||||
List<String> effectTexts = item.effects.map((e) => e.description).toList();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../widgets/responsive_container.dart';
|
|||
import '../game/save_manager.dart';
|
||||
import '../providers/battle_provider.dart';
|
||||
import '../game/config/theme_config.dart';
|
||||
import '../game/config/app_strings.dart';
|
||||
|
||||
class MainMenuScreen extends StatefulWidget {
|
||||
const MainMenuScreen({super.key});
|
||||
|
|
@ -79,7 +80,7 @@ class _MainMenuScreenState extends State<MainMenuScreen> {
|
|||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
"COLOSSEUM'S CHOICE",
|
||||
AppStrings.gameTitle,
|
||||
style: TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeHero,
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
|
|
@ -112,7 +113,7 @@ class _MainMenuScreenState extends State<MainMenuScreen> {
|
|||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
),
|
||||
),
|
||||
child: const Text("CONTINUE"),
|
||||
child: const Text(AppStrings.continueGame),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
|
@ -139,7 +140,7 @@ class _MainMenuScreenState extends State<MainMenuScreen> {
|
|||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
),
|
||||
),
|
||||
child: const Text("NEW GAME"),
|
||||
child: const Text(AppStrings.startGame),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/battle_provider.dart';
|
||||
import '../providers/settings_provider.dart'; // Import SettingsProvider
|
||||
import 'main_menu_screen.dart';
|
||||
import '../game/config/theme_config.dart';
|
||||
import '../game/config/app_strings.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
|
@ -14,7 +16,7 @@ class SettingsScreen extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Settings',
|
||||
AppStrings.settings,
|
||||
style: TextStyle(
|
||||
fontSize: ThemeConfig.fontSizeTitle,
|
||||
fontWeight: ThemeConfig.fontWeightBold,
|
||||
|
|
@ -22,18 +24,34 @@ class SettingsScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
// Placeholder for future settings
|
||||
const Text(
|
||||
'Effect Intensity: Normal',
|
||||
style: TextStyle(color: ThemeConfig.textColorWhite),
|
||||
|
||||
// Enemy Animation Toggle
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settings, child) {
|
||||
return SizedBox(
|
||||
width: 300,
|
||||
child: SwitchListTile(
|
||||
title: const Text(
|
||||
AppStrings.enemyAnimations,
|
||||
style: TextStyle(color: ThemeConfig.textColorWhite),
|
||||
),
|
||||
value: settings.enableEnemyAnimations,
|
||||
onChanged: (value) {
|
||||
settings.toggleEnemyAnimations(value);
|
||||
},
|
||||
activeColor: ThemeConfig.btnActionActive,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Volume: 100%',
|
||||
style: TextStyle(color: ThemeConfig.textColorWhite),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
|
||||
// Restart Button
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
|
@ -43,7 +61,7 @@ class SettingsScreen extends StatelessWidget {
|
|||
onPressed: () {
|
||||
_showConfirmationDialog(
|
||||
context,
|
||||
title: 'Restart Game?',
|
||||
title: '${AppStrings.restart} Game?',
|
||||
content: 'All progress will be lost. Are you sure?',
|
||||
onConfirm: () {
|
||||
context.read<BattleProvider>().initializeBattle();
|
||||
|
|
@ -51,7 +69,7 @@ class SettingsScreen extends StatelessWidget {
|
|||
const SnackBar(content: Text('Game Restarted!')),
|
||||
);
|
||||
// Optionally switch tab back to Battle (index 0)
|
||||
// But MainWrapper controls the index.
|
||||
// But MainWrapper controls the index.
|
||||
// We can't easily switch tab from here without a callback or Provider.
|
||||
// For now, just restart logic is enough.
|
||||
},
|
||||
|
|
@ -70,24 +88,32 @@ class SettingsScreen extends StatelessWidget {
|
|||
onPressed: () {
|
||||
_showConfirmationDialog(
|
||||
context,
|
||||
title: 'Return to Main Menu?',
|
||||
content: 'Unsaved progress may be lost. (Progress is saved automatically after each stage)',
|
||||
title: '${AppStrings.returnToMenu}?',
|
||||
content:
|
||||
'Unsaved progress may be lost. (Progress is saved automatically after each stage)',
|
||||
onConfirm: () {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const MainMenuScreen()),
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MainMenuScreen(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: const Text('Return to Main Menu'),
|
||||
child: const Text(AppStrings.returnToMenu),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmationDialog(BuildContext context, {required String title, required String content, required VoidCallback onConfirm}) {
|
||||
void _showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String content,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -96,14 +122,17 @@ class SettingsScreen extends StatelessWidget {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: const Text(AppStrings.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
onConfirm();
|
||||
},
|
||||
child: const Text('Confirm', style: TextStyle(color: Colors.red)),
|
||||
child: const Text(
|
||||
AppStrings.confirm,
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -105,6 +105,41 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> animateDefense(VoidCallback onImpact) async {
|
||||
// Defense: Wobble/Shake horizontally
|
||||
_translateController.duration = const Duration(milliseconds: 800);
|
||||
|
||||
// Sequence: Left -> Right -> Center
|
||||
_translateAnimation =
|
||||
TweenSequence<Offset>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween<Offset>(begin: Offset.zero, end: const Offset(-10, 0)),
|
||||
weight: 25,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween<Offset>(
|
||||
begin: const Offset(-10, 0),
|
||||
end: const Offset(10, 0),
|
||||
),
|
||||
weight: 50,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween<Offset>(begin: const Offset(10, 0), end: Offset.zero),
|
||||
weight: 25,
|
||||
),
|
||||
]).animate(
|
||||
CurvedAnimation(
|
||||
parent: _translateController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
|
||||
await _translateController.forward();
|
||||
if (!mounted) return;
|
||||
onImpact();
|
||||
_translateController.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ class CharacterStatusCard extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 8), // 아이콘과 정보 사이 간격
|
||||
|
||||
if (!isPlayer)
|
||||
if (!isPlayer && !hideStats)
|
||||
Consumer<BattleProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.currentEnemyIntent != null && !character.isDead) {
|
||||
|
|
|
|||
|
|
@ -286,6 +286,11 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
|
|||
class FeedbackTextData {
|
||||
final String id;
|
||||
final Widget widget;
|
||||
final String eventId; // To prevent duplicates
|
||||
|
||||
FeedbackTextData({required this.id, required this.widget});
|
||||
FeedbackTextData({
|
||||
required this.id,
|
||||
required this.widget,
|
||||
required this.eventId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,161 +7,84 @@
|
|||
- **프로젝트명:** Colosseum's Choice
|
||||
- **플랫폼:** Flutter (Android/iOS/Web/Desktop)
|
||||
- **장르:** 텍스트 기반의 턴제 RPG + GUI (로그라이크 요소 포함)
|
||||
- **상태:** 프로토타입 단계 (전투 시각화, 데이터 주도 시스템, 반응형 UI 구현 완료)
|
||||
- **상태:** 핵심 시스템 구현 완료 및 안정화 (i18n 구조 적용, 애니메이션 동기화 완료)
|
||||
|
||||
## 2. 현재 구현된 핵심 기능 (Feature Status)
|
||||
|
||||
### A. 게임 흐름 (Game Flow)
|
||||
|
||||
1. **메인 메뉴 (`MainMenuScreen`):** 게임 시작 버튼.
|
||||
2. **캐릭터 선택 (`CharacterSelectionScreen`):** 'Warrior' 직업 구현.
|
||||
3. **메인 게임 (`MainWrapper`):** 하단 탭 네비게이션 (Battle / Inventory).
|
||||
4. **반응형 레이아웃 (Responsive UI):**
|
||||
- `ResponsiveContainer` 위젯을 통해 최대 너비(600px) 및 높이(1000px) 제한.
|
||||
- 웹/태블릿 환경에서도 모바일 앱처럼 중앙 정렬된 화면 제공.
|
||||
- **Battle UI Layout:** 플레이어(좌측 하단) vs 적(우측 상단) 대각선 구도 배치 및 캐릭터 임시 아이콘 적용.
|
||||
- **Widget Refactoring:** `BattleScreen`의 주요 UI 컴포넌트(`CharacterStatusCard`, `BattleLogOverlay` 등)를 `lib/widgets/battle/`로 분리하여 모듈화.
|
||||
1. **메인 메뉴 (`MainMenuScreen`):** 게임 시작, 이어하기(저장된 데이터 있을 시), 설정 버튼.
|
||||
2. **캐릭터 선택 (`CharacterSelectionScreen`):** 'Warrior' 직업 구현 (스탯 확인 후 시작).
|
||||
3. **메인 게임 (`MainWrapper`):** 하단 탭 네비게이션 (Battle / Inventory / Settings).
|
||||
4. **설정 (`SettingsScreen`):**
|
||||
- 적 애니메이션 활성화/비활성화 토글 (`SettingsProvider` 연동).
|
||||
- 게임 재시작, 메인 메뉴로 돌아가기 기능.
|
||||
5. **반응형 레이아웃 (Responsive UI):**
|
||||
- `ResponsiveContainer`를 통해 다양한 화면 크기 대응 (최대 너비/높이 제한).
|
||||
- Battle UI: 플레이어(좌하단) vs 적(우상단) 대각선 구도.
|
||||
|
||||
### B. 전투 시스템 (`BattleProvider`)
|
||||
|
||||
- **턴제 전투:** 플레이어 턴 -> 적 턴.
|
||||
- **행동 선택:** 공격(Attack) / 방어(Defend).
|
||||
- **리스크 시스템 (Risk System):** Safe(100%/50%), Normal(80%/100%), Risky(40%/200%) 선택.
|
||||
- **리스크 시스템 (Risk System):**
|
||||
- **Safe:** 성공률 100%+, 효율 50%.
|
||||
- **Normal:** 성공률 80%+, 효율 100%.
|
||||
- **Risky:** 성공률 40%+, 효율 200% (성공 시 강력한 이펙트).
|
||||
- **Luck 보정:** `totalLuck` 1당 성공률 +1%.
|
||||
- **적 인공지능 (Enemy AI & Intent):**
|
||||
- **Intent UI:** 적의 다음 행동(공격/방어, 리스크)을 미리 표시.
|
||||
- **선제 방어 (Pre-emptive Defense):** 적이 방어를 선택하면 턴 시작 전에 즉시 방어도가 적용됨.
|
||||
- **Defense Restriction:** `DefenseForbidden` 상태 시 방어 행동 선택 불가.
|
||||
- **Variance Removed:** 적의 공격/방어 수치 계산 시 랜덤 분산(Variance) 제거 (고정 수치).
|
||||
- **적 장비 시스템 (Enemy Equipment):**
|
||||
- 적에게 아이템 장착 가능 (`enemies.json`의 `equipment` 필드).
|
||||
- 장착된 아이템의 스탯 및 특수 효과(상태이상 등)가 전투 시 적용됨.
|
||||
- **시각 효과 (Visual Effects):**
|
||||
- **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황).
|
||||
- **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이.
|
||||
- **Effect Icons:** 공격/방어 및 리스크 레벨에 따른 아이콘 애니메이션 출력.
|
||||
- **Advanced Animations:**
|
||||
- **Risk-Based:** Safe(Wobble), Normal(Dash), Risky(Scale Up + Heavy Dash + Shake + Explosion).
|
||||
- **Icon-Only:** 공격 시 캐릭터 아이콘만 이동하며, 스탯 정보(HP/Armor)는 일시적으로 숨김 처리.
|
||||
- **Impact Sync:** 타격 이펙트와 데미지 텍스트가 애니메이션 타격 시점에 정확히 동기화됨.
|
||||
- **Intent UI:** 적의 다음 행동(공격/방어, 데미지/방어도) 미리 표시.
|
||||
- **동기화된 애니메이션:** 적 행동 결정(`_generateEnemyIntent`)은 이전 애니메이션이 완전히 끝난 후 이루어짐.
|
||||
- **선제 방어:** 적이 방어 행동을 선택하면 턴 시작 시 즉시 방어도가 적용됨.
|
||||
- **애니메이션 및 타격감 (Visuals & Impact):**
|
||||
- **UI 주도 Impact 처리:** 애니메이션 타격 시점(`onImpact`)에 정확히 데미지가 적용되고 텍스트가 뜸 (완벽한 동기화).
|
||||
- **적 돌진:** 적도 공격 시 플레이어 위치로 돌진함 (설정에서 끄기 가능).
|
||||
- **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`).
|
||||
- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`.
|
||||
- **행운 시스템 (Luck System):**
|
||||
- 아이템 옵션으로 `luck` 스탯 제공.
|
||||
- `totalLuck` 수치만큼 행동(공격/방어) 성공 확률 증가 (1 Luck = +1%).
|
||||
- 성공 확률은 최대 100%로 제한됨.
|
||||
- **UI:** 인벤토리에서 Luck 수치 확인 가능, 전투 시 Risk 선택 창에서 보정된 확률 표시.
|
||||
|
||||
### C. 데이터 주도 설계 (Data-Driven Design)
|
||||
### C. 데이터 및 로직 (Architecture)
|
||||
|
||||
- **JSON 데이터:** `assets/data/items.json` (ID 포함), `assets/data/enemies.json` (장비 포함).
|
||||
- **데이터 로더:** `ItemTable` (ID 조회 지원), `EnemyTable` (장비 장착 지원).
|
||||
- **Data-Driven:** `items.json`, `enemies.json`, `players.json`.
|
||||
- **Logic 분리:**
|
||||
- `BattleProvider`: UI 상태 관리 및 이벤트 스트림(`damageStream`, `effectStream`) 발송.
|
||||
- `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직 순수 함수화.
|
||||
- `BattleLogManager`: 전투 로그 관리.
|
||||
- `LootGenerator`: 아이템 생성, 접두사(Prefix) 부여, 랜덤 스탯 로직.
|
||||
- `SettingsProvider`: 전역 설정(애니메이션 on/off 등) 관리 및 영구 저장.
|
||||
- **Soft i18n:** UI 텍스트는 `lib/game/config/app_strings.dart`에서 통합 관리.
|
||||
- **Config:** `GameConfig`, `BattleConfig`, `ItemConfig` 등 설정 값 중앙화.
|
||||
|
||||
### D. 아이템 및 경제 (`Item`, `Inventory`)
|
||||
### D. 아이템 및 경제
|
||||
|
||||
- **장비:** 무기, 방어구, 방패, 장신구.
|
||||
- **아이콘 및 색상 (`ItemUtils`):**
|
||||
- 무기: 빨강 삼각형 (`Icons.change_history`)
|
||||
- 방패: 파랑 방패 (`Icons.shield`)
|
||||
- 갑옷: 파랑 옷 (`Icons.checkroom`)
|
||||
- 장신구: 보라 다이아몬드 (`Icons.diamond`)
|
||||
- **가격:** JSON 고정 가격 사용. 판매 시 60% (`GameMath.floor`) 획득.
|
||||
- **인벤토리:** 장착 슬롯 및 가방(Bag) 그리드 UI 구현.
|
||||
- **아이템 시스템 (Item System):**
|
||||
- **Rarity (희귀도):** Common, Rare, Epic, Legendary. (드랍 확률 관여)
|
||||
- **Tier (티어):** 1티어(초반), 2티어(중반), 3티어(후반). (라운드 진행도에 따라 등장 제한)
|
||||
- **획득 로직:** 현재 라운드(Tier)에 맞는 아이템 풀 내에서 Rarity 확률에 따라 결정.
|
||||
- **시스템:**
|
||||
- **Rarity:** Common ~ Unique.
|
||||
- **Tier:** 라운드 진행도에 따라 상위 티어 아이템 등장.
|
||||
- **Prefix:** Rarity에 따라 접두사가 붙으며 스탯이 변형됨 (예: "Sharp Wooden Sword").
|
||||
- **상점 (`ShopProvider`):** 아이템 구매/판매, 리롤(Reroll), 인벤토리 관리.
|
||||
|
||||
### E. 스테이지 시스템 (`StageModel`)
|
||||
### E. 저장 및 진행 (Persistence)
|
||||
|
||||
- **타입:** Battle, Shop, Rest, Elite.
|
||||
- **적 등장 테이블 (Enemy Pool):** 적 조우 시, 현재 스테이지(라운드)에 따라 등장 가능한 적 목록(`enemy_table_pool`)을 설정하여 해당 범위 내에서 적을 랜덤 생성해야 함.
|
||||
- **게임 구조 (Game Structure):**
|
||||
- **총 3라운드 (3 Rounds):** 각 라운드는 12개의 스테이지로 구성 (12/12/12).
|
||||
- **라운드 구성:**
|
||||
1. **1라운드:** 지하 불법 투기장 (Underground Illegal Arena)
|
||||
2. **2라운드:** 콜로세움 (Colosseum)
|
||||
3. **3라운드:** 왕의 투기장 (King's Arena) - 최종 보스(Final Boss) 등장.
|
||||
- **자동 저장:** 스테이지 클리어 시 `SaveManager`를 통해 자동 저장.
|
||||
- **Permadeath:** 패배 시 저장 데이터 삭제 (로그라이크 요소).
|
||||
|
||||
### F. 시스템 및 설정 (System & Settings)
|
||||
## 3. 작업 컨벤션 (Working Conventions)
|
||||
|
||||
- **설정 페이지 (Settings Screen):**
|
||||
- 게임 재시작 (Restart Game) 및 메인 메뉴로 돌아가기 (Return to Main Menu) 기능.
|
||||
- 하단 네비게이션 바(BottomNavigationBar)에 설정 탭 추가.
|
||||
- **로컬 저장 (Local Storage):**
|
||||
- `shared_preferences`를 사용하여 스테이지 클리어 시 자동 저장.
|
||||
- 메인 메뉴에서 '이어하기 (CONTINUE)' 버튼을 통해 저장된 시점부터 게임 재개 가능.
|
||||
- 저장 데이터: 스테이지 진행도, 턴 수, 플레이어 상태(체력, 장비, 인벤토리 등).
|
||||
|
||||
## 3. 핵심 파일 및 아키텍처
|
||||
|
||||
- **`lib/providers/battle_provider.dart`:**
|
||||
- **Core Logic:** 상태 관리, 전투 루프.
|
||||
- **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달.
|
||||
- **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등).
|
||||
- **`lib/utils/item_utils.dart`:** 아이템 타입별 아이콘 및 색상 로직 중앙화.
|
||||
- **`lib/widgets/battle/`:** `BattleScreen`에서 분리된 재사용 가능한 위젯들.
|
||||
- **UI Components:** `CharacterStatusCard`, `BattleLogOverlay`, `FloatingBattleTexts`, `StageUI`.
|
||||
- **Effects:** `BattleAnimationWidget` (공격 애니메이션), `ExplosionWidget` (파티클), `ShakeWidget` (화면 흔들림).
|
||||
- **`lib/widgets/responsive_container.dart`:** 반응형 레이아웃 컨테이너.
|
||||
- **`lib/game/model/`:**
|
||||
- `damage_event.dart`, `effect_event.dart`: 이벤트 모델.
|
||||
- `entity.dart`: `Character` (Player/Enemy).
|
||||
- `item.dart`: `Item` (ID 필드 포함).
|
||||
- **`lib/screens/battle_screen.dart`:**
|
||||
- `StreamSubscription`을 통해 이펙트 이벤트 수신 및 `Overlay` 애니메이션 렌더링.
|
||||
- `Stack` 및 `Positioned` 기반의 정교한 레이아웃.
|
||||
|
||||
## 4. 작업 컨벤션 (Working Conventions)
|
||||
|
||||
- **Prompt Driven Development:** `prompt/XX_description.md` 유지.
|
||||
- **유사 작업 통합:** 작업 내용이 이전 프롬프트와 유사한 경우 새로운 프롬프트를 생성하지 않고 기존 프롬프트에 내용을 추가합니다.
|
||||
- **Language:** **모든 프롬프트 파일(prompt/XX\_...)은 반드시 한국어(Korean)로 작성해야 합니다.**
|
||||
- **Prompt Driven Development:** `prompt/XX_description.md` 유지. (유사 작업 통합 및 인덱스 정리 권장)
|
||||
- **i18n Strategy (Soft i18n):** UI에 표시되는 문자열은 하드코딩하지 않고 `lib/game/config/app_strings.dart`의 상수를 사용해야 합니다. (전투 로그 등 동적 문자열 제외)
|
||||
- **Config Management:** 하드코딩되는 값들은 `config` 폴더 내 파일들(`lib/game/config/` 등)에서 통합 관리할 수 있도록 작성해야 합니다.
|
||||
- **State Management:** `Provider` + `Stream` (이벤트성 데이터).
|
||||
- **Data:** JSON 기반.
|
||||
- **State Management:** `Provider` (UI 상태) + `Stream` (이벤트성 데이터).
|
||||
- **Data:** JSON 기반 + `Table` 클래스로 로드.
|
||||
|
||||
## 5. 다음 단계 작업 (Next Steps)
|
||||
## 4. 최근 주요 변경 사항 (Change Log)
|
||||
|
||||
1. **아이템 시스템 고도화:** `items.json`에 `rarity`, `tier` 필드 추가 및 `ItemTable` 로직 수정.
|
||||
2. **[x] 상점 구매 기능:** `Shop` 스테이지 구매 UI 구현 (Tier/Rarity 기반 목록 생성).
|
||||
3. **적 등장 테이블 구현:** 스테이지별 등장 가능한 적 목록(`enemy_table_pool`) 설정 및 적용.
|
||||
4. **이미지 리소스 적용:** JSON 경로에 맞는 실제 이미지 파일 추가 및 UI 표시.
|
||||
5. **밸런싱 및 콘텐츠 확장:** 아이템/적 데이터 추가 및 밸런스 조정.
|
||||
- **[Refactor] BattleProvider:** `CombatCalculator`, `BattleLogManager`, `LootGenerator` 분리로 코드 다이어트.
|
||||
- **[Refactor] Animation Sync:** `Future.delayed` 예측 방식을 버리고, UI 애니메이션 콜백(`onImpact`)을 통해 로직을 트리거하는 방식으로 변경하여 타격감 동기화 해결.
|
||||
- **[Refactor] Settings:** `SettingsProvider` 도입 및 적 애니메이션 토글 기능 추가.
|
||||
- **[Fix] Bugs:** 아이템 이름 생성 오류 수정, 리워드 팝업 깜빡임 및 중복 생성 수정, 앱 크래시(Null Safety) 수정.
|
||||
|
||||
## 6. 장기 목표 (Future Roadmap / TODO)
|
||||
## 5. 다음 단계 (Next Steps)
|
||||
|
||||
- [ ] **출혈 상태 이상 조건 변경:** 공격 시 상대방의 방어도에 의해 공격이 완전히 막힐 경우, 출혈 상태 이상이 적용되지 않도록 로직 변경.
|
||||
- [ ] **장비 분해 시스템 (적 장비):** 플레이어 장비의 옵션으로 상대방의 장비를 분해하여 '언암드' 상태로 만들 수 있는 시스템 구현.
|
||||
- [ ] **플레이어 공격 데미지 분산(Variance) 적용 여부 검토:** 현재 적에게만 적용된 +/- 20% 데미지 분산을 플레이어에게도 적용할지 결정.
|
||||
- [x] **애니메이션 및 타격감 고도화:**
|
||||
- 캐릭터별 이미지 추가 및 하스스톤 스타일의 공격 모션(대상에게 돌진 후 타격) 구현 완료 (Icon-Only Animation).
|
||||
- **Risky 공격:** 하스스톤의 7데미지 이상 타격감(화면 흔들림, 강렬한 파티클 및 임팩트) 구현 완료.
|
||||
- [ ] **체력 조건부 특수 능력:** 캐릭터의 체력이 30% 미만일 때 발동 가능한 특수 능력 시스템 구현.
|
||||
- [ ] **영구 스탯 수정자 로직 적용 (필수):**
|
||||
- 현재 `Character` 클래스에 `permanentModifiers` 필드만 선언되어 있음.
|
||||
- 추후 `totalAtk`, `totalDefense`, `totalMaxHp` 계산 시 이 수정자들을 반드시 반영해야 함.
|
||||
- [ ] **Google OAuth 로그인 및 계정 연동:**
|
||||
- Firebase Auth 등을 활용한 구글 로그인 구현.
|
||||
- Firestore 또는 Realtime Database에 유저 계정 정보(진행 상황, 재화 등) 저장 및 불러오기 기능 추가.
|
||||
- _Note: 이 기능은 게임의 핵심 로직이 안정화된 후, 완전 나중에 진행할 예정입니다._
|
||||
- [ ] **설정 페이지 (Settings Page) 구현 (Priority: Very Low):**
|
||||
- **이펙트 강도 조절 (Effect Intensity):** 1 ~ 999 범위로 설정 가능.
|
||||
- **Easter Egg:** 강도를 999로 설정하고 Risky 공격 성공 시, "심각한 오류로 프로세스가 종료되었습니다" 같은 페이크 시스템 팝업 출력.
|
||||
|
||||
---
|
||||
|
||||
**이 프롬프트를 읽은 AI 에이전트는 위 내용을 바탕으로 즉시 개발을 이어가십시오.**
|
||||
|
||||
## 7. 프롬프트 히스토리 (Prompt History)
|
||||
|
||||
- [x] 45_config_refactoring.md
|
||||
- [x] 46_shop_refactoring.md
|
||||
- [x] 47_inventory_full_handling.md
|
||||
- [x] 48_refactor_stage_ui.md
|
||||
- [x] 49_implement_item_icons.md
|
||||
- [x] 50_expand_item_pool.md
|
||||
- [x] 51_refactor_prefix_table.md
|
||||
- [x] 52_round_based_enemy_pool.md
|
||||
- [x] 53_refine_stage_rewards.md
|
||||
- [x] 54_fix_shop_logic.md
|
||||
- [x] 55_fix_shop_ui_sync.md
|
||||
- [x] 56_permadeath_implementation.md
|
||||
1. **밸런싱:** 현재 몬스터 및 아이템 스탯 미세 조정.
|
||||
2. **콘텐츠 확장:** 더 많은 아이템, 적, 스킬 패턴 추가.
|
||||
3. **튜토리얼:** 신규 유저를 위한 가이드 추가.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# 57. BattleProvider Refactoring
|
||||
|
||||
## 1. 목표 (Goal)
|
||||
- 비대해진 `BattleProvider` 클래스(약 900라인)를 역할별로 분리하여 유지보수성을 높이고 가독성을 개선합니다.
|
||||
- `CombatCalculator`(전투 계산)와 `BattleLogManager`(로그 관리) 클래스를 도입합니다.
|
||||
|
||||
## 2. 구현 계획 (Implementation Plan)
|
||||
1. **디렉토리 생성:** `lib/game/logic` 폴더를 생성하여 로직 클래스들을 모아둡니다.
|
||||
2. **`BattleLogManager` 분리:**
|
||||
- 전투 로그 리스트(`_battleLogs`)와 로그 추가 메서드(`logBattleInfo`)를 전담하는 클래스를 생성합니다.
|
||||
3. **`CombatCalculator` 분리:**
|
||||
- 공격/방어 성공 확률, 데미지 산출 로직, 상태이상 적용 확률 등 순수 계산 로직을 분리합니다.
|
||||
4. **`BattleProvider` 수정:**
|
||||
- 위 클래스들을 인스턴스로 포함하고, 해당 로직을 위임(delegation) 처리합니다.
|
||||
- `ChangeNotifier`로서의 UI 상태 관리 책임은 유지합니다.
|
||||
|
||||
## 3. 기대 효과 (Expected Outcome)
|
||||
- `BattleProvider`의 코드 라인 수 감소.
|
||||
- 전투 공식 수정 시 `CombatCalculator`만 수정하면 되므로 안전성 확보.
|
||||
- 로그 포맷이나 저장 방식 변경 시 `BattleLogManager`만 수정하면 됨.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# 58. Refactor Item Creation Logic
|
||||
|
||||
## 1. 목표 (Goal)
|
||||
- `ItemTemplate` 클래스 내부에 강하게 결합된 아이템 생성 및 접두사(Prefix) 적용 로직을 분리합니다.
|
||||
- `LootGenerator` 클래스를 생성하여 전리품 생성 및 옵션 부여 로직을 중앙화합니다.
|
||||
|
||||
## 2. 구현 계획 (Implementation Plan)
|
||||
1. **`LootGenerator` 생성 (`lib/game/logic/loot_generator.dart`):**
|
||||
- `ItemTemplate`을 입력받아 실제 `Item` 객체를 생성하는 static 메서드 `generate`를 구현합니다.
|
||||
- 기존 `createItem`에 있던 Rarity별 접두사 처리, 스탯 보정, 이름 변경 로직을 이곳으로 이동합니다.
|
||||
2. **`ItemTemplate` 수정 (`lib/game/data/item_table.dart`):**
|
||||
- `createItem` 메서드가 `LootGenerator`를 호출하도록 변경하거나, 해당 메서드를 제거하고 호출부(`BattleProvider`, `EnemyTemplate`)에서 `LootGenerator`를 직접 사용하도록 수정합니다.
|
||||
- (호환성을 위해 `createItem`은 `LootGenerator`를 호출하는 래퍼로 남겨두는 것을 권장)
|
||||
|
||||
## 3. 기대 효과 (Expected Outcome)
|
||||
- `ItemTemplate`은 순수한 데이터 정의(DTO) 역할에 집중.
|
||||
- 아이템 생성 알고리즘(접두사, 랜덤 스탯 등)이 변경되더라도 데이터 클래스에는 영향 없음.
|
||||
- 추후 '제작(Crafting)' 시스템이나 '상점 전용 생성' 등 다양한 생성 규칙 추가 시 `LootGenerator` 확장 용이.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# 59. Centralize Constants and Configuration
|
||||
|
||||
## 1. 목표 (Goal)
|
||||
- 코드 곳곳에 흩어져 있는 '매직 넘버(Magic Numbers)'와 하드코딩된 설정 값들을 `lib/game/config/` 폴더 내의 설정 파일들로 중앙화합니다.
|
||||
- 특히 전투 공식, 확률, 아이템 생성 가중치 등을 설정 파일로 이동하여 밸런스 조정 및 유지보수를 용이하게 합니다.
|
||||
|
||||
## 2. 구현 계획 (Implementation Plan)
|
||||
1. **설정 파일 업데이트:**
|
||||
- `BattleConfig`: 리스크 레벨별 확률, 효율(Efficiency), 데미지 분산 범위(현재는 제거됨, 필요 시 부활), 상태이상 확률 등.
|
||||
- `ItemConfig`: 아이템 생성 시 Rarity 가중치(이미 일부 존재), Prefix 등장 확률 등.
|
||||
- `GameConfig`: 골드 보상 공식 상수, 스테이지 관련 상수 등.
|
||||
2. **코드 리팩토링:**
|
||||
- `CombatCalculator`: 하드코딩된 리스크 확률(0.5, 0.8, 0.4 등)과 효율(0.5, 1.0, 2.0)을 `BattleConfig` 상수로 대체.
|
||||
- `LootGenerator`: Prefix 등장 확률(50% 등)을 `ItemConfig` 상수로 대체.
|
||||
- `BattleProvider`: 골드 계산 공식 상수를 `GameConfig`로 이동.
|
||||
|
||||
## 3. 기대 효과 (Expected Outcome)
|
||||
- 게임 밸런스 조정 시 코드 로직을 건드리지 않고 `config` 파일만 수정하면 됨.
|
||||
- 코드의 가독성이 향상됨 (숫자의 의미가 변수명으로 명확해짐).
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# 61. System Stabilization & Refactoring (Summary)
|
||||
|
||||
## 1. 개요
|
||||
이 문서는 프로젝트 안정화 및 리팩토링 과정에서 진행된 61번부터 76번까지의 작업 내용을 요약 및 통합한 것입니다.
|
||||
|
||||
## 2. 주요 변경 사항
|
||||
|
||||
### A. 구조 개선 및 리팩토링
|
||||
- **i18n 적용 (Soft i18n):** `AppStrings.dart`를 도입하여 UI 텍스트를 중앙화했습니다.
|
||||
- **설정 시스템 (`SettingsProvider`):** 적 애니메이션 On/Off 등 게임 설정을 관리하고 영구 저장하는 시스템을 구축했습니다.
|
||||
- **전투 로직 동기화 (UI-Driven Impact):**
|
||||
- 기존 `Future.delayed` 기반의 불안정한 타이밍 로직을 제거했습니다.
|
||||
- UI(`BattleScreen`)의 애니메이션 타격 시점(`onImpact`)에 `BattleProvider`의 데미지 로직(`handleImpact`)을 호출하는 구조로 변경하여 시각 효과와 데이터 처리를 완벽하게 동기화했습니다.
|
||||
- **적 Intent 생성 지연:** 적의 공격 애니메이션이 완전히 끝난 후 다음 행동을 결정하도록 하여, 시각적 혼란(공격 중 방어 이펙트 출력 등)을 방지했습니다.
|
||||
|
||||
### B. 버그 수정
|
||||
- **Null Safety Crash:** 공격 실패 시 `EffectEvent`의 null 값을 참조하여 앱이 종료되는 문제를 수정했습니다.
|
||||
- **리워드 시스템:**
|
||||
- 리워드 팝업이 깜빡이거나 이전 데이터를 보여주는 문제 수정.
|
||||
- 승리 시 리워드가 중복 생성(두 번 호출)되는 문제 수정.
|
||||
- **아이템 이름:** `LootGenerator`의 문자열 보간 오류로 인해 "Instance of..."가 출력되던 문제를 수정했습니다.
|
||||
- **애니메이션 중복:** 적 캐릭터 카드에 애니메이션 위젯이 중복 적용되어 발생하던 이상 현상을 수정했습니다.
|
||||
|
||||
### C. 기능 추가
|
||||
- **적 공격 애니메이션:** 플레이어와 마찬가지로 적도 공격 시 대상을 향해 돌진하는 애니메이션을 추가했습니다.
|
||||
|
||||
이 작업들을 통해 게임의 안정성, 코드의 유지보수성, 그리고 플레이어의 시각적 경험이 크게 향상되었습니다.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# 62. 애니메이션 및 피드백 동기화 관련 이슈 진행 현황
|
||||
|
||||
## 1. 문제 발생 현상
|
||||
* 플레이어 공격 실패(`MISS`) 시, 화면에 `MISS` 텍스트가 두 번 올라오는 현상 발생.
|
||||
* 적의 방어 실패(`FAILED`) 시에도 유사한 중복 텍스트 현상 발생.
|
||||
* 로그상 (`[UI Debug] Feedback Event`)으로는 이벤트가 한 번만 발생했지만, UI에는 두 번 표시됨.
|
||||
* `_addFloatingEffect` 함수 내부에 `eventId` 기반의 중복 체크 로직이 추가되었음에도 현상 지속.
|
||||
|
||||
## 2. 진단 및 해결 시도
|
||||
### 2.1. 원인 가설
|
||||
1. **`BattleScreen` 인스턴스 중복:** 가장 유력한 가설. 하나의 `EffectEvent`가 발생했을 때, 여러 `BattleScreen` 인스턴스가 각자 이벤트를 받아 화면에 피드백 텍스트를 띄우는 경우.
|
||||
* `[UI Debug] BattleScreen initialized: ${hashCode}` 로그로 확인 필요. (현재 확인되지 않음)
|
||||
2. **`_addFloatingEffect` 내부의 `setState` 문제:** `setState` 호출 시 `_floatingFeedbackTexts` 리스트에 위젯이 중복으로 추가되거나, 위젯 렌더링 과정에서 불필요한 복제가 발생하는 경우. (리스트 `clear()` 로직 추가로 해결 시도 중)
|
||||
3. **UI 렌더링 타이밍/시각적 착시:** `FloatingFeedbackText` 위젯의 생명주기가 꼬여서 이전 텍스트가 완전히 사라지기 전에 새 텍스트가 뜨거나, 애니메이션이 반복되는 것처럼 보이는 착시.
|
||||
|
||||
### 2.2. 현재까지 적용된 주요 조치
|
||||
* **`EffectEvent` `eventId` 기반 중복 체크 (UI 레벨):** `_addFloatingEffect` 함수에서 `eventId`를 기반으로 동일한 이벤트에 대한 피드백 텍스트가 이미 리스트에 있다면 추가하지 않도록 `_floatingFeedbackTexts.any((e) => e.eventId == event.id)` 로직 추가.
|
||||
* **`_floatingFeedbackTexts.clear()` 도입:** 새로운 피드백 텍스트(`MISS`/`FAILED`)가 뜰 때, 기존의 모든 피드백 텍스트를 리스트에서 제거한 후 추가하도록 수정. (화면에 항상 하나의 피드백 텍스트만 유지)
|
||||
* **`addPostFrameCallback` 제거:** `_addFloatingEffect` 내 `WidgetsBinding.instance.addPostFrameCallback` 제거 (불필요한 비동기 지연 및 잠재적 문제 방지).
|
||||
* **디버그 로그 추가:**
|
||||
* `[UI Debug] BattleScreen initialized: ${hashCode}` (`BattleScreen` 초기화 횟수 확인용)
|
||||
* `[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}` (`_addFloatingEffect` 호출 확인용)
|
||||
* `FloatingFeedbackText`에 `event.id` 전체 표시 (화면상 ID 일치 여부 확인용)
|
||||
|
||||
## 3. 남아있는 문제 (현재 진단)
|
||||
* 로그상 `[UI Debug] Feedback Event`는 한 번만 찍히지만, **화면에는 `MISS` 텍스트가 두 번 표시됨.**
|
||||
* 이는 **UI 레벨에서의 렌더링 문제**이거나, `_addFloatingEffect` 함수 **내부 로직 중 `setState`가 비정상적으로 두 번 호출**되는 문제일 가능성이 높습니다.
|
||||
* `_floatingFeedbackTexts.clear()` 로직이 추가되었으므로, 같은 리스트에 두 번 추가되는 것은 막혔을 것입니다.
|
||||
|
||||
## 4. 다음 단계 제안
|
||||
* **`[UI Debug] BattleScreen initialized: ...` 로그 결과 확인:** 이 로그가 두 번 이상 찍힌다면 `BattleScreen` 인스턴스가 중복된 것이므로, `MainWrapper`나 라우팅 구조를 점검해야 합니다.
|
||||
* **화면상 `MISS` 텍스트의 ID 확인:** 화면에 보이는 두 개의 `MISS` 텍스트의 ID가 **정확히 동일한지** 확인 필요 (현재 `event.id` 전체를 표시하도록 수정됨).
|
||||
* **ID가 동일하다면:** 하나의 `FeedbackTextData` 객체가 UI에 중복 렌더링되는 문제. (Key 문제, `Stack` 리빌드 문제 등)
|
||||
* **ID가 다르다면:** `_addFloatingEffect` 자체가 두 번 호출된 것. (로그가 하나라는 것과 모순됨. 로그 시스템 확인 필요)
|
||||
|
||||
**현재까지의 모든 문제 해결 노력은 `BattleProvider` 내의 로직 중복이나 타이밍 오류를 잡는 데 초점을 맞췄습니다. 하지만 `MISS` 텍스트 중복 문제는 `BattleScreen` (UI) 쪽에서 발생하는 현상으로 보입니다.**
|
||||
Loading…
Reference in New Issue