426 lines
16 KiB
C#
426 lines
16 KiB
C#
using System;
|
|
using System.Collections;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
using UnityEngine.Video;
|
|
using OctoCore.Transitions;
|
|
using Unheimlich.Core;
|
|
|
|
namespace Unheimlich.Transitions
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class OctoEyelidBlink : Singleton<OctoEyelidBlink>, 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;
|
|
|
|
/// <summary>True while a blink sequence is currently animating.</summary>
|
|
public bool IsBusy { get; private set; }
|
|
|
|
private int _topTweenId = -1;
|
|
private int _bottomTweenId = -1;
|
|
private int _delayTweenId = -1;
|
|
private int _videoTweenId = -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;
|
|
|
|
/// <summary>
|
|
/// 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.).
|
|
/// </summary>
|
|
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 ----------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void SetLayerSwapEnabled(bool enabled)
|
|
{
|
|
_swapLayerOrderOnTransition = enabled;
|
|
}
|
|
|
|
/// <summary>Closes the eyelids over the given duration and invokes the callback on completion.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Opens the eyelids over the given duration and invokes the callback on completion.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public bool IsClipReady(VideoClip clip)
|
|
{
|
|
return _isWarmedUp && _pendingClip == clip;
|
|
}
|
|
|
|
/// <summary>Stops playback, resets the video to its first frame, and hides the overlay.</summary>
|
|
public void HideVideo()
|
|
{
|
|
if (!HasVideo)
|
|
return;
|
|
|
|
_showRequested = false;
|
|
_videoPlayer.Pause();
|
|
_videoPlayer.time = 0.0;
|
|
SetVideoVisible(false);
|
|
_videoShown = false;
|
|
}
|
|
|
|
/// <summary>Reveals and plays the video overlay after the given delay, once warm-up has completed.</summary>
|
|
public void ShowVideoAfterDelay(float delay)
|
|
{
|
|
LogVideoState("ShowVideoAfterDelay (before HasVideo check)");
|
|
|
|
if (!HasVideo)
|
|
return;
|
|
|
|
_videoTweenId = LeanTween.delayedCall(gameObject, delay, () =>
|
|
{
|
|
_videoTweenId = -1;
|
|
ShowVideo();
|
|
}).id;
|
|
}
|
|
|
|
// ---------- Extra API (not part of the interface, used by HallucinationFlash directly) ----------
|
|
|
|
/// <summary>Waits for the given duration before invoking the callback.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 ----------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void SetEyelidsBehindVideo()
|
|
{
|
|
_topEyelid.transform.SetAsFirstSibling();
|
|
_bottomEyelid.transform.SetAsFirstSibling();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
}
|
|
} |