Initial flame_lua_runtime package
This commit is contained in:
14
lib/flame_lua_runtime.dart
Normal file
14
lib/flame_lua_runtime.dart
Normal 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;
|
||||
90
lib/runtime/audio/runtime_audio_cache.dart
Normal file
90
lib/runtime/audio/runtime_audio_cache.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
27
lib/runtime/audio/runtime_audio_debug.dart
Normal file
27
lib/runtime/audio/runtime_audio_debug.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
124
lib/runtime/audio/runtime_audio_loading.dart
Normal file
124
lib/runtime/audio/runtime_audio_loading.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
352
lib/runtime/audio/runtime_audio_manager.dart
Normal file
352
lib/runtime/audio/runtime_audio_manager.dart
Normal 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;
|
||||
}
|
||||
99
lib/runtime/audio/runtime_audio_player.dart
Normal file
99
lib/runtime/audio/runtime_audio_player.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
125
lib/runtime/commands/command_audio.dart
Normal file
125
lib/runtime/commands/command_audio.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
part of 'command_executor.dart';
|
||||
|
||||
extension _CommandExecutorAudio on CommandExecutor {
|
||||
Future<_CommandResult> _playSound(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) async {
|
||||
final audio = _audio;
|
||||
if (audio == null) {
|
||||
_emitCommandCompletion(command, context);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
|
||||
final scope = _scopeFor(command, context, defaultTarget: false);
|
||||
final scopeEpoch = _scopeEpochFor(scope, context);
|
||||
if (!_scopeIsAlive(scope)) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
|
||||
final task = _registerTask(scope, handle);
|
||||
final playback = await audio.play(
|
||||
_requiredAudioResource(command),
|
||||
volume: _optionalVolume(command),
|
||||
);
|
||||
if (_disposed ||
|
||||
task.isCancelled ||
|
||||
(handle?.isCancelled ?? false) ||
|
||||
!_scopeIsAlive(scope)) {
|
||||
await playback?.cancel();
|
||||
task.complete(_CommandResult.cancelled);
|
||||
return task.future;
|
||||
}
|
||||
if (playback == null) {
|
||||
task.complete(_CommandResult.cancelled);
|
||||
return task.future;
|
||||
}
|
||||
|
||||
task.addCancelCallback(() {
|
||||
async.unawaited(playback.cancel());
|
||||
});
|
||||
await playback.done;
|
||||
|
||||
if (_disposed ||
|
||||
task.isCancelled ||
|
||||
(handle?.isCancelled ?? false) ||
|
||||
!_scopeIsAlive(scope)) {
|
||||
task.complete(_CommandResult.cancelled);
|
||||
return task.future;
|
||||
}
|
||||
|
||||
_emitCommandCompletion(
|
||||
command,
|
||||
context.copyWith(scope: scope, scopeEpoch: scopeEpoch),
|
||||
);
|
||||
task.complete(_CommandResult.completed);
|
||||
return task.future;
|
||||
}
|
||||
|
||||
Future<_CommandResult> _playBgm(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) async {
|
||||
final audio = _audio;
|
||||
if (audio == null) {
|
||||
_emitCommandCompletion(command, context);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
|
||||
final scope = _scopeFor(command, context, defaultTarget: false);
|
||||
final scopeEpoch = _scopeEpochFor(scope, context);
|
||||
if (!_scopeIsAlive(scope)) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
|
||||
final channel = _audioChannel(command);
|
||||
final playback = await audio.playBgm(
|
||||
_requiredAudioResource(command),
|
||||
channel: channel,
|
||||
volume: _optionalVolume(command),
|
||||
loop: _optionalBool(command.payload['loop'], 'play_bgm.loop') ?? true,
|
||||
);
|
||||
if (_disposed || (handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) {
|
||||
await audio.stopBgm(channel: channel);
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
if (playback == null) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
|
||||
_registerBgmChannel(channel: channel, scope: scope);
|
||||
handle?.addCancelCallback(() {
|
||||
_unregisterBgmChannel(channel);
|
||||
async.unawaited(audio.stopBgm(channel: channel));
|
||||
});
|
||||
_emitCommandCompletion(
|
||||
command,
|
||||
context.copyWith(scope: scope, scopeEpoch: scopeEpoch),
|
||||
);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
|
||||
Future<_CommandResult> _controlBgm(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
_BgmControl control,
|
||||
) async {
|
||||
final audio = _audio;
|
||||
final channel = _audioChannel(command);
|
||||
if (audio != null) {
|
||||
switch (control) {
|
||||
case _BgmControl.pause:
|
||||
await audio.pauseBgm(channel: channel);
|
||||
case _BgmControl.resume:
|
||||
await audio.resumeBgm(channel: channel);
|
||||
case _BgmControl.stop:
|
||||
await audio.stopBgm(channel: channel);
|
||||
_unregisterBgmChannel(channel);
|
||||
}
|
||||
}
|
||||
_emitCommandCompletion(command, context);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
}
|
||||
52
lib/runtime/commands/command_composite.dart
Normal file
52
lib/runtime/commands/command_composite.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
part of 'command_executor.dart';
|
||||
|
||||
extension _CommandExecutorComposite on CommandExecutor {
|
||||
Future<_CommandResult> _sequence(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) async {
|
||||
final commands = _commandsFromPayload(command);
|
||||
final childContext = _childContextFor(command, context);
|
||||
for (final child in commands) {
|
||||
if (_disposed ||
|
||||
(handle?.isCancelled ?? false) ||
|
||||
!_scopeIsAlive(childContext.scope)) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
final result = await _execute(child, childContext);
|
||||
if (result == _CommandResult.cancelled) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
if (_disposed ||
|
||||
(handle?.isCancelled ?? false) ||
|
||||
!_scopeIsAlive(childContext.scope)) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
_emitCommandCompletion(command, childContext);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
|
||||
Future<_CommandResult> _parallel(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) async {
|
||||
final commands = _commandsFromPayload(command);
|
||||
final childContext = _childContextFor(command, context);
|
||||
final results = await Future.wait(
|
||||
commands.map((child) => _execute(child, childContext)),
|
||||
);
|
||||
if (_disposed ||
|
||||
(handle?.isCancelled ?? false) ||
|
||||
!_scopeIsAlive(childContext.scope) ||
|
||||
results.contains(_CommandResult.cancelled)) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
|
||||
_emitCommandCompletion(command, childContext);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
}
|
||||
230
lib/runtime/commands/command_executor.dart
Normal file
230
lib/runtime/commands/command_executor.dart
Normal file
@@ -0,0 +1,230 @@
|
||||
import 'dart:async' as async;
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/effects.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../audio/runtime_audio_manager.dart';
|
||||
import '../lifecycle/runtime_task_registry.dart';
|
||||
import '../models/game_diff.dart';
|
||||
import '../models/runtime_command.dart';
|
||||
import '../models/runtime_event.dart';
|
||||
import '../models/runtime_node.dart';
|
||||
import '../protocol/runtime_protocol.dart';
|
||||
import '../rendering/render_tree_controller.dart';
|
||||
import '../resources/game_resource_manager.dart';
|
||||
import '../rendering/runtime_component.dart';
|
||||
import 'runtime_command_registry.dart';
|
||||
|
||||
// These part files keep CommandExecutor as a single private implementation
|
||||
// unit while grouping command handlers by responsibility. They are not a
|
||||
// plugin system and should not expose additional public API.
|
||||
part 'command_target_effects.dart';
|
||||
part 'command_composite.dart';
|
||||
part 'command_audio.dart';
|
||||
part 'command_resources.dart';
|
||||
part 'command_lifecycle_context.dart';
|
||||
part 'command_toast.dart';
|
||||
part 'command_validation.dart';
|
||||
part 'command_support.dart';
|
||||
|
||||
class CommandExecutor {
|
||||
CommandExecutor({
|
||||
required RenderTreeController renderTree,
|
||||
required void Function(RuntimeEvent event) eventSink,
|
||||
RuntimeAudioManager? audio,
|
||||
GameResourceManager? resources,
|
||||
Vector2? overlaySize,
|
||||
}) : _renderTree = renderTree,
|
||||
_eventSink = eventSink,
|
||||
_audio = audio,
|
||||
_resources = resources,
|
||||
_overlaySize = overlaySize ?? Vector2(720, 720);
|
||||
|
||||
final RenderTreeController _renderTree;
|
||||
final void Function(RuntimeEvent event) _eventSink;
|
||||
final RuntimeAudioManager? _audio;
|
||||
final GameResourceManager? _resources;
|
||||
final Vector2 _overlaySize;
|
||||
late final RuntimeTaskRegistry<_CommandResult> _tasks =
|
||||
RuntimeTaskRegistry<_CommandResult>(
|
||||
cancelledValue: _CommandResult.cancelled,
|
||||
);
|
||||
final RuntimeCommandRegistry _commandRegistry = RuntimeCommandRegistry();
|
||||
final Set<String> _ownedBgmChannels = {};
|
||||
final Map<String, Set<String>> _bgmChannelsByScope = {};
|
||||
final Map<String, String> _bgmScopeByChannel = {};
|
||||
int _toastSerial = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_commandRegistry.dispose();
|
||||
_tasks.dispose();
|
||||
final channels = _ownedBgmChannels.toList(growable: false);
|
||||
_ownedBgmChannels.clear();
|
||||
_bgmChannelsByScope.clear();
|
||||
_bgmScopeByChannel.clear();
|
||||
for (final channel in channels) {
|
||||
async.unawaited(_audio?.stopBgm(channel: channel));
|
||||
}
|
||||
}
|
||||
|
||||
void cancelScope(String scope) {
|
||||
_commandRegistry.cancelScope(scope);
|
||||
_tasks.cancelScope(scope);
|
||||
final channels = _bgmChannelsByScope.remove(scope) ?? const <String>{};
|
||||
for (final channel in channels) {
|
||||
_bgmScopeByChannel.remove(channel);
|
||||
_ownedBgmChannels.remove(channel);
|
||||
async.unawaited(_audio?.stopBgm(channel: channel));
|
||||
}
|
||||
}
|
||||
|
||||
void executeAll(List<RuntimeCommand> commands) {
|
||||
for (final command in commands) {
|
||||
execute(command);
|
||||
}
|
||||
}
|
||||
|
||||
void execute(RuntimeCommand command) {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
_validate(command);
|
||||
async.unawaited(_execute(command, const _CommandContext()));
|
||||
}
|
||||
|
||||
Future<_CommandResult> _execute(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
) async {
|
||||
if (_disposed) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
|
||||
final commandContext = _commandContextFor(command, context);
|
||||
final handle = _createCommandHandle(command, commandContext);
|
||||
try {
|
||||
if (handle?.isCancelled ?? false) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
final result = await _executeCore(command, commandContext, handle);
|
||||
if (handle?.isCancelled ?? false) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
handle?.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_CommandResult> _executeCore(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) async {
|
||||
if (_disposed || (handle?.isCancelled ?? false)) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
|
||||
switch (command.type) {
|
||||
case RuntimeCommandType.movePath:
|
||||
return _movePath(command, context, handle);
|
||||
case RuntimeCommandType.moveTo:
|
||||
return _targetEffect(command, context, handle, (component, duration) {
|
||||
return MoveToEffect(
|
||||
_requiredVector(command),
|
||||
EffectController(duration: duration),
|
||||
);
|
||||
});
|
||||
case RuntimeCommandType.fadeTo:
|
||||
return _targetEffect(command, context, handle, (component, duration) {
|
||||
final alpha = _requiredNormalizedDouble(
|
||||
command.payload['alpha'],
|
||||
'fade_to.alpha',
|
||||
);
|
||||
final start = component.renderAlpha;
|
||||
return FunctionEffect((progress, _) {
|
||||
final t = _readDouble(progress) ?? 1;
|
||||
component.setRuntimeAlpha(start + (alpha - start) * t);
|
||||
}, EffectController(duration: duration));
|
||||
});
|
||||
case RuntimeCommandType.scaleTo:
|
||||
return _targetEffect(command, context, handle, (component, duration) {
|
||||
final scale = _requiredDouble(
|
||||
command.payload['scale'],
|
||||
'scale_to.scale',
|
||||
);
|
||||
return ScaleEffect.to(
|
||||
Vector2.all(scale),
|
||||
EffectController(duration: duration),
|
||||
);
|
||||
});
|
||||
case RuntimeCommandType.rotateTo:
|
||||
return _targetEffect(command, context, handle, (component, duration) {
|
||||
final angle = _requiredDouble(
|
||||
command.payload['angle'],
|
||||
'rotate_to.angle',
|
||||
);
|
||||
return RotateEffect.to(angle, EffectController(duration: duration));
|
||||
});
|
||||
case RuntimeCommandType.removeNode:
|
||||
return _removeNode(command, context);
|
||||
case RuntimeCommandType.sequence:
|
||||
return _sequence(command, context, handle);
|
||||
case RuntimeCommandType.parallel:
|
||||
return _parallel(command, context, handle);
|
||||
case RuntimeCommandType.delay:
|
||||
return _delay(command, context, handle);
|
||||
case RuntimeCommandType.toast:
|
||||
return _toast(command, context, handle);
|
||||
case RuntimeCommandType.playSound:
|
||||
return _playSound(command, context, handle);
|
||||
case RuntimeCommandType.playBgm:
|
||||
return _playBgm(command, context, handle);
|
||||
case RuntimeCommandType.pauseBgm:
|
||||
return _controlBgm(command, context, _BgmControl.pause);
|
||||
case RuntimeCommandType.resumeBgm:
|
||||
return _controlBgm(command, context, _BgmControl.resume);
|
||||
case RuntimeCommandType.stopBgm:
|
||||
return _controlBgm(command, context, _BgmControl.stop);
|
||||
case RuntimeCommandType.preloadResources:
|
||||
return _preloadResources(command, context, handle);
|
||||
case RuntimeCommandType.evictResources:
|
||||
return _evictResources(command, context, handle);
|
||||
case RuntimeCommandType.cancelCommands:
|
||||
return _cancelCommands(command, context);
|
||||
case RuntimeCommandType.playSpineAnimation:
|
||||
return _playSpineAnimation(command, context);
|
||||
case RuntimeCommandType.copyText:
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: _requiredText(command, 'copy_text.text')),
|
||||
);
|
||||
_emitCommandCompletion(command, context);
|
||||
return _CommandResult.completed;
|
||||
default:
|
||||
throw UnsupportedError('Unsupported runtime command: ${command.type}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CommandContext {
|
||||
const _CommandContext({this.scope, this.scopeEpoch, this.group});
|
||||
|
||||
final String? scope;
|
||||
final int? scopeEpoch;
|
||||
final String? group;
|
||||
|
||||
_CommandContext copyWith({String? scope, int? scopeEpoch, String? group}) {
|
||||
return _CommandContext(
|
||||
scope: scope ?? this.scope,
|
||||
scopeEpoch: scopeEpoch ?? this.scopeEpoch,
|
||||
group: group ?? this.group,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _CommandResult { completed, cancelled }
|
||||
|
||||
enum _BgmControl { pause, resume, stop }
|
||||
154
lib/runtime/commands/command_lifecycle_context.dart
Normal file
154
lib/runtime/commands/command_lifecycle_context.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
part of 'command_executor.dart';
|
||||
|
||||
extension _CommandExecutorLifecycle on CommandExecutor {
|
||||
Future<_CommandResult> _delay(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) {
|
||||
final scope = _scopeFor(command, context, defaultTarget: false);
|
||||
final scopeEpoch = _scopeEpochFor(scope, context);
|
||||
final task = _registerTask(scope, handle);
|
||||
_schedule(_duration(command, defaultValue: 0), task, () {
|
||||
if ((handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) {
|
||||
task.cancel();
|
||||
return;
|
||||
}
|
||||
_emitCommandCompletion(
|
||||
command,
|
||||
context.copyWith(scope: scope, scopeEpoch: scopeEpoch),
|
||||
);
|
||||
task.complete(_CommandResult.completed);
|
||||
});
|
||||
return task.future;
|
||||
}
|
||||
|
||||
void _schedule(
|
||||
double seconds,
|
||||
RuntimeTask<_CommandResult> task,
|
||||
void Function() callback,
|
||||
) {
|
||||
void guardedCallback() {
|
||||
if (_disposed || task.isCancelled) {
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
if (seconds <= 0) {
|
||||
async.scheduleMicrotask(guardedCallback);
|
||||
return;
|
||||
}
|
||||
|
||||
late final async.Timer timer;
|
||||
timer = async.Timer(Duration(milliseconds: (seconds * 1000).round()), () {
|
||||
task.removeTimer(timer);
|
||||
guardedCallback();
|
||||
});
|
||||
task.addTimer(timer);
|
||||
}
|
||||
|
||||
RuntimeTask<_CommandResult> _registerTask(
|
||||
String? scope,
|
||||
RuntimeCommandHandle? handle,
|
||||
) {
|
||||
final task = _tasks.create(scope: scope);
|
||||
handle?.addCancelCallback(task.cancel);
|
||||
return task;
|
||||
}
|
||||
|
||||
_CommandContext _commandContextFor(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
) {
|
||||
return context.copyWith(group: _commandGroupFor(command, context));
|
||||
}
|
||||
|
||||
RuntimeCommandHandle? _createCommandHandle(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
) {
|
||||
if (command.type == RuntimeCommandType.cancelCommands) {
|
||||
return null;
|
||||
}
|
||||
final id = _optionalString(command.payload['id'], 'id');
|
||||
final group = context.group;
|
||||
final scope = _completionScopeFor(command, context);
|
||||
if (id == null && group == null && scope == null) {
|
||||
return null;
|
||||
}
|
||||
return _commandRegistry.create(id: id, group: group, scope: scope);
|
||||
}
|
||||
|
||||
_CommandContext _childContextFor(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
) {
|
||||
final scope = _scopeFor(command, context, defaultTarget: false);
|
||||
return context.copyWith(
|
||||
scope: scope,
|
||||
scopeEpoch: _scopeEpochFor(scope, context),
|
||||
group: _commandGroupFor(command, context),
|
||||
);
|
||||
}
|
||||
|
||||
String? _commandGroupFor(RuntimeCommand command, _CommandContext context) {
|
||||
final commandGroup = _optionalString(
|
||||
command.payload['commandGroup'],
|
||||
'commandGroup',
|
||||
);
|
||||
if (commandGroup != null) {
|
||||
return commandGroup;
|
||||
}
|
||||
if (_usesGroupAsCommandGroup(command.type)) {
|
||||
final legacyGroup = _optionalString(command.payload['group'], 'group');
|
||||
if (legacyGroup != null) {
|
||||
return legacyGroup;
|
||||
}
|
||||
}
|
||||
return context.group;
|
||||
}
|
||||
|
||||
bool _usesGroupAsCommandGroup(String commandType) {
|
||||
return commandType != RuntimeCommandType.preloadResources &&
|
||||
commandType != RuntimeCommandType.evictResources &&
|
||||
commandType != RuntimeCommandType.cancelCommands;
|
||||
}
|
||||
|
||||
String? _scopeFor(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context, {
|
||||
required bool defaultTarget,
|
||||
}) {
|
||||
final explicit = _optionalString(command.payload['scope'], 'scope');
|
||||
if (explicit != null) {
|
||||
return explicit;
|
||||
}
|
||||
if (context.scope != null) {
|
||||
return context.scope;
|
||||
}
|
||||
if (defaultTarget) {
|
||||
return command.target;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _completionScopeFor(RuntimeCommand command, _CommandContext context) {
|
||||
final explicit = _optionalString(command.payload['scope'], 'scope');
|
||||
return explicit ?? context.scope;
|
||||
}
|
||||
|
||||
int? _scopeEpochFor(String? scope, _CommandContext context) {
|
||||
if (scope == null) {
|
||||
return null;
|
||||
}
|
||||
if (scope == context.scope && context.scopeEpoch != null) {
|
||||
return context.scopeEpoch;
|
||||
}
|
||||
return _renderTree.epochOf(scope);
|
||||
}
|
||||
|
||||
bool _scopeIsAlive(String? scope) {
|
||||
return scope == null || _renderTree.contains(scope);
|
||||
}
|
||||
}
|
||||
83
lib/runtime/commands/command_resources.dart
Normal file
83
lib/runtime/commands/command_resources.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
part of 'command_executor.dart';
|
||||
|
||||
extension _CommandExecutorResources on CommandExecutor {
|
||||
Future<_CommandResult> _preloadResources(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) async {
|
||||
final group = _requiredResourceGroup(command);
|
||||
final failOnError =
|
||||
_optionalBool(
|
||||
command.payload['failOnError'],
|
||||
'preload_resources.failOnError',
|
||||
) ??
|
||||
false;
|
||||
final resources = _resources;
|
||||
final audio = _audio;
|
||||
if (resources != null && resources.hasPackage) {
|
||||
await resources.preloadGroup(group, failOnError: failOnError);
|
||||
}
|
||||
if (audio != null && audio.hasPackage) {
|
||||
await audio.preloadGroup(group, failOnError: failOnError);
|
||||
}
|
||||
if (handle?.isCancelled ?? false) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
_emitCommandCompletion(command, context);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
|
||||
Future<_CommandResult> _evictResources(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) async {
|
||||
final group = _requiredResourceGroup(command);
|
||||
final resources = _resources;
|
||||
final audio = _audio;
|
||||
if (resources != null && resources.hasPackage) {
|
||||
resources.evictGroup(group);
|
||||
}
|
||||
if (audio != null && audio.hasPackage) {
|
||||
audio.evictGroup(group);
|
||||
}
|
||||
if (handle?.isCancelled ?? false) {
|
||||
return _CommandResult.cancelled;
|
||||
}
|
||||
_emitCommandCompletion(command, context);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
|
||||
Future<_CommandResult> _cancelCommands(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
) async {
|
||||
final id = _optionalString(command.payload['id'], 'cancel_commands.id');
|
||||
final group = _optionalString(
|
||||
command.payload['group'],
|
||||
'cancel_commands.group',
|
||||
);
|
||||
final scope = _optionalString(
|
||||
command.payload['scope'],
|
||||
'cancel_commands.scope',
|
||||
);
|
||||
if (id == null && group == null && scope == null) {
|
||||
throw const FormatException(
|
||||
'cancel_commands requires id, group or scope',
|
||||
);
|
||||
}
|
||||
if (id != null) {
|
||||
_commandRegistry.cancelId(id);
|
||||
}
|
||||
if (group != null) {
|
||||
_commandRegistry.cancelGroup(group);
|
||||
}
|
||||
if (scope != null) {
|
||||
_commandRegistry.cancelScope(scope);
|
||||
_tasks.cancelScope(scope);
|
||||
}
|
||||
_emitCommandCompletion(command, context);
|
||||
return _CommandResult.completed;
|
||||
}
|
||||
}
|
||||
248
lib/runtime/commands/command_support.dart
Normal file
248
lib/runtime/commands/command_support.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
part of 'command_executor.dart';
|
||||
|
||||
extension _CommandExecutorSupport on CommandExecutor {
|
||||
void _appendCompletionEffect(
|
||||
List<Effect> effects,
|
||||
RuntimeCommand command,
|
||||
String target,
|
||||
int targetEpoch,
|
||||
RuntimeTask<_CommandResult> task,
|
||||
String? scope,
|
||||
int? scopeEpoch,
|
||||
) {
|
||||
effects.add(
|
||||
FunctionEffect((_, __) {
|
||||
if (!_scopeIsAlive(scope) ||
|
||||
!_renderTree.isNodeEpochAlive(target, targetEpoch)) {
|
||||
task.cancel();
|
||||
return;
|
||||
}
|
||||
_emitCompletion(command, target, scope, targetEpoch, scopeEpoch);
|
||||
task.complete(_CommandResult.completed);
|
||||
}, EffectController(duration: 0.01)),
|
||||
);
|
||||
}
|
||||
|
||||
void _emitCompletion(
|
||||
RuntimeCommand command,
|
||||
String target,
|
||||
String? scope, [
|
||||
int? targetEpoch,
|
||||
int? scopeEpoch,
|
||||
]) {
|
||||
final onComplete = _optionalString(
|
||||
command.payload['onComplete'],
|
||||
'onComplete',
|
||||
);
|
||||
if (onComplete == null) {
|
||||
return;
|
||||
}
|
||||
_emitEventIfScopeAlive(
|
||||
RuntimeEvent(
|
||||
type: RuntimeEventType.animationDone,
|
||||
target: target,
|
||||
handler: onComplete,
|
||||
),
|
||||
scope,
|
||||
targetEpoch: targetEpoch,
|
||||
scopeEpoch: scopeEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
void _emitCommandCompletion(RuntimeCommand command, _CommandContext context) {
|
||||
final onComplete = _optionalString(
|
||||
command.payload['onComplete'],
|
||||
'onComplete',
|
||||
);
|
||||
if (onComplete == null) {
|
||||
return;
|
||||
}
|
||||
_emitEventIfScopeAlive(
|
||||
RuntimeEvent(
|
||||
type: RuntimeEventType.animationDone,
|
||||
target: command.target,
|
||||
handler: onComplete,
|
||||
),
|
||||
_completionScopeFor(command, context),
|
||||
scopeEpoch: context.scopeEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
void _emitEventIfScopeAlive(
|
||||
RuntimeEvent event,
|
||||
String? scope, {
|
||||
int? targetEpoch,
|
||||
int? scopeEpoch,
|
||||
}) {
|
||||
if (!_scopeIsAlive(scope) || _disposed) {
|
||||
return;
|
||||
}
|
||||
_eventSink(
|
||||
event.withLifecycle(
|
||||
scope: scope,
|
||||
targetEpoch: targetEpoch,
|
||||
scopeEpoch: scopeEpoch,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Vector2 _requiredVector(RuntimeCommand command) {
|
||||
final x = _readDouble(command.payload['x']);
|
||||
final y = _readDouble(command.payload['y']);
|
||||
if (x == null || y == null) {
|
||||
throw FormatException('${command.type}.x/y are required numbers');
|
||||
}
|
||||
return Vector2(x, y);
|
||||
}
|
||||
|
||||
String _requiredTarget(RuntimeCommand command) {
|
||||
final target = command.target;
|
||||
if (target == null || target.isEmpty) {
|
||||
throw FormatException('${command.type}.target is required');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
String _requiredText(RuntimeCommand command, String field) {
|
||||
return _optionalString(command.payload['text'], field)!;
|
||||
}
|
||||
|
||||
double _duration(RuntimeCommand command, {required double defaultValue}) {
|
||||
final duration = _readDouble(command.payload['duration']) ?? defaultValue;
|
||||
if (duration < 0) {
|
||||
throw FormatException('${command.type}.duration must be >= 0');
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
double _requiredDouble(Object? value, String field) {
|
||||
final result = _readDouble(value);
|
||||
if (result == null) {
|
||||
throw FormatException('$field must be a number');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void _registerBgmChannel({required String channel, required String? scope}) {
|
||||
_unregisterBgmChannel(channel);
|
||||
_ownedBgmChannels.add(channel);
|
||||
if (scope == null) {
|
||||
return;
|
||||
}
|
||||
_bgmScopeByChannel[channel] = scope;
|
||||
_bgmChannelsByScope.putIfAbsent(scope, () => {}).add(channel);
|
||||
}
|
||||
|
||||
void _unregisterBgmChannel(String channel) {
|
||||
_ownedBgmChannels.remove(channel);
|
||||
final oldScope = _bgmScopeByChannel.remove(channel);
|
||||
if (oldScope == null) {
|
||||
return;
|
||||
}
|
||||
final channels = _bgmChannelsByScope[oldScope];
|
||||
channels?.remove(channel);
|
||||
if (channels != null && channels.isEmpty) {
|
||||
_bgmChannelsByScope.remove(oldScope);
|
||||
}
|
||||
}
|
||||
|
||||
String _audioChannel(RuntimeCommand command) {
|
||||
return _optionalString(command.payload['channel'], 'channel') ??
|
||||
RuntimeAudioChannel.defaultBgm;
|
||||
}
|
||||
|
||||
String _requiredResourceGroup(RuntimeCommand command) {
|
||||
return _optionalString(command.payload['group'], '${command.type}.group')!;
|
||||
}
|
||||
|
||||
void _validateCancelCommands(RuntimeCommand command) {
|
||||
final id = _optionalString(command.payload['id'], 'cancel_commands.id');
|
||||
final group = _optionalString(
|
||||
command.payload['group'],
|
||||
'cancel_commands.group',
|
||||
);
|
||||
final scope = _optionalString(
|
||||
command.payload['scope'],
|
||||
'cancel_commands.scope',
|
||||
);
|
||||
if (id == null && group == null && scope == null) {
|
||||
throw const FormatException(
|
||||
'cancel_commands requires id, group or scope',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _requiredAudioResource(RuntimeCommand command) {
|
||||
final asset = command.payload['asset'] ?? command.payload['name'];
|
||||
return _optionalString(asset, 'play_sound.asset/name')!;
|
||||
}
|
||||
|
||||
String _requiredSpineAnimation(RuntimeCommand command) {
|
||||
final value = _optionalString(
|
||||
command.payload['animation'],
|
||||
'play_spine_animation.animation',
|
||||
);
|
||||
if (value == null) {
|
||||
throw const FormatException(
|
||||
'play_spine_animation.animation must be a non-empty string',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool? _optionalBool(Object? value, String field) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is bool) {
|
||||
return value;
|
||||
}
|
||||
throw FormatException('$field must be a boolean');
|
||||
}
|
||||
|
||||
double _optionalVolume(RuntimeCommand command) {
|
||||
final value = command.payload['volume'];
|
||||
if (value == null) {
|
||||
return 1;
|
||||
}
|
||||
return _requiredNormalizedDouble(value, 'play_sound.volume');
|
||||
}
|
||||
|
||||
double _requiredNormalizedDouble(Object? value, String field) {
|
||||
final result = _requiredDouble(value, field);
|
||||
if (result < 0 || result > 1) {
|
||||
throw FormatException('$field must be between 0 and 1');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String? _optionalString(Object? value, String field) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw FormatException('$field must be a non-empty string');
|
||||
}
|
||||
|
||||
int? _optionalInt(Object? value, String field) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
throw FormatException('$field must be an integer');
|
||||
}
|
||||
|
||||
double? _readDouble(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
143
lib/runtime/commands/command_target_effects.dart
Normal file
143
lib/runtime/commands/command_target_effects.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
part of 'command_executor.dart';
|
||||
|
||||
extension _CommandExecutorTargetEffects on CommandExecutor {
|
||||
Future<_CommandResult> _movePath(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) {
|
||||
final target = _requiredTarget(command);
|
||||
final component = _renderTree.componentById(target);
|
||||
if (component == null) {
|
||||
return Future.value(_CommandResult.cancelled);
|
||||
}
|
||||
|
||||
final scope = _scopeFor(command, context, defaultTarget: true);
|
||||
final scopeEpoch = _scopeEpochFor(scope, context);
|
||||
final targetEpoch = _renderTree.epochOf(target);
|
||||
final task = _registerTask(scope, handle);
|
||||
final pathValue = command.payload['path'] as List;
|
||||
final duration = _duration(command, defaultValue: 0.4);
|
||||
final perStepDuration = duration / pathValue.length;
|
||||
final effects = <Effect>[];
|
||||
|
||||
for (final point in pathValue) {
|
||||
final map = point as Map;
|
||||
final x = _readDouble(map['x'])!;
|
||||
final y = _readDouble(map['y'])!;
|
||||
effects.add(
|
||||
MoveToEffect(
|
||||
Vector2(x, y),
|
||||
EffectController(duration: perStepDuration),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_appendCompletionEffect(
|
||||
effects,
|
||||
command,
|
||||
target,
|
||||
targetEpoch,
|
||||
task,
|
||||
scope,
|
||||
scopeEpoch,
|
||||
);
|
||||
final effect = SequenceEffect(effects);
|
||||
handle?.addCancelCallback(effect.removeFromParent);
|
||||
component.add(effect);
|
||||
return task.future;
|
||||
}
|
||||
|
||||
Future<_CommandResult> _targetEffect(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
Effect Function(RuntimeComponent component, double duration) factory,
|
||||
) {
|
||||
final target = _requiredTarget(command);
|
||||
final component = _renderTree.componentById(target);
|
||||
if (component == null) {
|
||||
return Future.value(_CommandResult.cancelled);
|
||||
}
|
||||
|
||||
final scope = _scopeFor(command, context, defaultTarget: true);
|
||||
final scopeEpoch = _scopeEpochFor(scope, context);
|
||||
final targetEpoch = _renderTree.epochOf(target);
|
||||
final task = _registerTask(scope, handle);
|
||||
final effects = <Effect>[
|
||||
factory(component, _duration(command, defaultValue: 0.2)),
|
||||
];
|
||||
_appendCompletionEffect(
|
||||
effects,
|
||||
command,
|
||||
target,
|
||||
targetEpoch,
|
||||
task,
|
||||
scope,
|
||||
scopeEpoch,
|
||||
);
|
||||
final effect = SequenceEffect(effects);
|
||||
handle?.addCancelCallback(effect.removeFromParent);
|
||||
component.add(effect);
|
||||
return task.future;
|
||||
}
|
||||
|
||||
Future<_CommandResult> _playSpineAnimation(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
) {
|
||||
final target = _requiredTarget(command);
|
||||
final component = _renderTree.componentById(target);
|
||||
if (component == null) {
|
||||
return Future.value(_CommandResult.cancelled);
|
||||
}
|
||||
|
||||
final animation = _requiredSpineAnimation(command);
|
||||
final track =
|
||||
_optionalInt(command.payload['track'], 'play_spine_animation.track') ??
|
||||
0;
|
||||
final loop =
|
||||
_optionalBool(command.payload['loop'], 'play_spine_animation.loop') ??
|
||||
true;
|
||||
final queue =
|
||||
_optionalBool(command.payload['queue'], 'play_spine_animation.queue') ??
|
||||
false;
|
||||
final delay = _readDouble(command.payload['delay']) ?? 0;
|
||||
if (track < 0) {
|
||||
throw const FormatException('play_spine_animation.track must be >= 0');
|
||||
}
|
||||
if (delay < 0) {
|
||||
throw const FormatException('play_spine_animation.delay must be >= 0');
|
||||
}
|
||||
|
||||
final played = component.playSpineAnimation(
|
||||
animation,
|
||||
track: track,
|
||||
loop: loop,
|
||||
queue: queue,
|
||||
delay: delay,
|
||||
);
|
||||
if (!played) {
|
||||
return Future.value(_CommandResult.cancelled);
|
||||
}
|
||||
final scope = _completionScopeFor(command, context);
|
||||
_emitCompletion(
|
||||
command,
|
||||
target,
|
||||
scope,
|
||||
_renderTree.epochOf(target),
|
||||
context.scopeEpoch,
|
||||
);
|
||||
return Future.value(_CommandResult.completed);
|
||||
}
|
||||
|
||||
Future<_CommandResult> _removeNode(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
) {
|
||||
final target = _requiredTarget(command);
|
||||
_renderTree.removeById(target);
|
||||
_emitCompletion(command, target, _completionScopeFor(command, context));
|
||||
return Future.value(_CommandResult.completed);
|
||||
}
|
||||
}
|
||||
96
lib/runtime/commands/command_toast.dart
Normal file
96
lib/runtime/commands/command_toast.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
part of 'command_executor.dart';
|
||||
|
||||
extension _CommandExecutorToast on CommandExecutor {
|
||||
Future<_CommandResult> _toast(
|
||||
RuntimeCommand command,
|
||||
_CommandContext context,
|
||||
RuntimeCommandHandle? handle,
|
||||
) {
|
||||
final text = _toastText(command);
|
||||
final duration = _duration(command, defaultValue: 1.8);
|
||||
final scope = _scopeFor(command, context, defaultTarget: false);
|
||||
final scopeEpoch = _scopeEpochFor(scope, context);
|
||||
final task = _registerTask(scope, handle);
|
||||
final toastId = 'runtime_toast_${++_toastSerial}';
|
||||
final toastTextId = '${toastId}_text';
|
||||
|
||||
_renderTree.apply(
|
||||
NodeDiff(
|
||||
creates: _toastNodes(id: toastId, textId: toastTextId, text: text),
|
||||
),
|
||||
);
|
||||
task.addCancelCallback(() => _renderTree.removeById(toastId));
|
||||
|
||||
_schedule(duration, task, () {
|
||||
if ((handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) {
|
||||
_renderTree.removeById(toastId);
|
||||
task.cancel();
|
||||
return;
|
||||
}
|
||||
_renderTree.removeById(toastId);
|
||||
_emitCommandCompletion(
|
||||
command,
|
||||
context.copyWith(scope: scope, scopeEpoch: scopeEpoch),
|
||||
);
|
||||
task.complete(_CommandResult.completed);
|
||||
});
|
||||
|
||||
return task.future;
|
||||
}
|
||||
|
||||
List<RuntimeNode> _toastNodes({
|
||||
required String id,
|
||||
required String textId,
|
||||
required String text,
|
||||
}) {
|
||||
const width = 360.0;
|
||||
const minHeight = 38.0;
|
||||
final lineCount = text.split('\n').length;
|
||||
final height = (minHeight + (lineCount - 1) * 16).clamp(38.0, 92.0);
|
||||
final x = ((_overlaySize.x - width) / 2).clamp(12.0, _overlaySize.x);
|
||||
final y = (_overlaySize.y - height - 58).clamp(12.0, _overlaySize.y);
|
||||
|
||||
return [
|
||||
RuntimeNode(
|
||||
id: id,
|
||||
type: RuntimeNodeType.panel,
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
color: const Color(0xee020617),
|
||||
radius: 12,
|
||||
layer: 10000,
|
||||
),
|
||||
RuntimeNode(
|
||||
id: textId,
|
||||
type: RuntimeNodeType.text,
|
||||
parent: id,
|
||||
text: text,
|
||||
x: 14,
|
||||
y: 0,
|
||||
width: width - 28,
|
||||
height: height,
|
||||
color: const Color(0xfff8fafc),
|
||||
fontSize: 13,
|
||||
textAlign: RuntimeTextAlignValue.center,
|
||||
layer: 10001,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String _toastText(RuntimeCommand command) {
|
||||
final text = _optionalString(command.payload['text'], 'toast.text');
|
||||
final message = _optionalString(
|
||||
command.payload['message'],
|
||||
'toast.message',
|
||||
);
|
||||
if (text != null) {
|
||||
return text;
|
||||
}
|
||||
if (message != null) {
|
||||
return message;
|
||||
}
|
||||
throw const FormatException('toast.text or toast.message is required');
|
||||
}
|
||||
}
|
||||
154
lib/runtime/commands/command_validation.dart
Normal file
154
lib/runtime/commands/command_validation.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
part of 'command_executor.dart';
|
||||
|
||||
extension _CommandExecutorValidation on CommandExecutor {
|
||||
void _validate(RuntimeCommand command) {
|
||||
if (!RuntimeCommandType.isSupported(command.type)) {
|
||||
throw UnsupportedError('Unsupported runtime command: ${command.type}');
|
||||
}
|
||||
RuntimeProtocolSchema.ensureKnownKeys(
|
||||
command.payload,
|
||||
allowed: RuntimeProtocolSchema.allowedCommandPayloadFields(command.type),
|
||||
context: 'RuntimeCommand.${command.type}.payload',
|
||||
);
|
||||
_optionalString(command.payload['id'], 'id');
|
||||
_optionalString(command.payload['group'], 'group');
|
||||
_optionalString(command.payload['commandGroup'], 'commandGroup');
|
||||
_optionalString(command.payload['scope'], 'scope');
|
||||
_estimatedDuration(command);
|
||||
}
|
||||
|
||||
double _estimatedDuration(RuntimeCommand command) {
|
||||
_optionalString(command.payload['onComplete'], 'onComplete');
|
||||
|
||||
switch (command.type) {
|
||||
case RuntimeCommandType.movePath:
|
||||
_requiredTarget(command);
|
||||
_validatePath(command.payload['path']);
|
||||
return _duration(command, defaultValue: 0.4);
|
||||
case RuntimeCommandType.moveTo:
|
||||
_requiredTarget(command);
|
||||
_requiredVector(command);
|
||||
return _duration(command, defaultValue: 0.2);
|
||||
case RuntimeCommandType.fadeTo:
|
||||
_requiredTarget(command);
|
||||
_requiredNormalizedDouble(command.payload['alpha'], 'fade_to.alpha');
|
||||
return _duration(command, defaultValue: 0.2);
|
||||
case RuntimeCommandType.scaleTo:
|
||||
_requiredTarget(command);
|
||||
_requiredDouble(command.payload['scale'], 'scale_to.scale');
|
||||
return _duration(command, defaultValue: 0.2);
|
||||
case RuntimeCommandType.rotateTo:
|
||||
_requiredTarget(command);
|
||||
_requiredDouble(command.payload['angle'], 'rotate_to.angle');
|
||||
return _duration(command, defaultValue: 0.2);
|
||||
case RuntimeCommandType.removeNode:
|
||||
_requiredTarget(command);
|
||||
return 0;
|
||||
case RuntimeCommandType.delay:
|
||||
return _duration(command, defaultValue: 0);
|
||||
case RuntimeCommandType.sequence:
|
||||
return _commandsFromPayload(
|
||||
command,
|
||||
).fold<double>(0, (sum, child) => sum + _estimatedDuration(child));
|
||||
case RuntimeCommandType.parallel:
|
||||
var maxDuration = 0.0;
|
||||
for (final child in _commandsFromPayload(command)) {
|
||||
final duration = _estimatedDuration(child);
|
||||
if (duration > maxDuration) {
|
||||
maxDuration = duration;
|
||||
}
|
||||
}
|
||||
return maxDuration;
|
||||
case RuntimeCommandType.toast:
|
||||
_toastText(command);
|
||||
return _duration(command, defaultValue: 1.8);
|
||||
case RuntimeCommandType.playSound:
|
||||
_requiredAudioResource(command);
|
||||
_optionalVolume(command);
|
||||
return 0;
|
||||
case RuntimeCommandType.playBgm:
|
||||
_requiredAudioResource(command);
|
||||
_optionalVolume(command);
|
||||
_audioChannel(command);
|
||||
_optionalBool(command.payload['loop'], 'play_bgm.loop');
|
||||
return 0;
|
||||
case RuntimeCommandType.pauseBgm:
|
||||
case RuntimeCommandType.resumeBgm:
|
||||
case RuntimeCommandType.stopBgm:
|
||||
_audioChannel(command);
|
||||
return 0;
|
||||
case RuntimeCommandType.preloadResources:
|
||||
_requiredResourceGroup(command);
|
||||
_optionalBool(
|
||||
command.payload['failOnError'],
|
||||
'preload_resources.failOnError',
|
||||
);
|
||||
return 0;
|
||||
case RuntimeCommandType.evictResources:
|
||||
_requiredResourceGroup(command);
|
||||
return 0;
|
||||
case RuntimeCommandType.cancelCommands:
|
||||
_validateCancelCommands(command);
|
||||
return 0;
|
||||
case RuntimeCommandType.playSpineAnimation:
|
||||
_requiredTarget(command);
|
||||
_requiredSpineAnimation(command);
|
||||
final track = _optionalInt(
|
||||
command.payload['track'],
|
||||
'play_spine_animation.track',
|
||||
);
|
||||
if (track != null && track < 0) {
|
||||
throw const FormatException(
|
||||
'play_spine_animation.track must be >= 0',
|
||||
);
|
||||
}
|
||||
_optionalBool(command.payload['loop'], 'play_spine_animation.loop');
|
||||
_optionalBool(command.payload['queue'], 'play_spine_animation.queue');
|
||||
final delay = _readDouble(command.payload['delay']);
|
||||
if (delay != null && delay < 0) {
|
||||
throw const FormatException(
|
||||
'play_spine_animation.delay must be >= 0',
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
case RuntimeCommandType.copyText:
|
||||
_requiredText(command, 'copy_text.text');
|
||||
return 0;
|
||||
default:
|
||||
throw UnsupportedError('Unsupported runtime command: ${command.type}');
|
||||
}
|
||||
}
|
||||
|
||||
void _validatePath(Object? pathValue) {
|
||||
if (pathValue is! List || pathValue.isEmpty) {
|
||||
throw const FormatException('move_path.path must be a non-empty list');
|
||||
}
|
||||
for (final point in pathValue) {
|
||||
if (point is! Map) {
|
||||
throw const FormatException('move_path.path item must be a map');
|
||||
}
|
||||
final x = _readDouble(point['x']);
|
||||
final y = _readDouble(point['y']);
|
||||
if (x == null || y == null) {
|
||||
throw const FormatException('move_path point requires x/y');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<RuntimeCommand> _commandsFromPayload(RuntimeCommand command) {
|
||||
final value = command.payload['commands'];
|
||||
if (value is! List) {
|
||||
throw FormatException('${command.type}.commands must be a list');
|
||||
}
|
||||
return value
|
||||
.map((item) {
|
||||
if (item is! Map) {
|
||||
throw FormatException(
|
||||
'${command.type}.commands item must be a map',
|
||||
);
|
||||
}
|
||||
return RuntimeCommand.fromMap(Map<String, Object?>.from(item));
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
146
lib/runtime/commands/runtime_command_registry.dart
Normal file
146
lib/runtime/commands/runtime_command_registry.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
class RuntimeCommandRegistry {
|
||||
final Set<RuntimeCommandHandle> _handles = {};
|
||||
final Map<String, Set<RuntimeCommandHandle>> _handlesById = {};
|
||||
final Map<String, Set<RuntimeCommandHandle>> _handlesByGroup = {};
|
||||
final Map<String, Set<RuntimeCommandHandle>> _handlesByScope = {};
|
||||
bool _disposed = false;
|
||||
|
||||
int get activeHandleCount => _handles.length;
|
||||
|
||||
RuntimeCommandHandle create({String? id, String? group, String? scope}) {
|
||||
if (_disposed) {
|
||||
throw StateError('RuntimeCommandRegistry has been disposed');
|
||||
}
|
||||
|
||||
late final RuntimeCommandHandle handle;
|
||||
handle = RuntimeCommandHandle._(
|
||||
id: id,
|
||||
group: group,
|
||||
scope: scope,
|
||||
onComplete: _unregister,
|
||||
);
|
||||
_handles.add(handle);
|
||||
_index(_handlesById, id, handle);
|
||||
_index(_handlesByGroup, group, handle);
|
||||
_index(_handlesByScope, scope, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
void cancelId(String id) {
|
||||
_cancelAll(_handlesById[id]);
|
||||
}
|
||||
|
||||
void cancelGroup(String group) {
|
||||
_cancelAll(_handlesByGroup[group]);
|
||||
}
|
||||
|
||||
void cancelScope(String scope) {
|
||||
_cancelAll(_handlesByScope[scope]);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
_cancelAll(_handles);
|
||||
_handles.clear();
|
||||
_handlesById.clear();
|
||||
_handlesByGroup.clear();
|
||||
_handlesByScope.clear();
|
||||
}
|
||||
|
||||
void _index(
|
||||
Map<String, Set<RuntimeCommandHandle>> index,
|
||||
String? key,
|
||||
RuntimeCommandHandle handle,
|
||||
) {
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
index.putIfAbsent(key, () => {}).add(handle);
|
||||
}
|
||||
|
||||
void _cancelAll(Set<RuntimeCommandHandle>? handles) {
|
||||
final snapshot = handles?.toList(growable: false) ?? const [];
|
||||
for (final handle in snapshot) {
|
||||
handle.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _unregister(RuntimeCommandHandle handle) {
|
||||
_handles.remove(handle);
|
||||
_unindex(_handlesById, handle.id, handle);
|
||||
_unindex(_handlesByGroup, handle.group, handle);
|
||||
_unindex(_handlesByScope, handle.scope, handle);
|
||||
}
|
||||
|
||||
void _unindex(
|
||||
Map<String, Set<RuntimeCommandHandle>> index,
|
||||
String? key,
|
||||
RuntimeCommandHandle handle,
|
||||
) {
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
final handles = index[key];
|
||||
handles?.remove(handle);
|
||||
if (handles != null && handles.isEmpty) {
|
||||
index.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeCommandHandle {
|
||||
RuntimeCommandHandle._({
|
||||
required this.id,
|
||||
required this.group,
|
||||
required this.scope,
|
||||
required void Function(RuntimeCommandHandle handle) onComplete,
|
||||
}) : _onComplete = onComplete;
|
||||
|
||||
final String? id;
|
||||
final String? group;
|
||||
final String? scope;
|
||||
final void Function(RuntimeCommandHandle handle) _onComplete;
|
||||
final List<void Function()> _cancelCallbacks = [];
|
||||
bool _cancelled = false;
|
||||
bool _completed = false;
|
||||
|
||||
bool get isCancelled => _cancelled;
|
||||
|
||||
bool get isCompleted => _completed;
|
||||
|
||||
void addCancelCallback(void Function() callback) {
|
||||
if (_cancelled) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
if (_completed) {
|
||||
return;
|
||||
}
|
||||
_cancelCallbacks.add(callback);
|
||||
}
|
||||
|
||||
void complete() {
|
||||
if (_completed) {
|
||||
return;
|
||||
}
|
||||
_completed = true;
|
||||
_cancelCallbacks.clear();
|
||||
_onComplete(this);
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
if (_cancelled || _completed) {
|
||||
return;
|
||||
}
|
||||
_cancelled = true;
|
||||
final callbacks = _cancelCallbacks.toList(growable: false);
|
||||
_cancelCallbacks.clear();
|
||||
for (final callback in callbacks) {
|
||||
callback();
|
||||
}
|
||||
complete();
|
||||
}
|
||||
}
|
||||
138
lib/runtime/diagnostics/runtime_diagnostics.dart
Normal file
138
lib/runtime/diagnostics/runtime_diagnostics.dart
Normal 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,
|
||||
}
|
||||
160
lib/runtime/display/runtime_viewport.dart
Normal file
160
lib/runtime/display/runtime_viewport.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
80
lib/runtime/events/runtime_event_dispatcher.dart
Normal file
80
lib/runtime/events/runtime_event_dispatcher.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
lib/runtime/events/runtime_event_gate.dart
Normal file
47
lib/runtime/events/runtime_event_gate.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
377
lib/runtime/game/flame_lua_game.dart
Normal file
377
lib/runtime/game/flame_lua_game.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
45
lib/runtime/game/lua_game_widget.dart
Normal file
45
lib/runtime/game/lua_game_widget.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/runtime/game/runtime_locale.dart
Normal file
162
lib/runtime/game/runtime_locale.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
7
lib/runtime/game/runtime_options.dart
Normal file
7
lib/runtime/game/runtime_options.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
class RuntimeOptions {
|
||||
const RuntimeOptions({this.runtimeLuaRoot = defaultRuntimeLuaRoot});
|
||||
|
||||
static const defaultRuntimeLuaRoot = 'assets/runtime/lua';
|
||||
|
||||
final String runtimeLuaRoot;
|
||||
}
|
||||
50
lib/runtime/lifecycle/runtime_async_gate.dart
Normal file
50
lib/runtime/lifecycle/runtime_async_gate.dart
Normal 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);
|
||||
}
|
||||
77
lib/runtime/lifecycle/runtime_serial_queue.dart
Normal file
77
lib/runtime/lifecycle/runtime_serial_queue.dart
Normal 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;
|
||||
}
|
||||
71
lib/runtime/lifecycle/runtime_session.dart
Normal file
71
lib/runtime/lifecycle/runtime_session.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
129
lib/runtime/lifecycle/runtime_task_registry.dart
Normal file
129
lib/runtime/lifecycle/runtime_task_registry.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
173
lib/runtime/models/game_diff.dart
Normal file
173
lib/runtime/models/game_diff.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/runtime/models/runtime_command.dart
Normal file
43
lib/runtime/models/runtime_command.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
64
lib/runtime/models/runtime_event.dart
Normal file
64
lib/runtime/models/runtime_event.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
572
lib/runtime/models/runtime_node.dart
Normal file
572
lib/runtime/models/runtime_node.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
96
lib/runtime/packages/game_package.dart
Normal file
96
lib/runtime/packages/game_package.dart
Normal 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 }
|
||||
231
lib/runtime/packages/game_package_activation_controller.dart
Normal file
231
lib/runtime/packages/game_package_activation_controller.dart
Normal 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;
|
||||
}
|
||||
265
lib/runtime/packages/game_package_manifest.dart
Normal file
265
lib/runtime/packages/game_package_manifest.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
226
lib/runtime/packages/game_package_repository.dart
Normal file
226
lib/runtime/packages/game_package_repository.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
117
lib/runtime/packages/package_verifier.dart
Normal file
117
lib/runtime/packages/package_verifier.dart
Normal 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];
|
||||
}
|
||||
}
|
||||
86
lib/runtime/packages/stable_package_store.dart
Normal file
86
lib/runtime/packages/stable_package_store.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
490
lib/runtime/protocol/runtime_protocol.dart
Normal file
490
lib/runtime/protocol/runtime_protocol.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
489
lib/runtime/rendering/render_tree_controller.dart
Normal file
489
lib/runtime/rendering/render_tree_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1075
lib/runtime/rendering/runtime_component.dart
Normal file
1075
lib/runtime/rendering/runtime_component.dart
Normal file
File diff suppressed because it is too large
Load Diff
66
lib/runtime/resources/game_resource_cache.dart
Normal file
66
lib/runtime/resources/game_resource_cache.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
28
lib/runtime/resources/game_resource_debug.dart
Normal file
28
lib/runtime/resources/game_resource_debug.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
186
lib/runtime/resources/game_resource_loading.dart
Normal file
186
lib/runtime/resources/game_resource_loading.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
279
lib/runtime/resources/game_resource_manager.dart
Normal file
279
lib/runtime/resources/game_resource_manager.dart
Normal 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;
|
||||
}
|
||||
91
lib/runtime/resources/resource_load_limiter.dart
Normal file
91
lib/runtime/resources/resource_load_limiter.dart
Normal 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';
|
||||
}
|
||||
296
lib/runtime/scripting/lua_dardo_script_engine.dart
Normal file
296
lib/runtime/scripting/lua_dardo_script_engine.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
13
lib/runtime/scripting/script_engine.dart
Normal file
13
lib/runtime/scripting/script_engine.dart
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user