view skaapsteker/sprites/base.py @ 623:65881746dc20

More Sprite hierarchy work.
author Jeremy Thurgood <firxen@gmail.com>
date Sat, 07 May 2011 13:59:00 +0200
parents da331c80ec08
children 83569a6b3ad8
line wrap: on
line source

"""Basic sprite classes."""

import re
import time

from pygame import Rect
import pygame.transform

from .. import data
from .. import dialogue
from .. import sound
from ..physics import Sprite
from ..constants import Layers
from ..engine import OpenDialog, AddSpriteEvent, OpenNotification
from ..utils import cadd, csub, cmul, cdiv


TILE_SIZE = (64, 64)

# Collision Layers (values are ids not numbers)
PC_LAYER = 0
MONSTER_LAYER = 1
NPC_LAYER = 2
PROJECTILE_LAYER = 3

def notify(text):
    OpenNotification.post(text=text)


def tile_midbottom(pos):
    return cadd(cmul(pos, TILE_SIZE), cdiv(TILE_SIZE, (2, 1)))


class GameSprite(Sprite):
    image_dir = 'sprites/'
    image_file = None
    sprite_layer = None
    debug_color = (240, 0, 0)

    def __init__(self, pos, **opts):
        Sprite.__init__(self)
        if self.sprite_layer is not None:
            # pygame's Sprite class clobbers self._layer in __init__(), so we need to thwart it.
            self._layer = self.sprite_layer
        self.setup_image_data(pos, **opts)
        self.setup(**opts)

    def setup(self):
        pass

    def get_tile_pos(self):
        return cdiv(self.rect.center, TILE_SIZE)

    def setup_image_data(self, pos, **opts):
        self.image = data.load_image(self.image_dir + self.image_file)
        self.rect = self.image.get_rect(midbottom=tile_midbottom(pos))
        self.collide_rect = self.rect.move(0, 0)

    def get_debug_color(self):
        return self.debug_color

    def draw_debug(self, surface):
        pygame.draw.rect(surface, self.get_debug_color(), self.collide_rect, 1)


class AnimatedGameSprite(GameSprite):
    # first item is the starting animation
    animation_regexes = (
        # TODO: swap back once we know how to swap
        ("running", r"^.*_\d+.png$"),
        ("standing", r"^.*_standing.png$"),
        ("attacking", r"^.*_attacking.png$"),
    )

    wants_updates = True
    frame_pause = 0.1  # default time between animation frames
    facings = None


    def setup_image_data(self, pos, **opts):
        self._animations = dict((k, []) for k, r in self.animation_regexes)
        self._frame = 0
        self._last_time = 0
        self._animation = self.animation_regexes[0][0]
        if self.facings and self._animation in self.facings:
            self.facing = self.facings[self._animation][0][0]
        else:
            self.facing = None

        for image in data.get_files(self.image_dir):
            for name, pattern in self.animation_regexes:
                if re.match(pattern, image):
                    img = data.load_image("%s/%s" % (self.image_dir, image))
                    if self.facings and name in self.facings:
                        if not self._animations[name]:
                            self._animations[name] = dict((k, []) for k, t in self.facings[name])
                        for facing, transform in self.facings[name]:
                            if transform:
                                mod_img = transform(img)
                            else:
                                mod_img = img
                            collide_rect = mod_img.get_bounding_rect(1).inflate(-2,-2)
                            self._animations[name][facing].append((mod_img, collide_rect))
                    else:
                        collide_rect = img.get_bounding_rect(1).inflate(-2,-2)
                        self._animations[name].append((img, collide_rect))

        self.collide_rect = Rect((0, 0), (2, 2))
        if isinstance(pos, pygame.Rect):
            self.collide_rect.midbottom = pos.midbottom
        else:
            self.collide_rect.midbottom = tile_midbottom(pos)
        self._update_image()


    def _update_image(self, force=False):
        if self.facing:
            images = self._animations[self._animation][self.facing]
        else:
            images = self._animations[self._animation]
        if self._frame >= len(images):
            self._frame = 0
        cand_image, cand_collide_rect = images[self._frame]
        cand_collide_rect = cand_collide_rect.move(0, 0) # copy collide rect before we move it

        cur_pos = self.collide_rect.midbottom

        cand_rect = cand_image.get_rect()
        cand_rect_offset = csub(cand_rect.midbottom, cand_collide_rect.midbottom)
        cand_rect.midbottom = cadd(cur_pos, cand_rect_offset)
        cand_collide_rect.midbottom = cur_pos

        if not self.check_collide_rect(cand_collide_rect, cand_rect, cand_image) and not force:
            return

        self.image = cand_image
        self.collide_rect = cand_collide_rect
        self.rect = cand_rect
        self.rect_offset = cand_rect_offset
        self.init_pos()

    def update(self):
        if self._last_time is not None:
            if time.time() - self._last_time > self.frame_pause:
                self._frame += 1
                self._last_time = time.time()
                force = False
                if self._animation == 'attacking':
                    force = True
                self._update_image(force)
        else:
            self._last_time = time.time()


