630 lines
20 KiB
Dart
630 lines
20 KiB
Dart
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),
|
||
),
|
||
);
|
||
}
|
||
}
|