hypatia-software-organization/hypatia-engine

View on GitHub
hypatia/sprites.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

"""Sprites!

Tools for animation. Animation sources are GIFs from disk, which
have been made into an AnimatedSprite object. Stateful animations
which represent objects, e.g., :class:`Walkabout` represents an
:class:`actor.Actor`.

Examples of "tools":

  * functions for creating an animation from a single suface
  * loading animations from disk
  * adding frame-specific positional data
  * contextually-aware sprites

Warning:
    Sometimes an "animation" can consist of one frame.

Note:
    I wanna add support for loading character animations
    from sprite sheets.

See Also:

    * :mod:`resources`
    * :mod:`actor`
    * :class:`Walkabout`

"""

import os
import copy
import itertools
import collections

try:
    import ConfigParser as configparser
except ImportError:
    import configparser

import pygame
from PIL import Image

from hypatia import constants
from hypatia import resources
from hypatia import animatedsprite


class BadWalkabout(Exception):
    """Walkabout Resource specified does not contain any
    GIF files (AnimatedSprite) for creating a Walkabout sprite.

    Used in Walkabout when no files match "*.gif"
    in the provided Resource.

    Attributes:
        failed_name (str): The supplied archive was appended to the
            resources' walkabout direction. This is the value of
            the attempted which resulted in KeyError.

    See Also:
        * Walkabout.__init__()
        * resources.Resource

    """

    def __init__(self, failed_name):
        """Set the exception message and "failed_name" attribute
        to the provided failed_name argument.

        Args:
            failed_name (str): :class:`Walkabout` resource archive
                which *should* have contained files of pattern
                ``*.gif,`` but didn't.

        """

        super(BadWalkabout, self).__init__(failed_name)
        self.failed_name = failed_name


