beatstreets

The game screen appears here if your browser supports the Canvas API.

Attribution

Code the Classics – Volume 2.

Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.

Original Python code


# Beat Streets - Code the Classics Volume 2
# Code by Andrew Gillett and Eben Upton
# Graphics by Dan Malone
# Music and sound effects by Allister Brimble
# https://github.com/raspberrypipress/Code-the-Classics-Vol2.git
# https://store.rpipress.cc/products/code-the-classics-volume-ii

# If the game window doesn't fit on the screen, you may need to turn off or reduce display scaling in the Windows/macOS settings
# On Windows, you can uncomment the following two lines to fix the issue. It sets the program as "DPI aware"
# meaning that display scaling won't be applied to it.
#import ctypes
#ctypes.windll.user32.SetProcessDPIAware()

import pgzero, pgzrun, pygame, sys, json, time
from abc import ABC, abstractmethod
from enum import Enum
from random import randint, choice
from pygame import Vector2, mixer

HEALTH_STAMINA_BAR_WIDTH = 235
HEALTH_STAMINA_BAR_HEIGHT = 26

INTRO_ENABLED = True

FLYING_KICK_VEL_X = 3
FLYING_KICK_VEL_Y = -8

JUMP_GRAVITY = 0.4
THROWN_GRAVITY = 0.025
WEAPON_GRAVITY = 0.5

BARREL_THROW_VEL_X = 4
BARREL_THROW_VEL_Y = 0

# For when player is thrown by boss
PLAYER_THROW_VEL_X = 5
PLAYER_THROW_VEL_Y = 0.5

# By default, the effect of an attack on the opponent's stamina is damage * 100
# Some attacks have an additional stamina damage multiplier
BASE_STAMINA_DAMAGE_MULTIPLIER = 100

# If stamina goes below zero, player can be knocked over more easily and minimum interval between attacks
# is longer
MIN_STAMINA = -100

DEBUG_LOGGING_ENABLED = False
DEBUG_SHOW_SCROLL_POS = False
DEBUG_SHOW_BOUNDARY = False
DEBUG_SHOW_ATTACKS = False
DEBUG_SHOW_TARGET_POS = False
DEBUG_SHOW_ANCHOR_POINTS = False
DEBUG_SHOW_HIT_AREA_WIDTH = False
DEBUG_SHOW_LOGS = False
DEBUG_SHOW_HEALTH_AND_STAMINA = False
DEBUG_PROFILING = False

# These symbols substitute for the controller button images when displaying text.
# The symbols representing these images must be ones that aren't actually used themselves, e.g. we don't use the
# percent sign in text
SPECIAL_FONT_SYMBOLS = {'xb_a':'%'}

# Create a version of SPECIAL_FONT_SYMBOLS where the keys and values are swapped
SPECIAL_FONT_SYMBOLS_INVERSE = dict((v,k) for k,v in SPECIAL_FONT_SYMBOLS.items())

debug_drawcalls = []

# Class for measuring how long code takes to run
class Profiler:
    def __init__(self, name=""):
        self.start_time = time.perf_counter()
        self.name = name

    def get_ms(self):
        endTime = time.perf_counter()
        diff = endTime - self.start_time
        return diff * 1000

    def __str__(self):
        return f"{self.name}: {self.get_ms()}ms"

# Check Python version number. sys.version_info gives version as a tuple, e.g. if (3,7,2,'final',0) for version 3.7.2.
# Unlike many languages, Python can compare two tuples in the same way that you can compare numbers.
if sys.version_info < (3,6):
    print("This game requires at least version 3.6 of Python. Please download it from www.python.org")
    sys.exit()

# Check Pygame Zero version. This is a bit trickier because Pygame Zero only lets us get its version number as a string.
# So we have to split the string into a list, using '.' as the character to split on. We convert each element of the
# version number into an integer - but only if the string contains numbers and nothing else, because it's possible for
# a component of the version to contain letters as well as numbers (e.g. '2.0.dev0')
# This uses a Python feature called list comprehension
pgzero_version = [int(s) if s.isnumeric() else s for s in pgzero.__version__.split('.')]
if pgzero_version < [1,2]:
    print(f"This game requires at least version 1.2 of Pygame Zero. You have version {pgzero.__version__}. Please upgrade using the command 'pip3 install --upgrade pgzero'")
    sys.exit()

WIDTH = 800
HEIGHT = 480
TITLE = "Beat Streets"

MIN_WALK_Y = 310

ENEMY_APPROACH_PLAYER_DISTANCE = 85
ENEMY_APPROACH_PLAYER_DISTANCE_SCOOTERBOY = 140
ENEMY_APPROACH_PLAYER_DISTANCE_BARREL = 180

ANCHOR_CENTRE = ("center", "center")
ANCHOR_CENTRE_BOTTOM = ("center", "bottom")

BACKGROUND_TILE_SPACING = 290

# 1st row of TILE_DEMO+_3.png
BACKGROUND_TILES = ["wall_end1", "wall_fill1", "wall_fill5", "wall_fill2", "alley1", "wall_end6", "wall_fill7",
                    "wall_fill5", "alley2", "wall_end3", "wall_fill3", "wall_fill4", "wall_fill8", "alley5",
                    "wall_end2", "alley3", "wall_end4", "wall_fill6",
#row 2
                    "alley6", "wall_end8", "wall_fill4", "alley7", "wall_end5", "alley8", "set_pc_a1", "set_pc_a2",
                    "alley9", "set_pc_b1", "set_pc_b2", "set_pc_b3", "wall_end3", "wall_fill3", "alley8", "set_pc_a1",
                    "set_pc_a2", "wall_fill2",
#3
                    "con_start2", "con_end1a", "con_end2", "con_start2", "con_end1", "con_fill1", "con_end2a",
                    "con_start2", "con_end1a", "con_fill1a", "con_end2", "set_pc_c1", "set_pc_c2", "set_pc_c3",
                    "con_start1", "con_end1", "con_fill1", "con_fill2", "con_fill1a", "con_fill2a",
#4
                    "wall_end1", "alley10", "steps_end1a", "steps_fill1a", "steps_fill2a", "steps_end2a",
                    "flats_alley1", "steps_end1", "steps_end2", "flats_alley1",  "flats_end1a", "steps_fill2",
                    "steps_fill1", "flats_end2a", "flats_alley2", "set_pc_d1", "set_pc_d2", "set_pc_d3", "steps_end2a"]

fullscreen_black_bmp = pygame.Surface((WIDTH, HEIGHT))
fullscreen_black_bmp.fill((0, 0, 0))


# Utility functions

def clamp(value, min_val, max_val):
    # Clamp a value within a given range
    return min(max(value, min_val), max_val)

def remap(old_val, old_min, old_max, new_min, new_max):
    # todo explain
    return (new_max - new_min)*(old_val - old_min) / (old_max - old_min) + new_min

def remap_clamp(old_val, old_min, old_max, new_min, new_max):
    # todo explain
    # These first two lines are in case new_min and new_max are inverted
    lower_limit = min(new_min, new_max)
    upper_limit = max(new_min, new_max)
    return min(upper_limit, max(lower_limit, remap(old_val, old_min, old_max, new_min, new_max)))

def sign(x):
    # Returns 1, 0 or -1 depending on whether number is positive, zero or negative
    if x == 0:
        return 0
    else:
        return -1 if x < 0 else 1

def move_towards(n, target, speed):
    # Returns new value, and the direction of travel (-1, 0 or 1)
    if n < target:
        return min(n + speed, target), 1
    elif n > target:
        return max(n - speed, target), -1
    else:
        return n, 0

# ABC = abstract base class - a class which is only there to serve as a base class, not to be instantiated directly
class Controls(ABC):
    NUM_BUTTONS = 4

    def __init__(self):
        self.button_previously_down = [False for i in range(Controls.NUM_BUTTONS)]
        self.is_button_pressed = [False for i in range(Controls.NUM_BUTTONS)]

    def update(self):
        # Call each frame to update button status
        for button in range(Controls.NUM_BUTTONS):
            button_down = self.button_down(button)
            self.is_button_pressed[button] = button_down and not self.button_previously_down[button]
            self.button_previously_down[button] = button_down

    @abstractmethod
    def get_x(self):
        # Overridden by subclasses
        pass

    @abstractmethod
    def get_y(self):
        # Overridden by subclasses
        pass

    @abstractmethod
    def button_down(self, button):
        # Overridden by subclasses
        pass

    def button_pressed(self, button):
        return self.is_button_pressed[button]

class KeyboardControls(Controls):
    def get_x(self):
        if keyboard.left:
            return -1
        elif keyboard.right:
            return 1
        else:
            return 0

    def get_y(self):
        if keyboard.up:
            return -1
        elif keyboard.down:
            return 1
        else:
            return 0

    def button_down(self, button):
        if button == 0:
            return keyboard.space or keyboard.z or keyboard.lctrl   # punch
        elif button == 1:
            return keyboard.x or keyboard.lalt      # kick
        elif button == 2:
            return keyboard.c or keyboard.lshift    # elbow
        elif button == 3:
            return keyboard.a   # flying kick

class JoystickControls(Controls):
    def __init__(self, joystick):
        super().__init__()
        self.joystick = joystick
        joystick.init() # Not necessary in Pygame 2.0.0 onwards

    def get_axis(self, axis_num):
        if self.joystick.get_numhats() > 0 and self.joystick.get_hat(0)[axis_num] != 0:
            # For some reason, dpad up/down are inverted when getting inputs from
            # an Xbox controller, so need to negate the value if axis_num is 1
            return self.joystick.get_hat(0)[axis_num] * (-1 if axis_num == 1 else 1)

        axis_value = self.joystick.get_axis(axis_num)
        if abs(axis_value) < 0.6:
            # Dead-zone
            return 0
        else:
            # digital movement
            return 1 if axis_value > 0 else -1

    def get_x(self):
        return self.get_axis(0)

    def get_y(self):
        return self.get_axis(1)

    def button_down(self, button):
        # Before checking button, check to make sure that the controller actually has enough buttons
        # There are some weird devices out there which could cause a crash if this check were not present
        if self.joystick.get_numbuttons() <= button:
            print("Warning: main controller does not have enough buttons!")
            return False
        return self.joystick.get_button(button) != 0

class Attack:
    def __init__(self, sprite=None, strength=None, anim_time=None, frame_time=5, frames=0, hit_frames=(),
                 recovery_time=0, reach=80, throw=False, grab=False, combo_next=None, flyingkick=False,
                 stamina_cost=10, rear_attack=False, stamina_damage_multiplier=1, stun_time_multiplier=1, initial_sound=None, hit_sound=None):
        # Some data for attacks loaded from attacks.json must be modified to be in the format the game expects
        # For example, the keys in combo_next should be integers, but are strings in the json file as JSON only allows
        # string keys.
        if combo_next is not None:
            combo_next = {int(key):value for (key,value) in combo_next.items()}

        self.sprite = sprite
        self.strength = strength
        self.recovery_time = recovery_time  # Can't attack for this many frames after attack animation finishes
        self.anim_time = anim_time      # Frames for which animation plays, this allows us to stay on the last frame longer than previous frames
        self.frame_time = frame_time    # Frames for which each animation frame plays
        self.frames = frames            # Number of frames in animation
        self.hit_frames = hit_frames    # frames on which an opponent can be hit by this attack
        self.reach = reach              # Opponent must be closer than this for attack to hit
        self.throw = throw              # Is this an attack where we throw something, such as a barrel or the player?
        self.grab = grab                # Is this the attack where the boss grabs the player and throws him?
        self.combo_next = combo_next
        self.flying_kick = flyingkick
        self.stamina_cost = stamina_cost
        self.rear_attack = rear_attack
        self.stamina_damage_multiplier = stamina_damage_multiplier  # Does this attack do additional damage to the opponent's stamina?
        self.stun_time_multiplier = stun_time_multiplier
        self.initial_sound = initial_sound
        self.hit_sound = hit_sound

# Load attack data from file
with open("attacks.json") as attacks_file:
    ATTACKS = json.load(attacks_file)
    for key, value in ATTACKS.items():
        # Turn values in the dictionary into constructor parameters of the Attack class
        ATTACKS[key] = Attack(**value)

