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,14 @@
library;
export 'runtime/game/flame_lua_game.dart' show FlameLuaGame;
export 'runtime/game/lua_game_widget.dart' show LuaGameWidget;
export 'runtime/game/runtime_locale.dart' show RuntimeLocaleResolver;
export 'runtime/game/runtime_options.dart' show RuntimeOptions;
export 'runtime/packages/game_package_repository.dart'
show
AssetGamePackageRepository,
GamePackageRepository,
RemoteGamePackageRepository;
export 'runtime/scripting/lua_dardo_script_engine.dart'
show LuaDardoScriptEngine;
export 'runtime/scripting/script_engine.dart' show ScriptEngine;

View File

@@ -0,0 +1,90 @@
part of 'runtime_audio_manager.dart';
extension _RuntimeAudioManagerCache on RuntimeAudioManager {
void _releaseCachedAudio() {
_loadLimiter.clearPending();
for (final path in _audios.keys.toList(growable: false)) {
_removeAudioRecord(path);
}
_channels.clear();
final players = _players.toList(growable: false);
_players.clear();
final pooledPlayers = _sfxPool.toList(growable: false);
_sfxPool.clear();
for (final player in [...players, ...pooledPlayers]) {
player.dispose();
}
}
RuntimeAudioPlayer _takeSfxPlayer() {
if (_sfxPool.isNotEmpty) {
return _sfxPool.removeLast();
}
return _playerFactory();
}
Future<void> _releaseSfxPlayer(RuntimeAudioPlayer player) async {
if (_disposed || _sfxPool.length >= _maxSfxPoolSize) {
await player.dispose();
return;
}
_sfxPool.add(player);
}
void _touch(_AudioResourceRecord record) {
record.lastUsed = ++_accessCounter;
}
void _enforceAudioBudget() {
while (_isOverBudget()) {
final victim = _leastRecentlyUsedAudio();
if (victim == null || !_removeAudioRecord(victim)) {
return;
}
}
}
bool _isOverBudget() {
final maxBytes = _maxCacheBytes;
final maxEntries = _maxCacheEntries;
return (maxBytes != null && _cacheBytes > maxBytes) ||
(maxEntries != null && _readyAudioCount > maxEntries);
}
int get _readyAudioCount => _audios.values
.where((record) => record.state == GameResourceState.ready)
.length;
String? _leastRecentlyUsedAudio() {
String? victimPath;
_AudioResourceRecord? victim;
for (final entry in _audios.entries) {
final record = entry.value;
if (record.state != GameResourceState.ready) {
continue;
}
if (victim == null || record.lastUsed < victim.lastUsed) {
victim = record;
victimPath = entry.key;
}
}
return victimPath;
}
bool _removeAudioRecord(String path) {
final record = _audios.remove(path);
if (record == null) {
return false;
}
_cacheBytes -= record.bytes?.length ?? 0;
if (_cacheBytes < 0) {
_cacheBytes = 0;
}
record
..state = GameResourceState.disposed
..bytes = null
..inflight = null;
return true;
}
}

View File

@@ -0,0 +1,27 @@
part of 'runtime_audio_manager.dart';
extension _RuntimeAudioManagerDebug on RuntimeAudioManager {
Map<String, Object?> _audioRecordDebugJson({
required String? key,
required String path,
required String? preload,
required bool declared,
}) {
final record = _audios[path];
return {
if (key != null) 'key': key,
'path': path,
'type': GameResourceType.audio,
'declared': declared,
if (preload != null) 'preload': preload,
if (key != null && _package?.manifest.resources[key]?.group != null)
'group': _package?.manifest.resources[key]?.group,
'state': (record?.state ?? GameResourceState.idle).name,
if (record != null) 'generation': record.generation,
'loading': record?.inflight != null,
'ready': record?.bytes != null,
if (record?.bytes != null) 'bytes': record!.bytes!.length,
if (record?.lastError != null) 'error': record!.lastError.toString(),
};
}
}

View File

