Initial flame_lua_runtime package

This commit is contained in:
gem
2026-06-07 22:53:58 +08:00
commit 733b2fb798
262 changed files with 28439 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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