feat: add game graphic
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user