Python Forum
[Pyglet] Making tetrominos
Thread Rating:
  • 0 Vote(s) - 0 Average
  • 1
  • 2
  • 3
  • 4
  • 5
[Pyglet] Making tetrominos
#11
See, to me tetrominoes and minos should only know how to rotate and shift position. Neither of them should know about the field.

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 = 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))
        self.update()
  
    def shift(self, vector):
        self.pivot += vector
        self.grid_position += vector
        self.update()
  
    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.minos = [
            Mino(pos + location, shape.pivot + location, shape.color, batch) for pos in shape.minos
        ]
        self.wall_kicks = shape.wall_kick
        self.rotation = 0
        self.old_rotation = 0

    def get_positions(self):
        return (mino.grid_position for mino in self.minos)

    def get_wall_kicks(self):
        return self.wall_kicks[self.rotation][self.old_rotation]
            
    def rotate(self, angle):
        """Rotate tetronimo."""
        self.old_rotation = self.rotation
        self.rotation = (self.rotation - int(math.copysign(1, angle))) % 4
        for mino in self.minos:
            mino.rotate(angle)

    def shift(self, vector):
        """Move in x and y direction."""
        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)))
The field should not know about the minos, as long as they're in control of the player:
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)]

    def add_mino(self, mino):
        self.grid[mino.grid_position.y][mino.grid_position.x] = mino

    def add_minos(self, minos):
        for mino in minos:
            self.add_mino(mino)

    def is_cell_free(self, position):
        if 0 <= position.x < self.width and 0 <= position.y < self.height:
            if not self.grid[position.y][position.x]:
                return True
        return False

    def are_cells_free(self, positions):
        return all(map(self.is_cell_free, positions))
In the input_handler module I can provide an interface that both the InputHandler and the Tetris classes can use, through the Command pattern. The thing is, in a Tetris game there are two entities that control a tetromino: the player, trough keyboard (or joypad, or whatever) inputs, and the game itself, which, at least, is supposed to be moving the tetromino down at a speed set by certain game conditions (i.e. every 10 lines cleared the speed increases), but in the case of modern Tetris, it must also perform wall kicks (and the Tetromino class already provides the shift method to pull them off). And, furthermore, the thing about the Command pattern is that it allows me to save a move and undo it if the tetromino fails the collision check.

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


class Command:
    def execute(self):
        pass

    def undo(self):
        pass


class Rotate(Command):
    def __init__(self, tetromino, angle):
        self.tetromino = tetromino
        self.angle = angle

    def execute(self):
        self.tetromino.rotate(self.angle)

    def undo(self):
        self.tetromino.rotate(-self.angle)


class Shift(Command):
    def __init__(self, tetromino, vector):
        self.tetromino = tetromino
        self.vector = vector

    def execute(self):
        self.tetromino.shift(self.vector)

    def undo(self):
        self.tetromino.shift(-self.vector)


class InputHandler:
    def __init__(
            self,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.KeyStateHandler()
        
        # 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_x_movement(self, tetromino, dt, delay=0.08, 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:
                        self.lx_repeat_timer = 0
                        return Shift(tetromino, Vec2(-1, 0))
                    else:
                        self.lx_repeat_timer += dt
                else:
                    self.lx_delay_timer += dt
            else:
                self.lx_was_pressed = True
                return Shift(tetromino, 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:
                        self.rx_repeat_timer = 0
                        return Shift(tetromino, Vec2(1, 0))
                    else:
                        self.rx_repeat_timer += dt
                else:
                    self.rx_delay_timer += dt
            else:
                self.rx_was_pressed = True
                return Shift(tetromino, Vec2(1, 0))
                
        else:
            self.rx_was_pressed = False
            self.rx_delay_timer = 0
            self.rx_repeat_timer = 0

    def handle_y_movement(self, tetromino, dt, delay=0.08, repeat=0.05):
        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:
                        self.soft_drop_repeat_timer = 0
                        return Shift(tetromino, Vec2(0, -1))
                    else:
                        self.soft_drop_repeat_timer += dt
                else:
                    self.soft_drop_delay_timer += dt
            else:
                self.soft_drop_was_pressed = True
                return Shift(tetromino, 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(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
                return Rotate(tetromino, 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
                return Rotate(tetromino, -90)
        else:
            self.rotate_cw_was_pressed = False
Now, the Tetris class is the one that manages the entire game logic. And it serves as an intermediary between the field and the tetrominoes:

import pyglet
from pyglet.math import Vec2

import input_handler
from field import Field
from tetromino import Tetromino


class Tetris:
    field_width = 10
    field_height = 20
    
    def __init__(self, batch):
        self.batch = batch

        self.input_handler = input_handler.InputHandler()
        self.field = Field(self.field_width, self.field_height)
        self.new_tetromino()
        self.tetromino_has_landed = False
        
    def new_tetromino(self):
        position = Vec2(self.field_width // 2, self.field_height)
        self.tetromino = Tetromino.random(position, self.batch)

    def update(self, dt):
        x_movement = self.input_handler.handle_x_movement(self.tetromino, dt)
        if x_movement:
            x_movement.execute()
            if not self.field.are_cells_free(self.tetromino.get_positions()):
                x_movement.undo()

        y_movement = self.input_handler.handle_y_movement(self.tetromino, dt)
        if y_movement:
            y_movement.execute()
            if not self.field.are_cells_free(self.tetromino.get_positions()):
                y_movement.undo()
                self.tetromino_has_landed = True

        rotation = self.input_handler.handle_rotation(self.tetromino)
        if rotation:
            rotation.execute()
            if not self.field.are_cells_free(self.tetromino.get_positions()):
                wall_kicks = (input_handler.Shift(self.tetromino, Vec2(x, y)) for x, y in self.tetromino.get_wall_kicks())
                for wall_kick in wall_kicks:
                    wall_kick.execute()
                    if self.field.are_cells_free(self.tetromino.get_positions()):
                        break
                    else:
                        wall_kick.undo()
                else:
                    rotation.undo()

        if self.tetromino_has_landed:
            self.field.add_minos(self.tetromino.minos)
            self.new_tetromino()
            self.tetromino_has_landed = False
What do you think about this solution? What I like is that it makes the implementation of wall kicks dirt simple. What I don't like is that these tetrominoes now carry this kick data that is supposed to be used externally, which is probably a bit confusing. I mean, for sure, conceptually they're part of the Tetromino behavior.

I think that for the batch problem, I can implement the MVC design pattern, instead of what I'm doing. So, Mino becomes a simple object that only stores grid position and the pivot point, and all the rendering goes into a separate part of the codebase, so that I can use just use a single batch for rendering (I really think pyglet would like me to use a single batch for rendering all I can draw in a window. Multiple batches are probably meant to be used if I have more than one window.)
Reply
#12
The game generates a Tetronimo at random. The Tetronimo generates a large number of Minos. When you make a Minos, you get a rectangle. The rectangle floats above the grid rather of being added to it.
Reply


Forum Jump:

User Panel Messages

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