Compare commits
16 Commits
5ebe6ee786
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea3663853 | ||
|
|
79ee35db2f | ||
|
|
6608d0a975 | ||
|
|
8ddc3be3a7 | ||
|
|
0d4fbd030c | ||
|
|
7b3c5cb0f5 | ||
|
|
4f36d68b74 | ||
|
|
220bb0aba1 | ||
|
|
4ce5fe1ae7 | ||
|
|
638ea22562 | ||
|
|
38f6e0c0c9 | ||
|
|
e2a584d4dc | ||
|
|
409942b4af | ||
|
|
5e6a4877f4 | ||
|
|
8d2c97269a | ||
|
|
45ab9d7861 |
@@ -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.
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Added TexturePacker frame, manual source-region, and nine-slice image rendering fields for image-capable nodes.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
|
|
||||||
- Initial extracted package skeleton for `flame_lua_runtime`.
|
- Initial extracted package skeleton for `flame_lua_runtime`.
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -34,6 +34,80 @@ Supported node concepts include:
|
|||||||
|
|
||||||
Lua may compose higher-level widgets, but those widgets must normalize into supported runtime nodes.
|
Lua may compose higher-level widgets, but those widgets must normalize into supported runtime nodes.
|
||||||
|
|
||||||
|
### Color and alpha
|
||||||
|
|
||||||
|
`RuntimeNode.color` supports `#RRGGBB` and `#AARRGGBB`. When the alpha channel is present in the color, it is multiplied with `RuntimeNode.alpha` and any runtime animation alpha.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Final opacity = color alpha × node alpha × runtime animation alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
`#RRGGBB` colors behave as fully opaque colors. `#00000000` is fully transparent.
|
||||||
|
|
||||||
|
### Text shadow
|
||||||
|
|
||||||
|
Text-capable nodes may use flat shadow fields:
|
||||||
|
|
||||||
|
- `textShadowColor`: `#RRGGBB` or `#AARRGGBB` shadow color.
|
||||||
|
- `textShadowOffsetX` / `textShadowOffsetY`: shadow offset in runtime pixels.
|
||||||
|
- `textShadowBlur`: non-negative blur radius.
|
||||||
|
|
||||||
|
The shadow color alpha is multiplied by `RuntimeNode.alpha` and any runtime animation alpha.
|
||||||
|
|
||||||
|
### Image regions and nine-slice
|
||||||
|
|
||||||
|
Image-capable nodes (`image`, `sprite`, and image-backed `button`) may draw only part of an asset.
|
||||||
|
|
||||||
|
For manual atlas regions, set source fields in image pixels:
|
||||||
|
|
||||||
|
- `sourceX` / `sourceY`: top-left source position inside the loaded image.
|
||||||
|
- `sourceWidth` / `sourceHeight`: source region size.
|
||||||
|
|
||||||
|
For TexturePacker JSON atlases, declare `atlas` on an image resource and set `frame` on the node. Image-backed buttons may also use `pressedFrame` and `disabledFrame`. The runtime supports TexturePacker JSON Hash and JSON Array `frames` formats with non-rotated frames. `rotated: true` frames are rejected.
|
||||||
|
|
||||||
|
When frame/source fields are omitted, the full image is used.
|
||||||
|
|
||||||
|
Image-capable nodes may also use nine-slice scaling with source-pixel insets:
|
||||||
|
|
||||||
|
- `sliceLeft` / `sliceTop` / `sliceRight` / `sliceBottom`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -86,6 +94,17 @@
|
|||||||
---@field type RuntimeNodeType
|
---@field type RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -109,6 +128,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -143,6 +166,17 @@
|
|||||||
---@field type? RuntimeNodeType
|
---@field type? RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -166,6 +200,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -550,8 +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 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
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -86,6 +94,17 @@
|
|||||||
---@field type RuntimeNodeType
|
---@field type RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -109,6 +128,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -143,6 +166,17 @@
|
|||||||
---@field type? RuntimeNodeType
|
---@field type? RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -166,6 +200,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -550,8 +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 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
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -86,6 +94,17 @@
|
|||||||
---@field type RuntimeNodeType
|
---@field type RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -109,6 +128,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -143,6 +166,17 @@
|
|||||||
---@field type? RuntimeNodeType
|
---@field type? RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -166,6 +200,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -550,8 +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 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
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -86,6 +94,17 @@
|
|||||||
---@field type RuntimeNodeType
|
---@field type RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -109,6 +128,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -143,6 +166,17 @@
|
|||||||
---@field type? RuntimeNodeType
|
---@field type? RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -166,6 +200,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -550,8 +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 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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -130,9 +130,12 @@ String _formatDebugValue(Object? value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum RuntimeDiagnosticType {
|
enum RuntimeDiagnosticType {
|
||||||
|
luaLog,
|
||||||
luaEventError,
|
luaEventError,
|
||||||
diffApplyError,
|
diffApplyError,
|
||||||
packageActivationError,
|
packageActivationError,
|
||||||
resourceLoadError,
|
resourceLoadError,
|
||||||
commandError,
|
commandError,
|
||||||
|
networkError,
|
||||||
|
hostBridgeError,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flame/game.dart';
|
import 'package:flame/game.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.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';
|
||||||
@@ -13,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,13 +24,17 @@ 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) {
|
||||||
|
final diagnostics = RuntimeDiagnostics();
|
||||||
return GameWidget(
|
return GameWidget(
|
||||||
game: FlameLuaGame(
|
game: FlameLuaGame(
|
||||||
scriptEngine: LuaDardoScriptEngine(),
|
scriptEngine: LuaDardoScriptEngine(diagnostics: diagnostics),
|
||||||
scriptEngineFactory: LuaDardoScriptEngine.new,
|
scriptEngineFactory: () =>
|
||||||
|
LuaDardoScriptEngine(diagnostics: diagnostics),
|
||||||
|
diagnostics: diagnostics,
|
||||||
packageRepository:
|
packageRepository:
|
||||||
packageRepository ??
|
packageRepository ??
|
||||||
(serverUrl == null
|
(serverUrl == null
|
||||||
@@ -38,6 +45,7 @@ class LuaGameWidget extends StatelessWidget {
|
|||||||
)),
|
)),
|
||||||
gameId: gameId,
|
gameId: gameId,
|
||||||
runtimeOptions: runtimeOptions,
|
runtimeOptions: runtimeOptions,
|
||||||
|
hostBridge: hostBridge,
|
||||||
localeOverride: localeOverride,
|
localeOverride: localeOverride,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
228
lib/runtime/host/runtime_host_bridge.dart
Normal file
228
lib/runtime/host/runtime_host_bridge.dart
Normal 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';
|
||||||
|
}
|
||||||
@@ -8,6 +8,17 @@ class RuntimeNode {
|
|||||||
required this.type,
|
required this.type,
|
||||||
this.parent,
|
this.parent,
|
||||||
this.asset,
|
this.asset,
|
||||||
|
this.frame,
|
||||||
|
this.pressedFrame,
|
||||||
|
this.disabledFrame,
|
||||||
|
this.sourceX,
|
||||||
|
this.sourceY,
|
||||||
|
this.sourceWidth,
|
||||||
|
this.sourceHeight,
|
||||||
|
this.sliceLeft,
|
||||||
|
this.sliceTop,
|
||||||
|
this.sliceRight,
|
||||||
|
this.sliceBottom,
|
||||||
this.pressedAsset,
|
this.pressedAsset,
|
||||||
this.disabledAsset,
|
this.disabledAsset,
|
||||||
this.animation,
|
this.animation,
|
||||||
@@ -31,6 +42,10 @@ class RuntimeNode {
|
|||||||
this.color,
|
this.color,
|
||||||
this.fontSize,
|
this.fontSize,
|
||||||
this.textAlign = RuntimeTextAlignValue.center,
|
this.textAlign = RuntimeTextAlignValue.center,
|
||||||
|
this.textShadowColor,
|
||||||
|
this.textShadowOffsetX,
|
||||||
|
this.textShadowOffsetY,
|
||||||
|
this.textShadowBlur,
|
||||||
this.radius,
|
this.radius,
|
||||||
this.strokeWidth,
|
this.strokeWidth,
|
||||||
this.value,
|
this.value,
|
||||||
@@ -66,6 +81,17 @@ class RuntimeNode {
|
|||||||
final String type;
|
final String type;
|
||||||
final String? parent;
|
final String? parent;
|
||||||
final String? asset;
|
final String? asset;
|
||||||
|
final String? frame;
|
||||||
|
final String? pressedFrame;
|
||||||
|
final String? disabledFrame;
|
||||||
|
final double? sourceX;
|
||||||
|
final double? sourceY;
|
||||||
|
final double? sourceWidth;
|
||||||
|
final double? sourceHeight;
|
||||||
|
final double? sliceLeft;
|
||||||
|
final double? sliceTop;
|
||||||
|
final double? sliceRight;
|
||||||
|
final double? sliceBottom;
|
||||||
final String? pressedAsset;
|
final String? pressedAsset;
|
||||||
final String? disabledAsset;
|
final String? disabledAsset;
|
||||||
final String? animation;
|
final String? animation;
|
||||||
@@ -89,6 +115,10 @@ class RuntimeNode {
|
|||||||
final Color? color;
|
final Color? color;
|
||||||
final double? fontSize;
|
final double? fontSize;
|
||||||
final String textAlign;
|
final String textAlign;
|
||||||
|
final Color? textShadowColor;
|
||||||
|
final double? textShadowOffsetX;
|
||||||
|
final double? textShadowOffsetY;
|
||||||
|
final double? textShadowBlur;
|
||||||
final double? radius;
|
final double? radius;
|
||||||
final double? strokeWidth;
|
final double? strokeWidth;
|
||||||
final double? value;
|
final double? value;
|
||||||
@@ -205,6 +235,36 @@ class RuntimeNode {
|
|||||||
type: nextType,
|
type: nextType,
|
||||||
parent: _parentProp(props, currentParent: parent, nodeId: id),
|
parent: _parentProp(props, currentParent: parent, nodeId: id),
|
||||||
asset: _stringProp(props, RuntimeProtocolField.asset) ?? asset,
|
asset: _stringProp(props, RuntimeProtocolField.asset) ?? asset,
|
||||||
|
frame: _stringProp(props, RuntimeProtocolField.frame) ?? frame,
|
||||||
|
pressedFrame:
|
||||||
|
_stringProp(props, RuntimeProtocolField.pressedFrame) ?? pressedFrame,
|
||||||
|
disabledFrame:
|
||||||
|
_stringProp(props, RuntimeProtocolField.disabledFrame) ??
|
||||||
|
disabledFrame,
|
||||||
|
sourceX:
|
||||||
|
_nonNegativeDoubleProp(props, RuntimeProtocolField.sourceX) ??
|
||||||
|
sourceX,
|
||||||
|
sourceY:
|
||||||
|
_nonNegativeDoubleProp(props, RuntimeProtocolField.sourceY) ??
|
||||||
|
sourceY,
|
||||||
|
sourceWidth:
|
||||||
|
_positiveDoubleProp(props, RuntimeProtocolField.sourceWidth) ??
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight:
|
||||||
|
_positiveDoubleProp(props, RuntimeProtocolField.sourceHeight) ??
|
||||||
|
sourceHeight,
|
||||||
|
sliceLeft:
|
||||||
|
_nonNegativeDoubleProp(props, RuntimeProtocolField.sliceLeft) ??
|
||||||
|
sliceLeft,
|
||||||
|
sliceTop:
|
||||||
|
_nonNegativeDoubleProp(props, RuntimeProtocolField.sliceTop) ??
|
||||||
|
sliceTop,
|
||||||
|
sliceRight:
|
||||||
|
_nonNegativeDoubleProp(props, RuntimeProtocolField.sliceRight) ??
|
||||||
|
sliceRight,
|
||||||
|
sliceBottom:
|
||||||
|
_nonNegativeDoubleProp(props, RuntimeProtocolField.sliceBottom) ??
|
||||||
|
sliceBottom,
|
||||||
pressedAsset:
|
pressedAsset:
|
||||||
_stringProp(props, RuntimeProtocolField.pressedAsset) ?? pressedAsset,
|
_stringProp(props, RuntimeProtocolField.pressedAsset) ?? pressedAsset,
|
||||||
disabledAsset:
|
disabledAsset:
|
||||||
@@ -232,6 +292,18 @@ class RuntimeNode {
|
|||||||
color: _colorProp(props, RuntimeProtocolField.color) ?? color,
|
color: _colorProp(props, RuntimeProtocolField.color) ?? color,
|
||||||
fontSize: _doubleProp(props, RuntimeProtocolField.fontSize) ?? fontSize,
|
fontSize: _doubleProp(props, RuntimeProtocolField.fontSize) ?? fontSize,
|
||||||
textAlign: nextTextAlign,
|
textAlign: nextTextAlign,
|
||||||
|
textShadowColor:
|
||||||
|
_colorProp(props, RuntimeProtocolField.textShadowColor) ??
|
||||||
|
textShadowColor,
|
||||||
|
textShadowOffsetX:
|
||||||
|
_doubleProp(props, RuntimeProtocolField.textShadowOffsetX) ??
|
||||||
|
textShadowOffsetX,
|
||||||
|
textShadowOffsetY:
|
||||||
|
_doubleProp(props, RuntimeProtocolField.textShadowOffsetY) ??
|
||||||
|
textShadowOffsetY,
|
||||||
|
textShadowBlur:
|
||||||
|
_nonNegativeDoubleProp(props, RuntimeProtocolField.textShadowBlur) ??
|
||||||
|
textShadowBlur,
|
||||||
radius: _doubleProp(props, RuntimeProtocolField.radius) ?? radius,
|
radius: _doubleProp(props, RuntimeProtocolField.radius) ?? radius,
|
||||||
strokeWidth:
|
strokeWidth:
|
||||||
_doubleProp(props, RuntimeProtocolField.strokeWidth) ?? strokeWidth,
|
_doubleProp(props, RuntimeProtocolField.strokeWidth) ?? strokeWidth,
|
||||||
@@ -325,6 +397,20 @@ class RuntimeNode {
|
|||||||
nodeId: _requiredString(map, RuntimeProtocolField.id),
|
nodeId: _requiredString(map, RuntimeProtocolField.id),
|
||||||
),
|
),
|
||||||
asset: _stringProp(map, RuntimeProtocolField.asset),
|
asset: _stringProp(map, RuntimeProtocolField.asset),
|
||||||
|
frame: _stringProp(map, RuntimeProtocolField.frame),
|
||||||
|
pressedFrame: _stringProp(map, RuntimeProtocolField.pressedFrame),
|
||||||
|
disabledFrame: _stringProp(map, RuntimeProtocolField.disabledFrame),
|
||||||
|
sourceX: _nonNegativeDoubleProp(map, RuntimeProtocolField.sourceX),
|
||||||
|
sourceY: _nonNegativeDoubleProp(map, RuntimeProtocolField.sourceY),
|
||||||
|
sourceWidth: _positiveDoubleProp(map, RuntimeProtocolField.sourceWidth),
|
||||||
|
sourceHeight: _positiveDoubleProp(map, RuntimeProtocolField.sourceHeight),
|
||||||
|
sliceLeft: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceLeft),
|
||||||
|
sliceTop: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceTop),
|
||||||
|
sliceRight: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceRight),
|
||||||
|
sliceBottom: _nonNegativeDoubleProp(
|
||||||
|
map,
|
||||||
|
RuntimeProtocolField.sliceBottom,
|
||||||
|
),
|
||||||
pressedAsset: _stringProp(map, RuntimeProtocolField.pressedAsset),
|
pressedAsset: _stringProp(map, RuntimeProtocolField.pressedAsset),
|
||||||
disabledAsset: _stringProp(map, RuntimeProtocolField.disabledAsset),
|
disabledAsset: _stringProp(map, RuntimeProtocolField.disabledAsset),
|
||||||
animation: _stringProp(map, RuntimeProtocolField.animation),
|
animation: _stringProp(map, RuntimeProtocolField.animation),
|
||||||
@@ -352,6 +438,19 @@ class RuntimeNode {
|
|||||||
color: _colorProp(map, RuntimeProtocolField.color),
|
color: _colorProp(map, RuntimeProtocolField.color),
|
||||||
fontSize: _doubleProp(map, RuntimeProtocolField.fontSize),
|
fontSize: _doubleProp(map, RuntimeProtocolField.fontSize),
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
|
textShadowColor: _colorProp(map, RuntimeProtocolField.textShadowColor),
|
||||||
|
textShadowOffsetX: _doubleProp(
|
||||||
|
map,
|
||||||
|
RuntimeProtocolField.textShadowOffsetX,
|
||||||
|
),
|
||||||
|
textShadowOffsetY: _doubleProp(
|
||||||
|
map,
|
||||||
|
RuntimeProtocolField.textShadowOffsetY,
|
||||||
|
),
|
||||||
|
textShadowBlur: _nonNegativeDoubleProp(
|
||||||
|
map,
|
||||||
|
RuntimeProtocolField.textShadowBlur,
|
||||||
|
),
|
||||||
radius: _doubleProp(map, RuntimeProtocolField.radius),
|
radius: _doubleProp(map, RuntimeProtocolField.radius),
|
||||||
strokeWidth: _doubleProp(map, RuntimeProtocolField.strokeWidth),
|
strokeWidth: _doubleProp(map, RuntimeProtocolField.strokeWidth),
|
||||||
value: _normalizedValueProp(map, RuntimeProtocolField.value),
|
value: _normalizedValueProp(map, RuntimeProtocolField.value),
|
||||||
@@ -497,6 +596,17 @@ class RuntimeNode {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static double? _positiveDoubleProp(Map<String, Object?> map, String key) {
|
||||||
|
final value = _doubleProp(map, key);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value <= 0) {
|
||||||
|
throw FormatException('RuntimeNode.$key must be > 0');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
static double? _scrollProp(
|
static double? _scrollProp(
|
||||||
Map<String, Object?> map,
|
Map<String, Object?> map,
|
||||||
String key, {
|
String key, {
|
||||||
|
|||||||
273
lib/runtime/network/runtime_network_manager.dart
Normal file
273
lib/runtime/network/runtime_network_manager.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +255,16 @@ class GameResource {
|
|||||||
'spine resource.skeleton must be a non-empty string',
|
'spine resource.skeleton must be a non-empty string',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (path is! String || path.isEmpty) {
|
} else {
|
||||||
|
if (path is! String || path.isEmpty) {
|
||||||
throw const FormatException('resource.path must be a non-empty string');
|
throw const FormatException('resource.path must be a non-empty string');
|
||||||
}
|
}
|
||||||
|
if (atlas != null && (atlas is! String || atlas.isEmpty)) {
|
||||||
|
throw const FormatException(
|
||||||
|
'image resource.atlas must be a non-empty string',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
final preload = map['preload'] as String? ?? GameResourcePreload.required;
|
final preload = map['preload'] as String? ?? GameResourcePreload.required;
|
||||||
if (!GameResourcePreload.isSupported(preload)) {
|
if (!GameResourcePreload.isSupported(preload)) {
|
||||||
throw const FormatException('resource.preload is unsupported');
|
throw const FormatException('resource.preload is unsupported');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ class PackageVerifier {
|
|||||||
if (resource.type == GameResourceType.spine) {
|
if (resource.type == GameResourceType.spine) {
|
||||||
return [resource.atlas!, resource.skeleton!];
|
return [resource.atlas!, resource.skeleton!];
|
||||||
}
|
}
|
||||||
|
if (resource.type == GameResourceType.image && resource.atlas != null) {
|
||||||
|
return [resource.path, resource.atlas!];
|
||||||
|
}
|
||||||
return [resource.path];
|
return [resource.path];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -138,6 +138,17 @@ class RuntimeProtocolField {
|
|||||||
static const target = 'target';
|
static const target = 'target';
|
||||||
static const parent = 'parent';
|
static const parent = 'parent';
|
||||||
static const asset = 'asset';
|
static const asset = 'asset';
|
||||||
|
static const frame = 'frame';
|
||||||
|
static const pressedFrame = 'pressedFrame';
|
||||||
|
static const disabledFrame = 'disabledFrame';
|
||||||
|
static const sourceX = 'sourceX';
|
||||||
|
static const sourceY = 'sourceY';
|
||||||
|
static const sourceWidth = 'sourceWidth';
|
||||||
|
static const sourceHeight = 'sourceHeight';
|
||||||
|
static const sliceLeft = 'sliceLeft';
|
||||||
|
static const sliceTop = 'sliceTop';
|
||||||
|
static const sliceRight = 'sliceRight';
|
||||||
|
static const sliceBottom = 'sliceBottom';
|
||||||
static const pressedAsset = 'pressedAsset';
|
static const pressedAsset = 'pressedAsset';
|
||||||
static const disabledAsset = 'disabledAsset';
|
static const disabledAsset = 'disabledAsset';
|
||||||
static const animation = 'animation';
|
static const animation = 'animation';
|
||||||
@@ -161,6 +172,10 @@ class RuntimeProtocolField {
|
|||||||
static const color = 'color';
|
static const color = 'color';
|
||||||
static const fontSize = 'fontSize';
|
static const fontSize = 'fontSize';
|
||||||
static const textAlign = 'textAlign';
|
static const textAlign = 'textAlign';
|
||||||
|
static const textShadowColor = 'textShadowColor';
|
||||||
|
static const textShadowOffsetX = 'textShadowOffsetX';
|
||||||
|
static const textShadowOffsetY = 'textShadowOffsetY';
|
||||||
|
static const textShadowBlur = 'textShadowBlur';
|
||||||
static const radius = 'radius';
|
static const radius = 'radius';
|
||||||
static const strokeWidth = 'strokeWidth';
|
static const strokeWidth = 'strokeWidth';
|
||||||
static const value = 'value';
|
static const value = 'value';
|
||||||
@@ -221,6 +236,17 @@ class RuntimeProtocolSchema {
|
|||||||
RuntimeProtocolField.type,
|
RuntimeProtocolField.type,
|
||||||
RuntimeProtocolField.parent,
|
RuntimeProtocolField.parent,
|
||||||
RuntimeProtocolField.asset,
|
RuntimeProtocolField.asset,
|
||||||
|
RuntimeProtocolField.frame,
|
||||||
|
RuntimeProtocolField.pressedFrame,
|
||||||
|
RuntimeProtocolField.disabledFrame,
|
||||||
|
RuntimeProtocolField.sourceX,
|
||||||
|
RuntimeProtocolField.sourceY,
|
||||||
|
RuntimeProtocolField.sourceWidth,
|
||||||
|
RuntimeProtocolField.sourceHeight,
|
||||||
|
RuntimeProtocolField.sliceLeft,
|
||||||
|
RuntimeProtocolField.sliceTop,
|
||||||
|
RuntimeProtocolField.sliceRight,
|
||||||
|
RuntimeProtocolField.sliceBottom,
|
||||||
RuntimeProtocolField.pressedAsset,
|
RuntimeProtocolField.pressedAsset,
|
||||||
RuntimeProtocolField.disabledAsset,
|
RuntimeProtocolField.disabledAsset,
|
||||||
RuntimeProtocolField.animation,
|
RuntimeProtocolField.animation,
|
||||||
@@ -244,6 +270,10 @@ class RuntimeProtocolSchema {
|
|||||||
RuntimeProtocolField.color,
|
RuntimeProtocolField.color,
|
||||||
RuntimeProtocolField.fontSize,
|
RuntimeProtocolField.fontSize,
|
||||||
RuntimeProtocolField.textAlign,
|
RuntimeProtocolField.textAlign,
|
||||||
|
RuntimeProtocolField.textShadowColor,
|
||||||
|
RuntimeProtocolField.textShadowOffsetX,
|
||||||
|
RuntimeProtocolField.textShadowOffsetY,
|
||||||
|
RuntimeProtocolField.textShadowBlur,
|
||||||
RuntimeProtocolField.radius,
|
RuntimeProtocolField.radius,
|
||||||
RuntimeProtocolField.strokeWidth,
|
RuntimeProtocolField.strokeWidth,
|
||||||
RuntimeProtocolField.value,
|
RuntimeProtocolField.value,
|
||||||
@@ -286,6 +316,17 @@ class RuntimeProtocolSchema {
|
|||||||
RuntimeProtocolField.type,
|
RuntimeProtocolField.type,
|
||||||
RuntimeProtocolField.parent,
|
RuntimeProtocolField.parent,
|
||||||
RuntimeProtocolField.asset,
|
RuntimeProtocolField.asset,
|
||||||
|
RuntimeProtocolField.frame,
|
||||||
|
RuntimeProtocolField.pressedFrame,
|
||||||
|
RuntimeProtocolField.disabledFrame,
|
||||||
|
RuntimeProtocolField.sourceX,
|
||||||
|
RuntimeProtocolField.sourceY,
|
||||||
|
RuntimeProtocolField.sourceWidth,
|
||||||
|
RuntimeProtocolField.sourceHeight,
|
||||||
|
RuntimeProtocolField.sliceLeft,
|
||||||
|
RuntimeProtocolField.sliceTop,
|
||||||
|
RuntimeProtocolField.sliceRight,
|
||||||
|
RuntimeProtocolField.sliceBottom,
|
||||||
RuntimeProtocolField.pressedAsset,
|
RuntimeProtocolField.pressedAsset,
|
||||||
RuntimeProtocolField.disabledAsset,
|
RuntimeProtocolField.disabledAsset,
|
||||||
RuntimeProtocolField.animation,
|
RuntimeProtocolField.animation,
|
||||||
@@ -309,6 +350,10 @@ class RuntimeProtocolSchema {
|
|||||||
RuntimeProtocolField.color,
|
RuntimeProtocolField.color,
|
||||||
RuntimeProtocolField.fontSize,
|
RuntimeProtocolField.fontSize,
|
||||||
RuntimeProtocolField.textAlign,
|
RuntimeProtocolField.textAlign,
|
||||||
|
RuntimeProtocolField.textShadowColor,
|
||||||
|
RuntimeProtocolField.textShadowOffsetX,
|
||||||
|
RuntimeProtocolField.textShadowOffsetY,
|
||||||
|
RuntimeProtocolField.textShadowBlur,
|
||||||
RuntimeProtocolField.radius,
|
RuntimeProtocolField.radius,
|
||||||
RuntimeProtocolField.strokeWidth,
|
RuntimeProtocolField.strokeWidth,
|
||||||
RuntimeProtocolField.value,
|
RuntimeProtocolField.value,
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ class RenderTreeController {
|
|||||||
contentOffsetY: parentContentOffset.y,
|
contentOffsetY: parentContentOffset.y,
|
||||||
);
|
);
|
||||||
component.setViewportCulled(_isCulledByParentListView(component, parent));
|
component.setViewportCulled(_isCulledByParentListView(component, parent));
|
||||||
|
component.setInheritedAlpha(parent?.renderAlpha ?? 1);
|
||||||
if (component.parent == target) {
|
if (component.parent == target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,153 @@ import '../models/runtime_node.dart';
|
|||||||
import '../protocol/runtime_protocol.dart';
|
import '../protocol/runtime_protocol.dart';
|
||||||
import '../resources/game_resource_manager.dart';
|
import '../resources/game_resource_manager.dart';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Color composeRuntimeColorAlpha(Color color, double alpha) {
|
||||||
|
return color.withValues(alpha: color.a * alpha.clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Rect runtimeImageSourceRect({
|
||||||
|
required double imageWidth,
|
||||||
|
required double imageHeight,
|
||||||
|
double? sourceX,
|
||||||
|
double? sourceY,
|
||||||
|
double? sourceWidth,
|
||||||
|
double? sourceHeight,
|
||||||
|
}) {
|
||||||
|
final x = (sourceX ?? 0).clamp(0.0, imageWidth).toDouble();
|
||||||
|
final y = (sourceY ?? 0).clamp(0.0, imageHeight).toDouble();
|
||||||
|
final maxWidth = imageWidth - x;
|
||||||
|
final maxHeight = imageHeight - y;
|
||||||
|
final width = (sourceWidth ?? maxWidth).clamp(0.0, maxWidth).toDouble();
|
||||||
|
final height = (sourceHeight ?? maxHeight).clamp(0.0, maxHeight).toDouble();
|
||||||
|
return Rect.fromLTWH(x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
List<({Rect source, Rect destination})> runtimeNineSliceRects({
|
||||||
|
required Rect source,
|
||||||
|
required Rect destination,
|
||||||
|
double sliceLeft = 0,
|
||||||
|
double sliceTop = 0,
|
||||||
|
double sliceRight = 0,
|
||||||
|
double sliceBottom = 0,
|
||||||
|
double destinationOverlap = 0,
|
||||||
|
double sourceInset = 0,
|
||||||
|
}) {
|
||||||
|
if (source.width <= 0 ||
|
||||||
|
source.height <= 0 ||
|
||||||
|
destination.width <= 0 ||
|
||||||
|
destination.height <= 0) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
final left = sliceLeft.clamp(0.0, source.width).toDouble();
|
||||||
|
final top = sliceTop.clamp(0.0, source.height).toDouble();
|
||||||
|
final right = sliceRight.clamp(0.0, source.width - left).toDouble();
|
||||||
|
final bottom = sliceBottom.clamp(0.0, source.height - top).toDouble();
|
||||||
|
final destLeft = left.clamp(0.0, destination.width).toDouble();
|
||||||
|
final destTop = top.clamp(0.0, destination.height).toDouble();
|
||||||
|
final destRight = right.clamp(0.0, destination.width - destLeft).toDouble();
|
||||||
|
final destBottom = bottom.clamp(0.0, destination.height - destTop).toDouble();
|
||||||
|
|
||||||
|
final sourceXs = [
|
||||||
|
source.left,
|
||||||
|
source.left + left,
|
||||||
|
source.right - right,
|
||||||
|
source.right,
|
||||||
|
];
|
||||||
|
final sourceYs = [
|
||||||
|
source.top,
|
||||||
|
source.top + top,
|
||||||
|
source.bottom - bottom,
|
||||||
|
source.bottom,
|
||||||
|
];
|
||||||
|
final destXs = [
|
||||||
|
destination.left,
|
||||||
|
destination.left + destLeft,
|
||||||
|
destination.right - destRight,
|
||||||
|
destination.right,
|
||||||
|
];
|
||||||
|
final destYs = [
|
||||||
|
destination.top,
|
||||||
|
destination.top + destTop,
|
||||||
|
destination.bottom - destBottom,
|
||||||
|
destination.bottom,
|
||||||
|
];
|
||||||
|
|
||||||
|
final parts = <({Rect source, Rect destination})>[];
|
||||||
|
for (var y = 0; y < 3; y++) {
|
||||||
|
for (var x = 0; x < 3; x++) {
|
||||||
|
final rawSourcePart = Rect.fromLTRB(
|
||||||
|
sourceXs[x],
|
||||||
|
sourceYs[y],
|
||||||
|
sourceXs[x + 1],
|
||||||
|
sourceYs[y + 1],
|
||||||
|
);
|
||||||
|
final rawDestPart = Rect.fromLTRB(
|
||||||
|
destXs[x],
|
||||||
|
destYs[y],
|
||||||
|
destXs[x + 1],
|
||||||
|
destYs[y + 1],
|
||||||
|
);
|
||||||
|
if (rawSourcePart.width <= 0 ||
|
||||||
|
rawSourcePart.height <= 0 ||
|
||||||
|
rawDestPart.width <= 0 ||
|
||||||
|
rawDestPart.height <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final sourcePart = _insetNineSliceSourceRect(
|
||||||
|
rawSourcePart,
|
||||||
|
bounds: source,
|
||||||
|
inset: sourceInset,
|
||||||
|
);
|
||||||
|
final destPart = _overlapNineSliceDestinationRect(
|
||||||
|
rawDestPart,
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
bounds: destination,
|
||||||
|
overlap: destinationOverlap,
|
||||||
|
);
|
||||||
|
parts.add((source: sourcePart, destination: destPart));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 rect, {
|
||||||
|
required int x,
|
||||||
|
required int y,
|
||||||
|
required Rect bounds,
|
||||||
|
required double overlap,
|
||||||
|
}) {
|
||||||
|
if (overlap <= 0) {
|
||||||
|
return rect;
|
||||||
|
}
|
||||||
|
return Rect.fromLTRB(
|
||||||
|
x == 0 ? rect.left : math.max(bounds.left, rect.left - overlap),
|
||||||
|
y == 0 ? rect.top : math.max(bounds.top, rect.top - overlap),
|
||||||
|
x == 2 ? rect.right : math.min(bounds.right, rect.right + overlap),
|
||||||
|
y == 2 ? rect.bottom : math.min(bounds.bottom, rect.bottom + overlap),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class RuntimeComponent extends PositionComponent
|
class RuntimeComponent extends PositionComponent
|
||||||
with HasVisibility, TapCallbacks {
|
with HasVisibility, TapCallbacks {
|
||||||
RuntimeComponent({
|
RuntimeComponent({
|
||||||
@@ -58,11 +205,35 @@ class RuntimeComponent extends PositionComponent
|
|||||||
double _parentContentOffsetY = 0;
|
double _parentContentOffsetY = 0;
|
||||||
bool _viewportCulled = false;
|
bool _viewportCulled = false;
|
||||||
bool _pressed = false;
|
bool _pressed = false;
|
||||||
|
double _inheritedAlpha = 1;
|
||||||
|
|
||||||
double get renderAlpha => _runtimeAlpha ?? _node.alpha;
|
double get localAlpha => _runtimeAlpha ?? _node.alpha;
|
||||||
|
|
||||||
|
double get renderAlpha => (_inheritedAlpha * localAlpha).clamp(0.0, 1.0);
|
||||||
|
|
||||||
void setRuntimeAlpha(double value) {
|
void setRuntimeAlpha(double value) {
|
||||||
_runtimeAlpha = value.clamp(0, 1).toDouble();
|
final next = value.clamp(0, 1).toDouble();
|
||||||
|
if (_runtimeAlpha == next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_runtimeAlpha = next;
|
||||||
|
_refreshInheritedAlphaSubtree();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setInheritedAlpha(double value) {
|
||||||
|
final next = value.clamp(0, 1).toDouble();
|
||||||
|
if (_inheritedAlpha == next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_inheritedAlpha = next;
|
||||||
|
_refreshInheritedAlphaSubtree();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshInheritedAlphaSubtree() {
|
||||||
|
_syncTextStyle(_node);
|
||||||
|
for (final child in children.whereType<RuntimeComponent>()) {
|
||||||
|
child.setInheritedAlpha(renderAlpha);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setParentScroll({
|
void setParentScroll({
|
||||||
@@ -90,6 +261,7 @@ class RuntimeComponent extends PositionComponent
|
|||||||
_syncImage(node);
|
_syncImage(node);
|
||||||
_syncSpine(node);
|
_syncSpine(node);
|
||||||
_syncParticle(node);
|
_syncParticle(node);
|
||||||
|
_refreshInheritedAlphaSubtree();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool containsVisualPoint(Vector2 point) {
|
bool containsVisualPoint(Vector2 point) {
|
||||||
@@ -156,7 +328,10 @@ class RuntimeComponent extends PositionComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
final paint = Paint()
|
final paint = Paint()
|
||||||
..color = (_node.color ?? _defaultColor()).withValues(alpha: renderAlpha);
|
..color = composeRuntimeColorAlpha(
|
||||||
|
_node.color ?? _defaultColor(),
|
||||||
|
renderAlpha,
|
||||||
|
);
|
||||||
|
|
||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.transform(Float64List.fromList(transform.transformMatrix.storage));
|
canvas.transform(Float64List.fromList(transform.transformMatrix.storage));
|
||||||
@@ -177,7 +352,10 @@ class RuntimeComponent extends PositionComponent
|
|||||||
@override
|
@override
|
||||||
void render(Canvas canvas) {
|
void render(Canvas canvas) {
|
||||||
final paint = Paint()
|
final paint = Paint()
|
||||||
..color = (_node.color ?? _defaultColor()).withValues(alpha: renderAlpha);
|
..color = composeRuntimeColorAlpha(
|
||||||
|
_node.color ?? _defaultColor(),
|
||||||
|
renderAlpha,
|
||||||
|
);
|
||||||
|
|
||||||
switch (_node.type) {
|
switch (_node.type) {
|
||||||
case RuntimeNodeType.circle:
|
case RuntimeNodeType.circle:
|
||||||
@@ -224,7 +402,7 @@ class RuntimeComponent extends PositionComponent
|
|||||||
final rect = Rect.fromLTWH(0, 0, size.x, size.y);
|
final rect = Rect.fromLTWH(0, 0, size.x, size.y);
|
||||||
final radius = Radius.circular(_node.radius ?? 4);
|
final radius = Radius.circular(_node.radius ?? 4);
|
||||||
final backgroundPaint = Paint()
|
final backgroundPaint = Paint()
|
||||||
..color = const Color(0x33475569).withValues(alpha: renderAlpha);
|
..color = composeRuntimeColorAlpha(const Color(0x33475569), renderAlpha);
|
||||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), backgroundPaint);
|
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), backgroundPaint);
|
||||||
|
|
||||||
final value = _node.value ?? 0;
|
final value = _node.value ?? 0;
|
||||||
@@ -327,11 +505,15 @@ class RuntimeComponent extends PositionComponent
|
|||||||
final contentRect = _listViewContentRect();
|
final contentRect = _listViewContentRect();
|
||||||
final scrollbars = _listViewScrollbarVisibility();
|
final scrollbars = _listViewScrollbarVisibility();
|
||||||
final trackPaint = Paint()
|
final trackPaint = Paint()
|
||||||
..color = (_node.scrollbarTrackColor ?? const Color(0x33475569))
|
..color = composeRuntimeColorAlpha(
|
||||||
.withValues(alpha: renderAlpha);
|
_node.scrollbarTrackColor ?? const Color(0x33475569),
|
||||||
|
renderAlpha,
|
||||||
|
);
|
||||||
final thumbPaint = Paint()
|
final thumbPaint = Paint()
|
||||||
..color = (_node.scrollbarThumbColor ?? const Color(0xaa94a3b8))
|
..color = composeRuntimeColorAlpha(
|
||||||
.withValues(alpha: renderAlpha);
|
_node.scrollbarThumbColor ?? const Color(0xaa94a3b8),
|
||||||
|
renderAlpha,
|
||||||
|
);
|
||||||
|
|
||||||
final contentHeight = _node.contentHeight;
|
final contentHeight = _node.contentHeight;
|
||||||
if (scrollbars.vertical &&
|
if (scrollbars.vertical &&
|
||||||
@@ -411,12 +593,20 @@ class RuntimeComponent extends PositionComponent
|
|||||||
(_node.type == RuntimeNodeType.sprite ||
|
(_node.type == RuntimeNodeType.sprite ||
|
||||||
_node.type == RuntimeNodeType.image ||
|
_node.type == RuntimeNodeType.image ||
|
||||||
_node.type == RuntimeNodeType.button)) {
|
_node.type == RuntimeNodeType.button)) {
|
||||||
canvas.drawImageRect(
|
final imagePaint = Paint()
|
||||||
|
..color = composeRuntimeColorAlpha(Colors.white, renderAlpha);
|
||||||
|
final source = _imageSourceRect(image, _currentImageFrame(_node));
|
||||||
|
if (_usesNineSlice(source, rect)) {
|
||||||
|
_drawNineSliceImage(
|
||||||
|
canvas,
|
||||||
image,
|
image,
|
||||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
source,
|
||||||
rect,
|
rect,
|
||||||
Paint()..color = Colors.white.withValues(alpha: renderAlpha),
|
imagePaint..filterQuality = FilterQuality.none,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
canvas.drawImageRect(image, source, rect, imagePaint);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +618,58 @@ class RuntimeComponent extends PositionComponent
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rect _imageSourceRect(ui.Image image, String? frameName) {
|
||||||
|
final frame = _loadedAsset == null
|
||||||
|
? null
|
||||||
|
: _resources.textureFrame(_loadedAsset!, frameName);
|
||||||
|
if (frame != null) {
|
||||||
|
return frame.rect;
|
||||||
|
}
|
||||||
|
return runtimeImageSourceRect(
|
||||||
|
imageWidth: image.width.toDouble(),
|
||||||
|
imageHeight: image.height.toDouble(),
|
||||||
|
sourceX: _node.sourceX,
|
||||||
|
sourceY: _node.sourceY,
|
||||||
|
sourceWidth: _node.sourceWidth,
|
||||||
|
sourceHeight: _node.sourceHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _usesNineSlice(Rect source, Rect destination) {
|
||||||
|
if (source.width <= 0 ||
|
||||||
|
source.height <= 0 ||
|
||||||
|
destination.width <= 0 ||
|
||||||
|
destination.height <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (_node.sliceLeft ?? 0) > 0 ||
|
||||||
|
(_node.sliceTop ?? 0) > 0 ||
|
||||||
|
(_node.sliceRight ?? 0) > 0 ||
|
||||||
|
(_node.sliceBottom ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawNineSliceImage(
|
||||||
|
Canvas canvas,
|
||||||
|
ui.Image image,
|
||||||
|
Rect source,
|
||||||
|
Rect destination,
|
||||||
|
Paint paint,
|
||||||
|
) {
|
||||||
|
final parts = runtimeNineSliceRects(
|
||||||
|
source: source,
|
||||||
|
destination: destination,
|
||||||
|
destinationOverlap: 1,
|
||||||
|
sourceInset: 0.5,
|
||||||
|
sliceLeft: _node.sliceLeft ?? 0,
|
||||||
|
sliceTop: _node.sliceTop ?? 0,
|
||||||
|
sliceRight: _node.sliceRight ?? 0,
|
||||||
|
sliceBottom: _node.sliceBottom ?? 0,
|
||||||
|
);
|
||||||
|
for (final part in parts) {
|
||||||
|
canvas.drawImageRect(image, part.source, part.destination, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _applyBase(RuntimeNode node) {
|
void _applyBase(RuntimeNode node) {
|
||||||
_syncVisibility();
|
_syncVisibility();
|
||||||
size = Vector2(node.width ?? 40, node.height ?? 40);
|
size = Vector2(node.width ?? 40, node.height ?? 40);
|
||||||
@@ -504,6 +746,19 @@ class RuntimeComponent extends PositionComponent
|
|||||||
return node.asset;
|
return node.asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _currentImageFrame(RuntimeNode node) {
|
||||||
|
if (node.type != RuntimeNodeType.button) {
|
||||||
|
return node.frame;
|
||||||
|
}
|
||||||
|
if (!node.interactive && node.disabledFrame != null) {
|
||||||
|
return node.disabledFrame;
|
||||||
|
}
|
||||||
|
if (_pressed && node.pressedFrame != null) {
|
||||||
|
return node.pressedFrame;
|
||||||
|
}
|
||||||
|
return node.frame;
|
||||||
|
}
|
||||||
|
|
||||||
void _releaseRetainedImage(String asset, int generation, ui.Image? image) {
|
void _releaseRetainedImage(String asset, int generation, ui.Image? image) {
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
return;
|
return;
|
||||||
@@ -946,20 +1201,11 @@ class RuntimeComponent extends PositionComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
final text = label ?? '';
|
final text = label ?? '';
|
||||||
final color = _textColor(node);
|
|
||||||
final style = TextStyle(
|
|
||||||
color: color.withValues(alpha: renderAlpha),
|
|
||||||
fontSize: node.fontSize ?? 18,
|
|
||||||
fontWeight: node.type == RuntimeNodeType.button
|
|
||||||
? FontWeight.w600
|
|
||||||
: FontWeight.normal,
|
|
||||||
);
|
|
||||||
|
|
||||||
final component = _textComponent;
|
final component = _textComponent;
|
||||||
if (component == null) {
|
if (component == null) {
|
||||||
_textComponent = TextComponent(
|
_textComponent = TextComponent(
|
||||||
text: text,
|
text: text,
|
||||||
textRenderer: TextPaint(style: style),
|
textRenderer: TextPaint(style: _textStyle(node)),
|
||||||
anchor: _textAnchor(node),
|
anchor: _textAnchor(node),
|
||||||
position: _textPosition(node),
|
position: _textPosition(node),
|
||||||
priority: priority + 1,
|
priority: priority + 1,
|
||||||
@@ -970,10 +1216,30 @@ class RuntimeComponent extends PositionComponent
|
|||||||
|
|
||||||
component
|
component
|
||||||
..text = text
|
..text = text
|
||||||
..textRenderer = TextPaint(style: style)
|
|
||||||
..anchor = _textAnchor(node)
|
..anchor = _textAnchor(node)
|
||||||
..position = _textPosition(node)
|
..position = _textPosition(node)
|
||||||
..priority = priority + 1;
|
..priority = priority + 1;
|
||||||
|
_syncTextStyle(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncTextStyle(RuntimeNode node) {
|
||||||
|
final component = _textComponent;
|
||||||
|
if (component == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
component.textRenderer = TextPaint(style: _textStyle(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle _textStyle(RuntimeNode node) {
|
||||||
|
final color = _textColor(node);
|
||||||
|
return TextStyle(
|
||||||
|
color: composeRuntimeColorAlpha(color, renderAlpha),
|
||||||
|
fontSize: node.fontSize ?? 18,
|
||||||
|
fontWeight: node.type == RuntimeNodeType.button
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
shadows: _textShadows(node),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -984,6 +1250,7 @@ class RuntimeComponent extends PositionComponent
|
|||||||
_releaseLoadedSpine();
|
_releaseLoadedSpine();
|
||||||
_releaseParticle();
|
_releaseParticle();
|
||||||
_runtimeAlpha = null;
|
_runtimeAlpha = null;
|
||||||
|
_inheritedAlpha = 1;
|
||||||
_pressed = false;
|
_pressed = false;
|
||||||
_textComponent = null;
|
_textComponent = null;
|
||||||
super.onRemove();
|
super.onRemove();
|
||||||
@@ -1018,6 +1285,23 @@ class RuntimeComponent extends PositionComponent
|
|||||||
return (node.text ?? '').contains('\n');
|
return (node.text ?? '').contains('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Shadow>? _textShadows(RuntimeNode node) {
|
||||||
|
final color = node.textShadowColor;
|
||||||
|
if (color == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
Shadow(
|
||||||
|
color: composeRuntimeColorAlpha(color, renderAlpha),
|
||||||
|
offset: Offset(
|
||||||
|
node.textShadowOffsetX ?? 0,
|
||||||
|
node.textShadowOffsetY ?? 0,
|
||||||
|
),
|
||||||
|
blurRadius: node.textShadowBlur ?? 0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
Color _textColor(RuntimeNode node) {
|
Color _textColor(RuntimeNode node) {
|
||||||
if (node.type == RuntimeNodeType.button) {
|
if (node.type == RuntimeNodeType.button) {
|
||||||
return Colors.white;
|
return Colors.white;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flame_spine/flame_spine.dart';
|
import 'package:flame_spine/flame_spine.dart';
|
||||||
@@ -32,6 +33,7 @@ class GameResourceManager {
|
|||||||
final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true);
|
final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true);
|
||||||
GamePackage? _package;
|
GamePackage? _package;
|
||||||
final Map<String, _ImageResourceRecord> _images = {};
|
final Map<String, _ImageResourceRecord> _images = {};
|
||||||
|
final Map<String, RuntimeTextureAtlas> _textureAtlases = {};
|
||||||
int _cacheBytes = 0;
|
int _cacheBytes = 0;
|
||||||
int _accessCounter = 0;
|
int _accessCounter = 0;
|
||||||
|
|
||||||
@@ -49,8 +51,10 @@ class GameResourceManager {
|
|||||||
|
|
||||||
Future<void> mount(GamePackage package) async {
|
Future<void> mount(GamePackage package) async {
|
||||||
_releaseCachedImages();
|
_releaseCachedImages();
|
||||||
|
_textureAtlases.clear();
|
||||||
_asyncGate.activate();
|
_asyncGate.activate();
|
||||||
_package = package;
|
_package = package;
|
||||||
|
await loadDeclaredTextureAtlases(package.manifest);
|
||||||
await preloadDeclaredImages(package.manifest);
|
await preloadDeclaredImages(package.manifest);
|
||||||
await preloadDeclaredSpines(package.manifest);
|
await preloadDeclaredSpines(package.manifest);
|
||||||
}
|
}
|
||||||
@@ -58,6 +62,7 @@ class GameResourceManager {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_asyncGate.close();
|
_asyncGate.close();
|
||||||
_releaseCachedImages();
|
_releaseCachedImages();
|
||||||
|
_textureAtlases.clear();
|
||||||
_package = null;
|
_package = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +151,17 @@ class GameResourceManager {
|
|||||||
return _loadImage(keyOrPath, failOnError: false, retain: retain);
|
return _loadImage(keyOrPath, failOnError: false, retain: retain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RuntimeTextureFrame? textureFrame(String keyOrPath, String? frameName) {
|
||||||
|
if (frameName == null || frameName.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final path = _tryResolve(keyOrPath);
|
||||||
|
if (path == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _textureAtlases[path]?.frames[frameName];
|
||||||
|
}
|
||||||
|
|
||||||
Future<SpineComponent?> createSpineComponent(String? keyOrPath) {
|
Future<SpineComponent?> createSpineComponent(String? keyOrPath) {
|
||||||
return _createSpineComponent(keyOrPath);
|
return _createSpineComponent(keyOrPath);
|
||||||
}
|
}
|
||||||
@@ -223,6 +239,24 @@ class GameResourceManager {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> loadDeclaredTextureAtlases(GamePackageManifest manifest) async {
|
||||||
|
final activePackage = _package;
|
||||||
|
if (activePackage == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final entry in manifest.resources.entries) {
|
||||||
|
final resource = entry.value;
|
||||||
|
final atlas = resource.atlas;
|
||||||
|
if (resource.type != GameResourceType.image || atlas == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final imagePath = activePackage.resolveResourcePath(entry.key);
|
||||||
|
final atlasPath = activePackage.resolveResourcePath(atlas);
|
||||||
|
final source = await activePackage.readText(atlasPath);
|
||||||
|
_textureAtlases[imagePath] = RuntimeTextureAtlas.fromJsonString(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> preloadDeclaredImages(GamePackageManifest manifest) async {
|
Future<void> preloadDeclaredImages(GamePackageManifest manifest) async {
|
||||||
final futures = <Future<void>>[];
|
final futures = <Future<void>>[];
|
||||||
for (final entry in manifest.resources.entries) {
|
for (final entry in manifest.resources.entries) {
|
||||||
@@ -254,6 +288,100 @@ class GameResourceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RuntimeTextureAtlas {
|
||||||
|
const RuntimeTextureAtlas({required this.frames});
|
||||||
|
|
||||||
|
final Map<String, RuntimeTextureFrame> frames;
|
||||||
|
|
||||||
|
factory RuntimeTextureAtlas.fromJsonString(String source) {
|
||||||
|
final value = jsonDecode(source);
|
||||||
|
if (value is! Map) {
|
||||||
|
throw const FormatException('Texture atlas JSON must be an object');
|
||||||
|
}
|
||||||
|
final framesValue = value['frames'];
|
||||||
|
final frames = <String, RuntimeTextureFrame>{};
|
||||||
|
if (framesValue is Map) {
|
||||||
|
for (final entry in framesValue.entries) {
|
||||||
|
if (entry.key is! String || entry.value is! Map) {
|
||||||
|
throw const FormatException('Texture atlas frames must be objects');
|
||||||
|
}
|
||||||
|
frames[entry.key as String] = RuntimeTextureFrame.fromMap(
|
||||||
|
Map<String, Object?>.from(entry.value as Map),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (framesValue is List) {
|
||||||
|
for (final item in framesValue) {
|
||||||
|
if (item is! Map) {
|
||||||
|
throw const FormatException('Texture atlas frames must be objects');
|
||||||
|
}
|
||||||
|
final map = Map<String, Object?>.from(item);
|
||||||
|
final filename = map['filename'];
|
||||||
|
if (filename is! String || filename.isEmpty) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Texture atlas array frames require filename',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frames[filename] = RuntimeTextureFrame.fromMap(map);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw const FormatException('Texture atlas frames must be a map or list');
|
||||||
|
}
|
||||||
|
return RuntimeTextureAtlas(frames: frames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RuntimeTextureFrame {
|
||||||
|
const RuntimeTextureFrame({
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double x;
|
||||||
|
final double y;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
ui.Rect get rect => ui.Rect.fromLTWH(x, y, width, height);
|
||||||
|
|
||||||
|
factory RuntimeTextureFrame.fromMap(Map<String, Object?> map) {
|
||||||
|
final rotated = map['rotated'];
|
||||||
|
if (rotated == true) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Rotated TexturePacker frames are unsupported',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final frame = map['frame'];
|
||||||
|
if (frame is! Map) {
|
||||||
|
throw const FormatException('TexturePacker frame must be an object');
|
||||||
|
}
|
||||||
|
final frameMap = Map<String, Object?>.from(frame);
|
||||||
|
return RuntimeTextureFrame(
|
||||||
|
x: _number(frameMap, 'x'),
|
||||||
|
y: _number(frameMap, 'y'),
|
||||||
|
width: _positiveNumber(frameMap, 'w'),
|
||||||
|
height: _positiveNumber(frameMap, 'h'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _number(Map<String, Object?> map, String key) {
|
||||||
|
final value = map[key];
|
||||||
|
if (value is num) {
|
||||||
|
return value.toDouble();
|
||||||
|
}
|
||||||
|
throw FormatException('TexturePacker frame.$key must be a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _positiveNumber(Map<String, Object?> map, String key) {
|
||||||
|
final value = _number(map, key);
|
||||||
|
if (value <= 0) {
|
||||||
|
throw FormatException('TexturePacker frame.$key must be > 0');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum GameResourceState { idle, loading, ready, failed, disposed }
|
enum GameResourceState { idle, loading, ready, failed, disposed }
|
||||||
|
|
||||||
class ResourceLoadException implements Exception {
|
class ResourceLoadException implements Exception {
|
||||||
|
|||||||
@@ -1,22 +1,61 @@
|
|||||||
|
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 '../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 {
|
||||||
|
LuaDardoScriptEngine({RuntimeDiagnostics? diagnostics})
|
||||||
|
: _diagnostics = 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();
|
||||||
@@ -32,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');
|
||||||
@@ -104,9 +206,307 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
_lua.pushDartFunction(_importModule);
|
_lua.pushDartFunction(_importModule);
|
||||||
_lua.setField(-2, 'import');
|
_lua.setField(-2, 'import');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_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) {
|
||||||
|
final argumentCount = lua.getTop();
|
||||||
|
final messageParts = <String>[];
|
||||||
|
for (var index = 1; index <= argumentCount; index++) {
|
||||||
|
messageParts.add(_formatLuaLogValue(lua, index));
|
||||||
|
}
|
||||||
|
final message = messageParts.join(' ');
|
||||||
|
|
||||||
|
_diagnostics?.record(
|
||||||
|
type: RuntimeDiagnosticType.luaLog,
|
||||||
|
message: message,
|
||||||
|
context: {'argumentCount': argumentCount},
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
int _importModule(LuaState lua) {
|
int _importModule(LuaState lua) {
|
||||||
final moduleName = lua.toStr(1);
|
final moduleName = lua.toStr(1);
|
||||||
if (moduleName == null || moduleName.isEmpty) {
|
if (moduleName == null || moduleName.isEmpty) {
|
||||||
@@ -179,6 +579,25 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatLuaLogValue(LuaState lua, int index) {
|
||||||
|
if (lua.isNil(index) || lua.isNone(index)) {
|
||||||
|
return 'nil';
|
||||||
|
}
|
||||||
|
if (lua.isBoolean(index)) {
|
||||||
|
return lua.toBoolean(index).toString();
|
||||||
|
}
|
||||||
|
if (lua.isInteger(index)) {
|
||||||
|
return lua.toInteger(index).toString();
|
||||||
|
}
|
||||||
|
if (lua.isNumber(index)) {
|
||||||
|
return lua.toNumber(index).toString();
|
||||||
|
}
|
||||||
|
if (lua.isString(index)) {
|
||||||
|
return lua.toStr(index) ?? '';
|
||||||
|
}
|
||||||
|
return lua.typeName2(index);
|
||||||
|
}
|
||||||
|
|
||||||
bool _isSafeModuleName(String value) {
|
bool _isSafeModuleName(String value) {
|
||||||
return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) &&
|
return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) &&
|
||||||
!value.contains('..') &&
|
!value.contains('..') &&
|
||||||
|
|||||||
11
lib/runtime/scripting/runtime_script_services.dart
Normal file
11
lib/runtime/scripting/runtime_script_services.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
92
lib/runtime/storage/runtime_storage_manager.dart
Normal file
92
lib/runtime/storage/runtime_storage_manager.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
93
test/runtime/host/runtime_host_bridge_test.dart
Normal file
93
test/runtime/host/runtime_host_bridge_test.dart
Normal 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});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@ void main() {
|
|||||||
'type': 'button',
|
'type': 'button',
|
||||||
'parent': 'top_bar',
|
'parent': 'top_bar',
|
||||||
'asset': 'dice_normal',
|
'asset': 'dice_normal',
|
||||||
|
'frame': 'dice_idle.png',
|
||||||
|
'pressedFrame': 'dice_pressed.png',
|
||||||
|
'disabledFrame': 'dice_disabled.png',
|
||||||
|
'sourceX': 4,
|
||||||
|
'sourceY': 5,
|
||||||
|
'sourceWidth': 64,
|
||||||
|
'sourceHeight': 32,
|
||||||
|
'sliceLeft': 6,
|
||||||
|
'sliceTop': 7,
|
||||||
|
'sliceRight': 8,
|
||||||
|
'sliceBottom': 9,
|
||||||
'pressedAsset': 'dice_pressed',
|
'pressedAsset': 'dice_pressed',
|
||||||
'disabledAsset': 'dice_disabled',
|
'disabledAsset': 'dice_disabled',
|
||||||
'animation': 'idle',
|
'animation': 'idle',
|
||||||
@@ -33,6 +44,10 @@ void main() {
|
|||||||
'color': '#112233',
|
'color': '#112233',
|
||||||
'fontSize': 18,
|
'fontSize': 18,
|
||||||
'textAlign': 'left',
|
'textAlign': 'left',
|
||||||
|
'textShadowColor': '#80000000',
|
||||||
|
'textShadowOffsetX': 2,
|
||||||
|
'textShadowOffsetY': 3,
|
||||||
|
'textShadowBlur': 4,
|
||||||
'radius': 10,
|
'radius': 10,
|
||||||
'strokeWidth': 3,
|
'strokeWidth': 3,
|
||||||
'value': 0.6,
|
'value': 0.6,
|
||||||
@@ -68,6 +83,17 @@ void main() {
|
|||||||
expect(node.type, 'button');
|
expect(node.type, 'button');
|
||||||
expect(node.parent, 'top_bar');
|
expect(node.parent, 'top_bar');
|
||||||
expect(node.asset, 'dice_normal');
|
expect(node.asset, 'dice_normal');
|
||||||
|
expect(node.frame, 'dice_idle.png');
|
||||||
|
expect(node.pressedFrame, 'dice_pressed.png');
|
||||||
|
expect(node.disabledFrame, 'dice_disabled.png');
|
||||||
|
expect(node.sourceX, 4);
|
||||||
|
expect(node.sourceY, 5);
|
||||||
|
expect(node.sourceWidth, 64);
|
||||||
|
expect(node.sourceHeight, 32);
|
||||||
|
expect(node.sliceLeft, 6);
|
||||||
|
expect(node.sliceTop, 7);
|
||||||
|
expect(node.sliceRight, 8);
|
||||||
|
expect(node.sliceBottom, 9);
|
||||||
expect(node.pressedAsset, 'dice_pressed');
|
expect(node.pressedAsset, 'dice_pressed');
|
||||||
expect(node.disabledAsset, 'dice_disabled');
|
expect(node.disabledAsset, 'dice_disabled');
|
||||||
expect(node.animation, 'idle');
|
expect(node.animation, 'idle');
|
||||||
@@ -91,6 +117,10 @@ void main() {
|
|||||||
expect(node.color, const Color(0xff112233));
|
expect(node.color, const Color(0xff112233));
|
||||||
expect(node.fontSize, 18);
|
expect(node.fontSize, 18);
|
||||||
expect(node.textAlign, 'left');
|
expect(node.textAlign, 'left');
|
||||||
|
expect(node.textShadowColor, const Color(0x80000000));
|
||||||
|
expect(node.textShadowOffsetX, 2);
|
||||||
|
expect(node.textShadowOffsetY, 3);
|
||||||
|
expect(node.textShadowBlur, 4);
|
||||||
expect(node.radius, 10);
|
expect(node.radius, 10);
|
||||||
expect(node.strokeWidth, 3);
|
expect(node.strokeWidth, 3);
|
||||||
expect(node.value, 0.6);
|
expect(node.value, 0.6);
|
||||||
@@ -135,6 +165,21 @@ void main() {
|
|||||||
expect(node.rotation, 0);
|
expect(node.rotation, 0);
|
||||||
expect(node.loop, isTrue);
|
expect(node.loop, isTrue);
|
||||||
expect(node.textAlign, 'center');
|
expect(node.textAlign, 'center');
|
||||||
|
expect(node.textShadowColor, isNull);
|
||||||
|
expect(node.textShadowOffsetX, isNull);
|
||||||
|
expect(node.textShadowOffsetY, isNull);
|
||||||
|
expect(node.textShadowBlur, isNull);
|
||||||
|
expect(node.frame, isNull);
|
||||||
|
expect(node.pressedFrame, isNull);
|
||||||
|
expect(node.disabledFrame, isNull);
|
||||||
|
expect(node.sourceX, isNull);
|
||||||
|
expect(node.sourceY, isNull);
|
||||||
|
expect(node.sourceWidth, isNull);
|
||||||
|
expect(node.sourceHeight, isNull);
|
||||||
|
expect(node.sliceLeft, isNull);
|
||||||
|
expect(node.sliceTop, isNull);
|
||||||
|
expect(node.sliceRight, isNull);
|
||||||
|
expect(node.sliceBottom, isNull);
|
||||||
expect(node.scrollbarVisible, isTrue);
|
expect(node.scrollbarVisible, isTrue);
|
||||||
expect(node.paddingLeft, 0);
|
expect(node.paddingLeft, 0);
|
||||||
expect(node.paddingTop, 0);
|
expect(node.paddingTop, 0);
|
||||||
@@ -170,11 +215,26 @@ void main() {
|
|||||||
'paddingBottom': 11,
|
'paddingBottom': 11,
|
||||||
'contentWidth': 120,
|
'contentWidth': 120,
|
||||||
'contentHeight': 100,
|
'contentHeight': 100,
|
||||||
|
'sourceX': 3,
|
||||||
|
'sourceY': 4,
|
||||||
|
'sourceWidth': 40,
|
||||||
|
'sourceHeight': 41,
|
||||||
|
'frame': 'piece.png',
|
||||||
|
'pressedFrame': 'piece_down.png',
|
||||||
|
'disabledFrame': 'piece_disabled.png',
|
||||||
|
'sliceLeft': 5,
|
||||||
|
'sliceTop': 6,
|
||||||
|
'sliceRight': 7,
|
||||||
|
'sliceBottom': 8,
|
||||||
'pressedAsset': 'button_pressed',
|
'pressedAsset': 'button_pressed',
|
||||||
'disabledAsset': 'button_disabled',
|
'disabledAsset': 'button_disabled',
|
||||||
'scrollX': 90,
|
'scrollX': 90,
|
||||||
'scrollY': 80,
|
'scrollY': 80,
|
||||||
'textAlign': 'right',
|
'textAlign': 'right',
|
||||||
|
'textShadowColor': '#40000000',
|
||||||
|
'textShadowOffsetX': 1,
|
||||||
|
'textShadowOffsetY': 2,
|
||||||
|
'textShadowBlur': 3,
|
||||||
'preset': 'trail',
|
'preset': 'trail',
|
||||||
'count': 12,
|
'count': 12,
|
||||||
});
|
});
|
||||||
@@ -197,11 +257,26 @@ void main() {
|
|||||||
expect(updated.paddingBottom, 11);
|
expect(updated.paddingBottom, 11);
|
||||||
expect(updated.contentWidth, 120);
|
expect(updated.contentWidth, 120);
|
||||||
expect(updated.contentHeight, 100);
|
expect(updated.contentHeight, 100);
|
||||||
|
expect(updated.sourceX, 3);
|
||||||
|
expect(updated.sourceY, 4);
|
||||||
|
expect(updated.sourceWidth, 40);
|
||||||
|
expect(updated.sourceHeight, 41);
|
||||||
|
expect(updated.frame, 'piece.png');
|
||||||
|
expect(updated.pressedFrame, 'piece_down.png');
|
||||||
|
expect(updated.disabledFrame, 'piece_disabled.png');
|
||||||
|
expect(updated.sliceLeft, 5);
|
||||||
|
expect(updated.sliceTop, 6);
|
||||||
|
expect(updated.sliceRight, 7);
|
||||||
|
expect(updated.sliceBottom, 8);
|
||||||
expect(updated.pressedAsset, 'button_pressed');
|
expect(updated.pressedAsset, 'button_pressed');
|
||||||
expect(updated.disabledAsset, 'button_disabled');
|
expect(updated.disabledAsset, 'button_disabled');
|
||||||
expect(updated.scrollX, 68);
|
expect(updated.scrollX, 68);
|
||||||
expect(updated.scrollY, 60);
|
expect(updated.scrollY, 60);
|
||||||
expect(updated.textAlign, 'right');
|
expect(updated.textAlign, 'right');
|
||||||
|
expect(updated.textShadowColor, const Color(0x40000000));
|
||||||
|
expect(updated.textShadowOffsetX, 1);
|
||||||
|
expect(updated.textShadowOffsetY, 2);
|
||||||
|
expect(updated.textShadowBlur, 3);
|
||||||
expect(updated.preset, 'trail');
|
expect(updated.preset, 'trail');
|
||||||
expect(updated.count, 12);
|
expect(updated.count, 12);
|
||||||
});
|
});
|
||||||
@@ -235,6 +310,16 @@ void main() {
|
|||||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'progress', 'value': 2}),
|
() => RuntimeNode.fromMap({'id': 'a', 'type': 'progress', 'value': 2}),
|
||||||
throwsFormatException,
|
throwsFormatException,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
RuntimeNode.fromMap({'id': 'a', 'type': 'image', 'sourceWidth': 0}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
RuntimeNode.fromMap({'id': 'a', 'type': 'image', 'sliceLeft': -1}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
() => RuntimeNode.fromMap({
|
() => RuntimeNode.fromMap({
|
||||||
'id': 'a',
|
'id': 'a',
|
||||||
@@ -243,6 +328,14 @@ void main() {
|
|||||||
}),
|
}),
|
||||||
throwsFormatException,
|
throwsFormatException,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
() => RuntimeNode.fromMap({
|
||||||
|
'id': 'a',
|
||||||
|
'type': 'text',
|
||||||
|
'textShadowBlur': -1,
|
||||||
|
}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
() =>
|
() =>
|
||||||
RuntimeNode.fromMap({'id': 'a', 'type': 'listView', 'scrollY': -1}),
|
RuntimeNode.fromMap({'id': 'a', 'type': 'listView', 'scrollY': -1}),
|
||||||
|
|||||||
195
test/runtime/network/runtime_network_manager_test.dart
Normal file
195
test/runtime/network/runtime_network_manager_test.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 = '''
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ void main() {
|
|||||||
'board': {
|
'board': {
|
||||||
'type': 'image',
|
'type': 'image',
|
||||||
'path': 'assets/board.png',
|
'path': 'assets/board.png',
|
||||||
|
'atlas': 'assets/board.json',
|
||||||
'preload': 'lazy',
|
'preload': 'lazy',
|
||||||
'group': 'board',
|
'group': 'board',
|
||||||
},
|
},
|
||||||
@@ -53,6 +54,7 @@ void main() {
|
|||||||
expect(manifest.display.scaleMode, 'fit');
|
expect(manifest.display.scaleMode, 'fit');
|
||||||
expect(manifest.resources['board']?.type, 'image');
|
expect(manifest.resources['board']?.type, 'image');
|
||||||
expect(manifest.resources['board']?.path, 'assets/board.png');
|
expect(manifest.resources['board']?.path, 'assets/board.png');
|
||||||
|
expect(manifest.resources['board']?.atlas, 'assets/board.json');
|
||||||
expect(manifest.resources['board']?.preload, GameResourcePreload.lazy);
|
expect(manifest.resources['board']?.preload, GameResourcePreload.lazy);
|
||||||
expect(manifest.resources['board']?.group, 'board');
|
expect(manifest.resources['board']?.group, 'board');
|
||||||
expect(manifest.resources['roll']?.type, GameResourceType.audio);
|
expect(manifest.resources['roll']?.type, GameResourceType.audio);
|
||||||
|
|||||||
220
test/runtime/packages/game_package_repository_test.dart
Normal file
220
test/runtime/packages/game_package_repository_test.dart
Normal 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();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:flame_lua_runtime/runtime/models/runtime_node.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/rendering/runtime_component.dart';
|
import 'package:flame_lua_runtime/runtime/rendering/runtime_component.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:flutter/material.dart' show Color;
|
||||||
|
import 'package:flutter/rendering.dart' show Rect;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -114,6 +116,282 @@ void main() {
|
|||||||
expect(component.renderAlpha, 1);
|
expect(component.renderAlpha, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('inherits parent alpha for prefab-like subtrees', () async {
|
||||||
|
final parent = RuntimeComponent(
|
||||||
|
node: const RuntimeNode(
|
||||||
|
id: 'parent',
|
||||||
|
type: RuntimeNodeType.panel,
|
||||||
|
alpha: 0.8,
|
||||||
|
),
|
||||||
|
resources: GameResourceManager(),
|
||||||
|
onNodeTap: (_, __) {},
|
||||||
|
);
|
||||||
|
final child = RuntimeComponent(
|
||||||
|
node: const RuntimeNode(
|
||||||
|
id: 'child',
|
||||||
|
type: RuntimeNodeType.text,
|
||||||
|
text: 'Child',
|
||||||
|
alpha: 0.5,
|
||||||
|
color: Color(0xffffffff),
|
||||||
|
),
|
||||||
|
resources: GameResourceManager(),
|
||||||
|
onNodeTap: (_, __) {},
|
||||||
|
);
|
||||||
|
parent.add(child);
|
||||||
|
parent.updateTree(0);
|
||||||
|
child.setInheritedAlpha(parent.renderAlpha);
|
||||||
|
|
||||||
|
expect(child.renderAlpha, closeTo(0.4, 0.001));
|
||||||
|
final text = child.children.whereType<TextComponent>().single;
|
||||||
|
expect(
|
||||||
|
((text.textRenderer as TextPaint).style.color!).a,
|
||||||
|
closeTo(0.4, 0.003),
|
||||||
|
);
|
||||||
|
|
||||||
|
parent.setRuntimeAlpha(0.25);
|
||||||
|
|
||||||
|
expect(child.renderAlpha, closeTo(0.125, 0.001));
|
||||||
|
final updatedText = child.children.whereType<TextComponent>().single;
|
||||||
|
expect(identical(updatedText, text), isTrue);
|
||||||
|
expect(
|
||||||
|
((updatedText.textRenderer as TextPaint).style.color!).a,
|
||||||
|
closeTo(0.125, 0.003),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('propagates parent node alpha updates to child subtree', () {
|
||||||
|
final parent = RuntimeComponent(
|
||||||
|
node: const RuntimeNode(
|
||||||
|
id: 'parent',
|
||||||
|
type: RuntimeNodeType.panel,
|
||||||
|
alpha: 0.8,
|
||||||
|
),
|
||||||
|
resources: GameResourceManager(),
|
||||||
|
onNodeTap: (_, __) {},
|
||||||
|
);
|
||||||
|
final child = RuntimeComponent(
|
||||||
|
node: const RuntimeNode(
|
||||||
|
id: 'child',
|
||||||
|
type: RuntimeNodeType.text,
|
||||||
|
text: 'Child',
|
||||||
|
alpha: 0.5,
|
||||||
|
color: Color(0xffffffff),
|
||||||
|
),
|
||||||
|
resources: GameResourceManager(),
|
||||||
|
onNodeTap: (_, __) {},
|
||||||
|
);
|
||||||
|
parent.add(child);
|
||||||
|
parent.updateTree(0);
|
||||||
|
child.setInheritedAlpha(parent.renderAlpha);
|
||||||
|
|
||||||
|
parent.updateNode(
|
||||||
|
const RuntimeNode(
|
||||||
|
id: 'parent',
|
||||||
|
type: RuntimeNodeType.panel,
|
||||||
|
alpha: 0.6,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final text = child.children.whereType<TextComponent>().single;
|
||||||
|
expect(child.renderAlpha, closeTo(0.3, 0.001));
|
||||||
|
expect(
|
||||||
|
((text.textRenderer as TextPaint).style.color!).a,
|
||||||
|
closeTo(0.3, 0.003),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiplies color alpha with node and runtime alpha', () {
|
||||||
|
expect(
|
||||||
|
composeRuntimeColorAlpha(const Color(0xffffffff), 1).a,
|
||||||
|
closeTo(1, 0.001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
composeRuntimeColorAlpha(const Color(0x80ffffff), 1).a,
|
||||||
|
closeTo(0.5, 0.003),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
composeRuntimeColorAlpha(const Color(0x80ffffff), 0.5).a,
|
||||||
|
closeTo(0.25, 0.003),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
composeRuntimeColorAlpha(const Color(0x00ffffff), 1).a,
|
||||||
|
closeTo(0, 0.001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
composeRuntimeColorAlpha(const Color(0x80ffffff), 0.25).a,
|
||||||
|
closeTo(0.125, 0.003),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computes atlas source region and clamps to image bounds', () {
|
||||||
|
expect(
|
||||||
|
runtimeImageSourceRect(
|
||||||
|
imageWidth: 100,
|
||||||
|
imageHeight: 80,
|
||||||
|
sourceX: 10,
|
||||||
|
sourceY: 12,
|
||||||
|
sourceWidth: 30,
|
||||||
|
sourceHeight: 20,
|
||||||
|
),
|
||||||
|
const Rect.fromLTWH(10, 12, 30, 20),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
runtimeImageSourceRect(
|
||||||
|
imageWidth: 100,
|
||||||
|
imageHeight: 80,
|
||||||
|
sourceX: 90,
|
||||||
|
sourceY: 70,
|
||||||
|
sourceWidth: 30,
|
||||||
|
sourceHeight: 20,
|
||||||
|
),
|
||||||
|
const Rect.fromLTWH(90, 70, 10, 10),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computes nine-slice source and destination rects', () {
|
||||||
|
final parts = runtimeNineSliceRects(
|
||||||
|
source: const Rect.fromLTWH(10, 20, 30, 40),
|
||||||
|
destination: const Rect.fromLTWH(0, 0, 90, 120),
|
||||||
|
sliceLeft: 5,
|
||||||
|
sliceTop: 6,
|
||||||
|
sliceRight: 7,
|
||||||
|
sliceBottom: 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parts, hasLength(9));
|
||||||
|
expect(parts.first.source, const Rect.fromLTRB(10, 20, 15, 26));
|
||||||
|
expect(parts.first.destination, const Rect.fromLTRB(0, 0, 5, 6));
|
||||||
|
expect(parts[4].source, const Rect.fromLTRB(15, 26, 33, 52));
|
||||||
|
expect(parts[4].destination, const Rect.fromLTRB(5, 6, 83, 112));
|
||||||
|
expect(parts.last.source, const Rect.fromLTRB(33, 52, 40, 60));
|
||||||
|
expect(parts.last.destination, const Rect.fromLTRB(83, 112, 90, 120));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlaps nine-slice destination seams without changing bounds', () {
|
||||||
|
final parts = runtimeNineSliceRects(
|
||||||
|
source: const Rect.fromLTWH(0, 0, 30, 30),
|
||||||
|
destination: const Rect.fromLTWH(0, 0, 90, 90),
|
||||||
|
sliceLeft: 10,
|
||||||
|
sliceTop: 10,
|
||||||
|
sliceRight: 10,
|
||||||
|
sliceBottom: 10,
|
||||||
|
destinationOverlap: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parts.first.destination, const Rect.fromLTRB(0, 0, 10.5, 10.5));
|
||||||
|
expect(parts[4].destination, const Rect.fromLTRB(9.5, 9.5, 80.5, 80.5));
|
||||||
|
expect(parts.last.destination, const Rect.fromLTRB(79.5, 79.5, 90, 90));
|
||||||
|
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', () {
|
||||||
|
final component = RuntimeComponent(
|
||||||
|
node: const RuntimeNode(
|
||||||
|
id: 'text',
|
||||||
|
type: RuntimeNodeType.text,
|
||||||
|
text: 'Fade me',
|
||||||
|
alpha: 0,
|
||||||
|
color: Color(0xffffffff),
|
||||||
|
),
|
||||||
|
resources: GameResourceManager(),
|
||||||
|
onNodeTap: (_, __) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final text = component.children.whereType<TextComponent>().single;
|
||||||
|
expect(((text.textRenderer as TextPaint).style.color!).a, 0);
|
||||||
|
|
||||||
|
component.setRuntimeAlpha(1);
|
||||||
|
|
||||||
|
final updatedText = component.children.whereType<TextComponent>().single;
|
||||||
|
expect(identical(updatedText, text), isTrue);
|
||||||
|
expect(((updatedText.textRenderer as TextPaint).style.color!).a, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'updates button text alpha style without rebuilding text component',
|
||||||
|
() {
|
||||||
|
final component = RuntimeComponent(
|
||||||
|
node: const RuntimeNode(
|
||||||
|
id: 'button',
|
||||||
|
type: RuntimeNodeType.button,
|
||||||
|
text: 'Fade me',
|
||||||
|
alpha: 0,
|
||||||
|
),
|
||||||
|
resources: GameResourceManager(),
|
||||||
|
onNodeTap: (_, __) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final text = component.children.whereType<TextComponent>().single;
|
||||||
|
expect(((text.textRenderer as TextPaint).style.color!).a, 0);
|
||||||
|
|
||||||
|
component.setRuntimeAlpha(1);
|
||||||
|
|
||||||
|
final updatedText = component.children
|
||||||
|
.whereType<TextComponent>()
|
||||||
|
.single;
|
||||||
|
expect(identical(updatedText, text), isTrue);
|
||||||
|
expect(((updatedText.textRenderer as TextPaint).style.color!).a, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('applies text shadow style', () {
|
||||||
|
final component = RuntimeComponent(
|
||||||
|
node: const RuntimeNode(
|
||||||
|
id: 'text',
|
||||||
|
type: RuntimeNodeType.text,
|
||||||
|
text: 'Shadowed',
|
||||||
|
alpha: 0.5,
|
||||||
|
textShadowColor: Color(0x80000000),
|
||||||
|
textShadowOffsetX: 2,
|
||||||
|
textShadowOffsetY: 3,
|
||||||
|
textShadowBlur: 4,
|
||||||
|
),
|
||||||
|
resources: GameResourceManager(),
|
||||||
|
onNodeTap: (_, __) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final text = component.children.whereType<TextComponent>().single;
|
||||||
|
final style = (text.textRenderer as TextPaint).style;
|
||||||
|
final shadow = style.shadows!.single;
|
||||||
|
expect(shadow.color.a, closeTo(0.25, 0.003));
|
||||||
|
expect(shadow.offset.dx, 2);
|
||||||
|
expect(shadow.offset.dy, 3);
|
||||||
|
expect(shadow.blurRadius, 4);
|
||||||
|
});
|
||||||
|
|
||||||
test('multi-line non-button text is top aligned', () {
|
test('multi-line non-button text is top aligned', () {
|
||||||
final component = RuntimeComponent(
|
final component = RuntimeComponent(
|
||||||
node: const RuntimeNode(
|
node: const RuntimeNode(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async' as async;
|
import 'dart:async' as async;
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' show Rect;
|
||||||
|
|
||||||
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';
|
||||||
@@ -61,6 +62,19 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('loads TexturePacker atlas frames for image resources', () async {
|
||||||
|
final resources = GameResourceManager();
|
||||||
|
final package = await _createTextureAtlasPackage('texture_atlas');
|
||||||
|
|
||||||
|
await resources.mount(package);
|
||||||
|
|
||||||
|
final idle = resources.textureFrame('ui', 'button_idle.png');
|
||||||
|
final pressed = resources.textureFrame('ui', 'button_pressed.png');
|
||||||
|
expect(idle?.rect, Rect.fromLTWH(2, 3, 40, 20));
|
||||||
|
expect(pressed?.rect, Rect.fromLTWH(44, 3, 40, 20));
|
||||||
|
expect(resources.textureFrame('ui', 'missing.png'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('exports image debug json and evicts failed records', () async {
|
test('exports image debug json and evicts failed records', () async {
|
||||||
final resources = GameResourceManager();
|
final resources = GameResourceManager();
|
||||||
final package = await _createPackage('debug_json');
|
final package = await _createPackage('debug_json');
|
||||||
@@ -332,6 +346,87 @@ Future<GamePackage> _createPackage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<GamePackage> _createTextureAtlasPackage(String name) async {
|
||||||
|
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
||||||
|
Directory('${root.path}/assets').createSync(recursive: true);
|
||||||
|
File('${root.path}/assets/ui.png').writeAsBytesSync(_pngBytes);
|
||||||
|
File('${root.path}/assets/ui.json').writeAsStringSync('''
|
||||||
|
{
|
||||||
|
"frames": {
|
||||||
|
"button_idle.png": {
|
||||||
|
"frame": { "x": 2, "y": 3, "w": 40, "h": 20 },
|
||||||
|
"rotated": false,
|
||||||
|
"trimmed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''');
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
if (root.existsSync()) {
|
||||||
|
root.deleteSync(recursive: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final hashAtlas = RuntimeTextureAtlas.fromJsonString(
|
||||||
|
File('${root.path}/assets/ui.json').readAsStringSync(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
hashAtlas.frames['button_idle.png']?.rect,
|
||||||
|
Rect.fromLTWH(2, 3, 40, 20),
|
||||||
|
);
|
||||||
|
final arrayAtlas = RuntimeTextureAtlas.fromJsonString('''
|
||||||
|
{
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"filename": "button_pressed.png",
|
||||||
|
"frame": { "x": 44, "y": 3, "w": 40, "h": 20 },
|
||||||
|
"rotated": false,
|
||||||
|
"trimmed": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
''');
|
||||||
|
final mergedAtlas =
|
||||||
|
'''
|
||||||
|
{
|
||||||
|
"frames": {
|
||||||
|
"button_idle.png": {
|
||||||
|
"frame": { "x": 2, "y": 3, "w": 40, "h": 20 },
|
||||||
|
"rotated": false,
|
||||||
|
"trimmed": false
|
||||||
|
},
|
||||||
|
"button_pressed.png": {
|
||||||
|
"frame": { "x": ${arrayAtlas.frames['button_pressed.png']!.x}, "y": 3, "w": 40, "h": 20 },
|
||||||
|
"rotated": false,
|
||||||
|
"trimmed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
File('${root.path}/assets/ui.json').writeAsStringSync(mergedAtlas);
|
||||||
|
|
||||||
|
return GamePackage.file(
|
||||||
|
rootPath: root.path,
|
||||||
|
manifest: GamePackageManifest(
|
||||||
|
gameId: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
version: '0.1.0',
|
||||||
|
runtimeApiVersion: 1,
|
||||||
|
entry: 'scripts/main.lua',
|
||||||
|
assetsBase: 'assets',
|
||||||
|
resources: const {
|
||||||
|
'ui': GameResource(
|
||||||
|
type: GameResourceType.image,
|
||||||
|
path: 'assets/ui.png',
|
||||||
|
atlas: 'assets/ui.json',
|
||||||
|
preload: GameResourcePreload.lazy,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<GamePackage> _createMultiImagePackage(String name) async {
|
Future<GamePackage> _createMultiImagePackage(String name) async {
|
||||||
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
||||||
Directory('${root.path}/assets').createSync(recursive: true);
|
Directory('${root.path}/assets').createSync(recursive: true);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -895,6 +899,180 @@ end
|
|||||||
expect(c.y, 53);
|
expect(c.y, 53);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runtime.log records Lua debug messages in diagnostics', () async {
|
||||||
|
final package = await _createPackage(
|
||||||
|
mainScript: '''
|
||||||
|
function smoke_test(ctx)
|
||||||
|
runtime.log("smoke", ctx.runtimeApiVersion)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function init(ctx)
|
||||||
|
runtime.log("init", true, nil)
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_event(event)
|
||||||
|
runtime.log("event", event.type, event.target)
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
final diagnostics = RuntimeDiagnostics();
|
||||||
|
final engine = LuaDardoScriptEngine(diagnostics: diagnostics);
|
||||||
|
|
||||||
|
await engine.loadPackage(package);
|
||||||
|
expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue);
|
||||||
|
engine.init({'runtimeApiVersion': 1});
|
||||||
|
engine.dispatchEvent(
|
||||||
|
const RuntimeEvent(
|
||||||
|
type: RuntimeEventType.tap,
|
||||||
|
target: 'debug_button',
|
||||||
|
handler: 'debug',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
diagnostics.entries.map((entry) => entry.type),
|
||||||
|
everyElement(RuntimeDiagnosticType.luaLog),
|
||||||
|
);
|
||||||
|
expect(diagnostics.entries.map((entry) => entry.message), [
|
||||||
|
'smoke 1',
|
||||||
|
'init true nil',
|
||||||
|
'event tap debug_button',
|
||||||
|
]);
|
||||||
|
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: '''
|
||||||
@@ -926,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 {},
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -86,6 +94,17 @@
|
|||||||
---@field type RuntimeNodeType
|
---@field type RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -109,6 +128,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -143,6 +166,17 @@
|
|||||||
---@field type? RuntimeNodeType
|
---@field type? RuntimeNodeType
|
||||||
---@field parent? string
|
---@field parent? string
|
||||||
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image.
|
||||||
|
---@field frame? string TexturePacker frame name within the asset atlas.
|
||||||
|
---@field pressedFrame? string Button pressed-state TexturePacker frame name.
|
||||||
|
---@field disabledFrame? string Button disabled-state TexturePacker frame name.
|
||||||
|
---@field sourceX? number Source atlas region x in image pixels.
|
||||||
|
---@field sourceY? number Source atlas region y in image pixels.
|
||||||
|
---@field sourceWidth? number Source atlas region width in image pixels.
|
||||||
|
---@field sourceHeight? number Source atlas region height in image pixels.
|
||||||
|
---@field sliceLeft? number Left nine-slice inset in source pixels.
|
||||||
|
---@field sliceTop? number Top nine-slice inset in source pixels.
|
||||||
|
---@field sliceRight? number Right nine-slice inset in source pixels.
|
||||||
|
---@field sliceBottom? number Bottom nine-slice inset in source pixels.
|
||||||
---@field pressedAsset? string Button pressed-state image asset key.
|
---@field pressedAsset? string Button pressed-state image asset key.
|
||||||
---@field disabledAsset? string Button disabled-state image asset key.
|
---@field disabledAsset? string Button disabled-state image asset key.
|
||||||
---@field animation? string
|
---@field animation? string
|
||||||
@@ -166,6 +200,10 @@
|
|||||||
---@field color? string
|
---@field color? string
|
||||||
---@field fontSize? number
|
---@field fontSize? number
|
||||||
---@field textAlign? RuntimeTextAlign
|
---@field textAlign? RuntimeTextAlign
|
||||||
|
---@field textShadowColor? string
|
||||||
|
---@field textShadowOffsetX? number
|
||||||
|
---@field textShadowOffsetY? number
|
||||||
|
---@field textShadowBlur? number
|
||||||
---@field radius? number
|
---@field radius? number
|
||||||
---@field strokeWidth? number
|
---@field strokeWidth? number
|
||||||
---@field value? number
|
---@field value? number
|
||||||
@@ -550,8 +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 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
|
||||||
|
|||||||
Reference in New Issue
Block a user