Python Forum
[Pyglet] Making tetrominos
Thread Rating:
  • 0 Vote(s) - 0 Average
  • 1
  • 2
  • 3
  • 4
  • 5
[Pyglet] Making tetrominos
#1
Hi. I don't think this is really a game development problem, it's more of a design problem best I can tell.

I've written a base Tetromino class that is functional for every possible shape:

class Tetromino:
    def __init__(self, minos, pivot, wall_kick_data):
        self.minos = minos
        self.pivot = pivot
        self.wall_kick_data = wall_kick_data

        self.current_rotation = 0
        self.previous_rotation = 0

    def move_by(self, vector):
        self.pivot += vector
        for mino in self.minos:
            mino.move_by(vector)

    def rotate(self, angle):
        self.previous_rotation = self.current_rotation
        if angle > 0:
            self.current_rotation = (self.current_rotation - 1) % 4
        else:
            self.current_rotation = (self.current_rotation + 1) % 4

        for mino in self.minos:
            mino.rotate(self.pivot, angle)

    def update(self):
        for mino in self.minos:
            mino.update()
As you've probably noticed, it contains a list of minos objects (apparently that's the official name for the blocks that compose a tetromino), which in turn are a subclass of the pyglet.shape.Rectangle class:

from pyglet.shapes import Rectangle
from pyglet.math import Vec2


