2d top down, how to handle jumping

Godot Version

4.2.2

Question

Looking for a method to handle Z levels in a 2d top down game.

Problem:
Have a 2 tile horizontal wall and the “second” layer such as a jumpable surface of a kitchen counter is on top of a “wall” tile (2 tilemaps).

What I tried so far:

  • Using heightmap tilemap - the main issue is that it introduces a custom map problem where I need to manually setup the height of each tile underneath and if you change the overhead map, you add a lot of manual work. Mainly it means that you cannot have procedural maps. But also without going very detailed of 1:1 tile size, its very hard to have a “generic” large enough tile as different surfaces are of different size.

  • Have 3 physics layers related to physics layers that I flick on an off in the state machine during the jump:

const LAYER_ALWAYS_WALLS: int = 1
const LAYER_JUMPABLE: int = 2
const LAYER_IGNORABLE_HEIGHT: int = 32

func _enter_state() -> void:
	match current_state:
		STATE.JUMP:
			collision_mask -= Globals.LAYER_JUMPABLE + Globals.LAYER_IGNORABLE_HEIGHT
			_animate_jump() # A tween of player sprite jumping and landing animation, doesn't move actual player coordinates

func _exit_state() -> void:
	match current_state:
		STATE.JUMP:
			collision_mask += Globals.LAYER_JUMPABLE + Globals.LAYER_IGNORABLE_HEIGHT
			is_jumping = false

func _update_state(delta: float) -> void:
	var direction = Input.get_vector("left", "right", "up", "down")

	match current_state:
...
		STATE.JUMP:
			if velocity != Vector2.ZERO:
				velocity = last_move_direction * speed * dash_multiplier
				move_and_slide()

But I am struggling to deal with the scenario of “falling” where if the character ends up on a wall part of the tile and should fall down to the nearest walkable surface. In the image it would be for example falling ontop of the cooker or fridge.

So I wanted to ask, what kind of strategies I can use for dealing with this?

Saw there ishttps://forum.godotengine.org/t/how-to-create-different-height-levels-in-a-top-down-2d-game/47906/5? topic, however with this, we are back to custom handcrafted maps and you cannot use procedural generation. I don’t mind if that’s the case, but would be cool if there are other methods.

from a very long history of Games. It’s simply not recommended to use your character in that way. Instead have your character remain still on the ground and have the character elements jump in Z. Then it’s also recommended to use your own layers for ground, jump and fly. It’s usually a problem trying to get your character back on the ground position as it was.

it’s strictly because you’re referring to isometric view. hard 2d side view is okay to do whatever you like.

That is what happens, only the sprite moves to mimic the jump, character itself and collisions associated with it, remain in the ground.

you’re talking about the Cat character. so what’s the problem. Have the floor. Then you have the counter top floor. Then you got the wall. And, finally counter wall to prevent passing the counter. It’s just 4 elements.
jodot9

Just make sure the tree hierarchy is correct as well. Because the earliest items are placed in the back.
The higher the number the more they move forward too. Then there’s the amount of collision layers.
jodot10

Thank you for the lovely diagram, it is almost correct, except the counter top and counter wall are on top of each other (on the same tilespace but using 2 different tilemaps).

The wall is 2 tiles high, so the problem is while I have no issues with this behaviour on the west side, on the north facing wall, the wall which overlaps the kitchen counter interfering with collisions.

Since both the wall and kitchen counter has their own collision shapes for them kitchen counter’s collision is around corners to stop you falling off without “jumping off”.

And the character collides both with the wall collision and kitchen counter collision. The latter is not an issue as during the jump, I disable the collision mask for the border areas, but the former collision still happens with the wall and character ends up stuck.

But also if I jump on the counter on the left side, and try to walk up north, it is colliding with the wall mask below the kitchen counter

Also I am not entirely sure how can I detect correctly when the player “overjumps” the counter and needs to “fall” down the wall.

