feat: get cards from backend

This commit is contained in:
2026-06-24 08:35:31 +03:30
parent d837aca96e
commit 108a972761
83 changed files with 593 additions and 38 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"type": "dart",
"program": "lib/main.dart",
"args": [
"--dart-define=BASE_URL=http://192.168.1.105:8080",
"--dart-define=BASE_URL=http://192.168.1.104:8080",
"--target-platform=android-arm64"
]
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

-5
View File
@@ -1,5 +0,0 @@
دسته‌کارت تصویری را اینجا بگذارید: یک فایل PNG برای هر کارت با نام کد کوتاه:
AS.png 2S.png ... 10S.png JS.png QS.png KS.png (پیک/spades = S)
...H (دل/hearts), ...D (خشت/diamonds), ...C (گشنیز/clubs)
و back.png برای پشت کارت.
تا وقتی این تصاویر نباشند، صحنه به‌صورت برداری (vector) کارت‌ها را می‌کشد.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

+10
View File
@@ -19,6 +19,8 @@ import 'feature/game/presentation/screen/tier_list_screen.dart';
import 'feature/profile/presentation/bloc/profile_bloc.dart';
import 'feature/profile/presentation/bloc/profile_event.dart';
import 'feature/profile/presentation/screen/profile_screen.dart';
import 'feature/ranked/presentation/bloc/leaderboard_bloc.dart';
import 'feature/ranked/presentation/screen/leaderboard_screen.dart';
import 'feature/shop/presentation/bloc/shop_bloc.dart';
import 'feature/shop/presentation/bloc/shop_event.dart';
import 'feature/shop/presentation/screen/shop_screen.dart';
@@ -46,6 +48,14 @@ class HakemApp extends StatelessWidget {
child: const ProfileScreen(),
),
),
GoRoute(
path: '/leaderboard',
builder: (_, __) => BlocProvider(
create: (_) =>
locator<LeaderboardBloc>()..add(LoadLeaderboardEvent()),
child: const LeaderboardScreen(),
),
),
GoRoute(
path: '/shop',
builder: (_, __) => BlocProvider(
+1 -1
View File
@@ -8,7 +8,7 @@ class AppConfig {
/// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیش‌فرض را عوض کنید.
static const String baseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: 'http://192.168.1.105:8080',
defaultValue: 'http://192.168.1.104:8080',
);
static String get apiUrl => '$baseUrl/api';
+10
View File
@@ -24,6 +24,11 @@ import '../../feature/profile/domain/repository/profile_repository.dart';
import '../../feature/profile/domain/use_cases/get_profile_usecase.dart';
import '../../feature/profile/domain/use_cases/save_profile_usecase.dart';
import '../../feature/profile/presentation/bloc/profile_bloc.dart';
import '../../feature/ranked/data/data_source/remote/ranked_api_provider.dart';
import '../../feature/ranked/data/repository/ranked_repository_impl.dart';
import '../../feature/ranked/domain/repository/ranked_repository.dart';
import '../../feature/ranked/domain/use_cases/get_leaderboard_usecase.dart';
import '../../feature/ranked/presentation/bloc/leaderboard_bloc.dart';
import '../../feature/shop/data/data_source/remote/shop_api_provider.dart';
import '../../feature/shop/data/repository/shop_repository_impl.dart';
import '../../feature/shop/domain/repository/shop_repository.dart';
@@ -56,6 +61,7 @@ Future<void> setupLocator() async {
locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
locator.registerSingleton<RankedApiProvider>(RankedApiProvider());
locator.registerSingleton<GameApiProvider>(GameApiProvider());
locator.registerSingleton<GameWsProvider>(GameWsProvider(locator()));
@@ -67,6 +73,7 @@ Future<void> setupLocator() async {
locator.registerSingleton<ShopRepository>(ShopRepositoryImpl(locator()));
locator.registerSingleton<ProfileRepository>(
ProfileRepositoryImpl(locator()));
locator.registerSingleton<RankedRepository>(RankedRepositoryImpl(locator()));
locator.registerSingleton<GameRepository>(
GameRepositoryImpl(locator(), locator()));
@@ -85,6 +92,8 @@ Future<void> setupLocator() async {
locator.registerSingleton<AdRewardUseCase>(AdRewardUseCase(locator()));
locator.registerSingleton<GetProfileUseCase>(GetProfileUseCase(locator()));
locator.registerSingleton<SaveProfileUseCase>(SaveProfileUseCase(locator()));
locator.registerSingleton<GetLeaderboardUseCase>(
GetLeaderboardUseCase(locator()));
locator.registerSingleton<GetTiersUseCase>(GetTiersUseCase(locator()));
locator.registerSingleton<GetTablesInfoUseCase>(
GetTablesInfoUseCase(locator()));
@@ -97,6 +106,7 @@ Future<void> setupLocator() async {
ShopBloc(locator(), locator(), locator(), locator(), locator()));
locator.registerFactory<ProfileBloc>(
() => ProfileBloc(locator(), locator()));
locator.registerFactory<LeaderboardBloc>(() => LeaderboardBloc(locator()));
locator.registerFactory<GameBloc>(() => GameBloc(locator()));
locator.registerFactory<TierBloc>(() => TierBloc(locator()));
locator.registerFactory<PrivateInfoBloc>(() => PrivateInfoBloc(locator()));
+65
View File
@@ -0,0 +1,65 @@
import 'dart:ui' as ui;
import 'package:dio/dio.dart';
import 'package:flame/components.dart';
import 'package:flutter/foundation.dart';
import '../config.dart';
/// بارگذاریِ تصاویرِ کارت از backend (نه bundle داخل اپ) تا حجم اپ کم بماند.
/// هر اسکین یک پوشه روی سرور دارد: `/cards/<skin>/<code>.png` و `/cards/<skin>/back.jpg`.
/// تصاویرِ دانلودشده در حافظه کش می‌شوند تا هر کارت فقط یک‌بار از شبکه بیاید.
class CardImages {
CardImages._();
static const _fallbackSkin = 'simple';
static final Dio _dio = Dio(BaseOptions(
responseType: ResponseType.bytes,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 12),
validateStatus: (s) => s != null && s >= 200 && s < 300,
));
static final Map<String, Future<Sprite?>> _cache = {};
static String _url(String skin, String file) =>
'${AppConfig.baseUrl}/cards/$skin/$file';
/// اسپرایتِ رویِ یک کارت (مثل "AS"). در صورت خطا/۴۰۴ به اسکینِ پیش‌فرض و
/// سپس به `null` (کشیدنِ برداری در CardComponent) برمی‌گردد.
static Future<Sprite?> front(String skin, String code) =>
_load(skin, '$code.png');
/// اسپرایتِ پشتِ کارت برای اسکین داده‌شده.
static Future<Sprite?> back(String skin) => _load(skin, 'back.jpg');
static Future<Sprite?> _load(String skin, String file) {
final key = '$skin/$file';
return _cache.putIfAbsent(key, () => _fetch(skin, file));
}
static Future<Sprite?> _fetch(String skin, String file) async {
final sprite = await _fetchFrom(skin, file);
if (sprite != null) return sprite;
if (skin != _fallbackSkin) return _fetchFrom(_fallbackSkin, file);
return null;
}
static Future<Sprite?> _fetchFrom(String skin, String file) async {
try {
final res = await _dio.get<List<int>>(_url(skin, file));
final data = res.data;
if (data == null || data.isEmpty) return null;
final image = await _decode(Uint8List.fromList(data));
return Sprite(image);
} catch (e) {
debugPrint('CardImages: $skin/$file failed: $e');
return null;
}
}
static Future<ui.Image> _decode(Uint8List bytes) async {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
return frame.image;
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
/// نشانِ رتبه (برنز/نقره/طلا/الماس/پادشاه) با رنگ و برچسبِ فارسی.
class RankBadge extends StatelessWidget {
final String tier; // bronze..king
final int? points; // در صورت نمایش امتیاز
final double size;
const RankBadge({super.key, required this.tier, this.points, this.size = 16});
static const _tiers = {
'bronze': ('برنز', [Color(0xFFCD7F32), Color(0xFF8C5A2B)]),
'silver': ('نقره', [Color(0xFFBFC6CC), Color(0xFF8A949B)]),
'gold': ('طلا', [Color(0xFFFFD54F), Color(0xFFB8860B)]),
'diamond': ('الماس', [Color(0xFF5BE0E6), Color(0xFF1E8A99)]),
'king': ('پادشاه', [Color(0xFFB388FF), Color(0xFF6A1B9A)]),
};
@override
Widget build(BuildContext context) {
final t = _tiers[tier] ?? _tiers['bronze']!;
final label = t.$1;
final colors = t.$2;
return Container(
padding: EdgeInsets.symmetric(horizontal: size * 0.6, vertical: size * 0.25),
decoration: BoxDecoration(
gradient: LinearGradient(colors: colors),
borderRadius: BorderRadius.circular(size),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)],
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Image.asset(
'assets/images/badge/$tier.png',
width: size * 1.5,
height: size * 1.5,
errorBuilder: (_, __, ___) =>
Icon(Icons.military_tech, color: Colors.white, size: size * 1.1),
),
SizedBox(width: size * 0.3),
Text(
points == null ? label : '$label · $points',
style: TextStyle(
color: Colors.white,
fontSize: size * 0.85,
fontWeight: FontWeight.bold,
),
),
]),
);
}
}
@@ -39,8 +39,9 @@ class _MobileScreenState extends State<MobileScreen> {
if (s is LoginSuccess) {
context.push('/otp');
} else if (s is LoginError) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(s.message)));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(s.message)));
}
},
builder: (context, state) {
@@ -58,33 +59,43 @@ class _MobileScreenState extends State<MobileScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('برای ورود شماره موبایلت رو وارد کن',
style: TextStyle(color: Colors.white70)),
const Text(
'برای ورود شماره موبایلت رو وارد کن',
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 20),
TextField(
controller: _controller,
keyboardType: TextInputType.phone,
textAlign: TextAlign.center,
style:
const TextStyle(fontSize: 20, letterSpacing: 2),
textDirection: TextDirection.ltr,
style: const TextStyle(
fontSize: 20,
letterSpacing: 2,
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(11),
],
decoration:
const InputDecoration(hintText: '09xxxxxxxxx'),
decoration: const InputDecoration(
hintText: '09xxxxxxxxx',
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 20),
GameButton(
label: 'دریافت کد',
width: double.infinity,
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
onTap: (!_valid || loading)
? null
: () => context
.read<AuthBloc>()
.add(LoginOtpEvent(_controller.text.trim())),
colors: const [
Color(0xFF3FA34D),
Color(0xFF1B5E20),
],
onTap:
(!_valid || loading)
? null
: () => context.read<AuthBloc>().add(
LoginOtpEvent(_controller.text.trim()),
),
),
],
),
@@ -5,12 +5,16 @@ class GamePlayer {
final String name;
final bool bot;
final bool connected;
final int coins;
final String rankTier; // bronze..king (خالی برای بات)
GamePlayer.fromJson(Map<String, dynamic> j)
: seat = (j['seat'] ?? 0) as int,
name = (j['name'] ?? '') as String,
bot = (j['bot'] ?? false) as bool,
connected = (j['connected'] ?? false) as bool;
connected = (j['connected'] ?? false) as bool,
coins = (j['coins'] ?? 0) as int,
rankTier = (j['rank_tier'] ?? '') as String;
}
class TrickCard {
@@ -11,6 +11,7 @@ import '../../../../core/service/app_sounds.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
import '../../../wallet/presentation/bloc/wallet_event.dart';
import '../../../wallet/presentation/bloc/wallet_status.dart';
import '../../domain/entities/game_entities.dart';
import '../bloc/game_bloc.dart';
import '../bloc/game_state.dart';
@@ -33,7 +34,12 @@ class _GameScreenState extends State<GameScreen> {
@override
void initState() {
super.initState();
_game = HokmGame(context.read<GameBloc>());
final ws = context.read<WalletBloc>().state.walletStatus;
final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple';
_game = HokmGame(
context.read<GameBloc>(),
skin: skin.isEmpty ? 'simple' : skin,
);
}
@override
@@ -3,25 +3,30 @@ import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../../../../../core/network/card_images.dart';
/// یک کارت روی میز؛ اگر تصویر `assets/images/cards/<code>.png` موجود باشد از آن
/// استفاده می‌کند، وگرنه نسخه‌ی برداری می‌کشد. برای پشت کارت `back.jpg`.
/// کارت‌های قابل‌بازی فقط با **کشیدن (drag)** به سمت زمین بازی می‌شوند (نه tap).
class CardComponent extends PositionComponent
with DragCallbacks, HasGameReference {
final String code; // مثل "AS"؛ برای پشت کارت خالی
final bool faceUp;
final String skin; // اسکینِ کارت؛ تصاویر از backend بارگذاری می‌شوند
bool faceUp; // قابلِ تغییر: برای انیمیشنِ برگرداندنِ کارت هنگام پخش
VoidCallback? onPlay; // در صورت مجاز بودن، بازیِ این کارت
bool dimmed; // کارت غیرمجاز/غیرفعال
Vector2? home; // موقعیت اصلی در دست (برای برگشت پس از کشیدنِ ناقص)
int restPriority = 0; // ترتیب لایه‌ی اصلی در دست (برای بازگردانی پس از کشیدن)
Rect? dropZone; // ناحیه‌ی وسط میز؛ رهاکردن کارت در آن یعنی بازی
Sprite? _sprite;
Sprite? _front;
Sprite? _back;
bool _dragging = false;
bool _overZone = false; // کارت روی ناحیه‌ی انداختن است (برای هایلایت)
CardComponent({
required this.code,
required this.faceUp,
this.skin = 'simple',
this.onPlay,
this.dimmed = false,
super.position,
@@ -35,11 +40,24 @@ class CardComponent extends PositionComponent
@override
Future<void> onLoad() async {
try {
_sprite = await game.loadSprite(faceUp ? 'cards/$code.png' : 'cards/back.jpg');
} catch (_) {
_sprite = null; // fallback برداری
if (code.isNotEmpty) {
_front = await CardImages.front(skin, code);
}
_back = await CardImages.back(skin);
}
/// برگرداندنِ کارت از پشت به رو با انیمیشن (هنگام پخش‌شدن از روی دسته).
void flipToFront({double delay = 0}) {
if (faceUp) return;
add(ScaleEffect.to(
Vector2(0.04, scale.y == 0 ? 1 : scale.y),
EffectController(duration: 0.13, startDelay: delay, curve: Curves.easeIn),
onComplete: () {
faceUp = true; // در لبه‌ی نازک، رو می‌شود
add(ScaleEffect.to(
Vector2(1, 1), EffectController(duration: 0.13, curve: Curves.easeOut)));
},
));
}
// بزرگ‌نماییِ کارتِ انتخاب‌شده هنگام کشیدن (واضح‌تر برای دیدن).
@@ -126,10 +144,11 @@ class CardComponent extends PositionComponent
Paint()..color = Color(_dragging ? 0x66000000 : 0x44000000));
}
if (_sprite != null) {
final sprite = faceUp ? _front : _back;
if (sprite != null) {
canvas.save();
canvas.clipRRect(rrect);
_sprite!.render(canvas, size: size);
sprite.render(canvas, size: size);
canvas.restore();
} else if (faceUp) {
_drawVectorFace(canvas, rrect);
@@ -26,6 +26,9 @@ import 'turn_timer.dart';
class HokmGame extends FlameGame {
final GameBloc cubit;
/// اسکینِ کارت (از فروشگاه/کیف‌پول انتخاب می‌شود)؛ تصاویر از backend می‌آیند.
final String skin;
// وضعیت بازی و اشتراکِ stream.
GameState? _s;
StreamSubscription? _sub;
@@ -35,6 +38,7 @@ 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;
@@ -44,6 +48,7 @@ class HokmGame extends FlameGame {
double _shake = 0;
double _t = 0;
List<String> _prevTrick = const [];
bool _cutThisTrick = false; // افکتِ بریدن فقط یک‌بار در هر دست
int _prevHandSize = 0; // برای تشخیصِ پخشِ کارت (دستِ جدید) جهت صدای بُر زدن
bool _silent = false; // هنگام خروج از میز، دیگر صدایی پخش نشود
bool _introActive = true; // تا پایانِ نمایشِ «جستجوی حریف»
@@ -64,7 +69,7 @@ class HokmGame extends FlameGame {
bool _beatPlayed = false; // یک‌بار در هر نوبت، هنگام رسیدن به آستانه
bool _wasMyTurn = false;
HokmGame(this.cubit);
HokmGame(this.cubit, {this.skin = 'simple'});
@override
Color backgroundColor() => const Color(0xFF2C0A10);
@@ -72,6 +77,12 @@ class HokmGame extends FlameGame {
@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!;
@@ -132,6 +143,7 @@ class HokmGame extends FlameGame {
}
final addedOne =
now.length == _prevTrick.length + 1 && _isPrefix(_prevTrick, now);
if (now.length <= 1) _cutThisTrick = false; // دستِ جدید ⇒ ریستِ افکتِ بریدن
if (addedOne) {
final card = s.trick.last;
final isCut =
@@ -139,12 +151,14 @@ class HokmGame extends FlameGame {
s.leadSuit != null &&
s.leadSuit != s.trump &&
suitName(card.card) == s.trump;
if (isCut) {
// افکتِ بریدن فقط برای اولین بریدنِ هر دست (بریدن‌های بعدی فقط صدای گذاشتن).
if (isCut && !_cutThisTrick) {
_cutThisTrick = true;
_shake = 1.0;
add(Lightning());
AppSounds.cut(); // بریدن با حکم (شمشیر)
} else {
AppSounds.placeCard(); // کارت روی میز نشست (همه‌ی بازیکنان)
AppSounds.placeCard(); // کارت روی میز نشست
}
} else if (now.isEmpty && _prevTrick.isNotEmpty) {
AppSounds.takeCard(); // دستِ کامل جمع شد
@@ -382,11 +396,13 @@ class HokmGame extends FlameGame {
final target = Vector2(cx + t * stepX, baseY + norm * norm * dip);
final legal = _legal(code);
var c = _hand[code];
final isNew = c == null;
if (c == null) {
// کارت جدید ⇒ از مرکز میز پخش می‌شود.
// کارت جدید ⇒ پشت‌رو از مرکز میز (دسته) پخش می‌شود و سپس رو می‌گردد.
c = CardComponent(
code: code,
faceUp: true,
faceUp: false,
skin: skin,
size: Vector2(hw, hh),
position: size / 2,
);
@@ -403,6 +419,7 @@ class HokmGame extends FlameGame {
c.restPriority = 10 + i;
c.priority = 10 + i;
_moveTo(c, target);
if (isNew) c.flipToFront(delay: 0.28 + i * 0.04); // پس از رسیدن، رو شود
}
}
@@ -451,6 +468,7 @@ class HokmGame extends FlameGame {
c = CardComponent(
code: tc.card,
faceUp: true,
skin: skin,
size: Vector2(_cardW, _cardH),
position: _seatOrigin(_rel(tc.seat)),
);
@@ -618,6 +636,32 @@ class HokmGame extends FlameGame {
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) {
@@ -660,6 +704,7 @@ class HokmGame extends FlameGame {
final c = CardComponent(
code: '',
faceUp: false,
skin: skin,
size: sz,
position: pos,
priority: priority,
@@ -19,6 +19,8 @@ class ProfileModel {
xpInto: (wallet['xp_into_level'] ?? 0) as int,
xpNext: (wallet['xp_for_next'] ?? 1) as int,
vip: (stats['vip'] ?? false) as bool,
rankPoints: (wallet['rank_points'] ?? 0) as int,
rankTier: (wallet['rank_tier'] ?? 'bronze') as String,
stats: s == null
? null
: ProfileStats(
@@ -28,6 +28,8 @@ class ProfileEntity {
final int xpInto;
final int xpNext;
final bool vip;
final int rankPoints;
final String rankTier; // bronze..king
final ProfileStats? stats; // null یعنی قفل (غیر VIP)
const ProfileEntity({
@@ -39,6 +41,8 @@ class ProfileEntity {
required this.xpInto,
required this.xpNext,
required this.vip,
required this.rankPoints,
required this.rankTier,
required this.stats,
});
}
@@ -6,6 +6,7 @@ import 'package:random_avatar/random_avatar.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/game_ui.dart';
import '../../../../core/widgets/rank_badge.dart';
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
import '../../../wallet/presentation/bloc/wallet_event.dart';
import '../../domain/entities/profile_entity.dart';
@@ -146,6 +147,8 @@ class ProfileScreen extends StatelessWidget {
if (d.vip) ...[const SizedBox(width: 8), const _VipBadge()],
],
),
const SizedBox(height: 8),
RankBadge(tier: d.rankTier, points: d.rankPoints),
if (d.mobile.isNotEmpty)
Text(
d.mobile,
@@ -0,0 +1,10 @@
import 'package:dio/dio.dart';
import '../../../../../core/locator/locator.dart';
import '../../../../../core/network/api_provider_imp.dart';
class RankedApiProvider {
ApiProviderImp get _api => locator<ApiProviderImp>();
Future<Response> getLeaderboard() => _api.get('/leaderboard');
}
@@ -0,0 +1,22 @@
import 'package:dio/dio.dart';
import '../../../../core/error/custom_error.dart';
import '../../../../core/resources/data_state.dart';
import '../../domain/entities/leaderboard.dart';
import '../../domain/repository/ranked_repository.dart';
import '../data_source/remote/ranked_api_provider.dart';
class RankedRepositoryImpl extends RankedRepository {
final RankedApiProvider api;
RankedRepositoryImpl(this.api);
@override
Future<DataState<Leaderboard>> getLeaderboard() async {
final Response res = await api.getLeaderboard();
if (res.statusCode == 200) {
return DataSuccess(
Leaderboard.fromJson(Map<String, dynamic>.from(res.data as Map)));
}
return DataError(errorConvertor(res.statusCode, null));
}
}
@@ -0,0 +1,29 @@
/// یک ردیف از جدولِ رتبه‌بندی.
class RankEntry {
final int rank;
final String name;
final String avatar;
final int rankPoints;
final String tier; // bronze..king
RankEntry.fromJson(Map<String, dynamic> j)
: rank = (j['rank'] ?? 0) as int,
name = (j['name'] ?? '') as String,
avatar = (j['avatar'] ?? '') as String,
rankPoints = (j['rank_points'] ?? 0) as int,
tier = (j['tier'] ?? 'bronze') as String;
}
/// جدولِ رتبه‌بندیِ فصلِ جاری.
class Leaderboard {
final int season;
final List<RankEntry> entries;
const Leaderboard(this.season, this.entries);
factory Leaderboard.fromJson(Map<String, dynamic> j) => Leaderboard(
(j['season'] ?? 1) as int,
((j['entries'] as List?) ?? [])
.map((e) => RankEntry.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
);
}
@@ -0,0 +1,6 @@
import '../../../../core/resources/data_state.dart';
import '../entities/leaderboard.dart';
abstract class RankedRepository {
Future<DataState<Leaderboard>> getLeaderboard();
}
@@ -0,0 +1,14 @@
import '../../../../core/resources/data_state.dart';
import '../../../../core/usecase/use_case.dart';
import '../entities/leaderboard.dart';
import '../repository/ranked_repository.dart';
class GetLeaderboardUseCase
implements UseCase<DataState<Leaderboard>, NoParams> {
final RankedRepository repository;
GetLeaderboardUseCase(this.repository);
@override
Future<DataState<Leaderboard>> call(NoParams params) =>
repository.getLeaderboard();
}
@@ -0,0 +1,41 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/resources/data_state.dart';
import '../../../../core/usecase/use_case.dart';
import '../../domain/entities/leaderboard.dart';
import '../../domain/use_cases/get_leaderboard_usecase.dart';
abstract class LeaderboardEvent {}
class LoadLeaderboardEvent extends LeaderboardEvent {}
abstract class LeaderboardState {}
class LeaderboardInitial extends LeaderboardState {}
class LeaderboardLoading extends LeaderboardState {}
class LeaderboardLoaded extends LeaderboardState {
final Leaderboard data;
LeaderboardLoaded(this.data);
}
class LeaderboardError extends LeaderboardState {
final String message;
LeaderboardError(this.message);
}
class LeaderboardBloc extends Bloc<LeaderboardEvent, LeaderboardState> {
final GetLeaderboardUseCase getLeaderboardUseCase;
LeaderboardBloc(this.getLeaderboardUseCase) : super(LeaderboardInitial()) {
on<LoadLeaderboardEvent>((event, emit) async {
emit(LeaderboardLoading());
final res = await getLeaderboardUseCase(const NoParams());
if (res is DataSuccess) {
emit(LeaderboardLoaded(res.data!));
} else {
emit(LeaderboardError(res.error!));
}
});
}
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:random_avatar/random_avatar.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/game_ui.dart';
import '../../../../core/widgets/rank_badge.dart';
import '../../domain/entities/leaderboard.dart';
import '../bloc/leaderboard_bloc.dart';
/// جدولِ رتبه‌بندیِ فصل: برترین بازیکنان بر اساس امتیازِ رتبه.
class LeaderboardScreen extends StatelessWidget {
const LeaderboardScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: GameBackground(
child: SafeArea(
child: Column(
children: [
Row(children: [
IconButton(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back, color: AppColors.gold),
),
const Spacer(),
const GlowText('رتبه‌بندی فصل', size: 22),
const Spacer(),
const SizedBox(width: 48),
]),
Expanded(
child: BlocBuilder<LeaderboardBloc, LeaderboardState>(
builder: (context, state) {
if (state is LeaderboardError) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text(state.message,
style: const TextStyle(color: Colors.white70)),
TextButton(
onPressed: () => context
.read<LeaderboardBloc>()
.add(LoadLeaderboardEvent()),
child: const Text('تلاش مجدد'),
),
]),
);
}
if (state is! LeaderboardLoaded) {
return const Center(
child:
CircularProgressIndicator(color: AppColors.gold));
}
final lb = state.data;
if (lb.entries.isEmpty) {
return const Center(
child: Text('هنوز کسی در این فصل امتیاز نگرفته است',
style: TextStyle(color: Colors.white54)),
);
}
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text('فصل ${lb.season}',
style: const TextStyle(
color: AppColors.gold, fontSize: 14)),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 20),
itemCount: lb.entries.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 8),
itemBuilder: (_, i) => _row(lb.entries[i]),
),
),
],
);
},
),
),
],
),
),
),
);
}
Widget _row(RankEntry e) {
final medal = e.rank <= 3;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF4A0C16), Color(0xFF2A0710)],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: medal ? AppColors.gold : AppColors.goldDark,
width: medal ? 1.6 : 1),
),
child: Row(children: [
SizedBox(
width: 28,
child: Text('${e.rank}',
textAlign: TextAlign.center,
style: TextStyle(
color: medal ? AppColors.gold : Colors.white70,
fontWeight: FontWeight.bold,
fontSize: 16)),
),
const SizedBox(width: 8),
SizedBox(
width: 40,
height: 40,
child: ClipOval(
child: RandomAvatar(e.avatar.isEmpty ? e.name : e.avatar)),
),
const SizedBox(width: 10),
Expanded(
child: Text(e.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 15)),
),
RankBadge(tier: e.tier, points: e.rankPoints, size: 13),
]),
);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/config.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/game_ui.dart';
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
@@ -320,6 +321,7 @@ class _CardsTab extends StatelessWidget {
title: c.title,
glowColor: const Color(0xFF0E3C73),
icon: Icons.style,
art: _CardSkinArt(skin: c.id, selected: data.selectedCard == c.id),
subtitle: c.priceCoins > 0 ? '${c.priceCoins} سکه' : 'پیش‌فرض',
action: _cardAction(context, c, data),
),
@@ -381,6 +383,7 @@ class _ItemCard extends StatelessWidget {
final String subtitle;
final String? ribbon;
final Widget action;
final Widget? art; // هنرِ سفارشی (مثل پیش‌نمایشِ اسکینِ کارت) به‌جای آیکن
const _ItemCard({
required this.title,
required this.icon,
@@ -388,6 +391,7 @@ class _ItemCard extends StatelessWidget {
required this.subtitle,
required this.action,
this.ribbon,
this.art,
});
@override
@@ -418,7 +422,7 @@ class _ItemCard extends StatelessWidget {
),
),
const SizedBox(height: 6),
Expanded(child: _GlowArt(color: glowColor, icon: icon)),
Expanded(child: art ?? _GlowArt(color: glowColor, icon: icon)),
const SizedBox(height: 4),
Text(
subtitle,
@@ -481,6 +485,71 @@ class _GlowArt extends StatelessWidget {
}
}
/// پیش‌نمایشِ اسکینِ کارت در فروشگاه: دو کارت فن‌شده (پشت + تک‌خال پیک)
/// که مستقیماً از backend بارگذاری می‌شوند تا کاربر هنرِ واقعی را ببیند.
class _CardSkinArt extends StatelessWidget {
final String skin;
final bool selected;
const _CardSkinArt({required this.skin, required this.selected});
String _url(String file) => '${AppConfig.baseUrl}/cards/$skin/$file';
Widget _card(String file, {double angle = 0, double dx = 0}) {
return Transform.translate(
offset: Offset(dx, 0),
child: Transform.rotate(
angle: angle,
child: AspectRatio(
aspectRatio: 0.7,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
_url(file),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFF0E3C73),
child: const Icon(Icons.style, color: AppColors.gold),
),
loadingBuilder: (ctx, child, progress) => progress == null
? child
: Container(color: const Color(0xFF0A2547)),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: const RadialGradient(
colors: [Color(0xFF12508F), Colors.black87],
radius: 0.95,
),
border: Border.all(
color: selected ? AppColors.gold : Colors.black26,
width: selected ? 2 : 1,
),
),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
_card('back.jpg', angle: -0.22, dx: -16),
_card('AS.png', angle: 0.22, dx: 16),
],
),
),
);
}
}
class _PriceButton extends StatelessWidget {
final String label;
final VoidCallback? onTap;
@@ -84,6 +84,14 @@ class _LobbyScreenState extends State<LobbyScreen> {
},
),
const SizedBox(height: 16),
GameButton(
label: 'رتبه‌بندی',
icon: Icons.leaderboard,
width: double.infinity,
colors: const [Color(0xFFE9952F), Color(0xFF9C5A00)],
onTap: () => context.push('/leaderboard'),
),
const SizedBox(height: 16),
GameButton(
label: 'فروشگاه',
icon: Icons.storefront,
+1 -1
View File
@@ -72,7 +72,7 @@ flutter:
assets:
- assets/images/
- assets/images/cards/
- assets/images/badge/
- assets/audio/
fonts: