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 supportedLocales; final GameDisplayConfig display; final Map resources; final Map modules; static GamePackageManifest fromJsonString(String source) { return fromMap(jsonDecode(source) as Map); } static GamePackageManifest fromMap(Map map) { final resourcesValue = map['resources']; final resources = {}; 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.from(entry.value as Map), ); } } final modulesValue = map['modules']; final modules = {}; 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.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 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 _stringList( Map map, String key, { required List 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 = []; 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 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 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 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 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; } }