using UnityEngine; using System.Collections; using System.Collections.Generic; using System.Linq; using DG.Tweening; using TMPro; using UnityEngine.UI; using AibisDream; namespace AibisDream.MiniGame.Language { /// /// 粒子排布区域形状 /// public enum LayoutShape { Rectangle, // 矩形 Circle // 圆形 } /// /// 游戏完成阶段 /// public enum CompletionPhase { None, // 未完成 Completed, // 已完成但尚未进入聚焦流程 FocusHolding, // 聚焦位置保持态(文字飘动/变换,等待外部命令触发下一步) Focusing, // 正在聚焦放大并收敛 Finished // 全流程完成 } /// /// 语言粒子系统主管理器 /// 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; [Tooltip("可选:基于 chineseFontAsset 的 Material Preset(如 huoshantextmaterial)")] [SerializeField] private Material chineseFontMaterial; [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 anxietyPhrases = new List(); // 干扰短句(笑话等,用于非目标粒子,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(0.627451f, 1f, 0.8470588f, 1f); // 目标粒子 #A0FFD8 [SerializeField] private Color nonTargetParticleColor = new Color(1f, 0.1882353f, 0.3019608f, 1f); // 干扰粒子固定红色 #FF304D [SerializeField] private Color floatingTextColor = new Color(0.7f, 0.78f, 1f, 1f); // 背景浮动文字颜色 [SerializeField] private Color targetCalmColor = new Color(0.3607843f, 0.945098f, 0.8156863f, 1f); // 聚拢后的固定目标色 #5CF1D0 [Header("连接线颜色(用于连线的颜色,与上面可分开调节)")] [SerializeField] private Color targetConnectionColor = new Color(0.627451f, 1f, 0.8470588f, 0.49f); // 目标连线 #A0FFD8 [SerializeField] private Color nonTargetConnectionColor = new Color(1f, 0.3137255f, 0.4392157f, 0.49f); // 干扰连线 #FF5070 [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; // 非目标粒子漂浮幅度 [SerializeField] private Color[] nonTargetMarqueeColors = { // 保留单色数组以兼容旧的 "rainbow" 演出参数;屏幕粒子不再循环彩虹色。 new Color(1f, 0.1882353f, 0.3019608f, 1f) // #FF304D }; [SerializeField, Min(0.02f)] private float nonTargetMarqueeStepDuration = 0.18f; [Header("连接线粗细")] [SerializeField] private float targetConnectionThickness = 0.04f; // 目标连线粗细 [SerializeField] private float nonTargetConnectionThickness = 0.02f; // 非目标连线粗细 [Header("连接线 Bloom 发光效果")] [SerializeField] private bool enableBloomGlow = true; // 是否启用连接线 HDR 发光 [SerializeField, Range(1f, 5f)] private float lineGlowIntensity = 2f; // 连接线发光强度 [SerializeField, Range(1f, 5f)] private float targetLineGlowIntensity = 2.5f; // 目标连接线发光强度 [Header("目标 UI")] [SerializeField] private TMP_Text integrationProgressText; [SerializeField] private TMP_Text interferenceCountText; [Header("完成/确认阶段 — 屏幕内显示范围")] [Tooltip("为真时,完成连线后(含确认按钮、聚焦、稳定化)目标字符位置会被限制在裁剪区域内,避免超出电视机屏幕可视区")] [SerializeField] private bool clampCompletionPhasePositions = true; [Tooltip("可选:场景中一块与屏幕内框对齐的 RectTransform(World Space Canvas 子物体即可)。不指定则用粒子用的 movementBounds(canvasSize - 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; [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 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 Color focusInterferenceColor = new Color(1f, 0.1882353f, 0.3019608f, 1f); // #FF304D [SerializeField, Range(0.02f, 0.5f)] private float focusInterferenceShakeAmplitude = 0.16f; [SerializeField, Range(1f, 60f)] private float focusInterferenceShakeSpeed = 24f; [SerializeField, Range(0.02f, 0.5f)] private float focusInterferenceChangeInterval = 0.055f; [SerializeField, Range(0.05f, 0.95f)] private float focusInterferenceTransitionRatio = 0.4f; [Header("真话攻击表现")] [SerializeField] private Color truthAttackColor = new Color(1f, 0.1882353f, 0.3019608f, 1f); // #FF304D [SerializeField, Min(0.1f)] private float truthAttackCharacterSpacing = 0.9f; [SerializeField, Min(0f)] private float truthAttackTravelDistance = 0.75f; [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; private bool renderLayerResolved; [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 bool useGlyphBoundsForSeparation = true; // 字号较大时按实际字形扩大间距 [SerializeField, Min(0f)] private float separationPadding = 0.08f; // 字形外额外留白 [SerializeField, Min(1f)] private float differentTypeSpacingMultiplier = 1.2f; [SerializeField, Min(1f)] private float differentTypeForceMultiplier = 1.75f; [SerializeField, Range(0f, 1f)] private float separationPositionCorrection = 0.45f; [SerializeField, Min(0.01f)] private float maxSeparationCorrectionPerFrame = 0.25f; [SerializeField] private float lineAlphaMultiplier = 0.65f; // 连接线透明度缩放 // 粒子列表 private List floatingParticles = new List(); private List candidateParticles = new List(); private List targetParticles = new List(); // 目标粒子列表(原 redParticles) // 效果系统(传播效果已移除,保留空列表供渲染器使用) private static readonly List s_emptyPropagations = new List(); private Dictionary previousConnections = new Dictionary(); 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 orderedRedParticles = new List(); private bool allowInput = true; private float focusTimer = 0f; private Dictionary focusVisuals = new Dictionary(); /// 完成阶段屏幕钳制用 SmoothDamp 速度缓存(按粒子) private readonly Dictionary completionClampSmoothVelocity = new Dictionary(); private Vector3 focusCentroid; private List finalTargetCharacters = new List(); private List finalTargetPositions = new List(); 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 entranceTargets = new Dictionary(); private bool pendingCompletionDialog; private class TargetFocusVisual { public CandidateParticle particle; 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 const string FocusInterferenceTweenId = "HuoshanFocusInterference"; private const string FocusInterferenceAdjustmentTweenId = "HuoshanFocusInterferenceAdjustment"; private int truthAttackSequenceIndex; // 边界 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; public bool IsInitialized => isInitialized; public CompletionPhase CurrentCompletionPhase => completionPhase; /// Inspector 中的候选粒子总数,用于未在 Yarn 中指定非目标数量时恢复默认 private int defaultCandidateCountFromInspector; private bool hasCapturedDefaultCandidateCount; private void CaptureDefaultCandidateCountFromInspector() { if (hasCapturedDefaultCandidateCount) return; defaultCandidateCountFromInspector = candidateCount; hasCapturedDefaultCandidateCount = true; } private void Start() { // Start 中不再自动初始化,等待外部调用 InitializeSystem } /// /// 初始化系统(可以由外部调用,用于配置游戏参数) /// /// 焦虑短句列表 /// 目标句子 /// 完成时触发的对话节点 /// /// 非目标候选粒子总数(蓝/干扰侧可交互粒子数量)。≥0 时候选池大小 = 目标句字数 + 该值;<0 时使用 Inspector 的 Candidate Count。 /// public void InitializeSystem(List 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(); targetSentence = useSentence; completionDialogNode = dialogNode ?? ""; // 设置全局焦虑短句配置 TextParticle.SetAnxietyPhrases(anxietyPhrases); // 根据目标句子更新目标粒子数量 redParticleCount = Mathf.Min(targetSentence.Length, candidateCount); // 重新初始化游戏状态并播放入场动画 ResetGame(true); } /// /// 在再次 start_expression 前调用:若系统已初始化过,则淡出当前粒子、连线与相关 UI,避免与新一轮重叠。 /// duration≤0 时使用 。 /// 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; } /// /// 在打开视图前设置所有初始状态(panels、button 等不激活) /// 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; } } /// /// 等待表达流程就绪(开场表现 + 火山表达panel 播完) /// public IEnumerator WaitUntilExpressionFlowReady() { if (!isInitialized) yield break; while (!allowInput) { yield return null; } } /// /// 停止系统(关闭时调用,但保持初始化状态,可以重新开启) /// 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 ResolveRenderLayer() { renderLayer = LayerMask.NameToLayer(renderLayerName); if (renderLayer == -1) { Debug.LogWarning($"Layer '{renderLayerName}' not found, using default layer."); renderLayer = 0; } renderLayerResolved = true; } private static void ApplyLayerRecursively(GameObject root, int layer) { if (root == null) return; root.layer = layer; var transforms = root.GetComponentsInChildren(true); for (int i = 0; i < transforms.Length; i++) transforms[i].gameObject.layer = layer; } private void ApplyParticleRenderLayer(GameObject particleRoot) { ApplyLayerRecursively(particleRoot, renderLayer); } private void InitializeCanvas() { ResolveRenderLayer(); // 管理器自身也进 HuoshanScreen,避免子物体(连线/噪点)继承 Default gameObject.layer = renderLayer; // 如果没有指定canvas,创建一个 if (worldCanvas == null) { GameObject canvasObj = new GameObject("Language Canvas"); canvasObj.transform.SetParent(transform); canvasObj.transform.localPosition = Vector3.zero; ApplyLayerRecursively(canvasObj, renderLayer); worldCanvas = canvasObj.AddComponent(); worldCanvas.renderMode = RenderMode.WorldSpace; RectTransform rectTransform = canvasObj.GetComponent(); rectTransform.sizeDelta = new Vector2(canvasSize, canvasSize); } else { // 设置已有 Canvas 整棵树到渲染层(含已有 TMP / 粒子) ApplyLayerRecursively(worldCanvas.gameObject, 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(); particle.SetFont(chineseFontAsset, chineseFontMaterial); particle.SetColor(floatingTextColor); particle.SetFontSize(floatingTextFontSize); SetParticleBounds(particle); ConfigureParticleScreenMask(particle); ApplyParticleRenderLayer(obj); floatingParticles.Add(particle); } // 创建候选粒子 for (int i = 0; i < candidateCount; i++) { AddOneCandidateParticle(i); } // 选择红色粒子 SelectRedParticles(); } private GameObject CreateParticleObject(Vector3 position, string name) { if (!renderLayerResolved) ResolveRenderLayer(); GameObject obj = new GameObject(name); obj.layer = renderLayer; // 添加 RectTransform(在 Canvas 下必需) RectTransform rectTransform = obj.AddComponent(); rectTransform.SetParent(worldCanvas.transform, false); rectTransform.position = position; rectTransform.localScale = Vector3.one; rectTransform.sizeDelta = Vector2.zero; // 父对象不需要大小 ApplyParticleRenderLayer(obj); return obj; } private void AddOneCandidateParticle(int index) { Vector3 pos = GetRandomPositionInBounds(); GameObject obj = CreateParticleObject(pos, $"CandidateParticle_{index}"); CandidateParticle particle = obj.AddComponent(); particle.SetFont(chineseFontAsset, chineseFontMaterial); particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); particle.SetNonTargetMarquee( nonTargetMarqueeColors, index, nonTargetMarqueeStepDuration); particle.SetFontStyles(targetParticleFontSize, nonTargetParticleFontSize, targetParticleBold, nonTargetParticleBold); particle.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude); SetParticleBounds(particle); ConfigureParticleScreenMask(particle); // AddComponent / SetFont 可能生成 Text、Glow、TMP SubMesh,再刷一遍 layer ApplyParticleRenderLayer(obj); candidateParticles.Add(particle); } /// /// 增删候选粒子以匹配目标数量(用于 Yarn 动态指定非目标粒子总数后复用同一视图) /// 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); } private void InitializeRenderers() { // 连接线渲染器 GameObject connObj = new GameObject("ConnectionRenderer"); connObj.transform.SetParent(transform); connObj.transform.localPosition = Vector3.zero; ApplyLayerRecursively(connObj, renderLayer); connectionRenderer = connObj.AddComponent(); 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; ApplyLayerRecursively(noiseObj, renderLayer); noiseOverlayRenderer = noiseObj.AddComponent(); // 设置效果区域为 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 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); } } // 如果不够,强制添加 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]); } } } private void Update() { // 如果系统未初始化,不执行任何逻辑 if (!isInitialized) return; FlushPendingCompletionDialog(); // 初始化检查 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(); } /// /// 完成阶段使用的裁剪范围:优先用 Inspector 指定的屏幕 Rect,否则与粒子运动边界一致。 /// 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; 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); } } float zoomedGlyph = glyphExtent * Mathf.Max(1f, focusZoomScale); float shakeExtent = Mathf.Max(shuffleDistance, nonTargetShakeIntensity, completionPositionSmoothTime * 0.1f); float horizontal = Mathf.Max(padding, zoomedGlyph, 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 currentConnections = new Dictionary(); 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 visited = new HashSet(); Queue queue = new Queue(); 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(); 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 GetOrderedRedParticles() { if (targetParticles.Count == 0) return new List(); if (targetParticles.Count == 1) return new List(targetParticles); // 从最左边的粒子开始 var ordered = new List(); var startParticle = targetParticles.OrderBy(p => p.transform.position.x).First(); HashSet visited = new HashSet(); Stack stack = new Stack(); 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(); DOTween.Kill(FocusInterferenceTweenId); 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.SetColors( targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); particle.calmProgress = 0f; particle.resolvedColorProgress = 0f; particle.ClearFocusInterference(); particle.SetFocusInterference( focusInterferenceColor, focusInterferenceShakeAmplitude, focusInterferenceShakeSpeed); 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); DOTween.To( () => particle.resolvedColorProgress, value => particle.resolvedColorProgress = value, 1f, Mathf.Max(0.05f, focusMoveDuration)) .SetEase(Ease.InOutSine) .SetTarget(particle) .OnComplete(() => particle.resolvedColorProgress = 1f); 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) { return new TargetFocusVisual { particle = particle, finalChar = finalChar, startShakeIntensity = Mathf.Max(particle.anxietyShakeIntensity, 4f) }; } private void EnterFocusHolding() { completionPhase = CompletionPhase.FocusHolding; focusTimer = 0f; TryStartCompletionDialog(); } /// /// 进入 FocusHolding 后启动梳理完成节点。 /// 若当前正处在 Yarn 命令中(如 expression_skip_to_focus),对话仍在跑,则延后到对话空闲再启动。 /// private void TryStartCompletionDialog() { if (string.IsNullOrEmpty(completionDialogNode)) return; var dialog = DialogController.Instance; if (dialog == null) return; bool dialogueBusy = dialog.DialogueRunner != null && dialog.DialogueRunner.IsDialogueRunning; if (dialogueBusy) { pendingCompletionDialog = true; return; } pendingCompletionDialog = false; dialog.StartDialogNode(completionDialogNode); } private void FlushPendingCompletionDialog() { if (!pendingCompletionDialog) return; var dialog = DialogController.Instance; if (dialog == null) { pendingCompletionDialog = false; return; } if (dialog.DialogueRunner != null && dialog.DialogueRunner.IsDialogueRunning) return; pendingCompletionDialog = false; if (!string.IsNullOrEmpty(completionDialogNode)) dialog.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); } } /// /// 真话聚合尝试(非等待):FocusHolding 下把目标字拉向最终位置并按序逐字锁定。 /// target 小于 1 时最后几个字永远差一点锁不住,用于"感觉快要成功了"。 /// 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); } /// /// 真话聚合挣扎(非等待):向 target 推进的途中反复"够到一点又被拽回去"。 /// 回抽幅度必须跨过 UpdateTruthGatherLock 的 0.08 解锁带,已锁的字才会崩开重新乱跳—— /// 挣扎感来自进度曲线本身的往复,不依赖任何颜色或额外特效。 /// /// 最终停留的聚合进度 /// 整段时长 /// 回抽次数;<=0 时退化为普通聚合 public void BeginTruthStrain(float target, float duration, int tugs) { if (completionPhase != CompletionPhase.FocusHolding) { Debug.LogWarning( $"[LanguageParticleManager] 真话挣扎在 {completionPhase} 状态被调用;已安全跳过。"); return; } int tugCount = Mathf.Clamp(tugs, 0, 4); if (tugCount <= 0) { BeginTruthGather(target, duration); return; } float clampedTarget = Mathf.Clamp01(target); float start = truthGatherProgress; float total = Mathf.Max(0.2f, duration); // 末尾留一段单纯的推进,让整句最后停在 target 而不是停在回抽底部 float settleDuration = total * 0.18f; float cycleDuration = (total - settleDuration) / tugCount; float pushDuration = Mathf.Max(0.05f, cycleDuration * 0.72f); float recoilDuration = Mathf.Max(0.04f, cycleDuration - pushDuration); DOTween.Kill(TruthGatherTweenId); Sequence sequence = DOTween.Sequence().SetId(TruthGatherTweenId).SetTarget(this); for (int i = 1; i <= tugCount; i++) { // 每次比上一次够得更远,最后一次刚好摸到 target 再被拽回来 float peak = Mathf.Lerp(start, clampedTarget, i / (float)tugCount); // 回抽越往后越狠;下限 0.1 保证越过 0.08 解锁带 float recoil = 0.10f + 0.03f * (i - 1); float valley = Mathf.Max(0f, peak - recoil); sequence.Append( DOTween.To( () => truthGatherProgress, value => truthGatherProgress = value, peak, pushDuration) .SetEase(Ease.OutCubic)); sequence.Append( DOTween.To( () => truthGatherProgress, value => truthGatherProgress = value, valley, recoilDuration) .SetEase(Ease.OutExpo)); } sequence.Append( DOTween.To( () => truthGatherProgress, value => truthGatherProgress = value, clampedTarget, Mathf.Max(0.05f, settleDuration)) .SetEase(Ease.OutCubic)); } /// /// 真话聚合崩塌(非等待):进度被一口气拽回 0,所有已锁的字沿原路退回悬浮位并恢复乱跳。 /// Ease.InBack 会先向上顶一点点再垮掉——"又够了一下下,然后整个塌了"。 /// public void CollapseTruthGather(float duration) { if (completionPhase != CompletionPhase.FocusHolding) { Debug.LogWarning( $"[LanguageParticleManager] 真话崩塌在 {completionPhase} 状态被调用;已安全跳过。"); return; } DOTween.Kill(TruthGatherTweenId); DOTween.To( () => truthGatherProgress, value => truthGatherProgress = value, 0f, Mathf.Max(0.08f, duration)) .SetEase(Ease.InBack) .SetId(TruthGatherTweenId) .SetTarget(this); } /// 真话聚合被打断:目标字向外爆散并恢复乱跳(非等待)。 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; visual.particle.resolvedColorProgress = 1f; 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(); } } /// /// 从 FocusHolding 进入稳定化阶段(文字锁定、排列到最终位置) /// 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; } /// /// 聚焦干扰传播。in:从当前最不稳定的字传播到最稳定的字; /// out:从当前最稳定的字反向传播并恢复固定目标色。 /// duration 表示整次传播从第一颗开始到最后一颗完成的总时长。 /// characterPool 可传入空格或竖线分隔的词组变体。 /// public IEnumerator PlayFocusInterferenceAndWait( string direction, float duration, string characterPool = "", string colorHex = "", float shakeAmplitude = -1f, float shakeSpeed = -1f, float changeInterval = -1f, float transitionRatio = -1f) { bool isIn = string.Equals(direction, "in", System.StringComparison.OrdinalIgnoreCase); bool isOut = string.Equals(direction, "out", System.StringComparison.OrdinalIgnoreCase); bool isAdjust = string.Equals(direction, "adjust", System.StringComparison.OrdinalIgnoreCase); if (!isIn && !isOut && !isAdjust) { Debug.LogWarning( $"[LanguageParticleManager] 聚焦干扰模式“{direction}”无效;请使用 in、out 或 adjust。"); yield break; } if (completionPhase != CompletionPhase.FocusHolding && completionPhase != CompletionPhase.Focusing && completionPhase != CompletionPhase.Finished) { Debug.LogWarning( $"[LanguageParticleManager] 聚焦干扰在 {completionPhase} 状态被调用;已安全跳过。"); yield break; } List particles = focusVisuals.Values .Where(visual => visual?.particle != null) .Select(visual => visual.particle) .Distinct() .ToList(); if (particles.Count == 0) { particles = targetParticles .Where(particle => particle != null) .Distinct() .ToList(); } if (particles.Count == 0) yield break; if (isAdjust) { yield return AdjustFocusInterferenceAndWait( particles, duration, characterPool, colorHex, shakeAmplitude, shakeSpeed, changeInterval); yield break; } bool useRainbowMarquee = string.Equals( colorHex, "rainbow", System.StringComparison.OrdinalIgnoreCase); Color resolvedColor = focusInterferenceColor; if (!string.IsNullOrWhiteSpace(colorHex) && !useRainbowMarquee) { string htmlColor = colorHex[0] == '#' ? colorHex : $"#{colorHex}"; if (!ColorUtility.TryParseHtmlString(htmlColor, out resolvedColor)) { Debug.LogWarning( $"[LanguageParticleManager] 干扰颜色“{colorHex}”无效;改用 Inspector 默认颜色。"); resolvedColor = focusInterferenceColor; } } float resolvedShakeAmplitude = shakeAmplitude >= 0f ? shakeAmplitude : focusInterferenceShakeAmplitude; float resolvedShakeSpeed = shakeSpeed >= 0f ? shakeSpeed : focusInterferenceShakeSpeed; float resolvedChangeInterval = changeInterval > 0f ? changeInterval : focusInterferenceChangeInterval; float resolvedTransitionRatio = transitionRatio > 0f ? transitionRatio : focusInterferenceTransitionRatio; resolvedTransitionRatio = Mathf.Clamp(resolvedTransitionRatio, 0.05f, 0.95f); System.Func orderIndex = particle => focusVisuals.TryGetValue(particle, out TargetFocusVisual visual) && visual != null ? visual.orderIndex : targetParticles.IndexOf(particle); particles = isIn ? particles.OrderBy(GetFocusStabilityScore) .ThenBy(orderIndex) .ToList() : particles.OrderByDescending(GetFocusStabilityScore) .ThenByDescending(orderIndex) .ToList(); DOTween.Kill(FocusInterferenceTweenId); float totalDuration = Mathf.Max(0f, duration); float perParticleDuration = totalDuration <= 0f ? 0f : Mathf.Max(0.05f, totalDuration * resolvedTransitionRatio); perParticleDuration = Mathf.Min(perParticleDuration, totalDuration); float stagger = particles.Count > 1 ? Mathf.Max(0f, totalDuration - perParticleDuration) / (particles.Count - 1) : 0f; float targetProgress = isIn ? 1f : 0f; if (totalDuration <= 0f) { foreach (CandidateParticle particle in particles) { if (isIn || !string.IsNullOrWhiteSpace(colorHex)) { ConfigureFocusInterferenceColor( particle, orderIndex(particle), useRainbowMarquee, resolvedColor, resolvedShakeAmplitude, resolvedShakeSpeed); } particle.focusInterferenceProgress = targetProgress; string particlePool = BuildFocusInterferencePool(characterPool, particle); if (isIn && !string.IsNullOrEmpty(particlePool)) { particle.SetOverrideCharPool(particlePool); particle.SetChangeInterval(resolvedChangeInterval); particle.ResetChangeTimer(0f); } if (isOut) { particle.ClearFocusInterference(); RestoreFocusParticleCharacter(particle); } } yield break; } Sequence sequence = DOTween.Sequence() .SetId(FocusInterferenceTweenId) .SetTarget(this); for (int i = 0; i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (isIn || !string.IsNullOrWhiteSpace(colorHex)) { ConfigureFocusInterferenceColor( particle, orderIndex(particle), useRainbowMarquee, resolvedColor, resolvedShakeAmplitude, resolvedShakeSpeed); } float startTime = i * stagger; string particlePool = BuildFocusInterferencePool(characterPool, particle); if (isIn && !string.IsNullOrEmpty(particlePool)) { sequence.InsertCallback(startTime, () => { particle.SetOverrideCharPool(particlePool); particle.SetChangeInterval(resolvedChangeInterval); particle.ResetChangeTimer(0f); }); } Tween tween = DOTween.To( () => particle.focusInterferenceProgress, value => particle.focusInterferenceProgress = value, targetProgress, perParticleDuration) .SetEase(isIn ? Ease.InOutSine : Ease.OutSine); if (isOut) { tween.OnComplete(() => RestoreFocusParticleCharacter(particle)); } sequence.Insert(startTime, tween); } yield return sequence.WaitForCompletion(); if (isOut) { foreach (CandidateParticle particle in particles) particle.ClearFocusInterference(); } } private void ConfigureFocusInterferenceColor( CandidateParticle particle, int particleIndex, bool useRainbowMarquee, Color color, float shakeAmplitude, float shakeSpeed) { if (particle == null) return; if (useRainbowMarquee) { particle.SetFocusInterferenceMarquee( nonTargetMarqueeColors, Mathf.Max(0, particleIndex), nonTargetMarqueeStepDuration, shakeAmplitude, shakeSpeed); return; } particle.SetFocusInterference(color, shakeAmplitude, shakeSpeed); } private IEnumerator AdjustFocusInterferenceAndWait( List particles, float duration, string characterPool, string colorHex, float shakeAmplitude, float shakeSpeed, float changeInterval) { List activeParticles = particles .Where(particle => particle != null && particle.focusInterferenceProgress > 0.001f) .ToList(); if (activeParticles.Count == 0) { Debug.LogWarning( "[LanguageParticleManager] adjust 未找到已经进入干扰状态的目标文字;已安全跳过。"); yield break; } bool adjustColor = !string.IsNullOrWhiteSpace(colorHex); Color targetColor = focusInterferenceColor; if (adjustColor) { string htmlColor = colorHex[0] == '#' ? colorHex : $"#{colorHex}"; if (!ColorUtility.TryParseHtmlString(htmlColor, out targetColor)) { Debug.LogWarning( $"[LanguageParticleManager] 干扰颜色“{colorHex}”无效;本次不调整颜色。"); adjustColor = false; } } bool adjustAmplitude = shakeAmplitude >= 0f; bool adjustSpeed = shakeSpeed >= 0f; bool adjustInterval = changeInterval > 0f; bool adjustCharacters = !string.IsNullOrWhiteSpace(characterPool); float adjustDuration = Mathf.Max(0f, duration); DOTween.Kill(FocusInterferenceAdjustmentTweenId); if (adjustDuration <= 0f) { foreach (CandidateParticle particle in activeParticles) { if (adjustColor) particle.focusInterferenceColor = targetColor; if (adjustAmplitude) particle.focusInterferenceShakeAmplitude = Mathf.Max(0f, shakeAmplitude); if (adjustSpeed) particle.focusInterferenceShakeSpeed = Mathf.Max(0f, shakeSpeed); if (adjustInterval) particle.SetChangeInterval(changeInterval); if (adjustCharacters) { string pool = BuildFocusInterferencePool(characterPool, particle); particle.SetOverrideCharPool(pool); particle.ResetChangeTimer(0f); } } yield break; } Sequence sequence = DOTween.Sequence() .SetId(FocusInterferenceAdjustmentTweenId) .SetTarget(this); foreach (CandidateParticle particle in activeParticles) { if (adjustColor) { sequence.Join(DOTween.To( () => particle.focusInterferenceColor, value => particle.focusInterferenceColor = value, targetColor, adjustDuration) .SetEase(Ease.InOutSine)); } if (adjustAmplitude) { sequence.Join(DOTween.To( () => particle.focusInterferenceShakeAmplitude, value => particle.focusInterferenceShakeAmplitude = value, Mathf.Max(0f, shakeAmplitude), adjustDuration) .SetEase(Ease.InOutSine)); } if (adjustSpeed) { sequence.Join(DOTween.To( () => particle.focusInterferenceShakeSpeed, value => particle.focusInterferenceShakeSpeed = value, Mathf.Max(0f, shakeSpeed), adjustDuration) .SetEase(Ease.InOutSine)); } if (adjustInterval) { sequence.Join(DOTween.To( () => particle.ChangeInterval, particle.SetChangeInterval, changeInterval, adjustDuration) .SetEase(Ease.InOutSine)); } if (adjustCharacters) { string pool = BuildFocusInterferencePool(characterPool, particle); particle.SetOverrideCharPool(pool); particle.ResetChangeTimer(0f); } } if (sequence.Duration(false) <= 0f) { sequence.Kill(); yield break; } yield return sequence.WaitForCompletion(); } private string BuildFocusInterferencePool( string characterPool, CandidateParticle particle) { if (string.IsNullOrWhiteSpace(characterPool) || particle == null) return null; string[] variants = characterPool.Split( new[] { '|', ' ', '\t', '\r', '\n', '\u3000' }, System.StringSplitOptions.RemoveEmptyEntries); if (variants.Length <= 1) return SanitizeSlotMachinePool(characterPool); int characterIndex = focusVisuals.TryGetValue( particle, out TargetFocusVisual visual) && visual != null ? visual.orderIndex : orderedRedParticles.IndexOf(particle); if (characterIndex < 0) return SanitizeSlotMachinePool(characterPool); var characters = new System.Text.StringBuilder(variants.Length); foreach (string variant in variants) { if (characterIndex < variant.Length) characters.Append(variant[characterIndex]); } return characters.Length > 0 ? characters.ToString() : SanitizeSlotMachinePool(characterPool); } private void RestoreFocusParticleCharacter(CandidateParticle particle) { if (particle == null) return; particle.SetOverrideCharPool(null); particle.ClearFocusInterference(); if (focusVisuals.TryGetValue(particle, out TargetFocusVisual visual) && visual != null && !string.IsNullOrEmpty(visual.finalChar)) { particle.ForceSetCharacter(visual.finalChar); return; } int index = orderedRedParticles.IndexOf(particle); if (index >= 0 && index < finalTargetCharacters.Count) particle.ForceSetCharacter(finalTargetCharacters[index]); } private float GetFocusStabilityScore(CandidateParticle particle) { if (particle == null) return 0f; if (completionPhase == CompletionPhase.FocusHolding && focusVisuals.TryGetValue(particle, out TargetFocusVisual visual) && visual != null) { int count = Mathf.Max(1, focusVisuals.Count); float threshold = 0.45f + 0.5f * (visual.orderIndex + 1f) / count; float approach = threshold > 0f ? Mathf.Clamp01(truthGatherProgress / threshold) : 0f; return visual.gatherLocked ? 1f + approach * 0.01f : approach; } return Mathf.Clamp01(particle.calmProgress); } 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)); } /// /// 等待聚焦流程全部完成(稳定化 → Finished) /// 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); } /// /// 老虎机换字演出:聚焦字在 duration 内只从 charPool 里疯狂换字,到点后自动恢复默认字符池并继续抖动。 /// 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))); } /// /// 清洗老虎机字符池:去掉空格与 | 分隔符,空结果返回 null。 /// 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); } } } /// /// 将已完成聚焦的目标粒子改排为红色攻击词。文字必须完整放进现有目标粒子, /// 超长时拒绝播放,避免演出层静默删字。 /// /// /// Holds the current truth phrase in place, then continuously increases both its /// shake and its blend from the stable truth color to the requested warning color. /// The final unstable state is retained for the following attack. /// public IEnumerator DestabilizeTruthPhraseAndWait( string text, float duration, float shakeAmplitude, float shakeSpeed, string colorHex) { List particles = GetPresentationPhraseParticles(); if (!ValidatePresentationPhrase(text, particles, "真话失稳")) yield break; Color unstableColor = truthAttackColor; if (!string.IsNullOrWhiteSpace(colorHex)) { string htmlColor = colorHex[0] == '#' ? colorHex : $"#{colorHex}"; if (!ColorUtility.TryParseHtmlString(htmlColor, out unstableColor)) { Debug.LogWarning( $"[LanguageParticleManager] 真话失稳颜色“{colorHex}”无效;改用攻击色。"); unstableColor = truthAttackColor; } } PreparePresentationPhraseControl(); float totalDuration = Mathf.Max(0.12f, duration); float resolvedShakeAmplitude = Mathf.Max(0f, shakeAmplitude); float resolvedShakeSpeed = Mathf.Max(0f, shakeSpeed); List destinations = CalculatePresentationPhrasePositions(text.Length); UpdateFinalPresentationPhrase(text, destinations); for (int i = 0; i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) continue; particle.transform.DOKill(); DOTween.Kill(particle); particle.velocity = Vector2.zero; particle.isStatic = true; particle.anxietyShakeIntensity = 0f; particle.ClearFocusInterference(); particle.SetColors( targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); particle.SetFontStyles( targetParticleFontSize, nonTargetParticleFontSize, false, nonTargetParticleBold); particle.resolvedColorProgress = 1f; particle.calmProgress = 1f; if (i >= text.Length) { particle.alpha = 0f; continue; } particle.ForceSetCharacter(text[i].ToString()); particle.alpha = 1f; particle.SetFocusInterference( unstableColor, resolvedShakeAmplitude, resolvedShakeSpeed); particle.focusInterferenceProgress = 0f; DOTween.To( () => particle.focusInterferenceProgress, value => particle.focusInterferenceProgress = value, 1f, totalDuration) .SetEase(Ease.InQuad) .SetTarget(particle); } yield return new WaitForSeconds(totalDuration); for (int i = 0; i < text.Length && i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) continue; particle.focusInterferenceProgress = 1f; particle.alpha = 1f; } } public IEnumerator PlayTruthAttackAndWait( string text, float duration, float impact) { List particles = GetPresentationPhraseParticles(); if (!ValidatePresentationPhrase(text, particles, "真话攻击")) yield break; PreparePresentationPhraseControl(); float totalDuration = Mathf.Max(0.08f, duration); float resolvedImpact = Mathf.Clamp01(impact); float travelDuration = totalDuration * 0.68f; float settleDuration = Mathf.Max(0.04f, totalDuration - travelDuration); int directionSign = truthAttackSequenceIndex++ % 2 == 0 ? -1 : 1; List destinations = CalculatePresentationPhrasePositions(text.Length); UpdateFinalPresentationPhrase(text, destinations); for (int i = 0; i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) continue; particle.transform.DOKill(); DOTween.Kill(particle); particle.velocity = Vector2.zero; particle.isStatic = true; particle.anxietyShakeIntensity = 0f; particle.ClearFocusInterference(); particle.SetFocusInterference(truthAttackColor, 0f, 0f); particle.SetColors( targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); particle.SetFontStyles( targetParticleFontSize, nonTargetParticleFontSize, false, nonTargetParticleBold); particle.resolvedColorProgress = 1f; particle.calmProgress = 1f; if (i >= text.Length) { DOTween.To( () => particle.alpha, value => particle.alpha = value, 0f, Mathf.Min(0.12f, totalDuration)) .SetEase(Ease.OutQuad) .SetTarget(particle); continue; } particle.ForceSetCharacter(text[i].ToString()); particle.alpha = 1f; particle.focusInterferenceProgress = 1f; Vector3 destination = destinations[i]; float travel = truthAttackTravelDistance * Mathf.Lerp(0.55f, 1f, resolvedImpact); float verticalKick = ((i % 3) - 1) * 0.06f * resolvedImpact; particle.transform.position = destination + new Vector3(directionSign * travel, verticalKick, 0f); particle.transform.localScale = new Vector3( focusZoomScale * Mathf.Lerp(1.12f, 1.42f, resolvedImpact), focusZoomScale * Mathf.Lerp(0.86f, 0.66f, resolvedImpact), focusZoomScale); float delay = Mathf.Min(totalDuration * 0.12f, i * 0.018f); float localTravelDuration = Mathf.Max(0.04f, travelDuration - delay); Sequence sequence = DOTween.Sequence().SetTarget(particle); if (delay > 0f) sequence.AppendInterval(delay); sequence.Append( particle.transform.DOMove(destination, localTravelDuration) .SetEase(Ease.OutExpo)); sequence.Join( particle.transform.DOScale( Vector3.one * focusZoomScale, localTravelDuration) .SetEase(Ease.OutCubic)); sequence.Append( particle.transform.DOPunchScale( Vector3.one * (0.08f + 0.12f * resolvedImpact), settleDuration, 1, 0.18f)); } yield return new WaitForSeconds(totalDuration); for (int i = 0; i < text.Length && i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) continue; particle.transform.position = destinations[i]; particle.transform.localScale = Vector3.one * focusZoomScale; particle.alpha = 1f; } } /// /// 将攻击词先压散,再重组为固定青绿色真话。 /// public IEnumerator SettleTruthPhraseAndWait(string text, float duration) { List particles = GetPresentationPhraseParticles(); if (!ValidatePresentationPhrase(text, particles, "真话稳定")) yield break; PreparePresentationPhraseControl(); float totalDuration = Mathf.Max(0.12f, duration); float breakDuration = totalDuration * 0.32f; float reformDuration = Mathf.Max(0.06f, totalDuration - breakDuration); for (int i = 0; i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) continue; particle.transform.DOKill(); DOTween.Kill(particle); Vector3 breakOffset = new Vector3( (i % 2 == 0 ? -1f : 1f) * (0.16f + i * 0.025f), ((i % 3) - 1) * 0.08f, 0f); particle.transform.DOMove( particle.transform.position + breakOffset, breakDuration) .SetEase(Ease.InCubic) .SetTarget(particle); particle.transform.DOScale( Vector3.one * focusZoomScale * 0.72f, breakDuration) .SetEase(Ease.InCubic) .SetTarget(particle); DOTween.To( () => particle.alpha, value => particle.alpha = value, 0.15f, breakDuration) .SetEase(Ease.InQuad) .SetTarget(particle); } yield return new WaitForSeconds(breakDuration); List destinations = CalculatePresentationPhrasePositions(text.Length); UpdateFinalPresentationPhrase(text, destinations); for (int i = 0; i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) continue; particle.transform.DOKill(); DOTween.Kill(particle); particle.velocity = Vector2.zero; particle.isStatic = true; particle.anxietyShakeIntensity = 0f; particle.SetColors( targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); particle.SetFontStyles( targetParticleFontSize, nonTargetParticleFontSize, false, nonTargetParticleBold); particle.resolvedColorProgress = 1f; particle.calmProgress = 1f; if (i >= text.Length) { particle.alpha = 0f; particle.ClearFocusInterference(); continue; } particle.ForceSetCharacter(text[i].ToString()); particle.SetFocusInterference(truthAttackColor, 0f, 0f); particle.focusInterferenceProgress = 1f; particle.alpha = 0.15f; particle.transform.DOMove(destinations[i], reformDuration) .SetEase(Ease.OutCubic) .SetTarget(particle); particle.transform.DOScale( Vector3.one * focusZoomScale, reformDuration) .SetEase(Ease.OutBack) .SetTarget(particle); DOTween.To( () => particle.alpha, value => particle.alpha = value, 1f, reformDuration) .SetEase(Ease.OutQuad) .SetTarget(particle); DOTween.To( () => particle.focusInterferenceProgress, value => particle.focusInterferenceProgress = value, 0f, reformDuration) .SetEase(Ease.OutSine) .SetTarget(particle); } yield return new WaitForSeconds(reformDuration); for (int i = 0; i < particles.Count; i++) { CandidateParticle particle = particles[i]; if (particle == null) continue; particle.ClearFocusInterference(); particle.alpha = i < text.Length ? 1f : 0f; if (i < text.Length) { particle.transform.position = destinations[i]; particle.transform.localScale = Vector3.one * focusZoomScale; } } } private List GetPresentationPhraseParticles() { List particles = orderedRedParticles .Where(particle => particle != null) .Distinct() .ToList(); if (particles.Count > 0) return particles; return targetParticles .Where(particle => particle != null) .OrderBy(particle => particle.transform.position.x) .ToList(); } private static bool ValidatePresentationPhrase( string text, List particles, string context) { if (string.IsNullOrEmpty(text)) { Debug.LogWarning($"[LanguageParticleManager] {context}文字为空;已安全跳过。"); return false; } if (particles == null || particles.Count == 0) { Debug.LogWarning($"[LanguageParticleManager] {context}没有可用目标粒子;已安全跳过。"); return false; } if (text.Length > particles.Count) { Debug.LogWarning( $"[LanguageParticleManager] {context}“{text}”需要 {text.Length} 个目标粒子," + $"当前只有 {particles.Count} 个;为避免截断已跳过。"); return false; } return true; } private void PreparePresentationPhraseControl() { DOTween.Kill(LieCompressionTweenId); DOTween.Kill(TruthGatherTweenId); DOTween.Kill(FocusInterferenceTweenId); DOTween.Kill(FocusInterferenceAdjustmentTweenId); StopSlotMachineShuffle(); truthGatherProgress = 0f; lieEdgeCompression = 0f; lieEdgeJitter = 0f; interferenceLogicSuspended = true; completionPhase = CompletionPhase.Finished; DestroyFocusVisualDecorationsAndClear(); if (connectionRenderer != null) connectionRenderer.TargetAlphaScale = 0f; } private List CalculatePresentationPhrasePositions(int characterCount) { var positions = new List(Mathf.Max(0, characterCount)); Vector3 center = worldCanvas != null ? worldCanvas.transform.position : transform.position; float spacing = Mathf.Max(0.1f, truthAttackCharacterSpacing); float startX = -spacing * Mathf.Max(0, characterCount - 1) * 0.5f; Bounds clip = GetCompletionClipBounds(); for (int i = 0; i < characterCount; i++) { Vector3 position = center + new Vector3(startX + i * spacing, 0f, 0f); positions.Add(ClampPositionToBounds(position, clip)); } return positions; } private void UpdateFinalPresentationPhrase(string text, List positions) { finalTargetCharacters.Clear(); finalTargetPositions.Clear(); for (int i = 0; i < text.Length; i++) finalTargetCharacters.Add(text[i].ToString()); finalTargetPositions.AddRange(positions); } public void SetTruthWarmVisuals(Color warmColor) { ApplyPresentationResolvedVisuals(warmColor); } /// /// 梳理完成/真话阶段:目标字保持固定的聚拢目标色,并取消加粗以便大屏辨认。 /// resolvedColor 保留用于兼容既有演出调用,不再允许演出覆盖粒子目标色。 /// public void ApplyPresentationResolvedVisuals(Color resolvedColor) { foreach (CandidateParticle particle in targetParticles) { if (particle == null) continue; particle.SetColors( targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); particle.resolvedColorProgress = 1f; 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(); 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.resolvedColorProgress = 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); DOTween.Kill(FocusInterferenceTweenId); truthGatherProgress = 0f; StopSlotMachineShuffle(); foreach (CandidateParticle particle in targetParticles) { if (particle == null) continue; DOTween.Kill(particle); particle.alpha = Mathf.Clamp01(alphaValue); particle.calmProgress = 0f; particle.resolvedColorProgress = 0f; particle.ClearFocusInterference(); particle.SetFocusInterference( focusInterferenceColor, focusInterferenceShakeAmplitude, focusInterferenceShakeSpeed); 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() { focusVisuals.Clear(); } private void CompleteFocusSequence() { var particlesToScale = new List(); 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; p.resolvedColorProgress = 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); } /// /// 淡出 UI 元素(使用 CanvasGroup),结束后 SetActive(false) /// private void FadeOutUIElement(GameObject obj, float duration) { if (obj == null || !obj.activeSelf) return; var cg = obj.GetComponent(); if (cg == null) cg = obj.AddComponent(); cg.DOKill(); cg.alpha = 1f; cg.DOFade(0f, duration) .SetEase(Ease.OutQuad) .OnComplete(() => obj.SetActive(false)); } /// /// 淡出 SpriteRenderer,结束后禁用并隐藏 /// 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); }); } /// /// 立即隐藏火山释放 log(无动画),用于新一轮 start 前先淡出文字、再单独淡入 log。 /// 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; } /// /// 若火山释放 log 界面已淡出,则淡入显示(start_expression 时调用,确保流程开始前界面可见) /// /// 淡入时长(秒) 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(); } /// /// 恢复 SpriteRenderer 显示(用于重新开始时恢复火山释放 log 界面) /// 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(); 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(); if (group != null) group.alpha = 1f; } private Vector3 ComputeCentroid(List 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; pendingCompletionDialog = false; 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.resolvedColorProgress = 0f; p.ClearFocusInterference(); p.SetFocusInterference( focusInterferenceColor, focusInterferenceShakeAmplitude, focusInterferenceShakeSpeed); p.alpha = p.baseAlpha; p.transform.localScale = Vector3.one; DOTween.Kill(p); p.transform.DOKill(); p.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude); } 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 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; } 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)); 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)); 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()); } /// /// 独立协程:播放火山表达panel,播完后将表达流程标记为就绪 /// private IEnumerator PlayExpressionPanelThenReady() { if (!string.IsNullOrEmpty(expressionPanelTimelineName) && TimelineCenter.Instance != null) { // 火山表达 panel timeline 驱动 panel1/2,播放前需先激活(若之前被 fade 过,需重置 alpha) if (languageDeepPanel1 != null) { var cg1 = languageDeepPanel1.GetComponent(); if (cg1 != null) cg1.alpha = 1f; languageDeepPanel1.SetActive(true); } if (languageDeepPanel2 != null) { var cg2 = languageDeepPanel2.GetComponent(); 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; for (int i = 0; i < candidateParticles.Count - 1; i++) { var particleA = candidateParticles[i]; if (particleA == null) continue; for (int j = i + 1; j < candidateParticles.Count; j++) { var particleB = candidateParticles[j]; if (particleB == null || (particleA.isStatic && particleB.isStatic)) continue; Vector2 posA = particleA.transform.position; Vector2 posB = particleB.transform.position; Vector2 diff = posA - posB; float distSqr = diff.sqrMagnitude; float requiredSpacing = CalculateRequiredParticleSpacing(particleA, particleB); if (distSqr >= requiredSpacing * requiredSpacing) continue; float distance; Vector2 direction; if (distSqr > 0.000001f) { distance = Mathf.Sqrt(distSqr); direction = diff / distance; } else { // 完全重合时 normalized 会得到零向量;使用稳定的配对方向打破死锁。 distance = 0f; float angle = (i * 37f + j * 61f) * 2.3999632f; direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); } float overlap = requiredSpacing - distance; float normalizedOverlap = Mathf.Clamp01(overlap / Mathf.Max(requiredSpacing, 0.0001f)); bool differentTypes = particleA.originalIsRed != particleB.originalIsRed; float forceMultiplier = differentTypes ? Mathf.Max(1f, differentTypeForceMultiplier) : 1f; Vector2 push = direction * (normalizedOverlap * separationForce * forceMultiplier * Time.deltaTime); bool moveA = !particleA.isStatic; bool moveB = !particleB.isStatic; if (moveA) particleA.ApplyForce(push * (moveB ? 0.5f : 1f)); if (moveB) particleB.ApplyForce(-push * (moveA ? 0.5f : 1f)); float correction = Mathf.Min( overlap * separationPositionCorrection, maxSeparationCorrectionPerFrame); if (correction <= 0f) continue; if (moveA) { float share = moveB ? 0.5f : 1f; particleA.transform.position += (Vector3)(direction * correction * share); } if (moveB) { float share = moveA ? 0.5f : 1f; particleB.transform.position -= (Vector3)(direction * correction * share); } } } } private float CalculateRequiredParticleSpacing( CandidateParticle particleA, CandidateParticle particleB) { float required = Mathf.Max(0.01f, minParticleSpacing); if (!useGlyphBoundsForSeparation) return required; float radiusA = GetParticleGlyphRadius(particleA, required * 0.5f); float radiusB = GetParticleGlyphRadius(particleB, required * 0.5f); float glyphSpacing = radiusA + radiusB + Mathf.Max(0f, separationPadding); if (particleA.originalIsRed != particleB.originalIsRed) { glyphSpacing *= Mathf.Max(1f, differentTypeSpacingMultiplier); } return Mathf.Max(required, glyphSpacing); } private static float GetParticleGlyphRadius( CandidateParticle particle, float fallback) { if (particle?.textMesh == null || particle.textMesh.renderer == null) return fallback; Bounds bounds = particle.textMesh.renderer.bounds; float radius = Mathf.Max(bounds.extents.x, bounds.extents.y); return radius > 0.0001f ? radius : fallback; } 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 visited = new HashSet(); int largestCluster = 0; foreach (var red in targetParticles) { if (visited.Contains(red)) continue; int clusterSize = 0; Queue queue = new Queue(); 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 interferingParticles = new HashSet(); 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 } }