Build an isometric 3D game in 2D — #5 More order and move sync

Fj max
6 min readApr 18, 2021

This is part 5 of the 2d/3d(game/engine) conversion used in my puzzle game: Ladder Box, which is available on Steam:

You can find all the textures, scripts in this GitHub repo: https://github.com/fengjiongmax/3D_iso_in_2D

Godot 3.2.x is used in this project, you can check the commits for what’s new in each part of this series.

In the last 4 tutorials, we’ve built a working mechanic that lets a movable move freely on an empty board, even the board has some unmovable here and there.

The problem

If you set the board like this:

new_movable(0,1,0)
new_movable(0,0,0)
new_movable(0,0,1)
new_movable(1,0,0)

run the game and hit ‘Z’, you’ll see:

the one V3(0,0,0) will stay still, but this is not what we want, so let’s figure out how this happened and how to solve this.

When we creating movables, we will add them to the group “movables”, and when we issue a command to make them move, we call the command function in their states one by one, the order of calling the command function is the order we create them, that’s :

new_movable(0,1,0)
new_movable(0,0,0)
new_movable(0,0,1)
new_movable(1,0,0)

This means when the V3(0,0,0) receives the command, both V3(0,0,1) and V3(1,0,0) have not run their set_next_target function in move state yet, which means they haven’t set their game_pos to their correspond target_game_pos, which makes the V3(0,0,0) think there’s a block in front of it and a block right above it, so it stays.

Then this is a similar problem that we solved in the second part of this series, but this will be a bit tricky to solve.

Solution

We need to call the command in a custom order so that we can issue commands and update them correctly, to do that, let’s create a custom script to write codes for that:

class_name Compare# this is key "S" direction
# Vector3.FORWARD = Vector3( 0, 0, -1 )
static func forward_compare(item1:BlockBase,item2:BlockBase):
if item1.game_pos.z > item2.game_pos.z:
return false
elif item1.game_pos.z < item2.game_pos.z:
return true
else:
return y_compare(item1,item2)
# this is key "Z" direction
# Vector3.BACK = Vector3( 0, 0, 1 )
static func back_compare(item1:BlockBase,item2:BlockBase):
if item1.game_pos.z > item2.game_pos.z:
return true
elif item1.game_pos.z < item2.game_pos.z:
return false
else:
return y_compare(item1,item2)
# this is key "X" direction
# Vector3.RIGHT = Vector3( 1, 0, 0 )
static func right_compare(item1:BlockBase,item2:BlockBase):
if item1.game_pos.x > item2.game_pos.x:
return true
elif item1.game_pos.x < item2.game_pos.x:
return false
else:
return y_compare(item1,item2)
# this is key "A" direction
# Vector3.LEFT = Vector3( -1, 0, 0 )
static func left_compare(item1:BlockBase,item2:BlockBase):
if item1.game_pos.x > item2.game_pos.x:
return false
elif item1.game_pos.x < item2.game_pos.x:
return true
else:
return y_compare(item1,item2)
static func y_compare(item1:BlockBase,item2:BlockBase):
if item1.game_pos.y > item2.game_pos.y:
return false
elif item1.game_pos.y < item2.game_pos.y:
return true
else:
# compare by z-index : z-index = x+y+z
if GridUtils.calc_xyz(item1.game_pos) > GridUtils.calc_xyz(item2.game_pos):
return true
else:
return false

We can call the corresponding function according to the key pressed, and we can calculate the correct order by using the sort_custom function in Array:

Sorts the array using a custom method. The arguments are an object that holds the method and the name of such method. The custom method receives two arguments (a pair of elements from the array) and must return either true or false.

Note: you cannot randomize the return value as the heapsort algorithm expects a deterministic result. Doing so will result in unexpected behavior.

Add a function in grid.gd:

func sort_by_direction(direction:Vector3) -> Array:
var _sorted = []

_sorted = get_tree().get_nodes_in_group("movable").duplicate()
match direction:
Vector3.FORWARD:
_sorted.sort_custom(Compare,"forward_compare")
Vector3.BACK:
_sorted.sort_custom(Compare,"back_compare")
Vector3.LEFT:
_sorted.sort_custom(Compare,"left_compare")
Vector3.RIGHT:
_sorted.sort_custom(Compare,"right_compare")

return _sorted

And in main.gd :

var sorted = []
func send_command(command:Vector3) -> void:
sorted = Grid.sort_by_direction(command)
for i in sorted:
i.receive_command({"direction":command})
set_physics_process(true)

also in the update function:

