using UnityEngine; using System.Collections; using System.Collections.Generic; using System.Linq; using DG.Tweening; using Shapes; using TMPro; using UnityEngine.UI; using AibisDream; namespace AibisDream.MiniGame.Language { /// /// 粒子排布区域形状 /// 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; [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(1f, 0.39f, 0.39f, 1f); // 目标粒子颜色 [SerializeField] private Color nonTargetParticleColor = new Color(0.2f, 0.78f, 1f, 1f); // 非目标粒子颜色(如笑话短句) [SerializeField] private Color floatingTextColor = new Color(0.7f, 0.78f, 1f, 1f); // 背景浮动文字颜色 [SerializeField] private Color targetCalmColor = new Color(0.78f, 0.59f, 0.59f, 1f); // 目标粒子平静后颜色 [Header("连接线颜色(用于连线的颜色,与上面可分开调节)")] [SerializeField] private Color targetConnectionColor = new Color(1f, 0.39f, 0.39f, 1f); // 目标连线颜色 [SerializeField] private Color nonTargetConnectionColor = new Color(0.39f, 0.78f, 1f, 0.7f);// 非目标连线颜色 [Header("文字样式配置")] [SerializeField] private float targetParticleFontSize = 4.5f; // 目标粒子字体大小 [SerializeField] private float nonTargetParticleFontSize = 3.7f; // 非目标粒子字体大小 [SerializeField] private float floatingTextFontSize = 3f; // 背景浮动文字大小 [SerializeField] private bool targetParticleBold = true; // 目标粒子是否加粗 [SerializeField] private bool nonTargetParticleBold = false; // 非目标粒子是否加粗 [Header("非目标粒子干扰效果")] [SerializeField, Range(0f, 0.1f)] private float nonTargetShakeIntensity = 0.02f; // 非目标粒子抖动强度 [SerializeField, Range(1f, 20f)] private float nonTargetShakeSpeed = 8f; // 非目标粒子抖动速度 [SerializeField, Range(0.1f, 1f)] private float nonTargetChangeInterval = 0.4f; // 非目标粒子变字间隔(越小越快) [SerializeField, Range(0f, 0.03f)] private float nonTargetFloatAmplitude = 0.015f; // 非目标粒子漂浮幅度 [Header("连接线粗细")] [SerializeField] private float targetConnectionThickness = 0.04f; // 目标连线粗细 [SerializeField] private float nonTargetConnectionThickness = 0.02f; // 非目标连线粗细 [Header("Bloom 发光效果")] [SerializeField] private bool enableBloomGlow = true; // 是否启用 Bloom 发光 [SerializeField, Range(1f, 5f)] private float textGlowIntensity = 1.8f; // 文字发光强度 [SerializeField, Range(1f, 5f)] private float targetTextGlowIntensity = 2.5f; // 目标粒子文字发光强度 [SerializeField, Range(1f, 5f)] private float lineGlowIntensity = 2f; // 连接线发光强度 [SerializeField, Range(1f, 5f)] private float targetLineGlowIntensity = 2.5f; // 目标连接线发光强度 [SerializeField, Range(0f, 1f)] private float glowDilate = 0.3f; // 发光扩散 [SerializeField, Range(0f, 1f)] private float glowSoftness = 0.5f; // 发光柔和度 [Header("目标 UI")] [SerializeField] private TMP_Text integrationProgressText; [SerializeField] private TMP_Text interferenceCountText; [Header("完成/确认阶段 — 屏幕内显示范围")] [Tooltip("为真时,完成连线后(含确认按钮、聚焦、稳定化)目标字符位置会被限制在裁剪区域内,避免超出电视机屏幕可视区")] [SerializeField] private bool clampCompletionPhasePositions = true; [Tooltip("可选:场景中一块与屏幕内框对齐的 RectTransform(World Space Canvas 子物体即可)。不指定则用粒子用的 movementBounds(canvasSize - textMargin)")] [SerializeField] private RectTransform completionScreenClipRect; [Tooltip("在裁剪区基础上再向内缩进的世界单位距离,避免放大后的字半个挂在边外")] [SerializeField] private float completionClipEdgePadding = 0.2f; [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; [Tooltip("再次 start_expression 时,若上一轮粒子/UI 仍在,先淡出再重开;协程 duration≤0 时用此值(秒)")] [SerializeField] private float restartExpressionFadeDuration = 0.55f; [SerializeField] private float probabilityDuration = 3.5f; [SerializeField] private float predictionSequenceGap = 0.4f; [SerializeField] private float spreadDistance = 0.35f; [Header("聚焦表现")] [SerializeField] private Color focusRingColor = Color.white; [SerializeField, Range(0.01f, 0.3f)] private float focusRingThickness = 0.08f; [SerializeField, Range(0.05f, 1f)] private float focusRingAlpha = 0.85f; [SerializeField] private float statusFadeOutDuration = 0.45f; [SerializeField] private float shuffleDistance = 0.35f; [SerializeField] private float shuffleFrequency = 3.5f; [SerializeField] private float shuffleVerticalScale = 0.6f; [Header("开场表现")] [SerializeField] private float entranceDelay = 0.45f; [SerializeField] private int entranceCoreCount = 8; [SerializeField] private float entranceCoreRadius = 0.55f; [SerializeField] private float entranceInitialScale = 0.25f; [SerializeField] private float entranceCoreBurstDuration = 1f; [SerializeField] private float entranceCoreStagger = 0.08f; [SerializeField] private float entranceCascadeDuration = 0.8f; [SerializeField] private float entranceCascadeStagger = 0.03f; [SerializeField] private float entranceSearchRadius = 2f; [Header("渲染层设置")] [SerializeField] private string renderLayerName = "HuoshanScreen"; private int renderLayer = 0; [Header("调试/测试")] [SerializeField] private bool enableDebugShortcuts = true; [SerializeField] private KeyCode debugCompleteKey = KeyCode.F5; [SerializeField] private KeyCode debugResetKey = KeyCode.F9; [SerializeField] private KeyCode debugFullTestKey = KeyCode.F10; [SerializeField] private bool autoRunDebugTestOnStart = false; [Header("布局优化")] [SerializeField] private bool enableSeparation = true; // 是否启用粒子分离力 [SerializeField] private float minParticleSpacing = 0.6f; // 粒子最小间距 [SerializeField] private float separationForce = 2.5f; // 分离力度 [SerializeField] private float lineAlphaMultiplier = 0.65f; // 连接线透明度缩放 // 粒子列表 private List 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 readonly Dictionary entranceTargets = new Dictionary(); private class TargetFocusVisual { public CandidateParticle particle; public GameObject ringObject; public Disc ringDisc; public TMP_Text probabilityLabel; public float probability = 90f; public float targetProbability = 100f; public float startShakeIntensity; public string finalChar; public bool stabilized; public Vector3 focusPosition; public Vector3 finalPosition; public int orderIndex; } private bool focusTweensCompleted = false; // 边界 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; /// 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; 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() { 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; 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) { particle.transform.DOKill(); } } foreach (var particle in floatingParticles) { if (particle != null) { particle.transform.DOKill(); } } // 重置游戏状态(但不重置 isInitialized,这样可以重新开启) isCompleted = false; completionPhase = CompletionPhase.None; allowInput = false; ClearCompletionClampSmoothVelocity(); } private void InitializeCanvas() { // 获取渲染层索引 renderLayer = LayerMask.NameToLayer(renderLayerName); if (renderLayer == -1) { Debug.LogWarning($"Layer '{renderLayerName}' not found, using default layer."); renderLayer = 0; } // 如果没有指定canvas,创建一个 if (worldCanvas == null) { GameObject canvasObj = new GameObject("Language Canvas"); canvasObj.transform.SetParent(transform); canvasObj.transform.localPosition = Vector3.zero; canvasObj.layer = renderLayer; worldCanvas = canvasObj.AddComponent(); worldCanvas.renderMode = RenderMode.WorldSpace; RectTransform rectTransform = canvasObj.GetComponent(); rectTransform.sizeDelta = new Vector2(canvasSize, canvasSize); } else { // 设置已有 Canvas 的 layer worldCanvas.gameObject.layer = renderLayer; } // 确保 Canvas 配置正确 if (worldCanvas != null) { // 设置 WorldSpace Canvas 的相机 if (worldCanvas.worldCamera == null) { worldCanvas.worldCamera = Camera.main; } } // 设置运动边界 boundsCenter = worldCanvas.transform.position; float halfSize = canvasSize / 2f - textMargin; if (layoutShape == LayoutShape.Circle) { boundsRadius = circleRadiusOverride > 0 ? circleRadiusOverride : halfSize; movementBounds = new Bounds(boundsCenter, new Vector3(boundsRadius * 2f, boundsRadius * 2f, 0f)); } else { boundsRadius = 0f; movementBounds = new Bounds( boundsCenter, new Vector3(halfSize * 2f, halfSize * 2f, 0f) ); } } private void InitializeParticles() { // 创建背景浮动文字 for (int i = 0; i < floatingCount; i++) { Vector3 pos = GetRandomPositionInBounds(); GameObject obj = CreateParticleObject(pos, $"FloatingParticle_{i}"); FloatingTextParticle particle = obj.AddComponent(); particle.SetFont(chineseFontAsset); particle.SetColor(floatingTextColor); particle.SetFontSize(floatingTextFontSize); SetParticleBounds(particle); // 设置发光效果 if (enableBloomGlow) { particle.SetGlowSettings(true, textGlowIntensity, floatingTextColor); particle.SetUnderlaySettings(glowDilate, glowSoftness); } floatingParticles.Add(particle); } // 创建候选粒子 for (int i = 0; i < candidateCount; i++) { AddOneCandidateParticle(i); } // 选择红色粒子 SelectRedParticles(); } private GameObject CreateParticleObject(Vector3 position, string name) { GameObject obj = new GameObject(name); obj.layer = renderLayer; // 添加 RectTransform(在 Canvas 下必需) RectTransform rectTransform = obj.AddComponent(); rectTransform.SetParent(worldCanvas.transform, false); rectTransform.position = position; rectTransform.localScale = Vector3.one; rectTransform.sizeDelta = Vector2.zero; // 父对象不需要大小 return obj; } private void AddOneCandidateParticle(int index) { Vector3 pos = GetRandomPositionInBounds(); GameObject obj = CreateParticleObject(pos, $"CandidateParticle_{index}"); CandidateParticle particle = obj.AddComponent(); particle.SetFont(chineseFontAsset); particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); particle.SetFontStyles(targetParticleFontSize, nonTargetParticleFontSize, targetParticleBold, nonTargetParticleBold); particle.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude); SetParticleBounds(particle); if (enableBloomGlow) { particle.SetGlowSettings(true, textGlowIntensity, nonTargetParticleColor); particle.SetUnderlaySettings(glowDilate, glowSoftness); } 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 InitializeRenderers() { // 连接线渲染器 GameObject connObj = new GameObject("ConnectionRenderer"); connObj.transform.SetParent(transform); connObj.transform.localPosition = Vector3.zero; connObj.layer = 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; noiseObj.layer = 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); // 为目标粒子设置更强的发光效果 if (enableBloomGlow) { particle.SetGlowSettings(true, targetTextGlowIntensity, targetParticleColor); } } } // 如果不够,强制添加 int remaining = redParticleCount - targetParticles.Count; for (int i = 0; i < remaining && i < shuffled.Count; i++) { if (!shuffled[i].isRed) { shuffled[i].isRed = true; shuffled[i].originalIsRed = true; targetParticles.Add(shuffled[i]); // 为目标粒子设置更强的发光效果 if (enableBloomGlow) { shuffled[i].SetGlowSettings(true, targetTextGlowIntensity, targetParticleColor); } } } } private void Update() { // 如果系统未初始化,不执行任何逻辑 if (!isInitialized) return; // 初始化检查 if (!initializationComplete) { initializationFrames++; if (initializationFrames >= 30) { initializationComplete = true; } else if (initializationFrames >= 20) { // 建立初始连接状态 BuildConnectionMap(); } } HandleDebugShortcuts(); UpdateConnections(); if (!statusUIHidden) { UpdateStatusUI(); } if (completionPhase == CompletionPhase.None) { if (initializationComplete && !interferenceLogicSuspended) { DetectConnectionChanges(); } if (!isCompleted && CheckRedParticlesConnected()) { isCompleted = true; OnTargetsCompleted(); } if (allowInput && !interferenceLogicSuspended) { UpdateInput(); } if (!interferenceLogicSuspended) { ApplySeparationForces(); } } if (completionPhase == CompletionPhase.FocusHolding) { UpdateFocusHolding(); } else if (completionPhase == CompletionPhase.Focusing) { UpdateFocusSequence(); } UpdateRenderers(); } private void LateUpdate() { if (!isInitialized || !clampCompletionPhasePositions || !isCompleted) return; if (completionPhase == CompletionPhase.None) return; // 连线刚完成、等待确认时保持粒子当前世界坐标,避免 GetCompletionClipBounds(含 padding/屏幕 Rect) // 比游玩用 movementBounds 更紧时,在 Completed 首帧 LateUpdate 产生生硬瞬移。 if (completionPhase == CompletionPhase.Completed) return; Bounds clip = GetCompletionClipBounds(); if (clip.size.x < 0.01f || clip.size.y < 0.01f) return; float dt = Time.deltaTime; if (dt < 1e-6f) dt = 1e-6f; float smooth = Mathf.Max(0.0001f, completionPositionSmoothTime); for (int i = 0; i < targetParticles.Count; i++) { var p = targetParticles[i]; if (p == null) continue; Vector3 cur = p.transform.position; Vector3 desired = ClampPositionToBounds(cur, clip); if (!completionClampSmoothVelocity.TryGetValue(p, out Vector3 vel)) vel = Vector3.zero; Vector3 next = Vector3.SmoothDamp(cur, desired, ref vel, smooth, Mathf.Infinity, dt); completionClampSmoothVelocity[p] = vel; p.transform.position = next; } } private void ClearCompletionClampSmoothVelocity() { completionClampSmoothVelocity.Clear(); } /// /// 完成阶段使用的裁剪范围:优先用 Inspector 指定的屏幕 Rect,否则与粒子运动边界一致。 /// private Bounds GetCompletionClipBounds() { 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; } if (completionClipEdgePadding > 0f) { Bounds shrunk = b; shrunk.Expand(-completionClipEdgePadding); if (shrunk.size.x >= 0.01f && shrunk.size.y >= 0.01f) { b = shrunk; } } return b; } 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) { 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() { if (focusSequenceButton != null) { focusSequenceButton.interactable = false; } ClearCompletionClampSmoothVelocity(); completionPhase = CompletionPhase.Focusing; focusTimer = 0f; focusTweensCompleted = false; interferenceLogicSuspended = true; SetStatusUIVisibility(false, statusFadeOutDuration); // 两个 panel、confirm button、火山释放 log 界面在 focus 触发时淡出 FadeOutUIElement(languageDeepPanel1, focusFadeDuration); FadeOutUIElement(languageDeepPanel2, focusFadeDuration); if (focusSequenceButton != null) { FadeOutUIElement(focusSequenceButton.gameObject, focusFadeDuration); } FadeOutSpriteRenderer(releaseLogSpriteRenderer, focusFadeDuration); if (connectionRenderer != null) { connectionRenderer.NonTargetAlphaScale = 0f; connectionRenderer.TargetAlphaScale = 1.2f; } foreach (var particle in floatingParticles) { particle.isCalmed = true; particle.velocity = Vector2.zero; FadeOutTextParticle(particle, focusFadeDuration); } foreach (var particle in candidateParticles) { if (!particle.isRed) { particle.isCalmed = true; particle.velocity = Vector2.zero; FadeOutTextParticle(particle, focusFadeDuration); } else { particle.isStatic = false; particle.isCalmed = false; particle.InitializeAnxietyEffect(); particle.changeSpeedMultiplier = 6f; particle.anxietyShakeIntensity = Mathf.Max(particle.anxietyShakeIntensity, 4f); particle.SetChangeInterval(0.05f); particle.ResetChangeTimer(0.05f); } } focusCentroid = ComputeCentroid(orderedRedParticles); Vector3 focusTargetCenter = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero; Bounds focusClipBounds = GetCompletionClipBounds(); DestroyFocusVisualDecorationsAndClear(); int tweenTargetCount = orderedRedParticles.Count; int completedTweens = 0; for (int i = 0; i < orderedRedParticles.Count; i++) { var particle = orderedRedParticles[i]; Vector3 offset = particle.transform.position - focusCentroid; float indexCenter = (orderedRedParticles.Count - 1) * 0.5f; Vector3 spreadOffset = new Vector3((i - indexCenter) * spreadDistance, Mathf.Sin(i * 1.4f) * spreadDistance * 0.6f, 0f); Vector3 focusPosition = focusTargetCenter + offset * focusZoomScale + spreadOffset; Vector3 finalPosition = finalTargetPositions.Count > i ? finalTargetPositions[i] : focusTargetCenter; focusPosition = ClampPositionToBounds(focusPosition, focusClipBounds); finalPosition = ClampPositionToBounds(finalPosition, focusClipBounds); particle.transform.DOKill(); particle.transform.DOMove(focusPosition, focusMoveDuration) .SetEase(Ease.InOutQuad) .OnComplete(() => { completedTweens++; if (completedTweens >= tweenTargetCount) { EnterFocusHolding(); } }); particle.transform.DOScale(Vector3.one * focusZoomScale, focusMoveDuration).SetEase(Ease.InOutQuad); string finalChar = i < finalTargetCharacters.Count ? finalTargetCharacters[i] : particle.currentChar; TargetFocusVisual visual = CreateFocusVisual(particle, finalChar); visual.focusPosition = focusPosition; visual.finalPosition = finalPosition; visual.orderIndex = i; focusVisuals[particle] = visual; } if (tweenTargetCount == 0) { EnterFocusHolding(); } } private TargetFocusVisual CreateFocusVisual(CandidateParticle particle, string finalChar) { TargetFocusVisual visual = new TargetFocusVisual { particle = particle, finalChar = finalChar, startShakeIntensity = Mathf.Max(particle.anxietyShakeIntensity, 4f) }; GameObject ringObj = new GameObject("FocusRing"); ringObj.layer = renderLayer; ringObj.transform.SetParent(particle.transform, false); visual.ringObject = ringObj; float estimatedRadius = 0.55f; if (particle != null && particle.textMesh != null) { estimatedRadius = Mathf.Clamp(particle.textMesh.fontSize * 0.05f, 0.35f, 0.75f); } var disc = ringObj.AddComponent(); disc.Type = DiscType.Ring; disc.Radius = estimatedRadius; disc.Thickness = Mathf.Clamp(focusRingThickness, 0.01f, 0.3f); Color ringColor = focusRingColor; ringColor.a = focusRingAlpha; disc.Color = ringColor; visual.ringDisc = disc; GameObject labelObj = new GameObject("ProbabilityLabel"); labelObj.layer = renderLayer; labelObj.transform.SetParent(particle.transform, false); RectTransform rectTransform = labelObj.AddComponent(); rectTransform.localPosition = new Vector3(0f, estimatedRadius + 0.55f, 0f); rectTransform.localScale = Vector3.one; rectTransform.sizeDelta = new Vector2(2f, 0.6f); var label = labelObj.AddComponent(); if (chineseFontAsset != null) { label.font = chineseFontAsset; } label.fontSize = 2f; label.alignment = TextAlignmentOptions.Center; label.text = $"{Mathf.RoundToInt(visual.probability)}%"; visual.probabilityLabel = label; return visual; } private void EnterFocusHolding() { completionPhase = CompletionPhase.FocusHolding; focusTimer = 0f; if (!string.IsNullOrEmpty(completionDialogNode)) { DialogController.Instance?.StartDialogNode(completionDialogNode); } } private void UpdateFocusHolding() { focusTimer += Time.deltaTime; foreach (var visual in focusVisuals.Values) { if (visual == null || visual.particle == null) continue; Vector3 shuffleOffset = CalculateShuffleOffset(visual, 0f, focusTimer); visual.particle.transform.position = visual.focusPosition + shuffleOffset; } } private void UpdateFocusSequence() { if (!focusTweensCompleted) return; focusTimer += Time.deltaTime; float baseInterval = Mathf.Max(0.2f, probabilityDuration); float arrangementDuration = Mathf.Max(0.2f, probabilityDuration * 0.6f); foreach (var visual in focusVisuals.Values) { if (visual == null || visual.particle == null) continue; float localTime = focusTimer - visual.orderIndex * predictionSequenceGap; float probabilityProgress = Mathf.Clamp01(localTime / baseInterval); float arrangementProgress = Mathf.Clamp01(localTime / arrangementDuration); float probability = Mathf.Lerp(visual.probability, visual.targetProbability, probabilityProgress); if (visual.probabilityLabel != null) { visual.probabilityLabel.text = $"{Mathf.RoundToInt(probability)}%"; } 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); 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; if (visual.probabilityLabel != null) { visual.probabilityLabel.text = "100%"; } } } bool allStable = focusVisuals.Values.All(v => v.stabilized); if (allStable) { CompleteFocusSequence(); } } /// /// 从 FocusHolding 进入稳定化阶段(概率递增、文字锁定、排列到最终位置) /// public void ResolveFocusSequence() { if (completionPhase != CompletionPhase.FocusHolding) return; completionPhase = CompletionPhase.Focusing; focusTweensCompleted = true; focusTimer = 0f; } /// /// 等待聚焦流程全部完成(稳定化 → Finished) /// public IEnumerator WaitUntilFocusFinished() { while (completionPhase != CompletionPhase.Finished && completionPhase != CompletionPhase.None) { yield return null; } } private void DestroyFocusVisualDecorationsAndClear() { foreach (var visual in focusVisuals.Values) { if (visual == null) continue; if (visual.ringObject != null) Destroy(visual.ringObject); if (visual.probabilityLabel != null) Destroy(visual.probabilityLabel.gameObject); } 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; } if (connectionRenderer != null) { connectionRenderer.TargetAlphaScale = 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 void FadeOutTextParticle(TextParticle particle, float duration) { if (particle == null) return; DOTween.To(() => particle.alpha, value => particle.alpha = value, 0f, duration) .SetEase(Ease.OutQuad); if (particle.glowSprite != null) { particle.glowSprite.DOFade(0f, duration); } } /// /// 淡出 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.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 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) { isCompleted = false; completionPhase = CompletionPhase.None; completionTimer = 0f; focusTimer = 0f; fadeOutAlpha = 1f; allowInput = !playEntranceEffect; orderedRedParticles.Clear(); DestroyFocusVisualDecorationsAndClear(); finalTargetCharacters.Clear(); finalTargetPositions.Clear(); previousConnections.Clear(); initializationComplete = false; initializationFrames = 0; focusTweensCompleted = false; interferenceLogicSuspended = false; ClearCompletionClampSmoothVelocity(); SetStatusUIVisibility(true, 0f); if (entranceRoutine != null) { StopCoroutine(entranceRoutine); entranceRoutine = null; } if (confirmTimelineRoutine != null) { StopCoroutine(confirmTimelineRoutine); confirmTimelineRoutine = null; } if (expressionPanelRoutine != null) { StopCoroutine(expressionPanelRoutine); expressionPanelRoutine = null; } if (focusSequenceButton != null) { focusSequenceButton.onClick.RemoveListener(HandleFocusButtonClicked); focusSequenceButton.onClick.AddListener(HandleFocusButtonClicked); focusSequenceButton.interactable = false; focusSequenceButton.gameObject.SetActive(false); } if (languageDeepPanel1 != null) languageDeepPanel1.SetActive(false); if (languageDeepPanel2 != null) languageDeepPanel2.SetActive(false); // 恢复火山释放 log 界面 SpriteRenderer(重新开始时恢复) RestoreSpriteRenderer(releaseLogSpriteRenderer); if (connectionRenderer != null) { connectionRenderer.NonTargetAlphaScale = lineAlphaMultiplier; connectionRenderer.TargetAlphaScale = 1f; } // 重置所有粒子 foreach (var p in candidateParticles) { p.isRed = false; p.isOrange = false; p.hasEffect = false; p.isStatic = false; p.originalIsRed = false; p.changeSpeedMultiplier = 1f; p.vibrationOffset = Vector2.zero; p.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f)); p.transform.position = GetRandomPositionInBounds(); p.isCalmed = false; p.alpha = p.baseAlpha; p.transform.localScale = Vector3.one; p.transform.DOKill(); p.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude); if (p.glowSprite != null) { Color glowColor = p.glowSprite.color; glowColor.a = 0.2f; p.glowSprite.color = glowColor; } } foreach (var floating in floatingParticles) { floating.isCalmed = false; floating.alpha = floating.baseAlpha; floating.transform.DOKill(); } 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; if (candidate.glowSprite != null) { Color glowColor = candidate.glowSprite.color; glowColor.a = 0f; candidate.glowSprite.color = glowColor; } } yield return new WaitForSeconds(Mathf.Max(0f, entranceDelay)); int coreCount = Mathf.Clamp(entranceCoreCount, 1, particles.Count); var shuffled = particles.OrderBy(_ => Random.value).ToList(); var coreCandidates = shuffled.Take(coreCount).ToList(); var remainingCandidates = shuffled.Skip(coreCount).ToList(); float radius = Mathf.Max(0.1f, entranceCoreRadius); float maxCoreTime = 0f; for (int i = 0; i < coreCandidates.Count; i++) { var candidate = coreCandidates[i]; float angle = (Mathf.PI * 2f / Mathf.Max(1, coreCandidates.Count)) * i; Vector3 startPos = burstOrigin + new Vector3(Mathf.Cos(angle), Mathf.Sin(angle), 0f) * radius; candidate.transform.position = startPos; float delay = i * Mathf.Max(0f, entranceCoreStagger); Sequence seq = DOTween.Sequence(); seq.SetDelay(delay); seq.Append(candidate.transform.DOMove(entranceTargets[candidate], entranceCoreBurstDuration).SetEase(Ease.OutCubic)); seq.Join(candidate.transform.DOScale(Vector3.one, entranceCoreBurstDuration).SetEase(Ease.OutBack)); seq.Join(DOTween.To(() => candidate.alpha, value => candidate.alpha = value, candidate.baseAlpha, entranceCoreBurstDuration).SetEase(Ease.OutQuad)); if (candidate.glowSprite != null) { seq.Join(candidate.glowSprite.DOFade(0.2f, entranceCoreBurstDuration)); } seq.OnComplete(() => { candidate.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f)); }); maxCoreTime = Mathf.Max(maxCoreTime, delay + entranceCoreBurstDuration); } if (coreCandidates.Count > 0) { yield return new WaitForSeconds(maxCoreTime * 0.6f); } float cascadeDuration = 0f; for (int i = 0; i < remainingCandidates.Count; i++) { var candidate = remainingCandidates[i]; Vector2 offset = Random.insideUnitCircle * entranceSearchRadius; candidate.transform.position = burstOrigin + new Vector3(offset.x, offset.y, 0f); float delay = i * Mathf.Max(0f, entranceCascadeStagger); Sequence seq = DOTween.Sequence(); seq.SetDelay(delay); seq.Append(candidate.transform.DOMove(entranceTargets[candidate], entranceCascadeDuration).SetEase(Ease.OutQuad)); seq.Join(candidate.transform.DOScale(Vector3.one, entranceCascadeDuration * 0.8f).SetEase(Ease.OutBack)); seq.Join(DOTween.To(() => candidate.alpha, value => candidate.alpha = value, candidate.baseAlpha, entranceCascadeDuration).SetEase(Ease.OutSine)); if (candidate.glowSprite != null) { seq.Join(candidate.glowSprite.DOFade(0.2f, entranceCascadeDuration)); } seq.OnComplete(() => { candidate.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f)); }); cascadeDuration = Mathf.Max(cascadeDuration, delay + entranceCascadeDuration); } float waitTime = Mathf.Max(maxCoreTime, cascadeDuration); if (waitTime > 0f) { yield return new WaitForSeconds(waitTime + 0.2f); } foreach (var candidate in particles) { if (candidate == null) continue; candidate.alpha = candidate.baseAlpha; candidate.transform.localScale = Vector3.one; } entranceTargets.Clear(); entranceRoutine = null; // 开场表现完成,启动独立的火山表达panel协程(避免与 Entrance 耦合) if (expressionPanelRoutine != null) { StopCoroutine(expressionPanelRoutine); } expressionPanelRoutine = StartCoroutine(PlayExpressionPanelThenReady()); } /// /// 独立协程:播放火山表达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; float minSpacingSqr = minParticleSpacing * minParticleSpacing; for (int i = 0; i < candidateParticles.Count - 1; i++) { var particleA = candidateParticles[i]; if (particleA.isStatic) continue; Vector2 posA = particleA.transform.position; for (int j = i + 1; j < candidateParticles.Count; j++) { var particleB = candidateParticles[j]; if (particleB.isStatic) continue; Vector2 posB = particleB.transform.position; Vector2 diff = posA - posB; float distSqr = diff.sqrMagnitude; if (distSqr < minSpacingSqr && distSqr > 0.0001f) { float distance = Mathf.Sqrt(distSqr); float pushStrength = 1f - (distance / minParticleSpacing); Vector2 direction = diff / distance; Vector2 push = direction * (pushStrength * separationForce); particleA.ApplyForce(push * Time.deltaTime); particleB.ApplyForce(-push * Time.deltaTime); } } } } private void UpdateStatusUI() { if (statusUIHidden) return; if (integrationProgressText != null) { float ratio = CalculateIntegrationRatio(); int percent = Mathf.RoundToInt(ratio * 100f); integrationProgressText.text = $"{percent}% 语言整合完成度"; } if (interferenceCountText != null) { int interferenceCount = CountInterferenceNodes(); interferenceCountText.text = $"{interferenceCount} 处异常思绪仍在干扰表达"; } } private float CalculateIntegrationRatio() { if (targetParticles.Count == 0) return 0f; HashSet 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 } }