629 lines
22 KiB
Dart
629 lines
22 KiB
Dart
import 'dart:async';
|
|
|
|
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';
|
|
import '../../../../core/theme/app_theme.dart';
|
|
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
|
|
import '../../../wallet/presentation/bloc/wallet_event.dart';
|
|
import '../../../wallet/presentation/bloc/wallet_state.dart';
|
|
import '../../../wallet/presentation/bloc/wallet_status.dart';
|
|
import '../../domain/entities/game_entities.dart';
|
|
import '../bloc/game_bloc.dart';
|
|
import '../bloc/game_state.dart';
|
|
import '../widgets/chat_sheet.dart';
|
|
import '../widgets/flame/hokm_game.dart';
|
|
import '../widgets/table_hud.dart';
|
|
|
|
/// صفحهی میز بازی: صحنهی Flame + اوورلیهای وضعیت.
|
|
class GameScreen extends StatefulWidget {
|
|
final int prize;
|
|
const GameScreen({super.key, this.prize = 0});
|
|
|
|
@override
|
|
State<GameScreen> createState() => _GameScreenState();
|
|
}
|
|
|
|
class _GameScreenState extends State<GameScreen> {
|
|
late final HokmGame _game;
|
|
Timer? _introTimer;
|
|
bool _introHidden = false;
|
|
int _countdown = 0; // شمارشِ معکوسِ شروعِ بازی پس از یافتنِ حریفان (۳…۲…۱)
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final ws = context.read<WalletBloc>().state.walletStatus;
|
|
final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple';
|
|
_game = HokmGame(
|
|
context.read<GameBloc>(),
|
|
skin: skin.isEmpty ? 'simple' : skin,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_introTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _showSearch(GameUiState s) => s.state == null || !_introHidden;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (!didPop) _confirmLeave(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();
|
|
}
|
|
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) {
|
|
// حریفان پیدا شدند ⇒ شمارشِ معکوسِ ۳…۲…۱ با صدا تا کاربر بداند
|
|
// بازی در حالِ شروع است، سپس اوورلی کنار میرود و بُر زدن اجرا میشود.
|
|
_countdown = 3; // داخلِ build هستیم؛ همین build مقدار را نشان میدهد
|
|
AppSounds.tick();
|
|
_introTimer = Timer.periodic(const Duration(seconds: 1), (t) {
|
|
if (!mounted) {
|
|
t.cancel();
|
|
return;
|
|
}
|
|
if (_countdown <= 1) {
|
|
t.cancel();
|
|
setState(() => _introHidden = true);
|
|
_game.endIntro();
|
|
} else {
|
|
setState(() => _countdown--);
|
|
AppSounds.tick();
|
|
}
|
|
});
|
|
}
|
|
return Stack(
|
|
children: [
|
|
GameWidget(game: _game),
|
|
if (!_showSearch(state) && state.state != null)
|
|
TableHud(
|
|
s: state.state!,
|
|
connection: state.connection,
|
|
chatBubbles: state.chatBubbles,
|
|
onExit: () => _confirmLeave(context),
|
|
onChat: () => _openChat(context),
|
|
),
|
|
if (state.connection == WsStatus.disconnected) _connBanner(),
|
|
if (_showSearch(state)) _searchPanel(state),
|
|
// دیالوگِ انتخابِ حکم و بنرِ نوبت فقط پس از پایانِ انیمیشنِ
|
|
// پخشِ کارت نمایش داده میشوند (نه وسطِ بُر زدن/پخش).
|
|
if (!_showSearch(state))
|
|
ValueListenableBuilder<bool>(
|
|
valueListenable: _game.animating,
|
|
builder: (context, animating, _) {
|
|
if (animating) return const SizedBox.shrink();
|
|
return Stack(
|
|
children: [
|
|
if (_showTrumpPicker(state)) _trumpPicker(context),
|
|
if (_isMyPlayTurn(state)) _turnBanner(),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
if (state.handResult != null && state.gameOver == null)
|
|
_handResult(state),
|
|
if (state.gameOver != null) _gameOver(context, state),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openChat(BuildContext context) {
|
|
final bloc = context.read<GameBloc>();
|
|
ChatSheet.show(
|
|
context,
|
|
onText: bloc.sendChatText,
|
|
onEmoji: bloc.sendChatEmoji,
|
|
);
|
|
}
|
|
|
|
bool _isMyPlayTurn(GameUiState s) =>
|
|
s.state != null &&
|
|
s.gameOver == null &&
|
|
s.state!.phase == 'playing' &&
|
|
s.state!.isMyTurn &&
|
|
!s.state!.trickDone;
|
|
|
|
// بنرِ «نوبت شماست» وقتی کاربر باید کارت بیندازد.
|
|
Widget _turnBanner() => Align(
|
|
alignment: const Alignment(0, 0.3),
|
|
child: IgnorePointer(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
colors: [AppColors.gold, AppColors.goldDark],
|
|
),
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 8)],
|
|
),
|
|
child: const Text(
|
|
'نوبت شماست',
|
|
style: TextStyle(
|
|
color: AppColors.bg,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
bool _showTrumpPicker(GameUiState s) =>
|
|
s.state != null &&
|
|
s.state!.phase == 'choose_trump' &&
|
|
s.state!.amHakem &&
|
|
s.gameOver == null;
|
|
|
|
Future<void> _confirmLeave(BuildContext context) async {
|
|
if (context.read<GameBloc>().state.gameOver != null) {
|
|
_exitToLobby(context);
|
|
return;
|
|
}
|
|
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('خروج'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (yes == true && context.mounted) {
|
|
context.read<GameBloc>().leave();
|
|
_exitToLobby(context);
|
|
}
|
|
}
|
|
|
|
void _exitToLobby(BuildContext context) {
|
|
_game.silence(); // پس از خروج، صدای کارتهای بات پخش نشود
|
|
context.read<WalletBloc>().add(LoadWalletEvent());
|
|
context.go('/lobby');
|
|
}
|
|
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _searchPanel(GameUiState state) {
|
|
final players = state.state?.players ?? const <GamePlayer>[];
|
|
final mySeat = state.state?.yourSeat ?? -1;
|
|
final searching = state.state == null;
|
|
GamePlayer? at(int seat) {
|
|
for (final p in players) {
|
|
if (p.seat == seat) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return Container(
|
|
color: Colors.black.withValues(alpha: 0.78),
|
|
alignment: Alignment.center,
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 20),
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 18),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.panel,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: AppColors.gold, width: 2.5),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'جستجوی حریف',
|
|
style: TextStyle(
|
|
color: AppColors.gold,
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
for (var seat = 0; seat < 4; seat++)
|
|
_searchSlot(at(seat), seat == mySeat, seat),
|
|
],
|
|
),
|
|
if (searching) ...[
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'در حال یافتن حریفان…',
|
|
style: TextStyle(color: Colors.white70, fontSize: 13),
|
|
),
|
|
] else if (_countdown > 0) ...[
|
|
const SizedBox(height: 14),
|
|
const Text(
|
|
'شروع بازی',
|
|
style: TextStyle(color: Colors.white70, fontSize: 13),
|
|
),
|
|
const SizedBox(height: 6),
|
|
TweenAnimationBuilder<double>(
|
|
// هر عدد با یک «پاپ» ظاهر میشود تا واضح و جلبتوجه باشد.
|
|
key: ValueKey(_countdown),
|
|
tween: Tween(begin: 1.6, end: 1.0),
|
|
duration: const Duration(milliseconds: 400),
|
|
curve: Curves.easeOut,
|
|
builder: (_, scale, child) =>
|
|
Transform.scale(scale: scale, child: child),
|
|
child: Container(
|
|
width: 54,
|
|
height: 54,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: const LinearGradient(
|
|
colors: [AppColors.gold, AppColors.goldDark],
|
|
),
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
),
|
|
child: Text(
|
|
'$_countdown',
|
|
style: const TextStyle(
|
|
color: AppColors.bg,
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _searchSlot(GamePlayer? p, bool isYou, int seat) {
|
|
final found = p != null;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
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,
|
|
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
|
|
? _SearchingAvatar(slot: seat) // آواتارهای متغیر و متفاوت
|
|
: 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _trumpPicker(BuildContext context) {
|
|
const suits = [
|
|
('spades', '♠', 'پیک', Colors.white),
|
|
('hearts', '♥', 'دل', AppColors.suitRed),
|
|
('diamonds', '♦', 'خشت', AppColors.suitRed),
|
|
('clubs', '♣', 'گشنیز', Colors.white),
|
|
];
|
|
return Container(
|
|
color: Colors.black54,
|
|
alignment: Alignment.center,
|
|
child: Container(
|
|
margin: const EdgeInsets.all(24),
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.panel,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppColors.gold, width: 2),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'حکم را انتخاب کن',
|
|
style: TextStyle(color: AppColors.gold, fontSize: 20),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
for (final (id, sym, name, color) in suits)
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.bgDark,
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Divider(color: AppColors.goldFaint, height: 1),
|
|
const SizedBox(height: 12),
|
|
// بُرِ مجدد با مصرفِ یک بلیت (۵ کارتِ تازه، سپس باز هم انتخابِ حکم).
|
|
BlocBuilder<WalletBloc, WalletBlocState>(
|
|
builder: (context, ws) {
|
|
final tickets = ws.walletStatus is WalletLoaded
|
|
? (ws.walletStatus as WalletLoaded).wallet.tickets
|
|
: 0;
|
|
final canReshuffle = tickets > 0;
|
|
return Column(
|
|
children: [
|
|
ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFF8E1B7A),
|
|
disabledBackgroundColor: const Color(0xFF3A2233),
|
|
minimumSize: const Size(220, 48),
|
|
),
|
|
onPressed: canReshuffle
|
|
? () {
|
|
AppSounds.button();
|
|
context.read<GameBloc>().reshuffle();
|
|
// پس از مصرفِ بلیت، موجودی را تازه کن (بدون نگهداشتنِ context).
|
|
final wallet = context.read<WalletBloc>();
|
|
Future.delayed(const Duration(milliseconds: 500),
|
|
() => wallet.add(LoadWalletEvent()));
|
|
}
|
|
: null,
|
|
icon: const Icon(Icons.casino, color: Colors.white),
|
|
label: const Text(
|
|
'بُر مجدد (۱ بلیت)',
|
|
style: TextStyle(
|
|
color: Colors.white, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
canReshuffle
|
|
? 'بلیتهای شما: $tickets'
|
|
: 'بلیت کافی ندارید',
|
|
style: const TextStyle(
|
|
color: Colors.white60, fontSize: 12),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _handResult(GameUiState s) {
|
|
final r = s.handResult!;
|
|
final mySeat = s.state?.yourSeat ?? 0;
|
|
final myTeam = mySeat % 2;
|
|
final won = r.winnerTeam == myTeam;
|
|
return IgnorePointer(
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 18),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black87,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppColors.gold),
|
|
),
|
|
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(
|
|
r.hakemKot
|
|
? 'حاکمکُت! (${r.points} امتیاز)'
|
|
: 'کُت! (${r.points} امتیاز)',
|
|
style: const TextStyle(color: AppColors.gold),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}',
|
|
style: const TextStyle(color: Colors.white70),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _gameOver(BuildContext context, GameUiState s) {
|
|
final g = s.gameOver!;
|
|
final mySeat = s.state?.yourSeat ?? 0;
|
|
final won = g.winnerTeam == mySeat % 2;
|
|
return Container(
|
|
color: Colors.black87,
|
|
alignment: Alignment.center,
|
|
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('بازگشت به لابی'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// آواتارِ در حالِ تغییر برای جایگاههای خالیِ پنل جستجو (حسِ «در حال یافتن»).
|
|
class _SearchingAvatar extends StatefulWidget {
|
|
final int slot; // هر جایگاه دامنهی seedِ متفاوت دارد تا یکسان نباشند
|
|
const _SearchingAvatar({required this.slot});
|
|
|
|
@override
|
|
State<_SearchingAvatar> createState() => _SearchingAvatarState();
|
|
}
|
|
|
|
class _SearchingAvatarState extends State<_SearchingAvatar> {
|
|
Timer? _timer;
|
|
int _i = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_i = widget.slot * 13; // شروعِ متفاوت برای هر جایگاه
|
|
_timer = Timer.periodic(const Duration(milliseconds: 240), (_) {
|
|
if (mounted) setState(() => _i++);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// پیشوندِ مخصوصِ جایگاه ⇒ آواتارِ هر جایگاه با بقیه فرق دارد.
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: RandomAvatar('s${widget.slot}-$_i'),
|
|
);
|
|
}
|
|
}
|