func _physics_process(delta):
for _m in sorted:
_m._update(delta)
pass
pass

Run the scene:

The V3(0,0,0) moves at the beginning. But the V(0,1,0) will fall in the middle of the road, let’s see if we can fix that.

Move sync

As we discussed in the 3rd part of this series, we know that the movable moves in a vector 2 direction but they are in a grid-based board, so after each update, they may not end up in the exact engine location, so they may not reach the target_engine_pos the same time, that causes the fall.

Then we need to sync the movements of the movable, we’re gonna use signal to do that, add these to movable.gd:

signal block_reach_targetfunc reach_target():
emit_signal("block_reach_target")

and in the main.gd, we will call the send command to tell the movable when one of them reaches the target:

func block_reach_target():
for i in sorted:
i._command({"reach_target":true})
pass
pass
  • and connect the movable.block_rach_target signal to movable_into_idle function when creating it.
func new_movable(x,y,z):
var _m = movable.instance()
$movable.add_child(_m)
_m.initial_game_pos(x,y,z)
_m.connect("block_reach_target",self,"block_reach_target")
pass

And call reach_target function every time the block reaches the target, we need to modify move, jump, fall states.

move.gd, replace _update, add _command:

func _update(_delta:float) -> void:
var _after_move = movable.position + engine_direction * _delta * movable.MOVESPEED
var _reach_target = Math.is_betweenv(movable.position,_after_move,target_engine_pos)

if !_reach_target:
movable.position = _after_move
else:
movable.position = target_engine_pos
movable.z_index = target_z
movable.reach_target()
func _command(_msg:Dictionary={}) -> void:
if !_msg.keys().has("reach_target"):
return
movable.engine_fit_game_pos()
var _self_down_axie = movable.game_pos + Vector3.DOWN
if Grid.coordinate_within_rangev(_self_down_axie) && Grid.get_game_axisv(_self_down_axie) == null:
var _down_moved = Grid.get_game_axisv(_self_down_axie + direction)
if !(_down_moved is Movable && _down_moved.get_node("state_machine").state.name == "move"):
_state_machine.switch_state("fall",{"direction":direction})
return

set_next_target()

and same for jump.gd:

func _update(_delta:float) -> void:
var _after_move = movable.position + engine_direction * _delta * movable.MOVESPEED
var _reach_target = Math.is_betweenv(movable.position,_after_move,target_engine_pos)

if !_reach_target:
movable.position = _after_move
else:
movable.position = target_engine_pos
movable.reach_target()
func _command(_msg:Dictionary={}) -> void:
if !_msg.keys().has("reach_target"):
return
movable.engine_fit_game_pos()
_state_machine.switch_state("move",{"direction":move_direction})

fall.gd also:

func _update(_delta:float) -> void:
var _after_move = movable.position + engine_direction * _delta * movable.MOVESPEED
var _reach_target = Math.is_betweenv(movable.position,_after_move,target_engine_pos)

if !_reach_target:
movable.position = _after_move
else:
movable.position = target_engine_pos
movable.reach_target()
func _command(_msg:Dictionary={}) -> void:
if !_msg.keys().has("reach_target"):
return

movable.engine_fit_game_pos()
var _self_down_axie = movable.game_pos + Vector3.DOWN
var _down_moved = Grid.get_game_axisv(_self_down_axie + move_direction)
if _down_moved is Movable && _down_moved.get_node("state_machine").state.name == "move":
if move_direction == Vector3.ZERO:
_state_machine.switch_state("idle",{})
return
else:
_state_machine.switch_state("move",{"direction":move_direction})
return
set_next_target()

And run the scene, you will see:

they move as expected, but you may see some of them wobble a little bit, that’s because when a movable emits signal “block_reach_target” all the movable get set to their target_engine_pos, but in the _physics_process function of main.gd, it may be in the middle of an update, so some movables will move one update cycle faster than others.

this can be fixed by this, use a block_reached_target variable, and set it to true when block_reach_target gets called, and _physics_update will update movables accordingly:

var block_reached_target := falsefunc _physics_process(delta):
for _m in sorted:
if block_reached_target:
block_reached_target = false
break
_m._update(delta)
func block_reach_target():
block_reached_target = true
for i in sorted:
i._command({"reach_target":true})
pass
pass

and no more shaky movables.

That’s it for this article, next article will be the finale of this series, we will be testing a lot and adjusting the behaviors of the movable to make the movement feels natural.

You can find all the textures, scripts in this GitHub repo: https://github.com/fengjiongmax/3D_iso_in_2D

--

--