00:00
00:00
3p0ch
If you like hard games try my Daxolissian System series

plasmid @3p0ch

Cat

Scientist

Read the manual & try stuff

So Cal

Joined on 2/13/10

Level:
13
Exp Points:
1,650 / 1,880
Exp Rank:
38,330
Vote Power:
5.48 votes
Audio Scouts
1
Rank:
Portal Security
Global Rank:
23,500
Blams:
50
Saves:
375
B/P Bonus:
8%
Whistle:
Normal
Medals:
4,624
Supporter:
3y 3m 14d

Godot tutorial - All the King's Men - The level

Posted by 3p0ch - September 8th, 2022


The level boundary's collision layers and masks

Open the Level scene, and before we add a script to it we'll address an issue that you might have noticed – right now the enemies can't walk off the left edge of the screen because we set up a border there to keep the player from walking off the screen, and of course we need to fix that before we add code to make the level end in Defeat when an enemy walks off the left edge. So now we delve into the Dark Arts of Collision Layers and Collision Masks which no one really understands, even though the Godot docs attempt to explain it at https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html#collision-layers-and-masks yet fail as expected for something this arcane. My own pitiful attempt to explain it is: if two objects A and B overlap, the collision will be detected if 1) A's Mask includes a number where B has a Layer, OR 2) A's Layers includes a number where B has a Mask. I know full well that it makes no sense. But I do know that things end up working if you go to the Player scene and set its root node's CollisionObject2D-Collision-Layer to include both 1 and 2 and set its CollisionObject2D-Collision-Mask to include both 1 and 2, then go to the Level's LeftBorder and RightBorder and set each of them to include ONLY 2 (and NOT 1) for both the CollisionObject2D-Collision-Layer and CollisionObject2D-Collision-Mask, while the Enemy still has the default of just 1 for both the Layer and Mask. I would say something about the player colliding with the left and right borders on layer 2 while the enemies are only in layer 1 and therefore don't collide with the left and right borders which are only in layer 2, but it would be incomprehensible gibberish. Still, if you make those changes and playtest it, then the enemies will be able to walk off the screen while the player can't so things will work like you want.


Text overlays

Now let's move on to things that can be understood by human beings. We're going to want to be able to display a "Victory" or "Defeat" message when the level is won or lost, and further down the line we're going to want to have the level fade in and out on transitions instead of just popping instantaneously. An in general, when you're making more complex games, you'll usually want to have some sort of HUD that shows the player's stats like their HP and maybe ammo and maybe inventory and maybe a minimap of the level, so it's common to have a HUD node that has all of that. That's the approach I took with this game and is a good teaching point, so in your Level scene make a child node that's a Control node and name it HUD. In your own game, you might decide you want to save the HUD as its own independent scene instead of having it just be a child node within the Level node, especially if you have many levels in your game which each have their own .tscn file but all need the same HUD. In that case you would want to make a res://hud directory and save the HUD scene there, and then add it as a child in all of the Level scenes. That's how I did it in my Daxolissian System games anyway.


Since the root node of the scene is a YSort node, and since we want the HUD to appear in front of everything else in the scene, on the HUD node set its Control-Rect-Position property to be (0, 600) so it thinks it's at the very bottom of the screen and will draw itself in front of everything else. We can set its child nodes to have different y-coordinates including negative y-coordinates so they end up on-screen, but since the y-coordinate of the HUD node will be what dictates which nodes get drawn in front of which other nodes, we can use shenanigans like setting a large y-coordinate for the root node to ensure that the HUD gets drawn in front of everything else. You might be wondering why I didn't set the Z index of the HUD node so it shows up in front of everything else? Well, the Z index is a property of Node2Ds and not Control nodes (which is what the HUD is) so tough luck and just deal with this workaround.


In preparation for making the Victory and Defeat text, we need to go get an appropriate font to show them (and other text). Go download it from https://www.1001freefonts.com/prince-valiant.font where you can see that it is indeed a free font that you can use in your game, and rename it prince_valiant.ttf to make things easier. Then in Godot make a res://font directory and put it there. With Godot there's a bit more to it: right click the res://font directory and choose New Resource and make a new DynamicFont named valiant_large.tres (since this will be larger than your usual text) and edit it to have DynamicFont-Settings-Size 80, OutlineSize 5, and OutlineColor black. Then in DynamicFont-Font drag the prince_valiant.ttf.


