diff --git a/assets/audio/heart_beat.mp3 b/assets/audio/heart_beat.mp3 new file mode 100644 index 0000000..fdd83f3 Binary files /dev/null and b/assets/audio/heart_beat.mp3 differ diff --git a/lib/core/service/app_sounds.dart b/lib/core/service/app_sounds.dart index 9ab02a6..81bb7af 100644 --- a/lib/core/service/app_sounds.dart +++ b/lib/core/service/app_sounds.dart @@ -15,6 +15,7 @@ class AppSounds { static const _win = 'win.mp3'; // برد بازی static const _button = 'button.mp3'; // فشردنِ دکمه‌ها static const _selectHokm = 'select_hokm.mp3'; // انتخابِ حکم + static const _heartBeat = 'heart_beat.mp3'; // ثانیه‌های پایانیِ نوبت static const _all = [ _shuffle, @@ -24,6 +25,7 @@ class AppSounds { _win, _button, _selectHokm, + _heartBeat, ]; static final Map _pools = {}; @@ -57,5 +59,26 @@ class AppSounds { static void cut() => _play(_cut, 0.9); static void win() => _play(_win, 0.9); static void selectHokm() => _play(_selectHokm, 0.9); + + // کنترلِ ضربانِ قلب: همیشه حداکثر یک ضربان هم‌زمان (بدون انباشت/تکرارِ سریع) + // و قابلِ توقف وقتی نوبت تمام می‌شود. + static Function? _hbStop; + + static void heartBeat() { + final pool = _pools[_heartBeat]; + if (muted || pool == null) return; + _hbStop?.call(); // قطعِ ضربانِ قبلی پیش از پخشِ جدید + _hbStop = null; + pool.start(volume: 0.8).then((stop) { + _hbStop = stop; + }, onError: (_) {}); + } + + static void stopHeartBeat() { + try { + _hbStop?.call(); + } catch (_) {} + _hbStop = null; + } static void button() => _play(_button, 0.5, cutoffMs: 130); // کلیکِ کوتاه } diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index 1301b54..8207e62 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -4,6 +4,7 @@ import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:random_avatar/random_avatar.dart'; import '../../../../core/network/ws_client.dart'; import '../../../../core/service/app_sounds.dart'; @@ -50,56 +51,63 @@ class _GameScreenState extends State { onPopInvokedWithResult: (didPop, _) { if (!didPop) _confirmLeave(context); }, - child: Scaffold( - body: BlocConsumer( - listenWhen: (a, b) => - (a.notice != b.notice && b.notice != null) || - (a.gameOver == null && b.gameOver != null), - listener: (context, state) { - if (state.gameOver != null) { - final won = state.gameOver!.winnerTeam == - (state.state?.yourSeat ?? 0) % 2; - if (won) AppSounds.win(); - } - if (state.notice != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.notice!), - duration: const Duration(seconds: 2)), - ); - context.read().clearNotice(); - // خطا پیش از شروعِ بازی (مثلاً سکه‌ی ناکافی) ⇒ کاربر در دیالوگِ - // «جستجوی حریف» گیر نکند؛ به لابی برگردد. - if (state.state == null && state.gameOver == null) { - _exitToLobby(context); + child: SafeArea( + top: false, + child: Scaffold( + body: BlocConsumer( + listenWhen: + (a, b) => + (a.notice != b.notice && b.notice != null) || + (a.gameOver == null && b.gameOver != null), + listener: (context, state) { + if (state.gameOver != null) { + final won = + state.gameOver!.winnerTeam == + (state.state?.yourSeat ?? 0) % 2; + if (won) AppSounds.win(); } - } - }, - builder: (context, state) { - if (state.state != null && _introTimer == null) { - // کمی «حریفان پیدا شد» نشان بده، بعد اوورلی را کنار بزن و همان - // لحظه انیمیشنِ بُر زدن (با صدا) را اجرا کن. - _introTimer = Timer(const Duration(milliseconds: 1000), () { - if (!mounted) return; - setState(() => _introHidden = true); - _game.endIntro(); - }); - } - return Stack( - children: [ - GameWidget(game: _game), - _backButton(context), - if (state.connection == WsStatus.disconnected) _connBanner(), - if (_showSearch(state)) _searchPanel(state), - if (!_showSearch(state) && _showTrumpPicker(state)) - _trumpPicker(context), - if (state.handResult != null && state.gameOver == null) - _handResult(state), - if (!_showSearch(state) && _isMyPlayTurn(state)) _turnBanner(), - if (state.gameOver != null) _gameOver(context, state), - ], - ); - }, + if (state.notice != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.notice!), + duration: const Duration(seconds: 2), + ), + ); + context.read().clearNotice(); + // خطا پیش از شروعِ بازی (مثلاً سکه‌ی ناکافی) ⇒ کاربر در دیالوگِ + // «جستجوی حریف» گیر نکند؛ به لابی برگردد. + if (state.state == null && state.gameOver == null) { + _exitToLobby(context); + } + } + }, + builder: (context, state) { + if (state.state != null && _introTimer == null) { + // کمی «حریفان پیدا شد» نشان بده، بعد اوورلی را کنار بزن و همان + // لحظه انیمیشنِ بُر زدن (با صدا) را اجرا کن. + _introTimer = Timer(const Duration(milliseconds: 1000), () { + if (!mounted) return; + setState(() => _introHidden = true); + _game.endIntro(); + }); + } + return Stack( + children: [ + GameWidget(game: _game), + _backButton(context), + if (state.connection == WsStatus.disconnected) _connBanner(), + if (_showSearch(state)) _searchPanel(state), + if (!_showSearch(state) && _showTrumpPicker(state)) + _trumpPicker(context), + if (state.handResult != null && state.gameOver == null) + _handResult(state), + if (!_showSearch(state) && _isMyPlayTurn(state)) + _turnBanner(), + if (state.gameOver != null) _gameOver(context, state), + ], + ); + }, + ), ), ), ); @@ -114,24 +122,28 @@ class _GameScreenState extends State { // بنرِ «نوبت شماست» وقتی کاربر باید کارت بیندازد. Widget _turnBanner() => Align( - alignment: const Alignment(0, 0.46), - child: IgnorePointer( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFE9B949), Color(0xFFB8860B)]), - borderRadius: BorderRadius.circular(20), - boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 8)], - ), - child: const Text('نوبت شماست', - style: TextStyle( - color: Color(0xFF3A0A12), - fontWeight: FontWeight.bold, - fontSize: 16)), + alignment: const Alignment(0, 0.80), + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFE9B949), Color(0xFFB8860B)], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 8)], + ), + child: const Text( + 'نوبت شماست', + style: TextStyle( + color: Color(0xFF3A0A12), + fontWeight: FontWeight.bold, + fontSize: 16, ), ), - ); + ), + ), + ); bool _showTrumpPicker(GameUiState s) => s.state != null && @@ -140,15 +152,15 @@ class _GameScreenState extends State { s.gameOver == null; Widget _backButton(BuildContext context) => Positioned( - top: 8, - right: 8, - child: SafeArea( - child: IconButton( - icon: const Icon(Icons.arrow_back, color: AppColors.gold), - onPressed: () => _confirmLeave(context), - ), - ), - ); + top: 8, + right: 8, + child: SafeArea( + child: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.gold), + onPressed: () => _confirmLeave(context), + ), + ), + ); Future _confirmLeave(BuildContext context) async { if (context.read().state.gameOver != null) { @@ -157,19 +169,24 @@ class _GameScreenState extends State { } final yes = await showDialog( context: context, - builder: (_) => AlertDialog( - backgroundColor: AppColors.panel, - title: const Text('خروج از میز'), - content: const Text('از میز خارج می‌شوید؟ ورودی بازگردانده نمی‌شود.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('ماندن')), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('خروج')), - ], - ), + builder: + (_) => AlertDialog( + backgroundColor: AppColors.panel, + title: const Text('خروج از میز'), + content: const Text( + 'از میز خارج می‌شوید؟ ورودی بازگردانده نمی‌شود.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('ماندن'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('خروج'), + ), + ], + ), ); if (yes == true && context.mounted) { context.read().leave(); @@ -184,22 +201,24 @@ class _GameScreenState extends State { } Widget _connBanner() => Positioned( - top: 0, - left: 0, - right: 0, - child: Material( - color: Colors.orange.shade900, - child: const SafeArea( - bottom: false, - child: Padding( - padding: EdgeInsets.all(8), - child: Text('ارتباط با سرور قطع شد، در حال تلاش برای اتصال مجدد…', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white)), - ), + top: 0, + left: 0, + right: 0, + child: Material( + color: Colors.orange.shade900, + child: const SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.all(8), + child: Text( + 'ارتباط با سرور قطع شد، در حال تلاش برای اتصال مجدد…', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white), ), ), - ); + ), + ), + ); Widget _searchPanel(GameUiState state) { final players = state.state?.players ?? const []; @@ -226,11 +245,14 @@ class _GameScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('جستجوی حریف', - style: TextStyle( - color: AppColors.gold, - fontSize: 22, - fontWeight: FontWeight.bold)), + const Text( + 'جستجوی حریف', + style: TextStyle( + color: AppColors.gold, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 18), Row( mainAxisAlignment: MainAxisAlignment.center, @@ -239,31 +261,11 @@ class _GameScreenState extends State { _searchSlot(at(seat), seat == mySeat), ], ), - const SizedBox(height: 18), - Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), - decoration: BoxDecoration( - color: AppColors.bgDark, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.goldDark), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.monetization_on, color: AppColors.gold), - const SizedBox(width: 8), - Text('${widget.prize}', - style: const TextStyle( - color: AppColors.gold, - fontSize: 18, - fontWeight: FontWeight.bold)), - ]), - ), if (searching) ...[ const SizedBox(height: 16), - const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - strokeWidth: 2, color: AppColors.gold), + const Text( + 'در حال یافتن حریفان…', + style: TextStyle(color: Colors.white70, fontSize: 13), ), ], ], @@ -276,34 +278,52 @@ class _GameScreenState extends State { final found = p != null; return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(found ? p.name : '...', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + found ? p.name : '...', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: isYou ? AppColors.gold : Colors.white70, fontSize: 12)), - const SizedBox(height: 4), - Container( - width: 58, - height: 58, - decoration: BoxDecoration( - color: AppColors.bgDark, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isYou ? AppColors.gold : AppColors.goldDark, width: 1.5), + color: isYou ? AppColors.gold : Colors.white70, + fontSize: 12, + ), ), - child: Icon( - found - ? (p.bot ? Icons.smart_toy : Icons.person) - : Icons.help_outline, - color: found ? AppColors.gold : Colors.white24, - size: 30, + const SizedBox(height: 4), + Container( + width: 58, + height: 58, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.bgDark, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isYou ? AppColors.gold : AppColors.goldDark, + width: 1.5, + ), + ), + child: + !found + ? const _SearchingAvatar() // آواتارهای متغیر تا یافتنِ حریف + : p.bot + ? const Icon( + Icons.smart_toy, + color: AppColors.gold, + size: 30, + ) + : ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RandomAvatar(p.name), + ), ), - ), - const SizedBox(height: 2), - Text(found ? (p.bot ? 'ربات' : (isYou ? 'شما' : 'حریف')) : '', - style: const TextStyle(color: Colors.white38, fontSize: 10)), - ]), + const SizedBox(height: 2), + Text( + found ? (p.bot ? 'ربات' : (isYou ? 'شما' : 'حریف')) : '', + style: const TextStyle(color: Colors.white38, fontSize: 10), + ), + ], + ), ); } @@ -328,8 +348,10 @@ class _GameScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('حکم را انتخاب کن', - style: TextStyle(color: AppColors.gold, fontSize: 20)), + const Text( + 'حکم را انتخاب کن', + style: TextStyle(color: AppColors.gold, fontSize: 20), + ), const SizedBox(height: 16), Wrap( spacing: 12, @@ -343,11 +365,17 @@ class _GameScreenState extends State { minimumSize: const Size(120, 64), ), onPressed: () => context.read().chooseTrump(id), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text(sym, style: TextStyle(fontSize: 26, color: color)), - const SizedBox(width: 8), - Text(name, style: const TextStyle(color: AppColors.text)), - ]), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(sym, style: TextStyle(fontSize: 26, color: color)), + const SizedBox(width: 8), + Text( + name, + style: const TextStyle(color: AppColors.text), + ), + ], + ), ), ], ), @@ -371,26 +399,34 @@ class _GameScreenState extends State { borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.gold), ), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(won ? 'این دست را بردید!' : 'این دست را باختید', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + won ? 'این دست را بردید!' : 'این دست را باختید', style: TextStyle( - color: won ? AppColors.green : Colors.redAccent, - fontSize: 20, - fontWeight: FontWeight.bold)), - if (r.kot) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( + color: won ? AppColors.green : Colors.redAccent, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + if (r.kot) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( r.hakemKot ? 'حاکم‌کُت! (${r.points} امتیاز)' : 'کُت! (${r.points} امتیاز)', - style: const TextStyle(color: AppColors.gold)), - ), - const SizedBox(height: 6), - Text( + 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)), - ]), + style: const TextStyle(color: Colors.white70), + ), + ], + ), ), ), ); @@ -403,25 +439,67 @@ class _GameScreenState extends State { return Container( color: Colors.black87, alignment: Alignment.center, - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(won ? 'بردید! 🎉' : 'باختید', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + won ? 'بردید! 🎉' : 'باختید', style: TextStyle( - color: won ? AppColors.gold : Colors.redAccent, - fontSize: 32, - fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - Text( - 'نتیجه نهایی: ${g.scores.isNotEmpty ? g.scores[0] : 0} - ${g.scores.length > 1 ? g.scores[1] : 0}', - style: const TextStyle(color: Colors.white70, fontSize: 18)), - const SizedBox(height: 28), - SizedBox( - width: 220, - child: ElevatedButton( - onPressed: () => _exitToLobby(context), - child: const Text('بازگشت به لابی'), + color: won ? AppColors.gold : Colors.redAccent, + fontSize: 32, + fontWeight: FontWeight.bold, + ), ), - ), - ]), + const SizedBox(height: 12), + Text( + 'نتیجه نهایی: ${g.scores.isNotEmpty ? g.scores[0] : 0} - ${g.scores.length > 1 ? g.scores[1] : 0}', + style: const TextStyle(color: Colors.white70, fontSize: 18), + ), + const SizedBox(height: 28), + SizedBox( + width: 220, + child: ElevatedButton( + onPressed: () => _exitToLobby(context), + child: const Text('بازگشت به لابی'), + ), + ), + ], + ), + ); + } +} + +/// آواتارِ در حالِ تغییر برای جایگاه‌های خالیِ پنل جستجو (حسِ «در حال یافتن»). +class _SearchingAvatar extends StatefulWidget { + const _SearchingAvatar(); + + @override + State<_SearchingAvatar> createState() => _SearchingAvatarState(); +} + +class _SearchingAvatarState extends State<_SearchingAvatar> { + Timer? _timer; + int _i = 0; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(milliseconds: 220), (_) { + if (mounted) setState(() => _i++); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RandomAvatar('seek-${_i % 24}'), ); } } diff --git a/lib/feature/game/presentation/widgets/flame/card_component.dart b/lib/feature/game/presentation/widgets/flame/card_component.dart index fa4cc59..8cc0226 100644 --- a/lib/feature/game/presentation/widgets/flame/card_component.dart +++ b/lib/feature/game/presentation/widgets/flame/card_component.dart @@ -42,13 +42,28 @@ class CardComponent extends PositionComponent } } + // بزرگ‌نماییِ کارتِ انتخاب‌شده هنگام کشیدن (واضح‌تر برای دیدن). + static const _dragScale = 1.4; + + void _scaleTo(double s) { + children.whereType().toList().forEach((e) => e.removeFromParent()); + add(ScaleEffect.to( + Vector2.all(s), EffectController(duration: 0.12, curve: Curves.easeOut))); + } + + /// بازنشانیِ فوریِ مقیاس به اندازه‌ی عادی (هنگام بازی روی میز). + void resetScale() { + children.whereType().toList().forEach((e) => e.removeFromParent()); + scale = Vector2.all(1); + } + @override void onDragStart(DragStartEvent event) { super.onDragStart(event); if (!_playable) return; _dragging = true; priority = 1000; // روی همه‌ی کارت‌ها - scale = Vector2.all(1.12); // بزرگ‌نمایی هنگام کشیدن (فیدبک) + _scaleTo(_dragScale); // بزرگ‌نماییِ نرم هنگام انتخاب } @override @@ -63,11 +78,13 @@ class CardComponent extends PositionComponent super.onDragEnd(event); if (!_dragging) return; _dragging = false; - scale = Vector2.all(1); - // فقط اگر داخل ناحیه‌ی وسط میز رها شد ⇒ بازی؛ وگرنه برگشت به جای مرتبِ خود. + // فقط اگر داخل ناحیه‌ی وسط میز رها شد ⇒ بازی (اندازه به حالت عادی)؛ + // وگرنه برگشت به جای مرتبِ خود. if (_inDropZone()) { + resetScale(); onPlay?.call(); } else { + _scaleTo(1); _returnHome(); } _overZone = false; @@ -79,7 +96,7 @@ class CardComponent extends PositionComponent if (!_dragging) return; _dragging = false; _overZone = false; - scale = Vector2.all(1); + _scaleTo(1); _returnHome(); } @@ -147,15 +164,15 @@ class CardComponent extends PositionComponent ..color = const Color(0xFF1A1A1A), ); - // هایلایت طلایی وقتی کارت روی ناحیه‌ی انداختن است (رهاکنی، بازی می‌شود). - if (_dragging && _overZone) { + // هایلایت طلایی: هنگام کشیدن همیشه (انتخاب)، و پررنگ‌تر روی ناحیه‌ی انداختن. + if (_dragging) { canvas.drawRRect( rrect, Paint() ..style = PaintingStyle.stroke - ..strokeWidth = size.x * 0.06 + ..strokeWidth = size.x * (_overZone ? 0.06 : 0.035) ..color = const Color(0xFFE9B949) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6), + ..maskFilter = MaskFilter.blur(BlurStyle.normal, _overZone ? 6 : 3), ); } } diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index 6660c94..c6bc0fc 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -47,10 +47,16 @@ class HokmGame extends FlameGame { int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف» - bool _pendingShuffle = false; // بُرِ دستِ اول که تا پایانِ اینترو به تعویق افتاده + bool _pendingShuffle = + false; // بُرِ دستِ اول که تا پایانِ اینترو به تعویق افتاده bool _shuffling = false; // در حالِ پخشِ انیمیشنِ بُر زدن (بازیِ کارت ممنوع) String? _prevTrump; // برای تشخیصِ لحظه‌ی انتخابِ حکم + // مدیریتِ ضربانِ قلب (فقط نوبتِ خودِ کاربر؛ مستقل از بازساختِ صحنه). + double _turnElapsed = 0; + bool _beatPlayed = false; // یک‌بار در هر نوبت، هنگام رسیدن به آستانه + bool _wasMyTurn = false; + HokmGame(this.cubit); @override @@ -101,6 +107,7 @@ class HokmGame extends FlameGame { @override void onRemove() { _sub?.cancel(); + AppSounds.stopHeartBeat(); super.onRemove(); } @@ -118,7 +125,8 @@ class HokmGame extends FlameGame { now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now); if (addedOne) { final card = s.trick.last; - final isCut = s.trump != null && + final isCut = + s.trump != null && s.leadSuit != null && s.leadSuit != s.trump && suitName(card.card) == s.trump; @@ -136,7 +144,10 @@ class HokmGame extends FlameGame { } /// قطعِ صداهای بازی هنگام خروج از میز (جلوگیری از پخشِ صدای کارت پس از ترک). - void silence() => _silent = true; + void silence() { + _silent = true; + AppSounds.stopHeartBeat(); + } /// پایانِ نمایشِ «جستجوی حریف»؛ اگر بُرِ دستِ اول به تعویق افتاده بود، اکنون /// (که اوورلی کنار رفته) با صدا و تصویرِ هماهنگ اجرا می‌شود. @@ -180,8 +191,12 @@ class HokmGame extends FlameGame { ), ), ); - reveal.add(ScaleEffect.to( - Vector2.all(1.0), EffectController(duration: 0.35, curve: Curves.easeOutBack))); + reveal.add( + ScaleEffect.to( + Vector2.all(1.0), + EffectController(duration: 0.35, curve: Curves.easeOutBack), + ), + ); reveal.add(RemoveEffect(delay: 1.3)); add(reveal); } @@ -198,6 +213,44 @@ class HokmGame extends FlameGame { super.update(dt); _t += dt; if (_shake > 0) _shake = math.max(0, _shake - dt * 2.2); + _updateHeartBeat(dt); + } + + // ضربانِ قلب فقط در ثانیه‌های پایانیِ نوبتِ خودِ کاربر؛ هنگامِ پایانِ نوبت + // (بازی‌شدنِ کارت توسط کاربر یا سیستم) بلافاصله قطع می‌شود. + void _updateHeartBeat(double dt) { + final s = _s; + final myTurn = !_silent && + !_introActive && + !_shuffling && + s != null && + ((s.phase == 'playing' && s.turn == s.yourSeat) || + (s.phase == 'choose_trump' && s.hakem == s.yourSeat)); + + if (!myTurn) { + if (_wasMyTurn) { + AppSounds.stopHeartBeat(); // نوبت تمام شد ⇒ توقفِ فوریِ صدا + _wasMyTurn = false; + } + return; + } + + if (!_wasMyTurn) { + // شروعِ نوبتِ من ⇒ شمارش از صفر. + _wasMyTurn = true; + _turnElapsed = 0; + _beatPlayed = false; + } + _turnElapsed += dt; + + final dur = (s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000.0 : 30.0); + final remaining = dur - _turnElapsed; + final warn = math.min(8.0, dur * 0.4); + // فقط یک‌بار، دقیقاً وقتی به آستانه‌ی پایانی رسیدیم. + if (!_beatPlayed && remaining > 0 && remaining <= warn) { + _beatPlayed = true; + AppSounds.heartBeat(); + } } @override @@ -222,7 +275,7 @@ class HokmGame extends FlameGame { void _relayout() { final s = _s; if (s == null) return; - _cardW = size.x * 0.19; + _cardW = size.x * 0.22; // کارت‌های روی میز بزرگ‌تر (دیدِ بهتر) _cardH = _cardW * kCardRatio; _rebuildBacksAndInfo(s); @@ -244,12 +297,19 @@ class HokmGame extends FlameGame { // حرکتِ نرمِ یک کارت به مقصد، بدون انباشته‌شدنِ افکت‌ها. void _moveTo(CardComponent c, Vector2 target, {double dur = 0.38}) { - c.children.whereType().toList().forEach((e) => e.removeFromParent()); + c.children.whereType().toList().forEach( + (e) => e.removeFromParent(), + ); if ((c.position - target).length < 0.5) { c.position = target; return; } - c.add(MoveToEffect(target, EffectController(duration: dur, curve: Curves.easeOutCubic))); + c.add( + MoveToEffect( + target, + EffectController(duration: dur, curve: Curves.easeOutCubic), + ), + ); } // ناحیه‌ی وسط میز که رهاکردنِ کارت در آن یعنی بازی (بالاتر از دستِ شما). @@ -270,11 +330,12 @@ class HokmGame extends FlameGame { } // چیدمانِ بادبزنیِ منحنی: کارت‌ها چرخش و قوسِ ملایم دارند (مثل دستِ واقعی). - final hw = size.x * 0.225, hh = hw * kCardRatio; - final handW = size.x * 0.86; + // اندازه‌ی دست کمی بزرگ‌تر از کارت‌های روی میز است (خوانا ولی نه نامتوازن). + final hw = size.x * 0.24, hh = hw * kCardRatio; + final handW = size.x * 0.90; final stepX = n > 1 ? ((handW - hw) / (n - 1)).clamp(0.0, hw * 0.62) : 0.0; final cx = size.x / 2; - final baseY = size.y * 0.86; + final baseY = size.y * 0.83; final tMax = (n - 1) / 2; const edgeAngle = 0.34; // چرخشِ کارت‌های کناری (رادیان) final dip = hh * 0.16; // افتِ عمودیِ کارت‌های کناری برای قوس @@ -288,7 +349,12 @@ class HokmGame extends FlameGame { var c = _hand[code]; if (c == null) { // کارت جدید ⇒ از مرکز میز پخش می‌شود. - c = CardComponent(code: code, faceUp: true, size: Vector2(hw, hh), position: size / 2); + c = CardComponent( + code: code, + faceUp: true, + size: Vector2(hw, hh), + position: size / 2, + ); _hand[code] = c; add(c); } else { @@ -312,12 +378,18 @@ class HokmGame extends FlameGame { for (final code in _trick.keys.toList()) { if (present.contains(code)) continue; final c = _trick.remove(code)!; - c.children.whereType().toList().forEach((e) => e.removeFromParent()); - c.add(SequenceEffect([ - MoveToEffect(_seatOrigin(_rel(s.turn)), - EffectController(duration: 0.25, curve: Curves.easeIn)), - RemoveEffect(), - ])); + c.children.whereType().toList().forEach( + (e) => e.removeFromParent(), + ); + c.add( + SequenceEffect([ + MoveToEffect( + _seatOrigin(_rel(s.turn)), + EffectController(duration: 0.25, curve: Curves.easeIn), + ), + RemoveEffect(), + ]), + ); } for (final tc in s.trick) { @@ -330,6 +402,7 @@ class HokmGame extends FlameGame { c.onPlay = null; c.dimmed = false; c.home = null; + c.resetScale(); // کارتِ بازی‌شده هم‌اندازه‌ی بقیه‌ی کارت‌های میز } else { c = CardComponent( code: tc.card, @@ -409,9 +482,10 @@ class HokmGame extends FlameGame { final mine = team == s.yourSeat % 2; final w = _cardW * 0.40, h = w * kCardRatio; // تیم شما جلوی شما (پایین)، تیم حریف جلوی حریفِ سمت راست. - final base = mine - ? Vector2(size.x * 0.28, size.y * 0.72) - : Vector2(size.x * 0.82, size.y * 0.24); + final base = + mine + ? Vector2(size.x * 0.28, size.y * 0.72) + : Vector2(size.x * 0.82, size.y * 0.24); final step = Vector2(w * 0.40, 0); final n = won.clamp(1, 7); final start = base - step * ((n - 1) / 2); @@ -429,10 +503,12 @@ class HokmGame extends FlameGame { for (var team = 0; team < 2; team++) { final score = team < s.scores.length ? s.scores[team] : 0; final mine = team == s.yourSeat % 2; - final pos = mine - ? Vector2(size.x * 0.50, size.y * 0.74) - : Vector2(size.x * 0.84, size.y * 0.44); - final puck = ScorePuck(score, radius: radius, position: pos)..priority = 22; + final pos = + mine + ? Vector2(size.x * 0.50, size.y * 0.74) + : Vector2(size.x * 0.84, size.y * 0.44); + final puck = ScorePuck(score, radius: radius, position: pos) + ..priority = 22; _info.add(puck); add(puck); } @@ -467,13 +543,23 @@ class HokmGame extends FlameGame { void _addInfo(GameState s) { if (s.trump != null) { final (sym, col) = _trumpGlyph(s.trump!); - _addLabel('حکم: $sym', Vector2(size.x * 0.04, size.y * 0.04), size.x * 0.05, - color: col, anchor: Anchor.topLeft, bold: true); + _addLabel( + 'حکم: $sym', + Vector2(size.x * 0.04, size.y * 0.04), + size.x * 0.05, + color: col, + anchor: Anchor.topLeft, + bold: true, + ); } final a = s.scores.isNotEmpty ? s.scores[0] : 0; final b = s.scores.length > 1 ? s.scores[1] : 0; - _addLabel('$a - $b', Vector2(size.x / 2, size.y * 0.04), size.x * 0.05, - anchor: Anchor.topCenter); + _addLabel( + '$a - $b', + Vector2(size.x / 2, size.y * 0.04), + size.x * 0.05, + anchor: Anchor.topCenter, + ); for (final p in s.players) { final pos = _seatLabelPos(_rel(p.seat)); @@ -486,22 +572,27 @@ class HokmGame extends FlameGame { bold: isTurn, ); // تاجِ حاکم (روی همه‌ی فازها نشان داده می‌شود تا حاکم مشخص باشد). + // y را داخلِ صفحه نگه می‌داریم؛ برای بازیکنِ بالا، تاج زیرِ نام می‌نشیند. if (s.hakem == p.seat) { - final crown = CrownBadge(size.x * 0.06, pos - Vector2(0, size.y * 0.066)) - ..priority = 23; + final above = pos.y > size.y * 0.5; // پایین/کنار ⇒ تاج بالای نام + final cy = above + ? pos.y - size.y * 0.066 + : pos.y + size.y * 0.05; // بازیکنِ بالا ⇒ تاج پایینِ نام + final crown = CrownBadge(size.x * 0.07, Vector2(pos.x, cy)) + ..priority = 25; _info.add(crown); add(crown); } if (isTurn) { final tpos = pos - Vector2(0, size.y * 0.03); final human = !p.bot && p.connected; - // حلقه‌ی شمارشِ نوبت برای بازیکنِ انسان. - if (human && - s.turnTimeoutMs > 0 && - (s.phase == 'playing' || s.phase == 'choose_trump')) { + // حلقه‌ی شمارشِ نوبت برای بازیکنِ انسان. اگر سرور مهلت را نفرستد، + // پیش‌فرضِ ۳۰ ثانیه استفاده می‌شود تا شمارنده همیشه نمایش داده شود. + if (human && (s.phase == 'playing' || s.phase == 'choose_trump')) { + final dur = s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000.0 : 30.0; final timer = TurnTimer( - radius: size.x * 0.03, - durationSeconds: s.turnTimeoutMs / 1000.0, + radius: size.x * 0.045, + durationSeconds: dur, position: tpos, )..priority = 20; _info.add(timer); @@ -519,15 +610,25 @@ class HokmGame extends FlameGame { // ===== کمکی‌های ساختِ عنصر (ثبت در فهرستِ بازساخته‌شونده) ===== void _addBack(Vector2 pos, Vector2 sz, {required int priority}) { - final c = CardComponent(code: '', faceUp: false, size: sz, position: pos, priority: priority); + final c = CardComponent( + code: '', + faceUp: false, + size: sz, + position: pos, + priority: priority, + ); _backs.add(c); add(c); } - void _addLabel(String text, Vector2 pos, double fontSize, - {Color color = const Color(0xFFE9B949), - Anchor anchor = Anchor.center, - bool bold = false}) { + void _addLabel( + String text, + Vector2 pos, + double fontSize, { + Color color = const Color(0xFFE9B949), + Anchor anchor = Anchor.center, + bool bold = false, + }) { final t = TextComponent( text: text, anchor: anchor, diff --git a/lib/feature/game/presentation/widgets/flame/turn_timer.dart b/lib/feature/game/presentation/widgets/flame/turn_timer.dart index fb45b59..410b286 100644 --- a/lib/feature/game/presentation/widgets/flame/turn_timer.dart +++ b/lib/feature/game/presentation/widgets/flame/turn_timer.dart @@ -4,7 +4,8 @@ import 'package:flame/components.dart'; import 'package:flutter/material.dart'; /// حلقه‌ی شمارشِ معکوسِ نوبت: کمانی که در طولِ مهلتِ نوبت خالی می‌شود. -/// رنگ از طلایی به قرمز می‌رود و در ثانیه‌های پایانی هشدار می‌دهد. +/// در ثانیه‌های پایانی قرمز و ضربان‌دار می‌شود. (صدای ضربانِ قلب در HokmGame +/// مدیریت می‌شود تا هنگامِ بازی‌شدنِ کارت بلافاصله قطع شود.) class TurnTimer extends PositionComponent { final double radius; final double durationSeconds; @@ -20,6 +21,8 @@ class TurnTimer extends PositionComponent { anchor: Anchor.center, ); + double get _warnSecs => math.min(8.0, durationSeconds * 0.4); + static final _bg = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 @@ -28,6 +31,13 @@ class TurnTimer extends PositionComponent { @override void update(double dt) { _elapsed += dt; + final remaining = durationSeconds - _elapsed; + // ضربانِ تصویری در پنجره‌ی هشدار. + if (remaining > 0 && remaining <= _warnSecs) { + scale = Vector2.all(1 + 0.18 * math.sin(_elapsed * 2 * math.pi / 0.7).abs()); + } else { + scale = Vector2.all(1); + } } @override @@ -35,11 +45,12 @@ class TurnTimer extends PositionComponent { final center = Offset(radius, radius); canvas.drawCircle(center, radius, _bg); - final frac = - durationSeconds <= 0 ? 0.0 : (1 - _elapsed / durationSeconds).clamp(0.0, 1.0); + final frac = durationSeconds <= 0 + ? 0.0 + : (1 - _elapsed / durationSeconds).clamp(0.0, 1.0); if (frac <= 0) return; - final color = frac > 0.3 ? const Color(0xFFE9B949) : const Color(0xFFE53935); + final color = frac > 0.35 ? const Color(0xFFE9B949) : const Color(0xFFE53935); final arc = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 4 @@ -47,7 +58,7 @@ class TurnTimer extends PositionComponent { ..color = color; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), - -math.pi / 2, // شروع از بالا + -math.pi / 2, 2 * math.pi * frac, false, arc,