feat: add heart beat for timing

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