feat: better ux

This commit is contained in:
2026-06-15 17:44:39 +03:30
parent 068930c7bd
commit 323031adbe
2 changed files with 135 additions and 51 deletions
+78 -5
View File
@@ -1,4 +1,5 @@
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
@@ -7,17 +8,23 @@ const double kCardRatio = 726 / 500;
/// یک کارت روی میز؛ اگر تصویر `assets/images/cards/<code>.png` موجود باشد از آن
/// استفاده می‌کند، وگرنه نسخه‌ی برداری می‌کشد. برای پشت کارت `back.jpg`.
class CardComponent extends PositionComponent with TapCallbacks, HasGameReference {
/// کارت‌های قابل‌بازی هم با tap و هم با کشیدن (drag) به سمت زمین بازی می‌شوند.
class CardComponent extends PositionComponent
with TapCallbacks, DragCallbacks, HasGameReference {
final String code; // مثل "AS"؛ برای پشت کارت خالی
final bool faceUp;
VoidCallback? onTap; // با تغییر نوبت/مجازبودن به‌روز می‌شود
VoidCallback? onPlay; // در صورت مجاز بودن، بازیِ این کارت
bool dimmed; // کارت غیرمجاز/غیرفعال
Vector2? home; // موقعیت اصلی در دست (برای برگشت پس از کشیدنِ ناقص)
Rect? dropZone; // ناحیه‌ی وسط میز؛ رهاکردن کارت در آن یعنی بازی
Sprite? _sprite;
bool _dragging = false;
bool _overZone = false; // کارت روی ناحیه‌ی انداختن است (برای هایلایت)
CardComponent({
required this.code,
required this.faceUp,
this.onTap,
this.onPlay,
this.dimmed = false,
super.position,
super.size,
@@ -26,6 +33,8 @@ class CardComponent extends PositionComponent with TapCallbacks, HasGameReferenc
super.anchor = Anchor.center,
});
bool get _playable => onPlay != null && !dimmed;
@override
Future<void> onLoad() async {
try {
@@ -36,8 +45,60 @@ class CardComponent extends PositionComponent with TapCallbacks, HasGameReferenc
}
@override
void onTapDown(TapDownEvent event) {
if (onTap != null && !dimmed) onTap!.call();
void onTapUp(TapUpEvent event) {
if (_playable) onPlay!.call();
}
@override
void onDragStart(DragStartEvent event) {
super.onDragStart(event);
if (!_playable) return;
_dragging = true;
priority = 1000; // روی همه‌ی کارت‌ها
scale = Vector2.all(1.12); // بزرگ‌نمایی هنگام کشیدن (فیدبک)
}
@override
void onDragUpdate(DragUpdateEvent event) {
if (!_dragging) return;
position += event.localDelta;
_overZone = _inDropZone();
}
@override
void onDragEnd(DragEndEvent event) {
super.onDragEnd(event);
if (!_dragging) return;
_dragging = false;
scale = Vector2.all(1);
// فقط اگر داخل ناحیه‌ی وسط میز رها شد ⇒ بازی؛ وگرنه برگشت به جای مرتبِ خود.
if (_inDropZone()) {
onPlay?.call();
} else {
_returnHome();
}
_overZone = false;
}
@override
void onDragCancel(DragCancelEvent event) {
super.onDragCancel(event);
if (!_dragging) return;
_dragging = false;
_overZone = false;
scale = Vector2.all(1);
_returnHome();
}
bool _inDropZone() {
final z = dropZone;
if (z != null) return z.contains(position.toOffset());
return position.y < game.size.y * 0.72; // fallback
}
void _returnHome() {
if (home == null) return;
add(MoveToEffect(home!, EffectController(duration: 0.2, curve: Curves.easeOut)));
}
@override
@@ -67,6 +128,18 @@ class CardComponent extends PositionComponent with TapCallbacks, HasGameReferenc
..strokeWidth = 1.5
..color = const Color(0xFF1A1A1A),
);
// هایلایت طلایی وقتی کارت روی ناحیه‌ی انداختن است (رهاکنی، بازی می‌شود).
if (_dragging && _overZone) {
canvas.drawRRect(
rrect,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.x * 0.06
..color = const Color(0xFFE9B949)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
);
}
}
void _drawVectorBack(Canvas canvas, RRect rrect) {
+57 -46
View File
@@ -25,9 +25,9 @@ class HokmGame extends FlameGame {
double _cardW = 60;
double _cardH = 87;
double _shake = 0; // شدت تکانِ صفحه (افکت برد با حکم)
double _shake = 0; // شدت تکانِ صفحه (افکت بریدن با حکم)
double _t = 0; // زمان برای نوسان تکان
bool _prevTrickDone = false;
List<String> _prevTrick = const []; // کدِ کارت‌های دست جاری (برای تشخیص کارت تازه‌بازی‌شده)
HokmGame(this.cubit);
@@ -40,18 +40,44 @@ class HokmGame extends FlameGame {
_sub = cubit.stream.listen((ui) {
if (ui.state != null) {
final s = ui.state!;
if (s.trickDone && !_prevTrickDone) _onTrickComplete(s);
_prevTrickDone = s.trickDone;
_checkCut(s);
_s = s;
_relayout();
}
});
if (cubit.state.state != null) {
_prevTrick = cubit.state.state!.trick.map((t) => t.card).toList();
_s = cubit.state.state;
_relayout();
}
}
// اگر دقیقاً یک کارت تازه به زمین اضافه شده و آن کارت «بُرِش با حکم» باشد
// (خالِ زمینه آتو نیست ولی کارتِ تازه آتوست)، همان لحظه تکان + رعد.
void _checkCut(GameState s) {
final now = s.trick.map((t) => t.card).toList();
final added = now.length == _prevTrick.length + 1 &&
_isPrefix(_prevTrick, now);
if (added) {
final last = now.last;
if (s.trump != null &&
s.leadSuit != null &&
s.leadSuit != s.trump &&
_suitName(last) == s.trump) {
_shake = 1.0;
add(_Lightning());
}
}
_prevTrick = now;
}
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);
@@ -73,37 +99,6 @@ class HokmGame extends FlameGame {
}
}
// هنگام کامل‌شدن یک دست: اگر برنده «بریده» باشد (خالِ زمینه آتو نبوده ولی
// کارتِ برنده آتوست — یعنی بازیکن چون خال زمینه را نداشت آتو زد) تکان + رعد.
void _onTrickComplete(GameState s) {
if (s.trick.length < 4) return;
final winner = _winnerCard(s);
final cutWithTrump = s.trump != null &&
s.leadSuit != s.trump &&
_suitName(winner.card) == s.trump;
_shake = cutWithTrump ? 1.0 : 0.3;
if (cutWithTrump) add(_Lightning());
}
TrickCard _winnerCard(GameState s) {
var best = s.trick.first;
for (final tc in s.trick.skip(1)) {
if (_beats(tc.card, best.card, s.trump, s.leadSuit)) best = tc;
}
return best;
}
// آیا کارت a کارت b را می‌برد (با توجه به حکم و خال زمینه)؟ مطابق منطق سرور.
bool _beats(String a, String b, String? trump, String? lead) {
final aT = _suitName(a) == trump, bT = _suitName(b) == trump;
if (aT && !bT) return true;
if (!aT && bT) return false;
if (aT && bT) return _rankValue(a) > _rankValue(b);
if (_suitName(a) != lead) return false;
if (_suitName(b) != lead) return true;
return _rankValue(a) > _rankValue(b);
}
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
@@ -187,6 +182,14 @@ class HokmGame extends FlameGame {
return _rankValue(a) - _rankValue(b);
}
// ناحیه‌ی وسط میز که رهاکردن کارت در آن یعنی بازی (بالاتر از دستِ شما).
Rect _dropZone() => Rect.fromLTRB(
size.x * 0.15,
size.y * 0.22,
size.x * 0.85,
size.y * 0.66,
);
void _layoutHand(GameState s) {
// کارت‌های دست بر اساس خال و سپس رتبه مرتب می‌شوند تا کار بازیکن راحت‌تر باشد.
final codes = List<String>.from(s.yourHand)..sort(_cardCompare);
@@ -200,10 +203,13 @@ class HokmGame extends FlameGame {
}
}
final handW = size.x * 0.72;
final step = n > 1 ? (handW - _cardW) / (n - 1) : 0.0;
// کارت‌های دستِ خودتان بزرگ‌تر از کارت‌های روی میز.
final hw = size.x * 0.225;
final hh = hw * kCardRatio;
final handW = size.x * 0.80;
final step = n > 1 ? (handW - hw) / (n - 1) : 0.0;
final startX = size.x / 2 - (step * (n - 1)) / 2;
final y = size.y * 0.84;
final y = size.y * 0.85;
for (var i = 0; i < n; i++) {
final code = codes[i];
@@ -216,16 +222,18 @@ class HokmGame extends FlameGame {
c = CardComponent(
code: code,
faceUp: true,
size: Vector2(_cardW, _cardH),
size: Vector2(hw, hh),
position: size / 2,
);
_hand[code] = c;
add(c);
} else {
c.size = Vector2(_cardW, _cardH);
c.size = Vector2(hw, hh);
}
c.onTap = legal ? () => cubit.playCard(code) : null;
c.onPlay = legal ? () => cubit.playCard(code) : null;
c.dimmed = dim;
c.home = target;
c.dropZone = _dropZone();
c.priority = 10 + i;
_moveTo(c, target);
}
@@ -255,8 +263,9 @@ class HokmGame extends FlameGame {
// اگر خودِ شما بازی کردید، همان کارتِ دست را منتقل کن (پرواز به وسط).
c = _hand.remove(tc.card);
if (c != null) {
c.onTap = null;
c.onPlay = null;
c.dimmed = false;
c.home = null;
} else {
c = CardComponent(
code: tc.card,
@@ -333,9 +342,10 @@ class HokmGame extends FlameGame {
if (won <= 0) continue;
final mine = team == s.yourSeat % 2;
final w = _cardW * 0.40, h = w * kCardRatio;
// تیم شما جلوی شما (پایین)، تیم حریف جلوی حریفِ سمت راست.
final base = mine
? Vector2(size.x * 0.30, size.y * 0.60)
: Vector2(size.x * 0.70, size.y * 0.40);
? 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);
@@ -377,9 +387,10 @@ 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 / 2, size.y * 0.72) // تیم شما: پایینِ مرکز
: Vector2(size.x / 2, size.y * 0.28); // تیم حریف: بالای مرکز
? Vector2(size.x * 0.50, size.y * 0.74)
: Vector2(size.x * 0.84, size.y * 0.44);
final puck = _Puck(score, radius: radius, position: pos)..priority = 22;
_info.add(puck);
add(puck);