Add bidirectional host bridge

This commit is contained in:
gem
2026-06-09 16:26:37 +08:00
parent 7b3c5cb0f5
commit 0d4fbd030c
17 changed files with 632 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -137,4 +137,5 @@ enum RuntimeDiagnosticType {
resourceLoadError,
commandError,
networkError,
hostBridgeError,
}

View File

@@ -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();

View File

@@ -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,
),
);

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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>()));
});
});
}

View File

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

View File

@@ -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(

View File

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