feat: add game graphic

This commit is contained in:
2026-07-08 09:06:32 +03:30
parent ffd2646eea
commit 46a649166b
15 changed files with 521 additions and 57 deletions
@@ -90,8 +90,15 @@ class _GameScreenState extends State<GameScreen> {
listenWhen:
(a, b) =>
(a.notice != b.notice && b.notice != null) ||
(a.gameOver == null && b.gameOver != null),
(a.gameOver == null && b.gameOver != null) ||
(a.handResult == null && b.handResult != null),
listener: (context, state) {
// جشنِ کُت: هنگامِ دستِ کُت‌شده‌ی بُرده، ضربه‌ی لمسی.
final hr = state.handResult;
if (hr != null && hr.kot) {
final myTeam = (state.state?.yourSeat ?? 0) % 2;
if (hr.winnerTeam == myTeam) HapticFeedback.mediumImpact();
}
if (state.gameOver != null) {
final won =
state.gameOver!.winnerTeam ==
@@ -540,44 +547,77 @@ class _GameScreenState extends State<GameScreen> {
final mySeat = s.state?.yourSeat ?? 0;
final myTeam = mySeat % 2;
final won = r.winnerTeam == myTeam;
final kotWin = won && r.kot; // جشنِ ویژه فقط برای کُتِ بُرده
return IgnorePointer(
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 18),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.gold),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
won ? 'این دست را بردید!' : 'این دست را باختید',
style: TextStyle(
color: won ? AppColors.green : Colors.redAccent,
fontSize: 20,
fontWeight: FontWeight.bold,
),
child: Stack(
children: [
if (kotWin) const Positioned.fill(child: Confetti(count: 40)),
Center(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 28, vertical: 18),
decoration: BoxDecoration(
gradient: kotWin
? const LinearGradient(
colors: [AppColors.lapisMid, AppColors.lapisInk])
: null,
color: kotWin ? null : Colors.black87,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.gold, width: kotWin ? 2 : 1),
boxShadow: kotWin
? [
BoxShadow(
color: AppColors.gold.withValues(alpha: 0.4),
blurRadius: 20),
]
: null,
),
if (r.kot)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
r.hakemKot
? 'حاکم‌کُت! (${r.points} امتیاز)'
: 'کُت! (${r.points} امتیاز)',
style: const TextStyle(color: AppColors.gold),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (kotWin)
Text(
r.hakemKot ? 'حاکم‌کُت! 🔥' : 'کُت! 🔥',
style: const TextStyle(
color: AppColors.gold,
fontSize: 30,
fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 8)],
),
),
Text(
won ? 'این دست را بردید!' : 'این دست را باختید',
style: TextStyle(
color: won ? AppColors.green : Colors.redAccent,
fontSize: kotWin ? 16 : 20,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 6),
Text(
'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}',
style: const TextStyle(color: Colors.white70),
if (r.kot && !kotWin)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
r.hakemKot
? 'حاکم‌کُت! (${r.points} امتیاز)'
: 'کُت! (${r.points} امتیاز)',
style: const TextStyle(color: AppColors.gold),
),
),
if (kotWin)
Text('+${r.points} امتیاز',
style: const TextStyle(color: AppColors.gold)),
const SizedBox(height: 6),
Text(
'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}',
style: const TextStyle(color: Colors.white70),
),
],
),
],
),
),
),
],
),
);
}
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import '../../../../../core/service/app_sounds.dart';
import '../../../../../core/settings/app_settings.dart';
import '../../../../../core/theme/app_theme.dart';
import '../../../domain/entities/game_entities.dart';
import '../../bloc/game_bloc.dart';
@@ -84,6 +85,9 @@ class HokmGame extends FlameGame {
@override
Future<void> onLoad() async {
add(Felt(carpet: carpet));
if (!AppSettings.I.lowGraphics.value) {
add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کم‌مصرف خاموش)
}
_sub = cubit.stream.listen((ui) {
if (ui.state == null) return;
final s = ui.state!;
@@ -172,6 +176,9 @@ class HokmGame extends FlameGame {
_bestTrumpRank = rank;
_shake = 1.0;
add(Lightning());
if (!AppSettings.I.lowGraphics.value) {
add(CutBurst()); // فلاش + شوک‌ویو + جرقه (کم‌مصرف: خاموش)
}
AppSounds.cut(); // بریدن با حکم (شمشیر)
HapticFeedback.heavyImpact(); // ضربه‌ی لمسیِ بُرِش با حکم
} else {
@@ -379,7 +386,8 @@ class HokmGame extends FlameGame {
}
// حرکتِ نرمِ یک کارت به مقصد، بدون انباشته‌شدنِ افکت‌ها.
void _moveTo(CardComponent c, Vector2 target, {double dur = 0.38}) {
void _moveTo(CardComponent c, Vector2 target,
{double dur = 0.38, bool bounce = false}) {
c.children.whereType<MoveToEffect>().toList().forEach(
(e) => e.removeFromParent(),
);
@@ -387,10 +395,12 @@ class HokmGame extends FlameGame {
c.position = target;
return;
}
// کارتِ تازه‌نشسته روی میز با کمی overshoot می‌نشیند (حسِ «جا افتادن»).
c.add(
MoveToEffect(
target,
EffectController(duration: dur, curve: Curves.easeOutCubic),
EffectController(
duration: dur, curve: bounce ? Curves.easeOutBack : Curves.easeOutCubic),
),
);
}
@@ -507,6 +517,7 @@ class HokmGame extends FlameGame {
for (final tc in s.trick) {
final slot = size / 2 + _trickOffset(_rel(tc.seat));
var c = _trick[tc.card];
final isNew = c == null; // فقط کارتِ تازه‌نشسته bounce می‌شود
if (c == null) {
// اگر خودِ شما بازی کردید، همان کارتِ دست را منتقل کن (پرواز به وسط).
c = _hand.remove(tc.card);
@@ -530,7 +541,7 @@ class HokmGame extends FlameGame {
c.size = Vector2(_cardW, _cardH);
c.angle = 0; // کارت‌های روی زمین صاف‌اند (چرخشِ بادبزنیِ دست حذف می‌شود)
c.priority = 5;
_moveTo(c, slot);
_moveTo(c, slot, bounce: isNew);
}
// دستِ کامل (۴ کارت) ⇒ کارتِ برنده را طلایی هایلایت کن و یک‌بار «پاپ» بزن،
@@ -4,6 +4,7 @@ import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../../../../../core/network/card_images.dart';
import '../../../../../core/settings/app_settings.dart';
import '../../../../../core/theme/app_theme.dart';
// رنگ‌های مشترکِ میز (منبعِ واحد در AppColors).
@@ -19,6 +20,7 @@ class Felt extends PositionComponent with HasGameReference {
Felt({this.carpet = 'classic'});
Sprite? _carpetSprite;
Sprite? _bgSprite; // پس‌زمینه‌ی نقاشی‌شده (اختیاری، قابلِ تعویض)
bool get _hasCarpet => carpet != 'classic';
@@ -27,6 +29,15 @@ class Felt extends PositionComponent with HasGameReference {
if (_hasCarpet) {
_carpetSprite = await CardImages.carpet(carpet);
}
// پس‌زمینه‌ی سفارشی (تالار/کاخ): فایلِ assets/images/game_bg.jpg را بگذارید.
// اگر نبود یا حالتِ کم‌مصرف باشد، به گرادیانِ لاجوردیِ پیش‌فرض برمی‌گردد.
if (!AppSettings.I.lowGraphics.value) {
try {
_bgSprite = await Sprite.load('game_bg.jpg');
} catch (_) {
_bgSprite = null;
}
}
}
@override
@@ -34,18 +45,36 @@ class Felt extends PositionComponent with HasGameReference {
final w = game.size.x, h = game.size.y;
final center = Offset(w / 2, h / 2);
// ۱) زمینِ لاجوردیِ نمدی (کلِ صفحه) با وینیتِ شعاعی برای عمق. همیشه کشیده
// می‌شود تا زیرِ میز/فرش یکدست باشد و لبه‌های فرش با پس‌زمینه جور شود.
canvas.drawRect(
Rect.fromLTWH(0, 0, w, h),
Paint()
..shader = RadialGradient(
center: Alignment.center,
radius: 1.0,
colors: const [AppColors.lapisHi, AppColors.lapisNight],
stops: const [0.5, 1.0],
).createShader(Rect.fromLTWH(0, 0, w, h)),
);
// ۱) پس‌زمینه: تصویرِ سفارشیِ نقاشی‌شده (اگر موجود باشد) یا گرادیانِ لاجوردیِ
// پیش‌فرض. تصویر با پوششِ کامل (cover) کشیده و کمی تیره می‌شود تا کارت‌ها خوانا
// بمانند و وینیتِ شعاعی هم رویش می‌آید تا لبه‌ها با تم جور شود.
final full = Rect.fromLTWH(0, 0, w, h);
final bg = _bgSprite;
if (bg != null) {
_drawCover(canvas, bg, w, h);
canvas.drawRect(full, Paint()..color = const Color(0x66081428));
canvas.drawRect(
full,
Paint()
..shader = RadialGradient(
center: Alignment.center,
radius: 1.1,
colors: const [Color(0x00000000), AppColors.lapisNight],
stops: const [0.55, 1.0],
).createShader(full),
);
} else {
canvas.drawRect(
full,
Paint()
..shader = RadialGradient(
center: Alignment.center,
radius: 1.0,
colors: const [AppColors.lapisHi, AppColors.lapisNight],
stops: const [0.5, 1.0],
).createShader(full),
);
}
// ۲) میزِ مرکزی با قابِ طلایی.
final rect = Rect.fromCenter(
@@ -123,6 +152,78 @@ class Felt extends PositionComponent with HasGameReference {
..color = _gold.withValues(alpha: 0.65),
);
}
// تصویرِ پس‌زمینه را با حفظِ نسبت، پوششِ کاملِ صفحه (cover) و مرکز-برش می‌کشد.
void _drawCover(Canvas canvas, Sprite sprite, double w, double h) {
final img = sprite.image;
final iw = img.width.toDouble(), ih = img.height.toDouble();
final scale = math.max(w / iw, h / ih);
final dw = iw * scale, dh = ih * scale;
sprite.render(
canvas,
position: Vector2((w - dw) / 2, (h - dh) / 2),
size: Vector2(dw, dh),
);
}
}
/// ذراتِ طلاییِ معلق (اتمسفرِ سبک). چند نقطه‌ی کم‌رنگ که آرام بالا می‌روند و
/// چشمک می‌زنند؛ عمق می‌دهد بدونِ بارِ گرافیکیِ محسوس (بدونِ blur).
class Motes extends PositionComponent with HasGameReference {
final int count;
Motes({this.count = 12}) {
priority = 1; // بالای Felt، زیرِ کارت‌ها (priority ۵)
}
final _rng = math.Random();
double _t = 0;
late final List<_Mote> _motes;
@override
Future<void> onLoad() async {
_motes = List.generate(
count,
(_) => _Mote(
x: _rng.nextDouble(),
y: _rng.nextDouble(),
r: 1.0 + _rng.nextDouble() * 1.8,
speed: 0.006 + _rng.nextDouble() * 0.014,
drift: (_rng.nextDouble() - 0.5) * 0.03,
phase: _rng.nextDouble() * 6.28,
),
);
}
@override
void update(double dt) => _t += dt;
@override
void render(Canvas canvas) {
final w = game.size.x, h = game.size.y;
for (final m in _motes) {
var y = (m.y - m.speed * _t) % 1.0;
if (y < 0) y += 1;
final xx = (m.x + m.drift * math.sin(_t * 0.3 + m.phase)) * w;
final tw = (0.35 + 0.28 * math.sin(_t * 1.4 + m.phase)).clamp(0.0, 1.0);
canvas.drawCircle(
Offset(xx, y * h),
m.r,
Paint()..color = _gold.withValues(alpha: tw * 0.45),
);
}
}
}
class _Mote {
final double x, y, r, speed, drift, phase;
_Mote({
required this.x,
required this.y,
required this.r,
required this.speed,
required this.drift,
required this.phase,
});
}
/// دیسکِ فلزیِ شمارش (امتیاز تیم) با عددِ وسط؛ از ۰ تا ۷.
@@ -161,6 +262,71 @@ class ScorePuck extends PositionComponent {
}
/// افکت رعد روی زمین هنگام «بریدن با حکم»؛ پس از مدت کوتاهی خودش حذف می‌شود.
/// افکتِ بریدن با حکم: فلاشِ کوتاهِ صفحه + حلقه‌ی شوک‌ویوِ طلایی + جرقه‌ها.
/// همراهِ Lightning اضافه می‌شود تا بُرِش «قدرتمند» حس شود.
class CutBurst extends PositionComponent with HasGameReference {
static const _max = 0.5;
double _life = _max;
final _rng = math.Random();
late final List<_Spark> _sparks;
@override
Future<void> onLoad() async {
size = game.size;
_sparks = List.generate(16, (_) {
final ang = _rng.nextDouble() * math.pi * 2;
final spd = 0.10 + _rng.nextDouble() * 0.17;
return _Spark(ang, spd, 1.5 + _rng.nextDouble() * 2.5);
});
}
@override
void update(double dt) {
_life -= dt;
if (_life <= 0) removeFromParent();
}
@override
void render(Canvas canvas) {
final t = (1 - _life / _max).clamp(0.0, 1.0); // پیشرفت ۰..۱
final center = Offset(size.x / 2, size.y / 2);
// ۱) فلاشِ کوتاهِ صفحه (فقط اوایل).
final flash = (1 - t * 3).clamp(0.0, 1.0);
if (flash > 0) {
canvas.drawRect(
Rect.fromLTWH(0, 0, size.x, size.y),
Paint()..color = const Color(0xFFFFF3C4).withValues(alpha: flash * 0.45),
);
}
final fade = (1 - t).clamp(0.0, 1.0);
// ۲) حلقه‌ی شوک‌ویوِ طلایی که باز می‌شود.
canvas.drawCircle(
center,
t * size.x * 0.55,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.x * 0.022 * fade
..color = _gold.withValues(alpha: fade * 0.8)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4),
);
// ۳) جرقه‌های طلایی که به بیرون می‌پاشند.
final sp = Paint()..color = const Color(0xFFFFF3C4).withValues(alpha: fade);
for (final s in _sparks) {
final d = s.speed * size.x * t;
final p = center + Offset(math.cos(s.ang), math.sin(s.ang)) * d;
canvas.drawCircle(p, s.r * fade, sp);
}
}
}
class _Spark {
final double ang, speed, r;
_Spark(this.ang, this.speed, this.r);
}
class Lightning extends PositionComponent with HasGameReference {
static const _max = 0.55;
double _life = _max;
@@ -211,6 +211,22 @@ class TableHud extends StatelessWidget {
);
}
/// گرادیانِ قابِ آواتار بر اساسِ نشانِ رتبه (برنز→پادشاه). پیش‌فرض: طلایی.
List<Color> _rankFrame(String tier) {
switch (tier) {
case 'bronze':
return const [Color(0xFFCD9B6A), Color(0xFF7A4A22)];
case 'silver':
return const [Color(0xFFEDF1F4), Color(0xFF98A2AD)];
case 'diamond':
return const [Color(0xFF9BE7F0), Color(0xFF2E8BA0)];
case 'king':
return const [Color(0xFFCE9BEA), Color(0xFF6A2FA0)];
default: // gold و ناشناخته/بات
return const [Color(0xFFF3D27A), AppColors.goldDark];
}
}
class _PlayerSeat extends StatelessWidget {
final GamePlayer player;
final double diameter;
@@ -271,19 +287,24 @@ class _PlayerSeat extends StatelessWidget {
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// قابِ طلایی + آواتار.
// قابِ رتبه‌ای + آواتار (رنگِ قاب بر اساسِ نشانِ رتبه‌ی بازیکن).
Container(
width: diameter,
height: diameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF3D27A), AppColors.goldDark],
colors: _rankFrame(player.bot ? '' : player.rankTier),
),
boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 6),
boxShadow: [
const BoxShadow(color: Colors.black54, blurRadius: 6),
if (isTurn)
BoxShadow(
color: _rankFrame(player.rankTier)[0]
.withValues(alpha: 0.6),
blurRadius: 12),
],
),
padding: EdgeInsets.all(ringW),
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:random_avatar/random_avatar.dart';
import '../../../../core/settings/app_settings.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/game_ui.dart';
import '../../../../core/widgets/rank_badge.dart';
@@ -198,6 +199,8 @@ class ProfileScreen extends StatelessWidget {
const SizedBox(height: 8),
_statsSection(context, d),
const SizedBox(height: 16),
const _GraphicsToggle(),
const SizedBox(height: 8),
Align(
alignment: Alignment.center,
child: TextButton.icon(
@@ -490,3 +493,40 @@ class _EditProfileSheetState extends State<_EditProfileSheet> {
);
}
}
/// سوئیچِ «گرافیک کم‌مصرف»: افکت‌های اتمسفری را برای گوشی‌های ضعیف خاموش می‌کند.
class _GraphicsToggle extends StatelessWidget {
const _GraphicsToggle();
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: AppSettings.I.lowGraphics,
builder: (context, low, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: AppColors.panel.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.goldFaint),
),
child: Row(
children: [
const Icon(Icons.bolt, color: AppColors.gold, size: 20),
const SizedBox(width: 10),
const Expanded(
child: Text(
'گرافیک کم‌مصرف',
style: TextStyle(color: Colors.white, fontSize: 14),
),
),
Switch(
value: low,
activeThumbColor: AppColors.gold,
onChanged: (v) => AppSettings.I.setLowGraphics(v),
),
],
),
),
);
}
}
@@ -13,6 +13,7 @@ import '../bloc/wallet_bloc.dart';
import '../bloc/wallet_event.dart';
import '../bloc/wallet_state.dart';
import '../bloc/wallet_status.dart';
import '../widgets/coin_fly.dart';
import '../widgets/streak_dialog.dart';
/// لابی اصلی: کیف‌پول، دکمه بازی/دورهمی/فروشگاه/سکه روزانه.
@@ -24,6 +25,9 @@ class LobbyScreen extends StatefulWidget {
}
class _LobbyScreenState extends State<LobbyScreen> {
final _coinKey = GlobalKey(); // چیپِ سکه (مقصدِ پروازِ سکه‌ها)
final _dailyKey = GlobalKey(); // کارتِ هدیه‌ی روزانه (مبدأ)
@override
void initState() {
super.initState();
@@ -32,6 +36,20 @@ class _LobbyScreenState extends State<LobbyScreen> {
void _reload() => context.read<WalletBloc>().add(LoadWalletEvent());
Offset? _center(GlobalKey k) {
final box = k.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.attached) return null;
return box.localToGlobal(box.size.center(Offset.zero));
}
void _flyCoins() {
final from = _center(_dailyKey);
final to = _center(_coinKey);
if (from != null && to != null && mounted) {
CoinFly.play(context, from: from, to: to);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -41,6 +59,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
listener: (context, state) {
final d = state.dailyStatus;
if (d is DailySuccess) {
_flyCoins(); // پروازِ سکه‌ها از کارتِ هدیه به چیپِ سکه
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}')),
);
@@ -56,7 +75,10 @@ class _LobbyScreenState extends State<LobbyScreen> {
return SafeArea(
child: Column(
children: [
_TopBar(wallet: wallet, onCoinTap: () => _openShop(context)),
_TopBar(
wallet: wallet,
coinKey: _coinKey,
onCoinTap: () => _openShop(context)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 8),
@@ -121,6 +143,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
),
const SizedBox(height: 14),
_DailyCard(
key: _dailyKey,
wallet: wallet,
onTap:
() => StreakDialog.show(
@@ -347,7 +370,7 @@ class _ActionTile extends StatelessWidget {
class _DailyCard extends StatelessWidget {
final WalletEntity? wallet;
final VoidCallback onTap;
const _DailyCard({required this.wallet, required this.onTap});
const _DailyCard({super.key, required this.wallet, required this.onTap});
@override
Widget build(BuildContext context) {
@@ -478,7 +501,8 @@ class _DailyCard extends StatelessWidget {
class _TopBar extends StatelessWidget {
final WalletEntity? wallet;
final VoidCallback onCoinTap;
const _TopBar({required this.wallet, required this.onCoinTap});
final Key? coinKey;
const _TopBar({required this.wallet, required this.onCoinTap, this.coinKey});
@override
Widget build(BuildContext context) {
@@ -582,6 +606,7 @@ class _TopBar extends StatelessWidget {
),
const SizedBox(width: 8),
GestureDetector(
key: coinKey,
onTap: onCoinTap,
child: StatChip(
icon: Icons.monetization_on,
@@ -0,0 +1,135 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
/// انیمیشنِ «پروازِ سکه‌ها به کیف‌پول»: چند سکه از مبدأ (مثلاً کارتِ هدیه) روی یک
/// قوس به سمتِ مقصد (چیپِ سکه) پرواز می‌کنند. با Overlay نمایش داده می‌شود.
class CoinFly {
static void play(
BuildContext context, {
required Offset from,
required Offset to,
int count = 9,
}) {
final overlay = Overlay.maybeOf(context, rootOverlay: true);
if (overlay == null) return;
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => _CoinFlyOverlay(
from: from,
to: to,
count: count,
onDone: () => entry.remove(),
),
);
overlay.insert(entry);
}
}
class _CoinFlyOverlay extends StatefulWidget {
final Offset from, to;
final int count;
final VoidCallback onDone;
const _CoinFlyOverlay({
required this.from,
required this.to,
required this.count,
required this.onDone,
});
@override
State<_CoinFlyOverlay> createState() => _CoinFlyOverlayState();
}
class _CoinFlyOverlayState extends State<_CoinFlyOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _c;
late final List<_Coin> _coins;
@override
void initState() {
super.initState();
final r = math.Random();
_coins = List.generate(widget.count, (_) {
return _Coin(
jitter: Offset((r.nextDouble() - 0.5) * 46, (r.nextDouble() - 0.5) * 46),
delay: r.nextDouble() * 0.28,
arc: -60 - r.nextDouble() * 90, // ارتفاعِ قوس (به بالا)
size: 16 + r.nextDouble() * 8,
);
});
_c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 950),
)
..addStatusListener((s) {
if (s == AnimationStatus.completed) widget.onDone();
})
..forward();
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: AnimatedBuilder(
animation: _c,
builder: (_, __) {
return Stack(
children: [
for (final coin in _coins) _buildCoin(coin),
],
);
},
),
);
}
Widget _buildCoin(_Coin coin) {
final local = ((_c.value - coin.delay) / (1 - coin.delay)).clamp(0.0, 1.0);
if (local <= 0) return const SizedBox.shrink();
final start = widget.from + coin.jitter;
final end = widget.to;
// قوسِ درجه‌دو: نقطه‌ی کنترل بالای میانه.
final mid = Offset((start.dx + end.dx) / 2, (start.dy + end.dy) / 2 + coin.arc);
final t = Curves.easeInCubic.transform(local);
final p = _quad(start, mid, end, t);
final opacity = local < 0.85 ? 1.0 : (1 - (local - 0.85) / 0.15);
final scale = 1.0 - 0.35 * local;
return Positioned(
left: p.dx - coin.size / 2,
top: p.dy - coin.size / 2,
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: Transform.scale(
scale: scale,
child: Icon(Icons.monetization_on,
color: AppColors.gold, size: coin.size),
),
),
);
}
Offset _quad(Offset a, Offset b, Offset c, double t) {
final u = 1 - t;
return a * (u * u) + b * (2 * u * t) + c * (t * t);
}
}
class _Coin {
final Offset jitter;
final double delay, arc, size;
_Coin({
required this.jitter,
required this.delay,
required this.arc,
required this.size,
});
}