Files
tetris/tetris.py
T

1417 lines
38 KiB
Python

import pygame
import pygame.draw as draw
from collections import namedtuple, defaultdict
from typing import NamedTuple, Any, Iterable, Optional, Callable
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]
@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)
def __eq__(self, other):
return id(self) == id(other)
def __hash__(self):
return id(self)
class HashableDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __eq__(self, other):
return id(self) == id(other)
def __hash__(self):
return id(self)
class Action(NamedTuple):
class Type(Enum):
# In Game
ROTATE_LEFT = auto()
ROTATE_RIGHT = auto()
DROP = auto()
SWAP_HOLD = auto()
MOVE_LEFT = auto()
MOVE_RIGHT = auto()
OPEN_HELP = auto()
# Game over (also in game)
RESTART = auto()
# Help
CLOSE_HELP = auto()
key: int
type: Type
repeat: bool
desc: str
@staticmethod
def make_map(*acts):
return HashableDict({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()
total_width = 0
total_height = 0
last_width = 0
last_height = 0
for char in text:
if char == "\n":
total_width = max(total_width, last_width)
if last_width == 0:
# empty line
last_height = self.glyphs[" "].height * self.scale
last_width = 0
total_height += last_height + self.row_kern
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
if total_width == 0:
# empty line
last_height = self.glyphs[" "].height * self.scale
total_height += last_height
return Size(total_width, total_height)
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":
if x == initial_x:
# empty row
max_height = self.glyphs[" "].height * self.scale
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 LazyVariable:
def __init__(self, thunk: Callable[[], Any]):
self.thunk = thunk
self.need_eval = True
self.value = None
def __get__(self, instance: Any, owner: Optional[type] = None):
if self.need_eval:
self.value = self.thunk()
self.need_eval = False
return self.value
def __set__(self, instance: Any, value: Any):
self.value = value
self.need_eval = False
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 and held pieces.",
),
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.",
),
Action(
pygame.K_r,
Action.Type.RESTART,
False,
"Start a new game.",
),
Action(
pygame.K_h,
Action.Type.OPEN_HELP,
False,
"Toggle the help menu.",
),
)
GAME_OVER_ACTION_MAP = Action.make_map(
Action(pygame.K_r, Action.Type.RESTART, False, "Start a new game.")
)
HELP_ACTION_MAP = Action.make_map(
Action(
pygame.K_ESCAPE,
Action.Type.CLOSE_HELP,
False,
"Close the help menu.",
),
Action(
pygame.K_h,
Action.Type.CLOSE_HELP,
False,
"Close the help menu.",
),
)
# The actual font data is long, so I put it at the end
FONT = LazyVariable(lambda: Font(6, 3, 3, GLYPH_DATA))
SMALL_FONT = LazyVariable(lambda: Game.FONT.scaled(4, 2, 2))
TINY_FONT = LazyVariable(lambda: Game.FONT.scaled(2, 2, 2))
CONTROL_DISPLAY_BORDER = 5
INFO_BOX_BORDER_SIZE = 5
HELP_MENU_INSET = 10
CONTROLS_START_X = (BOARD_SIZE.width + 2) * CELL_SIZE.width
@staticmethod
@cache
def make_help_string(am: dict[int, Action]):
key_names = [pygame.key.name(k) for k in am.keys()]
max_key_len = max(map(len, key_names))
out = ""
for act in am.values():
if out:
out += "\n"
out += f"{pygame.key.name(act.key).rjust(max_key_len)}: {act.desc}"
return out
def __init__(self):
self._game_over = False
self.high_score = 0
self._score = 0
@property
def game_over(self) -> bool:
return self._game_over
@game_over.setter
def game_over(self, newval: bool):
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
@property
def score(self) -> int:
return self._score
@score.setter
def score(self, newval: int):
self._score = newval
if newval > self.high_score:
self.high_score = newval
def random_tetromino(self):
if not self.cur_random_peice_seq:
self.cur_random_peice_seq = list(Game.TETROMINOS)
random.shuffle(self.cur_random_peice_seq)
return self.cur_random_peice_seq.pop(0)
def draw_block(self, x: int, y: int, color, target=None):
"""Draw a block at (X, Y). Coordinates are for the top left corner."""
if target is None:
target = self.screen
draw.rect(
target,
Game.PALETTE[color].norm,
(x, y, Game.CELL_SIZE.width, Game.CELL_SIZE.height),
)
draw.polygon(
target,
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(
target,
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
used_next = False
if not to_swap:
to_swap = self.next_piece
used_next = True
if not self.intersects_board(*self.current_block_pos, to_swap):
self.held_piece = self.current_block
self.current_block = to_swap
if used_next:
self.next_piece = self.random_tetromino()
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
# give the user some leeway to splin many times
self.subcell_drop = 0
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
# give the user some leeway to move in tight quarters
self.subcell_drop = 0
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
if Action.Type.DROP not in self.pending_actions:
self.stop_drop = False
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 if not self.stop_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)
case Action.Type.RESTART:
self.start_new_game()
self.pending_actions = []
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
# prevent user from accidentally dropping next piece too
self.stop_drop = True
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.pop(y)
# this also restores indices for future clears
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))]):
# Order matters here
for y in sorted(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_and_instructions(self):
text = (
"Press <h> \nfor help.\n"
"\n"
f"Score: {self.score:05}\n"
f" HS: {self.high_score:05}"
)
exts = self.TINY_FONT.compute_extents(text)
x = Game.CONTROLS_START_X + Game.CONTROL_DISPLAY_BORDER
y = (
self.screen.get_size()[1]
- Game.CONTROL_DISPLAY_BORDER
- exts.height
)
self.TINY_FONT.render_text(text, self.screen, x, y, "white")
@cache
def get_scaled_piece(
self, piece: Tetromino, scale: float
) -> pygame.surface.Surface:
ow = piece.width * Game.CELL_SIZE.width
oh = piece.height * Game.CELL_SIZE.height
os_surf = pygame.surface.Surface((ow, oh), pygame.SRCALPHA)
for y, row in enumerate(piece.shape):
for x, cell in enumerate(row):
if cell:
self.draw_block(
x * Game.CELL_SIZE.width,
y * Game.CELL_SIZE.height,
piece.color,
os_surf,
)
sw, sh = int(ow * scale), int(oh * scale)
return pygame.transform.scale(os_surf, (sw, sh))
def draw_boxed_piece(
self,
text: str,
piece: Tetromino | None,
x: int,
y: int,
box_size: int,
) -> Size:
"""Return the overall bounding box."""
text_ext = Game.TINY_FONT.compute_extents(text)
Game.TINY_FONT.center_text(
text,
self.screen,
x + box_size // 2,
y + text_ext.height // 2,
"white",
)
pygame.draw.rect(
self.screen,
Game.PALETTE["gray"].norm,
(
x,
y + text_ext.height + Game.TINY_FONT.row_kern,
box_size,
box_size,
),
width=Game.INFO_BOX_BORDER_SIZE,
)
if piece:
piece_surf = self.get_scaled_piece(piece, 0.75)
pw, ph = piece_surf.get_size()
self.screen.blit(
piece_surf,
(
x + box_size // 2 - pw // 2,
y
+ text_ext.height
+ Game.TINY_FONT.row_kern
+ box_size // 2
- ph // 2,
),
)
return Size(
max(box_size, text_ext.width),
text_ext.height + box_size + Game.TINY_FONT.row_kern,
)
def draw_held_and_next_piece(self):
width = int(Game.CONTROLS_WIDTH * 0.8)
y = Game.CONTROL_DISPLAY_BORDER
x = Game.CONTROLS_START_X + Game.CONTROLS_WIDTH // 2 - width // 2
exts = self.draw_boxed_piece("Hold", self.held_piece, x, y, width)
self.draw_boxed_piece(
"Next",
self.next_piece,
x,
y + exts.height + 2 * Game.CONTROL_DISPLAY_BORDER,
width,
)
def process_and_draw_help_overlay(self):
self.draw_gray_overlay()
hs = Game.make_help_string(Game.GAME_ACTION_MAP)
exts = Game.TINY_FONT.compute_extents(hs)
sw, sh = self.screen.get_size()
tot_border = Game.INFO_BOX_BORDER_SIZE + Game.HELP_MENU_INSET
box_x = sw // 2 - exts.width // 2 - tot_border
box_y = sh // 2 - exts.height // 2 - tot_border
box_w = exts.width + 2 * tot_border
box_h = exts.height + 2 * tot_border
pygame.draw.rect(self.screen, "black", (box_x, box_y, box_w, box_h))
pygame.draw.rect(
self.screen,
Game.PALETTE["gray"].norm,
(box_x, box_y, box_w, box_h),
width=Game.INFO_BOX_BORDER_SIZE,
)
Game.TINY_FONT.center_text(hs, self.screen, sw // 2, sh // 2, "white")
if Action.Type.CLOSE_HELP in self.pending_actions:
self.pending_actions = []
self.action_map = Game.GAME_ACTION_MAP
self.help_mode = False
def game_loop(self):
self.doing_drop = False
self.maybe_clear_rows()
if Action.Type.OPEN_HELP in self.pending_actions:
# do no matter what
self.help_mode = True
self.action_map = Game.HELP_ACTION_MAP
elif 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.swap_in_next_piece()
self.screen.fill("black")
if not self.clearing_rows:
self.draw_current_block()
self.draw_board_content()
self.draw_board_border()
self.draw_held_and_next_piece()
self.draw_score_and_instructions()
if self.help_mode:
self.process_and_draw_help_overlay()
def start_new_game(self):
self.cur_random_peice_seq = None
self.clear_board()
self.next_piece = None
self.swap_in_next_piece(True)
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_held_and_next_piece()
self.draw_score_and_instructions()
self.draw_gray_overlay()
sw, sh = self.screen.get_size()
exts = self.FONT.compute_extents("Game Over!")
score_text = f"Score: {self.score:05}"
exts2 = self.SMALL_FONT.compute_extents(score_text)
exts3 = self.SMALL_FONT.compute_extents("Press <r> to retry.")
text_width = max(exts.width, exts2.width, exts3.width)
text_height = (
exts.height + exts2.height + exts3.height + 2 * self.FONT.row_kern
)
tot_border = Game.INFO_BOX_BORDER_SIZE + Game.HELP_MENU_INSET
box_x = sw // 2 - text_width // 2 - tot_border
box_y = sh // 2 - text_height // 2 - tot_border
box_w = text_width + 2 * tot_border
box_h = text_height + 2 * tot_border
pygame.draw.rect(self.screen, "black", (box_x, box_y, box_w, box_h))
pygame.draw.rect(
self.screen,
Game.PALETTE["gray"].norm,
(box_x, box_y, box_w, box_h),
width=Game.INFO_BOX_BORDER_SIZE,
)
self.FONT.render_text(
"Game Over!",
self.screen,
sw // 2 - exts.width // 2,
sh // 2 - text_height // 2,
"white",
)
self.SMALL_FONT.render_text(
score_text,
self.screen,
sw // 2 - exts2.width // 2,
sh // 2 - text_height // 2 + exts.height + self.FONT.row_kern,
"white",
)
self.SMALL_FONT.render_text(
"Press <r> to retry.",
self.screen,
sw // 2 - exts3.width // 2,
sh // 2
- text_height // 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 swap_in_next_piece(self, force: bool = False):
if not self.next_piece or force:
self.next_piece = self.random_tetromino()
self.current_block = self.random_tetromino()
else:
self.current_block = self.next_piece
self.next_piece = self.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)
# not in __init__ to facilitate debugging in ipython
def init(self):
pygame.init()
self.screen = pygame.display.set_mode(
Game.WINDOW_SIZE, pygame.SCALED | pygame.HWACCEL
)
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.stop_drop = False
self.help_mode = False
self.high_score = 0
self.start_new_game()
def run(self):
self.init()
self.loop()
GLYPH_DATA = [
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(
">",
[
"** ",
" ** ",
" *",
" ** ",
"** ",
],
),
]
if __name__ == "__main__":
game = Game()
game.run()