import 'dart:async'; // StreamController 사용을 위해 import import 'dart:math'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; // For context.read in _prepareNextStage import '../game/model/entity.dart'; import '../game/model/item.dart'; import '../game/model/status_effect.dart'; import '../game/model/stage.dart'; import '../game/data/item_table.dart'; import '../game/data/enemy_table.dart'; import '../game/data/player_table.dart'; import '../utils/game_math.dart'; import '../game/enums.dart'; import '../game/model/damage_event.dart'; // DamageEvent import import '../game/model/effect_event.dart'; // EffectEvent import import '../game/save_manager.dart'; import '../game/config/game_config.dart'; import '../game/config/battle_config.dart'; // Import BattleConfig import 'shop_provider.dart'; // Import ShopProvider import '../game/logic/battle_log_manager.dart'; import '../game/logic/combat_calculator.dart'; class EnemyIntent { final EnemyActionType type; final int value; final RiskLevel risk; final String description; final bool isSuccess; final int finalValue; bool isApplied; // New field to track if effect (like defense) is already applied EnemyIntent({ required this.type, required this.value, required this.risk, required this.description, required this.isSuccess, required this.finalValue, this.isApplied = false, }); } class BattleProvider with ChangeNotifier { late Character player; late Character enemy; // Kept for compatibility, active during Battle/Elite late StageModel currentStage; // The current stage object EnemyIntent? currentEnemyIntent; final BattleLogManager _logManager = BattleLogManager(); bool isPlayerTurn = true; int stage = 1; int turnCount = 1; List rewardOptions = []; bool showRewardPopup = false; int _lastGoldReward = 0; // New: Stores gold gained from last victory List get logs => _logManager.logs; int get lastGoldReward => _lastGoldReward; void refreshUI() { notifyListeners(); } // Damage Event Stream final _damageEventController = StreamController.broadcast(); Stream get damageStream => _damageEventController.stream; // Effect Event Stream final _effectEventController = StreamController.broadcast(); Stream get effectStream => _effectEventController.stream; // Dependency injection final ShopProvider shopProvider; BattleProvider({required this.shopProvider}) { // initializeBattle(); // Do not auto-start logic } @override void dispose() { _damageEventController.close(); // StreamController 닫기 _effectEventController.close(); super.dispose(); } void loadFromSave(Map data) { stage = data['stage']; turnCount = data['turnCount']; player = Character.fromJson(data['player']); _logManager.clear(); _addLog("Game Loaded! Resuming Stage $stage"); _prepareNextStage(); notifyListeners(); } void initializeBattle() { 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.gold = GameConfig.startingGold; // Provide starter equipment // final starterSword = Item( // id: "starter_sword", // name: "Wooden Sword", // description: "A basic sword", // atkBonus: 5, // hpBonus: 0, // slot: EquipmentSlot.weapon, // ); // final starterArmor = Item( // id: "starter_armor", // name: "Leather Armor", // description: "Basic protection", // atkBonus: 0, // hpBonus: 20, // slot: EquipmentSlot.armor, // ); // final starterShield = Item( // id: "starter_shield", // name: "Wooden Shield", // description: "A small shield", // atkBonus: 0, // hpBonus: 0, // armorBonus: 3, // slot: EquipmentSlot.shield, // ); // final starterRing = Item( // id: "starter_ring", // name: "Copper Ring", // description: "A simple ring", // atkBonus: 1, // hpBonus: 5, // slot: EquipmentSlot.accessory, // ); // player.addToInventory(starterSword); // player.equip(starterSword); // player.addToInventory(starterArmor); // player.equip(starterArmor); // player.addToInventory(starterShield); // player.equip(starterShield); // player.addToInventory(starterRing); // player.equip(starterRing); // Add new status effect items for testing player.addToInventory(ItemTable.weapons[3].createItem()); // Stunning Hammer player.addToInventory(ItemTable.weapons[4].createItem()); // Jagged Dagger player.addToInventory(ItemTable.weapons[5].createItem()); // Sunderer Axe player.addToInventory(ItemTable.shields[3].createItem()); // Cursed Shield _prepareNextStage(); _logManager.clear(); _addLog("Game Started! Stage 1"); notifyListeners(); } void _prepareNextStage() { // Save Game at the start of each stage SaveManager.saveGame(this); StageType type; // Stage Type Logic if (stage % GameConfig.eliteStageInterval == 0) { type = StageType.elite; // Every 10th stage is a Boss/Elite } else if (stage % GameConfig.shopStageInterval == 0) { type = StageType.shop; // Every 5th stage is a Shop (except 10, 20...) } else if (stage % GameConfig.restStageInterval == 0) { type = StageType.rest; // Every 8th stage is a Rest } else { type = StageType.battle; } // Prepare Data based on Type Character? newEnemy; 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(applyImmediate: false); // Generate first intent without applying effects _addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared."); } 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 ); turnCount = 1; notifyListeners(); } // Shop-related methods are now handled by ShopProvider // Replaces _spawnEnemy // void _spawnEnemy() { ... } - Removed 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; // Update Enemy Status Effects at the start of Player's turn (user request) enemy.updateStatusEffects(); // 1. Check for Defense Forbidden status if (type == ActionType.defend && player.hasStatus(StatusEffectType.defenseForbidden)) { _addLog("Cannot defend! You are under Defense Forbidden status."); notifyListeners(); // 상태 변경을 알림 _endPlayerTurn(); return; } isPlayerTurn = false; notifyListeners(); // 2. Process Start-of-Turn Effects (Stun, Bleed) bool canAct = _processStartTurnEffects(player); if (player.isDead) { await _onDefeat(); return; } if (!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( risk: risk, luck: player.totalLuck, baseValue: baseValue, ); if (result.success) { if (type == ActionType.attack) { int damage = result.value; final event = EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(), type: ActionType.attack, risk: risk, target: EffectTarget.enemy, feedbackType: null, attacker: player, targetEntity: enemy, damageValue: damage, isSuccess: true, ); _effectEventController.sink.add( event, ); // No Future.delayed here, BattleScreen will trigger impact } 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); // Process impact via handleImpact for safety } } 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); // Process impact via handleImpact for safety } // Now check for enemy death (if applicable from bleed, or previous impacts) if (enemy.isDead) { // Check enemy death after player's action _onVictory(); return; } _endPlayerTurn(); } void _endPlayerTurn() { // Update durations at end of turn player.updateStatusEffects(); // Check if enemy is dead from bleed if (enemy.isDead) { _onVictory(); return; } Future.delayed( const Duration(milliseconds: GameConfig.animDelayEnemyTurn), () => _enemyTurn(), ); } Future _enemyTurn() async { if (!isPlayerTurn && (player.isDead || enemy.isDead)) return; _addLog("Enemy's turn..."); await Future.delayed( const Duration(milliseconds: GameConfig.animDelayEnemyTurn), ); // Enemy Turn Start Logic // Armor decay if (enemy.armor > 0) { enemy.armor = (enemy.armor * GameConfig.armorDecayRate).toInt(); _addLog("Enemy's armor decayed to ${enemy.armor}."); } // 1. Process Start-of-Turn Effects for Enemy bool canAct = _processStartTurnEffects(enemy); // Check death from bleed before acting if (enemy.isDead) { _onVictory(); return; } if (canAct && currentEnemyIntent != null) { final intent = currentEnemyIntent!; if (intent.type == EnemyActionType.defend) { // Handle Deferred Defense (from first turn) if (!intent.isApplied && intent.isSuccess) { // Apply defense now final eventId = DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(); final event = EffectEvent( id: eventId, type: ActionType.defend, risk: intent.risk, target: EffectTarget.enemy, feedbackType: null, attacker: enemy, targetEntity: enemy, armorGained: intent.finalValue, isSuccess: true, ); _effectEventController.sink.add(event); _processAttackImpact(event); // Apply armor and log intent.isApplied = true; } else if (intent.isApplied) { _addLog("Enemy maintains defensive stance."); } else { // Failed defense (already logged? maybe not if deferred) // If it was deferred fail, we should log it now? // But _generateEnemyIntent logged fail immediately even if deferred? // No, I changed it to log only if !success. // Wait, previous logic: if (!success) log. // So fail is already logged. _addLog("Enemy tried to defend but fumbled!"); } } else { // Attack Logic if (intent.isSuccess) { final event = EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(), type: ActionType.attack, risk: intent.risk, target: EffectTarget.player, feedbackType: null, attacker: enemy, targetEntity: player, damageValue: intent.finalValue, isSuccess: true, ); _effectEventController.sink.add(event); // No Future.delayed here, BattleScreen will trigger impact } else { _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, ); // Send event for miss feedback handleImpact(event); // Process impact via handleImpact for safety } } } else if (!canAct) { _addLog("Enemy is stunned and cannot act!"); } else { _addLog("Enemy did nothing."); } // Wait for potential animations to finish before generating next intent // If attacking, we need to wait for the attack animation + return if (currentEnemyIntent?.type == EnemyActionType.attack && currentEnemyIntent?.isSuccess == true) { int animDelay = GameConfig.animDelayNormal; if (currentEnemyIntent!.risk == RiskLevel.safe) animDelay = GameConfig.animDelaySafe; if (currentEnemyIntent!.risk == RiskLevel.risky) animDelay = GameConfig.animDelayRisky; // Wait for impact (handled by UI) + Return time + small buffer // Since we removed the pre-impact delay, the UI animation starts immediately. // We want to generate intent AFTER the full animation cycle. // Full cycle ~= 2 * animDelay (Forward + Reverse) await Future.delayed(Duration(milliseconds: animDelay)); } else { // For non-animating actions, a small pause is nice for pacing await Future.delayed(const Duration(milliseconds: 500)); } // Generate next intent if (!player.isDead) { _generateEnemyIntent(); } // Player Turn Start Logic // Armor decay if (player.armor > 0) { player.armor = (player.armor * GameConfig.armorDecayRate).toInt(); _addLog("Player's armor decayed to ${player.armor}."); } if (player.isDead) { await _onDefeat(); return; } isPlayerTurn = true; turnCount++; notifyListeners(); } void _addLog(String message) { _logManager.addLog(message); notifyListeners(); } 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) { if (item.id == "reward_skip") { _addLog("Skipped reward."); _completeStage(); return true; } else { bool added = player.addToInventory(item); if (added) { _addLog("Added ${item.name} to inventory."); _completeStage(); return true; } else { _addLog("Inventory is full! Could not take ${item.name}."); return false; } } } 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) { if (player.equip(item)) { _addLog("Equipped ${item.name}."); } 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(); } } /// Proceed to next stage from non-battle stages (Shop, Rest) void proceedToNextStage() { stage++; _prepareNextStage(); } void _generateEnemyIntent({bool applyImmediate = true}) { if (enemy.isDead) { currentEnemyIntent = null; return; } final random = Random(); // Decide Action Type // If baseDefense is 0, CANNOT defend. bool canDefend = enemy.baseDefense > 0; // Check for DefenseForbidden status if (enemy.hasStatus(StatusEffectType.defenseForbidden)) { canDefend = false; } bool isAttack = true; if (canDefend) { // 70% Attack, 30% Defend isAttack = random.nextDouble() < BattleConfig.enemyAttackChance; } else { isAttack = true; } // Decide Risk Level RiskLevel risk = RiskLevel.values[random.nextInt(RiskLevel.values.length)]; double efficiency = 1.0; switch (risk) { case RiskLevel.safe: efficiency = BattleConfig.safeEfficiency; break; case RiskLevel.normal: efficiency = BattleConfig.normalEfficiency; break; case RiskLevel.risky: efficiency = BattleConfig.riskyEfficiency; break; } if (isAttack) { // Attack Intent // Variance removed as per request int damage = (enemy.totalAtk * efficiency).toInt(); if (damage < 1) damage = 1; // Calculate success immediately bool success = false; switch (risk) { case RiskLevel.safe: success = random.nextDouble() < BattleConfig.safeBaseChance; break; case RiskLevel.normal: success = random.nextDouble() < BattleConfig.normalBaseChance; break; case RiskLevel.risky: success = random.nextDouble() < BattleConfig.riskyBaseChance; break; } currentEnemyIntent = EnemyIntent( type: EnemyActionType.attack, value: damage, risk: risk, description: "Attacks for $damage (${risk.name})", isSuccess: success, finalValue: damage, ); } else { // Defend Intent int baseDef = enemy.totalDefense; // Variance removed int armor = (baseDef * 2 * efficiency).toInt(); // Calculate success immediately bool success = false; switch (risk) { case RiskLevel.safe: success = random.nextDouble() < BattleConfig.safeBaseChance; break; case RiskLevel.normal: success = random.nextDouble() < BattleConfig.normalBaseChance; break; case RiskLevel.risky: success = random.nextDouble() < BattleConfig.riskyBaseChance; break; } currentEnemyIntent = EnemyIntent( type: EnemyActionType.defend, value: armor, risk: risk, description: "Defends for $armor (${risk.name})", isSuccess: success, finalValue: armor, ); // Apply defense immediately if successful AND allowed if (success && applyImmediate) { enemy.armor += armor; currentEnemyIntent!.isApplied = true; _addLog("Enemy prepares defense! (+$armor Armor)"); _effectEventController.sink.add( EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(), type: ActionType.defend, risk: risk, target: EffectTarget.enemy, feedbackType: null, // 방어 성공이므로 feedbackType 없음 ), ); } else { // Either failed or deferred if (!success) { _addLog("Enemy tried to defend but fumbled!"); } // If deferred (applyImmediate = false), we don't log or apply yet. } } notifyListeners(); } // New public method to be called by UI at impact moment void handleImpact(EffectEvent event) { if (event.isSuccess == false || event.feedbackType != null) { // 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 return; } // Only process actual attack or defend impacts here _processAttackImpact(event); } // 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, ), ); _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); } 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(); } /// Process effects that happen at the start of the turn (Bleed, Stun). /// Returns true if the character can act, false if stunned. bool _processStartTurnEffects(Character character) { final result = CombatCalculator.processStartTurnEffects(character); int totalBleed = result['bleedDamage']; bool isStunned = result['isStunned']; // 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, ), ); } // 2. Stun Check if (isStunned) { _addLog("${character.name} is stunned!"); } return !isStunned; } /// Tries to apply status effects from attacker's equipment to the target. void _tryApplyStatusEffects(Character attacker, Character target) { List effectsToApply = CombatCalculator.getAppliedEffects( attacker, ); for (var effect in effectsToApply) { target.addStatusEffect(effect); _addLog("Applied ${effect.type.name} to ${target.name}!"); } } /// Process effects that happen at the start of the turn (Bleed, Stun). }