feat: 火山玩法本地化

仍有硬编码残留,待处理
This commit is contained in:
2026-07-28 19:13:53 +08:00
parent 3de3f1a2e2
commit ca5b714124
72 changed files with 2634 additions and 626 deletions
@@ -15,7 +15,7 @@ MonoBehaviour:
m_DefaultGroup: 09546aca93a801441859af2a811fe53b
m_currentHash:
serializedVersion: 2
Hash: e92b9b9776d0601449874b8c1b0ed1ae
Hash: ae2a22805ec7ff55174c38995aa3e54d
m_OptimizeCatalogSize: 0
m_BuildRemoteCatalog: 0
m_BundleLocalCatalog: 0
@@ -68,6 +68,9 @@ MonoBehaviour:
- {fileID: 11400000, guid: af94af36950b08d4f9a5037546bbe040, type: 2}
- {fileID: 11400000, guid: 60fd2fd8c7094b242b01c63de65de9ea, type: 2}
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
- {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
- {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
- {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
m_BuildSettings:
m_CompileScriptsInVirtualMode: 0
m_CleanupStreamingAssetsAfterBuilds: 1
@@ -115,6 +118,9 @@ MonoBehaviour:
- Locale-zh-Hans
- SceneSO
- Locale-ja-JP
- Locale-pt-BR
- Locale-ru
- Locale-es
m_SchemaTemplates: []
m_GroupTemplateObjects:
- {fileID: 11400000, guid: 0c158052680a3e94184c450d00291514, type: 2}
@@ -35,6 +35,24 @@ MonoBehaviour:
m_SerializedLabels:
- Locale
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 9b1581a48f0ca11448dbddaf79c11252
m_Address: Russian (ru)
m_ReadOnly: 1
m_SerializedLabels:
- Locale
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 3cadb0d5f8bf5e64599f52e139f7e3cc
m_Address: Spanish (es)
m_ReadOnly: 1
m_SerializedLabels:
- Locale
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: d0c46f4777ac92249a43143a15892fd2
m_Address: Portuguese (Brazil) (pt-BR)
m_ReadOnly: 1
m_SerializedLabels:
- Locale
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -0,0 +1,31 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)
m_EditorClassIdentifier:
m_GroupName: Localization-String-Tables-Portuguese (Brazil) (pt-BR)
m_Data:
m_SerializedData: []
m_GUID: 67ced675275774640b77e0ded8750379
m_SerializeEntries:
- m_GUID: 63300a11309a7b34bbf7a345b123cab8
m_Address: ChapterInfo_pt-BR
m_ReadOnly: 1
m_SerializedLabels:
- Locale-pt-BR
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
m_Schemas:
- {fileID: 11400000, guid: b430e9b782d3119489b903dce4915542, type: 2}
- {fileID: 11400000, guid: 70748601b27158b47810538f6ed1c013, type: 2}
@@ -1,7 +1,8 @@
fileFormatVersion: 2
guid: e721272cd05718a4d8872d0f3e3f5b8c
DefaultImporter:
guid: 089fc86f714d6a94bafea3b117fbd639
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,31 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
m_Name: Localization-String-Tables-Russian (ru)
m_EditorClassIdentifier:
m_GroupName: Localization-String-Tables-Russian (ru)
m_Data:
m_SerializedData: []
m_GUID: c1e47d7d0d831ac4b881f19aa2bf17e0
m_SerializeEntries:
- m_GUID: c69caec2066296840887d7e0c2eff52e
m_Address: ChapterInfo_ru
m_ReadOnly: 1
m_SerializedLabels:
- Locale-ru
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
m_Schemas:
- {fileID: 11400000, guid: b251ae14d0b273843b5674d239daa31c, type: 2}
- {fileID: 11400000, guid: 808fc72235ececa47aa55960a94a9a9a, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1c592abe924a856439461f3785ec53da
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,31 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
m_Name: Localization-String-Tables-Spanish (es)
m_EditorClassIdentifier:
m_GroupName: Localization-String-Tables-Spanish (es)
m_Data:
m_SerializedData: []
m_GUID: c8471b00dabdd224ab9c17d71d951cbc
m_SerializeEntries:
- m_GUID: 728457f9c2ef69c489530047d495eca5
m_Address: ChapterInfo_es
m_ReadOnly: 1
m_SerializedLabels:
- Locale-es
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
m_Schemas:
- {fileID: 11400000, guid: 3ddf5f47a1b265049825b193e7e93fbd, type: 2}
- {fileID: 11400000, guid: 3e5e5c2400a39814994a9fcb8eaf49a6, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 26fd03652560e7f4abec092229fc38f2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)_BundledAssetGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
m_InternalBundleIdMode: 1
m_Compression: 1
m_IncludeAddressInCatalog: 1
m_IncludeGUIDInCatalog: 1
m_IncludeLabelsInCatalog: 1
m_InternalIdNamingMode: 0
m_CacheClearBehavior: 0
m_IncludeInBuild: 1
m_BundledAssetProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
m_ForceUniqueProvider: 0
m_UseAssetBundleCache: 1
m_UseAssetBundleCrc: 1
m_UseAssetBundleCrcForCachedBundles: 1
m_UseUWRForLocalBundles: 0
m_Timeout: 0
m_ChunkedTransfer: 0
m_RedirectLimit: -1
m_RetryCount: 0
m_BuildPath:
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
m_LoadPath:
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
m_BundleMode: 0
m_AssetBundleProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
m_BundleNaming: 1
m_AssetLoadMode: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b430e9b782d3119489b903dce4915542
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)_ContentUpdateGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
m_StaticContent: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 70748601b27158b47810538f6ed1c013
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
m_Name: Localization-String-Tables-Russian (ru)_BundledAssetGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
m_InternalBundleIdMode: 1
m_Compression: 1
m_IncludeAddressInCatalog: 1
m_IncludeGUIDInCatalog: 1
m_IncludeLabelsInCatalog: 1
m_InternalIdNamingMode: 0
m_CacheClearBehavior: 0
m_IncludeInBuild: 1
m_BundledAssetProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
m_ForceUniqueProvider: 0
m_UseAssetBundleCache: 1
m_UseAssetBundleCrc: 1
m_UseAssetBundleCrcForCachedBundles: 1
m_UseUWRForLocalBundles: 0
m_Timeout: 0
m_ChunkedTransfer: 0
m_RedirectLimit: -1
m_RetryCount: 0
m_BuildPath:
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
m_LoadPath:
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
m_BundleMode: 0
m_AssetBundleProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
m_BundleNaming: 1
m_AssetLoadMode: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b251ae14d0b273843b5674d239daa31c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
m_Name: Localization-String-Tables-Russian (ru)_ContentUpdateGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
m_StaticContent: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 808fc72235ececa47aa55960a94a9a9a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
m_Name: Localization-String-Tables-Spanish (es)_BundledAssetGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
m_InternalBundleIdMode: 1
m_Compression: 1
m_IncludeAddressInCatalog: 1
m_IncludeGUIDInCatalog: 1
m_IncludeLabelsInCatalog: 1
m_InternalIdNamingMode: 0
m_CacheClearBehavior: 0
m_IncludeInBuild: 1
m_BundledAssetProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
m_ForceUniqueProvider: 0
m_UseAssetBundleCache: 1
m_UseAssetBundleCrc: 1
m_UseAssetBundleCrcForCachedBundles: 1
m_UseUWRForLocalBundles: 0
m_Timeout: 0
m_ChunkedTransfer: 0
m_RedirectLimit: -1
m_RetryCount: 0
m_BuildPath:
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
m_LoadPath:
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
m_BundleMode: 0
m_AssetBundleProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
m_BundleNaming: 1
m_AssetLoadMode: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3ddf5f47a1b265049825b193e7e93fbd
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
m_Name: Localization-String-Tables-Spanish (es)_ContentUpdateGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
m_StaticContent: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3e5e5c2400a39814994a9fcb8eaf49a6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,249 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AibisDream.Framework;
using AibisDream.MiniGame.Language;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.Localization;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
using UnityEngine.TestTools;
namespace AibisDream.EditorTests.Huoshan
{
public sealed class ExpressionLocalizationTests
{
private const string CatalogPath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionContentCatalog.asset";
[TestCase(null, false)]
[TestCase("", false)]
[TestCase("plain", false)]
[TestCase("L10N.key", false)]
[TestCase("l10n.key", true)]
public void LocalizedParamPrefix_IsStrict(string value, bool expected)
{
Assert.That(LocalizationKit.IsLocalizedParam(value), Is.EqualTo(expected));
}
[Test]
public void LocalizedParamKey_StripsOnlyKnownPrefix()
{
Assert.That(LocalizationKit.GetL10NParamKey("l10n.hs.exp.log1.target"),
Is.EqualTo("hs.exp.log1.target"));
Assert.That(LocalizationKit.GetL10NParamKey("raw"), Is.EqualTo("raw"));
}
[UnityTest]
public IEnumerator LocalizeParamAsync_UsesExplicitLocaleAndDoesNotFallback()
{
Locale chinese = LocalizationEditorSettings.GetLocale(
new LocaleIdentifier("zh-Hans"));
Locale english = LocalizationEditorSettings.GetLocale(
new LocaleIdentifier("en"));
Assert.That(chinese, Is.Not.Null);
Assert.That(english, Is.Not.Null);
Locale previous = LocalizationSettings.SelectedLocale;
LocalizationSettings.SelectedLocale = english;
Task<string> chineseTask = LocalizationKit.LocalizeParamAsync(
"l10n.hs.exp.log1.target",
chinese);
Task<string> englishTask = LocalizationKit.LocalizeParamAsync(
"l10n.hs.exp.log1.target",
english);
while (!chineseTask.IsCompleted || !englishTask.IsCompleted)
yield return null;
LocalizationSettings.SelectedLocale = previous;
Assert.That(chineseTask.Result, Is.EqualTo("我就是个笑话"));
Assert.That(englishTask.Result, Is.Empty);
}
[UnityTest]
public IEnumerator LocalizeParamAsync_ReturnsRawTextAndMissingKeyMarker()
{
Task<string> rawTask = LocalizationKit.LocalizeParamAsync("raw text");
while (!rawTask.IsCompleted)
yield return null;
Assert.That(rawTask.Result, Is.EqualTo("raw text"));
Locale chinese = LocalizationEditorSettings.GetLocale(
new LocaleIdentifier("zh-Hans"));
LogAssert.Expect(
LogType.Error,
new Regex(@"Params 缺少条目.*Key=hs\.exp\.missing.*Locale=zh-Hans"));
Task<string> missingTask = LocalizationKit.LocalizeParamAsync(
"l10n.hs.exp.missing",
chinese);
while (!missingTask.IsCompleted)
yield return null;
Assert.That(missingTask.Result, Is.EqualTo("⟦hs.exp.missing⟧"));
}
[Test]
public void LegacyCommonParams_ArePresentInUnityParams()
{
StringTableCollection collection =
LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable);
Assert.That(collection, Is.Not.Null);
string[] legacyKeys = { "天空", "海报", "植物", "朋友", "情绪", "记忆", "逻辑" };
foreach (string key in legacyKeys)
Assert.That(collection.SharedData.GetEntry(key), Is.Not.Null, key);
Assert.That(AssetDatabase.LoadAssetAtPath<TextAsset>(
"Assets/StreamingAssets/Config/params.csv"), Is.Null);
}
[Test]
public void ExpressionCatalog_IsValidAndCaseSensitive()
{
ExpressionContentCatalog catalog =
AssetDatabase.LoadAssetAtPath<ExpressionContentCatalog>(CatalogPath);
Assert.That(catalog, Is.Not.Null);
Assert.That(catalog.GetValidationErrors(), Is.Empty);
Assert.That(catalog.ActorLoopReference, Is.EqualTo("l10n.hs.exp.actor.loop"));
Assert.That(catalog.Rounds.Select(round => round.Id),
Is.EqualTo(new[] { "log1", "log2", "log3" }));
Assert.That(catalog.TryGetRound("log1", out ExpressionRoundDefinition log1), Is.True);
Assert.That(log1.NonTargetParticleCount, Is.EqualTo(16));
Assert.That(catalog.TryGetRound("LOG1", out _), Is.False);
Assert.That(catalog.TryGetRound("missing", out _), Is.False);
}
[Test]
public void ExpressionCatalog_RejectsDuplicateIdsAndNonLocalizedReferences()
{
ExpressionContentCatalog catalog =
ScriptableObject.CreateInstance<ExpressionContentCatalog>();
try
{
SetPrivateField(catalog, "actorLoopReference", "raw");
var first = new ExpressionRoundDefinition();
var second = new ExpressionRoundDefinition();
ConfigureRound(first, "log1", "l10n.target", "l10n.tokens", 1);
ConfigureRound(second, "log1", "raw", "l10n.tokens", 1);
SetPrivateField(
catalog,
"rounds",
new List<ExpressionRoundDefinition> { first, second });
string errors = string.Join("\n", catalog.GetValidationErrors());
Assert.That(errors, Does.Contain("Actor Loop"));
Assert.That(errors, Does.Contain("重复"));
Assert.That(errors, Does.Contain("l10n.*"));
}
finally
{
Object.DestroyImmediate(catalog);
}
}
[Test]
public void TokenParser_TrimsDropsEmptyAndRejectsFullwidthSeparator()
{
Assert.That(
LanguageYarnCommand.TryParseExpressionTokens(
" 哈哈 | | 嘿嘿|呵呵 ",
out var tokens),
Is.True);
Assert.That(tokens, Is.EqualTo(new[] { "哈哈", "嘿嘿", "呵呵" }));
LogAssert.Expect(LogType.Error, new Regex("全角分隔符"));
Assert.That(
LanguageYarnCommand.TryParseExpressionTokens("哈哈|嘿嘿", out _),
Is.False);
Assert.That(
LanguageYarnCommand.TryParseExpressionTokens(" | | ", out _),
Is.False);
}
[Test]
public void UnicodeTokenizer_PreservesTextElementsAndDropsWhitespace()
{
string combining = "e\u0301";
var elements =
ExpressionTextTokenizer.GetVisibleElements($"中 A あ {combining} 😀。");
Assert.That(
elements,
Is.EqualTo(new[] { "中", "A", "あ", combining, "😀", "。" }));
}
[Test]
public void UnicodeLayout_PreservesWordGapWithoutWhitespaceParticle()
{
ExpressionTextTokenizer.Layout layout =
ExpressionTextTokenizer.BuildLayout("A B", 1f, 0.6f);
Assert.That(layout.VisibleElements, Is.EqualTo(new[] { "A", "B" }));
Assert.That(layout.Offsets[0], Is.EqualTo(-0.8f).Within(0.0001f));
Assert.That(layout.Offsets[1], Is.EqualTo(0.8f).Within(0.0001f));
}
[Test]
public void UnicodeSequenceMatch_UsesWholeTextElements()
{
var source = ExpressionTextTokenizer.GetVisibleElements("A😀e\u0301。");
var fragment = ExpressionTextTokenizer.GetVisibleElements("😀e\u0301");
Assert.That(ExpressionTextTokenizer.FindVisibleSequence(source, fragment),
Is.EqualTo(1));
Assert.That(
ExpressionTextTokenizer.FindVisibleSequence(
source,
ExpressionTextTokenizer.GetVisibleElements("😀x")),
Is.EqualTo(-1));
}
[Test]
public void ExpressContentValidation_HasNoBlockingErrors()
{
var issues = ExpressionLocalizationValidator.Validate();
string errors = string.Join(
"\n",
issues
.Where(issue =>
issue.Severity == ExpressionLocalizationIssueSeverity.Error)
.Select(issue => issue.Message));
Assert.That(errors, Is.Empty);
Assert.That(
issues.Any(issue =>
issue.Severity == ExpressionLocalizationIssueSeverity.Warning),
Is.True,
"英日空条目应作为待翻译警告保留。");
}
private static void ConfigureRound(
ExpressionRoundDefinition round,
string id,
string target,
string tokens,
int nonTargetCount)
{
SetPrivateField(round, "id", id);
SetPrivateField(round, "targetReference", target);
SetPrivateField(round, "tokenReference", tokens);
SetPrivateField(round, "nonTargetParticleCount", nonTargetCount);
}
private static void SetPrivateField(object target, string fieldName, object value)
{
FieldInfo field = target.GetType().GetField(
fieldName,
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null, fieldName);
field.SetValue(target, value);
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: c12e0d2c486f4ea6a5d4f70878b6e7c1
guid: d6b8797332ac4876b66e381ea3c98723
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -0,0 +1,292 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AibisDream.Framework;
using AibisDream.MiniGame.Language;
using AibisDream.Utility;
using UnityEditor;
using UnityEditor.Localization;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Tables;
namespace AibisDream.EditorTests.Huoshan
{
public enum ExpressionLocalizationIssueSeverity
{
Warning,
Error
}
public readonly struct ExpressionLocalizationIssue
{
public ExpressionLocalizationIssue(
ExpressionLocalizationIssueSeverity severity,
string message)
{
Severity = severity;
Message = message;
}
public ExpressionLocalizationIssueSeverity Severity { get; }
public string Message { get; }
}
/// <summary>
/// 校验 Stage6 Express 命令、轮次 Catalog 与 Params String Table 的引用关系。
/// 英日空值是待翻译警告,不阻断内容校验。
/// </summary>
public static class ExpressionLocalizationValidator
{
private const string Stage6Path = "Assets/Yarn/FP/FP_Huoshan1/Stage6.yarn";
private const string CatalogPath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionContentCatalog.asset";
private static readonly Regex YarnCommandRegex =
new(@"^\s*<<(?<command>[A-Za-z0-9_]+)(?<arguments>.*?)>>", RegexOptions.Compiled);
private static readonly Regex QuotedArgumentRegex =
new("\"(?<value>(?:\\\\.|[^\"])*)\"", RegexOptions.Compiled);
private static readonly Regex L10NRegex =
new(@"l10n\.(?<key>[A-Za-z0-9._-]+)", RegexOptions.Compiled);
private static readonly Regex ChineseRegex =
new(@"[\u3400-\u9FFF]", RegexOptions.Compiled);
private static readonly Dictionary<string, int[]> VisibleArgumentIndices =
new(StringComparer.Ordinal)
{
["start_expression"] = new[] { 0, 1 },
["expression_lie_begin"] = new[] { 0 },
["expression_truth_lie_flicker"] = new[] { 0, 1 },
["expression_lie_morph_truth"] = new[] { 0, 1 },
["expression_truth_leak"] = new[] { 0 },
["expression_lie_shuffle"] = new[] { 0 },
["expression_actor_flash"] = new[] { 0 },
["expression_actor_flash_in"] = new[] { 0 },
["expression_flash_in"] = new[] { 0 },
["expression_actor_truth_flash"] = new[] { 0 },
["expression_truth_flash"] = new[] { 0 },
["expression_actor_word_hit"] = new[] { 0 },
["expression_word_hit"] = new[] { 0 },
["expression_actor_slot"] = new[] { 0, 1 },
["expression_actor_scroll"] = new[] { 0 },
["expression_truth_attack"] = new[] { 0 },
["expression_truth_destabilize"] = new[] { 0 },
["expression_truth_settle"] = new[] { 0 }
};
[MenuItem("Tools/AIBIS/Huoshan/Validate Express Localization")]
public static void ValidateFromMenu()
{
IReadOnlyList<ExpressionLocalizationIssue> issues = Validate();
int errorCount = issues.Count(issue =>
issue.Severity == ExpressionLocalizationIssueSeverity.Error);
int warningCount = issues.Count - errorCount;
foreach (ExpressionLocalizationIssue issue in issues)
{
if (issue.Severity == ExpressionLocalizationIssueSeverity.Error)
Debug.LogError($"[ExpressL10N] {issue.Message}");
else
Debug.LogWarning($"[ExpressL10N] {issue.Message}");
}
if (errorCount == 0)
{
Debug.Log(
$"[ExpressL10N] 校验通过。errors=0, pending translations={warningCount}");
}
}
public static IReadOnlyList<ExpressionLocalizationIssue> Validate()
{
var issues = new List<ExpressionLocalizationIssue>();
var referencedKeys = new HashSet<string>(StringComparer.Ordinal);
ValidateStage6(issues, referencedKeys);
ValidateCatalog(issues, referencedKeys);
ValidateParams(issues, referencedKeys);
return issues;
}
private static void ValidateStage6(
List<ExpressionLocalizationIssue> issues,
HashSet<string> referencedKeys)
{
if (!File.Exists(Stage6Path))
{
AddError(issues, $"找不到 Stage6 Yarn{Stage6Path}");
return;
}
string[] lines = File.ReadAllLines(Stage6Path);
for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++)
{
string line = lines[lineIndex];
if (line.TrimStart().StartsWith("//", StringComparison.Ordinal))
continue;
foreach (Match keyMatch in L10NRegex.Matches(line))
referencedKeys.Add(keyMatch.Groups["key"].Value);
Match commandMatch = YarnCommandRegex.Match(line);
if (!commandMatch.Success)
continue;
string command = commandMatch.Groups["command"].Value;
if (!VisibleArgumentIndices.TryGetValue(command, out int[] visibleIndices))
continue;
string[] arguments = QuotedArgumentRegex
.Matches(commandMatch.Groups["arguments"].Value)
.Cast<Match>()
.Select(match => match.Groups["value"].Value)
.ToArray();
foreach (int argumentIndex in visibleIndices)
{
if (argumentIndex >= arguments.Length)
continue;
string value = arguments[argumentIndex];
if (!LocalizationKit.IsLocalizedParam(value) && ChineseRegex.IsMatch(value))
{
AddError(
issues,
$"{Stage6Path}:{lineIndex + 1} 的 {command} 玩家文字参数仍为中文硬编码:{value}");
}
}
}
}
private static void ValidateCatalog(
List<ExpressionLocalizationIssue> issues,
HashSet<string> referencedKeys)
{
ExpressionContentCatalog catalog =
AssetDatabase.LoadAssetAtPath<ExpressionContentCatalog>(CatalogPath);
if (catalog == null)
{
AddError(issues, $"找不到轮次 Catalog{CatalogPath}");
return;
}
foreach (string validationError in catalog.GetValidationErrors())
AddError(issues, validationError);
AddCatalogReference(catalog.ActorLoopReference, referencedKeys);
foreach (ExpressionRoundDefinition round in catalog.Rounds)
{
if (round == null)
continue;
AddCatalogReference(round.TargetReference, referencedKeys);
AddCatalogReference(round.TokenReference, referencedKeys);
}
}
private static void ValidateParams(
List<ExpressionLocalizationIssue> issues,
HashSet<string> referencedKeys)
{
StringTableCollection collection =
LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable);
if (collection == null)
{
AddError(issues, $"找不到 String Table Collection{ConstRef.ParamsTable}");
return;
}
StringTable chinese =
collection.GetTable(new LocaleIdentifier("zh-Hans")) as StringTable;
StringTable english =
collection.GetTable(new LocaleIdentifier("en")) as StringTable;
StringTable japanese =
collection.GetTable(new LocaleIdentifier("ja-JP")) as StringTable;
foreach (string key in referencedKeys.OrderBy(value => value, StringComparer.Ordinal))
{
if (collection.SharedData.GetEntry(key) == null)
{
AddError(issues, $"Params Shared Data 缺少 Key{key}");
continue;
}
StringTableEntry chineseEntry = chinese?.GetEntry(key);
if (string.IsNullOrWhiteSpace(chineseEntry?.LocalizedValue))
{
AddError(issues, $"Params 中文值为空:{key}");
continue;
}
if (key.EndsWith(".tokens", StringComparison.Ordinal) ||
string.Equals(key, "hs.exp.actor.loop", StringComparison.Ordinal))
{
ValidatePipeList(issues, key, chineseEntry.LocalizedValue);
}
AddPendingTranslation(issues, english, "en", key);
AddPendingTranslation(issues, japanese, "ja-JP", key);
}
}
private static void ValidatePipeList(
List<ExpressionLocalizationIssue> issues,
string key,
string value)
{
if (value.Contains(''))
{
AddError(issues, $"Params/{key} 使用了全角分隔符“|”,必须使用半角“|”。");
return;
}
bool hasToken = value
.Split('|')
.Any(token => !string.IsNullOrWhiteSpace(token));
if (!hasToken)
AddError(issues, $"Params/{key} 没有有效项。");
}
private static void AddPendingTranslation(
List<ExpressionLocalizationIssue> issues,
StringTable table,
string localeCode,
string key)
{
StringTableEntry entry = table?.GetEntry(key);
if (entry == null)
{
AddError(issues, $"Params/{key} 缺少 {localeCode} 条目。");
return;
}
if (string.IsNullOrEmpty(entry.LocalizedValue))
{
issues.Add(new ExpressionLocalizationIssue(
ExpressionLocalizationIssueSeverity.Warning,
$"待翻译:Params/{key}, Locale={localeCode}"));
}
}
private static void AddCatalogReference(
string reference,
HashSet<string> referencedKeys)
{
if (LocalizationKit.IsLocalizedParam(reference))
referencedKeys.Add(LocalizationKit.GetL10NParamKey(reference));
}
private static void AddError(
List<ExpressionLocalizationIssue> issues,
string message)
{
issues.Add(new ExpressionLocalizationIssue(
ExpressionLocalizationIssueSeverity.Error,
message));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5c602299e38f4bf4899abfbcbed84d77
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a86a72005fba4c99a59962fb9e92df83, type: 3}
m_Name: ExpressionContentCatalog
m_EditorClassIdentifier:
actorLoopReference: l10n.hs.exp.actor.loop
rounds:
- id: log1
targetReference: l10n.hs.exp.log1.target
tokenReference: l10n.hs.exp.log1.tokens
nonTargetParticleCount: 16
- id: log2
targetReference: l10n.hs.exp.log2.target
tokenReference: l10n.hs.exp.log2.tokens
nonTargetParticleCount: 28
- id: log3
targetReference: l10n.hs.exp.log3.target
tokenReference: l10n.hs.exp.log3.tokens
nonTargetParticleCount: 38
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b2d94952a18d4d178421691ab103f0fd
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -197,7 +197,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 107.3, y: -4}
m_AnchoredPosition: {x: 107.3, y: -8}
m_SizeDelta: {x: 106, y: 20}
m_Pivot: {x: 0, y: 1}
--- !u!222 &6236610923486505318
@@ -756,7 +756,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 107.29999, y: -25}
m_AnchoredPosition: {x: 107.29999, y: -22.6}
m_SizeDelta: {x: 96, y: 20}
m_Pivot: {x: 0, y: 1}
--- !u!222 &6685312199246720920
@@ -280,7 +280,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 132.9, y: -5}
m_AnchoredPosition: {x: 132.9, y: -9}
m_SizeDelta: {x: 132.5, y: 25}
m_Pivot: {x: 0, y: 1}
--- !u!222 &3142962418811844898
@@ -743,7 +743,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 132.90002, y: -31.25}
m_AnchoredPosition: {x: 132.90002, y: -29.9}
m_SizeDelta: {x: 120, y: 25}
m_Pivot: {x: 0, y: 1}
--- !u!222 &2054489642493287172
@@ -17,6 +17,9 @@ MonoBehaviour:
- {fileID: 11400000, guid: 8906df9235e52fb41b11fe8776a77032, type: 2}
- {fileID: 11400000, guid: bdf8ad79a9f707b45805e23c25ccdadb, type: 2}
- {fileID: 11400000, guid: 9d8accfdeabc8034c986233920a17184, type: 2}
- {fileID: 11400000, guid: 63300a11309a7b34bbf7a345b123cab8, type: 2}
- {fileID: 11400000, guid: c69caec2066296840887d7e0c2eff52e, type: 2}
- {fileID: 11400000, guid: 728457f9c2ef69c489530047d495eca5, type: 2}
m_Extensions: []
m_Group: String Table
references:
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: ChapterInfo_es
m_EditorClassIdentifier:
m_LocaleId:
m_Code: es
m_SharedData: {fileID: 11400000, guid: 7ea8c68fdc624de4c86434963c06ec55, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 728457f9c2ef69c489530047d495eca5
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: ChapterInfo_pt-BR
m_EditorClassIdentifier:
m_LocaleId:
m_Code: pt-BR
m_SharedData: {fileID: 11400000, guid: 7ea8c68fdc624de4c86434963c06ec55, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 63300a11309a7b34bbf7a345b123cab8
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: ChapterInfo_ru
m_EditorClassIdentifier:
m_LocaleId:
m_Code: ru
m_SharedData: {fileID: 11400000, guid: 7ea8c68fdc624de4c86434963c06ec55, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c69caec2066296840887d7e0c2eff52e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -43,6 +43,118 @@ MonoBehaviour:
m_Key: "\u903B\u8F91"
m_Metadata:
m_Items: []
- m_Id: 200000000000001001
m_Key: hs.exp.log1.target
m_Metadata:
m_Items: []
- m_Id: 200000000000001002
m_Key: hs.exp.log1.tokens
m_Metadata:
m_Items: []
- m_Id: 200000000000001003
m_Key: hs.exp.log2.target
m_Metadata:
m_Items: []
- m_Id: 200000000000001004
m_Key: hs.exp.log2.tokens
m_Metadata:
m_Items: []
- m_Id: 200000000000001005
m_Key: hs.exp.log3.target
m_Metadata:
m_Items: []
- m_Id: 200000000000001006
m_Key: hs.exp.log3.tokens
m_Metadata:
m_Items: []
- m_Id: 200000000000001007
m_Key: hs.exp.actor.loop
m_Metadata:
m_Items: []
- m_Id: 200000000000001008
m_Key: hs.exp.atk.why
m_Metadata:
m_Items: []
- m_Id: 200000000000001009
m_Key: hs.exp.atk.doubt
m_Metadata:
m_Items: []
- m_Id: 200000000000001010
m_Key: hs.exp.atk.last
m_Metadata:
m_Items: []
- m_Id: 200000000000001011
m_Key: hs.exp.atk.weather
m_Metadata:
m_Items: []
- m_Id: 200000000000001012
m_Key: hs.exp.atk.unsold
m_Metadata:
m_Items: []
- m_Id: 200000000000001013
m_Key: hs.exp.atk.explain
m_Metadata:
m_Items: []
- m_Id: 200000000000001014
m_Key: hs.exp.atk.refuse
m_Metadata:
m_Items: []
- m_Id: 200000000000001015
m_Key: hs.exp.atk.trust
m_Metadata:
m_Items: []
- m_Id: 200000000000001016
m_Key: hs.exp.atk.enough
m_Metadata:
m_Items: []
- m_Id: 200000000000001017
m_Key: hs.exp.atk.damn
m_Metadata:
m_Items: []
- m_Id: 200000000000001018
m_Key: hs.exp.atk.useless
m_Metadata:
m_Items: []
- m_Id: 200000000000001019
m_Key: hs.exp.atk.prove
m_Metadata:
m_Items: []
- m_Id: 200000000000001020
m_Key: hs.exp.atk.nothing
m_Metadata:
m_Items: []
- m_Id: 200000000000001021
m_Key: hs.exp.atk.catchup
m_Metadata:
m_Items: []
- m_Id: 200000000000001022
m_Key: hs.exp.atk.yingli
m_Metadata:
m_Items: []
- m_Id: 200000000000001023
m_Key: hs.exp.atk.courage
m_Metadata:
m_Items: []
- m_Id: 200000000000001024
m_Key: hs.exp.atk.say
m_Metadata:
m_Items: []
- m_Id: 200000000000001025
m_Key: hs.exp.atk.trash
m_Metadata:
m_Items: []
- m_Id: 200000000000001026
m_Key: hs.exp.atk.hopeless
m_Metadata:
m_Items: []
- m_Id: 200000000000001027
m_Key: hs.exp.atk.why_loop
m_Metadata:
m_Items: []
- m_Id: 200000000000001028
m_Key: hs.exp.final.win
m_Metadata:
m_Items: []
m_Metadata:
m_Items: []
m_KeyGenerator:
+112
View File
@@ -46,6 +46,118 @@ MonoBehaviour:
m_Localized: Logic
m_Metadata:
m_Items: []
- m_Id: 200000000000001001
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001002
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001003
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001004
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001005
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001006
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001007
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001008
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001009
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001010
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001011
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001012
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001013
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001014
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001015
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001016
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001017
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001018
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001019
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001020
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001021
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001022
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001023
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001024
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001025
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001026
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001027
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001028
m_Localized:
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
+23
View File
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: Params_es
m_EditorClassIdentifier:
m_LocaleId:
m_Code: es
m_SharedData: {fileID: 11400000, guid: 4a2d391471bd9cd439150c52659e9846, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1edf9254b97981643bdecd6d22ed1150
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+112
View File
@@ -46,6 +46,118 @@ MonoBehaviour:
m_Localized: "\u8AD6\u7406"
m_Metadata:
m_Items: []
- m_Id: 200000000000001001
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001002
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001003
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001004
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001005
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001006
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001007
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001008
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001009
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001010
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001011
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001012
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001013
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001014
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001015
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001016
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001017
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001018
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001019
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001020
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001021
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001022
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001023
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001024
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001025
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001026
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001027
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001028
m_Localized:
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
+23
View File
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: Params_pt-BR
m_EditorClassIdentifier:
m_LocaleId:
m_Code: pt-BR
m_SharedData: {fileID: 11400000, guid: 4a2d391471bd9cd439150c52659e9846, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6ad3c9298dbc3b642b1251ffc306eaa6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+23
View File
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: Params_ru
m_EditorClassIdentifier:
m_LocaleId:
m_Code: ru
m_SharedData: {fileID: 11400000, guid: 4a2d391471bd9cd439150c52659e9846, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b91f4281442d2b54f92fbecb5bd56426
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+112
View File
@@ -46,6 +46,118 @@ MonoBehaviour:
m_Localized: "\u903B\u8F91"
m_Metadata:
m_Items: []
- m_Id: 200000000000001001
m_Localized: "\u6211\u5C31\u662F\u4E2A\u7B11\u8BDD"
m_Metadata:
m_Items: []
- m_Id: 200000000000001002
m_Localized: "\u54C8\u54C8|\u563F\u563F|\u5475\u5475"
m_Metadata:
m_Items: []
- m_Id: 200000000000001003
m_Localized: "\u4E0D\u8981\u79BB\u5F00\u6211"
m_Metadata:
m_Items: []
- m_Id: 200000000000001004
m_Localized: "\u54C8\u54C8\u54C8|\u563F\u563F\u563F|\u5475\u5475\u5475|\u7B11\u6B7B\u4E86|\u597D\u597D\u7B11"
m_Metadata:
m_Items: []
- m_Id: 200000000000001005
m_Localized: "\u4E3A\u4EC0\u4E48\u4E0D\u76F8\u4FE1\u6211"
m_Metadata:
m_Items: []
- m_Id: 200000000000001006
m_Localized: "\u54C8\u54C8\u54C8\u54C8|\u7B11\u6B7B\u6211\u4E86|\u592A\u597D\u7B11\u4E86|\u7EF7\u4E0D\u4F4F\u4E86|\u7B11\u4E0D\u6D3B\u4E86|\u771F\u7684\u592A\u641E\u7B11"
m_Metadata:
m_Items: []
- m_Id: 200000000000001007
m_Localized: "\u6CA1\u95EE\u9898|\u5187\u95EE\u9898|\u5E3D\u95EE\u9898"
m_Metadata:
m_Items: []
- m_Id: 200000000000001008
m_Localized: "\u4E3A\u4EC0\u4E48"
m_Metadata:
m_Items: []
- m_Id: 200000000000001009
m_Localized: "\u4E0D\u76F8\u4FE1\u6211"
m_Metadata:
m_Items: []
- m_Id: 200000000000001010
m_Localized: "\u6211\u660E\u767D\u6211\u57AB\u5E95"
m_Metadata:
m_Items: []
- m_Id: 200000000000001011
m_Localized: "\u5929\u6C14\u771F\u7684\u4E0D\u884C"
m_Metadata:
m_Items: []
- m_Id: 200000000000001012
m_Localized: "\u5356\u4E0D\u51FA\u53BB"
m_Metadata:
m_Items: []
- m_Id: 200000000000001013
m_Localized: "\u52AA\u529B\u89E3\u91CA"
m_Metadata:
m_Items: []
- m_Id: 200000000000001014
m_Localized: "\u4E0D\u613F\u610F\u76F8\u4FE1\u6211"
m_Metadata:
m_Items: []
- m_Id: 200000000000001015
m_Localized: "\u4E00\u70B9\u4FE1\u4EFB"
m_Metadata:
m_Items: []
- m_Id: 200000000000001016
m_Localized: "\u53D7\u591F\u4E86"
m_Metadata:
m_Items: []
- m_Id: 200000000000001017
m_Localized: "\u771F\u8BE5\u6B7B"
m_Metadata:
m_Items: []
- m_Id: 200000000000001018
m_Localized: "\u771F\u6CA1\u7528"
m_Metadata:
m_Items: []
- m_Id: 200000000000001019
m_Localized: "\u8BC1\u660E\u81EA\u5DF1"
m_Metadata:
m_Items: []
- m_Id: 200000000000001020
m_Localized: "\u4EC0\u4E48\u90FD\u505A\u4E0D\u5230"
m_Metadata:
m_Items: []
- m_Id: 200000000000001021
m_Localized: "\u8FFD\u4E0A\u4F60"
m_Metadata:
m_Items: []
- m_Id: 200000000000001022
m_Localized: "\u5E2E\u5230\u82F1\u7406"
m_Metadata:
m_Items: []
- m_Id: 200000000000001023
m_Localized: "\u6CA1\u6709\u52C7\u6C14"
m_Metadata:
m_Items: []
- m_Id: 200000000000001024
m_Localized: "\u8BF4\u51FA\u6765"
m_Metadata:
m_Items: []
- m_Id: 200000000000001025
m_Localized: "\u5E9F\u7269"
m_Metadata:
m_Items: []
- m_Id: 200000000000001026
m_Localized: "\u70C2\u6CE5\u6276\u4E0D\u4E0A\u5899"
m_Metadata:
m_Items: []
- m_Id: 200000000000001027
m_Localized: "\u4E3A\u4EC0\u4E48\u4E3A\u4EC0\u4E48"
m_Metadata:
m_Items: []
- m_Id: 200000000000001028
m_Localized: "\u6211\u60F3\u8D62\u4E00\u6B21"
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
+4 -4
View File
@@ -258,19 +258,19 @@ MonoBehaviour:
m_Metadata:
m_Items: []
- m_Id: 200000000000000001
m_Localized: "\u76EE\u6807\uFF1A\u5265\u79BB\u7EA2\u8272\u7684\u5E72\u6270\u8BED\u8A00\uFF0C\u805A\u96C6\u84DD\u8272\u7684\u771F\u5B9E\u8868\u8FBE"
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000002
m_Localized: "\u8F93\u51FA"
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000003
m_Localized: "{0}% \u8BED\u8A00\u6574\u5408\u5B8C\u6210\u5EA6"
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000004
m_Localized: "{0} \u5904\u5F02\u5E38\u601D\u7EEA\u4ECD\u5728\u5E72\u6270\u8868\u8FBE"
m_Localized:
m_Metadata:
m_Items: []
references:
+23
View File
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: UIText_es
m_EditorClassIdentifier:
m_LocaleId:
m_Code: es
m_SharedData: {fileID: 11400000, guid: 994c69bf4b2403043bc9562f2d24ee56, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 380c27123212d66468aa1f3ed3bf72ea
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+4 -4
View File
@@ -247,19 +247,19 @@ MonoBehaviour:
m_Metadata:
m_Items: []
- m_Id: 200000000000000001
m_Localized: "\u76EE\u6807\uFF1A\u5265\u79BB\u7EA2\u8272\u7684\u5E72\u6270\u8BED\u8A00\uFF0C\u805A\u96C6\u84DD\u8272\u7684\u771F\u5B9E\u8868\u8FBE"
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000002
m_Localized: "\u8F93\u51FA"
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000003
m_Localized: "{0}% \u8BED\u8A00\u6574\u5408\u5B8C\u6210\u5EA6"
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000004
m_Localized: "{0} \u5904\u5F02\u5E38\u601D\u7EEA\u4ECD\u5728\u5E72\u6270\u8868\u8FBE"
m_Localized:
m_Metadata:
m_Items: []
references:
+23
View File
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: UIText_pt-BR
m_EditorClassIdentifier:
m_LocaleId:
m_Code: pt-BR
m_SharedData: {fileID: 11400000, guid: 994c69bf4b2403043bc9562f2d24ee56, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7d496f91e240bb143b62a6239c817d09
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+23
View File
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9620f8c34305754d8cc9a7e49e852d9, type: 3}
m_Name: UIText_ru
m_EditorClassIdentifier:
m_LocaleId:
m_Code: ru
m_SharedData: {fileID: 11400000, guid: 994c69bf4b2403043bc9562f2d24ee56, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
references:
version: 2
RefIds: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f679069e455200841a92c27186383940
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+1
View File
@@ -16127,6 +16127,7 @@ MonoBehaviour:
expressionView: {fileID: 5162511683866016917}
logReleasePresentation: {fileID: 2138889543}
screenPresentation: {fileID: 2138889544}
contentCatalog: {fileID: 11400000, guid: b2d94952a18d4d178421691ab103f0fd, type: 2}
defaultAnxietyPhrases:
- "\u6211\u597D\u6015"
- "\u600E\u4E48\u529E"
@@ -17,6 +17,7 @@ namespace AibisDream
public const string FixWhackMoleData = Root + "/维修·打地鼠/关卡数据";
public const string HuoShanEmotionWave = Root + "/火山/情绪波配置";
public const string HuoShanWaveform = Root + "/火山/波形配置";
public const string HuoShanExpressionContent = Root + "/火山/表达内容配置";
public const string BlockPuzzleValidator = Root + "/方块拼图/验证器";
public const string MemoryPunchTapeCatalog = Root + "/记忆/打孔带目录";
}
+1
View File
@@ -52,6 +52,7 @@ namespace AibisDream.Utility
public const string UITextTable = "UIText";
public const string ChapterInfoTable = "ChapterInfo";
public const string ParamsTable = "Params";
#endregion
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AibisDream.Framework;
using AibisDream.Utility;
using UnityEngine;
@@ -79,10 +80,16 @@ namespace AibisDream
return LocalizedLine.InvalidLine;
}
// 对 substitutions 做本地化解析(保留旧 LocalisedLineProvider 的能力)
for (int i = 0; i < line.Substitutions.Length; i++)
// 对 substitutions 并行做本地化解析(保留旧 LocalisedLineProvider 的能力)
if (line.Substitutions.Length > 0)
{
line.Substitutions[i] = LocalizationKit.LocalizeParam(line.Substitutions[i]);
var localizationTasks = new Task<string>[line.Substitutions.Length];
for (int i = 0; i < line.Substitutions.Length; i++)
localizationTasks[i] = LocalizationKit.LocalizeParamAsync(line.Substitutions[i]);
string[] localizedSubstitutions = await Task.WhenAll(localizationTasks);
for (int i = 0; i < localizedSubstitutions.Length; i++)
line.Substitutions[i] = localizedSubstitutions[i];
}
// 先展开项目级语义标记,再保留旧 LocalisedLineProvider 的文本转义处理。
@@ -25,6 +25,7 @@ namespace AibisDream.FixSystem
private PunchTape punchTapeData;
private PunchTapeDefinition punchTapeDefinition;
private int localizationRefreshVersion;
private void OnEnable()
{
@@ -34,6 +35,7 @@ namespace AibisDream.FixSystem
private void OnDisable()
{
LocalizationSettings.SelectedLocaleChanged -= HandleSelectedLocaleChanged;
localizationRefreshVersion++;
}
public void Setup(PunchTape punchTape, PunchTapeDefinition definition)
@@ -57,8 +59,7 @@ namespace AibisDream.FixSystem
if (nameText != null)
{
RefreshLocalizedName(LocalizationKit.GetLocaleKind(
LocalizationSettings.SelectedLocale?.Identifier.Code));
RefreshLocalizedName(LocalizationSettings.SelectedLocale);
}
memoryIndexLabelText?.RefreshString();
@@ -73,17 +74,26 @@ namespace AibisDream.FixSystem
private void HandleSelectedLocaleChanged(Locale locale)
{
RefreshLocalizedName(LocalizationKit.GetLocaleKind(locale?.Identifier.Code));
RefreshLocalizedName(locale);
}
private void RefreshLocalizedName(LocaleKind localeKind)
private async void RefreshLocalizedName(Locale locale)
{
if (nameText == null || punchTapeDefinition == null)
return;
int version = ++localizationRefreshVersion;
string localizedName = await LocalizationKit.LocalizeParamAsync(
punchTapeDefinition.DisplayNameParam,
locale);
if (version != localizationRefreshVersion ||
nameText == null ||
punchTapeDefinition == null)
return;
nameText.StringReference.Arguments = new object[]
{
LocalizationKit.LocalizeParam(punchTapeDefinition.DisplayNameParam, localeKind)
localizedName
};
nameText.RefreshString();
}
+1 -24
View File
@@ -13,10 +13,8 @@ namespace AibisDream.Kit
public class ConfigUtil : SingletonBase<ConfigUtil>
{
private const string CharacterConfigPath = "/Config/character.csv";
private const string ParamConfigPath = "/Config/params.csv";
private Dictionary<string, Character> _characters;
private Dictionary<string, Param> _params;
/// <summary>
/// 用于单例模式
@@ -41,7 +39,6 @@ namespace AibisDream.Kit
public override void OnSingletonInit()
{
InitCharacterConfig();
InitParamConfig();
}
private void InitCharacterConfig()
@@ -52,15 +49,6 @@ namespace AibisDream.Kit
.ToDictionary(item => item.Key, item => item);
}
private void InitParamConfig()
{
var paramList = CsvUtil.ReadAsBean<Param>(Application.streamingAssetsPath + ParamConfigPath);
_params = paramList
.Where(item => !string.IsNullOrWhiteSpace(item.Key))
.ToDictionary(item => item.Key, item => item);
}
/// <summary>
/// 按key获取角色预设
/// </summary>
@@ -72,17 +60,6 @@ namespace AibisDream.Kit
return _characters.TryGetValue(key, out character);
}
/// <summary>
/// 按key获取参数本地化配置
/// </summary>
/// <param name="key">key</param>
/// <param name="param">参数配置</param>
/// <returns>是否能获取</returns>
public bool TryGetParam(string key, out Param param)
{
return _params.TryGetValue(key, out param);
}
/// <summary>
/// 按类型读取Json配置
/// </summary>
@@ -176,4 +153,4 @@ namespace AibisDream.Kit
return fileNames;
}
}
}
}
-27
View File
@@ -1,27 +0,0 @@
using AibisDream.Framework;
namespace AibisDream.Kit
{
public class Param
{
public string Key { get; set; }
public string Cn { get; set; }
public string En { get; set; }
public string Ja { get; set; }
public string LocalizeByKind(LocaleKind kind)
{
switch (kind)
{
case LocaleKind.En:
return string.IsNullOrWhiteSpace(En) ? Key : En;
case LocaleKind.Ja:
if (!string.IsNullOrWhiteSpace(Ja)) return Ja;
return string.IsNullOrWhiteSpace(Cn) ? Key : Cn;
case LocaleKind.Cn:
default:
return string.IsNullOrWhiteSpace(Cn) ? Key : Cn;
}
}
}
}
@@ -1,5 +1,7 @@
using System.Collections;
using AibisDream.Kit;
using System;
using System.Threading.Tasks;
using AibisDream.Utility;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
@@ -127,39 +129,85 @@ namespace AibisDream.Framework
}
/// <summary>
/// 本地化参数
/// 判断字符串是否为 l10n 参数引用。
/// </summary>
/// <param name="param">参数名</param>
/// <returns></returns>
public static string LocalizeParam(string param)
public static bool IsLocalizedParam(string value)
{
return LocalizeParam(param, CurrentLocaleKind);
return !string.IsNullOrEmpty(value) &&
value.StartsWith(LocalizationPrefix, StringComparison.Ordinal);
}
/// <summary>
/// 按指定语言列解析本地化参数,供需要响应 Locale 切换的非 Yarn UI 使用
/// 从 Params String Table 异步解析 l10n 参数。非 l10n 参数原样返回
/// 当前 Locale 条目为空时保留空值,不使用任何 Locale 回退。
/// </summary>
public static string LocalizeParam(string param, LocaleKind localeKind)
public static async Task<string> LocalizeParamAsync(string param, Locale locale = null)
{
// 非本地化参数直接返回
if (string.IsNullOrEmpty(param)) return param;
if (!param.StartsWith(LocalizationPrefix)) return param;
if (!IsLocalizedParam(param))
return param;
var localizationKey = param[LocalizationPrefix.Length..];
if (ConfigUtil.Instance.TryGetParam(localizationKey, out var value))
string localizationKey = GetL10NParamKey(param);
if (string.IsNullOrEmpty(localizationKey))
{
return value.LocalizeByKind(localeKind);
Debug.LogError("[LocalizationKit] l10n 参数缺少 Entry Key。");
return "⟦empty⟧";
}
// 如果找不到,则使用原始值
Debug.LogWarning($"Variable Key '{localizationKey}' not found in params csv.");
return localizationKey;
try
{
await LocalizationSettings.InitializationOperation.Task;
Locale resolvedLocale = locale != null ? locale : LocalizationSettings.SelectedLocale;
var operation = LocalizationSettings.StringDatabase.GetTableEntryAsync(
ConstRef.ParamsTable,
localizationKey,
resolvedLocale,
FallbackBehavior.DontUseFallback);
var result = await operation.Task;
if (result.Entry != null)
return result.Entry.LocalizedValue ?? string.Empty;
string localeCode = resolvedLocale != null
? resolvedLocale.Identifier.Code
: "<null>";
Debug.LogError(
$"[LocalizationKit] Params 缺少条目:Table={ConstRef.ParamsTable}, " +
$"Key={localizationKey}, Locale={localeCode}。");
return $"⟦{localizationKey}⟧";
}
catch (Exception exception)
{
string localeCode = locale != null
? locale.Identifier.Code
: LocalizationSettings.SelectedLocale?.Identifier.Code ?? "<null>";
Debug.LogError(
$"[LocalizationKit] Params 查询失败:Table={ConstRef.ParamsTable}, " +
$"Key={localizationKey}, Locale={localeCode}。\n{exception}");
return $"⟦{localizationKey}⟧";
}
}
/// <summary>
/// 协程版 l10n 参数解析,供 Yarn Command 和 MonoBehaviour 使用。
/// </summary>
public static IEnumerator LocalizeParam(
string param,
Locale locale,
Action<string> completed)
{
Task<string> task = LocalizeParamAsync(param, locale);
while (!task.IsCompleted)
yield return null;
string result = task.Status == TaskStatus.RanToCompletion
? task.Result
: $"⟦{GetL10NParamKey(param)}⟧";
completed?.Invoke(result);
}
public static string GetL10NParamKey(string key)
{
// 本地化参数则去除前缀,非本地化参数直接返回
return key.StartsWith(LocalizationPrefix) ? key[LocalizationPrefix.Length..] : key;
return IsLocalizedParam(key) ? key[LocalizationPrefix.Length..] : key;
}
/// <summary>
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using AibisDream.Framework;
using UnityEngine;
namespace AibisDream.MiniGame.Language
{
[Serializable]
public sealed class ExpressionRoundDefinition
{
[SerializeField] private string id;
[SerializeField] private string targetReference;
[SerializeField] private string tokenReference;
[SerializeField, Min(0)] private int nonTargetParticleCount;
public string Id => id;
public string TargetReference => targetReference;
public string TokenReference => tokenReference;
public int NonTargetParticleCount => nonTargetParticleCount;
public bool TryValidate(out string error)
{
if (string.IsNullOrWhiteSpace(id))
{
error = "轮次 ID 不能为空。";
return false;
}
if (!LocalizationKit.IsLocalizedParam(targetReference))
{
error = $"轮次 '{id}' 的目标文字必须配置为 l10n.* 引用。";
return false;
}
if (!LocalizationKit.IsLocalizedParam(tokenReference))
{
error = $"轮次 '{id}' 的干扰 token 必须配置为 l10n.* 引用。";
return false;
}
if (nonTargetParticleCount < 0)
{
error = $"轮次 '{id}' 的非目标粒子数量不能为负数。";
return false;
}
error = null;
return true;
}
}
[CreateAssetMenu(
fileName = "ExpressionContentCatalog",
menuName = AibisAssetMenus.HuoShanExpressionContent)]
public sealed class ExpressionContentCatalog : ScriptableObject
{
[SerializeField] private string actorLoopReference;
[SerializeField] private List<ExpressionRoundDefinition> rounds = new();
private Dictionary<string, ExpressionRoundDefinition> roundById;
public string ActorLoopReference => actorLoopReference;
public IReadOnlyList<ExpressionRoundDefinition> Rounds => rounds;
public bool TryGetRound(string roundId, out ExpressionRoundDefinition definition)
{
EnsureLookup();
if (string.IsNullOrWhiteSpace(roundId))
{
definition = null;
return false;
}
return roundById.TryGetValue(roundId, out definition);
}
public List<string> GetValidationErrors()
{
var errors = new List<string>();
if (!LocalizationKit.IsLocalizedParam(actorLoopReference))
errors.Add("Actor Loop 必须配置为 l10n.* 引用。");
var ids = new HashSet<string>(StringComparer.Ordinal);
if (rounds == null || rounds.Count == 0)
{
errors.Add("Catalog 中没有表达轮次。");
return errors;
}
for (int i = 0; i < rounds.Count; i++)
{
ExpressionRoundDefinition round = rounds[i];
if (round == null)
{
errors.Add($"rounds[{i}] 为空。");
continue;
}
if (!round.TryValidate(out string error))
errors.Add(error);
if (!string.IsNullOrWhiteSpace(round.Id) && !ids.Add(round.Id))
errors.Add($"存在重复轮次 ID'{round.Id}'。");
}
return errors;
}
private void EnsureLookup()
{
if (roundById != null)
return;
roundById = new Dictionary<string, ExpressionRoundDefinition>(StringComparer.Ordinal);
if (rounds == null)
return;
foreach (ExpressionRoundDefinition round in rounds)
{
if (round == null || string.IsNullOrWhiteSpace(round.Id) || roundById.ContainsKey(round.Id))
continue;
roundById.Add(round.Id, round);
}
}
private void OnValidate()
{
roundById = null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a86a72005fba4c99a59962fb9e92df83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -4,6 +4,7 @@ using UnityEngine;
using AibisDream.FixSystem;
using AibisDream.MiniGame.Language;
using System.Collections.Generic;
using UnityEngine.Localization;
namespace AibisDream
{
@@ -17,6 +18,7 @@ namespace AibisDream
[SerializeField] private GameObject expressionView;
[SerializeField] private LogReleasePresentationController logReleasePresentation;
[SerializeField] private ExpressionScreenPresentationController screenPresentation;
[SerializeField] private ExpressionContentCatalog contentCatalog;
[Header("默认配置")]
[SerializeField] private List<string> defaultAnxietyPhrases = new List<string>
@@ -54,6 +56,18 @@ namespace AibisDream
[SerializeField, Range(0.3f, 1f)] private float screenStressCap = 0.85f;
public LogReleasePresentationController LogReleasePresentation => logReleasePresentation;
public ExpressionContentCatalog ContentCatalog => contentCatalog;
public Locale ActiveExpressionLocale { get; private set; }
public Locale GetExpressionLocale(Locale fallback)
{
return ActiveExpressionLocale != null ? ActiveExpressionLocale : fallback;
}
public void SetActiveExpressionLocale(Locale locale)
{
ActiveExpressionLocale = locale;
}
/// <summary>当前屏幕 Glitch 档位(01),由 expression_screen_glitch 持续保持。</summary>
public float ScreenGlitchLevel => screenGlitchIntensity;
@@ -274,6 +288,7 @@ namespace AibisDream
/// </summary>
public void CloseView()
{
ActiveExpressionLocale = null;
screenPresentation?.ResetImmediate();
ResetScreenGlitchState();
ApplyScreenGlitchProperties();
@@ -941,6 +956,7 @@ namespace AibisDream
private void OnDisable()
{
ActiveExpressionLocale = null;
ResetScreenGlitchState();
ApplyScreenGlitchProperties();
@@ -0,0 +1,108 @@
using System.Collections.Generic;
using System.Globalization;
namespace AibisDream.MiniGame.Language
{
/// <summary>
/// 将玩家可见文字按 Unicode 文本元素拆分。空白保留在布局中,但不生成粒子。
/// 随机粒子字符池不使用本类,维持现有行为。
/// </summary>
public static class ExpressionTextTokenizer
{
public readonly struct Layout
{
public Layout(List<string> visibleElements, List<float> offsets)
{
VisibleElements = visibleElements;
Offsets = offsets;
}
public IReadOnlyList<string> VisibleElements { get; }
public IReadOnlyList<float> Offsets { get; }
public int Count => VisibleElements?.Count ?? 0;
}
public static List<string> GetVisibleElements(string text)
{
var result = new List<string>();
if (string.IsNullOrEmpty(text))
return result;
TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(text);
while (enumerator.MoveNext())
{
string element = enumerator.GetTextElement();
if (!string.IsNullOrWhiteSpace(element))
result.Add(element);
}
return result;
}
public static Layout BuildLayout(
string text,
float characterSpacing,
float whitespaceSpacingMultiplier)
{
var visible = new List<string>();
var positions = new List<float>();
if (string.IsNullOrEmpty(text))
return new Layout(visible, positions);
float spacing = characterSpacing > 0f ? characterSpacing : 1f;
float whitespaceAdvance = spacing * whitespaceSpacingMultiplier;
float cursor = 0f;
TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(text);
while (enumerator.MoveNext())
{
string element = enumerator.GetTextElement();
if (string.IsNullOrWhiteSpace(element))
{
if (visible.Count > 0)
cursor += whitespaceAdvance;
continue;
}
visible.Add(element);
positions.Add(cursor);
cursor += spacing;
}
if (positions.Count > 0)
{
float center = (positions[0] + positions[positions.Count - 1]) * 0.5f;
for (int i = 0; i < positions.Count; i++)
positions[i] -= center;
}
return new Layout(visible, positions);
}
public static int FindVisibleSequence(
IReadOnlyList<string> source,
IReadOnlyList<string> fragment)
{
if (source == null || fragment == null || fragment.Count == 0 || fragment.Count > source.Count)
return -1;
int lastStart = source.Count - fragment.Count;
for (int start = 0; start <= lastStart; start++)
{
bool matches = true;
for (int i = 0; i < fragment.Count; i++)
{
if (!string.Equals(source[start + i], fragment[i], System.StringComparison.Ordinal))
{
matches = false;
break;
}
}
if (matches)
return start;
}
return -1;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f8ba4ca26b9b46649c0da7161dc9c756
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -93,6 +93,7 @@ namespace AibisDream.MiniGame.Language
[SerializeField] private float floatingTextFontSize = 3f; // 背景浮动文字大小
[SerializeField] private bool targetParticleBold = true; // 目标粒子是否加粗
[SerializeField] private bool nonTargetParticleBold = false; // 非目标粒子是否加粗
[SerializeField, Range(0.1f, 2f)] private float whitespaceSpacingMultiplier = 0.6f;
[Header("非目标粒子干扰效果")]
[SerializeField, Range(0f, 0.1f)] private float nonTargetShakeIntensity = 0.02f; // 非目标粒子抖动强度
@@ -354,10 +355,18 @@ namespace AibisDream.MiniGame.Language
CaptureDefaultCandidateCountFromInspector();
string useSentence = string.IsNullOrEmpty(sentence) ? "这是一个测试示例" : sentence;
int visibleTargetCount = ExpressionTextTokenizer.GetVisibleElements(useSentence).Count;
if (visibleTargetCount == 0)
{
Debug.LogError("[LanguageParticleManager] 目标句没有可显示的 Unicode 文本元素;初始化已中止。");
return;
}
if (nonTargetParticleTotal >= 0)
{
candidateCount = Mathf.Max(useSentence.Length + nonTargetParticleTotal, useSentence.Length);
candidateCount = Mathf.Max(
visibleTargetCount + nonTargetParticleTotal,
visibleTargetCount);
}
else
{
@@ -409,7 +418,7 @@ namespace AibisDream.MiniGame.Language
TextParticle.SetAnxietyPhrases(anxietyPhrases);
// 根据目标句子更新目标粒子数量
redParticleCount = Mathf.Min(targetSentence.Length, candidateCount);
redParticleCount = Mathf.Min(visibleTargetCount, candidateCount);
// 重新初始化游戏状态并播放入场动画
ResetGame(true);
@@ -1247,7 +1256,11 @@ namespace AibisDream.MiniGame.Language
SetStatusUIVisibility(true, 0f);
orderedRedParticles = GetOrderedRedParticles();
int displayLength = Mathf.Min(orderedRedParticles.Count, targetSentence.Length);
ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout(
targetSentence,
0.9f,
whitespaceSpacingMultiplier);
int displayLength = Mathf.Min(orderedRedParticles.Count, layout.Count);
if (displayLength < orderedRedParticles.Count)
{
@@ -1256,20 +1269,17 @@ namespace AibisDream.MiniGame.Language
for (int i = 0; i < displayLength; i++)
{
finalTargetCharacters.Add(targetSentence[i].ToString());
finalTargetCharacters.Add(layout.VisibleElements[i]);
}
Vector3 displayCenter = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
if (finalTargetCharacters.Count > 0)
{
float spacing = 0.9f;
float totalWidth = spacing * Mathf.Max(0, finalTargetCharacters.Count - 1);
float startX = -totalWidth / 2f;
finalTargetPositions.Clear();
Bounds clip = GetCompletionClipBounds();
for (int i = 0; i < finalTargetCharacters.Count; i++)
{
Vector3 pos = displayCenter + new Vector3(startX + i * spacing, 0f, 0f);
Vector3 pos = displayCenter + new Vector3(layout.Offsets[i], 0f, 0f);
finalTargetPositions.Add(ClampPositionToBounds(pos, clip));
}
}
@@ -2151,6 +2161,7 @@ namespace AibisDream.MiniGame.Language
List<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话失稳"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
Color unstableColor = truthAttackColor;
if (!string.IsNullOrWhiteSpace(colorHex))
@@ -2169,8 +2180,8 @@ namespace AibisDream.MiniGame.Language
float totalDuration = Mathf.Max(0.12f, duration);
float resolvedShakeAmplitude = Mathf.Max(0f, shakeAmplitude);
float resolvedShakeSpeed = Mathf.Max(0f, shakeSpeed);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text.Length);
UpdateFinalPresentationPhrase(text, destinations);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
UpdateFinalPresentationPhrase(characters, destinations);
for (int i = 0; i < particles.Count; i++)
{
@@ -2197,13 +2208,13 @@ namespace AibisDream.MiniGame.Language
particle.resolvedColorProgress = 1f;
particle.calmProgress = 1f;
if (i >= text.Length)
if (i >= characters.Count)
{
particle.alpha = 0f;
continue;
}
particle.ForceSetCharacter(text[i].ToString());
particle.ForceSetCharacter(characters[i]);
particle.alpha = 1f;
particle.SetFocusInterference(
unstableColor,
@@ -2221,7 +2232,7 @@ namespace AibisDream.MiniGame.Language
yield return new WaitForSeconds(totalDuration);
for (int i = 0; i < text.Length && i < particles.Count; i++)
for (int i = 0; i < characters.Count && i < particles.Count; i++)
{
CandidateParticle particle = particles[i];
if (particle == null)
@@ -2239,6 +2250,7 @@ namespace AibisDream.MiniGame.Language
List<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话攻击"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
PreparePresentationPhraseControl();
@@ -2247,8 +2259,8 @@ namespace AibisDream.MiniGame.Language
float travelDuration = totalDuration * 0.68f;
float settleDuration = Mathf.Max(0.04f, totalDuration - travelDuration);
int directionSign = truthAttackSequenceIndex++ % 2 == 0 ? -1 : 1;
List<Vector3> destinations = CalculatePresentationPhrasePositions(text.Length);
UpdateFinalPresentationPhrase(text, destinations);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
UpdateFinalPresentationPhrase(characters, destinations);
for (int i = 0; i < particles.Count; i++)
{
@@ -2276,7 +2288,7 @@ namespace AibisDream.MiniGame.Language
particle.resolvedColorProgress = 1f;
particle.calmProgress = 1f;
if (i >= text.Length)
if (i >= characters.Count)
{
DOTween.To(
() => particle.alpha,
@@ -2288,7 +2300,7 @@ namespace AibisDream.MiniGame.Language
continue;
}
particle.ForceSetCharacter(text[i].ToString());
particle.ForceSetCharacter(characters[i]);
particle.alpha = 1f;
particle.focusInterferenceProgress = 1f;
@@ -2325,7 +2337,7 @@ namespace AibisDream.MiniGame.Language
yield return new WaitForSeconds(totalDuration);
for (int i = 0; i < text.Length && i < particles.Count; i++)
for (int i = 0; i < characters.Count && i < particles.Count; i++)
{
CandidateParticle particle = particles[i];
if (particle == null)
@@ -2344,6 +2356,7 @@ namespace AibisDream.MiniGame.Language
List<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话稳定"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
PreparePresentationPhraseControl();
@@ -2384,8 +2397,8 @@ namespace AibisDream.MiniGame.Language
yield return new WaitForSeconds(breakDuration);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text.Length);
UpdateFinalPresentationPhrase(text, destinations);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
UpdateFinalPresentationPhrase(characters, destinations);
for (int i = 0; i < particles.Count; i++)
{
CandidateParticle particle = particles[i];
@@ -2410,14 +2423,14 @@ namespace AibisDream.MiniGame.Language
particle.resolvedColorProgress = 1f;
particle.calmProgress = 1f;
if (i >= text.Length)
if (i >= characters.Count)
{
particle.alpha = 0f;
particle.ClearFocusInterference();
continue;
}
particle.ForceSetCharacter(text[i].ToString());
particle.ForceSetCharacter(characters[i]);
particle.SetFocusInterference(truthAttackColor, 0f, 0f);
particle.focusInterferenceProgress = 1f;
particle.alpha = 0.15f;
@@ -2453,8 +2466,8 @@ namespace AibisDream.MiniGame.Language
if (particle == null)
continue;
particle.ClearFocusInterference();
particle.alpha = i < text.Length ? 1f : 0f;
if (i < text.Length)
particle.alpha = i < characters.Count ? 1f : 0f;
if (i < characters.Count)
{
particle.transform.position = destinations[i];
particle.transform.localScale = Vector3.one * focusZoomScale;
@@ -2494,10 +2507,17 @@ namespace AibisDream.MiniGame.Language
return false;
}
if (text.Length > particles.Count)
int requiredParticles = ExpressionTextTokenizer.GetVisibleElements(text).Count;
if (requiredParticles == 0)
{
Debug.LogWarning($"[LanguageParticleManager] {context}没有可显示的文本元素;已安全跳过。");
return false;
}
if (requiredParticles > particles.Count)
{
Debug.LogWarning(
$"[LanguageParticleManager] {context}“{text}”需要 {text.Length} 个目标粒子," +
$"[LanguageParticleManager] {context}“{text}”需要 {requiredParticles} 个目标粒子," +
$"当前只有 {particles.Count} 个;为避免截断已跳过。");
return false;
}
@@ -2521,27 +2541,32 @@ namespace AibisDream.MiniGame.Language
connectionRenderer.TargetAlphaScale = 0f;
}
private List<Vector3> CalculatePresentationPhrasePositions(int characterCount)
private List<Vector3> CalculatePresentationPhrasePositions(string text)
{
var positions = new List<Vector3>(Mathf.Max(0, characterCount));
Vector3 center = worldCanvas != null ? worldCanvas.transform.position : transform.position;
float spacing = Mathf.Max(0.1f, truthAttackCharacterSpacing);
float startX = -spacing * Mathf.Max(0, characterCount - 1) * 0.5f;
ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout(
text,
spacing,
whitespaceSpacingMultiplier);
var positions = new List<Vector3>(layout.Count);
Vector3 center = worldCanvas != null ? worldCanvas.transform.position : transform.position;
Bounds clip = GetCompletionClipBounds();
for (int i = 0; i < characterCount; i++)
for (int i = 0; i < layout.Count; i++)
{
Vector3 position = center + new Vector3(startX + i * spacing, 0f, 0f);
Vector3 position = center + new Vector3(layout.Offsets[i], 0f, 0f);
positions.Add(ClampPositionToBounds(position, clip));
}
return positions;
}
private void UpdateFinalPresentationPhrase(string text, List<Vector3> positions)
private void UpdateFinalPresentationPhrase(
IReadOnlyList<string> characters,
List<Vector3> positions)
{
finalTargetCharacters.Clear();
finalTargetPositions.Clear();
for (int i = 0; i < text.Length; i++)
finalTargetCharacters.Add(text[i].ToString());
for (int i = 0; i < characters.Count; i++)
finalTargetCharacters.Add(characters[i]);
finalTargetPositions.AddRange(positions);
}
@@ -2579,7 +2604,9 @@ namespace AibisDream.MiniGame.Language
if (string.IsNullOrEmpty(fragment) || orderedRedParticles.Count == 0)
yield break;
int startIndex = targetSentence.IndexOf(fragment, System.StringComparison.Ordinal);
List<string> targetElements = ExpressionTextTokenizer.GetVisibleElements(targetSentence);
List<string> fragmentElements = ExpressionTextTokenizer.GetVisibleElements(fragment);
int startIndex = ExpressionTextTokenizer.FindVisibleSequence(targetElements, fragmentElements);
if (startIndex < 0)
{
Debug.LogWarning($"[LanguageParticleManager] 真话泄漏片段“{fragment}”不在目标句“{targetSentence}”中。");
@@ -2587,12 +2614,12 @@ namespace AibisDream.MiniGame.Language
}
var leaked = new List<CandidateParticle>();
int endExclusive = Mathf.Min(startIndex + fragment.Length, orderedRedParticles.Count);
int endExclusive = Mathf.Min(startIndex + fragmentElements.Count, orderedRedParticles.Count);
for (int i = startIndex; i < endExclusive; i++)
{
CandidateParticle particle = orderedRedParticles[i];
if (particle == null) continue;
particle.ForceSetCharacter(targetSentence[i].ToString());
particle.ForceSetCharacter(targetElements[i]);
particle.isStatic = true;
particle.alpha = 1f;
leaked.Add(particle);
@@ -1,7 +1,11 @@
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using AibisDream.FixSystem;
using AibisDream;
using AibisDream.Framework;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using Yarn.Unity;
namespace AibisDream.MiniGame.Language
@@ -13,6 +17,66 @@ namespace AibisDream.MiniGame.Language
{
private static AibisDream.ExpressionManager ExpressionManager => FixSystemCenter.SystemDic.Get<AibisDream.ExpressionManager>();
private static Locale GetCommandLocale()
{
Locale current = LocalizationSettings.SelectedLocale;
return ExpressionManager != null
? ExpressionManager.GetExpressionLocale(current)
: current;
}
private static IEnumerator ResolveTextReferences(
Locale locale,
System.Action<string[]> completed,
params string[] references)
{
var tasks = new Task<string>[references.Length];
for (int i = 0; i < references.Length; i++)
tasks[i] = LocalizationKit.LocalizeParamAsync(references[i], locale);
Task<string[]> all = Task.WhenAll(tasks);
while (!all.IsCompleted)
yield return null;
completed?.Invoke(all.Status == TaskStatus.RanToCompletion
? all.Result
: System.Array.Empty<string>());
}
private static bool ValidateRequiredText(string value, string command, string parameter)
{
if (!string.IsNullOrWhiteSpace(value))
return true;
UnityEngine.Debug.LogError(
$"[LanguageYarnCommand] {command} 的玩家可见参数 {parameter} 解析为空;命令已安全结束。");
return false;
}
public static bool TryParseExpressionTokens(string value, out List<string> tokens)
{
tokens = new List<string>();
if (string.IsNullOrWhiteSpace(value))
return false;
if (value.Contains(""))
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] 干扰 token 使用了全角分隔符“|”;请改用半角“|”。");
return false;
}
string[] parts = value.Split('|');
foreach (string part in parts)
{
string token = part.Trim();
if (!string.IsNullOrEmpty(token))
tokens.Add(token);
}
return tokens.Count > 0;
}
private static bool TryGetPresentation(out LogReleasePresentationController presentation)
{
presentation = ExpressionManager != null
@@ -30,8 +94,8 @@ namespace AibisDream.MiniGame.Language
/// 启动表达粒子系统(在打开视图后调用),等粒子字入场完成即可返回(可交互);panel 动效并行不阻塞。
/// 流程:若有上一轮则先淡出字与连线 → 再淡入火山释放 log → 再启动新一轮并等粒子就绪。
/// 注意:Yarn Spinner 2.x 对同名 <see cref="YarnCommand"/> 重载支持不可靠,只保留一个注册入口,用可选参数区分 3/4 参调用。
/// <<start_expression "哈哈|嘿嘿|呵呵" "我很害怕" "ExpressionCompleted">>
/// <<start_expression "哈哈|嘿嘿|呵呵" "我很害怕" "ExpressionCompleted" 24>>
/// <<start_expression "l10n.hs.exp.log1.tokens" "l10n.hs.exp.log1.target" "ExpressionCompleted">>
/// <<start_expression "l10n.hs.exp.log1.tokens" "l10n.hs.exp.log1.target" "ExpressionCompleted" 24>>
/// (第四项为「非目标」候选粒子总数,候选池 = 目标句字数 + 该值;不写第四项则用 Inspector 的 Candidate Count
/// </summary>
/// <param name="anxietyPhrasesStr">干扰短句(笑话等),用 | 分隔,用于非目标粒子字符池,如:"哈哈|嘿嘿|呵呵"</param>
@@ -60,21 +124,103 @@ namespace AibisDream.MiniGame.Language
yield break;
}
string[] phrases = null;
if (!string.IsNullOrEmpty(anxietyPhrasesStr))
Locale locale = LocalizationSettings.SelectedLocale;
string[] localized = null;
yield return ResolveTextReferences(
locale,
values => localized = values,
anxietyPhrasesStr,
targetSentence);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "start_expression", nameof(anxietyPhrasesStr)) ||
!ValidateRequiredText(localized[1], "start_expression", nameof(targetSentence)))
yield break;
if (!TryParseExpressionTokens(localized[0], out List<string> phrases))
{
phrases = anxietyPhrasesStr.Split('|');
for (int i = 0; i < phrases.Length; i++)
{
phrases[i] = phrases[i].Trim();
}
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] start_expression 没有有效的干扰 token;命令已安全结束。");
yield break;
}
// 先清理上一轮演出,再淡出旧文字/连线并恢复 Log 操作界面。
yield return ExpressionManager.PreparePresentationForNextRound();
yield return ExpressionManager.FadeOutParticlesBeforeNewRound(0f);
yield return ExpressionManager.EnsureReleaseLogFadedIn(1f);
ExpressionManager.StartSystem(phrases, targetSentence, completionNode, nonTargetParticleTotal);
ExpressionManager.SetActiveExpressionLocale(locale);
ExpressionManager.StartSystem(
phrases,
localized[1],
completionNode,
nonTargetParticleTotal);
yield return ExpressionManager.WaitUntilExpressionFlowReady();
}
/// <summary>
/// 使用 ExpressionContentCatalog 中的轮次配置启动表达系统。
/// <<start_expression_round "log1" "LOG1梳理完成">>
/// </summary>
[YarnCommand("start_expression_round")]
public static IEnumerator StartExpressionRound(string roundId, string completionNode = "")
{
if (ExpressionManager == null)
{
UnityEngine.Debug.LogError("ExpressionManager 未找到!请确保场景中有 ExpressionManager 组件。");
yield break;
}
ExpressionContentCatalog catalog = ExpressionManager.ContentCatalog;
if (catalog == null)
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] ExpressionContentCatalog 未配置;无法启动表达轮次。");
yield break;
}
List<string> catalogErrors = catalog.GetValidationErrors();
if (catalogErrors.Count > 0)
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] ExpressionContentCatalog 数据无效;无法启动表达轮次。\n" +
string.Join("\n", catalogErrors));
yield break;
}
if (!catalog.TryGetRound(roundId, out ExpressionRoundDefinition round))
{
UnityEngine.Debug.LogError(
$"[LanguageYarnCommand] ExpressionContentCatalog 中不存在轮次 '{roundId}'。");
yield break;
}
Locale locale = LocalizationSettings.SelectedLocale;
string[] localized = null;
yield return ResolveTextReferences(
locale,
values => localized = values,
round.TokenReference,
round.TargetReference);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "start_expression_round", nameof(round.TokenReference)) ||
!ValidateRequiredText(localized[1], "start_expression_round", nameof(round.TargetReference)))
yield break;
if (!TryParseExpressionTokens(localized[0], out List<string> phrases))
{
UnityEngine.Debug.LogError(
$"[LanguageYarnCommand] 表达轮次 '{roundId}' 没有有效的干扰 token。");
yield break;
}
yield return ExpressionManager.PreparePresentationForNextRound();
yield return ExpressionManager.FadeOutParticlesBeforeNewRound(0f);
yield return ExpressionManager.EnsureReleaseLogFadedIn(1f);
ExpressionManager.SetActiveExpressionLocale(locale);
ExpressionManager.StartSystem(
phrases,
localized[1],
completionNode ?? string.Empty,
round.NonTargetParticleCount);
yield return ExpressionManager.WaitUntilExpressionFlowReady();
}
@@ -241,8 +387,17 @@ namespace AibisDream.MiniGame.Language
UnityEngine.Debug.LogError("ExpressionManager 未找到!");
yield break;
}
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
keyword);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_lie_begin", nameof(keyword)))
yield break;
yield return ExpressionManager.StartCoroutine(
ExpressionManager.BeginExpressionLie(keyword, preset));
ExpressionManager.BeginExpressionLie(localized[0], preset));
}
[YarnCommand("expression_lie_break")]
@@ -385,24 +540,37 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 结尾真假快速闪切(非阻塞,节奏由同长度 wait 控制):真心话与谎话交替独占屏幕,
/// 两者的屏幕底色(电视头背景)不同,越到后面切得越快,结束定格在真话帧。
/// <<expression_truth_lie_flicker "没问题|冇问题|帽问题" "我就是个笑话" 3.2 0.24 12>>
/// <<expression_truth_lie_flicker "l10n.hs.exp.actor.loop" "l10n.hs.exp.log1.target" 3.2 0.24 12>>
/// </summary>
[YarnCommand("expression_truth_lie_flicker")]
public static void ExpressionTruthLieFlicker(
public static IEnumerator ExpressionTruthLieFlicker(
string liePhrases,
string truthPhrase,
float duration,
float interval = 0.24f,
float fontSize = 12f)
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StartTruthLieFlicker(liePhrases, truthPhrase, duration, interval, fontSize);
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
liePhrases,
truthPhrase);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "expression_truth_lie_flicker", nameof(liePhrases)) ||
!ValidateRequiredText(localized[1], "expression_truth_lie_flicker", nameof(truthPhrase)))
yield break;
presentation.StartTruthLieFlicker(localized[0], localized[1], duration, interval, fontSize);
}
/// <summary>
/// 定格的谎话被 glitch 翻转成真话(阻塞):大字乱跳中长度从谎话渐变到真话、真假底色抢闪,
/// 最后落在暖色真话。紧接 expression_memory_cut + expression_truth_snap 收尾。
/// <<expression_lie_morph_truth "没问题" "我就是个笑话" 0.9>>
/// <<expression_lie_morph_truth "l10n.hs.exp.atk.why" "l10n.hs.exp.log1.target" 0.9>>
/// </summary>
[YarnCommand("expression_lie_morph_truth")]
public static IEnumerator ExpressionLieMorphTruth(
@@ -412,8 +580,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
lieText,
truthText);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "expression_lie_morph_truth", nameof(lieText)) ||
!ValidateRequiredText(localized[1], "expression_lie_morph_truth", nameof(truthText)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.MorphLieToTruth(lieText, truthText, duration));
presentation.MorphLieToTruth(localized[0], localized[1], duration));
}
[YarnCommand("expression_truth_leak")]
@@ -421,24 +600,44 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
fragment);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_truth_leak", nameof(fragment)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.LeakTruthFragment(fragment, duration));
presentation.LeakTruthFragment(localized[0], duration));
}
[YarnCommand("expression_lie_shuffle")]
public static void ExpressionLieShuffle(
public static IEnumerator ExpressionLieShuffle(
string characterPool,
float duration,
float interval)
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StartLieCharacterShuffle(characterPool, duration, interval);
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
characterPool);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_lie_shuffle", nameof(characterPool)))
yield break;
presentation.StartLieCharacterShuffle(localized[0], duration, interval);
}
/// <summary>
/// 演员大字闪现(阻塞):抖动的粒子字瞬间隐藏,屏幕中央出现白色大字(红青色散双影+急速缩放),
/// 停顿 holdDuration 秒后始终恢复粒子字抖动;结尾定格请使用 expression_actor_flash_in。
/// <<expression_actor_flash "没问题" 0.6 10.5 1.15 1.60 0.03 0.22 0.5 0.2>>
/// <<expression_actor_flash "l10n.hs.exp.atk.why" 0.6 10.5 1.15 1.60 0.03 0.22 0.5 0.2>>
/// </summary>
[YarnCommand("expression_actor_flash")]
public static IEnumerator ExpressionActorFlash(
@@ -454,9 +653,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_flash", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.FlashActorLie(
text,
localized[0],
holdDuration,
fontSize,
settledScale,
@@ -470,7 +679,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 演员大字入场并保持(阻塞):入场表现与 expression_actor_flash 相同,但不执行退场,
/// 适合在段落结尾定格。后续清场命令仍会正常移除它。
/// <<expression_actor_flash_in "没问题" 0.35 10.5 1.1 1.45 0.008 0.06 0.5 0>>
/// <<expression_actor_flash_in "l10n.hs.exp.atk.why" 0.35 10.5 1.1 1.45 0.008 0.06 0.5 0>>
/// </summary>
[YarnCommand("expression_actor_flash_in")]
public static IEnumerator ExpressionActorFlashIn(
@@ -486,9 +695,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_flash_in", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.FlashActorLie(
text,
localized[0],
holdDuration,
fontSize,
settledScale,
@@ -505,10 +724,36 @@ namespace AibisDream.MiniGame.Language
/// <<expression_actor_flash_loop_start>>
/// </summary>
[YarnCommand("expression_actor_flash_loop_start")]
public static void ExpressionActorFlashLoopStart()
public static IEnumerator ExpressionActorFlashLoopStart()
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StartActorFlashLoop();
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
ExpressionContentCatalog catalog = ExpressionManager.ContentCatalog;
if (catalog == null || !LocalizationKit.IsLocalizedParam(catalog.ActorLoopReference))
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] Actor Loop 本地化引用未配置;命令已安全结束。");
yield break;
}
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
catalog.ActorLoopReference);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_flash_loop_start", "ActorLoopReference"))
yield break;
if (!TryParseExpressionTokens(localized[0], out List<string> phrases))
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] Actor Loop 短句列表无效;命令已安全结束。");
yield break;
}
presentation.StartActorFlashLoop(phrases);
}
/// <summary>
@@ -525,7 +770,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 真话闪现(阻塞):谎话大字之间,暖色完整真话带细微抖动挣扎浮现一拍;
/// "被掐断"由紧随其后的 expression_glitch_pulse 表现。不推进谎话冲击等级。
/// <<expression_actor_truth_flash "我就是个笑话" 0.35 8.5 0.55 0.02>>
/// <<expression_actor_truth_flash "l10n.hs.exp.log1.target" 0.35 8.5 0.55 0.02>>
/// </summary>
[YarnCommand("expression_actor_truth_flash")]
public static IEnumerator ExpressionActorTruthFlash(
@@ -537,25 +782,52 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_truth_flash", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.FlashActorTruth(text, holdDuration, fontSize, alpha, jitter));
presentation.FlashActorTruth(localized[0], holdDuration, fontSize, alpha, jitter));
}
/// <summary>
/// 老虎机换字(非阻塞,节拍由 Yarn wait 控制):聚焦字在 duration 内只从 charPool 里疯狂换字。
/// truthPool 非空时其字符也混入换字池,并以真话暖色显示。
/// <<expression_actor_slot "没问题冇" 5 0.05 14 "我就是个笑话">>
/// <<expression_actor_slot "l10n.hs.exp.actor.loop" 5 0.05 14 "l10n.hs.exp.log1.target">>
/// </summary>
[YarnCommand("expression_actor_slot")]
public static void ExpressionActorSlot(
public static IEnumerator ExpressionActorSlot(
string charPool,
float duration,
float interval = 0.05f,
float fontSize = 14f,
string truthPool = "")
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StartActorSlotMachine(charPool, duration, interval, fontSize, truthPool);
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
charPool,
truthPool);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "expression_actor_slot", nameof(charPool)))
yield break;
presentation.StartActorSlotMachine(
localized[0],
duration,
interval,
fontSize,
localized[1] ?? string.Empty);
}
/// <summary>
@@ -583,7 +855,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 单字/短词满屏重击,并等待该拍结束。
/// <<expression_actor_word_hit "" 0.16 18 1.55>>
/// <<expression_actor_word_hit "l10n.hs.exp.atk.why" 0.16 18 1.55>>
/// </summary>
[YarnCommand("expression_actor_word_hit")]
public static IEnumerator ExpressionActorWordHit(
@@ -594,13 +866,23 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_word_hit", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.PlayActorWordHit(text, holdDuration, fontSize, punchScale));
presentation.PlayActorWordHit(localized[0], holdDuration, fontSize, punchScale));
}
/// <summary>
/// 单列词组严格裁在表情屏内,像字幕一样无缝向下滚动。
/// <<expression_actor_scroll "没问题|冇问题|帽问题" 1.8 0.85 0.45 5.2>>
/// <<expression_actor_scroll "l10n.hs.exp.actor.loop" 1.8 0.85 0.45 5.2>>
/// </summary>
[YarnCommand("expression_actor_scroll")]
public static IEnumerator ExpressionActorScroll(
@@ -612,9 +894,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
phraseList);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_scroll", nameof(phraseList)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.ScrollActorLies(
phraseList,
localized[0],
scrollDuration,
loopDuration,
holdDuration,
@@ -742,7 +1034,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 用现有目标粒子完整显示一个红色攻击词,并等待确定性的冲击动作结束。
/// 超过目标粒子数量时会安全跳过,不会截断。
/// <<expression_truth_attack "为什么" 0.34 0.45>>
/// <<expression_truth_attack "l10n.hs.exp.atk.why" 0.34 0.45>>
/// </summary>
[YarnCommand("expression_truth_attack")]
public static IEnumerator ExpressionTruthAttack(
@@ -753,17 +1045,26 @@ namespace AibisDream.MiniGame.Language
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_truth_attack", nameof(text)))
yield break;
// 红屏武装时每个攻击词都把屏幕顶一下(punch),力度跟着这个词的 impact 走。
// 写在这里而不是 Yarn 里逐条加 pulse:攻击列表经常重调,两边手写必然脱节。
ExpressionManager.PunchScreenForAttack(impact);
yield return ExpressionManager.StartCoroutine(
presentation.PlayTruthAttack(text, duration, impact));
presentation.PlayTruthAttack(localized[0], duration, impact));
}
/// <summary>
/// Keep an already resolved truth phrase in place while it gradually becomes unstable and red.
/// <<expression_truth_destabilize "不要那样看我" 1.6 0.13 22 "FF304D">>
/// <<expression_truth_destabilize "l10n.hs.exp.log3.target" 1.6 0.13 22 "FF304D">>
/// </summary>
[YarnCommand("expression_truth_destabilize")]
public static IEnumerator ExpressionTruthDestabilize(
@@ -775,9 +1076,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_truth_destabilize", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.DestabilizeTruthPhrase(
text,
localized[0],
duration,
shakeAmplitude,
shakeSpeed,
@@ -786,7 +1097,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 将当前攻击词重组为固定青绿色真话,并等待稳定完成。
/// <<expression_truth_settle "我想赢一次" 0.8>>
/// <<expression_truth_settle "l10n.hs.exp.final.win" 0.8>>
/// </summary>
[YarnCommand("expression_truth_settle")]
public static IEnumerator ExpressionTruthSettle(
@@ -795,8 +1106,18 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_truth_settle", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.SettleTruthPhrase(text, duration));
presentation.SettleTruthPhrase(localized[0], duration));
}
[YarnCommand("expression_truth_blackout")]
@@ -808,14 +808,19 @@ namespace AibisDream.MiniGame.Language
}
}
public void StartActorFlashLoop()
public void StartActorFlashLoop(IReadOnlyList<string> phrases)
{
EnsureRuntimeObjects();
if (!ExpectState(nameof(StartActorFlashLoop), PresentationState.Memory))
return;
if (phrases == null || phrases.Count == 0)
{
Debug.LogError("[LogReleasePresentation] Actor Flash Loop 没有有效短句;已安全跳过。");
return;
}
StopActorFlashLoopImmediate(true);
actorFlashLoopRoutine = StartCoroutine(ActorFlashLoopRoutine());
actorFlashLoopRoutine = StartCoroutine(ActorFlashLoopRoutine(phrases));
}
public void StopActorFlashLoop()
@@ -823,27 +828,33 @@ namespace AibisDream.MiniGame.Language
StopActorFlashLoopImmediate(true);
}
private IEnumerator ActorFlashLoopRoutine()
private IEnumerator ActorFlashLoopRoutine(IReadOnlyList<string> phrases)
{
while (state == PresentationState.Memory)
{
yield return FlashActorLie(
"没问题", 0.25f, 10.5f, 1.1f, 1.45f, 0.008f, 0.06f, 0.50f, 0f);
if (state != PresentationState.Memory)
break;
yield return new WaitForSeconds(0.3f);
for (int i = 0; i < phrases.Count && state == PresentationState.Memory; i++)
{
int style = i % 3;
float hold = style == 2 ? 0.15f : 0.25f;
float fontSize = style == 0 ? 10.5f : style == 1 ? 11.2f : 12f;
float punchScale = style == 0 ? 1.45f : style == 1 ? 1.85f : 2.20f;
float chromaDistance = style == 2 ? 0.010f : 0.008f;
float chromaAlpha = style == 2 ? 0.08f : 0.06f;
float impact = style == 0 ? 0.50f : style == 1 ? 0.72f : 1f;
yield return FlashActorLie(
"冇问题", 0.25f, 11.2f, 1.1f, 1.85f, 0.008f, 0.06f, 0.72f, 0f);
if (state != PresentationState.Memory)
break;
yield return new WaitForSeconds(0.3f);
yield return FlashActorLie(
"帽问题", 0.15f, 12.0f, 1.1f, 2.20f, 0.010f, 0.08f, 1.00f, 0f);
if (state != PresentationState.Memory)
break;
yield return new WaitForSeconds(0.3f);
yield return FlashActorLie(
phrases[i],
hold,
fontSize,
1.1f,
punchScale,
chromaDistance,
chromaAlpha,
impact,
0f);
if (state == PresentationState.Memory)
yield return new WaitForSeconds(0.3f);
}
}
actorFlashLoopRoutine = null;
-9
View File
@@ -1,9 +0,0 @@
键,中文,英文,日文
Key,Cn,En,Ja
天空,天空,Sky,そら
海报,海报,Poster,ポスター
植物,植物,Plant,植物
朋友,朋友,Friend,友達
情绪,情绪,Emo,情緒
记忆,记忆,Memory,記憶
逻辑,逻辑,Logic,論理
1 中文 英文 日文
2 Key Cn En Ja
3 天空 天空 Sky そら
4 海报 海报 Poster ポスター
5 植物 植物 Plant 植物
6 朋友 朋友 Friend 友達
7 情绪 情绪 Emo 情緒
8 记忆 记忆 Memory 記憶
9 逻辑 逻辑 Logic 論理
+39 -39
View File
@@ -71,7 +71,7 @@ me: 这一条看起来……是一个关于焦虑的记录。 #line:046ab93d
Task: 第 1 条log:左键排斥、右键吸引,连接目标并分离干扰 #line:07a299c
<<play_sfx event:/Scriptal/hs1_log1>>
<<set_global_param hs1LogStage 1>>
<<start_expression "哈哈|嘿嘿|呵呵" "我就是个笑话" "LOG1梳理完成" 16>>
<<start_expression_round "log1" "LOG1梳理完成">>
===
@@ -131,8 +131,8 @@ position: 900,2100
// JumpDevTool 专用:从 Stage6就绪存档恢复后,跳过粒子玩法直接预览 LOG1 演出
<<clear_tasks>>
<<switch_fix_system_to Expression>>
<<start_expression "哈哈|嘿嘿|呵呵" "我就是个笑话" "LOG1梳理完成" 16>>
// <<start_expression "哈哈哈哈|笑死我了|太好笑了|绷不住了|笑不活了|真的太搞笑" "不要那样看我" "LOG疏通全部完成" 38>>
<<start_expression_round "log1" "LOG1梳理完成">>
// <<start_expression "l10n.hs.exp.log3.tokens" "l10n.hs.exp.log3.target" "LOG疏通全部完成" 38>>
===
@@ -243,7 +243,7 @@ Task: 第 2 条log:左键排斥、右键吸引,连接目标并分离干扰 #
<<set_global_param hs1LogStage 1>>
<<stop_sfx event:/Scriptal/hs1_log1>>
<<play_sfx event:/Scriptal/hs1_log2>>
<<start_expression "哈哈哈|嘿嘿嘿|呵呵呵|笑死了|好好笑" "不要离开我" "LOG2梳理完成" 28>>
<<start_expression_round "log2" "LOG2梳理完成">>
===
@@ -297,7 +297,7 @@ position: 900,2550
// JumpDevTool 专用:从 Stage6就绪存档恢复后,跳过粒子玩法直接预览 LOG2 演出
<<clear_tasks>>
<<switch_fix_system_to Expression>>
<<start_expression "哈哈哈|嘿嘿嘿|呵呵呵|笑死了|好好笑" "不要离开我" "LOG2梳理完成" 28>>
<<start_expression_round "log2" "LOG2梳理完成">>
===
@@ -399,7 +399,7 @@ Task: 第 3 条log:左键排斥、右键吸引,连接目标并分离干扰 #
<<set_global_param hs1LogStage 1>>
<<stop_sfx event:/Scriptal/hs1_log2>>
<<play_sfx event:/Scriptal/hs1_log3>>
<<start_expression "哈哈哈哈|笑死我了|太好笑了|绷不住了|笑不活了|真的太搞笑" "为什么不相信我" "LOG疏通全部完成" 38>>
<<start_expression_round "log3" "LOG疏通全部完成">>
===
@@ -462,7 +462,7 @@ position: 900,3000
// JumpDevTool 专用:从 Stage6就绪存档恢复后,跳过粒子玩法直接预览 LOG3 演出
<<clear_tasks>>
<<switch_fix_system_to Expression>>
<<start_expression "哈哈哈哈|笑死我了|太好笑了|绷不住了|笑不活了|真的太搞笑" "为什么不相信我" "LOG疏通全部完成" 38>>
<<start_expression_round "log3" "LOG疏通全部完成">>
===
@@ -507,7 +507,7 @@ hs: …… #line:085bda6
<<expression_truth_gather 1 0.5>>
<<wait 0.5>>
hs: <speed=0.2>不要那样看我…… #line:00e7b75
<<expression_truth_destabilize "为什么不相信我" 1.6 0.05 22 "FF304D">>
<<expression_truth_destabilize "l10n.hs.exp.log3.target" 1.6 0.05 22 "FF304D">>
<<jump LOG3到结束>>
===
@@ -528,69 +528,69 @@ me: 等会…其他的log好像有异常传入…… #line:0d3cc39
// 节奏不是一句一格。四种拍子交替:**空**(留白)/ **连**(挤在一起吐出来)/
// **顿**(狠话之后的静默)/ **急**(越来越快)。均匀推进会把最狠的几句读平。
// —— 空:起手两记,各自留白
<<expression_truth_attack "为什么" 0.30 0.32>>
<<expression_truth_attack "l10n.hs.exp.atk.why" 0.30 0.32>>
hs: <speed=0.1>为什么#auto_next #line:059ef58
<<wait 2>>
<<expression_truth_attack "不相信我" 0.32 0.40>>
<<expression_truth_attack "l10n.hs.exp.atk.doubt" 0.32 0.40>>
hs: <speed=0.5>不相信我? #line:06810ae
<<wait 1.5>>
// —— 连:辩解一口气倒出来,中间不喘
<<expression_truth_attack "我明白我垫底" 0.32 0.34>>
<<expression_truth_attack "l10n.hs.exp.atk.last" 0.32 0.34>>
hs: <speed=0.2>我明白我垫底, #line:00248ff
<<expression_truth_attack "天气真的不行" 0.34 0.46>>
<<expression_truth_attack "l10n.hs.exp.atk.weather" 0.34 0.46>>
hs: <speed=0.2>可是这周天气真的不行! #line:0ffc958
<<expression_truth_attack "卖不出去" 0.34 0.50>>
<<expression_truth_attack "l10n.hs.exp.atk.unsold" 0.34 0.50>>
hs: <speed=0.5>其它销售……也是一顶都卖不出去! #line:0d2c25f
// —— 顿:辩解说完,发现没人信,卡壳一下
<<wait 0.6>>
<<expression_truth_attack "努力解释" 0.34 0.52>>
<<expression_truth_attack "l10n.hs.exp.atk.explain" 0.34 0.52>>
hs: <speed=0.2>为什么我都这么努力解释了,#auto_next #line:0ebd252
<<expression_truth_attack "不愿意相信我" 0.36 0.58>>
<<expression_truth_attack "l10n.hs.exp.atk.refuse" 0.36 0.58>>
hs: <speed=0.5>你还是不愿意相信我? #line:0328a7c
<<expression_truth_attack "一点信任" 0.38 0.62>>
<<expression_truth_attack "l10n.hs.exp.atk.trust" 0.38 0.62>>
hs: <speed=0.2>到底它们是你的员工,还是我?#auto_next #line:0a915ef
hs: <speed=0.5>你对我从来没有一点信任…… #line:0b69892
// —— 顿:最长的一次静默,攒住再爆
<<wait 1.0>>
<<expression_truth_attack "受够了" 0.42 0.76>>
<<expression_truth_attack "l10n.hs.exp.atk.enough" 0.42 0.76>>
hs: <speed=0.5>我真的受够了! #line:0312421
hs: <speed=0.2>总是被你当狗一样训,#auto_next #line:00412cb
<<expression_truth_attack "受够了" 0.28 0.88>>
<<expression_truth_attack "l10n.hs.exp.atk.enough" 0.28 0.88>>
<<wait 0.2>>
<<expression_truth_attack "受够了" 0.28 0.88>>
<<expression_truth_attack "l10n.hs.exp.atk.enough" 0.28 0.88>>
<<wait 0.2>>
<<expression_truth_attack "受够了" 0.28 0.88>>
<<expression_truth_attack "l10n.hs.exp.atk.enough" 0.28 0.88>>
<<wait 1.5>>
<<expression_truth_attack "真该死" 0.34 0.55>>
<<expression_truth_attack "l10n.hs.exp.atk.damn" 0.34 0.55>>
hs: <speed=0.2>路斯,我它码真该死! #line:03678e3
<<expression_truth_attack "真没用" 0.34 0.58>>
<<expression_truth_attack "l10n.hs.exp.atk.useless" 0.34 0.58>>
hs: 我真没用! #line:093977a
<<wait 0.45>>
<<expression_truth_attack "证明自己" 0.35 0.60>>
<<expression_truth_attack "l10n.hs.exp.atk.prove" 0.35 0.60>>
hs: <speed=0.2>你不知道我有多想证明自己,#auto_next #line:0555cab
<<expression_truth_attack "什么都做不到" 0.38 0.66>>
<<expression_truth_attack "l10n.hs.exp.atk.nothing" 0.38 0.66>>
hs: <speed=0.5>可是我什么都做不到…… #line:0cf70a3
<<wait 0.5>>
// —— 急:一句比一句挤,往最狠的那句冲
<<expression_truth_attack "追上你" 0.36 0.64>>
<<expression_truth_attack "l10n.hs.exp.atk.catchup" 0.36 0.64>>
hs: <speed=0.2>我没办法追上你,#auto_next #line:0deff66
<<expression_truth_attack "帮到英理" 0.38 0.68>>
<<expression_truth_attack "l10n.hs.exp.atk.yingli" 0.38 0.68>>
hs: <speed=0.2>没办法帮到英理,#auto_next #line:0c2e7b8
<<expression_truth_attack "没有勇气" 0.40 0.74>>
<<expression_truth_attack "l10n.hs.exp.atk.courage" 0.40 0.74>>
hs: <speed=0.2>甚至连自己被羞辱了都没有勇气#auto_next #line:00ab3a7
<<expression_truth_attack "说出来" 0.40 0.78>>
<<expression_truth_attack "l10n.hs.exp.atk.say" 0.40 0.78>>
hs: <speed=0.5>说出来,#auto_next #line:0ad49b4
// —— 顿:最狠那句前面一定要有空
<<wait 0.55>>
<<expression_truth_attack "废物" 0.42 0.82>>
<<expression_truth_attack "l10n.hs.exp.atk.trash" 0.42 0.82>>
hs: <speed=0.2>我就是个废物…… #line:079cc40
<<expression_truth_attack "废物" 0.22 0.82>>
<<expression_truth_attack "l10n.hs.exp.atk.trash" 0.22 0.82>>
<<wait 0.2>>
<<expression_truth_attack "废物" 0.22 0.82>>
<<expression_truth_attack "l10n.hs.exp.atk.trash" 0.22 0.82>>
<<wait 0.2>>
<<expression_truth_attack "废物" 0.22 0.82>>
<<expression_truth_attack "l10n.hs.exp.atk.trash" 0.22 0.82>>
<<wait 0.2>>
<<expression_truth_attack "烂泥扶不上墙" 0.58 1.00>>
<<expression_truth_attack "l10n.hs.exp.atk.hopeless" 0.58 1.00>>
hs: <speed=0.8>我它码就是个烂泥扶不上墙的废物!! #line:0e8a4da
// —— 空:吼到底之后整个泄下来,转成气音
<<wait 0.85>>
@@ -602,15 +602,15 @@ hs: <speed=0.3>就这么想赢一次…… #line:08aece0
<<expression_screen_stress 0.3 1.5>>
// 五连击必须**错开**:原本五条挤在同一帧,五记 punch 会合成一记,读不出连击。
// 间隔越来越短 = 越捶越急。
<<expression_truth_attack "为什么为什么" 0.4 1.00>>
<<expression_truth_attack "l10n.hs.exp.atk.why_loop" 0.4 1.00>>
<<wait 0.3>>
<<expression_truth_attack "为什么为什么" 0.3 1.00>>
<<expression_truth_attack "l10n.hs.exp.atk.why_loop" 0.3 1.00>>
<<wait 0.24>>
<<expression_truth_attack "为什么为什么" 0.2 1.00>>
<<expression_truth_attack "l10n.hs.exp.atk.why_loop" 0.2 1.00>>
<<wait 0.18>>
<<expression_truth_attack "为什么为什么" 0.2 1.00>>
<<expression_truth_attack "l10n.hs.exp.atk.why_loop" 0.2 1.00>>
<<wait 0.14>>
<<expression_truth_attack "为什么为什么" 0.2 1.00>>
<<expression_truth_attack "l10n.hs.exp.atk.why_loop" 0.2 1.00>>
<<start_timeline "火山Glitch/火山头痛3">>
<<expression_memory_cut>>
<<expression_glitch_pulse 0.82 0.03 0.18>>
@@ -622,7 +622,7 @@ hs: <speed=0.3>就这么想赢一次…… #line:08aece0
<<expression_screen_rage 0 0.55>>
<<expression_truth_snap 0.1 0.25>>
hs: <speed=0.2><shake a=0.3>我真的好想…</shake> #line:02fff1d
<<expression_truth_settle "我想赢一次" 0.55>>
<<expression_truth_settle "l10n.hs.exp.final.win" 0.55>>
<<wait_timeline "火山Glitch/火山头痛3">>
<<wait 0.6>>
me: …… #line:04c2999
+65 -384
View File
@@ -1,412 +1,93 @@
# 火山 Stage6 本地化完整实施计划
# 火山 Stage6 / ExpressSystem 本地化方案
## 1. 目标与边界
## 当前决策
Stage6 继续使用项目现有的两套本地化入口:
ExpressSystem 的玩家可见玩法文字统一通过 `l10n.*` 引用,并以 Unity
Localization 的 `Params` String Table Collection 作为唯一数据源。
- 固定 UIUnity Localization 的现有 `UIText` String Table Collection
- Stage6 玩法及演出内容:新增独立的 `HuoshanStage6` String Table Collection
- Yarn 普通对白:继续沿用项目 Yarn Spinner 本地化体系,但本轮不启用 `FP_Huoshan1` 的整章对白翻译
- `params.csv` 继续只用于 `l10n.*` 通用短语替换,不承载 Stage6 玩法文本
- `l10n.xxx` 查询 `Params/xxx`
- 查询使用 `DontUseFallback`,不回退中文或其他 Locale
- 中文表保存源数据;英文、日文表保留同一套 Key,未翻译值留空
- Key 不存在时显示 `⟦key⟧` 并记录包含 Table、Key、Locale 的错误
- Express 必填值解析为空时安全终止命令,不启动粒子或释放演出。
- `StreamingAssets/Config/params.csv` 已退出运行链路。
- 固定 UI 继续使用 `UIText`;普通 Yarn 对白、Task、教学和角色名不迁入
`Params`
本轮纳入:
## 轮次配置
- 表达小游戏目标、输出、整合率、干扰数量等固定 UI。
- 三轮语言粒子的目标句和干扰 token。
- LOG3 释放演出中的攻击词、稳定词、失稳词等动态文字。
- 相关程序接口、Yarn 命令协议和使用文档。
- 中、英、日三种语言数据;英日文首版标记为待母语审校。
三轮正式内容由 `ExpressionContentCatalog` 管理,场景中的
`ExpressionManager` 持有 Catalog 引用:
本轮明确暂缓:
| Round ID | Target | Tokens | 非目标粒子数 |
|---|---|---|---:|
| `log1` | `l10n.hs.exp.log1.target` | `l10n.hs.exp.log1.tokens` | 16 |
| `log2` | `l10n.hs.exp.log2.target` | `l10n.hs.exp.log2.tokens` | 28 |
| `log3` | `l10n.hs.exp.log3.target` | `l10n.hs.exp.log3.tokens` | 38 |
- 普通角色对白。
- Task 面板中的“释放表达模块的阻塞 log”和 LOG1/2/3 任务提示
- “左键排斥、右键吸引”等 Yarn 操作教学。
- Yarn line tag 和 Yarn 本地化 CSV。
- 调试日志、Inspector Tooltip、对象名、节点名等开发文本。
Actor Loop 使用 `l10n.hs.exp.actor.loop`。Round ID 大小写敏感;重复 ID、
未知 ID、空引用、非 `l10n.*` 引用或解析后的空值均视为数据错误
因此,本轮完成后核心小游戏及释放演出可以多语言运行,但任务面板与操作教学仍会显示中文,等待之后随对白本地化统一处理。
## 2. 本地化数据结构
### 2.1 固定 UI`UIText`
继续使用现有键,不新建重复条目:
| Key | 用途 | 中文示例 | 英文草稿 | 日文草稿 |
|---|---|---|---|---|
| `huoshan_expression_objective` | 小游戏目标 | 目标:分离红色干扰,连接蓝色真心 | OBJECTIVE: ISOLATE RED INTERFERENCE. CONNECT THE BLUE TRUTH. | 目標:赤い干渉を分離し、青い本音をつなげる |
| `huoshan_expression_output` | 输出按钮 | 输出 | OUTPUT | 出力 |
| `huoshan_expression_integration` | 整合率 | 语言整合率:{0}% | LANGUAGE INTEGRATION: {0}% | 言語統合率:{0}% |
| `huoshan_expression_interference` | 干扰数量 | 干扰思绪:{0} | INTERFERENCE SIGNALS: {0} | 干渉思考:{0} |
要求:
- 保留 `{0}` 参数,程序继续通过格式化参数填入数值。
- 英文和日文目前的中文占位必须替换。
- 固定 UI 继续使用现有 `LocalizeStringEvent``StringDatabase` 绑定。
- 切换 Locale 时立即刷新这些固定 UI。
### 2.2 玩法内容:`HuoshanStage6`
新增 String Table Collection
```text
HuoshanStage6
├─ zh-Hans
├─ en
└─ ja-JP
```
键名只表达语义,不包含语言、行号或 Yarn 节点名。
#### 语言粒子数据
```text
round.log1.target
round.log1.interference_tokens
round.log2.target
round.log2.interference_tokens
round.log3.target
round.log3.interference_tokens
```
中文源数据:
| Key | 中文值 |
|---|---|
| `round.log1.target` | 我就是个笑话 |
| `round.log1.interference_tokens` | 哈哈\|嘿嘿\|呵呵 |
| `round.log2.target` | 不要离开我 |
| `round.log2.interference_tokens` | 哈哈哈\|嘿嘿嘿\|呵呵呵\|笑死了\|好好笑 |
| `round.log3.target` | 不要那样看我 |
| `round.log3.interference_tokens` | 哈哈哈哈\|笑死我了\|太好笑了\|绷不住了\|笑不活了\|真的太搞笑 |
`interference_tokens` 的表值使用 `|` 分隔。解析规则:
- 按 `|` 拆分。
- 对每项执行 Trim。
- 丢弃空项。
- 至少保留一个有效 token,否则视为数据错误。
- token 由本地化人员针对语言重新创作,不要求与中文逐字对应。
- 英文示例:`HA|HAHA|LOL|JK`
- 日文示例:`ハハ|笑|冗談|ウケる`
#### LOG3 释放演出数据
按语义建立以下键;重复演出复用同一个键:
```text
release.attack.why
release.attack.dont_believe_me
release.attack.i_know_im_last
release.attack.bad_weather
release.attack.cannot_sell
release.attack.try_to_explain
release.attack.refuse_to_believe
release.attack.a_little_trust
release.attack.had_enough
release.attack.damn
release.attack.useless
release.attack.prove_myself
release.attack.cannot_do_anything
release.attack.catch_up
release.attack.help_yingli
release.attack.no_courage
release.attack.say_it
release.attack.trash
release.attack.hopeless
release.attack.why_loop
release.settle.want_to_win_once
release.destabilize.dont_look_at_me
```
映射规则:
| 中文文本 | Key |
|---|---|
| 为什么 | `release.attack.why` |
| 不相信我 | `release.attack.dont_believe_me` |
| 我明白我垫底 | `release.attack.i_know_im_last` |
| 天气真的不行 | `release.attack.bad_weather` |
| 卖不出去 | `release.attack.cannot_sell` |
| 努力解释 | `release.attack.try_to_explain` |
| 不愿意相信我 | `release.attack.refuse_to_believe` |
| 一点信任 | `release.attack.a_little_trust` |
| 受够了 | `release.attack.had_enough` |
| 真该死 | `release.attack.damn` |
| 真没用 | `release.attack.useless` |
| 证明自己 | `release.attack.prove_myself` |
| 什么都做不到 | `release.attack.cannot_do_anything` |
| 追上你 | `release.attack.catch_up` |
| 帮到英理 | `release.attack.help_yingli` |
| 没有勇气 | `release.attack.no_courage` |
| 说出来 | `release.attack.say_it` |
| 废物 | `release.attack.trash` |
| 烂泥扶不上墙 | `release.attack.hopeless` |
| 为什么为什么 | `release.attack.why_loop` |
| 我想赢一次 | `release.settle.want_to_win_once` |
| 不要那样看我 | `release.destabilize.dont_look_at_me` |
Yarn 中重复出现的“受够了”“废物”“为什么为什么”继续保持原来的重复次数和演出参数,只复用同一条本地化键,不能因去重而改变演出节奏。
未来如果 Stage6 使用 `expression_system_message` 或其他会显示字符串的释放命令,也必须新增对应的 `release.*` 键,不能直接写玩家可见文本。
### 2.3 表格元数据
英日文第一版可以直接投入开发测试,但每个非中文条目增加统一备注:
```text
DRAFT / NEEDS NATIVE REVIEW
Context: HuoShan Stage6 expression minigame
```
释放演出条目应额外注明:
- 显示形式,例如瞬间闪现、连续攻击或最终稳定文字。
- 建议长度。
- 是否允许创译。
- 重复次数由 Yarn 控制,本地化值本身不要手工重复。
## 3. 程序接口和数据流
### 3.1 本地化引用协议
Yarn 命令参数支持两种输入:
```text
@h6.<entry-key> 从 HuoshanStage6 String Table 解析
普通字符串 按原始字符串使用,保持旧内容兼容
```
示例:
正式 Yarn 使用:
```yarn
<<start_expression
"@h6.round.log1.interference_tokens"
"@h6.round.log1.target"
"LOG1梳理完成"
16>>
<<expression_truth_attack "@h6.release.attack.why" 0.30 0.32>>
<<expression_truth_settle "@h6.release.settle.want_to_win_once" 0.55>>
<<expression_truth_destabilize
"@h6.release.destabilize.dont_look_at_me"
1.6
0.05
22
"FF304D">>
<<start_expression_round "log1" "LOG1梳理完成">>
```
其中:
`start_expression` 继续兼容原始字符串,也允许将前两个参数写为
`l10n.*``completionNode``memoryKey``preset`、颜色、Timeline 名称及
数值参数均为结构参数,不做本地化解析。
- 前两个 `start_expression` 参数是本地化内容。
- 完成节点 `LOG1梳理完成` 是 Yarn 流程标识,不做本地化。
- 粒子数量、持续时间、颜色、Timeline、memory preset 等均为结构参数,不做本地化。
## Locale 生命周期
### 3.2 建议公共数据结构
启动轮次时捕获当前 Locale,目标句和 token 并行解析;校验通过后才初始化
粒子系统。该 Locale 快照会保留到下一轮开始或 ExpressSystem 关闭,当前轮
后续释放演出都使用同一快照。没有活动轮次时,演出命令使用命令调用时的
当前 Locale。
```csharp
public readonly struct Stage6TextReference
{
public string RawValue { get; }
public bool IsLocalized { get; }
public string EntryKey { get; }
}
这可避免切换语言时旧异步请求覆盖新一轮内容。`PunchTapeItem` 也通过请求
版本号避免旧名称刷新覆盖新 Locale。
public sealed class Stage6RoundText
{
public string TargetText { get; init; }
public IReadOnlyList<string> InterferenceTokens { get; init; }
}
## Key 约定
public sealed class Stage6LocalizationContext
{
public const string TableName = "HuoshanStage6";
public const string ReferencePrefix = "@h6.";
Express 使用短前缀 `hs.exp.*`
public Locale RoundLocale { get; private set; }
- 三轮目标和 token`hs.exp.log{1..3}.target``.tokens`
- Actor Loop`hs.exp.actor.loop`
- LOG3 攻击词:`hs.exp.atk.*`
- 最终稳定文字:`hs.exp.final.win`
public IEnumerator BeginRound(Locale locale);
重复演出复用同一 Key,但 Yarn 中仍保留原有调用次数、等待、数值、顺序和
`#line:` 标签。
public IEnumerator Resolve(
string textReference,
Action<string> completed);
## token 与 Unicode
public IEnumerator Resolve(
string textReference,
Locale locale,
Action<string> completed);
干扰 token 只接受半角 `|`
public IEnumerator LoadRound(
string roundId,
Locale locale,
Action<Stage6RoundText> completed);
}
```
- 按 `|` 拆分;
- Trim 并删除空项;
- 全空时中止轮次;
- 全角 `` 是数据错误。
职责划分:
正式目标句和释放演出文字按 `StringInfo` 的 Unicode 文本元素处理。汉字、
假名、拉丁字母、组合字符、代理字符和标点均保持完整;空白不生成粒子,但
最终排列保留词间距。目标粒子数量、最终排列、攻击词长度校验和 truth leak
匹配使用同一套文本元素结果。
- `Stage6TextReference`:判断参数是本地化引用还是兼容用原始字符串。
- `Stage6LocalizationContext`:统一访问 `HuoshanStage6`,避免每个 Yarn 命令各写一套解析逻辑。
- `Stage6RoundText`:向粒子系统提供已经解析完成的数据;粒子系统不直接访问 String Table
- `LanguageYarnCommand`:在启动小游戏前等待本地化解析完成,再把纯文本数据交给 `LanguageParticleManager`
- Log 释放相关 Yarn Command:显示前通过同一 resolver 解析字符串,再交给 `LogReleasePresentationController`
- `LanguageParticleManager``LogReleasePresentationController` 最终只接收已解析的文本,不关心键、表名或 Locale。
本次明确不修改 `TextParticle` 的随机字符池、`chineseChars`
`chineseWords`、目标粒子入场阶段的随机字符来源,以及背景/干扰粒子的字符
池策略
### 3.3 解析和错误处理
## 内容校验
解析规则必须集中实现
编辑器校验覆盖
1. 参数不以 `@h6.` 开头时,直接返回原字符串。
2. 参数以 `@h6.` 开头时,取后半部分作为 Entry Key。
3. Entry Key 为空时记录错误,并显示可识别的错误占位。
4. 优先获取指定 Locale 的条目。
5. 当前语言缺失时回退到 `zh-Hans` 的同名条目。
6. 中文源条目也缺失时:
- 输出明确错误日志,包含 Table、Key、Locale 和调用命令。
- 玩家画面显示 `⟦缺失的键名⟧`
- 不得直接显示 `@h6.xxx`,也不能静默返回空字符串。
7. `interference_tokens` 解析后为空时终止该次 `start_expression`,输出数据错误,避免粒子生成阶段产生难以定位的异常。
- Stage6 正式 Express 文字参数不得残留中文硬编码;
- Yarn 和 Catalog 中引用的 `l10n.*` Key 必须存在于 `Params` Shared Data
- 中文值必须非空;
- 英文、日文空值只报告待翻译,不导致校验失败;
- `.tokens` 至少包含一个有效项,且不能使用全角 ``
- completion node、preset、memory key、颜色、Timeline 等结构参数不误报。
### 3.4 Locale 切换行为
固定 UI
- `objective``output``integration``interference` 监听 `SelectedLocaleChanged`
- 切换语言后立即刷新。
- 当前数值必须保留,只替换格式模板。
语言粒子:
- 每次 `start_expression` 开始时捕获当前 Locale。
- 同一轮的目标句、目标粒子和干扰 token 全部使用该快照。
- 轮次进行中切换 Locale,不重建、不替换现有粒子。
- 下一次 `start_expression` 使用新的 Locale。
- 异步解析结束前不得开始生成粒子。
LOG 释放命令:
- 每条命令执行时解析其文字参数。
- 如果该命令属于仍在进行的表达轮次,沿用该轮的 Locale 快照。
- 没有活动轮次时使用命令执行时的当前 Locale。
- 单次命令解析完成后再启动对应演出,避免先显示 key、后替换文字。
### 3.5 Unicode 与粒子拆分
目标句不能按 UTF-16 `char``string.Length` 拆分,必须使用 Unicode 文本元素/字素簇。
规则:
- 中文汉字、日文假名、拉丁字母、组合音标均按完整文本元素处理。
- 英文空格不生成粒子,但在成功连接后的排版中形成单词间距。
- 标点是否生成粒子沿用当前玩法规则,但不能被拆成代理项。
- 有效目标粒子数量、完成判断和候选粒子数量都基于同一套文本元素结果。
- 干扰 token 是本地化短词;生成粒子时再按同样的文本元素规则取字符。
- 不使用英文 `string.Length` 直接决定粒子数量。
## 4. 具体改动安排
### 4.1 Unity Localization 资源
- 补齐 `UIText` 中四个现有火山 UI 键的英日文。
- 新建 `HuoshanStage6` Collection 及中、英、日三张表。
- 将 Collection 按项目现有 Localization Addressables 方式纳入构建。
- 配置英日文字体或 TMP fallback,覆盖拉丁字符、日文假名、常用标点。
### 4.2 语言粒子系统
- `LanguageYarnCommand` 接入统一的 `@h6.` resolver。
- `start_expression` 在调用 `LanguageParticleManager` 前解析目标句和 token。
- `LanguageParticleManager` 接收 `Stage6RoundText` 或等价的纯数据参数。
- 保留现有原始字符串调用方式,避免其他测试场景和旧 Yarn 立即失效。
- 将固定 UI 的中文常量仅作为开发兜底,不作为正式语言数据来源。
### 4.3 LOG 释放演出
- 所有接受玩家可见字符串的释放命令接入相同 resolver。
- 将 Stage6 中的攻击、settle、destabilize 参数改为 `@h6.` 引用。
- 不修改数值演出参数、调用顺序和重复次数。
- `LogReleasePresentationController` 保持表现层职责,只接收最终显示文本。
### 4.4 Yarn 内容
- 仅替换本地化范围内的 Yarn 命令参数。
- 不添加或修改 `#line:` 标签。
- 不配置 `FP_Huoshan1.yarnproject` 的整章 localization。
- 不生成 Stage6 Yarn 翻译 CSV。
- 被注释的示例命令不需要建立正式本地化条目;若未来启用,必须先补键。
- Task 行、鼠标教学和普通对白保持原样。
### 4.5 Prefab、图片与文档
- `ExpressManager.prefab` 当前没有确认的运行时玩家文本,也不是 Stage6 正式文本数据源,本轮不向其中写多语言文案。
- `ExpressionScreen_NoText.png` 已确认无烘焙文字,不制作多语言 Sprite。
- 场景或 Prefab 中诸如“输出”的序列化 TMP 文本只作为编辑器预览;运行时必须由 `UIText` 覆盖。
- 更新 `LogReleaseYarnCommands.md`
- 说明 `@h6.` 引用格式。
- 列出哪些参数可本地化、哪些是结构参数。
- 提供 attack、settle、destabilize 示例。
- 记录回退与错误显示规则。
- 同步更新语言系统说明文档中的 `start_expression` 示例,展示本地化键和旧原始字符串两种写法。
## 5. 测试与验收
### 5.1 数据完整性测试
- `HuoshanStage6` 的中、英、日表拥有相同键集合。
- 中文源条目不得为空。
- 英日文不得残留中文占位;允许通过备注标记待审校。
- 所有 `interference_tokens` 至少包含一个有效 token。
- 所有 Stage6 中实际执行的本地化命令参数都能解析。
- 结构参数没有被误当成本地化键。
- 扫描 Stage6 后,剩余硬编码玩家文本必须全部属于已声明的暂缓范围。
### 5.2 编辑模式测试
- `@h6.key` 能解析指定 Locale。
- 普通字符串保持原值。
- 缺少目标语言时回退中文。
- 三种语言都缺失时显示 `⟦key⟧` 并记录错误。
- token 能正确 Trim 和过滤空项。
- Unicode 文本元素拆分能处理中文、英文、日文和组合字符。
- 英文空格不生成粒子,但结果排版保留词间距。
- 重复攻击词引用同一 Key 时仍能按 Yarn 调用次数重复播放。
### 5.3 PlayMode/场景验收
分别使用 `zh-Hans``en``ja-JP` 运行 LOG1、LOG2、LOG3
- 固定 UI 显示正确,格式参数正确。
- 每轮目标句和干扰 token 使用对应语言。
- 粒子连接、排斥、吸引和完成判断不受文本长度影响。
- LOG3 attack、settle、destabilize 全部显示对应语言。
- 演出调用顺序、节奏、颜色和持续时间与改造前一致。
- 中途切换语言时,固定 UI 立即更新,当前轮粒子不变,下一轮使用新语言。
- 字体无缺字方框。
- 英文长文本和日文文本没有溢出、异常换行或关键画面遮挡。
### 5.4 回归验收
- 使用旧的原始字符串调用 `start_expression` 仍然可以运行。
- `languageTest` 等测试内容不因新增 resolver 失效。
- Yarn 编译无错误。
- Stage6 的完成节点跳转保持不变。
- Task 完成逻辑和 Yarn line ID 不受影响。
- Addressables/Localization 构建包含新增表格。
- 在 Unity Editor 中完成最终视觉检查;当前无法通过 Unity MCP 自动进行场景级检查。
## 6. 交付判定
满足以下条件后视为本轮完成:
- Stage6 固定小游戏 UI 已具备中英日数据。
- 三轮粒子内容和全部正式 LOG 释放文本均从 `HuoshanStage6` 获取。
- Yarn 中相关硬编码文本已替换为 `@h6.` 引用。
- 原始字符串调用保持兼容。
- Locale 快照、回退、缺键和 Unicode 拆分规则均有测试覆盖。
- 文档足以让后续策划直接新增本地化键并在 Yarn 命令中使用。
- 任务提示、操作教学和普通对白仍明确列为后续 Yarn 本地化工作,不被误报为已完成。
运行时和 EditMode 测试同时覆盖原始字符串、空值不回退、缺 Key 占位、
显式 Locale、轮次配置、token 解析和 Unicode 文本元素行为。