Godot Tactics RPG – 03. Input & Camera

7thSage here again, welcome back to part 3 of the Godot Tactics tutorial. Sorry about the long delay. Life has been fairly hectic lately.

This time we’ll be splitting things up a little different than the original lesson, and I wanted to extend a couple things a little bit. This lesson will focus on getting input set up, along with the camera and the battle scene. The next lesson will focus primarily on the state machine.

Setting Up Input Mapping

I think the biggest things in Godot that are a bit different with input are that the mouse scroll wheel is considered a button click. One click for each ‘tick’ the wheel scrolls. The other is that the Axis components in the input manager are split up as one Axis each for positive and negative, and when we check the axis in code, we call a function with the two as parameters.

To get to where we set the mapping. Go to “Project->Project Settings” and Select the “Input Map” tab. Because of the positive and negative axis being split, and that I’m adding a few extra functions, there are a few more buttons than the original to add to the list.

To add a new action type a name where the text “Add New Action” is, and click the button next to it that says Add. Once you’ve added an action you can click the plus sign next to the action name to add a keybinding to it, and remember that the scroll wheel is counted as a button.

After clicking the plus button “add a new event”, if the field “Listening for input” is highlighted, you can press the key you are looking for to avoid having to find it in the list.

We’ll add the following list of actions and keybindings. Sorry in advance for the long list of keys.

  • move_left
    • Keyboard A
    • Keyboard Left
    • Joypad Axis 0- (Left Stick Left)
    • Joypad Button 13 (D-pad Left)
  • move_right
    • Keyboard D
    • Keyboard Right
    • Joypad Axis 0+ (Left Stick Right)
    • Joypad Button 14 (D-pad Right)
  • move_up
    • Keyboard W
    • Keyboard Up
    • Joypad Axis 1- (Left Stick Up)
    • Joypad Button 11 (D-pad Up)
  • move_down
    • Keyboard S
    • Keyboard Down
    • Joypad Axis 1+ (Left Stick Down)
    • Joypad Button 12 (D-pad Down)
  • fire_1
    • Joypad Button 0 (Sony Cross, Nintendo B)
    • Keyboard Enter
  • fire_2
    • Joypad Button 1 (Sony Circle, Nintendo A)
    • Keyboard Shift
  • fire_3
    • Joypad Button 2 (Sony Square, Nintendo Y)
  • fire_4
    • Joypad Button 3 (Sony Triangle, Nintendo X)
  • camera_activate
    • Right Mouse Button
    • Keyboard Alt
  • camera_left
    • Joypad Axis 2- (Right Stick Left)
  • camera_right
    • Joypad Axis 2+ (Right Stick Right)
  • camera_up
    • Joypad Axis 3- (Right Stick Up)
  • camera_down
    • Joypad Axis 3+ (Right Stick Down)
  • zoom_up
    • Button Mouse Wheel Up
  • zoom_down
    • Button Mouse Wheel Down
  • quit
    • Keyboard Escape

There are other keybindings that we could add, or you can change them around to be the buttons you like. I tried to have sensible bindings, but you may disagree with some choices and will want to change them. We’ll also add mouse controls for the camera, but that will be handled a little different. I also wanted to add that I tried to keep with the style of the original tutorial with how input is handled, but it could probably use a bit of work still. It should be enough to get you going and I wanted to be able to have at least something to control the camera.

Scene Setup

In the original tutorial the scene setup was done in the next lesson with state machines, but I wanted to move most of the setup and the camera code to this lesson, and leave the next lesson to deal with mostly just the state machines.

Start by creating a new 3d Scene named “Battle.tscn” in the Scenes folder.

Open up the scene and add a new child node to Battle, name it “Battle Controller”, this one can be a regular node, it doesn’t need to be a 3d node. Next we’ll create a few child nodes under Battle Controller. A regular Node “Input Controller”, a 3d Node “Board”, a DirectionalLight3D node and a 3d node “Camera Controller”. Under Camera Controller we need another child 3d Node, “Heading”, and under that a child 3d Node “Pitch” These need to be 3d nodes because we’ll be using their transforms. We’ll create one last child node under Pitch, a Camera3D node.

