This is the seven part of the tutorial creating platformer game on Godot Engine. In this tutorial, we will refactoring the player.gd code structure and creating Enemy.

  1. Part 1 : Preparation
  2. Part 2 : Player Creation
  3. Part 3 : Player Creation 2
  4. Part 4 : Tilemap and Camera
  5. Part 5 : Player Animation
  6. Part 6 : Parallax Background and Level Bounds
  7. Part 7 : Character Controller and Enemy

Refactoring the Player and Create Character Controller

Character Controller is a script to calculate our character movement. Currently, we have a player and we code all movement calculation inside player.gd. Now we need to Refactor our player.gd script and move some code inside player.gd into character controller script. Our first job is Refactor player.gd

Character Controller Script

Open Script Window (press F3), click File - New, and name it as character_controller.gd. Copy this code and paste to character_controller.gd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
extends KinematicBody2D
# defines GRAVITY
# `export` makes your variable editable in the editor
# `var GRAVITY = 10` defines a variable named GRAVITY and assign it 10
export var GRAVITY = 10.0
# set the maximum falling speed per frame
export var MAX_FALLING_SPEED = 15.0
# MOVE_SPEED
export var MOVE_SPEED = 5.0
export var MOVE_SPEED_TIME_NEEDED = .15
var move_step = 0.0
export var DECELERATION_TIME_NEEDED = .15
var dec_step = 0.0
# jump power
export var MAX_JUMP_POWER = 5.0
export var MIN_JUMP_POWER = 2.0
export var MAX_AIR_JUMP_POWER = 3.0
export var MIN_AIR_JUMP_POWER = 1.0
export var MAX_AIR_JUMP_COUNT = 2.0
# store the player velocity
var velocity = Vector2()
# store status of jump input
var is_jump_pressed = false
# store status if last frame grounded
var last_frame_grounded = false
#store jump counter
var air_jump_count = 0
var facing_dir = 1
onready var bounds = get_node("/root").get_child(0).get_node("Bounds")
func _ready():
move_step = MOVE_SPEED / MOVE_SPEED_TIME_NEEDED
dec_step = MOVE_SPEED / DECELERATION_TIME_NEEDED
func calculate_movement(right_input, left_input, jump_input, delta):
# make a Vector2 variable movement and add gravity into y axis
var movement = Vector2(velocity.x, velocity.y + GRAVITY * delta)
#Apply the horizontal movement
if right_input:
movement.x += move_step * delta
elif left_input:
movement.x -= move_step * delta
elif movement.x != 0:
#get the direction of movement
var _dir = sign(movement.x)
#calculate deceleration amount and direction
var _dec = _dir * -1 * dec_step * delta
# apply to movement
movement.x += _dec
# stop it when reached 0
if _dir == 1 && movement.x < 0:
movement.x = 0
elif _dir == -1 && movement.x > 0:
movement.x = 0
#if the movement.x more that max_speed, gap it
if abs(movement.x) > MOVE_SPEED:
movement.x = sign(movement.x) * MOVE_SPEED
#Apply jumping
if jump_input:
if !is_jump_pressed && last_frame_grounded:
movement.y = -MAX_JUMP_POWER
elif !is_jump_pressed && !last_frame_grounded && air_jump_count < MAX_AIR_JUMP_COUNT:
movement.y = -MAX_AIR_JUMP_POWER
air_jump_count += 1
is_jump_pressed = true
elif !jump_input && is_jump_pressed:
if air_jump_count != 0 && movement.y < -MIN_AIR_JUMP_POWER:
movement.y = -MIN_AIR_JUMP_POWER
elif movement.y < -MIN_JUMP_POWER:
movement.y = -MIN_JUMP_POWER
is_jump_pressed = false
# set the velocity = movement
velocity = movement
if get_center_pos().x - get_node("CollisionShape2D").get_shape().get_extents().x + velocity.x < bounds.get_left() and bounds.left_stop:
velocity.x = bounds.get_left()-get_center_pos().x + get_node("CollisionShape2D").get_shape().get_extents().x
elif get_center_pos().x + get_node("CollisionShape2D").get_shape().get_extents().x + velocity.x > bounds.get_right() and bounds.right_stop:
velocity.x = bounds.get_right()-get_center_pos().x - get_node("CollisionShape2D").get_shape().get_extents().x
if get_center_pos().y - get_node("CollisionShape2D").get_shape().get_extents().y + velocity.y < bounds.get_top() and bounds.top_stop:
velocity.y = bounds.get_top() - get_center_pos().y+ get_node("CollisionShape2D").get_shape().get_extents().x
# apply the movement by calling move(velocity) and store the remaining movement
return move(velocity)
func collision_handling(remaining_movement):
# collision handling
if is_colliding():
var normal = get_collision_normal()
remaining_movement = normal.slide(remaining_movement)
velocity = normal.slide(velocity)
move(remaining_movement)
# if normal is floor, then set as grounded
if normal == Vector2(0, -1):
last_frame_grounded = true
air_jump_count = 0
elif last_frame_grounded:
last_frame_grounded = false
if velocity.x != 0:
facing_dir = sign(velocity.x)
func get_center_pos():
return get_pos() + get_node("CollisionShape2D").get_pos()