Actually, just had an idea based on you diagram, if I create a “elevated space” physics level, I can check if the character body is inside this area and disable the “walls” collisions while the character is on that area.

And I can probably use the same technique to figure out when the character will “land” inside the wall and make it fall down to the nearest “surface”

Think 3 physics levels are enough for this. Will try this approach, it ought to be generic enough to not have to create custom areas for each map.

Thanks for the idea!

Ok, failed to make it work, thought that maybe I can use an area2d to see what physics layers are being interacted with, but best I can get is tilemaps

But really not clear how I can get to the actual physics layer that is being interacted with.

Got this function trying to get this info using an area2d

func _get_elevation_layer() -> int:
	var elevation_layer: int = 0
	var overlapping_bodies = heightmap_layer_area.get_overlapping_bodies()
	for overlap_body in overlapping_bodies:
		if overlap_body is TileMap:
			printt("overlapping body: ", overlap_body.name)
			var tile = overlap_body.local_to_map(heightmap_layer_area.global_position)
			if tile:
				# Unclear on what to do after
				printt("tile: ", tile)

	return elevation_layer

This produces desired output if I am jumping on top of kitchen counters.

overlapping body: 	RoomBuilder (floor tile)
overlapping body: 	Kitchen (elevation_1 tile)

But can’t figure out how to know with with layer I am colliding inside those tiles.

Also tried using signals

func _on_heightmap_layer_area_body_entered(body):
	printt("_on_heightmap_layer_area_body_entered: ", body)


func _on_heightmap_layer_area_body_exited(body):
	printt("_on_heightmap_layer_area_body_exited: ", body)
_on_heightmap_layer_area_body_entered: 	PlayerCharacter:<CharacterBody2D#160859948268>
_on_heightmap_layer_area_body_entered: 	RoomBuilder:<TileMap#160658621688>
_on_heightmap_layer_area_body_entered: 	Kitchen:<TileMap#160742513378>
_on_heightmap_layer_area_body_exited: 	Kitchen:<TileMap#160742513378>
_on_heightmap_layer_area_body_entered: 	Kitchen:<TileMap#160742513378>

But the body here turns out to be the tilemap and once again I am not sure how to extract the physics layer information I need.

Any ideas I can try to go from a tilemap to getting with which physics layer something happened with?

Alternatively think my only remaining option is to add custom shapes onto the map and go via custom everything route :frowning:

There are many different approaches you can take here.

You can use tile by tile movement and check the blocks beside you. As you could give tiles variables like can-not-enter or jump-able. Which could restrict movement or allow the player to automatically move to other side.

There are also 2.5D tutorials with 3D movement. Where you can use the 3d tutorials to setup the controller. This would make jumping easy.

Tile by tile movement doesn’t seem like it would work to me, as the kitchen counter tiles tend to have areas which are not entire tiles. In the screenshot at the top, the horizontal kitchen counter tiles actually extend up until the wall texture changes colour. That’s why I originally went down the collision shapes route.

But I am curious about the 2.5D tutorials, would mean changing the gameworld into a 3d node and setting up 3D shapes of the counters? I tried to do this as an experiment (attached video)

Is this what you mean by 2.5D?

Yeah. Just like that. You just need to change the camera angle and perspective to make the game appear more 2D than the typical 3D. You also tilt the character plane so that the angle matches the camera.

Thanks, will try it. I struggled to even get this camera tilt to work (first time playing around with 3d things), would you happen to know any tutorials that explain how to achieve the more 2d top down camera look?

But jumping and collisions were so much simpler in this method, just the initial “I have no idea what I am doing” was pretty scary to setup and overall the world looked… not amazing and not as cute as I wanted, but think I need to give it more of a stab. (that video is actually relatively old as I tried it before as a solution to the collisions, but was disappointed by the look and feel of it. But sounds like I should revisit this again :thinking: )

Once you change the perspective and the look of things. You will notice how much different things can look. Just use YouTube to look up basic things, the feel of the game will be based on your own art style.