We need to make a few adjustments to the transforms on the nodes we created for the CameraController. First up on “Heading” the “y” value on the Rotation transform should be set to 45 degrees. On the “Pitch” node we need to set the “x” Rotation transform to -35, and finally on the Camera3D node, under the Camera3D section, set the Projection mode to Orthogonal, and the size to 10. Under the Node3D section, set the “z” Position to 10. The z position isn’t really needed for Orthogonal view, but if its set too close, we get some weird clipping.

Next we need to create a new folder under Scripts. Name this one “Controller”. We’ll place all our controller scripts in here. Create a new script named “BattleController.gd” and attach it to the Battle Controller node. This script will be pretty simple. We’ll use it mostly to keep track of all the major components.

The lines for InputController and CameraController will give an error until we create those scripts.

extends Node
class_name BattleController

@export var board: BoardCreator
@export var inputController: InputController
@export var cameraController: CameraController

Board

Before we can link up those nodes to those objects, we need to add scripts to each so they will identifiable by a type. We’ll start with a fairly simple one. BoardCreator. The original tutorial created a shortened version of the board creator script from the last lesson, but I kinda like the idea of using the whole script. It will mean that if we decide to update it in the future, we won’t have two places to edit map data.

Before its usable for this purpose we’ll need to do a tiny bit of refactoring. The code for saving and loading needs to be moved out into its own functions so we can specify the filepath either when clicking the button, or by state machine later on.

The functions the buttons link to now look like this.

func Save():
	var saveFile = savePath + fileName
	SaveMap(saveFile)

func Load():
	var saveFile = savePath + fileName
	LoadMap(saveFile)

func SaveJSON():
	#var saveFile = savePath + fileName
	#var save_game = FileAccess.open(saveFile, FileAccess.WRITE)
	var saveFile = "res://Data/Levels/savegame.json"
	SaveMapJSON(saveFile)

func LoadJSON():
	#var saveFile = savePath + fileName
	#var save_game = FileAccess.open(saveFile, FileAccess.WRITE)
	var saveFile = "res://Data/Levels/savegame.json"
	LoadMapJSON(saveFile)

And the main bit of the functions we move out to new functions. Sorry about the large script dump for the next bit, but not much has changed from last time. Just refactored to work outside the buttons.

func SaveMap(saveFile):
	var save_game = FileAccess.open(saveFile, FileAccess.WRITE)
	var version = 1
	var size = tiles.size()
	
	save_game.store_8(version)
	save_game.store_16(size)

	for key in tiles:
		save_game.store_8(key.x)
		save_game.store_8(key.y)
		save_game.store_8(tiles[key].height)
		
	save_game.close()

func LoadMap(saveFile):
	Clear()	

	if not FileAccess.file_exists(saveFile):
		return # Error! We don't have a save to load.
		
	var save_game = FileAccess.open(saveFile, FileAccess.READ)
	var version = save_game.get_8()
	var size = save_game.get_16()
	
	for i in range(size):
		var save_x = save_game.get_8()
		var save_z = save_game.get_8()
		var save_height = save_game.get_8()
		
		var t: Tile = _Create()
		t.Load(Vector2i(save_x, save_z) , save_height)
		tiles[Vector2i(t.pos.x,t.pos.y)] = t	
	
	save_game.close()
	_UpdateMarker()

func SaveMapJSON(saveFile):
	var main_dict = {
		"version": "1.0.0",
		"tiles": []
	}
		
	for key in tiles:
		var save_dict = {
			"pos_x" : tiles[key].pos.x,
			"pos_z" : tiles[key].pos.y,
			"height" : tiles[key].height
			}
		main_dict["tiles"].append(save_dict)

	var save_game = FileAccess.open(saveFile, FileAccess.WRITE)
	
	var json_string = JSON.stringify(main_dict, "\t", false)
	save_game.store_line(json_string)

