3656 lines
149 KiB
C#
3656 lines
149 KiB
C#
using UnityEngine;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using DG.Tweening;
|
||
using TMPro;
|
||
using UnityEngine.UI;
|
||
using UnityEngine.Localization;
|
||
using UnityEngine.Localization.Settings;
|
||
using AibisDream;
|
||
using AibisDream.Utility;
|
||
|
||
namespace AibisDream.MiniGame.Language
|
||
{
|
||
/// <summary>
|
||
/// 粒子排布区域形状
|
||
/// </summary>
|
||
public enum LayoutShape
|
||
{
|
||
Rectangle, // 矩形
|
||
Circle // 圆形
|
||
}
|
||
|
||
/// <summary>
|
||
/// 游戏完成阶段
|
||
/// </summary>
|
||
public enum CompletionPhase
|
||
{
|
||
None, // 未完成
|
||
Completed, // 已完成但尚未进入聚焦流程
|
||
FocusHolding, // 聚焦位置保持态(文字飘动/变换,等待外部命令触发下一步)
|
||
Focusing, // 正在聚焦放大并收敛
|
||
Finished // 全流程完成
|
||
}
|
||
|
||
/// <summary>
|
||
/// 语言粒子系统主管理器
|
||
/// </summary>
|
||
public class LanguageParticleManager : MonoBehaviour
|
||
{
|
||
[Header("Canvas设置")]
|
||
[SerializeField] private Canvas worldCanvas;
|
||
[SerializeField] private float canvasSize = 10f;
|
||
[SerializeField] private LayoutShape layoutShape = LayoutShape.Rectangle;
|
||
[Tooltip("圆形布局时的半径(留空则自动使用 canvasSize/2 - textMargin)")]
|
||
[SerializeField] private float circleRadiusOverride = -1f;
|
||
|
||
[Header("字体设置")]
|
||
[SerializeField] private TMP_FontAsset chineseFontAsset;
|
||
[Tooltip("可选:基于 chineseFontAsset 的 Material Preset(如 huoshantextmaterial)")]
|
||
[SerializeField] private Material chineseFontMaterial;
|
||
|
||
[Header("粒子预制体")]
|
||
[SerializeField] private GameObject floatingParticlePrefab;
|
||
[SerializeField] private GameObject candidateParticlePrefab;
|
||
|
||
[Header("粒子数量")]
|
||
[SerializeField] private int floatingCount = 100;
|
||
[SerializeField] private int candidateCount = 50;
|
||
[SerializeField] private int redParticleCount = 8;
|
||
|
||
[Header("游戏参数")]
|
||
[SerializeField] private float connectionDistance = 2f; // Unity单位
|
||
[SerializeField] private float textMargin = 1f;
|
||
|
||
[Header("游戏配置")]
|
||
private List<string> anxietyPhrases = new List<string>();
|
||
private string targetSentence = string.Empty;
|
||
private IReadOnlyList<string> defaultCharacterPool = new List<string>().AsReadOnly();
|
||
private IReadOnlyList<string> interferenceCharacterPool = new List<string>().AsReadOnly();
|
||
[SerializeField] private string completionDialogNode = ""; // 完成时触发的对话节点
|
||
|
||
[Header("波形参数")]
|
||
[SerializeField] private float waveformAmplitude = 0.3f;
|
||
[SerializeField] private float interactionRadius = 0.5f;
|
||
|
||
[Header("鼠标交互参数")]
|
||
[SerializeField] private float mouseInteractionRadius = 1.5f;
|
||
[SerializeField] private float repulsionForce = 2f;
|
||
[SerializeField] private float attractionForce = 1.5f;
|
||
|
||
[Header("颜色配置(用于粒子/字的颜色,与目标/非目标解绑,可自由搭配)")]
|
||
[SerializeField] private Color targetParticleColor = new Color(0.627451f, 1f, 0.8470588f, 1f); // 目标粒子 #A0FFD8
|
||
[SerializeField] private Color nonTargetParticleColor = new Color(1f, 0.1882353f, 0.3019608f, 1f); // 干扰粒子固定红色 #FF304D
|
||
[SerializeField] private Color floatingTextColor = new Color(0.7f, 0.78f, 1f, 1f); // 背景浮动文字颜色
|
||
[SerializeField] private Color targetCalmColor = new Color(0.3607843f, 0.945098f, 0.8156863f, 1f); // 聚拢后的固定目标色 #5CF1D0
|
||
|
||
[Header("连接线颜色(用于连线的颜色,与上面可分开调节)")]
|
||
[SerializeField] private Color targetConnectionColor = new Color(0.627451f, 1f, 0.8470588f, 0.49f); // 目标连线 #A0FFD8
|
||
[SerializeField] private Color nonTargetConnectionColor = new Color(1f, 0.3137255f, 0.4392157f, 0.49f); // 干扰连线 #FF5070
|
||
|
||
[Header("文字样式配置")]
|
||
[SerializeField] private float targetParticleFontSize = 4.5f; // 目标粒子字体大小
|
||
[SerializeField] private float nonTargetParticleFontSize = 3.7f; // 非目标粒子字体大小
|
||
[SerializeField] private float floatingTextFontSize = 3f; // 背景浮动文字大小
|
||
[SerializeField] private bool targetParticleBold = true; // 目标粒子是否加粗
|
||
[SerializeField] private bool nonTargetParticleBold = false; // 非目标粒子是否加粗
|
||
[SerializeField, Range(0.1f, 2f)] private float whitespaceSpacingMultiplier = 0.6f;
|
||
|
||
[Header("非目标粒子干扰效果")]
|
||
[SerializeField, Range(0f, 0.1f)] private float nonTargetShakeIntensity = 0.02f; // 非目标粒子抖动强度
|
||
[SerializeField, Range(1f, 20f)] private float nonTargetShakeSpeed = 8f; // 非目标粒子抖动速度
|
||
[SerializeField, Range(0.1f, 1f)] private float nonTargetChangeInterval = 0.4f; // 非目标粒子变字间隔(越小越快)
|
||
[SerializeField, Range(0f, 0.03f)] private float nonTargetFloatAmplitude = 0.015f; // 非目标粒子漂浮幅度
|
||
[SerializeField] private Color[] nonTargetMarqueeColors =
|
||
{
|
||
// 保留单色数组以兼容旧的 "rainbow" 演出参数;屏幕粒子不再循环彩虹色。
|
||
new Color(1f, 0.1882353f, 0.3019608f, 1f) // #FF304D
|
||
};
|
||
[SerializeField, Min(0.02f)] private float nonTargetMarqueeStepDuration = 0.18f;
|
||
|
||
[Header("连接线粗细")]
|
||
[SerializeField] private float targetConnectionThickness = 0.04f; // 目标连线粗细
|
||
[SerializeField] private float nonTargetConnectionThickness = 0.02f; // 非目标连线粗细
|
||
|
||
[Header("连接线 Bloom 发光效果")]
|
||
[SerializeField] private bool enableBloomGlow = true; // 是否启用连接线 HDR 发光
|
||
[SerializeField, Range(1f, 5f)] private float lineGlowIntensity = 2f; // 连接线发光强度
|
||
[SerializeField, Range(1f, 5f)] private float targetLineGlowIntensity = 2.5f; // 目标连接线发光强度
|
||
|
||
[Header("目标 UI")]
|
||
[SerializeField] private TMP_Text integrationProgressText;
|
||
[SerializeField] private TMP_Text interferenceCountText;
|
||
|
||
[Header("完成/确认阶段 — 屏幕内显示范围")]
|
||
[Tooltip("为真时,完成连线后(含确认按钮、聚焦、稳定化)目标字符位置会被限制在裁剪区域内,避免超出电视机屏幕可视区")]
|
||
[SerializeField] private bool clampCompletionPhasePositions = true;
|
||
[Tooltip("可选:场景中一块与屏幕内框对齐的 RectTransform(World Space Canvas 子物体即可)。不指定则用粒子用的 movementBounds(canvasSize - textMargin)")]
|
||
[SerializeField] private RectTransform completionScreenClipRect;
|
||
[Tooltip("在裁剪区基础上再向内缩进的世界单位距离,避免放大后的字半个挂在边外")]
|
||
[SerializeField] private float completionClipEdgePadding = 0.35f;
|
||
[Tooltip("屏幕内容的最小安全边距;最终边距还会根据字形、光晕、圆环和晃动幅度动态增大。")]
|
||
[SerializeField, Min(0f)] private float minimumScreenMargin = 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;
|
||
[SerializeField] private ExpressionScreenPresentationController screenPresentation;
|
||
[Tooltip("开场表达panel Timeline 名称(DirectorName 或 DirectorName/AddressableKey),播完才算表达流程完成")]
|
||
[SerializeField] private string expressionPanelTimelineName = "火山表达模块/火山表达panel";
|
||
[Tooltip("火山表达确认 Timeline 名称,播完才解锁按钮交互")]
|
||
[SerializeField] private string expressionConfirmTimelineName = "火山表达模块/火山表达确认";
|
||
[SerializeField] private float focusZoomScale = 1.6f;
|
||
[SerializeField] private float focusMoveDuration = 1.2f;
|
||
[SerializeField] private float focusFadeDuration = 1f;
|
||
[SerializeField] private float logOperationFadeDuration = 0.6f;
|
||
[Tooltip("再次 start_expression 时,若上一轮粒子/UI 仍在,先淡出再重开;协程 duration≤0 时用此值(秒)")]
|
||
[SerializeField] private float restartExpressionFadeDuration = 0.55f;
|
||
[SerializeField] private float probabilityDuration = 3.5f;
|
||
[SerializeField] private float predictionSequenceGap = 0.4f;
|
||
[SerializeField] private float spreadDistance = 0.35f;
|
||
|
||
[Header("聚焦表现")]
|
||
[SerializeField] private float statusFadeOutDuration = 0.45f;
|
||
[SerializeField] private float shuffleDistance = 0.35f;
|
||
[SerializeField] private float shuffleFrequency = 3.5f;
|
||
[SerializeField] private float shuffleVerticalScale = 0.6f;
|
||
|
||
[Header("聚焦干扰表现")]
|
||
[SerializeField] private Color focusInterferenceColor = new Color(1f, 0.1882353f, 0.3019608f, 1f); // #FF304D
|
||
[SerializeField, Range(0.02f, 0.5f)] private float focusInterferenceShakeAmplitude = 0.16f;
|
||
[SerializeField, Range(1f, 60f)] private float focusInterferenceShakeSpeed = 24f;
|
||
|
||
[Header("真话攻击表现")]
|
||
[SerializeField] private Color truthAttackColor = new Color(1f, 0.1882353f, 0.3019608f, 1f); // #FF304D
|
||
[SerializeField, Min(0.1f)] private float truthAttackCharacterSpacing = 0.9f;
|
||
[SerializeField, Min(0f)] private float truthAttackTravelDistance = 0.75f;
|
||
|
||
[Header("开场表现")]
|
||
[SerializeField] private float entranceDelay = 0.45f;
|
||
[SerializeField] private int entranceCoreCount = 8;
|
||
[SerializeField] private float entranceCoreRadius = 0.55f;
|
||
[SerializeField] private float entranceInitialScale = 0.25f;
|
||
[SerializeField] private float entranceCoreBurstDuration = 1f;
|
||
[SerializeField] private float entranceCoreStagger = 0.08f;
|
||
[SerializeField] private float entranceCascadeDuration = 0.8f;
|
||
[SerializeField] private float entranceCascadeStagger = 0.03f;
|
||
[SerializeField] private float entranceSearchRadius = 2f;
|
||
|
||
[Header("渲染层设置")]
|
||
[SerializeField] private string renderLayerName = "HuoshanScreen";
|
||
private int renderLayer = 0;
|
||
private bool renderLayerResolved;
|
||
|
||
[Header("布局优化")]
|
||
[SerializeField] private bool enableSeparation = true; // 是否启用粒子分离力
|
||
[SerializeField] private float minParticleSpacing = 0.6f; // 粒子最小间距
|
||
[SerializeField] private float separationForce = 2.5f; // 分离力度
|
||
[SerializeField] private bool useGlyphBoundsForSeparation = true; // 字号较大时按实际字形扩大间距
|
||
[SerializeField, Min(0f)] private float separationPadding = 0.08f; // 字形外额外留白
|
||
[SerializeField, Min(1f)] private float differentTypeSpacingMultiplier = 1.2f;
|
||
[SerializeField, Min(1f)] private float differentTypeForceMultiplier = 1.75f;
|
||
[SerializeField, Range(0f, 1f)] private float separationPositionCorrection = 0.45f;
|
||
[SerializeField, Min(0.01f)] private float maxSeparationCorrectionPerFrame = 0.25f;
|
||
[SerializeField] private float lineAlphaMultiplier = 0.65f; // 连接线透明度缩放
|
||
|
||
// 粒子列表
|
||
private List<FloatingTextParticle> floatingParticles = new List<FloatingTextParticle>();
|
||
private List<CandidateParticle> candidateParticles = new List<CandidateParticle>();
|
||
private List<CandidateParticle> targetParticles = new List<CandidateParticle>(); // 目标粒子列表(原 redParticles)
|
||
|
||
// 效果系统(传播效果已移除,保留空列表供渲染器使用)
|
||
private static readonly List<EffectPropagation> s_emptyPropagations = new List<EffectPropagation>();
|
||
private Dictionary<string, bool> previousConnections = new Dictionary<string, bool>();
|
||
private bool initializationComplete = false;
|
||
private int initializationFrames = 0;
|
||
|
||
// 游戏状态
|
||
private bool isCompleted = false;
|
||
private CompletionPhase completionPhase = CompletionPhase.None;
|
||
private float completionTimer = 0f;
|
||
private float fadeOutAlpha = 1f;
|
||
private List<CandidateParticle> orderedRedParticles = new List<CandidateParticle>();
|
||
private bool allowInput = true;
|
||
private float focusTimer = 0f;
|
||
private Dictionary<CandidateParticle, TargetFocusVisual> focusVisuals = new Dictionary<CandidateParticle, TargetFocusVisual>();
|
||
/// <summary>完成阶段屏幕钳制用 SmoothDamp 速度缓存(按粒子)</summary>
|
||
private readonly Dictionary<CandidateParticle, Vector3> completionClampSmoothVelocity = new Dictionary<CandidateParticle, Vector3>();
|
||
private Vector3 focusCentroid;
|
||
private List<string> finalTargetCharacters = new List<string>();
|
||
private List<Vector3> finalTargetPositions = new List<Vector3>();
|
||
private bool interferenceLogicSuspended = false;
|
||
private bool statusUIHidden = false;
|
||
private float integrationTextBaseAlpha = 1f;
|
||
private float interferenceTextBaseAlpha = 1f;
|
||
private Coroutine entranceRoutine;
|
||
private Coroutine expressionPanelRoutine;
|
||
private Coroutine confirmTimelineRoutine;
|
||
private Coroutine focusTransitionRoutine;
|
||
private Coroutine slotMachineRoutine;
|
||
private readonly Dictionary<CandidateParticle, Vector3> entranceTargets = new Dictionary<CandidateParticle, Vector3>();
|
||
private bool pendingCompletionDialog;
|
||
|
||
private class TargetFocusVisual
|
||
{
|
||
public CandidateParticle particle;
|
||
public float startShakeIntensity;
|
||
public string finalChar;
|
||
public bool stabilized;
|
||
public Vector3 focusPosition;
|
||
public Vector3 finalPosition;
|
||
public Vector3 lieEdgePosition;
|
||
public int orderIndex;
|
||
public bool gatherLocked;
|
||
public Vector3 scatterOffset;
|
||
}
|
||
|
||
private bool focusTweensCompleted = false;
|
||
private bool focusMoveTweensCompleted;
|
||
private float resolveTotalDurationOverride = -1f;
|
||
private float lieEdgeCompression;
|
||
private float lieEdgeJitter;
|
||
private float truthGatherProgress;
|
||
private LogReleasePresentationController presentationController;
|
||
private const string IntegrationLocalizationKey = "huoshan_expression_integration";
|
||
private const string InterferenceLocalizationKey = "huoshan_expression_interference";
|
||
private string integrationStatusFormat = "{0}% 语言整合完成度";
|
||
private string interferenceStatusFormat = "{0} 处异常思绪仍在干扰表达";
|
||
private Coroutine statusLocalizationRoutine;
|
||
private const string LieCompressionTweenId = "HuoshanLieEdgeCompression";
|
||
private const string TruthGatherTweenId = "HuoshanTruthGather";
|
||
private const string FocusInterferenceTweenId = "HuoshanFocusInterference";
|
||
private int truthAttackSequenceIndex;
|
||
|
||
// 边界
|
||
private Bounds movementBounds;
|
||
private Vector3 boundsCenter;
|
||
private float boundsRadius; // 圆形时有效
|
||
|
||
// 连接渲染(使用Shapes)
|
||
private ConnectionRenderer connectionRenderer;
|
||
|
||
// 噪点叠加渲染器(复古显示器效果)
|
||
private NoiseOverlayRenderer noiseOverlayRenderer;
|
||
|
||
[Header("复古显示器效果")]
|
||
[SerializeField] private bool enableRetroEffect = true;
|
||
[SerializeField, Range(0f, 1f)] private float retroEffectIntensity = 0.5f;
|
||
|
||
// 是否已经初始化
|
||
private bool isInitialized = false;
|
||
|
||
/// <summary>Inspector 中的候选粒子总数,用于未在 Yarn 中指定非目标数量时恢复默认</summary>
|
||
private int defaultCandidateCountFromInspector;
|
||
private bool hasCapturedDefaultCandidateCount;
|
||
|
||
private void CaptureDefaultCandidateCountFromInspector()
|
||
{
|
||
if (hasCapturedDefaultCandidateCount) return;
|
||
defaultCandidateCountFromInspector = candidateCount;
|
||
hasCapturedDefaultCandidateCount = true;
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
ResolveScreenPresentation();
|
||
LocalizationSettings.SelectedLocaleChanged += HandleLocaleChanged;
|
||
statusLocalizationRoutine = StartCoroutine(RefreshStatusLocalization());
|
||
// Start 中不再自动初始化,等待外部调用 InitializeSystem
|
||
}
|
||
|
||
private void ResolveScreenPresentation()
|
||
{
|
||
if (screenPresentation == null)
|
||
screenPresentation = GetComponentInParent<ExpressionScreenPresentationController>(true);
|
||
}
|
||
|
||
private void HandleLocaleChanged(Locale locale)
|
||
{
|
||
if (!isActiveAndEnabled)
|
||
return;
|
||
|
||
if (statusLocalizationRoutine != null)
|
||
StopCoroutine(statusLocalizationRoutine);
|
||
statusLocalizationRoutine = StartCoroutine(RefreshStatusLocalization());
|
||
}
|
||
|
||
private IEnumerator RefreshStatusLocalization()
|
||
{
|
||
yield return LocalizationSettings.InitializationOperation;
|
||
|
||
var integrationOperation = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(
|
||
ConstRef.UITextTable,
|
||
IntegrationLocalizationKey);
|
||
yield return integrationOperation;
|
||
if (!string.IsNullOrEmpty(integrationOperation.Result))
|
||
integrationStatusFormat = integrationOperation.Result;
|
||
|
||
var interferenceOperation = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(
|
||
ConstRef.UITextTable,
|
||
InterferenceLocalizationKey);
|
||
yield return interferenceOperation;
|
||
if (!string.IsNullOrEmpty(interferenceOperation.Result))
|
||
interferenceStatusFormat = interferenceOperation.Result;
|
||
|
||
statusLocalizationRoutine = null;
|
||
if (isInitialized)
|
||
UpdateStatusUI();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 初始化系统(可以由外部调用,用于配置游戏参数)
|
||
/// </summary>
|
||
/// <param name="phrases">焦虑短句列表</param>
|
||
/// <param name="sentence">目标句子</param>
|
||
/// <param name="localizedDefaultPool">当前 Locale 的默认随机字符池</param>
|
||
/// <param name="dialogNode">完成时触发的对话节点</param>
|
||
/// <param name="nonTargetParticleTotal">
|
||
/// 非目标候选粒子总数(蓝/干扰侧可交互粒子数量)。≥0 时候选池大小 = 目标句字数 + 该值;<0 时使用 Inspector 的 Candidate Count。
|
||
/// </param>
|
||
public void InitializeSystem(
|
||
List<string> phrases,
|
||
string sentence,
|
||
IReadOnlyList<string> localizedDefaultPool,
|
||
string dialogNode = null,
|
||
int nonTargetParticleTotal = -1)
|
||
{
|
||
CaptureDefaultCandidateCountFromInspector();
|
||
|
||
if (phrases == null || phrases.Count == 0 || string.IsNullOrWhiteSpace(sentence))
|
||
{
|
||
Debug.LogError(
|
||
"[LanguageParticleManager] 目标文字或干扰 token 为空;初始化已中止。");
|
||
return;
|
||
}
|
||
|
||
List<string> resolvedDefaultPool =
|
||
ExpressionTextTokenizer.BuildCharacterPool(localizedDefaultPool);
|
||
List<string> resolvedInterferencePool =
|
||
ExpressionTextTokenizer.BuildCharacterPool(phrases);
|
||
if (resolvedDefaultPool.Count == 0 || resolvedInterferencePool.Count == 0)
|
||
{
|
||
Debug.LogError(
|
||
"[LanguageParticleManager] 默认字符池或干扰字符池没有有效的 Unicode 文本元素;初始化已中止。");
|
||
return;
|
||
}
|
||
|
||
string useSentence = sentence;
|
||
int visibleTargetCount = ExpressionTextTokenizer.GetVisibleElements(useSentence).Count;
|
||
if (visibleTargetCount == 0)
|
||
{
|
||
Debug.LogError("[LanguageParticleManager] 目标句没有可显示的 Unicode 文本元素;初始化已中止。");
|
||
return;
|
||
}
|
||
|
||
if (nonTargetParticleTotal >= 0)
|
||
{
|
||
candidateCount = Mathf.Max(
|
||
visibleTargetCount + nonTargetParticleTotal,
|
||
visibleTargetCount);
|
||
}
|
||
else
|
||
{
|
||
candidateCount = defaultCandidateCountFromInspector;
|
||
}
|
||
|
||
// 在创建或复用粒子前先提交本轮不可变快照,防止首帧或上一轮字符泄漏。
|
||
anxietyPhrases = new List<string>(phrases);
|
||
targetSentence = useSentence;
|
||
completionDialogNode = dialogNode ?? "";
|
||
defaultCharacterPool = resolvedDefaultPool.AsReadOnly();
|
||
interferenceCharacterPool = resolvedInterferencePool.AsReadOnly();
|
||
|
||
// 如果是第一次初始化,先执行基础初始化
|
||
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);
|
||
ApplyCharacterPoolsToParticles();
|
||
}
|
||
|
||
// 根据目标句子更新目标粒子数量
|
||
redParticleCount = Mathf.Min(visibleTargetCount, candidateCount);
|
||
|
||
// 重新初始化游戏状态并播放入场动画
|
||
ResetGame(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在再次 <c>start_expression</c> 前调用:若系统已初始化过,则淡出当前粒子、连线与相关 UI,避免与新一轮重叠。
|
||
/// duration≤0 时使用 <see cref="restartExpressionFadeDuration"/>。
|
||
/// </summary>
|
||
public IEnumerator FadeOutBeforeNewExpressionRound(float duration)
|
||
{
|
||
if (!isInitialized)
|
||
yield break;
|
||
|
||
ResolveScreenPresentation();
|
||
screenPresentation?.PrepareForNewRound();
|
||
StopFocusTransitionRoutine();
|
||
KillCompletionUiTweens();
|
||
resolveTotalDurationOverride = -1f;
|
||
float d = duration > 0f ? duration : restartExpressionFadeDuration;
|
||
|
||
// 火山释放 log 与文字同时叠在屏幕上时,先把 log 藏起来,等文字淡出结束后再由 FadeInReleaseLogIfNeeded 出现
|
||
HideReleaseLogImmediate();
|
||
|
||
if (entranceRoutine != null)
|
||
{
|
||
StopCoroutine(entranceRoutine);
|
||
entranceRoutine = null;
|
||
}
|
||
if (expressionPanelRoutine != null)
|
||
{
|
||
StopCoroutine(expressionPanelRoutine);
|
||
expressionPanelRoutine = null;
|
||
}
|
||
if (confirmTimelineRoutine != null)
|
||
{
|
||
StopCoroutine(confirmTimelineRoutine);
|
||
confirmTimelineRoutine = null;
|
||
}
|
||
|
||
DOTween.Kill(this);
|
||
foreach (var p in candidateParticles)
|
||
{
|
||
if (p != null)
|
||
{
|
||
DOTween.Kill(p);
|
||
p.transform.DOKill();
|
||
p.velocity = Vector2.zero;
|
||
p.isStatic = true;
|
||
}
|
||
}
|
||
foreach (var f in floatingParticles)
|
||
{
|
||
if (f != null)
|
||
{
|
||
DOTween.Kill(f);
|
||
f.transform.DOKill();
|
||
f.velocity = Vector2.zero;
|
||
f.isCalmed = true;
|
||
}
|
||
}
|
||
|
||
interferenceLogicSuspended = true;
|
||
allowInput = false;
|
||
isCompleted = false;
|
||
completionPhase = CompletionPhase.None;
|
||
|
||
DestroyFocusVisualDecorationsAndClear();
|
||
|
||
DOTween.To(() => fadeOutAlpha, v => fadeOutAlpha = v, 0f, d)
|
||
.SetEase(Ease.OutQuad)
|
||
.SetTarget(this);
|
||
|
||
if (connectionRenderer != null)
|
||
{
|
||
DOTween.To(() => connectionRenderer.NonTargetAlphaScale, v => connectionRenderer.NonTargetAlphaScale = v, 0f, d)
|
||
.SetEase(Ease.OutQuad)
|
||
.SetTarget(this);
|
||
DOTween.To(() => connectionRenderer.TargetAlphaScale, v => connectionRenderer.TargetAlphaScale = v, 0f, d)
|
||
.SetEase(Ease.OutQuad)
|
||
.SetTarget(this);
|
||
}
|
||
|
||
foreach (var p in candidateParticles)
|
||
{
|
||
if (p != null)
|
||
FadeOutTextParticle(p, d);
|
||
}
|
||
foreach (var f in floatingParticles)
|
||
{
|
||
if (f != null)
|
||
FadeOutTextParticle(f, d);
|
||
}
|
||
|
||
SetStatusUIVisibility(false, d);
|
||
if (focusSequenceButton != null && focusSequenceButton.gameObject.activeSelf)
|
||
FadeOutUIElement(focusSequenceButton.gameObject, d);
|
||
FadeOutUIElement(languageDeepPanel1, d);
|
||
FadeOutUIElement(languageDeepPanel2, d);
|
||
|
||
yield return new WaitForSeconds(d);
|
||
fadeOutAlpha = 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在打开视图前设置所有初始状态(panels、button 等不激活)
|
||
/// </summary>
|
||
public void SetInitialStatesBeforeOpen()
|
||
{
|
||
RestoreCompletionUiAlpha();
|
||
if (languageDeepPanel1 != null)
|
||
languageDeepPanel1.SetActive(false);
|
||
if (languageDeepPanel2 != null)
|
||
languageDeepPanel2.SetActive(false);
|
||
if (focusSequenceButton != null)
|
||
{
|
||
focusSequenceButton.gameObject.SetActive(false);
|
||
focusSequenceButton.interactable = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 等待表达流程就绪(粒子字入场完成即可交互;panel 动效可并行、不阻塞)
|
||
/// </summary>
|
||
public IEnumerator WaitUntilExpressionFlowReady()
|
||
{
|
||
if (!isInitialized) yield break;
|
||
while (!allowInput)
|
||
{
|
||
yield return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 停止系统(关闭时调用,但保持初始化状态,可以重新开启)
|
||
/// </summary>
|
||
public void StopSystem()
|
||
{
|
||
// 如果未初始化,直接返回
|
||
if (!isInitialized)
|
||
return;
|
||
|
||
StopFocusTransitionRoutine();
|
||
KillCompletionUiTweens();
|
||
RestoreTargetPresentationDefaults();
|
||
|
||
if (entranceRoutine != null)
|
||
{
|
||
StopCoroutine(entranceRoutine);
|
||
entranceRoutine = null;
|
||
}
|
||
|
||
if (confirmTimelineRoutine != null)
|
||
{
|
||
StopCoroutine(confirmTimelineRoutine);
|
||
confirmTimelineRoutine = null;
|
||
}
|
||
|
||
if (expressionPanelRoutine != null)
|
||
{
|
||
StopCoroutine(expressionPanelRoutine);
|
||
expressionPanelRoutine = null;
|
||
}
|
||
|
||
// 停止所有 DOTween 动画
|
||
DOTween.Kill(this);
|
||
foreach (var particle in candidateParticles)
|
||
{
|
||
if (particle != null)
|
||
{
|
||
DOTween.Kill(particle);
|
||
particle.transform.DOKill();
|
||
}
|
||
}
|
||
foreach (var particle in floatingParticles)
|
||
{
|
||
if (particle != null)
|
||
{
|
||
DOTween.Kill(particle);
|
||
particle.transform.DOKill();
|
||
}
|
||
}
|
||
|
||
// 重置游戏状态(但不重置 isInitialized,这样可以重新开启)
|
||
isCompleted = false;
|
||
completionPhase = CompletionPhase.None;
|
||
allowInput = false;
|
||
ClearCompletionClampSmoothVelocity();
|
||
ReleaseScreenClipMaterials();
|
||
}
|
||
|
||
private void ResolveRenderLayer()
|
||
{
|
||
renderLayer = LayerMask.NameToLayer(renderLayerName);
|
||
if (renderLayer == -1)
|
||
{
|
||
Debug.LogWarning($"Layer '{renderLayerName}' not found, using default layer.");
|
||
renderLayer = 0;
|
||
}
|
||
renderLayerResolved = true;
|
||
}
|
||
|
||
private static void ApplyLayerRecursively(GameObject root, int layer)
|
||
{
|
||
if (root == null) return;
|
||
root.layer = layer;
|
||
var transforms = root.GetComponentsInChildren<Transform>(true);
|
||
for (int i = 0; i < transforms.Length; i++)
|
||
transforms[i].gameObject.layer = layer;
|
||
}
|
||
|
||
private void ApplyParticleRenderLayer(GameObject particleRoot)
|
||
{
|
||
ApplyLayerRecursively(particleRoot, renderLayer);
|
||
}
|
||
|
||
private void InitializeCanvas()
|
||
{
|
||
ResolveRenderLayer();
|
||
// 管理器自身也进 HuoshanScreen,避免子物体(连线/噪点)继承 Default
|
||
gameObject.layer = renderLayer;
|
||
|
||
// 如果没有指定canvas,创建一个
|
||
if (worldCanvas == null)
|
||
{
|
||
GameObject canvasObj = new GameObject("Language Canvas");
|
||
canvasObj.transform.SetParent(transform);
|
||
canvasObj.transform.localPosition = Vector3.zero;
|
||
ApplyLayerRecursively(canvasObj, renderLayer);
|
||
worldCanvas = canvasObj.AddComponent<Canvas>();
|
||
worldCanvas.renderMode = RenderMode.WorldSpace;
|
||
|
||
RectTransform rectTransform = canvasObj.GetComponent<RectTransform>();
|
||
rectTransform.sizeDelta = new Vector2(canvasSize, canvasSize);
|
||
}
|
||
else
|
||
{
|
||
// 设置已有 Canvas 整棵树到渲染层(含已有 TMP / 粒子)
|
||
ApplyLayerRecursively(worldCanvas.gameObject, renderLayer);
|
||
}
|
||
|
||
// 确保 Canvas 配置正确
|
||
if (worldCanvas != null)
|
||
{
|
||
RectTransform rectTransform = worldCanvas.GetComponent<RectTransform>();
|
||
if (rectTransform != null)
|
||
rectTransform.sizeDelta = new Vector2(canvasSize, canvasSize);
|
||
|
||
// 设置 WorldSpace Canvas 的相机
|
||
if (worldCanvas.worldCamera == null)
|
||
{
|
||
worldCanvas.worldCamera = Camera.main;
|
||
}
|
||
}
|
||
|
||
// 设置运动边界
|
||
boundsCenter = worldCanvas.transform.position;
|
||
float halfSize = canvasSize / 2f - textMargin;
|
||
if (layoutShape == LayoutShape.Circle)
|
||
{
|
||
boundsRadius = circleRadiusOverride > 0 ? circleRadiusOverride : halfSize;
|
||
movementBounds = new Bounds(boundsCenter, new Vector3(boundsRadius * 2f, boundsRadius * 2f, 0f));
|
||
}
|
||
else
|
||
{
|
||
boundsRadius = 0f;
|
||
movementBounds = new Bounds(
|
||
boundsCenter,
|
||
new Vector3(halfSize * 2f, halfSize * 2f, 0f)
|
||
);
|
||
}
|
||
}
|
||
|
||
private void InitializeParticles()
|
||
{
|
||
// 创建背景浮动文字
|
||
for (int i = 0; i < floatingCount; i++)
|
||
{
|
||
Vector3 pos = GetRandomPositionInBounds();
|
||
GameObject obj = CreateParticleObject(pos, $"FloatingParticle_{i}");
|
||
FloatingTextParticle particle = obj.AddComponent<FloatingTextParticle>();
|
||
particle.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool);
|
||
particle.SetFont(chineseFontAsset, chineseFontMaterial);
|
||
particle.SetColor(floatingTextColor);
|
||
particle.SetFontSize(floatingTextFontSize);
|
||
SetParticleBounds(particle);
|
||
ConfigureParticleScreenMask(particle);
|
||
ApplyParticleRenderLayer(obj);
|
||
|
||
floatingParticles.Add(particle);
|
||
}
|
||
|
||
// 创建候选粒子
|
||
for (int i = 0; i < candidateCount; i++)
|
||
{
|
||
AddOneCandidateParticle(i);
|
||
}
|
||
|
||
// 选择红色粒子
|
||
SelectRedParticles();
|
||
}
|
||
|
||
private GameObject CreateParticleObject(Vector3 position, string name)
|
||
{
|
||
if (!renderLayerResolved)
|
||
ResolveRenderLayer();
|
||
|
||
GameObject obj = new GameObject(name);
|
||
obj.layer = renderLayer;
|
||
|
||
// 添加 RectTransform(在 Canvas 下必需)
|
||
RectTransform rectTransform = obj.AddComponent<RectTransform>();
|
||
rectTransform.SetParent(worldCanvas.transform, false);
|
||
rectTransform.position = position;
|
||
rectTransform.localScale = Vector3.one;
|
||
rectTransform.sizeDelta = Vector2.zero; // 父对象不需要大小
|
||
ApplyParticleRenderLayer(obj);
|
||
|
||
return obj;
|
||
}
|
||
|
||
private void AddOneCandidateParticle(int index)
|
||
{
|
||
Vector3 pos = GetRandomPositionInBounds();
|
||
GameObject obj = CreateParticleObject(pos, $"CandidateParticle_{index}");
|
||
CandidateParticle particle = obj.AddComponent<CandidateParticle>();
|
||
particle.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool);
|
||
particle.SetFont(chineseFontAsset, chineseFontMaterial);
|
||
particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor);
|
||
particle.SetNonTargetMarquee(
|
||
nonTargetMarqueeColors,
|
||
index,
|
||
nonTargetMarqueeStepDuration);
|
||
particle.SetFontStyles(targetParticleFontSize, nonTargetParticleFontSize, targetParticleBold, nonTargetParticleBold);
|
||
particle.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude);
|
||
SetParticleBounds(particle);
|
||
ConfigureParticleScreenMask(particle);
|
||
// AddComponent / SetFont 可能生成 Text、Glow、TMP SubMesh,再刷一遍 layer
|
||
ApplyParticleRenderLayer(obj);
|
||
|
||
candidateParticles.Add(particle);
|
||
}
|
||
|
||
private void ApplyCharacterPoolsToParticles()
|
||
{
|
||
foreach (CandidateParticle particle in candidateParticles)
|
||
particle?.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool);
|
||
foreach (FloatingTextParticle particle in floatingParticles)
|
||
particle?.SetCharacterPools(defaultCharacterPool, interferenceCharacterPool);
|
||
}
|
||
|
||
private void RefreshAllRandomCharacters()
|
||
{
|
||
foreach (CandidateParticle particle in candidateParticles)
|
||
particle?.InitializeRandomCharacter();
|
||
foreach (FloatingTextParticle particle in floatingParticles)
|
||
particle?.InitializeRandomCharacter();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 增删候选粒子以匹配目标数量(用于 Yarn 动态指定非目标粒子总数后复用同一视图)
|
||
/// </summary>
|
||
private void EnsureCandidatePoolSize(int targetSize)
|
||
{
|
||
if (targetSize < 0) targetSize = 0;
|
||
candidateCount = targetSize;
|
||
|
||
while (candidateParticles.Count < targetSize)
|
||
{
|
||
AddOneCandidateParticle(candidateParticles.Count);
|
||
}
|
||
|
||
while (candidateParticles.Count > targetSize)
|
||
{
|
||
int last = candidateParticles.Count - 1;
|
||
CandidateParticle p = candidateParticles[last];
|
||
candidateParticles.RemoveAt(last);
|
||
if (p != null)
|
||
{
|
||
p.transform.DOKill();
|
||
Destroy(p.gameObject);
|
||
}
|
||
}
|
||
}
|
||
|
||
private Vector3 GetRandomPositionInBounds()
|
||
{
|
||
if (layoutShape == LayoutShape.Circle)
|
||
{
|
||
Vector2 offset = Random.insideUnitCircle * boundsRadius;
|
||
return boundsCenter + new Vector3(offset.x, offset.y, 0f);
|
||
}
|
||
float x = Random.Range(movementBounds.min.x, movementBounds.max.x);
|
||
float y = Random.Range(movementBounds.min.y, movementBounds.max.y);
|
||
return new Vector3(x, y, 0f);
|
||
}
|
||
|
||
private void SetParticleBounds(TextParticle particle)
|
||
{
|
||
particle.SetMovementBounds(movementBounds);
|
||
if (layoutShape == LayoutShape.Circle)
|
||
particle.SetCircularBounds(boundsCenter, boundsRadius);
|
||
}
|
||
|
||
private void ConfigureParticleScreenMask(TextParticle particle)
|
||
{
|
||
if (particle == null)
|
||
return;
|
||
|
||
particle.SetScreenClipRect(completionScreenClipRect);
|
||
}
|
||
|
||
private void InitializeRenderers()
|
||
{
|
||
// 连接线渲染器
|
||
GameObject connObj = new GameObject("ConnectionRenderer");
|
||
connObj.transform.SetParent(transform);
|
||
connObj.transform.localPosition = Vector3.zero;
|
||
ApplyLayerRecursively(connObj, renderLayer);
|
||
connectionRenderer = connObj.AddComponent<ConnectionRenderer>();
|
||
connectionRenderer.manager = this;
|
||
connectionRenderer.connectionDistance = connectionDistance;
|
||
connectionRenderer.SetConnectionStyles(
|
||
targetConnectionColor,
|
||
nonTargetConnectionColor,
|
||
nonTargetConnectionColor,
|
||
targetConnectionThickness,
|
||
nonTargetConnectionThickness,
|
||
nonTargetConnectionThickness
|
||
);
|
||
connectionRenderer.NonTargetAlphaScale = lineAlphaMultiplier;
|
||
connectionRenderer.TargetAlphaScale = 1f;
|
||
|
||
// 设置连接线发光效果
|
||
if (enableBloomGlow)
|
||
{
|
||
connectionRenderer.SetGlowSettings(
|
||
true,
|
||
targetLineGlowIntensity,
|
||
lineGlowIntensity,
|
||
lineGlowIntensity
|
||
);
|
||
}
|
||
|
||
// 噪点叠加渲染器(复古显示器效果)
|
||
if (enableRetroEffect)
|
||
{
|
||
GameObject noiseObj = new GameObject("NoiseOverlayRenderer");
|
||
noiseObj.transform.SetParent(transform);
|
||
noiseObj.transform.localPosition = Vector3.zero;
|
||
ApplyLayerRecursively(noiseObj, renderLayer);
|
||
noiseOverlayRenderer = noiseObj.AddComponent<NoiseOverlayRenderer>();
|
||
|
||
// 设置效果区域为 Canvas 区域
|
||
Vector3 center = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
|
||
noiseOverlayRenderer.SetArea(center, new Vector2(canvasSize, canvasSize));
|
||
noiseOverlayRenderer.SetIntensity(retroEffectIntensity);
|
||
}
|
||
}
|
||
|
||
private void SelectRedParticles()
|
||
{
|
||
targetParticles.Clear();
|
||
List<CandidateParticle> shuffled = candidateParticles.OrderBy(x => Random.value).ToList();
|
||
float effectiveSize = layoutShape == LayoutShape.Circle
|
||
? boundsRadius * 2f
|
||
: Mathf.Min(movementBounds.size.x, movementBounds.size.y);
|
||
float minDistance = effectiveSize / (redParticleCount * 0.8f);
|
||
|
||
foreach (var particle in shuffled)
|
||
{
|
||
if (targetParticles.Count >= redParticleCount) break;
|
||
|
||
bool tooClose = false;
|
||
foreach (var selected in targetParticles)
|
||
{
|
||
if (Vector3.Distance(particle.transform.position, selected.transform.position) < minDistance)
|
||
{
|
||
tooClose = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!tooClose || targetParticles.Count == 0)
|
||
{
|
||
particle.isRed = true;
|
||
particle.originalIsRed = true;
|
||
targetParticles.Add(particle);
|
||
|
||
}
|
||
}
|
||
|
||
// 如果不够,强制添加
|
||
int remaining = redParticleCount - targetParticles.Count;
|
||
for (int i = 0; i < remaining && i < shuffled.Count; i++)
|
||
{
|
||
if (!shuffled[i].isRed)
|
||
{
|
||
shuffled[i].isRed = true;
|
||
shuffled[i].originalIsRed = true;
|
||
targetParticles.Add(shuffled[i]);
|
||
|
||
}
|
||
}
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
// 如果系统未初始化,不执行任何逻辑
|
||
if (!isInitialized)
|
||
return;
|
||
|
||
FlushPendingCompletionDialog();
|
||
|
||
// 初始化检查
|
||
if (!initializationComplete)
|
||
{
|
||
initializationFrames++;
|
||
if (initializationFrames >= 30)
|
||
{
|
||
initializationComplete = true;
|
||
}
|
||
else if (initializationFrames >= 20)
|
||
{
|
||
// 建立初始连接状态
|
||
BuildConnectionMap();
|
||
}
|
||
}
|
||
UpdateConnections();
|
||
if (!statusUIHidden)
|
||
{
|
||
UpdateStatusUI();
|
||
}
|
||
|
||
if (completionPhase == CompletionPhase.None)
|
||
{
|
||
if (initializationComplete && !interferenceLogicSuspended)
|
||
{
|
||
DetectConnectionChanges();
|
||
}
|
||
|
||
if (!isCompleted && CheckRedParticlesConnected())
|
||
{
|
||
isCompleted = true;
|
||
OnTargetsCompleted();
|
||
}
|
||
|
||
if (allowInput && !interferenceLogicSuspended)
|
||
{
|
||
UpdateInput();
|
||
}
|
||
|
||
if (!interferenceLogicSuspended)
|
||
{
|
||
ApplySeparationForces();
|
||
}
|
||
}
|
||
|
||
if (completionPhase == CompletionPhase.FocusHolding)
|
||
{
|
||
UpdateFocusHolding();
|
||
}
|
||
else if (completionPhase == CompletionPhase.Focusing)
|
||
{
|
||
UpdateFocusSequence();
|
||
}
|
||
UpdateRenderers();
|
||
}
|
||
|
||
private void LateUpdate()
|
||
{
|
||
if (!isInitialized || !clampCompletionPhasePositions || !isCompleted)
|
||
return;
|
||
if (completionPhase == CompletionPhase.None)
|
||
return;
|
||
// 连线刚完成、等待确认时保持粒子当前世界坐标,避免 GetCompletionClipBounds(含 padding/屏幕 Rect)
|
||
// 比游玩用 movementBounds 更紧时,在 Completed 首帧 LateUpdate 产生生硬瞬移。
|
||
if (completionPhase == CompletionPhase.Completed)
|
||
return;
|
||
|
||
Bounds clip = GetCompletionClipBounds();
|
||
if (clip.size.x < 0.01f || clip.size.y < 0.01f)
|
||
return;
|
||
|
||
float dt = Time.deltaTime;
|
||
if (dt < 1e-6f)
|
||
dt = 1e-6f;
|
||
float smooth = Mathf.Max(0.0001f, completionPositionSmoothTime);
|
||
|
||
for (int i = 0; i < targetParticles.Count; i++)
|
||
{
|
||
var p = targetParticles[i];
|
||
if (p == null) continue;
|
||
Vector3 cur = p.transform.position;
|
||
Vector3 desired = ClampPositionToBounds(cur, clip);
|
||
if (!completionClampSmoothVelocity.TryGetValue(p, out Vector3 vel))
|
||
vel = Vector3.zero;
|
||
Vector3 next = Vector3.SmoothDamp(cur, desired, ref vel, smooth, Mathf.Infinity, dt);
|
||
completionClampSmoothVelocity[p] = vel;
|
||
p.transform.position = next;
|
||
}
|
||
}
|
||
|
||
private void ClearCompletionClampSmoothVelocity()
|
||
{
|
||
completionClampSmoothVelocity.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 完成阶段使用的裁剪范围:优先用 Inspector 指定的屏幕 Rect,否则与粒子运动边界一致。
|
||
/// </summary>
|
||
private Bounds GetCompletionClipBounds()
|
||
{
|
||
Bounds b = GetScreenWorldBounds();
|
||
Vector2 safetyPadding = CalculateScreenSafetyPadding();
|
||
if (safetyPadding.sqrMagnitude > 0f)
|
||
{
|
||
Bounds shrunk = b;
|
||
shrunk.Expand(new Vector3(-safetyPadding.x * 2f, -safetyPadding.y * 2f, 0f));
|
||
if (shrunk.size.x >= 0.01f && shrunk.size.y >= 0.01f)
|
||
b = shrunk;
|
||
}
|
||
|
||
return b;
|
||
}
|
||
|
||
public Bounds GetScreenWorldBounds()
|
||
{
|
||
Bounds b;
|
||
if (completionScreenClipRect != null)
|
||
{
|
||
var corners = new Vector3[4];
|
||
completionScreenClipRect.GetWorldCorners(corners);
|
||
float minX = float.MaxValue, maxX = float.MinValue;
|
||
float minY = float.MaxValue, maxY = float.MinValue;
|
||
for (int c = 0; c < 4; c++)
|
||
{
|
||
minX = Mathf.Min(minX, corners[c].x);
|
||
maxX = Mathf.Max(maxX, corners[c].x);
|
||
minY = Mathf.Min(minY, corners[c].y);
|
||
maxY = Mathf.Max(maxY, corners[c].y);
|
||
}
|
||
float z = corners[0].z;
|
||
Vector3 center = new Vector3((minX + maxX) * 0.5f, (minY + maxY) * 0.5f, z);
|
||
Vector3 size = new Vector3(Mathf.Max(0f, maxX - minX), Mathf.Max(0f, maxY - minY), 0f);
|
||
b = new Bounds(center, size);
|
||
}
|
||
else
|
||
{
|
||
b = movementBounds;
|
||
}
|
||
|
||
return b;
|
||
}
|
||
|
||
private Vector2 CalculateScreenSafetyPadding()
|
||
{
|
||
float padding = Mathf.Max(minimumScreenMargin, completionClipEdgePadding);
|
||
float glyphExtent = 0f;
|
||
float maxScale = 1f;
|
||
|
||
foreach (CandidateParticle particle in targetParticles)
|
||
{
|
||
if (particle == null)
|
||
continue;
|
||
|
||
maxScale = Mathf.Max(maxScale, particle.transform.localScale.x, particle.transform.localScale.y);
|
||
|
||
if (particle.textMesh != null && particle.textMesh.renderer != null)
|
||
{
|
||
Bounds textBounds = particle.textMesh.renderer.bounds;
|
||
glyphExtent = Mathf.Max(glyphExtent, textBounds.extents.x, textBounds.extents.y);
|
||
}
|
||
}
|
||
|
||
// renderer.bounds 已含当前缩放;未放大到 focusZoom 时再预留缩放余量,避免重复乘 zoom
|
||
float resolvedGlyph = maxScale >= focusZoomScale * 0.9f
|
||
? glyphExtent
|
||
: glyphExtent * Mathf.Max(1f, focusZoomScale);
|
||
// 抖动/漂浮是局部偏移,硬钳制根节点后仍可能露出字形一角,留一点余量即可
|
||
float floatAllowance = Mathf.Max(0.04f, focusInterferenceShakeAmplitude * 0.35f);
|
||
|
||
float horizontal = padding + resolvedGlyph + floatAllowance;
|
||
return new Vector2(horizontal, horizontal);
|
||
}
|
||
|
||
private static Vector3 ClampPositionToBounds(Vector3 worldPos, Bounds b)
|
||
{
|
||
return new Vector3(
|
||
Mathf.Clamp(worldPos.x, b.min.x, b.max.x),
|
||
Mathf.Clamp(worldPos.y, b.min.y, b.max.y),
|
||
worldPos.z
|
||
);
|
||
}
|
||
|
||
private void UpdateRenderers()
|
||
{
|
||
// 更新连接线渲染器
|
||
if (connectionRenderer != null)
|
||
{
|
||
Bounds screenBounds = GetScreenWorldBounds();
|
||
connectionRenderer.SetScreenBounds(
|
||
screenBounds,
|
||
completionScreenClipRect != null || (screenBounds.size.x > 0.001f && screenBounds.size.y > 0.001f));
|
||
connectionRenderer.SetScreenClippingEnabled(ShouldClipConnections(completionPhase));
|
||
connectionRenderer.SetData(
|
||
candidateParticles,
|
||
s_emptyPropagations,
|
||
completionPhase,
|
||
fadeOutAlpha
|
||
);
|
||
}
|
||
}
|
||
|
||
private void UpdateConnections()
|
||
{
|
||
// 重置所有连接
|
||
foreach (var particle in candidateParticles)
|
||
{
|
||
particle.connections.Clear();
|
||
}
|
||
|
||
// 完成目标后,不再创建目标粒子与非目标粒子的新连接
|
||
bool suppressTargetNonTargetConnections = isCompleted;
|
||
|
||
// 检测连接
|
||
for (int i = 0; i < candidateParticles.Count; i++)
|
||
{
|
||
for (int j = i + 1; j < candidateParticles.Count; j++)
|
||
{
|
||
if (suppressTargetNonTargetConnections)
|
||
{
|
||
bool iIsTarget = candidateParticles[i].isRed;
|
||
bool jIsTarget = candidateParticles[j].isRed;
|
||
if (iIsTarget != jIsTarget)
|
||
continue;
|
||
}
|
||
|
||
float distance = Vector3.Distance(
|
||
candidateParticles[i].transform.position,
|
||
candidateParticles[j].transform.position
|
||
);
|
||
|
||
if (distance < connectionDistance)
|
||
{
|
||
candidateParticles[i].connections.Add(candidateParticles[j]);
|
||
candidateParticles[j].connections.Add(candidateParticles[i]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void BuildConnectionMap()
|
||
{
|
||
previousConnections.Clear();
|
||
for (int i = 0; i < candidateParticles.Count; i++)
|
||
{
|
||
for (int j = i + 1; j < candidateParticles.Count; j++)
|
||
{
|
||
float distance = Vector3.Distance(
|
||
candidateParticles[i].transform.position,
|
||
candidateParticles[j].transform.position
|
||
);
|
||
|
||
if (distance < connectionDistance)
|
||
{
|
||
string key = $"{Mathf.Min(i, j)}-{Mathf.Max(i, j)}";
|
||
previousConnections[key] = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void DetectConnectionChanges()
|
||
{
|
||
Dictionary<string, bool> currentConnections = new Dictionary<string, bool>();
|
||
|
||
for (int i = 0; i < candidateParticles.Count; i++)
|
||
{
|
||
for (int j = i + 1; j < candidateParticles.Count; j++)
|
||
{
|
||
float distance = Vector3.Distance(
|
||
candidateParticles[i].transform.position,
|
||
candidateParticles[j].transform.position
|
||
);
|
||
|
||
if (distance < connectionDistance)
|
||
{
|
||
string key = $"{Mathf.Min(i, j)}-{Mathf.Max(i, j)}";
|
||
currentConnections[key] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
previousConnections = currentConnections;
|
||
}
|
||
|
||
private bool CheckRedParticlesConnected()
|
||
{
|
||
if (targetParticles.Count == 0) return false;
|
||
if (targetParticles.Count == 1) return true;
|
||
|
||
// 检查所有目标粒子是否连通
|
||
HashSet<CandidateParticle> visited = new HashSet<CandidateParticle>();
|
||
Queue<CandidateParticle> queue = new Queue<CandidateParticle>();
|
||
queue.Enqueue(targetParticles[0]);
|
||
visited.Add(targetParticles[0]);
|
||
|
||
while (queue.Count > 0)
|
||
{
|
||
var current = queue.Dequeue();
|
||
foreach (var connected in current.connections)
|
||
{
|
||
if (connected.isRed && !visited.Contains(connected))
|
||
{
|
||
visited.Add(connected);
|
||
queue.Enqueue(connected);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (visited.Count != targetParticles.Count)
|
||
return false;
|
||
|
||
// 检查目标粒子是否与非目标粒子分离
|
||
foreach (var red in targetParticles)
|
||
{
|
||
foreach (var other in candidateParticles)
|
||
{
|
||
if (other.isRed) continue;
|
||
|
||
float dist = Vector3.Distance(red.transform.position, other.transform.position);
|
||
if (dist < connectionDistance)
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private void OnTargetsCompleted()
|
||
{
|
||
completionPhase = CompletionPhase.Completed;
|
||
completionTimer = 0f;
|
||
focusTimer = 0f;
|
||
fadeOutAlpha = 1f;
|
||
allowInput = false;
|
||
DestroyFocusVisualDecorationsAndClear();
|
||
finalTargetCharacters.Clear();
|
||
finalTargetPositions.Clear();
|
||
focusTweensCompleted = false;
|
||
interferenceLogicSuspended = false;
|
||
SetStatusUIVisibility(true, 0f);
|
||
|
||
orderedRedParticles = GetOrderedRedParticles();
|
||
ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout(
|
||
targetSentence,
|
||
0.9f,
|
||
whitespaceSpacingMultiplier);
|
||
int displayLength = Mathf.Min(orderedRedParticles.Count, layout.Count);
|
||
|
||
if (displayLength < orderedRedParticles.Count)
|
||
{
|
||
orderedRedParticles = orderedRedParticles.Take(displayLength).ToList();
|
||
}
|
||
|
||
for (int i = 0; i < displayLength; i++)
|
||
{
|
||
finalTargetCharacters.Add(layout.VisibleElements[i]);
|
||
}
|
||
|
||
Vector3 displayCenter = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
|
||
if (finalTargetCharacters.Count > 0)
|
||
{
|
||
finalTargetPositions.Clear();
|
||
Bounds clip = GetCompletionClipBounds();
|
||
for (int i = 0; i < finalTargetCharacters.Count; i++)
|
||
{
|
||
Vector3 pos = displayCenter + new Vector3(layout.Offsets[i], 0f, 0f);
|
||
finalTargetPositions.Add(ClampPositionToBounds(pos, clip));
|
||
}
|
||
}
|
||
|
||
// 完成目标后:停止目标粒子的移动
|
||
foreach (var particle in targetParticles)
|
||
{
|
||
if (particle != null)
|
||
{
|
||
particle.velocity = Vector2.zero;
|
||
particle.isStatic = true;
|
||
}
|
||
}
|
||
|
||
// 首先激活 button 但不可交互,播放火山表达确认后再解锁(若之前被 fade 过,需重置 alpha)
|
||
if (focusSequenceButton != null)
|
||
{
|
||
var btnCg = focusSequenceButton.GetComponent<CanvasGroup>();
|
||
if (btnCg != null) btnCg.alpha = 1f;
|
||
focusSequenceButton.gameObject.SetActive(true);
|
||
focusSequenceButton.interactable = false;
|
||
}
|
||
if (confirmTimelineRoutine != null)
|
||
{
|
||
StopCoroutine(confirmTimelineRoutine);
|
||
}
|
||
confirmTimelineRoutine = StartCoroutine(PlayConfirmTimelineAndUnlockButton());
|
||
|
||
foreach (var particle in candidateParticles)
|
||
{
|
||
if (!particle.isRed)
|
||
{
|
||
particle.isCalmed = true;
|
||
particle.hasEffect = false;
|
||
particle.isOrange = false;
|
||
particle.changeSpeedMultiplier = 1f;
|
||
}
|
||
}
|
||
|
||
foreach (var particle in floatingParticles)
|
||
{
|
||
particle.isCalmed = true;
|
||
}
|
||
}
|
||
|
||
private List<CandidateParticle> GetOrderedRedParticles()
|
||
{
|
||
if (targetParticles.Count == 0) return new List<CandidateParticle>();
|
||
if (targetParticles.Count == 1) return new List<CandidateParticle>(targetParticles);
|
||
|
||
// 从最左边的粒子开始
|
||
var ordered = new List<CandidateParticle>();
|
||
var startParticle = targetParticles.OrderBy(p => p.transform.position.x).First();
|
||
|
||
HashSet<CandidateParticle> visited = new HashSet<CandidateParticle>();
|
||
Stack<CandidateParticle> stack = new Stack<CandidateParticle>();
|
||
stack.Push(startParticle);
|
||
visited.Add(startParticle);
|
||
|
||
while (stack.Count > 0)
|
||
{
|
||
var current = stack.Pop();
|
||
ordered.Add(current);
|
||
|
||
foreach (var connected in current.connections)
|
||
{
|
||
if (connected.isRed && !visited.Contains(connected))
|
||
{
|
||
visited.Add(connected);
|
||
stack.Push(connected);
|
||
}
|
||
}
|
||
}
|
||
|
||
return ordered;
|
||
}
|
||
|
||
private IEnumerator PlayConfirmTimelineAndUnlockButton()
|
||
{
|
||
ResolveScreenPresentation();
|
||
if (screenPresentation != null)
|
||
{
|
||
yield return screenPresentation.PlayOutputAndWait();
|
||
if (screenPresentation == null ||
|
||
screenPresentation.State != ExpressionScreenPresentationController.ScreenState.Floating)
|
||
yield break;
|
||
}
|
||
else 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;
|
||
|
||
if (focusSequenceButton != null)
|
||
focusSequenceButton.interactable = false;
|
||
|
||
ResolveScreenPresentation();
|
||
if (screenPresentation != null)
|
||
{
|
||
StartCoroutine(PlayClickThenBeginFocus());
|
||
return;
|
||
}
|
||
|
||
BeginFocusSequence();
|
||
}
|
||
|
||
private IEnumerator PlayClickThenBeginFocus()
|
||
{
|
||
yield return screenPresentation.PlayClickAndWait();
|
||
if (screenPresentation == null ||
|
||
screenPresentation.State != ExpressionScreenPresentationController.ScreenState.Click)
|
||
{
|
||
yield break;
|
||
}
|
||
|
||
screenPresentation.HideOverlayForFocus();
|
||
BeginFocusSequence();
|
||
}
|
||
|
||
private void BeginFocusSequence()
|
||
{
|
||
StopFocusTransitionRoutine();
|
||
DOTween.Kill(FocusInterferenceTweenId);
|
||
if (focusSequenceButton != null)
|
||
{
|
||
focusSequenceButton.interactable = false;
|
||
}
|
||
|
||
ClearCompletionClampSmoothVelocity();
|
||
|
||
completionPhase = CompletionPhase.Focusing;
|
||
focusTimer = 0f;
|
||
focusTweensCompleted = false;
|
||
focusMoveTweensCompleted = false;
|
||
lieEdgeCompression = 0f;
|
||
lieEdgeJitter = 0f;
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
truthGatherProgress = 0f;
|
||
interferenceLogicSuspended = true;
|
||
SetStatusUIVisibility(false, statusFadeOutDuration);
|
||
|
||
// 两个 panel、confirm button、火山释放 log 界面在 focus 触发时淡出
|
||
float operationFadeDuration = Mathf.Max(0.01f, logOperationFadeDuration);
|
||
FadeOutUIElement(languageDeepPanel1, operationFadeDuration);
|
||
FadeOutUIElement(languageDeepPanel2, operationFadeDuration);
|
||
if (focusSequenceButton != null)
|
||
{
|
||
FadeOutUIElement(focusSequenceButton.gameObject, operationFadeDuration);
|
||
}
|
||
FadeOutSpriteRenderer(releaseLogSpriteRenderer, operationFadeDuration);
|
||
|
||
if (connectionRenderer != null)
|
||
{
|
||
connectionRenderer.NonTargetAlphaScale = 0f;
|
||
connectionRenderer.TargetAlphaScale = 1.2f;
|
||
}
|
||
|
||
foreach (var particle in floatingParticles)
|
||
{
|
||
particle.isCalmed = true;
|
||
particle.velocity = Vector2.zero;
|
||
FadeOutTextParticle(particle, operationFadeDuration);
|
||
}
|
||
|
||
foreach (var particle in candidateParticles)
|
||
{
|
||
if (!particle.isRed)
|
||
{
|
||
particle.isCalmed = true;
|
||
particle.velocity = Vector2.zero;
|
||
FadeOutTextParticle(particle, operationFadeDuration);
|
||
}
|
||
else
|
||
{
|
||
particle.SetColors(
|
||
targetParticleColor,
|
||
nonTargetParticleColor,
|
||
nonTargetParticleColor,
|
||
targetCalmColor);
|
||
particle.calmProgress = 0f;
|
||
particle.resolvedColorProgress = 0f;
|
||
particle.ClearFocusInterference();
|
||
particle.SetFocusInterference(
|
||
focusInterferenceColor,
|
||
focusInterferenceShakeAmplitude,
|
||
focusInterferenceShakeSpeed);
|
||
particle.isStatic = false;
|
||
particle.isCalmed = false;
|
||
particle.InitializeAnxietyEffect();
|
||
particle.changeSpeedMultiplier = 6f;
|
||
particle.anxietyShakeIntensity = Mathf.Max(particle.anxietyShakeIntensity, 4f);
|
||
particle.SetChangeInterval(0.05f);
|
||
particle.ResetChangeTimer(0.05f);
|
||
}
|
||
}
|
||
|
||
focusCentroid = ComputeCentroid(orderedRedParticles);
|
||
Vector3 focusTargetCenter = worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
|
||
Bounds focusClipBounds = GetCompletionClipBounds();
|
||
|
||
DestroyFocusVisualDecorationsAndClear();
|
||
int tweenTargetCount = orderedRedParticles.Count;
|
||
int completedTweens = 0;
|
||
|
||
for (int i = 0; i < orderedRedParticles.Count; i++)
|
||
{
|
||
var particle = orderedRedParticles[i];
|
||
Vector3 offset = particle.transform.position - focusCentroid;
|
||
float indexCenter = (orderedRedParticles.Count - 1) * 0.5f;
|
||
Vector3 spreadOffset = new Vector3((i - indexCenter) * spreadDistance, Mathf.Sin(i * 1.4f) * spreadDistance * 0.6f, 0f);
|
||
Vector3 focusPosition = focusTargetCenter + offset * focusZoomScale + spreadOffset;
|
||
Vector3 finalPosition = finalTargetPositions.Count > i ? finalTargetPositions[i] : focusTargetCenter;
|
||
focusPosition = ClampPositionToBounds(focusPosition, focusClipBounds);
|
||
finalPosition = ClampPositionToBounds(finalPosition, focusClipBounds);
|
||
|
||
particle.transform.DOKill();
|
||
particle.transform.DOMove(focusPosition, focusMoveDuration)
|
||
.SetEase(Ease.InOutQuad)
|
||
.OnComplete(() =>
|
||
{
|
||
completedTweens++;
|
||
if (completedTweens >= tweenTargetCount)
|
||
focusMoveTweensCompleted = true;
|
||
});
|
||
particle.transform.DOScale(Vector3.one * focusZoomScale, focusMoveDuration).SetEase(Ease.InOutQuad);
|
||
DOTween.To(
|
||
() => particle.resolvedColorProgress,
|
||
value => particle.resolvedColorProgress = value,
|
||
1f,
|
||
Mathf.Max(0.05f, focusMoveDuration))
|
||
.SetEase(Ease.InOutSine)
|
||
.SetTarget(particle)
|
||
.OnComplete(() => particle.resolvedColorProgress = 1f);
|
||
|
||
string finalChar = i < finalTargetCharacters.Count ? finalTargetCharacters[i] : particle.currentChar;
|
||
TargetFocusVisual visual = CreateFocusVisual(particle, finalChar);
|
||
visual.focusPosition = focusPosition;
|
||
visual.finalPosition = finalPosition;
|
||
visual.orderIndex = i;
|
||
focusVisuals[particle] = visual;
|
||
}
|
||
|
||
if (tweenTargetCount == 0)
|
||
focusMoveTweensCompleted = true;
|
||
|
||
focusTransitionRoutine = StartCoroutine(WaitForFocusPresentationEntry());
|
||
}
|
||
|
||
private IEnumerator WaitForFocusPresentationEntry()
|
||
{
|
||
while (releaseLogSpriteRenderer != null &&
|
||
releaseLogSpriteRenderer.gameObject.activeSelf &&
|
||
releaseLogSpriteRenderer.enabled &&
|
||
releaseLogSpriteRenderer.color.a > 0.001f)
|
||
{
|
||
yield return null;
|
||
}
|
||
|
||
while (!focusMoveTweensCompleted && completionPhase == CompletionPhase.Focusing)
|
||
yield return null;
|
||
|
||
if (completionPhase == CompletionPhase.Focusing && !focusTweensCompleted)
|
||
EnterFocusHolding();
|
||
|
||
focusTransitionRoutine = null;
|
||
}
|
||
|
||
private void StopFocusTransitionRoutine()
|
||
{
|
||
if (focusTransitionRoutine == null)
|
||
return;
|
||
|
||
StopCoroutine(focusTransitionRoutine);
|
||
focusTransitionRoutine = null;
|
||
}
|
||
|
||
private TargetFocusVisual CreateFocusVisual(CandidateParticle particle, string finalChar)
|
||
{
|
||
return new TargetFocusVisual
|
||
{
|
||
particle = particle,
|
||
finalChar = finalChar,
|
||
startShakeIntensity = Mathf.Max(particle.anxietyShakeIntensity, 4f)
|
||
};
|
||
}
|
||
|
||
private void EnterFocusHolding()
|
||
{
|
||
completionPhase = CompletionPhase.FocusHolding;
|
||
focusTimer = 0f;
|
||
TryStartCompletionDialog();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 进入 FocusHolding 后启动梳理完成节点。
|
||
/// 若当前正处在 Yarn 命令中,对话仍在跑,则延后到对话空闲再启动。
|
||
/// </summary>
|
||
private void TryStartCompletionDialog()
|
||
{
|
||
if (string.IsNullOrEmpty(completionDialogNode))
|
||
return;
|
||
|
||
var dialog = DialogController.Instance;
|
||
if (dialog == null)
|
||
return;
|
||
|
||
bool dialogueBusy = dialog.DialogueRunner != null && dialog.DialogueRunner.IsDialogueRunning;
|
||
if (dialogueBusy)
|
||
{
|
||
pendingCompletionDialog = true;
|
||
return;
|
||
}
|
||
|
||
pendingCompletionDialog = false;
|
||
dialog.StartDialogNode(completionDialogNode);
|
||
}
|
||
|
||
private void FlushPendingCompletionDialog()
|
||
{
|
||
if (!pendingCompletionDialog)
|
||
return;
|
||
|
||
var dialog = DialogController.Instance;
|
||
if (dialog == null)
|
||
{
|
||
pendingCompletionDialog = false;
|
||
return;
|
||
}
|
||
|
||
if (dialog.DialogueRunner != null && dialog.DialogueRunner.IsDialogueRunning)
|
||
return;
|
||
|
||
pendingCompletionDialog = false;
|
||
if (!string.IsNullOrEmpty(completionDialogNode))
|
||
dialog.StartDialogNode(completionDialogNode);
|
||
}
|
||
|
||
private void UpdateFocusHolding()
|
||
{
|
||
focusTimer += Time.deltaTime;
|
||
float compression = Mathf.SmoothStep(0f, 1f, lieEdgeCompression);
|
||
float gather = Mathf.SmoothStep(0f, 1f, truthGatherProgress);
|
||
int visualCount = Mathf.Max(1, focusVisuals.Count);
|
||
|
||
foreach (var visual in focusVisuals.Values)
|
||
{
|
||
if (visual == null || visual.particle == null)
|
||
continue;
|
||
|
||
// 真话聚合尝试:悬浮位被拉向最终位置;谎话挤压仍拥有更高优先级
|
||
Vector3 gatherPosition = Vector3.Lerp(visual.focusPosition, visual.finalPosition, gather);
|
||
Vector3 compressedPosition = Vector3.Lerp(
|
||
gatherPosition,
|
||
visual.lieEdgePosition,
|
||
compression);
|
||
Vector3 shuffleOffset = CalculateShuffleOffset(visual, compression, focusTimer) *
|
||
Mathf.Lerp(1f, 0.18f, compression) *
|
||
Mathf.Lerp(1f, 0.12f, gather);
|
||
Vector3 edgeJitter = CalculateLieEdgeJitter(visual, compression);
|
||
visual.scatterOffset = Vector3.Lerp(visual.scatterOffset, Vector3.zero, Time.deltaTime * 5.5f);
|
||
// 硬钳制:shuffle/jitter 会顶出裁剪区,仅靠 LateUpdate SmoothDamp 追不上,字会贴边甚至出屏
|
||
visual.particle.transform.position = ClampPositionToBounds(
|
||
compressedPosition + shuffleOffset + edgeJitter + visual.scatterOffset,
|
||
GetCompletionClipBounds());
|
||
|
||
// 锁字判定用原始进度:target=0.92 时阈值 0.95 的最后一个字永远锁不住
|
||
UpdateTruthGatherLock(visual, truthGatherProgress, visualCount);
|
||
}
|
||
}
|
||
|
||
private void UpdateTruthGatherLock(TargetFocusVisual visual, float gather, int visualCount)
|
||
{
|
||
// 按字序阶梯式锁定:阈值 0.45~0.95。目标进度 <1 时最后几个字永远差一点锁不住。
|
||
float lockThreshold = 0.45f + 0.5f * (visual.orderIndex + 1f) / visualCount;
|
||
if (!visual.gatherLocked && gather >= lockThreshold)
|
||
{
|
||
visual.gatherLocked = true;
|
||
visual.particle.ForceSetCharacter(visual.finalChar);
|
||
visual.particle.SetChangeInterval(2f);
|
||
visual.particle.ResetChangeTimer(2f);
|
||
visual.particle.anxietyShakeIntensity = 0.6f;
|
||
visual.particle.isStatic = true;
|
||
}
|
||
else if (visual.gatherLocked && gather < lockThreshold - 0.08f)
|
||
{
|
||
visual.gatherLocked = false;
|
||
visual.particle.isStatic = false;
|
||
visual.particle.SetChangeInterval(0.06f);
|
||
visual.particle.ResetChangeTimer(0.06f);
|
||
visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 4.5f);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 真话聚合尝试(非等待):FocusHolding 下把目标字拉向最终位置并按序逐字锁定。
|
||
/// target 小于 1 时最后几个字永远差一点锁不住,用于"感觉快要成功了"。
|
||
/// </summary>
|
||
public void BeginTruthGather(float target, float duration)
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] 真话聚合在 {completionPhase} 状态被调用;已安全跳过。");
|
||
return;
|
||
}
|
||
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
DOTween.To(
|
||
() => truthGatherProgress,
|
||
value => truthGatherProgress = value,
|
||
Mathf.Clamp01(target),
|
||
Mathf.Max(0.05f, duration))
|
||
.SetEase(Ease.InOutSine)
|
||
.SetId(TruthGatherTweenId)
|
||
.SetTarget(this);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 真话聚合挣扎(非等待):向 target 推进的途中反复"够到一点又被拽回去"。
|
||
/// 回抽幅度必须跨过 UpdateTruthGatherLock 的 0.08 解锁带,已锁的字才会崩开重新乱跳——
|
||
/// 挣扎感来自进度曲线本身的往复,不依赖任何颜色或额外特效。
|
||
/// </summary>
|
||
/// <param name="target">最终停留的聚合进度</param>
|
||
/// <param name="duration">整段时长</param>
|
||
/// <param name="tugs">回抽次数;<=0 时退化为普通聚合</param>
|
||
public void BeginTruthStrain(float target, float duration, int tugs)
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] 真话挣扎在 {completionPhase} 状态被调用;已安全跳过。");
|
||
return;
|
||
}
|
||
|
||
int tugCount = Mathf.Clamp(tugs, 0, 4);
|
||
if (tugCount <= 0)
|
||
{
|
||
BeginTruthGather(target, duration);
|
||
return;
|
||
}
|
||
|
||
float clampedTarget = Mathf.Clamp01(target);
|
||
float start = truthGatherProgress;
|
||
float total = Mathf.Max(0.2f, duration);
|
||
// 末尾留一段单纯的推进,让整句最后停在 target 而不是停在回抽底部
|
||
float settleDuration = total * 0.18f;
|
||
float cycleDuration = (total - settleDuration) / tugCount;
|
||
float pushDuration = Mathf.Max(0.05f, cycleDuration * 0.72f);
|
||
float recoilDuration = Mathf.Max(0.04f, cycleDuration - pushDuration);
|
||
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
Sequence sequence = DOTween.Sequence().SetId(TruthGatherTweenId).SetTarget(this);
|
||
for (int i = 1; i <= tugCount; i++)
|
||
{
|
||
// 每次比上一次够得更远,最后一次刚好摸到 target 再被拽回来
|
||
float peak = Mathf.Lerp(start, clampedTarget, i / (float)tugCount);
|
||
// 回抽越往后越狠;下限 0.1 保证越过 0.08 解锁带
|
||
float recoil = 0.10f + 0.03f * (i - 1);
|
||
float valley = Mathf.Max(0f, peak - recoil);
|
||
|
||
sequence.Append(
|
||
DOTween.To(
|
||
() => truthGatherProgress,
|
||
value => truthGatherProgress = value,
|
||
peak,
|
||
pushDuration)
|
||
.SetEase(Ease.OutCubic));
|
||
sequence.Append(
|
||
DOTween.To(
|
||
() => truthGatherProgress,
|
||
value => truthGatherProgress = value,
|
||
valley,
|
||
recoilDuration)
|
||
.SetEase(Ease.OutExpo));
|
||
}
|
||
|
||
sequence.Append(
|
||
DOTween.To(
|
||
() => truthGatherProgress,
|
||
value => truthGatherProgress = value,
|
||
clampedTarget,
|
||
Mathf.Max(0.05f, settleDuration))
|
||
.SetEase(Ease.OutCubic));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 真话聚合崩塌(非等待):进度被一口气拽回 0,所有已锁的字沿原路退回悬浮位并恢复乱跳。
|
||
/// Ease.InBack 会先向上顶一点点再垮掉——"又够了一下下,然后整个塌了"。
|
||
/// </summary>
|
||
public void CollapseTruthGather(float duration)
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] 真话崩塌在 {completionPhase} 状态被调用;已安全跳过。");
|
||
return;
|
||
}
|
||
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
DOTween.To(
|
||
() => truthGatherProgress,
|
||
value => truthGatherProgress = value,
|
||
0f,
|
||
Mathf.Max(0.08f, duration))
|
||
.SetEase(Ease.InBack)
|
||
.SetId(TruthGatherTweenId)
|
||
.SetTarget(this);
|
||
}
|
||
|
||
/// <summary>真话聚合被打断:目标字向外爆散并恢复乱跳(非等待)。</summary>
|
||
public void ScatterTruthGather(float burstDistance)
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
return;
|
||
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
truthGatherProgress = 0f;
|
||
foreach (TargetFocusVisual visual in focusVisuals.Values)
|
||
{
|
||
if (visual?.particle == null)
|
||
continue;
|
||
|
||
visual.gatherLocked = false;
|
||
Vector2 direction = UnityEngine.Random.insideUnitCircle.normalized;
|
||
visual.scatterOffset = (Vector3)(direction *
|
||
Mathf.Max(0f, burstDistance) *
|
||
UnityEngine.Random.Range(0.6f, 1.3f));
|
||
visual.particle.isStatic = false;
|
||
visual.particle.SetChangeInterval(0.05f);
|
||
visual.particle.ResetChangeTimer(0.05f);
|
||
visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 6f);
|
||
}
|
||
}
|
||
|
||
private void UpdateFocusSequence()
|
||
{
|
||
if (!focusTweensCompleted)
|
||
return;
|
||
|
||
focusTimer += Time.deltaTime;
|
||
CalculateResolveTiming(
|
||
focusVisuals.Count,
|
||
resolveTotalDurationOverride,
|
||
Mathf.Max(0.2f, probabilityDuration),
|
||
Mathf.Max(0f, predictionSequenceGap),
|
||
out float baseInterval,
|
||
out float activeStagger);
|
||
float arrangementDuration = Mathf.Max(0.2f, probabilityDuration * 0.6f);
|
||
if (resolveTotalDurationOverride > 0f)
|
||
arrangementDuration = Mathf.Max(0.05f, baseInterval * 0.65f);
|
||
|
||
foreach (var visual in focusVisuals.Values)
|
||
{
|
||
if (visual == null || visual.particle == null)
|
||
continue;
|
||
|
||
float localTime = focusTimer - visual.orderIndex * activeStagger;
|
||
float probabilityProgress = Mathf.Clamp01(localTime / baseInterval);
|
||
float arrangementProgress = Mathf.Clamp01(localTime / arrangementDuration);
|
||
|
||
float interval = Mathf.Lerp(0.05f, 0.8f, probabilityProgress);
|
||
visual.particle.SetChangeInterval(interval);
|
||
visual.particle.changeSpeedMultiplier = Mathf.Lerp(6f, 1f, probabilityProgress);
|
||
visual.particle.anxietyShakeIntensity = Mathf.Lerp(visual.startShakeIntensity, 0f, probabilityProgress);
|
||
visual.particle.calmProgress = probabilityProgress;
|
||
visual.particle.resolvedColorProgress = 1f;
|
||
|
||
Vector3 dynamicTarget = GetDynamicArrangementTarget(visual, arrangementProgress, localTime);
|
||
Vector3 targetPosition = Vector3.Lerp(visual.focusPosition, dynamicTarget, Mathf.SmoothStep(0f, 1f, arrangementProgress));
|
||
Vector3 shuffleOffset = CalculateShuffleOffset(visual, arrangementProgress, localTime);
|
||
visual.particle.transform.position = ClampPositionToBounds(
|
||
targetPosition + shuffleOffset,
|
||
GetCompletionClipBounds());
|
||
|
||
if (!visual.stabilized && probabilityProgress >= 1f)
|
||
{
|
||
visual.particle.ForceSetCharacter(visual.finalChar);
|
||
visual.particle.SetChangeInterval(1.5f);
|
||
visual.particle.ResetChangeTimer(visual.particle.changeSpeedMultiplier);
|
||
visual.particle.isStatic = true;
|
||
visual.stabilized = true;
|
||
}
|
||
}
|
||
|
||
bool allStable = focusVisuals.Values.All(v => v.stabilized);
|
||
if (allStable)
|
||
{
|
||
CompleteFocusSequence();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 FocusHolding 进入稳定化阶段(文字锁定、排列到最终位置)
|
||
/// </summary>
|
||
public void ResolveFocusSequence(float totalDuration = -1f)
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
return;
|
||
|
||
resolveTotalDurationOverride = totalDuration > 0f ? Mathf.Max(0.05f, totalDuration) : -1f;
|
||
completionPhase = CompletionPhase.Focusing;
|
||
focusTweensCompleted = true;
|
||
focusTimer = 0f;
|
||
}
|
||
|
||
public static void CalculateResolveTiming(
|
||
int characterCount,
|
||
float totalDuration,
|
||
float defaultCharacterDuration,
|
||
float defaultStagger,
|
||
out float characterDuration,
|
||
out float stagger)
|
||
{
|
||
int count = Mathf.Max(1, characterCount);
|
||
if (totalDuration <= 0f)
|
||
{
|
||
characterDuration = Mathf.Max(0.05f, defaultCharacterDuration);
|
||
stagger = Mathf.Max(0f, defaultStagger);
|
||
return;
|
||
}
|
||
|
||
float total = Mathf.Max(0.05f, totalDuration);
|
||
float staggerBudget = count > 1 ? total * 0.35f : 0f;
|
||
stagger = count > 1 ? staggerBudget / (count - 1) : 0f;
|
||
characterDuration = Mathf.Max(0.05f, total - staggerBudget);
|
||
|
||
float computedEnd = characterDuration + stagger * (count - 1);
|
||
if (computedEnd > total)
|
||
characterDuration = Mathf.Max(0.01f, characterDuration - (computedEnd - total));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 等待聚焦流程全部完成(稳定化 → Finished)
|
||
/// </summary>
|
||
public IEnumerator WaitUntilFocusFinished()
|
||
{
|
||
while (completionPhase != CompletionPhase.Finished && completionPhase != CompletionPhase.None)
|
||
{
|
||
yield return null;
|
||
}
|
||
}
|
||
|
||
public static bool ShouldClipConnections(CompletionPhase phase)
|
||
{
|
||
return phase == CompletionPhase.Focusing ||
|
||
phase == CompletionPhase.FocusHolding ||
|
||
phase == CompletionPhase.Finished;
|
||
}
|
||
|
||
public void SetPresentationController(LogReleasePresentationController controller)
|
||
{
|
||
presentationController = controller;
|
||
}
|
||
|
||
public void SetScreenPresentationController(ExpressionScreenPresentationController controller)
|
||
{
|
||
screenPresentation = controller;
|
||
}
|
||
|
||
public void BeginLieEdgeCompression(float duration, float jitterAmount)
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] 谎话边缘挤压在 {completionPhase} 状态被调用;已安全跳过。");
|
||
return;
|
||
}
|
||
|
||
Bounds edgeBounds = GetCompletionClipBounds();
|
||
int visualCount = Mathf.Max(1, focusVisuals.Count);
|
||
int index = 0;
|
||
foreach (TargetFocusVisual visual in focusVisuals.Values.OrderBy(value => value.orderIndex))
|
||
{
|
||
if (visual?.particle == null)
|
||
continue;
|
||
|
||
visual.lieEdgePosition = CalculateScreenEdgePosition(
|
||
edgeBounds,
|
||
index,
|
||
visualCount,
|
||
0.04f);
|
||
visual.particle.isStatic = false;
|
||
visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 5.5f);
|
||
visual.particle.SetChangeInterval(0.045f);
|
||
index++;
|
||
}
|
||
|
||
DOTween.Kill(LieCompressionTweenId);
|
||
DOTween.To(
|
||
() => lieEdgeCompression,
|
||
value => lieEdgeCompression = value,
|
||
1f,
|
||
Mathf.Max(0.05f, duration))
|
||
.SetEase(Ease.InCubic)
|
||
.SetId(LieCompressionTweenId)
|
||
.SetTarget(this);
|
||
DOTween.To(
|
||
() => lieEdgeJitter,
|
||
value => lieEdgeJitter = value,
|
||
Mathf.Max(0f, jitterAmount),
|
||
Mathf.Max(0.04f, duration * 0.7f))
|
||
.SetEase(Ease.OutQuad)
|
||
.SetId(LieCompressionTweenId)
|
||
.SetTarget(this);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 老虎机换字演出:聚焦字在 duration 内只从 charPool 里疯狂换字,到点后自动恢复默认字符池并继续抖动。
|
||
/// </summary>
|
||
public void BeginSlotMachineShuffle(string charPool, float duration, float interval = 0.05f)
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] 老虎机换字在 {completionPhase} 状态被调用;已安全跳过。");
|
||
return;
|
||
}
|
||
|
||
StopSlotMachineShuffle();
|
||
string pool = SanitizeSlotMachinePool(charPool);
|
||
if (string.IsNullOrEmpty(pool))
|
||
{
|
||
Debug.LogWarning("[LanguageParticleManager] 老虎机换字字符池为空;已安全跳过。");
|
||
return;
|
||
}
|
||
IReadOnlyList<string> poolElements =
|
||
ExpressionTextTokenizer.GetVisibleElements(pool).AsReadOnly();
|
||
|
||
float changeInterval = Mathf.Max(0.02f, interval);
|
||
foreach (TargetFocusVisual visual in focusVisuals.Values)
|
||
{
|
||
if (visual?.particle == null)
|
||
continue;
|
||
|
||
visual.particle.SetOverrideCharacterPool(poolElements);
|
||
visual.particle.SetChangeInterval(changeInterval);
|
||
visual.particle.ResetChangeTimer(changeInterval);
|
||
visual.particle.isStatic = false;
|
||
visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 5f);
|
||
}
|
||
|
||
slotMachineRoutine = StartCoroutine(EndSlotMachineShuffleAfter(Mathf.Max(0.05f, duration)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清洗老虎机字符池:去掉空格与 | 分隔符,空结果返回 null。
|
||
/// </summary>
|
||
public static string SanitizeSlotMachinePool(string charPool)
|
||
{
|
||
if (string.IsNullOrEmpty(charPool))
|
||
return null;
|
||
string withoutSeparators = charPool
|
||
.Replace("|", string.Empty)
|
||
.Replace("|", string.Empty);
|
||
List<string> elements =
|
||
ExpressionTextTokenizer.GetVisibleElements(withoutSeparators);
|
||
return elements.Count == 0 ? null : string.Concat(elements);
|
||
}
|
||
|
||
public void StopSlotMachineShuffle()
|
||
{
|
||
if (slotMachineRoutine != null)
|
||
{
|
||
StopCoroutine(slotMachineRoutine);
|
||
slotMachineRoutine = null;
|
||
}
|
||
|
||
foreach (CandidateParticle particle in targetParticles)
|
||
particle?.SetOverrideCharacterPool(null);
|
||
}
|
||
|
||
private IEnumerator EndSlotMachineShuffleAfter(float duration)
|
||
{
|
||
yield return new WaitForSeconds(duration);
|
||
slotMachineRoutine = null;
|
||
foreach (CandidateParticle particle in targetParticles)
|
||
particle?.SetOverrideCharacterPool(null);
|
||
}
|
||
|
||
public void PrepareTruthReleaseFromLie()
|
||
{
|
||
DOTween.Kill(LieCompressionTweenId);
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
truthGatherProgress = 0f;
|
||
foreach (TargetFocusVisual visual in focusVisuals.Values)
|
||
{
|
||
if (visual?.particle == null)
|
||
continue;
|
||
|
||
// The resolve sequence now starts from the squeezed perimeter position,
|
||
// making every real character visibly rush back through the broken lie.
|
||
visual.focusPosition = visual.particle.transform.position;
|
||
visual.lieEdgePosition = visual.focusPosition;
|
||
visual.startShakeIntensity = Mathf.Max(visual.startShakeIntensity, 6.5f);
|
||
visual.particle.anxietyShakeIntensity = visual.startShakeIntensity;
|
||
}
|
||
|
||
lieEdgeCompression = 0f;
|
||
lieEdgeJitter = 0f;
|
||
}
|
||
|
||
public Bounds GetFinalTruthWorldBounds()
|
||
{
|
||
if (finalTargetPositions.Count == 0)
|
||
return GetTargetWorldBounds();
|
||
|
||
Bounds result = new Bounds(finalTargetPositions[0], Vector3.zero);
|
||
for (int i = 1; i < finalTargetPositions.Count; i++)
|
||
result.Encapsulate(finalTargetPositions[i]);
|
||
|
||
float glyphPadding = Mathf.Max(0.3f, targetParticleFontSize * 0.09f * focusZoomScale);
|
||
result.Expand(new Vector3(glyphPadding * 2f, glyphPadding * 2f, 0f));
|
||
return result;
|
||
}
|
||
|
||
public static Vector3 CalculateScreenEdgePosition(
|
||
Bounds bounds,
|
||
int index,
|
||
int count,
|
||
float inset)
|
||
{
|
||
int safeCount = Mathf.Max(1, count);
|
||
float angle = -Mathf.PI * 0.5f +
|
||
Mathf.PI * 2f * ((index + 0.5f) / safeCount);
|
||
Vector2 direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
|
||
float availableX = Mathf.Max(0f, bounds.extents.x - Mathf.Max(0f, inset));
|
||
float availableY = Mathf.Max(0f, bounds.extents.y - Mathf.Max(0f, inset));
|
||
float distanceX = Mathf.Abs(direction.x) > 0.0001f
|
||
? availableX / Mathf.Abs(direction.x)
|
||
: float.PositiveInfinity;
|
||
float distanceY = Mathf.Abs(direction.y) > 0.0001f
|
||
? availableY / Mathf.Abs(direction.y)
|
||
: float.PositiveInfinity;
|
||
float distance = Mathf.Min(distanceX, distanceY);
|
||
return bounds.center + (Vector3)(direction * distance);
|
||
}
|
||
|
||
public void SetTargetParticlesAlpha(float targetAlpha, float duration = 0f)
|
||
{
|
||
float alphaValue = Mathf.Clamp01(targetAlpha);
|
||
foreach (CandidateParticle particle in targetParticles)
|
||
{
|
||
if (particle == null) continue;
|
||
DOTween.Kill(particle);
|
||
if (duration <= 0f)
|
||
{
|
||
particle.alpha = alphaValue;
|
||
}
|
||
else
|
||
{
|
||
DOTween.To(() => particle.alpha, value => particle.alpha = value, alphaValue, duration)
|
||
.SetEase(Ease.OutQuad)
|
||
.SetTarget(particle);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将已完成聚焦的目标粒子改排为红色攻击词。文字必须完整放进现有目标粒子,
|
||
/// 超长时拒绝播放,避免演出层静默删字。
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Holds the current truth phrase in place, then continuously increases both its
|
||
/// shake and its blend from the stable truth color to the requested warning color.
|
||
/// The final unstable state is retained for the following attack.
|
||
/// </summary>
|
||
public IEnumerator DestabilizeTruthPhraseAndWait(
|
||
string text,
|
||
float duration,
|
||
float shakeAmplitude,
|
||
float shakeSpeed,
|
||
string colorHex)
|
||
{
|
||
List<CandidateParticle> particles = GetPresentationPhraseParticles();
|
||
if (!ValidatePresentationPhrase(text, particles, "真话失稳"))
|
||
yield break;
|
||
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
|
||
|
||
Color unstableColor = truthAttackColor;
|
||
if (!string.IsNullOrWhiteSpace(colorHex))
|
||
{
|
||
string htmlColor = colorHex[0] == '#' ? colorHex : $"#{colorHex}";
|
||
if (!ColorUtility.TryParseHtmlString(htmlColor, out unstableColor))
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] 真话失稳颜色“{colorHex}”无效;改用攻击色。");
|
||
unstableColor = truthAttackColor;
|
||
}
|
||
}
|
||
|
||
PreparePresentationPhraseControl();
|
||
|
||
float totalDuration = Mathf.Max(0.12f, duration);
|
||
float resolvedShakeAmplitude = Mathf.Max(0f, shakeAmplitude);
|
||
float resolvedShakeSpeed = Mathf.Max(0f, shakeSpeed);
|
||
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
|
||
UpdateFinalPresentationPhrase(characters, destinations);
|
||
|
||
for (int i = 0; i < particles.Count; i++)
|
||
{
|
||
CandidateParticle particle = particles[i];
|
||
if (particle == null)
|
||
continue;
|
||
|
||
particle.transform.DOKill();
|
||
DOTween.Kill(particle);
|
||
particle.velocity = Vector2.zero;
|
||
particle.isStatic = true;
|
||
particle.anxietyShakeIntensity = 0f;
|
||
particle.ClearFocusInterference();
|
||
particle.SetColors(
|
||
targetParticleColor,
|
||
nonTargetParticleColor,
|
||
nonTargetParticleColor,
|
||
targetCalmColor);
|
||
particle.SetFontStyles(
|
||
targetParticleFontSize,
|
||
nonTargetParticleFontSize,
|
||
false,
|
||
nonTargetParticleBold);
|
||
particle.resolvedColorProgress = 1f;
|
||
particle.calmProgress = 1f;
|
||
|
||
if (i >= characters.Count)
|
||
{
|
||
particle.alpha = 0f;
|
||
continue;
|
||
}
|
||
|
||
particle.ForceSetCharacter(characters[i]);
|
||
particle.alpha = 1f;
|
||
particle.SetFocusInterference(
|
||
unstableColor,
|
||
resolvedShakeAmplitude,
|
||
resolvedShakeSpeed);
|
||
particle.focusInterferenceProgress = 0f;
|
||
DOTween.To(
|
||
() => particle.focusInterferenceProgress,
|
||
value => particle.focusInterferenceProgress = value,
|
||
1f,
|
||
totalDuration)
|
||
.SetEase(Ease.InQuad)
|
||
.SetTarget(particle);
|
||
}
|
||
|
||
yield return new WaitForSeconds(totalDuration);
|
||
|
||
for (int i = 0; i < characters.Count && i < particles.Count; i++)
|
||
{
|
||
CandidateParticle particle = particles[i];
|
||
if (particle == null)
|
||
continue;
|
||
particle.focusInterferenceProgress = 1f;
|
||
particle.alpha = 1f;
|
||
}
|
||
}
|
||
|
||
public IEnumerator PlayTruthAttackAndWait(
|
||
string text,
|
||
float duration,
|
||
float impact)
|
||
{
|
||
List<CandidateParticle> particles = GetPresentationPhraseParticles();
|
||
if (!ValidatePresentationPhrase(text, particles, "真话攻击"))
|
||
yield break;
|
||
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
|
||
|
||
PreparePresentationPhraseControl();
|
||
|
||
float totalDuration = Mathf.Max(0.08f, duration);
|
||
float resolvedImpact = Mathf.Clamp01(impact);
|
||
float travelDuration = totalDuration * 0.68f;
|
||
float settleDuration = Mathf.Max(0.04f, totalDuration - travelDuration);
|
||
int directionSign = truthAttackSequenceIndex++ % 2 == 0 ? -1 : 1;
|
||
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
|
||
UpdateFinalPresentationPhrase(characters, destinations);
|
||
|
||
for (int i = 0; i < particles.Count; i++)
|
||
{
|
||
CandidateParticle particle = particles[i];
|
||
if (particle == null)
|
||
continue;
|
||
|
||
particle.transform.DOKill();
|
||
DOTween.Kill(particle);
|
||
particle.velocity = Vector2.zero;
|
||
particle.isStatic = true;
|
||
particle.anxietyShakeIntensity = 0f;
|
||
particle.ClearFocusInterference();
|
||
particle.SetFocusInterference(truthAttackColor, 0f, 0f);
|
||
particle.SetColors(
|
||
targetParticleColor,
|
||
nonTargetParticleColor,
|
||
nonTargetParticleColor,
|
||
targetCalmColor);
|
||
particle.SetFontStyles(
|
||
targetParticleFontSize,
|
||
nonTargetParticleFontSize,
|
||
false,
|
||
nonTargetParticleBold);
|
||
particle.resolvedColorProgress = 1f;
|
||
particle.calmProgress = 1f;
|
||
|
||
if (i >= characters.Count)
|
||
{
|
||
DOTween.To(
|
||
() => particle.alpha,
|
||
value => particle.alpha = value,
|
||
0f,
|
||
Mathf.Min(0.12f, totalDuration))
|
||
.SetEase(Ease.OutQuad)
|
||
.SetTarget(particle);
|
||
continue;
|
||
}
|
||
|
||
particle.ForceSetCharacter(characters[i]);
|
||
particle.alpha = 1f;
|
||
particle.focusInterferenceProgress = 1f;
|
||
|
||
Vector3 destination = destinations[i];
|
||
float travel = truthAttackTravelDistance * Mathf.Lerp(0.55f, 1f, resolvedImpact);
|
||
float verticalKick = ((i % 3) - 1) * 0.06f * resolvedImpact;
|
||
particle.transform.position = destination +
|
||
new Vector3(directionSign * travel, verticalKick, 0f);
|
||
particle.transform.localScale = new Vector3(
|
||
focusZoomScale * Mathf.Lerp(1.12f, 1.42f, resolvedImpact),
|
||
focusZoomScale * Mathf.Lerp(0.86f, 0.66f, resolvedImpact),
|
||
focusZoomScale);
|
||
|
||
float delay = Mathf.Min(totalDuration * 0.12f, i * 0.018f);
|
||
float localTravelDuration = Mathf.Max(0.04f, travelDuration - delay);
|
||
Sequence sequence = DOTween.Sequence().SetTarget(particle);
|
||
if (delay > 0f)
|
||
sequence.AppendInterval(delay);
|
||
sequence.Append(
|
||
particle.transform.DOMove(destination, localTravelDuration)
|
||
.SetEase(Ease.OutExpo));
|
||
sequence.Join(
|
||
particle.transform.DOScale(
|
||
Vector3.one * focusZoomScale,
|
||
localTravelDuration)
|
||
.SetEase(Ease.OutCubic));
|
||
sequence.Append(
|
||
particle.transform.DOPunchScale(
|
||
Vector3.one * (0.08f + 0.12f * resolvedImpact),
|
||
settleDuration,
|
||
1,
|
||
0.18f));
|
||
}
|
||
|
||
yield return new WaitForSeconds(totalDuration);
|
||
|
||
for (int i = 0; i < characters.Count && i < particles.Count; i++)
|
||
{
|
||
CandidateParticle particle = particles[i];
|
||
if (particle == null)
|
||
continue;
|
||
particle.transform.position = destinations[i];
|
||
particle.transform.localScale = Vector3.one * focusZoomScale;
|
||
particle.alpha = 1f;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将攻击词先压散,再重组为固定青绿色真话。
|
||
/// </summary>
|
||
public IEnumerator SettleTruthPhraseAndWait(string text, float duration)
|
||
{
|
||
List<CandidateParticle> particles = GetPresentationPhraseParticles();
|
||
if (!ValidatePresentationPhrase(text, particles, "真话稳定"))
|
||
yield break;
|
||
List<string> characters = ExpressionTextTokenizer.GetVisibleElements(text);
|
||
|
||
PreparePresentationPhraseControl();
|
||
|
||
float totalDuration = Mathf.Max(0.12f, duration);
|
||
float breakDuration = totalDuration * 0.32f;
|
||
float reformDuration = Mathf.Max(0.06f, totalDuration - breakDuration);
|
||
|
||
for (int i = 0; i < particles.Count; i++)
|
||
{
|
||
CandidateParticle particle = particles[i];
|
||
if (particle == null)
|
||
continue;
|
||
|
||
particle.transform.DOKill();
|
||
DOTween.Kill(particle);
|
||
Vector3 breakOffset = new Vector3(
|
||
(i % 2 == 0 ? -1f : 1f) * (0.16f + i * 0.025f),
|
||
((i % 3) - 1) * 0.08f,
|
||
0f);
|
||
particle.transform.DOMove(
|
||
particle.transform.position + breakOffset,
|
||
breakDuration)
|
||
.SetEase(Ease.InCubic)
|
||
.SetTarget(particle);
|
||
particle.transform.DOScale(
|
||
Vector3.one * focusZoomScale * 0.72f,
|
||
breakDuration)
|
||
.SetEase(Ease.InCubic)
|
||
.SetTarget(particle);
|
||
DOTween.To(
|
||
() => particle.alpha,
|
||
value => particle.alpha = value,
|
||
0.15f,
|
||
breakDuration)
|
||
.SetEase(Ease.InQuad)
|
||
.SetTarget(particle);
|
||
}
|
||
|
||
yield return new WaitForSeconds(breakDuration);
|
||
|
||
List<Vector3> destinations = CalculatePresentationPhrasePositions(text);
|
||
UpdateFinalPresentationPhrase(characters, destinations);
|
||
for (int i = 0; i < particles.Count; i++)
|
||
{
|
||
CandidateParticle particle = particles[i];
|
||
if (particle == null)
|
||
continue;
|
||
|
||
particle.transform.DOKill();
|
||
DOTween.Kill(particle);
|
||
particle.velocity = Vector2.zero;
|
||
particle.isStatic = true;
|
||
particle.anxietyShakeIntensity = 0f;
|
||
particle.SetColors(
|
||
targetParticleColor,
|
||
nonTargetParticleColor,
|
||
nonTargetParticleColor,
|
||
targetCalmColor);
|
||
particle.SetFontStyles(
|
||
targetParticleFontSize,
|
||
nonTargetParticleFontSize,
|
||
false,
|
||
nonTargetParticleBold);
|
||
particle.resolvedColorProgress = 1f;
|
||
particle.calmProgress = 1f;
|
||
|
||
if (i >= characters.Count)
|
||
{
|
||
particle.alpha = 0f;
|
||
particle.ClearFocusInterference();
|
||
continue;
|
||
}
|
||
|
||
particle.ForceSetCharacter(characters[i]);
|
||
particle.SetFocusInterference(truthAttackColor, 0f, 0f);
|
||
particle.focusInterferenceProgress = 1f;
|
||
particle.alpha = 0.15f;
|
||
particle.transform.DOMove(destinations[i], reformDuration)
|
||
.SetEase(Ease.OutCubic)
|
||
.SetTarget(particle);
|
||
particle.transform.DOScale(
|
||
Vector3.one * focusZoomScale,
|
||
reformDuration)
|
||
.SetEase(Ease.OutBack)
|
||
.SetTarget(particle);
|
||
DOTween.To(
|
||
() => particle.alpha,
|
||
value => particle.alpha = value,
|
||
1f,
|
||
reformDuration)
|
||
.SetEase(Ease.OutQuad)
|
||
.SetTarget(particle);
|
||
DOTween.To(
|
||
() => particle.focusInterferenceProgress,
|
||
value => particle.focusInterferenceProgress = value,
|
||
0f,
|
||
reformDuration)
|
||
.SetEase(Ease.OutSine)
|
||
.SetTarget(particle);
|
||
}
|
||
|
||
yield return new WaitForSeconds(reformDuration);
|
||
|
||
for (int i = 0; i < particles.Count; i++)
|
||
{
|
||
CandidateParticle particle = particles[i];
|
||
if (particle == null)
|
||
continue;
|
||
particle.ClearFocusInterference();
|
||
particle.alpha = i < characters.Count ? 1f : 0f;
|
||
if (i < characters.Count)
|
||
{
|
||
particle.transform.position = destinations[i];
|
||
particle.transform.localScale = Vector3.one * focusZoomScale;
|
||
}
|
||
}
|
||
}
|
||
|
||
private List<CandidateParticle> GetPresentationPhraseParticles()
|
||
{
|
||
List<CandidateParticle> particles = orderedRedParticles
|
||
.Where(particle => particle != null)
|
||
.Distinct()
|
||
.ToList();
|
||
if (particles.Count > 0)
|
||
return particles;
|
||
|
||
return targetParticles
|
||
.Where(particle => particle != null)
|
||
.OrderBy(particle => particle.transform.position.x)
|
||
.ToList();
|
||
}
|
||
|
||
private static bool ValidatePresentationPhrase(
|
||
string text,
|
||
List<CandidateParticle> particles,
|
||
string context)
|
||
{
|
||
if (string.IsNullOrEmpty(text))
|
||
{
|
||
Debug.LogWarning($"[LanguageParticleManager] {context}文字为空;已安全跳过。");
|
||
return false;
|
||
}
|
||
|
||
if (particles == null || particles.Count == 0)
|
||
{
|
||
Debug.LogWarning($"[LanguageParticleManager] {context}没有可用目标粒子;已安全跳过。");
|
||
return false;
|
||
}
|
||
|
||
int requiredParticles = ExpressionTextTokenizer.GetVisibleElements(text).Count;
|
||
if (requiredParticles == 0)
|
||
{
|
||
Debug.LogWarning($"[LanguageParticleManager] {context}没有可显示的文本元素;已安全跳过。");
|
||
return false;
|
||
}
|
||
|
||
if (requiredParticles > particles.Count)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] {context}“{text}”需要 {requiredParticles} 个目标粒子," +
|
||
$"当前只有 {particles.Count} 个;为避免截断已跳过。");
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private void PreparePresentationPhraseControl()
|
||
{
|
||
DOTween.Kill(LieCompressionTweenId);
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
DOTween.Kill(FocusInterferenceTweenId);
|
||
StopSlotMachineShuffle();
|
||
truthGatherProgress = 0f;
|
||
lieEdgeCompression = 0f;
|
||
lieEdgeJitter = 0f;
|
||
interferenceLogicSuspended = true;
|
||
completionPhase = CompletionPhase.Finished;
|
||
DestroyFocusVisualDecorationsAndClear();
|
||
if (connectionRenderer != null)
|
||
connectionRenderer.TargetAlphaScale = 0f;
|
||
}
|
||
|
||
private List<Vector3> CalculatePresentationPhrasePositions(string text)
|
||
{
|
||
float spacing = Mathf.Max(0.1f, truthAttackCharacterSpacing);
|
||
ExpressionTextTokenizer.Layout layout = ExpressionTextTokenizer.BuildLayout(
|
||
text,
|
||
spacing,
|
||
whitespaceSpacingMultiplier);
|
||
var positions = new List<Vector3>(layout.Count);
|
||
Vector3 center = worldCanvas != null ? worldCanvas.transform.position : transform.position;
|
||
Bounds clip = GetCompletionClipBounds();
|
||
for (int i = 0; i < layout.Count; i++)
|
||
{
|
||
Vector3 position = center + new Vector3(layout.Offsets[i], 0f, 0f);
|
||
positions.Add(ClampPositionToBounds(position, clip));
|
||
}
|
||
return positions;
|
||
}
|
||
|
||
private void UpdateFinalPresentationPhrase(
|
||
IReadOnlyList<string> characters,
|
||
List<Vector3> positions)
|
||
{
|
||
finalTargetCharacters.Clear();
|
||
finalTargetPositions.Clear();
|
||
for (int i = 0; i < characters.Count; i++)
|
||
finalTargetCharacters.Add(characters[i]);
|
||
finalTargetPositions.AddRange(positions);
|
||
}
|
||
|
||
public void SetTruthWarmVisuals(Color warmColor)
|
||
{
|
||
ApplyPresentationResolvedVisuals(warmColor);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 梳理完成/真话阶段:目标字保持固定的聚拢目标色,并取消加粗以便大屏辨认。
|
||
/// resolvedColor 保留用于兼容既有演出调用,不再允许演出覆盖粒子目标色。
|
||
/// </summary>
|
||
public void ApplyPresentationResolvedVisuals(Color resolvedColor)
|
||
{
|
||
foreach (CandidateParticle particle in targetParticles)
|
||
{
|
||
if (particle == null) continue;
|
||
particle.SetColors(
|
||
targetParticleColor,
|
||
nonTargetParticleColor,
|
||
nonTargetParticleColor,
|
||
targetCalmColor);
|
||
particle.resolvedColorProgress = 1f;
|
||
particle.calmProgress = 1f;
|
||
particle.SetFontStyles(
|
||
targetParticleFontSize,
|
||
nonTargetParticleFontSize,
|
||
false,
|
||
nonTargetParticleBold);
|
||
}
|
||
}
|
||
|
||
public IEnumerator FlashTruthFragment(string fragment, float duration = 0.22f)
|
||
{
|
||
if (string.IsNullOrEmpty(fragment) || orderedRedParticles.Count == 0)
|
||
yield break;
|
||
|
||
List<string> targetElements = ExpressionTextTokenizer.GetVisibleElements(targetSentence);
|
||
List<string> fragmentElements = ExpressionTextTokenizer.GetVisibleElements(fragment);
|
||
int startIndex = ExpressionTextTokenizer.FindVisibleSequence(targetElements, fragmentElements);
|
||
if (startIndex < 0)
|
||
{
|
||
Debug.LogWarning($"[LanguageParticleManager] 真话泄漏片段“{fragment}”不在目标句“{targetSentence}”中。");
|
||
yield break;
|
||
}
|
||
|
||
var leaked = new List<CandidateParticle>();
|
||
int endExclusive = Mathf.Min(startIndex + fragmentElements.Count, orderedRedParticles.Count);
|
||
for (int i = startIndex; i < endExclusive; i++)
|
||
{
|
||
CandidateParticle particle = orderedRedParticles[i];
|
||
if (particle == null) continue;
|
||
particle.ForceSetCharacter(targetElements[i]);
|
||
particle.isStatic = true;
|
||
particle.alpha = 1f;
|
||
leaked.Add(particle);
|
||
}
|
||
|
||
yield return new WaitForSeconds(Mathf.Max(0.02f, duration));
|
||
|
||
if (completionPhase == CompletionPhase.FocusHolding)
|
||
{
|
||
foreach (CandidateParticle particle in leaked)
|
||
{
|
||
if (particle == null) continue;
|
||
particle.alpha = 0.2f;
|
||
particle.isStatic = false;
|
||
particle.ResetChangeTimer(0.02f);
|
||
}
|
||
}
|
||
}
|
||
|
||
public Bounds GetTargetWorldBounds()
|
||
{
|
||
bool hasBounds = false;
|
||
Bounds result = new Bounds(
|
||
worldCanvas != null ? worldCanvas.transform.position : transform.position,
|
||
Vector3.zero);
|
||
|
||
foreach (CandidateParticle particle in targetParticles)
|
||
{
|
||
if (particle == null || particle.textMesh == null || particle.textMesh.renderer == null)
|
||
continue;
|
||
|
||
Bounds textBounds = particle.textMesh.renderer.bounds;
|
||
if (!hasBounds)
|
||
{
|
||
result = textBounds;
|
||
hasBounds = true;
|
||
}
|
||
else
|
||
{
|
||
result.Encapsulate(textBounds);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
public IEnumerator ResolveFocusAndWait(float totalDuration)
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] ResolveFocusAndWait 在 {completionPhase} 状态被调用;已安全跳过。");
|
||
yield break;
|
||
}
|
||
ResolveFocusSequence(totalDuration);
|
||
yield return WaitUntilFocusFinished();
|
||
}
|
||
|
||
public void SnapTruthImmediate()
|
||
{
|
||
if (completionPhase != CompletionPhase.FocusHolding)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[LanguageParticleManager] 真话瞬间稳定在 {completionPhase} 状态被调用;已安全跳过。");
|
||
return;
|
||
}
|
||
|
||
DOTween.Kill(LieCompressionTweenId);
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
truthGatherProgress = 0f;
|
||
StopSlotMachineShuffle();
|
||
completionPhase = CompletionPhase.Focusing;
|
||
focusTweensCompleted = true;
|
||
focusTimer = 0f;
|
||
|
||
foreach (TargetFocusVisual visual in focusVisuals.Values)
|
||
{
|
||
if (visual?.particle == null)
|
||
continue;
|
||
|
||
CandidateParticle particle = visual.particle;
|
||
particle.transform.DOKill();
|
||
particle.transform.position = visual.finalPosition;
|
||
particle.ForceSetCharacter(visual.finalChar);
|
||
particle.SetChangeInterval(1.5f);
|
||
particle.ResetChangeTimer(1.5f);
|
||
particle.anxietyShakeIntensity = 0f;
|
||
particle.changeSpeedMultiplier = 1f;
|
||
particle.calmProgress = 1f;
|
||
particle.resolvedColorProgress = 1f;
|
||
particle.isStatic = true;
|
||
visual.stabilized = true;
|
||
}
|
||
|
||
resolveTotalDurationOverride = -1f;
|
||
lieEdgeCompression = 0f;
|
||
lieEdgeJitter = 0f;
|
||
CompleteFocusSequence();
|
||
}
|
||
|
||
public void RestoreTargetPresentationDefaults(float alphaValue = 1f)
|
||
{
|
||
DOTween.Kill(LieCompressionTweenId);
|
||
DOTween.Kill(TruthGatherTweenId);
|
||
DOTween.Kill(FocusInterferenceTweenId);
|
||
truthGatherProgress = 0f;
|
||
StopSlotMachineShuffle();
|
||
foreach (CandidateParticle particle in targetParticles)
|
||
{
|
||
if (particle == null) continue;
|
||
DOTween.Kill(particle);
|
||
particle.alpha = Mathf.Clamp01(alphaValue);
|
||
particle.calmProgress = 0f;
|
||
particle.resolvedColorProgress = 0f;
|
||
particle.ClearFocusInterference();
|
||
particle.SetFocusInterference(
|
||
focusInterferenceColor,
|
||
focusInterferenceShakeAmplitude,
|
||
focusInterferenceShakeSpeed);
|
||
particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor);
|
||
particle.SetFontStyles(
|
||
targetParticleFontSize,
|
||
nonTargetParticleFontSize,
|
||
targetParticleBold,
|
||
nonTargetParticleBold);
|
||
}
|
||
resolveTotalDurationOverride = -1f;
|
||
lieEdgeCompression = 0f;
|
||
lieEdgeJitter = 0f;
|
||
}
|
||
|
||
public void ReleaseScreenClipMaterials()
|
||
{
|
||
foreach (CandidateParticle particle in candidateParticles)
|
||
particle?.ClearScreenClip();
|
||
foreach (FloatingTextParticle particle in floatingParticles)
|
||
particle?.ClearScreenClip();
|
||
}
|
||
|
||
private void RebuildScreenClipMaterials()
|
||
{
|
||
foreach (CandidateParticle particle in candidateParticles)
|
||
ConfigureParticleScreenMask(particle);
|
||
foreach (FloatingTextParticle particle in floatingParticles)
|
||
ConfigureParticleScreenMask(particle);
|
||
}
|
||
|
||
private void DestroyFocusVisualDecorationsAndClear()
|
||
{
|
||
focusVisuals.Clear();
|
||
}
|
||
|
||
private void CompleteFocusSequence()
|
||
{
|
||
var particlesToScale = new List<CandidateParticle>();
|
||
foreach (var visual in focusVisuals.Values)
|
||
{
|
||
if (visual?.particle != null)
|
||
particlesToScale.Add(visual.particle);
|
||
}
|
||
DestroyFocusVisualDecorationsAndClear();
|
||
foreach (var p in particlesToScale)
|
||
{
|
||
p.transform.DOScale(Vector3.one * focusZoomScale, 0.3f).SetEase(Ease.OutQuad);
|
||
p.isStatic = true;
|
||
p.changeSpeedMultiplier = 1f;
|
||
p.resolvedColorProgress = 1f;
|
||
}
|
||
|
||
if (connectionRenderer != null)
|
||
{
|
||
connectionRenderer.TargetAlphaScale = 0f;
|
||
}
|
||
|
||
lieEdgeCompression = 0f;
|
||
lieEdgeJitter = 0f;
|
||
truthGatherProgress = 0f;
|
||
completionPhase = CompletionPhase.Finished;
|
||
}
|
||
|
||
private Vector3 GetDynamicArrangementTarget(TargetFocusVisual visual, float arrangementProgress, float localTime)
|
||
{
|
||
if (visual == null)
|
||
{
|
||
return Vector3.zero;
|
||
}
|
||
|
||
Vector3 target = visual.finalPosition;
|
||
if (!visual.stabilized && finalTargetPositions.Count > 1)
|
||
{
|
||
int swapIndex = (visual.orderIndex + 1) % finalTargetPositions.Count;
|
||
swapIndex = Mathf.Clamp(swapIndex, 0, finalTargetPositions.Count - 1);
|
||
Vector3 neighbor = finalTargetPositions[swapIndex];
|
||
float swapStrength = Mathf.Clamp01(Mathf.Sin(Mathf.Max(0f, localTime) * shuffleFrequency * 0.25f));
|
||
float blend = (1f - arrangementProgress) * swapStrength * 0.5f;
|
||
target = Vector3.Lerp(target, neighbor, blend);
|
||
}
|
||
|
||
return target;
|
||
}
|
||
|
||
private Vector3 CalculateShuffleOffset(TargetFocusVisual visual, float arrangementProgress, float localTime)
|
||
{
|
||
if (visual == null || shuffleDistance <= 0f || visual.stabilized)
|
||
{
|
||
return Vector3.zero;
|
||
}
|
||
|
||
float shuffleAmount = Mathf.Clamp01(1f - arrangementProgress);
|
||
if (shuffleAmount <= 0f)
|
||
{
|
||
return Vector3.zero;
|
||
}
|
||
|
||
float timeSeed = (focusTimer + localTime * 0.35f) * shuffleFrequency + visual.orderIndex * 0.75f;
|
||
float x = Mathf.Sin(timeSeed) * shuffleDistance * shuffleAmount;
|
||
float y = Mathf.Cos(timeSeed * 0.8f) * shuffleDistance * shuffleVerticalScale * shuffleAmount;
|
||
return new Vector3(x, y, 0f);
|
||
}
|
||
|
||
private Vector3 CalculateLieEdgeJitter(TargetFocusVisual visual, float compression)
|
||
{
|
||
if (visual == null || compression <= 0f || lieEdgeJitter <= 0f)
|
||
return Vector3.zero;
|
||
|
||
float seed = visual.orderIndex * 1.731f;
|
||
float x = Mathf.Sin(focusTimer * 29f + seed) +
|
||
Mathf.Sin(focusTimer * 53f + seed * 0.61f) * 0.35f;
|
||
float y = Mathf.Cos(focusTimer * 37f + seed * 1.17f) +
|
||
Mathf.Sin(focusTimer * 61f + seed) * 0.28f;
|
||
return new Vector3(x, y, 0f) * lieEdgeJitter * compression;
|
||
}
|
||
|
||
private void FadeOutTextParticle(TextParticle particle, float duration)
|
||
{
|
||
if (particle == null) return;
|
||
|
||
DOTween.To(() => particle.alpha, value => particle.alpha = value, 0f, duration)
|
||
.SetEase(Ease.OutQuad)
|
||
.SetTarget(particle);
|
||
|
||
}
|
||
|
||
/// <summary>
|
||
/// 淡出 UI 元素(使用 CanvasGroup),结束后 SetActive(false)
|
||
/// </summary>
|
||
private void FadeOutUIElement(GameObject obj, float duration)
|
||
{
|
||
if (obj == null || !obj.activeSelf) return;
|
||
|
||
var cg = obj.GetComponent<CanvasGroup>();
|
||
if (cg == null)
|
||
cg = obj.AddComponent<CanvasGroup>();
|
||
cg.DOKill();
|
||
cg.alpha = 1f;
|
||
cg.DOFade(0f, duration)
|
||
.SetEase(Ease.OutQuad)
|
||
.OnComplete(() => obj.SetActive(false));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 淡出 SpriteRenderer,结束后禁用并隐藏
|
||
/// </summary>
|
||
private void FadeOutSpriteRenderer(SpriteRenderer sr, float duration)
|
||
{
|
||
if (sr == null || !sr.gameObject.activeSelf) return;
|
||
|
||
sr.DOFade(0f, duration)
|
||
.SetEase(Ease.OutQuad)
|
||
.OnComplete(() =>
|
||
{
|
||
sr.enabled = false;
|
||
sr.gameObject.SetActive(false);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 立即隐藏火山释放 log(无动画),用于新一轮 start 前先淡出文字、再单独淡入 log。
|
||
/// </summary>
|
||
private void HideReleaseLogImmediate()
|
||
{
|
||
if (releaseLogSpriteRenderer == null)
|
||
return;
|
||
DOTween.Kill(releaseLogSpriteRenderer);
|
||
releaseLogSpriteRenderer.enabled = false;
|
||
releaseLogSpriteRenderer.gameObject.SetActive(false);
|
||
Color c = releaseLogSpriteRenderer.color;
|
||
c.a = 0f;
|
||
releaseLogSpriteRenderer.color = c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 若火山释放 log 界面已淡出,则淡入显示(start_expression 时调用,确保流程开始前界面可见)
|
||
/// </summary>
|
||
/// <param name="duration">淡入时长(秒)</param>
|
||
public IEnumerator FadeInReleaseLogIfNeeded(float duration = 1f)
|
||
{
|
||
if (releaseLogSpriteRenderer == null) yield break;
|
||
if (releaseLogSpriteRenderer.gameObject.activeSelf && releaseLogSpriteRenderer.color.a > 0.9f)
|
||
yield break; // 已显示,跳过
|
||
|
||
DOTween.Kill(releaseLogSpriteRenderer);
|
||
releaseLogSpriteRenderer.enabled = true;
|
||
releaseLogSpriteRenderer.gameObject.SetActive(true);
|
||
Color c = releaseLogSpriteRenderer.color;
|
||
c.a = 0f;
|
||
releaseLogSpriteRenderer.color = c;
|
||
yield return releaseLogSpriteRenderer.DOFade(1f, duration).SetEase(Ease.OutQuad).WaitForCompletion();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 恢复 SpriteRenderer 显示(用于重新开始时恢复火山释放 log 界面)
|
||
/// </summary>
|
||
private void RestoreSpriteRenderer(SpriteRenderer sr)
|
||
{
|
||
if (sr == null) return;
|
||
DOTween.Kill(sr);
|
||
sr.enabled = true;
|
||
sr.gameObject.SetActive(true);
|
||
Color c = sr.color;
|
||
c.a = 1f;
|
||
sr.color = c;
|
||
}
|
||
|
||
private void KillCompletionUiTweens()
|
||
{
|
||
KillCanvasGroupTween(languageDeepPanel1);
|
||
KillCanvasGroupTween(languageDeepPanel2);
|
||
if (focusSequenceButton != null)
|
||
KillCanvasGroupTween(focusSequenceButton.gameObject);
|
||
if (releaseLogSpriteRenderer != null)
|
||
DOTween.Kill(releaseLogSpriteRenderer);
|
||
}
|
||
|
||
private static void KillCanvasGroupTween(GameObject obj)
|
||
{
|
||
if (obj == null) return;
|
||
CanvasGroup group = obj.GetComponent<CanvasGroup>();
|
||
if (group != null)
|
||
group.DOKill();
|
||
}
|
||
|
||
private void RestoreCompletionUiAlpha()
|
||
{
|
||
RestoreCanvasGroupAlpha(languageDeepPanel1);
|
||
RestoreCanvasGroupAlpha(languageDeepPanel2);
|
||
if (focusSequenceButton != null)
|
||
RestoreCanvasGroupAlpha(focusSequenceButton.gameObject);
|
||
}
|
||
|
||
private static void RestoreCanvasGroupAlpha(GameObject obj)
|
||
{
|
||
if (obj == null) return;
|
||
CanvasGroup group = obj.GetComponent<CanvasGroup>();
|
||
if (group != null)
|
||
group.alpha = 1f;
|
||
}
|
||
|
||
private Vector3 ComputeCentroid(List<CandidateParticle> particles)
|
||
{
|
||
if (particles == null || particles.Count == 0)
|
||
{
|
||
return worldCanvas != null ? worldCanvas.transform.position : Vector3.zero;
|
||
}
|
||
|
||
Vector3 sum = Vector3.zero;
|
||
foreach (var particle in particles)
|
||
{
|
||
sum += particle.transform.position;
|
||
}
|
||
|
||
return sum / particles.Count;
|
||
}
|
||
|
||
private void UpdateInput()
|
||
{
|
||
// 只在允许输入时处理鼠标逻辑
|
||
if (!allowInput || completionPhase != CompletionPhase.None) return;
|
||
|
||
Vector2 mouseWorldPos = GetMouseWorldPosition();
|
||
|
||
// 左键点击:排斥附近粒子
|
||
if (Input.GetMouseButton(0))
|
||
{
|
||
ApplyForceToNearbyParticles(mouseWorldPos, mouseInteractionRadius, repulsionForce);
|
||
}
|
||
|
||
// 右键点击:吸引附近粒子
|
||
if (Input.GetMouseButton(1))
|
||
{
|
||
ApplyForceToNearbyParticles(mouseWorldPos, mouseInteractionRadius, -attractionForce);
|
||
}
|
||
}
|
||
|
||
private void ApplyForceToNearbyParticles(Vector2 center, float repulsionRadius, float repulsionForce)
|
||
{
|
||
foreach (var particle in candidateParticles)
|
||
{
|
||
Vector2 particlePos = particle.transform.position;
|
||
float distance = Vector2.Distance(center, particlePos);
|
||
|
||
if (distance < repulsionRadius && distance > 0.01f)
|
||
{
|
||
// 计算方向和力度
|
||
Vector2 direction = (particlePos - center).normalized;
|
||
float forceMagnitude = repulsionForce * (1f - distance / repulsionRadius);
|
||
|
||
// 应用力
|
||
particle.ApplyForce(direction * forceMagnitude);
|
||
}
|
||
}
|
||
}
|
||
|
||
private Vector2 GetMouseWorldPosition()
|
||
{
|
||
Vector3 mousePos = Input.mousePosition;
|
||
Camera cam = Camera.main;
|
||
if (cam != null)
|
||
{
|
||
// 对于正交相机,需要使用正确的Z距离
|
||
float zDistance = cam.transform.position.z - worldCanvas.transform.position.z;
|
||
Vector3 worldPos = cam.ScreenToWorldPoint(new Vector3(mousePos.x, mousePos.y, Mathf.Abs(zDistance)));
|
||
return new Vector2(worldPos.x, worldPos.y);
|
||
}
|
||
return Vector2.zero;
|
||
}
|
||
|
||
public void ResetGame(bool playEntranceEffect = true)
|
||
{
|
||
StopFocusTransitionRoutine();
|
||
KillCompletionUiTweens();
|
||
RestoreTargetPresentationDefaults();
|
||
RestoreCompletionUiAlpha();
|
||
isCompleted = false;
|
||
completionPhase = CompletionPhase.None;
|
||
pendingCompletionDialog = false;
|
||
completionTimer = 0f;
|
||
focusTimer = 0f;
|
||
fadeOutAlpha = 1f;
|
||
allowInput = !playEntranceEffect;
|
||
orderedRedParticles.Clear();
|
||
DestroyFocusVisualDecorationsAndClear();
|
||
finalTargetCharacters.Clear();
|
||
finalTargetPositions.Clear();
|
||
previousConnections.Clear();
|
||
initializationComplete = false;
|
||
initializationFrames = 0;
|
||
focusTweensCompleted = false;
|
||
interferenceLogicSuspended = false;
|
||
ClearCompletionClampSmoothVelocity();
|
||
SetStatusUIVisibility(true, 0f);
|
||
if (entranceRoutine != null)
|
||
{
|
||
StopCoroutine(entranceRoutine);
|
||
entranceRoutine = null;
|
||
}
|
||
|
||
if (confirmTimelineRoutine != null)
|
||
{
|
||
StopCoroutine(confirmTimelineRoutine);
|
||
confirmTimelineRoutine = null;
|
||
}
|
||
|
||
if (expressionPanelRoutine != null)
|
||
{
|
||
StopCoroutine(expressionPanelRoutine);
|
||
expressionPanelRoutine = null;
|
||
}
|
||
|
||
if (focusSequenceButton != null)
|
||
{
|
||
focusSequenceButton.onClick.RemoveListener(HandleFocusButtonClicked);
|
||
focusSequenceButton.onClick.AddListener(HandleFocusButtonClicked);
|
||
focusSequenceButton.interactable = false;
|
||
focusSequenceButton.gameObject.SetActive(false);
|
||
}
|
||
|
||
if (languageDeepPanel1 != null)
|
||
languageDeepPanel1.SetActive(false);
|
||
if (languageDeepPanel2 != null)
|
||
languageDeepPanel2.SetActive(false);
|
||
|
||
// 恢复火山释放 log 界面 SpriteRenderer(重新开始时恢复)
|
||
RestoreSpriteRenderer(releaseLogSpriteRenderer);
|
||
|
||
if (connectionRenderer != null)
|
||
{
|
||
connectionRenderer.NonTargetAlphaScale = lineAlphaMultiplier;
|
||
connectionRenderer.TargetAlphaScale = 1f;
|
||
}
|
||
|
||
// 重置所有粒子
|
||
foreach (var p in candidateParticles)
|
||
{
|
||
p.isRed = false;
|
||
p.isOrange = false;
|
||
p.hasEffect = false;
|
||
p.isStatic = false;
|
||
p.originalIsRed = false;
|
||
p.changeSpeedMultiplier = 1f;
|
||
p.vibrationOffset = Vector2.zero;
|
||
p.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f));
|
||
p.transform.position = GetRandomPositionInBounds();
|
||
p.isCalmed = false;
|
||
p.calmProgress = 0f;
|
||
p.resolvedColorProgress = 0f;
|
||
p.ClearFocusInterference();
|
||
p.SetFocusInterference(
|
||
focusInterferenceColor,
|
||
focusInterferenceShakeAmplitude,
|
||
focusInterferenceShakeSpeed);
|
||
p.alpha = p.baseAlpha;
|
||
p.transform.localScale = Vector3.one;
|
||
DOTween.Kill(p);
|
||
p.transform.DOKill();
|
||
p.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude);
|
||
|
||
}
|
||
|
||
foreach (var floating in floatingParticles)
|
||
{
|
||
floating.isCalmed = false;
|
||
floating.alpha = floating.baseAlpha;
|
||
DOTween.Kill(floating);
|
||
floating.transform.DOKill();
|
||
}
|
||
|
||
ReleaseScreenClipMaterials();
|
||
RebuildScreenClipMaterials();
|
||
|
||
SelectRedParticles();
|
||
RefreshAllRandomCharacters();
|
||
UpdateStatusUI();
|
||
if (playEntranceEffect)
|
||
{
|
||
PlayEntranceSequence();
|
||
}
|
||
}
|
||
|
||
private void PlayEntranceSequence()
|
||
{
|
||
if (!gameObject.activeInHierarchy || candidateParticles.Count == 0)
|
||
{
|
||
Debug.LogWarning($"[LanguageParticleManager] PlayEntranceSequence 跳过: activeInHierarchy={gameObject.activeInHierarchy}, candidateParticles.Count={candidateParticles.Count} (不会播放火山表达panel)");
|
||
allowInput = true; // 否则 allowInput 会一直为 false
|
||
return;
|
||
}
|
||
|
||
if (entranceRoutine != null)
|
||
{
|
||
StopCoroutine(entranceRoutine);
|
||
}
|
||
|
||
entranceRoutine = StartCoroutine(EntranceSequenceRoutine());
|
||
}
|
||
|
||
private IEnumerator EntranceSequenceRoutine()
|
||
{
|
||
allowInput = false;
|
||
entranceTargets.Clear();
|
||
|
||
Vector3 burstOrigin = worldCanvas != null ? worldCanvas.transform.position : transform.position;
|
||
List<CandidateParticle> particles = candidateParticles.Where(p => p != null).ToList();
|
||
if (particles.Count == 0)
|
||
{
|
||
allowInput = true;
|
||
entranceRoutine = null;
|
||
yield break;
|
||
}
|
||
|
||
// panel / 状态文字与粒子入场并行:先出字,不把交互锁到整段 UI 动效播完
|
||
if (expressionPanelRoutine != null)
|
||
{
|
||
StopCoroutine(expressionPanelRoutine);
|
||
}
|
||
expressionPanelRoutine = StartCoroutine(PlayExpressionPanelThenReady());
|
||
|
||
float initialScale = Mathf.Max(0.05f, entranceInitialScale);
|
||
|
||
foreach (var candidate in particles)
|
||
{
|
||
entranceTargets[candidate] = candidate.transform.position;
|
||
candidate.transform.DOKill();
|
||
candidate.velocity = Vector2.zero;
|
||
candidate.transform.position = burstOrigin;
|
||
candidate.transform.localScale = Vector3.one * initialScale;
|
||
candidate.alpha = 0f;
|
||
}
|
||
|
||
yield return new WaitForSeconds(Mathf.Max(0f, entranceDelay));
|
||
|
||
int coreCount = Mathf.Clamp(entranceCoreCount, 1, particles.Count);
|
||
var shuffled = particles.OrderBy(_ => Random.value).ToList();
|
||
var coreCandidates = shuffled.Take(coreCount).ToList();
|
||
var remainingCandidates = shuffled.Skip(coreCount).ToList();
|
||
float radius = Mathf.Max(0.1f, entranceCoreRadius);
|
||
float maxCoreTime = 0f;
|
||
|
||
for (int i = 0; i < coreCandidates.Count; i++)
|
||
{
|
||
var candidate = coreCandidates[i];
|
||
float angle = (Mathf.PI * 2f / Mathf.Max(1, coreCandidates.Count)) * i;
|
||
Vector3 startPos = burstOrigin + new Vector3(Mathf.Cos(angle), Mathf.Sin(angle), 0f) * radius;
|
||
candidate.transform.position = startPos;
|
||
|
||
float delay = i * Mathf.Max(0f, entranceCoreStagger);
|
||
Sequence seq = DOTween.Sequence();
|
||
seq.SetDelay(delay);
|
||
seq.Append(candidate.transform.DOMove(entranceTargets[candidate], entranceCoreBurstDuration).SetEase(Ease.OutCubic));
|
||
seq.Join(candidate.transform.DOScale(Vector3.one, entranceCoreBurstDuration).SetEase(Ease.OutBack));
|
||
seq.Join(DOTween.To(() => candidate.alpha, value => candidate.alpha = value, candidate.baseAlpha, entranceCoreBurstDuration).SetEase(Ease.OutQuad));
|
||
seq.OnComplete(() =>
|
||
{
|
||
candidate.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f));
|
||
});
|
||
maxCoreTime = Mathf.Max(maxCoreTime, delay + entranceCoreBurstDuration);
|
||
}
|
||
|
||
float coreLead = 0f;
|
||
if (coreCandidates.Count > 0)
|
||
{
|
||
coreLead = maxCoreTime * 0.6f;
|
||
yield return new WaitForSeconds(coreLead);
|
||
}
|
||
|
||
float cascadeDuration = 0f;
|
||
for (int i = 0; i < remainingCandidates.Count; i++)
|
||
{
|
||
var candidate = remainingCandidates[i];
|
||
Vector2 offset = Random.insideUnitCircle * entranceSearchRadius;
|
||
candidate.transform.position = burstOrigin + new Vector3(offset.x, offset.y, 0f);
|
||
float delay = i * Mathf.Max(0f, entranceCascadeStagger);
|
||
Sequence seq = DOTween.Sequence();
|
||
seq.SetDelay(delay);
|
||
seq.Append(candidate.transform.DOMove(entranceTargets[candidate], entranceCascadeDuration).SetEase(Ease.OutQuad));
|
||
seq.Join(candidate.transform.DOScale(Vector3.one, entranceCascadeDuration * 0.8f).SetEase(Ease.OutBack));
|
||
seq.Join(DOTween.To(() => candidate.alpha, value => candidate.alpha = value, candidate.baseAlpha, entranceCascadeDuration).SetEase(Ease.OutSine));
|
||
seq.OnComplete(() =>
|
||
{
|
||
candidate.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f));
|
||
});
|
||
cascadeDuration = Mathf.Max(cascadeDuration, delay + entranceCascadeDuration);
|
||
}
|
||
|
||
// 只等 cascade / core 剩余时间,避免把已等过的 core 前半段再算一遍
|
||
float remaining = Mathf.Max(maxCoreTime - coreLead, cascadeDuration);
|
||
if (remaining > 0f)
|
||
{
|
||
yield return new WaitForSeconds(remaining);
|
||
}
|
||
|
||
foreach (var candidate in particles)
|
||
{
|
||
if (candidate == null) continue;
|
||
candidate.alpha = candidate.baseAlpha;
|
||
candidate.transform.localScale = Vector3.one;
|
||
}
|
||
|
||
entranceTargets.Clear();
|
||
// 粒子字已到位即可交互;panel 环光等动效继续播,不再挡输入
|
||
allowInput = true;
|
||
entranceRoutine = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 独立协程:播放火山表达 panel / 状态文字(与粒子入场并行,不负责解锁交互)
|
||
/// </summary>
|
||
private IEnumerator PlayExpressionPanelThenReady()
|
||
{
|
||
ResolveScreenPresentation();
|
||
if (screenPresentation != null)
|
||
{
|
||
if (languageDeepPanel1 != null)
|
||
languageDeepPanel1.SetActive(true);
|
||
if (languageDeepPanel2 != null)
|
||
languageDeepPanel2.SetActive(true);
|
||
|
||
yield return screenPresentation.PlayPanelAndWait();
|
||
if (screenPresentation == null ||
|
||
screenPresentation.State != ExpressionScreenPresentationController.ScreenState.Panel)
|
||
{
|
||
expressionPanelRoutine = null;
|
||
yield break;
|
||
}
|
||
}
|
||
else if (!string.IsNullOrEmpty(expressionPanelTimelineName) && TimelineCenter.Instance != null)
|
||
{
|
||
// 火山表达 panel timeline 驱动 panel1/2,播放前需先激活(若之前被 fade 过,需重置 alpha)
|
||
if (languageDeepPanel1 != null)
|
||
{
|
||
var cg1 = languageDeepPanel1.GetComponent<CanvasGroup>();
|
||
if (cg1 != null) cg1.alpha = 1f;
|
||
languageDeepPanel1.SetActive(true);
|
||
}
|
||
if (languageDeepPanel2 != null)
|
||
{
|
||
var cg2 = languageDeepPanel2.GetComponent<CanvasGroup>();
|
||
if (cg2 != null) cg2.alpha = 1f;
|
||
languageDeepPanel2.SetActive(true);
|
||
}
|
||
|
||
yield return null; // 确保 DirectorHandler 已注册
|
||
yield return TimelineCenter.Instance.PlayTimelineAsync(expressionPanelTimelineName);
|
||
}
|
||
else if (string.IsNullOrEmpty(expressionPanelTimelineName))
|
||
{
|
||
Debug.LogWarning("[LanguageParticleManager] expressionPanelTimelineName 未配置,跳过火山表达panel播放");
|
||
}
|
||
else if (TimelineCenter.Instance == null)
|
||
{
|
||
Debug.LogWarning("[LanguageParticleManager] TimelineCenter.Instance 为空,无法播放火山表达panel");
|
||
}
|
||
|
||
expressionPanelRoutine = null;
|
||
}
|
||
|
||
private void ApplySeparationForces()
|
||
{
|
||
if (!enableSeparation || candidateParticles.Count <= 1)
|
||
return;
|
||
|
||
for (int i = 0; i < candidateParticles.Count - 1; i++)
|
||
{
|
||
var particleA = candidateParticles[i];
|
||
if (particleA == null)
|
||
continue;
|
||
|
||
for (int j = i + 1; j < candidateParticles.Count; j++)
|
||
{
|
||
var particleB = candidateParticles[j];
|
||
if (particleB == null || (particleA.isStatic && particleB.isStatic))
|
||
continue;
|
||
|
||
Vector2 posA = particleA.transform.position;
|
||
Vector2 posB = particleB.transform.position;
|
||
Vector2 diff = posA - posB;
|
||
float distSqr = diff.sqrMagnitude;
|
||
float requiredSpacing = CalculateRequiredParticleSpacing(particleA, particleB);
|
||
if (distSqr >= requiredSpacing * requiredSpacing)
|
||
continue;
|
||
|
||
float distance;
|
||
Vector2 direction;
|
||
if (distSqr > 0.000001f)
|
||
{
|
||
distance = Mathf.Sqrt(distSqr);
|
||
direction = diff / distance;
|
||
}
|
||
else
|
||
{
|
||
// 完全重合时 normalized 会得到零向量;使用稳定的配对方向打破死锁。
|
||
distance = 0f;
|
||
float angle = (i * 37f + j * 61f) * 2.3999632f;
|
||
direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
|
||
}
|
||
|
||
float overlap = requiredSpacing - distance;
|
||
float normalizedOverlap = Mathf.Clamp01(overlap / Mathf.Max(requiredSpacing, 0.0001f));
|
||
bool differentTypes = particleA.originalIsRed != particleB.originalIsRed;
|
||
float forceMultiplier = differentTypes
|
||
? Mathf.Max(1f, differentTypeForceMultiplier)
|
||
: 1f;
|
||
Vector2 push = direction *
|
||
(normalizedOverlap * separationForce * forceMultiplier * Time.deltaTime);
|
||
bool moveA = !particleA.isStatic;
|
||
bool moveB = !particleB.isStatic;
|
||
|
||
if (moveA)
|
||
particleA.ApplyForce(push * (moveB ? 0.5f : 1f));
|
||
if (moveB)
|
||
particleB.ApplyForce(-push * (moveA ? 0.5f : 1f));
|
||
|
||
float correction = Mathf.Min(
|
||
overlap * separationPositionCorrection,
|
||
maxSeparationCorrectionPerFrame);
|
||
if (correction <= 0f)
|
||
continue;
|
||
|
||
if (moveA)
|
||
{
|
||
float share = moveB ? 0.5f : 1f;
|
||
particleA.transform.position += (Vector3)(direction * correction * share);
|
||
}
|
||
if (moveB)
|
||
{
|
||
float share = moveA ? 0.5f : 1f;
|
||
particleB.transform.position -= (Vector3)(direction * correction * share);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private float CalculateRequiredParticleSpacing(
|
||
CandidateParticle particleA,
|
||
CandidateParticle particleB)
|
||
{
|
||
float required = Mathf.Max(0.01f, minParticleSpacing);
|
||
if (!useGlyphBoundsForSeparation)
|
||
return required;
|
||
|
||
float radiusA = GetParticleGlyphRadius(particleA, required * 0.5f);
|
||
float radiusB = GetParticleGlyphRadius(particleB, required * 0.5f);
|
||
float glyphSpacing = radiusA + radiusB + Mathf.Max(0f, separationPadding);
|
||
if (particleA.originalIsRed != particleB.originalIsRed)
|
||
{
|
||
glyphSpacing *= Mathf.Max(1f, differentTypeSpacingMultiplier);
|
||
}
|
||
|
||
return Mathf.Max(required, glyphSpacing);
|
||
}
|
||
|
||
private static float GetParticleGlyphRadius(
|
||
CandidateParticle particle,
|
||
float fallback)
|
||
{
|
||
if (particle?.textMesh == null || particle.textMesh.renderer == null)
|
||
return fallback;
|
||
|
||
Bounds bounds = particle.textMesh.renderer.bounds;
|
||
float radius = Mathf.Max(bounds.extents.x, bounds.extents.y);
|
||
return radius > 0.0001f ? radius : fallback;
|
||
}
|
||
|
||
private void UpdateStatusUI()
|
||
{
|
||
if (statusUIHidden)
|
||
return;
|
||
|
||
if (integrationProgressText != null)
|
||
{
|
||
float ratio = CalculateIntegrationRatio();
|
||
int percent = Mathf.RoundToInt(ratio * 100f);
|
||
integrationProgressText.text = string.Format(integrationStatusFormat, percent);
|
||
}
|
||
|
||
if (interferenceCountText != null)
|
||
{
|
||
int interferenceCount = CountInterferenceNodes();
|
||
interferenceCountText.text = string.Format(interferenceStatusFormat, interferenceCount);
|
||
}
|
||
}
|
||
|
||
private float CalculateIntegrationRatio()
|
||
{
|
||
if (targetParticles.Count == 0)
|
||
return 0f;
|
||
|
||
HashSet<CandidateParticle> visited = new HashSet<CandidateParticle>();
|
||
int largestCluster = 0;
|
||
|
||
foreach (var red in targetParticles)
|
||
{
|
||
if (visited.Contains(red))
|
||
continue;
|
||
|
||
int clusterSize = 0;
|
||
Queue<CandidateParticle> queue = new Queue<CandidateParticle>();
|
||
queue.Enqueue(red);
|
||
visited.Add(red);
|
||
|
||
while (queue.Count > 0)
|
||
{
|
||
var current = queue.Dequeue();
|
||
clusterSize++;
|
||
|
||
foreach (var connected in current.connections)
|
||
{
|
||
if (connected == null || !connected.isRed)
|
||
continue;
|
||
|
||
if (!visited.Contains(connected))
|
||
{
|
||
visited.Add(connected);
|
||
queue.Enqueue(connected);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (clusterSize > largestCluster)
|
||
{
|
||
largestCluster = clusterSize;
|
||
}
|
||
}
|
||
|
||
largestCluster = Mathf.Clamp(largestCluster, 0, targetParticles.Count);
|
||
return largestCluster / Mathf.Max(1f, targetParticles.Count);
|
||
}
|
||
|
||
private int CountInterferenceNodes()
|
||
{
|
||
if (targetParticles.Count == 0)
|
||
return 0;
|
||
|
||
HashSet<CandidateParticle> interferingParticles = new HashSet<CandidateParticle>();
|
||
|
||
foreach (var red in targetParticles)
|
||
{
|
||
foreach (var connected in red.connections)
|
||
{
|
||
if (connected == null)
|
||
continue;
|
||
|
||
if (!connected.isRed)
|
||
{
|
||
interferingParticles.Add(connected);
|
||
}
|
||
}
|
||
}
|
||
|
||
return interferingParticles.Count;
|
||
}
|
||
|
||
private void SetStatusUIVisibility(bool visible, float duration = 0f)
|
||
{
|
||
statusUIHidden = !visible;
|
||
float integrationAlpha = visible ? integrationTextBaseAlpha : 0f;
|
||
float interferenceAlpha = visible ? interferenceTextBaseAlpha : 0f;
|
||
|
||
AnimateStatusText(integrationProgressText, integrationAlpha, duration);
|
||
AnimateStatusText(interferenceCountText, interferenceAlpha, duration);
|
||
}
|
||
|
||
private void AnimateStatusText(TMP_Text text, float targetAlpha, float duration)
|
||
{
|
||
if (text == null)
|
||
return;
|
||
|
||
DOTween.Kill(text);
|
||
if (duration > 0f)
|
||
{
|
||
text.DOFade(targetAlpha, duration);
|
||
}
|
||
else
|
||
{
|
||
Color color = text.color;
|
||
color.a = targetAlpha;
|
||
text.color = color;
|
||
}
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
LocalizationSettings.SelectedLocaleChanged -= HandleLocaleChanged;
|
||
}
|
||
|
||
#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
|
||
}
|
||
}
|
||
|