696 lines
29 KiB
Dart
696 lines
29 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flame/components.dart';
|
|
import 'package:flame/effects.dart';
|
|
import 'package:flame/game.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart' show HapticFeedback;
|
|
|
|
import '../../../../../core/service/app_sounds.dart';
|
|
import '../../../../../core/settings/app_settings.dart';
|
|
import '../../../../../core/theme/app_theme.dart';
|
|
import '../../../domain/entities/game_entities.dart';
|
|
import '../../bloc/game_bloc.dart';
|
|
import 'card_codes.dart';
|
|
import 'card_component.dart';
|
|
import 'shuffle_animation.dart';
|
|
import 'table_pieces.dart';
|
|
|
|
/// صحنهی میز حکم با انیمیشن. شما همیشه پایین میز (rel=0) هستید.
|
|
/// کامپوننتهای دست و trick ماندگارند و با افکت حرکت جابهجا میشوند.
|
|
///
|
|
/// این کلاس فقط «وضعیتِ سرور → چیدمانِ صحنه» را مدیریت میکند:
|
|
/// - [_layoutHand] / [_layoutTrick] کارتهای دست و زمین (ماندگار، با انیمیشن).
|
|
/// - [_rebuildBacksAndInfo] عناصرِ بازساختهشونده (پشتکارت، شمارندهها، برچسبها).
|
|
/// - [_checkCut] + [update]/[render] افکتِ «بریدن با حکم» (تکان + رعد).
|
|
class HokmGame extends FlameGame {
|
|
final GameBloc cubit;
|
|
|
|
/// اسکینِ کارت (از فروشگاه/کیفپول انتخاب میشود)؛ تصاویر از backend میآیند.
|
|
final String skin;
|
|
|
|
// وضعیت بازی و اشتراکِ stream.
|
|
GameState? _s;
|
|
StreamSubscription? _sub;
|
|
|
|
// کامپوننتهای ماندگار (با کدِ کارت کلید میخورند) و بازساختهشونده.
|
|
final Map<String, CardComponent> _hand = {};
|
|
final Map<String, CardComponent> _trick = {};
|
|
final List<Component> _backs = [];
|
|
final List<Component> _info = [];
|
|
|
|
// ابعادِ کارتهای روی میز (بر اساس عرض صفحه محاسبه میشود).
|
|
double _cardW = 60;
|
|
double _cardH = 87;
|
|
|
|
// افکتِ تکانِ صفحه هنگام بریدن با حکم.
|
|
double _shake = 0;
|
|
double _t = 0;
|
|
List<String> _prevTrick = const [];
|
|
bool _highlightedTrick = false; // آیا کارتِ برندهی دستِ کامل هایلایت شده (یکبار)
|
|
int _bestTrumpRank = 0; // بالاترین رتبهی حکمِ بازیشده در دستِ جاری (برای افکتِ بریدن)
|
|
int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن
|
|
String _prevHandSig = ''; // امضای دست؛ برای تشخیصِ بُرِ مجدد (تغییرِ ۵ کارت در فازِ حکم)
|
|
bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود
|
|
bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف»
|
|
bool _pendingShuffle =
|
|
false; // بُرِ دستِ اول که تا پایانِ اینترو به تعویق افتاده
|
|
bool _shuffling = false; // در حالِ بُر زدن/پخشِ ۵ کارت (بازیِ کارت ممنوع)
|
|
bool _dealing = false; // در حالِ پخشِ بقیهی کارتها پس از حکم (بازی ممنوع)
|
|
String? _prevTrump; // برای تشخیصِ لحظهی انتخابِ حکم
|
|
|
|
/// آیا انیمیشنی در حال اجراست (اینترو/بُر زدن/پخشِ کارت). صفحه از این برای
|
|
/// پنهانکردنِ دیالوگِ انتخابِ حکم تا پایانِ پخشِ کارتها استفاده میکند.
|
|
final ValueNotifier<bool> animating = ValueNotifier(true);
|
|
void _refreshAnimating() =>
|
|
animating.value = _introActive || _shuffling || _dealing;
|
|
|
|
// مدیریتِ ضربانِ قلب (فقط نوبتِ خودِ کاربر؛ مستقل از بازساختِ صحنه).
|
|
double _turnElapsed = 0;
|
|
bool _beatPlayed = false; // اولین ضربان در پنجرهی پایانیِ نوبت پخش شد
|
|
double _hbAccum = 0; // انباشتِ زمان برای تکرارِ منظمِ ضربان تا پایانِ نوبت
|
|
bool _wasMyTurn = false;
|
|
String _turnSig = ''; // امضای نوبت (نوبت|تعدادِ کارتِ زمین|فاز) — همگام با HUD
|
|
|
|
/// فرشِ انتخابی؛ 'classic' یعنی سطحِ میزِ ترسیمی. فرشِ تصویری بهعنوانِ سطحِ
|
|
/// میز (بریده در شکلِ میز) روی زمینِ لاجوردی کشیده میشود.
|
|
final String carpet;
|
|
|
|
HokmGame(this.cubit, {this.skin = 'simple', this.carpet = 'classic'});
|
|
|
|
@override
|
|
Color backgroundColor() => AppColors.lapisNight;
|
|
|
|
@override
|
|
Future<void> onLoad() async {
|
|
add(Felt(carpet: carpet));
|
|
if (!AppSettings.I.lowGraphics.value) {
|
|
add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کممصرف خاموش)
|
|
}
|
|
_sub = cubit.stream.listen((ui) {
|
|
if (ui.state == null) return;
|
|
final s = ui.state!;
|
|
_checkTrickSounds(s);
|
|
// شروعِ دستِ جدید (پخشِ کارت): انیمیشن و صدای بُر زدن.
|
|
// دستِ اول تا پایانِ نمایشِ «جستجوی حریف» به تعویق میافتد تا انیمیشن
|
|
// زیرِ اوورلی پنهان نشود و صدا با تصویر هماهنگ باشد.
|
|
if (_prevHandSize == 0 && s.yourHand.isNotEmpty && !_silent) {
|
|
if (_introActive) {
|
|
_pendingShuffle = true;
|
|
} else {
|
|
_shuffleNow();
|
|
}
|
|
}
|
|
// بُرِ مجدد: هنوز در فازِ حکم هستیم اما ۵ کارتِ حاکم عوض شده ⇒ انیمیشنِ بُر دوباره.
|
|
final sig = (s.yourHand.toList()..sort()).join(',');
|
|
if (!_silent &&
|
|
!_introActive &&
|
|
s.phase == 'choose_trump' &&
|
|
s.trump == null &&
|
|
_prevHandSize > 0 &&
|
|
_prevHandSig.isNotEmpty &&
|
|
sig != _prevHandSig) {
|
|
_shuffleNow();
|
|
}
|
|
_prevHandSig = sig;
|
|
// لحظهی انتخابِ حکم: صدا + نمایشِ بزرگِ حکم در مرکز.
|
|
if (_prevTrump == null && s.trump != null && !_silent) {
|
|
AppSounds.selectHokm();
|
|
_spawnTrumpReveal(s.trump!);
|
|
_dealAllNow(); // پخشِ بقیهی کارتها (بازی تا پایانِ آن قفل است)
|
|
}
|
|
_prevTrump = s.trump;
|
|
_prevHandSize = s.yourHand.length;
|
|
_s = s;
|
|
_relayout();
|
|
});
|
|
if (cubit.state.state != null) {
|
|
_prevTrick = cubit.state.state!.trick.map((t) => t.card).toList();
|
|
_prevHandSize = cubit.state.state!.yourHand.length;
|
|
_prevHandSig = (cubit.state.state!.yourHand.toList()..sort()).join(',');
|
|
_prevTrump = cubit.state.state!.trump;
|
|
_s = cubit.state.state;
|
|
_relayout();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onGameResize(Vector2 size) {
|
|
super.onGameResize(size);
|
|
if (isLoaded) _relayout();
|
|
}
|
|
|
|
@override
|
|
void onRemove() {
|
|
_sub?.cancel();
|
|
AppSounds.stopHeartBeat();
|
|
super.onRemove();
|
|
}
|
|
|
|
// ===== صداها و افکتِ بریدن با حکم (تکان + رعد) =====
|
|
|
|
// با هر تغییرِ زمین: افزودنِ کارت ⇒ صدای گذاشتن/انداختن (و بریدن با حکم ⇒
|
|
// شمشیر + تکان + رعد)؛ خالیشدنِ زمین ⇒ صدای جمعشدنِ دست.
|
|
void _checkTrickSounds(GameState s) {
|
|
final now = s.trick.map((t) => t.card).toList();
|
|
// در زمانِ اینترو/بُر زدن/پخش، کارتی روی میز نشان داده نمیشود ⇒ صدا هم نه.
|
|
if (_silent || _introActive || _shuffling || _dealing) {
|
|
_prevTrick = now;
|
|
return;
|
|
}
|
|
final addedOne =
|
|
now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now);
|
|
if (now.length <= 1) _bestTrumpRank = 0; // دستِ جدید ⇒ ریستِ بالاترین حکم
|
|
if (addedOne) {
|
|
final card = s.trick.last;
|
|
// بریدن: خالِ راهبر حکم نیست و کارتِ بازیشده حکم است.
|
|
final isTrump = s.trump != null &&
|
|
s.leadSuit != null &&
|
|
s.leadSuit != s.trump &&
|
|
suitName(card.card) == s.trump;
|
|
final rank = isTrump ? rankValue(card.card) : 0;
|
|
// افکتِ بریدن فقط وقتی حکمِ بازیشده «دست را میبرد» (بالاترین حکم تا کنون):
|
|
// یعنی اولین بریدن یا روبریدنِ بالاتر. حکمِ پایینتر (بازنده) فقط صدای گذاشتن.
|
|
if (isTrump && rank > _bestTrumpRank) {
|
|
_bestTrumpRank = rank;
|
|
_shake = 1.0;
|
|
add(Lightning());
|
|
if (!AppSettings.I.lowGraphics.value) {
|
|
add(CutBurst()); // فلاش + شوکویو + جرقه (کممصرف: خاموش)
|
|
}
|
|
AppSounds.cut(); // بریدن با حکم (شمشیر)
|
|
HapticFeedback.heavyImpact(); // ضربهی لمسیِ بُرِش با حکم
|
|
} else {
|
|
AppSounds.placeCard(); // کارت روی میز نشست
|
|
}
|
|
} else if (now.isEmpty && _prevTrick.isNotEmpty) {
|
|
AppSounds.takeCard(); // دستِ کامل جمع شد
|
|
}
|
|
_prevTrick = now;
|
|
}
|
|
|
|
/// قطعِ صداهای بازی هنگام خروج از میز (جلوگیری از پخشِ صدای کارت پس از ترک).
|
|
void silence() {
|
|
_silent = true;
|
|
AppSounds.stopHeartBeat();
|
|
}
|
|
|
|
/// پایانِ نمایشِ «جستجوی حریف»؛ اگر بُرِ دستِ اول به تعویق افتاده بود، اکنون
|
|
/// (که اوورلی کنار رفته) با صدا و تصویرِ هماهنگ اجرا میشود.
|
|
void endIntro() {
|
|
_introActive = false;
|
|
if (_pendingShuffle && !_silent) {
|
|
_pendingShuffle = false;
|
|
_shuffleNow();
|
|
}
|
|
_refreshAnimating();
|
|
}
|
|
|
|
/// اجرای همزمانِ انیمیشن و صدای بُر زدن (صدا با پایانِ انیمیشن قطع میشود).
|
|
/// در طولِ انیمیشن، بازیِ کارت قفل است تا کارتی وسطِ بُر زدن انداخته نشود.
|
|
void _shuffleNow() {
|
|
if (_silent) return;
|
|
AppSounds.shuffle(cutoffMs: ShuffleAnimation.totalMs);
|
|
add(ShuffleAnimation(size));
|
|
_shuffling = true;
|
|
_refreshAnimating();
|
|
Future.delayed(const Duration(milliseconds: ShuffleAnimation.totalMs), () {
|
|
_shuffling = false;
|
|
_refreshAnimating();
|
|
if (_s != null && !_silent) _relayout(); // فعالسازیِ دوبارهی کارتها
|
|
});
|
|
}
|
|
|
|
/// پخشِ بقیهی کارتها پس از انتخابِ حکم؛ تا پایانِ انیمیشن بازیِ کارت قفل است.
|
|
void _dealAllNow() {
|
|
if (_silent) return;
|
|
AppSounds.shuffle(cutoffMs: ShuffleAnimation.dealMs);
|
|
add(ShuffleAnimation(size, dealOnly: true));
|
|
_dealing = true;
|
|
_refreshAnimating();
|
|
Future.delayed(const Duration(milliseconds: ShuffleAnimation.dealMs), () {
|
|
_dealing = false;
|
|
_refreshAnimating();
|
|
if (_s != null && !_silent) _relayout();
|
|
});
|
|
}
|
|
|
|
/// نمایشِ بزرگ و گذرای حکمِ انتخابشده در مرکزِ میز (با پاپ).
|
|
void _spawnTrumpReveal(String trump) {
|
|
final (sym, col) = suitGlyph(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<String> a, List<String> b) {
|
|
for (var i = 0; i < a.length; i++) {
|
|
if (a[i] != b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
void update(double dt) {
|
|
super.update(dt);
|
|
_t += dt;
|
|
if (_shake > 0) _shake = math.max(0, _shake - dt * 2.2);
|
|
_updateHeartBeat(dt);
|
|
}
|
|
|
|
// ضربانِ قلب فقط در ثانیههای پایانیِ نوبتِ خودِ کاربر؛ هنگامِ پایانِ نوبت
|
|
// (بازیشدنِ کارت توسط کاربر یا سیستم) بلافاصله قطع میشود.
|
|
//
|
|
// زمانسنجِ نوبت باید دقیقاً همزمان با «حلقهی زمانِ نوبت» در HUD شروع شود؛
|
|
// آن حلقه با تغییرِ امضای نوبت (نوبت|تعدادِ کارتِ زمین|فاز) ریست میشود و به
|
|
// انیمیشنِ بُر/پخش کاری ندارد. پس اینجا هم شمارش را به همان امضا میبندیم و
|
|
// فقط «پخشِ صدا» را در حینِ انیمیشن/اینترو خاموش میکنیم تا صدا و تصویر یکی شوند.
|
|
void _updateHeartBeat(double dt) {
|
|
final s = _s;
|
|
final isMyTurn = s != null &&
|
|
((s.phase == 'playing' && s.turn == s.yourSeat) ||
|
|
(s.phase == 'choose_trump' && s.hakem == s.yourSeat));
|
|
|
|
if (!isMyTurn || _silent) {
|
|
if (_wasMyTurn) {
|
|
AppSounds.stopHeartBeat(); // نوبت تمام شد ⇒ توقفِ فوریِ صدا
|
|
_wasMyTurn = false;
|
|
}
|
|
_turnSig = s == null ? '' : '${s.turn}|${s.trick.length}|${s.phase}';
|
|
return;
|
|
}
|
|
|
|
// امضای نوبت — همانی که حلقهی HUD استفاده میکند.
|
|
final sig = '${s.turn}|${s.trick.length}|${s.phase}';
|
|
if (!_wasMyTurn || sig != _turnSig) {
|
|
// شروعِ نوبتِ من (یا نوبتِ جدید) ⇒ شمارش از صفر، همزمان با حلقهی HUD.
|
|
_wasMyTurn = true;
|
|
_turnSig = sig;
|
|
_turnElapsed = 0;
|
|
_beatPlayed = false;
|
|
_hbAccum = 0;
|
|
// صدای «نوبتِ شما» فقط هنگامِ بازیِ کارت و بیرون از انیمیشنها.
|
|
if (s.phase == 'playing' && !_introActive && !_shuffling && !_dealing) {
|
|
AppSounds.turn();
|
|
}
|
|
}
|
|
_turnElapsed += dt;
|
|
|
|
// در حینِ اینترو/بُر/پخش صدایی پخش نمیکنیم (اما شمارش ادامه دارد تا با
|
|
// حلقهی HUD همگام بماند). این انیمیشنها در ابتدای نوبتاند و با پنجرهی
|
|
// پایانیِ ضربان همپوشانی ندارند.
|
|
if (_introActive || _shuffling || _dealing) return;
|
|
|
|
final dur = (s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000.0 : 30.0);
|
|
final remaining = dur - _turnElapsed;
|
|
final warn = math.min(7.0, dur * 0.4);
|
|
// در پنجرهی پایانیِ نوبت، ضربان را با فاصلهی منظم تکرار کن تا تا لحظهی
|
|
// آخر شنیده شود (نه فقط یک ضربانِ زودهنگام که در سکوتِ پایانی گم میشود).
|
|
if (remaining > 0 && remaining <= warn) {
|
|
if (!_beatPlayed) {
|
|
_beatPlayed = true;
|
|
_hbAccum = 0;
|
|
AppSounds.heartBeat();
|
|
} else {
|
|
_hbAccum += dt;
|
|
if (_hbAccum >= 0.95) {
|
|
_hbAccum = 0;
|
|
AppSounds.heartBeat();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void render(Canvas canvas) {
|
|
if (_shake <= 0) {
|
|
super.render(canvas);
|
|
return;
|
|
}
|
|
final dx = math.sin(_t * 55) * _shake * size.x * 0.018;
|
|
final dy = math.cos(_t * 70) * _shake * size.y * 0.010;
|
|
canvas.save();
|
|
canvas.translate(dx, dy);
|
|
super.render(canvas);
|
|
canvas.restore();
|
|
}
|
|
|
|
// ===== چیدمان =====
|
|
|
|
// جایگاهِ نسبیِ یک seat نسبت به شما: 0=پایین، 1=چپ، 2=بالا، 3=راست.
|
|
int _rel(int seat) => (seat - _s!.yourSeat + 4) % 4;
|
|
|
|
void _relayout() {
|
|
final s = _s;
|
|
if (s == null) return;
|
|
_cardW = size.x * 0.22; // کارتهای روی میز بزرگتر (دیدِ بهتر)
|
|
_cardH = _cardW * kCardRatio;
|
|
|
|
_rebuildBacksAndInfo(s);
|
|
_layoutTrick(s);
|
|
_layoutHand(s);
|
|
}
|
|
|
|
// آیا این کارت در نوبتِ فعلی قابل بازی است (با رعایت follow-suit)؟
|
|
bool _legal(String code) {
|
|
final s = _s!;
|
|
// هنگامِ اینترو/بُر زدن/پخشِ کارتها، بازیِ کارت مجاز نیست.
|
|
if (_introActive || _shuffling || _dealing) return false;
|
|
if (s.phase != 'playing' || s.trickDone || !s.isMyTurn) return false;
|
|
final lead = s.leadSuit;
|
|
if (lead == null || lead.isEmpty) return true;
|
|
final hasLead = s.yourHand.any((c) => suitName(c) == lead);
|
|
return !hasLead || suitName(code) == lead;
|
|
}
|
|
|
|
// حرکتِ نرمِ یک کارت به مقصد، بدون انباشتهشدنِ افکتها.
|
|
void _moveTo(CardComponent c, Vector2 target,
|
|
{double dur = 0.38, bool bounce = false}) {
|
|
c.children.whereType<MoveToEffect>().toList().forEach(
|
|
(e) => e.removeFromParent(),
|
|
);
|
|
if ((c.position - target).length < 0.5) {
|
|
c.position = target;
|
|
return;
|
|
}
|
|
// کارتِ تازهنشسته روی میز با کمی overshoot مینشیند (حسِ «جا افتادن»).
|
|
c.add(
|
|
MoveToEffect(
|
|
target,
|
|
EffectController(
|
|
duration: dur, curve: bounce ? Curves.easeOutBack : Curves.easeOutCubic),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ناحیهی وسط میز که رهاکردنِ کارت در آن یعنی بازی (بالاتر از دستِ شما).
|
|
Rect _dropZone() =>
|
|
Rect.fromLTRB(size.x * 0.15, size.y * 0.22, size.x * 0.85, size.y * 0.66);
|
|
|
|
void _layoutHand(GameState s) {
|
|
// در زمانِ اینترو/بُر زدنِ ابتدای هَند، کارتهای دست پنهاناند و پس از پایانِ
|
|
// بُر زدن «پخش» میشوند (میآیند). در زمانِ پخشِ بقیهی کارتها مخفی نمیشوند.
|
|
if (_introActive || _shuffling) {
|
|
for (final c in _hand.values.toList()) {
|
|
c.removeFromParent();
|
|
}
|
|
_hand.clear();
|
|
return;
|
|
}
|
|
// مرتبسازی بر اساس خال و رتبه تا انتخاب برای بازیکن راحتتر باشد.
|
|
final codes = List<String>.from(s.yourHand)..sort(compareCards);
|
|
final n = codes.length;
|
|
|
|
// کارتهایی که دیگر در دست نیستند و به trick هم نرفتهاند ⇒ حذف.
|
|
for (final code in _hand.keys.toList()) {
|
|
if (!codes.contains(code)) {
|
|
final c = _hand.remove(code)!;
|
|
if (!_trick.containsKey(code)) c.removeFromParent();
|
|
}
|
|
}
|
|
|
|
// چیدمانِ بادبزنیِ واقعی: کارتها روی قوسِ یک دایره چیده و همراستا با شعاعِ آن
|
|
// چرخانده میشوند (کارتِ وسط بالا و صاف، کناریها پایینتر و چرخیده) — مثلِ دستِ
|
|
// واقعی. مرکزِ قوس پایینِ دست است. اندازهی دست کمی بزرگتر از کارتهای میز.
|
|
final hw = size.x * 0.24, hh = hw * kCardRatio;
|
|
final cx = size.x / 2;
|
|
final baseY = size.y * 0.86;
|
|
final tMax = (n - 1) / 2;
|
|
// زاویهی بینِ کارتها؛ با تعدادِ زیاد جمعتر میشود تا بادبزن بیشازحد باز نشود.
|
|
final perCard = n > 1 ? (0.9 / (n - 1)).clamp(0.0, 0.11) : 0.0;
|
|
final maxA = perCard * tMax;
|
|
// شعاعِ قوس از عرضِ گسترشِ دست مشتق میشود تا فاصلهها متوازن بماند.
|
|
final spreadX = size.x * 0.86 - hw;
|
|
final radius = maxA > 0.01 ? (spreadX / 2) / math.sin(maxA) : size.y;
|
|
final pivotY = baseY + radius;
|
|
|
|
for (var i = 0; i < n; i++) {
|
|
final code = codes[i];
|
|
final a = (i - tMax) * perCard; // زاویهی این کارت روی قوس
|
|
final target = Vector2(
|
|
cx + radius * math.sin(a),
|
|
pivotY - radius * math.cos(a),
|
|
);
|
|
final legal = _legal(code);
|
|
var c = _hand[code];
|
|
final isNew = c == null;
|
|
if (c == null) {
|
|
// کارت جدید ⇒ پشترو از مرکز میز (دسته) پخش میشود و سپس رو میگردد.
|
|
c = CardComponent(
|
|
code: code,
|
|
faceUp: false,
|
|
skin: skin,
|
|
size: Vector2(hw, hh),
|
|
position: size / 2,
|
|
);
|
|
_hand[code] = c;
|
|
add(c);
|
|
} else {
|
|
c.size = Vector2(hw, hh);
|
|
}
|
|
c.angle = a; // چرخشِ کارت همراستا با شعاعِ قوس
|
|
c.onPlay = legal ? () => cubit.playCard(code) : null;
|
|
c.dimmed = s.phase == 'playing' && s.isMyTurn && !legal;
|
|
c.home = target;
|
|
c.dropZone = _dropZone();
|
|
c.restPriority = 10 + i;
|
|
c.priority = 10 + i;
|
|
_moveTo(c, target);
|
|
if (isNew) c.flipToFront(delay: 0.28 + i * 0.04); // پس از رسیدن، رو شود
|
|
}
|
|
}
|
|
|
|
void _layoutTrick(GameState s) {
|
|
// در زمانِ اینترو/بُر زدن/پخش، کارتهای روی میز پنهان میمانند تا با انیمیشن
|
|
// تداخل نکنند (پس از پایانِ انیمیشن دوباره چیده میشوند).
|
|
if (_introActive || _shuffling || _dealing) {
|
|
for (final c in _trick.values.toList()) {
|
|
c.removeFromParent();
|
|
}
|
|
_trick.clear();
|
|
return;
|
|
}
|
|
final present = s.trick.map((t) => t.card).toSet();
|
|
|
|
// کارتهای trick که دیگر نیستند (دست جمع شد) ⇒ برنده کارتها را «جمع میکند»:
|
|
// به سمتِ جایگاهِ برنده (s.turn) پرواز میکنند، کوچک میشوند و محو (scoop).
|
|
final winnerOrigin = _seatOrigin(_rel(s.turn));
|
|
var i = 0;
|
|
for (final code in _trick.keys.toList()) {
|
|
if (present.contains(code)) continue;
|
|
final c = _trick.remove(code)!;
|
|
c.highlight = false;
|
|
c.priority = 30; // رویِ همه هنگام جمعشدن
|
|
c.children.whereType<Effect>().toList().forEach((e) => e.removeFromParent());
|
|
final delay = i * 0.05; // ترتیبِ کوتاه برای حسِ «جارو کردن»
|
|
c.add(ScaleEffect.to(
|
|
Vector2.all(0.5),
|
|
EffectController(duration: 0.34, startDelay: delay, curve: Curves.easeIn),
|
|
));
|
|
c.add(SequenceEffect([
|
|
MoveToEffect(
|
|
winnerOrigin,
|
|
EffectController(
|
|
duration: 0.36, startDelay: delay, curve: Curves.easeInBack),
|
|
),
|
|
RemoveEffect(),
|
|
]));
|
|
i++;
|
|
}
|
|
|
|
for (final tc in s.trick) {
|
|
final slot = size / 2 + _trickOffset(_rel(tc.seat));
|
|
var c = _trick[tc.card];
|
|
final isNew = c == null; // فقط کارتِ تازهنشسته bounce میشود
|
|
if (c == null) {
|
|
// اگر خودِ شما بازی کردید، همان کارتِ دست را منتقل کن (پرواز به وسط).
|
|
c = _hand.remove(tc.card);
|
|
if (c != null) {
|
|
c.onPlay = null;
|
|
c.dimmed = false;
|
|
c.home = null;
|
|
c.resetScale(); // کارتِ بازیشده هماندازهی بقیهی کارتهای میز
|
|
} else {
|
|
c = CardComponent(
|
|
code: tc.card,
|
|
faceUp: true,
|
|
skin: skin,
|
|
size: Vector2(_cardW, _cardH),
|
|
position: _seatOrigin(_rel(tc.seat)),
|
|
);
|
|
add(c);
|
|
}
|
|
_trick[tc.card] = c;
|
|
}
|
|
c.size = Vector2(_cardW, _cardH);
|
|
c.angle = 0; // کارتهای روی زمین صافاند (چرخشِ بادبزنیِ دست حذف میشود)
|
|
c.priority = 5;
|
|
_moveTo(c, slot, bounce: isNew);
|
|
}
|
|
|
|
// دستِ کامل (۴ کارت) ⇒ کارتِ برنده را طلایی هایلایت کن و یکبار «پاپ» بزن،
|
|
// تا کاربر پیش از جمعشدنِ کارتها بفهمد چه کسی برد.
|
|
if (s.trickDone && s.trick.length == 4) {
|
|
final ws = _winnerSeat(s.trick, s.trump, s.leadSuit);
|
|
for (final tc in s.trick) {
|
|
final comp = _trick[tc.card];
|
|
if (comp == null) continue;
|
|
final win = tc.seat == ws;
|
|
comp.highlight = win;
|
|
if (win && !_highlightedTrick) comp.pulse();
|
|
}
|
|
_highlightedTrick = true;
|
|
} else if (!s.trickDone) {
|
|
_highlightedTrick = false;
|
|
for (final c in _trick.values) {
|
|
c.highlight = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// برندهی دستِ جاری را از روی کارتها + حکم + خالِ زمینه حساب میکند (سمتِ کلاینت).
|
|
int _winnerSeat(List<TrickCard> trick, String? trump, String? lead) {
|
|
var best = trick.first;
|
|
for (final tc in trick.skip(1)) {
|
|
if (_beats(tc.card, best.card, trump, lead)) best = tc;
|
|
}
|
|
return best.seat;
|
|
}
|
|
|
|
bool _beats(String a, String b, String? trump, String? lead) {
|
|
final aS = suitName(a), bS = suitName(b);
|
|
final aT = aS == trump, bT = bS == trump;
|
|
if (aT && !bT) return true;
|
|
if (!aT && bT) return false;
|
|
if (aT && bT) return rankValue(a) > rankValue(b);
|
|
if (aS != lead) return false; // فقط کارتِ همخالِ زمینه میتواند ببرد
|
|
if (bS != lead) return true;
|
|
return rankValue(a) > rankValue(b);
|
|
}
|
|
|
|
// مبدأِ نشستنِ هر جایگاه (برای پرتاب/جمعِ کارتها).
|
|
Vector2 _seatOrigin(int rel) {
|
|
final c = size / 2;
|
|
switch (rel) {
|
|
case 1:
|
|
return Vector2(size.x * 0.10, c.y);
|
|
case 2:
|
|
return Vector2(c.x, size.y * 0.14);
|
|
case 3:
|
|
return Vector2(size.x * 0.90, c.y);
|
|
default:
|
|
return Vector2(c.x, size.y * 0.82);
|
|
}
|
|
}
|
|
|
|
// جابهجاییِ کارتِ هر جایگاه از مرکز، در ناحیهی trick.
|
|
Vector2 _trickOffset(int rel) {
|
|
final d = _cardW * 0.62;
|
|
switch (rel) {
|
|
case 1:
|
|
return Vector2(-d, 0);
|
|
case 2:
|
|
return Vector2(0, -d);
|
|
case 3:
|
|
return Vector2(d, 0);
|
|
default:
|
|
return Vector2(0, d);
|
|
}
|
|
}
|
|
|
|
// ===== عناصرِ بازساختهشونده در هر بهروزرسانی =====
|
|
|
|
void _rebuildBacksAndInfo(GameState s) {
|
|
for (final c in _backs) {
|
|
c.removeFromParent();
|
|
}
|
|
for (final c in _info) {
|
|
c.removeFromParent();
|
|
}
|
|
_backs.clear();
|
|
_info.clear();
|
|
|
|
// پشتِ کارتِ حریفان در زمانِ بُر زدنِ ابتدای هَند پنهان است (انیمیشنِ بُر
|
|
// خودش کارتها را پخش میکند)؛ پس از آن نمایش داده میشود.
|
|
final hideBacks = _introActive || _shuffling;
|
|
for (var seat = 0; seat < 4 && !hideBacks; seat++) {
|
|
if (seat == s.yourSeat) continue;
|
|
final count = seat < s.handCounts.length ? s.handCounts[seat] : 0;
|
|
_addBacks(_rel(seat), count);
|
|
}
|
|
// امتیاز/نام/نشان/تاج/شمارندهی نوبت و دستهای برده (tricks_won) اکنون در
|
|
// اوورلیِ Flutter (TableHud) نمایش داده میشوند تا واضحتر و کمشلوغتر باشند.
|
|
}
|
|
|
|
// پشتکارتهای یک حریف بهصورت بادبزنی.
|
|
void _addBacks(int rel, int count) {
|
|
if (count <= 0) return;
|
|
final n = count.clamp(1, 13);
|
|
final w = _cardW * 0.7, h = _cardH * 0.7;
|
|
final c = size / 2;
|
|
Vector2 base, stepV;
|
|
double angle = 0; // کارتهای کناری ۹۰° میچرخند تا «بهسمتِ» بازیکنِ کنار باشند
|
|
switch (rel) {
|
|
case 2:
|
|
base = Vector2(c.x, size.y * 0.12);
|
|
stepV = Vector2(size.x * 0.45 / 13, 0);
|
|
break;
|
|
case 1:
|
|
// مرکزِ کارت روی لبهی چپ ⇒ فقط نیمهی داخلیِ کارت دیده میشود (شلوغیِ کمتر).
|
|
base = Vector2(0, c.y);
|
|
stepV = Vector2(0, size.y * 0.36 / 13);
|
|
angle = math.pi / 2;
|
|
break;
|
|
default:
|
|
base = Vector2(size.x, c.y);
|
|
stepV = Vector2(0, size.y * 0.36 / 13);
|
|
angle = -math.pi / 2;
|
|
}
|
|
final start = base - stepV * ((n - 1) / 2);
|
|
for (var i = 0; i < n; i++) {
|
|
_addBack(start + stepV * i.toDouble(), Vector2(w, h),
|
|
priority: 1, angle: angle);
|
|
}
|
|
}
|
|
|
|
// ===== کمکیهای ساختِ عنصر (ثبت در فهرستِ بازساختهشونده) =====
|
|
|
|
void _addBack(Vector2 pos, Vector2 sz,
|
|
{required int priority, double angle = 0}) {
|
|
final c = CardComponent(
|
|
code: '',
|
|
faceUp: false,
|
|
skin: skin,
|
|
size: sz,
|
|
position: pos,
|
|
angle: angle,
|
|
priority: priority,
|
|
);
|
|
_backs.add(c);
|
|
add(c);
|
|
}
|
|
|
|
}
|