Step 3 - Adding Player Behavior#

In this step, we will be adding player behavior to our character.

We want our dinosaur to be able to move left, right and jump. We also want to apply physics laws to him, such as gravity and collisions with future obstacles.

First, let's add physics to our dinosaur. To do this, we will add a RigidBody component to our him. Add the following code after the animation code, but before the main.add call.

shared.py#
18player.add(p_animation)  # Add the animation component to the player
19
20# define the player rigidbody
21player_body = rb.RigidBody(
22    gravity=rb.Vector(y=rb.Display.res.y * -0.5),
23    pos_correction=1,
24    friction=0.8,
25)
26player.add(player_body)
This enables physics for our player. Running the file, you should see the dinosaur slowly falling down the screen. 2 things to note here:
  • We base the gravity off of our Display resolution so that scaling our screen does not affect the gravity.

  • pos_correction is the proportion we correct the position of the player every frame if it is colliding with something. Setting it to 1 means that all overlaps are fully corrected in one frame.

Let's also add a hitbox to our player. For simplicity, we will use a rectangular hitbox. Add the following code right after the previous block.

shared.py#
26player.add(player_body)
27
28# add a hitbox to the player with the collider
29player.add(rb.Rectangle(
30    width=64,
31    height=64,
32    tag="player",
33))

Since we haven't given it a color, this rectangle won't actually draw unless the following debugging line is added to main.py. Feel free to remove it once you've confirmed that the rectangle was added properly.

rb.Game.debug = True
../../../_images/12.png

Running the script at this point should show a falling dinosaur.

Next we need to make a player controller. A player controller is a script that defines how a player reacts to certain stimuli. In this case, we'll use a player controller to handle keyboard inputs and make our dino move. Make a new file called player_controller.py.

The player controller will be implemented as a custom component. To define a custom component, inherit from rubato's Component class:

player_controller.py#
 1import rubato as rb
 2
 3class PlayerController(rb.Component):
 4
 5    def setup(self):
 6        # Called when added to Game Object.
 7        # Specifics can be found in the Custom Components tutorial.
 8        self.initial_pos = self.gameobj.pos.clone()
 9
10        self.animation: rb.Animation = self.gameobj.get(rb.Animation)
11        self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)

Note that we've used the GameObject get method to get references to some of the components. We'll use those references later when we want to modify the player's state such as its animation.

Let's now add movement. Since we want to check player input every frame, lets create a custom update function.

player_controller.py#
 5    def setup(self):
 6        # Called when added to Game Object.
 7        # Specifics can be found in the Custom Components tutorial.
 8        self.initial_pos = self.gameobj.pos.clone()
 9
10        self.animation: rb.Animation = self.gameobj.get(rb.Animation)
11        self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)
12
13    def update(self):
14        # Runs once every frame.
15        # Movement
16        if rb.Input.key_pressed("a"):
17            self.rigid.velocity.x = -300
18            self.animation.flipx = True
19        elif rb.Input.key_pressed("d"):
20            self.rigid.velocity.x = 300
21            self.animation.flipx = False

Here we check for player input using key_pressed(). We then update the player's horizontal velocity in the corresponding direction. We also flip the player's animation depending on the direction we want to face.

Next, import the player controller at the top of shared.py and add it to the player.

shared.py#
28# add a hitbox to the player with the collider
29player.add(rb.Rectangle(
30    width=64,
31    height=64,
32    tag="player",
33))
34player.add(player_comp := PlayerController())

Cool!

The := operator is called the walrus operator. It assigns the value to the variable on the left and returns the value. It is equivalent to player_comp = PlayerController(), then player.add(player_comp).

Finally, let's add a jump behavior. Unlike moving left and right, we don't want the user to be able to move up forever if they keep holding the jump key. We also want to limit the number of jumps the player gets. We will do this by creating a jump counter and process the jump through an event listener.

First create a jump count variable in the setup method.

player_controller.py#
 5    def setup(self):
 6        # Called when added to Game Object.
 7        # Specifics can be found in the Custom Components tutorial.
 8        self.initial_pos = self.gameobj.pos.clone()
 9
10        self.animation: rb.Animation = self.gameobj.get(rb.Animation)
11        self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)
12
13        # Tracks the number of jumps the player has left
14        self.jumps = 2

Now onto the event listener.

An event listener is a piece of code that waits for an event to be broadcast and then runs a function. We will create a function to handle jumping that is called when a key is pressed.

