433 lines
15 KiB
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));
|
|
}
|
|
}
|
|
}
|