func LoadMapJSON(saveFile):
	Clear()

	if not FileAccess.file_exists(saveFile):
		return # Error! We don't have a save to load.
		
	var save_game = FileAccess.open(saveFile, FileAccess.READ)	

	var json_text = save_game.get_as_text()	
	var json = JSON.new()
	var parse_result = json.parse(json_text)

	if parse_result != OK:
		print("Error %s reading json file." % parse_result)
		return
		
	var data = {}
	data = json.get_data()

	for mtile in data["tiles"]:
		var t: Tile = _Create()
		t.Load(Vector2(mtile["pos_x"], mtile["pos_z"]) , mtile["height"])
		tiles[Vector2i(t.pos.x,t.pos.y)] = t
	
	save_game.close()
	_UpdateMarker()

With that our board creator is ready. We can attach it to the Board node.

Camera & Input Stubs

Next up we’ll create two more scripts and place them in the “Controller” Folder. Create “CameraController.gd” and “InputController.gd”, and attach them to their respective nodes. For the moment we’ll create simple stubs for both the scripts to make sure things are not throwing errors as we go.

InputController.gd

extends Node
class_name InputController

CameraController.gd

extends Node
class_name CameraController

Now that we have the scripts with classnames and attached to their nodes, lets go to the inspector of Battle Controller in the scene. Where it lists Board, Input Controller and Camera Controller. Click the assign button and add find each respective object in the scene tree that opens.

Test Script

For testing purposes we’re also going to need a temporary node to place things on and a script to go on it. We’ll delete this before moving onto the next lesson where the functions will have a more permanent location to be called. Under BattleController create another node named something like “Test”. Under Scripts create a folder “Test”, and inside it a “test.gd” file, and attach the script to the node.

In this script and several others later, we’ll get and store a reference to the Battle Controller and save it in the variable “_owner”. We grab the reference to it when the node is first loaded in the _ready() function with get_node(). I wanted to keep the variable named similar to the original tutorial, but unfortunately the variable “owner” is already used by the node class, but as the variable should be private, the underscore is fitting anyway.

For those not used to using command line, “../” is going up in the node tree to the parent. If it was “../../” it would go up two levels. And to access child nodes in Godot we can use  a dollar sign followed by the child’s name, such as $Heading.

Before doing much testing we need to load a board. Since the Battle Controller has a reference to the board and other components, we can use the _owner variable to access it, and load the board. The changes we make to the map in the editor view won’t display in play mode unless they are saved and loaded.

After that is a call to AddListeners(). This will be where we watch for Signals (Similar to what Events are in Unity). We’ll talk about those in a bit, and here is our first time seeing _exit_tree(). This is called when the node is destroyed and we’ll clean up any Signals the node was listening for.  Also note that we don’t need to name the class this time, since we won’t be referencing it in other classes.

extends Node

var _owner: BattleController

func _ready():
	_owner = get_node("../")
	
	var saveFile = _owner.board.savePath + _owner.board.fileName
	_owner.board.LoadMap(saveFile)
	
	AddListeners()
	
func _exit_tree():
	RemoveListeners()

func AddListeners():
	pass

func RemoveListeners():
	pass

Now that we have most of our framework setup, we can start working on our input controller and camera. We’ll start with the basic arrow keys.

The simplest way to get the axis value would be by calling Input.get_axis(‘move_left’, ‘move_right’) inside the _process function. The problem with this approach is that our cursor would be moving 60 times or more a second. To fix this we’ll create a repeater class that will limit this to being called a more reasonable amount.

Repeater

In Unity this was placed as part of the input controller, but I think to make this a named type, we need to move it out into its own file. Under the Script folder create a new folder called “Common”, and under that create another folder named “Input”. Inside Input create a script “Repeater.gd”

In the original Repeater class, the there was a longer delay after you first started holding a button before it registered again and then each delay after was set to shorter constant delay. I decided to simplify the script a little and keep it all to one delay. Perhaps that was a mistake, but it seems to feel ok to me. If you would like to revert back to the original idea, the changes are fairly minor, and I suspect you should be able to compare the Unity code to figure it out.

We’ll start with a blank script this time. We don’t need to extend Node in this one. To start off We’ll give the class a name. _rate is how often the button will register after being held. In this case 250, which is in milliseconds, or 4 times every second. _next will be used to keep track of the time that has elapsed, and the last two variables store which axis is being used.

