update
This commit is contained in:
parent
135bf26332
commit
9540dd22a3
|
|
@ -16,6 +16,7 @@ class EffectEvent {
|
|||
final int? damageValue; // 공격 시 데미지 값
|
||||
final bool? isSuccess; // 성공 여부 (Missed or Failed가 아닌 경우)
|
||||
final int? armorGained; // 방어 시 얻는 방어도
|
||||
final bool triggersTurnChange; // 턴 전환 트리거 여부
|
||||
|
||||
EffectEvent({
|
||||
required this.id,
|
||||
|
|
@ -28,5 +29,6 @@ class EffectEvent {
|
|||
this.damageValue,
|
||||
this.isSuccess,
|
||||
this.armorGained,
|
||||
this.triggersTurnChange = true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 +433,18 @@ 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)
|
||||
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() {
|
||||
|
|
@ -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) {
|
||||
|
||||
final intent = currentEnemyIntent!;
|
||||
|
||||
|
||||
|
||||
if (intent.type == EnemyActionType.defend) {
|
||||
// Defensive Action (Non-animating)
|
||||
|
||||
// Already handled in _generateEnemyIntent
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
} 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
|
||||
if (!player.isDead) {
|
||||
// Generate NEXT intent
|
||||
_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
|
||||
|
|
|
|||
|
|
@ -50,10 +50,12 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
bool _showLogs = false;
|
||||
bool _isPlayerAttacking = false; // Player Attack Animation State
|
||||
bool _isEnemyAttacking = false; // Enemy Attack Animation State
|
||||
DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print("[UI Debug] BattleScreen initialized: ${hashCode}"); // Debug Log
|
||||
final battleProvider = context.read<BattleProvider>();
|
||||
_damageSubscription = battleProvider.damageStream.listen(
|
||||
_addFloatingDamageText,
|
||||
|
|
@ -71,7 +73,6 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
}
|
||||
|
||||
void _addFloatingDamageText(DamageEvent event) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
|
||||
GlobalKey targetKey = event.target == DamageTarget.player
|
||||
|
|
@ -119,7 +120,6 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
final Set<String> _processedEffectIds = {};
|
||||
|
|
@ -128,14 +128,24 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
if (_processedEffectIds.contains(event.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_processedEffectIds.add(event.id);
|
||||
if (_processedEffectIds.length > 20) {
|
||||
_processedEffectIds.remove(_processedEffectIds.first);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
|
||||
// Feedback Text Cooldown
|
||||
if (event.feedbackType != null) {
|
||||
print("[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}");
|
||||
if (_lastFeedbackTime != null &&
|
||||
DateTime.now().difference(_lastFeedbackTime!).inMilliseconds < 300) {
|
||||
return; // Skip if too soon
|
||||
}
|
||||
_lastFeedbackTime = DateTime.now();
|
||||
}
|
||||
|
||||
GlobalKey targetKey = event.target == EffectTarget.player
|
||||
? _playerKey
|
||||
: _enemyKey;
|
||||
|
|
@ -162,7 +172,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
void showEffect() {
|
||||
if (!mounted) return;
|
||||
|
||||
// feedbackType이 존재하면 해당 텍스트를 표시하고 기존 이펙트 아이콘은 건너뜜
|
||||
// Handle Feedback Text (MISS / FAILED)
|
||||
if (event.feedbackType != null) {
|
||||
String feedbackText;
|
||||
Color feedbackColor;
|
||||
|
|
@ -176,21 +186,29 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
feedbackColor = ThemeConfig.failedText;
|
||||
break;
|
||||
default:
|
||||
feedbackText = ""; // Should not happen with current enums
|
||||
feedbackText = "";
|
||||
feedbackColor = ThemeConfig.textColorWhite;
|
||||
}
|
||||
|
||||
final String id = UniqueKey().toString();
|
||||
|
||||
// Prevent duplicate feedback texts for the same event ID (UI Level)
|
||||
if (_floatingFeedbackTexts.any((e) => e.eventId == event.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_floatingFeedbackTexts.clear(); // Clear previous texts
|
||||
_floatingFeedbackTexts.add(
|
||||
FeedbackTextData(
|
||||
id: id,
|
||||
eventId: event.id,
|
||||
widget: Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: FloatingFeedbackText(
|
||||
key: ValueKey(id),
|
||||
feedback: feedbackText,
|
||||
feedback: "$feedbackText (${event.id.substring(0, 4)})",
|
||||
color: feedbackColor,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
|
|
@ -204,10 +222,10 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
),
|
||||
);
|
||||
});
|
||||
return; // feedbackType이 있으면 아이콘 이펙트는 표시하지 않음
|
||||
return; // Return early for feedback
|
||||
}
|
||||
|
||||
// Use BattleConfig for Icon, Color, and Size
|
||||
// Handle Icon Effect
|
||||
IconData icon = BattleConfig.getIcon(event.type);
|
||||
Color color = BattleConfig.getColor(event.type, event.risk);
|
||||
double size = BattleConfig.getSize(event.risk);
|
||||
|
|
@ -240,11 +258,10 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
});
|
||||
}
|
||||
|
||||
// 1. Attack Animation Trigger (All Risk Levels)
|
||||
// 1. Player Attack Animation Trigger (Success or Miss)
|
||||
if (event.type == ActionType.attack &&
|
||||
event.target == EffectTarget.enemy &&
|
||||
event.feedbackType == null) {
|
||||
// Calculate target position (Enemy) relative to Player
|
||||
event.target == EffectTarget.enemy) {
|
||||
|
||||
final RenderBox? playerBox =
|
||||
_playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? enemyBox =
|
||||
|
|
@ -253,30 +270,26 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
if (playerBox != null && enemyBox != null) {
|
||||
final playerPos = playerBox.localToGlobal(Offset.zero);
|
||||
final enemyPos = enemyBox.localToGlobal(Offset.zero);
|
||||
|
||||
final offset = enemyPos - playerPos;
|
||||
|
||||
// Start Animation: Hide Stats
|
||||
setState(() {
|
||||
_isPlayerAttacking = true;
|
||||
});
|
||||
|
||||
// Force SAFE animation for MISS, otherwise use event risk
|
||||
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
|
||||
|
||||
_playerAnimKey.currentState
|
||||
?.animateAttack(offset, () {
|
||||
showEffect(); // Show Effect at Impact!
|
||||
// Trigger impact logic in provider
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
|
||||
// Shake and Explosion ONLY for Risky
|
||||
if (event.risk == RiskLevel.risky) {
|
||||
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
|
||||
_shakeKey.currentState?.shake();
|
||||
|
||||
RenderBox? stackBox =
|
||||
_stackKey.currentContext?.findRenderObject()
|
||||
as RenderBox?;
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackBox != null) {
|
||||
Offset localEnemyPos = stackBox.globalToLocal(enemyPos);
|
||||
// Center of the enemy card roughly
|
||||
localEnemyPos += Offset(
|
||||
enemyBox.size.width / 2,
|
||||
enemyBox.size.height / 2,
|
||||
|
|
@ -284,9 +297,8 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
_explosionKey.currentState?.explode(localEnemyPos);
|
||||
}
|
||||
}
|
||||
}, event.risk)
|
||||
}, animRisk)
|
||||
.then((_) {
|
||||
// End Animation: Show Stats
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPlayerAttacking = false;
|
||||
|
|
@ -294,20 +306,19 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
}
|
||||
});
|
||||
}
|
||||
} else if (event.type == ActionType.attack &&
|
||||
event.target == EffectTarget.player &&
|
||||
event.feedbackType == null) {
|
||||
}
|
||||
// 2. Enemy Attack Animation Trigger (Success or Miss)
|
||||
else if (event.type == ActionType.attack &&
|
||||
event.target == EffectTarget.player) {
|
||||
|
||||
// Check Settings
|
||||
bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations;
|
||||
|
||||
if (!enableAnim) {
|
||||
showEffect(); // Just show effect if anim disabled
|
||||
context.read<BattleProvider>().handleImpact(event); // Process impact immediately if anim disabled
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Enemy Attack Animation Trigger
|
||||
final RenderBox? playerBox =
|
||||
_playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? enemyBox =
|
||||
|
|
@ -316,29 +327,24 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
if (playerBox != null && enemyBox != null) {
|
||||
final playerPos = playerBox.localToGlobal(Offset.zero);
|
||||
final enemyPos = enemyBox.localToGlobal(Offset.zero);
|
||||
|
||||
// Enemy moves TO Player
|
||||
final offset = playerPos - enemyPos;
|
||||
|
||||
// Start Animation: Hide Stats
|
||||
setState(() {
|
||||
_isEnemyAttacking = true;
|
||||
});
|
||||
|
||||
// Force SAFE animation for MISS
|
||||
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
|
||||
|
||||
_enemyAnimKey.currentState
|
||||
?.animateAttack(offset, () {
|
||||
showEffect(); // Show Effect at Impact!
|
||||
// Trigger impact logic in provider
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
|
||||
// Shake and Explosion ONLY for Risky (Enemy can also do risky attacks)
|
||||
if (event.risk == RiskLevel.risky) {
|
||||
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
|
||||
_shakeKey.currentState?.shake();
|
||||
|
||||
// Explosion on Player
|
||||
RenderBox? stackBox =
|
||||
_stackKey.currentContext?.findRenderObject()
|
||||
as RenderBox?;
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackBox != null) {
|
||||
Offset localPlayerPos = stackBox.globalToLocal(playerPos);
|
||||
localPlayerPos += Offset(
|
||||
|
|
@ -348,9 +354,8 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
_explosionKey.currentState?.explode(localPlayerPos);
|
||||
}
|
||||
}
|
||||
}, event.risk)
|
||||
}, animRisk)
|
||||
.then((_) {
|
||||
// End Animation: Show Stats
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isEnemyAttacking = false;
|
||||
|
|
@ -358,13 +363,38 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Not a player/enemy attack movement, show immediately
|
||||
}
|
||||
// 3. Defend Animation Trigger (Success OR Failure)
|
||||
else if (event.type == ActionType.defend) {
|
||||
if (event.target == EffectTarget.player) {
|
||||
_playerAnimKey.currentState?.animateDefense(() {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
});
|
||||
} else if (event.target == EffectTarget.enemy) {
|
||||
_enemyAnimKey.currentState?.animateDefense(() {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
});
|
||||
} else {
|
||||
showEffect();
|
||||
// Also process impact immediately for non-animating effects (e.g., failed defense, stun)
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
}
|
||||
}
|
||||
// 4. Others (Feedback for attacks, Buffs, etc.)
|
||||
else {
|
||||
showEffect();
|
||||
|
||||
// If it's a feedback event (MISS/FAILED for attacks), wait 500ms.
|
||||
if (event.feedbackType != null) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) context.read<BattleProvider>().handleImpact(event);
|
||||
});
|
||||
} else {
|
||||
// Success events (Icon)
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showRiskLevelSelection(BuildContext context, ActionType actionType) {
|
||||
|
|
@ -452,6 +482,11 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sync animation setting to provider logic
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
context.read<BattleProvider>().skipAnimations =
|
||||
!settings.enableEnemyAnimations;
|
||||
|
||||
return ResponsiveContainer(
|
||||
child: Consumer<BattleProvider>(
|
||||
builder: (context, battleProvider, child) {
|
||||
|
|
@ -616,7 +651,9 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
child: SimpleDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Text("${AppStrings.victory} ${AppStrings.chooseReward}"),
|
||||
const Text(
|
||||
"${AppStrings.victory} ${AppStrings.chooseReward}",
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
|
|||
|
|
@ -89,10 +89,13 @@ class SettingsScreen extends StatelessWidget {
|
|||
_showConfirmationDialog(
|
||||
context,
|
||||
title: '${AppStrings.returnToMenu}?',
|
||||
content: 'Unsaved progress may be lost. (Progress is saved automatically after each stage)',
|
||||
content:
|
||||
'Unsaved progress may be lost. (Progress is saved automatically after each stage)',
|
||||
onConfirm: () {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const MainMenuScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MainMenuScreen(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
|
|
@ -105,7 +108,12 @@ class SettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void _showConfirmationDialog(BuildContext context, {required String title, required String content, required VoidCallback onConfirm}) {
|
||||
void _showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String content,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -121,7 +129,10 @@ class SettingsScreen extends StatelessWidget {
|
|||
Navigator.pop(context);
|
||||
onConfirm();
|
||||
},
|
||||
child: const Text(AppStrings.confirm, style: TextStyle(color: Colors.red)),
|
||||
child: const Text(
|
||||
AppStrings.confirm,
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -105,6 +105,41 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> animateDefense(VoidCallback onImpact) async {
|
||||
// Defense: Wobble/Shake horizontally
|
||||
_translateController.duration = const Duration(milliseconds: 800);
|
||||
|
||||
// Sequence: Left -> Right -> Center
|
||||
_translateAnimation =
|
||||
TweenSequence<Offset>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween<Offset>(begin: Offset.zero, end: const Offset(-10, 0)),
|
||||
weight: 25,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween<Offset>(
|
||||
begin: const Offset(-10, 0),
|
||||
end: const Offset(10, 0),
|
||||
),
|
||||
weight: 50,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween<Offset>(begin: const Offset(10, 0), end: Offset.zero),
|
||||
weight: 25,
|
||||
),
|
||||
]).animate(
|
||||
CurvedAnimation(
|
||||
parent: _translateController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
|
||||
await _translateController.forward();
|
||||
if (!mounted) return;
|
||||
onImpact();
|
||||
_translateController.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ class CharacterStatusCard extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 8), // 아이콘과 정보 사이 간격
|
||||
|
||||
if (!isPlayer)
|
||||
if (!isPlayer && !hideStats)
|
||||
Consumer<BattleProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.currentEnemyIntent != null && !character.isDead) {
|
||||
|
|
|
|||
|
|
@ -286,6 +286,11 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
|
|||
class FeedbackTextData {
|
||||
final String id;
|
||||
final Widget widget;
|
||||
final String eventId; // To prevent duplicates
|
||||
|
||||
FeedbackTextData({required this.id, required this.widget});
|
||||
FeedbackTextData({
|
||||
required this.id,
|
||||
required this.widget,
|
||||
required this.eventId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# 62. 애니메이션 및 피드백 동기화 관련 이슈 진행 현황
|
||||
|
||||
## 1. 문제 발생 현상
|
||||
* 플레이어 공격 실패(`MISS`) 시, 화면에 `MISS` 텍스트가 두 번 올라오는 현상 발생.
|
||||
* 적의 방어 실패(`FAILED`) 시에도 유사한 중복 텍스트 현상 발생.
|
||||
* 로그상 (`[UI Debug] Feedback Event`)으로는 이벤트가 한 번만 발생했지만, UI에는 두 번 표시됨.
|
||||
* `_addFloatingEffect` 함수 내부에 `eventId` 기반의 중복 체크 로직이 추가되었음에도 현상 지속.
|
||||
|
||||
## 2. 진단 및 해결 시도
|
||||
### 2.1. 원인 가설
|
||||
1. **`BattleScreen` 인스턴스 중복:** 가장 유력한 가설. 하나의 `EffectEvent`가 발생했을 때, 여러 `BattleScreen` 인스턴스가 각자 이벤트를 받아 화면에 피드백 텍스트를 띄우는 경우.
|
||||
* `[UI Debug] BattleScreen initialized: ${hashCode}` 로그로 확인 필요. (현재 확인되지 않음)
|
||||
2. **`_addFloatingEffect` 내부의 `setState` 문제:** `setState` 호출 시 `_floatingFeedbackTexts` 리스트에 위젯이 중복으로 추가되거나, 위젯 렌더링 과정에서 불필요한 복제가 발생하는 경우. (리스트 `clear()` 로직 추가로 해결 시도 중)
|
||||
3. **UI 렌더링 타이밍/시각적 착시:** `FloatingFeedbackText` 위젯의 생명주기가 꼬여서 이전 텍스트가 완전히 사라지기 전에 새 텍스트가 뜨거나, 애니메이션이 반복되는 것처럼 보이는 착시.
|
||||
|
||||
### 2.2. 현재까지 적용된 주요 조치
|
||||
* **`EffectEvent` `eventId` 기반 중복 체크 (UI 레벨):** `_addFloatingEffect` 함수에서 `eventId`를 기반으로 동일한 이벤트에 대한 피드백 텍스트가 이미 리스트에 있다면 추가하지 않도록 `_floatingFeedbackTexts.any((e) => e.eventId == event.id)` 로직 추가.
|
||||
* **`_floatingFeedbackTexts.clear()` 도입:** 새로운 피드백 텍스트(`MISS`/`FAILED`)가 뜰 때, 기존의 모든 피드백 텍스트를 리스트에서 제거한 후 추가하도록 수정. (화면에 항상 하나의 피드백 텍스트만 유지)
|
||||
* **`addPostFrameCallback` 제거:** `_addFloatingEffect` 내 `WidgetsBinding.instance.addPostFrameCallback` 제거 (불필요한 비동기 지연 및 잠재적 문제 방지).
|
||||
* **디버그 로그 추가:**
|
||||
* `[UI Debug] BattleScreen initialized: ${hashCode}` (`BattleScreen` 초기화 횟수 확인용)
|
||||
* `[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}` (`_addFloatingEffect` 호출 확인용)
|
||||
* `FloatingFeedbackText`에 `event.id` 전체 표시 (화면상 ID 일치 여부 확인용)
|
||||
|
||||
## 3. 남아있는 문제 (현재 진단)
|
||||
* 로그상 `[UI Debug] Feedback Event`는 한 번만 찍히지만, **화면에는 `MISS` 텍스트가 두 번 표시됨.**
|
||||
* 이는 **UI 레벨에서의 렌더링 문제**이거나, `_addFloatingEffect` 함수 **내부 로직 중 `setState`가 비정상적으로 두 번 호출**되는 문제일 가능성이 높습니다.
|
||||
* `_floatingFeedbackTexts.clear()` 로직이 추가되었으므로, 같은 리스트에 두 번 추가되는 것은 막혔을 것입니다.
|
||||
|
||||
## 4. 다음 단계 제안
|
||||
* **`[UI Debug] BattleScreen initialized: ...` 로그 결과 확인:** 이 로그가 두 번 이상 찍힌다면 `BattleScreen` 인스턴스가 중복된 것이므로, `MainWrapper`나 라우팅 구조를 점검해야 합니다.
|
||||
* **화면상 `MISS` 텍스트의 ID 확인:** 화면에 보이는 두 개의 `MISS` 텍스트의 ID가 **정확히 동일한지** 확인 필요 (현재 `event.id` 전체를 표시하도록 수정됨).
|
||||
* **ID가 동일하다면:** 하나의 `FeedbackTextData` 객체가 UI에 중복 렌더링되는 문제. (Key 문제, `Stack` 리빌드 문제 등)
|
||||
* **ID가 다르다면:** `_addFloatingEffect` 자체가 두 번 호출된 것. (로그가 하나라는 것과 모순됨. 로그 시스템 확인 필요)
|
||||
|
||||
**현재까지의 모든 문제 해결 노력은 `BattleProvider` 내의 로직 중복이나 타이밍 오류를 잡는 데 초점을 맞췄습니다. 하지만 `MISS` 텍스트 중복 문제는 `BattleScreen` (UI) 쪽에서 발생하는 현상으로 보입니다.**
|
||||
Loading…
Reference in New Issue