I wrote this post a while back, but here’s a version with most files restored. (Partly from the fact that they’re still on my hard drive, but also thanks Internet Archive!)
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:
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:
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
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.
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())Code language: PHP (php)
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,
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())Code language: PHP (php)
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 * speedCode language: PHP (php)
At the top of your script, create a variable called “speed”:
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_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.
We’ll also want to add a few more variables that have to do with when the mouse was last moved.
last_mouse_intensity. Then, add this line to the top of
Now let’s go back to
_input. Just after the second if statement, add this line of code:
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
if mouse_moved == false: last_mouse_intensity = Vector2(0, 0) else: mouse_moved = falseCode language: PHP (php)
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:
Now we’ll go back to
_process and add a couple lines to the if statement we wrote earlier:
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 * speedCode language: PHP (php)
5. I can’t finish this without showing you what it looks like in my game
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!