Files
front-hokm/lib/feature/game/presentation/widgets/table_hud.dart
T
2026-06-25 01:11:34 +03:30

630 lines
20 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 '../../../../core/theme/app_theme.dart';
import '../../domain/entities/game_entities.dart';
import '../bloc/game_state.dart';
/// اوورلیِ روی صحنه‌ی Flame: آواتارِ بازیکنان با قابِ طلایی، نام، نشانِ رتبه،
/// شمارنده‌ی نوبت، تاجِ حاکم و تعدادِ کارت — به‌علاوه‌ی پنلِ امتیاز/حکم (بالا-چپ)،
/// نشانگرِ اتصال و دکمه‌ی خروج (بالا-راست) و دکمه‌ی گفتگو (پایین-راست).
/// همه‌ی چیدمان نسبت به اندازه‌ی صفحه است تا با کارت‌های Flame هم‌تراز بماند.
class TableHud extends StatelessWidget {
final GameState s;
final WsStatus connection;
final Map<int, ChatBubble> chatBubbles;
final VoidCallback onExit;
final VoidCallback onChat;
const TableHud({
super.key,
required this.s,
required this.connection,
required this.chatBubbles,
required this.onExit,
required this.onChat,
});
// مکانِ آواتارِ هر جایگاهِ نسبی (۰=پایین/شما، ۱=چپ، ۲=بالا، ۳=راست).
static const _spots = {
0: Alignment(0, 1.0),
1: Alignment(-1.0, -0.05), // چسبیده به لبه‌ی چپ
2: Alignment(0, -0.92),
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,
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',
bubble: chatBubbles[p.seat],
),
),
),
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: AppColors.goldFaint,
),
_scoreCol('حریف', opp),
],
),
),
const SizedBox(height: 6),
_hokmChip(),
],
);
}
Widget _scoreCol(String label, int v) => Column(
children: [
Text(
label,
style: const TextStyle(color: AppColors.gold, fontSize: 12),
),
Text(
'$v',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
);
Widget _hokmChip() {
final (glyph, color) = suitGlyph(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: AppColors.gold,
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 ? AppColors.online : Colors.redAccent,
size: 16,
),
const SizedBox(width: 5),
Text(
ok ? 'آنلاین' : 'قطع',
style: TextStyle(
color: ok ? AppColors.online : Colors.redAccent,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(width: 8),
_RoundBtn(
icon: Icons.logout,
color: AppColors.accent,
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: AppColors.goldDark, width: 1.4),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)],
);
}
class _PlayerSeat extends StatelessWidget {
final GamePlayer player;
final double diameter;
final bool isTurn;
final bool isHakem;
final bool nameAbove;
final int cardCount;
final int
teamTricks; // دست‌های برده‌ی تیمِ این بازیکن در این هَند (tricks_won)
final String turnToken;
final double turnSeconds;
final bool showRing;
final ChatBubble? bubble; // پیام/شکلکِ فعالِ این بازیکن (یا null)
const _PlayerSeat({
required this.player,
required this.diameter,
required this.isTurn,
required this.isHakem,
required this.nameAbove,
required this.cardCount,
required this.teamTricks,
required this.turnToken,
required this.turnSeconds,
required this.showRing,
required this.bubble,
});
@override
Widget build(BuildContext context) {
final ringW = diameter * 0.08;
final name = Container(
constraints: BoxConstraints(maxWidth: diameter * 1.7),
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xCC14100B),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isTurn ? AppColors.gold : const Color(0x55B8860B),
),
),
child: Text(
player.bot ? '${player.name} (ربات)' : player.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: isTurn ? AppColors.gold : 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), AppColors.goldDark],
),
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.40,
child: Text('👑', style: TextStyle(fontSize: diameter * 0.4)),
),
// دست‌های برده‌ی تیمِ این بازیکن در این هَند (بالا-چپ، با نشانِ تک‌خال).
if (teamTricks > 0)
Positioned(
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 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: AppColors.gold,
fontSize: diameter * 0.16,
),
),
],
],
);
final content = Column(
mainAxisSize: MainAxisSize.min,
children:
nameAbove
? [nameRow, SizedBox(height: diameter * 0.06), avatar]
: [avatar, SizedBox(height: diameter * 0.06), nameRow],
);
if (bubble == null) return content;
// حبابِ پیام بالای آواتار (برای بازیکنِ بالا، زیرِ آواتار تا از صفحه بیرون نزند).
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
content,
Positioned(
top: nameAbove ? null : -diameter * 0.62,
bottom: nameAbove ? -diameter * 0.62 : null,
child: _ChatBubbleView(bubble: bubble!, diameter: diameter),
),
],
);
}
}
/// نمایشِ حبابِ پیام/شکلک با یک «پاپ»ِ ورود. شکلک بزرگ و متن در یک پیلِ روشن.
class _ChatBubbleView extends StatelessWidget {
final ChatBubble bubble;
final double diameter;
const _ChatBubbleView({required this.bubble, required this.diameter});
@override
Widget build(BuildContext context) {
final isEmoji = (bubble.emoji ?? '').isNotEmpty;
final child = isEmoji
? Text(bubble.emoji!, style: TextStyle(fontSize: diameter * 0.7))
: Container(
constraints: BoxConstraints(maxWidth: diameter * 2.4),
padding: EdgeInsets.symmetric(
horizontal: diameter * 0.16, vertical: diameter * 0.08),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(diameter * 0.3),
border: Border.all(color: AppColors.goldDark, width: 1.4),
boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 5)
],
),
child: Text(
bubble.text ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.bgDark,
fontSize: diameter * 0.2,
fontWeight: FontWeight.bold,
),
),
);
return TweenAnimationBuilder<double>(
key: ValueKey(bubble.id),
tween: Tween(begin: 0.5, end: 1.0),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutBack,
builder: (_, v, c) => Transform.scale(scale: v, child: c),
child: child,
);
}
}
/// حلقه‌ی شمارشِ معکوسِ نوبت: از پر به خالی طیِ `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 ? AppColors.suitRed : AppColors.online;
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 _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: AppColors.gold, 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,
});
@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: AppColors.gold, width: 1.6),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 6)],
),
child: Icon(icon, color: Colors.white, size: size * 0.5),
),
);
}
}