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,648 / 1,880
Exp Rank:
38,274
Vote Power:
5.48 votes
Audio Scouts
1
Rank:
Portal Security
Global Rank:
23,488
Blams:
50
Saves:
375
B/P Bonus:
8%
Whistle:
Normal
Medals:
4,619
Supporter:
3y 3m 6d

Godot tutorial - All the King's Men - Enemies

Posted by 3p0ch - September 8th, 2022


This game has two types of enemies – slimes and goblilns – that are overall pretty similar but with minor differences (their hp and movement speed, and of course their sprites). In broader terms, it's common for games to have different types of enemies that are in many ways the same (they have hp that gets drained when the player attacks them and they go poof when the hp is depleted) and in other ways different (how they move, how they attack the player, etc). There are a few different ways to go about making the enemies.


1) Make a completely new scene and script for each different type of enemy. This will work, and most beginners will probably do it that way, and you'll probably want to make your first game or two that way to keep things simple to understand when you're just starting out. But you'll have to re-write a lot of the "groundwork" code for each enemy. While copy/pasting code can be done, in general you'll want to avoid doing it if possible – if you have a game where the enemies spawn into the middle of the scene and you want to change the spawning process for all enemies to play a glittery animation when they spawn, or be added as a potential target for the player's multi-strike attack when they spawn, then it's better to have the spawning code that's common to all enemies be in one place instead of having to go and edit it for each of the different enemies.


2) Make a single Enemy scene and script that controls every type of enemy. That's what I did in this game, but only because the enemies are very similar, and I wouldn't recommend doing this as your usual approach because if you make a lot of different enemies with a lot of different behaviors then putting all the code into a single script would make it get hideously complicated. But for this simple game, I'll show you how I did it.


3) Make a base Enemy class that handles the stuff that all (or almost all) enemies in the game should do, and then make separate SpecificEnemy scenes that extend Enemy and do things that are unique to that enemy type. This is what I usually do in more complex games like Daxolissian System, so I'll also show you how to do that but much later.


The enemy node hierarchy

First for approach #2: make a res://enemy folder. Go to the pack of sprites that you got earlier and drag all of the slime_run_anim and goblin_run_anim sprites into the res://enemy folder. Make a new scene with a root node of KinematicBody2D and name it Enemy. Give it child nodes of an AnimatedSprite and a CollisionShape2D. In the AnimatedSprite, for its Frames property make a new SpriteFrames and edit it to have one animation call slime with all of the slime sprites and another animation called goblin with all of the goblin sprites, and set the speed for each of them to be 15 FPS. In the AnimatedSprite's Node2D-Transform set the Scale to (2, 2) and adjust the position so the "feet" are at the center of the Enemy node (both while the slime animation is playing and while the goblin animation is playing – you can turn the AnimatedSprite's Playing property on and switch the Animation property to see how they look in the viewport). I did it by setting the Node2D-Transform-Position to (0, -8). Lastly, since the sprites are facing right but we want the enemies to run toward the left, set the AnimatedSprite's FlipH property to On. Then for the ColisionShape2D set its shape to a CapsuleShape2D and edit it to have a radius of 6 and height of 14 or so, and set the CollisionShape2D's Node2D-RotationDegrees to 90. Make sure the AnimatedSprite's Animation property is set to slime (we'll set things up so that the slime is the default enemy type) and save the scene as res://enemy/enemy.tscn.


The enemy code

Now for the code: attach a script to the root Enemy node and give it the following code.

extends KinematicBody2D

export var is_goblin:bool

var speed_min:float = 50.0
var speed_max:float = 100.0
var goblin_speed_multiplier:float = 0.8
var slime_hp:int = 2
var goblin_hp:int = 4

var speed:float
var hp:int
var movement_vector:Vector2 = Vector2.ZERO

onready var sprite_node = get_node("AnimatedSprite")

func _ready():
	hp = slime_hp
	speed = rand_range(speed_min, speed_max)
	if is_goblin:
		hp = goblin_hp
		sprite_node.animation = "goblin"
		speed *= goblin_speed_multiplier

func _physics_process(delta):
	movement_vector = Vector2(-speed, 0)
	move_and_slide(movement_vector)

Before I describe what that code does, save it and go to the Level scene and add a few Enemy nodes to the center of it. If you click on an Enemy node in the Level's hierarchy, then in the Inspector you'll see that it has a property called IsGoblin that you can click to turn on. It doesn't look like it does anything to the enemy when you're looking at the Level scene in the editor, but if you play the game then any enemies where you turned on IsGoblin should end up being goblins. When you declare variables with the keyword "export" in front, then you can set them for each instance in the editor like that, which can be handy if you want to manually set parameters for specific enemies within the levels of your games. If you go back to the enemy.gd script and change that line to

