diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index 4994551..54e95f7 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -16,6 +16,7 @@ import '../../domain/entities/game_entities.dart'; import '../bloc/game_bloc.dart'; import '../bloc/game_state.dart'; import '../widgets/flame/hokm_game.dart'; +import '../widgets/table_hud.dart'; /// صفحه‌ی میز بازی: صحنه‌ی Flame + اوورلی‌های وضعیت. class GameScreen extends StatefulWidget { @@ -100,7 +101,18 @@ class _GameScreenState extends State { return Stack( children: [ GameWidget(game: _game), - _backButton(context), + if (!_showSearch(state) && state.state != null) + TableHud( + s: state.state!, + connection: state.connection, + onExit: () => _confirmLeave(context), + onChat: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('گفتگو به‌زودی'), + duration: Duration(seconds: 1), + ), + ), + ), if (state.connection == WsStatus.disconnected) _connBanner(), if (_showSearch(state)) _searchPanel(state), // دیالوگِ انتخابِ حکم و بنرِ نوبت فقط پس از پایانِ انیمیشنِ @@ -166,17 +178,6 @@ class _GameScreenState extends State { s.state!.amHakem && s.gameOver == null; - Widget _backButton(BuildContext context) => Positioned( - top: 8, - right: 8, - child: SafeArea( - child: IconButton( - icon: const Icon(Icons.arrow_back, color: AppColors.gold), - onPressed: () => _confirmLeave(context), - ), - ), - ); - Future _confirmLeave(BuildContext context) async { if (context.read().state.gameOver != null) { _exitToLobby(context); diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index 40b4755..255efb6 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -9,12 +9,10 @@ import 'package:flutter/material.dart'; import '../../../../../core/service/app_sounds.dart'; import '../../../domain/entities/game_entities.dart'; import '../../bloc/game_bloc.dart'; -import 'badges.dart'; import 'card_codes.dart'; import 'card_component.dart'; import 'shuffle_animation.dart'; import 'table_pieces.dart'; -import 'turn_timer.dart'; /// صحنه‌ی میز حکم با انیمیشن. شما همیشه پایین میز (rel=0) هستید. /// کامپوننت‌های دست و trick ماندگارند و با افکت حرکت جابه‌جا می‌شوند. @@ -38,7 +36,6 @@ class HokmGame extends FlameGame { final Map _trick = {}; final List _backs = []; final List _info = []; - final Map _badge = {}; // نشان‌های رتبه (پیش‌بارگذاری) // ابعادِ کارت‌های روی میز (بر اساس عرض صفحه محاسبه می‌شود). double _cardW = 60; @@ -72,17 +69,11 @@ class HokmGame extends FlameGame { HokmGame(this.cubit, {this.skin = 'simple'}); @override - Color backgroundColor() => const Color(0xFF2C0A10); + Color backgroundColor() => const Color(0xFF0C3A1B); @override Future onLoad() async { add(Felt()); - // پیش‌بارگذاریِ نشان‌های رتبه (اگر فایلی نبود، بی‌نشان رد می‌شود). - for (final t in const ['bronze', 'silver', 'gold', 'diamond', 'king']) { - try { - _badge[t] = await loadSprite('badge/$t.png'); - } catch (_) {} - } _sub = cubit.stream.listen((ui) { if (ui.state == null) return; final s = ui.state!; @@ -534,8 +525,8 @@ class HokmGame extends FlameGame { _addBacks(_rel(seat), count); } _addTricksWon(s); - _addScorePucks(s); - _addInfo(s); + // امتیاز/نام/نشان/تاج/شمارنده‌ی نوبت اکنون در اوورلیِ Flutter (TableHud) کشیده + // می‌شوند تا متن واضح‌تر و چیدمان نزدیک به تصویرِ مرجع باشد. } // شمارنده ۱: دست‌های برده‌ی این هَند (tricks_won) — دسته‌کارتِ پشت‌رو + عدد. @@ -561,24 +552,6 @@ class HokmGame extends FlameGame { } } - // شمارنده ۲: امتیاز بازی (scores = هندهای برده) — دیسکِ هر تیم، ۰ تا ۷. - // یکی جلوی شما (پایین)، دیگری جلوی حریف (راست) — نه جلوی یارِ بالا. - void _addScorePucks(GameState s) { - final radius = size.x * 0.05; - 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; - _info.add(puck); - add(puck); - } - } - // پشت‌کارت‌های یک حریف به‌صورت بادبزنی. void _addBacks(int rel, int count) { if (count <= 0) return; @@ -605,99 +578,6 @@ 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, - ); - } - 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, - ); - - for (final p in s.players) { - final pos = _seatLabelPos(_rel(p.seat)); - final isTurn = s.turn == p.seat; - _addLabel( - '${p.name}${p.bot ? ' (ربات)' : ''}${p.connected ? '' : ' …'}', - pos, - size.x * 0.035, - color: isTurn ? const Color(0xFFE9B949) : Colors.white70, - bold: isTurn, - ); - // نشانِ رتبه (راستِ نام) و سکه (چپِ نام). جایگاهِ پایین روی همان خطِ نام، - // بقیه کمی زیرِ نام تا خارج از صفحه نرود. - if (!p.bot) { - final isBottom = pos.y > size.y * 0.75; - final y = isBottom ? pos.y : pos.y + size.y * 0.032; - // سکه: چپِ نام (متن به سمتِ چپ کشیده می‌شود). - _addLabel( - '${p.coins}', - Vector2(pos.x - size.x * 0.06, y), - size.x * 0.034, - color: const Color(0xFFE9B949), - anchor: Anchor.centerRight, - ); - // نشانِ رتبه: راستِ نام. - final spr = _badge[p.rankTier]; - if (spr != null) { - final b = SpriteComponent( - sprite: spr, - anchor: Anchor.centerLeft, - size: Vector2.all(size.x * 0.06), - position: Vector2(pos.x + size.x * 0.07, y), - )..priority = 22; - _info.add(b); - add(b); - } - } - // تاجِ حاکم (روی همه‌ی فازها نشان داده می‌شود تا حاکم مشخص باشد). - // y را داخلِ صفحه نگه می‌داریم؛ برای بازیکنِ بالا، تاج زیرِ نام می‌نشیند. - if (s.hakem == p.seat) { - 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.phase == 'playing' || s.phase == 'choose_trump')) { - final dur = s.turnTimeoutMs > 0 ? s.turnTimeoutMs / 1000.0 : 30.0; - final timer = TurnTimer( - radius: size.x * 0.045, - durationSeconds: dur, - position: tpos, - )..priority = 20; - _info.add(timer); - add(timer); - } - // ستاره‌ی نوبت (مطابقِ تصویرِ مرجع) با ضربان. - final star = StarBadge(size.x * 0.015, tpos)..priority = 24; - star.add(pulse()); - _info.add(star); - add(star); - } - } - } - // ===== کمکی‌های ساختِ عنصر (ثبت در فهرستِ بازساخته‌شونده) ===== void _addBack(Vector2 pos, Vector2 sz, {required int priority}) { @@ -740,19 +620,6 @@ class HokmGame extends FlameGame { add(t); } - Vector2 _seatLabelPos(int rel) { - switch (rel) { - case 1: - return Vector2(size.x * 0.07, size.y * 0.30); - case 2: - return Vector2(size.x / 2, size.y * 0.07); - case 3: - return Vector2(size.x * 0.93, size.y * 0.30); - default: - return Vector2(size.x / 2, size.y * 0.975); - } - } - (String, Color) _trumpGlyph(String suit) { switch (suit) { case 'hearts': diff --git a/lib/feature/game/presentation/widgets/flame/table_pieces.dart b/lib/feature/game/presentation/widgets/flame/table_pieces.dart index 1f60aaf..e6c7a2e 100644 --- a/lib/feature/game/presentation/widgets/flame/table_pieces.dart +++ b/lib/feature/game/presentation/widgets/flame/table_pieces.dart @@ -7,49 +7,72 @@ import 'package:flutter/material.dart'; const _gold = Color(0xFFE9B949); const _goldDark = Color(0xFFB8860B); -/// نمدِ سبزِ بیضی‌شکلِ میز با عمق: لبه‌ی برجسته، گرادیانِ شعاعی و وینیت. +/// میز: زمینِ سبزِ نمدی در کلِ صفحه + یک «میزِ» مرکزیِ سرمه‌ای با قابِ طلاییِ +/// دولایه (مطابقِ تصویرِ مرجع). کارت‌های زمین داخلِ ناحیه‌ی سرمه‌ای انداخته می‌شوند +/// و پشت‌کارتِ حریفان روی نمدِ سبزِ دورِ آن می‌نشیند. class Felt extends PositionComponent with HasGameReference { @override void render(Canvas canvas) { final w = game.size.x, h = game.size.y; final center = Offset(w / 2, h / 2); - final rect = Rect.fromCenter(center: center, width: w * 0.86, height: h * 0.62); - final radius = Radius.circular(h * 0.3); - final felt = RRect.fromRectAndRadius(rect, radius); - // ۱) لبه‌ی چوبیِ بیرونی (ریل) با گرادیان — حسِ برجستگی بدون drawShadowِ هر-فریمی. - final rail = RRect.fromRectAndRadius( - rect.inflate(w * 0.03), Radius.circular(h * 0.33)); + // ۱) زمینِ سبزِ نمدی (کلِ صفحه) با وینیتِ شعاعی برای عمق. + canvas.drawRect( + Rect.fromLTWH(0, 0, w, h), + Paint() + ..shader = RadialGradient( + center: Alignment.center, + radius: 1.0, + colors: const [Color(0xFF1C7A3E), Color(0xFF0C3A1B)], + stops: const [0.5, 1.0], + ).createShader(Rect.fromLTWH(0, 0, w, h)), + ); + + // ۲) میزِ مرکزیِ سرمه‌ای با قابِ طلایی. + final rect = Rect.fromCenter( + center: center, width: w * 0.72, height: h * 0.46); + final radius = Radius.circular(h * 0.05); + final table = RRect.fromRectAndRadius(rect, radius); + + // سایه‌ی نرمِ زیرِ میز (یک‌بار، نه هر-فریمی سنگین). canvas.drawRRect( - rail, + table.shift(const Offset(0, 6)), + Paint() + ..color = const Color(0x66000000) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12), + ); + + // قابِ طلاییِ بیرونی. + canvas.drawRRect( + RRect.fromRectAndRadius(rect.inflate(w * 0.012), radius), Paint() ..shader = const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFF5A2E12), Color(0xFF2E1608)], - ).createShader(rail.outerRect), + colors: [_gold, _goldDark], + ).createShader(rect), ); - // ۲) حلقه‌ی طلایی بین ریل و نمد. + // سطحِ سرمه‌ایِ میز با گرادیانِ شعاعی (مرکز روشن‌تر). canvas.drawRRect( - RRect.fromRectAndRadius(rect.inflate(w * 0.008), radius), - Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = w * 0.012 - ..color = _gold, - ); - - // ۳) نمدِ سبز با گرادیانِ شعاعی (مرکز روشن، لبه‌ها تیره) — خودش حسِ وینیت/عمق می‌دهد. - canvas.drawRRect( - felt, + table, Paint() ..shader = RadialGradient( center: Alignment.center, - radius: 0.95, - colors: const [Color(0xFF34A03F), Color(0xFF0E3614)], - stops: const [0.45, 1.0], + radius: 0.9, + colors: const [Color(0xFF153A63), Color(0xFF07172E)], + stops: const [0.4, 1.0], ).createShader(rect), ); + + // خطِ طلاییِ نازکِ داخلی (قابِ دولایه). + canvas.drawRRect( + RRect.fromRectAndRadius(rect.deflate(w * 0.018), Radius.circular(h * 0.04)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = w * 0.004 + ..color = _gold.withValues(alpha: 0.65), + ); } } diff --git a/lib/feature/game/presentation/widgets/table_hud.dart b/lib/feature/game/presentation/widgets/table_hud.dart new file mode 100644 index 0000000..12ee0d7 --- /dev/null +++ b/lib/feature/game/presentation/widgets/table_hud.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; +import 'package:random_avatar/random_avatar.dart'; + +import '../../../../core/network/ws_client.dart'; +import '../../domain/entities/game_entities.dart'; + +/// اوورلیِ روی صحنه‌ی Flame: آواتارِ بازیکنان با قابِ طلایی، نام، نشانِ رتبه، +/// شمارنده‌ی نوبت، تاجِ حاکم و تعدادِ کارت — به‌علاوه‌ی پنلِ امتیاز/حکم (بالا-چپ)، +/// نشانگرِ اتصال و دکمه‌ی خروج (بالا-راست) و دکمه‌ی گفتگو (پایین-راست). +/// همه‌ی چیدمان نسبت به اندازه‌ی صفحه است تا با کارت‌های Flame هم‌تراز بماند. +class TableHud extends StatelessWidget { + final GameState s; + final WsStatus connection; + final VoidCallback onExit; + final VoidCallback onChat; + const TableHud({ + super.key, + required this.s, + required this.connection, + required this.onExit, + required this.onChat, + }); + + // مکانِ آواتارِ هر جایگاهِ نسبی (۰=پایین/شما، ۱=چپ، ۲=بالا، ۳=راست). + static const _spots = { + 0: Alignment(0, 1.0), + 1: Alignment(-0.94, -0.08), + 2: Alignment(0, -0.92), + 3: Alignment(0.94, -0.08), + }; + + 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', + ), + ), + ), + 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, + ), + ), + ], + ); + }); + } + + // پنلِ امتیاز (ما/حریف) + چیپِ حکم. امتیازِ تیمِ شما = scores[yourSeat%2]. + Widget _scorePanel() { + final myTeam = s.yourSeat % 2; + final mine = myTeam < s.scores.length ? s.scores[myTeam] : 0; + final opp = (1 - myTeam) < s.scores.length ? s.scores[1 - myTeam] : 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: _panelDeco(), + child: Row( + children: [ + _scoreCol('ما', mine), + Container( + width: 1, + height: 30, + margin: const EdgeInsets.symmetric(horizontal: 12), + color: const Color(0x33E9B949), + ), + _scoreCol('حریف', opp), + ], + ), + ), + const SizedBox(height: 6), + _hokmChip(), + ], + ); + } + + 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)), + ], + ); + + Widget _hokmChip() { + final (glyph, color) = _trumpGlyph(s.trump); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: _panelDeco(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('حکم', + style: TextStyle( + color: Color(0xFFE9B949), + fontSize: 13, + fontWeight: FontWeight.bold)), + const SizedBox(width: 6), + Text(glyph, style: TextStyle(color: color, fontSize: 18)), + ], + ), + ); + } + + Widget _topRight(BuildContext context) { + final ok = connection == WsStatus.connected; + return Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: _panelDeco(), + child: Row( + children: [ + 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)), + ], + ), + ), + const SizedBox(width: 8), + _RoundBtn( + icon: Icons.logout, + color: const Color(0xFFB81D2A), + onTap: onExit, + size: 40, + ), + ], + ); + } + + 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)], + ); + + (String, Color) _trumpGlyph(String? suit) { + switch (suit) { + case 'hearts': + return ('♥', const Color(0xFFE53935)); + case 'diamonds': + return ('♦', const Color(0xFFE53935)); + case 'clubs': + return ('♣', Colors.white); + case 'spades': + return ('♠', Colors.white); + default: + return ('—', Colors.white54); + } + } +} + +class _PlayerSeat extends StatelessWidget { + final GamePlayer player; + final double diameter; + final bool isTurn; + final bool isHakem; + final bool nameAbove; + final int cardCount; + final String turnToken; + final double turnSeconds; + final bool showRing; + const _PlayerSeat({ + required this.player, + required this.diameter, + required this.isTurn, + required this.isHakem, + required this.nameAbove, + required this.cardCount, + required this.turnToken, + required this.turnSeconds, + required this.showRing, + }); + + @override + Widget build(BuildContext context) { + final ringW = diameter * 0.08; + final name = Container( + constraints: BoxConstraints(maxWidth: diameter * 1.7), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xCC14100B), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isTurn ? const Color(0xFFE9B949) : const Color(0x55B8860B)), + ), + child: Text( + player.bot ? '${player.name} (ربات)' : player.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: isTurn ? const Color(0xFFE9B949) : Colors.white, + fontSize: diameter * 0.18, + fontWeight: FontWeight.bold, + ), + ), + ); + + final avatar = SizedBox( + width: diameter, + height: diameter, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // قابِ طلایی + آواتار. + Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF3D27A), Color(0xFFB8860B)], + ), + 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), + ), + ), + ), + // حلقه‌ی شمارشِ نوبت (روی قاب). + if (isTurn && showRing) + Positioned.fill( + child: _TurnRing( + token: turnToken, + seconds: turnSeconds, + stroke: ringW * 1.1, + ), + ), + // تاجِ حاکم بالای آواتار. + if (isHakem) + Positioned( + top: -diameter * 0.32, + child: Icon(Icons.workspace_premium, + color: const Color(0xFFFFC107), size: diameter * 0.42), + ), + // حبابِ تعدادِ کارت (پایین-راست). + if (cardCount > 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)), + ), + ), + ], + ), + ); + + // نشانِ رتبه‌ی فشرده (تصویر) کنارِ آواتار. + 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, + children: [ + badge, + if (!player.bot && player.rankTier.isNotEmpty) const SizedBox(width: 4), + 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)), + ], + ], + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: nameAbove + ? [nameRow, SizedBox(height: diameter * 0.06), avatar] + : [avatar, SizedBox(height: diameter * 0.06), nameRow], + ); + } +} + +/// حلقه‌ی شمارشِ معکوسِ نوبت: از پر به خالی طیِ `seconds`؛ نزدیکِ پایان قرمز +/// می‌شود. با تغییرِ `token` (نوبتِ جدید) از نو شروع می‌شود. +class _TurnRing extends StatefulWidget { + final String token; + final double seconds; + final double stroke; + const _TurnRing( + {required this.token, required this.seconds, required this.stroke}); + + @override + State<_TurnRing> createState() => _TurnRingState(); +} + +class _TurnRingState extends State<_TurnRing> + with SingleTickerProviderStateMixin { + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, duration: Duration(milliseconds: (widget.seconds * 1000).round())) + ..forward(); + } + + @override + void didUpdateWidget(covariant _TurnRing old) { + super.didUpdateWidget(old); + if (old.token != widget.token) { + _c.duration = Duration(milliseconds: (widget.seconds * 1000).round()); + _c + ..reset() + ..forward(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _c, + builder: (_, __) { + final left = (1 - _c.value).clamp(0.0, 1.0); + return CustomPaint( + painter: _RingPainter(left: left, stroke: widget.stroke), + ); + }, + ); + } +} + +class _RingPainter extends CustomPainter { + final double left; // ۱..۰ + final double stroke; + _RingPainter({required this.left, required this.stroke}); + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final center = rect.center; + 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); + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + start, + sweep, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = stroke + ..color = color, + ); + } + + @override + bool shouldRepaint(covariant _RingPainter old) => + old.left != left || old.stroke != stroke; +} + +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}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFE9B949), width: 1.6), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)], + ), + child: Icon(icon, color: Colors.white, size: size * 0.5), + ), + ); + } +}