import 'dart:convert'; import 'dart:ui' as ui; import 'package:flame_spine/flame_spine.dart'; import '../diagnostics/runtime_diagnostics.dart'; import '../lifecycle/runtime_async_gate.dart'; import '../packages/game_package.dart'; import '../packages/game_package_manifest.dart'; import 'resource_load_limiter.dart'; // These part files only group GameResourceManager private helpers. The public // facade stays in GameResourceManager so callers do not depend on extensions. part 'game_resource_loading.dart'; part 'game_resource_debug.dart'; part 'game_resource_cache.dart'; class GameResourceManager { GameResourceManager({ RuntimeDiagnostics? diagnostics, int? maxCacheBytes, int? maxCacheEntries, int maxConcurrentLoads = 4, }) : _diagnostics = diagnostics, _maxCacheBytes = maxCacheBytes, _maxCacheEntries = maxCacheEntries, _loadLimiter = ResourceLoadLimiter(maxConcurrentLoads); final RuntimeDiagnostics? _diagnostics; final int? _maxCacheBytes; final int? _maxCacheEntries; final ResourceLoadLimiter _loadLimiter; final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true); GamePackage? _package; final Map _images = {}; final Map _textureAtlases = {}; int _cacheBytes = 0; int _accessCounter = 0; int get generation => _asyncGate.generation; bool get hasPackage => _package != null; GamePackage get package { final value = _package; if (value == null) { throw StateError('GameResourceManager has no active package'); } return value; } Future mount(GamePackage package) async { _releaseCachedImages(); _textureAtlases.clear(); _asyncGate.activate(); _package = package; await loadDeclaredTextureAtlases(package.manifest); await preloadDeclaredImages(package.manifest); await preloadDeclaredSpines(package.manifest); } void dispose() { _asyncGate.close(); _releaseCachedImages(); _textureAtlases.clear(); _package = null; } String resolve(String keyOrPath) { return package.resolveResourcePath(keyOrPath); } GameResourceState imageState(String keyOrPath) { final path = _tryResolve(keyOrPath); if (path == null) { return GameResourceState.failed; } return _images[path]?.state ?? GameResourceState.idle; } Object? imageError(String keyOrPath) { final path = _tryResolve(keyOrPath); if (path == null) { return StateError('GameResourceManager has no active package'); } return _images[path]?.lastError; } Map imagesDebugJson() { final activePackage = _package; final declaredPaths = {}; final resources = >[]; if (activePackage != null) { for (final entry in activePackage.manifest.resources.entries) { final resource = entry.value; if (resource.type != GameResourceType.image) { continue; } final path = activePackage.resolveResourcePath(entry.key); declaredPaths.add(path); resources.add( _imageRecordDebugJson( key: entry.key, path: path, preload: resource.preload, declared: true, ), ); } } for (final path in _images.keys) { if (declaredPaths.contains(path)) { continue; } resources.add( _imageRecordDebugJson( key: null, path: path, preload: null, declared: false, ), ); } return { 'generation': generation, 'hasPackage': activePackage != null, 'count': resources.length, 'activeLoads': _loadLimiter.activeCount, 'pendingLoads': _loadLimiter.pendingCount, 'resources': resources, }; } bool evictImage(String keyOrPath) { final path = _tryResolve(keyOrPath); if (path == null) { return false; } return _removeImageRecord(path); } Future retryImage(String keyOrPath) { evictImage(keyOrPath); return loadImage(keyOrPath); } Future loadImage(String? keyOrPath, {bool retain = false}) { return _loadImage(keyOrPath, failOnError: false, retain: retain); } RuntimeTextureFrame? textureFrame(String keyOrPath, String? frameName) { if (frameName == null || frameName.isEmpty) { return null; } final path = _tryResolve(keyOrPath); if (path == null) { return null; } return _textureAtlases[path]?.frames[frameName]; } Future createSpineComponent(String? keyOrPath) { return _createSpineComponent(keyOrPath); } bool retainImage(String keyOrPath, {int? generation}) { final path = _tryResolve(keyOrPath); if (path == null) { return false; } final record = _images[path]; if (record == null || record.state != GameResourceState.ready || (generation != null && record.generation != generation)) { return false; } record.refCount++; _touch(record); return true; } bool releaseImage(String keyOrPath, {int? generation}) { final path = _tryResolve(keyOrPath); if (path == null) { return false; } final record = _images[path]; if (record == null || (generation != null && record.generation != generation)) { return false; } if (record.refCount > 0) { record.refCount--; } _touch(record); _enforceImageBudget(); return true; } Future preloadGroup(String group, {bool failOnError = false}) async { final activePackage = _package; if (activePackage == null) { throw StateError('GameResourceManager has no active package'); } final futures = >[]; for (final entry in activePackage.manifest.resources.entries) { final resource = entry.value; if (resource.type == GameResourceType.image && resource.group == group) { futures.add( _loadImage(entry.key, failOnError: failOnError).then((_) {}), ); } if (resource.type == GameResourceType.spine && resource.group == group) { futures.add(_preloadSpine(entry.key, failOnError: failOnError)); } } await Future.wait(futures); } int evictGroup(String group) { final activePackage = _package; if (activePackage == null) { return 0; } var count = 0; for (final entry in activePackage.manifest.resources.entries) { final resource = entry.value; if (resource.type != GameResourceType.image || resource.group != group) { continue; } final path = activePackage.resolveResourcePath(entry.key); if (_removeImageRecord(path)) { count++; } } return count; } Future loadDeclaredTextureAtlases(GamePackageManifest manifest) async { final activePackage = _package; if (activePackage == null) { return; } for (final entry in manifest.resources.entries) { final resource = entry.value; final atlas = resource.atlas; if (resource.type != GameResourceType.image || atlas == null) { continue; } final imagePath = activePackage.resolveResourcePath(entry.key); final atlasPath = activePackage.resolveResourcePath(atlas); final source = await activePackage.readText(atlasPath); _textureAtlases[imagePath] = RuntimeTextureAtlas.fromJsonString(source); } } Future preloadDeclaredImages(GamePackageManifest manifest) async { final futures = >[]; for (final entry in manifest.resources.entries) { final resource = entry.value; if (resource.type != GameResourceType.image || resource.preload == GameResourcePreload.lazy) { continue; } final failOnError = resource.preload == GameResourcePreload.required; futures.add(_loadImage(entry.key, failOnError: failOnError).then((_) {})); } await Future.wait(futures); } Future preloadDeclaredSpines(GamePackageManifest manifest) async { final futures = >[]; for (final entry in manifest.resources.entries) { final resource = entry.value; if (resource.type != GameResourceType.spine || resource.preload == GameResourcePreload.lazy) { continue; } final failOnError = resource.preload == GameResourcePreload.required; futures.add(_preloadSpine(entry.key, failOnError: failOnError)); } await Future.wait(futures); } } class RuntimeTextureAtlas { const RuntimeTextureAtlas({required this.frames}); final Map frames; factory RuntimeTextureAtlas.fromJsonString(String source) { final value = jsonDecode(source); if (value is! Map) { throw const FormatException('Texture atlas JSON must be an object'); } final framesValue = value['frames']; final frames = {}; if (framesValue is Map) { for (final entry in framesValue.entries) { if (entry.key is! String || entry.value is! Map) { throw const FormatException('Texture atlas frames must be objects'); } frames[entry.key as String] = RuntimeTextureFrame.fromMap( Map.from(entry.value as Map), ); } } else if (framesValue is List) { for (final item in framesValue) { if (item is! Map) { throw const FormatException('Texture atlas frames must be objects'); } final map = Map.from(item); final filename = map['filename']; if (filename is! String || filename.isEmpty) { throw const FormatException( 'Texture atlas array frames require filename', ); } frames[filename] = RuntimeTextureFrame.fromMap(map); } } else { throw const FormatException('Texture atlas frames must be a map or list'); } return RuntimeTextureAtlas(frames: frames); } } class RuntimeTextureFrame { const RuntimeTextureFrame({ required this.x, required this.y, required this.width, required this.height, }); final double x; final double y; final double width; final double height; ui.Rect get rect => ui.Rect.fromLTWH(x, y, width, height); factory RuntimeTextureFrame.fromMap(Map map) { final rotated = map['rotated']; if (rotated == true) { throw const FormatException( 'Rotated TexturePacker frames are unsupported', ); } final frame = map['frame']; if (frame is! Map) { throw const FormatException('TexturePacker frame must be an object'); } final frameMap = Map.from(frame); return RuntimeTextureFrame( x: _number(frameMap, 'x'), y: _number(frameMap, 'y'), width: _positiveNumber(frameMap, 'w'), height: _positiveNumber(frameMap, 'h'), ); } static double _number(Map map, String key) { final value = map[key]; if (value is num) { return value.toDouble(); } throw FormatException('TexturePacker frame.$key must be a number'); } static double _positiveNumber(Map map, String key) { final value = _number(map, key); if (value <= 0) { throw FormatException('TexturePacker frame.$key must be > 0'); } return value; } } enum GameResourceState { idle, loading, ready, failed, disposed } class ResourceLoadException implements Exception { const ResourceLoadException(this.message); final String message; @override String toString() => 'ResourceLoadException: $message'; } class _ImageResourceRecord { _ImageResourceRecord({required this.generation}); final int generation; GameResourceState state = GameResourceState.idle; Future? inflight; ui.Image? image; Object? lastError; int estimatedBytes = 0; int refCount = 0; int lastUsed = 0; }