Then copy this code into our player.gd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#make sure the path is right
extends "res://character_controller.gd"
var last_anim = ""
onready var anim = get_node("anim")
onready var sprite = get_node("Sprite")
# Called when the node is "ready", that means called when the game started.
# Use this function for initialize
func _ready():
last_anim = anim.get_current_animation()
# makes `_fixed_process(delta)` running
set_fixed_process(true)
# Called during the fixed processing step of the main loop.
# Fixed processing means that the frame rate is synced to the physics,
# i.e. the delta variable should be constant.
# only active when set_fixed_process(true) is called
func _fixed_process(delta):
#input
var right_input = Input.is_action_pressed("right")
var left_input = Input.is_action_pressed("left")
var jump_input = Input.is_action_pressed("jump")
var remaining_movement = calculate_movement(right_input, left_input, jump_input, delta)
collision_handling(remaining_movement)
sprite.set_flip_h(facing_dir != 1)
var new_anim = "Idle"
if last_frame_grounded:
if velocity.x != 0:
new_anim = "Move"
else:
new_anim = "Jump"
#apply animation
if new_anim != last_anim:
anim.play(new_anim)
last_anim = new_anim

Now, our player.gd only receive input and other stuff such animation. All movement calculation inside calculate_movement and collision_handling function.

Player

Try it, make sure the game can run.

IMPORTANT : I ended up changing my Player sprite Centered option into false and reposition the CollisionShape2D. This is not a big difference, I just want to make sure my game is at pixel perfect.

Create Enemy Node

Enemy is like player, the main node is KinematicBody2D. Create a KinematicBody2D, rename it into Slime, and create Sprite and CollisionShape2D inside Slime. After that what you need to do is

  • Setup sprite as usually. Load the texture, set the Centered = false, VFrames = 20, HFrames = 22 and Frames = 6.
  • setup the CollisionShape2D with RectangleShape2D, and set the size according to the sprite size.
Slime

Enemy Script

Create a script and give it a name “enemy.gd”, attach it into Slime. Different with the player which is using user input for any action, enemy take all input from code. So, we need to make the behavior. For the first time, the behavior is quite simple, just walk and if detect wall, reverse the walk direction. The code will look like this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#make sure the path is right
extends "res://character_controller.gd"
export var initial_movement = 1
var move_dir = 0
func _ready():
move_dir = sign(initial_movement)
set_fixed_process(true)
func _fixed_process(delta):
var remaining_movement = calculate_movement(move_dir == 1, move_dir == -1, false, delta)
if is_colliding():
if get_collision_normal() == Vector2(move_dir * -1, 0):
move_dir *= -1
collision_handling(remaining_movement)

