Camera/CameraPanning.cs
using System;
using HC3.Terrain;

namespace HC3;

public sealed class CameraPanning : Component
{
	public static CameraPanning Instance { get; private set; }

	[Property] public CameraComponent Camera { get; set; }

	[Property] float ZoomSpeed { get; set; } = 100.0f;
	[Property] float MaxZoomDistance { get; set; } = 10000.0f;
	[Property] float OriginHeight { get; set; } = 150.0f;
	[Property] float PanSpeed { get; set; } = 25.0f;
	[Property] float MoveSpeed { get; set; } = 500.0f;
	[Property] Curve MoveSpeedZoomFactor { get; set; } = Curve.Linear;
	[Property] float EdgeScrollBorder { get; set; } = 10.0f;

	// We want to store the start position and angles. This way, other players
	// will always have the camera default to starting where it should be. This
	// saves us having to instantiate the camera at runtime.
	[Property, Hide] private Vector3 _startPosition;
	[Property, Hide] private Rotation _startAngles;

	Vector3 _origin;
	Vector3 _targetOrigin;
	float _targetDistance = 1200;
	float _distance = 1200;
	float _yawVelocity = 0;
	float _lastYaw = 0;
	Vector3 _panStart = 0;
	Vector2 _panSpeed;
	bool _oribiting;
	bool _panning;
	bool _driving;
	Vector3 _inputDirection;
	Rotation _viewAngles;
	float _terrainHeight;
	bool _freeCamActive = false;

	public GameObject FollowTarget => _followTarget;
	GameObject _followTarget;

	private TimeSince _timeSinceInput;
	private const float IdleRotationDelay = 30f;
	private const float IdleRotationSpeed = 5f;
	private const float ManualOrbitSpeed = 60f;

	[Property, Group( "Free Cam" )] public float FreeCamSpeed { get; set; } = 250f;
	[Property, Group( "Free Cam" )] public float FreeCamAcceleration { get; set; } = 10f;
	[Property, Group( "Free Cam" )] public float FreeCamDamping { get; set; } = 5f;
	[Property, Group( "Free Cam" )] public float FreeCamLookSensitivity { get; set; } = 0.1f;
	[Property, Group( "Free Cam" )] public float FreeCamMinPitch { get; set; } = -89f;
	[Property, Group( "Free Cam" )] public float FreeCamMaxPitch { get; set; } = 89f;

	Vector3 _freeCamVelocity = Vector3.Zero;
	Angles _freeCamAngles;

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

		Instance = this;

		// If we're the host then store the start position of the camera.

		if ( Networking.IsHost )
		{
			if ( Camera is not { } camera )
			{
				_startPosition = default;
				_startAngles = Rotation.Identity;
			}
			else
			{
				// Figure out target origin and distance from initial camera transform
				// TODO: this is still a bit off :(

				var ray = new Ray( camera.WorldPosition, camera.WorldRotation.Forward );
				var plane = new Plane( Vector3.Up, OriginHeight );

				_startPosition = plane.Trace( ray, true ) ?? default;
				_startAngles = camera.WorldRotation;

				_targetDistance = _distance = (_startPosition - camera.WorldPosition).Length;
			}
		}

		_origin = _startPosition;
		_targetOrigin = _startPosition;
		_viewAngles = _startAngles;