class Walkabout(pygame.sprite.Sprite):
    """Sprite animations for a character which walks around.

    Contextually-aware graphical representation.

    The walkabout sprites specified to be therein
    walkabout_directory, are files with an action__direction.gif
    filename convention.

    Blits its children relative to its own anchor.

    Attributes:
        resource (Resource): --
        animations (dict): 2D dictionary [action][direction] whose
            values are PygAnimations.
        animation_anchors (dict): 2D dictionary [action][direction]
            whose values are AnimAnchors.
        rect (pygame.Rect): position on tilemap
        size (tuple): the size of the animation in pixels.
        action (constants.Action): --
        direction (constnts.Direction): --
        topleft_float (x,y tuple): --
        position_rect

    See Also:
        * actor.Actor: The Walkabout class represents an
        actor object!

    """

    def __init__(self, directory, position=None, children=None):
        """

        Args:
            directory (str): directory containing (animated)
            walkabout GIFs. Assumed parent is data/walkabouts/
            position (tuple): (x, y) coordinates (integers)
                referring to absolute pixel coordinate.
            children (list|None): Walkabout objects drawn relative to
                this Walkabout instance.

        Example:
            >>> debug = Walkabout('debug')
            >>> Walkabout('debug', position=(44, 55), children=[debug])
            <Walkabout sprite(in ... groups)>

        """

        super(Walkabout, self).__init__()

        # the attributes we're generating
        self.animations = {}
        self.animation_anchors = {}
        self.actions = []
        self.directions = []
        self.size = None  # will be removed in future?

        if not position:
            position = (0, 0)

        topleft_float = (float(position[0]), float(position[1]))

        # specify the files to load
        # how will i glob a resource
        resource = resources.Resource('walkabouts', directory)
        sprite_files = resource.get_type('.gif')

        # no sprites matching pattern!
        if not sprite_files:

            raise BadWalkabout(directory)

        for sprite_path in sprite_files.keys():
            file_name, file_ext = os.path.splitext(sprite_path)
            file_name = os.path.split(file_name)[1]

            if file_name == 'only':
                action = constants.Action.stand
                direction = constants.Direction.south

            else:
                action, direction = file_name.split('_', 1)
                direction = getattr(constants.Direction, direction)
                action = getattr(constants.Action, action)

            self.actions.append(action)
            self.directions.append(direction)

            # load pyganim from gif file
            animation = sprite_files[sprite_path]

            try:
                self.animations[action][direction] = animation
            except KeyError:
                self.animations[action] = {direction: animation}

        # If the last file_name from the above for loop is "only,"
        # that means that that name denotes the fact that there is
        # ONLY one sprite for the supplied Walkabout resource.
        if file_name == 'only':

            # We want to set all the appropriate action/direction
            # animations in the self.animations dictionary to the
            # only animation provided, which was assigned as the
            # "Stand South" animation.
            animation = (self.animations[constants.Action.stand][
                         constants.Direction.south])

            for action in constants.Action.all():

                for direction in constants.Direction.cardinals_and_ordinals():

                    # We set everything to the Stand South animation since
                    # it was set first.
                    try:
                        self.animations[action][direction] = animation
                    except KeyError:
                        self.animations[action] = {direction: animation}

        # ... set the rest of the attribs
        self.resource = resource

        # NOTE: this is lazy and results in smaller frames
        # having a bunch of "padding"
        self.size = animation.largest_frame_size()

        self.rect = pygame.Rect(position, self.size)
        self.topleft_float = topleft_float
        self.action = constants.Action.stand
        self.direction = constants.Direction.south
        self.child_walkabouts = children or []

        self.image = self.animations[self.action][self.direction]

    def __getitem__(self, key):
        """Fetch sprites associated with action (key).

        Args:
            key (constants.Action): return dictionary of
                sprites for this action (key).

        Returns:
            dict: sprites associated with action supplied (key)

        Examples:
            >>> walkabout = Walkabout('debug')
            >>> walkabout[constants.Action.walk][constants.Direction.south]
            <AnimatedSprite sprite(in ... groups)>

        """

        return self.animations[key]

    def current_animation(self):
        """Returns the animation selected by the current action
        and direction.

        Returns:
            PygAnim: the animation associated with this Walkabout's
                current action and direction.

        Example:
            >>> walkabout = Walkabout('debug')
            >>> walkabout.current_animation()
            <AnimatedSprite sprite(in ... groups)>

        """

        return self.animations[self.action][self.direction]

    def update(self, clock, screen, offset):
        """Call this once per main loop iteration (tick). Advance
        the active animation's frame according to the clock, use
        said surface/image/frame as this Walkabout's "image" attribute.

        Args:
            clock (pygame.time.Clock): The system clock. Typically
                and defaultly the game.screen.clock. It will control
                the animation. Time is a key factor in updating the
                animations.
            screen (???): I think I'm actually sending the
                viewport, here, I'm not sure? Will touch up later.

        See Also:
            * Walkabout.current_animation()
            * animatedsprite.AnimatedSprite
            * animatedsprite.AnimatedSprite.update()
            * pygame.time.Clock

        """

        active_animation = self.current_animation()
        active_animation.update(clock,
                                self.topleft_float,
                                screen)
        self.image = active_animation.image

    def blit(self, clock, screen, offset):
        """Draw the appropriate/active animation to screen.

        Args:
            clock (pygame.time.Clock): The system clock. Typically
                and defaultly the game.screen.clock. It will control
                the animation. Time is a key factor in updating the
                animations.
            screen (pygame.Surface): the primary display/screen.
            offset (x, y tuple): the x, y coords of the absolute
                starting top left corner for the current
                screen/viewport position.

        Note:
            All sprites will be sync'd because of how clock
            ticks work. The clock is ticked once per main
            loop iteration, and animations are advanced by
            getting the difference between two ticks.

        """

        # `position_on_screen` is the Walkabout sprite's
        # position ON SCREEN.
        #
        # `position_on_screen` is derived from the absolute
        # position of this Walkabout, i.e., the `topleft_float`
        # attribute, being subtracted by the provided `offset`.
        x, y = self.topleft_float
        x -= offset[0]
        y -= offset[1]
        # sprite position on viewport
        # There are no half-pixels, thus we don't use floats.
        position_on_screen = (int(x), int(y))

        # Update the state of the current animation. This affects
        # this Walkabout's `image` property.
        #
        # See: Walkabout.update()
        self.update(clock, screen, offset)

        # Blit the current image for this Walkabout to the
        # supplied viewport surface (`screen`) at the supplied
        # `position_on_screen`, which we figured out earlier.
        screen.blit(self.image, position_on_screen)

        # Render and update child walkabouts. Render a child
        # Walkabout so that its head anchor occupies the same
        # position as its parent head anchor (THIS Walkabout).
        #
        # This means getting the difference between the following
        # child anchors and THIS Walkabout's (parent) anchors and
        # using said difference as the offset for the child
        # Walkabout sprite/animation.
        current_frame = self.current_animation().active_frame
        parent_anchor = current_frame.anchors['head_anchor']
        # Adjust the parent anchor to consider the
        # position on screen for child walkabout
        # anchor calculations.
        parent_anchor = parent_anchor + position_on_screen

        for child_walkabout in self.child_walkabouts:
            # We update the current animation to reflect this
            # Walkabout's current action and direction.
            child_walkabout.action = self.action
            child_walkabout.direction = self.direction

            # Get and update this child walkabout's
            # current animation.
            child_active_anim = child_walkabout.current_animation()
            child_active_anim.update(clock,
                                     self.topleft_float,
                                     screen)

            # Now that the child walkabout's current animation
            # has been updated, get the active frame of the
            # child animation in order to find its head anchor.
            child_active_frame = child_active_anim.active_frame
            child_frame_anchor = child_active_frame.anchors['head_anchor']

            # As aforementioned, resolve the child Walkabout's
            # position by subtracting the child's anchor from
            # the adjusted parent anchor.
            child_position = (parent_anchor - child_frame_anchor).as_tuple()
            screen.blit(child_active_anim.image, child_position)

    def runtime_setup(self):
        """Perform actions to setup the walkabout. Actions performed
        once pygame is running and walkabout has been initialized.

        Convert and play all the animations, run init for children.

        Note:
            It MAY be bad to leave the sprites in play mode in startup
            by default.

        """

        if len(self.animations) == 1:
            actions = (constants.Action.stand,)
            directions = (constants.Direction.south,)

        else:
            actions = (constants.Action.walk, constants.Action.stand)
            directions = (constants.Direction.north, constants.Direction.south,
                          constants.Direction.east, constants.Direction.west)

        for action in actions:

            for direction in directions:
                animated_sprite = self.animations[action][direction]
                animated_sprite.convert_alpha()

        for walkabout_child in self.child_walkabouts:
            walkabout_child.runtime_setup()


