Skip to content
The Peeps191 edited this page Jan 14, 2022 · 2 revisions

Introduction

This wiki page is a work-in-progress. Anybody is welcome to add to it!

Before You Start

Before starting this tutorial, it would be helpful if you had a basic understanding of Python 3 Object Oriented Programming and a basic understanding of how pygame works. If you don't know how those work yet, it's best you follow a basic tutorial on Python and pygame first.

This tutorial was written with Python 3.6+ in mind, and so it is incompatible with Python 2.7 and earlier.

Full code of this tutorial can be found in the https://github.com/bitcraft/pyscroll/tree/master/apps/tutorial directory of this repository.

Pyscroll and PyTMX

These are two complimentary projects that are designed to take advantage of the excellent Tiled Map Editor. PyTMX is a Python project that will load TMX files and theoretically can be used by any Python multimedia library. It has built-in support for pygame, but has also been used with pyglet and pysdl2.

Pyscroll uses PyTMX to read TMX map data and then exposes a simple mechanism for pygame. It just renders maps and provides smooth scrolling. It doesn't include anything else, by design.

Style

This tutorial follows the PEP 8 coding style guidelines. A good way to automate formatting is to use a tool like Black.

A code editor that highlights Python syntax and provides help with formatting is recommended. Some good cross-platform options are:

  • Visual Studio Code if you want an editor for all programming languages;
  • PyCharm if you want an IDE with first-class Python support; or
  • Mu if you are a newbie to Python programming.

Getting Started with Pyscroll and PyGame

Let's get started with a simple game loop.

class QuestGame:
    """ This class is a basic game.

    This class will load data, create a pyscroll group, a hero object.
    It also reads input and moves the Hero around the map.
    Finally, it uses a pyscroll group to render the map and Hero.
    """

    def __init__(self):
        """ Init the game here
        """

    def draw(self, surface):
        """ Drawing code goes here
        """

    def handle_input(self):
        """ Handle pygame input events
        """

    def update(self, dt):
        """ Tasks that occur over time should be handled here
        """

    def run(self):
        """ Run the game loop
        """

# simple wrapper to keep the screen resizeable
def init_screen(width, height):
    global temp_surface
    screen = pygame.display.set_mode((width, height), pygame.RESIZABLE)
    temp_surface = pygame.Surface((width / 2, height / 2)).convert()
    return screen


def main() -> None:
    pygame.init()
    pygame.font.init()
    screen = init_screen(800, 600)
    pygame.display.set_caption('Quest - An epic journey.')

    try:
        game = QuestGame()
        game.run()
    except KeyboardInterrupt:
        pass
    finally:
        pygame.quit()


if name == "__main__":
    main()

The QuestGame class will be the "main" part of your program. It will run everything.

Setting up the Hero

Our Character, The Hero, has three collision rects: two for the whole sprite "rect" and "old_rect", and another to check collisions with walls, called "feet". The position list is used because pygame rects are inaccurate for positioning sprites; because the values they get are 'rounded down' as integers, the sprite would move faster moving left or up. Feet is 1/2 as wide as the normal rect, and 8 pixels tall. This size allows the top of the sprite to overlap walls. The 'feet' rect is used for collisions, while the 'rect' rect is used for drawing. There is also an old_rect that is used to reposition the sprite if it collides with level walls.

class Hero(pygame.sprite.Sprite):
    def __init__(self):
        pygame.sprite.Sprite.__init__(self)
        self.image = load_image('hero.png').convert_alpha()
        # .convert_alpha allows for transparency around you character
        self.velocity = [0, 0]
        self._position = [0, 0]
        self._old_position = self.position
        self.rect = self.image.get_rect()
        self.feet = pygame.Rect(0, 0, self.rect.width * .5, 8)

    def position(self):
        return list(self._position)

    def position(self, value):
        self._position = list(value)

    def update(self, dt):
        self._old_position = self._position[:]
        self._position[0] += self.velocity[0] * dt
        self._position[1] += self.velocity[1] * dt
        self.rect.topleft = self._position
        self.feet.midbottom = self.rect.midbottom

    def move_back(self, dt):
        """ If called after an update, the sprite can move back to give the
            illusion of the sprite not moving.
        """
        self._position = self._old_position
        self.rect.topleft = self._position
        self.feet.midbottom = self.rect.midbottom

Setting up the QuestGame

The input_handling section is going to change a lot during the tutorial. I am laying out the tutorial by "scaffolding" the structure. I believe that scaffolding is a great way to learn because you can see why bad solutions are bad and why good solutions are good. Input_handling turns the game from a rather complicated image viewer into an actual game.

class Hero(pygame.sprite.Sprite):
    """ Hero class above.
    """"

class QuestGame(object):
    """ This class is a basic game.

    This class will load data, create a pyscroll group, a hero object.
    It also reads input and moves the Hero around the map.
    Finally, it uses a pyscroll group to render the map and Hero.
    """
    def __init__(self):
        # true while running
        self.running = False

        # load data from pytmx
        tmx_data = pytmx.load_pygame(self.filename)

        # create new data source for pyscroll
        map_data = pyscroll.data.TiledMapData(tmx_data)

        w, h = screen.get_size()

        # create new renderer (camera)
        # clamp_camera is used to prevent the map from scrolling past the edge
        self.map_layer = pyscroll.BufferedRenderer(map_data,
                                                   (w / 2, h / 2),
                                                   clamp_camera=True)

        self.group = pyscroll.PyscrollGroup(map_layer=self.map_layer) 
