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,96 @@
import 'dart:io';
import 'package:flutter/services.dart';
import '../game/runtime_options.dart';
import 'game_package_manifest.dart';
class GamePackage {
const GamePackage.asset({
required this.rootPath,
required this.manifest,
this.runtimeLuaRoot = RuntimeOptions.defaultRuntimeLuaRoot,
}) : source = GamePackageSource.asset;
static const runtimeLuaPrefix = 'runtime:';
const GamePackage.file({
required this.rootPath,
required this.manifest,
this.runtimeLuaRoot = RuntimeOptions.defaultRuntimeLuaRoot,
}) : source = GamePackageSource.file;
final String rootPath;
final GamePackageSource source;
final GamePackageManifest manifest;
final String runtimeLuaRoot;
String get entryPath => _join(rootPath, manifest.entry);
bool get isAsset => source == GamePackageSource.asset;
Future<String> readText(String relativeOrAbsolutePath) async {
final runtimePath = _resolveRuntimeLuaPath(relativeOrAbsolutePath);
if (runtimePath != null) {
return rootBundle.loadString(runtimePath);
}
final path = _resolvePackagePath(relativeOrAbsolutePath);
if (isAsset) {
return rootBundle.loadString(path);
}
return File(path).readAsString();
}
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
final path = _resolvePackagePath(relativeOrAbsolutePath);
if (isAsset) {
return rootBundle.load(path);
}
final bytes = await File(path).readAsBytes();
return ByteData.sublistView(bytes);
}
String resolveResourcePath(String keyOrPath) {
final resource = manifest.resources[keyOrPath];
if (resource != null) {
return _join(rootPath, resource.path);
}
if (keyOrPath.startsWith(rootPath)) {
return keyOrPath;
}
if (keyOrPath.contains('/')) {
return _join(rootPath, keyOrPath);
}
return _join(_join(rootPath, manifest.assetsBase), keyOrPath);
}
String _resolvePackagePath(String relativeOrAbsolutePath) {
if (relativeOrAbsolutePath.startsWith(rootPath)) {
return relativeOrAbsolutePath;
}
return _join(rootPath, relativeOrAbsolutePath);
}
String? _resolveRuntimeLuaPath(String path) {
if (!path.startsWith(runtimeLuaPrefix)) {
return null;
}
final name = path.substring(runtimeLuaPrefix.length);
if (name.isEmpty || name.contains('/') || name.contains('..')) {
throw FormatException('Invalid runtime Lua module path: $path');
}
return _join(runtimeLuaRoot, name);
}
String _join(String left, String right) {
final normalizedLeft = left.endsWith('/')
? left.substring(0, left.length - 1)
: left;
final normalizedRight = right.startsWith('/') ? right.substring(1) : right;
return '$normalizedLeft/$normalizedRight';
}
}
enum GamePackageSource { asset, file }

View File

