game/lib/screens/battle_visual_handler.dart

433 lines
15 KiB
Dart

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));
}
}
}