Add bidirectional host bridge
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
## Unreleased
|
||||
|
||||
- Added TexturePacker frame, manual source-region, and nine-slice image rendering fields for image-capable nodes.
|
||||
- Added Runtime host bridge APIs for bidirectional Flutter host calls and Lua notifications.
|
||||
- Added Runtime Lua networking APIs for async HTTP/HTTPS requests and WS/WSS connections.
|
||||
- Fixed nine-slice image seams by overlapping destination slices and using inset source sampling during runtime rendering.
|
||||
- Fixed Runtime alpha inheritance so parent fade commands apply to the full child subtree.
|
||||
|
||||
@@ -92,6 +92,22 @@ Network results are delivered back to Lua through `on_event(event)`:
|
||||
- `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
|
||||
|
||||
Runtime commands request generic side effects owned by Dart/Flame.
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
---| 'network_ws_message'
|
||||
---| 'network_ws_error'
|
||||
---| 'network_ws_close'
|
||||
---| 'host_notify'
|
||||
---| 'host_call'
|
||||
---| 'host_call_result'
|
||||
|
||||
---@alias RuntimeScaleMode
|
||||
---| 'fit'
|
||||
@@ -598,6 +601,20 @@
|
||||
---@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
|
||||
---@field import fun(moduleName: string): table
|
||||
---@field log fun(...: any)
|
||||
@@ -605,6 +622,9 @@
|
||||
---@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
|
||||
runtime = runtime
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
---| 'network_ws_message'
|
||||
---| 'network_ws_error'
|
||||
---| 'network_ws_close'
|
||||
---| 'host_notify'
|
||||
---| 'host_call'
|
||||
---| 'host_call_result'
|
||||
|
||||
---@alias RuntimeScaleMode
|
||||
---| 'fit'
|
||||
@@ -598,6 +601,20 @@
|
||||
---@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
|
||||
---@field import fun(moduleName: string): table
|
||||
---@field log fun(...: any)
|
||||
@@ -605,6 +622,9 @@
|
||||
---@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
|
||||
runtime = runtime
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
---| 'network_ws_message'
|
||||
---| 'network_ws_error'
|
||||
---| 'network_ws_close'
|
||||
---| 'host_notify'
|
||||
---| 'host_call'
|
||||
---| 'host_call_result'
|
||||
|
||||
---@alias RuntimeScaleMode
|
||||
---| 'fit'
|
||||
@@ -598,6 +601,20 @@
|
||||
---@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
|
||||
---@field import fun(moduleName: string): table
|
||||
---@field log fun(...: any)
|
||||
@@ -605,6 +622,9 @@
|
||||
---@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
|
||||
runtime = runtime
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
---| 'network_ws_message'
|
||||
---| 'network_ws_error'
|
||||
---| 'network_ws_close'
|
||||
---| 'host_notify'
|
||||
---| 'host_call'
|
||||
---| 'host_call_result'
|
||||
|
||||
---@alias RuntimeScaleMode
|
||||
---| 'fit'
|
||||
@@ -598,6 +601,20 @@
|
||||
---@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
|
||||
---@field import fun(moduleName: string): table
|
||||
---@field log fun(...: any)
|
||||
@@ -605,6 +622,9 @@
|
||||
---@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
|
||||
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/runtime_locale.dart' show RuntimeLocaleResolver;
|
||||
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'
|
||||
show
|
||||
AssetGamePackageRepository,
|
||||
|
||||
@@ -137,4 +137,5 @@ enum RuntimeDiagnosticType {
|
||||
resourceLoadError,
|
||||
commandError,
|
||||
networkError,
|
||||
hostBridgeError,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../commands/command_executor.dart';
|
||||
import '../diagnostics/runtime_diagnostics.dart';
|
||||
import '../events/runtime_event_dispatcher.dart';
|
||||
import '../lifecycle/runtime_session.dart';
|
||||
import '../host/runtime_host_bridge.dart';
|
||||
import '../models/game_diff.dart';
|
||||
import '../models/runtime_event.dart';
|
||||
import '../network/runtime_network_manager.dart';
|
||||
@@ -41,6 +42,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
||||
this.audioMaxConcurrentLoads = 4,
|
||||
this.audioSfxPoolSize = 8,
|
||||
this.runtimeOptions = const RuntimeOptions(),
|
||||
this.hostBridge = const RuntimeHostBridge(),
|
||||
Locale? localeOverride,
|
||||
}) : _bootstrapScriptEngine = scriptEngine,
|
||||
_localeOverride = localeOverride,
|
||||
@@ -62,6 +64,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
||||
final int audioMaxConcurrentLoads;
|
||||
final int audioSfxPoolSize;
|
||||
final RuntimeOptions runtimeOptions;
|
||||
final RuntimeHostBridge hostBridge;
|
||||
final Locale? _localeOverride;
|
||||
|
||||
late final GameResourceManager _resources;
|
||||
@@ -69,6 +72,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
||||
late final RenderTreeController _renderTree;
|
||||
late final PositionComponent _viewportRoot;
|
||||
RuntimeNetworkManager? _network;
|
||||
RuntimeHostBridgeManager? _hostBridgeManager;
|
||||
RuntimeViewportConfig? _viewportConfig;
|
||||
late final CommandExecutor _commands;
|
||||
RuntimeSession? _session;
|
||||
@@ -83,6 +87,28 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
||||
|
||||
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() {
|
||||
if (!_runtimeInitialized) {
|
||||
return {'initialized': false};
|
||||
@@ -110,6 +136,12 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
||||
diagnostics: diagnostics,
|
||||
);
|
||||
_network = network;
|
||||
final hostBridgeManager = RuntimeHostBridgeManager(
|
||||
bridge: hostBridge,
|
||||
eventSink: _emitEvent,
|
||||
diagnostics: diagnostics,
|
||||
);
|
||||
_hostBridgeManager = hostBridgeManager;
|
||||
final activation =
|
||||
await PackageActivationController(
|
||||
repository: _packageRepository,
|
||||
@@ -119,7 +151,10 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
||||
resourceManagerFactory: _createResourceManager,
|
||||
audioManagerFactory: _createAudioManager,
|
||||
scriptEngineFactory: _scriptEngineFactory,
|
||||
scriptServices: RuntimeScriptServices(network: network),
|
||||
scriptServices: RuntimeScriptServices(
|
||||
network: network,
|
||||
hostBridge: hostBridgeManager,
|
||||
),
|
||||
store: StablePackageStore(runtimeOptions: runtimeOptions),
|
||||
assetFallback: AssetGamePackageRepository(
|
||||
runtimeOptions: runtimeOptions,
|
||||
@@ -168,6 +203,8 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
||||
_runtimeInitialized = true;
|
||||
_applyDiff(activation.initialDiff);
|
||||
} catch (error) {
|
||||
_hostBridgeManager?.dispose();
|
||||
_hostBridgeManager = null;
|
||||
_network?.dispose();
|
||||
_network = null;
|
||||
session.dispose();
|
||||
@@ -347,6 +384,8 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
||||
_events?.dispose();
|
||||
if (_runtimeInitialized) {
|
||||
_commands.dispose();
|
||||
_hostBridgeManager?.dispose();
|
||||
_hostBridgeManager = null;
|
||||
_network?.dispose();
|
||||
_network = null;
|
||||
_renderTree.clear();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flame/game.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../diagnostics/runtime_diagnostics.dart';
|
||||
import '../host/runtime_host_bridge.dart';
|
||||
import '../packages/game_package_repository.dart';
|
||||
import '../scripting/lua_dardo_script_engine.dart';
|
||||
import 'flame_lua_game.dart';
|
||||
@@ -14,6 +15,7 @@ class LuaGameWidget extends StatelessWidget {
|
||||
this.serverUrl,
|
||||
this.localeOverride,
|
||||
this.runtimeOptions = const RuntimeOptions(),
|
||||
this.hostBridge = const RuntimeHostBridge(),
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -22,6 +24,7 @@ class LuaGameWidget extends StatelessWidget {
|
||||
final Uri? serverUrl;
|
||||
final Locale? localeOverride;
|
||||
final RuntimeOptions runtimeOptions;
|
||||
final RuntimeHostBridge hostBridge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -42,6 +45,7 @@ class LuaGameWidget extends StatelessWidget {
|
||||
)),
|
||||
gameId: gameId,
|
||||
runtimeOptions: runtimeOptions,
|
||||
hostBridge: hostBridge,
|
||||
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';
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async' as async;
|
||||
import 'package:lua_dardo_plus/lua.dart';
|
||||
|
||||
import '../diagnostics/runtime_diagnostics.dart';
|
||||
import '../host/runtime_host_bridge.dart';
|
||||
import '../models/game_diff.dart';
|
||||
import '../models/runtime_event.dart';
|
||||
import '../network/runtime_network_manager.dart';
|
||||
@@ -19,6 +20,7 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
||||
late final Map<String, String> _moduleScripts;
|
||||
RuntimeScriptServices _services = const RuntimeScriptServices();
|
||||
int _networkRequestCounter = 0;
|
||||
int _hostCallCounter = 0;
|
||||
final Set<String> _loadingModules = {};
|
||||
|
||||
@override
|
||||
@@ -28,6 +30,7 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
||||
}) async {
|
||||
_services = services;
|
||||
_networkRequestCounter = 0;
|
||||
_hostCallCounter = 0;
|
||||
final script = await package.readText(package.manifest.entry);
|
||||
_moduleScripts = {};
|
||||
for (final entry in package.manifest.modules.entries) {
|
||||
@@ -135,6 +138,15 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
||||
_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');
|
||||
}
|
||||
|
||||
@@ -204,6 +216,56 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import '../host/runtime_host_bridge.dart';
|
||||
import '../network/runtime_network_manager.dart';
|
||||
|
||||
class RuntimeScriptServices {
|
||||
const RuntimeScriptServices({this.network});
|
||||
const RuntimeScriptServices({this.network, this.hostBridge});
|
||||
|
||||
final RuntimeNetworkManager? network;
|
||||
final RuntimeHostBridgeManager? hostBridge;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
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()
|
||||
..record(
|
||||
type: RuntimeDiagnosticType.commandError,
|
||||
@@ -29,6 +29,8 @@ void main() {
|
||||
expect(game.diagnosticsDumpText(), contains('command failed'));
|
||||
expect(game.diagnosticsDebugJson()['count'], 1);
|
||||
expect(game.resourcesDebugJson(), {'initialized': false});
|
||||
expect(game.notifyLua('host.ready'), isFalse);
|
||||
await expectLater(game.callLua('host.ready'), throwsA(isA<StateError>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
|
||||
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
|
||||
import 'package:flame_lua_runtime/runtime/packages/game_package_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/network/runtime_network_manager.dart';
|
||||
import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart';
|
||||
@@ -1021,6 +1022,57 @@ function on_event(event) return {} end
|
||||
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 {
|
||||
final package = await _createPackage(
|
||||
mainScript: '''
|
||||
@@ -1052,6 +1104,26 @@ 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(
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
---| 'network_ws_message'
|
||||
---| 'network_ws_error'
|
||||
---| 'network_ws_close'
|
||||
---| 'host_notify'
|
||||
---| 'host_call'
|
||||
---| 'host_call_result'
|
||||
|
||||
---@alias RuntimeScaleMode
|
||||
---| 'fit'
|
||||
@@ -598,6 +601,20 @@
|
||||
---@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
|
||||
---@field import fun(moduleName: string): table
|
||||
---@field log fun(...: any)
|
||||
@@ -605,6 +622,9 @@
|
||||
---@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
|
||||
runtime = runtime
|
||||
|
||||
Reference in New Issue
Block a user