"""
Sprite class for encapsulating all visible objects in ggame applications.
"""
import os
import math
from ggame.sysdeps import GFX_Sprite
from ggame.asset import (
RectangleAsset,
CircleAsset,
ImageAsset,
PolygonAsset,
EllipseAsset,
TextAsset,
LineAsset,
)
from ggame.app import App
# pylint: disable=useless-object-inheritance
[docs]
class Sprite(object): # pylint: disable=too-many-public-methods
"""
The Sprite class combines the idea of a visual/graphical asset, a
position on the screen, and *behavior*. Although the Sprite can be
used as-is, it is generally subclassed to give it some desired behavior.
When subclassing the Sprite class, you may customize the initialization
code to use a specific asset. A 'step' or 'poll' method may be added
for handling per-frame actions (e.g. checking for collisions). Step or poll
functions are not automatically called by the :class:`~ggame.app.App` class,
but you may subclass the :class:`~ggame.app.App` class in order to do this.
Furthermore, you may wish to define event callback methods in your customized
sprite class. With customized creation, event handling, and periodic processing
you can achieve fully autonomous behavior for your sprite objects.
:param asset asset: An existing graphical asset
:param tuple(int,int) pos: The sprite position may be provided, which
specifies the starting (x,y) coordinates of the sprite on the screen.
By default, the position of a sprite defines the location of its upper-left
hand corner. This behavior can be modified by customizing its
:data:`center`.
:param asset edgedef: An edge definition asset may be provided, which
specifies an asset that will be used to define the boundaries of
the sprite for the purpose of collision detection. If no `edgedef`
asset is given, the required asset is used, which will be a rectangular
asset in the case of an image texture. This option is typically used
to define a visible image outline for a texture-based sprite that has
a transparent texture image background.
:returns: Nothing. If the position is on screen the sprite will be displayed
in the browser.
Example of use:
.. literalinclude:: ../examples/spritesprite.py
This creates a sprite using the 'player.png' image, positioned with its
upper-left corner at coordinates (100,100) and with a 50 pixel radius
circular collision border.
"""
def __init__(self, asset, pos=(0, 0), edgedef=None):
self._index = 0
if isinstance(asset, ImageAsset):
self.asset = asset
try:
self.gfx = GFX_Sprite(asset.gfx) # gfx is PIXI Sprite
except: # pylint: disable=bare-except
self.gfx = None
raise
elif isinstance(
asset, (RectangleAsset, CircleAsset, EllipseAsset, PolygonAsset, LineAsset)
):
self.asset = asset
self.gfx = GFX_Sprite(asset.gfx.generateTexture())
elif isinstance(asset, TextAsset):
self.asset = asset.clone()
self.gfx = self.asset.gfx # gfx is PIXI Text (from Sprite)
self.gfx.visible = True
if not edgedef:
self.edgedef = asset
else:
self.edgedef = edgedef
self.xmin = self.xmax = self.ymin = self.ymax = 0
self.position = pos
"""Tuple indicates the position of the sprite on the screen."""
self._extentsdirty = True
"""Boolean indicates if extents must be calculated before collision test"""
self._createBaseVertices()
self._absolutevertices = None
self.setExtents()
App.add(self)
def _createBaseVertices(self):
"""
Create sprite-relative list of vertex coordinates for boundary
"""
self._basevertices = []
assettype = type(self.edgedef)
if assettype in [RectangleAsset, ImageAsset, TextAsset]:
self._basevertices = [
(0, 0),
(0, self.edgedef.height),
(self.edgedef.width, self.edgedef.height),
(self.edgedef.width, 0),
]
elif assettype in [PolygonAsset, LineAsset]:
if assettype is PolygonAsset:
self._basevertices = self.edgedef.path[:-1]
elif assettype is LineAsset:
self._basevertices = [
(0, 0),
(self.edgedef.delta_x, self.edgedef.delta_y),
]
xpoints, ypoints = zip(*self._basevertices)
xmin = min(xpoints)
ymin = min(ypoints)
xpoints = [x - xmin for x in xpoints]
ypoints = [y - ymin for y in ypoints]
self._basevertices = list(zip(xpoints, ypoints))
elif assettype is EllipseAsset:
w = self.edgedef.halfw * 2
h = self.edgedef.halfh * 2
self._basevertices = [(0, 0), (0, h), (w, h), (w, 0)]
def _xformVertices(self):
"""
Create window-relative list of vertex coordinates for boundary
"""
# find center as sprite-relative points (note sprite may be scaled)
x = self.width * self.fxcenter / self.scale
y = self.height * self.fycenter / self.scale
if self.scale != 1.0:
sc = self.scale
# center-relative, scaled coordinates
crsc = [((xp - x) * sc, (yp - y) * sc) for xp, yp in self._basevertices]
else:
crsc = [(xp - x, yp - y) for xp, yp in self._basevertices]
# absolute, rotated coordinates
c = math.cos(self.rotation)
s = math.sin(self.rotation)
self._absolutevertices = [
(self.x + x * c + y * s, self.y + -x * s + y * c) for x, y in crsc
]
[docs]
def setExtents(self):
"""
update min/max x and y based on position, center, width, height
"""
if self._extentsdirty:
if isinstance(self.edgedef, CircleAsset):
th = (
math.atan2(self.fycenter - 0.5, 0.5 - self.fxcenter) + self.rotation
)
d = self.edgedef.radius * 2 * self.scale
l = (
math.sqrt(
math.pow(self.fxcenter - 0.5, 2)
+ math.pow(self.fycenter - 0.5, 2)
)
* d
)
self.xmin = self.x + int(l * math.cos(th)) - d // 2
self.ymin = self.y - int(l * math.sin(th)) - d // 2
self.xmax = self.xmin + d
self.ymax = self.ymin + d
else:
# Build vertex list
self._xformVertices()
x, y = zip(*self._absolutevertices)
self.xmin = min(x)
self.xmax = max(x)
self.ymin = min(y)
self.ymax = max(y)
self._extentsdirty = False
[docs]
def firstImage(self):
"""
Select and display the *first* image used by this sprite. This only
does something useful if the asset is an :class:`~ggame.asset.ImageAsset`
defined with multiple images.
"""
self.gfx.texture = self.asset[0]
[docs]
def lastImage(self):
"""
Select and display the *last* image used by this sprite. This only
does something useful if the asset is an :class:`~ggame.asset.ImageAsset`
defined with multiple images.
"""
self.gfx.texture = self.asset[-1]
[docs]
def nextImage(self, wrap=False):
"""
Select and display the *next* image used by this sprite.
If the current image is already the *last* image, then
the image is not advanced.
:param boolean wrap: If `True`, then calling
:meth:`nextImage` on the last image will cause the *first*
image to be loaded.
This only does something useful if the asset is an
:class:`~ggame.asset.ImageAsset` defined with multiple images.
"""
self._index += 1
if self._index >= len(self.asset):
if wrap:
self._index = 0
else:
self._index = len(self.asset) - 1
self.gfx.texture = self.asset[self._index]
[docs]
def prevImage(self, wrap=False):
"""
Select and display the *previous* image used by this sprite.
If the current image is already the *first* image, then
the image is not changed.
:param boolean wrap: If `True`, then calling
:meth:`prevImage` on the first image will cause the *last*
image to be loaded.
This only does something useful if the asset is an
:class:`~ggame.asset.ImageAsset` defined with multiple images.
"""
self._index -= 1
if self._index < 0:
if wrap:
self._index = len(self.asset) - 1
else:
self._index = 0
self.gfx.texture = self.asset[self._index]
[docs]
def setImage(self, index=0):
"""
Select the image to display by giving its `index`.
:param int index: An index to specify the image to display.
A value of zero represents the *first* image in the asset.
This is equivalent to setting the :data:`index` property
directly.
This only does something useful if the asset is an
:class:`~ggame.asset.ImageAsset` defined with multiple images.
"""
self.index = index
def rectangularCollisionModel(self):
"""
Obsolete. No op.
"""
def circularCollisionModel(self):
"""
Obsolete. No op.
"""
@property
def index(self):
"""
This is an integer index into the list of images available for this sprite.
"""
return self._index
@index.setter
def index(self, value):
self._index = value
try:
self.gfx.texture = self.asset[self._index]
except: # pylint: disable=bare-except
self._index = 0
self.gfx.texture = self.asset[self._index]
@property
def width(self):
"""
This is an integer representing the display width of the sprite.
Assigning a value to the width will scale the image horizontally.
"""
return self.gfx.width
@width.setter
def width(self, value):
self.gfx.width = value
self._extentsdirty = True
@property
def height(self):
"""
This is an integer representing the display height of the sprite.
Assigning a value to the height will scale the image vertically.
"""
return self.gfx.height
@height.setter
def height(self, value):
self.gfx.height = value
self._extentsdirty = True
@property
def x(self):
"""
This represents the x-coordinate of the sprite on the screen. Assigning
a value to this attribute will move the sprite horizontally.
"""
return self.gfx.position.x
@x.setter
def x(self, value):
delta_x = value - self.gfx.position.x
self.xmax += delta_x
self.xmin += delta_x
# Adjust extents directly with low overhead
self.gfx.position.x = value
@property
def y(self):
"""
This represents the y-coordinate of the sprite on the screen. Assigning
a value to this attribute will move the sprite vertically.
"""
return self.gfx.position.y
@y.setter
def y(self, value):
delta_y = value - self.gfx.position.y
self.ymax += delta_y
self.ymin += delta_y
# Adjust extents directly with low overhead
self.gfx.position.y = value
@property
def position(self):
"""
This represents the (x,y) coordinates of the sprite on the screen. Assigning
a value to this attribute will move the sprite to the new coordinates.
"""
return (self.gfx.position.x, self.gfx.position.y)
@position.setter
def position(self, value):
self.x, self.y = value
@property
def fxcenter(self):
"""
This represents the horizontal position of the sprite "center", as a floating
point number between 0.0 and 1.0. A value of 0.0 means that the x-coordinate
of the sprite refers to its left hand edge. A value of 1.0 refers to its
right hand edge. Any value in between may be specified. Values may be assigned
to this attribute.
"""
try:
return self.gfx.anchor.x
except: # pylint: disable=bare-except
return 0.0
@fxcenter.setter
def fxcenter(self, value):
try:
self.gfx.anchor.x = value
self._extentsdirty = True
except: # pylint: disable=bare-except
pass
@property
def fycenter(self):
"""
This represents the vertical position of the sprite "center", as a floating
point number between 0.0 and 1.0. A value of 0.0 means that the x-coordinate
of the sprite refers to its top edge. A value of 1.0 refers to its
bottom edge. Any value in between may be specified. Values may be assigned
to this attribute.
"""
try:
return self.gfx.anchor.y
except: # pylint: disable=bare-except
return 0.0
@fycenter.setter
def fycenter(self, value):
try:
self.gfx.anchor.y = value
self._extentsdirty = True
except: # pylint: disable=bare-except
pass
@property
def center(self):
"""
This attribute represents the horizontal and vertical position of the
sprite "center" as a tuple of floating point numbers. See the
descriptions for :data:`fxcenter` and :data:`fycenter` for
more details.
"""
try:
return (self.gfx.anchor.x, self.gfx.anchor.y)
except: # pylint: disable=bare-except
return (0.0, 0.0)
@center.setter
def center(self, value):
try:
self.gfx.anchor.x = value[0]
self.gfx.anchor.y = value[1]
self._extentsdirty = True
except: # pylint: disable=bare-except
pass
@property
def visible(self):
"""
This boolean attribute may be used to change the visibility of the sprite.
Setting `~ggame.Sprite.visible` to `False` will prevent the sprite from
rendering on the screen.
"""
return self.gfx.visible
@visible.setter
def visible(self, value):
self.gfx.visible = value
@property
def scale(self):
"""
This attribute may be used to change the size of the sprite ('scale' it) on the
screen. Value may be a floating point number. A value of 1.0 means that the
sprite image will keep its original size. A value of 2.0 would double it, etc.
"""
try:
return self.gfx.scale.x
except AttributeError:
return 1.0
@scale.setter
def scale(self, value):
self.gfx.scale.x = value
self.gfx.scale.y = value
self._extentsdirty = True
@property
def rotation(self):
"""
This attribute may be used to change the rotation of the sprite on the screen.
Value may be a floating point number. A value of 0.0 means no rotation. A value
of 1.0 means a rotation of 1 radian in a counter-clockwise direction. One
radian is 180/pi or approximately 57.3 degrees.
"""
try:
return -self.gfx.rotation
except AttributeError:
return 0.0
@rotation.setter
def rotation(self, value):
if self.gfx.rotation != -value:
self.gfx.rotation = -value
self._extentsdirty = True
[docs]
@classmethod
def collidingCircleWithPoly(cls, circ, poly): # pylint: disable=unused-argument
"""
Determine if a CircleAsset sprite overlaps with a PolygonAsset sprite. This
method is called after determining that the two objects are overlapping in their
overall extents.
:param Sprite circ: A CircleAsset-based sprite.
:param Sprite poly: A PolygonAsset-based sprite.
:returns: True if the sprites are overlapping, False otherwise.
:rtype: boolean
"""
return True # no implementation yet
[docs]
def collidingPolyWithPoly(self, obj):
"""
Determine if a pair of PolygonAsset-based sprites are overlapping. This
method is called after determining that the two objects are overlapping in their
overall extents. This should onlyb e called if `self` is a PolygonAsset-based
sprite.
:param Sprite obj: A PolygonAsset-based sprite.
:returns: True if slef overlaps with obj, False otherwise.
:rtype: boolean
"""
return True # no implementation yet
[docs]
def collidingWith(self, obj):
"""
Determine if this sprite is currently overlapping another
sprite object.
:param Sprite obj: A reference to another Sprite object.
:rtype: boolean
:returns: `True` if this the sprites are overlapping, `False` otherwise.
"""
if self is obj:
return False
self.setExtents()
obj.setExtents()
# Gross check for overlap will usually rule out a collision
if (
self.xmin > obj.xmax
or self.xmax < obj.xmin
or self.ymin > obj.ymax
or self.ymax < obj.ymin
):
return False
# Otherwise, perform a careful overlap determination
if isinstance(self.asset, CircleAsset):
if isinstance(obj.asset, CircleAsset):
# two circles .. check distance between
sx = (self.xmin + self.xmax) / 2
sy = (self.ymin + self.ymax) / 2
ox = (obj.xmin + obj.xmax) / 2
oy = (obj.ymin + obj.ymax) / 2
d = math.sqrt((sx - ox) ** 2 + (sy - oy) ** 2)
return d <= self.width / 2 + obj.width / 2
return self.collidingCircleWithPoly(self, obj)
if isinstance(obj.asset, CircleAsset):
return self.collidingCircleWithPoly(obj, self)
return self.collidingPolyWithPoly(obj)
[docs]
def collidingWithSprites(self, sclass=None):
"""
Determine if this sprite is colliding with any other sprites
of a certain class.
:param class sclass: A class identifier that is either :class:`Sprite`
or a subclass of it that identifies the class of sprites to check
for collisions. If `None` then all objects that are subclassed from
the :class:`Sprite` class are checked.
:rtype: list
:returns: A (potentially empty) list of sprite objects of the given
class that are overlapping with this sprite.
"""
if sclass is None:
slist = App.spritelist
else:
slist = App.getSpritesbyClass(sclass)
return list(filter(self.collidingWith, slist))
[docs]
@staticmethod
def getImagePath(imagename):
"""
Determine a path to the ggame-provided image that will work for both online
(runpython.org) and locally installed ggame libraries. Do not use this with
user-provided images.
:param str imagename: The name of an image file found inside the
ggame/images folder.
"""
# differences in online vs. local operation
try:
thispath = os.path.dirname(__file__)
imagepath = os.path.join(thispath, "images")
except NameError:
imagepath = "images"
return os.path.join(imagepath, imagename)
[docs]
def destroy(self):
"""
Prevent the sprite from being displayed or checked in collision
detection. Once this is called, the sprite can no longer be displayed
or used. If you only want to prevent a sprite from being displayed,
set the :data:`visible` attribute to `False`.
"""
try:
App.remove(self)
self.gfx.destroy()
except ValueError:
pass