Step 17 - Views#

Shooting Bullets#
  1"""
  2Platformer Game
  3
  4python -m arcade.examples.platform_tutorial.17_views
  5"""
  6import math
  7
  8import arcade
  9
 10# Constants
 11SCREEN_WIDTH = 1000
 12SCREEN_HEIGHT = 650
 13SCREEN_TITLE = "Platformer"
 14
 15# Constants used to scale our sprites from their original size
 16TILE_SCALING = 0.5
 17CHARACTER_SCALING = TILE_SCALING * 2
 18COIN_SCALING = TILE_SCALING
 19SPRITE_PIXEL_SIZE = 128
 20GRID_PIXEL_SIZE = SPRITE_PIXEL_SIZE * TILE_SCALING
 21
 22# Shooting Constants
 23SPRITE_SCALING_LASER = 0.8
 24SHOOT_SPEED = 15
 25BULLET_SPEED = 12
 26BULLET_DAMAGE = 25
 27
 28# Movement speed of player, in pixels per frame
 29PLAYER_MOVEMENT_SPEED = 7
 30GRAVITY = 1.5
 31PLAYER_JUMP_SPEED = 30
 32
 33# How many pixels to keep as a minimum margin between the character
 34# and the edge of the screen.
 35LEFT_VIEWPORT_MARGIN = 200
 36RIGHT_VIEWPORT_MARGIN = 200
 37BOTTOM_VIEWPORT_MARGIN = 150
 38TOP_VIEWPORT_MARGIN = 100
 39
 40PLAYER_START_X = 2
 41PLAYER_START_Y = 1
 42
 43# Constants used to track if the player is facing left or right
 44RIGHT_FACING = 0
 45LEFT_FACING = 1
 46
 47LAYER_NAME_MOVING_PLATFORMS = "Moving Platforms"
 48LAYER_NAME_PLATFORMS = "Platforms"
 49LAYER_NAME_COINS = "Coins"
 50LAYER_NAME_BACKGROUND = "Background"
 51LAYER_NAME_LADDERS = "Ladders"
 52LAYER_NAME_PLAYER = "Player"
 53LAYER_NAME_ENEMIES = "Enemies"
 54LAYER_NAME_BULLETS = "Bullets"
 55
 56
 57def load_texture_pair(filename):
 58    """
 59    Load a texture pair, with the second being a mirror image.
 60    """
 61    texture = arcade.load_texture(filename)
 62    return [texture, texture.flip_left_right()]
 63
 64
 65class Entity(arcade.Sprite):
 66    def __init__(self, name_folder, name_file):
 67        super().__init__()
 68
 69        # Default to facing right
 70        self.facing_direction = RIGHT_FACING
 71
 72        # Used for image sequences
 73        self.cur_texture = 0
 74        self.scale = CHARACTER_SCALING
 75
 76        main_path = f":resources:images/animated_characters/{name_folder}/{name_file}"
 77
 78        self.idle_texture_pair = load_texture_pair(f"{main_path}_idle.png")
 79        self.jump_texture_pair = load_texture_pair(f"{main_path}_jump.png")
 80        self.fall_texture_pair = load_texture_pair(f"{main_path}_fall.png")
 81
 82        # Load textures for walking
 83        self.walk_textures = []
 84        for i in range(8):
 85            texture = load_texture_pair(f"{main_path}_walk{i}.png")
 86            self.walk_textures.append(texture)
 87
 88        # Load textures for climbing
 89        self.climbing_textures = []
 90        texture = arcade.load_texture(f"{main_path}_climb0.png")
 91        self.climbing_textures.append(texture)
 92        texture = arcade.load_texture(f"{main_path}_climb1.png")
 93        self.climbing_textures.append(texture)
 94
 95        # Set the initial texture
 96        self.texture = self.idle_texture_pair[0]
 97
 98        # Hit box will be set based on the first image used. If you want to specify
 99        # a different hit box, you can do it like the code below. Doing this when
