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
+23
View File
@@ -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<String, AudioPool> _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); // کلیکِ کوتاه
}
@@ -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<GameScreen> {
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _confirmLeave(context);
},
child: Scaffold(
body: BlocConsumer<GameBloc, GameUiState>(
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<GameBloc>().clearNotice();
// خطا پیش از شروعِ بازی (مثلاً سکه‌ی ناکافی) ⇒ کاربر در دیالوگِ
// «جستجوی حریف» گیر نکند؛ به لابی برگردد.
if (state.state == null && state.gameOver == null) {
_exitToLobby(context);
child: SafeArea(
top: false,
child: Scaffold(
body: BlocConsumer<GameBloc, GameUiState>(
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<GameBloc>().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<GameScreen> {
// بنرِ «نوبت شماست» وقتی کاربر باید کارت بیندازد.
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<GameScreen> {
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<void> _confirmLeave(BuildContext context) async {
if (context.read<GameBloc>().state.gameOver != null) {
@@ -157,19 +169,24 @@ class _GameScreenState extends State<GameScreen> {
}
final yes = await showDialog<bool>(
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<GameBloc>().leave();
@@ -184,22 +201,24 @@ class _GameScreenState extends State<GameScreen> {
}
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 <GamePlayer>[];
@@ -226,11 +245,14 @@ class _GameScreenState extends State<GameScreen> {
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<GameScreen> {
_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<GameScreen> {
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<GameScreen> {
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<GameScreen> {
minimumSize: const Size(120, 64),
),
onPressed: () => context.read<GameBloc>().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<GameScreen> {
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<GameScreen> {
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}'),
);
}
}
@@ -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
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),
);
}
}
@@ -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<MoveToEffect>().toList().forEach((e) => e.removeFromParent());
c.children.whereType<MoveToEffect>().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<MoveToEffect>().toList().forEach((e) => e.removeFromParent());
c.add(SequenceEffect([
MoveToEffect(_seatOrigin(_rel(s.turn)),
EffectController(duration: 0.25, curve: Curves.easeIn)),
RemoveEffect(),
]));
c.children.whereType<MoveToEffect>().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,
@@ -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,