@@ -0,0 +1,231 @@
import '../audio/runtime_audio_manager.dart';
import '../models/game_diff.dart';
import '../resources/game_resource_manager.dart';
import '../scripting/script_engine.dart';
import 'game_package.dart';
import 'game_package_repository.dart';
import 'package_verifier.dart';
import 'stable_package_store.dart';
class PackageActivationController {
const PackageActivationController({
required this.repository,
required this.resources,
required this.scriptEngine,
this.audio,
this.runtimeApiVersion = 1,
this.store = const StablePackageStore(),
this.assetFallback = const AssetGamePackageRepository(),
this.resourceManagerFactory,
this.audioManagerFactory,
this.scriptEngineFactory,
});
final GamePackageRepository repository;
final GameResourceManager resources;
final ScriptEngine scriptEngine;
final RuntimeAudioManager? audio;
final int runtimeApiVersion;
final StablePackageStore store;
final GamePackageRepository assetFallback;
final GameResourceManager Function()? resourceManagerFactory;
final RuntimeAudioManager Function()? audioManagerFactory;
final ScriptEngine Function()? scriptEngineFactory;
Future<PackageActivationResult> activate({
required String gameId,
required Map<String, Object?> Function(GamePackage package) contextBuilder,
bool Function()? shouldContinue,
}) async {
final plan = await prepare(
gameId: gameId,
contextBuilder: contextBuilder,
shouldContinue: shouldContinue,
);
await commit(plan, shouldContinue: shouldContinue);
return PackageActivationResult.fromPlan(plan);
}
Future<PackageActivationPlan> prepare({
required String gameId,
required Map<String, Object?> Function(GamePackage package) contextBuilder,
bool Function()? shouldContinue,
}) async {
final verifier = PackageVerifier(runtimeApiVersion: runtimeApiVersion);
final candidates = await _candidatePackages(gameId, shouldContinue);
Object? lastError;
for (final candidate in candidates) {
try {
_ensureContinue(shouldContinue);
final plan = await _prepareCandidate(
candidate: candidate,
verifier: verifier,
contextBuilder: contextBuilder,
shouldContinue: shouldContinue,
);
return plan;
} catch (error) {
if (shouldContinue != null && !shouldContinue()) {
rethrow;
}
lastError = error;
}
}
throw StateError(
'No activatable package for $gameId. Last error: $lastError',
);
}
Future<void> commit(
PackageActivationPlan plan, {
bool Function()? shouldContinue,
}) async {
_ensureContinue(shouldContinue);
await store.markStable(plan.package);
_ensureContinue(shouldContinue);
}
Future<List<GamePackage>> _candidatePackages(
String gameId,
bool Function()? shouldContinue,
) async {
final candidates = <GamePackage>[];
try {
final package = await repository.load(gameId);
_ensureContinue(shouldContinue);
candidates.add(package);
} catch (_) {
// Continue with stable/fallback candidates.
}
_ensureContinue(shouldContinue);
final stable = await store.stablePackage(gameId);
if (stable != null && !_containsPackage(candidates, stable)) {
candidates.add(stable);
}
_ensureContinue(shouldContinue);
final previous = await store.previousStablePackage(gameId);
if (previous != null && !_containsPackage(candidates, previous)) {
candidates.add(previous);
}
_ensureContinue(shouldContinue);
final fallback = await assetFallback.load(gameId);
if (!_containsPackage(candidates, fallback)) {
candidates.add(fallback);
}
_ensureContinue(shouldContinue);
return candidates;
}
Future<PackageActivationPlan> _prepareCandidate({
required GamePackage candidate,
required PackageVerifier verifier,
required Map<String, Object?> Function(GamePackage package) contextBuilder,
required bool Function()? shouldContinue,
}) async {
final preparedResources = resourceManagerFactory?.call() ?? resources;
final preparedAudio = audioManagerFactory?.call() ?? audio;
final preparedScriptEngine = scriptEngineFactory?.call() ?? scriptEngine;
final ownsPreparedResources = preparedResources != resources;
final ownsPreparedAudio = preparedAudio != null && preparedAudio != audio;
try {
await verifier.verify(candidate);
_ensureContinue(shouldContinue);
await preparedResources.mount(candidate);
_ensureContinue(shouldContinue);
await preparedAudio?.mount(candidate);
_ensureContinue(shouldContinue);
await preparedScriptEngine.loadPackage(candidate);
_ensureContinue(shouldContinue);
final context = contextBuilder(candidate);
_ensureContinue(shouldContinue);
if (!preparedScriptEngine.smokeTest(context)) {
throw StateError('Lua package smoke_test returned false');
}
_ensureContinue(shouldContinue);
final diff = preparedScriptEngine.init(context);
_ensureContinue(shouldContinue);
return PackageActivationPlan(
package: candidate,
initialDiff: diff,
resources: preparedResources,
scriptEngine: preparedScriptEngine,
audio: preparedAudio,
);
} catch (_) {
if (ownsPreparedResources) {
preparedResources.dispose();
}
if (ownsPreparedAudio) {
preparedAudio.dispose();
}
rethrow;
}
}
void _ensureContinue(bool Function()? shouldContinue) {
if (shouldContinue != null && !shouldContinue()) {
throw StateError('Package activation cancelled');
}
}
bool _containsPackage(List<GamePackage> packages, GamePackage package) {
return packages.any(
(item) =>
item.source == package.source && item.rootPath == package.rootPath,
);
}
}
class PackageActivationPlan {
const PackageActivationPlan({
required this.package,
required this.initialDiff,
required this.resources,
required this.scriptEngine,
this.audio,
});
final GamePackage package;
final GameDiff initialDiff;
final GameResourceManager resources;
final ScriptEngine scriptEngine;
final RuntimeAudioManager? audio;
}
class PackageActivationResult {
const PackageActivationResult({
required this.package,
required this.initialDiff,
required this.resources,
required this.scriptEngine,
this.audio,
});
factory PackageActivationResult.fromPlan(PackageActivationPlan plan) {
return PackageActivationResult(
package: plan.package,
initialDiff: plan.initialDiff,
resources: plan.resources,
scriptEngine: plan.scriptEngine,
audio: plan.audio,
);
}
final GamePackage package;
final GameDiff initialDiff;
final GameResourceManager resources;
final ScriptEngine scriptEngine;
final RuntimeAudioManager? audio;
}

