This commit is contained in:
Horoli 2025-12-08 03:00:36 +09:00
parent 135bf26332
commit 9540dd22a3
8 changed files with 627 additions and 326 deletions

View File

@ -16,6 +16,7 @@ class EffectEvent {
final int? damageValue; // final int? damageValue; //
final bool? isSuccess; // (Missed or Failed가 ) final bool? isSuccess; // (Missed or Failed가 )
final int? armorGained; // final int? armorGained; //
final bool triggersTurnChange; //
EffectEvent({ EffectEvent({
required this.id, required this.id,
@ -28,5 +29,6 @@ class EffectEvent {
this.damageValue, this.damageValue,
this.isSuccess, this.isSuccess,
this.armorGained, this.armorGained,
this.triggersTurnChange = true,
}); });
} }

View File

@ -31,6 +31,7 @@ class EnemyIntent {
final String description; final String description;
final bool isSuccess; final bool isSuccess;
final int finalValue; final int finalValue;
bool isApplied; // Mutable flag to prevent double execution
EnemyIntent({ EnemyIntent({
required this.type, required this.type,
@ -39,6 +40,7 @@ class EnemyIntent {
required this.description, required this.description,
required this.isSuccess, required this.isSuccess,
required this.finalValue, required this.finalValue,
this.isApplied = false,
}); });
} }
@ -51,6 +53,8 @@ class BattleProvider with ChangeNotifier {
final BattleLogManager _logManager = BattleLogManager(); final BattleLogManager _logManager = BattleLogManager();
bool isPlayerTurn = true; bool isPlayerTurn = true;
int _turnTransactionId = 0; // To prevent async race conditions
bool skipAnimations = false; // Sync with SettingsProvider
int stage = 1; int stage = 1;
int turnCount = 1; int turnCount = 1;
@ -88,6 +92,7 @@ class BattleProvider with ChangeNotifier {
} }
void loadFromSave(Map<String, dynamic> data) { void loadFromSave(Map<String, dynamic> data) {
_turnTransactionId++; // Invalidate previous timers
stage = data['stage']; stage = data['stage'];
turnCount = data['turnCount']; turnCount = data['turnCount'];
player = Character.fromJson(data['player']); player = Character.fromJson(data['player']);
@ -100,6 +105,7 @@ class BattleProvider with ChangeNotifier {
} }
void initializeBattle() { void initializeBattle() {
_turnTransactionId++; // Invalidate previous timers
stage = 1; stage = 1;
turnCount = 1; turnCount = 1;
// Load player from PlayerTable // Load player from PlayerTable
@ -180,6 +186,7 @@ class BattleProvider with ChangeNotifier {
} }
void _prepareNextStage() { void _prepareNextStage() {
_turnTransactionId++; // Invalidate previous timers
// Save Game at the start of each stage // Save Game at the start of each stage
SaveManager.saveGame(this); SaveManager.saveGame(this);
@ -216,6 +223,7 @@ class BattleProvider with ChangeNotifier {
showRewardPopup = false; showRewardPopup = false;
_generateEnemyIntent(); // Generate first intent _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."); _addLog("Stage $stage ($type) started! A wild ${enemy.name} appeared.");
} else if (type == StageType.shop) { } else if (type == StageType.shop) {
@ -258,10 +266,69 @@ class BattleProvider with ChangeNotifier {
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup) if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
return; return;
// Update Enemy Status Effects at the start of Player's turn (user request) // 0. Apply Enemy Pre-emptive Defense (Just-in-Time)
enemy.updateStatusEffects(); 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 && if (type == ActionType.defend &&
player.hasStatus(StatusEffectType.defenseForbidden)) { player.hasStatus(StatusEffectType.defenseForbidden)) {
_addLog("Cannot defend! You are under Defense Forbidden status."); _addLog("Cannot defend! You are under Defense Forbidden status.");
@ -335,7 +402,7 @@ class BattleProvider with ChangeNotifier {
isSuccess: true, isSuccess: true,
); );
_effectEventController.sink.add(event); _effectEventController.sink.add(event);
handleImpact(event); // Process impact via handleImpact for safety // handleImpact(event); // REMOVED: Driven by UI
} }
} else { } else {
// Failure // Failure
@ -366,16 +433,19 @@ class BattleProvider with ChangeNotifier {
event, event,
); // Send event for miss/fail feedback ); // Send event for miss/fail feedback
_addLog("${player.name}'s ${type.name} ${feedbackType.name}!"); _addLog("${player.name}'s ${type.name} ${feedbackType.name}!");
handleImpact(event); // Process impact via handleImpact for safety print("[Logic Debug] Player Action Failed Event"); // Debug Log
// handleImpact(event); // REMOVED: Driven by UI
} }
// Now check for enemy death (if applicable from bleed, or previous impacts) // Now check for enemy death (if applicable from bleed, or previous impacts)
if (enemy.isDead) {
// Check enemy death after player's action
_onVictory();
return;
}
// Removed redundant `if (enemy.isDead)` check as it's handled in `_processAttackImpact` // _endPlayerTurn(); // REMOVED: Driven by UI via handleImpact
}
_endPlayerTurn();
}
void _endPlayerTurn() { void _endPlayerTurn() {
// Update durations at end of turn // Update durations at end of turn
@ -387,49 +457,76 @@ class BattleProvider with ChangeNotifier {
return; return;
} }
int tid = _turnTransactionId;
Future.delayed( Future.delayed(
const Duration(milliseconds: GameConfig.animDelayEnemyTurn), 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; if (!isPlayerTurn && (player.isDead || enemy.isDead)) return;
_addLog("Enemy's turn..."); _addLog("Enemy's turn...");
await Future.delayed( // REMOVED: Initial delay for faster pacing
const Duration(milliseconds: GameConfig.animDelayEnemyTurn), // await Future.delayed(
); // const Duration(milliseconds: GameConfig.animDelayEnemyTurn),
// );
// Enemy Turn Start Logic
// Armor decay // Armor decay
if (enemy.armor > 0) { if (enemy.armor > 0) {
enemy.armor = (enemy.armor * GameConfig.armorDecayRate).toInt(); enemy.armor = (enemy.armor * GameConfig.armorDecayRate).toInt();
_addLog("Enemy's armor decayed to ${enemy.armor}."); _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); bool canAct = _processStartTurnEffects(enemy);
// Check death from bleed before acting
if (enemy.isDead) { if (enemy.isDead) {
_onVictory(); _onVictory();
return; return;
} }
if (canAct && currentEnemyIntent != null) { if (canAct && currentEnemyIntent != null) {
final intent = currentEnemyIntent!;
final intent = currentEnemyIntent!; if (intent.type == EnemyActionType.defend) {
// Defensive Action (Non-animating)
// Check if already applied in Phase 3 of previous turn
if (intent.isApplied) {
_addLog("Enemy maintains defensive stance.");
// Proceed manually
int tid = _turnTransactionId;
int delay = skipAnimations ? 500 : 1000; // Faster if animations off
Future.delayed(Duration(milliseconds: delay), () {
if (tid != _turnTransactionId) return;
_endEnemyTurn();
});
return;
}
if (intent.type == EnemyActionType.defend) { if (intent.isSuccess) {
// ... (success logic) ...
// Already handled in _generateEnemyIntent } else {
// ... (failure logic) ...
_addLog("Enemy maintains defensive stance."); }
// For defense (if not applied), we proceed manually
} else { // Attack Logic 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) { if (intent.isSuccess) {
final event = EffectEvent( final event = EffectEvent(
id: id:
@ -445,8 +542,12 @@ class BattleProvider with ChangeNotifier {
isSuccess: true, isSuccess: true,
); );
_effectEventController.sink.add(event); _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 { } else {
// Missed Attack
_addLog("Enemy's ${intent.risk.name} attack missed!"); _addLog("Enemy's ${intent.risk.name} attack missed!");
final event = EffectEvent( final event = EffectEvent(
id: id:
@ -460,43 +561,53 @@ class BattleProvider with ChangeNotifier {
targetEntity: player, targetEntity: player,
isSuccess: false, isSuccess: false,
); );
_effectEventController.sink.add( _effectEventController.sink.add(event);
event, return; // Exit _startEnemyTurn after emitting event for UI to handle
); // Send event for miss feedback
handleImpact(event); // Process impact via handleImpact for safety
} }
} }
} else if (!canAct) { } else if (!canAct) {
_addLog("Enemy is stunned and cannot act!"); _addLog("Enemy is stunned and cannot act!");
int tid = _turnTransactionId;
Future.delayed(const Duration(milliseconds: 500), () {
if (tid != _turnTransactionId) return;
_endEnemyTurn();
});
} else { } else {
_addLog("Enemy did nothing."); _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 // Phase 2: End Enemy Turn & Generate Next Intent
// If attacking, we need to wait for the attack animation + return void _endEnemyTurn() {
if (currentEnemyIntent?.type == EnemyActionType.attack && if (player.isDead) return; // Game Over check
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 // Generate NEXT intent
// Since we removed the pre-impact delay, the UI animation starts immediately. _generateEnemyIntent();
// 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 _processMiddleTurn();
if (!player.isDead) { }
_generateEnemyIntent();
}
// 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 // Player Turn Start Logic
// Armor decay // Armor decay
if (player.armor > 0) { if (player.armor > 0) {
@ -505,7 +616,7 @@ class BattleProvider with ChangeNotifier {
} }
if (player.isDead) { if (player.isDead) {
await _onDefeat(); _onDefeat();
return; return;
} }
@ -628,7 +739,6 @@ class BattleProvider with ChangeNotifier {
stage++; stage++;
showRewardPopup = false; showRewardPopup = false;
rewardOptions.clear(); // Clear options to prevent flash on next victory
_prepareNextStage(); _prepareNextStage();
@ -753,7 +863,7 @@ class BattleProvider with ChangeNotifier {
// Defend Intent // Defend Intent
int baseDef = enemy.totalDefense; int baseDef = enemy.totalDefense;
// Variance removed // Variance removed
int armor = (baseDef * 2 * efficiency).toInt(); int armor = (baseDef * efficiency).toInt();
// Calculate success immediately // Calculate success immediately
bool success = false; bool success = false;
@ -778,39 +888,104 @@ class BattleProvider with ChangeNotifier {
finalValue: armor, finalValue: armor,
); );
// Apply defense immediately if successful // Note: Armor is NO LONGER applied here instantly.
if (success) { // It is applied in _applyEnemyIntentEffects() which is called before Player turn.
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!");
}
} }
notifyListeners(); 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 // New public method to be called by UI at impact moment
void handleImpact(EffectEvent event) { void handleImpact(EffectEvent event) {
if (event.isSuccess == false || event.feedbackType != null) { if (event.isSuccess == false || event.feedbackType != null) {
// If it's a miss/fail/feedback, just log and return // If it's a miss/fail/feedback, just log and return
// Logging and feedback text should already be handled when event created // Logging and feedback text should already be handled when event created
notifyListeners(); // Ensure UI updates for log 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; return;
} }
// Only process actual attack or defend impacts here // Only process actual attack or defend impacts here
_processAttackImpact(event); _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 // Refactored common attack impact logic

View File

@ -50,10 +50,12 @@ class _BattleScreenState extends State<BattleScreen> {
bool _showLogs = false; bool _showLogs = false;
bool _isPlayerAttacking = false; // Player Attack Animation State bool _isPlayerAttacking = false; // Player Attack Animation State
bool _isEnemyAttacking = false; // Enemy Attack Animation State bool _isEnemyAttacking = false; // Enemy Attack Animation State
DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts
@override @override
void initState() { void initState() {
super.initState(); super.initState();
print("[UI Debug] BattleScreen initialized: ${hashCode}"); // Debug Log
final battleProvider = context.read<BattleProvider>(); final battleProvider = context.read<BattleProvider>();
_damageSubscription = battleProvider.damageStream.listen( _damageSubscription = battleProvider.damageStream.listen(
_addFloatingDamageText, _addFloatingDamageText,
@ -71,54 +73,52 @@ class _BattleScreenState extends State<BattleScreen> {
} }
void _addFloatingDamageText(DamageEvent event) { void _addFloatingDamageText(DamageEvent event) {
WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return;
if (!mounted) return;
GlobalKey targetKey = event.target == DamageTarget.player GlobalKey targetKey = event.target == DamageTarget.player
? _playerKey ? _playerKey
: _enemyKey; : _enemyKey;
if (targetKey.currentContext == null) return; if (targetKey.currentContext == null) return;
RenderBox? renderBox = RenderBox? renderBox =
targetKey.currentContext!.findRenderObject() as RenderBox?; targetKey.currentContext!.findRenderObject() as RenderBox?;
if (renderBox == null) return; if (renderBox == null) return;
Offset position = renderBox.localToGlobal(Offset.zero); Offset position = renderBox.localToGlobal(Offset.zero);
RenderBox? stackRenderBox = RenderBox? stackRenderBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?; _stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackRenderBox != null) { if (stackRenderBox != null) {
Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero);
position = position - stackOffset; position = position - stackOffset;
} }
position = position + Offset(renderBox.size.width / 2 - 20, -20); position = position + Offset(renderBox.size.width / 2 - 20, -20);
final String id = UniqueKey().toString(); final String id = UniqueKey().toString();
setState(() { setState(() {
_floatingDamageTexts.add( _floatingDamageTexts.add(
DamageTextData( DamageTextData(
id: id, id: id,
widget: Positioned( widget: Positioned(
left: position.dx, left: position.dx,
top: position.dy, top: position.dy,
child: FloatingDamageText( child: FloatingDamageText(
key: ValueKey(id), key: ValueKey(id),
damage: event.damage.toString(), damage: event.damage.toString(),
color: event.color, color: event.color,
onRemove: () { onRemove: () {
if (mounted) { if (mounted) {
setState(() { setState(() {
_floatingDamageTexts.removeWhere((e) => e.id == id); _floatingDamageTexts.removeWhere((e) => e.id == id);
}); });
} }
}, },
),
), ),
), ),
); ),
}); );
}); });
} }
@ -128,108 +128,92 @@ class _BattleScreenState extends State<BattleScreen> {
if (_processedEffectIds.contains(event.id)) { if (_processedEffectIds.contains(event.id)) {
return; return;
} }
_processedEffectIds.add(event.id); _processedEffectIds.add(event.id);
if (_processedEffectIds.length > 20) { if (_processedEffectIds.length > 20) {
_processedEffectIds.remove(_processedEffectIds.first); _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;
if (targetKey.currentContext == null) return;
RenderBox? renderBox =
targetKey.currentContext!.findRenderObject() as RenderBox?;
if (renderBox == null) return;
Offset position = renderBox.localToGlobal(Offset.zero);
RenderBox? stackRenderBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackRenderBox != null) {
Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero);
position = position - stackOffset;
}
position =
position +
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
// 0. Prepare Effect Function
void showEffect() {
if (!mounted) return; if (!mounted) return;
GlobalKey targetKey = event.target == EffectTarget.player // Handle Feedback Text (MISS / FAILED)
? _playerKey if (event.feedbackType != null) {
: _enemyKey; String feedbackText;
if (targetKey.currentContext == null) return; Color feedbackColor;
switch (event.feedbackType) {
RenderBox? renderBox = case BattleFeedbackType.miss:
targetKey.currentContext!.findRenderObject() as RenderBox?; feedbackText = "MISS";
if (renderBox == null) return; feedbackColor = ThemeConfig.missText;
break;
Offset position = renderBox.localToGlobal(Offset.zero); case BattleFeedbackType.failed:
feedbackText = "FAILED";
RenderBox? stackRenderBox = feedbackColor = ThemeConfig.failedText;
_stackKey.currentContext?.findRenderObject() as RenderBox?; break;
if (stackRenderBox != null) { default:
Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); feedbackText = "";
position = position - stackOffset; feedbackColor = ThemeConfig.textColorWhite;
}
position =
position +
Offset(renderBox.size.width / 2 - 30, renderBox.size.height / 2 - 30);
// 0. Prepare Effect Function
void showEffect() {
if (!mounted) return;
// feedbackType이
if (event.feedbackType != null) {
String feedbackText;
Color feedbackColor;
switch (event.feedbackType) {
case BattleFeedbackType.miss:
feedbackText = "MISS";
feedbackColor = ThemeConfig.missText;
break;
case BattleFeedbackType.failed:
feedbackText = "FAILED";
feedbackColor = ThemeConfig.failedText;
break;
default:
feedbackText = ""; // Should not happen with current enums
feedbackColor = ThemeConfig.textColorWhite;
}
final String id = UniqueKey().toString();
setState(() {
_floatingFeedbackTexts.add(
FeedbackTextData(
id: id,
widget: Positioned(
left: position.dx,
top: position.dy,
child: FloatingFeedbackText(
key: ValueKey(id),
feedback: feedbackText,
color: feedbackColor,
onRemove: () {
if (mounted) {
setState(() {
_floatingFeedbackTexts.removeWhere((e) => e.id == id);
});
}
},
),
),
),
);
});
return; // feedbackType이
} }
// Use BattleConfig for Icon, Color, and Size
IconData icon = BattleConfig.getIcon(event.type);
Color color = BattleConfig.getColor(event.type, event.risk);
double size = BattleConfig.getSize(event.risk);
final String id = UniqueKey().toString(); 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(() { setState(() {
_floatingEffects.add( _floatingFeedbackTexts.clear(); // Clear previous texts
FloatingEffectData( _floatingFeedbackTexts.add(
FeedbackTextData(
id: id, id: id,
eventId: event.id,
widget: Positioned( widget: Positioned(
left: position.dx, left: position.dx,
top: position.dy, top: position.dy,
child: FloatingEffect( child: FloatingFeedbackText(
key: ValueKey(id), key: ValueKey(id),
icon: icon, feedback: "$feedbackText (${event.id.substring(0, 4)})",
color: color, color: feedbackColor,
size: size,
onRemove: () { onRemove: () {
if (mounted) { if (mounted) {
setState(() { setState(() {
_floatingEffects.removeWhere((e) => e.id == id); _floatingFeedbackTexts.removeWhere((e) => e.id == id);
}); });
} }
}, },
@ -238,133 +222,179 @@ class _BattleScreenState extends State<BattleScreen> {
), ),
); );
}); });
return; // Return early for feedback
} }
// 1. Attack Animation Trigger (All Risk Levels) // Handle Icon Effect
if (event.type == ActionType.attack && IconData icon = BattleConfig.getIcon(event.type);
event.target == EffectTarget.enemy && Color color = BattleConfig.getColor(event.type, event.risk);
event.feedbackType == null) { double size = BattleConfig.getSize(event.risk);
// Calculate target position (Enemy) relative to Player
final RenderBox? playerBox =
_playerKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? enemyBox =
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
if (playerBox != null && enemyBox != null) { final String id = UniqueKey().toString();
final playerPos = playerBox.localToGlobal(Offset.zero);
final enemyPos = enemyBox.localToGlobal(Offset.zero);
final offset = enemyPos - playerPos; setState(() {
_floatingEffects.add(
// Start Animation: Hide Stats FloatingEffectData(
setState(() { id: id,
_isPlayerAttacking = true; widget: Positioned(
}); left: position.dx,
top: position.dy,
_playerAnimKey.currentState child: FloatingEffect(
?.animateAttack(offset, () { key: ValueKey(id),
showEffect(); // Show Effect at Impact! icon: icon,
// Trigger impact logic in provider color: color,
context.read<BattleProvider>().handleImpact(event); size: size,
onRemove: () {
// Shake and Explosion ONLY for Risky if (mounted) {
if (event.risk == RiskLevel.risky) { setState(() {
_shakeKey.currentState?.shake(); _floatingEffects.removeWhere((e) => e.id == id);
});
RenderBox? stackBox =
_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,
);
_explosionKey.currentState?.explode(localEnemyPos);
} }
},
),
),
),
);
});
}
// 1. Player Attack Animation Trigger (Success or Miss)
if (event.type == ActionType.attack &&
event.target == EffectTarget.enemy) {
final RenderBox? playerBox =
_playerKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? enemyBox =
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
if (playerBox != null && enemyBox != null) {
final playerPos = playerBox.localToGlobal(Offset.zero);
final enemyPos = enemyBox.localToGlobal(Offset.zero);
final offset = enemyPos - playerPos;
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();
context.read<BattleProvider>().handleImpact(event);
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
_shakeKey.currentState?.shake();
RenderBox? stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox != null) {
Offset localEnemyPos = stackBox.globalToLocal(enemyPos);
localEnemyPos += Offset(
enemyBox.size.width / 2,
enemyBox.size.height / 2,
);
_explosionKey.currentState?.explode(localEnemyPos);
} }
}, event.risk) }
.then((_) { }, animRisk)
// End Animation: Show Stats .then((_) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isPlayerAttacking = false; _isPlayerAttacking = false;
}); });
} }
}); });
} }
} else if (event.type == ActionType.attack && }
event.target == EffectTarget.player && // 2. Enemy Attack Animation Trigger (Success or Miss)
event.feedbackType == null) { else if (event.type == ActionType.attack &&
event.target == EffectTarget.player) {
// Check Settings
bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations; bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations;
if (!enableAnim) { if (!enableAnim) {
showEffect(); // Just show effect if anim disabled
context.read<BattleProvider>().handleImpact(event); // Process impact immediately if anim disabled
return;
}
// 2. Enemy Attack Animation Trigger
final RenderBox? playerBox =
_playerKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? enemyBox =
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
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;
});
_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) {
_shakeKey.currentState?.shake();
// Explosion on Player
RenderBox? stackBox =
_stackKey.currentContext?.findRenderObject()
as RenderBox?;
if (stackBox != null) {
Offset localPlayerPos = stackBox.globalToLocal(playerPos);
localPlayerPos += Offset(
playerBox.size.width / 2,
playerBox.size.height / 2,
);
_explosionKey.currentState?.explode(localPlayerPos);
}
}
}, event.risk)
.then((_) {
// End Animation: Show Stats
if (mounted) {
setState(() {
_isEnemyAttacking = false;
});
}
});
}
} else {
// 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); context.read<BattleProvider>().handleImpact(event);
return;
} }
});
final RenderBox? playerBox =
_playerKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? enemyBox =
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
if (playerBox != null && enemyBox != null) {
final playerPos = playerBox.localToGlobal(Offset.zero);
final enemyPos = enemyBox.localToGlobal(Offset.zero);
final offset = playerPos - enemyPos;
setState(() {
_isEnemyAttacking = true;
});
// Force SAFE animation for MISS
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
_enemyAnimKey.currentState
?.animateAttack(offset, () {
showEffect();
context.read<BattleProvider>().handleImpact(event);
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
_shakeKey.currentState?.shake();
RenderBox? stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox != null) {
Offset localPlayerPos = stackBox.globalToLocal(playerPos);
localPlayerPos += Offset(
playerBox.size.width / 2,
playerBox.size.height / 2,
);
_explosionKey.currentState?.explode(localPlayerPos);
}
}
}, animRisk)
.then((_) {
if (mounted) {
setState(() {
_isEnemyAttacking = false;
});
}
});
}
}
// 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();
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) { void _showRiskLevelSelection(BuildContext context, ActionType actionType) {
@ -452,6 +482,11 @@ class _BattleScreenState extends State<BattleScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Sync animation setting to provider logic
final settings = context.watch<SettingsProvider>();
context.read<BattleProvider>().skipAnimations =
!settings.enableEnemyAnimations;
return ResponsiveContainer( return ResponsiveContainer(
child: Consumer<BattleProvider>( child: Consumer<BattleProvider>(
builder: (context, battleProvider, child) { builder: (context, battleProvider, child) {
@ -616,7 +651,9 @@ class _BattleScreenState extends State<BattleScreen> {
child: SimpleDialog( child: SimpleDialog(
title: Row( title: Row(
children: [ children: [
const Text("${AppStrings.victory} ${AppStrings.chooseReward}"), const Text(
"${AppStrings.victory} ${AppStrings.chooseReward}",
),
const Spacer(), const Spacer(),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -24,7 +24,7 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
// Enemy Animation Toggle // Enemy Animation Toggle
Consumer<SettingsProvider>( Consumer<SettingsProvider>(
builder: (context, settings, child) { builder: (context, settings, child) {
@ -51,7 +51,7 @@ class SettingsScreen extends StatelessWidget {
style: TextStyle(color: ThemeConfig.textColorWhite), style: TextStyle(color: ThemeConfig.textColorWhite),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
// Restart Button // Restart Button
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -69,7 +69,7 @@ class SettingsScreen extends StatelessWidget {
const SnackBar(content: Text('Game Restarted!')), const SnackBar(content: Text('Game Restarted!')),
); );
// Optionally switch tab back to Battle (index 0) // Optionally switch tab back to Battle (index 0)
// But MainWrapper controls the index. // But MainWrapper controls the index.
// We can't easily switch tab from here without a callback or Provider. // We can't easily switch tab from here without a callback or Provider.
// For now, just restart logic is enough. // For now, just restart logic is enough.
}, },
@ -89,10 +89,13 @@ class SettingsScreen extends StatelessWidget {
_showConfirmationDialog( _showConfirmationDialog(
context, context,
title: '${AppStrings.returnToMenu}?', 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: () { onConfirm: () {
Navigator.of(context).pushAndRemoveUntil( Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const MainMenuScreen()), MaterialPageRoute(
builder: (context) => const MainMenuScreen(),
),
(route) => false, (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( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@ -121,7 +129,10 @@ class SettingsScreen extends StatelessWidget {
Navigator.pop(context); Navigator.pop(context);
onConfirm(); onConfirm();
}, },
child: const Text(AppStrings.confirm, style: TextStyle(color: Colors.red)), child: const Text(
AppStrings.confirm,
style: TextStyle(color: Colors.red),
),
), ),
], ],
), ),

View File

@ -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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(

View File

@ -125,7 +125,7 @@ class CharacterStatusCard extends StatelessWidget {
), ),
const SizedBox(height: 8), // const SizedBox(height: 8), //
if (!isPlayer) if (!isPlayer && !hideStats)
Consumer<BattleProvider>( Consumer<BattleProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
if (provider.currentEnemyIntent != null && !character.isDead) { if (provider.currentEnemyIntent != null && !character.isDead) {

View File

@ -286,6 +286,11 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
class FeedbackTextData { class FeedbackTextData {
final String id; final String id;
final Widget widget; 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,
});
} }

View File

@ -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) 쪽에서 발생하는 현상으로 보입니다.**