# coding=utf-8
"""A number of utility functions."""
from random import random, randrange
from collections import defaultdict
from bisect import bisect
import pygame as pg
from pygame import Rect
from . import cb, grid
# be sure to change util.rst
__all__ = ('dd', 'ir', 'sum_pos', 'pos_in_rect', 'normalise_colour',
'call_in_nest', 'bezier', 'Singleton', 'OwnError', 'Owned',
'randsgn', 'rand0', 'weighted_rand', 'align_rect', 'position_sfc',
'convert_sfc', 'combine_drawn', 'blank_sfc')
# abstract
[docs]def dd (default, items = {}, **kwargs):
"""Create a ``collections.defaultdict`` with a static default.
dd(default[, items], **kwargs) -> default_dict
:arg default: the default value.
:arg items: dict or dict-like to initialise with.
:arg kwargs: extra items to initialise with.
:return: the created ``defaultdict``.
"""
items = items.copy()
items.update(kwargs)
return defaultdict(lambda: default, items)
[docs]def ir (x):
"""Returns the argument rounded to the nearest integer.
This is about twice as fast as int(round(x)).
"""
y = int(x)
return (y + (x - y >= .5)) if x > 0 else (y - (y - x >= .5))
[docs]def sum_pos (*pos):
"""Sum all given ``(x, y)`` positions component-wise."""
sx = sy = 0
for x, y in pos:
sx +=x
sy +=y
return (sx, sy)
[docs]def pos_in_rect (pos, rect, round_val=False):
"""Return the position relative to ``rect`` given by ``pos``.
:arg pos: a position identifier. This can be:
- ``(x, y)``, where each is either a number relative to ``rect``'s
top-left, or the name of a property of ``pygame.Rect`` which returns a
number.
- a single number ``x`` that is the same as ``(x, x)``.
- the name of a property of ``pygame.Rect`` which returns an ``(x, y)``
sequence of numbers.
:arg rect: a Pygame-style rect, or just a ``(width, height)`` size to assume a
rect with top-left ``(0, 0)``.
:arg round_val: whether to round the resulting numbers to integers before
returning.
:return: the qualified position relative to ``rect``'s top-left, as ``(x, y)``
numbers.
"""
if len(rect) == 2 and isinstance(rect[0], (int, float)):
# got a size
rect = ((0, 0), rect)
rect = Rect(rect)
if isinstance(pos, basestring):
x, y = getattr(rect, pos)
x -= rect.left
y -= rect.top
elif isinstance(pos, (int, float)):
x = y = pos
else:
x, y = pos
if isinstance(x, basestring):
x = getattr(rect, x) - rect.left
if isinstance(y, basestring):
y = getattr(rect, y) - rect.top
return (ir(x), ir(y)) if round_val else (x, y)
[docs]def normalise_colour (c):
"""Turn a colour into ``(R, G, B, A)`` format with each number from ``0``
to ``255``.
Accepts 3- or 4-item sequences (if 3, alpha is assumed to be ``255``), or an
integer whose hexadecimal representation is ``0xrrggbbaa``, or a CSS-style
colour in a string (``'#rgb'``, ``'#rrggbb'``, ``'#rgba'``, ``'#rrggbbaa'``
- or without the leading ``'#'``).
"""
if isinstance(c, int):
a = c % 256
c >>= 8
b = c % 256
c >>= 8
g = c % 256
c >>= 8
r = c % 256
elif isinstance(c, basestring):
if c[0] == '#':
c = c[1:]
if len(c) < 6:
c = list(c)
if len(c) == 3:
c.append('f')
c = [x + x for x in c]
else:
if len(c) == 6:
c = [c[:2], c[2:4], c[4:], 'ff']
else: # len(c) == 8
c = [c[:2], c[2:4], c[4:6], c[6:]]
for i in xrange(4):
x = 0
for k, n in zip((16, 1), c[i]):
n = ord(n)
x += k * (n - (48 if n < 97 else 87))
c[i] = x
r, g, b, a = c
else:
r, g, b = c[:3]
a = 255 if len(c) < 4 else c[3]
return (r, g, b, a)
[docs]def call_in_nest (f, *args):
"""Collapse a number of similar data structures into one.
Used in ``interp_*`` functions.
call_in_nest(f, *args) -> result
:arg f: a function to call with elements of ``args``.
:arg args: each argument is a data structure of nested lists with a similar
format.
:return: a new structure in the same format as the given arguments with each
non-list object the result of calling ``f`` with the corresponding
objects from each arg.
For example::
>>> f = lambda n, c: str(n) + c
>>> arg1 = [1, 2, 3, [4, 5], []]
>>> arg2 = ['a', 'b', 'c', ['d', 'e'], []]
>>> call_in_nest(f, arg1, arg2)
['1a', '2b', '3c', ['4d', '5e'], []]
One argument may have a list where others do not. In this case, those that do
not have the object in that place passed to ``f`` for each object in the
(possibly further nested) list in the argument that does. For example::
>>> call_in_nest(f, [1, 2, [3, 4]], [1, 2, 3], 1)
[f(1, 1, 1), f(2, 2, 1), [f(3, 3, 1), f(4, 3, 1)]]
However, in arguments with lists, all lists must be the same length.
"""
# Rect is a sequence but isn't recognised as collections.Sequence, so test
# this way
is_list = [(hasattr(arg, '__len__') and hasattr(arg, '__getitem__') and
not isinstance(arg, basestring))
for arg in args]
if any(is_list):
n = len(args[is_list.index(True)])
# listify non-list args (assume all lists are the same length)
args = (arg if this_is_list else [arg] * n
for this_is_list, arg in zip(is_list, args))
return [call_in_nest(f, *inner_args) for inner_args in zip(*args)]
else:
return f(*args)
# better for smaller numbers of points
def _bezier_recursive (t, *pts):
if len(pts) > 3:
return ((1 - t) * _bezier_recursive(t, *pts[:-1]) +
t * _bezier_recursive(t, *pts[1:]))
elif len(pts) == 3:
a, b, c = pts
ti = 1 - t
return ti * ti * a + 2 * t * ti * b + t * t * c
elif len(pts) == 2:
return (1 - t) * pts[0] + t * pts[1]
else:
return pts[0]
# better for larger numbers of points
def _bezier_flat (t, *pts):
n_pts = n = len(pts) - 1
ti = 1 - t
b = 0
choose = 1
# generate terms in pairs
for i in xrange(n_pts // 2 + 1):
b += choose * ti ** n * t ** i * pts[i]
if i != n: # else this is the 'middle' term, which has no pair
b += choose * ti ** i * t ** n * pts[n]
choose = choose * n // (i + 1)
n -= 1
return b
[docs]def bezier (t, *pts):
"""Compute a 1D Bézier curve point.
:arg t: curve parameter.
:arg pts: points defining the curve.
"""
if len(pts) >= 5: # empirical
return _bezier_flat(t, *pts)
elif pts:
return _bezier_recursive(t, *pts)
else:
raise ValueError('expected at least one point')
[docs]class Singleton (object):
"""For easily making singleton objects (eg. as identitfiers).
:arg name: string representation of the object.
"""
__slots__ = ('name',)
def __init__ (self, name):
#: String representation, as passed to the constructor.
self.name = name
def __str__ (self):
return self.name
__repr__ = __str__
[docs]class OwnError (RuntimeError):
"""Raised when taking ownership of an :class:`Owned <engine.util.Owned>`
instance fails."""
pass
[docs]class Owned (object):
"""Manage 'owners' of an object.
Owned([max_owners], on_full='throw')
:arg max_owners: the maximum number of owners that this object can have
(greater than zero).
:arg on_full: behaviour when taking ownership is attempted but the limited
specified by ``max_owners`` has been reached. One of:
- ``'throw'``: raise an :class:`OwnError <engine.util.OwnError>`
exception.
- ``'ignore'``: don't add the owner.
- ``'replace'``: remove another owner (choice of owner to remove is
undefined).
"""
def __init__ (self, max_owners=None, on_full='throw'):
#: As passed to the constructor.
self.max_owners = max_owners
if on_full not in ('throw', 'ignore', 'replace'):
raise ValueError('unknown value for on_full: {}'.format(on_full))
self._on_full = on_full
# {owner_id: release_cb}
self._owners = {}
@property
def max_owners (self):
"""As passed to the constructor.
Decreasing this value below the current number of owners does not cause owners
to be removed.
"""
return self._max_owners
@max_owners.setter
def max_owners (self, max_owners):
if max_owners is not None:
max_owners = int(max_owners)
if max_owners <= 0:
raise ValueError('max_owners must be greater than zero')
self._max_owners = max_owners
@property
def owners (self):
"""Set-like container of owner identifiers as passed to
:meth:`own <engine.util.Owned.own>`."""
return self._owners.viewkeys()
@property
def owner (self):
"""Identifier for any single owner of this instance, or ``None``."""
try:
owner = next(self._owners.iterkeys())
except StopIteration:
owner = None
return owner
[docs] def own (self, owner_id, release_cb=None):
"""Attempt to take ownership of this instance.
own(owner_id[, release_cb]) -> success
:arg owner_id: non-``None`` hashable identifier for the owner, used for later
removal.
:arg release_cb: optional function to call when this owner is removed (through
:meth:`release <engine.util.Owned.release>`, or by being
replaced by another owner if this instance allows it). This
is called like ``release_cb(owned_instance, owner_id)``; if it
is determined that the function cannot take any arguments, it
is not given any.
:return: whether the attempt succeeded. If not,
:class:`OwnError <engine.util.OwnError>` may be raised instead,
depending on the ``on_full`` argument passed to the constructor.
"""
if owner_id is None:
raise ValueError('owner_id cannot be None')
success = False
owners = self._owners
if release_cb is not None:
release_cb = cb.wrap_fn(release_cb)
if owner_id not in owners:
max_owners = self.max_owners
if max_owners is not None and len(owners) >= max_owners:
# reached owner limit
if self._on_full == 'throw':
raise OwnError('{} already has the maximum number of '
'owners ({})'.format(self, max_owners))
elif self._on_full == 'replace':
# len(owners) >= max_owners > 0, so this is safe
self.release(next(owners.iterkeys()))
owners[owner_id] = release_cb
success = True
# else ignore: success = False
else:
owners[owner_id] = release_cb
success = True
return success
[docs] def release (self, owner_id):
"""Relinquish ownership of this instance.
:arg owner_id: identifier passed to :meth:`own <engine.util.Owned.own>`.
"""
release_cb = self._owners.pop(owner_id, None)
if release_cb is not None:
release_cb(self, owner_id)
# random
[docs]def randsgn ():
"""Randomly return ``1`` or ``-1``."""
return 2 * randrange(2) - 1
[docs]def rand0 ():
"""Zero-centred random (``-1 <= x < 1``)."""
return 2 * random() - 1
[docs]def weighted_rand (ws):
"""Return a weighted random choice.
weighted_rand(ws) -> index
:arg ws: weightings, either a list of numbers to weight by or a
``{key: weighting}`` dict for any keys.
:return: the chosen index in the list or key in the dict.
"""
if isinstance(ws, dict):
indices, ws = zip(*ws.iteritems())
else:
indices = range(len(ws))
cumulative = []
last = 0
for w in ws:
last += w
cumulative.append(last)
index = min(bisect(cumulative, cumulative[-1] * random()), len(ws) - 1)
return indices[index]
# graphics
[docs]def align_rect (rect, within, alignment = 0, pad = 0, offset = 0):
"""Align a rect within another rect.
align_rect(rect, within, alignment = 0, pad = 0, offset = 0) -> pos
:arg rect: the Pygame-style rect to align.
:arg within: the rect to align ``rect`` within.
:arg alignment: ``(x, y)`` alignment; each is ``< 0`` for left-/top-aligned,
``0`` for centred, ``> 0`` for right-/bottom-aligned. Can be
just one number to use on both axes.
:arg pad: ``(x, y)`` padding to leave around the inner edge of ``within``. Can
be negative to allow positioning outside of ``within``, and can be
just one number to use on both axes.
:arg offset: ``(x, y)`` amounts to offset by after all other positioning; can
be just one number to use on both axes.
:return: the position the top-left corner of the rect should be moved to for
the wanted alignment.
"""
pos = alignment
pos = [pos, pos] if isinstance(pos, (int, float)) else list(pos)
if isinstance(pad, (int, float)):
pad = (pad, pad)
if isinstance(offset, (int, float)):
offset = (offset, offset)
rect = Rect(rect)
sz = rect.size
within = Rect(within)
within = list(within.inflate(-2 * pad[0], -2 * pad[1]))
for axis in (0, 1):
align = pos[axis]
if align < 0:
x = 0
elif align == 0:
x = (within[2 + axis] - sz[axis]) / 2.
else: # align > 0
x = within[2 + axis] - sz[axis]
pos[axis] = ir(within[axis] + x + offset[axis])
return pos
[docs]def position_sfc (sfc, dest, alignment = 0, pad = 0, offset = 0, rect = None,
within = None, blit_flags = 0):
"""Blit a surface onto another with alignment.
position_sfc(sfc, dest, alignment = 0, pad = 0, offset = 0,
rect = sfc.get_rect(), within = dest.get_rect(), blit_flags = 0)
``alignment``, ``pad``, ``offset``, ``rect`` and ``within`` are as taken by
:func:`align_rect <engine.util.align_rect>`. Only the portion of ``sfc``
within ``rect`` is copied.
:arg sfc: source surface to copy.
:arg dest: destination surface to blit to.
:arg blit_flags: the ``special_flags`` argument taken by
``pygame.Surface.blit``.
"""
if rect is None:
rect = sfc.get_rect()
if within is None:
within = dest.get_rect()
dest.blit(sfc, align_rect(rect, within, alignment, pad, offset), rect,
blit_flags)
def has_alpha (sfc):
"""Return if the given surface has transparency of any kind."""
return sfc.get_alpha() is not None or sfc.get_colorkey() is not None
[docs]def convert_sfc (sfc):
"""Convert a surface for blitting."""
return sfc.convert_alpha() if has_alpha(sfc) else sfc.convert()
[docs]def combine_drawn (*drawn):
"""Combine the given drawn flags.
These are as returned by :meth:`engine.game.World.draw`.
"""
if True in drawn:
return True
rects = sum((list(d) for d in drawn if d), [])
return rects if rects else False
[docs]def blank_sfc (size):
"""Create a transparent surface with the given ``(width, height)`` size."""
sfc = pg.Surface(size).convert_alpha()
sfc.fill((0, 0, 0, 0))
return sfc