In the Level scene, give the HUD node a child node of type Label, and make sure that the Label node is a child of the HUD node and not a child of the root Level node. Give that Label node a name of Outcome. For now, let's set it up to show "Victory" because I'm an eternal optimist – set its Text property to be the text Victory and set its Align property to be Center, then under Control set Control-Rect-Position to (0, -400) and Control-Rect-MinSize to (1024, 0). I prefer adjusting MinSize instead of Size because it tends to do what I want more reliably; if you just adjust the Size of a node that inherits from Control then sometimes it ignores it and just does what it wants. In Control-ThemeOverrides-Colors set the FontColor to be yellow (for Victory, the default) and for Control-ThemeOverrides-Fonts drag in the valiant_large.tres font that you made earlier. If that looks nice in the viewport, then in the hierarchy click the eye icon next to Level/HUD/Outcome to make it invisible (since you only want to show it after the level is won or lost).


Make an enemy group

Before writing the code for the Level scene, let's also assign the Enemy scene to a group with name "enemy". We'll want to do this because in order to tell whether Victory has been achieved, the Level scene will need to check whether there are still any enemies in play, and a reliable way to tell how many nodes of a specific type are in play is to use get_nodes_in_group() and see how many nodes are found. To add the Enemy scene to an "enemy" group, open the Enemy scene and click the root node, and in the window on the right click on the Node tab (next to the Inspector tab), then the Groups button on the right near the top. Type "enemy" (without the quotes) in the box and click the Add button, and now the root node of the Enemy scene is in the "enemy" group.


Code for winning and losing a level

Now for code: add this script to the root node of the Level scene

extends YSort

var level_won:bool = false
var level_lost:bool = false

onready var outcome_node = get_node("HUD/Outcome")

func _ready():
	pass

func _physics_process(delta):
	if (not level_lost) and (not level_won):
		if (get_tree().get_nodes_in_group("enemy").size() < 1):
			level_won = true
			outcome_node.visible = true

func lose():
	if (not level_lost) and (not level_won):
		outcome_node.text = "Defeat"
		outcome_node.set("custom_colors/font_color", Color.purple)
		level_lost = true
		outcome_node.visible = true

The booleans level_won and level_lost will be set to false initially and will be set to true once the win or lose conditions are met. Part of the reason for doing that is that I don't want to immediately queue_free() the Level once a win or lose condition is met – I want it to stick around for a couple seconds so the player can see the Victory or Defeat text and, once we add the "allies", be able to tell that they were defeated by an arrow in the back by seeing their corpse and ghost floating up. Suppose the player defeats the last of the enemies, and the level is still running while it's showing Victory, and during that time the hound comes and eats the player? I don't want it to change to a Defeat in that case, so adding the check of whether the level has already been won or lost before checking the win or lose conditions will prevent that from happening. The other thing that we'll do later is make the scene wait and fade to black a little bit after the Victory or Defeat, so in physics_process() near the beginning we can check whether the level has been won or loss and if so then run code to handle making the scene fade out. In general, I've found it useful to have booleans like that to tell if the win or lose conditions for a level have been met yet so I pretty much always do it that way.


We'll check for the win condition in _physics_process(): if the level has not already been won or lost then we'll use get_tree().get_nodes_in_group("enemy") and check its size to see how many enemies are in play, and if it's less than 1 we'll set the level to won and we'll make the node with the victory text visible. Let's look more closely at that command though. When you're starting off learning Godot and seeing commands like this being used, it might at first seem like they just sort of magically work but you're not completely sure why, so you when you see an example you'll just copy/paste it into your own game's code and try to change it as little as possible while hoping it still does what you expect.. So instead, let's take a moment to break things down and see how it works. The method get_tree() (which is run by the Level node and can be found in the API as a method for Nodes) will return the SceneTree that the node is in (the game's active SceneTree), and the SceneTree in the API reference has the method get_nodes_in_group() where if you have a name of a group inside the parenthesis it will return an Array with all the nodes in that group where each element of the array is a node in that group. If you look at the Array in the API reference, you'll find that is has a size() method which returns an integer telling you how many elements are in the array. The SceneTree might still seem like a nebulous thing, and if you'd like to get more of a description of it, then on the API reference page for SceneTree there's a section of Tutorials on the SceneTree with links you can click to see a more descriptive explanation of them.


For the lose condition: instead of just having the Level scene check for enemies walking off the left edge of the screen, we'll plan to make the Enemy scene emit a signal when it walks off the left edge of the screen, and then we'll connect that signal to the lose() function we just added. In the lose() method, if the win or lose conditions have not already been set, then we'll change the outcome node's text and color to be Defeat and make it visible, and set the level_lost boolean to true. Since we haven't added the signal to the enemy and connected it to that function yet, it won't actually be called if you playtest the game right now, but you can test the game and make sure the Victory screen appears if you kill all the enemies.


Making the enemy emit a signal for defeat

Now let's go to the Enemy code and add the signal. First, near the top of the script, insert a line declaring the signal named "reached_end"

extends KinematicBody2D

signal reached_end
export var is_goblin:bool = true

Then in _physics_process() just after the player moves make it emit the signal if it reaches an x coordinate of 0 or less

	move_and_slide(movement_vector)
	if global_position.x < 0:
		emit_signal("reached_end")

