feat: 火山表达维修粒子字符池拆分

This commit is contained in:
2026-07-29 13:00:59 +08:00
parent a48e4babd4
commit 8f2ee603d5
26 changed files with 1830 additions and 401 deletions
@@ -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<ExpressionParticleLanguageProfile>(
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<string> 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(
"noisedoubt",
ExpressionParticleUnitMode.Word,
out _),
Is.False);
}
[Test]
public void RoundSnapshot_IsParsedOnceAndKeepsItsLocaleAndWordUnits()
{
ExpressionParticleLanguageProfile languageProfile =
AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
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<ExpressionParticleLanguageProfile>(
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<CandidateParticle>();
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<CandidateParticle>();
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<string>)null);
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo(combining));
}
finally
{
@@ -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*<<(?<command>[A-Za-z0-9_]+)(?<arguments>.*?)>>", 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<ExpressionLocalizationIssue> issues)
{
ExpressionParticleLanguageProfile profile =
AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
ParticleProfilePath);
if (profile == null)
{
AddError(issues, $"找不到粒子语言 Profile{ParticleProfilePath}");
return null;
}
foreach (string validationError in profile.GetValidationErrors())
AddError(issues, validationError);
foreach (string localeCode in SupportedLocaleCodes)
{
bool canResolve = profile.Locales.Any(settings =>
{
if (settings == null ||
string.IsNullOrWhiteSpace(settings.LocaleCode))
{
return false;
}
if (string.Equals(
settings.LocaleCode,
localeCode,
StringComparison.OrdinalIgnoreCase))
{
return true;
}
string requestedLanguage =
localeCode.Split('-')[0];
string configuredLanguage =
settings.LocaleCode.Split('-')[0];
return string.Equals(
requestedLanguage,
configuredLanguage,
StringComparison.OrdinalIgnoreCase);
});
if (!canResolve)
{
AddError(
issues,
$"粒子语言 Profile 无法解析支持的 Locale{localeCode}");
}
}
return profile;
}
private static void ValidateStage6(
List<ExpressionLocalizationIssue> issues,
HashSet<string> referencedKeys)
@@ -190,7 +258,8 @@ namespace AibisDream.EditorTests.Huoshan
private static void ValidateParams(
List<ExpressionLocalizationIssue> issues,
HashSet<string> referencedKeys)
HashSet<string> 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<ExpressionLocalizationIssue> 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<ExpressionLocalizationIssue> 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<ExpressionLocalizationIssue> 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<ExpressionLocalizationIssue> 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<ExpressionLocalizationIssue> 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<string> referencedKeys)
@@ -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
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 57d812538c3e4bddafe59f8813562f46
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+1
View File
@@ -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
@@ -233,7 +233,7 @@ namespace AibisDream.MiniGame.Language
/// 目标粒子:从本轮本地化默认池随机;非目标粒子:从笑话/干扰短句字符中随机。
/// 使用 IsTarget 判断,与红蓝颜色解绑。
/// </summary>
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();
}
}
}
@@ -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;
@@ -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
/// <summary>
/// 启动粒子系统(在打开视图后调用,通常通过 Yarn 对话控制)
/// </summary>
/// <param name="anxietyPhrases">干扰短句列表(笑话等,非目标粒子从中随机取字符)</param>
/// <param name="targetSentence">目标句子</param>
/// <param name="defaultCharacterPool">当前 Locale 的默认随机字符池</param>
/// <param name="snapshot">当前 Locale 已完成粒子单位解析的轮次快照</param>
/// <param name="completionNodeName">完成时触发的对话节点名称(可选)</param>
/// <param name="nonTargetParticleTotal">非目标候选粒子总数;&lt;0 时使用 Inspector 默认候选池大小</param>
/// <param name="baseNonTargetParticleCount">轮次配置中的基础非目标粒子数</param>
public void StartSystem(
List<string> anxietyPhrases,
string targetSentence,
IReadOnlyList<string> 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
}
}
/// <summary>
/// 启动粒子系统(字符串数组版本,方便 Yarn 调用)
/// </summary>
public void StartSystem(
string[] anxietyPhrases,
string targetSentence,
IReadOnlyList<string> defaultCharacterPool,
string completionNodeName = null,
int nonTargetParticleTotal = -1)
{
List<string> phrasesList = anxietyPhrases != null ? new List<string>(anxietyPhrases) : null;
StartSystem(
phrasesList,
targetSentence,
defaultCharacterPool,
completionNodeName,
nonTargetParticleTotal);
}
/// <summary>
/// 若火山释放 log 界面已淡出,则先淡入再返回(start_expression 时调用)
/// </summary>
@@ -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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 34ce433966a94546a460c32821f6e741
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<ExpressionParticleLocaleSettings> locales = new();
private readonly HashSet<string> warnedFallbackLocales =
new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<ExpressionParticleLocaleSettings> 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<string> GetValidationErrors()
{
var errors = new List<string>();
var codes = new HashSet<string>(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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3a7f6c18f2184d09b551e103c67268a1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<string> targetUnits,
List<float> targetGapWeights,
List<string> defaultPoolUnits,
List<string> 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<string> TargetUnits { get; }
public IReadOnlyList<float> TargetGapWeights { get; }
public IReadOnlyList<string> DefaultPoolUnits { get; }
public IReadOnlyList<string> InterferencePoolUnits { get; }
public static bool TryCreate(
LocaleIdentifier locale,
ExpressionParticleLocaleSettings profile,
string targetText,
IEnumerable<string> 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<string> defaultUnits))
{
error = "默认粒子池为空或包含非法分隔符。";
return false;
}
List<string> interferenceUnits =
ExpressionTextTokenizer.BuildUnitPool(
interferencePhrases,
profile.UnitMode);
if (interferenceUnits.Count == 0)
{
error = "干扰短句没有有效的粒子单位。";
return false;
}
snapshot = new ExpressionRoundTextSnapshot(
locale,
profile,
targetText,
new List<string>(target.Units),
new List<float>(target.GapWeights),
defaultUnits,
interferenceUnits);
error = null;
return true;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1972c8c6ecaa4512b9cc891a82a5d608
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
/// </summary>
public static class ExpressionTextTokenizer
{
public readonly struct UnitSequence
{
public UnitSequence(List<string> units, List<float> gapWeights)
{
Units = units;
GapWeights = gapWeights;
}
public IReadOnlyList<string> Units { get; }
public IReadOnlyList<float> GapWeights { get; }
public int Count => Units?.Count ?? 0;
}
public readonly struct Layout
{
public Layout(List<string> visibleElements, List<float> offsets)
@@ -40,16 +54,56 @@ namespace AibisDream.MiniGame.Language
}
public static List<string> BuildCharacterPool(IEnumerable<string> texts)
{
return BuildUnitPool(texts, ExpressionParticleUnitMode.Grapheme);
}
public static List<string> TokenizeText(
string text,
ExpressionParticleUnitMode mode)
{
return new List<string>(BuildLayoutUnits(text, mode).Units);
}
public static bool TryTokenizePool(
string pool,
ExpressionParticleUnitMode mode,
out List<string> units)
{
units = new List<string>();
if (string.IsNullOrWhiteSpace(pool) ||
pool.Contains("|") ||
pool.Contains(""))
{
return false;
}
units = TokenizeText(pool, mode);
return units.Count > 0;
}
public static List<string> BuildUnitPool(
IEnumerable<string> texts,
ExpressionParticleUnitMode mode)
{
var result = new List<string>();
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<string> source,
IReadOnlyList<string> fragment)
{
return FindVisibleSequence(source, fragment);
}
private static UnitSequence BuildGraphemeUnits(string text)
{
var units = new List<string>();
var gaps = new List<float>();
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<string>();
var gaps = new List<float>();
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<string> units,
List<float> gaps,
bool hasLeadingSeparator)
{
if (word.Length == 0)
return;
units.Add(word.ToString());
gaps.Add(units.Count == 1 ? 0f : hasLeadingSeparator ? 1f : 0f);
word.Clear();
}
}
}
@@ -42,7 +42,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 背景粒子从干扰短句(笑话等)字符中随机显示单字。
/// </summary>
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();
}
}
@@ -64,10 +64,9 @@ namespace AibisDream.MiniGame.Language
[SerializeField] private float textMargin = 1f;
[Header("游戏配置")]
private List<string> anxietyPhrases = new List<string>();
private string targetSentence = string.Empty;
private IReadOnlyList<string> defaultCharacterPool = new List<string>().AsReadOnly();
private IReadOnlyList<string> interferenceCharacterPool = new List<string>().AsReadOnly();
private ExpressionRoundTextSnapshot activeSnapshot;
private IReadOnlyList<string> defaultUnitPool = new List<string>().AsReadOnly();
private IReadOnlyList<string> interferenceUnitPool = new List<string>().AsReadOnly();
[SerializeField] private string completionDialogNode = ""; // 完成时触发的对话节点
[Header("波形参数")]
@@ -222,7 +221,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>完成阶段屏幕钳制用 SmoothDamp 速度缓存(按粒子)</summary>
private readonly Dictionary<CandidateParticle, Vector3> completionClampSmoothVelocity = new Dictionary<CandidateParticle, Vector3>();
private Vector3 focusCentroid;
private List<string> finalTargetCharacters = new List<string>();
private List<string> finalTargetUnits = new List<string>();
private List<Vector3> finalTargetPositions = new List<Vector3>();
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
/// <summary>Inspector 中的候选粒子总数,用于未在 Yarn 中指定非目标数量时恢复默认</summary>
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
/// <summary>
/// 初始化系统(可以由外部调用,用于配置游戏参数)
/// </summary>
/// <param name="phrases">焦虑短句列表</param>
/// <param name="sentence">目标句子</param>
/// <param name="localizedDefaultPool">当前 Locale 的默认随机字符池</param>
/// <param name="snapshot">当前 Locale 已完成粒子单位解析的轮次快照</param>
/// <param name="dialogNode">完成时触发的对话节点</param>
/// <param name="nonTargetParticleTotal">
/// 非目标候选粒子总数(蓝/干扰侧可交互粒子数量)。≥0 时候选池大小 = 目标句字数 + 该值;&lt;0 时使用 Inspector 的 Candidate Count。
/// <param name="baseNonTargetParticleCount">
/// 轮次配置中的基础非目标粒子数。≥0 时先应用 Locale Profile 数量倍率;
/// &lt;0 时使用 Inspector 的 Candidate Count。
/// </param>
public void InitializeSystem(
List<string> phrases,
string sentence,
IReadOnlyList<string> 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<string> resolvedDefaultPool =
ExpressionTextTokenizer.BuildCharacterPool(localizedDefaultPool);
List<string> 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<string>(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<FloatingTextParticle>();
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<CandidateParticle>();
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();
}
/// <summary>
@@ -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<string> targetUnits =
activeSnapshot?.TargetUnits ?? new List<string>().AsReadOnly();
IReadOnlyList<float> targetGapWeights =
activeSnapshot?.TargetGapWeights ?? new List<float>().AsReadOnly();
List<Vector3> 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<string> poolElements =
ExpressionTextTokenizer.TokenizeText(particlePool, ActiveUnitMode);
if (poolElements.Count == 0)
{
Debug.LogWarning("[LanguageParticleManager] 老虎机换字字符池为空;已安全跳过。");
return;
}
IReadOnlyList<string> 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<string>)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<string>)null);
}
public void PrepareTruthReleaseFromLie()
@@ -2211,7 +2259,7 @@ namespace AibisDream.MiniGame.Language
List<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话失稳"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
List<string> 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<Vector3> 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<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话攻击"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
List<string> 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<Vector3> 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<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话稳定"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
List<string> 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<Vector3> 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<CandidateParticle> 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<Vector3> CalculatePresentationPhrasePositions(string text)
{
float spacing = Mathf.Max(0.1f, truthAttackCharacterSpacing);
ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout(
text,
spacing,
whitespaceSpacingMultiplier);
var positions = new List<Vector3>(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<string> TokenizeActiveText(string text)
{
return ExpressionTextTokenizer.TokenizeText(text, ActiveUnitMode);
}
private List<Vector3> CalculatePhrasePositions(
IReadOnlyList<string> units,
IReadOnlyList<float> gapWeights)
{
int count = units?.Count ?? 0;
var offsets = new List<float>(count);
var positions = new List<Vector3>(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<string> characters,
IReadOnlyList<string> units,
List<Vector3> 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<string> targetElements = ExpressionTextTokenizer.GetVisibleElements(targetSentence);
List<string> fragmentElements = ExpressionTextTokenizer.GetVisibleElements(fragment);
int startIndex = ExpressionTextTokenizer.FindVisibleSequence(targetElements, fragmentElements);
IReadOnlyList<string> targetElements =
activeSnapshot?.TargetUnits ?? new List<string>().AsReadOnly();
List<string> 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;
@@ -90,18 +90,20 @@ namespace AibisDream.MiniGame.Language
public static bool TryParseExpressionPool(
string value,
out List<string> characters)
out List<string> units)
{
characters = new List<string>();
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<string> 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<string> 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<string> 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();
@@ -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<string> EmptyCharacterPool =
new List<string>().AsReadOnly();
protected IReadOnlyList<string> defaultCharacterPool = EmptyCharacterPool;
protected IReadOnlyList<string> interferenceCharacterPool = EmptyCharacterPool;
protected IReadOnlyList<string> overrideCharacterPool = EmptyCharacterPool;
protected IReadOnlyList<string> defaultUnitPool = EmptyCharacterPool;
protected IReadOnlyList<string> interferenceUnitPool = EmptyCharacterPool;
protected IReadOnlyList<string> 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);
}
/// <summary>
@@ -215,16 +247,16 @@ namespace AibisDream.MiniGame.Language
/// </summary>
protected string GetRandomCharFromPhrases()
{
if (interferenceCharacterPool == null || interferenceCharacterPool.Count == 0)
if (interferenceUnitPool == null || interferenceUnitPool.Count == 0)
return GetRandomChar();
return GetRandomPoolElement(interferenceCharacterPool);
return GetRandomPoolElement(interferenceUnitPool);
}
/// <summary>
/// 获取下一次要显示的字符。子类可重写以区分目标粒子与非目标粒子(与红蓝颜色解绑)。
/// 默认从本轮本地化字符池随机返回一个 Unicode 文本元素。
/// </summary>
protected virtual string GetNextDisplayChar()
protected virtual string GetNextDisplayUnit()
{
if (TryGetOverridePoolChar(out string overrideChar))
return overrideChar;
@@ -234,19 +266,25 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 设置换字字符池覆盖(老虎机演出用);传空/null 清除,恢复默认字符池。
/// </summary>
public void SetOverrideCharPool(string pool)
public void SetOverrideUnitPool(string pool)
{
SetOverrideCharacterPool(
SetOverrideUnitPool(
ExpressionTextTokenizer.GetVisibleElements(pool));
}
public void SetOverrideCharacterPool(IReadOnlyList<string> pool)
public void SetOverrideUnitPool(IReadOnlyList<string> pool)
{
overrideCharacterPool = pool != null && pool.Count > 0
overrideUnitPool = pool != null && pool.Count > 0
? pool
: EmptyCharacterPool;
}
[System.Obsolete("Use SetOverrideUnitPool instead.")]
public void SetOverrideCharacterPool(IReadOnlyList<string> 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;
}
/// <summary>
/// 设置本轮默认与干扰字符池。调用方负责传入不可变的本轮快照。
/// </summary>
public void SetCharacterPools(
public void SetUnitPools(
IReadOnlyList<string> defaultPool,
IReadOnlyList<string> 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<string> defaultCharacters,
IReadOnlyList<string> 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<string> 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()
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 84ac13641a36486d8f1a891bad6b1f28
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f6244beee7e24912bd0e6019ac802f92
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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"
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e93af359c134479a88d141136397693e
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<object>()
.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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 26043594097949158ba1572f70c94f78
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+29 -13
View File
@@ -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 命中和字形边缘连接行为。