Initial flame_lua_runtime package

This commit is contained in:
gem
2026-06-07 22:53:58 +08:00
commit 733b2fb798
262 changed files with 28439 additions and 0 deletions

View File

@@ -0,0 +1,940 @@
import 'dart:async' as async;
import 'dart:io';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart';
import 'package:flame_lua_runtime/runtime/audio/runtime_audio_player.dart';
import 'package:flame_lua_runtime/runtime/commands/command_executor.dart';
import 'package:flame_lua_runtime/runtime/models/game_diff.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_command.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_node.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/protocol/runtime_protocol.dart';
import 'package:flame_lua_runtime/runtime/rendering/render_tree_controller.dart';
import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('CommandExecutor', () {
test('adds move_path sequence effect to target component', () {
final harness = _CommandHarness();
harness.createNode('piece');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.movePath,
target: 'piece',
payload: {
'path': [
{'x': 10, 'y': 20},
{'x': 30, 'y': 40},
],
'duration': 0.6,
'onComplete': 'done',
},
),
);
final component = harness.controller.componentById('piece')!;
expect(component.children.whereType<SequenceEffect>(), isNotEmpty);
});
test('adds generic transform effects to target component', () {
final harness = _CommandHarness();
harness.createNode('node');
for (final command in const [
RuntimeCommand(
type: RuntimeCommandType.moveTo,
target: 'node',
payload: {'x': 10, 'y': 20},
),
RuntimeCommand(
type: RuntimeCommandType.fadeTo,
target: 'node',
payload: {'alpha': 0.5},
),
RuntimeCommand(
type: RuntimeCommandType.scaleTo,
target: 'node',
payload: {'scale': 1.5},
),
RuntimeCommand(
type: RuntimeCommandType.rotateTo,
target: 'node',
payload: {'angle': 1.2},
),
]) {
harness.executor.execute(command);
}
final component = harness.controller.componentById('node')!;
expect(component.children.whereType<SequenceEffect>(), hasLength(4));
});
test(
'remove_node removes target and emits completion event immediately',
() {
final harness = _CommandHarness();
harness.createNode('node');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.removeNode,
target: 'node',
payload: {'onComplete': 'removed'},
),
);
expect(harness.controller.componentById('node'), isNull);
expect(harness.events.map((event) => event.toMap()), [
{
'type': RuntimeEventType.animationDone,
'target': 'node',
'handler': 'removed',
},
]);
},
);
test('ignores transform commands for missing targets', () {
final harness = _CommandHarness();
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.moveTo,
target: 'missing',
payload: {'x': 1, 'y': 2},
),
);
expect(harness.events, isEmpty);
});
test('copy_text writes text to the platform clipboard', () async {
final harness = _CommandHarness();
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
calls.add(call);
return null;
});
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.copyText,
payload: {'text': 'copy me'},
),
);
await Future<void>.delayed(Duration.zero);
expect(calls.single.method, 'Clipboard.setData');
expect(calls.single.arguments, {'text': 'copy me'});
});
test('toast creates temporary overlay and emits completion', () async {
final harness = _CommandHarness();
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.toast,
payload: {
'text': 'Hello toast',
'duration': 0.01,
'onComplete': 'toast_done',
},
),
);
expect(harness.controller.componentById('runtime_toast_1'), isNotNull);
final text = harness.controller.componentById('runtime_toast_1_text')!;
expect(text.node.text, 'Hello toast');
expect(text.node.parent, 'runtime_toast_1');
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(harness.controller.componentById('runtime_toast_1'), isNull);
expect(harness.controller.componentById('runtime_toast_1_text'), isNull);
expect(harness.events.map((event) => event.toMap()), [
{'type': RuntimeEventType.animationDone, 'handler': 'toast_done'},
]);
});
test('cancel_commands removes active toast overlay', () async {
final harness = _CommandHarness();
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.toast,
payload: {'text': 'Cancel me', 'duration': 10, 'id': 'toast_a'},
),
);
expect(harness.controller.componentById('runtime_toast_1'), isNotNull);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.cancelCommands,
payload: {'id': 'toast_a'},
),
);
await Future<void>.delayed(Duration.zero);
expect(harness.controller.componentById('runtime_toast_1'), isNull);
});
test('runs sequence commands in order', () async {
final harness = _CommandHarness();
harness
..createNode('first')
..createNode('second');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.sequence,
payload: {
'commands': [
{'type': RuntimeCommandType.removeNode, 'target': 'first'},
{'type': RuntimeCommandType.removeNode, 'target': 'second'},
],
'onComplete': 'sequence_done',
},
),
);
expect(harness.controller.componentById('first'), isNull);
expect(harness.controller.componentById('second'), isNotNull);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(harness.controller.componentById('second'), isNull);
expect(harness.events.map((event) => event.toMap()), [
{'type': RuntimeEventType.animationDone, 'handler': 'sequence_done'},
]);
});
test('runs parallel commands together', () async {
final harness = _CommandHarness();
harness
..createNode('first')
..createNode('second');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.parallel,
payload: {
'commands': [
{'type': RuntimeCommandType.removeNode, 'target': 'first'},
{'type': RuntimeCommandType.removeNode, 'target': 'second'},
],
'onComplete': 'parallel_done',
},
),
);
expect(harness.controller.componentById('first'), isNull);
expect(harness.controller.componentById('second'), isNull);
await Future<void>.delayed(Duration.zero);
expect(harness.events.map((event) => event.toMap()), [
{'type': RuntimeEventType.animationDone, 'handler': 'parallel_done'},
]);
});
test('plays sound and emits completion after playback ends', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(await _createAudioPackage('play_sound'));
final harness = _CommandHarness(audio: audio);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.playSound,
payload: {'asset': 'dice', 'volume': 0.4, 'onComplete': 'sound_done'},
),
);
await _waitFor(() => players.isNotEmpty);
expect(players.single.startedBytes, _audioBytes);
expect(players.single.volume, 0.4);
expect(harness.events, isEmpty);
players.single.complete();
await Future<void>.delayed(Duration.zero);
expect(harness.events.map((event) => event.toMap()), [
{'type': RuntimeEventType.animationDone, 'handler': 'sound_done'},
]);
audio.dispose();
});
test('cancels scoped sound when scope is removed', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(await _createAudioPackage('scoped_sound'));
final harness = _CommandHarness(audio: audio);
harness.createNode('panel');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.playSound,
payload: {
'asset': 'dice',
'scope': 'panel',
'onComplete': 'sound_done',
},
),
);
await _waitFor(() => players.isNotEmpty);
harness.controller.removeById('panel');
await Future<void>.delayed(Duration.zero);
expect(players.single.disposed, isTrue);
expect(harness.events, isEmpty);
audio.dispose();
});
test('starts bgm command and controls channel', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(await _createAudioPackage('bgm_command'));
final harness = _CommandHarness(audio: audio);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.playBgm,
payload: {
'asset': 'dice',
'channel': 'music',
'volume': 0.2,
'loop': true,
'onComplete': 'bgm_started',
},
),
);
await _waitFor(() => players.isNotEmpty);
await Future<void>.delayed(Duration.zero);
expect(players.single.startedBytes, _audioBytes);
expect(players.single.volume, 0.2);
expect(players.single.loop, isTrue);
expect(audio.hasBgm(channel: 'music'), isTrue);
expect(harness.events.map((event) => event.toMap()), [
{'type': RuntimeEventType.animationDone, 'handler': 'bgm_started'},
]);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.pauseBgm,
payload: {'channel': 'music'},
),
);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.resumeBgm,
payload: {'channel': 'music'},
),
);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.stopBgm,
payload: {'channel': 'music'},
),
);
await Future<void>.delayed(Duration.zero);
expect(players.single.paused, isTrue);
expect(players.single.resumed, isTrue);
expect(players.single.stopped, isTrue);
expect(audio.hasBgm(channel: 'music'), isFalse);
audio.dispose();
});
test('stops scoped bgm when scope is removed after start', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(await _createAudioPackage('scoped_bgm_command'));
final harness = _CommandHarness(audio: audio);
harness.createNode('panel');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.playBgm,
payload: {'asset': 'dice', 'channel': 'music', 'scope': 'panel'},
),
);
await _waitFor(() => players.isNotEmpty);
harness.controller.removeById('panel');
await Future<void>.delayed(Duration.zero);
expect(players.single.stopped, isTrue);
expect(audio.hasBgm(channel: 'music'), isFalse);
audio.dispose();
});
test('dispose stops owned bgm channels', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(await _createAudioPackage('dispose_bgm_command'));
final harness = _CommandHarness(audio: audio);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.playBgm,
payload: {'asset': 'dice', 'channel': 'music'},
),
);
await _waitFor(() => players.isNotEmpty);
harness.executor.dispose();
await Future<void>.delayed(Duration.zero);
expect(players.single.stopped, isTrue);
expect(audio.hasBgm(channel: 'music'), isFalse);
audio.dispose();
});
test(
'preloads and evicts resource groups with completion events',
() async {
final audio = RuntimeAudioManager();
await audio.mount(
await _createGroupedAudioPackage('resource_commands'),
);
final harness = _CommandHarness(audio: audio);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.preloadResources,
payload: {'group': 'scene', 'onComplete': 'resources_ready'},
),
);
await _waitFor(() => harness.events.isNotEmpty);
expect(audio.audioState('dice'), GameResourceState.ready);
expect(harness.events.single.handler, 'resources_ready');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.evictResources,
payload: {'group': 'scene', 'onComplete': 'resources_evicted'},
),
);
await _waitFor(() => harness.events.length == 2);
expect(audio.audioState('dice'), GameResourceState.idle);
expect(harness.events.last.handler, 'resources_evicted');
audio.dispose();
},
);
test('runs delay completion asynchronously', () async {
final harness = _CommandHarness();
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.delay,
payload: {'duration': 0, 'onComplete': 'delay_done'},
),
);
expect(harness.events, isEmpty);
await Future<void>.delayed(Duration.zero);
expect(harness.events.map((event) => event.toMap()), [
{'type': RuntimeEventType.animationDone, 'handler': 'delay_done'},
]);
});
test('drops delayed completion when scope node was removed', () async {
final harness = _CommandHarness();
harness.createNode('panel');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.delay,
payload: {'duration': 0, 'onComplete': 'late_done', 'scope': 'panel'},
),
);
harness.controller.removeById('panel');
await Future<void>.delayed(Duration.zero);
expect(harness.events, isEmpty);
});
test('scope removal cancels inherited pending sequence commands', () async {
final harness = _CommandHarness();
harness
..createNode('panel')
..createNode('second');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.sequence,
payload: {
'scope': 'panel',
'commands': [
{'type': RuntimeCommandType.delay, 'duration': 0.02},
{'type': RuntimeCommandType.removeNode, 'target': 'second'},
],
'onComplete': 'sequence_done',
},
),
);
harness.controller.removeById('panel');
await Future<void>.delayed(const Duration(milliseconds: 40));
expect(harness.controller.componentById('second'), isNotNull);
expect(harness.events, isEmpty);
});
test('dispose cancels pending delayed completion', () async {
final harness = _CommandHarness();
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.delay,
payload: {'duration': 0, 'onComplete': 'late_done'},
),
);
harness.executor.dispose();
await Future<void>.delayed(Duration.zero);
expect(harness.events, isEmpty);
});
test('cancel_commands cancels pending command by id', () async {
final harness = _CommandHarness();
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.delay,
payload: {
'id': 'intro_delay',
'duration': 0.02,
'onComplete': 'late_done',
},
),
);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.cancelCommands,
payload: {'id': 'intro_delay'},
),
);
await Future<void>.delayed(const Duration(milliseconds: 40));
expect(harness.events, isEmpty);
});
test('cancel_commands cancels inherited command group', () async {
final harness = _CommandHarness();
harness.createNode('node');
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.sequence,
payload: {
'group': 'intro',
'commands': [
{'type': RuntimeCommandType.delay, 'duration': 0.02},
{'type': RuntimeCommandType.removeNode, 'target': 'node'},
],
'onComplete': 'sequence_done',
},
),
);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.cancelCommands,
payload: {'group': 'intro'},
),
);
await Future<void>.delayed(const Duration(milliseconds: 40));
expect(harness.controller.componentById('node'), isNotNull);
expect(harness.events, isEmpty);
});
test('cancel_commands cancels scoped sound by command group', () async {
final players = <_FakeRuntimeAudioPlayer>[];
final audio = RuntimeAudioManager(
playerFactory: () {
final player = _FakeRuntimeAudioPlayer();
players.add(player);
return player;
},
);
await audio.mount(await _createAudioPackage('cancel_sound_group'));
final harness = _CommandHarness(audio: audio);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.playSound,
payload: {
'asset': 'dice',
'group': 'sfx_intro',
'onComplete': 'sound_done',
},
),
);
await _waitFor(() => players.isNotEmpty);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.cancelCommands,
payload: {'group': 'sfx_intro'},
),
);
await Future<void>.delayed(Duration.zero);
expect(players.single.disposed, isTrue);
expect(harness.events, isEmpty);
audio.dispose();
});
test(
'resource commands use commandGroup without confusing resource group',
() async {
final audio = RuntimeAudioManager();
await audio.mount(
await _createGroupedAudioPackage('resource_group_safe'),
);
final harness = _CommandHarness(audio: audio);
harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.preloadResources,
payload: {
'group': 'scene',
'commandGroup': 'loading',
'onComplete': 'resources_ready',
},
),
);
await _waitFor(() => harness.events.isNotEmpty);
expect(audio.audioState('dice'), GameResourceState.ready);
expect(harness.events.single.handler, 'resources_ready');
audio.dispose();
},
);
test('validates composite commands before executing children', () {
final harness = _CommandHarness();
harness
..createNode('first')
..createNode('second');
expect(
() => harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.sequence,
payload: {
'commands': [
{'type': RuntimeCommandType.removeNode, 'target': 'first'},
{
'type': RuntimeCommandType.fadeTo,
'target': 'second',
'alpha': 2,
},
],
},
),
),
throwsFormatException,
);
expect(harness.controller.componentById('first'), isNotNull);
expect(harness.controller.componentById('second'), isNotNull);
});
test('validates required command payloads', () {
final harness = _CommandHarness();
harness.createNode('node');
expect(
() => harness.executor.execute(
const RuntimeCommand(type: RuntimeCommandType.moveTo, target: 'node'),
),
throwsFormatException,
);
expect(
() => harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.fadeTo,
target: 'node',
payload: {'alpha': 2},
),
),
throwsFormatException,
);
expect(
() => harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.scaleTo,
target: 'node',
payload: {'scale': 'big'},
),
),
throwsFormatException,
);
expect(
() => harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.rotateTo,
target: 'node',
payload: {'angle': 0, 'duration': -1},
),
),
throwsFormatException,
);
expect(
() => harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.playSpineAnimation,
target: 'node',
),
),
throwsFormatException,
);
expect(
() => harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.playSpineAnimation,
target: 'node',
payload: {'animation': 'walk', 'track': -1},
),
),
throwsFormatException,
);
expect(
() => harness.executor.execute(
const RuntimeCommand(
type: RuntimeCommandType.moveTo,
target: 'node',
payload: {'x': 1, 'y': 2, 'duraton': 0.2},
),
),
throwsFormatException,
);
});
});
}
Future<void> _waitFor(bool Function() predicate) async {
for (var i = 0; i < 20; i++) {
if (predicate()) {
return;
}
await Future<void>.delayed(Duration.zero);
}
throw StateError('Timed out waiting for test condition');
}
class _CommandHarness {
_CommandHarness({
RuntimeAudioManager? audio,
GameResourceManager? resources,
}) {
final activeResources = resources ?? GameResourceManager();
controller = RenderTreeController(
root: Component(),
resources: activeResources,
eventSink: events.add,
);
executor = CommandExecutor(
renderTree: controller,
eventSink: events.add,
audio: audio,
resources: activeResources,
);
controller.onScopeRemoved = executor.cancelScope;
}
final events = <RuntimeEvent>[];
late final RenderTreeController controller;
late final CommandExecutor executor;
void createNode(String id) {
controller.apply(
NodeDiff(
creates: [
RuntimeNode(
id: id,
type: RuntimeNodeType.rect,
width: 100,
height: 100,
),
],
),
);
}
}
const _audioBytes = [9, 8, 7, 6];
Future<GamePackage> _createAudioPackage(String name) async {
final root = await Directory.systemTemp.createTemp('command_audio_${name}_');
Directory('${root.path}/assets').createSync(recursive: true);
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
addTearDown(() {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
});
return GamePackage.file(
rootPath: root.path,
manifest: const GamePackageManifest(
gameId: 'test',
name: 'Test',
version: '0.1.0',
runtimeApiVersion: 1,
entry: 'scripts/main.lua',
assetsBase: 'assets',
resources: {
'dice': GameResource(
type: GameResourceType.audio,
path: 'assets/dice.wav',
preload: GameResourcePreload.lazy,
),
},
),
);
}
Future<GamePackage> _createGroupedAudioPackage(String name) async {
final root = await Directory.systemTemp.createTemp('command_audio_${name}_');
Directory('${root.path}/assets').createSync(recursive: true);
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
addTearDown(() {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
});
return GamePackage.file(
rootPath: root.path,
manifest: const GamePackageManifest(
gameId: 'test',
name: 'Test',
version: '0.1.0',
runtimeApiVersion: 1,
entry: 'scripts/main.lua',
assetsBase: 'assets',
resources: {
'dice': GameResource(
type: GameResourceType.audio,
path: 'assets/dice.wav',
preload: GameResourcePreload.lazy,
group: 'scene',
),
},
),
);
}
class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer {
final _done = async.Completer<void>();
List<int>? startedBytes;
double? volume;
var loop = false;
var paused = false;
var resumed = false;
var stopped = false;
var disposed = false;
@override
Future<void> get done => _done.future;
@override
Future<void> start(
Uint8List bytes, {
required double volume,
bool loop = false,
}) async {
startedBytes = bytes.toList(growable: false);
this.volume = volume;
this.loop = loop;
}
void complete() {
if (!_done.isCompleted) {
_done.complete();
}
}
@override
Future<void> pause() async {
paused = true;
}
@override
Future<void> resume() async {
resumed = true;
}
@override
Future<void> stop() async {
stopped = true;
complete();
}
@override
Future<void> dispose() async {
disposed = true;
complete();
}
}