See, to me tetrominoes and minos should only know how to rotate and shift position. Neither of them should know about the field.
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.)
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 = FalseNow, 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 = FalseWhat 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.)