Step 11 - Add Character Animations, and Better Keyboard Control

Add character animations!

Animate Characters
  1"""
  2Platformer Game
  3
  4python -m arcade.examples.platform_tutorial.11_animate_character
  5"""
  6import arcade
  7import os
  8
  9# Constants
 10SCREEN_WIDTH = 1000
 11SCREEN_HEIGHT = 650
 12SCREEN_TITLE = "Platformer"
 13
 14# Constants used to scale our sprites from their original size
 15TILE_SCALING = 0.5
 16CHARACTER_SCALING = TILE_SCALING * 2
 17COIN_SCALING = TILE_SCALING
 18SPRITE_PIXEL_SIZE = 128
 19GRID_PIXEL_SIZE = (SPRITE_PIXEL_SIZE * TILE_SCALING)
 20
 21# Movement speed of player, in pixels per frame
 22PLAYER_MOVEMENT_SPEED = 7
 23GRAVITY = 1.5
 24PLAYER_JUMP_SPEED = 30
 25
 26# How many pixels to keep as a minimum margin between the character
 27# and the edge of the screen.
 28LEFT_VIEWPORT_MARGIN = 200
 29RIGHT_VIEWPORT_MARGIN = 200
 30BOTTOM_VIEWPORT_MARGIN = 150
 31TOP_VIEWPORT_MARGIN = 100
 32
 33PLAYER_START_X = SPRITE_PIXEL_SIZE * TILE_SCALING * 2
 34PLAYER_START_Y = SPRITE_PIXEL_SIZE * TILE_SCALING * 1
 35
 36# Constants used to track if the player is facing left or right
 37RIGHT_FACING = 0
 38LEFT_FACING = 1
 39
 40
 41def load_texture_pair(filename):
 42    """
 43    Load a texture pair, with the second being a mirror image.
 44    """
 45    return [
 46        arcade.load_texture(filename),
 47        arcade.load_texture(filename, flipped_horizontally=True)
 48    ]
 49
 50
 51class PlayerCharacter(arcade.Sprite):
 52    """ Player Sprite"""
 53    def __init__(self):
 54
 55        # Set up parent class
 56        super().__init__()
 57
 58        # Default to face-right
 59        self.character_face_direction = RIGHT_FACING
 60
 61        # Used for flipping between image sequences
 62        self.cur_texture = 0
 63        self.scale = CHARACTER_SCALING
 64
 65        # Track our state
 66        self.jumping = False
 67        self.climbing = False
 68        self.is_on_ladder = False
 69
 70        # --- Load Textures ---
 71
 72        # Images from Kenney.nl's Asset Pack 3
 73        # main_path = ":resources:images/animated_characters/female_adventurer/femaleAdventurer"
 74        # main_path = ":resources:images/animated_characters/female_person/femalePerson"
 75        main_path = ":resources:images/animated_characters/male_person/malePerson"
 76        # main_path = ":resources:images/animated_characters/male_adventurer/maleAdventurer"
 77        # main_path = ":resources:images/animated_characters/zombie/zombie"
 78        # main_path = ":resources:images/animated_characters/robot/robot"
 79
 80        # Load textures for idle standing
 81        self.idle_texture_pair = load_texture_pair(f"{main_path}_idle.png")
 82        self.jump_texture_pair = load_texture_pair(f"{main_path}_jump.png")
 83        self.fall_texture_pair = load_texture_pair(f"{main_path}_fall.png")
 84
 85        # Load textures for walking
 86        self.walk_textures = []
 87        for i in range(8):
 88            texture = load_texture_pair(f"{main_path}_walk{i}.png")
 89            self.walk_textures.append(texture)
 90
 91        # Load textures for climbing
 92        self.climbing_textures = []
 93        texture = arcade.load_texture(f"{main_path}_climb0.png")
 94        self.climbing_textures.append(texture)
 95        texture = arcade.load_texture(f"{main_path}_climb1.png")
 96        self.climbing_textures.append(texture)
 97
 98        # Set the initial texture
 99        self.texture = self.idle_texture_pair[0]
