Step 5 - Finishing Touches#
This is the final step! We'll be making quality-of-life changes to the game to make it play more like a real platformer.
Important
This step covers general game development concepts that are not unique to rubato (grounding detection, camera scrolling, etc). We will not be going over how these work, rather we will be focusing on how to implement them in our game. A quick Google search on any of these topics is a great place to start learning.
Jump Limit#
Right now, when you move around, you'll find that you quickly run out of jumps. This is because we implemented a 2 jump limit. However,
once you run out of jumps, you can't do anything to reset your jump counter. We want this counter to be reset whenever you land on the ground. To do
that, we will add a ground detection hitbox to the player, making sure to set the trigger
parameter to true.
Making a hitbox a trigger
prevents the hitbox from resolving collisions in the rubato physics engine. It will still detect overlap
and call the relevant callbacks. We will define a player_collide callback that will be called when the player's ground detector collides.
When this happens, we use the provided collision Manifold
to
make sure the other collider is a ground hitbox, that we are not already grounded, and that we are indeed falling towards the ground.
That code looks like this:
In shared.py
, add the following code:
16player = rb.GameObject(
17 pos=rb.Display.center_left + rb.Vector(50, 0),
18 z_index=1,
19)
20
21# Create animation and initialize states
22p_animation = rb.Spritesheet.from_folder(
23 path="files/dino",
24 sprite_size=rb.Vector(24, 24),
25 default_state="idle",
26)
27p_animation.scale = rb.Vector(4, 4)
28p_animation.fps = 10 # The frames will change 10 times a second
29player.add(p_animation) # Add the animation component to the player
30
31player.add(
32 # add a hitbox to the player with the collider
33 rb.Rectangle(width=40, height=64, tag="player"),
34 # add a ground detector
35 rb.Rectangle(
36 width=34,
37 height=2,
38 offset=rb.Vector(0, -32),
39 trigger=True,
40 tag="player_ground_detector",
41 ),
42 # add a rigidbody to the player
43 rb.RigidBody(
44 gravity=rb.Vector(y=rb.Display.res.y * -1.5),
45 pos_correction=1,
46 friction=1,
47 ),
48 # add custom player component
49 player_comp := PlayerController(),
50)
In player_controller.py
we get our ground detector and set its on_collide and on_exit callbacks:
7 def setup(self):
8 # Called when added to Game Object.
9 # Specifics can be found in the Custom Components tutorial.
10 self.initial_pos = self.gameobj.pos.clone()
11
12 self.animation: rb.Animation = self.gameobj.get(rb.Animation)
13 self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)
14
15 rects = self.gameobj.get_all(rb.Rectangle)
16 self.detector = [r for r in rects if r.tag == "player_ground_detector"][0]
17 self.detector.on_collide = self.ground_detect
18 self.detector.on_exit = self.ground_exit
19
20 # Tracks the number of jumps the player has left
21 self.jumps = 2
22 # Tracks the ground state
23 self.grounded = False
24
25 rb.Radio.listen(rb.Events.KEYDOWN, self.handle_key_down)
26
27 def ground_detect(self, col_info: rb.Manifold):
28 if "ground" in col_info.shape_b.tag and self.rigid.velocity.y >= 0:
29 if not self.grounded:
30 self.grounded = True
31 self.jumps = 2
32 self.animation.set_state("idle", True)
33
34 def ground_exit(self, col_info: rb.Manifold):
35 if "ground" in col_info.shape_b.tag:
36 self.grounded = False
Camera Scroll#
In your testing, you may have also noticed that you are able to walk past the right side of your screen. This is because there is actually more level space there! Remember that we set our level to be 120% the width of the screen. Lets use rubato's built-in lerp function to make our camera follow the player.
38 def update(self):
39 # Runs once every frame.
40 # Movement
41 if rb.Input.key_pressed("a"):
42 self.rigid.velocity.x = -300
43 self.animation.flipx = True
44 elif rb.Input.key_pressed("d"):
45 self.rigid.velocity.x = 300
46 self.animation.flipx = False
47
48 # define a custom fixed update function
49 def fixed_update(self):
50 # have the camera follow the player
51 current_scene = rb.Game.current()
52 camera_ideal = rb.Math.clamp(
53 self.gameobj.pos.x + rb.Display.res.x / 4,
54 rb.Display.center.x,
55 shared.level1_size - rb.Display.res.x,
56 )
57 current_scene.camera.pos.x = rb.Math.lerp(
58 current_scene.camera.pos.x,
59 camera_ideal,
60 rb.Time.fixed_delta / 0.4,
61 )
lerp
and clamp
are both built-in methods to the Math
class.
Note that we've used Time.fixed_delta
, which represents the
time elapsed since the last update to the physics engine, in seconds. This is to make our camera follow the player more smoothly,
in line with the fps.
Final Player Controller Touches#
We currently only change the animation when the player jumps. Lets add some more animations when the player is moving left and right.
38 def update(self):
39 # Runs once every frame.
40 # Movement
41 if rb.Input.key_pressed("a"):
42 self.rigid.velocity.x = -300
43 self.animation.flipx = True
44 elif rb.Input.key_pressed("d"):
45 self.rigid.velocity.x = 300
46 self.animation.flipx = False
47
48 # Running animation states
49 if self.grounded:
50 if self.rigid.velocity.x in (-300, 300):
51 if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
52 self.animation.set_state("sneak", True)
53 else:
54 self.animation.set_state("run", True)
55 else:
56 if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
57 self.animation.set_state("crouch", True)
58 else:
59 self.animation.set_state("idle", True)
Let's also add a reset function. If the player falls off the level or presses the reset key ("r" in this case), we want to place them back at the start of the level.
54 # Running animation states
55 if self.grounded:
56 if self.rigid.velocity.x in (-300, 300):
57 if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
58 self.animation.set_state("sneak", True)
59 else:
60 self.animation.set_state("run", True)
61 else:
62 if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
63 self.animation.set_state("crouch", True)
64 else:
65 self.animation.set_state("idle", True)
66
67 # Reset
68 if rb.Input.key_pressed("r") or self.gameobj.pos.y < -550:
69 self.gameobj.pos = self.initial_pos.clone()
70 self.rigid.stop()
71 self.grounded = False
72 rb.Game.current().camera.pos = rb.Vector(0, 0)
Finally, let's add a little bit of polish to the player movement in the form of friction. This will make the player feel a little more grounded.
38 def update(self):
39 # Runs once every frame.
40 # Movement
41 if rb.Input.key_pressed("a"):
42 self.rigid.velocity.x = -300
43 self.animation.flipx = True
44 elif rb.Input.key_pressed("d"):
45 self.rigid.velocity.x = 300
46 self.animation.flipx = False
47 else:
48 if not self.grounded:
49 self.rigid.velocity.x = 0
50 self.rigid.friction = 0
51 else:
52 self.rigid.friction = 1
To Conclude#
That's it! You've finished your first platformer in rubato!
This was just the tip of the iceberg of what rubato can do.
If you got lost, here's the full code, just for kicks:
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 main_menu
11import level1
12
13rb.Game.set_scene("main_menu")
14
15# begin the game
16rb.begin()
1import rubato as rb
2from player_controller import PlayerController
3
4##### MISC #####
5level1_size = int(rb.Display.res.x * 1.2)
6
7##### COLORS #####
8
9platform_color = rb.Color.from_hex("#b8e994")
10background_color = rb.Color.from_hex("#82ccdd")
11win_color = rb.Color.green.darker(75)
12
13##### PLAYER PREFAB #####
14
15# Create the player and set its starting position
16player = rb.GameObject(
17 pos=rb.Display.center_left + rb.Vector(50, 0),
18 z_index=1,
19)
20
21# Create animation and initialize states
22p_animation = rb.Spritesheet.from_folder(
23 path="files/dino",
24 sprite_size=rb.Vector(24, 24),
25 default_state="idle",
26)
27p_animation.scale = rb.Vector(4, 4)
28p_animation.fps = 10 # The frames will change 10 times a second
29player.add(p_animation) # Add the animation component to the player
30
31player.add(
32 # add a hitbox to the player with the collider
33 rb.Rectangle(width=40, height=64, tag="player"),
34 # add a ground detector
35 rb.Rectangle(
36 width=34,
37 height=2,
38 offset=rb.Vector(0, -32),
39 trigger=True,
40 tag="player_ground_detector",
41 ),
42 # add a rigidbody to the player
43 rb.RigidBody(
44 gravity=rb.Vector(y=rb.Display.res.y * -1.5),
45 pos_correction=1,
46 friction=1,
47 ),
48 # add custom player component
49 player_comp := PlayerController(),
50)
51
52##### SIDE BOUDARIES #####
53
54left = rb.GameObject(pos=rb.Display.center_left - rb.Vector(25, 0)).add(
55 rb.Rectangle(width=50, height=rb.Display.res.y),
56)
57right = rb.GameObject().add(rb.Rectangle(width=50, height=rb.Display.res.y))
1import rubato as rb
2import shared
3
4
5class PlayerController(rb.Component):
6
7 def setup(self):
8 # Called when added to Game Object.
9 # Specifics can be found in the Custom Components tutorial.
10 self.initial_pos = self.gameobj.pos.clone()
11
12 self.animation: rb.Animation = self.gameobj.get(rb.Animation)
13 self.rigid: rb.RigidBody = self.gameobj.get(rb.RigidBody)
14
15 rects = self.gameobj.get_all(rb.Rectangle)
16 self.detector = [r for r in rects if r.tag == "player_ground_detector"][0]
17 self.detector.on_collide = self.ground_detect
18 self.detector.on_exit = self.ground_exit
19
20 # Tracks the number of jumps the player has left
21 self.jumps = 2
22 # Tracks the ground state
23 self.grounded = False
24
25 rb.Radio.listen(rb.Events.KEYDOWN, self.handle_key_down)
26
27 def ground_detect(self, col_info: rb.Manifold):
28 if "ground" in col_info.shape_b.tag and self.rigid.velocity.y >= 0:
29 if not self.grounded:
30 self.grounded = True
31 self.jumps = 2
32 self.animation.set_state("idle", True)
33
34 def ground_exit(self, col_info: rb.Manifold):
35 if "ground" in col_info.shape_b.tag:
36 self.grounded = False
37
38 def update(self):
39 # Runs once every frame.
40 # Movement
41 if rb.Input.key_pressed("a"):
42 self.rigid.velocity.x = -300
43 self.animation.flipx = True
44 elif rb.Input.key_pressed("d"):
45 self.rigid.velocity.x = 300
46 self.animation.flipx = False
47 else:
48 if not self.grounded:
49 self.rigid.velocity.x = 0
50 self.rigid.friction = 0
51 else:
52 self.rigid.friction = 1
53
54 # Running animation states
55 if self.grounded:
56 if self.rigid.velocity.x in (-300, 300):
57 if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
58 self.animation.set_state("sneak", True)
59 else:
60 self.animation.set_state("run", True)
61 else:
62 if rb.Input.key_pressed("shift") or rb.Input.key_pressed("s"):
63 self.animation.set_state("crouch", True)
64 else:
65 self.animation.set_state("idle", True)
66
67 # Reset
68 if rb.Input.key_pressed("r") or self.gameobj.pos.y < -550:
69 self.gameobj.pos = self.initial_pos.clone()
70 self.rigid.stop()
71 self.grounded = False
72 rb.Game.current().camera.pos = rb.Vector(0, 0)
73
74 # define a custom fixed update function
75 def fixed_update(self):
76 # have the camera follow the player
77 current_scene = rb.Game.current()
78 camera_ideal = rb.Math.clamp(
79 self.gameobj.pos.x + rb.Display.res.x / 4,
80 rb.Display.center.x,
81 shared.level1_size - rb.Display.res.x,
82 )
83 current_scene.camera.pos.x = rb.Math.lerp(
84 current_scene.camera.pos.x,
85 camera_ideal,
86 rb.Time.fixed_delta / 0.4,
87 )
88
89 def handle_key_down(self, event: rb.KeyResponse):
90 if event.key == "w" and self.jumps > 0:
91 if self.jumps == 2:
92 self.rigid.velocity.y = 800
93 self.animation.set_state("jump", freeze=2)
94 elif self.jumps == 1:
95 self.rigid.velocity.y = 800
96 self.animation.set_state("somer", True)
97 self.jumps -= 1
1import shared
2import rubato as rb
3
4scene = rb.Scene("level1", background_color=shared.background_color)
5
6# create the ground
7ground = rb.GameObject().add(
8 ground_rect := rb.Rectangle(
9 width=1270,
10 height=50,
11 color=shared.platform_color,
12 tag="ground",
13 )
14)
15ground_rect.bottom_left = rb.Display.bottom_left
16
17end_location = rb.Vector(rb.Display.left + shared.level1_size - 128, 450)
18
19# create platforms
20platforms = [
21 rb.Rectangle(
22 150,
23 40,
24 offset=rb.Vector(-650, -200),
25 ),
26 rb.Rectangle(
27 150,
28 40,
29 offset=rb.Vector(500, 40),
30 ),
31 rb.Rectangle(
32 150,
33 40,
34 offset=rb.Vector(800, 200),
35 ),
36 rb.Rectangle(256, 40, offset=end_location - (0, 64 + 20))
37]
38
39for p in platforms:
40 p.tag = "ground"
41 p.color = shared.platform_color
42
43# create pillars
44pillars = [
45 rb.GameObject(pos=rb.Vector(-260)).add(rb.Rectangle(
46 width=100,
47 height=650,
48 )),
49 rb.GameObject(pos=rb.Vector(260)).add(rb.Rectangle(
50 width=100,
51 height=400,
52 )),
53]
54
55for pillar in pillars:
56 r = pillar.get(rb.Rectangle)
57 r.bottom = rb.Display.bottom + 50
58 r.tag = "ground"
59 r.color = shared.platform_color
60
61# program the right boundary
62shared.right.pos = rb.Display.center_left + (shared.level1_size + 25, 0)
63
64scene.add(
65 shared.player,
66 ground,
67 rb.wrap(platforms),
68 *pillars,
69 shared.left,
70 shared.right,
71)
1import rubato as rb
2
3scene = rb.Scene("main_menu", background_color=rb.Color.black) # make a new scene
4
5title_font = rb.Font(size=64, styles=rb.Font.BOLD, color=rb.Color.white)
6title = rb.Text(text="PLATFORMER DEMO!", font=title_font)
7
8play_button = rb.GameObject(pos=(0, -75)).add(
9 rb.Button(
10 width=300,
11 height=100,
12 onrelease=lambda: rb.Game.set_scene("level1"),
13 ),
14 rb.Text(
15 "PLAY",
16 rb.Font(size=32, color=rb.Color.white),
17 ),
18 r := rb.Raster(
19 width=300,
20 height=100,
21 z_index=-1,
22 ),
23)
24r.fill(color=rb.Color.gray.darker(100))
25
26scene.add(
27 rb.wrap(title, pos=(0, 75)),
28 play_button,
29)
We're also including a version with some more in-depth features that weren't covered in this tutorial, including win detection, advanced animation switching, and a respawn system. Also new scenes, with multiple levels. Noice.
Sneak Peak:
Here is what that code looks like:
This code has new files.
"""
The platformer example tutorial
"""
import rubato as rb
# initialize a new game
rb.init(
name="Platformer Demo", # Set a name
res=(1920, 1080), # Increase the window resolution
fullscreen=True, # Set the window to fullscreen
)
import main_menu
import level1
import level2
import end_menu
rb.Game.set_scene("main_menu")
# begin the game
rb.begin()
from rubato import Tilemap, Display, Vector, Rectangle, wrap, Radio, Events, Game, Time
import shared
scene = shared.DataScene("level1", background_color=shared.background_color)
scene.level_size = int(Display.res.x * 1.2)
end_location = Vector(Display.left + scene.level_size - 128, -416)
tilemap = Tilemap("files/level1.tmx", (8, 8), "ground")
has_won = False
def won():
global click_listener, has_won
if not has_won:
has_won = True
click_listener = Radio.listen(Events.MOUSEUP, go_to_next)
scene.add(shared.win_text, shared.win_sub_text)
def go_to_next():
Game.set_scene("level2")
click_listener.remove()
def switch():
global has_won
shared.player.pos = Display.bottom_left + Vector(50, 160)
shared.player_comp.initial_pos = shared.player.pos.clone()
shared.right.pos = Display.center_left + Vector(scene.level_size + 25, 0)
shared.flag.pos = end_location
shared.flag.get(Rectangle).on_enter = lambda col_info: won() if col_info.shape_b.tag == "player" else None
scene.remove(shared.win_text, shared.win_sub_text)
has_won = False
shared.start_time = Time.now()
scene.camera.pos = Vector(0, 0)
scene.on_switch = switch
shared.cloud_generator(scene, 4, True)
scene.add(wrap(tilemap, pos=(192, 0)), shared.player, shared.left, shared.right, shared.flag)
from rubato import Display, Vector, Rectangle, GameObject, Radio, Events, Game, Spritesheet, SimpleTilemap, Surface
from moving_platform import MovingPlatform
import shared
scene = shared.DataScene("level2", background_color=shared.background_color)
scene.level_size = int(Display.res.x * 2)
end_location = Vector(Display.left + scene.level_size - 128, 0)
tileset = Spritesheet("files/cavesofgallet_tiles.png", (8, 8))
platforms = [
GameObject(pos=Vector(-885, -50)),
GameObject(pos=Vector(-735, -50)).add(MovingPlatform(100, "r", 400, 2)),
GameObject(pos=Vector(-185, -450)).add(MovingPlatform(150, "u", 980, 0)),
GameObject(pos=Vector(0, 300)).add(MovingPlatform(200, "r", 500, 0)),
GameObject(pos=Vector(1300, 0)).add(MovingPlatform(100, "l", 500, 1)),
GameObject(pos=Vector(1700, -400)).add(MovingPlatform(1000, "u", 400, 0)),
GameObject(pos=Vector(2215, 100)),
GameObject(pos=Vector(2730, -84)).add(
Rectangle(320, 40, tag="ground"),
SimpleTilemap(
[[0, 0, 0, 0, 0, 0, 0, 0]],
[tileset.get(4, 1)],
(8, 8),
scale=(5, 5),
)
),
]
platform = SimpleTilemap([[0, 0, 0, 0]], [tileset.get(4, 1)], (8, 8), scale=(5, 5))
for p in platforms:
if len(p.get_all(Rectangle)) == 0:
p.add(r := Rectangle(160, 40))
if MovingPlatform in p:
r.tag = "moving_ground"
else:
r.tag = "ground"
p.add(platform.clone())
shared.cloud_generator(scene, 10)
has_won = False
def won():
global click_listener, has_won
if not has_won:
has_won = True
click_listener = Radio.listen(Events.MOUSEUP, go_to_next)
scene.add(shared.win_text, shared.win_sub_text)
def go_to_next():
Game.set_scene("end_menu")
click_listener.remove()
def switch():
global has_won
shared.player.pos = Vector(Display.left + 50, 0)
shared.player_comp.initial_pos = shared.player.pos.clone()
shared.right.pos = Display.center_left + Vector(scene.level_size + 25, 0)
shared.flag.pos = end_location
shared.flag.get(Rectangle).on_enter = lambda col_info: won() if col_info.shape_b.tag == "player" else None
scene.remove(shared.win_text, shared.win_sub_text)
has_won = False
scene.camera.pos = Vector(0, 0)
scene.on_switch = switch
scene.add(*platforms, shared.player, shared.left, shared.right, shared.flag, shared.vignette)
from rubato import Scene, Color, Text, wrap, Font, Game
import shared
scene = Scene("main_menu", background_color=shared.background_color)
title_font = Font(font=shared.font_name, size=58, color=Color.white)
title = Text("Rubato Platformer Demo", title_font)
play_button = shared.smooth_button_generator(
(0, -75),
300,
100,
"PLAY",
lambda: Game.set_scene("level1"),
Color.gray.darker(100),
)
scene.add(wrap(title, pos=(0, 75)), play_button)
from rubato import Scene, Color, Game, Display, Time, Text, GameObject
import shared, time
scene = Scene("end_menu", background_color=shared.background_color)
restart_button = shared.smooth_button_generator(
(0, -75),
600,
100,
"RESTART",
lambda: Game.set_scene("main_menu"),
Color.gray.darker(100),
)
screenshot_button = shared.smooth_button_generator(
(0, -200),
600,
100,
"SAVE SCREENSHOT",
lambda: Display.save_screenshot((f"platformer time {time.asctime()}").replace(" ", "-").replace(":", "-")),
Color.gray.darker(100),
)
time_text = GameObject(pos=(0, 100)).add(Text("", shared.white_32))
def on_switch():
final_time = Time.now() - shared.start_time
time_text.get(Text).text = f"Final time: {round(final_time, 2)} seconds"
scene.on_switch = on_switch
scene.add(restart_button, screenshot_button, time_text)
import rubato as rb
from random import randint
##### DATA SCENE #####
class DataScene(rb.Scene):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.level_size = 0
from player_controller import PlayerController
##### MISC #####
font_name = "Mozart"
black_32 = rb.Font(font=font_name, size=32)
white_32 = rb.Font(font=font_name, size=32, color=rb.Color.white)
start_time = 0
##### COLORS #####
dirt_color = rb.Color.from_hex("#2c3e50")
platform_color = rb.Color.from_hex("#2c3e50")
wood_color = rb.Color.from_hex("#2c3e50")
background_color = rb.Color.from_hex("#21263f")
win_color = rb.Color.green.darker(75)
##### FOG EFFECT #####
class VignetteScroll(rb.Component):
def update(self):
self.gameobj.pos = player.pos
vignette = rb.GameObject(z_index=1000).add(rb.Image("files/vignette/vignette.png"), VignetteScroll())
##### PLAYER #####
# Create the player
player = rb.GameObject(z_index=1)
# Create animation and initialize states
p_animation = rb.Spritesheet.from_folder(
path="files/dino",
sprite_size=rb.Vector(24, 24),
default_state="idle",
)
p_animation.scale = rb.Vector(4, 4)
p_animation.fps = 10 # The frames will change 10 times a second
player.add(p_animation) # Add the animation component to the player
player.add(
# add a hitbox to the player with the collider
rb.Rectangle(width=40, height=64, tag="player"),
# add a ground detector
rb.Rectangle(
width=34,
height=2,
offset=rb.Vector(0, -32),
trigger=True,
tag="player_ground_detector",
),
# add a rigidbody to the player
rb.RigidBody(gravity=rb.Vector(y=rb.Display.res.y * -1.5), pos_correction=1, friction=1),
# add custom player component
player_comp := PlayerController(),
)
##### Flag #####
# Create animation for flag
flag_sheet = rb.Spritesheet(
path="files/flag.png",
sprite_size=rb.Vector(32, 32),
grid_size=rb.Vector(6, 1),
)
flag_animation = rb.Animation(scale=rb.Vector(4, 4), fps=6, flipx=True)
flag_animation.add_spritesheet("", flag_sheet, to_coord=flag_sheet.end)
# create the end flag
flag = rb.GameObject()
flag.add(flag_animation)
flag.add(
rb.Rectangle(
trigger=True,
tag="flag",
width=-flag_animation.anim_frame().size_scaled().x,
height=flag_animation.anim_frame().size_scaled().y,
)
)
##### SIDE BOUDARIES #####
left = rb.GameObject(pos=rb.Display.center_left - rb.Vector(25, 0)).add(rb.Rectangle(width=50, height=rb.Display.res.y))
right = rb.GameObject().add(rb.Rectangle(width=50, height=rb.Display.res.y))
##### LEVEL WIN TEXT #####
win_font = rb.Font(font=font_name, size=96, color=win_color)
win_text = rb.GameObject(z_index=10000, ignore_cam=True).add(rb.Text("Level Complete!", win_font, anchor=(0, 0.5)))
win_sub_text = rb.GameObject(
pos=(0, -100),
z_index=10000,
ignore_cam=True,
).add(rb.Text("Click anywhere to move on", white_32))
##### CLOUD #####
class CloudMover(rb.Component):
def __init__(self):
super().__init__()
self.speed = randint(10, 50)
def update(self):
if isinstance(scene := rb.Game.current(), DataScene):
if self.gameobj.pos.x < -1210: # -960 - 250
self.gameobj.pos.x = scene.level_size - 710 # -960 + 250
self.gameobj.pos += rb.Vector(-self.speed, 0) * rb.Time.delta_time
def clone(self):
return CloudMover()
cloud_template = rb.GameObject(z_index=-1).add(rb.Image("files/cloud.png", scale=rb.Vector(10, 10)), CloudMover())
cloud_template.get(rb.Image).set_alpha(170)
def cloud_generator(scene: DataScene, num_clouds: int, top_only: bool = False):
half_width = int(rb.Display.res.x / 2)
half_height = int(rb.Display.res.y / 2)
for _ in range(num_clouds):
rand_pos = rb.Vector(
randint(-half_width, scene.level_size - half_width),
randint(0 if top_only else -half_height, half_height),
)
cloud = cloud_template.clone()
cloud.pos = rand_pos
scene.add(cloud)
##### NICE BUTTON #####
def smooth_button_generator(pos, w, h, text, onrelease, color):
t = rb.Text(text, white_32.clone())
r = rb.Raster(w, h, z_index=-1)
r.fill(color)
b = rb.Button(
w,
h,
onhover=lambda: rb.Time.recurrent_call(increase_font_size, 0.003),
onexit=lambda: rb.Time.recurrent_call(decrease_font_size, 0.003),
onrelease=onrelease,
)
font_changing: rb.RecurrentTask | None = None
def increase_font_size(task: rb.RecurrentTask):
nonlocal font_changing
if font_changing is not None and font_changing != task:
font_changing.stop()
t.font_object.size += 1
if t.font_object.size >= 64:
task.stop()
font_changing = None
t.font_object.size = 64
else:
font_changing = task
def decrease_font_size(task: rb.RecurrentTask):
nonlocal font_changing
if font_changing is not None and font_changing != task:
font_changing.stop()
t.font_object.size -= 1
if t.font_object.size <= 32:
task.stop()
font_changing = None
t.font_object.size = 32
else:
font_changing = task
return rb.GameObject(pos=pos).add(b, t, r)
from rubato import Component, Animation, RigidBody, Rectangle, Manifold, Radio, Events, KeyResponse, JoyButtonResponse \
, Input, Math, Display, Game, Time, Vector
from shared import DataScene
class PlayerController(Component):
def setup(self):
self.initial_pos = self.gameobj.pos.clone()
self.animation: Animation = self.gameobj.get(Animation)
self.rigid: RigidBody = self.gameobj.get(RigidBody)
rects = self.gameobj.get_all(Rectangle)
self.ground_detector: Rectangle = [r for r in rects if r.tag == "player_ground_detector"][0]
self.ground_detector.on_collide = self.ground_detect
self.ground_detector.on_exit = self.ground_exit
self.grounded = False # tracks the ground state
self.jumps = 0 # tracks the number of jumps the player has left
Radio.listen(Events.KEYDOWN, self.handle_key_down)
Radio.listen(Events.JOYBUTTONDOWN, self.handle_controller_button)
def ground_detect(self, col_info: Manifold):
if "ground" in col_info.shape_b.tag and self.rigid.velocity.y >= 0:
if not self.grounded:
self.grounded = True
self.jumps = 2
self.animation.set_state("idle", True)
def ground_exit(self, col_info: Manifold):
if "ground" in col_info.shape_b.tag:
self.grounded = False
def handle_key_down(self, event: KeyResponse):
if event.key == "w" and self.jumps > 0:
self.grounded = False
if self.jumps == 2:
self.rigid.velocity.y = 800
self.animation.set_state("jump", freeze=2)
elif self.jumps == 1:
self.rigid.velocity.y = 800
self.animation.set_state("somer", True)
self.jumps -= 1
def handle_controller_button(self, event: JoyButtonResponse):
if event.button == 0: # xbox a button / sony x button
self.handle_key_down(KeyResponse(event.timestamp, "w", "", 0, 0))
def update(self):
move_axis = Input.controller_axis(0, 0) if Input.controllers() else 0
centered = Input.axis_centered(move_axis)
# Movement
if Input.key_pressed("a") or (move_axis < 0 and not centered):
self.rigid.velocity.x = -300
self.animation.flipx = True
elif Input.key_pressed("d") or (move_axis > 0 and not centered):
self.rigid.velocity.x = 300
self.animation.flipx = False
else:
if not self.grounded:
self.rigid.velocity.x = 0
self.rigid.friction = 0
else:
self.rigid.friction = 1
# Running animation states
if self.grounded:
if self.rigid.velocity.x in (-300, 300):
if Input.key_pressed("shift") or Input.key_pressed("s"):
self.animation.set_state("sneak", True)
else:
self.animation.set_state("run", True)
else:
if Input.key_pressed("shift") or Input.key_pressed("s"):
self.animation.set_state("crouch", True)
else:
self.animation.set_state("idle", True)
# Reset
if Input.key_pressed("r") or (
Input.controller_button(0, 6) if Input.controllers() else False
) or self.gameobj.pos.y < -550:
self.animation.set_state("idle", True)
self.gameobj.pos = self.initial_pos.clone()
self.rigid.stop()
self.grounded = False
Game.current().camera.pos = Vector(0, 0)
# define a custom fixed update function
def fixed_update(self):
# have the camera follow the player
current_scene = Game.current()
if isinstance(current_scene, DataScene):
camera_ideal = Math.clamp(
self.gameobj.pos.x + Display.res.x / 4, Display.center.x, current_scene.level_size - Display.res.x
)
current_scene.camera.pos.x = Math.lerp(current_scene.camera.pos.x, camera_ideal, Time.fixed_delta / 0.4)
from rubato import Component, Time, Vector, Rectangle, Manifold
class MovingPlatform(Component):
def __init__(self, speed, direction, bound, pause=0):
super().__init__()
self.speed = speed
self.direction = direction
if direction == "r":
self.direction_vect = Vector(1, 0)
elif direction == "l":
self.direction_vect = Vector(-1, 0)
elif direction == "u":
self.direction_vect = Vector(0, 1)
elif direction == "d":
self.direction_vect = Vector(0, -1)
self.bound = bound
self.pause = pause
self.pause_counter = 0
def setup(self):
self.initial_pos = self.gameobj.pos.clone()
self.hitbox = self.gameobj.get(Rectangle)
self.hitbox.on_collide = self.collision_detect
def collision_detect(self, col_info: Manifold):
if col_info.shape_b.tag == "player" and self.pause_counter <= 0:
col_info.shape_b.gameobj.pos.x += self.direction_vect.x * self.speed * Time.fixed_delta
def fixed_update(self):
if self.pause_counter > 0:
self.pause_counter -= Time.fixed_delta
return
self.gameobj.pos += self.direction_vect * self.speed * Time.fixed_delta
self.old_dir_vect = self.direction_vect.clone()
if self.direction == "r":
if self.gameobj.pos.x > self.initial_pos.x + self.bound:
self.direction_vect = Vector(-1, 0)
if self.gameobj.pos.x < self.initial_pos.x:
self.direction_vect = Vector(1, 0)
elif self.direction == "l":
if self.gameobj.pos.x < self.initial_pos.x - self.bound:
self.direction_vect = Vector(1, 0)
if self.gameobj.pos.x > self.initial_pos.x:
self.direction_vect = Vector(-1, 0)
elif self.direction == "u":
if self.gameobj.pos.y > self.initial_pos.y + self.bound:
self.direction_vect = Vector(0, -1)
if self.gameobj.pos.y < self.initial_pos.y:
self.direction_vect = Vector(0, 1)
elif self.direction == "d":
if self.gameobj.pos.y < self.initial_pos.y - self.bound:
self.direction_vect = Vector(0, 1)
if self.gameobj.pos.y > self.initial_pos.y:
self.direction_vect = Vector(0, -1)
if self.old_dir_vect != self.direction_vect:
self.pause_counter = self.pause
We hope this tutorial gave enough detail as to the basics of rubato to let you make your own games and simulations! If you have questions or feedback, please feel free to contact us on our Discord server or by sending us an email!