Initial flame_lua_runtime package

This commit is contained in:
gem
2026-06-07 22:53:58 +08:00
commit 733b2fb798
262 changed files with 28439 additions and 0 deletions

View 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;
}
}

View 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(),
};
}
}

View 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;
}
}
}

View 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;
}

View 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';
}