feat: 火山表达维修粒子字符池拆分
This commit is contained in:
@@ -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(
|
||||
"noise|doubt",
|
||||
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
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 57d812538c3e4bddafe59f8813562f46
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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">非目标候选粒子总数;<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 时候选池大小 = 目标句字数 + 该值;<0 时使用 Inspector 的 Candidate Count。
|
||||
/// <param name="baseNonTargetParticleCount">
|
||||
/// 轮次配置中的基础非目标粒子数。≥0 时先应用 Locale Profile 数量倍率;
|
||||
/// <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()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84ac13641a36486d8f1a891bad6b1f28
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
@@ -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 命中和字形边缘连接行为。
|
||||
|
||||
Reference in New Issue
Block a user