1026 lines
31 KiB
Dart
1026 lines
31 KiB
Dart
import 'dart:async'; // StreamController 사용을 위해 import
|
|
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 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,
|
|
});
|
|
}
|
|
|
|
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 _turnTransactionId = 0; // To prevent async race conditions
|
|
bool skipAnimations = false; // Sync with SettingsProvider
|
|
|
|
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 => _logManager.logs;
|
|
int get lastGoldReward => _lastGoldReward;
|
|
|
|
void refreshUI() {
|
|
notifyListeners();
|
|
}
|
|
|
|
// 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;
|
|
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 닫기
|
|
_effectEventController.close();
|
|
super.dispose();
|
|
}
|
|
|
|
void loadFromSave(Map<String, dynamic> data) {
|
|
_turnTransactionId++; // Invalidate previous timers
|
|
stage = data['stage'];
|
|
turnCount = data['turnCount'];
|
|
player = Character.fromJson(data['player']);
|
|
|
|
_logManager.clear();
|
|
_addLog("Game Loaded! Resuming Stage $stage");
|
|
|
|
_prepareNextStage();
|
|
notifyListeners();
|
|
}
|
|
|
|
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.gold = GameConfig.startingGold;
|
|
|
|
// 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() {
|
|
_turnTransactionId++; // Invalidate previous timers
|
|
// Save Game at the start of each stage
|
|
SaveManager.saveGame(this);
|
|
|
|
// Reset Player Armor at start of new stage
|
|
player.armor = 0;
|
|
|
|
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;
|
|
|
|
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.");
|
|
} 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();
|
|
}
|
|
|
|
Future<void> _onDefeat() async {
|
|
_addLog("Player defeated! Enemy wins!");
|
|
await SaveManager.clearSaveData();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Handle player's action choice
|
|
Future<void> 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();
|
|
|
|
// 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(); // Allow player to choose another action
|
|
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(
|
|
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
|
|
}
|
|
} 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
|
|
}
|
|
|
|
// 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(); // REMOVED: Driven by UI via handleImpact
|
|
}
|
|
|
|
void _endPlayerTurn() {
|
|
// Update durations at end of turn
|
|
player.updateStatusEffects();
|
|
|
|
// 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();
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Recalculates the current enemy intent value based on current stats.
|
|
/// Used to update UI when enemy stats change (e.g. Disarmed applied).
|
|
void updateEnemyIntent() {
|
|
if (currentEnemyIntent == null || enemy.isDead) return;
|
|
|
|
final intent = currentEnemyIntent!;
|
|
int newValue = 0;
|
|
|
|
// Recalculate value based on current stats
|
|
if (intent.type == EnemyActionType.attack) {
|
|
newValue = (enemy.totalAtk *
|
|
CombatCalculator.getEfficiency(ActionType.attack, intent.risk))
|
|
.toInt();
|
|
if (newValue < 1 && enemy.totalAtk > 0) newValue = 1;
|
|
} else {
|
|
newValue = (enemy.totalDefense *
|
|
CombatCalculator.getEfficiency(ActionType.defend, intent.risk))
|
|
.toInt();
|
|
if (newValue < 1 && enemy.totalDefense > 0) newValue = 1;
|
|
}
|
|
|
|
// Replace intent with updated value, keeping other properties
|
|
currentEnemyIntent = EnemyIntent(
|
|
type: intent.type,
|
|
value: newValue,
|
|
risk: intent.risk,
|
|
description: "$newValue (${intent.risk.name})",
|
|
isSuccess: intent.isSuccess,
|
|
finalValue: newValue,
|
|
isApplied: intent.isApplied,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
// --- Turn Management Phases ---
|
|
|
|
// 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
|
|
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;
|
|
}
|
|
|
|
// --- Turn Management Phases ---
|
|
|
|
// Phase 1: Enemy Action Phase
|
|
Future<void> _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
|
|
final result = CombatCalculator.processStartTurnEffects(enemy);
|
|
bool canAct = !result['isStunned'];
|
|
|
|
if (enemy.isDead) {
|
|
_onVictory();
|
|
return;
|
|
}
|
|
|
|
if (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 (!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.updateStatusEffects();
|
|
|
|
// Generate NEXT intent
|
|
_generateEnemyIntent();
|
|
|
|
_processMiddleTurn();
|
|
}
|
|
|
|
// Phase 3: Middle Turn (Apply Defense Effects)
|
|
Future<void> _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<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();
|
|
}
|
|
|
|
@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 (canAttack) {
|
|
// Must attack
|
|
isAttack = true;
|
|
} else if (canDefend) {
|
|
// Must defend
|
|
isAttack = false;
|
|
} else {
|
|
// Both forbidden (Rare case, effectively stunned but not via Stun status)
|
|
// Default to Defend as a fallback, outcomes will be handled by stats/luck
|
|
isAttack = false;
|
|
}
|
|
|
|
// Decide Risk Level
|
|
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 &&
|
|
!currentEnemyIntent!.isApplied) {
|
|
final intent = currentEnemyIntent!;
|
|
if (intent.isSuccess) {
|
|
enemy.armor += intent.finalValue;
|
|
_addLog(
|
|
"${enemy.name} assumes a defensive stance (+${intent.finalValue} Armor).",
|
|
);
|
|
} else {
|
|
_addLog("${enemy.name} tried to defend but failed.");
|
|
}
|
|
intent.isApplied = true;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
),
|
|
);
|
|
_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);
|
|
|
|
// 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) {
|
|
List<StatusEffect> effectsToApply = CombatCalculator.getAppliedEffects(
|
|
attacker,
|
|
random: _random, // Pass injected random
|
|
);
|
|
|
|
for (var effect in effectsToApply) {
|
|
target.addStatusEffect(effect);
|
|
_addLog("Applied ${effect.type.name} to ${target.name}!");
|
|
}
|
|
}
|
|
}
|