def palette_cycle(surface):
    """get_palette is not sufficient; it generates superflous colors.

    Note:
      Need to see if I can convert 32bit alpha to 8 bit temporarily,
      to be converted back at end of palette/color manipulations.

    """

    original_surface = surface.copy()  # don't touch! used for later calc
    width, height = surface.get_size()
    ordered_color_list = []
    seen_colors = set()

    for coordinate in itertools.product(range(0, width), range(0, height)):
        color = surface.get_at(coordinate)
        color = tuple(color)

        if color in seen_colors:

            continue

        ordered_color_list.append(color)
        seen_colors.add(color)

    # reverse the color list but not the pixel arrays, then replace!
    old_color_list = collections.deque(ordered_color_list)
    new_surface = surface.copy()
    frames = []

    for rotation_i in range(len(ordered_color_list)):
        new_surface = new_surface.copy()

        new_color_list = copy.copy(old_color_list)
        new_color_list.rotate(1)

        color_translations = dict(zip(old_color_list, new_color_list))

        # replace each former color with the color from newcolor_list
        for coordinate in itertools.product(range(0, width), range(0, height)):
            color = new_surface.get_at(coordinate)
            color = tuple(color)
            new_color = color_translations[color]
            new_surface.set_at(coordinate, new_color)

        frame = new_surface.copy()
        frames.append((frame, 250))
        old_color_list = copy.copy(new_color_list)

    return animatedsprite.AnimatedSprite.from_surface_duration_list(frames)