Files
flutter_lua_runtime/lib/runtime/packages/game_package_manifest.dart
2026-06-09 12:49:01 +08:00

273 lines
7.8 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 {},
});
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');
}
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;
}
}