Character controller

Godot Version

4.4.1

Question

I tried to do a 3rd person character controller and it works fine if i use the camera's global_basis for the rotation, but when i changed it to work with the mesh's global_basis it doesn't work while i move forward, (AND ONLY FORWARD). I tried to debug it, but got nowhere, (I'm new to godot). I think i need some help with debugging/figuring this one out.

The code in question:

extends CharacterBody3D

enum TRAVEL_MODE { ON_FOOT, ON_BROOM }

var travel_mode: TRAVEL_MODE = TRAVEL_MODE.ON_FOOT

@export_group("Movement")
@export_range(0.0, 100.0) var max_movement_speed: float = 5.0
@export_range(0.0, 100.0) var acceleration: float = 30.0
@export_range(0.0, 100.0) var sprint_multiplier: float = 2.0
@export_range(0.0, 100.0) var rotation_speed: float = 12.0
@export_range(0.0, 100.0) var jump_strength: float = 10.0

const GRAVITY: float = -30.0

@export_group("Camera")
@export_range(0.01, 10.0) var vertical_mouse_sensitivity: float = 0.25
@export_range(0.01, 10.0) var horizontal_mouse_sensitivity: float = 0.25

const TILT_UPPER_LIMIT: float = 30.0
const TILT_LOWER_LIMIT: float = -30.0

var camera_input_direction: Vector2 = Vector2.ZERO
var last_movement_direction: Vector3 = Vector3.BACK

@onready var player_mesh: Node3D = $PlayerMesh
@onready var camera_pivot: Marker3D = $CameraPivot
@onready var spring_arm: SpringArm3D = $CameraPivot/SpringArm3D
@onready var camera: Camera3D = $CameraPivot/SpringArm3D/Camera3D

func _input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_cancel"):
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
	elif event.is_action_pressed("ui_open"):
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
	
	if event.is_action_pressed("change_travel_mode"):
		if travel_mode == TRAVEL_MODE.ON_FOOT:
			travel_mode = TRAVEL_MODE.ON_BROOM
		else:
			travel_mode = TRAVEL_MODE.ON_FOOT

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
		camera_input_direction = event.screen_relative * Vector2(vertical_mouse_sensitivity, horizontal_mouse_sensitivity)

func _physics_process(delta: float) -> void:
	camera_pivot.rotation.x += camera_input_direction.y * delta
	camera_pivot.rotation.x = clamp(camera_pivot.rotation.x, deg_to_rad(TILT_LOWER_LIMIT), deg_to_rad(TILT_UPPER_LIMIT))
	camera_pivot.rotation.y -= camera_input_direction.x * delta
	camera_input_direction = Vector2.ZERO
	
	var raw_input: Vector2 = Input.get_vector("move_left", "move_right", "move_backward", "move_forward")
	var move_direction: Vector3
	match travel_mode:
		TRAVEL_MODE.ON_FOOT:
			var forward: Vector3
			var right: Vector3
			if Input.is_action_pressed("free_look"):
				forward = -player_mesh.global_basis.z
				right = player_mesh.global_basis.x
			else:
				forward = -camera.global_basis.z
				right = camera.global_basis.x
			
			move_direction = forward * raw_input.y + right * raw_input.x
			move_direction.y = 0.0
			move_direction = move_direction.normalized()
			
			var y_velocity: float = velocity.y
			velocity.y = 0.0
			
			var final_speed_max_speed: float = max_movement_speed
			if Input.is_action_pressed("run"):
				final_speed_max_speed *= sprint_multiplier
			
			velocity = velocity.move_toward(move_direction * final_speed_max_speed, acceleration * delta)
			if Input.is_action_just_pressed("jump") and is_on_floor():
				velocity.y += jump_strength
			else:
				velocity.y = y_velocity + GRAVITY * delta
		TRAVEL_MODE.ON_BROOM:
			pass
	
	move_and_slide()
	
	if move_direction.length() > 0.2:
		last_movement_direction = move_direction
	
	var target_angle: float = Vector3.BACK.signed_angle_to(last_movement_direction, Vector3.UP)
	player_mesh.global_rotation.y = lerp_angle(player_mesh.rotation.y, target_angle, rotation_speed * delta)

