463 lines
17 KiB
C#
463 lines
17 KiB
C#
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 的引用关系。
|
||
/// 未翻译 Locale 的空值是待翻译警告,不阻断内容校验。
|
||
/// </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 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);
|
||
|
||
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_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);
|
||
referencedKeys.Add(
|
||
LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam));
|
||
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)
|
||
{
|
||
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);
|
||
|
||
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,
|
||
ExpressionParticleLanguageProfile particleProfile)
|
||
{
|
||
StringTableCollection collection =
|
||
LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable);
|
||
if (collection == null)
|
||
{
|
||
AddError(issues, $"找不到 String Table Collection:{ConstRef.ParamsTable}");
|
||
return;
|
||
}
|
||
|
||
var tables = SupportedLocaleCodes.ToDictionary(
|
||
code => code,
|
||
code => collection.GetTable(new LocaleIdentifier(code)) as StringTable,
|
||
StringComparer.OrdinalIgnoreCase);
|
||
string poolKey =
|
||
LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam);
|
||
|
||
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 = tables["zh-Hans"]?.GetEntry(key);
|
||
if (string.IsNullOrWhiteSpace(chineseEntry?.LocalizedValue))
|
||
{
|
||
AddError(issues, $"Params 中文值为空:{key}");
|
||
}
|
||
|
||
foreach (string localeCode in SupportedLocaleCodes)
|
||
{
|
||
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.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 ValidateVisibleText(
|
||
List<ExpressionLocalizationIssue> issues,
|
||
string key,
|
||
string localeCode,
|
||
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} 的粒子池不能包含“|”或“|”。");
|
||
return;
|
||
}
|
||
|
||
if (!LanguageYarnCommand.TryParseExpressionPool(value, mode, out _))
|
||
{
|
||
string expected =
|
||
mode == ExpressionParticleUnitMode.Word
|
||
? "空白分隔单词"
|
||
: "Unicode 文本元素";
|
||
AddError(
|
||
issues,
|
||
$"Params/{key}, Locale={localeCode} 没有有效的{expected}。");
|
||
}
|
||
}
|
||
|
||
private static void ValidatePipeList(
|
||
List<ExpressionLocalizationIssue> issues,
|
||
string key,
|
||
string localeCode,
|
||
string value,
|
||
ExpressionParticleUnitMode mode)
|
||
{
|
||
if (value.Contains('|'))
|
||
{
|
||
AddError(
|
||
issues,
|
||
$"Params/{key}, Locale={localeCode} 使用了全角分隔符“|”,必须使用半角“|”。");
|
||
return;
|
||
}
|
||
|
||
string[] phrases = value
|
||
.Split('|')
|
||
.Where(token => !string.IsNullOrWhiteSpace(token))
|
||
.ToArray();
|
||
if (phrases.Length == 0)
|
||
{
|
||
AddError(
|
||
issues,
|
||
$"Params/{key}, Locale={localeCode} 没有有效项。");
|
||
return;
|
||
}
|
||
|
||
foreach (string phrase in phrases)
|
||
{
|
||
if (ExpressionTextTokenizer.TokenizeText(phrase, mode).Count == 0)
|
||
{
|
||
AddError(
|
||
issues,
|
||
$"Params/{key}, Locale={localeCode} 包含没有有效粒子单位的短句。");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|
||
}
|