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;
|
||||
|
||||
// Animations (Duration in milliseconds)
|
||||
static const int animDelaySafe = 500;
|
||||
static const int animDelayNormal = 400;
|
||||
static const int animDelayRisky = 1100;
|
||||
static const int animDelaySafe = 600; // 500 + 100 buffer
|
||||
static const int animDelayNormal = 500; // 400 + 100 buffer
|
||||
static const int animDelayRisky = 1200; // 1100 + 100 buffer
|
||||
static const int animDelayEnemyTurn = 1000;
|
||||
|
||||
// Save System
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import '../enums.dart';
|
||||
import 'entity.dart'; // Import Character entity
|
||||
|
||||
enum EffectTarget { player, enemy }
|
||||
|
||||
|
|
@ -9,11 +10,23 @@ class EffectEvent {
|
|||
final EffectTarget target; // 이펙트가 표시될 위치의 대상
|
||||
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({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.risk,
|
||||
required this.target,
|
||||
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) {
|
||||
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);
|
||||
|
||||
|
|
@ -251,7 +254,6 @@ class BattleProvider with ChangeNotifier {
|
|||
}
|
||||
|
||||
/// Handle player's action choice
|
||||
|
||||
Future<void> playerAction(ActionType type, RiskLevel risk) async {
|
||||
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
|
||||
return;
|
||||
|
|
@ -287,93 +289,39 @@ class BattleProvider with ChangeNotifier {
|
|||
_addLog("Player chose to ${type.name} with ${risk.name} risk.");
|
||||
|
||||
// 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(
|
||||
risk: risk,
|
||||
luck: player.totalLuck,
|
||||
baseValue: baseValue
|
||||
baseValue: baseValue,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
if (type == ActionType.attack) {
|
||||
int damage = result.value;
|
||||
|
||||
final eventId =
|
||||
final event = EffectEvent(
|
||||
id:
|
||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||
Random().nextInt(1000).toString();
|
||||
_effectEventController.sink.add(
|
||||
EffectEvent(
|
||||
id: eventId,
|
||||
Random().nextInt(1000).toString(),
|
||||
type: ActionType.attack,
|
||||
risk: risk,
|
||||
target: EffectTarget.enemy,
|
||||
feedbackType: null,
|
||||
),
|
||||
attacker: player,
|
||||
targetEntity: enemy,
|
||||
damageValue: damage,
|
||||
isSuccess: true,
|
||||
);
|
||||
|
||||
// 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 {
|
||||
// Defense Success
|
||||
_effectEventController.sink.add(
|
||||
EffectEvent(
|
||||
event,
|
||||
); // No Future.delayed here, BattleScreen will trigger impact
|
||||
} else {
|
||||
// Defense Success - Impact is immediate, so process it directly
|
||||
final event = EffectEvent(
|
||||
id:
|
||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||
Random().nextInt(1000).toString(),
|
||||
|
|
@ -381,45 +329,49 @@ class BattleProvider with ChangeNotifier {
|
|||
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,
|
||||
);
|
||||
|
||||
int armorGained = result.value;
|
||||
player.armor += armorGained;
|
||||
_addLog("Player gained $armorGained armor.");
|
||||
_effectEventController.sink.add(event);
|
||||
handleImpact(event); // Process impact via handleImpact for safety
|
||||
}
|
||||
} else {
|
||||
// Failure
|
||||
if (type == ActionType.attack) {
|
||||
_addLog("Player's attack missed!");
|
||||
_effectEventController.sink.add(
|
||||
EffectEvent(
|
||||
id:
|
||||
final eventId =
|
||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||
Random().nextInt(1000).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: EffectTarget.enemy,
|
||||
feedbackType: BattleFeedbackType.miss,
|
||||
),
|
||||
target: eventTarget,
|
||||
feedbackType: feedbackType,
|
||||
attacker: player,
|
||||
targetEntity: eventTargetEntity,
|
||||
isSuccess: false,
|
||||
);
|
||||
} 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,
|
||||
feedbackType: BattleFeedbackType.failed,
|
||||
),
|
||||
);
|
||||
}
|
||||
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) {
|
||||
// Check enemy death after player's action
|
||||
_onVictory();
|
||||
return;
|
||||
}
|
||||
|
|
@ -465,92 +417,83 @@ class BattleProvider with ChangeNotifier {
|
|||
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.");
|
||||
// Apply defense immediately if successful, then send event
|
||||
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 {
|
||||
// Attack Logic
|
||||
if (intent.isSuccess) {
|
||||
_effectEventController.sink.add(
|
||||
EffectEvent(
|
||||
final event = EffectEvent(
|
||||
id:
|
||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||
Random().nextInt(1000).toString(),
|
||||
type: ActionType.attack,
|
||||
risk: intent.risk,
|
||||
target: EffectTarget.player,
|
||||
feedbackType: null, // 공격 성공이므로 feedbackType 없음
|
||||
),
|
||||
feedbackType: null,
|
||||
attacker: enemy,
|
||||
targetEntity: player,
|
||||
damageValue: intent.finalValue,
|
||||
isSuccess: true,
|
||||
);
|
||||
|
||||
// Fix: Wait for animation to play before applying damage
|
||||
// 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);
|
||||
_effectEventController.sink.add(event);
|
||||
// No Future.delayed here, BattleScreen will trigger impact
|
||||
} else {
|
||||
_addLog("Enemy's ${intent.risk.name} attack missed!");
|
||||
_effectEventController.sink.add(
|
||||
EffectEvent(
|
||||
final event = EffectEvent(
|
||||
id:
|
||||
DateTime.now().millisecondsSinceEpoch.toString() +
|
||||
Random().nextInt(1000).toString(),
|
||||
type: ActionType.attack, // 적의 공격이므로 ActionType.attack
|
||||
type: ActionType.attack,
|
||||
risk: intent.risk,
|
||||
target: EffectTarget.player, // 플레이어가 회피했으므로 플레이어 위치에 이펙트
|
||||
feedbackType: BattleFeedbackType.miss, // 변경: MISS 피드백
|
||||
),
|
||||
target: EffectTarget.player,
|
||||
feedbackType: BattleFeedbackType.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) {
|
||||
|
|
@ -559,6 +502,26 @@ class BattleProvider with ChangeNotifier {
|
|||
_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
|
||||
if (!player.isDead) {
|
||||
_generateEnemyIntent();
|
||||
|
|
@ -581,50 +544,6 @@ class BattleProvider with ChangeNotifier {
|
|||
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) {
|
||||
_logManager.addLog(message);
|
||||
notifyListeners();
|
||||
|
|
@ -634,7 +553,10 @@ class BattleProvider with ChangeNotifier {
|
|||
// Calculate Gold Reward
|
||||
// Base 10 + (Stage * 5) + Random variance
|
||||
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;
|
||||
_lastGoldReward = goldReward; // Store for UI display
|
||||
|
|
@ -662,7 +584,8 @@ class BattleProvider with ChangeNotifier {
|
|||
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.
|
||||
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;
|
||||
|
|
@ -681,7 +604,7 @@ class BattleProvider with ChangeNotifier {
|
|||
ItemTemplate? item = ItemTable.getRandomItem(
|
||||
tier: currentTier,
|
||||
minRarity: minRarity,
|
||||
maxRarity: maxRarity
|
||||
maxRarity: maxRarity,
|
||||
);
|
||||
|
||||
if (item != null) {
|
||||
|
|
@ -905,4 +828,145 @@ class BattleProvider with ChangeNotifier {
|
|||
}
|
||||
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
|
||||
?.animateAttack(offset, () {
|
||||
showEffect(); // Show Effect at Impact!
|
||||
// Trigger impact logic in provider
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
|
||||
// Shake and Explosion ONLY for Risky
|
||||
if (event.risk == RiskLevel.risky) {
|
||||
_shakeKey.currentState?.shake();
|
||||
|
|
@ -300,6 +303,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
|
||||
if (!enableAnim) {
|
||||
showEffect(); // Just show effect if anim disabled
|
||||
context.read<BattleProvider>().handleImpact(event); // Process impact immediately if anim disabled
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -324,6 +328,8 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
_enemyAnimKey.currentState
|
||||
?.animateAttack(offset, () {
|
||||
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)
|
||||
if (event.risk == RiskLevel.risky) {
|
||||
|
|
@ -355,6 +361,8 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
} else {
|
||||
// Not a player/enemy attack movement, show immediately
|
||||
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