using System; using System.Collections.Generic; using System.Threading; using BriarQueen.Framework.Coordinators.Events; using BriarQueen.Framework.Events.UI; using BriarQueen.Framework.Managers.Input; using BriarQueen.Game.Cinematics.Data; using Cysharp.Threading.Tasks; using PrimeTween; using TMPro; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.UI; using VContainer; namespace BriarQueen.Game.Cinematics { public abstract class BaseCinematic : MonoBehaviour { [Header("Cinematic Settings")] [SerializeField] protected List _cinematicPanels = new(); [Header("Crossfade Settings")] [Tooltip("Max crossfade duration. Actual fade is clamped so EACH panel still lasts exactly DisplayTime.")] [SerializeField] [Min(0f)] protected float _maxCrossfadeDuration = 0.75f; [Header("Subtitles")] [SerializeField] protected bool _logSubtitleTimingWarnings = true; [Tooltip("If true, clears subtitle text when a panel ends (recommended for VO sync).")] [SerializeField] protected bool _clearSubtitlesBetweenPanels = true; [Header("UI Elements")] [SerializeField] protected CanvasGroup _cinematicCanvasGroup; [SerializeField] protected TextMeshProUGUI _cinematicText; [SerializeField] protected TextMeshProUGUI _skipText; [Header("Cinematic Images (Two Layers For Crossfade)")] [SerializeField] protected Image _imageA; [SerializeField] protected Image _imageB; [Header("Input")] [SerializeField] protected string _skipActionName = "Cancel"; [Header("Tween Settings")] [SerializeField] protected float _canvasFadeInDuration = 0.25f; [SerializeField] protected float _canvasFadeOutDuration = 0.25f; [SerializeField] protected float _finalImageFadeOutDuration = 0.25f; [Header("Skip Text Tween (Yoyo Alpha)")] [SerializeField] protected bool _animateSkipText = true; [SerializeField] [Min(0.05f)] protected float _skipTextHalfCycleSeconds = 0.75f; [SerializeField] [Range(0f, 1f)] protected float _skipTextMinAlpha = 0.25f; [SerializeField] [Range(0f, 1f)] protected float _skipTextMaxAlpha = 1f; [SerializeField] protected Ease _skipTextEase = Ease.InOutSine; [Header("Test/Debug (No Injection Needed)")] [Tooltip("If true, Escape will skip even when InputManager isn't injected (handy for editor tests).")] [SerializeField] private bool _allowKeyboardEscapeSkipFallback = true; private CancellationTokenSource _cinematicCts; private bool _endingStarted; private bool _isPlaying; private bool _skipRequested; private Sequence _skipTextSequence; protected EventCoordinator EventCoordinator; protected InputManager InputManager; protected virtual void Awake() { if (_cinematicCanvasGroup != null) _cinematicCanvasGroup.alpha = 0f; SetImageAlpha(_imageA, 0f); SetImageAlpha(_imageB, 0f); ClearSubtitleText(); StopSkipTextTween(); } private void Update() { if (!_isPlaying) return; if (!_allowKeyboardEscapeSkipFallback) return; if (InputManager != null) return; var kb = Keyboard.current; if (kb != null && kb.escapeKey.wasPressedThisFrame) RequestSkip(); } protected virtual void OnEnable() { BindSkipAction(); } protected virtual void OnDisable() { UnbindSkipAction(); CancelAndDisposeCts(); StopSkipTextTween(); _isPlaying = false; } protected virtual void OnDestroy() { UnbindSkipAction(); CancelAndDisposeCts(); StopSkipTextTween(); } protected async UniTask PlayCinematic(CancellationToken destroyToken) { if (_cinematicCanvasGroup == null || _imageA == null || _imageB == null) { Debug.LogWarning("[BaseCinematic] Missing CanvasGroup or Images."); return; } _isPlaying = true; if (_cinematicPanels == null || _cinematicPanels.Count == 0) { await EndCinematicFlow(); return; } CancelAndDisposeCts(); _cinematicCts = CancellationTokenSource.CreateLinkedTokenSource(destroyToken); var playbackToken = _cinematicCts.Token; _skipRequested = false; _endingStarted = false; _cinematicCanvasGroup.blocksRaycasts = false; _cinematicCanvasGroup.interactable = false; _cinematicCanvasGroup.alpha = 0f; ClearSubtitleText(); SetImageAlpha(_imageA, 0f); SetImageAlpha(_imageB, 0f); StartSkipTextTweenIfNeeded(); await FadeCanvasGroup(_cinematicCanvasGroup, 0f, 1f, _canvasFadeInDuration, destroyToken); _cinematicCanvasGroup.blocksRaycasts = true; _cinematicCanvasGroup.interactable = true; try { for (var i = 0; i < _cinematicPanels.Count; i++) { if (_skipRequested) break; var panel = GetPanel(i); if (panel == null || panel.CinematicImage == null) continue; var currentTime = Mathf.Max(0f, panel.DisplayTime); var nextTime = i + 1 < _cinematicPanels.Count ? Mathf.Max(0f, GetPanel(i + 1)?.DisplayTime ?? 0f) : 0f; var isFirst = i == 0; var isLast = i == _cinematicPanels.Count - 1; await RunPanelWithSubtitles(i, currentTime, nextTime, isFirst, isLast, playbackToken); if (_clearSubtitlesBetweenPanels && !isLast) ClearSubtitleText(); } } catch (OperationCanceledException) { } var safeExitToken = this.GetCancellationTokenOnDestroy(); await FadeOutAtEnd(safeExitToken); await UniTask.Delay(TimeSpan.FromSeconds(2f), DelayType.UnscaledDeltaTime, cancellationToken: safeExitToken); await EndCinematicFlow(); } protected virtual UniTask OnCinematicEnd() { return UniTask.CompletedTask; } protected void RequestSkip() { Debug.Log("Skip Requested"); if (_skipRequested || _endingStarted) return; _skipRequested = true; if (_cinematicCts != null && !_cinematicCts.IsCancellationRequested) try { _cinematicCts.Cancel(); } catch { } } protected virtual void BindSkipAction() { if (InputManager == null) return; InputManager.BindPauseForSkip(OnSkipPerformed); if (_skipText != null) { var inputType = InputManager.DeviceInputType; switch (inputType) { case DeviceInputType.KeyboardAndMouse: _skipText.text = "Press Escape to Skip."; break; case DeviceInputType.XboxController: _skipText.text = "Press B to Skip."; break; case DeviceInputType.PlaystationController: _skipText.text = "Press Circle to Skip."; break; case DeviceInputType.SwitchProController: _skipText.text = "Press B to Skip."; break; default: _skipText.text = "Press Cancel to Skip."; break; } } } protected virtual void UnbindSkipAction() { if (InputManager == null) return; InputManager.ResetPauseBind(OnSkipPerformed); } private void OnSkipPerformed(InputAction.CallbackContext _) { RequestSkip(); } private void CancelAndDisposeCts() { if (_cinematicCts == null) return; try { _cinematicCts.Cancel(); } catch { } _cinematicCts.Dispose(); _cinematicCts = null; } private async UniTask EndCinematicFlow() { if (_endingStarted) return; _endingStarted = true; _isPlaying = false; StopSkipTextTween(); ClearSubtitleText(); await OnCinematicEnd(); } private void StartSkipTextTweenIfNeeded() { if (!_animateSkipText || _skipText == null) return; StopSkipTextTween(); var c = _skipText.color; c.a = Mathf.Clamp01(_skipTextMaxAlpha); _skipText.color = c; _skipTextSequence = Sequence.Create( useUnscaledTime: true, cycleMode: Sequence.SequenceCycleMode.Yoyo, cycles: -1 ) .Group(Tween.Alpha(_skipText, new TweenSettings { startValue = _skipTextMaxAlpha, endValue = _skipTextMinAlpha, settings = new TweenSettings { duration = _skipTextHalfCycleSeconds, ease = _skipTextEase, useUnscaledTime = true } })); } private void StopSkipTextTween() { if (_skipTextSequence.isAlive) _skipTextSequence.Stop(); _skipTextSequence = default; } private CinematicPanel GetPanel(int index) { if (_cinematicPanels == null) return null; if (index < 0 || index >= _cinematicPanels.Count) return null; return _cinematicPanels[index]; } private async UniTask RunPanelWithSubtitles(int panelIndex, float currentTime, float nextTime, bool isFirst, bool isLast, CancellationToken token) { using var panelCts = CancellationTokenSource.CreateLinkedTokenSource(token); var panelToken = panelCts.Token; await UniTask.WhenAll( ShowPanelExact(panelIndex, currentTime, nextTime, isFirst, isLast, panelToken), RunSubtitlesForPanel(panelIndex, currentTime, panelToken) ); } private async UniTask RunSubtitlesForPanel(int panelIndex, float panelTime, CancellationToken token) { if (_cinematicText == null) return; var panel = GetPanel(panelIndex); if (panel == null) return; var subs = panel.Subtitles; if (subs == null || subs.Count == 0) { ClearSubtitleText(); if (panelTime > 0f) await UniTask.Delay(TimeSpan.FromSeconds(panelTime), DelayType.UnscaledDeltaTime, cancellationToken: token); return; } var sum = 0f; for (var i = 0; i < subs.Count; i++) { if (subs[i].Equals(default)) continue; sum += Mathf.Max(0f, subs[i].DisplayTime); } if (_logSubtitleTimingWarnings && sum > panelTime + 0.001f) Debug.LogWarning( $"[BaseCinematic] Panel '{panel.name}' subtitle times sum to {sum:0.###}s but panel DisplayTime is {panelTime:0.###}s. Subtitles will be cut short."); var remaining = panelTime; for (var i = 0; i < subs.Count; i++) { token.ThrowIfCancellationRequested(); var sub = subs[i]; if (sub.Equals(default)) continue; var subTime = Mathf.Max(0f, sub.DisplayTime); if (subTime <= 0f) continue; var actual = Mathf.Min(subTime, remaining); if (actual <= 0f) break; _cinematicText.text = sub.Subtitle ?? string.Empty; await UniTask.Delay(TimeSpan.FromSeconds(actual), DelayType.UnscaledDeltaTime, cancellationToken: token); remaining -= actual; if (remaining <= 0f) break; } if (remaining > 0f) { _cinematicText.text = string.Empty; await UniTask.Delay(TimeSpan.FromSeconds(remaining), DelayType.UnscaledDeltaTime, cancellationToken: token); } } private void ClearSubtitleText() { if (_cinematicText != null) _cinematicText.text = string.Empty; } private async UniTask ShowPanelExact(int panelIndex, float currentTime, float nextTime, bool isFirst, bool isLast, CancellationToken token) { var panel = GetPanel(panelIndex); if (panel == null || panel.CinematicImage == null) return; var boundaryFade = 0f; if (!isLast) boundaryFade = Mathf.Min(_maxCrossfadeDuration, currentTime, nextTime); var introFade = 0f; if (isFirst) { introFade = isLast ? Mathf.Min(_maxCrossfadeDuration, currentTime) : Mathf.Min(_maxCrossfadeDuration, currentTime, nextTime); _imageA.sprite = panel.CinematicImage; SetImageAlpha(_imageA, 0f); SetImageAlpha(_imageB, 0f); if (introFade > 0f) await FadeImage(_imageA, 0f, 1f, introFade, token); else SetImageAlpha(_imageA, 1f); var hold = Mathf.Max(0f, currentTime - introFade - boundaryFade); if (hold > 0f) await UniTask.Delay(TimeSpan.FromSeconds(hold), DelayType.UnscaledDeltaTime, cancellationToken: token); if (!isLast && boundaryFade > 0f) await CrossfadeToNext(panelIndex + 1, boundaryFade, token); return; } var holdTime = Mathf.Max(0f, currentTime - boundaryFade); if (holdTime > 0f) await UniTask.Delay(TimeSpan.FromSeconds(holdTime), DelayType.UnscaledDeltaTime, cancellationToken: token); if (!isLast && boundaryFade > 0f) await CrossfadeToNext(panelIndex + 1, boundaryFade, token); } private async UniTask CrossfadeToNext(int nextPanelIndex, float duration, CancellationToken token) { var nextPanel = GetPanel(nextPanelIndex); if (nextPanel == null || nextPanel.CinematicImage == null) return; var current = GetMoreVisible(_imageA, _imageB); var next = current == _imageA ? _imageB : _imageA; next.sprite = nextPanel.CinematicImage; var currentAlpha = GetImageAlpha(current); SetImageAlpha(next, 0f); var seq = Sequence.Create(useUnscaledTime: true) .Group(Tween.Alpha(current, new TweenSettings { startValue = currentAlpha, endValue = 0f, settings = new TweenSettings { duration = duration, ease = Ease.Linear, useUnscaledTime = true } })) .Group(Tween.Alpha(next, new TweenSettings { startValue = 0f, endValue = 1f, settings = new TweenSettings { duration = duration, ease = Ease.Linear, useUnscaledTime = true } })); await seq.ToUniTask(cancellationToken: token); } private async UniTask FadeOutAtEnd(CancellationToken token) { StopSkipTextTween(); if (EventCoordinator != null) { if (_cinematicCanvasGroup != null) { _cinematicCanvasGroup.blocksRaycasts = false; _cinematicCanvasGroup.interactable = false; _cinematicCanvasGroup.alpha = 1f; } EventCoordinator.PublishImmediate(new FadeEvent(false, 1f)); return; } var img = GetMoreVisible(_imageA, _imageB); var imgFrom = GetImageAlpha(img); if ((_cinematicCanvasGroup == null || _cinematicCanvasGroup.alpha <= 0.001f) && imgFrom <= 0.001f) return; var canvasFrom = _cinematicCanvasGroup != null ? _cinematicCanvasGroup.alpha : 0f; var imgDur = Mathf.Max(0f, _finalImageFadeOutDuration); var canvasDur = Mathf.Max(0f, _canvasFadeOutDuration); var seq = Sequence.Create(useUnscaledTime: true); if (img != null && imgDur > 0f && imgFrom > 0.001f) seq.Group(Tween.Alpha(img, new TweenSettings { startValue = imgFrom, endValue = 0f, settings = new TweenSettings { duration = imgDur, ease = Ease.OutQuad, useUnscaledTime = true } })); else if (img != null) SetImageAlpha(img, 0f); if (_cinematicCanvasGroup != null && canvasDur > 0f && canvasFrom > 0.001f) { _cinematicCanvasGroup.blocksRaycasts = false; _cinematicCanvasGroup.interactable = false; seq.Group(Tween.Alpha(_cinematicCanvasGroup, new TweenSettings { startValue = canvasFrom, endValue = 0f, settings = new TweenSettings { duration = canvasDur, ease = Ease.OutQuad, useUnscaledTime = true } })); } else if (_cinematicCanvasGroup != null) { _cinematicCanvasGroup.alpha = 0f; _cinematicCanvasGroup.blocksRaycasts = false; _cinematicCanvasGroup.interactable = false; } try { if (seq.isAlive) await seq.ToUniTask(cancellationToken: token); } catch (OperationCanceledException) { if (img != null) SetImageAlpha(img, 0f); if (_cinematicCanvasGroup != null) _cinematicCanvasGroup.alpha = 0f; } } private static async UniTask FadeCanvasGroup(CanvasGroup group, float from, float to, float duration, CancellationToken token) { if (group == null) return; if (duration <= 0f) { group.alpha = to; return; } group.alpha = from; var seq = Sequence.Create(useUnscaledTime: true) .Group(Tween.Alpha(group, new TweenSettings { startValue = from, endValue = to, settings = new TweenSettings { duration = duration, ease = Ease.OutQuad, useUnscaledTime = true } })); await seq.ToUniTask(cancellationToken: token); } private static async UniTask FadeImage(Image image, float from, float to, float duration, CancellationToken token) { if (image == null) return; if (duration <= 0f) { SetImageAlpha(image, to); return; } SetImageAlpha(image, from); var seq = Sequence.Create(useUnscaledTime: true) .Group(Tween.Alpha(image, new TweenSettings { startValue = from, endValue = to, settings = new TweenSettings { duration = duration, ease = Ease.OutQuad, useUnscaledTime = true } })); await seq.ToUniTask(cancellationToken: token); } private static float GetImageAlpha(Image image) { if (image == null) return 0f; return image.color.a; } private static void SetImageAlpha(Image image, float alpha) { if (image == null) return; var c = image.color; c.a = Mathf.Clamp01(alpha); image.color = c; } private static Image GetMoreVisible(Image a, Image b) { var aa = GetImageAlpha(a); var bb = GetImageAlpha(b); return aa >= bb ? a : b; } [Inject] public void Construct(EventCoordinator eventCoordinator, InputManager inputManager) { EventCoordinator = eventCoordinator; InputManager = inputManager; } } }