Compare commits

...

8 Commits

Author SHA1 Message Date
gem
4ea3663853 feat: add runtime i18n API with manifest translations 2026-06-12 10:19:14 +08:00
gem
79ee35db2f feat: add package source compatibility controls 2026-06-10 17:54:12 +08:00
gem
6608d0a975 feat: add Lua runtime storage API 2026-06-10 11:31:17 +08:00
gem
8ddc3be3a7 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
2026-06-10 00:04:00 +08:00
gem
0d4fbd030c Add bidirectional host bridge 2026-06-09 16:26:37 +08:00
gem
7b3c5cb0f5 Add runtime networking APIs 2026-06-09 16:09:19 +08:00
gem
4f36d68b74 Format nine-slice rendering test 2026-06-09 16:04:37 +08:00
gem
220bb0aba1 Improve nine-slice atlas sampling 2026-06-09 14:49:17 +08:00
38 changed files with 2789 additions and 37 deletions

View File

@@ -133,6 +133,8 @@ A warning about missing `homepage` / `repository` is acceptable until real publi
## Development rules ## Development rules
- Keep protocol fields white-listed and explicit. - Keep protocol fields white-listed and explicit.
- Write code that ordinary maintainers can safely modify: keep control flow obvious, names explicit, abstractions shallow, and avoid clever or hidden behavior.
- Remove deprecated, unused, or superseded code promptly when replacing behavior; do not leave parallel old paths unless a documented compatibility window requires them.
- Prefer simple data models over implicit behavior. - Prefer simple data models over implicit behavior.
- Runtime commands must be generic, not game-specific. - Runtime commands must be generic, not game-specific.
- Lua helper aliases are allowed only if normalized before protocol validation. - Lua helper aliases are allowed only if normalized before protocol validation.

View File

@@ -3,7 +3,9 @@
## Unreleased ## Unreleased
- Added TexturePacker frame, manual source-region, and nine-slice image rendering fields for image-capable nodes. - Added TexturePacker frame, manual source-region, and nine-slice image rendering fields for image-capable nodes.
- Fixed nine-slice image seams by overlapping destination slices during runtime rendering. - Added Runtime host bridge APIs for bidirectional Flutter host calls and Lua notifications.
- Added Runtime Lua networking APIs for async HTTP/HTTPS requests and WS/WSS connections.
- Fixed nine-slice image seams by overlapping destination slices and using inset source sampling during runtime rendering.
- Fixed Runtime alpha inheritance so parent fade commands apply to the full child subtree. - Fixed Runtime alpha inheritance so parent fade commands apply to the full child subtree.
- Added Runtime text shadow fields for text-capable nodes. - Added Runtime text shadow fields for text-capable nodes.
- Fixed Runtime node color alpha composition so `#AARRGGBB` alpha now multiplies with node/runtime alpha instead of being overwritten. - Fixed Runtime node color alpha composition so `#AARRGGBB` alpha now multiplies with node/runtime alpha instead of being overwritten.

View File

@@ -19,6 +19,9 @@ 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. - Runtime commands for movement, fading, scaling, rotation, sequencing, audio, resources, toast, clipboard, and Spine animation.
- Shared Lua helper modules under `assets/runtime/lua/`. - Shared Lua helper modules under `assets/runtime/lua/`.
- Configurable Runtime Lua asset root via `RuntimeOptions.runtimeLuaRoot`. - Configurable Runtime Lua asset root via `RuntimeOptions.runtimeLuaRoot`.
- Multi-package loading: shared framework packages loaded once, game packages loaded on top.
- Asset, local file, and remote package repositories for bundled, development, and hot-update workflows.
- Remote package compatibility checks for Runtime version, host build, platform, and release channel.
## Example ## Example
@@ -59,6 +62,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: Your app should provide game package assets such as:
```text ```text
@@ -82,6 +97,14 @@ The game manifest declares package-local scripts and shared Runtime Lua modules:
} }
``` ```
## Package loading modes
- `AssetGamePackageRepository`: bundled app assets and fallback packages.
- `FileGamePackageRepository`: local development directory, useful when large images should not be bundled into the app during iteration.
- `RemoteGamePackageRepository`: remote zip packages with sha256 verification, compatibility checks, and stable cache fallback.
Remote compatibility is configured with `RuntimeOptions.runtimeVersion`, `hostBuild`, `platform`, and `channel`.
## Runtime asset path ## Runtime asset path
When used as a published package, configure: When used as a published package, configure:
@@ -105,7 +128,7 @@ For AI agents and maintainers, start with:
- [`AGENTS.md`](AGENTS.md) — package boundaries, rules, public API, and validation commands. - [`AGENTS.md`](AGENTS.md) — package boundaries, rules, public API, and validation commands.
- [`docs/quick-start.md`](docs/quick-start.md) — host app integration. - [`docs/quick-start.md`](docs/quick-start.md) — host app integration.
- [`docs/architecture.md`](docs/architecture.md) — Dart/Lua/Flame responsibilities. - [`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/protocol.md`](docs/protocol.md) — RuntimeEvent, GameDiff, RuntimeNode, RuntimeCommand boundary.
- [`docs/validation.md`](docs/validation.md) — checks, smoke tests, and release flow. - [`docs/validation.md`](docs/validation.md) — checks, smoke tests, and release flow.

View File

@@ -68,6 +68,28 @@ Package dependency root:
packages/flame_lua_runtime/assets/runtime/lua 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 ## Safety model
- Lua module loading is manifest-declared. - Lua module loading is manifest-declared.

View File

