diff --git a/Assets/Scenes/HuoShanFixScene.unity b/Assets/Scenes/HuoShanFixScene.unity index 601d5da39..1df79dc17 100644 --- a/Assets/Scenes/HuoShanFixScene.unity +++ b/Assets/Scenes/HuoShanFixScene.unity @@ -9251,6 +9251,7 @@ MonoBehaviour: _currentStage: 0 _intensity: 0 _transitionDuration: 0.5 + _compositeMode: 0 _stage1Clip: {fileID: 8300000, guid: 9f4ca2e877b18ff46902c106f56ed672, type: 3} _stage2Clip: {fileID: 8300000, guid: 5b5068defdc3579479bf0efa306a07ca, type: 3} _stage3Clip: {fileID: 8300000, guid: a965c5c0ea798db4787e279d5bef59fa, type: 3} @@ -13253,7 +13254,7 @@ GameObject: - component: {fileID: 1711705855} - component: {fileID: 1711705856} m_Layer: 5 - m_Name: bound + m_Name: ExpressionScreenMaskRect m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 @@ -15006,6 +15007,7 @@ GameObject: m_Component: - component: {fileID: 2138889539} - component: {fileID: 2138889540} + - component: {fileID: 2138889543} - component: {fileID: 2138889542} - component: {fileID: 2138889541} m_Layer: 0 @@ -15048,6 +15050,7 @@ MonoBehaviour: m_EditorClassIdentifier: particleManager: {fileID: 3669563163941125876} expressionView: {fileID: 5162511683866016917} + logReleasePresentation: {fileID: 2138889543} defaultAnxietyPhrases: - "\u6211\u597D\u6015" - "\u600E\u4E48\u529E" @@ -15060,6 +15063,36 @@ MonoBehaviour: ambientNoiseDensity: 0 ambientNoiseScale: 360 ambientNoiseBrightness: 0.5 +--- !u!114 &2138889543 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2138889538} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 2d202e7d79d64fb5a9d7d42e974cbacd, type: 3} + m_Name: + m_EditorClassIdentifier: + particleManager: {fileID: 3669563163941125876} + faceRenderer: {fileID: 2468058580162010626} + glitchController: {fileID: 741515695} + expressionScreenMaskRect: {fileID: 1711705855} + screenFont: {fileID: 11400000, guid: f242fb3dde1933640859f1c54591c9c8, type: 2} + officeMemory: {fileID: 21300000, guid: 1f0146f4ad75ca54da8909a24be3ce03, type: 3} + sunsetMemory: {fileID: 21300000, guid: 4d9b651ad3ad15941a4a49bb381c5a2d, type: 3} + privateOfficeMemory: {fileID: 21300000, guid: 56e0c1ba37badb94d8fa982258ade9e7, type: 3} + memoryMaterial: {fileID: 2100000, guid: f99b647eb8e7425fb1f0e32590dfc77d, type: 2} + faceFadeDuration: 1 + memoryFadeDuration: 0.55 + memoryExitDuration: 0.45 + faceDipRatio: 0.015 + lieColor: {r: 0.66, g: 0.94, b: 1, a: 1} + errorColor: {r: 1, g: 0.34, b: 0.18, a: 1} + truthWarmColor: {r: 1, g: 0.84, b: 0.64, a: 1} + screenGlowSortingOrder: 8 + screenTextSortingOrder: 18 --- !u!114 &2138889541 MonoBehaviour: m_ObjectHideFlags: 0 @@ -16677,7 +16710,7 @@ SpriteRenderer: m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 2 - m_Sprite: {fileID: 21300000, guid: 3af0a72c847ead14baf8582c9358a985, type: 3} + m_Sprite: {fileID: 21300000, guid: 6d0210d3312a4bd4a5042d6aa3d59f5b, type: 3} m_Color: {r: 1, g: 1, b: 1, a: 0} m_FlipX: 0 m_FlipY: 0 @@ -17295,6 +17328,7 @@ MonoBehaviour: clampCompletionPhasePositions: 1 completionScreenClipRect: {fileID: 1711705855} completionClipEdgePadding: 0.2 + minimumScreenMargin: 0.12 completionPositionSmoothTime: 0.2 languageDeepPanel1: {fileID: 1752165850} languageDeepPanel2: {fileID: 2141248582} @@ -17304,6 +17338,9 @@ MonoBehaviour: expressionConfirmTimelineName: "\u706B\u5C71\u8868\u8FBE\u6A21\u5757/\u706B\u5C71\u8868\u8FBE\u786E\u8BA4" focusZoomScale: 1.6 focusMoveDuration: 1.2 + focusFadeDuration: 0.6 + logOperationFadeDuration: 0.6 + glowSortingOrder: 8 focusFadeRatio: 0.8 restartExpressionFadeDuration: 0.55 probabilityDuration: 3.5 diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/CandidateParticle.cs b/Assets/Scripts/MiniGame/HuoShan/Language/CandidateParticle.cs index 3c371ca3e..d885df199 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/CandidateParticle.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/CandidateParticle.cs @@ -215,6 +215,8 @@ namespace AibisDream.MiniGame.Language /// protected override string GetNextDisplayChar() { + if (TryGetOverridePoolChar(out string overrideChar)) + return overrideChar; return IsTarget ? GetRandomChar() : GetRandomCharFromPhrases(); } diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ConnectionRenderer.cs b/Assets/Scripts/MiniGame/HuoShan/Language/ConnectionRenderer.cs index 3027a639f..f2b1e929d 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/ConnectionRenderer.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ConnectionRenderer.cs @@ -37,6 +37,10 @@ namespace AibisDream.MiniGame.Language private float fadeOutAlpha; private float nonTargetAlphaScale = 1f; private float targetAlphaScale = 1f; + private Bounds screenBounds; + private bool hasScreenBounds; + private bool screenClippingEnabled; + private bool warnedMissingBounds; public float NonTargetAlphaScale { @@ -62,11 +66,30 @@ namespace AibisDream.MiniGame.Language fadeOutAlpha = fadeAlpha; } + public void SetScreenBounds(Bounds bounds, bool valid = true) + { + screenBounds = bounds; + hasScreenBounds = valid && bounds.size.x > 0.001f && bounds.size.y > 0.001f; + if (hasScreenBounds) + warnedMissingBounds = false; + } + + public void SetScreenClippingEnabled(bool enabled) + { + screenClippingEnabled = enabled; + } + public override void DrawShapes(Camera cam) { if (candidateParticles == null || candidateParticles.Count == 0) return; + if (screenClippingEnabled && !hasScreenBounds && !warnedMissingBounds) + { + warnedMissingBounds = true; + Debug.LogWarning("[ConnectionRenderer] 未提供有效的 ExpressionScreenMaskRect,连线将沿用未裁切行为。"); + } + using (Draw.Command(cam)) { Draw.BlendMode = ShapesBlendMode.Transparent; @@ -117,7 +140,7 @@ namespace AibisDream.MiniGame.Language Color lineColor = ApplyGlow(nonTargetConnectionColor, nonTargetGlowIntensity); lineColor.a = alpha * nonTargetAlphaScale; - Draw.Line(p1.transform.position, p2.transform.position, lineColor); + DrawClippedLine(p1.transform.position, p2.transform.position, lineColor); } } } @@ -150,7 +173,7 @@ namespace AibisDream.MiniGame.Language Color lineColor = ApplyGlow(targetConnectionColor, targetGlowIntensity); lineColor.a = alpha * targetAlphaScale; - Draw.Line(redParticles[i].transform.position, redParticles[j].transform.position, lineColor); + DrawClippedLine(redParticles[i].transform.position, redParticles[j].transform.position, lineColor); } } } @@ -172,7 +195,7 @@ namespace AibisDream.MiniGame.Language // 绘制连接线(应用发光) Color lineColor = ApplyGlow(propagationColor, propagationGlowIntensity); lineColor.a = 0.3f; - Draw.Line(info.source.transform.position, info.particle.transform.position, lineColor); + DrawClippedLine(info.source.transform.position, info.particle.transform.position, lineColor); // 绘制脉冲光点 if (info.pulseProgress < 1f) @@ -188,11 +211,15 @@ namespace AibisDream.MiniGame.Language // 脉冲光点使用更强的 HDR 发光 Color pulseColor = ApplyGlow(new Color(1f, 0.78f, 0.39f, pulseAlpha), propagationGlowIntensity * 1.5f); - Draw.Ring(pulsePos, pulseSize, pulseSize * 0.5f, pulseColor); - - // 光晕 - pulseColor.a = pulseAlpha * 0.5f; - Draw.Disc(pulsePos, pulseSize * 1.5f, pulseColor); + float visualRadius = pulseSize * 1.5f; + if (TryConstrainPoint(pulsePos, visualRadius, out Vector3 containedPulse)) + { + Draw.Ring(containedPulse, pulseSize, pulseSize * 0.5f, pulseColor); + + // 光晕 + pulseColor.a = pulseAlpha * 0.5f; + Draw.Disc(containedPulse, visualRadius, pulseColor); + } } // 绘制箭头 @@ -208,7 +235,9 @@ namespace AibisDream.MiniGame.Language float angle = Mathf.Atan2(direction.y, direction.x); Color arrowColor = ApplyGlow(new Color(1f, 0.78f, 0.39f, 0.8f), propagationGlowIntensity); - DrawArrow(arrowPos, angle, 0.1f, arrowColor); + const float arrowSize = 0.1f; + if (TryConstrainPoint(arrowPos, arrowSize, out Vector3 containedArrow)) + DrawArrow(containedArrow, angle, arrowSize, arrowColor); } } } @@ -231,6 +260,94 @@ namespace AibisDream.MiniGame.Language Draw.Triangle(tip, left, right, color); } + private void DrawClippedLine(Vector3 from, Vector3 to, Color color) + { + if (screenClippingEnabled && hasScreenBounds) + { + if (!TryClipLineToBounds(from, to, screenBounds, out from, out to)) + return; + } + + Draw.Line(from, to, color); + } + + private bool TryConstrainPoint(Vector3 point, float radius, out Vector3 constrained) + { + if (!screenClippingEnabled || !hasScreenBounds) + { + constrained = point; + return true; + } + + float minX = screenBounds.min.x + radius; + float maxX = screenBounds.max.x - radius; + float minY = screenBounds.min.y + radius; + float maxY = screenBounds.max.y - radius; + if (minX > maxX || minY > maxY) + { + constrained = point; + return false; + } + + constrained = new Vector3( + Mathf.Clamp(point.x, minX, maxX), + Mathf.Clamp(point.y, minY, maxY), + point.z); + return true; + } + + /// + /// Liang–Barsky line clipping against a world-space axis-aligned rectangle. + /// Kept public for component tests and editor diagnostics. + /// + public static bool TryClipLineToBounds( + Vector3 from, + Vector3 to, + Bounds bounds, + out Vector3 clippedFrom, + out Vector3 clippedTo) + { + clippedFrom = from; + clippedTo = to; + + float dx = to.x - from.x; + float dy = to.y - from.y; + float t0 = 0f; + float t1 = 1f; + + if (!ClipTest(-dx, from.x - bounds.min.x, ref t0, ref t1) || + !ClipTest(dx, bounds.max.x - from.x, ref t0, ref t1) || + !ClipTest(-dy, from.y - bounds.min.y, ref t0, ref t1) || + !ClipTest(dy, bounds.max.y - from.y, ref t0, ref t1)) + { + return false; + } + + clippedFrom = Vector3.LerpUnclamped(from, to, t0); + clippedTo = Vector3.LerpUnclamped(from, to, t1); + return true; + } + + private static bool ClipTest(float p, float q, ref float t0, ref float t1) + { + if (Mathf.Approximately(p, 0f)) + return q >= 0f; + + float r = q / p; + if (p < 0f) + { + if (r > t1) return false; + if (r > t0) t0 = r; + } + else + { + if (r < t0) return false; + if (r < t1) t1 = r; + } + + return true; + } + /// /// 设置连接线样式 /// @@ -279,4 +396,3 @@ namespace AibisDream.MiniGame.Language } } } - diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionManager.cs b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionManager.cs index ee27d806d..1c07b283d 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionManager.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/ExpressionManager.cs @@ -12,9 +12,10 @@ namespace AibisDream /// public class ExpressionManager : MonoBehaviour { - [Header("系统组件")] - [SerializeField] private LanguageParticleManager particleManager; - [SerializeField] private GameObject expressionView; + [Header("系统组件")] + [SerializeField] private LanguageParticleManager particleManager; + [SerializeField] private GameObject expressionView; + [SerializeField] private LogReleasePresentationController logReleasePresentation; [Header("默认配置")] [SerializeField] private List defaultAnxietyPhrases = new List @@ -32,6 +33,8 @@ namespace AibisDream [Tooltip("噪波故障效果控制器,可选;置于 Expression View 子物体上")] [SerializeField] private SpriteNoiseGlitchController glitchController; + public LogReleasePresentationController LogReleasePresentation => logReleasePresentation; + void Start() { // 注册到系统字典 @@ -48,16 +51,26 @@ namespace AibisDream expressionView.SetActive(false); } - // 查找粒子管理器 - if (particleManager == null) - { - particleManager = GetComponentInChildren(true); - } + // 查找粒子管理器 + if (particleManager == null) + { + particleManager = GetComponentInChildren(true); + } - if (glitchController == null) - { - glitchController = GetComponentInChildren(true); - } + if (glitchController == null) + { + glitchController = GetComponentInChildren(true); + } + + if (logReleasePresentation == null) + { + logReleasePresentation = GetComponent(); + } + + if (particleManager != null) + { + particleManager.SetPresentationController(logReleasePresentation); + } } /// @@ -89,6 +102,7 @@ namespace AibisDream { if (particleManager != null) { + particleManager.SetPresentationController(logReleasePresentation); // 使用传入的配置,如果没有则使用默认配置 var phrases = anxietyPhrases ?? defaultAnxietyPhrases; var sentence = targetSentence ?? defaultTargetSentence; @@ -126,6 +140,12 @@ namespace AibisDream yield return StartCoroutine(particleManager.FadeOutBeforeNewExpressionRound(duration)); } + public IEnumerator PreparePresentationForNextRound() + { + if (logReleasePresentation == null) yield break; + yield return logReleasePresentation.PrepareForNextRound(); + } + /// /// 等待表达流程就绪(开场表现 + 火山表达panel 播完) /// @@ -161,6 +181,11 @@ namespace AibisDream /// public void CloseView() { + if (logReleasePresentation != null) + { + logReleasePresentation.ForceCleanupImmediate(true); + } + if (expressionView != null) { expressionView.SetActive(false); @@ -177,6 +202,11 @@ namespace AibisDream /// public void ResetSystem(bool playEntranceEffect = true) { + if (logReleasePresentation != null) + { + logReleasePresentation.ForceCleanupImmediate(true); + } + if (particleManager != null) { particleManager.ResetGame(playEntranceEffect); @@ -235,5 +265,66 @@ namespace AibisDream yield return glitchController.StartCoroutine(glitchController.TransitionToAndWait(to, duration)); } + public IEnumerator BeginExpressionMemory( + string memoryKey, + string preset, + float fadeDuration = -1f, + float pushSpeed = -1f, + float horizontalSpeed = -1f, + bool preserveGlitch = false) + { + if (logReleasePresentation == null) + { + Debug.LogError("[ExpressionManager] LogReleasePresentationController 未配置;记忆命令安全结束。"); + yield break; + } + yield return logReleasePresentation.BeginMemory( + memoryKey, + preset, + fadeDuration, + pushSpeed, + horizontalSpeed, + preserveGlitch); + } + + public IEnumerator BeginExpressionLie(string keyword, string preset) + { + if (logReleasePresentation == null) + { + Debug.LogError("[ExpressionManager] LogReleasePresentationController 未配置;谎话命令安全结束。"); + yield break; + } + yield return logReleasePresentation.BeginLie(keyword, preset); + } + + public IEnumerator BreakExpressionLie() + { + if (logReleasePresentation == null) yield break; + yield return logReleasePresentation.BreakLie(); + } + + public IEnumerator RevealExpressionTruth() + { + if (logReleasePresentation == null) yield break; + yield return logReleasePresentation.RevealTruth(); + } + + public IEnumerator EndExpressionMemory() + { + if (logReleasePresentation == null) yield break; + yield return logReleasePresentation.EndMemory(); + } + + public void ImpactExpressionTruth() + { + logReleasePresentation?.TruthImpact(); + } + + private void OnDisable() + { + if (logReleasePresentation != null) + logReleasePresentation.ForceCleanupImmediate(true); + } + + } } -} \ No newline at end of file diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs b/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs index 2821a6b6a..d07ab0d4f 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/LanguageParticleManager.cs @@ -120,6 +120,8 @@ namespace AibisDream.MiniGame.Language [SerializeField] private RectTransform completionScreenClipRect; [Tooltip("在裁剪区基础上再向内缩进的世界单位距离,避免放大后的字半个挂在边外")] [SerializeField] private float completionClipEdgePadding = 0.2f; + [Tooltip("屏幕内容的最小安全边距;最终边距还会根据字形、光晕、圆环和晃动幅度动态增大。")] + [SerializeField, Min(0f)] private float minimumScreenMargin = 0.12f; [Tooltip("将目标粒子拉回屏幕裁剪区时的平滑时间(越大越柔和,略增加拖尾感)")] [SerializeField, Range(0.02f, 0.55f)] private float completionPositionSmoothTime = 0.2f; @@ -136,6 +138,8 @@ namespace AibisDream.MiniGame.Language [SerializeField] private float focusZoomScale = 1.6f; [SerializeField] private float focusMoveDuration = 1.2f; [SerializeField] private float focusFadeDuration = 1f; + [SerializeField] private float logOperationFadeDuration = 0.6f; + [SerializeField] private int glowSortingOrder = 8; [Tooltip("再次 start_expression 时,若上一轮粒子/UI 仍在,先淡出再重开;协程 duration≤0 时用此值(秒)")] [SerializeField] private float restartExpressionFadeDuration = 0.55f; [SerializeField] private float probabilityDuration = 3.5f; @@ -212,6 +216,8 @@ namespace AibisDream.MiniGame.Language private Coroutine expressionPanelRoutine; private Coroutine debugTestRoutine; private Coroutine confirmTimelineRoutine; + private Coroutine focusTransitionRoutine; + private Coroutine slotMachineRoutine; private readonly Dictionary entranceTargets = new Dictionary(); private class TargetFocusVisual @@ -219,18 +225,22 @@ namespace AibisDream.MiniGame.Language public CandidateParticle particle; public GameObject ringObject; public Disc ringDisc; - public TMP_Text probabilityLabel; - public float probability = 90f; - public float targetProbability = 100f; public float startShakeIntensity; public string finalChar; public bool stabilized; public Vector3 focusPosition; public Vector3 finalPosition; + public Vector3 lieEdgePosition; public int orderIndex; } private bool focusTweensCompleted = false; + private bool focusMoveTweensCompleted; + private float resolveTotalDurationOverride = -1f; + private float lieEdgeCompression; + private float lieEdgeJitter; + private LogReleasePresentationController presentationController; + private const string LieCompressionTweenId = "HuoshanLieEdgeCompression"; // 边界 private Bounds movementBounds; @@ -350,6 +360,9 @@ namespace AibisDream.MiniGame.Language if (!isInitialized) yield break; + StopFocusTransitionRoutine(); + KillCompletionUiTweens(); + resolveTotalDurationOverride = -1f; float d = duration > 0f ? duration : restartExpressionFadeDuration; // 火山释放 log 与文字同时叠在屏幕上时,先把 log 藏起来,等文字淡出结束后再由 FadeInReleaseLogIfNeeded 出现 @@ -440,6 +453,7 @@ namespace AibisDream.MiniGame.Language /// public void SetInitialStatesBeforeOpen() { + RestoreCompletionUiAlpha(); if (languageDeepPanel1 != null) languageDeepPanel1.SetActive(false); if (languageDeepPanel2 != null) @@ -471,6 +485,10 @@ namespace AibisDream.MiniGame.Language // 如果未初始化,直接返回 if (!isInitialized) return; + + StopFocusTransitionRoutine(); + KillCompletionUiTweens(); + RestoreTargetPresentationDefaults(); if (entranceRoutine != null) { @@ -502,6 +520,7 @@ namespace AibisDream.MiniGame.Language { if (particle != null) { + DOTween.Kill(particle); particle.transform.DOKill(); } } @@ -509,6 +528,7 @@ namespace AibisDream.MiniGame.Language { if (particle != null) { + DOTween.Kill(particle); particle.transform.DOKill(); } } @@ -518,6 +538,7 @@ namespace AibisDream.MiniGame.Language completionPhase = CompletionPhase.None; allowInput = false; ClearCompletionClampSmoothVelocity(); + ReleaseScreenClipMaterials(); } private void InitializeCanvas() @@ -589,6 +610,7 @@ namespace AibisDream.MiniGame.Language particle.SetColor(floatingTextColor); particle.SetFontSize(floatingTextFontSize); SetParticleBounds(particle); + ConfigureParticleScreenMask(particle); // 设置发光效果 if (enableBloomGlow) @@ -635,6 +657,7 @@ namespace AibisDream.MiniGame.Language particle.SetFontStyles(targetParticleFontSize, nonTargetParticleFontSize, targetParticleBold, nonTargetParticleBold); particle.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude); SetParticleBounds(particle); + ConfigureParticleScreenMask(particle); if (enableBloomGlow) { @@ -690,6 +713,16 @@ namespace AibisDream.MiniGame.Language particle.SetCircularBounds(boundsCenter, boundsRadius); } + private void ConfigureParticleScreenMask(TextParticle particle) + { + if (particle == null) + return; + + particle.SetScreenClipRect(completionScreenClipRect); + int sortingLayerId = worldCanvas != null ? worldCanvas.sortingLayerID : 0; + particle.SetGlowMasking(sortingLayerId, glowSortingOrder); + } + private void InitializeRenderers() { // 连接线渲染器 @@ -899,6 +932,21 @@ namespace AibisDream.MiniGame.Language /// 完成阶段使用的裁剪范围:优先用 Inspector 指定的屏幕 Rect,否则与粒子运动边界一致。 /// private Bounds GetCompletionClipBounds() + { + Bounds b = GetScreenWorldBounds(); + Vector2 safetyPadding = CalculateScreenSafetyPadding(); + if (safetyPadding.sqrMagnitude > 0f) + { + Bounds shrunk = b; + shrunk.Expand(new Vector3(-safetyPadding.x * 2f, -safetyPadding.y * 2f, 0f)); + if (shrunk.size.x >= 0.01f && shrunk.size.y >= 0.01f) + b = shrunk; + } + + return b; + } + + public Bounds GetScreenWorldBounds() { Bounds b; if (completionScreenClipRect != null) @@ -924,17 +972,40 @@ namespace AibisDream.MiniGame.Language b = movementBounds; } - if (completionClipEdgePadding > 0f) + return b; + } + + private Vector2 CalculateScreenSafetyPadding() + { + float padding = Mathf.Max(minimumScreenMargin, completionClipEdgePadding); + float glyphExtent = 0f; + float glowExtent = 0f; + + foreach (CandidateParticle particle in targetParticles) { - Bounds shrunk = b; - shrunk.Expand(-completionClipEdgePadding); - if (shrunk.size.x >= 0.01f && shrunk.size.y >= 0.01f) + if (particle == null) + continue; + + if (particle.textMesh != null && particle.textMesh.renderer != null) { - b = shrunk; + Bounds textBounds = particle.textMesh.renderer.bounds; + glyphExtent = Mathf.Max(glyphExtent, textBounds.extents.x, textBounds.extents.y); + } + + if (particle.glowSprite != null) + { + Bounds glowBounds = particle.glowSprite.bounds; + glowExtent = Mathf.Max(glowExtent, glowBounds.extents.x, glowBounds.extents.y); } } - return b; + float zoomedGlyph = glyphExtent * Mathf.Max(1f, focusZoomScale); + float focusRingRadius = Mathf.Clamp(targetParticleFontSize * 0.05f, 0.35f, 0.75f) + + Mathf.Max(focusRingThickness, 0f); + float shakeExtent = Mathf.Max(shuffleDistance, nonTargetShakeIntensity, completionPositionSmoothTime * 0.1f); + + float horizontal = Mathf.Max(padding, zoomedGlyph, glowExtent, focusRingRadius, shakeExtent); + return new Vector2(horizontal, horizontal); } private static Vector3 ClampPositionToBounds(Vector3 worldPos, Bounds b) @@ -951,6 +1022,11 @@ namespace AibisDream.MiniGame.Language // 更新连接线渲染器 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, @@ -1225,6 +1301,7 @@ namespace AibisDream.MiniGame.Language private void BeginFocusSequence() { + StopFocusTransitionRoutine(); if (focusSequenceButton != null) { focusSequenceButton.interactable = false; @@ -1235,17 +1312,21 @@ namespace AibisDream.MiniGame.Language completionPhase = CompletionPhase.Focusing; focusTimer = 0f; focusTweensCompleted = false; + focusMoveTweensCompleted = false; + lieEdgeCompression = 0f; + lieEdgeJitter = 0f; interferenceLogicSuspended = true; SetStatusUIVisibility(false, statusFadeOutDuration); // 两个 panel、confirm button、火山释放 log 界面在 focus 触发时淡出 - FadeOutUIElement(languageDeepPanel1, focusFadeDuration); - FadeOutUIElement(languageDeepPanel2, focusFadeDuration); + float operationFadeDuration = Mathf.Max(0.01f, logOperationFadeDuration); + FadeOutUIElement(languageDeepPanel1, operationFadeDuration); + FadeOutUIElement(languageDeepPanel2, operationFadeDuration); if (focusSequenceButton != null) { - FadeOutUIElement(focusSequenceButton.gameObject, focusFadeDuration); + FadeOutUIElement(focusSequenceButton.gameObject, operationFadeDuration); } - FadeOutSpriteRenderer(releaseLogSpriteRenderer, focusFadeDuration); + FadeOutSpriteRenderer(releaseLogSpriteRenderer, operationFadeDuration); if (connectionRenderer != null) { @@ -1257,7 +1338,7 @@ namespace AibisDream.MiniGame.Language { particle.isCalmed = true; particle.velocity = Vector2.zero; - FadeOutTextParticle(particle, focusFadeDuration); + FadeOutTextParticle(particle, operationFadeDuration); } foreach (var particle in candidateParticles) @@ -1266,7 +1347,7 @@ namespace AibisDream.MiniGame.Language { particle.isCalmed = true; particle.velocity = Vector2.zero; - FadeOutTextParticle(particle, focusFadeDuration); + FadeOutTextParticle(particle, operationFadeDuration); } else { @@ -1306,9 +1387,7 @@ namespace AibisDream.MiniGame.Language { completedTweens++; if (completedTweens >= tweenTargetCount) - { - EnterFocusHolding(); - } + focusMoveTweensCompleted = true; }); particle.transform.DOScale(Vector3.one * focusZoomScale, focusMoveDuration).SetEase(Ease.InOutQuad); @@ -1321,9 +1400,37 @@ namespace AibisDream.MiniGame.Language } if (tweenTargetCount == 0) + focusMoveTweensCompleted = true; + + focusTransitionRoutine = StartCoroutine(WaitForFocusPresentationEntry()); + } + + private IEnumerator WaitForFocusPresentationEntry() + { + while (releaseLogSpriteRenderer != null && + releaseLogSpriteRenderer.gameObject.activeSelf && + releaseLogSpriteRenderer.enabled && + releaseLogSpriteRenderer.color.a > 0.001f) { - EnterFocusHolding(); + 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) @@ -1355,24 +1462,6 @@ namespace AibisDream.MiniGame.Language disc.Color = ringColor; visual.ringDisc = disc; - GameObject labelObj = new GameObject("ProbabilityLabel"); - labelObj.layer = renderLayer; - labelObj.transform.SetParent(particle.transform, false); - RectTransform rectTransform = labelObj.AddComponent(); - rectTransform.localPosition = new Vector3(0f, estimatedRadius + 0.55f, 0f); - rectTransform.localScale = Vector3.one; - rectTransform.sizeDelta = new Vector2(2f, 0.6f); - - var label = labelObj.AddComponent(); - if (chineseFontAsset != null) - { - label.font = chineseFontAsset; - } - label.fontSize = 2f; - label.alignment = TextAlignmentOptions.Center; - label.text = $"{Mathf.RoundToInt(visual.probability)}%"; - visual.probabilityLabel = label; - return visual; } @@ -1390,14 +1479,21 @@ namespace AibisDream.MiniGame.Language private void UpdateFocusHolding() { focusTimer += Time.deltaTime; + float compression = Mathf.SmoothStep(0f, 1f, lieEdgeCompression); foreach (var visual in focusVisuals.Values) { if (visual == null || visual.particle == null) continue; - Vector3 shuffleOffset = CalculateShuffleOffset(visual, 0f, focusTimer); - visual.particle.transform.position = visual.focusPosition + shuffleOffset; + Vector3 compressedPosition = Vector3.Lerp( + visual.focusPosition, + visual.lieEdgePosition, + compression); + Vector3 shuffleOffset = CalculateShuffleOffset(visual, compression, focusTimer) * + Mathf.Lerp(1f, 0.18f, compression); + Vector3 edgeJitter = CalculateLieEdgeJitter(visual, compression); + visual.particle.transform.position = compressedPosition + shuffleOffset + edgeJitter; } } @@ -1407,28 +1503,31 @@ namespace AibisDream.MiniGame.Language return; focusTimer += Time.deltaTime; - float baseInterval = Mathf.Max(0.2f, probabilityDuration); + 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 * predictionSequenceGap; + float localTime = focusTimer - visual.orderIndex * activeStagger; float probabilityProgress = Mathf.Clamp01(localTime / baseInterval); float arrangementProgress = Mathf.Clamp01(localTime / arrangementDuration); - float probability = Mathf.Lerp(visual.probability, visual.targetProbability, probabilityProgress); - if (visual.probabilityLabel != null) - { - visual.probabilityLabel.text = $"{Mathf.RoundToInt(probability)}%"; - } - float interval = Mathf.Lerp(0.05f, 0.8f, probabilityProgress); visual.particle.SetChangeInterval(interval); visual.particle.changeSpeedMultiplier = Mathf.Lerp(6f, 1f, probabilityProgress); visual.particle.anxietyShakeIntensity = Mathf.Lerp(visual.startShakeIntensity, 0f, probabilityProgress); + visual.particle.calmProgress = probabilityProgress; Vector3 dynamicTarget = GetDynamicArrangementTarget(visual, arrangementProgress, localTime); Vector3 targetPosition = Vector3.Lerp(visual.focusPosition, dynamicTarget, Mathf.SmoothStep(0f, 1f, arrangementProgress)); @@ -1442,10 +1541,6 @@ namespace AibisDream.MiniGame.Language visual.particle.ResetChangeTimer(visual.particle.changeSpeedMultiplier); visual.particle.isStatic = true; visual.stabilized = true; - if (visual.probabilityLabel != null) - { - visual.probabilityLabel.text = "100%"; - } } } @@ -1457,18 +1552,45 @@ namespace AibisDream.MiniGame.Language } /// - /// 从 FocusHolding 进入稳定化阶段(概率递增、文字锁定、排列到最终位置) + /// 从 FocusHolding 进入稳定化阶段(文字锁定、排列到最终位置) /// - public void ResolveFocusSequence() + 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)); + } + /// /// 等待聚焦流程全部完成(稳定化 → Finished) /// @@ -1480,6 +1602,366 @@ namespace AibisDream.MiniGame.Language } } + public static bool ShouldClipConnections(CompletionPhase phase) + { + return phase == CompletionPhase.Focusing || + phase == CompletionPhase.FocusHolding || + phase == CompletionPhase.Finished; + } + + public void SetPresentationController(LogReleasePresentationController controller) + { + presentationController = controller; + } + + public void BeginLieEdgeCompression(float duration, float jitterAmount) + { + if (completionPhase != CompletionPhase.FocusHolding) + { + Debug.LogWarning( + $"[LanguageParticleManager] 谎话边缘挤压在 {completionPhase} 状态被调用;已安全跳过。"); + return; + } + + Bounds edgeBounds = GetCompletionClipBounds(); + int visualCount = Mathf.Max(1, focusVisuals.Count); + int index = 0; + foreach (TargetFocusVisual visual in focusVisuals.Values.OrderBy(value => value.orderIndex)) + { + if (visual?.particle == null) + continue; + + visual.lieEdgePosition = CalculateScreenEdgePosition( + edgeBounds, + index, + visualCount, + 0.04f); + visual.particle.isStatic = false; + visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 5.5f); + visual.particle.SetChangeInterval(0.045f); + index++; + } + + DOTween.Kill(LieCompressionTweenId); + DOTween.To( + () => lieEdgeCompression, + value => lieEdgeCompression = value, + 1f, + Mathf.Max(0.05f, duration)) + .SetEase(Ease.InCubic) + .SetId(LieCompressionTweenId) + .SetTarget(this); + DOTween.To( + () => lieEdgeJitter, + value => lieEdgeJitter = value, + Mathf.Max(0f, jitterAmount), + Mathf.Max(0.04f, duration * 0.7f)) + .SetEase(Ease.OutQuad) + .SetId(LieCompressionTweenId) + .SetTarget(this); + } + + /// + /// 老虎机换字演出:聚焦字在 duration 内只从 charPool 里疯狂换字,到点后自动恢复默认字符池并继续抖动。 + /// + public void BeginSlotMachineShuffle(string charPool, float duration, float interval = 0.05f) + { + if (completionPhase != CompletionPhase.FocusHolding) + { + Debug.LogWarning( + $"[LanguageParticleManager] 老虎机换字在 {completionPhase} 状态被调用;已安全跳过。"); + return; + } + + StopSlotMachineShuffle(); + string pool = SanitizeSlotMachinePool(charPool); + if (string.IsNullOrEmpty(pool)) + { + Debug.LogWarning("[LanguageParticleManager] 老虎机换字字符池为空;已安全跳过。"); + return; + } + + float changeInterval = Mathf.Max(0.02f, interval); + foreach (TargetFocusVisual visual in focusVisuals.Values) + { + if (visual?.particle == null) + continue; + + visual.particle.SetOverrideCharPool(pool); + visual.particle.SetChangeInterval(changeInterval); + visual.particle.ResetChangeTimer(changeInterval); + visual.particle.isStatic = false; + visual.particle.anxietyShakeIntensity = Mathf.Max(visual.particle.anxietyShakeIntensity, 5f); + } + + slotMachineRoutine = StartCoroutine(EndSlotMachineShuffleAfter(Mathf.Max(0.05f, duration))); + } + + /// + /// 清洗老虎机字符池:去掉空格与 | 分隔符,空结果返回 null。 + /// + public static string SanitizeSlotMachinePool(string charPool) + { + if (string.IsNullOrEmpty(charPool)) + return null; + string cleaned = charPool.Replace(" ", string.Empty).Replace("|", string.Empty); + return cleaned.Length == 0 ? null : cleaned; + } + + public void StopSlotMachineShuffle() + { + if (slotMachineRoutine != null) + { + StopCoroutine(slotMachineRoutine); + slotMachineRoutine = null; + } + + foreach (CandidateParticle particle in targetParticles) + particle?.SetOverrideCharPool(null); + } + + private IEnumerator EndSlotMachineShuffleAfter(float duration) + { + yield return new WaitForSeconds(duration); + slotMachineRoutine = null; + foreach (CandidateParticle particle in targetParticles) + particle?.SetOverrideCharPool(null); + } + + public void PrepareTruthReleaseFromLie() + { + DOTween.Kill(LieCompressionTweenId); + foreach (TargetFocusVisual visual in focusVisuals.Values) + { + if (visual?.particle == null) + continue; + + // The resolve sequence now starts from the squeezed perimeter position, + // making every real character visibly rush back through the broken lie. + visual.focusPosition = visual.particle.transform.position; + visual.lieEdgePosition = visual.focusPosition; + visual.startShakeIntensity = Mathf.Max(visual.startShakeIntensity, 6.5f); + visual.particle.anxietyShakeIntensity = visual.startShakeIntensity; + } + + lieEdgeCompression = 0f; + lieEdgeJitter = 0f; + } + + public Bounds GetFinalTruthWorldBounds() + { + if (finalTargetPositions.Count == 0) + return GetTargetWorldBounds(); + + Bounds result = new Bounds(finalTargetPositions[0], Vector3.zero); + for (int i = 1; i < finalTargetPositions.Count; i++) + result.Encapsulate(finalTargetPositions[i]); + + float glyphPadding = Mathf.Max(0.3f, targetParticleFontSize * 0.09f * focusZoomScale); + result.Expand(new Vector3(glyphPadding * 2f, glyphPadding * 2f, 0f)); + return result; + } + + public static Vector3 CalculateScreenEdgePosition( + Bounds bounds, + int index, + int count, + float inset) + { + int safeCount = Mathf.Max(1, count); + float angle = -Mathf.PI * 0.5f + + Mathf.PI * 2f * ((index + 0.5f) / safeCount); + Vector2 direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); + float availableX = Mathf.Max(0f, bounds.extents.x - Mathf.Max(0f, inset)); + float availableY = Mathf.Max(0f, bounds.extents.y - Mathf.Max(0f, inset)); + float distanceX = Mathf.Abs(direction.x) > 0.0001f + ? availableX / Mathf.Abs(direction.x) + : float.PositiveInfinity; + float distanceY = Mathf.Abs(direction.y) > 0.0001f + ? availableY / Mathf.Abs(direction.y) + : float.PositiveInfinity; + float distance = Mathf.Min(distanceX, distanceY); + return bounds.center + (Vector3)(direction * distance); + } + + public void SetTargetParticlesAlpha(float targetAlpha, float duration = 0f) + { + float alphaValue = Mathf.Clamp01(targetAlpha); + foreach (CandidateParticle particle in targetParticles) + { + if (particle == null) continue; + DOTween.Kill(particle); + if (duration <= 0f) + { + particle.alpha = alphaValue; + } + else + { + DOTween.To(() => particle.alpha, value => particle.alpha = value, alphaValue, duration) + .SetEase(Ease.OutQuad) + .SetTarget(particle); + } + } + } + + public void SetTruthWarmVisuals(Color warmColor) + { + foreach (CandidateParticle particle in targetParticles) + { + if (particle == null) continue; + particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, warmColor); + } + } + + public IEnumerator FlashTruthFragment(string fragment, float duration = 0.22f) + { + if (string.IsNullOrEmpty(fragment) || orderedRedParticles.Count == 0) + yield break; + + int startIndex = targetSentence.IndexOf(fragment, System.StringComparison.Ordinal); + if (startIndex < 0) + { + Debug.LogWarning($"[LanguageParticleManager] 真话泄漏片段“{fragment}”不在目标句“{targetSentence}”中。"); + yield break; + } + + var leaked = new List(); + int endExclusive = Mathf.Min(startIndex + fragment.Length, orderedRedParticles.Count); + for (int i = startIndex; i < endExclusive; i++) + { + CandidateParticle particle = orderedRedParticles[i]; + if (particle == null) continue; + particle.ForceSetCharacter(targetSentence[i].ToString()); + particle.isStatic = true; + particle.alpha = 1f; + leaked.Add(particle); + } + + yield return new WaitForSeconds(Mathf.Max(0.02f, duration)); + + if (completionPhase == CompletionPhase.FocusHolding) + { + foreach (CandidateParticle particle in leaked) + { + if (particle == null) continue; + particle.alpha = 0.2f; + particle.isStatic = false; + particle.ResetChangeTimer(0.02f); + } + } + } + + public Bounds GetTargetWorldBounds() + { + bool hasBounds = false; + Bounds result = new Bounds( + worldCanvas != null ? worldCanvas.transform.position : transform.position, + Vector3.zero); + + foreach (CandidateParticle particle in targetParticles) + { + if (particle == null || particle.textMesh == null || particle.textMesh.renderer == null) + continue; + + Bounds textBounds = particle.textMesh.renderer.bounds; + if (!hasBounds) + { + result = textBounds; + hasBounds = true; + } + else + { + result.Encapsulate(textBounds); + } + } + + return result; + } + + public IEnumerator ResolveFocusAndWait(float totalDuration) + { + if (completionPhase != CompletionPhase.FocusHolding) + { + Debug.LogWarning( + $"[LanguageParticleManager] ResolveFocusAndWait 在 {completionPhase} 状态被调用;已安全跳过。"); + yield break; + } + ResolveFocusSequence(totalDuration); + yield return WaitUntilFocusFinished(); + } + + public void SnapTruthImmediate() + { + if (completionPhase != CompletionPhase.FocusHolding) + { + Debug.LogWarning( + $"[LanguageParticleManager] 真话瞬间稳定在 {completionPhase} 状态被调用;已安全跳过。"); + return; + } + + DOTween.Kill(LieCompressionTweenId); + StopSlotMachineShuffle(); + completionPhase = CompletionPhase.Focusing; + focusTweensCompleted = true; + focusTimer = 0f; + + foreach (TargetFocusVisual visual in focusVisuals.Values) + { + if (visual?.particle == null) + continue; + + CandidateParticle particle = visual.particle; + particle.transform.DOKill(); + particle.transform.position = visual.finalPosition; + particle.ForceSetCharacter(visual.finalChar); + particle.SetChangeInterval(1.5f); + particle.ResetChangeTimer(1.5f); + particle.anxietyShakeIntensity = 0f; + particle.changeSpeedMultiplier = 1f; + particle.calmProgress = 1f; + particle.isStatic = true; + visual.stabilized = true; + } + + resolveTotalDurationOverride = -1f; + lieEdgeCompression = 0f; + lieEdgeJitter = 0f; + CompleteFocusSequence(); + } + + public void RestoreTargetPresentationDefaults(float alphaValue = 1f) + { + DOTween.Kill(LieCompressionTweenId); + StopSlotMachineShuffle(); + foreach (CandidateParticle particle in targetParticles) + { + if (particle == null) continue; + DOTween.Kill(particle); + particle.alpha = Mathf.Clamp01(alphaValue); + particle.calmProgress = 0f; + particle.SetColors(targetParticleColor, nonTargetParticleColor, nonTargetParticleColor, targetCalmColor); + } + resolveTotalDurationOverride = -1f; + lieEdgeCompression = 0f; + lieEdgeJitter = 0f; + } + + public void ReleaseScreenClipMaterials() + { + foreach (CandidateParticle particle in candidateParticles) + particle?.ClearScreenClip(); + foreach (FloatingTextParticle particle in floatingParticles) + particle?.ClearScreenClip(); + } + + private void RebuildScreenClipMaterials() + { + foreach (CandidateParticle particle in candidateParticles) + ConfigureParticleScreenMask(particle); + foreach (FloatingTextParticle particle in floatingParticles) + ConfigureParticleScreenMask(particle); + } + private void DestroyFocusVisualDecorationsAndClear() { foreach (var visual in focusVisuals.Values) @@ -1488,8 +1970,6 @@ namespace AibisDream.MiniGame.Language continue; if (visual.ringObject != null) Destroy(visual.ringObject); - if (visual.probabilityLabel != null) - Destroy(visual.probabilityLabel.gameObject); } focusVisuals.Clear(); } @@ -1515,6 +1995,8 @@ namespace AibisDream.MiniGame.Language connectionRenderer.TargetAlphaScale = 0f; } + lieEdgeCompression = 0f; + lieEdgeJitter = 0f; completionPhase = CompletionPhase.Finished; } @@ -1558,16 +2040,30 @@ namespace AibisDream.MiniGame.Language 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); + .SetEase(Ease.OutQuad) + .SetTarget(particle); if (particle.glowSprite != null) { - particle.glowSprite.DOFade(0f, duration); + particle.glowSprite.DOFade(0f, duration).SetTarget(particle); } } @@ -1581,6 +2077,7 @@ namespace AibisDream.MiniGame.Language var cg = obj.GetComponent(); if (cg == null) cg = obj.AddComponent(); + cg.DOKill(); cg.alpha = 1f; cg.DOFade(0f, duration) .SetEase(Ease.OutQuad) @@ -1651,6 +2148,40 @@ namespace AibisDream.MiniGame.Language sr.color = c; } + private void KillCompletionUiTweens() + { + KillCanvasGroupTween(languageDeepPanel1); + KillCanvasGroupTween(languageDeepPanel2); + if (focusSequenceButton != null) + KillCanvasGroupTween(focusSequenceButton.gameObject); + if (releaseLogSpriteRenderer != null) + DOTween.Kill(releaseLogSpriteRenderer); + } + + private static void KillCanvasGroupTween(GameObject obj) + { + if (obj == null) return; + CanvasGroup group = obj.GetComponent(); + if (group != null) + group.DOKill(); + } + + private void RestoreCompletionUiAlpha() + { + RestoreCanvasGroupAlpha(languageDeepPanel1); + RestoreCanvasGroupAlpha(languageDeepPanel2); + if (focusSequenceButton != null) + RestoreCanvasGroupAlpha(focusSequenceButton.gameObject); + } + + private static void RestoreCanvasGroupAlpha(GameObject obj) + { + if (obj == null) return; + CanvasGroup group = obj.GetComponent(); + if (group != null) + group.alpha = 1f; + } + private Vector3 ComputeCentroid(List particles) { if (particles == null || particles.Count == 0) @@ -1722,6 +2253,10 @@ namespace AibisDream.MiniGame.Language public void ResetGame(bool playEntranceEffect = true) { + StopFocusTransitionRoutine(); + KillCompletionUiTweens(); + RestoreTargetPresentationDefaults(); + RestoreCompletionUiAlpha(); isCompleted = false; completionPhase = CompletionPhase.None; completionTimer = 0f; @@ -1792,8 +2327,10 @@ namespace AibisDream.MiniGame.Language p.velocity = new Vector2(Random.Range(-0.5f, 0.5f), Random.Range(-0.5f, 0.5f)); p.transform.position = GetRandomPositionInBounds(); p.isCalmed = false; + p.calmProgress = 0f; p.alpha = p.baseAlpha; p.transform.localScale = Vector3.one; + DOTween.Kill(p); p.transform.DOKill(); p.SetInterferenceParams(nonTargetShakeIntensity, nonTargetShakeSpeed, nonTargetChangeInterval, nonTargetFloatAmplitude); @@ -1809,9 +2346,13 @@ namespace AibisDream.MiniGame.Language { floating.isCalmed = false; floating.alpha = floating.baseAlpha; + DOTween.Kill(floating); floating.transform.DOKill(); } + ReleaseScreenClipMaterials(); + RebuildScreenClipMaterials(); + SelectRedParticles(); UpdateStatusUI(); if (playEntranceEffect) diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/LanguageYarnCommand.cs b/Assets/Scripts/MiniGame/HuoShan/Language/LanguageYarnCommand.cs index 0b5ed66a9..69ddc6bc2 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/LanguageYarnCommand.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/LanguageYarnCommand.cs @@ -13,6 +13,19 @@ namespace AibisDream.MiniGame.Language { private static AibisDream.ExpressionManager ExpressionManager => FixSystemCenter.SystemDic.Get(); + private static bool TryGetPresentation(out LogReleasePresentationController presentation) + { + presentation = ExpressionManager != null + ? ExpressionManager.LogReleasePresentation + : null; + if (presentation != null) + return true; + + UnityEngine.Debug.LogError( + "[LanguageYarnCommand] LogReleasePresentationController 未找到;演出命令安全结束。"); + return false; + } + /// /// 启动表达粒子系统(在打开视图后调用),等待火山表达panel播完再返回。 /// 流程:若有上一轮则先淡出字与连线 → 再淡入火山释放 log → 再启动新一轮并等表达 panel 就绪。 @@ -57,7 +70,8 @@ namespace AibisDream.MiniGame.Language } } - // 先淡出上一轮文字/连线等,再淡入火山释放 log,最后开新一轮粒子(避免 log 与旧字叠在一起) + // 先清理上一轮演出,再淡出旧文字/连线并恢复 Log 操作界面。 + yield return ExpressionManager.PreparePresentationForNextRound(); yield return ExpressionManager.FadeOutParticlesBeforeNewRound(0f); yield return ExpressionManager.EnsureReleaseLogFadedIn(1f); ExpressionManager.StartSystem(phrases, targetSentence, completionNode, nonTargetParticleTotal); @@ -165,6 +179,400 @@ namespace AibisDream.MiniGame.Language yield return ExpressionManager.StartCoroutine(ExpressionManager.TransitionGlitchAndWait(to, duration)); } + [YarnCommand("expression_memory_begin")] + public static IEnumerator ExpressionMemoryBegin( + string memoryKey, + string preset, + float fadeDuration = -1f, + float pushSpeed = -1f, + float horizontalSpeed = -1f, + bool preserveGlitch = false) + { + if (ExpressionManager == null) + { + UnityEngine.Debug.LogError("ExpressionManager 未找到!"); + yield break; + } + yield return ExpressionManager.StartCoroutine( + ExpressionManager.BeginExpressionMemory( + memoryKey, + preset, + fadeDuration, + pushSpeed, + horizontalSpeed, + preserveGlitch)); + } + + /// + /// 显式淡入火山脸,并等待淡入完成。 + /// <> + /// + [YarnCommand("expression_face_fade_in")] + public static IEnumerator ExpressionFaceFadeIn(float duration = 0.45f) + { + if (!TryGetPresentation(out LogReleasePresentationController presentation)) + yield break; + yield return ExpressionManager.StartCoroutine( + presentation.BeginFaceEntry(duration)); + } + + [YarnCommand("expression_lie_begin")] + public static IEnumerator ExpressionLieBegin(string keyword, string preset) + { + if (ExpressionManager == null) + { + UnityEngine.Debug.LogError("ExpressionManager 未找到!"); + yield break; + } + yield return ExpressionManager.StartCoroutine( + ExpressionManager.BeginExpressionLie(keyword, preset)); + } + + [YarnCommand("expression_lie_break")] + public static IEnumerator ExpressionLieBreak() + { + if (ExpressionManager == null) + { + UnityEngine.Debug.LogError("ExpressionManager 未找到!"); + yield break; + } + yield return ExpressionManager.StartCoroutine(ExpressionManager.BreakExpressionLie()); + } + + [YarnCommand("expression_truth_reveal")] + public static IEnumerator ExpressionTruthReveal() + { + if (ExpressionManager == null) + { + UnityEngine.Debug.LogError("ExpressionManager 未找到!"); + yield break; + } + yield return ExpressionManager.StartCoroutine(ExpressionManager.RevealExpressionTruth()); + } + + // 以下命令是一动作一命令。非 IEnumerator 命令只启动效果,节拍由 Yarn 的 wait 控制。 + + [YarnCommand("expression_lie_prepare")] + public static void ExpressionLiePrepare(float freezeDuration, float noiseTarget) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.PrepareLieVisuals(freezeDuration, noiseTarget); + } + + [YarnCommand("expression_truth_squeeze")] + public static void ExpressionTruthSqueeze(float duration, float jitter, float alpha) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.SqueezeTruthCharacters(duration, jitter, alpha); + } + + [YarnCommand("expression_lie_show")] + public static void ExpressionLieShow(string keyword, float duration, float characterSpacing) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.ShowLieKeyword(keyword, duration, characterSpacing); + } + + [YarnCommand("expression_scanline")] + public static void ExpressionScanline(float duration, bool stalled) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.PlayScreenScanline(duration, stalled); + } + + [YarnCommand("expression_system_message")] + public static void ExpressionSystemMessage(string value, string style, float fadeDuration) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.ShowSystemMessage(value, style, fadeDuration); + } + + [YarnCommand("expression_system_message_hide")] + public static void ExpressionSystemMessageHide(float fadeDuration) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.HideSystemMessage(fadeDuration); + } + + [YarnCommand("expression_lie_crack_begin")] + public static void ExpressionLieCrackBegin(float lieAlpha) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.BeginLieCracking(lieAlpha); + } + + [YarnCommand("expression_screen_dim")] + public static void ExpressionScreenDim(float alpha, float riseDuration, float fallDuration) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.PlayScreenDim(alpha, riseDuration, fallDuration); + } + + [YarnCommand("expression_memory_jolt")] + public static void ExpressionMemoryJolt(float offset, float duration, float tear) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.PlayMemoryJolt(offset, duration, tear); + } + + [YarnCommand("expression_truth_leak")] + public static IEnumerator ExpressionTruthLeak(string fragment, float duration) + { + if (!TryGetPresentation(out LogReleasePresentationController presentation)) + yield break; + yield return ExpressionManager.StartCoroutine( + presentation.LeakTruthFragment(fragment, duration)); + } + + [YarnCommand("expression_lie_shuffle")] + public static void ExpressionLieShuffle( + string characterPool, + float duration, + float interval) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.StartLieCharacterShuffle(characterPool, duration, interval); + } + + /// + /// 演员大字闪现(阻塞):抖动的粒子字瞬间隐藏,屏幕中央出现白色大字(红青色散双影+急速缩放), + /// 停顿 holdDuration 秒后恢复粒子字抖动。 + /// <> + /// + [YarnCommand("expression_actor_flash")] + public static IEnumerator ExpressionActorFlash( + string text, + float holdDuration = 0.6f, + float fontSize = 10.5f, + float settledScale = -1f, + float punchScale = -1f, + float chromaDistance = -1f, + float chromaAlpha = -1f, + float impact = -1f, + float glitchPeak = -1f) + { + if (!TryGetPresentation(out LogReleasePresentationController presentation)) + yield break; + yield return ExpressionManager.StartCoroutine( + presentation.FlashActorLie( + text, + holdDuration, + fontSize, + settledScale, + punchScale, + chromaDistance, + chromaAlpha, + impact, + glitchPeak)); + } + + /// + /// 老虎机换字(非阻塞,节拍由 Yarn wait 控制):聚焦字在 duration 内只从 charPool 里疯狂换字。 + /// <> + /// + [YarnCommand("expression_actor_slot")] + public static void ExpressionActorSlot( + string charPool, + float duration, + float interval = 0.05f, + float fontSize = 14f) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.StartActorSlotMachine(charPool, duration, interval, fontSize); + } + + /// + /// 将 Glitch、记忆冲击与画面推近缓慢堆到峰值,停住后保持峰值,等待后续硬切。 + /// <> + /// + [YarnCommand("expression_overload_peak")] + public static IEnumerator ExpressionOverloadPeak( + float riseDuration = 0.75f, + float holdDuration = 0.22f, + float zoom = 1.10f, + float glitchPeak = 1f, + float impact = 1f) + { + if (!TryGetPresentation(out LogReleasePresentationController presentation)) + yield break; + yield return ExpressionManager.StartCoroutine( + presentation.BuildActorOverloadPeak( + riseDuration, + holdDuration, + zoom, + glitchPeak, + impact)); + } + + /// + /// 单字/短词满屏重击,并等待该拍结束。 + /// <> + /// + [YarnCommand("expression_actor_word_hit")] + public static IEnumerator ExpressionActorWordHit( + string text, + float holdDuration = 0.16f, + float fontSize = 18f, + float punchScale = 1.55f) + { + if (!TryGetPresentation(out LogReleasePresentationController presentation)) + yield break; + yield return ExpressionManager.StartCoroutine( + presentation.PlayActorWordHit(text, holdDuration, fontSize, punchScale)); + } + + /// + /// 单列词组严格裁在表情屏内,像字幕一样无缝向下滚动。 + /// <> + /// + [YarnCommand("expression_actor_scroll")] + public static IEnumerator ExpressionActorScroll( + string phraseList, + float scrollDuration = 1.8f, + float loopDuration = 0.85f, + float holdDuration = 0.45f, + float fontSize = 5.2f) + { + if (!TryGetPresentation(out LogReleasePresentationController presentation)) + yield break; + yield return ExpressionManager.StartCoroutine( + presentation.ScrollActorLies( + phraseList, + scrollDuration, + loopDuration, + holdDuration, + fontSize)); + } + + [YarnCommand("expression_memory_cut")] + public static void ExpressionMemoryCut() + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.CutMemoryAndGlitchImmediate(); + } + + [YarnCommand("expression_truth_snap")] + public static void ExpressionTruthSnap(float peripheral, float calmRadius) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.SnapTruthImmediate(peripheral, calmRadius); + } + + [YarnCommand("expression_glitch_pulse")] + public static void ExpressionGlitchPulse(float peak, float riseDuration, float fallDuration) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.PlayGlitchPulse(peak, riseDuration, fallDuration); + } + + [YarnCommand("expression_truth_blackout")] + public static IEnumerator ExpressionTruthBlackout(float duration, float alpha) + { + if (!TryGetPresentation(out LogReleasePresentationController presentation)) + yield break; + yield return ExpressionManager.StartCoroutine( + presentation.HoldTruthBlackout(duration, alpha)); + } + + [YarnCommand("expression_lie_split")] + public static void ExpressionLieSplit( + float duration, + float distanceRatio, + float stretchX, + float squashY) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + { + presentation.SplitLieVisual( + duration, + distanceRatio, + stretchX, + squashY); + } + } + + [YarnCommand("expression_truth_flash")] + public static void ExpressionTruthFlash( + float alpha, + float riseDuration, + float holdDuration, + float fallDuration) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + { + presentation.PlayTruthFlash( + alpha, + riseDuration, + holdDuration, + fallDuration); + } + } + + [YarnCommand("expression_memory_brightness_pulse")] + public static void ExpressionMemoryBrightnessPulse( + float amount, + float riseDuration, + float fallDuration) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.PlayMemoryBrightnessPulse(amount, riseDuration, fallDuration); + } + + [YarnCommand("expression_memory_tear")] + public static void ExpressionMemoryTear(float peak, float duration) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.PlayMemoryTear(peak, duration); + } + + [YarnCommand("expression_face_dip")] + public static void ExpressionFaceDip(float duration, float ratio) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.PlayFaceDip(duration, ratio); + } + + [YarnCommand("expression_truth_release")] + public static void ExpressionTruthRelease(float alphaDuration) + { + if (TryGetPresentation(out LogReleasePresentationController presentation)) + presentation.ReleaseTruthCharacters(alphaDuration); + } + + [YarnCommand("expression_truth_resolve")] + public static IEnumerator ExpressionTruthResolve( + float duration, + float peripheral, + float calmRadius) + { + if (!TryGetPresentation(out LogReleasePresentationController presentation)) + yield break; + yield return ExpressionManager.StartCoroutine( + presentation.ResolveTruthCharacters(duration, peripheral, calmRadius)); + } + + [YarnCommand("expression_memory_end")] + public static IEnumerator ExpressionMemoryEnd() + { + if (ExpressionManager == null) + { + UnityEngine.Debug.LogError("ExpressionManager 未找到!"); + yield break; + } + yield return ExpressionManager.StartCoroutine(ExpressionManager.EndExpressionMemory()); + } + + [YarnCommand("expression_truth_impact")] + public static void ExpressionTruthImpact() + { + if (ExpressionManager == null) + { + UnityEngine.Debug.LogError("ExpressionManager 未找到!"); + return; + } + ExpressionManager.ImpactExpressionTruth(); + } + // /// // /// Glitch 噪波过渡(指定起止值),等待完成后再继续下一步。 // /// diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/LogReleasePresentationController.cs b/Assets/Scripts/MiniGame/HuoShan/Language/LogReleasePresentationController.cs new file mode 100644 index 000000000..b10909ace --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/LogReleasePresentationController.cs @@ -0,0 +1,2228 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using DG.Tweening; +using TMPro; +using UnityEngine; + +namespace AibisDream.MiniGame.Language +{ + /// + /// Owns the post-output performance for Huoshan's three blocked logs. Memory effects + /// stay on their own SpriteRenderers, while the opaque face and dialogue UI remain clean. + /// + public sealed class LogReleasePresentationController : MonoBehaviour + { + public enum PresentationState + { + Idle, + LogOperation, + FaceEntering, + Memory, + LieHolding, + LieCracking, + TruthResolving, + TruthHolding, + Returning + } + + private enum MemoryKind + { + Office, + Sunset, + PrivateOffice + } + + private readonly struct Preset + { + public readonly string Name; + public readonly float BaseDistortion; + public readonly float LiePause; + public readonly float BreakPeak; + public readonly float BreakDuration; + public readonly float TruthDuration; + public readonly float TruthPeripheral; + + public Preset( + string name, + float baseDistortion, + float liePause, + float breakPeak, + float breakDuration, + float truthDuration, + float truthPeripheral) + { + Name = name; + BaseDistortion = baseDistortion; + LiePause = liePause; + BreakPeak = breakPeak; + BreakDuration = breakDuration; + TruthDuration = truthDuration; + TruthPeripheral = truthPeripheral; + } + } + + private static readonly Preset LightPreset = new Preset("Light", 0.10f, 0.35f, 0.25f, 0.25f, 0.70f, 0.15f); + private static readonly Preset MediumPreset = new Preset("Medium", 0.20f, 0.55f, 0.50f, 0.35f, 0.90f, 0.30f); + private static readonly Preset HeavyPreset = new Preset("Heavy", 0.35f, 0.80f, 0.85f, 0.50f, 1.10f, 0.50f); + + [Header("Scene References")] + [SerializeField] private LanguageParticleManager particleManager; + [SerializeField] private SpriteRenderer faceRenderer; + [SerializeField] private SpriteNoiseGlitchController glitchController; + [SerializeField] private RectTransform expressionScreenMaskRect; + [SerializeField] private TMP_FontAsset screenFont; + + [Header("Memory Assets")] + [SerializeField] private Sprite officeMemory; + [SerializeField] private Sprite sunsetMemory; + [SerializeField] private Sprite privateOfficeMemory; + [SerializeField] private Material memoryMaterial; + + [Header("Timing")] + [SerializeField, Min(0.01f)] private float faceFadeDuration = 0.45f; + [SerializeField, Min(0.01f)] private float memoryFadeDuration = 0.55f; + [SerializeField, Min(0.01f)] private float memoryExitDuration = 0.45f; + [SerializeField, Min(0f)] private float faceDipRatio = 0.015f; + + [Header("Screen Styling")] + [SerializeField] private Color lieColor = new Color(0.66f, 0.94f, 1f, 1f); + [SerializeField] private Color errorColor = new Color(1f, 0.34f, 0.18f, 1f); + [SerializeField] private Color truthWarmColor = new Color(1f, 0.84f, 0.64f, 1f); + [SerializeField] private int screenGlowSortingOrder = 8; + [SerializeField] private int screenTextSortingOrder = 18; + + private PresentationState state = PresentationState.Idle; + private Preset currentPreset = LightPreset; + private MemoryKind currentMemoryKind = MemoryKind.Office; + private string currentLieKeyword = string.Empty; + private string currentLeakFragment = string.Empty; + + private Transform runtimeRoot; + private SpriteRenderer memoryRendererA; + private SpriteRenderer memoryRendererB; + private SpriteRenderer activeMemoryRenderer; + private SpriteRenderer scanlineRenderer; + private SpriteRenderer screenDimRenderer; + private SpriteMask screenSpriteMask; + private TextMeshPro lieText; + private TextMeshPro lieGhostText; + private TextMeshPro systemText; + private TextMeshPro actorFlashText; + private TextMeshPro actorFlashChromaRed; + private TextMeshPro actorFlashChromaCyan; + private TMPRectClipper lieClipper; + private TMPRectClipper lieGhostClipper; + private TMPRectClipper systemClipper; + private TMPRectClipper actorFlashClipper; + private TMPRectClipper actorFlashChromaRedClipper; + private TMPRectClipper actorFlashChromaCyanClipper; + private readonly List actorScrollTexts = new List(); + private readonly List actorScrollClippers = new List(); + private bool actorFlashActive; + private int actorFlashCount; + private Coroutine actorOverdriveRoutine; + + private Texture2D runtimeWhiteTexture; + private Sprite runtimeWhiteSprite; + private MaterialPropertyBlock memoryBlockA; + private MaterialPropertyBlock memoryBlockB; + private float memoryOpacityA; + private float memoryOpacityB; + private float saturation = 1f; + private float brightness = 1f; + private float contrast = 1f; + private float vignette; + private float noise; + private float horizontalTear; + private float memoryImpact; + private float memoryDamage; + private float memoryOverdrive; + private float memoryTearSeed; + private Color memoryTint = Color.white; + private Vector2 calmCenter = new Vector2(0.5f, 0.5f); + private float calmRadius; + private float calmFeather = 0.18f; + private float effectStrength; + private float memoryMotionWeight; + private float memoryMotionTime; + private float memoryPushSpeed; + private float memoryHorizontalSpeed; + private float memoryZoom = 1f; + private Vector3 memoryBasePosition; + private Vector3 memoryBaseScale; + private Vector3 faceBasePosition; + private bool runtimeObjectsReady; + private Coroutine lieShuffleRoutine; + + private static readonly int SaturationId = Shader.PropertyToID("_Saturation"); + private static readonly int BrightnessId = Shader.PropertyToID("_Brightness"); + private static readonly int ContrastId = Shader.PropertyToID("_Contrast"); + private static readonly int VignetteId = Shader.PropertyToID("_Vignette"); + private static readonly int NoiseId = Shader.PropertyToID("_Noise"); + private static readonly int HorizontalTearId = Shader.PropertyToID("_HorizontalTear"); + private static readonly int MemoryImpactId = Shader.PropertyToID("_Impact"); + private static readonly int MemoryDamageId = Shader.PropertyToID("_Damage"); + private static readonly int MemoryOverdriveId = Shader.PropertyToID("_Overdrive"); + private static readonly int MemoryTearSeedId = Shader.PropertyToID("_TearSeed"); + private static readonly int ColorTintId = Shader.PropertyToID("_ColorTint"); + private static readonly int CalmCenterId = Shader.PropertyToID("_CalmCenter"); + private static readonly int CalmRadiusId = Shader.PropertyToID("_CalmRadius"); + private static readonly int CalmFeatherId = Shader.PropertyToID("_CalmFeather"); + private static readonly int EffectStrengthId = Shader.PropertyToID("_EffectStrength"); + private static readonly int OpacityId = Shader.PropertyToID("_Opacity"); + + public PresentationState State => state; + + private void Awake() + { + EnsureRuntimeObjects(); + faceBasePosition = faceRenderer != null ? faceRenderer.transform.position : Vector3.zero; + ForceCleanupImmediate(true); + } + + private void Update() + { + if (!runtimeObjectsReady || activeMemoryRenderer == null || !activeMemoryRenderer.enabled) + return; + + if (memoryMotionWeight > 0.001f) + { + memoryMotionTime += Time.deltaTime * memoryMotionWeight; + float scale = (1f + memoryMotionTime * memoryPushSpeed) * memoryZoom; + activeMemoryRenderer.transform.localScale = memoryBaseScale * scale; + + float horizontal = memoryMotionTime * memoryHorizontalSpeed; + activeMemoryRenderer.transform.position = memoryBasePosition + Vector3.left * horizontal; + + if (currentMemoryKind == MemoryKind.Sunset) + saturation = Mathf.Max(0.58f, saturation - Time.deltaTime * 0.028f * memoryMotionWeight); + else if (currentMemoryKind == MemoryKind.PrivateOffice) + brightness = Mathf.Max(0.62f, brightness - Time.deltaTime * 0.018f * memoryMotionWeight); + } + + ApplyMemoryBlocks(); + } + + public IEnumerator PrepareForNextRound() + { + EnsureRuntimeObjects(); + bool anythingVisible = + (faceRenderer != null && faceRenderer.gameObject.activeSelf && faceRenderer.color.a > 0.001f) || + memoryOpacityA > 0.001f || + memoryOpacityB > 0.001f || + (lieText != null && lieText.gameObject.activeSelf) || + (actorFlashText != null && actorFlashText.gameObject.activeSelf) || + actorScrollTexts.Exists(text => text != null && text.gameObject.activeSelf); + + DOTween.Kill(this); + StopAllCoroutines(); + state = PresentationState.Returning; + + if (!anythingVisible) + { + ForceCleanupImmediate(true); + state = PresentationState.LogOperation; + yield break; + } + + const float duration = 0.35f; + TweenMemoryOpacity(memoryRendererA, 0f, duration); + TweenMemoryOpacity(memoryRendererB, 0f, duration); + FadeText(lieText, 0f, duration); + FadeText(lieGhostText, 0f, duration); + FadeText(systemText, 0f, duration); + FadeText(actorFlashText, 0f, duration); + FadeText(actorFlashChromaRed, 0f, duration); + FadeText(actorFlashChromaCyan, 0f, duration); + foreach (TextMeshPro text in actorScrollTexts) + FadeText(text, 0f, duration); + if (faceRenderer != null && faceRenderer.gameObject.activeSelf) + faceRenderer.DOFade(0f, duration).SetEase(Ease.OutQuad).SetTarget(this); + + yield return new WaitForSeconds(duration); + ForceCleanupImmediate(true); + state = PresentationState.LogOperation; + } + + public IEnumerator BeginFaceEntry(float duration = -1f) + { + EnsureRuntimeObjects(); + EnsureScreenTextClipMaterials(); + if (!ExpectState( + nameof(BeginFaceEntry), + PresentationState.Idle, + PresentationState.LogOperation, + PresentationState.Returning)) + { + yield break; + } + + state = PresentationState.FaceEntering; + if (glitchController != null) + { + glitchController.SetCompositeMode(SpriteNoiseGlitchController.CompositeMode.TransparentOverlay); + glitchController.Intensity = 0f; + glitchController.SetAmbientNoise(0f, 320f, 0f); + } + + if (faceRenderer == null) + { + Debug.LogError("[LogReleasePresentation] 火山脸 SpriteRenderer 未配置;跳过脸淡入,避免阻塞 Yarn。"); + state = PresentationState.Memory; + yield break; + } + + faceRenderer.transform.position = faceBasePosition; + faceRenderer.enabled = true; + faceRenderer.gameObject.SetActive(true); + Color faceColor = faceRenderer.color; + faceColor.a = 0f; + faceRenderer.color = faceColor; + float fadeDuration = duration > 0f ? Mathf.Max(0.01f, duration) : faceFadeDuration; + yield return faceRenderer.DOFade(1f, fadeDuration) + .SetEase(Ease.OutQuad) + .SetTarget(this) + .WaitForCompletion(); + } + + public IEnumerator BeginMemory( + string memoryKey, + string presetName, + float fadeDuration = -1f, + float pushSpeed = -1f, + float horizontalSpeed = -1f, + bool preserveGlitch = false) + { + EnsureRuntimeObjects(); + EnsureScreenTextClipMaterials(); + if (!TryResolveMemory(memoryKey, out Sprite memorySprite, out MemoryKind kind)) + { + Debug.LogError($"[LogReleasePresentation] 未知记忆图片“{memoryKey}”;命令安全结束。"); + yield break; + } + if (!TryResolvePreset(presetName, out Preset preset)) + { + Debug.LogError($"[LogReleasePresentation] 未知演出预设“{presetName}”;命令安全结束。"); + yield break; + } + + if (!ExpectState( + nameof(BeginMemory), + PresentationState.FaceEntering, + PresentationState.Memory, + PresentationState.TruthHolding)) + { + yield break; + } + + currentPreset = preset; + currentMemoryKind = kind; + currentLeakFragment = GetLeakFragment(kind); + actorFlashCount = 0; + memoryImpact = 0f; + memoryDamage = 0f; + memoryOverdrive = 0f; + memoryTearSeed = 0f; + state = PresentationState.Memory; + + if (glitchController != null) + { + glitchController.SetCompositeMode(SpriteNoiseGlitchController.CompositeMode.TransparentOverlay); + if (!preserveGlitch) + glitchController.Intensity = 0f; + glitchController.SetAmbientNoise(0f, 320f, 0f); + glitchController.ClearCalmField(true); + } + + SpriteRenderer incoming = activeMemoryRenderer == memoryRendererA ? memoryRendererB : memoryRendererA; + SpriteRenderer outgoing = activeMemoryRenderer; + incoming.sprite = memorySprite; + incoming.enabled = true; + incoming.gameObject.SetActive(true); + FitMemoryToView(incoming); + activeMemoryRenderer = incoming; + + ConfigureMemoryLook(kind, preset); + memoryMotionTime = 0f; + memoryMotionWeight = 1f; + memoryZoom = 1f; + memoryPushSpeed = pushSpeed >= 0f + ? pushSpeed + : kind == MemoryKind.PrivateOffice ? 0.040f : 0.022f; + memoryHorizontalSpeed = horizontalSpeed >= 0f + ? horizontalSpeed + : kind == MemoryKind.Sunset ? 0.075f : 0f; + memoryBasePosition = incoming.transform.position; + memoryBaseScale = incoming.transform.localScale; + float resolvedFadeDuration = fadeDuration > 0f + ? Mathf.Max(0.01f, fadeDuration) + : memoryFadeDuration; + SetMemoryOpacity(incoming, 0f); + TweenMemoryOpacity(incoming, 1f, resolvedFadeDuration); + if (outgoing != null && outgoing != incoming) + TweenMemoryOpacity(outgoing, 0f, resolvedFadeDuration); + + yield return new WaitForSeconds(resolvedFadeDuration); + } + + public IEnumerator BeginLie(string keyword, string presetName) + { + EnsureRuntimeObjects(); + if (!TryResolvePreset(presetName, out Preset requestedPreset)) + { + Debug.LogError($"[LogReleasePresentation] 未知演出预设“{presetName}”;命令安全结束。"); + yield break; + } + + if (!string.Equals(requestedPreset.Name, currentPreset.Name, StringComparison.OrdinalIgnoreCase)) + { + Debug.LogWarning( + $"[LogReleasePresentation] expression_lie_begin 的预设 {requestedPreset.Name} " + + $"与当前记忆 {currentPreset.Name} 不一致,将沿用当前记忆预设。"); + } + + PrepareLieVisuals(0.25f, 0.005f); + SqueezeTruthCharacters( + 0.22f, + 0.055f + currentPreset.BreakPeak * 0.065f, + 0.28f); + ShowLieKeyword(keyword, 0.22f, 14f); + PlayScreenScanline(0.32f, currentMemoryKind == MemoryKind.Sunset); + if (currentMemoryKind == MemoryKind.PrivateOffice) + ShowSystemMessage("输出正常", "normal", 0.12f); + else + HideSystemMessageImmediate(); + yield return new WaitForSeconds(0.25f); + } + + public IEnumerator BreakLie() + { + EnsureRuntimeObjects(); + if (!ExpectState(nameof(BreakLie), PresentationState.LieHolding)) + yield break; + + yield return new WaitForSeconds(currentPreset.LiePause); + BeginLieCracking(0.48f); + PlayScreenScanline(0.22f, true); + PlayScreenDim(0.28f, 0.06f, 0.12f); + PlayMemoryJolt(0.08f, 0.24f, 0.11f); + yield return LeakTruthFragment(currentLeakFragment, 0.24f); + + if (currentMemoryKind == MemoryKind.PrivateOffice) + { + HideSystemMessage(0.01f); + yield return new WaitForSeconds(0.08f); + ShowSystemMessage("输出与原始 log 不一致", "error", 0.08f); + yield return PulseGlitch(HeavyPreset.BreakPeak, 0.12f, 0.18f); + } + } + + public IEnumerator RevealTruth() + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(RevealTruth), + PresentationState.LieCracking, + PresentationState.LieHolding)) + { + yield break; + } + + DOTween.Kill(this); + yield return HoldTruthBlackout(0.055f, 0.42f); + SplitLieVisual(currentPreset.BreakDuration, 0.18f, 1.85f, 0.42f); + PlayTruthFlash(0.62f, 0.045f, 0.025f, 0.16f); + PlayMemoryBrightnessPulse(0.16f, 0.06f, 0.20f); + PlayMemoryTear(currentPreset.BreakPeak, currentPreset.BreakDuration); + PlayGlitchPulse(currentPreset.BreakPeak, 0.12f, currentPreset.BreakDuration); + PlayFaceDip(currentPreset.BreakDuration, faceDipRatio); + ReleaseTruthCharacters(0.08f); + yield return ResolveTruthCharacters( + currentPreset.TruthDuration, + currentPreset.TruthPeripheral, + 0.42f); + } + + public void PrepareLieVisuals(float freezeDuration, float noiseTarget) + { + EnsureRuntimeObjects(); + if (!ExpectState(nameof(PrepareLieVisuals), PresentationState.Memory)) + return; + + state = PresentationState.LieHolding; + DOTween.Kill(this); + float duration = Mathf.Max(0.01f, freezeDuration); + DOTween.To(() => memoryMotionWeight, value => memoryMotionWeight = value, 0f, duration) + .SetEase(Ease.OutQuad) + .SetTarget(this); + DOTween.To(() => noise, value => noise = value, Mathf.Clamp01(noiseTarget), duration) + .SetEase(Ease.OutQuad) + .SetTarget(this); + } + + public void SqueezeTruthCharacters(float duration, float jitter, float alpha) + { + if (!ExpectState( + nameof(SqueezeTruthCharacters), + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + + particleManager?.SetTargetParticlesAlpha(Mathf.Clamp01(alpha), Mathf.Max(0.01f, duration)); + particleManager?.BeginLieEdgeCompression( + Mathf.Max(0.01f, duration), + Mathf.Max(0f, jitter)); + } + + public void ShowLieKeyword(string keyword, float duration, float characterSpacing) + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(ShowLieKeyword), + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + + currentLieKeyword = keyword ?? string.Empty; + PositionScreenText(); + SetTextActive(lieText, currentLieKeyword, lieColor); + lieText.characterSpacing = -26f; + lieText.transform.localScale = new Vector3(1.35f, 0.62f, 1f); + SetTextAlpha(lieText, 0f); + float tweenDuration = Mathf.Max(0.01f, duration); + DOTween.To( + () => lieText.characterSpacing, + value => lieText.characterSpacing = value, + characterSpacing, + tweenDuration) + .SetEase(Ease.OutBack) + .SetTarget(this); + lieText.transform.DOScale(Vector3.one, tweenDuration) + .SetEase(Ease.OutBack) + .SetTarget(this); + FadeText(lieText, 1f, Mathf.Min(0.14f, tweenDuration)); + if (lieGhostText != null) + lieGhostText.gameObject.SetActive(false); + } + + public void PlayScreenScanline(float duration, bool stalled) + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(PlayScreenScanline), + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + PlayScanline(Mathf.Max(0.01f, duration), stalled); + } + + public void ShowSystemMessage(string value, string style, float fadeDuration) + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(ShowSystemMessage), + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + Color color; + if (string.Equals(style, "error", StringComparison.OrdinalIgnoreCase)) + { + color = errorColor; + } + else + { + if (!string.Equals(style, "normal", StringComparison.OrdinalIgnoreCase)) + Debug.LogWarning($"[LogReleasePresentation] 未知系统提示样式“{style}”,改用 normal。"); + color = new Color(0.58f, 0.78f, 0.82f, 1f); + } + SetSystemMessage(value ?? string.Empty, color, Mathf.Max(0.01f, fadeDuration)); + } + + public void HideSystemMessage(float fadeDuration) + { + if (systemText == null || !systemText.gameObject.activeSelf) + return; + + systemText.DOKill(); + systemText.DOFade(0f, Mathf.Max(0.01f, fadeDuration)) + .SetEase(Ease.OutQuad) + .SetTarget(this) + .OnComplete(HideSystemMessageImmediate); + } + + public void BeginLieCracking(float lieAlpha) + { + if (!ExpectState( + nameof(BeginLieCracking), + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + + state = PresentationState.LieCracking; + if (lieText == null || !lieText.gameObject.activeSelf) + return; + Color dimmed = lieText.color; + dimmed.a = Mathf.Clamp01(lieAlpha); + lieText.color = dimmed; + } + + public void PlayScreenDim(float alpha, float riseDuration, float fallDuration) + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(PlayScreenDim), + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + PulseScreenDim(alpha, riseDuration, fallDuration); + } + + public void PlayMemoryJolt(float offset, float duration, float tear) + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(PlayMemoryJolt), + PresentationState.Memory, + PresentationState.LieHolding, + PresentationState.LieCracking, + PresentationState.TruthResolving)) + return; + horizontalTear = Mathf.Max(horizontalTear, Mathf.Clamp01(tear)); + if (activeMemoryRenderer == null) + return; + + Transform target = activeMemoryRenderer.transform; + target.DOKill(); + Vector3 origin = target.position; + float totalDuration = Mathf.Max(0.02f, duration); + Sequence sequence = DOTween.Sequence().SetTarget(this); + sequence.Append(target.DOMoveX(origin.x + offset, totalDuration * 0.35f).SetEase(Ease.OutQuad)); + sequence.Append(target.DOMoveX(origin.x, totalDuration * 0.65f).SetEase(Ease.InOutQuad)); + } + + public IEnumerator LeakTruthFragment(string fragment, float duration) + { + if (!ExpectState( + nameof(LeakTruthFragment), + PresentationState.LieHolding, + PresentationState.LieCracking)) + yield break; + + currentLeakFragment = fragment ?? string.Empty; + yield return ShuffleLieCharactersRoutine( + currentLeakFragment, + Mathf.Max(0.01f, duration), + 0.045f); + } + + public void StartLieCharacterShuffle(string characterPool, float duration, float interval) + { + if (!ExpectState( + nameof(StartLieCharacterShuffle), + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + + StopLieShuffleImmediate(false); + lieShuffleRoutine = StartCoroutine(ShuffleLieCharactersRoutine( + characterPool, + Mathf.Max(0.01f, duration), + Mathf.Max(0.02f, interval))); + } + + public void CutMemoryAndGlitchImmediate() + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(CutMemoryAndGlitchImmediate), + PresentationState.Memory, + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + + DOTween.Kill(this); + StopActorOverdriveImmediate(); + memoryMotionWeight = 0f; + memoryZoom = 1f; + memoryOpacityA = 0f; + memoryOpacityB = 0f; + horizontalTear = 0f; + noise = 0f; + effectStrength = 0f; + memoryImpact = 0f; + memoryDamage = 0f; + memoryOverdrive = 0f; + calmRadius = 0f; + DisableMemoryRenderer(memoryRendererA); + DisableMemoryRenderer(memoryRendererB); + activeMemoryRenderer = null; + + if (glitchController != null) + { + glitchController.Intensity = 0f; + glitchController.SetAmbientNoise(0f, 320f, 0f); + glitchController.ClearCalmField(true); + glitchController.SetCompositeMode(SpriteNoiseGlitchController.CompositeMode.TransparentOverlay); + } + } + + /// + /// 演员大字闪现:glitch 一拍的瞬间隐藏抖动的粒子字,屏幕中央出现白色大字。 + /// 三次冲击逐级增强并累积记忆损伤;第三次大字不再退回粒子,而是留给老虎机过载态接管。 + /// + public IEnumerator FlashActorLie( + string text, + float holdDuration, + float fontSize = 10.5f, + float settledScaleValue = -1f, + float punchScaleValue = -1f, + float chromaDistance = -1f, + float chromaAlpha = -1f, + float impact = -1f, + float glitchPeak = -1f) + { + EnsureRuntimeObjects(); + EnsureScreenTextClipMaterials(); + if (!ExpectState(nameof(FlashActorLie), PresentationState.Memory)) + yield break; + if (actorFlashActive) + { + Debug.LogWarning("[LogReleasePresentation] expression_actor_flash 在上一次闪现结束前被调用;已安全跳过。"); + yield break; + } + + actorFlashActive = true; + actorFlashCount = Mathf.Clamp(actorFlashCount + 1, 1, 3); + int flashLevel = actorFlashCount; + float flashProgress = flashLevel / 3f; + float resolvedImpact = impact >= 0f ? Mathf.Clamp01(impact) : GetFlashImpact(flashLevel); + float resolvedGlitchPeak = glitchPeak >= 0f + ? Mathf.Clamp01(glitchPeak) + : Mathf.Lerp(0.16f, 0.42f, flashProgress); + float resolvedChromaAlpha = chromaAlpha >= 0f + ? Mathf.Clamp01(chromaAlpha) + : Mathf.Lerp(0.22f, 0.36f, flashProgress); + float resolvedChromaDistance = chromaDistance >= 0f + ? chromaDistance + : Mathf.Lerp(0.025f, 0.065f, flashProgress); + float resolvedSettledScale = settledScaleValue > 0f + ? settledScaleValue + : Mathf.Lerp(1.15f, 1.25f, flashProgress); + float resolvedPunchScale = punchScaleValue > 0f + ? punchScaleValue + : resolvedSettledScale * Mathf.Lerp(1.28f, 1.42f, flashProgress); + memoryDamage = Mathf.Max(memoryDamage, GetFlashDamage(flashLevel)); + memoryTearSeed = Mathf.Repeat(memoryTearSeed + 0.271f + flashLevel * 0.137f, 1f); + StartCoroutine(PulseMemoryImpact( + resolvedImpact, + 0.045f, + Mathf.Max(0.05f, holdDuration - 0.08f), + Mathf.Lerp(0.26f, 0.16f, flashProgress))); + try + { + if (glitchController != null) + { + StartCoroutine(PulseGlitch(resolvedGlitchPeak, 0.04f, 0.14f)); + } + + particleManager?.SetTargetParticlesAlpha(0f, 0f); + + PositionScreenText(); + string value = text ?? string.Empty; + float resolvedFontSize = Mathf.Max(0.1f, fontSize); + actorFlashText.fontSize = resolvedFontSize; + actorFlashChromaRed.fontSize = resolvedFontSize; + actorFlashChromaCyan.fontSize = resolvedFontSize; + SetTextActive(actorFlashText, value, Color.white); + SetTextActive(actorFlashChromaRed, value, new Color(1f, 0.25f, 0.25f, resolvedChromaAlpha)); + SetTextActive(actorFlashChromaCyan, value, new Color(0.3f, 0.9f, 1f, resolvedChromaAlpha)); + Vector3 center = actorFlashText.transform.position; + actorFlashChromaRed.transform.position = center + Vector3.left * resolvedChromaDistance; + actorFlashChromaCyan.transform.position = center + Vector3.right * resolvedChromaDistance; + + const float punchDuration = 0.14f; + Vector3 settledScale = Vector3.one * resolvedSettledScale; + Vector3 punchScale = Vector3.one * resolvedPunchScale; + actorFlashText.transform.localScale = punchScale; + actorFlashChromaRed.transform.localScale = punchScale; + actorFlashChromaCyan.transform.localScale = punchScale; + actorFlashText.transform.DOScale(settledScale, punchDuration).SetEase(Ease.OutCubic).SetTarget(this); + actorFlashChromaRed.transform.DOScale(settledScale, punchDuration).SetEase(Ease.OutCubic).SetTarget(this); + actorFlashChromaCyan.transform.DOScale(settledScale, punchDuration).SetEase(Ease.OutCubic).SetTarget(this); + + yield return new WaitForSeconds(Mathf.Max(0.05f, holdDuration)); + } + finally + { + actorFlashActive = false; + bool holdForOverdrive = flashLevel >= 3 && state == PresentationState.Memory; + if (!holdForOverdrive) + HideActorFlashVisualsImmediate(); + if (!holdForOverdrive && state == PresentationState.Memory) + particleManager?.SetTargetParticlesAlpha(1f, 0f); + } + } + + /// + /// 老虎机换字演出:聚焦字在 duration 内只从 charPool 里疯狂换字(非阻塞,节拍由 Yarn wait 控制)。 + /// + public void StartActorSlotMachine( + string charPool, + float duration, + float interval, + float fontSize = 14f) + { + EnsureRuntimeObjects(); + if (!ExpectState(nameof(StartActorSlotMachine), PresentationState.Memory)) + return; + string pool = LanguageParticleManager.SanitizeSlotMachinePool(charPool); + if (string.IsNullOrEmpty(pool)) + { + Debug.LogWarning("[LogReleasePresentation] 演员老虎机字符池为空;已安全跳过。"); + return; + } + + StopActorOverdriveImmediate(); + particleManager?.SetTargetParticlesAlpha(0f, 0f); + float resolvedFontSize = Mathf.Max(0.1f, fontSize); + actorFlashText.fontSize = resolvedFontSize; + actorFlashChromaRed.fontSize = resolvedFontSize; + actorFlashChromaCyan.fontSize = resolvedFontSize; + actorOverdriveRoutine = StartCoroutine(ActorOverdriveRoutine( + pool, + Mathf.Max(0.05f, duration), + Mathf.Max(0.02f, interval))); + if (glitchController != null) + { + StartCoroutine(PulseGlitch( + 0.64f, + Mathf.Max(0.05f, duration * 0.82f), + Mathf.Max(0.05f, duration * 0.18f))); + } + } + + public IEnumerator PlayActorWordHit( + string text, + float holdDuration, + float fontSize = 18f, + float punchScale = 1.55f) + { + EnsureRuntimeObjects(); + EnsureScreenTextClipMaterials(); + if (!ExpectState(nameof(PlayActorWordHit), PresentationState.Memory)) + yield break; + + StopActorOverdriveImmediate(); + HideActorScrollImmediate(); + HideActorFlashVisualsImmediate(); + particleManager?.SetTargetParticlesAlpha(0f, 0f); + PositionScreenText(); + + string value = text ?? string.Empty; + actorFlashText.fontSize = Mathf.Max(0.1f, fontSize); + SetTextActive(actorFlashText, value, Color.white); + actorFlashText.transform.localScale = Vector3.one * Mathf.Max(0.1f, punchScale); + actorFlashText.transform.DOScale(Vector3.one, 0.065f) + .SetEase(Ease.OutCubic) + .SetTarget(actorFlashText); + + memoryTearSeed = Mathf.Repeat(memoryTearSeed + 0.239f, 1f); + memoryImpact = 1f; + yield return new WaitForSeconds(Mathf.Max(0.05f, holdDuration)); + + HideTextImmediate(actorFlashText); + actorFlashText.fontSize = 10.5f; + yield return new WaitForSeconds(0.025f); + } + + public IEnumerator ScrollActorLies( + string phraseList, + float scrollDuration, + float loopDuration, + float holdDuration, + float fontSize = 5.2f) + { + EnsureRuntimeObjects(); + EnsureScreenTextClipMaterials(); + if (!ExpectState(nameof(ScrollActorLies), PresentationState.Memory)) + yield break; + + string[] phrases = ParseActorPhrases(phraseList); + if (phrases.Length == 0) + { + Debug.LogWarning("[LogReleasePresentation] 向下刷屏词组为空;已安全跳过。"); + yield break; + } + + StopActorOverdriveImmediate(); + HideActorFlashVisualsImmediate(); + HideActorScrollImmediate(); + particleManager?.SetTargetParticlesAlpha(0f, 0f); + + const int itemCount = 4; + EnsureActorScrollTextPool(itemCount); + + Bounds screen = GetScreenBounds(); + float rowSpacing = screen.size.y / (itemCount - 1f); + float wrapSpan = rowSpacing * itemCount; + float speed = wrapSpan / Mathf.Max(0.20f, loopDuration); + float[] yPositions = new float[itemCount]; + int[] phraseIndices = new int[itemCount]; + + for (int i = 0; i < actorScrollTexts.Count; i++) + { + TextMeshPro text = actorScrollTexts[i]; + bool used = i < itemCount; + text.gameObject.SetActive(used); + if (!used) + continue; + + phraseIndices[i] = i % phrases.Length; + yPositions[i] = screen.max.y + rowSpacing * (i - 1); + text.fontSize = Mathf.Max(0.1f, fontSize); + text.rectTransform.sizeDelta = screen.size; + SetTextActive(text, phrases[phraseIndices[i]], new Color(1f, 1f, 1f, 0.92f)); + text.transform.position = new Vector3(screen.center.x, yPositions[i], screen.center.z); + text.transform.localScale = Vector3.one; + } + + float elapsed = 0f; + float moveDuration = Mathf.Max(0.05f, scrollDuration); + while (elapsed < moveDuration && state == PresentationState.Memory) + { + float delta = Time.deltaTime; + for (int i = 0; i < itemCount; i++) + { + TextMeshPro text = actorScrollTexts[i]; + yPositions[i] -= speed * delta; + if (yPositions[i] < screen.min.y - rowSpacing) + { + yPositions[i] += wrapSpan; + phraseIndices[i] = (phraseIndices[i] + 1) % phrases.Length; + text.text = phrases[phraseIndices[i]]; + } + + Vector3 position = text.transform.position; + position.y = yPositions[i]; + text.transform.position = position; + } + + elapsed += delta; + yield return null; + } + + if (state == PresentationState.Memory) + yield return new WaitForSeconds(Mathf.Max(0f, holdDuration)); + HideActorScrollImmediate(); + } + + public void SnapTruthImmediate(float peripheral, float calmTarget) + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(SnapTruthImmediate), + PresentationState.Memory, + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + + state = PresentationState.TruthResolving; + StopLieShuffleImmediate(true); + HideLieLayersImmediate(); + HideSystemMessageImmediate(); + StopScanline(); + StopScreenDim(); + + particleManager?.PrepareTruthReleaseFromLie(); + particleManager?.SetTruthWarmVisuals(truthWarmColor); + particleManager?.SetTargetParticlesAlpha(1f, 0f); + Bounds truthBounds = particleManager != null + ? particleManager.GetFinalTruthWorldBounds() + : new Bounds(GetScreenBounds().center, Vector3.one); + SetCalmField(truthBounds); + effectStrength = Mathf.Clamp01(peripheral); + calmRadius = Mathf.Clamp01(calmTarget); + particleManager?.SnapTruthImmediate(); + + if (glitchController != null) + glitchController.Intensity = 0f; + memoryMotionWeight = 0f; + state = PresentationState.TruthHolding; + } + + public void PlayGlitchPulse(float peak, float riseDuration, float fallDuration) + { + if (!ExpectState( + nameof(PlayGlitchPulse), + PresentationState.Memory, + PresentationState.LieHolding, + PresentationState.LieCracking, + PresentationState.TruthResolving, + PresentationState.TruthHolding)) + return; + StartCoroutine(PulseGlitch( + Mathf.Clamp01(peak), + Mathf.Max(0.01f, riseDuration), + Mathf.Max(0.01f, fallDuration))); + } + + public IEnumerator BuildActorOverloadPeak( + float riseDuration, + float holdDuration, + float zoom, + float glitchPeak, + float impact) + { + EnsureRuntimeObjects(); + if (!ExpectState(nameof(BuildActorOverloadPeak), PresentationState.Memory)) + yield break; + + float rise = Mathf.Max(0.01f, riseDuration); + float resolvedZoom = Mathf.Max(1f, zoom); + float resolvedImpact = Mathf.Clamp01(impact); + memoryTearSeed = Mathf.Repeat(memoryTearSeed + 0.347f, 1f); + + DOTween.To(() => memoryZoom, value => memoryZoom = value, resolvedZoom, rise) + .SetEase(Ease.InQuad) + .SetTarget(this); + DOTween.To(() => memoryImpact, value => memoryImpact = value, resolvedImpact, rise) + .SetEase(Ease.InQuad) + .SetTarget(this); + DOTween.To(() => memoryOverdrive, value => memoryOverdrive = value, 1f, rise) + .SetEase(Ease.InQuad) + .SetTarget(this); + + if (glitchController != null) + { + glitchController.SetCompositeMode(SpriteNoiseGlitchController.CompositeMode.TransparentOverlay); + glitchController.TransitionTo(Mathf.Clamp01(glitchPeak), rise); + } + + if (actorFlashText != null) + { + actorFlashText.transform.DOScale( + actorFlashText.transform.localScale * resolvedZoom, + rise) + .SetEase(Ease.InQuad) + .SetTarget(this); + } + if (actorFlashChromaRed != null) + { + actorFlashChromaRed.transform.DOScale( + actorFlashChromaRed.transform.localScale * resolvedZoom, + rise) + .SetEase(Ease.InQuad) + .SetTarget(this); + } + if (actorFlashChromaCyan != null) + { + actorFlashChromaCyan.transform.DOScale( + actorFlashChromaCyan.transform.localScale * resolvedZoom, + rise) + .SetEase(Ease.InQuad) + .SetTarget(this); + } + + yield return new WaitForSeconds(rise); + yield return new WaitForSeconds(Mathf.Max(0f, holdDuration)); + } + + public IEnumerator HoldTruthBlackout(float duration, float alpha) + { + if (!ExpectState( + nameof(HoldTruthBlackout), + PresentationState.LieHolding, + PresentationState.LieCracking)) + yield break; + + HoldTruthBreakBlackout(alpha); + yield return new WaitForSeconds(Mathf.Max(0.01f, duration)); + } + + public void SplitLieVisual(float duration, float distanceRatio, float stretchX, float squashY) + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(SplitLieVisual), + PresentationState.LieHolding, + PresentationState.LieCracking, + PresentationState.TruthResolving)) + return; + if (lieText == null || !lieText.gameObject.activeSelf) + return; + + Bounds screenBounds = GetScreenBounds(); + Vector3 lieOrigin = lieText.transform.position; + float tweenDuration = Mathf.Max(0.01f, duration); + float distance = screenBounds.extents.x * Mathf.Max(0f, distanceRatio); + SetTextActive(lieGhostText, currentLieKeyword, truthWarmColor); + lieGhostText.transform.position = lieOrigin + Vector3.left * 0.10f; + lieGhostText.transform.localScale = Vector3.one; + lieGhostText.characterSpacing = lieText.characterSpacing; + SetTextAlpha(lieGhostText, 0.90f); + lieText.transform.DOScale( + new Vector3(Mathf.Max(0.01f, stretchX), Mathf.Max(0.01f, squashY), 1f), + tweenDuration) + .SetEase(Ease.InCubic) + .SetTarget(this); + lieText.transform.DOMoveX(lieOrigin.x + distance, tweenDuration) + .SetEase(Ease.InCubic) + .SetTarget(this); + lieGhostText.transform.DOMoveX(lieOrigin.x - distance, tweenDuration) + .SetEase(Ease.InCubic) + .SetTarget(this); + FadeText(lieText, 0f, tweenDuration); + FadeText(lieGhostText, 0f, tweenDuration); + } + + public void PlayTruthFlash(float alpha, float riseDuration, float holdDuration, float fallDuration) + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(PlayTruthFlash), + PresentationState.LieHolding, + PresentationState.LieCracking, + PresentationState.TruthResolving, + PresentationState.TruthHolding)) + return; + PulseTruthBreakFlash(alpha, riseDuration, holdDuration, fallDuration); + } + + public void PlayMemoryBrightnessPulse(float amount, float riseDuration, float fallDuration) + { + if (!ExpectState( + nameof(PlayMemoryBrightnessPulse), + PresentationState.Memory, + PresentationState.LieHolding, + PresentationState.LieCracking, + PresentationState.TruthResolving, + PresentationState.TruthHolding)) + return; + PulseMemoryRelease(amount, riseDuration, fallDuration); + } + + public void PlayMemoryTear(float peak, float duration) + { + if (!ExpectState( + nameof(PlayMemoryTear), + PresentationState.Memory, + PresentationState.LieHolding, + PresentationState.LieCracking, + PresentationState.TruthResolving, + PresentationState.TruthHolding)) + return; + horizontalTear = Mathf.Max(horizontalTear, Mathf.Clamp01(peak)); + DOTween.To( + () => horizontalTear, + value => horizontalTear = value, + 0f, + Mathf.Max(0.01f, duration)) + .SetEase(Ease.OutQuad) + .SetTarget(this); + } + + public void PlayFaceDip(float duration, float ratio) + { + if (!ExpectState( + nameof(PlayFaceDip), + PresentationState.LieHolding, + PresentationState.LieCracking, + PresentationState.TruthResolving)) + return; + DipFace(Mathf.Max(0.01f, duration), Mathf.Max(0f, ratio)); + } + + public void ReleaseTruthCharacters(float alphaDuration) + { + if (!ExpectState( + nameof(ReleaseTruthCharacters), + PresentationState.LieHolding, + PresentationState.LieCracking)) + return; + + state = PresentationState.TruthResolving; + HideSystemMessage(0.15f); + particleManager?.PrepareTruthReleaseFromLie(); + particleManager?.SetTruthWarmVisuals(truthWarmColor); + particleManager?.SetTargetParticlesAlpha(1f, Mathf.Max(0.01f, alphaDuration)); + Bounds truthBounds = particleManager != null + ? particleManager.GetFinalTruthWorldBounds() + : new Bounds(GetScreenBounds().center, Vector3.one); + SetCalmField(truthBounds); + } + + public IEnumerator ResolveTruthCharacters(float duration, float peripheral, float calmTarget) + { + if (!ExpectState(nameof(ResolveTruthCharacters), PresentationState.TruthResolving)) + yield break; + + float resolveDuration = Mathf.Max(0.01f, duration); + DOTween.To( + () => effectStrength, + value => effectStrength = value, + Mathf.Clamp01(peripheral), + resolveDuration) + .SetEase(Ease.OutQuad) + .SetTarget(this); + calmRadius = 0f; + DOTween.To( + () => calmRadius, + value => calmRadius = value, + Mathf.Clamp01(calmTarget), + resolveDuration) + .SetEase(Ease.OutCubic) + .SetTarget(this); + brightness = Mathf.Max(brightness, currentMemoryKind == MemoryKind.Sunset ? 1.02f : brightness); + + if (particleManager != null) + yield return particleManager.ResolveFocusAndWait(resolveDuration); + else + yield return new WaitForSeconds(resolveDuration); + + if (currentMemoryKind == MemoryKind.Sunset) + { + DOTween.To(() => saturation, value => saturation = value, 0.92f, 0.2f) + .SetEase(Ease.OutQuad) + .SetTarget(this); + } + + HideLieLayersImmediate(); + HideSystemMessageImmediate(); + StopScanline(); + StopScreenDim(); + ReleaseScreenTextClipMaterials(); + if (glitchController != null) + glitchController.TransitionTo(0f, 0.18f); + memoryMotionWeight = 0f; + state = PresentationState.TruthHolding; + } + + public void TruthImpact() + { + EnsureRuntimeObjects(); + if (!ExpectState(nameof(TruthImpact), PresentationState.TruthHolding, PresentationState.TruthResolving)) + return; + + StartCoroutine(TruthImpactRoutine()); + } + + public IEnumerator EndMemory() + { + EnsureRuntimeObjects(); + if (!ExpectState( + nameof(EndMemory), + PresentationState.Memory, + PresentationState.LieCracking, + PresentationState.TruthHolding)) + { + yield break; + } + + state = PresentationState.Returning; + TweenMemoryOpacity(memoryRendererA, 0f, memoryExitDuration); + TweenMemoryOpacity(memoryRendererB, 0f, memoryExitDuration); + HideLieLayersImmediate(); + HideSystemMessageImmediate(); + StopScanline(); + StopScreenDim(); + if (glitchController != null) + { + glitchController.ClearCalmField(true); + glitchController.TransitionTo(0f, 0.12f); + glitchController.SetAmbientNoise(0f, 320f, 0f); + } + + yield return new WaitForSeconds(memoryExitDuration); + DisableMemoryRenderer(memoryRendererA); + DisableMemoryRenderer(memoryRendererB); + + if (currentMemoryKind == MemoryKind.PrivateOffice) + { + particleManager?.SetTargetParticlesAlpha(0.6f, 0.18f); + if (glitchController != null) + { + glitchController.SetAmbientNoise(0.012f, 430f, 0.24f); + glitchController.Intensity = 0.03f; + } + } + + state = PresentationState.TruthHolding; + } + + public void ForceCleanupImmediate(bool hideFace) + { + DOTween.Kill(this); + StopAllCoroutines(); + + memoryOpacityA = 0f; + memoryOpacityB = 0f; + DisableMemoryRenderer(memoryRendererA); + DisableMemoryRenderer(memoryRendererB); + activeMemoryRenderer = null; + HideLieLayersImmediate(); + HideSystemMessageImmediate(); + StopScanline(); + StopScreenDim(); + ReleaseScreenTextClipMaterials(); + + if (faceRenderer != null) + { + faceRenderer.transform.position = faceBasePosition; + if (hideFace) + { + Color faceColor = faceRenderer.color; + faceColor.a = 0f; + faceRenderer.color = faceColor; + faceRenderer.enabled = false; + faceRenderer.gameObject.SetActive(false); + } + } + + if (glitchController != null) + { + glitchController.Intensity = 0f; + glitchController.ClearCalmField(true); + glitchController.SetAmbientNoise(0f, 320f, 0f); + glitchController.SetCompositeMode(SpriteNoiseGlitchController.CompositeMode.OpaqueBackground); + } + + particleManager?.RestoreTargetPresentationDefaults(); + state = PresentationState.Idle; + currentLieKeyword = string.Empty; + currentLeakFragment = string.Empty; + memoryMotionWeight = 0f; + memoryZoom = 1f; + horizontalTear = 0f; + memoryImpact = 0f; + memoryDamage = 0f; + memoryOverdrive = 0f; + calmRadius = 0f; + } + + private static float GetFlashDamage(int flashLevel) + { + return flashLevel switch + { + 1 => 0.18f, + 2 => 0.40f, + _ => 0.66f + }; + } + + private static float GetFlashImpact(int flashLevel) + { + return flashLevel switch + { + 1 => 0.46f, + 2 => 0.72f, + _ => 1f + }; + } + + private IEnumerator PulseMemoryImpact( + float peak, + float riseDuration, + float holdDuration, + float fallDuration) + { + float start = memoryImpact; + yield return DOTween.To( + () => memoryImpact, + value => memoryImpact = value, + Mathf.Clamp01(peak), + Mathf.Max(0.01f, riseDuration)) + .SetEase(Ease.OutQuad) + .SetTarget(this) + .WaitForCompletion(); + yield return new WaitForSeconds(Mathf.Max(0f, holdDuration)); + yield return DOTween.To( + () => memoryImpact, + value => memoryImpact = value, + start, + Mathf.Max(0.01f, fallDuration)) + .SetEase(Ease.OutCubic) + .SetTarget(this) + .WaitForCompletion(); + } + + private IEnumerator ActorOverdriveRoutine(string pool, float duration, float interval) + { + PositionScreenText(); + string initial = actorFlashText != null ? actorFlashText.text : string.Empty; + int characterCount = Mathf.Max(1, string.IsNullOrEmpty(initial) ? 3 : initial.Length); + float elapsed = 0f; + float nextShuffle = 0f; + + while (elapsed < duration && state == PresentationState.Memory) + { + float progress = Mathf.Clamp01(elapsed / duration); + float eased = progress * progress * (3f - 2f * progress); + memoryOverdrive = Mathf.Lerp(0.35f, 1f, eased); + memoryDamage = Mathf.Max(memoryDamage, Mathf.Lerp(0.66f, 1f, eased)); + + if (elapsed >= nextShuffle) + { + string value = BuildRandomActorText(pool, characterCount); + SetActorOverdriveText(value, eased); + memoryTearSeed = Mathf.Repeat(memoryTearSeed + 0.173f, 1f); + float acceleratingInterval = Mathf.Lerp( + interval, + Mathf.Max(0.02f, interval * 0.42f), + eased); + nextShuffle = elapsed + acceleratingInterval; + } + + float pulse = 1f + Mathf.Sin(elapsed * Mathf.Lerp(12f, 29f, eased)) * + Mathf.Lerp(0.025f, 0.095f, eased); + Vector3 scale = new Vector3( + pulse * Mathf.Lerp(1f, 1.16f, eased), + pulse * Mathf.Lerp(1f, 0.90f, eased), + 1f); + SetActorOverdriveScale(scale); + + elapsed += Time.deltaTime; + yield return null; + } + + if (state == PresentationState.Memory) + { + memoryImpact = 1f; + memoryDamage = 1f; + memoryOverdrive = 1f; + SetActorOverdriveScale(new Vector3(1.18f, 0.88f, 1f)); + } + actorOverdriveRoutine = null; + } + + private static string BuildRandomActorText(string pool, int characterCount) + { + if (string.IsNullOrEmpty(pool)) + return string.Empty; + + char[] value = new char[Mathf.Max(1, characterCount)]; + for (int i = 0; i < value.Length; i++) + value[i] = pool[UnityEngine.Random.Range(0, pool.Length)]; + return new string(value); + } + + private void SetActorOverdriveText(string value, float progress) + { + SetTextActive(actorFlashText, value, Color.white); + SetTextActive( + actorFlashChromaRed, + value, + new Color(1f, 0.18f, 0.18f, Mathf.Lerp(0.22f, 0.38f, progress))); + SetTextActive( + actorFlashChromaCyan, + value, + new Color(0.16f, 0.92f, 1f, Mathf.Lerp(0.22f, 0.38f, progress))); + + Vector3 center = actorFlashText.transform.position; + float chromaDistance = Mathf.Lerp(0.04f, 0.11f, progress); + float verticalJitter = UnityEngine.Random.Range(-0.012f, 0.012f) * progress; + actorFlashChromaRed.transform.position = + center + Vector3.left * chromaDistance + Vector3.up * verticalJitter; + actorFlashChromaCyan.transform.position = + center + Vector3.right * chromaDistance - Vector3.up * verticalJitter; + } + + private void SetActorOverdriveScale(Vector3 scale) + { + if (actorFlashText != null) + actorFlashText.transform.localScale = scale; + if (actorFlashChromaRed != null) + actorFlashChromaRed.transform.localScale = scale; + if (actorFlashChromaCyan != null) + actorFlashChromaCyan.transform.localScale = scale; + } + + private void StopActorOverdriveImmediate() + { + if (actorOverdriveRoutine == null) + return; + StopCoroutine(actorOverdriveRoutine); + actorOverdriveRoutine = null; + } + + public static string[] ParseActorPhrases(string phraseList) + { + if (string.IsNullOrWhiteSpace(phraseList)) + return Array.Empty(); + + string[] raw = phraseList.Split('|'); + var result = new List(raw.Length); + foreach (string item in raw) + { + string value = item.Trim(); + if (!string.IsNullOrEmpty(value)) + result.Add(value); + } + return result.ToArray(); + } + + private void EnsureActorScrollTextPool(int count) + { + while (actorScrollTexts.Count < count) + { + int index = actorScrollTexts.Count; + TextMeshPro text = CreateScreenText( + $"ActorScrollText_{index:00}", + 3.2f, + screenTextSortingOrder - index % 2); + TMPRectClipper clipper = text.gameObject.AddComponent(); + clipper.Initialize(text, expressionScreenMaskRect); + actorScrollTexts.Add(text); + actorScrollClippers.Add(clipper); + } + } + + private void HideActorScrollImmediate() + { + foreach (TextMeshPro text in actorScrollTexts) + HideTextImmediate(text); + } + + private IEnumerator TruthImpactRoutine() + { + float original = brightness; + float peak = original + 0.18f; + yield return DOTween.To(() => brightness, value => brightness = value, peak, 0.08f) + .SetEase(Ease.OutQuad) + .SetTarget(this) + .WaitForCompletion(); + yield return DOTween.To(() => brightness, value => brightness = value, original, 0.18f) + .SetEase(Ease.InQuad) + .SetTarget(this) + .WaitForCompletion(); + } + + private IEnumerator PulseGlitch(float peak, float riseDuration, float fallDuration) + { + if (glitchController == null) + yield break; + + glitchController.SetCompositeMode(SpriteNoiseGlitchController.CompositeMode.TransparentOverlay); + yield return glitchController.TransitionToAndWait(Mathf.Clamp01(peak), riseDuration); + yield return glitchController.TransitionToAndWait(0f, fallDuration); + } + + private IEnumerator ShuffleLieCharactersRoutine( + string characterPool, + float duration, + float interval) + { + if (lieText == null || string.IsNullOrEmpty(currentLieKeyword)) + yield break; + + string original = currentLieKeyword; + string pool = string.IsNullOrWhiteSpace(characterPool) + ? original + : characterPool.Replace(" ", string.Empty); + if (string.IsNullOrEmpty(pool)) + pool = original; + + float elapsed = 0f; + while (elapsed < duration) + { + float step = Mathf.Min(interval, duration - elapsed); + elapsed += step; + char[] characters = original.ToCharArray(); + int replacementCount = Mathf.Clamp( + Mathf.CeilToInt(characters.Length * 0.66f), + 1, + characters.Length); + int startIndex = UnityEngine.Random.Range(0, characters.Length); + for (int i = 0; i < replacementCount; i++) + { + int index = (startIndex + i) % characters.Length; + characters[index] = pool[UnityEngine.Random.Range(0, pool.Length)]; + } + lieText.text = new string(characters); + yield return new WaitForSeconds(step); + } + lieText.text = original; + SetTextAlpha(lieText, 1f); + lieShuffleRoutine = null; + } + + private void StopLieShuffleImmediate(bool restoreKeyword) + { + if (lieShuffleRoutine != null) + { + StopCoroutine(lieShuffleRoutine); + lieShuffleRoutine = null; + } + if (restoreKeyword && lieText != null && !string.IsNullOrEmpty(currentLieKeyword)) + lieText.text = currentLieKeyword; + } + + private void DipFace(float duration, float ratio) + { + if (faceRenderer == null) + return; + + faceRenderer.transform.DOKill(); + float height = faceRenderer.bounds.size.y; + faceRenderer.transform.DOMoveY(faceBasePosition.y - height * ratio, Mathf.Max(0.06f, duration * 0.35f)) + .SetLoops(2, LoopType.Yoyo) + .SetEase(Ease.InOutSine) + .SetTarget(this) + .OnComplete(() => faceRenderer.transform.position = faceBasePosition); + } + + private void SetCalmField(Bounds truthBounds) + { + if (activeMemoryRenderer != null) + { + Bounds memoryBounds = activeMemoryRenderer.bounds; + if (memoryBounds.size.x > 0.001f && memoryBounds.size.y > 0.001f) + { + calmCenter = new Vector2( + Mathf.InverseLerp(memoryBounds.min.x, memoryBounds.max.x, truthBounds.center.x), + Mathf.InverseLerp(memoryBounds.min.y, memoryBounds.max.y, truthBounds.center.y)); + } + } + calmRadius = 0f; + calmFeather = 0.20f; + glitchController?.SetCalmField(truthBounds); + } + + private void ConfigureMemoryLook(MemoryKind kind, Preset preset) + { + saturation = kind switch + { + MemoryKind.Office => 0.82f, + MemoryKind.Sunset => 0.96f, + _ => 0.72f + }; + brightness = kind switch + { + MemoryKind.Office => 0.88f, + MemoryKind.Sunset => 0.94f, + _ => 0.76f + }; + contrast = kind == MemoryKind.PrivateOffice ? 1.20f : 1.08f; + vignette = kind switch + { + MemoryKind.Office => 0.24f, + MemoryKind.Sunset => 0.18f, + _ => 0.46f + }; + noise = 0.04f + preset.BaseDistortion * 0.28f; + horizontalTear = 0f; + memoryTint = kind switch + { + MemoryKind.Office => new Color(0.83f, 0.92f, 1f, 1f), + MemoryKind.Sunset => new Color(1f, 0.90f, 0.78f, 1f), + _ => new Color(0.78f, 0.86f, 1f, 1f) + }; + calmCenter = new Vector2(0.5f, 0.5f); + calmRadius = 0f; + calmFeather = 0.18f; + effectStrength = preset.BaseDistortion; + ApplyMemoryBlocks(); + } + + private void EnsureRuntimeObjects() + { + if (runtimeObjectsReady) + return; + + if (particleManager == null) + particleManager = GetComponentInChildren(true); + + GameObject rootObject = new GameObject("LogReleasePresentation"); + runtimeRoot = rootObject.transform; + runtimeRoot.SetParent(transform, false); + + runtimeWhiteTexture = new Texture2D(1, 1, TextureFormat.RGBA32, false) + { + name = "Huoshan Presentation White", + filterMode = FilterMode.Bilinear, + wrapMode = TextureWrapMode.Clamp, + hideFlags = HideFlags.DontSave + }; + runtimeWhiteTexture.SetPixel(0, 0, Color.white); + runtimeWhiteTexture.Apply(); + runtimeWhiteSprite = Sprite.Create( + runtimeWhiteTexture, + new Rect(0f, 0f, 1f, 1f), + new Vector2(0.5f, 0.5f), + 1f); + runtimeWhiteSprite.name = "Huoshan Presentation White"; + runtimeWhiteSprite.hideFlags = HideFlags.DontSave; + + memoryRendererA = CreateMemoryRenderer("MemoryRendererA"); + memoryRendererB = CreateMemoryRenderer("MemoryRendererB"); + memoryBlockA = new MaterialPropertyBlock(); + memoryBlockB = new MaterialPropertyBlock(); + + CreateScreenMask(); + lieText = CreateScreenText("LieKeyword", 4.1f, screenTextSortingOrder); + lieGhostText = CreateScreenText("LieKeywordGhost", 4.1f, screenTextSortingOrder - 1); + systemText = CreateScreenText("SystemPrompt", 1.35f, screenTextSortingOrder + 1); + actorFlashText = CreateScreenText("ActorFlashKeyword", 10.5f, screenTextSortingOrder); + actorFlashChromaRed = CreateScreenText("ActorFlashChromaRed", 10.5f, screenTextSortingOrder - 1); + actorFlashChromaCyan = CreateScreenText("ActorFlashChromaCyan", 10.5f, screenTextSortingOrder - 1); + lieClipper = lieText.gameObject.AddComponent(); + lieGhostClipper = lieGhostText.gameObject.AddComponent(); + systemClipper = systemText.gameObject.AddComponent(); + actorFlashClipper = actorFlashText.gameObject.AddComponent(); + actorFlashChromaRedClipper = actorFlashChromaRed.gameObject.AddComponent(); + actorFlashChromaCyanClipper = actorFlashChromaCyan.gameObject.AddComponent(); + lieClipper.Initialize(lieText, expressionScreenMaskRect); + lieGhostClipper.Initialize(lieGhostText, expressionScreenMaskRect); + systemClipper.Initialize(systemText, expressionScreenMaskRect); + actorFlashClipper.Initialize(actorFlashText, expressionScreenMaskRect); + actorFlashChromaRedClipper.Initialize(actorFlashChromaRed, expressionScreenMaskRect); + actorFlashChromaCyanClipper.Initialize(actorFlashChromaCyan, expressionScreenMaskRect); + PositionScreenText(); + + GameObject scanlineObject = new GameObject("ScreenScanline"); + scanlineObject.transform.SetParent(runtimeRoot, false); + scanlineRenderer = scanlineObject.AddComponent(); + scanlineRenderer.sprite = runtimeWhiteSprite; + scanlineRenderer.color = new Color(0.55f, 0.95f, 1f, 0f); + scanlineRenderer.sortingLayerID = GetScreenSortingLayerId(); + scanlineRenderer.sortingOrder = screenTextSortingOrder - 2; + scanlineRenderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + scanlineRenderer.enabled = false; + + GameObject dimObject = new GameObject("ScreenBrightnessDip"); + dimObject.transform.SetParent(runtimeRoot, false); + screenDimRenderer = dimObject.AddComponent(); + screenDimRenderer.sprite = runtimeWhiteSprite; + screenDimRenderer.color = new Color(0.02f, 0.05f, 0.08f, 0f); + screenDimRenderer.sortingLayerID = GetScreenSortingLayerId(); + screenDimRenderer.sortingOrder = screenGlowSortingOrder + 1; + screenDimRenderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + Bounds screen = GetScreenBounds(); + screenDimRenderer.transform.position = screen.center; + screenDimRenderer.transform.localScale = new Vector3(screen.size.x, screen.size.y, 1f); + screenDimRenderer.enabled = false; + + runtimeObjectsReady = true; + } + + private SpriteRenderer CreateMemoryRenderer(string objectName) + { + GameObject obj = new GameObject(objectName); + obj.transform.SetParent(runtimeRoot, false); + SpriteRenderer renderer = obj.AddComponent(); + renderer.sharedMaterial = memoryMaterial; + renderer.sortingOrder = 0; + renderer.maskInteraction = SpriteMaskInteraction.None; + renderer.enabled = false; + obj.SetActive(false); + return renderer; + } + + private TextMeshPro CreateScreenText(string objectName, float fontSize, int sortingOrder) + { + GameObject obj = new GameObject(objectName); + obj.transform.SetParent(runtimeRoot, false); + RectTransform rect = obj.AddComponent(); + TextMeshPro text = obj.AddComponent(); + text.font = screenFont != null ? screenFont : TMP_Settings.defaultFontAsset; + text.fontSize = fontSize; + text.enableAutoSizing = false; + text.alignment = TextAlignmentOptions.Center; + text.overflowMode = TextOverflowModes.Overflow; + text.sortingLayerID = GetScreenSortingLayerId(); + text.sortingOrder = sortingOrder; + text.text = string.Empty; + obj.SetActive(false); + return text; + } + + private void CreateScreenMask() + { + GameObject maskObject = new GameObject("ExpressionScreenSpriteMask"); + maskObject.transform.SetParent(runtimeRoot, false); + screenSpriteMask = maskObject.AddComponent(); + screenSpriteMask.sprite = runtimeWhiteSprite; + screenSpriteMask.alphaCutoff = 0.01f; + screenSpriteMask.isCustomRangeActive = true; + int sortingLayer = GetScreenSortingLayerId(); + screenSpriteMask.frontSortingLayerID = sortingLayer; + screenSpriteMask.frontSortingOrder = screenTextSortingOrder + 2; + screenSpriteMask.backSortingLayerID = sortingLayer; + screenSpriteMask.backSortingOrder = screenGlowSortingOrder - 1; + AlignMaskToScreenRect(maskObject.transform); + } + + private void PositionScreenText() + { + Bounds screen = GetScreenBounds(); + Vector3 center = screen.center; + if (faceRenderer != null) + center.z = faceRenderer.transform.position.z; + + PositionText(lieText, center, screen.size); + PositionText(lieGhostText, center, screen.size); + PositionText(actorFlashText, center, screen.size); + PositionText(actorFlashChromaRed, center, screen.size); + PositionText(actorFlashChromaCyan, center, screen.size); + Vector3 systemPosition = center + Vector3.up * screen.extents.y * 0.70f; + PositionText(systemText, systemPosition, new Vector2(screen.size.x, screen.size.y * 0.22f)); + } + + private void EnsureScreenTextClipMaterials() + { + lieClipper?.Initialize(lieText, expressionScreenMaskRect); + lieGhostClipper?.Initialize(lieGhostText, expressionScreenMaskRect); + systemClipper?.Initialize(systemText, expressionScreenMaskRect); + actorFlashClipper?.Initialize(actorFlashText, expressionScreenMaskRect); + actorFlashChromaRedClipper?.Initialize(actorFlashChromaRed, expressionScreenMaskRect); + actorFlashChromaCyanClipper?.Initialize(actorFlashChromaCyan, expressionScreenMaskRect); + foreach (TMPRectClipper clipper in actorScrollClippers) + clipper?.Initialize(clipper.GetComponent(), expressionScreenMaskRect); + } + + private void ReleaseScreenTextClipMaterials() + { + lieClipper?.ReleaseMaterial(); + lieGhostClipper?.ReleaseMaterial(); + systemClipper?.ReleaseMaterial(); + actorFlashClipper?.ReleaseMaterial(); + actorFlashChromaRedClipper?.ReleaseMaterial(); + actorFlashChromaCyanClipper?.ReleaseMaterial(); + foreach (TMPRectClipper clipper in actorScrollClippers) + clipper?.ReleaseMaterial(); + } + + private static void PositionText(TextMeshPro text, Vector3 position, Vector2 size) + { + if (text == null) + return; + text.transform.position = position; + RectTransform rect = text.rectTransform; + rect.sizeDelta = size; + rect.localScale = Vector3.one; + } + + private void AlignMaskToScreenRect(Transform maskTransform) + { + Bounds screen = GetScreenBounds(); + maskTransform.position = screen.center; + maskTransform.rotation = expressionScreenMaskRect != null + ? expressionScreenMaskRect.rotation + : Quaternion.identity; + maskTransform.localScale = new Vector3(screen.size.x, screen.size.y, 1f); + } + + /// + /// 记忆图作为全屏背景:按主相机视野 cover 缩放(铺满、超出部分裁掉), + /// 渲染顺序在脸后面(sortingOrder 0 vs 脸的 2),透过脸的透明区域露出。 + /// + private void FitMemoryToView(SpriteRenderer renderer) + { + if (renderer == null || renderer.sprite == null) + return; + + Vector2 viewportSize; + Vector3 viewportCenter; + Quaternion viewportRotation = Quaternion.identity; + Camera viewCamera = Camera.main; + if (viewCamera != null) + { + float planeZ = renderer.transform.position.z; + float height = viewCamera.orthographic + ? viewCamera.orthographicSize * 2f + : 2f * Mathf.Abs(planeZ - viewCamera.transform.position.z) * + Mathf.Tan(viewCamera.fieldOfView * 0.5f * Mathf.Deg2Rad); + viewportSize = new Vector2(height * viewCamera.aspect, height); + viewportCenter = new Vector3( + viewCamera.transform.position.x, + viewCamera.transform.position.y, + planeZ); + } + else if (faceRenderer != null && faceRenderer.sprite != null) + { + Vector3 faceScale = faceRenderer.transform.lossyScale; + Vector3 faceSpriteSize = faceRenderer.sprite.bounds.size; + viewportSize = new Vector2( + Mathf.Abs(faceSpriteSize.x * faceScale.x), + Mathf.Abs(faceSpriteSize.y * faceScale.y)); + viewportCenter = faceRenderer.transform.position; + viewportRotation = faceRenderer.transform.rotation; + } + else + { + Bounds screen = GetScreenBounds(); + float height = screen.size.y * 2.4f; + viewportSize = new Vector2(height * 16f / 9f, height); + viewportCenter = screen.center; + } + + Vector2 spriteSize = renderer.sprite.bounds.size; + float uniformScale = Mathf.Max( + viewportSize.x / Mathf.Max(0.001f, spriteSize.x), + viewportSize.y / Mathf.Max(0.001f, spriteSize.y)); + renderer.transform.SetPositionAndRotation(viewportCenter, viewportRotation); + renderer.transform.localScale = Vector3.one * uniformScale; + } + + private void ApplyMemoryBlocks() + { + ApplyMemoryBlock(memoryRendererA, memoryBlockA, memoryOpacityA); + ApplyMemoryBlock(memoryRendererB, memoryBlockB, memoryOpacityB); + } + + private void ApplyMemoryBlock(SpriteRenderer renderer, MaterialPropertyBlock block, float opacity) + { + if (renderer == null || block == null) + return; + + renderer.GetPropertyBlock(block); + block.SetFloat(SaturationId, saturation); + block.SetFloat(BrightnessId, brightness); + block.SetFloat(ContrastId, contrast); + block.SetFloat(VignetteId, vignette); + block.SetFloat(NoiseId, noise); + block.SetFloat(HorizontalTearId, horizontalTear); + block.SetFloat(MemoryImpactId, memoryImpact); + block.SetFloat(MemoryDamageId, memoryDamage); + block.SetFloat(MemoryOverdriveId, memoryOverdrive); + block.SetFloat(MemoryTearSeedId, memoryTearSeed); + block.SetColor(ColorTintId, memoryTint); + block.SetVector(CalmCenterId, new Vector4(calmCenter.x, calmCenter.y, 0f, 0f)); + block.SetFloat(CalmRadiusId, calmRadius); + block.SetFloat(CalmFeatherId, calmFeather); + block.SetFloat(EffectStrengthId, effectStrength); + block.SetFloat(OpacityId, opacity); + renderer.SetPropertyBlock(block); + } + + private void TweenMemoryOpacity(SpriteRenderer renderer, float target, float duration) + { + if (renderer == null) + return; + DOTween.To( + () => GetMemoryOpacity(renderer), + value => SetMemoryOpacity(renderer, value), + Mathf.Clamp01(target), + Mathf.Max(0.01f, duration)) + .SetEase(Ease.OutQuad) + .SetTarget(this); + } + + private float GetMemoryOpacity(SpriteRenderer renderer) + { + return renderer == memoryRendererA ? memoryOpacityA : memoryOpacityB; + } + + private void SetMemoryOpacity(SpriteRenderer renderer, float opacity) + { + if (renderer == memoryRendererA) + memoryOpacityA = opacity; + else if (renderer == memoryRendererB) + memoryOpacityB = opacity; + ApplyMemoryBlocks(); + } + + private void DisableMemoryRenderer(SpriteRenderer renderer) + { + if (renderer == null) + return; + renderer.enabled = false; + renderer.gameObject.SetActive(false); + renderer.sprite = null; + } + + private void PlayScanline(float duration, bool stalled) + { + if (scanlineRenderer == null) + return; + + Bounds screen = GetScreenBounds(); + scanlineRenderer.enabled = true; + scanlineRenderer.gameObject.SetActive(true); + scanlineRenderer.transform.localScale = new Vector3(screen.size.x, 0.035f, 1f); + float y = stalled ? screen.center.y : screen.max.y; + scanlineRenderer.transform.position = new Vector3(screen.center.x, y, screen.center.z); + scanlineRenderer.color = new Color(0.55f, 0.95f, 1f, stalled ? 0.72f : 0.62f); + scanlineRenderer.transform.DOKill(); + if (!stalled) + { + scanlineRenderer.transform.DOMoveY(screen.min.y, duration) + .SetEase(Ease.Linear) + .SetTarget(this) + .OnComplete(StopScanline); + } + else + { + scanlineRenderer.DOFade(0.15f, 0.055f) + .SetLoops(4, LoopType.Yoyo) + .SetTarget(this); + } + } + + private void StopScanline() + { + if (scanlineRenderer == null) + return; + scanlineRenderer.transform.DOKill(); + scanlineRenderer.DOKill(); + scanlineRenderer.enabled = false; + scanlineRenderer.gameObject.SetActive(false); + } + + private void PulseScreenDim(float alpha, float riseDuration, float fallDuration) + { + if (screenDimRenderer == null) + return; + + screenDimRenderer.DOKill(); + screenDimRenderer.enabled = true; + screenDimRenderer.gameObject.SetActive(true); + screenDimRenderer.color = new Color(0.02f, 0.05f, 0.08f, 0f); + Sequence sequence = DOTween.Sequence().SetTarget(this); + sequence.Append(screenDimRenderer.DOFade(Mathf.Clamp01(alpha), Mathf.Max(0.01f, riseDuration))); + sequence.Append(screenDimRenderer.DOFade(0f, Mathf.Max(0.01f, fallDuration))); + sequence.OnComplete(StopScreenDim); + } + + private void PulseTruthBreakFlash( + float alpha, + float riseDuration, + float holdDuration, + float fallDuration) + { + if (screenDimRenderer == null) + return; + + screenDimRenderer.DOKill(); + screenDimRenderer.enabled = true; + screenDimRenderer.gameObject.SetActive(true); + screenDimRenderer.color = new Color( + truthWarmColor.r, + truthWarmColor.g, + truthWarmColor.b, + 0f); + Sequence sequence = DOTween.Sequence().SetTarget(this); + sequence.Append(screenDimRenderer.DOFade(Mathf.Clamp01(alpha), Mathf.Max(0.01f, riseDuration))); + sequence.AppendInterval(Mathf.Max(0f, holdDuration)); + sequence.Append(screenDimRenderer.DOFade(0f, Mathf.Max(0.01f, fallDuration))); + sequence.OnComplete(StopScreenDim); + } + + private void HoldTruthBreakBlackout(float alpha) + { + if (screenDimRenderer == null) + return; + + screenDimRenderer.DOKill(); + screenDimRenderer.enabled = true; + screenDimRenderer.gameObject.SetActive(true); + screenDimRenderer.color = new Color(0.01f, 0.025f, 0.04f, Mathf.Clamp01(alpha)); + } + + private void PulseMemoryRelease(float amount, float riseDuration, float fallDuration) + { + float originalBrightness = brightness; + Sequence sequence = DOTween.Sequence().SetTarget(this); + sequence.Append(DOTween.To( + () => brightness, + value => brightness = value, + originalBrightness + amount, + Mathf.Max(0.01f, riseDuration))); + sequence.Append(DOTween.To( + () => brightness, + value => brightness = value, + originalBrightness, + Mathf.Max(0.01f, fallDuration))); + } + + private void StopScreenDim() + { + if (screenDimRenderer == null) + return; + screenDimRenderer.DOKill(); + screenDimRenderer.enabled = false; + screenDimRenderer.gameObject.SetActive(false); + } + + private void SetSystemMessage(string value, Color color, float fadeDuration) + { + SetTextActive(systemText, value, color); + SetTextAlpha(systemText, 0f); + FadeText(systemText, 1f, fadeDuration); + } + + private void HideSystemMessageImmediate() + { + if (systemText == null) + return; + systemText.DOKill(); + systemText.text = string.Empty; + systemText.gameObject.SetActive(false); + } + + private void HideLieLayersImmediate() + { + StopLieShuffleImmediate(false); + HideTextImmediate(lieText); + HideTextImmediate(lieGhostText); + HideActorFlashImmediate(); + } + + private void HideActorFlashImmediate() + { + StopActorOverdriveImmediate(); + actorFlashActive = false; + actorFlashCount = 0; + memoryImpact = 0f; + memoryDamage = 0f; + memoryOverdrive = 0f; + HideActorFlashVisualsImmediate(); + HideActorScrollImmediate(); + } + + private void HideActorFlashVisualsImmediate() + { + if (actorFlashText != null) + actorFlashText.fontSize = 10.5f; + if (actorFlashChromaRed != null) + actorFlashChromaRed.fontSize = 10.5f; + if (actorFlashChromaCyan != null) + actorFlashChromaCyan.fontSize = 10.5f; + HideTextImmediate(actorFlashText); + HideTextImmediate(actorFlashChromaRed); + HideTextImmediate(actorFlashChromaCyan); + } + + private static void HideTextImmediate(TextMeshPro text) + { + if (text == null) + return; + text.DOKill(); + text.text = string.Empty; + text.gameObject.SetActive(false); + } + + private static void SetTextActive(TextMeshPro text, string value, Color color) + { + if (text == null) + return; + text.gameObject.SetActive(true); + text.enabled = true; + text.text = value ?? string.Empty; + text.color = color; + } + + private static void SetTextAlpha(TextMeshPro text, float alpha) + { + if (text == null) + return; + Color color = text.color; + color.a = Mathf.Clamp01(alpha); + text.color = color; + } + + private void FadeText(TextMeshPro text, float alpha, float duration) + { + if (text == null || !text.gameObject.activeSelf) + return; + text.DOFade(alpha, duration).SetEase(Ease.OutQuad).SetTarget(this); + } + + private Bounds GetScreenBounds() + { + if (expressionScreenMaskRect == null) + return particleManager != null + ? particleManager.GetScreenWorldBounds() + : new Bounds(transform.position, new Vector3(5.77f, 4.2f, 0f)); + + Vector3[] corners = new Vector3[4]; + expressionScreenMaskRect.GetWorldCorners(corners); + Bounds result = new Bounds(corners[0], Vector3.zero); + for (int i = 1; i < corners.Length; i++) + result.Encapsulate(corners[i]); + return result; + } + + private int GetScreenSortingLayerId() + { + Canvas canvas = expressionScreenMaskRect != null + ? expressionScreenMaskRect.GetComponentInParent() + : null; + return canvas != null ? canvas.sortingLayerID : 0; + } + + private bool TryResolveMemory(string key, out Sprite sprite, out MemoryKind kind) + { + switch ((key ?? string.Empty).Trim().ToLowerInvariant()) + { + case "office": + sprite = officeMemory; + kind = MemoryKind.Office; + return sprite != null; + case "sunset": + sprite = sunsetMemory; + kind = MemoryKind.Sunset; + return sprite != null; + case "private_office": + sprite = privateOfficeMemory; + kind = MemoryKind.PrivateOffice; + return sprite != null; + default: + sprite = null; + kind = MemoryKind.Office; + return false; + } + } + + private static bool TryResolvePreset(string value, out Preset preset) + { + switch ((value ?? string.Empty).Trim().ToLowerInvariant()) + { + case "light": + preset = LightPreset; + return true; + case "medium": + preset = MediumPreset; + return true; + case "heavy": + preset = HeavyPreset; + return true; + default: + preset = LightPreset; + return false; + } + } + + private static string GetLeakFragment(MemoryKind kind) + { + return kind switch + { + MemoryKind.Office => "笑话", + MemoryKind.Sunset => "离开", + _ => "不要那样看我" + }; + } + + private bool ExpectState(string command, params PresentationState[] allowed) + { + for (int i = 0; i < allowed.Length; i++) + { + if (state == allowed[i]) + return true; + } + + Debug.LogWarning( + $"[LogReleasePresentation] {command} 在状态 {state} 被调用;已安全跳过,不阻塞 Yarn。"); + return false; + } + + private void OnDestroy() + { + DOTween.Kill(this); + if (runtimeWhiteSprite != null) + Destroy(runtimeWhiteSprite); + if (runtimeWhiteTexture != null) + Destroy(runtimeWhiteTexture); + } + } +} diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/LogReleasePresentationController.cs.meta b/Assets/Scripts/MiniGame/HuoShan/Language/LogReleasePresentationController.cs.meta new file mode 100644 index 000000000..55f7ae3e6 --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/LogReleasePresentationController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2d202e7d79d64fb5a9d7d42e974cbacd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/TMPRectClipper.cs b/Assets/Scripts/MiniGame/HuoShan/Language/TMPRectClipper.cs new file mode 100644 index 000000000..a33296ba7 --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/TMPRectClipper.cs @@ -0,0 +1,116 @@ +using TMPro; +using UnityEngine; + +namespace AibisDream.MiniGame.Language +{ + /// + /// Gives one TMP object its own hard rectangular clip material. The screen rect is + /// converted from world corners into the TMP object's local coordinate system every + /// frame, so clipping remains correct while the text moves, scales or rotates. + /// + [DisallowMultipleComponent] + public sealed class TMPRectClipper : MonoBehaviour + { + private static readonly int ClipRectId = Shader.PropertyToID("_ClipRect"); + + private TMP_Text target; + private RectTransform screenRect; + private Material sourceMaterial; + private Material runtimeMaterial; + + public void Initialize(TMP_Text text, RectTransform rect) + { + target = text; + screenRect = rect; + RebuildMaterial(); + UpdateClipRect(); + } + + public void SetScreenRect(RectTransform rect) + { + screenRect = rect; + UpdateClipRect(); + } + + private void LateUpdate() + { + UpdateClipRect(); + } + + public void UpdateClipRect() + { + if (target == null || screenRect == null) + return; + + if (runtimeMaterial == null) + RebuildMaterial(); + if (runtimeMaterial == null) + return; + + runtimeMaterial.SetVector(ClipRectId, CalculateLocalClipRect(target.transform, screenRect)); + } + + public static Vector4 CalculateLocalClipRect(Transform textTransform, RectTransform rect) + { + if (textTransform == null || rect == null) + return new Vector4(-32767f, -32767f, 32767f, 32767f); + + Vector3[] corners = new Vector3[4]; + rect.GetWorldCorners(corners); + + float minX = float.PositiveInfinity; + float minY = float.PositiveInfinity; + float maxX = float.NegativeInfinity; + float maxY = float.NegativeInfinity; + + for (int i = 0; i < corners.Length; i++) + { + Vector3 local = textTransform.InverseTransformPoint(corners[i]); + minX = Mathf.Min(minX, local.x); + minY = Mathf.Min(minY, local.y); + maxX = Mathf.Max(maxX, local.x); + maxY = Mathf.Max(maxY, local.y); + } + + return new Vector4(minX, minY, maxX, maxY); + } + + public void ReleaseMaterial() + { + if (target != null && sourceMaterial != null) + target.fontSharedMaterial = sourceMaterial; + + if (runtimeMaterial != null) + { + if (Application.isPlaying) + Destroy(runtimeMaterial); + else + DestroyImmediate(runtimeMaterial); + } + + runtimeMaterial = null; + sourceMaterial = null; + } + + private void RebuildMaterial() + { + ReleaseMaterial(); + if (target == null || target.fontSharedMaterial == null) + return; + + sourceMaterial = target.fontSharedMaterial; + runtimeMaterial = new Material(sourceMaterial) + { + name = $"{sourceMaterial.name} (Screen Clip)", + hideFlags = HideFlags.DontSave + }; + runtimeMaterial.EnableKeyword("MASK_HARD"); + target.fontSharedMaterial = runtimeMaterial; + } + + private void OnDestroy() + { + ReleaseMaterial(); + } + } +} diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/TMPRectClipper.cs.meta b/Assets/Scripts/MiniGame/HuoShan/Language/TMPRectClipper.cs.meta new file mode 100644 index 000000000..65278f521 --- /dev/null +++ b/Assets/Scripts/MiniGame/HuoShan/Language/TMPRectClipper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a71be4f42384665b776b10a4a3fe735 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/MiniGame/HuoShan/Language/TextParticle.cs b/Assets/Scripts/MiniGame/HuoShan/Language/TextParticle.cs index 6e46f2e33..f947d40d1 100644 --- a/Assets/Scripts/MiniGame/HuoShan/Language/TextParticle.cs +++ b/Assets/Scripts/MiniGame/HuoShan/Language/TextParticle.cs @@ -37,11 +37,14 @@ namespace AibisDream.MiniGame.Language protected float changeTimer; protected float changeInterval = 1f; + protected string overrideCharPool; protected Bounds movementBounds; protected bool useCircularBounds = false; protected Vector3 boundsCenter; protected float boundsRadius; protected TMP_FontAsset customFont; + private TMPRectClipper screenClipper; + private bool ownsGlowSprite; // 字符集 protected static string chineseChars = "的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严龙飞"; @@ -100,6 +103,7 @@ namespace AibisDream.MiniGame.Language glowSprite = glowObj.AddComponent(); glowSprite.sprite = CreateCircleSprite(); glowSprite.color = new Color(1, 1, 1, 0.2f); + ownsGlowSprite = true; } changeTimer = Random.Range(0.5f, 1.5f); @@ -140,6 +144,36 @@ namespace AibisDream.MiniGame.Language useCircularBounds = false; } + /// + /// Registers the shared television-screen rect. Each particle owns a separate TMP + /// material instance, so moving one particle never changes another particle's clip. + /// + public void SetScreenClipRect(RectTransform rect) + { + if (textMesh == null) + return; + + if (screenClipper == null) + screenClipper = textMesh.GetComponent() ?? textMesh.gameObject.AddComponent(); + screenClipper.Initialize(textMesh, rect); + } + + public void ClearScreenClip() + { + if (screenClipper != null) + screenClipper.ReleaseMaterial(); + } + + public void SetGlowMasking(int sortingLayerId, int sortingOrder) + { + if (glowSprite == null) + return; + + glowSprite.sortingLayerID = sortingLayerId; + glowSprite.sortingOrder = sortingOrder; + glowSprite.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + } + /// /// 设置圆形运动边界(与 SetMovementBounds 二选一,圆形模式下会覆盖矩形边界) /// @@ -222,8 +256,30 @@ namespace AibisDream.MiniGame.Language /// protected virtual string GetNextDisplayChar() { + if (TryGetOverridePoolChar(out string overrideChar)) + return overrideChar; return GetRandomChar(); } + + /// + /// 设置换字字符池覆盖(老虎机演出用);传空/null 清除,恢复默认字符池。 + /// + public void SetOverrideCharPool(string pool) + { + overrideCharPool = string.IsNullOrEmpty(pool) ? null : pool; + } + + protected bool TryGetOverridePoolChar(out string character) + { + if (string.IsNullOrEmpty(overrideCharPool)) + { + character = null; + return false; + } + + character = overrideCharPool[Random.Range(0, overrideCharPool.Length)].ToString(); + return true; + } /// /// 设置全局干扰短句配置(笑话等,由 LanguageParticleManager 调用) @@ -411,6 +467,26 @@ namespace AibisDream.MiniGame.Language return Sprite.Create(texture, new Rect(0, 0, resolution, resolution), new Vector2(0.5f, 0.5f)); } + + protected virtual void OnDestroy() + { + ClearScreenClip(); + + if (!ownsGlowSprite || glowSprite == null || glowSprite.sprite == null) + return; + + Texture2D texture = glowSprite.sprite.texture; + if (Application.isPlaying) + { + Destroy(glowSprite.sprite); + if (texture != null) Destroy(texture); + } + else + { + DestroyImmediate(glowSprite.sprite); + if (texture != null) DestroyImmediate(texture); + } + } } }