Building a Tetris Clone with Pygame
Blocks Module
This module defines all the tetromino shapes used in the game. Each piece is represented as a template with rotatoin states.
import random
from collections import namedtuple
Point = namedtuple('Point', 'X Y')
ShapeDef = namedtuple('ShapeDef', 'template start_pos end_pos name rotation')
Tetromino = namedtuple('Tetromino', 'template start_pos end_pos name next')
# Tetromino definitions using 4x4 grid templates
# '.' represents empty space, 'O' represents a filled block
# S-piece: two horizontal blocks stacked with offset
S_SHAPE = [
Tetromino(['.OO',
'OO.',
'...'], Point(0, 0), Point(2, 1), 'S', 1),
Tetromino(['O..',
'OO.',
'.O.'], Point(0, 0), Point(1, 2), 'S', 0)]
# Z-piece: mirror of S-piece
Z_SHAPE = [
Tetromino(['OO.',
'.OO',
'...'], Point(0, 0), Point(2, 1), 'Z', 1),
Tetromino(['.O.',
'OO.',
'O..'], Point(0, 0), Point(1, 2), 'Z', 0)]
# I-piece: vertical or horizontal line
I_SHAPE = [
Tetromino(['.O..',
'.O..',
'.O..',
'.O..'], Point(1, 0), Point(1, 3), 'I', 1),
Tetromino(['....',
'....',
'OOOO',
'....'], Point(0, 2), Point(3, 2), 'I', 0)]
# O-piece: 2x2 square
O_SHAPE = [
Tetromino(['OO',
'OO'], Point(0, 0), Point(1, 1), 'O', 0)]
# J-piece: L-shaped with variant rotations
J_SHAPE = [
Tetromino(['O..',
'OOO',
'...'], Point(0, 0), Point(2, 1), 'J', 1),
Tetromino(['.OO',
'.O.',
'.O.'], Point(1, 0), Point(2, 2), 'J', 2),
Tetromino(['...',
'OOO',
'..O'], Point(0, 1), Point(2, 2), 'J', 3),
Tetromino(['.O.',
'.O.',
'OO.'], Point(0, 0), Point(1, 2), 'J', 0)]
# L-piece: mirror of J-piece
L_SHAPE = [
Tetromino(['..O',
'OOO',
'...'], Point(0, 0), Point(2, 1), 'L', 1),
Tetromino(['.O.',
'.O.',
'.OO'], Point(1, 0), Point(2, 2), 'L', 2),
Tetromino(['...',
'OOO',
'O..'], Point(0, 1), Point(2, 2), 'L', 3),
Tetromino(['OO.',
'.O.',
'.O.'], Point(0, 0), Point(1, 2), 'L', 0)]
# T-piece: T-shaped
T_SHAPE = [
Tetromino(['.O.',
'OOO',
'...'], Point(0, 0), Point(2, 1), 'T', 1),
Tetromino(['.O.',
'.OO',
'.O.'], Point(1, 0), Point(2, 2), 'T', 2),
Tetromino(['...',
'OOO',
'.O.'], Point(0, 1), Point(2, 2), 'T', 3),
Tetromino(['.O.',
'OO.',
'.O.'], Point(0, 0), Point(1, 2), 'T', 0)]
# Collection of all tetrominoes
TETROMINOES = {
'O': O_SHAPE,
'I': I_SHAPE,
'Z': Z_SHAPE,
'T': T_SHAPE,
'L': L_SHAPE,
'S': S_SHAPE,
'J': J_SHAPE
}
def spawn_tetromino():
"""Generate a random tetromino piece."""
piece_type = random.choice('OIJLSTZ')
variants = TETROMINOES[piece_type]
rotation_index = random.randint(0, len(variants) - 1)
return variants[rotation_index]
def get_next_rotation(current_piece):
"""Get the next rotation state of a piece."""
variants = TETROMINOES[current_piece.name]
return variants[current_piece.next]
Main Game Module
This module handles the game loop, rendering, input processign, and game mechanics.
# -*- coding: utf-8 -*-
import sys
import time
import pygame
from pygame.locals import *
import blocks
CELL_SIZE = 30
GRID_HEIGHT = 20
GRID_WIDTH = 10
BORDER_SIZE = 4
BORDER_COLOR = (40, 40, 200)
WINDOW_WIDTH = CELL_SIZE * (GRID_WIDTH + 5)
WINDOW_HEIGHT = CELL_SIZE * GRID_HEIGHT
BACKGROUND_COLOR = (40, 40, 60)
GRID_COLOR = (0, 0, 0)
PIECE_COLOR = (20, 128, 200)
GAMEOVER_COLOR = (200, 30, 30)
def draw_text(surface, font_obj, x, y, content, fcolor=(255, 255, 255)):
"""Render and display text on screen."""
text_surface = font_obj.render(content, True, fcolor)
surface.blit(text_surface, (x, y))
def main():
pygame.init()
screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
pygame.display.set_caption('Tetris')
score_font = pygame.font.SysFont('SimHei', 24)
gameover_font = pygame.font.Font(None, 72)
info_panel_x = GRID_WIDTH * CELL_SIZE + BORDER_SIZE + 10
gameover_dims = gameover_font.size('GAME OVER')
font_height = int(score_font.size('Score')[1])
current_piece = None
preview_piece = None
current_color = PIECE_COLOR
piece_x, piece_y = 0, 0
grid = None
game_over = True
game_started = False
total_score = 0
base_drop_interval = 0.5
current_drop_interval = base_drop_interval
is_paused = False
last_drop_time = None
last_keypress_time = None
def lock_piece():
"""Lock current piece into the grid and check for line clears."""
nonlocal current_piece, preview_piece, grid, piece_x, piece_y, game_over, total_score, current_drop_interval
for row in range(current_piece.start_pos.Y, current_piece.end_pos.Y + 1):
for col in range(current_piece.start_pos.X, current_piece.end_pos.X + 1):
if current_piece.template[row][col] != '.':
grid[piece_y + row][piece_x + col] = 'X'
if piece_y + current_piece.start_pos.Y <= 0:
game_over = True
else:
# Check for completed lines
completed_lines = []
for row in range(current_piece.start_pos.Y, current_piece.end_pos.Y + 1):
if all(cell == 'X' for cell in grid[piece_y + row]):
completed_lines.append(piece_y + row)
if completed_lines:
# Calculate score based on lines cleared
line_count = len(completed_lines)
if line_count == 1:
total_score += 100
elif line_count == 2:
total_score += 300
elif line_count == 3:
total_score += 700
elif line_count == 4:
total_score += 1500
current_drop_interval = base_drop_interval - 0.03 * (total_score // 10000)
# Remove completed lines and shift above rows down
dest_row = src_row = completed_lines[-1]
while src_row >= 0:
while src_row in completed_lines:
src_row -= 1
if src_row < 0:
grid[dest_row] = ['.'] * GRID_WIDTH
else:
grid[dest_row] = grid[src_row]
dest_row -= 1
src_row -= 1
current_piece = preview_piece
preview_piece = blocks.spawn_tetromino()
piece_x, piece_y = (GRID_WIDTH - current_piece.end_pos.X - 1) // 2, -1 - current_piece.end_pos.Y
def can_place(pos_x, pos_y, piece):
"""Check if piece can be placed at given position."""
nonlocal grid
for row in range(piece.start_pos.Y, piece.end_pos.Y + 1):
if pos_y + piece.end_pos.Y >= GRID_HEIGHT:
return False
for col in range(piece.start_pos.X, piece.end_pos.X + 1):
if pos_y + row >= 0 and piece.template[row][col] != '.' and grid[pos_y + row][pos_x + col] != '.':
return False
return True
while True:
for event in pygame.event.get():
if event.type == QUIT:
sys.exit()
elif event.type == KEYDOWN:
if event.key == K_RETURN:
if game_over:
game_started = True
game_over = False
total_score = 0
last_drop_time = time.time()
last_keypress_time = time.time()
grid = [['.'] * GRID_WIDTH for _ in range(GRID_HEIGHT)]
current_piece = blocks.spawn_tetromino()
preview_piece = blocks.spawn_tetromino()
piece_x, piece_y = (GRID_WIDTH - current_piece.end_pos.X - 1) // 2, -1 - current_piece.end_pos.Y
elif event.key == K_SPACE:
if not game_over:
is_paused = not is_paused
elif event.key in (K_w, K_UP):
# Rotate piece
if 0 <= piece_x <= GRID_WIDTH - len(current_piece.template[0]):
next_rotation = blocks.get_next_rotation(current_piece)
if can_place(piece_x, piece_y, next_rotation):
current_piece = next_rotation
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_LEFT:
if not game_over and not is_paused:
if time.time() - last_keypress_time > 0.1:
last_keypress_time = time.time()
if piece_x > -current_piece.start_pos.X:
if can_place(piece_x - 1, piece_y, current_piece):
piece_x -= 1
if event.key == pygame.K_RIGHT:
if not game_over and not is_paused:
if time.time() - last_keypress_time > 0.1:
last_keypress_time = time.time()
if piece_x + current_piece.end_pos.X + 1 < GRID_WIDTH:
if can_place(piece_x + 1, piece_y, current_piece):
piece_x += 1
if event.key == pygame.K_DOWN:
if not game_over and not is_paused:
if time.time() - last_keypress_time > 0.1:
last_keypress_time = time.time()
if not can_place(piece_x, piece_y + 1, current_piece):
lock_piece()
else:
last_drop_time = time.time()
piece_y += 1
# Fill background
screen.fill(BACKGROUND_COLOR)
# Draw border between game area and info panel
pygame.draw.line(screen, BORDER_COLOR,
(CELL_SIZE * GRID_WIDTH + BORDER_SIZE // 2, 0),
(CELL_SIZE * GRID_WIDTH + BORDER_SIZE // 2, WINDOW_HEIGHT), BORDER_SIZE)
# Draw locked pieces
if grid:
for row_idx, row_data in enumerate(grid):
for col_idx, cell in enumerate(row_data):
if cell != '.':
pygame.draw.rect(screen, current_color,
(col_idx * CELL_SIZE, row_idx * CELL_SIZE, CELL_SIZE, CELL_SIZE), 0)
# Draw grid lines
for x in range(GRID_WIDTH):
pygame.draw.line(screen, GRID_COLOR, (x * CELL_SIZE, 0), (x * CELL_SIZE, WINDOW_HEIGHT), 1)
for y in range(GRID_HEIGHT):
pygame.draw.line(screen, GRID_COLOR, (0, y * CELL_SIZE), (GRID_WIDTH * CELL_SIZE, y * CELL_SIZE), 1)
if not game_over:
current_time = time.time()
if current_time - last_drop_time > current_drop_interval:
if not is_paused:
if not can_place(piece_x, piece_y + 1, current_piece):
lock_piece()
else:
last_drop_time = current_time
piece_y += 1
else:
if game_started:
draw_text(screen, gameover_font,
(WINDOW_WIDTH - gameover_dims[0]) // 2,
(WINDOW_HEIGHT - gameover_dims[1]) // 2,
'GAME OVER', GAMEOVER_COLOR)
# Draw falling piece
if current_piece:
for row in range(current_piece.start_pos.Y, current_piece.end_pos.Y + 1):
for col in range(current_piece.start_pos.X, current_piece.end_pos.X + 1):
if current_piece.template[row][col] != '.':
pygame.draw.rect(screen, current_color,
((piece_x + col) * CELL_SIZE,
(piece_y + row) * CELL_SIZE,
CELL_SIZE, CELL_SIZE), 0)
# Display score and next piece info
draw_text(screen, score_font, info_panel_x, 10, f'Score: ')
draw_text(screen, score_font, info_panel_x, 10 + font_height + 6, f'{total_score}')
draw_text(screen, score_font, info_panel_x, 20 + (font_height + 6) * 2, f'Level: ')
draw_text(screen, score_font, info_panel_x, 20 + (font_height + 6) * 3, f'{total_score // 10000}')
draw_text(screen, score_font, info_panel_x, 30 + (font_height + 6) * 4, f'Next:')
if preview_piece:
preview_y = 30 + (font_height + 6) * 5
for row in range(preview_piece.start_pos.Y, preview_piece.end_pos.Y + 1):
for col in range(preview_piece.start_pos.X, preview_piece.end_pos.X + 1):
if preview_piece.template[row][col] != '.':
pygame.draw.rect(screen, current_color,
(info_panel_x + col * CELL_SIZE,
preview_y + row * CELL_SIZE,
CELL_SIZE, CELL_SIZE), 0)
pygame.display.flip()
if __name__ == '__main__':
main()