export var is_goblin:bool = true

then it will by default have a value of true, and you can try adding a few more enemies to the level to see it work and that you should instead manually uncheck it if you want the enemy to not be a goblin.


The actual code so far is pretty simple. We'll want the enemies to each have different random speeds instead of all moving at the exact same speed, so we have the speed_min and speed_max variables and in _ready() we set this instance's actual speed to be a random number between them with the line

speed = rand_range(speed_min, speed_max)

We also have variables defining the slime's hp and the goblin's hp, and another variable just called hp which will be the hp for the specific enemy instance depending on whether it's a slime or a goblin. In _ready() it first sets the hp and speed to be the values for a slime, then if is_goblin is true it changes them (and the animation that the AnimatedSprite is playing) to be the values for goblins, including multiplying the speed by a multiplier so goblins are usually a little slower than slimes. In _physics_process() we just have the enemy move left according to whatever its speed is. You might be wondering why there are two lines there instead of just

move_and_slide(Vector2(-speed, 0))

and the reason is that we'll be doing some more complicated stuff later to modify the movement_vector before actually moving the enemy.


Testing and tweaking - HP bars

If you play the game now, the enemies walk left and you can see in the Output panel that it's properly detecting collisions when the enemies get hit with the sword. So next let's add code to make the enemies actually get damaged when you swipe them. The HP bar will be useful to have as a visible indicator, so in the Enemy scene add a new child node of type ProgressBar and name it HPBar. Set its ProgressBar-Percent-Visible property to false (unchecked), Range-Step to 1, and Range-Value to 50 (so when we look at it in the viewport we can see how it looks when it's 50% filled). To give it intuitive colors, under Control-ThemeOverrides-Styles make a new StyleBoxFlat for both the foreground and background and edit them to be a nice shade of green for the foreground and red for the background. Set Control-Rect-Position to (-20, -40) and Size to (40, 6) to get it centered a little above the enemy's head, then save the Enemy scene. This was pretty quick and dirty, and you'll probably want to use a TextureProgress node instead of the humble ProgressBar node to be fancier in your own games.


Now for the code, we'll add some variables to handle the cooldown for making the HP bar visible (I'll only want it to be visible briefly after an enemy is struck and then disappear), a reference to the HPBar node with the hp_bar variable, a command in _ready() to set the hp bar to full health, and a function called take_damage() that will run when the enemy gets hit. With game design, it's pretty common to have a weapon or anything that inflicts damage 1) look for collisions with any targets that it can inflict damage on, and 2) tell its target to run a method to actually take the damage, while the target is responsible for carrying the code saying what it will do when it gets damaged. The enemy script will be

extends KinematicBody2D

export var is_goblin:bool = true

var speed_min:float = 50.0
var speed_max:float = 100.0
var goblin_speed_multiplier:float = 0.8
var slime_hp:int = 2
var goblin_hp:int = 4
var hp_bar_cooldown_max:float = 2.0

var speed:float
var hp:int
var movement_vector:Vector2 = Vector2.ZERO
var hp_bar_cooldown:float = 0.0

onready var sprite_node = get_node("AnimatedSprite")
onready var hp_bar = get_node("HPBar")

func _ready():
	hp = slime_hp
	speed = rand_range(speed_min, speed_max)
	if is_goblin:
		hp = goblin_hp
		sprite_node.animation = "goblin"
		speed *= goblin_speed_multiplier
	hp_bar.max_value = hp

func _physics_process(delta):
	if hp_bar_cooldown > 0.0:
		hp_bar_cooldown -= delta
	else:
		hp_bar.visible = false
	
	movement_vector = Vector2(-speed, 0)
	move_and_slide(movement_vector)

func take_damage():
	hp -= 1
	if hp <= 0:
		queue_free()
	hp_bar.value = hp
	hp_bar.visible = true
	hp_bar_cooldown = hp_bar_cooldown_max

