|
| 1 | +# |
| 2 | +# Arcade Platformer |
| 3 | +# |
| 4 | +# Demonstrating the capbilities of arcade in a platformer game |
| 5 | +# Supporting the Arcade Platformer article on https://realpython.com |
| 6 | +# |
| 7 | +# All game artwork and sounds, except the tile map and victory sound, |
| 8 | +# from www.kenney.nl |
| 9 | + |
| 10 | + |
| 11 | +# Import libraries |
| 12 | +import arcade |
| 13 | +import pathlib |
| 14 | + |
| 15 | +# Game constants |
| 16 | +# Window dimensions |
| 17 | +SCREEN_WIDTH = 1000 |
| 18 | +SCREEN_HEIGHT = 650 |
| 19 | +SCREEN_TITLE = "Arcade Platformer" |
| 20 | + |
| 21 | +# Scaling Constants |
| 22 | +MAP_SCALING = 1.0 |
| 23 | + |
| 24 | +# Player constants |
| 25 | +GRAVITY = 1.0 |
| 26 | +PLAYER_START_X = 65 |
| 27 | +PLAYER_START_Y = 256 |
| 28 | +PLAYER_MOVE_SPEED = 10 |
| 29 | +PLAYER_JUMP_SPEED = 20 |
| 30 | + |
| 31 | +# Viewport margins |
| 32 | +# How close do we have to be to scroll the viewport? |
| 33 | +LEFT_VIEWPORT_MARGIN = 50 |
| 34 | +RIGHT_VIEWPORT_MARGIN = 300 |
| 35 | +TOP_VIEWPORT_MARGIN = 150 |
| 36 | +BOTTOM_VIEWPORT_MARGIN = 150 |
| 37 | + |
| 38 | +# Assets path |
| 39 | +ASSETS_PATH = pathlib.Path(__file__).resolve().parent.parent / "assets" |
| 40 | + |
| 41 | + |
| 42 | +# Classes |
| 43 | +class Platformer(arcade.Window): |
| 44 | + """PlatformerView class. Derived from arcade.View, provides all functionality |
| 45 | + from arcade.Window, plus managing different views for our game. |
| 46 | + """ |
| 47 | + |
| 48 | + def __init__(self) -> None: |
| 49 | + """Create the game view""" |
| 50 | + # First initialize the parent |
| 51 | + super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) |
| 52 | + |
| 53 | + # These lists will hold different sets of sprites |
| 54 | + self.coins_list = None |
| 55 | + self.background_list = None |
| 56 | + self.walls_list = None |
| 57 | + self.ladders_list = None |
| 58 | + self.goals_list = None |
| 59 | + self.enemies_list = None |
| 60 | + |
| 61 | + # One sprite for the player, no more is needed |
| 62 | + self.player = None |
| 63 | + |
| 64 | + # We need a physics engine as well |
| 65 | + self.physics_engine = None |
| 66 | + |
| 67 | + # Someplace to keep score |
| 68 | + self.score = 0 |
| 69 | + |
| 70 | + # Which level are we on? |
| 71 | + self.level = 1 |
| 72 | + |
| 73 | + # Load up our sounds here |
| 74 | + self.coin_sound = arcade.load_sound( |
| 75 | + str(ASSETS_PATH / "sounds" / "coin.wav") |
| 76 | + ) |
| 77 | + self.jump_sound = arcade.load_sound( |
| 78 | + str(ASSETS_PATH / "sounds" / "jump.wav") |
| 79 | + ) |
| 80 | + self.victory_sound = arcade.load_sound( |
| 81 | + str(ASSETS_PATH / "sounds" / "victory.wav") |
| 82 | + ) |
| 83 | + |
| 84 | + def setup(self) -> None: |
| 85 | + """Sets up the game for the current level""" |
| 86 | + |
| 87 | + # Get the current map based on the level |
| 88 | + map_name = f"platform_level_{self.level:02}.tmx" |
| 89 | + map_path = ASSETS_PATH / map_name |
| 90 | + |
| 91 | + # What are the names of the layers? |
| 92 | + wall_layer = "ground" |
| 93 | + coin_layer = "coins" |
| 94 | + goal_layer = "goal" |
| 95 | + background_layer = "background" |
| 96 | + ladders_layer = "ladders" |
| 97 | + |
| 98 | + # Load the current map |
| 99 | + map = arcade.tilemap.read_tmx(str(map_path)) |
| 100 | + |
| 101 | + # Load the layers |
| 102 | + self.background_list = arcade.tilemap.process_layer( |
| 103 | + map, layer_name=background_layer, scaling=MAP_SCALING |
| 104 | + ) |
| 105 | + self.goals_list = arcade.tilemap.process_layer( |
| 106 | + map, layer_name=goal_layer, scaling=MAP_SCALING |
| 107 | + ) |
| 108 | + self.walls_list = arcade.tilemap.process_layer( |
| 109 | + map, layer_name=wall_layer, scaling=MAP_SCALING |
| 110 | + ) |
| 111 | + self.ladders_list = arcade.tilemap.process_layer( |
| 112 | + map, layer_name=ladders_layer, scaling=MAP_SCALING |
| 113 | + ) |
| 114 | + self.coins_list = arcade.tilemap.process_layer( |
| 115 | + map, layer_name=coin_layer, scaling=MAP_SCALING |
| 116 | + ) |
| 117 | + |
| 118 | + # Set the background color |
| 119 | + background_color = arcade.color.AERO_BLUE |
| 120 | + if map.background_color: |
| 121 | + background_color = map.background_color |
| 122 | + arcade.set_background_color(background_color) |
| 123 | + |
| 124 | + # Find the edge of the map to control viewport scrolling |
| 125 | + self.map_width = (map.map_size.width - 1) * map.tile_size.width |
| 126 | + |
| 127 | + # Create the player sprite, if they're not already setup |
| 128 | + if not self.player: |
| 129 | + self.player = self.create_player_sprite() |
| 130 | + |
| 131 | + # If we have a player sprite, we need to move it back to the beginning |
| 132 | + self.player.center_x = PLAYER_START_X |
| 133 | + self.player.center_y = PLAYER_START_Y |
| 134 | + self.player.change_x = 0 |
| 135 | + self.player.change_y = 0 |
| 136 | + |
| 137 | + # Reset the viewport |
| 138 | + self.view_left = 0 |
| 139 | + self.view_bottom = 0 |
| 140 | + |
| 141 | + # Load the physics engine for this map |
| 142 | + self.physics_engine = arcade.PhysicsEnginePlatformer( |
| 143 | + player_sprite=self.player, |
| 144 | + platforms=self.walls_list, |
| 145 | + gravity_constant=GRAVITY, |
| 146 | + ladders=self.ladders_list, |
| 147 | + ) |
| 148 | + |
| 149 | + def create_player_sprite(self) -> arcade.AnimatedWalkingSprite: |
| 150 | + """Creates the animated player sprite |
| 151 | +
|
| 152 | + Returns: |
| 153 | + The properly setup player sprite |
| 154 | + """ |
| 155 | + # Where are the player images stored? |
| 156 | + texture_path = ASSETS_PATH / "images" / "player" |
| 157 | + |
| 158 | + # Setup the appropriate textures |
| 159 | + walking_paths = [ |
| 160 | + texture_path / f"alienGreen_walk{x}.png" for x in (1, 2) |
| 161 | + ] |
| 162 | + climbing_paths = [ |
| 163 | + texture_path / f"alienGreen_climb{x}.png" for x in (1, 2) |
| 164 | + ] |
| 165 | + standing_path = texture_path / "alienGreen_stand.png" |
| 166 | + |
| 167 | + # Load them all now |
| 168 | + walking_right_textures = [ |
| 169 | + arcade.load_texture(texture) for texture in walking_paths |
| 170 | + ] |
| 171 | + walking_left_textures = [ |
| 172 | + arcade.load_texture(texture, mirrored=True) |
| 173 | + for texture in walking_paths |
| 174 | + ] |
| 175 | + |
| 176 | + walking_up_textures = [ |
| 177 | + arcade.load_texture(texture) for texture in climbing_paths |
| 178 | + ] |
| 179 | + walking_down_textures = [ |
| 180 | + arcade.load_texture(texture) for texture in climbing_paths |
| 181 | + ] |
| 182 | + |
| 183 | + standing_right_textures = [arcade.load_texture(standing_path)] |
| 184 | + |
| 185 | + standing_left_textures = [ |
| 186 | + arcade.load_texture(standing_path, mirrored=True) |
| 187 | + ] |
| 188 | + |
| 189 | + # Create the sprite |
| 190 | + player = arcade.AnimatedWalkingSprite() |
| 191 | + |
| 192 | + # Add the proper textures |
| 193 | + player.stand_left_textures = standing_left_textures |
| 194 | + player.stand_right_textures = standing_right_textures |
| 195 | + player.walk_left_textures = walking_left_textures |
| 196 | + player.walk_right_textures = walking_right_textures |
| 197 | + player.walk_up_textures = walking_up_textures |
| 198 | + player.walk_down_textures = walking_down_textures |
| 199 | + |
| 200 | + # Set the player defaults |
| 201 | + player.center_x = PLAYER_START_X |
| 202 | + player.center_y = PLAYER_START_Y |
| 203 | + player.state = arcade.FACE_RIGHT |
| 204 | + |
| 205 | + # Set the initial texture |
| 206 | + player.texture = player.stand_right_textures[0] |
| 207 | + |
| 208 | + return player |
| 209 | + |
| 210 | + def on_key_press(self, key: int, modifiers: int) -> None: |
| 211 | + """Processes key presses |
| 212 | +
|
| 213 | + Arguments: |
| 214 | + key -- Which key was pressed |
| 215 | + modifiers -- Which modifiers were down at the time |
| 216 | + """ |
| 217 | + |
| 218 | + # Check for player left/right movement |
| 219 | + if key in [arcade.key.LEFT, arcade.key.J]: |
| 220 | + self.player.change_x = -PLAYER_MOVE_SPEED |
| 221 | + elif key in [arcade.key.RIGHT, arcade.key.L]: |
| 222 | + self.player.change_x = PLAYER_MOVE_SPEED |
| 223 | + |
| 224 | + # Check if player can climb up or down |
| 225 | + elif key in [arcade.key.UP, arcade.key.I]: |
| 226 | + if self.physics_engine.is_on_ladder(): |
| 227 | + self.player.change_y = PLAYER_MOVE_SPEED |
| 228 | + elif key in [arcade.key.DOWN, arcade.key.K]: |
| 229 | + if self.physics_engine.is_on_ladder(): |
| 230 | + self.player.change_y = -PLAYER_MOVE_SPEED |
| 231 | + |
| 232 | + # Check if we can jump |
| 233 | + elif key == arcade.key.SPACE: |
| 234 | + if self.physics_engine.can_jump(): |
| 235 | + self.player.change_y = PLAYER_JUMP_SPEED |
| 236 | + # Play the jump sound |
| 237 | + arcade.play_sound(self.jump_sound) |
| 238 | + |
| 239 | + def on_key_release(self, key: int, modifiers: int) -> None: |
| 240 | + """Processes key releases |
| 241 | +
|
| 242 | + Arguments: |
| 243 | + key -- The key which was released |
| 244 | + modifiers -- Which modifiers were down at the time |
| 245 | + """ |
| 246 | + |
| 247 | + # Check for player left/right movement |
| 248 | + if key in [ |
| 249 | + arcade.key.LEFT, |
| 250 | + arcade.key.J, |
| 251 | + arcade.key.RIGHT, |
| 252 | + arcade.key.L, |
| 253 | + ]: |
| 254 | + self.player.change_x = 0 |
| 255 | + |
| 256 | + # Check if player can climb up or down |
| 257 | + elif key in [ |
| 258 | + arcade.key.UP, |
| 259 | + arcade.key.I, |
| 260 | + arcade.key.DOWN, |
| 261 | + arcade.key.K, |
| 262 | + ]: |
| 263 | + if self.physics_engine.is_on_ladder(): |
| 264 | + self.player.change_y = 0 |
| 265 | + |
| 266 | + def on_update(self, delta_time: float) -> None: |
| 267 | + """Updates the position of all screen objects |
| 268 | +
|
| 269 | + Arguments: |
| 270 | + delta_time -- How much time since the last call |
| 271 | + """ |
| 272 | + |
| 273 | + # Update the player animation |
| 274 | + self.player.update_animation(delta_time) |
| 275 | + |
| 276 | + # Update player movement based on the physics engine |
| 277 | + self.physics_engine.update() |
| 278 | + |
| 279 | + # Restrict user movement so they can't walk off screen |
| 280 | + if self.player.left < 0: |
| 281 | + self.player.left = 0 |
| 282 | + |
| 283 | + # Check if we've picked up a coin |
| 284 | + coins_hit = arcade.check_for_collision_with_list( |
| 285 | + sprite=self.player, sprite_list=self.coins_list |
| 286 | + ) |
| 287 | + |
| 288 | + for coin in coins_hit: |
| 289 | + # Add the coin score to our score |
| 290 | + self.score += int(coin.properties["point_value"]) |
| 291 | + |
| 292 | + # Play the coin sound |
| 293 | + arcade.play_sound(self.coin_sound) |
| 294 | + |
| 295 | + # Remove the coin |
| 296 | + coin.remove_from_sprite_lists() |
| 297 | + |
| 298 | + # Now check if we're at the ending goal |
| 299 | + goals_hit = arcade.check_for_collision_with_list( |
| 300 | + sprite=self.player, sprite_list=self.goals_list |
| 301 | + ) |
| 302 | + |
| 303 | + if goals_hit: |
| 304 | + # Play the victory sound |
| 305 | + self.victory_sound.play() |
| 306 | + |
| 307 | + # Setup the next level |
| 308 | + self.level += 1 |
| 309 | + self.setup() |
| 310 | + |
| 311 | + # Set the viewport, scrolling if necessary |
| 312 | + self.scroll_viewport() |
| 313 | + |
| 314 | + def scroll_viewport(self) -> None: |
| 315 | + """Scrolls the viewport when the player gets close to the edges""" |
| 316 | + # Scroll left |
| 317 | + # Find the current left boundary |
| 318 | + left_boundary = self.view_left + LEFT_VIEWPORT_MARGIN |
| 319 | + |
| 320 | + # Are we to the left of this boundary? Then we should scroll left |
| 321 | + if self.player.left < left_boundary: |
| 322 | + self.view_left -= left_boundary - self.player.left |
| 323 | + # But don't scroll past the left edge of the map |
| 324 | + if self.view_left < 0: |
| 325 | + self.view_left = 0 |
| 326 | + |
| 327 | + # Scroll right |
| 328 | + # Find the current right boundary |
| 329 | + right_boundary = self.view_left + SCREEN_WIDTH - RIGHT_VIEWPORT_MARGIN |
| 330 | + |
| 331 | + # Are we right of this boundary? Then we should scroll right |
| 332 | + if self.player.right > right_boundary: |
| 333 | + self.view_left += self.player.right - right_boundary |
| 334 | + # Don't scroll past the right edge of the map |
| 335 | + if self.view_left > self.map_width - SCREEN_WIDTH: |
| 336 | + self.view_left = self.map_width - SCREEN_WIDTH |
| 337 | + |
| 338 | + # Scroll up |
| 339 | + top_boundary = self.view_bottom + SCREEN_HEIGHT - TOP_VIEWPORT_MARGIN |
| 340 | + if self.player.top > top_boundary: |
| 341 | + self.view_bottom += self.player.top - top_boundary |
| 342 | + |
| 343 | + # Scroll down |
| 344 | + bottom_boundary = self.view_bottom + BOTTOM_VIEWPORT_MARGIN |
| 345 | + if self.player.bottom < bottom_boundary: |
| 346 | + self.view_bottom -= bottom_boundary - self.player.bottom |
| 347 | + |
| 348 | + # Only scroll to integers. Otherwise we end up with pixels that |
| 349 | + # don't line up on the screen |
| 350 | + self.view_bottom = int(self.view_bottom) |
| 351 | + self.view_left = int(self.view_left) |
| 352 | + |
| 353 | + # Do the scrolling |
| 354 | + arcade.set_viewport( |
| 355 | + left=self.view_left, |
| 356 | + right=SCREEN_WIDTH + self.view_left, |
| 357 | + bottom=self.view_bottom, |
| 358 | + top=SCREEN_HEIGHT + self.view_bottom, |
| 359 | + ) |
| 360 | + |
| 361 | + def on_draw(self) -> None: |
| 362 | + """Draws everything""" |
| 363 | + |
| 364 | + arcade.start_render() |
| 365 | + |
| 366 | + # Draw all the sprites |
| 367 | + self.background_list.draw() |
| 368 | + self.walls_list.draw() |
| 369 | + self.coins_list.draw() |
| 370 | + self.goals_list.draw() |
| 371 | + self.ladders_list.draw() |
| 372 | + self.player.draw() |
| 373 | + |
| 374 | + # Draw the score in the lower left |
| 375 | + score_text = f"Score: {self.score}" |
| 376 | + |
| 377 | + # First a black background for a shadow effect |
| 378 | + arcade.draw_text( |
| 379 | + score_text, |
| 380 | + start_x=10 + self.view_left, |
| 381 | + start_y=10 + self.view_bottom, |
| 382 | + color=arcade.csscolor.BLACK, |
| 383 | + font_size=40, |
| 384 | + ) |
| 385 | + # Now in white slightly shifted |
| 386 | + arcade.draw_text( |
| 387 | + score_text, |
| 388 | + start_x=15 + self.view_left, |
| 389 | + start_y=15 + self.view_bottom, |
| 390 | + color=arcade.csscolor.WHITE, |
| 391 | + font_size=40, |
| 392 | + ) |
| 393 | + |
| 394 | +# Main |
| 395 | +if __name__ == "__main__": |
| 396 | + window = Platformer() |
| 397 | + window.setup() |
| 398 | + arcade.run() |
0 commit comments