S K Ä R V A

Dev Log 2

Taking Control

Root Cause is a 6DOF shooter. If you are not sure what that means, 6DOF means 6 Degrees of Freedom; linear movement on 3 axis and rotation around 3 axis, totalling 6. Old games like Descent or the cyberspace portions of System Shock are good examples of this type of flight control. But how do I implement this type of control in Godot?

Let’s first establish what mechanics I am looking for from the controller:

Pretty short and simple scope. I want to be able to fly around and shoot but have a little bit of acceleration and deceleration to motion from inertia. I also want the player to be pushed around by certain weapons or from collisions, but not have to fight much to maintain control over the ship.

My lack of game dev experince initially led me to think CharacterBody is the go-to for any kind of player controlled physics body so I should use that, while a rigid body is a set-and-forget kind of object like boxes and ragdolls not for fine control.

CharacterBody3D

CharacterBody is a physics body that dev has full control and responsibility over, including when to update in the simulation. This means any kind of physical feedback I want the character to have, from input or collisions or whatever else, I need to pretty much implement the forces of that myself. In my case, I had very little physical interaction to worry about; pretty just ship control, and collisions with the world, other ships, and weapon fire. So the plan was that I would use the strength of the movement input actions in _physics_process to directly effect the acceleration, and then use that, along with any opposing deceleration that was needed, to update the velocity property that in built into the CharacterBody3D and call move_and_slide to have it commit a step, or tick, in the simulation.

It wasn’t too complex to get the initial control of the ship done overall though I felt like the code was messy for what it accomplished, and I didn’t have high confidence it was the most efficient way to get it all functioning. I ensured I used the local transform on the input to have movement be based on the ship’s current orientation in the world. Lots of work went into getting inertia working right as I needed to check inputs and apply opposing velocity based on the orientation if no input for that particular axis was detected that frame, including with rotation. For a decent amount of time there was a bug with changing direction and the intertia swapping with it, so I was moving left and switched to pressing right, instead of having some delay before the ship really started moving in the direction being input, it just immediately had all that inertia in the new direction. Eventually I got it fixed though I don’t really recall looking and implementing a fix! That was the last piece to have movement feeling good enough for a prototype stage.

extends PlayerState


@export_range(0, 10, 0.1) var move_speed : float = 5.0
@export_range(0, 20, 0.1) var move_acceleration: float = 10.0
@export_range(0, 0.1, 0.001) var rotation_speed : float = 0.05
@export_range(0, 2, 0.1) var rotation_acceleration: float = 1.0

var last_rotation := Vector3.ZERO
var rotation_weight := 0.0
var view_rotation := Vector3.ZERO


func _physics_process(delta: float) -> void:
	var move_direction := _player.input.movement_direction
	var rotation_direction := _player.input.rotation_direction

	apply_rotation(rotation_direction, delta)
	apply_movement(move_direction, delta)

	if _player.velocity.length() <= 0.01 and view_rotation.length() <= 0.01:
		_state_machine.transition_to("Idle")


func apply_movement(movement_direction: Vector3, delta: float) -> void:
	var forward := _player.transform.basis.z * movement_direction.z
	var up := _player.transform.basis.y * movement_direction.y
	var right := _player.transform.basis.x * movement_direction.x

	movement_direction = forward + up + right

	var new_velocity := _player.velocity + (movement_direction * move_acceleration * delta)

	if new_velocity.length() > move_speed:
        new_velocity = new_velocity.normalized() * move_speed
	if movement_direction.x == 0.0 and abs(new_velocity.x) > 0.01:
		new_velocity.x = _player.velocity.x + -_player.velocity.x * 
		                    (move_acceleration * 0.5) * delta
	if movement_direction.y == 0.0 and abs(new_velocity.y) > 0.01:
		new_velocity.y = _player.velocity.y + -_player.velocity.y * 
		                    (move_acceleration * 0.5) * delta
	if movement_direction.z == 0.0 and abs(new_velocity.z) > 0.01:
		new_velocity.z = _player.velocity.z + -_player.velocity.z * 
		                    (move_acceleration * 0.5) * delta

	_player.velocity = new_velocity

	_player.move_and_slide()


