diff --git a/Assets/Editor/Huoshan/ExpressionLocalizationTests.cs b/Assets/Editor/Huoshan/ExpressionLocalizationTests.cs index 529e06503..2d6d0029a 100644 --- a/Assets/Editor/Huoshan/ExpressionLocalizationTests.cs +++ b/Assets/Editor/Huoshan/ExpressionLocalizationTests.cs @@ -22,6 +22,8 @@ namespace AibisDream.EditorTests.Huoshan { private const string CatalogPath = "Assets/GameContent/Feature_Huoshan/Expression/ExpressionContentCatalog.asset"; + private const string ParticleProfilePath = + "Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset"; [TestCase(null, false)] [TestCase("", false)] @@ -208,6 +210,210 @@ namespace AibisDream.EditorTests.Huoshan Is.False); } + [Test] + public void ParticleLanguageProfile_ResolvesExactPrefixAndFallbackModes() + { + ExpressionParticleLanguageProfile profile = + AssetDatabase.LoadAssetAtPath( + ParticleProfilePath); + Assert.That(profile, Is.Not.Null); + Assert.That(profile.GetValidationErrors(), Is.Empty); + + Assert.That( + profile.Resolve(new LocaleIdentifier("zh-Hans")).UnitMode, + Is.EqualTo(ExpressionParticleUnitMode.Grapheme)); + Assert.That( + profile.Resolve(new LocaleIdentifier("ja-JP")).UnitMode, + Is.EqualTo(ExpressionParticleUnitMode.Grapheme)); + + foreach (string localeCode in new[] { "en", "es", "ru", "pt-BR" }) + { + Assert.That( + profile.Resolve(new LocaleIdentifier(localeCode)).UnitMode, + Is.EqualTo(ExpressionParticleUnitMode.Word), + localeCode); + } + + Assert.That( + profile.Resolve(new LocaleIdentifier("en-US")).LocaleCode, + Is.EqualTo("en")); + + LogAssert.Expect( + LogType.Warning, + new Regex(@"Locale 'zz-ZZ'.*Grapheme")); + Assert.That( + profile.Resolve(new LocaleIdentifier("zz-ZZ")).UnitMode, + Is.EqualTo(ExpressionParticleUnitMode.Grapheme)); + } + + [Test] + public void WordTokenizer_UsesWhitespaceBoundariesAndKeepsPunctuation() + { + Assert.That( + ExpressionTextTokenizer.TokenizeText( + "I am not a joke", + ExpressionParticleUnitMode.Word), + Is.EqualTo(new[] { "I", "am", "not", "a", "joke" })); + Assert.That( + ExpressionTextTokenizer.TokenizeText( + "don't self-doubt ¿Por qué? веришь?", + ExpressionParticleUnitMode.Word), + Is.EqualTo(new[] + { + "don't", + "self-doubt", + "¿Por", + "qué?", + "веришь?" + })); + Assert.That( + ExpressionTextTokenizer.TokenizeText( + "one \t\r\n two", + ExpressionParticleUnitMode.Word), + Is.EqualTo(new[] { "one", "two" })); + } + + [Test] + public void GraphemeTokenizer_PreservesCjkKanaAndCombiningCharacters() + { + const string combining = "e\u0301"; + Assert.That( + ExpressionTextTokenizer.TokenizeText( + $"汉 あ {combining}", + ExpressionParticleUnitMode.Grapheme), + Is.EqualTo(new[] { "汉", "あ", combining })); + } + + [Test] + public void WordPool_PreservesDuplicateWeightsAndRejectsPipeSeparators() + { + Assert.That( + LanguageYarnCommand.TryParseExpressionPool( + "noise doubt noise", + ExpressionParticleUnitMode.Word, + out List pool), + Is.True); + Assert.That(pool, Is.EqualTo(new[] { "noise", "doubt", "noise" })); + Assert.That( + LanguageYarnCommand.TryParseExpressionPool( + "noise|doubt", + ExpressionParticleUnitMode.Word, + out _), + Is.False); + Assert.That( + LanguageYarnCommand.TryParseExpressionPool( + "noise|doubt", + ExpressionParticleUnitMode.Word, + out _), + Is.False); + } + + [Test] + public void RoundSnapshot_IsParsedOnceAndKeepsItsLocaleAndWordUnits() + { + ExpressionParticleLanguageProfile languageProfile = + AssetDatabase.LoadAssetAtPath( + ParticleProfilePath); + ExpressionParticleLocaleSettings english = + languageProfile.Resolve(new LocaleIdentifier("en")); + Assert.That( + ExpressionRoundTextSnapshot.TryCreate( + new LocaleIdentifier("en"), + english, + "I am not a joke", + new[] { "false alarm", "false alarm" }, + "noise doubt noise", + out ExpressionRoundTextSnapshot snapshot, + out string error), + Is.True, + error); + + Locale previous = LocalizationSettings.SelectedLocale; + try + { + LocalizationSettings.SelectedLocale = + LocalizationEditorSettings.GetLocale( + new LocaleIdentifier("zh-Hans")); + + Assert.That(snapshot.Locale.Code, Is.EqualTo("en")); + Assert.That( + snapshot.TargetUnits, + Is.EqualTo(new[] { "I", "am", "not", "a", "joke" })); + Assert.That( + snapshot.InterferencePoolUnits, + Is.EqualTo(new[] { "false", "alarm", "false", "alarm" })); + Assert.That( + snapshot.DefaultPoolUnits, + Is.EqualTo(new[] { "noise", "doubt", "noise" })); + } + finally + { + LocalizationSettings.SelectedLocale = previous; + } + } + + [Test] + public void WordProfile_ScalesConfiguredNonTargetCounts() + { + ExpressionParticleLanguageProfile profile = + AssetDatabase.LoadAssetAtPath( + ParticleProfilePath); + ExpressionParticleLocaleSettings english = + profile.Resolve(new LocaleIdentifier("en")); + + Assert.That(english.ScaleNonTargetCount(16), Is.EqualTo(10)); + Assert.That(english.ScaleNonTargetCount(28), Is.EqualTo(18)); + Assert.That(english.ScaleNonTargetCount(38), Is.EqualTo(25)); + } + + [Test] + public void ParticleGeometry_UsesVisualEdgeDistanceAndShortestSeparationAxis() + { + var left = new Bounds(Vector3.zero, new Vector3(4f, 1f, 0.01f)); + var right = new Bounds( + new Vector3(4.15f, 0f, 0f), + new Vector3(4f, 1f, 0.01f)); + + Assert.That( + ExpressionParticleGeometry.BoundsDistance(left, right), + Is.EqualTo(0.15f).Within(0.0001f)); + + right.center = new Vector3(3.8f, 0f, 0f); + Assert.That( + ExpressionParticleGeometry.TryGetSeparation( + left, + right, + 0.1f, + out Vector2 direction, + out float overlap), + Is.True); + Assert.That(direction, Is.EqualTo(Vector2.left)); + Assert.That(overlap, Is.EqualTo(0.3f).Within(0.0001f)); + } + + [Test] + public void TextParticle_VisualBoundsIncludesTheWholeWordForPointerDistance() + { + var gameObject = new GameObject("TextParticleBoundsTest"); + try + { + CandidateParticle particle = + gameObject.AddComponent(); + particle.ForceSetUnitText("self-doubt"); + Bounds bounds = particle.GetVisualWorldBounds(); + + Assert.That(bounds.size.x, Is.GreaterThan(0f)); + Assert.That( + particle.DistanceToVisualBounds( + new Vector2(bounds.max.x, bounds.center.y)), + Is.EqualTo(0f).Within(0.0001f)); + } + finally + { + Object.DestroyImmediate(gameObject); + } + } + [Test] public void Params_ContainsLocalizedExpressPoolForEveryLocale() { @@ -250,28 +456,28 @@ namespace AibisDream.EditorTests.Huoshan try { CandidateParticle particle = gameObject.AddComponent(); - Assert.That(particle.currentChar, Is.Empty); + Assert.That(particle.CurrentUnitText, Is.Null.Or.Empty); string combining = "e\u0301"; - particle.SetCharacterPools( + particle.SetUnitPools( new[] { "😀" }, new[] { combining }); particle.originalIsRed = true; - particle.InitializeRandomCharacter(); - Assert.That(particle.currentChar, Is.EqualTo("😀")); + particle.InitializeRandomUnit(); + Assert.That(particle.CurrentUnitText, Is.EqualTo("😀")); particle.originalIsRed = false; - particle.InitializeRandomCharacter(); - Assert.That(particle.currentChar, Is.EqualTo(combining)); + particle.InitializeRandomUnit(); + Assert.That(particle.CurrentUnitText, Is.EqualTo(combining)); - particle.SetOverrideCharacterPool(new[] { "。" }); - particle.InitializeRandomCharacter(); - Assert.That(particle.currentChar, Is.EqualTo("。")); + particle.SetOverrideUnitPool(new[] { "。" }); + particle.InitializeRandomUnit(); + Assert.That(particle.CurrentUnitText, Is.EqualTo("。")); - particle.SetOverrideCharacterPool(null); - particle.InitializeRandomCharacter(); - Assert.That(particle.currentChar, Is.EqualTo(combining)); + particle.SetOverrideUnitPool((IReadOnlyList)null); + particle.InitializeRandomUnit(); + Assert.That(particle.CurrentUnitText, Is.EqualTo(combining)); } finally { diff --git a/Assets/Editor/Huoshan/ExpressionLocalizationValidator.cs b/Assets/Editor/Huoshan/ExpressionLocalizationValidator.cs index 9c657f7e0..7e4ee7283 100644 --- a/Assets/Editor/Huoshan/ExpressionLocalizationValidator.cs +++ b/Assets/Editor/Huoshan/ExpressionLocalizationValidator.cs @@ -43,6 +43,18 @@ namespace AibisDream.EditorTests.Huoshan 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); @@ -110,10 +122,66 @@ namespace AibisDream.EditorTests.Huoshan ValidateCatalog(issues, referencedKeys); referencedKeys.Add( LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam)); - ValidateParams(issues, referencedKeys); + 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) @@ -190,7 +258,8 @@ namespace AibisDream.EditorTests.Huoshan private static void ValidateParams( List issues, - HashSet referencedKeys) + HashSet referencedKeys, + ExpressionParticleLanguageProfile particleProfile) { StringTableCollection collection = LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable); @@ -200,18 +269,10 @@ namespace AibisDream.EditorTests.Huoshan 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; - StringTable spanish = - collection.GetTable(new LocaleIdentifier("es")) as StringTable; - StringTable russian = - collection.GetTable(new LocaleIdentifier("ru")) as StringTable; - StringTable portuguese = - collection.GetTable(new LocaleIdentifier("pt-BR")) as StringTable; + var tables = SupportedLocaleCodes.ToDictionary( + code => code, + code => collection.GetTable(new LocaleIdentifier(code)) as StringTable, + StringComparer.OrdinalIgnoreCase); string poolKey = LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam); @@ -223,128 +284,164 @@ namespace AibisDream.EditorTests.Huoshan continue; } - StringTableEntry chineseEntry = chinese?.GetEntry(key); + StringTableEntry chineseEntry = tables["zh-Hans"]?.GetEntry(key); if (string.IsNullOrWhiteSpace(chineseEntry?.LocalizedValue)) { AddError(issues, $"Params 中文值为空:{key}"); - continue; } - if (key.EndsWith(".tokens", StringComparison.Ordinal)) + foreach (string localeCode in SupportedLocaleCodes) { - ValidatePipeList(issues, key, chineseEntry.LocalizedValue); - } + 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.Equals(key, poolKey, StringComparison.Ordinal)) - { - ValidateCharacterPool( - issues, - key, - "zh-Hans", - chineseEntry.LocalizedValue); - AddPendingPoolTranslation(issues, english, "en", key); - AddPendingPoolTranslation(issues, japanese, "ja-JP", key); - AddPendingPoolTranslation(issues, spanish, "es", key); - AddPendingPoolTranslation(issues, russian, "ru", key); - AddPendingPoolTranslation(issues, portuguese, "pt-BR", key); - } - else - { - AddPendingTranslation(issues, english, "en", key); - AddPendingTranslation(issues, japanese, "ja-JP", key); + 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 ValidateCharacterPool( + private static void ValidateVisibleText( List issues, string key, string localeCode, - string value) + 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} 的字符池不能包含“|”或“|”。"); + $"Params/{key}, Locale={localeCode} 的粒子池不能包含“|”或“|”。"); return; } - if (!LanguageYarnCommand.TryParseExpressionPool(value, out _)) + if (!LanguageYarnCommand.TryParseExpressionPool(value, mode, out _)) { + string expected = + mode == ExpressionParticleUnitMode.Word + ? "空白分隔单词" + : "Unicode 文本元素"; AddError( issues, - $"Params/{key}, Locale={localeCode} 没有有效的 Unicode 文本元素。"); + $"Params/{key}, Locale={localeCode} 没有有效的{expected}。"); } } private static void ValidatePipeList( List issues, string key, - string value) + string localeCode, + string value, + ExpressionParticleUnitMode mode) { if (value.Contains('|')) { - AddError(issues, $"Params/{key} 使用了全角分隔符“|”,必须使用半角“|”。"); + AddError( + issues, + $"Params/{key}, Locale={localeCode} 使用了全角分隔符“|”,必须使用半角“|”。"); return; } - bool hasToken = value + string[] phrases = 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) + .Where(token => !string.IsNullOrWhiteSpace(token)) + .ToArray(); + if (phrases.Length == 0) { - AddError(issues, $"Params/{key} 缺少 {localeCode} 条目。"); + AddError( + issues, + $"Params/{key}, Locale={localeCode} 没有有效项。"); return; } - if (string.IsNullOrEmpty(entry.LocalizedValue)) + foreach (string phrase in phrases) { - issues.Add(new ExpressionLocalizationIssue( - ExpressionLocalizationIssueSeverity.Warning, - $"待翻译:Params/{key}, Locale={localeCode}")); + if (ExpressionTextTokenizer.TokenizeText(phrase, mode).Count == 0) + { + AddError( + issues, + $"Params/{key}, Locale={localeCode} 包含没有有效粒子单位的短句。"); + return; + } } } - private static void AddPendingPoolTranslation( - 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}")); - return; - } - - ValidateCharacterPool( - issues, - key, - localeCode, - entry.LocalizedValue); - } - private static void AddCatalogReference( string reference, HashSet referencedKeys) diff --git a/Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset b/Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset new file mode 100644 index 000000000..0c137ad5d --- /dev/null +++ b/Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset @@ -0,0 +1,69 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3a7f6c18f2184d09b551e103c67268a1, type: 3} + m_Name: ExpressionParticleLanguageProfile + m_EditorClassIdentifier: + locales: + - localeCode: zh-Hans + unitMode: 0 + targetFontScale: 1 + nonTargetFontScale: 1 + floatingFontScale: 1 + nonTargetCountScale: 1 + wordSpacing: 0.18 + visualEdgeConnectionDistance: 0 + minimumReadablePhraseScale: 0.65 + - localeCode: ja-JP + unitMode: 0 + targetFontScale: 1 + nonTargetFontScale: 1 + floatingFontScale: 1 + nonTargetCountScale: 1 + wordSpacing: 0.18 + visualEdgeConnectionDistance: 0 + minimumReadablePhraseScale: 0.65 + - localeCode: en + unitMode: 1 + targetFontScale: 0.65 + nonTargetFontScale: 0.58 + floatingFontScale: 0.58 + nonTargetCountScale: 0.65 + wordSpacing: 0.18 + visualEdgeConnectionDistance: 0.18 + minimumReadablePhraseScale: 0.65 + - localeCode: es + unitMode: 1 + targetFontScale: 0.65 + nonTargetFontScale: 0.58 + floatingFontScale: 0.58 + nonTargetCountScale: 0.65 + wordSpacing: 0.18 + visualEdgeConnectionDistance: 0.18 + minimumReadablePhraseScale: 0.65 + - localeCode: ru + unitMode: 1 + targetFontScale: 0.65 + nonTargetFontScale: 0.58 + floatingFontScale: 0.58 + nonTargetCountScale: 0.65 + wordSpacing: 0.18 + visualEdgeConnectionDistance: 0.18 + minimumReadablePhraseScale: 0.65 + - localeCode: pt-BR + unitMode: 1 + targetFontScale: 0.65 + nonTargetFontScale: 0.58 + floatingFontScale: 0.58 + nonTargetCountScale: 0.65 + wordSpacing: 0.18 + visualEdgeConnectionDistance: 0.18 + minimumReadablePhraseScale: 0.65 diff --git a/Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset.meta b/Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset.meta new file mode 100644 index 000000000..c759bc53f --- /dev/null +++ b/Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 57d812538c3e4bddafe59f8813562f46 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scenes/HuoShanFixScene.unity b/Assets/Scenes/HuoShanFixScene.unity index c500f550b..824dbb5f6 100644 --- a/Assets/Scenes/HuoShanFixScene.unity +++ b/Assets/Scenes/HuoShanFixScene.unity @@ -16128,6 +16128,7 @@ MonoBehaviour: logReleasePresentation: {fileID: 2138889543} screenPresentation: {fileID: 2138889544} contentCatalog: {fileID: 11400000, guid: b2d94952a18d4d178421691ab103f0fd, type: 2} + particleLanguageProfile: {fileID: 11400000, guid: 57d812538c3e4bddafe59f8813562f46, type: 2} screenOverlayRenderer: {fileID: 2063129674} volcanoOverlayRenderer: {fileID: 1944146357} spriteOverlayFadeInAlpha: 1 diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/CandidateParticle.cs b/Assets/Scripts/MiniGame/HuoShan/Language/CandidateParticle.cs index e5f3d6f7e..917558f7e 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/CandidateParticle.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/CandidateParticle.cs @@ -233,7 +233,7 @@ namespace AibisDream.MiniGame.Language /// 目标粒子:从本轮本地化默认池随机;非目标粒子:从笑话/干扰短句字符中随机。 /// 使用 IsTarget 判断,与红蓝颜色解绑。 /// - protected override string GetNextDisplayChar() + protected override string GetNextDisplayUnit() { if (TryGetOverridePoolChar(out string overrideChar)) return overrideChar; @@ -274,8 +274,7 @@ namespace AibisDream.MiniGame.Language if (changeTimer > 0f) return; - currentChar = GetNextDisplayChar(); - UpdateText(); + ForceSetUnitText(GetNextDisplayUnit()); changeTimer = Mathf.Max(0.02f, changeInterval); } @@ -431,6 +430,7 @@ namespace AibisDream.MiniGame.Language textMesh.fontSize = nonTargetFontSize; textMesh.fontStyle = nonTargetBold ? TMPro.FontStyles.Bold : TMPro.FontStyles.Normal; } + InvalidateVisualBounds(); } } } diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ConnectionRenderer.cs b/Assets/Scripts/MiniGame/HuoShan/Language/ConnectionRenderer.cs index 6a39933bf..c673be267 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/ConnectionRenderer.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ConnectionRenderer.cs @@ -133,15 +133,20 @@ namespace AibisDream.MiniGame.Language // 跳过红色粒子 if (p1.isRed && p2.isRed) continue; - float distance = Vector3.Distance(p1.transform.position, p2.transform.position); - if (distance < connectionDistance) - { - float alpha = Mathf.Lerp(0.7f, 0f, distance / connectionDistance) * fadeOutAlpha; - Color lineColor = nonTargetConnectionColor; - lineColor.a = alpha * nonTargetAlphaScale * nonTargetConnectionColor.a; + if (!p1.connections.Contains(p2)) + continue; - DrawClippedLine(p1.transform.position, p2.transform.position, lineColor); - } + ResolveConnectionVisual( + p1, + p2, + out Vector3 from, + out Vector3 to, + out float proximity); + float alpha = Mathf.Lerp(0f, 0.7f, proximity) * fadeOutAlpha; + Color lineColor = nonTargetConnectionColor; + lineColor.a = alpha * nonTargetAlphaScale * nonTargetConnectionColor.a; + + DrawClippedLine(from, to, lineColor); } } } @@ -162,23 +167,50 @@ namespace AibisDream.MiniGame.Language { for (int j = i + 1; j < redParticles.Count; j++) { - float distance = Vector3.Distance( - redParticles[i].transform.position, - redParticles[j].transform.position - ); + CandidateParticle first = redParticles[i]; + CandidateParticle second = redParticles[j]; + if (!first.connections.Contains(second)) + continue; - if (distance < connectionDistance) - { - float alpha = Mathf.Lerp(1f, 0.4f, distance / connectionDistance); - Color lineColor = targetConnectionColor; - lineColor.a = alpha * targetAlphaScale * targetConnectionColor.a; + ResolveConnectionVisual( + first, + second, + out Vector3 from, + out Vector3 to, + out float proximity); + float alpha = Mathf.Lerp(0.4f, 1f, proximity); + Color lineColor = targetConnectionColor; + lineColor.a = alpha * targetAlphaScale * targetConnectionColor.a; - DrawClippedLine(redParticles[i].transform.position, redParticles[j].transform.position, lineColor); - } + DrawClippedLine(from, to, lineColor); } } } + private void ResolveConnectionVisual( + CandidateParticle first, + CandidateParticle second, + out Vector3 from, + out Vector3 to, + out float proximity) + { + if (manager != null && + manager.TryGetConnectionVisual( + first, + second, + out from, + out to, + out proximity)) + { + return; + } + + from = first.transform.position; + to = second.transform.position; + float distance = Vector3.Distance(from, to); + proximity = 1f - Mathf.Clamp01(distance / Mathf.Max(0.0001f, connectionDistance)); + } + private void DrawPropagationEffects() { Draw.LineGeometry = LineGeometry.Flat2D; diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionManager.cs b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionManager.cs index 4951855a6..99d2eba51 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionManager.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionManager.cs @@ -19,6 +19,7 @@ namespace AibisDream [SerializeField] private LogReleasePresentationController logReleasePresentation; [SerializeField] private ExpressionScreenPresentationController screenPresentation; [SerializeField] private ExpressionContentCatalog contentCatalog; + [SerializeField] private ExpressionParticleLanguageProfile particleLanguageProfile; [Header("Sprite 淡入淡出")] [SerializeField] private SpriteRenderer screenOverlayRenderer; @@ -48,6 +49,8 @@ namespace AibisDream public LogReleasePresentationController LogReleasePresentation => logReleasePresentation; public ExpressionContentCatalog ContentCatalog => contentCatalog; + public ExpressionParticleLanguageProfile ParticleLanguageProfile => + particleLanguageProfile; public Locale ActiveExpressionLocale { get; private set; } public Locale GetExpressionLocale(Locale fallback) @@ -151,39 +154,36 @@ namespace AibisDream /// /// 启动粒子系统(在打开视图后调用,通常通过 Yarn 对话控制) /// - /// 干扰短句列表(笑话等,非目标粒子从中随机取字符) - /// 目标句子 - /// 当前 Locale 的默认随机字符池 + /// 当前 Locale 已完成粒子单位解析的轮次快照 /// 完成时触发的对话节点名称(可选) - /// 非目标候选粒子总数;<0 时使用 Inspector 默认候选池大小 + /// 轮次配置中的基础非目标粒子数 public void StartSystem( - List anxietyPhrases, - string targetSentence, - IReadOnlyList defaultCharacterPool, + ExpressionRoundTextSnapshot snapshot, string completionNodeName = null, - int nonTargetParticleTotal = -1) + int baseNonTargetParticleCount = -1) { HideSpriteOverlaysForMinigame(); if (particleManager != null) { particleManager.SetPresentationController(logReleasePresentation); - if (anxietyPhrases == null || anxietyPhrases.Count == 0 || - string.IsNullOrWhiteSpace(targetSentence) || - defaultCharacterPool == null || - defaultCharacterPool.Count == 0) + if (snapshot == null || + snapshot.TargetUnits == null || + snapshot.TargetUnits.Count == 0 || + snapshot.DefaultPoolUnits == null || + snapshot.DefaultPoolUnits.Count == 0 || + snapshot.InterferencePoolUnits == null || + snapshot.InterferencePoolUnits.Count == 0) { Debug.LogError( - "[ExpressionManager] 启动参数缺少目标文字、干扰 token 或默认字符池;系统未启动。"); + "[ExpressionManager] 轮次粒子单位快照无效;系统未启动。"); return; } particleManager.InitializeSystem( - anxietyPhrases, - targetSentence, - defaultCharacterPool, + snapshot, completionNodeName, - nonTargetParticleTotal); + baseNonTargetParticleCount); } } @@ -216,25 +216,6 @@ namespace AibisDream } } - /// - /// 启动粒子系统(字符串数组版本,方便 Yarn 调用) - /// - public void StartSystem( - string[] anxietyPhrases, - string targetSentence, - IReadOnlyList defaultCharacterPool, - string completionNodeName = null, - int nonTargetParticleTotal = -1) - { - List phrasesList = anxietyPhrases != null ? new List(anxietyPhrases) : null; - StartSystem( - phrasesList, - targetSentence, - defaultCharacterPool, - completionNodeName, - nonTargetParticleTotal); - } - /// /// 若火山释放 log 界面已淡出,则先淡入再返回(start_expression 时调用) /// diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleGeometry.cs b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleGeometry.cs new file mode 100644 index 000000000..2854bc9fe --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleGeometry.cs @@ -0,0 +1,106 @@ +using UnityEngine; + +namespace AibisDream.MiniGame.Language +{ + public static class ExpressionParticleGeometry + { + public static bool TryGetConnection( + TextParticle first, + TextParticle second, + float centerDistanceLimit, + float edgeDistanceLimit, + out Vector3 from, + out Vector3 to, + out float proximity) + { + from = first != null ? first.transform.position : Vector3.zero; + to = second != null ? second.transform.position : Vector3.zero; + proximity = 0f; + if (first == null || second == null) + return false; + + float centerDistance = Vector3.Distance( + first.transform.position, + second.transform.position); + bool centerConnected = + centerDistanceLimit > 0f && centerDistance < centerDistanceLimit; + + Bounds firstBounds = first.GetVisualWorldBounds(); + Bounds secondBounds = second.GetVisualWorldBounds(); + float edgeDistance = BoundsDistance(firstBounds, secondBounds); + bool edgeConnected = + edgeDistanceLimit > 0f && edgeDistance <= edgeDistanceLimit; + + if (!centerConnected && !edgeConnected) + return false; + + from = firstBounds.ClosestPoint(secondBounds.center); + to = secondBounds.ClosestPoint(firstBounds.center); + if ((to - from).sqrMagnitude < 0.000001f) + { + from = first.transform.position; + to = second.transform.position; + } + + float centerProximity = centerConnected + ? 1f - Mathf.Clamp01(centerDistance / centerDistanceLimit) + : 0f; + float edgeProximity = edgeConnected + ? 1f - Mathf.Clamp01(edgeDistance / Mathf.Max(0.0001f, edgeDistanceLimit)) + : 0f; + proximity = Mathf.Max(centerProximity, edgeProximity); + return true; + } + + public static float BoundsDistance(Bounds first, Bounds second) + { + float dx = Mathf.Max( + first.min.x - second.max.x, + second.min.x - first.max.x, + 0f); + float dy = Mathf.Max( + first.min.y - second.max.y, + second.min.y - first.max.y, + 0f); + return Mathf.Sqrt(dx * dx + dy * dy); + } + + public static bool TryGetSeparation( + Bounds first, + Bounds second, + float padding, + out Vector2 direction, + out float overlap) + { + float overlapX = + Mathf.Min(first.max.x, second.max.x) - + Mathf.Max(first.min.x, second.min.x) + + padding; + float overlapY = + Mathf.Min(first.max.y, second.max.y) - + Mathf.Max(first.min.y, second.min.y) + + padding; + + if (overlapX <= 0f || overlapY <= 0f) + { + direction = Vector2.zero; + overlap = 0f; + return false; + } + + Vector2 centerDelta = first.center - second.center; + if (overlapX <= overlapY) + { + direction = new Vector2(centerDelta.x >= 0f ? 1f : -1f, 0f); + overlap = overlapX; + } + else + { + direction = new Vector2(0f, centerDelta.y >= 0f ? 1f : -1f); + overlap = overlapY; + } + + return true; + } + } +} diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleGeometry.cs.meta b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleGeometry.cs.meta new file mode 100644 index 000000000..d7a38ca79 --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleGeometry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34ce433966a94546a460c32821f6e741 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleLanguageProfile.cs b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleLanguageProfile.cs new file mode 100644 index 000000000..8d5ee0022 --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleLanguageProfile.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Localization; + +namespace AibisDream.MiniGame.Language +{ + public enum ExpressionParticleUnitMode + { + Grapheme, + Word + } + + [Serializable] + public sealed class ExpressionParticleLocaleSettings + { + [SerializeField] private string localeCode = "zh-Hans"; + [SerializeField] private ExpressionParticleUnitMode unitMode = + ExpressionParticleUnitMode.Grapheme; + [SerializeField, Min(0.1f)] private float targetFontScale = 1f; + [SerializeField, Min(0.1f)] private float nonTargetFontScale = 1f; + [SerializeField, Min(0.1f)] private float floatingFontScale = 1f; + [SerializeField, Min(0f)] private float nonTargetCountScale = 1f; + [SerializeField, Min(0f)] private float wordSpacing = 0.18f; + [SerializeField, Min(0f)] private float visualEdgeConnectionDistance = 0f; + [SerializeField, Range(0.1f, 1f)] private float minimumReadablePhraseScale = 0.65f; + + public string LocaleCode => localeCode; + public ExpressionParticleUnitMode UnitMode => unitMode; + public float TargetFontScale => targetFontScale; + public float NonTargetFontScale => nonTargetFontScale; + public float FloatingFontScale => floatingFontScale; + public float NonTargetCountScale => nonTargetCountScale; + public float WordSpacing => wordSpacing; + public float VisualEdgeConnectionDistance => visualEdgeConnectionDistance; + public float MinimumReadablePhraseScale => minimumReadablePhraseScale; + + public int ScaleNonTargetCount(int baseCount) + { + if (baseCount < 0) + return baseCount; + return Mathf.Max(0, Mathf.RoundToInt(baseCount * nonTargetCountScale)); + } + + public static ExpressionParticleLocaleSettings CreateFallback() + { + return new ExpressionParticleLocaleSettings(); + } + } + + [CreateAssetMenu( + fileName = "ExpressionParticleLanguageProfile", + menuName = "AIBIS/火山/表达粒子语言配置")] + public sealed class ExpressionParticleLanguageProfile : ScriptableObject + { + [SerializeField] private List locales = new(); + + private readonly HashSet warnedFallbackLocales = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Locales => locales; + + public ExpressionParticleLocaleSettings Resolve(LocaleIdentifier locale) + { + string code = locale.Code ?? string.Empty; + ExpressionParticleLocaleSettings exact = FindExact(code); + if (exact != null) + return exact; + + int separator = code.IndexOf('-'); + string language = separator > 0 ? code.Substring(0, separator) : code; + ExpressionParticleLocaleSettings prefix = FindLanguage(language); + if (prefix != null) + return prefix; + + if (warnedFallbackLocales.Add(code)) + { + Debug.LogWarning( + $"[ExpressionParticleLanguageProfile] Locale '{code}' 未配置;" + + "回退到 Grapheme 粒子模式。"); + } + + return ExpressionParticleLocaleSettings.CreateFallback(); + } + + public List GetValidationErrors() + { + var errors = new List(); + var codes = new HashSet(StringComparer.OrdinalIgnoreCase); + if (locales == null || locales.Count == 0) + { + errors.Add("表达粒子语言配置没有 Locale 项。"); + return errors; + } + + foreach (ExpressionParticleLocaleSettings settings in locales) + { + if (settings == null) + { + errors.Add("表达粒子语言配置包含空项。"); + continue; + } + + if (string.IsNullOrWhiteSpace(settings.LocaleCode)) + { + errors.Add("表达粒子语言配置包含空 Locale Code。"); + continue; + } + + if (!codes.Add(settings.LocaleCode)) + errors.Add($"表达粒子语言配置存在重复 Locale Code:{settings.LocaleCode}"); + } + + return errors; + } + + private ExpressionParticleLocaleSettings FindExact(string code) + { + if (locales == null) + return null; + + foreach (ExpressionParticleLocaleSettings settings in locales) + { + if (settings != null && + string.Equals( + settings.LocaleCode, + code, + StringComparison.OrdinalIgnoreCase)) + { + return settings; + } + } + + return null; + } + + private ExpressionParticleLocaleSettings FindLanguage(string language) + { + if (string.IsNullOrEmpty(language) || locales == null) + return null; + + foreach (ExpressionParticleLocaleSettings settings in locales) + { + if (settings == null || string.IsNullOrWhiteSpace(settings.LocaleCode)) + continue; + + string configured = settings.LocaleCode; + int separator = configured.IndexOf('-'); + string configuredLanguage = + separator > 0 ? configured.Substring(0, separator) : configured; + if (string.Equals( + configuredLanguage, + language, + StringComparison.OrdinalIgnoreCase)) + { + return settings; + } + } + + return null; + } + } +} diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleLanguageProfile.cs.meta b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleLanguageProfile.cs.meta new file mode 100644 index 000000000..85a486969 --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionParticleLanguageProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a7f6c18f2184d09b551e103c67268a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionRoundTextSnapshot.cs b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionRoundTextSnapshot.cs new file mode 100644 index 000000000..673c7c16b --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionRoundTextSnapshot.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using UnityEngine.Localization; + +namespace AibisDream.MiniGame.Language +{ + public sealed class ExpressionRoundTextSnapshot + { + private ExpressionRoundTextSnapshot( + LocaleIdentifier locale, + ExpressionParticleLocaleSettings profile, + string targetText, + List targetUnits, + List targetGapWeights, + List defaultPoolUnits, + List interferencePoolUnits) + { + Locale = locale; + Profile = profile; + TargetText = targetText; + TargetUnits = targetUnits.AsReadOnly(); + TargetGapWeights = targetGapWeights.AsReadOnly(); + DefaultPoolUnits = defaultPoolUnits.AsReadOnly(); + InterferencePoolUnits = interferencePoolUnits.AsReadOnly(); + } + + public LocaleIdentifier Locale { get; } + public ExpressionParticleLocaleSettings Profile { get; } + public string TargetText { get; } + public IReadOnlyList TargetUnits { get; } + public IReadOnlyList TargetGapWeights { get; } + public IReadOnlyList DefaultPoolUnits { get; } + public IReadOnlyList InterferencePoolUnits { get; } + + public static bool TryCreate( + LocaleIdentifier locale, + ExpressionParticleLocaleSettings profile, + string targetText, + IEnumerable interferencePhrases, + string defaultPool, + out ExpressionRoundTextSnapshot snapshot, + out string error) + { + snapshot = null; + profile ??= ExpressionParticleLocaleSettings.CreateFallback(); + + ExpressionTextTokenizer.UnitSequence target = + ExpressionTextTokenizer.BuildLayoutUnits(targetText, profile.UnitMode); + if (target.Count == 0) + { + error = "目标句没有有效的粒子单位。"; + return false; + } + + if (!ExpressionTextTokenizer.TryTokenizePool( + defaultPool, + profile.UnitMode, + out List defaultUnits)) + { + error = "默认粒子池为空或包含非法分隔符。"; + return false; + } + + List interferenceUnits = + ExpressionTextTokenizer.BuildUnitPool( + interferencePhrases, + profile.UnitMode); + if (interferenceUnits.Count == 0) + { + error = "干扰短句没有有效的粒子单位。"; + return false; + } + + snapshot = new ExpressionRoundTextSnapshot( + locale, + profile, + targetText, + new List(target.Units), + new List(target.GapWeights), + defaultUnits, + interferenceUnits); + error = null; + return true; + } + } +} diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionRoundTextSnapshot.cs.meta b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionRoundTextSnapshot.cs.meta new file mode 100644 index 000000000..b007c5725 --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionRoundTextSnapshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1972c8c6ecaa4512b9cc891a82a5d608 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionTextTokenizer.cs b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionTextTokenizer.cs index a0712b059..bcc843193 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionTextTokenizer.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionTextTokenizer.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Globalization; +using System.Text; namespace AibisDream.MiniGame.Language { @@ -9,6 +10,19 @@ namespace AibisDream.MiniGame.Language /// public static class ExpressionTextTokenizer { + public readonly struct UnitSequence + { + public UnitSequence(List units, List gapWeights) + { + Units = units; + GapWeights = gapWeights; + } + + public IReadOnlyList Units { get; } + public IReadOnlyList GapWeights { get; } + public int Count => Units?.Count ?? 0; + } + public readonly struct Layout { public Layout(List visibleElements, List offsets) @@ -40,16 +54,56 @@ namespace AibisDream.MiniGame.Language } public static List BuildCharacterPool(IEnumerable texts) + { + return BuildUnitPool(texts, ExpressionParticleUnitMode.Grapheme); + } + + public static List TokenizeText( + string text, + ExpressionParticleUnitMode mode) + { + return new List(BuildLayoutUnits(text, mode).Units); + } + + public static bool TryTokenizePool( + string pool, + ExpressionParticleUnitMode mode, + out List units) + { + units = new List(); + if (string.IsNullOrWhiteSpace(pool) || + pool.Contains("|") || + pool.Contains("|")) + { + return false; + } + + units = TokenizeText(pool, mode); + return units.Count > 0; + } + + public static List BuildUnitPool( + IEnumerable texts, + ExpressionParticleUnitMode mode) { var result = new List(); if (texts == null) return result; foreach (string text in texts) - result.AddRange(GetVisibleElements(text)); + result.AddRange(TokenizeText(text, mode)); return result; } + public static UnitSequence BuildLayoutUnits( + string text, + ExpressionParticleUnitMode mode) + { + return mode == ExpressionParticleUnitMode.Word + ? BuildWordUnits(text) + : BuildGraphemeUnits(text); + } + public static Layout BuildLayout( string text, float characterSpacing, @@ -115,5 +169,80 @@ namespace AibisDream.MiniGame.Language return -1; } + + public static int FindUnitSequence( + IReadOnlyList source, + IReadOnlyList fragment) + { + return FindVisibleSequence(source, fragment); + } + + private static UnitSequence BuildGraphemeUnits(string text) + { + var units = new List(); + var gaps = new List(); + if (string.IsNullOrEmpty(text)) + return new UnitSequence(units, gaps); + + float pendingGap = 0f; + TextElementEnumerator enumerator = + StringInfo.GetTextElementEnumerator(text); + while (enumerator.MoveNext()) + { + string element = enumerator.GetTextElement(); + if (string.IsNullOrWhiteSpace(element)) + { + if (units.Count > 0) + pendingGap += 1f; + continue; + } + + units.Add(element); + gaps.Add(units.Count == 1 ? 0f : pendingGap); + pendingGap = 0f; + } + + return new UnitSequence(units, gaps); + } + + private static UnitSequence BuildWordUnits(string text) + { + var units = new List(); + var gaps = new List(); + if (string.IsNullOrEmpty(text)) + return new UnitSequence(units, gaps); + + var word = new StringBuilder(); + bool sawSeparatorAfterUnit = false; + foreach (char value in text) + { + if (char.IsWhiteSpace(value)) + { + FlushWord(word, units, gaps, sawSeparatorAfterUnit); + if (units.Count > 0) + sawSeparatorAfterUnit = true; + continue; + } + + word.Append(value); + } + + FlushWord(word, units, gaps, sawSeparatorAfterUnit); + return new UnitSequence(units, gaps); + } + + private static void FlushWord( + StringBuilder word, + List units, + List gaps, + bool hasLeadingSeparator) + { + if (word.Length == 0) + return; + + units.Add(word.ToString()); + gaps.Add(units.Count == 1 ? 0f : hasLeadingSeparator ? 1f : 0f); + word.Clear(); + } } } diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/FloatingTextParticle.cs b/Assets/Scripts/MiniGame/HuoShan/Language/FloatingTextParticle.cs index 301a3eca6..f88efadf2 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/FloatingTextParticle.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/FloatingTextParticle.cs @@ -42,7 +42,7 @@ namespace AibisDream.MiniGame.Language /// /// 背景粒子从干扰短句(笑话等)字符中随机显示单字。 /// - protected override string GetNextDisplayChar() + protected override string GetNextDisplayUnit() { return GetRandomCharFromPhrases(); } @@ -56,6 +56,7 @@ namespace AibisDream.MiniGame.Language if (textMesh != null) { textMesh.fontSize = size; + InvalidateVisualBounds(); } } diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs b/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs index 475100b1b..e34b6fdd7 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs @@ -64,10 +64,9 @@ namespace AibisDream.MiniGame.Language [SerializeField] private float textMargin = 1f; [Header("游戏配置")] - private List anxietyPhrases = new List(); - private string targetSentence = string.Empty; - private IReadOnlyList defaultCharacterPool = new List().AsReadOnly(); - private IReadOnlyList interferenceCharacterPool = new List().AsReadOnly(); + private ExpressionRoundTextSnapshot activeSnapshot; + private IReadOnlyList defaultUnitPool = new List().AsReadOnly(); + private IReadOnlyList interferenceUnitPool = new List().AsReadOnly(); [SerializeField] private string completionDialogNode = ""; // 完成时触发的对话节点 [Header("波形参数")] @@ -222,7 +221,7 @@ namespace AibisDream.MiniGame.Language /// 完成阶段屏幕钳制用 SmoothDamp 速度缓存(按粒子) private readonly Dictionary completionClampSmoothVelocity = new Dictionary(); private Vector3 focusCentroid; - private List finalTargetCharacters = new List(); + private List finalTargetUnits = new List(); private List finalTargetPositions = new List(); private bool interferenceLogicSuspended = false; private bool statusUIHidden = false; @@ -240,7 +239,7 @@ namespace AibisDream.MiniGame.Language { public CandidateParticle particle; public float startShakeIntensity; - public string finalChar; + public string finalUnitText; public bool stabilized; public Vector3 focusPosition; public Vector3 finalPosition; @@ -266,6 +265,11 @@ namespace AibisDream.MiniGame.Language private const string TruthGatherTweenId = "HuoshanTruthGather"; private const string FocusInterferenceTweenId = "HuoshanFocusInterference"; private int truthAttackSequenceIndex; + private float activePhraseScale = 1f; + + private ExpressionParticleUnitMode ActiveUnitMode => + activeSnapshot?.Profile?.UnitMode ?? ExpressionParticleUnitMode.Grapheme; + private float ResolvedFocusScale => focusZoomScale * activePhraseScale; // 边界 private Bounds movementBounds; @@ -288,11 +292,17 @@ namespace AibisDream.MiniGame.Language /// Inspector 中的候选粒子总数,用于未在 Yarn 中指定非目标数量时恢复默认 private int defaultCandidateCountFromInspector; private bool hasCapturedDefaultCandidateCount; + private float defaultTargetFontSizeFromInspector; + private float defaultNonTargetFontSizeFromInspector; + private float defaultFloatingFontSizeFromInspector; private void CaptureDefaultCandidateCountFromInspector() { if (hasCapturedDefaultCandidateCount) return; defaultCandidateCountFromInspector = candidateCount; + defaultTargetFontSizeFromInspector = targetParticleFontSize; + defaultNonTargetFontSizeFromInspector = nonTargetParticleFontSize; + defaultFloatingFontSizeFromInspector = floatingTextFontSize; hasCapturedDefaultCandidateCount = true; } @@ -346,65 +356,59 @@ namespace AibisDream.MiniGame.Language /// /// 初始化系统(可以由外部调用,用于配置游戏参数) /// - /// 焦虑短句列表 - /// 目标句子 - /// 当前 Locale 的默认随机字符池 + /// 当前 Locale 已完成粒子单位解析的轮次快照 /// 完成时触发的对话节点 - /// - /// 非目标候选粒子总数(蓝/干扰侧可交互粒子数量)。≥0 时候选池大小 = 目标句字数 + 该值;<0 时使用 Inspector 的 Candidate Count。 + /// + /// 轮次配置中的基础非目标粒子数。≥0 时先应用 Locale Profile 数量倍率; + /// <0 时使用 Inspector 的 Candidate Count。 /// public void InitializeSystem( - List phrases, - string sentence, - IReadOnlyList localizedDefaultPool, + ExpressionRoundTextSnapshot snapshot, string dialogNode = null, - int nonTargetParticleTotal = -1) + int baseNonTargetParticleCount = -1) { CaptureDefaultCandidateCountFromInspector(); - if (phrases == null || phrases.Count == 0 || string.IsNullOrWhiteSpace(sentence)) + if (snapshot == null || + snapshot.TargetUnits == null || + snapshot.TargetUnits.Count == 0 || + snapshot.DefaultPoolUnits == null || + snapshot.DefaultPoolUnits.Count == 0 || + snapshot.InterferencePoolUnits == null || + snapshot.InterferencePoolUnits.Count == 0) { Debug.LogError( - "[LanguageParticleManager] 目标文字或干扰 token 为空;初始化已中止。"); + "[LanguageParticleManager] 粒子单位快照无效;初始化已中止。"); return; } - List resolvedDefaultPool = - ExpressionTextTokenizer.BuildCharacterPool(localizedDefaultPool); - List resolvedInterferencePool = - ExpressionTextTokenizer.BuildCharacterPool(phrases); - if (resolvedDefaultPool.Count == 0 || resolvedInterferencePool.Count == 0) - { - Debug.LogError( - "[LanguageParticleManager] 默认字符池或干扰字符池没有有效的 Unicode 文本元素;初始化已中止。"); - return; - } + ExpressionParticleLocaleSettings profile = + snapshot.Profile ?? ExpressionParticleLocaleSettings.CreateFallback(); + int targetUnitCount = snapshot.TargetUnits.Count; + int scaledNonTargetCount = + profile.ScaleNonTargetCount(baseNonTargetParticleCount); - string useSentence = sentence; - int visibleTargetCount = ExpressionTextTokenizer.GetVisibleElements(useSentence).Count; - if (visibleTargetCount == 0) - { - Debug.LogError("[LanguageParticleManager] 目标句没有可显示的 Unicode 文本元素;初始化已中止。"); - return; - } - - if (nonTargetParticleTotal >= 0) + if (scaledNonTargetCount >= 0) { candidateCount = Mathf.Max( - visibleTargetCount + nonTargetParticleTotal, - visibleTargetCount); + targetUnitCount + scaledNonTargetCount, + targetUnitCount); } else { candidateCount = defaultCandidateCountFromInspector; } - // 在创建或复用粒子前先提交本轮不可变快照,防止首帧或上一轮字符泄漏。 - anxietyPhrases = new List(phrases); - targetSentence = useSentence; + activeSnapshot = snapshot; completionDialogNode = dialogNode ?? ""; - defaultCharacterPool = resolvedDefaultPool.AsReadOnly(); - interferenceCharacterPool = resolvedInterferencePool.AsReadOnly(); + defaultUnitPool = snapshot.DefaultPoolUnits; + interferenceUnitPool = snapshot.InterferencePoolUnits; + targetParticleFontSize = + defaultTargetFontSizeFromInspector * profile.TargetFontScale; + nonTargetParticleFontSize = + defaultNonTargetFontSizeFromInspector * profile.NonTargetFontScale; + floatingTextFontSize = + defaultFloatingFontSizeFromInspector * profile.FloatingFontScale; // 如果是第一次初始化,先执行基础初始化 if (!isInitialized) @@ -440,11 +444,10 @@ namespace AibisDream.MiniGame.Language else { EnsureCandidatePoolSize(candidateCount); - ApplyCharacterPoolsToParticles(); + ApplyUnitConfigurationToParticles(); } - // 根据目标句子更新目标粒子数量 - redParticleCount = Mathf.Min(visibleTargetCount, candidateCount); + redParticleCount = Mathf.Min(targetUnitCount, candidateCount); // 重新初始化游戏状态并播放入场动画 ResetGame(true); @@ -726,7 +729,7 @@ namespace AibisDream.MiniGame.Language Vector3 pos = GetRandomPositionInBounds(); GameObject obj = CreateParticleObject(pos, $"FloatingParticle_{i}"); FloatingTextParticle particle = obj.AddComponent(); - particle.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool); + particle.SetUnitPools(defaultUnitPool, interferenceUnitPool); particle.SetFont(chineseFontAsset, chineseFontMaterial); particle.SetColor(floatingTextColor); particle.SetFontSize(floatingTextFontSize); @@ -771,7 +774,7 @@ namespace AibisDream.MiniGame.Language Vector3 pos = GetRandomPositionInBounds(); GameObject obj = CreateParticleObject(pos, $"CandidateParticle_{index}"); CandidateParticle particle = obj.AddComponent(); - particle.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool); + particle.SetUnitPools(defaultUnitPool, interferenceUnitPool); particle.SetFont(chineseFontAsset, chineseFontMaterial); particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); particle.SetNonTargetMarquee( @@ -788,20 +791,32 @@ namespace AibisDream.MiniGame.Language candidateParticles.Add(particle); } - private void ApplyCharacterPoolsToParticles() + private void ApplyUnitConfigurationToParticles() { foreach (CandidateParticle particle in candidateParticles) - particle?.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool); + { + if (particle == null) continue; + particle.SetUnitPools(defaultUnitPool, interferenceUnitPool); + particle.SetFontStyles( + targetParticleFontSize, + nonTargetParticleFontSize, + targetParticleBold, + nonTargetParticleBold); + } foreach (FloatingTextParticle particle in floatingParticles) - particle?.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool); + { + if (particle == null) continue; + particle.SetUnitPools(defaultUnitPool, interferenceUnitPool); + particle.SetFontSize(floatingTextFontSize); + } } - private void RefreshAllRandomCharacters() + private void RefreshAllRandomUnits() { foreach (CandidateParticle particle in candidateParticles) - particle?.InitializeRandomCharacter(); + particle?.InitializeRandomUnit(); foreach (FloatingTextParticle particle in floatingParticles) - particle?.InitializeRandomCharacter(); + particle?.InitializeRandomUnit(); } /// @@ -1183,12 +1198,12 @@ namespace AibisDream.MiniGame.Language continue; } - float distance = Vector3.Distance( - candidateParticles[i].transform.position, - candidateParticles[j].transform.position - ); - - if (distance < connectionDistance) + if (TryGetConnectionVisual( + candidateParticles[i], + candidateParticles[j], + out _, + out _, + out _)) { candidateParticles[i].connections.Add(candidateParticles[j]); candidateParticles[j].connections.Add(candidateParticles[i]); @@ -1204,12 +1219,12 @@ namespace AibisDream.MiniGame.Language { for (int j = i + 1; j < candidateParticles.Count; j++) { - float distance = Vector3.Distance( - candidateParticles[i].transform.position, - candidateParticles[j].transform.position - ); - - if (distance < connectionDistance) + if (TryGetConnectionVisual( + candidateParticles[i], + candidateParticles[j], + out _, + out _, + out _)) { string key = $"{Mathf.Min(i, j)}-{Mathf.Max(i, j)}"; previousConnections[key] = true; @@ -1226,12 +1241,12 @@ namespace AibisDream.MiniGame.Language { for (int j = i + 1; j < candidateParticles.Count; j++) { - float distance = Vector3.Distance( - candidateParticles[i].transform.position, - candidateParticles[j].transform.position - ); - - if (distance < connectionDistance) + if (TryGetConnectionVisual( + candidateParticles[i], + candidateParticles[j], + out _, + out _, + out _)) { string key = $"{Mathf.Min(i, j)}-{Mathf.Max(i, j)}"; currentConnections[key] = true; @@ -1276,8 +1291,7 @@ namespace AibisDream.MiniGame.Language { if (other.isRed) continue; - float dist = Vector3.Distance(red.transform.position, other.transform.position); - if (dist < connectionDistance) + if (red.connections.Contains(other)) return false; } } @@ -1285,26 +1299,50 @@ namespace AibisDream.MiniGame.Language return true; } + public bool TryGetConnectionVisual( + CandidateParticle first, + CandidateParticle second, + out Vector3 from, + out Vector3 to, + out float proximity) + { + float edgeDistance = + ActiveUnitMode == ExpressionParticleUnitMode.Word + ? activeSnapshot?.Profile?.VisualEdgeConnectionDistance ?? 0.18f + : 0f; + return ExpressionParticleGeometry.TryGetConnection( + first, + second, + connectionDistance, + edgeDistance, + out from, + out to, + out proximity); + } + private void OnTargetsCompleted() { completionPhase = CompletionPhase.Completed; completionTimer = 0f; focusTimer = 0f; fadeOutAlpha = 1f; + activePhraseScale = 1f; allowInput = false; DestroyFocusVisualDecorationsAndClear(); - finalTargetCharacters.Clear(); + finalTargetUnits.Clear(); finalTargetPositions.Clear(); focusTweensCompleted = false; interferenceLogicSuspended = false; SetStatusUIVisibility(true, 0f); orderedRedParticles = GetOrderedRedParticles(); - ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout( - targetSentence, - 0.9f, - whitespaceSpacingMultiplier); - int displayLength = Mathf.Min(orderedRedParticles.Count, layout.Count); + IReadOnlyList targetUnits = + activeSnapshot?.TargetUnits ?? new List().AsReadOnly(); + IReadOnlyList targetGapWeights = + activeSnapshot?.TargetGapWeights ?? new List().AsReadOnly(); + List layoutPositions = + CalculatePhrasePositions(targetUnits, targetGapWeights); + int displayLength = Mathf.Min(orderedRedParticles.Count, targetUnits.Count); if (displayLength < orderedRedParticles.Count) { @@ -1313,18 +1351,17 @@ namespace AibisDream.MiniGame.Language for (int i = 0; i < displayLength; i++) { - finalTargetCharacters.Add(layout.VisibleElements[i]); + finalTargetUnits.Add(targetUnits[i]); } - Vector3 displayCenter = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero; - if (finalTargetCharacters.Count > 0) + if (finalTargetUnits.Count > 0) { finalTargetPositions.Clear(); Bounds clip = GetCompletionClipBounds(); - for (int i = 0; i < finalTargetCharacters.Count; i++) + for (int i = 0; i < finalTargetUnits.Count; i++) { - Vector3 pos = displayCenter + new Vector3(layout.Offsets[i], 0f, 0f); - finalTargetPositions.Add(ClampPositionToBounds(pos, clip)); + finalTargetPositions.Add( + ClampPositionToBounds(layoutPositions[i], clip)); } } @@ -1545,7 +1582,8 @@ namespace AibisDream.MiniGame.Language Vector3 offset = particle.transform.position - focusCentroid; float indexCenter = (orderedRedParticles.Count - 1) * 0.5f; Vector3 spreadOffset = new Vector3((i - indexCenter) * spreadDistance, Mathf.Sin(i * 1.4f) * spreadDistance * 0.6f, 0f); - Vector3 focusPosition = focusTargetCenter + offset * focusZoomScale + spreadOffset; + Vector3 focusPosition = + focusTargetCenter + offset * ResolvedFocusScale + spreadOffset; Vector3 finalPosition = finalTargetPositions.Count > i ? finalTargetPositions[i] : focusTargetCenter; focusPosition = ClampPositionToBounds(focusPosition, focusClipBounds); finalPosition = ClampPositionToBounds(finalPosition, focusClipBounds); @@ -1559,7 +1597,10 @@ namespace AibisDream.MiniGame.Language if (completedTweens >= tweenTargetCount) focusMoveTweensCompleted = true; }); - particle.transform.DOScale(Vector3.one * focusZoomScale, focusMoveDuration).SetEase(Ease.InOutQuad); + particle.transform.DOScale( + Vector3.one * ResolvedFocusScale, + focusMoveDuration) + .SetEase(Ease.InOutQuad); DOTween.To( () => particle.resolvedColorProgress, value => particle.resolvedColorProgress = value, @@ -1569,8 +1610,11 @@ namespace AibisDream.MiniGame.Language .SetTarget(particle) .OnComplete(() => particle.resolvedColorProgress = 1f); - string finalChar = i < finalTargetCharacters.Count ? finalTargetCharacters[i] : particle.currentChar; - TargetFocusVisual visual = CreateFocusVisual(particle, finalChar); + string finalUnitText = i < finalTargetUnits.Count + ? finalTargetUnits[i] + : particle.CurrentUnitText; + TargetFocusVisual visual = + CreateFocusVisual(particle, finalUnitText); visual.focusPosition = focusPosition; visual.finalPosition = finalPosition; visual.orderIndex = i; @@ -1611,12 +1655,14 @@ namespace AibisDream.MiniGame.Language focusTransitionRoutine = null; } - private TargetFocusVisual CreateFocusVisual(CandidateParticle particle, string finalChar) + private TargetFocusVisual CreateFocusVisual( + CandidateParticle particle, + string finalUnitText) { return new TargetFocusVisual { particle = particle, - finalChar = finalChar, + finalUnitText = finalUnitText, startShakeIntensity = Mathf.Max(particle.anxietyShakeIntensity, 4f) }; } @@ -1712,7 +1758,7 @@ namespace AibisDream.MiniGame.Language if (!visual.gatherLocked && gather >= lockThreshold) { visual.gatherLocked = true; - visual.particle.ForceSetCharacter(visual.finalChar); + visual.particle.ForceSetUnitText(visual.finalUnitText); visual.particle.SetChangeInterval(2f); visual.particle.ResetChangeTimer(2f); visual.particle.anxietyShakeIntensity = 0.6f; @@ -1911,7 +1957,7 @@ namespace AibisDream.MiniGame.Language if (!visual.stabilized && probabilityProgress >= 1f) { - visual.particle.ForceSetCharacter(visual.finalChar); + visual.particle.ForceSetUnitText(visual.finalUnitText); visual.particle.SetChangeInterval(1.5f); visual.particle.ResetChangeTimer(visual.particle.changeSpeedMultiplier); visual.particle.isStatic = true; @@ -2054,14 +2100,16 @@ namespace AibisDream.MiniGame.Language } StopSlotMachineShuffle(); - string pool = SanitizeSlotMachinePool(charPool); - if (string.IsNullOrEmpty(pool)) + string particlePool = ActiveUnitMode == ExpressionParticleUnitMode.Word + ? charPool?.Replace("|", " ").Replace("|", " ") + : SanitizeSlotMachinePool(charPool); + List poolElements = + ExpressionTextTokenizer.TokenizeText(particlePool, ActiveUnitMode); + if (poolElements.Count == 0) { Debug.LogWarning("[LanguageParticleManager] 老虎机换字字符池为空;已安全跳过。"); return; } - IReadOnlyList poolElements = - ExpressionTextTokenizer.GetVisibleElements(pool).AsReadOnly(); float changeInterval = Mathf.Max(0.02f, interval); foreach (TargetFocusVisual visual in focusVisuals.Values) @@ -2069,7 +2117,7 @@ namespace AibisDream.MiniGame.Language if (visual?.particle == null) continue; - visual.particle.SetOverrideCharacterPool(poolElements); + visual.particle.SetOverrideUnitPool(poolElements); visual.particle.SetChangeInterval(changeInterval); visual.particle.ResetChangeTimer(changeInterval); visual.particle.isStatic = false; @@ -2103,7 +2151,7 @@ namespace AibisDream.MiniGame.Language } foreach (CandidateParticle particle in targetParticles) - particle?.SetOverrideCharacterPool(null); + particle?.SetOverrideUnitPool((IReadOnlyList)null); } private IEnumerator EndSlotMachineShuffleAfter(float duration) @@ -2111,7 +2159,7 @@ namespace AibisDream.MiniGame.Language yield return new WaitForSeconds(duration); slotMachineRoutine = null; foreach (CandidateParticle particle in targetParticles) - particle?.SetOverrideCharacterPool(null); + particle?.SetOverrideUnitPool((IReadOnlyList)null); } public void PrepareTruthReleaseFromLie() @@ -2211,7 +2259,7 @@ namespace AibisDream.MiniGame.Language List particles = GetPresentationPhraseParticles(); if (!ValidatePresentationPhrase(text, particles, "真话失稳")) yield break; - List characters = ExpressionTextTokenizer.GetVisibleElements(text); + List units = TokenizeActiveText(text); Color unstableColor = truthAttackColor; if (!string.IsNullOrWhiteSpace(colorHex)) @@ -2231,7 +2279,7 @@ namespace AibisDream.MiniGame.Language float resolvedShakeAmplitude = Mathf.Max(0f, shakeAmplitude); float resolvedShakeSpeed = Mathf.Max(0f, shakeSpeed); List destinations = CalculatePresentationPhrasePositions(text); - UpdateFinalPresentationPhrase(characters, destinations); + UpdateFinalPresentationPhrase(units, destinations); for (int i = 0; i < particles.Count; i++) { @@ -2258,13 +2306,13 @@ namespace AibisDream.MiniGame.Language particle.resolvedColorProgress = 1f; particle.calmProgress = 1f; - if (i >= characters.Count) + if (i >= units.Count) { particle.alpha = 0f; continue; } - particle.ForceSetCharacter(characters[i]); + particle.ForceSetUnitText(units[i]); particle.alpha = 1f; particle.SetFocusInterference( unstableColor, @@ -2282,7 +2330,7 @@ namespace AibisDream.MiniGame.Language yield return new WaitForSeconds(totalDuration); - for (int i = 0; i < characters.Count && i < particles.Count; i++) + for (int i = 0; i < units.Count && i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) @@ -2300,7 +2348,7 @@ namespace AibisDream.MiniGame.Language List particles = GetPresentationPhraseParticles(); if (!ValidatePresentationPhrase(text, particles, "真话攻击")) yield break; - List characters = ExpressionTextTokenizer.GetVisibleElements(text); + List units = TokenizeActiveText(text); PreparePresentationPhraseControl(); @@ -2310,7 +2358,7 @@ namespace AibisDream.MiniGame.Language float settleDuration = Mathf.Max(0.04f, totalDuration - travelDuration); int directionSign = truthAttackSequenceIndex++ % 2 == 0 ? -1 : 1; List destinations = CalculatePresentationPhrasePositions(text); - UpdateFinalPresentationPhrase(characters, destinations); + UpdateFinalPresentationPhrase(units, destinations); for (int i = 0; i < particles.Count; i++) { @@ -2338,7 +2386,7 @@ namespace AibisDream.MiniGame.Language particle.resolvedColorProgress = 1f; particle.calmProgress = 1f; - if (i >= characters.Count) + if (i >= units.Count) { DOTween.To( () => particle.alpha, @@ -2350,7 +2398,7 @@ namespace AibisDream.MiniGame.Language continue; } - particle.ForceSetCharacter(characters[i]); + particle.ForceSetUnitText(units[i]); particle.alpha = 1f; particle.focusInterferenceProgress = 1f; @@ -2360,9 +2408,9 @@ namespace AibisDream.MiniGame.Language particle.transform.position = destination + new Vector3(directionSign * travel, verticalKick, 0f); particle.transform.localScale = new Vector3( - focusZoomScale * Mathf.Lerp(1.12f, 1.42f, resolvedImpact), - focusZoomScale * Mathf.Lerp(0.86f, 0.66f, resolvedImpact), - focusZoomScale); + ResolvedFocusScale * Mathf.Lerp(1.12f, 1.42f, resolvedImpact), + ResolvedFocusScale * Mathf.Lerp(0.86f, 0.66f, resolvedImpact), + ResolvedFocusScale); float delay = Mathf.Min(totalDuration * 0.12f, i * 0.018f); float localTravelDuration = Mathf.Max(0.04f, travelDuration - delay); @@ -2374,7 +2422,7 @@ namespace AibisDream.MiniGame.Language .SetEase(Ease.OutExpo)); sequence.Join( particle.transform.DOScale( - Vector3.one * focusZoomScale, + Vector3.one * ResolvedFocusScale, localTravelDuration) .SetEase(Ease.OutCubic)); sequence.Append( @@ -2387,13 +2435,13 @@ namespace AibisDream.MiniGame.Language yield return new WaitForSeconds(totalDuration); - for (int i = 0; i < characters.Count && i < particles.Count; i++) + for (int i = 0; i < units.Count && i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) continue; particle.transform.position = destinations[i]; - particle.transform.localScale = Vector3.one * focusZoomScale; + particle.transform.localScale = Vector3.one * ResolvedFocusScale; particle.alpha = 1f; } } @@ -2406,7 +2454,7 @@ namespace AibisDream.MiniGame.Language List particles = GetPresentationPhraseParticles(); if (!ValidatePresentationPhrase(text, particles, "真话稳定")) yield break; - List characters = ExpressionTextTokenizer.GetVisibleElements(text); + List units = TokenizeActiveText(text); PreparePresentationPhraseControl(); @@ -2432,7 +2480,7 @@ namespace AibisDream.MiniGame.Language .SetEase(Ease.InCubic) .SetTarget(particle); particle.transform.DOScale( - Vector3.one * focusZoomScale * 0.72f, + Vector3.one * ResolvedFocusScale * 0.72f, breakDuration) .SetEase(Ease.InCubic) .SetTarget(particle); @@ -2448,7 +2496,7 @@ namespace AibisDream.MiniGame.Language yield return new WaitForSeconds(breakDuration); List destinations = CalculatePresentationPhrasePositions(text); - UpdateFinalPresentationPhrase(characters, destinations); + UpdateFinalPresentationPhrase(units, destinations); for (int i = 0; i < particles.Count; i++) { CandidateParticle particle = particles[i]; @@ -2473,14 +2521,14 @@ namespace AibisDream.MiniGame.Language particle.resolvedColorProgress = 1f; particle.calmProgress = 1f; - if (i >= characters.Count) + if (i >= units.Count) { particle.alpha = 0f; particle.ClearFocusInterference(); continue; } - particle.ForceSetCharacter(characters[i]); + particle.ForceSetUnitText(units[i]); particle.SetFocusInterference(truthAttackColor, 0f, 0f); particle.focusInterferenceProgress = 1f; particle.alpha = 0.15f; @@ -2488,7 +2536,7 @@ namespace AibisDream.MiniGame.Language .SetEase(Ease.OutCubic) .SetTarget(particle); particle.transform.DOScale( - Vector3.one * focusZoomScale, + Vector3.one * ResolvedFocusScale, reformDuration) .SetEase(Ease.OutBack) .SetTarget(particle); @@ -2516,11 +2564,11 @@ namespace AibisDream.MiniGame.Language if (particle == null) continue; particle.ClearFocusInterference(); - particle.alpha = i < characters.Count ? 1f : 0f; - if (i < characters.Count) + particle.alpha = i < units.Count ? 1f : 0f; + if (i < units.Count) { particle.transform.position = destinations[i]; - particle.transform.localScale = Vector3.one * focusZoomScale; + particle.transform.localScale = Vector3.one * ResolvedFocusScale; } } } @@ -2540,7 +2588,7 @@ namespace AibisDream.MiniGame.Language .ToList(); } - private static bool ValidatePresentationPhrase( + private bool ValidatePresentationPhrase( string text, List particles, string context) @@ -2557,7 +2605,7 @@ namespace AibisDream.MiniGame.Language return false; } - int requiredParticles = ExpressionTextTokenizer.GetVisibleElements(text).Count; + int requiredParticles = TokenizeActiveText(text).Count; if (requiredParticles == 0) { Debug.LogWarning($"[LanguageParticleManager] {context}没有可显示的文本元素;已安全跳过。"); @@ -2593,30 +2641,124 @@ namespace AibisDream.MiniGame.Language private List CalculatePresentationPhrasePositions(string text) { - float spacing = Mathf.Max(0.1f, truthAttackCharacterSpacing); - ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout( - text, - spacing, - whitespaceSpacingMultiplier); - var positions = new List(layout.Count); - Vector3 center = worldCanvas != null ? worldCanvas.transform.position : transform.position; - Bounds clip = GetCompletionClipBounds(); - for (int i = 0; i < layout.Count; i++) + ExpressionTextTokenizer.UnitSequence sequence = + ExpressionTextTokenizer.BuildLayoutUnits(text, ActiveUnitMode); + return CalculatePhrasePositions(sequence.Units, sequence.GapWeights); + } + + private List TokenizeActiveText(string text) + { + return ExpressionTextTokenizer.TokenizeText(text, ActiveUnitMode); + } + + private List CalculatePhrasePositions( + IReadOnlyList units, + IReadOnlyList gapWeights) + { + int count = units?.Count ?? 0; + var offsets = new List(count); + var positions = new List(count); + if (count == 0) { - Vector3 position = center + new Vector3(layout.Offsets[i], 0f, 0f); + activePhraseScale = 1f; + return positions; + } + + bool wordMode = ActiveUnitMode == ExpressionParticleUnitMode.Word; + float graphemeSpacing = Mathf.Max(0.1f, truthAttackCharacterSpacing); + float wordSpacing = Mathf.Max( + 0f, + activeSnapshot?.Profile?.WordSpacing ?? 0.18f); + float cursor = 0f; + float maxUnitWidth = 0f; + + for (int i = 0; i < count; i++) + { + float width = MeasureUnitWorldWidth(units[i]); + maxUnitWidth = Mathf.Max(maxUnitWidth, width); + float gapWeight = + gapWeights != null && i < gapWeights.Count ? gapWeights[i] : 0f; + + if (wordMode) + { + if (i > 0) + cursor += wordSpacing * Mathf.Max(1f, gapWeight); + offsets.Add(cursor + width * 0.5f); + cursor += width; + } + else + { + if (i > 0 && gapWeight > 0f) + cursor += graphemeSpacing * whitespaceSpacingMultiplier * gapWeight; + offsets.Add(cursor); + cursor += graphemeSpacing; + } + } + + float baseCenter = (offsets[0] + offsets[offsets.Count - 1]) * 0.5f; + for (int i = 0; i < offsets.Count; i++) + offsets[i] -= baseCenter; + + float baseWidth = wordMode + ? cursor + : Mathf.Max( + maxUnitWidth * focusZoomScale, + offsets[offsets.Count - 1] - offsets[0] + + maxUnitWidth * focusZoomScale); + float widthAtFocus = wordMode ? baseWidth * focusZoomScale : baseWidth; + Bounds clip = GetCompletionClipBounds(); + float availableWidth = Mathf.Max(0.01f, clip.size.x); + activePhraseScale = widthAtFocus > availableWidth + ? availableWidth / widthAtFocus + : 1f; + + float minimumReadable = + activeSnapshot?.Profile?.MinimumReadablePhraseScale ?? 0.65f; + if (activePhraseScale < minimumReadable) + { + Debug.LogError( + $"[LanguageParticleManager] 表达文字需要缩放到 {activePhraseScale:0.00}," + + $"低于 Locale Profile 最小可读值 {minimumReadable:0.00};" + + "运行时仍会缩放以保证文字完整显示。"); + } + + Vector3 center = + worldCanvas != null ? worldCanvas.transform.position : transform.position; + float offsetScale = wordMode ? ResolvedFocusScale : activePhraseScale; + for (int i = 0; i < offsets.Count; i++) + { + Vector3 position = + center + new Vector3(offsets[i] * offsetScale, 0f, 0f); positions.Add(ClampPositionToBounds(position, clip)); } + return positions; } + private float MeasureUnitWorldWidth(string unit) + { + CandidateParticle reference = targetParticles.FirstOrDefault( + particle => particle != null && particle.textMesh != null); + if (reference?.textMesh == null) + return Mathf.Max(0.1f, truthAttackCharacterSpacing); + + float preferred = reference.textMesh.GetPreferredValues(unit).x; + float rootScale = Mathf.Max( + 0.0001f, + Mathf.Abs(reference.transform.localScale.x)); + float worldScale = + Mathf.Abs(reference.textMesh.transform.lossyScale.x) / rootScale; + return Mathf.Max(0.01f, preferred * worldScale); + } + private void UpdateFinalPresentationPhrase( - IReadOnlyList characters, + IReadOnlyList units, List positions) { - finalTargetCharacters.Clear(); + finalTargetUnits.Clear(); finalTargetPositions.Clear(); - for (int i = 0; i < characters.Count; i++) - finalTargetCharacters.Add(characters[i]); + for (int i = 0; i < units.Count; i++) + finalTargetUnits.Add(units[i]); finalTargetPositions.AddRange(positions); } @@ -2654,12 +2796,16 @@ namespace AibisDream.MiniGame.Language if (string.IsNullOrEmpty(fragment) || orderedRedParticles.Count == 0) yield break; - List targetElements = ExpressionTextTokenizer.GetVisibleElements(targetSentence); - List fragmentElements = ExpressionTextTokenizer.GetVisibleElements(fragment); - int startIndex = ExpressionTextTokenizer.FindVisibleSequence(targetElements, fragmentElements); + IReadOnlyList targetElements = + activeSnapshot?.TargetUnits ?? new List().AsReadOnly(); + List fragmentElements = TokenizeActiveText(fragment); + int startIndex = + ExpressionTextTokenizer.FindUnitSequence(targetElements, fragmentElements); if (startIndex < 0) { - Debug.LogWarning($"[LanguageParticleManager] 真话泄漏片段“{fragment}”不在目标句“{targetSentence}”中。"); + Debug.LogWarning( + $"[LanguageParticleManager] 真话泄漏片段“{fragment}”不在目标句" + + $"“{activeSnapshot?.TargetText}”中。"); yield break; } @@ -2669,7 +2815,7 @@ namespace AibisDream.MiniGame.Language { CandidateParticle particle = orderedRedParticles[i]; if (particle == null) continue; - particle.ForceSetCharacter(targetElements[i]); + particle.ForceSetUnitText(targetElements[i]); particle.isStatic = true; particle.alpha = 1f; leaked.Add(particle); @@ -2753,7 +2899,7 @@ namespace AibisDream.MiniGame.Language CandidateParticle particle = visual.particle; particle.transform.DOKill(); particle.transform.position = visual.finalPosition; - particle.ForceSetCharacter(visual.finalChar); + particle.ForceSetUnitText(visual.finalUnitText); particle.SetChangeInterval(1.5f); particle.ResetChangeTimer(1.5f); particle.anxietyShakeIntensity = 0f; @@ -2833,7 +2979,10 @@ namespace AibisDream.MiniGame.Language DestroyFocusVisualDecorationsAndClear(); foreach (var p in particlesToScale) { - p.transform.DOScale(Vector3.one * focusZoomScale, 0.3f).SetEase(Ease.OutQuad); + p.transform.DOScale( + Vector3.one * ResolvedFocusScale, + 0.3f) + .SetEase(Ease.OutQuad); p.isStatic = true; p.changeSpeedMultiplier = 1f; p.resolvedColorProgress = 1f; @@ -3069,15 +3218,16 @@ namespace AibisDream.MiniGame.Language foreach (var particle in candidateParticles) { Vector2 particlePos = particle.transform.position; - float distance = Vector2.Distance(center, particlePos); + float distance = particle.DistanceToVisualBounds(center); - if (distance < repulsionRadius && distance > 0.01f) + if (distance < repulsionRadius) { - // 计算方向和力度 - Vector2 direction = (particlePos - center).normalized; + Vector2 delta = particlePos - center; + Vector2 direction = delta.sqrMagnitude > 0.0001f + ? delta.normalized + : Vector2.up; float forceMagnitude = repulsionForce * (1f - distance / repulsionRadius); - // 应用力 particle.ApplyForce(direction * forceMagnitude); } } @@ -3112,7 +3262,7 @@ namespace AibisDream.MiniGame.Language allowInput = !playEntranceEffect; orderedRedParticles.Clear(); DestroyFocusVisualDecorationsAndClear(); - finalTargetCharacters.Clear(); + finalTargetUnits.Clear(); finalTargetPositions.Clear(); previousConnections.Clear(); initializationComplete = false; @@ -3201,7 +3351,7 @@ namespace AibisDream.MiniGame.Language RebuildScreenClipMaterials(); SelectRedParticles(); - RefreshAllRandomCharacters(); + RefreshAllRandomUnits(); UpdateStatusUI(); if (playEntranceEffect) { @@ -3403,32 +3553,56 @@ namespace AibisDream.MiniGame.Language if (particleB == null || (particleA.isStatic && particleB.isStatic)) continue; - Vector2 posA = particleA.transform.position; - Vector2 posB = particleB.transform.position; - Vector2 diff = posA - posB; - float distSqr = diff.sqrMagnitude; - float requiredSpacing = CalculateRequiredParticleSpacing(particleA, particleB); - if (distSqr >= requiredSpacing * requiredSpacing) - continue; - - float distance; Vector2 direction; - if (distSqr > 0.000001f) + float overlap; + bool differentTypes = + particleA.originalIsRed != particleB.originalIsRed; + + if (ActiveUnitMode == ExpressionParticleUnitMode.Word && + useGlyphBoundsForSeparation) { - distance = Mathf.Sqrt(distSqr); - direction = diff / distance; + float padding = Mathf.Max(0f, separationPadding); + if (differentTypes) + padding *= Mathf.Max(1f, differentTypeSpacingMultiplier); + if (!ExpressionParticleGeometry.TryGetSeparation( + particleA.GetVisualWorldBounds(), + particleB.GetVisualWorldBounds(), + padding, + out direction, + out overlap)) + { + continue; + } } else { - // 完全重合时 normalized 会得到零向量;使用稳定的配对方向打破死锁。 - distance = 0f; - float angle = (i * 37f + j * 61f) * 2.3999632f; - direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); + Vector2 diff = + particleA.transform.position - particleB.transform.position; + float distSqr = diff.sqrMagnitude; + float requiredSpacing = + CalculateRequiredParticleSpacing(particleA, particleB); + if (distSqr >= requiredSpacing * requiredSpacing) + continue; + + float distance; + if (distSqr > 0.000001f) + { + distance = Mathf.Sqrt(distSqr); + direction = diff / distance; + } + else + { + distance = 0f; + float angle = (i * 37f + j * 61f) * 2.3999632f; + direction = + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); + } + + overlap = requiredSpacing - distance; } - float overlap = requiredSpacing - distance; - float normalizedOverlap = Mathf.Clamp01(overlap / Mathf.Max(requiredSpacing, 0.0001f)); - bool differentTypes = particleA.originalIsRed != particleB.originalIsRed; + float normalizedOverlap = Mathf.Clamp01( + overlap / Mathf.Max(minParticleSpacing, 0.0001f)); float forceMultiplier = differentTypes ? Mathf.Max(1f, differentTypeForceMultiplier) : 1f; diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/LanguageYarnCommand.cs b/Assets/Scripts/MiniGame/HuoShan/Language/LanguageYarnCommand.cs index 77f54b34a..8b77fbab9 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/LanguageYarnCommand.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/LanguageYarnCommand.cs @@ -90,18 +90,20 @@ namespace AibisDream.MiniGame.Language public static bool TryParseExpressionPool( string value, - out List characters) + out List units) { - characters = new List(); - if (string.IsNullOrWhiteSpace(value) || - value.Contains('|') || - value.Contains('|')) - { - return false; - } + return TryParseExpressionPool( + value, + ExpressionParticleUnitMode.Grapheme, + out units); + } - characters = ExpressionTextTokenizer.GetVisibleElements(value); - return characters.Count > 0; + public static bool TryParseExpressionPool( + string value, + ExpressionParticleUnitMode mode, + out List units) + { + return ExpressionTextTokenizer.TryTokenizePool(value, mode, out units); } private static bool TryGetPresentation(out LogReleasePresentationController presentation) @@ -171,10 +173,21 @@ namespace AibisDream.MiniGame.Language "[LanguageYarnCommand] start_expression 没有有效的干扰 token;命令已安全结束。"); yield break; } - if (!TryParseExpressionPool(localized[2], out List defaultCharacterPool)) + ExpressionParticleLocaleSettings profile = + ExpressionManager.ParticleLanguageProfile != null + ? ExpressionManager.ParticleLanguageProfile.Resolve(locale.Identifier) + : ExpressionParticleLocaleSettings.CreateFallback(); + if (!ExpressionRoundTextSnapshot.TryCreate( + locale.Identifier, + profile, + localized[1], + phrases, + localized[2], + out ExpressionRoundTextSnapshot snapshot, + out string snapshotError)) { UnityEngine.Debug.LogError( - "[LanguageYarnCommand] start_expression 的默认字符池无效;命令已安全结束。"); + $"[LanguageYarnCommand] start_expression 粒子单位解析失败:{snapshotError}"); yield break; } @@ -184,9 +197,7 @@ namespace AibisDream.MiniGame.Language yield return ExpressionManager.EnsureReleaseLogFadedIn(1f); ExpressionManager.SetActiveExpressionLocale(locale); ExpressionManager.StartSystem( - phrases, - localized[1], - defaultCharacterPool, + snapshot, completionNode, nonTargetParticleTotal); yield return ExpressionManager.WaitUntilExpressionFlowReady(); @@ -249,10 +260,21 @@ namespace AibisDream.MiniGame.Language $"[LanguageYarnCommand] 表达轮次 '{roundId}' 没有有效的干扰 token。"); yield break; } - if (!TryParseExpressionPool(localized[2], out List defaultCharacterPool)) + ExpressionParticleLocaleSettings profile = + ExpressionManager.ParticleLanguageProfile != null + ? ExpressionManager.ParticleLanguageProfile.Resolve(locale.Identifier) + : ExpressionParticleLocaleSettings.CreateFallback(); + if (!ExpressionRoundTextSnapshot.TryCreate( + locale.Identifier, + profile, + localized[1], + phrases, + localized[2], + out ExpressionRoundTextSnapshot snapshot, + out string snapshotError)) { UnityEngine.Debug.LogError( - $"[LanguageYarnCommand] 表达轮次 '{roundId}' 的默认字符池无效。"); + $"[LanguageYarnCommand] 表达轮次 '{roundId}' 粒子单位解析失败:{snapshotError}"); yield break; } @@ -261,9 +283,7 @@ namespace AibisDream.MiniGame.Language yield return ExpressionManager.EnsureReleaseLogFadedIn(1f); ExpressionManager.SetActiveExpressionLocale(locale); ExpressionManager.StartSystem( - phrases, - localized[1], - defaultCharacterPool, + snapshot, completionNode ?? string.Empty, round.NonTargetParticleCount); yield return ExpressionManager.WaitUntilExpressionFlowReady(); diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/TextParticle.cs b/Assets/Scripts/MiniGame/HuoShan/Language/TextParticle.cs index b7e20de86..ce7df5238 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/TextParticle.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/TextParticle.cs @@ -18,7 +18,21 @@ namespace AibisDream.MiniGame.Language public float alpha; public float baseAlpha = 1f; public float size = 4f; - public string currentChar; + [SerializeField] + [UnityEngine.Serialization.FormerlySerializedAs("currentChar")] + private string currentUnitText; + public string CurrentUnitText => currentUnitText; + + [System.Obsolete("Use CurrentUnitText instead.")] + public string currentChar + { + get => currentUnitText; + set + { + currentUnitText = value; + UpdateText(); + } + } [Header("状态")] public bool isStatic = false; @@ -29,9 +43,9 @@ namespace AibisDream.MiniGame.Language protected float changeInterval = 1f; private static readonly IReadOnlyList EmptyCharacterPool = new List().AsReadOnly(); - protected IReadOnlyList defaultCharacterPool = EmptyCharacterPool; - protected IReadOnlyList interferenceCharacterPool = EmptyCharacterPool; - protected IReadOnlyList overrideCharacterPool = EmptyCharacterPool; + protected IReadOnlyList defaultUnitPool = EmptyCharacterPool; + protected IReadOnlyList interferenceUnitPool = EmptyCharacterPool; + protected IReadOnlyList overrideUnitPool = EmptyCharacterPool; protected Bounds movementBounds; protected bool useCircularBounds = false; protected Vector3 boundsCenter; @@ -39,6 +53,8 @@ namespace AibisDream.MiniGame.Language protected TMP_FontAsset customFont; private Material customFontMaterial; private TMPRectClipper screenClipper; + private Vector2 preferredLocalSize; + private bool preferredSizeDirty = true; protected virtual void Awake() { @@ -82,7 +98,7 @@ namespace AibisDream.MiniGame.Language SyncLayerToChildren(); changeTimer = Random.Range(0.5f, 1.5f); - currentChar = string.Empty; + currentUnitText = string.Empty; UpdateText(); // TMP 重建 mesh / SubMesh 后再对齐一次 layer SyncLayerToChildren(); @@ -113,7 +129,7 @@ namespace AibisDream.MiniGame.Language changeTimer -= Time.deltaTime; if (changeTimer <= 0) { - currentChar = GetNextDisplayChar(); + currentUnitText = GetNextDisplayUnit(); UpdateText(); changeTimer = changeInterval; } @@ -169,13 +185,20 @@ namespace AibisDream.MiniGame.Language if (useCircularBounds) { - Vector2 offset = new Vector2(pos.x - boundsCenter.x, pos.y - boundsCenter.y); + Bounds visualBounds = GetVisualWorldBounds(); + float visualRadius = Mathf.Max( + visualBounds.extents.x, + visualBounds.extents.y); + float allowedRadius = Mathf.Max(0f, boundsRadius - visualRadius); + Vector2 visualCenter = visualBounds.center; + Vector2 offset = visualCenter - (Vector2)boundsCenter; float dist = offset.magnitude; - if (dist > boundsRadius && boundsRadius > 0.0001f) + if (dist > allowedRadius && boundsRadius > 0.0001f) { Vector2 norm = offset / dist; - pos = boundsCenter + (Vector3)(norm * boundsRadius); - pos.z = transform.position.z; + Vector2 correction = + (Vector2)boundsCenter + norm * allowedRadius - visualCenter; + pos += (Vector3)correction; // 反弹速度(沿径向反射) Vector2 vel = velocity; Vector2 reflect = vel - 2f * Vector2.Dot(vel, norm) * norm; @@ -185,17 +208,26 @@ namespace AibisDream.MiniGame.Language } else { - if (pos.x < movementBounds.min.x || pos.x > movementBounds.max.x) + Bounds visualBounds = GetVisualWorldBounds(); + if (visualBounds.min.x < movementBounds.min.x || + visualBounds.max.x > movementBounds.max.x) { velocity.x *= -1; - pos.x = Mathf.Clamp(pos.x, movementBounds.min.x, movementBounds.max.x); + if (visualBounds.min.x < movementBounds.min.x) + pos.x += movementBounds.min.x - visualBounds.min.x; + else + pos.x -= visualBounds.max.x - movementBounds.max.x; bounced = true; } - if (pos.y < movementBounds.min.y || pos.y > movementBounds.max.y) + if (visualBounds.min.y < movementBounds.min.y || + visualBounds.max.y > movementBounds.max.y) { velocity.y *= -1; - pos.y = Mathf.Clamp(pos.y, movementBounds.min.y, movementBounds.max.y); + if (visualBounds.min.y < movementBounds.min.y) + pos.y += movementBounds.min.y - visualBounds.min.y; + else + pos.y -= visualBounds.max.y - movementBounds.max.y; bounced = true; } } @@ -206,7 +238,7 @@ namespace AibisDream.MiniGame.Language protected string GetRandomChar() { - return GetRandomPoolElement(defaultCharacterPool); + return GetRandomPoolElement(defaultUnitPool); } /// @@ -215,16 +247,16 @@ namespace AibisDream.MiniGame.Language /// protected string GetRandomCharFromPhrases() { - if (interferenceCharacterPool == null || interferenceCharacterPool.Count == 0) + if (interferenceUnitPool == null || interferenceUnitPool.Count == 0) return GetRandomChar(); - return GetRandomPoolElement(interferenceCharacterPool); + return GetRandomPoolElement(interferenceUnitPool); } /// /// 获取下一次要显示的字符。子类可重写以区分目标粒子与非目标粒子(与红蓝颜色解绑)。 /// 默认从本轮本地化字符池随机返回一个 Unicode 文本元素。 /// - protected virtual string GetNextDisplayChar() + protected virtual string GetNextDisplayUnit() { if (TryGetOverridePoolChar(out string overrideChar)) return overrideChar; @@ -234,19 +266,25 @@ namespace AibisDream.MiniGame.Language /// /// 设置换字字符池覆盖(老虎机演出用);传空/null 清除,恢复默认字符池。 /// - public void SetOverrideCharPool(string pool) + public void SetOverrideUnitPool(string pool) { - SetOverrideCharacterPool( + SetOverrideUnitPool( ExpressionTextTokenizer.GetVisibleElements(pool)); } - public void SetOverrideCharacterPool(IReadOnlyList pool) + public void SetOverrideUnitPool(IReadOnlyList pool) { - overrideCharacterPool = pool != null && pool.Count > 0 + overrideUnitPool = pool != null && pool.Count > 0 ? pool : EmptyCharacterPool; } + [System.Obsolete("Use SetOverrideUnitPool instead.")] + public void SetOverrideCharacterPool(IReadOnlyList characterPool) + { + SetOverrideUnitPool(characterPool); + } + protected bool TryGetOverridePoolChar(out string character) { if (!HasOverrideCharacterPool) @@ -255,35 +293,49 @@ namespace AibisDream.MiniGame.Language return false; } - character = GetRandomPoolElement(overrideCharacterPool); + character = GetRandomPoolElement(overrideUnitPool); return true; } /// /// 设置本轮默认与干扰字符池。调用方负责传入不可变的本轮快照。 /// - public void SetCharacterPools( + public void SetUnitPools( IReadOnlyList defaultPool, IReadOnlyList interferencePool) { - defaultCharacterPool = defaultPool != null && defaultPool.Count > 0 + defaultUnitPool = defaultPool != null && defaultPool.Count > 0 ? defaultPool : EmptyCharacterPool; - interferenceCharacterPool = + interferenceUnitPool = interferencePool != null && interferencePool.Count > 0 ? interferencePool : EmptyCharacterPool; } - public void InitializeRandomCharacter() + [System.Obsolete("Use SetUnitPools instead.")] + public void SetCharacterPools( + IReadOnlyList defaultCharacters, + IReadOnlyList interferenceCharacters) { - currentChar = GetNextDisplayChar(); + SetUnitPools(defaultCharacters, interferenceCharacters); + } + + public void InitializeRandomUnit() + { + currentUnitText = GetNextDisplayUnit(); UpdateText(); changeTimer = Random.Range(0.5f, 1.5f); } + [System.Obsolete("Use InitializeRandomUnit instead.")] + public void InitializeRandomCharacter() + { + InitializeRandomUnit(); + } + protected bool HasOverrideCharacterPool => - overrideCharacterPool != null && overrideCharacterPool.Count > 0; + overrideUnitPool != null && overrideUnitPool.Count > 0; private static string GetRandomPoolElement(IReadOnlyList pool) { @@ -295,13 +347,62 @@ namespace AibisDream.MiniGame.Language protected void UpdateText() { if (textMesh != null) - textMesh.text = currentChar; + { + textMesh.text = currentUnitText; + preferredSizeDirty = true; + } } + public void ForceSetUnitText(string unitText) + { + currentUnitText = unitText; + UpdateText(); + } + + [System.Obsolete("Use ForceSetUnitText instead.")] public void ForceSetCharacter(string character) { - currentChar = character; - UpdateText(); + ForceSetUnitText(character); + } + + public Bounds GetVisualWorldBounds() + { + RefreshPreferredSize(); + Transform textTransform = textMesh != null ? textMesh.transform : transform; + Vector3 scale = textTransform.lossyScale; + Vector3 size = new Vector3( + Mathf.Max(0.001f, preferredLocalSize.x * Mathf.Abs(scale.x)), + Mathf.Max(0.001f, preferredLocalSize.y * Mathf.Abs(scale.y)), + 0.001f); + return new Bounds(textTransform.position, size); + } + + public float DistanceToVisualBounds(Vector2 worldPoint) + { + Bounds bounds = GetVisualWorldBounds(); + float dx = Mathf.Max(bounds.min.x - worldPoint.x, 0f, worldPoint.x - bounds.max.x); + float dy = Mathf.Max(bounds.min.y - worldPoint.y, 0f, worldPoint.y - bounds.max.y); + return Mathf.Sqrt(dx * dx + dy * dy); + } + + protected void InvalidateVisualBounds() + { + preferredSizeDirty = true; + } + + private void RefreshPreferredSize() + { + if (!preferredSizeDirty) + return; + + preferredSizeDirty = false; + if (textMesh == null || string.IsNullOrEmpty(currentUnitText)) + { + preferredLocalSize = Vector2.zero; + return; + } + + preferredLocalSize = textMesh.GetPreferredValues(currentUnitText); } public void SetStaticState(bool value) @@ -380,6 +481,8 @@ namespace AibisDream.MiniGame.Language if (customFontMaterial != null) textMesh.fontSharedMaterial = customFontMaterial; + + InvalidateVisualBounds(); } protected virtual void OnDestroy() diff --git a/Assets/Tests/Huoshan.meta b/Assets/Tests/Huoshan.meta new file mode 100644 index 000000000..43cc31015 --- /dev/null +++ b/Assets/Tests/Huoshan.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 84ac13641a36486d8f1a891bad6b1f28 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Huoshan/PlayMode.meta b/Assets/Tests/Huoshan/PlayMode.meta new file mode 100644 index 000000000..fd250017c --- /dev/null +++ b/Assets/Tests/Huoshan/PlayMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f6244beee7e24912bd0e6019ac802f92 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Huoshan/PlayMode/AibisDream.Huoshan.Tests.PlayMode.asmdef b/Assets/Tests/Huoshan/PlayMode/AibisDream.Huoshan.Tests.PlayMode.asmdef new file mode 100644 index 000000000..2268144d7 --- /dev/null +++ b/Assets/Tests/Huoshan/PlayMode/AibisDream.Huoshan.Tests.PlayMode.asmdef @@ -0,0 +1,19 @@ +{ + "name": "AibisDream.Huoshan.Tests.PlayMode", + "rootNamespace": "AibisDream.Huoshan.Tests.PlayMode", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/Assets/Tests/Huoshan/PlayMode/AibisDream.Huoshan.Tests.PlayMode.asmdef.meta b/Assets/Tests/Huoshan/PlayMode/AibisDream.Huoshan.Tests.PlayMode.asmdef.meta new file mode 100644 index 000000000..47012d704 --- /dev/null +++ b/Assets/Tests/Huoshan/PlayMode/AibisDream.Huoshan.Tests.PlayMode.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e93af359c134479a88d141136397693e +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Huoshan/PlayMode/ExpressionParticlePlayModeTests.cs b/Assets/Tests/Huoshan/PlayMode/ExpressionParticlePlayModeTests.cs new file mode 100644 index 000000000..3bbb4bfa2 --- /dev/null +++ b/Assets/Tests/Huoshan/PlayMode/ExpressionParticlePlayModeTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace AibisDream.Huoshan.Tests.PlayMode +{ + public sealed class ExpressionParticlePlayModeTests + { + private const string RuntimeAssemblyName = "Assembly-CSharp"; + + [Test] + public void WordMode_TokenizesWholeWordsAtRuntime() + { + Type tokenizerType = RequireRuntimeType( + "AibisDream.MiniGame.Language.ExpressionTextTokenizer"); + Type modeType = RequireRuntimeType( + "AibisDream.MiniGame.Language.ExpressionParticleUnitMode"); + object wordMode = Enum.Parse(modeType, "Word"); + MethodInfo tokenize = tokenizerType.GetMethod( + "TokenizeText", + BindingFlags.Public | BindingFlags.Static); + + Assert.That(tokenize, Is.Not.Null); + IEnumerable result = (IEnumerable)tokenize.Invoke( + null, + new[] { "I am not self-doubt", wordMode }); + string[] units = result.Cast() + .Select(value => value.ToString()) + .ToArray(); + + Assert.That( + units, + Is.EqualTo(new[] { "I", "am", "not", "self-doubt" })); + } + + [UnityTest] + public IEnumerator WordParticle_BoundsIncludesBothPointerEdges() + { + GameObject gameObject = new GameObject("WordParticlePlayModeTest"); + try + { + Component particle = gameObject.AddComponent( + RequireRuntimeType( + "AibisDream.MiniGame.Language.CandidateParticle")); + Invoke(particle, "ForceSetUnitText", "self-doubt"); + yield return null; + + Bounds bounds = (Bounds)Invoke(particle, "GetVisualWorldBounds"); + Assert.That(bounds.size.x, Is.GreaterThan(0f)); + + float leftDistance = (float)Invoke( + particle, + "DistanceToVisualBounds", + new Vector2(bounds.min.x, bounds.center.y)); + float rightDistance = (float)Invoke( + particle, + "DistanceToVisualBounds", + new Vector2(bounds.max.x, bounds.center.y)); + Assert.That(leftDistance, Is.EqualTo(0f).Within(0.0001f)); + Assert.That(rightDistance, Is.EqualTo(0f).Within(0.0001f)); + } + finally + { + UnityEngine.Object.Destroy(gameObject); + } + + yield return null; + } + + [UnityTest] + public IEnumerator LongWords_ConnectThroughVisualEdgeDistance() + { + GameObject firstObject = new GameObject("FirstWordParticle"); + GameObject secondObject = new GameObject("SecondWordParticle"); + try + { + Type particleType = RequireRuntimeType( + "AibisDream.MiniGame.Language.CandidateParticle"); + Component first = firstObject.AddComponent(particleType); + Component second = secondObject.AddComponent(particleType); + Invoke(first, "ForceSetUnitText", "internationalization"); + Invoke(second, "ForceSetUnitText", "characteristically"); + yield return null; + + Bounds firstBounds = + (Bounds)Invoke(first, "GetVisualWorldBounds"); + Bounds secondBounds = + (Bounds)Invoke(second, "GetVisualWorldBounds"); + secondObject.transform.position = new Vector3( + firstBounds.extents.x + secondBounds.extents.x + 0.1f, + 0f, + 0f); + + Type geometryType = RequireRuntimeType( + "AibisDream.MiniGame.Language.ExpressionParticleGeometry"); + MethodInfo connection = geometryType.GetMethod( + "TryGetConnection", + BindingFlags.Public | BindingFlags.Static); + object[] arguments = + { + first, + second, + 0.01f, + 0.18f, + Vector3.zero, + Vector3.zero, + 0f + }; + + Assert.That(connection, Is.Not.Null); + bool connected = (bool)connection.Invoke(null, arguments); + Assert.That(connected, Is.True); + Assert.That((float)arguments[6], Is.GreaterThan(0f)); + Assert.That( + Vector3.Distance( + firstObject.transform.position, + secondObject.transform.position), + Is.GreaterThan(0.01f)); + } + finally + { + UnityEngine.Object.Destroy(firstObject); + UnityEngine.Object.Destroy(secondObject); + } + + yield return null; + } + + private static Type RequireRuntimeType(string fullName) + { + Type type = Type.GetType($"{fullName}, {RuntimeAssemblyName}"); + Assert.That(type, Is.Not.Null, fullName); + return type; + } + + private static object Invoke( + object target, + string methodName, + params object[] arguments) + { + MethodInfo method = target.GetType().GetMethod( + methodName, + BindingFlags.Public | BindingFlags.Instance); + Assert.That(method, Is.Not.Null, methodName); + return method.Invoke(target, arguments); + } + } +} diff --git a/Assets/Tests/Huoshan/PlayMode/ExpressionParticlePlayModeTests.cs.meta b/Assets/Tests/Huoshan/PlayMode/ExpressionParticlePlayModeTests.cs.meta new file mode 100644 index 000000000..9a2576074 --- /dev/null +++ b/Assets/Tests/Huoshan/PlayMode/ExpressionParticlePlayModeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 26043594097949158ba1572f70c94f78 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Docs/HuoShan/HuoshanStage6LocalizationPlan.md b/Docs/HuoShan/HuoshanStage6LocalizationPlan.md index 1c3dbef77..7d5fb8f9e 100644 --- a/Docs/HuoShan/HuoshanStage6LocalizationPlan.md +++ b/Docs/HuoShan/HuoshanStage6LocalizationPlan.md @@ -60,7 +60,7 @@ Express 使用短前缀 `hs.exp.*`: 重复演出复用同一 Key,但 Yarn 中仍保留原有调用次数、等待、数值、顺序和 `#line:` 标签。 -## token 与 Unicode +## token 与粒子单位 干扰 token 只接受半角 `|`: @@ -69,15 +69,28 @@ Express 使用短前缀 `hs.exp.*`: - 全空时中止轮次; - 全角 `|` 是数据错误。 -正式目标句和释放演出文字按 `StringInfo` 的 Unicode 文本元素处理。汉字、 -假名、拉丁字母、组合字符、代理字符和标点均保持完整;空白不生成粒子,但 -最终排列保留词间距。目标粒子数量、最终排列、攻击词长度校验和 truth leak -匹配使用同一套文本元素结果。 +`ExpressionParticleLanguageProfile` 决定当前 Locale 的粒子单位: -随机字符池同样按 Unicode 文本元素处理。`hs.exp.pool` 是连续文本,不使用 -`|` 或全角 `|`,空白被忽略,重复元素保留为随机权重。目标粒子从该池取字, -非目标和背景浮动粒子继续从本轮 token 展开的干扰池取字;临时老虎机池结束 -后按粒子角色恢复。旧的 `chineseChars` 与 `chineseWords` 运行时常量均已删除。 +- `zh-Hans`、`ja-JP` 使用 `Grapheme`,通过 `StringInfo` 按 Unicode 文本元素 + 切分;空白不生成粒子,但最终排列保留间距; +- `en`、`es`、`ru`、`pt-BR` 使用 `Word`,按 Unicode 空白切分并折叠连续 + 空白;撇号、连字符和标点留在词内; +- Locale 先完整匹配,再按语言前缀匹配;未知 Locale 警告一次并回退 + `Grapheme`。 + +目标句、token、`hs.exp.pool` 在 Yarn 命令入口一次性解析为 +`ExpressionRoundTextSnapshot`。目标粒子数量、最终排列、攻击词长度校验、 +truth leak 和老虎机覆盖池都使用同一份快照或同一 Unit Mode,不会在 Manager +内再次按字母拆分。切换全局 Locale 不会改变进行中的轮次快照。 + +`hs.exp.pool` 仍禁止 `|` 和全角 `|`。Grapheme 模式使用连续文本,Word 模式 +使用空白分隔单词;两种模式都保留重复项作为随机权重。目标粒子从该池取单位, +非目标和背景浮动粒子从本轮 token 展开的干扰池取单位;临时老虎机池结束后按 +粒子角色恢复。 + +Word Profile 还会缩小三类字号、降低非目标粒子数量,并启用宽度感知排版、 +字形边缘连接、Bounds 分离、屏幕边界和鼠标命中。全屏 Actor 故障/老虎机演出 +仍保持原来的字符级视觉。 ## 内容校验 @@ -85,12 +98,15 @@ Express 使用短前缀 `hs.exp.*`: - Stage6 正式 Express 文字参数不得残留中文硬编码; - Yarn 和 Catalog 中引用的 `l10n.*` Key 必须存在于 `Params` Shared Data; +- Profile Locale Code 必须唯一,所有支持 Locale 必须可解析; - 中文值必须非空; - 未翻译 Locale 的空值只报告待翻译,不导致校验失败; -- `.tokens` 至少包含一个有效项,且不能使用全角 `|`; +- 每个非空 Locale 的目标文字和 `.tokens` 必须按该 Locale 模式得到有效单位, + `.tokens` 不能使用全角 `|`; - `hs.exp.pool` 必须存在于所有 Params Locale 表;中文非空,非空译文不得 - 包含 `|`、`|` 或只有空白; + 包含 `|`、`|` 或只有空白,并按 Locale 模式验证; - completion node、preset、memory key、颜色、Timeline 等结构参数不误报。 -运行时和 EditMode 测试同时覆盖原始字符串、空值不回退、缺 Key 占位、 -显式 Locale、轮次配置、token 解析和 Unicode 文本元素行为。 +EditMode 与 PlayMode 测试覆盖原始字符串、空值不回退、缺 Key 占位、显式 +Locale、轮次配置、Locale/Profile 解析、Word/Grapheme 切分、快照、粒子 +数量倍率,以及真实粒子组件的 Bounds 命中和字形边缘连接行为。