Files
front-hokm/lib/feature/game/presentation/screen/game_screen.dart
T
2026-07-10 00:56:58 +03:30

271 lines
11 KiB
Dart

import 'dart:async';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:random_avatar/random_avatar.dart';
import '../../../../core/locator/locator.dart';
import '../../../../core/network/ws_client.dart';
import '../../../../core/service/app_sounds.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/player_avatar.dart';
import '../../../shop/data/data_source/remote/frame_api_provider.dart';
import '../../../tournament/data/data_source/remote/tournament_api_provider.dart';
import '../../../tournament/domain/entities/tournament.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 '../../../chat/presentation/widgets/chat_panel.dart';
import '../widgets/confetti.dart';
import '../widgets/flame/hokm_game.dart';
import '../widgets/table_hud.dart';
part 'game_screen.parts.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; // شمارشِ معکوسِ شروعِ بازی پس از یافتنِ حریفان (۳…۲…۱)
Tournament? _tournament; // تورنومنتِ فعالِ ثبت‌نام‌شده (اگر باشد) — برای نشانِ درون‌بازی
@override
void initState() {
super.initState();
final ws = context.read<WalletBloc>().state.walletStatus;
final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple';
// URL کاملِ فرش از سرور می‌آید (خالی ⇒ میزِ کلاسیک).
final carpetUrl = ws is WalletLoaded ? ws.wallet.selectedCarpetImg : '';
_game = HokmGame(
context.read<GameBloc>(),
skin: skin.isEmpty ? 'simple' : skin,
carpetUrl: carpetUrl,
);
_loadTournament();
locator<FrameApiProvider>().warmCache(); // رنگِ قاب‌ها برای HUD (best-effort)
}
/// بررسی می‌کند آیا کاربر در تورنومنتِ فعالی ثبت‌نام کرده تا نشانِ درون‌بازی و
/// امتیازِ پایانِ بازی نمایش داده شود (این بازیِ عمومی به تورنومنت امتیاز می‌دهد).
Future<void> _loadTournament() async {
try {
final list = await locator<TournamentApiProvider>().getTournaments();
final active = list.where((t) => t.joined && t.isActive).toList();
if (mounted && active.isNotEmpty) {
setState(() => _tournament = active.first);
}
} catch (_) {/* بی‌اهمیت */}
}
@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) ||
(a.handResult == null && b.handResult != null),
listener: (context, state) {
// بازخوردِ لمسیِ بُردِ دست (کُت قوی‌تر) تا کاربر بی‌درنگ بفهمد بُرد.
final hr = state.handResult;
if (hr != null) {
final myTeam = (state.state?.yourSeat ?? 0) % 2;
if (hr.winnerTeam == myTeam) {
hr.kot
? HapticFeedback.heavyImpact()
: HapticFeedback.mediumImpact();
} else {
HapticFeedback.lightImpact(); // باختِ دست هم حس شود تا پایانِ دست معلوم باشد
}
}
if (state.gameOver != null) {
final won =
state.gameOver!.winnerTeam ==
(state.state?.yourSeat ?? 0) % 2;
if (won) {
AppSounds.win();
HapticFeedback.mediumImpact();
}
}
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: [
// فرش به‌عنوانِ سطحِ میز داخلِ صحنه‌ی Flame (Felt) کشیده می‌شود.
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) const _ConnBanner(),
if (_tournament != null && !_showSearch(state))
_TournamentBadge(title: _tournament!.title),
if (_showSearch(state))
_SearchPanel(state: state, countdown: _countdown),
// دیالوگِ انتخابِ حکم و بنرِ نوبت فقط پس از پایانِ انیمیشنِ
// پخشِ کارت نمایش داده می‌شوند (نه وسطِ بُر زدن/پخش).
if (!_showSearch(state))
ValueListenableBuilder<bool>(
valueListenable: _game.animating,
builder: (context, animating, _) {
if (animating) return const SizedBox.shrink();
return Stack(
children: [
if (_showTrumpPicker(state)) const _TrumpPicker(),
if (_isMyPlayTurn(state)) const _TurnBanner(),
],
);
},
),
if (state.handResult != null && state.gameOver == null)
Positioned.fill(child: _HandResult(state: state)),
if (state.gameOver != null)
_GameOver(
state: state,
prize: widget.prize,
tournamentTitle: _tournament?.title,
onExit: () => _exitToLobby(context),
),
],
);
},
),
),
),
);
}
void _openChat(BuildContext context) {
final bloc = context.read<GameBloc>();
final wallet = context.read<WalletBloc>();
ChatPanel.show(
context,
onText: bloc.sendChatText,
onEmoji: bloc.sendChatEmoji,
onWalletChanged: () => wallet.add(LoadWalletEvent()),
);
}
bool _isMyPlayTurn(GameUiState s) =>
s.state != null &&
s.gameOver == null &&
s.state!.phase == 'playing' &&
s.state!.isMyTurn &&
!s.state!.trickDone;
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');
}
}