player_controller.py#
18    def update(self):
19        # Runs once every frame.
20        # Movement
21        if rb.Input.key_pressed("a"):
22            self.rigid.velocity.x = -300
23            self.animation.flipx = True
24        elif rb.Input.key_pressed("d"):
25            self.rigid.velocity.x = 300
26            self.animation.flipx = False
27
28    def handle_key_down(self, event: rb.KeyResponse):
29        if event.key == "w" and self.jumps > 0:
30            if self.jumps == 2:
31                self.rigid.velocity.y = 800
32                self.animation.set_state("jump", freeze=2)
33            elif self.jumps == 1:
34                self.rigid.velocity.y = 800
35                self.animation.set_state("somer", True)
36            self.jumps -= 1

Finally, we register the event handler:

player_controller.py#
 5    def setup(self):
 6        # Called when added to Game Object.
 7        # Specifics can be found in the Custom Components tutorial.
 8        self.initial_pos = self.gameobj.pos.clone()
 9
10        self.animation: rb.Animation = self.gameobj.get(rb.Animation)
11        self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)
12
13        # Tracks the number of jumps the player has left
14        self.jumps = 2
15
16        rb.Radio.listen(rb.Events.KEYDOWN, self.handle_key_down)

Let's break this down.

We check if the keydown event's key is "w" and if you still have jumps remaining. If so, we set the y velocity to 800 (remember that we are in a cartesian system). We also want to vary the jump animation on your last jump. The first is a regular jump and the second is a somersault. Finally, we decrement the number of jumps you have left, so you can't jump infinitely.

The Radio.listen(rb.Events.KEYDOWN, self.handle_keydown) line is where we tell rubato to listen for a keydown event. Whenever that event is broadcast (this happens automatically), rubato will invoke the handle_keydown function. The Events class has many other rubato-triggered events that you can listen for.

Running the script at this point should show a falling dinosaur, and let you jump twice and move a little left and right before falling to your doom. In the next step, we'll be building the level for the player to explore.

Here is what you should have so far if you've been following along:

main.py#
 1import rubato as rb
 2
 3# initialize a new game
 4rb.init(
 5    name="Platformer Demo",  # Set a name
 6    res=(1920, 1080),  # Set the window resolution (in pixels).
 7    fullscreen=True,  # Set the window to be fullscreen
 8)
 9
10import shared
11
12main = rb.Scene(background_color=rb.Color.cyan.lighter())
13
14# Add the player to the scene
15main.add(shared.player)
16
17# begin the game
18rb.begin()
shared.py#
 1import rubato as rb
 2from player_controller import PlayerController
 3
 4# Create the player and set its starting position
 5player = rb.GameObject(
 6    pos=rb.Display.center_left + rb.Vector(50, 0),
 7    z_index=1,
 8)
 9
10# Create animation and initialize states
11p_animation = rb.Spritesheet.from_folder(
12    path="files/dino",
13    sprite_size=rb.Vector(24, 24),
14    default_state="idle",
15)
16p_animation.scale = rb.Vector(4, 4)
17p_animation.fps = 10  # The frames will change 10 times a second
18player.add(p_animation)  # Add the animation component to the player
19
20# define the player rigidbody
21player_body = rb.RigidBody(
22    gravity=rb.Vector(y=rb.Display.res.y * -0.5),
23    pos_correction=1,
24    friction=0.8,
25)
26player.add(player_body)
27
28# add a hitbox to the player with the collider
29player.add(rb.Rectangle(
30    width=64,
31    height=64,
32    tag="player",
33))
34player.add(player_comp := PlayerController())
player_controller.py#
 1import rubato as rb
 2
 3class PlayerController(rb.Component):
 4
 5    def setup(self):
 6        # Called when added to Game Object.
 7        # Specifics can be found in the Custom Components tutorial.
 8        self.initial_pos = self.gameobj.pos.clone()
 9
10        self.animation: rb.Animation = self.gameobj.get(rb.Animation)
11        self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)
12
13        # Tracks the number of jumps the player has left
14        self.jumps = 2
15
16        rb.Radio.listen(rb.Events.KEYDOWN, self.handle_key_down)
17
18    def update(self):
19        # Runs once every frame.
20        # Movement
21        if rb.Input.key_pressed("a"):
22            self.rigid.velocity.x = -300
23            self.animation.flipx = True
24        elif rb.Input.key_pressed("d"):
25            self.rigid.velocity.x = 300
26            self.animation.flipx = False
27
28    def handle_key_down(self, event: rb.KeyResponse):
29        if event.key == "w" and self.jumps > 0:
30            if self.jumps == 2:
31                self.rigid.velocity.y = 800
32                self.animation.set_state("jump", freeze=2)
33            elif self.jumps == 1:
34                self.rigid.velocity.y = 800
35                self.animation.set_state("somer", True)
36            self.jumps -= 1