We'll also need to go back to the Player script and make the swipe actually inflict damage instead of just printing stuff to the Output window. To do it, we know that get_overlapping_bodies will give you an array of all of the bodies that the swipe overlaps. Those bodies could be an enemy, or could be the player, or could be an invisible border of the level. So we'll look at each body the swipe overlaps and test to see whether it has the take_damage() method that we just added to the enemy, and if so then we'll tell the body to run its take_damage() method. So the player's code will now have

	# Swipe
	if mouse_is_clicked and swipe_cooldown <= 0.0:
		swipe_cooldown = swipe_cooldown_max
		swipe_sprite.flip_v = not swipe_sprite.flip_v
		swipe_pivot.visible = true
		for body in swipe_area.get_overlapping_bodies():
			if body.has_method("take_damage"):
				body.take_damage()


Testing and tweaking - Knockback from hits

You should be able to test that out now and it should work. But it seems sort of bland, doesn't it? Let's add some knockback effect so if there's a hit then the player and enemy (or enemies) will be pushed apart some. Here's the code with knockback added for the player.

extends KinematicBody2D

var speed:float = 400.0
var swipe_cooldown_max:float = 0.3
var swipe_display_time:float = 0.05
var strike_knockback_force:float = 200.0
var strike_knockback_cooldown_max:float = 0.5

var move_vector:Vector2
var swipe_cooldown:float = 0.0
var last_mouse_position:Vector2 = Vector2.ZERO
var mouse_is_clicked:bool = false
var strike_knockback_normalized:Vector2 = Vector2.ZERO
var strike_knockback_cooldown:float = 0.0

onready var swipe_pivot = get_node("SwipePivot")
onready var swipe_area = get_node("SwipePivot/Swipe")
onready var swipe_sprite = get_node("SwipePivot/Swipe/Sprite")
onready var sprite_node = get_node("AnimatedSprite")

func _ready():
	pass # Replace with function body.

func _physics_process(delta):
	# Cooldowns
	if swipe_cooldown > 0.0:
		swipe_cooldown -= delta
		if not swipe_cooldown > swipe_cooldown_max - swipe_display_time:
			swipe_pivot.visible = false
			sprite_node.rotation = 0.0
	
	if strike_knockback_cooldown > 0.0:
		strike_knockback_cooldown -= delta
	
	# Movement
	move_vector = speed * Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	if move_vector.length() > 0.1:
		sprite_node.animation = "run"
	else:
		sprite_node.animation = "idle"
	if move_vector.x > 0.01:
		sprite_node.flip_h = false
	if move_vector.x < -0.01:
		sprite_node.flip_h = true
	if strike_knockback_cooldown > 0.0:
		move_vector += strike_knockback_normalized * strike_knockback_force * (strike_knockback_cooldown / strike_knockback_cooldown_max)
	move_and_slide(move_vector)
	
	# Point the sword toward the mouse
	swipe_pivot.look_at(last_mouse_position)
	
	# Swipe
	if mouse_is_clicked and swipe_cooldown <= 0.0:
		swipe_cooldown = swipe_cooldown_max
		swipe_sprite.flip_v = not swipe_sprite.flip_v
		swipe_pivot.visible = true
		for body in swipe_area.get_overlapping_bodies():
			if body.has_method("take_damage"):
					body.take_damage(global_position)
					strike_knockback_normalized = (swipe_pivot.global_position - swipe_area.global_position).normalized()
					strike_knockback_cooldown = strike_knockback_cooldown_max

func _unhandled_input(event):
	if event is InputEventMouseMotion:
		last_mouse_position = event.position
	if event is InputEventMouseButton:
		if event.button_index == BUTTON_LEFT:
			if event.pressed:
				mouse_is_clicked = true
			else:
				mouse_is_clicked = false

Don't try to run the game just yet because it won't work until we do a little more with the Enemy too, but I'll describe what this is doing. Now when a swipe occurs, if it hits any bodies that will take damage (e.g. enemies) then the player's strike_knockback_cooldown will be set to max and Godot will calculate the strike_knockback_normalized vector, which is a vector pointing from the swipe's area node toward the swipe's pivot node, which is the direction we want the player to be pushed. If you're not familiar with vector math, basically if you want a vector giving you the direction (and distance) from point A to point B, then take the coordinates of point B and subtract the coordinates of point A and that's the vector you want. If that's completely alien to you, check Godot's docs on vector math at https://docs.godotengine.org/en/stable/tutorials/math/vector_math.html and try to absorb what you can because it can be useful for programming games.


