Step 17 - Views#

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