feat: multi-package loading with base framework support
- 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
This commit is contained in:
15
README.md
15
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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> basePackages;
|
||||
}
|
||||
|
||||
@@ -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 = <GamePackage>[];
|
||||
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);
|
||||
|
||||
@@ -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<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?>);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,34 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
||||
Future<void> loadPackage(
|
||||
GamePackage package, {
|
||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||
}) {
|
||||
return loadPackages([package], services: services);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadPackages(
|
||||
List<GamePackage> 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();
|
||||
|
||||
@@ -4,11 +4,19 @@ import '../packages/game_package.dart';
|
||||
import 'runtime_script_services.dart';
|
||||
|
||||
abstract interface class ScriptEngine {
|
||||
// 加载单个包(向后兼容,内部调 loadPackages([package]))。
|
||||
Future<void> loadPackage(
|
||||
GamePackage package, {
|
||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||
});
|
||||
|
||||
// 加载多个包,按顺序合并模块,后加载的同名模块覆盖先加载的。
|
||||
// 入口脚本使用最后一个包。
|
||||
Future<void> loadPackages(
|
||||
List<GamePackage> packages, {
|
||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||
});
|
||||
|
||||
bool smokeTest(Map<String, Object?> context);
|
||||
|
||||
GameDiff init(Map<String, Object?> context);
|
||||
|
||||
@@ -156,6 +156,12 @@ class _FakeScriptEngine implements ScriptEngine {
|
||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> loadPackages(
|
||||
List<GamePackage> packages, {
|
||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
bool smokeTest(Map<String, Object?> context) => true;
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ class _FakeScriptEngine implements ScriptEngine {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadPackages(
|
||||
List<GamePackage> packages, {
|
||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
GameDiff dispatchEvent(RuntimeEvent event) {
|
||||
throw UnimplementedError();
|
||||
|
||||
@@ -366,6 +366,17 @@ class _FakeScriptEngine implements ScriptEngine {
|
||||
loadedPackages.add(package.rootPath);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadPackages(
|
||||
List<GamePackage> packages, {
|
||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||
}) async {
|
||||
for (final package in packages) {
|
||||
_package = package;
|
||||
loadedPackages.add(package.rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool smokeTest(Map<String, Object?> context) {
|
||||
return !smokeFailures.contains(_package?.rootPath);
|
||||
|
||||
Reference in New Issue
Block a user