In the Level code, set up the _ready() function like this

func _ready():
	for enemy in get_tree().get_nodes_in_group("enemy"):
		enemy.connect("reached_end", self, "lose")

That will use a "for" loop. Remember that get_tree().get_nodes_in_group("enemy") will give you an Array of all of the nodes in the group "enemy", and with Arrays you can use "for" loops to run commands with each of the different elements in the array. Between the "for" and "in" of that command you can specify a name to refer to the current element in the array that the "for" loop is looking at – basically think of it like declaring a new variable called "enemy" and for each element in the array of nodes it will run the following block of code, in this case attaching that enemy's "reached_end" signal to the "lose" function that's found in self (the Level's code).


If setting up signals seemed like a hassle, you could have used other approaches like in the Level's _physics_process() function writing a for loop to make it loop through all the enemies found with get_tree().get_nodes_in_group("enemy") and check their global_position.x to see if it's less than zero. But in this game I know that there will be multiple different things that can lead to Defeat – an enemy could reach the end, or the player could get hit by an arrow, or the player could be burned to a crisp, or the player could be mauled by a hound. So it seemed like things would be cleaner if I have a lose() function that handles all the stuff that the Level should do when the game is lost and use signals to connect the various different things that could make the level be lost. And using signals can be useful when you're programming in general, so it's worth seeing an example like this of how to set up a custom signal. Go ahead and play test the game and the Defeat should show up properly if you let an enemy through.


Setting up a "story" scene

Now things work, but in the actual game once the level is won or lost we won't want it to just sit there with "Victory" or "Defeat" showing forever. In this game I had a separate screen with the king talking to the player to give the game a little bit of a story, so let's make that Story scene and transition back and forth between it and the Level scene.


Make a directory res://story and make a new scene with a root node being a Sprite named Story. From the assets I made, drag background.png into the res://story directory, drag that to the Story node's Texture property, and turn off Sprite-Offset-Centered. Add a child Sprite node and go download an asset pack including the picture of the king from https://kiddolink.itch.io/fantasy-npc-non-playable-characters-pack. Rename the picture of the sketch drawing of the king "king.png" and drag it into res://story, then drag that into the child sprite's Texture and position it so it looks nice (I used Sprite-Offset-Centered off and Node2D-Transform-Position (0, 130)). To the root Story node, add another child node of type Label. In the Label node's Text property, type the following text for the first level (it's fine for the Text to have multiple lines)

Sir Knight, the goblins of the East are laying seige.

Pray thee, vanquish the vile invaders lest they reach our farmlands.

Move with WASD / arrows
and use thy mouse to smite them!

The default font looks horrible, and the DynamicFont resource we made earlier would be too large for this text box. So go back to the res://font folder and right click on it to make a New Resource and choose DynamicFont named valiant_medium.tres. Set it to have Size 36, OutlineSize 3, OutlineColor black, and UseFilter on, and in DynamicFont-Font-FontData drag the prince_valiant.ttf. Back in the Story scene's Label node, in Control-ThemeOverrides-Fonts drag that new font you just made and you'll get a better sense of how things look. Under the Label properties set Align to Center, Valign to Center, and Autowrap On. To position it, in Control-Rect set Position to (400, 50) and MinSize to (600, 400). (You might need to click that Undo button next to the Size property after you set MinSize to make things look right.) For the button: first add a child node to Story of type CenterContainer and set its Control-Rect to have Position (400, 500) and MinSize (600, 100). Then give that CenterContainer node a child node of type Button – usually you'll probably want to use a TextureButton instead of just a Button because that lets you make slick graphics for each of the button's states (idle, mouse hover, pressed, disabled, etc.) but a regular Button will work for a game jam or a tutorial. The CenterContainer node just serves to keep the Button node centered to align underneath the text since that's how Godot''s UI design works – the CenterContainer is a container type of node, and containers are used to keep their child nodes arranged how you want. I won't go into detail here, there's a huge discussion on UI design and Control nodes in the Godot docs at https://docs.godotengine.org/en/stable/tutorials/ui/index.html but for now just type some text in the Button node's Text property like " A'ight " (where I added a couple of spaces on each side to have some padding on the button's borders) and in Control-ThemeOverrides-Fonts drag the new font you made, then in Control-ThemeOverrides-Colors set all of the colors to yellow. When I make buttons I tend to like to set the BaseButton-ActionMode to be ButtonPress so things happen immediately when the player clicks the button instead of waiting for click and release, which would also mean you wouldn't need to worry about setting up additional font colors for the Pressed state (or making new pictures for the Pressed state if you use a TextureButton instead of a plain old Button). Save the scene as res://story/story.tscn.


I'll mention that I've been using ThemeOverrides a lot. If you're making a larger game, you'll probably want to make use of Themes where you can set up default fonts for text boxes, colors for buttons, and in general decide how you want your UI elements to look by default, so you could just tell all of your UI nodes to use that Theme and not have to set all of the individual properties in ThemeOverrides every time you add a UI node. That's also covered in the Godot docs' UI section linked above. Also, notice that I intentionally misspelled seige to serve as a teaching point: Godot didn't give you any indication of a misspelled word and doesn't have any internal spelling or grammar checking, so when you're writing a lot of text you'll probably want to type it in a word processing program and then paste it into Godot.


Transitioning between the story and level scenes

Now add some code to the Story scene to make it switch to the Level scene when you click the button. You could do this

extends Node2D

onready var button_node = get_node("CenterContainer/Button")

func _ready():
	button_node.connect("pressed", self, "start_level")

func start_level():
	get_tree().change_scene("res://level/level.tscn")

This will work, but be aware that the command of get_tree().change_scene("res://level/level.tscn") is inefficient and if you're going to load a large and complex level then the game might hang temporarily during the loading process and look janky. After we introduce Autoloads, I'll show you a different way to do it which worked reliably for me.


Now that we can transition from the Story scene to the Level scene, we'll also need to set up the Level scene to go back to the Story scene after a level is won or lost. First in the Level scene let's add a way to make the scene fade in and out. On the HUD node, add a child TextureRect and name it Overlay. From my assets, drag white_pixel.png to res://level and then drag it to the Overlay node's Texture property, turn on Expand, and set Control-Rect to have Position (0, -600) and MinSize (1024, 600). The screen should now be covered in white, so next set the property CanvasItem-Visibility-Modulate to have a color of black (which is what we'll want to fade to) and an A value (opacity) of zero, and then the scene will look like normal again.


A bug that I promise you'll encounter and need to be aware of

But I've intentionally laid a trap for you, and one which many Godot devs fall prey to. Try playtesting your game and you'll be in for a surprise because now that player's sword swipe is no longer working! So what broke? The issue is in how the mouse click is being handled. When we added the Overlay node, it covered the entire screen. Control nodes tend to be UI elements like Buttons that might consume mouse clicks, and that's exactly what's going on here. Godot is making the mouse clicks be detected by the Overlay node which isn't doing anything with them, instead of passing them on to the Player node so it can swipe the sword. If you had a complex UI with a menu on top of other things then that would be what you want – if a player clicks a button on the UI then you don't want other things underneath it to also respond to the mouse click. But in this case it's not what we want and it's breaking our game. To fix it, in the Overlay node's properties set Control-Mouse-Filter to Ignore, and now it will ignore mouse input so it will be detected by the Player and the game will work again. Believe me, this happens a lot and will mess you up if you don't realize what's going on.


Fading out the level

Now let's expand the Level's code to handle transitions and making the Overlay fade the level in and out.

extends YSort

var level_won:bool = false
var level_lost:bool = false
var pause_until_fade:float = 2.0
var fade_duration_max:float = 1.0

var fade_duration:float = 0.0
onready var outcome_node = get_node("HUD/Outcome")
onready var overlay_node = get_node("HUD/Overlay")

func _ready():
	for enemy in get_tree().get_nodes_in_group("enemy"):
		enemy.connect("reached_end", self, "lose")

func _physics_process(delta):
	if (not level_lost) and (not level_won):
		if (get_tree().get_nodes_in_group("enemy").size() < 1):
			level_won = true
			outcome_node.visible = true
	else:
		if pause_until_fade > 0.0:
			pause_until_fade -= delta
		else:
			fade_duration += delta
			if fade_duration < fade_duration_max:
				overlay_node.modulate.a = fade_duration / fade_duration_max
			else:
				get_tree().change_scene("res://story/story.tscn")

func lose():
	if (not level_lost) and (not level_won):
		outcome_node.text = "Defeat"
		outcome_node.set("custom_colors/font_color", Color.purple)
		level_lost = true
		outcome_node.visible = true

Now we've set up the variables pause_until_fade, fade_duration_max, and fade_duration, and in the _physics_process() function we added an else: block that will run if the level has been either lost or won. In that else block: first we see if the pause_until_fade has reached zero yet and if not we subtract delta from it, and if the pause_until_fade has reached zero then we count up fade_duration. If it hasn't reached fade_duration_max, the we set the Overlay node's Modulate property's opacity to be fade_duration / fade_duration max (making the overlay gradually become more opaque as fade_duration gets closer to fade_duration_max), and when fade_duration reaches fade_duration max we switch to the Story scene. As an aside, if you wanted to make it so the level would just restart instead of going back to the Story scene when the player is defeated, you could do that with get_tree().reload_current_scene() and I'll leave that as an exercise for the reader. Go ahead and test it out, and this is starting to feel a little more like a game. Next we'll add our "allies".


1

Comments

Comments ain't a thing here.