Level/DoorComponent.cs
using Sandbox;

public class DoorComponent : BaseInteract, Component.IDamageable
{
	[Property, Group( "Movement" )] public Angles MoveDir { get; set; }
	[Property, Group( "Movement" )] public float Distance { get; set; } = 90.0f;
	[Property, Group( "Movement" )] public float DoorSpeed { get; set; } = 3f;
	[Property, Range( -120, 120 ), Group( "Movement" )] public float StartingRotation { get; set; } = 0.0f;
	[Property, Group( "Sound" )] public SoundEvent OpenSound { get; set; }
	[Property, Group( "Sound" )] public SoundEvent CloseSound { get; set; }
	[Property, Group( "Sound" )] public SoundEvent FullyCloseSound { get; set; }
	[Property, Group( "Sound" )] public SoundEvent LockedSound { get; set; }
	[Property, Group( "Sound" )] public SoundEvent UnlockedSound { get; set; }

	[Property] public bool Locked { get; set; } = false;
	[Property] Model HandleModel { get; set; }
	[Property, Group( "Blocked" )] public bool IsBlocked { get; set; } = false;
	[Property, Group( "Blocked" )] public List<GameObject> Blockers { get; set; } = new List<GameObject>();

	[Property, Range( 0.25f, 0.5f ), Group( "Physics" )] public float PeekFraction { get; set; } = 0.33f;
	[Property, Range( 10.0f, 50.0f ), Group( "Physics" )] public float Weight { get; set; } = 25.0f;

	[Property] public Action OnUnlocked { get; set; }

	private Rotation startRotation;
	private Rotation targetRotation;

	private float swingVelocity = 0f;

	private float fracOpen; // 0..1

	private enum States
	{
		/// <summary>
		/// Can't move door, not rotating.
		/// </summary>
		Closed,

		/// <summary>
		/// Can't move door, but rotating to fixed ~10deg position.
		/// </summary>
		Opening,

		/// <summary>
		/// Can move door, door will rotate.
		/// </summary>
		Open,

		/// <summary>
		/// Player pressed E on an open door, so we'll force it to 100% open
		/// </summary>
		ForcedOpen,

		/// <summary>
		/// Can't move door, but rotating to close point
		/// </summary>
		Closing,
	};

	private float openingDirection = 1.0f;
	private States state;

	/// <summary>
	/// Is this door open or in the process of opening?
	/// </summary>
	public bool IsOpen => MathF.Abs( fracOpen ) > 0.05f;

	private GameObject HandleObject { get; set; }
	private Vector3 HandlePos { get; set; } = new Vector3( 40, 0, 48 );
	private Model DoorModel => GameObject.Components.Get<ModelRenderer>( FindMode.InSelf ).Model;

	[Property] private Opium.InventoryItemResource ItemResource { get; set; }

	public bool HasKey()
	{
		// TODO: Make this derive from the interaction instead of this hack
		var player = Scene.GetAllComponents<Opium.PlayerController>().First();
		var inventory = player.Inventory;

		if ( inventory.HasItem( ItemResource ) )
		{
			return true;
		}

		return false;
	}

	public override string GetUseIcon()
	{
		if ( IsBlocked )
			return "ui/interactions/blocked_door.png";

		if ( HasKey() && Locked )
			return "ui/interactions/unlocked_door.png";

		if ( Locked )
			return "ui/interactions/locked_door.png";

		return null;
	}

	protected override void DrawGizmos()
	{
		base.DrawGizmos();

		if ( !Gizmo.IsSelected ) return;

		var gizmodoorRotation = MathF.Sin( Time.Now * DoorSpeed ).Remap( -1, 1, -MoveDir.Normal.AsVector3().Length, Distance ).LerpTo( -MoveDir.Normal.AsVector3().Length, Time.Delta );

		Gizmo.Draw.Color = Color.Red;
		Gizmo.Transform = new Transform( Transform.Position, Transform.Rotation * Rotation.FromAxis( -MoveDir.Normal.AsVector3(), gizmodoorRotation ), Transform.Scale );
		Gizmo.Draw.Color = Color.Blue.WithAlpha( 1f );
		Gizmo.Draw.LineBBox( DoorModel.Bounds );
		Gizmo.Draw.Color = Color.Blue.WithAlpha( 0.5f );
		Gizmo.Draw.SolidBox( DoorModel.Bounds );

		if ( StartingRotation != 0 )
		{
			Gizmo.Transform = new Transform( Transform.Position, Transform.Rotation * Rotation.FromAxis( -MoveDir.Normal.AsVector3(), StartingRotation ), Transform.Scale );
			Gizmo.Draw.Color = Color.Green.WithAlpha( 1f );
			Gizmo.Draw.LineBBox( DoorModel.Bounds );
			Gizmo.Draw.Color = Color.Green.WithAlpha( 0.25f );
			Gizmo.Draw.SolidBox( DoorModel.Bounds );
		}

		if ( Game.IsPlaying )
		{
			Gizmo.Draw.Color = Color.White;
			Gizmo.Transform = global::Transform.Zero;
			Gizmo.Draw.Text( $"State: {state}", Transform.World ); ;
		}
	}