100        # changing the texture for example would make the hitbox update whenever the
101        # texture is changed. This can be expensive so if the textures are very similar
102        # it may not be worth doing.
103        #
104        # self.hit_box = arcade.hitbox.RotatableHitBox(
105        #     self.texture.hit_box_points,
106        #     position=self.position,
107        #     scale=self.scale_xy,
108        #     angle=self.angle,
109        # )
110
111
112class Enemy(Entity):
113    def __init__(self, name_folder, name_file):
114        # Setup parent class
115        super().__init__(name_folder, name_file)
116
117        self.should_update_walk = 0
118        self.health = 0
119
120    def update_animation(self, delta_time: float = 1 / 60):
121        # Figure out if we need to flip face left or right
122        if self.change_x < 0 and self.facing_direction == RIGHT_FACING:
123            self.facing_direction = LEFT_FACING
124        elif self.change_x > 0 and self.facing_direction == LEFT_FACING:
125            self.facing_direction = RIGHT_FACING
126
127        # Idle animation
128        if self.change_x == 0:
129            self.texture = self.idle_texture_pair[self.facing_direction]
130            return
131
132        # Walking animation
133        if self.should_update_walk == 3:
134            self.cur_texture += 1
135            if self.cur_texture > 7:
136                self.cur_texture = 0
137            self.texture = self.walk_textures[self.cur_texture][self.facing_direction]
138            self.should_update_walk = 0
139            return
140
141        self.should_update_walk += 1
142
143
144class RobotEnemy(Enemy):
145    def __init__(self):
146        # Set up parent class
147        super().__init__("robot", "robot")
148
149        self.health = 100
150
151
152class ZombieEnemy(Enemy):
153    def __init__(self):
154        # Set up parent class
155        super().__init__("zombie", "zombie")
156
157        self.health = 50
158
159
160class PlayerCharacter(Entity):
161    """Player Sprite"""
162
163    def __init__(self):
164        # Set up parent class
165        super().__init__("male_person", "malePerson")
166
167        # Track our state
168        self.jumping = False
169        self.climbing = False
170        self.is_on_ladder = False
171
172    def update_animation(self, delta_time: float = 1 / 60):
173        # Figure out if we need to flip face left or right
174        if self.change_x < 0 and self.facing_direction == RIGHT_FACING:
175            self.facing_direction = LEFT_FACING
176        elif self.change_x > 0 and self.facing_direction == LEFT_FACING:
177            self.facing_direction = RIGHT_FACING
178
179        # Climbing animation
180        if self.is_on_ladder:
181            self.climbing = True
182        if not self.is_on_ladder and self.climbing:
183            self.climbing = False
184        if self.climbing and abs(self.change_y) > 1:
185            self.cur_texture += 1
186            if self.cur_texture > 7:
187                self.cur_texture = 0
188        if self.climbing:
189            self.texture = self.climbing_textures[self.cur_texture // 4]
190            return
191
192        # Jumping animation
193        if self.change_y > 0 and not self.is_on_ladder:
194            self.texture = self.jump_texture_pair[self.facing_direction]
195            return
196        elif self.change_y < 0 and not self.is_on_ladder:
197            self.texture = self.fall_texture_pair[self.facing_direction]
198            return
199
200        # Idle animation
201        if self.change_x == 0:
202            self.texture = self.idle_texture_pair[self.facing_direction]
203            return
204
205        # Walking animation
206        self.cur_texture += 1
207        if self.cur_texture > 7:
208            self.cur_texture = 0
209        self.texture = self.walk_textures[self.cur_texture][self.facing_direction]
210
211
212class MainMenu(arcade.View):
213    """Class that manages the 'menu' view."""
214
215    def on_show_view(self):
216        """Called when switching to this view."""
217        self.window.background_color = arcade.color.WHITE
218
219    def on_draw(self):
220        """Draw the menu"""
221        self.clear()
222        arcade.draw_text(
223            "Main Menu - Click to play",
224            SCREEN_WIDTH / 2,
225            SCREEN_HEIGHT / 2,
226            arcade.color.BLACK,
227            font_size=30,
228            anchor_x="center",
229        )
230
231    def on_mouse_press(self, _x, _y, _button, _modifiers):
232        """Use a mouse press to advance to the 'game' view."""
233        game_view = GameView()
234        self.window.show_view(game_view)
235
236
237class GameView(arcade.View):
238    """
239    Main application class.
240    """
241
242    def __init__(self):
243        """
244        Initializer for the game
245        """
246        super().__init__()
247
248        # Track the current state of what key is pressed
249        self.left_pressed = False
250        self.right_pressed = False
251        self.up_pressed = False
252        self.down_pressed = False
253        self.shoot_pressed = False
254        self.jump_needs_reset = False
255
256        # Our TileMap Object
257        self.tile_map = None
258
259        # Our Scene Object
260        self.scene = None
261
262        # Separate variable that holds the player sprite
263        self.player_sprite = None
264
265        # Our 'physics' engine
266        self.physics_engine = None
267
268        # A Camera that can be used for scrolling the screen
269        self.camera = None
270
271        # A Camera that can be used to draw GUI elements
272        self.gui_camera = None
273
274        self.end_of_map = 0
275
276        # Keep track of the score
277        self.score = 0
278
279        # Shooting mechanics
280        self.can_shoot = False
281        self.shoot_timer = 0
282
283        # Load sounds
284        self.collect_coin_sound = arcade.load_sound(":resources:sounds/coin1.wav")
285        self.jump_sound = arcade.load_sound(":resources:sounds/jump1.wav")
286        self.game_over = arcade.load_sound(":resources:sounds/gameover1.wav")
287        self.shoot_sound = arcade.load_sound(":resources:sounds/hurt5.wav")
288        self.hit_sound = arcade.load_sound(":resources:sounds/hit5.wav")
289
290    def setup(self):
291        """Set up the game here. Call this function to restart the game."""
292
293        # Set up the Cameras
294        viewport = (0, 0, self.window.width, self.window.height)
295        self.camera = arcade.SimpleCamera(viewport=viewport)
296        self.gui_camera = arcade.SimpleCamera(viewport=viewport)
297
298        # Map name
299        map_name = ":resources:tiled_maps/map_with_ladders.json"
300
301        # Layer Specific Options for the Tilemap
302        layer_options = {
303            LAYER_NAME_PLATFORMS: {
304                "use_spatial_hash": True,
305            },
306            LAYER_NAME_MOVING_PLATFORMS: {
307                "use_spatial_hash": False,
308            },
309            LAYER_NAME_LADDERS: {
310                "use_spatial_hash": True,
311            },
312            LAYER_NAME_COINS: {
313                "use_spatial_hash": True,
314            },
315        }
316
317        # Load in TileMap
318        self.tile_map = arcade.load_tilemap(map_name, TILE_SCALING, layer_options)
319
320        # Initiate New Scene with our TileMap, this will automatically add all layers
321        # from the map as SpriteLists in the scene in the proper order.
322        self.scene = arcade.Scene.from_tilemap(self.tile_map)
323
324        # Keep track of the score
325        self.score = 0
326
327        # Shooting mechanics
328        self.can_shoot = True
329        self.shoot_timer = 0
330
331        # Set up the player, specifically placing it at these coordinates.
332        self.player_sprite = PlayerCharacter()
333        self.player_sprite.center_x = (
334            self.tile_map.tile_width * TILE_SCALING * PLAYER_START_X
335        )
336        self.player_sprite.center_y = (
337            self.tile_map.tile_height * TILE_SCALING * PLAYER_START_Y
338        )
339        self.scene.add_sprite(LAYER_NAME_PLAYER, self.player_sprite)
340
341        # Calculate the right edge of the my_map in pixels
342        self.end_of_map = self.tile_map.width * GRID_PIXEL_SIZE
343
344        # -- Enemies
345        enemies_layer = self.tile_map.object_lists[LAYER_NAME_ENEMIES]
346
347        for my_object in enemies_layer:
348            cartesian = self.tile_map.get_cartesian(
349                my_object.shape[0], my_object.shape[1]
350            )
351            enemy_type = my_object.properties["type"]
352            if enemy_type == "robot":
353                enemy = RobotEnemy()
354            elif enemy_type == "zombie":
355                enemy = ZombieEnemy()
356            enemy.center_x = math.floor(
357                cartesian[0] * TILE_SCALING * self.tile_map.tile_width
358            )
359            enemy.center_y = math.floor(
360                (cartesian[1] + 1) * (self.tile_map.tile_height * TILE_SCALING)
361            )
362            if "boundary_left" in my_object.properties:
363                enemy.boundary_left = my_object.properties["boundary_left"]
364            if "boundary_right" in my_object.properties:
365                enemy.boundary_right = my_object.properties["boundary_right"]
366            if "change_x" in my_object.properties:
367                enemy.change_x = my_object.properties["change_x"]
368            self.scene.add_sprite(LAYER_NAME_ENEMIES, enemy)
369
370        # Add bullet spritelist to Scene
371        self.scene.add_sprite_list(LAYER_NAME_BULLETS)
372
373        # --- Other stuff
374        # Set the background color
375        if self.tile_map.background_color:
376            self.window.background_color = self.tile_map.background_color
377
378        # Create the 'physics engine'
379        self.physics_engine = arcade.PhysicsEnginePlatformer(
380            self.player_sprite,
381            platforms=self.scene[LAYER_NAME_MOVING_PLATFORMS],
382            gravity_constant=GRAVITY,
383            ladders=self.scene[LAYER_NAME_LADDERS],
384            walls=self.scene[LAYER_NAME_PLATFORMS],
385        )
386
387    def on_show_view(self):
388        self.setup()
389
390    def on_draw(self):
391        """Render the screen."""
392
393        # Clear the screen to the background color
394        self.clear()
395
396        # Activate the game camera
397        self.camera.use()
398
399        # Draw our Scene
400        self.scene.draw()
401
402        # Draw hit boxes.
403        # self.scene[LAYER_NAME_COINS].draw_hit_boxes(color=arcade.color.WHITE)
404        # self.scene[LAYER_NAME_ENEMIES].draw_hit_boxes(color=arcade.color.WHITE)
405        # self.scene[LAYER_NAME_PLAYER].draw_hit_boxes(color=arcade.color.WHITE)
406        self.scene.draw_hit_boxes(color=arcade.color.WHITE)
407
408        # Activate the GUI camera before drawing GUI elements
409        self.gui_camera.use()
410
411        # Draw our score on the screen, scrolling it with the viewport
412        score_text = f"Score: {self.score}"
413        arcade.draw_text(
414            score_text,
415            10,
416            10,
417            arcade.csscolor.BLACK,
418            18,
419        )
420
421    def process_keychange(self):
422        """
423        Called when we change a key up/down or we move on/off a ladder.
424        """
425        # Process up/down
426        if self.up_pressed and not self.down_pressed:
427            if self.physics_engine.is_on_ladder():
428                self.player_sprite.change_y = PLAYER_MOVEMENT_SPEED
429            elif (
430                self.physics_engine.can_jump(y_distance=10)
431                and not self.jump_needs_reset
432            ):
433                self.player_sprite.change_y = PLAYER_JUMP_SPEED
434                self.jump_needs_reset = True
435                arcade.play_sound(self.jump_sound)
436        elif self.down_pressed and not self.up_pressed:
437            if self.physics_engine.is_on_ladder():
438                self.player_sprite.change_y = -PLAYER_MOVEMENT_SPEED
439
440        # Process up/down when on a ladder and no movement
441        if self.physics_engine.is_on_ladder():
442            if not self.up_pressed and not self.down_pressed:
443                self.player_sprite.change_y = 0
444            elif self.up_pressed and self.down_pressed:
445                self.player_sprite.change_y = 0
446
447        # Process left/right
448        if self.right_pressed and not self.left_pressed:
449            self.player_sprite.change_x = PLAYER_MOVEMENT_SPEED
450        elif self.left_pressed and not self.right_pressed:
451            self.player_sprite.change_x = -PLAYER_MOVEMENT_SPEED
452        else:
453            self.player_sprite.change_x = 0
454
455    def on_key_press(self, key, modifiers):
456        """Called whenever a key is pressed."""
457
458        if key == arcade.key.UP or key == arcade.key.W:
459            self.up_pressed = True
460        elif key == arcade.key.DOWN or key == arcade.key.S:
461            self.down_pressed = True
462        elif key == arcade.key.LEFT or key == arcade.key.A:
463            self.left_pressed = True
464        elif key == arcade.key.RIGHT or key == arcade.key.D:
465            self.right_pressed = True
466
467        if key == arcade.key.Q:
468            self.shoot_pressed = True
469
470        if key == arcade.key.PLUS:
471            self.camera.zoom(0.01)
472        elif key == arcade.key.MINUS:
473            self.camera.zoom(-0.01)
474
475        self.process_keychange()
476
477    def on_key_release(self, key, modifiers):
478        """Called when the user releases a key."""
479
480        if key == arcade.key.UP or key == arcade.key.W:
481            self.up_pressed = False
482            self.jump_needs_reset = False
483        elif key == arcade.key.DOWN or key == arcade.key.S:
484            self.down_pressed = False
485        elif key == arcade.key.LEFT or key == arcade.key.A:
486            self.left_pressed = False
487        elif key == arcade.key.RIGHT or key == arcade.key.D:
488            self.right_pressed = False
489
490        if key == arcade.key.Q:
491            self.shoot_pressed = False
492
493        self.process_keychange()
494
495    def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
496        try:
497            self.camera.zoom += -0.01 * scroll_y
498        except Exception:
499            pass
500
501    def center_camera_to_player(self, speed=0.2):
502        screen_center_x = self.player_sprite.center_x - (self.camera.viewport_width / 2)
503        screen_center_y = self.player_sprite.center_y - (
504            self.camera.viewport_height / 2
505        )
506        if screen_center_x < 0:
507            screen_center_x = 0
508        if screen_center_y < 0:
509            screen_center_y = 0
510        player_centered = (screen_center_x, screen_center_y)
511
512        self.camera.move_to(player_centered, speed)
513
514    def on_update(self, delta_time):
515        """Movement and game logic"""
516
517        # Move the player with the physics engine
518        self.physics_engine.update()
519
520        # Update animations
521        if self.physics_engine.can_jump():
522            self.player_sprite.can_jump = False
523        else:
524            self.player_sprite.can_jump = True
525
526        if self.physics_engine.is_on_ladder() and not self.physics_engine.can_jump():
527            self.player_sprite.is_on_ladder = True
528            self.process_keychange()
529        else:
530            self.player_sprite.is_on_ladder = False
531            self.process_keychange()
532
533        if self.can_shoot:
534            if self.shoot_pressed:
535                arcade.play_sound(self.shoot_sound)
536                bullet = arcade.Sprite(
537                    ":resources:images/space_shooter/laserBlue01.png",
538                    SPRITE_SCALING_LASER,
539                )
540
541                if self.player_sprite.facing_direction == RIGHT_FACING:
542                    bullet.change_x = BULLET_SPEED
543                else:
544                    bullet.change_x = -BULLET_SPEED
545
546                bullet.center_x = self.player_sprite.center_x
547                bullet.center_y = self.player_sprite.center_y
548
549                self.scene.add_sprite(LAYER_NAME_BULLETS, bullet)
550
551                self.can_shoot = False
552        else:
553            self.shoot_timer += 1
554            if self.shoot_timer == SHOOT_SPEED:
555                self.can_shoot = True
556                self.shoot_timer = 0
557
558        # Update Animations
559        self.scene.update_animation(
560            delta_time,
561            [
562                LAYER_NAME_COINS,
563                LAYER_NAME_BACKGROUND,
564                LAYER_NAME_PLAYER,
565                LAYER_NAME_ENEMIES,
566            ],
567        )
568
569        # Update moving platforms, enemies, and bullets
570        self.scene.update(
571            [LAYER_NAME_MOVING_PLATFORMS, LAYER_NAME_ENEMIES, LAYER_NAME_BULLETS]
572        )
573
574        # See if the enemy hit a boundary and needs to reverse direction.
575        for enemy in self.scene[LAYER_NAME_ENEMIES]:
576            if (
577                enemy.boundary_right
578                and enemy.right > enemy.boundary_right
579                and enemy.change_x > 0
580            ):
581                enemy.change_x *= -1
582
583            if (
584                enemy.boundary_left
585                and enemy.left < enemy.boundary_left
586                and enemy.change_x < 0
587            ):
588                enemy.change_x *= -1
589
590        for bullet in self.scene[LAYER_NAME_BULLETS]:
591            hit_list = arcade.check_for_collision_with_lists(
592                bullet,
593                [
594                    self.scene[LAYER_NAME_ENEMIES],
595                    self.scene[LAYER_NAME_PLATFORMS],
596                    self.scene[LAYER_NAME_MOVING_PLATFORMS],
597                ],
598            )
599
600            if hit_list:
601                bullet.remove_from_sprite_lists()
602
603                for collision in hit_list:
604                    if self.scene[LAYER_NAME_ENEMIES] in collision.sprite_lists:
605                        # The collision was with an enemy
606                        collision.health -= BULLET_DAMAGE
607
608                        if collision.health <= 0:
609                            collision.remove_from_sprite_lists()
610                            self.score += 100
611
612                        # Hit sound
613                        arcade.play_sound(self.hit_sound)
614
615                return
616
617            if (bullet.right < 0) or (
618                bullet.left
619                > (self.tile_map.width * self.tile_map.tile_width) * TILE_SCALING
620            ):
621                bullet.remove_from_sprite_lists()
622
623        player_collision_list = arcade.check_for_collision_with_lists(
624            self.player_sprite,
625            [
626                self.scene[LAYER_NAME_COINS],
627                self.scene[LAYER_NAME_ENEMIES],
628            ],
629        )
630
631        # Loop through each coin we hit (if any) and remove it
632        for collision in player_collision_list:
633            if self.scene[LAYER_NAME_ENEMIES] in collision.sprite_lists:
634                arcade.play_sound(self.game_over)
635                game_over = GameOverView()
636                self.window.show_view(game_over)
637                return
638            else:
639                # Figure out how many points this coin is worth
640                if "Points" not in collision.properties:
641                    print("Warning, collected a coin without a Points property.")
642                else:
643                    points = int(collision.properties["Points"])
644                    self.score += points
645
646                # Remove the coin
647                collision.remove_from_sprite_lists()
648                arcade.play_sound(self.collect_coin_sound)
649
650        # Position the camera
651        self.center_camera_to_player()
652
653
654class GameOverView(arcade.View):
655    """Class to manage the game overview"""
656
657    def on_show_view(self):
658        """Called when switching to this view"""
659        self.window.background_color = arcade.color.BLACK
660
661    def on_draw(self):
662        """Draw the game overview"""
663        self.clear()
664        arcade.draw_text(
665            "Game Over - Click to restart",
666            SCREEN_WIDTH / 2,
667            SCREEN_HEIGHT / 2,
668            arcade.color.WHITE,
669            30,
670            anchor_x="center",
671        )
672
673    def on_mouse_press(self, _x, _y, _button, _modifiers):
674        """Use a mouse press to advance to the 'game' view."""
675        game_view = GameView()
676        self.window.show_view(game_view)
677
678
679def main():
680    """Main function"""
681    window = arcade.Window(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
682    menu_view = MainMenu()
683    window.show_view(menu_view)
684    arcade.run()
685
686
687if __name__ == "__main__":
688    main()
689if __name__ == "__main__":
690    main()
691    main()
692    main()