update
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../game/enums.dart';
|
||||
|
||||
class BattleAnimationWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const BattleAnimationWidget({super.key, required this.child});
|
||||
|
||||
@override
|
||||
BattleAnimationWidgetState createState() => BattleAnimationWidgetState();
|
||||
}
|
||||
|
||||
class BattleAnimationWidgetState extends State<BattleAnimationWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _scaleController;
|
||||
late AnimationController _translateController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<Offset> _translateAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scaleController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_translateController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.2,
|
||||
).animate(CurvedAnimation(parent: _scaleController, curve: Curves.easeOut));
|
||||
|
||||
// Default translation, will be updated on animateAttack
|
||||
_translateAnimation = Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: Offset.zero,
|
||||
).animate(_translateController);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleController.dispose();
|
||||
_translateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> animateAttack(
|
||||
Offset targetOffset,
|
||||
VoidCallback onImpact,
|
||||
RiskLevel risk,
|
||||
) async {
|
||||
if (risk == RiskLevel.safe || risk == RiskLevel.normal) {
|
||||
// Safe & Normal: Dash/Wobble without scale
|
||||
final isSafe = risk == RiskLevel.safe;
|
||||
final duration = isSafe ? 500 : 400;
|
||||
final offsetFactor = isSafe ? 0.2 : 0.5;
|
||||
|
||||
_translateController.duration = Duration(milliseconds: duration);
|
||||
_translateAnimation =
|
||||
Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: targetOffset * offsetFactor,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _translateController,
|
||||
curve: Curves.easeOutQuad,
|
||||
),
|
||||
);
|
||||
|
||||
await _translateController.forward();
|
||||
if (!mounted) return;
|
||||
onImpact();
|
||||
await _translateController.reverse();
|
||||
} else {
|
||||
// Risky: Scale + Heavy Dash
|
||||
_scaleController.duration = const Duration(milliseconds: 600);
|
||||
_translateController.duration = const Duration(milliseconds: 500);
|
||||
|
||||
// 1. Scale Up (Preparation)
|
||||
await _scaleController.forward();
|
||||
if (!mounted) return;
|
||||
|
||||
// 2. Dash to Target (Impact)
|
||||
_translateAnimation = Tween<Offset>(begin: Offset.zero, end: targetOffset)
|
||||
.animate(
|
||||
CurvedAnimation(
|
||||
parent: _translateController,
|
||||
curve: Curves.easeInExpo, // Heavy impact curve
|
||||
),
|
||||
);
|
||||
|
||||
await _translateController.forward();
|
||||
if (!mounted) return;
|
||||
|
||||
// 3. Impact Callback (Shake)
|
||||
onImpact();
|
||||
|
||||
// 4. Return (Reset)
|
||||
_scaleController.reverse();
|
||||
_translateController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([_scaleController, _translateController]),
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: _translateAnimation.value,
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,101 +3,124 @@ import 'package:provider/provider.dart';
|
||||
import '../../game/model/entity.dart';
|
||||
import '../../game/enums.dart';
|
||||
import '../../providers/battle_provider.dart';
|
||||
import 'battle_animation_widget.dart';
|
||||
import '../../game/config/theme_config.dart';
|
||||
import '../../game/config/animation_config.dart';
|
||||
|
||||
class CharacterStatusCard extends StatelessWidget {
|
||||
final Character character;
|
||||
final bool isPlayer;
|
||||
final bool isTurn;
|
||||
final GlobalKey<BattleAnimationWidgetState>? animationKey;
|
||||
final bool hideStats;
|
||||
|
||||
const CharacterStatusCard({
|
||||
super.key,
|
||||
required this.character,
|
||||
this.isPlayer = false,
|
||||
this.isTurn = false,
|
||||
this.animationKey,
|
||||
this.hideStats = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
"Armor: ${character.armor}",
|
||||
style: const TextStyle(color: Colors.white),
|
||||
AnimatedOpacity(
|
||||
opacity: hideStats ? 0.0 : 1.0,
|
||||
duration: AnimationConfig.fadeDuration,
|
||||
child: Column(
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
"Armor: ${character.armor}",
|
||||
style: const TextStyle(color: ThemeConfig.textColorWhite),
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
"${character.name}: HP ${character.hp}/${character.totalMaxHp}",
|
||||
style: TextStyle(
|
||||
color: character.isDead
|
||||
? ThemeConfig.statHpEnemyColor
|
||||
: ThemeConfig.textColorWhite,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: LinearProgressIndicator(
|
||||
value: character.totalMaxHp > 0
|
||||
? character.hp / character.totalMaxHp
|
||||
: 0,
|
||||
color: !isPlayer
|
||||
? ThemeConfig.statHpEnemyColor
|
||||
: ThemeConfig.statHpPlayerColor,
|
||||
backgroundColor: ThemeConfig.textColorGrey,
|
||||
),
|
||||
),
|
||||
if (character.statusEffects.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Wrap(
|
||||
spacing: 4.0,
|
||||
children: character.statusEffects.map((effect) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ThemeConfig.effectBg,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
"${effect.type.name.toUpperCase()} (${effect.duration})",
|
||||
style: const TextStyle(
|
||||
color: ThemeConfig.effectText,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
Text("ATK: ${character.totalAtk}"),
|
||||
Text("DEF: ${character.totalDefense}"),
|
||||
Text("LUCK: ${character.totalLuck}"),
|
||||
],
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
"${character.name}: HP ${character.hp}/${character.totalMaxHp}",
|
||||
style: TextStyle(
|
||||
color: character.isDead ? Colors.red : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: LinearProgressIndicator(
|
||||
value: character.totalMaxHp > 0
|
||||
? character.hp / character.totalMaxHp
|
||||
: 0,
|
||||
color: !isPlayer ? Colors.red : Colors.green,
|
||||
backgroundColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
if (character.statusEffects.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Wrap(
|
||||
spacing: 4.0,
|
||||
children: character.statusEffects.map((effect) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepOrange,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
"${effect.type.name.toUpperCase()} (${effect.duration})",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
Text("ATK: ${character.totalAtk}"),
|
||||
Text("DEF: ${character.totalDefense}"),
|
||||
// 캐릭터 아이콘/이미지 영역 추가
|
||||
Container(
|
||||
width: 100, // 임시 크기
|
||||
height: 100, // 임시 크기
|
||||
decoration: BoxDecoration(
|
||||
color: isPlayer
|
||||
? Colors.lightBlue
|
||||
: Colors.deepOrange, // 플레이어/적 구분 색상
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: isPlayer
|
||||
? const Icon(
|
||||
Icons.person,
|
||||
size: 60,
|
||||
color: Colors.white,
|
||||
) // 플레이어 아이콘
|
||||
: const Icon(
|
||||
Icons.psychology,
|
||||
size: 60,
|
||||
color: Colors.white,
|
||||
), // 적 아이콘 (몬스터 대신)
|
||||
BattleAnimationWidget(
|
||||
key: animationKey,
|
||||
child: Container(
|
||||
width: 100, // 임시 크기
|
||||
height: 100, // 임시 크기
|
||||
decoration: BoxDecoration(
|
||||
color: isPlayer
|
||||
? Colors.lightBlue
|
||||
: Colors.deepOrange, // 플레이어/적 구분 색상
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: isPlayer
|
||||
? const Icon(
|
||||
Icons.person,
|
||||
size: 60,
|
||||
color: ThemeConfig.textColorWhite,
|
||||
) // 플레이어 아이콘
|
||||
: const Icon(
|
||||
Icons.psychology,
|
||||
size: 60,
|
||||
color: ThemeConfig.textColorWhite,
|
||||
), // 적 아이콘 (몬스터 대신)
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8), // 아이콘과 정보 사이 간격
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Particle {
|
||||
Offset position;
|
||||
Offset velocity;
|
||||
Color color;
|
||||
double size;
|
||||
double life; // 1.0 to 0.0
|
||||
double decay;
|
||||
|
||||
Particle({
|
||||
required this.position,
|
||||
required this.velocity,
|
||||
required this.color,
|
||||
required this.size,
|
||||
required this.life,
|
||||
required this.decay,
|
||||
});
|
||||
}
|
||||
|
||||
class ExplosionWidget extends StatefulWidget {
|
||||
const ExplosionWidget({super.key});
|
||||
|
||||
@override
|
||||
ExplosionWidgetState createState() => ExplosionWidgetState();
|
||||
}
|
||||
|
||||
class ExplosionWidgetState extends State<ExplosionWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
final List<Particle> _particles = [];
|
||||
final Random _random = Random();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
);
|
||||
_controller.addListener(_updateParticles);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_updateParticles);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateParticles() {
|
||||
if (_particles.isEmpty) return;
|
||||
|
||||
for (var i = _particles.length - 1; i >= 0; i--) {
|
||||
final p = _particles[i];
|
||||
p.position += p.velocity;
|
||||
p.velocity += Offset(0, 0.5); // Gravity
|
||||
p.life -= p.decay;
|
||||
if (p.life <= 0) {
|
||||
_particles.removeAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (_particles.isEmpty) {
|
||||
_controller.stop();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void explode(Offset position) {
|
||||
// Clear old particles if any (optional, or just add more)
|
||||
// _particles.clear();
|
||||
|
||||
// Create new particles
|
||||
for (int i = 0; i < 30; i++) {
|
||||
final double angle = _random.nextDouble() * 2 * pi;
|
||||
final double speed = _random.nextDouble() * 5 + 2;
|
||||
final double dx = cos(angle) * speed;
|
||||
final double dy = sin(angle) * speed;
|
||||
|
||||
// Random colors for fire/explosion effect
|
||||
Color color;
|
||||
final r = _random.nextDouble();
|
||||
if (r < 0.33) {
|
||||
color = Colors.redAccent;
|
||||
} else if (r < 0.66) {
|
||||
color = Colors.orangeAccent;
|
||||
} else {
|
||||
color = Colors.yellowAccent;
|
||||
}
|
||||
|
||||
_particles.add(
|
||||
Particle(
|
||||
position: position,
|
||||
velocity: Offset(dx, dy),
|
||||
color: color,
|
||||
size: _random.nextDouble() * 4 + 2,
|
||||
life: 1.0,
|
||||
decay: _random.nextDouble() * 0.02 + 0.01,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_controller.isAnimating) {
|
||||
_controller.repeat(); // Use repeat to keep loop running until empty
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: ExplosionPainter(_particles),
|
||||
size: Size.infinite,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExplosionPainter extends CustomPainter {
|
||||
final List<Particle> particles;
|
||||
|
||||
ExplosionPainter(this.particles);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final p in particles) {
|
||||
final paint = Paint()
|
||||
..color = p.color.withOpacity(p.life.clamp(0.0, 1.0))
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawCircle(p.position, p.size, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant ExplosionPainter oldDelegate) {
|
||||
return true; // Always repaint when animating
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/config/theme_config.dart';
|
||||
import '../../game/config/animation_config.dart';
|
||||
|
||||
class FloatingDamageText extends StatefulWidget {
|
||||
final String damage;
|
||||
final Color color;
|
||||
@@ -26,14 +29,20 @@ class FloatingDamageTextState extends State<FloatingDamageText>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
duration: AnimationConfig.floatingTextDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_offsetAnimation = Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.0),
|
||||
end: const Offset(0.0, -1.5),
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
_offsetAnimation =
|
||||
Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.0),
|
||||
end: const Offset(0.0, -1.5),
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: AnimationConfig.floatingTextCurve,
|
||||
),
|
||||
);
|
||||
|
||||
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(
|
||||
@@ -75,7 +84,7 @@ class FloatingDamageTextState extends State<FloatingDamageText>
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 2.0,
|
||||
color: Colors.black,
|
||||
color: ThemeConfig.feedbackShadow,
|
||||
offset: Offset(1.0, 1.0),
|
||||
),
|
||||
],
|
||||
@@ -124,14 +133,16 @@ class FloatingEffectState extends State<FloatingEffect>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
duration: AnimationConfig.floatingEffectDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.5,
|
||||
end: 1.5,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut));
|
||||
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.5).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: AnimationConfig.floatingEffectScaleCurve,
|
||||
),
|
||||
);
|
||||
|
||||
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(
|
||||
@@ -203,14 +214,20 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
duration: AnimationConfig.floatingTextDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_offsetAnimation = Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.0),
|
||||
end: const Offset(0.0, -1.5),
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
_offsetAnimation =
|
||||
Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.0),
|
||||
end: const Offset(0.0, -1.5),
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: AnimationConfig.floatingTextCurve,
|
||||
),
|
||||
);
|
||||
|
||||
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(
|
||||
@@ -252,7 +269,7 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 2.0,
|
||||
color: Colors.black,
|
||||
color: ThemeConfig.feedbackShadow,
|
||||
offset: Offset(1.0, 1.0),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ShakeWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
final double shakeOffset;
|
||||
final int shakeCount;
|
||||
final Duration duration;
|
||||
|
||||
const ShakeWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.shakeOffset = 10.0,
|
||||
this.shakeCount = 3,
|
||||
this.duration = const Duration(milliseconds: 400),
|
||||
});
|
||||
|
||||
@override
|
||||
ShakeWidgetState createState() => ShakeWidgetState();
|
||||
}
|
||||
|
||||
class ShakeWidgetState extends State<ShakeWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this, duration: widget.duration);
|
||||
_controller.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_controller.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void shake() {
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final double sineValue = sin(
|
||||
widget.shakeCount * 2 * pi * _controller.value,
|
||||
);
|
||||
return Transform.translate(
|
||||
offset: Offset(sineValue * widget.shakeOffset, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user