class_name Repeater

const _rate:int = 250

var _next: int
var _axisPos: String
var _axisNeg: String

Next up is the _init() function. This is the objects constructor. So later on when we call something like Repeater.new(‘move_left’, ‘move_right’), this is what is called.

func _init(negativeAxis:String, positiveAxis:String):
	_axisNeg = negativeAxis
	_axisPos = positiveAxis

Don’t confuse the Update() function with any built in functions. We will call this manually each frame to check if we should call any new events. Start by setting the return value, ‘retValue’ to 0, which will stay unless we get input and there has been a long enough delay. Next we get the axis input and round it to the closest int value. Then if there is a value, we check if there has been enough time, and if so, we set it to return our rounded value. That wraps up the Repeater script. We can save and close that now.

func Update():
	var retValue:int = 0
	var value:int = roundi(Input.get_axis(_axisNeg, _axisPos))
	
	if value != 0:
		if Time.get_ticks_msec() > _next:
			retValue = value
			_next = Time.get_ticks_msec() + _rate
	
	else:
		_next = 0
	
	return retValue

And that finishes off the Repeater class.

Move Event

Now that we have our Repeater, we can move on to the InputController. We’ll start by creating a signal moveEvent. As mentioned earlier, this is similar to Unity’s Events. It is essentially a function that we will call whenever we detect movement and that there has been enough delay. When this function is called, it will in turn call the move function(or whatever we name it) on all objects that are registered to listen to it. Next up we create a Repeater object for the horizontal and vertical axis of our movement device. In _process() we call the Repeaters Update() function that will return 0 unless there is input and enough time has elapsed. Then if either variable has a value, we call emit on the signal, telling all listeners. Because of how GDScript handles variables, we can pass whatever type of data through that we need, so in this case we send a Vector2i with both values.

signal moveEvent(point:Vector2i)

var _hor:Repeater = Repeater.new('move_left', 'move_right')
var _ver:Repeater = Repeater.new('move_up', 'move_down')

func _process(delta):
	var x = _hor.Update()
	var y = _ver.Update()
	
	if x != 0 || y != 0:
		moveEvent.emit(Vector2i(x,y))

Testing Move Events

Now that we have our first signal set up, we can head over to the “Test.gd” script and actually test it out. We’ll add a line to each the AddListeners() and RemoveListeners() functions to connect and disconnect respectively our signal. The signal lives on the InputController, so to access it we get it from the _owner variable we set earlier. The parameter “OnMove” is the function that will be called on this object when an event happens. We use _owner again to get the position from the board, and we add the new movement to it. The board will then handle updating the cursor position like it did from the previous lesson.

func AddListeners():
	_owner.inputController.moveEvent.connect(OnMove)

func RemoveListeners():
	_owner.inputController.moveEvent.disconnect(OnMove)

func OnMove(e:Vector2i):
	_owner.board.pos += e

Now lets test everything out so far and make sure it is all working. You’ll need to click the play button in the top right this time, as this code isn’t set to work in the editor. If things are not working, check that you have scripts attached to the main nodes, and that in the BattleController inspector, all the individual controllers are linked up.

Buttons

Alright, now that we hopefully have movement working, lets finish up the rest of the non camera buttons. Back in InputController.gd, we need two more signals, and a variable for storing the button names we’ll be checking.

signal fireEvent(button:int)
signal quitEvent()

var buttons = ['fire_1','fire_2','fire_3','fire_4']

In the _process() function we’ll add the loop to check each button in turn. If the button was pressed this frame, an event will fire with the button number(starting at zero). No further events will be fired if the button is held. The quit action is similar, but we’ll call a specific event for quit instead of the more generic button fire event.

for i in range(buttons.size()):
	if Input.is_action_just_pressed(buttons[i]):
		fireEvent.emit(i)
		
if Input.is_action_just_pressed('quit'):
	quitEvent.emit()

