- Add RuntimeOptions.basePackages for loading framework packages before game package - Add ScriptEngine.loadPackages() for multi-package module merging - LuaDardoScriptEngine merges modules from all packages, game overrides framework - PackageActivationController loads base packages first, then game package - GamePackageManifest parses optional 'base' field - Update docs: README, quick-start, lua-package-format, architecture - Update all test mocks with loadPackages() implementation
280 lines
8.0 KiB
Dart
280 lines
8.0 KiB
Dart
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 {},
|
||
this.base,
|
||
});
|
||
|
||
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;
|
||
|
||
/// 依赖的框架包 gameId。加载时会先加载框架包,再加载游戏包。
|
||
final String? base;
|
||
|
||
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 base = map['base'] 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,
|
||
base: base,
|
||
);
|
||
}
|
||
|
||
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');
|
||
}
|
||
if (atlas != null && (atlas is! String || atlas.isEmpty)) {
|
||
throw const FormatException(
|
||
'image resource.atlas 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;
|
||
}
|
||
}
|