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 Player.tscn and create a new Node (not Node2D, okay?) and rename it as “Controller”. Create a script with name character_controller.gd and attach it to Controller. Now it’s time for refactoring our code structure. I’m not explaining too much since this action only moving our code calculation from player.gd to 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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
extends Node
# 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")
onready var kb = get_node("..")
func _ready():
move_step = MOVE_SPEED / MOVE_SPEED_TIME_NEEDED
dec_step = MOVE_SPEED / DECELERATION_TIME_NEEDED
func move(right_input, left_input, jump_input, delta):
if !verify():
print("Parent is invalid! Parent must be KinematicBody2D")
return 0
# 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 - kb.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 + kb.get_node("CollisionShape2D").get_shape().get_extents().x
elif get_center_pos().x + kb.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 - kb.get_node("CollisionShape2D").get_shape().get_extents().x
if get_center_pos().y - kb.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+ kb.get_node("CollisionShape2D").get_shape().get_extents().x
# apply the movement by calling move(velocity) and store the remaining movement
return kb.move(velocity)
func collision_handling(remaining_movement):
# collision handling
if kb.is_colliding():
var normal = kb.get_collision_normal()
remaining_movement = normal.slide(remaining_movement)
velocity = normal.slide(velocity)
kb.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 verify():
if kb.is_type("KinematicBody2D"):
return true
else:
print("Failed to verify")
return false
func get_center_pos():
if verify():
return kb.get_pos() + kb.get_node("CollisionShape2D").get_pos()
return Vector2()

See, not really different with the player.gd right? Okay, I will only explain important part

Since the attached script is not the KinematicBody2D, we can’t call all KinematicBody2D function directly from character_controller.gd. We need to find our KinematicBody2D with this code onready var kb = get_node("..") to call the KinematicBody2D function (ex : move, is_colliding).

func verify() will verify our controller parent is KinematicBody2D. If the parent is not KinematicBody2D then, return false.

Okay, crystal clear right? 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
extends KinematicBody2D
var last_anim = ""
onready var anim = get_node("anim")
onready var sprite = get_node("Sprite")
onready var controller = get_node("Controller")
# 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 = controller.move(right_input, left_input, jump_input, delta)
controller.collision_handling(remaining_movement)
sprite.set_flip_h(controller.facing_dir != 1)
var new_anim = "Idle"
if controller.last_frame_grounded:
if controller.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 onready var controller = get_node("Controller").

Player

Also, don’t forget to change the camera.gd script.

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
extends Camera2D
export var target = "../Player"
export var forward_offset = 60
export var max_y_offset = 5
export var x_smoothing = .05
export var y_smoothing = .1
onready var target_node = get_node(target)
onready var bounds = get_node("../Bounds")
func _ready():
OS.set_window_size(OS.get_window_size() * 4)
set_limit(0, bounds.get_left())
set_limit(1, bounds.get_top())
set_limit(2, bounds.get_right())
set_limit(3, bounds.get_bottom())
var pos = target_node.get_pos()
pos = Vector2(round(pos.x), round(pos.y))
set_pos(pos)
set_fixed_process(true)
func _fixed_process(delta):
var target_pos = target_node.controller.get_center_pos() + Vector2(1, 0) * target_node.controller.facing_dir * forward_offset
target_pos.x = lerp(get_pos().x, target_pos.x, x_smoothing)
#gap it when the next position is out of bound
if abs(target_pos.x - target_node.controller.get_center_pos().x) > forward_offset:
target_pos.x = target_node.controller.get_center_pos().x + target_node.controller.facing_dir * forward_offset * -1
target_pos.y = lerp(get_pos().y, target_pos.y + max_y_offset, y_smoothing)
if abs(target_pos.y - target_node.controller.get_center_pos().y) > max_y_offset:
target_pos.y = target_node.controller.get_center_pos().y + (sign(target_pos.y - target_node.controller.get_center_pos().y) * max_y_offset)
set_pos(Vector2(round(target_pos.x), round(target_pos.y)))

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, CollisionShape2D, and Node inside Slime. After that what you need to do is

  • Rename the Node to Controller and attach script character_controller.gd.
  • 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
21
extends KinematicBody2D
export var initial_movement = 1
var move_dir = 0
onready var controller = get_node("Controller")
func _ready():
move_dir = sign(initial_movement)
set_fixed_process(true)
func _fixed_process(delta):
var remaining_movement = controller.move(move_dir == 1, move_dir == -1, false, delta)
if is_colliding():
if get_collision_normal() == Vector2(move_dir * -1, 0):
move_dir *= -1
controller.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
controller.collision_handling(remaining_movement)
sprite.set_flip_h(controller.facing_dir == 1)
var new_anim = "Idle"
if controller.last_frame_grounded:
if controller.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
controller.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
controller.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(controller.get_center_pos().x + get_node("CollisionShape2D").get_shape().get_extents().x) >= round(get_node("../Bounds").get_right()) and move_dir == 1)
or (round(controller.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 interaction between Player and Enemy.

Download Project Here