Back to the “Test.gd” script. We’ll add the last of the listeners for the Test script and two simple functions. The fire script simply prints the text “Fire: ” and displays the button number. When adding a variable to a string, you’ll have to tell GDScript to convert it to string first. The OnQuit() function calls the engine’s quit function. This is especially important if we are ever playing the game fullscreen. Eventually it would probably make sense to add at least a confirmation window before calling the engine’s quit, not to mention deal with whatever saving or cleanup that may be required, but for now at least we won’t get stuck in fullscreen.

func AddListeners():
	_owner.inputController.moveEvent.connect(OnMove)
	_owner.inputController.fireEvent.connect(OnFire)
	_owner.inputController.quitEvent.connect(OnQuit)

func RemoveListeners():
	_owner.inputController.moveEvent.disconnect(OnMove)
	_owner.inputController.fireEvent.disconnect(OnFire)
	_owner.inputController.quitEvent.disconnect(OnQuit)

func OnFire(e:int):
	print("Fire: " + str(e))
	
func OnQuit():
	get_tree().quit()

Camera Input

Next up is setting up the input for the camera. We’ll start with some setup for the zoom function of the camera with the mouse scroll wheel, or alternately by holding down another key. In Godot, the mouse scroll wheel is registered as a button press instead of an axis. It however does not register as being held down with the is_action_pressed() state, but it does register is_action_just_pressed(). Now I want the mousewheel and holding a button to feel similar, so we’ll create a new repeater class. Back in the same folder as before “Scripts->Common->Input” create a new script called “ButtonRepeater.gd”

This script is similar to the previous repeater, but has a few differences. Here we want a little faster movement for when we hold down the button, so I’ve set the delay to 50 milliseconds. In the Update() function, the first check is primarily for the scroll wheel. Since it will only register is_action_just_pressed(), it will register every scrollwheel tick, but if the is_action_pressed() button is held, we know its not the scroll wheel, and we check if the delay has passed.

class_name ButtonRepeater

const _rate: int = 50
var _next: int
var _button: String

func _init(limitButton:String):
	_button = limitButton

func Update():
	if Input.is_action_just_pressed(_button):
		_next = Time.get_ticks_msec() + _rate
		return true
		
	if Input.is_action_pressed(_button): 
		if Time.get_ticks_msec() > _next:
			_next = Time.get_ticks_msec() + _rate
			return true

	return false

Ok. Back to InputController.gd, we just need to add a signal for the zoom function of the camera, and another for rotating it. Rotating the camera won’t use a repeater, but we’ll be using the one we just created for the zoom function. These lines go toward the top of the code with the other class variables and signals.

signal cameraRotateEvent(point:Vector2)
signal cameraZoomEvent(scroll:int)

var _camZoomUp:ButtonRepeater = ButtonRepeater.new('zoom_up')
var _camZoomDown:ButtonRepeater = ButtonRepeater.new('zoom_down')

var _camZoomUp:ButtonRepeater = ButtonRepeater.new('zoom_up')
var _camZoomDown:ButtonRepeater = ButtonRepeater.new('zoom_down')

var _lastMouse:Vector2

After that in the _process() function, the code for emitting the wheel events is pretty simple. We check if there is any presses either going up or down and emit the signal if it returns true.

if _camZoomUp.Update():
	cameraZoomEvent.emit(-1)
	
if _camZoomDown.Update():
	cameraZoomEvent.emit(1)

A little bit more involved, the rotation function is split into a couple parts. Still in _process() for these. Getting the input for the joystick axis is pretty simple. We grab the two input components and if either of them are not zero, we emit a signal with a vector containing the two values.

var camX = Input.get_axis('camera_right','camera_left')
var camY = Input.get_axis('camera_down', 'camera_up')

if camX !=0 || camY !=0:
	cameraRotateEvent.emit(Vector2(camX,camY))

Here is where the keybinding for camera_activate comes into play. It’s just a key we hold down to tell the game to start listening to the mouse for controlling the camera, so we are only moving it when we intend to. The first check for when the button is first pressed lets us initialize the starting point for the mouse after we hit the button. After that as long as the button is held, we get the mouses current position and compare it to what it was the previous time. Then we update the _lastMouse position for the next update. We set a value, vectorLimit, to divide the mouse movement by, to get it closer in line to the zero to one values the joystick gives us. After that all that is left is emitting the mouse movement.