class Monster(AnimatedGameSprite):

    sprite_layer = Layers.PLAYER
    collision_layer = MONSTER_LAYER
    collides_with = set([PC_LAYER, PROJECTILE_LAYER])

    debug_color = (240, 120, 120)

    block = True

    attack_frame = None  # Mark a speficic frame in the animation as when the attack lands
    attack_damage = 1

    def __init__(self, pos, **opts):
        AnimatedGameSprite.__init__(self, pos, **opts)
        self.floor_rect = Rect(self.collide_rect.topleft, (self.collide_rect.width, 2))
        self.health = 10
        self._done_attack = False
        self.setup(**opts)

    def setup(self, fishmonger_count=False):
        self.fishmonger_count = fishmonger_count

    def collided_player(self, player):
        self.start_attack(player)

    def update(self):
        AnimatedGameSprite.update(self)
        if self._animation == 'attacking':
            if self._frame == 0 and self._done_attack:
                # We've just looped through the animation sequence
                self._animation = self._old_state
                self.facing = self._old_facing
                self._update_image(True)
            elif self._frame == self.attack_frame and not self._done_attack:
                # Attack the player
                self.do_attack()

    def _launch_projectile(self, cls):
        from .player import Player # avoid circular imports
        if self.facing == 'left':
            pos = pygame.Rect(self.rect.midleft, (0, 0))
        else:
            pos = pygame.Rect(self.rect.midright, (0, 0))
        projectile = cls(pos, direction=self.facing, hits=Player, source=self)
        projectile.launch()
        AddSpriteEvent.post(projectile)

    def do_attack(self):
        """Overriden by monster classes"""
        if self.check_collides(self._target):
            self._target.damage(self.attack_damage)
        self._done_attack = True

    def start_attack(self, player):
        if player.invisible:
            return
        if self._animation == 'attacking':
            return # We're already attacking
        elif self.attack_frame is not None:
            self._done_attack = False
            self._target = player
            self._old_state = self._animation
            self._old_facing = self.facing
            self._animation = 'attacking'
            self._frame = 0  # Start the attack from the beginning
            self._update_image(True)
            self._last_time = self._start_attack_time = time.time()
        else:
            player.damage(1)  # collision damage

    def damage(self, damage):
        self.health -= damage
        if self.health <= 0:
            AddSpriteEvent.post(Skeleton(self.rect.midbottom))
            self.kill()
            if self.fishmonger_count:
                self.world.missions.fishmonger_demons_killed += 1

