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,125 @@
part of 'command_executor.dart';
extension _CommandExecutorAudio on CommandExecutor {
Future<_CommandResult> _playSound(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) async {
final audio = _audio;
if (audio == null) {
_emitCommandCompletion(command, context);
return _CommandResult.completed;
}
final scope = _scopeFor(command, context, defaultTarget: false);
final scopeEpoch = _scopeEpochFor(scope, context);
if (!_scopeIsAlive(scope)) {
return _CommandResult.cancelled;
}
final task = _registerTask(scope, handle);
final playback = await audio.play(
_requiredAudioResource(command),
volume: _optionalVolume(command),
);
if (_disposed ||
task.isCancelled ||
(handle?.isCancelled ?? false) ||
!_scopeIsAlive(scope)) {
await playback?.cancel();
task.complete(_CommandResult.cancelled);
return task.future;
}
if (playback == null) {
task.complete(_CommandResult.cancelled);
return task.future;
}
task.addCancelCallback(() {
async.unawaited(playback.cancel());
});
await playback.done;
if (_disposed ||
task.isCancelled ||
(handle?.isCancelled ?? false) ||
!_scopeIsAlive(scope)) {
task.complete(_CommandResult.cancelled);
return task.future;
}
_emitCommandCompletion(
command,
context.copyWith(scope: scope, scopeEpoch: scopeEpoch),
);
task.complete(_CommandResult.completed);
return task.future;
}
Future<_CommandResult> _playBgm(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) async {
final audio = _audio;
if (audio == null) {
_emitCommandCompletion(command, context);
return _CommandResult.completed;
}
final scope = _scopeFor(command, context, defaultTarget: false);
final scopeEpoch = _scopeEpochFor(scope, context);
if (!_scopeIsAlive(scope)) {
return _CommandResult.cancelled;
}
final channel = _audioChannel(command);
final playback = await audio.playBgm(
_requiredAudioResource(command),
channel: channel,
volume: _optionalVolume(command),
loop: _optionalBool(command.payload['loop'], 'play_bgm.loop') ?? true,
);
if (_disposed || (handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) {
await audio.stopBgm(channel: channel);
return _CommandResult.cancelled;
}
if (playback == null) {
return _CommandResult.cancelled;
}
_registerBgmChannel(channel: channel, scope: scope);
handle?.addCancelCallback(() {
_unregisterBgmChannel(channel);
async.unawaited(audio.stopBgm(channel: channel));
});
_emitCommandCompletion(
command,
context.copyWith(scope: scope, scopeEpoch: scopeEpoch),
);
return _CommandResult.completed;
}
Future<_CommandResult> _controlBgm(
RuntimeCommand command,
_CommandContext context,
_BgmControl control,
) async {
final audio = _audio;
final channel = _audioChannel(command);
if (audio != null) {
switch (control) {
case _BgmControl.pause:
await audio.pauseBgm(channel: channel);
case _BgmControl.resume:
await audio.resumeBgm(channel: channel);
case _BgmControl.stop:
await audio.stopBgm(channel: channel);
_unregisterBgmChannel(channel);
}
}
_emitCommandCompletion(command, context);
return _CommandResult.completed;
}
}

View File

@@ -0,0 +1,52 @@
part of 'command_executor.dart';
extension _CommandExecutorComposite on CommandExecutor {
Future<_CommandResult> _sequence(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) async {
final commands = _commandsFromPayload(command);
final childContext = _childContextFor(command, context);
for (final child in commands) {
if (_disposed ||
(handle?.isCancelled ?? false) ||
!_scopeIsAlive(childContext.scope)) {
return _CommandResult.cancelled;
}
final result = await _execute(child, childContext);
if (result == _CommandResult.cancelled) {
return _CommandResult.cancelled;
}
}
if (_disposed ||
(handle?.isCancelled ?? false) ||
!_scopeIsAlive(childContext.scope)) {
return _CommandResult.cancelled;
}
_emitCommandCompletion(command, childContext);
return _CommandResult.completed;
}
Future<_CommandResult> _parallel(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) async {
final commands = _commandsFromPayload(command);
final childContext = _childContextFor(command, context);
final results = await Future.wait(
commands.map((child) => _execute(child, childContext)),
);
if (_disposed ||
(handle?.isCancelled ?? false) ||
!_scopeIsAlive(childContext.scope) ||
results.contains(_CommandResult.cancelled)) {
return _CommandResult.cancelled;
}
_emitCommandCompletion(command, childContext);
return _CommandResult.completed;
}
}

View File

@@ -0,0 +1,230 @@
import 'dart:async' as async;
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/services.dart';
import '../audio/runtime_audio_manager.dart';
import '../lifecycle/runtime_task_registry.dart';
import '../models/game_diff.dart';
import '../models/runtime_command.dart';
import '../models/runtime_event.dart';
import '../models/runtime_node.dart';
import '../protocol/runtime_protocol.dart';
import '../rendering/render_tree_controller.dart';
import '../resources/game_resource_manager.dart';
import '../rendering/runtime_component.dart';
import 'runtime_command_registry.dart';
// These part files keep CommandExecutor as a single private implementation
// unit while grouping command handlers by responsibility. They are not a
// plugin system and should not expose additional public API.
part 'command_target_effects.dart';
part 'command_composite.dart';
part 'command_audio.dart';
part 'command_resources.dart';
part 'command_lifecycle_context.dart';
part 'command_toast.dart';
part 'command_validation.dart';
part 'command_support.dart';
class CommandExecutor {
CommandExecutor({
required RenderTreeController renderTree,
required void Function(RuntimeEvent event) eventSink,
RuntimeAudioManager? audio,
GameResourceManager? resources,
Vector2? overlaySize,
}) : _renderTree = renderTree,
_eventSink = eventSink,
_audio = audio,
_resources = resources,
_overlaySize = overlaySize ?? Vector2(720, 720);
final RenderTreeController _renderTree;
final void Function(RuntimeEvent event) _eventSink;
final RuntimeAudioManager? _audio;
final GameResourceManager? _resources;
final Vector2 _overlaySize;
late final RuntimeTaskRegistry<_CommandResult> _tasks =
RuntimeTaskRegistry<_CommandResult>(
cancelledValue: _CommandResult.cancelled,
);
final RuntimeCommandRegistry _commandRegistry = RuntimeCommandRegistry();
final Set<String> _ownedBgmChannels = {};
final Map<String, Set<String>> _bgmChannelsByScope = {};
final Map<String, String> _bgmScopeByChannel = {};
int _toastSerial = 0;
bool _disposed = false;
void dispose() {
_disposed = true;
_commandRegistry.dispose();
_tasks.dispose();
final channels = _ownedBgmChannels.toList(growable: false);
_ownedBgmChannels.clear();
_bgmChannelsByScope.clear();
_bgmScopeByChannel.clear();
for (final channel in channels) {
async.unawaited(_audio?.stopBgm(channel: channel));
}
}
void cancelScope(String scope) {
_commandRegistry.cancelScope(scope);
_tasks.cancelScope(scope);
final channels = _bgmChannelsByScope.remove(scope) ?? const <String>{};
for (final channel in channels) {
_bgmScopeByChannel.remove(channel);
_ownedBgmChannels.remove(channel);
async.unawaited(_audio?.stopBgm(channel: channel));
}
}
void executeAll(List<RuntimeCommand> commands) {
for (final command in commands) {
execute(command);
}
}
void execute(RuntimeCommand command) {
if (_disposed) {
return;
}
_validate(command);
async.unawaited(_execute(command, const _CommandContext()));
}
Future<_CommandResult> _execute(
RuntimeCommand command,
_CommandContext context,
) async {
if (_disposed) {
return _CommandResult.cancelled;
}
final commandContext = _commandContextFor(command, context);
final handle = _createCommandHandle(command, commandContext);
try {
if (handle?.isCancelled ?? false) {
return _CommandResult.cancelled;
}
final result = await _executeCore(command, commandContext, handle);
if (handle?.isCancelled ?? false) {
return _CommandResult.cancelled;
}
return result;
} finally {
handle?.complete();
}
}
Future<_CommandResult> _executeCore(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) async {
if (_disposed || (handle?.isCancelled ?? false)) {
return _CommandResult.cancelled;
}
switch (command.type) {
case RuntimeCommandType.movePath:
return _movePath(command, context, handle);
case RuntimeCommandType.moveTo:
return _targetEffect(command, context, handle, (component, duration) {
return MoveToEffect(
_requiredVector(command),
EffectController(duration: duration),
);
});
case RuntimeCommandType.fadeTo:
return _targetEffect(command, context, handle, (component, duration) {
final alpha = _requiredNormalizedDouble(
command.payload['alpha'],
'fade_to.alpha',
);
final start = component.renderAlpha;
return FunctionEffect((progress, _) {
final t = _readDouble(progress) ?? 1;
component.setRuntimeAlpha(start + (alpha - start) * t);
}, EffectController(duration: duration));
});
case RuntimeCommandType.scaleTo:
return _targetEffect(command, context, handle, (component, duration) {
final scale = _requiredDouble(
command.payload['scale'],
'scale_to.scale',
);
return ScaleEffect.to(
Vector2.all(scale),
EffectController(duration: duration),
);
});
case RuntimeCommandType.rotateTo:
return _targetEffect(command, context, handle, (component, duration) {
final angle = _requiredDouble(
command.payload['angle'],
'rotate_to.angle',
);
return RotateEffect.to(angle, EffectController(duration: duration));
});
case RuntimeCommandType.removeNode:
return _removeNode(command, context);
case RuntimeCommandType.sequence:
return _sequence(command, context, handle);
case RuntimeCommandType.parallel:
return _parallel(command, context, handle);
case RuntimeCommandType.delay:
return _delay(command, context, handle);
case RuntimeCommandType.toast:
return _toast(command, context, handle);
case RuntimeCommandType.playSound:
return _playSound(command, context, handle);
case RuntimeCommandType.playBgm:
return _playBgm(command, context, handle);
case RuntimeCommandType.pauseBgm:
return _controlBgm(command, context, _BgmControl.pause);
case RuntimeCommandType.resumeBgm:
return _controlBgm(command, context, _BgmControl.resume);
case RuntimeCommandType.stopBgm:
return _controlBgm(command, context, _BgmControl.stop);
case RuntimeCommandType.preloadResources:
return _preloadResources(command, context, handle);
case RuntimeCommandType.evictResources:
return _evictResources(command, context, handle);
case RuntimeCommandType.cancelCommands:
return _cancelCommands(command, context);
case RuntimeCommandType.playSpineAnimation:
return _playSpineAnimation(command, context);
case RuntimeCommandType.copyText:
await Clipboard.setData(
ClipboardData(text: _requiredText(command, 'copy_text.text')),
);
_emitCommandCompletion(command, context);
return _CommandResult.completed;
default:
throw UnsupportedError('Unsupported runtime command: ${command.type}');
}
}
}
class _CommandContext {
const _CommandContext({this.scope, this.scopeEpoch, this.group});
final String? scope;
final int? scopeEpoch;
final String? group;
_CommandContext copyWith({String? scope, int? scopeEpoch, String? group}) {
return _CommandContext(
scope: scope ?? this.scope,
scopeEpoch: scopeEpoch ?? this.scopeEpoch,
group: group ?? this.group,
);
}
}
enum _CommandResult { completed, cancelled }
enum _BgmControl { pause, resume, stop }

View File

@@ -0,0 +1,154 @@
part of 'command_executor.dart';
extension _CommandExecutorLifecycle on CommandExecutor {
Future<_CommandResult> _delay(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) {
final scope = _scopeFor(command, context, defaultTarget: false);
final scopeEpoch = _scopeEpochFor(scope, context);
final task = _registerTask(scope, handle);
_schedule(_duration(command, defaultValue: 0), task, () {
if ((handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) {
task.cancel();
return;
}
_emitCommandCompletion(
command,
context.copyWith(scope: scope, scopeEpoch: scopeEpoch),
);
task.complete(_CommandResult.completed);
});
return task.future;
}
void _schedule(
double seconds,
RuntimeTask<_CommandResult> task,
void Function() callback,
) {
void guardedCallback() {
if (_disposed || task.isCancelled) {
return;
}
callback();
}
if (seconds <= 0) {
async.scheduleMicrotask(guardedCallback);
return;
}
late final async.Timer timer;
timer = async.Timer(Duration(milliseconds: (seconds * 1000).round()), () {
task.removeTimer(timer);
guardedCallback();
});
task.addTimer(timer);
}
RuntimeTask<_CommandResult> _registerTask(
String? scope,
RuntimeCommandHandle? handle,
) {
final task = _tasks.create(scope: scope);
handle?.addCancelCallback(task.cancel);
return task;
}
_CommandContext _commandContextFor(
RuntimeCommand command,
_CommandContext context,
) {
return context.copyWith(group: _commandGroupFor(command, context));
}
RuntimeCommandHandle? _createCommandHandle(
RuntimeCommand command,
_CommandContext context,
) {
if (command.type == RuntimeCommandType.cancelCommands) {
return null;
}
final id = _optionalString(command.payload['id'], 'id');
final group = context.group;
final scope = _completionScopeFor(command, context);
if (id == null && group == null && scope == null) {
return null;
}
return _commandRegistry.create(id: id, group: group, scope: scope);
}
_CommandContext _childContextFor(
RuntimeCommand command,
_CommandContext context,
) {
final scope = _scopeFor(command, context, defaultTarget: false);
return context.copyWith(
scope: scope,
scopeEpoch: _scopeEpochFor(scope, context),
group: _commandGroupFor(command, context),
);
}
String? _commandGroupFor(RuntimeCommand command, _CommandContext context) {
final commandGroup = _optionalString(
command.payload['commandGroup'],
'commandGroup',
);
if (commandGroup != null) {
return commandGroup;
}
if (_usesGroupAsCommandGroup(command.type)) {
final legacyGroup = _optionalString(command.payload['group'], 'group');
if (legacyGroup != null) {
return legacyGroup;
}
}
return context.group;
}
bool _usesGroupAsCommandGroup(String commandType) {
return commandType != RuntimeCommandType.preloadResources &&
commandType != RuntimeCommandType.evictResources &&
commandType != RuntimeCommandType.cancelCommands;
}
String? _scopeFor(
RuntimeCommand command,
_CommandContext context, {
required bool defaultTarget,
}) {
final explicit = _optionalString(command.payload['scope'], 'scope');
if (explicit != null) {
return explicit;
}
if (context.scope != null) {
return context.scope;
}
if (defaultTarget) {
return command.target;
}
return null;
}
String? _completionScopeFor(RuntimeCommand command, _CommandContext context) {
final explicit = _optionalString(command.payload['scope'], 'scope');
return explicit ?? context.scope;
}
int? _scopeEpochFor(String? scope, _CommandContext context) {
if (scope == null) {
return null;
}
if (scope == context.scope && context.scopeEpoch != null) {
return context.scopeEpoch;
}
return _renderTree.epochOf(scope);
}
bool _scopeIsAlive(String? scope) {
return scope == null || _renderTree.contains(scope);
}
}

View File

@@ -0,0 +1,83 @@
part of 'command_executor.dart';
extension _CommandExecutorResources on CommandExecutor {
Future<_CommandResult> _preloadResources(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) async {
final group = _requiredResourceGroup(command);
final failOnError =
_optionalBool(
command.payload['failOnError'],
'preload_resources.failOnError',
) ??
false;
final resources = _resources;
final audio = _audio;
if (resources != null && resources.hasPackage) {
await resources.preloadGroup(group, failOnError: failOnError);
}
if (audio != null && audio.hasPackage) {
await audio.preloadGroup(group, failOnError: failOnError);
}
if (handle?.isCancelled ?? false) {
return _CommandResult.cancelled;
}
_emitCommandCompletion(command, context);
return _CommandResult.completed;
}
Future<_CommandResult> _evictResources(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) async {
final group = _requiredResourceGroup(command);
final resources = _resources;
final audio = _audio;
if (resources != null && resources.hasPackage) {
resources.evictGroup(group);
}
if (audio != null && audio.hasPackage) {
audio.evictGroup(group);
}
if (handle?.isCancelled ?? false) {
return _CommandResult.cancelled;
}
_emitCommandCompletion(command, context);
return _CommandResult.completed;
}
Future<_CommandResult> _cancelCommands(
RuntimeCommand command,
_CommandContext context,
) async {
final id = _optionalString(command.payload['id'], 'cancel_commands.id');
final group = _optionalString(
command.payload['group'],
'cancel_commands.group',
);
final scope = _optionalString(
command.payload['scope'],
'cancel_commands.scope',
);
if (id == null && group == null && scope == null) {
throw const FormatException(
'cancel_commands requires id, group or scope',
);
}
if (id != null) {
_commandRegistry.cancelId(id);
}
if (group != null) {
_commandRegistry.cancelGroup(group);
}
if (scope != null) {
_commandRegistry.cancelScope(scope);
_tasks.cancelScope(scope);
}
_emitCommandCompletion(command, context);
return _CommandResult.completed;
}
}

View File

@@ -0,0 +1,248 @@
part of 'command_executor.dart';
extension _CommandExecutorSupport on CommandExecutor {
void _appendCompletionEffect(
List<Effect> effects,
RuntimeCommand command,
String target,
int targetEpoch,
RuntimeTask<_CommandResult> task,
String? scope,
int? scopeEpoch,
) {
effects.add(
FunctionEffect((_, __) {
if (!_scopeIsAlive(scope) ||
!_renderTree.isNodeEpochAlive(target, targetEpoch)) {
task.cancel();
return;
}
_emitCompletion(command, target, scope, targetEpoch, scopeEpoch);
task.complete(_CommandResult.completed);
}, EffectController(duration: 0.01)),
);
}
void _emitCompletion(
RuntimeCommand command,
String target,
String? scope, [
int? targetEpoch,
int? scopeEpoch,
]) {
final onComplete = _optionalString(
command.payload['onComplete'],
'onComplete',
);
if (onComplete == null) {
return;
}
_emitEventIfScopeAlive(
RuntimeEvent(
type: RuntimeEventType.animationDone,
target: target,
handler: onComplete,
),
scope,
targetEpoch: targetEpoch,
scopeEpoch: scopeEpoch,
);
}
void _emitCommandCompletion(RuntimeCommand command, _CommandContext context) {
final onComplete = _optionalString(
command.payload['onComplete'],
'onComplete',
);
if (onComplete == null) {
return;
}
_emitEventIfScopeAlive(
RuntimeEvent(
type: RuntimeEventType.animationDone,
target: command.target,
handler: onComplete,
),
_completionScopeFor(command, context),
scopeEpoch: context.scopeEpoch,
);
}
void _emitEventIfScopeAlive(
RuntimeEvent event,
String? scope, {
int? targetEpoch,
int? scopeEpoch,
}) {
if (!_scopeIsAlive(scope) || _disposed) {
return;
}
_eventSink(
event.withLifecycle(
scope: scope,
targetEpoch: targetEpoch,
scopeEpoch: scopeEpoch,
),
);
}
Vector2 _requiredVector(RuntimeCommand command) {
final x = _readDouble(command.payload['x']);
final y = _readDouble(command.payload['y']);
if (x == null || y == null) {
throw FormatException('${command.type}.x/y are required numbers');
}
return Vector2(x, y);
}
String _requiredTarget(RuntimeCommand command) {
final target = command.target;
if (target == null || target.isEmpty) {
throw FormatException('${command.type}.target is required');
}
return target;
}
String _requiredText(RuntimeCommand command, String field) {
return _optionalString(command.payload['text'], field)!;
}
double _duration(RuntimeCommand command, {required double defaultValue}) {
final duration = _readDouble(command.payload['duration']) ?? defaultValue;
if (duration < 0) {
throw FormatException('${command.type}.duration must be >= 0');
}
return duration;
}
double _requiredDouble(Object? value, String field) {
final result = _readDouble(value);
if (result == null) {
throw FormatException('$field must be a number');
}
return result;
}
void _registerBgmChannel({required String channel, required String? scope}) {
_unregisterBgmChannel(channel);
_ownedBgmChannels.add(channel);
if (scope == null) {
return;
}
_bgmScopeByChannel[channel] = scope;
_bgmChannelsByScope.putIfAbsent(scope, () => {}).add(channel);
}
void _unregisterBgmChannel(String channel) {
_ownedBgmChannels.remove(channel);
final oldScope = _bgmScopeByChannel.remove(channel);
if (oldScope == null) {
return;
}
final channels = _bgmChannelsByScope[oldScope];
channels?.remove(channel);
if (channels != null && channels.isEmpty) {
_bgmChannelsByScope.remove(oldScope);
}
}
String _audioChannel(RuntimeCommand command) {
return _optionalString(command.payload['channel'], 'channel') ??
RuntimeAudioChannel.defaultBgm;
}
String _requiredResourceGroup(RuntimeCommand command) {
return _optionalString(command.payload['group'], '${command.type}.group')!;
}
void _validateCancelCommands(RuntimeCommand command) {
final id = _optionalString(command.payload['id'], 'cancel_commands.id');
final group = _optionalString(
command.payload['group'],
'cancel_commands.group',
);
final scope = _optionalString(
command.payload['scope'],
'cancel_commands.scope',
);
if (id == null && group == null && scope == null) {
throw const FormatException(
'cancel_commands requires id, group or scope',
);
}
}
String _requiredAudioResource(RuntimeCommand command) {
final asset = command.payload['asset'] ?? command.payload['name'];
return _optionalString(asset, 'play_sound.asset/name')!;
}
String _requiredSpineAnimation(RuntimeCommand command) {
final value = _optionalString(
command.payload['animation'],
'play_spine_animation.animation',
);
if (value == null) {
throw const FormatException(
'play_spine_animation.animation must be a non-empty string',
);
}
return value;
}
bool? _optionalBool(Object? value, String field) {
if (value == null) {
return null;
}
if (value is bool) {
return value;
}
throw FormatException('$field must be a boolean');
}
double _optionalVolume(RuntimeCommand command) {
final value = command.payload['volume'];
if (value == null) {
return 1;
}
return _requiredNormalizedDouble(value, 'play_sound.volume');
}
double _requiredNormalizedDouble(Object? value, String field) {
final result = _requiredDouble(value, field);
if (result < 0 || result > 1) {
throw FormatException('$field must be between 0 and 1');
}
return result;
}
String? _optionalString(Object? value, String field) {
if (value == null) {
return null;
}
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('$field must be a non-empty string');
}
int? _optionalInt(Object? value, String field) {
if (value == null) {
return null;
}
if (value is num) {
return value.toInt();
}
throw FormatException('$field must be an integer');
}
double? _readDouble(Object? value) {
if (value == null) {
return null;
}
if (value is num) {
return value.toDouble();
}
return null;
}
}

View File

@@ -0,0 +1,143 @@
part of 'command_executor.dart';
extension _CommandExecutorTargetEffects on CommandExecutor {
Future<_CommandResult> _movePath(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) {
final target = _requiredTarget(command);
final component = _renderTree.componentById(target);
if (component == null) {
return Future.value(_CommandResult.cancelled);
}
final scope = _scopeFor(command, context, defaultTarget: true);
final scopeEpoch = _scopeEpochFor(scope, context);
final targetEpoch = _renderTree.epochOf(target);
final task = _registerTask(scope, handle);
final pathValue = command.payload['path'] as List;
final duration = _duration(command, defaultValue: 0.4);
final perStepDuration = duration / pathValue.length;
final effects = <Effect>[];
for (final point in pathValue) {
final map = point as Map;
final x = _readDouble(map['x'])!;
final y = _readDouble(map['y'])!;
effects.add(
MoveToEffect(
Vector2(x, y),
EffectController(duration: perStepDuration),
),
);
}
_appendCompletionEffect(
effects,
command,
target,
targetEpoch,
task,
scope,
scopeEpoch,
);
final effect = SequenceEffect(effects);
handle?.addCancelCallback(effect.removeFromParent);
component.add(effect);
return task.future;
}
Future<_CommandResult> _targetEffect(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
Effect Function(RuntimeComponent component, double duration) factory,
) {
final target = _requiredTarget(command);
final component = _renderTree.componentById(target);
if (component == null) {
return Future.value(_CommandResult.cancelled);
}
final scope = _scopeFor(command, context, defaultTarget: true);
final scopeEpoch = _scopeEpochFor(scope, context);
final targetEpoch = _renderTree.epochOf(target);
final task = _registerTask(scope, handle);
final effects = <Effect>[
factory(component, _duration(command, defaultValue: 0.2)),
];
_appendCompletionEffect(
effects,
command,
target,
targetEpoch,
task,
scope,
scopeEpoch,
);
final effect = SequenceEffect(effects);
handle?.addCancelCallback(effect.removeFromParent);
component.add(effect);
return task.future;
}
Future<_CommandResult> _playSpineAnimation(
RuntimeCommand command,
_CommandContext context,
) {
final target = _requiredTarget(command);
final component = _renderTree.componentById(target);
if (component == null) {
return Future.value(_CommandResult.cancelled);
}
final animation = _requiredSpineAnimation(command);
final track =
_optionalInt(command.payload['track'], 'play_spine_animation.track') ??
0;
final loop =
_optionalBool(command.payload['loop'], 'play_spine_animation.loop') ??
true;
final queue =
_optionalBool(command.payload['queue'], 'play_spine_animation.queue') ??
false;
final delay = _readDouble(command.payload['delay']) ?? 0;
if (track < 0) {
throw const FormatException('play_spine_animation.track must be >= 0');
}
if (delay < 0) {
throw const FormatException('play_spine_animation.delay must be >= 0');
}
final played = component.playSpineAnimation(
animation,
track: track,
loop: loop,
queue: queue,
delay: delay,
);
if (!played) {
return Future.value(_CommandResult.cancelled);
}
final scope = _completionScopeFor(command, context);
_emitCompletion(
command,
target,
scope,
_renderTree.epochOf(target),
context.scopeEpoch,
);
return Future.value(_CommandResult.completed);
}
Future<_CommandResult> _removeNode(
RuntimeCommand command,
_CommandContext context,
) {
final target = _requiredTarget(command);
_renderTree.removeById(target);
_emitCompletion(command, target, _completionScopeFor(command, context));
return Future.value(_CommandResult.completed);
}
}

View File

@@ -0,0 +1,96 @@
part of 'command_executor.dart';
extension _CommandExecutorToast on CommandExecutor {
Future<_CommandResult> _toast(
RuntimeCommand command,
_CommandContext context,
RuntimeCommandHandle? handle,
) {
final text = _toastText(command);
final duration = _duration(command, defaultValue: 1.8);
final scope = _scopeFor(command, context, defaultTarget: false);
final scopeEpoch = _scopeEpochFor(scope, context);
final task = _registerTask(scope, handle);
final toastId = 'runtime_toast_${++_toastSerial}';
final toastTextId = '${toastId}_text';
_renderTree.apply(
NodeDiff(
creates: _toastNodes(id: toastId, textId: toastTextId, text: text),
),
);
task.addCancelCallback(() => _renderTree.removeById(toastId));
_schedule(duration, task, () {
if ((handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) {
_renderTree.removeById(toastId);
task.cancel();
return;
}
_renderTree.removeById(toastId);
_emitCommandCompletion(
command,
context.copyWith(scope: scope, scopeEpoch: scopeEpoch),
);
task.complete(_CommandResult.completed);
});
return task.future;
}
List<RuntimeNode> _toastNodes({
required String id,
required String textId,
required String text,
}) {
const width = 360.0;
const minHeight = 38.0;
final lineCount = text.split('\n').length;
final height = (minHeight + (lineCount - 1) * 16).clamp(38.0, 92.0);
final x = ((_overlaySize.x - width) / 2).clamp(12.0, _overlaySize.x);
final y = (_overlaySize.y - height - 58).clamp(12.0, _overlaySize.y);
return [
RuntimeNode(
id: id,
type: RuntimeNodeType.panel,
x: x,
y: y,
width: width,
height: height,
color: const Color(0xee020617),
radius: 12,
layer: 10000,
),
RuntimeNode(
id: textId,
type: RuntimeNodeType.text,
parent: id,
text: text,
x: 14,
y: 0,
width: width - 28,
height: height,
color: const Color(0xfff8fafc),
fontSize: 13,
textAlign: RuntimeTextAlignValue.center,
layer: 10001,
),
];
}
String _toastText(RuntimeCommand command) {
final text = _optionalString(command.payload['text'], 'toast.text');
final message = _optionalString(
command.payload['message'],
'toast.message',
);
if (text != null) {
return text;
}
if (message != null) {
return message;
}
throw const FormatException('toast.text or toast.message is required');
}
}

View File

@@ -0,0 +1,154 @@
part of 'command_executor.dart';
extension _CommandExecutorValidation on CommandExecutor {
void _validate(RuntimeCommand command) {
if (!RuntimeCommandType.isSupported(command.type)) {
throw UnsupportedError('Unsupported runtime command: ${command.type}');
}
RuntimeProtocolSchema.ensureKnownKeys(
command.payload,
allowed: RuntimeProtocolSchema.allowedCommandPayloadFields(command.type),
context: 'RuntimeCommand.${command.type}.payload',
);
_optionalString(command.payload['id'], 'id');
_optionalString(command.payload['group'], 'group');
_optionalString(command.payload['commandGroup'], 'commandGroup');
_optionalString(command.payload['scope'], 'scope');
_estimatedDuration(command);
}
double _estimatedDuration(RuntimeCommand command) {
_optionalString(command.payload['onComplete'], 'onComplete');
switch (command.type) {
case RuntimeCommandType.movePath:
_requiredTarget(command);
_validatePath(command.payload['path']);
return _duration(command, defaultValue: 0.4);
case RuntimeCommandType.moveTo:
_requiredTarget(command);
_requiredVector(command);
return _duration(command, defaultValue: 0.2);
case RuntimeCommandType.fadeTo:
_requiredTarget(command);
_requiredNormalizedDouble(command.payload['alpha'], 'fade_to.alpha');
return _duration(command, defaultValue: 0.2);
case RuntimeCommandType.scaleTo:
_requiredTarget(command);
_requiredDouble(command.payload['scale'], 'scale_to.scale');
return _duration(command, defaultValue: 0.2);
case RuntimeCommandType.rotateTo:
_requiredTarget(command);
_requiredDouble(command.payload['angle'], 'rotate_to.angle');
return _duration(command, defaultValue: 0.2);
case RuntimeCommandType.removeNode:
_requiredTarget(command);
return 0;
case RuntimeCommandType.delay:
return _duration(command, defaultValue: 0);
case RuntimeCommandType.sequence:
return _commandsFromPayload(
command,
).fold<double>(0, (sum, child) => sum + _estimatedDuration(child));
case RuntimeCommandType.parallel:
var maxDuration = 0.0;
for (final child in _commandsFromPayload(command)) {
final duration = _estimatedDuration(child);
if (duration > maxDuration) {
maxDuration = duration;
}
}
return maxDuration;
case RuntimeCommandType.toast:
_toastText(command);
return _duration(command, defaultValue: 1.8);
case RuntimeCommandType.playSound:
_requiredAudioResource(command);
_optionalVolume(command);
return 0;
case RuntimeCommandType.playBgm:
_requiredAudioResource(command);
_optionalVolume(command);
_audioChannel(command);
_optionalBool(command.payload['loop'], 'play_bgm.loop');
return 0;
case RuntimeCommandType.pauseBgm:
case RuntimeCommandType.resumeBgm:
case RuntimeCommandType.stopBgm:
_audioChannel(command);
return 0;
case RuntimeCommandType.preloadResources:
_requiredResourceGroup(command);
_optionalBool(
command.payload['failOnError'],
'preload_resources.failOnError',
);
return 0;
case RuntimeCommandType.evictResources:
_requiredResourceGroup(command);
return 0;
case RuntimeCommandType.cancelCommands:
_validateCancelCommands(command);
return 0;
case RuntimeCommandType.playSpineAnimation:
_requiredTarget(command);
_requiredSpineAnimation(command);
final track = _optionalInt(
command.payload['track'],
'play_spine_animation.track',
);
if (track != null && track < 0) {
throw const FormatException(
'play_spine_animation.track must be >= 0',
);
}
_optionalBool(command.payload['loop'], 'play_spine_animation.loop');
_optionalBool(command.payload['queue'], 'play_spine_animation.queue');
final delay = _readDouble(command.payload['delay']);
if (delay != null && delay < 0) {
throw const FormatException(
'play_spine_animation.delay must be >= 0',
);
}
return 0;
case RuntimeCommandType.copyText:
_requiredText(command, 'copy_text.text');
return 0;
default:
throw UnsupportedError('Unsupported runtime command: ${command.type}');
}
}
void _validatePath(Object? pathValue) {
if (pathValue is! List || pathValue.isEmpty) {
throw const FormatException('move_path.path must be a non-empty list');
}
for (final point in pathValue) {
if (point is! Map) {
throw const FormatException('move_path.path item must be a map');
}
final x = _readDouble(point['x']);
final y = _readDouble(point['y']);
if (x == null || y == null) {
throw const FormatException('move_path point requires x/y');
}
}
}
List<RuntimeCommand> _commandsFromPayload(RuntimeCommand command) {
final value = command.payload['commands'];
if (value is! List) {
throw FormatException('${command.type}.commands must be a list');
}
return value
.map((item) {
if (item is! Map) {
throw FormatException(
'${command.type}.commands item must be a map',
);
}
return RuntimeCommand.fromMap(Map<String, Object?>.from(item));
})
.toList(growable: false);
}
}

View File

@@ -0,0 +1,146 @@
class RuntimeCommandRegistry {
final Set<RuntimeCommandHandle> _handles = {};
final Map<String, Set<RuntimeCommandHandle>> _handlesById = {};
final Map<String, Set<RuntimeCommandHandle>> _handlesByGroup = {};
final Map<String, Set<RuntimeCommandHandle>> _handlesByScope = {};
bool _disposed = false;
int get activeHandleCount => _handles.length;
RuntimeCommandHandle create({String? id, String? group, String? scope}) {
if (_disposed) {
throw StateError('RuntimeCommandRegistry has been disposed');
}
late final RuntimeCommandHandle handle;
handle = RuntimeCommandHandle._(
id: id,
group: group,
scope: scope,
onComplete: _unregister,
);
_handles.add(handle);
_index(_handlesById, id, handle);
_index(_handlesByGroup, group, handle);
_index(_handlesByScope, scope, handle);
return handle;
}
void cancelId(String id) {
_cancelAll(_handlesById[id]);
}
void cancelGroup(String group) {
_cancelAll(_handlesByGroup[group]);
}
void cancelScope(String scope) {
_cancelAll(_handlesByScope[scope]);
}
void dispose() {
if (_disposed) {
return;
}
_disposed = true;
_cancelAll(_handles);
_handles.clear();
_handlesById.clear();
_handlesByGroup.clear();
_handlesByScope.clear();
}
void _index(
Map<String, Set<RuntimeCommandHandle>> index,
String? key,
RuntimeCommandHandle handle,
) {
if (key == null) {
return;
}
index.putIfAbsent(key, () => {}).add(handle);
}
void _cancelAll(Set<RuntimeCommandHandle>? handles) {
final snapshot = handles?.toList(growable: false) ?? const [];
for (final handle in snapshot) {
handle.cancel();
}
}
void _unregister(RuntimeCommandHandle handle) {
_handles.remove(handle);
_unindex(_handlesById, handle.id, handle);
_unindex(_handlesByGroup, handle.group, handle);
_unindex(_handlesByScope, handle.scope, handle);
}
void _unindex(
Map<String, Set<RuntimeCommandHandle>> index,
String? key,
RuntimeCommandHandle handle,
) {
if (key == null) {
return;
}
final handles = index[key];
handles?.remove(handle);
if (handles != null && handles.isEmpty) {
index.remove(key);
}
}
}
class RuntimeCommandHandle {
RuntimeCommandHandle._({
required this.id,
required this.group,
required this.scope,
required void Function(RuntimeCommandHandle handle) onComplete,
}) : _onComplete = onComplete;
final String? id;
final String? group;
final String? scope;
final void Function(RuntimeCommandHandle handle) _onComplete;
final List<void Function()> _cancelCallbacks = [];
bool _cancelled = false;
bool _completed = false;
bool get isCancelled => _cancelled;
bool get isCompleted => _completed;
void addCancelCallback(void Function() callback) {
if (_cancelled) {
callback();
return;
}
if (_completed) {
return;
}
_cancelCallbacks.add(callback);
}
void complete() {
if (_completed) {
return;
}
_completed = true;
_cancelCallbacks.clear();
_onComplete(this);
}
void cancel() {
if (_cancelled || _completed) {
return;
}
_cancelled = true;
final callbacks = _cancelCallbacks.toList(growable: false);
_cancelCallbacks.clear();
for (final callback in callbacks) {
callback();
}
complete();
}
}