feat: 火山表达维修粒子字符池拆分

This commit is contained in:
2026-07-29 13:00:59 +08:00
parent a48e4babd4
commit 8f2ee603d5
26 changed files with 1830 additions and 401 deletions
@@ -22,6 +22,8 @@ namespace AibisDream.EditorTests.Huoshan
{
private const string CatalogPath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionContentCatalog.asset";
private const string ParticleProfilePath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset";
[TestCase(null, false)]
[TestCase("", false)]
@@ -208,6 +210,210 @@ namespace AibisDream.EditorTests.Huoshan
Is.False);
}
[Test]
public void ParticleLanguageProfile_ResolvesExactPrefixAndFallbackModes()
{
ExpressionParticleLanguageProfile profile =
AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
ParticleProfilePath);
Assert.That(profile, Is.Not.Null);
Assert.That(profile.GetValidationErrors(), Is.Empty);
Assert.That(
profile.Resolve(new LocaleIdentifier("zh-Hans")).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Grapheme));
Assert.That(
profile.Resolve(new LocaleIdentifier("ja-JP")).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Grapheme));
foreach (string localeCode in new[] { "en", "es", "ru", "pt-BR" })
{
Assert.That(
profile.Resolve(new LocaleIdentifier(localeCode)).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Word),
localeCode);
}
Assert.That(
profile.Resolve(new LocaleIdentifier("en-US")).LocaleCode,
Is.EqualTo("en"));
LogAssert.Expect(
LogType.Warning,
new Regex(@"Locale 'zz-ZZ'.*Grapheme"));
Assert.That(
profile.Resolve(new LocaleIdentifier("zz-ZZ")).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Grapheme));
}
[Test]
public void WordTokenizer_UsesWhitespaceBoundariesAndKeepsPunctuation()
{
Assert.That(
ExpressionTextTokenizer.TokenizeText(
"I am not a joke",
ExpressionParticleUnitMode.Word),
Is.EqualTo(new[] { "I", "am", "not", "a", "joke" }));
Assert.That(
ExpressionTextTokenizer.TokenizeText(
"don't self-doubt ¿Por qué? веришь?",
ExpressionParticleUnitMode.Word),
Is.EqualTo(new[]
{
"don't",
"self-doubt",
"¿Por",
"qué?",
"веришь?"
}));
Assert.That(
ExpressionTextTokenizer.TokenizeText(
"one \t\r\n two",
ExpressionParticleUnitMode.Word),
Is.EqualTo(new[] { "one", "two" }));
}
[Test]
public void GraphemeTokenizer_PreservesCjkKanaAndCombiningCharacters()
{
const string combining = "e\u0301";
Assert.That(
ExpressionTextTokenizer.TokenizeText(
$"汉 あ {combining}",
ExpressionParticleUnitMode.Grapheme),
Is.EqualTo(new[] { "汉", "あ", combining }));
}
[Test]
public void WordPool_PreservesDuplicateWeightsAndRejectsPipeSeparators()
{
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
"noise doubt noise",
ExpressionParticleUnitMode.Word,
out List<string> pool),
Is.True);
Assert.That(pool, Is.EqualTo(new[] { "noise", "doubt", "noise" }));
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
"noise|doubt",
ExpressionParticleUnitMode.Word,
out _),
Is.False);
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
"noisedoubt",
ExpressionParticleUnitMode.Word,
out _),
Is.False);
}
[Test]
public void RoundSnapshot_IsParsedOnceAndKeepsItsLocaleAndWordUnits()
{
ExpressionParticleLanguageProfile languageProfile =
AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
ParticleProfilePath);
ExpressionParticleLocaleSettings english =
languageProfile.Resolve(new LocaleIdentifier("en"));
Assert.That(
ExpressionRoundTextSnapshot.TryCreate(
new LocaleIdentifier("en"),
english,
"I am not a joke",
new[] { "false alarm", "false alarm" },
"noise doubt noise",
out ExpressionRoundTextSnapshot snapshot,
out string error),
Is.True,
error);
Locale previous = LocalizationSettings.SelectedLocale;
try
{
LocalizationSettings.SelectedLocale =
LocalizationEditorSettings.GetLocale(
new LocaleIdentifier("zh-Hans"));
Assert.That(snapshot.Locale.Code, Is.EqualTo("en"));
Assert.That(
snapshot.TargetUnits,
Is.EqualTo(new[] { "I", "am", "not", "a", "joke" }));
Assert.That(
snapshot.InterferencePoolUnits,
Is.EqualTo(new[] { "false", "alarm", "false", "alarm" }));
Assert.That(
snapshot.DefaultPoolUnits,
Is.EqualTo(new[] { "noise", "doubt", "noise" }));
}
finally
{
LocalizationSettings.SelectedLocale = previous;
}
}
[Test]
public void WordProfile_ScalesConfiguredNonTargetCounts()
{
ExpressionParticleLanguageProfile profile =
AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
ParticleProfilePath);
ExpressionParticleLocaleSettings english =
profile.Resolve(new LocaleIdentifier("en"));
Assert.That(english.ScaleNonTargetCount(16), Is.EqualTo(10));
Assert.That(english.ScaleNonTargetCount(28), Is.EqualTo(18));
Assert.That(english.ScaleNonTargetCount(38), Is.EqualTo(25));
}
[Test]
public void ParticleGeometry_UsesVisualEdgeDistanceAndShortestSeparationAxis()
{
var left = new Bounds(Vector3.zero, new Vector3(4f, 1f, 0.01f));
var right = new Bounds(
new Vector3(4.15f, 0f, 0f),
new Vector3(4f, 1f, 0.01f));
Assert.That(
ExpressionParticleGeometry.BoundsDistance(left, right),
Is.EqualTo(0.15f).Within(0.0001f));
right.center = new Vector3(3.8f, 0f, 0f);
Assert.That(
ExpressionParticleGeometry.TryGetSeparation(
left,
right,
0.1f,
out Vector2 direction,
out float overlap),
Is.True);
Assert.That(direction, Is.EqualTo(Vector2.left));
Assert.That(overlap, Is.EqualTo(0.3f).Within(0.0001f));
}
[Test]
public void TextParticle_VisualBoundsIncludesTheWholeWordForPointerDistance()
{
var gameObject = new GameObject("TextParticleBoundsTest");
try
{
CandidateParticle particle =
gameObject.AddComponent<CandidateParticle>();
particle.ForceSetUnitText("self-doubt");
Bounds bounds = particle.GetVisualWorldBounds();
Assert.That(bounds.size.x, Is.GreaterThan(0f));
Assert.That(
particle.DistanceToVisualBounds(
new Vector2(bounds.max.x, bounds.center.y)),
Is.EqualTo(0f).Within(0.0001f));
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void Params_ContainsLocalizedExpressPoolForEveryLocale()
{
@@ -250,28 +456,28 @@ namespace AibisDream.EditorTests.Huoshan
try
{
CandidateParticle particle = gameObject.AddComponent<CandidateParticle>();
Assert.That(particle.currentChar, Is.Empty);
Assert.That(particle.CurrentUnitText, Is.Null.Or.Empty);
string combining = "e\u0301";
particle.SetCharacterPools(
particle.SetUnitPools(
new[] { "😀" },
new[] { combining });
particle.originalIsRed = true;
particle.InitializeRandomCharacter();
Assert.That(particle.currentChar, Is.EqualTo("😀"));
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo("😀"));
particle.originalIsRed = false;
particle.InitializeRandomCharacter();
Assert.That(particle.currentChar, Is.EqualTo(combining));
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo(combining));
particle.SetOverrideCharacterPool(new[] { "。" });
particle.InitializeRandomCharacter();
Assert.That(particle.currentChar, Is.EqualTo("。"));
particle.SetOverrideUnitPool(new[] { "。" });
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo("。"));
particle.SetOverrideCharacterPool(null);
particle.InitializeRandomCharacter();
Assert.That(particle.currentChar, Is.EqualTo(combining));
particle.SetOverrideUnitPool((IReadOnlyList<string>)null);
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo(combining));
}
finally
{
@@ -43,6 +43,18 @@ namespace AibisDream.EditorTests.Huoshan
private const string Stage6Path = "Assets/Yarn/FP/FP_Huoshan1/Stage6.yarn";
private const string CatalogPath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionContentCatalog.asset";
private const string ParticleProfilePath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset";
private static readonly string[] SupportedLocaleCodes =
{
"zh-Hans",
"en",
"ja-JP",
"es",
"ru",
"pt-BR"
};
private static readonly Regex YarnCommandRegex =
new(@"^\s*<<(?<command>[A-Za-z0-9_]+)(?<arguments>.*?)>>", RegexOptions.Compiled);
@@ -110,10 +122,66 @@ namespace AibisDream.EditorTests.Huoshan
ValidateCatalog(issues, referencedKeys);
referencedKeys.Add(
LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam));
ValidateParams(issues, referencedKeys);
ExpressionParticleLanguageProfile particleProfile =
ValidateParticleProfile(issues);
ValidateParams(issues, referencedKeys, particleProfile);
return issues;
}
private static ExpressionParticleLanguageProfile ValidateParticleProfile(
List<ExpressionLocalizationIssue> issues)
{
ExpressionParticleLanguageProfile profile =
AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
ParticleProfilePath);
if (profile == null)
{
AddError(issues, $"找不到粒子语言 Profile{ParticleProfilePath}");
return null;
}
foreach (string validationError in profile.GetValidationErrors())
AddError(issues, validationError);
foreach (string localeCode in SupportedLocaleCodes)
{
bool canResolve = profile.Locales.Any(settings =>
{
if (settings == null ||
string.IsNullOrWhiteSpace(settings.LocaleCode))
{
return false;
}
if (string.Equals(
settings.LocaleCode,
localeCode,
StringComparison.OrdinalIgnoreCase))
{
return true;
}
string requestedLanguage =
localeCode.Split('-')[0];
string configuredLanguage =
settings.LocaleCode.Split('-')[0];
return string.Equals(
requestedLanguage,
configuredLanguage,
StringComparison.OrdinalIgnoreCase);
});
if (!canResolve)
{
AddError(
issues,
$"粒子语言 Profile 无法解析支持的 Locale{localeCode}");
}
}
return profile;
}
private static void ValidateStage6(
List<ExpressionLocalizationIssue> issues,
HashSet<string> referencedKeys)
@@ -190,7 +258,8 @@ namespace AibisDream.EditorTests.Huoshan
private static void ValidateParams(
List<ExpressionLocalizationIssue> issues,
HashSet<string> referencedKeys)
HashSet<string> referencedKeys,
ExpressionParticleLanguageProfile particleProfile)
{
StringTableCollection collection =
LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable);
@@ -200,18 +269,10 @@ namespace AibisDream.EditorTests.Huoshan
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;
StringTable spanish =
collection.GetTable(new LocaleIdentifier("es")) as StringTable;
StringTable russian =
collection.GetTable(new LocaleIdentifier("ru")) as StringTable;
StringTable portuguese =
collection.GetTable(new LocaleIdentifier("pt-BR")) as StringTable;
var tables = SupportedLocaleCodes.ToDictionary(
code => code,
code => collection.GetTable(new LocaleIdentifier(code)) as StringTable,
StringComparer.OrdinalIgnoreCase);
string poolKey =
LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam);
@@ -223,128 +284,164 @@ namespace AibisDream.EditorTests.Huoshan
continue;
}
StringTableEntry chineseEntry = chinese?.GetEntry(key);
StringTableEntry chineseEntry = tables["zh-Hans"]?.GetEntry(key);
if (string.IsNullOrWhiteSpace(chineseEntry?.LocalizedValue))
{
AddError(issues, $"Params 中文值为空:{key}");
continue;
}
if (key.EndsWith(".tokens", StringComparison.Ordinal))
foreach (string localeCode in SupportedLocaleCodes)
{
ValidatePipeList(issues, key, chineseEntry.LocalizedValue);
}
StringTable table = tables[localeCode];
StringTableEntry entry = table?.GetEntry(key);
if (entry == null)
{
if (string.Equals(
localeCode,
"zh-Hans",
StringComparison.Ordinal))
{
AddError(
issues,
$"Params/{key} 缺少 {localeCode} 条目。");
}
else
{
issues.Add(new ExpressionLocalizationIssue(
ExpressionLocalizationIssueSeverity.Warning,
$"待翻译:Params/{key}, Locale={localeCode}(缺少条目)"));
}
continue;
}
if (string.Equals(key, poolKey, StringComparison.Ordinal))
{
ValidateCharacterPool(
issues,
key,
"zh-Hans",
chineseEntry.LocalizedValue);
AddPendingPoolTranslation(issues, english, "en", key);
AddPendingPoolTranslation(issues, japanese, "ja-JP", key);
AddPendingPoolTranslation(issues, spanish, "es", key);
AddPendingPoolTranslation(issues, russian, "ru", key);
AddPendingPoolTranslation(issues, portuguese, "pt-BR", key);
}
else
{
AddPendingTranslation(issues, english, "en", key);
AddPendingTranslation(issues, japanese, "ja-JP", key);
if (string.IsNullOrWhiteSpace(entry.LocalizedValue))
{
if (string.Equals(localeCode, "zh-Hans", StringComparison.Ordinal))
continue;
issues.Add(new ExpressionLocalizationIssue(
ExpressionLocalizationIssueSeverity.Warning,
$"待翻译:Params/{key}, Locale={localeCode}"));
continue;
}
ExpressionParticleLocaleSettings settings =
particleProfile != null
? particleProfile.Resolve(new LocaleIdentifier(localeCode))
: ExpressionParticleLocaleSettings.CreateFallback();
if (string.Equals(key, poolKey, StringComparison.Ordinal))
{
ValidateParticlePool(
issues,
key,
localeCode,
entry.LocalizedValue,
settings.UnitMode);
}
else if (key.EndsWith(".tokens", StringComparison.Ordinal))
{
ValidatePipeList(
issues,
key,
localeCode,
entry.LocalizedValue,
settings.UnitMode);
}
else
{
ValidateVisibleText(
issues,
key,
localeCode,
entry.LocalizedValue,
settings.UnitMode);
}
}
}
}
private static void ValidateCharacterPool(
private static void ValidateVisibleText(
List<ExpressionLocalizationIssue> issues,
string key,
string localeCode,
string value)
string value,
ExpressionParticleUnitMode mode)
{
if (ExpressionTextTokenizer.TokenizeText(value, mode).Count == 0)
{
AddError(
issues,
$"Params/{key}, Locale={localeCode} 没有有效的粒子单位。");
}
}
private static void ValidateParticlePool(
List<ExpressionLocalizationIssue> issues,
string key,
string localeCode,
string value,
ExpressionParticleUnitMode mode)
{
if (value.Contains('|') || value.Contains(''))
{
AddError(
issues,
$"Params/{key}, Locale={localeCode} 的字符池不能包含“|”或“|”。");
$"Params/{key}, Locale={localeCode} 的粒子池不能包含“|”或“|”。");
return;
}
if (!LanguageYarnCommand.TryParseExpressionPool(value, out _))
if (!LanguageYarnCommand.TryParseExpressionPool(value, mode, out _))
{
string expected =
mode == ExpressionParticleUnitMode.Word
? "空白分隔单词"
: "Unicode 文本元素";
AddError(
issues,
$"Params/{key}, Locale={localeCode} 没有有效的 Unicode 文本元素。");
$"Params/{key}, Locale={localeCode} 没有有效的{expected}。");
}
}
private static void ValidatePipeList(
List<ExpressionLocalizationIssue> issues,
string key,
string value)
string localeCode,
string value,
ExpressionParticleUnitMode mode)
{
if (value.Contains(''))
{
AddError(issues, $"Params/{key} 使用了全角分隔符“|”,必须使用半角“|”。");
AddError(
issues,
$"Params/{key}, Locale={localeCode} 使用了全角分隔符“|”,必须使用半角“|”。");
return;
}
bool hasToken = value
string[] phrases = 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)
.Where(token => !string.IsNullOrWhiteSpace(token))
.ToArray();
if (phrases.Length == 0)
{
AddError(issues, $"Params/{key} 缺少 {localeCode} 条目。");
AddError(
issues,
$"Params/{key}, Locale={localeCode} 没有有效项。");
return;
}
if (string.IsNullOrEmpty(entry.LocalizedValue))
foreach (string phrase in phrases)
{
issues.Add(new ExpressionLocalizationIssue(
ExpressionLocalizationIssueSeverity.Warning,
$"待翻译:Params/{key}, Locale={localeCode}"));
if (ExpressionTextTokenizer.TokenizeText(phrase, mode).Count == 0)
{
AddError(
issues,
$"Params/{key}, Locale={localeCode} 包含没有有效粒子单位的短句。");
return;
}
}
}
private static void AddPendingPoolTranslation(
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}"));
return;
}
ValidateCharacterPool(
issues,
key,
localeCode,
entry.LocalizedValue);
}
private static void AddCatalogReference(
string reference,
HashSet<string> referencedKeys)