Commits

Richard Jones committed 7e2b9e2

add side-scroller example and extend tmx to cope with separate tileset files

Comments (0)

Files changed (6)

 <?xml version="1.0" encoding="UTF-8"?>
 <map version="1.0" orientation="orthogonal" width="50" height="50" tilewidth="32" tileheight="32">
- <tileset firstgid="1" name="tiles" tilewidth="32" tileheight="32">
-  <image source="tiles.png" width="32" height="32"/>
- </tileset>
- <tileset firstgid="2" name="triggers" tilewidth="32" tileheight="32">
-  <image source="triggers.png" width="256" height="128"/>
-  <tile id="0">
-   <properties>
-    <property name="blockers" value="tr"/>
-   </properties>
-  </tile>
-  <tile id="1">
-   <properties>
-    <property name="blockers" value="lr"/>
-   </properties>
-  </tile>
-  <tile id="2">
-   <properties>
-    <property name="blockers" value="tlr"/>
-   </properties>
-  </tile>
-  <tile id="3">
-   <properties>
-    <property name="blockers" value="lrb"/>
-   </properties>
-  </tile>
-  <tile id="8">
-   <properties>
-    <property name="blockers" value="tl"/>
-   </properties>
-  </tile>
-  <tile id="9">
-   <properties>
-    <property name="blockers" value="bl"/>
-   </properties>
-  </tile>
-  <tile id="10">
-   <properties>
-    <property name="blockers" value="br"/>
-   </properties>
-  </tile>
-  <tile id="11">
-   <properties>
-    <property name="blockers" value="tb"/>
-   </properties>
-  </tile>
-  <tile id="16">
-   <properties>
-    <property name="blockers" value="t"/>
-   </properties>
-  </tile>
-  <tile id="17">
-   <properties>
-    <property name="blockers" value="l"/>
-   </properties>
-  </tile>
-  <tile id="18">
-   <properties>
-    <property name="blockers" value="r"/>
-   </properties>
-  </tile>
-  <tile id="19">
-   <properties>
-    <property name="blockers" value="b"/>
-   </properties>
-  </tile>
-  <tile id="20">
-   <properties>
-    <property name="blockers" value="trb"/>
-   </properties>
-  </tile>
-  <tile id="21">
-   <properties>
-    <property name="blockers" value="tlb"/>
-   </properties>
-  </tile>
-  <tile id="25">
-   <properties>
-    <property name="blockers" value="tlrb"/>
-   </properties>
-  </tile>
-  <tile id="26">
-   <properties>
-    <property name="player" value=""/>
-   </properties>
-  </tile>
-  <tile id="27">
-   <properties>
-    <property name="enemy" value=""/>
-   </properties>
-  </tile>
-  <tile id="28">
-   <properties>
-    <property name="exit" value=""/>
-   </properties>
-  </tile>
-  <tile id="29">
-   <properties>
-    <property name="reverse" value=""/>
-   </properties>
-  </tile>
- </tileset>
+ <tileset firstgid="1" source="tiles.tsx"/>
+ <tileset firstgid="2" source="triggers.tsx"/>
  <layer name="set" width="50" height="50">
   <data encoding="base64" compression="zlib">
    eJztlkEOgCAQA+H/n/a8B2OCQEeZSTg2WFsbe2ut/+R8HX2w0AeLE32Qd472PKPoo+rTXTMPFu4VC31Ufbpr5sHCvWKhj6pPd808WLhXLPRR9emumQcL94qFPqr+qWurO2keLFZmvGvXiPs5ij6qPp2tebB4+3/1dHZxYh5k7nyMdCj5PtL3zyL1Pc7Oc7Q/xHMBpUQBKg==
