Compare commits
2
Commits
108a972761
...
e0d6763c00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d6763c00 | ||
|
|
08937a66cb |
@@ -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,19 @@ class _GameScreenState extends State<GameScreen> {
|
||||
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),
|
||||
// دیالوگِ انتخابِ حکم و بنرِ نوبت فقط پس از پایانِ انیمیشنِ
|
||||
@@ -110,10 +123,12 @@ class _GameScreenState extends State<GameScreen> {
|
||||
valueListenable: _game.animating,
|
||||
builder: (context, animating, _) {
|
||||
if (animating) return const SizedBox.shrink();
|
||||
return Stack(children: [
|
||||
return Stack(
|
||||
children: [
|
||||
if (_showTrumpPicker(state)) _trumpPicker(context),
|
||||
if (_isMyPlayTurn(state)) _turnBanner(),
|
||||
]);
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (state.handResult != null && state.gameOver == null)
|
||||
@@ -137,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),
|
||||
@@ -166,17 +181,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
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<void> _confirmLeave(BuildContext context) async {
|
||||
if (context.read<GameBloc>().state.gameOver != null) {
|
||||
_exitToLobby(context);
|
||||
|
||||
@@ -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<String, CardComponent> _trick = {};
|
||||
final List<Component> _backs = [];
|
||||
final List<Component> _info = [];
|
||||
final Map<String, Sprite> _badge = {}; // نشانهای رتبه (پیشبارگذاری)
|
||||
|
||||
// ابعادِ کارتهای روی میز (بر اساس عرض صفحه محاسبه میشود).
|
||||
double _cardW = 60;
|
||||
@@ -66,23 +63,18 @@ 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'});
|
||||
|
||||
@override
|
||||
Color backgroundColor() => const Color(0xFF2C0A10);
|
||||
Color backgroundColor() => const Color(0xFF0C3A1B);
|
||||
|
||||
@override
|
||||
Future<void> 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!;
|
||||
@@ -280,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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,50 +536,8 @@ class HokmGame extends FlameGame {
|
||||
final count = seat < s.handCounts.length ? s.handCounts[seat] : 0;
|
||||
_addBacks(_rel(seat), count);
|
||||
}
|
||||
_addTricksWon(s);
|
||||
_addScorePucks(s);
|
||||
_addInfo(s);
|
||||
}
|
||||
|
||||
// شمارنده ۱: دستهای بردهی این هَند (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);
|
||||
}
|
||||
}
|
||||
|
||||
// شمارنده ۲: امتیاز بازی (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);
|
||||
}
|
||||
// امتیاز/نام/نشان/تاج/شمارندهی نوبت و دستهای برده (tricks_won) اکنون در
|
||||
// اوورلیِ Flutter (TableHud) نمایش داده میشوند تا واضحتر و کمشلوغتر باشند.
|
||||
}
|
||||
|
||||
// پشتکارتهای یک حریف بهصورت بادبزنی.
|
||||
@@ -586,173 +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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
_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);
|
||||
}
|
||||
|
||||
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':
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
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(-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',
|
||||
),
|
||||
),
|
||||
),
|
||||
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 int
|
||||
teamTricks; // دستهای بردهی تیمِ این بازیکن در این هَند (tricks_won)
|
||||
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.teamTricks,
|
||||
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: 2, vertical: 1),
|
||||
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.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: 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 _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,
|
||||
});
|
||||
|
||||
@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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user