Here are two examples of 2.5D games with different perspectives.

Wow thanks for the videos! The gdquest one looks like just what I need! Will check it out thanks!

Gonna need to re-write the state machine on another node for character control :laughing:

Hope that helps. You really notice the difference when changing the camera projection to orthogonal. That makes things super 2D like.

1 Like

Sort of had an idea, if I have a separate multiple area2ds on specific elevated_x physics levels, for example elevated1Area and elevated2Area, then maybe I can use separate on_body_entered_elevatedXArea to see which areas are entered and left? Is this a reasonable solution or is there a better way than making a unique detector for each physics layer?

For now I am still more comfortable with 2d space, rather than going fully 3d, think I am most unsure how to create proper 3d assets that don’t look rubbish out of what essentially are low res sprites for both player and decorations (I also only have the character sprites walking left and right, not other directions, so that kinda looks out of place in a 3d world).

You might need two layers for the character and move them between those layers while jumping past a certain height. Not sure how to do any of that myself, because you’re basically writing your own custom physics.

Okay, after some blood, sweat and tears, got something that generally works (it of course needs a lot more refining, but the general idea of multi-level jumping in a completely 2d world is doable for a small number of surfaces). Here’s a short video

tl;dr for those who don’t want to read, its most likely overly complicated and has some edge cases that need ironing out, but generally works for what I wanted to do.

For example jumping into the left hand wall while on the kitchen surface, will land you on a jumpable area and you fall down. Also its kinda tricky to jump up onto the fridge top (elevation 2) but possible.

If anyone else is wondering about it, here what it contains:

  • 3 physics layers to walk around on: floor, elevation 1, elevation 2
  • 3 physics layers to handle falling: jumpable, always wall and ignorable wall
  • Character2D with individual Area2D’ for each physics layer to detect
  • Tilemaps use all physics layers
  • Limitation: Assumes that the “higher” level is higher than previous in bitmask terms

Welcome any improvements!

Thinking about it, ignorable wall can most likely be collapsed into jumpable and save you a physics layer. Will try it later.

Layer’s bitmask is defined in a global autoload and has 2 helper functions to deal with

const LAYER_NONE: int = 0
const LAYER_FLOOR: int = 1
const LAYER_JUMPABLE: int = 2
const LAYER_ALWAYS_WALL: int = 4
const LAYER_IGNORABLE_WALL: int = 8
const LAYER_ELEVATED_1: int = 16
const LAYER_ELEVATED_2: int = 32

func add_collision_layer(collision_mask: int, layer_bitmask: int) -> int:
	return collision_mask | layer_bitmask


func remove_collision_layer(collision_mask: int, layer_bitmask: int) -> int:
	return collision_mask & ~layer_bitmask

Ended up creating multiple collision areas under the base collision shape, each interacting with an individual layer
collision-areas

Then in the character script got a bunch of code to deal with setting and unsetting a stack of areas with which the areas are colliding.

func _on_layer_floor_area_body_entered(_body):
	if _body.name == self.name:
		return
	if Globals.LAYER_FLOOR not in _current_layers_stack:
		_current_layers_stack.append(Globals.LAYER_FLOOR)


func _on_layer_floor_area_body_exited(_body):
	if _body.name == self.name:
		return
	_current_layers_stack.erase(Globals.LAYER_FLOOR)


func _on_layer_elevated_1_area_body_entered(_body):
	if _body.name == self.name:
		return
	if Globals.LAYER_ELEVATED_1 not in _current_layers_stack:
		_current_layers_stack.append(Globals.LAYER_ELEVATED_1)


func _on_layer_elevated_1_area_body_exited(_body):
	if _body.name == self.name:
		return
	_current_layers_stack.erase(Globals.LAYER_ELEVATED_1)

func _on_layer_elevated_2_area_body_entered(_body):
	if _body.name == self.name:
		return
	if Globals.LAYER_ELEVATED_2 not in _current_layers_stack:
		_current_layers_stack.append(Globals.LAYER_ELEVATED_2)


