import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../game/enums.dart'; import '../game/models.dart'; import '../game/config.dart'; import '../providers/battle_provider.dart'; import '../providers/settings_provider.dart'; import '../widgets/battle/effect_sprite_widget.dart'; import '../widgets/battle/floating_battle_texts.dart'; import '../widgets/battle/battle_animation_widget.dart'; import '../widgets/battle/shake_widget.dart'; import '../widgets/battle/explosion_widget.dart'; enum AnimationPhase { none, start, middle, end, hurt, block } mixin BattleVisualHandler on State { // State variables for animations final List floatingDamageTexts = []; final List floatingEffects = []; final List floatingFeedbackTexts = []; final GlobalKey playerKey = GlobalKey(); final GlobalKey enemyKey = GlobalKey(); final GlobalKey stackKey = GlobalKey(); final GlobalKey shakeKey = GlobalKey(); final GlobalKey playerAnimKey = GlobalKey(); final GlobalKey enemyAnimKey = GlobalKey(); final GlobalKey explosionKey = GlobalKey(); final GlobalKey effectSpriteKey = GlobalKey(); final GlobalKey rootStackKey = GlobalKey(); AnimationPhase playerAnimPhase = AnimationPhase.none; RiskLevel? activeRiskLevel; bool isAttackSuccess = true; bool isPlayerAttacking = false; bool isEnemyAttacking = false; StreamSubscription? damageSubscription; StreamSubscription? effectSubscription; StreamSubscription? healSubscription; DateTime? lastFeedbackTime; void setupVisualListeners(BattleProvider battleProvider) { damageSubscription = battleProvider.damageStream.listen(_onDamageEvent); effectSubscription = battleProvider.effectStream.listen(onEffectEvent); healSubscription = battleProvider.healStream.listen(onHealEvent); } void disposeVisualListeners() { damageSubscription?.cancel(); effectSubscription?.cancel(); healSubscription?.cancel(); } // --- Animation Core Logic --- void _onDamageEvent(DamageEvent event) { if (!mounted) return; if (event.target == DamageTarget.player) { if (event.armorDamage > 0) { _triggerPlayerBlock(); } else if (event.damage > 0) { _triggerPlayerHurt(); } } addFloatingDamageText(event); } void _triggerPlayerHurt() { if (playerAnimPhase != AnimationPhase.none && playerAnimPhase != AnimationPhase.hurt) { return; } setState(() => playerAnimPhase = AnimationPhase.hurt); Future.delayed(const Duration(milliseconds: 800), () { if (mounted && playerAnimPhase == AnimationPhase.hurt) { setState(() => playerAnimPhase = AnimationPhase.none); } }); } void _triggerPlayerBlock() { if (playerAnimPhase != AnimationPhase.none && playerAnimPhase != AnimationPhase.block) { return; } setState(() => playerAnimPhase = AnimationPhase.block); Future.delayed(const Duration(milliseconds: 800), () { if (mounted && playerAnimPhase == AnimationPhase.block) { setState(() => playerAnimPhase = AnimationPhase.none); } }); } void onHealEvent(HealEvent event) { if (!mounted) return; RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?; if (rootBox == null) return; Offset rootOffset = rootBox.localToGlobal(Offset.zero); Offset position = Offset(rootBox.size.width / 2, rootBox.size.height / 2); if (event.target == HealTarget.player && playerAnimKey.currentContext != null) { RenderBox? imageBox = playerAnimKey.currentContext!.findRenderObject() as RenderBox?; if (imageBox != null) { Offset globalCenter = imageBox.localToGlobal( Offset(imageBox.size.width / 2, imageBox.size.height / 2), ); position = globalCenter - rootOffset; } } effectSpriteKey.currentState?.playEffect( position: position, assetPath: 'assets/images/effects/heal.png', frameCount: 4, tileWidth: 100.0, tileHeight: 100.0, scale: 6.0, ); final String id = UniqueKey().toString(); setState(() { floatingDamageTexts.add( DamageTextData( id: id, widget: Positioned( key: ValueKey('pos_$id'), left: position.dx + BattleConfig.damageTextOffsetX, top: position.dy + BattleConfig.damageTextOffsetY, child: FloatingDamageText( key: ValueKey(id), damage: "+${event.amount}", color: ThemeConfig.statHpPlayerColor, onRemove: () { if (mounted) { setState(() => floatingDamageTexts.removeWhere((e) => e.id == id)); } }, ), ), ), ); }); } void onEffectEvent(EffectEvent event) { if (!mounted) return; void showEffect() { if (event.feedbackType != null) { addFloatingFeedbackText(event); return; } IconData icon = BattleConfig.getIcon(event.type); Color color = BattleConfig.getColor(event.type, event.risk); double size = BattleConfig.getSize(event.risk); addFloatingEffect(event, icon, color, size); } if (event.isVisualOnly) { showEffect(); context.read().handleImpact(event); } else if (event.type == ActionType.attack && event.target == EffectTarget.enemy) { final RenderBox? pBox = playerKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? eBox = enemyKey.currentContext?.findRenderObject() as RenderBox?; if (pBox != null && eBox != null) { final offset = eBox.localToGlobal(Offset.zero) - pBox.localToGlobal(Offset.zero); setState(() { isPlayerAttacking = true; activeRiskLevel = event.risk; isAttackSuccess = event.isSuccess == true && event.feedbackType == null; }); final animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; playerAnimKey.currentState?.animateAttack( offset, () { showEffect(); context.read().handleImpact(event); if (event.risk == RiskLevel.risky && event.feedbackType == null) { shakeKey.currentState?.shake(); RenderBox? sBox = stackKey.currentContext?.findRenderObject() as RenderBox?; if (sBox != null) { Offset localEnemyPos = sBox.globalToLocal(eBox.localToGlobal(Offset.zero)) + Offset(eBox.size.width / 2, eBox.size.height / 2); explosionKey.currentState?.explode(localEnemyPos); } } }, animRisk, onAnimationStart: () => setState(() => playerAnimPhase = AnimationPhase.start), onAnimationMiddle: () => setState(() => playerAnimPhase = AnimationPhase.middle), onAnimationEnd: () => setState(() => playerAnimPhase = AnimationPhase.end), ).then((_) { if (mounted) { setState(() { isPlayerAttacking = false; playerAnimPhase = AnimationPhase.none; activeRiskLevel = null; isAttackSuccess = true; }); } }); } } else if (event.type == ActionType.attack && event.target == EffectTarget.player) { bool enableAnim = context.read().enableEnemyAnimations; if (!enableAnim) { showEffect(); context.read().handleImpact(event); return; } final RenderBox? pBox = playerKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? eBox = enemyKey.currentContext?.findRenderObject() as RenderBox?; if (pBox != null && eBox != null) { final offset = pBox.localToGlobal(Offset.zero) - eBox.localToGlobal(Offset.zero); setState(() => isEnemyAttacking = true); final animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; enemyAnimKey.currentState?.animateAttack(offset, () { showEffect(); context.read().handleImpact(event); if (event.risk == RiskLevel.risky && event.feedbackType == null) { shakeKey.currentState?.shake(); RenderBox? sBox = stackKey.currentContext?.findRenderObject() as RenderBox?; if (sBox != null) { Offset localPlayerPos = sBox.globalToLocal(pBox.localToGlobal(Offset.zero)) + Offset(pBox.size.width / 2, pBox.size.height / 2); explosionKey.currentState?.explode(localPlayerPos); } } }, animRisk).then((_) { if (mounted) setState(() => isEnemyAttacking = false); }); } } else if (event.type == ActionType.defend) { if (event.target == EffectTarget.player) { setState(() => isPlayerAttacking = true); playerAnimKey.currentState?.animateDefense(() { showEffect(); context.read().handleImpact(event); }).then((_) { if (mounted) setState(() => isPlayerAttacking = false); }); } else { showEffect(); context.read().handleImpact(event); } } else { showEffect(); context.read().handleImpact(event); } } void addFloatingDamageText(DamageEvent event) { if (!mounted) return; final String id = UniqueKey().toString(); final targetKey = event.target == DamageTarget.player ? playerKey : enemyKey; final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height / 3)); RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?; if (rootBox != null) position = rootBox.globalToLocal(position); setState(() { floatingDamageTexts.add( DamageTextData( id: id, widget: Positioned( key: ValueKey('pos_$id'), left: position.dx, top: position.dy, child: FloatingDamageText( key: ValueKey(id), damage: event.damage.toString(), color: event.color, onRemove: () { if (mounted) { setState(() => floatingDamageTexts.removeWhere((e) => e.id == id)); } }, ), ), ), ); }); } void addFloatingEffect(EffectEvent event, IconData icon, Color color, double size) { if (!mounted) return; final String id = UniqueKey().toString(); final targetKey = event.target == EffectTarget.player ? playerKey : enemyKey; final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; Offset position = box.localToGlobal(Offset.zero); RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?; if (rootBox != null) { position = rootBox.globalToLocal(position); } double offsetX = 0; double offsetY = 0; if (event.target == EffectTarget.enemy) { offsetX = box.size.width * BattleConfig.effectEnemyOffsetX; offsetY = box.size.height * BattleConfig.effectEnemyOffsetY; } else { offsetX = box.size.width * BattleConfig.effectPlayerOffsetX; offsetY = box.size.height * BattleConfig.effectPlayerOffsetY; } position = position + Offset(offsetX, offsetY); setState(() { floatingEffects.add( FloatingEffectData( id: id, widget: Positioned( key: ValueKey('pos_$id'), left: position.dx, top: position.dy, child: FloatingEffect( key: ValueKey(id), icon: icon, color: color, size: size, onRemove: () { if (mounted) { setState(() => floatingEffects.removeWhere((e) => e.id == id)); } }, ), ), ), ); }); } void addFloatingFeedbackText(EffectEvent event) { if (!mounted || event.feedbackType == null) return; final now = DateTime.now(); if (lastFeedbackTime != null && now.difference(lastFeedbackTime!) < const Duration(milliseconds: 300)) { return; } lastFeedbackTime = now; final String id = UniqueKey().toString(); final targetKey = event.target == EffectTarget.player ? playerKey : enemyKey; final RenderBox? box = targetKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; Offset position = box.localToGlobal(Offset.zero); RenderBox? rootBox = rootStackKey.currentContext?.findRenderObject() as RenderBox?; if (rootBox != null) { position = rootBox.globalToLocal(position); } double offsetX = 0; double offsetY = 0; if (event.target == EffectTarget.enemy) { offsetX = box.size.width * BattleConfig.effectEnemyOffsetX; offsetY = box.size.height * BattleConfig.effectEnemyOffsetY; } else { offsetX = box.size.width * BattleConfig.effectPlayerOffsetX; offsetY = box.size.height * BattleConfig.effectPlayerOffsetY; } position = position + Offset(offsetX, offsetY); final feedbackText = BattleConfig.getFeedbackText(event.feedbackType!); final feedbackColor = BattleConfig.getFeedbackColor(event.feedbackType!); setState(() { floatingFeedbackTexts.add( FeedbackTextData( id: id, eventId: event.id, widget: Positioned( key: ValueKey('pos_$id'), 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)); } }, ), ), ), ); }); } bool get hasPendingBattleAnimations { return isPlayerAttacking || isEnemyAttacking || floatingDamageTexts.isNotEmpty || floatingEffects.isNotEmpty || floatingFeedbackTexts.isNotEmpty || (explosionKey.currentState?.isAnimating ?? false); } Future waitForBattleAnimationsToSettle() async { final deadline = DateTime.now().add( AnimationConfig.attackRiskyTotal + AnimationConfig.floatingTextDuration + const Duration(milliseconds: 400), ); while (mounted && hasPendingBattleAnimations && DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(milliseconds: 50)); } } }