A Component that implements a physics character controller. It stores collision capsule size and movement parameters, traces the scene to slide and step, applies acceleration and friction, classifies ground contact, and contains unstuck logic and a Move/MoveTo API.
namespace KOTH;
// TODO : visit, CharacterControllerHelper seems like a mess
[Category("Physics")]
[Icon("directions_walk")]
[EditorHandle("materials/gizmo/charactercontroller.png")]
public class CharacterController : Component
{
[Range(0, 200)]
[Property] public float Radius { get; set; } = 16.0f;
[Range(0, 200)]
[Property] public float Height { get; set; } = 64.0f;
[Range(0, 50)]
[Property] public float StepHeight { get; set; } = 18.0f;
[Range(0, 90)]
[Property] public float GroundAngle { get; set; } = 45.0f;
[Range(0, 64)]
[Property] public float Acceleration { get; set; } = 10.0f;
/// <summary>
/// When jumping into walls, should we bounce off or just stop dead?
/// </summary>
[Range(0, 1)]
[Property] public float Bounciness { get; set; } = 0.3f;
[Property, Group("Collision")]
public TagSet IgnoreLayers { get; set; } = new();
public BBox BoundingBox => new BBox(new Vector3(-Radius, -Radius, 0), new Vector3(Radius, Radius, Height));
[Sync]
public Vector3 Velocity { get; set; }
[Sync]
public bool IsOnGround { get; set; }
public GameObject GroundObject { get; set; }
public Collider GroundCollider { get; set; }
protected override void DrawGizmos()
{
Gizmo.Draw.LineBBox(BoundingBox);
}
/// <summary>
/// Add acceleration to the current velocity.
/// No need to scale by time delta - it will be done inside.
/// </summary>
public void Accelerate(Vector3 vector)
{
Velocity = Velocity.WithAcceleration(vector, Acceleration * Time.Delta);
}
/// <summary>
/// Apply an amount of friction to the current velocity.
/// No need to scale by time delta - it will be done inside.
/// </summary>
public void ApplyFriction(float frictionAmount, float stopSpeed = 140.0f)
{
var speed = Velocity.Length;
if (speed < 0.01f) return;
// Bleed off some speed, but if we have less than the bleed
// threshold, bleed the threshold amount.
float control = (speed < stopSpeed) ? stopSpeed : speed;
// Add the amount to the drop amount.
var drop = control * Time.Delta * frictionAmount;
// scale the velocity
float newspeed = speed - drop;
if (newspeed < 0) newspeed = 0;
if (newspeed == speed) return;
newspeed /= speed;
Velocity *= newspeed;
}
SceneTrace BuildTrace(Vector3 from, Vector3 to) => BuildTrace(Scene.Trace.Ray(from, to));
SceneTrace BuildTrace(SceneTrace source)
{
var trace = source.Size(BoundingBox).IgnoreGameObjectHierarchy(GameObject);
return trace.WithoutTags(IgnoreLayers);
}
/// <summary>
/// Trace the controller's current position to the specified delta
/// </summary>
public SceneTraceResult TraceDirection(Vector3 direction)
{
return BuildTrace(GameObject.WorldPosition, GameObject.WorldPosition + direction).Run();
}
void Move(bool WithStep)
{
if (WithStep && IsOnGround)
{
Velocity = Velocity.WithZ(0);
}
if (Velocity.Length < 0.001f)
{
Velocity = Vector3.Zero;
return;
}
var CharacterMover = new CharacterControllerHelper(BuildTrace(WorldPosition, WorldPosition), WorldPosition, Velocity);
CharacterMover.Bounce = 0;
CharacterMover.MaxStandableAngle = 70f;
if (WithStep && IsOnGround)
{
CharacterMover.TryMoveWithStep(Time.Delta, StepHeight);
// CharacterMover.TryMove(Time.Delta);
}
else
{
CharacterMover.TryMove(Time.Delta);
}
WorldPosition = CharacterMover.Position;
Velocity = CharacterMover.Velocity;
}
const float MinSurfVelocity = 1200f;
void CategorizePosition()
{
var Position = WorldPosition;
var point = Position + Vector3.Down * 2;
var vBumpOrigin = Position;
var wasOnGround = IsOnGround;
// TODO : check we're on ramp (with scene rays?)
if (!IsOnGround && Velocity.Length >= MinSurfVelocity)
{
ClearGround();
return;
}
//
// trace down one step height if we're already on the ground "step down". If not, search for floor right below us
// because if we do StepHeight we'll snap that many units to the ground
//
point.z -= wasOnGround ? StepHeight : 0.1f;
var pm = BuildTrace(vBumpOrigin, point).Run();
//
// we didn't hit - or the ground is too steep to be ground
//
if (!pm.Hit || Vector3.GetAngle(Vector3.Up, pm.Normal) > GroundAngle)
{
ClearGround();
return;
}
//
// we are on ground
//
IsOnGround = true;
GroundObject = pm.GameObject;
GroundCollider = pm.Shape?.Collider as Collider;
//
// move to this ground position, if we moved, and hit
//
if (wasOnGround && !pm.StartedSolid && pm.Fraction > 0.0f && pm.Fraction < 1.0f)
{
WorldPosition = pm.EndPosition + pm.Normal * 0.01f;
}
}
/// <summary>
/// Disconnect from ground and punch our velocity. This is useful if you want the player to jump or something.
/// </summary>
public void Punch(in Vector3 amount)
{
ClearGround();
Velocity += amount;
}
void ClearGround()
{
IsOnGround = false;
GroundObject = default;
GroundCollider = default;
}
/// <summary>
/// Move a character, with this velocity
/// </summary>
public void Move()
{
if (TryUnstuck())
return;
if (IsOnGround)
{
Move(true);
}
else
{
Move(false);
}
CategorizePosition();
}
/// <summary>
/// Move from our current position to this target position, but using tracing an sliding.
/// This is good for different control modes like ladders and stuff.
/// </summary>
public void MoveTo(Vector3 targetPosition, bool useStep)
{
if (TryUnstuck())
return;
var pos = WorldPosition;
var delta = targetPosition - pos;
var mover = new CharacterControllerHelper(BuildTrace(pos, pos), pos, delta);
mover.MaxStandableAngle = GroundAngle;
if (useStep)
{
mover.TryMoveWithStep(1.0f, StepHeight);
}
else
{
mover.TryMove(1.0f);
}
WorldPosition = mover.Position;
}
int _stuckTries;
bool TryUnstuck()
{
var result = BuildTrace(WorldPosition, WorldPosition).Run();
if (!result.StartedSolid)
{
_stuckTries = 0;
return false;
}
Log.Info($"attempting unstuck for {GameObject} against {result.GameObject}");
if (result.GameObject.Tags.Contains("player_collider"))
{
// return false;
}
//using ( Gizmo.Scope( "unstuck", Transform.World ) )
//{
// Gizmo.Draw.Color = Gizmo.Colors.Red;
// Gizmo.Draw.LineBBox( BoundingBox );
//}
int AttemptsPerTick = 20;
for (int i = 0; i < AttemptsPerTick; i++)
{
var pos = WorldPosition + Vector3.Random.Normal * (((float)_stuckTries) / 2.0f);
// First try the up direction for moving platforms
if (i == 0)
{
pos = WorldPosition + Vector3.Up * 2;
}
result = BuildTrace(pos, pos).Run();
if (!result.StartedSolid)
{
//Log.Info( $"unstuck after {_stuckTries} tries ({_stuckTries * AttemptsPerTick} tests)" );
WorldPosition = pos;
return false;
}
}
_stuckTries++;
return true;
}
}