update
This commit is contained in:
parent
5b1ce14ed4
commit
8f72b9a812
|
|
@ -59,6 +59,7 @@
|
||||||
"baseHp": 40,
|
"baseHp": 40,
|
||||||
"baseAtk": 10,
|
"baseAtk": 10,
|
||||||
"baseDefense": 2,
|
"baseDefense": 2,
|
||||||
|
"baseDodge": 25,
|
||||||
"image": "assets/images/enemies/shadow_assassin.png",
|
"image": "assets/images/enemies/shadow_assassin.png",
|
||||||
"equipment": ["jagged_dagger"],
|
"equipment": ["jagged_dagger"],
|
||||||
"tier": 3
|
"tier": 3
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,18 @@
|
||||||
{
|
{
|
||||||
"id": "rusty_dagger",
|
"id": "rusty_dagger",
|
||||||
"name": "Rusty Dagger",
|
"name": "Rusty Dagger",
|
||||||
"description": "Old and rusty, but better than nothing.",
|
"description": "Old and rusty, but can disarm foes.",
|
||||||
"baseAtk": 3,
|
"baseAtk": 3,
|
||||||
"slot": "weapon",
|
"slot": "weapon",
|
||||||
"price": 30,
|
"price": 30,
|
||||||
"image": "assets/images/items/rusty_dagger.png",
|
"image": "assets/images/items/rusty_dagger.png",
|
||||||
|
"effects": [
|
||||||
|
{
|
||||||
|
"type": "disarmed",
|
||||||
|
"probability": 100,
|
||||||
|
"duration": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
"rarity": "magic",
|
"rarity": "magic",
|
||||||
"tier": "tier1"
|
"tier": "tier1"
|
||||||
},
|
},
|
||||||
|
|
@ -42,7 +49,14 @@
|
||||||
"price": 80,
|
"price": 80,
|
||||||
"image": "assets/images/items/iron_sword.png",
|
"image": "assets/images/items/iron_sword.png",
|
||||||
"rarity": "magic",
|
"rarity": "magic",
|
||||||
"tier": "tier2"
|
"tier": "tier2",
|
||||||
|
"effects": [
|
||||||
|
{
|
||||||
|
"type": "disarmed",
|
||||||
|
"probability": 100,
|
||||||
|
"duration": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "battle_axe",
|
"id": "battle_axe",
|
||||||
|
|
@ -267,4 +281,4 @@
|
||||||
"tier": "tier3"
|
"tier": "tier3"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,17 @@
|
||||||
"baseHp": 50,
|
"baseHp": 50,
|
||||||
"baseAtk": 5,
|
"baseAtk": 5,
|
||||||
"baseDefense": 5,
|
"baseDefense": 5,
|
||||||
|
"baseDodge": 2,
|
||||||
"image": "assets/images/players/warrior.png"
|
"image": "assets/images/players/warrior.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rogue",
|
||||||
|
"name": "Rogue",
|
||||||
|
"description": "A swift shadow with high evasion but lower health.",
|
||||||
|
"baseHp": 40,
|
||||||
|
"baseAtk": 7,
|
||||||
|
"baseDefense": 2,
|
||||||
|
"baseDodge": 15,
|
||||||
|
"image": "assets/images/players/rogue.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ class GameConfig {
|
||||||
static const double stageHealRatio = 0.1;
|
static const double stageHealRatio = 0.1;
|
||||||
static const double vulnerableDamageMultiplier = 1.5;
|
static const double vulnerableDamageMultiplier = 1.5;
|
||||||
static const double armorDecayRate = 1.0;
|
static const double armorDecayRate = 1.0;
|
||||||
|
static const double disarmedDamageMultiplier =
|
||||||
|
0.2; // New: Reduces ATK to 10% when disarmed
|
||||||
|
|
||||||
// Rewards
|
// Rewards
|
||||||
static const int baseGoldReward = 10;
|
static const int baseGoldReward = 10;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ class EnemyTemplate {
|
||||||
final int baseHp;
|
final int baseHp;
|
||||||
final int baseAtk;
|
final int baseAtk;
|
||||||
final int baseDefense;
|
final int baseDefense;
|
||||||
|
final int baseDodge; // New: Base dodge chance
|
||||||
final String? image;
|
final String? image;
|
||||||
final List<String> equipmentIds;
|
final List<String> equipmentIds;
|
||||||
final int tier;
|
final int tier;
|
||||||
|
|
@ -20,6 +21,7 @@ class EnemyTemplate {
|
||||||
required this.baseHp,
|
required this.baseHp,
|
||||||
required this.baseAtk,
|
required this.baseAtk,
|
||||||
required this.baseDefense,
|
required this.baseDefense,
|
||||||
|
this.baseDodge = 1, // Default value
|
||||||
this.image,
|
this.image,
|
||||||
this.equipmentIds = const [],
|
this.equipmentIds = const [],
|
||||||
this.tier = 1,
|
this.tier = 1,
|
||||||
|
|
@ -31,6 +33,7 @@ class EnemyTemplate {
|
||||||
baseHp: json['baseHp'] ?? 10,
|
baseHp: json['baseHp'] ?? 10,
|
||||||
baseAtk: json['baseAtk'] ?? 1,
|
baseAtk: json['baseAtk'] ?? 1,
|
||||||
baseDefense: json['baseDefense'] ?? 0,
|
baseDefense: json['baseDefense'] ?? 0,
|
||||||
|
baseDodge: json['baseDodge'] ?? 1, // Parse from JSON or default to 1
|
||||||
image: json['image'],
|
image: json['image'],
|
||||||
equipmentIds: (json['equipment'] as List<dynamic>?)?.cast<String>() ?? [],
|
equipmentIds: (json['equipment'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||||
tier: json['tier'] ?? 1,
|
tier: json['tier'] ?? 1,
|
||||||
|
|
@ -46,6 +49,7 @@ class EnemyTemplate {
|
||||||
maxHp: baseHp,
|
maxHp: baseHp,
|
||||||
atk: baseAtk,
|
atk: baseAtk,
|
||||||
baseDefense: baseDefense,
|
baseDefense: baseDefense,
|
||||||
|
baseDodge: baseDodge, // Pass baseDodge to Character constructor
|
||||||
armor: 0,
|
armor: 0,
|
||||||
image: image,
|
image: image,
|
||||||
);
|
);
|
||||||
|
|
@ -85,7 +89,10 @@ class EnemyTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a random enemy suitable for the current stage.
|
/// Returns a random enemy suitable for the current stage.
|
||||||
static EnemyTemplate getRandomEnemy({required int stage, bool isElite = false}) {
|
static EnemyTemplate getRandomEnemy({
|
||||||
|
required int stage,
|
||||||
|
bool isElite = false,
|
||||||
|
}) {
|
||||||
int targetTier = 1;
|
int targetTier = 1;
|
||||||
if (stage > GameConfig.tier2StageMax) {
|
if (stage > GameConfig.tier2StageMax) {
|
||||||
targetTier = 3;
|
targetTier = 3;
|
||||||
|
|
@ -94,7 +101,7 @@ class EnemyTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
List<EnemyTemplate> pool = isElite ? eliteEnemies : normalEnemies;
|
List<EnemyTemplate> pool = isElite ? eliteEnemies : normalEnemies;
|
||||||
|
|
||||||
// Filter by tier
|
// Filter by tier
|
||||||
var tierPool = pool.where((e) => e.tier == targetTier).toList();
|
var tierPool = pool.where((e) => e.tier == targetTier).toList();
|
||||||
|
|
||||||
|
|
@ -108,7 +115,12 @@ class EnemyTable {
|
||||||
|
|
||||||
if (tierPool.isEmpty) {
|
if (tierPool.isEmpty) {
|
||||||
// Should not happen if JSON is correct
|
// Should not happen if JSON is correct
|
||||||
return const EnemyTemplate(name: "Fallback Enemy", baseHp: 10, baseAtk: 1, baseDefense: 0);
|
return const EnemyTemplate(
|
||||||
|
name: "Fallback Enemy",
|
||||||
|
baseHp: 10,
|
||||||
|
baseAtk: 1,
|
||||||
|
baseDefense: 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tierPool[_random.nextInt(tierPool.length)];
|
return tierPool[_random.nextInt(tierPool.length)];
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class ItemTemplate {
|
||||||
final int atkBonus;
|
final int atkBonus;
|
||||||
final int hpBonus;
|
final int hpBonus;
|
||||||
final int armorBonus;
|
final int armorBonus;
|
||||||
|
final int dodge; // New
|
||||||
final EquipmentSlot slot;
|
final EquipmentSlot slot;
|
||||||
final List<ItemEffect> effects;
|
final List<ItemEffect> effects;
|
||||||
final int price;
|
final int price;
|
||||||
|
|
@ -30,6 +31,7 @@ class ItemTemplate {
|
||||||
required this.atkBonus,
|
required this.atkBonus,
|
||||||
required this.hpBonus,
|
required this.hpBonus,
|
||||||
required this.armorBonus,
|
required this.armorBonus,
|
||||||
|
this.dodge = 0,
|
||||||
required this.slot,
|
required this.slot,
|
||||||
required this.effects,
|
required this.effects,
|
||||||
required this.price,
|
required this.price,
|
||||||
|
|
@ -54,6 +56,7 @@ class ItemTemplate {
|
||||||
atkBonus: json['atkBonus'] ?? json['baseAtk'] ?? 0,
|
atkBonus: json['atkBonus'] ?? json['baseAtk'] ?? 0,
|
||||||
hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0,
|
hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0,
|
||||||
armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0,
|
armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0,
|
||||||
|
dodge: json['dodge'] ?? 0,
|
||||||
slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']),
|
slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']),
|
||||||
effects: effectsList,
|
effects: effectsList,
|
||||||
price: json['price'] ?? 10,
|
price: json['price'] ?? 10,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class PlayerTemplate {
|
||||||
final int baseHp;
|
final int baseHp;
|
||||||
final int baseAtk;
|
final int baseAtk;
|
||||||
final int baseDefense;
|
final int baseDefense;
|
||||||
|
final int baseDodge; // New field
|
||||||
final String? image;
|
final String? image;
|
||||||
|
|
||||||
const PlayerTemplate({
|
const PlayerTemplate({
|
||||||
|
|
@ -18,6 +19,7 @@ class PlayerTemplate {
|
||||||
required this.baseHp,
|
required this.baseHp,
|
||||||
required this.baseAtk,
|
required this.baseAtk,
|
||||||
required this.baseDefense,
|
required this.baseDefense,
|
||||||
|
this.baseDodge = 1, // Default 1
|
||||||
this.image,
|
this.image,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -29,6 +31,7 @@ class PlayerTemplate {
|
||||||
baseHp: json['baseHp'],
|
baseHp: json['baseHp'],
|
||||||
baseAtk: json['baseAtk'],
|
baseAtk: json['baseAtk'],
|
||||||
baseDefense: json['baseDefense'],
|
baseDefense: json['baseDefense'],
|
||||||
|
baseDodge: json['baseDodge'] ?? 1, // Parse with default
|
||||||
image: json['image'],
|
image: json['image'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +42,7 @@ class PlayerTemplate {
|
||||||
maxHp: baseHp,
|
maxHp: baseHp,
|
||||||
atk: baseAtk,
|
atk: baseAtk,
|
||||||
baseDefense: baseDefense,
|
baseDefense: baseDefense,
|
||||||
|
baseDodge: baseDodge, // Use template value
|
||||||
armor: 0,
|
armor: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ enum StatusEffectType {
|
||||||
vulnerable, // Takes 50% more damage
|
vulnerable, // Takes 50% more damage
|
||||||
bleed, // Takes damage at start/end of turn
|
bleed, // Takes damage at start/end of turn
|
||||||
defenseForbidden, // Cannot use Defend action
|
defenseForbidden, // Cannot use Defend action
|
||||||
|
disarmed, // Attack strength reduced (e.g., 10%)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 공격 실패 시 이펙트 피드백 타입 정의
|
/// 공격 실패 시 이펙트 피드백 타입 정의
|
||||||
enum BattleFeedbackType {
|
enum BattleFeedbackType {
|
||||||
miss, // 공격이 빗나감
|
miss, // 공격이 빗나감
|
||||||
failed, // 방어 실패
|
failed, // 방어 실패
|
||||||
|
dodge, // 회피 성공
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 스탯에 적용될 수 있는 수정자(Modifier)의 타입 정의.
|
/// 스탯에 적용될 수 있는 수정자(Modifier)의 타입 정의.
|
||||||
|
|
@ -33,7 +35,7 @@ enum EquipmentSlot { weapon, armor, shield, accessory }
|
||||||
|
|
||||||
enum DamageType { normal, bleed, vulnerable }
|
enum DamageType { normal, bleed, vulnerable }
|
||||||
|
|
||||||
enum StatType { maxHp, atk, defense, luck }
|
enum StatType { maxHp, atk, defense, luck, dodge }
|
||||||
|
|
||||||
enum ItemRarity { normal, magic, rare, legendary, unique }
|
enum ItemRarity { normal, magic, rare, legendary, unique }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,34 +23,45 @@ class CombatResult {
|
||||||
class CombatCalculator {
|
class CombatCalculator {
|
||||||
static final Random _random = Random();
|
static final Random _random = Random();
|
||||||
|
|
||||||
|
/// Helper to get efficiency multiplier based on risk and action type.
|
||||||
|
static double getEfficiency(ActionType actionType, RiskLevel risk) {
|
||||||
|
switch (risk) {
|
||||||
|
case RiskLevel.safe:
|
||||||
|
return actionType == ActionType.attack
|
||||||
|
? BattleConfig.attackSafeEfficiency
|
||||||
|
: BattleConfig.defendSafeEfficiency;
|
||||||
|
case RiskLevel.normal:
|
||||||
|
return actionType == ActionType.attack
|
||||||
|
? BattleConfig.attackNormalEfficiency
|
||||||
|
: BattleConfig.defendNormalEfficiency;
|
||||||
|
case RiskLevel.risky:
|
||||||
|
return actionType == ActionType.attack
|
||||||
|
? BattleConfig.attackRiskyEfficiency
|
||||||
|
: BattleConfig.defendRiskyEfficiency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculates success and efficiency based on Risk Level and Luck.
|
/// Calculates success and efficiency based on Risk Level and Luck.
|
||||||
static CombatResult calculateActionOutcome({
|
static CombatResult calculateActionOutcome({
|
||||||
required ActionType actionType, // New: Action type (attack or defend)
|
required ActionType actionType, // New: Action type (attack or defend)
|
||||||
required RiskLevel risk,
|
required RiskLevel risk,
|
||||||
required int luck,
|
required int luck,
|
||||||
required int baseValue,
|
required int baseValue,
|
||||||
|
Random? random, // Injectable Random
|
||||||
}) {
|
}) {
|
||||||
double efficiency = 1.0;
|
final effectiveRandom = random ?? _random;
|
||||||
|
double efficiency = getEfficiency(actionType, risk);
|
||||||
|
|
||||||
double baseChance = 0.0;
|
double baseChance = 0.0;
|
||||||
|
|
||||||
switch (risk) {
|
switch (risk) {
|
||||||
case RiskLevel.safe:
|
case RiskLevel.safe:
|
||||||
baseChance = BattleConfig.safeBaseChance;
|
baseChance = BattleConfig.safeBaseChance;
|
||||||
efficiency = actionType == ActionType.attack
|
|
||||||
? BattleConfig.attackSafeEfficiency
|
|
||||||
: BattleConfig.defendSafeEfficiency;
|
|
||||||
break;
|
break;
|
||||||
case RiskLevel.normal:
|
case RiskLevel.normal:
|
||||||
baseChance = BattleConfig.normalBaseChance;
|
baseChance = BattleConfig.normalBaseChance;
|
||||||
efficiency = actionType == ActionType.attack
|
|
||||||
? BattleConfig.attackNormalEfficiency
|
|
||||||
: BattleConfig.defendNormalEfficiency;
|
|
||||||
break;
|
break;
|
||||||
case RiskLevel.risky:
|
case RiskLevel.risky:
|
||||||
baseChance = BattleConfig.riskyBaseChance;
|
baseChance = BattleConfig.riskyBaseChance;
|
||||||
efficiency = actionType == ActionType.attack
|
|
||||||
? BattleConfig.attackRiskyEfficiency
|
|
||||||
: BattleConfig.defendRiskyEfficiency;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +69,7 @@ class CombatCalculator {
|
||||||
double chance = baseChance + (luck / 100.0);
|
double chance = baseChance + (luck / 100.0);
|
||||||
if (chance > 1.0) chance = 1.0;
|
if (chance > 1.0) chance = 1.0;
|
||||||
|
|
||||||
bool success = _random.nextDouble() < chance;
|
bool success = effectiveRandom.nextDouble() < chance;
|
||||||
int finalValue = (baseValue * efficiency).toInt();
|
int finalValue = (baseValue * efficiency).toInt();
|
||||||
if (finalValue < 1 && baseValue > 0) finalValue = 1;
|
if (finalValue < 1 && baseValue > 0) finalValue = 1;
|
||||||
|
|
||||||
|
|
@ -146,12 +157,13 @@ class CombatCalculator {
|
||||||
|
|
||||||
/// Tries to apply status effects from attacker's equipment.
|
/// Tries to apply status effects from attacker's equipment.
|
||||||
/// Returns a list of applied effects.
|
/// Returns a list of applied effects.
|
||||||
static List<StatusEffect> getAppliedEffects(Character attacker) {
|
static List<StatusEffect> getAppliedEffects(Character attacker, {Random? random}) {
|
||||||
|
final effectiveRandom = random ?? _random;
|
||||||
List<StatusEffect> appliedEffects = [];
|
List<StatusEffect> appliedEffects = [];
|
||||||
|
|
||||||
for (var item in attacker.equipment.values) {
|
for (var item in attacker.equipment.values) {
|
||||||
for (var effect in item.effects) {
|
for (var effect in item.effects) {
|
||||||
if (_random.nextInt(100) < effect.probability) {
|
if (effectiveRandom.nextInt(100) < effect.probability) {
|
||||||
appliedEffects.add(
|
appliedEffects.add(
|
||||||
StatusEffect(
|
StatusEffect(
|
||||||
type: effect.type,
|
type: effect.type,
|
||||||
|
|
@ -164,4 +176,12 @@ class CombatCalculator {
|
||||||
}
|
}
|
||||||
return appliedEffects;
|
return appliedEffects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculates if a dodge occurs.
|
||||||
|
/// [targetDodge] is the total dodge chance percentage (e.g. 5 = 5%).
|
||||||
|
static bool calculateDodge(int targetDodge, {Random? random}) {
|
||||||
|
final effectiveRandom = random ?? _random;
|
||||||
|
if (targetDodge <= 0) return false;
|
||||||
|
return effectiveRandom.nextInt(100) < targetDodge;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class LootGenerator {
|
||||||
int finalHp = template.hpBonus;
|
int finalHp = template.hpBonus;
|
||||||
int finalArmor = template.armorBonus;
|
int finalArmor = template.armorBonus;
|
||||||
int finalLuck = template.luck;
|
int finalLuck = template.luck;
|
||||||
|
int finalDodge = template.dodge;
|
||||||
|
|
||||||
// 0. Normal Rarity: Prefix logic for base stat variations
|
// 0. Normal Rarity: Prefix logic for base stat variations
|
||||||
if (template.rarity == ItemRarity.normal) {
|
if (template.rarity == ItemRarity.normal) {
|
||||||
|
|
@ -44,6 +45,8 @@ class LootGenerator {
|
||||||
finalAtk = (finalAtk * mult).floor();
|
finalAtk = (finalAtk * mult).floor();
|
||||||
finalHp = (finalHp * mult).floor();
|
finalHp = (finalHp * mult).floor();
|
||||||
finalArmor = (finalArmor * mult).floor();
|
finalArmor = (finalArmor * mult).floor();
|
||||||
|
// Dodge typically stays integer, but if we want to scale it:
|
||||||
|
// finalDodge = (finalDodge * mult).floor();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +78,9 @@ class LootGenerator {
|
||||||
case StatType.luck:
|
case StatType.luck:
|
||||||
finalLuck += value;
|
finalLuck += value;
|
||||||
break;
|
break;
|
||||||
|
case StatType.dodge: // Handle dodge
|
||||||
|
finalDodge += value;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +123,9 @@ class LootGenerator {
|
||||||
case StatType.luck:
|
case StatType.luck:
|
||||||
finalLuck += value;
|
finalLuck += value;
|
||||||
break;
|
break;
|
||||||
|
case StatType.dodge: // Handle dodge
|
||||||
|
finalDodge += value;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -130,6 +139,7 @@ class LootGenerator {
|
||||||
atkBonus: finalAtk,
|
atkBonus: finalAtk,
|
||||||
hpBonus: finalHp,
|
hpBonus: finalHp,
|
||||||
armorBonus: finalArmor,
|
armorBonus: finalArmor,
|
||||||
|
dodge: finalDodge, // Pass dodge
|
||||||
slot: template.slot,
|
slot: template.slot,
|
||||||
effects: template.effects,
|
effects: template.effects,
|
||||||
price: template.price,
|
price: template.price,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class Character {
|
||||||
int armor; // Current temporary shield/armor points in battle
|
int armor; // Current temporary shield/armor points in battle
|
||||||
int baseAtk;
|
int baseAtk;
|
||||||
int baseDefense; // Base defense stat
|
int baseDefense; // Base defense stat
|
||||||
|
int baseDodge; // New: Base dodge chance (e.g. 1 = 1%)
|
||||||
|
|
||||||
int gold; // New: Currency
|
int gold; // New: Currency
|
||||||
String? image; // New: Image path
|
String? image; // New: Image path
|
||||||
|
|
@ -32,6 +33,7 @@ class Character {
|
||||||
required this.armor,
|
required this.armor,
|
||||||
required int atk,
|
required int atk,
|
||||||
this.baseDefense = 0,
|
this.baseDefense = 0,
|
||||||
|
this.baseDodge = 1,
|
||||||
this.gold = 0,
|
this.gold = 0,
|
||||||
this.image,
|
this.image,
|
||||||
}) : baseMaxHp = maxHp,
|
}) : baseMaxHp = maxHp,
|
||||||
|
|
@ -46,6 +48,7 @@ class Character {
|
||||||
'armor': armor,
|
'armor': armor,
|
||||||
'baseAtk': baseAtk,
|
'baseAtk': baseAtk,
|
||||||
'baseDefense': baseDefense,
|
'baseDefense': baseDefense,
|
||||||
|
'baseDodge': baseDodge,
|
||||||
'gold': gold,
|
'gold': gold,
|
||||||
'image': image,
|
'image': image,
|
||||||
'equipment': equipment.map((key, value) => MapEntry(key.name, value.id)),
|
'equipment': equipment.map((key, value) => MapEntry(key.name, value.id)),
|
||||||
|
|
@ -63,6 +66,7 @@ class Character {
|
||||||
armor: json['armor'],
|
armor: json['armor'],
|
||||||
atk: json['baseAtk'],
|
atk: json['baseAtk'],
|
||||||
baseDefense: json['baseDefense'],
|
baseDefense: json['baseDefense'],
|
||||||
|
baseDodge: json['baseDodge'] ?? 1,
|
||||||
gold: json['gold'],
|
gold: json['gold'],
|
||||||
image: json['image'],
|
image: json['image'],
|
||||||
);
|
);
|
||||||
|
|
@ -161,7 +165,12 @@ class Character {
|
||||||
|
|
||||||
int get totalAtk {
|
int get totalAtk {
|
||||||
int bonus = equipment.values.fold(0, (sum, item) => sum + item.atkBonus);
|
int bonus = equipment.values.fold(0, (sum, item) => sum + item.atkBonus);
|
||||||
return baseAtk + bonus;
|
int finalAtk = baseAtk + bonus;
|
||||||
|
|
||||||
|
if (hasStatus(StatusEffectType.disarmed)) {
|
||||||
|
finalAtk = (finalAtk * GameConfig.disarmedDamageMultiplier).toInt();
|
||||||
|
}
|
||||||
|
return finalAtk;
|
||||||
}
|
}
|
||||||
|
|
||||||
int get totalDefense {
|
int get totalDefense {
|
||||||
|
|
@ -169,6 +178,11 @@ class Character {
|
||||||
return baseDefense + bonus;
|
return baseDefense + bonus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int get totalDodge {
|
||||||
|
int bonus = equipment.values.fold(0, (sum, item) => sum + item.dodge);
|
||||||
|
return baseDodge + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
int get totalLuck {
|
int get totalLuck {
|
||||||
return equipment.values.fold(0, (sum, item) => sum + item.luck);
|
return equipment.values.fold(0, (sum, item) => sum + item.luck);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class ItemEffect {
|
||||||
String typeStr = type.name.toUpperCase();
|
String typeStr = type.name.toUpperCase();
|
||||||
// Customize names if needed
|
// Customize names if needed
|
||||||
if (type == StatusEffectType.defenseForbidden) typeStr = "UNBLOCKABLE";
|
if (type == StatusEffectType.defenseForbidden) typeStr = "UNBLOCKABLE";
|
||||||
|
if (type == StatusEffectType.disarmed) typeStr = "DISARM";
|
||||||
|
|
||||||
String durationStr = "${duration}t";
|
String durationStr = "${duration}t";
|
||||||
String valStr = value > 0 ? " ($value dmg)" : "";
|
String valStr = value > 0 ? " ($value dmg)" : "";
|
||||||
|
|
@ -42,6 +43,7 @@ class Item {
|
||||||
final int atkBonus;
|
final int atkBonus;
|
||||||
final int hpBonus;
|
final int hpBonus;
|
||||||
final int armorBonus; // New stat for defense
|
final int armorBonus; // New stat for defense
|
||||||
|
final int dodge; // New: Dodge chance bonus
|
||||||
final EquipmentSlot slot;
|
final EquipmentSlot slot;
|
||||||
final List<ItemEffect> effects; // Status effects this item can inflict
|
final List<ItemEffect> effects; // Status effects this item can inflict
|
||||||
final int price; // New: Sell/Buy value
|
final int price; // New: Sell/Buy value
|
||||||
|
|
@ -57,6 +59,7 @@ class Item {
|
||||||
required this.atkBonus,
|
required this.atkBonus,
|
||||||
required this.hpBonus,
|
required this.hpBonus,
|
||||||
this.armorBonus = 0, // Default to 0 for backward compatibility
|
this.armorBonus = 0, // Default to 0 for backward compatibility
|
||||||
|
this.dodge = 0, // Default to 0
|
||||||
required this.slot,
|
required this.slot,
|
||||||
this.effects = const [], // Default to no effects
|
this.effects = const [], // Default to no effects
|
||||||
this.price = 0,
|
this.price = 0,
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,10 @@ class BattleProvider with ChangeNotifier {
|
||||||
|
|
||||||
// Dependency injection
|
// Dependency injection
|
||||||
final ShopProvider shopProvider;
|
final ShopProvider shopProvider;
|
||||||
|
final Random _random; // Injected Random instance
|
||||||
|
|
||||||
BattleProvider({required this.shopProvider}) {
|
BattleProvider({required this.shopProvider, Random? random})
|
||||||
|
: _random = random ?? Random() {
|
||||||
// initializeBattle(); // Do not auto-start logic
|
// initializeBattle(); // Do not auto-start logic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +136,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
// Save Game at the start of each stage
|
// Save Game at the start of each stage
|
||||||
SaveManager.saveGame(this);
|
SaveManager.saveGame(this);
|
||||||
|
|
||||||
|
// Reset Player Armor at start of new stage
|
||||||
|
player.armor = 0;
|
||||||
|
|
||||||
StageType type;
|
StageType type;
|
||||||
|
|
||||||
// Stage Type Logic
|
// Stage Type Logic
|
||||||
|
|
@ -214,10 +219,10 @@ class BattleProvider with ChangeNotifier {
|
||||||
player.hasStatus(StatusEffectType.defenseForbidden)) {
|
player.hasStatus(StatusEffectType.defenseForbidden)) {
|
||||||
_addLog("Cannot defend! You are under Defense Forbidden status.");
|
_addLog("Cannot defend! You are under Defense Forbidden status.");
|
||||||
notifyListeners(); // 상태 변경을 알림
|
notifyListeners(); // 상태 변경을 알림
|
||||||
_endPlayerTurn();
|
// _endPlayerTurn(); // Allow player to choose another action
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isPlayerTurn = false;
|
isPlayerTurn = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
|
@ -246,28 +251,48 @@ class BattleProvider with ChangeNotifier {
|
||||||
risk: risk,
|
risk: risk,
|
||||||
luck: player.totalLuck,
|
luck: player.totalLuck,
|
||||||
baseValue: baseValue,
|
baseValue: baseValue,
|
||||||
|
random: _random, // Pass injected random
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (type == ActionType.attack) {
|
if (type == ActionType.attack) {
|
||||||
int damage = result.value;
|
// 1. Check for Dodge (Moved from _processAttackImpact)
|
||||||
|
if (CombatCalculator.calculateDodge(enemy.totalDodge, random: _random)) { // Pass injected random
|
||||||
|
_addLog("${enemy.name} dodged the attack!");
|
||||||
|
final event = EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
_random.nextInt(1000).toString(), // Use injected random
|
||||||
|
type: ActionType.attack,
|
||||||
|
risk: risk,
|
||||||
|
target: EffectTarget.enemy,
|
||||||
|
feedbackType: BattleFeedbackType.dodge, // Dodge feedback
|
||||||
|
attacker: player,
|
||||||
|
targetEntity: enemy,
|
||||||
|
damageValue: 0,
|
||||||
|
isSuccess:
|
||||||
|
false, // Treated as fail for animation purposes (or custom)
|
||||||
|
);
|
||||||
|
_effectEventController.sink.add(event);
|
||||||
|
} else {
|
||||||
|
// 2. Hit Success
|
||||||
|
int damage = result.value;
|
||||||
|
|
||||||
final event = EffectEvent(
|
final event = EffectEvent(
|
||||||
id:
|
id:
|
||||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
Random().nextInt(1000).toString(),
|
_random.nextInt(1000).toString(), // Use injected random
|
||||||
type: ActionType.attack,
|
type: ActionType.attack,
|
||||||
risk: risk,
|
risk: risk,
|
||||||
target: EffectTarget.enemy,
|
target: EffectTarget.enemy,
|
||||||
feedbackType: null,
|
feedbackType: null,
|
||||||
attacker: player,
|
attacker: player,
|
||||||
targetEntity: enemy,
|
targetEntity: enemy,
|
||||||
damageValue: damage,
|
damageValue: damage,
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
);
|
);
|
||||||
_effectEventController.sink.add(
|
_effectEventController.sink.add(event);
|
||||||
event,
|
}
|
||||||
); // No Future.delayed here, BattleScreen will trigger impact
|
|
||||||
} else {
|
} else {
|
||||||
// Defense Success - Impact is immediate, so process it directly
|
// Defense Success - Impact is immediate, so process it directly
|
||||||
final event = EffectEvent(
|
final event = EffectEvent(
|
||||||
|
|
@ -348,6 +373,40 @@ class BattleProvider with ChangeNotifier {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recalculates the current enemy intent value based on current stats.
|
||||||
|
/// Used to update UI when enemy stats change (e.g. Disarmed applied).
|
||||||
|
void updateEnemyIntent() {
|
||||||
|
if (currentEnemyIntent == null || enemy.isDead) return;
|
||||||
|
|
||||||
|
final intent = currentEnemyIntent!;
|
||||||
|
int newValue = 0;
|
||||||
|
|
||||||
|
// Recalculate value based on current stats
|
||||||
|
if (intent.type == EnemyActionType.attack) {
|
||||||
|
newValue = (enemy.totalAtk *
|
||||||
|
CombatCalculator.getEfficiency(ActionType.attack, intent.risk))
|
||||||
|
.toInt();
|
||||||
|
if (newValue < 1 && enemy.totalAtk > 0) newValue = 1;
|
||||||
|
} else {
|
||||||
|
newValue = (enemy.totalDefense *
|
||||||
|
CombatCalculator.getEfficiency(ActionType.defend, intent.risk))
|
||||||
|
.toInt();
|
||||||
|
if (newValue < 1 && enemy.totalDefense > 0) newValue = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace intent with updated value, keeping other properties
|
||||||
|
currentEnemyIntent = EnemyIntent(
|
||||||
|
type: intent.type,
|
||||||
|
value: newValue,
|
||||||
|
risk: intent.risk,
|
||||||
|
description: "$newValue (${intent.risk.name})",
|
||||||
|
isSuccess: intent.isSuccess,
|
||||||
|
finalValue: newValue,
|
||||||
|
isApplied: intent.isApplied,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Turn Management Phases ---
|
// --- Turn Management Phases ---
|
||||||
|
|
||||||
// Phase 4: Start Player Turn
|
// Phase 4: Start Player Turn
|
||||||
|
|
@ -364,6 +423,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Intent if stats changed (e.g. status effects expired)
|
||||||
|
updateEnemyIntent();
|
||||||
|
|
||||||
// [New] Apply Pre-emptive Enemy Intent (Defense/Buffs)
|
// [New] Apply Pre-emptive Enemy Intent (Defense/Buffs)
|
||||||
// MOVED: Logic moved to applyPendingEnemyDefense() to sync with animation.
|
// MOVED: Logic moved to applyPendingEnemyDefense() to sync with animation.
|
||||||
// We just check intent existence here but do NOT apply effects yet.
|
// We just check intent existence here but do NOT apply effects yet.
|
||||||
|
|
@ -430,7 +492,8 @@ class BattleProvider with ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process Start-of-Turn Effects
|
// Process Start-of-Turn Effects
|
||||||
bool canAct = _processStartTurnEffects(enemy);
|
final result = CombatCalculator.processStartTurnEffects(enemy);
|
||||||
|
bool canAct = !result['isStunned'];
|
||||||
|
|
||||||
if (enemy.isDead) {
|
if (enemy.isDead) {
|
||||||
_onVictory();
|
_onVictory();
|
||||||
|
|
@ -456,17 +519,43 @@ class BattleProvider with ChangeNotifier {
|
||||||
} else {
|
} else {
|
||||||
// Attack Action (Animating)
|
// Attack Action (Animating)
|
||||||
if (intent.isSuccess) {
|
if (intent.isSuccess) {
|
||||||
|
// 1. Check for Dodge
|
||||||
|
if (CombatCalculator.calculateDodge(player.totalDodge, random: _random)) { // Pass injected random
|
||||||
|
_addLog("${player.name} dodged the attack!");
|
||||||
|
final event = EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
_random.nextInt(1000).toString(), // Use injected random
|
||||||
|
type: ActionType.attack,
|
||||||
|
risk: intent.risk,
|
||||||
|
target: EffectTarget.player,
|
||||||
|
feedbackType: BattleFeedbackType.dodge,
|
||||||
|
attacker: enemy,
|
||||||
|
targetEntity: player,
|
||||||
|
damageValue: 0,
|
||||||
|
isSuccess: false,
|
||||||
|
);
|
||||||
|
_effectEventController.sink.add(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate damage to account for status changes (like Disarmed)
|
||||||
|
int finalDamage = (enemy.totalAtk *
|
||||||
|
CombatCalculator.getEfficiency(ActionType.attack, intent.risk))
|
||||||
|
.toInt();
|
||||||
|
if (finalDamage < 1 && enemy.totalAtk > 0) finalDamage = 1;
|
||||||
|
|
||||||
final event = EffectEvent(
|
final event = EffectEvent(
|
||||||
id:
|
id:
|
||||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
Random().nextInt(1000).toString(),
|
_random.nextInt(1000).toString(), // Use injected random
|
||||||
type: ActionType.attack,
|
type: ActionType.attack,
|
||||||
risk: intent.risk,
|
risk: intent.risk,
|
||||||
target: EffectTarget.player,
|
target: EffectTarget.player,
|
||||||
feedbackType: null,
|
feedbackType: null,
|
||||||
attacker: enemy,
|
attacker: enemy,
|
||||||
targetEntity: player,
|
targetEntity: player,
|
||||||
damageValue: intent.finalValue,
|
damageValue: finalDamage,
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
);
|
);
|
||||||
_effectEventController.sink.add(event);
|
_effectEventController.sink.add(event);
|
||||||
|
|
@ -490,16 +579,17 @@ class BattleProvider with ChangeNotifier {
|
||||||
_effectEventController.sink.add(event);
|
_effectEventController.sink.add(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!canAct) {
|
} else if (!canAct) { // If cannot act (stunned)
|
||||||
_addLog("Enemy is stunned and cannot act!");
|
_addLog("Enemy is stunned and cannot act!");
|
||||||
int tid = _turnTransactionId;
|
int tid = _turnTransactionId;
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
if (tid != _turnTransactionId) return;
|
if (tid != _turnTransactionId) return;
|
||||||
_endEnemyTurn();
|
_endEnemyTurn();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
_addLog("Enemy did nothing.");
|
_addLog("Enemy did nothing.");
|
||||||
|
|
||||||
int tid = _turnTransactionId;
|
int tid = _turnTransactionId;
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
if (tid != _turnTransactionId) return;
|
if (tid != _turnTransactionId) return;
|
||||||
|
|
@ -512,6 +602,9 @@ class BattleProvider with ChangeNotifier {
|
||||||
void _endEnemyTurn() {
|
void _endEnemyTurn() {
|
||||||
if (player.isDead) return; // Game Over check
|
if (player.isDead) return; // Game Over check
|
||||||
|
|
||||||
|
// Update enemy status at the end of their turn
|
||||||
|
enemy.updateStatusEffects();
|
||||||
|
|
||||||
// Generate NEXT intent
|
// Generate NEXT intent
|
||||||
_generateEnemyIntent();
|
_generateEnemyIntent();
|
||||||
|
|
||||||
|
|
@ -690,29 +783,45 @@ class BattleProvider with ChangeNotifier {
|
||||||
_prepareNextStage();
|
_prepareNextStage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void generateEnemyIntent() {
|
||||||
|
_generateEnemyIntent();
|
||||||
|
}
|
||||||
|
|
||||||
void _generateEnemyIntent() {
|
void _generateEnemyIntent() {
|
||||||
if (enemy.isDead) {
|
if (enemy.isDead) {
|
||||||
currentEnemyIntent = null;
|
currentEnemyIntent = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final random = Random();
|
// Use the injected _random field
|
||||||
|
// final random = Random(); // Removed
|
||||||
|
|
||||||
// Decide Action Type
|
// Decide Action Type
|
||||||
bool canDefend = enemy.baseDefense > 0;
|
// Check constraints
|
||||||
if (enemy.hasStatus(StatusEffectType.defenseForbidden)) {
|
bool canDefend = enemy.baseDefense > 0 &&
|
||||||
canDefend = false;
|
!enemy.hasStatus(StatusEffectType.defenseForbidden);
|
||||||
}
|
bool canAttack = true; // Attack is always possible, but strength is affected by status.
|
||||||
bool isAttack = true;
|
|
||||||
|
|
||||||
if (canDefend) {
|
bool isAttack = true; // Default to attack
|
||||||
isAttack = random.nextDouble() < BattleConfig.enemyAttackChance;
|
|
||||||
} else {
|
if (canAttack && canDefend) {
|
||||||
|
// Both options available: Use configured probability
|
||||||
|
isAttack = _random.nextDouble() < BattleConfig.enemyAttackChance;
|
||||||
|
} else if (canAttack) {
|
||||||
|
// Must attack
|
||||||
isAttack = true;
|
isAttack = true;
|
||||||
|
} else if (canDefend) {
|
||||||
|
// Must defend
|
||||||
|
isAttack = false;
|
||||||
|
} else {
|
||||||
|
// Both forbidden (Rare case, effectively stunned but not via Stun status)
|
||||||
|
// Default to Defend as a fallback, outcomes will be handled by stats/luck
|
||||||
|
isAttack = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide Risk Level
|
// Decide Risk Level
|
||||||
RiskLevel risk = RiskLevel.values[random.nextInt(RiskLevel.values.length)];
|
RiskLevel risk = RiskLevel.values[_random.nextInt(RiskLevel.values.length)];
|
||||||
|
|
||||||
CombatResult result;
|
CombatResult result;
|
||||||
if (isAttack) {
|
if (isAttack) {
|
||||||
|
|
@ -872,6 +981,11 @@ class BattleProvider with ChangeNotifier {
|
||||||
|
|
||||||
// Try applying status effects
|
// Try applying status effects
|
||||||
_tryApplyStatusEffects(attacker, target);
|
_tryApplyStatusEffects(attacker, target);
|
||||||
|
|
||||||
|
// If target is enemy, update intent to reflect potential status changes (e.g. Disarmed)
|
||||||
|
if (target == enemy) {
|
||||||
|
updateEnemyIntent();
|
||||||
|
}
|
||||||
} else if (event.type == ActionType.defend) {
|
} else if (event.type == ActionType.defend) {
|
||||||
// Defense Impact is immediate (no anim delay from UI)
|
// Defense Impact is immediate (no anim delay from UI)
|
||||||
if (event.isSuccess!) {
|
if (event.isSuccess!) {
|
||||||
|
|
@ -900,6 +1014,7 @@ class BattleProvider with ChangeNotifier {
|
||||||
void _tryApplyStatusEffects(Character attacker, Character target) {
|
void _tryApplyStatusEffects(Character attacker, Character target) {
|
||||||
List<StatusEffect> effectsToApply = CombatCalculator.getAppliedEffects(
|
List<StatusEffect> effectsToApply = CombatCalculator.getAppliedEffects(
|
||||||
attacker,
|
attacker,
|
||||||
|
random: _random, // Pass injected random
|
||||||
);
|
);
|
||||||
|
|
||||||
for (var effect in effectsToApply) {
|
for (var effect in effectsToApply) {
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,10 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
feedbackText = "FAILED";
|
feedbackText = "FAILED";
|
||||||
feedbackColor = ThemeConfig.failedText;
|
feedbackColor = ThemeConfig.failedText;
|
||||||
break;
|
break;
|
||||||
|
case BattleFeedbackType.dodge:
|
||||||
|
feedbackText = "DODGE";
|
||||||
|
feedbackColor = ThemeConfig.statLuckColor; // Use Luck color (Greenish)
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
feedbackText = "";
|
feedbackText = "";
|
||||||
feedbackColor = ThemeConfig.textColorWhite;
|
feedbackColor = ThemeConfig.textColorWhite;
|
||||||
|
|
@ -649,14 +653,15 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
!battleProvider.enemy.isDead &&
|
!battleProvider.enemy.isDead &&
|
||||||
!battleProvider.showRewardPopup &&
|
!battleProvider.showRewardPopup &&
|
||||||
!_isPlayerAttacking &&
|
!_isPlayerAttacking &&
|
||||||
!_isEnemyAttacking,
|
!_isEnemyAttacking, // Enabled even if disarmed (damage reduced)
|
||||||
isDefendEnabled:
|
isDefendEnabled:
|
||||||
battleProvider.isPlayerTurn &&
|
battleProvider.isPlayerTurn &&
|
||||||
!battleProvider.player.isDead &&
|
!battleProvider.player.isDead &&
|
||||||
!battleProvider.enemy.isDead &&
|
!battleProvider.enemy.isDead &&
|
||||||
!battleProvider.showRewardPopup &&
|
!battleProvider.showRewardPopup &&
|
||||||
!_isPlayerAttacking &&
|
!_isPlayerAttacking &&
|
||||||
!_isEnemyAttacking,
|
!_isEnemyAttacking &&
|
||||||
|
!battleProvider.player.hasStatus(StatusEffectType.defenseForbidden), // Disable if defense is forbidden
|
||||||
onAttackPressed: () =>
|
onAttackPressed: () =>
|
||||||
_showRiskLevelSelection(context, ActionType.attack),
|
_showRiskLevelSelection(context, ActionType.attack),
|
||||||
onDefendPressed: () =>
|
onDefendPressed: () =>
|
||||||
|
|
@ -859,6 +864,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}");
|
if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}");
|
||||||
if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}");
|
if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}");
|
||||||
if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}");
|
if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}");
|
||||||
|
if (item.dodge > 0) stats.add("+${item.dodge}% Dodge"); // Add Dodge
|
||||||
|
|
||||||
List<String> effectTexts = item.effects.map((e) => e.description).toList();
|
List<String> effectTexts = item.effects.map((e) => e.description).toList();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,18 +35,23 @@ class CharacterStatsWidget extends StatelessWidget {
|
||||||
_buildStatItem(
|
_buildStatItem(
|
||||||
AppStrings.atk,
|
AppStrings.atk,
|
||||||
"${player.totalAtk}",
|
"${player.totalAtk}",
|
||||||
color: ThemeConfig.statAtkColor,
|
// color: ThemeConfig.statAtkColor,
|
||||||
),
|
),
|
||||||
_buildStatItem(
|
_buildStatItem(
|
||||||
AppStrings.def,
|
AppStrings.def,
|
||||||
"${player.totalDefense}",
|
"${player.totalDefense}",
|
||||||
color: ThemeConfig.statDefColor,
|
// color: ThemeConfig.statDefColor,
|
||||||
),
|
),
|
||||||
_buildStatItem(AppStrings.armor, "${player.armor}"),
|
// _buildStatItem(AppStrings.armor, "${player.armor}"),
|
||||||
_buildStatItem(
|
_buildStatItem(
|
||||||
AppStrings.luck,
|
AppStrings.luck,
|
||||||
"${player.totalLuck}",
|
"${player.totalLuck}",
|
||||||
color: ThemeConfig.statLuckColor,
|
// color: ThemeConfig.statLuckColor,
|
||||||
|
),
|
||||||
|
_buildStatItem(
|
||||||
|
"Dodge", // TODO: Add to AppStrings
|
||||||
|
"${player.totalDodge}%",
|
||||||
|
// color: ThemeConfig.statLuckColor,
|
||||||
),
|
),
|
||||||
_buildStatItem(
|
_buildStatItem(
|
||||||
AppStrings.gold,
|
AppStrings.gold,
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,11 @@ class InventoryGridWidget extends StatelessWidget {
|
||||||
player.totalLuck,
|
player.totalLuck,
|
||||||
player.totalLuck - (oldItem?.luck ?? 0) + newItem.luck,
|
player.totalLuck - (oldItem?.luck ?? 0) + newItem.luck,
|
||||||
),
|
),
|
||||||
|
_buildStatChangeRow(
|
||||||
|
"Dodge",
|
||||||
|
player.totalDodge,
|
||||||
|
player.totalDodge - (oldItem?.dodge ?? 0) + newItem.dodge,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@
|
||||||
- **Normal:** 성공률 80%+, 효율 100%.
|
- **Normal:** 성공률 80%+, 효율 100%.
|
||||||
- **Risky:** 성공률 40%+, 효율 200% (성공 시 강력한 이펙트).
|
- **Risky:** 성공률 40%+, 효율 200% (성공 시 강력한 이펙트).
|
||||||
- **Luck 보정:** `totalLuck` 1당 성공률 +1%.
|
- **Luck 보정:** `totalLuck` 1당 성공률 +1%.
|
||||||
|
- **회피 시스템 (Dodge System):**
|
||||||
|
- 캐릭터는 `dodge` 스탯을 가지며, 공격을 회피할 확률이 생김.
|
||||||
|
- `CombatCalculator`에서 회피 성공 여부를 계산.
|
||||||
|
- 공격이 회피되면 `dodge` 피드백과 함께 데미지가 0으로 처리됨.
|
||||||
- **적 인공지능 (Enemy AI & Intent):**
|
- **적 인공지능 (Enemy AI & Intent):**
|
||||||
- **Intent UI:** 적의 다음 행동(공격/방어, 데미지/방어도) 미리 표시.
|
- **Intent UI:** 적의 다음 행동(공격/방어, 데미지/방어도) 미리 표시.
|
||||||
- **동기화된 애니메이션:** 적 행동 결정(`_generateEnemyIntent`)은 이전 애니메이션이 완전히 끝난 후 이루어짐.
|
- **동기화된 애니메이션:** 적 행동 결정(`_generateEnemyIntent`)은 이전 애니메이션이 완전히 끝난 후 이루어짐.
|
||||||
|
|
@ -40,7 +44,7 @@
|
||||||
- **UI 주도 Impact 처리:** 애니메이션 타격 시점(`onImpact`)에 정확히 데미지가 적용되고 텍스트가 뜸 (완벽한 동기화).
|
- **UI 주도 Impact 처리:** 애니메이션 타격 시점(`onImpact`)에 정확히 데미지가 적용되고 텍스트가 뜸 (완벽한 동기화).
|
||||||
- **적 돌진:** 적도 공격 시 플레이어 위치로 돌진함 (설정에서 끄기 가능).
|
- **적 돌진:** 적도 공격 시 플레이어 위치로 돌진함 (설정에서 끄기 가능).
|
||||||
- **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text, Risky 공격 시 크기 확대), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`). **적 방어 시 성공/실패 이펙트 추가.**
|
- **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text, Risky 공격 시 크기 확대), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`). **적 방어 시 성공/실패 이펙트 추가.**
|
||||||
- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`.
|
- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`, `Disarmed`.
|
||||||
- **UI 알림 (Toast):** 하단 네비게이션을 가리지 않는 상단 `Overlay` 기반 알림 시스템.
|
- **UI 알림 (Toast):** 하단 네비게이션을 가리지 않는 상단 `Overlay` 기반 알림 시스템.
|
||||||
|
|
||||||
### C. 데이터 및 로직 (Architecture)
|
### C. 데이터 및 로직 (Architecture)
|
||||||
|
|
@ -48,114 +52,11 @@
|
||||||
- **Data-Driven:** `items.json`, `enemies.json`, `players.json`.
|
- **Data-Driven:** `items.json`, `enemies.json`, `players.json`.
|
||||||
- **Logic 분리:**
|
- **Logic 분리:**
|
||||||
- `BattleProvider`: UI 상태 관리 및 이벤트 스트림(`damageStream`, `effectStream`) 발송.
|
- `BattleProvider`: UI 상태 관리 및 이벤트 스트림(`damageStream`, `effectStream`) 발송.
|
||||||
- `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직 순수 함수화. **공격/방어 액션 타입별 효율 분리.**
|
- `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직을 순수 함수화하여 전투 로직의 재사용성과 테스트 용이성을 높임.
|
||||||
- `BattleLogManager`: 전투 로그 관리.
|
- **공격/방어 액션 타입별 효율 분리:** 각 행동(공격/방어) 및 리스크 레벨(`Safe`, `Normal`, `Risky`)에 따른 효율(`efficiency`)을 분리하여 적용.
|
||||||
- `LootGenerator`: 아이템 생성, 접두사(Prefix) 부여, 랜덤 스탯 로직.
|
- **받는 피해 계산:** 취약성(`Vulnerable`) 적용 및 현재 방어도를 통한 데미지 흡수 계산 (`calculateDamageToHp`, `calculateRemainingArmor`).
|
||||||
- `SettingsProvider`: 전역 설정(애니메이션 on/off 등) 관리 및 영구 저장.
|
- **주는 피해 계산:** 공격자가 `Disarmed` 상태일 경우, 최종 데미지 값에 대한 감폭 적용 (`Character.totalAtk` 게터에서 처리).
|
||||||
- **Soft i18n:** UI 텍스트는 `lib/game/config/app_strings.dart`에서 통합 관리.
|
- **기타 계산:** 회피 확률(`calculateDodge`), 턴 시작 효과(`processStartTurnEffects`), 아이템에 의한 상태이상 적용 확률(`getAppliedEffects`) 등을 담당.
|
||||||
- **Config:** `GameConfig`, `BattleConfig`, `ItemConfig` 등 설정 값 중앙화. **BattleConfig의 공격/방어 효율 분리.**
|
|
||||||
|
|
||||||
### D. 아이템 및 경제
|
|
||||||
|
|
||||||
- **장비:** 무기, 방어구, 방패, 장신구.
|
|
||||||
- **시스템:**
|
|
||||||
- **Rarity:** Common ~ Unique.
|
|
||||||
- **Tier:** 라운드 진행도에 따라 상위 티어 아이템 등장.
|
|
||||||
- **Prefix:** Rarity에 따라 접두사가 붙으며 스탯이 변형됨 (예: "Sharp Wooden Sword").
|
|
||||||
- **상점 (`ShopProvider`):** 아이템 구매/판매, 리롤(Reroll), 인벤토리 관리.
|
|
||||||
|
|
||||||
### E. 저장 및 진행 (Persistence)
|
|
||||||
|
|
||||||
- **자동 저장:** 스테이지 클리어 시 `SaveManager`를 통해 자동 저장.
|
|
||||||
- **Permadeath:** 패배 시 저장 데이터 삭제 (로그라이크 요소).
|
|
||||||
|
|
||||||
### G. 스테이지 시스템 (Stage System)
|
|
||||||
|
|
||||||
- **Map Generation:** 진행에 따라 랜덤하게 다음 스테이지 타입이 결정됨 (현재는 단순 랜덤).
|
|
||||||
- **Underground Colosseum System (Rounds/Tiers):**
|
|
||||||
- **Round Progression:** 스테이지 진행(`stage` count)에 따라 난이도(Tier)가 상승.
|
|
||||||
- **Tier:**
|
|
||||||
- Tier 1: Stage 1 ~ 12 (지하 불법 투기장)
|
|
||||||
- Tier 2: Stage 13 ~ 24 (콜로세움)
|
|
||||||
- Tier 3: Stage 25+ (왕의 투기장)
|
|
||||||
- **Stage Types:**
|
|
||||||
- **Battle:** 일반 몬스터 전투.
|
|
||||||
- **Elite:** 강화된 몬스터 전투 (보상 증가, 12 스테이지마다 등장).
|
|
||||||
- **Shop:** 아이템 구매/판매/리롤 (5 스테이지마다 등장).
|
|
||||||
- **Rest:** 휴식 (8 스테이지마다 등장).
|
|
||||||
|
|
||||||
### F. 코드 구조 (Code Structure - Barrel Pattern)
|
|
||||||
|
|
||||||
- **Barrel File Pattern:** `lib/` 내의 모든 주요 디렉토리는 해당 폴더의 파일들을 묶어주는 단일 진입점 파일(`.dart`)을 가집니다.
|
|
||||||
- `lib/game/models.dart`, `lib/game/config.dart`, `lib/game/data.dart`, `lib/game/logic.dart`
|
|
||||||
- `lib/providers.dart`, `lib/utils.dart`, `lib/screens.dart`, `lib/widgets.dart`
|
|
||||||
- **Imports:** 개별 파일 import 대신 위 Barrel File을 사용하여 가독성과 유지보수성을 높였습니다.
|
|
||||||
|
|
||||||
## 3. 작업 컨벤션 (Working Conventions)
|
|
||||||
|
|
||||||
- **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` (UI 상태) + `Stream` (이벤트성 데이터).
|
|
||||||
- **Data:** JSON 기반 + `Table` 클래스로 로드.
|
|
||||||
- **Barrel File Pattern (Strict):** `lib/` 하위의 모든 주요 디렉토리는 Barrel File을 유지해야 하며, 외부에서 참조 시 **반드시** 이 Barrel File을 import 해야 합니다. 개별 파일에 대한 직접 import는 허용되지 않습니다.
|
|
||||||
|
|
||||||
## 4. 최근 주요 변경 사항 (Change Log)
|
|
||||||
|
|
||||||
- **[Refactor] BattleProvider:** `CombatCalculator`, `BattleLogManager`, `LootGenerator` 분리로 코드 다이어트.
|
|
||||||
- **[Refactor] Animation Sync:** `Future.delayed` 예측 방식을 버리고, UI 애니메이션 콜백(`onImpact`)을 통해 로직을 트리거하는 방식으로 변경하여 타격감 동기화 해결.
|
|
||||||
- **[Refactor] Settings:** `SettingsProvider` 도입 및 적 애니메이션 토글 기능 추가.
|
|
||||||
|
|
||||||
# 00. 프로젝트 컨텍스트 및 복구 (Project Context & Restore Point)
|
|
||||||
|
|
||||||
이 파일은 다른 개발 환경이나 새로운 AI 세션에서 프로젝트의 현재 상태를 빠르게 파악하고 작업을 이어가기 위해 작성되었습니다.
|
|
||||||
|
|
||||||
## 1. 프로젝트 개요
|
|
||||||
|
|
||||||
- **프로젝트명:** Colosseum's Choice
|
|
||||||
- **플랫폼:** Flutter (Android/iOS/Web/Desktop)
|
|
||||||
- **장르:** 텍스트 기반의 턴제 RPG + GUI (로그라이크 요소 포함)
|
|
||||||
- **상태:** 핵심 시스템 구현 완료 및 안정화 (i18n 구조 적용, 애니메이션 동기화 완료)
|
|
||||||
|
|
||||||
## 2. 현재 구현된 핵심 기능 (Feature Status)
|
|
||||||
|
|
||||||
### A. 게임 흐름 (Game Flow)
|
|
||||||
|
|
||||||
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% (성공 시 강력한 이펙트).
|
|
||||||
- **Luck 보정:** `totalLuck` 1당 성공률 +1%.
|
|
||||||
- **적 인공지능 (Enemy AI & Intent):**
|
|
||||||
- **Intent UI:** 적의 다음 행동(공격/방어, 데미지/방어도) 미리 표시.
|
|
||||||
- **동기화된 애니메이션:** 적 행동 결정(`_generateEnemyIntent`)은 이전 애니메이션이 완전히 끝난 후 이루어짐.
|
|
||||||
- **선제 방어:** 적이 방어 행동을 선택하면 턴 시작 시 **데이터상으로 즉시 방어도가 적용되나, 시각적 애니메이션은 플레이어가 행동을 선택하는 시점에 발동됨.**
|
|
||||||
- **애니메이션 및 타격감 (Visuals & Impact):**
|
|
||||||
- **UI 주도 Impact 처리:** 애니메이션 타격 시점(`onImpact`)에 정확히 데미지가 적용되고 텍스트가 뜸 (완벽한 동기화).
|
|
||||||
- **적 돌진:** 적도 공격 시 플레이어 위치로 돌진함 (설정에서 끄기 가능).
|
|
||||||
- **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text, Risky 공격 시 크기 확대), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`). **적 방어 시 성공/실패 이펙트 추가.**
|
|
||||||
- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`.
|
|
||||||
|
|
||||||
### C. 데이터 및 로직 (Architecture)
|
|
||||||
|
|
||||||
- **Data-Driven:** `items.json`, `enemies.json`, `players.json`.
|
|
||||||
- **Logic 분리:**
|
|
||||||
- `BattleProvider`: UI 상태 관리 및 이벤트 스트림(`damageStream`, `effectStream`) 발송.
|
|
||||||
- `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직 순수 함수화. **공격/방어 액션 타입별 효율 분리.**
|
|
||||||
- `BattleLogManager`: 전투 로그 관리.
|
- `BattleLogManager`: 전투 로그 관리.
|
||||||
- `LootGenerator`: 아이템 생성, 접두사(Prefix) 부여, 랜덤 스탯 로직.
|
- `LootGenerator`: 아이템 생성, 접두사(Prefix) 부여, 랜덤 스탯 로직.
|
||||||
- `SettingsProvider`: 전역 설정(애니메이션 on/off 등) 관리 및 영구 저장.
|
- `SettingsProvider`: 전역 설정(애니메이션 on/off 등) 관리 및 영구 저장.
|
||||||
|
|
@ -209,6 +110,8 @@
|
||||||
|
|
||||||
## 4. 최근 주요 변경 사항 (Change Log)
|
## 4. 최근 주요 변경 사항 (Change Log)
|
||||||
|
|
||||||
|
- **[Feature] Dynamic Intent UI:** 적의 상태(예: Disarmed) 변화 시, UI에 표시되는 인텐트(공격/방어 값)가 실시간으로 갱신되도록 로직을 개선하여 전투 정보의 정확성과 직관성 향상.
|
||||||
|
- **[Feature] Dodge Mechanic:** 캐릭터의 `dodge` 스탯에 기반한 공격 회피 시스템 구현. 회피 시 전용 피드백과 함께 데미지 무효화.
|
||||||
- **[Refactor] BattleProvider:** `CombatCalculator`, `BattleLogManager`, `LootGenerator` 분리로 코드 다이어트.
|
- **[Refactor] BattleProvider:** `CombatCalculator`, `BattleLogManager`, `LootGenerator` 분리로 코드 다이어트.
|
||||||
- **[Refactor] Animation Sync:** `Future.delayed` 예측 방식을 버리고, UI 애니메이션 콜백(`onImpact`)을 통해 로직을 트리거하는 방식으로 변경하여 타격감 동기화 해결.
|
- **[Refactor] Animation Sync:** `Future.delayed` 예측 방식을 버리고, UI 애니메이션 콜백(`onImpact`)을 통해 로직을 트리거하는 방식으로 변경하여 타격감 동기화 해결.
|
||||||
- **[Refactor] Settings:** `SettingsProvider` 도입 및 적 애니메이션 토글 기능 추가.
|
- **[Refactor] Settings:** `SettingsProvider` 도입 및 적 애니메이션 토글 기능 추가.
|
||||||
|
|
@ -228,4 +131,4 @@
|
||||||
|
|
||||||
1. **밸런싱:** 현재 몬스터 및 아이템 스탯 미세 조정.
|
1. **밸런싱:** 현재 몬스터 및 아이템 스탯 미세 조정.
|
||||||
2. **콘텐츠 확장:** 더 많은 아이템, 적, 스킬 패턴 추가.
|
2. **콘텐츠 확장:** 더 많은 아이템, 적, 스킬 패턴 추가.
|
||||||
3. **튜토리얼:** 신규 유저를 위한 가이드 추가.
|
3. **튜토리얼:** 신규 유저를 위한 가이드 추가.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 65. 동적 인텐트 UI 갱신 및 로직 보강
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
적에게 '무장 해제(Disarmed)'와 같은 상태 이상이 적용되거나 해제될 때, UI에 표시되는 적의 행동 예고(인텐트) 값이 실시간으로 변경되도록 시스템을 개선합니다. 이를 통해 플레이어는 더 정확한 정보를 바탕으로 전략적인 결정을 내릴 수 있습니다.
|
||||||
|
|
||||||
|
## 주요 변경 사항
|
||||||
|
|
||||||
|
### 1. 동적 인텐트 재계산 로직 추가 (`BattleProvider`)
|
||||||
|
- `updateEnemyIntent()` 메서드를 추가하여, 현재 적의 능력치(`totalAtk`, `totalDefense`)를 기준으로 인텐트의 공격 값이나 방어 값을 다시 계산하는 기능을 구현했습니다.
|
||||||
|
- 이 메서드는 기존 인텐트의 `risk`와 `type`은 유지하면서, 변경된 스탯에 따른 결과 값만 갱신하여 UI에 즉시 반영합니다.
|
||||||
|
|
||||||
|
### 2. 인텐트 UI 갱신 트리거 추가
|
||||||
|
- **플레이어 공격 시:** 플레이어의 공격으로 적에게 상태 이상이 적용되는 시점(`_processAttackImpact` 내부)에 `updateEnemyIntent()`를 호출하여, UI의 인텐트 값이 즉시 변경되도록 했습니다. (예: '무장 해제' 적용 시 데미지 수치 감소)
|
||||||
|
- **턴 시작 시:** 플레이어 턴이 시작될 때(`_startPlayerTurn` 내부)에도 `updateEnemyIntent()`를 호출하여, 만료된 상태 이상 효과가 인텐트 값에 반영되도록 했습니다.
|
||||||
|
|
||||||
|
### 3. 상태 이상 처리 로직 보강
|
||||||
|
- 적의 턴이 끝나는 시점(`_endEnemyTurn` 내부)에 `enemy.updateStatusEffects()`를 호출하도록 추가했습니다.
|
||||||
|
- 이를 통해 적의 상태 이상 지속시간이 매 턴 정확하게 감소하며, 다음 턴의 인텐트가 가장 최신 상태를 기준으로 생성되도록 보장합니다.
|
||||||
|
|
||||||
|
### 4. `CombatCalculator` 리팩토링
|
||||||
|
- `getEfficiency` 헬퍼 메서드를 추가하여, 행동 타입과 위험도에 따른 효율 계수를 가져오는 로직을 중앙화하고 코드 중복을 제거했습니다.
|
||||||
|
|
||||||
|
## 기대 효과
|
||||||
|
- 플레이어는 적의 상태 변화(버프/디버프)가 적의 다음 행동에 미치는 영향을 UI를 통해 직관적으로 확인할 수 있습니다.
|
||||||
|
- 전투 정보의 투명성이 향상되어 게임의 전략적 깊이가 더해집니다.
|
||||||
|
- 상태 이상 시스템과 전투 로직의 일관성 및 안정성이 강화됩니다.
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart'; // Import SharedPreferences
|
||||||
|
import 'package:game_test/providers/battle_provider.dart';
|
||||||
|
import 'package:game_test/providers/shop_provider.dart';
|
||||||
|
import 'package:game_test/game/models.dart';
|
||||||
|
import 'package:game_test/game/enums.dart';
|
||||||
|
import 'package:game_test/game/data/item_table.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('BattleProvider Armor Reset Test', () {
|
||||||
|
late BattleProvider battleProvider;
|
||||||
|
late ShopProvider shopProvider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
// Fix Binding has not yet been initialized error
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// Mock SharedPreferences
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
// Mock ItemTable data to prevent RangeError in initializeBattle
|
||||||
|
// initializeBattle accesses indices up to 5 for weapons and 3 for shields
|
||||||
|
ItemTable.weapons = List.generate(
|
||||||
|
10,
|
||||||
|
(index) => ItemTemplate(
|
||||||
|
id: "weapon_$index",
|
||||||
|
name: "Weapon $index",
|
||||||
|
description: "Test Weapon",
|
||||||
|
atkBonus: 10,
|
||||||
|
hpBonus: 0,
|
||||||
|
armorBonus: 0,
|
||||||
|
slot: EquipmentSlot.weapon,
|
||||||
|
effects: [],
|
||||||
|
price: 10,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ItemTable.shields = List.generate(
|
||||||
|
10,
|
||||||
|
(index) => ItemTemplate(
|
||||||
|
id: "shield_$index",
|
||||||
|
name: "Shield $index",
|
||||||
|
description: "Test Shield",
|
||||||
|
atkBonus: 0,
|
||||||
|
hpBonus: 0,
|
||||||
|
armorBonus: 10,
|
||||||
|
slot: EquipmentSlot.shield,
|
||||||
|
effects: [],
|
||||||
|
price: 10,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Initialize other lists to empty to avoid null pointer if accessed loosely
|
||||||
|
ItemTable.armors = [];
|
||||||
|
ItemTable.accessories = [];
|
||||||
|
|
||||||
|
shopProvider = ShopProvider();
|
||||||
|
battleProvider = BattleProvider(shopProvider: shopProvider);
|
||||||
|
battleProvider.initializeBattle(); // Initialize player and stage 1
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Armor should be reset to 0 when proceeding to next stage', () {
|
||||||
|
// 1. Setup initial state
|
||||||
|
battleProvider.player.armor = 50;
|
||||||
|
expect(
|
||||||
|
battleProvider.player.armor,
|
||||||
|
50,
|
||||||
|
reason: "Player armor should be set to 50 initially.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Simulate proceeding to next stage
|
||||||
|
// Using proceedToNextStage which calls _prepareNextStage internally
|
||||||
|
battleProvider.proceedToNextStage();
|
||||||
|
|
||||||
|
// 3. Verify armor is reset
|
||||||
|
expect(battleProvider.stage, 2, reason: "Stage should advance to 2.");
|
||||||
|
expect(
|
||||||
|
battleProvider.player.armor,
|
||||||
|
0,
|
||||||
|
reason: "Player armor should be reset to 0 in the new stage.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Armor should be reset to 0 when re-initializing battle', () {
|
||||||
|
// 1. Setup initial state
|
||||||
|
battleProvider.player.armor = 20;
|
||||||
|
expect(battleProvider.player.armor, 20);
|
||||||
|
|
||||||
|
// 2. Re-initialize
|
||||||
|
battleProvider.initializeBattle();
|
||||||
|
|
||||||
|
// 3. Verify armor is reset
|
||||||
|
expect(battleProvider.stage, 1);
|
||||||
|
expect(
|
||||||
|
battleProvider.player.armor,
|
||||||
|
0,
|
||||||
|
reason: "Player armor should be reset to 0 on initialization.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:game_test/providers/battle_provider.dart';
|
||||||
|
import 'package:game_test/providers/shop_provider.dart';
|
||||||
|
import 'package:game_test/game/models.dart';
|
||||||
|
import 'package:game_test/game/enums.dart';
|
||||||
|
import 'package:game_test/game/data/item_table.dart';
|
||||||
|
import 'package:game_test/game/config/game_config.dart'; // Import GameConfig for multiplier
|
||||||
|
import 'dart:math'; // Import dart:math
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Disarm Mechanic (Weakened Attack) Test', () {
|
||||||
|
late BattleProvider battleProvider;
|
||||||
|
late ShopProvider shopProvider;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
// Mock ItemTable
|
||||||
|
ItemTable.weapons = [];
|
||||||
|
ItemTable.shields = [];
|
||||||
|
ItemTable.armors = [];
|
||||||
|
ItemTable.accessories = [];
|
||||||
|
|
||||||
|
shopProvider = ShopProvider();
|
||||||
|
// Pass a fixed seed Random for predictable intent generation
|
||||||
|
battleProvider = BattleProvider(
|
||||||
|
shopProvider: shopProvider,
|
||||||
|
random: Random(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
battleProvider.enemy = Character(
|
||||||
|
name: "Enemy",
|
||||||
|
maxHp: 100,
|
||||||
|
armor: 0,
|
||||||
|
atk: 50, // High base ATK for clear percentage reduction
|
||||||
|
baseDefense:
|
||||||
|
0, // Set to 0 to make canDefend false for predictable intent
|
||||||
|
);
|
||||||
|
|
||||||
|
battleProvider.player = Character(
|
||||||
|
name: "Player",
|
||||||
|
maxHp: 100,
|
||||||
|
armor: 0,
|
||||||
|
atk: 50, // High base ATK for clear percentage reduction
|
||||||
|
baseDefense: 10,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enemy totalAtk reduced to 10% when Disarmed (Attack Forbidden)', () {
|
||||||
|
// 1. Verify initial ATK
|
||||||
|
expect(battleProvider.enemy.totalAtk, 50);
|
||||||
|
|
||||||
|
// 2. Apply Disarm
|
||||||
|
battleProvider.enemy.addStatusEffect(
|
||||||
|
StatusEffect(type: StatusEffectType.disarmed, duration: 2, value: 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Verify ATK is reduced to 10%
|
||||||
|
final expectedAtk = (50 * GameConfig.disarmedDamageMultiplier).toInt();
|
||||||
|
expect(battleProvider.enemy.totalAtk, expectedAtk);
|
||||||
|
|
||||||
|
// 4. Verify enemy still generates an attack intent (now predictable due to baseDefense: 0)
|
||||||
|
battleProvider.generateEnemyIntent();
|
||||||
|
final intent = battleProvider.currentEnemyIntent;
|
||||||
|
expect(intent, isNotNull);
|
||||||
|
expect(intent!.type, EnemyActionType.attack);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Player totalAtk reduced to 10% when Disarmed (Attack Forbidden) and turn proceeds',
|
||||||
|
() async {
|
||||||
|
// 1. Verify initial ATK
|
||||||
|
expect(battleProvider.player.totalAtk, 50);
|
||||||
|
|
||||||
|
// 2. Apply Disarm to Player
|
||||||
|
battleProvider.player.addStatusEffect(
|
||||||
|
StatusEffect(type: StatusEffectType.disarmed, duration: 2, value: 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Verify ATK is reduced to 10%
|
||||||
|
final expectedAtk = (50 * GameConfig.disarmedDamageMultiplier).toInt();
|
||||||
|
expect(battleProvider.player.totalAtk, expectedAtk);
|
||||||
|
|
||||||
|
battleProvider.isPlayerTurn = true;
|
||||||
|
|
||||||
|
// 4. Attempt Attack - it should now proceed, not be rejected
|
||||||
|
await battleProvider.playerAction(ActionType.attack, RiskLevel.safe);
|
||||||
|
|
||||||
|
// 5. Verify turn ended (proceeded)
|
||||||
|
expect(
|
||||||
|
battleProvider.isPlayerTurn,
|
||||||
|
false,
|
||||||
|
reason: "Turn should end after successful (weakened) action.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Verify log no longer contains "Cannot attack" (optional but good)
|
||||||
|
expect(
|
||||||
|
battleProvider.logs.last,
|
||||||
|
isNot(contains("Cannot attack")),
|
||||||
|
reason: "Should not log 'Cannot attack' anymore.",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
battleProvider.logs.last,
|
||||||
|
contains("Player chose to attack with safe risk"),
|
||||||
|
reason: "Should log normal attack.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue