lillian-lemmer/hypatia

View on GitHub
hypatia/game.py

Summary

Maintainability
C
1 day
Test Coverage
# This module is part of Hypatia and is released under the
# MIT License: http://opensource.org/licenses/MIT

"""Why stuff is drawn; logic flow for the game.

Game logic, game component interaction.

Glues various modules/game components together with behaviors defined
in methods belonging to Game().

Note:
  I have not decided firmly on the approach to take. Expect heavy
  changes in the future.

  Sorry for the poor documentation, I have not devised an actual
  architecture for this particular module. I have not decided
  firmly on the approach to take. Here, I'm sort of imitating
  Flask's app.

"""

import os
import sys
import xml.etree.ElementTree as ET

try:
    import ConfigParser as configparser

except ImportError:
    import configparser

import pygame

from hypatia import tiles
from hypatia import dialog
from hypatia import render
from hypatia import player
from hypatia import sprites
from hypatia import physics
from hypatia import resources
from hypatia import constants
from hypatia import controllers


class TMXException(Exception):
    """Base class for all exceptions related to TMX.

    See Also:
        :class:`TMX`

    """
    pass


class TMXMissingPlayerStartPosition(TMXException):
    """TMX file parsed does not have a player start
    position, which is required to create scenes.

    See Also:
        :class:`TMX`

    """

    def __init__(self):
        message = "TMX file missing player_start_position"
        super(TMXMissingPlayerStartPosition, self).__init__(message)


class TMXTooManyTilesheets(TMXException):
    """A TMX file was attempted to be imported through
    `TileMap.from_tmx()`, but the TMX defined more than
    one tilesheet. This is a feature Hypatia does not
    support.

    See Also:
        :meth:`TileMap.from_tmx()` and :class:`TMX`.

    """

    def __init__(self):
        """The exception message is this class' docstring.

        Note:
            Mostly scaffolding, plus won't be here for long.

        """

        message = TMXTooManyTilesheets.__docstring__
        super(TMXTooManyTilesheets, self).__init__(message)


class TMXVersionUnsupported(TMXException):
    """Attempted to create a TileMap from a TMX map, but
    the TMX map version is unsupported.

    Attribs:
        map_version (str): the version which was attempted

    """

    def __init__(self, map_version):
        """

        Args:
            map_version (str): the map version which is
                unsupported. This becomes the map_version
                attribute.

        """

        message = 'version %s unsupported' % map_version
        super(TMXVersionUnsupported, self).__init__(message)
        self.map_version = map_version


class TMXLayersNotCSV(TMXException):
    """The data encoding used for layers during Tilemap.from_tmx()
    is not supported. Only CSV is supported.

    Attribs:
        data_encoding (str): the failed data encoding.

    """

    def __init__(self, data_encoding):
        """

        Args:
            data_encoding (str): the failed data encoding

        """

        message = 'tmx layer data encoding %s unsupported' % data_encoding
        super(TMXLayersNotCSV, self).__init__(message)
        self.data_encodign = data_encoding


# not in use
class Hypatia(object):

    def __init__(self, **kwargs):

        for key, value in kwargs.items():
            setattr(self, key, value)


