Code/Gameplay/BoatController.cs

A component controlling a demo boat. It handles seating/boarding, player mounting/dismounting, buoyant movement forces, stabilization, input-based thrust/turning, and playback of related sounds.

File Access
using System;
using Sandbox;
using Sandbox.Movement;

namespace RedSnail.WaterTool2;

/// <summary>
/// A minimal demo boat: press to board it, drive it with the movement keys, and it keeps itself upright.
/// </summary>
[Title("Demo Boat Controller")]
[Category("Water Tool 2")]
[Icon("directions_boat")]
public sealed class BoatController : Component, Component.IPressable, ISitTarget
{
	private TimeSince m_TimeSinceLastUnderWave;
	private float m_LastHitTimer = 1.0f;

	[Property, Group("Seat")] public GameObject SeatPosition { get; set; }
	[Property, Group("Seat")] public GameObject EyePosition { get; set; }
	[Property, Group("Seat")] public GameObject ExitPoint { get; set; }

	[Property, Group("Movement")] public float ThrustForce { get; set; } = 200_000f;
	[Property, Group("Movement")] public float ReverseForce { get; set; } = 80_000f;
	[Property, Group("Movement")] public float TurnForce { get; set; } = 60_000f;
	[Property, Group("Movement")] public float Stability { get; set; } = 50_000f;
	[Property, Group("Movement")] public float TerminalSpeed { get; set; } = 800f;

	[Property, Group("Interaction")] public string TooltipTitle { get; set; } = "Drive";
	[Property, Group("Interaction")] public string TooltipIcon { get; set; } = "directions_boat";

	[Property, Group("Sounds")] public SoundEvent BoatUnderWaves { get; set; }
	[Property, Group("Sounds")] public SoundPointComponent BoatOnWaterLoop { get; set; }

	private Rigidbody m_Rigidbody;
	private Buoyancy m_Buoyancy;

	private float m_TargetThrust;
	private float m_TargetTurn;

	public bool IsOccupied => GetComponentInChildren<PlayerController>(false).IsValid();



	protected override void OnStart()
	{
		m_Rigidbody = GetComponent<Rigidbody>();
		m_Buoyancy = GetComponent<Buoyancy>();
	}



	protected override void OnFixedUpdate()
	{
		if (!m_Rigidbody.IsValid())
			return;

		HandleSounds();
		Stabilize();

		if (IsOccupied)
		{
			HandleMovement();
		}
		else
		{
			m_TargetThrust = 0f;
			m_TargetTurn = 0f;
		}
	}



	public bool CanPress(IPressable.Event _Event)
	{
		return _Event.Source is PlayerController && !IsOccupied;
	}



	public bool Press(IPressable.Event _Event)
	{
		if (_Event.Source is not PlayerController player || IsOccupied)
			return false;

		MountPlayer(player);

		return true;
	}



	public IPressable.Tooltip? GetTooltip(IPressable.Event _Event)
	{
		if (IsOccupied)
			return null;

		return new IPressable.Tooltip
		{
			Title = TooltipTitle,
			Icon = TooltipIcon
		};
	}



	public void AskToLeave(PlayerController _Player)
	{
		DismountPlayer(_Player);
	}



	public void UpdatePlayerAnimator(PlayerController _Controller, SkinnedModelRenderer _Renderer)
	{
		_Controller.LocalTransform = global::Transform.Zero;
		_Renderer.LocalRotation = Rotation.Identity;
		_Renderer.Set("sit", (int)BaseChair.AnimatorSitPose.ChairForward);
		_Renderer.Set("b_grounded", true);
		_Renderer.Set("b_climbing", false);
		_Renderer.Set("b_swim", false);
		_Renderer.Set("duck", false);
	}



	/// <summary>
	/// The camera rides with the seat but stays level: the boat's pitch and roll are left out, so the view doesn't heave
	/// with the hull.
	/// </summary>
	public Transform CalculateEyeTransform(PlayerController _Controller)
	{
		var anchor = EyePosition ?? SeatPosition ?? GameObject;

		return new Transform
		{
			Position = anchor.WorldPosition,
			Rotation = _Controller.EyeAngles.ToRotation()
		};
	}



