add : flash transitions between scenes

This commit is contained in:
2026-07-20 18:46:56 +02:00
parent 50f65e99a7
commit a0cfb31079
14 changed files with 347 additions and 150 deletions
@@ -6,8 +6,10 @@ using Unheimlich.Transitions;
namespace Unheimlich.Features.Hallucination
{
/// <summary>
/// Scene-local trigger for a double-blink hallucination sequence. Delegates all
/// visual work to the persistent OctoEyelidBlink singleton.
/// Scene-local trigger for a hallucination blink. Delegates all visual work to the
/// persistent OctoEyelidBlink singleton. Runs as a single close-hold-open cycle when
/// the layer swap is enabled (the correct video/eyelid z-order makes one blink enough),
/// or as the classic double close-hold-open-hold-close-hold-open sequence otherwise.
/// </summary>
public class HallucinationFlash : MonoBehaviour
{
@@ -18,12 +20,45 @@ namespace Unheimlich.Features.Hallucination
[SerializeField, Min(0f)] private float _closeDuration = 0.1f;
[SerializeField, Min(0f)] private float _holdClosed = 0.05f;
[SerializeField, Min(0f)] private float _openDuration = 0.15f;
[Tooltip("How long the video stays visible after the eyes have fully reopened, before it's hidden.")]
[SerializeField, Min(0f)] private float _visibleHold = 0.5f;
[SerializeField, Range(0f, 1f)] private float _showVideoAtCloseProgress = 0.25f;
/// <summary>Triggers the hallucination sequence. No-op if the singleton is missing or busy.</summary>
[Header("Layering")]
[Tooltip("If enabled, the eyelid bars render behind the video overlay while closing (video shows on top of them), 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).")]
[SerializeField] private bool _swapLayerOrderOnTransition = false;
[Header("Debug")]
[Tooltip("Logs the full call stack every time Play() is triggered, so you can trace who requested this hallucination.")]
[SerializeField] private bool _logCallers = false;
/// <summary>
/// Starts warming up this hallucination's video clip ahead of time, without triggering
/// the blink sequence. Call this in advance (e.g. when the player enters a trigger volume
/// near the hallucination point) so the video is ready the moment Play() is actually called,
/// instead of racing the warm-up against the eyelid close animation.
/// </summary>
public void Prewarm()
{
OctoEyelidBlink blink = OctoEyelidBlink.Instance;
if (blink == null)
return;
blink.SetClip(_clip, _material);
}
/// <summary>
/// Triggers the hallucination blink. If the layer swap is enabled, runs a single
/// close-hold-open cycle with the video revealed partway through closing and hidden
/// after the configured hold once reopened. If disabled, runs the classic double
/// blink instead. No-op if the singleton is missing or already busy.
/// </summary>
public void Play()
{
if (_logCallers)
Debug.LogError($"[HallucinationFlash] Play() called on '{gameObject.name}' at t={Time.time:F2}\n{StackTraceUtility.ExtractStackTrace()}");
OctoEyelidBlink blink = OctoEyelidBlink.Instance;
if (blink == null)
@@ -36,7 +71,45 @@ namespace Unheimlich.Features.Hallucination
return;
blink.SetClip(_clip, _material);
blink.SetLayerSwapEnabled(_swapLayerOrderOnTransition);
if (_swapLayerOrderOnTransition)
PlaySingleBlink(blink);
else
PlayDoubleBlink(blink);
blink.ShowVideoAfterDelay(_closeDuration * _showVideoAtCloseProgress);
}
/// <summary>
/// Single close-hold-open cycle: video is revealed partway through closing and hidden
/// after staying visible for _visibleHold once the eyes are back open. Relies on the
/// eyelid/video layer swap being correctly set beforehand for the reveal to look right.
/// </summary>
private void PlaySingleBlink(OctoEyelidBlink blink)
{
blink.CloseEyes(_closeDuration, () =>
{
blink.Hold(_holdClosed, () =>
{
blink.OpenEyes(_openDuration, () =>
{
blink.Hold(_visibleHold, () =>
{
blink.HideVideo();
});
});
});
});
}
/// <summary>
/// Classic double blink: close, reveal video, open, hold visible, close again,
/// hide video, hold, open. Used when the layer swap is disabled, since without the
/// correct z-order a single blink wouldn't hide the video cleanly on its own.
/// </summary>
private void PlayDoubleBlink(OctoEyelidBlink blink)
{
blink.CloseEyes(_closeDuration, () =>
{
blink.Hold(_holdClosed, () =>
@@ -57,8 +130,6 @@ namespace Unheimlich.Features.Hallucination
});
});
});
blink.ShowVideoAfterDelay(_closeDuration * _showVideoAtCloseProgress);
}
}
}
@@ -32,6 +32,11 @@ namespace Unheimlich.Transitions
[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; }
@@ -42,11 +47,36 @@ namespace Unheimlich.Transitions
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;
@@ -92,14 +122,40 @@ namespace Unheimlich.Transitions
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)
{
@@ -107,6 +163,10 @@ namespace Unheimlich.Transitions
CancelCurrent();
ResetVideoToStart();
_videoShown = false;
_showRequested = false;
if (_swapLayerOrderOnTransition)
SetEyelidsBehindVideo();
RectTransform top = _topEyelid.rectTransform;
RectTransform bottom = _bottomEyelid.rectTransform;
@@ -129,6 +189,9 @@ namespace Unheimlich.Transitions
/// <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;
@@ -147,16 +210,28 @@ namespace Unheimlich.Transitions
{
_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);
@@ -166,6 +241,8 @@ namespace Unheimlich.Transitions
/// <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;
@@ -236,28 +313,46 @@ namespace Unheimlich.Transitions
_isWarmedUp = true;
if (_logVideoDebug)
Debug.Log($"[OctoEyelidBlink] Warm-up complete for clip: {_pendingClip?.name}");
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;
// Safety check: if warm-up hasn't completed yet (e.g. clip assigned too late),
// skip showing the video rather than risk a corrupted first frame. The eyelid
// blink itself remains unaffected.
_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.LogWarning("[OctoEyelidBlink] Video not warmed up in time, skipping display.");
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()
@@ -271,8 +366,43 @@ namespace Unheimlich.Transitions
private void SetVideoVisible(bool visible)
{
if (_videoDisplay != null)
_videoDisplay.enabled = 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 ----------