class Game(object):
    """Simulates the interaction between game components."""

    def __init__(self, screen=None, scene=None,
                 viewport_size=None, dialogbox=None):

        self.screen = screen or render.Screen()
        self.viewport = render.Viewport(viewport_size)
        self.dialogbox = dialogbox or dialog.DialogBox(self.viewport.rect.size)

        # everything has been added, run runtime_setup() on each
        # relevant item
        self.scene = scene
        self.scene.runtime_setup()
        self.start_loop()

    # will be removed
    def old_render(self):
        """Drawing behavior for game objects.

        Parts of this should go to their respective classes, .e.g,
        scene.

        Needs to be updated to use sprite groups.

        """

        first_tilemap_layer = self.scene.tilemap.layer_images[0]
        self.viewport.center_on(self.scene.human_player.walkabout,
                                first_tilemap_layer.get_rect())
        self.viewport.blit(first_tilemap_layer)
        self.scene.tilemap.blit_layer_animated_tiles(self.viewport, 0)

        # render each npc walkabout
        for npc in self.scene.npcs:
            npc.walkabout.blit(self.screen.clock,
                               self.viewport.surface,
                               self.viewport.rect.topleft)

        # finally human and rest map layers last
        self.scene.human_player.walkabout.blit(self.screen.clock,
                                               self.viewport.surface,
                                               self.viewport.rect.topleft)

        for i, layer in enumerate(self.scene.tilemap.layer_images[1:], 1):
            self.viewport.blit(layer)
            self.scene.tilemap.blit_layer_animated_tiles(self.viewport, i)

        self.dialogbox.blit(self.viewport.surface)

    def render(self):
        """Drawing behavior for game objects.

        Parts of this should go to their respective classes, .e.g,
        scene.

        Needs to be updated to use sprite groups.

        """

        self.scene.render(self.viewport, self.screen.clock)
        self.dialogbox.blit(self.viewport.surface)

    def start_loop(self):
        controller = controllers.WorldController(self)

        while controller.handle_input():
            controller.handle_input()
            self.screen.update(self.viewport.surface)
            self.render()

        pygame.quit()
        sys.exit()


