Initial flame_lua_runtime package
This commit is contained in:
66
lib/runtime/resources/game_resource_cache.dart
Normal file
66
lib/runtime/resources/game_resource_cache.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
part of 'game_resource_manager.dart';
|
||||
|
||||
extension _GameResourceManagerCache on GameResourceManager {
|
||||
void _touch(_ImageResourceRecord record) {
|
||||
record.lastUsed = ++_accessCounter;
|
||||
}
|
||||
|
||||
void _enforceImageBudget() {
|
||||
while (_isOverBudget()) {
|
||||
final victim = _leastRecentlyUsedEvictableImage();
|
||||
if (victim == null || !_removeImageRecord(victim)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isOverBudget() {
|
||||
final maxBytes = _maxCacheBytes;
|
||||
final maxEntries = _maxCacheEntries;
|
||||
return (maxBytes != null && _cacheBytes > maxBytes) ||
|
||||
(maxEntries != null && _readyImageCount > maxEntries);
|
||||
}
|
||||
|
||||
int get _readyImageCount => _images.values
|
||||
.where((record) => record.state == GameResourceState.ready)
|
||||
.length;
|
||||
|
||||
String? _leastRecentlyUsedEvictableImage() {
|
||||
String? victimPath;
|
||||
_ImageResourceRecord? victim;
|
||||
for (final entry in _images.entries) {
|
||||
final record = entry.value;
|
||||
if (record.state != GameResourceState.ready || record.refCount > 0) {
|
||||
continue;
|
||||
}
|
||||
if (victim == null || record.lastUsed < victim.lastUsed) {
|
||||
victim = record;
|
||||
victimPath = entry.key;
|
||||
}
|
||||
}
|
||||
return victimPath;
|
||||
}
|
||||
|
||||
void _releaseCachedImages() {
|
||||
_loadLimiter.clearPending();
|
||||
for (final path in _images.keys.toList(growable: false)) {
|
||||
_removeImageRecord(path);
|
||||
}
|
||||
}
|
||||
|
||||
bool _removeImageRecord(String path) {
|
||||
final record = _images.remove(path);
|
||||
if (record == null) {
|
||||
return false;
|
||||
}
|
||||
record.state = GameResourceState.disposed;
|
||||
_cacheBytes -= record.estimatedBytes;
|
||||
if (_cacheBytes < 0) {
|
||||
_cacheBytes = 0;
|
||||
}
|
||||
record.image?.dispose();
|
||||
record.image = null;
|
||||
record.inflight = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
28
lib/runtime/resources/game_resource_debug.dart
Normal file
28
lib/runtime/resources/game_resource_debug.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
part of 'game_resource_manager.dart';
|
||||
|
||||
extension _GameResourceManagerDebug on GameResourceManager {
|
||||
Map<String, Object?> _imageRecordDebugJson({
|
||||
required String? key,
|
||||
required String path,
|
||||
required String? preload,
|
||||
required bool declared,
|
||||
}) {
|
||||
final record = _images[path];
|
||||
return {
|
||||
if (key != null) 'key': key,
|
||||
'path': path,
|
||||
'type': GameResourceType.image,
|
||||
'declared': declared,
|
||||
if (preload != null) 'preload': preload,
|
||||
if (key != null && _package?.manifest.resources[key]?.group != null)
|
||||
'group': _package?.manifest.resources[key]?.group,
|
||||
'state': (record?.state ?? GameResourceState.idle).name,
|
||||
if (record != null) 'generation': record.generation,
|
||||
'loading': record?.inflight != null,
|
||||
'ready': record?.image != null,
|
||||
if (record != null) 'refCount': record.refCount,
|
||||
if (record != null) 'bytes': record.estimatedBytes,
|
||||
if (record?.lastError != null) 'error': record!.lastError.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
186
lib/runtime/resources/game_resource_loading.dart
Normal file
186
lib/runtime/resources/game_resource_loading.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
part of 'game_resource_manager.dart';
|
||||
|
||||
extension _GameResourceManagerLoading on GameResourceManager {
|
||||
Future<ui.Image?> _loadImage(
|
||||
String? keyOrPath, {
|
||||
required bool failOnError,
|
||||
bool retain = false,
|
||||
}) {
|
||||
if (keyOrPath == null || keyOrPath.isEmpty) {
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
final requestToken = _asyncGate.token;
|
||||
final requestGeneration = requestToken.generation;
|
||||
final path = _tryResolve(keyOrPath);
|
||||
if (path == null) {
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
final existing = _images[path];
|
||||
if (existing != null) {
|
||||
final image = existing.image;
|
||||
if (existing.generation == requestGeneration &&
|
||||
existing.state == GameResourceState.ready &&
|
||||
image != null) {
|
||||
if (retain) {
|
||||
existing.refCount++;
|
||||
}
|
||||
_touch(existing);
|
||||
return Future.value(image);
|
||||
}
|
||||
final inflight = existing.inflight;
|
||||
if (existing.generation == requestGeneration && inflight != null) {
|
||||
return failOnError
|
||||
? _throwIfNull(inflight, keyOrPath)
|
||||
: inflight.catchError((_) => null);
|
||||
}
|
||||
}
|
||||
|
||||
final record = _ImageResourceRecord(generation: requestGeneration)
|
||||
..state = GameResourceState.loading;
|
||||
_images[path] = record;
|
||||
|
||||
final future = _decodeImage(path, record, requestToken, retain: retain);
|
||||
record.inflight = future;
|
||||
return failOnError ? _throwIfNull(future, keyOrPath) : future;
|
||||
}
|
||||
|
||||
Future<ui.Image?> _throwIfNull(
|
||||
Future<ui.Image?> future,
|
||||
String keyOrPath,
|
||||
) async {
|
||||
final image = await future;
|
||||
if (image == null) {
|
||||
throw ResourceLoadException('Required image resource failed: $keyOrPath');
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
Future<ui.Image?> _decodeImage(
|
||||
String path,
|
||||
_ImageResourceRecord record,
|
||||
RuntimeAsyncToken requestToken, {
|
||||
required bool retain,
|
||||
}) async {
|
||||
try {
|
||||
final activePackage = _package;
|
||||
if (activePackage == null) {
|
||||
throw StateError('GameResourceManager has no active package');
|
||||
}
|
||||
|
||||
final frame = await _loadLimiter.run(() async {
|
||||
final bytes = await activePackage.readBytes(path);
|
||||
final codec = await ui.instantiateImageCodec(
|
||||
bytes.buffer.asUint8List(),
|
||||
);
|
||||
return codec.getNextFrame();
|
||||
});
|
||||
record.inflight = null;
|
||||
|
||||
if (!_asyncGate.accepts(requestToken) || _images[path] != record) {
|
||||
frame.image.dispose();
|
||||
record.state = GameResourceState.disposed;
|
||||
return null;
|
||||
}
|
||||
|
||||
record
|
||||
..image = frame.image
|
||||
..estimatedBytes = frame.image.width * frame.image.height * 4
|
||||
..state = GameResourceState.ready
|
||||
..lastError = null;
|
||||
if (retain) {
|
||||
record.refCount++;
|
||||
}
|
||||
_cacheBytes += record.estimatedBytes;
|
||||
_touch(record);
|
||||
_enforceImageBudget();
|
||||
return frame.image;
|
||||
} catch (error) {
|
||||
record.inflight = null;
|
||||
if (!_asyncGate.accepts(requestToken) || _images[path] != record) {
|
||||
record.state = GameResourceState.disposed;
|
||||
return null;
|
||||
}
|
||||
record
|
||||
..state = GameResourceState.failed
|
||||
..lastError = error;
|
||||
_diagnostics?.record(
|
||||
type: RuntimeDiagnosticType.resourceLoadError,
|
||||
message: 'Image resource failed to load',
|
||||
error: error,
|
||||
context: {'path': path, 'generation': requestToken.generation},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadSpine(
|
||||
String keyOrPath, {
|
||||
required bool failOnError,
|
||||
}) async {
|
||||
final spine = await _createSpineComponent(keyOrPath);
|
||||
spine?.dispose();
|
||||
if (failOnError && spine == null) {
|
||||
throw ResourceLoadException('Required spine resource failed: $keyOrPath');
|
||||
}
|
||||
}
|
||||
|
||||
Future<SpineComponent?> _createSpineComponent(String? keyOrPath) async {
|
||||
if (keyOrPath == null || keyOrPath.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final requestToken = _asyncGate.token;
|
||||
final activePackage = _package;
|
||||
if (activePackage == null) {
|
||||
return null;
|
||||
}
|
||||
final resource = activePackage.manifest.resources[keyOrPath];
|
||||
if (resource == null || resource.type != GameResourceType.spine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await initSpineFlutter();
|
||||
final atlasPath = activePackage.resolveResourcePath(resource.atlas!);
|
||||
final skeletonPath = activePackage.resolveResourcePath(
|
||||
resource.skeleton!,
|
||||
);
|
||||
final drawable = await _loadLimiter.run(() {
|
||||
return SkeletonDrawableFlutter.fromMemory(atlasPath, skeletonPath, (
|
||||
name,
|
||||
) async {
|
||||
final bytes = await activePackage.readBytes(name);
|
||||
return bytes.buffer.asUint8List(
|
||||
bytes.offsetInBytes,
|
||||
bytes.lengthInBytes,
|
||||
);
|
||||
});
|
||||
});
|
||||
if (!_asyncGate.accepts(requestToken)) {
|
||||
drawable.dispose();
|
||||
return null;
|
||||
}
|
||||
return SpineComponent(drawable);
|
||||
} catch (error) {
|
||||
if (!_asyncGate.accepts(requestToken)) {
|
||||
return null;
|
||||
}
|
||||
_diagnostics?.record(
|
||||
type: RuntimeDiagnosticType.resourceLoadError,
|
||||
message: 'Spine resource failed to load',
|
||||
error: error,
|
||||
context: {'key': keyOrPath, 'generation': requestToken.generation},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _tryResolve(String keyOrPath) {
|
||||
try {
|
||||
return resolve(keyOrPath);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
279
lib/runtime/resources/game_resource_manager.dart
Normal file
279
lib/runtime/resources/game_resource_manager.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
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 = {};
|
||||
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();
|
||||
_asyncGate.activate();
|
||||
_package = package;
|
||||
await preloadDeclaredImages(package.manifest);
|
||||
await preloadDeclaredSpines(package.manifest);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_asyncGate.close();
|
||||
_releaseCachedImages();
|
||||
_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);
|
||||
}
|
||||
|
||||
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> 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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
91
lib/runtime/resources/resource_load_limiter.dart
Normal file
91
lib/runtime/resources/resource_load_limiter.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'dart:async' as async;
|
||||
import 'dart:collection';
|
||||
|
||||
class ResourceLoadLimiter {
|
||||
ResourceLoadLimiter(int maxConcurrent)
|
||||
: maxConcurrent = _validateMaxConcurrent(maxConcurrent);
|
||||
|
||||
final int maxConcurrent;
|
||||
final Queue<_QueuedLoadBase> _queue = Queue<_QueuedLoadBase>();
|
||||
var _active = 0;
|
||||
|
||||
int get activeCount => _active;
|
||||
|
||||
int get pendingCount => _queue.length;
|
||||
|
||||
Future<T> run<T>(Future<T> Function() task) {
|
||||
final queued = _QueuedLoad<T>(task);
|
||||
_queue.add(queued);
|
||||
_pump();
|
||||
return queued.completer.future;
|
||||
}
|
||||
|
||||
void clearPending() {
|
||||
while (_queue.isNotEmpty) {
|
||||
_queue.removeFirst().cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _pump() {
|
||||
while (_active < maxConcurrent && _queue.isNotEmpty) {
|
||||
final queued = _queue.removeFirst();
|
||||
_active++;
|
||||
async.unawaited(_runQueued(queued));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runQueued(_QueuedLoadBase queued) async {
|
||||
try {
|
||||
await queued.run();
|
||||
} finally {
|
||||
_active--;
|
||||
_pump();
|
||||
}
|
||||
}
|
||||
|
||||
static int _validateMaxConcurrent(int value) {
|
||||
if (value < 1) {
|
||||
throw ArgumentError.value(value, 'maxConcurrent', 'must be >= 1');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _QueuedLoadBase {
|
||||
Future<void> run();
|
||||
|
||||
void cancel();
|
||||
}
|
||||
|
||||
class _QueuedLoad<T> implements _QueuedLoadBase {
|
||||
_QueuedLoad(this._task);
|
||||
|
||||
final Future<T> Function() _task;
|
||||
final completer = async.Completer<T>();
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
if (completer.isCompleted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
completer.complete(await _task());
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void cancel() {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(const ResourceLoadCancelledException());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceLoadCancelledException implements Exception {
|
||||
const ResourceLoadCancelledException();
|
||||
|
||||
@override
|
||||
String toString() => 'ResourceLoadCancelledException';
|
||||
}
|
||||
Reference in New Issue
Block a user