Initial flame_lua_runtime package
This commit is contained in:
125
lib/runtime/commands/command_audio.dart
Normal file
125
lib/runtime/commands/command_audio.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
52
lib/runtime/commands/command_composite.dart
Normal file
52
lib/runtime/commands/command_composite.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
230
lib/runtime/commands/command_executor.dart
Normal file
230
lib/runtime/commands/command_executor.dart
Normal 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 }
|
||||
154
lib/runtime/commands/command_lifecycle_context.dart
Normal file
154
lib/runtime/commands/command_lifecycle_context.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
83
lib/runtime/commands/command_resources.dart
Normal file
83
lib/runtime/commands/command_resources.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
248
lib/runtime/commands/command_support.dart
Normal file
248
lib/runtime/commands/command_support.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
143
lib/runtime/commands/command_target_effects.dart
Normal file
143
lib/runtime/commands/command_target_effects.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
96
lib/runtime/commands/command_toast.dart
Normal file
96
lib/runtime/commands/command_toast.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
154
lib/runtime/commands/command_validation.dart
Normal file
154
lib/runtime/commands/command_validation.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
146
lib/runtime/commands/runtime_command_registry.dart
Normal file
146
lib/runtime/commands/runtime_command_registry.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user