diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 1e9d4b7..2482603 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/core/network/card_images.dart b/lib/core/network/card_images.dart index fbfd897..84c0408 100644 --- a/lib/core/network/card_images.dart +++ b/lib/core/network/card_images.dart @@ -1,14 +1,20 @@ +import 'dart:async'; +import 'dart:io'; import 'dart:ui' as ui; import 'package:dio/dio.dart'; import 'package:flame/components.dart'; import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; import '../config.dart'; -/// بارگذاریِ تصاویرِ کارت از backend (نه bundle داخل اپ) تا حجم اپ کم بماند. -/// هر اسکین یک پوشه روی سرور دارد: `/cards//.png` و `/cards//back.jpg`. -/// تصاویرِ دانلودشده در حافظه کش می‌شوند تا هر کارت فقط یک‌بار از شبکه بیاید. +/// تصاویرِ کارت از backend می‌آیند (نه bundle داخل اپ) تا حجمِ اپ کم بماند، اما هر +/// تصویر فقط **یک‌بار برای همیشه** دانلود می‌شود: بایت‌ها روی دیسکِ ماندگار ذخیره و +/// در جلسه‌های بعد از دیسک خوانده می‌شوند (بدونِ شبکه). یک کشِ درون‌حافظه‌ایِ +/// همگام ([_ready]) هم نگه می‌داریم تا کارت‌ها بدونِ پرش/انتظار رندر شوند. +/// +/// مسیرِ سرور: `/cards//.png` و `/cards//back.jpg`. class CardImages { CardImages._(); @@ -19,53 +25,97 @@ class CardImages { receiveTimeout: const Duration(seconds: 12), validateStatus: (s) => s != null && s >= 200 && s < 300, )); - static final Map> _cache = {}; + + // اسپرایتِ آماده و decode‌شده (کلید: "/") — دسترسیِ همگام برای رندرِ فوری. + static final Map _ready = {}; + // Futureهای در حالِ اجرا/تمام‌شده برای جلوگیری از بارگذاریِ تکراری. + static final Map> _inflight = {}; + static Future? _dir; static String _url(String skin, String 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 front(String skin, String code) => _load(skin, '$code.png'); - /// اسپرایتِ پشتِ کارت برای اسکین داده‌شده. + /// اسپرایتِ پشتِ کارت برای اسکین. static Future 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 _load(String skin, String 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 _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; + var s = await _fromDiskOrNet(skin, file); + if (s == null && skin != _fallbackSkin) { + s = await _fromDiskOrNet(_fallbackSkin, file); + } + if (s != null) _ready['$skin/$file'] = s; + return s; } - static Future _fetchFrom(String skin, String file) async { + static Future _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 { final res = await _dio.get>(_url(skin, file)); final data = res.data; if (data == null || data.isEmpty) return null; - final image = await _decode(Uint8List.fromList(data)); - return Sprite(image); + final bytes = Uint8List.fromList(data); + unawaited(_writeDisk(skin, file, bytes)); + return _toSprite(bytes); } catch (e) { debugPrint('CardImages: $skin/$file failed: $e'); return null; } } - /// تصویرِ فرش (برای سطحِ میز) از URL کاملِ backend. کش‌شده با کلیدِ URL. + /// تصویرِ فرش از URL کاملِ backend (کش‌شده در حافظه با کلیدِ URL). static Future carpetByUrl(String url) { 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 { final res = await _dio.get>(url); final data = res.data; 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) { debugPrint('CardImages: carpet $url failed: $e'); return null; @@ -73,9 +123,34 @@ class CardImages { }); } - static Future _decode(Uint8List bytes) async { - final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - return frame.image; + // ===== دیسک ===== + + static Future _cacheDir() => + _dir ??= getApplicationSupportDirectory().then((d) => '${d.path}/hakem_cards'); + + static Future _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 _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 _toSprite(Uint8List bytes) async { + try { + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + return Sprite(frame.image); + } catch (_) { + return null; + } } } diff --git a/lib/feature/game/presentation/widgets/flame/card_component.dart b/lib/feature/game/presentation/widgets/flame/card_component.dart index f6b38f6..39eb781 100644 --- a/lib/feature/game/presentation/widgets/flame/card_component.dart +++ b/lib/feature/game/presentation/widgets/flame/card_component.dart @@ -44,10 +44,27 @@ class CardComponent extends PositionComponent @override Future onLoad() async { + // اگر تصویر از قبل در حافظه آماده باشد (از کشِ دیسک/preload)، همان لحظه ست می‌شود + // ⇒ کارت با تصویرِ درست و بدونِ پرش رندر می‌شود. وگرنه بدونِ مسدودکردنِ رندر در + // پس‌زمینه بارگذاری و به‌محضِ آماده‌شدن جای‌گزین می‌شود (نسخه‌ی برداری تا آن لحظه). 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); } /// برگرداندنِ کارت از پشت به رو با انیمیشن (هنگام پخش‌شدن از روی دسته). diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index 9e786c7..2399c00 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -7,6 +7,7 @@ import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show HapticFeedback; +import '../../../../../core/network/card_images.dart'; import '../../../../../core/service/app_sounds.dart'; import '../../../../../core/settings/app_settings.dart'; import '../../../../../core/theme/app_theme.dart'; @@ -79,6 +80,7 @@ class HokmGame extends FlameGame { @override Future onLoad() async { + CardImages.preloadDeck(skin); // گرم‌کردنِ کشِ تصاویرِ کارت (پس‌زمینه، fire-and-forget) add(Felt(carpetUrl: carpetUrl)); if (!AppSettings.I.lowGraphics.value) { add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کم‌مصرف خاموش) diff --git a/pubspec.lock b/pubspec.lock index 146c0d1..37902fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -569,7 +569,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" diff --git a/pubspec.yaml b/pubspec.yaml index fb1426c..6976bd1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: flutter_svg: ^2.3.0 # نمایشِ شخصیتِ آواتارِ svg (و png با Image.network) connectivity_plus: ^6.1.0 # تشخیصِ قطعِ اینترنت برای نمایشِ اوورلیِ «اتصال نیست» package_info_plus: ^8.1.1 # نمایشِ نسخه‌ی اپ در لابی + path_provider: 2.1.5 # کشِ دیسکیِ ماندگارِ تصاویرِ کارت (دانلودِ یک‌بار برای همیشه) # خرید درون‌برنامه‌ای: هر flavor یکی را استفاده می‌کند (مایکت/کافه‌بازار). myket_iap: ^1.1.11 flutter_poolakey: ^2.2.0