Isometric 3D in 2D Environment #5 More order and move sync

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.

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_target
func 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 := false
func _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