Files
aibis-dream/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs
T

2940 lines
116 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using DG.Tweening;
using Shapes;
using TMPro;
using UnityEngine.UI;
using AibisDream;
namespace AibisDream.MiniGame.Language
{
/// <summary>
/// 粒子排布区域形状
/// </summary>
public enum LayoutShape
{
Rectangle, // 矩形
Circle // 圆形
}
/// <summary>
/// 游戏完成阶段
/// </summary>
public enum CompletionPhase
{
None, // 未完成
Completed, // 已完成但尚未进入聚焦流程
FocusHolding, // 聚焦位置保持态(文字飘动/变换,等待外部命令触发下一步)
Focusing, // 正在聚焦放大并收敛
Finished // 全流程完成
}
/// <summary>
/// 语言粒子系统主管理器
/// </summary>
public class LanguageParticleManager : MonoBehaviour
{
[Header("Canvas设置")]
[SerializeField] private Canvas worldCanvas;
[SerializeField] private float canvasSize = 10f;
[SerializeField] private LayoutShape layoutShape = LayoutShape.Rectangle;
[Tooltip("圆形布局时的半径(留空则自动使用 canvasSize/2 - textMargin")]
[SerializeField] private float circleRadiusOverride = -1f;
[Header("字体设置")]
[SerializeField] private TMP_FontAsset chineseFontAsset;
[Header("粒子预制体")]
[SerializeField] private GameObject floatingParticlePrefab;
[SerializeField] private GameObject candidateParticlePrefab;
[Header("粒子数量")]
[SerializeField] private int floatingCount = 100;
[SerializeField] private int candidateCount = 50;
[SerializeField] private int redParticleCount = 8;
[Header("游戏参数")]
[SerializeField] private float connectionDistance = 2f; // Unity单位
[SerializeField] private float textMargin = 1f;
[Header("游戏配置")]
[SerializeField] private List<string> anxietyPhrases = new List<string>(); // 干扰短句(笑话等,用于非目标粒子,Yarn 会覆盖)
[SerializeField] private string targetSentence = "这是一个测试示例"; // 目标句子
[SerializeField] private string completionDialogNode = ""; // 完成时触发的对话节点
[Header("波形参数")]
[SerializeField] private float waveformAmplitude = 0.3f;
[SerializeField] private float interactionRadius = 0.5f;
[Header("鼠标交互参数")]
[SerializeField] private float mouseInteractionRadius = 1.5f;
[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); // 非目标粒子颜色(如笑话短句)
[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); // 目标粒子平静后颜色
[Header("连接线颜色(用于连线的颜色,与上面可分开调节)")]
[SerializeField] private Color targetConnectionColor = new Color(1f, 0.39f, 0.39f, 1f); // 目标连线颜色
[SerializeField] private Color nonTargetConnectionColor = new Color(0.39f, 0.78f, 1f, 0.7f);// 非目标连线颜色
[Header("文字样式配置")]
[SerializeField] private float targetParticleFontSize = 4.5f; // 目标粒子字体大小
[SerializeField] private float nonTargetParticleFontSize = 3.7f; // 非目标粒子字体大小
[SerializeField] private float floatingTextFontSize = 3f; // 背景浮动文字大小
[SerializeField] private bool targetParticleBold = true; // 目标粒子是否加粗
[SerializeField] private bool nonTargetParticleBold = false; // 非目标粒子是否加粗
[Header("非目标粒子干扰效果")]
[SerializeField, Range(0f, 0.1f)] private float nonTargetShakeIntensity = 0.02f; // 非目标粒子抖动强度
[SerializeField, Range(1f, 20f)] private float nonTargetShakeSpeed = 8f; // 非目标粒子抖动速度
[SerializeField, Range(0.1f, 1f)] private float nonTargetChangeInterval = 0.4f; // 非目标粒子变字间隔(越小越快)
[SerializeField, Range(0f, 0.03f)] private float nonTargetFloatAmplitude = 0.015f; // 非目标粒子漂浮幅度
[Header("连接线粗细")]
[SerializeField] private float targetConnectionThickness = 0.04f; // 目标连线粗细
[SerializeField] private float nonTargetConnectionThickness = 0.02f; // 非目标连线粗细
[Header("Bloom 发光效果")]
[SerializeField] private bool enableBloomGlow = true; // 是否启用 Bloom 发光
[SerializeField, Range(1f, 5f)] private float textGlowIntensity = 1.8f; // 文字发光强度
[SerializeField, Range(1f, 5f)] private float targetTextGlowIntensity = 2.5f; // 目标粒子文字发光强度
[SerializeField, Range(1f, 5f)] private float lineGlowIntensity = 2f; // 连接线发光强度
[SerializeField, Range(1f, 5f)] private float targetLineGlowIntensity = 2.5f; // 目标连接线发光强度
[SerializeField, Range(0f, 1f)] private float glowDilate = 0.3f; // 发光扩散
[SerializeField, Range(0f, 1f)] private float glowSoftness = 0.5f; // 发光柔和度
[Header("目标 UI")]
[SerializeField] private TMP_Text integrationProgressText;
[SerializeField] private TMP_Text interferenceCountText;
[Header("完成/确认阶段 — 屏幕内显示范围")]
[Tooltip("为真时,完成连线后(含确认按钮、聚焦、稳定化)目标字符位置会被限制在裁剪区域内,避免超出电视机屏幕可视区")]
[SerializeField] private bool clampCompletionPhasePositions = true;
[Tooltip("可选:场景中一块与屏幕内框对齐的 RectTransformWorld Space Canvas 子物体即可)。不指定则用粒子用的 movementBoundscanvasSize - textMargin")]
[SerializeField] private RectTransform completionScreenClipRect;
[Tooltip("在裁剪区基础上再向内缩进的世界单位距离,避免放大后的字半个挂在边外")]
[SerializeField] private float completionClipEdgePadding = 0.2f;
[Tooltip("屏幕内容的最小安全边距;最终边距还会根据字形、光晕、圆环和晃动幅度动态增大。")]
[SerializeField, Min(0f)] private float minimumScreenMargin = 0.12f;
[Tooltip("将目标粒子拉回屏幕裁剪区时的平滑时间(越大越柔和,略增加拖尾感)")]
[SerializeField, Range(0.02f, 0.55f)] private float completionPositionSmoothTime = 0.2f;
[Header("完成阶段 UI")]
[SerializeField] private GameObject languageDeepPanel1;
[SerializeField] private GameObject languageDeepPanel2;
[SerializeField] private Button focusSequenceButton;
[Tooltip("火山释放log界面,触发 focus 时对其 SpriteRenderer 做淡出,重新开始时恢复")]
[SerializeField] private SpriteRenderer releaseLogSpriteRenderer;
[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;
[SerializeField] private float logOperationFadeDuration = 0.6f;
[SerializeField] private int glowSortingOrder = 8;
[Tooltip("再次 start_expression 时,若上一轮粒子/UI 仍在,先淡出再重开;协程 duration≤0 时用此值(秒)")]
[SerializeField] private float restartExpressionFadeDuration = 0.55f;
[SerializeField] private float probabilityDuration = 3.5f;
[SerializeField] private float predictionSequenceGap = 0.4f;
[SerializeField] private float spreadDistance = 0.35f;
[Header("聚焦表现")]
[SerializeField] private Color focusRingColor = Color.white;
[SerializeField, Range(0.01f, 0.3f)] private float focusRingThickness = 0.08f;
[SerializeField, Range(0.05f, 1f)] private float focusRingAlpha = 0.85f;
[SerializeField] private float statusFadeOutDuration = 0.45f;
[SerializeField] private float shuffleDistance = 0.35f;
[SerializeField] private float shuffleFrequency = 3.5f;
[SerializeField] private float shuffleVerticalScale = 0.6f;
[Header("开场表现")]
[SerializeField] private float entranceDelay = 0.45f;
[SerializeField] private int entranceCoreCount = 8;
[SerializeField] private float entranceCoreRadius = 0.55f;
[SerializeField] private float entranceInitialScale = 0.25f;
[SerializeField] private float entranceCoreBurstDuration = 1f;
[SerializeField] private float entranceCoreStagger = 0.08f;
[SerializeField] private float entranceCascadeDuration = 0.8f;
[SerializeField] private float entranceCascadeStagger = 0.03f;
[SerializeField] private float entranceSearchRadius = 2f;
[Header("渲染层设置")]
[SerializeField] private string renderLayerName = "HuoshanScreen";
private int renderLayer = 0;
[Header("调试/测试")]
[SerializeField] private bool enableDebugShortcuts = true;
[SerializeField] private KeyCode debugCompleteKey = KeyCode.F5;
[SerializeField] private KeyCode debugResetKey = KeyCode.F9;
[SerializeField] private KeyCode debugFullTestKey = KeyCode.F10;
[SerializeField] private bool autoRunDebugTestOnStart = false;
[Header("布局优化")]
[SerializeField] private bool enableSeparation = true; // 是否启用粒子分离力
[SerializeField] private float minParticleSpacing = 0.6f; // 粒子最小间距
[SerializeField] private float separationForce = 2.5f; // 分离力度
[SerializeField] private float lineAlphaMultiplier = 0.65f; // 连接线透明度缩放
// 粒子列表
private List<FloatingTextParticle> floatingParticles = new List<FloatingTextParticle>();
private List<CandidateParticle> candidateParticles = new List<CandidateParticle>();
private List<CandidateParticle> targetParticles = new List<CandidateParticle>(); // 目标粒子列表(原 redParticles
// 效果系统(传播效果已移除,保留空列表供渲染器使用)
private static readonly List<EffectPropagation> s_emptyPropagations = new List<EffectPropagation>();
private Dictionary<string, bool> previousConnections = new Dictionary<string, bool>();
private bool initializationComplete = false;
private int initializationFrames = 0;
// 游戏状态
private bool isCompleted = false;
private CompletionPhase completionPhase = CompletionPhase.None;
private float completionTimer = 0f;
private float fadeOutAlpha = 1f;
private List<CandidateParticle> orderedRedParticles = new List<CandidateParticle>();
private bool allowInput = true;
private float focusTimer = 0f;
private Dictionary<CandidateParticle, TargetFocusVisual> focusVisuals = new Dictionary<CandidateParticle, TargetFocusVisual>();
/// <summary>完成阶段屏幕钳制用 SmoothDamp 速度缓存(按粒子)</summary>
private readonly Dictionary<CandidateParticle, Vector3> completionClampSmoothVelocity = new Dictionary<CandidateParticle, Vector3>();
private Vector3 focusCentroid;
private List<string> finalTargetCharacters = new List<string>();
private List<Vector3> finalTargetPositions = new List<Vector3>();
private bool interferenceLogicSuspended = false;
private bool statusUIHidden = false;
private float integrationTextBaseAlpha = 1f;
private float interferenceTextBaseAlpha = 1f;
private Coroutine entranceRoutine;
private Coroutine expressionPanelRoutine;
private Coroutine debugTestRoutine;
private Coroutine confirmTimelineRoutine;
private Coroutine focusTransitionRoutine;
private Coroutine slotMachineRoutine;
private readonly Dictionary<CandidateParticle, Vector3> entranceTargets = new Dictionary<CandidateParticle, Vector3>();
private class TargetFocusVisual
{
public CandidateParticle particle;
public GameObject ringObject;
public Disc ringDisc;
public float startShakeIntensity;
public string finalChar;
public bool stabilized;
public Vector3 focusPosition;
public Vector3 finalPosition;
public Vector3 lieEdgePosition;
public int orderIndex;
public bool gatherLocked;
public Vector3 scatterOffset;
}
private bool focusTweensCompleted = false;
private bool focusMoveTweensCompleted;
private float resolveTotalDurationOverride = -1f;
private float lieEdgeCompression;
private float lieEdgeJitter;
private float truthGatherProgress;
private LogReleasePresentationController presentationController;
private const string LieCompressionTweenId = "HuoshanLieEdgeCompression";
private const string TruthGatherTweenId = "HuoshanTruthGather";
// 边界
private Bounds movementBounds;
private Vector3 boundsCenter;
private float boundsRadius; // 圆形时有效
// 连接渲染(使用Shapes
private ConnectionRenderer connectionRenderer;
// 噪点叠加渲染器(复古显示器效果)
private NoiseOverlayRenderer noiseOverlayRenderer;
[Header("复古显示器效果")]
[SerializeField] private bool enableRetroEffect = true;
[SerializeField, Range(0f, 1f)] private float retroEffectIntensity = 0.5f;
// 是否已经初始化
private bool isInitialized = false;
/// <summary>Inspector 中的候选粒子总数,用于未在 Yarn 中指定非目标数量时恢复默认</summary>
private int defaultCandidateCountFromInspector;
private bool hasCapturedDefaultCandidateCount;
private void CaptureDefaultCandidateCountFromInspector()
{
if (hasCapturedDefaultCandidateCount) return;
defaultCandidateCountFromInspector = candidateCount;
hasCapturedDefaultCandidateCount = true;
}
private void Start()
{
// Start 中不再自动初始化,等待外部调用 InitializeSystem
}
/// <summary>
/// 初始化系统(可以由外部调用,用于配置游戏参数)
/// </summary>
/// <param name="phrases">焦虑短句列表</param>
/// <param name="sentence">目标句子</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)
{
CaptureDefaultCandidateCountFromInspector();
string useSentence = string.IsNullOrEmpty(sentence) ? "这是一个测试示例" : sentence;
if (nonTargetParticleTotal >= 0)
{
candidateCount = Mathf.Max(useSentence.Length + nonTargetParticleTotal, useSentence.Length);
}
else
{
candidateCount = defaultCandidateCountFromInspector;
}
// 如果是第一次初始化,先执行基础初始化
if (!isInitialized)
{
InitializeCanvas();
InitializeParticles();
InitializeRenderers();
if (integrationProgressText != null)
{
integrationTextBaseAlpha = integrationProgressText.color.a;
}
if (interferenceCountText != null)
{
interferenceTextBaseAlpha = interferenceCountText.color.a;
}
if (focusSequenceButton != null)
{
focusSequenceButton.interactable = false;
focusSequenceButton.gameObject.SetActive(false);
focusSequenceButton.onClick.RemoveListener(HandleFocusButtonClicked);
focusSequenceButton.onClick.AddListener(HandleFocusButtonClicked);
}
if (languageDeepPanel1 != null)
languageDeepPanel1.SetActive(false);
if (languageDeepPanel2 != null)
languageDeepPanel2.SetActive(false);
isInitialized = true;
}
else
{
EnsureCandidatePoolSize(candidateCount);
}
// 设置游戏配置
anxietyPhrases = phrases ?? new List<string>();
targetSentence = useSentence;
completionDialogNode = dialogNode ?? "";
// 设置全局焦虑短句配置
TextParticle.SetAnxietyPhrases(anxietyPhrases);
// 根据目标句子更新目标粒子数量
redParticleCount = Mathf.Min(targetSentence.Length, candidateCount);
// 重新初始化游戏状态并播放入场动画
ResetGame(true);
}
/// <summary>
/// 在再次 <c>start_expression</c> 前调用:若系统已初始化过,则淡出当前粒子、连线与相关 UI,避免与新一轮重叠。
/// duration≤0 时使用 <see cref="restartExpressionFadeDuration"/>。
/// </summary>
public IEnumerator FadeOutBeforeNewExpressionRound(float duration)
{
if (!isInitialized)
yield break;
StopFocusTransitionRoutine();
KillCompletionUiTweens();
resolveTotalDurationOverride = -1f;
float d = duration > 0f ? duration : restartExpressionFadeDuration;
// 火山释放 log 与文字同时叠在屏幕上时,先把 log 藏起来,等文字淡出结束后再由 FadeInReleaseLogIfNeeded 出现
HideReleaseLogImmediate();
if (entranceRoutine != null)
{
StopCoroutine(entranceRoutine);
entranceRoutine = null;
}
if (expressionPanelRoutine != null)
{
StopCoroutine(expressionPanelRoutine);
expressionPanelRoutine = null;
}
if (confirmTimelineRoutine != null)
{
StopCoroutine(confirmTimelineRoutine);
confirmTimelineRoutine = null;
}
DOTween.Kill(this);
foreach (var p in candidateParticles)
{
if (p != null)
{
DOTween.Kill(p);
p.transform.DOKill();
p.velocity = Vector2.zero;
p.isStatic = true;
}
}
foreach (var f in floatingParticles)
{
if (f != null)
{
DOTween.Kill(f);
f.transform.DOKill();
f.velocity = Vector2.zero;
f.isCalmed = true;
}
}
interferenceLogicSuspended = true;
allowInput = false;
isCompleted = false;
completionPhase = CompletionPhase.None;
DestroyFocusVisualDecorationsAndClear();
DOTween.To(() => fadeOutAlpha, v => fadeOutAlpha = v, 0f, d)
.SetEase(Ease.OutQuad)
.SetTarget(this);
if (connectionRenderer != null)
{
DOTween.To(() => connectionRenderer.NonTargetAlphaScale, v => connectionRenderer.NonTargetAlphaScale = v, 0f, d)
.SetEase(Ease.OutQuad)
.SetTarget(this);
DOTween.To(() => connectionRenderer.TargetAlphaScale, v => connectionRenderer.TargetAlphaScale = v, 0f, d)
.SetEase(Ease.OutQuad)
.SetTarget(this);
}
foreach (var p in candidateParticles)
{
if (p != null)
FadeOutTextParticle(p, d);
}
foreach (var f in floatingParticles)
{
if (f != null)
FadeOutTextParticle(f, d);
}
SetStatusUIVisibility(false, d);
if (focusSequenceButton != null && focusSequenceButton.gameObject.activeSelf)
FadeOutUIElement(focusSequenceButton.gameObject, d);
FadeOutUIElement(languageDeepPanel1, d);
FadeOutUIElement(languageDeepPanel2, d);
yield return new WaitForSeconds(d);
fadeOutAlpha = 0f;
}
/// <summary>
/// 在打开视图前设置所有初始状态(panels、button 等不激活)
/// </summary>
public void SetInitialStatesBeforeOpen()
{
RestoreCompletionUiAlpha();
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>
public void StopSystem()
{
// 如果未初始化,直接返回
if (!isInitialized)
return;
StopFocusTransitionRoutine();
KillCompletionUiTweens();
RestoreTargetPresentationDefaults();
if (entranceRoutine != null)
{
StopCoroutine(entranceRoutine);
entranceRoutine = null;
}
if (debugTestRoutine != null)
{
StopCoroutine(debugTestRoutine);
debugTestRoutine = null;
}
if (confirmTimelineRoutine != null)
{
StopCoroutine(confirmTimelineRoutine);
confirmTimelineRoutine = null;
}
if (expressionPanelRoutine != null)
{
StopCoroutine(expressionPanelRoutine);
expressionPanelRoutine = null;
}
// 停止所有 DOTween 动画
DOTween.Kill(this);
foreach (var particle in candidateParticles)
{
if (particle != null)
{
DOTween.Kill(particle);
particle.transform.DOKill();
}
}
foreach (var particle in floatingParticles)
{
if (particle != null)
{
DOTween.Kill(particle);
particle.transform.DOKill();
}
}
// 重置游戏状态(但不重置 isInitialized,这样可以重新开启)
isCompleted = false;
completionPhase = CompletionPhase.None;
allowInput = false;
ClearCompletionClampSmoothVelocity();
ReleaseScreenClipMaterials();
}
private void InitializeCanvas()
{
// 获取渲染层索引
renderLayer = LayerMask.NameToLayer(renderLayerName);
if (renderLayer == -1)
{
Debug.LogWarning($"Layer '{renderLayerName}' not found, using default layer.");
renderLayer = 0;
}
// 如果没有指定canvas,创建一个
if (worldCanvas == null)
{
GameObject canvasObj = new GameObject("Language Canvas");
canvasObj.transform.SetParent(transform);
canvasObj.transform.localPosition = Vector3.zero;
canvasObj.layer = renderLayer;
worldCanvas = canvasObj.AddComponent<Canvas>();
worldCanvas.renderMode = RenderMode.WorldSpace;
RectTransform rectTransform = canvasObj.GetComponent<RectTransform>();
rectTransform.sizeDelta = new Vector2(canvasSize, canvasSize);
}
else
{
// 设置已有 Canvas 的 layer
worldCanvas.gameObject.layer = renderLayer;
}
// 确保 Canvas 配置正确
if (worldCanvas != null)
{
// 设置 WorldSpace Canvas 的相机
if (worldCanvas.worldCamera == null)
{
worldCanvas.worldCamera = Camera.main;
}
}
// 设置运动边界
boundsCenter = worldCanvas.transform.position;
float halfSize = canvasSize / 2f - textMargin;
if (layoutShape == LayoutShape.Circle)
{
boundsRadius = circleRadiusOverride > 0 ? circleRadiusOverride : halfSize;
movementBounds = new Bounds(boundsCenter, new Vector3(boundsRadius * 2f, boundsRadius * 2f, 0f));
}
else
{
boundsRadius = 0f;
movementBounds = new Bounds(
boundsCenter,
new Vector3(halfSize * 2f, halfSize * 2f, 0f)
);
}
}
private void InitializeParticles()
{
// 创建背景浮动文字
for (int i = 0; i < floatingCount; i++)
{
Vector3 pos = GetRandomPositionInBounds();
GameObject obj = CreateParticleObject(pos, $"FloatingParticle_{i}");
FloatingTextParticle particle = obj.AddComponent<FloatingTextParticle>();
particle.SetFont(chineseFontAsset);
particle.SetColor(floatingTextColor);
particle.SetFontSize(floatingTextFontSize);
SetParticleBounds(particle);
ConfigureParticleScreenMask(particle);
// 设置发光效果
if (enableBloomGlow)
{
particle.SetGlowSettings(true, textGlowIntensity, floatingTextColor);
particle.SetUnderlaySettings(glowDilate, glowSoftness);
}
floatingParticles.Add(particle);
}
// 创建候选粒子
for (int i = 0; i < candidateCount; i++)
{
AddOneCandidateParticle(i);
}
// 选择红色粒子
SelectRedParticles();
}
private GameObject CreateParticleObject(Vector3 position, string name)
{
GameObject obj = new GameObject(name);
obj.layer = renderLayer;
// 添加 RectTransform(在 Canvas 下必需)
RectTransform rectTransform = obj.AddComponent<RectTransform>();
rectTransform.SetParent(worldCanvas.transform, false);
rectTransform.position = position;
rectTransform.localScale = Vector3.one;
rectTransform.sizeDelta = Vector2.zero; // 父对象不需要大小
return obj;
}
private void AddOneCandidateParticle(int index)
{
Vector3 pos = GetRandomPositionInBounds();
GameObject obj = CreateParticleObject(pos, $"CandidateParticle_{index}");
CandidateParticle particle = obj.AddComponent<CandidateParticle>();
particle.SetFont(chineseFontAsset);
particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor);
particle.SetFontStyles(targetParticleFontSize, nonTargetParticleFontSize, targetParticleBold, nonTargetParticleBold);
particle.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude);
SetParticleBounds(particle);
ConfigureParticleScreenMask(particle);
if (enableBloomGlow)
{
particle.SetGlowSettings(true, textGlowIntensity, nonTargetParticleColor);
particle.SetUnderlaySettings(glowDilate, glowSoftness);
}
candidateParticles.Add(particle);
}
/// <summary>
/// 增删候选粒子以匹配目标数量(用于 Yarn 动态指定非目标粒子总数后复用同一视图)
/// </summary>
private void EnsureCandidatePoolSize(int targetSize)
{
if (targetSize < 0) targetSize = 0;
candidateCount = targetSize;
while (candidateParticles.Count < targetSize)
{
AddOneCandidateParticle(candidateParticles.Count);
}
while (candidateParticles.Count > targetSize)
{
int last = candidateParticles.Count - 1;
CandidateParticle p = candidateParticles[last];
candidateParticles.RemoveAt(last);
if (p != null)
{
p.transform.DOKill();
Destroy(p.gameObject);
}
}
}
private Vector3 GetRandomPositionInBounds()
{
if (layoutShape == LayoutShape.Circle)
{
Vector2 offset = Random.insideUnitCircle * boundsRadius;
return boundsCenter + new Vector3(offset.x, offset.y, 0f);
}
float x = Random.Range(movementBounds.min.x, movementBounds.max.x);
float y = Random.Range(movementBounds.min.y, movementBounds.max.y);
return new Vector3(x, y, 0f);
}
private void SetParticleBounds(TextParticle particle)
{
particle.SetMovementBounds(movementBounds);
if (layoutShape == LayoutShape.Circle)
particle.SetCircularBounds(boundsCenter, boundsRadius);
}
private void ConfigureParticleScreenMask(TextParticle particle)
{
if (particle == null)
return;
particle.SetScreenClipRect(completionScreenClipRect);
int sortingLayerId = worldCanvas != null ? worldCanvas.sortingLayerID : 0;
particle.SetGlowMasking(sortingLayerId, glowSortingOrder);
}
private void InitializeRenderers()
{
// 连接线渲染器
GameObject connObj = new GameObject("ConnectionRenderer");
connObj.transform.SetParent(transform);
connObj.transform.localPosition = Vector3.zero;
connObj.layer = renderLayer;
connectionRenderer = connObj.AddComponent<ConnectionRenderer>();
connectionRenderer.manager = this;
connectionRenderer.connectionDistance = connectionDistance;
connectionRenderer.SetConnectionStyles(
targetConnectionColor,
nonTargetConnectionColor,
nonTargetConnectionColor,
targetConnectionThickness,
nonTargetConnectionThickness,
nonTargetConnectionThickness
);
connectionRenderer.NonTargetAlphaScale = lineAlphaMultiplier;
connectionRenderer.TargetAlphaScale = 1f;
// 设置连接线发光效果
if (enableBloomGlow)
{
connectionRenderer.SetGlowSettings(
true,
targetLineGlowIntensity,
lineGlowIntensity,
lineGlowIntensity
);
}
// 噪点叠加渲染器(复古显示器效果)
if (enableRetroEffect)
{
GameObject noiseObj = new GameObject("NoiseOverlayRenderer");
noiseObj.transform.SetParent(transform);
noiseObj.transform.localPosition = Vector3.zero;
noiseObj.layer = renderLayer;
noiseOverlayRenderer = noiseObj.AddComponent<NoiseOverlayRenderer>();
// 设置效果区域为 Canvas 区域
Vector3 center = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
noiseOverlayRenderer.SetArea(center, new Vector2(canvasSize, canvasSize));
noiseOverlayRenderer.SetIntensity(retroEffectIntensity);
}
}
private void SelectRedParticles()
{
targetParticles.Clear();
List<CandidateParticle> shuffled = candidateParticles.OrderBy(x => Random.value).ToList();
float effectiveSize = layoutShape == LayoutShape.Circle
? boundsRadius * 2f
: Mathf.Min(movementBounds.size.x, movementBounds.size.y);
float minDistance = effectiveSize / (redParticleCount * 0.8f);
foreach (var particle in shuffled)
{
if (targetParticles.Count >= redParticleCount) break;
bool tooClose = false;
foreach (var selected in targetParticles)
{
if (Vector3.Distance(particle.transform.position, selected.transform.position) < minDistance)
{
tooClose = true;
break;
}
}
if (!tooClose || targetParticles.Count == 0)
{
particle.isRed = true;
particle.originalIsRed = true;
targetParticles.Add(particle);
// 为目标粒子设置更强的发光效果
if (enableBloomGlow)
{
particle.SetGlowSettings(true, targetTextGlowIntensity, targetParticleColor);
}
}
}
// 如果不够,强制添加
int remaining = redParticleCount - targetParticles.Count;
for (int i = 0; i < remaining && i < shuffled.Count; i++)
{
if (!shuffled[i].isRed)
{
shuffled[i].isRed = true;
shuffled[i].originalIsRed = true;
targetParticles.Add(shuffled[i]);
// 为目标粒子设置更强的发光效果
if (enableBloomGlow)
{
shuffled[i].SetGlowSettings(true, targetTextGlowIntensity, targetParticleColor);
}
}
}
}
private void Update()
{
// 如果系统未初始化,不执行任何逻辑
if (!isInitialized)
return;
// 初始化检查
if (!initializationComplete)
{
initializationFrames++;
if (initializationFrames >= 30)
{
initializationComplete = true;
}
else if (initializationFrames >= 20)
{
// 建立初始连接状态
BuildConnectionMap();
}
}
HandleDebugShortcuts();
UpdateConnections();
if (!statusUIHidden)
{
UpdateStatusUI();
}
if (completionPhase == CompletionPhase.None)
{
if (initializationComplete && !interferenceLogicSuspended)
{
DetectConnectionChanges();
}
if (!isCompleted && CheckRedParticlesConnected())
{
isCompleted = true;
OnTargetsCompleted();
}
if (allowInput && !interferenceLogicSuspended)
{
UpdateInput();
}
if (!interferenceLogicSuspended)
{
ApplySeparationForces();
}
}
if (completionPhase == CompletionPhase.FocusHolding)
{
UpdateFocusHolding();
}
else if (completionPhase == CompletionPhase.Focusing)
{
UpdateFocusSequence();
}
UpdateRenderers();
}
private void LateUpdate()
{
if (!isInitialized || !clampCompletionPhasePositions || !isCompleted)
return;
if (completionPhase == CompletionPhase.None)
return;
// 连线刚完成、等待确认时保持粒子当前世界坐标,避免 GetCompletionClipBounds(含 padding/屏幕 Rect
// 比游玩用 movementBounds 更紧时,在 Completed 首帧 LateUpdate 产生生硬瞬移。
if (completionPhase == CompletionPhase.Completed)
return;
Bounds clip = GetCompletionClipBounds();
if (clip.size.x < 0.01f || clip.size.y < 0.01f)
return;
float dt = Time.deltaTime;
if (dt < 1e-6f)
dt = 1e-6f;
float smooth = Mathf.Max(0.0001f, completionPositionSmoothTime);
for (int i = 0; i < targetParticles.Count; i++)
{
var p = targetParticles[i];
if (p == null) continue;
Vector3 cur = p.transform.position;
Vector3 desired = ClampPositionToBounds(cur, clip);
if (!completionClampSmoothVelocity.TryGetValue(p, out Vector3 vel))
vel = Vector3.zero;
Vector3 next = Vector3.SmoothDamp(cur, desired, ref vel, smooth, Mathf.Infinity, dt);
completionClampSmoothVelocity[p] = vel;
p.transform.position = next;
}
}
private void ClearCompletionClampSmoothVelocity()
{
completionClampSmoothVelocity.Clear();
}
/// <summary>
/// 完成阶段使用的裁剪范围:优先用 Inspector 指定的屏幕 Rect,否则与粒子运动边界一致。
/// </summary>
private Bounds GetCompletionClipBounds()
{
Bounds b = GetScreenWorldBounds();
Vector2 safetyPadding = CalculateScreenSafetyPadding();
if (safetyPadding.sqrMagnitude > 0f)
{
Bounds shrunk = b;
shrunk.Expand(new Vector3(-safetyPadding.x * 2f, -safetyPadding.y * 2f, 0f));
if (shrunk.size.x >= 0.01f && shrunk.size.y >= 0.01f)
b = shrunk;
}
return b;
}
public Bounds GetScreenWorldBounds()
{
Bounds b;
if (completionScreenClipRect != null)
{
var corners = new Vector3[4];
completionScreenClipRect.GetWorldCorners(corners);
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
for (int c = 0; c < 4; c++)
{
minX = Mathf.Min(minX, corners[c].x);
maxX = Mathf.Max(maxX, corners[c].x);
minY = Mathf.Min(minY, corners[c].y);
maxY = Mathf.Max(maxY, corners[c].y);
}
float z = corners[0].z;
Vector3 center = new Vector3((minX + maxX) * 0.5f, (minY + maxY) * 0.5f, z);
Vector3 size = new Vector3(Mathf.Max(0f, maxX - minX), Mathf.Max(0f, maxY - minY), 0f);
b = new Bounds(center, size);
}
else
{
b = movementBounds;
}
return b;
}
private Vector2 CalculateScreenSafetyPadding()
{
float padding = Mathf.Max(minimumScreenMargin, completionClipEdgePadding);
float glyphExtent = 0f;
float glowExtent = 0f;
foreach (CandidateParticle particle in targetParticles)
{
if (particle == null)
continue;
if (particle.textMesh != null && particle.textMesh.renderer != null)
{
Bounds textBounds = particle.textMesh.renderer.bounds;
glyphExtent = Mathf.Max(glyphExtent, textBounds.extents.x, textBounds.extents.y);
}
if (particle.glowSprite != null)
{
Bounds glowBounds = particle.glowSprite.bounds;
glowExtent = Mathf.Max(glowExtent, glowBounds.extents.x, glowBounds.extents.y);
}
}
float zoomedGlyph = glyphExtent * Mathf.Max(1f, focusZoomScale);
float focusRingRadius = Mathf.Clamp(targetParticleFontSize * 0.05f, 0.35f, 0.75f) +
Mathf.Max(focusRingThickness, 0f);
float shakeExtent = Mathf.Max(shuffleDistance, nonTargetShakeIntensity, completionPositionSmoothTime * 0.1f);
float horizontal = Mathf.Max(padding, zoomedGlyph, glowExtent, focusRingRadius, shakeExtent);
return new Vector2(horizontal, horizontal);
}
private static Vector3 ClampPositionToBounds(Vector3 worldPos, Bounds b)
{
return new Vector3(
Mathf.Clamp(worldPos.x, b.min.x, b.max.x),
Mathf.Clamp(worldPos.y, b.min.y, b.max.y),
worldPos.z
);
}
private void UpdateRenderers()
{
// 更新连接线渲染器
if (connectionRenderer != null)
{
Bounds screenBounds = GetScreenWorldBounds();
connectionRenderer.SetScreenBounds(
screenBounds,
completionScreenClipRect != null || (screenBounds.size.x > 0.001f && screenBounds.size.y > 0.001f));
connectionRenderer.SetScreenClippingEnabled(ShouldClipConnections(completionPhase));
connectionRenderer.SetData(
candidateParticles,
s_emptyPropagations,
completionPhase,
fadeOutAlpha
);
}
}
private void UpdateConnections()
{
// 重置所有连接
foreach (var particle in candidateParticles)
{
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
);
if (distance < connectionDistance)
{
candidateParticles[i].connections.Add(candidateParticles[j]);
candidateParticles[j].connections.Add(candidateParticles[i]);
}
}
}
}
private void BuildConnectionMap()
{
previousConnections.Clear();
for (int i = 0; i < candidateParticles.Count; i++)
{
for (int j = i + 1; j < candidateParticles.Count; j++)
{
float distance = Vector3.Distance(
candidateParticles[i].transform.position,
candidateParticles[j].transform.position
);
if (distance < connectionDistance)
{
string key = $"{Mathf.Min(i, j)}-{Mathf.Max(i, j)}";
previousConnections[key] = true;
}
}
}
}
private void DetectConnectionChanges()
{
Dictionary<string, bool> currentConnections = new Dictionary<string, bool>();
for (int i = 0; i < candidateParticles.Count; i++)
{
for (int j = i + 1; j < candidateParticles.Count; j++)
{
float distance = Vector3.Distance(
candidateParticles[i].transform.position,
candidateParticles[j].transform.position
);
if (distance < connectionDistance)
{
string key = $"{Mathf.Min(i, j)}-{Mathf.Max(i, j)}";
currentConnections[key] = true;
}
}
}
previousConnections = currentConnections;
}
private bool CheckRedParticlesConnected()
{
if (targetParticles.Count == 0) return false;
if (targetParticles.Count == 1) return true;
// 检查所有目标粒子是否连通
HashSet<CandidateParticle> visited = new HashSet<CandidateParticle>();
Queue<CandidateParticle> queue = new Queue<CandidateParticle>();
queue.Enqueue(targetParticles[0]);
visited.Add(targetParticles[0]);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var connected in current.connections)
{
if (connected.isRed && !visited.Contains(connected))
{
visited.Add(connected);
queue.Enqueue(connected);
}
}
}
if (visited.Count != targetParticles.Count)
return false;
// 检查目标粒子是否与非目标粒子分离
foreach (var red in targetParticles)
{
foreach (var other in candidateParticles)
{
if (other.isRed) continue;
float dist = Vector3.Distance(red.transform.position, other.transform.position);
if (dist < connectionDistance)
return false;
}
}
return true;
}
private void OnTargetsCompleted()
{
completionPhase = CompletionPhase.Completed;
completionTimer = 0f;
focusTimer = 0f;
fadeOutAlpha = 1f;
allowInput = false;
DestroyFocusVisualDecorationsAndClear();
finalTargetCharacters.Clear();
finalTargetPositions.Clear();
focusTweensCompleted = false;
interferenceLogicSuspended = false;
SetStatusUIVisibility(true, 0f);
orderedRedParticles = GetOrderedRedParticles();
int displayLength = Mathf.Min(orderedRedParticles.Count, targetSentence.Length);
if (displayLength < orderedRedParticles.Count)
{
orderedRedParticles = orderedRedParticles.Take(displayLength).ToList();
}
for (int i = 0; i < displayLength; i++)
{
finalTargetCharacters.Add(targetSentence[i].ToString());
}
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);
finalTargetPositions.Add(ClampPositionToBounds(pos, clip));
}
}
// 完成目标后:停止目标粒子的移动
foreach (var particle in targetParticles)
{
if (particle != null)
{
particle.velocity = Vector2.zero;
particle.isStatic = true;
}
}
// 首先激活 button 但不可交互,播放火山表达确认后再解锁(若之前被 fade 过,需重置 alpha)
if (focusSequenceButton != null)
{
var btnCg = focusSequenceButton.GetComponent<CanvasGroup>();
if (btnCg != null) btnCg.alpha = 1f;
focusSequenceButton.gameObject.SetActive(true);
focusSequenceButton.interactable = false;
}
if (confirmTimelineRoutine != null)
{
StopCoroutine(confirmTimelineRoutine);
}
confirmTimelineRoutine = StartCoroutine(PlayConfirmTimelineAndUnlockButton());
foreach (var particle in candidateParticles)
{
if (!particle.isRed)
{
particle.isCalmed = true;
particle.hasEffect = false;
particle.isOrange = false;
particle.changeSpeedMultiplier = 1f;
}
}
foreach (var particle in floatingParticles)
{
particle.isCalmed = true;
}
}
private List<CandidateParticle> GetOrderedRedParticles()
{
if (targetParticles.Count == 0) return new List<CandidateParticle>();
if (targetParticles.Count == 1) return new List<CandidateParticle>(targetParticles);
// 从最左边的粒子开始
var ordered = new List<CandidateParticle>();
var startParticle = targetParticles.OrderBy(p => p.transform.position.x).First();
HashSet<CandidateParticle> visited = new HashSet<CandidateParticle>();
Stack<CandidateParticle> stack = new Stack<CandidateParticle>();
stack.Push(startParticle);
visited.Add(startParticle);
while (stack.Count > 0)
{
var current = stack.Pop();
ordered.Add(current);
foreach (var connected in current.connections)
{
if (connected.isRed && !visited.Contains(connected))
{
visited.Add(connected);
stack.Push(connected);
}
}
}
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)
return;
BeginFocusSequence();
}
private void BeginFocusSequence()
{
StopFocusTransitionRoutine();
if (focusSequenceButton != null)
{
focusSequenceButton.interactable = false;
}
ClearCompletionClampSmoothVelocity();
completionPhase = CompletionPhase.Focusing;
focusTimer = 0f;
focusTweensCompleted = false;
focusMoveTweensCompleted = false;
lieEdgeCompression = 0f;
lieEdgeJitter = 0f;
DOTween.Kill(TruthGatherTweenId);
truthGatherProgress = 0f;
interferenceLogicSuspended = true;
SetStatusUIVisibility(false, statusFadeOutDuration);
// 两个 panel、confirm button、火山释放 log 界面在 focus 触发时淡出
float operationFadeDuration = Mathf.Max(0.01f, logOperationFadeDuration);
FadeOutUIElement(languageDeepPanel1, operationFadeDuration);
FadeOutUIElement(languageDeepPanel2, operationFadeDuration);
if (focusSequenceButton != null)
{
FadeOutUIElement(focusSequenceButton.gameObject, operationFadeDuration);
}
FadeOutSpriteRenderer(releaseLogSpriteRenderer, operationFadeDuration);
if (connectionRenderer != null)
{
connectionRenderer.NonTargetAlphaScale = 0f;
connectionRenderer.TargetAlphaScale = 1.2f;
}
foreach (var particle in floatingParticles)
{
particle.isCalmed = true;
particle.velocity = Vector2.zero;
FadeOutTextParticle(particle, operationFadeDuration);
}
foreach (var particle in candidateParticles)
{
if (!particle.isRed)
{
particle.isCalmed = true;
particle.velocity = Vector2.zero;
FadeOutTextParticle(particle, operationFadeDuration);
}
else
{
particle.isStatic = false;
particle.isCalmed = false;
particle.InitializeAnxietyEffect();
particle.changeSpeedMultiplier = 6f;
particle.anxietyShakeIntensity = Mathf.Max(particle.anxietyShakeIntensity, 4f);
particle.SetChangeInterval(0.05f);
particle.ResetChangeTimer(0.05f);
}
}
focusCentroid = ComputeCentroid(orderedRedParticles);
Vector3 focusTargetCenter = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
Bounds focusClipBounds = GetCompletionClipBounds();
DestroyFocusVisualDecorationsAndClear();
int tweenTargetCount = orderedRedParticles.Count;
int completedTweens = 0;
for (int i = 0; i < orderedRedParticles.Count; i++)
{
var particle = orderedRedParticles[i];
Vector3 offset = particle.transform.position - focusCentroid;
float indexCenter = (orderedRedParticles.Count - 1) * 0.5f;
Vector3 spreadOffset = new Vector3((i - indexCenter) * spreadDistance, Mathf.Sin(i * 1.4f) * spreadDistance * 0.6f, 0f);
Vector3 focusPosition = focusTargetCenter + offset * focusZoomScale + spreadOffset;
Vector3 finalPosition = finalTargetPositions.Count > i ? finalTargetPositions[i] : focusTargetCenter;
focusPosition = ClampPositionToBounds(focusPosition, focusClipBounds);
finalPosition = ClampPositionToBounds(finalPosition, focusClipBounds);
particle.transform.DOKill();
particle.transform.DOMove(focusPosition, focusMoveDuration)
.SetEase(Ease.InOutQuad)
.OnComplete(() =>
{
completedTweens++;
if (completedTweens >= tweenTargetCount)
focusMoveTweensCompleted = true;
});
particle.transform.DOScale(Vector3.one * focusZoomScale, focusMoveDuration).SetEase(Ease.InOutQuad);
string finalChar = i < finalTargetCharacters.Count ? finalTargetCharacters[i] : particle.currentChar;
TargetFocusVisual visual = CreateFocusVisual(particle, finalChar);
visual.focusPosition = focusPosition;
visual.finalPosition = finalPosition;
visual.orderIndex = i;
focusVisuals[particle] = visual;
}
if (tweenTargetCount == 0)
focusMoveTweensCompleted = true;
focusTransitionRoutine = StartCoroutine(WaitForFocusPresentationEntry());
}
private IEnumerator WaitForFocusPresentationEntry()
{
while (releaseLogSpriteRenderer != null &&
releaseLogSpriteRenderer.gameObject.activeSelf &&
releaseLogSpriteRenderer.enabled &&
releaseLogSpriteRenderer.color.a > 0.001f)
{
yield return null;
}
while (!focusMoveTweensCompleted && completionPhase == CompletionPhase.Focusing)
yield return null;
if (completionPhase == CompletionPhase.Focusing && !focusTweensCompleted)
EnterFocusHolding();
focusTransitionRoutine = null;
}
private void StopFocusTransitionRoutine()
{
if (focusTransitionRoutine == null)
return;
StopCoroutine(focusTransitionRoutine);
focusTransitionRoutine = null;
}
private TargetFocusVisual CreateFocusVisual(CandidateParticle particle, string finalChar)
{
TargetFocusVisual visual = new TargetFocusVisual
{
particle = particle,
finalChar = finalChar,
startShakeIntensity = Mathf.Max(particle.anxietyShakeIntensity, 4f)
};
GameObject ringObj = new GameObject("FocusRing");
ringObj.layer = renderLayer;
ringObj.transform.SetParent(particle.transform, false);
visual.ringObject = ringObj;
float estimatedRadius = 0.55f;
if (particle != null && particle.textMesh != null)
{
estimatedRadius = Mathf.Clamp(particle.textMesh.fontSize * 0.05f, 0.35f, 0.75f);
}
var disc = ringObj.AddComponent<Disc>();
disc.Type = DiscType.Ring;
disc.Radius = estimatedRadius;
disc.Thickness = Mathf.Clamp(focusRingThickness, 0.01f, 0.3f);
Color ringColor = focusRingColor;
ringColor.a = focusRingAlpha;
disc.Color = ringColor;
visual.ringDisc = disc;
return visual;
}
private void EnterFocusHolding()
{
completionPhase = CompletionPhase.FocusHolding;
focusTimer = 0f;
if (!string.IsNullOrEmpty(completionDialogNode))
{
DialogController.Instance?.StartDialogNode(completionDialogNode);
}
}
private void UpdateFocusHolding()
{
focusTimer += Time.deltaTime;
float compression = Mathf.SmoothStep(0f, 1f, lieEdgeCompression);
float gather = Mathf.SmoothStep(0f, 1f, truthGatherProgress);
int visualCount = Mathf.Max(1, focusVisuals.Count);
foreach (var visual in focusVisuals.Values)
{
if (visual == null || visual.particle == null)
continue;
// 真话聚合尝试:悬浮位被拉向最终位置;谎话挤压仍拥有更高优先级
Vector3 gatherPosition = Vector3.Lerp(visual.focusPosition, visual.finalPosition, gather);
Vector3 compressedPosition = Vector3.Lerp(
gatherPosition,
visual.lieEdgePosition,
compression);
Vector3 shuffleOffset = CalculateShuffleOffset(visual, compression, focusTimer) *
Mathf.Lerp(1f, 0.18f, compression) *
Mathf.Lerp(1f, 0.12f, gather);
Vector3 edgeJitter = CalculateLieEdgeJitter(visual, compression);
visual.scatterOffset = Vector3.Lerp(visual.scatterOffset, Vector3.zero, Time.deltaTime * 5.5f);
visual.particle.transform.position =
compressedPosition + shuffleOffset + edgeJitter + visual.scatterOffset;
// 锁字判定用原始进度:target=0.92 时阈值 0.95 的最后一个字永远锁不住
UpdateTruthGatherLock(visual, truthGatherProgress, visualCount);
}
}
private void UpdateTruthGatherLock(TargetFocusVisual visual, float gather, int visualCount)
{
// 按字序阶梯式锁定:阈值 0.45~0.95。目标进度 <1 时最后几个字永远差一点锁不住。
float lockThreshold = 0.45f + 0.5f * (visual.orderIndex + 1f) / visualCount;
if (!visual.gatherLocked && gather >= lockThreshold)
{
visual.gatherLocked = true;
visual.particle.ForceSetCharacter(visual.finalChar);
visual.particle.SetChangeInterval(2f);
visual.particle.ResetChangeTimer(2f);
visual.particle.anxietyShakeIntensity = 0.6f;
visual.particle.isStatic = true;
}
else if (visual.gatherLocked && gather < lockThreshold - 0.08f)
{
visual.gatherLocked = false;
visual.particle.isStatic = false;
visual.particle.SetChangeInterval(0.06f);
visual.particle.ResetChangeTimer(0.06f);
visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 4.5f);
}
}
/// <summary>
/// 真话聚合尝试(非等待):FocusHolding 下把目标字拉向最终位置并按序逐字锁定。
/// target 小于 1 时最后几个字永远差一点锁不住,用于"感觉快要成功了"。
/// </summary>
public void BeginTruthGather(float target, float duration)
{
if (completionPhase != CompletionPhase.FocusHolding)
{
Debug.LogWarning(
$"[LanguageParticleManager] 真话聚合在 {completionPhase} 状态被调用;已安全跳过。");
return;
}
DOTween.Kill(TruthGatherTweenId);
DOTween.To(
() => truthGatherProgress,
value => truthGatherProgress = value,
Mathf.Clamp01(target),
Mathf.Max(0.05f, duration))
.SetEase(Ease.InOutSine)
.SetId(TruthGatherTweenId)
.SetTarget(this);
}
/// <summary>真话聚合被打断:目标字向外爆散并恢复乱跳(非等待)。</summary>
public void ScatterTruthGather(float burstDistance)
{
if (completionPhase != CompletionPhase.FocusHolding)
return;
DOTween.Kill(TruthGatherTweenId);
truthGatherProgress = 0f;
foreach (TargetFocusVisual visual in focusVisuals.Values)
{
if (visual?.particle == null)
continue;
visual.gatherLocked = false;
Vector2 direction = UnityEngine.Random.insideUnitCircle.normalized;
visual.scatterOffset = (Vector3)(direction *
Mathf.Max(0f, burstDistance) *
UnityEngine.Random.Range(0.6f, 1.3f));
visual.particle.isStatic = false;
visual.particle.SetChangeInterval(0.05f);
visual.particle.ResetChangeTimer(0.05f);
visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 6f);
}
}
private void UpdateFocusSequence()
{
if (!focusTweensCompleted)
return;
focusTimer += Time.deltaTime;
CalculateResolveTiming(
focusVisuals.Count,
resolveTotalDurationOverride,
Mathf.Max(0.2f, probabilityDuration),
Mathf.Max(0f, predictionSequenceGap),
out float baseInterval,
out float activeStagger);
float arrangementDuration = Mathf.Max(0.2f, probabilityDuration * 0.6f);
if (resolveTotalDurationOverride > 0f)
arrangementDuration = Mathf.Max(0.05f, baseInterval * 0.65f);
foreach (var visual in focusVisuals.Values)
{
if (visual == null || visual.particle == null)
continue;
float localTime = focusTimer - visual.orderIndex * activeStagger;
float probabilityProgress = Mathf.Clamp01(localTime / baseInterval);
float arrangementProgress = Mathf.Clamp01(localTime / arrangementDuration);
float interval = Mathf.Lerp(0.05f, 0.8f, probabilityProgress);
visual.particle.SetChangeInterval(interval);
visual.particle.changeSpeedMultiplier = Mathf.Lerp(6f, 1f, probabilityProgress);
visual.particle.anxietyShakeIntensity = Mathf.Lerp(visual.startShakeIntensity, 0f, probabilityProgress);
visual.particle.calmProgress = probabilityProgress;
Vector3 dynamicTarget = GetDynamicArrangementTarget(visual, arrangementProgress, localTime);
Vector3 targetPosition = Vector3.Lerp(visual.focusPosition, dynamicTarget, Mathf.SmoothStep(0f, 1f, arrangementProgress));
Vector3 shuffleOffset = CalculateShuffleOffset(visual, arrangementProgress, localTime);
visual.particle.transform.position = targetPosition + shuffleOffset;
if (!visual.stabilized && probabilityProgress >= 1f)
{
visual.particle.ForceSetCharacter(visual.finalChar);
visual.particle.SetChangeInterval(1.5f);
visual.particle.ResetChangeTimer(visual.particle.changeSpeedMultiplier);
visual.particle.isStatic = true;
visual.stabilized = true;
}
}
bool allStable = focusVisuals.Values.All(v => v.stabilized);
if (allStable)
{
CompleteFocusSequence();
}
}
/// <summary>
/// 从 FocusHolding 进入稳定化阶段(文字锁定、排列到最终位置)
/// </summary>
public void ResolveFocusSequence(float totalDuration = -1f)
{
if (completionPhase != CompletionPhase.FocusHolding)
return;
resolveTotalDurationOverride = totalDuration > 0f ? Mathf.Max(0.05f, totalDuration) : -1f;
completionPhase = CompletionPhase.Focusing;
focusTweensCompleted = true;
focusTimer = 0f;
}
public static void CalculateResolveTiming(
int characterCount,
float totalDuration,
float defaultCharacterDuration,
float defaultStagger,
out float characterDuration,
out float stagger)
{
int count = Mathf.Max(1, characterCount);
if (totalDuration <= 0f)
{
characterDuration = Mathf.Max(0.05f, defaultCharacterDuration);
stagger = Mathf.Max(0f, defaultStagger);
return;
}
float total = Mathf.Max(0.05f, totalDuration);
float staggerBudget = count > 1 ? total * 0.35f : 0f;
stagger = count > 1 ? staggerBudget / (count - 1) : 0f;
characterDuration = Mathf.Max(0.05f, total - staggerBudget);
float computedEnd = characterDuration + stagger * (count - 1);
if (computedEnd > total)
characterDuration = Mathf.Max(0.01f, characterDuration - (computedEnd - total));
}
/// <summary>
/// 等待聚焦流程全部完成(稳定化 → Finished)
/// </summary>
public IEnumerator WaitUntilFocusFinished()
{
while (completionPhase != CompletionPhase.Finished && completionPhase != CompletionPhase.None)
{
yield return null;
}
}
public static bool ShouldClipConnections(CompletionPhase phase)
{
return phase == CompletionPhase.Focusing ||
phase == CompletionPhase.FocusHolding ||
phase == CompletionPhase.Finished;
}
public void SetPresentationController(LogReleasePresentationController controller)
{
presentationController = controller;
}
public void BeginLieEdgeCompression(float duration, float jitterAmount)
{
if (completionPhase != CompletionPhase.FocusHolding)
{
Debug.LogWarning(
$"[LanguageParticleManager] 谎话边缘挤压在 {completionPhase} 状态被调用;已安全跳过。");
return;
}
Bounds edgeBounds = GetCompletionClipBounds();
int visualCount = Mathf.Max(1, focusVisuals.Count);
int index = 0;
foreach (TargetFocusVisual visual in focusVisuals.Values.OrderBy(value => value.orderIndex))
{
if (visual?.particle == null)
continue;
visual.lieEdgePosition = CalculateScreenEdgePosition(
edgeBounds,
index,
visualCount,
0.04f);
visual.particle.isStatic = false;
visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 5.5f);
visual.particle.SetChangeInterval(0.045f);
index++;
}
DOTween.Kill(LieCompressionTweenId);
DOTween.To(
() => lieEdgeCompression,
value => lieEdgeCompression = value,
1f,
Mathf.Max(0.05f, duration))
.SetEase(Ease.InCubic)
.SetId(LieCompressionTweenId)
.SetTarget(this);
DOTween.To(
() => lieEdgeJitter,
value => lieEdgeJitter = value,
Mathf.Max(0f, jitterAmount),
Mathf.Max(0.04f, duration * 0.7f))
.SetEase(Ease.OutQuad)
.SetId(LieCompressionTweenId)
.SetTarget(this);
}
/// <summary>
/// 老虎机换字演出:聚焦字在 duration 内只从 charPool 里疯狂换字,到点后自动恢复默认字符池并继续抖动。
/// </summary>
public void BeginSlotMachineShuffle(string charPool, float duration, float interval = 0.05f)
{
if (completionPhase != CompletionPhase.FocusHolding)
{
Debug.LogWarning(
$"[LanguageParticleManager] 老虎机换字在 {completionPhase} 状态被调用;已安全跳过。");
return;
}
StopSlotMachineShuffle();
string pool = SanitizeSlotMachinePool(charPool);
if (string.IsNullOrEmpty(pool))
{
Debug.LogWarning("[LanguageParticleManager] 老虎机换字字符池为空;已安全跳过。");
return;
}
float changeInterval = Mathf.Max(0.02f, interval);
foreach (TargetFocusVisual visual in focusVisuals.Values)
{
if (visual?.particle == null)
continue;
visual.particle.SetOverrideCharPool(pool);
visual.particle.SetChangeInterval(changeInterval);
visual.particle.ResetChangeTimer(changeInterval);
visual.particle.isStatic = false;
visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 5f);
}
slotMachineRoutine = StartCoroutine(EndSlotMachineShuffleAfter(Mathf.Max(0.05f, duration)));
}
/// <summary>
/// 清洗老虎机字符池:去掉空格与 | 分隔符,空结果返回 null。
/// </summary>
public static string SanitizeSlotMachinePool(string charPool)
{
if (string.IsNullOrEmpty(charPool))
return null;
string cleaned = charPool.Replace(" ", string.Empty).Replace("|", string.Empty);
return cleaned.Length == 0 ? null : cleaned;
}
public void StopSlotMachineShuffle()
{
if (slotMachineRoutine != null)
{
StopCoroutine(slotMachineRoutine);
slotMachineRoutine = null;
}
foreach (CandidateParticle particle in targetParticles)
particle?.SetOverrideCharPool(null);
}
private IEnumerator EndSlotMachineShuffleAfter(float duration)
{
yield return new WaitForSeconds(duration);
slotMachineRoutine = null;
foreach (CandidateParticle particle in targetParticles)
particle?.SetOverrideCharPool(null);
}
public void PrepareTruthReleaseFromLie()
{
DOTween.Kill(LieCompressionTweenId);
DOTween.Kill(TruthGatherTweenId);
truthGatherProgress = 0f;
foreach (TargetFocusVisual visual in focusVisuals.Values)
{
if (visual?.particle == null)
continue;
// The resolve sequence now starts from the squeezed perimeter position,
// making every real character visibly rush back through the broken lie.
visual.focusPosition = visual.particle.transform.position;
visual.lieEdgePosition = visual.focusPosition;
visual.startShakeIntensity = Mathf.Max(visual.startShakeIntensity, 6.5f);
visual.particle.anxietyShakeIntensity = visual.startShakeIntensity;
}
lieEdgeCompression = 0f;
lieEdgeJitter = 0f;
}
public Bounds GetFinalTruthWorldBounds()
{
if (finalTargetPositions.Count == 0)
return GetTargetWorldBounds();
Bounds result = new Bounds(finalTargetPositions[0], Vector3.zero);
for (int i = 1; i < finalTargetPositions.Count; i++)
result.Encapsulate(finalTargetPositions[i]);
float glyphPadding = Mathf.Max(0.3f, targetParticleFontSize * 0.09f * focusZoomScale);
result.Expand(new Vector3(glyphPadding * 2f, glyphPadding * 2f, 0f));
return result;
}
public static Vector3 CalculateScreenEdgePosition(
Bounds bounds,
int index,
int count,
float inset)
{
int safeCount = Mathf.Max(1, count);
float angle = -Mathf.PI * 0.5f +
Mathf.PI * 2f * ((index + 0.5f) / safeCount);
Vector2 direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
float availableX = Mathf.Max(0f, bounds.extents.x - Mathf.Max(0f, inset));
float availableY = Mathf.Max(0f, bounds.extents.y - Mathf.Max(0f, inset));
float distanceX = Mathf.Abs(direction.x) > 0.0001f
? availableX / Mathf.Abs(direction.x)
: float.PositiveInfinity;
float distanceY = Mathf.Abs(direction.y) > 0.0001f
? availableY / Mathf.Abs(direction.y)
: float.PositiveInfinity;
float distance = Mathf.Min(distanceX, distanceY);
return bounds.center + (Vector3)(direction * distance);
}
public void SetTargetParticlesAlpha(float targetAlpha, float duration = 0f)
{
float alphaValue = Mathf.Clamp01(targetAlpha);
foreach (CandidateParticle particle in targetParticles)
{
if (particle == null) continue;
DOTween.Kill(particle);
if (duration <= 0f)
{
particle.alpha = alphaValue;
}
else
{
DOTween.To(() => particle.alpha, value => particle.alpha = value, alphaValue, duration)
.SetEase(Ease.OutQuad)
.SetTarget(particle);
}
}
}
public void SetTruthWarmVisuals(Color warmColor)
{
ApplyPresentationResolvedVisuals(warmColor);
}
/// <summary>
/// 梳理完成/真话阶段:目标字改为指定色(默认白),并取消加粗以便大屏辨认。
/// 玩法阶段颜色不受影响;下一次 start_expression 会通过 RestoreTargetPresentationDefaults 还原。
/// </summary>
public void ApplyPresentationResolvedVisuals(Color resolvedColor)
{
Color color = resolvedColor;
color.a = 1f;
foreach (CandidateParticle particle in targetParticles)
{
if (particle == null) continue;
particle.SetColors(color, nonTargetParticleColor, nonTargetParticleColor, color);
particle.calmProgress = 1f;
particle.SetFontStyles(
targetParticleFontSize,
nonTargetParticleFontSize,
false,
nonTargetParticleBold);
}
}
public IEnumerator FlashTruthFragment(string fragment, float duration = 0.22f)
{
if (string.IsNullOrEmpty(fragment) || orderedRedParticles.Count == 0)
yield break;
int startIndex = targetSentence.IndexOf(fragment, System.StringComparison.Ordinal);
if (startIndex < 0)
{
Debug.LogWarning($"[LanguageParticleManager] 真话泄漏片段“{fragment}”不在目标句“{targetSentence}”中。");
yield break;
}
var leaked = new List<CandidateParticle>();
int endExclusive = Mathf.Min(startIndex + fragment.Length, orderedRedParticles.Count);
for (int i = startIndex; i < endExclusive; i++)
{
CandidateParticle particle = orderedRedParticles[i];
if (particle == null) continue;
particle.ForceSetCharacter(targetSentence[i].ToString());
particle.isStatic = true;
particle.alpha = 1f;
leaked.Add(particle);
}
yield return new WaitForSeconds(Mathf.Max(0.02f, duration));
if (completionPhase == CompletionPhase.FocusHolding)
{
foreach (CandidateParticle particle in leaked)
{
if (particle == null) continue;
particle.alpha = 0.2f;
particle.isStatic = false;
particle.ResetChangeTimer(0.02f);
}
}
}
public Bounds GetTargetWorldBounds()
{
bool hasBounds = false;
Bounds result = new Bounds(
worldCanvas != null ? worldCanvas.transform.position : transform.position,
Vector3.zero);
foreach (CandidateParticle particle in targetParticles)
{
if (particle == null || particle.textMesh == null || particle.textMesh.renderer == null)
continue;
Bounds textBounds = particle.textMesh.renderer.bounds;
if (!hasBounds)
{
result = textBounds;
hasBounds = true;
}
else
{
result.Encapsulate(textBounds);
}
}
return result;
}
public IEnumerator ResolveFocusAndWait(float totalDuration)
{
if (completionPhase != CompletionPhase.FocusHolding)
{
Debug.LogWarning(
$"[LanguageParticleManager] ResolveFocusAndWait 在 {completionPhase} 状态被调用;已安全跳过。");
yield break;
}
ResolveFocusSequence(totalDuration);
yield return WaitUntilFocusFinished();
}
public void SnapTruthImmediate()
{
if (completionPhase != CompletionPhase.FocusHolding)
{
Debug.LogWarning(
$"[LanguageParticleManager] 真话瞬间稳定在 {completionPhase} 状态被调用;已安全跳过。");
return;
}
DOTween.Kill(LieCompressionTweenId);
DOTween.Kill(TruthGatherTweenId);
truthGatherProgress = 0f;
StopSlotMachineShuffle();
completionPhase = CompletionPhase.Focusing;
focusTweensCompleted = true;
focusTimer = 0f;
foreach (TargetFocusVisual visual in focusVisuals.Values)
{
if (visual?.particle == null)
continue;
CandidateParticle particle = visual.particle;
particle.transform.DOKill();
particle.transform.position = visual.finalPosition;
particle.ForceSetCharacter(visual.finalChar);
particle.SetChangeInterval(1.5f);
particle.ResetChangeTimer(1.5f);
particle.anxietyShakeIntensity = 0f;
particle.changeSpeedMultiplier = 1f;
particle.calmProgress = 1f;
particle.isStatic = true;
visual.stabilized = true;
}
resolveTotalDurationOverride = -1f;
lieEdgeCompression = 0f;
lieEdgeJitter = 0f;
CompleteFocusSequence();
}
public void RestoreTargetPresentationDefaults(float alphaValue = 1f)
{
DOTween.Kill(LieCompressionTweenId);
DOTween.Kill(TruthGatherTweenId);
truthGatherProgress = 0f;
StopSlotMachineShuffle();
foreach (CandidateParticle particle in targetParticles)
{
if (particle == null) continue;
DOTween.Kill(particle);
particle.alpha = Mathf.Clamp01(alphaValue);
particle.calmProgress = 0f;
particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor);
particle.SetFontStyles(
targetParticleFontSize,
nonTargetParticleFontSize,
targetParticleBold,
nonTargetParticleBold);
}
resolveTotalDurationOverride = -1f;
lieEdgeCompression = 0f;
lieEdgeJitter = 0f;
}
public void ReleaseScreenClipMaterials()
{
foreach (CandidateParticle particle in candidateParticles)
particle?.ClearScreenClip();
foreach (FloatingTextParticle particle in floatingParticles)
particle?.ClearScreenClip();
}
private void RebuildScreenClipMaterials()
{
foreach (CandidateParticle particle in candidateParticles)
ConfigureParticleScreenMask(particle);
foreach (FloatingTextParticle particle in floatingParticles)
ConfigureParticleScreenMask(particle);
}
private void DestroyFocusVisualDecorationsAndClear()
{
foreach (var visual in focusVisuals.Values)
{
if (visual == null)
continue;
if (visual.ringObject != null)
Destroy(visual.ringObject);
}
focusVisuals.Clear();
}
private void CompleteFocusSequence()
{
var particlesToScale = new List<CandidateParticle>();
foreach (var visual in focusVisuals.Values)
{
if (visual?.particle != null)
particlesToScale.Add(visual.particle);
}
DestroyFocusVisualDecorationsAndClear();
foreach (var p in particlesToScale)
{
p.transform.DOScale(Vector3.one * focusZoomScale, 0.3f).SetEase(Ease.OutQuad);
p.isStatic = true;
p.changeSpeedMultiplier = 1f;
}
if (connectionRenderer != null)
{
connectionRenderer.TargetAlphaScale = 0f;
}
lieEdgeCompression = 0f;
lieEdgeJitter = 0f;
truthGatherProgress = 0f;
completionPhase = CompletionPhase.Finished;
}
private Vector3 GetDynamicArrangementTarget(TargetFocusVisual visual, float arrangementProgress, float localTime)
{
if (visual == null)
{
return Vector3.zero;
}
Vector3 target = visual.finalPosition;
if (!visual.stabilized && finalTargetPositions.Count > 1)
{
int swapIndex = (visual.orderIndex + 1) % finalTargetPositions.Count;
swapIndex = Mathf.Clamp(swapIndex, 0, finalTargetPositions.Count - 1);
Vector3 neighbor = finalTargetPositions[swapIndex];
float swapStrength = Mathf.Clamp01(Mathf.Sin(Mathf.Max(0f, localTime) * shuffleFrequency * 0.25f));
float blend = (1f - arrangementProgress) * swapStrength * 0.5f;
target = Vector3.Lerp(target, neighbor, blend);
}
return target;
}
private Vector3 CalculateShuffleOffset(TargetFocusVisual visual, float arrangementProgress, float localTime)
{
if (visual == null || shuffleDistance <= 0f || visual.stabilized)
{
return Vector3.zero;
}
float shuffleAmount = Mathf.Clamp01(1f - arrangementProgress);
if (shuffleAmount <= 0f)
{
return Vector3.zero;
}
float timeSeed = (focusTimer + localTime * 0.35f) * shuffleFrequency + visual.orderIndex * 0.75f;
float x = Mathf.Sin(timeSeed) * shuffleDistance * shuffleAmount;
float y = Mathf.Cos(timeSeed * 0.8f) * shuffleDistance * shuffleVerticalScale * shuffleAmount;
return new Vector3(x, y, 0f);
}
private Vector3 CalculateLieEdgeJitter(TargetFocusVisual visual, float compression)
{
if (visual == null || compression <= 0f || lieEdgeJitter <= 0f)
return Vector3.zero;
float seed = visual.orderIndex * 1.731f;
float x = Mathf.Sin(focusTimer * 29f + seed) +
Mathf.Sin(focusTimer * 53f + seed * 0.61f) * 0.35f;
float y = Mathf.Cos(focusTimer * 37f + seed * 1.17f) +
Mathf.Sin(focusTimer * 61f + seed) * 0.28f;
return new Vector3(x, y, 0f) * lieEdgeJitter * compression;
}
private void FadeOutTextParticle(TextParticle particle, float duration)
{
if (particle == null) return;
DOTween.To(() => particle.alpha, value => particle.alpha = value, 0f, duration)
.SetEase(Ease.OutQuad)
.SetTarget(particle);
if (particle.glowSprite != null)
{
particle.glowSprite.DOFade(0f, duration).SetTarget(particle);
}
}
/// <summary>
/// 淡出 UI 元素(使用 CanvasGroup),结束后 SetActive(false)
/// </summary>
private void FadeOutUIElement(GameObject obj, float duration)
{
if (obj == null || !obj.activeSelf) return;
var cg = obj.GetComponent<CanvasGroup>();
if (cg == null)
cg = obj.AddComponent<CanvasGroup>();
cg.DOKill();
cg.alpha = 1f;
cg.DOFade(0f, duration)
.SetEase(Ease.OutQuad)
.OnComplete(() => obj.SetActive(false));
}
/// <summary>
/// 淡出 SpriteRenderer,结束后禁用并隐藏
/// </summary>
private void FadeOutSpriteRenderer(SpriteRenderer sr, float duration)
{
if (sr == null || !sr.gameObject.activeSelf) return;
sr.DOFade(0f, duration)
.SetEase(Ease.OutQuad)
.OnComplete(() =>
{
sr.enabled = false;
sr.gameObject.SetActive(false);
});
}
/// <summary>
/// 立即隐藏火山释放 log(无动画),用于新一轮 start 前先淡出文字、再单独淡入 log。
/// </summary>
private void HideReleaseLogImmediate()
{
if (releaseLogSpriteRenderer == null)
return;
DOTween.Kill(releaseLogSpriteRenderer);
releaseLogSpriteRenderer.enabled = false;
releaseLogSpriteRenderer.gameObject.SetActive(false);
Color c = releaseLogSpriteRenderer.color;
c.a = 0f;
releaseLogSpriteRenderer.color = c;
}
/// <summary>
/// 若火山释放 log 界面已淡出,则淡入显示(start_expression 时调用,确保流程开始前界面可见)
/// </summary>
/// <param name="duration">淡入时长(秒)</param>
public IEnumerator FadeInReleaseLogIfNeeded(float duration = 1f)
{
if (releaseLogSpriteRenderer == null) yield break;
if (releaseLogSpriteRenderer.gameObject.activeSelf && releaseLogSpriteRenderer.color.a > 0.9f)
yield break; // 已显示,跳过
DOTween.Kill(releaseLogSpriteRenderer);
releaseLogSpriteRenderer.enabled = true;
releaseLogSpriteRenderer.gameObject.SetActive(true);
Color c = releaseLogSpriteRenderer.color;
c.a = 0f;
releaseLogSpriteRenderer.color = c;
yield return releaseLogSpriteRenderer.DOFade(1f, duration).SetEase(Ease.OutQuad).WaitForCompletion();
}
/// <summary>
/// 恢复 SpriteRenderer 显示(用于重新开始时恢复火山释放 log 界面)
/// </summary>
private void RestoreSpriteRenderer(SpriteRenderer sr)
{
if (sr == null) return;
DOTween.Kill(sr);
sr.enabled = true;
sr.gameObject.SetActive(true);
Color c = sr.color;
c.a = 1f;
sr.color = c;
}
private void KillCompletionUiTweens()
{
KillCanvasGroupTween(languageDeepPanel1);
KillCanvasGroupTween(languageDeepPanel2);
if (focusSequenceButton != null)
KillCanvasGroupTween(focusSequenceButton.gameObject);
if (releaseLogSpriteRenderer != null)
DOTween.Kill(releaseLogSpriteRenderer);
}
private static void KillCanvasGroupTween(GameObject obj)
{
if (obj == null) return;
CanvasGroup group = obj.GetComponent<CanvasGroup>();
if (group != null)
group.DOKill();
}
private void RestoreCompletionUiAlpha()
{
RestoreCanvasGroupAlpha(languageDeepPanel1);
RestoreCanvasGroupAlpha(languageDeepPanel2);
if (focusSequenceButton != null)
RestoreCanvasGroupAlpha(focusSequenceButton.gameObject);
}
private static void RestoreCanvasGroupAlpha(GameObject obj)
{
if (obj == null) return;
CanvasGroup group = obj.GetComponent<CanvasGroup>();
if (group != null)
group.alpha = 1f;
}
private Vector3 ComputeCentroid(List<CandidateParticle> particles)
{
if (particles == null || particles.Count == 0)
{
return worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
}
Vector3 sum = Vector3.zero;
foreach (var particle in particles)
{
sum += particle.transform.position;
}
return sum / particles.Count;
}
private void UpdateInput()
{
// 只在允许输入时处理鼠标逻辑
if (!allowInput || completionPhase != CompletionPhase.None) return;
Vector2 mouseWorldPos = GetMouseWorldPosition();
// 左键点击:排斥附近粒子
if (Input.GetMouseButton(0))
{
ApplyForceToNearbyParticles(mouseWorldPos, mouseInteractionRadius, repulsionForce);
}
// 右键点击:吸引附近粒子
if (Input.GetMouseButton(1))
{
ApplyForceToNearbyParticles(mouseWorldPos, mouseInteractionRadius, -attractionForce);
}
}
private void ApplyForceToNearbyParticles(Vector2 center, float repulsionRadius, float repulsionForce)
{
foreach (var particle in candidateParticles)
{
Vector2 particlePos = particle.transform.position;
float distance = Vector2.Distance(center, particlePos);
if (distance < repulsionRadius && distance > 0.01f)
{
// 计算方向和力度
Vector2 direction = (particlePos - center).normalized;
float forceMagnitude = repulsionForce * (1f - distance / repulsionRadius);
// 应用力
particle.ApplyForce(direction * forceMagnitude);
}
}
}
private Vector2 GetMouseWorldPosition()
{
Vector3 mousePos = Input.mousePosition;
Camera cam = Camera.main;
if (cam != null)
{
// 对于正交相机,需要使用正确的Z距离
float zDistance = cam.transform.position.z - worldCanvas.transform.position.z;
Vector3 worldPos = cam.ScreenToWorldPoint(new Vector3(mousePos.x, mousePos.y, Mathf.Abs(zDistance)));
return new Vector2(worldPos.x, worldPos.y);
}
return Vector2.zero;
}
public void ResetGame(bool playEntranceEffect = true)
{
StopFocusTransitionRoutine();
KillCompletionUiTweens();
RestoreTargetPresentationDefaults();
RestoreCompletionUiAlpha();
isCompleted = false;
completionPhase = CompletionPhase.None;
completionTimer = 0f;
focusTimer = 0f;
fadeOutAlpha = 1f;
allowInput = !playEntranceEffect;
orderedRedParticles.Clear();
DestroyFocusVisualDecorationsAndClear();
finalTargetCharacters.Clear();
finalTargetPositions.Clear();
previousConnections.Clear();
initializationComplete = false;
initializationFrames = 0;
focusTweensCompleted = false;
interferenceLogicSuspended = false;
ClearCompletionClampSmoothVelocity();
SetStatusUIVisibility(true, 0f);
if (entranceRoutine != null)
{
StopCoroutine(entranceRoutine);
entranceRoutine = null;
}
if (confirmTimelineRoutine != null)
{
StopCoroutine(confirmTimelineRoutine);
confirmTimelineRoutine = null;
}
if (expressionPanelRoutine != null)
{
StopCoroutine(expressionPanelRoutine);
expressionPanelRoutine = null;
}
if (focusSequenceButton != null)
{
focusSequenceButton.onClick.RemoveListener(HandleFocusButtonClicked);
focusSequenceButton.onClick.AddListener(HandleFocusButtonClicked);
focusSequenceButton.interactable = false;
focusSequenceButton.gameObject.SetActive(false);
}
if (languageDeepPanel1 != null)
languageDeepPanel1.SetActive(false);
if (languageDeepPanel2 != null)
languageDeepPanel2.SetActive(false);
// 恢复火山释放 log 界面 SpriteRenderer(重新开始时恢复)
RestoreSpriteRenderer(releaseLogSpriteRenderer);
if (connectionRenderer != null)
{
connectionRenderer.NonTargetAlphaScale = lineAlphaMultiplier;
connectionRenderer.TargetAlphaScale = 1f;
}
// 重置所有粒子
foreach (var p in candidateParticles)
{
p.isRed = false;
p.isOrange = false;
p.hasEffect = false;
p.isStatic = false;
p.originalIsRed = false;
p.changeSpeedMultiplier = 1f;
p.vibrationOffset = Vector2.zero;
p.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f));
p.transform.position = GetRandomPositionInBounds();
p.isCalmed = false;
p.calmProgress = 0f;
p.alpha = p.baseAlpha;
p.transform.localScale = Vector3.one;
DOTween.Kill(p);
p.transform.DOKill();
p.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude);
if (p.glowSprite != null)
{
Color glowColor = p.glowSprite.color;
glowColor.a = 0.2f;
p.glowSprite.color = glowColor;
}
}
foreach (var floating in floatingParticles)
{
floating.isCalmed = false;
floating.alpha = floating.baseAlpha;
DOTween.Kill(floating);
floating.transform.DOKill();
}
ReleaseScreenClipMaterials();
RebuildScreenClipMaterials();
SelectRedParticles();
UpdateStatusUI();
if (playEntranceEffect)
{
PlayEntranceSequence();
}
}
private void HandleDebugShortcuts()
{
if (!enableDebugShortcuts || !Application.isPlaying)
return;
// 如果未初始化,先初始化
if (!isInitialized)
return;
if (Input.GetKeyDown(debugCompleteKey))
{
DebugCompleteTargets();
}
if (Input.GetKeyDown(debugResetKey))
{
ResetGame();
}
if (Input.GetKeyDown(debugFullTestKey))
{
QueueDebugFullFlow();
}
}
[ContextMenu("Debug/一键完成目标")]
public void DebugCompleteTargets()
{
if (!isCompleted)
{
isCompleted = true;
OnTargetsCompleted();
}
if (completionPhase == CompletionPhase.Completed)
{
BeginFocusSequence();
}
}
[ContextMenu("Debug/重置场景")]
public void DebugResetScene()
{
ResetGame();
}
[ContextMenu("Debug/完整流程测试")]
public void DebugPlayFullFlow()
{
QueueDebugFullFlow();
}
private void QueueDebugFullFlow()
{
if (!gameObject.activeInHierarchy)
return;
if (debugTestRoutine != null)
{
StopCoroutine(debugTestRoutine);
}
debugTestRoutine = StartCoroutine(DebugFullFlowRoutine());
}
private IEnumerator DebugFullFlowRoutine()
{
DebugCompleteTargets();
if (completionPhase == CompletionPhase.Completed)
{
BeginFocusSequence();
}
while (completionPhase == CompletionPhase.Focusing)
{
yield return null;
}
if (completionPhase == CompletionPhase.FocusHolding)
{
yield return new WaitForSeconds(1.5f);
ResolveFocusSequence();
}
while (completionPhase != CompletionPhase.Finished)
{
yield return null;
}
yield return new WaitForSeconds(0.5f);
ResetGame();
debugTestRoutine = null;
}
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)
{
StopCoroutine(entranceRoutine);
}
entranceRoutine = StartCoroutine(EntranceSequenceRoutine());
}
private IEnumerator EntranceSequenceRoutine()
{
allowInput = false;
entranceTargets.Clear();
Vector3 burstOrigin = worldCanvas != null ? worldCanvas.transform.position : transform.position;
List<CandidateParticle> particles = candidateParticles.Where(p => p != null).ToList();
if (particles.Count == 0)
{
allowInput = true;
entranceRoutine = null;
yield break;
}
float initialScale = Mathf.Max(0.05f, entranceInitialScale);
foreach (var candidate in particles)
{
entranceTargets[candidate] = candidate.transform.position;
candidate.transform.DOKill();
candidate.velocity = Vector2.zero;
candidate.transform.position = burstOrigin;
candidate.transform.localScale = Vector3.one * initialScale;
candidate.alpha = 0f;
if (candidate.glowSprite != null)
{
Color glowColor = candidate.glowSprite.color;
glowColor.a = 0f;
candidate.glowSprite.color = glowColor;
}
}
yield return new WaitForSeconds(Mathf.Max(0f, entranceDelay));
int coreCount = Mathf.Clamp(entranceCoreCount, 1, particles.Count);
var shuffled = particles.OrderBy(_ => Random.value).ToList();
var coreCandidates = shuffled.Take(coreCount).ToList();
var remainingCandidates = shuffled.Skip(coreCount).ToList();
float radius = Mathf.Max(0.1f, entranceCoreRadius);
float maxCoreTime = 0f;
for (int i = 0; i < coreCandidates.Count; i++)
{
var candidate = coreCandidates[i];
float angle = (Mathf.PI * 2f / Mathf.Max(1, coreCandidates.Count)) * i;
Vector3 startPos = burstOrigin + new Vector3(Mathf.Cos(angle), Mathf.Sin(angle), 0f) * radius;
candidate.transform.position = startPos;
float delay = i * Mathf.Max(0f, entranceCoreStagger);
Sequence seq = DOTween.Sequence();
seq.SetDelay(delay);
seq.Append(candidate.transform.DOMove(entranceTargets[candidate], entranceCoreBurstDuration).SetEase(Ease.OutCubic));
seq.Join(candidate.transform.DOScale(Vector3.one, entranceCoreBurstDuration).SetEase(Ease.OutBack));
seq.Join(DOTween.To(() => candidate.alpha, value => candidate.alpha = value, candidate.baseAlpha, entranceCoreBurstDuration).SetEase(Ease.OutQuad));
if (candidate.glowSprite != null)
{
seq.Join(candidate.glowSprite.DOFade(0.2f, entranceCoreBurstDuration));
}
seq.OnComplete(() =>
{
candidate.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f));
});
maxCoreTime = Mathf.Max(maxCoreTime, delay + entranceCoreBurstDuration);
}
if (coreCandidates.Count > 0)
{
yield return new WaitForSeconds(maxCoreTime * 0.6f);
}
float cascadeDuration = 0f;
for (int i = 0; i < remainingCandidates.Count; i++)
{
var candidate = remainingCandidates[i];
Vector2 offset = Random.insideUnitCircle * entranceSearchRadius;
candidate.transform.position = burstOrigin + new Vector3(offset.x, offset.y, 0f);
float delay = i * Mathf.Max(0f, entranceCascadeStagger);
Sequence seq = DOTween.Sequence();
seq.SetDelay(delay);
seq.Append(candidate.transform.DOMove(entranceTargets[candidate], entranceCascadeDuration).SetEase(Ease.OutQuad));
seq.Join(candidate.transform.DOScale(Vector3.one, entranceCascadeDuration * 0.8f).SetEase(Ease.OutBack));
seq.Join(DOTween.To(() => candidate.alpha, value => candidate.alpha = value, candidate.baseAlpha, entranceCascadeDuration).SetEase(Ease.OutSine));
if (candidate.glowSprite != null)
{
seq.Join(candidate.glowSprite.DOFade(0.2f, entranceCascadeDuration));
}
seq.OnComplete(() =>
{
candidate.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f));
});
cascadeDuration = Mathf.Max(cascadeDuration, delay + entranceCascadeDuration);
}
float waitTime = Mathf.Max(maxCoreTime, cascadeDuration);
if (waitTime > 0f)
{
yield return new WaitForSeconds(waitTime + 0.2f);
}
foreach (var candidate in particles)
{
if (candidate == null) continue;
candidate.alpha = candidate.baseAlpha;
candidate.transform.localScale = Vector3.one;
}
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,播放前需先激活(若之前被 fade 过,需重置 alpha)
if (languageDeepPanel1 != null)
{
var cg1 = languageDeepPanel1.GetComponent<CanvasGroup>();
if (cg1 != null) cg1.alpha = 1f;
languageDeepPanel1.SetActive(true);
}
if (languageDeepPanel2 != null)
{
var cg2 = languageDeepPanel2.GetComponent<CanvasGroup>();
if (cg2 != null) cg2.alpha = 1f;
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()
{
if (!enableSeparation || candidateParticles.Count <= 1)
return;
float minSpacingSqr = minParticleSpacing * minParticleSpacing;
for (int i = 0; i < candidateParticles.Count - 1; i++)
{
var particleA = candidateParticles[i];
if (particleA.isStatic)
continue;
Vector2 posA = particleA.transform.position;
for (int j = i + 1; j < candidateParticles.Count; j++)
{
var particleB = candidateParticles[j];
if (particleB.isStatic)
continue;
Vector2 posB = particleB.transform.position;
Vector2 diff = posA - posB;
float distSqr = diff.sqrMagnitude;
if (distSqr < minSpacingSqr && distSqr > 0.0001f)
{
float distance = Mathf.Sqrt(distSqr);
float pushStrength = 1f - (distance / minParticleSpacing);
Vector2 direction = diff / distance;
Vector2 push = direction * (pushStrength * separationForce);
particleA.ApplyForce(push * Time.deltaTime);
particleB.ApplyForce(-push * Time.deltaTime);
}
}
}
}
private void UpdateStatusUI()
{
if (statusUIHidden)
return;
if (integrationProgressText != null)
{
float ratio = CalculateIntegrationRatio();
int percent = Mathf.RoundToInt(ratio * 100f);
integrationProgressText.text = $"{percent}% 语言整合完成度";
}
if (interferenceCountText != null)
{
int interferenceCount = CountInterferenceNodes();
interferenceCountText.text = $"{interferenceCount} 处异常思绪仍在干扰表达";
}
}
private float CalculateIntegrationRatio()
{
if (targetParticles.Count == 0)
return 0f;
HashSet<CandidateParticle> visited = new HashSet<CandidateParticle>();
int largestCluster = 0;
foreach (var red in targetParticles)
{
if (visited.Contains(red))
continue;
int clusterSize = 0;
Queue<CandidateParticle> queue = new Queue<CandidateParticle>();
queue.Enqueue(red);
visited.Add(red);
while (queue.Count > 0)
{
var current = queue.Dequeue();
clusterSize++;
foreach (var connected in current.connections)
{
if (connected == null || !connected.isRed)
continue;
if (!visited.Contains(connected))
{
visited.Add(connected);
queue.Enqueue(connected);
}
}
}
if (clusterSize > largestCluster)
{
largestCluster = clusterSize;
}
}
largestCluster = Mathf.Clamp(largestCluster, 0, targetParticles.Count);
return largestCluster / Mathf.Max(1f, targetParticles.Count);
}
private int CountInterferenceNodes()
{
if (targetParticles.Count == 0)
return 0;
HashSet<CandidateParticle> interferingParticles = new HashSet<CandidateParticle>();
foreach (var red in targetParticles)
{
foreach (var connected in red.connections)
{
if (connected == null)
continue;
if (!connected.isRed)
{
interferingParticles.Add(connected);
}
}
}
return interferingParticles.Count;
}
private void SetStatusUIVisibility(bool visible, float duration = 0f)
{
statusUIHidden = !visible;
float integrationAlpha = visible ? integrationTextBaseAlpha : 0f;
float interferenceAlpha = visible ? interferenceTextBaseAlpha : 0f;
AnimateStatusText(integrationProgressText, integrationAlpha, duration);
AnimateStatusText(interferenceCountText, interferenceAlpha, duration);
}
private void AnimateStatusText(TMP_Text text, float targetAlpha, float duration)
{
if (text == null)
return;
DOTween.Kill(text);
if (duration > 0f)
{
text.DOFade(targetAlpha, duration);
}
else
{
Color color = text.color;
color.a = targetAlpha;
text.color = color;
}
}
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
Vector3 center = worldCanvas != null ? worldCanvas.transform.position : transform.position;
float halfSize = canvasSize / 2f - textMargin;
float radius = layoutShape == LayoutShape.Circle && circleRadiusOverride > 0
? circleRadiusOverride
: halfSize;
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.5f);
if (layoutShape == LayoutShape.Circle)
{
// 圆形:用多段线近似圆
const int segments = 64;
Vector3 prev = center + new Vector3(radius, 0f, 0f);
for (int i = 1; i <= segments; i++)
{
float angle = (float)i / segments * Mathf.PI * 2f;
Vector3 curr = center + new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0f);
Gizmos.DrawLine(prev, curr);
prev = curr;
}
}
else
{
// 矩形
Vector3 ext = new Vector3(halfSize, halfSize, 0f);
Gizmos.DrawLine(center + new Vector3(-ext.x, -ext.y, 0f), center + new Vector3(ext.x, -ext.y, 0f));
Gizmos.DrawLine(center + new Vector3(ext.x, -ext.y, 0f), center + new Vector3(ext.x, ext.y, 0f));
Gizmos.DrawLine(center + new Vector3(ext.x, ext.y, 0f), center + new Vector3(-ext.x, ext.y, 0f));
Gizmos.DrawLine(center + new Vector3(-ext.x, ext.y, 0f), center + new Vector3(-ext.x, -ext.y, 0f));
}
}
#endif
}
}