update
This commit is contained in:
parent
eab99eee6f
commit
f49902c067
|
|
@ -266,65 +266,8 @@ class BattleProvider with ChangeNotifier {
|
|||
if (!isPlayerTurn || player.isDead || enemy.isDead || showRewardPopup)
|
||||
return;
|
||||
|
||||
// 0. Apply Enemy Pre-emptive Defense (Just-in-Time)
|
||||
if (currentEnemyIntent?.type == EnemyActionType.defend &&
|
||||
!currentEnemyIntent!.isApplied) {
|
||||
final intent = currentEnemyIntent!;
|
||||
|
||||
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;
|
||||
}
|
||||
// 0. Apply Enemy Pre-emptive Defense - REMOVED (Standard Turn-Based Logic)
|
||||
// Defense now happens on Enemy's Turn.
|
||||
|
||||
// Update Enemy Status Effects at the start of Player's turn (user request)
|
||||
|
||||
|
|
@ -433,7 +376,6 @@ class BattleProvider with ChangeNotifier {
|
|||
event,
|
||||
); // Send event for miss/fail feedback
|
||||
_addLog("${player.name}'s ${type.name} ${feedbackType.name}!");
|
||||
print("[Logic Debug] Player Action Failed Event"); // Debug Log
|
||||
// handleImpact(event); // REMOVED: Driven by UI
|
||||
}
|
||||
|
||||
|
|
@ -469,18 +411,90 @@ class BattleProvider with ChangeNotifier {
|
|||
|
||||
// --- Turn Management Phases ---
|
||||
|
||||
// Phase 4: Start Player Turn
|
||||
void _startPlayerTurn() {
|
||||
// Player Turn Start Logic
|
||||
// Armor decay (Player)
|
||||
if (player.armor > 0) {
|
||||
player.armor = (player.armor * GameConfig.armorDecayRate).toInt();
|
||||
_addLog("Player's armor decayed to ${player.armor}.");
|
||||
}
|
||||
|
||||
if (player.isDead) {
|
||||
_onDefeat();
|
||||
return;
|
||||
}
|
||||
|
||||
// [New] Apply Pre-emptive Enemy Intent (Defense/Buffs)
|
||||
if (currentEnemyIntent != null) {
|
||||
final intent = currentEnemyIntent!;
|
||||
if (intent.type == EnemyActionType.defend) {
|
||||
if (intent.isSuccess) {
|
||||
enemy.armor += intent.finalValue;
|
||||
_addLog(
|
||||
"${enemy.name} assumes a defensive stance (+${intent.finalValue} Armor).",
|
||||
);
|
||||
} else {
|
||||
_addLog("${enemy.name} tried to defend but failed.");
|
||||
}
|
||||
intent.isApplied = true; // Mark as applied so we don't do it again
|
||||
}
|
||||
// Add other pre-emptive intent types here if needed (e.g., Buffs)
|
||||
}
|
||||
|
||||
isPlayerTurn = true;
|
||||
turnCount++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _addLog(String log) {
|
||||
_logManager.addLog(log);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check Status Effects at Start of Turn
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 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...");
|
||||
// REMOVED: Initial delay for faster pacing
|
||||
// await Future.delayed(
|
||||
// const Duration(milliseconds: GameConfig.animDelayEnemyTurn),
|
||||
// );
|
||||
|
||||
// Armor decay
|
||||
// Armor decay (Enemy)
|
||||
if (enemy.armor > 0) {
|
||||
enemy.armor = (enemy.armor * GameConfig.armorDecayRate).toInt();
|
||||
_addLog("Enemy's armor decayed to ${enemy.armor}.");
|
||||
|
|
@ -498,33 +512,18 @@ class BattleProvider with ChangeNotifier {
|
|||
final intent = currentEnemyIntent!;
|
||||
|
||||
if (intent.type == EnemyActionType.defend) {
|
||||
// Defensive Action (Non-animating)
|
||||
// Defensive Action (Pre-applied at start of Player Turn)
|
||||
// Just show a log or maintain stance visual
|
||||
_addLog("${enemy.name} maintains defensive stance.");
|
||||
|
||||
// Check if already applied in Phase 3 of previous turn
|
||||
if (intent.isApplied) {
|
||||
_addLog("Enemy maintains defensive stance.");
|
||||
// Proceed manually
|
||||
// IMPORTANT: We still need to end the turn sequence properly.
|
||||
// Since no animation is needed (or a very short one), we can just delay slightly.
|
||||
int tid = _turnTransactionId;
|
||||
int delay = skipAnimations ? 500 : 1000; // Faster if animations off
|
||||
Future.delayed(Duration(milliseconds: delay), () {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (tid != _turnTransactionId) return;
|
||||
_endEnemyTurn();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -542,10 +541,8 @@ class BattleProvider with ChangeNotifier {
|
|||
isSuccess: true,
|
||||
);
|
||||
_effectEventController.sink.add(event);
|
||||
// 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
|
||||
// UI monitors event -> animates -> calls handleImpact -> _endEnemyTurn
|
||||
return;
|
||||
} else {
|
||||
// Missed Attack
|
||||
_addLog("Enemy's ${intent.risk.name} attack missed!");
|
||||
|
|
@ -562,7 +559,7 @@ class BattleProvider with ChangeNotifier {
|
|||
isSuccess: false,
|
||||
);
|
||||
_effectEventController.sink.add(event);
|
||||
return; // Exit _startEnemyTurn after emitting event for UI to handle
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (!canAct) {
|
||||
|
|
@ -594,42 +591,14 @@ class BattleProvider with ChangeNotifier {
|
|||
|
||||
// Phase 3: Middle Turn (Apply Defense Effects)
|
||||
Future<void> _processMiddleTurn() async {
|
||||
// Apply Intent Effects (Pre-emptive Defense)
|
||||
// Phase 3 Middle Turn - Reduced functionality as Defense is now Phase 1.
|
||||
int tid = _turnTransactionId;
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await Future.delayed(const Duration(milliseconds: 200)); // Short pause
|
||||
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) {
|
||||
player.armor = (player.armor * GameConfig.armorDecayRate).toInt();
|
||||
_addLog("Player's armor decayed to ${player.armor}.");
|
||||
}
|
||||
|
||||
if (player.isDead) {
|
||||
_onDefeat();
|
||||
return;
|
||||
}
|
||||
|
||||
isPlayerTurn = true;
|
||||
turnCount++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _addLog(String message) {
|
||||
_logManager.addLog(message);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onVictory() {
|
||||
// Calculate Gold Reward
|
||||
// Base 10 + (Stage * 5) + Random variance
|
||||
|
|
@ -897,21 +866,14 @@ class BattleProvider with ChangeNotifier {
|
|||
/// 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;
|
||||
}
|
||||
// No pre-emptive effects in Standard Turn-Based model.
|
||||
// Logic cleared.
|
||||
}
|
||||
|
||||
// New public method to be called by UI at impact moment
|
||||
void handleImpact(EffectEvent event) {
|
||||
if (event.isSuccess == false || event.feedbackType != null) {
|
||||
if ((event.isSuccess == false || event.feedbackType != null) &&
|
||||
event.type != ActionType.defend) {
|
||||
// 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
|
||||
|
|
@ -920,12 +882,9 @@ class BattleProvider with ChangeNotifier {
|
|||
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) {
|
||||
// Special Case: Phase 1 relies on manual timer. Phase 3 relies on _processMiddleTurn sequence.
|
||||
_endEnemyTurn();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -970,10 +929,10 @@ class BattleProvider with ChangeNotifier {
|
|||
// 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;
|
||||
}
|
||||
// REMOVED: Blocking Enemy Defend. Now we want to process it.
|
||||
// if (event.attacker == enemy && event.type == ActionType.defend) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Only process actual attack or defend impacts here
|
||||
_processAttackImpact(event);
|
||||
|
|
@ -1068,40 +1027,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(
|
||||
|
|
@ -1113,6 +1038,4 @@ class BattleProvider with ChangeNotifier {
|
|||
_addLog("Applied ${effect.type.name} to ${target.name}!");
|
||||
}
|
||||
}
|
||||
|
||||
/// Process effects that happen at the start of the turn (Bleed, Stun).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
class SettingsProvider with ChangeNotifier {
|
||||
static const String _keyEnemyAnim = 'settings_enemy_anim';
|
||||
|
||||
bool _enableEnemyAnimations = false; // Default: Disabled
|
||||
bool _enableEnemyAnimations = true; // Default: Enabled
|
||||
|
||||
bool get enableEnemyAnimations => _enableEnemyAnimations;
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ class SettingsProvider with ChangeNotifier {
|
|||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_enableEnemyAnimations = prefs.getBool(_keyEnemyAnim) ?? false;
|
||||
_enableEnemyAnimations = prefs.getBool(_keyEnemyAnim) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,11 +126,13 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
|
||||
void _addFloatingEffect(EffectEvent event) {
|
||||
if (_processedEffectIds.contains(event.id)) {
|
||||
// print("[UI Debug] Duplicate Event Ignored: ${event.id}");
|
||||
return;
|
||||
}
|
||||
|
||||
_processedEffectIds.add(event.id);
|
||||
if (_processedEffectIds.length > 20) {
|
||||
// Keep the set size manageable
|
||||
if (_processedEffectIds.length > 50) {
|
||||
_processedEffectIds.remove(_processedEffectIds.first);
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +140,9 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
|
||||
// Feedback Text Cooldown
|
||||
if (event.feedbackType != null) {
|
||||
print("[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}");
|
||||
// print(
|
||||
// "[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}",
|
||||
// );
|
||||
if (_lastFeedbackTime != null &&
|
||||
DateTime.now().difference(_lastFeedbackTime!).inMilliseconds < 300) {
|
||||
return; // Skip if too soon
|
||||
|
|
@ -208,7 +212,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
top: position.dy,
|
||||
child: FloatingFeedbackText(
|
||||
key: ValueKey(id),
|
||||
feedback: "$feedbackText (${event.id.substring(0, 4)})",
|
||||
feedback: feedbackText,
|
||||
color: feedbackColor,
|
||||
onRemove: () {
|
||||
if (mounted) {
|
||||
|
|
@ -259,9 +263,7 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
}
|
||||
|
||||
// 1. Player Attack Animation Trigger (Success or Miss)
|
||||
if (event.type == ActionType.attack &&
|
||||
event.target == EffectTarget.enemy) {
|
||||
|
||||
if (event.type == ActionType.attack && event.target == EffectTarget.enemy) {
|
||||
final RenderBox? playerBox =
|
||||
_playerKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? enemyBox =
|
||||
|
|
@ -277,7 +279,9 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
});
|
||||
|
||||
// Force SAFE animation for MISS, otherwise use event risk
|
||||
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
|
||||
final RiskLevel animRisk = event.feedbackType != null
|
||||
? RiskLevel.safe
|
||||
: event.risk;
|
||||
|
||||
_playerAnimKey.currentState
|
||||
?.animateAttack(offset, () {
|
||||
|
|
@ -310,7 +314,6 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
// 2. Enemy Attack Animation Trigger (Success or Miss)
|
||||
else if (event.type == ActionType.attack &&
|
||||
event.target == EffectTarget.player) {
|
||||
|
||||
bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations;
|
||||
|
||||
if (!enableAnim) {
|
||||
|
|
@ -334,7 +337,9 @@ class _BattleScreenState extends State<BattleScreen> {
|
|||
});
|
||||
|
||||
// Force SAFE animation for MISS
|
||||
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
|
||||
final RiskLevel animRisk = event.feedbackType != null
|
||||
? RiskLevel.safe
|
||||
: event.risk;
|
||||
|
||||
_enemyAnimKey.currentState
|
||||
?.animateAttack(offset, () {
|
||||
|
|
|
|||
|
|
@ -90,9 +90,18 @@ class CharacterStatusCard extends StatelessWidget {
|
|||
}).toList(),
|
||||
),
|
||||
),
|
||||
Text("ATK: ${character.totalAtk}", style: const TextStyle(color: ThemeConfig.textColorWhite)),
|
||||
Text("DEF: ${character.totalDefense}", style: const TextStyle(color: ThemeConfig.textColorWhite)),
|
||||
Text("LUCK: ${character.totalLuck}", style: const TextStyle(color: ThemeConfig.textColorWhite)),
|
||||
Text(
|
||||
"ATK: ${character.totalAtk}",
|
||||
style: const TextStyle(color: ThemeConfig.textColorWhite),
|
||||
),
|
||||
Text(
|
||||
"DEF: ${character.totalDefense}",
|
||||
style: const TextStyle(color: ThemeConfig.textColorWhite),
|
||||
),
|
||||
Text(
|
||||
"LUCK: ${character.totalLuck}",
|
||||
style: const TextStyle(color: ThemeConfig.textColorWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -124,7 +133,6 @@ class CharacterStatusCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 8), // 아이콘과 정보 사이 간격
|
||||
|
||||
if (!isPlayer && !hideStats)
|
||||
Consumer<BattleProvider>(
|
||||
builder: (context, provider, child) {
|
||||
|
|
|
|||
|
|
@ -268,9 +268,10 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
|
|||
fontWeight: FontWeight.bold,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 2.0,
|
||||
blurRadius:
|
||||
0.0, // Sharper shadow to avoid blurry/double look
|
||||
color: ThemeConfig.feedbackShadow,
|
||||
offset: Offset(1.0, 1.0),
|
||||
offset: Offset(1.5, 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,58 +1,90 @@
|
|||
# 62. 애니메이션 및 피드백 동기화 관련 이슈 진행 현황
|
||||
|
||||
## 1. 문제 발생 현상
|
||||
* 플레이어 공격 실패(`MISS`) 시, 화면에 `MISS` 텍스트가 두 번 올라오는 현상 발생.
|
||||
* 적의 방어 실패(`FAILED`) 시에도 유사한 중복 텍스트 현상 발생.
|
||||
* 로그상 (`[UI Debug] Feedback Event`)으로는 이벤트가 한 번만 발생했지만, UI에는 두 번 표시됨.
|
||||
* 특히, "적이 방어 행동(Action Phase, Phase 1)을 했을 때만" 문제가 발생한다고 특정됨.
|
||||
|
||||
- 플레이어 공격 실패(`MISS`) 시, 화면에 `MISS` 텍스트가 두 번 올라오는 현상 발생.
|
||||
- 적의 방어 실패(`FAILED`) 시에도 유사한 중복 텍스트 현상 발생.
|
||||
- 로그상 (`[UI Debug] Feedback Event`)으로는 이벤트가 한 번만 발생했지만, UI에는 두 번 표시됨.
|
||||
- 특히, "적이 방어 행동(Action Phase, Phase 1)을 했을 때만" 문제가 발생한다고 특정됨.
|
||||
|
||||
## 2. 진단 및 해결 시도
|
||||
|
||||
### 2.1. 원인 가설
|
||||
|
||||
1. **`BattleScreen` 인스턴스 중복:** 가장 유력한 가설. 하나의 `EffectEvent`가 발생했을 때, 여러 `BattleScreen` 인스턴스가 각자 이벤트를 받아 화면에 피드백 텍스트를 띄우는 경우.
|
||||
* `[UI Debug] BattleScreen initialized: ${hashCode}` 로그로 확인 필요. **현재 로그에서는 단 한 번만 찍히는 것으로 보이나, 화면에는 중복 현상이 발생하고 있음.** 이는 `BattleScreen` 인스턴스 자체의 중복보다는, **동일 인스턴스 내에서의 렌더링 또는 이벤트 처리 문제**임을 시사.
|
||||
- `[UI Debug] BattleScreen initialized: ${hashCode}` 로그로 확인 필요. **현재 로그에서는 단 한 번만 찍히는 것으로 보이나, 화면에는 중복 현상이 발생하고 있음.** 이는 `BattleScreen` 인스턴스 자체의 중복보다는, **동일 인스턴스 내에서의 렌더링 또는 이벤트 처리 문제**임을 시사.
|
||||
2. **`_addFloatingEffect` 내부의 `setState` 문제 / `FloatingFeedbackText` 위젯의 생명주기 문제:** `setState` 호출 시 `_floatingFeedbackTexts` 리스트에 위젯이 중복으로 추가되거나, 위젯 렌더링 과정에서 불필요한 복제가 발생하는 경우.
|
||||
* `_floatingFeedbackTexts.clear()` 로직 도입 및 `eventId` 기반 중복 체크로 리스트 데이터 중복은 해결. 그러나 화면상 중복 표시는 지속.
|
||||
- `_floatingFeedbackTexts.clear()` 로직 도입 및 `eventId` 기반 중복 체크로 리스트 데이터 중복은 해결. 그러나 화면상 중복 표시는 지속.
|
||||
3. **UI 렌더링 타이밍/시각적 착시:** `FloatingFeedbackText` 위젯의 생명주기가 꼬여서 이전 텍스트가 완전히 사라지기 전에 새 텍스트가 뜨거나, 애니메이션이 반복되는 것처럼 보이는 착시.
|
||||
|
||||
### 2.2. 현재까지 적용된 주요 조치
|
||||
|
||||
#### **A. 피드백 텍스트 중복 출력 방지 및 디버깅 강화**
|
||||
* **`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` 초기화 횟수 확인용. **로그상 1회**).
|
||||
* `[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}` (`_addFloatingEffect` 호출 확인용. **로그상 1회**).
|
||||
* `FloatingFeedbackText`에 `event.id` 전체 표시 (화면상 ID 일치 여부 확인용).
|
||||
* `[Logic Debug]` 로그 추가 (`BattleProvider` 이벤트 발행 시점 확인용).
|
||||
|
||||
- **`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` 초기화 횟수 확인용. **로그상 1회**).
|
||||
- `[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}` (`_addFloatingEffect` 호출 확인용. **로그상 1회**).
|
||||
- `FloatingFeedbackText`에 `event.id` 전체 표시 (화면상 ID 일치 여부 확인용).
|
||||
- `[Logic Debug]` 로그 추가 (`BattleProvider` 이벤트 발행 시점 확인용).
|
||||
|
||||
#### **B. 비동기 턴 흐름 안정화 (Transaction ID 패턴)**
|
||||
* **`_turnTransactionId` 패턴 도입:** `BattleProvider`에 `int _turnTransactionId`를 추가하여 턴 전환 시 ID를 증가시키고, 모든 `Future.delayed` 콜백 내에서 ID를 체크하여 이전 턴의 "좀비 타이머"가 다음 턴 로직을 침범하지 않도록 방지.
|
||||
|
||||
- **`_turnTransactionId` 패턴 도입:** `BattleProvider`에 `int _turnTransactionId`를 추가하여 턴 전환 시 ID를 증가시키고, 모든 `Future.delayed` 콜백 내에서 ID를 체크하여 이전 턴의 "좀비 타이머"가 다음 턴 로직을 침범하지 않도록 방지.
|
||||
|
||||
#### **C. 적의 방어 행동 타이밍 및 시각화 개선**
|
||||
* **방어도 계산식 수정:** `_generateEnemyIntent`에서 `baseDef * 2` 상수를 제거하여 `Risky` 방어가 과도하게 적용되지 않도록 수정.
|
||||
* **"Just-in-Time" 방어 도입:** 플레이어가 공격하기 직전(`playerAction` 시작 시)에 적이 방어(성공/실패) 행동을 발동하고, 500ms 딜레이 후 플레이어 공격 로직 진행.
|
||||
* **방어 애니메이션 추가:** `BattleAnimationWidget`에 `animateDefense` 메서드 추가 (좌우 흔들림).
|
||||
* **`_addFloatingEffect` 로직 통합:**
|
||||
* `showEffect`는 순수 UI만 담당 (딜레이, `handleImpact` 호출 제거).
|
||||
* 공격/방어 애니메이션은 성공/실패 모두 실행. (`animateAttack` 또는 `animateDefense`).
|
||||
* `MISS`/`FAILED` 시에는 500ms 딜레이 후 `handleImpact`.
|
||||
* 적/플레이어 공격 `MISS` 시 `RiskLevel.safe` 애니메이션 강제.
|
||||
* 적 공격 애니메이션 중 `intent` 정보 숨김.
|
||||
* **Phase 1 방어 행동 시 딜레이 조절:** `skipAnimations` 설정에 따라 딜레이(1.5초 또는 0.5초) 적용.
|
||||
|
||||
- **방어도 계산식 수정:** `_generateEnemyIntent`에서 `baseDef * 2` 상수를 제거하여 `Risky` 방어가 과도하게 적용되지 않도록 수정.
|
||||
- **"Just-in-Time" 방어 도입:** 플레이어가 공격하기 직전(`playerAction` 시작 시)에 적이 방어(성공/실패) 행동을 발동하고, 500ms 딜레이 후 플레이어 공격 로직 진행.
|
||||
- **방어 애니메이션 추가:** `BattleAnimationWidget`에 `animateDefense` 메서드 추가 (좌우 흔들림).
|
||||
- **`_addFloatingEffect` 로직 통합:**
|
||||
- `showEffect`는 순수 UI만 담당 (딜레이, `handleImpact` 호출 제거).
|
||||
- 공격/방어 애니메이션은 성공/실패 모두 실행. (`animateAttack` 또는 `animateDefense`).
|
||||
- `MISS`/`FAILED` 시에는 500ms 딜레이 후 `handleImpact`.
|
||||
- 적/플레이어 공격 `MISS` 시 `RiskLevel.safe` 애니메이션 강제.
|
||||
- 적 공격 애니메이션 중 `intent` 정보 숨김.
|
||||
- **Phase 1 방어 행동 시 딜레이 조절:** `skipAnimations` 설정에 따라 딜레이(1.5초 또는 0.5초) 적용.
|
||||
|
||||
## 3. 남아있는 문제 (현재 진단)
|
||||
* 로그상 `[UI Debug] Feedback Event`는 한 번만 찍히지만, **화면에는 `MISS` 텍스트가 두 번 표시됨.**
|
||||
* **`[UI Debug] BattleScreen initialized` 로그도 1회만 찍히므로 `BattleScreen` 인스턴스 중복은 아님.**
|
||||
* 이벤트가 두 번 발행되거나 `_addFloatingEffect` 함수가 두 번 호출되는 것도 아님. (로그 증거).
|
||||
* `_floatingFeedbackTexts.clear()` 로직 도입으로 리스트에 두 번 추가될 수도 없음.
|
||||
* **가장 유력한 가설:** `_addFloatingEffect` 내부의 `setState`가 호출된 후, Flutter의 위젯 트리 리빌드 및 렌더링 과정에서 **`FloatingFeedbackText` 위젯이 어떤 이유로 인해 화면에 두 번 그려지거나 (복제되거나), 또는 렌더링 과정에서 잔상이 남아 두 번으로 보이는 것.**
|
||||
* 이것은 Flutter의 렌더링 파이프라인이나 `Stack` / `Positioned` 위젯의 상호작용과 관련된 심도 깊은 UI 버그일 가능성이 높음.
|
||||
|
||||
- 로그상 `[UI Debug] Feedback Event`는 한 번만 찍히지만, **화면에는 `MISS` 텍스트가 두 번 표시됨.**
|
||||
- **`[UI Debug] BattleScreen initialized` 로그도 1회만 찍히므로 `BattleScreen` 인스턴스 중복은 아님.**
|
||||
- 이벤트가 두 번 발행되거나 `_addFloatingEffect` 함수가 두 번 호출되는 것도 아님. (로그 증거).
|
||||
- `_floatingFeedbackTexts.clear()` 로직 도입으로 리스트에 두 번 추가될 수도 없음.
|
||||
- **가장 유력한 가설:** `_addFloatingEffect` 내부의 `setState`가 호출된 후, Flutter의 위젯 트리 리빌드 및 렌더링 과정에서 **`FloatingFeedbackText` 위젯이 어떤 이유로 인해 화면에 두 번 그려지거나 (복제되거나), 또는 렌더링 과정에서 잔상이 남아 두 번으로 보이는 것.**
|
||||
- 이것은 Flutter의 렌더링 파이프라인이나 `Stack` / `Positioned` 위젯의 상호작용과 관련된 심도 깊은 UI 버그일 가능성이 높음.
|
||||
|
||||
## 4. 다음 단계 제안
|
||||
* **화면상 `MISS` 텍스트의 ID 확인:** 화면에 보이는 두 개의 `MISS` 텍스트의 ID가 **정확히 동일한지** 확인 필요 (현재 `event.id` 전체를 표시하도록 수정됨).
|
||||
* **ID가 동일하다면:** 하나의 `FeedbackTextData` 객체가 UI에 중복 렌더링되는 문제. (Key 문제, `Stack` 리빌드 문제 등)
|
||||
* **ID가 다르다면:** `_addFloatingEffect` 자체가 두 번 호출된 것. (로그가 하나라는 것과 모순됨. 로그 시스템 확인 필요)
|
||||
* **Flutter DevTools (Inspector) 활용:** 다른 환경에서 `Widget Inspector`를 통해 `Stack` 아래에 `FloatingFeedbackText` 위젯이 실제로 몇 개나 존재하는지 확인하는 것이 가장 정확합니다.
|
||||
|
||||
- **화면상 `MISS` 텍스트의 ID 확인:** 화면에 보이는 두 개의 `MISS` 텍스트의 ID가 **정확히 동일한지** 확인 필요 (현재 `event.id` 전체를 표시하도록 수정됨).
|
||||
- **ID가 동일하다면:** 하나의 `FeedbackTextData` 객체가 UI에 중복 렌더링되는 문제. (Key 문제, `Stack` 리빌드 문제 등)
|
||||
- **ID가 다르다면:** `_addFloatingEffect` 자체가 두 번 호출된 것. (로그가 하나라는 것과 모순됨. 로그 시스템 확인 필요)
|
||||
- **Flutter DevTools (Inspector) 활용:** 다른 환경에서 `Widget Inspector`를 통해 `Stack` 아래에 `FloatingFeedbackText` 위젯이 실제로 몇 개나 존재하는지 확인하는 것이 가장 정확합니다.
|
||||
|
||||
**현재까지의 모든 문제 해결 노력은 `BattleProvider` 내의 로직 중복이나 타이밍 오류를 잡는 데 초점을 맞췄습니다. 하지만 `MISS` 텍스트 중복 문제는 `BattleScreen` (UI) 쪽에서 발생하는 현상으로 보입니다.**
|
||||
|
||||
## 5. 최근 조치 및 해결 (2025-12-08 Update)
|
||||
|
||||
### 5.1. 적 방어 턴 멈춤 현상 (Hang) 해결
|
||||
|
||||
- **문제:** 적이 방어(Defend) 행동을 할 때, 특히 실패하거나 피드백이 있는 경우 턴이 넘어가지 않고 멈추는 현상 발생.
|
||||
- **원인:** `BattleProvider.handleImpact` 메서드 내에 `isSuccess == false` 또는 `feedbackType != null`인 경우 조기 반환(Early Return)하는 로직이 있었는데, 여기서 `ActionType.defend`를 예외 처리하지 않아 방어 실패 시 턴 종료 로직(`_endEnemyTurn`)이 호출되지 않았음. (기존 로직이 공격 중심이라 방어 실패를 고려하지 않음).
|
||||
- **해결:** `handleImpact`의 조기 반환 조건에 `&& event.type != ActionType.defend`를 추가하여, 방어 이벤트는 실패하더라도 정상적인 턴 종료 로직을 타도록 수정함.
|
||||
|
||||
### 5.2. 선제 방어 (Pre-emptive Defense) 로직 재도입 및 개선
|
||||
|
||||
- **배경:** 기존의 "적 턴에 방어" 방식은 직관적(Intent가 보이면 바로 방어해야 함)이지 않고, "Just-in-Time" 방식(플레이어 공격 시점에 방어 끼어들기)은 "두 번 행동하는 느낌"을 주어 어색했음.
|
||||
- **해결:** **"턴 시작 시 방어(Start-of-Turn Defense)"** 방식으로 변경.
|
||||
1. **플레이어 턴 시작 시 (`_startPlayerTurn`):** 적의 Intent가 `Defend`라면, **즉시 방어도를 적용**하고 로그를 출력함. (애니메이션 없음).
|
||||
2. **플레이어 행동:** 이미 방어도가 올라간 적을 공격함. (끼어드는 연출 없음).
|
||||
3. **적 턴 (`_startEnemyTurn`):** 이미 방어 행동을 했으므로, 별도의 애니메이션 없이 "방어 태세 유지" 로그만 출력하고 턴을 종료함.
|
||||
- **결과:** "의도가 보이면 이미 방어 상태"라는 직관과 일치하며, 불필요한 연출 끊김 현상을 해결함.
|
||||
|
||||
### 5.3. UI 및 설정 정리
|
||||
|
||||
- **디버그 로그 제거:** `BattleProvider` 및 `BattleScreen`에 남아있던 `[Logic Debug]`, `[UI Debug]` 등의 `print` 문을 주석 처리하거나 제거하여 콘솔을 정리함.
|
||||
- 특히 화면상 `MISS(1234)` 처럼 ID가 노출되던 문제를 해결하기 위해 `BattleScreen`의 텍스트 보간 로직 수정.
|
||||
- **UI 복구:** 임시로 가려두었던 적의 **Intent UI** (`CharacterStatusCard`)를 다시 주석 해제하여 보이도록 복구함.
|
||||
- **설정 변경:** `SettingsProvider`에서 `_enableEnemyAnimations` 기본값을 `true`로 변경하여 적 애니메이션이 기본적으로 켜지도록 함.
|
||||
|
|
|
|||
Loading…
Reference in New Issue