Files
front-hokm/lib/feature/game/presentation/widgets/table_hud.dart
T
2026-06-24 09:09:00 +03:30

474 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
),
);
}
}