632 lines
19 KiB
C#
632 lines
19 KiB
C#
using System.Collections;
|
||
using DG.Tweening;
|
||
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
|
||
namespace AibisDream
|
||
{
|
||
/// <summary>
|
||
/// Mobile-friendly fullscreen eye viewport: elliptical sight + vertical ellipse blink.
|
||
/// </summary>
|
||
[DisallowMultipleComponent]
|
||
[ExecuteAlways]
|
||
public class EyeViewportOverlay : MonoBehaviour
|
||
{
|
||
private const string OverlaySortingLayer = "StartUI";
|
||
private const float OverlayPlaneDistance = 10f;
|
||
|
||
private static readonly int EyeWidthId = Shader.PropertyToID("_EyeWidth");
|
||
private static readonly int EyeHeightId = Shader.PropertyToID("_EyeHeight");
|
||
private static readonly int EdgeSoftnessId = Shader.PropertyToID("_EdgeSoftness");
|
||
private static readonly int BlinkOpenId = Shader.PropertyToID("_BlinkOpen");
|
||
|
||
/// <summary>Persistence:本遮罩=0,UI Canvas=1,Dialog Canvas=3。</summary>
|
||
[SerializeField] private int sortingOrder;
|
||
|
||
[Tooltip("Assign in Inspector so the shader is included in builds. Falls back to Shader.Find at runtime.")]
|
||
[SerializeField] private Shader overlayShader;
|
||
|
||
private Canvas overlayCanvas;
|
||
private Image overlayImage;
|
||
private Material instanceMaterial;
|
||
private bool overlayShaderAvailable;
|
||
private bool overlayFallbackLogged;
|
||
private EyeVisualSettings activeSettings;
|
||
private Coroutine autoBlinkRoutine;
|
||
private Tween blinkTween;
|
||
private float blinkOpen = 1f;
|
||
private bool moduleActive;
|
||
private bool editorPreviewActive;
|
||
private bool hierarchyReady;
|
||
private bool lifecycleReady;
|
||
private Coroutine deferPrepareRoutine;
|
||
|
||
private void Start()
|
||
{
|
||
lifecycleReady = true;
|
||
if (!Application.isPlaying)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PrepareHierarchyIfNeeded();
|
||
if (!moduleActive && !editorPreviewActive)
|
||
{
|
||
SetOverlayActive(false);
|
||
}
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
StopEffects();
|
||
if (instanceMaterial != null)
|
||
{
|
||
Destroy(instanceMaterial);
|
||
}
|
||
}
|
||
|
||
public void EnterModule(EyeVisualSettings settings)
|
||
{
|
||
moduleActive = true;
|
||
editorPreviewActive = false;
|
||
ShowOverlay(settings);
|
||
}
|
||
|
||
public void ExitModule()
|
||
{
|
||
moduleActive = false;
|
||
if (editorPreviewActive)
|
||
{
|
||
return;
|
||
}
|
||
|
||
StopEffects();
|
||
SetOverlayActive(false);
|
||
activeSettings = null;
|
||
}
|
||
|
||
public void EnterEditorPreview(EyeVisualSettings settings)
|
||
{
|
||
editorPreviewActive = true;
|
||
ShowOverlay(settings);
|
||
}
|
||
|
||
public void ExitEditorPreview()
|
||
{
|
||
editorPreviewActive = false;
|
||
if (moduleActive)
|
||
{
|
||
ShowOverlay(activeSettings);
|
||
return;
|
||
}
|
||
|
||
StopEffects();
|
||
SetOverlayActive(false);
|
||
activeSettings = null;
|
||
}
|
||
|
||
public void ApplySettings(EyeVisualSettings settings)
|
||
{
|
||
if (settings == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!settings.enableViewportOverlay)
|
||
{
|
||
if (editorPreviewActive)
|
||
{
|
||
ExitEditorPreview();
|
||
}
|
||
else if (moduleActive)
|
||
{
|
||
ExitModule();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
PrepareHierarchyIfNeeded();
|
||
if (instanceMaterial == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
activeSettings = settings;
|
||
PushViewportParams(settings);
|
||
SetBlinkOpen(blinkOpen);
|
||
|
||
if (!overlayCanvas.gameObject.activeInHierarchy)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (settings.autoBlink && Application.isPlaying && (moduleActive || editorPreviewActive))
|
||
{
|
||
StartAutoBlink(settings);
|
||
}
|
||
else
|
||
{
|
||
StopAutoBlink();
|
||
}
|
||
}
|
||
|
||
public Tween PlayBlink(float? totalDuration = null, bool restartAutoBlinkAfter = false)
|
||
{
|
||
if (instanceMaterial == null || activeSettings == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
blinkTween?.Kill();
|
||
|
||
float closeDuration = activeSettings.blinkCloseDuration;
|
||
float openDuration = activeSettings.blinkOpenDuration;
|
||
if (totalDuration.HasValue)
|
||
{
|
||
float total = Mathf.Max(0.05f, totalDuration.Value);
|
||
closeDuration = total * 0.42f;
|
||
openDuration = total * 0.58f;
|
||
}
|
||
|
||
blinkTween = DOTween.Sequence()
|
||
.Append(CreateBlinkTween(blinkOpen, 0f, closeDuration, Ease.InQuad))
|
||
.Append(CreateBlinkTween(0f, 1f, openDuration, Ease.OutQuad))
|
||
.OnComplete(() =>
|
||
{
|
||
SetBlinkOpen(1f);
|
||
if (restartAutoBlinkAfter)
|
||
{
|
||
RestartAutoBlinkCooldown(activeSettings);
|
||
}
|
||
});
|
||
|
||
return blinkTween;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 先眨眼,再在最后一次眨眼的开眼阶段触发效果(聚焦 1 次 / 想象 2 次)。
|
||
/// </summary>
|
||
public IEnumerator PlayPreEffectBlinkThen(
|
||
int blinkCount,
|
||
EyeVisualSettings settings,
|
||
System.Action onEffectStart,
|
||
float effectAtOpenRatio,
|
||
float betweenBlinkGap = 0f)
|
||
{
|
||
if (!ShouldPlayTransitionBlink(settings))
|
||
{
|
||
onEffectStart?.Invoke();
|
||
yield break;
|
||
}
|
||
|
||
StopAutoBlink();
|
||
|
||
bool effectStarted = false;
|
||
blinkCount = Mathf.Max(1, blinkCount);
|
||
effectAtOpenRatio = Mathf.Clamp01(effectAtOpenRatio);
|
||
|
||
for (int i = 0; i < blinkCount; i++)
|
||
{
|
||
bool isLastBlink = i == blinkCount - 1;
|
||
Tween blink = PlayBlink();
|
||
if (blink == null)
|
||
{
|
||
if (isLastBlink && !effectStarted)
|
||
{
|
||
effectStarted = true;
|
||
onEffectStart?.Invoke();
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (isLastBlink)
|
||
{
|
||
float effectDelay = settings.blinkCloseDuration + settings.blinkOpenDuration * effectAtOpenRatio;
|
||
yield return new WaitForSeconds(effectDelay);
|
||
if (!effectStarted)
|
||
{
|
||
effectStarted = true;
|
||
onEffectStart?.Invoke();
|
||
}
|
||
|
||
yield return blink.WaitForCompletion();
|
||
}
|
||
else
|
||
{
|
||
yield return blink.WaitForCompletion();
|
||
if (betweenBlinkGap > 0f)
|
||
{
|
||
yield return new WaitForSeconds(betweenBlinkGap);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!effectStarted)
|
||
{
|
||
onEffectStart?.Invoke();
|
||
}
|
||
|
||
RestartAutoBlinkCooldown(settings);
|
||
}
|
||
|
||
private bool ShouldPlayTransitionBlink(EyeVisualSettings settings)
|
||
{
|
||
return Application.isPlaying
|
||
&& moduleActive
|
||
&& !editorPreviewActive
|
||
&& overlayShaderAvailable
|
||
&& settings != null
|
||
&& settings.enableViewportOverlay;
|
||
}
|
||
|
||
private void RestartAutoBlinkCooldown(EyeVisualSettings settings)
|
||
{
|
||
if (settings == null || !settings.autoBlink || !Application.isPlaying)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!(moduleActive || editorPreviewActive))
|
||
{
|
||
return;
|
||
}
|
||
|
||
StartAutoBlink(settings);
|
||
}
|
||
|
||
private void ShowOverlay(EyeVisualSettings settings)
|
||
{
|
||
if (settings == null || !settings.enableViewportOverlay)
|
||
{
|
||
ExitModule();
|
||
return;
|
||
}
|
||
|
||
PrepareHierarchyIfNeeded();
|
||
if (!overlayShaderAvailable || instanceMaterial == null)
|
||
{
|
||
DisableOverlayFallback();
|
||
return;
|
||
}
|
||
|
||
ApplyCanvasRenderSettings();
|
||
activeSettings = settings;
|
||
ApplySettings(settings);
|
||
if (!editorPreviewActive)
|
||
{
|
||
SetBlinkOpen(1f);
|
||
}
|
||
|
||
SetOverlayActive(true);
|
||
|
||
if (settings.autoBlink && Application.isPlaying)
|
||
{
|
||
StartAutoBlink(settings);
|
||
}
|
||
}
|
||
|
||
private void PushViewportParams(EyeVisualSettings settings)
|
||
{
|
||
instanceMaterial.SetFloat(EyeWidthId, settings.eyeWidth);
|
||
instanceMaterial.SetFloat(EyeHeightId, settings.eyeHeight);
|
||
instanceMaterial.SetFloat(EdgeSoftnessId, settings.edgeSoftness);
|
||
}
|
||
|
||
private Tween CreateBlinkTween(float from, float to, float duration, Ease ease)
|
||
{
|
||
blinkOpen = from;
|
||
SetBlinkOpen(from);
|
||
return DOTween.To(() => blinkOpen, value =>
|
||
{
|
||
blinkOpen = value;
|
||
SetBlinkOpen(value);
|
||
}, to, duration).SetEase(ease);
|
||
}
|
||
|
||
private void StartAutoBlink(EyeVisualSettings settings)
|
||
{
|
||
StopAutoBlink();
|
||
if (!settings.autoBlink)
|
||
{
|
||
return;
|
||
}
|
||
|
||
autoBlinkRoutine = StartCoroutine(AutoBlinkRoutine(settings));
|
||
}
|
||
|
||
private IEnumerator AutoBlinkRoutine(EyeVisualSettings settings)
|
||
{
|
||
while (enabled && overlayCanvas != null && overlayCanvas.gameObject.activeInHierarchy)
|
||
{
|
||
float min = Mathf.Max(1f, settings.autoBlinkInterval.x);
|
||
float max = Mathf.Max(min, settings.autoBlinkInterval.y);
|
||
yield return new WaitForSeconds(Random.Range(min, max));
|
||
|
||
Tween blink = PlayBlink();
|
||
if (blink != null)
|
||
{
|
||
yield return blink.WaitForCompletion();
|
||
}
|
||
}
|
||
}
|
||
|
||
private void StopEffects()
|
||
{
|
||
blinkTween?.Kill();
|
||
blinkTween = null;
|
||
StopAutoBlink();
|
||
}
|
||
|
||
private void StopAutoBlink()
|
||
{
|
||
if (autoBlinkRoutine == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
StopCoroutine(autoBlinkRoutine);
|
||
autoBlinkRoutine = null;
|
||
}
|
||
|
||
public void SetBlinkOpenPreview(float value)
|
||
{
|
||
SetBlinkOpen(value);
|
||
}
|
||
|
||
public float GetBlinkOpenPreview() => blinkOpen;
|
||
|
||
private void SetBlinkOpen(float value)
|
||
{
|
||
blinkOpen = Mathf.Clamp01(value);
|
||
instanceMaterial?.SetFloat(BlinkOpenId, blinkOpen);
|
||
}
|
||
|
||
private void SetOverlayActive(bool active)
|
||
{
|
||
if (overlayCanvas != null)
|
||
{
|
||
overlayCanvas.gameObject.SetActive(active);
|
||
}
|
||
}
|
||
|
||
private void PrepareHierarchyIfNeeded()
|
||
{
|
||
if (Application.isPlaying && !lifecycleReady)
|
||
{
|
||
ScheduleDeferredPrepare();
|
||
return;
|
||
}
|
||
|
||
EnsureOverlayHierarchy();
|
||
hierarchyReady = overlayCanvas != null;
|
||
ApplyCanvasRenderSettings();
|
||
}
|
||
|
||
private void ScheduleDeferredPrepare()
|
||
{
|
||
if (deferPrepareRoutine != null || !isActiveAndEnabled)
|
||
{
|
||
return;
|
||
}
|
||
|
||
deferPrepareRoutine = StartCoroutine(DeferredPrepareHierarchy());
|
||
}
|
||
|
||
private IEnumerator DeferredPrepareHierarchy()
|
||
{
|
||
yield return null;
|
||
deferPrepareRoutine = null;
|
||
EnsureOverlayHierarchy();
|
||
hierarchyReady = overlayCanvas != null;
|
||
|
||
if (!overlayShaderAvailable || instanceMaterial == null)
|
||
{
|
||
if (moduleActive || editorPreviewActive)
|
||
{
|
||
DisableOverlayFallback();
|
||
}
|
||
|
||
yield break;
|
||
}
|
||
|
||
if (activeSettings != null && instanceMaterial != null)
|
||
{
|
||
PushViewportParams(activeSettings);
|
||
SetBlinkOpen(blinkOpen);
|
||
}
|
||
|
||
ApplyCanvasRenderSettings();
|
||
}
|
||
|
||
private void EnsureOverlayHierarchy()
|
||
{
|
||
if (overlayCanvas == null)
|
||
{
|
||
overlayCanvas = FindExistingOverlayCanvas();
|
||
}
|
||
|
||
if (overlayCanvas == null)
|
||
{
|
||
var canvasGo = new GameObject("EyeViewportOverlayCanvas");
|
||
canvasGo.transform.SetParent(transform, false);
|
||
overlayCanvas = canvasGo.AddComponent<Canvas>();
|
||
|
||
canvasGo.AddComponent<CanvasScaler>();
|
||
canvasGo.AddComponent<GraphicRaycaster>();
|
||
}
|
||
|
||
NormalizeCanvasTransform(overlayCanvas.transform);
|
||
overlayCanvas.pixelPerfect = false;
|
||
|
||
var scaler = overlayCanvas.GetComponent<CanvasScaler>();
|
||
if (scaler == null)
|
||
{
|
||
scaler = overlayCanvas.gameObject.AddComponent<CanvasScaler>();
|
||
}
|
||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||
scaler.referenceResolution = new Vector2(1920f, 1080f);
|
||
scaler.matchWidthOrHeight = 0.5f;
|
||
|
||
if (overlayCanvas.GetComponent<GraphicRaycaster>() == null)
|
||
{
|
||
overlayCanvas.gameObject.AddComponent<GraphicRaycaster>();
|
||
}
|
||
|
||
EnsureOverlayImage();
|
||
if (overlayImage != null && instanceMaterial == null && !TrySetupOverlayMaterial())
|
||
{
|
||
SetOverlayActive(false);
|
||
}
|
||
|
||
ApplyCanvasRenderSettings();
|
||
}
|
||
|
||
private Canvas FindExistingOverlayCanvas()
|
||
{
|
||
foreach (var canvas in GetComponentsInChildren<Canvas>(true))
|
||
{
|
||
if (canvas != null && canvas.gameObject.name == "EyeViewportOverlayCanvas")
|
||
{
|
||
return canvas;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private void EnsureOverlayImage()
|
||
{
|
||
if (overlayImage == null && overlayCanvas != null)
|
||
{
|
||
Transform existing = overlayCanvas.transform.Find("Overlay");
|
||
if (existing != null)
|
||
{
|
||
overlayImage = existing.GetComponent<Image>();
|
||
}
|
||
}
|
||
|
||
if (overlayImage == null)
|
||
{
|
||
var imageGo = new GameObject("Overlay", typeof(RectTransform));
|
||
imageGo.transform.SetParent(overlayCanvas.transform, false);
|
||
overlayImage = imageGo.AddComponent<Image>();
|
||
}
|
||
|
||
var rect = overlayImage.rectTransform;
|
||
rect.anchorMin = Vector2.zero;
|
||
rect.anchorMax = Vector2.one;
|
||
rect.offsetMin = Vector2.zero;
|
||
rect.offsetMax = Vector2.zero;
|
||
rect.localScale = Vector3.one;
|
||
overlayImage.raycastTarget = false;
|
||
overlayImage.color = Color.black;
|
||
}
|
||
|
||
private static void NormalizeCanvasTransform(Transform canvasTransform)
|
||
{
|
||
if (canvasTransform == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
canvasTransform.localPosition = Vector3.zero;
|
||
canvasTransform.localRotation = Quaternion.identity;
|
||
canvasTransform.localScale = Vector3.one;
|
||
}
|
||
|
||
private void DisableOverlayFallback()
|
||
{
|
||
StopEffects();
|
||
SetOverlayActive(false);
|
||
|
||
if (overlayFallbackLogged)
|
||
{
|
||
return;
|
||
}
|
||
|
||
overlayFallbackLogged = true;
|
||
Debug.LogWarning(
|
||
"[EyeViewportOverlay] 遮罩 shader 不可用,已跳过眼洞遮罩(视觉模块仍可正常使用,只是没有椭圆暗角/眨眼)。",
|
||
this);
|
||
}
|
||
|
||
private bool TrySetupOverlayMaterial()
|
||
{
|
||
overlayShaderAvailable = false;
|
||
|
||
Shader shader = overlayShader != null
|
||
? overlayShader
|
||
: Shader.Find("UI/EyeViewportOverlay");
|
||
|
||
if (shader == null)
|
||
{
|
||
Debug.LogError("[EyeViewportOverlay] 找不到 UI/EyeViewportOverlay shader。", this);
|
||
return false;
|
||
}
|
||
|
||
if (!shader.isSupported)
|
||
{
|
||
Debug.LogError("[EyeViewportOverlay] UI/EyeViewportOverlay shader 在当前平台不受支持。", this);
|
||
return false;
|
||
}
|
||
|
||
instanceMaterial = new Material(shader)
|
||
{
|
||
name = "EyeViewportOverlay (Instance)",
|
||
hideFlags = HideFlags.DontSave
|
||
};
|
||
overlayImage.material = instanceMaterial;
|
||
overlayShaderAvailable = true;
|
||
return true;
|
||
}
|
||
|
||
private void ApplyCanvasRenderSettings()
|
||
{
|
||
if (overlayCanvas == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
overlayCanvas.renderMode = RenderMode.ScreenSpaceCamera;
|
||
Camera uiCamera = ResolveUICamera();
|
||
overlayCanvas.worldCamera = uiCamera;
|
||
overlayCanvas.planeDistance = OverlayPlaneDistance;
|
||
overlayCanvas.sortingLayerName = OverlaySortingLayer;
|
||
overlayCanvas.sortingOrder = sortingOrder;
|
||
SetOverlayUILayer(overlayCanvas.gameObject);
|
||
}
|
||
|
||
private static void SetOverlayUILayer(GameObject root)
|
||
{
|
||
int uiLayer = LayerMask.NameToLayer("UI");
|
||
if (uiLayer < 0)
|
||
{
|
||
uiLayer = 5;
|
||
}
|
||
|
||
foreach (Transform node in root.GetComponentsInChildren<Transform>(true))
|
||
{
|
||
node.gameObject.layer = uiLayer;
|
||
}
|
||
}
|
||
|
||
private static Camera ResolveUICamera()
|
||
{
|
||
if (UIManager.Instance != null && UIManager.Instance.UICamera != null)
|
||
{
|
||
return UIManager.Instance.UICamera;
|
||
}
|
||
|
||
var uiManager = Object.FindObjectOfType<UIManager>();
|
||
if (uiManager != null && uiManager.UICamera != null)
|
||
{
|
||
return uiManager.UICamera;
|
||
}
|
||
|
||
return Camera.main;
|
||
}
|
||
}
|
||
}
|