408 lines
12 KiB
Dart
408 lines
12 KiB
Dart
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<String, _ImageResourceRecord> _images = {};
|
|
final Map<String, RuntimeTextureAtlas> _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<void> 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<String, Object?> imagesDebugJson() {
|
|
final activePackage = _package;
|
|
final declaredPaths = <String>{};
|
|
final resources = <Map<String, Object?>>[];
|
|
|
|
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<ui.Image?> retryImage(String keyOrPath) {
|
|
evictImage(keyOrPath);
|
|
return loadImage(keyOrPath);
|
|
}
|
|
|
|
Future<ui.Image?> 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<SpineComponent?> 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<void> preloadGroup(String group, {bool failOnError = false}) async {
|
|
final activePackage = _package;
|
|
if (activePackage == null) {
|
|
throw StateError('GameResourceManager has no active package');
|
|
}
|
|
final futures = <Future<void>>[];
|
|
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<void> 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<void> preloadDeclaredImages(GamePackageManifest manifest) async {
|
|
final futures = <Future<void>>[];
|
|
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<void> preloadDeclaredSpines(GamePackageManifest manifest) async {
|
|
final futures = <Future<void>>[];
|
|
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<String, RuntimeTextureFrame> 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 = <String, RuntimeTextureFrame>{};
|
|
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<String, Object?>.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<String, Object?>.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<String, Object?> 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<String, Object?>.from(frame);
|
|
return RuntimeTextureFrame(
|
|
x: _number(frameMap, 'x'),
|
|
y: _number(frameMap, 'y'),
|
|
width: _positiveNumber(frameMap, 'w'),
|
|
height: _positiveNumber(frameMap, 'h'),
|
|
);
|
|
}
|
|
|
|
static double _number(Map<String, Object?> 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<String, Object?> 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<ui.Image?>? inflight;
|
|
ui.Image? image;
|
|
Object? lastError;
|
|
int estimatedBytes = 0;
|
|
int refCount = 0;
|
|
int lastUsed = 0;
|
|
}
|