View File

@@ -0,0 +1,265 @@
import 'dart:convert';
import '../display/runtime_viewport.dart';
class GamePackageManifest {
const GamePackageManifest({
required this.gameId,
required this.name,
required this.version,
required this.runtimeApiVersion,
required this.entry,
required this.assetsBase,
this.defaultLocale = 'en',
this.supportedLocales = const ['en'],
this.display = const GameDisplayConfig(),
this.resources = const {},
this.modules = const {},
});
final String gameId;
final String name;
final String version;
final int runtimeApiVersion;
final String entry;
final String assetsBase;
final String defaultLocale;
final List<String> supportedLocales;
final GameDisplayConfig display;
final Map<String, GameResource> resources;
final Map<String, String> modules;
static GamePackageManifest fromJsonString(String source) {
return fromMap(jsonDecode(source) as Map<String, Object?>);
}
static GamePackageManifest fromMap(Map<String, Object?> map) {
final resourcesValue = map['resources'];
final resources = <String, GameResource>{};
if (resourcesValue is Map) {
for (final entry in resourcesValue.entries) {
if (entry.key is! String || entry.value is! Map) {
throw const FormatException('manifest.resources must be a map');
}
resources[entry.key as String] = GameResource.fromMap(
Map<String, Object?>.from(entry.value as Map),
);
}
}
final modulesValue = map['modules'];
final modules = <String, String>{};
if (modulesValue is Map) {
for (final entry in modulesValue.entries) {
if (entry.key is! String || entry.value is! String) {
throw const FormatException('manifest.modules must be a string map');
}
modules[entry.key as String] = entry.value as String;
}
}
final defaultLocale = (map['defaultLocale'] as String?) ?? 'en';
final supportedLocales = _stringList(
map,
'supportedLocales',
fallback: [defaultLocale],
);
if (!supportedLocales.contains(defaultLocale)) {
throw const FormatException(
'manifest.supportedLocales must include defaultLocale',
);
}
final displayValue = map['display'];
final display = displayValue == null
? const GameDisplayConfig()
: GameDisplayConfig.fromMap(
Map<String, Object?>.from(displayValue as Map),
);
return GamePackageManifest(
gameId: _string(map, 'gameId'),
name: _string(map, 'name'),
version: _string(map, 'version'),
runtimeApiVersion: _int(map, 'runtimeApiVersion'),
entry: _string(map, 'entry'),
assetsBase: (map['assetsBase'] as String?) ?? 'assets',
defaultLocale: defaultLocale,
supportedLocales: supportedLocales,
display: display,
resources: resources,
modules: modules,
);
}
static String _string(Map<String, Object?> map, String key) {
final value = map[key];
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('manifest.$key must be a non-empty string');
}
static List<String> _stringList(
Map<String, Object?> map,
String key, {
required List<String> fallback,
}) {
final value = map[key];
if (value == null) {
return fallback;
}
if (value is! List || value.isEmpty) {
throw FormatException('manifest.$key must be a non-empty string list');
}
final result = <String>[];
for (final item in value) {
if (item is! String || item.isEmpty) {
throw FormatException('manifest.$key must be a non-empty string list');
}
result.add(item);
}
return result;
}
static int _int(Map<String, Object?> map, String key) {
final value = map[key];
if (value is num) {
return value.toInt();
}
throw FormatException('manifest.$key must be an integer');
}
}
class GameDisplayConfig {
const GameDisplayConfig({
this.designWidth = 720,
this.designHeight = 720,
this.scaleMode = RuntimeScaleMode.fit,
});
final double designWidth;
final double designHeight;
final String scaleMode;
RuntimeViewportConfig toViewportConfig() {
return RuntimeViewportConfig(
designWidth: designWidth,
designHeight: designHeight,
scaleMode: scaleMode,
);
}
static GameDisplayConfig fromMap(Map<String, Object?> map) {
final designWidth = _number(map, 'designWidth', fallback: 720);
final designHeight = _number(map, 'designHeight', fallback: 720);
final scaleMode = (map['scaleMode'] as String?) ?? RuntimeScaleMode.fit;
if (designWidth <= 0 || designHeight <= 0) {
throw const FormatException('manifest.display design size must be > 0');
}
if (!RuntimeScaleMode.isSupported(scaleMode)) {
throw const FormatException('manifest.display.scaleMode is unsupported');
}
return GameDisplayConfig(
designWidth: designWidth,
designHeight: designHeight,
scaleMode: scaleMode,
);
}
static double _number(
Map<String, Object?> map,
String key, {
required double fallback,
}) {
final value = map[key];
if (value == null) {
return fallback;
}
if (value is num) {
return value.toDouble();
}
throw FormatException('manifest.display.$key must be a number');
}
}
class GameResource {
const GameResource({
required this.type,
required this.path,
this.preload = GameResourcePreload.required,
this.group,
this.atlas,
this.skeleton,
});
final String type;
final String path;
final String preload;
final String? group;
final String? atlas;
final String? skeleton;
static GameResource fromMap(Map<String, Object?> map) {
final type = map['type'];
final path = map['path'];
final atlas = map['atlas'];
final skeleton = map['skeleton'];
if (type is! String || type.isEmpty) {
throw const FormatException('resource.type must be a non-empty string');
}
if (!GameResourceType.isSupported(type)) {
throw const FormatException('resource.type is unsupported');
}
if (type == GameResourceType.spine) {
if (atlas is! String || atlas.isEmpty) {
throw const FormatException(
'spine resource.atlas must be a non-empty string',
);
}
if (skeleton is! String || skeleton.isEmpty) {
throw const FormatException(
'spine resource.skeleton must be a non-empty string',
);
}
} else if (path is! String || path.isEmpty) {
throw const FormatException('resource.path must be a non-empty string');
}
final preload = map['preload'] as String? ?? GameResourcePreload.required;
if (!GameResourcePreload.isSupported(preload)) {
throw const FormatException('resource.preload is unsupported');
}
final group = map['group'];
if (group != null && (group is! String || group.isEmpty)) {
throw const FormatException('resource.group must be a non-empty string');
}
return GameResource(
type: type,
path: path as String? ?? '',
preload: preload,
group: group as String?,
atlas: atlas as String?,
skeleton: skeleton as String?,
);
}
}
abstract final class GameResourceType {
static const image = 'image';
static const audio = 'audio';
static const spine = 'spine';
static bool isSupported(String value) {
return value == image || value == audio || value == spine;
}
}
abstract final class GameResourcePreload {
static const required = 'required';
static const lazy = 'lazy';
static const optional = 'optional';
static bool isSupported(String value) {
return value == required || value == lazy || value == optional;
}
}

