Dec 02, 2018

3D Movement is Hard


I’m a programmer of over a decade with 3D math & gamedev experience. It took me 8+ hours over 4+ weekends to code basic movement with a 3rd person camera and double-jumping to work in Unity. Countless failures & dead-ends. Background doesn’t matter; gamedev takes practice.

I even cheated! It’s not nearly as from-scratch as it could be! It has both a RigidBody, PlayerController, AND uses InControl for the annoying parts. It even uses Transform.LookAt and Transform.Rotate! I referred to other people’s code to get started with jumping! I’ve implemented the basic parts of PlayerController in the past. RigidBody is harder to do from scratch. Control coding is extremely frustrating in Unity without a framework. There were plenty of things here I thought I knew how to do but when I sat down to do it I felt totally lost.

Here’s what I’ve got so far. It’s far from perfect, but that’s kinda the point.

using UnityEngine;
using UnityEngine.SceneManagement;
using InControl;

public class PlayerInput : MonoBehaviour
{
  private MyDebugActions debugActions;
  private MyCharacterActions characterActions;
  private CharacterController controller;

  // General Movement variables
  public float Speed = 10f;
  public float Gravity = -1f;
  private Vector3 velocity = Vector3.zero;

  // Jumping variables
  public float JumpPower = 20f;
  public int MaxJumps = 2;
  public float AirJumpCoolDown = 0f;
  public float GroundedJumpCoolDown = 0f;
  private int jumpCount = 0;
  private float nextJumpAllowed = 0f;
  private bool wasInAir = false;

  // Camera variables
  public Transform PlayerCamera;
  public Transform CameraFocusPoint;
  public float CameraDistance = 15f;
  public float CameraSpeed = 10f;
  public float CameraHeightOffset = 1.5f;
  private Vector2 cameraPitchYaw = Vector3.zero;

  private void Start()
  {
    controller = GetComponent<CharacterController>();
    velocity = Vector3.zero;

    debugActions = new MyDebugActions();

    debugActions.ResetScene.AddDefaultBinding( Key.R );
    debugActions.ResetScene.AddDefaultBinding( Key.Q );

    characterActions = new MyCharacterActions();

    characterActions.Up.AddDefaultBinding( Key.UpArrow );
    characterActions.Up.AddDefaultBinding( Key.W );
    characterActions.Up.AddDefaultBinding( InputControlType.DPadUp );
    characterActions.Down.AddDefaultBinding( Key.DownArrow );
    characterActions.Down.AddDefaultBinding( Key.S );
    characterActions.Down.AddDefaultBinding( InputControlType.DPadDown );
    characterActions.Left.AddDefaultBinding( Key.LeftArrow );
    characterActions.Left.AddDefaultBinding( Key.A );
    characterActions.Left.AddDefaultBinding( InputControlType.DPadLeft );
    characterActions.Right.AddDefaultBinding( Key.RightArrow );
    characterActions.Right.AddDefaultBinding( Key.D );
    characterActions.Right.AddDefaultBinding( InputControlType.DPadRight );

    characterActions.CameraUp.AddDefaultBinding( Mouse.PositiveY );
    characterActions.CameraUp.AddDefaultBinding( Key.I );
    characterActions.CameraDown.AddDefaultBinding( Mouse.NegativeY );
    characterActions.CameraDown.AddDefaultBinding( Key.K );
    characterActions.CameraLeft.AddDefaultBinding( Mouse.NegativeX );
    characterActions.CameraLeft.AddDefaultBinding( Key.J );
    characterActions.CameraRight.AddDefaultBinding( Mouse.PositiveX );
    characterActions.CameraRight.AddDefaultBinding( Key.L );

    characterActions.Jump.AddDefaultBinding( Key.Space );
    characterActions.Jump.AddDefaultBinding( InputControlType.Action1 );

    characterActions.Interact.AddDefaultBinding( Key.X );
    characterActions.Interact.AddDefaultBinding( InputControlType.Action2 );
  }

  private void DebugUpdate()
  {
    if (debugActions.ResetScene.WasPressed)
    {
      SceneManager.LoadScene(SceneManager.GetActiveScene().GetHashCode());
    }
    if (debugActions.Quit.WasPressed)
    {
      Application.Quit();
    }
  }

