view mamba/level.py @ 561:9afaa1969d6f

Level format 2 support * * * Allow for blank author * * * Fix thinko in write code
author Neil Muller <drnlmuller@gmail.com>
date Tue, 18 Oct 2011 11:41:28 +0200
parents 6c61d5862310
children 2d3dee657638
line wrap: on
line source

"""
Level for our shiny game.
"""

import pygame
from pygame.surface import Surface
from pygame.sprite import RenderUpdates

from mamba.constants import UP, DOWN, LEFT, RIGHT, VERSION
from mamba.data import load_file
from mamba import sprites

from StringIO import StringIO


class InvalidMapError(Exception):
    pass


def mktile(cls, **kw):
    return (cls, kw)


TILE_MAP = {
    '.': None,
    'X': mktile(sprites.TileSprite, image_name='wall', name='wall',
        solid=True),
    'A': mktile(sprites.TileSprite, image_name='biohazard', name='biohazard',
        solid=True),
    'R': mktile(sprites.DoorSprite, colour='red'),
    'B': mktile(sprites.DoorSprite, colour='blue'),
    'Y': mktile(sprites.DoorSprite, colour='yellow'),
    'e': mktile(sprites.EntrySprite),
    'E': mktile(sprites.ExitSprite),
    '~': mktile(sprites.PuddleSprite),
    '$': mktile(sprites.FireSprite),
    'r': mktile(sprites.Painter, colour='red', name='paint'),
    'b': mktile(sprites.Painter, colour='blue', name='paint'),
    'y': mktile(sprites.Painter, colour='yellow', name='paint'),
    '^': mktile(sprites.ArrowSprite, direction=UP),
    'v': mktile(sprites.ArrowSprite, direction=DOWN),
    '<': mktile(sprites.ArrowSprite, direction=LEFT),
    '>': mktile(sprites.ArrowSprite, direction=RIGHT),
    '@': mktile(sprites.FlipArrows),
    }

THING_MAP = {
    'M': mktile(sprites.BigMouse),
    'm': mktile(sprites.SmallMouse),
    'f': mktile(sprites.Frog),
    '&': mktile(sprites.Snail),
    'l': mktile(sprites.Lizard),
    's': mktile(sprites.Salamander),
    }


class Tileset(object):
    def __init__(self, tileset_name):
        self.name = tileset_name
        self.load_tiles()

    def load_tiles(self):
        self.tiles = {}
        self.floor = sprites.TileSprite('.', tileset=self.name,
                                        image_name='floor').image
        for name, value in TILE_MAP.items():
            if value is not None:
                value[1]['tileset'] = self.name
            self.tiles[name] = value
        self.tiles.update(THING_MAP)

    def __getitem__(self, key):
        try:
            tilespec = self.tiles[key]
        except KeyError:
            raise InvalidMapError("Unknown tile type: '%s'" % key)
        if not tilespec:
            return None
        cls, params = tilespec
        params['tile_char'] = key
        return cls(**params)

    def get_tile(self, key, tile_pos, *groups):
        tile = self[key]
        if tile:
            tile.add(*groups)
            tile.set_tile_pos(tile_pos)
        return tile


