Step 16 - Shooting Bullets#

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