# I use "from pyscroll.group import PyscrollGroup", if you do this don't use the pyscroll bit above.
        self.hero = Hero()

        # put the hero in the center of the map
        self.hero.position = self.map_layer.rect.center

        # add our hero to the group
        self.group.add(self.hero)

    def draw(self, surface):
        """ Drawing code goes here
        """
        # center the map/screen on our Hero
        self.group.center(self.hero.rect.center)

        # draw the map and all sprites
        self.group.draw(surface)

    def handle_input(self):
        """ Handle pygame input events
        """
        for event in pygame.event.get():
            if event.type == QUIT:
                self.running = False
                break

            elif event.type == KEYDOWN:
                if event.key == K_ESCAPE:
                    self.running = False
                    pygame.exit()
                    break

        """ You might want to add in debug keys here, like print(self.hero.position), etc.
            A list of all PyGame keybindings can be found at http://www.pygame.org/docs/ref/key.html 
        """

            # this will be handled if the window is resized
            elif event.type == VIDEORESIZE:
                init_screen(event.w, event.h)
                self.map_layer.set_size((event.w / 2, event.h / 2))

        # using get_pressed is slightly less accurate than testing for events,
        # but is much easier to use.
        pressed = pygame.key.get_pressed()
        if pressed[K_UP]:
            self.hero.velocity[1] = -HERO_MOVE_SPEED
        elif pressed[K_DOWN]:
            self.hero.velocity[1] = HERO_MOVE_SPEED

        if pressed[K_LEFT]:
            self.hero.velocity[0] = -HERO_MOVE_SPEED
        elif pressed[K_RIGHT]:
            self.hero.velocity[0] = HERO_MOVE_SPEED

    def run(self):
        """ Run the game loop
        """
        clock = pygame.time.Clock()
        fps = 60
        scale = pygame.transform.scale
        self.running = True

        try:
            while self.running:
                dt = clock.tick(fps) / 1000.
                self.handle_input()
                self.update(dt)
                self.draw(temp_surface)
                scale(temp_surface, screen.get_size(), screen)
                pygame.display.flip()

        except KeyboardInterrupt:
            self.running = False
            pygame.exit()

Scrolling

If you run the code now, you should be able to watch the hero move around. But wait, its not scrolling! Pyscroll has a very simple method of scrolling a map. Just call .center((x, y)) on your pyscroll object and it will move that (x, y) point to the center of your screen.

Pyscroll includes a PyscrollGroup object which you should absolutely use if you want to handle pygame sprites.

Changing Map

I use the get_map function just out of ease of use. You can find it below.

def get_map(filename):
    return os.path.join(RESOURCES_DIR, filename)

Changing the map is vital to any large game. It allows multiple levels/rooms and slows down loading times, because nobody likes waiting.To change maps we must first create a new renderer(camera), on the new map. Then, we must put all of the collision objects(from the map) into a list. To finish, we need to put all our sprites onto our new renderer.

def map_change(self, map):
            mapfile = get_map(map)
            tmx_data = load_pygame(mapfile)
            print(tmx_data)
            map_data = pyscroll.data.TiledMapData(tmx_data)
            self.walls = list()
            for object in tmx_data.objects:
                self.walls.append(pygame.Rect(
                    object.x, object.y,
                    object.width, object.height))


            self.map_layer = pyscroll.BufferedRenderer(map_data, screen.get_size())
            self.map_layer.zoom = 2
            self.group = PyscrollGroup(map_layer=self.map_layer, default_layer=4)
# Pydesigner (https://github.com/pydsigner) wanted to add "pyscroll" before the PyscrollGroup bit 
# See my comment above for an alternative
            self.hero = Hero('Tiles/character/character_still.png')
            self.hero.position = self.map_layer.map_rect.center

Now, what you must do is link this into your main loop somehow. I'll leave it up to you to work out.

Character Animation

In this final section we will switch the character image periodically to make the sprite look like it is running/walking. Note you will need to use a sprite sheet that has been separated into individual frames/images. William Edwards has written a good bash (Linux) script to do just this, go to http://pastebin.com/raw/KWHnVEfd to download it.

directionX and directionY should both equal "still" if no arrow keys are being pressed, then the character should not continue to be animated (just stand still).

    def animation(self, direction, number):
        self.counter += 1
        if self.directionX == "still" and self.directionY == "still":
            if self.direction == "left":
                self.hero = Hero('Tiles/character/walking_left/walking_left1.png')
            if self.direction == "right":
                self.hero = Hero('Tiles/character/walking_right/walking_right1.png')
            if self.direction == "up":
                self.hero = Hero('Tiles/character/walking_up/walking_up1.png')
            if self.direction == "down":
                self.hero = Hero('Tiles/character/walking_down/walking_down1.png')

        else:
            self.hero = Hero('Tiles/character/walking_' +direction +'/walking_' +direction +str(number) +'.png')

                
            self.group.remove(self)
            self.group.add(self.hero)

Again, I will leave the linking to the main loop up to you. Have fun.

If you (and I) have done everything correctly you should now have a fully working game.