feat: add heart beat for timing
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user