Code/Miscellaneous/BoatController.cs
using System;
using Sandbox;
using Sandbox.Movement;
namespace RedSnail.WaterTool;
/// <summary>
/// Minimal demo boat controller.
/// </summary>
[Title( "Demo Boat Controller" ), Group( "Water" ), Icon( "directions_boat" )]
public sealed class BoatController : Component, Component.IPressable, ISitTarget
{
[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";
private Rigidbody m_Rigidbody;
private Buoyancy m_Buoyancy;
private float m_TargetThrust;
private float m_TargetTurn;
public bool IsOccupied => GetComponentInChildren<PlayerController>( false ) != null;
protected override void OnStart()
{
m_Rigidbody = GetComponent<Rigidbody>();
m_Buoyancy = GetComponent<Buoyancy>();
}
protected override void OnFixedUpdate()
{
if ( !m_Rigidbody.IsValid() )
return;
Stabilize();
if ( IsOccupied )
HandleMovement();
else
{
// Smoothly reset forces when unmanned
m_TargetThrust = 0f;
m_TargetTurn = 0f;
}
}
public bool CanPress( IPressable.Event e )
{
return e.Source is PlayerController && !IsOccupied;
}
public bool Press( IPressable.Event e )
{
if ( e.Source is not PlayerController player ) return false;
if ( IsOccupied ) return false;
MountPlayer( player );
return true;
}
public IPressable.Tooltip? GetTooltip( IPressable.Event e )
{
if ( IsOccupied ) return null;
var tooltip = new IPressable.Tooltip
{
Title = TooltipTitle,
Icon = TooltipIcon
};
return tooltip;
}
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 );
}
public Transform CalculateEyeTransform( PlayerController controller )
{
var anchor = EyePosition ?? SeatPosition ?? GameObject;
// Position follows the seat anchor so the camera rides with the boat.
// Rotation uses the player's eye angles in pure world space, the boat's
// pitch and roll are intentionally NOT applied so the view stays level
// even when the hull bobs or banks.
return new Transform
{
Position = anchor.WorldPosition,
Rotation = controller.EyeAngles.ToRotation()
};
}
private void MountPlayer( PlayerController player )
{
var seat = SeatPosition ?? GameObject;
// Disable the player's own physics so they don't fight the boat
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;
// Move to exit point, or eject to the side if none is set
player.WorldPosition = ExitPoint != null
? ExitPoint.WorldPosition
: WorldPosition + WorldRotation.Right * 100f + Vector3.Up * 30f;
m_TargetThrust = 0f;
m_TargetTurn = 0f;
}
private void HandleMovement()
{
// Only push when the hull is actually in the water
if ( m_Buoyancy is { IsTouchingWater: false } )
return;
float fwd = Input.AnalogMove.x; // W = +1 S = -1
float side = Input.AnalogMove.y; // D = +1 A = -1
// Thrust
float wantedThrust = fwd > 0.02f ? ThrustForce * fwd
: fwd < -0.02f ? ReverseForce * fwd
: 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 );
// Turning
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 decelerates naturally
float damping = ( TerminalSpeed / ( speed + 0.001f ) ) * 0.5f;
m_Rigidbody.LinearDamping = float.Clamp( damping, 0.5f, 5f );
}
private void Stabilize()
{
Vector3 torque = Vector3.Cross( WorldRotation.Up, Vector3.Up ) * Stability;
m_Rigidbody.ApplyTorque( torque );
}
}