Tutorial¶
To show how parts of the engine work, I’ll make a simple (boring) sliding block game. Before starting, you should have a working knowledge of both Python and Pygame. I’ll be using Python 2 here, but Python 3 works just as well, provided you’re using an appropriate version of Pygame, and I haven’t broken anything.
Setup¶
Start by downloading the source from
the GitHub repository and building
it using the instructions in the readme. Instead of using the whole template,
we’ll just use the engine here, so copy out the game/engine/
package and
create a Python script in the same directory. We need a little bit of code to
get everything running:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | import random
import pygame as pg
import engine
from engine import conf, evt, gfx, util
class Conf (object):
# the width and height of the image we're working with
IMG_SIZE = (500, 500)
# the number of tiles, horizontally and vertically
N_TILES = (5, 5)
# the size of each actual tile graphic
TILE_SIZE = (99, 99)
# the gap between tiles and around the edge of the screen
TILE_GAP = (1, 1)
class Puzzle (engine.game.World):
def init (self):
pass
if __name__ == '__main__':
# add our settings to the main settings object
conf.add(Conf)
# set the window size
conf.RES_W = (conf.IMG_SIZE[0] + conf.TILE_GAP[0],
conf.IMG_SIZE[1] + conf.TILE_GAP[1])
# initialise the engine
engine.init()
# run with a Puzzle as the world
engine.game.run(Puzzle)
# now we're finished: quit the engine
engine.quit()
|
This will show a blank window, which you can close like any other window.
- We won’t use some of these imports for a while, but it’s worth having them all together at the start.
World.init()
is where your world’s initialisation code goes.- The
Conf
object is not especially necessary, but it’s nice to keep all the magic constants together. It gets added to the globalconf
object, which makes it easy to save settings if we want to at a later date.
Graphics¶
To start with, we need an image. To make things simple and to avoid dwelling
on engine-independent stuff, let’s assume a fixed size for the image: 500
pixels wide and high. I’m using
a lovely Toady Bloyster. Place the image in a
directory named img
next to the script.
To load a Pygame surface from an image file, use the
ResourceManager
available to the
world—working in the init
method, we use:
img = self.resources.img('img.jpg')
Most of the code to split the image up into tiles is basic Pygame.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | # split up into tiles
imgs = []
alpha = util.has_alpha(img)
nx, ny = conf.N_TILES
gap_x, gap_y = conf.TILE_GAP
tile_w, tile_h = conf.TILE_SIZE
for i in xrange(nx):
for j in xrange(ny):
# create empty surface of the correct size and convert
sfc = pg.Surface(conf.TILE_SIZE)
if alpha:
sfc = sfc.convert_alpha()
else:
sfc = sfc.convert()
# copy the correct portion from the source image
x = (tile_w + gap_x) * i
y = (tile_h + gap_y) * j
sfc.blit(img, (0, 0), (x, y, tile_w, tile_h))
# wrap with a graphic
imgs.append(((i, j), gfx.Graphic(sfc)))
|
In the last line, I create a Graphic
object and store it in the imgs
list. This wraps the surface, and allows
for automatic drawing once added to the graphics manager, which we’ll do soon.
Converting the tile surfaces is necessary if the loaded image has transparency
(otherwise transparent areas will appear black).
For positioning the tiles easily, I’ll create a
Grid
. You can set the position of a graphic using
a number of attributes and methods; here, I use
Graphic.pos
. Again, the rest of this
code should contain nothing unfamiliar:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | # randomise tile positions and remove one
random.shuffle(imgs)
missing = random.randrange(nx * ny)
self.missing = [missing // ny, missing % ny]
imgs[missing] = (imgs[missing][0], None)
# create grid for positioning
grid = util.grid.Grid(conf.N_TILES, conf.TILE_SIZE, conf.TILE_GAP)
self.grid = grid
# position graphics
# and turn the tile list into a grid for easier access
self.tiles = tiles = []
for i in xrange(nx):
col = []
tiles.append(col)
for j in xrange(ny):
orig_pos, graphic = imgs[i * ny + j]
col.append((orig_pos, graphic))
# get the tile's top-left corner from the grid
x, y = grid.tile_pos(i, j)
if graphic is not None:
# and move the graphic there
graphic.pos = (x + gap_x, y + gap_y)
|
The only thing left to do is add the graphics to the graphics manager. This is
accessed through World.graphics
, and has
an add()
method. I also add
a dark grey background; Pygame-style colours and 0xrrggbbaa
are supported
too.
1 2 3 4 5 6 7 8 9 10 | # add to the graphics manager
# make sure to remove the missing tile
imgs.pop(missing)
self.graphics.add(
# a background to show up between the tiles and in the gap
# '111' is a CSS-style colour (dark grey)
# 1 is the layer, which is further back than the default 0
gfx.Colour('111', self.graphics.rect, 1),
*(graphic for orig_pos, graphic in imgs)
)
|
And now the tiles show up on the screen. Try the full code.
Input¶
The best way to do input handling is by creating a configuration file. Create
an evt
directory next to the script and create a file to store them in.
I’m calling it controls
, but if you’re on Windows, you might want to add an
extension (like .txt
) to make it easier to edit.
First, let’s add some more ways to quit the game. We create a button
event
that issues signals when it is pressed down, and attach a couple of keyboard
keys using same the names as Pygame:
button quit DOWN
# this is a comment
kbd ESCAPE
kbd BACKSPACE
The quit
argument is the name we choose to give the event, and it is
required; we’ll see its use soon.
For playing, what we want to do is move tiles in four directions: left, right,
up or down. This corresponds to a button4
event, so let’s make one of
those:
button4 move DOWN
left kbd LEFT
right kbd RIGHT
up kbd UP
down kbd DOWN
This time, we’ve used the left
, etc. keywords to define which ‘component’
of the event each input is attached to.
Now let’s use these definitions in our code. Working in the init
method
again, add:
eh = self.evthandler
eh.load('controls')
This loads the events we’ve defined into this world’s event handler, and now they’re easy to access:
eh['quit'].cb(lambda evts: conf.GAME.quit_world())
eh['move'].cb(self.move)
(conf.GAME
contains the current running game.) We’ve registered
callback functions for each event using
BaseEvent.cb()
; the arguments these get
called with depends on the event type. A button
passes a single argument
containing information about the numbers of DOWN
, etc. events that occurred
in the last frame. We only get called if there was at least one event, and
we’ve only registered for DOWN
events, so we just ignore it here and quit
the world (this is the only running world, so it causes the game to end).
Now we need to define the move
method we’ve referenced above. First, let’s
write the code that will just move a tile to the missing tile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def move_tile (self, start_x, start_y):
"""Move the given tile to the missing tile."""
# set the tile's new position
dest_x, dest_y = self.missing
orig_pos, graphic = self.tiles[start_x][start_y]
self.tiles[dest_x][dest_y] = (orig_pos, graphic)
# mark the original position as missing
self.missing = (start_x, start_y)
self.tiles[start_x][start_y] = None
# get graphic's new on-screen position
screen_x, screen_y = self.grid.tile_pos(dest_x, dest_y)
screen_x += conf.TILE_GAP[0]
screen_y += conf.TILE_GAP[1]
# move the graphic
graphic.pos = (screen_x, screen_y)
|
Nothing here is new.
A button4
calls callbacks with three arguments: the axis and direction:
component | axis | direction |
---|---|---|
left | 0 | -1 |
right | 0 | 1 |
up | 1 | -1 |
down | 1 | 1 |
and a dict
with a key for each button mode (DOWN
), giving numbers of
events in the last frame (like for button
). We could just ignore the
numbers of events and assume we only got one to limit the number of moves to
one per frame, but I’ll do it properly here:
1 2 3 4 5 6 7 8 9 10 11 12 | def move (self, axis, dirn, evts):
for i in xrange(evts[evt.bmode.DOWN]):
# get the tile to move
start = list(self.missing)
start[axis] -= dirn
x, y = start
# check if the tile exists
if x < 0 or x >= conf.N_TILES[0] or y < 0 or y >= conf.N_TILES[1]:
# the tile is out of bounds
return
# move the tile
self.move_tile(x, y)
|
The useful thing about the event system is that you can define lots of different inputs to do the same thing. Let’s use the following:
button4 move DOWN
# arrow keys
left kbd LEFT
right kbd RIGHT
up kbd UP
down kbd DOWN
# WASD
left kbd a
left kbd q
right kbd d
right kbd e
up kbd w
up kbd z
up kbd COMMA
down kbd s
down kbd o
# gamepad analogue sticks
left right pad axis 0 .6 .4
up down pad axis 1 .6 .4
left right pad axis 3 .6 .4
up down pad axis 4 .6 .4
This supports the WASD
keys for a number of keyboard layouts, and the
analogue sticks on all connected gamepads (for an Xbox 360 controller and any
other controller with analogue sticks bound to the same axes). Gamepads are
initialised automatically.
How about supporting mouse input too? The obvious control scheme is to move any clicked tile to the missing tile if it’s next to it. To support both left- and right mouse buttons, write the event definition:
button click DOWN
mouse button LEFT
mouse button RIGHT
attach it to a callback:
eh['click'].cb(self.click)
and define the callback:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | def click (self, evts):
# get the tile clicked on
x, y = pg.mouse.get_pos()
tile = self.grid.tile_at(x - conf.TILE_GAP[0], y - conf.TILE_GAP[1])
if tile is None:
# clicked on the gap between tiles, so do nothing
return
x, y = tile
for i in xrange(evts[evt.bmode.DOWN]):
if self.tiles[x][y] is None:
# this is the missing tile
break
# make sure the clicked tile is next to the missing tile
if tuple(self.missing) not in ((x - 1, y), (x, y - 1), (x + 1, y),
(x, y + 1)):
# it's not
break
self.move_tile(x, y)
|
The only new thing here is the call to
Grid.tile_at()
—it saves a bit of work,
and handles the edge cases for us.
You might notice you can’t see the cursor. This is the default behaviour, so let’s disable that. This setting is actually configured on a per-world basis, and what we want can be achieved by the following:
if __name__ == '__main__':
# make the mouse visible
conf.MOUSE_VISIBLE[Puzzle.id] = True
# ...
Interpolation¶
Instead of moving the tiles instantly to their destination, let’s try sliding
them over a short period. This is achieved using the ‘interpolation’ provided
in the sched
module. First define the movement duration
in our Conf
object, in seconds:
MOVE_TIME = .2
In our move_tile
method, we end up with:
def move_tile (self, start_x, start_y):
# ...
# move the graphic
self.scheduler.interp_simple(graphic, 'pos', (screen_x, screen_y),
conf.MOVE_TIME)
This moves the graphic linearly to the destination position instead of setting it straight away. Try the game again and you’ll see it in action.
Now, if you go a little crazy and try pressing lots of buttons at once, you might end up with more than one missing tile. This is because we’re not bothering to stop any already-running motions on the same graphic when we start a new one.
To fix this, we can use the
locked variants
of the
interpolation methods. We require a few changes:
1 2 3 4 5 6 7 8 9 | def init (self):
# ...
if graphic is not None:
# we'll use this for movement
graphic.interp_to = self.scheduler.interp_simple_locked(
graphic, 'pos', t=conf.MOVE_TIME
)
# and move the graphic there
graphic.pos = (x + gap_x, y + gap_y)
|
def move_tile (self, start_x, start_y):
# ...
# move the graphic
graphic.interp_to((screen_x, screen_y))
Here’s the final code.
Everything else (exercises!)¶
I’ve gone over the (currently) most developed systems in the engine
(evt
, gfx
, and interpolation in sched
).
The rest is fairly simple or just uses Pygame directly, but here I’ve detailed
a few more things it might be useful to know.
Audio¶
At the moment, audio is fairly basic. To play music, just create a music
directory next to the script and put some files supported by Pygame in there,
set conf.MUSIC_AUTOPLAY
to True
, and they’ll just play when the
game starts.
Sound files go in a sound
directory, named like name0.ogg
,
name1.ogg
, etc. to randomly choose one each time sound 'name'
is
played. Volume works with something like:
conf.SOUND_VOLUMES['name'] = .3
It might be worth finding an appropriate sound effect and getting it to play
when a tile is moved (see World.play_snd()
).
Victory condition¶
At the moment, it’s not possible to win the game. There are a number of ways
this could be implemented, but this wouldn’t teach anything about the engine,
so I’ve left it as an exercise. You might find the orig_pos
part of each
entry in the tiles
attribute we’ve defined useful.
After you’ve managed that, try putting together a victory message using
World.resources.text
via ResourceManager.text
via
res.load_text
(take note of
conf.TEXT_RENDERERS
and conf.FONT_DIR
).
High scores¶
Try timing a player’s attempt by keeping a counter and adding the frame
duration (World.scheduler.elapsed
via
Timer.elapsed
) to it each frame
(World.update()
).
As mentioned earlier, the conf
object could easily be used to save
settings. Try tracking and saving a list of the best times (see
SettingsManager.save()
and
SettingsManager.dump()
).