Monthly Archives: May 2023

Tactical RPG Movement in Godot 4

Recently started going through a nice little tutorial on gdquest.com that teaches grid based movement in Godot.

Only problem is, the code in the tutorial is currently for Godot 3. And there are quite a few differences between Godot 3 and Godot 4.

Some of it is just changing the names of some functions. Some of it is complete behavior change and requires changing the structure of the code.

Below are the scripts from the tutorial with the changes needed for Godot 4 up through step 3 “Creating the Unit” in the GDQuest tutorial. I will include highlights to the necessary changes and commentary to my own changes.

Grid class

# Represents a grid for a tactical game, size width and height and size of each cell in pixels
# helper functions for calculating coordinates
class_name Grid
extends Resource

# grid rows and columns

# first change here is the use of the @ symbol on export
# the decorators in Godot 4 use an @keyword vs 
# just a plain keyword in Godot 3
@export var size := Vector2(30, 30)
@export var cell_size := Vector2(80, 80)

var _half_cell_size = cell_size / 2

# get pixel position from grid coordinates
func calculate_map_position(grid_position: Vector2) -> Vector2:
	return grid_position * cell_size + _half_cell_size
	
# get grid coordinates from pixel position
func calculate_grid_coordinates(map_position: Vector2) -> Vector2:
	return (map_position / cell_size).floor()

# check that cursor or move stays inside grid boundaries

# I just renamed vars / rewrote this to be a little clearer about what it is doing
func is_within_bounds(cell_coordinates: Vector2) -> bool:
	var inside_x := cell_coordinates.x >= 0 and cell_coordinates.x < size.x
	var inside_y := cell_coordinates.y >= 0 and cell_coordinates.y < size.y
	return inside_x and inside_y

# Godot 4 was not really liking using clamp as the name here 
# since it is a built in func
# so just updated it to be gridclamp	
func gridclamp(grid_position: Vector2) -> Vector2:
	var clamped_position := grid_position
	clamped_position.x = clamp(clamped_position.x, 0, size.x - 1.0)
	clamped_position.y = clamp(clamped_position.y, 0, size.y - 1.0)
	return clamped_position
	
func as_index(cell: Vector2) -> int:
	return int(cell.x + size.x * cell.y)

Unit class

# again use of the @ symbol on the decorators
@tool
class_name Unit
extends Path2D

@export var grid: Resource = preload("res://resources/Grid.tres")
@export var move_range := 6

# setget no longer works in Godot 4
# there are a couple of new ways to create your getter and setter
# using a function inside of the set: block triggered an infinite loop
# so we write the setter code here
@export var skin: Texture :
	set(value):
		skin = value
		if not _sprite:
                        # yield has been replaced with await
                        # and we await the value on the self object
			await self.ready
		_sprite.texture = value
	get:
		return _sprite.texture
		
@export var skin_offset := Vector2.ZERO :
	set(value):
		skin_offset = value
		if not _sprite:
			await self.ready
		_sprite.position = value
	get:
		return _sprite.position

@export var move_speed := 600.0

var cell := Vector2.ZERO :
	set(value):
		cell = grid.gridclamp(value)
	get:
		return cell
	
var is_selected := false :
	set(value):
		is_selected = value
		if is_selected:
			_anim_player.play("selected")
		else:
			_anim_player.play("idle")
	get:
		return is_selected
	
var _is_walking := false :
	set(value):
		_is_walking = value
		set_process(_is_walking)
	get:
		return _is_walking
	
@onready var _sprite: Sprite2D = $PathFollow2D/Sprite
@onready var _anim_player: AnimationPlayer = $AnimationPlayer
@onready var _path_follow: PathFollow2D = $PathFollow2D

	
signal walk_finished

func _ready() -> void:
	set_process(false)
	
	self.cell = grid.calculate_grid_coordinates(position)
	position = grid.calculate_map_position(cell)
	
        # function renamed from .editor_hint
	if not Engine.is_editor_hint():
		curve = Curve2D.new()
		
	var points := [
		Vector2(2,2),
		Vector2(2,5),
		Vector2(8,5),
		Vector2(8,7),
	]
	walk_along(PackedVector2Array(points))
		
func _process(delta: float) -> void:
        # .offset has been renamed to .progress
	_path_follow.progress += move_speed * delta
	
        # .unit_offset is now .progress_ratio
	if _path_follow.progress_ratio >= 1.0:
		self._is_walking = false
		_path_follow.progress = 0.0
		position = grid.calculate_map_position(cell)
		curve.clear_points()
		emit_signal("walk_finished")
		
# PoolVector2Array is now PackedVector2Array
func walk_along(path: PackedVector2Array) -> void:
        # there is no path.empty() value
        # so we use not path.size() instead
	if not path.size():
		return
	
	curve.add_point(Vector2.ZERO)
	for point in path:
		curve.add_point(grid.calculate_map_position(point) - position)
		
	cell = path[-1]
	self._is_walking = true
		

I hope this helps you on your journey of making games.
Keep getting wiser, stronger, and better.