feat: 硬编码本地化

This commit is contained in:
2026-07-28 21:34:42 +08:00
parent ca5b714124
commit a48e4babd4
37 changed files with 558 additions and 732 deletions
@@ -23,6 +23,18 @@ MonoBehaviour:
m_SerializedLabels:
- Locale-pt-BR
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 6ad3c9298dbc3b642b1251ffc306eaa6
m_Address: Params_pt-BR
m_ReadOnly: 1
m_SerializedLabels:
- Locale-pt-BR
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 7d496f91e240bb143b62a6239c817d09
m_Address: UIText_pt-BR
m_ReadOnly: 1
m_SerializedLabels:
- Locale-pt-BR
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -23,6 +23,18 @@ MonoBehaviour:
m_SerializedLabels:
- Locale-ru
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: b91f4281442d2b54f92fbecb5bd56426
m_Address: Params_ru
m_ReadOnly: 1
m_SerializedLabels:
- Locale-ru
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: f679069e455200841a92c27186383940
m_Address: UIText_ru
m_ReadOnly: 1
m_SerializedLabels:
- Locale-ru
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -23,6 +23,18 @@ MonoBehaviour:
m_SerializedLabels:
- Locale-es
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 1edf9254b97981643bdecd6d22ed1150
m_Address: Params_es
m_ReadOnly: 1
m_SerializedLabels:
- Locale-es
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 380c27123212d66468aa1f3ed3bf72ea
m_Address: UIText_es
m_ReadOnly: 1
m_SerializedLabels:
- Locale-es
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -41,6 +41,26 @@ namespace AibisDream.EditorTests.Huoshan
Assert.That(LocalizationKit.GetL10NParamKey("raw"), Is.EqualTo("raw"));
}
[Test]
public void MissingParamResult_RequiresMatchingLocalizedReference()
{
Assert.That(
LocalizationKit.IsMissingParamResult(
"l10n.hs.exp.pool",
"⟦hs.exp.pool⟧"),
Is.True);
Assert.That(
LocalizationKit.IsMissingParamResult(
"l10n.hs.exp.pool",
"⟦another.key⟧"),
Is.False);
Assert.That(
LocalizationKit.IsMissingParamResult(
"raw",
"⟦raw⟧"),
Is.False);
}
[UnityTest]
public IEnumerator LocalizeParamAsync_UsesExplicitLocaleAndDoesNotFallback()
{
@@ -111,7 +131,6 @@ namespace AibisDream.EditorTests.Huoshan
AssetDatabase.LoadAssetAtPath<ExpressionContentCatalog>(CatalogPath);
Assert.That(catalog, Is.Not.Null);
Assert.That(catalog.GetValidationErrors(), Is.Empty);
Assert.That(catalog.ActorLoopReference, Is.EqualTo("l10n.hs.exp.actor.loop"));
Assert.That(catalog.Rounds.Select(round => round.Id),
Is.EqualTo(new[] { "log1", "log2", "log3" }));
@@ -128,7 +147,6 @@ namespace AibisDream.EditorTests.Huoshan
ScriptableObject.CreateInstance<ExpressionContentCatalog>();
try
{
SetPrivateField(catalog, "actorLoopReference", "raw");
var first = new ExpressionRoundDefinition();
var second = new ExpressionRoundDefinition();
ConfigureRound(first, "log1", "l10n.target", "l10n.tokens", 1);
@@ -139,7 +157,6 @@ namespace AibisDream.EditorTests.Huoshan
new List<ExpressionRoundDefinition> { first, second });
string errors = string.Join("\n", catalog.GetValidationErrors());
Assert.That(errors, Does.Contain("Actor Loop"));
Assert.That(errors, Does.Contain("重复"));
Assert.That(errors, Does.Contain("l10n.*"));
}
@@ -168,6 +185,100 @@ namespace AibisDream.EditorTests.Huoshan
Is.False);
}
[Test]
public void CharacterPoolParser_PreservesUnicodeDuplicatesAndRejectsSeparators()
{
string combining = "e\u0301";
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
$" 中 中 あ {combining} 😀。 ",
out List<string> pool),
Is.True);
Assert.That(
pool,
Is.EqualTo(new[] { "中", "中", "あ", combining, "😀", "。" }));
Assert.That(
LanguageYarnCommand.TryParseExpressionPool("中|あ", out _),
Is.False);
Assert.That(
LanguageYarnCommand.TryParseExpressionPool("中|あ", out _),
Is.False);
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(" \t ", out _),
Is.False);
}
[Test]
public void Params_ContainsLocalizedExpressPoolForEveryLocale()
{
StringTableCollection collection =
LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable);
Assert.That(collection, Is.Not.Null);
string key =
LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam);
Assert.That(collection.SharedData.GetEntry(key), Is.Not.Null);
string[] localeCodes = { "zh-Hans", "en", "ja-JP", "es", "ru", "pt-BR" };
foreach (string localeCode in localeCodes)
{
StringTable table =
collection.GetTable(new LocaleIdentifier(localeCode)) as StringTable;
Assert.That(table, Is.Not.Null, localeCode);
Assert.That(table.GetEntry(key), Is.Not.Null, localeCode);
}
StringTable chinese =
collection.GetTable(new LocaleIdentifier("zh-Hans")) as StringTable;
Assert.That(
ExpressionTextTokenizer.GetVisibleElements(
chinese.GetEntry(key).LocalizedValue),
Is.Not.Empty);
foreach (string localeCode in localeCodes.Skip(1))
{
StringTable table =
collection.GetTable(new LocaleIdentifier(localeCode)) as StringTable;
Assert.That(table.GetEntry(key).LocalizedValue, Is.Empty, localeCode);
}
}
[Test]
public void TextParticle_UsesConfiguredRoleAndOverridePoolsWithoutFirstFrameFallback()
{
var gameObject = new GameObject("TextParticlePoolTest");
try
{
CandidateParticle particle = gameObject.AddComponent<CandidateParticle>();
Assert.That(particle.currentChar, Is.Empty);
string combining = "e\u0301";
particle.SetCharacterPools(
new[] { "😀" },
new[] { combining });
particle.originalIsRed = true;
particle.InitializeRandomCharacter();
Assert.That(particle.currentChar, Is.EqualTo("😀"));
particle.originalIsRed = false;
particle.InitializeRandomCharacter();
Assert.That(particle.currentChar, Is.EqualTo(combining));
particle.SetOverrideCharacterPool(new[] { "。" });
particle.InitializeRandomCharacter();
Assert.That(particle.currentChar, Is.EqualTo("。"));
particle.SetOverrideCharacterPool(null);
particle.InitializeRandomCharacter();
Assert.That(particle.currentChar, Is.EqualTo(combining));
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void UnicodeTokenizer_PreservesTextElementsAndDropsWhitespace()
{
@@ -221,7 +332,7 @@ namespace AibisDream.EditorTests.Huoshan
issues.Any(issue =>
issue.Severity == ExpressionLocalizationIssueSeverity.Warning),
Is.True,
"英日空条目应作为待翻译警告保留。");
"未翻译 Locale 的空条目应作为待翻译警告保留。");
}
private static void ConfigureRound(
@@ -36,7 +36,7 @@ namespace AibisDream.EditorTests.Huoshan
/// <summary>
/// 校验 Stage6 Express 命令、轮次 Catalog 与 Params String Table 的引用关系。
/// 英日空值是待翻译警告,不阻断内容校验。
/// 未翻译 Locale 的空值是待翻译警告,不阻断内容校验。
/// </summary>
public static class ExpressionLocalizationValidator
{
@@ -60,7 +60,6 @@ namespace AibisDream.EditorTests.Huoshan
new(StringComparer.Ordinal)
{
["start_expression"] = new[] { 0, 1 },
["expression_lie_begin"] = new[] { 0 },
["expression_truth_lie_flicker"] = new[] { 0, 1 },
["expression_lie_morph_truth"] = new[] { 0, 1 },
["expression_truth_leak"] = new[] { 0 },
@@ -109,6 +108,8 @@ namespace AibisDream.EditorTests.Huoshan
ValidateStage6(issues, referencedKeys);
ValidateCatalog(issues, referencedKeys);
referencedKeys.Add(
LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam));
ValidateParams(issues, referencedKeys);
return issues;
}
@@ -178,7 +179,6 @@ namespace AibisDream.EditorTests.Huoshan
foreach (string validationError in catalog.GetValidationErrors())
AddError(issues, validationError);
AddCatalogReference(catalog.ActorLoopReference, referencedKeys);
foreach (ExpressionRoundDefinition round in catalog.Rounds)
{
if (round == null)
@@ -206,6 +206,14 @@ namespace AibisDream.EditorTests.Huoshan
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;
string poolKey =
LocalizationKit.GetL10NParamKey(ConstRef.ExpressParticlePoolParam);
foreach (string key in referencedKeys.OrderBy(value => value, StringComparer.Ordinal))
{
@@ -222,14 +230,51 @@ namespace AibisDream.EditorTests.Huoshan
continue;
}
if (key.EndsWith(".tokens", StringComparison.Ordinal) ||
string.Equals(key, "hs.exp.actor.loop", StringComparison.Ordinal))
if (key.EndsWith(".tokens", StringComparison.Ordinal))
{
ValidatePipeList(issues, key, chineseEntry.LocalizedValue);
}
AddPendingTranslation(issues, english, "en", key);
AddPendingTranslation(issues, japanese, "ja-JP", key);
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);
}
}
}
private static void ValidateCharacterPool(
List<ExpressionLocalizationIssue> issues,
string key,
string localeCode,
string value)
{
if (value.Contains('|') || value.Contains(''))
{
AddError(
issues,
$"Params/{key}, Locale={localeCode} 的字符池不能包含“|”或“|”。");
return;
}
if (!LanguageYarnCommand.TryParseExpressionPool(value, out _))
{
AddError(
issues,
$"Params/{key}, Locale={localeCode} 没有有效的 Unicode 文本元素。");
}
}
@@ -272,6 +317,34 @@ namespace AibisDream.EditorTests.Huoshan
}
}
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)
@@ -105,17 +105,12 @@ namespace AibisDream.EditorTests.Huoshan
Assert.That(lastCompletion, Is.EqualTo(totalDuration).Within(0.0001f));
}
[TestCase("LightPreset", 0.10f, 0.35f, 0.25f, 0.25f, 0.70f, 0.15f)]
[TestCase("MediumPreset", 0.20f, 0.55f, 0.50f, 0.35f, 0.90f, 0.30f)]
[TestCase("HeavyPreset", 0.35f, 0.80f, 0.85f, 0.50f, 1.10f, 0.50f)]
[TestCase("LightPreset", 0.10f)]
[TestCase("MediumPreset", 0.20f)]
[TestCase("HeavyPreset", 0.35f)]
public void PresentationPreset_UsesApprovedValues(
string presetFieldName,
float baseDistortion,
float liePause,
float breakPeak,
float breakDuration,
float truthDuration,
float truthPeripheral)
float baseDistortion)
{
const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic;
FieldInfo presetField = typeof(LogReleasePresentationController).GetField(presetFieldName, flags);
@@ -124,11 +119,6 @@ namespace AibisDream.EditorTests.Huoshan
Type presetType = preset.GetType();
AssertPresetField(presetType, preset, "BaseDistortion", baseDistortion);
AssertPresetField(presetType, preset, "LiePause", liePause);
AssertPresetField(presetType, preset, "BreakPeak", breakPeak);
AssertPresetField(presetType, preset, "BreakDuration", breakDuration);
AssertPresetField(presetType, preset, "TruthDuration", truthDuration);
AssertPresetField(presetType, preset, "TruthPeripheral", truthPeripheral);
}
[Test]
Binary file not shown.
@@ -12,7 +12,6 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: a86a72005fba4c99a59962fb9e92df83, type: 3}
m_Name: ExpressionContentCatalog
m_EditorClassIdentifier:
actorLoopReference: l10n.hs.exp.actor.loop
rounds:
- id: log1
targetReference: l10n.hs.exp.log1.target
@@ -67,10 +67,6 @@ MonoBehaviour:
m_Key: hs.exp.log3.tokens
m_Metadata:
m_Items: []
- m_Id: 200000000000001007
m_Key: hs.exp.actor.loop
m_Metadata:
m_Items: []
- m_Id: 200000000000001008
m_Key: hs.exp.atk.why
m_Metadata:
@@ -155,6 +151,10 @@ MonoBehaviour:
m_Key: hs.exp.final.win
m_Metadata:
m_Items: []
- m_Id: 200000000000001029
m_Key: hs.exp.pool
m_Metadata:
m_Items: []
m_Metadata:
m_Items: []
m_KeyGenerator:
+3
View File
@@ -17,6 +17,9 @@ MonoBehaviour:
- {fileID: 11400000, guid: 9248cb179e7aafa4c903f1fc54b41ef7, type: 2}
- {fileID: 11400000, guid: aeb318c895d57014b89d5540c6baff95, type: 2}
- {fileID: 11400000, guid: 9ed23d1d910a1a147ba5c316a87cbad4, type: 2}
- {fileID: 11400000, guid: 6ad3c9298dbc3b642b1251ffc306eaa6, type: 2}
- {fileID: 11400000, guid: b91f4281442d2b54f92fbecb5bd56426, type: 2}
- {fileID: 11400000, guid: 1edf9254b97981643bdecd6d22ed1150, type: 2}
m_Extensions: []
m_Group: String Table
references:
+4 -4
View File
@@ -70,10 +70,6 @@ MonoBehaviour:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001007
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001008
m_Localized:
m_Metadata:
@@ -158,6 +154,10 @@ MonoBehaviour:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001029
m_Localized:
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
+5 -1
View File
@@ -17,7 +17,11 @@ MonoBehaviour:
m_SharedData: {fileID: 11400000, guid: 4a2d391471bd9cd439150c52659e9846, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
m_TableData:
- m_Id: 200000000000001029
m_Localized:
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
+4 -4
View File
@@ -70,10 +70,6 @@ MonoBehaviour:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001007
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001008
m_Localized:
m_Metadata:
@@ -158,6 +154,10 @@ MonoBehaviour:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000001029
m_Localized:
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
+5 -1
View File
@@ -17,7 +17,11 @@ MonoBehaviour:
m_SharedData: {fileID: 11400000, guid: 4a2d391471bd9cd439150c52659e9846, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
m_TableData:
- m_Id: 200000000000001029
m_Localized:
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
+5 -1
View File
@@ -17,7 +17,11 @@ MonoBehaviour:
m_SharedData: {fileID: 11400000, guid: 4a2d391471bd9cd439150c52659e9846, type: 2}
m_Metadata:
m_Items: []
m_TableData: []
m_TableData:
- m_Id: 200000000000001029
m_Localized:
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
+4 -4
View File
@@ -70,10 +70,6 @@ MonoBehaviour:
m_Localized: "\u54C8\u54C8\u54C8\u54C8|\u7B11\u6B7B\u6211\u4E86|\u592A\u597D\u7B11\u4E86|\u7EF7\u4E0D\u4F4F\u4E86|\u7B11\u4E0D\u6D3B\u4E86|\u771F\u7684\u592A\u641E\u7B11"
m_Metadata:
m_Items: []
- m_Id: 200000000000001007
m_Localized: "\u6CA1\u95EE\u9898|\u5187\u95EE\u9898|\u5E3D\u95EE\u9898"
m_Metadata:
m_Items: []
- m_Id: 200000000000001008
m_Localized: "\u4E3A\u4EC0\u4E48"
m_Metadata:
@@ -158,6 +154,10 @@ MonoBehaviour:
m_Localized: "\u6211\u60F3\u8D62\u4E00\u6B21"
m_Metadata:
m_Items: []
- m_Id: 200000000000001029
m_Localized: 的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严龙飞
m_Metadata:
m_Items: []
references:
version: 2
RefIds: []
@@ -19,10 +19,6 @@ MonoBehaviour:
m_Key: start_newGame
m_Metadata:
m_Items: []
- m_Id: 22296654778368
m_Key: start_title
m_Metadata:
m_Items: []
- m_Id: 22296671555584
m_Key: start_selcetLevel
m_Metadata:
@@ -31,38 +27,10 @@ MonoBehaviour:
m_Key: start_quit
m_Metadata:
m_Items: []
- m_Id: 22296675749889
m_Key: start_survey
m_Metadata:
m_Items: []
- m_Id: 22296675749890
m_Key: community
m_Metadata:
m_Items: []
- m_Id: 22296675749891
m_Key: record_title
m_Metadata:
m_Items: []
- m_Id: 26730768109568
m_Key: record_continue
m_Metadata:
m_Items: []
- m_Id: 22296675749892
m_Key: main_setting
m_Metadata:
m_Items: []
- m_Id: 22296675749893
m_Key: main_record
m_Metadata:
m_Items: []
- m_Id: 22296675749894
m_Key: main_auto
m_Metadata:
m_Items: []
- m_Id: 22296675749895
m_Key: main_fast
m_Metadata:
m_Items: []
- m_Id: 22296675749896
m_Key: setting_title
m_Metadata:
@@ -119,10 +87,6 @@ MonoBehaviour:
m_Key: setting_textSpeed_fast
m_Metadata:
m_Items: []
- m_Id: 752706112196608
m_Key: save_warning
m_Metadata:
m_Items: []
- m_Id: 753317281009664
m_Key: save_return
m_Metadata:
+3
View File
@@ -17,6 +17,9 @@ MonoBehaviour:
- {fileID: 11400000, guid: f3e7269fc46911b4dbe7ea36aa77aa90, type: 2}
- {fileID: 11400000, guid: e6ecffb64cf71b44bbc44ebf958029cc, type: 2}
- {fileID: 11400000, guid: 7c757424270c91e43bf45198f69c983a, type: 2}
- {fileID: 11400000, guid: 7d496f91e240bb143b62a6239c817d09, type: 2}
- {fileID: 11400000, guid: f679069e455200841a92c27186383940, type: 2}
- {fileID: 11400000, guid: 380c27123212d66468aa1f3ed3bf72ea, type: 2}
m_Extensions: []
m_Group: String Table
references:
+6 -57
View File
@@ -16,18 +16,12 @@ MonoBehaviour:
m_Code: en
m_SharedData: {fileID: 11400000, guid: 994c69bf4b2403043bc9562f2d24ee56, type: 2}
m_Metadata:
m_Items:
- rid: 7917695930343620616
m_Items: []
m_TableData:
- m_Id: 3350325104640
m_Localized: New Game
m_Metadata:
m_Items: []
- m_Id: 22296654778368
m_Localized: All Our\nBroken Parts
m_Metadata:
m_Items:
- rid: 7917695930343620616
- m_Id: 22296671555584
m_Localized: Chapter Select
m_Metadata:
@@ -40,34 +34,6 @@ MonoBehaviour:
m_Localized: Exit
m_Metadata:
m_Items: []
- m_Id: 22296675749889
m_Localized: Questionnaire
m_Metadata:
m_Items: []
- m_Id: 22296675749890
m_Localized: Discord
m_Metadata:
m_Items: []
- m_Id: 22296675749891
m_Localized: Medical Records
m_Metadata:
m_Items: []
- m_Id: 22296675749892
m_Localized: Settings
m_Metadata:
m_Items: []
- m_Id: 22296675749893
m_Localized: Log
m_Metadata:
m_Items: []
- m_Id: 22296675749894
m_Localized: Auto
m_Metadata:
m_Items: []
- m_Id: 22296675749895
m_Localized: Fast Forward
m_Metadata:
m_Items: []
- m_Id: 22296675749896
m_Localized: <SETTINGS>
m_Metadata:
@@ -128,19 +94,6 @@ MonoBehaviour:
m_Localized: Fast
m_Metadata:
m_Items: []
- m_Id: 752706112196608
m_Localized: 'This is a temporary feature prepared for testing purposes.
If
this is your first playthrough, we recommend going back and clicking Start
Game to begin.
If you wish to select a chapter, please restart the
game before using this feature, as bugs may otherwise occur.'
m_Metadata:
m_Items: []
- m_Id: 753317281009664
m_Localized: Back
m_Metadata:
@@ -258,25 +211,21 @@ MonoBehaviour:
m_Metadata:
m_Items: []
- m_Id: 200000000000000001
m_Localized:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000002
m_Localized:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000003
m_Localized:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000004
m_Localized:
m_Localized:
m_Metadata:
m_Items: []
references:
version: 2
RefIds:
- rid: 7917695930343620616
type: {class: SmartFormatTag, ns: UnityEngine.Localization.Metadata, asm: Unity.Localization}
data:
m_Entries: 0010305847140000
RefIds: []
+4 -40
View File
@@ -22,10 +22,6 @@ MonoBehaviour:
m_Localized: "\u30CB\u30E5\u30FC\u30B2\u30FC\u30E0"
m_Metadata:
m_Items: []
- m_Id: 22296654778368
m_Localized: "\u611B\u3068\u30ED\u30DC\u30C3\u30C8\n\u4FEE\u7406\u6280\u8853"
m_Metadata:
m_Items: []
- m_Id: 22296671555584
m_Localized: "\u30C1\u30E3\u30D7\u30BF\u30FC\u9078\u629E"
m_Metadata:
@@ -38,38 +34,10 @@ MonoBehaviour:
m_Localized: "\u7D42\u4E86"
m_Metadata:
m_Items: []
- m_Id: 22296675749889
m_Localized: "\u30A2\u30F3\u30B1\u30FC\u30C8"
m_Metadata:
m_Items: []
- m_Id: 22296675749890
m_Localized: Discord
m_Metadata:
m_Items: []
- m_Id: 22296675749891
m_Localized: "\u30AB\u30EB\u30C6"
m_Metadata:
m_Items: []
- m_Id: 26730768109568
m_Localized: "\u623B\u308B"
m_Metadata:
m_Items: []
- m_Id: 22296675749892
m_Localized: "\u8A2D\u5B9A"
m_Metadata:
m_Items: []
- m_Id: 22296675749893
m_Localized: "\u30D0\u30C3\u30AF\u30ED\u30B0"
m_Metadata:
m_Items: []
- m_Id: 22296675749894
m_Localized: "\u81EA\u52D5"
m_Metadata:
m_Items: []
- m_Id: 22296675749895
m_Localized: "\u65E9\u9001\u308A"
m_Metadata:
m_Items: []
- m_Id: 22296675749896
m_Localized: "<\u8A2D\u5B9A>"
m_Metadata:
@@ -126,10 +94,6 @@ MonoBehaviour:
m_Localized: "\u9AD8\u901F"
m_Metadata:
m_Items: []
- m_Id: 752706112196608
m_Localized: "\u3053\u308C\u306F\u30C6\u30B9\u30C8\u7528\u306E\u4E00\u6642\u7684\u306A\u6A5F\u80FD\u3067\u3059\n\n\u521D\u3081\u3066\u30D7\u30EC\u30A4\u3059\u308B\u5834\u5408\u306F\u3001\u623B\u3063\u3066\u300C\u30B2\u30FC\u30E0\u30B9\u30BF\u30FC\u30C8\u300D\u304B\u3089\u59CB\u3081\u308B\u3053\u3068\u3092\u304A\u52E7\u3081\u3057\u307E\u3059\n\n\u30C1\u30E3\u30D7\u30BF\u30FC\u9078\u629E\u3092\u4F7F\u7528\u3057\u305F\u3044\u65B9\u306F\u3001\u30B2\u30FC\u30E0\u3092\u518D\u8D77\u52D5\u3057\u3066\u304B\u3089\u3054\u5229\u7528\u304F\u3060\u3055\u3044\u3002\u30D0\u30B0\u304C\u767A\u751F\u3059\u308B\u6050\u308C\u304C\u3042\u308A\u307E\u3059"
m_Metadata:
m_Items: []
- m_Id: 753317281009664
m_Localized: "\u623B\u308B"
m_Metadata:
@@ -247,19 +211,19 @@ MonoBehaviour:
m_Metadata:
m_Items: []
- m_Id: 200000000000000001
m_Localized:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000002
m_Localized:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000003
m_Localized:
m_Localized:
m_Metadata:
m_Items: []
- m_Id: 200000000000000004
m_Localized:
m_Localized:
m_Metadata:
m_Items: []
references:
+2 -46
View File
@@ -16,18 +16,12 @@ MonoBehaviour:
m_Code: zh-Hans
m_SharedData: {fileID: 11400000, guid: 994c69bf4b2403043bc9562f2d24ee56, type: 2}
m_Metadata:
m_Items:
- rid: 7917695939544875419
m_Items: []
m_TableData:
- m_Id: 3350325104640
m_Localized: "\u65B0\u6E38\u620F"
m_Metadata:
m_Items: []
- m_Id: 22296654778368
m_Localized: "\u7231\u4E0E\u673A\u5668\u4EBA\\n\u7EF4\u4FEE\u6280\u672F"
m_Metadata:
m_Items:
- rid: 7917695939544875419
- m_Id: 22296671555584
m_Localized: "\u9009\u62E9\u7AE0\u8282"
m_Metadata:
@@ -40,38 +34,10 @@ MonoBehaviour:
m_Localized: "\u9000\u51FA"
m_Metadata:
m_Items: []
- m_Id: 22296675749889
m_Localized: "\u95EE\u5377"
m_Metadata:
m_Items: []
- m_Id: 22296675749890
m_Localized: 325268983
m_Metadata:
m_Items: []
- m_Id: 22296675749891
m_Localized: "\u8BCA\u7597\u8BB0\u5F55"
m_Metadata:
m_Items: []
- m_Id: 26730768109568
m_Localized: "\u8FD4\u56DE"
m_Metadata:
m_Items: []
- m_Id: 22296675749892
m_Localized: "\u8BBE\u7F6E"
m_Metadata:
m_Items: []
- m_Id: 22296675749893
m_Localized: "\u8BB0\u5F55"
m_Metadata:
m_Items: []
- m_Id: 22296675749894
m_Localized: "\u81EA\u52A8"
m_Metadata:
m_Items: []
- m_Id: 22296675749895
m_Localized: "\u5FEB\u8FDB"
m_Metadata:
m_Items: []
- m_Id: 22296675749896
m_Localized: "<\u8BBE\u7F6E>"
m_Metadata:
@@ -128,12 +94,6 @@ MonoBehaviour:
m_Localized: "\u5FEB\u901F"
m_Metadata:
m_Items: []
- m_Id: 752706112196608
m_Localized: "\u8FD9\u662F\u4E00\u4E2A\u4E3A\u4FBF\u4E8E\u6D4B\u8BD5\u51C6\u5907\u7684\u4E34\u65F6\u529F\u80FD\n
\n \u5982\u679C\u9996\u6B21\u6E38\u73A9\uFF0C\u63A8\u8350\u60A8\u8FD4\u56DE\u5E76\u70B9\u51FB\u5F00\u59CB\u6E38\u620F\u76F4\u63A5\u5F00\u59CB\n
\n \u63A8\u8350\u60F3\u8981\u9009\u5173\u7684\u73A9\u5BB6\u91CD\u542F\u6E38\u620F\u540E\u518D\u4F7F\u7528\u672C\u529F\u80FD,\u5426\u5219\u53EF\u80FD\u51FA\u73B0bug"
m_Metadata:
m_Items: []
- m_Id: 753317281009664
m_Localized: "\u8FD4\u56DE"
m_Metadata:
@@ -268,8 +228,4 @@ MonoBehaviour:
m_Items: []
references:
version: 2
RefIds:
- rid: 7917695939544875419
type: {class: SmartFormatTag, ns: UnityEngine.Localization.Metadata, asm: Unity.Localization}
data:
m_Entries: 0010305847140000
RefIds: []
@@ -173,8 +173,6 @@ MonoBehaviour:
redParticleCount: 8
connectionDistance: 2
textMargin: 1
anxietyPhrases: []
targetSentence: "\u522B\u8FC7\u6765\u6211\u611F\u89C9\u5BB3\u6015"
completionDialogNode:
waveformAmplitude: 0.3
interactionRadius: 0.5
+1 -11
View File
@@ -16128,13 +16128,6 @@ MonoBehaviour:
logReleasePresentation: {fileID: 2138889543}
screenPresentation: {fileID: 2138889544}
contentCatalog: {fileID: 11400000, guid: b2d94952a18d4d178421691ab103f0fd, type: 2}
defaultAnxietyPhrases:
- "\u6211\u597D\u6015"
- "\u600E\u4E48\u529E"
- "\u4E0D\u884C"
- "\u597D\u96BE"
- "\u505A\u4E0D\u5230"
defaultTargetSentence: "\u8FD9\u662F\u4E00\u4E2A\u6D4B\u8BD5\u793A\u4F8B"
screenOverlayRenderer: {fileID: 2063129674}
volcanoOverlayRenderer: {fileID: 1944146357}
spriteOverlayFadeInAlpha: 1
@@ -16218,7 +16211,6 @@ MonoBehaviour:
memoryExitDuration: 0.45
faceDipRatio: 0.015
lieColor: {r: 0.66, g: 0.94, b: 1, a: 1}
errorColor: {r: 1, g: 0.34, b: 0.18, a: 1}
truthWarmColor: {r: 1, g: 1, b: 1, a: 1}
truthAccentColor: {r: 1, g: 0.84, b: 0.6, a: 1}
truthBackdropColor: {r: 0.3, g: 0.2, b: 0.1, a: 0.78}
@@ -18471,8 +18463,6 @@ MonoBehaviour:
redParticleCount: 8
connectionDistance: 1.8
textMargin: 0.45
anxietyPhrases: []
targetSentence: "\u522B\u8FC7\u6765\u6211\u611F\u89C9\u5BB3\u6015"
completionDialogNode:
waveformAmplitude: 0.3
interactionRadius: 0.5
@@ -18576,7 +18566,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "\u8F93\u51FA"
m_text:
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: f242fb3dde1933640859f1c54591c9c8, type: 2}
m_sharedMaterial: {fileID: -6659093157667844055, guid: f242fb3dde1933640859f1c54591c9c8, type: 2}
+1
View File
@@ -53,6 +53,7 @@ namespace AibisDream.Utility
public const string UITextTable = "UIText";
public const string ChapterInfoTable = "ChapterInfo";
public const string ParamsTable = "Params";
public const string ExpressParticlePoolParam = "l10n.hs.exp.pool";
#endregion
@@ -210,6 +210,22 @@ namespace AibisDream.Framework
return IsLocalizedParam(key) ? key[LocalizationPrefix.Length..] : key;
}
/// <summary>
/// 判断解析结果是否是当前 <c>l10n.</c> 引用对应的缺失标记。
/// 非本地化原始文本即使使用相同括号格式也不会被误判。
/// </summary>
public static bool IsMissingParamResult(string source, string localizedValue)
{
if (!IsLocalizedParam(source))
return false;
string key = GetL10NParamKey(source);
string marker = string.IsNullOrEmpty(key)
? "⟦empty⟧"
: $"⟦{key}⟧";
return string.Equals(localizedValue, marker, StringComparison.Ordinal);
}
/// <summary>
/// 确保本地化系统已初始化
/// </summary>
@@ -230,7 +230,7 @@ namespace AibisDream.MiniGame.Language
}
/// <summary>
/// 目标粒子:chineseChars 随机单字;非目标粒子:从笑话/干扰短句字符中随机。
/// 目标粒子:从本轮本地化默认池随机;非目标粒子:从笑话/干扰短句字符中随机。
/// 使用 IsTarget 判断,与红蓝颜色解绑。
/// </summary>
protected override string GetNextDisplayChar()
@@ -264,7 +264,7 @@ namespace AibisDream.MiniGame.Language
private void UpdateFocusInterferenceCharacters()
{
if (focusInterferenceProgress <= 0f ||
string.IsNullOrEmpty(overrideCharPool) ||
!HasOverrideCharacterPool ||
(!isStatic && !isCalmed))
{
return;
@@ -54,12 +54,10 @@ namespace AibisDream.MiniGame.Language
menuName = AibisAssetMenus.HuoShanExpressionContent)]
public sealed class ExpressionContentCatalog : ScriptableObject
{
[SerializeField] private string actorLoopReference;
[SerializeField] private List<ExpressionRoundDefinition> rounds = new();
private Dictionary<string, ExpressionRoundDefinition> roundById;
public string ActorLoopReference => actorLoopReference;
public IReadOnlyList<ExpressionRoundDefinition> Rounds => rounds;
public bool TryGetRound(string roundId, out ExpressionRoundDefinition definition)
@@ -77,9 +75,6 @@ namespace AibisDream.MiniGame.Language
public List<string> GetValidationErrors()
{
var errors = new List<string>();
if (!LocalizationKit.IsLocalizedParam(actorLoopReference))
errors.Add("Actor Loop 必须配置为 l10n.* 引用。");
var ids = new HashSet<string>(StringComparer.Ordinal);
if (rounds == null || rounds.Count == 0)
{
@@ -20,15 +20,6 @@ namespace AibisDream
[SerializeField] private ExpressionScreenPresentationController screenPresentation;
[SerializeField] private ExpressionContentCatalog contentCatalog;
[Header("默认配置")]
[SerializeField] private List<string> defaultAnxietyPhrases = new List<string>
{
"哈哈",
"嘿嘿",
"呵呵"
};
[SerializeField] private string defaultTargetSentence = "这是一个测试示例";
[Header("Sprite 淡入淡出")]
[SerializeField] private SpriteRenderer screenOverlayRenderer;
[SerializeField] private SpriteRenderer volcanoOverlayRenderer;
@@ -162,20 +153,37 @@ namespace AibisDream
/// </summary>
/// <param name="anxietyPhrases">干扰短句列表(笑话等,非目标粒子从中随机取字符)</param>
/// <param name="targetSentence">目标句子</param>
/// <param name="defaultCharacterPool">当前 Locale 的默认随机字符池</param>
/// <param name="completionNodeName">完成时触发的对话节点名称(可选)</param>
/// <param name="nonTargetParticleTotal">非目标候选粒子总数;&lt;0 时使用 Inspector 默认候选池大小</param>
public void StartSystem(List<string> anxietyPhrases, string targetSentence, string completionNodeName = null, int nonTargetParticleTotal = -1)
public void StartSystem(
List<string> anxietyPhrases,
string targetSentence,
IReadOnlyList<string> defaultCharacterPool,
string completionNodeName = null,
int nonTargetParticleTotal = -1)
{
HideSpriteOverlaysForMinigame();
if (particleManager != null)
{
particleManager.SetPresentationController(logReleasePresentation);
// 使用传入的配置,如果没有则使用默认配置
var phrases = anxietyPhrases ?? defaultAnxietyPhrases;
var sentence = targetSentence ?? defaultTargetSentence;
particleManager.InitializeSystem(phrases, sentence, completionNodeName, nonTargetParticleTotal);
if (anxietyPhrases == null || anxietyPhrases.Count == 0 ||
string.IsNullOrWhiteSpace(targetSentence) ||
defaultCharacterPool == null ||
defaultCharacterPool.Count == 0)
{
Debug.LogError(
"[ExpressionManager] 启动参数缺少目标文字、干扰 token 或默认字符池;系统未启动。");
return;
}
particleManager.InitializeSystem(
anxietyPhrases,
targetSentence,
defaultCharacterPool,
completionNodeName,
nonTargetParticleTotal);
}
}
@@ -211,10 +219,20 @@ namespace AibisDream
/// <summary>
/// 启动粒子系统(字符串数组版本,方便 Yarn 调用)
/// </summary>
public void StartSystem(string[] anxietyPhrases, string targetSentence, string completionNodeName = null, int nonTargetParticleTotal = -1)
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, completionNodeName, nonTargetParticleTotal);
StartSystem(
phrasesList,
targetSentence,
defaultCharacterPool,
completionNodeName,
nonTargetParticleTotal);
}
/// <summary>
@@ -921,28 +939,6 @@ namespace AibisDream
targetOpacity);
}
public IEnumerator BeginExpressionLie(string keyword, string preset)
{
if (logReleasePresentation == null)
{
Debug.LogError("[ExpressionManager] LogReleasePresentationController 未配置;谎话命令安全结束。");
yield break;
}
yield return logReleasePresentation.BeginLie(keyword, preset);
}
public IEnumerator BreakExpressionLie()
{
if (logReleasePresentation == null) yield break;
yield return logReleasePresentation.BreakLie();
}
public IEnumerator RevealExpressionTruth()
{
if (logReleasePresentation == null) yield break;
yield return logReleasePresentation.RevealTruth();
}
public IEnumerator EndExpressionMemory()
{
if (logReleasePresentation == null) yield break;
@@ -4,8 +4,8 @@ using System.Globalization;
namespace AibisDream.MiniGame.Language
{
/// <summary>
/// 将玩家可见文字按 Unicode 文本元素拆分。空白保留在布局中,但不生成粒子。
/// 随机粒子字符池不使用本类,维持现有行为
/// 将玩家可见文字按 Unicode 文本元素拆分。
/// 空白保留在布局中但不生成粒子;随机字符池直接忽略空白
/// </summary>
public static class ExpressionTextTokenizer
{
@@ -39,6 +39,17 @@ namespace AibisDream.MiniGame.Language
return result;
}
public static List<string> BuildCharacterPool(IEnumerable<string> texts)
{
var result = new List<string>();
if (texts == null)
return result;
foreach (string text in texts)
result.AddRange(GetVisibleElements(text));
return result;
}
public static Layout BuildLayout(
string text,
float characterSpacing,
@@ -64,8 +64,10 @@ namespace AibisDream.MiniGame.Language
[SerializeField] private float textMargin = 1f;
[Header("游戏配置")]
[SerializeField] private List<string> anxietyPhrases = new List<string>(); // 干扰短句(笑话等,用于非目标粒子,Yarn 会覆盖)
[SerializeField] private string targetSentence = "这是一个测试示例"; // 目标句子
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();
[SerializeField] private string completionDialogNode = ""; // 完成时触发的对话节点
[Header("波形参数")]
@@ -346,15 +348,39 @@ namespace AibisDream.MiniGame.Language
/// </summary>
/// <param name="phrases">焦虑短句列表</param>
/// <param name="sentence">目标句子</param>
/// <param name="localizedDefaultPool">当前 Locale 的默认随机字符池</param>
/// <param name="dialogNode">完成时触发的对话节点</param>
/// <param name="nonTargetParticleTotal">
/// 非目标候选粒子总数(蓝/干扰侧可交互粒子数量)。≥0 时候选池大小 = 目标句字数 + 该值;&lt;0 时使用 Inspector 的 Candidate Count。
/// </param>
public void InitializeSystem(List<string> phrases, string sentence, string dialogNode = null, int nonTargetParticleTotal = -1)
public void InitializeSystem(
List<string> phrases,
string sentence,
IReadOnlyList<string> localizedDefaultPool,
string dialogNode = null,
int nonTargetParticleTotal = -1)
{
CaptureDefaultCandidateCountFromInspector();
string useSentence = string.IsNullOrEmpty(sentence) ? "这是一个测试示例" : sentence;
if (phrases == null || phrases.Count == 0 || string.IsNullOrWhiteSpace(sentence))
{
Debug.LogError(
"[LanguageParticleManager] 目标文字或干扰 token 为空;初始化已中止。");
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;
}
string useSentence = sentence;
int visibleTargetCount = ExpressionTextTokenizer.GetVisibleElements(useSentence).Count;
if (visibleTargetCount == 0)
{
@@ -373,6 +399,13 @@ namespace AibisDream.MiniGame.Language
candidateCount = defaultCandidateCountFromInspector;
}
// 在创建或复用粒子前先提交本轮不可变快照,防止首帧或上一轮字符泄漏。
anxietyPhrases = new List<string>(phrases);
targetSentence = useSentence;
completionDialogNode = dialogNode ?? "";
defaultCharacterPool = resolvedDefaultPool.AsReadOnly();
interferenceCharacterPool = resolvedInterferencePool.AsReadOnly();
// 如果是第一次初始化,先执行基础初始化
if (!isInitialized)
{
@@ -407,16 +440,9 @@ namespace AibisDream.MiniGame.Language
else
{
EnsureCandidatePoolSize(candidateCount);
ApplyCharacterPoolsToParticles();
}
// 设置游戏配置
anxietyPhrases = phrases ?? new List<string>();
targetSentence = useSentence;
completionDialogNode = dialogNode ?? "";
// 设置全局焦虑短句配置
TextParticle.SetAnxietyPhrases(anxietyPhrases);
// 根据目标句子更新目标粒子数量
redParticleCount = Mathf.Min(visibleTargetCount, candidateCount);
@@ -700,6 +726,7 @@ namespace AibisDream.MiniGame.Language
Vector3 pos = GetRandomPositionInBounds();
GameObject obj = CreateParticleObject(pos, $"FloatingParticle_{i}");
FloatingTextParticle particle = obj.AddComponent<FloatingTextParticle>();
particle.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool);
particle.SetFont(chineseFontAsset, chineseFontMaterial);
particle.SetColor(floatingTextColor);
particle.SetFontSize(floatingTextFontSize);
@@ -744,6 +771,7 @@ namespace AibisDream.MiniGame.Language
Vector3 pos = GetRandomPositionInBounds();
GameObject obj = CreateParticleObject(pos, $"CandidateParticle_{index}");
CandidateParticle particle = obj.AddComponent<CandidateParticle>();
particle.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool);
particle.SetFont(chineseFontAsset, chineseFontMaterial);
particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor);
particle.SetNonTargetMarquee(
@@ -760,6 +788,22 @@ namespace AibisDream.MiniGame.Language
candidateParticles.Add(particle);
}
private void ApplyCharacterPoolsToParticles()
{
foreach (CandidateParticle particle in candidateParticles)
particle?.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool);
foreach (FloatingTextParticle particle in floatingParticles)
particle?.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool);
}
private void RefreshAllRandomCharacters()
{
foreach (CandidateParticle particle in candidateParticles)
particle?.InitializeRandomCharacter();
foreach (FloatingTextParticle particle in floatingParticles)
particle?.InitializeRandomCharacter();
}
/// <summary>
/// 增删候选粒子以匹配目标数量(用于 Yarn 动态指定非目标粒子总数后复用同一视图)
/// </summary>
@@ -2016,6 +2060,8 @@ namespace AibisDream.MiniGame.Language
Debug.LogWarning("[LanguageParticleManager] 老虎机换字字符池为空;已安全跳过。");
return;
}
IReadOnlyList<string> poolElements =
ExpressionTextTokenizer.GetVisibleElements(pool).AsReadOnly();
float changeInterval = Mathf.Max(0.02f, interval);
foreach (TargetFocusVisual visual in focusVisuals.Values)
@@ -2023,7 +2069,7 @@ namespace AibisDream.MiniGame.Language
if (visual?.particle == null)
continue;
visual.particle.SetOverrideCharPool(pool);
visual.particle.SetOverrideCharacterPool(poolElements);
visual.particle.SetChangeInterval(changeInterval);
visual.particle.ResetChangeTimer(changeInterval);
visual.particle.isStatic = false;
@@ -2040,8 +2086,12 @@ namespace AibisDream.MiniGame.Language
{
if (string.IsNullOrEmpty(charPool))
return null;
string cleaned = charPool.Replace(" ", string.Empty).Replace("|", string.Empty);
return cleaned.Length == 0 ? null : cleaned;
string withoutSeparators = charPool
.Replace("|", string.Empty)
.Replace("", string.Empty);
List<string> elements =
ExpressionTextTokenizer.GetVisibleElements(withoutSeparators);
return elements.Count == 0 ? null : string.Concat(elements);
}
public void StopSlotMachineShuffle()
@@ -2053,7 +2103,7 @@ namespace AibisDream.MiniGame.Language
}
foreach (CandidateParticle particle in targetParticles)
particle?.SetOverrideCharPool(null);
particle?.SetOverrideCharacterPool(null);
}
private IEnumerator EndSlotMachineShuffleAfter(float duration)
@@ -2061,7 +2111,7 @@ namespace AibisDream.MiniGame.Language
yield return new WaitForSeconds(duration);
slotMachineRoutine = null;
foreach (CandidateParticle particle in targetParticles)
particle?.SetOverrideCharPool(null);
particle?.SetOverrideCharacterPool(null);
}
public void PrepareTruthReleaseFromLie()
@@ -3151,6 +3201,7 @@ namespace AibisDream.MiniGame.Language
RebuildScreenClipMaterials();
SelectRedParticles();
RefreshAllRandomCharacters();
UpdateStatusUI();
if (playEntranceEffect)
{
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using AibisDream.FixSystem;
using AibisDream;
using AibisDream.Framework;
using AibisDream.Utility;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using Yarn.Unity;
@@ -38,9 +39,19 @@ namespace AibisDream.MiniGame.Language
while (!all.IsCompleted)
yield return null;
completed?.Invoke(all.Status == TaskStatus.RanToCompletion
? all.Result
: System.Array.Empty<string>());
if (all.Status != TaskStatus.RanToCompletion)
{
completed?.Invoke(System.Array.Empty<string>());
yield break;
}
string[] values = all.Result;
for (int i = 0; i < values.Length; i++)
{
if (LocalizationKit.IsMissingParamResult(references[i], values[i]))
values[i] = null;
}
completed?.Invoke(values);
}
private static bool ValidateRequiredText(string value, string command, string parameter)
@@ -77,6 +88,22 @@ namespace AibisDream.MiniGame.Language
return tokens.Count > 0;
}
public static bool TryParseExpressionPool(
string value,
out List<string> characters)
{
characters = new List<string>();
if (string.IsNullOrWhiteSpace(value) ||
value.Contains('|') ||
value.Contains(''))
{
return false;
}
characters = ExpressionTextTokenizer.GetVisibleElements(value);
return characters.Count > 0;
}
private static bool TryGetPresentation(out LogReleasePresentationController presentation)
{
presentation = ExpressionManager != null
@@ -130,10 +157,12 @@ namespace AibisDream.MiniGame.Language
locale,
values => localized = values,
anxietyPhrasesStr,
targetSentence);
if (localized == null || localized.Length != 2 ||
targetSentence,
ConstRef.ExpressParticlePoolParam);
if (localized == null || localized.Length != 3 ||
!ValidateRequiredText(localized[0], "start_expression", nameof(anxietyPhrasesStr)) ||
!ValidateRequiredText(localized[1], "start_expression", nameof(targetSentence)))
!ValidateRequiredText(localized[1], "start_expression", nameof(targetSentence)) ||
!ValidateRequiredText(localized[2], "start_expression", "defaultCharacterPool"))
yield break;
if (!TryParseExpressionTokens(localized[0], out List<string> phrases))
@@ -142,6 +171,12 @@ namespace AibisDream.MiniGame.Language
"[LanguageYarnCommand] start_expression 没有有效的干扰 token;命令已安全结束。");
yield break;
}
if (!TryParseExpressionPool(localized[2], out List<string> defaultCharacterPool))
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] start_expression 的默认字符池无效;命令已安全结束。");
yield break;
}
// 先清理上一轮演出,再淡出旧文字/连线并恢复 Log 操作界面。
yield return ExpressionManager.PreparePresentationForNextRound();
@@ -151,6 +186,7 @@ namespace AibisDream.MiniGame.Language
ExpressionManager.StartSystem(
phrases,
localized[1],
defaultCharacterPool,
completionNode,
nonTargetParticleTotal);
yield return ExpressionManager.WaitUntilExpressionFlowReady();
@@ -199,10 +235,12 @@ namespace AibisDream.MiniGame.Language
locale,
values => localized = values,
round.TokenReference,
round.TargetReference);
if (localized == null || localized.Length != 2 ||
round.TargetReference,
ConstRef.ExpressParticlePoolParam);
if (localized == null || localized.Length != 3 ||
!ValidateRequiredText(localized[0], "start_expression_round", nameof(round.TokenReference)) ||
!ValidateRequiredText(localized[1], "start_expression_round", nameof(round.TargetReference)))
!ValidateRequiredText(localized[1], "start_expression_round", nameof(round.TargetReference)) ||
!ValidateRequiredText(localized[2], "start_expression_round", "defaultCharacterPool"))
yield break;
if (!TryParseExpressionTokens(localized[0], out List<string> phrases))
@@ -211,6 +249,12 @@ namespace AibisDream.MiniGame.Language
$"[LanguageYarnCommand] 表达轮次 '{roundId}' 没有有效的干扰 token。");
yield break;
}
if (!TryParseExpressionPool(localized[2], out List<string> defaultCharacterPool))
{
UnityEngine.Debug.LogError(
$"[LanguageYarnCommand] 表达轮次 '{roundId}' 的默认字符池无效。");
yield break;
}
yield return ExpressionManager.PreparePresentationForNextRound();
yield return ExpressionManager.FadeOutParticlesBeforeNewRound(0f);
@@ -219,6 +263,7 @@ namespace AibisDream.MiniGame.Language
ExpressionManager.StartSystem(
phrases,
localized[1],
defaultCharacterPool,
completionNode ?? string.Empty,
round.NonTargetParticleCount);
yield return ExpressionManager.WaitUntilExpressionFlowReady();
@@ -379,49 +424,6 @@ namespace AibisDream.MiniGame.Language
presentation.BeginFaceEntry(duration));
}
[YarnCommand("expression_lie_begin")]
public static IEnumerator ExpressionLieBegin(string keyword, string preset)
{
if (ExpressionManager == null)
{
UnityEngine.Debug.LogError("ExpressionManager 未找到!");
yield break;
}
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
keyword);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_lie_begin", nameof(keyword)))
yield break;
yield return ExpressionManager.StartCoroutine(
ExpressionManager.BeginExpressionLie(localized[0], preset));
}
[YarnCommand("expression_lie_break")]
public static IEnumerator ExpressionLieBreak()
{
if (ExpressionManager == null)
{
UnityEngine.Debug.LogError("ExpressionManager 未找到!");
yield break;
}
yield return ExpressionManager.StartCoroutine(ExpressionManager.BreakExpressionLie());
}
[YarnCommand("expression_truth_reveal")]
public static IEnumerator ExpressionTruthReveal()
{
if (ExpressionManager == null)
{
UnityEngine.Debug.LogError("ExpressionManager 未找到!");
yield break;
}
yield return ExpressionManager.StartCoroutine(ExpressionManager.RevealExpressionTruth());
}
// 以下命令是一动作一命令。非 IEnumerator 命令只启动效果,节拍由 Yarn 的 wait 控制。
[YarnCommand("expression_lie_prepare")]
@@ -540,7 +542,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 结尾真假快速闪切(非阻塞,节奏由同长度 wait 控制):真心话与谎话交替独占屏幕,
/// 两者的屏幕底色(电视头背景)不同,越到后面切得越快,结束定格在真话帧。
/// <<expression_truth_lie_flicker "l10n.hs.exp.actor.loop" "l10n.hs.exp.log1.target" 3.2 0.24 12>>
/// <<expression_truth_lie_flicker "l10n.hs.exp.log1.tokens" "l10n.hs.exp.log1.target" 3.2 0.24 12>>
/// </summary>
[YarnCommand("expression_truth_lie_flicker")]
public static IEnumerator ExpressionTruthLieFlicker(
@@ -719,54 +721,6 @@ namespace AibisDream.MiniGame.Language
true));
}
/// <summary>
/// 非阻塞启动“没问题 → 冇问题 → 帽问题”三拍无限闪现循环。
/// <<expression_actor_flash_loop_start>>
/// </summary>
[YarnCommand("expression_actor_flash_loop_start")]
public static IEnumerator ExpressionActorFlashLoopStart()
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
ExpressionContentCatalog catalog = ExpressionManager.ContentCatalog;
if (catalog == null || !LocalizationKit.IsLocalizedParam(catalog.ActorLoopReference))
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] Actor Loop 本地化引用未配置;命令已安全结束。");
yield break;
}
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
catalog.ActorLoopReference);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_flash_loop_start", "ActorLoopReference"))
yield break;
if (!TryParseExpressionTokens(localized[0], out List<string> phrases))
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] Actor Loop 短句列表无效;命令已安全结束。");
yield break;
}
presentation.StartActorFlashLoop(phrases);
}
/// <summary>
/// 立即停止无限闪现循环、清除当前大字,并恢复晃动粒子字。
/// <<expression_actor_flash_loop_stop>>
/// </summary>
[YarnCommand("expression_actor_flash_loop_stop")]
public static void ExpressionActorFlashLoopStop()
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StopActorFlashLoop();
}
/// <summary>
/// 真话闪现(阻塞):谎话大字之间,暖色完整真话带细微抖动挣扎浮现一拍;
/// "被掐断"由紧随其后的 expression_glitch_pulse 表现。不推进谎话冲击等级。
@@ -799,7 +753,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 老虎机换字(非阻塞,节拍由 Yarn wait 控制):聚焦字在 duration 内只从 charPool 里疯狂换字。
/// truthPool 非空时其字符也混入换字池,并以真话暖色显示。
/// <<expression_actor_slot "l10n.hs.exp.actor.loop" 5 0.05 14 "l10n.hs.exp.log1.target">>
/// <<expression_actor_slot "l10n.hs.exp.log1.tokens" 5 0.05 14 "l10n.hs.exp.log1.target">>
/// </summary>
[YarnCommand("expression_actor_slot")]
public static IEnumerator ExpressionActorSlot(
@@ -882,7 +836,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 单列词组严格裁在表情屏内,像字幕一样无缝向下滚动。
/// <<expression_actor_scroll "l10n.hs.exp.actor.loop" 1.8 0.85 0.45 5.2>>
/// <<expression_actor_scroll "l10n.hs.exp.log1.tokens" 1.8 0.85 0.45 5.2>>
/// </summary>
[YarnCommand("expression_actor_scroll")]
public static IEnumerator ExpressionActorScroll(
@@ -38,34 +38,19 @@ namespace AibisDream.MiniGame.Language
{
public readonly string Name;
public readonly float BaseDistortion;
public readonly float LiePause;
public readonly float BreakPeak;
public readonly float BreakDuration;
public readonly float TruthDuration;
public readonly float TruthPeripheral;
public Preset(
string name,
float baseDistortion,
float liePause,
float breakPeak,
float breakDuration,
float truthDuration,
float truthPeripheral)
float baseDistortion)
{
Name = name;
BaseDistortion = baseDistortion;
LiePause = liePause;
BreakPeak = breakPeak;
BreakDuration = breakDuration;
TruthDuration = truthDuration;
TruthPeripheral = truthPeripheral;
}
}
private static readonly Preset LightPreset = new Preset("Light", 0.10f, 0.35f, 0.25f, 0.25f, 0.70f, 0.15f);
private static readonly Preset MediumPreset = new Preset("Medium", 0.20f, 0.55f, 0.50f, 0.35f, 0.90f, 0.30f);
private static readonly Preset HeavyPreset = new Preset("Heavy", 0.35f, 0.80f, 0.85f, 0.50f, 1.10f, 0.50f);
private static readonly Preset LightPreset = new Preset("Light", 0.10f);
private static readonly Preset MediumPreset = new Preset("Medium", 0.20f);
private static readonly Preset HeavyPreset = new Preset("Heavy", 0.35f);
[Header("Scene References")]
[SerializeField] private LanguageParticleManager particleManager;
@@ -96,7 +81,6 @@ namespace AibisDream.MiniGame.Language
[Header("Screen Styling")]
[SerializeField] private Color lieColor = new Color(0.66f, 0.94f, 1f, 1f);
[SerializeField] private Color errorColor = new Color(1f, 0.34f, 0.18f, 1f);
[SerializeField] private Color truthWarmColor = new Color(1f, 1f, 1f, 1f);
[Tooltip("爆发段真话闪现与老虎机真话字符的暖色;需与谎话的冷白明显区分。")]
[SerializeField] private Color truthAccentColor = new Color(1f, 0.84f, 0.6f, 1f);
@@ -118,7 +102,6 @@ namespace AibisDream.MiniGame.Language
private Preset currentPreset = LightPreset;
private MemoryKind currentMemoryKind = MemoryKind.Office;
private string currentLieKeyword = string.Empty;
private string currentLeakFragment = string.Empty;
private Transform runtimeRoot;
private SpriteRenderer memoryRendererA;
@@ -129,7 +112,6 @@ namespace AibisDream.MiniGame.Language
private SpriteMask screenSpriteMask;
private TextMeshPro lieText;
private TextMeshPro lieGhostText;
private TextMeshPro systemText;
private TextMeshPro actorFlashText;
private TextMeshPro actorFlashChromaRed;
private TextMeshPro actorFlashChromaCyan;
@@ -141,7 +123,6 @@ namespace AibisDream.MiniGame.Language
private string truthWarmColorHex;
private TMPRectClipper lieClipper;
private TMPRectClipper lieGhostClipper;
private TMPRectClipper systemClipper;
private TMPRectClipper actorFlashClipper;
private TMPRectClipper actorFlashChromaRedClipper;
private TMPRectClipper actorFlashChromaCyanClipper;
@@ -150,7 +131,6 @@ namespace AibisDream.MiniGame.Language
private bool actorFlashActive;
private int actorFlashCount;
private Coroutine actorOverdriveRoutine;
private Coroutine actorFlashLoopRoutine;
private Texture2D runtimeWhiteTexture;
private Sprite runtimeWhiteSprite;
private MaterialPropertyBlock memoryBlockA;
@@ -254,7 +234,6 @@ namespace AibisDream.MiniGame.Language
TweenMemoryOpacity(memoryRendererB, 0f, duration);
FadeText(lieText, 0f, duration);
FadeText(lieGhostText, 0f, duration);
FadeText(systemText, 0f, duration);
FadeText(actorFlashText, 0f, duration);
FadeText(actorFlashChromaRed, 0f, duration);
FadeText(actorFlashChromaCyan, 0f, duration);
@@ -337,7 +316,6 @@ namespace AibisDream.MiniGame.Language
currentPreset = preset;
currentMemoryKind = kind;
currentLeakFragment = GetLeakFragment(kind);
_ = horizontalSpeed;
actorFlashCount = 0;
memoryImpact = 0f;
@@ -398,84 +376,6 @@ namespace AibisDream.MiniGame.Language
Mathf.Max(0.01f, duration));
}
public IEnumerator BeginLie(string keyword, string presetName)
{
EnsureRuntimeObjects();
if (!TryResolvePreset(presetName, out Preset requestedPreset))
{
Debug.LogError($"[LogReleasePresentation] 未知演出预设“{presetName}”;命令安全结束。");
yield break;
}
if (!string.Equals(requestedPreset.Name, currentPreset.Name, StringComparison.OrdinalIgnoreCase))
{
Debug.LogWarning(
$"[LogReleasePresentation] expression_lie_begin 的预设 {requestedPreset.Name} " +
$"与当前记忆 {currentPreset.Name} 不一致,将沿用当前记忆预设。");
}
PrepareLieVisuals(0.25f, 0.005f);
SqueezeTruthCharacters(
0.22f,
0.055f + currentPreset.BreakPeak * 0.065f,
0.28f);
ShowLieKeyword(keyword, 0.22f, 14f);
PlayScreenScanline(0.32f, currentMemoryKind == MemoryKind.Sunset);
if (currentMemoryKind == MemoryKind.PrivateOffice)
ShowSystemMessage("输出正常", "normal", 0.12f);
else
HideSystemMessageImmediate();
yield return new WaitForSeconds(0.25f);
}
public IEnumerator BreakLie()
{
EnsureRuntimeObjects();
if (!ExpectState(nameof(BreakLie), PresentationState.LieHolding))
yield break;
yield return new WaitForSeconds(currentPreset.LiePause);
BeginLieCracking(0.48f);
PlayScreenScanline(0.22f, true);
PlayScreenDim(0.28f, 0.06f, 0.12f);
PlayMemoryJolt(0.08f, 0.24f, 0.11f);
yield return LeakTruthFragment(currentLeakFragment, 0.24f);
if (currentMemoryKind == MemoryKind.PrivateOffice)
{
HideSystemMessage(0.01f);
yield return new WaitForSeconds(0.08f);
ShowSystemMessage("输出与原始 log 不一致", "error", 0.08f);
yield return PulseGlitch(HeavyPreset.BreakPeak, 0.12f, 0.18f);
}
}
public IEnumerator RevealTruth()
{
EnsureRuntimeObjects();
if (!ExpectState(
nameof(RevealTruth),
PresentationState.LieCracking,
PresentationState.LieHolding))
{
yield break;
}
DOTween.Kill(this);
yield return HoldTruthBlackout(0.055f, 0.42f);
SplitLieVisual(currentPreset.BreakDuration, 0.18f, 1.85f, 0.42f);
PlayTruthFlash(0.62f, 0.045f, 0.025f, 0.16f);
PlayMemoryBrightnessPulse(0.16f, 0.06f, 0.20f);
PlayMemoryTear(currentPreset.BreakPeak, currentPreset.BreakDuration);
PlayGlitchPulse(currentPreset.BreakPeak, 0.12f, currentPreset.BreakDuration);
PlayFaceDip(currentPreset.BreakDuration, faceDipRatio);
ReleaseTruthCharacters(0.08f);
yield return ResolveTruthCharacters(
currentPreset.TruthDuration,
currentPreset.TruthPeripheral,
0.42f);
}
public void PrepareLieVisuals(float freezeDuration, float noiseTarget)
{
EnsureRuntimeObjects();
@@ -560,42 +460,6 @@ namespace AibisDream.MiniGame.Language
PlayScanline(Mathf.Max(0.01f, duration), stalled);
}
public void ShowSystemMessage(string value, string style, float fadeDuration)
{
EnsureRuntimeObjects();
if (!ExpectState(
nameof(ShowSystemMessage),
PresentationState.FaceEntering,
PresentationState.Memory,
PresentationState.LieHolding,
PresentationState.LieCracking))
return;
Color color;
if (string.Equals(style, "error", StringComparison.OrdinalIgnoreCase))
{
color = errorColor;
}
else
{
if (!string.Equals(style, "normal", StringComparison.OrdinalIgnoreCase))
Debug.LogWarning($"[LogReleasePresentation] 未知系统提示样式“{style}”,改用 normal。");
color = new Color(0.58f, 0.78f, 0.82f, 1f);
}
SetSystemMessage(value ?? string.Empty, color, Mathf.Max(0.01f, fadeDuration));
}
public void HideSystemMessage(float fadeDuration)
{
if (systemText == null || !systemText.gameObject.activeSelf)
return;
systemText.DOKill();
systemText.DOFade(0f, Mathf.Max(0.01f, fadeDuration))
.SetEase(Ease.OutQuad)
.SetTarget(this)
.OnComplete(HideSystemMessageImmediate);
}
public void BeginLieCracking(float lieAlpha)
{
if (!ExpectState(
@@ -654,9 +518,9 @@ namespace AibisDream.MiniGame.Language
PresentationState.LieCracking))
yield break;
currentLeakFragment = fragment ?? string.Empty;
string resolvedFragment = fragment ?? string.Empty;
yield return ShuffleLieCharactersRoutine(
currentLeakFragment,
resolvedFragment,
Mathf.Max(0.01f, duration),
0.045f);
}
@@ -688,7 +552,6 @@ namespace AibisDream.MiniGame.Language
return;
DOTween.Kill(this);
StopActorFlashLoopImmediate(false);
StopActorOverdriveImmediate();
memoryPushSpeed = 0f;
memoryPushProgress = 0f;
@@ -808,73 +671,6 @@ namespace AibisDream.MiniGame.Language
}
}
public void StartActorFlashLoop(IReadOnlyList<string> phrases)
{
EnsureRuntimeObjects();
if (!ExpectState(nameof(StartActorFlashLoop), PresentationState.Memory))
return;
if (phrases == null || phrases.Count == 0)
{
Debug.LogError("[LogReleasePresentation] Actor Flash Loop 没有有效短句;已安全跳过。");
return;
}
StopActorFlashLoopImmediate(true);
actorFlashLoopRoutine = StartCoroutine(ActorFlashLoopRoutine(phrases));
}
public void StopActorFlashLoop()
{
StopActorFlashLoopImmediate(true);
}
private IEnumerator ActorFlashLoopRoutine(IReadOnlyList<string> phrases)
{
while (state == PresentationState.Memory)
{
for (int i = 0; i < phrases.Count && state == PresentationState.Memory; i++)
{
int style = i % 3;
float hold = style == 2 ? 0.15f : 0.25f;
float fontSize = style == 0 ? 10.5f : style == 1 ? 11.2f : 12f;
float punchScale = style == 0 ? 1.45f : style == 1 ? 1.85f : 2.20f;
float chromaDistance = style == 2 ? 0.010f : 0.008f;
float chromaAlpha = style == 2 ? 0.08f : 0.06f;
float impact = style == 0 ? 0.50f : style == 1 ? 0.72f : 1f;
yield return FlashActorLie(
phrases[i],
hold,
fontSize,
1.1f,
punchScale,
chromaDistance,
chromaAlpha,
impact,
0f);
if (state == PresentationState.Memory)
yield return new WaitForSeconds(0.3f);
}
}
actorFlashLoopRoutine = null;
}
private void StopActorFlashLoopImmediate(bool restoreParticles)
{
if (actorFlashLoopRoutine != null)
{
StopCoroutine(actorFlashLoopRoutine);
actorFlashLoopRoutine = null;
}
actorFlashActive = false;
memoryImpact = 0f;
HideActorFlashVisualsImmediate();
if (restoreParticles && state == PresentationState.Memory)
particleManager?.SetTargetParticlesAlpha(1f, 0f);
}
/// <summary>
/// 真话闪现:谎话大字之间,暖色完整真话带着细微抖动挣扎浮现一拍。
/// 不推进谎话冲击等级、不累积记忆损伤;"被掐断"由紧随其后的 expression_glitch_pulse 表现。
@@ -1100,7 +896,6 @@ namespace AibisDream.MiniGame.Language
state = PresentationState.TruthResolving;
StopLieShuffleImmediate(true);
HideLieLayersImmediate();
HideSystemMessageImmediate();
StopScanline();
StopScreenDim();
@@ -1706,7 +1501,6 @@ namespace AibisDream.MiniGame.Language
return;
state = PresentationState.TruthResolving;
HideSystemMessage(0.15f);
particleManager?.PrepareTruthReleaseFromLie();
particleManager?.ApplyPresentationResolvedVisuals(presentationResolvedColor);
particleManager?.SetTargetParticlesAlpha(1f, Mathf.Max(0.01f, alphaDuration));
@@ -1752,7 +1546,6 @@ namespace AibisDream.MiniGame.Language
}
HideLieLayersImmediate();
HideSystemMessageImmediate();
StopScanline();
StopScreenDim();
ReleaseScreenTextClipMaterials();
@@ -1790,7 +1583,6 @@ namespace AibisDream.MiniGame.Language
TweenMemoryOpacity(memoryRendererA, 0f, memoryExitDuration);
TweenMemoryOpacity(memoryRendererB, 0f, memoryExitDuration);
HideLieLayersImmediate();
HideSystemMessageImmediate();
StopScanline();
StopScreenDim();
SilenceNoiseOverlayIfPresent();
@@ -1815,7 +1607,6 @@ namespace AibisDream.MiniGame.Language
{
DOTween.Kill(this);
StopAllCoroutines();
actorFlashLoopRoutine = null;
RestoreFaceSpriteImmediate();
memoryOpacityA = 0f;
@@ -1824,7 +1615,6 @@ namespace AibisDream.MiniGame.Language
DisableMemoryRenderer(memoryRendererB);
activeMemoryRenderer = null;
HideLieLayersImmediate();
HideSystemMessageImmediate();
StopScanline();
StopScreenDim();
ReleaseScreenTextClipMaterials();
@@ -1847,7 +1637,6 @@ namespace AibisDream.MiniGame.Language
particleManager?.RestoreTargetPresentationDefaults();
state = PresentationState.Idle;
currentLieKeyword = string.Empty;
currentLeakFragment = string.Empty;
memoryPushSpeed = 0f;
memoryPushProgress = 0f;
memoryZoom = 1f;
@@ -2309,21 +2098,18 @@ namespace AibisDream.MiniGame.Language
CreateScreenMask();
lieText = CreateScreenText("LieKeyword", 4.1f, screenTextSortingOrder);
lieGhostText = CreateScreenText("LieKeywordGhost", 4.1f, screenTextSortingOrder - 1);
systemText = CreateScreenText("SystemPrompt", 1.35f, screenTextSortingOrder + 1);
actorFlashText = CreateScreenText("ActorFlashKeyword", 10.5f, screenTextSortingOrder);
actorFlashChromaRed = CreateScreenText("ActorFlashChromaRed", 10.5f, screenTextSortingOrder - 1);
actorFlashChromaCyan = CreateScreenText("ActorFlashChromaCyan", 10.5f, screenTextSortingOrder - 1);
truthFlashText = CreateScreenText("ActorTruthFlash", 8.5f, screenTextSortingOrder - 1);
lieClipper = lieText.gameObject.AddComponent<TMPRectClipper>();
lieGhostClipper = lieGhostText.gameObject.AddComponent<TMPRectClipper>();
systemClipper = systemText.gameObject.AddComponent<TMPRectClipper>();
actorFlashClipper = actorFlashText.gameObject.AddComponent<TMPRectClipper>();
actorFlashChromaRedClipper = actorFlashChromaRed.gameObject.AddComponent<TMPRectClipper>();
actorFlashChromaCyanClipper = actorFlashChromaCyan.gameObject.AddComponent<TMPRectClipper>();
truthFlashClipper = truthFlashText.gameObject.AddComponent<TMPRectClipper>();
lieClipper.Initialize(lieText, expressionScreenMaskRect);
lieGhostClipper.Initialize(lieGhostText, expressionScreenMaskRect);
systemClipper.Initialize(systemText, expressionScreenMaskRect);
actorFlashClipper.Initialize(actorFlashText, expressionScreenMaskRect);
actorFlashChromaRedClipper.Initialize(actorFlashChromaRed, expressionScreenMaskRect);
actorFlashChromaCyanClipper.Initialize(actorFlashChromaCyan, expressionScreenMaskRect);
@@ -2437,15 +2223,12 @@ namespace AibisDream.MiniGame.Language
PositionText(actorFlashChromaRed, center, screen.size);
PositionText(actorFlashChromaCyan, center, screen.size);
PositionText(truthFlashText, center, screen.size);
Vector3 systemPosition = center + Vector3.up * screen.extents.y * 0.70f;
PositionText(systemText, systemPosition, new Vector2(screen.size.x, screen.size.y * 0.22f));
}
private void EnsureScreenTextClipMaterials()
{
lieClipper?.Initialize(lieText, expressionScreenMaskRect);
lieGhostClipper?.Initialize(lieGhostText, expressionScreenMaskRect);
systemClipper?.Initialize(systemText, expressionScreenMaskRect);
actorFlashClipper?.Initialize(actorFlashText, expressionScreenMaskRect);
actorFlashChromaRedClipper?.Initialize(actorFlashChromaRed, expressionScreenMaskRect);
actorFlashChromaCyanClipper?.Initialize(actorFlashChromaCyan, expressionScreenMaskRect);
@@ -2458,7 +2241,6 @@ namespace AibisDream.MiniGame.Language
{
lieClipper?.ReleaseMaterial();
lieGhostClipper?.ReleaseMaterial();
systemClipper?.ReleaseMaterial();
actorFlashClipper?.ReleaseMaterial();
actorFlashChromaRedClipper?.ReleaseMaterial();
actorFlashChromaCyanClipper?.ReleaseMaterial();
@@ -2751,22 +2533,6 @@ namespace AibisDream.MiniGame.Language
screenDimRenderer.gameObject.SetActive(false);
}
private void SetSystemMessage(string value, Color color, float fadeDuration)
{
SetTextActive(systemText, value, color);
SetTextAlpha(systemText, 0f);
FadeText(systemText, 1f, fadeDuration);
}
private void HideSystemMessageImmediate()
{
if (systemText == null)
return;
systemText.DOKill();
systemText.text = string.Empty;
systemText.gameObject.SetActive(false);
}
private void HideLieLayersImmediate()
{
StopLieShuffleImmediate(false);
@@ -2777,7 +2543,6 @@ namespace AibisDream.MiniGame.Language
private void HideActorFlashImmediate()
{
StopActorFlashLoopImmediate(false);
StopActorOverdriveImmediate();
StopTruthLieFlicker();
actorFlashActive = false;
@@ -2885,25 +2650,6 @@ namespace AibisDream.MiniGame.Language
}
}
private static bool TryResolvePreset(string value, out Preset preset)
{
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
{
case "light":
preset = LightPreset;
return true;
case "medium":
preset = MediumPreset;
return true;
case "heavy":
preset = HeavyPreset;
return true;
default:
preset = LightPreset;
return false;
}
}
private static Preset GetPresetForMemory(MemoryKind kind)
{
return kind switch
@@ -2914,16 +2660,6 @@ namespace AibisDream.MiniGame.Language
};
}
private static string GetLeakFragment(MemoryKind kind)
{
return kind switch
{
MemoryKind.Office => "笑话",
MemoryKind.Sunset => "离开",
_ => "不要那样看我"
};
}
private bool ExpectState(string command, params PresentationState[] allowed)
{
for (int i = 0; i < allowed.Length; i++)
@@ -26,7 +26,7 @@ Floating Count: 100
Candidate Count: 50
Red Particle Count: 8
Connection Distance: 2
Target Sentence: "别过来我感觉害怕"
Target Sentence: 由启动命令传入,不在 Inspector 中保存测试文案
Waveform Amplitude: 0.3
Interaction Radius: 0.5
```
@@ -68,7 +68,7 @@ Interaction Radius: 0.5
1. Window → TextMeshPro → Font Asset Creator
2. 选择支持中文的字体(如:思源黑体、微软雅黑)
3. Character Set: Custom Characters
4. 粘贴`TextParticle.cs`中的`chineseChars`字符
4. 粘贴 `Params/hs.exp.pool` 及正式 Express 文案所需字符
5. Generate Font Atlas
### 看不到粒子?
@@ -50,7 +50,7 @@ Language/
#### 游戏参数
- **Connection Distance**: 连接距离阈值(默认2,Unity单位)
- **Target Sentence**: 目标句子(默认"别过来我感觉害怕"
- **Target Sentence**: 目标句子(必须由启动命令传入
#### 波形参数
- **Waveform Amplitude**: 波形振幅(默认0.3
@@ -91,7 +91,7 @@ Language/
完成第一阶段后:
1. 红色粒子自动排列成水平一行
2. 蓝色粒子淡出
3. 红色粒子逐个揭示目标文字"别过来我感觉害怕"
3. 红色粒子逐个揭示启动命令传入的目标文字
4. 文字出现剧烈抖动(焦虑效果)
### 第三阶段:波形交互
@@ -158,15 +158,12 @@ Language/
### 添加新的文字内容
修改`CandidateParticle.cs`中的字符集:
```csharp
protected static string chineseChars = "你的字符集...";
protected static string[] chineseWords = { "词组1", "词组2", ... };
```
在 Unity Localization 的 `Params/hs.exp.pool` 中配置当前语言的随机字符池。
字符池按 Unicode 文本元素拆分,忽略空白;重复字符可用于提高出现权重。
### 修改目标句子
在Manager的Inspector中修改`Target Sentence`字段
修改 `ExpressionContentCatalog` 对应轮次引用的 `Params` 本地化条目。
### 调整波形特性
@@ -27,7 +27,11 @@ namespace AibisDream.MiniGame.Language
protected float changeTimer;
protected float changeInterval = 1f;
protected string overrideCharPool;
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 Bounds movementBounds;
protected bool useCircularBounds = false;
protected Vector3 boundsCenter;
@@ -36,13 +40,6 @@ namespace AibisDream.MiniGame.Language
private Material customFontMaterial;
private TMPRectClipper screenClipper;
// 字符集
protected static string chineseChars = "的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严龙飞";
protected static string[] chineseWords = { "不安", "紧张", "焦虑", "担忧", "烦躁", "恐慌", "恐惧", "绝望", "痛苦", "悲伤", "愤怒", "孤独", "无助", "迷茫", "困惑", "压抑", "沉重", "疲惫", "空虚", "失落" };
// 动态配置的干扰短句(笑话/焦虑等,由 Yarn start_expression 传入,用于非目标粒子字符池)
protected static List<string> configuredAnxietyPhrases = new List<string>();
protected virtual void Awake()
{
if (textMesh == null)
@@ -85,7 +82,7 @@ namespace AibisDream.MiniGame.Language
SyncLayerToChildren();
changeTimer = Random.Range(0.5f, 1.5f);
currentChar = GetNextDisplayChar();
currentChar = string.Empty;
UpdateText();
// TMP 重建 mesh / SubMesh 后再对齐一次 layer
SyncLayerToChildren();
@@ -112,7 +109,7 @@ namespace AibisDream.MiniGame.Language
// 边界检测
CheckBounds();
// 字符变化:目标粒子用 chineseChars 随机,非目标粒子从干扰短句字符中随机
// 字符变化:目标粒子用本轮默认池,非目标粒子从干扰短句字符中随机
changeTimer -= Time.deltaTime;
if (changeTimer <= 0)
{
@@ -209,7 +206,7 @@ namespace AibisDream.MiniGame.Language
protected string GetRandomChar()
{
return chineseChars[Random.Range(0, chineseChars.Length)].ToString();
return GetRandomPoolElement(defaultCharacterPool);
}
/// <summary>
@@ -218,22 +215,14 @@ namespace AibisDream.MiniGame.Language
/// </summary>
protected string GetRandomCharFromPhrases()
{
if (configuredAnxietyPhrases == null || configuredAnxietyPhrases.Count == 0)
if (interferenceCharacterPool == null || interferenceCharacterPool.Count == 0)
return GetRandomChar();
var chars = new List<char>();
foreach (var phrase in configuredAnxietyPhrases)
{
if (string.IsNullOrEmpty(phrase)) continue;
foreach (char c in phrase)
chars.Add(c);
}
if (chars.Count == 0) return GetRandomChar();
return chars[Random.Range(0, chars.Count)].ToString();
return GetRandomPoolElement(interferenceCharacterPool);
}
/// <summary>
/// 获取下一次要显示的字符。子类可重写以区分目标粒子与非目标粒子(与红蓝颜色解绑)。
/// 默认返回 chineseChars 随机单字
/// 默认从本轮本地化字符池随机返回一个 Unicode 文本元素
/// </summary>
protected virtual string GetNextDisplayChar()
{
@@ -247,27 +236,60 @@ namespace AibisDream.MiniGame.Language
/// </summary>
public void SetOverrideCharPool(string pool)
{
overrideCharPool = string.IsNullOrEmpty(pool) ? null : pool;
SetOverrideCharacterPool(
ExpressionTextTokenizer.GetVisibleElements(pool));
}
public void SetOverrideCharacterPool(IReadOnlyList<string> pool)
{
overrideCharacterPool = pool != null && pool.Count > 0
? pool
: EmptyCharacterPool;
}
protected bool TryGetOverridePoolChar(out string character)
{
if (string.IsNullOrEmpty(overrideCharPool))
if (!HasOverrideCharacterPool)
{
character = null;
return false;
}
character = overrideCharPool[Random.Range(0, overrideCharPool.Length)].ToString();
character = GetRandomPoolElement(overrideCharacterPool);
return true;
}
/// <summary>
/// 设置全局干扰短句配置(笑话等,由 LanguageParticleManager 调用)
/// 设置本轮默认与干扰字符池。调用方负责传入不可变的本轮快照。
/// </summary>
public static void SetAnxietyPhrases(List<string> phrases)
public void SetCharacterPools(
IReadOnlyList<string> defaultPool,
IReadOnlyList<string> interferencePool)
{
configuredAnxietyPhrases = phrases ?? new List<string>();
defaultCharacterPool = defaultPool != null && defaultPool.Count > 0
? defaultPool
: EmptyCharacterPool;
interferenceCharacterPool =
interferencePool != null && interferencePool.Count > 0
? interferencePool
: EmptyCharacterPool;
}
public void InitializeRandomCharacter()
{
currentChar = GetNextDisplayChar();
UpdateText();
changeTimer = Random.Range(0.5f, 1.5f);
}
protected bool HasOverrideCharacterPool =>
overrideCharacterPool != null && overrideCharacterPool.Count > 0;
private static string GetRandomPoolElement(IReadOnlyList<string> pool)
{
if (pool == null || pool.Count == 0)
return string.Empty;
return pool[Random.Range(0, pool.Count)];
}
protected void UpdateText()
+15 -12
View File
@@ -7,9 +7,9 @@ Localization 的 `Params` String Table Collection 作为唯一数据源。
- `l10n.xxx` 查询 `Params/xxx`
- 查询使用 `DontUseFallback`,不回退中文或其他 Locale。
- 中文表保存源数据;英文、日文表保留同一套 Key,未翻译值留空。
- Key 不存在时显示 `⟦key⟧` 并记录包含 Table、Key、Locale 的错误。
- Express 必填值解析为空时安全终止命令,不启动粒子或释放演出。
- 中文表保存源数据;其他 Locale 表保留同一套 Key,未翻译值留空。
- Key 不存在时返回 `⟦key⟧` 并记录包含 Table、Key、Locale 的错误。
- Express 必填值解析为空或缺失标记时安全终止命令,不启动粒子或释放演出。
- `StreamingAssets/Config/params.csv` 已退出运行链路。
- 固定 UI 继续使用 `UIText`;普通 Yarn 对白、Task、教学和角色名不迁入
`Params`
@@ -25,8 +25,8 @@ Localization 的 `Params` String Table Collection 作为唯一数据源。
| `log2` | `l10n.hs.exp.log2.target` | `l10n.hs.exp.log2.tokens` | 28 |
| `log3` | `l10n.hs.exp.log3.target` | `l10n.hs.exp.log3.tokens` | 38 |
Actor Loop 使用 `l10n.hs.exp.actor.loop`Round ID 大小写敏感;重复 ID、
未知 ID、空引用、非 `l10n.*` 引用或解析后的空值均视为数据错误。
Round ID 大小写敏感;重复 ID、未知 ID、空引用、非 `l10n.*` 引用或解析
后的空值均视为数据错误。
正式 Yarn 使用:
@@ -40,8 +40,8 @@ Actor Loop 使用 `l10n.hs.exp.actor.loop`。Round ID 大小写敏感;重复 I
## Locale 生命周期
启动轮次时捕获当前 Locale,目标句token 并行解析;校验通过后才初始化
粒子系统。该 Locale 快照会保留到下一轮开始或 ExpressSystem 关闭,当前轮
启动轮次时捕获当前 Locale,目标句token `l10n.hs.exp.pool` 并行解析;
校验通过后才初始化粒子系统。该 Locale 快照会保留到下一轮开始或 ExpressSystem 关闭,当前轮
后续释放演出都使用同一快照。没有活动轮次时,演出命令使用命令调用时的
当前 Locale。
@@ -53,9 +53,9 @@ Actor Loop 使用 `l10n.hs.exp.actor.loop`。Round ID 大小写敏感;重复 I
Express 使用短前缀 `hs.exp.*`
- 三轮目标和 token`hs.exp.log{1..3}.target``.tokens`
- Actor Loop`hs.exp.actor.loop`
- LOG3 攻击词:`hs.exp.atk.*`
- 最终稳定文字:`hs.exp.final.win`
- 全局随机字符池:`hs.exp.pool`
重复演出复用同一 Key,但 Yarn 中仍保留原有调用次数、等待、数值、顺序和
`#line:` 标签。
@@ -74,9 +74,10 @@ Express 使用短前缀 `hs.exp.*`
最终排列保留词间距。目标粒子数量、最终排列、攻击词长度校验和 truth leak
匹配使用同一套文本元素结果。
本次明确不修改 `TextParticle` 的随机字符池、`chineseChars`
`chineseWords`、目标粒子入场阶段的随机字符来源,以及背景/干扰粒子的字符
池策略。
随机字符池同样按 Unicode 文本元素处理。`hs.exp.pool` 是连续文本,不使用
`|` 或全角 ``,空白被忽略,重复元素保留为随机权重。目标粒子从该池取字,
非目标和背景浮动粒子继续从本轮 token 展开的干扰池取字;临时老虎机池结束
后按粒子角色恢复。旧的 `chineseChars``chineseWords` 运行时常量均已删除。
## 内容校验
@@ -85,8 +86,10 @@ Express 使用短前缀 `hs.exp.*`
- Stage6 正式 Express 文字参数不得残留中文硬编码;
- Yarn 和 Catalog 中引用的 `l10n.*` Key 必须存在于 `Params` Shared Data
- 中文值必须非空;
- 英文、日文空值只报告待翻译,不导致校验失败;
- 未翻译 Locale 的空值只报告待翻译,不导致校验失败;
- `.tokens` 至少包含一个有效项,且不能使用全角 ``
- `hs.exp.pool` 必须存在于所有 Params Locale 表;中文非空,非空译文不得
包含 `|``` 或只有空白;
- completion node、preset、memory key、颜色、Timeline 等结构参数不误报。
运行时和 EditMode 测试同时覆盖原始字符串、空值不回退、缺 Key 占位、
-4
View File
@@ -2,8 +2,6 @@
`Stage6.yarn` 已改为使用原子命令编排。除特别标记为“等待”的命令外,命令只启动效果并立即让 Yarn 继续;并行效果之间的节奏用 `<<wait 秒数>>` 调整。
旧命令 `expression_lie_begin``expression_lie_break``expression_truth_reveal` 仍然保留,供其他节点兼容使用。Stage6 不再依赖它们。
## 火山脸与 LOG1 大字
| 命令 | 参数 | 行为 |
@@ -11,8 +9,6 @@
| `expression_face_fade_in` | `duration` | 显式淡入火山脸并等待完成。三条 LOG 都在完成节点中调用,不再由粒子系统自动淡入。 |
| `expression_actor_flash` | `"text" holdDuration` | 清晰的大字重击;连续调用会逐级增强,但每次结束都退回晃动粒子字。 |
| `expression_actor_flash_in` | 与 `expression_actor_flash` 相同 | 阻塞播放相同的大字重击入场,但保持最后一帧、不退场;用于段落结尾定格。 |
| `expression_actor_flash_loop_start` | 无 | 非阻塞启动“没问题 → 冇问题 → 帽问题”三拍无限循环,参数固定为 LOG1 当前节奏。 |
| `expression_actor_flash_loop_stop` | 无 | 立即停止循环、清掉当前大字并恢复晃动粒子字。 |
| `expression_actor_slot` | `"characterPool" duration interval fontSize "truthPool"` | 中央大字持续换字。`truthPool` 非空时其字符也混入换字池,并在主字上以真话暖色(`truthAccentColor`)显示——真假字符交替闪烁即"争夺"。命令不等待,节奏由紧随其后的同长度 `wait` 控制;结束后停在最后一帧。 |
| `expression_actor_truth_flash` | `"text" holdDuration fontSize alpha jitter` | 真话闪现(等待):谎话大字之间,暖色完整真话带细微抖动挣扎浮现一拍。不推进谎话冲击等级、不累积记忆损伤;"被掐断"由紧随其后的 `expression_glitch_pulse` 表现。 |
| `expression_actor_word_hit` | `"text" holdDuration` | 单字或短词独占屏幕重击,并等待该拍完成。 |