func _on_layer_elevated_2_area_body_exited(_body):
	if _body.name == self.name:
		return
	_current_layers_stack.erase(Globals.LAYER_ELEVATED_2)


func _on_layer_jumpable_area_body_entered(_body):
	if _body.name == self.name:
		return
	if Globals.LAYER_JUMPABLE not in _current_layers_stack:
		_current_layers_stack.append(Globals.LAYER_JUMPABLE)


func _on_layer_jumpable_area_body_exited(_body):
	if _body.name == self.name:
		return
	_current_layers_stack.erase(Globals.LAYER_JUMPABLE)


func _on_layer_ignorable_wall_area_body_entered(_body):
	if _body.name == self.name:
		return
	if Globals.LAYER_IGNORABLE_WALL not in _current_layers_stack:
		_current_layers_stack.append(Globals.LAYER_IGNORABLE_WALL)


func _on_layer_ignorable_wall_area_body_exited(_body):
	if _body.name == self.name:
		return
	_current_layers_stack.erase(Globals.LAYER_IGNORABLE_WALL)

This produces debug like

*** Area: jumpable - enter, stack: 	[1, 16, 2]
*** Area: elevated 1 - exit, stack: 	[1, 2]

Some helper functions to decide if the character is falling and setting of collision masks based on which walkable level character is on and setup of the layer stack.

var _current_layers_stack: Array[int] = [Globals.LAYER_FLOOR]

func _is_falling() -> bool:
	if _current_layers_stack.has(Globals.LAYER_JUMPABLE) or _current_layers_stack.size() == 0:
		return true
	if (
		_current_layers_stack.has(Globals.LAYER_IGNORABLE_WALL)
		and not _current_layers_stack.has(Globals.LAYER_FLOOR)
		and not _current_layers_stack.has(Globals.LAYER_ELEVATED_1)
		and not _current_layers_stack.has(Globals.LAYER_ELEVATED_2)
	):
		return true
	return false


func _set_collision_masks() -> void:
	match _current_layer:
		Globals.LAYER_FLOOR:
			collision_mask = Globals.add_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
		Globals.LAYER_ELEVATED_1:
			collision_mask = Globals.remove_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
		Globals.LAYER_ELEVATED_2:
			collision_mask = Globals.remove_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
		_:
			printt("Unexpected layer: ", _current_layer, collision_shape.global_position)


func _get_current_elevation() -> int:
	var _highest_layer: int = 0
	if _current_layers_stack.size() > 0:
		_highest_layer = _current_layers_stack.max()
	return _highest_layer

I am using a state machine to decide what character is doing, but basically when you enter the jump state

		STATE.JUMP:
			collision_mask = Globals.remove_collision_layer(collision_mask, Globals.LAYER_JUMPABLE)
			collision_mask = Globals.remove_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
			_animate_jump()
			is_jumping = true

And when you leave the jumping state

		STATE.JUMP:
			collision_mask = Globals.add_collision_layer(collision_mask, Globals.LAYER_JUMPABLE)
			collision_mask = Globals.add_collision_layer(collision_mask, Globals.LAYER_IGNORABLE_WALL)
			is_jumping = false
			velocity = Vector2.ZERO
			_set_collision_masks()

Then during the jumping process itself

		STATE.JUMP:
			if velocity != Vector2.ZERO:
				velocity = last_move_direction * speed * (dash_multiplier/3)
				var collision = move_and_collide(velocity * delta)
				if collision:
					_set_state(STATE.FALL)
                                        return
			# Jumping in place
			if !animation_player.is_playing():
				_set_state(STATE.IDLE)

Tilemap setup involves setting multiple areas to the different physics layers. for example kitchen counter inner area is elevation 1 and the black border around it is jumpable
Same for the walls, some parts are jumpable and the “top” of the walls is always wall