# --------------------------------------------------------------
#
# spygame (pygame based 2D game engine for the openAI gym)
#
# created: 2017/04/04 in PyCharm
# (c) 2017 Sven Mika - ducandu GmbH
#
# --------------------------------------------------------------
from abc import ABCMeta, abstractmethod
import xml.etree.ElementTree
import pygame
import os.path
from itertools import chain
from typing import Union
import types
import pytmx
import sys
import math
import re
import numpy as np
import functools
VERSION_ = '0.1'
RELEASE_ = '0.1a9'
# some debug flags that we can set to switch on debug rendering, collision handling, etc..
DEBUG_NONE = 0x0 # no debugging
DEBUG_ALL = 0xffff # full debugging
# will not render TiledTileLayers that are marked as 'do_render'==true in the tmx files
DEBUG_DONT_RENDER_TILED_TILE_LAYERS = 0x1
# will render all collision tiles (those layers that have a type) with a square frame and - when being considered - filled green
DEBUG_RENDER_COLLISION_TILES = 0x2
DEBUG_RENDER_COLLISION_TILES_COLOR_DEFAULT = pygame.Color("red")
DEBUG_RENDER_COLLISION_TILES_COLOR_OTHER = pygame.Color("cyan")
# render the tiles currently under consideration for colliding with a sprite
DEBUG_RENDER_ACTIVE_COLLISION_TILES = 0x4
DEBUG_RENDER_ACTIVE_COLLISION_TILES_COLOR = pygame.Color("green")
DEBUG_RENDER_ACTIVE_COLLISION_TILES_COLOR_GREYED_OUT = pygame.Color("grey")
# will render all Sprites (even those without an image (e.g. when blinking) with a rectangular frame representing the Sprite's .rect property
DEBUG_RENDER_SPRITES_RECTS = 0x8
DEBUG_RENDER_SPRITES_RECTS_COLOR = pygame.Color("orange")
# will render every Sprite before the Sprite's tick method was called
DEBUG_RENDER_SPRITES_BEFORE_EACH_TICK = 0x10
DEBUG_RENDER_SPRITES_AFTER_EACH_TICK = 0x20
# will render every Sprite before the Sprite's collision detection algo runs
DEBUG_RENDER_SPRITES_BEFORE_COLLISION_DETECTION = 0x40
# by default, no debugging (you can set this through a Game's c'tor using the debug_flags kwarg)
DEBUG_FLAGS = DEBUG_NONE
[docs]class EventObject(object):
"""
An EventObject introduces event handling and most objects that occur in spygame games will inherit from this class.
NOTE: spygame events are not(!) pygame events.
EventObject can 'have' some events, which are simple strings (the names of the events, e.g. 'hit', 'jump', 'collided', etc..).
EventObject can trigger any event by their name.
If an EventObject wants to trigger an event, this event must have been registered with the EventObject beforehand (will raise exception otherwise).
"""
def __init__(self):
# - listeners keeps a list of callbacks indexed by event name for quick lookup
# - a listener is an array of 2 elements: 0=target, 1=callback
self.listeners = {} # keys=event names; values=list of 2 elements (0=target object, 1=callback method)
# stores all valid event names; that way, we can check validity of event when subscribers subscribe to some event
self.valid_events = set()
[docs] def register_event(self, *events):
"""
registers a possible event (str) with this object; only registered events are allowed to be triggered later
:param str events: the event (or events) that should be registered
"""
for event in events:
self.valid_events.add(event)
[docs] def unregister_event(self, *events):
"""
Removes one or more events from this EventObject's event registry; unregistered events are no longer allowed to be triggered.
:param str events: the event(s) that should be removed from the registry
"""
self.valid_events.remove(event)
[docs] def unregister_events(self):
"""
Unregisters all events from this GameObject (see 'unregister_event').
"""
self.valid_events.clear()
[docs] def check_event(self, event: str):
"""
Checks whether the given event is in this EventObject's registry (raises exception if not).
:param str event: the event to be checked
"""
# make sure the event is valid (registered)
if event not in self.valid_events:
raise Exception("ERROR: event '{}' not valid in this EventObject ({}); event has not been registered!".format(event, type(self).__name__))
[docs] def on_event(self, event, target=None, callback=None, register=False):
"""
Binds a callback to an event on this EventObject.
If you provide a `target` object, that object will add this event to it's list of binds, allowing it to automatically remove it when
it is destroyed.
From here on, if the event gets triggered, the callback will be called on the target object.
Note: Only previously registered events may be triggered (we can register the event here by setting register=True).
:param Union[str,List[str]] event: the name of the event to be bound to the callback (e.g. tick, got_hit, etc..)
:param target (EventObject): The target object on which to call the callback (defaults to self if not given)
:param callable callback: the bound method to call on target if the event gets triggered
:param bool register: whether we should register this event right now (only registered events are allowed to be triggered later)
"""
if register:
self.register_event(event)
else:
self.check_event(event) # checks whether it's already registered
# more than one event given
if isinstance(event, list):
for i in range(len(event)):
self.on_event(event[i], target, callback)
return
# handle the case where there is no target provided, swapping the target and callback parameters
if not callback:
callback = target
target = None
# if there's still no callback, default to the event name
if not callback:
callback = event
# handle case for callback that is a string, this will pull the callback from the target object or from this object
if isinstance(callback, str):
callback = getattr(target or self, callback)
# listener is an array of 2 elements: 0=target, 1=callback
if event not in self.listeners:
self.listeners[event] = []
self.listeners[event].append([target or self, callback])
# with a provided target, the events bound to the target, so we can erase these events if the target no longer exists
if target:
if not hasattr(target, "binds"):
target.event_binds = []
target.event_binds.append([self, event, callback])
# TODO: good debugging: warn if a registered event doesn't get triggered for a long time?
[docs] def trigger_event(self, event, *params):
"""
Triggers an event and specifies the parameters to be passed to the bound event handlers (callbacks) as \*params.
:param str event: the name of the event that should be triggered; note: this event name will have to be registered with the EventObject
in order for the trigger to succeed
:param any params: the parameters to be passed to the handler methods as \*args
"""
self.check_event(event)
# make sure there are any listeners for this specific event, if not, early out
if event in self.listeners:
# call each listener in the context of either the target passed into `on_event` ([0]) or the object itself
for listener in self.listeners[event]:
listener[1](*params)
[docs] def off_event(self, event, target=None, callback=None, unregister=False):
"""
Unbinds an event from a target/callback.
Can be called with 1, 2, or 3 parameters, each of which unbinds a more specific listener.
:param str event: the name of the event to unbind from the callback
:param EventObject target: the target EventObject to unbind this event from (callback would be a member of this target)
:param callable callback: the callback to unbind the event from
:param bool unregister: whether we should unregister this event as well
"""
if unregister:
self.unregister_event(event)
else:
self.check_event(event)
# without a target, remove all the listeners
if not target:
if hasattr(self, "listeners") and event in self.listeners:
del self.listeners[event]
else:
# if the callback is a string, find a method of the same name on the target
if isinstance(callback, str) and hasattr(target, callback):
callback = getattr(target, callback)
if hasattr(self, "listeners") and event in self.listeners:
l = self.listeners[event]
# loop from the end to the beginning, which allows us to remove elements without having to affect the loop
for i in range(len(l) - 1, -1, -1):
if l[i][0] is target:
if not callback or callback is l[i][1]:
l.pop(i)
[docs] def debind_events(self):
"""
Called to remove any listeners from this object.
E.g. when this object is destroyed you'll want all the event listeners to be removed from this object.
"""
if hasattr(self, "event_binds"):
for source, event, _ in self.event_binds:
source.off_event(event, self)
# can handle events as well as
[docs]class State(EventObject):
"""
A simple state class that serves as a dict with settable and gettable key/value pairs.
Setting a new value will trigger "changed"+key events.
"""
def __init__(self):
super().__init__()
self.dict = {}
# sets a value in our dict and triggers a changed event
[docs] def set(self, key, value, trigger_event=False):
# trigger an event that the value changed
if trigger_event:
old = self.dict[key] if key in self.dict else None
self.trigger_event("changed." + key, value)
# set to new value
self.dict[key] = value
# retrieve a value from the dict
[docs] def get(self, key):
if key not in self.dict:
raise (Exception, "ERROR: key {} not in dict!".format(key))
return self.dict[key]
# decrease value by amount
[docs] def dec(self, key, amount: int = 1):
self.dict[key] -= amount
# increase value by amount
[docs] def inc(self, key, amount: int = 1):
self.dict[key] += amount
[docs]class GameObject(EventObject):
"""
A GameObject adds the capability to add one or more Component objects to the GameObject
(e.g. animation, physics, etc..).
Component objects are stored by their name in the GameObject.components dict.
"""
# stores all GameObjects by a unique int ID
id_to_obj = {}
next_id = 0
def __init__(self):
super().__init__()
self.components = {} # dict of added components by component's name
self.is_destroyed = False
self.id = GameObject.next_id
GameObject.id_to_obj[self.id] = self
GameObject.next_id += 1
# register events that need to trigger (later)
self.register_event("destroyed")
[docs] def add_component(self, component):
"""
Adds a component object to this GameObject -> calls the component's added method.
:param Component component: component to be added to GameObject under game_obj.components[component.name]
:return: the same Component for chaining
:rtype: Component
"""
component.game_object = self
assert component.name not in self.components, "ERROR: component with name {} already exists in Entity!".format(component.name)
self.components[component.name] = component
component.added()
return component
[docs] def remove_component(self, component):
"""
Removes the given component from this GameObject.
:param Component component: the Component object to be removed
"""
assert component.name in self.components, "ERROR: component with name {} does no exist in Entity!".format(component.name)
# call the removed handler (if implemented)
component.removed()
# only then erase the component from the GameObject
del self.components[component.name]
[docs] def destroy(self):
"""
Destroys the GameObject by calling debind and removing the object from it's parent.
Will trigger a `destroyed` event (callback).
"""
# we are already dead -> return
if self.is_destroyed:
return
# debind events where we are the target
self.debind_events()
self.is_destroyed = True
# tell everyone we are done
self.trigger_event("destroyed")
# remove ourselves from the id_to_obj dict
del GameObject.id_to_obj[self.id]
[docs] def tick(self, game_loop):
"""
A tick (coming from the GameObject containing Stage).
Override this if you want your GameObject to do something each frame.
:param GameLoop game_loop: the GameLoop that's currently playing
"""
pass
[docs]class SpriteSheet(object):
"""
Represents a spritesheet loaded from a tsx file.
Stores each single image (as pygame.Surface) in the sheet by its position.
Allows for already doing flip transformations (x/y and/or both axes) so we save time during the game.
Stores single tile properties in tile_props_by_id dict (only for those tiles that actually have custom properties defined in the tsx file).
"""
def __init__(self, file, store_flips=None):
"""
:param str file: the tsx file name to be loaded into this object
:param dict store_flips: dictionary ({"x": [True|False], "y": [True|False]}) with the flip-options; None for default (only x)
"""
try:
tree = xml.etree.ElementTree.parse(file)
except:
raise Exception("ERROR: could not open tsx(xml) file: {}".format(file))
elem = tree.getroot()
props = elem.attrib
self.name = props["name"]
self.tw = int(props["tilewidth"])
self.th = int(props["tileheight"])
assert "tilecount" in props, "ERROR: no `tilecount` property in properties of tsx file: `{}`!".format(file)
self.count = int(props["tilecount"])
self.cols = int(props["columns"])
self.tiles = [] # the list of all Surfaces
self.tiles_flipped_x = [] # the list of all Surfaces (flipped on x-axis)
self.tiles_flipped_y = [] # the list of all Surfaces (flipped on y-axis)
self.tiles_flipped_xy = [] # the list of all Surfaces (flipped on both axes)
self.tile_props_by_id = {} # contains tile properties set in the tmx file for each tile by tile ID
# by default, only flip on x-axis (usually that's enough for 2D games)
if not store_flips:
store_flips = {"x": True, "y": False}
for child in elem:
# the image asset -> load and save all Surfaces
if child.tag == "image":
props = child.attrib
self.w = int(props["width"])
self.h = int(props["height"])
image_file = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(file)), os.path.relpath(props["source"])))
# image_file = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(file)), os.path.relpath("../images/debug.png")))
image = pygame.image.load(image_file).convert_alpha()
col = -1
row = 0
for tile in range(self.count):
col += 1
if col >= self.cols:
col = 0
row += 1
surf = pygame.Surface((self.tw, self.th), flags=pygame.SRCALPHA)
surf.blit(image, (0, 0),
area=pygame.Rect(col * self.tw, row * self.th, self.tw, self.th)) # blits the correct frame of the image to this new surface
self.tiles.append(surf)
# do the necessary flippings (will save time later when rendering the Sprite)
if store_flips["x"]:
surf_x_flipped = pygame.transform.flip(surf, True, False)
self.tiles_flipped_x.append(surf_x_flipped)
if store_flips["y"]: # x and y
self.tiles_flipped_xy.append(pygame.transform.flip(surf_x_flipped, False, True))
if store_flips["y"]:
self.tiles_flipped_y.append(pygame.transform.flip(surf, False, True))
# single tiles (and their properties)
elif child.tag == "tile":
id_ = int(child.attrib["id"])
self.tile_props_by_id[id_] = {} # create new dict for this tile
for tag in child:
# the expected properties tag
if tag.tag == "properties":
for prop in tag:
val = prop.attrib["value"]
type_ = prop.attrib["type"] if "type" in prop.attrib else None
if type_:
if type_ == "bool":
val = True if val == "true" else False
else:
val = int(val) if type_ == "int" else float(val) if type_ == "float" else val
self.tile_props_by_id[id_][prop.attrib["name"]] = val
else:
raise ("ERROR: expected only <properties> tag within <tile> in tsx file {}".format(file))
[docs]class Sprite(GameObject, pygame.sprite.Sprite):
"""
A Sprite can be added to a Stage; has a type and a collision mask for collision detection with other Sprites or TiledTileLayers also on the Stage.
Sprite objects inherit from pygame.sprite.Sprite, so a Sprite has an image and a position/collision rect via rect property (pygame.rect).
Each Sprite can have either a static image or hold a SpriteSheet object from which it can pull images for animation purposes; either way, the `image`
property holds the current image.
The image rect (property `image_rect`) can be different from the collision rect (property `rect`); usually one would want the collision rect to be
a little smaller than the actual image.
"""
# dict of Sprite types (by name) to bitmappable-int (1, 2, 4, 8, 16, etc..)
# - this can be used to determine which Sprites collide with which other Sprites
types = {
"none": 0x0, # e.g. background Sprites (e.g. waterfalls)
"dockable": 0x1, # if other objects would like to dock to this Sprite
"default": 0x2, # e.g. collision layers (should normally also be dockable)
"one_way_platform": 0x4, # objects can only collide with this Sprite when coming from the top (also no x-collisions) (should normally also be dockable)
"particle": 0x8, # e.g. an arrow/shot/etc..
"friendly": 0x10,
"enemy": 0x20,
"all": 0xffff,
}
next_type = 0x200
[docs] @staticmethod
def get_type(types_):
"""
Returns the bitmap code for an already existing Sprite type or for a new type (the code will be created then).
Types are usually used for collision masks.
:param str types_: the type(s) (comma-separated), whose code(s) should be returned
:return: the type as an int; if many types are given, returns a bitmask with all those bits set that represent the given types
:rtype: int
"""
ret = 0
for type_ in types_.split(","):
if type_ not in Sprite.types:
Sprite.types[type_] = Sprite.next_type
Sprite.next_type *= 2
ret |= Sprite.types[type_]
return ret
def __init__(self, x, y, **kwargs):
"""
:param int x: the initial x position of this Sprite
:param int y: the initial y position of this Sprite
:param any **kwargs:
- sprite_sheet: a ready SpriteSheet object to use (set initial image to first frame in the SpriteSheet)
- image_file: use image_file (str) as a file name for a static image
- image_section (Tuple[int,int,int,int]): offset-x, offset-y, width, height defining a rect to use only a subsection of the given static image
- width_height (Tuple[int,int]): the dimensions of the collision rect; if not given, we'll try to derive the collision rect from
the given image/spritesheet
- image_rect: a pygame.Rect defining the x/y offset and width/height of the Sprite's image with respect to the Sprite's rect (collision)
e.g. if the image is 32x32 but the collision rect should only be 16x32 (slim), the width_height kwarg should be (16, 32) and the image_rect kwarg
should be pygame.Rect(-8, 0, 32, 32)
"""
pygame.sprite.Sprite.__init__(self)
GameObject.__init__(self)
# can be set to the number of ticks to ignore by the containing stage depending on whether this Sprite is within the Stage's viewable borders
self.ignore_after_n_ticks = 1 # >0: not to be ignored; <=0: ignore this sprite for one tick
# determine the image of this Sprite, its collision rect, and its image-offset-rect (where with respect to the collision rect do we draw the image?)
# - with SpriteSheet
if "sprite_sheet" in kwargs:
sheet = kwargs["sprite_sheet"]
assert isinstance(sheet, SpriteSheet), "ERROR: in Sprite's ctor: kwargs[`sprite_sheet`] must be of type `SpriteSheet`!"
self.spritesheet = sheet
# TODO: make it possible to create a Sprite from more than one tile (e.g. for a platform/elevator). Either in x-direction or y-direction or both
self.image = sheet.tiles[kwargs.get("tile", 0)]
width_height = kwargs.get("width_height", (self.spritesheet.tw, self.spritesheet.th))
self.rect = pygame.Rect(x, y, width_height[0], width_height[1]) # collision rect
self.image_rect = kwargs.get("image_rect", pygame.Rect(width_height[0] / 2 - self.spritesheet.tw / 2,
width_height[1] / 2 - self.spritesheet.th / 2,
self.spritesheet.tw, self.spritesheet.th))
self.do_render = True
# - an image file -> fixed image -> store as Surface in self.image
elif "image_file" in kwargs:
image = kwargs["image_file"]
assert isinstance(image, str), "ERROR: in Sprite's ctor: kwargs[`image_file`] must be of type str!"
self.spritesheet = None
source = pygame.image.load(image)
if "image_section" in kwargs:
sec = kwargs["image_section"]
assert isinstance(sec, tuple) and len(sec) == 4,\
"ERROR: in Sprite's ctor: kwargs[`image_section`] must be of type tuple and of len 4 (offset-x, offset-y, width, height)!"
self.image = pygame.Surface((sec[2], sec[3]))
self.image.blit(source, dest=(0, 0), area=pygame.Rect(*sec))
else:
self.image = source
self.image_rect = self.image.get_rect()
width_height = kwargs.get("width_height", (self.image_rect.width, self.image_rect.height))
self.rect = pygame.Rect(x, y, width_height[0], width_height[1]) # collision
# fix image x/y (would be 0,0 otherwise)
self.image_rect = kwargs.get("image_rect", pygame.Rect(width_height[0] / 2 - self.image_rect.width / 2,
width_height[1] / 2 - self.image_rect.height / 2,
self.image_rect.width, self.image_rect.height))
self.do_render = True
# - empty image plus a collision rect of some size
elif "width_height" in kwargs:
width_height = kwargs["width_height"]
assert isinstance(width_height, tuple) and len(width_height) == 2,\
"ERROR: in Sprite's ctor: kwargs[`width_height`] must be of type tuple and of len 2!"
self.spritesheet = None
self.image = None
self.rect = pygame.Rect(x, y, width_height[0], width_height[1])
self.image_rect = pygame.Rect(0, 0, width_height[0], width_height[1])
self.do_render = True if DEBUG_FLAGS & DEBUG_RENDER_SPRITES_RECTS else False
# - tiny-size rect (no image)
else:
self.spritesheet = None
self.image = None
self.rect = pygame.Rect(x, y, 1, 1)
self.image_rect = pygame.Rect(0, 0, 1, 1)
self.do_render = True if DEBUG_FLAGS & DEBUG_RENDER_SPRITES_RECTS else False
# GameObject specific stuff
self.type = Sprite.get_type("default") # specifies the type of the Sprite (can be used e.g. for collision detection)
self.handles_own_collisions = False # set to True if this object takes care of its own collision handling
self.collision_mask = Sprite.get_type("default") # set the bits here that we would like to collide with (all other types will be ignored)
self.stage = None # the current Stage this Sprite is in
self.sprite_groups = [] # the current Groups that this Sprite belongs to
self.do_render = kwargs.get("do_render", self.do_render) # we may overwrite this
self.render_order = kwargs.get("render_order", 50) # the higher this number the later this Sprite will be rendered in the Stage's render function
self.flip = {"x": False, "y": False} # 'x': flip in x direction, 'y': flip in y direction, False: don't flip
# our min/max positions (might be adjusted to more accurate values once placed onto a Stage)
self.x_min = "auto" # by default, make x-position be limited automatically by Stage/Level borders
self.x_max = "auto"
self.y_min = None # by default, make y-positions not limited
self.y_max = None
self.on_event("added_to_stage", self, "added_to_stage", register=True) # allow any Stage to trigger this event using this Sprite
[docs] def added_to_stage(self, stage):
"""
adjusts our max positions based on the stage's level's dimensions - only if it's a Level (not a simple Screen)
:param Stage stage: the Stage we were added to
"""
# TODO: make this independent on Level or simple Screen (even a Screen should have dimensions)
if isinstance(stage.screen, Level):
if self.x_min == "auto":
self.x_min = 0
if self.x_max == "auto":
self.x_max = stage.screen.width - self.rect.width
if self.y_min == "auto":
self.y_min = 0
if self.y_max == "auto":
self.y_max = stage.screen.height - self.rect.height
[docs] def move(self, x, y, absolute=False):
"""
Moves us by x/y pixels (or to x,y if absolute=True).
:param Union[int,None] x: the amount in pixels to move in x-direction
:param Union[int,None] y: the amount in pixels to move in y-direction
:param bool absolute: whether x and y are given as absolute coordinates (default: False): in this case x/y=None means do not move in this dimension
"""
# absolute coordinates given
if absolute:
if x is not None:
self.rect.x = x
if y is not None:
self.rect.y = y
# do a minimum of 1 pix (if larger 0.0)
else:
if 0 < x < 1:
x = 1
self.rect.x += x
if 0 < y < 1:
y = 1
self.rect.y += y
# then we do the boundary checking
if self.x_max is not None and self.rect.x > self.x_max:
self.rect.x = self.x_max
elif self.x_min is not None and self.rect.x < self.x_min:
self.rect.x = self.x_min
if self.y_max is not None and self.rect.y > self.y_max:
self.rect.y = self.y_max
elif self.y_min is not None and self.rect.y < self.y_min:
self.rect.y = self.y_min
# @override(GameObject)
[docs] def destroy(self):
super().destroy()
# if we are on a stage -> remove us from that stage
if self.stage:
self.stage.remove_sprite(self)
# remove us from all our pygame.sprite.Groups
for sprite_group in self.sprite_groups:
sprite_group.remove(self)
[docs] def render(self, display):
"""
Paints the Sprite with its current image onto the given Display object.
:param Display display: the Display object to render on (Display has a pygame.Surface, on which we blit our image)
"""
if self.image:
#print("render at x={}".format(self.rect.x + self.image_rect.x - display.offsets[0]))
display.surface.blit(self.image, (self.rect.x + self.image_rect.x - display.offsets[0], self.rect.y + self.image_rect.y - display.offsets[1]))
if DEBUG_FLAGS & DEBUG_RENDER_SPRITES_RECTS:
pygame.draw.rect(display.surface, DEBUG_RENDER_SPRITES_RECTS_COLOR,
pygame.Rect((self.rect.x - display.offsets[0], self.rect.y - display.offsets[1]),
(self.rect.w, self.rect.h)), 1)
[docs]class Repeater(Sprite):
"""
A background 2D image that scrolls slower than the Viewport (to create a pseudo 3D effect).
"""
def __init__(self, x, y, image_file, **kwargs):
ro = kwargs.pop("render_order", 0) # by default, make this Sprite render first
super().__init__(x, y, image_file=image_file, render_order=ro, **kwargs)
self.vx = kwargs.get("vx", 1)
self.vy = kwargs.get("vy", 1)
self.repeat_x = kwargs.get("repeat_x", True)
self.repeat_y = kwargs.get("repeat_y", True)
self.repeat_w = kwargs.get("repeat_w", self.rect.width)
self.repeat_h = kwargs.get("repeat_h", self.rect.height)
# don't collide with anything
self.type = Sprite.get_type("none")
self.collision_mask = 0
# @override(Sprite)
[docs] def render(self, display):
# debug rendering (no backgrounds) -> early out
if DEBUG_FLAGS & DEBUG_DONT_RENDER_TILED_TILE_LAYERS:
return
self.ignore_after_n_ticks = 100 # replenish counter so that the repeater never goes out of the Viewport's scope
view_x = display.offsets[0]
view_y = display.offsets[1]
offset_x = self.rect.x + view_x * self.vx
offset_y = self.rect.y + view_y * self.vy
if self.repeat_x:
start_x = math.floor(-offset_x % self.repeat_w)
if start_x > 0:
start_x -= self.repeat_w
else:
start_x = self.rect.x - view_x
if self.repeat_y:
start_y = math.floor(-offset_y % self.repeat_h)
if start_y > 0:
start_y -= self.repeat_h
else:
start_y = self.rect.y - view_y
scale = 1.0
cur_y = start_y
while cur_y < display.height / scale:
cur_x = start_x
while cur_x < display.width / scale:
#display.surface.blit(self.image, dest=(math.floor(cur_x + view_x), math.floor(cur_y + view_y)))
display.surface.blit(self.image, dest=(math.floor(cur_x), math.floor(cur_y)))
cur_x += self.repeat_w
if not self.repeat_x:
break
cur_y += self.repeat_h
if not self.repeat_y:
break
[docs]class MovableRock(Sprite):
def __init__(self, x, y, **kwargs):
if not kwargs.get("image_file"):
kwargs["image_file"] = "images/movable_rock.png"
super().__init__(x, y, **kwargs)
self.type = Sprite.get_type("default,dockable")
self.collision_mask = Sprite.get_type("default,friendly,enemy,particle")
# add Physics (and thus Dockable) components to this Rock
# - pre-tick: Physics (movement + collision resolution)
#self.register_event("collision")
phys = PlatformerPhysics("physics")
phys.is_pushable = True # rock can be pushed by an agent
phys.vx_max = 10 # max move speed (when pushed): this should be very slow
phys.is_heavy = True # rock makes Stage's viewport rock if it hits ground AND squeezes agents :(
self.cmp_physics = self.add_component(phys)
# subscribe/register to some events
self.on_event("bump.bottom", self, "land", register=True)
self.register_event("bump.top", "bump.left", "bump.right", "hit.liquid")
[docs] def tick(self, game_loop):
# let our physics component handle all movements
self.cmp_physics.tick(game_loop)
[docs] def land(self, *args):
self.stage.shake_viewport(1, 10)
[docs]class Autobuild(object):
"""
Mix-in class to force x, y, width, height structure of ctors. All autobuild objects (objects that are built automatically by a TiledTileLayer with
the property autobuild_objects=true) will have to abode to this ctor parameter structure.
"""
def __init__(self, x, y, w, h, tile_w, tile_h):
"""
:param int x: the x position of the Autobuild in tile units
:param int y: the y position of the Autobuild in tile units
:param int w: the width of the Autobuild in tile units
:param int h: the height of the Autobuild in tile units
:param int tile_w: the tile width of the layer
:param int tile_h: the tile height of the layer
"""
self.x_in_tiles = x
self.y_in_tiles = y
self.w_in_tiles = w
self.h_in_tiles = h
self.tile_w = tile_w
self.tile_h = tile_h
[docs]class Ladder(Sprite, Autobuild):
"""
A Ladder object that actors can climb on.
One-way-platform type: one cannot fall through the top of the ladder but does not collide with the rest (e.g. from below) of the ladder.
A Ladder object does not have an image and is thus not(!) being rendered; the image of the ladder has to be integrated into a rendered TiledTileLayer.
TiledTileLayers have the possibility to generate Ladder objects automatically from those tiles that are flagged with the type='ladder' property. In that
case, the TiledTileLayer property 'build_ladders' (bool) has to be set to true.
"""
def __init__(self, x, y, w, h, tile_w, tile_h):
"""
:param int x: the x position of the Ladder in tile units
:param int y: the y position of the Ladder in tile units
:param int w: the width of the Ladder in tile units
:param int h: the height of the Ladder in tile units
:param int tile_w: the tile width of the layer
:param int tile_h: the tile height of the layer
"""
Autobuild.__init__(self, x, y, w, h, tile_w, tile_h)
# transform values here to make collision with ladder to only trigger when player is relatively close to the x-center of the ladder
# - make this a 2px wide vertical axis in the center of the ladder
x_px = self.x_in_tiles * self.tile_w + int(self.w_in_tiles * self.tile_w/2) - 1
y_px = self.y_in_tiles * self.tile_h
# call the Sprite ctor (now everything is in px)
Sprite.__init__(self, x_px, y_px, width_height=(2, self.h_in_tiles * self.tile_h))
# collision types
self.type = Sprite.get_type("ladder,dockable,one_way_platform")
self.collision_mask = 0 # do not do any collisions
[docs]class LiquidBody(Sprite, Autobuild):
"""
A LiquidBody object (quicksand, water, etc..) that an actor will sink into and die. The AIBrain of enemies will avoid stepping into such an object.
"""
def __init__(self, x, y, w, h, tile_w, tile_h, description="quicksand"):
"""
:param int x: the x position of the Ladder in tile units
:param int y: the y position of the Ladder in tile units
:param int w: the width of the Ladder in tile units
:param int h: the height of the Ladder in tile units
:param int tile_w: the tile width of the layer
:param int tile_h: the tile height of the layer
"""
Autobuild.__init__(self, x, y, w, h, tile_w, tile_h)
# make the liquid object a little lower than the actual tiles (especially at the top assuming that the top is done with tiles only showing
# the very shallow surface of the liquid body)
x_px = self.x_in_tiles * self.tile_w
y_px = self.y_in_tiles * self.tile_h + int(self.tile_h * 0.9)
# call the Sprite ctor (now everything is in px)
Sprite.__init__(self, x_px, y_px, width_height=(self.w_in_tiles * self.tile_w, self.h_in_tiles * self.tile_h - int(self.tile_h * 0.9)))
# can be used to distinguish between different types of liquids (water, quicksand, lava, etc..)
self.description = description
# collision types
self.type = Sprite.get_type("liquid")
self.collision_mask = 0 # do not do any collisions
[docs]class AnimatedSprite(Sprite):
"""
Adds an Animation component to each Sprite instance.
AnimatedSprites need a SpriteSheet (no static images or no-render allowed).
:param int x: the initial x position of the Sprite
:param int y: the initial y position of the Sprite
:param SpriteSheet spritesheet: the SpriteSheet object to use for this Sprite
:param dict animation_setup: the dictionary with the animation setup data to be sent to Animation.register_settings (the name of the registry record will
be kwargs["anim_settings_name"] OR spritesheet.name)
"""
def __init__(self, x, y, sprite_sheet, animation_setup, **kwargs):
"""
:param int x: the initial x position of the AnimatedSprite
:param int y: the initial y position of the AnimatedSprite
:param SpriteSheet sprite_sheet: the SpriteSheet to use for animations
:param dict animation_setup: a dictionary with all the different animation name and their settings (animation speed, frames to use, etc..)
"""
assert isinstance(sprite_sheet, SpriteSheet), "ERROR: AnimatedSprite needs a SpriteSheet in its c'tor!"
super().__init__(x, y, sprite_sheet=sprite_sheet, **kwargs)
self.cmp_animation = self.add_component(Animation("animation"))
self.anim_settings_name = kwargs.get("anim_settings_name", None) or sprite_sheet.name
Animation.register_settings(self.anim_settings_name, animation_setup, register_events_on=self)
# play the default animation (now that we have added the Animation Component, we can call play_animation on ourselves)
self.play_animation(animation_setup["default"])
[docs]class Display(object):
"""
A simple wrapper class for a pygame.display/pygame.Surface object representing the pygame display.
Also stores offset information for Viewport focusing (if Viewport is smaller that the Level, which is usually the case).
"""
instantiated = False
def __init__(self, width=600, height=400, title="Spygame Rocks!"):
"""
:param int width: the width of the Display
:param int height: the height of the Display
:param str title: the caption to use on the pygame display
"""
assert not Display.instantiated, "ERROR: can only create one {} object!".format(type(self).__name__)
Display.instantiated = True
pygame.display.set_caption(title)
self.width = width
self.height = height
self.surface = pygame.display.set_mode((width, height))
self.offsets = [0, 0]
[docs] def change_dims(self, width, height):
"""
Changes the Display's size dynamically (during the game).
:param int width: the new width to use
:param int height: the new height to use
"""
self.width = width
self.height = height
pygame.display.set_mode((width, height))
assert self.surface is pygame.display.get_surface(), "ERROR: self.display is not same object as pygame.display.get_surface() anymore!"
[docs] def debug_refresh(self):
"""
Force-refreshes the display (used only for debug purposes).
"""
pygame.display.flip()
pygame.event.get([]) # we seem to have to do this
[docs]class GameLoop(object):
"""
Class that represents the GameLoop.
Has play and pause functions: play starts the tick/callback loop.
Has clock for ticking (keeps track of self.dt each tick), handles and abides to max-fps rate setting.
Handles keyboard input registrations via its KeyboardInputs object.
Needs a callback to know what to do each tick.
Tick method does keyboard_inputs.tick, then calls the given callback with self as only argument.
"""
# static loop object (the currently active GameLoop gets stored here)
active_loop = None
[docs] @staticmethod
def play_a_loop(**kwargs):
"""
Factory: plays a given GameLoop object or creates a new one using the given \*\*kwargs options.
:param any kwargs:
- force_loop (bool): whether to play regardless of whether we still have some active loop running
- callback (callable): the GameLoop's callback loop function
- keyboard_inputs (KeyboardInputs): the GameLoop's KeyboardInputs object
- display (Display): the Display object to render everything on
- max_fps (int): the max frames per second to loop through
- screen_obj (Screen): alternatively, a Screen can be given, from which we will extract `display`, `max_fps` and `keyboard_inputs`
- game_loop (Union[str,GameLoop]): the GameLoop to use (instead of creating a new one); "new" or [empty] for new one
- dont_play (bool): whether - after creating the GameLoop - it should be played. Can be used for openAI gym purposes, where we just step,
not tick
:return: the created/played GameLoop object or None
:rtype: Union[GameLoop,None]
"""
defaults(kwargs, {"force_loop": False, "screen_obj": None, "keyboard_inputs": None, "display": None, "max_fps": None,
"game_loop" : "new", "dont_play": False})
# - if there's no other loop active, run the default stageGameLoop
# - or: there is an active loop, but we force overwrite it
if GameLoop.active_loop is None or ("force_loop" in kwargs and kwargs["force_loop"]):
# generate a new loop (and play)
if kwargs["game_loop"] == "new":
keyboard_inputs = None
# set keyboard inputs directly
if kwargs["keyboard_inputs"]:
keyboard_inputs = kwargs["keyboard_inputs"]
# or through the screen_obj
elif kwargs["screen_obj"]:
keyboard_inputs = kwargs["screen_obj"].keyboard_inputs
display = None
# set display directly
if kwargs["display"]:
display = kwargs["display"]
# or through the screen_obj
elif kwargs["screen_obj"]:
display = kwargs["screen_obj"].display
max_fps = 60
# set display directly
if kwargs["max_fps"]:
max_fps = kwargs["max_fps"]
# or through the screen_obj
elif kwargs["screen_obj"]:
max_fps = kwargs["screen_obj"].max_fps
loop = GameLoop(Stage.stage_default_game_loop_callback, display=display,
keyboard_inputs=keyboard_inputs, max_fps=max_fps)
if not kwargs["dont_play"]:
loop.play()
return loop
# just play an already existing loop
elif isinstance(kwargs["game_loop"], GameLoop):
kwargs["game_loop"].play()
return kwargs["game_loop"]
# do nothing
return None
def __init__(self, callback, display, keyboard_inputs=None, max_fps=60):
"""
:param callable callback: the callback function to call each time we `tick` (after collecting keyboard events)
:param Display display: the Display object associated with the loop
:param KeyboardInputs keyboard_inputs: the KeyboardInputs object to use for collecting keyboard information each tick (we simply call the
KeyboardInputs' `tick` method during our own `tick` method)
:param int max_fps: the maximum frame rate per second to allow when ticking. fps can be slower, but never faster
"""
self.is_paused = True # True -> Game loop will be paused (no frames, no ticks)
self.callback = callback # gets called each tick with this GameLoop instance as the first parameter (can then extract dt as `game_loop.dt`)
self.timer = pygame.time.Clock() # our tick object
self.frame = 0 # global frame counter
self.dt = 0.0 # time since last tick was executed
# registers those keyboard inputs to capture each tick (up/right/down/left as default if none given)
# - keyboard inputs can be changed during the loop via self.keyboard_input.update_keys([new key list])
self.keyboard_inputs = keyboard_inputs or KeyboardInputs(None)
self.display = display
self.max_fps = max_fps
[docs] def pause(self):
"""
Pauses this GameLoop.
"""
self.is_paused = True
GameLoop.active_loop = None
[docs] def play(self, max_fps=None):
"""
Plays this GameLoop (after pausing the currently running GameLoop, if any).
"""
# pause the current loop
if GameLoop.active_loop:
GameLoop.active_loop.pause()
GameLoop.active_loop = self
self.is_paused = False
# tick as long as we are not paused
while not self.is_paused:
self.tick(max_fps)
[docs] def tick(self, max_fps=None):
"""
Called each frame of the GameLoop.
Collects keyboard events.
Calls the GameLoop's `callback`.
Keeps a frame counter.
:param int max_fps: the maximum allowed number of frames per second (usually 60)
"""
if not max_fps:
max_fps = self.max_fps
# move the clock and store the dt (since last frame) in sec
self.dt = self.timer.tick(max_fps) / 1000
# default global events?
events = pygame.event.get(pygame.QUIT) # TODO: add more here?
for e in events:
if e.type == pygame.QUIT:
raise Exception(SystemExit, "QUIT")
# collect keyboard events
self.keyboard_inputs.tick()
# call the callback with self (for references to important game parameters)
self.callback(self)
# increase global frame counter
self.frame += 1
[docs] def step(self, action):
"""
(!)for reinforcement learning only(!) WIP:
Executes one action on the game.
The action gets translated into a keyboard sequence first, then is played.
:param str action: the action to execute on the MDP
"""
# default global events?
events = pygame.event.get(pygame.QUIT) # TODO: add more here?
for e in events:
if e.type == pygame.QUIT:
raise (SystemExit, "QUIT")
# collect keyboard events
self.keyboard_inputs.tick()
# call the callback with self (for references to important game parameters)
self.callback(self)
# increase global frame counter
self.frame += 1
[docs]class Stage(GameObject):
"""
A Stage is a container class for Sprites sorted by pygame.sprite.Groups and TiledTileLayers.
Sprites within a Stage can collide with each other or with the TiledTileLayers in the Stage.
Sprites and TiledTileLayers that are to be rendered are stored sorted by their render_order property (lowest renders first).
"""
# list of all Stages
max_stages = 10
stages = [None for x in range(max_stages)]
active_stage = 0 # the currently ticked/rendered Stage
locate_obj = Sprite(0, 0, width_height=(0, 0)) # used to do test collisions on a Stage
[docs] @staticmethod
def stage_default_game_loop_callback(game_loop: GameLoop):
"""
The default game loop callback to use if none is given when staging a Scene.
Order: Clamps dt (to avoid extreme values), ticks all stages, renders all stages, updates the pygame.display
:param GameLoop game_loop: the currently playing (active) GameLoop
"""
# clamp dt
if game_loop.dt < 0:
game_loop.dt = 1.0 / 60
elif game_loop.dt > 1.0 / 15:
game_loop.dt = 1.0 / 15
# tick all Stages
for i, stage in enumerate(Stage.stages):
Stage.active_stage = i
if stage:
stage.tick(game_loop)
# render all Stages and refresh the pygame.display
Stage.render_stages(game_loop.display, refresh_after_render=True)
Stage.active_stage = 0
[docs] @staticmethod
def render_stages(display, refresh_after_render=False):
"""
Loops through all Stages and renders all of them.
:param Display display: Display object on which to render
:param bool refresh_after_render: do we refresh the pygame.display after all Stages have been called with `render`?
"""
# black out display (really necessary? I think so)
display.surface.fill(pygame.Color("#000000"))
# call render on all Stages
for i, stage in enumerate(Stage.stages):
Stage.active_stage = i
if stage:
stage.render(display)
# for debugging purposes
if refresh_after_render:
pygame.display.flip()
[docs] @staticmethod
def clear_stage(idx):
"""
Clears one of the Stage objects by index.
:param int idx: the index of the Stage to clear (index==slot in static Stage.stages list)
"""
if Stage.stages[idx]:
Stage.stages[idx].destroy()
Stage.stages[idx] = None
[docs] @staticmethod
def clear_stages():
"""
Clears all our Stage objects.
"""
for i in range(len(Stage.stages)):
Stage.clear_stage(i)
[docs] @staticmethod
def get_stage(idx=0):
"""
Returns the Stage at the given index (returns None if none found).
:param Union[int,None] idx: the index of the Stage to return (0=default Stage)
:return: the Stage object at the given index or None if there is no Stage at that index
:rtype: Union[Stage,None]
"""
if idx is None:
idx = Stage.active_stage
return Stage.stages[idx]
[docs] @staticmethod
def stage_screen(screen, screen_func=None, stage_idx=None, options=None):
"""
Supported options are (if not given, we take some of them from given Screen object, instead):
- stage_idx (int): sets the stage index to use (0-9)
- stage_class (class): sets the class (must be a Stage class) to be used when creating the new Stage
- force_loop (bool): if set to True and we currently have a GameLoop running, stop the current GameLoop and replace it with a new one, which has
to be given via the "game_loop" option (as GameLoop object, or as string "new" for a default GameLoop)
- keyboard_inputs (KeyboardInputs): the KeyboardInputs object to use for the new GameLoop
- display (Display): the Display to use for the new GameLoop
- components (List[Component]): a list of Component objects to add to the new Stage (e.g. a Viewport)
:param Screen screen: the Screen object to set up on a certain stage
:param callable screen_func: the function to use to set up the Stage (before playing it)
:param int stage_idx: the Stage index to use (0=default Stage)
:param dict options: options to be used when instantiating the Stage
:return: the new Stage object
:rtype: Stage
"""
if options is None:
options = {}
defaults(options, {"stage_class": Stage})
# figure out which stage to use
stage_idx = stage_idx if stage_idx is not None else (options["stage_idx"] if "stage_idx" in options else 0)
# clean up an existing stage if necessary
Stage.clear_stage(stage_idx)
# create a new Stage and make this this the active stage
stage = Stage.stages[stage_idx] = options["stage_class"](screen, options)
Stage.active_stage = stage_idx
# setup the Stage via the screen_fun (passing it the newly created Stage)
if not screen_func:
screen_func = screen.screen_func
screen_func(stage)
Stage.active_stage = 0
# finally return the stage to the user for use if needed
return stage
def __init__(self, screen, options=None):
"""
:param Screen screen: the Stage's Screen object (a Screen determines which elements (layers and sprites) go on the Stage)
:param dict options: the options ruling the behavior of this Stage. options can be:
components (list): a list of components to add to this Stage during construction (usually, a Viewport gets added)
tile_sprite_handler (callable): a method taking a TiledTileLayer and returning an ndarray (tile-x/y position) of TileSprite objects (None if tile is
empty)
physics_collision_detector (callable): a method to use to detect a possible collision between two Sprites (defaults to AABBCollision.collide)
tick_sprites_in_range_only (bool): if set to True (default), we will not tick those Sprite objects that are currently outside a) our Viewport
component or b) outside the display
"""
super().__init__()
self.screen = screen # the screen object associated with this Stage
self.tiled_tile_layers = {} # TiledLayer objects by name
self.tiled_object_groups = {} # TiledObjectGroup objects by name
self.to_render = [] # list of all layers and sprites by name (TiledTileLayers AND Sprites) in the order in which they have to be rendered
# dict of pygame.sprite.Group objects (by name) that contain Sprites (each TiledObjectGroup results in one Group)
# - the name of the group is always the name of the TiledObjectGroup in the tmx file
self.sprite_groups = {}
self.sprites = [] # a plain list of all Sprites in this Stage
self.remove_list = [] # sprites to be removed from the Stage (only remove when Stage gets ticked)
defaults(options, {"physics_collision_detector": AABBCollision.collide, "tick_sprites_in_range_only": True, "tick_sprites_n_more_frames": 500})
self.options = options
self.is_paused = False
self.is_hidden = False
# register events that we will trigger
self.register_event("destroyed",
"added_to_stage", "removed_from_stage", # Sprites added/removed to/from us
"pre_ticks", "pre_collisions", # before we tick all Sprites, before we analyse all Sprites for collisions
"post_tick", # after we ticked all Sprites
"pre_render", "post_render" # before/after we render all our layers
)
# add Components to this Stage (given in options)
self.cmp_viewport = None # type: Union[Viewport,None]
if "components" in self.options:
for comp in self.options["components"]:
assert isinstance(comp, Component), "ERROR: one of the given components in Stage's c'tor (options['components']) is not of type Component!"
self.add_component(comp)
if comp.name == "viewport":
self.cmp_viewport = comp
# store the viewable range Rect
self.respect_viewable_range = self.cmp_viewport and self.options["tick_sprites_in_range_only"]
self.viewable_rect = pygame.Rect(0, 0, self.screen.display.width, self.screen.display.height)
# make sure our destroyed method is called when the stage is destroyed
self.on_event("destroyed")
[docs] def destroyed(self):
self.invoke("debind_events")
[docs] def for_each(self, callback, params=None):
"""
Calls the given callback function for each sprite, each time passing it the sprite itself and \*params.
:param callable callback: the callback to call for each sprite in the Stage
:param any params: the params to pass as second/third/etc.. parameter to the callback
"""
if not params:
params = []
for sprite in self.sprites:
callback(sprite, *params)
[docs] def invoke(self, func_name, params=None):
"""
Calls a function on all of the GameObjects on this Stage.
:param str func_name: the function name to call on all our GameObjects using getattr
:param Union[list,None] params: the \*args passed to that function
"""
if not params:
params = []
for sprite in self.sprites:
func = getattr(sprite, func_name, None)
if callable(func):
func(*params)
[docs] def detect(self, detector, params=None):
"""
Returns the first GameObject in this Stage that - when passed to the detector function with params - returns True.
:param callable detector: a function that returns a bool
:param list params: the list of positional args that are passed to the detector
:return: the first GameObject in this Stage that - when passed to the detector function with params - returns True
:rtype: Union[Sprite,None]
"""
if not params:
params = []
for sprite in self.sprites:
if detector(sprite, *params):
return sprite
[docs] def locate(self, x, y, w=1, h=1, type_=Sprite.get_type("default"), collision_mask=Sprite.get_type("default")):
"""
Returns the first Collision found by colliding the given measurements (Rect) against this Stage's objects.
Starts with all TiledTileLayer objects, then all other Sprites.
:param int x: the x-coordinate of the Rect to check
:param int y: the y-coordinate of the Rect to check
:param int w: the width of the Rect to check
:param int h: the height of the Rect to check
:param int type_: the type of the Rect (has to match collision_mask of Stage's objects)
:param int collision_mask: the collision mask of the Rect (only layers and Sprites that match this mask are checked)
:return: the first Collision encountered
:rtype: Union[Collision,None]
"""
obj = self.locate_obj
obj.rect.x = x
obj.rect.y = y
obj.rect.width = w
obj.rect.height = h
obj.type = type_
obj.collision_mask = collision_mask
if DEBUG_FLAGS & DEBUG_RENDER_SPRITES_RECTS:
pygame.draw.rect(self.screen.display.surface, DEBUG_RENDER_SPRITES_RECTS_COLOR,
pygame.Rect((obj.rect.x - self.screen.display.offsets[0], obj.rect.y - self.screen.display.offsets[1]),
(obj.rect.w, obj.rect.h)), 1)
GameLoop.active_loop.display.debug_refresh()
# collide with all matching tile layers
for tiled_tile_layer in self.tiled_tile_layers.values():
if obj.collision_mask & tiled_tile_layer.type:
col = tiled_tile_layer.collide_simple_with_sprite(obj, self.options["physics_collision_detector"])
# don't solve -> just return
if col:
return col
# collide with all Sprites (only if both collision masks match each others types)
for sprite in self.sprites:
if obj.collision_mask & sprite.type and sprite.collision_mask & obj.type:
col = self.options["physics_collision_detector"](obj, sprite)
if col:
return col
# nothing found
return None
[docs] def add_tiled_layer(self, pytmx_layer, pytmx_tiled_map):
"""
Adds a pytmx.TiledElement to the Stage with all its tiles or objects.
The TiledElement could either be converted into a TiledTileLayer or a TiledObjectGroup (these objects are generated in this function based on the
pytmx equivalent being passed in).
:param pytmx.pytmx.TiledElement pytmx_layer: the original pytmx object to derive our TiledTileLayer or TileObjectGroup from
:param pytmx.pytmx.TiledMap pytmx_tiled_map: the original pytmx TiledMap object (the tmx file) to which this layer belongs
"""
# a TiledObjectGroup ("Object Layer" in the tmx file)
if isinstance(pytmx_layer, pytmx.pytmx.TiledObjectGroup):
assert pytmx_layer.name not in self.tiled_object_groups, "ERROR: TiledObjectGroup with name {} already exists in Stage!".format(pytmx_layer.name)
l = TiledObjectGroup(pytmx_layer, pytmx_tiled_map)
self.add_tiled_object_group(l)
# a TiledTileLayer ("Tile Layer" in the tmx file)
elif isinstance(pytmx_layer, pytmx.pytmx.TiledTileLayer):
assert pytmx_layer.name not in self.tiled_tile_layers, "ERROR: TiledTileLayer with name {} already exists in Stage!".format(pytmx_layer.name)
assert "tile_sprite_handler" in self.options, \
"ERROR: a TiledTileLayer needs a tile_sprite_handler callable to generate all TileSprite objects in the layer!"
l = TiledTileLayer(pytmx_layer, pytmx_tiled_map, self.options["tile_sprite_handler"])
self.add_tiled_tile_layer(l)
else:
raise Exception("ERROR: pytmx_layer of type {} cannot be added to Stage. Needs to be pytmx.pytmx.TiledTileLayer or pytmx.pytmx.TiledObjectGroup!".
format(type(pytmx_layer).__name__))
[docs] def add_tiled_object_group(self, tiled_object_group):
"""
Adds a TiledObjectGroup (all it's objects as single Sprites) to this Stage.
:param TiledObjectGroup tiled_object_group:
"""
# add the layer to our tiled_layers list
self.tiled_object_groups[tiled_object_group.name] = tiled_object_group
# add the (already created) sprite-group to this stage under the name of the layer
assert tiled_object_group.name not in self.sprite_groups, \
"ERROR: trying to add a TiledObjectGroup to a Stage, but the Stage already has a sprite_group with the name of that layer ({})". \
format(tiled_object_group.name)
self.sprite_groups[tiled_object_group.name] = tiled_object_group.sprite_group
# add each single sprite of the group to the Stage
for sprite in tiled_object_group.sprite_group.sprites():
self.add_sprite(sprite, tiled_object_group.name)
[docs] def add_tiled_tile_layer(self, tiled_tile_layer):
"""
Adds a TiledTileLayer to this Stage.
Puts it in the ordered to_render list, in the tiled_layers list.
:param TiledTileLayer tiled_tile_layer: the TiledTileLayer to add to this Stage
"""
# put the pytmx_layer into one of the collision groups (if not type==none)?
# - this is useful for our solve_collisions method
#if tiled_tile_layer.type != Sprite.get_type("none"):
# self.tiled_layers_to_collide.append(tiled_tile_layer)
# put only TiledTileLayers in to_render (iff do_render=true) and single Sprites (from the TiledObjectGroup) all ordered by render_order
self.tiled_tile_layers[tiled_tile_layer.name] = tiled_tile_layer
# add it to the to_render list and re-sort the list by render_order values (note: this list also contains single Sprites)
if tiled_tile_layer.do_render:
self.to_render.append(tiled_tile_layer)
self.to_render.sort(key=lambda x: x.render_order)
# capture ladders and other autobuild structures?
if tiled_tile_layer.properties.get("autobuild_objects") == "true":
objects = tiled_tile_layer.capture_autobuilds()
for obj in objects:
self.add_sprite(obj, "autobuilds")
[docs] def add_sprite(self, sprite, group_name):
"""
Adds a new single Sprite to an existing or a new pygame.sprite.Group.
:param Sprite sprite: the Sprite to be added to this Stage (the Sprite's position is defined in its rect.x/y properties)
:param str group_name: the name of the group to which the GameObject should be added (group will not be created if it doesn't exist yet)
:return: the Sprite that was added
:rtype: Sprite
"""
# if the group doesn't exist yet, create it
if group_name not in self.sprite_groups:
self.sprite_groups[group_name] = pygame.sprite.Group()
sprite.stage = self # set the Stage of this GameObject
self.sprite_groups[group_name].add(sprite)
self.sprites.append(sprite)
sprite.sprite_groups.append(self.sprite_groups[group_name])
# add each single Sprite to the sorted (by render_order) to_render list and to the "all"-sprites list
# - note: the to_render list also contains entire TiledTileLayer objects
if sprite.do_render:
self.to_render.append(sprite)
self.to_render.sort(key=lambda x: x.render_order)
# trigger two events, one on the Stage with the object as target and one on the object with the Stage as target
self.trigger_event("added_to_stage", sprite)
sprite.trigger_event("added_to_stage", self)
return sprite
[docs] def remove_sprite(self, sprite: Sprite):
"""
Removes a Sprite from this Stage by putting it in the remove_list for later removal.
:param Sprite sprite: the Sprite to be removed from the Stage
"""
self.remove_list.append(sprite)
[docs] def force_remove_sprite(self, sprite: Sprite):
"""
Force-removes the given Sprite immediately (without putting it in the remove_list first).
:param Sprite sprite: the Sprite to be removed from the Stage
"""
try:
self.sprites.remove(sprite)
if sprite.do_render:
self.to_render.remove(sprite)
except ValueError:
return
# destroy the object
sprite.destroy()
self.trigger_event("removed_from_stage", sprite)
[docs] def pause(self):
"""
Pauses playing the Stage.
"""
self.is_paused = True
[docs] def unpause(self):
"""
Unpauses playing the Stage.
"""
self.is_paused = False
[docs] def tick(self, game_loop):
"""
Gets called each frame by the GameLoop.
Calls the tick method on all its Sprites (but only if the sprite is within the viewport).
:param GameLoop game_loop: the GameLoop object that's currently running (and ticking all Stages)
"""
if self.is_paused:
return False
# do the ticking of all Sprite objects
self.trigger_event("pre_ticks", game_loop)
# only tick sprites that are within our viewport
if self.respect_viewable_range:
self.viewable_rect.x = self.cmp_viewport.x
self.viewable_rect.y = self.cmp_viewport.y
for sprite in self.sprites:
if sprite.rect.bottom > self.viewable_rect.top and sprite.rect.top < self.viewable_rect.bottom and \
sprite.rect.left < self.viewable_rect.right and sprite.rect.right > self.viewable_rect.left:
sprite.ignore_after_n_ticks = self.options["tick_sprites_n_more_frames"] # reset to max
self.tick_sprite(sprite, game_loop)
else:
sprite.ignore_after_n_ticks -= 1 # if reaches 0 -> ignore
if sprite.ignore_after_n_ticks > 0:
self.tick_sprite(sprite, game_loop)
else:
for sprite in self.sprites:
sprite.ignore_after_n_ticks = self.options["tick_sprites_n_more_frames"] # always reset to max
self.tick_sprite(sprite, game_loop)
# do the collision resolution
self.trigger_event("pre_collisions", game_loop)
self.solve_collisions()
# garbage collect destroyed GameObjects
for sprite in self.remove_list:
self.force_remove_sprite(sprite)
self.remove_list.clear()
self.trigger_event("post_tick", game_loop)
[docs] @staticmethod
def tick_sprite(sprite, game_loop):
"""
ticks one single sprite
:param Sprite sprite: the Sprite object to tick
:param GameLoop game_loop: the GameLoop object that's currently playing
"""
if DEBUG_FLAGS & DEBUG_RENDER_SPRITES_BEFORE_EACH_TICK:
sprite.render(game_loop.display)
game_loop.display.debug_refresh()
sprite.tick(game_loop)
if DEBUG_FLAGS & DEBUG_RENDER_SPRITES_AFTER_EACH_TICK:
sprite.render(game_loop.display)
game_loop.display.debug_refresh()
[docs] def solve_collisions(self):
"""
Look for the objects layer and do each object against the main collision layer.
Some objects in the objects layer do their own collision -> skip those here (e.g. ladder climbing objects).
After the main collision layer, do each object against each other.
"""
# collide each object with all collidable layers (matching collision mask of object)
for sprite in self.sprites:
# not ignored (one-tick) and if this game_object completely handles its own collisions within its tick -> ignore it
if sprite.ignore_after_n_ticks > 0 and not sprite.handles_own_collisions and sprite.collision_mask > 0:
# collide with all matching tile layers
for tiled_tile_layer in self.tiled_tile_layers.values():
# only collide, if one of the types of the layer matches one of the bits in the Sprite's collision_mask
if sprite.collision_mask & tiled_tile_layer.type:
col = tiled_tile_layer.collide_simple_with_sprite(sprite, self.options["physics_collision_detector"])
if col:
sprite.trigger_event("collision", col)
# collide all Sprites with all other Sprites (both ways!)
# - only check if sprite1's collision_matrix matches sprite2's type
for sprite in self.sprites:
# not ignored (one-tick) and if this Sprite completely handles its own collisions within its tick -> ignore it
if sprite.ignore_after_n_ticks > 0 and not sprite.handles_own_collisions and sprite.collision_mask > 0:
for sprite2 in self.sprites:
if sprite is not sprite2 and sprite2.collision_mask > 0 and sprite.collision_mask & sprite2.type and sprite2.collision_mask & sprite.type:
direction, v = self.estimate_sprite_direction(sprite)
col = self.options["physics_collision_detector"](sprite, sprite2, direction=direction, direction_veloc=v)
if col:
# trigger "collision" for sprite1
sprite.trigger_event("collision", col)
## but only for sprite2 if it does NOT handle its own collisions
#if not sprite2.handles_own_collisions:
sprite2.trigger_event("collision", col.invert())
[docs] @staticmethod
def estimate_sprite_direction(sprite):
"""
tries to return an accurate tuple of direction (x/y) and direction_veloc
- if sprite directly has vx and vy, use these
- if sprite has a physics component: use vx and vy from that Component
- else: pretend vx and vy are 0.0
then return the direction whose veloc component is highest plus that highest veloc
:param Sprite sprite: the Sprite to estimate
:return: tuple of direction (x/y) and direction_veloc
:rtype: Tuple[str,float]
"""
phys = sprite.components.get("physics", None)
# sprite has a physics component with vx/vy
if phys and hasattr(phys, "vx") and hasattr(phys, "vy"):
vx = phys.vx
vy = phys.vy
# sprite has a vx/vy (meaning handles its own physics)
elif hasattr(sprite, "vx") and hasattr(sprite, "vy"):
vx = sprite.vx
vy = sprite.vy
else:
vx = 0.0
vy = 0.0
if abs(vx) > abs(vy):
return "x", vx
else:
return "y", vy
[docs] def hide(self):
"""
Hides the Stage.
"""
self.is_hidden = True
[docs] def show(self):
"""
Unhides the Stage.
"""
self.is_hidden = False
[docs] def stop(self):
"""
Stops playing the Stage (stops calling `tick` on all GameObjects).
"""
self.hide()
self.pause()
[docs] def start(self):
"""
Starts running the Stage (and calling all GameObject's `tick` method).
"""
self.show()
self.unpause()
[docs] def render(self, display):
"""
Gets called each frame by the GameLoop (after 'tick' is called on all Stages).
Renders all its layers (ordered by 'render_order' property of the TiledTileLayer in the tmx file).
TODO: renders Sprites that are not part of any layer.
:param Display display: the Display object to render on
"""
if self.is_hidden:
return False
self.trigger_event("pre_render", display)
# loop through the sorted to_render list and render all TiledTileLayer and Sprite objects in this list
for layer_or_sprite in self.to_render:
if getattr(layer_or_sprite, "ignore_after_n_ticks", 1) <= 0:
continue
layer_or_sprite.render(display)
self.trigger_event("post_render", display)
[docs]class TmxLayer(object, metaclass=ABCMeta):
"""
A wrapper class for the pytmx TiledObject class that can either represent a TiledTileLayer or a TiledObjectGroup.
Needs to implement render and stores some spygame specific properties such as collision, render, etc.
:param pytmx.pytmx.TiledElement tmx_layer_obj: the underlying pytmx TiledTileLayer
:param pytmx.pytmx.TiledMap tmx_tiled_map: the underlying pytmx TiledMap object (representing the tmx file)
"""
def __init__(self, tmx_layer_obj, tmx_tiled_map):
self.pytmx_layer = tmx_layer_obj
self.pytmx_tiled_map = tmx_tiled_map
self.name = tmx_layer_obj.name
properties = tmx_layer_obj.properties
defaults(properties, {"do_render": "true", "render_order": 0})
self.properties = properties
[docs]class TiledTileLayer(TmxLayer):
"""
A wrapper class for pytmx.pytmx.TiledTileLayer, which represents a 'normal' tile layer in a tmx file.
Reads in all tiles' images into one Surface object so we can render the entire layer at once.
Implements `render`.
"""
def __init__(self, pytmx_layer, pytmx_tiled_map, tile_sprite_handler):
"""
:param pytmx.pytmx.TiledTileLayer pytmx_layer: the underlying pytmx TiledTileLayer
:param pytmx.pytmx.TiledMap pytmx_tiled_map: the underlying pytmx TiledMap object (representing the tmx file)
:param callable tile_sprite_handler: the callable that returns an ndarray, populated with TileSprite objects for storage in this layer
"""
super().__init__(pytmx_layer, pytmx_tiled_map)
self.type_str = self.properties.get("type", "none")
self.type = 0
# get type mask of this layer from `type` property
for t in self.type_str.split(","):
self.type |= Sprite.get_type(t)
# an ndarray holding all single tiles (by x/y position) from this layer
# non-existing tiles are not(!) stored in this ndarray and return None at the respective x/y position
self.tile_sprites = tile_sprite_handler(self)
# update do_render indicator depending on some debug settings
self.do_render = (self.properties["do_render"] == "true" and not (DEBUG_FLAGS & DEBUG_DONT_RENDER_TILED_TILE_LAYERS)) or \
(self.type != Sprite.get_type("none") and (DEBUG_FLAGS & DEBUG_RENDER_COLLISION_TILES))
self.render_order = int(self.properties["render_order"])
# put this layer in one single Sprite that we can then blit on the display (with 'area=[some rect]' to avoid drawing the entire layer each time)
self.pygame_sprite = None
# we are rendering this layer, need to store entire image in this structure
if self.do_render:
self.pygame_sprite = self.build_sprite_surface()
[docs] def build_sprite_surface(self):
"""
Builds the image (pygame.Surface) for this tile layer based on all found tiles in the layer.
"""
surf = pygame.Surface((self.pytmx_layer.width * self.pytmx_tiled_map.tilewidth, self.pytmx_layer.height * self.pytmx_tiled_map.tileheight),
flags=pygame.SRCALPHA)
# rendered collision layer
if self.type != Sprite.get_type("none") and (DEBUG_FLAGS & DEBUG_RENDER_COLLISION_TILES):
# red for normal collisions, light-blue for touch collisions
color = DEBUG_RENDER_COLLISION_TILES_COLOR_DEFAULT if self.type & Sprite.get_type("default") else DEBUG_RENDER_COLLISION_TILES_COLOR_OTHER
for (x, y, image), (_, _, gid) in zip(self.pytmx_layer.tiles(), self.pytmx_layer.iter_data()):
surf.blit(image.convert_alpha(), (x * self.pytmx_tiled_map.tilewidth, y * self.pytmx_tiled_map.tileheight))
tile_props = self.pytmx_tiled_map.get_tile_properties_by_gid(gid) or {}
# normal collision tiles
if not tile_props.get("no_collision"):
pygame.draw.rect(surf, color, pygame.Rect((x * self.pytmx_tiled_map.tilewidth, y * self.pytmx_tiled_map.tileheight),
(self.pytmx_tiled_map.tilewidth, self.pytmx_tiled_map.tileheight)), 1)
# "normal" layer (and no debug rendering)
else:
for x, y, image in self.pytmx_layer.tiles():
surf.blit(image.convert_alpha(), (x * self.pytmx_tiled_map.tilewidth, y * self.pytmx_tiled_map.tileheight))
pygame_sprite = pygame.sprite.Sprite()
pygame_sprite.image = surf
pygame_sprite.rect = surf.get_rect()
return pygame_sprite
[docs] def render(self, display):
"""
Blits a part of our Sprite's image onto the Display's Surface using the Display's offset attributes.
:param Display display: the Display object to render on
"""
assert self.do_render, "ERROR: TiledTileLayer.render() called but self.do_render is False!"
assert not isinstance(self.pygame_sprite, Sprite), "ERROR: TiledTileLayer.render() called but self.pygame_sprite is not a Sprite!"
r = pygame.Rect(self.pygame_sprite.rect) # make a clone so we don't change the original Rect
# apply the display offsets (camera)
r.x += display.offsets[0]
r.y += display.offsets[1]
r.width = display.width
r.height = display.height
display.surface.blit(self.pygame_sprite.image, dest=(0, 0), area=r)
[docs] def capture_autobuilds(self):
"""
Captures all autobuild objects in this layer and returns them in a list of objects.
Once an autobuild tile is found: searches neighboring tiles (starting to move right and down) for the same property and thus measures the object's
width and height (in tiles).
:return: list of generated autobuild objects
:rtype: List[object]
"""
objects = []
# loop through each tile and look for ladder type property
for y in range(self.pytmx_layer.height):
for x in range(self.pytmx_layer.width):
tile_sprite = self.tile_sprites[(x, y)] # type: TileSprite
if not tile_sprite:
continue
props = tile_sprite.tile_props
# we hit the upper left corner of an autobuild object -> spread out to find more neighboring similar tiles
ctor = props.get("autobuild_class", False)
if ctor:
assert isinstance(ctor, type), "ERROR: translation of tile ({},{}) property `autobuild_class` did not yield a defined class!".format(x, y)
tile_left = self.tile_sprites[(x-1, y)] # type: TileSprite
tile_top = self.tile_sprites[(x, y-1)] # type: TileSprite
if tile_left and tile_left.tile_props.get("autobuild_class") == ctor or \
tile_top and tile_top.tile_props.get("autobuild_class") == ctor:
continue
# measure width and height
w = 1
h = 1
x2 = x+1
while True and x2 < self.pytmx_layer.width:
ts = self.tile_sprites[(x2, y)]
if not (ts and ts.tile_props.get("autobuild_class") == ctor):
break
w += 1
x2 += 1
y2 = y+1
while True and y2 < self.pytmx_layer.height:
ts = self.tile_sprites[(x, y2)]
if not (ts and ts.tile_props.get("autobuild_class") == ctor):
break
h += 1
y2 += 1
# insert new object (all autobuild objects need to accept x, y, w, h in their constructors)
objects.append(ctor(x, y, w, h, self.pytmx_tiled_map.tilewidth, self.pytmx_tiled_map.tileheight, **props.get("autobuild_kwargs", {})))
return objects
[docs] def get_overlapping_tiles(self, sprite):
"""
Returns the tile boundaries (which tiles does the sprite overlap with?).
:param Sprite sprite: the sprite to test against
:return: a tuple of (start-x. end-x, start-y, end-y) tile-coordinates to consider as overlapping with the given Sprite
:rtype: tuple
"""
tile_start_x = min(max(0, sprite.rect.left // self.pytmx_tiled_map.tilewidth), self.pytmx_tiled_map.width - 1)
tile_end_x = max(0, min(self.pytmx_tiled_map.width - 1, (sprite.rect.right - 1) // self.pytmx_tiled_map.tilewidth))
tile_start_y = min(max(0, sprite.rect.top // self.pytmx_tiled_map.tileheight), self.pytmx_tiled_map.height - 1)
tile_end_y = max(0, min(self.pytmx_tiled_map.height - 1, (sprite.rect.bottom - 1) // self.pytmx_tiled_map.tileheight))
return tile_start_x, tile_end_x, tile_start_y, tile_end_y
[docs] def collide_simple_with_sprite(self, sprite, collision_detector):
"""
Collides a Sprite (that only obeys simple physics rules) with a TiledTileLayer and solves all detected collisions.
The Sprite needs to have the properties vx and vy, which are interpreted as the Sprite's velocity.
Ignores slopes.
:param Sprite sprite: the Sprite to test for collisions against a TiledTileLayer
:param callable collision_detector: the collision detector method to use (this is set in the Sprite's Stage's options)
"""
tile_start_x, tile_end_x, tile_start_y, tile_end_y = self.get_overlapping_tiles(sprite)
xy, v = Stage.estimate_sprite_direction(sprite)
# very simple algo: look through tile list (in no particular order) and return first tile that collides
# None if no colliding tile found
for tile_x in range(tile_start_x, tile_end_x + 1):
for tile_y in range(tile_start_y, tile_end_y + 1):
tile_sprite = self.tile_sprites[tile_x, tile_y]
if not tile_sprite:
continue
col = collision_detector(sprite, tile_sprite, collision_objects=None,
direction=xy, direction_veloc=v, original_pos=(sprite.rect.x, sprite.rect.y))
if col:
return col
return None
[docs]class TileSprite(Sprite):
"""
Class used by TiledTileLayer objects to have a means of representing single tiles in terms of Sprite objects
(used for collision detector function).
"""
def __init__(self, layer, pytmx_tiled_map, id_, tile_props, rect):
"""
:param TiledTileLayer layer: the TiledTileLayer object to which this tile belongs
:param pytmx.pytmx.TiledMap pytmx_tiled_map: the tmx tiled-map object to which this tile belongs
(useful to have to look up certain map-side properties, e.g. tilewidth/height)
:param int id_: tthe ID of the tile in the layer
:param dict tile_props: the properties dict of this tile (values already translated into python types)
:param Union[pygame.Rect,None] rect: the pygame.Rect representing the position and size of the tile
"""
super().__init__(rect.x, rect.y, width_height=(rect.width, rect.height))
self.tiled_tile_layer = layer
self.pytmx_tiled_map = pytmx_tiled_map
self.tile = id_
self.tile_x = self.rect.x // self.pytmx_tiled_map.tilewidth
self.tile_y = self.rect.y // self.pytmx_tiled_map.tileheight
self.tile_props = tile_props
# add the `dockable` type to all tiles
self.type |= Sprite.get_type("dockable")
[docs]class SlopedTileSprite(TileSprite):
"""
a TileSprite that supports storing some temporary calculations about a slope in the tile and its relation to a Sprite that's currently colliding
with the TileSprite
- used by the PlatformerPhysics Component when detecting and handling slope collisions
"""
def __init__(self, layer, pytmx_tiled_map, id_, tile_props, rect):
"""
:param TiledTileLayer layer: the TiledTileLayer object to which this tile belongs
:param pytmx.pytmx.TiledMap pytmx_tiled_map: the tmx tiled-map object to which this tile belongs
(useful to have to look up certain map-side properties, e.g. tilewidth/height)
:param int id_: tthe ID of the tile in the layer
:param dict tile_props: the properties dict of this tile (values already translated into python types)
:param Union[pygame.Rect,None] rect: the pygame.Rect representing the position and size of the tile
"""
super().__init__(layer, pytmx_tiled_map, id_, tile_props, rect)
# slope properties of the tile
self.slope = tile_props.get("slope", None) # the slope property of the tile in the tmx file (inverse steepness (1/m in y=mx+b) of the line that defines the slope)
self.offset = tile_props.get("offset", None) # the offset property of the tile in the tmx file (in px (b in y=mx+b))
self.is_full = (self.slope == 0.0 and self.offset == 1.0) # is this a full collision tile?
self.max_x = self.pytmx_tiled_map.tilewidth
self.max_y = max(self.get_y(0), self.get_y(self.rect.width)) # store our highest y-value (height of this tile)
[docs] def get_y(self, x):
"""
Calculates the y value (in normal cartesian y-direction (positive values on up axis)) for a given x-value.
:param int x: the x-value (x=0 for left edge of tile x=tilewidth for right edge of tile)
:return: the calculated y-value
:rtype: int
"""
# y = mx + b
if self.slope is None or self.offset is None:
return 0
return self.slope * min(x, self.max_x) + self.offset * self.rect.height
[docs] def sloped_xy_pull(self, sprite):
"""
Applies a so-called xy-pull on a Sprite object moving in x-direction in this sloped tile.
An xy-pull is a change in the y-coordinate because of the x-movement (sliding up/down a slope while moving left/right).
:param Sprite sprite: the Sprite object that's moving on the slope
"""
if self.slope == 0 or not self.slope:
return
# the local x value for the Sprite on the tile's internal x-axis (0=left edge of tile)
x_local = max(0, (sprite.rect.left if self.slope < 0 else sprite.rect.right) - self.rect.left)
# the absolute y-position that we will force the sprite into
y = self.rect.bottom - self.get_y(x_local) - sprite.rect.height
sprite.move(None, y, True)
[docs]class TiledObjectGroup(TmxLayer):
"""
A wrapper class for the pytmx.TiledObjectGroup class, which represents an object layer in a tmx file.
Generates all GameObjects specified in the layer (a.g. the agent, enemies, etc..).
Implements `render` by looping through all GameObjects and rendering their Sprites one by one.
"""
def __init__(self, pytmx_layer: pytmx.pytmx.TiledObjectGroup, pytmx_tiled_map: pytmx.pytmx.TiledMap):
super().__init__(pytmx_layer, pytmx_tiled_map)
# create the sprite group for this layer (all GameObjects will be added to this group)
self.sprite_group = pygame.sprite.Group()
# construct each object from the layer (as a Sprite) and add them to the sprite_group of this layer
for obj in self.pytmx_layer:
# allow objects in the tmx file to be 'switched-off' by making them invisible
if not obj.visible:
continue
obj_props = obj.properties
# if the (Sprite) class of the object is given, construct it here using its c'tor
# - classes are given as strings: e.g. sypg.Sprite, vikings.Viking, Agent (Agent class would be in __main__ module)
# - first look in the tile's properties for the 'class' field, only then try the 'type' field directly of the object (manually given by designer)
class_global = obj_props.pop("class", None) or obj.type
if class_global:
ctor = convert_type(class_global, force_class=True)
assert isinstance(ctor, type), "ERROR: python class `{}` for object in object-layer `{}` not defined!".\
format(class_global, self.pytmx_layer.name)
# get other kwargs for the Sprite's c'tor
kwargs = get_kwargs_from_obj_props(obj_props)
# generate the Sprite
sprite = ctor(obj.x, obj.y, **kwargs)
## add the do_render and render_order to the new instance
#sprite.do_render = (obj_props.get("do_render", "true") == "true") # the default for objects is true
#if sprite.do_render:
# sprite.render_order = int(obj_props.get("render_order", 50)) # the default for objects is 50
self.sprite_group.add(sprite)
[docs]class Collision(object):
"""
A simple feature object that stores collision properties for collisions between two objects or between an object and a TiledTileLayer.
"""
def __init__(self):
self.sprite1 = None # hook into the first Sprite participating in this collision
self.sprite2 = None # hook into the second Sprite participating in this collision (this could be a TileSprite)
self.is_collided = True # True if a collision happened (usually True)
self.distance = 0 # how much do we have to move sprite1 to separate the two Sprites? (always negative)
self.magnitude = 0 # abs(distance)
self.impact = 0.0 # NOT SURE: the impulse of the collision on some mass (used for pushing heavy objects)
self.normal_x = 0.0 # x-component of the collision normal
self.normal_y = 0.0 # y-component of the collision normal
self.separate = [0, 0] # (-distance * normal_x, -distance * normal_y) how much to we have to change x/y values for rect to separate the two sprites
self.direction = None # None, 'x' or 'y' (direction in which we measure the collision; the other direction is ignored)
self.direction_veloc = 0 # velocity direction component (e.g. direction=='x' veloc==5 -> moving right, veloc==-10.4 -> moving left)
self.original_pos = [0, 0] # the original x/y-position of sprite1 before the move that lead to the collision happened
[docs] def invert(self):
"""
Inverts this Collision in place to yield the Collision for the case that the two Sprites are switched.
"""
# flip the sprites
tmp = self.sprite1
self.sprite1 = self.sprite2
self.sprite2 = tmp
# invert the normal and separate (leave distance negative, leave magnitude positive)
self.normal_x = -self.normal_x
self.normal_y = -self.normal_y
self.separate = [-self.separate[0], -self.separate[1]]
# the direction veloc
self.direction_veloc = -self.direction_veloc
return self
## OBSOLETE CLASS
#class PlatformerCollision(Collision):
# """
# A collision object that can be used by PlatformerPhysics to handle Collisions.
# """
#
# def __init__(self):
# super().__init__()
# self.impact = 0.0 # the impulse of the collision on some mass (used for pushing heavy objects)
# # OBSOLETE: these should all be no longer needed
# # self.slope = False # whether this is a collision with a sloped TileSprite of a TiledTileLayer
# (will also be False if obj1 collides with the Tile's rect, but obj1 is still in air (slope))
# # self.slope_y_pull = 0 # amount of y that Sprite has to move up (negative) or down (positive) because of the collision (with a slope)
# #self.slope_up_down = 0 # 0=no slope, -1=down slope, 1 = up slope
[docs]class Component(GameObject, metaclass=ABCMeta):
"""
A Component can be added to and removed from other GameObjects.
Use "extend" to make a Component's method be callable directly from the owning GameObject.
:param str name: the name of the component (the name can be used to retrieve any GameObject's components via the [GameObject].components dict)
"""
def __init__(self, name):
super().__init__()
self.name = name
self.game_object = None # to be set by Entity when this component gets added
[docs] @abstractmethod
def added(self):
"""
Gets called when the component is added to a GameObject.
"""
pass
[docs] def removed(self):
"""
Gets called when the component is removed from a GameObject.
"""
pass
[docs] def extend(self, method):
"""
Extends the given method (has to take self as 1st param) onto the GameObject, so that this method can be called directly from the GameObject.
The extended method will take two self's (0=Component, 1=GameObject), thus selfs should be called 'comp' and 'game_object' OR 'self' and 'game_object'
:param callable method: method, which to make callable from within the owning GameObject
"""
assert self.game_object, "ERROR: need self.game_object in order to extend the method to that GameObject!"
# keep the original method under a different name (just in case it's still needed by the overwriting method)
old = getattr(self.game_object, method.__name__, None)
if old:
setattr(self.game_object, "_super_" + method.__name__, old)
# use the MethodType function to bind the given method function to only this object (not any other instances of the GameObject's class)
setattr(self.game_object, method.__name__, types.MethodType(method, self.game_object))
[docs]class Brain(Component, metaclass=ABCMeta):
"""
A generic Brain class that has a command dict for other classes to be able to look up what the brain currently wants.
Also has a main-switch to activate/deactivate the Brain.
Should implement `tick` method and set self.commands each tick.
"""
def __init__(self, name="brain", commands=None):
super().__init__(name)
self.is_active = True # main switch: if False, we don't do anything
if not commands:
commands = []
self.commands = {command: False for command in commands} # the commands coming from the brain (e.g. `jump`, `sword`, `attack`, etc..)
[docs] def reset(self):
"""
Sets all commands to False.
"""
for key in self.commands:
self.commands[key] = False
[docs] def activate(self):
"""
Makes this Brain active: we will react to the GameLoop's keyboard events.
"""
self.is_active = True
[docs] def deactivate(self):
"""
Makes this Brain inactive: we will not(!) react to the GameLoop's keyboard events (no exceptions).
"""
self.is_active = False
self.reset() # set all commands to False
[docs] @abstractmethod
def tick(self, game_loop):
"""
Needs to be called by the GameObject at some point during the GameObject's `tick` method.
:param GameLoop game_loop: the currently playing GameLoop object
"""
pass
[docs]class SimpleHumanBrain(Brain):
"""
looks for keys that match our stored commands and sets these commands to True if the key is pressed, False otherwise
"""
[docs] def tick(self, game_loop):
"""
Needs to be called by the GameObject at some point during the GameObject's `tick` method.
Translates all keys from the GameLoops's KeyboardInputs object into our command dict.
:param GameLoop game_loop: the currently playing GameLoop object
"""
# main switch is set to OFF
if not self.is_active:
return
# first reset everything to False
self.reset()
# current animation does not block: normal commands possible
for key_code, is_pressed in game_loop.keyboard_inputs.keyboard_registry.items():
# look up the str description of the key
desc = game_loop.keyboard_inputs.descriptions[key_code]
# look up the key-to-command translation rules
self.commands[desc] = is_pressed
[docs]class KeyboardBrainTranslation(object):
"""
A class to represent a relationship between a pressed key and a command (or two commands)
The normal relationship is: [some key]: when pressed -> [command] is True; when not pressed -> [command] is False
But other, more complex relationships are supported as well.
"""
# key-to-command flags
NORMAL = 0x0 # normal: when key down -> command is True (this is essentially: DOWN_LEAVE_UP_LEAVE)
DOWN_ONE_TICK = 0x1 # when key down -> command is True for only one tick (after that, key needs to be released to fire another command)
# DOWN_LEAVE = 0x2 # when key down -> command is x (and stays x as long as key is down)
UP_ONE_TICK = 0x2 # when key up -> command is y for one frame
# can only execute command if an animation is currently not playing or just completed (e.g. swinging sword)
BLOCK_REPEAT_UNTIL_ANIM_COMPLETE = 0x4
# when key down -> command is x (and stays x); when key gets released -> command is y for one frame (BUT only after a certain animation has been completed)
BLOCK_OTHER_CMD_UNTIL_ANIM_COMPLETE = 0x8
# some flags needed to describe the state for the DOWN_LEAVE_UP_ONE_TICK_WAIT_FOR_ANIM type of key-command-translation
STATE_NONE = 0x0
STATE_CHARGING = 0x1 # we are currently charging after key-down (when fully charged, we are ready to execute upon other_command)
STATE_FULLY_CHARGED = 0x2 # if set, we are fully charged and we will execute other_command as soon as the key is released
STATE_CMD_RECEIVED = 0x4 # if set, the key for the other_command has already been released, but we are still waiting for the charging to be complete
def __init__(self, key, command, flags=0, other_command=None, animation_to_complete=None):
"""
:param str key: the key's description, e.g. `up` for K_UP
:param str command: the `main` command's description; can be any string e.g. `fire`, `jump`
:param int flags: keyboard-command behavior flags
:param str other_command: a possible second command associated with the key (when key is released, e.g. `release_bow`)
:param Union[list,str] animation_to_complete: animation(s) that needs to be completed in order for the other_command to be executable
"""
self.key = key
self.command = command
assert flags & (self.BLOCK_REPEAT_UNTIL_ANIM_COMPLETE | self.BLOCK_OTHER_CMD_UNTIL_ANIM_COMPLETE) == 0 or \
isinstance(animation_to_complete, str) or isinstance(animation_to_complete, set), "ERROR: animation_to_complete needs to be of type str or set!"
self.flags = flags
self.other_command = other_command
# this could be a set of anims (one of them has to be completed)
self.animation_to_complete = {animation_to_complete} if isinstance(animation_to_complete, str) else animation_to_complete
self.state_other_command = 0 # the current state for the other_command (charging, charged, cmd_received)
self.is_disabled = False # set to True for temporarily blocking this translation
[docs]class AnimationLinkedBrain(Brain, metaclass=ABCMeta):
"""
A Brain that is linked to an Animation component and can thus subscribe to events triggered by that Component.
"""
def __init__(self, name="brain", commands=None):
super().__init__(name, commands)
self.game_obj_cmp_anim = None # our GameObject's Animation Component (if any); needed for animation flags
[docs] def added(self):
# search for an Animation component of our game_object
self.game_obj_cmp_anim = self.game_object.components.get("animation")
assert isinstance(self.game_obj_cmp_anim, Animation),\
"ERROR: {} needs its GameObject to also have a Component called `animation` that's of type Animation!".format(type(self).__name__)
[docs]class HumanPlayerBrain(AnimationLinkedBrain):
"""
An AnimationLinkedBrain that handles agent control (via the GameLoop´s keyboard registry).
Supports special keyboard->command translations (e.g. key down -> command A for one tick; key up -> command B for one tick).
"""
def __init__(self, name="brain", key_brain_translations=None):
"""
:param str name: the name of this component
:param Union[list,None] key_brain_translations: list of KeyboardBrainTranslation objects or None
"""
super().__init__(name)
# stores the values of the keyboard_inputs in the previous tick (to catch changes in the keyboard state)
self.keyboard_prev = {}
# build our key_brain_translation dict to translate key inputs into commands
if key_brain_translations is None:
key_brain_translations = []
self.key_brain_translations = {}
self.add_translations(key_brain_translations)
self.animation_prev = None
self.is_paralyzed = False # is this brain paralyzed? (e.g. when agent is dizzy)
self.paralyzes_exceptions = None # keys that are still ok to be handled, even if paralyzed
[docs] def added(self):
super().added()
# subscribe to anim.ends events
self.game_obj_cmp_anim.on_event("anim.ends", self, "anim_ends", register=True)
[docs] def anim_ends(self, anim, anim_new):
pass
[docs] def add_translations(self, key_brain_translations):
"""
Adds a single or more KeyboardBrainTranslation object to our dict.
:param Union[KeyboardBrainTranslation,str,dict,tuple] key_brain_translations: the keyboard-to-command translation to be added to this Brain
(can be represented in different ways; see code)
"""
# list: re-call this method one-by-one
if isinstance(key_brain_translations, list):
for trans in key_brain_translations:
self.add_translations(trans)
# str: key = command
elif isinstance(key_brain_translations, str):
self.add_translations(KeyboardBrainTranslation(key_brain_translations, key_brain_translations))
# tuple: pass as positional args into c'tor (key,cmd,flags,other_cmd,anim_to_be_completed)
elif isinstance(key_brain_translations, tuple):
self.add_translations(KeyboardBrainTranslation(*key_brain_translations))
# dict: pass as kwargs into c'tor
elif isinstance(key_brain_translations, dict):
self.add_translations(KeyboardBrainTranslation(**key_brain_translations))
# KeyboardBrainTranslation: take as is and store
elif isinstance(key_brain_translations, KeyboardBrainTranslation):
assert key_brain_translations.key not in self.key_brain_translations, "ERROR: key {} already in key_brain_translations dict!". \
format(key_brain_translations.key)
self.key_brain_translations[key_brain_translations.key] = key_brain_translations
self.keyboard_prev[key_brain_translations.key] = False # create the entry for the key (for faster lookup later without [dict].get())
self.commands[key_brain_translations.command] = False
if key_brain_translations.other_command:
self.commands[key_brain_translations.other_command] = False
# not supported type
else:
raise Exception("ERROR: key_brain_translations parameter has wrong type; needs to be str, KeyboardBrainTranslation, tuple, or dict!")
[docs] def remove_translation(self, key):
"""
Adds a single KeyboardBrainTranslation object to our dict.
:param str key: the key (str) to be removed from our key-to-command translation dict
"""
self.key_brain_translations.pop(key, None)
#def enable_translation(self, key):
# trans = self.key_brain_translations.get(key)
# if trans:
# trans.is_disabled = False
#def disable_translation(self, key):
# trans = self.key_brain_translations.get(key)
# if trans:
# trans.is_disabled = True
[docs] def tick(self, game_loop: GameLoop):
"""
Needs to be called by the GameObject at some point during the GameObject's `tick` method.
Translates all keys from the GameLoops's KeyboardInputs object into our command dict.
:param GameLoop game_loop: the currently playing GameLoop object
"""
# main switch is set to OFF
if not self.is_active:
return
# support for `paralyzes` flag and `paralyzes_exceptions` is built into this class
self.is_paralyzed = bool(self.game_obj_cmp_anim.flags & Animation.get_flag("paralyzes"))
self.paralyzes_exceptions = self.game_obj_cmp_anim.properties.get("paralyzes_exceptions")
# first reset everything to False
self.reset()
# current animation does not block: normal commands possible
for key_code, is_pressed in game_loop.keyboard_inputs.keyboard_registry.items():
# look up the str description of the key
desc = game_loop.keyboard_inputs.descriptions[key_code]
# look up the key-to-command translation rules
trans = self.key_brain_translations.get(desc) # type: KeyboardBrainTranslation
# not a known key to this Brain OR this translation is (temporarily) disabled
if not trans or trans.is_disabled or (self.is_paralyzed and (not self.paralyzes_exceptions or desc not in self.paralyzes_exceptions)):
continue
# normal translation
if trans.flags == trans.NORMAL:
self.commands[trans.command] = is_pressed
# key is currently down
elif is_pressed:
# if we don't repeat the command -> check whether this press is new and only then set the command
if trans.flags & trans.DOWN_ONE_TICK:
# key was previously up -> new press
if self.keyboard_prev[desc] is False:
# check for condition on the current anim (don't set command if a certain anim is currently playing)
if (trans.flags & trans.BLOCK_REPEAT_UNTIL_ANIM_COMPLETE) == 0 or \
self.game_obj_cmp_anim.animation not in trans.animation_to_complete:
self.commands[trans.command] = True
# NORMAL: down -> leave command=True
else:
self.commands[trans.command] = True
# key is currently up
else:
# we fire a single-tick other_command if the key has just been released
if trans.flags & trans.UP_ONE_TICK and self.keyboard_prev[desc] is True:
# we have an anim condition on other_command
if trans.flags & trans.BLOCK_OTHER_CMD_UNTIL_ANIM_COMPLETE:
if trans.state_other_command & trans.STATE_FULLY_CHARGED:
# fire command and reset all state flags
self.commands[trans.other_command] = True
trans.state_other_command = 0
# set the STATE_CMD_RECEIVED flag and wait for the charging to be complete
elif trans.state_other_command & trans.STATE_CHARGING:
trans.state_other_command |= trans.STATE_CMD_RECEIVED
# no anim condition
else:
self.commands[trans.other_command] = True
# if we are waiting for other_command to be charged -> check whether we have to keep the main command active (until charging is done)
elif trans.flags & trans.BLOCK_OTHER_CMD_UNTIL_ANIM_COMPLETE and trans.state_other_command & trans.STATE_CHARGING:
self.commands[trans.command] = True
# check for other command dependency on animation and start charging (or reset state)
if trans.flags & trans.BLOCK_OTHER_CMD_UNTIL_ANIM_COMPLETE:
# we are currently charging (playing the animation)
if self.game_obj_cmp_anim.animation in trans.animation_to_complete:
trans.state_other_command |= trans.STATE_CHARGING
trans.state_other_command &= ~trans.STATE_FULLY_CHARGED
# we are just done with the animation -> set to fully charged
elif self.animation_prev in trans.animation_to_complete:
# if we have already got the command -> fire it now
if trans.state_other_command & trans.STATE_CMD_RECEIVED:
self.commands[trans.command] = False
self.commands[trans.other_command] = True
trans.state_other_command = 0
# otherwise, update the charging state and keep waiting for the key to be released
else:
trans.state_other_command |= trans.STATE_FULLY_CHARGED
trans.state_other_command &= ~trans.STATE_CHARGING
# update keyboard_prev dict
self.keyboard_prev[desc] = is_pressed
# store previous animation
self.animation_prev = self.game_obj_cmp_anim.animation
[docs]class AIBrain(AnimationLinkedBrain):
"""
An AnimationLinkedBrain that can handle simple left/right logic for 2D platformer monsters.
The brain will take care of avoiding cliffs, but other than that always just walk from left to right and back.
Overwrite this to implement more complex behaviors in the tick method.
"""
def __init__(self, name="brain", commands=None):
if not commands:
commands = ["left", "right"]
super().__init__(name, commands)
self.flipped = False # if True: character is turning left
[docs] def added(self):
super().added()
# simple behavior: change our direction if we run into a wall
self.game_object.on_event("bump.right", self, "bumped", register=True)
self.game_object.on_event("bump.left", self, "bumped", register=True)
[docs] def tick(self, game_loop):
"""
Needs to be called by the GameObject at some point during the GameObject's `tick` method.
:param GameLoop game_loop: the currently playing GameLoop object
"""
obj = self.game_object
self.reset()
if self.game_obj_cmp_anim and self.game_obj_cmp_anim.flags & Animation.get_flag("paralyzes"):
return
# look for edges ahead -> then change direction if one is detected
# - makes sure an enemy character does not fall off a cliff
if (not (game_loop.frame % 3) and self.check_cliff_ahead()) or obj.rect.x <= 0 or obj.rect.x >= obj.x_max:
self.toggle_direction()
self.commands["left" if self.flipped else "right"] = True
[docs] def bumped(self, col):
self.toggle_direction()
[docs] def toggle_direction(self):
"""
Changes the current direction (left to right or vice-versa)
"""
self.flipped = self.flipped is False
[docs] def check_cliff_ahead(self):
"""
Checks whether there is a cliff ahead (returns true if yes).
"""
obj = self.game_object
tile_w = obj.stage.screen.tmx_obj.tilewidth
tile_h = obj.stage.screen.tmx_obj.tileheight
# check below character (c=character sprite, _=locateObject (a stripe with x=x width=w-6 and height=3))
# ccc -> walking direction
# ccc
# _
w = max(tile_w * 1.5, obj.rect.width - 6)
col = obj.stage.locate((obj.rect.right - tile_w - w) if self.flipped else (obj.rect.left + tile_w),
obj.rect.bottom - tile_h * 0.5,
w,
tile_h * 1.75,
Sprite.get_type("default"))
if not col or isinstance(col.sprite2, LiquidBody):
return True
return False
# checks whether an enemy is in sight
[docs] def check_enemy_ahead(self):
pass
[docs]class Animation(Component):
"""
A Component that takes care of setting the image property of its owning Sprite object based on so-called "animation settings".
Animation settings are stored in a global registry under the name of the SpriteSheet object that holds the images that belong to the animation setting.
The tick method has to be called by the Sprite's tick method in order for the Sprite's image to be changed on each tick.
"""
# static animation-properties registry
# - stores single animation records (these are NOT Animation objects, but simple dicts representing settings for single animation sequences)
animation_settings = {}
# some flags
animation_flags = {
"none": 0x0,
# if set: this animation does not change the Sprite's image depending on time, but they have to be set manually via the
# frame property of the Animation component (which gives the SpriteSheet's frame, not the anim_settings frame-slot)
"manual": 0x1,
"all": 0xffff,
}
next_flag = 0x2
[docs] @staticmethod
def get_flag(flags):
"""
Returns the bitmap code for an already existing Animation flag or for a new flag (the code will be created then).
Flags are usually used to tell a Brain Component or the character directly what effects the animation has.
:param str flags: the flag(s) (comma-separated), whose code(s) should be returned
:return: the flag as an int; if many flags are given, returns a bitmask with all those bits set that represent the given flags
:rtype: int
"""
ret = 0
for flag in flags.split(","):
if flag not in Animation.animation_flags:
Animation.animation_flags[flag] = Animation.next_flag
Animation.next_flag *= 2
ret |= Animation.animation_flags[flag]
return ret
[docs] @staticmethod
def register_settings(settings_name, settings, register_events_on=None):
# we do not have this name registered yet
if settings_name not in Animation.animation_settings:
assert "default" in settings, "ERROR: no entry `default` in animation-settings. Each settings block needs a default animation name."
for anim in settings:
if anim != "default":
defaults(settings[anim], {
"rate": 1 / 3, # the rate with which to play this animation in 1/s
"frames": [0, 1], # the frames to play from our spritesheet (starts with 0)
"priority": 0, # which priority to use for next if next is given
# flags bitmap that determines the behavior of the animation (e.g. block controls during animation play, etc..)
"flags": 0,
"callbacks": None,
"loop": True, # whether to loop the animation when done
"next": None, # which animation to play next (str or callable returning a str)
"next_priority": 0, # which priority to use for next if next is given
"trigger": None, # which events to trigger on the game_object that plays this animation
"trigger_data": [], # *args data to pass to the event handler if trigger is given
"properties": {}, # some custom properties of this anim
})
Animation.animation_settings[settings_name] = settings
if isinstance(register_events_on, EventObject):
l = list(chain.from_iterable(("anim." + anim, "anim_loop." + anim, "anim_end." + anim) for anim in settings))
register_events_on.register_event(*l)
[docs] @staticmethod
def get_settings(spritesheet_name, anim_setting):
if spritesheet_name not in Animation.animation_settings or anim_setting not in Animation.animation_settings[spritesheet_name]:
return None
return Animation.animation_settings[spritesheet_name][anim_setting]
def __init__(self, name):
super().__init__(name)
self.animation = None # str: we are playing this animation; None: we are undefined -> waiting for the next anim setup
self.rate = 1 / 3 # default rate in s
self.has_changed = False
self.priority = -1 # animation priority (takes the value of the highest priority animation that wants to be played simultaneously)
self.frame = 0 # the current frame in the animation 'frames' list OR: if self.animation is None: this is the actual frame from the SpriteSheet
self.time = 0 # the current time after starting the animation in s
self.flags = 0
self.properties = {} # custome properties of this Animation object
self.blink_rate = 3.0
self.blink_duration = 0
self.blink_time = 0
self.is_hidden = False # True: half the time we are blinking
[docs] def added(self):
# make sure our GameObject is actually a Sprite
assert isinstance(self.game_object, Sprite), "ERROR: Component Animation can only be added to a Sprite object!"
# tell our GameObject that we might trigger some "anim..." events on it
self.game_object.register_event("anim.start", "anim.frame", "anim.loop", "anim.end")
# extend some methods directly onto the GameObject
self.extend(self.play_animation)
self.extend(self.blink_animation)
[docs] def tick(self, game_loop):
"""
Needs to be called by the GameObject at some point during the GameObject's `tick` method.
:param GameLoop game_loop: the GameLoop that's currently playing
"""
obj = self.game_object
# blink stuff?
if self.blink_duration > 0:
self.blink_time += game_loop.dt
# blinking stops
if self.blink_time >= self.blink_duration:
self.blink_duration = 0
self.is_hidden = False
else:
frame = int(self.blink_time * self.blink_rate)
self.is_hidden = True if frame % 2 == 0 else False
# animation stuff?
anim_settings = None
if self.animation and not self.flags & Animation.get_flag("manual"):
anim_settings = Animation.get_settings(obj.anim_settings_name, self.animation)
rate = anim_settings["rate"] or self.rate
stepped = 0
self.time += game_loop.dt
if self.has_changed:
self.has_changed = False
else:
self.time += game_loop.dt
if self.time > rate:
stepped = self.time // rate
self.time -= stepped * rate
self.frame += stepped
# we are changing frames
if stepped > 0:
# there are no more frames
if self.frame >= len(anim_settings["frames"]):
# this animation ends
if anim_settings["loop"] is False or anim_settings["next"]:
self.frame = len(anim_settings["frames"]) - 1
obj.trigger_event("anim.end", self)
self.priority = -1
if anim_settings["trigger"]:
obj.trigger_event(anim_settings["trigger"], *anim_settings["trigger_data"])
# `next` could be a callable as well returning a str to use as animation setting
if anim_settings["next"]:
#print("playing next animation {}\n".format(anim_settings["next"]))
self.play_animation(obj, (anim_settings["next"]() if callable(anim_settings["next"]) else anim_settings["next"]),
anim_settings["next_priority"])
return
# this animation loops
else:
obj.trigger_event("anim.loop", self)
self.frame %= len(anim_settings["frames"])
obj.trigger_event("anim.frame", self)
# assign the correct image to the `image` field of the GameObject (already correctly x/y-flipped)
# hidden: no image
if self.is_hidden:
obj.image = None
# visible: some image
else:
tiles_dict = obj.spritesheet.tiles # no flipping
if obj.flip["x"]:
if obj.flip["y"]:
tiles_dict = obj.spritesheet.tiles_flipped_xy
else:
tiles_dict = obj.spritesheet.tiles_flipped_x
elif obj.flip["y"]:
tiles_dict = obj.spritesheet.tiles_flipped_y
# manual animation -> frame in SpriteSheet directly set manually
if self.flags & Animation.get_flag("manual"):
obj.image = tiles_dict[int(self.frame)]
# automatic animation: self.frame is the slot in the animation's frame list (not the SpriteSheet's!)
elif anim_settings:
# TEST DEBUG
frm = int(self.frame)
if frm >= len(anim_settings["frames"]):
print("BAD: anim='{}' frm {} >= len(anim_settings[frames]) {}!\n".format(self.animation, frm, len(anim_settings["frames"])))
tile = anim_settings["frames"][frm]
if tile >= len(tiles_dict):
print("BAD: anim='{}' tile {} >= len(tiles_dict) {}!\n".format(self.animation, tile, len(tiles_dict)))
obj.image = tiles_dict[tile]
[docs] def play_animation(self, game_object, name, priority=0):
"""
Plays an animation on our GameObject.
:param GameObject game_object: the GameObject on which to play the animation; the animation has to be setup via register_settings with the name
of the SpriteSheet of the GameObject
:param str name: the name of the animation to play
:param int priority: the priority with which to play this animation (if this method is called multiple times, it will pick the higher one)
"""
if name and name != self.animation:
# look up animation in list
anim_settings = Animation.get_settings(game_object.anim_settings_name, name)
assert anim_settings, "ERROR: animation-to-play (`{}`) not found in spritesheet settings `{}`!".format(name, game_object.anim_settings_name)
priority = priority or anim_settings["priority"]
if priority >= self.priority:
self.animation = name
self.has_changed = True
self.time = 0
self.frame = 0 # start each animation from 0
self.priority = priority
# set flags to sprite's properties
self.flags = anim_settings["flags"]
self.properties = anim_settings["properties"]
game_object.trigger_event("anim.start", self)
[docs] def blink_animation(self, game_object, rate=3.0, duration=3.0):
"""
Blinks the GameObject with the given parameters.
:param GameObject game_object: our GameObject to which blinking is applied
:param float rate: the rate with which to blink (in 1/s)
:param float duration: the duration of the blinking (in s); after the duration, the blinking stops
"""
self.blink_rate = rate
self.blink_duration = duration
self.blink_time = 0
[docs]class Dockable(Component):
"""
A dockable component allows 1) for Sprites to dock to a so-called "mother_ship" and b) for Sprites to become "mother_ships" themselves (other Sprites can
dock to this Sprite). Sprites that are docked to our mother_ship (this Component's Sprite) will be moved along with it when they stand on top.
"""
DEFINITELY_DOCKED = 0x1 # this object is definitely docked to something right now
DEFINITELY_NOT_DOCKED = 0x2 # this object is definitely not docked to something right now
TO_BE_DETERMINED = 0x4 # the docking state of this object is currently being determined
PREVIOUSLY_DOCKED = 0x8 # if set, the object was docked to something in the previous frame
def __init__(self, name="dockable"):
"""
:param str name: the name of the Component
"""
super().__init__(name)
self.docked_sprites = set() # set that holds all Sprites (by GameObject id) currently docked to this one
# holds the objects that we stand on and stood on previously:
# slot 0=current state; slot 1=previous state (sometimes we need the previous state since the current state gets reset to 0 every step)
self.docking_state = 0
self.docked_to = None # the reference to the object that we are currently docked to
[docs] def added(self):
# make sure our GameObject is a Sprite
assert isinstance(self.game_object, Sprite), "ERROR: game_object of Component Dockable must be of type Sprite (not {})!". \
format(type(self.game_object).__name__)
# extend our GameObject with move (thereby overriding the Sprite's move method)
self.extend(self.move)
[docs] def move(self, sprite, x, y, absolute=False):
"""
This will 'overwrite' the normal Sprite's `move` method by Component's extend.
:param Sprite sprite: the GameObject that this Component belongs to (the Sprite to move around)
:param Union[int,None] x: the amount in pixels to move in x-direction
:param Union[int,None] y: the amount in pixels to move in y-direction
:param bool absolute: whether x and y are given as absolute coordinates (default: False): in this case x/y=None means do not move in this dimension
"""
orig_x = sprite.rect.x
orig_y = sprite.rect.y
# first call the original Sprite's move method
sprite._super_move(x, y, absolute)
# move all our docked Sprites along with us
if not absolute:
for docked_sprite in self.docked_sprites:
docked_sprite.move(x, y, absolute=False)
else:
# translate into relative movement: we don't want the docked components to move to the given mothership's absolute values
x_move = x - orig_x if x is not None else 0
y_move = y - orig_y if y is not None else 0
for docked_sprite in self.docked_sprites:
docked_sprite.move(x_move, y_move)
[docs] def dock_to(self, mother_ship):
"""
A sprite lands on an elevator -> couple the elevator to the sprite so that when the elevator moves, the sprite moves along with it.
Only possible to dock to `dockable`-type objects.
:param Sprite mother_ship: the Sprite to dock to (Sprite needs to have a dockable component)
"""
prev = self.is_docked()
obj = self.game_object
# can only dock to dockable-type objects
if mother_ship.type & Sprite.get_type("dockable"):
self.docking_state = Dockable.DEFINITELY_DOCKED
if prev:
self.docking_state |= Dockable.PREVIOUSLY_DOCKED
self.docked_to = mother_ship
# add docked obj to mothership docked-obj-list (if present)
if "dockable" in mother_ship.components:
#print("adding {} (id {}) to mothership {}".format(type(obj).__name__, obj.id, type(self.docked_to).__name__))
mother_ship.components["dockable"].docked_sprites.add(obj)
[docs] def undock(self):
"""
Undocks itself from the mothership.
"""
obj = self.game_object
prev = self.is_docked()
self.docking_state = Dockable.DEFINITELY_NOT_DOCKED
if prev:
self.docking_state |= Dockable.PREVIOUSLY_DOCKED
# remove docked obj from mothership docked-obj-list (if present)
if self.docked_to and "dockable" in self.docked_to.components:
#print("removing {} (id {}) from mothership {}".format(type(obj).__name__, obj.id, type(self.docked_to).__name__))
self.docked_to.components["dockable"].docked_sprites.discard(obj)
self.docked_to = None
[docs] def undock_all_docked_objects(self):
"""
undocks all objects currently docked to this object
"""
l = list(self.docked_sprites)
for obj in l:
if "dockable" in obj.components:
obj.components["dockable"].undock()
[docs] def to_determine(self):
"""
Changes our docking state to be undetermined (saves the current state as PREVIOUS flag).
"""
prev = self.is_docked()
self.docking_state = Dockable.TO_BE_DETERMINED
if prev:
self.docking_state |= Dockable.PREVIOUSLY_DOCKED
[docs] def is_docked(self):
"""
:return: True if the current state is definitely docked OR (to-be-determined AND previous state was docked)
:rtype: bool
"""
return bool(self.docking_state & Dockable.DEFINITELY_DOCKED or
(self.docking_state & Dockable.TO_BE_DETERMINED and self.docking_state & Dockable.PREVIOUSLY_DOCKED))
[docs] def state_unsure(self):
"""
:return: True if our current docking state is not 100% sure (TO_BE_DETERMINED)
:rtype: bool
"""
return bool(self.docking_state & Dockable.TO_BE_DETERMINED)
[docs]class Elevator(Sprite):
"""
A simple elevator/moving platform class.
Can either go in x or in y direction, with a configurable speed and in between settable coordinate values.
Has a Dockable Component to be able to carry characters standing on top of it.
Is of type one_way_platform so one can jump on the Elevator also from below it.
"""
def __init__(self, x, y, direction="y", initial_veloc=50, max_pos=500, min_pos=0):
super().__init__(x, y, image_file="images/elevator.png")
self.direction = direction
self.vx = initial_veloc if direction == "x" else 0.0
self.vy = initial_veloc if direction == "y" else 0.0
self.max_pos = max_pos
self.min_pos = min_pos
# add Dockable component (so that objects can stand on the elevator and move along with it)
self.cmp_dockable = self.add_component(Dockable("dockable")) # type: Dockable
# adjust the type
self.type |= Sprite.get_type("dockable,one_way_platform")
self.collision_mask = 0 # don't do any collisions for this elevator (only other Sprites vs Elevator)
[docs] def tick(self, game_loop):
"""
Moving elevator up and down OR left and right.
"""
dt = game_loop.dt
self.move(self.vx * dt, self.vy * dt)
if self.direction == "x":
if self.rect.x < self.min_pos:
self.vx = abs(self.vx)
self.move(self.min_pos, None, absolute=True)
elif self.rect.x > self.max_pos:
self.vx = -abs(self.vx)
self.move(self.max_pos, None, absolute=True)
else:
if self.rect.y < self.min_pos:
self.vy = abs(self.vy)
self.move(None, self.min_pos, absolute=True)
elif self.rect.y > self.max_pos:
self.vy = -abs(self.vy)
self.move(None, self.max_pos, absolute=True)
[docs]class PhysicsComponent(Component, metaclass=ABCMeta):
"""
Defines an abstract generic physics component that can be added to agents (or enemies) to behave in the world.
GameObject's that own this Comonent may have a Brain component as well in order to steer behavior of the agent in `tick`.
Needs to override `tick` and `collision`.
"""
[docs] @staticmethod
def tile_sprite_handler(tile_sprite_class, layer):
"""
Populates the tile_sprites dict of a TiledTileLayer with tile_sprite_class (e.g. TileSprite or SlopedTileSprite) objects.
:param TiledTileLayer layer: the TiledTileLayer, whose tiles we would like to process and store (each one) in the returned np.ndarray
:param type tile_sprite_class: the TiledSprite subclass to use for generating TileSprite objects
:return: a 2D np.ndarray (x,y) with the created TileSprite objects for each x/y coordinate (None if there is no tile at a position)
:rtype: np.ndarray (2D)
"""
# set up ndarray
ret = np.ndarray(shape=(layer.pytmx_tiled_map.width, layer.pytmx_tiled_map.height), dtype=tile_sprite_class)
# loop through each tile and generate TileSprites
for x, y, gid in layer.pytmx_layer.iter_data():
# skip empty tiles (gid==0)
if gid == 0:
continue
tile_props = layer.pytmx_tiled_map.get_tile_properties_by_gid(gid) or {}
# go through dict and translate data types into proper python types ("true" -> bool, 0.0 -> float, etc..)
# also keep autobuild kwargs in a separate dict
look_for_autobuild = (True if tile_props.get("autobuild_class") else False)
autobuild_kwargs = {}
for key, value in tile_props.items():
value = convert_type(value)
# a special autobuild kwarg (for the autobuild c'tor)
if look_for_autobuild and key[:2] == "P_":
autobuild_kwargs[key[2:]] = value
else:
tile_props[key] = value
if look_for_autobuild:
tile_props["autobuild_kwargs"] = autobuild_kwargs
ret[x, y] = tile_sprite_class(layer, layer.pytmx_tiled_map, gid, tile_props,
pygame.Rect(x * layer.pytmx_tiled_map.tilewidth, y * layer.pytmx_tiled_map.tileheight,
layer.pytmx_tiled_map.tilewidth, layer.pytmx_tiled_map.tileheight))
return ret
# probably needs to be extended further by child classes
[docs] def added(self):
obj = self.game_object
# handle collisions
obj.on_event("collision", self, "collision", register=True)
# flag the GameObject as "handles collisions itself"
self.game_object.handles_own_collisions = True
# may determine x/y-speeds and movements of the GameObject (gravity, etc..)
[docs] @abstractmethod
def tick(self, game_loop: GameLoop):
"""
Needs to be called by the GameObject at some point during the GameObject's `tick` method.
:param GameLoop game_loop: the currently playing GameLoop object
"""
pass
[docs] @abstractmethod
def collision(self, col):
"""
This is the resolver for a Collision that happened between two Sprites under this PhysicsComponent.
:param Collision col: the Collision object describing the collision that already happened between two sprites
"""
pass
[docs]class ControlledPhysicsComponent(PhysicsComponent, metaclass=ABCMeta):
"""
When added to a GameObject, checks for an existing Brain Component and creates a property (self.game_obj_cmp_brain) for easy access.
"""
def __init__(self, name="physics"):
super().__init__(name)
self.game_obj_cmp_brain = None # the GameObject's HumanPlayerBrain component (used by Physics for steering and action control within `tick` method)
[docs] def added(self):
super().added()
self.game_obj_cmp_brain = self.game_object.components.get("brain", None)
# if there is a Component named `brain` in the GameObject it has to be of type Brain
assert not self.game_obj_cmp_brain or isinstance(self.game_obj_cmp_brain, Brain),\
"ERROR: {}'s `brain` Component is not of type Brain!".format(type(self.game_object).__name__)
[docs]class TopDownPhysics(ControlledPhysicsComponent):
"""
Defines "top-down-2D"-step physics (agent can move in any of the 4 directions using any step-size (smooth walking)).
To be addable to any character (player or enemy).
"""
def __init__(self, name="physics"):
super().__init__(name)
# velocities/physics stuff
self.vx = 0
self.vy = 0
self.run_acceleration = 300 # running acceleration
self.v_max = 150 # max run-speed
self.stops_abruptly_on_direction_change = True # Vikings stop abruptly when running in one direction, then the other direction is pressed
# environment stuff (TODO: where to get Level dimensions from?)
self.x_min = 0 # the minimum/maximum allowed positions
self.y_min = 0
self.x_max = 9000
self.y_max = 9000
self.touching = 0 # bitmap with those bits set that the entity is currently touching (colliding with)
# TODO: what does at_exit mean in terms of an MDP/RL-setting?
self.at_exit = False
[docs] def added(self):
super().added()
obj = self.game_object
self.x_max -= obj.rect.width
self.y_max -= obj.rect.height
obj.register_event("bump.top", "bump.bottom", "bump.left", "bump.right") # we will trigger these as well -> register them
# determines x/y-speeds and moves the GameObject
[docs] def tick(self, game_loop):
"""
Needs to be called by the GameObject at some point during the GameObject's `tick` method.
:param GameLoop game_loop: the currently playing GameLoop object
"""
dt = game_loop.dt
# accelerations
ax = 0
ay = 0
obj = self.game_object
stage = obj.stage
# entity has a brain component
if self.game_obj_cmp_brain:
# determine x speed
# -----------------
# user is trying to move left or right (or both?)
if self.game_obj_cmp_brain.commands["left"]:
# only left is pressed
if not self.game_obj_cmp_brain.commands["right"]:
if self.stops_abruptly_on_direction_change and self.vx > 0:
self.vx = 0 # stop first if still walking in other direction
ax = -self.run_acceleration # accelerate left
obj.flip["x"] = True # mirror sprite
# user presses both keys (left and right) -> just stop
else:
self.vx = 0
# only right is pressed
elif self.game_obj_cmp_brain.commands["right"]:
if self.stops_abruptly_on_direction_change and self.vx < 0:
self.vx = 0 # stop first if still walking in other direction
ax = self.run_acceleration # accelerate right
obj.flip["x"] = False
# stop immediately (vx=0; don't accelerate negatively)
else:
self.vx = 0
# determine y speed
# -----------------
# user is trying to move up or down (or both?)
if self.game_obj_cmp_brain.commands["up"]:
# only up is pressed
if not self.game_obj_cmp_brain.commands["down"]:
if self.stops_abruptly_on_direction_change and self.vy > 0:
self.vy = 0 # stop first if still walking in other direction
ay = -self.run_acceleration # accelerate up
# user presses both keys (up and down) -> just stop
else:
self.vy = 0
# only down is pressed
elif self.game_obj_cmp_brain.commands["down"]:
if self.stops_abruptly_on_direction_change and self.vy < 0:
self.vy = 0 # stop first if still walking in other direction
ay = self.run_acceleration # accelerate down
# stop immediately (vy=0; don't accelerate negatively)
else:
self.vy = 0
# entity has no steering unit (speed = 0)
else:
self.vx = 0
self.vy = 0
# TODO: check the entity's magnitude of vx and vy,
# reduce the max dt_step if necessary to prevent skipping through objects.
dt_step = dt
while dt_step > 0:
dt = min(1 / 30, dt_step)
# update x/y-velocity based on acceleration
self.vx += ax * dt
if abs(self.vx) > self.v_max:
self.vx = -self.v_max if self.vx < 0 else self.v_max
self.vy += ay * dt
if abs(self.vy) > self.v_max:
self.vy = -self.v_max if self.vy < 0 else self.v_max
# reset all touch flags before doing all the collision analysis
self.at_exit = False
# first move in x-direction and solve x-collisions
orig_pos = (obj.rect.x, obj.rect.y)
if self.vx != 0.0:
obj.move(self.vx * dt, 0.0)
if DEBUG_FLAGS & DEBUG_RENDER_SPRITES_BEFORE_COLLISION_DETECTION:
obj.render(game_loop.display)
game_loop.display.debug_refresh()
self.collide_in_one_direction(obj, "x", self.vx, orig_pos)
# then move in y-direction and solve y-collisions
if self.vy != 0.0:
obj.move(0.0, self.vy * dt)
if DEBUG_FLAGS & DEBUG_RENDER_SPRITES_BEFORE_COLLISION_DETECTION:
obj.render(game_loop.display)
game_loop.display.debug_refresh()
self.collide_in_one_direction(obj, "y", self.vy, orig_pos)
dt_step -= dt
[docs] def collide_in_one_direction(self, sprite, direction, direction_veloc, original_pos):
"""
Detects and solves all possible collisions between our GameObject and all Stage's objects (layers and Sprites) in one direction (x or y).
:param Sprite sprite: the GameObject of this Component (the moving/colliding Sprite)
:param str direction: either "x" or "y"
:param float direction_veloc: the velocity in the given direction (x/y-component of the velocity vector)
:param Tuple[int,int] original_pos: the position of the game_object before this collision detection execution
"""
stage = sprite.stage
# default layers
if sprite.collision_mask & Sprite.get_type("default"):
for layer in stage.tiled_tile_layers.values():
if layer.type & Sprite.get_type("default"):
self.collide_with_collision_layer(sprite, layer, direction, direction_veloc, original_pos)
# simple sprites (e.g. enemies)
for other_sprite in stage.sprites:
if sprite is other_sprite:
continue
if sprite.collision_mask & other_sprite.type:
col = AABBCollision.collide(sprite, other_sprite, direction=direction, direction_veloc=direction_veloc, original_pos=original_pos)
if col:
sprite.trigger_event("collision", col)
[docs] def collide_with_collision_layer(self, sprite, layer, direction, direction_veloc, original_pos):
"""
Collides a Sprite with a collision TiledTileLayer (type==default) and solves all detected collisions.
:param Sprite sprite: the Sprite to test for collisions against a TiledTileLayer
:param TiledTileLayer layer: the TiledTileLayer object in which to look for collision tiles (full of sloped)
:param str direction: `x` or `y` direction in which the sprite is currently moving before this test
:param float direction_veloc: the velocity in the given direction (could be negative or positive)
:param Tuple[int,int] original_pos: the position of the sprite before the move that caused this collision test to be executed
"""
# determine the tile boundaries (which tiles does the sprite overlap with?)
tile_start_x, tile_end_x, tile_start_y, tile_end_y = layer.get_overlapping_tiles(sprite)
# if sprite is moving in +/-x-direction:
# 1) move in columns from left to right (right to left) to look for tiles
if direction == 'x':
direction_x = int(math.copysign(1.0, direction_veloc))
for tile_x in range(tile_start_x if direction_x > 0 else tile_end_x, (tile_end_x if direction_x > 0 else tile_start_x) + direction_x, direction_x):
for tile_y in range(tile_start_y, tile_end_y + 1): # y-order doesn't matter
tile_sprite = layer.tile_sprites[tile_x, tile_y]
if tile_sprite:
col = AABBCollision.collide(sprite, tile_sprite, None, direction, direction_veloc, original_pos)
if col:
sprite.trigger_event("collision", col)
return
else:
direction_y = int(math.copysign(1.0, direction_veloc))
for tile_y in range(tile_start_y if direction_y > 0 else tile_end_y, (tile_end_y if direction_y > 0 else tile_start_y) + direction_y, direction_y):
for tile_x in range(tile_start_x, tile_end_x + 1): # x-order doesn't matter
tile_sprite = layer.tile_sprites[tile_x, tile_y]
if tile_sprite:
col = AABBCollision.collide(sprite, tile_sprite, None, direction, direction_veloc, original_pos)
if col:
sprite.trigger_event("collision", col)
return
[docs] def collision(self, col):
obj = self.game_object
assert obj is col.sprite1, "ERROR: game_object ({}) of physics component is not identical with passed in col.sprite1 ({})!".format(obj, col.sprite1)
assert hasattr(col, "sprite2"), "ERROR: no sprite2 in col-object!"
other_obj = col.sprite2
# collided with a tile (from a layer)
if isinstance(other_obj, TileSprite):
tile_props = other_obj.tile_props
# colliding with an exit
# TODO: what does exit mean? in a RL setting? end of episode?
if tile_props.get("exit"):
self.at_exit = True
obj.stage.options["screen_obj"].trigger_event("reached_exit", obj) # let the level know
return
# solve collision
obj.move(col.separate[0], col.separate[1])
# top/bottom collisions
if abs(col.normal_y) > 0.3:
if self.vy * col.normal_y < 0: # if normal_y < 0 -> vy is > 0 -> set to 0; if normal_x > 0 -> vy is < 0 -> set to 0
self.vy = 0
obj.trigger_event("bump." + ("bottom" if col.normal_y < 0 else "top"), col)
# left/right collisions
if abs(col.normal_x) > 0.3:
if self.vx * col.normal_x < 0: # if normal_y < 0 -> vx is > 0 -> set to 0; if normal_y > 0 -> vx is < 0 -> set to 0
self.vx = 0
obj.trigger_event("bump." + ("right" if col.normal_x < 0 else "left"), col)
[docs]class Viewport(Component):
"""
A viewport is a component that can be added to a Stage to help that Stage render the scene depending on scrolling/obj_to_follow certain GameObjects
- any GameObject with offset_x/y fields is supported, the Viewport will set these offsets to the Viewports x/y values
before each render call
"""
def __init__(self, display):
"""
:param Display display: the Display object associated with this Viewport
"""
super().__init__("viewport") # fix name to 'viewport' (only one viewport per Stage)
self.display = display # the pygame display (Surface) to draw on; so far we only need it to get the display's dimensions
# top/left corner (world coordinates) of the Viewport window
# - will be used as offset_x/y for the Display
self.x = 0
self.y = 0
# parameters used for shaking the Viewport (if something heavy lands on the ground)
self.is_shaking = False
self.shake_y = 0 # the current shake-y-offset
self.shake_time_total = 0
self.shake_time_switch = 0
self.shake_frequency = 5
self.scale = 1.0
self.directions = {}
self.obj_to_follow = None
self.max_speed = 10
self.bounding_box = None
[docs] def added(self):
assert isinstance(self.game_object, Stage), "ERROR: Viewport Component can only be added to a Stage, but game_objects is of type {}!".\
format(type(self.game_object).__name__)
self.game_object.on_event("pre_render", self, "pre_render")
self.extend(self.follow_object_with_viewport)
self.extend(self.unfollow_object_with_viewport)
self.extend(self.center_on_xy_with_viewport)
self.extend(self.move_to_xy_with_viewport)
self.extend(self.shake_viewport)
# EXTENSION methods (take self as well as GameObject as first two params)
[docs] def follow_object_with_viewport(self, stage, obj_to_follow, directions=None, bounding_box=None, max_speed=float("inf")):
"""
Makes the viewport follow a GameObject (obj_to_follow).
:param GameObject stage: our game_object (the Stage) that has `self` as component
:param GameObject obj_to_follow: the GameObject that we should follow
:param dict directions: dict with 'x' and 'y' set to either True or False depending on whether we follow only in x direction or y or both
:param dict bounding_box: should contain min_x, max_x, min_y, max_y so we know the boundaries of the camera
:param float max_speed: the max speed of the camera
"""
stage.off_event("post_tick", self, "follow")
if not directions:
directions = {"x": True, "y": True}
# this should be the level dimensions to avoid over-scrolling by the camera
# - if we don't have a Level (just a Screen), use the display's size
if not bounding_box: # get a default bounding box
# TODO: this is very specific to us having always a Stage (with options['screen_obj']) as our owning stage
w = self.game_object.screen.width if hasattr(self.game_object.screen, "width") else self.display.surface.get_width()
h = self.game_object.screen.height if hasattr(self.game_object.screen, "height") else self.display.surface.get_height()
bounding_box = {"min_x": 0, "min_y": 0, "max_x": w, "max_y": h}
self.directions = directions
self.obj_to_follow = obj_to_follow
self.bounding_box = bounding_box
self.max_speed = max_speed
stage.on_event("post_tick", self, "follow")
self.follow(first=(False if max_speed > 0.0 else True)) # start following
[docs] def unfollow_object_with_viewport(self, stage):
"""
Stops following.
:param GameObject stage: our game_object (the Stage) that has `self` as component
"""
stage.off_event("post_tick", self, "follow")
self.obj_to_follow = None
[docs] def center_on_xy_with_viewport(self, stage, x, y):
"""
Centers the Viewport on a given x/y position (so that the x/y position is in the center of the screen afterwards).
:param GameObject stage: our game_object (the Stage) that has `self` as component
:param int x: the x position to center on
:param int y: the y position to center on
"""
self.center_on(x, y)
[docs] def move_to_xy_with_viewport(self, stage, x, y):
"""
Moves the Viewport to the given x/y position (top-left corner, not center(!)).
:param GameObject stage: our game_object (the Stage) that has `self` as Component
:param int x: the x position to move to
:param int y: the y position to move to
"""
self.move_to(x, y)
[docs] def shake_viewport(self, stage, time=3, frequency=5):
"""
Shakes the Viewport object for the given time and with the given frequency.
:param GameObject stage: our game_object (the Stage) that has `self` as Component
:param float time: the amount of time (in sec) for which the Viewport should shake
:param floar frequency: the frequency (in up/down shakes per second) with which we should shake; higher numbers mean more rapid shaking
"""
self.is_shaking = True
self.shake_time_total = time
self.shake_frequency = frequency
self.shake_time_switch = 1 / (frequency * 2) # after this time, we have to switch direction (2 b/c up and down)
# END: EXTENSION METHODS
[docs] def follow(self, game_loop=None, first=False):
"""
Helper method to follow our self.obj_to_follow (should not be called by the API user).
Called when the Stage triggers Event 'post_tick' (passes GameLoop into it which is not used).
:param GameLoop game_loop: the GameLoop that's currently playing
:param bool first: whether this is the very first call to this function (if so, do a hard center on, otherwise a soft-center-on)
"""
follow_x = self.directions["x"](self.obj_to_follow) if callable(self.directions["x"]) else self.directions["x"]
follow_y = self.directions["y"](self.obj_to_follow) if callable(self.directions["y"]) else self.directions["y"]
func = self.center_on if first else self.soft_center_on
func(self.obj_to_follow.rect.centerx if follow_x else None, self.obj_to_follow.rect.centery if follow_y else None)
[docs] def soft_center_on(self, x=None, y=None):
"""
Soft-centers on a given x/y position respecting the Viewport's max_speed property (unlike center_on).
:param Union[int,None] x: the x position to center on (None if we should ignore the x position)
:param Union[int,None] y: the y position to center on (None if we should ignore the y position)
"""
if x:
dx = (x - self.display.width / 2 / self.scale - self.x) / 3 # //, this.followMaxSpeed);
if abs(dx) > self.max_speed:
dx = math.copysign(self.max_speed, dx)
if self.bounding_box:
if (self.x + dx) < self.bounding_box["min_x"]:
self.x = self.bounding_box["min_x"] / self.scale
elif self.x + dx > (self.bounding_box["max_x"] - self.display.width) / self.scale:
self.x = (self.bounding_box["max_x"] - self.display.width) / self.scale
else:
self.x += dx
else:
self.x += dx
if y:
dy = (y - self.display.height / 2 / self.scale - self.y) / 3
if abs(dy) > self.max_speed:
dy = math.copysign(self.max_speed, dy)
if self.bounding_box:
if self.y + dy < self.bounding_box["min_y"]:
self.y = self.bounding_box["min_y"] / self.scale
elif self.y + dy > (self.bounding_box["max_y"] - self.display.height) / self.scale:
self.y = (self.bounding_box["max_y"] - self.display.height) / self.scale
else:
self.y += dy
else:
self.y += dy
[docs] def center_on(self, x=None, y=None):
"""
Centers on a given x/y position without(!) respecting the Viewport's max_speed property (unlike soft_center_on).
:param Union[int,None] x: the x position to center on (None if we should ignore the x position)
:param Union[int,None] y: the y position to center on (None if we should ignore the y position)
"""
if x:
self.x = x - self.display.width / 2 / self.scale
if y:
self.y = y - self.display.height / 2 / self.scale
[docs] def move_to(self, x=None, y=None):
"""
Moves the Viewport to a given x/y position (top-left corner, not centering) without(!) respecting the Viewport's max_speed property.
:param Union[int,None] x: the x position to move to (None if we should ignore the x position)
:param Union[int,None] y: the y position to move to (None if we should ignore the y position)
"""
if x:
self.x = x
if y:
self.y = y
return self.game_object # ?? why
[docs] def tick(self, game_loop):
if self.is_shaking:
dt = game_loop.dt
self.shake_time_total -= dt
# done shaking
[docs] def pre_render(self, display):
"""
Sets the offset property of the given Display so that it matches our (previously) calculated x/y values.
:param Display display: the Display, whose offset we will change here
"""
self.display.offsets[0] = self.x
self.display.offsets[1] = self.y
[docs]class Screen(EventObject, metaclass=ABCMeta):
"""
A Screen object has a play and a done method that need to be implemented.
The play method stages the Screen on a Stage.
The done method can do some cleanup.
"""
def __init__(self, name: str = "start", **kwargs):
super().__init__()
self.name = name
self.id = kwargs.get("id", 0) # type: int
# handle keyboard inputs
self.keyboard_inputs = kwargs.get("keyboard_inputs", KeyboardInputs([])) # type: KeyboardInputs
# our Display object
self.display = kwargs.get("display", None) # type: Display
self.max_fps = kwargs.get("max_fps", 60) # type: float
[docs] @abstractmethod
def play(self):
pass
[docs] @abstractmethod
def done(self):
pass
[docs]class SimpleScreen(Screen):
"""
A simple Screen that has support for labels and sprites (static images) shown on the screen.
"""
def __init__(self, name, **kwargs):
super().__init__(name, **kwargs)
self.sprites = (kwargs["sprites"] if "sprites" in kwargs else [])
# labels example: {x: Q.width / 2, y: 220, w: 150, label: "NEW GAME", color: "white", align: "left", weight: "900", size: 22, family: "Fixedsys"},
self.labels = (kwargs["labels"] if "labels" in kwargs else [])
## TODO: audio? self.audio = kwargs["audio"] if "audio" in kwargs else []
[docs] @staticmethod
def screen_func(stage: Stage):
"""
Defines this screen's Stage setup.
Stage functions are used to setup a Stage (before playing it).
:param Stage stage: the Stage to be setup
"""
# get the Screen object (instance) from the options
screen = stage.options["screen_obj"]
# insert labels to screen
for label_def in screen.labels:
# generate new Font object
font = pygame.font.Font(None, label_def["size"])
surf = font.render(label_def["text"], 1, pygame.Color(label_def["color"]))
sprite = Sprite(label_def["x"], label_def["y"], surf)
stage.add_sprite(sprite, "labels")
# insert objects to screen
for game_obj in screen.game_objects:
stage.add_sprite(game_obj, "sprites")
[docs] def play(self):
"""
Plays the Screen.
"""
# start screen (will overwrite the old 0-stage (=main-stage))
# - also, will give our keyboard-input setup to the new GameLoop object
Stage.stage_screen(self, SimpleScreen.screen_func, stage_idx=0)
[docs] def done(self):
print("we're done!")
[docs]class Level(Screen, metaclass=ABCMeta):
"""
A Level class adds tmx file support to the Screen.
TiledTileLayers (background, collision, foreground, etc..) as well as single Sprite objects can be defined in the tmx file.
"""
def __init__(self, name: str = "test", **kwargs):
super().__init__(name, **kwargs)
# TODO: warn here if keyboard_inputs is given (should be given in tmx file exclusively)
self.tmx_file = kwargs.get("tmx_file", "data/" + name.lower() + ".tmx")
# load in the world's tmx file
self.tmx_obj = pytmx.load_pygame(self.tmx_file)
self.width = self.tmx_obj.width * self.tmx_obj.tilewidth
self.height = self.tmx_obj.height * self.tmx_obj.tileheight
self.register_event("mastered", "aborted", "lost")
# get keyboard_inputs directly from the pytmx object
if not self.keyboard_inputs:
key_list = self.tmx_obj.properties.get("keyboard_inputs", "")
assert len(key_list) > 0, "ERROR: tmx file needs a global map property `keyboard_inputs` such as e.g. `up,down,left,right`"
descriptions = key_list.split(",")
self.keyboard_inputs = KeyboardInputs(descriptions)
# populates a Stage with this Level by going through the tmx file layer by layer and adding it
# - unlike SimpleScreen, uses only the tmx file for adding things to the Stage
[docs] @staticmethod
def screen_func(stage):
"""
Sets up the Stage by adding all layers (one-by-one) from the tmx file to the Stage.
:param Stage stage:
"""
assert isinstance(stage.screen, Level), "ERROR: screen property of a Stage that uses Level.screen_func to stage a Screen must be a Level object!"
# force add the default physics functions to the Stage's options
defaults(stage.options, {"components": [Viewport(stage.screen.display)],
"physics_collision_detector": AABBCollision.collide,
"tile_sprite_handler": functools.partial(PhysicsComponent.tile_sprite_handler, TileSprite)
})
for layer in stage.screen.tmx_obj.layers:
stage.add_tiled_layer(layer, stage.screen.tmx_obj)
[docs] def play(self):
"""
Start level (stage the scene; will overwrite the old 0-stage (=main-stage)).
The options-object below will be also stored in [Stage object].options.
Child Level classes only need to do these three things: a) stage a screen, b) register some possible events, c) play a new game loop.
"""
Stage.stage_screen(self, None, stage_idx=0, options={
"tile_sprite_handler": functools.partial(PhysicsComponent.tile_sprite_handler, TileSprite),
#"components": [Viewport(self.display)]
})
# activate level triggers
self.on_event("agent_reached_exit", self, "done", register=True)
# play a new GameLoop giving it some options
GameLoop.play_a_loop(screen_obj=self)
[docs] def done(self):
Stage.get_stage().stop()
# switch off keyboard
self.keyboard_inputs.update_keys([]) # empty list -> no more keys
[docs]class Game(object):
"""
An object that serves as a container for Screen and Level objects.
Manages displaying the screens (start screen, menus, etc..) and playable levels of the game.
Also keeps a Display object (and determines its size), which is used for rendering and displaying the game.
"""
instantiated = False
def __init__(self, screens_and_levels, width=0, height=0, title="spygame Demo!", max_fps=60, debug_flags=DEBUG_NONE):
"""
:param list screens_and_levels: a list of Screen and Level definitions. Each item is a dict with
:param int width: the width of the screen in pixels (0 for auto)
:param int height: the height of the screen in pixels (0 for auto)
:param str title: the title of the game (will be displayed as the game Window caption)
:param int max_fps: the max. number of frames in one second (could be less if Game runs slow, but never more)
:param int debug_flags: a bitmap for setting different debug flags (see global variables DEBUG_...)
"""
assert not Game.instantiated, "ERROR: can only create one {} object!".format(type(self).__name__)
Game.instantiated = True
# init the pygame module (if this did not already happen)
pygame.init()
self.screens_by_name = {} # holds the Screen objects by key=level-name
self.screens = [] # list of screens
self.levels_by_name = {} # holds the Level objects by key=level-name
self.levels = [] # sorted list of levels
self.max_fps = max_fps
# try this: set debug flags globally
global DEBUG_FLAGS
DEBUG_FLAGS = debug_flags
# create the Display object for the entire game: we pass it to all levels and screen objects
self.display = Display(width, height, title) # use widthxheight for now (default); this will be reset to the largest Level dimensions further below
# our levels (if any) determine the size of the display
get_w_from_levels = True if width == 0 else False
get_h_from_levels = True if height == 0 else False
# initialize all screens and levels
for i, screen_or_level in enumerate(screens_and_levels):
name = screen_or_level.pop("name", "screen{:02d}".format(i))
id_ = screen_or_level.pop("id", 0)
keyboard_inputs = screen_or_level.pop("keyboard_inputs", None)
max_fps = screen_or_level.pop("max_fps", self.max_fps)
# Screen class has to be given since Screen (as a default) would be abstract
assert "class" in screen_or_level, "ERROR: Game object needs the 'class' property for all given Screens and Levels!"
assert issubclass(screen_or_level["class"], Screen), "ERROR: Game object needs the 'class' property to be a subclass of Screen!"
class_ = screen_or_level["class"]
# only distinguish between Level and "regular" Screen
if issubclass(class_, Level):
level = class_(name, id=id_, display=self.display, keyboard_inputs=keyboard_inputs, max_fps=max_fps, **screen_or_level)
self.levels_by_name[name] = level
self.levels.append(level)
# register events
level.on_event("mastered", self, "level_mastered")
level.on_event("aborted", self, "level_aborted")
level.on_event("lost", self, "level_lost")
# store level dimensions for display
if get_w_from_levels and level.width > width:
width = level.width
if get_h_from_levels and level.height > height:
height = level.height
# a Screen
else:
screen = class_(name, id=id_, display=self.display, keyboard_inputs=keyboard_inputs, max_fps=max_fps, **screen_or_level)
self.screens_by_name[name] = screen
self.screens.append(screen)
# now that we know all Level sizes, change the dims of the pygame.display if width and/or height were Level-dependent
if (get_w_from_levels and width > 0) or (get_h_from_levels and height > 0):
# static method
self.display.change_dims(width, height)
[docs] def get_next_level(self, level):
"""
returns the next level (if exists) as object; None if no next level
:param Level level: the Level, whose next Level we would like to get
:return: the next Level after level; None if no next Level exists
:rtype: Union[Level,None]
"""
try:
next_ = self.levels[(level if isinstance(level, int) else level.id) + 1]
except IndexError:
next_ = None
return next_
[docs] def level_mastered(self, level):
"""
a level has been successfully finished -> play next one
:param Level level: the Level object that has been mastered
"""
next_ = self.get_next_level(level)
if next_:
next_.play()
else:
print("All done!! Congrats!!")
self.level_aborted(level)
[docs] def level_lost(self, level):
"""
a level has been lost
:param Level level: the Level object in which the loss happened
"""
print("Game Over!")
self.level_aborted(level)
[docs] def level_aborted(self, level):
"""
aborts the level and tries to play the "start" screen
:param Level level: the Level object that has been aborted
"""
Stage.clear_stages()
screen = self.screens_by_name.get("start")
if screen:
screen.play()
else:
quit()
[docs]class CollisionAlgorithm(object):
"""
A static class that is used to store a collision algorithm.
"""
# the default collision objects
# - can be overridden via the collide method
default_collision_objects = (Collision(), Collision())
[docs] @staticmethod
@abstractmethod
def collide(sprite1, sprite2, collision_objects=None, original_pos=None):
"""
solves a simple spatial collision problem for two Sprites (that have a rect property)
- defaults to SAT collision between two objects
- thanks to doc's at: http://www.sevenson.com.au/actionscript/sat/
- TODO: handle angles on objects
- TODO: handle velocities of sprites prior to collision to calculate correct normals
:param Sprite sprite1: sprite 1
:param Sprite sprite2: sprite 2 (the other sprite)
:param Union[None,Tuple[Collision]] collision_objects: the two always-recycled returnable Collision instances (aside from None); if None,
use our default ones
:param Union[Tuple[int],None] original_pos: the position of sprite1 before doing the move that lead to this collision-detection call
:return: a Collision object with all details of the collision between the two Sprites (None if there is no collision)
:rtype: Union[None,Collision]
"""
pass
[docs]class AABBCollision(CollisionAlgorithm):
"""
A simple axis-aligned bounding-box collision mechanism which only works on Pygame rects.
"""
[docs] @staticmethod
def collide(sprite1, sprite2, collision_objects=None, direction='x', direction_veloc=0.0, original_pos=None):
# TODO: actually, we only need one collision object as we should always only resolve one object at a time
# TODO: utilize direction veloc information to only return the smallest separation collision
# direction must be given AND direction_veloc must not be 0.0
#assert direction == "x" or direction == "y", "ERROR: in AABB collision between {} and {}: direction needs to be either 'x' or 'y'!". \
# format(type(sprite1).__name__, type(sprite2).__name__)
#assert direction_veloc != 0.0, "ERROR in AABB collision between {} and {}: direction_veloc must not be 0.0!".\
# format(type(sprite1).__name__, type(sprite2).__name__)
# use default CollisionObjects?
if not collision_objects:
collision_objects = AABBCollision.default_collision_objects
ret = AABBCollision.try_collide(sprite1, sprite2, collision_objects[0], direction, direction_veloc)
if not ret:
return None
if not ret.is_collided:
return None
# fill in some more values in the recycled Collision object before returning it
ret.separate[0] = - ret.distance * ret.normal_x
ret.separate[1] = - ret.distance * ret.normal_y
if not original_pos:
original_pos = (sprite1.rect.x, sprite1.rect.y)
ret.original_pos = original_pos
return ret
[docs] @staticmethod
def try_collide(o1, o2, collision_obj, direction, direction_veloc):
"""
does the actual AABB collision test
:param Sprite o1: object 1
:param Sprite o2: object 2
:param Collision collision_obj: the collision object to be populated
:param str direction: the direction in which we have to measure a collision (x or y)
:param float direction_veloc: the velocity value in the given x- or y-direction
:return: the populated Collision object
:rtype: Collision
"""
# reset the recycled collision object
collision_obj.is_collided = False
collision_obj.normal_x = 0.0
collision_obj.normal_y = 0.0
collision_obj.magnitude = 0.0
collision_obj.direction = direction
collision_obj.direction_veloc = direction_veloc
# overlap?
if o1.rect.right > o2.rect.left and o1.rect.left < o2.rect.right and o1.rect.bottom > o2.rect.top and o1.rect.top < o2.rect.bottom:
collision_obj.sprite1 = o1
collision_obj.sprite2 = o2
collision_obj.is_collided = True
if direction == "x":
if direction_veloc > 0:
collision_obj.distance = -(o1.rect.right - o2.rect.left)
collision_obj.normal_x = -1.0
elif direction_veloc < 0:
collision_obj.distance = -(o2.rect.right - o1.rect.left)
collision_obj.normal_x = 1.0
else:
if direction_veloc > 0:
collision_obj.distance = -(o1.rect.bottom - o2.rect.top)
collision_obj.normal_y = -1.0
elif direction_veloc < 0:
collision_obj.distance = -(o2.rect.bottom - o1.rect.top)
collision_obj.normal_y = 1.0
collision_obj.magnitude = abs(collision_obj.distance)
return collision_obj if collision_obj.is_collided else None
# TODO: SATCollisions are WIP
[docs]class SATCollision(CollisionAlgorithm):
normal = [0.0, 0.0]
[docs] @staticmethod
def collide(sprite1, sprite2, collision_objects=None, original_pos=None):
# use default CollisionObjects?
if not collision_objects:
collision_objects = SATCollision.default_collision_objects
# do AABB first for a likely early out
# TODO: right now, we only have pygame.Rect anyway, so these are AABBs
if (sprite1.rect.right < sprite2.rect.left or sprite1.rect.bottom < sprite2.rect.top or
sprite2.rect.right < sprite1.rect.left or sprite2.rect.right < sprite1.rect.left):
return None
test = SATCollision.try_collide(sprite1, sprite2, collision_objects[0], False)
if not test:
return None
test = SATCollision.try_collide(sprite2, sprite1, collision_objects[1], True)
if not test:
return None
# pick the best collision from the two
ret = collision_objects[1] if collision_objects[1].magnitude < collision_objects[0].magnitude else collision_objects[0]
if not ret.is_collided:
return None
# fill in some more values in the recycled Collision object before returning it
ret.separate[0] = - ret.distance * ret.normal_x
ret.separate[1] = - ret.distance * ret.normal_y
if not original_pos:
original_pos = (sprite1.rect.x, sprite1.rect.y)
ret.original_pos = original_pos
return ret
[docs] @staticmethod
def calculate_normal(points, idx):
pt1 = points[idx]
pt2 = points[idx + 1] if idx < len(points) - 1 else points[0]
SATCollision.normal[0] = -(pt2[1] - pt1[1])
SATCollision.normal[1] = pt2[0] - pt1[0]
dist = math.sqrt(SATCollision.normal[0] ** 2 + SATCollision.normal[1] ** 2)
if dist > 0:
SATCollision.normal[0] /= dist
SATCollision.normal[1] /= dist
[docs] @staticmethod
def dot_product_against_normal(point):
return (SATCollision.normal[0] * point[0]) + (SATCollision.normal[1] * point[1])
[docs] @staticmethod
def try_collide(o1, o2, collision_obj, flip):
shortest_dist = float("inf")
collision_obj.is_collided = False
# the following only works for AABBs, we will have to change that once objects start rotating or being non-rects
p1 = [[o1.rect.x, o1.rect.y], [o1.rect.x + o1.rect.width, o1.rect.y],
[o1.rect.x + o1.rect.width, o1.rect.y + o1.rect.height], [o1.rect.x, o1.rect.y + o1.rect.height]]
p2 = [[o2.rect.x, o2.rect.y], [o2.rect.x + o2.rect.width, o2.rect.y],
[o2.rect.x + o2.rect.width, o2.rect.y + o2.rect.height], [o2.rect.x, o2.rect.y + o2.rect.height]]
# loop through all axes of sprite1
for i in range(len(p1)):
SATCollision.calculate_normal(p1, i)
min1 = SATCollision.dot_product_against_normal(p1[0])
max1 = min1
for j in range(1, len(p1)):
tmp = SATCollision.dot_product_against_normal(p1[j])
if tmp < min1:
min1 = tmp
if tmp > max1:
max1 = tmp
min2 = SATCollision.dot_product_against_normal(p2[0])
max2 = min2
for j in range(1, len(p2)):
tmp = SATCollision.dot_product_against_normal(p2[j])
if tmp < min2:
min2 = tmp
if tmp > max2:
max2 = tmp
d1 = min1 - max2
d2 = min2 - max1
if d1 > 0 or d2 > 0:
return None
min_dist = (max2 - min1) * -1
if flip:
min_dist *= -1
min_dist_abs = abs(min_dist)
if min_dist_abs < shortest_dist:
collision_obj.sprite1 = o1
collision_obj.sprite2 = o2
collision_obj.distance = min_dist
collision_obj.magnitude = min_dist_abs
collision_obj.normal_x = SATCollision.normal[0]
collision_obj.normal_y = SATCollision.normal[1]
if collision_obj.distance > 0:
collision_obj.distance *= -1
collision_obj.normal_x *= -1
collision_obj.normal_y *= -1
collision_obj.is_collided = True
shortest_dist = min_dist_abs
# return the actual collision
return collision_obj if collision_obj.is_collided else None
[docs]def defaults(dictionary, defaults_dict):
"""
Adds all key/value pairs from defaults_dict into dictionary, but only if dictionary doesn't have the key defined yet.
:param dict dictionary: the target dictionary
:param dict defaults_dict: the source (default) dictionary to take the keys from (only if they are not defined in dictionary
"""
for key, value in defaults_dict.items():
if key not in dictionary: # overwrite only if key is missing
dictionary[key] = value
# OBSOLETE: use
[docs]def extend(dictionary, extend_dict):
"""
Extends the dictionary with extend_dict, thereby overwriting existing keys.
:param dict dictionary: the target dictionary
:param dict extend_dict: the source (extension) dictionary to take the keys from (even if they are not defined in dictionary)
"""
for key, value in extend_dict.items():
dictionary[key] = value # overwrite no matter what
[docs]def convert_type(value, force_class=False):
"""
Converts the given value from a string (or other) type into the most likely type.
E.g.
'some text' -> 'some text' (str)
'1' -> 1 (int)
'-51' -> -51 (int)
'0.1' -> 0.1 (float)
'true' -> True (bool)
'False' -> False (bool)
[1, 2, 3] -> [1, 2, 3] (list)
spygame.Ladder -> <type spygame.Ladder> (a python class object; can be used as a ctor to construct objects of that class)
:param any value: the given value to be converted to the most-likely python type
:param bool force_class: if True, we will interpret even simple strings (starting with upper case but without any dots) as class names (e.g. Ladder)
:return: the converted value
:rtype: any
"""
as_str = str(value)
# int
if re.fullmatch('-?\\d+', as_str):
return int(value)
# float
elif re.fullmatch('-?\d+\.\d+', as_str):
return float(value)
# bool
elif re.fullmatch('(true|false)', as_str, flags=re.I):
return value in ("True", "true")
else:
match_obj = re.fullmatch('^((.+)\.)?([A-Z][a-zA-Z0-9]+)$', as_str)
# a class with preceding modules (force_class does not have to be set to trigger this detection)
if match_obj:
_, module_, class_ = match_obj.groups(default="__main__") # if no module given, assume a class defined in __main__
ctor = getattr(sys.modules[module_], class_, None)
assert isinstance(ctor, type), "ERROR: the string {}.{} does not resolve into a defined class!".format(module_, class_)
return ctor
# a class (no modules, but force_class is set to True)
elif force_class:
match_obj = re.fullmatch('^([A-Z][a-zA-Z0-9]+)$', as_str)
if match_obj:
(class_) = match_obj.groups()
ctor = getattr(sys.modules["__main__"], class_, None)
assert isinstance(ctor, type), "ERROR: the string {} does not resolve into a defined class!".format(class_)
return ctor
# str (or list or others)
return value
[docs]def get_kwargs_from_obj_props(obj_props):
"""
returns a kwargs dict retrieved from a single object's properties in a level-tmx TiledObjectGroup
"""
kwargs = {}
for key, value in obj_props.items():
# special cases
# a spritesheet (filename)
if key == "tsx":
kwargs["sprite_sheet"] = SpriteSheet("data/" + value + ".tsx")
# an image_file
elif key == "img":
kwargs["image_file"] = "images/" + value + ".png"
# a width/height information for the collision box
elif key == "width_height":
kwargs["width_height"] = tuple(map(lambda x: convert_type(x), value.split(",")))
# vanilla kwarg
else:
kwargs[key] = convert_type(value)
return kwargs