+<?xml version="1.0" encoding="UTF-8"?>
+<map version="1.0" orientation="orthogonal" width="100" height="15" tilewidth="32" tileheight="32">
+ <tileset firstgid="1" source="tiles.tsx"/>
+ <tileset firstgid="2" source="triggers.tsx"/>
+ <layer name="map" width="100" height="15">
+  <data encoding="base64" compression="zlib">
+   eJzt2DEKACAMBEH9/6d9gU0aF5yB6wNXhGStuS3XvFKd62e66NFFjy5a9AHAhP3Roo8O90eH+7zD/6rj9Q+1nKkDsJAAuw==
+  </data>
+ </layer>
+ <layer name="triggers" width="100" height="15" visible="0">
+  <data encoding="base64" compression="zlib">
+   eJztmEkKwDAIRXOPDvc/ZhFaKIUu4v9Go3ngroP4lAyt6dlW/IaGU/neG4u8qsLwIVR3cZC+w/IheLjoqYPVswjf/zB9CIiLUTVgw8w7kg8EVu+P7gnr+agO6lN87IxEbpB8rHvTYv1Ac846H1HWHsZ8MJlhnxvFnZBtf8WurXcOs58/eog0Fw9W85HtfN7rLtL5fN1f8UB9eN+hRg4N4uMC1mUY4w==
+  </data>
+ </layer>
+</map>
+'''Basic side-scolling shooter game.
+
+Developed for the Intro to Game Programming tutorial at US PyCon 2012.
+
+Copyright 2012 Richard Jones <richard@mechanicalcat.net>
+This code is placed in the Public Domain.
+'''
+import pygame
+import tmx
+
+#
+# Our enemies appear on the right-hand side of the screen when their triggers
+# become exposed. They move slowly left until they are no longer on the or die.
+#
+class Enemy(pygame.sprite.Sprite):
+    image = pygame.image.load('enemy.png')
+    def __init__(self, location, *groups):
+        super(Enemy, self).__init__(*groups)
+        self.rect = pygame.rect.Rect(location, self.image.get_size())
+
+    def update(self, dt, game):
+        # move the enemy by 50 pixels per second
+        self.rect.x += -50 * dt
+
+        # check for collision with the player; on collision mark the flag on the
+        # player to indicate game over (a health level could be decremented here
+        # instead)
+        if self.rect.colliderect(game.player.rect):
+            game.player.is_dead = True
+
+#
+# Bullets fired by the player move in one direction until their lifespan runs
+# out or they hit an enemy. This could be extended to allow for enemy bullets.
+#
+class Bullet(pygame.sprite.Sprite):
+    image = pygame.image.load('bullet.png')
+    def __init__(self, location, *groups):
+        super(Bullet, self).__init__(*groups)
+        self.rect = pygame.rect.Rect(location, self.image.get_size())
+        # time this bullet will live for in seconds
+        self.lifespan = 1
+
+    def update(self, dt, game):
+        # decrement the lifespan of the bullet by the amount of time passed and
+        # remove it from the game if its time runs out
+        self.lifespan -= dt
+        if self.lifespan < 0:
+            self.kill()
+            return
+
+        # move the enemy by 400 pixels per second
+        self.rect.x += 400 * dt
+
+        # check for collision with any of the enemy sprites; we pass the "kill
+        # if collided" flag as True so any collided enemies are removed from the
+        # game
+        if pygame.sprite.spritecollide(self, game.enemies, True):
+            game.explosion.play()
+            # we also remove the bullet from the game or it will continue on
+            # until its lifespan expires
+            self.kill()
+
+#
+# Our player of the game represented as a sprite with many attributes and user
+# control.
+#
+class Player(pygame.sprite.Sprite):
+    image = pygame.image.load('player-right.png')
+    def __init__(self, location, *groups):
+        super(Player, self).__init__(*groups)
+        self.rect = pygame.rect.Rect(location, self.image.get_size())
+        # is the player dead?
+        self.is_dead = False
+        # time since the player last shot
+        self.gun_cooldown = 0
+
+    def update(self, dt, game):
+        # handle the player movement left/right keys
+        key = pygame.key.get_pressed()
+        self.rect.x += game.view_dx
+        if key[pygame.K_LEFT]:
+            self.rect.x -= int(200 * dt)
+        if key[pygame.K_RIGHT]:
+            self.rect.x += int(200 * dt)
+        if key[pygame.K_UP]:
+            self.rect.y -= int(200 * dt)
+        if key[pygame.K_DOWN]:
+            self.rect.y += int(200 * dt)
+
+        # keep the player from moving off-screen
+        viewport = game.tilemap.viewport
+        if self.rect.bottom > viewport.bottom:
+            self.rect.bottom = viewport.bottom
+        elif self.rect.top < viewport.top:
+            self.rect.top = viewport.top
+        if self.rect.left < viewport.left:
+            self.rect.left = viewport.left
+        elif self.rect.right > viewport.right:
+            self.rect.right = viewport.right
+
+        # handle the player shooting key
+        if key[pygame.K_LSHIFT] and not self.gun_cooldown:
+            Bullet(self.rect.midright, game.sprites)
+            # set the amount of time until the player can shoot again
+            self.gun_cooldown = .2
+            game.shoot.play()
+
+        # decrement the time since the player last shot to a minimum of 0 (so
+        # boolean checks work)
+        self.gun_cooldown = max(0, self.gun_cooldown - dt)
+
+        # if the player touches any of the map blockers they die
+        if game.tilemap.layers['triggers'].collide(self.rect, 'blockers'):
+            self.is_dead = True
+
+        if game.tilemap.layers['triggers'].collide(self.rect, 'exit'):
+            game.won = True
+
+#
+# Our game class represents one loaded level of the game and stores all the
+# actors and other game-level state.
+#
+class Game(object):
+    def main(self, screen):
+        self.screen = screen
+
+        # grab a clock so we can limit and measure the passing of time
+        clock = pygame.time.Clock()
+
+        # we draw the background as a static image so we can just load it in the
+        # main loop
+        background = pygame.image.load('background.png')
+
+        # load our tilemap and set the viewport for rendering to the screen's
+        # size
+        self.tilemap = tmx.load('scroller.tmx', screen.get_size())
+
+        # add a layer for our sprites controlled by the tilemap scrolling
+        self.sprites = tmx.SpriteLayer()
+        self.tilemap.layers.append(self.sprites)
+        # fine the player start cell in the triggers layer
+        start_cell = self.tilemap.layers['triggers'].find('player')[0]
+        # use the "pixel" x and y coordinates for the player start
+        self.player = Player((start_cell.px, start_cell.py), self.sprites)
+
+        # add a separate layer for enemies so we can find them more easily later
+        self.enemies = tmx.SpriteLayer()
+        self.tilemap.layers.append(self.enemies)
+
+        # load the sound effects used in playing a level of the game
+        self.jump = pygame.mixer.Sound('jump.wav')
+        self.shoot = pygame.mixer.Sound('shoot.wav')
+        self.explosion = pygame.mixer.Sound('explosion.wav')
+
+        self.won = False
+        view_x = start_cell.px
+        while 1:
+            # limit updates to 30 times per second and determine how much time
+            # passed since the last update; convert the milliseconds value to
+            # seconds
+            dt = clock.tick(30)/1000.
+
+            # handle basic game events; terminate this main loop if the window
+            # is closed or the escape key is pressed
+            for event in pygame.event.get():
+                if event.type == pygame.QUIT:
+                    return
+                if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
+                    return
+
+            # move the view
+            self.tilemap.set_focus(view_x, start_cell.py)
+            self.view_dx = int(100 * dt)
+            view_x += self.view_dx
+
+            # update the tilemap and everything in it passing the elapsed time
+            # since the last update (in seconds) and this Game object
+            self.tilemap.update(dt, self)
+
+            # add an enemy for each "enemy" trigger that has been exposed
+            for cell in self.tilemap.layers['triggers'].collide(self.tilemap.viewport, 'enemy'):
+                # delete the enemy trigger so we don't make another one
+                del cell['enemy']
+                Enemy((cell.px+32, cell.py), self.enemies)
+
+            # construct the scene by drawing the background and then the rest of
+            # the game imagery over the top
+            screen.blit(background, (0, 0))
+            self.tilemap.draw(screen)
+            pygame.display.flip()
+
+            # terminate this main loop if the player dies; a simple change here
+            # could be to replace the "print" with the invocation of a simple
+            # "game over" scene
+            if self.player.is_dead:
+                print 'YOU DIED'
+                return
+
+            # terminate this main loop if the player wins
+            if self.won:
+                print 'YOU WIN'
+                return
+
+if __name__ == '__main__':
+    # if we're invoked as a program then initialise pygame, create a window and
+    # run the game
+    pygame.init()
+    screen = pygame.display.set_mode((640, 480))
+    Game().main(screen)
+
+<?xml version="1.0" encoding="UTF-8"?>
+<tileset name="tiles" tilewidth="32" tileheight="32">
+ <image source="tiles.png" width="32" height="32"/>
+</tileset>
 # "Tiled" TMX loader/renderer and more
 # inspired initially by http://silveiraneto.net/2009/12/19/tiled-tmx-map-loader-for-pygame/
 # but mostly rewritten and much broader in scope.
