feat: add heart beat for timing
This commit is contained in:
@@ -42,13 +42,28 @@ class CardComponent extends PositionComponent
|
||||
}
|
||||
}
|
||||
|
||||
// بزرگنماییِ کارتِ انتخابشده هنگام کشیدن (واضحتر برای دیدن).
|
||||
static const _dragScale = 1.4;
|
||||
|
||||
void _scaleTo(double s) {
|
||||
children.whereType<ScaleEffect>().toList().forEach((e) => e.removeFromParent());
|
||||
add(ScaleEffect.to(
|
||||
Vector2.all(s), EffectController(duration: 0.12, curve: Curves.easeOut)));
|
||||
}
|
||||
|
||||
/// بازنشانیِ فوریِ مقیاس به اندازهی عادی (هنگام بازی روی میز).
|
||||
void resetScale() {
|
||||
children.whereType<ScaleEffect>().toList().forEach((e) => e.removeFromParent());
|
||||
scale = Vector2.all(1);
|
||||
}
|
||||
|
||||
@override
|
||||
void onDragStart(DragStartEvent event) {
|
||||
super.onDragStart(event);
|
||||
if (!_playable) return;
|
||||
_dragging = true;
|
||||
priority = 1000; // روی همهی کارتها
|
||||
scale = Vector2.all(1.12); // بزرگنمایی هنگام کشیدن (فیدبک)
|
||||
_scaleTo(_dragScale); // بزرگنماییِ نرم هنگام انتخاب
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -63,11 +78,13 @@ class CardComponent extends PositionComponent
|
||||
super.onDragEnd(event);
|
||||
if (!_dragging) return;
|
||||
_dragging = false;
|
||||
scale = Vector2.all(1);
|
||||
// فقط اگر داخل ناحیهی وسط میز رها شد ⇒ بازی؛ وگرنه برگشت به جای مرتبِ خود.
|
||||
// فقط اگر داخل ناحیهی وسط میز رها شد ⇒ بازی (اندازه به حالت عادی)؛
|
||||
// وگرنه برگشت به جای مرتبِ خود.
|
||||
if (_inDropZone()) {
|
||||
resetScale();
|
||||
onPlay?.call();
|
||||
} else {
|
||||
_scaleTo(1);
|
||||
_returnHome();
|
||||
}
|
||||
_overZone = false;
|
||||
@@ -79,7 +96,7 @@ class CardComponent extends PositionComponent
|
||||
if (!_dragging) return;
|
||||
_dragging = false;
|
||||
_overZone = false;
|
||||
scale = Vector2.all(1);
|
||||
_scaleTo(1);
|
||||
_returnHome();
|
||||
}
|
||||
|
||||
@@ -147,15 +164,15 @@ class CardComponent extends PositionComponent
|
||||
..color = const Color(0xFF1A1A1A),
|
||||
);
|
||||
|
||||
// هایلایت طلایی وقتی کارت روی ناحیهی انداختن است (رهاکنی، بازی میشود).
|
||||
if (_dragging && _overZone) {
|
||||
// هایلایت طلایی: هنگام کشیدن همیشه (انتخاب)، و پررنگتر روی ناحیهی انداختن.
|
||||
if (_dragging) {
|
||||
canvas.drawRRect(
|
||||
rrect,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.x * 0.06
|
||||
..strokeWidth = size.x * (_overZone ? 0.06 : 0.035)
|
||||
..color = const Color(0xFFE9B949)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, _overZone ? 6 : 3),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,8 @@ import 'package:flame/components.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// حلقهی شمارشِ معکوسِ نوبت: کمانی که در طولِ مهلتِ نوبت خالی میشود.
|
||||
/// رنگ از طلایی به قرمز میرود و در ثانیههای پایانی هشدار میدهد.
|
||||
/// در ثانیههای پایانی قرمز و ضرباندار میشود. (صدای ضربانِ قلب در HokmGame
|
||||
/// مدیریت میشود تا هنگامِ بازیشدنِ کارت بلافاصله قطع شود.)
|
||||
class TurnTimer extends PositionComponent {
|
||||
final double radius;
|
||||
final double durationSeconds;
|
||||
@@ -20,6 +21,8 @@ class TurnTimer extends PositionComponent {
|
||||
anchor: Anchor.center,
|
||||
);
|
||||
|
||||
double get _warnSecs => math.min(8.0, durationSeconds * 0.4);
|
||||
|
||||
static final _bg = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3
|
||||
@@ -28,6 +31,13 @@ class TurnTimer extends PositionComponent {
|
||||
@override
|
||||
void update(double dt) {
|
||||
_elapsed += dt;
|
||||
final remaining = durationSeconds - _elapsed;
|
||||
// ضربانِ تصویری در پنجرهی هشدار.
|
||||
if (remaining > 0 && remaining <= _warnSecs) {
|
||||
scale = Vector2.all(1 + 0.18 * math.sin(_elapsed * 2 * math.pi / 0.7).abs());
|
||||
} else {
|
||||
scale = Vector2.all(1);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -35,11 +45,12 @@ class TurnTimer extends PositionComponent {
|
||||
final center = Offset(radius, radius);
|
||||
canvas.drawCircle(center, radius, _bg);
|
||||
|
||||
final frac =
|
||||
durationSeconds <= 0 ? 0.0 : (1 - _elapsed / durationSeconds).clamp(0.0, 1.0);
|
||||
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 color = frac > 0.35 ? const Color(0xFFE9B949) : const Color(0xFFE53935);
|
||||
final arc = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 4
|
||||
@@ -47,7 +58,7 @@ class TurnTimer extends PositionComponent {
|
||||
..color = color;
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-math.pi / 2, // شروع از بالا
|
||||
-math.pi / 2,
|
||||
2 * math.pi * frac,
|
||||
false,
|
||||
arc,
|
||||
|
||||
Reference in New Issue
Block a user