# The ScrollHeightActor class extends Pygame Zero's Actor class by providing the attribute 'vpos', which stores the object's
# current position using Pygame's Vector2 class. All code should change or read the position via vpos, as opposed to
# Actor's x/y or pos attributes. When the object is drawn, we set self.pos (equivalent to setting both self.x and
# self.y) based on vpos, but taking scrolling into account.
# It also includes the attribute height_above_ground which allows an actor to be considered to be in the air. This
# should be taken into account when determining draw order, as a fighter who is jumping will be further up the screen
# on the Y axis than if they were on the ground, but it's their Y position in relation to the ground which should
# determine whether they're drawn behind or in front of other actors. todo reword
class ScrollHeightActor(Actor):
    def __init__(self, img, pos, anchor=None, separate_shadow=False):
        super().__init__(img, pos, anchor=anchor)
        self.vpos = Vector2(pos)
        self.height_above_ground = 0
        if separate_shadow:
            self.shadow_actor = Actor("blank", pos, anchor=anchor)
        else:
            self.shadow_actor = None

    # We draw with the supplied Vector2 offset to enable scrolling
    def draw(self, offset):
        # Draw shadow first, if we are using a separate shadow sprite (most have the shadow as part of the sprite
        # but for player it is separate)
        if self.shadow_actor is not None:
            self.shadow_actor.pos = (self.vpos.x - offset.x, self.vpos.y - offset.y)
            self.shadow_actor.image = "blank" if self.image == "blank" else self.image + "_shadow"
            self.shadow_actor.draw()

        # Set Actor's screen pos
        self.pos = (self.vpos.x - offset.x, self.vpos.y - offset.y - self.height_above_ground)
        super().draw()
        if DEBUG_SHOW_ANCHOR_POINTS:
            screen.draw.circle(self.pos, 5, (255,255,255))

    def on_screen(self):
        # Use self.x rather than self.vpos.x to get actual screen position rather than world position
        # Note that self.x only updates when the actor is drawn, so if vpos.x is updated during update causing the
        # actor to move off-screen, the value returned by this method will not update until the following frame
        return 0 < self.x < WIDTH

    def get_draw_order_offset(self):
        # See Player and Stick classes for explanation
        return 0