class Mino(Rectangle):
    def __init__(self, *pargs, **kargs):
        super().__init__(*pargs, **kargs)
        self.grid_position = Vec2(self.x // self.width, self.y // self.height)

    def move_by(self, vector):
        self.grid_position += vector

    def rotate(self, pivot, angle):
        translated = self.grid_position - pivot
        rotated = translated.rotate(angle)
        self.grid_position = Vec2(*map(round, rotated + pivot))

    def update(self):
        self.x = self.grid_position.x * self.width
        self.y = self.grid_position.y * self.height
Now, this is all well and good, by my standards at least. The problem is when it comes to actually build these tetrominos according to their shape. The client is going to pick up a random shape for the next tetromino whenever it's time to spawn a new one.

The sheer amount of data I need to sort through is overwhelming though.
For each shape I have:
  • the shape data, a tuple of grid coordinates for each mino.

    shapes_data = {
        'I': ((-2, -2), (-1, -2), (0, -2), (1, -2)),
        'J': ((-2, -2), (-1, -2), (0, -2), (-2, -1)),
        'L': ((-2, -2), (-1, -2), (0, -2), (0, -1)),
        'O': ((-1, -2), (0, -2), (-1, -1), (0, -1)),
        'S': ((-2, -2), (-1, -2), (-1, -1), (0, -1)),
        'T': ((-2, -2), (-1, -2), (0, -2), (-1, -1)),
        'Z': ((-1, -2), (0, -2), (-2, -1), (-1, -1))}
    Note that this just gives me the shape, then I actually need to translate them to the right place, adding each one of them to (grid.width // 2, grid.height).

  • the pivot point for each shape
    shapes_pivots = {
        'I': (-0.5, -2.5),
        'J': (-1, -2),
        'L': (-1, -2),
        'O': (-0.5, -1.5),
        'S': (-1, -2),
        'T': (-1, -2),
        'Z': (-1, -2)}
    This also needs to be translated to the actual spawn point, adding it to (grid.width // 2, grid.height).

  • the wall kick data. This one is nasty. It contains a list of coordinates the tetromino is supposed to be moved by, according to its rotation states, whenever a rotation fails (because of an obstacle, either the walls or tetrominos in the grid). Anyway, the logic is not the problem, I got that.

    standard_wall_kicks_data = {
        0: {
            3: ((-1, 0), (-1, -1), (0, 2), (-1, 2)),
            1: ((1, 0), (1, -1), (0, 2), (1, 2))},
        1: {
            0: ((-1, 0), (-1, 1), (0, -2), (-1, -2)),
            2: ((-1, 0), (-1, 1), (0, -2), (-1, -2))},
        2: {
            1: ((1, 0), (1, -1), (0, 2), (1, 2)),
            3: ((-1, 0), (-1, -1), (0, 2), (-1, 2))},
        3: {
            2: ((1, 0), (1, 1), (0, -2), (1, -2)),
            0: ((1, 0), (1, 1), (0, -2), (1, -2))}
    }
    
    i_wall_kicks_data = {
        0: {
            3: ((1, 0), (-2, 0), (1, -2), (-2, 1)),
            1: ((2, 0), (-1, 0), (2, 1), (-1, -2))},
        1: {
            0: ((-2, 0), (1, 0), (-2, -1), (1, 2)),
            2: ((1, 0), (-2, 0), (1, -2), (-2, 1))},
        2: {
            1: ((-1, 0), (2, 0), (-1, 2), (2, -1)),
            3: ((-2, 0), (1, 0), (-2, -1), (1, 2))},
        3: {
            2: ((2, 0), (-1, 0), (2, 1), (-1, -2)),
            0: ((-1, 0), (2, 0), (-1, 2), (2, -1))}
    }
    
    shapes_wall_kicks_data = {
        'I': i_wall_kicks_data,
        'J': standard_wall_kicks_data,
        'L': standard_wall_kicks_data,
        'O': standard_wall_kicks_data,
        'S': standard_wall_kicks_data,
        'T': standard_wall_kicks_data,
        'Z': standard_wall_kicks_data}
    Notice how I is the only tetromino that follows a different ruleset.

  • finally colors:
    colors = {
        'I': i_color,
        'J': j_color,
        'L': l_color,
        'O': o_color,
        'S': s_color,
        'T': t_color,
        'Z': z_color}

Now, my idea was to create a class whose job is to make tetrominos for the client. And I came up with this juggernaut:

from pyglet.math import Vec2

from mino import Mino
from tetromino import Tetromino


shapes_data = {
    'I': ((-2, -2), (-1, -2), (0, -2), (1, -2)),
    'J': ((-2, -2), (-1, -2), (0, -2), (-2, -1)),
    'L': ((-2, -2), (-1, -2), (0, -2), (0, -1)),
    'O': ((-1, -2), (0, -2), (-1, -1), (0, -1)),
    'S': ((-2, -2), (-1, -2), (-1, -1), (0, -1)),
    'T': ((-2, -2), (-1, -2), (0, -2), (-1, -1)),
    'Z': ((-1, -2), (0, -2), (-2, -1), (-1, -1))}

shapes_pivots = {
    'I': (-0.5, -2.5),
    'J': (-1, -2),
    'L': (-1, -2),
    'O': (-0.5, -1.5),
    'S': (-1, -2),
    'T': (-1, -2),
    'Z': (-1, -2)}

standard_wall_kicks_data = {
    0: {
        3: ((-1, 0), (-1, -1), (0, 2), (-1, 2)),
        1: ((1, 0), (1, -1), (0, 2), (1, 2))},
    1: {
        0: ((-1, 0), (-1, 1), (0, -2), (-1, -2)),
        2: ((-1, 0), (-1, 1), (0, -2), (-1, -2))},
    2: {
        1: ((1, 0), (1, -1), (0, 2), (1, 2)),
        3: ((-1, 0), (-1, -1), (0, 2), (-1, 2))},
    3: {
        2: ((1, 0), (1, 1), (0, -2), (1, -2)),
        0: ((1, 0), (1, 1), (0, -2), (1, -2))}
}

i_wall_kicks_data = {
    0: {
        3: ((1, 0), (-2, 0), (1, -2), (-2, 1)),
        1: ((2, 0), (-1, 0), (2, 1), (-1, -2))},
    1: {
        0: ((-2, 0), (1, 0), (-2, -1), (1, 2)),
        2: ((1, 0), (-2, 0), (1, -2), (-2, 1))},
    2: {
        1: ((-1, 0), (2, 0), (-1, 2), (2, -1)),
        3: ((-2, 0), (1, 0), (-2, -1), (1, 2))},
    3: {
        2: ((2, 0), (-1, 0), (2, 1), (-1, -2)),
        0: ((-1, 0), (2, 0), (-1, 2), (2, -1))}
}


shapes_wall_kicks_data = {
    'I': i_wall_kicks_data,
    'J': standard_wall_kicks_data,
    'L': standard_wall_kicks_data,
    'O': standard_wall_kicks_data,
    'S': standard_wall_kicks_data,
    'T': standard_wall_kicks_data,
    'Z': standard_wall_kicks_data}


class StandardRotationSystem:
    def __init__(self):
        self.shapes_data = shapes_data
        self.shapes_pivots = shapes_pivots
        self.shapes_wall_kicks_data = shapes_wall_kicks_data

    def _get_minos(self, shape, dx, dy, cell_size, color, batch):
        minos = list()
        positions = (((x + dx) * cell_size, (y + dy) * cell_size) for (x, y) in self.shapes_data[shape])
        for pos in positions:
            mino = Mino(*pos, cell_size, cell_size, color, batch)
            minos.append(mino)
        return minos

    def _get_pivot(self, shape, dx, dy):
        pivot = Vec2(*self.shapes_pivots[shape]) + Vec2(dx, dy)
        return pivot

    def _get_wall_kick_data(self, shape):
        return self.shapes_wall_kicks_data[shape]

    def make_tetromino(self, shape, dx, dy, cell_size, color, batch):
        minos = self._get_minos(shape, dx, dy, cell_size, color, batch)
        pivot = self._get_pivot(shape, dx, dy)
        wall_kick_data = self._get_wall_kick_data(shape)
        return Tetromino(minos, pivot, wall_kick_data)
But, c'mon, this can't be the right way to do this. What's the best way to tame this complexity?
Reply
#2
I think you might be overthinking the problem. The Tetris playing field is a grid. Moving and rotating a tetronimo is computing the grid location for each of the tetronimo minos. A move is valid if all the minos are within the grid and if none of the minos collide with another minos. Collisions are detected by checking if the cell wheren the minos wants to move is empty or full.
import random


class Field:
    def __init__(self, wide, high):
        self.wide = wide
        self.high = high
        self.grid = [["_"] * wide for _ in range(high)]

    def set(self, x, y, color="_"):
        self.grid[y][x] = color

    def get(self, x, y):
        if 0 <= x < self.wide and 0 <= y < self.high:
            return self.grid[y][x]
        return "X"

    def __str__(self):
        return "\n".join(["".join(row) for row in self.grid])


class Minos:
    rotations = [(1, 0), (0, 1), (-1, 0), (0, -1)]
    field = None

    def __init__(self, color, position):
        self.color = color
        self.position = position
        self.location = []

    def compute(self, location, rotation):
        rcos, rsin = self.rotations[rotation]
        lx, ly = location
        px, py = self.position
        self.location = [lx + px * rcos + py * rsin, ly + px * rsin - py * rcos]
        return field.get(*self.location) == "_"

    def draw(self):
        self.field.set(*self.location, self.color)

    def erase(self):
        self.field.set(*self.location)


class Tetronimo:
    def __init__(self, shape, location, rotation):
        self.location = location
        self.rotation = rotation
        self.minos = [Minos(shape["color"], pos) for pos in shape["position"]]
        self.compute(location, rotation)
        self.draw()

    def move(self, shift, rotation):
        self.erase()
        location = (self.location[0] + shift[0], self.location[1] + shift[1])
        rotation = (self.rotation + rotation) % 4
        if self.compute(location, rotation):
            self.location = location
            self.rotation = rotation
        else:
            self.compute(self.location, self.rotation)
        self.draw()

    def draw(self):
        for minos in self.minos:
            minos.draw()

    def erase(self):
        for minos in self.minos:
            minos.erase()

    def __str__(self):
        return f"<Tetronimo {self.location}, {self.rotation}, {[minos.location for minos in self.minos]}>"

    def compute(self, location, rotation):
        return all(m.compute(location, rotation) for m in self.minos)


shapes = [
    {"color": "R", "position": ((0, 0), (1, 0), (2, 0), (2, 1))},
]

field = Field(5, 5)
Minos.field = field

x = Tetronimo(random.choice(shapes), [2, 2], 0)
print(x, field, sep="\n")
for _ in range(4):
    x.move((0, 0), 1)
    print(x, field, sep="\n")
for _ in range(3): 
    x.move((0, 1), 0)
    print(x, field, sep="\n")
Output:
<Tetronimo [2, 2], 0, [[2, 2], [3, 2], [4, 2], [4, 1]]> _____ ____R __RRR _____ _____ <Tetronimo (2, 2), 1, [[2, 2], [2, 3], [2, 4], [3, 4]]> _____ _____ __R__ __R__ __RR_ <Tetronimo (2, 2), 2, [[2, 2], [1, 2], [0, 2], [0, 3]]> _____ _____ RRR__ R____ _____ <Tetronimo (2, 2), 3, [[2, 2], [2, 1], [2, 0], [1, 0]]> _RR__ __R__ __R__ _____ _____ <Tetronimo (2, 2), 0, [[2, 2], [3, 2], [4, 2], [4, 1]]> _____ ____R __RRR _____ _____ <Tetronimo (2, 3), 0, [[2, 3], [3, 3], [4, 3], [4, 2]]> _____ _____ ____R __RRR _____ <Tetronimo (2, 4), 0, [[2, 4], [3, 4], [4, 4], [4, 3]]> _____ _____ _____ ____R __RRR <Tetronimo (2, 4), 0, [[2, 4], [3, 4], [4, 4], [4, 3]]> _____ _____ _____ ____R __RRR
Reply
#3
The thing is I'm more of a fan of modern Tetris, which means the Standard Rotation System, or Super Rotation System (SRS).

https://tetris.fandom.com/wiki/SRS

The SRS has pretty strict rules when it comes to tetrominoes rotation ("when unobstructed, the tetrominoes all appear to rotate purely about a single point", hence the different pivots in my implementation). And it features these wall kicks ("when a rotation is attempted, 5 positions are sequentially tested (inclusive of basic rotation); if none are available, the rotation fails completely."), which allow to perform pretty dope moves like the T-Spin, and frankly it feels more natural compared to other Tetris versions without wall kicks, like the classic NES and Game Boy versions.

https://tetris.com/play-tetris
This version features the SRS with wall kicks. You can easily see some of them in action by rotating tetrominoes against the edges of the field.

I've already implemented them in the game loop (actually this was supposed to be a follow up question, that is, where to put what where), and they seem to work fine, but I think my code is starting to become a mess, so I need to sort things out first.
Reply
#4
Instead of a bunch of dictionaries, I would use a class to collect all the information about a shape into one object.
import random

class Shape:
    """I know the details of a tetronimo shape."""
 
    instances = []  # List of all Shape objects
    dictionary = {}  # Lookup Shape by id.

    def __init__(self, id, origin, monos, wall_kick):
        self.id = id  # What shape am I
        self.color = len(self.instances)  # Index into a color table. For different themes?
        self.origin = origin  # Origin I rotate about
        self.monos = monos  # List of mono coordinates
        self.wall_kick = wall_kick  # Wall kick information for SRS
        self.dictionary[id] = self
        self.instances.append(self)

    @classmethod
    def get(cls, key):
        """Lookup shape by id."""
        return cls.dictionary[key]
 
    @classmethod
    def random(cls):
        """Return random shape."""
        return random.choice(cls.instances)


class Mino:
    """I am one block in a tetronimo."""
 
    def __init__(self, color, position):
        pass
 
 
class Tetronimo:
    """I am a tetronimo that the player is moving on the screen."""
 
    starting_location = (50, 3)

    @classmethod
    def random(cls):
        """Create a random Tetronimo."""
        return cls(Shape.random(), cls.starting_location, random.randint(0, 3))

    def __init__(self, shape, location, rotation=0):
        self.origin = shape.origin
        self.minos = [Mino(shape.color, position) for position in shape.monos]
        self.wall_kick = shape.wall_kick
        self.location = location
        self.rotation = rotation
        self.update()
 
    def rotate(self, rotation):
        """Implements standard rotation system."""
 
    def shift(self, x, y):
        """Move in x and y direction."""
 
    def update(self):
        """Update all the mino positions."""
 
 
ikick = [
    {3: ((1, 0), (-2, 0), (1, -2), (-2, 1)), 1: ((2, 0), (-1, 0), (2, 1), (-1, -2))},
    {0: ((-2, 0), (1, 0), (-2, -1), (1, 2)), 2: ((1, 0), (-2, 0), (1, -2), (-2, 1))},
    {1: ((-1, 0), (2, 0), (-1, 2), (2, -1)), 3: ((-2, 0), (1, 0), (-2, -1), (1, 2))},
    {2: ((2, 0), (-1, 0), (2, 1), (-1, -2)), 0: ((-1, 0), (2, 0), (-1, 2), (2, -1))},
]
 
jkick = [
    {3: ((-1, 0), (-1, -1), (0, 2), (-1, 2)), 1: ((1, 0), (1, -1), (0, 2), (1, 2))},
    {0: ((-1, 0), (-1, 1), (0, -2), (-1, -2)), 2: ((-1, 0), (-1, 1), (0, -2), (-1, -2))},
    {1: ((1, 0), (1, -1), (0, 2), (1, 2)), 3: ((-1, 0), (-1, -1), (0, 2), (-1, 2))},
    {2: ((1, 0), (1, 1), (0, -2), (1, -2)), 0: ((1, 0), (1, 1), (0, -2), (1, -2))},
]
 
Shape("I", (-0.5, -2.5), ((-2, -2), (-1, -2), (0, -2), (1, -2)), ikick),
Shape("J", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (-2, -1)), jkick),
Shape("L", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (0, -1)), jkick),
Shape("O", (-0.5, -1.5), ((-1, -2), (0, -2), (-1, -1), (0, -1)), jkick),
Shape("S", (-1, -2), ((-2, -2), (-1, -2), (-1, -1), (0, -1)), jkick),
Shape("T", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (-1, -1)), jkick),
Shape("Z", (-1, -2), ((-1, -2), (0, -2), (-2, -1), (-1, -1)), jkick),
 
