This commit is contained in:
2025-12-08 03:00:36 +09:00
parent 135bf26332
commit 9540dd22a3
8 changed files with 627 additions and 326 deletions
+252 -77
View File
@@ -31,6 +31,7 @@ class EnemyIntent {
final String description;
final bool isSuccess;
final int finalValue;
bool isApplied; // Mutable flag to prevent double execution
EnemyIntent({
required this.type,
@@ -39,6 +40,7 @@ class EnemyIntent {
required this.description,
required this.isSuccess,
required this.finalValue,
this.isApplied = false,
});
}
@@ -51,6 +53,8 @@ class BattleProvider with ChangeNotifier {
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;
@@ -88,6 +92,7 @@ class BattleProvider with ChangeNotifier {
}
void loadFromSave(Map<String, dynamic> data) {
_turnTransactionId++; // Invalidate previous timers
stage = data['stage'];
turnCount = data['turnCount'];
player = Character.fromJson(data['player']);
@@ -100,6 +105,7 @@ class BattleProvider with ChangeNotifier {
}
void initializeBattle() {
_turnTransactionId++; // Invalidate previous timers
stage = 1;
turnCount = 1;
// Load player from PlayerTable
@@ -180,6 +186,7 @@ class BattleProvider with ChangeNotifier {
}
void _prepareNextStage() {
_turnTransactionId++; // Invalidate previous timers
// Save Game at the start of each stage
SaveManager.saveGame(this);
@@ -216,6 +223,7 @@ class BattleProvider with ChangeNotifier {
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) {
@@ -258,10 +266,69 @@ class BattleProvider with ChangeNotifier {
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
return;
// Update Enemy Status Effects at the start of Player's turn (user request)
enemy.updateStatusEffects();
// 0. Apply Enemy Pre-emptive Defense (Just-in-Time)
if (currentEnemyIntent?.type == EnemyActionType.defend &&
!currentEnemyIntent!.isApplied) {
final intent = currentEnemyIntent!;
// 1. Check for Defense Forbidden status
if (intent.isSuccess) {
enemy.armor += intent.finalValue;
_addLog(
"Enemy raises shield just in time! (+${intent.finalValue} Armor)",
);
// Visual Effect for Enemy Defense Success
final event = EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
type: ActionType.defend,
risk: intent.risk,
target: EffectTarget.enemy,
feedbackType: null,
attacker: enemy,
targetEntity: enemy,
armorGained: intent.finalValue,
isSuccess: true,
);
_effectEventController.sink.add(event);
}
if (!intent.isSuccess) {
_addLog("Enemy tried to raise shield but fumbled!");
print("[Logic Debug] Enemy Defense Fumbled Event"); // Debug Log
// Visual Effect for Enemy Defense Failure
final event = EffectEvent(
id:
DateTime.now().millisecondsSinceEpoch.toString() +
Random().nextInt(1000).toString(),
type: ActionType.defend,
risk: intent.risk,
target: EffectTarget.enemy,
feedbackType: BattleFeedbackType.failed,
attacker: enemy,
targetEntity: enemy,
isSuccess: false,
);
_effectEventController.sink.add(event);
}
intent.isApplied = true;
notifyListeners();
// Re-add delay to show the defense before player attack lands
int tid = _turnTransactionId;
await Future.delayed(const Duration(milliseconds: 500));
if (tid != _turnTransactionId) 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.");
@@ -335,7 +402,7 @@ class BattleProvider with ChangeNotifier {
isSuccess: true,
);
_effectEventController.sink.add(event);
handleImpact(event); // Process impact via handleImpact for safety
// handleImpact(event); // REMOVED: Driven by UI
}
} else {
// Failure
@@ -366,16 +433,19 @@ class BattleProvider with ChangeNotifier {
event,
); // Send event for miss/fail feedback
_addLog("${player.name}'s ${type.name} ${feedbackType.name}!");
handleImpact(event); // Process impact via handleImpact for safety
print("[Logic Debug] Player Action Failed Event"); // Debug Log
// handleImpact(event); // REMOVED: Driven by UI
}
// Now check for enemy death (if applicable from bleed, or previous impacts)
// Now check for enemy death (if applicable from bleed, or previous impacts)
if (enemy.isDead) {
// Check enemy death after player's action
_onVictory();
return;
}
// Removed redundant `if (enemy.isDead)` check as it's handled in `_processAttackImpact`
_endPlayerTurn();
}
// _endPlayerTurn(); // REMOVED: Driven by UI via handleImpact
}
void _endPlayerTurn() {
// Update durations at end of turn
@@ -387,49 +457,76 @@ class BattleProvider with ChangeNotifier {
return;
}
int tid = _turnTransactionId;
Future.delayed(
const Duration(milliseconds: GameConfig.animDelayEnemyTurn),
() => _enemyTurn(),
() {
if (tid != _turnTransactionId) return;
_startEnemyTurn();
},
);
}
Future<void> _enemyTurn() async {
// --- 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...");
await Future.delayed(
const Duration(milliseconds: GameConfig.animDelayEnemyTurn),
);
// REMOVED: Initial delay for faster pacing
// 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
// Process Start-of-Turn Effects
bool canAct = _processStartTurnEffects(enemy);
// Check death from bleed before acting
if (enemy.isDead) {
_onVictory();
return;
}
if (canAct && currentEnemyIntent != null) {
if (canAct && currentEnemyIntent != null) {
final intent = currentEnemyIntent!;
final intent = currentEnemyIntent!;
if (intent.type == EnemyActionType.defend) {
// Defensive Action (Non-animating)
// Check if already applied in Phase 3 of previous turn
if (intent.isApplied) {
_addLog("Enemy maintains defensive stance.");
// Proceed manually
int tid = _turnTransactionId;
int delay = skipAnimations ? 500 : 1000; // Faster if animations off
Future.delayed(Duration(milliseconds: delay), () {
if (tid != _turnTransactionId) return;
_endEnemyTurn();
});
return;
}
if (intent.type == EnemyActionType.defend) {
// Already handled in _generateEnemyIntent
_addLog("Enemy maintains defensive stance.");
} else { // Attack Logic
if (intent.isSuccess) {
// ... (success logic) ...
} else {
// ... (failure logic) ...
}
// For defense (if not applied), we proceed manually
int tid = _turnTransactionId;
int delay = skipAnimations ? 500 : 1500; // Faster if animations off
Future.delayed(Duration(milliseconds: delay), () {
if (tid != _turnTransactionId) return;
_endEnemyTurn();
});
} else {
// Attack Action (Animating)
if (intent.isSuccess) {
final event = EffectEvent(
id:
@@ -445,8 +542,12 @@ class BattleProvider with ChangeNotifier {
isSuccess: true,
);
_effectEventController.sink.add(event);
// No Future.delayed here, BattleScreen will trigger impact
// CRITICAL: We DO NOT call _endEnemyTurn here.
// The UI will play the animation, then call handleImpact.
// handleImpact will trigger _endEnemyTurn.
return; // Exit _startEnemyTurn after emitting event for UI to handle
} else {
// Missed Attack
_addLog("Enemy's ${intent.risk.name} attack missed!");
final event = EffectEvent(
id:
@@ -460,43 +561,53 @@ class BattleProvider with ChangeNotifier {
targetEntity: player,
isSuccess: false,
);
_effectEventController.sink.add(
event,
); // Send event for miss feedback
handleImpact(event); // Process impact via handleImpact for safety
_effectEventController.sink.add(event);
return; // Exit _startEnemyTurn after emitting event for UI to handle
}
}
} else if (!canAct) {
_addLog("Enemy is stunned and cannot act!");
int tid = _turnTransactionId;
Future.delayed(const Duration(milliseconds: 500), () {
if (tid != _turnTransactionId) return;
_endEnemyTurn();
});
} else {
_addLog("Enemy did nothing.");
int tid = _turnTransactionId;
Future.delayed(const Duration(milliseconds: 500), () {
if (tid != _turnTransactionId) return;
_endEnemyTurn();
});
}
}
// 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;
// Phase 2: End Enemy Turn & Generate Next Intent
void _endEnemyTurn() {
if (player.isDead) return; // Game Over check
// 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
_generateEnemyIntent();
// Generate next intent
if (!player.isDead) {
_generateEnemyIntent();
}
_processMiddleTurn();
}
// Phase 3: Middle Turn (Apply Defense Effects)
Future<void> _processMiddleTurn() async {
// Apply Intent Effects (Pre-emptive Defense)
int tid = _turnTransactionId;
await Future.delayed(const Duration(milliseconds: 500));
if (tid != _turnTransactionId) return;
_applyEnemyIntentEffects();
// REMOVED: Delay for faster pacing
// Small pause to let the player see the enemy's new stance
_startPlayerTurn();
}
// Phase 4: Start Player Turn
void _startPlayerTurn() {
// Player Turn Start Logic
// Armor decay
if (player.armor > 0) {
@@ -505,7 +616,7 @@ class BattleProvider with ChangeNotifier {
}
if (player.isDead) {
await _onDefeat();
_onDefeat();
return;
}
@@ -628,7 +739,6 @@ class BattleProvider with ChangeNotifier {
stage++;
showRewardPopup = false;
rewardOptions.clear(); // Clear options to prevent flash on next victory
_prepareNextStage();
@@ -753,7 +863,7 @@ class BattleProvider with ChangeNotifier {
// Defend Intent
int baseDef = enemy.totalDefense;
// Variance removed
int armor = (baseDef * 2 * efficiency).toInt();
int armor = (baseDef * efficiency).toInt();
// Calculate success immediately
bool success = false;
@@ -778,39 +888,104 @@ class BattleProvider with ChangeNotifier {
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!");
}
// Note: Armor is NO LONGER applied here instantly.
// It is applied in _applyEnemyIntentEffects() which is called before Player turn.
}
notifyListeners();
}
/// Applies the effects of the enemy's intent (specifically Defense)
/// This should be called just before the Player's turn starts.
void _applyEnemyIntentEffects() {
if (currentEnemyIntent == null || enemy.isDead) return;
// Prevent duplicate application
if (currentEnemyIntent!.isApplied) return;
if (currentEnemyIntent!.type == EnemyActionType.defend) {
// Logic moved to playerAction for "Just-in-Time" defense.
// Nothing to do here except maybe log intent (optional, but Intent UI covers it).
return;
}
}
// 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
// Even on failure, proceed to end turn logic
if (event.attacker == player) {
_endPlayerTurn();
} else if (event.attacker == enemy) {
// Special Case: Do NOT call _endEnemyTurn for Enemy Defense (Phase 1 & 3).
// Phase 1 relies on manual timer. Phase 3 relies on _processMiddleTurn sequence.
if (event.type != ActionType.defend) {
_endEnemyTurn();
}
}
return;
}
// Special Case: Enemy Defense (Phase 3 & Phase 1)
// - Phase 3 Defense: Logic applied in _applyEnemyIntentEffects. Event is Visual Only.
// - Phase 1 Defense: Logic applied in _startEnemyTurn (if we add it there) or here?
// Wait, Phase 1 Defense is distinct.
// However, currently Phase 1 Defense also uses _effectEventController.sink.add(event).
// BUT Phase 1 Defense Logic is NOT applied in _startEnemyTurn yet (it just emits event).
// So Phase 1 Defense SHOULD go through _processAttackImpact?
// NO, because Phase 1 Defense uses the same ActionType.defend.
// Let's look at _startEnemyTurn for Phase 1 Defense:
// It emits event with armorGained. It does NOT increase armor directly.
// So for Phase 1, we NEED handleImpact -> _processAttackImpact.
// Let's look at _applyEnemyIntentEffects for Phase 3 Defense:
// It increases armor DIRECTLY: "enemy.armor += intent.finalValue;"
// AND it emits event.
// This discrepancy is the root cause.
// We should standardize.
// DECISION: Phase 3 Defense event should be flagged or handled as visual-only.
// Since we can't easily add flags to EffectEvent without changing other files,
// let's rely on the context.
// Actually, simply removing the direct armor application in _applyEnemyIntentEffects
// and letting handleImpact do it is cleaner?
// NO, because Phase 3 needs armor applied BEFORE Player Turn starts, independent of UI speed.
// And _processMiddleTurn relies on the logic sequence.
// So, we MUST block handleImpact for Phase 3 Defense.
// Phase 1 Defense (Rare, usually Attack) needs to work too.
// BUT wait, _startEnemyTurn (Phase 1) code:
// if (intent.type == EnemyActionType.defend) { ... sink.add(event); ... }
// It does NOT apply armor. So Phase 1 relies on handleImpact.
// PROBLEM: handleImpact cannot distinguish Phase 1 vs Phase 3 event easily.
// FIX: Update _startEnemyTurn (Phase 1) to ALSO apply armor directly and make the event visual-only.
// Then we can globally block Enemy Defend in handleImpact.
// Step 1: Modifying handleImpact to block ALL Enemy Defend logic.
if (event.attacker == enemy && event.type == ActionType.defend) {
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