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,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));
}
}
}