x = Tetronimo(Shape.random(), (3, 3))
y = Tetronimo.random()
z = Tetronimo(Shape.get("Z"), (3, 3))
Reply
#5
But where do I store the data? In the shape module?

What if I use the simple factory pattern instead?

from pyglet.math import Vec2

from mino import Mino


standard_wall_kick_data = {
    0: {
        3: ((-1, 0), (-1, -1), (0, 2), (-1, 2)),
        1: ((1, 0), (1, -1), (0, 2), (1, 2))},
    1: {
        0: ((-1, 0), (-1, 1), (0, -2), (-1, -2)),
        2: ((-1, 0), (-1, 1), (0, -2), (-1, -2))},
    2: {
        1: ((1, 0), (1, -1), (0, 2), (1, 2)),
        3: ((-1, 0), (-1, -1), (0, 2), (-1, 2))},
    3: {
        2: ((1, 0), (1, 1), (0, -2), (1, -2)),
        0: ((1, 0), (1, 1), (0, -2), (1, -2))}
}

i_wall_kick_data = {
    0: {
        3: ((1, 0), (-2, 0), (1, -2), (-2, 1)),
        1: ((2, 0), (-1, 0), (2, 1), (-1, -2))},
    1: {
        0: ((-2, 0), (1, 0), (-2, -1), (1, 2)),
        2: ((1, 0), (-2, 0), (1, -2), (-2, 1))},
    2: {
        1: ((-1, 0), (2, 0), (-1, 2), (2, -1)),
        3: ((-2, 0), (1, 0), (-2, -1), (1, 2))},
    3: {
        2: ((2, 0), (-1, 0), (2, 1), (-1, -2)),
        0: ((-1, 0), (2, 0), (-1, 2), (2, -1))}
}



class Tetromino:
    def __init__(self, shape_data, pivot, wall_kick_data, dx, dy, cell_size, color, batch):
        self.minos = [Mino(x + dx, y + dy, cell_size, color, batch) for (x, y) in shape_data]
        self.pivot = pivot + Vec2(dx, dy)
        self.wall_kick_data = wall_kick_data

        self.current_rotation = 0
        self.previous_rotation = 0

    def move_by(self, vector):
        self.pivot += vector
        for mino in self.minos:
            mino.move_by(vector)

    def rotate(self, angle):
        self.previous_rotation = self.current_rotation
        if angle > 0:
            self.current_rotation = (self.current_rotation - 1) % 4
        else:
            self.current_rotation = (self.current_rotation + 1) % 4

        for mino in self.minos:
            mino.rotate(self.pivot, angle)

    def update(self):
        for mino in self.minos:
            mino.update()


class I(Tetromino):
    def __init__(self, dx, dy, cell_size, color, batch):
        shape_data = ((-2, -2), (-1, -2), (0, -2), (1, -2))
        pivot = Vec2(-0.5, -2.5)
        wall_kick_data = i_wall_kick_data
        super().__init__(shape_data, pivot, wall_kick_data, dx, dy, cell_size, color, batch)


class J(Tetromino):
    def __init__(self, dx, dy, cell_size, color, batch):
        shape_data = ((-2, -2), (-1, -2), (0, -2), (-2, -1))
        pivot = Vec2(-1, -2)
        wall_kick_data = standard_wall_kick_data
        super().__init__(shape_data, pivot, wall_kick_data, dx, dy, cell_size, color, batch)


class L(Tetromino):
    def __init__(self, dx, dy, cell_size, color, batch):
        shape_data = ((-2, -2), (-1, -2), (0, -2), (0, -1))
        pivot = Vec2(-1, -2)
        wall_kick_data = standard_wall_kick_data
        super().__init__(shape_data, pivot, wall_kick_data, dx, dy, cell_size, color, batch)


class O(Tetromino):
    def __init__(self, dx, dy, cell_size, color, batch):
        shape_data = ((-1, -2), (0, -2), (-1, -1), (0, -1))
        pivot = Vec2(-0.5, -1.5)
        wall_kick_data = standard_wall_kick_data
        super().__init__(shape_data, pivot, wall_kick_data, dx, dy, cell_size, color, batch)


class S(Tetromino):
    def __init__(self, dx, dy, cell_size, color, batch):
        shape_data = ((-2, -2), (-1, -2), (-1, -1), (0, -1))
        pivot = Vec2(-1, -2)
        wall_kick_data = standard_wall_kick_data
        super().__init__(shape_data, pivot, wall_kick_data, dx, dy, cell_size, color, batch)


