405 lines
11 KiB
Dart
405 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'dart:math' as math;
|
|
import 'dart:async';
|
|
|
|
class ParticleDrawAnimation extends StatefulWidget {
|
|
final List<Map<String, dynamic>> members;
|
|
final Function(String winnerId) onDrawComplete;
|
|
final String? serverSeed;
|
|
final String? clientSeed;
|
|
final int? nonce;
|
|
final Duration animationDuration;
|
|
|
|
const ParticleDrawAnimation({
|
|
super.key,
|
|
required this.members,
|
|
required this.onDrawComplete,
|
|
this.serverSeed,
|
|
this.clientSeed,
|
|
this.nonce,
|
|
this.animationDuration = const Duration(seconds: 5),
|
|
});
|
|
|
|
@override
|
|
State<ParticleDrawAnimation> createState() => _ParticleDrawAnimationState();
|
|
}
|
|
|
|
class _ParticleDrawAnimationState extends State<ParticleDrawAnimation>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController _particleController;
|
|
late AnimationController _winnerController;
|
|
late Animation<double> _particleAnimation;
|
|
late Animation<double> _winnerAnimation;
|
|
|
|
bool _isAnimating = false;
|
|
bool _isComplete = false;
|
|
String? _winnerId;
|
|
Map<String, dynamic>? _winnerData;
|
|
List<Particle> _particles = [];
|
|
Timer? _particleTimer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_particleController = AnimationController(
|
|
duration: widget.animationDuration,
|
|
vsync: this,
|
|
);
|
|
|
|
_winnerController = AnimationController(
|
|
duration: const Duration(milliseconds: 1000),
|
|
vsync: this,
|
|
);
|
|
|
|
_particleAnimation = Tween<double>(
|
|
begin: 0,
|
|
end: 1,
|
|
).animate(CurvedAnimation(
|
|
parent: _particleController,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
|
|
_winnerAnimation = Tween<double>(
|
|
begin: 0,
|
|
end: 1,
|
|
).animate(CurvedAnimation(
|
|
parent: _winnerController,
|
|
curve: Curves.elasticOut,
|
|
));
|
|
|
|
_particleController.addStatusListener((status) {
|
|
if (status == AnimationStatus.completed) {
|
|
_completeAnimation();
|
|
}
|
|
});
|
|
|
|
// Auto-start after delay
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (mounted) _startAnimation();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_particleController.dispose();
|
|
_winnerController.dispose();
|
|
_particleTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startAnimation() {
|
|
if (_isAnimating) return;
|
|
|
|
setState(() {
|
|
_isAnimating = true;
|
|
});
|
|
|
|
_calculateWinner();
|
|
_generateParticles();
|
|
_particleController.forward();
|
|
}
|
|
|
|
void _calculateWinner() {
|
|
final serverSeed = widget.serverSeed ?? 'default_server_seed';
|
|
final clientSeed = widget.clientSeed ?? 'default_client_seed';
|
|
final nonce = widget.nonce ?? DateTime.now().millisecondsSinceEpoch;
|
|
|
|
final combinedSeed = '$serverSeed:$clientSeed:$nonce';
|
|
final hash = _generateHash(combinedSeed);
|
|
final randomValue = _hashToNumber(hash);
|
|
final selectedIndex = randomValue % widget.members.length;
|
|
|
|
_winnerId = widget.members[selectedIndex]['id'];
|
|
_winnerData = widget.members[selectedIndex];
|
|
}
|
|
|
|
void _generateParticles() {
|
|
_particles.clear();
|
|
final random = math.Random();
|
|
|
|
// Generate particles for each member
|
|
for (int i = 0; i < widget.members.length; i++) {
|
|
final member = widget.members[i];
|
|
final isWinner = member['id'] == _winnerId;
|
|
|
|
// Create multiple particles per member
|
|
for (int j = 0; j < (isWinner ? 20 : 5); j++) {
|
|
_particles.add(Particle(
|
|
id: '${member['id']}_$j',
|
|
memberId: member['id'],
|
|
memberName: member['name'] ?? 'Unknown',
|
|
x: random.nextDouble() * 300.w,
|
|
y: random.nextDouble() * 400.h,
|
|
vx: (random.nextDouble() - 0.5) * 2,
|
|
vy: (random.nextDouble() - 0.5) * 2,
|
|
size: random.nextDouble() * 8 + 4,
|
|
color: isWinner
|
|
? Colors.green.shade400
|
|
: Colors.primaries[random.nextInt(Colors.primaries.length)].shade400,
|
|
isWinner: isWinner,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
void _completeAnimation() {
|
|
setState(() {
|
|
_isAnimating = false;
|
|
_isComplete = true;
|
|
});
|
|
|
|
_winnerController.forward();
|
|
widget.onDrawComplete(_winnerId!);
|
|
}
|
|
|
|
String _generateHash(String input) {
|
|
int hash = 0;
|
|
for (int i = 0; i < input.length; i++) {
|
|
hash = ((hash << 5) - hash + input.codeUnitAt(i)) & 0xffffffff;
|
|
}
|
|
return hash.abs().toString();
|
|
}
|
|
|
|
int _hashToNumber(String hash) {
|
|
if (hash.length < 10) {
|
|
hash = hash.padRight(10, '0');
|
|
}
|
|
return int.parse(hash.substring(0, 10));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 300.w,
|
|
height: 400.h,
|
|
child: Column(
|
|
children: [
|
|
// Title
|
|
Text(
|
|
'Particle Draw',
|
|
style: TextStyle(
|
|
fontSize: 20.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.indigo.shade700,
|
|
),
|
|
),
|
|
SizedBox(height: 20.h),
|
|
|
|
// Particle Display
|
|
Expanded(
|
|
child: Container(
|
|
width: 280.w,
|
|
height: 300.h,
|
|
decoration: BoxDecoration(
|
|
color: Colors.black,
|
|
borderRadius: BorderRadius.circular(16.r),
|
|
border: Border.all(color: Colors.indigo.shade400, width: 2.w),
|
|
),
|
|
child: AnimatedBuilder(
|
|
animation: _particleAnimation,
|
|
builder: (context, child) {
|
|
return CustomPaint(
|
|
painter: ParticlePainter(
|
|
particles: _particles,
|
|
animationValue: _particleAnimation.value,
|
|
isComplete: _isComplete,
|
|
),
|
|
size: Size(280.w, 300.h),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
// Winner Display
|
|
if (_isComplete)
|
|
AnimatedBuilder(
|
|
animation: _winnerAnimation,
|
|
builder: (context, child) {
|
|
return Transform.scale(
|
|
scale: _winnerAnimation.value,
|
|
child: Container(
|
|
margin: EdgeInsets.only(top: 20.h),
|
|
padding: EdgeInsets.all(16.w),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [Colors.green.shade400, Colors.green.shade600],
|
|
),
|
|
borderRadius: BorderRadius.circular(12.r),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.green.shade300,
|
|
blurRadius: 10.r,
|
|
offset: Offset(0, 5.h),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(
|
|
Icons.emoji_events,
|
|
color: Colors.white,
|
|
size: 32.w,
|
|
),
|
|
SizedBox(height: 8.h),
|
|
Text(
|
|
'WINNER!',
|
|
style: TextStyle(
|
|
fontSize: 18.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
Text(
|
|
_winnerData?['name'] ?? 'Unknown',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.white.withOpacity(0.9),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
)
|
|
else if (_isAnimating)
|
|
Container(
|
|
margin: EdgeInsets.only(top: 20.h),
|
|
child: Text(
|
|
'Particles swirling...',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.indigo.shade600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class Particle {
|
|
final String id;
|
|
final String memberId;
|
|
final String memberName;
|
|
double x;
|
|
double y;
|
|
double vx;
|
|
double vy;
|
|
final double size;
|
|
final Color color;
|
|
final bool isWinner;
|
|
|
|
Particle({
|
|
required this.id,
|
|
required this.memberId,
|
|
required this.memberName,
|
|
required this.x,
|
|
required this.y,
|
|
required this.vx,
|
|
required this.vy,
|
|
required this.size,
|
|
required this.color,
|
|
required this.isWinner,
|
|
});
|
|
|
|
void update() {
|
|
x += vx;
|
|
y += vy;
|
|
|
|
// Bounce off walls
|
|
if (x <= 0 || x >= 280) vx = -vx;
|
|
if (y <= 0 || y >= 300) vy = -vy;
|
|
|
|
// Keep particles in bounds
|
|
x = x.clamp(0, 280);
|
|
y = y.clamp(0, 300);
|
|
}
|
|
}
|
|
|
|
class ParticlePainter extends CustomPainter {
|
|
final List<Particle> particles;
|
|
final double animationValue;
|
|
final bool isComplete;
|
|
|
|
ParticlePainter({
|
|
required this.particles,
|
|
required this.animationValue,
|
|
required this.isComplete,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()..style = PaintingStyle.fill;
|
|
|
|
for (final particle in particles) {
|
|
// Update particle position
|
|
particle.update();
|
|
|
|
// Apply animation effects
|
|
final alpha = isComplete && particle.isWinner
|
|
? 1.0
|
|
: (0.3 + 0.7 * animationValue);
|
|
|
|
paint.color = particle.color.withOpacity(alpha);
|
|
|
|
// Draw particle
|
|
canvas.drawCircle(
|
|
Offset(particle.x, particle.y),
|
|
particle.size * (isComplete && particle.isWinner ? 1.5 : 1.0),
|
|
paint,
|
|
);
|
|
|
|
// Draw member name for winner particles
|
|
if (isComplete && particle.isWinner) {
|
|
final textPainter = TextPainter(
|
|
text: TextSpan(
|
|
text: particle.memberName.length > 8
|
|
? '${particle.memberName.substring(0, 8)}...'
|
|
: particle.memberName,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
);
|
|
textPainter.layout();
|
|
textPainter.paint(
|
|
canvas,
|
|
Offset(
|
|
particle.x - textPainter.width / 2,
|
|
particle.y + particle.size + 5,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Draw connecting lines for winner particles
|
|
if (isComplete) {
|
|
final winnerParticles = particles.where((p) => p.isWinner).toList();
|
|
if (winnerParticles.isNotEmpty) {
|
|
paint.color = Colors.green.withOpacity(0.3);
|
|
paint.strokeWidth = 2;
|
|
paint.style = PaintingStyle.stroke;
|
|
|
|
for (int i = 0; i < winnerParticles.length - 1; i++) {
|
|
canvas.drawLine(
|
|
Offset(winnerParticles[i].x, winnerParticles[i].y),
|
|
Offset(winnerParticles[i + 1].x, winnerParticles[i + 1].y),
|
|
paint,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
|
}
|