Files
tetris/main.py
T

480 lines
15 KiB
Python
Raw Normal View History

2026-04-30 03:24:12 -07:00
import pygame
import pygame.draw as draw
2026-04-30 17:58:14 -07:00
from collections import namedtuple, defaultdict
from typing import NamedTuple, Any
2026-04-30 03:24:12 -07:00
import random
import math
2026-04-30 17:58:14 -07:00
from enum import Enum, auto
2026-04-30 03:24:12 -07:00
Cell = namedtuple("Cell", ["x", "y"])
Size = namedtuple("Size", ["width", "height"])
ColorSet = namedtuple("ColorSet", ["norm", "light", "dark"])
2026-04-30 17:58:14 -07:00
class Direction(int, Enum):
LEFT = -1
RIGHT = 1
def make_matrix(w: int, h: int, elt: Any = None) -> list[list]:
if not w:
return []
return [[elt] * w for _ in range(h)]
2026-04-30 03:24:12 -07:00
def flip(mat: list[list]) -> list[list]:
return list(reversed(mat))
def transpose(mat: list[list]) -> list[list]:
if not mat:
return []
2026-04-30 17:58:14 -07:00
out = make_matrix(len(mat), len(mat[0]))
2026-04-30 03:24:12 -07:00
for x in range(len(mat[0])):
for y in range(len(mat)):
out[x][y] = mat[y][x]
return out
def mirror(mat: list[list]) -> list[list]:
return [list(reversed(row)) for row in mat]
class Tetromino(NamedTuple):
color: str
shape: list[list[str]]
@property
def width(self):
return len(self.shape[0])
@property
def height(self):
return len(self.shape)
def rotated(self, times: int):
"""Return self rotated TIMES times to the right."""
times %= 4
if times == 0:
return self
if times == 1:
return Tetromino(self.color, mirror(transpose(self.shape)))
if times == 2:
return Tetromino(self.color, mirror(flip(self.shape)))
if times == 3:
return Tetromino(self.color, flip(transpose(self.shape)))
def __repr__(self):
2026-04-30 17:58:14 -07:00
out = []
for row in self.shape:
for cell in row:
out.append("*" if cell else " ")
out.append("\n")
return "".join(out)
class Action(NamedTuple):
key: int
type: Type
repeat: bool
desc: str
class Type(Enum):
ROTATE_LEFT = auto()
ROTATE_RIGHT = auto()
DROP = auto()
SWAP_HOLD = auto()
MOVE_LEFT = auto()
MOVE_RIGHT = auto()
@staticmethod
def make_map(*acts):
return {act.key: act for act in acts}
2026-04-30 03:24:12 -07:00
class Game:
2026-04-30 17:58:14 -07:00
MOVE_SPEED = 20 # cells per second
NORMAL_DROP_SPEED = 3 # cells per second
FAST_DROP_SPEED = 10 # cells per second
CLEAR_SPEED = 5 # frames per block
2026-04-30 03:24:12 -07:00
BOARD_SIZE = Size(10, 20) # Cell count
CELL_SIZE = Size(30, 30)
CELL_BORDER_SIZE = CELL_SIZE.width // 10
CONTROLS_WIDTH = 160
FRAMERATE = 120
WINDOW_SIZE = Size(
(BOARD_SIZE.width + 2) * CELL_SIZE.width + CONTROLS_WIDTH,
(BOARD_SIZE.height + 2) * CELL_SIZE.height,
)
PALETTE = {
"gray": ColorSet("#787878", "#9a9a9a", "#303030"),
"purple": ColorSet("#9a00cd", "#cd00ff", "#66009a"),
"yellow": ColorSet("#cdcd00", "#ffff00", "#9a9a00"),
"red": ColorSet("#cd0000", "#ff0000", "#9a0000"),
"orange": ColorSet("#cd6600", "#ff8900", "#9a4200"),
"blue": ColorSet("#0000cd", "#0000ff", "#00009a"),
"aqua": ColorSet("#00cdcd", "#00ffff", "#009a9a"),
"green": ColorSet("#00cd00", "#00ff00", "#009a00"),
}
TETROMINOS = [
2026-04-30 17:58:14 -07:00
Tetromino("aqua", [[1, 1, 1, 1]]),
Tetromino("yellow", [[1, 1], [1, 1]]),
Tetromino("purple", [[1, 1, 1], [0, 1, 0]]),
Tetromino("blue", [[0, 1], [0, 1], [1, 1]]),
Tetromino("orange", [[1, 0], [1, 0], [1, 1]]),
Tetromino("green", [[0, 1, 1], [1, 1, 0]]),
Tetromino("red", [[1, 1, 0], [0, 1, 1]]),
2026-04-30 03:24:12 -07:00
]
2026-04-30 17:58:14 -07:00
ACTION_MAP = Action.make_map(
Action(
pygame.K_z,
Action.Type.ROTATE_LEFT,
False,
"Rotate the current piece left.",
),
Action(
pygame.K_x,
Action.Type.ROTATE_RIGHT,
False,
"Rotate the current piece right.",
),
Action(
pygame.K_s,
Action.Type.SWAP_HOLD,
False,
"Swap the current piece and the held piece.",
),
Action(
pygame.K_DOWN, Action.Type.DROP, True, "Drop the current piece."
),
Action(
pygame.K_LEFT,
Action.Type.MOVE_LEFT,
True,
"Move the current piece left.",
),
Action(
pygame.K_RIGHT,
Action.Type.MOVE_RIGHT,
True,
"Move the current piece right.",
),
)
2026-04-30 03:24:12 -07:00
@staticmethod
def random_tetromino():
return random.choice(Game.TETROMINOS)
def __init__(self):
self.screen = None
self.clock = None
self.running = False
def draw_block(self, x: int, y: int, color):
"""Draw a block at (X, Y). Coordinates are for the top left corner."""
draw.rect(
self.screen,
Game.PALETTE[color].norm,
(x, y, Game.CELL_SIZE.width, Game.CELL_SIZE.height),
)
draw.polygon(
self.screen,
Game.PALETTE[color].light,
[
(x, y),
(x + Game.CELL_SIZE.width, y),
(
x + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE,
y + Game.CELL_BORDER_SIZE,
),
(x + Game.CELL_BORDER_SIZE, y + Game.CELL_BORDER_SIZE),
(
x + Game.CELL_BORDER_SIZE,
y + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE,
),
(x, y + Game.CELL_SIZE.height),
(x, y),
],
)
draw.polygon(
self.screen,
Game.PALETTE[color].dark,
[
(x + Game.CELL_SIZE.width, y),
(
x + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE,
y + Game.CELL_BORDER_SIZE,
),
(
x + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE,
y + Game.CELL_SIZE.height - Game.CELL_BORDER_SIZE,
),
(
x + Game.CELL_BORDER_SIZE,
y + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE,
),
(x, y + Game.CELL_SIZE.height),
(x + Game.CELL_SIZE.width, y + Game.CELL_SIZE.height),
(x + Game.CELL_SIZE.width, y),
],
)
def draw_board_border(self):
bwidth = (Game.BOARD_SIZE.width + 2) * Game.CELL_SIZE.width
bheight = Game.WINDOW_SIZE.height
for x in range(Game.BOARD_SIZE.width + 2):
self.draw_block(x * Game.CELL_SIZE.width, 0, "gray")
self.draw_block(
x * Game.CELL_SIZE.width,
bheight - Game.CELL_SIZE.height,
"gray",
)
for y in range(1, Game.BOARD_SIZE.height + 1):
self.draw_block(0, y * game.CELL_SIZE.height, "gray")
self.draw_block(
bwidth - Game.CELL_SIZE.width,
y * game.CELL_SIZE.height,
"gray",
)
def draw_board_content(self):
for x in range(Game.BOARD_SIZE.width):
for y in range(Game.BOARD_SIZE.height):
if self.board[y][x]:
self.draw_block(
(x + 1) * Game.CELL_SIZE.width,
(y + 1) * Game.CELL_SIZE.height,
self.board[y][x],
)
def draw_current_block(self):
start_x, start_y = self.current_block_pos
for x in range(self.current_block.width):
for y in range(self.current_block.height):
2026-04-30 17:58:14 -07:00
if self.current_block.shape[y][x]:
2026-04-30 03:24:12 -07:00
screen_x = (start_x + x + 1) * Game.CELL_SIZE.width
screen_y = (start_y + y + 1) * Game.CELL_SIZE.height
self.draw_block(
screen_x, screen_y, self.current_block.color
)
2026-04-30 17:58:14 -07:00
def handle_key_event(self, typ, key):
if key not in Game.ACTION_MAP:
return
elif typ == pygame.KEYDOWN:
act = Game.ACTION_MAP[key]
self.pending_actions.append(act.type)
self.key_states[key] = True
elif typ == pygame.KEYUP:
self.key_states[key] = False
2026-04-30 03:24:12 -07:00
def handle_events(self):
2026-04-30 17:58:14 -07:00
# process already pushed keys
for key, state in self.key_states.items():
if (
state
and key in self.ACTION_MAP
and self.ACTION_MAP[key].repeat
):
self.pending_actions.append(self.ACTION_MAP[key].type)
# now process new events
2026-04-30 03:24:12 -07:00
for event in pygame.event.get():
2026-04-30 17:58:14 -07:00
match event.type:
case pygame.QUIT:
self.running = False
case pygame.KEYDOWN | pygame.KEYUP:
self.handle_key_event(event.type, event.key)
def swap_with_hold(self):
pass
def intersects_board(self, x: int, y: int, piece: Tetromino):
pw, ph = piece.width, piece.height
if x < 0 or y < 0:
return True
elif x + pw > Game.BOARD_SIZE.width or y + ph > Game.BOARD_SIZE.height:
return True
shape = piece.shape
for cy, row in enumerate(shape):
for cx, cell in enumerate(row):
if cell and self.board[y + cy][x + cx]:
return True
return False
def rotate_current_piece(self, times: int):
new_piece = self.current_block.rotated(times)
cx, cy = self.current_block_pos
for dx in [0, -1]:
for dy in [1, 0, -1]:
np = Cell(cx + dx, cy + dy)
if not self.intersects_board(*np, new_piece):
self.current_block = new_piece
self.current_block_pos = np
return
def move_current_piece(self, dir: Direction):
dx = dir * Game.MOVE_SPEED / Game.FRAMERATE
self.subcell_move += dx
move_cell = int(self.subcell_move)
self.subcell_move -= move_cell
if move_cell:
new_pos = Cell(
self.current_block_pos.x + move_cell, self.current_block_pos.y
)
if not self.intersects_board(*new_pos, self.current_block):
self.current_block_pos = new_pos
def process_actions(self):
if not (
{Action.Type.MOVE_LEFT, Action.Type.MOVE_RIGHT}
& set(self.pending_actions)
):
self.subcell_move = 0
while self.pending_actions:
match self.pending_actions.pop(0):
case Action.Type.ROTATE_LEFT:
self.rotate_current_piece(Direction.LEFT)
case Action.Type.ROTATE_RIGHT:
self.rotate_current_piece(Direction.RIGHT)
case Action.Type.SWAP_HOLD:
self.swap_with_hold()
case Action.Type.DROP:
self.doing_drop = True
case Action.Type.MOVE_LEFT:
self.move_current_piece(Direction.LEFT)
case Action.Type.MOVE_RIGHT:
self.move_current_piece(Direction.RIGHT)
def place_piece(self, x: int, y: int, piece: Tetromino):
for dy, row in enumerate(piece.shape):
for dx, cell in enumerate(row):
if cell:
self.board[y + dy][x + dx] = piece.color
def advance_piece(self):
speed = (
Game.FAST_DROP_SPEED if self.doing_drop else Game.NORMAL_DROP_SPEED
)
self.subcell_drop += speed / Game.FRAMERATE
move_cell = int(self.subcell_drop)
self.subcell_drop -= move_cell
cp = self.current_block_pos
if not self.intersects_board(
cp.x, cp.y + move_cell, self.current_block
):
self.current_block_pos = Cell(cp.x, cp.y + move_cell)
else:
for dy in range(move_cell, -1, -1):
if not self.intersects_board(
cp.x, cp.y + dy, self.current_block
):
self.place_piece(cp.x, cp.y + dy, self.current_block)
self.current_block = None
return
# TODO Game over
print("Game over!")
@staticmethod
def generate_clear_cells():
bw = Game.BOARD_SIZE.width
if bw % 2 == 0:
return zip(range(bw // 2 - 1, -1, -1), range(bw // 2, bw))
else:
return zip(range(bw // 2, -1, -1), range(bw // 2, bw))
def maybe_clear_rows(self):
def do_single_clear(y: int):
row = self.board[y]
for x1, x2 in Game.generate_clear_cells():
if row[x1]:
row[x1] = None
row[x2] = None
break
def drop_rows_above_for_clear(y: int):
self.board[: y + 1] = self.board[:y]
self.board.insert(0, [None] * Game.BOARD_SIZE.width)
if not self.clearing_rows:
for y, row in enumerate(self.board):
if all(row):
self.clearing_rows.add(y)
elif not any(self.board[next(iter(self.clearing_rows))]):
for y in self.clearing_rows:
drop_rows_above_for_clear(y)
self.clearing_rows = set()
self.clearing_frames = self.CLEAR_SPEED
else:
if self.clearing_frames == self.CLEAR_SPEED:
self.clearing_frames = 0
for y in self.clearing_rows:
do_single_clear(y)
else:
self.clearing_frames += 1
2026-04-30 03:24:12 -07:00
def loop(self):
self.running = True
while self.running:
2026-04-30 17:58:14 -07:00
self.doing_drop = False
2026-04-30 03:24:12 -07:00
self.handle_events()
2026-04-30 17:58:14 -07:00
self.maybe_clear_rows()
if not self.clearing_rows:
self.process_actions()
self.advance_piece()
else:
# ignore input while clearing
self.pending_actions = []
if not self.current_block:
self.generate_new_block()
2026-04-30 03:24:12 -07:00
self.screen.fill("black")
2026-04-30 17:58:14 -07:00
if not self.clearing_rows:
self.draw_current_block()
2026-04-30 03:24:12 -07:00
self.draw_board_content()
2026-04-30 17:58:14 -07:00
self.draw_board_border()
2026-04-30 03:24:12 -07:00
pygame.display.flip()
self.clock.tick(Game.FRAMERATE)
def generate_new_block(self):
self.current_block = Game.random_tetromino()
# top left corner
self.current_block_pos = Cell(
self.BOARD_SIZE.width // 2
- math.ceil(self.current_block.width / 2),
0,
)
def init(self):
pygame.init()
2026-04-30 17:58:14 -07:00
self.screen = pygame.display.set_mode(Game.WINDOW_SIZE, pygame.SCALED)
2026-04-30 03:24:12 -07:00
pygame.display.set_caption("Tetris")
self.clock = pygame.time.Clock()
2026-04-30 17:58:14 -07:00
pygame.key.stop_text_input()
self.key_states = defaultdict(lambda: False)
self.pending_actions = []
2026-04-30 03:24:12 -07:00
# Game state
2026-04-30 17:58:14 -07:00
self.board = make_matrix(*Game.BOARD_SIZE)
2026-04-30 03:24:12 -07:00
self.generate_new_block()
2026-04-30 17:58:14 -07:00
self.held_piece = None
self.subcell_move = 0
self.subcell_drop = 0
self.clearing_rows = set()
# immediately clear the first block
self.clearing_frames = Game.CLEAR_SPEED
2026-04-30 03:24:12 -07:00
def run(self):
self.init()
self.loop()
if __name__ == "__main__":
game = Game()
game.run()