diff --git a/assets/audio/select_hokm.mp3 b/assets/audio/select_hokm.mp3 new file mode 100644 index 0000000..89e260e Binary files /dev/null and b/assets/audio/select_hokm.mp3 differ diff --git a/lib/core/service/app_sounds.dart b/lib/core/service/app_sounds.dart index 7cf99a1..9ab02a6 100644 --- a/lib/core/service/app_sounds.dart +++ b/lib/core/service/app_sounds.dart @@ -14,8 +14,17 @@ class AppSounds { static const _cut = 'cut.mp3'; // بریدن با حکم (شمشیر) static const _win = 'win.mp3'; // برد بازی static const _button = 'button.mp3'; // فشردنِ دکمه‌ها + static const _selectHokm = 'select_hokm.mp3'; // انتخابِ حکم - static const _all = [_shuffle, _placeCard, _takeCard, _cut, _win, _button]; + static const _all = [ + _shuffle, + _placeCard, + _takeCard, + _cut, + _win, + _button, + _selectHokm, + ]; static final Map _pools = {}; static bool muted = false; @@ -47,5 +56,6 @@ class AppSounds { static void takeCard() => _play(_takeCard, 0.7); static void cut() => _play(_cut, 0.9); static void win() => _play(_win, 0.9); + static void selectHokm() => _play(_selectHokm, 0.9); static void button() => _play(_button, 0.5, cutoffMs: 130); // کلیکِ کوتاه } diff --git a/lib/feature/game/domain/entities/game_entities.dart b/lib/feature/game/domain/entities/game_entities.dart index a4c8bfe..1c90a21 100644 --- a/lib/feature/game/domain/entities/game_entities.dart +++ b/lib/feature/game/domain/entities/game_entities.dart @@ -37,6 +37,7 @@ class GameState { final List tricksWon; final List scores; final int targetScore; + final int turnTimeoutMs; // مهلتِ نوبتِ انسان (برای شمارنده‌ی نوبت) final List players; GameState({ @@ -54,6 +55,7 @@ class GameState { required this.tricksWon, required this.scores, required this.targetScore, + required this.turnTimeoutMs, required this.players, }); @@ -78,6 +80,7 @@ class GameState { tricksWon: ints(j['tricks_won']), scores: ints(j['scores']), targetScore: (j['target_score'] ?? 7) as int, + turnTimeoutMs: (j['turn_timeout_ms'] ?? 0) as int, players: ((j['players'] as List?) ?? []) .map((e) => GamePlayer.fromJson(Map.from(e as Map))) .toList(), diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index f59255b..1301b54 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -68,13 +68,18 @@ class _GameScreenState extends State { duration: const Duration(seconds: 2)), ); context.read().clearNotice(); + // خطا پیش از شروعِ بازی (مثلاً سکه‌ی ناکافی) ⇒ کاربر در دیالوگِ + // «جستجوی حریف» گیر نکند؛ به لابی برگردد. + if (state.state == null && state.gameOver == null) { + _exitToLobby(context); + } } }, builder: (context, state) { if (state.state != null && _introTimer == null) { // کمی «حریفان پیدا شد» نشان بده، بعد اوورلی را کنار بزن و همان // لحظه انیمیشنِ بُر زدن (با صدا) را اجرا کن. - _introTimer = Timer(const Duration(milliseconds: 700), () { + _introTimer = Timer(const Duration(milliseconds: 1000), () { if (!mounted) return; setState(() => _introHidden = true); _game.endIntro(); @@ -90,6 +95,7 @@ class _GameScreenState extends 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), ], ); @@ -99,6 +105,34 @@ class _GameScreenState extends State { ); } + 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.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)), + ), + ), + ); + bool _showTrumpPicker(GameUiState s) => s.state != null && s.state!.phase == 'choose_trump' && diff --git a/lib/feature/game/presentation/widgets/flame/badges.dart b/lib/feature/game/presentation/widgets/flame/badges.dart new file mode 100644 index 0000000..068a8fc --- /dev/null +++ b/lib/feature/game/presentation/widgets/flame/badges.dart @@ -0,0 +1,91 @@ +import 'dart:math' as math; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flutter/material.dart'; + +const _gold = Color(0xFFE9B949); +const _goldDark = Color(0xFF8A5A00); + +/// ستاره‌ی طلایی روی بازیکنِ نوبت‌دار (مطابقِ تصویرِ مرجع). +class StarBadge extends PositionComponent { + final double r; + StarBadge(this.r, Vector2 pos) + : super(position: pos, size: Vector2.all(r * 2), anchor: Anchor.center); + + static final _fill = Paint()..color = _gold; + static final _stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = _goldDark; + + @override + void render(Canvas canvas) { + final path = _starPath(Offset(r, r), r, r * 0.45, 5); + canvas.drawPath(path, _fill); + canvas.drawPath(path, _stroke); + } +} + +/// تاجِ طلایی روی حاکم. +class CrownBadge extends PositionComponent { + final double w; + CrownBadge(this.w, Vector2 pos) + : super( + position: pos, + size: Vector2(w, w * 0.8), + anchor: Anchor.center, + ); + + static final _fill = Paint()..color = _gold; + static final _stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = _goldDark; + + @override + void render(Canvas canvas) { + final h = size.y; + final p = Path() + ..moveTo(0, h) // پایین‌چپ + ..lineTo(0, h * 0.35) // نوکِ چپ + ..lineTo(w * 0.25, h * 0.62) // فرورفتگی + ..lineTo(w * 0.5, h * 0.18) // نوکِ میانی (بلندترین) + ..lineTo(w * 0.75, h * 0.62) + ..lineTo(w, h * 0.35) // نوکِ راست + ..lineTo(w, h) // پایین‌راست + ..close(); + canvas.drawPath(p, _fill); + canvas.drawPath(p, _stroke); + // نگین‌های نوکِ تاج + final gem = Paint()..color = const Color(0xFFB71C1C); + canvas.drawCircle(Offset(w * 0.5, h * 0.14), w * 0.05, gem); + } +} + +Path _starPath(Offset c, double outer, double inner, int points) { + final path = Path(); + for (var i = 0; i < points * 2; i++) { + final rad = i.isEven ? outer : inner; + final ang = -math.pi / 2 + i * math.pi / points; + final pt = Offset(c.dx + rad * math.cos(ang), c.dy + rad * math.sin(ang)); + i == 0 ? path.moveTo(pt.dx, pt.dy) : path.lineTo(pt.dx, pt.dy); + } + return path..close(); +} + +/// افکتِ ضربان (برای جلبِ توجه؛ مثلاً ستاره‌ی نوبت). +PulseEffect pulse() => PulseEffect(); + +class PulseEffect extends ScaleEffect { + PulseEffect() + : super.by( + Vector2.all(0.25), + EffectController( + duration: 0.5, + reverseDuration: 0.5, + infinite: true, + curve: Curves.easeInOut, + ), + ); +} diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index 360d818..6660c94 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -9,10 +9,12 @@ import 'package:flutter/material.dart'; import '../../../../../core/service/app_sounds.dart'; import '../../../domain/entities/game_entities.dart'; import '../../bloc/game_bloc.dart'; +import 'badges.dart'; import 'card_codes.dart'; import 'card_component.dart'; import 'shuffle_animation.dart'; import 'table_pieces.dart'; +import 'turn_timer.dart'; /// صحنه‌ی میز حکم با انیمیشن. شما همیشه پایین میز (rel=0) هستید. /// کامپوننت‌های دست و trick ماندگارند و با افکت حرکت جابه‌جا می‌شوند. @@ -46,6 +48,8 @@ class HokmGame extends FlameGame { bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف» bool _pendingShuffle = false; // بُرِ دستِ اول که تا پایانِ اینترو به تعویق افتاده + bool _shuffling = false; // در حالِ پخشِ انیمیشنِ بُر زدن (بازیِ کارت ممنوع) + String? _prevTrump; // برای تشخیصِ لحظه‌ی انتخابِ حکم HokmGame(this.cubit); @@ -69,6 +73,12 @@ class HokmGame extends FlameGame { _shuffleNow(); } } + // لحظه‌ی انتخابِ حکم: صدا + نمایشِ بزرگِ حکم در مرکز. + if (_prevTrump == null && s.trump != null && !_silent) { + AppSounds.selectHokm(); + _spawnTrumpReveal(s.trump!); + } + _prevTrump = s.trump; _prevHandSize = s.yourHand.length; _s = s; _relayout(); @@ -76,6 +86,7 @@ class HokmGame extends FlameGame { if (cubit.state.state != null) { _prevTrick = cubit.state.state!.trick.map((t) => t.card).toList(); _prevHandSize = cubit.state.state!.yourHand.length; + _prevTrump = cubit.state.state!.trump; _s = cubit.state.state; _relayout(); } @@ -138,10 +149,41 @@ class HokmGame extends FlameGame { } /// اجرای هم‌زمانِ انیمیشن و صدای بُر زدن (صدا با پایانِ انیمیشن قطع می‌شود). + /// در طولِ انیمیشن، بازیِ کارت قفل است تا کارتی وسطِ بُر زدن انداخته نشود. void _shuffleNow() { if (_silent) return; AppSounds.shuffle(cutoffMs: ShuffleAnimation.totalMs); add(ShuffleAnimation(size)); + _shuffling = true; + Future.delayed(const Duration(milliseconds: ShuffleAnimation.totalMs), () { + _shuffling = false; + if (_s != null && !_silent) _relayout(); // فعال‌سازیِ دوباره‌ی کارت‌ها + }); + } + + /// نمایشِ بزرگ و گذرای حکمِ انتخاب‌شده در مرکزِ میز (با پاپ). + void _spawnTrumpReveal(String trump) { + final (sym, col) = _trumpGlyph(trump); + final reveal = TextComponent( + text: 'حکم $sym', + anchor: Anchor.center, + position: size / 2 - Vector2(0, size.y * 0.10), + priority: 7000, + scale: Vector2.all(0.4), + textRenderer: TextPaint( + style: TextStyle( + fontFamily: 'Dana', + color: col, + fontSize: size.x * 0.11, + fontWeight: FontWeight.bold, + shadows: const [Shadow(color: Colors.black, blurRadius: 6)], + ), + ), + ); + reveal.add(ScaleEffect.to( + Vector2.all(1.0), EffectController(duration: 0.35, curve: Curves.easeOutBack))); + reveal.add(RemoveEffect(delay: 1.3)); + add(reveal); } static bool _isPrefix(List a, List b) { @@ -191,6 +233,8 @@ class HokmGame extends FlameGame { // آیا این کارت در نوبتِ فعلی قابل بازی است (با رعایت follow-suit)؟ bool _legal(String code) { final s = _s!; + // هنگامِ نمایشِ اینترو یا انیمیشنِ بُر زدن، بازیِ کارت مجاز نیست. + if (_introActive || _shuffling) return false; if (s.phase != 'playing' || s.trickDone || !s.isMyTurn) return false; final lead = s.leadSuit; if (lead == null || lead.isEmpty) return true; @@ -441,15 +485,33 @@ class HokmGame extends FlameGame { color: isTurn ? const Color(0xFFE9B949) : Colors.white70, bold: isTurn, ); + // تاجِ حاکم (روی همه‌ی فازها نشان داده می‌شود تا حاکم مشخص باشد). + if (s.hakem == p.seat) { + final crown = CrownBadge(size.x * 0.06, pos - Vector2(0, size.y * 0.066)) + ..priority = 23; + _info.add(crown); + add(crown); + } if (isTurn) { - final dot = CircleComponent( - radius: size.x * 0.012, - anchor: Anchor.center, - position: pos - Vector2(0, size.y * 0.03), - paint: Paint()..color = const Color(0xFFE9B949), - )..priority = 20; - _info.add(dot); - add(dot); + 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')) { + final timer = TurnTimer( + radius: size.x * 0.03, + durationSeconds: s.turnTimeoutMs / 1000.0, + position: tpos, + )..priority = 20; + _info.add(timer); + add(timer); + } + // ستاره‌ی نوبت (مطابقِ تصویرِ مرجع) با ضربان. + final star = StarBadge(size.x * 0.015, tpos)..priority = 24; + star.add(pulse()); + _info.add(star); + add(star); } } } diff --git a/lib/feature/game/presentation/widgets/flame/turn_timer.dart b/lib/feature/game/presentation/widgets/flame/turn_timer.dart new file mode 100644 index 0000000..fb45b59 --- /dev/null +++ b/lib/feature/game/presentation/widgets/flame/turn_timer.dart @@ -0,0 +1,56 @@ +import 'dart:math' as math; + +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; + +/// حلقه‌ی شمارشِ معکوسِ نوبت: کمانی که در طولِ مهلتِ نوبت خالی می‌شود. +/// رنگ از طلایی به قرمز می‌رود و در ثانیه‌های پایانی هشدار می‌دهد. +class TurnTimer extends PositionComponent { + final double radius; + final double durationSeconds; + double _elapsed = 0; + + TurnTimer({ + required this.radius, + required this.durationSeconds, + required Vector2 position, + }) : super( + position: position, + size: Vector2.all(radius * 2), + anchor: Anchor.center, + ); + + static final _bg = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..color = Colors.black54; + + @override + void update(double dt) { + _elapsed += dt; + } + + @override + void render(Canvas canvas) { + final center = Offset(radius, radius); + canvas.drawCircle(center, radius, _bg); + + 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 arc = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round + ..color = color; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, // شروع از بالا + 2 * math.pi * frac, + false, + arc, + ); + } +}