+# TODO: support properties on more things
 
 import sys, pygame, struct
 from pygame.locals import *
 from pygame import Rect
-from xml import sax
+
+import xml.etree.ElementTree as ET
 
 class Tile(object):
     def __init__(self, gid, surface, tileset):
         self.tile_width = tileset.tile_width
         self.tile_height = tileset.tile_height
         self.properties = {}
+
+    def loadxml(self, tag):
+        props = tag.find('properties')
+        if props is None:
+            return
+        for c in props.findall('property'):
+            # store additional properties.
+            name = c.attrib['name']
+            value = c.attrib['value']
+
+            # TODO hax
+            if value.isdigit():
+                value = int(value)
+            self.properties[name] = value
+
     def __repr__(self):
         return '<Tile %d>' % self.gid
 
         self.tile_height = tile_height
         self.firstgid = firstgid
         self.tiles = []
+        self.properties = {}
+
+    @classmethod
+    def fromxml(cls, tag, firstgid=None):
+        if 'source' in tag.attrib:
+            firstgid = int(tag.attrib['firstgid'])
+            with open(tag.attrib['source']) as f:
+                tileset = ET.fromstring(f.read())
+            return cls.fromxml(tileset, firstgid)
+
+        name = tag.attrib['name']
+        if firstgid is None:
+            firstgid = int(tag.attrib['firstgid'])
+        tile_width = int(tag.attrib['tilewidth'])
+        tile_height = int(tag.attrib['tileheight'])
+
+        tileset = cls(name, tile_width, tile_height, firstgid)
+
+        for c in tag.getchildren():
+            if c.tag == "image":
+                # create a tileset
+                tileset.add_image(c.attrib['source'])
+            elif c.tag == 'tile':
+                gid = tileset.firstgid + int(c.attrib['id'])
+                tileset.get_tile(gid).loadxml(c)
+        return tileset
 
     def add_image(self, file):
         image = pygame.image.load(file).convert_alpha()
         self.top = py
         self.bottom = py + tile.tile_height
         self._added_properties = {}