class T(Tetromino):
    def __init__(self, dx, dy, cell_size, color, batch):
        shape_data = ((-2, -2), (-1, -2), (0, -2), (-1, -1))
        pivot = Vec2(-1, -2)
        wall_kick_data = standard_wall_kick_data
        super().__init__(shape_data, pivot, wall_kick_data, dx, dy, cell_size, color, batch)


class Z(Tetromino):
    def __init__(self, dx, dy, cell_size, color, batch):
        shape_data = ((-1, -2), (0, -2), (-2, -1), (-1, -1))
        pivot = Vec2(-1, -2)
        wall_kick_data = standard_wall_kick_data
        super().__init__(shape_data, pivot, wall_kick_data, dx, dy, cell_size, color, batch)


class TetrominoFactory:
    def make_tetromino(self, shape, dx, dy, cell_size, color, batch):
        args = (dx, dy, cell_size, color, batch)
        match shape:
            case 'I':
                return I(*args)
            case 'J':
                return J(*args)
            case 'L':
                return L(*args)
            case 'O':
                return O(*args)
            case 'S':
                return S(*args)
            case 'T':
                return T(*args)
            case 'Z':
                return Z(*args)
            case _:
                raise ValueError(shape)
This way I can store the raw data in the Tetromino subclasses. What I don't like is all those arguments I need to build the minos collection that get passed along the calls. But I need them since each Mino is technically a pyglet rectangle.
Reply
#6
I don't like the subclassing idea. Subclass when you need different attributes. The Tetronimo subclasses all have the same attributes, they just have different values for the attribute. It would be like subclassing int to make a Three class.

I wouldn't make a Shape module. I would put Shape, Mino and Tetronimo in one module. They are tightly coupled and I see no reason to break them out into separate modules. Shapes are created in that module and used by the Tetronimo class. Dont worry about the Shape objects getting definrd multiple times. Python only imports a module once. Subsequent imports reuse the exhisting module object.

tetronimo.py
import random


class Shape:
    """I know the details of a tetronimo shape.
    
    Creating an instance of Shape adds the shape to my dictionary.  Use
    get(id) to get the Shape instance that matches the id, or use random()
    go get a random shape.
    """
    dictionary = {}  # Lookup Shape by id.

    def __init__(self, id, color, origin, monos, wall_kick):
        self.id = id                # Key in dictionary.
        self.color = color          # Color when drawn.
        self.origin = origin        # Rotation origin.
        self.monos = monos          # List of mono coordinates.
        self.wall_kick = wall_kick  # Wall kick dictionary.
        self.dictionary[id] = self

    @classmethod
    def get(cls, key):
        """Lookup shape by id."""
        return cls.dictionary[key]

    @classmethod
    def random(cls):
        """Return random shape."""
        return random.choice(list(cls.dictionary.values()))


class Mino:
    """I am one block in a tetronimo."""

    def __init__(self, color, position):
        self.color = color
        self.position = position


class Tetronimo:
    """I am a tetronimo that the player is moving on the screen.

    Use random(location, rotation) to create a randomly shaped
    tetronimo.  location is the coordinates of the origin.  Rotation
    is 0..3.

    Use get(shape_id, location, rotation) to create a tetronimo
    of a specific shape.
    """
    @classmethod
    def random(cls, location, rotation=0):
        """Create a random Tetronimo."""
        return cls(Shape.random(), location, rotation)

    @classmethod
    def get(cls, shape_id, location, rotation=0):
        """Create Tetronimo using shape with matching id."""
        return cls(Shape.get(shape_id), location, rotation)

    def __init__(self, shape, location, rotation=0):
        self.shape = shape
        self.minos = [Mino(shape.color, position) for position in shape.monos]
        self.location = location
        self.rotation = rotation
        self.update()

    def __repr__(self):
        return f"<Tetronimo {self.shape.id} color={self.shape.color} location={self.location} roation={self.rotation}>"

    def rotate(self, rotation):
        """Implements standard rotation system."""

    def shift(self, x, y):
        """Move in x and y direction."""

    def update(self):
        """Update all the mino positions."""

#  The following code only runs the first time the module is imported.

# Set up the wall kick info.  This is the default wall kick.
kick = (
    {3: ((-1, 0), (-1, -1), (0, 2), (-1, 2)), 1: ((1, 0), (1, -1), (0, 2), (1, 2))},
    {0: ((-1, 0), (-1, 1), (0, -2), (-1, -2)), 2: ((-1, 0), (-1, 1), (0, -2), (-1, -2))},
    {1: ((1, 0), (1, -1), (0, 2), (1, 2)), 3: ((-1, 0), (-1, -1), (0, 2), (-1, 2))},
    {2: ((1, 0), (1, 1), (0, -2), (1, -2)), 0: ((1, 0), (1, 1), (0, -2), (1, -2))}
)
ikick = (
    {3: ((1, 0), (-2, 0), (1, -2), (-2, 1)), 1: ((2, 0), (-1, 0), (2, 1), (-1, -2))},
    {0: ((-2, 0), (1, 0), (-2, -1), (1, 2)), 2: ((1, 0), (-2, 0), (1, -2), (-2, 1))},
    {1: ((-1, 0), (2, 0), (-1, 2), (2, -1)), 3: ((-2, 0), (1, 0), (-2, -1), (1, 2))},
    {2: ((2, 0), (-1, 0), (2, 1), (-1, -2)), 0: ((-1, 0), (2, 0), (-1, 2), (2, -1))},
)

# Define all the shapes.  Adds shapes to Shape dictionary using id as key.
Shape("I", "cyan", (-0.5, -2.5), ((-2, -2), (-1, -2), (0, -2), (1, -2)), ikick)
Shape("J", "blue", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (-2, -1)), kick)
Shape("L", "orange", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (0, -1)), kick)
Shape("O", "yellow", (-0.5, -1.5), ((-1, -2), (0, -2), (-1, -1), (0, -1)), kick)
Shape("S", "green", (-1, -2), ((-2, -2), (-1, -2), (-1, -1), (0, -1)), kick)
Shape("T", "magenta", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (-1, -1)), kick)
Shape("Z", "red", (-1, -2), ((-1, -2), (0, -2), (-2, -1), (-1, -1)), kick)
test.py
from tetronimo import Tetronimo

print(Tetronimo.get("S", (0, 0)))
print(Tetronimo.random((0, 0), 3))
Output:
<Tetronimo S color=green location=(0, 0) roation=0> <Tetronimo L color=orange location=(0, 0) roation=3>
The game does't need to know the shapes. When making a Tetronimo the shape could be selected at random. See Tetronimo.random()

Another choice is to leave the tetronimo.py module generic and build the shapes in your game.

test.py
from tetronimo import Tetronimo, Shape

# Set up the wall kick info.  This is the default wall kick.
kick = (
    {3: ((-1, 0), (-1, -1), (0, 2), (-1, 2)), 1: ((1, 0), (1, -1), (0, 2), (1, 2))},
    {0: ((-1, 0), (-1, 1), (0, -2), (-1, -2)), 2: ((-1, 0), (-1, 1), (0, -2), (-1, -2))},
    {1: ((1, 0), (1, -1), (0, 2), (1, 2)), 3: ((-1, 0), (-1, -1), (0, 2), (-1, 2))},
    {2: ((1, 0), (1, 1), (0, -2), (1, -2)), 0: ((1, 0), (1, 1), (0, -2), (1, -2))}
)
ikick = (
    {3: ((1, 0), (-2, 0), (1, -2), (-2, 1)), 1: ((2, 0), (-1, 0), (2, 1), (-1, -2))},
    {0: ((-2, 0), (1, 0), (-2, -1), (1, 2)), 2: ((1, 0), (-2, 0), (1, -2), (-2, 1))},
    {1: ((-1, 0), (2, 0), (-1, 2), (2, -1)), 3: ((-2, 0), (1, 0), (-2, -1), (1, 2))},
    {2: ((2, 0), (-1, 0), (2, 1), (-1, -2)), 0: ((-1, 0), (2, 0), (-1, 2), (2, -1))},
)