if Input.is_action_just_pressed('camera_activate'):
	_lastMouse = get_viewport().get_mouse_position()

if Input.is_action_pressed('camera_activate'):
	var currentMouse:Vector2 = get_viewport().get_mouse_position()
	
	if _lastMouse != currentMouse:
		var mouseVector:Vector2 = _lastMouse - currentMouse
		_lastMouse = currentMouse
		var vectorLimit = 10
		var newVector:Vector2 = mouseVector/vectorLimit
		cameraRotateEvent.emit(newVector)

Camera Controller

Now that we have all the input set up, its time to move on to the CameraController.gd script. We’ll start with some setup. We have a variable for the BattleController and we set it in _ready(), along with a call to Zoom() with a zero value to sync our initial values once the function is finished. We also set up listening to our signals, and create a temporary placeholder for the Zoom and Orbit functions.

extends Node
class_name CameraController

var _owner: BattleController

func _ready():
	_owner = get_node("../")
	Zoom(0)
	AddListeners()
	
func _exit_tree():
	RemoveListeners()
	
func AddListeners():
	_owner.inputController.cameraZoomEvent.connect(Zoom)
	_owner.inputController.cameraRotateEvent.connect(Orbit)

func RemoveListeners():
	_owner.inputController.cameraZoomEvent.disconnect(Zoom)
	_owner.inputController.cameraRotateEvent.disconnect(Orbit)

func Zoom():
	pass

func Orbit():
	pass

Camera Follow – Lerp

Next lets get the camera to follow around the selection indicator. This isn’t instantiated until runtime, so instead of adding it in the inspector, we’ll do this one with code. Toward the top of the code, we’ll put a variable for the speed, and another to hold the object we are following.

@export var _followSpeed: float = 3.0
var _follow: Node3D

There are a couple general ways to use lerp. One is to have a set start and end point, and update the percentage of distance traveled each frame. This will give you a steady movement, the same speed the entire time. The other method, instead of moving the percentage, we change the start location each frame, and keep the percentage more or less the same. Because the start location is moving, the halfway point keeps moving, advancing our position, but because the total distance is shrinking as we go, the speed slows down as we approach our target. This is more or less what we are using.

In the _process() function we check if there is a node to follow and use a lerp function to smooth out the movement. After that there is a function that we will call later to set the object to follow.

func _process(delta):
	if _follow:
		self.position = self.position.lerp(_follow.position, _followSpeed * delta)

func setFollow(follow: Node3D):
	if follow:
		_follow = follow

Now before this will work, we need to jump over to “Test.gd” for a second and add a quick line to assign our follower. We’ll be giving it a more permanent home in the next lesson. At the end _ready() function, after we’re done assigning a BattleController to the _owner variable, add the following line.

_owner.cameraController.setFollow(_owner.board.marker)

Ok, back to CameraController.gd, lets get those Zoom and Orbit functions fleshed out. We’ll start off with some variables at the top to hold our min and max values along with the current zoom level.

var _minZoom = 5
var _maxZoom = 20
var _zoom = 10

var _minPitch = -90
var _maxPitch = 0

The Zoom() function is relatively simple, but depending on whether we set the camera to Orthogonal or Perspective mode, we use a different value to zoom. I noticed a bit of clipping on the orthogonal camera when the distance got too close, so instead of moving both values at the same time, I locked down the position when using the orthogonal camera.

func Zoom(scroll: int):
	_zoom = clamp(_zoom + scroll,_minZoom, _maxZoom )
	
	if $Heading/Pitch/Camera3D.projection == Camera3D.PROJECTION_ORTHOGONAL:
		$Heading/Pitch/Camera3D.position.z = 100
		$Heading/Pitch/Camera3D.size = _zoom
	else:
		$Heading/Pitch/Camera3D.position.z = _zoom

