7
\$\begingroup\$

A tween is a transition of a variable from one value to another value gradually over time using some sort of easing function. I could not really find a library for easily creating tweens in Pygame so I made a small utility library that should actually work with just about any game framework assuming that the framework has a suitable game loop.

Before showing the code for the library transytion, this is how to use transytion in conjunction with Pygame:

import transytion as ty
from dataclasses import dataclass
from transytion.ease_funcs import quad
import pygame


pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
running = True
dt = 0

@dataclass
class Ball:
    x: float
    y: float

ball = Ball(screen.get_width() / 2.0, 0.0)

# 1 second qudratic fall to center of screen.
fall = ty.Tween(1.0, # Duration of tween is 1 seconds.
                ball, # What object to mess with.
                {"y" : screen.get_height() / 2}, # Animate what to where.
                ease_func=quad) # How to animate it (defaults to linear)

ty.default_manager.add(fall) # Start the tween.

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    ty.default_manager.update(dt)
 
    screen.fill((0,0,0))
    pygame.draw.circle(screen, "red", (ball.x, ball.y), 40)

    pygame.display.flip()
    dt = clock.tick(60) / 1000

pygame.quit()

The program will make a ball drop from the top of the screen to the middle of the screen. The key lines are:

fall = ty.Tween(1.0, # Duration of tween is 1 seconds.
                ball, # What object to mess with.
                {"y" : screen.get_height() / 2}, # Animate what to where.
                ease_func=quad) # How to animate it (defaults to linear)

ty.default_manager.add(fall) # Start the tween.

Which makes a tween called fall and then adds it to a TweenManager class (one is provided by default in my library), which is then responsible for actually animating the fall tween.

This is the code, it is composed of two files: One for the base classes and the other for the easing functions. Here are the easing functions first:

transytion/src/transytion/ease_funcs/__init__.py

# Based on https://github.com/rxi/flux/blob/master/flux.lua
import math


def linear(x: float) -> float:
    """ Linear tween that returns the thing itself."""
    return x


def quad(x: float) -> float:
    return x ** 2


def cubic(x: float) -> float:
    return x ** 3


def quart(x: float) -> float:
    return x ** 4


def quint(x: float) -> float:
    return x ** 5


def sine(x: float) -> float:
    return -math.cos(x * (math.pi * (1/2.0))) + 1

Here is the code for creating, running, and managing the tweens:

transytion/src/transytion/__init__.py

from __future__ import annotations
from collections.abc import Callable
from itertools import pairwise
from dataclasses import dataclass
from typing import Any

from .ease_funcs import linear


class Tween:
    _initial: TweenNode = None
    _last: TweenNode = None
    _cur: TweenNode = None

    def __init__(self,
                 duration: float, 
                 obj: Any, 
                 targets: dict[(str, float)],
                 start: dict[(str, float)] | None = None,
                 ease_func: Callable[[float], float] = linear,
                 callback: Callable[[], None] = lambda: None):
        """Make a tween with one TweenNode contained in it."""
        tween = TweenNode(duration, obj, targets,
                          start, ease_func, callback)
        self._initial = tween
        self._last = tween
        self._cur = tween

    def reset(self):
        self._cur = self._initial


@dataclass
class TweenNode:
    duration: float
    obj: Any
    targets: dict[(str, float)] # String of the attributes you want to mutate!
    start: dict[(str, float)] | None = None
    ease_func: Callable[[float], float] = linear
    callback: Callable[[], None] = lambda: None
    _progress: float = 0.0
    _next_: TweenNode | None = None
    _prev_: TweenNode | None = None
    _paused: bool = False

    def __post_init__(self):
        """Must also have the original and resulting position to actually tween
        between those values."""
        self._original = {}
        self._destinations = {}
        # Just use what ever value the obj currently has.
        for target, dest in self.targets.items():
            self._original[target] = getattr(self.obj, target)
            self._destinations[target] = dest

    def safe_reset_to_start(self):
        """If start position is specified (not None), make sure values start
        from the start. Otherwise, start from where they currently are."""
        if self.start is not None:
            for target, start in self.start:
                self._original[target] = start

    @property
    def progress(self):
        return self._progress

    @progress.setter
    def progress(self, value: float):
        """Setter for progress, must also update the targeted variables when
        progress is incremented."""
        self._progress = value
        for var in self.targets:
            p = self.ease_func(self._progress)
            loc = (1 - p) * self._original[var] + p * self._destinations[var]
            setattr(self.obj, var, loc)