Remember, there is no hole handling, so if the enemy walks into the hole, the enemy will falling down. So make the level properly, for example like this

Example Enemy Level

You can try to play it, the enemy will move left and right. Don’t forget to lower the Move Speed value, I’ve set it to 0.2.

Moving Slime

Enemy Animation

Create AnimationPlayer node inside Slime node and rename it to “anim”. Then create Idle and Move animation. I do not explain how to do this because the way we create animation is not different as before. After that, add this code into enemy.gd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#other code
var last_anim = ""
onready var anim = get_node("anim")
onready var sprite = get_node("Sprite")
#other code
func _fixed_process(delta):
#other code
collision_handling(remaining_movement)
sprite.set_flip_h(facing_dir == 1)
var new_anim = "Idle"
if last_frame_grounded:
if velocity.x != 0:
new_anim = "Move"
#apply animation
if new_anim != last_anim:
anim.play(new_anim)
last_anim = new_anim
Slime Animation

Hole Detection

The behavior is not quite different with wall detection when a hole is in front of slime, then reverse the direction movement. However, this cannot be done without Raycast2D. There is 2 way to do raycast, directly from the script or create the node from editor and control from a script. I will do it directly from the script, it easier for me though. Open enemy.gd and add modify the code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#other code
onready var hole_raycaster = RayCast2D.new()
export var RAY_LENGTH = 5
func _ready():
move_dir = sign(initial_movement)
add_child(hole_raycaster)
hole_raycaster.add_exception(self)
hole_raycaster.set_pos(get_node("CollisionShape2D").get_pos() + Vector2(1, 0) * get_node("CollisionShape2D").get_shape().get_extents().x)
hole_raycaster.set_cast_to(Vector2(0, get_node("CollisionShape2D").get_shape().get_extents().y + RAY_LENGTH))
hole_raycaster.set_enabled(true)
#other code

Raycast : Query the closest object intersecting a ray. Vector Ray can be set by calling set_cast_to(Vector2). Don’t know how ray works? Imagine you have a laser, laser light will stop after reaching an object. Yes, a ray is like a laser.

Try to play it with Debug Visible Collision Shapes enabled, at the right side of Slime you will see arrow down indicating raycasting down.

Enemy RayCast2D

When slime moving left, raycast still at the right side right? we need to change the raycast position whenever the slime is changing direction

1
2
3
4
5
6
7
8
#other code
func _fixed_process(delta):
#other code
collision_handling(remaining_movement)
hole_raycaster.set_pos(get_node("CollisionShape2D").get_pos() + Vector2(move_dir, 0) * get_node("CollisionShape2D").get_shape().get_extents().x)
#other code

You can test it now, the position of Raycast must be change when the slime direction is changing.

Changing the Raycast Position

We haven’t code the hole detection yet. Modify again the enemy.gd

1
2
3
4
5
6
7
8
9
#othercode
func _fixed_process(delta):
#other code
#if no collider detected
if !hole_raycaster.is_colliding():
move_dir *= -1
collision_handling(remaining_movement)
#othercode

Now, modify our TileMap and test it.

Raycasting Hole

It seems fine, but the problem will occur when slime reaches the level bounds. Slime will stay at the edge of level bounds.

Enemy Stuck at the Edge of level bounds

We have more job now..

1
2
3
4
5
6
7
8
9
10
#other code
func _fixed_process(delta):
#other code
#if no collider detected
#or if reach at the levelbound.right
#or if reach at the levelbound.left
if (!hole_raycaster.is_colliding()
or (round(get_center_pos().x + get_node("CollisionShape2D").get_shape().get_extents().x) >= round(get_node("../Bounds").get_right()) and move_dir == 1)
or (round(get_center_pos().x - get_node("CollisionShape2D").get_shape().get_extents().x) <= round(get_node("../Bounds").get_left()) and move_dir == -1)):
move_dir *= -1

Test it and place the slime at the edge of level bounds

Raycasting Works Perfectly

This part ended here, next part will be the .

Download Project Here