feat(expression): 表达系统目标/非目标解绑与干扰短句逻辑

Made-with: Cursor
This commit is contained in:
2026-03-11 20:11:52 +08:00
parent f04c02337d
commit 725c841895
6 changed files with 237 additions and 48 deletions
@@ -8,10 +8,10 @@ namespace AibisDream.MiniGame.Language
/// </summary>
public class CandidateParticle : TextParticle
{
[Header("粒子状态")]
public bool isRed = false; // 是否是红色目标粒子
[Header("粒子状态(目标/非目标,颜色由 Manager 配置,可与红蓝解绑)")]
public bool isRed = false; // 当前是否为目标粒子(用于颜色/样式,可配成蓝)
public bool isOrange = false; // 是否受到效果影响(橙色)
public bool originalIsRed = false; // 原始是否为红色
public bool originalIsRed = false; // 原始是否为目标粒子(不变,用于字符逻辑)
public bool hasEffect = false; // 是否有效果
public bool isTemporaryRed = false; // 临时红色状态
@@ -190,6 +190,20 @@ namespace AibisDream.MiniGame.Language
}
}
/// <summary>
/// 目标粒子:chineseChars 随机单字;非目标粒子:从笑话/干扰短句字符中随机。
/// 使用 IsTarget 判断,与红蓝颜色解绑。
/// </summary>
protected override string GetNextDisplayChar()
{
return IsTarget ? GetRandomChar() : GetRandomCharFromPhrases();
}
/// <summary>
/// 是否为目标粒子(由 originalIsRed 表示,与显示颜色解绑)
/// </summary>
private bool IsTarget => originalIsRed;
public void InitializeAnxietyEffect()
{
anxietyShakePhase = Random.Range(0f, Mathf.PI * 2f);
@@ -22,11 +22,9 @@ namespace AibisDream
[Header("默认配置")]
[SerializeField] private List<string> defaultAnxietyPhrases = new List<string>
{
"我好怕",
"怎么办",
"不行",
"好难",
"做不到"
"哈哈",
"嘿嘿",
"呵呵"
};
[SerializeField] private string defaultTargetSentence = "这是一个测试示例";
@@ -71,9 +69,16 @@ namespace AibisDream
/// <summary>
/// 打开表达视图(只显示美术背景,不启动粒子系统)
/// 先设置所有初始状态,再打开视图
/// </summary>
public void OpenView()
{
// 先设置所有初始状态再打开
if (particleManager != null)
{
particleManager.SetInitialStatesBeforeOpen();
}
if (expressionView != null)
{
expressionView.SetActive(true);
@@ -83,7 +88,7 @@ namespace AibisDream
/// <summary>
/// 启动粒子系统(在打开视图后调用,通常通过 Yarn 对话控制)
/// </summary>
/// <param name="anxietyPhrases">焦虑短句列表(非目标字符会随机组成这些短句</param>
/// <param name="anxietyPhrases">干扰短句列表(笑话等,非目标粒子从中随机取字符</param>
/// <param name="targetSentence">目标句子</param>
/// <param name="completionNodeName">完成时触发的对话节点名称(可选)</param>
public void StartSystem(List<string> anxietyPhrases, string targetSentence, string completionNodeName = null)
@@ -107,6 +112,15 @@ namespace AibisDream
StartSystem(phrasesList, targetSentence, completionNodeName);
}
/// <summary>
/// 等待表达流程就绪(开场表现 + 火山表达panel 播完)
/// </summary>
public IEnumerator WaitUntilExpressionFlowReady()
{
if (particleManager == null) yield break;
yield return particleManager.WaitUntilExpressionFlowReady();
}
/// <summary>
/// 关闭表达系统
/// </summary>
@@ -39,6 +39,14 @@ namespace AibisDream.MiniGame.Language
}
}
/// <summary>
/// 背景粒子从干扰短句(笑话等)字符中随机显示单字。
/// </summary>
protected override string GetNextDisplayChar()
{
return GetRandomCharFromPhrases();
}
/// <summary>
/// 设置字体大小
/// </summary>
@@ -6,6 +6,7 @@ using DG.Tweening;
using Shapes;
using TMPro;
using UnityEngine.UI;
using AibisDream;
namespace AibisDream.MiniGame.Language
{
@@ -58,7 +59,7 @@ namespace AibisDream.MiniGame.Language
[SerializeField] private float textMargin = 1f;
[Header("游戏配置")]
[SerializeField] private List<string> anxietyPhrases = new List<string>(); // 焦虑短句配置
[SerializeField] private List<string> anxietyPhrases = new List<string>(); // 干扰短句(笑话等,用于非目标粒子,Yarn 会覆盖)
[SerializeField] private string targetSentence = "这是一个测试示例"; // 目标句子
[SerializeField] private string completionDialogNode = ""; // 完成时触发的对话节点
@@ -71,9 +72,9 @@ namespace AibisDream.MiniGame.Language
[SerializeField] private float repulsionForce = 2f;
[SerializeField] private float attractionForce = 1.5f;
[Header("颜色配置(用于粒子/字的颜色)")]
[SerializeField] private Color targetParticleColor = new Color(1f, 0.39f, 0.39f, 1f); // 目标粒子颜色(红)
[SerializeField] private Color nonTargetParticleColor = new Color(0.2f, 0.78f, 1f, 1f); // 非目标粒子颜色(
[Header("颜色配置(用于粒子/字的颜色,与目标/非目标解绑,可自由搭配")]
[SerializeField] private Color targetParticleColor = new Color(1f, 0.39f, 0.39f, 1f); // 目标粒子颜色
[SerializeField] private Color nonTargetParticleColor = new Color(0.2f, 0.78f, 1f, 1f); // 非目标粒子颜色(如笑话短句
[SerializeField] private Color floatingTextColor = new Color(0.7f, 0.78f, 1f, 1f); // 背景浮动文字颜色
[SerializeField] private Color targetCalmColor = new Color(0.78f, 0.59f, 0.59f, 1f); // 目标粒子平静后颜色
@@ -106,7 +107,13 @@ namespace AibisDream.MiniGame.Language
[SerializeField] private TMP_Text interferenceCountText;
[Header("完成阶段 UI")]
[SerializeField] private GameObject languageDeepPanel1;
[SerializeField] private GameObject languageDeepPanel2;
[SerializeField] private Button focusSequenceButton;
[Tooltip("开场表达panel Timeline 名称(DirectorName 或 DirectorName/AddressableKey),播完才算表达流程完成")]
[SerializeField] private string expressionPanelTimelineName = "火山表达模块/火山表达panel";
[Tooltip("火山表达确认 Timeline 名称,播完才解锁按钮交互")]
[SerializeField] private string expressionConfirmTimelineName = "火山表达模块/火山表达确认";
[SerializeField] private float focusZoomScale = 1.6f;
[SerializeField] private float focusMoveDuration = 1.2f;
[SerializeField] private float focusFadeDuration = 1f;
@@ -179,7 +186,9 @@ namespace AibisDream.MiniGame.Language
private float integrationTextBaseAlpha = 1f;
private float interferenceTextBaseAlpha = 1f;
private Coroutine entranceRoutine;
private Coroutine expressionPanelRoutine;
private Coroutine debugTestRoutine;
private Coroutine confirmTimelineRoutine;
private readonly Dictionary<CandidateParticle, Vector3> entranceTargets = new Dictionary<CandidateParticle, Vector3>();
private class TargetFocusVisual
@@ -254,6 +263,11 @@ namespace AibisDream.MiniGame.Language
focusSequenceButton.onClick.RemoveListener(HandleFocusButtonClicked);
focusSequenceButton.onClick.AddListener(HandleFocusButtonClicked);
}
if (languageDeepPanel1 != null)
languageDeepPanel1.SetActive(false);
if (languageDeepPanel2 != null)
languageDeepPanel2.SetActive(false);
isInitialized = true;
}
@@ -273,6 +287,34 @@ namespace AibisDream.MiniGame.Language
ResetGame(true);
}
/// <summary>
/// 在打开视图前设置所有初始状态(panels、button 等不激活)
/// </summary>
public void SetInitialStatesBeforeOpen()
{
if (languageDeepPanel1 != null)
languageDeepPanel1.SetActive(false);
if (languageDeepPanel2 != null)
languageDeepPanel2.SetActive(false);
if (focusSequenceButton != null)
{
focusSequenceButton.gameObject.SetActive(false);
focusSequenceButton.interactable = false;
}
}
/// <summary>
/// 等待表达流程就绪(开场表现 + 火山表达panel 播完)
/// </summary>
public IEnumerator WaitUntilExpressionFlowReady()
{
if (!isInitialized) yield break;
while (!allowInput)
{
yield return null;
}
}
/// <summary>
/// 停止系统(关闭时调用,但保持初始化状态,可以重新开启)
/// </summary>
@@ -293,6 +335,18 @@ namespace AibisDream.MiniGame.Language
StopCoroutine(debugTestRoutine);
debugTestRoutine = null;
}
if (confirmTimelineRoutine != null)
{
StopCoroutine(confirmTimelineRoutine);
confirmTimelineRoutine = null;
}
if (expressionPanelRoutine != null)
{
StopCoroutine(expressionPanelRoutine);
expressionPanelRoutine = null;
}
// 停止所有 DOTween 动画
DOTween.Kill(this);
@@ -640,11 +694,22 @@ namespace AibisDream.MiniGame.Language
particle.connections.Clear();
}
// 完成目标后,不再创建目标粒子与非目标粒子的新连接
bool suppressTargetNonTargetConnections = isCompleted;
// 检测连接
for (int i = 0; i < candidateParticles.Count; i++)
{
for (int j = i + 1; j < candidateParticles.Count; j++)
{
if (suppressTargetNonTargetConnections)
{
bool iIsTarget = candidateParticles[i].isRed;
bool jIsTarget = candidateParticles[j].isRed;
if (iIsTarget != jIsTarget)
continue;
}
float distance = Vector3.Distance(
candidateParticles[i].transform.position,
candidateParticles[j].transform.position
@@ -787,11 +852,27 @@ namespace AibisDream.MiniGame.Language
}
}
// 完成目标后:停止目标粒子的移动
foreach (var particle in targetParticles)
{
if (particle != null)
{
particle.velocity = Vector2.zero;
particle.isStatic = true;
}
}
// 首先激活 button 但不可交互,播放火山表达确认后再解锁
if (focusSequenceButton != null)
{
focusSequenceButton.gameObject.SetActive(true);
focusSequenceButton.interactable = true;
focusSequenceButton.interactable = false;
}
if (confirmTimelineRoutine != null)
{
StopCoroutine(confirmTimelineRoutine);
}
confirmTimelineRoutine = StartCoroutine(PlayConfirmTimelineAndUnlockButton());
foreach (var particle in candidateParticles)
{
@@ -842,6 +923,20 @@ namespace AibisDream.MiniGame.Language
return ordered;
}
private IEnumerator PlayConfirmTimelineAndUnlockButton()
{
if (!string.IsNullOrEmpty(expressionConfirmTimelineName) && TimelineCenter.Instance != null)
{
yield return TimelineCenter.Instance.PlayTimelineAsync(expressionConfirmTimelineName);
}
if (focusSequenceButton != null)
{
focusSequenceButton.interactable = true;
}
confirmTimelineRoutine = null;
}
private void HandleFocusButtonClicked()
{
if (completionPhase != CompletionPhase.Completed)
@@ -1235,6 +1330,18 @@ namespace AibisDream.MiniGame.Language
entranceRoutine = null;
}
if (confirmTimelineRoutine != null)
{
StopCoroutine(confirmTimelineRoutine);
confirmTimelineRoutine = null;
}
if (expressionPanelRoutine != null)
{
StopCoroutine(expressionPanelRoutine);
expressionPanelRoutine = null;
}
if (focusSequenceButton != null)
{
focusSequenceButton.onClick.RemoveListener(HandleFocusButtonClicked);
@@ -1243,6 +1350,11 @@ namespace AibisDream.MiniGame.Language
focusSequenceButton.gameObject.SetActive(false);
}
if (languageDeepPanel1 != null)
languageDeepPanel1.SetActive(false);
if (languageDeepPanel2 != null)
languageDeepPanel2.SetActive(false);
if (connectionRenderer != null)
{
connectionRenderer.NonTargetAlphaScale = lineAlphaMultiplier;
@@ -1375,7 +1487,11 @@ namespace AibisDream.MiniGame.Language
private void PlayEntranceSequence()
{
if (!gameObject.activeInHierarchy || candidateParticles.Count == 0)
{
Debug.LogWarning($"[LanguageParticleManager] PlayEntranceSequence 跳过: activeInHierarchy={gameObject.activeInHierarchy}, candidateParticles.Count={candidateParticles.Count} (不会播放火山表达panel)");
allowInput = true; // 否则 allowInput 会一直为 false
return;
}
if (entranceRoutine != null)
{
@@ -1493,7 +1609,40 @@ namespace AibisDream.MiniGame.Language
entranceTargets.Clear();
entranceRoutine = null;
// 开场表现完成,启动独立的火山表达panel协程(避免与 Entrance 耦合)
if (expressionPanelRoutine != null)
{
StopCoroutine(expressionPanelRoutine);
}
expressionPanelRoutine = StartCoroutine(PlayExpressionPanelThenReady());
}
/// <summary>
/// 独立协程:播放火山表达panel,播完后将表达流程标记为就绪
/// </summary>
private IEnumerator PlayExpressionPanelThenReady()
{
if (!string.IsNullOrEmpty(expressionPanelTimelineName) && TimelineCenter.Instance != null)
{
// 火山表达 panel timeline 驱动 panel1/2,播放前需先激活
if (languageDeepPanel1 != null) languageDeepPanel1.SetActive(true);
if (languageDeepPanel2 != null) languageDeepPanel2.SetActive(true);
yield return null; // 确保 DirectorHandler 已注册
yield return TimelineCenter.Instance.PlayTimelineAsync(expressionPanelTimelineName);
}
else if (string.IsNullOrEmpty(expressionPanelTimelineName))
{
Debug.LogWarning("[LanguageParticleManager] expressionPanelTimelineName 未配置,跳过火山表达panel播放");
}
else if (TimelineCenter.Instance == null)
{
Debug.LogWarning("[LanguageParticleManager] TimelineCenter.Instance 为空,无法播放火山表达panel");
}
allowInput = true;
expressionPanelRoutine = null;
}
private void ApplySeparationForces()
@@ -1,6 +1,7 @@
using System.Collections;
using System.Collections.Generic;
using AibisDream.FixSystem;
using AibisDream;
using Yarn.Unity;
namespace AibisDream.MiniGame.Language
@@ -11,40 +12,24 @@ namespace AibisDream.MiniGame.Language
public static class LanguageYarnCommand
{
private static AibisDream.ExpressionManager ExpressionManager => FixSystemCenter.SystemDic.Get<AibisDream.ExpressionManager>();
/// <summary>
/// 打开表达视图(只显示美术背景,不启动粒子系统)
/// <<open_expression>>
/// </summary>
[YarnCommand("open_expression")]
public static void OpenExpression()
{
if (ExpressionManager == null)
{
UnityEngine.Debug.LogError("ExpressionManager 未找到!请确保场景中有 ExpressionManager 组件。");
return;
}
ExpressionManager.OpenView();
}
/// <summary>
/// 启动表达粒子系统(在打开视图后调用)
/// <<start_expression "我好怕|怎么办|不行|好难" "我能做到" "ExpressionCompleted">>
/// 启动表达粒子系统(在打开视图后调用),等待火山表达panel播完再返回
/// <<start_expression "哈哈|嘿嘿|呵呵" "我很害怕" "ExpressionCompleted">>
/// </summary>
/// <param name="anxietyPhrasesStr">焦虑短句,用 | 分隔,如:"我好怕|怎么办|不行"</param>
/// <param name="anxietyPhrasesStr">干扰短句(笑话等),用 | 分隔,用于非目标粒子字符池,如:"哈哈|嘿嘿|呵呵"</param>
/// <param name="targetSentence">目标句子</param>
/// <param name="completionNode">完成时触发的对话节点(可选)</param>
[YarnCommand("start_expression")]
public static void StartExpression(string anxietyPhrasesStr, string targetSentence, string completionNode = "")
public static IEnumerator StartExpression(string anxietyPhrasesStr, string targetSentence, string completionNode = "")
{
if (ExpressionManager == null)
{
UnityEngine.Debug.LogError("ExpressionManager 未找到!请确保场景中有 ExpressionManager 组件。");
return;
yield break;
}
// 解析焦虑短句(用 | 分隔)
// 解析干扰短句(用 | 分隔)
string[] phrases = null;
if (!string.IsNullOrEmpty(anxietyPhrasesStr))
{
@@ -56,6 +41,7 @@ namespace AibisDream.MiniGame.Language
}
ExpressionManager.StartSystem(phrases, targetSentence, completionNode);
yield return ExpressionManager.WaitUntilExpressionFlowReady();
}
/// <summary>
@@ -94,10 +80,10 @@ namespace AibisDream.MiniGame.Language
}
/// <summary>
/// 重新启动表达系统(会重置并使用新配置,需要先 open_expression
/// 重新启动表达系统(会重置并使用新配置,相当于 close + open + start
/// <<restart_expression "我好怕|怎么办|不行" "我能做到" "ExpressionCompleted">>
/// </summary>
/// <param name="anxietyPhrasesStr">焦虑短句,用 | 分隔</param>
/// <param name="anxietyPhrasesStr">干扰短句(笑话等),用 | 分隔</param>
/// <param name="targetSentence">目标句子</param>
/// <param name="completionNode">完成时触发的对话节点(可选)</param>
[YarnCommand("restart_expression")]
@@ -47,7 +47,7 @@ namespace AibisDream.MiniGame.Language
protected static string chineseChars = "的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严龙飞";
protected static string[] chineseWords = { "不安", "紧张", "焦虑", "担忧", "烦躁", "恐慌", "恐惧", "绝望", "痛苦", "悲伤", "愤怒", "孤独", "无助", "迷茫", "困惑", "压抑", "沉重", "疲惫", "空虚", "失落" };
// 动态配置的焦虑短句(由 LanguageParticleManager 设置
// 动态配置的干扰短句(笑话/焦虑等,由 Yarn start_expression 传入,用于非目标粒子字符池
protected static List<string> configuredAnxietyPhrases = new List<string>();
protected virtual void Awake()
@@ -103,7 +103,7 @@ namespace AibisDream.MiniGame.Language
}
changeTimer = Random.Range(0.5f, 1.5f);
currentChar = GetRandomChar();
currentChar = GetNextDisplayChar();
UpdateText();
}
@@ -117,11 +117,11 @@ namespace AibisDream.MiniGame.Language
// 边界检测
CheckBounds();
// 字符变化
// 字符变化:目标粒子用 chineseChars 随机,非目标粒子从干扰短句字符中随机
changeTimer -= Time.deltaTime;
if (changeTimer <= 0)
{
currentChar = GetRandomChar();
currentChar = GetNextDisplayChar();
UpdateText();
changeTimer = changeInterval;
}
@@ -197,18 +197,36 @@ namespace AibisDream.MiniGame.Language
return chineseChars[Random.Range(0, chineseChars.Length)].ToString();
}
protected string GetRandomWord()
/// <summary>
/// 从干扰短句(笑话等)的字符中随机取一个字(每次只显示一个字符)。
/// 如 "哈哈|嘿嘿|呵呵" 会从 哈、嘿、呵 中随机。
/// </summary>
protected string GetRandomCharFromPhrases()
{
// 优先使用配置的焦虑短句,如果没有配置则使用默认词语
if (configuredAnxietyPhrases != null && configuredAnxietyPhrases.Count > 0)
if (configuredAnxietyPhrases == null || configuredAnxietyPhrases.Count == 0)
return GetRandomChar();
var chars = new List<char>();
foreach (var phrase in configuredAnxietyPhrases)
{
return configuredAnxietyPhrases[Random.Range(0, configuredAnxietyPhrases.Count)];
if (string.IsNullOrEmpty(phrase)) continue;
foreach (char c in phrase)
chars.Add(c);
}
return chineseWords[Random.Range(0, chineseWords.Length)];
if (chars.Count == 0) return GetRandomChar();
return chars[Random.Range(0, chars.Count)].ToString();
}
/// <summary>
/// 获取下一次要显示的字符。子类可重写以区分目标粒子与非目标粒子(与红蓝颜色解绑)。
/// 默认返回 chineseChars 随机单字。
/// </summary>
protected virtual string GetNextDisplayChar()
{
return GetRandomChar();
}
/// <summary>
/// 设置全局焦虑短句配置(由 LanguageParticleManager 调用)
/// 设置全局干扰短句配置(笑话等,由 LanguageParticleManager 调用)
/// </summary>
public static void SetAnxietyPhrases(List<string> phrases)
{