View File

@@ -0,0 +1,226 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import '../game/runtime_options.dart';
import 'game_package.dart';
import 'game_package_manifest.dart';
import 'package_verifier.dart';
import 'stable_package_store.dart';
abstract interface class GamePackageRepository {
Future<GamePackage> load(String gameId);
}
class AssetGamePackageRepository implements GamePackageRepository {
const AssetGamePackageRepository({
this.basePath = 'assets/games',
this.runtimeOptions = const RuntimeOptions(),
});
final String basePath;
final RuntimeOptions runtimeOptions;
@override
Future<GamePackage> load(String gameId) async {
final root = '$basePath/$gameId';
final source = await rootBundle.loadString('$root/manifest.json');
return GamePackage.asset(
rootPath: root,
manifest: GamePackageManifest.fromJsonString(source),
runtimeLuaRoot: runtimeOptions.runtimeLuaRoot,
);
}
}
class RemoteGamePackageRepository implements GamePackageRepository {
RemoteGamePackageRepository({
required this.baseUri,
this.runtimeApiVersion = 1,
this.runtimeOptions = const RuntimeOptions(),
GamePackageRepository? fallback,
StablePackageStore? store,
http.Client? client,
}) : fallback =
fallback ??
AssetGamePackageRepository(runtimeOptions: runtimeOptions),
store = store ?? StablePackageStore(runtimeOptions: runtimeOptions),
_client = client;
final Uri baseUri;
final int runtimeApiVersion;
final RuntimeOptions runtimeOptions;
final GamePackageRepository fallback;
final StablePackageStore store;
final http.Client? _client;
@override
Future<GamePackage> load(String gameId) async {
final verifier = PackageVerifier(runtimeApiVersion: runtimeApiVersion);
final client = _client ?? http.Client();
final shouldCloseClient = _client == null;
try {
final package = await _loadRemoteCandidate(client, gameId);
await verifier.verify(package);
return package;
} catch (_) {
final stable = await store.stablePackage(gameId);
if (stable != null) {
try {
await verifier.verify(stable);
return stable;
} catch (_) {
final previous = await store.previousStablePackage(gameId);
if (previous != null) {
try {
await verifier.verify(previous);
return previous;
} catch (_) {
// Fall through to bundled fallback.
}
}
}
}
return fallback.load(gameId);
} finally {
if (shouldCloseClient) {
client.close();
}
}
}
Future<GamePackage> _loadRemoteCandidate(
http.Client client,
String gameId,
) async {
final remoteManifest = await _fetchRemoteManifest(client, gameId);
if (remoteManifest.gameId != gameId) {
throw const FormatException('Remote manifest gameId mismatch');
}
final packageRoot = await _downloadAndExtract(
client,
gameId,
remoteManifest,
);
final manifestFile = File(p.join(packageRoot.path, 'manifest.json'));
final packageManifest = GamePackageManifest.fromJsonString(
await manifestFile.readAsString(),
);
if (packageManifest.gameId != gameId) {
throw const FormatException('Package manifest gameId mismatch');
}
if (packageManifest.version != remoteManifest.version) {
throw const FormatException('Package manifest version mismatch');
}
return GamePackage.file(
rootPath: packageRoot.path,
manifest: packageManifest,
runtimeLuaRoot: runtimeOptions.runtimeLuaRoot,
);
}
Future<RemotePackageManifest> _fetchRemoteManifest(
http.Client client,
String gameId,
) async {
final uri = baseUri.resolve('$gameId/remote_manifest.json');
final response = await client.get(uri);
if (response.statusCode != 200) {
throw HttpException(
'Remote manifest failed: ${response.statusCode}',
uri: uri,
);
}
return RemotePackageManifest.fromMap(
jsonDecode(response.body) as Map<String, Object?>,
);
}
Future<Directory> _downloadAndExtract(
http.Client client,
String gameId,
RemotePackageManifest manifest,
) async {
final packageBytes = await _downloadPackage(client, manifest.packageUrl);
_verifySha256(packageBytes, manifest.sha256);
final packageRoot = await store.versionDirectory(gameId, manifest.version);
if (packageRoot.existsSync()) {
packageRoot.deleteSync(recursive: true);
}
packageRoot.createSync(recursive: true);
final archive = ZipDecoder().decodeBytes(packageBytes);
for (final file in archive.files) {
final targetPath = p.normalize(p.join(packageRoot.path, file.name));
if (!p.isWithin(packageRoot.path, targetPath) &&
targetPath != packageRoot.path) {
throw const FormatException('Unsafe zip entry path');
}
if (file.isFile) {
File(targetPath)
..createSync(recursive: true)
..writeAsBytesSync(file.content as List<int>);
} else {
Directory(targetPath).createSync(recursive: true);
}
}
return packageRoot;
}
Future<List<int>> _downloadPackage(http.Client client, Uri uri) async {
final response = await client.get(uri);
if (response.statusCode != 200) {
throw HttpException(
'Package download failed: ${response.statusCode}',
uri: uri,
);
}
return response.bodyBytes;
}
void _verifySha256(List<int> bytes, String expected) {
final actual = sha256.convert(bytes).toString();
if (actual != expected) {
throw const FormatException('Package sha256 mismatch');
}
}
}
class RemotePackageManifest {
const RemotePackageManifest({
required this.gameId,
required this.version,
required this.packageUrl,
required this.sha256,
});
final String gameId;
final String version;
final Uri packageUrl;
final String sha256;
static RemotePackageManifest fromMap(Map<String, Object?> map) {
return RemotePackageManifest(
gameId: _string(map, 'gameId'),
version: _string(map, 'version'),
packageUrl: Uri.parse(_string(map, 'packageUrl')),
sha256: _string(map, 'sha256'),
);
}
static String _string(Map<String, Object?> map, String key) {
final value = map[key];
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('remote_manifest.$key must be a non-empty string');
}
}

