Refactor: Slim down BattleProvider and extract logic services. Restore original animations and positioning.
This commit is contained in:
+83
-906
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,432 @@
|
||||
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<T extends StatefulWidget> on State<T> {
|
||||
// State variables for animations
|
||||
final List<DamageTextData> floatingDamageTexts = [];
|
||||
final List<FloatingEffectData> floatingEffects = [];
|
||||
final List<FeedbackTextData> floatingFeedbackTexts = [];
|
||||
|
||||
final GlobalKey playerKey = GlobalKey();
|
||||
final GlobalKey enemyKey = GlobalKey();
|
||||
final GlobalKey stackKey = GlobalKey();
|
||||
final GlobalKey<ShakeWidgetState> shakeKey = GlobalKey<ShakeWidgetState>();
|
||||
final GlobalKey<BattleAnimationWidgetState> playerAnimKey = GlobalKey();
|
||||
final GlobalKey<BattleAnimationWidgetState> enemyAnimKey = GlobalKey();
|
||||
final GlobalKey<ExplosionWidgetState> explosionKey = GlobalKey();
|
||||
final GlobalKey<EffectSpriteWidgetState> effectSpriteKey = GlobalKey();
|
||||
final GlobalKey rootStackKey = GlobalKey();
|
||||
|
||||
AnimationPhase playerAnimPhase = AnimationPhase.none;
|
||||
RiskLevel? activeRiskLevel;
|
||||
bool isAttackSuccess = true;
|
||||
bool isPlayerAttacking = false;
|
||||
bool isEnemyAttacking = false;
|
||||
|
||||
StreamSubscription<DamageEvent>? damageSubscription;
|
||||
StreamSubscription<EffectEvent>? effectSubscription;
|
||||
StreamSubscription<HealEvent>? 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<BattleProvider>().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<BattleProvider>().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<SettingsProvider>().enableEnemyAnimations;
|
||||
if (!enableAnim) {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().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<BattleProvider>().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<BattleProvider>().handleImpact(event);
|
||||
}).then((_) {
|
||||
if (mounted) setState(() => isPlayerAttacking = false);
|
||||
});
|
||||
} else {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().handleImpact(event);
|
||||
}
|
||||
} else {
|
||||
showEffect();
|
||||
context.read<BattleProvider>().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<void> waitForBattleAnimationsToSettle() async {
|
||||
final deadline = DateTime.now().add(
|
||||
AnimationConfig.attackRiskyTotal +
|
||||
AnimationConfig.floatingTextDuration +
|
||||
const Duration(milliseconds: 400),
|
||||
);
|
||||
|
||||
while (mounted && hasPendingBattleAnimations && DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user