@@ -0,0 +1,124 @@
part of 'runtime_audio_manager.dart';
extension _RuntimeAudioManagerLoading on RuntimeAudioManager {
Future<Uint8List?> _loadAudio(
String? keyOrPath, {
required bool failOnError,
}) {
if (keyOrPath == null || keyOrPath.isEmpty) {
return Future.value(null);
}
final requestToken = _asyncGate.token;
final requestGeneration = requestToken.generation;
final path = _tryResolve(keyOrPath);
if (path == null) {
return Future.value(null);
}
final existing = _audios[path];
if (existing != null) {
final bytes = existing.bytes;
if (existing.generation == requestGeneration &&
existing.state == GameResourceState.ready &&
bytes != null) {
_touch(existing);
return Future.value(bytes);
}
final inflight = existing.inflight;
if (existing.generation == requestGeneration && inflight != null) {
return failOnError
? _throwIfNull(inflight, keyOrPath)
: inflight.catchError((_) => null);
}
}
final record = _AudioResourceRecord(generation: requestGeneration)
..state = GameResourceState.loading;
_audios[path] = record;
final future = _readAudio(path, record, requestToken);
record.inflight = future;
return failOnError ? _throwIfNull(future, keyOrPath) : future;
}
Future<Uint8List?> _throwIfNull(
Future<Uint8List?> future,
String keyOrPath,
) async {
final bytes = await future;
if (bytes == null) {
throw ResourceLoadException('Required audio resource failed: $keyOrPath');
}
return bytes;
}
Future<Uint8List?> _readAudio(
String path,
_AudioResourceRecord record,
RuntimeAsyncToken requestToken,
) async {
try {
final activePackage = _package;
if (activePackage == null) {
throw StateError('RuntimeAudioManager has no active package');
}
final ownedBytes = await _loadLimiter.run(() async {
final data = await activePackage.readBytes(path);
final bytes = data.buffer.asUint8List(
data.offsetInBytes,
data.lengthInBytes,
);
return Uint8List.fromList(bytes);
});
record.inflight = null;
if (_disposed ||
!_asyncGate.accepts(requestToken) ||
_audios[path] != record) {
record.state = GameResourceState.disposed;
return null;
}
record
..bytes = ownedBytes
..state = GameResourceState.ready
..lastError = null;
_cacheBytes += ownedBytes.length;
_touch(record);
_enforceAudioBudget();
return ownedBytes;
} catch (error) {
record.inflight = null;
if (_disposed ||
!_asyncGate.accepts(requestToken) ||
_audios[path] != record) {
record.state = GameResourceState.disposed;
return null;
}
record
..state = GameResourceState.failed
..lastError = error;
_diagnostics?.record(
type: RuntimeDiagnosticType.resourceLoadError,
message: 'Audio resource failed to load',
error: error,
context: {'path': path, 'generation': requestToken.generation},
);
return null;
}
}
String? _tryResolve(String keyOrPath) {
try {
final activePackage = _package;
if (activePackage == null) {
return null;
}
return activePackage.resolveResourcePath(keyOrPath);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,352 @@
import 'dart:typed_data';
import '../diagnostics/runtime_diagnostics.dart';
import '../lifecycle/runtime_async_gate.dart';
import '../packages/game_package.dart';
import '../packages/game_package_manifest.dart';
import '../resources/game_resource_manager.dart';
import '../resources/resource_load_limiter.dart';
import 'runtime_audio_player.dart';
// These part files only group RuntimeAudioManager private helpers. The public
// facade stays in RuntimeAudioManager so callers do not depend on extensions.
part 'runtime_audio_loading.dart';
part 'runtime_audio_debug.dart';
part 'runtime_audio_cache.dart';
class RuntimeAudioManager {
RuntimeAudioManager({
RuntimeDiagnostics? diagnostics,
RuntimeAudioPlayer Function()? playerFactory,
int maxSfxPoolSize = 8,
int? maxCacheBytes,
int? maxCacheEntries,
int maxConcurrentLoads = 4,
}) : _diagnostics = diagnostics,
_playerFactory = playerFactory ?? AudioplayersRuntimeAudioPlayer.new,
_maxSfxPoolSize = maxSfxPoolSize,
_maxCacheBytes = maxCacheBytes,
_maxCacheEntries = maxCacheEntries,
_loadLimiter = ResourceLoadLimiter(maxConcurrentLoads);
final RuntimeDiagnostics? _diagnostics;
final RuntimeAudioPlayer Function() _playerFactory;
final int _maxSfxPoolSize;
final int? _maxCacheBytes;
final int? _maxCacheEntries;
final ResourceLoadLimiter _loadLimiter;
final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true);
final Map<String, _AudioResourceRecord> _audios = {};
final Set<RuntimeAudioPlayer> _players = {};
final Map<String, RuntimeAudioPlayback> _channels = {};
final List<RuntimeAudioPlayer> _sfxPool = [];
GamePackage? _package;
int _cacheBytes = 0;
int _accessCounter = 0;
bool _disposed = false;
int get generation => _asyncGate.generation;
bool get hasPackage => _package != null;
Future<void> mount(GamePackage package) async {
_releaseCachedAudio();
_asyncGate.activate();
_disposed = false;
_package = package;
await preloadDeclaredAudio(package.manifest);
}
void dispose() {
_disposed = true;
_asyncGate.close();
_releaseCachedAudio();
_package = null;
}
GameResourceState audioState(String keyOrPath) {
final path = _tryResolve(keyOrPath);
if (path == null) {
return GameResourceState.failed;
}
return _audios[path]?.state ?? GameResourceState.idle;
}
Object? audioError(String keyOrPath) {
final path = _tryResolve(keyOrPath);
if (path == null) {
return StateError('RuntimeAudioManager has no active package');
}
return _audios[path]?.lastError;
}
Map<String, Object?> audioDebugJson() {
final activePackage = _package;
final declaredPaths = <String>{};
final resources = <Map<String, Object?>>[];
if (activePackage != null) {
for (final entry in activePackage.manifest.resources.entries) {
final resource = entry.value;
if (resource.type != GameResourceType.audio) {
continue;
}
final path = activePackage.resolveResourcePath(entry.key);
declaredPaths.add(path);
resources.add(
_audioRecordDebugJson(
key: entry.key,
path: path,
preload: resource.preload,
declared: true,
),
);
}
}
for (final path in _audios.keys) {
if (declaredPaths.contains(path)) {
continue;
}
resources.add(
_audioRecordDebugJson(
key: null,
path: path,
preload: null,
declared: false,
),
);
}
return {
'generation': generation,
'hasPackage': activePackage != null,
'count': resources.length,
'activeLoads': _loadLimiter.activeCount,
'pendingLoads': _loadLimiter.pendingCount,
'activePlayers': _players.length,
'pooledPlayers': _sfxPool.length,
'channels': _channels.keys.toList(growable: false)..sort(),
'resources': resources,
};
}
bool evictAudio(String keyOrPath) {
final path = _tryResolve(keyOrPath);
if (path == null) {
return false;
}
return _removeAudioRecord(path);
}
Future<bool> retryAudio(String keyOrPath) async {
evictAudio(keyOrPath);
final bytes = await _loadAudio(keyOrPath, failOnError: false);
return bytes != null;
}
Future<void> preloadDeclaredAudio(GamePackageManifest manifest) async {
final futures = <Future<void>>[];
for (final entry in manifest.resources.entries) {
final resource = entry.value;
if (resource.type != GameResourceType.audio ||
resource.preload == GameResourcePreload.lazy) {
continue;
}
final failOnError = resource.preload == GameResourcePreload.required;
futures.add(_loadAudio(entry.key, failOnError: failOnError).then((_) {}));
}
await Future.wait(futures);
}
Future<RuntimeAudioPlayback?> play(
String? keyOrPath, {
double volume = 1,
}) async {
if (_disposed) {
return null;
}
final bytes = await _loadAudio(keyOrPath, failOnError: false);
if (_disposed || bytes == null) {
return null;
}
final player = _takeSfxPlayer();
_players.add(player);
try {
await player.start(bytes, volume: volume);
} catch (error) {
_players.remove(player);
await player.dispose();
_diagnostics?.record(
type: RuntimeDiagnosticType.resourceLoadError,
message: 'Audio resource failed to play',
error: error,
context: {'resource': keyOrPath, 'generation': generation},
);
return null;
}
final playback = RuntimeAudioPlayback._(player, player.done);
playback.done.whenComplete(() async {
_players.remove(player);
if (playback.isCancelled) {
await player.dispose();
return;
}
await _releaseSfxPlayer(player);
});
return playback;
}
Future<RuntimeAudioPlayback?> playBgm(
String? keyOrPath, {
String channel = RuntimeAudioChannel.defaultBgm,
double volume = 1,
bool loop = true,
}) async {
if (_disposed) {
return null;
}
final bytes = await _loadAudio(keyOrPath, failOnError: false);
if (_disposed || bytes == null) {
return null;
}
await stopBgm(channel: channel);
if (_disposed) {
return null;
}
final player = _playerFactory();
_players.add(player);
try {
await player.start(bytes, volume: volume, loop: loop);
} catch (error) {
_players.remove(player);
await player.dispose();
_diagnostics?.record(
type: RuntimeDiagnosticType.resourceLoadError,
message: 'BGM resource failed to play',
error: error,
context: {
'resource': keyOrPath,
'channel': channel,
'generation': generation,
},
);
return null;
}
final playback = RuntimeAudioPlayback._(player, player.done);
_channels[channel] = playback;
playback.done.whenComplete(() async {
if (_channels[channel] == playback) {
_channels.remove(channel);
}
_players.remove(player);
await player.dispose();
});
return playback;
}
Future<void> pauseBgm({String channel = RuntimeAudioChannel.defaultBgm}) {
return _channels[channel]?.pause() ?? Future.value();
}
Future<void> resumeBgm({String channel = RuntimeAudioChannel.defaultBgm}) {
return _channels[channel]?.resume() ?? Future.value();
}
Future<void> stopBgm({
String channel = RuntimeAudioChannel.defaultBgm,
}) async {
final playback = _channels.remove(channel);
await playback?.stop();
}
bool hasBgm({String channel = RuntimeAudioChannel.defaultBgm}) {
return _channels.containsKey(channel);
}
Future<void> preloadGroup(String group, {bool failOnError = false}) async {
final activePackage = _package;
if (activePackage == null) {
throw StateError('RuntimeAudioManager has no active package');
}
final futures = <Future<void>>[];
for (final entry in activePackage.manifest.resources.entries) {
final resource = entry.value;
if (resource.type == GameResourceType.audio && resource.group == group) {
futures.add(
_loadAudio(entry.key, failOnError: failOnError).then((_) {}),
);
}
}
await Future.wait(futures);
}
int evictGroup(String group) {
final activePackage = _package;
if (activePackage == null) {
return 0;
}
var count = 0;
for (final entry in activePackage.manifest.resources.entries) {
final resource = entry.value;
if (resource.type != GameResourceType.audio || resource.group != group) {
continue;
}
final path = activePackage.resolveResourcePath(entry.key);
if (_removeAudioRecord(path)) {
count++;
}
}
return count;
}
}
abstract final class RuntimeAudioChannel {
static const defaultBgm = 'bgm';
}
class RuntimeAudioPlayback {
RuntimeAudioPlayback._(this._player, this.done);
final RuntimeAudioPlayer _player;
bool _cancelled = false;
final Future<void> done;
bool get isCancelled => _cancelled;
Future<void> pause() {
return _player.pause();
}
Future<void> resume() {
return _player.resume();
}
Future<void> stop() async {
_cancelled = true;
await _player.stop();
}
Future<void> cancel() async {
_cancelled = true;
await _player.dispose();
}
}
class _AudioResourceRecord {
_AudioResourceRecord({required this.generation});
final int generation;
GameResourceState state = GameResourceState.idle;
Future<Uint8List?>? inflight;
Uint8List? bytes;
Object? lastError;
int lastUsed = 0;
}

View File

@@ -0,0 +1,99 @@
import 'dart:async' as async;
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
abstract class RuntimeAudioPlayer {
Future<void> start(
Uint8List bytes, {
required double volume,
bool loop = false,
});
Future<void> pause();
Future<void> resume();
Future<void> stop();
Future<void> get done;
Future<void> dispose();
}
class AudioplayersRuntimeAudioPlayer implements RuntimeAudioPlayer {
AudioplayersRuntimeAudioPlayer({AudioPlayer? player})
: _player = player ?? AudioPlayer();
final AudioPlayer _player;
async.Completer<void> _done = async.Completer<void>();
async.StreamSubscription<void>? _completionSubscription;
bool _disposed = false;
@override
Future<void> get done => _done.future;
@override
Future<void> start(
Uint8List bytes, {
required double volume,
bool loop = false,
}) async {
if (_disposed) {
return;
}
await _completionSubscription?.cancel();
_completionSubscription = null;
if (_done.isCompleted) {
_done = async.Completer<void>();
}
_completionSubscription = _player.onPlayerComplete.listen((_) {
_completeDone();
});
await _player.setReleaseMode(loop ? ReleaseMode.loop : ReleaseMode.release);
await _player.play(BytesSource(bytes), volume: volume);
}
@override
Future<void> pause() async {
if (_disposed) {
return;
}
await _player.pause();
}
@override
Future<void> resume() async {
if (_disposed) {
return;
}
await _player.resume();
}
@override
Future<void> stop() async {
if (_disposed) {
return;
}
await _player.stop();
_completeDone();
}
@override
Future<void> dispose() async {
if (_disposed) {
return;
}
_disposed = true;
await _completionSubscription?.cancel();
_completionSubscription = null;
await _player.dispose();
_completeDone();
}
void _completeDone() {
if (!_done.isCompleted) {
_done.complete();
}
}
}

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

View File

@@ -0,0 +1,138 @@
import 'dart:convert';
class RuntimeDiagnostics {
RuntimeDiagnostics({this.maxEntries = 100});
final int maxEntries;
final List<RuntimeDiagnosticEntry> _entries = [];
List<RuntimeDiagnosticEntry> get entries => List.unmodifiable(_entries);
Map<String, Object?> toDebugJson() {
return {
'maxEntries': maxEntries,
'count': _entries.length,
'entries': _entries.map((entry) => entry.toDebugJson()).toList(),
};
}
String dumpText() {
if (_entries.isEmpty) {
return 'RuntimeDiagnostics: no entries';
}
final buffer = StringBuffer(
'RuntimeDiagnostics (${_entries.length}/$maxEntries)',
);
for (final entry in _entries) {
buffer
..writeln()
..write(entry.dumpText());
}
return buffer.toString();
}
void record({
required RuntimeDiagnosticType type,
required String message,
Object? error,
Map<String, Object?> context = const {},
}) {
if (_entries.length >= maxEntries) {
_entries.removeAt(0);
}
_entries.add(
RuntimeDiagnosticEntry(
type: type,
message: message,
error: error,
context: context,
timestamp: DateTime.now(),
),
);
}
void clear() {
_entries.clear();
}
}
class RuntimeDiagnosticEntry {
const RuntimeDiagnosticEntry({
required this.type,
required this.message,
required this.timestamp,
this.error,
this.context = const {},
});
final RuntimeDiagnosticType type;
final String message;
final DateTime timestamp;
final Object? error;
final Map<String, Object?> context;
Map<String, Object?> toDebugJson() {
return {
'timestamp': timestamp.toIso8601String(),
'type': type.name,
'message': message,
if (error != null) 'error': error.toString(),
if (context.isNotEmpty) 'context': _toDebugValue(context),
};
}
String dumpText() {
final buffer = StringBuffer(
'[${timestamp.toIso8601String()}] ${type.name}: $message',
);
if (error != null) {
buffer
..writeln()
..write(' error: $error');
}
if (context.isNotEmpty) {
buffer
..writeln()
..write(' context: ${_formatDebugValue(context)}');
}
return buffer.toString();
}
}
Object? _toDebugValue(Object? value) {
if (value == null || value is String || value is num || value is bool) {
return value;
}
if (value is DateTime) {
return value.toIso8601String();
}
if (value is Map) {
final entries = value.entries.toList()
..sort((a, b) => a.key.toString().compareTo(b.key.toString()));
return {
for (final entry in entries)
entry.key.toString(): _toDebugValue(entry.value),
};
}
if (value is Iterable) {
return value.map(_toDebugValue).toList();
}
return value.toString();
}
String _formatDebugValue(Object? value) {
try {
return jsonEncode(_toDebugValue(value));
} catch (_) {
return value.toString();
}
}
enum RuntimeDiagnosticType {
luaEventError,
diffApplyError,
packageActivationError,
resourceLoadError,
commandError,
}

View File

@@ -0,0 +1,160 @@
import 'package:flame/components.dart';
class RuntimeScaleMode {
const RuntimeScaleMode._();
static const fit = 'fit';
static const fill = 'fill';
static const stretch = 'stretch';
static const none = 'none';
static const all = {fit, fill, stretch, none};
static bool isSupported(String value) => all.contains(value);
}
class RuntimeViewportConfig {
const RuntimeViewportConfig({
required this.designWidth,
required this.designHeight,
this.scaleMode = RuntimeScaleMode.fit,
});
final double designWidth;
final double designHeight;
final String scaleMode;
Vector2 get designSize => Vector2(designWidth, designHeight);
}
class RuntimeViewportTransform {
const RuntimeViewportTransform({
required this.x,
required this.y,
required this.width,
required this.height,
required this.scaleX,
required this.scaleY,
required this.scaleMode,
});
final double x;
final double y;
final double width;
final double height;
final double scaleX;
final double scaleY;
final String scaleMode;
Map<String, Object?> toMap() {
return {
'x': x,
'y': y,
'width': width,
'height': height,
'scaleX': scaleX,
'scaleY': scaleY,
'scaleMode': scaleMode,
};
}
}
class RuntimeViewport {
const RuntimeViewport._();
static RuntimeViewportTransform compute({
required Vector2 screenSize,
required RuntimeViewportConfig config,
}) {
final designWidth = config.designWidth;
final designHeight = config.designHeight;
final screenWidth = screenSize.x;
final screenHeight = screenSize.y;
if (designWidth <= 0 || designHeight <= 0) {
throw const FormatException('Runtime viewport design size must be > 0');
}
if (!RuntimeScaleMode.isSupported(config.scaleMode)) {
throw FormatException(
'Runtime viewport scaleMode is unsupported: ${config.scaleMode}',
);
}
final safeScreenWidth = screenWidth <= 0 ? designWidth : screenWidth;
final safeScreenHeight = screenHeight <= 0 ? designHeight : screenHeight;
final scaleX = safeScreenWidth / designWidth;
final scaleY = safeScreenHeight / designHeight;
return switch (config.scaleMode) {
RuntimeScaleMode.fit => _uniform(
designWidth: designWidth,
designHeight: designHeight,
screenWidth: safeScreenWidth,
screenHeight: safeScreenHeight,
scale: scaleX < scaleY ? scaleX : scaleY,
scaleMode: config.scaleMode,
),
RuntimeScaleMode.fill => _uniform(
designWidth: designWidth,
designHeight: designHeight,
screenWidth: safeScreenWidth,
screenHeight: safeScreenHeight,
scale: scaleX > scaleY ? scaleX : scaleY,
scaleMode: config.scaleMode,
),
RuntimeScaleMode.stretch => RuntimeViewportTransform(
x: 0,
y: 0,
width: safeScreenWidth,
height: safeScreenHeight,
scaleX: scaleX,
scaleY: scaleY,
scaleMode: config.scaleMode,
),
RuntimeScaleMode.none => RuntimeViewportTransform(
x: (safeScreenWidth - designWidth) / 2,
y: (safeScreenHeight - designHeight) / 2,
width: designWidth,
height: designHeight,
scaleX: 1,
scaleY: 1,
scaleMode: config.scaleMode,
),
_ => throw FormatException(
'Runtime viewport scaleMode is unsupported: ${config.scaleMode}',
),
};
}
static void apply(
PositionComponent root,
RuntimeViewportTransform transform,
) {
root
..position = Vector2(transform.x, transform.y)
..scale = Vector2(transform.scaleX, transform.scaleY)
..size = Vector2(transform.width, transform.height);
}
static RuntimeViewportTransform _uniform({
required double designWidth,
required double designHeight,
required double screenWidth,
required double screenHeight,
required double scale,
required String scaleMode,
}) {
final width = designWidth * scale;
final height = designHeight * scale;
return RuntimeViewportTransform(
x: (screenWidth - width) / 2,
y: (screenHeight - height) / 2,
width: width,
height: height,
scaleX: scale,
scaleY: scale,
scaleMode: scaleMode,
);
}
}

View File

@@ -0,0 +1,80 @@
import '../diagnostics/runtime_diagnostics.dart';
import '../lifecycle/runtime_serial_queue.dart';
import '../lifecycle/runtime_session.dart';
import '../models/game_diff.dart';
import '../models/runtime_event.dart';
import '../scripting/script_engine.dart';
import 'runtime_event_gate.dart';
class RuntimeEventDispatcher {
RuntimeEventDispatcher({
required RuntimeSession session,
required ScriptEngine scriptEngine,
required bool Function(String id) isScopeAlive,
bool Function(String id, int epoch)? isNodeEpochAlive,
required void Function(GameDiff diff) applyDiff,
RuntimeDiagnostics? diagnostics,
void Function(Object error)? onError,
}) : _scriptEngine = scriptEngine,
_applyDiff = applyDiff,
_diagnostics = diagnostics,
_onError = onError,
_gate = RuntimeEventGate(
session: session,
isScopeAlive: isScopeAlive,
isNodeEpochAlive: isNodeEpochAlive,
) {
_queue = RuntimeSerialQueue<RuntimeEvent>(
shouldContinue: () => !_disposed && session.isActive,
onItem: _dispatch,
);
}
final ScriptEngine _scriptEngine;
final void Function(GameDiff diff) _applyDiff;
final RuntimeDiagnostics? _diagnostics;
final void Function(Object error)? _onError;
final RuntimeEventGate _gate;
late final RuntimeSerialQueue<RuntimeEvent> _queue;
bool _disposed = false;
int get pendingEventCount => _queue.pendingCount;
void enqueue(RuntimeEvent event) {
if (_disposed || !_gate.session.isActive) {
return;
}
_queue.enqueue(_gate.attachSession(event));
}
void dispose() {
_disposed = true;
_queue.dispose();
}
void _dispatch(RuntimeEvent event) {
if (!_gate.accepts(event)) {
return;
}
try {
final diff = _scriptEngine.dispatchEvent(event);
if (_disposed || !_gate.session.isActive || !_gate.accepts(event)) {
return;
}
_applyDiff(diff);
} catch (error) {
_diagnostics?.record(
type: RuntimeDiagnosticType.luaEventError,
message: 'Lua event dispatch failed',
error: error,
context: {
'eventType': event.type,
if (event.target != null) 'target': event.target,
if (event.handler != null) 'handler': event.handler,
},
);
_onError?.call(error);
}
}
}

View File

@@ -0,0 +1,47 @@
import '../lifecycle/runtime_session.dart';
import '../models/runtime_event.dart';
class RuntimeEventGate {
const RuntimeEventGate({
required this.session,
required bool Function(String id) isScopeAlive,
bool Function(String id, int epoch)? isNodeEpochAlive,
}) : _isScopeAlive = isScopeAlive,
_isNodeEpochAlive = isNodeEpochAlive;
final RuntimeSession session;
final bool Function(String id) _isScopeAlive;
final bool Function(String id, int epoch)? _isNodeEpochAlive;
RuntimeEvent attachSession(RuntimeEvent event) {
return event.withLifecycle(sessionId: event.sessionId ?? session.id);
}
bool accepts(RuntimeEvent event) {
final eventSessionId = event.sessionId;
if (eventSessionId != null && !session.accepts(eventSessionId)) {
return false;
}
final target = event.target;
final targetEpoch = event.targetEpoch;
final epochChecker = _isNodeEpochAlive;
if (target != null && targetEpoch != null && epochChecker != null) {
if (!epochChecker(target, targetEpoch)) {
return false;
}
}
final scope = event.scope;
if (scope != null && !_isScopeAlive(scope)) {
return false;
}
final scopeEpoch = event.scopeEpoch;
if (scope != null && scopeEpoch != null && epochChecker != null) {
if (!epochChecker(scope, scopeEpoch)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,377 @@
import 'dart:ui' show PlatformDispatcher;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import '../audio/runtime_audio_manager.dart';
import '../commands/command_executor.dart';
import '../diagnostics/runtime_diagnostics.dart';
import '../events/runtime_event_dispatcher.dart';
import '../lifecycle/runtime_session.dart';
import '../models/game_diff.dart';
import '../models/runtime_event.dart';
import '../packages/game_package.dart';
import '../packages/game_package_activation_controller.dart';
import '../packages/game_package_repository.dart';
import '../packages/stable_package_store.dart';
import '../protocol/runtime_protocol.dart';
import '../rendering/render_tree_controller.dart';
import '../display/runtime_viewport.dart';
import '../resources/game_resource_manager.dart';
import '../scripting/script_engine.dart';
import 'runtime_locale.dart';
import 'runtime_options.dart';
class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
FlameLuaGame({
required ScriptEngine scriptEngine,
ScriptEngine Function()? scriptEngineFactory,
required GamePackageRepository packageRepository,
required this.gameId,
RuntimeDiagnostics? diagnostics,
this.imageCacheMaxBytes,
this.imageCacheMaxEntries,
this.imageMaxConcurrentLoads = 4,
this.audioCacheMaxBytes,
this.audioCacheMaxEntries,
this.audioMaxConcurrentLoads = 4,
this.audioSfxPoolSize = 8,
this.runtimeOptions = const RuntimeOptions(),
Locale? localeOverride,
}) : _bootstrapScriptEngine = scriptEngine,
_localeOverride = localeOverride,
_scriptEngineFactory = scriptEngineFactory,
_packageRepository = packageRepository,
diagnostics = diagnostics ?? RuntimeDiagnostics();
final ScriptEngine _bootstrapScriptEngine;
final ScriptEngine Function()? _scriptEngineFactory;
late ScriptEngine _scriptEngine;
final GamePackageRepository _packageRepository;
final String gameId;
final RuntimeDiagnostics diagnostics;
final int? imageCacheMaxBytes;
final int? imageCacheMaxEntries;
final int imageMaxConcurrentLoads;
final int? audioCacheMaxBytes;
final int? audioCacheMaxEntries;
final int audioMaxConcurrentLoads;
final int audioSfxPoolSize;
final RuntimeOptions runtimeOptions;
final Locale? _localeOverride;
late final GameResourceManager _resources;
late final RuntimeAudioManager _audio;
late final RenderTreeController _renderTree;
late final PositionComponent _viewportRoot;
RuntimeViewportConfig? _viewportConfig;
late final CommandExecutor _commands;
RuntimeSession? _session;
RuntimeEventDispatcher? _events;
String? _draggingListViewId;
bool _runtimeInitialized = false;
String? loadError;
List<RuntimeDiagnosticEntry> get diagnosticEntries => diagnostics.entries;
Map<String, Object?> diagnosticsDebugJson() => diagnostics.toDebugJson();
String diagnosticsDumpText() => diagnostics.dumpText();
Map<String, Object?> resourcesDebugJson() {
if (!_runtimeInitialized) {
return {'initialized': false};
}
return {
'initialized': true,
'images': _resources.imagesDebugJson(),
'audio': _audio.audioDebugJson(),
};
}
@override
Color backgroundColor() => const Color(0xff0f172a);
@override
Future<void> onLoad() async {
await super.onLoad();
final session = RuntimeSession(gameId: gameId)..beginLoading();
_session = session;
try {
final activation =
await PackageActivationController(
repository: _packageRepository,
resources: _createResourceManager(),
scriptEngine: _bootstrapScriptEngine,
audio: _createAudioManager(),
resourceManagerFactory: _createResourceManager,
audioManagerFactory: _createAudioManager,
scriptEngineFactory: _scriptEngineFactory,
store: StablePackageStore(runtimeOptions: runtimeOptions),
assetFallback: AssetGamePackageRepository(
runtimeOptions: runtimeOptions,
),
).activate(
gameId: gameId,
contextBuilder: _buildContext,
shouldContinue: () => session.acceptsWork,
);
if (!session.acceptsWork) {
activation.resources.dispose();
activation.audio?.dispose();
return;
}
session.activate();
_resources = activation.resources;
_audio = activation.audio ?? _createAudioManager();
_scriptEngine = activation.scriptEngine;
_viewportConfig = activation.package.manifest.display.toViewportConfig();
_viewportRoot = PositionComponent();
add(_viewportRoot);
_applyViewportTransform();
_renderTree = RenderTreeController(
root: _viewportRoot,
resources: _resources,
eventSink: _emitEvent,
);
_commands = CommandExecutor(
renderTree: _renderTree,
eventSink: _emitEvent,
audio: _audio,
resources: _resources,
overlaySize: _viewportConfig?.designSize,
);
_renderTree.onScopeRemoved = _commands.cancelScope;
_events = RuntimeEventDispatcher(
session: session,
scriptEngine: _scriptEngine,
isScopeAlive: _renderTree.contains,
isNodeEpochAlive: _renderTree.isNodeEpochAlive,
applyDiff: _applyDiff,
diagnostics: diagnostics,
onError: (error) => debugPrint('Lua event failed: $error'),
);
_runtimeInitialized = true;
_applyDiff(activation.initialDiff);
} catch (error) {
session.dispose();
loadError = error.toString();
diagnostics.record(
type: RuntimeDiagnosticType.packageActivationError,
message: 'Lua game package activation failed',
error: error,
context: {'gameId': gameId},
);
debugPrint('Lua game load failed: $error');
}
}
GameResourceManager _createResourceManager() {
return GameResourceManager(
diagnostics: diagnostics,
maxCacheBytes: imageCacheMaxBytes,
maxCacheEntries: imageCacheMaxEntries,
maxConcurrentLoads: imageMaxConcurrentLoads,
);
}
RuntimeAudioManager _createAudioManager() {
return RuntimeAudioManager(
diagnostics: diagnostics,
maxSfxPoolSize: audioSfxPoolSize,
maxCacheBytes: audioCacheMaxBytes,
maxCacheEntries: audioCacheMaxEntries,
maxConcurrentLoads: audioMaxConcurrentLoads,
);
}
Map<String, Object?> _buildContext(GamePackage package) {
final display = package.manifest.display;
final viewport = RuntimeViewport.compute(
screenSize: size,
config: display.toViewportConfig(),
);
final locale = RuntimeLocaleResolver.resolve(
requested: _localeOverride ?? PlatformDispatcher.instance.locale,
defaultLocale: package.manifest.defaultLocale,
supportedLocales: package.manifest.supportedLocales,
);
return {
'screen': {'width': size.x, 'height': size.y},
'design': {'width': display.designWidth, 'height': display.designHeight},
'viewport': viewport.toMap(),
'seed': DateTime.now().millisecondsSinceEpoch,
'runtimeApiVersion': 1,
'gameId': package.manifest.gameId,
'gameVersion': package.manifest.version,
'locale': locale.toMap(),
};
}
void _emitEvent(RuntimeEvent event) {
final session = _session;
if (session == null || !session.isActive) {
return;
}
_events?.enqueue(event.withLifecycle(sessionId: session.id));
}
@override
void onScroll(PointerScrollInfo info) {
if (!_runtimeInitialized) {
return;
}
_renderTree.scrollListViewAt(
info.eventPosition.widget,
deltaX: info.scrollDelta.global.x,
deltaY: info.scrollDelta.global.y,
source: 'wheel',
);
}
@override
void onPanStart(DragStartInfo info) {
if (!_runtimeInitialized) {
_draggingListViewId = null;
return;
}
_draggingListViewId = _renderTree.listViewAt(info.eventPosition.widget);
final id = _draggingListViewId;
if (id != null) {
_renderTree.stopListViewVelocity(id);
info.handled = true;
}
}
@override
void onPanUpdate(DragUpdateInfo info) {
final id = _draggingListViewId;
if (!_runtimeInitialized || id == null) {
return;
}
final consumed = _renderTree.scrollListView(
id,
deltaX: -info.delta.global.x,
deltaY: -info.delta.global.y,
source: 'drag',
);
if (consumed) {
info.handled = true;
}
}
@override
void onPanEnd(DragEndInfo info) {
final id = _draggingListViewId;
if (id != null) {
_renderTree.setListViewVelocity(
id,
Vector2(-info.velocity.x, -info.velocity.y),
);
info.handled = true;
}
_draggingListViewId = null;
}
@override
void onPanCancel() {
_draggingListViewId = null;
}
@override
void update(double dt) {
super.update(dt);
if (_runtimeInitialized) {
_renderTree.updateListViewInertia(dt);
}
}
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
if (_runtimeInitialized) {
_applyViewportTransform();
_emitResizeEvent();
}
}
void _emitResizeEvent() {
final config = _viewportConfig;
if (config == null) {
return;
}
final viewport = RuntimeViewport.compute(screenSize: size, config: config);
_emitEvent(
RuntimeEvent(
type: RuntimeEventType.resize,
data: {
'screen': {'width': size.x, 'height': size.y},
'viewport': viewport.toMap(),
},
),
);
}
void _applyViewportTransform() {
final config = _viewportConfig;
if (config == null) {
return;
}
RuntimeViewport.apply(
_viewportRoot,
RuntimeViewport.compute(screenSize: size, config: config),
);
}
@override
void onRemove() {
_draggingListViewId = null;
_session?.beginDisposing();
_events?.dispose();
if (_runtimeInitialized) {
_commands.dispose();
_renderTree.clear();
_audio.dispose();
_resources.dispose();
}
_session?.dispose();
super.onRemove();
}
void _applyDiff(GameDiff diff) {
final session = _session;
if (session == null || !session.isActive) {
return;
}
try {
_renderTree
..apply(diff.render)
..apply(diff.ui);
} catch (error) {
diagnostics.record(
type: RuntimeDiagnosticType.diffApplyError,
message: 'Runtime diff apply failed',
error: error,
);
debugPrint('Runtime diff apply failed: $error');
return;
}
try {
_commands.executeAll(diff.commands);
} catch (error) {
diagnostics.record(
type: RuntimeDiagnosticType.commandError,
message: 'Runtime command execution failed',
error: error,
);
debugPrint('Runtime command execution failed: $error');
}
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import '../packages/game_package_repository.dart';
import '../scripting/lua_dardo_script_engine.dart';
import 'flame_lua_game.dart';
import 'runtime_options.dart';
class LuaGameWidget extends StatelessWidget {
const LuaGameWidget({
required this.gameId,
this.packageRepository,
this.serverUrl,
this.localeOverride,
this.runtimeOptions = const RuntimeOptions(),
super.key,
});
final String gameId;
final GamePackageRepository? packageRepository;
final Uri? serverUrl;
final Locale? localeOverride;
final RuntimeOptions runtimeOptions;
@override
Widget build(BuildContext context) {
return GameWidget(
game: FlameLuaGame(
scriptEngine: LuaDardoScriptEngine(),
scriptEngineFactory: LuaDardoScriptEngine.new,
packageRepository:
packageRepository ??
(serverUrl == null
? AssetGamePackageRepository(runtimeOptions: runtimeOptions)
: RemoteGamePackageRepository(
baseUri: serverUrl!,
runtimeOptions: runtimeOptions,
)),
gameId: gameId,
runtimeOptions: runtimeOptions,
localeOverride: localeOverride,
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'dart:ui' show Locale;
class RuntimeLocaleInfo {
const RuntimeLocaleInfo({
required this.requested,
required this.resolved,
required this.defaultLocale,
required this.supportedLocales,
required this.languageCode,
this.scriptCode,
this.countryCode,
});
final String requested;
final String resolved;
final String defaultLocale;
final List<String> supportedLocales;
final String languageCode;
final String? scriptCode;
final String? countryCode;
Map<String, Object?> toMap() {
return {
'requested': requested,
'resolved': resolved,
'default': defaultLocale,
'supported': supportedLocales,
'languageCode': languageCode,
if (scriptCode != null) 'scriptCode': scriptCode,
if (countryCode != null) 'countryCode': countryCode,
};
}
}
class RuntimeLocaleResolver {
const RuntimeLocaleResolver._();
static RuntimeLocaleInfo resolve({
required Locale requested,
required String defaultLocale,
required List<String> supportedLocales,
}) {
final requestedTag = normalizeTag(tagOf(requested));
final fallback = normalizeTag(defaultLocale);
final supported = supportedLocales.isEmpty
? [fallback]
: supportedLocales.map(normalizeTag).toList(growable: false);
final resolved = _resolveTag(
requestedTag: requestedTag,
fallback: fallback,
supported: supported,
);
return RuntimeLocaleInfo(
requested: requestedTag,
resolved: resolved,
defaultLocale: fallback,
supportedLocales: supported,
languageCode: requested.languageCode,
scriptCode: requested.scriptCode,
countryCode: requested.countryCode,
);
}
static Locale localeFromTag(String tag) {
final parts = normalizeTag(tag).split('-');
if (parts.isEmpty || parts.first.isEmpty) {
throw const FormatException('Locale tag must not be empty');
}
String? scriptCode;
String? countryCode;
for (final part in parts.skip(1)) {
if (part.length == 4 && scriptCode == null) {
scriptCode = part;
} else {
countryCode ??= part;
}
}
return Locale.fromSubtags(
languageCode: parts.first,
scriptCode: scriptCode,
countryCode: countryCode,
);
}
static String tagOf(Locale locale) {
final parts = [locale.languageCode];
final scriptCode = locale.scriptCode;
final countryCode = locale.countryCode;
if (scriptCode != null && scriptCode.isNotEmpty) {
parts.add(scriptCode);
}
if (countryCode != null && countryCode.isNotEmpty) {
parts.add(countryCode);
}
return normalizeTag(parts.join('-'));
}
static String normalizeTag(String tag) {
final normalized = tag.trim().replaceAll('_', '-');
if (normalized.isEmpty) {
throw const FormatException('Locale tag must not be empty');
}
final parts = normalized
.split('-')
.where((part) => part.isNotEmpty)
.toList(growable: false);
if (parts.isEmpty) {
throw const FormatException('Locale tag must not be empty');
}
if (!_isLocalePart(parts.first)) {
throw FormatException('Locale language code is invalid: ${parts.first}');
}
final result = <String>[parts.first.toLowerCase()];
for (final part in parts.skip(1)) {
if (!_isLocalePart(part)) {
throw FormatException('Locale tag part is invalid: $part');
}
if (part.length == 4) {
result.add(
'${part[0].toUpperCase()}${part.substring(1).toLowerCase()}',
);
} else if (part.length == 2 || part.length == 3) {
result.add(part.toUpperCase());
} else {
result.add(part.toLowerCase());
}
}
return result.join('-');
}
static String _resolveTag({
required String requestedTag,
required String fallback,
required List<String> supported,
}) {
final supportedSet = supported.toSet();
if (supportedSet.contains(requestedTag)) {
return requestedTag;
}
final requestedLanguage = requestedTag.split('-').first;
for (final candidate in supported) {
if (candidate.split('-').first == requestedLanguage) {
return candidate;
}
}
if (supportedSet.contains(fallback)) {
return fallback;
}
return supported.first;
}
static bool _isLocalePart(String value) {
return RegExp(r'^[A-Za-z0-9]{2,8}$').hasMatch(value);
}
}

View File

@@ -0,0 +1,7 @@
class RuntimeOptions {
const RuntimeOptions({this.runtimeLuaRoot = defaultRuntimeLuaRoot});
static const defaultRuntimeLuaRoot = 'assets/runtime/lua';
final String runtimeLuaRoot;
}

View File

@@ -0,0 +1,50 @@
class RuntimeAsyncGate {
RuntimeAsyncGate({bool initiallyClosed = false}) : _closed = initiallyClosed;
int _generation = 0;
bool _closed;
int get generation => _generation;
bool get isOpen => !_closed;
bool get isClosed => _closed;
RuntimeAsyncToken get token => RuntimeAsyncToken._(this, _generation);
RuntimeAsyncToken activate() {
_closed = false;
_generation++;
return token;
}
RuntimeAsyncToken advance() {
_generation++;
return token;
}
RuntimeAsyncToken close() {
_closed = true;
_generation++;
return token;
}
bool accepts(RuntimeAsyncToken token) {
return !_closed &&
identical(token._gate, this) &&
token.generation == _generation;
}
bool acceptsGeneration(int generation) {
return !_closed && generation == _generation;
}
}
class RuntimeAsyncToken {
const RuntimeAsyncToken._(this._gate, this.generation);
final RuntimeAsyncGate _gate;
final int generation;
bool get isAccepted => _gate.accepts(this);
}

View File

@@ -0,0 +1,77 @@
import 'dart:async' as async;
class RuntimeSerialQueue<T> {
RuntimeSerialQueue({required this.onItem, bool Function()? shouldContinue})
: _shouldContinue = shouldContinue;
final void Function(T item) onItem;
final bool Function()? _shouldContinue;
final List<T> _queue = [];
int _head = 0;
bool _disposed = false;
bool _draining = false;
int get pendingCount => _queue.length - _head;
bool get isDraining => _draining;
bool get isDisposed => _disposed;
void enqueue(T item) {
if (_disposed || !_canContinue()) {
return;
}
_queue.add(item);
_scheduleDrain();
}
void clear() {
_queue.clear();
_head = 0;
}
void dispose() {
_disposed = true;
clear();
}
void _scheduleDrain() {
if (_draining) {
return;
}
_draining = true;
async.scheduleMicrotask(_drain);
}
void _drain() {
try {
while (pendingCount > 0 && !_disposed && _canContinue()) {
onItem(_queue[_head++]);
_compactIfNeeded();
}
} finally {
_draining = false;
}
if (pendingCount > 0 && !_disposed && _canContinue()) {
_scheduleDrain();
}
}
void _compactIfNeeded() {
if (_head == 0) {
return;
}
if (_head == _queue.length) {
clear();
return;
}
if (_head < 32 || _head * 2 < _queue.length) {
return;
}
_queue.removeRange(0, _head);
_head = 0;
}
bool _canContinue() => _shouldContinue?.call() ?? true;
}

View File

@@ -0,0 +1,71 @@
enum RuntimeSessionState { created, loading, active, disposing, disposed }
class RuntimeSession {
RuntimeSession({required this.gameId}) : id = _nextId++;
static int _nextId = 1;
final int id;
final String gameId;
RuntimeSessionState _state = RuntimeSessionState.created;
RuntimeSessionState get state => _state;
bool get isLoading => _state == RuntimeSessionState.loading;
bool get isActive => _state == RuntimeSessionState.active;
bool get isDisposing => _state == RuntimeSessionState.disposing;
bool get isDisposed => _state == RuntimeSessionState.disposed;
bool get acceptsWork =>
_state != RuntimeSessionState.disposing &&
_state != RuntimeSessionState.disposed;
void beginLoading() {
_transition(
RuntimeSessionState.loading,
allowedFrom: const {RuntimeSessionState.created},
);
}
void activate() {
_transition(
RuntimeSessionState.active,
allowedFrom: const {
RuntimeSessionState.created,
RuntimeSessionState.loading,
},
);
}
void beginDisposing() {
if (_state == RuntimeSessionState.disposed ||
_state == RuntimeSessionState.disposing) {
return;
}
_state = RuntimeSessionState.disposing;
}
void dispose() {
_state = RuntimeSessionState.disposed;
}
bool accepts(int sessionId) => isActive && id == sessionId;
bool acceptsWorkFor(int sessionId) => acceptsWork && id == sessionId;
void _transition(
RuntimeSessionState next, {
required Set<RuntimeSessionState> allowedFrom,
}) {
if (_state == next) {
return;
}
if (!allowedFrom.contains(_state)) {
throw StateError('Invalid runtime session transition: $_state -> $next');
}
_state = next;
}
}

View File

@@ -0,0 +1,129 @@
import 'dart:async' as async;
class RuntimeTaskRegistry<T> {
RuntimeTaskRegistry({required this.cancelledValue});
final T cancelledValue;
final Set<RuntimeTask<T>> _tasks = {};
final Map<String, Set<RuntimeTask<T>>> _tasksByScope = {};
bool _disposed = false;
int get activeTaskCount => _tasks.length;
int scopedTaskCount(String scope) => _tasksByScope[scope]?.length ?? 0;
RuntimeTask<T> create({String? scope}) {
if (_disposed) {
throw StateError('RuntimeTaskRegistry has been disposed');
}
late final RuntimeTask<T> task;
task = RuntimeTask<T>._(
scope: scope,
cancelledValue: cancelledValue,
onComplete: _unregister,
);
_tasks.add(task);
if (scope != null) {
_tasksByScope.putIfAbsent(scope, () => {}).add(task);
}
return task;
}
void cancelScope(String scope) {
final tasks = _tasksByScope[scope]?.toList(growable: false) ?? const [];
for (final task in tasks) {
task.cancel();
}
}
void dispose() {
if (_disposed) {
return;
}
_disposed = true;
final tasks = _tasks.toList(growable: false);
for (final task in tasks) {
task.cancel();
}
_tasks.clear();
_tasksByScope.clear();
}
void _unregister(RuntimeTask<T> task) {
_tasks.remove(task);
final scope = task.scope;
if (scope == null) {
return;
}
final scopedTasks = _tasksByScope[scope];
scopedTasks?.remove(task);
if (scopedTasks != null && scopedTasks.isEmpty) {
_tasksByScope.remove(scope);
}
}
}
class RuntimeTask<T> {
RuntimeTask._({
required this.scope,
required this.cancelledValue,
required void Function(RuntimeTask<T> task) onComplete,
}) : _onComplete = onComplete;
final String? scope;
final T cancelledValue;
final void Function(RuntimeTask<T> task) _onComplete;
final async.Completer<T> _completer = async.Completer<T>();
final Set<async.Timer> _timers = {};
final List<void Function()> _cancelCallbacks = [];
bool _cancelled = false;
Future<T> get future => _completer.future;
bool get isCancelled => _cancelled;
void addTimer(async.Timer timer) {
if (_cancelled) {
timer.cancel();
return;
}
_timers.add(timer);
}
void removeTimer(async.Timer timer) {
_timers.remove(timer);
}
void addCancelCallback(void Function() callback) {
if (_cancelled) {
callback();
return;
}
_cancelCallbacks.add(callback);
}
void complete(T result) {
if (_completer.isCompleted) {
return;
}
_completer.complete(result);
_onComplete(this);
}
void cancel() {
if (_cancelled) {
return;
}
_cancelled = true;
for (final timer in _timers) {
timer.cancel();
}
_timers.clear();
for (final callback in _cancelCallbacks) {
callback();
}
_cancelCallbacks.clear();
complete(cancelledValue);
}
}

View File

@@ -0,0 +1,173 @@
import 'runtime_command.dart';
import 'runtime_node.dart';
import '../protocol/runtime_protocol.dart';
class NodeUpdate {
const NodeUpdate({required this.id, required this.props});
final String id;
final Map<String, Object?> props;
static NodeUpdate fromMap(Map<String, Object?> map) {
RuntimeProtocolSchema.ensureKnownKeys(
map,
allowed: RuntimeProtocolSchema.nodeUpdateFields,
context: 'NodeUpdate',
);
final id = map[RuntimeProtocolField.id];
if (id is! String || id.isEmpty) {
throw const FormatException('NodeUpdate.id must be a string');
}
final props = map[RuntimeProtocolField.props];
if (props is! Map) {
throw const FormatException('NodeUpdate.props must be a map');
}
final typedProps = Map<String, Object?>.from(props);
RuntimeProtocolSchema.ensureKnownKeys(
typedProps,
allowed: RuntimeProtocolSchema.nodePropsFields,
context: 'RuntimeNode.props',
);
return NodeUpdate(id: id, props: typedProps);
}
}
class NodeRemove {
const NodeRemove({required this.id});
final String id;
static NodeRemove fromValue(Object? value) {
if (value is String && value.isNotEmpty) {
return NodeRemove(id: value);
}
if (value is Map) {
RuntimeProtocolSchema.ensureKnownKeys(
value,
allowed: RuntimeProtocolSchema.nodeRemoveFields,
context: 'NodeRemove',
);
final id = value[RuntimeProtocolField.id];
if (id is String && id.isNotEmpty) {
return NodeRemove(id: id);
}
}
throw const FormatException('NodeRemove must be an id string or {id}');
}
}
class NodeDiff {
const NodeDiff({
this.creates = const [],
this.updates = const [],
this.removes = const [],
});
final List<RuntimeNode> creates;
final List<NodeUpdate> updates;
final List<NodeRemove> removes;
static NodeDiff empty = const NodeDiff();
static NodeDiff fromMap(Object? value) {
if (value == null) {
return NodeDiff.empty;
}
if (value is! Map) {
throw const FormatException('NodeDiff must be a map');
}
RuntimeProtocolSchema.ensureKnownKeys(
value,
allowed: RuntimeProtocolSchema.nodeDiffFields,
context: 'NodeDiff',
);
return NodeDiff(
creates: _readList(
value[RuntimeProtocolField.creates],
(item) => RuntimeNode.fromMap(Map<String, Object?>.from(item as Map)),
),
updates: _readList(
value[RuntimeProtocolField.updates],
(item) => NodeUpdate.fromMap(Map<String, Object?>.from(item as Map)),
),
removes: _readList(
value[RuntimeProtocolField.removes],
NodeRemove.fromValue,
),
);
}
static List<T> _readList<T>(Object? value, T Function(Object? value) mapper) {
if (value == null) {
return const [];
}
if (value is List) {
return value.map(mapper).toList(growable: false);
}
if (value is Map && value.isEmpty) {
return const [];
}
if (value is Map && value.keys.every(_isPositiveIntegerKey)) {
final entries = value.entries.toList()
..sort(
(a, b) => int.parse(
a.key.toString(),
).compareTo(int.parse(b.key.toString())),
);
return entries
.map((entry) => mapper(entry.value))
.toList(growable: false);
}
throw const FormatException('Diff field must be a list');
}
static bool _isPositiveIntegerKey(Object? key) {
final value = int.tryParse(key.toString());
return value != null && value > 0;
}
}
class GameDiff {
const GameDiff({
required this.render,
required this.ui,
required this.commands,
});
final NodeDiff render;
final NodeDiff ui;
final List<RuntimeCommand> commands;
static const empty = GameDiff(
render: NodeDiff(),
ui: NodeDiff(),
commands: [],
);
static GameDiff fromMap(Map<String, Object?> map) {
RuntimeProtocolSchema.ensureKnownKeys(
map,
allowed: RuntimeProtocolSchema.gameDiffFields,
context: 'GameDiff',
);
final commandsValue = map[RuntimeProtocolField.commands];
final commands = commandsValue == null
? const <RuntimeCommand>[]
: NodeDiff._readList(
commandsValue,
(item) =>
RuntimeCommand.fromMap(Map<String, Object?>.from(item as Map)),
);
return GameDiff(
render: NodeDiff.fromMap(map[RuntimeProtocolField.render]),
ui: NodeDiff.fromMap(map[RuntimeProtocolField.ui]),
commands: commands,
);
}
}

View File

@@ -0,0 +1,43 @@
import '../protocol/runtime_protocol.dart';
class RuntimeCommand {
const RuntimeCommand({
required this.type,
this.target,
this.payload = const {},
});
final String type;
final String? target;
final Map<String, Object?> payload;
static RuntimeCommand fromMap(Map<String, Object?> map) {
final type = map[RuntimeProtocolField.type];
if (type is! String || type.isEmpty) {
throw const FormatException('RuntimeCommand.type must be a string');
}
if (!RuntimeCommandType.isSupported(type)) {
throw FormatException('RuntimeCommand.type is unsupported: $type');
}
RuntimeProtocolSchema.ensureKnownKeys(
map,
allowed: RuntimeProtocolSchema.allowedCommandFields(type),
context: 'RuntimeCommand.$type',
);
final targetValue = map[RuntimeProtocolField.target];
if (targetValue != null && targetValue is! String) {
throw const FormatException('RuntimeCommand.target must be a string');
}
final payload = Map<String, Object?>.from(map)
..remove(RuntimeProtocolField.type)
..remove(RuntimeProtocolField.target);
return RuntimeCommand(
type: type,
target: targetValue as String?,
payload: payload,
);
}
}

View File

@@ -0,0 +1,64 @@
class RuntimeEvent {
const RuntimeEvent({
required this.type,
this.target,
this.handler,
this.x,
this.y,
this.data = const {},
this.sessionId,
this.scope,
this.targetEpoch,
this.scopeEpoch,
});
final String type;
final String? target;
final String? handler;
final double? x;
final double? y;
final Map<String, Object?> data;
/// Runtime-internal lifecycle session. Not exposed to Lua.
final int? sessionId;
/// Runtime-internal lifecycle scope. Not exposed to Lua.
final String? scope;
/// Runtime-internal target node epoch. Not exposed to Lua.
final int? targetEpoch;
/// Runtime-internal scope node epoch. Not exposed to Lua.
final int? scopeEpoch;
RuntimeEvent withLifecycle({
int? sessionId,
String? scope,
int? targetEpoch,
int? scopeEpoch,
}) {
return RuntimeEvent(
type: type,
target: target,
handler: handler,
x: x,
y: y,
data: data,
sessionId: sessionId ?? this.sessionId,
scope: scope ?? this.scope,
targetEpoch: targetEpoch ?? this.targetEpoch,
scopeEpoch: scopeEpoch ?? this.scopeEpoch,
);
}
Map<String, Object?> toMap() {
return {
'type': type,
if (target != null) 'target': target,
if (handler != null) 'handler': handler,
if (x != null) 'x': x,
if (y != null) 'y': y,
if (data.isNotEmpty) 'data': data,
};
}
}

View File

@@ -0,0 +1,572 @@
import 'package:flutter/material.dart';
import '../protocol/runtime_protocol.dart';
class RuntimeNode {
const RuntimeNode({
required this.id,
required this.type,
this.parent,
this.asset,
this.pressedAsset,
this.disabledAsset,
this.animation,
this.skin,
this.loop = true,
this.text,
this.x = 0,
this.y = 0,
this.width,
this.height,
this.paddingLeft = 0,
this.paddingTop = 0,
this.paddingRight = 0,
this.paddingBottom = 0,
this.anchor = RuntimeAnchorValue.topLeft,
this.layer = 0,
this.visible = true,
this.alpha = 1,
this.scale = 1,
this.rotation = 0,
this.color,
this.fontSize,
this.textAlign = RuntimeTextAlignValue.center,
this.radius,
this.strokeWidth,
this.value,
this.scrollX = 0,
this.scrollY = 0,
this.contentWidth,
this.contentHeight,
this.virtualized = false,
this.cacheExtent = 0,
this.inertia = true,
this.scrollbarThumbColor,
this.scrollbarTrackColor,
this.scrollbarThickness,
this.scrollbarVisible = true,
this.interactive = false,
this.onTap,
this.onScroll,
this.preset,
this.count,
this.duration,
this.speedMin,
this.speedMax,
this.gravityX,
this.gravityY,
this.spread,
this.colorTo,
this.radiusTo,
this.autoRemove = true,
this.fadeOut = true,
});
final String id;
final String type;
final String? parent;
final String? asset;
final String? pressedAsset;
final String? disabledAsset;
final String? animation;
final String? skin;
final bool loop;
final String? text;
final double x;
final double y;
final double? width;
final double? height;
final double paddingLeft;
final double paddingTop;
final double paddingRight;
final double paddingBottom;
final String anchor;
final int layer;
final bool visible;
final double alpha;
final double scale;
final double rotation;
final Color? color;
final double? fontSize;
final String textAlign;
final double? radius;
final double? strokeWidth;
final double? value;
final double scrollX;
final double scrollY;
final double? contentWidth;
final double? contentHeight;
final bool virtualized;
final double cacheExtent;
final bool inertia;
final Color? scrollbarThumbColor;
final Color? scrollbarTrackColor;
final double? scrollbarThickness;
final bool scrollbarVisible;
final bool interactive;
final String? onTap;
final String? onScroll;
final String? preset;
final int? count;
final double? duration;
final double? speedMin;
final double? speedMax;
final double? gravityX;
final double? gravityY;
final double? spread;
final Color? colorTo;
final double? radiusTo;
final bool autoRemove;
final bool fadeOut;
RuntimeNode copyWithProps(Map<String, Object?> props) {
RuntimeProtocolSchema.ensureKnownKeys(
props,
allowed: RuntimeProtocolSchema.nodePropsFields,
context: 'RuntimeNode.props',
);
final nextType = _stringProp(props, RuntimeProtocolField.type) ?? type;
if (!RuntimeNodeType.isSupported(nextType)) {
throw FormatException('RuntimeNode.type is unsupported: $nextType');
}
final nextAnchor =
_stringProp(props, RuntimeProtocolField.anchor) ?? anchor;
if (!RuntimeAnchorValue.isSupported(nextAnchor)) {
throw FormatException('RuntimeNode.anchor is unsupported: $nextAnchor');
}
final nextTextAlign =
_stringProp(props, RuntimeProtocolField.textAlign) ?? textAlign;
if (!RuntimeTextAlignValue.isSupported(nextTextAlign)) {
throw FormatException(
'RuntimeNode.textAlign is unsupported: $nextTextAlign',
);
}
final nextPreset =
_stringProp(props, RuntimeProtocolField.preset) ?? preset;
_validateParticlePreset(nextPreset);
final nextWidth = _doubleProp(props, RuntimeProtocolField.width) ?? width;
final nextHeight =
_doubleProp(props, RuntimeProtocolField.height) ?? height;
final nextContentWidth =
_doubleProp(props, RuntimeProtocolField.contentWidth) ?? contentWidth;
final nextContentHeight =
_doubleProp(props, RuntimeProtocolField.contentHeight) ?? contentHeight;
final nextPaddingLeft =
_nonNegativeDoubleProp(props, RuntimeProtocolField.paddingLeft) ??
paddingLeft;
final nextPaddingTop =
_nonNegativeDoubleProp(props, RuntimeProtocolField.paddingTop) ??
paddingTop;
final nextPaddingRight =
_nonNegativeDoubleProp(props, RuntimeProtocolField.paddingRight) ??
paddingRight;
final nextPaddingBottom =
_nonNegativeDoubleProp(props, RuntimeProtocolField.paddingBottom) ??
paddingBottom;
final nextViewportWidth = nextWidth == null
? null
: (nextWidth - nextPaddingLeft - nextPaddingRight)
.clamp(0.0, nextWidth)
.toDouble();
final nextViewportHeight = nextHeight == null
? null
: (nextHeight - nextPaddingTop - nextPaddingBottom)
.clamp(0.0, nextHeight)
.toDouble();
final nextScrollX = props.containsKey(RuntimeProtocolField.scrollX)
? _scrollProp(
props,
RuntimeProtocolField.scrollX,
contentExtent: nextContentWidth,
viewportExtent: nextViewportWidth,
)!
: _clampScroll(
scrollX,
contentExtent: nextContentWidth,
viewportExtent: nextViewportWidth,
);
final nextScrollY = props.containsKey(RuntimeProtocolField.scrollY)
? _scrollProp(
props,
RuntimeProtocolField.scrollY,
contentExtent: nextContentHeight,
viewportExtent: nextViewportHeight,
)!
: _clampScroll(
scrollY,
contentExtent: nextContentHeight,
viewportExtent: nextViewportHeight,
);
return RuntimeNode(
id: id,
type: nextType,
parent: _parentProp(props, currentParent: parent, nodeId: id),
asset: _stringProp(props, RuntimeProtocolField.asset) ?? asset,
pressedAsset:
_stringProp(props, RuntimeProtocolField.pressedAsset) ?? pressedAsset,
disabledAsset:
_stringProp(props, RuntimeProtocolField.disabledAsset) ??
disabledAsset,
animation:
_stringProp(props, RuntimeProtocolField.animation) ?? animation,
skin: _stringProp(props, RuntimeProtocolField.skin) ?? skin,
loop: _boolProp(props, RuntimeProtocolField.loop) ?? loop,
text: _stringProp(props, RuntimeProtocolField.text) ?? text,
x: _doubleProp(props, RuntimeProtocolField.x) ?? x,
y: _doubleProp(props, RuntimeProtocolField.y) ?? y,
width: nextWidth,
height: nextHeight,
paddingLeft: nextPaddingLeft,
paddingTop: nextPaddingTop,
paddingRight: nextPaddingRight,
paddingBottom: nextPaddingBottom,
anchor: nextAnchor,
layer: _intProp(props, RuntimeProtocolField.layer) ?? layer,
visible: _boolProp(props, RuntimeProtocolField.visible) ?? visible,
alpha: _doubleProp(props, RuntimeProtocolField.alpha) ?? alpha,
scale: _doubleProp(props, RuntimeProtocolField.scale) ?? scale,
rotation: _doubleProp(props, RuntimeProtocolField.rotation) ?? rotation,
color: _colorProp(props, RuntimeProtocolField.color) ?? color,
fontSize: _doubleProp(props, RuntimeProtocolField.fontSize) ?? fontSize,
textAlign: nextTextAlign,
radius: _doubleProp(props, RuntimeProtocolField.radius) ?? radius,
strokeWidth:
_doubleProp(props, RuntimeProtocolField.strokeWidth) ?? strokeWidth,
value: _normalizedValueProp(props, RuntimeProtocolField.value) ?? value,
scrollX: nextScrollX,
scrollY: nextScrollY,
contentWidth: nextContentWidth,
contentHeight: nextContentHeight,
virtualized:
_boolProp(props, RuntimeProtocolField.virtualized) ?? virtualized,
cacheExtent:
_nonNegativeDoubleProp(props, RuntimeProtocolField.cacheExtent) ??
cacheExtent,
inertia: _boolProp(props, RuntimeProtocolField.inertia) ?? inertia,
scrollbarThumbColor:
_colorProp(props, RuntimeProtocolField.scrollbarThumbColor) ??
scrollbarThumbColor,
scrollbarTrackColor:
_colorProp(props, RuntimeProtocolField.scrollbarTrackColor) ??
scrollbarTrackColor,
scrollbarThickness:
_nonNegativeDoubleProp(
props,
RuntimeProtocolField.scrollbarThickness,
) ??
scrollbarThickness,
scrollbarVisible:
_boolProp(props, RuntimeProtocolField.scrollbarVisible) ??
scrollbarVisible,
interactive:
_boolProp(props, RuntimeProtocolField.interactive) ?? interactive,
onTap: _stringProp(props, RuntimeProtocolField.onTap) ?? onTap,
onScroll: _stringProp(props, RuntimeProtocolField.onScroll) ?? onScroll,
preset: nextPreset,
count: _positiveIntProp(props, RuntimeProtocolField.count) ?? count,
duration:
_nonNegativeDoubleProp(props, RuntimeProtocolField.duration) ??
duration,
speedMin:
_nonNegativeDoubleProp(props, RuntimeProtocolField.speedMin) ??
speedMin,
speedMax:
_nonNegativeDoubleProp(props, RuntimeProtocolField.speedMax) ??
speedMax,
gravityX: _doubleProp(props, RuntimeProtocolField.gravityX) ?? gravityX,
gravityY: _doubleProp(props, RuntimeProtocolField.gravityY) ?? gravityY,
spread:
_nonNegativeDoubleProp(props, RuntimeProtocolField.spread) ?? spread,
colorTo: _colorProp(props, RuntimeProtocolField.colorTo) ?? colorTo,
radiusTo:
_nonNegativeDoubleProp(props, RuntimeProtocolField.radiusTo) ??
radiusTo,
autoRemove:
_boolProp(props, RuntimeProtocolField.autoRemove) ?? autoRemove,
fadeOut: _boolProp(props, RuntimeProtocolField.fadeOut) ?? fadeOut,
);
}
static RuntimeNode fromMap(Map<String, Object?> map) {
RuntimeProtocolSchema.ensureKnownKeys(
map,
allowed: RuntimeProtocolSchema.nodeFields,
context: 'RuntimeNode',
);
final type = _requiredString(map, RuntimeProtocolField.type);
if (!RuntimeNodeType.isSupported(type)) {
throw FormatException('RuntimeNode.type is unsupported: $type');
}
final anchor =
_stringProp(map, RuntimeProtocolField.anchor) ??
RuntimeAnchorValue.topLeft;
if (!RuntimeAnchorValue.isSupported(anchor)) {
throw FormatException('RuntimeNode.anchor is unsupported: $anchor');
}
final textAlign =
_stringProp(map, RuntimeProtocolField.textAlign) ??
RuntimeTextAlignValue.center;
if (!RuntimeTextAlignValue.isSupported(textAlign)) {
throw FormatException('RuntimeNode.textAlign is unsupported: $textAlign');
}
final preset = _stringProp(map, RuntimeProtocolField.preset);
_validateParticlePreset(preset);
return RuntimeNode(
id: _requiredString(map, RuntimeProtocolField.id),
type: type,
parent: _parentProp(
map,
currentParent: null,
nodeId: _requiredString(map, RuntimeProtocolField.id),
),
asset: _stringProp(map, RuntimeProtocolField.asset),
pressedAsset: _stringProp(map, RuntimeProtocolField.pressedAsset),
disabledAsset: _stringProp(map, RuntimeProtocolField.disabledAsset),
animation: _stringProp(map, RuntimeProtocolField.animation),
skin: _stringProp(map, RuntimeProtocolField.skin),
loop: _boolProp(map, RuntimeProtocolField.loop) ?? true,
text: _stringProp(map, RuntimeProtocolField.text),
x: _doubleProp(map, RuntimeProtocolField.x) ?? 0,
y: _doubleProp(map, RuntimeProtocolField.y) ?? 0,
width: _doubleProp(map, RuntimeProtocolField.width),
height: _doubleProp(map, RuntimeProtocolField.height),
paddingLeft:
_nonNegativeDoubleProp(map, RuntimeProtocolField.paddingLeft) ?? 0,
paddingTop:
_nonNegativeDoubleProp(map, RuntimeProtocolField.paddingTop) ?? 0,
paddingRight:
_nonNegativeDoubleProp(map, RuntimeProtocolField.paddingRight) ?? 0,
paddingBottom:
_nonNegativeDoubleProp(map, RuntimeProtocolField.paddingBottom) ?? 0,
anchor: anchor,
layer: _intProp(map, RuntimeProtocolField.layer) ?? 0,
visible: _boolProp(map, RuntimeProtocolField.visible) ?? true,
alpha: _doubleProp(map, RuntimeProtocolField.alpha) ?? 1,
scale: _doubleProp(map, RuntimeProtocolField.scale) ?? 1,
rotation: _doubleProp(map, RuntimeProtocolField.rotation) ?? 0,
color: _colorProp(map, RuntimeProtocolField.color),
fontSize: _doubleProp(map, RuntimeProtocolField.fontSize),
textAlign: textAlign,
radius: _doubleProp(map, RuntimeProtocolField.radius),
strokeWidth: _doubleProp(map, RuntimeProtocolField.strokeWidth),
value: _normalizedValueProp(map, RuntimeProtocolField.value),
scrollX:
_scrollProp(
map,
RuntimeProtocolField.scrollX,
contentExtent: _doubleProp(map, RuntimeProtocolField.contentWidth),
viewportExtent: _doubleProp(map, RuntimeProtocolField.width),
) ??
0,
scrollY:
_scrollProp(
map,
RuntimeProtocolField.scrollY,
contentExtent: _doubleProp(map, RuntimeProtocolField.contentHeight),
viewportExtent: _doubleProp(map, RuntimeProtocolField.height),
) ??
0,
contentWidth: _doubleProp(map, RuntimeProtocolField.contentWidth),
contentHeight: _doubleProp(map, RuntimeProtocolField.contentHeight),
virtualized: _boolProp(map, RuntimeProtocolField.virtualized) ?? false,
cacheExtent:
_nonNegativeDoubleProp(map, RuntimeProtocolField.cacheExtent) ?? 0,
inertia: _boolProp(map, RuntimeProtocolField.inertia) ?? true,
scrollbarThumbColor: _colorProp(
map,
RuntimeProtocolField.scrollbarThumbColor,
),
scrollbarTrackColor: _colorProp(
map,
RuntimeProtocolField.scrollbarTrackColor,
),
scrollbarThickness: _nonNegativeDoubleProp(
map,
RuntimeProtocolField.scrollbarThickness,
),
scrollbarVisible:
_boolProp(map, RuntimeProtocolField.scrollbarVisible) ?? true,
interactive: _boolProp(map, RuntimeProtocolField.interactive) ?? false,
onTap: _stringProp(map, RuntimeProtocolField.onTap),
onScroll: _stringProp(map, RuntimeProtocolField.onScroll),
preset: preset,
count: _positiveIntProp(map, RuntimeProtocolField.count),
duration: _nonNegativeDoubleProp(map, RuntimeProtocolField.duration),
speedMin: _nonNegativeDoubleProp(map, RuntimeProtocolField.speedMin),
speedMax: _nonNegativeDoubleProp(map, RuntimeProtocolField.speedMax),
gravityX: _doubleProp(map, RuntimeProtocolField.gravityX),
gravityY: _doubleProp(map, RuntimeProtocolField.gravityY),
spread: _nonNegativeDoubleProp(map, RuntimeProtocolField.spread),
colorTo: _colorProp(map, RuntimeProtocolField.colorTo),
radiusTo: _nonNegativeDoubleProp(map, RuntimeProtocolField.radiusTo),
autoRemove: _boolProp(map, RuntimeProtocolField.autoRemove) ?? true,
fadeOut: _boolProp(map, RuntimeProtocolField.fadeOut) ?? true,
);
}
static String _requiredString(Map<String, Object?> map, String key) {
final value = map[key];
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('RuntimeNode.$key must be a non-empty string');
}
static void _validateParticlePreset(String? preset) {
if (preset != null && !RuntimeParticlePresetValue.isSupported(preset)) {
throw FormatException('RuntimeNode.preset is unsupported: $preset');
}
}
static String? _stringProp(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is String) {
return value;
}
throw FormatException('RuntimeNode.$key must be a string');
}
static String? _parentProp(
Map<String, Object?> map, {
required String? currentParent,
required String nodeId,
}) {
if (!map.containsKey(RuntimeProtocolField.parent)) {
return currentParent;
}
final value = _stringProp(map, RuntimeProtocolField.parent);
if (value == null || value.isEmpty) {
return null;
}
if (value == nodeId) {
throw const FormatException('RuntimeNode.parent cannot reference itself');
}
return value;
}
static bool? _boolProp(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is bool) {
return value;
}
throw FormatException('RuntimeNode.$key must be a boolean');
}
static double? _doubleProp(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is num) {
return value.toDouble();
}
throw FormatException('RuntimeNode.$key must be a number');
}
static double? _normalizedValueProp(Map<String, Object?> map, String key) {
final value = _doubleProp(map, key);
if (value == null) {
return null;
}
if (value < 0 || value > 1) {
throw FormatException('RuntimeNode.$key must be between 0 and 1');
}
return value;
}
static double? _nonNegativeDoubleProp(Map<String, Object?> map, String key) {
final value = _doubleProp(map, key);
if (value == null) {
return null;
}
if (value < 0) {
throw FormatException('RuntimeNode.$key must be >= 0');
}
return value;
}
static double? _scrollProp(
Map<String, Object?> map,
String key, {
required double? contentExtent,
required double? viewportExtent,
}) {
final value = _doubleProp(map, key);
if (value == null) {
return null;
}
if (value < 0) {
throw FormatException('RuntimeNode.$key must be >= 0');
}
return _clampScroll(
value,
contentExtent: contentExtent,
viewportExtent: viewportExtent,
);
}
static double _clampScroll(
double value, {
required double? contentExtent,
required double? viewportExtent,
}) {
final maxScroll = (contentExtent ?? 0) - (viewportExtent ?? 0);
if (maxScroll <= 0) {
return 0;
}
return value.clamp(0, maxScroll).toDouble();
}
static int? _positiveIntProp(Map<String, Object?> map, String key) {
final value = _intProp(map, key);
if (value == null) {
return null;
}
if (value <= 0) {
throw FormatException('RuntimeNode.$key must be > 0');
}
return value;
}
static int? _intProp(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is num) {
return value.toInt();
}
throw FormatException('RuntimeNode.$key must be an integer');
}
static Color? _colorProp(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is! String || !value.startsWith('#')) {
throw FormatException('RuntimeNode.$key must be a hex color');
}
final hex = value.substring(1);
if (hex.length == 6) {
return Color(int.parse('ff$hex', radix: 16));
}
if (hex.length == 8) {
return Color(int.parse(hex, radix: 16));
}
throw FormatException('RuntimeNode.$key must be #RRGGBB or #AARRGGBB');
}
}

View File

@@ -0,0 +1,96 @@
import 'dart:io';
import 'package:flutter/services.dart';
import '../game/runtime_options.dart';
import 'game_package_manifest.dart';
class GamePackage {
const GamePackage.asset({
required this.rootPath,
required this.manifest,
this.runtimeLuaRoot = RuntimeOptions.defaultRuntimeLuaRoot,
}) : source = GamePackageSource.asset;
static const runtimeLuaPrefix = 'runtime:';
const GamePackage.file({
required this.rootPath,
required this.manifest,
this.runtimeLuaRoot = RuntimeOptions.defaultRuntimeLuaRoot,
}) : source = GamePackageSource.file;
final String rootPath;
final GamePackageSource source;
final GamePackageManifest manifest;
final String runtimeLuaRoot;
String get entryPath => _join(rootPath, manifest.entry);
bool get isAsset => source == GamePackageSource.asset;
Future<String> readText(String relativeOrAbsolutePath) async {
final runtimePath = _resolveRuntimeLuaPath(relativeOrAbsolutePath);
if (runtimePath != null) {
return rootBundle.loadString(runtimePath);
}
final path = _resolvePackagePath(relativeOrAbsolutePath);
if (isAsset) {
return rootBundle.loadString(path);
}
return File(path).readAsString();
}
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
final path = _resolvePackagePath(relativeOrAbsolutePath);
if (isAsset) {
return rootBundle.load(path);
}
final bytes = await File(path).readAsBytes();
return ByteData.sublistView(bytes);
}
String resolveResourcePath(String keyOrPath) {
final resource = manifest.resources[keyOrPath];
if (resource != null) {
return _join(rootPath, resource.path);
}
if (keyOrPath.startsWith(rootPath)) {
return keyOrPath;
}
if (keyOrPath.contains('/')) {
return _join(rootPath, keyOrPath);
}
return _join(_join(rootPath, manifest.assetsBase), keyOrPath);
}
String _resolvePackagePath(String relativeOrAbsolutePath) {
if (relativeOrAbsolutePath.startsWith(rootPath)) {
return relativeOrAbsolutePath;
}
return _join(rootPath, relativeOrAbsolutePath);
}
String? _resolveRuntimeLuaPath(String path) {
if (!path.startsWith(runtimeLuaPrefix)) {
return null;
}
final name = path.substring(runtimeLuaPrefix.length);
if (name.isEmpty || name.contains('/') || name.contains('..')) {
throw FormatException('Invalid runtime Lua module path: $path');
}
return _join(runtimeLuaRoot, name);
}
String _join(String left, String right) {
final normalizedLeft = left.endsWith('/')
? left.substring(0, left.length - 1)
: left;
final normalizedRight = right.startsWith('/') ? right.substring(1) : right;
return '$normalizedLeft/$normalizedRight';
}
}
enum GamePackageSource { asset, file }

View File

@@ -0,0 +1,231 @@
import '../audio/runtime_audio_manager.dart';
import '../models/game_diff.dart';
import '../resources/game_resource_manager.dart';
import '../scripting/script_engine.dart';
import 'game_package.dart';
import 'game_package_repository.dart';
import 'package_verifier.dart';
import 'stable_package_store.dart';
class PackageActivationController {
const PackageActivationController({
required this.repository,
required this.resources,
required this.scriptEngine,
this.audio,
this.runtimeApiVersion = 1,
this.store = const StablePackageStore(),
this.assetFallback = const AssetGamePackageRepository(),
this.resourceManagerFactory,
this.audioManagerFactory,
this.scriptEngineFactory,
});
final GamePackageRepository repository;
final GameResourceManager resources;
final ScriptEngine scriptEngine;
final RuntimeAudioManager? audio;
final int runtimeApiVersion;
final StablePackageStore store;
final GamePackageRepository assetFallback;
final GameResourceManager Function()? resourceManagerFactory;
final RuntimeAudioManager Function()? audioManagerFactory;
final ScriptEngine Function()? scriptEngineFactory;
Future<PackageActivationResult> activate({
required String gameId,
required Map<String, Object?> Function(GamePackage package) contextBuilder,
bool Function()? shouldContinue,
}) async {
final plan = await prepare(
gameId: gameId,
contextBuilder: contextBuilder,
shouldContinue: shouldContinue,
);
await commit(plan, shouldContinue: shouldContinue);
return PackageActivationResult.fromPlan(plan);
}
Future<PackageActivationPlan> prepare({
required String gameId,
required Map<String, Object?> Function(GamePackage package) contextBuilder,
bool Function()? shouldContinue,
}) async {
final verifier = PackageVerifier(runtimeApiVersion: runtimeApiVersion);
final candidates = await _candidatePackages(gameId, shouldContinue);
Object? lastError;
for (final candidate in candidates) {
try {
_ensureContinue(shouldContinue);
final plan = await _prepareCandidate(
candidate: candidate,
verifier: verifier,
contextBuilder: contextBuilder,
shouldContinue: shouldContinue,
);
return plan;
} catch (error) {
if (shouldContinue != null && !shouldContinue()) {
rethrow;
}
lastError = error;
}
}
throw StateError(
'No activatable package for $gameId. Last error: $lastError',
);
}
Future<void> commit(
PackageActivationPlan plan, {
bool Function()? shouldContinue,
}) async {
_ensureContinue(shouldContinue);
await store.markStable(plan.package);
_ensureContinue(shouldContinue);
}
Future<List<GamePackage>> _candidatePackages(
String gameId,
bool Function()? shouldContinue,
) async {
final candidates = <GamePackage>[];
try {
final package = await repository.load(gameId);
_ensureContinue(shouldContinue);
candidates.add(package);
} catch (_) {
// Continue with stable/fallback candidates.
}
_ensureContinue(shouldContinue);
final stable = await store.stablePackage(gameId);
if (stable != null && !_containsPackage(candidates, stable)) {
candidates.add(stable);
}
_ensureContinue(shouldContinue);
final previous = await store.previousStablePackage(gameId);
if (previous != null && !_containsPackage(candidates, previous)) {
candidates.add(previous);
}
_ensureContinue(shouldContinue);
final fallback = await assetFallback.load(gameId);
if (!_containsPackage(candidates, fallback)) {
candidates.add(fallback);
}
_ensureContinue(shouldContinue);
return candidates;
}
Future<PackageActivationPlan> _prepareCandidate({
required GamePackage candidate,
required PackageVerifier verifier,
required Map<String, Object?> Function(GamePackage package) contextBuilder,
required bool Function()? shouldContinue,
}) async {
final preparedResources = resourceManagerFactory?.call() ?? resources;
final preparedAudio = audioManagerFactory?.call() ?? audio;
final preparedScriptEngine = scriptEngineFactory?.call() ?? scriptEngine;
final ownsPreparedResources = preparedResources != resources;
final ownsPreparedAudio = preparedAudio != null && preparedAudio != audio;
try {
await verifier.verify(candidate);
_ensureContinue(shouldContinue);
await preparedResources.mount(candidate);
_ensureContinue(shouldContinue);
await preparedAudio?.mount(candidate);
_ensureContinue(shouldContinue);
await preparedScriptEngine.loadPackage(candidate);
_ensureContinue(shouldContinue);
final context = contextBuilder(candidate);
_ensureContinue(shouldContinue);
if (!preparedScriptEngine.smokeTest(context)) {
throw StateError('Lua package smoke_test returned false');
}
_ensureContinue(shouldContinue);
final diff = preparedScriptEngine.init(context);
_ensureContinue(shouldContinue);
return PackageActivationPlan(
package: candidate,
initialDiff: diff,
resources: preparedResources,
scriptEngine: preparedScriptEngine,
audio: preparedAudio,
);
} catch (_) {
if (ownsPreparedResources) {
preparedResources.dispose();
}
if (ownsPreparedAudio) {
preparedAudio.dispose();
}
rethrow;
}
}
void _ensureContinue(bool Function()? shouldContinue) {
if (shouldContinue != null && !shouldContinue()) {
throw StateError('Package activation cancelled');
}
}
bool _containsPackage(List<GamePackage> packages, GamePackage package) {
return packages.any(
(item) =>
item.source == package.source && item.rootPath == package.rootPath,
);
}
}
class PackageActivationPlan {
const PackageActivationPlan({
required this.package,
required this.initialDiff,
required this.resources,
required this.scriptEngine,
this.audio,
});
final GamePackage package;
final GameDiff initialDiff;
final GameResourceManager resources;
final ScriptEngine scriptEngine;
final RuntimeAudioManager? audio;
}
class PackageActivationResult {
const PackageActivationResult({
required this.package,
required this.initialDiff,
required this.resources,
required this.scriptEngine,
this.audio,
});
factory PackageActivationResult.fromPlan(PackageActivationPlan plan) {
return PackageActivationResult(
package: plan.package,
initialDiff: plan.initialDiff,
resources: plan.resources,
scriptEngine: plan.scriptEngine,
audio: plan.audio,
);
}
final GamePackage package;
final GameDiff initialDiff;
final GameResourceManager resources;
final ScriptEngine scriptEngine;
final RuntimeAudioManager? audio;
}

View File

@@ -0,0 +1,265 @@
import 'dart:convert';
import '../display/runtime_viewport.dart';
class GamePackageManifest {
const GamePackageManifest({
required this.gameId,
required this.name,
required this.version,
required this.runtimeApiVersion,
required this.entry,
required this.assetsBase,
this.defaultLocale = 'en',
this.supportedLocales = const ['en'],
this.display = const GameDisplayConfig(),
this.resources = const {},
this.modules = const {},
});
final String gameId;
final String name;
final String version;
final int runtimeApiVersion;
final String entry;
final String assetsBase;
final String defaultLocale;
final List<String> supportedLocales;
final GameDisplayConfig display;
final Map<String, GameResource> resources;
final Map<String, String> modules;
static GamePackageManifest fromJsonString(String source) {
return fromMap(jsonDecode(source) as Map<String, Object?>);
}
static GamePackageManifest fromMap(Map<String, Object?> map) {
final resourcesValue = map['resources'];
final resources = <String, GameResource>{};
if (resourcesValue is Map) {
for (final entry in resourcesValue.entries) {
if (entry.key is! String || entry.value is! Map) {
throw const FormatException('manifest.resources must be a map');
}
resources[entry.key as String] = GameResource.fromMap(
Map<String, Object?>.from(entry.value as Map),
);
}
}
final modulesValue = map['modules'];
final modules = <String, String>{};
if (modulesValue is Map) {
for (final entry in modulesValue.entries) {
if (entry.key is! String || entry.value is! String) {
throw const FormatException('manifest.modules must be a string map');
}
modules[entry.key as String] = entry.value as String;
}
}
final defaultLocale = (map['defaultLocale'] as String?) ?? 'en';
final supportedLocales = _stringList(
map,
'supportedLocales',
fallback: [defaultLocale],
);
if (!supportedLocales.contains(defaultLocale)) {
throw const FormatException(
'manifest.supportedLocales must include defaultLocale',
);
}
final displayValue = map['display'];
final display = displayValue == null
? const GameDisplayConfig()
: GameDisplayConfig.fromMap(
Map<String, Object?>.from(displayValue as Map),
);
return GamePackageManifest(
gameId: _string(map, 'gameId'),
name: _string(map, 'name'),
version: _string(map, 'version'),
runtimeApiVersion: _int(map, 'runtimeApiVersion'),
entry: _string(map, 'entry'),
assetsBase: (map['assetsBase'] as String?) ?? 'assets',
defaultLocale: defaultLocale,
supportedLocales: supportedLocales,
display: display,
resources: resources,
modules: modules,
);
}
static String _string(Map<String, Object?> map, String key) {
final value = map[key];
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('manifest.$key must be a non-empty string');
}
static List<String> _stringList(
Map<String, Object?> map,
String key, {
required List<String> fallback,
}) {
final value = map[key];
if (value == null) {
return fallback;
}
if (value is! List || value.isEmpty) {
throw FormatException('manifest.$key must be a non-empty string list');
}
final result = <String>[];
for (final item in value) {
if (item is! String || item.isEmpty) {
throw FormatException('manifest.$key must be a non-empty string list');
}
result.add(item);
}
return result;
}
static int _int(Map<String, Object?> map, String key) {
final value = map[key];
if (value is num) {
return value.toInt();
}
throw FormatException('manifest.$key must be an integer');
}
}
class GameDisplayConfig {
const GameDisplayConfig({
this.designWidth = 720,
this.designHeight = 720,
this.scaleMode = RuntimeScaleMode.fit,
});
final double designWidth;
final double designHeight;
final String scaleMode;
RuntimeViewportConfig toViewportConfig() {
return RuntimeViewportConfig(
designWidth: designWidth,
designHeight: designHeight,
scaleMode: scaleMode,
);
}
static GameDisplayConfig fromMap(Map<String, Object?> map) {
final designWidth = _number(map, 'designWidth', fallback: 720);
final designHeight = _number(map, 'designHeight', fallback: 720);
final scaleMode = (map['scaleMode'] as String?) ?? RuntimeScaleMode.fit;
if (designWidth <= 0 || designHeight <= 0) {
throw const FormatException('manifest.display design size must be > 0');
}
if (!RuntimeScaleMode.isSupported(scaleMode)) {
throw const FormatException('manifest.display.scaleMode is unsupported');
}
return GameDisplayConfig(
designWidth: designWidth,
designHeight: designHeight,
scaleMode: scaleMode,
);
}
static double _number(
Map<String, Object?> map,
String key, {
required double fallback,
}) {
final value = map[key];
if (value == null) {
return fallback;
}
if (value is num) {
return value.toDouble();
}
throw FormatException('manifest.display.$key must be a number');
}
}
class GameResource {
const GameResource({
required this.type,
required this.path,
this.preload = GameResourcePreload.required,
this.group,
this.atlas,
this.skeleton,
});
final String type;
final String path;
final String preload;
final String? group;
final String? atlas;
final String? skeleton;
static GameResource fromMap(Map<String, Object?> map) {
final type = map['type'];
final path = map['path'];
final atlas = map['atlas'];
final skeleton = map['skeleton'];
if (type is! String || type.isEmpty) {
throw const FormatException('resource.type must be a non-empty string');
}
if (!GameResourceType.isSupported(type)) {
throw const FormatException('resource.type is unsupported');
}
if (type == GameResourceType.spine) {
if (atlas is! String || atlas.isEmpty) {
throw const FormatException(
'spine resource.atlas must be a non-empty string',
);
}
if (skeleton is! String || skeleton.isEmpty) {
throw const FormatException(
'spine resource.skeleton must be a non-empty string',
);
}
} else if (path is! String || path.isEmpty) {
throw const FormatException('resource.path must be a non-empty string');
}
final preload = map['preload'] as String? ?? GameResourcePreload.required;
if (!GameResourcePreload.isSupported(preload)) {
throw const FormatException('resource.preload is unsupported');
}
final group = map['group'];
if (group != null && (group is! String || group.isEmpty)) {
throw const FormatException('resource.group must be a non-empty string');
}
return GameResource(
type: type,
path: path as String? ?? '',
preload: preload,
group: group as String?,
atlas: atlas as String?,
skeleton: skeleton as String?,
);
}
}
abstract final class GameResourceType {
static const image = 'image';
static const audio = 'audio';
static const spine = 'spine';
static bool isSupported(String value) {
return value == image || value == audio || value == spine;
}
}
abstract final class GameResourcePreload {
static const required = 'required';
static const lazy = 'lazy';
static const optional = 'optional';
static bool isSupported(String value) {
return value == required || value == lazy || value == optional;
}
}

View File

@@ -0,0 +1,226 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import '../game/runtime_options.dart';
import 'game_package.dart';
import 'game_package_manifest.dart';
import 'package_verifier.dart';
import 'stable_package_store.dart';
abstract interface class GamePackageRepository {
Future<GamePackage> load(String gameId);
}
class AssetGamePackageRepository implements GamePackageRepository {
const AssetGamePackageRepository({
this.basePath = 'assets/games',
this.runtimeOptions = const RuntimeOptions(),
});
final String basePath;
final RuntimeOptions runtimeOptions;
@override
Future<GamePackage> load(String gameId) async {
final root = '$basePath/$gameId';
final source = await rootBundle.loadString('$root/manifest.json');
return GamePackage.asset(
rootPath: root,
manifest: GamePackageManifest.fromJsonString(source),
runtimeLuaRoot: runtimeOptions.runtimeLuaRoot,
);
}
}
class RemoteGamePackageRepository implements GamePackageRepository {
RemoteGamePackageRepository({
required this.baseUri,
this.runtimeApiVersion = 1,
this.runtimeOptions = const RuntimeOptions(),
GamePackageRepository? fallback,
StablePackageStore? store,
http.Client? client,
}) : fallback =
fallback ??
AssetGamePackageRepository(runtimeOptions: runtimeOptions),
store = store ?? StablePackageStore(runtimeOptions: runtimeOptions),
_client = client;
final Uri baseUri;
final int runtimeApiVersion;
final RuntimeOptions runtimeOptions;
final GamePackageRepository fallback;
final StablePackageStore store;
final http.Client? _client;
@override
Future<GamePackage> load(String gameId) async {
final verifier = PackageVerifier(runtimeApiVersion: runtimeApiVersion);
final client = _client ?? http.Client();
final shouldCloseClient = _client == null;
try {
final package = await _loadRemoteCandidate(client, gameId);
await verifier.verify(package);
return package;
} catch (_) {
final stable = await store.stablePackage(gameId);
if (stable != null) {
try {
await verifier.verify(stable);
return stable;
} catch (_) {
final previous = await store.previousStablePackage(gameId);
if (previous != null) {
try {
await verifier.verify(previous);
return previous;
} catch (_) {
// Fall through to bundled fallback.
}
}
}
}
return fallback.load(gameId);
} finally {
if (shouldCloseClient) {
client.close();
}
}
}
Future<GamePackage> _loadRemoteCandidate(
http.Client client,
String gameId,
) async {
final remoteManifest = await _fetchRemoteManifest(client, gameId);
if (remoteManifest.gameId != gameId) {
throw const FormatException('Remote manifest gameId mismatch');
}
final packageRoot = await _downloadAndExtract(
client,
gameId,
remoteManifest,
);
final manifestFile = File(p.join(packageRoot.path, 'manifest.json'));
final packageManifest = GamePackageManifest.fromJsonString(
await manifestFile.readAsString(),
);
if (packageManifest.gameId != gameId) {
throw const FormatException('Package manifest gameId mismatch');
}
if (packageManifest.version != remoteManifest.version) {
throw const FormatException('Package manifest version mismatch');
}
return GamePackage.file(
rootPath: packageRoot.path,
manifest: packageManifest,
runtimeLuaRoot: runtimeOptions.runtimeLuaRoot,
);
}
Future<RemotePackageManifest> _fetchRemoteManifest(
http.Client client,
String gameId,
) async {
final uri = baseUri.resolve('$gameId/remote_manifest.json');
final response = await client.get(uri);
if (response.statusCode != 200) {
throw HttpException(
'Remote manifest failed: ${response.statusCode}',
uri: uri,
);
}
return RemotePackageManifest.fromMap(
jsonDecode(response.body) as Map<String, Object?>,
);
}
Future<Directory> _downloadAndExtract(
http.Client client,
String gameId,
RemotePackageManifest manifest,
) async {
final packageBytes = await _downloadPackage(client, manifest.packageUrl);
_verifySha256(packageBytes, manifest.sha256);
final packageRoot = await store.versionDirectory(gameId, manifest.version);
if (packageRoot.existsSync()) {
packageRoot.deleteSync(recursive: true);
}
packageRoot.createSync(recursive: true);
final archive = ZipDecoder().decodeBytes(packageBytes);
for (final file in archive.files) {
final targetPath = p.normalize(p.join(packageRoot.path, file.name));
if (!p.isWithin(packageRoot.path, targetPath) &&
targetPath != packageRoot.path) {
throw const FormatException('Unsafe zip entry path');
}
if (file.isFile) {
File(targetPath)
..createSync(recursive: true)
..writeAsBytesSync(file.content as List<int>);
} else {
Directory(targetPath).createSync(recursive: true);
}
}
return packageRoot;
}
Future<List<int>> _downloadPackage(http.Client client, Uri uri) async {
final response = await client.get(uri);
if (response.statusCode != 200) {
throw HttpException(
'Package download failed: ${response.statusCode}',
uri: uri,
);
}
return response.bodyBytes;
}
void _verifySha256(List<int> bytes, String expected) {
final actual = sha256.convert(bytes).toString();
if (actual != expected) {
throw const FormatException('Package sha256 mismatch');
}
}
}
class RemotePackageManifest {
const RemotePackageManifest({
required this.gameId,
required this.version,
required this.packageUrl,
required this.sha256,
});
final String gameId;
final String version;
final Uri packageUrl;
final String sha256;
static RemotePackageManifest fromMap(Map<String, Object?> map) {
return RemotePackageManifest(
gameId: _string(map, 'gameId'),
version: _string(map, 'version'),
packageUrl: Uri.parse(_string(map, 'packageUrl')),
sha256: _string(map, 'sha256'),
);
}
static String _string(Map<String, Object?> map, String key) {
final value = map[key];
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('remote_manifest.$key must be a non-empty string');
}
}

View File

@@ -0,0 +1,117 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'game_package.dart';
import 'game_package_manifest.dart';
class PackageVerifier {
const PackageVerifier({required this.runtimeApiVersion});
final int runtimeApiVersion;
Future<void> verify(GamePackage package) async {
_verifyManifest(package);
await _verifyEntry(package);
await _verifyDeclaredModules(package);
await _verifyDeclaredResources(package);
}
void _verifyManifest(GamePackage package) {
final manifest = package.manifest;
if (manifest.runtimeApiVersion > runtimeApiVersion) {
throw FormatException(
'Package runtimeApiVersion ${manifest.runtimeApiVersion} is newer than runtime $runtimeApiVersion',
);
}
if (manifest.gameId.isEmpty ||
manifest.version.isEmpty ||
manifest.entry.isEmpty) {
throw const FormatException('Package manifest is incomplete');
}
}
Future<void> _verifyEntry(GamePackage package) async {
final script = await package.readText(package.manifest.entry);
if (!script.contains('function init')) {
throw const FormatException('Lua package must define function init(ctx)');
}
if (!script.contains('function on_event')) {
throw const FormatException(
'Lua package must define function on_event(event)',
);
}
if (!script.contains('function smoke_test')) {
throw const FormatException(
'Lua package must define function smoke_test(ctx)',
);
}
}
Future<void> _verifyDeclaredModules(GamePackage package) async {
for (final entry in package.manifest.modules.entries) {
final name = entry.key;
final path = entry.value;
if (!_isSafeModuleName(name)) {
throw FormatException('Unsafe Lua module name: $name');
}
if (!_isSafeModulePath(path)) {
throw FormatException(
'Lua module path must be scripts/*.lua or runtime:*.lua: $path',
);
}
await package.readText(path);
}
}
bool _isSafeModuleName(String value) {
return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) &&
!value.contains('..') &&
!value.startsWith('.') &&
!value.endsWith('.');
}
bool _isSafeModulePath(String path) {
if (path.startsWith(GamePackage.runtimeLuaPrefix)) {
final name = path.substring(GamePackage.runtimeLuaPrefix.length);
return name.isNotEmpty &&
name.endsWith('.lua') &&
!name.contains('/') &&
!name.contains('..');
}
return path.startsWith('scripts/') &&
path.endsWith('.lua') &&
!path.contains('..');
}
Future<void> _verifyDeclaredResources(GamePackage package) async {
for (final resource in package.manifest.resources.values) {
final paths = _resourcePaths(resource);
for (final path in paths) {
if (path.contains('..')) {
throw const FormatException('Resource path must not contain ..');
}
if (package.isAsset) {
await package.readBytes(path);
continue;
}
final root = p.normalize(package.rootPath);
final target = p.normalize(p.join(root, path));
if (!p.isWithin(root, target) && target != root) {
throw const FormatException('Resource path escapes package root');
}
if (!File(target).existsSync()) {
throw FormatException('Missing declared resource: $path');
}
}
}
}
Iterable<String> _resourcePaths(GameResource resource) {
if (resource.type == GameResourceType.spine) {
return [resource.atlas!, resource.skeleton!];
}
return [resource.path];
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../game/runtime_options.dart';
import 'game_package.dart';
import 'game_package_manifest.dart';
class StablePackageStore {
const StablePackageStore({
RuntimeOptions runtimeOptions = const RuntimeOptions(),
}) : _runtimeOptions = runtimeOptions;
final RuntimeOptions _runtimeOptions;
Future<Directory> cacheRoot() async {
final support = await getApplicationSupportDirectory();
final root = Directory(p.join(support.path, 'flame_lua_packages'));
root.createSync(recursive: true);
return root;
}
Future<Directory> versionDirectory(String gameId, String version) async {
final root = await cacheRoot();
return Directory(p.join(root.path, gameId, version));
}
Future<void> markStable(GamePackage package) async {
if (package.source != GamePackageSource.file) {
return;
}
final marker = await _markerFile(package.manifest.gameId);
marker.createSync(recursive: true);
final previous = await stablePackage(package.manifest.gameId);
final data = {
'current': package.rootPath,
if (previous != null && previous.rootPath != package.rootPath)
'previous': previous.rootPath,
};
marker.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data));
}
Future<GamePackage?> stablePackage(String gameId) async {
final marker = await _markerFile(gameId);
if (!marker.existsSync()) {
return null;
}
final data =
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
return _packageFromPath(data['current']);
}
Future<GamePackage?> previousStablePackage(String gameId) async {
final marker = await _markerFile(gameId);
if (!marker.existsSync()) {
return null;
}
final data =
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
return _packageFromPath(data['previous']);
}
Future<File> _markerFile(String gameId) async {
final root = await cacheRoot();
return File(p.join(root.path, gameId, 'stable.json'));
}
GamePackage? _packageFromPath(Object? pathValue) {
if (pathValue is! String || pathValue.isEmpty) {
return null;
}
final manifestFile = File(p.join(pathValue, 'manifest.json'));
if (!manifestFile.existsSync()) {
return null;
}
return GamePackage.file(
rootPath: pathValue,
manifest: GamePackageManifest.fromJsonString(
manifestFile.readAsStringSync(),
),
runtimeLuaRoot: _runtimeOptions.runtimeLuaRoot,
);
}
}

View File

@@ -0,0 +1,490 @@
class RuntimeNodeType {
const RuntimeNodeType._();
static const panel = 'panel';
static const button = 'button';
static const text = 'text';
static const circle = 'circle';
static const rect = 'rect';
static const line = 'line';
static const progress = 'progress';
static const listView = 'listView';
static const sprite = 'sprite';
static const image = 'image';
static const spine = 'spine';
static const particle = 'particle';
static const all = {
panel,
button,
text,
circle,
rect,
line,
progress,
listView,
sprite,
image,
spine,
particle,
};
static bool isSupported(String value) => all.contains(value);
}
class RuntimeAnchorValue {
const RuntimeAnchorValue._();
static const center = 'center';
static const topLeft = 'topLeft';
static const topRight = 'topRight';
static const bottomLeft = 'bottomLeft';
static const bottomRight = 'bottomRight';
static const all = {center, topLeft, topRight, bottomLeft, bottomRight};
static bool isSupported(String value) => all.contains(value);
}
class RuntimeTextAlignValue {
const RuntimeTextAlignValue._();
static const left = 'left';
static const center = 'center';
static const right = 'right';
static const all = {left, center, right};
static bool isSupported(String value) => all.contains(value);
}
class RuntimeParticlePresetValue {
const RuntimeParticlePresetValue._();
static const burst = 'burst';
static const trail = 'trail';
static const snow = 'snow';
static const confetti = 'confetti';
static const all = {burst, trail, snow, confetti};
static bool isSupported(String value) => all.contains(value);
}
class RuntimeEventType {
const RuntimeEventType._();
static const tap = 'tap';
static const animationDone = 'animation_done';
static const resize = 'resize';
static const scroll = 'scroll';
}
class RuntimeCommandType {
const RuntimeCommandType._();
static const movePath = 'move_path';
static const moveTo = 'move_to';
static const fadeTo = 'fade_to';
static const scaleTo = 'scale_to';
static const rotateTo = 'rotate_to';
static const removeNode = 'remove_node';
static const sequence = 'sequence';
static const parallel = 'parallel';
static const delay = 'delay';
static const toast = 'toast';
static const playSound = 'play_sound';
static const playBgm = 'play_bgm';
static const pauseBgm = 'pause_bgm';
static const resumeBgm = 'resume_bgm';
static const stopBgm = 'stop_bgm';
static const preloadResources = 'preload_resources';
static const evictResources = 'evict_resources';
static const cancelCommands = 'cancel_commands';
static const playSpineAnimation = 'play_spine_animation';
static const copyText = 'copy_text';
static const all = {
movePath,
moveTo,
fadeTo,
scaleTo,
rotateTo,
removeNode,
sequence,
parallel,
delay,
toast,
playSound,
playBgm,
pauseBgm,
resumeBgm,
stopBgm,
preloadResources,
evictResources,
cancelCommands,
playSpineAnimation,
copyText,
};
static bool isSupported(String value) => all.contains(value);
}
class RuntimeProtocolField {
const RuntimeProtocolField._();
static const id = 'id';
static const type = 'type';
static const target = 'target';
static const parent = 'parent';
static const asset = 'asset';
static const pressedAsset = 'pressedAsset';
static const disabledAsset = 'disabledAsset';
static const animation = 'animation';
static const skin = 'skin';
static const loop = 'loop';
static const text = 'text';
static const x = 'x';
static const y = 'y';
static const width = 'width';
static const height = 'height';
static const paddingLeft = 'paddingLeft';
static const paddingTop = 'paddingTop';
static const paddingRight = 'paddingRight';
static const paddingBottom = 'paddingBottom';
static const anchor = 'anchor';
static const layer = 'layer';
static const visible = 'visible';
static const alpha = 'alpha';
static const scale = 'scale';
static const rotation = 'rotation';
static const color = 'color';
static const fontSize = 'fontSize';
static const textAlign = 'textAlign';
static const radius = 'radius';
static const strokeWidth = 'strokeWidth';
static const value = 'value';
static const scrollX = 'scrollX';
static const scrollY = 'scrollY';
static const contentWidth = 'contentWidth';
static const contentHeight = 'contentHeight';
static const virtualized = 'virtualized';
static const cacheExtent = 'cacheExtent';
static const inertia = 'inertia';
static const scrollbarThumbColor = 'scrollbarThumbColor';
static const scrollbarTrackColor = 'scrollbarTrackColor';
static const scrollbarThickness = 'scrollbarThickness';
static const scrollbarVisible = 'scrollbarVisible';
static const interactive = 'interactive';
static const onTap = 'onTap';
static const onScroll = 'onScroll';
static const props = 'props';
static const creates = 'creates';
static const updates = 'updates';
static const removes = 'removes';
static const render = 'render';
static const ui = 'ui';
static const commands = 'commands';
static const path = 'path';
static const duration = 'duration';
static const angle = 'angle';
static const message = 'message';
static const name = 'name';
static const volume = 'volume';
static const channel = 'channel';
static const group = 'group';
static const commandGroup = 'commandGroup';
static const scope = 'scope';
static const onComplete = 'onComplete';
static const failOnError = 'failOnError';
static const track = 'track';
static const queue = 'queue';
static const delay = 'delay';
static const preset = 'preset';
static const count = 'count';
static const speedMin = 'speedMin';
static const speedMax = 'speedMax';
static const gravityX = 'gravityX';
static const gravityY = 'gravityY';
static const spread = 'spread';
static const colorTo = 'colorTo';
static const radiusTo = 'radiusTo';
static const autoRemove = 'autoRemove';
static const fadeOut = 'fadeOut';
}
class RuntimeProtocolSchema {
const RuntimeProtocolSchema._();
static const nodeFields = {
RuntimeProtocolField.id,
RuntimeProtocolField.type,
RuntimeProtocolField.parent,
RuntimeProtocolField.asset,
RuntimeProtocolField.pressedAsset,
RuntimeProtocolField.disabledAsset,
RuntimeProtocolField.animation,
RuntimeProtocolField.skin,
RuntimeProtocolField.loop,
RuntimeProtocolField.text,
RuntimeProtocolField.x,
RuntimeProtocolField.y,
RuntimeProtocolField.width,
RuntimeProtocolField.height,
RuntimeProtocolField.paddingLeft,
RuntimeProtocolField.paddingTop,
RuntimeProtocolField.paddingRight,
RuntimeProtocolField.paddingBottom,
RuntimeProtocolField.anchor,
RuntimeProtocolField.layer,
RuntimeProtocolField.visible,
RuntimeProtocolField.alpha,
RuntimeProtocolField.scale,
RuntimeProtocolField.rotation,
RuntimeProtocolField.color,
RuntimeProtocolField.fontSize,
RuntimeProtocolField.textAlign,
RuntimeProtocolField.radius,
RuntimeProtocolField.strokeWidth,
RuntimeProtocolField.value,
RuntimeProtocolField.scrollX,
RuntimeProtocolField.scrollY,
RuntimeProtocolField.contentWidth,
RuntimeProtocolField.contentHeight,
RuntimeProtocolField.virtualized,
RuntimeProtocolField.cacheExtent,
RuntimeProtocolField.inertia,
RuntimeProtocolField.scrollbarThumbColor,
RuntimeProtocolField.scrollbarTrackColor,
RuntimeProtocolField.scrollbarThickness,
RuntimeProtocolField.scrollbarVisible,
RuntimeProtocolField.interactive,
RuntimeProtocolField.onTap,
RuntimeProtocolField.onScroll,
RuntimeProtocolField.preset,
RuntimeProtocolField.count,
RuntimeProtocolField.duration,
RuntimeProtocolField.speedMin,
RuntimeProtocolField.speedMax,
RuntimeProtocolField.gravityX,
RuntimeProtocolField.gravityY,
RuntimeProtocolField.spread,
RuntimeProtocolField.colorTo,
RuntimeProtocolField.radiusTo,
RuntimeProtocolField.autoRemove,
RuntimeProtocolField.fadeOut,
};
static const nodeUpdateFields = {
RuntimeProtocolField.id,
RuntimeProtocolField.props,
};
static const nodeRemoveFields = {RuntimeProtocolField.id};
static const nodePropsFields = {
RuntimeProtocolField.type,
RuntimeProtocolField.parent,
RuntimeProtocolField.asset,
RuntimeProtocolField.pressedAsset,
RuntimeProtocolField.disabledAsset,
RuntimeProtocolField.animation,
RuntimeProtocolField.skin,
RuntimeProtocolField.loop,
RuntimeProtocolField.text,
RuntimeProtocolField.x,
RuntimeProtocolField.y,
RuntimeProtocolField.width,
RuntimeProtocolField.height,
RuntimeProtocolField.paddingLeft,
RuntimeProtocolField.paddingTop,
RuntimeProtocolField.paddingRight,
RuntimeProtocolField.paddingBottom,
RuntimeProtocolField.anchor,
RuntimeProtocolField.layer,
RuntimeProtocolField.visible,
RuntimeProtocolField.alpha,
RuntimeProtocolField.scale,
RuntimeProtocolField.rotation,
RuntimeProtocolField.color,
RuntimeProtocolField.fontSize,
RuntimeProtocolField.textAlign,
RuntimeProtocolField.radius,
RuntimeProtocolField.strokeWidth,
RuntimeProtocolField.value,
RuntimeProtocolField.scrollX,
RuntimeProtocolField.scrollY,
RuntimeProtocolField.contentWidth,
RuntimeProtocolField.contentHeight,
RuntimeProtocolField.virtualized,
RuntimeProtocolField.cacheExtent,
RuntimeProtocolField.inertia,
RuntimeProtocolField.scrollbarThumbColor,
RuntimeProtocolField.scrollbarTrackColor,
RuntimeProtocolField.scrollbarThickness,
RuntimeProtocolField.scrollbarVisible,
RuntimeProtocolField.interactive,
RuntimeProtocolField.onTap,
RuntimeProtocolField.onScroll,
RuntimeProtocolField.preset,
RuntimeProtocolField.count,
RuntimeProtocolField.duration,
RuntimeProtocolField.speedMin,
RuntimeProtocolField.speedMax,
RuntimeProtocolField.gravityX,
RuntimeProtocolField.gravityY,
RuntimeProtocolField.spread,
RuntimeProtocolField.colorTo,
RuntimeProtocolField.radiusTo,
RuntimeProtocolField.autoRemove,
RuntimeProtocolField.fadeOut,
};
static const nodeDiffFields = {
RuntimeProtocolField.creates,
RuntimeProtocolField.updates,
RuntimeProtocolField.removes,
};
static const gameDiffFields = {
RuntimeProtocolField.render,
RuntimeProtocolField.ui,
RuntimeProtocolField.commands,
};
static const commandEnvelopeFields = {
RuntimeProtocolField.type,
RuntimeProtocolField.target,
};
static const commandCommonPayloadFields = {
RuntimeProtocolField.id,
RuntimeProtocolField.group,
RuntimeProtocolField.commandGroup,
RuntimeProtocolField.scope,
RuntimeProtocolField.onComplete,
};
static const commandPayloadFieldsByType = {
RuntimeCommandType.movePath: {
...commandCommonPayloadFields,
RuntimeProtocolField.path,
RuntimeProtocolField.duration,
},
RuntimeCommandType.moveTo: {
...commandCommonPayloadFields,
RuntimeProtocolField.x,
RuntimeProtocolField.y,
RuntimeProtocolField.duration,
},
RuntimeCommandType.fadeTo: {
...commandCommonPayloadFields,
RuntimeProtocolField.alpha,
RuntimeProtocolField.duration,
},
RuntimeCommandType.scaleTo: {
...commandCommonPayloadFields,
RuntimeProtocolField.scale,
RuntimeProtocolField.duration,
},
RuntimeCommandType.rotateTo: {
...commandCommonPayloadFields,
RuntimeProtocolField.angle,
RuntimeProtocolField.duration,
},
RuntimeCommandType.removeNode: commandCommonPayloadFields,
RuntimeCommandType.sequence: {
...commandCommonPayloadFields,
RuntimeProtocolField.commands,
},
RuntimeCommandType.parallel: {
...commandCommonPayloadFields,
RuntimeProtocolField.commands,
},
RuntimeCommandType.delay: {
...commandCommonPayloadFields,
RuntimeProtocolField.duration,
},
RuntimeCommandType.toast: {
...commandCommonPayloadFields,
RuntimeProtocolField.text,
RuntimeProtocolField.message,
RuntimeProtocolField.duration,
},
RuntimeCommandType.playSound: {
...commandCommonPayloadFields,
RuntimeProtocolField.asset,
RuntimeProtocolField.name,
RuntimeProtocolField.volume,
},
RuntimeCommandType.playBgm: {
...commandCommonPayloadFields,
RuntimeProtocolField.asset,
RuntimeProtocolField.name,
RuntimeProtocolField.volume,
RuntimeProtocolField.channel,
RuntimeProtocolField.loop,
},
RuntimeCommandType.pauseBgm: {
...commandCommonPayloadFields,
RuntimeProtocolField.channel,
},
RuntimeCommandType.resumeBgm: {
...commandCommonPayloadFields,
RuntimeProtocolField.channel,
},
RuntimeCommandType.stopBgm: {
...commandCommonPayloadFields,
RuntimeProtocolField.channel,
},
RuntimeCommandType.preloadResources: {
...commandCommonPayloadFields,
RuntimeProtocolField.failOnError,
},
RuntimeCommandType.evictResources: commandCommonPayloadFields,
RuntimeCommandType.cancelCommands: commandCommonPayloadFields,
RuntimeCommandType.playSpineAnimation: {
...commandCommonPayloadFields,
RuntimeProtocolField.animation,
RuntimeProtocolField.track,
RuntimeProtocolField.loop,
RuntimeProtocolField.queue,
RuntimeProtocolField.delay,
},
RuntimeCommandType.copyText: {
...commandCommonPayloadFields,
RuntimeProtocolField.text,
},
};
static void ensureKnownKeys(
Map<Object?, Object?> map, {
required Set<String> allowed,
required String context,
}) {
for (final key in map.keys) {
if (key is! String) {
throw FormatException('$context field key must be a string: $key');
}
if (!allowed.contains(key)) {
throw FormatException('$context has unsupported field: $key');
}
}
}
static Set<String> allowedCommandFields(String commandType) {
final payloadFields = commandPayloadFieldsByType[commandType];
if (payloadFields == null) {
throw UnsupportedError('Unsupported runtime command: $commandType');
}
return {...commandEnvelopeFields, ...payloadFields};
}
static Set<String> allowedCommandPayloadFields(String commandType) {
final payloadFields = commandPayloadFieldsByType[commandType];
if (payloadFields == null) {
throw UnsupportedError('Unsupported runtime command: $commandType');
}
return payloadFields;
}
}

View File

@@ -0,0 +1,489 @@
import 'package:flame/components.dart';
import '../models/game_diff.dart';
import '../models/runtime_event.dart';
import '../models/runtime_node.dart';
import '../protocol/runtime_protocol.dart';
import '../resources/game_resource_manager.dart';
import 'runtime_component.dart';
class RenderTreeController {
RenderTreeController({
required Component root,
required GameResourceManager resources,
required void Function(RuntimeEvent event) eventSink,
this.onScopeRemoved,
}) : _root = root,
_resources = resources,
_eventSink = eventSink;
final Component _root;
final GameResourceManager _resources;
final void Function(RuntimeEvent event) _eventSink;
final Map<String, RuntimeComponent> _components = {};
final Map<String, int> _epochs = {};
final Map<String, Vector2> _scrollVelocities = {};
void Function(String id)? onScopeRemoved;
RuntimeComponent? componentById(String id) => _components[id];
bool contains(String id) => _components.containsKey(id);
String? listViewAt(Vector2 canvasPosition) {
final hits =
_components.values
.where(
(component) =>
component.node.type == RuntimeNodeType.listView &&
component.containsVisualPoint(canvasPosition),
)
.toList(growable: false)
..sort((a, b) => b.priority.compareTo(a.priority));
return hits.isEmpty ? null : hits.first.node.id;
}
bool scrollListViewAt(
Vector2 canvasPosition, {
double deltaX = 0,
double deltaY = 0,
String source = 'wheel',
}) {
final id = listViewAt(canvasPosition);
if (id == null) {
return false;
}
return scrollListView(id, deltaX: deltaX, deltaY: deltaY, source: source);
}
bool scrollListView(
String id, {
double deltaX = 0,
double deltaY = 0,
String source = 'program',
}) {
final component = _components[id];
if (component == null || component.node.type != RuntimeNodeType.listView) {
return false;
}
final node = component.node;
final viewport = _listViewContentViewport(node);
final nextScrollX = _clampScroll(
node.scrollX + deltaX,
viewportExtent: viewport.x,
contentExtent: node.contentWidth,
);
final nextScrollY = _clampScroll(
node.scrollY + deltaY,
viewportExtent: viewport.y,
contentExtent: node.contentHeight,
);
if (nextScrollX == node.scrollX && nextScrollY == node.scrollY) {
return false;
}
component.updateNode(
node.copyWithProps({'scrollX': nextScrollX, 'scrollY': nextScrollY}),
);
_reattachChildrenOf(id);
_emitScrollEvent(component, source: source);
return true;
}
void setListViewVelocity(String id, Vector2 velocity) {
final component = _components[id];
if (component == null || component.node.type != RuntimeNodeType.listView) {
return;
}
if (!component.node.inertia) {
_scrollVelocities.remove(id);
return;
}
if (velocity.length2 < 1) {
_scrollVelocities.remove(id);
return;
}
_scrollVelocities[id] = velocity;
}
void stopListViewVelocity(String id) {
_scrollVelocities.remove(id);
}
void updateListViewInertia(double dt) {
if (_scrollVelocities.isEmpty || dt <= 0) {
return;
}
for (final entry in _scrollVelocities.entries.toList(growable: false)) {
final id = entry.key;
final velocity = entry.value;
final consumed = scrollListView(
id,
deltaX: velocity.x * dt,
deltaY: velocity.y * dt,
source: 'inertia',
);
final next = velocity * 0.88;
if (!consumed || next.length2 < 25) {
_scrollVelocities.remove(id);
} else {
_scrollVelocities[id] = next;
}
}
}
int epochOf(String id) => _epochs[id] ?? 0;
bool isNodeEpochAlive(String id, int epoch) {
return _components.containsKey(id) && epochOf(id) == epoch;
}
void clear() {
final ids = _components.keys.toList(growable: false);
for (final component in _components.values) {
component.removeFromParent();
}
_components.clear();
for (final id in ids) {
_bumpEpoch(id);
onScopeRemoved?.call(id);
}
}
void removeById(String id) {
final removedIds = <String>[];
for (final childId in _descendantIdsOf(id)) {
final child = _components.remove(childId);
if (child != null) {
removedIds.add(childId);
_bumpEpoch(childId);
child.removeFromParent();
}
}
final component = _components.remove(id);
if (component != null) {
removedIds.add(id);
_bumpEpoch(id);
component.removeFromParent();
}
for (final removedId in removedIds) {
onScopeRemoved?.call(removedId);
}
}
void apply(NodeDiff diff) {
_validateDiff(diff);
for (final remove in diff.removes) {
removeById(remove.id);
}
for (final create in diff.creates) {
_createOrReplace(create);
}
for (final update in diff.updates) {
final component = _components[update.id];
if (component == null) {
continue;
}
final nextNode = component.node.copyWithProps(update.props);
component.updateNode(nextNode);
_attachToParent(component);
_reattachChildrenOf(component.node.id);
}
}
void _validateDiff(NodeDiff diff) {
final nodes = <String, RuntimeNode>{
for (final entry in _components.entries) entry.key: entry.value.node,
};
for (final remove in diff.removes) {
_removeNodeSnapshot(nodes, remove.id);
}
for (final create in diff.creates) {
nodes[create.id] = create;
}
for (final update in diff.updates) {
final current = nodes[update.id];
if (current == null) {
continue;
}
nodes[update.id] = current.copyWithProps(update.props);
}
_validateParentGraph(nodes);
}
void _removeNodeSnapshot(Map<String, RuntimeNode> nodes, String id) {
final descendants = _descendantIdsOfSnapshot(nodes, id);
for (final childId in descendants) {
nodes.remove(childId);
}
nodes.remove(id);
}
List<String> _descendantIdsOfSnapshot(
Map<String, RuntimeNode> nodes,
String parentId,
) {
final descendants = <String>[];
for (final node in nodes.values) {
if (node.parent == parentId) {
descendants.add(node.id);
descendants.addAll(_descendantIdsOfSnapshot(nodes, node.id));
}
}
return descendants;
}
void _validateParentGraph(Map<String, RuntimeNode> nodes) {
for (final node in nodes.values) {
final parentId = node.parent;
if (parentId == null) {
continue;
}
if (parentId == node.id) {
throw const FormatException(
'RuntimeNode.parent cannot reference itself',
);
}
final seen = <String>{node.id};
var currentId = parentId;
while (true) {
if (!seen.add(currentId)) {
throw FormatException(
'RuntimeNode.parent would create a cycle: ${node.id} -> $parentId',
);
}
final parent = nodes[currentId];
final nextId = parent?.parent;
if (nextId == null) {
break;
}
currentId = nextId;
}
}
}
void _createOrReplace(RuntimeNode node) {
_validateParent(node);
final existing = _components.remove(node.id);
existing?.removeFromParent();
final epoch = _bumpEpoch(node.id);
late final RuntimeComponent component;
component = RuntimeComponent(
node: node,
resources: _resources,
onNodeTap: (tappedNode, localPosition) {
if (_components[tappedNode.id] != component) {
return;
}
_eventSink(
RuntimeEvent(
type: RuntimeEventType.tap,
target: tappedNode.id,
handler: tappedNode.onTap,
x: localPosition.x,
y: localPosition.y,
scope: tappedNode.id,
targetEpoch: epoch,
scopeEpoch: epoch,
),
);
},
);
_components[node.id] = component;
_attachToParent(component);
_reattachChildrenOf(node.id);
}
int _bumpEpoch(String id) {
final next = (_epochs[id] ?? 0) + 1;
_epochs[id] = next;
return next;
}
void _attachToParent(RuntimeComponent component) {
final parentId = component.node.parent;
final parent = parentId == null ? null : _components[parentId];
final target = parent ?? _root;
final parentIsListView = parent?.node.type == RuntimeNodeType.listView;
final parentScrollX = parentIsListView ? parent!.node.scrollX : 0.0;
final parentScrollY = parentIsListView ? parent!.node.scrollY : 0.0;
final parentContentOffset = parentIsListView
? parent!.listViewContentOffset()
: Vector2.zero();
component.setParentScroll(
x: parentScrollX,
y: parentScrollY,
contentOffsetX: parentContentOffset.x,
contentOffsetY: parentContentOffset.y,
);
component.setViewportCulled(_isCulledByParentListView(component, parent));
if (component.parent == target) {
return;
}
component.removeFromParent();
target.add(component);
}
void _validateParent(RuntimeNode node) {
final parentId = node.parent;
if (parentId != null && _wouldCreateCycle(node.id, parentId)) {
throw FormatException(
'RuntimeNode.parent would create a cycle: ${node.id} -> $parentId',
);
}
}
void _reattachChildrenOf(String parentId) {
for (final component in _components.values.toList(growable: false)) {
if (component.node.parent == parentId) {
_attachToParent(component);
}
}
}
List<String> _descendantIdsOf(String parentId) {
final descendants = <String>[];
for (final component in _components.values) {
if (component.node.parent == parentId) {
descendants.add(component.node.id);
descendants.addAll(_descendantIdsOf(component.node.id));
}
}
return descendants;
}
bool _isCulledByParentListView(
RuntimeComponent component,
RuntimeComponent? parent,
) {
final parentNode = parent?.node;
if (parentNode == null ||
parentNode.type != RuntimeNodeType.listView ||
!parentNode.virtualized) {
return false;
}
final cache = parentNode.cacheExtent;
final viewportLeft = parentNode.scrollX - cache;
final viewportTop = parentNode.scrollY - cache;
final viewport = parent?.listViewContentViewport() ?? Vector2.zero();
final viewportRight = parentNode.scrollX + viewport.x + cache;
final viewportBottom = parentNode.scrollY + viewport.y + cache;
final node = component.node;
final childLeft = node.x;
final childTop = node.y;
final childRight = node.x + (node.width ?? 0);
final childBottom = node.y + (node.height ?? 0);
return childRight < viewportLeft ||
childLeft > viewportRight ||
childBottom < viewportTop ||
childTop > viewportBottom;
}
Vector2 _listViewContentViewport(RuntimeNode node) {
final width = node.width ?? 0;
final height = node.height ?? 0;
final left = node.paddingLeft.clamp(0.0, width).toDouble();
final top = node.paddingTop.clamp(0.0, height).toDouble();
if (!node.scrollbarVisible) {
return Vector2(
(width - left - node.paddingRight).clamp(0.0, width).toDouble(),
(height - top - node.paddingBottom).clamp(0.0, height).toDouble(),
);
}
final thickness = (node.scrollbarThickness ?? 5).clamp(1.0, 16.0);
final gutter = thickness + 8;
var vertical = (node.contentHeight ?? 0) > height && height > 0;
var horizontal = (node.contentWidth ?? 0) > width && width > 0;
for (var i = 0; i < 2; i += 1) {
final viewportWidth =
width - left - node.paddingRight - (vertical ? gutter : 0);
final viewportHeight =
height - top - node.paddingBottom - (horizontal ? gutter : 0);
vertical =
(node.contentHeight ?? 0) > viewportHeight && viewportHeight > 0;
horizontal =
(node.contentWidth ?? 0) > viewportWidth && viewportWidth > 0;
}
return Vector2(
(width - left - node.paddingRight - (vertical ? gutter : 0))
.clamp(0.0, width)
.toDouble(),
(height - top - node.paddingBottom - (horizontal ? gutter : 0))
.clamp(0.0, height)
.toDouble(),
);
}
double _clampScroll(
double value, {
required double? viewportExtent,
required double? contentExtent,
}) {
final maxScroll = (contentExtent ?? 0) - (viewportExtent ?? 0);
if (maxScroll <= 0) {
return 0;
}
return value.clamp(0, maxScroll).toDouble();
}
void _emitScrollEvent(RuntimeComponent component, {required String source}) {
final node = component.node;
final handler = node.onScroll;
if (handler == null || handler.isEmpty) {
return;
}
final id = node.id;
_eventSink(
RuntimeEvent(
type: RuntimeEventType.scroll,
target: id,
handler: handler,
scope: id,
targetEpoch: epochOf(id),
scopeEpoch: epochOf(id),
data: {
'scrollX': node.scrollX,
'scrollY': node.scrollY,
'maxScrollX': _clampScroll(
double.infinity,
viewportExtent: node.width,
contentExtent: node.contentWidth,
),
'maxScrollY': _clampScroll(
double.infinity,
viewportExtent: node.height,
contentExtent: node.contentHeight,
),
'source': source,
},
),
);
}
bool _wouldCreateCycle(String nodeId, String parentId) {
var currentId = parentId;
while (true) {
if (currentId == nodeId) {
return true;
}
final parent = _components[currentId];
final nextId = parent?.node.parent;
if (nextId == null) {
return false;
}
currentId = nextId;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
part of 'game_resource_manager.dart';
extension _GameResourceManagerCache on GameResourceManager {
void _touch(_ImageResourceRecord record) {
record.lastUsed = ++_accessCounter;
}
void _enforceImageBudget() {
while (_isOverBudget()) {
final victim = _leastRecentlyUsedEvictableImage();
if (victim == null || !_removeImageRecord(victim)) {
return;
}
}
}
bool _isOverBudget() {
final maxBytes = _maxCacheBytes;
final maxEntries = _maxCacheEntries;
return (maxBytes != null && _cacheBytes > maxBytes) ||
(maxEntries != null && _readyImageCount > maxEntries);
}
int get _readyImageCount => _images.values
.where((record) => record.state == GameResourceState.ready)
.length;
String? _leastRecentlyUsedEvictableImage() {
String? victimPath;
_ImageResourceRecord? victim;
for (final entry in _images.entries) {
final record = entry.value;
if (record.state != GameResourceState.ready || record.refCount > 0) {
continue;
}
if (victim == null || record.lastUsed < victim.lastUsed) {
victim = record;
victimPath = entry.key;
}
}
return victimPath;
}
void _releaseCachedImages() {
_loadLimiter.clearPending();
for (final path in _images.keys.toList(growable: false)) {
_removeImageRecord(path);
}
}
bool _removeImageRecord(String path) {
final record = _images.remove(path);
if (record == null) {
return false;
}
record.state = GameResourceState.disposed;
_cacheBytes -= record.estimatedBytes;
if (_cacheBytes < 0) {
_cacheBytes = 0;
}
record.image?.dispose();
record.image = null;
record.inflight = null;
return true;
}
}

View File

@@ -0,0 +1,28 @@
part of 'game_resource_manager.dart';
extension _GameResourceManagerDebug on GameResourceManager {
Map<String, Object?> _imageRecordDebugJson({
required String? key,
required String path,
required String? preload,
required bool declared,
}) {
final record = _images[path];
return {
if (key != null) 'key': key,
'path': path,
'type': GameResourceType.image,
'declared': declared,
if (preload != null) 'preload': preload,
if (key != null && _package?.manifest.resources[key]?.group != null)
'group': _package?.manifest.resources[key]?.group,
'state': (record?.state ?? GameResourceState.idle).name,
if (record != null) 'generation': record.generation,
'loading': record?.inflight != null,
'ready': record?.image != null,
if (record != null) 'refCount': record.refCount,
if (record != null) 'bytes': record.estimatedBytes,
if (record?.lastError != null) 'error': record!.lastError.toString(),
};
}
}

View File

@@ -0,0 +1,186 @@
part of 'game_resource_manager.dart';
extension _GameResourceManagerLoading on GameResourceManager {
Future<ui.Image?> _loadImage(
String? keyOrPath, {
required bool failOnError,
bool retain = false,
}) {
if (keyOrPath == null || keyOrPath.isEmpty) {
return Future.value(null);
}
final requestToken = _asyncGate.token;
final requestGeneration = requestToken.generation;
final path = _tryResolve(keyOrPath);
if (path == null) {
return Future.value(null);
}
final existing = _images[path];
if (existing != null) {
final image = existing.image;
if (existing.generation == requestGeneration &&
existing.state == GameResourceState.ready &&
image != null) {
if (retain) {
existing.refCount++;
}
_touch(existing);
return Future.value(image);
}
final inflight = existing.inflight;
if (existing.generation == requestGeneration && inflight != null) {
return failOnError
? _throwIfNull(inflight, keyOrPath)
: inflight.catchError((_) => null);
}
}
final record = _ImageResourceRecord(generation: requestGeneration)
..state = GameResourceState.loading;
_images[path] = record;
final future = _decodeImage(path, record, requestToken, retain: retain);
record.inflight = future;
return failOnError ? _throwIfNull(future, keyOrPath) : future;
}
Future<ui.Image?> _throwIfNull(
Future<ui.Image?> future,
String keyOrPath,
) async {
final image = await future;
if (image == null) {
throw ResourceLoadException('Required image resource failed: $keyOrPath');
}
return image;
}
Future<ui.Image?> _decodeImage(
String path,
_ImageResourceRecord record,
RuntimeAsyncToken requestToken, {
required bool retain,
}) async {
try {
final activePackage = _package;
if (activePackage == null) {
throw StateError('GameResourceManager has no active package');
}
final frame = await _loadLimiter.run(() async {
final bytes = await activePackage.readBytes(path);
final codec = await ui.instantiateImageCodec(
bytes.buffer.asUint8List(),
);
return codec.getNextFrame();
});
record.inflight = null;
if (!_asyncGate.accepts(requestToken) || _images[path] != record) {
frame.image.dispose();
record.state = GameResourceState.disposed;
return null;
}
record
..image = frame.image
..estimatedBytes = frame.image.width * frame.image.height * 4
..state = GameResourceState.ready
..lastError = null;
if (retain) {
record.refCount++;
}
_cacheBytes += record.estimatedBytes;
_touch(record);
_enforceImageBudget();
return frame.image;
} catch (error) {
record.inflight = null;
if (!_asyncGate.accepts(requestToken) || _images[path] != record) {
record.state = GameResourceState.disposed;
return null;
}
record
..state = GameResourceState.failed
..lastError = error;
_diagnostics?.record(
type: RuntimeDiagnosticType.resourceLoadError,
message: 'Image resource failed to load',
error: error,
context: {'path': path, 'generation': requestToken.generation},
);
return null;
}
}
Future<void> _preloadSpine(
String keyOrPath, {
required bool failOnError,
}) async {
final spine = await _createSpineComponent(keyOrPath);
spine?.dispose();
if (failOnError && spine == null) {
throw ResourceLoadException('Required spine resource failed: $keyOrPath');
}
}
Future<SpineComponent?> _createSpineComponent(String? keyOrPath) async {
if (keyOrPath == null || keyOrPath.isEmpty) {
return null;
}
final requestToken = _asyncGate.token;
final activePackage = _package;
if (activePackage == null) {
return null;
}
final resource = activePackage.manifest.resources[keyOrPath];
if (resource == null || resource.type != GameResourceType.spine) {
return null;
}
try {
await initSpineFlutter();
final atlasPath = activePackage.resolveResourcePath(resource.atlas!);
final skeletonPath = activePackage.resolveResourcePath(
resource.skeleton!,
);
final drawable = await _loadLimiter.run(() {
return SkeletonDrawableFlutter.fromMemory(atlasPath, skeletonPath, (
name,
) async {
final bytes = await activePackage.readBytes(name);
return bytes.buffer.asUint8List(
bytes.offsetInBytes,
bytes.lengthInBytes,
);
});
});
if (!_asyncGate.accepts(requestToken)) {
drawable.dispose();
return null;
}
return SpineComponent(drawable);
} catch (error) {
if (!_asyncGate.accepts(requestToken)) {
return null;
}
_diagnostics?.record(
type: RuntimeDiagnosticType.resourceLoadError,
message: 'Spine resource failed to load',
error: error,
context: {'key': keyOrPath, 'generation': requestToken.generation},
);
return null;
}
}
String? _tryResolve(String keyOrPath) {
try {
return resolve(keyOrPath);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,279 @@
import 'dart:ui' as ui;
import 'package:flame_spine/flame_spine.dart';
import '../diagnostics/runtime_diagnostics.dart';
import '../lifecycle/runtime_async_gate.dart';
import '../packages/game_package.dart';
import '../packages/game_package_manifest.dart';
import 'resource_load_limiter.dart';
// These part files only group GameResourceManager private helpers. The public
// facade stays in GameResourceManager so callers do not depend on extensions.
part 'game_resource_loading.dart';
part 'game_resource_debug.dart';
part 'game_resource_cache.dart';
class GameResourceManager {
GameResourceManager({
RuntimeDiagnostics? diagnostics,
int? maxCacheBytes,
int? maxCacheEntries,
int maxConcurrentLoads = 4,
}) : _diagnostics = diagnostics,
_maxCacheBytes = maxCacheBytes,
_maxCacheEntries = maxCacheEntries,
_loadLimiter = ResourceLoadLimiter(maxConcurrentLoads);
final RuntimeDiagnostics? _diagnostics;
final int? _maxCacheBytes;
final int? _maxCacheEntries;
final ResourceLoadLimiter _loadLimiter;
final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true);
GamePackage? _package;
final Map<String, _ImageResourceRecord> _images = {};
int _cacheBytes = 0;
int _accessCounter = 0;
int get generation => _asyncGate.generation;
bool get hasPackage => _package != null;
GamePackage get package {
final value = _package;
if (value == null) {
throw StateError('GameResourceManager has no active package');
}
return value;
}
Future<void> mount(GamePackage package) async {
_releaseCachedImages();
_asyncGate.activate();
_package = package;
await preloadDeclaredImages(package.manifest);
await preloadDeclaredSpines(package.manifest);
}
void dispose() {
_asyncGate.close();
_releaseCachedImages();
_package = null;
}
String resolve(String keyOrPath) {
return package.resolveResourcePath(keyOrPath);
}
GameResourceState imageState(String keyOrPath) {
final path = _tryResolve(keyOrPath);
if (path == null) {
return GameResourceState.failed;
}
return _images[path]?.state ?? GameResourceState.idle;
}
Object? imageError(String keyOrPath) {
final path = _tryResolve(keyOrPath);
if (path == null) {
return StateError('GameResourceManager has no active package');
}
return _images[path]?.lastError;
}
Map<String, Object?> imagesDebugJson() {
final activePackage = _package;
final declaredPaths = <String>{};
final resources = <Map<String, Object?>>[];
if (activePackage != null) {
for (final entry in activePackage.manifest.resources.entries) {
final resource = entry.value;
if (resource.type != GameResourceType.image) {
continue;
}
final path = activePackage.resolveResourcePath(entry.key);
declaredPaths.add(path);
resources.add(
_imageRecordDebugJson(
key: entry.key,
path: path,
preload: resource.preload,
declared: true,
),
);
}
}
for (final path in _images.keys) {
if (declaredPaths.contains(path)) {
continue;
}
resources.add(
_imageRecordDebugJson(
key: null,
path: path,
preload: null,
declared: false,
),
);
}
return {
'generation': generation,
'hasPackage': activePackage != null,
'count': resources.length,
'activeLoads': _loadLimiter.activeCount,
'pendingLoads': _loadLimiter.pendingCount,
'resources': resources,
};
}
bool evictImage(String keyOrPath) {
final path = _tryResolve(keyOrPath);
if (path == null) {
return false;
}
return _removeImageRecord(path);
}
Future<ui.Image?> retryImage(String keyOrPath) {
evictImage(keyOrPath);
return loadImage(keyOrPath);
}
Future<ui.Image?> loadImage(String? keyOrPath, {bool retain = false}) {
return _loadImage(keyOrPath, failOnError: false, retain: retain);
}
Future<SpineComponent?> createSpineComponent(String? keyOrPath) {
return _createSpineComponent(keyOrPath);
}
bool retainImage(String keyOrPath, {int? generation}) {
final path = _tryResolve(keyOrPath);
if (path == null) {
return false;
}
final record = _images[path];
if (record == null ||
record.state != GameResourceState.ready ||
(generation != null && record.generation != generation)) {
return false;
}
record.refCount++;
_touch(record);
return true;
}
bool releaseImage(String keyOrPath, {int? generation}) {
final path = _tryResolve(keyOrPath);
if (path == null) {
return false;
}
final record = _images[path];
if (record == null ||
(generation != null && record.generation != generation)) {
return false;
}
if (record.refCount > 0) {
record.refCount--;
}
_touch(record);
_enforceImageBudget();
return true;
}
Future<void> preloadGroup(String group, {bool failOnError = false}) async {
final activePackage = _package;
if (activePackage == null) {
throw StateError('GameResourceManager has no active package');
}
final futures = <Future<void>>[];
for (final entry in activePackage.manifest.resources.entries) {
final resource = entry.value;
if (resource.type == GameResourceType.image && resource.group == group) {
futures.add(
_loadImage(entry.key, failOnError: failOnError).then((_) {}),
);
}
if (resource.type == GameResourceType.spine && resource.group == group) {
futures.add(_preloadSpine(entry.key, failOnError: failOnError));
}
}
await Future.wait(futures);
}
int evictGroup(String group) {
final activePackage = _package;
if (activePackage == null) {
return 0;
}
var count = 0;
for (final entry in activePackage.manifest.resources.entries) {
final resource = entry.value;
if (resource.type != GameResourceType.image || resource.group != group) {
continue;
}
final path = activePackage.resolveResourcePath(entry.key);
if (_removeImageRecord(path)) {
count++;
}
}
return count;
}
Future<void> preloadDeclaredImages(GamePackageManifest manifest) async {
final futures = <Future<void>>[];
for (final entry in manifest.resources.entries) {
final resource = entry.value;
if (resource.type != GameResourceType.image ||
resource.preload == GameResourcePreload.lazy) {
continue;
}
final failOnError = resource.preload == GameResourcePreload.required;
futures.add(_loadImage(entry.key, failOnError: failOnError).then((_) {}));
}
await Future.wait(futures);
}
Future<void> preloadDeclaredSpines(GamePackageManifest manifest) async {
final futures = <Future<void>>[];
for (final entry in manifest.resources.entries) {
final resource = entry.value;
if (resource.type != GameResourceType.spine ||
resource.preload == GameResourcePreload.lazy) {
continue;
}
final failOnError = resource.preload == GameResourcePreload.required;
futures.add(_preloadSpine(entry.key, failOnError: failOnError));
}
await Future.wait(futures);
}
}
enum GameResourceState { idle, loading, ready, failed, disposed }
class ResourceLoadException implements Exception {
const ResourceLoadException(this.message);
final String message;
@override
String toString() => 'ResourceLoadException: $message';
}
class _ImageResourceRecord {
_ImageResourceRecord({required this.generation});
final int generation;
GameResourceState state = GameResourceState.idle;
Future<ui.Image?>? inflight;
ui.Image? image;
Object? lastError;
int estimatedBytes = 0;
int refCount = 0;
int lastUsed = 0;
}

View File

@@ -0,0 +1,91 @@
import 'dart:async' as async;
import 'dart:collection';
class ResourceLoadLimiter {
ResourceLoadLimiter(int maxConcurrent)
: maxConcurrent = _validateMaxConcurrent(maxConcurrent);
final int maxConcurrent;
final Queue<_QueuedLoadBase> _queue = Queue<_QueuedLoadBase>();
var _active = 0;
int get activeCount => _active;
int get pendingCount => _queue.length;
Future<T> run<T>(Future<T> Function() task) {
final queued = _QueuedLoad<T>(task);
_queue.add(queued);
_pump();
return queued.completer.future;
}
void clearPending() {
while (_queue.isNotEmpty) {
_queue.removeFirst().cancel();
}
}
void _pump() {
while (_active < maxConcurrent && _queue.isNotEmpty) {
final queued = _queue.removeFirst();
_active++;
async.unawaited(_runQueued(queued));
}
}
Future<void> _runQueued(_QueuedLoadBase queued) async {
try {
await queued.run();
} finally {
_active--;
_pump();
}
}
static int _validateMaxConcurrent(int value) {
if (value < 1) {
throw ArgumentError.value(value, 'maxConcurrent', 'must be >= 1');
}
return value;
}
}
abstract class _QueuedLoadBase {
Future<void> run();
void cancel();
}
class _QueuedLoad<T> implements _QueuedLoadBase {
_QueuedLoad(this._task);
final Future<T> Function() _task;
final completer = async.Completer<T>();
@override
Future<void> run() async {
if (completer.isCompleted) {
return;
}
try {
completer.complete(await _task());
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
}
@override
void cancel() {
if (!completer.isCompleted) {
completer.completeError(const ResourceLoadCancelledException());
}
}
}
class ResourceLoadCancelledException implements Exception {
const ResourceLoadCancelledException();
@override
String toString() => 'ResourceLoadCancelledException';
}

View File

@@ -0,0 +1,296 @@
import 'package:lua_dardo_plus/lua.dart';
import '../models/game_diff.dart';
import '../models/runtime_event.dart';
import '../packages/game_package.dart';
import 'script_engine.dart';
class LuaDardoScriptEngine implements ScriptEngine {
late final LuaState _lua;
late final Map<String, String> _moduleScripts;
final Set<String> _loadingModules = {};
@override
Future<void> loadPackage(GamePackage package) async {
final script = await package.readText(package.manifest.entry);
_moduleScripts = {};
for (final entry in package.manifest.modules.entries) {
_moduleScripts[entry.key] = await package.readText(entry.value);
}
_loadingModules.clear();
_lua = LuaState.newState();
_lua.openLibs();
_disableUnsafeGlobals();
_installRuntimeApi();
final ok = _lua.doString(script);
if (!ok) {
final error = _lua.toStr(-1) ?? 'unknown Lua load error';
_lua.pop(1);
throw StateError(error);
}
}
@override
bool smokeTest(Map<String, Object?> context) {
_lua.getGlobal('smoke_test');
if (!_lua.isFunction(-1)) {
_lua.pop(1);
return true;
}
_pushValue(context);
final status = _lua.pCall(1, 1, 0);
if (status != ThreadStatus.luaOk) {
final error = _lua.toStr(-1) ?? 'unknown Lua smoke_test error';
_lua.pop(1);
throw StateError(error);
}
final result = _lua.toBoolean(-1);
_lua.pop(1);
return result;
}
@override
GameDiff init(Map<String, Object?> context) {
return _callDiffFunction('init', context);
}
@override
GameDiff dispatchEvent(RuntimeEvent event) {
return _callDiffFunction('on_event', event.toMap());
}
GameDiff _callDiffFunction(String name, Map<String, Object?> argument) {
_lua.getGlobal(name);
if (!_lua.isFunction(-1)) {
_lua.pop(1);
return GameDiff.empty;
}
_pushValue(argument);
final status = _lua.pCall(1, 1, 0);
if (status != ThreadStatus.luaOk) {
final error = _lua.toStr(-1) ?? 'unknown Lua runtime error';
_lua.pop(1);
throw StateError(error);
}
if (_lua.isNil(-1)) {
_lua.pop(1);
return GameDiff.empty;
}
if (!_lua.isTable(-1)) {
_lua.pop(1);
throw StateError('Lua function $name must return a table or nil');
}
final value = _readValue(-1);
_lua.pop(1);
if (value is! Map) {
throw StateError('Lua function $name returned a non-map value');
}
return GameDiff.fromMap(Map<String, Object?>.from(value));
}
void _installRuntimeApi() {
_lua.newTable();
_lua.newTable();
_lua.setField(-2, '__modules');
_lua.pushDartFunction(_importModule);
_lua.setField(-2, 'import');
_lua.setGlobal('runtime');
}
int _importModule(LuaState lua) {
final moduleName = lua.toStr(1);
if (moduleName == null || moduleName.isEmpty) {
throw const FormatException(
'runtime.import(moduleName) requires a module name',
);
}
if (!_isSafeModuleName(moduleName)) {
throw FormatException('Unsafe Lua module name: $moduleName');
}
final source = _moduleScripts[moduleName];
if (source == null) {
throw FormatException(
'Lua module is not declared in manifest.modules: $moduleName',
);
}
lua.getGlobal('runtime');
lua.getField(-1, '__modules');
final modulesIndex = lua.absIndex(-1);
lua.getField(modulesIndex, moduleName);
if (!lua.isNil(-1)) {
lua.remove(-2);
lua.remove(-2);
return 1;
}
lua.pop(1);
if (_loadingModules.contains(moduleName)) {
lua.pop(2);
throw FormatException('Circular Lua module import: $moduleName');
}
_loadingModules.add(moduleName);
try {
final loadStatus = lua.loadString(source);
if (loadStatus != ThreadStatus.luaOk) {
final error = lua.toStr(-1) ?? 'unknown Lua module load error';
lua.pop(3);
throw StateError('Failed to load Lua module $moduleName: $error');
}
final callStatus = lua.pCall(0, 1, 0);
if (callStatus != ThreadStatus.luaOk) {
final error = lua.toStr(-1) ?? 'unknown Lua module runtime error';
lua.pop(3);
throw StateError('Failed to run Lua module $moduleName: $error');
}
if (lua.isNil(-1)) {
lua.pop(1);
lua.pushBoolean(true);
}
if (!lua.isTable(-1) && !lua.isBoolean(-1)) {
lua.pop(3);
throw StateError(
'Lua module $moduleName must return a table, true, or nil',
);
}
lua.pushValue(-1);
lua.setField(modulesIndex, moduleName);
lua.remove(-2);
lua.remove(-2);
return 1;
} finally {
_loadingModules.remove(moduleName);
}
}
bool _isSafeModuleName(String value) {
return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) &&
!value.contains('..') &&
!value.startsWith('.') &&
!value.endsWith('.');
}
void _disableUnsafeGlobals() {
for (final name in const [
'os',
'package',
'dofile',
'loadfile',
'require',
]) {
_lua.pushNil();
_lua.setGlobal(name);
}
}
void _pushValue(Object? value) {
switch (value) {
case null:
_lua.pushNil();
case bool v:
_lua.pushBoolean(v);
case int v:
_lua.pushInteger(v);
case double v:
_lua.pushNumber(v);
case String v:
_lua.pushString(v);
case List<Object?> v:
_pushList(v);
case Map<String, Object?> v:
_pushMap(v);
default:
throw UnsupportedError('Unsupported value for Lua: $value');
}
}
void _pushList(List<Object?> values) {
_lua.newTable();
for (var i = 0; i < values.length; i++) {
_pushValue(values[i]);
_lua.setI(-2, i + 1);
}
}
void _pushMap(Map<String, Object?> values) {
_lua.newTable();
for (final entry in values.entries) {
_pushValue(entry.value);
_lua.setField(-2, entry.key);
}
}
Object? _readValue(int index) {
if (_lua.isNil(index) || _lua.isNone(index)) {
return null;
}
if (_lua.isBoolean(index)) {
return _lua.toBoolean(index);
}
if (_lua.isInteger(index)) {
return _lua.toInteger(index);
}
if (_lua.isNumber(index)) {
return _lua.toNumber(index);
}
if (_lua.isString(index)) {
return _lua.toStr(index);
}
if (_lua.isTable(index)) {
return _readTable(index);
}
throw UnsupportedError('Unsupported Lua type: ${_lua.typeName2(index)}');
}
Object _readTable(int index) {
final tableIndex = _lua.absIndex(index);
final length = _lua.rawLen(tableIndex);
final list = <Object?>[];
var hasOnlyArrayKeys = length > 0;
final map = <String, Object?>{};
_lua.pushNil();
while (_lua.next(tableIndex)) {
final value = _readValue(-1);
final key = _readValue(-2);
_lua.pop(1);
if (key is int && key >= 1 && key <= length) {
while (list.length < key) {
list.add(null);
}
list[key - 1] = value;
} else {
hasOnlyArrayKeys = false;
map[key.toString()] = value;
}
}
if (hasOnlyArrayKeys && map.isEmpty) {
return list;
}
for (var i = 0; i < list.length; i++) {
if (list[i] != null) {
map[(i + 1).toString()] = list[i];
}
}
return map;
}
}

View File

@@ -0,0 +1,13 @@
import '../models/game_diff.dart';
import '../models/runtime_event.dart';
import '../packages/game_package.dart';
abstract interface class ScriptEngine {
Future<void> loadPackage(GamePackage package);
bool smokeTest(Map<String, Object?> context);
GameDiff init(Map<String, Object?> context);
GameDiff dispatchEvent(RuntimeEvent event);
}