Compare commits
4 Commits
4ce5fe1ae7
...
0d4fbd030c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d4fbd030c | ||
|
|
7b3c5cb0f5 | ||
|
|
4f36d68b74 | ||
|
|
220bb0aba1 |
@@ -3,7 +3,9 @@
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- Added TexturePacker frame, manual source-region, and nine-slice image rendering fields for image-capable nodes.
|
- Added TexturePacker frame, manual source-region, and nine-slice image rendering fields for image-capable nodes.
|
||||||
- Fixed nine-slice image seams by overlapping destination slices during runtime rendering.
|
- Added Runtime host bridge APIs for bidirectional Flutter host calls and Lua notifications.
|
||||||
|
- Added Runtime Lua networking APIs for async HTTP/HTTPS requests and WS/WSS connections.
|
||||||
|
- Fixed nine-slice image seams by overlapping destination slices and using inset source sampling during runtime rendering.
|
||||||
- Fixed Runtime alpha inheritance so parent fade commands apply to the full child subtree.
|
- Fixed Runtime alpha inheritance so parent fade commands apply to the full child subtree.
|
||||||
- Added Runtime text shadow fields for text-capable nodes.
|
- Added Runtime text shadow fields for text-capable nodes.
|
||||||
- Fixed Runtime node color alpha composition so `#AARRGGBB` alpha now multiplies with node/runtime alpha instead of being overwritten.
|
- Fixed Runtime node color alpha composition so `#AARRGGBB` alpha now multiplies with node/runtime alpha instead of being overwritten.
|
||||||
|
|||||||
@@ -73,6 +73,41 @@ Image-capable nodes may also use nine-slice scaling with source-pixel insets:
|
|||||||
|
|
||||||
Nine-slice keeps corners unscaled, stretches edges on one axis, and stretches the center on both axes. Insets are clamped to the selected source region and destination size.
|
Nine-slice keeps corners unscaled, stretches edges on one axis, and stretches the center on both axes. Insets are clamped to the selected source region and destination size.
|
||||||
|
|
||||||
|
## Runtime network API
|
||||||
|
|
||||||
|
Lua may use runtime-owned async networking without blocking script execution:
|
||||||
|
|
||||||
|
- `runtime.http_request({ id?, method?, url, headers?, body?, timeout? })`
|
||||||
|
- `runtime.ws_connect({ id?, url, protocols? })`
|
||||||
|
- `runtime.ws_send(id, message)`
|
||||||
|
- `runtime.ws_close(id)`
|
||||||
|
|
||||||
|
HTTP requests support `http` and `https` URLs. WebSocket connections support `ws` and `wss` URLs.
|
||||||
|
|
||||||
|
Network results are delivered back to Lua through `on_event(event)`:
|
||||||
|
|
||||||
|
- `network_http`: HTTP request completed or failed. `event.data` includes `id`, `url`, `method`, `ok`, and either `status`/`headers`/`body` or `error`.
|
||||||
|
- `network_ws_open`: WebSocket connection opened.
|
||||||
|
- `network_ws_message`: WebSocket message received.
|
||||||
|
- `network_ws_error`: WebSocket connection or stream error.
|
||||||
|
- `network_ws_close`: WebSocket connection closed.
|
||||||
|
|
||||||
|
## Runtime host bridge
|
||||||
|
|
||||||
|
Flutter host apps may register a `RuntimeHostBridge` when creating `LuaGameWidget` or `FlameLuaGame`.
|
||||||
|
|
||||||
|
Lua-to-Flutter calls:
|
||||||
|
|
||||||
|
- `runtime.host_call({ id?, method, data? })`: async request. Result is delivered to Lua as `host_call_result` with `id`, `method`, `ok`, and either `result` or `error`.
|
||||||
|
- `runtime.host_notify({ method, data? })`: fire-and-forget notification to Flutter host code.
|
||||||
|
|
||||||
|
Flutter-to-Lua calls:
|
||||||
|
|
||||||
|
- `FlameLuaGame.notifyLua(method, data?)`: emits a `host_notify` event into Lua.
|
||||||
|
- `FlameLuaGame.callLua(method, data?, timeout?)`: emits a `host_call` event into Lua and waits for Lua to call `runtime.host_respond({ id, result?, error? })`.
|
||||||
|
|
||||||
|
Host bridge payloads must be JSON-like values: null, bool, number, string, list, or string-keyed map. Unsupported Dart objects are converted to strings.
|
||||||
|
|
||||||
## RuntimeCommand
|
## RuntimeCommand
|
||||||
|
|
||||||
Runtime commands request generic side effects owned by Dart/Flame.
|
Runtime commands request generic side effects owned by Dart/Flame.
|
||||||
|
|||||||
@@ -64,6 +64,14 @@
|
|||||||
---| 'animation_done'
|
---| 'animation_done'
|
||||||
---| 'resize'
|
---| 'resize'
|
||||||
---| 'scroll'
|
---| 'scroll'
|
||||||
|
---| 'network_http'
|
||||||
|
---| 'network_ws_open'
|
||||||
|
---| 'network_ws_message'
|
||||||
|
---| 'network_ws_error'
|
||||||
|
---| 'network_ws_close'
|
||||||
|
---| 'host_notify'
|
||||||
|
---| 'host_call'
|
||||||
|
---| 'host_call_result'
|
||||||
|
|
||||||
---@alias RuntimeScaleMode
|
---@alias RuntimeScaleMode
|
||||||
---| 'fit'
|
---| 'fit'
|
||||||
@@ -580,9 +588,43 @@
|
|||||||
---@field cancel_group fun(group: string): RuntimeCommand
|
---@field cancel_group fun(group: string): RuntimeCommand
|
||||||
---@field cancel_scope fun(scope: string): RuntimeCommand
|
---@field cancel_scope fun(scope: string): RuntimeCommand
|
||||||
|
|
||||||
|
---@class RuntimeHttpRequestOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method? string HTTP method. Defaults to GET.
|
||||||
|
---@field url string http/https URL.
|
||||||
|
---@field headers? table<string, string>
|
||||||
|
---@field body? string
|
||||||
|
---@field timeout? number Timeout in seconds. Defaults to 15.
|
||||||
|
|
||||||
|
---@class RuntimeWsConnectOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field url string ws/wss URL.
|
||||||
|
---@field protocols? string[]
|
||||||
|
|
||||||
|
---@class RuntimeHostCallOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method string Host method name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostNotifyOptions
|
||||||
|
---@field method string Host notification name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostRespondOptions
|
||||||
|
---@field id string Host-to-Lua call id from host_call event.
|
||||||
|
---@field result? any
|
||||||
|
---@field error? string
|
||||||
|
|
||||||
---@class RuntimeImportApi
|
---@class RuntimeImportApi
|
||||||
---@field import fun(moduleName: string): table
|
---@field import fun(moduleName: string): table
|
||||||
---@field log fun(...: any)
|
---@field log fun(...: any)
|
||||||
|
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
|
||||||
|
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
|
||||||
|
---@field ws_send fun(id: string, message: string): boolean
|
||||||
|
---@field ws_close fun(id: string): boolean
|
||||||
|
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
|
||||||
|
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
|
||||||
|
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
|
||||||
|
|
||||||
---@type RuntimeImportApi
|
---@type RuntimeImportApi
|
||||||
runtime = runtime
|
runtime = runtime
|
||||||
|
|||||||
@@ -64,6 +64,14 @@
|
|||||||
---| 'animation_done'
|
---| 'animation_done'
|
||||||
---| 'resize'
|
---| 'resize'
|
||||||
---| 'scroll'
|
---| 'scroll'
|
||||||
|
---| 'network_http'
|
||||||
|
---| 'network_ws_open'
|
||||||
|
---| 'network_ws_message'
|
||||||
|
---| 'network_ws_error'
|
||||||
|
---| 'network_ws_close'
|
||||||
|
---| 'host_notify'
|
||||||
|
---| 'host_call'
|
||||||
|
---| 'host_call_result'
|
||||||
|
|
||||||
---@alias RuntimeScaleMode
|
---@alias RuntimeScaleMode
|
||||||
---| 'fit'
|
---| 'fit'
|
||||||
@@ -580,9 +588,43 @@
|
|||||||
---@field cancel_group fun(group: string): RuntimeCommand
|
---@field cancel_group fun(group: string): RuntimeCommand
|
||||||
---@field cancel_scope fun(scope: string): RuntimeCommand
|
---@field cancel_scope fun(scope: string): RuntimeCommand
|
||||||
|
|
||||||
|
---@class RuntimeHttpRequestOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method? string HTTP method. Defaults to GET.
|
||||||
|
---@field url string http/https URL.
|
||||||
|
---@field headers? table<string, string>
|
||||||
|
---@field body? string
|
||||||
|
---@field timeout? number Timeout in seconds. Defaults to 15.
|
||||||
|
|
||||||
|
---@class RuntimeWsConnectOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field url string ws/wss URL.
|
||||||
|
---@field protocols? string[]
|
||||||
|
|
||||||
|
---@class RuntimeHostCallOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method string Host method name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostNotifyOptions
|
||||||
|
---@field method string Host notification name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostRespondOptions
|
||||||
|
---@field id string Host-to-Lua call id from host_call event.
|
||||||
|
---@field result? any
|
||||||
|
---@field error? string
|
||||||
|
|
||||||
---@class RuntimeImportApi
|
---@class RuntimeImportApi
|
||||||
---@field import fun(moduleName: string): table
|
---@field import fun(moduleName: string): table
|
||||||
---@field log fun(...: any)
|
---@field log fun(...: any)
|
||||||
|
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
|
||||||
|
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
|
||||||
|
---@field ws_send fun(id: string, message: string): boolean
|
||||||
|
---@field ws_close fun(id: string): boolean
|
||||||
|
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
|
||||||
|
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
|
||||||
|
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
|
||||||
|
|
||||||
---@type RuntimeImportApi
|
---@type RuntimeImportApi
|
||||||
runtime = runtime
|
runtime = runtime
|
||||||
|
|||||||
@@ -64,6 +64,14 @@
|
|||||||
---| 'animation_done'
|
---| 'animation_done'
|
||||||
---| 'resize'
|
---| 'resize'
|
||||||
---| 'scroll'
|
---| 'scroll'
|
||||||
|
---| 'network_http'
|
||||||
|
---| 'network_ws_open'
|
||||||
|
---| 'network_ws_message'
|
||||||
|
---| 'network_ws_error'
|
||||||
|
---| 'network_ws_close'
|
||||||
|
---| 'host_notify'
|
||||||
|
---| 'host_call'
|
||||||
|
---| 'host_call_result'
|
||||||
|
|
||||||
---@alias RuntimeScaleMode
|
---@alias RuntimeScaleMode
|
||||||
---| 'fit'
|
---| 'fit'
|
||||||
@@ -580,9 +588,43 @@
|
|||||||
---@field cancel_group fun(group: string): RuntimeCommand
|
---@field cancel_group fun(group: string): RuntimeCommand
|
||||||
---@field cancel_scope fun(scope: string): RuntimeCommand
|
---@field cancel_scope fun(scope: string): RuntimeCommand
|
||||||
|
|
||||||
|
---@class RuntimeHttpRequestOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method? string HTTP method. Defaults to GET.
|
||||||
|
---@field url string http/https URL.
|
||||||
|
---@field headers? table<string, string>
|
||||||
|
---@field body? string
|
||||||
|
---@field timeout? number Timeout in seconds. Defaults to 15.
|
||||||
|
|
||||||
|
---@class RuntimeWsConnectOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field url string ws/wss URL.
|
||||||
|
---@field protocols? string[]
|
||||||
|
|
||||||
|
---@class RuntimeHostCallOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method string Host method name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostNotifyOptions
|
||||||
|
---@field method string Host notification name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostRespondOptions
|
||||||
|
---@field id string Host-to-Lua call id from host_call event.
|
||||||
|
---@field result? any
|
||||||
|
---@field error? string
|
||||||
|
|
||||||
---@class RuntimeImportApi
|
---@class RuntimeImportApi
|
||||||
---@field import fun(moduleName: string): table
|
---@field import fun(moduleName: string): table
|
||||||
---@field log fun(...: any)
|
---@field log fun(...: any)
|
||||||
|
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
|
||||||
|
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
|
||||||
|
---@field ws_send fun(id: string, message: string): boolean
|
||||||
|
---@field ws_close fun(id: string): boolean
|
||||||
|
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
|
||||||
|
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
|
||||||
|
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
|
||||||
|
|
||||||
---@type RuntimeImportApi
|
---@type RuntimeImportApi
|
||||||
runtime = runtime
|
runtime = runtime
|
||||||
|
|||||||
@@ -64,6 +64,14 @@
|
|||||||
---| 'animation_done'
|
---| 'animation_done'
|
||||||
---| 'resize'
|
---| 'resize'
|
||||||
---| 'scroll'
|
---| 'scroll'
|
||||||
|
---| 'network_http'
|
||||||
|
---| 'network_ws_open'
|
||||||
|
---| 'network_ws_message'
|
||||||
|
---| 'network_ws_error'
|
||||||
|
---| 'network_ws_close'
|
||||||
|
---| 'host_notify'
|
||||||
|
---| 'host_call'
|
||||||
|
---| 'host_call_result'
|
||||||
|
|
||||||
---@alias RuntimeScaleMode
|
---@alias RuntimeScaleMode
|
||||||
---| 'fit'
|
---| 'fit'
|
||||||
@@ -580,9 +588,43 @@
|
|||||||
---@field cancel_group fun(group: string): RuntimeCommand
|
---@field cancel_group fun(group: string): RuntimeCommand
|
||||||
---@field cancel_scope fun(scope: string): RuntimeCommand
|
---@field cancel_scope fun(scope: string): RuntimeCommand
|
||||||
|
|
||||||
|
---@class RuntimeHttpRequestOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method? string HTTP method. Defaults to GET.
|
||||||
|
---@field url string http/https URL.
|
||||||
|
---@field headers? table<string, string>
|
||||||
|
---@field body? string
|
||||||
|
---@field timeout? number Timeout in seconds. Defaults to 15.
|
||||||
|
|
||||||
|
---@class RuntimeWsConnectOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field url string ws/wss URL.
|
||||||
|
---@field protocols? string[]
|
||||||
|
|
||||||
|
---@class RuntimeHostCallOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method string Host method name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostNotifyOptions
|
||||||
|
---@field method string Host notification name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostRespondOptions
|
||||||
|
---@field id string Host-to-Lua call id from host_call event.
|
||||||
|
---@field result? any
|
||||||
|
---@field error? string
|
||||||
|
|
||||||
---@class RuntimeImportApi
|
---@class RuntimeImportApi
|
||||||
---@field import fun(moduleName: string): table
|
---@field import fun(moduleName: string): table
|
||||||
---@field log fun(...: any)
|
---@field log fun(...: any)
|
||||||
|
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
|
||||||
|
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
|
||||||
|
---@field ws_send fun(id: string, message: string): boolean
|
||||||
|
---@field ws_close fun(id: string): boolean
|
||||||
|
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
|
||||||
|
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
|
||||||
|
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
|
||||||
|
|
||||||
---@type RuntimeImportApi
|
---@type RuntimeImportApi
|
||||||
runtime = runtime
|
runtime = runtime
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ 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,
|
||||||
|
|||||||
@@ -136,4 +136,6 @@ enum RuntimeDiagnosticType {
|
|||||||
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,6 +22,7 @@ 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 'runtime_locale.dart';
|
import 'runtime_locale.dart';
|
||||||
import 'runtime_options.dart';
|
import 'runtime_options.dart';
|
||||||
@@ -39,6 +42,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 +64,15 @@ 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;
|
||||||
RuntimeViewportConfig? _viewportConfig;
|
RuntimeViewportConfig? _viewportConfig;
|
||||||
late final CommandExecutor _commands;
|
late final CommandExecutor _commands;
|
||||||
RuntimeSession? _session;
|
RuntimeSession? _session;
|
||||||
@@ -80,6 +87,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};
|
||||||
@@ -102,6 +131,17 @@ 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 activation =
|
final activation =
|
||||||
await PackageActivationController(
|
await PackageActivationController(
|
||||||
repository: _packageRepository,
|
repository: _packageRepository,
|
||||||
@@ -111,6 +151,10 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
|||||||
resourceManagerFactory: _createResourceManager,
|
resourceManagerFactory: _createResourceManager,
|
||||||
audioManagerFactory: _createAudioManager,
|
audioManagerFactory: _createAudioManager,
|
||||||
scriptEngineFactory: _scriptEngineFactory,
|
scriptEngineFactory: _scriptEngineFactory,
|
||||||
|
scriptServices: RuntimeScriptServices(
|
||||||
|
network: network,
|
||||||
|
hostBridge: hostBridgeManager,
|
||||||
|
),
|
||||||
store: StablePackageStore(runtimeOptions: runtimeOptions),
|
store: StablePackageStore(runtimeOptions: runtimeOptions),
|
||||||
assetFallback: AssetGamePackageRepository(
|
assetFallback: AssetGamePackageRepository(
|
||||||
runtimeOptions: runtimeOptions,
|
runtimeOptions: runtimeOptions,
|
||||||
@@ -159,6 +203,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 +384,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();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flame/game.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import '../diagnostics/runtime_diagnostics.dart';
|
import '../diagnostics/runtime_diagnostics.dart';
|
||||||
|
import '../host/runtime_host_bridge.dart';
|
||||||
import '../packages/game_package_repository.dart';
|
import '../packages/game_package_repository.dart';
|
||||||
import '../scripting/lua_dardo_script_engine.dart';
|
import '../scripting/lua_dardo_script_engine.dart';
|
||||||
import 'flame_lua_game.dart';
|
import 'flame_lua_game.dart';
|
||||||
@@ -14,6 +15,7 @@ class LuaGameWidget extends StatelessWidget {
|
|||||||
this.serverUrl,
|
this.serverUrl,
|
||||||
this.localeOverride,
|
this.localeOverride,
|
||||||
this.runtimeOptions = const RuntimeOptions(),
|
this.runtimeOptions = const RuntimeOptions(),
|
||||||
|
this.hostBridge = const RuntimeHostBridge(),
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ class LuaGameWidget extends StatelessWidget {
|
|||||||
final Uri? serverUrl;
|
final Uri? serverUrl;
|
||||||
final Locale? localeOverride;
|
final Locale? localeOverride;
|
||||||
final RuntimeOptions runtimeOptions;
|
final RuntimeOptions runtimeOptions;
|
||||||
|
final RuntimeHostBridge hostBridge;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -42,6 +45,7 @@ class LuaGameWidget extends StatelessWidget {
|
|||||||
)),
|
)),
|
||||||
gameId: gameId,
|
gameId: gameId,
|
||||||
runtimeOptions: runtimeOptions,
|
runtimeOptions: runtimeOptions,
|
||||||
|
hostBridge: hostBridge,
|
||||||
localeOverride: localeOverride,
|
localeOverride: localeOverride,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
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';
|
||||||
|
}
|
||||||
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,7 @@
|
|||||||
import '../audio/runtime_audio_manager.dart';
|
import '../audio/runtime_audio_manager.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';
|
||||||
@@ -19,6 +20,7 @@ class PackageActivationController {
|
|||||||
this.resourceManagerFactory,
|
this.resourceManagerFactory,
|
||||||
this.audioManagerFactory,
|
this.audioManagerFactory,
|
||||||
this.scriptEngineFactory,
|
this.scriptEngineFactory,
|
||||||
|
this.scriptServices = const RuntimeScriptServices(),
|
||||||
});
|
});
|
||||||
|
|
||||||
final GamePackageRepository repository;
|
final GamePackageRepository repository;
|
||||||
@@ -31,6 +33,7 @@ class PackageActivationController {
|
|||||||
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,
|
||||||
@@ -144,7 +147,10 @@ class PackageActivationController {
|
|||||||
_ensureContinue(shouldContinue);
|
_ensureContinue(shouldContinue);
|
||||||
await preparedAudio?.mount(candidate);
|
await preparedAudio?.mount(candidate);
|
||||||
_ensureContinue(shouldContinue);
|
_ensureContinue(shouldContinue);
|
||||||
await preparedScriptEngine.loadPackage(candidate);
|
await preparedScriptEngine.loadPackage(
|
||||||
|
candidate,
|
||||||
|
services: scriptServices,
|
||||||
|
);
|
||||||
_ensureContinue(shouldContinue);
|
_ensureContinue(shouldContinue);
|
||||||
|
|
||||||
final context = contextBuilder(candidate);
|
final context = contextBuilder(candidate);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
|
|||||||
double sliceRight = 0,
|
double sliceRight = 0,
|
||||||
double sliceBottom = 0,
|
double sliceBottom = 0,
|
||||||
double destinationOverlap = 0,
|
double destinationOverlap = 0,
|
||||||
|
double sourceInset = 0,
|
||||||
}) {
|
}) {
|
||||||
if (source.width <= 0 ||
|
if (source.width <= 0 ||
|
||||||
source.height <= 0 ||
|
source.height <= 0 ||
|
||||||
@@ -88,7 +89,7 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
|
|||||||
final parts = <({Rect source, Rect destination})>[];
|
final parts = <({Rect source, Rect destination})>[];
|
||||||
for (var y = 0; y < 3; y++) {
|
for (var y = 0; y < 3; y++) {
|
||||||
for (var x = 0; x < 3; x++) {
|
for (var x = 0; x < 3; x++) {
|
||||||
final sourcePart = Rect.fromLTRB(
|
final rawSourcePart = Rect.fromLTRB(
|
||||||
sourceXs[x],
|
sourceXs[x],
|
||||||
sourceYs[y],
|
sourceYs[y],
|
||||||
sourceXs[x + 1],
|
sourceXs[x + 1],
|
||||||
@@ -100,12 +101,17 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
|
|||||||
destXs[x + 1],
|
destXs[x + 1],
|
||||||
destYs[y + 1],
|
destYs[y + 1],
|
||||||
);
|
);
|
||||||
if (sourcePart.width <= 0 ||
|
if (rawSourcePart.width <= 0 ||
|
||||||
sourcePart.height <= 0 ||
|
rawSourcePart.height <= 0 ||
|
||||||
rawDestPart.width <= 0 ||
|
rawDestPart.width <= 0 ||
|
||||||
rawDestPart.height <= 0) {
|
rawDestPart.height <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
final sourcePart = _insetNineSliceSourceRect(
|
||||||
|
rawSourcePart,
|
||||||
|
bounds: source,
|
||||||
|
inset: sourceInset,
|
||||||
|
);
|
||||||
final destPart = _overlapNineSliceDestinationRect(
|
final destPart = _overlapNineSliceDestinationRect(
|
||||||
rawDestPart,
|
rawDestPart,
|
||||||
x: x,
|
x: x,
|
||||||
@@ -119,6 +125,22 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rect _insetNineSliceSourceRect(
|
||||||
|
Rect rect, {
|
||||||
|
required Rect bounds,
|
||||||
|
required double inset,
|
||||||
|
}) {
|
||||||
|
if (inset <= 0 || rect.width <= inset * 2 || rect.height <= inset * 2) {
|
||||||
|
return rect;
|
||||||
|
}
|
||||||
|
return Rect.fromLTRB(
|
||||||
|
math.min(rect.right, math.max(bounds.left, rect.left + inset)),
|
||||||
|
math.min(rect.bottom, math.max(bounds.top, rect.top + inset)),
|
||||||
|
math.max(rect.left, math.min(bounds.right, rect.right - inset)),
|
||||||
|
math.max(rect.top, math.min(bounds.bottom, rect.bottom - inset)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Rect _overlapNineSliceDestinationRect(
|
Rect _overlapNineSliceDestinationRect(
|
||||||
Rect rect, {
|
Rect rect, {
|
||||||
required int x,
|
required int x,
|
||||||
@@ -575,7 +597,13 @@ class RuntimeComponent extends PositionComponent
|
|||||||
..color = composeRuntimeColorAlpha(Colors.white, renderAlpha);
|
..color = composeRuntimeColorAlpha(Colors.white, renderAlpha);
|
||||||
final source = _imageSourceRect(image, _currentImageFrame(_node));
|
final source = _imageSourceRect(image, _currentImageFrame(_node));
|
||||||
if (_usesNineSlice(source, rect)) {
|
if (_usesNineSlice(source, rect)) {
|
||||||
_drawNineSliceImage(canvas, image, source, rect, imagePaint);
|
_drawNineSliceImage(
|
||||||
|
canvas,
|
||||||
|
image,
|
||||||
|
source,
|
||||||
|
rect,
|
||||||
|
imagePaint..filterQuality = FilterQuality.none,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
canvas.drawImageRect(image, source, rect, imagePaint);
|
canvas.drawImageRect(image, source, rect, imagePaint);
|
||||||
}
|
}
|
||||||
@@ -630,7 +658,8 @@ class RuntimeComponent extends PositionComponent
|
|||||||
final parts = runtimeNineSliceRects(
|
final parts = runtimeNineSliceRects(
|
||||||
source: source,
|
source: source,
|
||||||
destination: destination,
|
destination: destination,
|
||||||
destinationOverlap: 0.5,
|
destinationOverlap: 1,
|
||||||
|
sourceInset: 0.5,
|
||||||
sliceLeft: _node.sliceLeft ?? 0,
|
sliceLeft: _node.sliceLeft ?? 0,
|
||||||
sliceTop: _node.sliceTop ?? 0,
|
sliceTop: _node.sliceTop ?? 0,
|
||||||
sliceRight: _node.sliceRight ?? 0,
|
sliceRight: _node.sliceRight ?? 0,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import 'dart:async' as async;
|
||||||
|
|
||||||
import 'package:lua_dardo_plus/lua.dart';
|
import 'package:lua_dardo_plus/lua.dart';
|
||||||
|
|
||||||
import '../diagnostics/runtime_diagnostics.dart';
|
import '../diagnostics/runtime_diagnostics.dart';
|
||||||
|
import '../host/runtime_host_bridge.dart';
|
||||||
import '../models/game_diff.dart';
|
import '../models/game_diff.dart';
|
||||||
import '../models/runtime_event.dart';
|
import '../models/runtime_event.dart';
|
||||||
|
import '../network/runtime_network_manager.dart';
|
||||||
import '../packages/game_package.dart';
|
import '../packages/game_package.dart';
|
||||||
|
import 'runtime_script_services.dart';
|
||||||
import 'script_engine.dart';
|
import 'script_engine.dart';
|
||||||
|
|
||||||
class LuaDardoScriptEngine implements ScriptEngine {
|
class LuaDardoScriptEngine implements ScriptEngine {
|
||||||
@@ -13,10 +18,19 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
final RuntimeDiagnostics? _diagnostics;
|
final RuntimeDiagnostics? _diagnostics;
|
||||||
late final LuaState _lua;
|
late final LuaState _lua;
|
||||||
late final Map<String, String> _moduleScripts;
|
late final Map<String, String> _moduleScripts;
|
||||||
|
RuntimeScriptServices _services = const RuntimeScriptServices();
|
||||||
|
int _networkRequestCounter = 0;
|
||||||
|
int _hostCallCounter = 0;
|
||||||
final Set<String> _loadingModules = {};
|
final Set<String> _loadingModules = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> loadPackage(GamePackage package) async {
|
Future<void> loadPackage(
|
||||||
|
GamePackage package, {
|
||||||
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
|
}) async {
|
||||||
|
_services = services;
|
||||||
|
_networkRequestCounter = 0;
|
||||||
|
_hostCallCounter = 0;
|
||||||
final script = await package.readText(package.manifest.entry);
|
final script = await package.readText(package.manifest.entry);
|
||||||
_moduleScripts = {};
|
_moduleScripts = {};
|
||||||
for (final entry in package.manifest.modules.entries) {
|
for (final entry in package.manifest.modules.entries) {
|
||||||
@@ -112,9 +126,227 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
_lua.pushDartFunction(_log);
|
_lua.pushDartFunction(_log);
|
||||||
_lua.setField(-2, 'log');
|
_lua.setField(-2, 'log');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_httpRequest);
|
||||||
|
_lua.setField(-2, 'http_request');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_wsConnect);
|
||||||
|
_lua.setField(-2, 'ws_connect');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_wsSend);
|
||||||
|
_lua.setField(-2, 'ws_send');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_wsClose);
|
||||||
|
_lua.setField(-2, 'ws_close');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_hostCall);
|
||||||
|
_lua.setField(-2, 'host_call');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_hostNotify);
|
||||||
|
_lua.setField(-2, 'host_notify');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_hostRespond);
|
||||||
|
_lua.setField(-2, 'host_respond');
|
||||||
|
|
||||||
_lua.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _nextNetworkRequestId(String prefix) {
|
||||||
|
_networkRequestCounter += 1;
|
||||||
|
return '$prefix:$_networkRequestCounter';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _requiredMapArgument(int index, String label) {
|
||||||
|
final value = _readValue(index);
|
||||||
|
if (value is Map) {
|
||||||
|
return Map<String, Object?>.from(value);
|
||||||
|
}
|
||||||
|
throw FormatException('$label requires a table');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _requiredString(Map<String, Object?> map, String key) {
|
||||||
|
final value = _optionalString(map, key);
|
||||||
|
if (value == null) {
|
||||||
|
throw FormatException('$key must be a non-empty string');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _optionalString(Map<String, Object?> map, String key) {
|
||||||
|
final value = map[key];
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw FormatException('$key must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _optionalNumber(Map<String, Object?> map, String key) {
|
||||||
|
final value = map[key];
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value is num) {
|
||||||
|
return value.toDouble();
|
||||||
|
}
|
||||||
|
throw FormatException('$key must be a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _optionalStringMap(Object? value, String key) {
|
||||||
|
if (value == null) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
if (value is! Map) {
|
||||||
|
throw FormatException('$key must be a string map');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
for (final entry in value.entries)
|
||||||
|
entry.key.toString(): entry.value?.toString() ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _optionalStringList(Object? value, String key) {
|
||||||
|
if (value == null) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
if (value is List) {
|
||||||
|
return value.map((item) => item.toString()).toList(growable: false);
|
||||||
|
}
|
||||||
|
if (value is Map) {
|
||||||
|
final entries = value.entries.toList()
|
||||||
|
..sort((a, b) => a.key.toString().compareTo(b.key.toString()));
|
||||||
|
return entries
|
||||||
|
.map((entry) => entry.value.toString())
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
throw FormatException('$key must be a string list');
|
||||||
|
}
|
||||||
|
|
||||||
int _log(LuaState lua) {
|
int _log(LuaState lua) {
|
||||||
final argumentCount = lua.getTop();
|
final argumentCount = lua.getTop();
|
||||||
final messageParts = <String>[];
|
final messageParts = <String>[];
|
||||||
|
|||||||
9
lib/runtime/scripting/runtime_script_services.dart
Normal file
9
lib/runtime/scripting/runtime_script_services.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import '../host/runtime_host_bridge.dart';
|
||||||
|
import '../network/runtime_network_manager.dart';
|
||||||
|
|
||||||
|
class RuntimeScriptServices {
|
||||||
|
const RuntimeScriptServices({this.network, this.hostBridge});
|
||||||
|
|
||||||
|
final RuntimeNetworkManager? network;
|
||||||
|
final RuntimeHostBridgeManager? hostBridge;
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
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);
|
Future<void> loadPackage(
|
||||||
|
GamePackage package, {
|
||||||
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
|
});
|
||||||
|
|
||||||
bool smokeTest(Map<String, Object?> context);
|
bool smokeTest(Map<String, Object?> context);
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -150,7 +151,10 @@ 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
|
@override
|
||||||
bool smokeTest(Map<String, Object?> context) => true;
|
bool smokeTest(Map<String, Object?> context) => true;
|
||||||
|
|||||||
@@ -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,18 @@ 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();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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';
|
||||||
|
|
||||||
@@ -357,7 +358,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,39 @@ void main() {
|
|||||||
expect(parts[4].source, const Rect.fromLTRB(10, 10, 20, 20));
|
expect(parts[4].source, const Rect.fromLTRB(10, 10, 20, 20));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('insets nine-slice source rects to avoid atlas edge sampling', () {
|
||||||
|
final parts = runtimeNineSliceRects(
|
||||||
|
source: const Rect.fromLTWH(10, 20, 30, 30),
|
||||||
|
destination: const Rect.fromLTWH(0, 0, 90, 90),
|
||||||
|
sliceLeft: 10,
|
||||||
|
sliceTop: 10,
|
||||||
|
sliceRight: 10,
|
||||||
|
sliceBottom: 10,
|
||||||
|
sourceInset: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parts.first.source, const Rect.fromLTRB(10.5, 20.5, 19.5, 29.5));
|
||||||
|
expect(parts[4].source, const Rect.fromLTRB(20.5, 30.5, 29.5, 39.5));
|
||||||
|
expect(parts.last.source, const Rect.fromLTRB(30.5, 40.5, 39.5, 49.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'keeps tiny nine-slice source rects when inset would collapse them',
|
||||||
|
() {
|
||||||
|
final parts = runtimeNineSliceRects(
|
||||||
|
source: const Rect.fromLTWH(0, 0, 3, 3),
|
||||||
|
destination: const Rect.fromLTWH(0, 0, 30, 30),
|
||||||
|
sliceLeft: 1,
|
||||||
|
sliceTop: 1,
|
||||||
|
sliceRight: 1,
|
||||||
|
sliceBottom: 1,
|
||||||
|
sourceInset: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parts[4].source, const Rect.fromLTRB(1, 1, 2, 2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('updates text alpha style without rebuilding text component', () {
|
test('updates text alpha style without rebuilding text component', () {
|
||||||
final component = RuntimeComponent(
|
final component = RuntimeComponent(
|
||||||
node: const RuntimeNode(
|
node: const RuntimeNode(
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import 'dart:io';
|
|||||||
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
|
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
|
||||||
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
|
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
|
||||||
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
|
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
|
||||||
|
import 'package:flame_lua_runtime/runtime/host/runtime_host_bridge.dart';
|
||||||
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
|
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
|
||||||
|
import 'package:flame_lua_runtime/runtime/network/runtime_network_manager.dart';
|
||||||
import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart';
|
import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart';
|
||||||
import 'package:flame_lua_runtime/runtime/scripting/lua_dardo_script_engine.dart';
|
import 'package:flame_lua_runtime/runtime/scripting/lua_dardo_script_engine.dart';
|
||||||
|
import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
Future<GamePackage> _loadExamplePackage(String gameId) async {
|
Future<GamePackage> _loadExamplePackage(String gameId) async {
|
||||||
@@ -941,6 +944,135 @@ end
|
|||||||
expect(diagnostics.entries.first.context, {'argumentCount': 2});
|
expect(diagnostics.entries.first.context, {'argumentCount': 2});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('exposes async HTTP runtime API to Lua', () async {
|
||||||
|
final package = await _createPackage(
|
||||||
|
mainScript: '''
|
||||||
|
function smoke_test(ctx) return true end
|
||||||
|
function init(ctx)
|
||||||
|
local id = runtime.http_request({
|
||||||
|
id = "login",
|
||||||
|
method = "post",
|
||||||
|
url = "https://example.com/login",
|
||||||
|
headers = { Authorization = "Bearer token" },
|
||||||
|
body = "{}",
|
||||||
|
timeout = 2,
|
||||||
|
})
|
||||||
|
return { commands = { { type = "toast", text = id } } }
|
||||||
|
end
|
||||||
|
function on_event(event) return {} end
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
final network = _RecordingNetworkManager();
|
||||||
|
final engine = LuaDardoScriptEngine();
|
||||||
|
|
||||||
|
await engine.loadPackage(
|
||||||
|
package,
|
||||||
|
services: RuntimeScriptServices(network: network),
|
||||||
|
);
|
||||||
|
final diff = engine.init({'runtimeApiVersion': 1});
|
||||||
|
|
||||||
|
expect(diff.commands.single.payload['text'], 'login');
|
||||||
|
expect(network.httpRequests, hasLength(1));
|
||||||
|
expect(network.httpRequests.single.id, 'login');
|
||||||
|
expect(network.httpRequests.single.method, 'POST');
|
||||||
|
expect(network.httpRequests.single.uri.scheme, 'https');
|
||||||
|
expect(
|
||||||
|
network.httpRequests.single.headers['Authorization'],
|
||||||
|
'Bearer token',
|
||||||
|
);
|
||||||
|
expect(network.httpRequests.single.body, '{}');
|
||||||
|
expect(network.httpRequests.single.timeout, const Duration(seconds: 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes WebSocket runtime API to Lua', () async {
|
||||||
|
final package = await _createPackage(
|
||||||
|
mainScript: '''
|
||||||
|
function smoke_test(ctx) return true end
|
||||||
|
function init(ctx)
|
||||||
|
local id = runtime.ws_connect({
|
||||||
|
id = "chat",
|
||||||
|
url = "wss://example.com/socket",
|
||||||
|
protocols = { "game.v1" },
|
||||||
|
})
|
||||||
|
local sent = runtime.ws_send(id, "hello")
|
||||||
|
local closed = runtime.ws_close(id)
|
||||||
|
return {
|
||||||
|
commands = {
|
||||||
|
{ type = "toast", text = id .. ":" .. tostring(sent) .. ":" .. tostring(closed) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
function on_event(event) return {} end
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
final network = _RecordingNetworkManager();
|
||||||
|
final engine = LuaDardoScriptEngine();
|
||||||
|
|
||||||
|
await engine.loadPackage(
|
||||||
|
package,
|
||||||
|
services: RuntimeScriptServices(network: network),
|
||||||
|
);
|
||||||
|
final diff = engine.init({'runtimeApiVersion': 1});
|
||||||
|
|
||||||
|
expect(diff.commands.single.payload['text'], 'chat:true:true');
|
||||||
|
expect(network.wsConnectRequests.single.id, 'chat');
|
||||||
|
expect(network.wsConnectRequests.single.uri.scheme, 'wss');
|
||||||
|
expect(network.wsConnectRequests.single.protocols, ['game.v1']);
|
||||||
|
expect(network.wsMessages, {'chat': 'hello'});
|
||||||
|
expect(network.closedWebSockets, ['chat']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes host bridge runtime API to Lua', () async {
|
||||||
|
final package = await _createPackage(
|
||||||
|
mainScript: '''
|
||||||
|
function smoke_test(ctx) return true end
|
||||||
|
function init(ctx)
|
||||||
|
local id = runtime.host_call({
|
||||||
|
id = "profile",
|
||||||
|
method = "user.profile",
|
||||||
|
data = { userId = 9 },
|
||||||
|
})
|
||||||
|
local notified = runtime.host_notify({
|
||||||
|
method = "analytics",
|
||||||
|
data = { event = "open" },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
commands = {
|
||||||
|
{ type = "toast", text = id .. ":" .. tostring(notified) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
function on_event(event)
|
||||||
|
if event.type == "host_call" then
|
||||||
|
runtime.host_respond({ id = event.data.id, result = { handled = event.data.method } })
|
||||||
|
end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
final hostBridge = _RecordingHostBridgeManager();
|
||||||
|
final engine = LuaDardoScriptEngine();
|
||||||
|
|
||||||
|
await engine.loadPackage(
|
||||||
|
package,
|
||||||
|
services: RuntimeScriptServices(hostBridge: hostBridge),
|
||||||
|
);
|
||||||
|
final diff = engine.init({'runtimeApiVersion': 1});
|
||||||
|
|
||||||
|
expect(diff.commands.single.payload['text'], 'profile:true');
|
||||||
|
expect(hostBridge.calls.single.method, 'user.profile');
|
||||||
|
expect(hostBridge.calls.single.data, {'userId': 9});
|
||||||
|
expect(hostBridge.notifications.single.method, 'analytics');
|
||||||
|
expect(hostBridge.notifications.single.data, {'event': 'open'});
|
||||||
|
|
||||||
|
final callFuture = hostBridge.callLua('flutter.request', data: {'id': 2});
|
||||||
|
final event = _RecordingHostBridgeManager.events.singleWhere(
|
||||||
|
(item) => item.type == RuntimeHostEventType.call,
|
||||||
|
);
|
||||||
|
engine.dispatchEvent(event);
|
||||||
|
expect(await callFuture, {'handled': 'flutter.request'});
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects undeclared module imports', () async {
|
test('rejects undeclared module imports', () async {
|
||||||
final package = await _createPackage(
|
final package = await _createPackage(
|
||||||
mainScript: '''
|
mainScript: '''
|
||||||
@@ -972,6 +1104,63 @@ function on_event(event) return {} end
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RecordingHostBridgeManager extends RuntimeHostBridgeManager {
|
||||||
|
_RecordingHostBridgeManager()
|
||||||
|
: super(bridge: const RuntimeHostBridge(), eventSink: events.add);
|
||||||
|
|
||||||
|
static final events = <RuntimeEvent>[];
|
||||||
|
final calls = <RuntimeHostCall>[];
|
||||||
|
final notifications = <RuntimeHostNotification>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> callHost(RuntimeHostCall call) async {
|
||||||
|
calls.add(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool notifyHost(RuntimeHostNotification notification) {
|
||||||
|
notifications.add(notification);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordingNetworkManager extends RuntimeNetworkManager {
|
||||||
|
_RecordingNetworkManager()
|
||||||
|
: super(
|
||||||
|
eventSink: (event) {
|
||||||
|
events.add(event);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
static final events = <RuntimeEvent>[];
|
||||||
|
final httpRequests = <RuntimeHttpRequest>[];
|
||||||
|
final wsConnectRequests = <RuntimeWebSocketConnectRequest>[];
|
||||||
|
final wsMessages = <String, Object?>{};
|
||||||
|
final closedWebSockets = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> httpRequest(RuntimeHttpRequest request) async {
|
||||||
|
httpRequests.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void wsConnect(RuntimeWebSocketConnectRequest request) {
|
||||||
|
wsConnectRequests.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool wsSend(String id, Object? message) {
|
||||||
|
wsMessages[id] = message;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool closeWebSocket(String id, {bool emitClose = true}) {
|
||||||
|
closedWebSockets.add(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<GamePackage> _createPackage({
|
Future<GamePackage> _createPackage({
|
||||||
required String mainScript,
|
required String mainScript,
|
||||||
Map<String, String> modules = const {},
|
Map<String, String> modules = const {},
|
||||||
|
|||||||
@@ -64,6 +64,14 @@
|
|||||||
---| 'animation_done'
|
---| 'animation_done'
|
||||||
---| 'resize'
|
---| 'resize'
|
||||||
---| 'scroll'
|
---| 'scroll'
|
||||||
|
---| 'network_http'
|
||||||
|
---| 'network_ws_open'
|
||||||
|
---| 'network_ws_message'
|
||||||
|
---| 'network_ws_error'
|
||||||
|
---| 'network_ws_close'
|
||||||
|
---| 'host_notify'
|
||||||
|
---| 'host_call'
|
||||||
|
---| 'host_call_result'
|
||||||
|
|
||||||
---@alias RuntimeScaleMode
|
---@alias RuntimeScaleMode
|
||||||
---| 'fit'
|
---| 'fit'
|
||||||
@@ -580,9 +588,43 @@
|
|||||||
---@field cancel_group fun(group: string): RuntimeCommand
|
---@field cancel_group fun(group: string): RuntimeCommand
|
||||||
---@field cancel_scope fun(scope: string): RuntimeCommand
|
---@field cancel_scope fun(scope: string): RuntimeCommand
|
||||||
|
|
||||||
|
---@class RuntimeHttpRequestOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method? string HTTP method. Defaults to GET.
|
||||||
|
---@field url string http/https URL.
|
||||||
|
---@field headers? table<string, string>
|
||||||
|
---@field body? string
|
||||||
|
---@field timeout? number Timeout in seconds. Defaults to 15.
|
||||||
|
|
||||||
|
---@class RuntimeWsConnectOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field url string ws/wss URL.
|
||||||
|
---@field protocols? string[]
|
||||||
|
|
||||||
|
---@class RuntimeHostCallOptions
|
||||||
|
---@field id? string
|
||||||
|
---@field method string Host method name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostNotifyOptions
|
||||||
|
---@field method string Host notification name registered by Flutter.
|
||||||
|
---@field data? any
|
||||||
|
|
||||||
|
---@class RuntimeHostRespondOptions
|
||||||
|
---@field id string Host-to-Lua call id from host_call event.
|
||||||
|
---@field result? any
|
||||||
|
---@field error? string
|
||||||
|
|
||||||
---@class RuntimeImportApi
|
---@class RuntimeImportApi
|
||||||
---@field import fun(moduleName: string): table
|
---@field import fun(moduleName: string): table
|
||||||
---@field log fun(...: any)
|
---@field log fun(...: any)
|
||||||
|
---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http.
|
||||||
|
---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close.
|
||||||
|
---@field ws_send fun(id: string, message: string): boolean
|
||||||
|
---@field ws_close fun(id: string): boolean
|
||||||
|
---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result.
|
||||||
|
---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code.
|
||||||
|
---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event.
|
||||||
|
|
||||||
---@type RuntimeImportApi
|
---@type RuntimeImportApi
|
||||||
runtime = runtime
|
runtime = runtime
|
||||||
|
|||||||
Reference in New Issue
Block a user