Files
aibis-dream/Assets/Editor/Huoshan/ExpressionLocalizationValidator.cs

463 lines
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}
}