Orbit() is split into two parts. We use the x value to rotate around the cursor, and the y value to handle the upward angle. The big thing to watch out for here is that even though we are using the mouse ‘x’ value, we will be rotating along the Heading node’s ‘y’ axis. Because we are not in the _process() function, we don’t have access to the variable ‘delta’, so instead we could either pass it to this function, or call get_process_delta_time() like I’ve done here. The two while loops are designed to keep the angle within -360 to 360 degrees. We can’t clamp the value because it would freeze rotation instead of causing it to loop around, so we loop through until we get within the correct range. In reality, it will likely never loop more than once, but better safe than sorry.

A quick note, we use deg_to_rad() because rotation.x uses radians instead of degrees, and instead of using small decimals that aren’t easy to visualize, we use the degrees and let it convert them for us. An angle of 360 degrees is 2 * PI radians.

To make things even more confusing, the vertical rotation uses the Pitch node’s ‘x’ rotation and the mouse’s ‘y’. This time we do want to clamp the value, because we aren’t looping in a full circle, but stopping at a specific low and high point set by _minPitch and _maxPitch earlier. I set the range of values to -90 for the camera pointing straight down, and 0 to be level with the ground. I am however a bit hesitant using a perfect 90 degrees on the camera. There is some potential for some odd behavior when rotation is straight above, although it seems like it is fine for now.

func Orbit(direction: Vector2):
	if direction.x != 0:
		var headingSpeed = 2
		var headingAngle = $Heading.rotation.y
		headingAngle += direction.x * headingSpeed * get_process_delta_time()
		$Heading.rotation.y = headingAngle
		while $Heading.rotation.y > deg_to_rad(360):
			$Heading.rotation.y -= deg_to_rad(720)
		while $Heading.rotation.y < deg_to_rad(-360):
			$Heading.rotation.y += deg_to_rad(720)
		
	if direction.y !=0:
		var orbitSpeed = 2
		var vAngle = direction.y
		var orbitAngle = $Heading/Pitch.rotation.x
		orbitAngle += direction.y * orbitSpeed * get_process_delta_time()
		orbitAngle = clamp(orbitAngle,deg_to_rad(_minPitch), deg_to_rad(_maxPitch) )
		$Heading/Pitch.rotation.x = orbitAngle

There is just one more function I want to add. AdjustedMovement(), this isn’t part of the original tutorial either, but what it does is allow us to change which way we move when the directional buttons are pressed based on the camera’s orientation. That way up will always move up. This is done as a helper function so that we can call it when we need, or ignore it when we don’t, such as for input in menus.

The first step is getting the angle in a format that is easier to understand with rad_to_deg(), the counterpart to what we used earlier, and then based on the angle, we flip the axis to point in the right direction. It’s a bit messy with the if statements, but its easy enough to read and works.

func AdjustedMovement(originalPoint:Vector2i):
	var angle = rad_to_deg($Heading.rotation.y)

	if ((angle >= -45 && angle < 45) || ( angle < -315 || angle >= 315)):
		return originalPoint
		
	elif ((angle >= 45 && angle < 130) || ( angle >= -315 && angle < -210 )):
		return Vector2i( originalPoint.y, originalPoint.x * -1)
		
	elif ((angle >= 130 && angle < 210) || ( angle >= -210 && angle < -130 )):
		return Vector2i(originalPoint.x * -1, originalPoint.y * -1)

	elif ((angle >= 210 && angle < 315) || ( angle >= -130 && angle < -45 )):
		return Vector2i(originalPoint.y * -1, originalPoint.x)

	else:
		print("Camera angle is wrong: " + str(angle))
		return originalPoint

To make use of it we need to go back to Test.gd one last time and modify the OnMove code.

func OnMove(e:Vector2i):
	var rotatedPoint = _owner.cameraController.AdjustedMovement(e)
	_owner.board.pos += rotatedPoint

Summary

And that is the last of this lesson. There was a lot to cover in this lesson. Several different helper scripts, scene nodes and far too many buttons to set up. But we got input working and a camera that has a fair bit of functionality. We learned a couple different techniques for handling various types of input. Learned to send and receive signals so our code can communicate with different parts of our code, and as before, if you are stuck, feel free to check out the lessons code in the repository.

Leave a Reply

Your email address will not be published. Required fields are marked *