This commit is contained in:
2025-12-05 16:48:00 +09:00
parent d3fd9680c2
commit d7cd938403
17 changed files with 1137 additions and 411 deletions
@@ -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,
),
);
},
);
}
}
+98 -75
View File
@@ -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), // 아이콘과 정보 사이 간격
+141
View File
@@ -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
}
}
+34 -17
View File
@@ -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),
),
],
+63
View File
@@ -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,
);
}
}