Adding Mouse Controls to a Vertical Shooter in Godot


Update: a version with all media restored is on my blog! And if you're reading this in the far future and it doesn't work, it's got its own fancy domain now so there's a good chance the Internet Archive will pick it up.


Earlier this year, I've been playing a bit of Tyrian, a really good vertical shooter that's many years older than I am. One thing really stuck out to me, however, and it's being able to control your ship with a mouse:

This, along with many other aspects of the game, continues to amaze me in how much polish was crammed into a game designed to run on much less processing power than most computers of today. Well, of course, I had to have mouse controls in my own game. So here's a quick tutorial on how to add mouse controls to a vertical shooter! (Even though this is written for a 2.5d game, it should be able to easily work with any other type of game that needs these controls.)


1. Setting everything up

First, you want to open up Godot and create a new game project (or use an existing project). From here, set up a new scene with a Spatial node. Add a camera and move it at least 15 units upward, and get it to face down toward the origin point. Then, create a MeshInstance node with a CylinderMesh, setting the top radius of the mesh to 0. This will be a cone that'll act as our spaceship and point in the direction that it's being moved. Lastly, just add a directional light anywhere in your scene so you can tell which direction the cone is pointing and save the scene as "MouseControls.tscn" (or whatever you like).

It should look like this:

An example of the scene. Note that -Z is "up" for the camera.

Next, create a script for the base node by clicking on the icon to the right of the the "Filter nodes" box and hit "Create". You should see this:

Attaching a script to MouseControls.tscn.

It's safe to delete everything except the first line here. Now we're going to have to do a bit of right-angled trigonometry.

2. Not right angled trig again!

