feat: add heart beat for timing

This commit is contained in:
2026-06-19 02:28:07 +03:30
parent a4cf2b5f39
commit c4c1453f7e
6 changed files with 476 additions and 246 deletions
@@ -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,