  private void Update()
  {
    DebugUpdate();

    // Update camera angles. Movement is relative to the direction the camera is facing.
    UpdateCamera( characterActions.CameraMove.Value.x, characterActions.CameraMove.Value.y );

    // Reset velocity while on ground.
    // When we're falling, we need to be able to keep gravity between frames.
    if (controller.isGrounded)
    {
      velocity = Vector3.zero;
    }

    // Update velocity along the X/Z axes
    UpdateMove( characterActions.Move.Value.x, characterActions.Move.Value.y );

    // Change player rotation if we're moving
    if (velocity.sqrMagnitude > 0.1f)
    {
      Vector3 xzVelocity = new Vector3(velocity.x, 0, velocity.z);
      transform.LookAt(transform.position + xzVelocity.normalized); // update angle before giving a y value.
    }

    // Update velocity along the Y axis
    UpdateJump(characterActions.Jump.WasPressed);

    // Interaction
    if (characterActions.Interact.WasPressed)
    {
      PerformInteract();
    }

    // Apply velocity
    controller.Move(velocity * Time.deltaTime);
  }

  private void PerformInteract()
  {
    Debug.Log("Interact");
  }

  private void UpdateCamera(float leftRight, float upDown)
  {
    if (!PlayerCamera || !CameraFocusPoint)
    {
      return;
    }
    CameraFocusPoint.localPosition = Vector3.up * CameraHeightOffset;
    // TODO this should do a trace instead of my haxx
    PlayerCamera.parent = null;
    cameraPitchYaw.y += leftRight * CameraSpeed * Time.deltaTime;
    cameraPitchYaw.x += upDown * CameraSpeed * Time.deltaTime;
    cameraPitchYaw.x = Mathf.Clamp(cameraPitchYaw.x, -40, 40);
    // note the up-down angles are not working the way I expect. the rotate about the wrong axis
    CameraFocusPoint.eulerAngles = Vector3.zero;
    CameraFocusPoint.Rotate(Vector3.up, cameraPitchYaw.y); // leftRight
    CameraFocusPoint.Rotate(Vector3.right, cameraPitchYaw.x); // upDown
    PlayerCamera.position = CameraFocusPoint.position + CameraFocusPoint.forward * CameraDistance;
    PlayerCamera.LookAt(CameraFocusPoint);
  }

  private void UpdateJump(bool pressed)
  {
    // if we hit the ground reset double jump count
    bool canJump = true;
    bool isAirJump = false;
    if (controller.isGrounded)
    {
      // reset air jumps
      jumpCount = 0;
      isAirJump = false;
      // Prevent bunnyhop upon landing
      if (wasInAir)
      {
        nextJumpAllowed = Time.time + GroundedJumpCoolDown;
      }
    }
    else
    {
      isAirJump = true;
    }
    // if we've hit our max jump count since we last touched the ground, don't jump
    if (jumpCount >= MaxJumps)
    {
      canJump = false;
    }
    // if either of the cooldowns are active, don't jump
    if (Time.time < nextJumpAllowed)
    {
      canJump = false;
    }
    if (pressed)
    {
      if (canJump)
      {
        // Prevent air jumping too soon after this
        nextJumpAllowed = Time.time + AirJumpCoolDown;
        // If we're jumping in the air, make sure we don't have to work against gravity
        if (isAirJump)
        {
          velocity.y = 0;
        }
        jumpCount++;
        velocity.y += JumpPower;
      }
    }
    velocity.y += Gravity;
    wasInAir = !controller.isGrounded;
  }

  private void UpdateMove( float leftRight, float forwardBack )
  {
    // Get the direction we're trying to move relative to the camera angles
    Vector3 direction = PlayerCamera.forward * forwardBack + PlayerCamera.right * leftRight;
    // Eliminate the Y-component so we don't apply velocity along the Y axis. That's what jumping is for!
    direction.y = 0;
    // Normalize after removing Y so we don't get a non-unit vector when we multiply by speed.
    direction = direction.normalized;
    //  It's important not to pollute the Y component, so make a temp variable for velocity along the X-Z plane.
    Vector3 xzVelocity = direction * Speed;
    // Controls are tight - set velocity directly.
    velocity.x = xzVelocity.x;
    velocity.z = xzVelocity.z;
  }
}

Newer

Comments aren't enabled.
You're welcome to contact me though.