	protected override void OnStart()
	{
		base.OnStart();

		startRotation = Transform.LocalRotation;

		if ( HandleModel != null )
		{
			HandleObject = new GameObject( true, "handle" );
			HandleObject.Parent = GameObject;
			HandleObject.Transform.Position = Transform.Position + Transform.Rotation * HandlePos;
			HandleObject.Components.Create<ModelRenderer>().Model = HandleModel;
		}

		if ( StartingRotation != 0 )
		{
			Transform.Rotation = Transform.Rotation * Rotation.FromAxis( -MoveDir.Normal.AsVector3(), StartingRotation );
			fracOpen = StartingRotation / Distance;
			state = States.Open;
		}
	}

	private void UpdatePhysics()
	{
		var renderer = Components.Get<ModelRenderer>();
		var origin = renderer.Bounds.Center;

		var nearbyObjects = Scene.FindInPhysics( BBox.FromPositionAndSize( origin, 10f ) )
			.Where( x => x != GameObject );

		foreach ( var go in nearbyObjects )
		{
			if ( go.Components.GetInAncestorsOrSelf<Opium.PlayerController>() is Opium.PlayerController player )
			{
				var openDirection = GetOpenDirection( go );
				var f = player.WishVelocity.Length * openDirection * 0.0003f * Weight;

				Rotate( f );
			}
		}
	}

	private float GetOpenDirection( GameObject player )
	{
		var doorToPlayer = player.Transform.Position - Transform.Position;
		return doorToPlayer.Dot( Transform.Rotation.Forward ) > 0 ? -1.0f : 1.0f; ;
	}

	private void UpdateRotation()
	{
		swingVelocity = swingVelocity.LerpTo( 0.0f, Time.Delta );
		RotateTo( fracOpen + swingVelocity * Time.Delta );
	}

	private void RotateTo( float target )
	{
		fracOpen = fracOpen.LerpTo( target, Time.Delta * DoorSpeed );
		targetRotation = Rotation.Lerp( targetRotation, startRotation, Time.Delta * DoorSpeed * 0.1f );
		Transform.Rotation = startRotation * Rotation.FromAxis( -MoveDir.Normal.AsVector3(), fracOpen * Distance );

		fracOpen = fracOpen.Clamp( -1, 1 );
	}

	protected override void OnFixedUpdate()
	{
		if ( IsBlocked )
		{
			if ( Blockers.Count == 0 )
			{
				IsBlocked = false;
			}
			else
			{
				foreach ( var blocker in Blockers )
				{
					if ( blocker.IsValid() )
					{
						IsBlocked = true;
						return;
					}
					else
					{
						IsBlocked = false;
					}
				}
			}
		}

		switch ( state )
		{
			case States.Closed:
				break;
			case States.Open:
				{
					if ( MathF.Abs( fracOpen ) >= 1.0f ) break;

					if ( !Locked )
					{
						UpdatePhysics();
						UpdateRotation();
					}
				}
				break;
			case States.Opening:
				RotateTo( openingDirection * PeekFraction );

				if ( MathF.Abs( fracOpen ) > (PeekFraction - 0.05f) )
					state = States.Open;

				break;
			case States.Closing:
				RotateTo( openingDirection * 0f );

				if ( MathF.Abs( fracOpen ).AlmostEqual( 0f, 0.01f ) )
					state = States.Closed;

				break;
			case States.ForcedOpen:
				RotateTo( openingDirection * 1.0f );
				break;
		}
	}

	private void Rotate( float target )
	{
		swingVelocity += target;
	}

	public override void OnUse( GameObject player )
	{
		ToggleDoor( player );
	}

	public override void OnUseFail( GameObject player )
	{
		Sound.Play( LockedSound, Transform.Position );
	}

	public override bool CanUse( GameObject player )
	{
		return CanToggleDoor();
	}

	public bool CanToggleDoor()
	{
		if ( Locked )
		{
			return HasKey();
		}

		return (state == States.Open || state == States.ForcedOpen || state == States.Closed) && !IsBlocked;
	}

	public bool ToggleDoor( GameObject player )
	{
		if ( state == States.ForcedOpen || MathF.Abs( fracOpen ) > 0.8f )
		{
			swingVelocity = 0;
			state = States.Closing;

			return true;
		}
		if ( state == States.Open )
		{
			swingVelocity = 0;
			state = States.ForcedOpen;

			return true;
		}
		else if ( state == States.Closed )
		{
			if ( Locked )
			{
				if ( HasKey() )
				{
					Locked = false;
					Sound.Play( UnlockedSound, Transform.Position );


					var inventory = player.Components.Get<PlayerInventory>( FindMode.EverythingInSelfAndDescendants );
					var data = inventory.FindItem( ItemResource );

					data.Container?.RemoveItem( data.Index );

					OnUnlocked?.Invoke();


					return false;
				}
				else
				{
					return false;
				}
			}

			OnPlayerOpen( player );

			return true;
		}

		return false;
	}

	private void OnPlayerOpen( GameObject player )
	{
		if ( OpenSound != null && IsOpen )
		{
			Sound.Play( CloseSound, Transform.Position );
		}

		if ( OpenSound != null && !IsOpen )
		{
			Sound.Play( OpenSound, Transform.Position );
		}

		state = States.Opening;
		openingDirection = GetOpenDirection( player );
	}

	public void OnDamage( in DamageInfo damage )
	{
		if ( IsBlocked ) return;
		if ( Locked ) return;

		if ( damage is Opium.DamageInfo dmg && dmg.HasTag( "open_door" ) )
		{
			if ( !IsOpen ) ToggleDoor( damage.Attacker );
		}
	}
}