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