# Inherits from both ScrollActor and ABC (abstract base class)
class Fighter(ScrollHeightActor, ABC):
    WEAPON_HOLD_HEIGHT = 100

    class FallingState(Enum):
        STANDING = 0
        FALLING = 1
        GETTING_UP = 2
        GRABBED = 3
        THROWN = 4

    def log(self, str):
        if DEBUG_LOGGING_ENABLED:
            l = f"{game.timer} {str} {self.vpos}"
            print(self, l)
            self.logs.append(l)

    def __init__(self, pos, anchor, speed, sprite, health, anim_update_rate=8, stamina=500, half_hit_area=Vector2(25, 20), lives=1, colour_variant=None, separate_shadow=False, hit_sound=None):
        super().__init__("blank", pos, anchor, separate_shadow=separate_shadow)

        # Speed is a Vector2 containing x and y speed
        self.speed = speed

        # e.g. "hero" or "enemy"
        self.sprite = sprite

        self.anim_update_rate = anim_update_rate

        self.facing_x = 1

        # Updates each game frame, then is translated into an animation frame number depending on the animation
        # being played
        self.frame = 0

        # Last attack is current attack if attack_timer is above zero
        self.last_attack = None

        # Above zero = currently attacking, zero or below = time since last attack
        self.attack_timer = 0

        # Are we knocked down or in the process of being knocked down?
        self.falling_state = Fighter.FallingState.STANDING

        # Are we currently walking? Used to determine whether to use standing or walking animation
        self.walking = False

        self.vel = Vector2(0, 0)  # Velocity X used when falling or being pushed backwards or for flying kick, velocity Y for jumping

        self.pickup_animation = None

        self.hit_timer = 0      # if above 0, we've just been hit and are doing the animation where we recoil from that
        self.hit_frame = 0

        self.stamina = stamina
        self.max_stamina = stamina

        # Determines whether an opponent's attack will hit us, based on the distance between us and the attack's reach
        # Larger number for the portal, because the portal is physically bigger
        self.half_hit_area = half_hit_area

        self.health = health
        self.start_health = health
        self.lives = lives

        # Used for enemies with multiple colour variants - appended to sprite name
        self.colour_variant = colour_variant

        self.hit_sound = hit_sound

        self.weapon = None

        # Used for animation where Scooterboy enemy is knocked off his scooter
        self.just_knocked_off_scooter = False

        self.use_die_animation = False

        self.logs = []

    def update(self):
        self.attack_timer -= 1

        # Apply gravity and velocity if in air
        if self.height_above_ground > 0 or self.vel.y != 0:
            self.vpos.x += self.vel.x
            self.vel.y += THROWN_GRAVITY if self.falling_state == Fighter.FallingState.THROWN else JUMP_GRAVITY
            self.height_above_ground -= self.vel.y
            self.apply_movement_boundaries(self.vel.x, 0)
            if self.height_above_ground < 0:
                self.height_above_ground = 0
                self.vel.x = 0
                self.vel.y = 0

                # Don't do the been hit animation after landing (from flying kick or from being thrown)
                self.hit_timer = 0

        # Update logic and animation based on current situation - falling, getting up, hit, pickup animation, or normal
        # walking/standing/attacking

        # Check for falling and dying
        # Portals don't fall when they die, so the logic for them dying is within their class
        if self.falling_state == Fighter.FallingState.FALLING:
            # Get pushed backwards
            self.vpos.x += self.vel.x
            self.vel.x, _ = move_towards(self.vel.x, 0, 0.5)

            self.apply_movement_boundaries(self.vel.x, 0)

            self.frame += 1

            if self.frame > 120:
                # If we're not yet out of health, get up and reset stamina
                if self.health > 0:
                    self.falling_state = Fighter.FallingState.GETTING_UP
                    self.frame = 0
                    self.stamina = self.max_stamina
                else:
                    # If we're out of health, flash on and off for a short while, then lose a life
                    if self.frame > 240:
                        self.lives -= 1

                        # If we still have lives left, get up
                        if self.lives > 0:
                            self.health = self.start_health
                            self.falling_state = Fighter.FallingState.GETTING_UP
                            self.frame = 0
                            self.stamina = self.max_stamina
                            self.use_die_animation = False
                        else:
                            self.died()

        elif self.falling_state == Fighter.FallingState.GETTING_UP:
            self.frame += 1
            self.vpos.x += 0.1 * self.facing_x     # Move forward slightly as we get up
            if self.frame > 20:
                self.falling_state = Fighter.FallingState.STANDING
                self.frame = 0

        elif self.falling_state == Fighter.FallingState.THROWN:
            self.frame += 1
            if self.height_above_ground <= 0:
                self.falling_state = Fighter.FallingState.FALLING
                self.frame = 80

        elif self.hit_timer > 0:
            # Playing the 'hit' animation, briefly stunned
            self.hit_timer -= 1

        elif self.pickup_animation is not None:
            # Doing animation for picking up a weapon
            self.frame += 1
            if self.frame > 30:
                self.pickup_animation = None

        elif self.override_walking():
            # If this is the case, we're in some kind of special state, managed by a subclass, which means we shouldn't
            # do the usual walking/attacking behaviour below - e.g. Scooterboy riding scooter
            pass

        elif self.falling_state == Fighter.FallingState.STANDING:
            # Standing, walking or attacking

            # Recover stamina over time
            if self.stamina < self.max_stamina:
                self.stamina += 1

            # Update position of held weapon
            # The weapon actor is invisible while being held as we switch to a different fighter sprite using the
            # weapon, but we update weapon pos so that if we drop the weapon, it reappears as a distinct sprite in the
            # correct location
            if self.weapon is not None:
                self.weapon.vpos = self.vpos + Vector2(self.facing_x * 20, 0)

            # Are we ready to attack or pick up/drop a weapon?
            # If we're out of stamina, recovery time will be longer
            last_attack_recovery_time = 0 if not self.last_attack else self.last_attack.recovery_time
            if self.stamina <= 0:
                last_attack_recovery_time *= 3
            if self.attack_timer <= -last_attack_recovery_time:
                # Not currently attacking, do we want to start attacking?

                # Before deciding if we want to attack - do we instead want to pick up or drop a weapon?
                if self.weapon is None:
                    # Find weapons within reach
                    nearby_weapons = [weapon for weapon in game.weapons if (weapon.vpos - self.vpos).length() < 50]
                    if len(nearby_weapons) > 0:
                        if self.determine_pick_up_weapon():
                            # Sort nearby weapons by distance. length_squared is used to order them instead of
                            # length as it is more efficient
                            nearby_weapons.sort(key=lambda weapon: (weapon.vpos - self.vpos).length_squared())
                            for weapon in nearby_weapons:
                                if weapon.can_be_picked_up():
                                    self.pickup_animation = weapon.name
                                    self.frame = 0
                                    self.weapon = weapon
                                    weapon.pick_up(Fighter.WEAPON_HOLD_HEIGHT)
                                    break
                else:
                    # Drop weapon?
                    if self.determine_drop_weapon():
                        self.drop_weapon()

                # Attack? Only allow if we didn't just start picking up a weapon!
                if self.pickup_animation is None:
                    attack = self.determine_attack()
                    if attack is not None:
                        self.log("Attack " + attack.sprite)
                        self.last_attack = attack
                        self.attack_timer = attack.anim_time
                        self.stamina -= attack.stamina_cost
                        self.stamina = max(self.stamina, MIN_STAMINA)
                        self.frame = 0

                        if attack.initial_sound is not None:
                            # * = unpack the elements of the tuple (sound to play, and number of variations) into
                            # arguments to pass to play_sound
                            game.play_sound(*attack.initial_sound)

                        # Is this a flying kick?
                        if attack.flying_kick:
                            self.vel.x = FLYING_KICK_VEL_X * self.facing_x
                            self.vel.y = FLYING_KICK_VEL_Y

                        # Grab player?
                        if attack.grab:
                            game.player.grabbed()

            # Update movement and animation, and pick up a weapon if desired
            # Must check attack_timer again as an attack may only just have started during the previous block of code
            if self.attack_timer <= 0:
                # Not attacking
                # Update facing_x. If get_desired_facing returns None, leave facing_x as it is
                desired_facing = self.get_desired_facing()
                if desired_facing is not None:
                    self.facing_x = desired_facing

                target = self.get_move_target()
                if target != self.vpos:
                    self.walking = True

                    self.vpos.x, dx = move_towards(self.vpos.x, target.x, self.speed.x)
                    self.vpos.y, dy = move_towards(self.vpos.y, target.y, self.speed.y)

                    self.apply_movement_boundaries(dx, dy)

                    self.frame += 1

                else:
                    # No movement, reset frame to standing
                    self.walking = False
                    self.frame = 7  # Resetting frame to 7 rather than zero fixes an issue where it looks weird if you only walk for a fraction of a second
            else:
                # Currently attacking
                self.frame += 1

                frame = self.get_attack_frame()

                # If current frame of attack is a hit frame, inflict damage to enemies
                if frame in self.last_attack.hit_frames:
                    # Is this a throw attack?
                    if self.last_attack.throw:
                        # If the current attack is a grab attack, that means we're the boss throwing the player
                        if self.last_attack.grab:
                            # Throw the player, if we haven't already done that on a previous frame
                            if game.player.falling_state == Fighter.FallingState.GRABBED:
                                game.player.hit(self, self.last_attack)
                                game.player.thrown(self.facing_x)

                        # Otherwise it's a normal throw of a barrel - make sure we still have the weapon, might have
                        # released it on a previous frame!
                        elif self.weapon is not None:
                            self.weapon.throw(self.facing_x, self)
                            self.weapon = None

                    # Call attack regardless of whether this is a throw attack, this fixes an issue where the barrel
                    # doesn't hit opponents because the position it's in when it's released is already past them
                    self.attack(self.last_attack)

    def attack(self, attack):
        # See if there is an opponent directly in front of us who we can hit (or behind us if it's a rear attack
        # such as elbow)
        if attack.strength > 0:
            # Loop through all opponents to see which (if any) this attack should hit
            for opponent in self.get_opponents():
                vec = opponent.vpos - self.vpos
                facing_correct = sign(self.facing_x) == sign(vec.x)
                if attack.rear_attack:
                    facing_correct = not facing_correct

                # Should attack hit this opponent?
                if abs(vec.y) < opponent.half_hit_area.y and facing_correct and abs(vec.x) < attack.reach + opponent.half_hit_area.x:
                    opponent.hit(self, attack)

                    # If we're using a weapon, it may have broken as a result of being used
                    if self.weapon is not None and self.weapon.is_broken():
                        self.drop_weapon()

            if DEBUG_SHOW_ATTACKS:
                attack_facing = self.facing_x * (-1 if attack.rear_attack else 1)
                debug_rect = Rect(self.x - (attack.reach if attack_facing == -1 else 0), self.y - 5, attack.reach, 10)
                debug_drawcalls.append(lambda: screen.draw.filled_rect(debug_rect, (255, 0, 0)))

    def hit(self, hitter, attack):
        # Hitter can be another fighter, or a weapon such as a barrel
        # Can't be hit if we're falling/getting up
        if self.falling_state == Fighter.FallingState.STANDING or self.falling_state == Fighter.FallingState.GRABBED:
            # Can't be hit if we're already in the hit animation
            if self.hit_timer <= 0:
                self.stamina -= attack.strength * BASE_STAMINA_DAMAGE_MULTIPLIER * attack.stamina_damage_multiplier
                self.stamina = max(self.stamina, MIN_STAMINA)
                self.health -= attack.strength

                # Hit timer ensures we can't receive damage again until it's counted down, and stuns the fighter
                # Stronger attacks stun for longer
                self.hit_timer = attack.strength * 8 * attack.stun_time_multiplier
                self.hit_frame = randint(0, 1)

                # Stop our attack if we're in the middle of one - unless it's a flying kick, in which case continue.
                # Code elsewhere will ensure we don't do the 'been hit' animation at the end of a flying kick
                if self.attack_timer > 0 and (self.last_attack is not None and not self.last_attack.flying_kick):
                    self.attack_timer = 0

                # Drop weapon
                if self.weapon is not None:
                    self.drop_weapon()

                if attack.hit_sound is not None:
                    # * = unpack the elements of the tuple (sound to play, and number of variations) into
                    # arguments to pass to play_sound
                    game.play_sound(*attack.hit_sound)

                if self.hit_sound is not None:
                    # Sound for me being hit (only used by portal)
                    game.play_sound(self.hit_sound)

                # Check for being knocked down due to being out of health or stamina
                # Portals can't fall
                if (self.stamina <= 0 or self.health <= 0) and not isinstance(self, EnemyPortal):
                    self.falling_state = Fighter.FallingState.FALLING
                    self.frame = 0
                    self.hit_timer = 0

                    # If we're knocked down due to being out of stamina, and we're close to death, just die already
                    if self.health < 3:
                        self.health = 0
                        self.use_die_animation = (randint(0,1) == 0)    # Use die animation 50% of the time

                # If the attacker was using a weapon, tell the weapon that it was used
                # Must check that hitter is a Fighter, as it might be a barrel!
                if isinstance(hitter, Fighter) and hitter.weapon is not None:
                    hitter.weapon.used()

            # Always face towards hitter
            # First check to make sure that hitter and I aren't at the same X position
            if hitter.vpos.x != self.vpos.x:
                self.facing_x = sign(hitter.vpos.x - self.vpos.x)

                if self.falling_state == Fighter.FallingState.FALLING and not self.use_die_animation:
                    # Get knocked backwards
                    self.vel.x += -self.facing_x * 10

    def died(self):
        # Called when out of lives, can be overridden in cases where subclasses need to know that - e.g.
        # EnemyHoodie may drop stick on death
        pass

    def draw(self, offset):
        # Determine sprite to use based on our current action
        self.image = self.determine_sprite()

        super().draw(offset)

        if DEBUG_SHOW_HEALTH_AND_STAMINA:
            text = f"HP: {self.health}\nSTM: {self.stamina}"
            screen.draw.text(text, fontsize=24, center=(self.x, self.y - 200), color="#FFFFFF", align="center")

        if DEBUG_SHOW_HIT_AREA_WIDTH:
            screen.draw.rect(Rect(self.x - self.half_hit_area.x, self.y - self.half_hit_area.y, self.half_hit_area.x * 2, self.half_hit_area.y * 2), (255,255,255))

        if DEBUG_SHOW_LOGS:
            y = self.y
            for l in reversed(self.logs):
                screen.draw.text(l, fontsize=14, center=(self.x, y), color="#FFFFFF", align="center")
                y += 10

    def determine_sprite(self):
        show = True

        if self.falling_state == Fighter.FallingState.FALLING:
            if self.frame > 60 and self.health <= 0 and (self.frame // 10) % 2 == 0:
                # If we're out of health, flash on and off for a short while
                show = False

            if show:
                # When we fall down, we stay on the last frame (2) for an extended period
                # If we've only just fallen off a scooter, play knocked_off frame 0 before
                # continuing from knockdown frame 1
                if self.just_knocked_off_scooter:
                    # Check if we need to transition to the knockdown stage of the animation
                    if self.frame > 10:
                        self.just_knocked_off_scooter = False

                        # Create the scooter as an independent object
                        game.scooters.append(Scooter(self.vpos, self.facing_x, self.colour_variant))

                # Now choose the sprite to use this frame
                if self.just_knocked_off_scooter:
                    anim_type = "knocked_off"
                    frame = 0
                elif self.use_die_animation:
                    anim_type = "die"
                    frame = min(self.frame // 20, 2)
                else:
                    last_frame = 3 if isinstance(self, EnemyScooterboy) else 2
                    anim_type = "knockdown"
                    frame = min(self.frame // 10, last_frame)

        elif self.falling_state == Fighter.FallingState.GETTING_UP:
            anim_type = "getup"
            frame = min(self.frame // 10, 1)

        elif self.falling_state == Fighter.FallingState.GRABBED:
            show = False

        elif self.falling_state == Fighter.FallingState.THROWN:
            anim_type = "thrown"
            frame = min(self.frame // 12, 3)

        elif self.hit_timer > 0:
            frame = self.hit_frame
            anim_type = "hit"

        elif self.pickup_animation is not None:
            # Doing animation for picking up a weapon
            frame = min(self.frame // 12, self.weapon.end_pickup_frame)
            anim_type = f"pickup_{self.pickup_animation}"

        elif self.attack_timer > 0:
            # Currently attacking
            anim_type = self.last_attack.sprite
            frame = self.get_attack_frame()

        else:
            # Walking or standing
            if self.walking:
                # There are four walk animation frames, we take self.frame (an unbounded number incrementing by 1 each
                # game frame) and divide it by self.anim_update_rate (giving that many frames of delay between
                # switching animation frames), the result of that is MODded 4 to reduce it to the actual animation
                # frame to use in the range 0-3
                anim_type = "walk"
                frame = (self.frame // self.anim_update_rate) % 4  # 4 frames of walking animation
            else:
                # Standing
                frame = 0
                # Use anim_type stand or walk depending on whether we have a weapon - we only have 'walk' sprites
                # for weapons
                anim_type = "walk" if self.weapon is not None else "stand"

            # Add the weapon name to the walking/standing animation
            # This isn't done for weapon attack animations, because barrel is released during the throw animation
            anim_type += ("_" + self.weapon.name) if self.weapon is not None else ""

        if show:
            # In sprite filenames, 0 = facing left, 1 = right
            facing_id = 1 if self.facing_x == 1 else 0
            image = f"{self.sprite}_{anim_type}_{facing_id}_{frame}"
            if self.colour_variant is not None:
                image += "_" + str(self.colour_variant)
        else:
            image = "blank"

        return image

    def get_attack_frame(self):
        # return value of this function is an animation frame, e.g. we are on the third frame of the punch animation
        # self.frame is a game frame, increasing by 1 every 1/60th of a second
        # We use self.last_attack to get the current attack that we're doing, i.e. it's the last attack we started
        # doing, and we're still doing it
        frame = (self.frame // self.last_attack.frame_time)
        frame = min(frame, self.last_attack.frames - 1)
        return frame

    def override_walking(self):
        # Used by subclasses to prevent the usual walking/attacking behaviour
        return False

    def drop_weapon(self):
        self.pickup_animation = None    # Stop pickup animation if we're in the middle of one
        self.weapon.dropped()
        self.weapon = None

    def grabbed(self):
        self.log("Grabbed")
        self.falling_state = Fighter.FallingState.GRABBED
        if self.weapon is not None:
            self.drop_weapon()

    def thrown(self, dir_x):
        self.log("Thrown")
        self.falling_state = Fighter.FallingState.THROWN
        self.vel.x = dir_x * PLAYER_THROW_VEL_X
        self.vel.y = PLAYER_THROW_VEL_Y
        self.facing_x = -dir_x

        # Shift position for throw animation
        self.vpos.x += dir_x * 50
        self.height_above_ground = 45

    def apply_movement_boundaries(self, dx, dy):
        # A fighter outside the boundary can walk in a direction which will help them get inside the boundary, but not
        # in the direction that will take them further out of it
        if dx < 0 and self.vpos.x < game.boundary.left:
            self.vpos.x = game.boundary.left
        elif dx > 0 and self.vpos.x > game.boundary.right:
            self.vpos.x = game.boundary.right
        if dy < 0 and self.vpos.y < game.boundary.top:
            self.vpos.y = game.boundary.top
        elif dy > 0 and self.vpos.y > game.boundary.bottom:
            self.vpos.y = game.boundary.bottom

    # Every class that inherits from Fighter must implement each of the following abstract methods

    @abstractmethod
    def determine_attack(self):
        pass

    @abstractmethod
    def determine_pick_up_weapon(self):
        pass

    @abstractmethod
    def determine_drop_weapon(self):
        pass

    @abstractmethod
    def get_opponents(self):
        pass

    @abstractmethod
    def get_move_target(self):
        pass

    @abstractmethod
    def get_desired_facing(self):
        pass

class Player(Fighter):
    def __init__(self, controls):
        # Anchor point just above bottom of sprite
        super().__init__(pos=(400, 400), anchor=("center",256), speed=Vector2(3,2), sprite="hero", health=30, lives=3, separate_shadow=True)
        self.controls = controls
        self.extra_life_timer = 0

    def update(self):
        super().update()

        self.extra_life_timer -= 1

        # Check for collecting powerups
        for powerup in game.powerups:
            if (powerup.vpos - self.vpos).length() < 30:
                powerup.collect(self)

    def draw(self, offset):
        super().draw(offset)
        # screen.draw.text(f"{self.vpos}", (0,0))
        # screen.draw.text(f"{self.vpos}", self.pos)

    def determine_attack(self):
        # Do we have a weapon?
        if self.weapon is not None:
            # Ensure we cannot attack during the pickup animation
            if self.pickup_animation is None and self.controls.button_pressed(0):
                return ATTACKS[self.weapon.name]

        elif self.controls.button_pressed(0):
            # in combo?
            if self.last_attack is not None and self.last_attack.combo_next is not None and self.attack_timer >= -30:
                # Get next attack in combo
                # 0 here represents button 0, ideally this code should be made more general, but in practice
                # the only combo we actually have is where you press button 0 three times to do a sequence of punches
                # ending in an uppercut
                if 0 in self.last_attack.combo_next:
                    return ATTACKS[self.last_attack.combo_next[0]]

            # Not in combo, just return default attack
            return ATTACKS["punch"]

        elif self.controls.button_pressed(1):
            return choice((ATTACKS["kick"], ATTACKS["highkick"]))

        elif self.controls.button_pressed(2):
            return ATTACKS["elbow"]

        elif self.controls.button_pressed(3):
            return ATTACKS["flyingkick"]

        return None

    def determine_pick_up_weapon(self):
        return self.controls.button_pressed(0)

    def determine_drop_weapon(self):
        return self.weapon is not None and self.controls.button_pressed(1)

    def get_opponents(self):
        return game.enemies

    def get_move_target(self):
        # Our target position is our current position offset based on control inputs and speed
        return self.vpos + Vector2(self.controls.get_x() * self.speed.x, self.controls.get_y() * self.speed.y)

    def get_desired_facing(self):
        dx = self.controls.get_x()
        if dx != 0:
            self.facing_x = sign(dx)
        else:
            # Keep facing same direction as before if no X input
            return self.facing_x

    def get_draw_order_offset(self):
        # Consider player to be in front of another object with the same Y pos
        return 1

    def gain_extra_life(self):
        self.lives += 1
        self.extra_life_timer = 30

class Enemy(Fighter, ABC):
    # State is an inner class - a class within a class, so its name doesn't clash with the global class State
    class State(Enum):
        APPROACH_PLAYER = 0
        GO_TO_POS = 1
        GO_TO_WEAPON = 2
        PAUSE = 3
        KNOCKED_DOWN = 4
        RIDING_SCOOTER = 5
        PORTAL = 6
        PORTAL_EXPLODE = 7

    def __init__(self, pos, name, attacks, start_timer,
                 speed=Vector2(1, 1),
                 health=15,
                 stamina=500,
                 approach_player_distance=ENEMY_APPROACH_PLAYER_DISTANCE,
                 anchor_y=256,
                 half_hit_area=Vector2(25, 20),
                 colour_variant=None,
                 hit_sound=None,
                 score=10):
        # Slower animation speed than Hero
        super().__init__(pos, ("center",anchor_y), speed=speed, sprite=name, health=health, stamina=stamina,
                         anim_update_rate=14, half_hit_area=half_hit_area, colour_variant=colour_variant, hit_sound=hit_sound)

        # Target is a Vector2 instance
        # Must make a copy of the value, not a copy of the reference
        self.target = Vector2(self.vpos)

        self.target_weapon = None

        # Enemies don't try to target player until their start timer drops to zero
        # e.g. on starting a new stage we might not want them to start targeting the player until they have
        # scrolled onto the screen
        self.state = Enemy.State.PAUSE
        self.state_timer = start_timer

        self.attacks = attacks
        self.approach_player_distance = approach_player_distance
        self.score = score

    def spawned(self):
        # Called when the enemy is added into the game (when its stage is reached)
        pass

    def update(self):
        if self.state == Enemy.State.APPROACH_PLAYER:
            player = game.player

            # If player is attacking and we are quite close, chance (each frame) of backing up a little
            if player.attack_timer > 0 \
              and abs(self.vpos.y - player.vpos.y) < 20 \
              and abs(self.vpos.x - player.vpos.x) < 200 \
              and randint(0, 500) == 0:
                self.log("Back away from attack")
                self.target.x = self.vpos.x - self.facing_x * 90
                self.state = Enemy.State.GO_TO_POS
            else:
                # Head towards player
                # If we are holding a barrel, use a larger X offset so we throw from a distance
                if isinstance(self.weapon, Barrel):
                    x_offset = ENEMY_APPROACH_PLAYER_DISTANCE_BARREL
                else:
                    x_offset = self.approach_player_distance
                self.target.x = player.vpos.x + (x_offset * sign(self.vpos.x - player.vpos.x))
                self.target.y = player.vpos.y

        elif self.state == Enemy.State.GO_TO_POS:
            # In this state we just check to see if we've reached the target position, if so we make a new decision
            if self.target == self.vpos:
                self.make_decision()

        elif self.state == Enemy.State.GO_TO_WEAPON:
            if not self.target_weapon.can_be_picked_up() or not self.target_weapon.on_screen():
                # Weapon no longer available, make a new decision
                self.target_weapon = None
                self.make_decision()
            else:
                self.target = Vector2(self.target_weapon.vpos)
                if self.target == self.vpos:
                    # Arrived - pick up weapon and make new decision
                    self.log("Pick up weapon")
                    self.pickup_animation = self.target_weapon.name
                    self.frame = 0
                    self.target_weapon.pick_up(Fighter.WEAPON_HOLD_HEIGHT)
                    self.weapon = self.target_weapon
                    self.target_weapon = None
                    self.make_decision()

        elif self.state == Enemy.State.PAUSE:
            self.state_timer -= 1
            if self.state_timer < 0:
                self.make_decision()

        elif self.state == Enemy.State.KNOCKED_DOWN:
            # Check to see if we've got up again, if so switch state
            if self.falling_state == Fighter.FallingState.STANDING:
                self.make_decision()

        # Update of RIDING_SCOOTER state is in EnemyScooterboy class

        if self.state == Enemy.State.APPROACH_PLAYER \
                or self.state == Enemy.State.GO_TO_POS \
                or self.state == Enemy.State.GO_TO_WEAPON:
            # Ensure that target position is within the level boundary
            self.target.x = max(self.target.x, game.boundary.left)
            self.target.x = min(self.target.x, game.boundary.right)
            self.target.y = max(self.target.y, game.boundary.top)
            self.target.y = min(self.target.y, game.boundary.bottom)

            # Check to see if another enemy is already heading for the new target pos, or one very close to it.
            # If so, make a new decision
            other_enemies_same_target = [enemy for enemy in game.enemies if enemy is not self and
                                         (enemy.target - self.target).length() < 20]
            if len(other_enemies_same_target) > 0:
                self.log("Same target")
                self.make_decision()

        # Call through to Fighter class update
        super().update()

    def draw(self, offset):
        super().draw(offset)

        if DEBUG_SHOW_TARGET_POS:
            screen.draw.line(self.vpos - offset, self.target - offset, (255,255,255))

    def determine_attack(self):
        # Allow attacking if we're in APPROACH_PLAYER state, aligned with player on Y axis, both I and player are
        # standing up, and we're within the right range of distances on the X axis, finally a must pass a random chance
        # check of 1 in 20
        # If we're holding a barrel, can be within any distance on the X axis

        # Unpack player pos into more convenient variables
        px, py = game.player.vpos

        holding_barrel = isinstance(self.weapon, Barrel)

        if self.state == Enemy.State.APPROACH_PLAYER \
               and game.player.falling_state == Fighter.FallingState.STANDING \
               and self.vpos.y == py \
               and (self.approach_player_distance * 0.9 < abs(self.vpos.x - px) <= self.approach_player_distance * 1.1 or
                    holding_barrel) \
               and randint(0,19) == 0:
            if self.weapon is not None:
                return ATTACKS[self.weapon.name]
            else:
                chosen_attack = ATTACKS[choice(self.attacks)]

                # If the chosen attack is a grab, don't allow it if the player is currently doing a flying kick
                if chosen_attack.grab and game.player.last_attack is not None and game.player.last_attack.flying_kick:
                    return None

                return chosen_attack

    def determine_pick_up_weapon(self):
        return False

    def determine_drop_weapon(self):
        return False

    def get_opponents(self):
        return [game.player]

    def get_move_target(self):
        # Move towards player
        # Choose a location to walk to, depending on which side of the player we're on
        # We aim for a position 1 pixel above the player on the Y axis, so that we draw behind them
        # offset_x = 80 if self.vpos.x > game.player.vpos.x else -80
        # return game.player.vpos + Vector2(offset_x, -1)
        if self.target is None:
            # If no target, just return our current position
            return self.vpos
        else:
            #return self.target.get_pos()
            return self.target

    def get_desired_facing(self):
        # Always face towards player, unless we're on a scooter
        if self.state == Enemy.State.RIDING_SCOOTER:
            return self.facing_x
        else:
            return 1 if self.vpos.x < game.player.vpos.x else -1

    def hit(self, hitter, attack):
        if self.state == Enemy.State.KNOCKED_DOWN:
            # Already knocked down
            return

        super().hit(hitter, attack)

        # If we're riding a scooter, then getting hit will always cause us to fall, regardless of stamina
        if self.state == Enemy.State.RIDING_SCOOTER:
            self.falling_state = Fighter.FallingState.FALLING
            self.frame = 0
            self.hit_timer = 0
            self.just_knocked_off_scooter = True

        if self.falling_state == Fighter.FallingState.FALLING:
            # Set state as knocked down
            self.state = Enemy.State.KNOCKED_DOWN
            self.log("Knocked down")

    def make_decision(self):
        player = game.player

        # If we're not going for a weapon:
        # If we're the only enemy, always move in to attack
        if len(game.enemies) == 1:
            self.log("Only enemy, go to player")
            self.state = Enemy.State.APPROACH_PLAYER
        else:
            # 7/10 chance of going directly to a point where we can attack the player, unless there's another enemy
            # already heading there in which case flank
            # 3/10 chance of going to a random point slightly further from the player
            # 1/10 chance of pausing for a short time

            r = randint(0, 9)
            if r < 7:
                # Check to see if another enemy on the same X side of the player is already heading to attack them
                # If so, flank instead
                other_enemies_on_same_side_attacking = [enemy for enemy in game.enemies if enemy is not self
                                                        and enemy.state == Enemy.State.APPROACH_PLAYER
                                                        and sign(enemy.vpos.x - player.vpos.x) == sign(self.vpos.x - player.vpos.x)]
                if len(other_enemies_on_same_side_attacking) > 0:
                    # Go to opposite side of player, at a Y position offset from them but on the same Y side that
                    # we're on now (e.g. if we're below, stay below). If Y pos is same, choose Y side randomly.
                    self.log("Begin flanking (same target)")
                    self.state = Enemy.State.GO_TO_POS
                    self.target.x = player.vpos.x - sign(self.vpos.x - player.vpos.x) * 50
                    self.target.y = player.vpos.y + sign(self.vpos.y - player.vpos.y) * 50
                    if self.target.y == player.vpos.y:
                        self.target.y = player.vpos.y + choice((-1,1)) * 50
                else:
                    # Go to player
                    self.log("Go to player")
                    self.state = Enemy.State.APPROACH_PLAYER

            elif r < 9:
                # Go to a random point at a moderate distance from the player
                # Stick to same half of screen on X axis
                self.log("Go to distance from player")
                x_side = sign(self.vpos.x - player.vpos.x)
                if x_side == 0:
                    x_side = choice((1,-1))
                x1 = int(player.vpos.x + (150 * x_side))
                x2 = int(player.vpos.x + (400 * x_side))
                x = randint(min(x1,x2), max(x1,x2))
                y = randint(game.boundary.top, game.boundary.bottom)
                self.target = Vector2(x, y)
                self.state = Enemy.State.GO_TO_POS

            else:
                # Pause
                self.log("Pause")
                self.state_timer = randint(50, 100)
                self.state = Enemy.State.PAUSE

class EnemyVax(Enemy):
    def __init__(self, pos, start_timer=20):
        super().__init__(pos, "vax", ("vax_lpunch", "vax_rpunch", "vax_pound"), start_timer=start_timer, colour_variant=randint(0,2), score=20)

class EnemyHoodie(Enemy):
    def __init__(self, pos, start_timer=20):
        super().__init__(pos, "hoodie", ("hoodie_lpunch", "hoodie_rpunch", "hoodie_special"), health=12, speed=Vector2(1.2, 1), start_timer=start_timer, colour_variant=randint(0,2), score=20)

    def died(self):
        super().died()

        # Chance of dropping a stick
        if randint(0, 2) == 0:
            game.weapons.append(Stick(self.vpos))

class EnemyScooterboy(Enemy):
    SCOOTER_SPEED_SLOW = 4
    SCOOTER_SPEED_FAST = 12
    SCOOTER_ACCELERATION = 0.2

    def __init__(self, pos, start_timer=20):
        super().__init__(pos, "scooterboy", ("scooterboy_attack1",), start_timer=start_timer, approach_player_distance=ENEMY_APPROACH_PLAYER_DISTANCE_SCOOTERBOY, colour_variant=randint(0,2), score=30)
        self.state = Enemy.State.RIDING_SCOOTER
        self.scooter_speed = EnemyScooterboy.SCOOTER_SPEED_SLOW
        self.scooter_target_speed = self.scooter_speed
        self.scooter_sound_channel = None

    def spawned(self):
        super().spawned()
        try:
            self.scooter_sound_channel = pygame.mixer.find_channel()
            if self.scooter_sound_channel is not None:
                self.scooter_sound_channel.play(game.get_sound("scooter_slow"), loops=-1, fade_ms=200)
        except Exception as e:
            # Don't crash if no sound hardware
            pass

    def make_decision(self):
        # Scooterboy stays on scooter until knocked off
        if self.state != Enemy.State.RIDING_SCOOTER:
            super().make_decision()

    def determine_sprite(self):
        # Riding scooter is a state unique to Scooterboy, so it is dealt with here
        if self.state == Enemy.State.RIDING_SCOOTER:
            facing_id = 1 if self.facing_x == 1 else 0
            frame = 0
            if self.scooter_speed < self.scooter_target_speed:
                # Currently speeding up
                frame = min(self.frame // 5, 2)
            return f"{self.sprite}_ride_{facing_id}_{frame}_{self.colour_variant}"
        else:
            return super().determine_sprite()

    def update(self):
        if self.state == Enemy.State.RIDING_SCOOTER:
            player = game.player

            # Change volume independently on left and right speakers
            if self.scooter_sound_channel is not None:
                left_volume = remap_clamp(abs(self.vpos.x - player.vpos.x + 500), 0, 1000, 1, 0)
                right_volume = remap_clamp(abs(self.vpos.x - player.vpos.x - 500), 0, 1000, 1, 0)
                self.scooter_sound_channel.set_volume(left_volume, right_volume)

            # Currently accelerating/decelerating?
            if self.scooter_speed != self.scooter_target_speed:
                self.scooter_speed, _ = move_towards(self.scooter_speed, self.scooter_target_speed, EnemyScooterboy.SCOOTER_ACCELERATION)
                self.frame += 1
            elif self.on_screen() and randint(0,30) == 0:
                # If on screen, random chance of accelerating
                self.scooter_target_speed = EnemyScooterboy.SCOOTER_SPEED_FAST
                if self.scooter_sound_channel is not None:
                    self.scooter_sound_channel.play(game.get_sound("scooter_accelerate", 6), loops=0, fade_ms=200)
                self.frame = 0

            # Move forward
            self.target.x = self.vpos.x + self.facing_x * self.scooter_speed
            self.vpos.x = self.target.x

            # Turn around if we've gone off the edge of the screen
            # We check self.x which is the actual screen position as opposed to the position in the scrolling level
            if (self.facing_x > 0 and self.x > WIDTH + 200) or (self.facing_x < 0 and self.x < -200):
                self.facing_x = -self.facing_x
                self.target.y = player.vpos.y

                # If player is standing, move to the same Y position as player, otherwise choose a random Y position
                # which is not close to the player Y position (to avoid player getting stunlocked)
                if game.player.falling_state == Fighter.FallingState.STANDING:
                    self.vpos.y = self.target.y
                else:
                    while abs(self.vpos.y - self.target.y) < 40:
                        self.vpos.y = randint(MIN_WALK_Y, HEIGHT-1)

                # Also slow down if at high speed
                self.scooter_target_speed = EnemyScooterboy.SCOOTER_SPEED_SLOW
                self.scooter_speed = self.scooter_target_speed

                # Go back to slow sound
                if self.scooter_sound_channel is not None:
                    self.scooter_sound_channel.play(game.get_sound("scooter_slow"), loops=-1, fade_ms=200)

            # Check to see if we hit the player
            if player.falling_state == Fighter.FallingState.STANDING \
              and abs(player.vpos.y - self.vpos.y) < 30 \
              and abs(self.vpos.x - player.vpos.x) < 60 \
              and player.height_above_ground < 20:
                player.hit(self, ATTACKS["scooter_hit"])

        elif self.just_knocked_off_scooter and self.scooter_sound_channel is not None and self.scooter_sound_channel.get_busy():
            self.scooter_sound_channel.stop()

        super().update()

    def override_walking(self):
        return self.state == Enemy.State.RIDING_SCOOTER

    def died(self):
        super().died()

        # Low chance of dropping a chain
        if randint(0, 19) == 0:
            game.weapons.append(Chain(self.vpos))

        # Stop scooter sound - only needed for when we're skipping stages in debug mode
        if self.scooter_sound_channel is not None and self.scooter_sound_channel.get_busy():
            self.scooter_sound_channel.stop()

class EnemyBoss(Enemy):
    def __init__(self, pos, start_timer=20):
        super().__init__(pos, "boss", ("boss_lpunch", "boss_rpunch", "boss_kick", "boss_grab_player",),
                         speed=Vector2(0.9,0.8), health=25, stamina=1000, start_timer=start_timer, anchor_y=280,
                         half_hit_area=Vector2(30, 20), colour_variant=randint(0,2), score=75)

    def make_decision(self):
        # Boss can pick up a barrel, if they're not currently holding one
        # Look for a barrel we can walk to. Barrel must not be held by anyone else and must be on the screen
        if self.weapon is None:
            available_barrels = [weapon for weapon in game.weapons if isinstance(weapon, Barrel) and weapon.can_be_picked_up() and weapon.on_screen()]
            if len(available_barrels) > 0:
                # Find a weapon to go to
                for weapon in available_barrels:
                    # Don't go to a barrel if another enemy is already going to it
                    other_enemies_same_target = [enemy for enemy in game.enemies if enemy is not self and
                                                 enemy.target_weapon is weapon]
                    if len(other_enemies_same_target) == 0:
                        # This weapon is OK to go for
                        self.log("Go to weapon")
                        self.state = Enemy.State.GO_TO_WEAPON
                        self.target_weapon = weapon
                        return

        # If we didn't enter the GO_TO_WEAPON state, call the parent method
        super().make_decision()

class EnemyPortal(Enemy):
    GENERATE_ANIMATION_FRAMES = 6
    GENERATE_ANIMATION_DIVISOR = 16
    GENERATE_ANIMATION_TIME = GENERATE_ANIMATION_FRAMES * GENERATE_ANIMATION_DIVISOR

    def __init__(self, pos, enemies, spawn_interval, spawn_interval_change=0, max_spawn_interval=600, max_enemies=5, start_timer=90):
        # Hittable area is larger for portals
        super().__init__(pos, "portal", (), start_timer=start_timer, anchor_y=340, half_hit_area=Vector2(50, 50), hit_sound="portal_hit")
        self.enemies = enemies
        self.spawn_interval = spawn_interval
        self.spawn_timer = spawn_interval
        self.spawn_interval_change = spawn_interval_change
        self.max_spawn_interval = max_spawn_interval
        self.max_enemies = max_enemies
        self.spawning_enemy = None
        self.spawn_facing = 0

    def spawned(self):
        super().spawned()
        game.play_sound("portal_appear")

    def make_decision(self):
        # Like all enemies, portals start in the PAUSE state until their start_timer expires
        self.state = Enemy.State.PORTAL

    def determine_sprite(self):
        if self.state == Enemy.State.PAUSE and self.frame // 8 < 4:
            return "portal_grow_" + str(min(self.frame // 8, 3))
        elif self.state == Enemy.State.PORTAL_EXPLODE:
            return "portal_destroyed_" + str(min(self.frame // 6, 7))
        elif self.spawning_enemy is not None:
            # 3 frames of neutral generate animation, then 3 frames of animation for generating specific enemy
            frame = self.frame // EnemyPortal.GENERATE_ANIMATION_DIVISOR
            if frame < 3:
                return "portal_generate_" + str(frame)
            else:
                frame = min(frame - 3, 2)
                return f"portal_generate_{self.spawning_enemy.sprite}_{self.spawn_facing}_{frame}_{self.spawning_enemy.colour_variant}"

        elif self.hit_timer > 0:
            return "portal_hit_0"
        else:
            return "portal_idle_" + str((self.frame // 8) % 8)

    def update(self):
        self.frame += 1

        if self.state == Enemy.State.PORTAL:
            if self.health <= 0:
                self.state = Enemy.State.PORTAL_EXPLODE
                self.frame = 0
                game.play_sound("portal_destroyed")

            else:
                self.spawn_timer -= 1
                if self.spawn_timer <= 0 and self.spawning_enemy is not None:
                    # Animation complete, actually put the enemy in the level
                    game.spawn_enemy(self.spawning_enemy)

                    self.spawning_enemy = None

                    # Reset spawn timer, depending on spawn_interval_change we may spawn less frequently as time goes on
                    self.spawn_interval += self.spawn_interval_change
                    self.spawn_interval = min(self.spawn_interval, self.max_spawn_interval)
                    self.spawn_timer = self.spawn_interval

                elif self.spawning_enemy is None and self.spawn_timer <= EnemyPortal.GENERATE_ANIMATION_TIME:
                    if len(game.enemies) >= self.max_enemies:
                        # Too many enemies to spawn at the moment, try again in one second
                        self.spawn_timer = 60
                    else:
                        # Randomly choose an enemy to spawn from our enemies list
                        chosen_enemy = choice(self.enemies)

                        # Choose direction for spawned enemy to face (0/1 = left/right)
                        self.spawn_facing = 0 if self.vpos.x > game.player.vpos.x else 1

                        # Instantiate the enemy, but it won't appear in the level until the animation is complete
                        self.spawning_enemy = chosen_enemy(self.vpos)

                        # Reset frame for spawning animation
                        self.frame = 0

                        game.play_sound("portal_enemy_spawn")

        elif self.state == Enemy.State.PORTAL_EXPLODE:
            if self.frame > 50:
                self.lives -= 1
                self.died()

        super().update()

    def override_walking(self):
        # A portal never walks
        return True

# This is the scooter on its own, with the rider having been knocked off
class Scooter(ScrollHeightActor):
    def __init__(self, pos, facing_x, colour_variant):
        super().__init__("blank", pos, ("center",256))
        self.facing_x = facing_x
        self.colour_variant = colour_variant
        self.vel_x = -facing_x * 8
        self.frame = 0
        game.play_sound("scooter_fall")

    def update(self):
        self.frame += 1
        self.vpos.x += self.vel_x
        self.vel_x *= 0.94
        facing_id = 1 if self.facing_x > 0 else 0
        self.image = f"scooterboy_bike_{facing_id}_{min(self.frame // 30, 2)}_{self.colour_variant}"

    def get_draw_order_offset(self):
        return -1

class Weapon(ScrollHeightActor):
    def __init__(self, name, sprite, pos, end_pickup_frame, anchor=ANCHOR_CENTRE, bounciness=0, ground_friction=0.5, air_friction=0.996, separate_shadow=False):
        super().__init__(sprite, pos, anchor=anchor, separate_shadow=separate_shadow)
        self.name = name
        self.end_pickup_frame = end_pickup_frame
        self.held = False
        self.vel = Vector2(0,0)
        self.bounciness = bounciness
        self.ground_friction = ground_friction
        self.air_friction = air_friction

    def update(self):
        if not self.held:
            # If not held, check whether we're above the ground, or if we're moving
            if self.height_above_ground > 0 or self.vel.y != 0:
                # Fall to ground
                self.vel.y += WEAPON_GRAVITY
                if self.vel.y > self.height_above_ground:
                    # Bounce if we have bounciness, but stop bouncing if Y velocity is low
                    if self.bounciness > 0 and self.vel.y > 1:
                        # eg bounciness 1, height_above_ground 10, vel y 15, bounce amount should be 5
                        self.height_above_ground = abs(self.height_above_ground - self.vel.y) * self.bounciness
                        self.vel.y = -self.vel.y * self.bounciness
                        #print(f"{self.vel.y=}, {self.height_above_ground=}")
                    else:
                        self.height_above_ground = 0
                        self.vel.y = 0
                else:
                    # Didn't bounce - apply velocity to Y pos
                    self.height_above_ground -= self.vel.y

                assert(self.height_above_ground >= 0)

            self.vpos.x += self.vel.x

            # Friction on X axis, varies depending on whether we're on the ground or in the air
            friction = self.ground_friction if self.height_above_ground == 0 else self.air_friction
            self.vel.x *= friction
            if abs(self.vel.x) < 0.05:
                self.vel.x = 0

    def can_be_picked_up(self):
        return not self.held and self.height_above_ground == 0

    def pick_up(self, hold_height):
        assert(not self.held)
        self.held = True
        self.height_above_ground = hold_height   # for when we are dropped
        self.vel = Vector2(0, 0)
        self.image = "blank"

    def dropped(self):
        # Subclass has the responsibility of setting image to the correct sprite
        assert(self.held)
        self.held = False

    def used(self):
        pass

    def is_broken(self):
        return False

class Barrel(Weapon):
    def __init__(self, pos):
        super().__init__("barrel", "barrel_upright", pos, end_pickup_frame=2, anchor=("center", 190), bounciness=0.75, ground_friction=0.96, separate_shadow=True)
        self.last_thrower = None
        self.frame = 0

    def update(self):
        # Call parent update
        super().update()

        # If moving, look for people to bash into
        # Won't collide if it can be picked up (if it is moving slowly enough)
        if not self.held and not self.can_be_picked_up() and self.vel.x != 0:
            for fighter in [game.player] + game.enemies:
                # Won't collide with the person who threw it
                # Won't collide with a fighter who is falling (incl. lying on the ground)
                # Must be within 30 pixels on X axis
                # Must be within 30 pixels on Y axis (vpos.y doesn't take height above ground into account, so this
                # is effectively the character's 'depth' in the level)
                # Must hit within the height of the character, taking into account height_above_ground for both the
                # barrel and fighter. The fighter may be able to jump over the barrel. The Y anchor of fighter sprites
                # is at the feet and the Y anchor of the barrel is at its centre.
                # The barrel isn't able to bounce above the head of a fighter (unless we added a really short fighter),
                # so we don't need to check that
                BARREL_HEIGHT = 40
                fighter_bottom_height = fighter.height_above_ground
                barrel_bottom_height = self.height_above_ground - (BARREL_HEIGHT // 2)
                barrel_top_height = barrel_bottom_height + BARREL_HEIGHT

                if fighter is not self.last_thrower \
                  and fighter.falling_state == Fighter.FallingState.STANDING \
                  and abs(fighter.vpos.y - self.vpos.y) < 30 \
                  and abs(self.vpos.x - fighter.vpos.x) < 30 \
                  and fighter_bottom_height < barrel_top_height:
                    fighter.hit(self, ATTACKS["barrel"])

            # Update rolling animation
            facing_id = 1 if self.vel.x > 0 else 0
            self.frame += 1
            self.image = f"barrel_roll_{facing_id}_{(self.frame // 14) % 4}"

    def throw(self, dir_x, thrower):
        self.dropped()
        self.vel.x = dir_x * BARREL_THROW_VEL_X
        self.vel.y = BARREL_THROW_VEL_Y
        self.last_thrower = thrower

        # Shift position for throw animation
        self.vpos.x += dir_x * 104
        #self.height_above_ground += 54

    def dropped(self):
        super().dropped()
        self.image = "barrel_roll_0_0"

    def can_be_picked_up(self):
        return super().can_be_picked_up() and self.vel.length() < 1

    def get_draw_order_offset(self):
        # Consider barrel to be in front of another object with the same Y pos
        # (including player which has draw offset of 1)
        return 2

class BreakableWeapon(Weapon):
    def __init__(self, pos, name, durability):
        super().__init__(name, name, pos, end_pickup_frame=1, anchor=("center", "center"))
        self.break_counter = durability

    def dropped(self):
        super().dropped()
        self.image = self.name

    def get_draw_order_offset(self):
        # Used for stick/chain on ground. Default draw order means it is sometimes drawn on top of a character standing on
        # it, but changing Y anchor point also has some undesirable effects
        return -50

    def used(self):
        self.break_counter -= 1
        if self.break_counter == 0:
            self.on_break()

    def is_broken(self):
        return self.break_counter <= 0

    @abstractmethod
    def on_break(self):
        # Can't call this break as that's a keyword in Python!
        pass

class Stick(BreakableWeapon):
    def __init__(self, pos):
        super().__init__(pos, "stick", durability=randint(12, 16))

    def on_break(self):
        game.play_sound("stick_break")

class Chain(BreakableWeapon):
    def __init__(self, pos):
        super().__init__(pos, "chain", durability=randint(18, 25))

    def on_break(self):
        game.play_sound("chain_break")

class Powerup(ScrollHeightActor):
    def __init__(self, image, pos):
        super().__init__(pos, image)
        self.collected = False

    def update(self):
        pass

    @abstractmethod
    def collect(self, collector):
        self.collected = True

class HealthPowerup(Powerup):
    def __init__(self, pos):
        super().__init__(pos, "health_pickup")

    def collect(self, collector):
        super().collect(collector)

        # Add 20 health to the player who collected us, but don't go over their max health
        collector.health = min(collector.health + 20, collector.start_health)

        game.play_sound("health", 1)

class ExtraLifePowerup(Powerup):
    def __init__(self, pos):
        super().__init__(pos, "ingame_life9")
        self.timer = 0

    def update(self):
        super().update()
        self.timer += 1
        self.image = "ingame_life" + str((self.timer // 2) % 10)

    def collect(self, collector):
        super().collect(collector)

        collector.gain_extra_life()

        game.play_sound("health", 1)

# A stage consists of a group of enemies and a level X boundary. When the enemies are
# defeated, the next stage begins
class Stage:
    def __init__(self, enemies, max_scroll_x, weapons=[], powerups=[]):
        self.enemies = enemies
        self.powerups = powerups
        self.max_scroll_x = max_scroll_x
        self.weapons = weapons

def setup_stages():
    global STAGES
    STAGES = (
            # Stage(max_scroll_x=0, enemies=[]),

            # Stage(max_scroll_x=200,
            #       enemies=[],
            #       #enemies=[EnemyScooterboy(pos=(200, 400))],
            #       #enemies=[EnemyPortal(pos=(600, 400), enemies=(EnemyVax, EnemyHoodie), spawn_interval=60, spawn_interval_change=30)],
            #       #enemies=[EnemyScooterboy(pos=(200, 400)),EnemyScooterboy(pos=(100, 300)),EnemyScooterboy(pos=(300, 600)),EnemyScooterboy(pos=(200, 500)),],
            #       #enemies=[EnemyVax(pos=(200, 400)),EnemyVax(pos=(100, 300)),EnemyVax(pos=(300, 600)),EnemyVax(pos=(200, 500)),],
            #       #enemies=[EnemyBoss(pos=(500, 380))],
            #       weapons=[Barrel((300, 400))]
            #       ),

            # Stage(max_scroll_x=250,
            #       #enemies=[EnemyScooterboy(pos=(200, 400))],
            #       enemies=[EnemyPortal(pos=(600, 400), enemies=(EnemyVax, EnemyHoodie), spawn_interval=120, spawn_interval_change=30, start_timer=300)],
            #       #enemies=[EnemyScooterboy(pos=(200, 400)),EnemyScooterboy(pos=(100, 300)),EnemyScooterboy(pos=(300, 600)),EnemyScooterboy(pos=(200, 500)),],
            #       #enemies=[EnemyBoss(pos=(500, 380))],
            #       weapons=[Barrel((300, 400))]
            #       ),

            Stage(max_scroll_x=300,
                  enemies=[EnemyVax(pos=(1000,400))],
                  #weapons=[Barrel((300, 400))],
                  #powerups=[HealthPowerup(pos=(1100, MIN_WALK_Y)), ExtraLifePowerup(pos=(1000, MIN_WALK_Y))]
                  ),

            Stage(max_scroll_x=600,
                  enemies=[EnemyVax(pos=(1400,400)),
                           EnemyHoodie(pos=(1500,500))],
                  weapons=[Barrel((1600, 400))]),

            Stage(max_scroll_x=600,
                  enemies=[EnemyScooterboy(pos=(200,400))]),

            Stage(max_scroll_x=900,
                  enemies=[EnemyBoss(pos=(1800,400)),
                           EnemyVax(pos=(400,400))]),

            Stage(max_scroll_x=1400,
                  enemies=[EnemyHoodie(pos=(2100,380)),
                           EnemyHoodie(pos=(2100,480)),
                           EnemyHoodie(pos=(800,420))],
                  powerups=[HealthPowerup(pos=(2300, MIN_WALK_Y))]
                  ),

            Stage(max_scroll_x=1900,
                  enemies=[EnemyVax(pos=(2400,380)),
                           EnemyHoodie(pos=(2500,480)),
                           EnemyScooterboy(pos=(2800,400))]),

            Stage(max_scroll_x=2500,
                  enemies=[EnemyScooterboy(pos=(3800,380)),
                           EnemyScooterboy(pos=(3300,480)),
                           EnemyScooterboy(pos=(1200,400))]),

            Stage(max_scroll_x=3000,
                  enemies=[EnemyVax(pos=(4000,380)),
                           EnemyVax(pos=(3900,480)),
                           EnemyVax(pos=(4200,460)),
                           EnemyVax(pos=(4200,450)),
                           EnemyHoodie(pos=(3900,300)),
                           EnemyHoodie(pos=(3950,320))]),

            Stage(max_scroll_x=3600,
                  enemies=[EnemyVax(pos=(4600,380)),
                           EnemyScooterboy(pos=(1200,350)),
                           EnemyScooterboy(pos=(1400,350)),
                           EnemyScooterboy(pos=(1600,350)),
                           EnemyScooterboy(pos=(1800,350)),
                           EnemyScooterboy(pos=(2000,350))],
                  powerups=[HealthPowerup(pos=(5100, MIN_WALK_Y))]
                  ),

            Stage(max_scroll_x=4600,
                  enemies=[EnemyHoodie(pos=(4800,380)),
                           EnemyHoodie(pos=(4800,350)),
                           EnemyScooterboy(pos=(1200,350)),
                           EnemyScooterboy(pos=(1400,350)),
                           EnemyScooterboy(pos=(4800,350)),
                           EnemyScooterboy(pos=(4800,400)),
                           EnemyScooterboy(pos=(4900,450))]),

            Stage(max_scroll_x=5500,
                  enemies=[EnemyBoss(pos=(6500,380)),
                           EnemyBoss(pos=(6500,360))],
                  weapons=[Barrel(pos=(6000, 400)),
                           Barrel(pos=(5900, 370))]),

            Stage(max_scroll_x=6400,
                  enemies=[EnemyBoss(pos=(7000,380)),
                           EnemyBoss(pos=(7000,360)),
                           EnemyBoss(pos=(7000,390))],
                  weapons=[Barrel(pos=(7000, 380))]),

            Stage(max_scroll_x=6900,
                  enemies=[EnemyVax(pos=(7500,380)),
                           EnemyScooterboy(pos=(7500,350)),
                           EnemyScooterboy(pos=(7500,360))]),

            Stage(max_scroll_x=7550,
                  enemies=[EnemyHoodie(pos=(8000,380), start_timer=50),
                           EnemyVax(pos=(8200,340), start_timer=100),
                           EnemyHoodie(pos=(8200,340), start_timer=150),
                           EnemyHoodie(pos=(7900,360), start_timer=200),
                           EnemyHoodie(pos=(8300,390), start_timer=250),
                           EnemyVax(pos=(8700,400), start_timer=300),
                           EnemyHoodie(pos=(8800,400), start_timer=400),
                           EnemyHoodie(pos=(8900,400), start_timer=500),
                           EnemyVax(pos=(9000,320), start_timer=600),
                           EnemyVax(pos=(9100,400), start_timer=700),
                           EnemyHoodie(pos=(9100,450), start_timer=800),
                           EnemyVax(pos=(9100,420), start_timer=900),
                           EnemyBoss(pos=(9100,450), start_timer=1000),
                           ],
                  powerups=[HealthPowerup(pos=(8000, MIN_WALK_Y)),
                            ExtraLifePowerup(pos=(8200, MIN_WALK_Y))]
                  ),

            Stage(max_scroll_x=8400,
                  enemies=[EnemyPortal(pos=(8900, 400), enemies=(EnemyVax, EnemyHoodie), spawn_interval=120, spawn_interval_change=30, max_spawn_interval=250, max_enemies=2),],
                  # weapons=[Barrel(pos=(9000,380)),
                  #          Barrel(pos=(8900,360))
                  ),

            Stage(max_scroll_x=8900,
                  enemies=[EnemyPortal(pos=(9500, 400), enemies=(EnemyVax, EnemyHoodie), spawn_interval=120, spawn_interval_change=50, max_spawn_interval=250, max_enemies=5),
                           EnemyPortal(pos=(9500, 400), enemies=(EnemyScooterboy,), spawn_interval=160, spawn_interval_change=50, max_spawn_interval=250, max_enemies=5),],
                  # weapons=[Barrel(pos=(9000,380)),
                  #          Barrel(pos=(8900,360))
                  ),

            Stage(max_scroll_x=9600,
                  enemies=[EnemyPortal(pos=(10000, 420), enemies=(EnemyVax, EnemyHoodie), spawn_interval=120, spawn_interval_change=50, max_spawn_interval=250, max_enemies=5),
                           EnemyScooterboy(pos=(10500,320)),
                           EnemyScooterboy(pos=(10500,350)),
                           EnemyScooterboy(pos=(10500,380)),
                           ],
                  # weapons=[Barrel(pos=(9000,380)),
                  #          Barrel(pos=(8900,360))
                  ),

            Stage(max_scroll_x=10800,
                  enemies=[EnemyPortal(pos=(11200, 420), enemies=(EnemyHoodie,), spawn_interval=40, spawn_interval_change=10, max_spawn_interval=250, max_enemies=8),
                           ],
                  # weapons=[Barrel(pos=(9000,380)),
                  #          Barrel(pos=(8900,360))
                  ),

            Stage(max_scroll_x=11400,
                  enemies=[EnemyPortal(pos=(12100, 340), enemies=(EnemyScooterboy,), spawn_interval=40, spawn_interval_change=20, max_spawn_interval=250, max_enemies=8),
                           EnemyPortal(pos=(11900, 400), enemies=(EnemyScooterboy,), spawn_interval=50, spawn_interval_change=25, max_spawn_interval=250, max_enemies=8),
                           ],
                  weapons=[Barrel(pos=(11800,380))],
                  powerups=[HealthPowerup(pos=(12000, MIN_WALK_Y)),
                            HealthPowerup(pos=(12500, MIN_WALK_Y))]
                  ),

            Stage(max_scroll_x=12600,
                  enemies=[EnemyPortal(pos=(12900, 340), enemies=(EnemyBoss,), spawn_interval=240, spawn_interval_change=20, max_spawn_interval=300, max_enemies=4),
                           EnemyHoodie(pos=(13200,320)),
                           EnemyHoodie(pos=(13200,330)),
                           EnemyVax(pos=(13400,360)),
                           ],
                  ),

            Stage(max_scroll_x=13400,
                  enemies=[EnemyPortal(pos=(13600, 320), enemies=(EnemyVax,), spawn_interval=230, spawn_interval_change=20, max_spawn_interval=300, max_enemies=10),
                           EnemyPortal(pos=(13600, 435), enemies=(EnemyHoodie,), spawn_interval=240, spawn_interval_change=20, max_spawn_interval=300, max_enemies=10),
                           EnemyPortal(pos=(14000, 320), enemies=(EnemyScooterboy,), spawn_interval=250, spawn_interval_change=30, max_spawn_interval=300, max_enemies=10),
                           EnemyPortal(pos=(14000, 435), enemies=(EnemyBoss,), spawn_interval=260, spawn_interval_change=30, max_spawn_interval=300, max_enemies=10),
                          ],
                  ),

            Stage(max_scroll_x=14700,
                  enemies=[EnemyPortal(pos=(14900, 320), enemies=(EnemyVax,), spawn_interval=220, spawn_interval_change=20, max_spawn_interval=300, max_enemies=8),
                           EnemyPortal(pos=(14900, 435), enemies=(EnemyHoodie,), spawn_interval=230, spawn_interval_change=20, max_spawn_interval=300, max_enemies=8),
                           EnemyPortal(pos=(15300, 320), enemies=(EnemyScooterboy,), spawn_interval=240, spawn_interval_change=20, max_spawn_interval=300, max_enemies=8),
                           EnemyPortal(pos=(15300, 435), enemies=(EnemyBoss,), spawn_interval=250, spawn_interval_change=20, max_spawn_interval=300, max_enemies=8),
                          ],
                  powerups=[HealthPowerup(pos=(14650, 350)),]
                  ),

            Stage(max_scroll_x=15400,
                  enemies=[EnemyPortal(pos=(15800, 350), enemies=(EnemyVax,EnemyHoodie,EnemyScooterboy), spawn_interval=60, spawn_interval_change=20, max_spawn_interval=300, max_enemies=8),
                          ],
                  powerups=[HealthPowerup(pos=(16000, MIN_WALK_Y)),]
                  ),

            Stage(max_scroll_x=16600,
                  enemies=[EnemyVax(pos=(17600,300)),
                           EnemyVax(pos=(17900,320)),
                           EnemyVax(pos=(17600,340)),
                           EnemyVax(pos=(17900,360)),
                           EnemyVax(pos=(17600,380)),
                           EnemyVax(pos=(17900,400)),
                           EnemyVax(pos=(17600,420)),
                          ],
                  powerups=[HealthPowerup(pos=(17000, MIN_WALK_Y)),],
                  weapons=[Barrel(pos=(17000,380))],
                  ),

            Stage(max_scroll_x=17400,
                  enemies=[EnemyBoss(pos=(17800,MIN_WALK_Y)),
                           EnemyScooterboy(pos=(18500,380)),
                           EnemyScooterboy(pos=(18600,380)),
                           EnemyScooterboy(pos=(18700,380)),
                           EnemyScooterboy(pos=(18800,380)),
                           EnemyScooterboy(pos=(19000,380)),
                          ],
                  weapons=[Stick(pos=(18000,340))],
                  ),

            Stage(max_scroll_x=18500,
                  enemies=[EnemyBoss(pos=(18800, 320)),
                           EnemyPortal(pos=(18900, 390), enemies=(EnemyVax, EnemyHoodie),
                                       start_timer=400, spawn_interval=30, spawn_interval_change=5, max_enemies=10),
                           ],
             ),

            Stage(max_scroll_x=19300,
                  enemies=[EnemyScooterboy(pos=(19900, 340))],
                  weapons=[Barrel(pos=(19400,340))],
                  powerups=[HealthPowerup(pos=(19600, MIN_WALK_Y)),],
             ),

            # Final battles

            Stage(max_scroll_x=20500,
                  enemies=[EnemyHoodie(pos=(20900, 380), start_timer=500),
                           EnemyBoss(pos=(21500,330)),
                           EnemyBoss(pos=(21500,350)),
                           EnemyBoss(pos=(21500,370)),
                           EnemyBoss(pos=(21500,390)),
                           EnemyBoss(pos=(18200,320)),
                           EnemyBoss(pos=(17800,390)),
                           ],
                  powerups=[ExtraLifePowerup(pos=(20900, MIN_WALK_Y))]),

            Stage(max_scroll_x=20500,
                  enemies=[EnemyPortal(pos=(20700, 315), enemies=(EnemyVax,), start_timer=600, spawn_interval=60, spawn_interval_change=5, max_enemies=20),
                           EnemyPortal(pos=(20700, 440), enemies=(EnemyHoodie,), start_timer=600, spawn_interval=60, spawn_interval_change=10, max_enemies=20),
                           EnemyPortal(pos=(21100, 315), enemies=(EnemyScooterboy,), start_timer=600, spawn_interval=60, spawn_interval_change=15, max_enemies=20),
                           EnemyPortal(pos=(21100, 440), enemies=(EnemyBoss,), start_timer=600, spawn_interval=60, spawn_interval_change=20, max_enemies=20),
                           ]),

    )

class Game:
    def __init__(self, controls=None):
        self.player = Player(controls)

        self.enemies = []
        self.weapons = []
        self.scooters = []
        self.powerups = []

        self.stage_index = -1
        self.timer = 0
        self.score = 0

        self.scroll_offset = Vector2(0,0)
        self.max_scroll_offset_x = 0
        self.scrolling = False

        self.boundary = Rect(0, MIN_WALK_Y, WIDTH-1, HEIGHT-MIN_WALK_Y)

        setup_stages()

        # Set up intro text, selecting randomly from one of several stolen items
        stolen_items = ("A SHIPMENT OF RASPBERRY\nPIS",
                        "YOUR COPY OF CODE THE\nCLASSICS VOL 2",
                        "THE COMPLETE WORKS OF\nSHAKESPEARE",
                        "THE BLOCKCHAIN",
                        "THE WORLD'S ENTIRE SUPPLY\nOF COVID VACCINES",
                        "ALL OF YOUR SAVED GAME\nFILES",
                        "YOUR DOG'S FLEA MEDICINE")

        self.text_active = INTRO_ENABLED
        self.intro_text = "THE NOTORIOUS CRIME BOSS\nEBEN UPTON HAS STOLEN\n" \
                          + choice(stolen_items) \
                          + "\n\n\nFIGHT TO RECLAIM WHAT\nHAS BEEN TAKEN!"
        self.outro_text = "FOLLOWING THE DEFEAT OF\n" \
                          + "THE EVIL GANG, HUMANITY\n" \
                          + "ENTERED A NEW GOLDEN AGE\n" \
                          + "IN WHICH CRIME BECAME A\n" \
                          + "THING OF THE PAST. THE\n" \
                          + "WORD ITSELF WAS SOON\n" \
                          + "FORGOTTEN AND EVERYONE\n" \
                          + "HAD A BIG PARTY IN YOUR\n" \
                          + "HONOUR.\n" \
                          + "\nNICE JOB!"
        self.current_text = self.intro_text
        self.displayed_text = ""

    def next_stage(self):
        # A stage is over when we've scrolled to its max_scroll_x and there are no enemies left
        # Enemies are created when we start scrolling (or here, if no scrolling is to take place or is already taking place)
        self.stage_index += 1
        if self.stage_index < len(STAGES):
            stage = STAGES[self.stage_index]
            self.max_scroll_offset_x = stage.max_scroll_x
            if self.scrolling or self.max_scroll_offset_x <= self.scroll_offset.x:
                print("No scrolling or already scrolling - create stage objects")
                self.create_stage_objects(stage)
        else:
            # If stage_index has reached len(STAGES), we go into the outro state (like intro text, but with different text)
            # After that, check_won() will return True and the game state code will pick up on this and end the game
            if not self.text_active:
                self.text_active = True
                self.current_text = self.outro_text
                self.displayed_text = ""
                self.timer = 0

    def check_won(self):
        # Have we been through all stages, and has the outro text finished?
        return self.stage_index >= len(STAGES) and not self.text_active

    def create_stage_objects(self, stage):
        # Copy the enemies list from the stage, and tell them that they've been spawned
        self.enemies = stage.enemies.copy()
        for enemy in self.enemies:
            enemy.spawned()

        # Add the weapons and powerups from the stage to the game
        self.weapons.extend(stage.weapons)
        self.powerups.extend(stage.powerups)

    def spawn_enemy(self, enemy):
        # Called by Portal
        self.enemies.append(enemy)
        enemy.spawned()

    def update(self):
        if DEBUG_PROFILING:
            p = Profiler()

        self.timer += 1

        if self.text_active:
            # Every 6 frames, update the displayed text to display an extra character, and make a sound if the
            # new character is visible (as opposed to a space or new line)
            if self.timer % 6 == 0 and len(self.displayed_text) < len(self.current_text):
                length_to_display = min(self.timer // 6, len(self.current_text))
                self.displayed_text = self.current_text[:length_to_display]
                if not self.displayed_text[-1].isspace():
                    self.play_sound("teletype")

            # Allow player to skip/leave text
            for button in range(4):
                if self.player.controls.button_pressed(button):
                    self.text_active = False
                    self.timer = 0

            return

        if DEBUG_SHOW_ATTACKS:
            debug_drawcalls.clear()

        # Update all objects
        for obj in [self.player] + self.enemies + self.weapons + self.scooters + self.powerups:
            obj.update()

        if self.scrolling:
            if self.scroll_offset.x < self.max_scroll_offset_x:
                # How far are we from reaching the new max scroll offset?
                diff = self.max_scroll_offset_x - self.scroll_offset.x
                # Scroll at 1-4px per frame depending on player's distance from right edge
                scroll_speed = self.player.x / (WIDTH/4)
                scroll_speed = min(diff, scroll_speed)
                self.scroll_offset.x += scroll_speed
                self.boundary.left = self.scroll_offset.x  # as boundary is a rectangle, moving boundary.left moves the entire rectangle
            else:
                # Scrolling is complete
                self.scrolling = False
        else:
            # Start scrolling if player is near right hand edge of screen and max_scroll_offset_x allows to to scroll
            begin_scroll_boundary = WIDTH - 300
            if self.player.vpos.x - self.scroll_offset.x > begin_scroll_boundary and self.scroll_offset.x < self.max_scroll_offset_x:
                self.scrolling = True

                # When we start scrolling, create enemies for the current stage
                if self.stage_index < len(STAGES):
                    print("Started scrolling - create stage objects")
                    stage = STAGES[self.stage_index]
                    self.create_stage_objects(stage)

        # Remove expired enemies and gain score
        self.score += sum([enemy.score for enemy in self.enemies if enemy.lives <= 0])
        self.enemies = [enemy for enemy in self.enemies if enemy.lives > 0]

        # Remove expired scooters
        self.scooters = [scooter for scooter in self.scooters if scooter.frame < 200]

        # Remove broken weapons and ones which are off the left of the screen
        self.weapons = [weapon for weapon in self.weapons if not weapon.is_broken() and weapon.x > -200]

        # Remove collected powerups, and ones off the left of the screen
        self.powerups = [powerup for powerup in self.powerups if not powerup.collected and powerup.x > -200]

        # If no enemies and we've fully scrolled to the current stage's max_scroll_x, start the next stage
        if len(self.enemies) == 0 and self.scroll_offset.x == self.max_scroll_offset_x:
            self.next_stage()

        if DEBUG_PROFILING:
            print(f"update: {p.get_ms()}")

    def draw(self):
        # Draw background
        self.draw_background()

        # Draw all objects, lowest on screen first
        # Y pos used is modified by result of get_draw_order_offset, for certain cases where we need more nuance than
        # just "lowest on screen first"
        p = Profiler()
        all_objs = [self.player] + self.enemies + self.weapons + self.scooters + self.powerups
        all_objs.sort(key=lambda obj: obj.vpos.y + obj.get_draw_order_offset())
        for obj in all_objs:
            if obj:
                obj.draw(self.scroll_offset)
        if DEBUG_PROFILING:
            print("objs: {0}".format(p.get_ms()))

        p = Profiler()

        # If player can scroll the level, show flashing arrow
        if self.scroll_offset.x < self.max_scroll_offset_x and (self.timer // 30) % 2 == 0:
            screen.blit("arrow", (WIDTH-450, 120))

        self.draw_ui()

        if DEBUG_PROFILING:
            print("icons: {0}".format(p.get_ms()))
            p = Profiler()

        # During the intro we show a black background, immediately after the intro we fade it away
        # Draw a black image with gradually decreasing opacity
        # An alpha value of 255 is fully opaque, 0 is fully transparent
        if self.text_active or self.timer < 255:
            if self.text_active:
                alpha = 255
            else:
                alpha = max(0, 255 - self.timer)
            fullscreen_black_bmp.set_alpha(alpha)
            screen.blit(fullscreen_black_bmp, (0, 0))

        # Show intro text
        if self.text_active:
            draw_text(self.displayed_text, 50, 50)

        # Debug
        if DEBUG_SHOW_SCROLL_POS:
            screen.draw.text(f"{self.scroll_offset} {self.max_scroll_offset_x}", (0, 25))
            screen.draw.text(str(self.boundary.left), (0, 45))

        if DEBUG_SHOW_BOUNDARY:
            screen.draw.rect(Rect(self.boundary.left - self.scroll_offset.x, self.boundary.top, self.boundary.width, self.boundary.height), (255,255,255))

        # If there are any debug draw calls, execute them - used by DEBUG_SHOW_ATTACKS
        for func in debug_drawcalls:
            func()

        if DEBUG_PROFILING:
            # Show profiler timing for everything not in another category
            print("rest: {0}".format(p.get_ms()))

    def draw_ui(self):
        # Show status bar and player health, stamina and lives
        # Have to use the actual Pygame blit rather than Pygame Zero version so that we can specify which area of the
        # source image to copy
        health_bar_w = int((game.player.health / game.player.start_health) * HEALTH_STAMINA_BAR_WIDTH)
        screen.surface.blit(getattr(images, "health"), (48, 11), Rect(0, 0, health_bar_w, HEALTH_STAMINA_BAR_HEIGHT))
        stamina_bar_w = int((game.player.stamina / game.player.max_stamina) * HEALTH_STAMINA_BAR_WIDTH)
        screen.surface.blit(getattr(images, "stamina"), (517, 11), Rect(0, 0, stamina_bar_w, HEALTH_STAMINA_BAR_HEIGHT))

        screen.blit("status", (0, 0))

        for i in range(game.player.lives):
            if game.player.extra_life_timer <= 0 or i < game.player.lives - 1:
                sprite_idx = 9
            else:
                sprite_idx = min(9, (30 - game.player.extra_life_timer) // 3)

            screen.blit("status_life" + str(sprite_idx), (i * 46 - 55, -35))

        # Show score
        draw_text(f"{self.score:04}", WIDTH // 2, 0, True)

    def draw_background(self):
        # Draw two copies of road background
        p = Profiler()
        road1_x = -(self.scroll_offset.x % WIDTH)
        road2_x = road1_x + WIDTH
        screen.blit("road", (road1_x, 0))
        screen.blit("road", (road2_x, 0))
        if DEBUG_PROFILING:
            print("road " + str(p.get_ms()))

        # Set initial position for background tiles
        # Due to isometric nature of background, each background tile includes a transparent part - the second line
        # skips that part for the first tile
        pos = -self.scroll_offset
        pos.x -= BACKGROUND_TILE_SPACING

        # Draw background tiles
        p = Profiler()
        for tile in BACKGROUND_TILES:
            # Don't bother drawing tile if it's off the left of the screen
            if pos.x + 417 >= 0:
                screen.blit(tile, pos)
                pos.x += BACKGROUND_TILE_SPACING
                if pos.x >= WIDTH:
                    # Stop once we've reached or gone past the right edge of the screen
                    break
            else:
                pos.x += BACKGROUND_TILE_SPACING
        if DEBUG_PROFILING:
            print("bg " + str(p.get_ms()))

    def shutdown(self):
        # When game is over, we need to tell enemies to die, since that's how the scooter engine sound effect gets
        # turned off
        for enemy in self.enemies:
            enemy.died()

    def get_sound(self, name, count=1):
        if self.player:
            return getattr(sounds, name + str(randint(0, count - 1)))

    def play_sound(self, name, count=1):
        # Some sounds have multiple varieties. If count > 1, we'll randomly choose one from those
        # We don't play any sounds if there is no player (e.g. if we're on the menu)
        if self.player:
            try:
                # Pygame Zero allows you to write things like 'sounds.explosion.play()'
                # This automatically loads and plays a file named 'explosion.wav' (or .ogg) from the sounds folder (if
                # such a file exists)
                # But what if you have files named 'explosion0.ogg' to 'explosion5.ogg' and want to randomly choose
                # one of them to play? You can generate a string such as 'explosion3', but to use such a string
                # to access an attribute of Pygame Zero's sounds object, we must use Python's built-in function getattr
                sound = self.get_sound(name, count)
                sound.play()
            except Exception as e:
                # If no sound file of that name was found, print the error that Pygame Zero provides, which
                # includes the filename.
                # Also occurs if sound fails to play for another reason (e.g. if this machine has no sound hardware)
                print(e)

# From Eggzy
def get_char_image_and_width(char):
    # Return width of given character. ord() gives the ASCII/Unicode code for the given character.
    if char == " ":
        return None, 22
    else:
        if char in SPECIAL_FONT_SYMBOLS_INVERSE:
            image = getattr(images, SPECIAL_FONT_SYMBOLS_INVERSE[char])
        else:
            image = getattr(images, "font0"+str(ord(char)))
        return image, image.get_width()

def text_width(text):
    return sum([get_char_image_and_width(c)[1] for c in text])

def draw_text(text, x, y, centre=False):
    # Note that the centre option does not work correctly for text with line breaks
    if centre:
        x -= text_width(text) // 2

    start_x = x

    for char in text:
        if char == "\n":
            # New line
            y += 35
            x = start_x
        else:
            image, width = get_char_image_and_width(char)
            if image is not None:
                screen.blit(image, (x, y))
            x += width

# Set up controls
def get_joystick_if_exists():
    return pygame.joystick.Joystick(0) if pygame.joystick.get_count() > 0 else None

def setup_joystick_controls():
    # We call this on startup, and keep calling it if no controller is present,
    # so a controller can be connected while the game is open
    global joystick_controls
    joystick = get_joystick_if_exists()
    joystick_controls = JoystickControls(joystick) if joystick is not None else None

def update_controls():
    keyboard_controls.update()
    # Allow a controller to be connected while the game is open
    if joystick_controls is None:
        setup_joystick_controls()
    if joystick_controls is not None:
        joystick_controls.update()

class State(Enum):
    TITLE = 1
    CONTROLS = 2
    PLAY = 3
    GAME_OVER = 4


# Pygame Zero calls the update and draw functions each frame

def update():
    global state, game, total_frames

    total_frames += 1

    update_controls()

    def button_pressed_controls(button_num):
        # Local function for detecting button 0 being pressed on either keyboard or controller, returns the controls
        # object which was used to press it, or None if button was not pressed
        for controls in (keyboard_controls, joystick_controls):
            # Check for fire button being pressed on each controls object
            # joystick_controls will be None if there no controller was connected on game startup,
            # so must check for that
            if controls is not None and controls.button_pressed(button_num):
                return controls
        return None

    if state == State.TITLE:
        # Check for start game
        if button_pressed_controls(0) is not None:
            state = State.CONTROLS

    elif state == State.CONTROLS:
        # Check for player starting game with either keyboard or controller
        controls = button_pressed_controls(0)
        if controls is not None:
            # Switch to play state, and create a new Game object, passing it the controls object which was used to start the game
            state = State.PLAY
            game = Game(controls)

    elif state == State.PLAY:
        game.update()
        if game.player.lives <= 0 or game.check_won():
            # Need to call game.shutdown to turn off scooter engine sound
            game.shutdown()
            state = State.GAME_OVER

    elif state == State.GAME_OVER:
        if button_pressed_controls(0) is not None:
            # Go back into title screen mode
            state = State.TITLE
            game = None

def draw():
    if state == State.TITLE:
        # Draw logo
        logo_img = images.title0 if total_frames // 20 % 2 == 0 else images.title1
        screen.blit(logo_img, (WIDTH//2 - logo_img.get_width() // 2, HEIGHT//2 - logo_img.get_height() // 2))

        draw_text(f"PRESS {SPECIAL_FONT_SYMBOLS['xb_a']} OR Z",  WIDTH//2, HEIGHT - 50, True)

    elif state == State.CONTROLS:
        screen.fill((0,0,0))
        screen.blit("menu_controls", (0,0))

    elif state == State.PLAY:
        game.draw()

    elif state == State.GAME_OVER:
        # Draw game over screen
        # Did player win or lose?
        if game.check_won():
            img = images.status_win
        else:
            img = images.status_lose
        screen.blit(img, (WIDTH//2 - img.get_width() // 2, HEIGHT//2 - img.get_height() // 2))

##############################################################################

# Set up sound system and start music
try:
    # Restart the Pygame audio mixer which Pygame Zero sets up by default. We find that the default settings
    # cause issues with delayed or non-playing sounds on some devices
    mixer.quit()
    mixer.init(44100, -16, 2, 1024)

    music.play("theme")
    music.set_volume(0.3)
except Exception as e:
    # If an error occurs (e.g. no sound hardware), ignore it
    pass

total_frames = 0

# Set up controls
keyboard_controls = KeyboardControls()
setup_joystick_controls()

# Set the initial game state
state = State.TITLE
game = None

# Tell Pygame Zero to take over
pgzrun.go()