World/MovieGate.cs
using System;
using Sandbox.MovieMaker;

namespace Causal;

public sealed class MovieGate : Component
{
	public enum HandoffMode
	{
		Absolute,
		Proportional,
		Restart
	}

	[Property] public MoviePlayer Player { get; set; }
	[Property] public MovieResource BaseMovie { get; set; }
	[Property] public MovieResource FailMovie { get; set; }
	[Property] public HandoffMode Handoff { get; set; } = HandoffMode.Absolute;
	[Property] public float FailOffset { get; set; }
	[Property] public float FailCheckFromSeconds { get; set; } = 1f;
	[Property] public float FailCheckToSeconds { get; set; } = 3f;
	[Property] public bool AllowInterrupt { get; set; } = true;
	[Property] public float ActionCooldown { get; set; } = 2f;
	[Property] public bool AdvanceWhileHidden { get; set; } = true;

	public bool IsPlaying => _player.IsValid() && _player.IsPlaying;
	public bool HasBranched => _hasBranched;

	private MoviePlayer _player;
	private IMovieCondition _condition;
	private TimeSince _timeSinceAction = 99f;
	private TimeSince _timeSinceHidden;
	private float _storedPosition;
	private bool _wasPlaying;
	private bool _hasBranched;
	private MovieResource _savedMovie;
	private float _savedPosition;
	private float _savedTimeScale = 1f;
	private bool _savedWasPlaying;
	private bool _hasSaved;

	protected override void OnStart()
	{
		ResolvePlayer();
		ResolveCondition();
	}

	public void PlayBase()
	{
		if ( !GameObject.IsValid() )
		{
			return;
		}

		if ( !_player.IsValid() )
		{
			Log.Warning( $"MovieGate on '{GameObject.Name}' has no MoviePlayer." );
			return;
		}

		if ( BaseMovie is null || !BaseMovie.IsValid() )
		{
			Log.Warning( $"MovieGate on '{GameObject.Name}' is missing its BaseMovie." );
			return;
		}

		if ( _timeSinceAction < ActionCooldown )
		{
			return;
		}

		if ( !AllowInterrupt && _player.IsPlaying )
		{
			return;
		}

		MovieResource selected = BaseMovie;
		if ( FailMovie.IsValid() && HasFailed() )
		{
			selected = FailMovie;
		}

		PlayAt( selected, 0f, true, _player.TimeScale );
		_hasBranched = selected == FailMovie;
		_timeSinceAction = 0f;
	}

	protected override void OnUpdate()
	{
		if ( !_player.IsValid() )
		{
			return;
		}

		if ( _player.IsPlaying )
		{
			_storedPosition = _player.PositionSeconds;
			_wasPlaying = true;
			TickBranch();
			return;
		}

		if ( !_wasPlaying )
		{
			return;
		}

		_wasPlaying = false;
		float pos = _player.PositionSeconds;
		float end = ClipDurationSeconds( _player.Clip );
		bool finished = end > 0f ? pos >= end - 0.05f : pos >= _storedPosition - 0.05f;
		if ( _storedPosition > 0.05f && !finished )
		{
			_player.PositionSeconds = _storedPosition;
			_player.IsPlaying = true;
		}
		else
		{
			_storedPosition = 0f;
		}
	}

	protected override void OnDisabled()
	{
		if ( !_player.IsValid() )
		{
			return;
		}

		_savedMovie = CurrentMovie();
		_savedPosition = _player.PositionSeconds;
		_savedTimeScale = _player.TimeScale;
		_savedWasPlaying = _player.IsPlaying || _storedPosition > 0.05f;
		_timeSinceHidden = 0f;
		_hasSaved = true;
	}

	protected override void OnEnabled()
	{
		if ( !_hasSaved )
		{
			return;
		}

		_hasSaved = false;

		ResolvePlayer();

		if ( !_savedWasPlaying || !_savedMovie.IsValid() || !_player.IsValid() )
		{
			return;
		}

		float end = MovieDurationSeconds( _savedMovie );
		float target = AdvanceWhileHidden ? _savedPosition + (float)_timeSinceHidden * _savedTimeScale : _savedPosition;
		target = end > 0f ? Math.Clamp( target, 0f, Math.Max( 0f, end - 0.05f ) ) : 0f;
		PlayAt( _savedMovie, target, true, _savedTimeScale );
	}

	private void TickBranch()
	{
		if ( _hasBranched || !FailMovie.IsValid() )
		{
			return;
		}

		float pos = _storedPosition;
		if ( pos < FailCheckFromSeconds || pos > FailCheckToSeconds )
		{
			return;
		}

		if ( !HasFailed() )
		{
			return;
		}

		float target = MapPosition( pos, ClipDurationSeconds( _player.Clip ), MovieDurationSeconds( FailMovie ), Handoff, FailOffset );
		PlayAt( FailMovie, target, true, _player.TimeScale );
		_hasBranched = true;
		_timeSinceAction = 0f;
	}

	private void PlayAt( MovieResource movie, float position, bool resumePlaying, float timeScale )
	{
		if ( !_player.IsValid() || !movie.IsValid() )
		{
			return;
		}

		_player.Play( movie );
		_player.TimeScale = timeScale;
		_player.PositionSeconds = position;
		_player.IsPlaying = resumePlaying;
		_storedPosition = resumePlaying ? position : 0f;
		_wasPlaying = resumePlaying;
	}

	private bool HasFailed()
	{
		return _condition is not null && _condition.HasFailed();
	}

	private MovieResource CurrentMovie()
	{
		var resource = _player.Resource as MovieResource;
		if ( resource.IsValid() )
		{
			return resource;
		}

		return _hasBranched ? FailMovie : BaseMovie;
	}

	private void ResolvePlayer()
	{
		_player = Player.IsValid() ? Player : GetComponent<MoviePlayer>();

		if ( !_player.IsValid() )
		{
			Log.Warning( $"MovieGate on '{GameObject.Name}' needs a MoviePlayer on the same GameObject." );
			return;
		}

		_player.CreateTargets = false;
	}

	private void ResolveCondition()
	{
		_condition = null;

		foreach ( var component in Components.GetAll() )
		{
			if ( component is IMovieCondition condition )
			{
				_condition = condition;
				break;
			}
		}

		if ( _condition is null )
		{
			Log.Warning( $"MovieGate on '{GameObject.Name}' found no IMovieCondition." );
		}
	}

	private static float MapPosition( float basePosition, float baseDuration, float failDuration, HandoffMode mode, float offset )
	{
		float mapped = mode switch
		{
			HandoffMode.Absolute => basePosition + offset,
			HandoffMode.Proportional => baseDuration > 0.001f ? basePosition / baseDuration * failDuration : 0f,
			_ => 0f,
		};

		return failDuration > 0f ? Math.Clamp( mapped, 0f, Math.Max( 0f, failDuration - 0.05f ) ) : 0f;
	}

	private static float ClipDurationSeconds( IMovieClip clip )
	{
		if ( clip is null )
		{
			return 0f;
		}

		return (float)clip.Duration.TotalSeconds;
	}

	internal static float MovieDurationSeconds( MovieResource movie )
	{
		if ( !movie.IsValid() || movie.Compiled is null )
		{
			return 0f;
		}

		return (float)movie.Compiled.Duration.TotalSeconds;
	}
}