# Define all the shapes.  Adds shapes to Shape dictionary using id as key.
Shape("I", "cyan", (-0.5, -2.5), ((-2, -2), (-1, -2), (0, -2), (1, -2)), ikick)
Shape("J", "blue", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (-2, -1)), kick)
Shape("L", "orange", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (0, -1)), kick)
Shape("O", "yellow", (-0.5, -1.5), ((-1, -2), (0, -2), (-1, -1), (0, -1)), kick)
Shape("S", "green", (-1, -2), ((-2, -2), (-1, -2), (-1, -1), (0, -1)), kick)
Shape("T", "magenta", (-1, -2), ((-2, -2), (-1, -2), (0, -2), (-1, -1)), kick)
Shape("Z", "red", (-1, -2), ((-1, -2), (0, -2), (-2, -1), (-1, -1)), kick)

print(Tetronimo.get("S", (0, 0)))
print(Tetronimo.random((0, 0), 3))
I have no experience with pyglet. That's why the Mino class is so spare. Some structural changes may be required to fit into the framework defined by pyglet.
Reply
#7
Okay, thanks, it works perfectly. I've made a few changes:

import math
import random

from pyglet.shapes import Rectangle
from pyglet.math import Vec2

import settings
 
 
class Mino(Rectangle):
 
    def __init__(self, grid_position, cell_size, color, batch):
        self.grid_position = grid_position
        x = self.grid_position.x * cell_size
        y = self.grid_position.y * cell_size
        super().__init__(x, y, cell_size, cell_size, color, batch)

    def rotate(self, pivot, angle):
        translated = self.grid_position - pivot
        rotated = translated.rotate(math.radians(angle))
        self.grid_position = Vec2(*map(round, rotated + pivot))

    def shift(self, vector):
        self.grid_position += vector

    def update(self):
        self.x = self.grid_position.x * self.width
        self.y = self.grid_position.y * self.height
 
 
class Tetromino:
    """I am a tetromino that the player is moving on the screen.
 
    Use random(location, rotation) to create a randomly shaped
    tetromimo.  location is the coordinates of the origin.  Rotation
    is 0..3.
 
    Use get(shape_id, offset, rotation) to create a tetromino
    of a specific shape.
    """

    @classmethod
    def random(cls, offset, cell_size, batch, rotation=0):
        """Create a random Tetronimo."""
        return cls(Shape.random(), offset, cell_size, batch, rotation)
 
    @classmethod
    def get(cls, shape_id, offset, cell_size, batch, rotation=0):
        """Create Tetronimo using shape with matching id."""
        return cls(Shape.get(shape_id), offset, cell_size, batch, rotation)
 
    def __init__(self, shape, offset, cell_size, batch, rotation=0):
        self.shape = shape
        self.minos = [Mino(position, cell_size, shape.color, batch) for position in shape.minos]
        self.location = shape.origin
        self.rotation = rotation
        self.shift(offset)
        self.update()
 
    def __repr__(self):
        return f"<Tetronimo {self.shape.id} color={self.shape.color} location={self.location} rotation={self.rotation}>"
 
    def rotate(self, angle):
        """Implements standard rotation system."""
        self.rotation = (self.rotation - int(math.copysign(1, angle))) % 4
        for mino in self.minos:
            mino.rotate(self.location, angle)
        
    def shift(self, vector):
        """Move in x and y direction."""
        self.location += vector
        for mino in self.minos:
            mino.shift(vector)
 
    def update(self):
        """Update all the mino positions."""
        for mino in self.minos:
            mino.update()


class Shape:
    """I know the details of a tetromino shape.
     
    Creating an instance of Shape adds the shape to my dictionary.  Use
    get(id) to get the Shape instance that matches the id, or use random()
    go get a random shape.
    """
    dictionary = {}  # Lookup Shape by id.
 
    def __init__(self, id, color, origin, minos, wall_kick):
        self.id = id                # Key in dictionary.
        self.color = color          # Color when drawn.
        self.origin = origin        # Rotation origin.
        self.minos = minos          # List of mino coordinates.
        self.wall_kick = wall_kick  # Wall kick dictionary.
        self.dictionary[id] = self
 
    @classmethod
    def get(cls, key):
        """Lookup shape by id."""
        return cls.dictionary[key]
 
    @classmethod
    def random(cls):
        """Return random shape."""
        return random.choice(list(cls.dictionary.values()))


#  The following code only runs the first time the module is imported.
pivot = Vec2(-1, -2)
i_pivot = Vec2(-0.5, -2.5)
o_pivot = Vec2(-0.5, -1.5)

i_minos = (Vec2(-2, -2), Vec2(-1, -2), Vec2(0, -2), Vec2(1, -2))
j_minos = (Vec2(-2, -2), Vec2(-1, -2), Vec2(0, -2), Vec2(-2, -1))
l_minos = (Vec2(-2, -2), Vec2(-1, -2), Vec2(0, -2), Vec2(0, -1))
o_minos = (Vec2(-1, -2), Vec2(0, -2), Vec2(-1, -1), Vec2(0, -1))
s_minos = (Vec2(-2, -2), Vec2(-1, -2), Vec2(-1, -1), Vec2(0, -1))
t_minos = (Vec2(-2, -2), Vec2(-1, -2), Vec2(0, -2), Vec2(-1, -1))
z_minos = (Vec2(-1, -2), Vec2(0, -2), Vec2(-2, -1), Vec2(-1, -1))


# Set up the wall kick info.  This is the default wall kick.
kick = (
    {
        3: (Vec2(-1, 0), Vec2(-1, -1), Vec2(0, 2), Vec2(-1, 2)),
        1: (Vec2(1, 0), Vec2(1, -1), Vec2(0, 2), Vec2(1, 2))},
    {
        0: (Vec2(-1, 0), Vec2(-1, 1), Vec2(0, -2), Vec2(-1, -2)),
        2: (Vec2(-1, 0), Vec2(-1, 1), Vec2(0, -2), Vec2(-1, -2))},
    {
        1: (Vec2(1, 0), Vec2(1, -1), Vec2(0, 2), Vec2(1, 2)),
        3: (Vec2(-1, 0), Vec2(-1, -1), Vec2(0, 2), Vec2(-1, 2))},
    {
        2: (Vec2(1, 0), Vec2(1, 1), Vec2(0, -2), Vec2(1, -2)),
        0: (Vec2(1, 0), Vec2(1, 1), Vec2(0, -2), Vec2(1, -2))})
i_kick = (
    {
        3: (Vec2(1, 0), Vec2(-2, 0), Vec2(1, -2), Vec2(-2, 1)),
        1: (Vec2(2, 0), Vec2(-1, 0), Vec2(2, 1), Vec2(-1, -2))},
    {
        0: (Vec2(-2, 0), Vec2(1, 0), Vec2(-2, -1), Vec2(1, 2)),
        2: (Vec2(1, 0), Vec2(-2, 0), Vec2(1, -2), Vec2(-2, 1))},
    {
        1: (Vec2(-1, 0), Vec2(2, 0), Vec2(-1, 2), Vec2(2, -1)),
        3: (Vec2(-2, 0), Vec2(1, 0), Vec2(-2, -1), Vec2(1, 2))},
    {
        2: (Vec2(2, 0), Vec2(-1, 0), Vec2(2, 1), Vec2(-1, -2)),
        0: (Vec2(-1, 0), Vec2(2, 0), Vec2(-1, 2), Vec2(2, -1))})