		_freeCamAngles = Camera.WorldRotation.Angles();
	}

	private Vector2 _smoothedLookDelta;

	private float _handheldTime;
	private Angles _handheldRotationOffset;
	private Angles _handheldRotationTarget;
	private Vector3 _handheldPositionOffset;
	private Vector3 _handheldPositionTarget;

	private float ShakeAmplitude => 0.3f;
	private float ShakeFrequency => 0.75f;
	private float ShakePositionAmplitude => 0.01f;
	private float ShakeSmoothness => 4.0f;

	private void SimulateFreeCamera()
	{
		// Raw mouse look input
		Vector2 lookInput = new( Input.AnalogLook.yaw * FreeCamLookSensitivity, Input.AnalogLook.pitch * FreeCamLookSensitivity );

		// Smooth it (exponential moving average)
		float smoothingSpeed = 10.0f; // Increase for faster responsiveness, decrease for more smoothing
		_smoothedLookDelta = Vector2.Lerp( _smoothedLookDelta, lookInput, RealTime.Delta * smoothingSpeed );

		_freeCamAngles.pitch = (_freeCamAngles.pitch + _smoothedLookDelta.y).Clamp( FreeCamMinPitch, FreeCamMaxPitch );
		_freeCamAngles.yaw += _smoothedLookDelta.x;
		_freeCamAngles.roll = 0;

		var rotation = _freeCamAngles.ToRotation();
		var moveInput = Input.AnalogMove.Normal;

		var mult = 1f;
		if ( Input.Keyboard.Down( "SHIFT" ) ) mult *= 4f;
		if ( Input.Keyboard.Down( "CTRL" ) ) mult *= 0.25f;

		var desiredVelocity = rotation * moveInput * FreeCamSpeed * mult;
		_freeCamVelocity = Vector3.Lerp( _freeCamVelocity, desiredVelocity, RealTime.Delta * FreeCamAcceleration );

		Camera.WorldPosition += _freeCamVelocity * RealTime.Delta;
		Camera.WorldRotation = rotation;

		_handheldTime += RealTime.Delta * ShakeFrequency;

		// 😷😷
		float yawNoise = MathF.Sin( _handheldTime * 0.9f + 12.5f ) + MathF.Sin( _handheldTime * 1.3f + 3.1f );
		float pitchNoise = MathF.Sin( _handheldTime * 1.1f + 1.5f ) + MathF.Sin( _handheldTime * 0.7f + 6.2f );
		float rollNoise = MathF.Sin( _handheldTime * 1.7f + 4.3f ) + MathF.Sin( _handheldTime * 1.0f + 8.1f );

		_handheldRotationTarget = new Angles(
			pitchNoise * ShakeAmplitude,
			yawNoise * ShakeAmplitude,
			rollNoise * ShakeAmplitude
		);

		float px = MathF.Sin( _handheldTime * 1.5f + 5.0f );
		float py = MathF.Sin( _handheldTime * 1.2f + 1.0f );
		float pz = MathF.Sin( _handheldTime * 1.8f + 3.0f );

		_handheldPositionTarget = new Vector3(
			px * ShakePositionAmplitude,
			py * ShakePositionAmplitude,
			pz * ShakePositionAmplitude * 0.5f
		);

		_handheldRotationOffset = Angles.Lerp( _handheldRotationOffset, _handheldRotationTarget, RealTime.Delta * ShakeSmoothness );
		_handheldPositionOffset = Vector3.Lerp( _handheldPositionOffset, _handheldPositionTarget, RealTime.Delta * ShakeSmoothness );

		Camera.WorldRotation *= _handheldRotationOffset.ToRotation();
		Camera.WorldPosition += Camera.WorldRotation * _handheldPositionOffset;

		SetMouseVisibility( MouseVisibility.Hidden );

		var tiltShift = Scene.Get<TiltShiftEffect>();
		if ( tiltShift.IsValid() )
		{
			tiltShift.Enabled = false;
		}

		var dof = Camera.GetComponent<DepthOfField>( true );
		if ( dof.IsValid() )
		{
			dof.Enabled = true;

			UpdateDepthOfField( dof );
		}

		var ui = Scene.Get<ScreenPanel>();
		if ( ui.IsValid() )
		{
			Scene.Get<ScreenPanel>().Enabled = false;
		}

		//BuildingZone.Instance.Line.Enabled = false;

		UpdateFieldOfView();
	}

	private void UpdateFieldOfView()
	{
		if ( Input.MouseWheel.y != 0 )
		{
			// Scale FOV change by vertical mouse movement
			float delta = -Input.MouseWheel.y;
			fov = fov.Clamp( 20, 120 );
			fov += delta;
			fov = fov.Clamp( 20, 120 );
		}

		Camera.FieldOfView = Camera.FieldOfView.LerpTo( Screen.CreateVerticalFieldOfView( fov, 9.0f / 16.0f ), Time.Delta * 2f );
	}

	private float fov = 50;
	private Vector3 _focusPoint;
	private void UpdateDepthOfField( DepthOfField dof )
	{
		if ( Input.Keyboard.Down( "MOUSE1" ) )
		{
			dof.BlurSize = Scene.Camera.FieldOfView.Remap( 20, 80, 50, 10 );
			dof.FocusRange = 1024;
			dof.FrontBlur = false;

			var tr = Scene.Trace.Ray( Scene.Camera.Transform.World.ForwardRay, 5000 )
								.Radius( 8 )
								.WithTag( "ground" )
								.Run();

			_focusPoint = tr.EndPosition;
		}

		var target = Scene.Camera.WorldPosition.Distance( _focusPoint ) + 32;

		dof.FocalDistance = dof.FocalDistance.LerpTo( target, Time.Delta * 10.0f );
	}

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

		// Toggle free camera
		if ( DebugMode.Enabled && Input.Keyboard.Pressed( "J" ) )
		{
			_freeCamActive = !_freeCamActive;

			// Reinitialize angles from current camera orientation
			if ( _freeCamActive )
				_freeCamAngles = Camera.WorldRotation.Angles();
		}

		UpdateTerrainHeight();

		if ( _freeCamActive )
		{
			SimulateFreeCamera();
		}
		else
		{
			BuildInput();
			Simulate();
		}

		if ( _panning )
		{
			Gizmo.Draw.Arrow( _origin - _panStart, _origin - _panStart + _inputDirection * 0.5f );
		}
	}

	private void Simulate()
	{
		if ( _oribiting )
		{
			_yawVelocity = _viewAngles.Yaw() - _lastYaw;
			_yawVelocity = _yawVelocity.Clamp( -10, 10 );
		}
		else
		{
			_yawVelocity = _yawVelocity.LerpTo( 0.0f, 5.0f * RealTime.Delta );
		}

		_lastYaw = _viewAngles.Yaw();
		_targetOrigin += _inputDirection * RealTime.Delta;
		_distance = _distance.LerpTo( _targetDistance, RealTime.Delta * 10.0f );
		_origin = _origin.LerpTo( _targetOrigin, RealTime.Delta * 10.0f );

		Camera.WorldPosition = _origin + _viewAngles.Backward * _distance;
		Camera.WorldRotation = _viewAngles;
	}

	private void SetMouseVisibility( MouseVisibility mode )
	{
		if ( Mouse.Visibility == mode ) return;
		Mouse.Visibility = mode;
	}

	private void BuildInput()
	{
		_oribiting = Input.Keyboard.Down( "MOUSE3" ) && !Input.Keyboard.Down( "ALT" );
		_panning = Input.Keyboard.Down( "MOUSE3" ) && Input.Keyboard.Down( "ALT" );
		_driving = _oribiting || _panning;

		SetMouseVisibility( _driving ? MouseVisibility.Hidden : MouseVisibility.Auto );

		var viewAngles = _viewAngles.Angles();

		bool anyInput = Input.AnalogMove.Length > 0.01f
			|| Input.MouseWheel.y != 0
			|| Input.Keyboard.Down( "MOUSE3" )
			|| Mouse.Delta.Length > 0.01f;

		if ( anyInput )
		{
			_timeSinceInput = 0;
		}

		if ( Input.AnalogMove.Length > 0.01f && FollowTarget.IsValid() )
		{
			Follow( null );
		}

		var manualOrbit = 0;

		if ( !BuildingPlacer.Instance.IsPlacing )
		{
			if ( Input.Keyboard.Down( "Q" ) ) manualOrbit = 1;
			if ( Input.Keyboard.Down( "E" ) ) manualOrbit = -1;
		}

		if ( _oribiting && !_panning )
		{
			viewAngles.yaw += Input.AnalogLook.yaw;
			viewAngles.pitch += Input.AnalogLook.pitch;

			if ( Input.Keyboard.Pressed( "MOUSE3" ) )
			{
				_yawVelocity = 0;
			}
		}
		else if ( manualOrbit != 0 )
		{
			viewAngles.yaw += manualOrbit * ManualOrbitSpeed * RealTime.Delta;
		}
		else if ( _timeSinceInput > IdleRotationDelay )
		{
			viewAngles.yaw += IdleRotationSpeed * RealTime.Delta;
		}
		else
		{
			viewAngles.yaw += _yawVelocity;
		}

		viewAngles.roll = 0;
		viewAngles.pitch = viewAngles.pitch.Clamp( 10, 89 );
		viewAngles = viewAngles.Normal;
		_viewAngles = viewAngles.ToRotation();

		var moveDir = Rotation.FromYaw( _viewAngles.Yaw() );

		if ( _followTarget.IsValid() )
		{
			_inputDirection = new Vector3();
			FrameOn( _followTarget.WorldPosition, false );
			_origin = _targetOrigin;
		}
		else if ( _panning )
		{
			if ( _panStart == 0 )
			{
				var tr = Scene.Trace.Ray( Scene.Camera.ScreenPixelToRay( Mouse.Position ), 6000f )
					.UsePhysicsWorld()
					.WithTag( "ground" )
					.Run();
				var hitPos = tr.Hit ? tr.HitPosition : tr.EndPosition;
				hitPos -= tr.Direction * 400f;
				_panStart = _origin - hitPos;
			}
			_inputDirection = new Vector3( _panSpeed ) * moveDir * PanSpeed;
			_panSpeed.x -= Input.AnalogLook.pitch;
			_panSpeed.y += Input.AnalogLook.yaw;
			_panSpeed.x = _panSpeed.x.Clamp( -PanSpeed, PanSpeed );
			_panSpeed.y = _panSpeed.y.Clamp( -PanSpeed, PanSpeed );
		}
		else
		{
			float speed = MoveSpeed * MoveSpeedZoomFactor.Evaluate( _distance / MaxZoomDistance );

			Vector3 move = Input.AnalogMove;
			if ( !_driving ) move += GetEdgeScroll();

			_inputDirection = move * moveDir * speed;

			_panSpeed = 0;
			_panStart = 0;
		}

		if ( !_followTarget.IsValid() )
		{
			_targetOrigin.z = OriginHeight + _terrainHeight;
		}

		if ( !Input.Down( "run" ) )
		{
			_targetDistance -= Input.MouseWheel.y * ZoomSpeed;
		}
		_targetDistance = _targetDistance.Clamp( 0, MaxZoomDistance );
	}

	private Vector3 GetEdgeScroll()
	{
		var isOnScreen = new Rect( 0, Screen.Size - 1 ).Shrink( 1 ).IsInside( Mouse.Position );
		if ( !isOnScreen )
			return Vector3.Zero;

		var panMovement = Vector3.Zero;
		PathMask move = 0;

		if ( Mouse.Position.y < EdgeScrollBorder )
		{
			float factor = (EdgeScrollBorder - Mouse.Position.y) / EdgeScrollBorder;
			panMovement += Vector3.Forward * factor;
			move |= PathMask.Up;
		}
		else if ( Mouse.Position.y > Screen.Height - EdgeScrollBorder )
		{
			float factor = (Mouse.Position.y - (Screen.Height - EdgeScrollBorder)) / EdgeScrollBorder;
			panMovement += Vector3.Backward * factor;
			move |= PathMask.Down;
		}

		if ( Mouse.Position.x < EdgeScrollBorder )
		{
			float factor = (EdgeScrollBorder - Mouse.Position.x) / EdgeScrollBorder;
			panMovement += Vector3.Left * factor;
			move |= PathMask.Right;
		}
		else if ( Mouse.Position.x > Screen.Width - EdgeScrollBorder )
		{
			float factor = (Mouse.Position.x - (Screen.Width - EdgeScrollBorder)) / EdgeScrollBorder;
			panMovement += Vector3.Right * factor;
			move |= PathMask.Left;
		}

		string cursor = "arrow";
		if ( move != 0 )
		{
			if ( move == PathMask.Up ) cursor = "dir_n";
			else if ( move == PathMask.Down ) cursor = "dir_s";
			else if ( move == PathMask.Left ) cursor = "dir_e";
			else if ( move == PathMask.Right ) cursor = "dir_w";
			else if ( move == (PathMask.Up | PathMask.Left) ) cursor = "dir_ne";
			else if ( move == (PathMask.Up | PathMask.Right) ) cursor = "dir_nw";
			else if ( move == (PathMask.Down | PathMask.Left) ) cursor = "dir_se";
			else if ( move == (PathMask.Down | PathMask.Right) ) cursor = "dir_sw";
		}
		Mouse.CursorType = cursor;

		return panMovement;
	}

	private void UpdateTerrainHeight()
	{
		ParkTerrain terrain = GridManager.Instance.Terrain;
		if ( !terrain.IsValid() )
			return;

		var gridPos = GridManager.WorldToGridPosition( _targetOrigin );
		_terrainHeight = terrain.GetHeight( gridPos ) * terrain.TileHeight;
	}

	public float GetZoomDistance()
	{
		return _distance;
	}
	public float GetMaxZoomDistance()
	{
		return MaxZoomDistance;
	}

	public void FrameOn( Vector3 position, bool setDistance = true ) => FrameOn( BBox.FromPositionAndSize( position, GridManager.GridSize ), setDistance );

	public void FrameOn( BBox target, bool setDistance = true )
	{
		if ( setDistance )
		{
			_targetDistance = MathX.SphereCameraDistance( target.Size.Length, Camera.FieldOfView );
		}

		_targetOrigin = target.Center;
		_targetOrigin -= (_viewAngles.Forward * OriginHeight * 2);
	}

	public void Follow( GameObject gameObject )
	{
		if ( !gameObject.IsValid() )
		{
			_followTarget = null;
			return;
		}

		FrameOn( gameObject.WorldPosition );
		_followTarget = gameObject;
	}

	public void ToggleFollow( GameObject gameObject )
	{
		if ( _followTarget != gameObject )
		{
			Follow( gameObject );
			return;
		}

		Follow( null );
	}

	public void StopFollowing( GameObject gameObject )
	{
		if ( _followTarget != gameObject )
		{
			return;
		}

		Follow( null );
	}
}