class Level(object):
    def __init__(self, level_name, level_namespace, source=None):
        self.level_name = level_name
        self.level_namespace = level_namespace
        self.source = source
        self.author = ''
        self.load_level_data()

    def load_format_1(self, level_data):
        """Load the original map format"""
        self.name = level_data[0].strip()
        tileset_name = level_data[1].strip()
        self.tileset = Tileset(tileset_name)
        self.background_track = level_data[2].strip()
        tiles_ascii = [line.strip() for line in level_data[3:]]
        try:
            end = tiles_ascii.index("end")
        except ValueError:
            raise InvalidMapError('Missing "end" marker in level')
        sprites_ascii = tiles_ascii[end + 1:]
        tiles_ascii = tiles_ascii[:end]
        self.tiles_ascii = tiles_ascii
        self.sprites_ascii = sprites_ascii

    def load_format_2(self, level_data):
        """Load the second mamba map format"""
        try:
            self.name = level_data[1].split('Name:', 1)[1].strip()
            tileset_name = level_data[3].split('Tileset:', 1)[1].strip()
            self.background_track = level_data[4].split('Music:', 1)[1].strip()
        except IndexError:
            raise InvalidMapError('Incorrectly formatted map header')
        try:
            # Missing author isn't fatal
            self.author = level_data[2].split('Author:', 1)[1].strip()
        except IndexError:
            self.author = ''
        self.tileset = Tileset(tileset_name)
        tiles_ascii = [line.strip() for line in level_data[5:]]
        try:
            end = tiles_ascii.index("end")
        except ValueError:
            raise InvalidMapError('Missing "end" marker in level')
        sprites_ascii = tiles_ascii[end + 1:]
        tiles_ascii = tiles_ascii[:end]
        self.tiles_ascii = tiles_ascii
        self.sprites_ascii = sprites_ascii

    def check_format(self, level_data):
        """Determine which format to load"""
        if (level_data[0].startswith('Version: ') and
                level_data[3].startswith('Tileset: ')):
            # Looks like a version 2 level file
            file_version = level_data[0].split('Version: ', 1)[1].strip()
            try:
                file_version = tuple([int(x) for x in file_version.split('.')])
                # We only compare on the first two elements, so point
                # rleases work as expected
                if file_version[:2] <= VERSION:
                    return 2
                else:
                    raise InvalidMapError("Unsupported map version")
            except ValueError:
                raise InvalidMapError("Unrecognised map version")
        else:
            # We assume anything we don't recognise is format 0 and rely on the
            # error checking there to save us if we're wrong.
            return 1

    def load_level_data(self):
        """
        This file format is potentially yucky.
        """
        if self.source is not None:
            level_data = StringIO(self.source)
        else:
            level_data = load_file('levels/%s.txt' % (self.level_name,))
        # XXX: Should we have some size restriction here?
        level_data = level_data.readlines()
        level_format = self.check_format(level_data)
        if level_format == 2:
            self.load_format_2(level_data)
        elif level_format == 1:
            self.load_format_1(level_data)
        else:
            # generic fallback
            raise InvalidMapError("Unrecognised map version")
        self.setup_level(self.tiles_ascii, self.sprites_ascii)
        self.make_background()

    def validate_level(self):
        old_tiles_ascii = self.tiles_ascii[:]
        old_tiles = self.tiles[:]
        try:
            self.update_tiles_ascii()
            self.setup_tiles(self.tiles_ascii)
        finally:
            self.tiles = old_tiles
            self.tiles_ascii = old_tiles_ascii

    def save_level(self, level_dir=None, is_user_dir=False, save_file=None):
        """Save the current state of the level"""
        if save_file is None:
            if level_dir is None:
                level_dir = 'levels'
            file_path = '%s/%s.txt' % (level_dir, self.level_name)
            save_file = load_file(file_path, 'wb', is_user_dir=is_user_dir)
        save_file.write('Version: %d.%d\n' % VERSION[:2])
        save_file.write('Name: %s\n' % self.name)
        save_file.write('Author: %s\n' % self.author)
        save_file.write('Tileset: %s\n' % self.tileset.name)
        save_file.write('Music: %s\n' % self.background_track)
        self.update_tiles_ascii()
        for tile_row in self.tiles_ascii:
            save_file.write('%s\n' % tile_row)
        save_file.write('end\n')
        for sprite_ascii in self.sprites_ascii:
            save_file.write('%s\n' % sprite_ascii)

    def unique_name(self):
        return '/'.join((self.level_namespace, self.level_name))

    def update_tiles_ascii(self):
        """Resync tiles and tile_ascii"""
        for i, tile_row in enumerate(self.tiles):
            new_row = []
            for tile in tile_row:
                if tile:
                    new_row.append(tile.tile_char)
                else:
                    new_row.append('.')
            self.tiles_ascii[i] = ''.join(new_row)

    def setup_level(self, tiles_ascii, sprites_ascii):
        self.sprites = RenderUpdates()
        self.setup_tiles(tiles_ascii)
        self.setup_sprites(sprites_ascii)

    def setup_tiles(self, tiles_ascii):
        self.tiles = []
        self.entry = None
        self.tile_size = (len(tiles_ascii[0]), len(tiles_ascii))
        for y, row in enumerate(tiles_ascii):
            if len(row) != self.tile_size[0]:
                raise InvalidMapError("Map not rectangular.")
            tile_row = []
            for x, tile_char in enumerate(row):
                #tile_orientation = self.get_tile_orientation(y, x, row,
                #        tile_char)
                tile = self.tileset.get_tile(tile_char, (x, y), self.sprites)
                tile_row.append(tile)
                if isinstance(tile, sprites.EntrySprite):
                    self.rejigger_entry_tile(tile)
            self.tiles.append(tile_row)
        if self.entry is None:
            raise InvalidMapError("Not enough entry points.")
        self.set_tile_orientations()

    def rejigger_entry_tile(self, tile):
        if self.entry is not None:
            raise InvalidMapError("Too many entry points.")
        self.entry = tile
        direction = []
        x, y = tile.tile_pos
        if x == 0:
            direction.append(RIGHT)
        elif x == self.tile_size[0] - 1:
            direction.append(LEFT)
        if y == 0:
            direction.append(DOWN)
        elif y == self.tile_size[1] - 1:
            direction.append(UP)
        if len(direction) < 1:
            raise InvalidMapError("Entry point must be along an edge.")
        if len(direction) > 1:
            raise InvalidMapError("Entry point must not be on a corner.")
        tile.set_direction(direction[0])

    def set_tile_orientations(self):
        tiles = [tile  # This is a scary listcomp. It makes me happy.
                 for row in self.tiles
                 for tile in row
                 if tile is not None]
        for tile in tiles:
            orientation = self.get_tile_orientation(tile)
            tile.use_variant(*orientation)

    def setup_sprites(self, sprites_ascii):
        self.extra_sprites = {}
        sprite_positions = []
        for sprite_ascii in sprites_ascii:
            try:
                pos, _sep, rest = sprite_ascii.partition(':')
            except ValueError:
                raise InvalidMapError('Unable to determine sprite position'
                        ' from line: %s' % sprite_ascii)
            try:
                pos = [int(x.strip()) for x in pos.split(',')]
            except ValueError:
                raise InvalidMapError("Sprite position must be two integers."
                        "Got %s" % pos)
            class_name, rest = rest.split(None, 1)
            args = rest.split()
            sprite_id, args = args[0], args[1:]
            try:
                cls = sprites.find_sprite(class_name)
            except KeyError:
                raise InvalidMapError("Unknown Sprite class: %s" % class_name)
            sprite = cls(*args)
            if pos in sprite_positions:
                raise InvalidMapError('Multiple sprites at %s.' % pos)
            sprite_positions.append(pos)
            sprite.set_tile_pos(pos)
            if sprite_id in self.extra_sprites:
                raise InvalidMapError('Duplicate sprite id: %s.' % sprite_id)
            self.extra_sprites[sprite_id] = sprite
            self.sprites.add(sprite)

    def is_same_tile(self, tile, x, y):
        """Is there a tile of the same type?"""
        if tile.tile_char is None:
            # This isn't really a tile, so bail
            return False
        try:
            other_tile = self.tiles[y][x]
        except IndexError:
            # We're over the edge of the map
            return False
        if other_tile is None:
            # Emptiness.
            return False
        return tile.tile_char == other_tile.tile_char

    def get_tile_orientation(self, tile):
        if tile is None:
            return (False, False, False, False)
        map_x, map_y = tile.tile_pos
        return (
            self.is_same_tile(tile, map_x, map_y - 1),  # above
            self.is_same_tile(tile, map_x, map_y + 1),  # below
            self.is_same_tile(tile, map_x - 1, map_y),  # left
            self.is_same_tile(tile, map_x + 1, map_y),  # right
            )

    def get_tile_size(self):
        return self.tile_size

    def get_size(self):
        x, y = self.get_tile_size()
        return sprites.tile_sizify((x, y))

    def make_background(self):
        if not pygame.display.get_init():
            # Skip if we're not actuallt in pygame
            return
        sx, sy = self.get_tile_size()
        self.background = Surface(self.get_size())
        [self.background.blit(self.tileset.floor, sprites.tile_sizify((x, y)))
         for x in range(sx) for y in range(sy)]

    def get_entry(self):
        return (self.entry.tile_pos, self.entry.direction)

    def draw(self, surface):
        surface.blit(self.background, (0, 0))
        self.sprites.draw(surface)

    def get_tile(self, tile_pos):
        x, y = tile_pos
        if 0 <= x < self.tile_size[0] and 0 <= y < self.tile_size[1]:
            return self.tiles[y][x]
        return None

    def replace_tile(self, tile_pos, new_tile_char):
        x, y = tile_pos
        old_tile = self.tiles[y][x]
        if old_tile is not None:
            old_tile.remove(self.sprites)
        new_tile = self.tileset.get_tile(new_tile_char, tile_pos, self.sprites)
        self.tiles[y][x] = new_tile
        # Fix orientations
        tiles = [self.get_tile((x + dx, y + dy)) for dx in (-1, 0, 1)
                for dy in (-1, 0, 1)]
        for tile in tiles:
            if tile:
                orientation = self.get_tile_orientation(tile)
                tile.use_variant(*orientation)

    def flip_arrows(self):
        for row in self.tiles:
            for tile in row:
                if isinstance(tile, sprites.ArrowSprite):
                    tile.rotate()

    def restart(self):
        """Reset the level state"""
        self.setup_level(self.tiles_ascii, self.sprites_ascii)
        self.make_background()

    def get_sprite_at(self, sprite_pos):
        """Return the sprite line at the given pos, or none if it doesn't
           exist"""
        for sprite_ascii in self.sprites_ascii:
            try:
                pos, _, rest = sprite_ascii.partition(':')
                pos = [int(x.strip()) for x in pos.split(',')]
            except ValueError:
                continue
            if pos[0] == sprite_pos[0] and pos[1] == sprite_pos[1]:
                pos, _sep, rest = sprite_ascii.partition(':')
                class_name, rest = rest.split(None, 1)
                args = rest.split()
                sprite_cls = sprites.find_sprite(class_name)
                sprite_id, args = args[0], args[1:]
                return (class_name, sprite_cls, sprite_id, args), sprite_ascii
        return None, None

    def remove_sprite(self, sprite):
        """Remove the given sprite line from the list of sprites"""
        self.sprites_ascii.remove(sprite)

    def validate_sprite(self, sprite):
        """Check that the sprite line is valid"""
        try:
            pos, _sep, rest = sprite.partition(':')
            pos = [int(x.strip()) for x in pos.split(',')]
            class_name, rest = rest.split(None, 1)
            args = rest.split()
        except ValueError:
            raise InvalidMapError('Unable to determine sprite parameters.')
        sprite_id, args = args[0], args[1:]
        try:
            cls = sprites.find_sprite(class_name)
        except KeyError:
            raise InvalidMapError("Unknown Sprite class: %s" % class_name)
        sprite = cls(*args)
        if sprite_id in self.extra_sprites:
            # Check that duplicate id is not at the same position
            if self.extra_sprites[sprite_id].tile_pos != pos:
                raise InvalidMapError('Duplicate sprite id: %s.' % sprite_id)

    def add_sprite(self, sprite):
        self.sprites_ascii.append(sprite)

    def replace_sprite(self, new_ascii):
        # Need to find the sprite at the same psoition
        pos, _sep, rest = new_ascii.partition(':')
        pos = [int(x.strip()) for x in pos.split(',')]
        old_sprite, old_ascii = self.get_sprite_at(pos)
        self.sprites_ascii.remove(old_ascii)
        self.sprites_ascii.append(new_ascii)