	private void MountPlayer(PlayerController _Player)
	{
		var seat = SeatPosition ?? GameObject;

		// The player's own physics would fight the boat's.
		if (_Player.Body.IsValid())
			_Player.Body.Enabled = false;

		if (_Player.ColliderObject.IsValid())
			_Player.ColliderObject.Enabled = false;

		_Player.GameObject.SetParent(seat, false);
		_Player.GameObject.LocalTransform = global::Transform.Zero;
	}



	private void DismountPlayer(PlayerController _Player)
	{
		_Player.GameObject.SetParent(null, true);

		if (_Player.Body.IsValid())
			_Player.Body.Enabled = true;

		if (_Player.ColliderObject.IsValid())
			_Player.ColliderObject.Enabled = true;

		_Player.WorldPosition = ExitPoint.IsValid()
			? ExitPoint.WorldPosition
			: WorldPosition + WorldRotation.Right * 100f + Vector3.Up * 30f;

		m_TargetThrust = 0f;
		m_TargetTurn = 0f;
	}



	private void HandleMovement()
	{
		// Only push while the hull is in the water.
		if (m_Buoyancy is { IsTouchingWater: false })
			return;

		float forward = Input.AnalogMove.x;
		float side = Input.AnalogMove.y;

		float wantedThrust = forward > 0.02f ? ThrustForce * forward
			: forward < -0.02f ? ReverseForce * forward
			: 0f;

		m_TargetThrust = float.Lerp(m_TargetThrust, wantedThrust, Time.Delta * 3f);

		float speed = m_Rigidbody.Velocity.WithZ(0).Length;
		float limiter = MathF.Min(1f, TerminalSpeed / (speed + 0.001f));

		m_Rigidbody.ApplyForce(WorldRotation.Right * m_TargetThrust * limiter);

		float speedFactor = float.Clamp(speed / 200f, 0.2f, 1f);
		float wantedTurn = side * TurnForce * speedFactor;
		m_TargetTurn = float.Lerp(m_TargetTurn, wantedTurn, Time.Delta * 5f);

		Vector3 bow = WorldPosition + WorldRotation.Forward * 60f;
		m_Rigidbody.ApplyForceAt(bow, WorldRotation.Left * m_TargetTurn);

		// Speed dependent damping, so the boat slows down naturally.
		float damping = (TerminalSpeed / (speed + 0.001f)) * 0.5f;
		m_Rigidbody.LinearDamping = float.Clamp(damping, 0.5f, 5f);
	}



	private void HandleSounds()
	{
		if (Scene.Camera is not CameraComponent camera)
			return;

		HandleWavesSound(camera);
		HandleMovementSound(camera);
	}



	private void HandleWavesSound(CameraComponent _Camera)
	{
		if (!BoatUnderWaves.IsValid() || !m_Buoyancy.IsValid())
			return;

		float distance = _Camera.WorldPosition.DistanceSquared(WorldPosition);
		float maxDistanceSquared = BoatUnderWaves.Distance * BoatUnderWaves.Distance;
		float speed = m_Rigidbody.Velocity.WithZ(0).Length;

		if (speed < 10.0f && distance < maxDistanceSquared && m_Buoyancy.IsTouchingWater && m_TimeSinceLastUnderWave > m_LastHitTimer)
		{
			Sound.Play(BoatUnderWaves, WorldPosition);

			m_TimeSinceLastUnderWave = 0;
			m_LastHitTimer = Game.Random.Float(2.0f, 10.0f);
		}
	}



	private void HandleMovementSound(CameraComponent _Camera)
	{
		if (!BoatOnWaterLoop.IsValid())
			return;

		float distance = _Camera.WorldPosition.DistanceSquared(WorldPosition);
		float maxDistanceSquared = BoatOnWaterLoop.Distance * BoatOnWaterLoop.Distance;

		// Too far from the camera to hear: don't keep the sound running.
		if (distance > maxDistanceSquared)
		{
			BoatOnWaterLoop.Enabled = false;
			return;
		}

		BoatOnWaterLoop.SoundOverride = true;
		BoatOnWaterLoop.Volume = m_Rigidbody.Velocity.WithZ(0).Length.Remap(0.0f, 200.0f);
		BoatOnWaterLoop.Enabled = true;
	}



	private void Stabilize()
	{
		m_Rigidbody.ApplyTorque(Vector3.Cross(WorldRotation.Up, Vector3.Up) * Stability);
	}
}