import 'dart:async'; // StreamController 사용을 위해 import import 'dart:math'; import 'package:flutter/material.dart'; 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'; class EnemyIntent { final EnemyActionType type; final int value; final RiskLevel risk; final String description; final bool isSuccess; final int finalValue; EnemyIntent({ required this.type, required this.value, required this.risk, required this.description, required this.isSuccess, required this.finalValue, }); } 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; List battleLogs = []; 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 => battleLogs; int get lastGoldReward => _lastGoldReward; // Damage Event Stream final _damageEventController = StreamController.broadcast(); Stream get damageStream => _damageEventController.stream; // Effect Event Stream final _effectEventController = StreamController.broadcast(); Stream get effectStream => _effectEventController.stream; BattleProvider() { // 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']); battleLogs.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 = 50; // 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(); battleLogs.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 % 10 == 0) { type = StageType.elite; // Every 10th stage is a Boss/Elite } else if (stage % 5 == 0) { type = StageType.shop; // Every 5th stage is a Shop (except 10, 20...) } else if (stage % 8 == 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; // Select random enemy template final random = Random(); EnemyTemplate template; if (isElite) { if (EnemyTable.eliteEnemies.isNotEmpty) { template = EnemyTable .eliteEnemies[random.nextInt(EnemyTable.eliteEnemies.length)]; } else { // Fallback if no elite enemies loaded template = const EnemyTemplate( name: "Elite Guardian", baseHp: 50, baseAtk: 10, baseDefense: 2, ); } } else { if (EnemyTable.normalEnemies.isNotEmpty) { template = EnemyTable .normalEnemies[random.nextInt(EnemyTable.normalEnemies.length)]; } else { // Fallback template = const EnemyTemplate( name: "Enemy", baseHp: 20, baseAtk: 5, baseDefense: 0, ); } } newEnemy = template.createCharacter(stage: stage); // Assign to the main 'enemy' field for UI compatibility enemy = newEnemy; isPlayerTurn = true; showRewardPopup = false; _generateEnemyIntent(); // Generate first intent _addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared."); } else if (type == StageType.shop) { // Generate random items for shop shopItems = _generateShopItems(); // 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, ); turnCount = 1; notifyListeners(); } /// Generate 4 random items for the shop based on current stage tier List _generateShopItems() { ItemTier currentTier = ItemTier.tier1; if (stage > 24) currentTier = ItemTier.tier3; else if (stage > 12) currentTier = ItemTier.tier2; List items = []; for (int i = 0; i < 4; i++) { ItemTemplate? template = ItemTable.getRandomItem(tier: currentTier); if (template != null) { items.add(template.createItem(stage: stage)); } } return items; } void rerollShopItems() { const int rerollCost = 50; if (player.gold >= rerollCost) { player.gold -= rerollCost; // Modify the existing list because shopItems is final currentStage.shopItems.clear(); currentStage.shopItems.addAll(_generateShopItems()); _addLog("Shop items rerolled for $rerollCost G."); notifyListeners(); } else { _addLog("Not enough gold to reroll!"); } } void buyItem(Item item) { if (player.gold >= item.price) { bool added = player.addToInventory(item); if (added) { player.gold -= item.price; currentStage.shopItems.remove(item); // Remove from shop _addLog("Bought ${item.name} for ${item.price} G."); } else { _addLog("Inventory is full!"); } notifyListeners(); } else { _addLog("Not enough gold!"); } } // Replaces _spawnEnemy // void _spawnEnemy() { ... } - Removed /// 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 (!canAct) { _endPlayerTurn(); // Skip turn if stunned return; } _addLog("Player chose to ${type.name} with ${risk.name} risk."); final random = Random(); bool success = false; double efficiency = 1.0; switch (risk) { case RiskLevel.safe: // Safe: 100% base chance + luck double chance = 1.0 + (player.totalLuck / 100.0); if (chance > 1.0) chance = 1.0; success = random.nextDouble() < chance; efficiency = 0.5; // 50% break; case RiskLevel.normal: // Normal: 80% base chance + luck double chance = 0.8 + (player.totalLuck / 100.0); if (chance > 1.0) chance = 1.0; success = random.nextDouble() < chance; efficiency = 1.0; // 100% break; case RiskLevel.risky: // Risky: 40% base chance + luck double chance = 0.4 + (player.totalLuck / 100.0); if (chance > 1.0) chance = 1.0; success = random.nextDouble() < chance; efficiency = 2.0; // 200% break; } if (success) { if (type == ActionType.attack) { int damage = (player.totalAtk * efficiency).toInt(); final eventId = DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(); _effectEventController.sink.add( EffectEvent( id: eventId, type: ActionType.attack, risk: risk, target: EffectTarget.enemy, feedbackType: null, // 공격 성공이므로 feedbackType 없음 ), ); // Animation Delays to sync with Impact if (risk == RiskLevel.safe) { await Future.delayed(const Duration(milliseconds: 500)); } else if (risk == RiskLevel.normal) { await Future.delayed(const Duration(milliseconds: 400)); } else if (risk == RiskLevel.risky) { await Future.delayed(const Duration(milliseconds: 1100)); } int damageToHp = 0; if (enemy.armor > 0) { if (enemy.armor >= damage) { enemy.armor -= damage; damageToHp = 0; _addLog("Enemy's armor absorbed all $damage damage."); } else { damageToHp = damage - enemy.armor; _addLog("Enemy's armor absorbed ${enemy.armor} damage."); enemy.armor = 0; } } else { damageToHp = damage; } if (damageToHp > 0) { _applyDamage(enemy, damageToHp, targetType: DamageTarget.enemy); _addLog("Player dealt $damageToHp damage to Enemy."); } else { _addLog("Player's attack was fully blocked by armor."); } // Try applying status effects from items _tryApplyStatusEffects(player, enemy); } else { _effectEventController.sink.add( EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(), type: ActionType.defend, risk: risk, target: EffectTarget.player, feedbackType: null, // 방어 성공이므로 feedbackType 없음 ), ); int armorGained = (player.totalDefense * efficiency).toInt(); player.armor += armorGained; _addLog("Player gained $armorGained armor."); } } else { if (type == ActionType.attack) { _addLog("Player's attack missed!"); _effectEventController.sink.add( EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(), type: type, risk: risk, target: EffectTarget.enemy, // 공격 실패는 적 위치에 MISS feedbackType: BattleFeedbackType.miss, ), ); } else { _addLog("Player's defense failed!"); _effectEventController.sink.add( EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(), type: type, risk: risk, target: EffectTarget.player, // 방어 실패는 내 위치에 FAILED feedbackType: BattleFeedbackType.failed, ), ); } } if (enemy.isDead) { _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(seconds: 1), () => _enemyTurn()); } Future _enemyTurn() async { if (!isPlayerTurn && (player.isDead || enemy.isDead)) return; _addLog("Enemy's turn..."); await Future.delayed(const Duration(seconds: 1)); // Enemy Turn Start Logic // Armor decay if (enemy.armor > 0) { enemy.armor = (enemy.armor * 0.5).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; // return; // Already handled by _processStartTurnEffects if damage applied } if (canAct && currentEnemyIntent != null) { final intent = currentEnemyIntent!; if (intent.type == EnemyActionType.defend) { // Already handled in _generateEnemyIntent _addLog("Enemy maintains defensive stance."); } else { // Attack Logic if (intent.isSuccess) { _effectEventController.sink.add( EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(), type: ActionType.attack, risk: intent.risk, target: EffectTarget.player, feedbackType: null, // 공격 성공이므로 feedbackType 없음 ), ); int incomingDamage = intent.finalValue; int damageToHp = 0; // Handle Player Armor if (player.armor > 0) { if (player.armor >= incomingDamage) { player.armor -= incomingDamage; damageToHp = 0; _addLog("Armor absorbed all $incomingDamage damage."); } else { damageToHp = incomingDamage - player.armor; _addLog("Armor absorbed ${player.armor} damage."); player.armor = 0; } } else { damageToHp = incomingDamage; } if (damageToHp > 0) { _applyDamage(player, damageToHp, targetType: DamageTarget.player); _addLog("Enemy dealt $damageToHp damage to Player HP."); } // Try applying status effects from enemy equipment _tryApplyStatusEffects(enemy, player); } else { _addLog("Enemy's ${intent.risk.name} attack missed!"); _effectEventController.sink.add( EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + Random().nextInt(1000).toString(), type: ActionType.attack, // 적의 공격이므로 ActionType.attack risk: intent.risk, target: EffectTarget.player, // 플레이어가 회피했으므로 플레이어 위치에 이펙트 feedbackType: BattleFeedbackType.miss, // 변경: MISS 피드백 ), ); } } } else if (!canAct) { _addLog("Enemy is stunned and cannot act!"); } else { _addLog("Enemy did nothing."); } // Generate next intent if (!player.isDead) { _generateEnemyIntent(); } // Player Turn Start Logic // Armor decay if (player.armor > 0) { player.armor = (player.armor * 0.5).toInt(); _addLog("Player's armor decayed to ${player.armor}."); } if (player.isDead) { _addLog("Player defeated! Enemy wins!"); } isPlayerTurn = true; turnCount++; 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) { bool canAct = true; // 1. Bleed Damage var bleedEffects = character.statusEffects .where((e) => e.type == StatusEffectType.bleed) .toList(); if (bleedEffects.isNotEmpty) { int totalBleed = bleedEffects.fold(0, (sum, e) => sum + e.value); int previousHp = character.hp; // Record HP before damage character.hp -= totalBleed; if (character.hp < 0) character.hp = 0; _addLog("${character.name} takes $totalBleed bleed damage!"); // Emit DamageEvent for bleed if (character == player) { _damageEventController.sink.add( DamageEvent( damage: totalBleed, target: DamageTarget.player, type: DamageType.bleed, ), ); } else if (character == enemy) { _damageEventController.sink.add( DamageEvent( damage: totalBleed, target: DamageTarget.enemy, type: DamageType.bleed, ), ); } } // 2. Stun Check if (character.hasStatus(StatusEffectType.stun)) { canAct = false; _addLog("${character.name} is stunned!"); } return canAct; } /// Tries to apply status effects from attacker's equipment to the target. void _tryApplyStatusEffects(Character attacker, Character target) { final random = Random(); for (var item in attacker.equipment.values) { for (var effect in item.effects) { // Roll for probability (0-100) if (random.nextInt(100) < effect.probability) { // Apply effect final newStatus = StatusEffect( type: effect.type, duration: effect.duration, value: effect.value, ); target.addStatusEffect(newStatus); _addLog("Applied ${effect.type.name} to ${target.name}!"); } } } } void _applyDamage( Character target, int damage, { required DamageTarget targetType, DamageType type = DamageType.normal, }) { // Check Vulnerable if (target.hasStatus(StatusEffectType.vulnerable)) { damage = (damage * 1.5).toInt(); _addLog("Vulnerable! Damage increased to $damage."); type = DamageType.vulnerable; } target.hp -= damage; if (target.hp < 0) target.hp = 0; _damageEventController.sink.add( DamageEvent(damage: damage, target: targetType, type: type), ); } void _addLog(String message) { battleLogs.add(message); notifyListeners(); } void _onVictory() { // Calculate Gold Reward // Base 10 + (Stage * 5) + Random variance final random = Random(); int goldReward = 10 + (stage * 5) + random.nextInt(10); player.gold += goldReward; _lastGoldReward = goldReward; // Store for UI display _addLog("Enemy defeated! Gained $goldReward Gold."); _addLog("Choose a reward."); List allTemplates = List.from(ItemTable.allItems); allTemplates.shuffle(random); // Shuffle to randomize selection // Item Rewards // Logic: Get random items based on current round tier? For now just random. // Ideally should use ItemTable.getRandomItem() with Tier logic. // Let's use our new weighted random logic if available, or fallback to simple shuffle for now to keep it simple. // Since we just refactored ItemTable, let's use getRandomItem! ItemTier currentTier = ItemTier.tier1; if (stage > 24) currentTier = ItemTier.tier3; else if (stage > 12) currentTier = ItemTier.tier2; rewardOptions = []; // Get 3 distinct items if possible for (int i = 0; i < 3; i++) { ItemTemplate? item = ItemTable.getRandomItem(tier: currentTier); 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(); } void selectReward(Item item) { if (item.id == "reward_skip") { _addLog("Skipped reward."); } else { bool added = player.addToInventory(item); if (added) { _addLog("Added ${item.name} to inventory."); } else { _addLog("Inventory is full! ${item.name} discarded."); } } // Heal player after selecting reward int healAmount = GameMath.floor(player.totalMaxHp * 0.1); 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 * 0.6); 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() { 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() < 0.7; } 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 = 0.5; break; case RiskLevel.normal: efficiency = 1.0; break; case RiskLevel.risky: efficiency = 2.0; 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() < 1.0; break; case RiskLevel.normal: success = random.nextDouble() < 0.8; break; case RiskLevel.risky: success = random.nextDouble() < 0.4; 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() < 1.0; break; case RiskLevel.normal: success = random.nextDouble() < 0.8; break; case RiskLevel.risky: success = random.nextDouble() < 0.4; 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 if (success) { enemy.armor += armor; _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 { _addLog("Enemy tried to defend but fumbled!"); } } notifyListeners(); } }