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; } } /// /// 校验 Stage6 Express 命令、轮次 Catalog 与 Params String Table 的引用关系。 /// 英日空值是待翻译警告,不阻断内容校验。 /// 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*<<(?[A-Za-z0-9_]+)(?.*?)>>", RegexOptions.Compiled); private static readonly Regex QuotedArgumentRegex = new("\"(?(?:\\\\.|[^\"])*)\"", RegexOptions.Compiled); private static readonly Regex L10NRegex = new(@"l10n\.(?[A-Za-z0-9._-]+)", RegexOptions.Compiled); private static readonly Regex ChineseRegex = new(@"[\u3400-\u9FFF]", RegexOptions.Compiled); private static readonly Dictionary 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 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 Validate() { var issues = new List(); var referencedKeys = new HashSet(StringComparer.Ordinal); ValidateStage6(issues, referencedKeys); ValidateCatalog(issues, referencedKeys); ValidateParams(issues, referencedKeys); return issues; } private static void ValidateStage6( List issues, HashSet 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() .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 issues, HashSet referencedKeys) { ExpressionContentCatalog catalog = AssetDatabase.LoadAssetAtPath(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 issues, HashSet 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 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 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 referencedKeys) { if (LocalizationKit.IsLocalizedParam(reference)) referencedKeys.Add(LocalizationKit.GetL10NParamKey(reference)); } private static void AddError( List issues, string message) { issues.Add(new ExpressionLocalizationIssue( ExpressionLocalizationIssueSeverity.Error, message)); } } }