What we need to do is create a function in _input() that will detect mouse movement. But how would we do that? Well, we can create an if statement to detect if the mouse has just been moved, and then get the amount it moved by since the last frame by getting event.relative. But there's a catch! It returns a Vector2 measured in pixels, and that isn't very useful in a 3D application where moving something by "1" can mean 1 meter, or 1 centimeter, or even a foot (although typically we say it's in meters). We need to somehow make event.relative be some kind of intensity out of 1. Let's call this mouse_intensity.

Alright, this should be easy. Just do a little event.relative / max_mouse_movement, right? Well... not so fast. What we're doing in this is dividing the x and y components of the 2D vector each by max_mouse_movement. But if the cursor is moving exactly diagonally, we get a vector with the coordinates (1, 1)! To identify exactly what went wrong, we need to draw a right angled triangle.

Let's call the initial position the mouse was in pi and the final position of the mouse pf. The displacement of the cursor will be represented by d, but I can't draw the fancy vector line over it so just keep that in mind. What we just got with the Vector2 was two components of d, dx and dy. What we really wanted to max out was the total displacement of the cursor, or dt. So let's draw a diagram of what we have so far.

A diagram of a frame of mouse movement.

With a little bit of the Pythagorean theorem we can determine that dt is equal to the square root of dx2 + dy2, which means if dx and dy are equal to 1, dt will be greater than 1! This is actually the same reason why strafe walking is faster than walking in a straight direction in most video games: because hitting a button to move just adds some amount to the velocity of the player in the X or Y directions, and hitting two buttons to move adds that amount to the velocity of the player in the X and Y directions. So how do we properly give each mouse movement a maximum distance and make it "normalized"?

It's actually pretty simple. First, we can envision the maximum space a cursor can move as a circle, with dt being the radius. Let's create a variable for that called radius. Then we can just limit that to and divide it by the maximum radius so we get a value out of 1 as an "intensity" of mouse movement. After that we need to split the radius back up into x and y components, so we'll need to get an angle with dx and dy. It can be anywhere provided it isn't 90 degrees and we use the same angle to find the components of the radius.

Now we can get to writing some code! Create a new function, _input(event), and translate this procedure to GDScript:

if event is InputEventMouseMotion:
    var vector = event.relative * -1
    var radius = clamp(abs(vector.length()), 0, max_mouse_movement) / max_mouse_movement # Divides by max_mouse_movement (and clamps) to get a value out of 1 (that can't be higher than 1)
        
    mouse_intensity = Vector2(radius, 0).rotated(vector.angle())

Note that we're multiplying event.relative by -1 because for some reason moving the cursor down means moving it in a positive direction. We'll need to create a few variables for this to work, max_mouse_movement and mouse_intensity. Furthermore, we should probably add a conditional statement under _input to lock the cursor in. Here's what our code looks like now:

extends Spatial
export(int, 10, 25) var max_mouse_movement = 10 # Maximum movement in the X or Y direction
var mouse_intensity : Vector2
func _input(event):
    # Pointer lock
    if event is InputEventMouseButton and event.pressed == true:
        if event.button_index == 1: # LEFT CLICK
            Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
        else: # RIGHT CLICK
            Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
    
    # Calculating movement
    if event is InputEventMouseMotion:
        var vector = event.relative * -1
        var radius = clamp(abs(vector.length()), 0, max_mouse_movement) / max_mouse_movement # Divides by max_mouse_movement (and clamps) to get a value out of 1 (that can't be higher than 1)
        
        mouse_intensity = Vector2(radius, 0).rotated(vector.angle())

3. Testing it out and feeling the jank

Well, now we need to test out what we've just written! Create a function, _process(delta), and in it write this:

$MeshInstance.rotation_degrees.x = -mouse_intensity.y * 90
$MeshInstance.rotation_degrees.z = mouse_intensity.x * 90
    
$MeshInstance.translation.x -= mouse_intensity.x * speed
$MeshInstance.translation.z -= mouse_intensity.y * speed

At the top of your script, create a variable called "speed":

export(float, 0.1, 1.0) var speed = 0.1

Now it's time to run your game!

...well. That's a bit janky, isn't it? Let's fix that. The plan? Interpolate between each mouse movement. Plus, we'll need to set mouse_intensity to zero every time the mouse is not being moved. This next bit's going to be a bit inspired from this forum post so I recommend having a look at that.

3. Fixing the jank

First, let's create a couple variables, interpolation_time and interpolation_to_zero_time. We want a second time for interpolating to zero so we can control whether the intensity comes to a slow or fast stop.

export(float, 0, 15) var interpolation_time = 4
export(float, 0, 15) var interpolation_to_zero_time = 12

We'll also want to add a few more variables that have to do with when the mouse was last moved.

var mouse_moved : bool = false
var last_mouse_intensity : Vector2

In _input, change mouse_intensity to last_mouse_intensity. Then, add this line to the top of _process:

mouse_intensity = mouse_intensity.linear_interpolate(last_mouse_intensity, (interpolation_time if last_mouse_intensity != Vector2() else interpolation_to_zero_time) * delta)

Now let's go back to _input. Just after the second if statement, add this line of code:

mouse_moved = true

This essentially tells us that the mouse just moved, and is going to be important for resetting mouse_intensity. Now, in _process, add an if statement to reset mouse_intensity if the mouse isn't moving (and to reset mouse_moved as well). Make sure to put this above where you set mouse_intensity.

if mouse_moved == false:
    last_mouse_intensity = Vector2(0, 0)
else:
    mouse_moved = false

Now this is much better!

We're almost done. Now we just need to add some final touches.

4. My TrackPoint glitches sometimes.

This went all nice for me, until I moved my TrackPoint one too many times and it started to give a bunch of small mouse events in succession. This resulted in a sort of "flickering" effect with the code above and I experienced a net loss in speed as I was moving the cone. It's a hardware issue, but what can we do to fix edge cases like this?

The answer is actually pretty simple: add a threshold between each time mouse movement stops. Essentially, we have to wait a certain amount of time after each time mouse movement stops before we can say it stops again. Sounds simple enough. Let's do this!

First, you'll need to add two variables:

export(int) var mouse_release_threshold = 0
var last_mouse_release_time = 0

Now we'll go back to _process and add a couple lines to the if statement we wrote earlier:

if mouse_moved == false:
    last_mouse_intensity = Vector2(0, 0)
elif OS.get_ticks_msec() - last_mouse_release_time > mouse_release_threshold:
    last_mouse_release_time = OS.get_ticks_msec()
    mouse_moved = false

What this essentially does is say that if enough time has elapsed since the last time mouse_moved was set to false, and all other previous conditions are met, then we can really set it false (and restart the "timer").

And with that, our code is done! Here's a commented version of everything we wrote:

# Code heavily inspired from godotengine.org/qa/98413/mousemotioninput-movement-unity-which-ranges-from-weapon
extends Spatial
export(int, 10, 25) var max_mouse_movement = 10 # Maximum movement in the X or Y direction
export(float, 0.1, 1.0) var speed = 0.1 # m/s
export(float, 0, 15) var interpolation_time = 4
export(float, 0, 15) var interpolation_to_zero_time = 12
# Frames since the last time the mouse was released for it to be allowed to be released again.
# Useful for hardware that glitches sometimes.
export(int) var mouse_release_threshold = 0 # In frames
var last_mouse_release_time = 0
var mouse_moved : bool = false
var last_mouse_intensity : Vector2
var mouse_intensity : Vector2 # Mouse velocity, normalized (mouse_velocity_pixels / max_mouse_movement)
# _input: Calculate the intensity of each mouse movement and multiply it by -1 because for some reason down is positive for Godot???
func _input(event):
    # Pointer lock
    if event is InputEventMouseButton and event.pressed == true:
        if event.button_index == 1:
            Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
        else:
            Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
    
    if event is InputEventMouseMotion:
        mouse_moved = true
        var vector = event.relative * -1
        var radius = clamp(abs(vector.length()), 0, max_mouse_movement) / max_mouse_movement # Divides by max_mouse_movement (and clamps) to get a value out of 1 (that can't be higher than 1)
        
        last_mouse_intensity = Vector2(radius, 0).rotated(vector.angle())
# _process: Interpolate between the current mouse intensity and the previously calculated mouse intensity to prevent choppiness
func _process(delta):
    # Check if the mouse was just moved and reset last_mouse_move if it wasn't moved at all
    if mouse_moved == false:
        last_mouse_intensity = Vector2(0, 0)
    elif OS.get_ticks_msec() - last_mouse_release_time > mouse_release_threshold:
        last_mouse_release_time = OS.get_ticks_msec()
        mouse_moved = false
    
    # Interpolate to the last position the mouse moved to
    mouse_intensity = mouse_intensity.linear_interpolate(last_mouse_intensity, (interpolation_time if last_mouse_intensity != Vector2() else interpolation_to_zero_time) * delta)
    
    # Moving/rotating
    
    $MeshInstance.rotation_degrees.x = -mouse_intensity.y * 90
    $MeshInstance.rotation_degrees.z = mouse_intensity.x * 90
    
    $MeshInstance.translation.x -= mouse_intensity.x * speed
    $MeshInstance.translation.z -= mouse_intensity.y * speed

5. I can't finish this without showing you what it looks like in my game

Apologies for all the cracks in audio/frame dips I had while recording this. You might want to mute it.

Now it's important to note that the implementation in my game is still a work-in-progress (which you can follow on GitHub), but aside from that, that's everything! I hope you found this article useful, and good luck in your own endeavors with game development!

Get InfiniteShooter

Leave a comment

Log in with itch.io to leave a comment.