feat: add runtime i18n API with manifest translations

This commit is contained in:
gem
2026-06-12 10:19:14 +08:00
parent 79ee35db2f
commit 4ea3663853
8 changed files with 141 additions and 0 deletions

View File

@@ -181,6 +181,25 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
_resources = activation.resources; _resources = activation.resources;
_audio = activation.audio ?? _createAudioManager(); _audio = activation.audio ?? _createAudioManager();
_scriptEngine = activation.scriptEngine; _scriptEngine = activation.scriptEngine;
// 合并所有包的翻译字典,框架包先、游戏包后,后加载的覆盖先加载的。
final mergedTranslations = <String, Map<String, String>>{};
for (final package in activation.packages) {
for (final entry in package.manifest.translations.entries) {
mergedTranslations.putIfAbsent(entry.key, () => {})
.addAll(entry.value);
}
}
final resolvedLocale = RuntimeLocaleResolver.resolve(
requested: _localeOverride ?? PlatformDispatcher.instance.locale,
defaultLocale: activation.package.manifest.defaultLocale,
supportedLocales: activation.package.manifest.supportedLocales,
);
_scriptEngine.setTranslations(
locale: resolvedLocale.resolved,
translations: mergedTranslations,
);
_viewportConfig = activation.package.manifest.display.toViewportConfig(); _viewportConfig = activation.package.manifest.display.toViewportConfig();
_viewportRoot = PositionComponent(); _viewportRoot = PositionComponent();
add(_viewportRoot); add(_viewportRoot);

View File

