feat: won trick ui

This commit is contained in:
2026-06-24 10:47:17 +03:30
parent 08937a66cb
commit e0d6763c00
3 changed files with 279 additions and 205 deletions
@@ -106,12 +106,13 @@ class _GameScreenState extends State<GameScreen> {
s: state.state!,
connection: state.connection,
onExit: () => _confirmLeave(context),
onChat: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('گفتگو به‌زودی'),
duration: Duration(seconds: 1),
),
),
onChat:
() => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('گفتگو به‌زودی'),
duration: Duration(seconds: 1),
),
),
),
if (state.connection == WsStatus.disconnected) _connBanner(),
if (_showSearch(state)) _searchPanel(state),
@@ -122,10 +123,12 @@ class _GameScreenState extends State<GameScreen> {
valueListenable: _game.animating,
builder: (context, animating, _) {
if (animating) return const SizedBox.shrink();
return Stack(children: [
if (_showTrumpPicker(state)) _trumpPicker(context),
if (_isMyPlayTurn(state)) _turnBanner(),
]);
return Stack(
children: [
if (_showTrumpPicker(state)) _trumpPicker(context),
if (_isMyPlayTurn(state)) _turnBanner(),
],
);
},
),
if (state.handResult != null && state.gameOver == null)
@@ -149,7 +152,7 @@ class _GameScreenState extends State<GameScreen> {
// بنرِ «نوبت شماست» وقتی کاربر باید کارت بیندازد.
Widget _turnBanner() => Align(
alignment: const Alignment(0, 0.80),
alignment: const Alignment(0, 0.3),
child: IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
@@ -63,7 +63,8 @@ class HokmGame extends FlameGame {
// مدیریتِ ضربانِ قلب (فقط نوبتِ خودِ کاربر؛ مستقل از بازساختِ صحنه).
double _turnElapsed = 0;
bool _beatPlayed = false; // یک‌بار در هر نوبت، هنگام رسیدن به آستانه
bool _beatPlayed = false; // اولین ضربان در پنجره‌ی پایانیِ نوبت پخش شد
double _hbAccum = 0; // انباشتِ زمان برای تکرارِ منظمِ ضربان تا پایانِ نوبت
bool _wasMyTurn = false;
HokmGame(this.cubit, {this.skin = 'simple'});
@@ -271,16 +272,27 @@ class HokmGame extends FlameGame {
_wasMyTurn = true;
_turnElapsed = 0;
_beatPlayed = false;
_hbAccum = 0;
}
_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();
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();
}
}
}
}
@@ -524,32 +536,8 @@ class HokmGame extends FlameGame {
final count = seat < s.handCounts.length ? s.handCounts[seat] : 0;
_addBacks(_rel(seat), count);
}
_addTricksWon(s);
// امتیاز/نام/نشان/تاج/شمارنده‌ی نوبت اکنون در اوورلیِ Flutter (TableHud) کشیده
// می‌شوند تا متن واضح‌تر و چیدمان نزدیک به تصویرِ مرجع باشد.
}
// شمارنده ۱: دست‌های برده‌ی این هَند (tricks_won) — دسته‌کارتِ پشت‌رو + عدد.
// رسیدن به ۷ یعنی پایان هَند (سرور صفر می‌کند).
void _addTricksWon(GameState s) {
for (var team = 0; team < 2; team++) {
final won = team < s.tricksWon.length ? s.tricksWon[team] : 0;
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.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);
for (var i = 0; i < n; i++) {
_addBack(start + step * i.toDouble(), Vector2(w, h), priority: 2);
}
_addLabel('$won', base - Vector2(0, h * 0.7), _cardW * 0.34, bold: true);
}
// امتیاز/نام/نشان/تاج/شمارنده‌ی نوبت و دست‌های برده (tricks_won) اکنون در
// اوورلیِ Flutter (TableHud) نمایش داده می‌شوند تا واضح‌تر و کم‌شلوغ‌تر باشند.
}
// پشت‌کارت‌های یک حریف به‌صورت بادبزنی.
@@ -559,67 +547,47 @@ class HokmGame extends FlameGame {
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(size.x * 0.07, c.y);
stepV = Vector2(0, size.y * 0.4 / 13);
// مرکزِ کارت روی لبه‌ی چپ ⇒ فقط نیمه‌ی داخلیِ کارت دیده می‌شود (شلوغیِ کمتر).
base = Vector2(0, c.y);
stepV = Vector2(0, size.y * 0.36 / 13);
angle = math.pi / 2;
break;
default:
base = Vector2(size.x * 0.93, c.y);
stepV = Vector2(0, size.y * 0.4 / 13);
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);
_addBack(start + stepV * i.toDouble(), Vector2(w, h),
priority: 1, angle: angle);
}
}
// ===== کمکی‌های ساختِ عنصر (ثبت در فهرستِ بازساخته‌شونده) =====
void _addBack(Vector2 pos, Vector2 sz, {required int priority}) {
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);
}
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,
position: pos,
priority: 21,
textRenderer: TextPaint(
style: TextStyle(
fontFamily: 'Dana',
color: color,
fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
shadows: const [Shadow(color: Colors.black, blurRadius: 4)],
),
),
);
_info.add(t);
add(t);
}
(String, Color) _trumpGlyph(String suit) {
switch (suit) {
case 'hearts':
@@ -24,60 +24,67 @@ class TableHud extends StatelessWidget {
// مکانِ آواتارِ هر جایگاهِ نسبی (۰=پایین/شما، ۱=چپ، ۲=بالا، ۳=راست).
static const _spots = {
0: Alignment(0, 1.0),
1: Alignment(-0.94, -0.08),
1: Alignment(-1.0, -0.05), // چسبیده به لبه‌ی چپ
2: Alignment(0, -0.92),
3: Alignment(0.94, -0.08),
3: Alignment(1.0, -0.05), // چسبیده به لبه‌ی راست
};
int _rel(int seat) => (seat - s.yourSeat + 4) % 4;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, c) {
final w = c.maxWidth;
final avatarD = (w * 0.17).clamp(48.0, 84.0);
return Stack(
children: [
for (final p in s.players)
Align(
alignment: _spots[_rel(p.seat)]!,
child: Padding(
padding: EdgeInsets.only(
bottom: _rel(p.seat) == 0 ? 2 : 0,
top: _rel(p.seat) == 2 ? 6 : 0,
),
child: _PlayerSeat(
player: p,
diameter: avatarD,
isTurn: s.turn == p.seat,
isHakem: s.hakem == p.seat,
nameAbove: _rel(p.seat) == 2,
cardCount:
p.seat < s.handCounts.length ? s.handCounts[p.seat] : 0,
turnToken: '${s.turn}|${s.trick.length}|${s.phase}',
turnSeconds: s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000 : 30,
showRing: s.phase == 'playing' || s.phase == 'choose_trump',
return LayoutBuilder(
builder: (context, c) {
final w = c.maxWidth;
final avatarD = (w * 0.17).clamp(48.0, 84.0);
return Stack(
children: [
for (final p in s.players)
Align(
alignment: _spots[_rel(p.seat)]!,
child: Padding(
padding: EdgeInsets.only(
bottom: _rel(p.seat) == 0 ? 2 : 0,
top: _rel(p.seat) == 2 ? 6 : 0,
),
child: _PlayerSeat(
player: p,
diameter: avatarD,
isTurn: s.turn == p.seat,
isHakem: s.hakem == p.seat,
nameAbove: _rel(p.seat) == 2,
cardCount:
p.seat < s.handCounts.length ? s.handCounts[p.seat] : 0,
teamTricks:
(p.seat % 2) < s.tricksWon.length
? s.tricksWon[p.seat % 2]
: 0,
turnToken: '${s.turn}|${s.trick.length}|${s.phase}',
turnSeconds:
s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000 : 30,
showRing: s.phase == 'playing' || s.phase == 'choose_trump',
),
),
),
Positioned(top: 8, left: 8, child: SafeArea(child: _scorePanel())),
Positioned(
top: 8,
right: 8,
child: SafeArea(child: _topRight(context)),
),
Positioned(top: 8, left: 8, child: SafeArea(child: _scorePanel())),
Positioned(
top: 8,
right: 8,
child: SafeArea(child: _topRight(context)),
),
Positioned(
bottom: 16,
right: 12,
child: _RoundBtn(
icon: Icons.chat_bubble,
color: const Color(0xFF1E5FA8),
onTap: onChat,
Positioned(
bottom: 16,
right: 12,
child: _RoundBtn(
icon: Icons.chat_bubble,
color: const Color(0xFF1E5FA8),
onTap: onChat,
),
),
),
],
);
});
],
);
},
);
}
// پنلِ امتیاز (ما/حریف) + چیپِ حکم. امتیازِ تیمِ شما = scores[yourSeat%2].
@@ -111,16 +118,21 @@ class TableHud extends StatelessWidget {
}
Widget _scoreCol(String label, int v) => Column(
children: [
Text(label,
style: const TextStyle(color: Color(0xFFE9B949), fontSize: 12)),
Text('$v',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold)),
],
);
children: [
Text(
label,
style: const TextStyle(color: Color(0xFFE9B949), fontSize: 12),
),
Text(
'$v',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
);
Widget _hokmChip() {
final (glyph, color) = _trumpGlyph(s.trump);
@@ -130,11 +142,14 @@ class TableHud extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('حکم',
style: TextStyle(
color: Color(0xFFE9B949),
fontSize: 13,
fontWeight: FontWeight.bold)),
const Text(
'حکم',
style: TextStyle(
color: Color(0xFFE9B949),
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 6),
Text(glyph, style: TextStyle(color: color, fontSize: 18)),
],
@@ -151,15 +166,20 @@ class TableHud extends StatelessWidget {
decoration: _panelDeco(),
child: Row(
children: [
Icon(ok ? Icons.wifi : Icons.wifi_off,
color: ok ? const Color(0xFF4CD964) : Colors.redAccent,
size: 16),
Icon(
ok ? Icons.wifi : Icons.wifi_off,
color: ok ? const Color(0xFF4CD964) : Colors.redAccent,
size: 16,
),
const SizedBox(width: 5),
Text(ok ? 'آنلاین' : 'قطع',
style: TextStyle(
color: ok ? const Color(0xFF4CD964) : Colors.redAccent,
fontSize: 12,
fontWeight: FontWeight.bold)),
Text(
ok ? 'آنلاین' : 'قطع',
style: TextStyle(
color: ok ? const Color(0xFF4CD964) : Colors.redAccent,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
@@ -175,15 +195,15 @@ class TableHud extends StatelessWidget {
}
static BoxDecoration _panelDeco() => BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2A2018), Color(0xFF14100B)],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFB8860B), width: 1.4),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)],
);
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2A2018), Color(0xFF14100B)],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFB8860B), width: 1.4),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)],
);
(String, Color) _trumpGlyph(String? suit) {
switch (suit) {
@@ -208,6 +228,8 @@ class _PlayerSeat extends StatelessWidget {
final bool isHakem;
final bool nameAbove;
final int cardCount;
final int
teamTricks; // دست‌های برده‌ی تیمِ این بازیکن در این هَند (tricks_won)
final String turnToken;
final double turnSeconds;
final bool showRing;
@@ -218,6 +240,7 @@ class _PlayerSeat extends StatelessWidget {
required this.isHakem,
required this.nameAbove,
required this.cardCount,
required this.teamTricks,
required this.turnToken,
required this.turnSeconds,
required this.showRing,
@@ -228,12 +251,13 @@ class _PlayerSeat extends StatelessWidget {
final ringW = diameter * 0.08;
final name = Container(
constraints: BoxConstraints(maxWidth: diameter * 1.7),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xCC14100B),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isTurn ? const Color(0xFFE9B949) : const Color(0x55B8860B)),
color: isTurn ? const Color(0xFFE9B949) : const Color(0x55B8860B),
),
),
child: Text(
player.bot ? '${player.name} (ربات)' : player.name,
@@ -266,16 +290,22 @@ class _PlayerSeat extends StatelessWidget {
end: Alignment.bottomCenter,
colors: [Color(0xFFF3D27A), Color(0xFFB8860B)],
),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)],
boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 6),
],
),
padding: EdgeInsets.all(ringW),
child: ClipOval(
child: Container(
color: const Color(0xFF1A2740),
child: player.bot
? Icon(Icons.smart_toy,
color: Colors.white70, size: diameter * 0.5)
: RandomAvatar(player.name.isEmpty ? '?' : player.name),
child:
player.bot
? Icon(
Icons.smart_toy,
color: Colors.white70,
size: diameter * 0.5,
)
: RandomAvatar(player.name.isEmpty ? '?' : player.name),
),
),
),
@@ -291,45 +321,32 @@ class _PlayerSeat extends StatelessWidget {
// تاجِ حاکم بالای آواتار.
if (isHakem)
Positioned(
top: -diameter * 0.32,
child: Icon(Icons.workspace_premium,
color: const Color(0xFFFFC107), size: diameter * 0.42),
top: -diameter * 0.40,
child: Text('👑', style: TextStyle(fontSize: diameter * 0.4)),
),
// حبابِ تعدادِ کارت (پایین-راست).
if (cardCount > 0)
// دست‌های برده‌ی تیمِ این بازیکن در این هَند (بالا-چپ، با نشانِ تک‌خال).
if (teamTricks > 0)
Positioned(
right: -diameter * 0.04,
bottom: -diameter * 0.04,
child: Container(
padding: EdgeInsets.all(diameter * 0.06),
constraints: BoxConstraints(
minWidth: diameter * 0.34, minHeight: diameter * 0.34),
decoration: BoxDecoration(
color: const Color(0xFF0E2A12),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFE9B949), width: 1.4),
),
alignment: Alignment.center,
child: Text('$cardCount',
style: TextStyle(
color: Colors.white,
fontSize: diameter * 0.18,
fontWeight: FontWeight.bold)),
),
left: -diameter * 0.06,
top: -diameter * 0.06,
child: _TricksBadge(count: teamTricks, diameter: diameter),
),
// حبابِ تعدادِ کارت (پایین-راست).
],
),
);
// نشانِ رتبه‌ی فشرده (تصویر) کنارِ آواتار.
final badge = (!player.bot && player.rankTier.isNotEmpty)
? Image.asset(
'assets/images/badge/${player.rankTier}.png',
width: diameter * 0.34,
height: diameter * 0.34,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
)
: const SizedBox.shrink();
final badge =
(!player.bot && player.rankTier.isNotEmpty)
? Image.asset(
'assets/images/badge/${player.rankTier}.png',
width: diameter * 0.34,
height: diameter * 0.34,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
)
: const SizedBox.shrink();
final nameRow = Row(
mainAxisSize: MainAxisSize.min,
@@ -339,18 +356,23 @@ class _PlayerSeat extends StatelessWidget {
Flexible(child: name),
if (!player.bot && player.coins > 0) ...[
const SizedBox(width: 4),
Text('${player.coins}',
style: TextStyle(
color: const Color(0xFFE9B949), fontSize: diameter * 0.16)),
Text(
'${player.coins}',
style: TextStyle(
color: const Color(0xFFE9B949),
fontSize: diameter * 0.16,
),
),
],
],
);
return Column(
mainAxisSize: MainAxisSize.min,
children: nameAbove
? [nameRow, SizedBox(height: diameter * 0.06), avatar]
: [avatar, SizedBox(height: diameter * 0.06), nameRow],
children:
nameAbove
? [nameRow, SizedBox(height: diameter * 0.06), avatar]
: [avatar, SizedBox(height: diameter * 0.06), nameRow],
);
}
}
@@ -361,8 +383,11 @@ class _TurnRing extends StatefulWidget {
final String token;
final double seconds;
final double stroke;
const _TurnRing(
{required this.token, required this.seconds, required this.stroke});
const _TurnRing({
required this.token,
required this.seconds,
required this.stroke,
});
@override
State<_TurnRing> createState() => _TurnRingState();
@@ -376,8 +401,9 @@ class _TurnRingState extends State<_TurnRing>
void initState() {
super.initState();
_c = AnimationController(
vsync: this, duration: Duration(milliseconds: (widget.seconds * 1000).round()))
..forward();
vsync: this,
duration: Duration(milliseconds: (widget.seconds * 1000).round()),
)..forward();
}
@override
@@ -423,7 +449,8 @@ class _RingPainter extends CustomPainter {
final radius = (size.shortestSide - stroke) / 2;
const start = -1.5708; // بالا
final sweep = 6.28318 * left;
final color = left < 0.3 ? const Color(0xFFE53935) : const Color(0xFF4CD964);
final color =
left < 0.3 ? const Color(0xFFE53935) : const Color(0xFF4CD964);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
start,
@@ -442,16 +469,92 @@ class _RingPainter extends CustomPainter {
old.left != left || old.stroke != stroke;
}
/// نشانِ دست‌های برده‌ی تیم؛ هنگامِ نمایش و هر بار که عدد زیاد می‌شود یک «پاپ»
/// (بزرگ‌نماییِ کوتاه) می‌زند تا کاربر بفهمد دست‌های برده اینجا نشان داده می‌شوند.
class _TricksBadge extends StatefulWidget {
final int count;
final double diameter;
const _TricksBadge({required this.count, required this.diameter});
@override
State<_TricksBadge> createState() => _TricksBadgeState();
}
class _TricksBadgeState extends State<_TricksBadge>
with SingleTickerProviderStateMixin {
late final AnimationController _c;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_c = AnimationController(
vsync: this, duration: const Duration(milliseconds: 420));
_scale = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.4, end: 1.35), weight: 50),
TweenSequenceItem(tween: Tween(begin: 1.35, end: 1.0), weight: 50),
]).animate(CurvedAnimation(parent: _c, curve: Curves.easeOut));
_c.forward(); // پاپِ اولیه هنگامِ ظاهرشدن
}
@override
void didUpdateWidget(covariant _TricksBadge old) {
super.didUpdateWidget(old);
if (old.count != widget.count) {
_c
..reset()
..forward(); // پاپ هر بار که دستِ برده اضافه می‌شود
}
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final d = widget.diameter;
return ScaleTransition(
scale: _scale,
child: Container(
padding:
EdgeInsets.symmetric(horizontal: d * 0.08, vertical: d * 0.03),
decoration: BoxDecoration(
color: const Color(0xFF7B0E14),
borderRadius: BorderRadius.circular(d),
border: Border.all(color: const Color(0xFFE9B949), width: 1.4),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.layers, color: Colors.white, size: d * 0.18),
SizedBox(width: d * 0.03),
Text('${widget.count}',
style: TextStyle(
color: Colors.white,
fontSize: d * 0.2,
fontWeight: FontWeight.bold)),
],
),
),
);
}
}
class _RoundBtn extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
final double size;
const _RoundBtn(
{required this.icon,
required this.color,
required this.onTap,
this.size = 46});
const _RoundBtn({
required this.icon,
required this.color,
required this.onTap,
this.size = 46,
});
@override
Widget build(BuildContext context) {