A MoveMode implementation for swimming in a game. It tracks water contact level, plays splash and swimming sounds, applies damping and river current forces, and handles swimming-specific movement and jumps when exiting water.
using Sandbox;
using Sandbox.Movement;
namespace RedSnail.WaterTool;
/// <summary>
/// The character is swimming
/// </summary>
[Icon( "scuba_diving" ), Group( "Movement" ), Title( "MoveMode - Swim (Fixed)" )]
public sealed class MoveModeSwimFixed : MoveMode
{
private bool m_WasInWater;
private bool m_HasTouchedWater;
private TimeSince m_TimeSinceStep;
[Property]
public int Priority { get; set; } = 10;
[Property, Range( 0, 1 )]
public float SwimLevel { get; set; } = 0.7f;
/// <summary>
/// We will update this based on how much you're in a "water" tagged trigger.
/// </summary>
public float WaterLevel { get; private set; }
// How hard the current grips the hull, the rate (per second, at full submersion)
// at which the body's velocity is pulled toward the flow velocity.
[Property, Group("River Current"), Range(0.1f, 10.0f)] private float CurrentDrag { get; set; } = 1.5f;
[Property, Group("Sounds")] public SoundEvent WaterSplashEnter { get; set; }
[Property, Group("Sounds")] public SoundEvent WaterSplashExit { get; set; }
[Property, Group("Sounds")] public SoundEvent WaterFootsteps { get; set; }
[Property, Group("Sounds")] public SoundPointComponent WaterSwimmingLoop { get; set; }
protected override void OnStart()
{
if (WaterSwimmingLoop.IsValid())
WaterSwimmingLoop.Enabled = false;
}
public override void UpdateRigidBody( Rigidbody body )
{
body.Gravity = false;
body.LinearDamping = 3.3f;
body.AngularDamping = 1f;
}
public override int Score( PlayerController controller )
{
if ( WaterLevel > SwimLevel ) return Priority;
return -100;
}
public override void OnModeBegin()
{
Controller.IsSwimming = true;
if (WaterSwimmingLoop.IsValid())
WaterSwimmingLoop.Enabled = true;
}
public override void OnModeEnd( MoveMode next )
{
Controller.IsSwimming = false;
if (WaterSwimmingLoop.IsValid())
WaterSwimmingLoop.Enabled = false;
// jump when leaving the water
if ( Input.Down( "Jump" ) )
{
Controller.Jump( Vector3.Up * 300 );
}
}
protected override void OnFixedUpdate()
{
UpdateWaterLevel();
if (m_HasTouchedWater != m_WasInWater)
{
if (m_HasTouchedWater)
{
if (WaterSplashEnter.IsValid())
Sound.Play(WaterSplashEnter, WorldPosition);
}
else
{
if (WaterSplashExit.IsValid())
Sound.Play(WaterSplashExit, WorldPosition);
}
m_WasInWater = m_HasTouchedWater;
}
const float epsilon = 0.001f;
float speed = (Controller.WalkSpeed * 0.5f / Controller.Velocity.Length + epsilon).Clamp(0.2f, 1.0f);
if (m_HasTouchedWater && Controller.IsOnGround && Controller.Velocity.Length > 10 && m_TimeSinceStep > speed)
{
m_TimeSinceStep = 0;
Sound.Play(WaterFootsteps, WorldPosition);
}
if (Controller.IsSwimming)
{
ApplyCurrentForce();
}
}
void UpdateWaterLevel()
{
if ( Controller?.Body == null )
return;
if (GameObject.Parent != Scene)
return;
var wt = WorldTransform;
Vector3 head = wt.PointToWorld( new Vector3( 0, 0, Controller.CurrentHeight ) );
Vector3 foot = wt.Position;
float waterLevel = 0;
var waterSurface = WaterManager.GetWaterHeightAt( head );
var level = Vector3.InverseLerp( waterSurface, foot, head );
level = (level * 100).CeilToInt() / 100.0f;
if (level > waterLevel)
{
m_HasTouchedWater = true;
waterLevel = level;
}
else
{
m_HasTouchedWater = false;
}
if ( WaterLevel != waterLevel )
{
WaterLevel = waterLevel;
}
}
private void ApplyCurrentForce()
{
Vector3 flowVelocity = WaterManager.GetFlowVelocityAt(WorldPosition);
if (flowVelocity.LengthSquared < 0.01f)
return;
Vector3 relative = flowVelocity - Controller.Body.Velocity.WithZ(0.0f);
Vector3 force = relative * (Controller.Body.Mass * CurrentDrag);
Controller.Body.ApplyForce(force);
}
public override Vector3 UpdateMove( Rotation eyes, Vector3 input )
{
if ( Input.Down( "jump" ) )
{
input += Vector3.Up;
}
return base.UpdateMove( eyes, input );
}
}