-        self._deleted = set()
+        self._deleted_properties = set()
     def __repr__(self):
         return '<Cell %s,%s %d>' % (self.px, self.py, self.tile.gid)
     def __contains__(self, key):
-        if key in self._deleted:
+        if key in self._deleted_properties:
             return False
         return key in self._added_properties or key in self.tile.properties
     def __getitem__(self, key):
-        if key in self._deleted:
+        if key in self._deleted_properties:
             raise KeyError(key)
         if key in self._added_properties:
             return self._added_properties[key]
         raise KeyError(key)
     def __setitem__(self, key, value):
         self._added_properties[key] = value
-    def __delitem__(self, key, value):
+    def __delitem__(self, key):
         self._deleted_properties.add(key)
     def intersects(self, other):
         '''Determine whether this Cell intersects with the other rect (which has
     def __init__(self, name, visible, map):
         self.name = name
         self.visible = visible
-        # TODO get from TMX
+        # TODO get from TMX?
         self.px_width = map.px_width
         self.px_height = map.px_height
         self.tile_width = map.tile_width
         self.properties = {}
         self.cells = {}
 
-    def set_data(self, data):
-        assert len(data) == self.width * self.height
+    @classmethod
+    def fromxml(cls, tag, map):
+        layer = cls(tag.attrib['name'], int(tag.attrib.get('visible', 1)), map)
+
+        data = tag.find('data')
+        if data is None:
+            raise ValueError('layer %s does not contain <data>' % layer.name)
+
+        data = data.text.strip()
+        data = data.decode('base64').decode('zlib')
+        data = struct.unpack('<%di' % (len(data)/4,), data)
+        assert len(data) == layer.width * layer.height
         for i, gid in enumerate(data):
             if gid < 1: continue   # not set
-            tile = self.tilesets[gid]
-            x = i % self.width
-            y = i // self.width
-            self.cells[x,y] = Cell(x, y, x*self.tile_width, y*self.tile_height, tile)
+            tile = map.tilesets[gid]
+            x = i % layer.width
+            y = i // layer.width
+            layer.cells[x,y] = Cell(x, y, x*map.tile_width, y*map.tile_height, tile)
+
+        return layer
 
     def update(self, dt, *args):
         pass
         for cell in self.get_in_region(rect.left, rect.top, rect.right, rect.bottom):
             if not cell.intersects(rect):
                 continue
-            if propname in cell.tile.properties:
+            if propname in cell:
                 r.append(cell)
         return r
 
             return self[item]
         return self.by_name[item]
 
-class TileMap(sax.ContentHandler):
+class TileMap(object):
     def __init__(self, size, origin=(0,0)):
         self.px_width = 0
         self.px_height = 0
         self.fx, self.fy = 0, 0             # viewport focus point
         self.view_w, self.view_h = size     # viewport size
         self.view_x, self.view_y = origin   # viewport offset
+        self.viewport = Rect(origin, size)
 
     def update(self, dt, *args):
         for layer in self.layers:
             if layer.visible:
                 layer.draw(screen)
 
-    def startElement(self, name, attrs):
+    @classmethod
+    def load(cls, filename, viewport):
+        with open(filename) as f:
+            map = ET.fromstring(f.read())
+
         # get most general map informations and create a surface
-        if name == 'map':
-            self.width = int(attrs['width'])
-            self.height  = int(attrs['height'])
-            self.tile_width = int(attrs['tilewidth'])
-            self.tile_height = int(attrs['tileheight'])
-            self.px_width = self.width * self.tile_width
-            self.px_height = self.height * self.tile_height
+        tilemap = TileMap(viewport)
+        tilemap.width = int(map.attrib['width'])
+        tilemap.height  = int(map.attrib['height'])
+        tilemap.tile_width = int(map.attrib['tilewidth'])
+        tilemap.tile_height = int(map.attrib['tileheight'])
+        tilemap.px_width = tilemap.width * tilemap.tile_width
+        tilemap.px_height = tilemap.height * tilemap.tile_height
 
-        elif name=="tileset":
-            name = attrs['name']
-            firstgid = int(attrs['firstgid'])
-            ts = Tileset(name, self.tile_width, self.tile_height, firstgid)
-            self.tileset = ts
+        for tag in map.findall('tileset'):
+            tilemap.tilesets.add(Tileset.fromxml(tag))
 
-        elif name=="image":
-            # create a tileset
-            # TODO width, height
-            self.tileset.add_image(attrs['source'])
+        for tag in map.findall('layer'):
+            layer = Layer.fromxml(tag, tilemap)
+            tilemap.layers.add_named(layer, layer.name)
 
-        elif name == 'property':
-            # store additional properties.
-            name = attrs['name']
-            value = attrs['value']
-            # TODO hax
-            if value.isdigit():
-                value = int(value)
-            if self.tile is not None:
-                self.tile.properties[name] = value
-            elif self.layer is not None:
-                self.layer.properties[name] = value
-            else:
-                self.properties[name] = value
-
-        # starting counting
-        elif name == 'layer':
-            self.layer = Layer(attrs['name'], int(attrs.get('visible', 1)), self)
-            self.layers.add_named(self.layer, attrs['name'])
-
-        elif name == 'data':
-            self.is_data = True
-
-        elif name == 'tile':
-            gid = self.tileset.firstgid + int(attrs['id'])
-            self.tile = self.tileset.get_tile(gid)
-
-    def characters(self, data):
-        if self.is_data:
-            data = data.strip()
-            if not data: return
-            data = data.decode('base64').decode('zlib')
-            data = struct.unpack('<%di' % (len(data)/4,), data)
-            self.layer.set_data(data)
-        else:
-            pass
-
-    def endElement(self, name):
-        if name == 'layer':
-            self.layer = None
-        elif name=="tileset":
-            self.tilesets.add(self.tileset)
-            self.tileset = None
-        elif name=="tile":
-            self.tile = None
-        elif name == 'data':
-            self.is_data = False
+        return tilemap
 
     _old_focus = None
     def set_focus(self, fx, fy, force=False):
 
         # determine child view bounds to match that focus point
         x, y = int(restricted_fx - w2), int(restricted_fy - h2)
+        self.viewport.x = x
+        self.viewport.y = y
 
         self.childs_ox = x - self.view_x
         self.childs_oy = y - self.view_y
         h = int(self.view_h)
         w2, h2 = w//2, h//2
 
-        # bottom-left corner of the
+        # bottom-left corner of the viewport
         x, y = fx - w2, fy - h2
+        self.viewport.x = x
+        self.viewport.y = y
 
         self.childs_ox = x - self.view_x
         self.childs_oy = y - self.view_y
         return int(screen_x), int(screen_y)
 
 def load(filename, viewport):
-    parser = sax.make_parser()
-    tilemap = TileMap(viewport)
-    parser.setContentHandler(tilemap)
-    parser.parse(filename)
-    return tilemap
+    return TileMap.load(filename, viewport)
 
+if __name__ == '__main__':
+    # allow image load to work
+    pygame.init()
+    pygame.display.set_mode((640, 480))
+    t = load(sys.argv[1], (0, 0))
+<?xml version="1.0" encoding="UTF-8"?>
+<tileset name="triggers" tilewidth="32" tileheight="32">
+ <image source="triggers.png" width="256" height="128"/>
+ <tile id="0">
+  <properties>
+   <property name="blockers" value="tr"/>
+  </properties>
+ </tile>
+ <tile id="1">
+  <properties>
+   <property name="blockers" value="lr"/>
+  </properties>
+ </tile>
+ <tile id="2">
+  <properties>
+   <property name="blockers" value="tlr"/>
+  </properties>
+ </tile>
+ <tile id="3">
+  <properties>
+   <property name="blockers" value="lrb"/>
+  </properties>
+ </tile>
+ <tile id="8">
+  <properties>
+   <property name="blockers" value="tl"/>
+  </properties>
+ </tile>
+ <tile id="9">
+  <properties>
+   <property name="blockers" value="bl"/>
+  </properties>
+ </tile>
+ <tile id="10">
+  <properties>
+   <property name="blockers" value="br"/>
+  </properties>
+ </tile>
+ <tile id="11">
+  <properties>
+   <property name="blockers" value="tb"/>
+  </properties>
+ </tile>
+ <tile id="16">
+  <properties>
+   <property name="blockers" value="t"/>
+  </properties>
+ </tile>
+ <tile id="17">
+  <properties>
+   <property name="blockers" value="l"/>
+  </properties>
+ </tile>
+ <tile id="18">
+  <properties>
+   <property name="blockers" value="r"/>
+  </properties>
+ </tile>
+ <tile id="19">
+  <properties>
+   <property name="blockers" value="b"/>
+  </properties>
+ </tile>
+ <tile id="20">
+  <properties>
+   <property name="blockers" value="trb"/>
+  </properties>
+ </tile>
+ <tile id="21">
+  <properties>
+   <property name="blockers" value="tlb"/>
+  </properties>
+ </tile>
+ <tile id="25">
+  <properties>
+   <property name="blockers" value="tlrb"/>
+  </properties>
+ </tile>
+ <tile id="26">
+  <properties>
+   <property name="player" value=""/>
+  </properties>
+ </tile>
+ <tile id="27">
+  <properties>
+   <property name="enemy" value=""/>
+  </properties>
+ </tile>
+ <tile id="28">
+  <properties>
+   <property name="exit" value=""/>
+  </properties>
+ </tile>
+ <tile id="29">
+  <properties>
+   <property name="reverse" value=""/>
+  </properties>
+ </tile>
+</tileset>