diff --git a/assets/images/character/Knight-Attack01.png b/assets/images/character/Knight-Attack01.png new file mode 100644 index 0000000..3b60575 Binary files /dev/null and b/assets/images/character/Knight-Attack01.png differ diff --git a/assets/images/character/Knight-Attack02.png b/assets/images/character/Knight-Attack02.png new file mode 100644 index 0000000..542daff Binary files /dev/null and b/assets/images/character/Knight-Attack02.png differ diff --git a/assets/images/character/Knight-Attack03.png b/assets/images/character/Knight-Attack03.png new file mode 100644 index 0000000..796a8e5 Binary files /dev/null and b/assets/images/character/Knight-Attack03.png differ diff --git a/assets/images/character/Knight-Block.png b/assets/images/character/Knight-Block.png new file mode 100644 index 0000000..658be04 Binary files /dev/null and b/assets/images/character/Knight-Block.png differ diff --git a/assets/images/character/Knight-Death.png b/assets/images/character/Knight-Death.png new file mode 100644 index 0000000..d2871da Binary files /dev/null and b/assets/images/character/Knight-Death.png differ diff --git a/assets/images/character/Knight-Hurt.png b/assets/images/character/Knight-Hurt.png new file mode 100644 index 0000000..e1e3400 Binary files /dev/null and b/assets/images/character/Knight-Hurt.png differ diff --git a/lib/game/config/battle_config.dart b/lib/game/config/battle_config.dart index 3b049b3..36c3ae3 100644 --- a/lib/game/config/battle_config.dart +++ b/lib/game/config/battle_config.dart @@ -97,4 +97,26 @@ class BattleConfig { return sizeSafe; } } + + static String getFeedbackText(BattleFeedbackType type) { + switch (type) { + case BattleFeedbackType.miss: + return "MISS"; + case BattleFeedbackType.failed: + return "FAILED"; + case BattleFeedbackType.dodge: + return "DODGE"; + } + } + + static Color getFeedbackColor(BattleFeedbackType type) { + switch (type) { + case BattleFeedbackType.miss: + return Colors.redAccent; // Or use ThemeConfig.missText if preferred + case BattleFeedbackType.failed: + return Colors.orangeAccent; // Or use ThemeConfig.failedText + case BattleFeedbackType.dodge: + return Colors.greenAccent; // Or use ThemeConfig.statLuckColor + } + } } diff --git a/lib/game/config/game_config.dart b/lib/game/config/game_config.dart index cb83d07..77b77e1 100644 --- a/lib/game/config/game_config.dart +++ b/lib/game/config/game_config.dart @@ -13,6 +13,7 @@ class GameConfig { static const int restStageInterval = 8; static const int tier1StageMax = 12; static const int tier2StageMax = 24; + static const int maxStage = 36; // Battle static const double stageHealRatio = 0.1; diff --git a/lib/game/enums.dart b/lib/game/enums.dart index ab73f0f..371eae4 100644 --- a/lib/game/enums.dart +++ b/lib/game/enums.dart @@ -10,7 +10,8 @@ enum StatusEffectType { bleed, // Takes damage at start/end of turn defenseForbidden, // Cannot use Defend action disarmed, // Attack strength reduced (e.g., 10%) - attackUp, // New: Increases Attack Power + attackUp, + heal, // New: Increases Attack Power } /// 공격 실패 시 이펙트 피드백 타입 정의 diff --git a/lib/game/logic.dart b/lib/game/logic.dart index b7edfdf..f8ea09a 100644 --- a/lib/game/logic.dart +++ b/lib/game/logic.dart @@ -1,3 +1,6 @@ export 'logic/battle_log_manager.dart'; export 'logic/combat_calculator.dart'; export 'logic/loot_generator.dart'; +export 'logic/enemy_ai_service.dart'; +export 'logic/stage_manager.dart'; +export 'logic/effect_event_factory.dart'; diff --git a/lib/game/logic/effect_event_factory.dart b/lib/game/logic/effect_event_factory.dart new file mode 100644 index 0000000..97cb298 --- /dev/null +++ b/lib/game/logic/effect_event_factory.dart @@ -0,0 +1,101 @@ +import 'dart:math'; +import '../enums.dart'; +import '../models.dart'; + +class EffectEventFactory { + /// Generates a unique ID for events. + static String _generateId(Random random) { + return DateTime.now().millisecondsSinceEpoch.toString() + + random.nextInt(1000).toString(); + } + + /// Creates a successful attack event. + static EffectEvent createAttackEvent({ + required Character attacker, + required Character target, + required EffectTarget effectTarget, + required RiskLevel risk, + required int damage, + required Random random, + }) { + return EffectEvent( + id: _generateId(random), + type: ActionType.attack, + risk: risk, + target: effectTarget, + feedbackType: null, + attacker: attacker, + targetEntity: target, + damageValue: damage, + isSuccess: true, + ); + } + + /// Creates a dodge event. + static EffectEvent createDodgeEvent({ + required Character attacker, + required Character target, + required EffectTarget effectTarget, + required RiskLevel risk, + required Random random, + }) { + return EffectEvent( + id: _generateId(random), + type: ActionType.attack, + risk: risk, + target: effectTarget, + feedbackType: BattleFeedbackType.dodge, + attacker: attacker, + targetEntity: target, + damageValue: 0, + isSuccess: false, + ); + } + + /// Creates a miss or failure event. + static EffectEvent createFailureEvent({ + required Character attacker, + required Character target, + required EffectTarget effectTarget, + required ActionType type, + required RiskLevel risk, + required Random random, + }) { + BattleFeedbackType feedbackType = (type == ActionType.attack) + ? BattleFeedbackType.miss + : BattleFeedbackType.failed; + + return EffectEvent( + id: _generateId(random), + type: type, + risk: risk, + target: effectTarget, + feedbackType: feedbackType, + attacker: attacker, + targetEntity: target, + isSuccess: false, + ); + } + + /// Creates a successful defense event. + static EffectEvent createDefenseEvent({ + required Character attacker, + required Character target, + required EffectTarget effectTarget, + required RiskLevel risk, + required int armorGained, + required Random random, + }) { + return EffectEvent( + id: _generateId(random), + type: ActionType.defend, + risk: risk, + target: effectTarget, + feedbackType: null, + attacker: attacker, + targetEntity: target, + armorGained: armorGained, + isSuccess: true, + ); + } +} diff --git a/lib/game/logic/enemy_ai_service.dart b/lib/game/logic/enemy_ai_service.dart new file mode 100644 index 0000000..1a75c24 --- /dev/null +++ b/lib/game/logic/enemy_ai_service.dart @@ -0,0 +1,74 @@ +import 'dart:math'; +import '../enums.dart'; +import '../models.dart'; +import '../config.dart'; +import 'combat_calculator.dart'; + +class EnemyAIService { + /// Decides the next action for the enemy based on status effects and probability. + static EnemyIntent generateIntent(Character enemy, Random random) { + if (enemy.isDead) { + return EnemyIntent( + type: EnemyActionType.attack, + value: 0, + risk: RiskLevel.safe, + description: "Dead", + isSuccess: false, + finalValue: 0, + ); + } + + // Decide Action Type + bool canDefend = + enemy.baseDefense > 0 && + !enemy.hasStatus(StatusEffectType.defenseForbidden); + + bool isAttack = true; // Default to attack + + if (canDefend) { + // Both options available or forced? Currently using probability from config. + isAttack = random.nextDouble() < BattleConfig.enemyAttackChance; + } else { + // Must attack if defense is forbidden or base defense is 0 + isAttack = true; + } + + // Decide Risk Level + RiskLevel risk = RiskLevel.values[random.nextInt(RiskLevel.values.length)]; + + CombatResult result; + if (isAttack) { + result = CombatCalculator.calculateActionOutcome( + actionType: ActionType.attack, + risk: risk, + luck: enemy.totalLuck, + baseValue: enemy.totalAtk, + ); + + return EnemyIntent( + type: EnemyActionType.attack, + value: result.value, + risk: risk, + description: "${result.value} (${risk.name})", + isSuccess: result.success, + finalValue: result.value, + ); + } else { + result = CombatCalculator.calculateActionOutcome( + actionType: ActionType.defend, + risk: risk, + luck: enemy.totalLuck, + baseValue: enemy.totalDefense, + ); + + return EnemyIntent( + type: EnemyActionType.defend, + value: result.value, + risk: risk, + description: "${result.value} (${risk.name})", + isSuccess: result.success, + finalValue: result.value, + ); + } + } +} diff --git a/lib/game/logic/stage_manager.dart b/lib/game/logic/stage_manager.dart new file mode 100644 index 0000000..2177f23 --- /dev/null +++ b/lib/game/logic/stage_manager.dart @@ -0,0 +1,105 @@ +import 'dart:math'; +import '../enums.dart'; +import '../models.dart'; +import '../data.dart'; +import '../config.dart'; + +class StageManager { + /// Determines the stage type based on the stage number and configured intervals. + static StageType getStageTypeFor(int stageNumber) { + if (stageNumber % GameConfig.eliteStageInterval == 0) { + return StageType.elite; + } else if (stageNumber % GameConfig.shopStageInterval == 0) { + return StageType.shop; + } else if (stageNumber % GameConfig.restStageInterval == 0) { + return StageType.rest; + } + return StageType.battle; + } + + /// Generates a Character instance for the enemy in the current stage. + static Character generateEnemy(int stage, StageType type) { + bool isElite = type == StageType.elite; + + // For non-battle stages, return dummy characters (Merchants/Campfires) + if (type == StageType.shop) { + return Character(name: "Merchant", maxHp: 9999, armor: 0, atk: 0); + } else if (type == StageType.rest) { + return Character(name: "Campfire", maxHp: 9999, armor: 0, atk: 0); + } + + // Normal or Elite Battle + EnemyTemplate template = EnemyTable.getRandomEnemy( + stage: stage, + isElite: isElite, + ); + return template.createCharacter(stage: stage); + } + + /// Generates 3 item reward options based on the current stage and tier. + static List generateRewardOptions(int stage, StageType currentStageType) { + ItemTier currentTier = _getTierForStage(stage); + List rewardOptions = []; + + bool isElite = currentStageType == StageType.elite; + bool isTier1 = currentTier == ItemTier.tier1; + + for (int i = 0; i < 3; i++) { + ItemRarity? minRarity; + ItemRarity? maxRarity; + + if (isElite && i == 0) { + if (isTier1) { + minRarity = ItemRarity.rare; + maxRarity = ItemRarity.rare; + } else { + minRarity = ItemRarity.legendary; + } + } else { + if (isTier1) { + maxRarity = ItemRarity.magic; + } + } + + ItemTemplate? item = ItemTable.getRandomItem( + tier: currentTier, + minRarity: minRarity, + maxRarity: maxRarity, + ); + + if (item != null) { + rewardOptions.add(item.createItem(stage: stage)); + } + } + + rewardOptions.add(_createSkipRewardItem()); + return rewardOptions; + } + + /// Calculates the gold reward for defeating an enemy. + static int calculateGoldReward(int stage, Random random) { + return GameConfig.baseGoldReward + + (stage * GameConfig.goldRewardPerStage) + + random.nextInt(GameConfig.goldRewardVariance); + } + + static ItemTier _getTierForStage(int stage) { + if (stage > GameConfig.tier2StageMax) { + return ItemTier.tier3; + } else if (stage > GameConfig.tier1StageMax) { + return ItemTier.tier2; + } + return ItemTier.tier1; + } + + static Item _createSkipRewardItem() { + return Item( + id: "reward_skip", + name: "Skip Reward", + description: "Take nothing and move on.", + atkBonus: 0, + hpBonus: 0, + slot: EquipmentSlot.accessory, + ); + } +} diff --git a/lib/game/model/battle_models.dart b/lib/game/model/battle_models.dart new file mode 100644 index 0000000..bfcf628 --- /dev/null +++ b/lib/game/model/battle_models.dart @@ -0,0 +1,30 @@ +import '../enums.dart'; + +/// Represents the result of applying status effects at the start of a turn. +class TurnEffectResult { + final bool canAct; + final bool effectTriggered; + + TurnEffectResult({required this.canAct, required this.effectTriggered}); +} + +/// Represents the planned action of an enemy for the current turn. +class EnemyIntent { + final EnemyActionType type; + final int value; + final RiskLevel risk; + final String description; + final bool isSuccess; + final int finalValue; + bool isApplied; // Mutable flag to prevent double execution + + EnemyIntent({ + required this.type, + required this.value, + required this.risk, + required this.description, + required this.isSuccess, + required this.finalValue, + this.isApplied = false, + }); +} diff --git a/lib/game/model/damage_event.dart b/lib/game/model/damage_event.dart index dc6b02d..26dba60 100644 --- a/lib/game/model/damage_event.dart +++ b/lib/game/model/damage_event.dart @@ -5,12 +5,14 @@ enum DamageTarget { player, enemy } class DamageEvent { final int damage; + final int armorDamage; // New field final DamageTarget target; final DamageType type; final RiskLevel? risk; DamageEvent({ required this.damage, + this.armorDamage = 0, required this.target, this.type = DamageType.normal, this.risk, diff --git a/lib/game/models.dart b/lib/game/models.dart index 96b12d3..50985f6 100644 --- a/lib/game/models.dart +++ b/lib/game/models.dart @@ -7,3 +7,4 @@ export 'model/stage.dart'; export 'model/stat.dart'; export 'model/stat_modifier.dart'; export 'model/status_effect.dart'; +export 'model/battle_models.dart'; diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index 33b353e..043f3b2 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -1,170 +1,89 @@ -import 'dart:async'; // StreamController 사용을 위해 import +import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import '../game/models.dart'; import '../game/data.dart'; - -import '../utils.dart'; import '../game/enums.dart'; - import '../game/save_manager.dart'; import '../game/config.dart'; -import 'shop_provider.dart'; - import '../game/logic.dart'; - -class TurnEffectResult { - final bool canAct; - final bool effectTriggered; - - TurnEffectResult({required this.canAct, required this.effectTriggered}); -} - -class EnemyIntent { - final EnemyActionType type; - final int value; - final RiskLevel risk; - final String description; - final bool isSuccess; - final int finalValue; - bool isApplied; // Mutable flag to prevent double execution - - EnemyIntent({ - required this.type, - required this.value, - required this.risk, - required this.description, - required this.isSuccess, - required this.finalValue, - this.isApplied = false, - }); -} +import 'shop_provider.dart'; class BattleProvider with ChangeNotifier { static final Duration _turnEffectVisualDelay = AnimationConfig.floatingTextDuration + const Duration(milliseconds: 100); late Character player; - late Character enemy; // Kept for compatibility, active during Battle/Elite + late Character enemy; - late StageModel currentStage; // The current stage object + late StageModel currentStage; EnemyIntent? currentEnemyIntent; final BattleLogManager _logManager = BattleLogManager(); bool isPlayerTurn = true; - int _turnTransactionId = 0; // To prevent async race conditions + int _turnTransactionId = 0; int stage = 1; int turnCount = 1; List rewardOptions = []; bool showRewardPopup = false; - int _lastGoldReward = 0; // New: Stores gold gained from last victory + int _lastGoldReward = 0; + + final ShopProvider shopProvider; + final Random _random; + + BattleProvider({required this.shopProvider, Random? random}) + : _random = random ?? Random(); List get logs => _logManager.logs; int get lastGoldReward => _lastGoldReward; - StageType get nextStageType => getStageTypeFor(stage + 1); + StageType get nextStageType => StageManager.getStageTypeFor(stage + 1); StageType getStageTypeFor(int stageNumber) { - if (stageNumber % GameConfig.eliteStageInterval == 0) { - return StageType.elite; - } else if (stageNumber % GameConfig.shopStageInterval == 0) { - return StageType.shop; - } else if (stageNumber % GameConfig.restStageInterval == 0) { - return StageType.rest; - } - - return StageType.battle; + return StageManager.getStageTypeFor(stageNumber); } void refreshUI() { notifyListeners(); } - // Damage Event Stream + // Streams for UI Feedback final _damageEventController = StreamController.broadcast(); Stream get damageStream => _damageEventController.stream; - // Effect Event Stream final _effectEventController = StreamController.broadcast(); Stream get effectStream => _effectEventController.stream; - // Heal Event Stream final _healEventController = StreamController.broadcast(); Stream get healStream => _healEventController.stream; - // Dependency injection - final ShopProvider shopProvider; - final Random _random; // Injected Random instance - - BattleProvider({required this.shopProvider, Random? random}) - : _random = random ?? Random() { - // initializeBattle(); // Do not auto-start logic - } - @override void dispose() { - _damageEventController.close(); // StreamController 닫기 + _damageEventController.close(); _effectEventController.close(); _healEventController.close(); super.dispose(); } - void loadFromSave(Map data) { - _turnTransactionId++; // Invalidate previous timers - stage = data['stage']; - turnCount = data['turnCount']; - player = Character.fromJson(data['player']); - - // [Fix] Update player image path from latest data (in case of legacy save data) - // This ensures that even if the save file has an old path, the UI uses the correct asset. - if (player.name == "Warrior") { - final template = PlayerTable.get("warrior"); - if (template != null && template.image != null) { - player.image = template.image; - } - } - - _logManager.clear(); - _addLog("Game Loaded! Resuming Stage $stage"); - - _prepareNextStage(); - notifyListeners(); - } + // --- Initialization & Flow --- void initializeBattle() { - _turnTransactionId++; // Invalidate previous timers - stage = 1; - turnCount = 1; - // Load player from PlayerTable - final playerTemplate = PlayerTable.get("warrior"); - if (playerTemplate != null) { - player = playerTemplate.createCharacter(); - } else { - // Fallback if data is missing - player = Character( - name: "Player", - maxHp: 50, - armor: 0, - atk: 5, - baseDefense: 5, - ); - } - - // Give test gold + player = + PlayerTable.get("warrior")?.createCharacter() ?? + Character(name: "Player", maxHp: 50, armor: 0, atk: 5, baseDefense: 5); player.gold = GameConfig.startingGold; + stage = 1; - // Add new status effect items for testing - player.addToInventory(ItemTable.weapons[6].createItem()); // Stunning Hammer - player.addToInventory(ItemTable.weapons[9].createItem()); // Jagged Dagger - player.addToInventory(ItemTable.weapons[5].createItem()); // Sunderer Axe - player.addToInventory(ItemTable.shields[3].createItem()); // Cursed Shield - - // Add Potions for Testing (Requested by User) + // Starting items var healPotion = ItemTable.get('potion_heal_small'); var armorPotion = ItemTable.get('potion_armor_small'); var strPotion = ItemTable.get('potion_strength_small'); + _addStartingItem('war_hammer'); // Stun test weapon + _addStartingItem('barbed_net'); // Bleed test weapon + _addStartingItem('hooked_spear'); // Disarm test weapon + _addStartingItem('cursed_shield'); // Defense-forbidden test shield if (healPotion != null) player.addToInventory(healPotion.createItem()); if (armorPotion != null) player.addToInventory(armorPotion.createItem()); if (strPotion != null) player.addToInventory(strPotion.createItem()); @@ -175,276 +94,508 @@ class BattleProvider with ChangeNotifier { notifyListeners(); } + void _addStartingItem(String itemId) { + final item = ItemTable.get(itemId); + if (item != null) { + player.addToInventory(item.createItem()); + } + } + + void loadFromSave(Map data) { + player = Character.fromJson(data['player']); + stage = data['stage'] ?? 1; + _prepareNextStage(); + _logManager.clear(); + _addLog("Game Loaded! Stage $stage"); + notifyListeners(); + } + void _prepareNextStage() { - _turnTransactionId++; // Invalidate previous timers - // Save Game at the start of each stage + _turnTransactionId++; SaveManager.saveGame(this); - // Reset Player Armor at start of new stage player.armor = 0; + StageType type = StageManager.getStageTypeFor(stage); - StageType type = getStageTypeFor(stage); - - // Prepare Data based on Type - Character? newEnemy; + enemy = StageManager.generateEnemy(stage, type); List shopItems = []; if (type == StageType.battle || type == StageType.elite) { - bool isElite = type == StageType.elite; - - EnemyTemplate template = EnemyTable.getRandomEnemy( - stage: stage, - isElite: isElite, - ); - - newEnemy = template.createCharacter(stage: stage); - - // Assign to the main 'enemy' field for UI compatibility - enemy = newEnemy; isPlayerTurn = true; showRewardPopup = false; - - _generateEnemyIntent(); // Generate first intent - _applyEnemyIntentEffects(); // Apply effects if it's a pre-emptive action (Defense) - - _addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared."); + _generateEnemyIntent(); } else if (type == StageType.shop) { - // Generate random items for shop using ShopProvider shopProvider.generateShopItems(stage); shopItems = shopProvider.availableItems; - - // Dummy enemy to prevent null errors in existing UI (until UI is fully updated) - enemy = Character(name: "Merchant", maxHp: 9999, armor: 0, atk: 0); - _addLog("Stage $stage: Entered a Shop."); } else if (type == StageType.rest) { - // Dummy enemy - enemy = Character(name: "Campfire", maxHp: 9999, armor: 0, atk: 0); _addLog("Stage $stage: Found a safe resting spot."); } - currentStage = StageModel( - type: type, - enemy: newEnemy, - shopItems: shopItems, // Pass items from ShopProvider - ); + currentStage = StageModel(type: type, enemy: enemy, shopItems: shopItems); turnCount = 1; notifyListeners(); } + // --- Combat Logic --- + + Future playerAction(ActionType type, RiskLevel risk) async { + if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) { + return; + } + + applyPendingEnemyDefense(); + + if (type == ActionType.defend && + player.hasStatus(StatusEffectType.defenseForbidden)) { + _addLog("Cannot defend! You are under Defense Forbidden status."); + notifyListeners(); + return; + } + + isPlayerTurn = false; + notifyListeners(); + + TurnEffectResult turnEffect = _processStartTurnEffects(player); + + if (player.isDead) { + await _onDefeat(); + return; + } + + if (turnEffect.effectTriggered) { + await Future.delayed(_turnEffectVisualDelay); + } + + if (!turnEffect.canAct) { + _endPlayerTurn(); + return; + } + + _addLog("Player chose to ${type.name} with ${risk.name} risk."); + + int baseValue = (type == ActionType.attack) + ? player.totalAtk + : player.totalDefense; + final result = CombatCalculator.calculateActionOutcome( + actionType: type, + risk: risk, + luck: player.totalLuck, + baseValue: baseValue, + random: _random, + ); + + if (result.success) { + if (type == ActionType.attack) { + if (CombatCalculator.calculateDodge( + enemy.totalDodge, + random: _random, + )) { + _addLog("${enemy.name} dodged the attack!"); + _effectEventController.sink.add( + EffectEventFactory.createDodgeEvent( + attacker: player, + target: enemy, + effectTarget: EffectTarget.enemy, + risk: risk, + random: _random, + ), + ); + } else { + _effectEventController.sink.add( + EffectEventFactory.createAttackEvent( + attacker: player, + target: enemy, + effectTarget: EffectTarget.enemy, + risk: risk, + damage: result.value, + random: _random, + ), + ); + } + } else { + _effectEventController.sink.add( + EffectEventFactory.createDefenseEvent( + attacker: player, + target: player, + effectTarget: EffectTarget.player, + risk: risk, + armorGained: result.value, + random: _random, + ), + ); + } + } else { + final event = EffectEventFactory.createFailureEvent( + attacker: player, + target: (type == ActionType.attack) ? enemy : player, + effectTarget: (type == ActionType.attack) + ? EffectTarget.enemy + : EffectTarget.player, + type: type, + risk: risk, + random: _random, + ); + _effectEventController.sink.add(event); + _addLog("${player.name}'s ${type.name} ${event.feedbackType!.name}!"); + } + + if (enemy.isDead) { + _onVictory(); + } + } + + void _endPlayerTurn() { + if (enemy.isDead) return; + isPlayerTurn = false; + player.updateEndOfTurnStatusEffects(); + + final tid = _turnTransactionId; + Future.delayed( + const Duration(milliseconds: GameConfig.animDelayEnemyTurn), + () { + if (tid != _turnTransactionId) return; + _processEnemyTurn(); + }, + ); + } + + Future _processEnemyTurn() async { + if (enemy.isDead || player.isDead) return; + + turnCount++; + TurnEffectResult turnEffect = _processStartTurnEffects(enemy); + + if (enemy.isDead) { + _onVictory(); + return; + } + + if (turnEffect.effectTriggered) { + await Future.delayed(_turnEffectVisualDelay); + } + + if (currentEnemyIntent != null && turnEffect.canAct) { + final intent = currentEnemyIntent!; + + if (intent.type == EnemyActionType.defend) { + _addLog("${enemy.name} maintains defensive stance."); + _effectEventController.sink.add( + EffectEventFactory.createDefenseEvent( + attacker: enemy, + target: enemy, + effectTarget: EffectTarget.enemy, + risk: intent.risk, + armorGained: intent.finalValue, + random: _random, + ), + ); + } else { + if (intent.isSuccess) { + if (CombatCalculator.calculateDodge( + player.totalDodge, + random: _random, + )) { + _addLog("${player.name} dodged the attack!"); + _effectEventController.sink.add( + EffectEventFactory.createDodgeEvent( + attacker: enemy, + target: player, + effectTarget: EffectTarget.player, + risk: intent.risk, + random: _random, + ), + ); + } else { + int finalDamage = + (enemy.totalAtk * + CombatCalculator.getEfficiency( + ActionType.attack, + intent.risk, + )) + .toInt(); + if (finalDamage < 1 && enemy.totalAtk > 0) finalDamage = 1; + + _effectEventController.sink.add( + EffectEventFactory.createAttackEvent( + attacker: enemy, + target: player, + effectTarget: EffectTarget.player, + risk: intent.risk, + damage: finalDamage, + random: _random, + ), + ); + } + } else { + _addLog("Enemy's ${intent.risk.name} attack missed!"); + _effectEventController.sink.add( + EffectEventFactory.createFailureEvent( + attacker: enemy, + target: player, + effectTarget: EffectTarget.player, + type: ActionType.attack, + risk: intent.risk, + random: _random, + ), + ); + } + } + } else if (!turnEffect.canAct) { + _addLog("Enemy is stunned and cannot act!"); + int tid = _turnTransactionId; + Future.delayed(const Duration(milliseconds: 500), () { + if (tid != _turnTransactionId) return; + _endEnemyTurn(); + }); + } else { + _addLog("Enemy did nothing."); + int tid = _turnTransactionId; + Future.delayed(const Duration(milliseconds: 500), () { + if (tid != _turnTransactionId) return; + _endEnemyTurn(); + }); + } + } + + void _endEnemyTurn() { + if (player.isDead) return; + enemy.updateEndOfTurnStatusEffects(); + _generateEnemyIntent(); + _startPlayerTurn(); + } + + void _startPlayerTurn() { + isPlayerTurn = true; + notifyListeners(); + } + + TurnEffectResult _processStartTurnEffects(Character character) { + Map result = CombatCalculator.processStartTurnEffects( + character, + ); + int bleedDamage = result['bleedDamage']; + bool isStunned = result['isStunned']; + + if (bleedDamage > 0) { + character.hp -= bleedDamage; + if (character.hp < 0) character.hp = 0; + _addLog("${character.name} took $bleedDamage bleed damage."); + + _damageEventController.sink.add( + DamageEvent( + damage: bleedDamage, + armorDamage: 0, + target: (character == player) + ? DamageTarget.player + : DamageTarget.enemy, + type: DamageType.bleed, + ), + ); + } + + character.updateStartOfTurnStatusEffects(); + return TurnEffectResult( + canAct: !isStunned, + effectTriggered: bleedDamage > 0 || isStunned, + ); + } + + // --- Post-Animation Impacts --- + + void handleImpact(EffectEvent event) { + if (event.isVisualOnly) { + notifyListeners(); + return; + } + + if ((event.isSuccess == false || event.feedbackType != null) && + event.type != ActionType.defend) { + notifyListeners(); + if (event.attacker == player) { + _endPlayerTurn(); + } else if (event.attacker == enemy) { + _endEnemyTurn(); + } + return; + } + + _processAttackImpact(event); + + if (event.triggersTurnChange) { + if (event.attacker == player) { + _endPlayerTurn(); + } else if (event.attacker == enemy) { + _endEnemyTurn(); + } + } + } + + void _processAttackImpact(EffectEvent event) { + final attacker = event.attacker!; + final target = event.targetEntity!; + + if (event.type == ActionType.attack) { + int incomingDamage = event.damageValue!; + + int damageToHp = CombatCalculator.calculateDamageToHp( + incomingDamage: incomingDamage, + currentArmor: target.armor, + isVulnerable: target.hasStatus(StatusEffectType.vulnerable), + ); + + int remainingArmor = CombatCalculator.calculateRemainingArmor( + incomingDamage: incomingDamage, + currentArmor: target.armor, + isVulnerable: target.hasStatus(StatusEffectType.vulnerable), + ); + + int armorDamage = target.armor - remainingArmor; + target.armor = remainingArmor; + + if (damageToHp > 0) { + target.hp -= damageToHp; + if (target.hp < 0) target.hp = 0; + } + + if (damageToHp > 0 || armorDamage > 0) { + _damageEventController.sink.add( + DamageEvent( + damage: damageToHp, + armorDamage: armorDamage, + target: (target == player) + ? DamageTarget.player + : DamageTarget.enemy, + type: target.hasStatus(StatusEffectType.vulnerable) + ? DamageType.vulnerable + : DamageType.normal, + risk: event.risk, + ), + ); + } + + if (damageToHp > 0) { + _addLog("${attacker.name} dealt $damageToHp damage to ${target.name}."); + } else if (armorDamage > 0) { + _addLog("${attacker.name}'s attack was fully blocked by armor."); + } + + _tryApplyStatusEffects(attacker, target, damageToHp); + + if (target == enemy) { + updateEnemyIntent(); + } + } else if (event.type == ActionType.defend) { + if (event.isSuccess!) { + int armorGained = event.armorGained!; + target.armor += armorGained; + _addLog("${target.name} gained $armorGained armor."); + } else { + _addLog("${target.name}'s defense failed!"); + } + } + + if (target.isDead) { + if (target == player) { + _onDefeat(); + } else { + _onVictory(); + } + } + notifyListeners(); + } + + // --- Rewards & Victory --- + + void _onVictory() { + if (stage >= GameConfig.maxStage) { + _onFinalVictory(); + return; + } + + int goldReward = StageManager.calculateGoldReward(stage, _random); + player.gold += goldReward; + _lastGoldReward = goldReward; + _addLog("Enemy defeated! Gained $goldReward Gold."); + _addLog("Choose a reward."); + + rewardOptions = StageManager.generateRewardOptions( + stage, + currentStage.type, + ); + showRewardPopup = true; + notifyListeners(); + } + + void _onFinalVictory() { + _addLog("CONGRATULATIONS! You have conquered the dungeon!"); + // You could set a flag like isGameCompleted = true here + notifyListeners(); + } + Future _onDefeat() async { _addLog("Player defeated! Enemy wins!"); await SaveManager.clearSaveData(); notifyListeners(); } - /// Handle player's action choice - Future playerAction(ActionType type, RiskLevel risk) async { - if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) { - return; - } - - // 0. Ensure Pre-emptive Enemy Defense is applied (if not already via animation) - applyPendingEnemyDefense(); - - // 1. Check for Defense Forbidden status (Player) - if (type == ActionType.defend && - player.hasStatus(StatusEffectType.defenseForbidden)) { - _addLog("Cannot defend! You are under Defense Forbidden status."); - notifyListeners(); // 상태 변경을 알림 - // _endPlayerTurn(); // Allow player to choose another action - return; - } - - isPlayerTurn = false; - notifyListeners(); - - // 2. Process Start-of-Turn Effects (Stun, Bleed) - TurnEffectResult turnEffect = _processStartTurnEffects(player); - - if (player.isDead) { - await _onDefeat(); - return; - } - - // If a visual effect occurred (bleed, stun), wait a bit before action - if (turnEffect.effectTriggered) { - await Future.delayed(_turnEffectVisualDelay); - } - - if (!turnEffect.canAct) { - _endPlayerTurn(); // Skip turn if stunned - return; - } - - _addLog("Player chose to ${type.name} with ${risk.name} risk."); - - // Calculate Outcome using CombatCalculator - int baseValue = (type == ActionType.attack) - ? player.totalAtk - : player.totalDefense; - - final result = CombatCalculator.calculateActionOutcome( - actionType: type, // Pass player's action type - risk: risk, - luck: player.totalLuck, - baseValue: baseValue, - random: _random, // Pass injected random - ); - - if (result.success) { - if (type == ActionType.attack) { - // 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( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - _random.nextInt(1000).toString(), // Use injected random - type: ActionType.attack, - risk: risk, - target: EffectTarget.enemy, - feedbackType: null, - attacker: player, - targetEntity: enemy, - damageValue: damage, - isSuccess: true, - ); - _effectEventController.sink.add(event); - } - } else { - // Defense Success - Impact is immediate, so process it directly - final event = EffectEvent( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: ActionType.defend, - risk: risk, - target: EffectTarget.player, - feedbackType: null, - targetEntity: player, // player is target for defense - armorGained: result.value, - attacker: player, // player is attacker in this context - isSuccess: true, - ); - _effectEventController.sink.add(event); - // handleImpact(event); // REMOVED: Driven by UI - } + bool selectReward(Item item, {bool completeStage = true}) { + if (item.id == "reward_skip") { + _addLog("Skipped reward."); } else { - // Failure - final eventId = - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(); - BattleFeedbackType feedbackType = (type == ActionType.attack) - ? BattleFeedbackType.miss - : BattleFeedbackType.failed; - EffectTarget eventTarget = (type == ActionType.attack) - ? EffectTarget.enemy - : EffectTarget.player; - Character eventTargetEntity = (type == ActionType.attack) - ? enemy - : player; - - final event = EffectEvent( - id: eventId, - type: type, - risk: risk, - target: eventTarget, - feedbackType: feedbackType, - attacker: player, - targetEntity: eventTargetEntity, - isSuccess: false, - ); - _effectEventController.sink.add( - event, - ); // Send event for miss/fail feedback - _addLog("${player.name}'s ${type.name} ${feedbackType.name}!"); - // handleImpact(event); // REMOVED: Driven by UI + if (player.addToInventory(item)) { + _addLog("Gained ${item.name}."); + } else { + _addLog("Inventory full! Could not take ${item.name}."); + return false; + } } - // Now check for enemy death (if applicable from bleed, or previous impacts) - if (enemy.isDead) { - // Check enemy death after player's action - _onVictory(); - return; + if (completeStage) { + showRewardPopup = false; + stage++; + _prepareNextStage(); } - - // _endPlayerTurn(); // REMOVED: Driven by UI via handleImpact + notifyListeners(); + return true; } - void _endPlayerTurn() { - // Update durations at end of turn - player.updateEndOfTurnStatusEffects(); + // --- Helper Methods --- - // Check if enemy is dead from bleed - if (enemy.isDead) { - _onVictory(); - return; - } - - int tid = _turnTransactionId; - Future.delayed( - const Duration(milliseconds: GameConfig.animDelayEnemyTurn), - () { - if (tid != _turnTransactionId) return; - _startEnemyTurn(); - }, - ); + void _addLog(String message) { + _logManager.addLog(message); + notifyListeners(); } - /// Recalculates the current enemy intent value based on current stats. - /// Used to update UI when enemy stats change (e.g. Disarmed applied). + void _generateEnemyIntent() { + currentEnemyIntent = EnemyAIService.generateIntent(enemy, _random); + notifyListeners(); + } + + void generateEnemyIntent() { + _generateEnemyIntent(); + } + + /// Recalculates the currently telegraphed enemy intent without changing + /// its action type, risk, or success roll. void updateEnemyIntent() { if (currentEnemyIntent == null || enemy.isDead) return; final intent = currentEnemyIntent!; - int newValue = 0; + final actionType = intent.type == EnemyActionType.attack + ? ActionType.attack + : ActionType.defend; + final baseValue = intent.type == EnemyActionType.attack + ? enemy.totalAtk + : enemy.totalDefense; + var newValue = + (baseValue * CombatCalculator.getEfficiency(actionType, intent.risk)) + .toInt(); - // 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; + if (newValue < 1 && baseValue > 0) { + newValue = 1; } - // Replace intent with updated value, keeping other properties currentEnemyIntent = EnemyIntent( type: intent.type, value: newValue, @@ -457,564 +608,6 @@ class BattleProvider with ChangeNotifier { notifyListeners(); } - // --- Turn Management Phases --- - - // Phase 4: Start Player Turn - void _startPlayerTurn() { - // Player Turn Start Logic - // Armor decay (Player) - if (player.armor > 0) { - player.armor = (player.armor * GameConfig.armorDecayRate).toInt(); - _addLog("Player's armor decayed to ${player.armor}."); - } - - if (player.isDead) { - _onDefeat(); - return; - } - - // Update Intent if stats changed (e.g. status effects expired) - updateEnemyIntent(); - - // [New] Apply Pre-emptive Enemy Intent (Defense/Buffs) - // MOVED: Logic moved to applyPendingEnemyDefense() to sync with animation. - // We just check intent existence here but do NOT apply effects yet. - if (currentEnemyIntent != null) { - // Intent generated, waiting for player interaction or action to apply. - } - - isPlayerTurn = true; - turnCount++; - notifyListeners(); - } - - void _addLog(String log) { - _logManager.addLog(log); - notifyListeners(); - } - - /// Check Status Effects at Start of Turn - TurnEffectResult _processStartTurnEffects(Character character) { - final result = CombatCalculator.processStartTurnEffects(character); - - int totalBleed = result['bleedDamage']; - bool isStunned = result['isStunned']; - bool effectTriggered = false; - - // 1. Bleed Damage - if (totalBleed > 0) { - character.hp -= totalBleed; - if (character.hp < 0) character.hp = 0; - _addLog("${character.name} takes $totalBleed bleed damage!"); - - // Emit DamageEvent for bleed - _damageEventController.sink.add( - DamageEvent( - damage: totalBleed, - target: (character == player) - ? DamageTarget.player - : DamageTarget.enemy, - type: DamageType.bleed, - ), - ); - effectTriggered = true; - } - - // 2. Stun Check - if (isStunned) { - _addLog("${character.name} is stunned!"); - effectTriggered = true; - } - - // 3. Update durations for start-of-turn effects immediately - character.updateStartOfTurnStatusEffects(); - - return TurnEffectResult( - canAct: !isStunned, - effectTriggered: effectTriggered, - ); - } - - // --- Turn Management Phases --- - - // Phase 1: Enemy Action Phase - Future _startEnemyTurn() async { - _turnTransactionId++; // Start of Enemy Turn Phase - if (!isPlayerTurn && (player.isDead || enemy.isDead)) return; - - _addLog("Enemy's turn..."); - - // Armor decay (Enemy) - if (enemy.armor > 0) { - enemy.armor = (enemy.armor * GameConfig.armorDecayRate).toInt(); - _addLog("Enemy's armor decayed to ${enemy.armor}."); - } - - // Process Start-of-Turn Effects - TurnEffectResult turnEffect = _processStartTurnEffects(enemy); - - if (enemy.isDead) { - _onVictory(); - return; - } - - // If a visual effect occurred (bleed, stun), wait a bit before action - if (turnEffect.effectTriggered) { - await Future.delayed(_turnEffectVisualDelay); - } - - if (turnEffect.canAct && currentEnemyIntent != null) { - final intent = currentEnemyIntent!; - - if (intent.type == EnemyActionType.defend) { - // Defensive Action (Pre-applied at start of Player Turn) - // Just show a log or maintain stance visual - _addLog("${enemy.name} maintains defensive stance."); - - // IMPORTANT: We still need to end the turn sequence properly. - // Since no animation is needed (or a very short one), we can just delay slightly. - int tid = _turnTransactionId; - Future.delayed(const Duration(milliseconds: 500), () { - if (tid != _turnTransactionId) return; - _endEnemyTurn(); - }); - return; - } else { - // Attack Action (Animating) - 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( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - _random.nextInt(1000).toString(), // Use injected random - type: ActionType.attack, - risk: intent.risk, - target: EffectTarget.player, - feedbackType: null, - attacker: enemy, - targetEntity: player, - damageValue: finalDamage, - isSuccess: true, - ); - _effectEventController.sink.add(event); - // UI monitors event -> animates -> calls handleImpact -> _endEnemyTurn - return; - } else { - // Missed Attack - _addLog("Enemy's ${intent.risk.name} attack missed!"); - final event = EffectEvent( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: ActionType.attack, - risk: intent.risk, - target: EffectTarget.player, - feedbackType: BattleFeedbackType.miss, - attacker: enemy, - targetEntity: player, - isSuccess: false, - ); - _effectEventController.sink.add(event); - return; - } - } - } else if (!turnEffect.canAct) { - // If cannot act (stunned) - _addLog("Enemy is stunned and cannot act!"); - int tid = _turnTransactionId; - Future.delayed(const Duration(milliseconds: 500), () { - if (tid != _turnTransactionId) return; - _endEnemyTurn(); - }); - } else { - _addLog("Enemy did nothing."); - - int tid = _turnTransactionId; - Future.delayed(const Duration(milliseconds: 500), () { - if (tid != _turnTransactionId) return; - _endEnemyTurn(); - }); - } - } - - // Phase 2: End Enemy Turn & Generate Next Intent - void _endEnemyTurn() { - if (player.isDead) return; // Game Over check - - // Update enemy status at the end of their turn - enemy.updateEndOfTurnStatusEffects(); - - // Generate NEXT intent - _generateEnemyIntent(); - - _processMiddleTurn(); - } - - // Phase 3: Middle Turn (Apply Defense Effects) - Future _processMiddleTurn() async { - // Phase 3 Middle Turn - Reduced functionality as Defense is now Phase 1. - int tid = _turnTransactionId; - // await Future.delayed(const Duration(milliseconds: 200)); // Removed for faster turn transition - if (tid != _turnTransactionId) return; - - _startPlayerTurn(); - } - - void _onVictory() { - // Calculate Gold Reward - // Base 10 + (Stage * 5) + Random variance - final random = Random(); - int goldReward = - GameConfig.baseGoldReward + - (stage * GameConfig.goldRewardPerStage) + - random.nextInt(GameConfig.goldRewardVariance); - - player.gold += goldReward; - _lastGoldReward = goldReward; // Store for UI display - _addLog("Enemy defeated! Gained $goldReward Gold."); - _addLog("Choose a reward."); - - ItemTier currentTier = ItemTier.tier1; - if (stage > GameConfig.tier2StageMax) { - currentTier = ItemTier.tier3; - } else if (stage > GameConfig.tier1StageMax) { - currentTier = ItemTier.tier2; - } - - rewardOptions = []; - - bool isElite = currentStage.type == StageType.elite; - bool isTier1 = currentTier == ItemTier.tier1; - - // Get 3 distinct items - for (int i = 0; i < 3; i++) { - ItemRarity? minRarity; - ItemRarity? maxRarity; - - // 1. Elite Reward Logic (First Item only) - if (isElite && i == 0) { - if (isTier1) { - // Tier 1 Elite: Guaranteed Rare - minRarity = ItemRarity.rare; - maxRarity = ItemRarity - .rare; // Or allow higher? Request said "Guaranteed Rare 1 drop". Let's fix to Rare. - } else { - // Tier 2/3 Elite: Guaranteed Legendary - minRarity = ItemRarity.legendary; - // maxRarity = ItemRarity.legendary; // Optional, but let's allow Unique too if weights permit, or fix to Legendary. Request said "Guaranteed Legendary". - } - } - // 2. Standard Reward Logic (Others) - else { - if (isTier1) { - // Tier 1 Normal/Other Rewards: Max Magic (No Rare+) - maxRarity = ItemRarity.magic; - } - // Tier 2/3 Normal: No extra restrictions - } - - ItemTemplate? item = ItemTable.getRandomItem( - tier: currentTier, - minRarity: minRarity, - maxRarity: maxRarity, - ); - - if (item != null) { - rewardOptions.add(item.createItem(stage: stage)); - } - } - - // Add "None" (Skip) Option - // We can represent "None" as a null or a special Item. - // Using a special Item with ID "reward_skip" is safer for List. - rewardOptions.add( - Item( - id: "reward_skip", - name: "Skip Reward", - description: "Take nothing and move on.", - atkBonus: 0, - hpBonus: 0, - slot: EquipmentSlot.accessory, - ), - ); - - showRewardPopup = true; - notifyListeners(); - } - - bool selectReward(Item item, {bool completeStage = true}) { - if (item.id == "reward_skip") { - _addLog("Skipped reward."); - if (completeStage) { - _completeStage(); - } - return true; - } else { - bool added = player.addToInventory(item); - if (added) { - _addLog("Added ${item.name} to inventory."); - if (completeStage) { - _completeStage(); - } - return true; - } else { - _addLog("Inventory is full! Could not take ${item.name}."); - return false; - } - } - } - - void completeStage() { - _completeStage(); - } - - void _completeStage() { - // Heal player after selecting reward - int healAmount = GameMath.floor( - player.totalMaxHp * GameConfig.stageHealRatio, - ); - player.heal(healAmount); - _addLog("Stage Cleared! Recovered $healAmount HP."); - - stage++; - showRewardPopup = false; - - _prepareNextStage(); - - // Log moved to _prepareNextStage - - // isPlayerTurn = true; // Handled in _prepareNextStage for battles - notifyListeners(); - } - - void equipItem(Item item, {EquipmentSlot? targetSlot}) { - final success = targetSlot == null - ? player.equip(item) - : player.equipToSlot(item, targetSlot); - - if (success) { - final slotName = targetSlot == null - ? item.typeName - : targetSlot == EquipmentSlot.weapon - ? "Main Weapon" - : targetSlot == EquipmentSlot.shield - ? "Subweapon" - : targetSlot.name; - _addLog("Equipped ${item.name} as $slotName."); - } else { - _addLog( - "Failed to equip ${item.name}.", - ); // Should not happen if logic is correct - } - notifyListeners(); - } - - void unequipItem(Item item) { - if (player.unequip(item)) { - _addLog("Unequipped ${item.name}."); - } else { - _addLog("Failed to unequip ${item.name} (Inventory might be full)."); - } - notifyListeners(); - } - - void discardItem(Item item) { - if (player.inventory.remove(item)) { - _addLog("Discarded ${item.name}."); - notifyListeners(); - } - } - - void sellItem(Item item) { - if (player.inventory.remove(item)) { - int sellPrice = GameMath.floor( - item.price * GameConfig.sellPriceMultiplier, - ); - player.gold += sellPrice; - _addLog("Sold ${item.name} for $sellPrice G."); - notifyListeners(); - } - } - - /// Use a consumable item during battle (Free Action) - void useConsumable(Item item) { - if (item.slot != EquipmentSlot.consumable) { - _addLog("Cannot use ${item.name}!"); - return; - } - - // 1. Apply Immediate Effects - bool effectApplied = false; - - // Heal - if (item.hpBonus > 0) { - int currentHp = player.hp; - player.heal(item.hpBonus); - int healedAmount = player.hp - currentHp; - if (healedAmount > 0) { - _addLog("Used ${item.name}. Recovered $healedAmount HP."); - _healEventController.sink.add(HealEvent(amount: healedAmount, target: HealTarget.player)); - effectApplied = true; - } else { - _addLog("Used ${item.name}. HP is already full."); - // Still consume? Yes, usually potions are lost even if full HP if used. - // But maybe valid to just say "Recovered 0 HP". - effectApplied = true; - } - } - - // Armor - if (item.armorBonus > 0) { - player.armor += item.armorBonus; - _addLog("Used ${item.name}. Gained ${item.armorBonus} Armor."); - effectApplied = true; - } - - // 2. Apply Status Effects (Buffs) - if (item.effects.isNotEmpty) { - for (var effect in item.effects) { - player.addStatusEffect( - StatusEffect( - type: effect.type, - duration: effect.duration, - value: effect.value, - ), - ); - // Log handled? Character.addStatusEffect might need logging or we log here. - // Let's add specific logs for known buffs - if (effect.type == StatusEffectType.attackUp) { - _addLog( - "Used ${item.name}. Attack Up for ${effect.duration} turn(s)!", - ); - } else { - _addLog("Used ${item.name}. Applied ${effect.type.name}!"); - } - } - effectApplied = true; - } - - if (effectApplied) { - player.inventory.remove(item); - notifyListeners(); - } - } - - /// Proceed to next stage from non-battle stages (Shop, Rest) - void proceedToNextStage() { - stage++; - _prepareNextStage(); - } - - @visibleForTesting - void generateEnemyIntent() { - _generateEnemyIntent(); - } - - void _generateEnemyIntent() { - if (enemy.isDead) { - currentEnemyIntent = null; - return; - } - - // Use the injected _random field - // final random = Random(); // Removed - - // Decide Action Type - // Check constraints - bool canDefend = - enemy.baseDefense > 0 && - !enemy.hasStatus(StatusEffectType.defenseForbidden); - bool canAttack = - true; // Attack is always possible, but strength is affected by status. - - bool isAttack = true; // Default to attack - - if (canAttack && canDefend) { - // Both options available: Use configured probability - isAttack = _random.nextDouble() < BattleConfig.enemyAttackChance; - } else if (canDefend) { - // Must defend - isAttack = false; - } else { - // Must attack (default) or both forbidden (defaults to attack currently) - isAttack = true; - } - - // Decide Risk Level - RiskLevel risk = RiskLevel.values[_random.nextInt(RiskLevel.values.length)]; - - CombatResult result; - if (isAttack) { - result = CombatCalculator.calculateActionOutcome( - actionType: ActionType.attack, - risk: risk, - luck: enemy.totalLuck, - baseValue: enemy.totalAtk, - ); - - currentEnemyIntent = EnemyIntent( - type: EnemyActionType.attack, - value: result.value, // Damage value from CombatCalculator - risk: risk, - description: "${result.value} (${risk.name})", - isSuccess: result.success, - finalValue: result.value, - ); - } else { - result = CombatCalculator.calculateActionOutcome( - actionType: ActionType.defend, - risk: risk, - luck: enemy.totalLuck, - baseValue: enemy.totalDefense, - ); - - currentEnemyIntent = EnemyIntent( - type: EnemyActionType.defend, - value: result.value, // Armor value from CombatCalculator - risk: risk, - description: "${result.value} (${risk.name})", - isSuccess: result.success, - finalValue: result.value, - ); - } - notifyListeners(); - } - - /// Ensure the enemy's pending defense is applied. - /// Called manually by UI during animation, or auto-called by playerAction as fallback. void applyPendingEnemyDefense() { if (currentEnemyIntent != null && currentEnemyIntent!.type == EnemyActionType.defend && @@ -1033,138 +626,6 @@ class BattleProvider with ChangeNotifier { } } - /// Applies the effects of the enemy's intent (specifically Defense) - /// This should be called just before the Player's turn starts. - void _applyEnemyIntentEffects() { - // No pre-emptive effects in Standard Turn-Based model. - // Logic cleared. - } - - // New public method to be called by UI at impact moment - void handleImpact(EffectEvent event) { - if (event.isVisualOnly) { - // Logic Skipped. Just log if needed, but usually logging is done at event creation. - // We do NOT process damage or armor here. - notifyListeners(); - return; - } - - if ((event.isSuccess == false || event.feedbackType != null) && - event.type != ActionType.defend) { - // If it's a miss/fail/feedback, just log and return - // Logging and feedback text should already be handled when event created - notifyListeners(); // Ensure UI updates for log - - // Even on failure, proceed to end turn logic - if (event.attacker == player) { - _endPlayerTurn(); - } else if (event.attacker == enemy) { - // Special Case: Phase 1 relies on manual timer. Phase 3 relies on _processMiddleTurn sequence. - _endEnemyTurn(); - } - return; - } - - // Only process actual attack or defend impacts here - _processAttackImpact(event); - - // After processing impact, proceed to end turn logic - if (event.triggersTurnChange) { - if (event.attacker == player) { - _endPlayerTurn(); - } else if (event.attacker == enemy) { - _endEnemyTurn(); - } - } - } - - // Refactored common attack impact logic - void _processAttackImpact(EffectEvent event) { - final attacker = event.attacker!; - final target = event.targetEntity!; - - // Attack type needs detailed damage calculation - if (event.type == ActionType.attack) { - int incomingDamage = event.damageValue!; - - // Calculate Damage to HP using CombatCalculator - int damageToHp = CombatCalculator.calculateDamageToHp( - incomingDamage: incomingDamage, - currentArmor: target.armor, - isVulnerable: target.hasStatus(StatusEffectType.vulnerable), - ); - - // Calculate Remaining Armor - int remainingArmor = CombatCalculator.calculateRemainingArmor( - incomingDamage: incomingDamage, - currentArmor: target.armor, - isVulnerable: target.hasStatus(StatusEffectType.vulnerable), - ); - - // Log details - if (target.armor > 0) { - int absorbed = target.armor - remainingArmor; - if (damageToHp == 0) { - _addLog("${target.name}'s armor absorbed all damage."); - } else { - _addLog("${target.name}'s armor absorbed $absorbed damage."); - } - } - target.armor = remainingArmor; - - if (damageToHp > 0) { - target.hp -= damageToHp; - if (target.hp < 0) target.hp = 0; - - _damageEventController.sink.add( - DamageEvent( - damage: damageToHp, - target: (target == player) - ? DamageTarget.player - : DamageTarget.enemy, - type: target.hasStatus(StatusEffectType.vulnerable) - ? DamageType.vulnerable - : DamageType.normal, - risk: event.risk, - ), - ); - _addLog("${attacker.name} dealt $damageToHp damage to ${target.name}."); - } else { - _addLog("${attacker.name}'s attack was fully blocked by armor."); - } - - // Try applying status effects - _tryApplyStatusEffects(attacker, target, damageToHp); - - // If target is enemy, update intent to reflect potential status changes (e.g. Disarmed) - if (target == enemy) { - updateEnemyIntent(); - } - } else if (event.type == ActionType.defend) { - // Defense Impact is immediate (no anim delay from UI) - if (event.isSuccess!) { - // Check success again for clarity - int armorGained = event.armorGained!; - target.armor += armorGained; - _addLog("${target.name} gained $armorGained armor."); - } else { - // Failed Defense - _addLog("${target.name}'s defense failed!"); - } - } - - // Check for death after impact - if (target.isDead) { - if (target == player) { - _onDefeat(); - } else { - _onVictory(); - } - } - notifyListeners(); - } - - /// Tries to applyStatus effects from attacker's equipment to the target. void _tryApplyStatusEffects( Character attacker, Character target, @@ -1172,17 +633,117 @@ class BattleProvider with ChangeNotifier { ) { List effectsToApply = CombatCalculator.getAppliedEffects( attacker, - random: _random, // Pass injected random + random: _random, ); - for (var effect in effectsToApply) { - // Logic: Bleed requires HP damage (penetrating armor) - if (effect.type == StatusEffectType.bleed && damageToHp <= 0) { - continue; - } - + if (effect.type == StatusEffectType.bleed && damageToHp <= 0) continue; target.addStatusEffect(effect); _addLog("Applied ${effect.type.name} to ${target.name}!"); } } + + // --- Equipment Management --- + + void equipItem(Item item, {EquipmentSlot? targetSlot}) { + bool success = false; + if (targetSlot != null) { + success = player.equipToSlot(item, targetSlot); + } else { + success = player.equip(item); + } + + if (success) { + _addLog("Equipped ${item.name}."); + notifyListeners(); + } + } + + void unequipItem(Item item) { + if (player.unequip(item)) { + _addLog("Unequipped ${item.name}."); + notifyListeners(); + } + } + + void sellItem(Item item) { + int price = (item.price * GameConfig.sellPriceMultiplier).floor(); + player.gold += price; + player.inventory.remove(item); + _addLog("Sold ${item.name} for $price G."); + notifyListeners(); + } + + void discardItem(Item item) { + player.inventory.remove(item); + _addLog("Discarded ${item.name}."); + notifyListeners(); + } + + void useConsumable(Item item) { + if (item.slot != EquipmentSlot.consumable) { + _addLog("Cannot use ${item.name}."); + return; + } + + var effectApplied = false; + + if (item.hpBonus > 0) { + final hpBefore = player.hp; + player.heal(item.hpBonus); + final healedAmount = player.hp - hpBefore; + + if (healedAmount > 0) { + _addLog("Used ${item.name}: Healed $healedAmount HP."); + _healEventController.sink.add( + HealEvent(amount: healedAmount, target: HealTarget.player), + ); + } else { + _addLog("Used ${item.name}: HP is already full."); + } + effectApplied = true; + } + + if (item.armorBonus > 0) { + player.armor += item.armorBonus; + _addLog("Used ${item.name}: Gained ${item.armorBonus} Armor."); + effectApplied = true; + } + + for (var effect in item.effects) { + if (effect.type == StatusEffectType.heal) { + final hpBefore = player.hp; + player.heal(effect.value); + final healedAmount = player.hp - hpBefore; + + if (healedAmount > 0) { + _addLog("Used ${item.name}: Healed $healedAmount HP."); + _healEventController.sink.add( + HealEvent(amount: healedAmount, target: HealTarget.player), + ); + } else { + _addLog("Used ${item.name}: HP is already full."); + } + } else { + player.addStatusEffect( + StatusEffect( + type: effect.type, + duration: effect.duration, + value: effect.value, + ), + ); + _addLog("Used ${item.name}: Applied ${effect.type.name}."); + } + effectApplied = true; + } + + if (effectApplied) { + player.inventory.remove(item); + notifyListeners(); + } + } + + void proceedToNextStage() { + stage++; + _prepareNextStage(); + } } diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 046e1d6..e8d8d47 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -5,14 +5,10 @@ import '../providers.dart'; import '../game/enums.dart'; import '../game/models.dart'; -import 'dart:async'; import '../widgets.dart'; import '../utils.dart'; -import 'main_menu_screen.dart'; import '../game/config.dart'; -import '../widgets/battle/effect_sprite_widget.dart'; - -enum AnimationPhase { none, start, middle, end } +import 'battle_visual_handler.dart'; class BattleScreen extends StatefulWidget { const BattleScreen({super.key}); @@ -21,48 +17,39 @@ class BattleScreen extends StatefulWidget { State createState() => _BattleScreenState(); } -class _BattleScreenState extends State { - final List _floatingDamageTexts = []; - final List _floatingEffects = []; - final List _floatingFeedbackTexts = []; - StreamSubscription? _damageSubscription; - StreamSubscription? _effectSubscription; - StreamSubscription? _healSubscription; - final GlobalKey _playerKey = GlobalKey(); - final GlobalKey _enemyKey = GlobalKey(); - final GlobalKey _stackKey = GlobalKey(); - final GlobalKey _shakeKey = GlobalKey(); - final GlobalKey _playerAnimKey = - GlobalKey(); - final GlobalKey _enemyAnimKey = - GlobalKey(); // Added Enemy Anim Key - final GlobalKey _explosionKey = - GlobalKey(); - final GlobalKey _effectSpriteKey = - GlobalKey(); +class _BattleScreenState extends State with BattleVisualHandler { bool _showLogs = false; - bool _isPlayerAttacking = false; // Player Attack Animation State - bool _isEnemyAttacking = false; // Enemy Attack Animation State bool _showEquipmentSwapPanel = false; - bool _isCompletingReward = false; - DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts // New State for Interactive Defense Animation int _lastTurnCount = -1; bool _hasShownEnemyDefense = false; - AnimationPhase _playerAnimPhase = AnimationPhase.none; String? _getOverrideImage(bool isPlayer) { if (!isPlayer) { return null; // Enemy animation image logic can be added later } - if (_playerAnimPhase == AnimationPhase.start) { - return "assets/images/character/warrior_attack_1.png"; - } else if (_playerAnimPhase == AnimationPhase.middle) { - return null; // Middle phase now uses default image or another image - } else if (_playerAnimPhase == AnimationPhase.end) { - return "assets/images/character/warrior_attack_2.png"; + if (playerAnimPhase == AnimationPhase.block) { + return "assets/images/character/Knight-Block.png"; + } + + if (playerAnimPhase == AnimationPhase.hurt) { + return "assets/images/character/Knight-Hurt.png"; + } + + if (playerAnimPhase == AnimationPhase.start || + playerAnimPhase == AnimationPhase.middle || + playerAnimPhase == AnimationPhase.end) { + if (!isAttackSuccess) return null; // Idle for failed attacks + + if (activeRiskLevel == RiskLevel.safe) { + return "assets/images/character/Knight-Attack01.png"; + } else if (activeRiskLevel == RiskLevel.normal) { + return "assets/images/character/Knight-Attack02.png"; + } else if (activeRiskLevel == RiskLevel.risky) { + return "assets/images/character/Knight-Attack03.png"; + } } return null; @@ -71,497 +58,15 @@ class _BattleScreenState extends State { @override void initState() { super.initState(); - final battleProvider = context.read(); - _damageSubscription = battleProvider.damageStream.listen( - _addFloatingDamageText, - ); - _effectSubscription = battleProvider.effectStream.listen( - _addFloatingEffect, - ); - _healSubscription = battleProvider.healStream.listen(_onHealEvent); + setupVisualListeners(context.read()); } @override void dispose() { - _damageSubscription?.cancel(); - _effectSubscription?.cancel(); - _healSubscription?.cancel(); + disposeVisualListeners(); super.dispose(); } - void _addFloatingDamageText(DamageEvent event) { - if (!mounted) return; - - GlobalKey targetKey = event.target == DamageTarget.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 + BattleConfig.damageTextOffsetX, - BattleConfig.damageTextOffsetY, - ); - - final String id = UniqueKey().toString(); - - final double scale = - event.risk == RiskLevel.risky || - (event.risk == null && - event.damage > BattleConfig.highDamageThreshold) - ? BattleConfig.damageScaleHigh - : BattleConfig.damageScaleNormal; - - setState(() { - _floatingDamageTexts.add( - DamageTextData( - id: id, - widget: Positioned( - key: ValueKey('pos_$id'), - left: position.dx, - top: position.dy, - child: Transform.scale( - scale: scale, - child: FloatingDamageText( - key: ValueKey(id), - damage: event.damage.toString(), - color: event.color, - onRemove: () { - if (mounted) { - setState(() { - _floatingDamageTexts.removeWhere((e) => e.id == id); - }); - } - }, - ), - ), - ), - ), - ); - }); - } - - void _onHealEvent(HealEvent event) { - if (!mounted) return; - - // Find position: Default to center of screen - Offset position = Offset( - MediaQuery.of(context).size.width / 2, - MediaQuery.of(context).size.height / 2, - ); - - // Try to get player's position if visible (in Battle UI) - if (event.target == HealTarget.player && - _playerKey.currentContext != null) { - RenderBox? renderBox = - _playerKey.currentContext!.findRenderObject() as RenderBox?; - if (renderBox != null) { - position = renderBox.localToGlobal( - Offset(renderBox.size.width / 2, renderBox.size.height / 2), - ); - } - } - - // Play visual effect (heal.png has 4 frames) - _effectSpriteKey.currentState?.playEffect( - position: position, - assetPath: 'assets/images/effects/heal.png', - frameCount: 4, - tileWidth: 100.0, // Assuming each frame is 100x100 - tileHeight: 100.0, - scale: 2.0, - ); - - // Play floating text - final String id = UniqueKey().toString(); - setState(() { - _floatingDamageTexts.add( - DamageTextData( - id: id, - widget: Positioned( - key: ValueKey('pos_$id'), - left: position.dx + BattleConfig.damageTextOffsetX, - top: position.dy + BattleConfig.damageTextOffsetY, - child: FloatingDamageText( - key: ValueKey(id), - damage: "+${event.amount}", - color: ThemeConfig.statHpPlayerColor, // Green color for heal - onRemove: () { - if (mounted) { - setState(() { - _floatingDamageTexts.removeWhere((e) => e.id == id); - }); - } - }, - ), - ), - ), - ); - }); - } - - final Set _processedEffectIds = {}; - - void _addFloatingEffect(EffectEvent event) { - if (_processedEffectIds.contains(event.id)) { - return; - } - - _processedEffectIds.add(event.id); - // Keep the set size manageable - if (_processedEffectIds.length > 50) { - _processedEffectIds.remove(_processedEffectIds.first); - } - - if (!mounted) return; - - bool shouldShowFeedback = true; - if (event.feedbackType != null) { - final now = DateTime.now(); - if (_lastFeedbackTime != null && - now.difference(_lastFeedbackTime!).inMilliseconds < - BattleConfig.feedbackCooldownMs) { - shouldShowFeedback = false; - } else { - _lastFeedbackTime = 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; - } - - // Adjust position based on target: - // Enemy (Top Right) -> Effect to the left/bottom of character (towards player) - // Player (Bottom Left) -> Effect to the right/top of character (towards enemy) - double offsetX = 0; - double offsetY = 0; - - if (event.target == EffectTarget.enemy) { - // Enemy is top-right, so effect should be left-bottom of its card - offsetX = renderBox.size.width * BattleConfig.effectEnemyOffsetX; - offsetY = renderBox.size.height * BattleConfig.effectEnemyOffsetY; - } else { - // Player is bottom-left, so effect should be right-top of its card - offsetX = renderBox.size.width * BattleConfig.effectPlayerOffsetX; - offsetY = renderBox.size.height * BattleConfig.effectPlayerOffsetY; - } - - position = position + Offset(offsetX, offsetY); - - // 0. Prepare Effect Function - void showEffect() { - if (!mounted) return; - - // Handle Feedback Text (MISS / FAILED) - if (event.feedbackType != null) { - if (!shouldShowFeedback) return; - - 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; - case BattleFeedbackType.dodge: - feedbackText = "DODGE"; - feedbackColor = - ThemeConfig.statLuckColor; // Use Luck color (Greenish) - break; - default: - feedbackText = ""; - feedbackColor = ThemeConfig.textColorWhite; - } - - 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(() { - _floatingFeedbackTexts.clear(); // Clear previous texts - _floatingFeedbackTexts.add( - FeedbackTextData( - id: id, - eventId: event.id, - widget: Positioned( - key: ValueKey('pos_$id'), - 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; // Return early for feedback - } - - // Handle Icon Effect - 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(); - - setState(() { - _floatingEffects.add( - FloatingEffectData( - id: id, - widget: Positioned( - key: ValueKey('pos_$id'), - 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.isVisualOnly) { - showEffect(); - context.read().handleImpact(event); - } else 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().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); - } - } - }, - animRisk, - onAnimationStart: () { - if (mounted) { - setState(() => _playerAnimPhase = AnimationPhase.start); - } - }, - onAnimationMiddle: () { - if (mounted) { - setState(() => _playerAnimPhase = AnimationPhase.middle); - } - }, - onAnimationEnd: () { - if (mounted) { - setState(() => _playerAnimPhase = AnimationPhase.end); - } - }, - ) - .then((_) { - if (mounted) { - setState(() { - _isPlayerAttacking = false; - _playerAnimPhase = AnimationPhase.none; - }); - } - }); - } - } - // 2. Enemy Attack Animation Trigger (Success or Miss) - else if (event.type == ActionType.attack && - event.target == EffectTarget.player) { - bool enableAnim = context.read().enableEnemyAnimations; - - if (!enableAnim) { - showEffect(); - context.read().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().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) { - setState(() => _isPlayerAttacking = true); // Reuse flag to block input - _playerAnimKey.currentState - ?.animateDefense(() { - showEffect(); - context.read().handleImpact(event); - }) - .then((_) { - if (mounted) setState(() => _isPlayerAttacking = false); - }); - } else if (event.target == EffectTarget.enemy) { - // Check settings for enemy animation - bool enableAnim = context - .read() - .enableEnemyAnimations; - if (!enableAnim) { - showEffect(); - context.read().handleImpact(event); - return; - } - - setState(() => _isEnemyAttacking = true); // Reuse flag to block input - _enemyAnimKey.currentState - ?.animateDefense(() { - showEffect(); - context.read().handleImpact(event); - }) - .then((_) { - if (mounted) setState(() => _isEnemyAttacking = false); - }); - } else { - showEffect(); - context.read().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().handleImpact(event); - }); - } else { - // Success events (Icon) - context.read().handleImpact(event); - } - } - } - void _showRiskLevelSelection(BuildContext context, ActionType actionType) { if (_showEquipmentSwapPanel) { setState(() => _showEquipmentSwapPanel = false); @@ -607,10 +112,10 @@ class _BattleScreenState extends State { !_hasShownEnemyDefense && context.read().enableEnemyAnimations) { _hasShownEnemyDefense = true; - setState(() => _isEnemyAttacking = true); // Block input momentarily + setState(() => isEnemyAttacking = true); // Block input momentarily // Trigger Animation - _enemyAnimKey.currentState + enemyAnimKey.currentState ?.animateDefense(() { // [New] Apply Logic Synced with Animation battleProvider.applyPendingEnemyDefense(); @@ -635,10 +140,10 @@ class _BattleScreenState extends State { triggersTurnChange: false, ); - _addFloatingEffect(visualEvent); + onEffectEvent(visualEvent); }) .then((_) { - if (mounted) setState(() => _isEnemyAttacking = false); + if (mounted) setState(() => isEnemyAttacking = false); }); return true; @@ -704,7 +209,7 @@ class _BattleScreenState extends State { ), ], ), - _buildItemStatText(item), + ItemStatWidget(item: item), ], ), ); @@ -797,8 +302,8 @@ class _BattleScreenState extends State { !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup && - !_isPlayerAttacking && - !_isEnemyAttacking; + !isPlayerAttacking && + !isEnemyAttacking; } Widget _buildEquipmentSwapButton(BattleProvider battleProvider) { @@ -818,64 +323,11 @@ class _BattleScreenState extends State { ); } - bool get _hasPendingBattleAnimations { - return _isPlayerAttacking || - _isEnemyAttacking || - _floatingDamageTexts.isNotEmpty || - _floatingEffects.isNotEmpty || - _floatingFeedbackTexts.isNotEmpty || - (_explosionKey.currentState?.isAnimating ?? false); - } - - Future _waitForBattleAnimationsToSettle() async { - final deadline = DateTime.now().add( - AnimationConfig.attackRiskyTotal + - AnimationConfig.floatingTextDuration + - const Duration(milliseconds: 400), - ); - - while (mounted && - _hasPendingBattleAnimations && - DateTime.now().isBefore(deadline)) { - await Future.delayed(const Duration(milliseconds: 50)); - } - } - - Future _selectRewardAfterAnimationsIfNeeded(Item item) async { - if (_isCompletingReward) return; - - final battleProvider = context.read(); - final shouldWaitForShop = - battleProvider.nextStageType == StageType.shop && - _hasPendingBattleAnimations; - - setState(() => _isCompletingReward = true); - final success = battleProvider.selectReward(item, completeStage: false); - - if (!success) { - if (mounted) { - setState(() => _isCompletingReward = false); - ToastUtils.showTopToast( - context, - "${AppStrings.inventoryFull} Cannot take item.", - ); - } - return; - } - - if (shouldWaitForShop) { - await _waitForBattleAnimationsToSettle(); - } - - if (!mounted) return; - context.read().completeStage(); - setState(() => _isCompletingReward = false); - } - @override Widget build(BuildContext context) { return ResponsiveContainer( child: Stack( + key: rootStackKey, children: [ Consumer( builder: (context, battleProvider, child) { @@ -887,341 +339,66 @@ class _BattleScreenState extends State { return _buildBattleUI(battleProvider); }, ), - EffectSpriteWidget(key: _effectSpriteKey), + EffectSpriteWidget(key: effectSpriteKey), ], ), ); } Widget _buildBattleUI(BattleProvider battleProvider) { - return ShakeWidget( - key: _shakeKey, - child: Stack( - key: _stackKey, - children: [ - // 1. Background Image - Container( - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/images/background/tier_1.jpg'), - fit: BoxFit.cover, - ), - ), - ), - // 1.1 Opacity Layer - Container(color: Colors.black.withValues(alpha: 0.7)), - - // 2. Battle Content (Top Bar + Characters) - Column( - children: [ - // Top Bar - const BattleHeader(), - - // Battle Area (Characters) - Expanded to fill available space - Expanded( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Stack( - children: [ - // Player (Bottom Left) - Rendered First - Positioned( - bottom: 80, // Space for FABs - left: 16, // Add some padding from left - child: CharacterStatusCard( - character: battleProvider.player, - isPlayer: true, - isTurn: battleProvider.isPlayerTurn, - key: _playerKey, - animationKey: _playerAnimKey, - hideStats: _isPlayerAttacking, - overrideImage: _getOverrideImage(true), - ), - ), - // Enemy (Top Right) - Rendered Last (On Top) - Positioned( - top: 16, // Add some padding from top - right: 16, // Add some padding from right - child: CharacterStatusCard( - character: battleProvider.enemy, - isPlayer: false, - isTurn: !battleProvider.isPlayerTurn, - key: _enemyKey, - animationKey: _enemyAnimKey, // Direct Pass - hideStats: _isEnemyAttacking, - ), - ), - ], // Close children list - ), // Close Stack - ), // Close Padding - ), // Close Expanded - ], // Close Column - ), // Close Column - // 3. Logs Overlay - if (_showLogs && battleProvider.logs.isNotEmpty) - Positioned( - top: 60, - left: 16, - right: 16, - height: BattleConfig.logsOverlayHeight, - child: BattleLogOverlay(logs: battleProvider.logs), - ), - - // 4. Battle Controls (Bottom Right) - Positioned( - bottom: 20, - right: 20, - child: BattleControls( - isAttackEnabled: - battleProvider.isPlayerTurn && - !battleProvider.player.isDead && - !battleProvider.enemy.isDead && - !battleProvider.showRewardPopup && - !_isPlayerAttacking && - !_isEnemyAttacking, // Enabled even if disarmed (damage reduced) - isDefendEnabled: - battleProvider.isPlayerTurn && - !battleProvider.player.isDead && - !battleProvider.enemy.isDead && - !battleProvider.showRewardPopup && - !_isPlayerAttacking && - !_isEnemyAttacking && - !battleProvider.player.hasStatus( - StatusEffectType.defenseForbidden, - ), // Disable if defense is forbidden - onAttackPressed: () => - _showRiskLevelSelection(context, ActionType.attack), - onDefendPressed: () => - _showRiskLevelSelection(context, ActionType.defend), - onItemPressed: () => _showInventoryDialog(context), - ), - ), - - if (_showEquipmentSwapPanel && _canUseEquipmentSwap(battleProvider)) - Positioned( - bottom: 20, - right: 96, - width: 260, - child: _buildEquipmentSwapPanel(), - ), - - // 5. Log Toggle Button (Bottom Left) - Positioned( - bottom: 20, - left: 20, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildEquipmentSwapButton(battleProvider), - const SizedBox(height: 12), - FloatingActionButton( - heroTag: "logToggle", - mini: true, - backgroundColor: ThemeConfig.toggleBtnBg, - onPressed: () { - setState(() { - _showLogs = !_showLogs; - }); - }, - child: Icon( - _showLogs ? Icons.visibility_off : Icons.visibility, - color: ThemeConfig.textColorWhite, - ), - ), - ], - ), - ), - - // Reward Popup - if (battleProvider.showRewardPopup) - Container( - color: ThemeConfig.cardBgColor, - child: Center( - child: SimpleDialog( - title: Row( - children: [ - const Text( - "${AppStrings.victory} ${AppStrings.chooseReward}", - ), - const Spacer(), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.monetization_on, - color: ThemeConfig.statGoldColor, - size: ThemeConfig.itemIconSizeSmall, - ), - const SizedBox(width: 4), - Text( - "${battleProvider.lastGoldReward} G", - style: TextStyle( - color: ThemeConfig.statGoldColor, - fontSize: ThemeConfig.fontSizeBody, - fontWeight: ThemeConfig.fontWeightBold, - ), - ), - ], - ), - ], - ), - children: battleProvider.rewardOptions.map((item) { - bool isSkip = item.id == "reward_skip"; - return SimpleDialogOption( - onPressed: _isCompletingReward - ? null - : () => _selectRewardAfterAnimationsIfNeeded(item), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (!isSkip) - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: ThemeConfig.rewardItemBg, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: item.rarity != ItemRarity.magic - ? ItemUtils.getRarityColor( - item.rarity, - ) - : ThemeConfig.rarityCommon, - ), - ), - child: Image.asset( - ItemUtils.getIconPath(item.slot), - width: ThemeConfig.itemIconSizeMedium, - height: ThemeConfig.itemIconSizeMedium, - fit: BoxFit.contain, - filterQuality: FilterQuality.high, - ), - ), - if (!isSkip) const SizedBox(width: 12), - Text( - item.name, - style: TextStyle( - fontWeight: ThemeConfig.fontWeightBold, - fontSize: ThemeConfig.fontSizeLarge, - color: isSkip - ? ThemeConfig.textColorGrey - : ItemUtils.getRarityColor(item.rarity), - ), - ), - ], - ), - if (!isSkip) _buildItemStatText(item), - Text( - item.description, - style: const TextStyle( - fontSize: ThemeConfig.fontSizeMedium, - color: ThemeConfig.textColorGrey, - ), - ), - ], - ), - ); - }).toList(), - ), - ), - ), - - // Floating Effects - ..._floatingDamageTexts.map((e) => e.widget), - ..._floatingEffects.map((e) => e.widget), - ..._floatingFeedbackTexts.map((e) => e.widget), - - // Explosion Layer - ExplosionWidget(key: _explosionKey), - - // Game Over Overlay - if (battleProvider.player.isDead) - Container( - color: ThemeConfig.battleBg, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - AppStrings.defeat, - style: TextStyle( - color: ThemeConfig.statHpColor, - fontSize: ThemeConfig.fontSizeHuge, - fontWeight: ThemeConfig.fontWeightBold, - letterSpacing: ThemeConfig.letterSpacingHeader, - ), - ), - const SizedBox(height: 32), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: ThemeConfig.menuButtonBg, - padding: const EdgeInsets.symmetric( - horizontal: ThemeConfig.paddingBtnHorizontal, - vertical: ThemeConfig.paddingBtnVertical, - ), - ), - onPressed: () { - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (context) => const MainMenuScreen(), - ), - (route) => false, - ); - }, - child: const Text( - AppStrings.returnToMenu, - style: TextStyle( - color: ThemeConfig.textColorWhite, - fontSize: ThemeConfig.fontSizeHeader, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildItemStatText(Item item) { - List stats = []; - 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}"); - if (item.dodge > 0) stats.add("+${item.dodge}% Dodge"); // Add Dodge - - List effectTexts = item.effects.map((e) => e.description).toList(); - - if (stats.isEmpty && effectTexts.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Stack( children: [ - if (stats.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4.0, bottom: 4.0), - child: Text( - stats.join(", "), - style: const TextStyle( - fontSize: ThemeConfig.fontSizeMedium, - color: ThemeConfig.statAtkColor, + Column( + children: [ + const BattleHeader(), + Expanded( + child: BattleArena( + battleProvider: battleProvider, + playerKey: playerKey, + enemyKey: enemyKey, + playerAnimKey: playerAnimKey, + enemyAnimKey: enemyAnimKey, + shakeKey: shakeKey, + stackKey: stackKey, + isPlayerAttacking: isPlayerAttacking, + isEnemyAttacking: isEnemyAttacking, + playerOverrideImage: _getOverrideImage(true), ), ), - ), - if (effectTexts.isNotEmpty) - Padding( - 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 - ), - ), + ], + ), + BattleBottomSection( + battleProvider: battleProvider, + showLogs: _showLogs, + isPlayerAttacking: isPlayerAttacking, + isEnemyAttacking: isEnemyAttacking, + onToggleLogs: () => setState(() => _showLogs = !_showLogs), + onAttackPressed: () => + _showRiskLevelSelection(context, ActionType.attack), + onDefendPressed: () => + _showRiskLevelSelection(context, ActionType.defend), + onItemPressed: () => _showInventoryDialog(context), + equipmentSwapButton: _buildEquipmentSwapButton(battleProvider), + equipmentSwapPanel: + _showEquipmentSwapPanel && _canUseEquipmentSwap(battleProvider) + ? _buildEquipmentSwapPanel() + : null, + ), + + // Reward Popup + if (battleProvider.showRewardPopup) + BattleRewardOverlay(battleProvider: battleProvider), + + // Floating Effects + ...floatingDamageTexts.map((e) => e.widget), + ...floatingEffects.map((e) => e.widget), + ...floatingFeedbackTexts.map((e) => e.widget), + + // Explosion Layer + ExplosionWidget(key: explosionKey), + + // Game Over Overlay + if (battleProvider.player.isDead) const BattleDefeatOverlay(), ], ); } diff --git a/lib/screens/battle_visual_handler.dart b/lib/screens/battle_visual_handler.dart new file mode 100644 index 0000000..000ea9d --- /dev/null +++ b/lib/screens/battle_visual_handler.dart @@ -0,0 +1,432 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../game/enums.dart'; +import '../game/models.dart'; +import '../game/config.dart'; +import '../providers/battle_provider.dart'; +import '../providers/settings_provider.dart'; +import '../widgets/battle/effect_sprite_widget.dart'; +import '../widgets/battle/floating_battle_texts.dart'; +import '../widgets/battle/battle_animation_widget.dart'; +import '../widgets/battle/shake_widget.dart'; +import '../widgets/battle/explosion_widget.dart'; + +enum AnimationPhase { none, start, middle, end, hurt, block } + +mixin BattleVisualHandler on State { + // State variables for animations + final List floatingDamageTexts = []; + final List floatingEffects = []; + final List floatingFeedbackTexts = []; + + final GlobalKey playerKey = GlobalKey(); + final GlobalKey enemyKey = GlobalKey(); + final GlobalKey stackKey = GlobalKey(); + final GlobalKey shakeKey = GlobalKey(); + final GlobalKey playerAnimKey = GlobalKey(); + final GlobalKey enemyAnimKey = GlobalKey(); + final GlobalKey explosionKey = GlobalKey(); + final GlobalKey effectSpriteKey = GlobalKey(); + final GlobalKey rootStackKey = GlobalKey(); + + AnimationPhase playerAnimPhase = AnimationPhase.none; + RiskLevel? activeRiskLevel; + bool isAttackSuccess = true; + bool isPlayerAttacking = false; + bool isEnemyAttacking = false; + + StreamSubscription? damageSubscription; + StreamSubscription? effectSubscription; + StreamSubscription? healSubscription; + + DateTime? lastFeedbackTime; + + void setupVisualListeners(BattleProvider battleProvider) { + damageSubscription = battleProvider.damageStream.listen(_onDamageEvent); + effectSubscription = battleProvider.effectStream.listen(onEffectEvent); + healSubscription = battleProvider.healStream.listen(onHealEvent); + } + + void disposeVisualListeners() { + damageSubscription?.cancel(); + effectSubscription?.cancel(); + healSubscription?.cancel(); + } + + // --- Animation Core Logic --- + + void _onDamageEvent(DamageEvent event) { + if (!mounted) return; + + if (event.target == DamageTarget.player) { + if (event.armorDamage > 0) { + _triggerPlayerBlock(); + } else if (event.damage > 0) { + _triggerPlayerHurt(); + } + } + + addFloatingDamageText(event); + } + + void _triggerPlayerHurt() { + if (playerAnimPhase != AnimationPhase.none && + playerAnimPhase != AnimationPhase.hurt) { + return; + } + setState(() => playerAnimPhase = AnimationPhase.hurt); + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted && playerAnimPhase == AnimationPhase.hurt) { + setState(() => playerAnimPhase = AnimationPhase.none); + } + }); + } + + void _triggerPlayerBlock() { + if (playerAnimPhase != AnimationPhase.none && + playerAnimPhase != AnimationPhase.block) { + return; + } + setState(() => playerAnimPhase = AnimationPhase.block); + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted && playerAnimPhase == AnimationPhase.block) { + setState(() => playerAnimPhase = AnimationPhase.none); + } + }); + } + + void onHealEvent(HealEvent event) { + if (!mounted) return; + + RenderBox? rootBox = + rootStackKey.currentContext?.findRenderObject() as RenderBox?; + if (rootBox == null) return; + Offset rootOffset = rootBox.localToGlobal(Offset.zero); + + Offset position = Offset(rootBox.size.width / 2, rootBox.size.height / 2); + + if (event.target == HealTarget.player && playerAnimKey.currentContext != null) { + RenderBox? imageBox = + playerAnimKey.currentContext!.findRenderObject() as RenderBox?; + if (imageBox != null) { + Offset globalCenter = imageBox.localToGlobal( + Offset(imageBox.size.width / 2, imageBox.size.height / 2), + ); + position = globalCenter - rootOffset; + } + } + + effectSpriteKey.currentState?.playEffect( + position: position, + assetPath: 'assets/images/effects/heal.png', + frameCount: 4, + tileWidth: 100.0, + tileHeight: 100.0, + scale: 6.0, + ); + + final String id = UniqueKey().toString(); + setState(() { + floatingDamageTexts.add( + DamageTextData( + id: id, + widget: Positioned( + key: ValueKey('pos_$id'), + left: position.dx + BattleConfig.damageTextOffsetX, + top: position.dy + BattleConfig.damageTextOffsetY, + child: FloatingDamageText( + key: ValueKey(id), + damage: "+${event.amount}", + color: ThemeConfig.statHpPlayerColor, + onRemove: () { + if (mounted) { + setState(() => floatingDamageTexts.removeWhere((e) => e.id == id)); + } + }, + ), + ), + ), + ); + }); + } + + void onEffectEvent(EffectEvent event) { + if (!mounted) return; + + void showEffect() { + if (event.feedbackType != null) { + addFloatingFeedbackText(event); + return; + } + + IconData icon = BattleConfig.getIcon(event.type); + Color color = BattleConfig.getColor(event.type, event.risk); + double size = BattleConfig.getSize(event.risk); + addFloatingEffect(event, icon, color, size); + } + + if (event.isVisualOnly) { + showEffect(); + context.read().handleImpact(event); + } else if (event.type == ActionType.attack && event.target == EffectTarget.enemy) { + final RenderBox? pBox = playerKey.currentContext?.findRenderObject() as RenderBox?; + final RenderBox? eBox = enemyKey.currentContext?.findRenderObject() as RenderBox?; + + if (pBox != null && eBox != null) { + final offset = eBox.localToGlobal(Offset.zero) - pBox.localToGlobal(Offset.zero); + + setState(() { + isPlayerAttacking = true; + activeRiskLevel = event.risk; + isAttackSuccess = event.isSuccess == true && event.feedbackType == null; + }); + + final animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; + + playerAnimKey.currentState?.animateAttack( + offset, + () { + showEffect(); + context.read().handleImpact(event); + if (event.risk == RiskLevel.risky && event.feedbackType == null) { + shakeKey.currentState?.shake(); + RenderBox? sBox = stackKey.currentContext?.findRenderObject() as RenderBox?; + if (sBox != null) { + Offset localEnemyPos = sBox.globalToLocal(eBox.localToGlobal(Offset.zero)) + + Offset(eBox.size.width / 2, eBox.size.height / 2); + explosionKey.currentState?.explode(localEnemyPos); + } + } + }, + animRisk, + onAnimationStart: () => setState(() => playerAnimPhase = AnimationPhase.start), + onAnimationMiddle: () => setState(() => playerAnimPhase = AnimationPhase.middle), + onAnimationEnd: () => setState(() => playerAnimPhase = AnimationPhase.end), + ).then((_) { + if (mounted) { + setState(() { + isPlayerAttacking = false; + playerAnimPhase = AnimationPhase.none; + activeRiskLevel = null; + isAttackSuccess = true; + }); + } + }); + } + } else if (event.type == ActionType.attack && event.target == EffectTarget.player) { + bool enableAnim = context.read().enableEnemyAnimations; + if (!enableAnim) { + showEffect(); + context.read().handleImpact(event); + return; + } + + final RenderBox? pBox = playerKey.currentContext?.findRenderObject() as RenderBox?; + final RenderBox? eBox = enemyKey.currentContext?.findRenderObject() as RenderBox?; + + if (pBox != null && eBox != null) { + final offset = pBox.localToGlobal(Offset.zero) - eBox.localToGlobal(Offset.zero); + setState(() => isEnemyAttacking = true); + + final animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; + + enemyAnimKey.currentState?.animateAttack(offset, () { + showEffect(); + context.read().handleImpact(event); + if (event.risk == RiskLevel.risky && event.feedbackType == null) { + shakeKey.currentState?.shake(); + RenderBox? sBox = stackKey.currentContext?.findRenderObject() as RenderBox?; + if (sBox != null) { + Offset localPlayerPos = sBox.globalToLocal(pBox.localToGlobal(Offset.zero)) + + Offset(pBox.size.width / 2, pBox.size.height / 2); + explosionKey.currentState?.explode(localPlayerPos); + } + } + }, animRisk).then((_) { + if (mounted) setState(() => isEnemyAttacking = false); + }); + } + } else if (event.type == ActionType.defend) { + if (event.target == EffectTarget.player) { + setState(() => isPlayerAttacking = true); + playerAnimKey.currentState?.animateDefense(() { + showEffect(); + context.read().handleImpact(event); + }).then((_) { + if (mounted) setState(() => isPlayerAttacking = false); + }); + } else { + showEffect(); + context.read().handleImpact(event); + } + } else { + showEffect(); + context.read().handleImpact(event); + } + } + + void addFloatingDamageText(DamageEvent event) { + if (!mounted) return; + final String id = UniqueKey().toString(); + final targetKey = event.target == DamageTarget.player ? playerKey : enemyKey; + final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + + Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height / 3)); + RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?; + if (rootBox != null) position = rootBox.globalToLocal(position); + + setState(() { + floatingDamageTexts.add( + DamageTextData( + id: id, + widget: Positioned( + key: ValueKey('pos_$id'), + 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)); + } + }, + ), + ), + ), + ); + }); + } + + void addFloatingEffect(EffectEvent event, IconData icon, Color color, double size) { + if (!mounted) return; + final String id = UniqueKey().toString(); + final targetKey = event.target == EffectTarget.player ? playerKey : enemyKey; + final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + + Offset position = box.localToGlobal(Offset.zero); + RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?; + if (rootBox != null) { + position = rootBox.globalToLocal(position); + } + + double offsetX = 0; + double offsetY = 0; + if (event.target == EffectTarget.enemy) { + offsetX = box.size.width * BattleConfig.effectEnemyOffsetX; + offsetY = box.size.height * BattleConfig.effectEnemyOffsetY; + } else { + offsetX = box.size.width * BattleConfig.effectPlayerOffsetX; + offsetY = box.size.height * BattleConfig.effectPlayerOffsetY; + } + position = position + Offset(offsetX, offsetY); + + setState(() { + floatingEffects.add( + FloatingEffectData( + id: id, + widget: Positioned( + key: ValueKey('pos_$id'), + 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)); + } + }, + ), + ), + ), + ); + }); + } + + void addFloatingFeedbackText(EffectEvent event) { + if (!mounted || event.feedbackType == null) return; + + final now = DateTime.now(); + if (lastFeedbackTime != null && now.difference(lastFeedbackTime!) < const Duration(milliseconds: 300)) { + return; + } + lastFeedbackTime = now; + + final String id = UniqueKey().toString(); + final targetKey = event.target == EffectTarget.player ? playerKey : enemyKey; + final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + + Offset position = box.localToGlobal(Offset.zero); + RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?; + if (rootBox != null) { + position = rootBox.globalToLocal(position); + } + + double offsetX = 0; + double offsetY = 0; + if (event.target == EffectTarget.enemy) { + offsetX = box.size.width * BattleConfig.effectEnemyOffsetX; + offsetY = box.size.height * BattleConfig.effectEnemyOffsetY; + } else { + offsetX = box.size.width * BattleConfig.effectPlayerOffsetX; + offsetY = box.size.height * BattleConfig.effectPlayerOffsetY; + } + position = position + Offset(offsetX, offsetY); + + final feedbackText = BattleConfig.getFeedbackText(event.feedbackType!); + final feedbackColor = BattleConfig.getFeedbackColor(event.feedbackType!); + + setState(() { + floatingFeedbackTexts.add( + FeedbackTextData( + id: id, + eventId: event.id, + widget: Positioned( + key: ValueKey('pos_$id'), + 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)); + } + }, + ), + ), + ), + ); + }); + } + + bool get hasPendingBattleAnimations { + return isPlayerAttacking || + isEnemyAttacking || + floatingDamageTexts.isNotEmpty || + floatingEffects.isNotEmpty || + floatingFeedbackTexts.isNotEmpty || + (explosionKey.currentState?.isAnimating ?? false); + } + + Future waitForBattleAnimationsToSettle() async { + final deadline = DateTime.now().add( + AnimationConfig.attackRiskyTotal + + AnimationConfig.floatingTextDuration + + const Duration(milliseconds: 400), + ); + + while (mounted && hasPendingBattleAnimations && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + } +} diff --git a/lib/widgets/battle.dart b/lib/widgets/battle.dart index 38e027c..dcac994 100644 --- a/lib/widgets/battle.dart +++ b/lib/widgets/battle.dart @@ -1,3 +1,5 @@ +export 'battle/battle_bottom_section.dart'; +export 'battle/battle_arena.dart'; export 'battle/battle_animation_widget.dart'; export 'battle/battle_controls.dart'; export 'battle/battle_header.dart'; @@ -7,3 +9,5 @@ export 'battle/explosion_widget.dart'; export 'battle/floating_battle_texts.dart'; export 'battle/risk_selection_dialog.dart'; export 'battle/shake_widget.dart'; +export 'battle/battle_overlays.dart'; +export 'battle/effect_sprite_widget.dart'; diff --git a/lib/widgets/battle/battle_arena.dart b/lib/widgets/battle/battle_arena.dart new file mode 100644 index 0000000..1228fef --- /dev/null +++ b/lib/widgets/battle/battle_arena.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import '../../providers/battle_provider.dart'; +import 'character_status_card.dart'; +import 'shake_widget.dart'; +import 'battle_animation_widget.dart'; + +class BattleArena extends StatelessWidget { + final BattleProvider battleProvider; + final GlobalKey playerKey; + final GlobalKey enemyKey; + final GlobalKey playerAnimKey; + final GlobalKey enemyAnimKey; + final GlobalKey shakeKey; + final GlobalKey stackKey; + final bool isPlayerAttacking; + final bool isEnemyAttacking; + final String? playerOverrideImage; + final String? enemyOverrideImage; + + const BattleArena({ + super.key, + required this.battleProvider, + required this.playerKey, + required this.enemyKey, + required this.playerAnimKey, + required this.enemyAnimKey, + required this.shakeKey, + required this.stackKey, + required this.isPlayerAttacking, + required this.isEnemyAttacking, + this.playerOverrideImage, + this.enemyOverrideImage, + }); + + @override + Widget build(BuildContext context) { + return ShakeWidget( + key: shakeKey, + child: Stack( + key: stackKey, + children: [ + // 1. Background Image + Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/background/tier_1.jpg'), + fit: BoxFit.cover, + ), + ), + ), + // 1.1 Opacity Layer + Container(color: Colors.black.withValues(alpha: 0.7)), + + // 2. Character Area + Padding( + padding: const EdgeInsets.all(16.0), + child: Stack( + children: [ + // Player (Bottom Left) + Positioned( + bottom: 80, + left: 16, + child: CharacterStatusCard( + character: battleProvider.player, + isPlayer: true, + isTurn: battleProvider.isPlayerTurn, + key: playerKey, + animationKey: playerAnimKey, + hideStats: isPlayerAttacking, + overrideImage: playerOverrideImage, + ), + ), + // Enemy (Top Right) + Positioned( + top: 16, + right: 16, + child: CharacterStatusCard( + character: battleProvider.enemy, + isPlayer: false, + isTurn: !battleProvider.isPlayerTurn, + key: enemyKey, + animationKey: enemyAnimKey, + hideStats: isEnemyAttacking, + overrideImage: enemyOverrideImage, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/battle/battle_bottom_section.dart b/lib/widgets/battle/battle_bottom_section.dart new file mode 100644 index 0000000..5fe4123 --- /dev/null +++ b/lib/widgets/battle/battle_bottom_section.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import '../../game/enums.dart'; +import '../../game/config.dart'; +import '../../providers/battle_provider.dart'; +import 'battle_controls.dart'; +import 'battle_log_overlay.dart'; + +class BattleBottomSection extends StatelessWidget { + final BattleProvider battleProvider; + final bool showLogs; + final bool isPlayerAttacking; + final bool isEnemyAttacking; + final VoidCallback onToggleLogs; + final VoidCallback onAttackPressed; + final VoidCallback onDefendPressed; + final VoidCallback onItemPressed; + + // Custom buttons/panels passed from parent to keep their logic there for now + final Widget equipmentSwapButton; + final Widget? equipmentSwapPanel; + + const BattleBottomSection({ + super.key, + required this.battleProvider, + required this.showLogs, + required this.isPlayerAttacking, + required this.isEnemyAttacking, + required this.onToggleLogs, + required this.onAttackPressed, + required this.onDefendPressed, + required this.onItemPressed, + required this.equipmentSwapButton, + this.equipmentSwapPanel, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // 1. Logs Overlay + if (showLogs && battleProvider.logs.isNotEmpty) + Positioned( + top: 60, + left: 16, + right: 16, + height: BattleConfig.logsOverlayHeight, + child: BattleLogOverlay(logs: battleProvider.logs), + ), + + // 2. Battle Controls (Bottom Right) + Positioned( + bottom: 20, + right: 20, + child: BattleControls( + isAttackEnabled: battleProvider.isPlayerTurn && + !battleProvider.player.isDead && + !battleProvider.enemy.isDead && + !battleProvider.showRewardPopup && + !isPlayerAttacking && + !isEnemyAttacking, + isDefendEnabled: battleProvider.isPlayerTurn && + !battleProvider.player.isDead && + !battleProvider.enemy.isDead && + !battleProvider.showRewardPopup && + !isPlayerAttacking && + !isEnemyAttacking && + !battleProvider.player.hasStatus( + StatusEffectType.defenseForbidden, + ), + onAttackPressed: onAttackPressed, + onDefendPressed: onDefendPressed, + onItemPressed: onItemPressed, + ), + ), + + // 3. Equipment Swap Panel + if (equipmentSwapPanel != null) + Positioned( + bottom: 20, + right: 96, + width: 260, + child: equipmentSwapPanel!, + ), + + // 4. Log Toggle & Swap Button (Bottom Left) + Positioned( + bottom: 20, + left: 20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + equipmentSwapButton, + const SizedBox(height: 12), + FloatingActionButton( + heroTag: "logToggle", + mini: true, + backgroundColor: ThemeConfig.toggleBtnBg, + onPressed: onToggleLogs, + child: Icon( + showLogs ? Icons.visibility_off : Icons.visibility, + color: ThemeConfig.textColorWhite, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/battle/battle_overlays.dart b/lib/widgets/battle/battle_overlays.dart new file mode 100644 index 0000000..0523b23 --- /dev/null +++ b/lib/widgets/battle/battle_overlays.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import '../../game/enums.dart'; +import '../../game/models.dart'; +import '../../game/config.dart'; +import '../../providers/battle_provider.dart'; +import '../../utils/item_utils.dart'; +import '../inventory/item_stat_widget.dart'; +import '../test/sprite_animation_widget.dart'; +import '../../screens/main_menu_screen.dart'; + +class BattleRewardOverlay extends StatefulWidget { + final BattleProvider battleProvider; + + const BattleRewardOverlay({super.key, required this.battleProvider}); + + @override + State createState() => _BattleRewardOverlayState(); +} + +class _BattleRewardOverlayState extends State { + bool _isCompletingReward = false; + + Future _selectReward(Item item) async { + if (_isCompletingReward) return; + setState(() => _isCompletingReward = true); + + widget.battleProvider.selectReward(item); + + if (mounted) { + setState(() => _isCompletingReward = false); + } + } + + @override + Widget build(BuildContext context) { + final battleProvider = widget.battleProvider; + return Container( + color: ThemeConfig.cardBgColor, + child: Center( + child: SimpleDialog( + title: Row( + children: [ + const Text( + "${AppStrings.victory} ${AppStrings.chooseReward}", + ), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.monetization_on, + color: ThemeConfig.statGoldColor, + size: ThemeConfig.itemIconSizeSmall, + ), + const SizedBox(width: 4), + Text( + "${battleProvider.lastGoldReward} G", + style: TextStyle( + color: ThemeConfig.statGoldColor, + fontSize: ThemeConfig.fontSizeBody, + fontWeight: ThemeConfig.fontWeightBold, + ), + ), + ], + ), + ], + ), + children: battleProvider.rewardOptions.map((item) { + bool isSkip = item.id == "reward_skip"; + return SimpleDialogOption( + onPressed: _isCompletingReward ? null : () => _selectReward(item), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (!isSkip) + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: ThemeConfig.rewardItemBg, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: item.rarity != ItemRarity.magic + ? ItemUtils.getRarityColor(item.rarity) + : ThemeConfig.rarityCommon, + ), + ), + child: Image.asset( + ItemUtils.getIconPath(item.slot), + width: ThemeConfig.itemIconSizeMedium, + height: ThemeConfig.itemIconSizeMedium, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + if (!isSkip) const SizedBox(width: 12), + Text( + item.name, + style: TextStyle( + fontWeight: ThemeConfig.fontWeightBold, + fontSize: ThemeConfig.fontSizeLarge, + color: isSkip + ? ThemeConfig.textColorGrey + : ItemUtils.getRarityColor(item.rarity), + ), + ), + ], + ), + if (!isSkip) ItemStatWidget(item: item), + Text( + item.description, + style: const TextStyle( + fontSize: ThemeConfig.fontSizeMedium, + color: ThemeConfig.textColorGrey, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ); + } +} + +class BattleDefeatOverlay extends StatelessWidget { + const BattleDefeatOverlay({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: ThemeConfig.battleBg, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SpriteAnimationWidget( + assetPath: 'assets/images/character/Knight-Death.png', + frameCount: 4, + scale: 4.0, + loop: false, + customDuration: Duration(milliseconds: 1500), + ), + const SizedBox(height: 16), + const Text( + AppStrings.defeat, + style: TextStyle( + color: ThemeConfig.statHpColor, + fontSize: ThemeConfig.fontSizeHuge, + fontWeight: ThemeConfig.fontWeightBold, + letterSpacing: ThemeConfig.letterSpacingHeader, + ), + ), + const SizedBox(height: 32), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ThemeConfig.menuButtonBg, + padding: const EdgeInsets.symmetric( + horizontal: ThemeConfig.paddingBtnHorizontal, + vertical: ThemeConfig.paddingBtnVertical, + ), + ), + onPressed: () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => const MainMenuScreen(), + ), + (route) => false, + ); + }, + child: const Text( + AppStrings.returnToMenu, + style: TextStyle( + color: ThemeConfig.textColorWhite, + fontSize: ThemeConfig.fontSizeHeader, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/battle/character_status_card.dart b/lib/widgets/battle/character_status_card.dart index e61d9b1..d556dca 100644 --- a/lib/widgets/battle/character_status_card.dart +++ b/lib/widgets/battle/character_status_card.dart @@ -129,7 +129,28 @@ class CharacterStatusCard extends StatelessWidget { // assetPath: 'assets/images/character/Soldier.png', assetPath: overrideImage ?? character.image!, scale: 5.0, // Zoomed in (300x300 in 200x200 box) - frameCount: 6, + frameCount: (overrideImage != null && + (overrideImage!.contains("Knight-Hurt") || + overrideImage!.contains("Knight-Death") || + overrideImage!.contains("Knight-Block"))) + ? 4 + : (overrideImage != null && + overrideImage!.contains("Knight-Attack01")) + ? 7 + : (overrideImage != null && + overrideImage! + .contains("Knight-Attack02")) + ? 10 + : (overrideImage != null && + overrideImage! + .contains("Knight-Attack03")) + ? 11 + : 6, + loop: !(overrideImage != null && + (overrideImage!.contains("Knight-Hurt") || + overrideImage!.contains("Knight-Death") || + overrideImage!.contains("Knight-Block") || + overrideImage!.contains("Knight-Attack"))), flip: !isPlayer, fallbackAssetPath: !isPlayer ? 'assets/images/enemies/Orc.png' diff --git a/lib/widgets/inventory.dart b/lib/widgets/inventory.dart index ba42846..4549bc1 100644 --- a/lib/widgets/inventory.dart +++ b/lib/widgets/inventory.dart @@ -1,3 +1,4 @@ export 'inventory/character_stats_widget.dart'; export 'inventory/inventory_grid_widget.dart'; export 'inventory/equipped_items_widget.dart'; +export 'inventory/item_stat_widget.dart'; diff --git a/lib/widgets/inventory/item_stat_widget.dart b/lib/widgets/inventory/item_stat_widget.dart new file mode 100644 index 0000000..73f7b67 --- /dev/null +++ b/lib/widgets/inventory/item_stat_widget.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import '../../game/models.dart'; +import '../../game/config.dart'; + +class ItemStatWidget extends StatelessWidget { + final Item item; + final double fontSize; + final Color? color; + + const ItemStatWidget({ + super.key, + required this.item, + this.fontSize = ThemeConfig.fontSizeMedium, + this.color, + }); + + @override + Widget build(BuildContext context) { + List stats = []; + 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}"); + if (item.dodge > 0) stats.add("+${item.dodge}% Dodge"); + + List effectTexts = item.effects.map((e) => e.description).toList(); + + if (stats.isEmpty && effectTexts.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (stats.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 4.0), + child: Text( + stats.join(", "), + style: TextStyle( + fontSize: fontSize, + color: color ?? ThemeConfig.statAtkColor, + ), + ), + ), + if (effectTexts.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + effectTexts.join(", "), + style: const TextStyle( + fontSize: 11, + color: ThemeConfig.rarityLegendary, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/test/sprite_animation_widget.dart b/lib/widgets/test/sprite_animation_widget.dart index f944ce7..2918a3f 100644 --- a/lib/widgets/test/sprite_animation_widget.dart +++ b/lib/widgets/test/sprite_animation_widget.dart @@ -10,6 +10,8 @@ class SpriteAnimationWidget extends StatefulWidget { final int frameCount; final double scale; final bool flip; + final bool loop; + final Duration? customDuration; final String? fallbackAssetPath; const SpriteAnimationWidget({ @@ -17,10 +19,11 @@ class SpriteAnimationWidget extends StatefulWidget { required this.assetPath, this.tileWidth = 100.0, this.tileHeight = 100.0, - this.frameCount = - 6, // Default guess, will adjust logic to use actual image width if possible + this.frameCount = 6, this.scale = 1.0, this.flip = false, + this.loop = true, + this.customDuration, this.fallbackAssetPath, }); @@ -40,11 +43,22 @@ class _SpriteAnimationWidgetState extends State super.initState(); _controller = AnimationController( vsync: this, - duration: const Duration(milliseconds: 600), // 100ms per frame approx + duration: widget.customDuration ?? const Duration(milliseconds: 600), ); _loadImage(); } + @override + void didUpdateWidget(covariant SpriteAnimationWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetPath != widget.assetPath) { + // Don't set _isLoading = true to avoid flickering. + // Keep showing the old image until the new one is loaded. + _controller.reset(); + _loadImage(); + } + } + Future _loadImage() async { try { await _loadAsset(widget.assetPath); @@ -82,11 +96,18 @@ class _SpriteAnimationWidgetState extends State ? maxFrames : widget.frameCount; - // Adjust duration based on frame count - _controller.duration = Duration( - milliseconds: _calculatedFrameCount * 100, - ); - _controller.repeat(); + // Adjust duration based on frame count if not custom + if (widget.customDuration == null) { + _controller.duration = Duration( + milliseconds: _calculatedFrameCount * 100, + ); + } + + if (widget.loop) { + _controller.repeat(); + } else { + _controller.forward(); + } }); } } @@ -99,17 +120,26 @@ class _SpriteAnimationWidgetState extends State @override Widget build(BuildContext context) { - if (_isLoading || _image == null) { + if (_image == null) { return SizedBox( width: widget.tileWidth * widget.scale, height: widget.tileHeight * widget.scale, - child: const Center(child: CircularProgressIndicator()), ); } return AnimatedBuilder( animation: _controller, builder: (context, child) { + int frame; + if (!widget.loop) { + frame = (_controller.value * _calculatedFrameCount) + .floor() + .clamp(0, _calculatedFrameCount - 1); + } else { + frame = (_controller.value * _calculatedFrameCount).floor() % + _calculatedFrameCount; + } + return Transform.scale( scaleX: widget.flip ? -1.0 : 1.0, alignment: Alignment.center, @@ -120,9 +150,7 @@ class _SpriteAnimationWidgetState extends State ), painter: SpriteSheetPainter( image: _image!, - currentFrame: - (_controller.value * _calculatedFrameCount).floor() % - _calculatedFrameCount, + currentFrame: frame, tileWidth: widget.tileWidth, tileHeight: widget.tileHeight, scale: widget.scale, diff --git a/test/consumable_test.dart b/test/consumable_test.dart new file mode 100644 index 0000000..bde54af --- /dev/null +++ b/test/consumable_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_test/game/enums.dart'; +import 'package:game_test/game/models.dart'; +import 'package:game_test/providers/battle_provider.dart'; +import 'package:game_test/providers/shop_provider.dart'; + +void main() { + late BattleProvider battleProvider; + + setUp(() { + battleProvider = BattleProvider(shopProvider: ShopProvider()); + battleProvider.player = Character( + name: 'Warrior', + hp: 50, + maxHp: 100, + armor: 0, + atk: 10, + baseDefense: 5, + ); + }); + + test('healing potion restores HP and is consumed', () async { + final potion = Item( + id: 'potion_heal_small', + name: 'Healing Potion', + description: 'Restores HP.', + atkBonus: 0, + hpBonus: 20, + slot: EquipmentSlot.consumable, + ); + final healEvents = []; + final subscription = battleProvider.healStream.listen(healEvents.add); + battleProvider.player.addToInventory(potion); + + battleProvider.useConsumable(potion); + await Future.delayed(Duration.zero); + + expect(battleProvider.player.hp, 70); + expect(battleProvider.player.inventory, isNot(contains(potion))); + expect(healEvents.single.amount, 20); + + await subscription.cancel(); + }); + + test('armor potion grants armor and is consumed', () { + final potion = Item( + id: 'potion_armor_small', + name: 'Iron Skin Potion', + description: 'Grants armor.', + atkBonus: 0, + hpBonus: 0, + armorBonus: 10, + slot: EquipmentSlot.consumable, + ); + battleProvider.player.addToInventory(potion); + + battleProvider.useConsumable(potion); + + expect(battleProvider.player.armor, 10); + expect(battleProvider.player.inventory, isNot(contains(potion))); + }); + + test('strength potion applies attack buff and is consumed', () { + final potion = Item( + id: 'potion_strength_small', + name: 'Strength Potion', + description: 'Increases attack.', + atkBonus: 0, + hpBonus: 0, + slot: EquipmentSlot.consumable, + effects: [ + ItemEffect( + type: StatusEffectType.attackUp, + probability: 100, + duration: 1, + value: 5, + ), + ], + ); + battleProvider.player.addToInventory(potion); + + battleProvider.useConsumable(potion); + + expect(battleProvider.player.hasStatus(StatusEffectType.attackUp), isTrue); + expect(battleProvider.player.totalAtk, 15); + expect(battleProvider.player.inventory, isNot(contains(potion))); + }); +} diff --git a/test/effect_event_factory_test.dart b/test/effect_event_factory_test.dart new file mode 100644 index 0000000..baa96bb --- /dev/null +++ b/test/effect_event_factory_test.dart @@ -0,0 +1,26 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_test/game/enums.dart'; +import 'package:game_test/game/logic/effect_event_factory.dart'; +import 'package:game_test/game/models.dart'; + +void main() { + test('enemy attack can target a named player character', () { + final enemy = Character(name: 'Scrawny Piso', maxHp: 20, armor: 0, atk: 6); + final player = Character(name: 'Warrior', maxHp: 50, armor: 0, atk: 5); + + final event = EffectEventFactory.createAttackEvent( + attacker: enemy, + target: player, + effectTarget: EffectTarget.player, + risk: RiskLevel.safe, + damage: 6, + random: Random(0), + ); + + expect(event.target, EffectTarget.player); + expect(event.attacker, enemy); + expect(event.targetEntity, player); + }); +}