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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// تنظیماتِ سراسریِ اپ (فعلاً کیفیتِ گرافیک). با ذخیره‌ی محلی پایدار می‌ماند.
class AppSettings {
AppSettings._();
static final AppSettings I = AppSettings._();
final _storage = const FlutterSecureStorage();
static const _kLowGfx = 'low_graphics';
/// حالتِ کم‌مصرف: افکت‌های اتمسفری (ذرات، شوک‌ویوِ بریدن، پس‌زمینه‌ی تصویری)
/// خاموش می‌شوند تا روی گوشی‌های ضعیف روان بماند. با ValueNotifier تا UI واکنش دهد.
final ValueNotifier<bool> lowGraphics = ValueNotifier(false);
Future<void> load() async {
lowGraphics.value = (await _storage.read(key: _kLowGfx)) == '1';
}
Future<void> setLowGraphics(bool v) async {
lowGraphics.value = v;
await _storage.write(key: _kLowGfx, value: v ? '1' : '0');
}
}
@@ -90,8 +90,15 @@ class _GameScreenState extends State<GameScreen> {
listenWhen: listenWhen:
(a, b) => (a, b) =>
(a.notice != b.notice && b.notice != null) || (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) { 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) { if (state.gameOver != null) {
final won = final won =
state.gameOver!.winnerTeam == state.gameOver!.winnerTeam ==
@@ -540,44 +547,77 @@ class _GameScreenState extends State<GameScreen> {
final mySeat = s.state?.yourSeat ?? 0; final mySeat = s.state?.yourSeat ?? 0;
final myTeam = mySeat % 2; final myTeam = mySeat % 2;
final won = r.winnerTeam == myTeam; final won = r.winnerTeam == myTeam;
final kotWin = won && r.kot; // جشنِ ویژه فقط برای کُتِ بُرده
return IgnorePointer( return IgnorePointer(
child: Center( child: Stack(
child: Container( children: [
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 18), if (kotWin) const Positioned.fill(child: Confetti(count: 40)),
decoration: BoxDecoration( Center(
color: Colors.black87, child: Container(
borderRadius: BorderRadius.circular(16), padding:
border: Border.all(color: AppColors.gold), const EdgeInsets.symmetric(horizontal: 28, vertical: 18),
), decoration: BoxDecoration(
child: Column( gradient: kotWin
mainAxisSize: MainAxisSize.min, ? const LinearGradient(
children: [ colors: [AppColors.lapisMid, AppColors.lapisInk])
Text( : null,
won ? 'این دست را بردید!' : 'این دست را باختید', color: kotWin ? null : Colors.black87,
style: TextStyle( borderRadius: BorderRadius.circular(16),
color: won ? AppColors.green : Colors.redAccent, border: Border.all(
fontSize: 20, color: AppColors.gold, width: kotWin ? 2 : 1),
fontWeight: FontWeight.bold, boxShadow: kotWin
), ? [
BoxShadow(
color: AppColors.gold.withValues(alpha: 0.4),
blurRadius: 20),
]
: null,
), ),
if (r.kot) child: Column(
Padding( mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.only(top: 6), children: [
child: Text( if (kotWin)
r.hakemKot Text(
? 'حاکم‌کُت! (${r.points} امتیاز)' r.hakemKot ? 'حاکم‌کُت! 🔥' : 'کُت! 🔥',
: 'کُت! (${r.points} امتیاز)', style: const TextStyle(
style: const TextStyle(color: AppColors.gold), 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,
),
), ),
), if (r.kot && !kotWin)
const SizedBox(height: 6), Padding(
Text( padding: const EdgeInsets.only(top: 6),
'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}', child: Text(
style: const TextStyle(color: Colors.white70), 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 'package:flutter/services.dart' show HapticFeedback;
import '../../../../../core/service/app_sounds.dart'; import '../../../../../core/service/app_sounds.dart';
import '../../../../../core/settings/app_settings.dart';
import '../../../../../core/theme/app_theme.dart'; import '../../../../../core/theme/app_theme.dart';
import '../../../domain/entities/game_entities.dart'; import '../../../domain/entities/game_entities.dart';
import '../../bloc/game_bloc.dart'; import '../../bloc/game_bloc.dart';
@@ -84,6 +85,9 @@ class HokmGame extends FlameGame {
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
add(Felt(carpet: carpet)); add(Felt(carpet: carpet));
if (!AppSettings.I.lowGraphics.value) {
add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کم‌مصرف خاموش)
}
_sub = cubit.stream.listen((ui) { _sub = cubit.stream.listen((ui) {
if (ui.state == null) return; if (ui.state == null) return;
final s = ui.state!; final s = ui.state!;
@@ -172,6 +176,9 @@ class HokmGame extends FlameGame {
_bestTrumpRank = rank; _bestTrumpRank = rank;
_shake = 1.0; _shake = 1.0;
add(Lightning()); add(Lightning());
if (!AppSettings.I.lowGraphics.value) {
add(CutBurst()); // فلاش + شوک‌ویو + جرقه (کم‌مصرف: خاموش)
}
AppSounds.cut(); // بریدن با حکم (شمشیر) AppSounds.cut(); // بریدن با حکم (شمشیر)
HapticFeedback.heavyImpact(); // ضربه‌ی لمسیِ بُرِش با حکم HapticFeedback.heavyImpact(); // ضربه‌ی لمسیِ بُرِش با حکم
} else { } 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( c.children.whereType<MoveToEffect>().toList().forEach(
(e) => e.removeFromParent(), (e) => e.removeFromParent(),
); );
@@ -387,10 +395,12 @@ class HokmGame extends FlameGame {
c.position = target; c.position = target;
return; return;
} }
// کارتِ تازه‌نشسته روی میز با کمی overshoot می‌نشیند (حسِ «جا افتادن»).
c.add( c.add(
MoveToEffect( MoveToEffect(
target, 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) { for (final tc in s.trick) {
final slot = size / 2 + _trickOffset(_rel(tc.seat)); final slot = size / 2 + _trickOffset(_rel(tc.seat));
var c = _trick[tc.card]; var c = _trick[tc.card];
final isNew = c == null; // فقط کارتِ تازه‌نشسته bounce می‌شود
if (c == null) { if (c == null) {
// اگر خودِ شما بازی کردید، همان کارتِ دست را منتقل کن (پرواز به وسط). // اگر خودِ شما بازی کردید، همان کارتِ دست را منتقل کن (پرواز به وسط).
c = _hand.remove(tc.card); c = _hand.remove(tc.card);
@@ -530,7 +541,7 @@ class HokmGame extends FlameGame {
c.size = Vector2(_cardW, _cardH); c.size = Vector2(_cardW, _cardH);
c.angle = 0; // کارت‌های روی زمین صاف‌اند (چرخشِ بادبزنیِ دست حذف می‌شود) c.angle = 0; // کارت‌های روی زمین صاف‌اند (چرخشِ بادبزنیِ دست حذف می‌شود)
c.priority = 5; 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 'package:flutter/material.dart';
import '../../../../../core/network/card_images.dart'; import '../../../../../core/network/card_images.dart';
import '../../../../../core/settings/app_settings.dart';
import '../../../../../core/theme/app_theme.dart'; import '../../../../../core/theme/app_theme.dart';
// رنگ‌های مشترکِ میز (منبعِ واحد در AppColors). // رنگ‌های مشترکِ میز (منبعِ واحد در AppColors).
@@ -19,6 +20,7 @@ class Felt extends PositionComponent with HasGameReference {
Felt({this.carpet = 'classic'}); Felt({this.carpet = 'classic'});
Sprite? _carpetSprite; Sprite? _carpetSprite;
Sprite? _bgSprite; // پس‌زمینه‌ی نقاشی‌شده (اختیاری، قابلِ تعویض)
bool get _hasCarpet => carpet != 'classic'; bool get _hasCarpet => carpet != 'classic';
@@ -27,6 +29,15 @@ class Felt extends PositionComponent with HasGameReference {
if (_hasCarpet) { if (_hasCarpet) {
_carpetSprite = await CardImages.carpet(carpet); _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 @override
@@ -34,18 +45,36 @@ class Felt extends PositionComponent with HasGameReference {
final w = game.size.x, h = game.size.y; final w = game.size.x, h = game.size.y;
final center = Offset(w / 2, h / 2); final center = Offset(w / 2, h / 2);
// ۱) زمینِ لاجوردیِ نمدی (کلِ صفحه) با وینیتِ شعاعی برای عمق. همیشه کشیده // ۱) پس‌زمینه: تصویرِ سفارشیِ نقاشی‌شده (اگر موجود باشد) یا گرادیانِ لاجوردیِ
// می‌شود تا زیرِ میز/فرش یکدست باشد و لبه‌های فرش با پس‌زمینه جور شود. // پیش‌فرض. تصویر با پوششِ کامل (cover) کشیده و کمی تیره می‌شود تا کارت‌ها خوانا
canvas.drawRect( // بمانند و وینیتِ شعاعی هم رویش می‌آید تا لبه‌ها با تم جور شود.
Rect.fromLTWH(0, 0, w, h), final full = Rect.fromLTWH(0, 0, w, h);
Paint() final bg = _bgSprite;
..shader = RadialGradient( if (bg != null) {
center: Alignment.center, _drawCover(canvas, bg, w, h);
radius: 1.0, canvas.drawRect(full, Paint()..color = const Color(0x66081428));
colors: const [AppColors.lapisHi, AppColors.lapisNight], canvas.drawRect(
stops: const [0.5, 1.0], full,
).createShader(Rect.fromLTWH(0, 0, w, h)), 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( final rect = Rect.fromCenter(
@@ -123,6 +152,78 @@ class Felt extends PositionComponent with HasGameReference {
..color = _gold.withValues(alpha: 0.65), ..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 { class Lightning extends PositionComponent with HasGameReference {
static const _max = 0.55; static const _max = 0.55;
double _life = _max; 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 { class _PlayerSeat extends StatelessWidget {
final GamePlayer player; final GamePlayer player;
final double diameter; final double diameter;
@@ -271,19 +287,24 @@ class _PlayerSeat extends StatelessWidget {
clipBehavior: Clip.none, clipBehavior: Clip.none,
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
// قابِ طلایی + آواتار. // قابِ رتبه‌ای + آواتار (رنگِ قاب بر اساسِ نشانِ رتبه‌ی بازیکن).
Container( Container(
width: diameter, width: diameter,
height: diameter, height: diameter,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: const LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Color(0xFFF3D27A), AppColors.goldDark], colors: _rankFrame(player.bot ? '' : player.rankTier),
), ),
boxShadow: const [ boxShadow: [
BoxShadow(color: Colors.black54, blurRadius: 6), 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), padding: EdgeInsets.all(ringW),
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:random_avatar/random_avatar.dart'; import 'package:random_avatar/random_avatar.dart';
import '../../../../core/settings/app_settings.dart';
import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/game_ui.dart'; import '../../../../core/widgets/game_ui.dart';
import '../../../../core/widgets/rank_badge.dart'; import '../../../../core/widgets/rank_badge.dart';
@@ -198,6 +199,8 @@ class ProfileScreen extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
_statsSection(context, d), _statsSection(context, d),
const SizedBox(height: 16), const SizedBox(height: 16),
const _GraphicsToggle(),
const SizedBox(height: 8),
Align( Align(
alignment: Alignment.center, alignment: Alignment.center,
child: TextButton.icon( 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_event.dart';
import '../bloc/wallet_state.dart'; import '../bloc/wallet_state.dart';
import '../bloc/wallet_status.dart'; import '../bloc/wallet_status.dart';
import '../widgets/coin_fly.dart';
import '../widgets/streak_dialog.dart'; import '../widgets/streak_dialog.dart';
/// لابی اصلی: کیف‌پول، دکمه بازی/دورهمی/فروشگاه/سکه روزانه. /// لابی اصلی: کیف‌پول، دکمه بازی/دورهمی/فروشگاه/سکه روزانه.
@@ -24,6 +25,9 @@ class LobbyScreen extends StatefulWidget {
} }
class _LobbyScreenState extends State<LobbyScreen> { class _LobbyScreenState extends State<LobbyScreen> {
final _coinKey = GlobalKey(); // چیپِ سکه (مقصدِ پروازِ سکه‌ها)
final _dailyKey = GlobalKey(); // کارتِ هدیه‌ی روزانه (مبدأ)
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -32,6 +36,20 @@ class _LobbyScreenState extends State<LobbyScreen> {
void _reload() => context.read<WalletBloc>().add(LoadWalletEvent()); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -41,6 +59,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
listener: (context, state) { listener: (context, state) {
final d = state.dailyStatus; final d = state.dailyStatus;
if (d is DailySuccess) { if (d is DailySuccess) {
_flyCoins(); // پروازِ سکه‌ها از کارتِ هدیه به چیپِ سکه
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}')), SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}')),
); );
@@ -56,7 +75,10 @@ class _LobbyScreenState extends State<LobbyScreen> {
return SafeArea( return SafeArea(
child: Column( child: Column(
children: [ children: [
_TopBar(wallet: wallet, onCoinTap: () => _openShop(context)), _TopBar(
wallet: wallet,
coinKey: _coinKey,
onCoinTap: () => _openShop(context)),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 8), padding: const EdgeInsets.fromLTRB(16, 6, 16, 8),
@@ -121,6 +143,7 @@ class _LobbyScreenState extends State<LobbyScreen> {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
_DailyCard( _DailyCard(
key: _dailyKey,
wallet: wallet, wallet: wallet,
onTap: onTap:
() => StreakDialog.show( () => StreakDialog.show(
@@ -347,7 +370,7 @@ class _ActionTile extends StatelessWidget {
class _DailyCard extends StatelessWidget { class _DailyCard extends StatelessWidget {
final WalletEntity? wallet; final WalletEntity? wallet;
final VoidCallback onTap; final VoidCallback onTap;
const _DailyCard({required this.wallet, required this.onTap}); const _DailyCard({super.key, required this.wallet, required this.onTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -478,7 +501,8 @@ class _DailyCard extends StatelessWidget {
class _TopBar extends StatelessWidget { class _TopBar extends StatelessWidget {
final WalletEntity? wallet; final WalletEntity? wallet;
final VoidCallback onCoinTap; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -582,6 +606,7 @@ class _TopBar extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
GestureDetector( GestureDetector(
key: coinKey,
onTap: onCoinTap, onTap: onCoinTap,
child: StatChip( child: StatChip(
icon: Icons.monetization_on, 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,
});
}
+2
View File
@@ -3,11 +3,13 @@ import 'package:flutter/material.dart';
import 'app.dart'; import 'app.dart';
import 'core/locator/locator.dart'; import 'core/locator/locator.dart';
import 'core/service/app_sounds.dart'; import 'core/service/app_sounds.dart';
import 'core/settings/app_settings.dart';
import 'feature/auth/domain/repository/auth_repository.dart'; import 'feature/auth/domain/repository/auth_repository.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await setupLocator(); await setupLocator();
await AppSettings.I.load(); // تنظیماتِ گرافیک
AppSounds.preload(); // پیش‌بارگذاریِ صداها (بدون انتظار) AppSounds.preload(); // پیش‌بارگذاریِ صداها (بدون انتظار)
final loggedIn = await locator<AuthRepository>().isLoggedIn(); final loggedIn = await locator<AuthRepository>().isLoggedIn();
runApp(HakemApp(loggedIn: loggedIn)); runApp(HakemApp(loggedIn: loggedIn));