using System; using System.Collections; using UnityEngine; using UnityEngine.UI; using UnityEngine.Video; using OctoCore.Transitions; using Unheimlich.Core; namespace Unheimlich.Transitions { /// /// Persistent singleton engine for "eyelid blink" transitions (eyelids + optional video overlay). /// Lives outside the OctoCore package (this project already references LeanTween here), and /// implements IOctoSceneTransition so OctoNavigation can drive it without the package ever /// depending on LeanTween or this concrete type. Registers itself with OctoTransitionProvider /// once ready so the package can find it at runtime. /// public class OctoEyelidBlink : Singleton, IOctoSceneTransition { [Header("Eyelids")] [SerializeField] private Image _topEyelid; [SerializeField] private Image _bottomEyelid; [Header("Video")] [Tooltip("RawImage displaying the shared RenderTexture. Its material is swapped per call.")] [SerializeField] private RawImage _videoDisplay; [SerializeField] private VideoPlayer _videoPlayer; [SerializeField] private bool _skipOnDrop = true; [SerializeField] private bool _logVideoDebug = false; [Header("Positions")] [SerializeField] private float _openOffset = 600f; [SerializeField] private float _closedOffset = 0f; // Set per-call via SetLayerSwapEnabled() rather than serialized here, since this // is a shared singleton: different callers (HallucinationFlash instances, scene // transitions) may want different behavior for the same shared component. private bool _swapLayerOrderOnTransition; /// True while a blink sequence is currently animating. public bool IsBusy { get; private set; } private int _topTweenId = -1; private int _bottomTweenId = -1; private int _delayTweenId = -1; private int _videoTweenId = -1; private int _hideVideoTweenId = -1; private bool _isWarmedUp; private bool _videoShown; private bool _showRequested; private VideoClip _pendingClip; private Material _defaultMaterial; private bool HasVideo => _videoPlayer != null && _videoDisplay != null && _videoPlayer.clip != null; /// /// Logs the individual state of every condition HasVideo depends on, plus the /// warm-up/shown flags. Use this to pinpoint exactly why the video overlay isn't /// appearing (missing reference, clip not assigned, warm-up not finished, etc.). /// private void LogVideoState(string context) { if (!_logVideoDebug) return; Debug.LogError( $"[OctoEyelidBlink] VideoState @ {context}" + $" | _videoPlayer={(_videoPlayer != null ? "OK" : "NULL")}" + $" | _videoDisplay={(_videoDisplay != null ? "OK" : "NULL")}" + $" | clip={(_videoPlayer != null && _videoPlayer.clip != null ? _videoPlayer.clip.name : "NULL")}" + $" | HasVideo={HasVideo}" + $" | _isWarmedUp={_isWarmedUp}" + $" | _videoShown={_videoShown}" + $" | _videoDisplay.enabled={(_videoDisplay != null ? _videoDisplay.enabled.ToString() : "N/A")}" + $" | _videoPlayer.isPrepared={(_videoPlayer != null ? _videoPlayer.isPrepared.ToString() : "N/A")}" + $" | _videoPlayer.isPlaying={(_videoPlayer != null ? _videoPlayer.isPlaying.ToString() : "N/A")}" ); } protected override void OnAwake() { OctoTransitionProvider.Current = this; // Only call DontDestroyOnLoad on a root object. If this component already lives // under the persistent Octo prefab, the parent handles persistence and this is a no-op. if (transform.parent == null) DontDestroyOnLoad(gameObject); if (_videoDisplay != null) _defaultMaterial = _videoDisplay.material; SetEyelidsOpen(); SetVideoVisible(false); ConfigureVideoPlayer(); } // Re-registers with the transition provider whenever Instance is restored // (e.g. after a SubsystemRegistration reset with Domain Reload disabled). protected override void OnEnabled() { OctoTransitionProvider.Current = this; } protected override void OnDestroyInstance() { if (OctoTransitionProvider.Current == (IOctoSceneTransition)this) OctoTransitionProvider.Current = null; } // ---------- IOctoSceneTransition ---------- /// /// Assigns the video clip and optional material to use during the next CloseEyes() call /// and starts warming the clip up. Pass a null clip to perform a plain blink with no video. /// A null material falls back to the RawImage's default material. /// public void SetClip(VideoClip clip, Material material) { if (_videoDisplay != null) _videoDisplay.material = material != null ? material : _defaultMaterial; if (_videoPlayer == null) return; // Avoid restarting an already-completed or in-progress warm-up for the same clip. // This lets a caller pre-warm a clip early (e.g. HallucinationFlash.Prewarm()) and // then safely call SetClip again with the same clip right before CloseEyes without // losing the warm-up progress already made. if (clip == _pendingClip && _videoPlayer.clip == clip) { LogVideoState("SetClip (skipped, same clip already set)"); return; } _isWarmedUp = false; _pendingClip = clip; _videoPlayer.clip = clip; LogVideoState("SetClip"); if (clip != null) StartCoroutine(WarmUpVideo()); } /// /// Enables or disables the eyelid/video layer swap for the next CloseEyes()/OpenEyes() /// sequence: when enabled, the eyelid bars render behind the video overlay while closing /// (video shows on top), then swap to render in front of it while opening (video /// disappears behind the still-closed-looking bars instead of staying visible during /// the reveal). Call this before CloseEyes() to opt a specific sequence in; it resets /// to disabled automatically once the sequence finishes opening, so it never leaks into /// unrelated callers (e.g. scene transitions) that don't set it explicitly. /// public void SetLayerSwapEnabled(bool enabled) { _swapLayerOrderOnTransition = enabled; } /// Closes the eyelids over the given duration and invokes the callback on completion. public void CloseEyes(float duration, Action onComplete) { IsBusy = true; CancelCurrent(); ResetVideoToStart(); _videoShown = false; _showRequested = false; if (_swapLayerOrderOnTransition) SetEyelidsBehindVideo(); RectTransform top = _topEyelid.rectTransform; RectTransform bottom = _bottomEyelid.rectTransform; _topTweenId = LeanTween.moveY(top, _closedOffset, duration) .setEase(LeanTweenType.easeOutQuad) .setOnComplete(() => { _topTweenId = -1; onComplete?.Invoke(); }) .id; _bottomTweenId = LeanTween.moveY(bottom, -_closedOffset, duration) .setEase(LeanTweenType.easeOutQuad) .setOnComplete(() => _bottomTweenId = -1) .id; } /// Opens the eyelids over the given duration and invokes the callback on completion. public void OpenEyes(float duration, Action onComplete) { if (_swapLayerOrderOnTransition) SetEyelidsInFrontOfVideo(); RectTransform top = _topEyelid.rectTransform; RectTransform bottom = _bottomEyelid.rectTransform; _topTweenId = LeanTween.moveY(top, _openOffset, duration) .setEase(LeanTweenType.easeInQuad) .setOnComplete(() => { _topTweenId = -1; onComplete?.Invoke(); }) .id; _bottomTweenId = LeanTween.moveY(bottom, -_openOffset, duration) .setEase(LeanTweenType.easeInQuad) .setOnComplete(() => { _bottomTweenId = -1; IsBusy = false; _swapLayerOrderOnTransition = false; }) .id; } /// /// True if the given clip is the one currently assigned and has finished warming up. /// Lets a caller (e.g. HallucinationFlash) check readiness after calling Prewarm-style /// SetClip ahead of time, without having to guess a fixed delay. /// public bool IsClipReady(VideoClip clip) { return _isWarmedUp && _pendingClip == clip; } /// Stops playback, resets the video to its first frame, and hides the overlay. public void HideVideo() { if (!HasVideo) return; _showRequested = false; _videoPlayer.Pause(); _videoPlayer.time = 0.0; SetVideoVisible(false); _videoShown = false; } /// Reveals and plays the video overlay after the given delay, once warm-up has completed. public void ShowVideoAfterDelay(float delay) { LogVideoState("ShowVideoAfterDelay (before HasVideo check)"); if (!HasVideo) return; _videoTweenId = LeanTween.delayedCall(gameObject, delay, () => { _videoTweenId = -1; ShowVideo(); }).id; } /// /// Hides the video overlay after the given delay. Used to make the video disappear /// partway through the opening animation instead of only once it has fully finished, /// so the person doesn't catch a stray frame of it while the eyelids are already opening. /// public void HideVideoAfterDelay(float delay) { if (_hideVideoTweenId != -1) { LeanTween.cancel(_hideVideoTweenId); _hideVideoTweenId = -1; } _hideVideoTweenId = LeanTween.delayedCall(gameObject, delay, () => { _hideVideoTweenId = -1; HideVideo(); }).id; } // ---------- Extra API (not part of the interface, used by HallucinationFlash directly) ---------- /// Waits for the given duration before invoking the callback. public void Hold(float duration, Action onComplete) { _delayTweenId = LeanTween.delayedCall(gameObject, duration, () => { _delayTweenId = -1; onComplete?.Invoke(); }).id; } // ---------- Video internals ---------- private void ConfigureVideoPlayer() { if (_videoPlayer == null) return; _videoPlayer.playOnAwake = false; _videoPlayer.isLooping = false; _videoPlayer.waitForFirstFrame = true; _videoPlayer.skipOnDrop = _skipOnDrop; } /// /// Prepares and briefly plays the assigned clip while hidden, forcing Unity to /// decode the first frames ahead of time. Prevents a blank/garbage frame from /// being visible the first time the video is actually shown. /// private IEnumerator WarmUpVideo() { if (!HasVideo) yield break; SetVideoVisible(false); ConfigureVideoPlayer(); _videoPlayer.Prepare(); while (!_videoPlayer.isPrepared) yield return null; _videoPlayer.Play(); while (!_videoPlayer.isPlaying) yield return null; // Wait a few frames so the RenderTexture actually receives video data. yield return null; yield return null; yield return null; _videoPlayer.Pause(); _videoPlayer.time = 0.0; // Wait one additional frame after seeking back to the start. yield return null; _isWarmedUp = true; if (_logVideoDebug) Debug.LogError($"[OctoEyelidBlink] Warm-up complete for clip: {_pendingClip?.name}"); // The requested reveal may have fired before warm-up finished and bailed out. // Catch up now instead of leaving the overlay stuck hidden for the whole sequence. if (_showRequested && !_videoShown) { if (_logVideoDebug) Debug.LogError("[OctoEyelidBlink] Warm-up finished after a pending show request, showing video now."); ShowVideo(); } } private void ShowVideo() { LogVideoState("ShowVideo (entry)"); if (!HasVideo || _videoShown) return; _showRequested = true; // Safety check: if warm-up hasn't completed yet (e.g. clip assigned too late, // or the reveal delay is shorter than warm-up takes), skip showing the video for // now rather than risk a corrupted first frame. WarmUpVideo() will catch up and // call ShowVideo() again as soon as it finishes, since _showRequested is still set. if (!_isWarmedUp) { if (_logVideoDebug) Debug.LogError("[OctoEyelidBlink] Video not warmed up in time, skipping display for now (will retry once warm-up completes)."); return; } _showRequested = false; _videoShown = true; SetVideoVisible(true); _videoPlayer.Play(); LogVideoState("ShowVideo (after SetVideoVisible(true))"); } private void ResetVideoToStart() { if (!HasVideo) return; _videoPlayer.Pause(); _videoPlayer.time = 0.0; } private void SetVideoVisible(bool visible) { if (_videoDisplay == null) { if (_logVideoDebug) Debug.LogError("[OctoEyelidBlink] SetVideoVisible called but _videoDisplay is NULL."); return; } _videoDisplay.enabled = visible; if (_logVideoDebug) Debug.LogError($"[OctoEyelidBlink] SetVideoVisible({visible}) applied. _videoDisplay.enabled is now {_videoDisplay.enabled} on '{_videoDisplay.gameObject.name}'."); } // ---------- Layering internals ---------- /// /// Moves both eyelid bars to the first sibling position, so any other UI element /// under the same parent (in particular the video overlay) renders on top of them. /// Used when closing, so the video appears layered above the bars. /// private void SetEyelidsBehindVideo() { _topEyelid.transform.SetAsFirstSibling(); _bottomEyelid.transform.SetAsFirstSibling(); } /// /// Moves both eyelid bars to the last sibling position, so they render in front of /// any other UI element under the same parent (in particular the video overlay). /// Used when opening, so the video disappears behind the still-closed-looking bars /// instead of staying visible while the scene is revealed. /// private void SetEyelidsInFrontOfVideo() { _topEyelid.transform.SetAsLastSibling(); _bottomEyelid.transform.SetAsLastSibling(); } // ---------- Eyelid internals ---------- private void SetEyelidsOpen() { _topEyelid.rectTransform.anchoredPosition = new Vector2(_topEyelid.rectTransform.anchoredPosition.x, _openOffset); _bottomEyelid.rectTransform.anchoredPosition = new Vector2(_bottomEyelid.rectTransform.anchoredPosition.x, -_openOffset); } private void CancelCurrent() { if (_topTweenId != -1) { LeanTween.cancel(_topTweenId); _topTweenId = -1; } if (_bottomTweenId != -1) { LeanTween.cancel(_bottomTweenId); _bottomTweenId = -1; } if (_delayTweenId != -1) { LeanTween.cancel(_delayTweenId); _delayTweenId = -1; } if (_videoTweenId != -1) { LeanTween.cancel(_videoTweenId); _videoTweenId = -1; } if (_hideVideoTweenId != -1) { LeanTween.cancel(_hideVideoTweenId); _hideVideoTweenId = -1; } } } }