# Define all the shapes.  Adds shapes to Shape dictionary using id as key.
Shape("I", settings.i_color, i_pivot, i_minos, i_kick)
Shape("J", settings.j_color, pivot, j_minos, kick)
Shape("L", settings.l_color, pivot, l_minos, kick)
Shape("O", settings.o_color, o_pivot, o_minos, None)
Shape("S", settings.s_color, pivot, s_minos, kick)
Shape("T", settings.t_color, pivot, t_minos, kick)
Shape("Z", settings.z_color, pivot, z_minos, kick)
Namely, at line 52 the Tetromino class accepts an offset value and then uses the shift and update methods to reposition itself.
Then I use pyglet's 2d Euclidean vectors right away to store the relevant data.
Ah, also, at line 155, None is passed to the O shape instead of the default kick data. That's because technically the O shape is not going to use it anyway since the rotation doesn't impact its position.
Reply
#8
Interesting stuff. A few comments.

I think there is a problem with rotation. Currently the Tetronimo rotation attribute is not used for anything and an angle argument is passed directly to the minos. But I thought the rotation quadrant info was used for the wall kick. And where is the wall kick info used? That logic probably belongs in the Tetronimo rotate method.

Don't repeat code if you can avoid it. This is bad:
i_minos = (Vec2(-2, -2), Vec2(-1, -2), Vec2(0, -2), Vec2(1, -2))
j_minos = (Vec2(-2, -2), Vec2(-1, -2), Vec2(0, -2), Vec2(-2, -1))
This is better:
i_minos = ((-2, -2), (-1, -2), (0, -2), (1, -2))
j_minos = ((-2, -2), (-1, -2), (0, -2), (-2, -1))
Move the vec2() call to the Shape __init__() method.
self.minos = [Vec2(*mino) for mino in minos]
Same goes with the rotation center and the kick.

Are there different sized Mino? If not, make cell_size a class variable. Why are you passing batch to Tetrnonimo __init__? Are you batching multiple Tetronimo, or do you want to batch the Mino in a Tetronimo? If the latter, have Tetronimo.__init__() create the batch and pass it to the Mino.__init__.

Since many shapes use the same rotation origin and wall kick, maybe the Shape class should have default values for these. That way you only need to provide values when a shape doesn't use the default values.

It looks like Mino needs to know about rotation origin, but Tetronimo does not. I would remove pivot from the Mino rotate method arguments and make it an attribute of the Mino object.

I would modify the code to look more like this:
class Shape:
    """I know the details of a tetronimo shape.
 
    Creating an instance of Shape adds the shape to my dictionary.  Use
    get(id) to get the Shape instance that matches the id, or use random()
    go get a random shape.
    """
    dictionary = {}  # Lookup Shape by id.
    default_kick = (
        {3: ((-1, 0), (-1, -1), (0, 2), (-1, 2)), 1: ((1, 0), (1, -1), (0, 2), (1, 2))},
        {0: ((-1, 0), (-1, 1), (0, -2), (-1, -2)), 2: ((-1, 0), (-1, 1), (0, -2), (-1, -2))},
        {1: ((1, 0), (1, -1), (0, 2), (1, 2)), 3: ((-1, 0), (-1, -1), (0, 2), (-1, 2))},
        {2: ((1, 0), (1, 1), (0, -2), (1, -2)), 0: ((1, 0), (1, 1), (0, -2), (1, -2))},
    )
    default_pivot = (-1, -2)
 
    def __init__(self, id, color, minos, pivot=None, wall_kick=None):
        pivot = self.default_pivot if pivot is None else pivot
        wall_kick = self.default_kick if wall_kick is None else wall_kick
        self.id = id
        self.color = color
        self.minos = [Vec2(mino for mino in minos)]
        self.pivot = Vec2(*pivot)
        self.wall_kick = [Vec2(*k) for k in wall_kick]
        self.dictionary[id] = self
 
    @classmethod
    def get(cls, key):
        """Lookup shape by id."""
        return cls.dictionary[key]
 
    @classmethod
    def random(cls):
        """Return random shape."""
        return random.choice(list(cls.dictionary.values()))
 
 
class Mino(Rectangle):
    cell_size = 50
 
    def __init__(self, grid_position, pivot, color, batch):
        self.grid_position = grid_position
        self.pivot = pivot
        x = self.grid_position.x * self.cell_size
        y = self.grid_position.y * self.cell_size
        super().__init__(x, y, self.cell_size, self.cell_size, color, batch)
 
    def rotate(self, angle):
        translated = self.grid_position - self.pivot
        rotated = translated.rotate(math.radians(angle))
        self.grid_position = Vec2(*map(round, rotated + self.pivot))
 
    def shift(self, vector):
        self.grid_position += vector
 
    def update(self):
        self.x = self.grid_position.x * self.width
        self.y = self.grid_position.y * self.height
 
 
class Tetronimo:
    """I am a tetronimo that the player is moving on the screen.
 
    Use random(location, rotation) to create a randomly shaped
    tetronimo.  location is the coordinates of the origin.  Rotation
    is 0..3.
 
    Use get(shape_id, location, rotation) to create a tetronimo
    of a specific shape.
    """
 
    @classmethod
    def random(cls, location):
        """Create a random Tetronimo."""
        return cls(Shape.random(), location)
 
    @classmethod
    def get(cls, shape_id, location, rotation=0):
        """Create Tetronimo using shape with matching id."""
        return cls(Shape.get(shape_id), location)
 
    def __init__(self, shape, location):
        self.shape = shape
        self.batch = pyglet.graphics.Batch()
        self.minos = [
            Mino(shape.color, position, self.batch, shape.pivot) for position in shape.minos
        ]
        self.location = location
 
    def rotate(self, angle):
        """Rotate tetronimo."""
        for mino in self.minos:
            mino.rotate(self.location, angle)
 
    def shift(self, vector):
        """Move in x and y direction."""
        self.location += vector
        for mino in self.minos:
            mino.shift(vector)
 
  
# Define all the shapes
Shape("I", settings.icolor, ((-2, -2), (-1, -2), (0, -2), (1, -2)),
    pivot=(-0.5, -2.5),
    wall_kick=[
        {3: ((1, 0), (-2, 0), (1, -2), (-2, 1)), 1: ((2, 0), (-1, 0), (2, 1), (-1, -2))},
        {0: ((-2, 0), (1, 0), (-2, -1), (1, 2)), 2: ((1, 0), (-2, 0), (1, -2), (-2, 1))},
        {1: ((-1, 0), (2, 0), (-1, 2), (2, -1)), 3: ((-2, 0), (1, 0), (-2, -1), (1, 2))},
        {2: ((2, 0), (-1, 0), (2, 1), (-1, -2)), 0: ((-1, 0), (2, 0), (-1, 2), (2, -1))}
    ]
)
Shape("O", settings.ocolor, ((-1, -2), (0, -2), (-1, -1), (0, -1)), (-0.5, -1.5), [])
Shape("J", settings.jcolor, ((-2, -2), (-1, -2), (0, -2), (-2, -1)))
Shape("L", settings.lcolor, ((-2, -2), (-1, -2), (0, -2), (0, -1)))
Shape("S", settings.scolor, ((-2, -2), (-1, -2), (-1, -1), (0, -1)))
Shape("T", settings.tcolor, ((-2, -2), (-1, -2), (0, -2), (-1, -1)))
Shape("Z", settings.zcolor, ((-1, -2), (0, -2), (-2, -1), (-1, -1)))
Reply
#9
Hold on, it's probably better to post the whole code structure at this point.