class TweenManager:
    _to_update: list[Tween]
    
    def __init__(self):
        self._to_update = []

    def add(self, tween: Tween):
        tween._cur.safe_reset_to_start()
        self._to_update.append(tween)

    def remove(self, tween: Tween):
        self._to_update.remove(tween)

    def update(self, dt):
        # Remove finished tweens.
        self._to_update = [x for x in self._to_update if x._cur is not None]
        # Must use range len pattern, since we must mutate the object.
        for i in range(len(self._to_update)):
            # However, most of the loop treats `_to_update` as immutable, so, 
            # for convenience, we refer to it as `tween_node`.
            tween_node = self._to_update[i]._cur
            # If it is paused, don't do anything.
            if tween_node._paused:
                continue
            if tween_node.progress < 1.0: # TweenNode in process.
                tween_node.progress += dt / tween_node.duration
            else: # TweenNode finished, move to the next.
                tween_node.callback()
                # Mut to next is here.
                self._to_update[i]._cur = tween_node._next_
                if self._to_update[i]._cur is not None:
                    # Determine whether to continue from current target's val
                    # or if the target val should be set to `start` for the
                    # next TweenNode.
                    self._to_update[i]._cur.safe_reset_to_start()
                # Otherwise, entire Tween has finished. (Will be removed in
                # next call to update.

    def pause(self, tween: Tween):
        tween._paused = True

    def resume(self, tween: Tween):
        tween._paused = False

    def pause_all(self):
        for tween in self._to_update:
            tween._paused = True

    def remove_all(self):
        self._to_update = []


default_manager = TweenManager()


def chain(tweens: list[Tween]) -> Tween:
    initial = tweens[0]._initial
    last = tweens[-1]._last
    for t1, t2 in pairwise(tweens):
        t1._last._next_ = t2._initial
        t2._initial._prev_ = t1._last
        t1._last = last
        t2._last = last
        t1._initial = initial
        t2._initial = initial
    return tweens[0]

Here is the Github for easy cloning. I made the library using uv so there are some auxiliary files, but the two above should be all you technically need.

\$\endgroup\$

4 Answers 4

5
\$\begingroup\$

Unnecessary (and Confusing) Class Attributes

In class Tween you have defined class attributes _initial, _last and _cur. Then in its __init__ method you have:

class Tween:
    _initial: TweenNode = None
    _last: TweenNode = None
    _cur: TweenNode = None

    def __init__(self,
                 # Other arguments omitted for clarity
                 ):
        """Make a tween with one TweenNode contained in it."""
        tween = TweenNode(duration, obj, targets,
                          start, ease_func, callback)
        self._initial = tween
        self._last = tween
        self._cur = tween

But those last 3 assignments in the above method are not updating the class attributes; they are creating and initializing instance variables, as can be seen in this example:

>>> class Foo:
...     x = 5
...     def __init__(self):
...         self.x = 9
...
>>> Foo.x
5
>>> foo = Foo()
>>> Foo.x
5
>>> foo.x
9
>>>

I could not find references to the class attributes, such as Tween._initial. So, those class attribute definitions are completely unnecessary and potentially confusing to the reader. A similar issue occurs in class TweenManager.

Would a better name for variable tween have been tween_node? The name tween would be more suitable if it were to reference an instance of class Tween rather than class TweenNode.

Docstrings

Adding additional docstrings would be helpful. Just one example: I was straining to figure out what was being calculated by function sine. It's name is so similar to sin as to be potentially misleading to the reader without a docstring.