class Scene(object):
    """A map with configuration data/meta, e.g., NPCs.

    Attributes:
      tilemap (hypatia.tiles.Tilemap): --
      player_start_position (tuple): (x, y); two integer tuple
        denoting the starting position for human player.
      human_player (hypatia.player.Player): the human player object.
      npcs (list): a list of hypatia.player.NPC objects

    Notes:
        Should have methods for managing npcs, e.g., add/remove.

    """

    def __init__(self, tilemap, player_start_position,
                 human_player, npcs=None):
        """
        Args:
            tilemap (tiles.TileMap): --
            player_start_position (tuple): x, y pixel coordinates
                for the human player's starting position.
            human_player (players.HumanPlayer): --
            npcs (List[players.Npc]): --
            npc_sprite_group (pygame.sprite.Group): --

        """

        self.tilemap = tilemap

        self.player_start_position = player_start_position
        self.human_player = human_player

        self.npcs = npcs or []

        npc_walkabouts = [n.walkabout for n in self.npcs]
        self.npc_sprite_group = pygame.sprite.Group(*npc_walkabouts)

    @staticmethod
    def create_human_player(start_position):
        """Currently mostly scaffolding for creating/loading the
        human character into the scene.

        Args:
            start_position (tuple): x, y pixel coordinates
                for the human player's starting position.

        Returns:
            player.HumanPlayer: --

        """

        # .. create player with player scene data
        bow = sprites.Walkabout('bow')
        human_walkabout = sprites.Walkabout('slime',
                                            position=start_position,
                                            children=[bow])
        velocity = physics.Velocity(20, 20)
        human_player = player.HumanPlayer(walkabout=human_walkabout,
                                          velocity=velocity)

        return human_player

    def to_tmx_resource(self, tmx_name):
        """Scaffolding.

        """

        pass

    @classmethod
    def from_tmx_resource(cls, tmx_name):
        """Create a scene from a Tiled editor TMX file in
        the scenes resource directory.

        Returns:
            Scene: A scene created using all compatible
                data from designated TMX file.

        """

        file_path = os.path.join('resources', 'scenes', tmx_name + '.tmx')
        tmx = TMX(file_path)
        human_player = cls.create_human_player(tmx.player_start_position)

        return Scene(tilemap=tmx.tilemap,
                     player_start_position=tmx.player_start_position,
                     human_player=human_player,
                     npcs=tmx.npcs)

    @classmethod
    def from_resource(self, scene_name):
        """The native format, and hopefully most reliable,
        stable, and generally best way of saving, loading,
        or creating Hypatia scenes.

        This defines the standard by which all
        other Scene constructors must follow.

        Args:
          scene_name (str): the name of the directory which corresponds
            to the map you want to load from resources/maps.

        """

        # load the scene zip from the scene resource and read
        # the general scene configuration, first.
        resource = resources.Resource('scenes', scene_name)
        scene_ini = resource['scene.ini']

        # Construct a TileMap from the tilemap.txt
        # contents from the scene resource.
        tilemap_string = resource['tilemap.txt']
        tilemap = tiles.TileMap.from_string(tilemap_string)

        # Get the player's starting position from the
        # general scene configuration.
        player_start_x = scene_ini.getint('general', 'player_start_x')
        player_start_y = scene_ini.getint('general', 'player_start_y')
        player_start_position = (player_start_x, player_start_y)

        # Create a player using the player
        # start position found.
        human_player = self.create_human_player(player_start_position)

        # npcs.ini
        #
        # Create a list of NPCs using a configuration file
        # from the scene resource.
        npcs_ini = resource['npcs.ini']

        npcs = []

        # each section title is the npc's name,
        # each sections key/value pairs are
        # the NPC's attributes.
        for npc_name in npcs_ini.sections():

            if npcs_ini.has_option(npc_name, 'walkabout'):
                # The NPC's walkabout resource name
                walkabout_name = npcs_ini.get(npc_name, 'walkabout')

            # the required (x, y) pixel coordinates referring
            # to the position of this NPC
            position_x = npcs_ini.getint(npc_name, 'position_x')
            position_y = npcs_ini.getint(npc_name, 'position_y')
            position = (position_x, position_y)

            # create the NPC's walkabout using the
            # designated walkabout name and position
            # from the NPC's config.
            npc_walkabout = sprites.Walkabout(walkabout_name,
                                              position=position)

            if npcs_ini.has_option(npc_name, 'say'):
                # Load some say text for the NPC, so when
                # an actor uses talk() on them, they say
                # this message--the say_text!
                say_text = npcs_ini.get(npc_name, 'say')
            else:
                say_text = None

            npc = player.Npc(walkabout=npc_walkabout, say_text=say_text)
            npcs.append(npc)

        return Scene(tilemap=tilemap,
                     player_start_position=player_start_position,
                     human_player=human_player,
                     npcs=npcs)

    def collide_check(self, rect):
        """Returns True if there are collisions with rect.

        Args:
            rect (pygame.Rect): The area/rectangle which
                to test for collisions against NPCs and
                the tilemap's wallmap.

        Notes:
            Should use pygame.sprite.spritecollide()

        """

        possible_collisions = self.tilemap.impassable_rects

        for npc in self.npcs:
            possible_collisions.append(npc.walkabout.rect)

        return rect.collidelist(possible_collisions) != -1

    def runtime_setup(self):
        """Initialize all the NPCs, tilemap, etc.

        Is this a horrible way of doing this? I dunno,
        not the fondest...

        """

        npcs_to_setup = tuple(npc.walkabout for npc in self.npcs)
        objects_to_setup = (self.tilemap, self.human_player.walkabout,)
        objects_to_setup = objects_to_setup + npcs_to_setup

        for object_to_setup in objects_to_setup + npcs_to_setup:
            object_to_setup.runtime_setup()

    def render(self, viewport, clock):
        """Render this Scene onto viewport.

        Args:
            viewport (render.Viewport): The global/master viewport,
                where stuff will be blitted to. Also used for some
                calculations.
            clock (pygame.time.Clock): Global/master/the game
                clock used for timing in this game.

        """

        (self.tilemap.tilesheet.animated_tiles_group.
         update(clock, viewport.surface, viewport.rect.topleft))
        first_tilemap_layer = self.tilemap.layer_images[0]
        viewport.center_on(self.human_player.walkabout,
                           first_tilemap_layer.get_rect())
        viewport.blit(first_tilemap_layer)
        self.tilemap.blit_layer_animated_tiles(viewport, 0)

        # render each npc walkabout
        # should use group draw
        for npc in self.npcs:
            npc.walkabout.blit(clock,
                               viewport.surface,
                               viewport.rect.topleft)

        # finally human and rest map layers last
        self.human_player.walkabout.blit(clock,
                                         viewport.surface,
                                         viewport.rect.topleft)

        for i, layer in enumerate(self.tilemap.layer_images[1:], 1):
            viewport.blit(layer)
            self.tilemap.blit_layer_animated_tiles(viewport, i)