@@ -64,6 +64,53 @@ runtime:*.lua
`runtime:` paths must not contain `/`, `..`, or an empty filename. `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 ## 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. 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.
@@ -95,6 +142,80 @@ Check without rewriting:
dart run tool/generate_lua_runtime_defs.dart --check dart run tool/generate_lua_runtime_defs.dart --check
``` ```
## Package sources
Host apps can load packages from three common sources:
```dart
// Bundled app assets.
AssetGamePackageRepository(runtimeOptions: runtimeOptions)
// Local development directory, useful when images should not be bundled into app assets.
FileGamePackageRepository(
baseDirectory: 'E:/lua_packages',
runtimeOptions: runtimeOptions,
)
// Remote update server.
RemoteGamePackageRepository(
baseUri: Uri.parse('https://example.com/lua-packages/'),
runtimeOptions: runtimeOptions,
)
```
A local development directory uses the same package layout as a downloaded remote zip:
```text
E:/lua_packages/gomoku/
manifest.json
scripts/
assets/
```
## Remote compatibility
Remote manifests may include a `compat` block. The server should use request query values to return the newest compatible package, and the client validates the returned manifest again before download.
```json
{
"gameId": "gomoku",
"version": "0.3.0",
"packageUrl": "https://example.com/packages/gomoku-0.3.0.zip",
"sha256": "...",
"compat": {
"runtimeApiVersion": 1,
"minRuntimeVersion": "0.4.0",
"maxRuntimeVersion": "0.4.9",
"minHostBuild": 120,
"platforms": ["windows", "android"],
"channels": ["dev", "prod"]
}
}
```
`RemoteGamePackageRepository` sends these query parameters when fetching `remote_manifest.json`:
```text
runtimeApiVersion
runtimeVersion
hostBuild
platform
channel
```
Configure them through `RuntimeOptions`:
```dart
RuntimeOptions(
runtimeVersion: '0.4.0',
hostBuild: 120,
platform: 'windows',
channel: 'dev',
)
```
If compatibility fails, the remote package is not downloaded. The repository falls back to stable cache, previous stable cache, then bundled assets.
## Package validation ## Package validation
A host repository can validate a game package with: A host repository can validate a game package with:

View File

@@ -73,6 +73,41 @@ Image-capable nodes may also use nine-slice scaling with source-pixel insets:
Nine-slice keeps corners unscaled, stretches edges on one axis, and stretches the center on both axes. Insets are clamped to the selected source region and destination size. Nine-slice keeps corners unscaled, stretches edges on one axis, and stretches the center on both axes. Insets are clamped to the selected source region and destination size.
## Runtime network API
Lua may use runtime-owned async networking without blocking script execution:
- `runtime.http_request({ id?, method?, url, headers?, body?, timeout? })`
- `runtime.ws_connect({ id?, url, protocols? })`
- `runtime.ws_send(id, message)`
- `runtime.ws_close(id)`
HTTP requests support `http` and `https` URLs. WebSocket connections support `ws` and `wss` URLs.
Network results are delivered back to Lua through `on_event(event)`:
- `network_http`: HTTP request completed or failed. `event.data` includes `id`, `url`, `method`, `ok`, and either `status`/`headers`/`body` or `error`.
- `network_ws_open`: WebSocket connection opened.
- `network_ws_message`: WebSocket message received.
- `network_ws_error`: WebSocket connection or stream error.
- `network_ws_close`: WebSocket connection closed.
## Runtime host bridge
Flutter host apps may register a `RuntimeHostBridge` when creating `LuaGameWidget` or `FlameLuaGame`.
Lua-to-Flutter calls:
- `runtime.host_call({ id?, method, data? })`: async request. Result is delivered to Lua as `host_call_result` with `id`, `method`, `ok`, and either `result` or `error`.
- `runtime.host_notify({ method, data? })`: fire-and-forget notification to Flutter host code.
Flutter-to-Lua calls:
- `FlameLuaGame.notifyLua(method, data?)`: emits a `host_notify` event into Lua.
- `FlameLuaGame.callLua(method, data?, timeout?)`: emits a `host_call` event into Lua and waits for Lua to call `runtime.host_respond({ id, result?, error? })`.
Host bridge payloads must be JSON-like values: null, bool, number, string, list, or string-keyed map. Unsupported Dart objects are converted to strings.
## RuntimeCommand ## RuntimeCommand
Runtime commands request generic side effects owned by Dart/Flame. Runtime commands request generic side effects owned by Dart/Flame.

View File

@@ -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).

View File

@@ -64,6 +64,14 @@
---| 'animation_done' ---| 'animation_done'
---| 'resize' ---| 'resize'
---| 'scroll' ---| 'scroll'
---| 'network_http'
---| 'network_ws_open'
---| 'network_ws_message'
---| 'network_ws_error'
---| 'network_ws_close'
---| 'host_notify'
---| 'host_call'
---| 'host_call_result'
---@alias RuntimeScaleMode ---@alias RuntimeScaleMode
---| 'fit' ---| 'fit'
@@ -580,9 +588,43 @@
---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_group fun(group: string): RuntimeCommand
---@field cancel_scope fun(scope: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand
---@class RuntimeHttpRequestOptions
---@field id? string
---@field method? string HTTP method. Defaults to GET.
---@field url string http/https URL.
---@field headers? table<string, string>
---@field body? string
---@field timeout? number Timeout in seconds. Defaults to 15.
---@class RuntimeWsConnectOptions
---@field id? string
---@field url string ws/wss URL.
---@field protocols? string[]
---@class RuntimeHostCallOptions
---@field id? string
---@field method string Host method name registered by Flutter.
---@field data? any
---@class RuntimeHostNotifyOptions
---@field method string Host notification name registered by Flutter.
---@field data? any
---@class RuntimeHostRespondOptions
---@field id string Host-to-Lua call id from host_call event.
---@field result? any
---@field error? string
---@class RuntimeImportApi ---@class RuntimeImportApi
---@field import fun(moduleName: string): table ---@field import fun(moduleName: string): table
---@field log fun(...: any) ---@field log fun(...: any)
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
---@field ws_send fun(id: string, message: string): boolean
---@field ws_close fun(id: string): boolean
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
---@type RuntimeImportApi ---@type RuntimeImportApi
runtime = runtime runtime = runtime

View File

@@ -64,6 +64,14 @@
---| 'animation_done' ---| 'animation_done'
---| 'resize' ---| 'resize'
---| 'scroll' ---| 'scroll'
---| 'network_http'
---| 'network_ws_open'
---| 'network_ws_message'
---| 'network_ws_error'
---| 'network_ws_close'
---| 'host_notify'
---| 'host_call'
---| 'host_call_result'
---@alias RuntimeScaleMode ---@alias RuntimeScaleMode
---| 'fit' ---| 'fit'
@@ -580,9 +588,43 @@
---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_group fun(group: string): RuntimeCommand
---@field cancel_scope fun(scope: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand
---@class RuntimeHttpRequestOptions
---@field id? string
---@field method? string HTTP method. Defaults to GET.
---@field url string http/https URL.
---@field headers? table<string, string>
---@field body? string
---@field timeout? number Timeout in seconds. Defaults to 15.
---@class RuntimeWsConnectOptions
---@field id? string
---@field url string ws/wss URL.
---@field protocols? string[]
---@class RuntimeHostCallOptions
---@field id? string
---@field method string Host method name registered by Flutter.
---@field data? any
---@class RuntimeHostNotifyOptions
---@field method string Host notification name registered by Flutter.
---@field data? any
---@class RuntimeHostRespondOptions
---@field id string Host-to-Lua call id from host_call event.
---@field result? any
---@field error? string
---@class RuntimeImportApi ---@class RuntimeImportApi
---@field import fun(moduleName: string): table ---@field import fun(moduleName: string): table
---@field log fun(...: any) ---@field log fun(...: any)
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
---@field ws_send fun(id: string, message: string): boolean
---@field ws_close fun(id: string): boolean
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
---@type RuntimeImportApi ---@type RuntimeImportApi
runtime = runtime runtime = runtime

View File

@@ -64,6 +64,14 @@
---| 'animation_done' ---| 'animation_done'
---| 'resize' ---| 'resize'
---| 'scroll' ---| 'scroll'
---| 'network_http'
---| 'network_ws_open'
---| 'network_ws_message'
---| 'network_ws_error'
---| 'network_ws_close'
---| 'host_notify'
---| 'host_call'
---| 'host_call_result'
---@alias RuntimeScaleMode ---@alias RuntimeScaleMode
---| 'fit' ---| 'fit'
@@ -580,9 +588,43 @@
---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_group fun(group: string): RuntimeCommand
---@field cancel_scope fun(scope: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand
---@class RuntimeHttpRequestOptions
---@field id? string
---@field method? string HTTP method. Defaults to GET.
---@field url string http/https URL.
---@field headers? table<string, string>
---@field body? string
---@field timeout? number Timeout in seconds. Defaults to 15.
---@class RuntimeWsConnectOptions
---@field id? string
---@field url string ws/wss URL.
---@field protocols? string[]
---@class RuntimeHostCallOptions
---@field id? string
---@field method string Host method name registered by Flutter.
---@field data? any
---@class RuntimeHostNotifyOptions
---@field method string Host notification name registered by Flutter.
---@field data? any
---@class RuntimeHostRespondOptions
---@field id string Host-to-Lua call id from host_call event.
---@field result? any
---@field error? string
---@class RuntimeImportApi ---@class RuntimeImportApi
---@field import fun(moduleName: string): table ---@field import fun(moduleName: string): table
---@field log fun(...: any) ---@field log fun(...: any)
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
---@field ws_send fun(id: string, message: string): boolean
---@field ws_close fun(id: string): boolean
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
---@type RuntimeImportApi ---@type RuntimeImportApi
runtime = runtime runtime = runtime

View File

@@ -64,6 +64,14 @@
---| 'animation_done' ---| 'animation_done'
---| 'resize' ---| 'resize'
---| 'scroll' ---| 'scroll'
---| 'network_http'
---| 'network_ws_open'
---| 'network_ws_message'
---| 'network_ws_error'
---| 'network_ws_close'
---| 'host_notify'
---| 'host_call'
---| 'host_call_result'
---@alias RuntimeScaleMode ---@alias RuntimeScaleMode
---| 'fit' ---| 'fit'
@@ -580,9 +588,43 @@
---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_group fun(group: string): RuntimeCommand
---@field cancel_scope fun(scope: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand
---@class RuntimeHttpRequestOptions
---@field id? string
---@field method? string HTTP method. Defaults to GET.
---@field url string http/https URL.
---@field headers? table<string, string>
---@field body? string
---@field timeout? number Timeout in seconds. Defaults to 15.
---@class RuntimeWsConnectOptions
---@field id? string
---@field url string ws/wss URL.
---@field protocols? string[]
---@class RuntimeHostCallOptions
---@field id? string
---@field method string Host method name registered by Flutter.
---@field data? any
---@class RuntimeHostNotifyOptions
---@field method string Host notification name registered by Flutter.
---@field data? any
---@class RuntimeHostRespondOptions
---@field id string Host-to-Lua call id from host_call event.
---@field result? any
---@field error? string
---@class RuntimeImportApi ---@class RuntimeImportApi
---@field import fun(moduleName: string): table ---@field import fun(moduleName: string): table
---@field log fun(...: any) ---@field log fun(...: any)
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
---@field ws_send fun(id: string, message: string): boolean
---@field ws_close fun(id: string): boolean
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
---@type RuntimeImportApi ---@type RuntimeImportApi
runtime = runtime runtime = runtime

View File

@@ -4,11 +4,23 @@ export 'runtime/game/flame_lua_game.dart' show FlameLuaGame;
export 'runtime/game/lua_game_widget.dart' show LuaGameWidget; export 'runtime/game/lua_game_widget.dart' show LuaGameWidget;
export 'runtime/game/runtime_locale.dart' show RuntimeLocaleResolver; export 'runtime/game/runtime_locale.dart' show RuntimeLocaleResolver;
export 'runtime/game/runtime_options.dart' show RuntimeOptions; export 'runtime/game/runtime_options.dart' show RuntimeOptions;
export 'runtime/host/runtime_host_bridge.dart'
show
RuntimeHostBridge,
RuntimeHostBridgeManager,
RuntimeHostCall,
RuntimeHostCallHandler,
RuntimeHostEventType,
RuntimeHostNotification,
RuntimeHostNotifyHandler;
export 'runtime/packages/game_package_repository.dart' export 'runtime/packages/game_package_repository.dart'
show show
AssetGamePackageRepository, AssetGamePackageRepository,
FileGamePackageRepository,
GamePackageRepository, GamePackageRepository,
RemoteGamePackageRepository; RemoteGamePackageRepository;
export 'runtime/scripting/lua_dardo_script_engine.dart' export 'runtime/scripting/lua_dardo_script_engine.dart'
show LuaDardoScriptEngine; show LuaDardoScriptEngine;
export 'runtime/scripting/script_engine.dart' show ScriptEngine; export 'runtime/scripting/script_engine.dart' show ScriptEngine;
export 'runtime/storage/runtime_storage_manager.dart'
show RuntimeStorageManager;

View File

@@ -136,4 +136,6 @@ enum RuntimeDiagnosticType {
packageActivationError, packageActivationError,
resourceLoadError, resourceLoadError,
commandError, commandError,
networkError,
hostBridgeError,
} }

View File

@@ -10,8 +10,10 @@ import '../commands/command_executor.dart';
import '../diagnostics/runtime_diagnostics.dart'; import '../diagnostics/runtime_diagnostics.dart';
import '../events/runtime_event_dispatcher.dart'; import '../events/runtime_event_dispatcher.dart';
import '../lifecycle/runtime_session.dart'; import '../lifecycle/runtime_session.dart';
import '../host/runtime_host_bridge.dart';
import '../models/game_diff.dart'; import '../models/game_diff.dart';
import '../models/runtime_event.dart'; import '../models/runtime_event.dart';
import '../network/runtime_network_manager.dart';
import '../packages/game_package.dart'; import '../packages/game_package.dart';
import '../packages/game_package_activation_controller.dart'; import '../packages/game_package_activation_controller.dart';
import '../packages/game_package_repository.dart'; import '../packages/game_package_repository.dart';
@@ -20,7 +22,9 @@ import '../protocol/runtime_protocol.dart';
import '../rendering/render_tree_controller.dart'; import '../rendering/render_tree_controller.dart';
import '../display/runtime_viewport.dart'; import '../display/runtime_viewport.dart';
import '../resources/game_resource_manager.dart'; import '../resources/game_resource_manager.dart';
import '../scripting/runtime_script_services.dart';
import '../scripting/script_engine.dart'; import '../scripting/script_engine.dart';
import '../storage/runtime_storage_manager.dart';
import 'runtime_locale.dart'; import 'runtime_locale.dart';
import 'runtime_options.dart'; import 'runtime_options.dart';
@@ -39,6 +43,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
this.audioMaxConcurrentLoads = 4, this.audioMaxConcurrentLoads = 4,
this.audioSfxPoolSize = 8, this.audioSfxPoolSize = 8,
this.runtimeOptions = const RuntimeOptions(), this.runtimeOptions = const RuntimeOptions(),
this.hostBridge = const RuntimeHostBridge(),
Locale? localeOverride, Locale? localeOverride,
}) : _bootstrapScriptEngine = scriptEngine, }) : _bootstrapScriptEngine = scriptEngine,
_localeOverride = localeOverride, _localeOverride = localeOverride,
@@ -60,12 +65,16 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
final int audioMaxConcurrentLoads; final int audioMaxConcurrentLoads;
final int audioSfxPoolSize; final int audioSfxPoolSize;
final RuntimeOptions runtimeOptions; final RuntimeOptions runtimeOptions;
final RuntimeHostBridge hostBridge;
final Locale? _localeOverride; final Locale? _localeOverride;
late final GameResourceManager _resources; late final GameResourceManager _resources;
late final RuntimeAudioManager _audio; late final RuntimeAudioManager _audio;
late final RenderTreeController _renderTree; late final RenderTreeController _renderTree;
late final PositionComponent _viewportRoot; late final PositionComponent _viewportRoot;
RuntimeNetworkManager? _network;
RuntimeHostBridgeManager? _hostBridgeManager;
RuntimeStorageManager? _storage;
RuntimeViewportConfig? _viewportConfig; RuntimeViewportConfig? _viewportConfig;
late final CommandExecutor _commands; late final CommandExecutor _commands;
RuntimeSession? _session; RuntimeSession? _session;
@@ -80,6 +89,28 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
String diagnosticsDumpText() => diagnostics.dumpText(); String diagnosticsDumpText() => diagnostics.dumpText();
Future<Object?> callLua(
String method, {
Object? data,
Duration timeout = const Duration(seconds: 15),
}) {
final hostBridgeManager = _hostBridgeManager;
if (!_runtimeInitialized || hostBridgeManager == null) {
return Future<Object?>.error(
StateError('Lua runtime is not initialized'),
);
}
return hostBridgeManager.callLua(method, data: data, timeout: timeout);
}
bool notifyLua(String method, {Object? data}) {
final hostBridgeManager = _hostBridgeManager;
if (!_runtimeInitialized || hostBridgeManager == null) {
return false;
}
return hostBridgeManager.notifyLua(method, data: data);
}
Map<String, Object?> resourcesDebugJson() { Map<String, Object?> resourcesDebugJson() {
if (!_runtimeInitialized) { if (!_runtimeInitialized) {
return {'initialized': false}; return {'initialized': false};
@@ -88,6 +119,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
'initialized': true, 'initialized': true,
'images': _resources.imagesDebugJson(), 'images': _resources.imagesDebugJson(),
'audio': _audio.audioDebugJson(), 'audio': _audio.audioDebugJson(),
'storage': _storage?.debugJson() ?? const {},
}; };
} }
@@ -102,15 +134,34 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
_session = session; _session = session;
try { try {
final network = RuntimeNetworkManager(
eventSink: _emitEvent,
diagnostics: diagnostics,
);
_network = network;
final hostBridgeManager = RuntimeHostBridgeManager(
bridge: hostBridge,
eventSink: _emitEvent,
diagnostics: diagnostics,
);
_hostBridgeManager = hostBridgeManager;
final storage = await RuntimeStorageManager.create(gameId: gameId);
_storage = storage;
final activation = final activation =
await PackageActivationController( await PackageActivationController(
repository: _packageRepository, repository: _packageRepository,
resources: _createResourceManager(), resources: _createResourceManager(),
scriptEngine: _bootstrapScriptEngine, scriptEngine: _bootstrapScriptEngine,
audio: _createAudioManager(), audio: _createAudioManager(),
runtimeOptions: runtimeOptions,
resourceManagerFactory: _createResourceManager, resourceManagerFactory: _createResourceManager,
audioManagerFactory: _createAudioManager, audioManagerFactory: _createAudioManager,
scriptEngineFactory: _scriptEngineFactory, scriptEngineFactory: _scriptEngineFactory,
scriptServices: RuntimeScriptServices(
network: network,
hostBridge: hostBridgeManager,
storage: storage,
),
store: StablePackageStore(runtimeOptions: runtimeOptions), store: StablePackageStore(runtimeOptions: runtimeOptions),
assetFallback: AssetGamePackageRepository( assetFallback: AssetGamePackageRepository(
runtimeOptions: runtimeOptions, runtimeOptions: runtimeOptions,
@@ -130,6 +181,25 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
_resources = activation.resources; _resources = activation.resources;
_audio = activation.audio ?? _createAudioManager(); _audio = activation.audio ?? _createAudioManager();
_scriptEngine = activation.scriptEngine; _scriptEngine = activation.scriptEngine;
// 合并所有包的翻译字典,框架包先、游戏包后,后加载的覆盖先加载的。
final mergedTranslations = <String, Map<String, String>>{};
for (final package in activation.packages) {
for (final entry in package.manifest.translations.entries) {
mergedTranslations.putIfAbsent(entry.key, () => {})
.addAll(entry.value);
}
}
final resolvedLocale = RuntimeLocaleResolver.resolve(
requested: _localeOverride ?? PlatformDispatcher.instance.locale,
defaultLocale: activation.package.manifest.defaultLocale,
supportedLocales: activation.package.manifest.supportedLocales,
);
_scriptEngine.setTranslations(
locale: resolvedLocale.resolved,
translations: mergedTranslations,
);
_viewportConfig = activation.package.manifest.display.toViewportConfig(); _viewportConfig = activation.package.manifest.display.toViewportConfig();
_viewportRoot = PositionComponent(); _viewportRoot = PositionComponent();
add(_viewportRoot); add(_viewportRoot);
@@ -159,6 +229,10 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
_runtimeInitialized = true; _runtimeInitialized = true;
_applyDiff(activation.initialDiff); _applyDiff(activation.initialDiff);
} catch (error) { } catch (error) {
_hostBridgeManager?.dispose();
_hostBridgeManager = null;
_network?.dispose();
_network = null;
session.dispose(); session.dispose();
loadError = error.toString(); loadError = error.toString();
diagnostics.record( diagnostics.record(
@@ -336,6 +410,10 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
_events?.dispose(); _events?.dispose();
if (_runtimeInitialized) { if (_runtimeInitialized) {
_commands.dispose(); _commands.dispose();
_hostBridgeManager?.dispose();
_hostBridgeManager = null;
_network?.dispose();
_network = null;
_renderTree.clear(); _renderTree.clear();
_audio.dispose(); _audio.dispose();
_resources.dispose(); _resources.dispose();

View File

@@ -2,6 +2,7 @@ import 'package:flame/game.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../diagnostics/runtime_diagnostics.dart'; import '../diagnostics/runtime_diagnostics.dart';
import '../host/runtime_host_bridge.dart';
import '../packages/game_package_repository.dart'; import '../packages/game_package_repository.dart';
import '../scripting/lua_dardo_script_engine.dart'; import '../scripting/lua_dardo_script_engine.dart';
import 'flame_lua_game.dart'; import 'flame_lua_game.dart';
@@ -14,6 +15,7 @@ class LuaGameWidget extends StatelessWidget {
this.serverUrl, this.serverUrl,
this.localeOverride, this.localeOverride,
this.runtimeOptions = const RuntimeOptions(), this.runtimeOptions = const RuntimeOptions(),
this.hostBridge = const RuntimeHostBridge(),
super.key, super.key,
}); });
@@ -22,6 +24,7 @@ class LuaGameWidget extends StatelessWidget {
final Uri? serverUrl; final Uri? serverUrl;
final Locale? localeOverride; final Locale? localeOverride;
final RuntimeOptions runtimeOptions; final RuntimeOptions runtimeOptions;
final RuntimeHostBridge hostBridge;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -42,6 +45,7 @@ class LuaGameWidget extends StatelessWidget {
)), )),
gameId: gameId, gameId: gameId,
runtimeOptions: runtimeOptions, runtimeOptions: runtimeOptions,
hostBridge: hostBridge,
localeOverride: localeOverride, localeOverride: localeOverride,
), ),
); );

View File

@@ -1,7 +1,31 @@
class RuntimeOptions { class RuntimeOptions {
const RuntimeOptions({this.runtimeLuaRoot = defaultRuntimeLuaRoot}); const RuntimeOptions({
this.runtimeLuaRoot = defaultRuntimeLuaRoot,
this.basePackages = const [],
this.runtimeVersion = '0.0.0',
this.hostBuild = 0,
this.channel = 'dev',
this.platform,
});
static const defaultRuntimeLuaRoot = 'assets/runtime/lua'; static const defaultRuntimeLuaRoot = 'assets/runtime/lua';
final String runtimeLuaRoot; final String runtimeLuaRoot;
// 框架包 gameId 列表,按顺序先于游戏包加载。
// 后加载的同名模块覆盖先加载的。
final List<String> basePackages;
// 宿主 Flutter App 当前集成的 Lua Runtime 版本。
// 远程包可通过 minRuntimeVersion/maxRuntimeVersion 限制兼容范围。
final String runtimeVersion;
// 宿主 App 构建号。远程包可通过 minHostBuild/maxHostBuild 避免旧 App 拉取新包。
final int hostBuild;
// dev/staging/prod 等发布通道,用于服务器和客户端双重筛选远程包。
final String channel;
// 平台名,例如 windows/android/ios。为空时 Runtime 会尝试自动识别。
final String? platform;
} }

View File

@@ -0,0 +1,228 @@
import 'dart:async' as async;
import '../diagnostics/runtime_diagnostics.dart';
import '../models/runtime_event.dart';
typedef RuntimeHostCallHandler =
async.FutureOr<Object?> Function(RuntimeHostCall call);
typedef RuntimeHostNotifyHandler =
void Function(RuntimeHostNotification notification);
class RuntimeHostBridge {
const RuntimeHostBridge({this.handlers = const {}, this.onNotify});
final Map<String, RuntimeHostCallHandler> handlers;
final RuntimeHostNotifyHandler? onNotify;
}
class RuntimeHostCall {
const RuntimeHostCall({required this.id, required this.method, this.data});
final String id;
final String method;
final Object? data;
}
class RuntimeHostNotification {
const RuntimeHostNotification({required this.method, this.data});
final String method;
final Object? data;
}
class RuntimeHostBridgeManager {
RuntimeHostBridgeManager({
required RuntimeHostBridge bridge,
required void Function(RuntimeEvent event) eventSink,
RuntimeDiagnostics? diagnostics,
}) : _bridge = bridge,
_eventSink = eventSink,
_diagnostics = diagnostics;
final RuntimeHostBridge _bridge;
final void Function(RuntimeEvent event) _eventSink;
final RuntimeDiagnostics? _diagnostics;
final Map<String, async.Completer<Object?>> _pendingLuaCalls = {};
var _nextCallId = 0;
bool _disposed = false;
Future<void> callHost(RuntimeHostCall call) async {
if (_disposed) {
return;
}
final handler = _bridge.handlers[call.method];
if (handler == null) {
_emitHostCallResult(
id: call.id,
method: call.method,
ok: false,
error: 'No host handler registered for ${call.method}',
);
return;
}
try {
final result = await handler(call);
_emitHostCallResult(
id: call.id,
method: call.method,
ok: true,
result: result,
);
} catch (error) {
_diagnostics?.record(
type: RuntimeDiagnosticType.hostBridgeError,
message: 'Runtime host call failed',
error: error,
context: {'id': call.id, 'method': call.method},
);
_emitHostCallResult(
id: call.id,
method: call.method,
ok: false,
error: error.toString(),
);
}
}
bool notifyHost(RuntimeHostNotification notification) {
if (_disposed) {
return false;
}
final handler = _bridge.onNotify;
if (handler == null) {
return false;
}
try {
handler(notification);
return true;
} catch (error) {
_diagnostics?.record(
type: RuntimeDiagnosticType.hostBridgeError,
message: 'Runtime host notification failed',
error: error,
context: {'method': notification.method},
);
return false;
}
}
Future<Object?> callLua(
String method, {
Object? data,
Duration timeout = const Duration(seconds: 15),
}) {
if (_disposed) {
return Future<Object?>.error(StateError('Runtime host bridge disposed'));
}
final id = 'host:${++_nextCallId}';
final completer = async.Completer<Object?>();
_pendingLuaCalls[id] = completer;
_emit(
RuntimeEvent(
type: RuntimeHostEventType.call,
data: {
'id': id,
'method': method,
if (data != null) 'data': _runtimeValue(data),
},
),
);
return completer.future.timeout(
timeout,
onTimeout: () {
_pendingLuaCalls.remove(id);
throw async.TimeoutException(
'Lua host call timed out: $method',
timeout,
);
},
);
}
bool notifyLua(String method, {Object? data}) {
if (_disposed) {
return false;
}
_emit(
RuntimeEvent(
type: RuntimeHostEventType.notify,
data: {'method': method, if (data != null) 'data': _runtimeValue(data)},
),
);
return true;
}
bool completeLuaCall(String id, {Object? result, String? error}) {
final completer = _pendingLuaCalls.remove(id);
if (completer == null || completer.isCompleted) {
return false;
}
if (error != null) {
completer.completeError(StateError(error));
} else {
completer.complete(_runtimeValue(result));
}
return true;
}
void dispose() {
_disposed = true;
for (final completer in _pendingLuaCalls.values) {
if (!completer.isCompleted) {
completer.completeError(StateError('Runtime host bridge disposed'));
}
}
_pendingLuaCalls.clear();
}
void _emitHostCallResult({
required String id,
required String method,
required bool ok,
Object? result,
String? error,
}) {
_emit(
RuntimeEvent(
type: RuntimeHostEventType.callResult,
data: {
'id': id,
'method': method,
'ok': ok,
if (ok) 'result': _runtimeValue(result),
if (!ok && error != null) 'error': error,
},
),
);
}
void _emit(RuntimeEvent event) {
if (_disposed) {
return;
}
_eventSink(event);
}
Object? _runtimeValue(Object? value) {
if (value == null || value is String || value is num || value is bool) {
return value;
}
if (value is Iterable) {
return value.map(_runtimeValue).toList(growable: false);
}
if (value is Map) {
return {
for (final entry in value.entries)
entry.key.toString(): _runtimeValue(entry.value),
};
}
return value.toString();
}
}
abstract final class RuntimeHostEventType {
static const notify = 'host_notify';
static const call = 'host_call';
static const callResult = 'host_call_result';
}

View File

@@ -0,0 +1,273 @@
import 'dart:async' as async;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:web_socket_channel/web_socket_channel.dart';
import '../diagnostics/runtime_diagnostics.dart';
import '../models/runtime_event.dart';
class RuntimeNetworkManager {
RuntimeNetworkManager({
required void Function(RuntimeEvent event) eventSink,
RuntimeDiagnostics? diagnostics,
http.Client? httpClient,
WebSocketChannel Function(Uri uri, {Iterable<String>? protocols})?
webSocketFactory,
}) : _eventSink = eventSink,
_diagnostics = diagnostics,
_httpClient = httpClient ?? http.Client(),
_ownsHttpClient = httpClient == null,
_webSocketFactory =
webSocketFactory ??
((uri, {protocols}) =>
WebSocketChannel.connect(uri, protocols: protocols));
final void Function(RuntimeEvent event) _eventSink;
final RuntimeDiagnostics? _diagnostics;
final http.Client _httpClient;
final bool _ownsHttpClient;
final WebSocketChannel Function(Uri uri, {Iterable<String>? protocols})
_webSocketFactory;
final Map<String, _RuntimeWebSocketConnection> _webSockets = {};
bool _disposed = false;
Future<void> httpRequest(RuntimeHttpRequest request) async {
if (_disposed) {
return;
}
try {
_ensureScheme(request.uri, const {'http', 'https'}, 'HTTP request');
final response = await _httpClient
.send(
http.Request(request.method, request.uri)
..headers.addAll(request.headers)
..body = request.body ?? '',
)
.timeout(request.timeout);
final body = await response.stream.bytesToString();
_emit(
RuntimeEvent(
type: RuntimeNetworkEventType.http,
data: {
'id': request.id,
'url': request.uri.toString(),
'method': request.method,
'ok': response.statusCode >= 200 && response.statusCode < 300,
'status': response.statusCode,
'headers': response.headers,
'body': body,
},
),
);
} catch (error) {
_diagnostics?.record(
type: RuntimeDiagnosticType.networkError,
message: 'Runtime HTTP request failed',
error: error,
context: {'id': request.id, 'url': request.uri.toString()},
);
_emit(
RuntimeEvent(
type: RuntimeNetworkEventType.http,
data: {
'id': request.id,
'url': request.uri.toString(),
'method': request.method,
'ok': false,
'error': error.toString(),
},
),
);
}
}
void wsConnect(RuntimeWebSocketConnectRequest request) {
if (_disposed) {
return;
}
try {
_ensureScheme(request.uri, const {'ws', 'wss'}, 'WebSocket connect');
closeWebSocket(request.id, emitClose: false);
final channel = _webSocketFactory(
request.uri,
protocols: request.protocols.isEmpty ? null : request.protocols,
);
final subscription = channel.stream.listen(
(message) {
_emit(
RuntimeEvent(
type: RuntimeNetworkEventType.wsMessage,
data: {
'id': request.id,
'url': request.uri.toString(),
'message': _webSocketMessageToString(message),
},
),
);
},
onError: (Object error) {
_diagnostics?.record(
type: RuntimeDiagnosticType.networkError,
message: 'Runtime WebSocket error',
error: error,
context: {'id': request.id, 'url': request.uri.toString()},
);
_emit(
RuntimeEvent(
type: RuntimeNetworkEventType.wsError,
data: {
'id': request.id,
'url': request.uri.toString(),
'error': error.toString(),
},
),
);
},
onDone: () {
_webSockets.remove(request.id);
_emit(
RuntimeEvent(
type: RuntimeNetworkEventType.wsClose,
data: {'id': request.id, 'url': request.uri.toString()},
),
);
},
);
_webSockets[request.id] = _RuntimeWebSocketConnection(
channel: channel,
subscription: subscription,
);
_emit(
RuntimeEvent(
type: RuntimeNetworkEventType.wsOpen,
data: {'id': request.id, 'url': request.uri.toString()},
),
);
} catch (error) {
_diagnostics?.record(
type: RuntimeDiagnosticType.networkError,
message: 'Runtime WebSocket connect failed',
error: error,
context: {'id': request.id, 'url': request.uri.toString()},
);
_emit(
RuntimeEvent(
type: RuntimeNetworkEventType.wsError,
data: {
'id': request.id,
'url': request.uri.toString(),
'error': error.toString(),
},
),
);
}
}
bool wsSend(String id, Object? message) {
final connection = _webSockets[id];
if (_disposed || connection == null) {
return false;
}
connection.channel.sink.add(message?.toString() ?? '');
return true;
}
bool closeWebSocket(String id, {bool emitClose = true}) {
final connection = _webSockets.remove(id);
if (connection == null) {
return false;
}
connection.subscription.cancel();
connection.channel.sink.close();
if (emitClose) {
_emit(
RuntimeEvent(type: RuntimeNetworkEventType.wsClose, data: {'id': id}),
);
}
return true;
}
void dispose() {
_disposed = true;
for (final id in _webSockets.keys.toList(growable: false)) {
closeWebSocket(id, emitClose: false);
}
if (_ownsHttpClient) {
_httpClient.close();
}
}
void _emit(RuntimeEvent event) {
if (_disposed) {
return;
}
_eventSink(event);
}
void _ensureScheme(Uri uri, Set<String> allowed, String label) {
if (!allowed.contains(uri.scheme)) {
throw FormatException(
'$label only supports ${allowed.join('/')} URLs: $uri',
);
}
}
String _webSocketMessageToString(Object? message) {
if (message is String) {
return message;
}
if (message is List<int>) {
return base64Encode(message);
}
return message?.toString() ?? '';
}
}
class RuntimeHttpRequest {
const RuntimeHttpRequest({
required this.id,
required this.method,
required this.uri,
this.headers = const {},
this.body,
this.timeout = const Duration(seconds: 15),
});
final String id;
final String method;
final Uri uri;
final Map<String, String> headers;
final String? body;
final Duration timeout;
}
class RuntimeWebSocketConnectRequest {
const RuntimeWebSocketConnectRequest({
required this.id,
required this.uri,
this.protocols = const [],
});
final String id;
final Uri uri;
final List<String> protocols;
}
abstract final class RuntimeNetworkEventType {
static const http = 'network_http';
static const wsOpen = 'network_ws_open';
static const wsMessage = 'network_ws_message';
static const wsError = 'network_ws_error';
static const wsClose = 'network_ws_close';
}
class _RuntimeWebSocketConnection {
const _RuntimeWebSocketConnection({
required this.channel,
required this.subscription,
});
final WebSocketChannel channel;
final async.StreamSubscription<dynamic> subscription;
}

View File

@@ -1,6 +1,8 @@
import '../audio/runtime_audio_manager.dart'; import '../audio/runtime_audio_manager.dart';
import '../game/runtime_options.dart';
import '../models/game_diff.dart'; import '../models/game_diff.dart';
import '../resources/game_resource_manager.dart'; import '../resources/game_resource_manager.dart';
import '../scripting/runtime_script_services.dart';
import '../scripting/script_engine.dart'; import '../scripting/script_engine.dart';
import 'game_package.dart'; import 'game_package.dart';
import 'game_package_repository.dart'; import 'game_package_repository.dart';
@@ -14,11 +16,13 @@ class PackageActivationController {
required this.scriptEngine, required this.scriptEngine,
this.audio, this.audio,
this.runtimeApiVersion = 1, this.runtimeApiVersion = 1,
this.runtimeOptions = const RuntimeOptions(),
this.store = const StablePackageStore(), this.store = const StablePackageStore(),
this.assetFallback = const AssetGamePackageRepository(), this.assetFallback = const AssetGamePackageRepository(),
this.resourceManagerFactory, this.resourceManagerFactory,
this.audioManagerFactory, this.audioManagerFactory,
this.scriptEngineFactory, this.scriptEngineFactory,
this.scriptServices = const RuntimeScriptServices(),
}); });
final GamePackageRepository repository; final GamePackageRepository repository;
@@ -26,11 +30,13 @@ class PackageActivationController {
final ScriptEngine scriptEngine; final ScriptEngine scriptEngine;
final RuntimeAudioManager? audio; final RuntimeAudioManager? audio;
final int runtimeApiVersion; final int runtimeApiVersion;
final RuntimeOptions runtimeOptions;
final StablePackageStore store; final StablePackageStore store;
final GamePackageRepository assetFallback; final GamePackageRepository assetFallback;
final GameResourceManager Function()? resourceManagerFactory; final GameResourceManager Function()? resourceManagerFactory;
final RuntimeAudioManager Function()? audioManagerFactory; final RuntimeAudioManager Function()? audioManagerFactory;
final ScriptEngine Function()? scriptEngineFactory; final ScriptEngine Function()? scriptEngineFactory;
final RuntimeScriptServices scriptServices;
Future<PackageActivationResult> activate({ Future<PackageActivationResult> activate({
required String gameId, required String gameId,
@@ -140,11 +146,33 @@ class PackageActivationController {
try { try {
await verifier.verify(candidate); await verifier.verify(candidate);
_ensureContinue(shouldContinue); _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); await preparedResources.mount(candidate);
_ensureContinue(shouldContinue); _ensureContinue(shouldContinue);
await preparedAudio?.mount(candidate); await preparedAudio?.mount(candidate);
_ensureContinue(shouldContinue); _ensureContinue(shouldContinue);
await preparedScriptEngine.loadPackage(candidate);
// 合并 base + game 包,传给脚本引擎。
final allPackages = [...basePackages, candidate];
await preparedScriptEngine.loadPackages(
allPackages,
services: scriptServices,
);
_ensureContinue(shouldContinue); _ensureContinue(shouldContinue);
final context = contextBuilder(candidate); final context = contextBuilder(candidate);
@@ -158,6 +186,7 @@ class PackageActivationController {
_ensureContinue(shouldContinue); _ensureContinue(shouldContinue);
return PackageActivationPlan( return PackageActivationPlan(
package: candidate, package: candidate,
packages: allPackages,
initialDiff: diff, initialDiff: diff,
resources: preparedResources, resources: preparedResources,
scriptEngine: preparedScriptEngine, scriptEngine: preparedScriptEngine,
@@ -191,6 +220,7 @@ class PackageActivationController {
class PackageActivationPlan { class PackageActivationPlan {
const PackageActivationPlan({ const PackageActivationPlan({
required this.package, required this.package,
required this.packages,
required this.initialDiff, required this.initialDiff,
required this.resources, required this.resources,
required this.scriptEngine, required this.scriptEngine,
@@ -198,6 +228,7 @@ class PackageActivationPlan {
}); });
final GamePackage package; final GamePackage package;
final List<GamePackage> packages;
final GameDiff initialDiff; final GameDiff initialDiff;
final GameResourceManager resources; final GameResourceManager resources;
final ScriptEngine scriptEngine; final ScriptEngine scriptEngine;
@@ -207,6 +238,7 @@ class PackageActivationPlan {
class PackageActivationResult { class PackageActivationResult {
const PackageActivationResult({ const PackageActivationResult({
required this.package, required this.package,
required this.packages,
required this.initialDiff, required this.initialDiff,
required this.resources, required this.resources,
required this.scriptEngine, required this.scriptEngine,
@@ -216,6 +248,7 @@ class PackageActivationResult {
factory PackageActivationResult.fromPlan(PackageActivationPlan plan) { factory PackageActivationResult.fromPlan(PackageActivationPlan plan) {
return PackageActivationResult( return PackageActivationResult(
package: plan.package, package: plan.package,
packages: plan.packages,
initialDiff: plan.initialDiff, initialDiff: plan.initialDiff,
resources: plan.resources, resources: plan.resources,
scriptEngine: plan.scriptEngine, scriptEngine: plan.scriptEngine,
@@ -224,6 +257,7 @@ class PackageActivationResult {
} }
final GamePackage package; final GamePackage package;
final List<GamePackage> packages;
final GameDiff initialDiff; final GameDiff initialDiff;
final GameResourceManager resources; final GameResourceManager resources;
final ScriptEngine scriptEngine; final ScriptEngine scriptEngine;

View File

@@ -15,6 +15,8 @@ class GamePackageManifest {
this.display = const GameDisplayConfig(), this.display = const GameDisplayConfig(),
this.resources = const {}, this.resources = const {},
this.modules = const {}, this.modules = const {},
this.translations = const {},
this.base,
}); });
final String gameId; final String gameId;
@@ -29,6 +31,13 @@ class GamePackageManifest {
final Map<String, GameResource> resources; final Map<String, GameResource> resources;
final Map<String, String> modules; final Map<String, String> modules;
/// 翻译字典locale → { key → translatedText }。
/// Runtime 会按包加载顺序合并,游戏包覆盖框架包。
final Map<String, Map<String, String>> translations;
/// 依赖的框架包 gameId。加载时会先加载框架包再加载游戏包。
final String? base;
static GamePackageManifest fromJsonString(String source) { static GamePackageManifest fromJsonString(String source) {
return fromMap(jsonDecode(source) as Map<String, Object?>); return fromMap(jsonDecode(source) as Map<String, Object?>);
} }
@@ -58,6 +67,28 @@ class GamePackageManifest {
} }
} }
final base = map['base'] as String?;
final translationsValue = map['translations'];
final translations = <String, Map<String, String>>{};
if (translationsValue is Map) {
for (final entry in translationsValue.entries) {
if (entry.key is! String || entry.value is! Map) {
throw const FormatException('manifest.translations must be a map');
}
final localeDict = <String, String>{};
for (final kv in (entry.value as Map).entries) {
if (kv.key is! String || kv.value is! String) {
throw const FormatException(
'manifest.translations values must be string maps',
);
}
localeDict[kv.key as String] = kv.value as String;
}
translations[entry.key as String] = localeDict;
}
}
final defaultLocale = (map['defaultLocale'] as String?) ?? 'en'; final defaultLocale = (map['defaultLocale'] as String?) ?? 'en';
final supportedLocales = _stringList( final supportedLocales = _stringList(
map, map,
@@ -89,6 +120,8 @@ class GamePackageManifest {
display: display, display: display,
resources: resources, resources: resources,
modules: modules, modules: modules,
translations: translations,
base: base,
); );
} }

View File

@@ -38,6 +38,35 @@ class AssetGamePackageRepository implements GamePackageRepository {
} }
} }
class FileGamePackageRepository implements GamePackageRepository {
const FileGamePackageRepository({
required this.baseDirectory,
this.runtimeOptions = const RuntimeOptions(),
});
final String baseDirectory;
final RuntimeOptions runtimeOptions;
@override
Future<GamePackage> load(String gameId) async {
final root = Directory(p.join(baseDirectory, gameId));
final manifestFile = File(p.join(root.path, 'manifest.json'));
if (!manifestFile.existsSync()) {
throw FileSystemException(
'Game package manifest not found',
manifestFile.path,
);
}
return GamePackage.file(
rootPath: root.path,
manifest: GamePackageManifest.fromJsonString(
await manifestFile.readAsString(),
),
runtimeLuaRoot: runtimeOptions.runtimeLuaRoot,
);
}
}
class RemoteGamePackageRepository implements GamePackageRepository { class RemoteGamePackageRepository implements GamePackageRepository {
RemoteGamePackageRepository({ RemoteGamePackageRepository({
required this.baseUri, required this.baseUri,
@@ -103,6 +132,10 @@ class RemoteGamePackageRepository implements GamePackageRepository {
if (remoteManifest.gameId != gameId) { if (remoteManifest.gameId != gameId) {
throw const FormatException('Remote manifest gameId mismatch'); throw const FormatException('Remote manifest gameId mismatch');
} }
remoteManifest.compatibility.verify(
runtimeApiVersion: runtimeApiVersion,
runtimeOptions: runtimeOptions,
);
final packageRoot = await _downloadAndExtract( final packageRoot = await _downloadAndExtract(
client, client,
@@ -130,7 +163,7 @@ class RemoteGamePackageRepository implements GamePackageRepository {
http.Client client, http.Client client,
String gameId, String gameId,
) async { ) async {
final uri = baseUri.resolve('$gameId/remote_manifest.json'); final uri = _remoteManifestUri(gameId);
final response = await client.get(uri); final response = await client.get(uri);
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw HttpException( throw HttpException(
@@ -143,6 +176,19 @@ class RemoteGamePackageRepository implements GamePackageRepository {
); );
} }
Uri _remoteManifestUri(String gameId) {
final uri = baseUri.resolve('$gameId/remote_manifest.json');
final query = Map<String, String>.from(uri.queryParameters)
..addAll({
'runtimeApiVersion': runtimeApiVersion.toString(),
'runtimeVersion': runtimeOptions.runtimeVersion,
'hostBuild': runtimeOptions.hostBuild.toString(),
'platform': _platformName(runtimeOptions.platform),
'channel': runtimeOptions.channel,
});
return uri.replace(queryParameters: query);
}
Future<Directory> _downloadAndExtract( Future<Directory> _downloadAndExtract(
http.Client client, http.Client client,
String gameId, String gameId,
@@ -200,12 +246,14 @@ class RemotePackageManifest {
required this.version, required this.version,
required this.packageUrl, required this.packageUrl,
required this.sha256, required this.sha256,
this.compatibility = const RemotePackageCompatibility(),
}); });
final String gameId; final String gameId;
final String version; final String version;
final Uri packageUrl; final Uri packageUrl;
final String sha256; final String sha256;
final RemotePackageCompatibility compatibility;
static RemotePackageManifest fromMap(Map<String, Object?> map) { static RemotePackageManifest fromMap(Map<String, Object?> map) {
return RemotePackageManifest( return RemotePackageManifest(
@@ -213,6 +261,7 @@ class RemotePackageManifest {
version: _string(map, 'version'), version: _string(map, 'version'),
packageUrl: Uri.parse(_string(map, 'packageUrl')), packageUrl: Uri.parse(_string(map, 'packageUrl')),
sha256: _string(map, 'sha256'), sha256: _string(map, 'sha256'),
compatibility: RemotePackageCompatibility.fromMap(map['compat']),
); );
} }
@@ -224,3 +273,182 @@ class RemotePackageManifest {
throw FormatException('remote_manifest.$key must be a non-empty string'); throw FormatException('remote_manifest.$key must be a non-empty string');
} }
} }
class RemotePackageCompatibility {
const RemotePackageCompatibility({
this.runtimeApiVersion,
this.minRuntimeVersion,
this.maxRuntimeVersion,
this.minHostBuild,
this.maxHostBuild,
this.platforms = const [],
this.channels = const [],
});
final int? runtimeApiVersion;
final String? minRuntimeVersion;
final String? maxRuntimeVersion;
final int? minHostBuild;
final int? maxHostBuild;
final List<String> platforms;
final List<String> channels;
static RemotePackageCompatibility fromMap(Object? value) {
if (value == null) {
return const RemotePackageCompatibility();
}
if (value is! Map) {
throw const FormatException('remote_manifest.compat must be a map');
}
final map = Map<String, Object?>.from(value);
return RemotePackageCompatibility(
runtimeApiVersion: _optionalInt(map, 'runtimeApiVersion'),
minRuntimeVersion: _optionalString(map, 'minRuntimeVersion'),
maxRuntimeVersion: _optionalString(map, 'maxRuntimeVersion'),
minHostBuild: _optionalInt(map, 'minHostBuild'),
maxHostBuild: _optionalInt(map, 'maxHostBuild'),
platforms: _optionalStringList(map, 'platforms'),
channels: _optionalStringList(map, 'channels'),
);
}
void verify({
required int runtimeApiVersion,
required RuntimeOptions runtimeOptions,
}) {
if (this.runtimeApiVersion != null &&
this.runtimeApiVersion != runtimeApiVersion) {
throw const FormatException('Remote package runtimeApiVersion mismatch');
}
if (minRuntimeVersion != null &&
_compareVersions(runtimeOptions.runtimeVersion, minRuntimeVersion!) <
0) {
throw const FormatException('Remote package requires newer runtime');
}
if (maxRuntimeVersion != null &&
_compareVersions(runtimeOptions.runtimeVersion, maxRuntimeVersion!) >
0) {
throw const FormatException(
'Remote package does not support this runtime',
);
}
if (minHostBuild != null && runtimeOptions.hostBuild < minHostBuild!) {
throw const FormatException('Remote package requires newer host build');
}
if (maxHostBuild != null && runtimeOptions.hostBuild > maxHostBuild!) {
throw const FormatException(
'Remote package does not support this host build',
);
}
final platform = _platformName(runtimeOptions.platform);
if (platforms.isNotEmpty && !platforms.contains(platform)) {
throw const FormatException(
'Remote package does not support this platform',
);
}
if (channels.isNotEmpty && !channels.contains(runtimeOptions.channel)) {
throw const FormatException(
'Remote package does not support this channel',
);
}
}
static String? _optionalString(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('remote_manifest.compat.$key must be a string');
}
static int? _optionalInt(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is num) {
return value.toInt();
}
throw FormatException('remote_manifest.compat.$key must be an integer');
}
static List<String> _optionalStringList(
Map<String, Object?> map,
String key,
) {
final value = map[key];
if (value == null) {
return const [];
}
if (value is! List) {
throw FormatException(
'remote_manifest.compat.$key must be a string list',
);
}
return value
.map((item) {
if (item is String && item.isNotEmpty) {
return item;
}
throw FormatException(
'remote_manifest.compat.$key must be a string list',
);
})
.toList(growable: false);
}
}
String _platformName(String? explicitPlatform) {
if (explicitPlatform != null && explicitPlatform.isNotEmpty) {
return explicitPlatform;
}
if (Platform.isAndroid) {
return 'android';
}
if (Platform.isIOS) {
return 'ios';
}
if (Platform.isMacOS) {
return 'macos';
}
if (Platform.isWindows) {
return 'windows';
}
if (Platform.isLinux) {
return 'linux';
}
if (Platform.isFuchsia) {
return 'fuchsia';
}
return 'unknown';
}
int _compareVersions(String left, String right) {
final leftParts = _versionParts(left);
final rightParts = _versionParts(right);
final count = leftParts.length > rightParts.length
? leftParts.length
: rightParts.length;
for (var index = 0; index < count; index++) {
final leftValue = index < leftParts.length ? leftParts[index] : 0;
final rightValue = index < rightParts.length ? rightParts[index] : 0;
if (leftValue != rightValue) {
return leftValue.compareTo(rightValue);
}
}
return 0;
}
List<int> _versionParts(String value) {
final normalized = value.split('-').first;
return normalized
.split('.')
.map((part) {
final digits = RegExp(r'^\d+').firstMatch(part)?.group(0);
return int.tryParse(digits ?? '') ?? 0;
})
.toList(growable: false);
}

View File

@@ -32,34 +32,31 @@ class StablePackageStore {
return; return;
} }
final marker = await _markerFile(package.manifest.gameId); final marker = await _markerFile(package.manifest.gameId);
marker.createSync(recursive: true); marker.parent.createSync(recursive: true);
final previous = await stablePackage(package.manifest.gameId); final previous = await stablePackage(package.manifest.gameId);
final data = { final data = {
'current': package.rootPath, 'current': package.rootPath,
if (previous != null && previous.rootPath != package.rootPath) if (previous != null && previous.rootPath != package.rootPath)
'previous': previous.rootPath, 'previous': previous.rootPath,
}; };
marker.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data)); final temporary = File('${marker.path}.tmp');
temporary.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(data),
);
if (marker.existsSync()) {
marker.deleteSync();
}
temporary.renameSync(marker.path);
} }
Future<GamePackage?> stablePackage(String gameId) async { Future<GamePackage?> stablePackage(String gameId) async {
final marker = await _markerFile(gameId); final data = await _readMarker(gameId);
if (!marker.existsSync()) { return _packageFromPath(data?['current']);
return null;
}
final data =
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
return _packageFromPath(data['current']);
} }
Future<GamePackage?> previousStablePackage(String gameId) async { Future<GamePackage?> previousStablePackage(String gameId) async {
final marker = await _markerFile(gameId); final data = await _readMarker(gameId);
if (!marker.existsSync()) { return _packageFromPath(data?['previous']);
return null;
}
final data =
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
return _packageFromPath(data['previous']);
} }
Future<File> _markerFile(String gameId) async { Future<File> _markerFile(String gameId) async {
@@ -67,6 +64,26 @@ class StablePackageStore {
return File(p.join(root.path, gameId, 'stable.json')); return File(p.join(root.path, gameId, 'stable.json'));
} }
Future<Map<String, Object?>?> _readMarker(String gameId) async {
final marker = await _markerFile(gameId);
if (!marker.existsSync()) {
return null;
}
final source = await marker.readAsString();
if (source.trim().isEmpty) {
return null;
}
try {
final value = jsonDecode(source);
if (value is Map) {
return Map<String, Object?>.from(value);
}
} catch (_) {
return null;
}
return null;
}
GamePackage? _packageFromPath(Object? pathValue) { GamePackage? _packageFromPath(Object? pathValue) {
if (pathValue is! String || pathValue.isEmpty) { if (pathValue is! String || pathValue.isEmpty) {
return null; return null;

View File

@@ -44,6 +44,7 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
double sliceRight = 0, double sliceRight = 0,
double sliceBottom = 0, double sliceBottom = 0,
double destinationOverlap = 0, double destinationOverlap = 0,
double sourceInset = 0,
}) { }) {
if (source.width <= 0 || if (source.width <= 0 ||
source.height <= 0 || source.height <= 0 ||
@@ -88,7 +89,7 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
final parts = <({Rect source, Rect destination})>[]; final parts = <({Rect source, Rect destination})>[];
for (var y = 0; y < 3; y++) { for (var y = 0; y < 3; y++) {
for (var x = 0; x < 3; x++) { for (var x = 0; x < 3; x++) {
final sourcePart = Rect.fromLTRB( final rawSourcePart = Rect.fromLTRB(
sourceXs[x], sourceXs[x],
sourceYs[y], sourceYs[y],
sourceXs[x + 1], sourceXs[x + 1],
@@ -100,12 +101,17 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
destXs[x + 1], destXs[x + 1],
destYs[y + 1], destYs[y + 1],
); );
if (sourcePart.width <= 0 || if (rawSourcePart.width <= 0 ||
sourcePart.height <= 0 || rawSourcePart.height <= 0 ||
rawDestPart.width <= 0 || rawDestPart.width <= 0 ||
rawDestPart.height <= 0) { rawDestPart.height <= 0) {
continue; continue;
} }
final sourcePart = _insetNineSliceSourceRect(
rawSourcePart,
bounds: source,
inset: sourceInset,
);
final destPart = _overlapNineSliceDestinationRect( final destPart = _overlapNineSliceDestinationRect(
rawDestPart, rawDestPart,
x: x, x: x,
@@ -119,6 +125,22 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
return parts; return parts;
} }
Rect _insetNineSliceSourceRect(
Rect rect, {
required Rect bounds,
required double inset,
}) {
if (inset <= 0 || rect.width <= inset * 2 || rect.height <= inset * 2) {
return rect;
}
return Rect.fromLTRB(
math.min(rect.right, math.max(bounds.left, rect.left + inset)),
math.min(rect.bottom, math.max(bounds.top, rect.top + inset)),
math.max(rect.left, math.min(bounds.right, rect.right - inset)),
math.max(rect.top, math.min(bounds.bottom, rect.bottom - inset)),
);
}
Rect _overlapNineSliceDestinationRect( Rect _overlapNineSliceDestinationRect(
Rect rect, { Rect rect, {
required int x, required int x,
@@ -575,7 +597,13 @@ class RuntimeComponent extends PositionComponent
..color = composeRuntimeColorAlpha(Colors.white, renderAlpha); ..color = composeRuntimeColorAlpha(Colors.white, renderAlpha);
final source = _imageSourceRect(image, _currentImageFrame(_node)); final source = _imageSourceRect(image, _currentImageFrame(_node));
if (_usesNineSlice(source, rect)) { if (_usesNineSlice(source, rect)) {
_drawNineSliceImage(canvas, image, source, rect, imagePaint); _drawNineSliceImage(
canvas,
image,
source,
rect,
imagePaint..filterQuality = FilterQuality.none,
);
} else { } else {
canvas.drawImageRect(image, source, rect, imagePaint); canvas.drawImageRect(image, source, rect, imagePaint);
} }
@@ -630,7 +658,8 @@ class RuntimeComponent extends PositionComponent
final parts = runtimeNineSliceRects( final parts = runtimeNineSliceRects(
source: source, source: source,
destination: destination, destination: destination,
destinationOverlap: 0.5, destinationOverlap: 1,
sourceInset: 0.5,
sliceLeft: _node.sliceLeft ?? 0, sliceLeft: _node.sliceLeft ?? 0,
sliceTop: _node.sliceTop ?? 0, sliceTop: _node.sliceTop ?? 0,
sliceRight: _node.sliceRight ?? 0, sliceRight: _node.sliceRight ?? 0,

View File

@@ -1,9 +1,15 @@
import 'dart:async' as async;
import 'package:lua_dardo_plus/lua.dart'; import 'package:lua_dardo_plus/lua.dart';
import '../diagnostics/runtime_diagnostics.dart'; import '../diagnostics/runtime_diagnostics.dart';
import '../host/runtime_host_bridge.dart';
import '../models/game_diff.dart'; import '../models/game_diff.dart';
import '../models/runtime_event.dart'; import '../models/runtime_event.dart';
import '../network/runtime_network_manager.dart';
import '../packages/game_package.dart'; import '../packages/game_package.dart';
import '../storage/runtime_storage_manager.dart';
import 'runtime_script_services.dart';
import 'script_engine.dart'; import 'script_engine.dart';
class LuaDardoScriptEngine implements ScriptEngine { class LuaDardoScriptEngine implements ScriptEngine {
@@ -13,15 +19,43 @@ class LuaDardoScriptEngine implements ScriptEngine {
final RuntimeDiagnostics? _diagnostics; final RuntimeDiagnostics? _diagnostics;
late final LuaState _lua; late final LuaState _lua;
late final Map<String, String> _moduleScripts; late final Map<String, String> _moduleScripts;
RuntimeScriptServices _services = const RuntimeScriptServices();
int _networkRequestCounter = 0;
int _hostCallCounter = 0;
final Set<String> _loadingModules = {}; final Set<String> _loadingModules = {};
@override @override
Future<void> loadPackage(GamePackage package) async { Future<void> loadPackage(
final script = await package.readText(package.manifest.entry); 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;
_moduleScripts = {}; _moduleScripts = {};
// 按顺序加载所有包的模块,后加载的同名模块覆盖先加载的。
for (final package in packages) {
for (final entry in package.manifest.modules.entries) { for (final entry in package.manifest.modules.entries) {
_moduleScripts[entry.key] = await package.readText(entry.value); _moduleScripts[entry.key] = await package.readText(entry.value);
} }
}
// 入口脚本使用最后一个包(游戏包)。
final entryPackage = packages.last;
final script = await entryPackage.readText(entryPackage.manifest.entry);
_loadingModules.clear(); _loadingModules.clear();
_lua = LuaState.newState(); _lua = LuaState.newState();
@@ -37,6 +71,69 @@ class LuaDardoScriptEngine implements ScriptEngine {
} }
} }
@override
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
}) {
_lua.getGlobal('runtime');
if (_lua.isNil(-1)) {
_lua.pop(1);
return;
}
_lua.newTable();
_lua.pushString(locale);
_lua.setField(-2, 'locale');
_lua.pushDartFunction((LuaState lua) {
lua.pushString(locale);
return 1;
});
_lua.setField(-2, 'get_locale');
_lua.pushDartFunction((LuaState lua) {
final key = lua.toStr(1);
if (key == null || key.isEmpty) {
if (lua.getTop() >= 2 && !lua.isNil(2)) {
lua.pushValue(2);
} else {
lua.pushString(key ?? '');
}
return 1;
}
final localeDict = translations[locale];
final value = localeDict?[key];
if (value != null) {
lua.pushString(value);
return 1;
}
for (final entry in translations.entries) {
if (entry.key != locale) {
final fallbackValue = entry.value[key];
if (fallbackValue != null) {
lua.pushString(fallbackValue);
return 1;
}
}
}
if (lua.getTop() >= 2 && !lua.isNil(2)) {
lua.pushValue(2);
} else {
lua.pushString(key);
}
return 1;
});
_lua.setField(-2, 't');
_lua.setField(-2, 'i18n');
_lua.pop(1);
}
@override @override
bool smokeTest(Map<String, Object?> context) { bool smokeTest(Map<String, Object?> context) {
_lua.getGlobal('smoke_test'); _lua.getGlobal('smoke_test');
@@ -112,9 +209,288 @@ class LuaDardoScriptEngine implements ScriptEngine {
_lua.pushDartFunction(_log); _lua.pushDartFunction(_log);
_lua.setField(-2, 'log'); _lua.setField(-2, 'log');
_lua.pushDartFunction(_httpRequest);
_lua.setField(-2, 'http_request');
_lua.pushDartFunction(_wsConnect);
_lua.setField(-2, 'ws_connect');
_lua.pushDartFunction(_wsSend);
_lua.setField(-2, 'ws_send');
_lua.pushDartFunction(_wsClose);
_lua.setField(-2, 'ws_close');
_lua.pushDartFunction(_hostCall);
_lua.setField(-2, 'host_call');
_lua.pushDartFunction(_hostNotify);
_lua.setField(-2, 'host_notify');
_lua.pushDartFunction(_hostRespond);
_lua.setField(-2, 'host_respond');
_lua.pushDartFunction(_storageGet);
_lua.setField(-2, 'storage_get');
_lua.pushDartFunction(_storageSet);
_lua.setField(-2, 'storage_set');
_lua.pushDartFunction(_storageRemove);
_lua.setField(-2, 'storage_remove');
_lua.pushDartFunction(_storageClear);
_lua.setField(-2, 'storage_clear');
_lua.setGlobal('runtime'); _lua.setGlobal('runtime');
} }
int _httpRequest(LuaState lua) {
final network = _requireNetwork();
final options = _requiredMapArgument(1, 'runtime.http_request(options)');
final url = _requiredString(options, 'url');
final uri = Uri.parse(url);
final id = _optionalString(options, 'id') ?? _nextNetworkRequestId('http');
final method = (_optionalString(options, 'method') ?? 'GET').toUpperCase();
final headers = _optionalStringMap(options['headers'], 'headers');
final body = _optionalString(options, 'body');
final timeout = Duration(
milliseconds: ((_optionalNumber(options, 'timeout') ?? 15) * 1000)
.round(),
);
async.unawaited(
network.httpRequest(
RuntimeHttpRequest(
id: id,
method: method,
uri: uri,
headers: headers,
body: body,
timeout: timeout,
),
),
);
lua.pushString(id);
return 1;
}
int _wsConnect(LuaState lua) {
final network = _requireNetwork();
final options = _requiredMapArgument(1, 'runtime.ws_connect(options)');
final url = _requiredString(options, 'url');
final id = _optionalString(options, 'id') ?? _nextNetworkRequestId('ws');
network.wsConnect(
RuntimeWebSocketConnectRequest(
id: id,
uri: Uri.parse(url),
protocols: _optionalStringList(options['protocols'], 'protocols'),
),
);
lua.pushString(id);
return 1;
}
int _wsSend(LuaState lua) {
final network = _requireNetwork();
final id = lua.toStr(1);
if (id == null || id.isEmpty) {
throw const FormatException('runtime.ws_send(id, message) requires id');
}
final message = _formatLuaLogValue(lua, 2);
lua.pushBoolean(network.wsSend(id, message));
return 1;
}
int _wsClose(LuaState lua) {
final network = _requireNetwork();
final id = lua.toStr(1);
if (id == null || id.isEmpty) {
throw const FormatException('runtime.ws_close(id) requires id');
}
lua.pushBoolean(network.closeWebSocket(id));
return 1;
}
int _hostCall(LuaState lua) {
final host = _requireHostBridge();
final options = _requiredMapArgument(1, 'runtime.host_call(options)');
final method = _requiredString(options, 'method');
final id = _optionalString(options, 'id') ?? _nextHostCallId();
async.unawaited(
host.callHost(
RuntimeHostCall(id: id, method: method, data: options['data']),
),
);
lua.pushString(id);
return 1;
}
int _hostNotify(LuaState lua) {
final host = _requireHostBridge();
final options = _requiredMapArgument(1, 'runtime.host_notify(options)');
final method = _requiredString(options, 'method');
lua.pushBoolean(
host.notifyHost(
RuntimeHostNotification(method: method, data: options['data']),
),
);
return 1;
}
int _hostRespond(LuaState lua) {
final host = _requireHostBridge();
final options = _requiredMapArgument(1, 'runtime.host_respond(options)');
final id = _requiredString(options, 'id');
final error = _optionalString(options, 'error');
lua.pushBoolean(
host.completeLuaCall(id, result: options['result'], error: error),
);
return 1;
}
int _storageGet(LuaState lua) {
final storage = _requireStorage();
final key = lua.toStr(1);
if (key == null || key.isEmpty) {
throw const FormatException(
'runtime.storage_get(key, defaultValue) requires key',
);
}
final defaultValue = _readValue(2);
_pushValue(storage.getValue(key, defaultValue));
return 1;
}
int _storageSet(LuaState lua) {
final storage = _requireStorage();
final key = lua.toStr(1);
if (key == null || key.isEmpty) {
throw const FormatException(
'runtime.storage_set(key, value) requires key',
);
}
final value = _readValue(2);
lua.pushBoolean(storage.setValue(key, value));
return 1;
}
int _storageRemove(LuaState lua) {
final storage = _requireStorage();
final key = lua.toStr(1);
if (key == null || key.isEmpty) {
throw const FormatException('runtime.storage_remove(key) requires key');
}
lua.pushBoolean(storage.remove(key));
return 1;
}
int _storageClear(LuaState lua) {
lua.pushBoolean(_requireStorage().clear());
return 1;
}
RuntimeHostBridgeManager _requireHostBridge() {
final hostBridge = _services.hostBridge;
if (hostBridge == null) {
throw StateError('Runtime host bridge service is not installed');
}
return hostBridge;
}
String _nextHostCallId() {
_hostCallCounter += 1;
return 'lua:$_hostCallCounter';
}
RuntimeNetworkManager _requireNetwork() {
final network = _services.network;
if (network == null) {
throw StateError('Runtime network service is not installed');
}
return network;
}
RuntimeStorageManager _requireStorage() {
final storage = _services.storage;
if (storage == null) {
throw StateError('Runtime storage service is not installed');
}
return storage;
}
String _nextNetworkRequestId(String prefix) {
_networkRequestCounter += 1;
return '$prefix:$_networkRequestCounter';
}
Map<String, Object?> _requiredMapArgument(int index, String label) {
final value = _readValue(index);
if (value is Map) {
return Map<String, Object?>.from(value);
}
throw FormatException('$label requires a table');
}
String _requiredString(Map<String, Object?> map, String key) {
final value = _optionalString(map, key);
if (value == null) {
throw FormatException('$key must be a non-empty string');
}
return value;
}
String? _optionalString(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('$key must be a non-empty string');
}
double? _optionalNumber(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is num) {
return value.toDouble();
}
throw FormatException('$key must be a number');
}
Map<String, String> _optionalStringMap(Object? value, String key) {
if (value == null) {
return const {};
}
if (value is! Map) {
throw FormatException('$key must be a string map');
}
return {
for (final entry in value.entries)
entry.key.toString(): entry.value?.toString() ?? '',
};
}
List<String> _optionalStringList(Object? value, String key) {
if (value == null) {
return const [];
}
if (value is List) {
return value.map((item) => item.toString()).toList(growable: false);
}
if (value is Map) {
final entries = value.entries.toList()
..sort((a, b) => a.key.toString().compareTo(b.key.toString()));
return entries
.map((entry) => entry.value.toString())
.toList(growable: false);
}
throw FormatException('$key must be a string list');
}
int _log(LuaState lua) { int _log(LuaState lua) {
final argumentCount = lua.getTop(); final argumentCount = lua.getTop();
final messageParts = <String>[]; final messageParts = <String>[];

View File

@@ -0,0 +1,11 @@
import '../host/runtime_host_bridge.dart';
import '../network/runtime_network_manager.dart';
import '../storage/runtime_storage_manager.dart';
class RuntimeScriptServices {
const RuntimeScriptServices({this.network, this.hostBridge, this.storage});
final RuntimeNetworkManager? network;
final RuntimeHostBridgeManager? hostBridge;
final RuntimeStorageManager? storage;
}

View File

@@ -1,13 +1,31 @@
import '../models/game_diff.dart'; import '../models/game_diff.dart';
import '../models/runtime_event.dart'; import '../models/runtime_event.dart';
import '../packages/game_package.dart'; import '../packages/game_package.dart';
import 'runtime_script_services.dart';
abstract interface class ScriptEngine { abstract interface class ScriptEngine {
Future<void> loadPackage(GamePackage package); // 加载单个包(向后兼容,内部调 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); bool smokeTest(Map<String, Object?> context);
GameDiff init(Map<String, Object?> context); GameDiff init(Map<String, Object?> context);
GameDiff dispatchEvent(RuntimeEvent event); GameDiff dispatchEvent(RuntimeEvent event);
/// 设置翻译字典和当前语言Lua 侧可通过 runtime.i18n.t(key, fallback) 查询。
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
});
} }

View File

@@ -0,0 +1,92 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
class RuntimeStorageManager {
RuntimeStorageManager._(this._file, this._values);
final File _file;
final Map<String, Object?> _values;
static Future<RuntimeStorageManager> create({required String gameId}) async {
final root = await getApplicationSupportDirectory();
final directory = Directory(p.join(root.path, 'flame_lua_storage'));
if (!directory.existsSync()) {
directory.createSync(recursive: true);
}
final file = File(p.join(directory.path, '$gameId.json'));
if (!file.existsSync()) {
return RuntimeStorageManager._(file, <String, Object?>{});
}
try {
final raw = jsonDecode(file.readAsStringSync());
if (raw is Map) {
return RuntimeStorageManager._(file, Map<String, Object?>.from(raw));
}
} catch (_) {
// Corrupt storage should not prevent a game from loading.
}
return RuntimeStorageManager._(file, <String, Object?>{});
}
Object? getValue(String key, [Object? defaultValue]) {
if (!_values.containsKey(key)) {
return defaultValue;
}
return _values[key];
}
bool setValue(String key, Object? value) {
_values[key] = _normalize(value);
_flush();
return true;
}
bool remove(String key) {
final removed = _values.remove(key) != null;
if (removed) {
_flush();
}
return removed;
}
bool clear() {
if (_values.isEmpty) {
return false;
}
_values.clear();
_flush();
return true;
}
Map<String, Object?> debugJson() => Map<String, Object?>.from(_values);
Object? _normalize(Object? value) {
if (value == null || value is bool || value is num || value is String) {
return value;
}
if (value is List) {
return value.map(_normalize).toList(growable: false);
}
if (value is Map) {
return {
for (final entry in value.entries)
entry.key.toString(): _normalize(entry.value),
};
}
return value.toString();
}
void _flush() {
final parent = _file.parent;
if (!parent.existsSync()) {
parent.createSync(recursive: true);
}
_file.writeAsStringSync(jsonEncode(_values));
}
}

View File

@@ -15,6 +15,7 @@ dependencies:
archive: ^4.0.9 archive: ^4.0.9
crypto: ^3.0.7 crypto: ^3.0.7
http: ^1.6.0 http: ^1.6.0
web_socket_channel: ^3.0.3
path_provider: ^2.1.5 path_provider: ^2.1.5
path: ^1.9.1 path: ^1.9.1
audioplayers: ^6.7.1 audioplayers: ^6.7.1
@@ -23,6 +24,7 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
stream_channel: ^2.1.4
flutter: flutter:
assets: assets:

View File

@@ -4,6 +4,7 @@ import 'package:flame_lua_runtime/runtime/lifecycle/runtime_session.dart';
import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; import 'package:flame_lua_runtime/runtime/models/game_diff.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart';
import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -137,6 +138,7 @@ void main() {
expect(script.events.map((event) => event.target), ['bad', 'good']); expect(script.events.map((event) => event.target), ['bad', 'good']);
}); });
}); });
} }
RuntimeSession _activeSession() { RuntimeSession _activeSession() {
@@ -150,7 +152,16 @@ class _FakeScriptEngine implements ScriptEngine {
bool failNext = false; bool failNext = false;
@override @override
Future<void> loadPackage(GamePackage package) async {} Future<void> loadPackage(
GamePackage package, {
RuntimeScriptServices services = const RuntimeScriptServices(),
}) async {}
@override
Future<void> loadPackages(
List<GamePackage> packages, {
RuntimeScriptServices services = const RuntimeScriptServices(),
}) async {}
@override @override
bool smokeTest(Map<String, Object?> context) => true; bool smokeTest(Map<String, Object?> context) => true;
@@ -167,4 +178,10 @@ class _FakeScriptEngine implements ScriptEngine {
} }
return GameDiff.empty; return GameDiff.empty;
} }
@override
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
}) {}
} }

View File

@@ -5,12 +5,13 @@ import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart';
import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart';
import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('FlameLuaGame diagnostics debug access', () { group('FlameLuaGame diagnostics debug access', () {
test('exposes diagnostics entries, dump text and debug json', () { test('exposes diagnostics entries, dump text and debug json', () async {
final diagnostics = RuntimeDiagnostics() final diagnostics = RuntimeDiagnostics()
..record( ..record(
type: RuntimeDiagnosticType.commandError, type: RuntimeDiagnosticType.commandError,
@@ -28,13 +29,27 @@ void main() {
expect(game.diagnosticsDumpText(), contains('command failed')); expect(game.diagnosticsDumpText(), contains('command failed'));
expect(game.diagnosticsDebugJson()['count'], 1); expect(game.diagnosticsDebugJson()['count'], 1);
expect(game.resourcesDebugJson(), {'initialized': false}); expect(game.resourcesDebugJson(), {'initialized': false});
expect(game.notifyLua('host.ready'), isFalse);
await expectLater(game.callLua('host.ready'), throwsA(isA<StateError>()));
}); });
}); });
} }
class _FakeScriptEngine implements ScriptEngine { class _FakeScriptEngine implements ScriptEngine {
@override @override
Future<void> loadPackage(GamePackage package) { Future<void> loadPackage(
GamePackage package, {
RuntimeScriptServices services = const RuntimeScriptServices(),
}) {
throw UnimplementedError();
}
@override
Future<void> loadPackages(
List<GamePackage> packages, {
RuntimeScriptServices services = const RuntimeScriptServices(),
}) {
throw UnimplementedError(); throw UnimplementedError();
} }
@@ -52,6 +67,12 @@ class _FakeScriptEngine implements ScriptEngine {
bool smokeTest(Map<String, Object?> context) { bool smokeTest(Map<String, Object?> context) {
throw UnimplementedError(); throw UnimplementedError();
} }
@override
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
}) {}
} }
class _FakePackageRepository implements GamePackageRepository { class _FakePackageRepository implements GamePackageRepository {

View File

@@ -0,0 +1,93 @@
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
import 'package:flame_lua_runtime/runtime/host/runtime_host_bridge.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('RuntimeHostBridgeManager', () {
test('calls registered host handler and emits result event', () async {
final events = <RuntimeEvent>[];
final manager = RuntimeHostBridgeManager(
bridge: RuntimeHostBridge(
handlers: {
'user.profile': (call) => {'name': 'Lua', 'id': call.data},
},
),
eventSink: events.add,
);
await manager.callHost(
const RuntimeHostCall(id: 'call_1', method: 'user.profile', data: 7),
);
expect(events.single.type, RuntimeHostEventType.callResult);
expect(events.single.data['ok'], isTrue);
expect(events.single.data['result'], {'name': 'Lua', 'id': 7});
});
test(
'emits failed result and diagnostics when host handler throws',
() async {
final diagnostics = RuntimeDiagnostics();
final events = <RuntimeEvent>[];
final manager = RuntimeHostBridgeManager(
bridge: RuntimeHostBridge(
handlers: {'boom': (_) => throw StateError('boom')},
),
eventSink: events.add,
diagnostics: diagnostics,
);
await manager.callHost(
const RuntimeHostCall(id: 'call_1', method: 'boom'),
);
expect(events.single.data['ok'], isFalse);
expect(events.single.data['error'], contains('boom'));
expect(
diagnostics.entries.single.type,
RuntimeDiagnosticType.hostBridgeError,
);
},
);
test('notifies host and emits Lua calls', () async {
RuntimeHostNotification? notification;
final events = <RuntimeEvent>[];
final manager = RuntimeHostBridgeManager(
bridge: RuntimeHostBridge(onNotify: (value) => notification = value),
eventSink: events.add,
);
expect(
manager.notifyHost(
const RuntimeHostNotification(
method: 'analytics',
data: {'level': 2},
),
),
isTrue,
);
expect(notification?.method, 'analytics');
expect(manager.notifyLua('pause', data: {'reason': 'host'}), isTrue);
expect(events.single.type, RuntimeHostEventType.notify);
expect(events.single.data['method'], 'pause');
});
test('completes Flutter-to-Lua call through host response', () async {
final events = <RuntimeEvent>[];
final manager = RuntimeHostBridgeManager(
bridge: const RuntimeHostBridge(),
eventSink: events.add,
);
final future = manager.callLua('select_avatar', data: {'current': 1});
final id = events.single.data['id']! as String;
expect(events.single.type, RuntimeHostEventType.call);
expect(manager.completeLuaCall(id, result: {'selected': 3}), isTrue);
expect(await future, {'selected': 3});
});
});
}

View File

@@ -0,0 +1,195 @@
import 'dart:async';
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
import 'package:flame_lua_runtime/runtime/network/runtime_network_manager.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
void main() {
group('RuntimeNetworkManager', () {
test('supports http and https requests', () async {
final events = <RuntimeEvent>[];
final manager = RuntimeNetworkManager(
eventSink: events.add,
httpClient: MockClient((request) async {
return http.Response(
'ok',
201,
headers: {'content-type': 'text/plain'},
);
}),
);
await manager.httpRequest(
RuntimeHttpRequest(
id: 'http_1',
method: 'GET',
uri: Uri.parse('http://example.com/ping'),
),
);
await manager.httpRequest(
RuntimeHttpRequest(
id: 'https_1',
method: 'POST',
uri: Uri.parse('https://example.com/ping'),
body: 'body',
),
);
expect(events.map((event) => event.type), [
RuntimeNetworkEventType.http,
RuntimeNetworkEventType.http,
]);
expect(events.first.data['ok'], isTrue);
expect(events.first.data['status'], 201);
expect(events.first.data['body'], 'ok');
expect(events.last.data['id'], 'https_1');
});
test('rejects unsupported HTTP schemes with error event', () async {
final diagnostics = RuntimeDiagnostics();
final events = <RuntimeEvent>[];
final manager = RuntimeNetworkManager(
eventSink: events.add,
diagnostics: diagnostics,
httpClient: MockClient((_) async => http.Response('no', 500)),
);
await manager.httpRequest(
RuntimeHttpRequest(
id: 'bad',
method: 'GET',
uri: Uri.parse('ftp://example.com/file'),
),
);
expect(events.single.type, RuntimeNetworkEventType.http);
expect(events.single.data['ok'], isFalse);
expect(events.single.data['error'], contains('http/https'));
expect(
diagnostics.entries.single.type,
RuntimeDiagnosticType.networkError,
);
});
test('supports ws and wss connections plus send and close', () async {
final events = <RuntimeEvent>[];
final controllers = <StreamChannelController<Object?>>[];
addTearDown(() {
for (final controller in controllers) {
controller.local.sink.close();
}
});
final manager = RuntimeNetworkManager(
eventSink: events.add,
webSocketFactory: (_, {protocols}) {
final controller = StreamChannelController<Object?>();
controllers.add(controller);
return _FakeWebSocketChannel(controller.foreign);
},
);
manager.wsConnect(
RuntimeWebSocketConnectRequest(
id: 'ws_1',
uri: Uri.parse('ws://example.com/socket'),
),
);
manager.wsConnect(
RuntimeWebSocketConnectRequest(
id: 'wss_1',
uri: Uri.parse('wss://example.com/socket'),
protocols: const ['game.v1'],
),
);
expect(manager.wsSend('wss_1', 'hello'), isTrue);
controllers.last.local.sink.add('server');
await Future<void>.delayed(Duration.zero);
expect(manager.closeWebSocket('wss_1'), isTrue);
expect(
events.map((event) => event.type),
contains(RuntimeNetworkEventType.wsOpen),
);
expect(
events.where((event) => event.type == RuntimeNetworkEventType.wsOpen),
hasLength(2),
);
expect(
events
.where((event) => event.type == RuntimeNetworkEventType.wsMessage)
.single
.data['message'],
'server',
);
expect(events.last.type, RuntimeNetworkEventType.wsClose);
});
test('rejects unsupported WebSocket schemes with error event', () {
final events = <RuntimeEvent>[];
final manager = RuntimeNetworkManager(eventSink: events.add);
manager.wsConnect(
RuntimeWebSocketConnectRequest(
id: 'bad',
uri: Uri.parse('http://example.com/socket'),
),
);
expect(events.single.type, RuntimeNetworkEventType.wsError);
expect(events.single.data['error'], contains('ws/wss'));
});
});
}
class _FakeWebSocketChannel extends StreamChannelMixin
implements WebSocketChannel {
_FakeWebSocketChannel(StreamChannel<Object?> channel) : _channel = channel;
final StreamChannel<Object?> _channel;
@override
Stream get stream => _channel.stream;
@override
WebSocketSink get sink => _FakeWebSocketSink(_channel.sink);
@override
int? get closeCode => null;
@override
String? get closeReason => null;
@override
String? get protocol => null;
@override
Future<void> get ready => Future<void>.value();
}
class _FakeWebSocketSink implements WebSocketSink {
_FakeWebSocketSink(this._sink);
final StreamSink<Object?> _sink;
@override
void add(Object? event) => _sink.add(event);
@override
void addError(Object error, [StackTrace? stackTrace]) {
_sink.addError(error, stackTrace);
}
@override
Future<void> addStream(Stream stream) => _sink.addStream(stream);
@override
Future<void> close([int? closeCode, String? closeReason]) => _sink.close();
@override
Future<void> get done => _sink.done;
}

View File

@@ -9,6 +9,7 @@ import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart';
import 'package:flame_lua_runtime/runtime/packages/stable_package_store.dart'; import 'package:flame_lua_runtime/runtime/packages/stable_package_store.dart';
import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart';
import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -216,6 +217,7 @@ void main() {
expect(store.markedPackages, [fallback.rootPath]); expect(store.markedPackages, [fallback.rootPath]);
}); });
}); });
} }
Map<String, Object?> _context(GamePackage package) { Map<String, Object?> _context(GamePackage package) {
@@ -357,11 +359,25 @@ class _FakeScriptEngine implements ScriptEngine {
GamePackage? _package; GamePackage? _package;
@override @override
Future<void> loadPackage(GamePackage package) async { Future<void> loadPackage(
GamePackage package, {
RuntimeScriptServices services = const RuntimeScriptServices(),
}) async {
_package = package; _package = package;
loadedPackages.add(package.rootPath); 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 @override
bool smokeTest(Map<String, Object?> context) { bool smokeTest(Map<String, Object?> context) {
return !smokeFailures.contains(_package?.rootPath); return !smokeFailures.contains(_package?.rootPath);
@@ -378,6 +394,12 @@ class _FakeScriptEngine implements ScriptEngine {
@override @override
GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty; GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty;
@override
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
}) {}
} }
const _validScript = ''' const _validScript = '''

View File

@@ -0,0 +1,220 @@
import 'dart:convert';
import 'dart:io';
import 'package:flame_lua_runtime/runtime/game/runtime_options.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart';
import 'package:flame_lua_runtime/runtime/packages/stable_package_store.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
group('FileGamePackageRepository', () {
test('loads a package from a local development directory', () async {
final root = await Directory.systemTemp.createTemp('local_packages_');
addTearDown(() => root.deleteSync(recursive: true));
await _writePackage(root.path, 'gomoku', version: '0.2.0');
final package = await FileGamePackageRepository(
baseDirectory: root.path,
runtimeOptions: const RuntimeOptions(runtimeLuaRoot: 'runtime/lua'),
).load('gomoku');
expect(package.source, GamePackageSource.file);
expect(package.manifest.gameId, 'gomoku');
expect(package.manifest.version, '0.2.0');
expect(package.runtimeLuaRoot, 'runtime/lua');
expect(await package.readText('scripts/main.lua'), contains('init'));
});
});
group('RemoteGamePackageRepository compatibility', () {
test(
'sends host compatibility query and falls back when incompatible',
() async {
late Uri requestedUri;
var downloadedPackage = false;
final fallback = await _createPackage('fallback');
final client = MockClient((request) async {
requestedUri = request.url;
if (request.url.path.endsWith('/remote_manifest.json')) {
return http.Response(
jsonEncode({
'gameId': 'gomoku',
'version': '2.0.0',
'packageUrl': 'http://example.test/packages/gomoku.zip',
'sha256': 'unused',
'compat': {
'runtimeApiVersion': 1,
'minRuntimeVersion': '1.2.0',
'minHostBuild': 200,
'platforms': ['windows'],
'channels': ['prod'],
},
}),
200,
);
}
downloadedPackage = true;
return http.Response('not used', 500);
});
final package = await RemoteGamePackageRepository(
baseUri: Uri.parse('http://example.test/'),
client: client,
fallback: _SinglePackageRepository(fallback),
store: _EmptyStablePackageStore(),
runtimeOptions: const RuntimeOptions(
runtimeVersion: '1.1.0',
hostBuild: 100,
channel: 'prod',
platform: 'windows',
),
).load('gomoku');
expect(package.rootPath, fallback.rootPath);
expect(requestedUri.queryParameters['runtimeApiVersion'], '1');
expect(requestedUri.queryParameters['runtimeVersion'], '1.1.0');
expect(requestedUri.queryParameters['hostBuild'], '100');
expect(requestedUri.queryParameters['platform'], 'windows');
expect(requestedUri.queryParameters['channel'], 'prod');
expect(downloadedPackage, isFalse);
},
);
});
group('StablePackageStore', () {
const channel = MethodChannel('plugins.flutter.io/path_provider');
setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test('ignores empty or malformed stable marker files', () async {
final support = await Directory.systemTemp.createTemp('support_');
addTearDown(() => support.deleteSync(recursive: true));
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getApplicationSupportDirectory') {
return support.path;
}
return null;
});
final store = const StablePackageStore();
final gameDir = Directory('${support.path}/flame_lua_packages/gomoku')
..createSync(recursive: true);
final marker = File('${gameDir.path}/stable.json');
marker.writeAsStringSync('');
expect(await store.stablePackage('gomoku'), isNull);
expect(await store.previousStablePackage('gomoku'), isNull);
marker.writeAsStringSync('{bad json');
expect(await store.stablePackage('gomoku'), isNull);
expect(await store.previousStablePackage('gomoku'), isNull);
});
test('writes stable marker atomically and reads current package', () async {
final support = await Directory.systemTemp.createTemp('support_');
addTearDown(() => support.deleteSync(recursive: true));
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getApplicationSupportDirectory') {
return support.path;
}
return null;
});
final package = await _createPackage('stable');
final store = const StablePackageStore();
await store.markStable(package);
final marker = File(
'${support.path}/flame_lua_packages/gomoku/stable.json',
);
expect(marker.existsSync(), isTrue);
expect(File('${marker.path}.tmp').existsSync(), isFalse);
final stable = await store.stablePackage('gomoku');
expect(stable?.rootPath, package.rootPath);
});
});
}
Future<GamePackage> _createPackage(String name) async {
final root = await Directory.systemTemp.createTemp('package_${name}_');
addTearDown(() => root.deleteSync(recursive: true));
await _writePackageRoot(root.path, gameId: 'gomoku', version: '1.0.0');
return GamePackage.file(
rootPath: root.path,
manifest: GamePackageManifest.fromJsonString(
await File('${root.path}/manifest.json').readAsString(),
),
);
}
Future<void> _writePackage(
String baseDirectory,
String gameId, {
required String version,
}) async {
final root = Directory('$baseDirectory/$gameId')..createSync(recursive: true);
await _writePackageRoot(root.path, gameId: gameId, version: version);
}
Future<void> _writePackageRoot(
String root, {
required String gameId,
required String version,
}) async {
Directory('$root/scripts').createSync(recursive: true);
await File('$root/scripts/main.lua').writeAsString('''
function smoke_test(ctx) return true end
function init(ctx) return {} end
function on_event(event) return {} end
''');
await File('$root/manifest.json').writeAsString(
jsonEncode({
'gameId': gameId,
'name': gameId,
'version': version,
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'assetsBase': 'assets',
}),
);
}
class _SinglePackageRepository implements GamePackageRepository {
const _SinglePackageRepository(this.package);
final GamePackage package;
@override
Future<GamePackage> load(String gameId) async => package;
}
class _EmptyStablePackageStore implements StablePackageStore {
@override
Future<Directory> cacheRoot() => throw UnimplementedError();
@override
Future<void> markStable(GamePackage package) async {}
@override
Future<GamePackage?> previousStablePackage(String gameId) async => null;
@override
Future<GamePackage?> stablePackage(String gameId) async => null;
@override
Future<Directory> versionDirectory(String gameId, String version) =>
throw UnimplementedError();
}

View File

@@ -4,7 +4,14 @@ import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
test('public runtime API exposes minimal integration surface', () { test('public runtime API exposes minimal integration surface', () {
const repository = AssetGamePackageRepository(); const repository = AssetGamePackageRepository();
const options = RuntimeOptions(runtimeLuaRoot: 'custom/runtime/lua'); const fileRepository = FileGamePackageRepository(baseDirectory: 'packages');
const options = RuntimeOptions(
runtimeLuaRoot: 'custom/runtime/lua',
runtimeVersion: '1.2.0',
hostBuild: 12,
platform: 'windows',
channel: 'dev',
);
const widget = LuaGameWidget( const widget = LuaGameWidget(
gameId: 'template', gameId: 'template',
packageRepository: repository, packageRepository: repository,
@@ -14,6 +21,11 @@ void main() {
expect(widget.gameId, 'template'); expect(widget.gameId, 'template');
expect(widget.packageRepository, same(repository)); expect(widget.packageRepository, same(repository));
expect(widget.runtimeOptions.runtimeLuaRoot, 'custom/runtime/lua'); expect(widget.runtimeOptions.runtimeLuaRoot, 'custom/runtime/lua');
expect(widget.runtimeOptions.runtimeVersion, '1.2.0');
expect(widget.runtimeOptions.hostBuild, 12);
expect(widget.runtimeOptions.platform, 'windows');
expect(widget.runtimeOptions.channel, 'dev');
expect(fileRepository.baseDirectory, 'packages');
expect(LuaDardoScriptEngine.new, isA<ScriptEngine Function()>()); expect(LuaDardoScriptEngine.new, isA<ScriptEngine Function()>());
expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans'); expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans');
}); });

View File

@@ -284,6 +284,39 @@ void main() {
expect(parts[4].source, const Rect.fromLTRB(10, 10, 20, 20)); expect(parts[4].source, const Rect.fromLTRB(10, 10, 20, 20));
}); });
test('insets nine-slice source rects to avoid atlas edge sampling', () {
final parts = runtimeNineSliceRects(
source: const Rect.fromLTWH(10, 20, 30, 30),
destination: const Rect.fromLTWH(0, 0, 90, 90),
sliceLeft: 10,
sliceTop: 10,
sliceRight: 10,
sliceBottom: 10,
sourceInset: 0.5,
);
expect(parts.first.source, const Rect.fromLTRB(10.5, 20.5, 19.5, 29.5));
expect(parts[4].source, const Rect.fromLTRB(20.5, 30.5, 29.5, 39.5));
expect(parts.last.source, const Rect.fromLTRB(30.5, 40.5, 39.5, 49.5));
});
test(
'keeps tiny nine-slice source rects when inset would collapse them',
() {
final parts = runtimeNineSliceRects(
source: const Rect.fromLTWH(0, 0, 3, 3),
destination: const Rect.fromLTWH(0, 0, 30, 30),
sliceLeft: 1,
sliceTop: 1,
sliceRight: 1,
sliceBottom: 1,
sourceInset: 0.5,
);
expect(parts[4].source, const Rect.fromLTRB(1, 1, 2, 2));
},
);
test('updates text alpha style without rebuilding text component', () { test('updates text alpha style without rebuilding text component', () {
final component = RuntimeComponent( final component = RuntimeComponent(
node: const RuntimeNode( node: const RuntimeNode(

View File

@@ -3,9 +3,12 @@ import 'dart:io';
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
import 'package:flame_lua_runtime/runtime/host/runtime_host_bridge.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
import 'package:flame_lua_runtime/runtime/network/runtime_network_manager.dart';
import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart';
import 'package:flame_lua_runtime/runtime/scripting/lua_dardo_script_engine.dart'; import 'package:flame_lua_runtime/runtime/scripting/lua_dardo_script_engine.dart';
import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
Future<GamePackage> _loadExamplePackage(String gameId) async { Future<GamePackage> _loadExamplePackage(String gameId) async {
@@ -941,6 +944,135 @@ end
expect(diagnostics.entries.first.context, {'argumentCount': 2}); expect(diagnostics.entries.first.context, {'argumentCount': 2});
}); });
test('exposes async HTTP runtime API to Lua', () async {
final package = await _createPackage(
mainScript: '''
function smoke_test(ctx) return true end
function init(ctx)
local id = runtime.http_request({
id = "login",
method = "post",
url = "https://example.com/login",
headers = { Authorization = "Bearer token" },
body = "{}",
timeout = 2,
})
return { commands = { { type = "toast", text = id } } }
end
function on_event(event) return {} end
''',
);
final network = _RecordingNetworkManager();
final engine = LuaDardoScriptEngine();
await engine.loadPackage(
package,
services: RuntimeScriptServices(network: network),
);
final diff = engine.init({'runtimeApiVersion': 1});
expect(diff.commands.single.payload['text'], 'login');
expect(network.httpRequests, hasLength(1));
expect(network.httpRequests.single.id, 'login');
expect(network.httpRequests.single.method, 'POST');
expect(network.httpRequests.single.uri.scheme, 'https');
expect(
network.httpRequests.single.headers['Authorization'],
'Bearer token',
);
expect(network.httpRequests.single.body, '{}');
expect(network.httpRequests.single.timeout, const Duration(seconds: 2));
});
test('exposes WebSocket runtime API to Lua', () async {
final package = await _createPackage(
mainScript: '''
function smoke_test(ctx) return true end
function init(ctx)
local id = runtime.ws_connect({
id = "chat",
url = "wss://example.com/socket",
protocols = { "game.v1" },
})
local sent = runtime.ws_send(id, "hello")
local closed = runtime.ws_close(id)
return {
commands = {
{ type = "toast", text = id .. ":" .. tostring(sent) .. ":" .. tostring(closed) },
},
}
end
function on_event(event) return {} end
''',
);
final network = _RecordingNetworkManager();
final engine = LuaDardoScriptEngine();
await engine.loadPackage(
package,
services: RuntimeScriptServices(network: network),
);
final diff = engine.init({'runtimeApiVersion': 1});
expect(diff.commands.single.payload['text'], 'chat:true:true');
expect(network.wsConnectRequests.single.id, 'chat');
expect(network.wsConnectRequests.single.uri.scheme, 'wss');
expect(network.wsConnectRequests.single.protocols, ['game.v1']);
expect(network.wsMessages, {'chat': 'hello'});
expect(network.closedWebSockets, ['chat']);
});
test('exposes host bridge runtime API to Lua', () async {
final package = await _createPackage(
mainScript: '''
function smoke_test(ctx) return true end
function init(ctx)
local id = runtime.host_call({
id = "profile",
method = "user.profile",
data = { userId = 9 },
})
local notified = runtime.host_notify({
method = "analytics",
data = { event = "open" },
})
return {
commands = {
{ type = "toast", text = id .. ":" .. tostring(notified) },
},
}
end
function on_event(event)
if event.type == "host_call" then
runtime.host_respond({ id = event.data.id, result = { handled = event.data.method } })
end
return {}
end
''',
);
final hostBridge = _RecordingHostBridgeManager();
final engine = LuaDardoScriptEngine();
await engine.loadPackage(
package,
services: RuntimeScriptServices(hostBridge: hostBridge),
);
final diff = engine.init({'runtimeApiVersion': 1});
expect(diff.commands.single.payload['text'], 'profile:true');
expect(hostBridge.calls.single.method, 'user.profile');
expect(hostBridge.calls.single.data, {'userId': 9});
expect(hostBridge.notifications.single.method, 'analytics');
expect(hostBridge.notifications.single.data, {'event': 'open'});
final callFuture = hostBridge.callLua('flutter.request', data: {'id': 2});
final event = _RecordingHostBridgeManager.events.singleWhere(
(item) => item.type == RuntimeHostEventType.call,
);
engine.dispatchEvent(event);
expect(await callFuture, {'handled': 'flutter.request'});
});
test('rejects undeclared module imports', () async { test('rejects undeclared module imports', () async {
final package = await _createPackage( final package = await _createPackage(
mainScript: ''' mainScript: '''
@@ -972,6 +1104,63 @@ function on_event(event) return {} end
}); });
} }
class _RecordingHostBridgeManager extends RuntimeHostBridgeManager {
_RecordingHostBridgeManager()
: super(bridge: const RuntimeHostBridge(), eventSink: events.add);
static final events = <RuntimeEvent>[];
final calls = <RuntimeHostCall>[];
final notifications = <RuntimeHostNotification>[];
@override
Future<void> callHost(RuntimeHostCall call) async {
calls.add(call);
}
@override
bool notifyHost(RuntimeHostNotification notification) {
notifications.add(notification);
return true;
}
}
class _RecordingNetworkManager extends RuntimeNetworkManager {
_RecordingNetworkManager()
: super(
eventSink: (event) {
events.add(event);
},
);
static final events = <RuntimeEvent>[];
final httpRequests = <RuntimeHttpRequest>[];
final wsConnectRequests = <RuntimeWebSocketConnectRequest>[];
final wsMessages = <String, Object?>{};
final closedWebSockets = <String>[];
@override
Future<void> httpRequest(RuntimeHttpRequest request) async {
httpRequests.add(request);
}
@override
void wsConnect(RuntimeWebSocketConnectRequest request) {
wsConnectRequests.add(request);
}
@override
bool wsSend(String id, Object? message) {
wsMessages[id] = message;
return true;
}
@override
bool closeWebSocket(String id, {bool emitClose = true}) {
closedWebSockets.add(id);
return true;
}
}
Future<GamePackage> _createPackage({ Future<GamePackage> _createPackage({
required String mainScript, required String mainScript,
Map<String, String> modules = const {}, Map<String, String> modules = const {},

View File

@@ -64,6 +64,14 @@
---| 'animation_done' ---| 'animation_done'
---| 'resize' ---| 'resize'
---| 'scroll' ---| 'scroll'
---| 'network_http'
---| 'network_ws_open'
---| 'network_ws_message'
---| 'network_ws_error'
---| 'network_ws_close'
---| 'host_notify'
---| 'host_call'
---| 'host_call_result'
---@alias RuntimeScaleMode ---@alias RuntimeScaleMode
---| 'fit' ---| 'fit'
@@ -580,9 +588,43 @@
---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_group fun(group: string): RuntimeCommand
---@field cancel_scope fun(scope: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand
---@class RuntimeHttpRequestOptions
---@field id? string
---@field method? string HTTP method. Defaults to GET.
---@field url string http/https URL.
---@field headers? table<string, string>
---@field body? string
---@field timeout? number Timeout in seconds. Defaults to 15.
---@class RuntimeWsConnectOptions
---@field id? string
---@field url string ws/wss URL.
---@field protocols? string[]
---@class RuntimeHostCallOptions
---@field id? string
---@field method string Host method name registered by Flutter.
---@field data? any
---@class RuntimeHostNotifyOptions
---@field method string Host notification name registered by Flutter.
---@field data? any
---@class RuntimeHostRespondOptions
---@field id string Host-to-Lua call id from host_call event.
---@field result? any
---@field error? string
---@class RuntimeImportApi ---@class RuntimeImportApi
---@field import fun(moduleName: string): table ---@field import fun(moduleName: string): table
---@field log fun(...: any) ---@field log fun(...: any)
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
---@field ws_send fun(id: string, message: string): boolean
---@field ws_close fun(id: string): boolean
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
---@type RuntimeImportApi ---@type RuntimeImportApi
runtime = runtime runtime = runtime