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; // 非目标粒子漂浮幅度 /// /// 按轮干扰表现档位:递进主要靠"文字更躁"传达,不实质加难 /// [System.Serializable] public class InterferencePresentationPreset { [Tooltip("非目标粒子抖动强度")] public float shakeIntensity = 0.02f; [Tooltip("非目标粒子抖动速度")] public float shakeSpeed = 8f; [Tooltip("非目标粒子变字间隔(越小越快越躁)")] public float changeInterval = 0.4f; [Tooltip("非目标粒子漂浮幅度")] public float floatAmplitude = 0.015f; [Tooltip("干扰计数映射的 glitch 基线上限(0 关闭联动)")] [Range(0f, 0.3f)] public float glitchBaselineMax = 0f; [Tooltip("非目标粒子向目标簇的轻微漂移加速度(仅观感,远小于玩家施力,0 关闭)")] public float driftBias = 0f; } [Header("干扰表现递进(start_expression 第5参为档位索引,缺省用上方全局参数)")] [SerializeField] private InterferencePresentationPreset[] interferencePresets = new InterferencePresentationPreset[] { new InterferencePresentationPreset(), new InterferencePresentationPreset { shakeIntensity = 0.035f, shakeSpeed = 10f, changeInterval = 0.28f, floatAmplitude = 0.02f, glitchBaselineMax = 0f, driftBias = 0.25f }, new InterferencePresentationPreset { shakeIntensity = 0.05f, shakeSpeed = 12f, changeInterval = 0.18f, floatAmplitude = 0.028f, glitchBaselineMax = 0f, driftBias = 0.4f }, }; [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; [Tooltip("聚焦阶段 UI/粒子淡出时长 = focusMoveDuration × 此比例,与粒子聚焦移动同节奏")] [SerializeField, Range(0.3f, 1f)] private float focusFadeRatio = 0.8f; [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("完成释放表现")] [Tooltip("完成瞬间冲击波扩散时长(秒)")] [SerializeField] private float completionShockwaveDuration = 0.6f; [Tooltip("非目标粒子被吹散的径向冲量")] [SerializeField] private float completionBlastForce = 3.5f; [Tooltip("吹散后非目标粒子透明度(相对 baseAlpha 的比例)")] [SerializeField, Range(0f, 1f)] private float completionBlastAlphaRatio = 0.35f; [Tooltip("吹散后到进入平静态的延迟(秒)")] [SerializeField] private float completionCalmDelay = 0.55f; [Tooltip("完成瞬间 glitch 脉冲峰值(0 关闭)")] [SerializeField, Range(0f, 1f)] private float completionGlitchPulsePeak = 0f; [Header("节奏统一")] [Tooltip("完成时确认按钮淡入/上浮时长(秒)")] [SerializeField] private float confirmButtonFadeDuration = 0.35f; [Tooltip("确认按钮上浮距离(Canvas 局部单位)")] [SerializeField] private float confirmButtonRiseOffset = 0.35f; [Tooltip("入场 cascade 进行到该比例时并行启动火山表达panel(消除串行空档)")] [SerializeField, Range(0f, 1f)] private float panelStartAtEntranceProgress = 0.6f; [Tooltip("状态文字数值滚动速度(百分点/秒)")] [SerializeField] private float statusCountUpSpeed = 90f; [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; [Tooltip("不稳定期聚焦环虚线旋转速度(DashOffset/秒)")] [SerializeField] private float focusRingDashRotateSpeed = 0.35f; [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 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 Vector3 labelBasePos; 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; } /// 聚焦阶段 UI/粒子统一淡出时长(与聚焦移动同节奏) private float FocusUiFadeDuration => focusMoveDuration * focusFadeRatio; // 入场表现是否结束(panel timeline 并行播放时,两者都完成才 allowInput) private bool entranceVisualsFinished = true; // 确认按钮基准位置(淡入上浮动画的落点,首次完成时捕获) private Vector2 confirmButtonBaseAnchoredPos; private bool confirmButtonBasePosCaptured; // 状态文字 count-up 显示值与脉冲检测 private float displayedIntegrationPercent; private float displayedInterferenceCount; private int lastInterferenceCount = -1; private Color interferenceTextBaseColor = Color.white; private bool interferenceTextBaseColorCaptured; // 完成瞬间吹散→延迟平静协程 private Coroutine completionBurstRoutine; // 当前生效的干扰表现参数(InitializeSystem 时由档位或全局参数决定) private float activeShakeIntensity; private float activeShakeSpeed; private float activeChangeInterval; private float activeFloatAmplitude; private float activeGlitchBaselineMax; private float activeDriftBias; private bool interferenceActiveParamsSet; private float glitchLinkTimer; // 顶层表达管理器(glitch 脉冲/联动用),粒子管理器挂在其子物体上 private ExpressionManager expressionManagerRef; private ExpressionManager ExpressionManagerRef { get { if (expressionManagerRef == null) { expressionManagerRef = GetComponentInParent(true); } return expressionManagerRef; } } 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; // 鼠标坐标换算用主相机缓存(避免每帧 Camera.main 查找) private Camera cachedMainCamera; /// Inspector 中的候选粒子总数,用于未在 Yarn 中指定非目标数量时恢复默认 private int defaultCandidateCountFromInspector; private bool hasCapturedDefaultCandidateCount; private void CaptureDefaultCandidateCountFromInspector() { if (hasCapturedDefaultCandidateCount) return; defaultCandidateCountFromInspector = candidateCount; hasCapturedDefaultCandidateCount = true; } /// /// 选择本轮生效的干扰表现参数:有效档位用预设,否则回落到 Inspector 全局参数(行为与旧版一致) /// private void ApplyInterferencePreset(int presetIndex) { if (presetIndex >= 0 && interferencePresets != null && presetIndex < interferencePresets.Length && interferencePresets[presetIndex] != null) { var p = interferencePresets[presetIndex]; activeShakeIntensity = p.shakeIntensity; activeShakeSpeed = p.shakeSpeed; activeChangeInterval = p.changeInterval; activeFloatAmplitude = p.floatAmplitude; activeGlitchBaselineMax = p.glitchBaselineMax; activeDriftBias = p.driftBias; } else { activeShakeIntensity = nonTargetShakeIntensity; activeShakeSpeed = nonTargetShakeSpeed; activeChangeInterval = nonTargetChangeInterval; activeFloatAmplitude = nonTargetFloatAmplitude; activeGlitchBaselineMax = 0f; activeDriftBias = 0f; } interferenceActiveParamsSet = true; glitchLinkTimer = 0f; } private void EnsureActiveInterferenceParams() { if (!interferenceActiveParamsSet) { ApplyInterferencePreset(-1); } } private void Start() { // Start 中不再自动初始化,等待外部调用 InitializeSystem } /// /// 初始化系统(可以由外部调用,用于配置游戏参数) /// /// 焦虑短句列表 /// 目标句子 /// 完成时触发的对话节点 /// /// 非目标候选粒子总数(蓝/干扰侧可交互粒子数量)。≥0 时候选池大小 = 目标句字数 + 该值;<0 时使用 Inspector 的 Candidate Count。 /// /// 干扰表现档位索引;<0 或越界时使用 Inspector 全局干扰参数 public void InitializeSystem(List phrases, string sentence, string dialogNode = null, int nonTargetParticleTotal = -1, int interferencePresetIndex = -1) { CaptureDefaultCandidateCountFromInspector(); ApplyInterferencePreset(interferencePresetIndex); 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; interferenceTextBaseColor = interferenceCountText.color; interferenceTextBaseColorCaptured = true; } 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; } if (completionBurstRoutine != null) { StopCoroutine(completionBurstRoutine); completionBurstRoutine = 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; } if (completionBurstRoutine != null) { StopCoroutine(completionBurstRoutine); completionBurstRoutine = 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); EnsureActiveInterferenceParams(); particle.SetInterferenceParams(activeShakeIntensity, activeShakeSpeed, activeChangeInterval, activeFloatAmplitude); 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; } } HandleDebugShortcuts(); UpdateConnections(); if (!statusUIHidden) { UpdateStatusUI(); } if (completionPhase == CompletionPhase.None) { if (!isCompleted && CheckRedParticlesConnected()) { isCompleted = true; OnTargetsCompleted(); } if (allowInput && !interferenceLogicSuspended) { UpdateInput(); } if (!interferenceLogicSuspended) { ApplySeparationForces(); ApplyInterferenceDrift(); UpdateInterferenceGlitchLink(); } } 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(); } // 聚焦及之后阶段非目标粒子已淡出,只需维护目标粒子间连线,跳过全量 O(n²) if (completionPhase == CompletionPhase.Focusing || completionPhase == CompletionPhase.FocusHolding || completionPhase == CompletionPhase.Finished) { for (int i = 0; i < targetParticles.Count; i++) { for (int j = i + 1; j < targetParticles.Count; j++) { float d = Vector3.Distance( targetParticles[i].transform.position, targetParticles[j].transform.position ); if (d < connectionDistance) { targetParticles[i].connections.Add(targetParticles[j]); targetParticles[j].connections.Add(targetParticles[i]); } } } return; } // 完成目标后,不再创建目标粒子与非目标粒子的新连接 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 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 并淡入上浮(不可交互,播放火山表达确认后再解锁),与确认 timeline 并行 foreach (var particle in candidateParticles) { if (!particle.isRed) { particle.hasEffect = false; particle.isOrange = false; particle.changeSpeedMultiplier = 1f; } } foreach (var particle in floatingParticles) { particle.isCalmed = true; } // 释放感:冲击波 + 笑话粒子被吹散 + glitch 短脉冲 PlayCompletionBurst(); } /// /// 完成瞬间的释放表现:从目标簇质心放出冲击波环,非目标粒子被径向吹散并渐隐, /// 延迟后进入平静态;同时打一次 glitch 短脉冲。 /// private void PlayCompletionBurst() { if (completionBurstRoutine != null) { StopCoroutine(completionBurstRoutine); } completionBurstRoutine = StartCoroutine(PlayCalmCompletionRoutine()); } private IEnumerator PlayCalmCompletionRoutine() { float fadeDuration = Mathf.Max(0.05f, completionCalmDelay); float moveDuration = Mathf.Max(0.05f, completionShockwaveDuration); if (connectionRenderer != null) { DOTween.To(() => connectionRenderer.NonTargetAlphaScale, v => connectionRenderer.NonTargetAlphaScale = v, 0f, fadeDuration) .SetEase(Ease.OutQuad) .SetTarget(this); } foreach (var particle in candidateParticles) { if (particle == null) continue; particle.velocity = Vector2.zero; if (!particle.isRed) { particle.isCalmed = true; particle.isStatic = true; FadeOutTextParticle(particle, fadeDuration); } else { particle.isStatic = true; particle.isCalmed = true; particle.changeSpeedMultiplier = 1f; } } foreach (var particle in floatingParticles) { if (particle == null) continue; particle.isCalmed = true; particle.velocity = Vector2.zero; FadeOutTextParticle(particle, fadeDuration); } int tweenTargetCount = orderedRedParticles.Count; int completedTweens = 0; for (int i = 0; i < orderedRedParticles.Count; i++) { var particle = orderedRedParticles[i]; if (particle == null) { completedTweens++; continue; } if (i < finalTargetCharacters.Count) { particle.ForceSetCharacter(finalTargetCharacters[i]); } Vector3 targetPos = finalTargetPositions.Count > i ? finalTargetPositions[i] : (worldCanvas != null ? worldCanvas.transform.position : Vector3.zero); particle.alpha = particle.baseAlpha; particle.transform.DOKill(); particle.transform.DOMove(targetPos, moveDuration) .SetEase(Ease.InOutQuad) .OnComplete(() => completedTweens++); particle.transform.DOScale(Vector3.one * focusZoomScale, moveDuration).SetEase(Ease.OutQuad); } if (tweenTargetCount > 0) { while (completedTweens < tweenTargetCount) { yield return null; } } else { yield return new WaitForSeconds(fadeDuration); } ShowFocusSequenceButton(); if (confirmTimelineRoutine != null) { StopCoroutine(confirmTimelineRoutine); } confirmTimelineRoutine = StartCoroutine(PlayConfirmTimelineAndUnlockButton()); completionBurstRoutine = null; } private void ShowFocusSequenceButton() { if (focusSequenceButton == null) return; var btnCg = focusSequenceButton.GetComponent(); if (btnCg == null) btnCg = focusSequenceButton.gameObject.AddComponent(); var btnRect = focusSequenceButton.transform as RectTransform; if (btnRect != null && !confirmButtonBasePosCaptured) { confirmButtonBaseAnchoredPos = btnRect.anchoredPosition; confirmButtonBasePosCaptured = true; } focusSequenceButton.gameObject.SetActive(true); focusSequenceButton.interactable = false; DOTween.Kill(btnCg); btnCg.alpha = 0f; btnCg.DOFade(1f, confirmButtonFadeDuration).SetEase(Ease.OutQuad); if (btnRect != null) { btnRect.DOKill(); btnRect.anchoredPosition = confirmButtonBaseAnchoredPos + Vector2.down * confirmButtonRiseOffset; btnRect.DOAnchorPos(confirmButtonBaseAnchoredPos, confirmButtonFadeDuration).SetEase(Ease.OutQuad); } } private void PlayCompletionBurstLegacy() { Vector3 center = ComputeCentroid(targetParticles); // 冲击波环(Shapes Ring 扩散淡出) GameObject waveObj = new GameObject("CompletionShockwave"); waveObj.layer = renderLayer; waveObj.transform.SetParent(transform, false); float z = worldCanvas != null ? worldCanvas.transform.position.z : center.z; waveObj.transform.position = new Vector3(center.x, center.y, z); var wave = waveObj.AddComponent(); wave.Type = DiscType.Ring; wave.Radius = 0.2f; wave.Thickness = 0.12f; Color waveColor = targetParticleColor; waveColor.a = 0.9f; wave.Color = waveColor; float maxRadius = layoutShape == LayoutShape.Circle ? boundsRadius : Mathf.Max(movementBounds.extents.x, movementBounds.extents.y); maxRadius *= 1.1f; Color waveEnd = waveColor; waveEnd.a = 0f; Sequence waveSeq = DOTween.Sequence(); waveSeq.Append(DOTween.To(() => wave.Radius, r => wave.Radius = r, maxRadius, completionShockwaveDuration).SetEase(Ease.OutCubic)); waveSeq.Join(DOTween.To(() => wave.Thickness, t => wave.Thickness = t, 0.02f, completionShockwaveDuration).SetEase(Ease.OutQuad)); waveSeq.Join(DOTween.To(() => wave.Color, c => wave.Color = c, waveEnd, completionShockwaveDuration).SetEase(Ease.OutQuad)); waveSeq.OnComplete(() => { if (waveObj != null) Destroy(waveObj); }); waveSeq.SetLink(waveObj); // 非目标粒子径向吹散 + 渐隐 foreach (var particle in candidateParticles) { if (particle == null || particle.isRed) continue; Vector2 dir = (Vector2)(particle.transform.position - center); dir = dir.sqrMagnitude < 0.0001f ? Random.insideUnitCircle.normalized : dir.normalized; particle.isCalmed = false; particle.ApplyForce(dir * completionBlastForce); DOTween.To(() => particle.alpha, v => particle.alpha = v, particle.baseAlpha * completionBlastAlphaRatio, completionCalmDelay) .SetEase(Ease.OutQuad) .SetTarget(particle); } if (completionBurstRoutine != null) { StopCoroutine(completionBurstRoutine); } completionBurstRoutine = StartCoroutine(CalmNonTargetsAfterBlast()); // glitch 短脉冲 if (completionGlitchPulsePeak > 0f && ExpressionManagerRef != null) { ExpressionManagerRef.PlayGlitchPulse(completionGlitchPulsePeak, 0.5f); } } private IEnumerator CalmNonTargetsAfterBlast() { yield return new WaitForSeconds(completionCalmDelay); foreach (var particle in candidateParticles) { if (particle != null && !particle.isRed) { particle.isCalmed = true; particle.velocity = Vector2.zero; } } completionBurstRoutine = null; } 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 触发时淡出(与粒子聚焦移动同节奏) float uiFadeDuration = FocusUiFadeDuration; FadeOutUIElement(languageDeepPanel1, uiFadeDuration); FadeOutUIElement(languageDeepPanel2, uiFadeDuration); if (focusSequenceButton != null) { FadeOutUIElement(focusSequenceButton.gameObject, uiFadeDuration); } FadeOutSpriteRenderer(releaseLogSpriteRenderer, uiFadeDuration); if (connectionRenderer != null) { connectionRenderer.NonTargetAlphaScale = 0f; connectionRenderer.TargetAlphaScale = 1.2f; } foreach (var particle in floatingParticles) { particle.isCalmed = true; particle.velocity = Vector2.zero; FadeOutTextParticle(particle, uiFadeDuration); } foreach (var particle in candidateParticles) { if (!particle.isRed) { particle.isCalmed = true; particle.velocity = Vector2.zero; FadeOutTextParticle(particle, uiFadeDuration); } 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 focusPosition = particle.transform.position; 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; // 不稳定期为旋转虚线环,锁定时收敛为实线(见 PlayFocusLockEffect) disc.Dashed = true; 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); visual.labelBasePos = rectTransform.localPosition; var label = labelObj.AddComponent(); if (chineseFontAsset != null) { label.font = chineseFontAsset; } label.fontSize = 2f; label.alignment = TextAlignmentOptions.Center; Color labelColor = targetParticleColor; labelColor.a = 0.9f; label.color = labelColor; label.text = FormatProbabilityText(visual.probability); visual.probabilityLabel = label; return visual; } /// 概率标签统一终端风格式 private static string FormatProbabilityText(float probability) { return $"> {Mathf.RoundToInt(probability)}%_"; } /// /// 不稳定期聚焦环虚线旋转 + 概率标签轻微抖动(每帧调用) /// private void UpdateFocusVisualIdle(TargetFocusVisual visual) { if (visual == null || visual.stabilized) return; if (visual.ringDisc != null) { visual.ringDisc.DashOffset += Time.deltaTime * focusRingDashRotateSpeed; } if (visual.probabilityLabel != null) { float jitter = 0.015f; Vector3 offset = new Vector3( (Mathf.PerlinNoise(Time.time * 9f, visual.orderIndex * 3.7f) - 0.5f) * jitter * 2f, (Mathf.PerlinNoise(visual.orderIndex * 5.1f, Time.time * 9f) - 0.5f) * jitter * 2f, 0f); visual.probabilityLabel.rectTransform.localPosition = visual.labelBasePos + offset; } } /// /// 单字锁定表现:环收敛为实线并闪白后淡出,标签定格 100% 后淡出,字符弹跳强调 /// private void PlayFocusLockEffect(TargetFocusVisual visual) { if (visual == null) return; if (visual.particle != null) { visual.particle.transform.DOPunchScale( Vector3.one * (focusZoomScale * 0.15f), 0.25f, 6, 0.6f); } if (visual.ringDisc != null) { var disc = visual.ringDisc; var ringObj = visual.ringObject; disc.Dashed = false; Color flashColor = Color.white; flashColor.a = 1f; Color fadeColor = focusRingColor; fadeColor.a = 0f; float baseRadius = disc.Radius; Sequence seq = DOTween.Sequence(); seq.Append(DOTween.To(() => disc.Color, c => disc.Color = c, flashColor, 0.08f)); seq.Join(DOTween.To(() => disc.Radius, r => disc.Radius = r, baseRadius * 0.82f, 0.16f).SetEase(Ease.OutQuad)); seq.Append(DOTween.To(() => disc.Color, c => disc.Color = c, fadeColor, 0.4f).SetEase(Ease.OutQuad)); seq.OnComplete(() => { if (ringObj != null) Destroy(ringObj); }); if (ringObj != null) seq.SetLink(ringObj); visual.ringDisc = null; visual.ringObject = null; } if (visual.probabilityLabel != null) { var label = visual.probabilityLabel; label.rectTransform.localPosition = visual.labelBasePos; label.text = FormatProbabilityText(100f); var labelSeq = DOTween.Sequence(); labelSeq.AppendInterval(0.3f); labelSeq.Append(label.DOFade(0f, 0.3f)); labelSeq.OnComplete(() => { if (label != null) Destroy(label.gameObject); }); labelSeq.SetLink(label.gameObject); visual.probabilityLabel = null; } } 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; UpdateFocusVisualIdle(visual); } } 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 = FormatProbabilityText(probability); } UpdateFocusVisualIdle(visual); 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; PlayFocusLockEffect(visual); } } 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; } } /// /// Returns the combined world-space bounds of the target sentence while it is /// in the focused completion presentation. Other UI and dialogue text are excluded. /// public bool TryGetFocusedTextBounds(out Bounds textBounds) { textBounds = default; if (completionPhase != CompletionPhase.Focusing && completionPhase != CompletionPhase.FocusHolding && completionPhase != CompletionPhase.Finished) { return false; } bool hasBounds = false; foreach (var particle in orderedRedParticles) { if (particle == null || particle.textMesh == null || !particle.textMesh.gameObject.activeInHierarchy) { continue; } Renderer textRenderer = particle.textMesh.GetComponent(); if (textRenderer == null || !textRenderer.enabled) continue; Bounds particleBounds = textRenderer.bounds; if (particleBounds.size.sqrMagnitude <= Mathf.Epsilon) continue; if (!hasBounds) { textBounds = particleBounds; hasBounds = true; } else { textBounds.Encapsulate(particleBounds); } } return hasBounds; } 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.DOKill(); 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; if (cachedMainCamera == null) { cachedMainCamera = Camera.main; } Camera cam = cachedMainCamera; 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(); initializationComplete = false; initializationFrames = 0; focusTweensCompleted = false; interferenceLogicSuspended = false; ClearCompletionClampSmoothVelocity(); displayedIntegrationPercent = 0f; displayedInterferenceCount = 0f; lastInterferenceCount = -1; if (interferenceCountText != null && interferenceTextBaseColorCaptured) { DOTween.Kill(interferenceCountText); interferenceCountText.color = interferenceTextBaseColor; } 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 (completionBurstRoutine != null) { StopCoroutine(completionBurstRoutine); completionBurstRoutine = 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(); DOTween.Kill(p); EnsureActiveInterferenceParams(); p.SetInterferenceParams(activeShakeIntensity, activeShakeSpeed, activeChangeInterval, activeFloatAmplitude); 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)"); entranceVisualsFinished = true; allowInput = true; // 否则 allowInput 会一直为 false return; } if (entranceRoutine != null) { StopCoroutine(entranceRoutine); } entranceRoutine = StartCoroutine(EntranceSequenceRoutine()); } private IEnumerator EntranceSequenceRoutine() { allowInput = false; entranceVisualsFinished = false; entranceTargets.Clear(); Vector3 burstOrigin = worldCanvas != null ? worldCanvas.transform.position : transform.position; List particles = candidateParticles.Where(p => p != null).ToList(); if (particles.Count == 0) { entranceVisualsFinished = true; 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) { // cascade 进行到指定比例时并行启动火山表达panel,消除"粒子播完才出 UI"的空档 float panelDelay = waitTime * Mathf.Clamp01(panelStartAtEntranceProgress); yield return new WaitForSeconds(panelDelay); StartExpressionPanelRoutine(); yield return new WaitForSeconds(waitTime - panelDelay + 0.2f); } else { StartExpressionPanelRoutine(); } foreach (var candidate in particles) { if (candidate == null) continue; candidate.alpha = candidate.baseAlpha; candidate.transform.localScale = Vector3.one; } entranceTargets.Clear(); entranceVisualsFinished = true; entranceRoutine = null; } private void StartExpressionPanelRoutine() { 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"); } // panel 与入场表现并行,两者都完成才解锁输入 while (!entranceVisualsFinished) { yield return null; } 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 ApplyInterferenceDrift() { if (activeDriftBias <= 0f || isCompleted || targetParticles.Count == 0) return; foreach (var particle in candidateParticles) { if (particle == null || particle.isRed || particle.isStatic || particle.isCalmed) continue; Vector2 pos = particle.transform.position; CandidateParticle nearest = null; float bestSqr = float.MaxValue; foreach (var t in targetParticles) { if (t == null) continue; float sqr = ((Vector2)t.transform.position - pos).sqrMagnitude; if (sqr < bestSqr) { bestSqr = sqr; nearest = t; } } if (nearest == null || bestSqr < 0.01f) continue; Vector2 dir = ((Vector2)nearest.transform.position - pos).normalized; particle.ApplyForce(dir * (activeDriftBias * Time.deltaTime)); } } /// /// 游玩期 glitch 基线随干扰计数轻微浮动(每 0.5s 更新一次;yarn 手动驱动后自动失效) /// private void UpdateInterferenceGlitchLink() { if (activeGlitchBaselineMax <= 0f || isCompleted || !allowInput) return; glitchLinkTimer += Time.deltaTime; if (glitchLinkTimer < 0.5f) return; glitchLinkTimer = 0f; var em = ExpressionManagerRef; if (em == null) return; // 以 6 处干扰为满强度参照,映射到 0~baselineMax float normalized = Mathf.Clamp01(CountInterferenceNodes() / 6f); em.SetGameplayGlitchBaseline(normalized * activeGlitchBaselineMax, 0.6f); } private void UpdateStatusUI() { if (statusUIHidden) return; if (integrationProgressText != null) { float targetPercent = CalculateIntegrationRatio() * 100f; displayedIntegrationPercent = Mathf.MoveTowards( displayedIntegrationPercent, targetPercent, statusCountUpSpeed * Time.deltaTime); integrationProgressText.text = $"{Mathf.RoundToInt(displayedIntegrationPercent)}% 语言整合完成度"; } if (interferenceCountText != null) { int interferenceCount = CountInterferenceNodes(); if (lastInterferenceCount >= 0 && interferenceCount < lastInterferenceCount) { PulseInterferenceText(); } lastInterferenceCount = interferenceCount; displayedInterferenceCount = Mathf.MoveTowards( displayedInterferenceCount, interferenceCount, statusCountUpSpeed * 0.3f * Time.deltaTime); interferenceCountText.text = $"{Mathf.RoundToInt(displayedInterferenceCount)} 处异常思绪仍在干扰表达"; } } /// /// 干扰计数下降时闪一下目标色,给玩家正反馈 /// private void PulseInterferenceText() { if (interferenceCountText == null || statusUIHidden || !interferenceTextBaseColorCaptured) return; DOTween.Kill(interferenceCountText); Color pulse = targetParticleColor; pulse.a = interferenceTextBaseAlpha; Color baseColor = interferenceTextBaseColor; baseColor.a = interferenceTextBaseAlpha; Sequence seq = DOTween.Sequence().SetTarget(interferenceCountText); seq.Append(DOTween.To( () => interferenceCountText.color, c => interferenceCountText.color = c, pulse, 0.08f)); seq.Append(DOTween.To( () => interferenceCountText.color, c => interferenceCountText.color = c, baseColor, 0.4f)); } 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 } }