Initial flame_lua_runtime package
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user