View File

@@ -0,0 +1,117 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'game_package.dart';
import 'game_package_manifest.dart';
class PackageVerifier {
const PackageVerifier({required this.runtimeApiVersion});
final int runtimeApiVersion;
Future<void> verify(GamePackage package) async {
_verifyManifest(package);
await _verifyEntry(package);
await _verifyDeclaredModules(package);
await _verifyDeclaredResources(package);
}
void _verifyManifest(GamePackage package) {
final manifest = package.manifest;
if (manifest.runtimeApiVersion > runtimeApiVersion) {
throw FormatException(
'Package runtimeApiVersion ${manifest.runtimeApiVersion} is newer than runtime $runtimeApiVersion',
);
}
if (manifest.gameId.isEmpty ||
manifest.version.isEmpty ||
manifest.entry.isEmpty) {
throw const FormatException('Package manifest is incomplete');
}
}
Future<void> _verifyEntry(GamePackage package) async {
final script = await package.readText(package.manifest.entry);
if (!script.contains('function init')) {
throw const FormatException('Lua package must define function init(ctx)');
}
if (!script.contains('function on_event')) {
throw const FormatException(
'Lua package must define function on_event(event)',
);
}
if (!script.contains('function smoke_test')) {
throw const FormatException(
'Lua package must define function smoke_test(ctx)',
);
}
}
Future<void> _verifyDeclaredModules(GamePackage package) async {
for (final entry in package.manifest.modules.entries) {
final name = entry.key;
final path = entry.value;
if (!_isSafeModuleName(name)) {
throw FormatException('Unsafe Lua module name: $name');
}
if (!_isSafeModulePath(path)) {
throw FormatException(
'Lua module path must be scripts/*.lua or runtime:*.lua: $path',
);
}
await package.readText(path);
}
}
bool _isSafeModuleName(String value) {
return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) &&
!value.contains('..') &&
!value.startsWith('.') &&
!value.endsWith('.');
}
bool _isSafeModulePath(String path) {
if (path.startsWith(GamePackage.runtimeLuaPrefix)) {
final name = path.substring(GamePackage.runtimeLuaPrefix.length);
return name.isNotEmpty &&
name.endsWith('.lua') &&
!name.contains('/') &&
!name.contains('..');
}
return path.startsWith('scripts/') &&
path.endsWith('.lua') &&
!path.contains('..');
}
Future<void> _verifyDeclaredResources(GamePackage package) async {
for (final resource in package.manifest.resources.values) {
final paths = _resourcePaths(resource);
for (final path in paths) {
if (path.contains('..')) {
throw const FormatException('Resource path must not contain ..');
}
if (package.isAsset) {
await package.readBytes(path);
continue;
}
final root = p.normalize(package.rootPath);
final target = p.normalize(p.join(root, path));
if (!p.isWithin(root, target) && target != root) {
throw const FormatException('Resource path escapes package root');
}
if (!File(target).existsSync()) {
throw FormatException('Missing declared resource: $path');
}
}
}
}
Iterable<String> _resourcePaths(GameResource resource) {
if (resource.type == GameResourceType.spine) {
return [resource.atlas!, resource.skeleton!];
}
return [resource.path];
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../game/runtime_options.dart';
import 'game_package.dart';
import 'game_package_manifest.dart';
class StablePackageStore {
const StablePackageStore({
RuntimeOptions runtimeOptions = const RuntimeOptions(),
}) : _runtimeOptions = runtimeOptions;
final RuntimeOptions _runtimeOptions;
Future<Directory> cacheRoot() async {
final support = await getApplicationSupportDirectory();
final root = Directory(p.join(support.path, 'flame_lua_packages'));
root.createSync(recursive: true);
return root;
}
Future<Directory> versionDirectory(String gameId, String version) async {
final root = await cacheRoot();
return Directory(p.join(root.path, gameId, version));
}
Future<void> markStable(GamePackage package) async {
if (package.source != GamePackageSource.file) {
return;
}
final marker = await _markerFile(package.manifest.gameId);
marker.createSync(recursive: true);
final previous = await stablePackage(package.manifest.gameId);
final data = {
'current': package.rootPath,
if (previous != null && previous.rootPath != package.rootPath)
'previous': previous.rootPath,
};
marker.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data));
}
Future<GamePackage?> stablePackage(String gameId) async {
final marker = await _markerFile(gameId);
if (!marker.existsSync()) {
return null;
}
final data =
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
return _packageFromPath(data['current']);
}
Future<GamePackage?> previousStablePackage(String gameId) async {
final marker = await _markerFile(gameId);
if (!marker.existsSync()) {
return null;
}
final data =
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
return _packageFromPath(data['previous']);
}
Future<File> _markerFile(String gameId) async {
final root = await cacheRoot();
return File(p.join(root.path, gameId, 'stable.json'));
}
GamePackage? _packageFromPath(Object? pathValue) {
if (pathValue is! String || pathValue.isEmpty) {
return null;
}
final manifestFile = File(p.join(pathValue, 'manifest.json'));
if (!manifestFile.existsSync()) {
return null;
}
return GamePackage.file(
rootPath: pathValue,
manifest: GamePackageManifest.fromJsonString(
manifestFile.readAsStringSync(),
),
runtimeLuaRoot: _runtimeOptions.runtimeLuaRoot,
);
}
}