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()