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
@@ -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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d6b8797332ac4876b66e381ea3c98723
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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: