Thanks! The car moves by pretty much just setting the velocity to the main nodes rotation times the current speed, and this is my hierarchy:
by having all wheels on the ground, i just mean so that the car’s model is rotated to look like its actually placed on the ground, instead of just floating above or clipping into slopes. This is the main script if you need it!
extends CharacterBody3D
@onready var boost_level_3l: GPUParticles3D = $Model/Particles/BoostLevel3L
@onready var boost_level_3r: GPUParticles3D = $Model/Particles/BoostLevel3R
@onready var boost_level_2l: GPUParticles3D = $Model/Particles/BoostLevel2L
@onready var boost_level_2r: GPUParticles3D = $Model/Particles/BoostLevel2R
@onready var boost_level_1l: GPUParticles3D = $Model/Particles/BoostLevel1L
@onready var boost_level_1r: GPUParticles3D = $Model/Particles/BoostLevel1R
@export var MAX_STEP_UP := 0.5 # Maximum height in meters the player can step up.
@export var MAX_STEP_DOWN := -0.5 # Maximum height in meters the player can step down.
enum STATE {
REGULAR,
DRIFTING,
SPINOUT
}
const SPEED := 15.0
const JUMP_VELOCITY := 9.5
const ACCEL_SPEED := 2.0
const DECEL_SPEED := 3.5
const TURN_SPEED := 6.0
const TURN_ACCEL_SPEED := 5.0
const DRIFT_TURN_ACCEL_SPEED := 5.0
const GRAVITY = -18.0
const DRIFT_TURN_FORCE := 85.0
const DRIFT_BOOST_DAMPING := 15.0
const DRIFT_BOOST_MULTIPLIER := 6.0
var current_speed := 0.0
var steer_input := 0.0
var accel_input := 0.0
var drift_direction := 0
var drift_boost := 0.0
var boost_velocity := Vector3.ZERO
var cur_state = STATE.REGULAR
var wish_dir := Vector3.ZERO # Player input (WASD) direction
var vertical := Vector3(0, 1, 0) # Shortcut for converting vectors to vertical
var horizontal := Vector3(1, 0, 1) # Shortcut for converting vectors to horizontal
func _physics_process(delta: float) -> void:
update_model(delta)
match cur_state:
STATE.REGULAR:
regular_state(delta)
STATE.DRIFTING:
drift_state(delta)
wish_dir = (transform.basis.z * current_speed).normalized()
move_and_step(delta)
func move_and_step(delta : float):
stair_step_up()
move_and_slide()
func enter_state(new_state : STATE):
cur_state = new_state
match new_state:
STATE.DRIFTING:
drift_direction = -sign(Input.get_axis("left", "right"))
func regular_state(delta : float) -> void:
handle_gravity(delta)
handle_floor_collision()
handle_jump()
update_inputs(delta)
var rotation_amount = steer_input * sign(current_speed) * TURN_SPEED * Vector2(velocity.x, velocity.z).length()
rotation_degrees.y += rotation_amount * delta
$Model.rotation_degrees.y = rad_to_deg(
lerp_angle(
deg_to_rad($Model.rotation_degrees.y),
(rotation_amount * 0.003),
delta * 5
)
)
handle_drift_boost()
if accel_input != 0:
current_speed = lerp(current_speed, SPEED * accel_input, ACCEL_SPEED * delta)
else:
current_speed = lerp(current_speed, 0.0, DECEL_SPEED * delta)
var direction := transform.basis.z
velocity.x = direction.x * current_speed
velocity.z = direction.z * current_speed
velocity += boost_velocity
boost_velocity = boost_velocity.move_toward(Vector3.ZERO, DRIFT_BOOST_DAMPING * delta)
if Input.is_action_pressed("drift") and abs(current_speed) > SPEED/1.5 and sign(Input.get_axis("left", "right")) != 0 and is_on_floor() and accel_input == -1:
enter_state(STATE.DRIFTING)
func drift_state(delta : float) -> void:
handle_gravity(delta)
handle_floor_collision()
handle_jump()
update_inputs(delta)
var rotation_amount = (drift_direction*DRIFT_TURN_FORCE) + (steer_input * sign(current_speed) * TURN_SPEED * 7)
if not is_on_floor(): rotation_amount *= 0.8
rotation_degrees.y += rotation_amount * delta
$Model.rotation_degrees.y = rad_to_deg(
lerp_angle(
deg_to_rad($Model.rotation_degrees.y),
deg_to_rad((drift_direction * 3) + (rotation_amount * 0.3)),
delta * 5
)
)
drift_boost = move_toward(drift_boost, 3, (0.04 + abs(steer_input)) * delta)
current_speed = lerp(current_speed, -SPEED, ACCEL_SPEED * delta)
if not Input.is_action_pressed("drift"):
enter_state(STATE.REGULAR)
var direction := transform.basis.z
velocity.x = direction.x * current_speed
velocity.z = direction.z * current_speed
velocity += boost_velocity
boost_velocity = boost_velocity.lerp(Vector3.ZERO, DRIFT_BOOST_DAMPING * delta)
func handle_gravity(delta : float) -> void:
if is_on_floor(): return
velocity.y += GRAVITY * delta
func handle_drift_boost():
if not is_on_floor() or drift_boost == 0: return
boost_velocity = velocity.normalized() * snapped(drift_boost, 1) * DRIFT_BOOST_MULTIPLIER
boost_velocity.y = 0
steer_input = 0
drift_boost = 0
func handle_jump() -> void:
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = JUMP_VELOCITY
func handle_floor_collision() -> void:
if is_on_floor():
velocity.y = 0
velocity = velocity.slide(get_floor_normal())
func update_model(delta : float) -> void:
if not is_on_floor():
$Model.rotation.x = lerp_angle($Model.rotation.x, velocity.normalized().y, 4 * delta)
else:
var normal = get_floor_normal()
var x_cross_normal = global_basis.x.cross(normal).normalized()
var forward_target = global_position + x_cross_normal
$Model.look_at(forward_target, normal)
$Model/WheelTopL.rotation.y = -steer_input * 0.5
$Model/WheelTopR.rotation.y = -steer_input * 0.5
$Model/WheelTopL.rotation.x += current_speed * delta
$Model/WheelTopR.rotation.x += current_speed * delta
$Model/WheelBottomL.rotation.x += current_speed * delta
$Model/WheelBottomR.rotation.x += current_speed * delta
if boost_velocity.length() <= 5:
boost_level_3l.emitting = false
boost_level_3r.emitting = false
boost_level_2l.emitting = false
boost_level_2r.emitting = false
boost_level_1l.emitting = false
boost_level_1r.emitting = false
if drift_boost >= 3:
boost_level_3l.emitting = true
boost_level_3r.emitting = true
if drift_boost >= 2:
boost_level_2l.emitting = true
boost_level_2r.emitting = true
if drift_boost >= 1:
boost_level_1l.emitting = true
boost_level_1r.emitting = true
func update_inputs(delta : float) -> void:
if cur_state == STATE.DRIFTING:
steer_input = lerp(steer_input, Input.get_axis("left", "right"), DRIFT_TURN_ACCEL_SPEED * delta)
else:
steer_input = lerp(steer_input, Input.get_axis("left", "right"), TURN_ACCEL_SPEED * delta)
accel_input = Input.get_axis("up", "down")
# Function: Handle walking up stairs
func stair_step_up():
if wish_dir == Vector3.ZERO:
return
if velocity.y > 0.1: return
# 0. Initialize testing variables
var body_test_params = PhysicsTestMotionParameters3D.new()
var body_test_result = PhysicsTestMotionResult3D.new()
var test_transform = global_transform ## Storing current global_transform for testing
var distance = wish_dir * 0.1 ## Distance forward we want to check
body_test_params.from = self.global_transform ## Self as origin point
body_test_params.motion = distance ## Go forward by current distance
# Pre-check: Are we colliding?
if !PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result):
## If we don't collide, return
return
# 1. Move test_transform to collision location
var remainder = body_test_result.get_remainder() ## Get remainder from collision
test_transform = test_transform.translated(body_test_result.get_travel()) ## Move test_transform by distance traveled before collision
# 2. Move test_transform up to ceiling (if any)
var step_up = MAX_STEP_UP * vertical
body_test_params.from = test_transform
body_test_params.motion = step_up
PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result)
test_transform = test_transform.translated(body_test_result.get_travel())
# 3. Move test_transform forward by remaining distance
body_test_params.from = test_transform
body_test_params.motion = remainder
PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result)
test_transform = test_transform.translated(body_test_result.get_travel())
# 3.5 Project remaining along wall normal (if any)
## So you can walk into wall and up a step
if body_test_result.get_collision_count() != 0:
remainder = body_test_result.get_remainder().length()
### Uh, there may be a better way to calculate this in Godot.
var wall_normal = body_test_result.get_collision_normal()
var dot_div_mag = wish_dir.dot(wall_normal) / (wall_normal * wall_normal).length()
var projected_vector = (wish_dir - dot_div_mag * wall_normal).normalized()
body_test_params.from = test_transform
body_test_params.motion = remainder * projected_vector
PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result)
test_transform = test_transform.translated(body_test_result.get_travel())
# 4. Move test_transform down onto step
body_test_params.from = test_transform
body_test_params.motion = MAX_STEP_UP * -vertical
# Return if no collision
if !PhysicsServer3D.body_test_motion(self.get_rid(), body_test_params, body_test_result):
return
test_transform = test_transform.translated(body_test_result.get_travel())
# 5. Check floor normal for un-walkable slope
var surface_normal = body_test_result.get_collision_normal()
if (snappedf(surface_normal.angle_to(vertical), 0.001) > floor_max_angle):
return
# 6. Move player up
var global_pos = global_position
var step_up_dist = test_transform.origin.y - global_pos.y
#velocity.y = 0
global_pos.y = test_transform.origin.y
global_position = global_pos