This is main.py:

import pyglet

import settings
from colored_window import ColoredWindow
from fixed_resolution import FixedResolution
from tetris import Tetris


class App:
    caption = 'Tetris'
    fps = 60
    bgcolor = settings.bgcolor
    
    def __init__(self):
        self.window = ColoredWindow(
            400, 800, caption=self.caption, color=self.bgcolor, resizable=True)
        self.fixed_res = FixedResolution(self.window, width=100, height=200)
        self.window.push_handlers(self)
        self.batch = pyglet.graphics.Batch()
        self.tetris = Tetris(self.batch)
        self.window.push_handlers(self.tetris.key_state_handler)

    def on_draw(self):
        self.window.clear()
        with self.fixed_res:
            self.batch.draw()

    def update(self, dt):
        self.tetris.update(dt)

    def run(self):
        pyglet.clock.schedule_interval(self.update, 1/self.fps)
        pyglet.app.run(1/self.fps)


if __name__ == '__main__':
    app = App()
    app.run()
As you can see, this is where the batch is created.
Quote:[The batch] manages a collection of drawables for batched rendering.

Many drawable pyglet objects accept an optional Batch argument in their constructors. By giving a Batch to multiple objects, you can tell pyglet that you expect to draw all of these objects at once, so it can optimise its use of OpenGL. Hence, drawing a Batch is often much faster than drawing each contained drawable separately.
https://pyglet.readthedocs.io/en/latest/...index.html

I can create multiple batches, but I think that would defeat the purpose. Plus, I'd have to track all the different batches to draw them here... I don't think that's viable.

However, I have to drag this batch object to each drawable object.

Next is tetris.py:
import pyglet
from pyglet.math import Vec2

from field import Field
from input_handler import InputHandler
from tetromino import Tetromino


