feat: 火山玩法本地化

仍有硬编码残留,待处理
This commit is contained in:
2026-07-28 19:13:53 +08:00
parent 3de3f1a2e2
commit ca5b714124
72 changed files with 2634 additions and 626 deletions
@@ -17,6 +17,7 @@ namespace AibisDream
public const string FixWhackMoleData = Root + "/维修·打地鼠/关卡数据";
public const string HuoShanEmotionWave = Root + "/火山/情绪波配置";
public const string HuoShanWaveform = Root + "/火山/波形配置";
public const string HuoShanExpressionContent = Root + "/火山/表达内容配置";
public const string BlockPuzzleValidator = Root + "/方块拼图/验证器";
public const string MemoryPunchTapeCatalog = Root + "/记忆/打孔带目录";
}
+1
View File
@@ -52,6 +52,7 @@ namespace AibisDream.Utility
public const string UITextTable = "UIText";
public const string ChapterInfoTable = "ChapterInfo";
public const string ParamsTable = "Params";
#endregion
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AibisDream.Framework;
using AibisDream.Utility;
using UnityEngine;
@@ -79,10 +80,16 @@ namespace AibisDream
return LocalizedLine.InvalidLine;
}
// 对 substitutions 做本地化解析(保留旧 LocalisedLineProvider 的能力)
for (int i = 0; i < line.Substitutions.Length; i++)
// 对 substitutions 并行做本地化解析(保留旧 LocalisedLineProvider 的能力)
if (line.Substitutions.Length > 0)
{
line.Substitutions[i] = LocalizationKit.LocalizeParam(line.Substitutions[i]);
var localizationTasks = new Task<string>[line.Substitutions.Length];
for (int i = 0; i < line.Substitutions.Length; i++)
localizationTasks[i] = LocalizationKit.LocalizeParamAsync(line.Substitutions[i]);
string[] localizedSubstitutions = await Task.WhenAll(localizationTasks);
for (int i = 0; i < localizedSubstitutions.Length; i++)
line.Substitutions[i] = localizedSubstitutions[i];
}
// 先展开项目级语义标记,再保留旧 LocalisedLineProvider 的文本转义处理。
@@ -25,6 +25,7 @@ namespace AibisDream.FixSystem
private PunchTape punchTapeData;
private PunchTapeDefinition punchTapeDefinition;
private int localizationRefreshVersion;
private void OnEnable()
{
@@ -34,6 +35,7 @@ namespace AibisDream.FixSystem
private void OnDisable()
{
LocalizationSettings.SelectedLocaleChanged -= HandleSelectedLocaleChanged;
localizationRefreshVersion++;
}
public void Setup(PunchTape punchTape, PunchTapeDefinition definition)
@@ -57,8 +59,7 @@ namespace AibisDream.FixSystem
if (nameText != null)
{
RefreshLocalizedName(LocalizationKit.GetLocaleKind(
LocalizationSettings.SelectedLocale?.Identifier.Code));
RefreshLocalizedName(LocalizationSettings.SelectedLocale);
}
memoryIndexLabelText?.RefreshString();
@@ -73,17 +74,26 @@ namespace AibisDream.FixSystem
private void HandleSelectedLocaleChanged(Locale locale)
{
RefreshLocalizedName(LocalizationKit.GetLocaleKind(locale?.Identifier.Code));
RefreshLocalizedName(locale);
}
private void RefreshLocalizedName(LocaleKind localeKind)
private async void RefreshLocalizedName(Locale locale)
{
if (nameText == null || punchTapeDefinition == null)
return;
int version = ++localizationRefreshVersion;
string localizedName = await LocalizationKit.LocalizeParamAsync(
punchTapeDefinition.DisplayNameParam,
locale);
if (version != localizationRefreshVersion ||
nameText == null ||
punchTapeDefinition == null)
return;
nameText.StringReference.Arguments = new object[]
{
LocalizationKit.LocalizeParam(punchTapeDefinition.DisplayNameParam, localeKind)
localizedName
};
nameText.RefreshString();
}
+1 -24
View File
@@ -13,10 +13,8 @@ namespace AibisDream.Kit
public class ConfigUtil : SingletonBase<ConfigUtil>
{
private const string CharacterConfigPath = "/Config/character.csv";
private const string ParamConfigPath = "/Config/params.csv";
private Dictionary<string, Character> _characters;
private Dictionary<string, Param> _params;
/// <summary>
/// 用于单例模式
@@ -41,7 +39,6 @@ namespace AibisDream.Kit
public override void OnSingletonInit()
{
InitCharacterConfig();
InitParamConfig();
}
private void InitCharacterConfig()
@@ -52,15 +49,6 @@ namespace AibisDream.Kit
.ToDictionary(item => item.Key, item => item);
}
private void InitParamConfig()
{
var paramList = CsvUtil.ReadAsBean<Param>(Application.streamingAssetsPath + ParamConfigPath);
_params = paramList
.Where(item => !string.IsNullOrWhiteSpace(item.Key))
.ToDictionary(item => item.Key, item => item);
}
/// <summary>
/// 按key获取角色预设
/// </summary>
@@ -72,17 +60,6 @@ namespace AibisDream.Kit
return _characters.TryGetValue(key, out character);
}
/// <summary>
/// 按key获取参数本地化配置
/// </summary>
/// <param name="key">key</param>
/// <param name="param">参数配置</param>
/// <returns>是否能获取</returns>
public bool TryGetParam(string key, out Param param)
{
return _params.TryGetValue(key, out param);
}
/// <summary>
/// 按类型读取Json配置
/// </summary>
@@ -176,4 +153,4 @@ namespace AibisDream.Kit
return fileNames;
}
}
}
}
-27
View File
@@ -1,27 +0,0 @@
using AibisDream.Framework;
namespace AibisDream.Kit
{
public class Param
{
public string Key { get; set; }
public string Cn { get; set; }
public string En { get; set; }
public string Ja { get; set; }
public string LocalizeByKind(LocaleKind kind)
{
switch (kind)
{
case LocaleKind.En:
return string.IsNullOrWhiteSpace(En) ? Key : En;
case LocaleKind.Ja:
if (!string.IsNullOrWhiteSpace(Ja)) return Ja;
return string.IsNullOrWhiteSpace(Cn) ? Key : Cn;
case LocaleKind.Cn:
default:
return string.IsNullOrWhiteSpace(Cn) ? Key : Cn;
}
}
}
}
@@ -1,5 +1,7 @@
using System.Collections;
using AibisDream.Kit;
using System;
using System.Threading.Tasks;
using AibisDream.Utility;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
@@ -127,39 +129,85 @@ namespace AibisDream.Framework
}
/// <summary>
/// 本地化参数
/// 判断字符串是否为 l10n 参数引用。
/// </summary>
/// <param name="param">参数名</param>
/// <returns></returns>
public static string LocalizeParam(string param)
public static bool IsLocalizedParam(string value)
{
return LocalizeParam(param, CurrentLocaleKind);
return !string.IsNullOrEmpty(value) &&
value.StartsWith(LocalizationPrefix, StringComparison.Ordinal);
}
/// <summary>
/// 按指定语言列解析本地化参数,供需要响应 Locale 切换的非 Yarn UI 使用
/// 从 Params String Table 异步解析 l10n 参数。非 l10n 参数原样返回
/// 当前 Locale 条目为空时保留空值,不使用任何 Locale 回退。
/// </summary>
public static string LocalizeParam(string param, LocaleKind localeKind)
public static async Task<string> LocalizeParamAsync(string param, Locale locale = null)
{
// 非本地化参数直接返回
if (string.IsNullOrEmpty(param)) return param;
if (!param.StartsWith(LocalizationPrefix)) return param;
if (!IsLocalizedParam(param))
return param;
var localizationKey = param[LocalizationPrefix.Length..];
if (ConfigUtil.Instance.TryGetParam(localizationKey, out var value))
string localizationKey = GetL10NParamKey(param);
if (string.IsNullOrEmpty(localizationKey))
{
return value.LocalizeByKind(localeKind);
Debug.LogError("[LocalizationKit] l10n 参数缺少 Entry Key。");
return "⟦empty⟧";
}
// 如果找不到,则使用原始值
Debug.LogWarning($"Variable Key '{localizationKey}' not found in params csv.");
return localizationKey;
try
{
await LocalizationSettings.InitializationOperation.Task;
Locale resolvedLocale = locale != null ? locale : LocalizationSettings.SelectedLocale;
var operation = LocalizationSettings.StringDatabase.GetTableEntryAsync(
ConstRef.ParamsTable,
localizationKey,
resolvedLocale,
FallbackBehavior.DontUseFallback);
var result = await operation.Task;
if (result.Entry != null)
return result.Entry.LocalizedValue ?? string.Empty;
string localeCode = resolvedLocale != null
? resolvedLocale.Identifier.Code
: "<null>";
Debug.LogError(
$"[LocalizationKit] Params 缺少条目:Table={ConstRef.ParamsTable}, " +
$"Key={localizationKey}, Locale={localeCode}。");
return $"⟦{localizationKey}⟧";
}
catch (Exception exception)
{
string localeCode = locale != null
? locale.Identifier.Code
: LocalizationSettings.SelectedLocale?.Identifier.Code ?? "<null>";
Debug.LogError(
$"[LocalizationKit] Params 查询失败:Table={ConstRef.ParamsTable}, " +
$"Key={localizationKey}, Locale={localeCode}。\n{exception}");
return $"⟦{localizationKey}⟧";
}
}
/// <summary>
/// 协程版 l10n 参数解析,供 Yarn Command 和 MonoBehaviour 使用。
/// </summary>
public static IEnumerator LocalizeParam(
string param,
Locale locale,
Action<string> completed)
{
Task<string> task = LocalizeParamAsync(param, locale);
while (!task.IsCompleted)
yield return null;
string result = task.Status == TaskStatus.RanToCompletion
? task.Result
: $"⟦{GetL10NParamKey(param)}⟧";
completed?.Invoke(result);
}
public static string GetL10NParamKey(string key)
{
// 本地化参数则去除前缀,非本地化参数直接返回
return key.StartsWith(LocalizationPrefix) ? key[LocalizationPrefix.Length..] : key;
return IsLocalizedParam(key) ? key[LocalizationPrefix.Length..] : key;
}
/// <summary>
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using AibisDream.Framework;
using UnityEngine;
namespace AibisDream.MiniGame.Language
{
[Serializable]
public sealed class ExpressionRoundDefinition
{
[SerializeField] private string id;
[SerializeField] private string targetReference;
[SerializeField] private string tokenReference;
[SerializeField, Min(0)] private int nonTargetParticleCount;
public string Id => id;
public string TargetReference => targetReference;
public string TokenReference => tokenReference;
public int NonTargetParticleCount => nonTargetParticleCount;
public bool TryValidate(out string error)
{
if (string.IsNullOrWhiteSpace(id))
{
error = "轮次 ID 不能为空。";
return false;
}
if (!LocalizationKit.IsLocalizedParam(targetReference))
{
error = $"轮次 '{id}' 的目标文字必须配置为 l10n.* 引用。";
return false;
}
if (!LocalizationKit.IsLocalizedParam(tokenReference))
{
error = $"轮次 '{id}' 的干扰 token 必须配置为 l10n.* 引用。";
return false;
}
if (nonTargetParticleCount < 0)
{
error = $"轮次 '{id}' 的非目标粒子数量不能为负数。";
return false;
}
error = null;
return true;
}
}
[CreateAssetMenu(
fileName = "ExpressionContentCatalog",
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)
{
EnsureLookup();
if (string.IsNullOrWhiteSpace(roundId))
{
definition = null;
return false;
}
return roundById.TryGetValue(roundId, out definition);
}
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)
{
errors.Add("Catalog 中没有表达轮次。");
return errors;
}
for (int i = 0; i < rounds.Count; i++)
{
ExpressionRoundDefinition round = rounds[i];
if (round == null)
{
errors.Add($"rounds[{i}] 为空。");
continue;
}
if (!round.TryValidate(out string error))
errors.Add(error);
if (!string.IsNullOrWhiteSpace(round.Id) && !ids.Add(round.Id))
errors.Add($"存在重复轮次 ID'{round.Id}'。");
}
return errors;
}
private void EnsureLookup()
{
if (roundById != null)
return;
roundById = new Dictionary<string, ExpressionRoundDefinition>(StringComparer.Ordinal);
if (rounds == null)
return;
foreach (ExpressionRoundDefinition round in rounds)
{
if (round == null || string.IsNullOrWhiteSpace(round.Id) || roundById.ContainsKey(round.Id))
continue;
roundById.Add(round.Id, round);
}
}
private void OnValidate()
{
roundById = null;
}
}
}
@@ -1,11 +1,11 @@
fileFormatVersion: 2
guid: c12e0d2c486f4ea6a5d4f70878b6e7c1
guid: a86a72005fba4c99a59962fb9e92df83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -4,6 +4,7 @@ using UnityEngine;
using AibisDream.FixSystem;
using AibisDream.MiniGame.Language;
using System.Collections.Generic;
using UnityEngine.Localization;
namespace AibisDream
{
@@ -17,6 +18,7 @@ namespace AibisDream
[SerializeField] private GameObject expressionView;
[SerializeField] private LogReleasePresentationController logReleasePresentation;
[SerializeField] private ExpressionScreenPresentationController screenPresentation;
[SerializeField] private ExpressionContentCatalog contentCatalog;
[Header("默认配置")]
[SerializeField] private List<string> defaultAnxietyPhrases = new List<string>
@@ -54,6 +56,18 @@ namespace AibisDream
[SerializeField, Range(0.3f, 1f)] private float screenStressCap = 0.85f;
public LogReleasePresentationController LogReleasePresentation => logReleasePresentation;
public ExpressionContentCatalog ContentCatalog => contentCatalog;
public Locale ActiveExpressionLocale { get; private set; }
public Locale GetExpressionLocale(Locale fallback)
{
return ActiveExpressionLocale != null ? ActiveExpressionLocale : fallback;
}
public void SetActiveExpressionLocale(Locale locale)
{
ActiveExpressionLocale = locale;
}
/// <summary>当前屏幕 Glitch 档位(01),由 expression_screen_glitch 持续保持。</summary>
public float ScreenGlitchLevel => screenGlitchIntensity;
@@ -274,6 +288,7 @@ namespace AibisDream
/// </summary>
public void CloseView()
{
ActiveExpressionLocale = null;
screenPresentation?.ResetImmediate();
ResetScreenGlitchState();
ApplyScreenGlitchProperties();
@@ -941,6 +956,7 @@ namespace AibisDream
private void OnDisable()
{
ActiveExpressionLocale = null;
ResetScreenGlitchState();
ApplyScreenGlitchProperties();
@@ -0,0 +1,108 @@
using System.Collections.Generic;
using System.Globalization;
namespace AibisDream.MiniGame.Language
{
/// <summary>
/// 将玩家可见文字按 Unicode 文本元素拆分。空白保留在布局中,但不生成粒子。
/// 随机粒子字符池不使用本类,维持现有行为。
/// </summary>
public static class ExpressionTextTokenizer
{
public readonly struct Layout
{
public Layout(List<string> visibleElements, List<float> offsets)
{
VisibleElements = visibleElements;
Offsets = offsets;
}
public IReadOnlyList<string> VisibleElements { get; }
public IReadOnlyList<float> Offsets { get; }
public int Count => VisibleElements?.Count ?? 0;
}
public static List<string> GetVisibleElements(string text)
{
var result = new List<string>();
if (string.IsNullOrEmpty(text))
return result;
TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(text);
while (enumerator.MoveNext())
{
string element = enumerator.GetTextElement();
if (!string.IsNullOrWhiteSpace(element))
result.Add(element);
}
return result;
}
public static Layout BuildLayout(
string text,
float characterSpacing,
float whitespaceSpacingMultiplier)
{
var visible = new List<string>();
var positions = new List<float>();
if (string.IsNullOrEmpty(text))
return new Layout(visible, positions);
float spacing = characterSpacing > 0f ? characterSpacing : 1f;
float whitespaceAdvance = spacing * whitespaceSpacingMultiplier;
float cursor = 0f;
TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(text);
while (enumerator.MoveNext())
{
string element = enumerator.GetTextElement();
if (string.IsNullOrWhiteSpace(element))
{
if (visible.Count > 0)
cursor += whitespaceAdvance;
continue;
}
visible.Add(element);
positions.Add(cursor);
cursor += spacing;
}
if (positions.Count > 0)
{
float center = (positions[0] + positions[positions.Count - 1]) * 0.5f;
for (int i = 0; i < positions.Count; i++)
positions[i] -= center;
}
return new Layout(visible, positions);
}
public static int FindVisibleSequence(
IReadOnlyList<string> source,
IReadOnlyList<string> fragment)
{
if (source == null || fragment == null || fragment.Count == 0 || fragment.Count > source.Count)
return -1;
int lastStart = source.Count - fragment.Count;
for (int start = 0; start <= lastStart; start++)
{
bool matches = true;
for (int i = 0; i < fragment.Count; i++)
{
if (!string.Equals(source[start + i], fragment[i], System.StringComparison.Ordinal))
{
matches = false;
break;
}
}
if (matches)
return start;
}
return -1;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f8ba4ca26b9b46649c0da7161dc9c756
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -93,6 +93,7 @@ namespace AibisDream.MiniGame.Language
[SerializeField] private float floatingTextFontSize = 3f; // 背景浮动文字大小
[SerializeField] private bool targetParticleBold = true; // 目标粒子是否加粗
[SerializeField] private bool nonTargetParticleBold = false; // 非目标粒子是否加粗
[SerializeField, Range(0.1f, 2f)] private float whitespaceSpacingMultiplier = 0.6f;
[Header("非目标粒子干扰效果")]
[SerializeField, Range(0f, 0.1f)] private float nonTargetShakeIntensity = 0.02f; // 非目标粒子抖动强度
@@ -354,10 +355,18 @@ namespace AibisDream.MiniGame.Language
CaptureDefaultCandidateCountFromInspector();
string useSentence = string.IsNullOrEmpty(sentence) ? "这是一个测试示例" : sentence;
int visibleTargetCount = ExpressionTextTokenizer.GetVisibleElements(useSentence).Count;
if (visibleTargetCount == 0)
{
Debug.LogError("[LanguageParticleManager] 目标句没有可显示的 Unicode 文本元素;初始化已中止。");
return;
}
if (nonTargetParticleTotal >= 0)
{
candidateCount = Mathf.Max(useSentence.Length + nonTargetParticleTotal, useSentence.Length);
candidateCount = Mathf.Max(
visibleTargetCount + nonTargetParticleTotal,
visibleTargetCount);
}
else
{
@@ -409,7 +418,7 @@ namespace AibisDream.MiniGame.Language
TextParticle.SetAnxietyPhrases(anxietyPhrases);
// 根据目标句子更新目标粒子数量
redParticleCount = Mathf.Min(targetSentence.Length, candidateCount);
redParticleCount = Mathf.Min(visibleTargetCount, candidateCount);
// 重新初始化游戏状态并播放入场动画
ResetGame(true);
@@ -1247,7 +1256,11 @@ namespace AibisDream.MiniGame.Language
SetStatusUIVisibility(true, 0f);
orderedRedParticles = GetOrderedRedParticles();
int displayLength = Mathf.Min(orderedRedParticles.Count, targetSentence.Length);
ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout(
targetSentence,
0.9f,
whitespaceSpacingMultiplier);
int displayLength = Mathf.Min(orderedRedParticles.Count, layout.Count);
if (displayLength < orderedRedParticles.Count)
{
@@ -1256,20 +1269,17 @@ namespace AibisDream.MiniGame.Language
for (int i = 0; i < displayLength; i++)
{
finalTargetCharacters.Add(targetSentence[i].ToString());
finalTargetCharacters.Add(layout.VisibleElements[i]);
}
Vector3 displayCenter = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
if (finalTargetCharacters.Count > 0)
{
float spacing = 0.9f;
float totalWidth = spacing * Mathf.Max(0, finalTargetCharacters.Count - 1);
float startX = -totalWidth / 2f;
finalTargetPositions.Clear();
Bounds clip = GetCompletionClipBounds();
for (int i = 0; i < finalTargetCharacters.Count; i++)
{
Vector3 pos = displayCenter + new Vector3(startX + i * spacing, 0f, 0f);
Vector3 pos = displayCenter + new Vector3(layout.Offsets[i], 0f, 0f);
finalTargetPositions.Add(ClampPositionToBounds(pos, clip));
}
}
@@ -2151,6 +2161,7 @@ namespace AibisDream.MiniGame.Language
List<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话失稳"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
Color unstableColor = truthAttackColor;
if (!string.IsNullOrWhiteSpace(colorHex))
@@ -2169,8 +2180,8 @@ namespace AibisDream.MiniGame.Language
float totalDuration = Mathf.Max(0.12f, duration);
float resolvedShakeAmplitude = Mathf.Max(0f, shakeAmplitude);
float resolvedShakeSpeed = Mathf.Max(0f, shakeSpeed);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text.Length);
UpdateFinalPresentationPhrase(text, destinations);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
UpdateFinalPresentationPhrase(characters, destinations);
for (int i = 0; i < particles.Count; i++)
{
@@ -2197,13 +2208,13 @@ namespace AibisDream.MiniGame.Language
particle.resolvedColorProgress = 1f;
particle.calmProgress = 1f;
if (i >= text.Length)
if (i >= characters.Count)
{
particle.alpha = 0f;
continue;
}
particle.ForceSetCharacter(text[i].ToString());
particle.ForceSetCharacter(characters[i]);
particle.alpha = 1f;
particle.SetFocusInterference(
unstableColor,
@@ -2221,7 +2232,7 @@ namespace AibisDream.MiniGame.Language
yield return new WaitForSeconds(totalDuration);
for (int i = 0; i < text.Length && i < particles.Count; i++)
for (int i = 0; i < characters.Count && i < particles.Count; i++)
{
CandidateParticle particle = particles[i];
if (particle == null)
@@ -2239,6 +2250,7 @@ namespace AibisDream.MiniGame.Language
List<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话攻击"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
PreparePresentationPhraseControl();
@@ -2247,8 +2259,8 @@ namespace AibisDream.MiniGame.Language
float travelDuration = totalDuration * 0.68f;
float settleDuration = Mathf.Max(0.04f, totalDuration - travelDuration);
int directionSign = truthAttackSequenceIndex++ % 2 == 0 ? -1 : 1;
List<Vector3> destinations = CalculatePresentationPhrasePositions(text.Length);
UpdateFinalPresentationPhrase(text, destinations);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
UpdateFinalPresentationPhrase(characters, destinations);
for (int i = 0; i < particles.Count; i++)
{
@@ -2276,7 +2288,7 @@ namespace AibisDream.MiniGame.Language
particle.resolvedColorProgress = 1f;
particle.calmProgress = 1f;
if (i >= text.Length)
if (i >= characters.Count)
{
DOTween.To(
() => particle.alpha,
@@ -2288,7 +2300,7 @@ namespace AibisDream.MiniGame.Language
continue;
}
particle.ForceSetCharacter(text[i].ToString());
particle.ForceSetCharacter(characters[i]);
particle.alpha = 1f;
particle.focusInterferenceProgress = 1f;
@@ -2325,7 +2337,7 @@ namespace AibisDream.MiniGame.Language
yield return new WaitForSeconds(totalDuration);
for (int i = 0; i < text.Length && i < particles.Count; i++)
for (int i = 0; i < characters.Count && i < particles.Count; i++)
{
CandidateParticle particle = particles[i];
if (particle == null)
@@ -2344,6 +2356,7 @@ namespace AibisDream.MiniGame.Language
List<CandidateParticle> particles = GetPresentationPhraseParticles();
if (!ValidatePresentationPhrase(text, particles, "真话稳定"))
yield break;
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
PreparePresentationPhraseControl();
@@ -2384,8 +2397,8 @@ namespace AibisDream.MiniGame.Language
yield return new WaitForSeconds(breakDuration);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text.Length);
UpdateFinalPresentationPhrase(text, destinations);
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
UpdateFinalPresentationPhrase(characters, destinations);
for (int i = 0; i < particles.Count; i++)
{
CandidateParticle particle = particles[i];
@@ -2410,14 +2423,14 @@ namespace AibisDream.MiniGame.Language
particle.resolvedColorProgress = 1f;
particle.calmProgress = 1f;
if (i >= text.Length)
if (i >= characters.Count)
{
particle.alpha = 0f;
particle.ClearFocusInterference();
continue;
}
particle.ForceSetCharacter(text[i].ToString());
particle.ForceSetCharacter(characters[i]);
particle.SetFocusInterference(truthAttackColor, 0f, 0f);
particle.focusInterferenceProgress = 1f;
particle.alpha = 0.15f;
@@ -2453,8 +2466,8 @@ namespace AibisDream.MiniGame.Language
if (particle == null)
continue;
particle.ClearFocusInterference();
particle.alpha = i < text.Length ? 1f : 0f;
if (i < text.Length)
particle.alpha = i < characters.Count ? 1f : 0f;
if (i < characters.Count)
{
particle.transform.position = destinations[i];
particle.transform.localScale = Vector3.one * focusZoomScale;
@@ -2494,10 +2507,17 @@ namespace AibisDream.MiniGame.Language
return false;
}
if (text.Length > particles.Count)
int requiredParticles = ExpressionTextTokenizer.GetVisibleElements(text).Count;
if (requiredParticles == 0)
{
Debug.LogWarning($"[LanguageParticleManager] {context}没有可显示的文本元素;已安全跳过。");
return false;
}
if (requiredParticles > particles.Count)
{
Debug.LogWarning(
$"[LanguageParticleManager] {context}“{text}”需要 {text.Length} 个目标粒子," +
$"[LanguageParticleManager] {context}“{text}”需要 {requiredParticles} 个目标粒子," +
$"当前只有 {particles.Count} 个;为避免截断已跳过。");
return false;
}
@@ -2521,27 +2541,32 @@ namespace AibisDream.MiniGame.Language
connectionRenderer.TargetAlphaScale = 0f;
}
private List<Vector3> CalculatePresentationPhrasePositions(int characterCount)
private List<Vector3> CalculatePresentationPhrasePositions(string text)
{
var positions = new List<Vector3>(Mathf.Max(0, characterCount));
Vector3 center = worldCanvas != null ? worldCanvas.transform.position : transform.position;
float spacing = Mathf.Max(0.1f, truthAttackCharacterSpacing);
float startX = -spacing * Mathf.Max(0, characterCount - 1) * 0.5f;
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 < characterCount; i++)
for (int i = 0; i < layout.Count; i++)
{
Vector3 position = center + new Vector3(startX + i * spacing, 0f, 0f);
Vector3 position = center + new Vector3(layout.Offsets[i], 0f, 0f);
positions.Add(ClampPositionToBounds(position, clip));
}
return positions;
}
private void UpdateFinalPresentationPhrase(string text, List<Vector3> positions)
private void UpdateFinalPresentationPhrase(
IReadOnlyList<string> characters,
List<Vector3> positions)
{
finalTargetCharacters.Clear();
finalTargetPositions.Clear();
for (int i = 0; i < text.Length; i++)
finalTargetCharacters.Add(text[i].ToString());
for (int i = 0; i < characters.Count; i++)
finalTargetCharacters.Add(characters[i]);
finalTargetPositions.AddRange(positions);
}
@@ -2579,7 +2604,9 @@ namespace AibisDream.MiniGame.Language
if (string.IsNullOrEmpty(fragment) || orderedRedParticles.Count == 0)
yield break;
int startIndex = targetSentence.IndexOf(fragment, System.StringComparison.Ordinal);
List<string> targetElements = ExpressionTextTokenizer.GetVisibleElements(targetSentence);
List<string> fragmentElements = ExpressionTextTokenizer.GetVisibleElements(fragment);
int startIndex = ExpressionTextTokenizer.FindVisibleSequence(targetElements, fragmentElements);
if (startIndex < 0)
{
Debug.LogWarning($"[LanguageParticleManager] 真话泄漏片段“{fragment}”不在目标句“{targetSentence}”中。");
@@ -2587,12 +2614,12 @@ namespace AibisDream.MiniGame.Language
}
var leaked = new List<CandidateParticle>();
int endExclusive = Mathf.Min(startIndex + fragment.Length, orderedRedParticles.Count);
int endExclusive = Mathf.Min(startIndex + fragmentElements.Count, orderedRedParticles.Count);
for (int i = startIndex; i < endExclusive; i++)
{
CandidateParticle particle = orderedRedParticles[i];
if (particle == null) continue;
particle.ForceSetCharacter(targetSentence[i].ToString());
particle.ForceSetCharacter(targetElements[i]);
particle.isStatic = true;
particle.alpha = 1f;
leaked.Add(particle);
@@ -1,7 +1,11 @@
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using AibisDream.FixSystem;
using AibisDream;
using AibisDream.Framework;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using Yarn.Unity;
namespace AibisDream.MiniGame.Language
@@ -13,6 +17,66 @@ namespace AibisDream.MiniGame.Language
{
private static AibisDream.ExpressionManager ExpressionManager => FixSystemCenter.SystemDic.Get<AibisDream.ExpressionManager>();
private static Locale GetCommandLocale()
{
Locale current = LocalizationSettings.SelectedLocale;
return ExpressionManager != null
? ExpressionManager.GetExpressionLocale(current)
: current;
}
private static IEnumerator ResolveTextReferences(
Locale locale,
System.Action<string[]> completed,
params string[] references)
{
var tasks = new Task<string>[references.Length];
for (int i = 0; i < references.Length; i++)
tasks[i] = LocalizationKit.LocalizeParamAsync(references[i], locale);
Task<string[]> all = Task.WhenAll(tasks);
while (!all.IsCompleted)
yield return null;
completed?.Invoke(all.Status == TaskStatus.RanToCompletion
? all.Result
: System.Array.Empty<string>());
}
private static bool ValidateRequiredText(string value, string command, string parameter)
{
if (!string.IsNullOrWhiteSpace(value))
return true;
UnityEngine.Debug.LogError(
$"[LanguageYarnCommand] {command} 的玩家可见参数 {parameter} 解析为空;命令已安全结束。");
return false;
}
public static bool TryParseExpressionTokens(string value, out List<string> tokens)
{
tokens = new List<string>();
if (string.IsNullOrWhiteSpace(value))
return false;
if (value.Contains(""))
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] 干扰 token 使用了全角分隔符“|”;请改用半角“|”。");
return false;
}
string[] parts = value.Split('|');
foreach (string part in parts)
{
string token = part.Trim();
if (!string.IsNullOrEmpty(token))
tokens.Add(token);
}
return tokens.Count > 0;
}
private static bool TryGetPresentation(out LogReleasePresentationController presentation)
{
presentation = ExpressionManager != null
@@ -30,8 +94,8 @@ namespace AibisDream.MiniGame.Language
/// 启动表达粒子系统(在打开视图后调用),等粒子字入场完成即可返回(可交互);panel 动效并行不阻塞。
/// 流程:若有上一轮则先淡出字与连线 → 再淡入火山释放 log → 再启动新一轮并等粒子就绪。
/// 注意:Yarn Spinner 2.x 对同名 <see cref="YarnCommand"/> 重载支持不可靠,只保留一个注册入口,用可选参数区分 3/4 参调用。
/// <<start_expression "哈哈|嘿嘿|呵呵" "我很害怕" "ExpressionCompleted">>
/// <<start_expression "哈哈|嘿嘿|呵呵" "我很害怕" "ExpressionCompleted" 24>>
/// <<start_expression "l10n.hs.exp.log1.tokens" "l10n.hs.exp.log1.target" "ExpressionCompleted">>
/// <<start_expression "l10n.hs.exp.log1.tokens" "l10n.hs.exp.log1.target" "ExpressionCompleted" 24>>
/// (第四项为「非目标」候选粒子总数,候选池 = 目标句字数 + 该值;不写第四项则用 Inspector 的 Candidate Count
/// </summary>
/// <param name="anxietyPhrasesStr">干扰短句(笑话等),用 | 分隔,用于非目标粒子字符池,如:"哈哈|嘿嘿|呵呵"</param>
@@ -60,21 +124,103 @@ namespace AibisDream.MiniGame.Language
yield break;
}
string[] phrases = null;
if (!string.IsNullOrEmpty(anxietyPhrasesStr))
Locale locale = LocalizationSettings.SelectedLocale;
string[] localized = null;
yield return ResolveTextReferences(
locale,
values => localized = values,
anxietyPhrasesStr,
targetSentence);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "start_expression", nameof(anxietyPhrasesStr)) ||
!ValidateRequiredText(localized[1], "start_expression", nameof(targetSentence)))
yield break;
if (!TryParseExpressionTokens(localized[0], out List<string> phrases))
{
phrases = anxietyPhrasesStr.Split('|');
for (int i = 0; i < phrases.Length; i++)
{
phrases[i] = phrases[i].Trim();
}
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] start_expression 没有有效的干扰 token;命令已安全结束。");
yield break;
}
// 先清理上一轮演出,再淡出旧文字/连线并恢复 Log 操作界面。
yield return ExpressionManager.PreparePresentationForNextRound();
yield return ExpressionManager.FadeOutParticlesBeforeNewRound(0f);
yield return ExpressionManager.EnsureReleaseLogFadedIn(1f);
ExpressionManager.StartSystem(phrases, targetSentence, completionNode, nonTargetParticleTotal);
ExpressionManager.SetActiveExpressionLocale(locale);
ExpressionManager.StartSystem(
phrases,
localized[1],
completionNode,
nonTargetParticleTotal);
yield return ExpressionManager.WaitUntilExpressionFlowReady();
}
/// <summary>
/// 使用 ExpressionContentCatalog 中的轮次配置启动表达系统。
/// <<start_expression_round "log1" "LOG1梳理完成">>
/// </summary>
[YarnCommand("start_expression_round")]
public static IEnumerator StartExpressionRound(string roundId, string completionNode = "")
{
if (ExpressionManager == null)
{
UnityEngine.Debug.LogError("ExpressionManager 未找到!请确保场景中有 ExpressionManager 组件。");
yield break;
}
ExpressionContentCatalog catalog = ExpressionManager.ContentCatalog;
if (catalog == null)
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] ExpressionContentCatalog 未配置;无法启动表达轮次。");
yield break;
}
List<string> catalogErrors = catalog.GetValidationErrors();
if (catalogErrors.Count > 0)
{
UnityEngine.Debug.LogError(
"[LanguageYarnCommand] ExpressionContentCatalog 数据无效;无法启动表达轮次。\n" +
string.Join("\n", catalogErrors));
yield break;
}
if (!catalog.TryGetRound(roundId, out ExpressionRoundDefinition round))
{
UnityEngine.Debug.LogError(
$"[LanguageYarnCommand] ExpressionContentCatalog 中不存在轮次 '{roundId}'。");
yield break;
}
Locale locale = LocalizationSettings.SelectedLocale;
string[] localized = null;
yield return ResolveTextReferences(
locale,
values => localized = values,
round.TokenReference,
round.TargetReference);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "start_expression_round", nameof(round.TokenReference)) ||
!ValidateRequiredText(localized[1], "start_expression_round", nameof(round.TargetReference)))
yield break;
if (!TryParseExpressionTokens(localized[0], out List<string> phrases))
{
UnityEngine.Debug.LogError(
$"[LanguageYarnCommand] 表达轮次 '{roundId}' 没有有效的干扰 token。");
yield break;
}
yield return ExpressionManager.PreparePresentationForNextRound();
yield return ExpressionManager.FadeOutParticlesBeforeNewRound(0f);
yield return ExpressionManager.EnsureReleaseLogFadedIn(1f);
ExpressionManager.SetActiveExpressionLocale(locale);
ExpressionManager.StartSystem(
phrases,
localized[1],
completionNode ?? string.Empty,
round.NonTargetParticleCount);
yield return ExpressionManager.WaitUntilExpressionFlowReady();
}
@@ -241,8 +387,17 @@ namespace AibisDream.MiniGame.Language
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(keyword, preset));
ExpressionManager.BeginExpressionLie(localized[0], preset));
}
[YarnCommand("expression_lie_break")]
@@ -385,24 +540,37 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 结尾真假快速闪切(非阻塞,节奏由同长度 wait 控制):真心话与谎话交替独占屏幕,
/// 两者的屏幕底色(电视头背景)不同,越到后面切得越快,结束定格在真话帧。
/// <<expression_truth_lie_flicker "没问题|冇问题|帽问题" "我就是个笑话" 3.2 0.24 12>>
/// <<expression_truth_lie_flicker "l10n.hs.exp.actor.loop" "l10n.hs.exp.log1.target" 3.2 0.24 12>>
/// </summary>
[YarnCommand("expression_truth_lie_flicker")]
public static void ExpressionTruthLieFlicker(
public static IEnumerator ExpressionTruthLieFlicker(
string liePhrases,
string truthPhrase,
float duration,
float interval = 0.24f,
float fontSize = 12f)
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StartTruthLieFlicker(liePhrases, truthPhrase, duration, interval, fontSize);
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
liePhrases,
truthPhrase);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "expression_truth_lie_flicker", nameof(liePhrases)) ||
!ValidateRequiredText(localized[1], "expression_truth_lie_flicker", nameof(truthPhrase)))
yield break;
presentation.StartTruthLieFlicker(localized[0], localized[1], duration, interval, fontSize);
}
/// <summary>
/// 定格的谎话被 glitch 翻转成真话(阻塞):大字乱跳中长度从谎话渐变到真话、真假底色抢闪,
/// 最后落在暖色真话。紧接 expression_memory_cut + expression_truth_snap 收尾。
/// <<expression_lie_morph_truth "没问题" "我就是个笑话" 0.9>>
/// <<expression_lie_morph_truth "l10n.hs.exp.atk.why" "l10n.hs.exp.log1.target" 0.9>>
/// </summary>
[YarnCommand("expression_lie_morph_truth")]
public static IEnumerator ExpressionLieMorphTruth(
@@ -412,8 +580,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
lieText,
truthText);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "expression_lie_morph_truth", nameof(lieText)) ||
!ValidateRequiredText(localized[1], "expression_lie_morph_truth", nameof(truthText)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.MorphLieToTruth(lieText, truthText, duration));
presentation.MorphLieToTruth(localized[0], localized[1], duration));
}
[YarnCommand("expression_truth_leak")]
@@ -421,24 +600,44 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
fragment);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_truth_leak", nameof(fragment)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.LeakTruthFragment(fragment, duration));
presentation.LeakTruthFragment(localized[0], duration));
}
[YarnCommand("expression_lie_shuffle")]
public static void ExpressionLieShuffle(
public static IEnumerator ExpressionLieShuffle(
string characterPool,
float duration,
float interval)
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StartLieCharacterShuffle(characterPool, duration, interval);
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
characterPool);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_lie_shuffle", nameof(characterPool)))
yield break;
presentation.StartLieCharacterShuffle(localized[0], duration, interval);
}
/// <summary>
/// 演员大字闪现(阻塞):抖动的粒子字瞬间隐藏,屏幕中央出现白色大字(红青色散双影+急速缩放),
/// 停顿 holdDuration 秒后始终恢复粒子字抖动;结尾定格请使用 expression_actor_flash_in。
/// <<expression_actor_flash "没问题" 0.6 10.5 1.15 1.60 0.03 0.22 0.5 0.2>>
/// <<expression_actor_flash "l10n.hs.exp.atk.why" 0.6 10.5 1.15 1.60 0.03 0.22 0.5 0.2>>
/// </summary>
[YarnCommand("expression_actor_flash")]
public static IEnumerator ExpressionActorFlash(
@@ -454,9 +653,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_flash", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.FlashActorLie(
text,
localized[0],
holdDuration,
fontSize,
settledScale,
@@ -470,7 +679,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 演员大字入场并保持(阻塞):入场表现与 expression_actor_flash 相同,但不执行退场,
/// 适合在段落结尾定格。后续清场命令仍会正常移除它。
/// <<expression_actor_flash_in "没问题" 0.35 10.5 1.1 1.45 0.008 0.06 0.5 0>>
/// <<expression_actor_flash_in "l10n.hs.exp.atk.why" 0.35 10.5 1.1 1.45 0.008 0.06 0.5 0>>
/// </summary>
[YarnCommand("expression_actor_flash_in")]
public static IEnumerator ExpressionActorFlashIn(
@@ -486,9 +695,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_flash_in", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.FlashActorLie(
text,
localized[0],
holdDuration,
fontSize,
settledScale,
@@ -505,10 +724,36 @@ namespace AibisDream.MiniGame.Language
/// <<expression_actor_flash_loop_start>>
/// </summary>
[YarnCommand("expression_actor_flash_loop_start")]
public static void ExpressionActorFlashLoopStart()
public static IEnumerator ExpressionActorFlashLoopStart()
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StartActorFlashLoop();
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>
@@ -525,7 +770,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 真话闪现(阻塞):谎话大字之间,暖色完整真话带细微抖动挣扎浮现一拍;
/// "被掐断"由紧随其后的 expression_glitch_pulse 表现。不推进谎话冲击等级。
/// <<expression_actor_truth_flash "我就是个笑话" 0.35 8.5 0.55 0.02>>
/// <<expression_actor_truth_flash "l10n.hs.exp.log1.target" 0.35 8.5 0.55 0.02>>
/// </summary>
[YarnCommand("expression_actor_truth_flash")]
public static IEnumerator ExpressionActorTruthFlash(
@@ -537,25 +782,52 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_truth_flash", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.FlashActorTruth(text, holdDuration, fontSize, alpha, jitter));
presentation.FlashActorTruth(localized[0], holdDuration, fontSize, alpha, jitter));
}
/// <summary>
/// 老虎机换字(非阻塞,节拍由 Yarn wait 控制):聚焦字在 duration 内只从 charPool 里疯狂换字。
/// truthPool 非空时其字符也混入换字池,并以真话暖色显示。
/// <<expression_actor_slot "没问题冇" 5 0.05 14 "我就是个笑话">>
/// <<expression_actor_slot "l10n.hs.exp.actor.loop" 5 0.05 14 "l10n.hs.exp.log1.target">>
/// </summary>
[YarnCommand("expression_actor_slot")]
public static void ExpressionActorSlot(
public static IEnumerator ExpressionActorSlot(
string charPool,
float duration,
float interval = 0.05f,
float fontSize = 14f,
string truthPool = "")
{
if (TryGetPresentation(out LogReleasePresentationController presentation))
presentation.StartActorSlotMachine(charPool, duration, interval, fontSize, truthPool);
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
charPool,
truthPool);
if (localized == null || localized.Length != 2 ||
!ValidateRequiredText(localized[0], "expression_actor_slot", nameof(charPool)))
yield break;
presentation.StartActorSlotMachine(
localized[0],
duration,
interval,
fontSize,
localized[1] ?? string.Empty);
}
/// <summary>
@@ -583,7 +855,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 单字/短词满屏重击,并等待该拍结束。
/// <<expression_actor_word_hit "" 0.16 18 1.55>>
/// <<expression_actor_word_hit "l10n.hs.exp.atk.why" 0.16 18 1.55>>
/// </summary>
[YarnCommand("expression_actor_word_hit")]
public static IEnumerator ExpressionActorWordHit(
@@ -594,13 +866,23 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_word_hit", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.PlayActorWordHit(text, holdDuration, fontSize, punchScale));
presentation.PlayActorWordHit(localized[0], holdDuration, fontSize, punchScale));
}
/// <summary>
/// 单列词组严格裁在表情屏内,像字幕一样无缝向下滚动。
/// <<expression_actor_scroll "没问题|冇问题|帽问题" 1.8 0.85 0.45 5.2>>
/// <<expression_actor_scroll "l10n.hs.exp.actor.loop" 1.8 0.85 0.45 5.2>>
/// </summary>
[YarnCommand("expression_actor_scroll")]
public static IEnumerator ExpressionActorScroll(
@@ -612,9 +894,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
phraseList);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_actor_scroll", nameof(phraseList)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.ScrollActorLies(
phraseList,
localized[0],
scrollDuration,
loopDuration,
holdDuration,
@@ -742,7 +1034,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 用现有目标粒子完整显示一个红色攻击词,并等待确定性的冲击动作结束。
/// 超过目标粒子数量时会安全跳过,不会截断。
/// <<expression_truth_attack "为什么" 0.34 0.45>>
/// <<expression_truth_attack "l10n.hs.exp.atk.why" 0.34 0.45>>
/// </summary>
[YarnCommand("expression_truth_attack")]
public static IEnumerator ExpressionTruthAttack(
@@ -753,17 +1045,26 @@ namespace AibisDream.MiniGame.Language
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_truth_attack", nameof(text)))
yield break;
// 红屏武装时每个攻击词都把屏幕顶一下(punch),力度跟着这个词的 impact 走。
// 写在这里而不是 Yarn 里逐条加 pulse:攻击列表经常重调,两边手写必然脱节。
ExpressionManager.PunchScreenForAttack(impact);
yield return ExpressionManager.StartCoroutine(
presentation.PlayTruthAttack(text, duration, impact));
presentation.PlayTruthAttack(localized[0], duration, impact));
}
/// <summary>
/// Keep an already resolved truth phrase in place while it gradually becomes unstable and red.
/// <<expression_truth_destabilize "不要那样看我" 1.6 0.13 22 "FF304D">>
/// <<expression_truth_destabilize "l10n.hs.exp.log3.target" 1.6 0.13 22 "FF304D">>
/// </summary>
[YarnCommand("expression_truth_destabilize")]
public static IEnumerator ExpressionTruthDestabilize(
@@ -775,9 +1076,19 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_truth_destabilize", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.DestabilizeTruthPhrase(
text,
localized[0],
duration,
shakeAmplitude,
shakeSpeed,
@@ -786,7 +1097,7 @@ namespace AibisDream.MiniGame.Language
/// <summary>
/// 将当前攻击词重组为固定青绿色真话,并等待稳定完成。
/// <<expression_truth_settle "我想赢一次" 0.8>>
/// <<expression_truth_settle "l10n.hs.exp.final.win" 0.8>>
/// </summary>
[YarnCommand("expression_truth_settle")]
public static IEnumerator ExpressionTruthSettle(
@@ -795,8 +1106,18 @@ namespace AibisDream.MiniGame.Language
{
if (!TryGetPresentation(out LogReleasePresentationController presentation))
yield break;
string[] localized = null;
yield return ResolveTextReferences(
GetCommandLocale(),
values => localized = values,
text);
if (localized == null || localized.Length != 1 ||
!ValidateRequiredText(localized[0], "expression_truth_settle", nameof(text)))
yield break;
yield return ExpressionManager.StartCoroutine(
presentation.SettleTruthPhrase(text, duration));
presentation.SettleTruthPhrase(localized[0], duration));
}
[YarnCommand("expression_truth_blackout")]
@@ -808,14 +808,19 @@ namespace AibisDream.MiniGame.Language
}
}
public void StartActorFlashLoop()
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());
actorFlashLoopRoutine = StartCoroutine(ActorFlashLoopRoutine(phrases));
}
public void StopActorFlashLoop()
@@ -823,27 +828,33 @@ namespace AibisDream.MiniGame.Language
StopActorFlashLoopImmediate(true);
}
private IEnumerator ActorFlashLoopRoutine()
private IEnumerator ActorFlashLoopRoutine(IReadOnlyList<string> phrases)
{
while (state == PresentationState.Memory)
{
yield return FlashActorLie(
"没问题", 0.25f, 10.5f, 1.1f, 1.45f, 0.008f, 0.06f, 0.50f, 0f);
if (state != PresentationState.Memory)
break;
yield return new WaitForSeconds(0.3f);
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(
"冇问题", 0.25f, 11.2f, 1.1f, 1.85f, 0.008f, 0.06f, 0.72f, 0f);
if (state != PresentationState.Memory)
break;
yield return new WaitForSeconds(0.3f);
yield return FlashActorLie(
"帽问题", 0.15f, 12.0f, 1.1f, 2.20f, 0.010f, 0.08f, 1.00f, 0f);
if (state != PresentationState.Memory)
break;
yield return new WaitForSeconds(0.3f);
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;