func apply_rotation(rotation_direction: Vector3, delta: float) -> void:
	if !rotation_direction.is_equal_approx(last_rotation):
		rotation_weight = 0.0
	rotation_weight = clamp(rotation_weight + rotation_acceleration * delta, 0.0, 1.0)
	last_rotation = rotation_direction

	view_rotation.x = lerp_angle(view_rotation.x, 
	                    rotation_direction.x * rotation_speed, rotation_weight)
	view_rotation.y = lerp_angle(view_rotation.y, 
	                    rotation_direction.y * rotation_speed, rotation_weight)
	view_rotation.z = lerp_angle(view_rotation.z, 
	                    rotation_direction.z * rotation_speed, rotation_weight)

	_player.rotate_object_local(Vector3.RIGHT, view_rotation.x)
	_player.rotate_object_local(Vector3.UP, view_rotation.y)
	_player.rotate_object_local(Vector3.FORWARD, view_rotation.z)

	_player.orthonormalize()

But then I was confronted with the issue of needing to program reactionary motion. Pure nightmare when I started to think about it; I didn’t just want the ship to bounce off of the wall for example. I wanted there to be rotational/torque force from the impact in addition to the linear force. I don’t have the physics math chops for that at the moment and I struggled to resolve whether I should just ignore it and move on or ditch the mechanic, as much as I wanted it. After talking with a friend about the control scheme in general he noted that I would just be recreating the functions in a rigid body that apply force!

RigidBody3D

RigidBody is a physics body that devs have partial control over, and, unless sleeping or frozen, will automatically update in the simulation every physics tick. Space flight is easy to get to work on with rigid bodies since I just need to get rid of gravity, leaving a floating ship that will move or bounce off of things when it collides right out of the box. That means all I needed to focus on is player input to movement of a rigid body, which generally is done by applying forces to it and luckily Godot has options to allow a healthy mix of flexibility as well as ease of use. I could make use of function calls that apply a type of force that won’t effect the other (linear vs rotational), so when movement buttons are pressed I knew I wouldn’t have to worry about the force creating any rotation on a ship as an undesired consequence. This alone removed a lot of the movement and rotating code I had written for the CharacterBody3D but there still remained the aspect of decelerating the ship, as I don’t want a full zero-g simulated game.

One way would be to have it so when no input is happening, I apply a force against its current motion, much like in a CharacterBody, but that seemed really lame! If rigid bodies have friction properties there must be something similar for flying rigid bodies; air resistance or something like that. After a little bit of digging through the properties in the inspector, making use of the doc tool tips, I stumbled on my answer. The damping property is exactly this counter force I was looking for, and can be applied to linear and rotation motion separately! The funny thing to me is that at a previous job at a medical training sim company, I had learned about the general concept of damping in the physics engine we made, it isn’t only for flying rigid bodies. Felt a little silly but it did let me accomplish exactly what I wanted with the fly mechanics for the game.

That checks off all of the flight controls I needed to move from CharacterBody to RigidBody, at least for now. For finer control of the object’s state in the simulation, I can make use of the _integrated_forces function to make alterations before the next step in the sim is done. Based on testing so far though I do not need to make use of this for my ship but it is good to have the knowledge tucked away for later.

extends PlayerState


@export_range(0, 1000, 0.5) var move_speed : float = 5.0
@export_range(0, 100, 0.1) var rotation_speed : float = 0.05

var last_rotation := Vector3.ZERO
var rotation_weight := 0.0
var view_rotation := Vector3.ZERO


func _physics_process(delta: float) -> void:
	var move_direction := _player.input.movement_direction
	var rotation_direction := _player.input.rotation_direction

	apply_rotation(rotation_direction, delta)
	apply_movement(move_direction, delta)

	if _player.linear_velocity.length() <= 0.01 and view_rotation.length() <= 0.01:
		_state_machine.transition_to("Idle")


