Files
tetris/tetris.py
T

1131 lines
32 KiB
Python

import pygame
import pygame.draw as draw
from collections import namedtuple, defaultdict
from typing import NamedTuple, Any, Iterable, Optional
from functools import cache
import random
import math
from enum import Enum, auto
Cell = namedtuple("Cell", ["x", "y"])
Size = namedtuple("Size", ["width", "height"])
ColorSet = namedtuple("ColorSet", ["norm", "light", "dark"])
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)]
def flip(mat: list[list]) -> list[list]:
return list(reversed(mat))
def transpose(mat: list[list]) -> list[list]:
if not mat:
return []
out = make_matrix(len(mat), len(mat[0]))
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):
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):
# In Game
ROTATE_LEFT = auto()
ROTATE_RIGHT = auto()
DROP = auto()
SWAP_HOLD = auto()
MOVE_LEFT = auto()
MOVE_RIGHT = auto()
# Game over
RESTART = auto()
@staticmethod
def make_map(*acts):
return {act.key: act for act in acts}
class Font:
class Glyph(NamedTuple):
char: str
bitmap: list[str]
@property
def width(self):
return len(self.bitmap[0])
@property
def height(self):
return len(self.bitmap)
@property
def size(self) -> Size:
return Size(self.width, self.height)
def __init__(
self,
scale: int,
kern: int,
row_kern: int,
glyphs: Iterable[Glyph] | dict[str, Glyph],
):
self.scale = scale
self.kern = kern
self.row_kern = row_kern
if isinstance(glyphs, dict):
self.glyphs: dict[str, Font.Glyph] = dict(glyphs)
else:
self.glyphs: dict[str, Font.Glyph] = {g.char: g for g in glyphs}
def scaled(
self,
new_scale: int,
new_kern: Optional[int],
new_row_kern: Optional[int],
):
return Font(
new_scale,
self.kern if new_kern is None else new_kern,
self.row_kern if new_row_kern is None else new_row_kern,
self.glyphs,
)
@cache
def compute_extents(self, text: str) -> Size:
text = text.lower()
multi_row = False
total_width = 0
total_height = 0
last_width = 0
last_height = 0
for char in text:
if char == "\n":
multi_row = True
total_width = max(total_width, last_width)
last_width = 0
total_height += last_height
last_height = 0
else:
glyph = self.glyphs[char]
last_width += glyph.width * self.scale + self.kern
last_height = max(last_height, glyph.height * self.scale)
total_width = max(last_width, total_width) - self.kern
total_height += last_height
return Size(total_width, total_height + (multi_row * self.row_kern))
def render_text(
self, text: str, dest: pygame.surface.Surface, x: int, y: int, color
):
text = text.lower()
initial_x = x
max_height = 0
for char in text:
if char == "\n":
y += max_height + self.row_kern
max_height = 0
x = initial_x
else:
glyph = self.glyphs[char]
dest.blit(self.render_glyph(char, color), (x, y))
x += glyph.width * self.scale + self.kern
max_height = max(max_height, glyph.height * self.scale)
def center_text(
self, text: str, dest: pygame.surface.Surface, x: int, y: int, color
):
text = text.lower()
exts = self.compute_extents(text)
self.render_text(
text, dest, x - (exts.width // 2), y - (exts.height // 2), color
)
@cache
def render_glyph(self, char: str, color) -> pygame.surface.Surface:
char = char.lower()
glyph = self.glyphs[char]
size = tuple(map(lambda d: d * self.scale, glyph.size))
surf = pygame.surface.Surface(size, pygame.SRCALPHA)
for y, row in enumerate(glyph.bitmap):
for x, cell in enumerate(row):
if cell != " ":
pygame.draw.rect(
surf,
color,
(
x * self.scale,
y * self.scale,
self.scale,
self.scale,
),
)
return surf
class Game:
PLACE_POINTS = 10
CLEAR_POINTS = 100
MOVE_SPEED = 20 # cells per second
NORMAL_DROP_SPEED = 3 # cells per second
FAST_DROP_SPEED = 30 # cells per second
CLEAR_SPEED = 5 # frames per block
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 = [
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]]),
]
GAME_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.",
),
)
GAME_OVER_ACTION_MAP = Action.make_map(
Action(pygame.K_r, Action.Type.RESTART, False, "Start a new game.")
)
FONT = Font(
6,
3,
3,
[
Font.Glyph(
" ",
[
" ",
" ",
" ",
" ",
" ",
],
),
Font.Glyph(
"a",
[
"*****",
"* *",
"*****",
"* *",
"* *",
],
),
Font.Glyph(
"b",
[
"**** ",
"* *",
"*****",
"* *",
"**** ",
],
),
Font.Glyph(
"c",
[
"*****",
"* *",
"* ",
"* *",
"*****",
],
),
Font.Glyph(
"d",
[
"**** ",
"* *",
"* *",
"* *",
"**** ",
],
),
Font.Glyph(
"e",
[
"*****",
"* ",
"**** ",
"* ",
"*****",
],
),
Font.Glyph(
"f",
[
"*****",
"* ",
"**** ",
"* ",
"* ",
],
),
Font.Glyph(
"g",
[
"*****",
"* ",
"* **",
"* *",
"*****",
],
),
Font.Glyph(
"h",
[
"* *",
"* *",
"*****",
"* *",
"* *",
],
),
Font.Glyph(
"i",
[
"*****",
" * ",
" * ",
" * ",
"*****",
],
),
Font.Glyph(
"j",
[
"*****",
" * ",
" * ",
"* * ",
" ** ",
],
),
Font.Glyph(
"k",
[
"* *",
"* * ",
"*** ",
"* * ",
"* *",
],
),
Font.Glyph(
"l",
[
"* ",
"* ",
"* ",
"* ",
"*****",
],
),
Font.Glyph(
"m",
[
"* *",
"** **",
"* * *",
"* *",
"* *",
],
),
Font.Glyph(
"n",
[
"* *",
"** *",
"* * *",
"* **",
"* *",
],
),
Font.Glyph(
"o",
[
" *** ",
"* *",
"* *",
"* *",
" *** ",
],
),
Font.Glyph(
"p",
[
"**** ",
"* *",
"**** ",
"* ",
"* ",
],
),
Font.Glyph(
"q",
[
" *** ",
"* *",
"* * *",
"* * ",
" ** *",
],
),
Font.Glyph(
"r",
[
"**** ",
"* *",
"**** ",
"* * ",
"* *",
],
),
Font.Glyph(
"s",
[
" ****",
"* ",
" *** ",
" *",
"**** ",
],
),
Font.Glyph(
"t",
[
"*****",
" * ",
" * ",
" * ",
" * ",
],
),
Font.Glyph(
"u",
[
"* *",
"* *",
"* *",
"* *",
" *** ",
],
),
Font.Glyph(
"v",
[
"* *",
"* *",
"* *",
" * * ",
" * ",
],
),
Font.Glyph(
"w",
[
"* *",
"* *",
"* * *",
"** **",
"* *",
],
),
Font.Glyph(
"x",
[
"* *",
" * * ",
" * ",
" * * ",
"* *",
],
),
Font.Glyph(
"y",
[
"* *",
" * * ",
" * ",
" * ",
" * ",
],
),
Font.Glyph(
"z",
[
"*****",
" * ",
" * ",
" * ",
"*****",
],
),
Font.Glyph(
"0",
[
" *** ",
"* *",
"* * *",
"* *",
" *** ",
],
),
Font.Glyph(
"1",
[
" ** ",
"* * ",
" * ",
" * ",
"*****",
],
),
Font.Glyph(
"2",
[
" *** ",
"* *",
" * ",
" * ",
" ****",
],
),
Font.Glyph(
"3",
[
" *** ",
"* *",
" ***",
"* *",
" *** ",
],
),
Font.Glyph(
"4",
[
"* * ",
"* * ",
"*****",
" * ",
" * ",
],
),
Font.Glyph(
"5",
[
"*****",
"* ",
"**** ",
" *",
"**** ",
],
),
Font.Glyph(
"6",
[
" ****",
"* ",
"**** ",
"* *",
" *** ",
],
),
Font.Glyph(
"7",
[
"*****",
" * ",
" * ",
" * ",
"* ",
],
),
Font.Glyph(
"8",
[
" *** ",
"* *",
" *** ",
"* *",
" *** ",
],
),
Font.Glyph(
"9",
[
" *** ",
"* *",
" ****",
" *",
"**** ",
],
),
Font.Glyph(
".",
[
" ",
" ",
" ",
" ",
" *",
],
),
Font.Glyph(
"!",
[
" *",
" *",
" *",
" ",
" *",
],
),
Font.Glyph(
":",
[
" ",
" *",
" ",
" *",
" ",
],
),
Font.Glyph(
"<",
[
" **",
" ** ",
"* ",
" ** ",
" **",
],
),
Font.Glyph(
">",
[
"** ",
" ** ",
" *",
" ** ",
"** ",
],
),
],
)
SMALL_FONT = FONT.scaled(4, 2, 2)
TINY_FONT = FONT.scaled(2, 1, 1)
SCORE_DISPLAY_BORDER = 5
CONTROLS_START_X = (BOARD_SIZE.width + 2) * CELL_SIZE.width
@staticmethod
def random_tetromino():
return random.choice(Game.TETROMINOS)
def __init__(self):
self._game_over = False
@property
def game_over(self):
return self._game_over
@game_over.setter
def game_over(self, newval):
if newval != self._game_over:
self.pending_actions = []
self._game_over = newval
if self._game_over:
self.action_map = Game.GAME_OVER_ACTION_MAP
else:
self.action_map = Game.GAME_ACTION_MAP
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):
if self.current_block.shape[y][x]:
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
)
def frame_time(self) -> float:
ticks = self.clock.get_time()
if not ticks:
return 1 / Game.FRAMERATE
else:
return ticks / 1000
def handle_key_event(self, typ, key):
if typ == pygame.KEYDOWN:
if key in self.action_map:
self.pending_actions.append(self.action_map[key].type)
self.key_states[key] = True
elif typ == pygame.KEYUP:
self.key_states[key] = False
def handle_events(self):
# 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
for event in pygame.event.get():
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):
to_swap = self.held_piece or Game.random_tetromino()
if not self.intersects_board(*self.current_block_pos, to_swap):
self.held_piece = self.current_block
self.current_block = to_swap
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 * self.frame_time()
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_game_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):
self.score += Game.PLACE_POINTS
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 * self.frame_time()
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
self.game_over = True
@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.score += self.CLEAR_POINTS
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
def draw_score(self):
text = f"Score: {self.score:05}"
exts = self.TINY_FONT.compute_extents(text)
x = Game.CONTROLS_START_X + Game.SCORE_DISPLAY_BORDER
y = self.screen.get_size()[1] - Game.SCORE_DISPLAY_BORDER - exts.height
self.TINY_FONT.render_text(text, self.screen, x, y, "white")
def game_loop(self):
self.doing_drop = False
self.maybe_clear_rows()
if not self.clearing_rows:
self.process_game_actions()
self.advance_piece()
else:
# ignore input while clearing
self.pending_actions = []
if not self.current_block:
self.generate_new_block()
self.screen.fill("black")
if not self.clearing_rows:
self.draw_current_block()
self.draw_board_content()
self.draw_board_border()
self.draw_score()
def start_new_game(self):
self.clear_board()
self.generate_new_block()
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
self.score = 0
self.held_piece = None
self.game_over = False
def handle_game_over_actions(self):
while self.pending_actions:
act = self.pending_actions.pop(0)
if act == Action.Type.RESTART:
self.start_new_game()
def make_gray_overlay(self):
s = pygame.surface.Surface(self.screen.get_size(), pygame.SRCALPHA)
pygame.draw.rect(s, "#44444466", (0, 0, *s.get_size()))
return s
def draw_gray_overlay(self):
self.screen.blit(self.gray_overlay, (0, 0))
def game_over_loop(self):
self.handle_game_over_actions()
if not self.game_over:
return
self.screen.fill("black")
self.draw_board_content()
self.draw_current_block()
self.draw_board_border()
self.draw_score()
self.draw_gray_overlay()
ss = self.screen.get_size()
exts = self.FONT.compute_extents("Game Over!")
self.FONT.center_text(
"Game Over!", self.screen, ss[0] // 2, ss[1] // 2, "white"
)
score_text = f"Score: {self.score:05}"
self.SMALL_FONT.center_text(
score_text,
self.screen,
ss[0] // 2,
ss[1] // 2 + exts.height + self.FONT.row_kern,
"white",
)
exts2 = self.SMALL_FONT.compute_extents(score_text)
self.SMALL_FONT.center_text(
"Press <r> to retry.",
self.screen,
ss[0] // 2,
ss[1] // 2 + exts.height + exts2.height + 2 * self.FONT.row_kern,
"white",
)
def loop(self):
self.running = True
while self.running:
self.handle_events()
if self.game_over:
self.game_over_loop()
else:
self.game_loop()
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 clear_board(self):
self.board = make_matrix(*Game.BOARD_SIZE)
def init(self):
pygame.init()
self.screen = pygame.display.set_mode(Game.WINDOW_SIZE, pygame.SCALED)
pygame.display.set_caption("Tetris")
self.clock = pygame.time.Clock()
self.gray_overlay = self.make_gray_overlay()
pygame.key.stop_text_input()
self.key_states = defaultdict(lambda: False)
self.pending_actions = []
self.start_new_game()
def run(self):
self.init()
self.loop()
if __name__ == "__main__":
game = Game()
game.run()