feat: featch cards to storage

This commit is contained in:
2026-07-12 09:05:39 +03:30
parent 2ada58d7e6
commit 5a3dbb0466
6 changed files with 121 additions and 26 deletions
File diff suppressed because one or more lines are too long
+97 -22
View File
@@ -1,14 +1,20 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../config.dart'; import '../config.dart';
/// بارگذاریِ تصاویرِ کارت از backend (نه bundle داخل اپ) تا حجم اپ کم بماند. /// تصاویرِ کارت از backend می‌آیند (نه bundle داخل اپ) تا حجمِ اپ کم بماند، اما هر
/// هر اسکین یک پوشه روی سرور دارد: `/cards/<skin>/<code>.png` و `/cards/<skin>/back.jpg`. /// تصویر فقط **یک‌بار برای همیشه** دانلود می‌شود: بایت‌ها روی دیسکِ ماندگار ذخیره و
/// تصاویرِ دانلودشده در حافظه کش می‌شوند تا هر کارت فقط یک‌بار از شبکه بیاید. /// در جلسه‌های بعد از دیسک خوانده می‌شوند (بدونِ شبکه). یک کشِ درون‌حافظه‌ایِ
/// همگام ([_ready]) هم نگه می‌داریم تا کارت‌ها بدونِ پرش/انتظار رندر شوند.
///
/// مسیرِ سرور: `/cards/<skin>/<code>.png` و `/cards/<skin>/back.jpg`.
class CardImages { class CardImages {
CardImages._(); CardImages._();
@@ -19,53 +25,97 @@ class CardImages {
receiveTimeout: const Duration(seconds: 12), receiveTimeout: const Duration(seconds: 12),
validateStatus: (s) => s != null && s >= 200 && s < 300, validateStatus: (s) => s != null && s >= 200 && s < 300,
)); ));
static final Map<String, Future<Sprite?>> _cache = {};
// اسپرایتِ آماده و decode‌شده (کلید: "<skin>/<file>") — دسترسیِ همگام برای رندرِ فوری.
static final Map<String, Sprite> _ready = {};
// Futureهای در حالِ اجرا/تمام‌شده برای جلوگیری از بارگذاریِ تکراری.
static final Map<String, Future<Sprite?>> _inflight = {};
static Future<String>? _dir;
static String _url(String skin, String file) => static String _url(String skin, String file) =>
'${AppConfig.baseUrl}/cards/$skin/$file'; '${AppConfig.baseUrl}/cards/$skin/$file';
/// اسپرایتِ رویِ یک کارت (مثل "AS"). در صورت خطا/۴۰۴ به اسکینِ پیش‌فرض و // ===== دسترسیِ همگام (اگر از قبل در حافظه آماده باشد) =====
/// سپس به `null` (کشیدنِ برداری در CardComponent) برمی‌گردد.
/// اسپرایتِ رویِ کارت اگر همین حالا در حافظه آماده باشد، وگرنه null.
static Sprite? cachedFront(String skin, String code) => _ready['$skin/$code.png'];
/// اسپرایتِ پشتِ کارت اگر در حافظه آماده باشد، وگرنه null.
static Sprite? cachedBack(String skin) => _ready['$skin/back.jpg'];
// ===== بارگذاریِ ناهمگام (دیسک → شبکه) =====
/// اسپرایتِ رویِ یک کارت (مثل "AS")؛ در صورت خطا null (کشیدنِ برداری در CardComponent).
static Future<Sprite?> front(String skin, String code) => static Future<Sprite?> front(String skin, String code) =>
_load(skin, '$code.png'); _load(skin, '$code.png');
/// اسپرایتِ پشتِ کارت برای اسکین داده‌شده. /// اسپرایتِ پشتِ کارت برای اسکین.
static Future<Sprite?> back(String skin) => _load(skin, 'back.jpg'); static Future<Sprite?> back(String skin) => _load(skin, 'back.jpg');
/// کلِ دسته (۵۲ کارت + پشت) را گرم می‌کند: از دیسک (فوری) یا در اولین بار از شبکه.
/// fire-and-forget؛ در طولِ صفحه‌ی «جستجوی حریف» اجرا می‌شود تا هنگامِ پخش آماده باشند.
static void preloadDeck(String skin) {
const ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
const suits = ['S', 'H', 'D', 'C'];
for (final s in suits) {
for (final r in ranks) {
_load(skin, '$r$s.png');
}
}
_load(skin, 'back.jpg');
}
static Future<Sprite?> _load(String skin, String file) { static Future<Sprite?> _load(String skin, String file) {
final key = '$skin/$file'; final key = '$skin/$file';
return _cache.putIfAbsent(key, () => _fetch(skin, file)); final ready = _ready[key];
if (ready != null) return Future.value(ready);
return _inflight.putIfAbsent(key, () => _fetch(skin, file));
} }
static Future<Sprite?> _fetch(String skin, String file) async { static Future<Sprite?> _fetch(String skin, String file) async {
final sprite = await _fetchFrom(skin, file); var s = await _fromDiskOrNet(skin, file);
if (sprite != null) return sprite; if (s == null && skin != _fallbackSkin) {
if (skin != _fallbackSkin) return _fetchFrom(_fallbackSkin, file); s = await _fromDiskOrNet(_fallbackSkin, file);
return null; }
if (s != null) _ready['$skin/$file'] = s;
return s;
} }
static Future<Sprite?> _fetchFrom(String skin, String file) async { static Future<Sprite?> _fromDiskOrNet(String skin, String file) async {
// ۱) دیسکِ ماندگار (اگر قبلاً دانلود شده).
final disk = await _readDisk(skin, file);
if (disk != null) {
final sp = await _toSprite(disk);
if (sp != null) return sp;
}
// ۲) شبکه — سپس ذخیره روی دیسک برای دفعه‌ی بعد.
try { try {
final res = await _dio.get<List<int>>(_url(skin, file)); final res = await _dio.get<List<int>>(_url(skin, file));
final data = res.data; final data = res.data;
if (data == null || data.isEmpty) return null; if (data == null || data.isEmpty) return null;
final image = await _decode(Uint8List.fromList(data)); final bytes = Uint8List.fromList(data);
return Sprite(image); unawaited(_writeDisk(skin, file, bytes));
return _toSprite(bytes);
} catch (e) { } catch (e) {
debugPrint('CardImages: $skin/$file failed: $e'); debugPrint('CardImages: $skin/$file failed: $e');
return null; return null;
} }
} }
/// تصویرِ فرش (برای سطحِ میز) از URL کاملِ backend. کش‌شده با کلیدِ URL. /// تصویرِ فرش از URL کاملِ backend (کش‌شده در حافظه با کلیدِ URL).
static Future<Sprite?> carpetByUrl(String url) { static Future<Sprite?> carpetByUrl(String url) {
if (url.isEmpty) return Future.value(null); if (url.isEmpty) return Future.value(null);
return _cache.putIfAbsent('carpet/$url', () async { final key = 'carpet/$url';
final ready = _ready[key];
if (ready != null) return Future.value(ready);
return _inflight.putIfAbsent(key, () async {
try { try {
final res = await _dio.get<List<int>>(url); final res = await _dio.get<List<int>>(url);
final data = res.data; final data = res.data;
if (data == null || data.isEmpty) return null; if (data == null || data.isEmpty) return null;
return Sprite(await _decode(Uint8List.fromList(data))); final sp = await _toSprite(Uint8List.fromList(data));
if (sp != null) _ready[key] = sp;
return sp;
} catch (e) { } catch (e) {
debugPrint('CardImages: carpet $url failed: $e'); debugPrint('CardImages: carpet $url failed: $e');
return null; return null;
@@ -73,9 +123,34 @@ class CardImages {
}); });
} }
static Future<ui.Image> _decode(Uint8List bytes) async { // ===== دیسک =====
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame(); static Future<String> _cacheDir() =>
return frame.image; _dir ??= getApplicationSupportDirectory().then((d) => '${d.path}/hakem_cards');
static Future<Uint8List?> _readDisk(String skin, String file) async {
try {
final f = File('${await _cacheDir()}/$skin/$file');
if (await f.exists()) return f.readAsBytes();
} catch (_) {}
return null;
}
static Future<void> _writeDisk(String skin, String file, Uint8List bytes) async {
try {
final f = File('${await _cacheDir()}/$skin/$file');
await f.parent.create(recursive: true);
await f.writeAsBytes(bytes, flush: false);
} catch (_) {}
}
static Future<Sprite?> _toSprite(Uint8List bytes) async {
try {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
return Sprite(frame.image);
} catch (_) {
return null;
}
} }
} }
@@ -44,10 +44,27 @@ class CardComponent extends PositionComponent
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
// اگر تصویر از قبل در حافظه آماده باشد (از کشِ دیسک/preload)، همان لحظه ست می‌شود
// ⇒ کارت با تصویرِ درست و بدونِ پرش رندر می‌شود. وگرنه بدونِ مسدودکردنِ رندر در
// پس‌زمینه بارگذاری و به‌محضِ آماده‌شدن جای‌گزین می‌شود (نسخه‌ی برداری تا آن لحظه).
if (code.isNotEmpty) { if (code.isNotEmpty) {
_front = await CardImages.front(skin, code); final cached = CardImages.cachedFront(skin, code);
if (cached != null) {
_front = cached;
} else {
CardImages.front(skin, code).then((s) {
if (isMounted) _front = s;
});
}
}
final cb = CardImages.cachedBack(skin);
if (cb != null) {
_back = cb;
} else {
CardImages.back(skin).then((s) {
if (isMounted) _back = s;
});
} }
_back = await CardImages.back(skin);
} }
/// برگرداندنِ کارت از پشت به رو با انیمیشن (هنگام پخش‌شدن از روی دسته). /// برگرداندنِ کارت از پشت به رو با انیمیشن (هنگام پخش‌شدن از روی دسته).
@@ -7,6 +7,7 @@ import 'package:flame/game.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback; import 'package:flutter/services.dart' show HapticFeedback;
import '../../../../../core/network/card_images.dart';
import '../../../../../core/service/app_sounds.dart'; import '../../../../../core/service/app_sounds.dart';
import '../../../../../core/settings/app_settings.dart'; import '../../../../../core/settings/app_settings.dart';
import '../../../../../core/theme/app_theme.dart'; import '../../../../../core/theme/app_theme.dart';
@@ -79,6 +80,7 @@ class HokmGame extends FlameGame {
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
CardImages.preloadDeck(skin); // گرم‌کردنِ کشِ تصاویرِ کارت (پس‌زمینه، fire-and-forget)
add(Felt(carpetUrl: carpetUrl)); add(Felt(carpetUrl: carpetUrl));
if (!AppSettings.I.lowGraphics.value) { if (!AppSettings.I.lowGraphics.value) {
add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کم‌مصرف خاموش) add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کم‌مصرف خاموش)
+1 -1
View File
@@ -569,7 +569,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+1
View File
@@ -48,6 +48,7 @@ dependencies:
flutter_svg: ^2.3.0 # نمایشِ شخصیتِ آواتارِ svg (و png با Image.network) flutter_svg: ^2.3.0 # نمایشِ شخصیتِ آواتارِ svg (و png با Image.network)
connectivity_plus: ^6.1.0 # تشخیصِ قطعِ اینترنت برای نمایشِ اوورلیِ «اتصال نیست» connectivity_plus: ^6.1.0 # تشخیصِ قطعِ اینترنت برای نمایشِ اوورلیِ «اتصال نیست»
package_info_plus: ^8.1.1 # نمایشِ نسخه‌ی اپ در لابی package_info_plus: ^8.1.1 # نمایشِ نسخه‌ی اپ در لابی
path_provider: 2.1.5 # کشِ دیسکیِ ماندگارِ تصاویرِ کارت (دانلودِ یک‌بار برای همیشه)
# خرید درون‌برنامه‌ای: هر flavor یکی را استفاده می‌کند (مایکت/کافه‌بازار). # خرید درون‌برنامه‌ای: هر flavor یکی را استفاده می‌کند (مایکت/کافه‌بازار).
myket_iap: ^1.1.11 myket_iap: ^1.1.11
flutter_poolakey: ^2.2.0 flutter_poolakey: ^2.2.0