A component that implements a simple boat controller for a vehicle in-game. It handles seating (mount/dismount), camera eye transform, input-driven thrust and turning while in water, buoyancy checks, stabilization torque, and plays simple sounds for waves and movement.
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);
}
}