diff --git a/README.md b/README.md index e96b592..7436a9a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ It is designed for Flutter apps that want to host Lua-authored 2D games or inter - Runtime commands for movement, fading, scaling, rotation, sequencing, audio, resources, toast, clipboard, and Spine animation. - Shared Lua helper modules under `assets/runtime/lua/`. - Configurable Runtime Lua asset root via `RuntimeOptions.runtimeLuaRoot`. +- Multi-package loading: shared framework packages loaded once, game packages loaded on top. ## Example @@ -59,6 +60,18 @@ LuaGameWidget( ) ``` +With a shared framework package: + +```dart +LuaGameWidget( + gameId: 'ludo', + runtimeOptions: const RuntimeOptions( + runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua', + basePackages: ['_framework'], + ), +) +``` + Your app should provide game package assets such as: ```text @@ -105,7 +118,7 @@ For AI agents and maintainers, start with: - [`AGENTS.md`](AGENTS.md) — package boundaries, rules, public API, and validation commands. - [`docs/quick-start.md`](docs/quick-start.md) — host app integration. - [`docs/architecture.md`](docs/architecture.md) — Dart/Lua/Flame responsibilities. -- [`docs/lua-package-format.md`](docs/lua-package-format.md) — manifest and Lua package rules. +- [`docs/lua-package-format.md`](docs/lua-package-format.md) — manifest, Lua package rules, and multi-package loading. - [`docs/protocol.md`](docs/protocol.md) — RuntimeEvent, GameDiff, RuntimeNode, RuntimeCommand boundary. - [`docs/validation.md`](docs/validation.md) — checks, smoke tests, and release flow. diff --git a/docs/architecture.md b/docs/architecture.md index 45200e8..a51e8a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,6 +68,28 @@ Package dependency root: packages/flame_lua_runtime/assets/runtime/lua ``` +### Multi-package loading + +The runtime supports loading multiple packages in sequence, with module merging: + +```text +RuntimeOptions.basePackages = ['_framework'] + -> load _framework package (modules: app, diff, ids, net, ...) + -> load game package (modules: state, rules, main, ...) + -> merge into flat _moduleScripts map + -> game modules override framework modules on name collision + -> execute game entry script +``` + +Key classes: + +- `RuntimeOptions.basePackages` — ordered list of framework package IDs. +- `PackageActivationController._prepareCandidate()` — loads base packages first, then game package, passes combined list to `ScriptEngine.loadPackages()`. +- `LuaDardoScriptEngine.loadPackages()` — iterates all packages, merges `manifest.modules` into `_moduleScripts`, executes entry from last package. +- `GamePackageManifest.base` — optional metadata field declaring framework dependency. + +Module resolution is flat: `runtime.import("xxx")` looks up `_moduleScripts[xxx]`. Game modules and framework modules share the same namespace. Later-loaded packages win on collision. + ## Safety model - Lua module loading is manifest-declared. diff --git a/docs/lua-package-format.md b/docs/lua-package-format.md index 03eed38..a71d324 100644 --- a/docs/lua-package-format.md +++ b/docs/lua-package-format.md @@ -64,6 +64,53 @@ runtime:*.lua `runtime:` paths must not contain `/`, `..`, or an empty filename. +## Base packages + +A game manifest can declare a `base` field to indicate it depends on a framework package: + +```json +{ + "gameId": "ludo", + "base": "_framework", + "modules": { ... } +} +``` + +The `base` field is metadata. Actual loading is controlled by `RuntimeOptions.basePackages`: + +```dart +LuaGameWidget( + gameId: 'ludo', + runtimeOptions: const RuntimeOptions( + basePackages: ['_framework'], + ), +) +``` + +Loading order: + +1. Base packages are loaded first, in `basePackages` order. +2. The game package is loaded last. +3. All modules are merged into a flat map. +4. Later packages override earlier packages on name collision. +5. The entry script always comes from the last (game) package. + +This means a game can override any framework module by declaring a module with the same name in its own manifest. + +## Multi-package module resolution + +When Lua code calls `runtime.import("xxx")`: + +1. Look up `xxx` in the merged module map (game modules first, then framework). +2. If not found, throw `FormatException: Lua module is not declared in manifest.modules`. + +Framework modules are transparent to game code: + +```lua +local app = runtime.import("app") -- resolved from framework +local state = runtime.import("state") -- resolved from game +``` + ## Entry module The manifest `entry` module should expose lifecycle/event functions expected by the script engine. Keep game-specific state in Lua modules and return runtime diffs/commands through the approved protocol. diff --git a/docs/quick-start.md b/docs/quick-start.md index ed12c1e..7f84444 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -98,3 +98,77 @@ flutter run --dart-define=LUA_GAME_ID=flight } } ``` + +## Multi-package loading (base + game) + +When multiple games share common modules (event router, diff utilities, panel stack, network layer), extract them into a framework package and load it before the game package. + +Framework package structure: + +```text +assets/games/_framework/ + manifest.json + scripts/ + app.lua + diff.lua + event_router.lua + ids.lua + net.lua + panel_stack.lua +``` + +Framework manifest: + +```json +{ + "gameId": "_framework", + "name": "Lua Game Framework", + "version": "1.0.0", + "runtimeApiVersion": 1, + "entry": "scripts/app.lua", + "assetsBase": "assets", + "modules": { + "app": "scripts/app.lua", + "diff": "scripts/diff.lua", + "event_router": "scripts/event_router.lua", + "ids": "scripts/ids.lua", + "net": "scripts/net.lua", + "panel_stack": "scripts/panel_stack.lua" + } +} +``` + +Game manifest (no framework modules needed): + +```json +{ + "gameId": "ludo", + "base": "_framework", + "modules": { + "state": "scripts/state.lua", + "rules": "scripts/rules.lua", + "main": "scripts/main.lua" + } +} +``` + +Game code imports framework modules transparently: + +```lua +local app = runtime.import("app") -- from framework +local state = runtime.import("state") -- from game +``` + +Embed with base packages: + +```dart +LuaGameWidget( + gameId: 'ludo', + runtimeOptions: const RuntimeOptions( + runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua', + basePackages: ['_framework'], + ), +) +``` + +Module resolution order: game package modules override framework modules with the same name. The entry script always comes from the last package (game package). diff --git a/lib/runtime/game/flame_lua_game.dart b/lib/runtime/game/flame_lua_game.dart index 57bf56d..2b77b2e 100644 --- a/lib/runtime/game/flame_lua_game.dart +++ b/lib/runtime/game/flame_lua_game.dart @@ -148,6 +148,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { resources: _createResourceManager(), scriptEngine: _bootstrapScriptEngine, audio: _createAudioManager(), + runtimeOptions: runtimeOptions, resourceManagerFactory: _createResourceManager, audioManagerFactory: _createAudioManager, scriptEngineFactory: _scriptEngineFactory, diff --git a/lib/runtime/game/runtime_options.dart b/lib/runtime/game/runtime_options.dart index 746e8e9..d527121 100644 --- a/lib/runtime/game/runtime_options.dart +++ b/lib/runtime/game/runtime_options.dart @@ -1,7 +1,14 @@ class RuntimeOptions { - const RuntimeOptions({this.runtimeLuaRoot = defaultRuntimeLuaRoot}); + const RuntimeOptions({ + this.runtimeLuaRoot = defaultRuntimeLuaRoot, + this.basePackages = const [], + }); static const defaultRuntimeLuaRoot = 'assets/runtime/lua'; final String runtimeLuaRoot; + + // 框架包 gameId 列表,按顺序先于游戏包加载。 + // 后加载的同名模块覆盖先加载的。 + final List basePackages; } diff --git a/lib/runtime/packages/game_package_activation_controller.dart b/lib/runtime/packages/game_package_activation_controller.dart index 8aa8701..9741bd0 100644 --- a/lib/runtime/packages/game_package_activation_controller.dart +++ b/lib/runtime/packages/game_package_activation_controller.dart @@ -1,4 +1,5 @@ import '../audio/runtime_audio_manager.dart'; +import '../game/runtime_options.dart'; import '../models/game_diff.dart'; import '../resources/game_resource_manager.dart'; import '../scripting/runtime_script_services.dart'; @@ -15,6 +16,7 @@ class PackageActivationController { required this.scriptEngine, this.audio, this.runtimeApiVersion = 1, + this.runtimeOptions = const RuntimeOptions(), this.store = const StablePackageStore(), this.assetFallback = const AssetGamePackageRepository(), this.resourceManagerFactory, @@ -28,6 +30,7 @@ class PackageActivationController { final ScriptEngine scriptEngine; final RuntimeAudioManager? audio; final int runtimeApiVersion; + final RuntimeOptions runtimeOptions; final StablePackageStore store; final GamePackageRepository assetFallback; final GameResourceManager Function()? resourceManagerFactory; @@ -143,12 +146,34 @@ class PackageActivationController { try { await verifier.verify(candidate); _ensureContinue(shouldContinue); + + // 加载 base packages(框架包),按 runtimeOptions.basePackages 顺序。 + final basePackages = []; + for (final baseId in runtimeOptions.basePackages) { + final baseCandidates = await _candidatePackages( + baseId, + shouldContinue, + ); + for (final baseCandidate in baseCandidates) { + try { + await verifier.verify(baseCandidate); + basePackages.add(baseCandidate); + break; + } catch (_) { + // Try next candidate. + } + } + } + await preparedResources.mount(candidate); _ensureContinue(shouldContinue); await preparedAudio?.mount(candidate); _ensureContinue(shouldContinue); - await preparedScriptEngine.loadPackage( - candidate, + + // 合并 base + game 包,传给脚本引擎。 + final allPackages = [...basePackages, candidate]; + await preparedScriptEngine.loadPackages( + allPackages, services: scriptServices, ); _ensureContinue(shouldContinue); diff --git a/lib/runtime/packages/game_package_manifest.dart b/lib/runtime/packages/game_package_manifest.dart index ca07fce..b5bfe47 100644 --- a/lib/runtime/packages/game_package_manifest.dart +++ b/lib/runtime/packages/game_package_manifest.dart @@ -15,6 +15,7 @@ class GamePackageManifest { this.display = const GameDisplayConfig(), this.resources = const {}, this.modules = const {}, + this.base, }); final String gameId; @@ -29,6 +30,9 @@ class GamePackageManifest { final Map resources; final Map modules; + /// 依赖的框架包 gameId。加载时会先加载框架包,再加载游戏包。 + final String? base; + static GamePackageManifest fromJsonString(String source) { return fromMap(jsonDecode(source) as Map); } @@ -58,6 +62,8 @@ class GamePackageManifest { } } + final base = map['base'] as String?; + final defaultLocale = (map['defaultLocale'] as String?) ?? 'en'; final supportedLocales = _stringList( map, @@ -89,6 +95,7 @@ class GamePackageManifest { display: display, resources: resources, modules: modules, + base: base, ); } diff --git a/lib/runtime/scripting/lua_dardo_script_engine.dart b/lib/runtime/scripting/lua_dardo_script_engine.dart index a3fd433..6017397 100644 --- a/lib/runtime/scripting/lua_dardo_script_engine.dart +++ b/lib/runtime/scripting/lua_dardo_script_engine.dart @@ -27,15 +27,34 @@ class LuaDardoScriptEngine implements ScriptEngine { Future loadPackage( GamePackage package, { RuntimeScriptServices services = const RuntimeScriptServices(), + }) { + return loadPackages([package], services: services); + } + + @override + Future loadPackages( + List packages, { + RuntimeScriptServices services = const RuntimeScriptServices(), }) async { + if (packages.isEmpty) { + throw const FormatException('loadPackages requires at least one package'); + } + _services = services; _networkRequestCounter = 0; _hostCallCounter = 0; - final script = await package.readText(package.manifest.entry); _moduleScripts = {}; - for (final entry in package.manifest.modules.entries) { - _moduleScripts[entry.key] = await package.readText(entry.value); + + // 按顺序加载所有包的模块,后加载的同名模块覆盖先加载的。 + for (final package in packages) { + for (final entry in package.manifest.modules.entries) { + _moduleScripts[entry.key] = await package.readText(entry.value); + } } + + // 入口脚本使用最后一个包(游戏包)。 + final entryPackage = packages.last; + final script = await entryPackage.readText(entryPackage.manifest.entry); _loadingModules.clear(); _lua = LuaState.newState(); diff --git a/lib/runtime/scripting/script_engine.dart b/lib/runtime/scripting/script_engine.dart index 27fe34f..8f0af50 100644 --- a/lib/runtime/scripting/script_engine.dart +++ b/lib/runtime/scripting/script_engine.dart @@ -4,11 +4,19 @@ import '../packages/game_package.dart'; import 'runtime_script_services.dart'; abstract interface class ScriptEngine { + // 加载单个包(向后兼容,内部调 loadPackages([package]))。 Future loadPackage( GamePackage package, { RuntimeScriptServices services = const RuntimeScriptServices(), }); + // 加载多个包,按顺序合并模块,后加载的同名模块覆盖先加载的。 + // 入口脚本使用最后一个包。 + Future loadPackages( + List packages, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }); + bool smokeTest(Map context); GameDiff init(Map context); diff --git a/test/runtime/events/runtime_event_dispatcher_test.dart b/test/runtime/events/runtime_event_dispatcher_test.dart index fb8ab6f..ba44ade 100644 --- a/test/runtime/events/runtime_event_dispatcher_test.dart +++ b/test/runtime/events/runtime_event_dispatcher_test.dart @@ -156,6 +156,12 @@ class _FakeScriptEngine implements ScriptEngine { RuntimeScriptServices services = const RuntimeScriptServices(), }) async {} + @override + Future loadPackages( + List packages, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }) async {} + @override bool smokeTest(Map context) => true; diff --git a/test/runtime/game/flame_lua_game_test.dart b/test/runtime/game/flame_lua_game_test.dart index 1e0767a..1d20e0c 100644 --- a/test/runtime/game/flame_lua_game_test.dart +++ b/test/runtime/game/flame_lua_game_test.dart @@ -44,6 +44,14 @@ class _FakeScriptEngine implements ScriptEngine { throw UnimplementedError(); } + @override + Future loadPackages( + List packages, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }) { + throw UnimplementedError(); + } + @override GameDiff dispatchEvent(RuntimeEvent event) { throw UnimplementedError(); diff --git a/test/runtime/packages/game_package_activation_controller_test.dart b/test/runtime/packages/game_package_activation_controller_test.dart index 4a2364f..5327ea0 100644 --- a/test/runtime/packages/game_package_activation_controller_test.dart +++ b/test/runtime/packages/game_package_activation_controller_test.dart @@ -366,6 +366,17 @@ class _FakeScriptEngine implements ScriptEngine { loadedPackages.add(package.rootPath); } + @override + Future loadPackages( + List packages, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }) async { + for (final package in packages) { + _package = package; + loadedPackages.add(package.rootPath); + } + } + @override bool smokeTest(Map context) { return !smokeFailures.contains(_package?.rootPath);