Fix: Delay Enemy Intent generation to allow attack animation to finish
This commit is contained in:
parent
0b48aea16d
commit
fef803d064
|
|
@ -25,9 +25,9 @@ class GameConfig {
|
||||||
static const int goldRewardVariance = 10;
|
static const int goldRewardVariance = 10;
|
||||||
|
|
||||||
// Animations (Duration in milliseconds)
|
// Animations (Duration in milliseconds)
|
||||||
static const int animDelaySafe = 500;
|
static const int animDelaySafe = 600; // 500 + 100 buffer
|
||||||
static const int animDelayNormal = 400;
|
static const int animDelayNormal = 500; // 400 + 100 buffer
|
||||||
static const int animDelayRisky = 1100;
|
static const int animDelayRisky = 1200; // 1100 + 100 buffer
|
||||||
static const int animDelayEnemyTurn = 1000;
|
static const int animDelayEnemyTurn = 1000;
|
||||||
|
|
||||||
// Save System
|
// Save System
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import '../enums.dart';
|
import '../enums.dart';
|
||||||
|
import 'entity.dart'; // Import Character entity
|
||||||
|
|
||||||
enum EffectTarget { player, enemy }
|
enum EffectTarget { player, enemy }
|
||||||
|
|
||||||
|
|
@ -9,11 +10,23 @@ class EffectEvent {
|
||||||
final EffectTarget target; // 이펙트가 표시될 위치의 대상
|
final EffectTarget target; // 이펙트가 표시될 위치의 대상
|
||||||
final BattleFeedbackType? feedbackType; // 새로운 피드백 타입
|
final BattleFeedbackType? feedbackType; // 새로운 피드백 타입
|
||||||
|
|
||||||
|
// New fields for impact logic
|
||||||
|
final Character? attacker;
|
||||||
|
final Character? targetEntity; // 실제 피해를 받는 캐릭터
|
||||||
|
final int? damageValue; // 공격 시 데미지 값
|
||||||
|
final bool? isSuccess; // 성공 여부 (Missed or Failed가 아닌 경우)
|
||||||
|
final int? armorGained; // 방어 시 얻는 방어도
|
||||||
|
|
||||||
EffectEvent({
|
EffectEvent({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.risk,
|
required this.risk,
|
||||||
required this.target,
|
required this.target,
|
||||||
this.feedbackType, // feedbackType 필드를 생성자에 추가
|
this.feedbackType, // feedbackType 필드를 생성자에 추가
|
||||||
|
this.attacker,
|
||||||
|
this.targetEntity,
|
||||||
|
this.damageValue,
|
||||||
|
this.isSuccess,
|
||||||
|
this.armorGained,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,10 @@ class BattleProvider with ChangeNotifier {
|
||||||
if (type == StageType.battle || type == StageType.elite) {
|
if (type == StageType.battle || type == StageType.elite) {
|
||||||
bool isElite = type == StageType.elite;
|
bool isElite = type == StageType.elite;
|
||||||
|
|
||||||
EnemyTemplate template = EnemyTable.getRandomEnemy(stage: stage, isElite: isElite);
|
EnemyTemplate template = EnemyTable.getRandomEnemy(
|
||||||
|
stage: stage,
|
||||||
|
isElite: isElite,
|
||||||
|
);
|
||||||
|
|
||||||
newEnemy = template.createCharacter(stage: stage);
|
newEnemy = template.createCharacter(stage: stage);
|
||||||
|
|
||||||
|
|
@ -251,7 +254,6 @@ class BattleProvider with ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle player's action choice
|
/// Handle player's action choice
|
||||||
|
|
||||||
Future<void> playerAction(ActionType type, RiskLevel risk) async {
|
Future<void> playerAction(ActionType type, RiskLevel risk) async {
|
||||||
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
|
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
|
||||||
return;
|
return;
|
||||||
|
|
@ -287,139 +289,89 @@ class BattleProvider with ChangeNotifier {
|
||||||
_addLog("Player chose to ${type.name} with ${risk.name} risk.");
|
_addLog("Player chose to ${type.name} with ${risk.name} risk.");
|
||||||
|
|
||||||
// Calculate Outcome using CombatCalculator
|
// Calculate Outcome using CombatCalculator
|
||||||
int baseValue = (type == ActionType.attack) ? player.totalAtk : player.totalDefense;
|
int baseValue = (type == ActionType.attack)
|
||||||
|
? player.totalAtk
|
||||||
|
: player.totalDefense;
|
||||||
|
|
||||||
final result = CombatCalculator.calculateActionOutcome(
|
final result = CombatCalculator.calculateActionOutcome(
|
||||||
risk: risk,
|
risk: risk,
|
||||||
luck: player.totalLuck,
|
luck: player.totalLuck,
|
||||||
baseValue: baseValue
|
baseValue: baseValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (type == ActionType.attack) {
|
if (type == ActionType.attack) {
|
||||||
int damage = result.value;
|
int damage = result.value;
|
||||||
|
|
||||||
final eventId =
|
final event = EffectEvent(
|
||||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
id:
|
||||||
Random().nextInt(1000).toString();
|
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(
|
_effectEventController.sink.add(
|
||||||
EffectEvent(
|
event,
|
||||||
id: eventId,
|
); // No Future.delayed here, BattleScreen will trigger impact
|
||||||
type: ActionType.attack,
|
|
||||||
risk: risk,
|
|
||||||
target: EffectTarget.enemy,
|
|
||||||
feedbackType: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Animation Delays
|
|
||||||
int delay = GameConfig.animDelayNormal;
|
|
||||||
if (risk == RiskLevel.safe) delay = GameConfig.animDelaySafe;
|
|
||||||
if (risk == RiskLevel.risky) delay = GameConfig.animDelayRisky;
|
|
||||||
|
|
||||||
await Future.delayed(Duration(milliseconds: delay));
|
|
||||||
|
|
||||||
// Calculate Damage to HP using CombatCalculator
|
|
||||||
int damageToHp = CombatCalculator.calculateDamageToHp(
|
|
||||||
incomingDamage: damage,
|
|
||||||
currentArmor: enemy.armor,
|
|
||||||
isVulnerable: enemy.hasStatus(StatusEffectType.vulnerable)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate Remaining Armor
|
|
||||||
int remainingArmor = CombatCalculator.calculateRemainingArmor(
|
|
||||||
incomingDamage: damage,
|
|
||||||
currentArmor: enemy.armor,
|
|
||||||
isVulnerable: enemy.hasStatus(StatusEffectType.vulnerable)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log details
|
|
||||||
if (enemy.armor > 0) {
|
|
||||||
int absorbed = enemy.armor - remainingArmor;
|
|
||||||
if (damageToHp == 0) {
|
|
||||||
_addLog("Enemy's armor absorbed all damage.");
|
|
||||||
} else {
|
|
||||||
_addLog("Enemy's armor absorbed $absorbed damage.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enemy.armor = remainingArmor;
|
|
||||||
|
|
||||||
if (damageToHp > 0) {
|
|
||||||
// Note: _applyDamage internally handles Vulnerable multiplier again for the DamageEvent and logs.
|
|
||||||
// To avoid double application, we should just pass the raw damage to _applyDamage
|
|
||||||
// OR refactor _applyDamage.
|
|
||||||
// Let's refactor _applyDamage to just apply the final value since we calculated it here.
|
|
||||||
// actually _applyDamage handles the reduction of HP.
|
|
||||||
// Let's call a simplified version or just do it here.
|
|
||||||
|
|
||||||
enemy.hp -= damageToHp;
|
|
||||||
if (enemy.hp < 0) enemy.hp = 0;
|
|
||||||
|
|
||||||
_damageEventController.sink.add(
|
|
||||||
DamageEvent(
|
|
||||||
damage: damageToHp,
|
|
||||||
target: DamageTarget.enemy,
|
|
||||||
type: enemy.hasStatus(StatusEffectType.vulnerable) ? DamageType.vulnerable : DamageType.normal
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_addLog("Player dealt $damageToHp damage to Enemy.");
|
|
||||||
} else {
|
|
||||||
_addLog("Player's attack was fully blocked by armor.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try applying status effects
|
|
||||||
_tryApplyStatusEffects(player, enemy);
|
|
||||||
} else {
|
} else {
|
||||||
// Defense Success
|
// Defense Success - Impact is immediate, so process it directly
|
||||||
_effectEventController.sink.add(
|
final event = EffectEvent(
|
||||||
EffectEvent(
|
id:
|
||||||
id:
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
Random().nextInt(1000).toString(),
|
||||||
Random().nextInt(1000).toString(),
|
type: ActionType.defend,
|
||||||
type: ActionType.defend,
|
risk: risk,
|
||||||
risk: risk,
|
target: EffectTarget.player,
|
||||||
target: EffectTarget.player,
|
feedbackType: null,
|
||||||
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);
|
||||||
int armorGained = result.value;
|
handleImpact(event); // Process impact via handleImpact for safety
|
||||||
player.armor += armorGained;
|
|
||||||
_addLog("Player gained $armorGained armor.");
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Failure
|
// Failure
|
||||||
if (type == ActionType.attack) {
|
final eventId =
|
||||||
_addLog("Player's attack missed!");
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
_effectEventController.sink.add(
|
Random().nextInt(1000).toString();
|
||||||
EffectEvent(
|
BattleFeedbackType feedbackType = (type == ActionType.attack)
|
||||||
id:
|
? BattleFeedbackType.miss
|
||||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
: BattleFeedbackType.failed;
|
||||||
Random().nextInt(1000).toString(),
|
EffectTarget eventTarget = (type == ActionType.attack)
|
||||||
type: type,
|
? EffectTarget.enemy
|
||||||
risk: risk,
|
: EffectTarget.player;
|
||||||
target: EffectTarget.enemy,
|
Character eventTargetEntity = (type == ActionType.attack)
|
||||||
feedbackType: BattleFeedbackType.miss,
|
? enemy
|
||||||
),
|
: player;
|
||||||
);
|
|
||||||
} else {
|
final event = EffectEvent(
|
||||||
_addLog("Player's defense failed!");
|
id: eventId,
|
||||||
_effectEventController.sink.add(
|
type: type,
|
||||||
EffectEvent(
|
risk: risk,
|
||||||
id:
|
target: eventTarget,
|
||||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
feedbackType: feedbackType,
|
||||||
Random().nextInt(1000).toString(),
|
attacker: player,
|
||||||
type: type,
|
targetEntity: eventTargetEntity,
|
||||||
risk: risk,
|
isSuccess: false,
|
||||||
target: EffectTarget.player,
|
);
|
||||||
feedbackType: BattleFeedbackType.failed,
|
_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) {
|
if (enemy.isDead) {
|
||||||
|
// Check enemy death after player's action
|
||||||
_onVictory();
|
_onVictory();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -465,92 +417,83 @@ class BattleProvider with ChangeNotifier {
|
||||||
if (enemy.isDead) {
|
if (enemy.isDead) {
|
||||||
_onVictory();
|
_onVictory();
|
||||||
return;
|
return;
|
||||||
// return; // Already handled by _processStartTurnEffects if damage applied
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canAct && currentEnemyIntent != null) {
|
if (canAct && currentEnemyIntent != null) {
|
||||||
final intent = currentEnemyIntent!;
|
final intent = currentEnemyIntent!;
|
||||||
|
|
||||||
if (intent.type == EnemyActionType.defend) {
|
if (intent.type == EnemyActionType.defend) {
|
||||||
// Already handled in _generateEnemyIntent
|
// Apply defense immediately if successful, then send event
|
||||||
_addLog("Enemy maintains defensive stance.");
|
if (intent.isSuccess) {
|
||||||
|
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);
|
||||||
|
handleImpact(event); // Process impact via handleImpact for safety
|
||||||
|
} else {
|
||||||
|
_addLog("Enemy tried to defend but fumbled!");
|
||||||
|
final event = EffectEvent(
|
||||||
|
id:
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
|
Random().nextInt(1000).toString(),
|
||||||
|
type: ActionType.defend,
|
||||||
|
risk: intent.risk,
|
||||||
|
target: EffectTarget.enemy,
|
||||||
|
feedbackType:
|
||||||
|
BattleFeedbackType.failed, // Feedback type for failed defense
|
||||||
|
attacker: enemy,
|
||||||
|
targetEntity: enemy,
|
||||||
|
isSuccess: false,
|
||||||
|
);
|
||||||
|
_effectEventController.sink.add(event);
|
||||||
|
handleImpact(event); // Process impact via handleImpact for safety
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Attack Logic
|
// Attack Logic
|
||||||
if (intent.isSuccess) {
|
if (intent.isSuccess) {
|
||||||
_effectEventController.sink.add(
|
final event = EffectEvent(
|
||||||
EffectEvent(
|
id:
|
||||||
id:
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
Random().nextInt(1000).toString(),
|
||||||
Random().nextInt(1000).toString(),
|
type: ActionType.attack,
|
||||||
type: ActionType.attack,
|
risk: intent.risk,
|
||||||
risk: intent.risk,
|
target: EffectTarget.player,
|
||||||
target: EffectTarget.player,
|
feedbackType: null,
|
||||||
feedbackType: null, // 공격 성공이므로 feedbackType 없음
|
attacker: enemy,
|
||||||
),
|
targetEntity: player,
|
||||||
|
damageValue: intent.finalValue,
|
||||||
|
isSuccess: true,
|
||||||
);
|
);
|
||||||
|
_effectEventController.sink.add(event);
|
||||||
// Fix: Wait for animation to play before applying damage
|
// No Future.delayed here, BattleScreen will trigger impact
|
||||||
// Determine delay based on risk level
|
|
||||||
int delay = GameConfig.animDelayNormal;
|
|
||||||
if (intent.risk == RiskLevel.safe) delay = GameConfig.animDelaySafe;
|
|
||||||
if (intent.risk == RiskLevel.risky) delay = GameConfig.animDelayRisky;
|
|
||||||
|
|
||||||
await Future.delayed(Duration(milliseconds: delay));
|
|
||||||
|
|
||||||
int incomingDamage = intent.finalValue;
|
|
||||||
|
|
||||||
// Calculate Damage using Calculator
|
|
||||||
int damageToHp = CombatCalculator.calculateDamageToHp(
|
|
||||||
incomingDamage: incomingDamage,
|
|
||||||
currentArmor: player.armor,
|
|
||||||
isVulnerable: player.hasStatus(StatusEffectType.vulnerable)
|
|
||||||
);
|
|
||||||
|
|
||||||
int remainingArmor = CombatCalculator.calculateRemainingArmor(
|
|
||||||
incomingDamage: incomingDamage,
|
|
||||||
currentArmor: player.armor,
|
|
||||||
isVulnerable: player.hasStatus(StatusEffectType.vulnerable)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (player.armor > 0) {
|
|
||||||
int absorbed = player.armor - remainingArmor;
|
|
||||||
if (damageToHp == 0) {
|
|
||||||
_addLog("Armor absorbed all damage.");
|
|
||||||
} else {
|
|
||||||
_addLog("Armor absorbed $absorbed damage.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
player.armor = remainingArmor;
|
|
||||||
|
|
||||||
if (damageToHp > 0) {
|
|
||||||
player.hp -= damageToHp;
|
|
||||||
if (player.hp < 0) player.hp = 0;
|
|
||||||
|
|
||||||
_damageEventController.sink.add(
|
|
||||||
DamageEvent(
|
|
||||||
damage: damageToHp,
|
|
||||||
target: DamageTarget.player,
|
|
||||||
type: player.hasStatus(StatusEffectType.vulnerable) ? DamageType.vulnerable : DamageType.normal
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_addLog("Enemy dealt $damageToHp damage to Player HP.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try applying status effects from enemy equipment
|
|
||||||
_tryApplyStatusEffects(enemy, player);
|
|
||||||
} else {
|
} else {
|
||||||
_addLog("Enemy's ${intent.risk.name} attack missed!");
|
_addLog("Enemy's ${intent.risk.name} attack missed!");
|
||||||
_effectEventController.sink.add(
|
final event = EffectEvent(
|
||||||
EffectEvent(
|
id:
|
||||||
id:
|
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
Random().nextInt(1000).toString(),
|
||||||
Random().nextInt(1000).toString(),
|
type: ActionType.attack,
|
||||||
type: ActionType.attack, // 적의 공격이므로 ActionType.attack
|
risk: intent.risk,
|
||||||
risk: intent.risk,
|
target: EffectTarget.player,
|
||||||
target: EffectTarget.player, // 플레이어가 회피했으므로 플레이어 위치에 이펙트
|
feedbackType: BattleFeedbackType.miss,
|
||||||
feedbackType: BattleFeedbackType.miss, // 변경: 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) {
|
} else if (!canAct) {
|
||||||
|
|
@ -559,6 +502,26 @@ class BattleProvider with ChangeNotifier {
|
||||||
_addLog("Enemy did nothing.");
|
_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
|
// Generate next intent
|
||||||
if (!player.isDead) {
|
if (!player.isDead) {
|
||||||
_generateEnemyIntent();
|
_generateEnemyIntent();
|
||||||
|
|
@ -581,50 +544,6 @@ class BattleProvider with ChangeNotifier {
|
||||||
notifyListeners();
|
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<StatusEffect> effectsToApply = CombatCalculator.getAppliedEffects(attacker);
|
|
||||||
|
|
||||||
for (var effect in effectsToApply) {
|
|
||||||
target.addStatusEffect(effect);
|
|
||||||
_addLog("Applied ${effect.type.name} to ${target.name}!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void _addLog(String message) {
|
void _addLog(String message) {
|
||||||
_logManager.addLog(message);
|
_logManager.addLog(message);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -634,7 +553,10 @@ class BattleProvider with ChangeNotifier {
|
||||||
// Calculate Gold Reward
|
// Calculate Gold Reward
|
||||||
// Base 10 + (Stage * 5) + Random variance
|
// Base 10 + (Stage * 5) + Random variance
|
||||||
final random = Random();
|
final random = Random();
|
||||||
int goldReward = GameConfig.baseGoldReward + (stage * GameConfig.goldRewardPerStage) + random.nextInt(GameConfig.goldRewardVariance);
|
int goldReward =
|
||||||
|
GameConfig.baseGoldReward +
|
||||||
|
(stage * GameConfig.goldRewardPerStage) +
|
||||||
|
random.nextInt(GameConfig.goldRewardVariance);
|
||||||
|
|
||||||
player.gold += goldReward;
|
player.gold += goldReward;
|
||||||
_lastGoldReward = goldReward; // Store for UI display
|
_lastGoldReward = goldReward; // Store for UI display
|
||||||
|
|
@ -662,7 +584,8 @@ class BattleProvider with ChangeNotifier {
|
||||||
if (isTier1) {
|
if (isTier1) {
|
||||||
// Tier 1 Elite: Guaranteed Rare
|
// Tier 1 Elite: Guaranteed Rare
|
||||||
minRarity = ItemRarity.rare;
|
minRarity = ItemRarity.rare;
|
||||||
maxRarity = ItemRarity.rare; // Or allow higher? Request said "Guaranteed Rare 1 drop". Let's fix to Rare.
|
maxRarity = ItemRarity
|
||||||
|
.rare; // Or allow higher? Request said "Guaranteed Rare 1 drop". Let's fix to Rare.
|
||||||
} else {
|
} else {
|
||||||
// Tier 2/3 Elite: Guaranteed Legendary
|
// Tier 2/3 Elite: Guaranteed Legendary
|
||||||
minRarity = ItemRarity.legendary;
|
minRarity = ItemRarity.legendary;
|
||||||
|
|
@ -681,7 +604,7 @@ class BattleProvider with ChangeNotifier {
|
||||||
ItemTemplate? item = ItemTable.getRandomItem(
|
ItemTemplate? item = ItemTable.getRandomItem(
|
||||||
tier: currentTier,
|
tier: currentTier,
|
||||||
minRarity: minRarity,
|
minRarity: minRarity,
|
||||||
maxRarity: maxRarity
|
maxRarity: maxRarity,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
|
|
@ -905,4 +828,145 @@ class BattleProvider with ChangeNotifier {
|
||||||
}
|
}
|
||||||
notifyListeners();
|
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<StatusEffect> 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).
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,9 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
_playerAnimKey.currentState
|
_playerAnimKey.currentState
|
||||||
?.animateAttack(offset, () {
|
?.animateAttack(offset, () {
|
||||||
showEffect(); // Show Effect at Impact!
|
showEffect(); // Show Effect at Impact!
|
||||||
|
// Trigger impact logic in provider
|
||||||
|
context.read<BattleProvider>().handleImpact(event);
|
||||||
|
|
||||||
// Shake and Explosion ONLY for Risky
|
// Shake and Explosion ONLY for Risky
|
||||||
if (event.risk == RiskLevel.risky) {
|
if (event.risk == RiskLevel.risky) {
|
||||||
_shakeKey.currentState?.shake();
|
_shakeKey.currentState?.shake();
|
||||||
|
|
@ -300,6 +303,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
|
|
||||||
if (!enableAnim) {
|
if (!enableAnim) {
|
||||||
showEffect(); // Just show effect if anim disabled
|
showEffect(); // Just show effect if anim disabled
|
||||||
|
context.read<BattleProvider>().handleImpact(event); // Process impact immediately if anim disabled
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,6 +328,8 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
_enemyAnimKey.currentState
|
_enemyAnimKey.currentState
|
||||||
?.animateAttack(offset, () {
|
?.animateAttack(offset, () {
|
||||||
showEffect(); // Show Effect at Impact!
|
showEffect(); // Show Effect at Impact!
|
||||||
|
// Trigger impact logic in provider
|
||||||
|
context.read<BattleProvider>().handleImpact(event);
|
||||||
|
|
||||||
// Shake and Explosion ONLY for Risky (Enemy can also do risky attacks)
|
// Shake and Explosion ONLY for Risky (Enemy can also do risky attacks)
|
||||||
if (event.risk == RiskLevel.risky) {
|
if (event.risk == RiskLevel.risky) {
|
||||||
|
|
@ -355,6 +361,8 @@ class _BattleScreenState extends State<BattleScreen> {
|
||||||
} else {
|
} else {
|
||||||
// Not a player/enemy attack movement, show immediately
|
// Not a player/enemy attack movement, show immediately
|
||||||
showEffect();
|
showEffect();
|
||||||
|
// Also process impact immediately for non-animating effects (e.g., failed defense, stun)
|
||||||
|
context.read<BattleProvider>().handleImpact(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 68. Global Animation Delay Adjustment
|
||||||
|
|
||||||
|
## 1. 목표 (Goal)
|
||||||
|
- 플레이어와 적 모두에게 적용되는 애니메이션 딜레이 설정(`GameConfig`)을 조정하여, 데미지 텍스트가 애니메이션 타격(Impact)보다 먼저 뜨는 현상을 근본적으로 해결합니다.
|
||||||
|
|
||||||
|
## 2. 원인 (Cause)
|
||||||
|
- `GameConfig`의 대기 시간이 `BattleAnimationWidget`의 재생 시간과 **완벽하게 동일(ms 단위)**하게 설정되어 있습니다.
|
||||||
|
- 코드 실행 컨텍스트 차이로 인해 `Timer`(로직 대기)가 `Animation`(UI 렌더링)보다 미세하게 먼저 완료될 수 있어, 텍스트가 먼저 뜨는 "경쟁 상태(Race Condition)"가 발생합니다.
|
||||||
|
|
||||||
|
## 3. 해결 방안 (Solution)
|
||||||
|
- `GameConfig`의 `animDelay...` 값들을 기존 값에서 **+100ms** 증가시킵니다.
|
||||||
|
- 이는 플레이어와 적 모두에게 동일하게 적용되므로 로직의 일관성을 해치지 않으면서, 시각적으로 "타격 후 텍스트"라는 자연스러운 순서를 보장합니다.
|
||||||
|
|
||||||
|
## 4. 변경 값
|
||||||
|
- Safe: 500 -> 600
|
||||||
|
- Normal: 400 -> 500
|
||||||
|
- Risky: 1100 -> 1200
|
||||||
|
|
||||||
|
## 5. 기대 효과 (Expected Outcome)
|
||||||
|
- 플레이어와 적 모두 공격 시 애니메이션이 목표에 도달한 직후 데미지 텍스트가 표시됨.
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 69. Sync Damage to Animation Impact (UI-Driven)
|
||||||
|
|
||||||
|
## 1. 목표 (Goal)
|
||||||
|
- 플레이어와 적 모두의 공격 시 데미지 텍스트 출력이 애니메이션 타격(Impact) 순간과 완벽하게 동기화되도록 수정합니다.
|
||||||
|
|
||||||
|
## 2. 문제점 (Problem)
|
||||||
|
- 기존에는 `BattleProvider`가 `Future.delayed`로 애니메이션 시간을 예측했으나, 이는 UI의 실제 Impact 시점과 미묘하게 어긋나 데미지 텍스트가 먼저 뜨는 현상이 발생했습니다.
|
||||||
|
|
||||||
|
## 3. 해결 방안 (Solution)
|
||||||
|
- **UI (BattleScreen)가 애니메이션 Impact 시점을 `BattleProvider`에게 직접 알려주어 데미지 처리를 트리거**하는 "UI 주도 Impact 처리" 방식으로 전환합니다.
|
||||||
|
|
||||||
|
## 4. 구현 계획 (Implementation Plan)
|
||||||
|
|
||||||
|
### A. `EffectEvent` 데이터 확장 (`lib/game/model/effect_event.dart`)
|
||||||
|
- `EffectEvent`에 `attacker`, `target` (Character 객체), `damage`, `risk`, `isSuccess` 등 Impact 시점에 필요한 모든 정보를 담습니다.
|
||||||
|
|
||||||
|
### B. `BattleProvider` 수정 (`lib/providers/battle_provider.dart`)
|
||||||
|
1. **`Future.delayed` 제거:** `playerAction` 및 `_enemyTurn`의 공격 로직에서 `await Future.delayed(...)`를 제거합니다.
|
||||||
|
2. **데미지 처리 로직 추출:** 공격 Impact 시점에 발생해야 할 모든 로직(HP 감소, `DamageEvent` 전송, 로그 기록, 상태이상 적용)을 `_processAttackImpact`라는 `private` 또는 `public` 메서드로 추출합니다.
|
||||||
|
3. **새로운 `public` 메서드 추가:** `BattleProvider.handleAttackImpact(String eventId, Character attacker, Character target, int damage, RiskLevel risk, bool isSuccess)`와 같은 메서드를 만들어, `BattleScreen`에서 Impact 시점에 호출하도록 합니다.
|
||||||
|
|
||||||
|
### C. `BattleScreen` 수정 (`lib/screens/battle_screen.dart`)
|
||||||
|
1. `_addFloatingEffect` 메서드 내에서 `animateAttack`를 호출할 때:
|
||||||
|
- `onImpact` 콜백 내에서 `context.read<BattleProvider>().handleAttackImpact(...)`를 호출합니다.
|
||||||
|
- `EffectEvent`에 담긴 정보를 기반으로 `handleAttackImpact`에 인자를 전달합니다.
|
||||||
|
|
||||||
|
## 5. 기대 효과 (Expected Outcome)
|
||||||
|
- 애니메이션과 데미지 텍스트 출력이 완벽하게 동기화되어, 게임의 타격감이 대폭 향상됩니다.
|
||||||
|
- 로직과 UI 간의 역할 분리가 명확해집니다.
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# 70. Fix Player Action Crash and Double Damage
|
||||||
|
|
||||||
|
## 1. 문제 (Problem)
|
||||||
|
- **Crash:** `BattleProvider.playerAction`에서 생성하는 `EffectEvent`에 `attacker`, `targetEntity`, `damageValue` 등이 누락되어 있어, UI가 `handleImpact`를 호출할 때 `event.attacker!` 등에서 Null Pointer Exception이 발생함.
|
||||||
|
- **Double Damage:** `playerAction` 메서드 내부에 구버전의 데미지 적용 로직이 남아 있어, 이를 수정하지 않으면 데미지가 두 번 들어감.
|
||||||
|
|
||||||
|
## 2. 해결 방안 (Solution)
|
||||||
|
- `playerAction` 메서드를 대대적으로 수정하여 `_enemyTurn`과 동일한 패턴을 적용합니다.
|
||||||
|
- **즉시 데미지 적용 로직 삭제:** HP/Armor 감소 로직을 제거합니다.
|
||||||
|
- **`EffectEvent` 완전체 생성:** `attacker`, `targetEntity`, `damageValue` 등을 모두 채워서 이벤트를 생성합니다.
|
||||||
|
- **위임:** 모든 데미지 처리는 `EffectEvent`를 통해 `handleImpact` -> `_processAttackImpact`로 위임됩니다.
|
||||||
|
|
||||||
|
## 3. 기대 효과 (Expected Outcome)
|
||||||
|
- 앱 크래시 해결.
|
||||||
|
- 플레이어 공격 시 애니메이션과 데미지 적용 시점이 정확히 일치.
|
||||||
|
- 데미지 중복 적용 방지.
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# 71. Fix Null Pointer Exception on Failed Actions
|
||||||
|
|
||||||
|
## 1. 문제 (Problem)
|
||||||
|
- `BattleProvider`에서 공격 실패(`Miss`) 또는 방어(`Defend`) 시 `_processAttackImpact`를 직접 호출하고 있음.
|
||||||
|
- `_processAttackImpact`는 `damageValue!`와 같이 강제 언래핑을 수행하므로, 실패한 공격(`damageValue`가 null)일 경우 앱이 크래시됨(`Unexpected null value`).
|
||||||
|
|
||||||
|
## 2. 해결 방안 (Solution)
|
||||||
|
- `playerAction` 및 `_enemyTurn` 메서드에서 `_processAttackImpact` 직접 호출을 모두 `handleImpact(event)` 호출로 변경합니다.
|
||||||
|
- `handleImpact` 메서드 내부에 있는 `if (!event.isSuccess!) return;` 안전 장치를 활용하여 크래시를 방지합니다.
|
||||||
|
|
||||||
|
## 3. 기대 효과 (Expected Outcome)
|
||||||
|
- 공격 실패 시에도 앱이 정상적으로 동작하며, 로그만 출력되고 데미지 로직은 스킵됨.
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# 72. Delay Enemy Intent Generation
|
||||||
|
|
||||||
|
## 1. 문제 (Problem)
|
||||||
|
- 적이 공격하는 도중에 `_generateEnemyIntent()`가 호출되어, 다음 턴 행동(예: 방어)이 미리 실행되고 이펙트가 겹쳐 보이는 현상 발생.
|
||||||
|
|
||||||
|
## 2. 해결 방안 (Solution)
|
||||||
|
- `BattleProvider._enemyTurn` 메서드에서 `_generateEnemyIntent()` 호출 전에 **충분한 대기 시간(`GameConfig.animDelayEnemyTurn` 또는 공격 애니메이션 시간)**을 둡니다.
|
||||||
|
- 이를 통해 적의 현재 행동 연출이 완전히 끝난 뒤에 다음 행동을 준비하도록 순서를 정리합니다.
|
||||||
|
|
||||||
|
## 3. 기대 효과 (Expected Outcome)
|
||||||
|
- 적 공격 -> 복귀 -> (잠시 후) -> 다음 턴 방어 준비(이펙트) 순서로 자연스럽게 진행됨.
|
||||||
Loading…
Reference in New Issue