Maybe your mesh has been rotated so it’s forward or right axis is pointing up, and here you are flattening the y axis.

It’s a 3D game, so the up vector aka the y axis should be always 0, since we don’t want the player to randomly start ascending while moving on the ground.

The problem seems to be with the raw_input & the meshes forward vector somehow not matching (?). Idk.

Yeah, if the player_mesh was rotated it’s forward vector could be pointing up. What does their rotation say in the editor?

At: res://Characters/Player/Scripts/player.gd:110:_physics_process()
Global rotation: (0.0, 0.603319, 0.0)Rotation: (0.0, 0.603319, 0.0)Global basis: [X: (0.823456, 0.0, -0.567379), Y: (0.0, 1.0, 0.0), Z: (0.567379, 0.0, 0.823456)]
   At: res://Characters/Player/Scripts/player.gd:110:_physics_process()
Global rotation: (0.0, 1.231638, 0.0)Rotation: (0.0, 1.231638, 0.0)Global basis: [X: (0.332693, 0.0, -0.943035), Y: (0.0, 1.0, 0.0), Z: (0.943035, 0.0, 0.332693)]
   At: res://Characters/Player/Scripts/player.gd:110:_physics_process()
Global rotation: (0.0, 0.603319, 0.0)Rotation: (0.0, 0.603319, 0.0)Global basis: [X: (0.823456, 0.0, -0.567379), Y: (0.0, 1.0, 0.0), Z: (0.567379, 0.0, 0.823456)]
   At: res://Characters/Player/Scripts/player.gd:110:_physics_process()
Global rotation: (0.0, 1.231638, 0.0)Rotation: (0.0, 1.231638, 0.0)Global basis: [X: (0.332693, 0.0, -0.943035), Y: (0.0, 1.0, 0.0), Z: (0.943035, 0.0, 0.332693)]

It goes between these 2 values no matter what i do.

If anybody can help me fix this, i would appreciate that.
Also here is some footage of what’s happening. (Sorry for low quality, i just made it quickly).

The intended behavior would be for the player to just move forward where it is facing, but it keeps rotating itself for some reason?

ah yeah looks like your target_angle is messing with it. lerp shouldn’t be applied as you are using it, for starters mixing global and local roation,

player_mesh.global_rotation.y = lerp_angle(player_mesh.rotation.y

in addition to accumulating it’s output; x = lerp(x, y, t) per frame is always frame-dependent, and multiplying it by delta actually makes it’s frame-dependency much worse.

Try using this move_toward function adapted for angles

func move_toward_angle(value: float, target: float, delta: float) -> float:
	var diff := fmod(target - value, TAU)
	var dir := signf(diff)
	if absf(diff) > PI:
		dir = -dir # angle closer in opposite direction

	if absf(diff) > delta:
		return value + dir * delta
	else:
		return target

With a rotation speed in radians you could try something like this

player_mesh.global_rotation.y = move_toward_angle(player_mesh.global_rotation.y, target_angle, TAU * delta)

Your target_angle calculation may also give problems, you may want to seperate move_direction and the angle, maybe use raw_input.angle() plus the camera’s rotation.

1 Like

Thank you for the tips & a solution.
It (seems to) work now!

(nvm it still does not work lol)
(Somehow every 2nd frame the rotation of the player gets messed up.)

When i move backwards it rotates the player’s mesh by 180 degree every frame and that 'causes the flickering effect.

Any idea why?

I got it, i am so dumb.

If the player move’s backwards it rotates 180 degrees to face the movement direction & the next frame the backwards direction becomes the original forward direction.

Seems like i have to implement movement differently based on input direction & not mesh direction or something like that.

If anyone wants to know how i fixed it, i just used the CharacterBody3D rotation matrix and not the player mesh’s.

func getForward() -> Vector3:
	if Input.is_action_pressed("free_look"):
		return self.global_basis.z
	else:
		return -camera_pivot.global_basis.z

func getRight() -> Vector3:
	if Input.is_action_pressed("free_look"):
		return -self.global_basis.x
	else:
		return camera_pivot.global_basis.x