game/lib/providers/battle_provider.dart

894 lines
26 KiB
Dart

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 'shop_provider.dart'; // Import ShopProvider
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<String> battleLogs = [];
bool isPlayerTurn = true;
int stage = 1;
int turnCount = 1;
List<Item> rewardOptions = [];
bool showRewardPopup = false;
int _lastGoldReward = 0; // New: Stores gold gained from last victory
List<String> get logs => battleLogs;
int get lastGoldReward => _lastGoldReward;
// Damage Event Stream
final _damageEventController = StreamController<DamageEvent>.broadcast();
Stream<DamageEvent> get damageStream => _damageEventController.stream;
// Effect Event Stream
final _effectEventController = StreamController<EffectEvent>.broadcast();
Stream<EffectEvent> 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<String, dynamic> 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 = 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();
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 % 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<Item> 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 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
/// Handle player's action choice
Future<void> 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: GameConfig.animDelaySafe));
} else if (risk == RiskLevel.normal) {
await Future.delayed(const Duration(milliseconds: GameConfig.animDelayNormal));
} else if (risk == RiskLevel.risky) {
await Future.delayed(const Duration(milliseconds: GameConfig.animDelayRisky));
}
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(milliseconds: GameConfig.animDelayEnemyTurn), () => _enemyTurn());
}
Future<void> _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;
// 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 * GameConfig.armorDecayRate).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 * GameConfig.vulnerableDamageMultiplier).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<ItemTemplate> 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 > GameConfig.tier2StageMax)
currentTier = ItemTier.tier3;
else if (stage > GameConfig.tier1StageMax)
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<Item>.
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() {
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();
}
}