Another important thing to notice is that I added a parameter for the take_damage() call, so now instead of just body.take_damage() it's body.take_damage(global_position). That's because when an enemy gets hit, if it's going to take knockback, then it won't be enough just to tell the enemy that it got hit and you'll also need to let the enemy know where the hit came from so it can be knocked back in the correct direction. That global_position in the parenthesis is the player's global_position (because this is the script for the Player scene so any bare property names like that are the player's properties). Notice that I'm using global_position and not just position, and this is a good point to talk about local versus global coordinates. Remember when we were setting up the SwipePivot node and set its position property to be (0, -8): that tells us the SwipePivot's position relative to its parent node (the root Player node). If the player moves around on the battlefield, the SwipePivot's position property will not change, it will always have its position be (0, -8) (unless we intentionally move SwipePivot's position relative to the Player node, but we won't). So if you even want to use the SwipePivot's position, you'll have to think for a bit: do you want the SwipePivot's position on the screen accounting for the fact that the player moved, which is its global_position, or do you just want the SwipePivot's position relative to the Player node, which is its position (also called local position). 99% of the time you'll really want its global_position, so I've gotten into the habit of just using .global_position all the time unless I specifically know that I need its local position in which case I'll use .position. But that distinction between global and local coordinates is highly likely to mess you up at some point in game development, so whenever it seems like your code just isn't working like it should, ask yourself whether you're accidentally using local coordinates when you want to be using global coordinates or vice-versa.


As a more minor side note, the syntax in the line to calculate strike_knockback_normalized might also look a little funny, but I want a normalized vector (meaning its length is set to 1) pointing from the swipe area to the swipe pivot. If you have a Vector2 then you can use its .normalized() method, but I don't want to calculate swipe_pivot.global_position.normalized() and/or swipe_area.global_position.normalized(), I want to subtract them and then normalize that vector. So the parentheses force the subtraction to happen first which gives you a Vector2, and then you can normalize() that Vector2, even though it might look a little funny to have a period coming right after a parenthesis like that.


Now we'll modify the Enemy script to take knockback and handle the parameter that gets passed in the take_damage() function.

extends KinematicBody2D

export var is_goblin:bool = true

var speed_min:float = 50.0
var speed_max:float = 100.0
var goblin_speed_multiplier:float = 0.8
var slime_hp:int = 2
var goblin_hp:int = 4
var strike_knockback_force:float = 800.0
var strike_knockback_cooldown_max:float = 0.5
var hp_bar_cooldown_max:float = 2.0

var speed:float
var hp:int
var movement_vector:Vector2 = Vector2.ZERO
var strike_knockback_cooldown:float = 0.0
var strike_knockback_normalized:Vector2 = Vector2.ZERO
var hp_bar_cooldown:float = 0.0

onready var sprite_node = get_node("AnimatedSprite")
onready var hp_bar = get_node("HPBar")

func _ready():
	hp = slime_hp
	speed = rand_range(speed_min, speed_max)
	if is_goblin:
		hp = goblin_hp
		sprite_node.animation = "goblin"
		speed *= goblin_speed_multiplier
	hp_bar.max_value = hp

func _physics_process(delta):
	if strike_knockback_cooldown > 0.0:
		strike_knockback_cooldown -= delta
	if hp_bar_cooldown > 0.0:
		hp_bar_cooldown -= delta
	else:
		hp_bar.visible = false
	
	movement_vector = Vector2(-speed, 0)
	if strike_knockback_cooldown > 0.0:
		movement_vector += strike_knockback_normalized * strike_knockback_force * strike_knockback_cooldown / strike_knockback_cooldown_max
	move_and_slide(movement_vector)

func take_damage(origin:Vector2):
	hp -= 1
	if hp <= 0:
		queue_free()
	hp_bar.value = hp
	hp_bar.visible = true
	hp_bar_cooldown = hp_bar_cooldown_max
	strike_knockback_normalized = (global_position - origin).normalized()
	strike_knockback_cooldown = strike_knockback_cooldown_max

The take_damage() function has been modified so now it takes a parameter which I'm calling origin (as in the origin of the strike that just hit the enemy) and I'm using static typing to make sure that it's a Vector2 to help avoid bugs. The strike_knockback_normalized and strike_knockback_cooldown work like in the player's script and they similarly affect the movement before the move_and_slide() command, albeit with more oomph than the player because I set strike_knockback_force to be larger with the enemy script. Go ahead and playtest.


That makes it feel a little more like a video game, but we've still got some work to do on the fundamental game mechanics because we'll want the enemies to be able to walk off the left edge of the screen and lead to a Defeat, and we'll want the level to recognize when all of the enemies are dead and lead to a Victory, and do a few more things which we'll deal with when we get there. These sorts of things – telling win the Win Condition or Lose Condition of a level have been met – are best handled by the Level in the next post.


1

Comments

Comments ain't a thing here.