class PatrollingMonster(Monster):
    """Monster that collides with horizontal geography"""

    debug_color = (120, 240, 120)

    patrol_speed = (200, 0)

    def update(self):
        Monster.update(self)
        if self._animation == 'running':
            if self.facing == 'left':
                self.velocity = (-self.patrol_speed[0], 0)
            elif self.facing == 'right':
                self.velocity = self.patrol_speed

    def collided(self, other):
        Monster.collided(self, other)
        # Check if the object we've collided with is the same height our higher than us
        if (other.block or other.floor) and other.collide_rect.bottom <= self.collide_rect.bottom:
            # Change direction
            self.change_facing()

    def change_facing(self):
        if self.facing == 'left':
            self.facing = 'right'
            self._update_image(True)
        else:
            self.facing = 'left'
            self._update_image(True)

    def check_floors(self, floors):
        """If we're only on 1 floor tile, and our centre is beyond half way,
           turn back"""
        if len(floors) != 1:
            return

        floor = floors[0]

        if self.facing == 'left':
            if self.collide_rect.centerx < floor.collide_rect.centerx:
                self.change_facing()
        else:
            if self.collide_rect.centerx > floor.collide_rect.centerx:
                self.change_facing()

class NPC(AnimatedGameSprite):

    sprite_layer = Layers.PLAYER
    collision_layer = NPC_LAYER
    collides_with = set([])

    debug_color = (240, 240, 240)
    bounce_factor = (0, 0)    #  NPC's don't bounce by default
    gravitates = False

    block = False
    actionable = True

    def setup(self, name, world, dsm, state, facing=None):
        self.name = name
        self.world = world
        self.dsm = dialogue.DSM(name, world, dsm, state)
        self._me = getattr(world.npcs, self.name)
        self.facing = facing
        self._update_image(True)  # Force things to the right image

    def player_action(self, player):
        OpenDialog.post(self)

    def remove(self):
        self._me.level = '_limbo'
        self.kill()


class BlockingNPC(NPC):

    collides_with = set([PC_LAYER])
    mobile = False
    block = True

    def setup(self, name, world, dsm, state, block, facing=None):
        NPC.setup(self, name, world, dsm, state, facing)
        self.block = block
        self._animation = 'standing'


class Projectile(AnimatedGameSprite):

    collision_layer = PROJECTILE_LAYER
    collides_with = set()
    launch_sound = None, None

    gravitates = False

    DAMAGE = 10

    PROJECTILE_SIZE = (0, 0) # pixels
    VELOCITY = (10, 10) # pixels/s

    def setup(self, direction, hits, source, **opts):
        super(Projectile, self).setup(**opts)
        self.facing = direction
        self.source = source # source of the projectile (may be None)
        self._update_image(True)  # ensure we get the direction right
        if self.launch_sound[0]:
            sound.load_sound(self.launch_sound[0], self.launch_sound[0], self.launch_sound[1])

        if isinstance(hits, tuple):
            self.hits = hits + (Geography,)
        else:
            self.hits = (hits, Geography)

        if self.facing == "left":
            shift = cdiv(self.PROJECTILE_SIZE, (-2, 1))
            dv = cmul(self.VELOCITY, (-1, 1))
        else:
            shift = cdiv(self.PROJECTILE_SIZE, (2, 1))
            dv = cmul(self.VELOCITY, (1, 1))

        self.rect.move_ip(shift)
        self.collide_rect.move_ip(shift)
        self.deltav(dv)

    def launch(self):
        if self.launch_sound[0]:
            sound.play_sound(self.launch_sound[0])

    def explode(self):
        self.kill()

    def collided(self, other):
        if not isinstance(other, self.hits):
            return
        if hasattr(other, 'damage'):
            other.damage(self.DAMAGE)
            if hasattr(self.source, 'steal_life'):
                self.source.steal_life(self.DAMAGE)
        self.explode()


class Item(GameSprite):
    mobile = False
    gravitates = False
    actionable = True
    liftable = True

    sprite_layer = Layers.PLAYER
    collision_layer = NPC_LAYER

    debug_color = (240, 0, 240)


    def setup(self, name, world):
        self.name = name
        self.world = world
        self._me = getattr(self.world.items, self.name)


    def player_action(self, player):
        if self.liftable:
            player.take_item(self)


    def remove(self):
        self._me.level = '_limbo'
        self.kill()