func apply_movement(movement_direction: Vector3, delta: float) -> void:
	var forward := _player.transform.basis.z * movement_direction.z
	var up := _player.transform.basis.y * movement_direction.y
	var right := _player.transform.basis.x * movement_direction.x

	movement_direction = forward + up + right

	_player.apply_central_force(movement_direction * move_speed)


func apply_rotation(rotation_direction: Vector3, delta: float) -> void:
	var forward := _player.transform.basis.z * rotation_direction.z
	var up := _player.transform.basis.y * rotation_direction.y
	var right := _player.transform.basis.x * rotation_direction.x

	rotation_direction = forward + up + right

	_player.apply_torque(rotation_direction * rotation_speed)

Much cleaner code! Still need to apply the ship’s transform so controls stay relative to the ship’s orientation but everything pretaining to acceleration and velocity tracking is much simpler, basically a single rigid body call.

This setup gets me 6DOF motion with a sense of inertia I have control over but also provides all of the physical feedback I am looking for with much simpler, easier to read code. So far this has been using controller and keyboard only input. The last remaining pieces are shooting weapons and mouse motion. Up until now everything has been handled in either _physics_process or _integrated forces but those don’t seem quite appropriate for these last pieces. Where does this fit in?

Wherefore Art Thou Input Handling?

Something like firing is a one-off action and so I want to handle it as an event instead of polling for it. This means that using _process or _physics_process isn’t the best practice; it can work but there is a risk that a quick button press are missed between ticks in the function. Polling the input states is better suited for mechanics that effect the physics simulation or involves holding a button.

Firing also needs to be replicated among all players when playing online using RPCs, unlike the current position and velocity which is replicated automatically by a MultiplayerSyncronizer. Since I want the RPC to only fire once on the event and not be able to repeat every tick from being held, the best home for handling the fire action is in either _input or _unhandled_input. So what’s the difference here?

For the most detailed explanation, refer to the Godot docs about InputEvents but the simplified version is that when an input event occurs, and this could be keyboard, controller, mouse, touch, etc) it goes down a chain:

Any one of these stages can eat the input event; it is automatic by the GUI and picking but any overrides I write will have to explicitly call Viewport.set_event_as_handled so the viewport knows to stop propogating the event down the chain.

At first glance it may seem like the right place is in _input since it means our control won’t be effected by the GUI but we actually want to handle firing and mouse motion after the GUI gets to use it. A good example for why is if the pause menu is open. If we use _input the ship inputs will still go through since the input is being handled before the GUI can use it. I could’ve worked around this with a boolean and have it skip handling so the GUI can handle input, but by simply moving controller logic, the controller doesn’t get a chance to handle the event in the first place. Way cleaner in my opinion. So I end up with this short bit of code for handling firing and mouse motion:

@export_range(0.0, 1.0) var mouse_sensitivity: float = 1.0

...

var mouse_velocity: Vector2

...

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("fire_weapon") and event.get_action_strength("fire_weapon") == 1.0:
		fire.rpc()

	if event is InputEventMouseMotion:
		mouse_velocity.x = -event.screen_relative.normalized().y * mouse_sensitivity
		mouse_velocity.y = -event.screen_relative.normalized().x * mouse_sensitivity

...

The mouse velocity is later used to calculate the rotation_direction that is used in the code from earlier. And with that the player is able to fly around, bounce off of walls, and shoot; all with the their choice of only keyboard, controller, or keyboard and mouse!

This code essentially takes the mouse motion and converts it into an input strength that is used for rotation speed, much like the code above. It is important to take a look at the docs for screen_relative as different mouse motion functions will or will not automatically scale, and have different use cases for such a thing!

There is a lingering question though! If the GUI will handle the input events that come in what the polling for actions? My initial thoughts are:

  1. The game will use the ship control inputs for GUI inputs, so won’t be a problem
  2. The GUI still eats the input by some magic I don’t know yet
  3. I will need to have some kind of pause logic that temporarily disables _process and _physics_process until the game unpauses

In any case, that is a problem for another time. Like when I get to building the GUI! Next I want to do a bit of clean up and build out the ship with an Entity Component System to make it easier on myself to have the game procedurally put AI ships together. Until next time!