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 的引用关系。 /// 未翻译 Locale 的空值是待翻译警告,不阻断内容校验。 /// 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*<<(?[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_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); referencedKeys.Add( LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam)); ExpressionParticleLanguageProfile particleProfile = ValidateParticleProfile(issues); ValidateParams(issues, referencedKeys, particleProfile); return issues; } private static ExpressionParticleLanguageProfile ValidateParticleProfile( List issues) { ExpressionParticleLanguageProfile profile = AssetDatabase.LoadAssetAtPath( 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 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); 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, 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 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 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 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 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)); } } }