-
Notifications
You must be signed in to change notification settings - Fork 28
Tutorial
This wiki page is a work-in-progress. Anybody is welcome to add to it!
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.
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.
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.
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.
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
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()
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.
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.
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.