class TMX(object):
    """`TMX` object to represent and "translate"
    supported Scene data from a TMX file.

    TMX files are capable of providing the information
    required to instantiate TileMap and Scene.

    TMX file must have the following settings:

      * orientation: orthogonal
      * tile layer format: csv
      * tile render order: right down

    You must also specify the tilesheet name you want to use
    in Hypatia, as your tileset image name. You may only use
    one image.

    Constants:
        SUPPORTED (str): the TMX file format which is supported.

    Attributes:
        root (ElementTree): the XML ElementTree root of the TMX file.
        player_start_position (tuple): (x, y) coordinate in which
            the player begins this scene at.
        layers (list): a 3D list of tile IDs referring to a tile
            by id in a Tilesheet. This data is extrapolated from
            a CSV-format list of tile IDs.
        npcs (List[players.Npc]): --

    See Also:
        http://doc.mapeditor.org/reference/tmx-map-format/

    """

    SUPPORTED = '1.0'

    def __init__(self, path_or_readable):
        """Read XML from path_or_readable, validate the TMX as being
        supported by Hypatia, and set all supported information as
        attributes.

        Args:
            path_or_readable (str|file-like-object): This is
                plopped right into ElementTree.parse().

        Note:
            This method is under-documented!

        """

        # parse TMXML for TileMap-specific/supported data
        tree = ET.parse(path_or_readable)
        self.root = tree.getroot()  # <map ...>

        # check the version first, make sure it's supported
        map_version = self.root.attrib['version']

        if map_version != self.SUPPORTED:

            raise TMXVersionUnsupported(map_version)

        # Get the Tilesheet (tileset) name from the tileset
        tileset_images = self.root.findall('.//tileset/image')

        if len(tileset_images) > 1:

            # too many tilesets!
            raise TMXTooManyTilesheets()

        tileset = self.root.find('.//tileset')
        tilesheet_name = tileset.attrib['name']

        # get the 3D constructor/blueprint of TileMap,
        # which simply references, by integer, the
        # tile from tilesheet.
        layers = []

        for layer_data in self.root.findall(".//layer/data"):
            data_encoding = layer_data.attrib['encoding']

            if data_encoding != 'csv':

                raise TMXLayersNotCSV(data_encoding)

            layer_csv = layer_data.text.strip()
            rows = layer_csv.split('\n')
            parsed_rows = []

            for row in rows:
                # TMX tilesets start their ids at 1, Hypatia Tilesheets
                # starts ids at 0.
                cells = row.split(',')[:-1]  # trailing comma
                parsed_row = [int(tile_id) - 1 for tile_id in cells]
                parsed_rows.append(parsed_row)

            layers.append(parsed_rows)

        self.tilemap = tiles.TileMap(tilesheet_name, layers)

        # loop through objects in the object layer to find the player's
        # start position and NPC information.
        self.npcs = []
        self.player_start_position = None

        for tmx_object in self.root.findall(".//objectgroup/object"):
            object_type = tmx_object.attrib['type']
            x = int(tmx_object.attrib['x'])
            y = int(tmx_object.attrib['y'])

            if object_type == 'player_start_position':
                self.player_start_position = (x, y)
            elif object_type == 'npc':
                properties = tmx_object.find('properties')
                xpath = ".//property[@name='%s']"

                position = (x, y)
                walkabout_name = (properties.find(xpath % 'walkabout').
                                  attrib['value'])
                walkabout = sprites.Walkabout(walkabout_name, position)
                say_text = properties.find(xpath % 'say').attrib['value']

                npc = player.Npc(walkabout=walkabout, say_text=say_text)
                self.npcs.append(npc)

        # should use xpath before loading all npcs...
        if self.player_start_position is None:

            raise TMXMissingPlayerStartPosition()