@@ -186,6 +186,7 @@ class PackageActivationController {
_ensureContinue(shouldContinue); _ensureContinue(shouldContinue);
return PackageActivationPlan( return PackageActivationPlan(
package: candidate, package: candidate,
packages: allPackages,
initialDiff: diff, initialDiff: diff,
resources: preparedResources, resources: preparedResources,
scriptEngine: preparedScriptEngine, scriptEngine: preparedScriptEngine,
@@ -219,6 +220,7 @@ class PackageActivationController {
class PackageActivationPlan { class PackageActivationPlan {
const PackageActivationPlan({ const PackageActivationPlan({
required this.package, required this.package,
required this.packages,
required this.initialDiff, required this.initialDiff,
required this.resources, required this.resources,
required this.scriptEngine, required this.scriptEngine,
@@ -226,6 +228,7 @@ class PackageActivationPlan {
}); });
final GamePackage package; final GamePackage package;
final List<GamePackage> packages;
final GameDiff initialDiff; final GameDiff initialDiff;
final GameResourceManager resources; final GameResourceManager resources;
final ScriptEngine scriptEngine; final ScriptEngine scriptEngine;
@@ -235,6 +238,7 @@ class PackageActivationPlan {
class PackageActivationResult { class PackageActivationResult {
const PackageActivationResult({ const PackageActivationResult({
required this.package, required this.package,
required this.packages,
required this.initialDiff, required this.initialDiff,
required this.resources, required this.resources,
required this.scriptEngine, required this.scriptEngine,
@@ -244,6 +248,7 @@ class PackageActivationResult {
factory PackageActivationResult.fromPlan(PackageActivationPlan plan) { factory PackageActivationResult.fromPlan(PackageActivationPlan plan) {
return PackageActivationResult( return PackageActivationResult(
package: plan.package, package: plan.package,
packages: plan.packages,
initialDiff: plan.initialDiff, initialDiff: plan.initialDiff,
resources: plan.resources, resources: plan.resources,
scriptEngine: plan.scriptEngine, scriptEngine: plan.scriptEngine,
@@ -252,6 +257,7 @@ class PackageActivationResult {
} }
final GamePackage package; final GamePackage package;
final List<GamePackage> packages;
final GameDiff initialDiff; final GameDiff initialDiff;
final GameResourceManager resources; final GameResourceManager resources;
final ScriptEngine scriptEngine; final ScriptEngine scriptEngine;

View File

@@ -15,6 +15,7 @@ class GamePackageManifest {
this.display = const GameDisplayConfig(), this.display = const GameDisplayConfig(),
this.resources = const {}, this.resources = const {},
this.modules = const {}, this.modules = const {},
this.translations = const {},
this.base, this.base,
}); });
@@ -30,6 +31,10 @@ class GamePackageManifest {
final Map<String, GameResource> resources; final Map<String, GameResource> resources;
final Map<String, String> modules; final Map<String, String> modules;
/// 翻译字典locale → { key → translatedText }。
/// Runtime 会按包加载顺序合并,游戏包覆盖框架包。
final Map<String, Map<String, String>> translations;
/// 依赖的框架包 gameId。加载时会先加载框架包再加载游戏包。 /// 依赖的框架包 gameId。加载时会先加载框架包再加载游戏包。
final String? base; final String? base;
@@ -64,6 +69,26 @@ class GamePackageManifest {
final base = map['base'] as String?; final base = map['base'] as String?;
final translationsValue = map['translations'];
final translations = <String, Map<String, String>>{};
if (translationsValue is Map) {
for (final entry in translationsValue.entries) {
if (entry.key is! String || entry.value is! Map) {
throw const FormatException('manifest.translations must be a map');
}
final localeDict = <String, String>{};
for (final kv in (entry.value as Map).entries) {
if (kv.key is! String || kv.value is! String) {
throw const FormatException(
'manifest.translations values must be string maps',
);
}
localeDict[kv.key as String] = kv.value as String;
}
translations[entry.key as String] = localeDict;
}
}
final defaultLocale = (map['defaultLocale'] as String?) ?? 'en'; final defaultLocale = (map['defaultLocale'] as String?) ?? 'en';
final supportedLocales = _stringList( final supportedLocales = _stringList(
map, map,
@@ -95,6 +120,7 @@ class GamePackageManifest {
display: display, display: display,
resources: resources, resources: resources,
modules: modules, modules: modules,
translations: translations,
base: base, base: base,
); );
} }

View File

@@ -71,6 +71,69 @@ class LuaDardoScriptEngine implements ScriptEngine {
} }
} }
@override
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
}) {
_lua.getGlobal('runtime');
if (_lua.isNil(-1)) {
_lua.pop(1);
return;
}
_lua.newTable();
_lua.pushString(locale);
_lua.setField(-2, 'locale');
_lua.pushDartFunction((LuaState lua) {
lua.pushString(locale);
return 1;
});
_lua.setField(-2, 'get_locale');
_lua.pushDartFunction((LuaState lua) {
final key = lua.toStr(1);
if (key == null || key.isEmpty) {
if (lua.getTop() >= 2 && !lua.isNil(2)) {
lua.pushValue(2);
} else {
lua.pushString(key ?? '');
}
return 1;
}
final localeDict = translations[locale];
final value = localeDict?[key];
if (value != null) {
lua.pushString(value);
return 1;
}
for (final entry in translations.entries) {
if (entry.key != locale) {
final fallbackValue = entry.value[key];
if (fallbackValue != null) {
lua.pushString(fallbackValue);
return 1;
}
}
}
if (lua.getTop() >= 2 && !lua.isNil(2)) {
lua.pushValue(2);
} else {
lua.pushString(key);
}
return 1;
});
_lua.setField(-2, 't');
_lua.setField(-2, 'i18n');
_lua.pop(1);
}
@override @override
bool smokeTest(Map<String, Object?> context) { bool smokeTest(Map<String, Object?> context) {
_lua.getGlobal('smoke_test'); _lua.getGlobal('smoke_test');

View File

@@ -22,4 +22,10 @@ abstract interface class ScriptEngine {
GameDiff init(Map<String, Object?> context); GameDiff init(Map<String, Object?> context);
GameDiff dispatchEvent(RuntimeEvent event); GameDiff dispatchEvent(RuntimeEvent event);
/// 设置翻译字典和当前语言Lua 侧可通过 runtime.i18n.t(key, fallback) 查询。
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
});
} }

View File

@@ -138,6 +138,7 @@ void main() {
expect(script.events.map((event) => event.target), ['bad', 'good']); expect(script.events.map((event) => event.target), ['bad', 'good']);
}); });
}); });
} }
RuntimeSession _activeSession() { RuntimeSession _activeSession() {
@@ -177,4 +178,10 @@ class _FakeScriptEngine implements ScriptEngine {
} }
return GameDiff.empty; return GameDiff.empty;
} }
@override
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
}) {}
} }

View File

@@ -33,6 +33,7 @@ void main() {
await expectLater(game.callLua('host.ready'), throwsA(isA<StateError>())); await expectLater(game.callLua('host.ready'), throwsA(isA<StateError>()));
}); });
}); });
} }
class _FakeScriptEngine implements ScriptEngine { class _FakeScriptEngine implements ScriptEngine {
@@ -66,6 +67,12 @@ class _FakeScriptEngine implements ScriptEngine {
bool smokeTest(Map<String, Object?> context) { bool smokeTest(Map<String, Object?> context) {
throw UnimplementedError(); throw UnimplementedError();
} }
@override
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
}) {}
} }
class _FakePackageRepository implements GamePackageRepository { class _FakePackageRepository implements GamePackageRepository {

View File

@@ -217,6 +217,7 @@ void main() {
expect(store.markedPackages, [fallback.rootPath]); expect(store.markedPackages, [fallback.rootPath]);
}); });
}); });
} }
Map<String, Object?> _context(GamePackage package) { Map<String, Object?> _context(GamePackage package) {
@@ -393,6 +394,12 @@ class _FakeScriptEngine implements ScriptEngine {
@override @override
GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty; GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty;
@override
void setTranslations({
required String locale,
required Map<String, Map<String, String>> translations,
}) {}
} }
const _validScript = ''' const _validScript = '''