100
101        # Hit box will be set based on the first image used. If you want to specify
102        # a different hit box, you can do it like the code below.
103        # self.set_hit_box([[-22, -64], [22, -64], [22, 28], [-22, 28]])
104        self.set_hit_box(self.texture.hit_box_points)
105
106    def update_animation(self, delta_time: float = 1/60):
107
108        # Figure out if we need to flip face left or right
109        if self.change_x < 0 and self.character_face_direction == RIGHT_FACING:
110            self.character_face_direction = LEFT_FACING
111        elif self.change_x > 0 and self.character_face_direction == LEFT_FACING:
112            self.character_face_direction = RIGHT_FACING
113
114        # Climbing animation
115        if self.is_on_ladder:
116            self.climbing = True
117        if not self.is_on_ladder and self.climbing:
118            self.climbing = False
119        if self.climbing and abs(self.change_y) > 1:
120            self.cur_texture += 1
121            if self.cur_texture > 7:
122                self.cur_texture = 0
123        if self.climbing:
124            self.texture = self.climbing_textures[self.cur_texture // 4]
125            return
126
127        # Jumping animation
128        if self.change_y > 0 and not self.is_on_ladder:
129            self.texture = self.jump_texture_pair[self.character_face_direction]
130            return
131        elif self.change_y < 0 and not self.is_on_ladder:
132            self.texture = self.fall_texture_pair[self.character_face_direction]
133            return
134
135        # Idle animation
136        if self.change_x == 0:
137            self.texture = self.idle_texture_pair[self.character_face_direction]
138            return
139
140        # Walking animation
141        self.cur_texture += 1
142        if self.cur_texture > 7:
143            self.cur_texture = 0
144        self.texture = self.walk_textures[self.cur_texture][self.character_face_direction]
145
146
147class MyGame(arcade.Window):
148    """
149    Main application class.
150    """
151
152    def __init__(self):
153        """
154        Initializer for the game
155        """
156
157        # Call the parent class and set up the window
158        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
159
160        # Set the path to start with this program
161        file_path = os.path.dirname(os.path.abspath(__file__))
162        os.chdir(file_path)
163
164        # Track the current state of what key is pressed
165        self.left_pressed = False
166        self.right_pressed = False
167        self.up_pressed = False
168        self.down_pressed = False
169        self.jump_needs_reset = False
170
171        # These are 'lists' that keep track of our sprites. Each sprite should
172        # go into a list.
173        self.coin_list = None
174        self.wall_list = None
175        self.background_list = None
176        self.ladder_list = None
177        self.player_list = None
178
179        # Separate variable that holds the player sprite
180        self.player_sprite = None
181
182        # Our 'physics' engine
183        self.physics_engine = None
184
185        # Used to keep track of our scrolling
186        self.view_bottom = 0
187        self.view_left = 0
188
189        self.end_of_map = 0
190
191        # Keep track of the score
192        self.score = 0
193
194        # Load sounds
195        self.collect_coin_sound = arcade.load_sound(":resources:sounds/coin1.wav")
196        self.jump_sound = arcade.load_sound(":resources:sounds/jump1.wav")
197        self.game_over = arcade.load_sound(":resources:sounds/gameover1.wav")
198
199    def setup(self):
200        """ Set up the game here. Call this function to restart the game. """
201
202        # Used to keep track of our scrolling
203        self.view_bottom = 0
204        self.view_left = 0
205
206        # Keep track of the score
207        self.score = 0
208
209        # Create the Sprite lists
210        self.player_list = arcade.SpriteList()
211        self.background_list = arcade.SpriteList()
212        self.wall_list = arcade.SpriteList()
213        self.coin_list = arcade.SpriteList()
214
215        # Set up the player, specifically placing it at these coordinates.
216        self.player_sprite = PlayerCharacter()
217
218        self.player_sprite.center_x = PLAYER_START_X
219        self.player_sprite.center_y = PLAYER_START_Y
220        self.player_list.append(self.player_sprite)
221
222        # --- Load in a map from the tiled editor ---
223
224        # Name of the layer in the file that has our platforms/walls
225        platforms_layer_name = 'Platforms'
226        moving_platforms_layer_name = 'Moving Platforms'
227
228        # Name of the layer that has items for pick-up
229        coins_layer_name = 'Coins'
230
231        # Map name
232        map_name = f":resources:tmx_maps/map_with_ladders.tmx"
233
234        # Read in the tiled map
235        my_map = arcade.tilemap.read_tmx(map_name)
236
237        # Calculate the right edge of the my_map in pixels
238        self.end_of_map = my_map.map_size.width * GRID_PIXEL_SIZE
239
240        # -- Platforms
241        self.wall_list = arcade.tilemap.process_layer(my_map,
242                                                      platforms_layer_name,
243                                                      TILE_SCALING,
244                                                      use_spatial_hash=True)
245
246        # -- Moving Platforms
247        moving_platforms_list = arcade.tilemap.process_layer(my_map, moving_platforms_layer_name, TILE_SCALING)
248        for sprite in moving_platforms_list:
249            self.wall_list.append(sprite)
250
251        # -- Background objects
252        self.background_list = arcade.tilemap.process_layer(my_map, "Background", TILE_SCALING)
253
254        # -- Background objects
255        self.ladder_list = arcade.tilemap.process_layer(my_map, "Ladders",
256                                                        TILE_SCALING,
257                                                        use_spatial_hash=True)
258
259        # -- Coins
260        self.coin_list = arcade.tilemap.process_layer(my_map, coins_layer_name,
261                                                      TILE_SCALING,
262                                                      use_spatial_hash=True)
263
264        # --- Other stuff
265        # Set the background color
266        if my_map.background_color:
267            arcade.set_background_color(my_map.background_color)
268
269        # Create the 'physics engine'
270        self.physics_engine = arcade.PhysicsEnginePlatformer(self.player_sprite,
271                                                             self.wall_list,
272                                                             gravity_constant=GRAVITY,
273                                                             ladders=self.ladder_list)
274
275    def on_draw(self):
276        """ Render the screen. """
277
278        # Clear the screen to the background color
279        arcade.start_render()
280
281        # Draw our sprites
282        self.wall_list.draw()
283        self.background_list.draw()
284        self.ladder_list.draw()
285        self.coin_list.draw()
286        self.player_list.draw()
287
288        # Draw our score on the screen, scrolling it with the viewport
289        score_text = f"Score: {self.score}"
290        arcade.draw_text(score_text, 10 + self.view_left, 10 + self.view_bottom,
291                         arcade.csscolor.BLACK, 18)
292
293        # Draw hit boxes.
294        # for wall in self.wall_list:
295        #     wall.draw_hit_box(arcade.color.BLACK, 3)
296        #
297        # self.player_sprite.draw_hit_box(arcade.color.RED, 3)
298
299    def process_keychange(self):
300        """
301        Called when we change a key up/down or we move on/off a ladder.
302        """
303        # Process up/down
304        if self.up_pressed and not self.down_pressed:
305            if self.physics_engine.is_on_ladder():
306                self.player_sprite.change_y = PLAYER_MOVEMENT_SPEED
307            elif self.physics_engine.can_jump(y_distance=10) and not self.jump_needs_reset:
308                self.player_sprite.change_y = PLAYER_JUMP_SPEED
309                self.jump_needs_reset = True
310                arcade.play_sound(self.jump_sound)
311        elif self.down_pressed and not self.up_pressed:
312            if self.physics_engine.is_on_ladder():
313                self.player_sprite.change_y = -PLAYER_MOVEMENT_SPEED
314
315        # Process up/down when on a ladder and no movement
316        if self.physics_engine.is_on_ladder():
317            if not self.up_pressed and not self.down_pressed:
318                self.player_sprite.change_y = 0
319            elif self.up_pressed and self.down_pressed:
320                self.player_sprite.change_y = 0
321
322        # Process left/right
323        if self.right_pressed and not self.left_pressed:
324            self.player_sprite.change_x = PLAYER_MOVEMENT_SPEED
325        elif self.left_pressed and not self.right_pressed:
326            self.player_sprite.change_x = -PLAYER_MOVEMENT_SPEED
327        else:
328            self.player_sprite.change_x = 0
329
330    def on_key_press(self, key, modifiers):
331        """Called whenever a key is pressed. """
332
333        if key == arcade.key.UP or key == arcade.key.W:
334            self.up_pressed = True
335        elif key == arcade.key.DOWN or key == arcade.key.S:
336            self.down_pressed = True
337        elif key == arcade.key.LEFT or key == arcade.key.A:
338            self.left_pressed = True
339        elif key == arcade.key.RIGHT or key == arcade.key.D:
340            self.right_pressed = True
341
342        self.process_keychange()
343
344    def on_key_release(self, key, modifiers):
345        """Called when the user releases a key. """
346
347        if key == arcade.key.UP or key == arcade.key.W:
348            self.up_pressed = False
349            self.jump_needs_reset = False
350        elif key == arcade.key.DOWN or key == arcade.key.S:
351            self.down_pressed = False
352        elif key == arcade.key.LEFT or key == arcade.key.A:
353            self.left_pressed = False
354        elif key == arcade.key.RIGHT or key == arcade.key.D:
355            self.right_pressed = False
356
357        self.process_keychange()
358
359    def on_update(self, delta_time):
360        """ Movement and game logic """
361
362        # Move the player with the physics engine
363        self.physics_engine.update()
364
365        # Update animations
366        if self.physics_engine.can_jump():
367            self.player_sprite.can_jump = False
368        else:
369            self.player_sprite.can_jump = True
370
371        if self.physics_engine.is_on_ladder() and not self.physics_engine.can_jump():
372            self.player_sprite.is_on_ladder = True
373            self.process_keychange()
374        else:
375            self.player_sprite.is_on_ladder = False
376            self.process_keychange()
377
378        self.coin_list.update_animation(delta_time)
379        self.background_list.update_animation(delta_time)
380        self.player_list.update_animation(delta_time)
381
382        # Update walls, used with moving platforms
383        self.wall_list.update()
384
385        # See if the moving wall hit a boundary and needs to reverse direction.
386        for wall in self.wall_list:
387
388            if wall.boundary_right and wall.right > wall.boundary_right and wall.change_x > 0:
389                wall.change_x *= -1
390            if wall.boundary_left and wall.left < wall.boundary_left and wall.change_x < 0:
391                wall.change_x *= -1
392            if wall.boundary_top and wall.top > wall.boundary_top and wall.change_y > 0:
393                wall.change_y *= -1
394            if wall.boundary_bottom and wall.bottom < wall.boundary_bottom and wall.change_y < 0:
395                wall.change_y *= -1
396
397        # See if we hit any coins
398        coin_hit_list = arcade.check_for_collision_with_list(self.player_sprite,
399                                                             self.coin_list)
400
401        # Loop through each coin we hit (if any) and remove it
402        for coin in coin_hit_list:
403
404            # Figure out how many points this coin is worth
405            if 'Points' not in coin.properties:
406                print("Warning, collected a coin without a Points property.")
407            else:
408                points = int(coin.properties['Points'])
409                self.score += points
410
411            # Remove the coin
412            coin.remove_from_sprite_lists()
413            arcade.play_sound(self.collect_coin_sound)
414
415        # Track if we need to change the viewport
416        changed_viewport = False
417
418        # --- Manage Scrolling ---
419
420        # Scroll left
421        left_boundary = self.view_left + LEFT_VIEWPORT_MARGIN
422        if self.player_sprite.left < left_boundary:
423            self.view_left -= left_boundary - self.player_sprite.left
424            changed_viewport = True
425
426        # Scroll right
427        right_boundary = self.view_left + SCREEN_WIDTH - RIGHT_VIEWPORT_MARGIN
428        if self.player_sprite.right > right_boundary:
429            self.view_left += self.player_sprite.right - right_boundary
430            changed_viewport = True
431
432        # Scroll up
433        top_boundary = self.view_bottom + SCREEN_HEIGHT - TOP_VIEWPORT_MARGIN
434        if self.player_sprite.top > top_boundary:
435            self.view_bottom += self.player_sprite.top - top_boundary
436            changed_viewport = True
437
438        # Scroll down
439        bottom_boundary = self.view_bottom + BOTTOM_VIEWPORT_MARGIN
440        if self.player_sprite.bottom < bottom_boundary:
441            self.view_bottom -= bottom_boundary - self.player_sprite.bottom
442            changed_viewport = True
443
444        if changed_viewport:
445            # Only scroll to integers. Otherwise we end up with pixels that
446            # don't line up on the screen
447            self.view_bottom = int(self.view_bottom)
448            self.view_left = int(self.view_left)
449
450            # Do the scrolling
451            arcade.set_viewport(self.view_left,
452                                SCREEN_WIDTH + self.view_left,
453                                self.view_bottom,
454                                SCREEN_HEIGHT + self.view_bottom)
455
456
457def main():
458    """ Main method """
459    window = MyGame()
460    window.setup()
461    arcade.run()
462
463
464if __name__ == "__main__":
465    main()