class Geography(GameSprite):
    mobile = False
    gravitates = False
    collides_with = set([PC_LAYER, MONSTER_LAYER, NPC_LAYER, PROJECTILE_LAYER])
    is_ground = True
    actionable = False
    bounce_factor = (0.0, 0.0)

    def setup(self, image):
        pass

    def setup_image_data(self, pos, image, **opts):
        self.tile_pos = pos
        self.image = image
        self.collide_rect = self.image.get_bounding_rect(1)
        self.floor_rect = Rect(self.collide_rect.topleft, (self.collide_rect.width, 2))
        self.rect = self.image.get_rect()
        self.rect_offset = csub(self.collide_rect.topleft, self.rect.topleft)
        self.rect.topleft = cmul(pos, TILE_SIZE)
        self.collide_rect.topleft = cadd(self.rect.topleft, self.rect_offset)
        self.floor_rect.topleft = cadd(self.rect.topleft, self.rect_offset)

    def get_debug_color(self):
        if self.floor or self.block:
            return (240, 240, 0)
        return (0, 240, 0)



class Doorway(GameSprite):
    mobile = False
    gravitates = False

    blocks = False
    actionable = True

    image_file = 'torii.png'


    def setup_image_data(self, pos, **opts):
        super(Doorway, self).setup_image_data(pos, **opts)
        self.image = pygame.transform.scale(self.image, self.image.get_rect().center)
        self.rect = self.image.get_rect(midbottom=self.rect.midbottom)
        self.collide_rect = self.rect


    def setup(self, facing, leadsto):
        self.facing = facing
        self.leadsto = leadsto


    def player_action(self, player):
        from .. import engine, levelscene
        engine.ChangeScene.post((levelscene.LevelScene, self.leadsto))



class CelestialDoorway(Doorway):
    def player_action(self, player):
        from .. import engine
        if len(self.world.fox.tails) < 8:
            engine.OpenNotification.post(text="You need eight tails to enter the Celestial Plane.")
            return
        super(CelestialDoorway, self).player_action(player)



class StartingDoorway(Doorway):
    actionable = False

    def setup_image_data(self, pos, **opts):
        self.image = pygame.Surface((0, 0))
        self.rect = self.image.get_rect(midbottom=tile_midbottom(pos))
        self.collide_rect = self.rect.move(0, 0)


    def setup(self, facing):
        Doorway.setup(self, facing, None)


class Skeleton(GameSprite):
    mobile = False
    gravitates = False
    actionable = False
    liftable = False
    image_dir = 'sprites/skulls/'
    sprite_layer = Layers.BEHIND
    debug_color = (255, 255, 0)


    def __init__(self, pos, player=False, **opts):
        self._pos = pos
        if player:
            self.image_file = 'kitsune.png'
        else:
            self.image_file = 'monster.png'
        super(Skeleton, self).__init__(pos, **opts)

    def setup_image_data(self, pos, **opts):
        super(Skeleton, self).setup_image_data(pos, **opts)
        # Pixel based rect, not tile:
        self.rect = self.image.get_rect(midbottom=self._pos)
        self.collide_rect = self.rect.move(0, 0)


def find_sprite(descr, mod_name=None):
    """Create a sprite object from a dictionary describing it."""
    descr = dict((str(k), v) for k, v in descr.items()) # convert unicode keys
    cls_name = descr.pop("type")
    if mod_name is None:
        mod_name, cls_name = cls_name.rsplit(".", 1)
    mod_name = ".".join(["skaapsteker.sprites", mod_name])
    mod =  __import__(mod_name, fromlist=[cls_name])
    cls = getattr(mod, cls_name)
    return cls(**descr)