class Tetris:
    field_width = 10
    field_height = 20
    
    def __init__(self, batch):
        self.batch = batch
        self.key_state_handler = pyglet.window.key.KeyStateHandler()

        self.input_handler = InputHandler(self.key_state_handler)
        self.field = Field(self.field_width, self.field_height)
        self.new_tetromino()

    def new_tetromino(self):
        position = Vec2(self.field_width // 2, self.field_height)
        self.tetromino = Tetromino.random(position, self.batch)

    def update(self, dt):
        self.input_handler.handle_movement_input(self.tetromino, dt)
        self.input_handler.handle_rotation_input(self.tetromino)
        self.tetromino.update()
Here we have the field, from field.py:

from pyglet.math import Vec2


class Field:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.grid = [[None for x in range(width)] for y in range(height)]
Tetromino, from tetromino.py (I used your last code snippet with just a few changes to make it work with the batch from the main module):

import math
import random

from pyglet.shapes import Rectangle
from pyglet.math import Vec2

import settings


class Shape:
    """I know the details of a tetronimo shape.
  
    Creating an instance of Shape adds the shape to my dictionary.  Use
    get(id) to get the Shape instance that matches the id, or use random()
    go get a random shape.
    """
    dictionary = {}  # Lookup Shape by id.
    default_kick = (
        {3: ((-1, 0), (-1, -1), (0, 2), (-1, 2)), 1: ((1, 0), (1, -1), (0, 2), (1, 2))},
        {0: ((-1, 0), (-1, 1), (0, -2), (-1, -2)), 2: ((-1, 0), (-1, 1), (0, -2), (-1, -2))},
        {1: ((1, 0), (1, -1), (0, 2), (1, 2)), 3: ((-1, 0), (-1, -1), (0, 2), (-1, 2))},
        {2: ((1, 0), (1, 1), (0, -2), (1, -2)), 0: ((1, 0), (1, 1), (0, -2), (1, -2))},
    )
    default_pivot = (-1, -2)
  
    def __init__(self, id, color, minos, pivot=None, wall_kick=None):
        pivot = self.default_pivot if pivot is None else pivot
        wall_kick = self.default_kick if wall_kick is None else wall_kick
        self.id = id
        self.color = color
        self.minos = [Vec2(*mino) for mino in minos]
        self.pivot = Vec2(*pivot)
        self.wall_kick = [Vec2(*k) for k in wall_kick]
        self.dictionary[id] = self
  
    @classmethod
    def get(cls, key):
        """Lookup shape by id."""
        return cls.dictionary[key]
  
    @classmethod
    def random(cls):
        """Return random shape."""
        return random.choice(list(cls.dictionary.values()))
  
  
class Mino(Rectangle):
    cell_size = 10
  
    def __init__(self, grid_position, pivot, color, batch):
        self.grid_position = grid_position
        self.pivot = pivot
        x = self.grid_position.x * self.cell_size
        y = self.grid_position.y * self.cell_size
        super().__init__(x, y, self.cell_size, self.cell_size, color, batch)
  
    def rotate(self, angle):
        translated = self.grid_position - self.pivot
        rotated = translated.rotate(math.radians(angle))
        self.grid_position = Vec2(*map(round, rotated + self.pivot))
  
    def shift(self, vector):
        self.pivot += vector
        self.grid_position += vector
  
    def update(self):
        self.x = self.grid_position.x * self.width
        self.y = self.grid_position.y * self.height
  
  
class Tetromino:
    """I am a tetronimo that the player is moving on the screen.
  
    Use random(location, rotation) to create a randomly shaped
    tetronimo.  location is the coordinates of the origin.  Rotation
    is 0..3.
  
    Use get(shape_id, location, rotation) to create a tetronimo
    of a specific shape.
    """
  
    @classmethod
    def random(cls, location, batch):
        """Create a random Tetronimo."""
        return cls(Shape.random(), location, batch)
  
    @classmethod
    def get(cls, shape_id, location, batch):
        """Create Tetronimo using shape with matching id."""
        return cls(Shape.get(shape_id), location, batch)
  
    def __init__(self, shape, location, batch):
        self.shape = shape
        self.minos = [
            Mino(position + location, shape.pivot + location, shape.color, batch) for position in shape.minos
        ]
        self.location = location
  
    def rotate(self, angle):
        """Rotate tetronimo."""
        for mino in self.minos:
            mino.rotate(angle)
  
    def shift(self, vector):
        """Move in x and y direction."""
        self.location += vector
        for mino in self.minos:
            mino.shift(vector)

    def update(self):
        for mino in self.minos:
            mino.update()
  
   
# Define all the shapes
Shape("I", settings.icolor, ((-2, -2), (-1, -2), (0, -2), (1, -2)),
    pivot=(-0.5, -2.5),
    wall_kick=[
        {3: ((1, 0), (-2, 0), (1, -2), (-2, 1)), 1: ((2, 0), (-1, 0), (2, 1), (-1, -2))},
        {0: ((-2, 0), (1, 0), (-2, -1), (1, 2)), 2: ((1, 0), (-2, 0), (1, -2), (-2, 1))},
        {1: ((-1, 0), (2, 0), (-1, 2), (2, -1)), 3: ((-2, 0), (1, 0), (-2, -1), (1, 2))},
        {2: ((2, 0), (-1, 0), (2, 1), (-1, -2)), 0: ((-1, 0), (2, 0), (-1, 2), (2, -1))}
    ]
)
Shape("O", settings.ocolor, ((-1, -2), (0, -2), (-1, -1), (0, -1)), (-0.5, -1.5), [])
Shape("J", settings.jcolor, ((-2, -2), (-1, -2), (0, -2), (-2, -1)))
Shape("L", settings.lcolor, ((-2, -2), (-1, -2), (0, -2), (0, -1)))
Shape("S", settings.scolor, ((-2, -2), (-1, -2), (-1, -1), (0, -1)))
Shape("T", settings.tcolor, ((-2, -2), (-1, -2), (0, -2), (-1, -1)))
Shape("Z", settings.zcolor, ((-1, -2), (0, -2), (-2, -1), (-1, -1)))
And, finally, my infamous InputHandler, from input_handler.py (I'm posting it only because I'm not using my real name, but to be fair I was in a frenzy to implement the delayed-repeated input and only when I finished I realized the monstrosity of what I came up with):

from pyglet.math import Vec2
from pyglet.window import key


class InputHandler:
    def __init__(self, key_state_handler, lx=key.LEFT, rx=key.RIGHT,
            soft_drop=key.DOWN, rotate_acw=key.Z, rotate_cw=key.X):
        """Welcome to hell."""
        self.key_state = key_state_handler
        
        # Key mapping
        self.lx = lx
        self.rx = rx
        self.soft_drop = soft_drop
        self.rotate_acw = rotate_acw
        self.rotate_cw = rotate_cw

        self.lx_delay_timer = 0
        self.rx_delay_timer = 0
        self.lx_repeat_timer = 0
        self.rx_repeat_timer = 0
        self.soft_drop_delay_timer = 0
        self.soft_drop_repeat_timer = 0

        self.lx_was_pressed = False
        self.rx_was_pressed = False
        self.soft_drop_was_pressed = False
        self.rotate_acw_was_pressed = False
        self.rotate_cw_was_pressed = False

    def handle_movement_input(self, tetromino, dt, delay=0.05, repeat=0.05):
        if self.key_state[self.lx] and not self.rx_was_pressed:
            if self.lx_was_pressed:
                if self.lx_delay_timer >= delay:
                    if self.lx_repeat_timer >= repeat:
                        tetromino.shift(Vec2(-1, 0))
                        self.lx_repeat_timer = 0
                    else:
                        self.lx_repeat_timer += dt
                else:
                    self.lx_delay_timer += dt
            else:
                self.lx_was_pressed = True
                tetromino.shift(Vec2(-1, 0))
        else:
            self.lx_was_pressed = False
            self.lx_delay_timer = 0
            self.lx_repeat_timer = 0

        if self.key_state[self.rx] and not self.lx_was_pressed:
            if self.rx_was_pressed:
                if self.rx_delay_timer >= delay:
                    if self.rx_repeat_timer >= repeat:
                        tetromino.shift(Vec2(1, 0))
                        self.rx_repeat_timer = 0
                    else:
                        self.rx_repeat_timer += dt
                else:
                    self.rx_delay_timer += dt
            else:
                self.rx_was_pressed = True
                tetromino.shift(Vec2(1, 0))
        else:
            self.rx_was_pressed = False
            self.rx_delay_timer = 0
            self.rx_repeat_timer = 0

        if self.key_state[self.soft_drop]:
            if self.soft_drop_was_pressed:
                if self.soft_drop_delay_timer >= delay:
                    if self.soft_drop_repeat_timer >= repeat:
                        tetromino.shift(Vec2(0, -1))
                        self.soft_drop_repeat_timer = 0
                    else:
                        self.soft_drop_repeat_timer += dt
                else:
                    self.soft_drop_delay_timer += dt
            else:
                self.soft_drop_was_pressed = True
                tetromino.shift(Vec2(0, -1))
        else:
            self.soft_drop_was_pressed = False
            self.soft_drop_delay_timer = 0
            self.soft_drop_repeat_timer = 0

    def handle_rotation_input(self, tetromino):
        if self.key_state[self.rotate_acw] and not self.rotate_cw_was_pressed:
            if not self.rotate_acw_was_pressed:
                self.rotate_acw_was_pressed = True
                tetromino.rotate(90)
        else:
            self.rotate_acw_was_pressed = False

        if self.key_state[self.rotate_cw] and not self.rotate_acw_was_pressed:
            if not self.rotate_cw_was_pressed:
                self.rotate_cw_was_pressed = True
                tetromino.rotate(-90)
        else:
            self.rotate_cw_was_pressed = False
(Sep-18-2023, 05:10 PM)deanhystad Wrote: I think there is a problem with rotation. Currently the Tetronimo rotation attribute is not used for anything and an angle argument is passed directly to the minos. But I thought the rotation quadrant info was used for the wall kick. And where is the wall kick info used? That logic probably belongs in the Tetronimo rotate method.

Yeah, I'm trying to figure out how to let the Field and Tetromino objects interact with each other. The thing is both the rotate and the shift methods need to know whether the desired movement is allowed or not. Now, the easy way out is to pass the whole Field object to the Tetromino objects. That way the Tetromino object can do pretty much everything with the Field. But, ideally, shouldn't objects operate independently from one another? Isn't that the whole OOP shtick?
Reply
#10
Object oriented programming has become a catchphrase used to describe any good programming practice. OOP is about using objects to organize the data and functions in your software. Minimizing interdependencies is just a good idea regardless of what paradigm you use.

A mino needs to know about the Field. That is where it ends up. That is who it asks if there is an open space. When the tetronimo comes to rest in the field, the minos move from being a tetronimo to being part of the field. Tetronimos really don't need to know about the field. A tetronimo can ask it's minos if a shift or rotation results in a collision.

Logically I think the minos are divided into two groups. I think each time a tetronimo is created, the minos in the tetronimo are a tetronimo batch. When the minos comes to rest the tetronimo is destroyed, and the minos become part of the field batch. Can you add a batch to a batch or move rectangles from one batch to another?

The dependency tree is not too bad. Game needs to know about Field and Tetronimo. Tetronimo needs to know about Shape and Mino. Mino needs to know about Field. Field needs to know about Mino. It's not often there is only one circular dependency to resolve, and it is a weak dependency. The only thing field needs to know about Mino is how to add them to a batch. To Field, a Mino is nothing more than a Rectangle. Might be that the field doesn't have to know about Mino at all. Whan the tetronimo lands, the minos add rectangles to the field (pass grid coordinates) and destroy themselves.

My thoughts:
Field is a grid of cells. Each cell can be empty or contain a rectangle. The grid provides a method that returns the content of the corresponding cell, a method that creates a rectangle in the specified cell, a method that return the coordinates of a cell, and a method or attribute that Mino can use to make an appropriate rectangle (cell_size attribute?).

The game creates the field which is initially empty.

The game creates a random Tetronimo. The Tetronimo creates multiple Minos. When a Minos is created it creates a rectangle. The rectangle is not added to the grid, but rather floats above the grid.

The game requests the Tetronimo move. The tetronimo tries a move and checks the minos for collisions. If there is a collision, and the move was a rotation, the tetronimo can try a wall kick.

The tetronimo returns a status indicating if the move was successful. If the move was not successful, the game destroys the tetronimo. When the tetronimo is destroyed, it returns to it's last successful location and destroys all the minos. When a minos is destroyed, it tells the field to create a rectangle in the mino's grid location.
Reply


Forum Jump:

User Panel Messages

Announcements
Announcement #1 8/1/2020
Announcement #2 8/2/2020
Announcement #3 8/6/2020