You have also defined a function chain, but I can find no reference to it. If it is actually used, then document how. If not, it should be removed.

\$\endgroup\$
4
\$\begingroup\$

Layout

In your main file, the definition of the Ball data class should come after the imports, but before the rest.

__main__

In your main file it's customary to guard against exec execution on importation.

if __name__ == '__main__':
    # All of the code you want to execute,
    # possibly wrapped up in a main function

Excessive newlines

In defining all of your exponentiation functions, you don't really need to use two newlines between function definitions.

safe_reset_to_start

Instead of the following, I suggest you simplify.

    def safe_reset_to_start(self):
        """If start position is specified (not None), make sure values start
        from the start. Otherwise, start from where they currently are."""
        if self.start is not None:
            for target, start in self.start:
                self._original[target] = start

Simpler:

    def safe_reset_to_start(self):
        """If start position is specified (not None), make sure values start
        from the start. Otherwise, start from where they currently are."""
        if self.start is not None:
            self._original.update(self.start)

Convoluted math

In defining sine your math is really overcomplicated.

  • In Python 3, / is floating point division. We don't need to tell it to be.
  • Don't multiply by one half when you can just divide by 2.
  • Obeying the operator precedence rules makes a lot of the parentheses extraneous.
  • Don't negate a value and add 1 when you can just subtract that value from 1.
def sine(x: float) -> float:
    return -math.cos(x * (math.pi * (1/2.0))) + 1

Simpler:

def sine(x: float) -> float:
    return 1 - math.cos(x * math.pi / 2)

Replacing a loop

The following loop can be implemented using the built-in function all. We also get short-circuiting by doing this.

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

Revised:

    running = all(e.type != pygame.QUIT for e in pygame.event.get())
\$\endgroup\$
1
  • \$\begingroup\$ Ah, I feel a bit silly: The reason why __main__ was not included was I was trying to do the minimal amount of change to the example on the Pygame-ce website. \$\endgroup\$ Commented yesterday
3
\$\begingroup\$

Simpler

It seems like you would want to exit out of the while loop as soon as you see the "QUIT" event, but you proceed to execute more code. You could break out of the loops without executing the remaining code with something like:

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
            break # out of the "for" loop
    if not running:
        break # out of the "while" loop 

ease_funcs

You can simplify the look of equations by explicitly exporting the functions you use. Change:

import math

to:

from math import cos, pi

and change:

return -math.cos(x * (math.pi * (1/2.0))) + 1

to:

return -cos(x * (pi * (1/2.0))) + 1
\$\endgroup\$
2
  • \$\begingroup\$ Truly tragic that Python doesn't have labeled breaks! :) \$\endgroup\$ Commented 22 hours ago
  • \$\begingroup\$ @Dair Not at all! That'd be a mess. Better to use a function for such cases. \$\endgroup\$ Commented 22 hours ago
3
\$\begingroup\$

There's been some discussion about this block:

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

You could put a break in the if, but it just breaks out of the event loop, not the while loop, so you're stuck with a one-off boolean.

I don't think that's too bad in a small program like this. But rather than labeled breaks (which Python doesn't support), this situation nicely illustrates using a function to compute the boolean value:

def is_running():
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            return False

    return True

while is_running():
    ...

More succinct, but arguably less readable,

def is_running():
    return not any(e.type == pygame.QUIT for e in pygame.event.get())
    
    # or:
    #return all(e.type != pygame.QUIT for e in pygame.event.get())

In Pygame, the original code is a common idiom. Although refactoring value is low in this application, this comes up often in contexts where it does matter.

The general refactoring pattern here is: avoid boolean variables. Refactoring to a function simplifies state by eliminating a mutation/reassignment, promotes reuse, flattens nested code, improves naming and hides unnecessary inlined implementation details.

\$\endgroup\$
1
  • \$\begingroup\$ Upvoted, but just to let people know: The example is intended to follow the one on the Pygame-ce closely as possible to make it accessible for newcomers to my library. \$\endgroup\$ Commented 22 hours ago

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.