I have a character controller that works swimmingly provided you go up slopes or stick to flat ground, but if you go down slopes it bounces all the way down.
My first instinct was to solve this with raycasts, which I did with an easy enough function here:
bool isGrounded () {
Vector3 origin = controller.transform.position - new Vector3 (0f, controller.height / 2f, 0f);
RaycastHit hit;
if (controller.isGrounded) {
return true;
}
if (Physics.Raycast(origin, -Vector3.up, out hit, groundCheckDistance, ~(gameObject.layer))){
controller.Move(new Vector3(0f, -hit.distance, 0f));
return true;
}
return false;
}
Now, this works, but it comes with enough caveats that I’ve determined that it’s not a valid solution at all. They’re as follows:
- The faster the controller is moving (I’m going for old-school 90s FPS style movement, so fast enough this is a problem), the larger the value groundCheckDistance needs to be.
- The larger groundCheckDistance is, the more the player snaps to the ground when coming out of a jump.
- The raycasts rarely hit when on slopes steeper than my 45 degree limit unless I extend them even more, which exacerbates problems 2 and 3.
I tried implementing Garth Smith’s SafeMove function but I couldn’t integrate it nicely into my character controller at all.
Are there any solutions to this predicament? For those interested, here’s my full character controller (with the function currently not active as I can’t abide by its buffoonery). It’s mostly a straight conversion of Zinglish’s Quake 3 movement script but with a couple added features and tweaks.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Rewired;
[RequireComponent(typeof(CharacterController))]
public class FPSController : MonoBehaviour {
public int playerID;
private Player player;
private CharacterController controller;
public Camera FPSCamera;
public float cameraYOffset = 0.5f;
public float mouseXSensitivity = 30f;
public float mouseYSensitivity = 30f;
public float friction = 6f;
public float moveSpeed = 7f;
public float runAcceleration = 14f;
public float runDecceleration = 10f;
public float airAcceleration = 2f;
public float airDecceleration = 2f;
public float airControl = 0.3f;
public float strafeSpeed = 1f;
public float strafeAcceleration = 50f;
public float jumpSpeed = 8.0f;
public float moveScale = 1.0f;
public float wishSpeedGlobal = 2f;
public float groundCheckDistance = 0.25f;
public bool isGrappling = false;
public bool isDead = false;
public AudioClip[] jumpSounds;
AudioSource audio;
private float cameraRotX = 0f;
private float cameraRotY = 0f;
private Vector3 moveDirection = Vector3.zero;
private Vector3 moveDirectionNorm = Vector3.zero;
public Vector3 playerVelocity = Vector3.zero;
private float playerTopVelocity = 0f;
private bool wishJump = false;
private float playerFriction = 0f;
private Vector3 contactPoint;
public class Cmd {
public float forwardMove;
public float rightMove;
public float upMove;
}
protected Cmd cmd;
void Start () {
player = ReInput.players.GetPlayer (playerID);
controller = GetComponent<CharacterController> ();
FPSCamera = GameObject.FindGameObjectWithTag ("PlayerCamera").GetComponent<Camera> ();
Cursor.visible = false;
Cursor.lockState = CursorLockMode.Locked;
cmd = new Cmd();
audio = GetComponent<AudioSource> ();
}
public float cmdScale() {
float max;
float total;
max = Mathf.Abs(cmd.forwardMove);
if (Mathf.Abs(cmd.rightMove) > max)
max = Mathf.Abs(cmd.rightMove);
if (max <= 0f) // Check (!max in js)
return 0f;
total = Mathf.Sqrt(cmd.forwardMove * cmd.forwardMove + cmd.rightMove * cmd.rightMove);
return moveSpeed * max / (moveScale * total);
}
void PlayJumpSound() {
if (audio.isPlaying) {
return;
}
audio.clip = jumpSounds [Random.Range (0, jumpSounds.Length)];
audio.Play();
}
//Movement
void SetMovementDir() {
cmd.forwardMove = player.GetAxisRaw ("MoveForward");
cmd.rightMove = player.GetAxisRaw ("Strafe");
}
void QueueJump() {
if (player.GetButtonDown ("Jump") && !wishJump) {
wishJump = true;
}
if (player.GetButtonUp ("Jump")) {
wishJump = false;
}
}
void Accelerate(Vector3 wishDir, float wishSpeed, float accel) {
float addSpeed;
float accelSpeed;
float currentSpeed;
currentSpeed = Vector3.Dot (playerVelocity, wishDir);
addSpeed = wishSpeed - currentSpeed;
if (addSpeed <= 0f) {
return;
}
accelSpeed = accel * Time.deltaTime * wishSpeed;
if (accelSpeed > addSpeed) {
accelSpeed = addSpeed;
}
playerVelocity.x += accelSpeed * wishDir.x;
playerVelocity.z += accelSpeed * wishDir.z;
}
void AirControl(Vector3 wishDir, float wishSpeed) {
float zSpeed;
float speed;
float dot;
float k;
if (cmd.forwardMove == 0f || wishSpeed == 0f) {
return;
}
zSpeed = playerVelocity.y;
playerVelocity.y = 0f;
speed = playerVelocity.magnitude;
playerVelocity.Normalize ();
dot = Vector3.Dot (playerVelocity, wishDir);
k = 32f;
k *= airControl * dot * dot * Time.deltaTime;
if (dot > 0f) {
playerVelocity = new Vector3 (playerVelocity.x * speed * wishDir.x * k,
playerVelocity.y * speed * wishDir.y * k,
playerVelocity.z * speed * wishDir.z * k);
playerVelocity.Normalize ();
}
playerVelocity *= speed;
playerVelocity.y = zSpeed;
}
void AirMove() {
Vector3 wishDir;
float wishVelocity = airAcceleration;
float accel;
float scale = cmdScale ();
SetMovementDir ();
wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
wishDir = transform.TransformDirection (wishDir);
wishDir.Normalize ();
moveDirection = wishDir;
float wishSpeed = wishDir.magnitude;
wishSpeed *= moveSpeed;
float wishSpeed2 = wishSpeed;
if (Vector3.Dot (playerVelocity, wishDir) < 0f) {
accel = airDecceleration;
} else {
accel = airAcceleration;
}
if (cmd.forwardMove == 0f && cmd.rightMove != 0f) {
if (wishSpeed > wishSpeedGlobal) {
wishSpeed = wishSpeedGlobal;
}
accel = strafeAcceleration;
Accelerate (wishDir, wishSpeed, accel);
if (airControl != 0f) {
AirControl (wishDir, wishSpeed2);
}
} else {
Accelerate (wishDir, wishSpeed, accel);
}
if ((controller.collisionFlags == CollisionFlags.Above) == true) {
if (playerVelocity.y > 0) {
playerVelocity.y = 0f;
}
}
playerVelocity.y += Physics.gravity.y * Time.deltaTime;
}
void ApplyFriction (){
Vector3 vec = playerVelocity;
float speed;
float newSpeed;
float control;
float drop;
vec.y = 0f;
speed = vec.magnitude;
drop = 0f;
if (controller.isGrounded) {
control = speed < runDecceleration ? runDecceleration : speed;
drop = control * friction * Time.deltaTime;
}
newSpeed = speed - drop;
playerFriction = newSpeed;
if (newSpeed < 0f) {
newSpeed = 0f;
}
if (speed > 0f) {
newSpeed /= speed;
}
playerVelocity.x *= newSpeed;
playerVelocity.z *= newSpeed;
}
void GroundMove() {
Vector3 wishDir;
if (!wishJump) {
ApplyFriction ();
}
float scale = cmdScale ();
SetMovementDir ();
wishDir = new Vector3 (cmd.rightMove, 0f, cmd.forwardMove);
wishDir = transform.TransformDirection (wishDir);
wishDir.Normalize ();
moveDirection = wishDir;
float wishSpeed = wishDir.magnitude;
wishSpeed *= moveSpeed;
Accelerate (wishDir, wishSpeed, runAcceleration);
playerVelocity.y = 0f;
if (wishJump) {
playerVelocity.y = jumpSpeed;
wishJump = false;
PlayJumpSound();
}
}
void SafeMove(Vector2 velocity) {
Vector3 displacement;
displacement.x = velocity.x * Time.deltaTime;
displacement.y = 0f;
displacement.z = -controller.transform.position.z;
controller.Move (displacement);
displacement.y = velocity.y * Time.deltaTime;
if (-Mathf.Abs (displacement.x) < displacement.y && displacement.y < 0) {
displacement.y = -Mathf.Abs (displacement.x) - 0.001f;
}
displacement.z = 0f;
displacement.x = 0f;
controller.Move (displacement);
}
bool isGrounded () {
Vector3 origin = controller.transform.position - new Vector3 (0f, controller.height / 2f, 0f);
RaycastHit hit;
if (controller.isGrounded) {
return true;
}
if (Physics.Raycast(origin, -Vector3.up, out hit, groundCheckDistance, ~(gameObject.layer))){
controller.Move(new Vector3(0f, -hit.distance, 0f));
return true;
}
return false;
}
void Update () {
if (!isDead) {
cameraRotX -= player.GetAxisRaw ("LookVertical") * mouseXSensitivity * Time.deltaTime;
cameraRotY += player.GetAxisRaw ("LookHorizontal") * mouseYSensitivity * Time.deltaTime;
}
if (cameraRotX < -90f) {
cameraRotX = -90f;
} else if (cameraRotX > 90f) {
cameraRotX = 90f;
}
transform.rotation = Quaternion.Euler (0f, cameraRotY, 0f);
FPSCamera.transform.rotation = Quaternion.Euler (cameraRotX, cameraRotY, 0f);
QueueJump ();
if (controller.isGrounded && !isGrappling && !isDead) {
GroundMove ();
} else if (!controller.isGrounded && !isGrappling && !isDead) {
AirMove ();
}
if (isDead) {
playerVelocity = new Vector3 (0f,0f,0f);
}
controller.Move (playerVelocity * Time.deltaTime);
}
}