Files
tetris/tetris.py
T

1627 lines
45 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, lru_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 height(self):
return len(self.shape)
@property
def width(self):
return len(self.shape[0])
@property
@lru_cache(maxsize=16)
def x_adj(self):
def clz(arr: list):
for i, c in enumerate(arr):
if c:
return i
return len(arr)
return min(map(clz, self.shape))
@property
@lru_cache(maxsize=16)
def y_adj(self):
for y, row in enumerate(self.shape):
for x, cell in enumerate(row):
if cell:
return y
return self.height
@property
@lru_cache(maxsize=16)
def bounding_box_width(self):
def ctz(arr: list):
for i, c in enumerate(reversed(arr)):
if c:
return i
return len(arr)
return max(map(lambda r: len(r) - ctz(r), self.shape))
@property
@lru_cache(maxsize=16)
def bounding_box_height(self):
for y, row in reversed(list(enumerate(self.shape))):
for x, cell in enumerate(row):
if cell:
return y + 1
return 0
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()
SOFT_DROP = auto()
HARD_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,
)
@lru_cache(maxsize=16)
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:
# Points
DROP_POINTS = 1
HARD_DROP_POINTS = 2
POINTS_PER_LINE = [100, 300, 500, 800]
LINES_PER_LEVEL = 10
MOVE_SPEED = 20 # cells per second
MOVE_INITIAL_SLOWDOWN = 3.0 # cells
DROP_LEVEL_SCALE = 0.025
# These are *= 1 + (DROP_LEVEL_SCALE * (LEVEL - 1))
NORMAL_DROP_SPEED = 3 # cells per second
FAST_DROP_SPEED = 30 # cells per second
MOVED_DROP_SPEED = 1 # cells per second
DROP_CLEAR_SCALE = 0.025
# This is *= 1 - (DROP_CLEAR_SCALE * (LEVEL - 1))
CLEAR_SPEED = 15 # 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", [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]]
),
Tetromino("yellow", [[1, 1], [1, 1]]),
Tetromino("purple", [[0, 0, 0], [1, 1, 1], [0, 1, 0]]),
Tetromino("blue", [[0, 0, 1], [0, 0, 1], [0, 1, 1]]),
Tetromino("orange", [[1, 0, 0], [1, 0, 0], [1, 1, 0]]),
Tetromino("green", [[0, 1, 1], [1, 1, 0], [0, 0, 0]]),
Tetromino("red", [[1, 1, 0], [0, 1, 1], [0, 0, 0]]),
]
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_c,
Action.Type.SWAP_HOLD,
False,
"Swap the current and held pieces.",
),
Action(
pygame.K_DOWN,
Action.Type.SOFT_DROP,
True,
"Soft drop the current piece.",
),
Action(
pygame.K_SPACE,
Action.Type.HARD_DROP,
True,
"Hard 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/pause.",
),
)
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
@lru_cache(maxsize=1)
def make_action_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 = "Keys:"
for act in am.values():
out += (
f"\n{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
@staticmethod
@lru_cache(maxsize=1)
def make_score_help_string():
return (
"Points:\n"
"10 lines clear to level up.\n"
f"Soft drop: {Game.DROP_POINTS} * dist. * level\n"
f"Hard drop: {Game.HARD_DROP_POINTS} * dist. * level\n"
f" 1 line: {Game.POINTS_PER_LINE[0]} * level\n"
f" 2 lines: {Game.POINTS_PER_LINE[1]} * level\n"
f" 3 lines: {Game.POINTS_PER_LINE[2]} * level\n"
f" 4 lines: {Game.POINTS_PER_LINE[3]} * level\n"
)
@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):
if self.did_swap:
return
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.move_piece_back_to_top()
self.did_swap = True
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.bounding_box_width, piece.bounding_box_height
if x + piece.x_adj < 0 or y + piece.y_adj < 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):
# some shapes jut out of the board, prevent that from crashing
try:
if cell and self.board[y + cy][x + cx]:
return True
except IndexError:
# a cell was out of bounds of the board, this is a hit!
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, -1]:
for dy in [0, 1, -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
self.did_user_move_or_spin = True
return
def move_current_piece(self, dir: Direction):
if self.was_move_down != dir:
self.subcell_move = 0
move_cell = dir
self.was_move_down = dir
self.inhibit_next_move = Game.MOVE_INITIAL_SLOWDOWN
else:
dx = dir * Game.MOVE_SPEED * self.frame_time()
self.subcell_move += dx
if self.inhibit_next_move > 0.0:
oi = self.inhibit_next_move
self.inhibit_next_move -= abs(self.subcell_move)
if self.inhibit_next_move <= 0.0:
self.inhibit_next_move = 0.0
self.subcell_move -= oi
else:
self.subcell_move = 0
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.did_user_move_or_spin = True
self.current_block_pos = new_pos
# test if the user is moving to a valid cell and don't place if they
# are
else:
move_cell = int(math.copysign(1, self.subcell_move))
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.did_user_move_or_spin = True
def process_game_actions(self):
move_set = {Action.Type.MOVE_LEFT, Action.Type.MOVE_RIGHT} & set(
self.pending_actions
)
move_both = len(move_set) == 2
if not move_set:
self.was_move_down = None
self.inhibit_next_move = 0.0
self.subcell_move = 0
if not (
{Action.Type.SOFT_DROP, Action.Type.HARD_DROP}
& set(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.SOFT_DROP if not self.stop_drop:
self.soft_drop = True
case Action.Type.HARD_DROP if not self.stop_drop:
self.hard_drop = True
case Action.Type.MOVE_LEFT if not move_both:
self.move_current_piece(Direction.LEFT)
self.was_move_down = Direction.LEFT
case Action.Type.MOVE_RIGHT if not move_both:
self.move_current_piece(Direction.RIGHT)
self.was_move_down = Direction.RIGHT
case Action.Type.RESTART:
self.start_new_game()
self.pending_actions = []
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
# prevent user from accidentally dropping next piece too
self.stop_drop = True
self.subcell_drop = 0
def cells_for_hard_drop(self):
cx, cy = self.current_block_pos
dy = 0
while not self.intersects_board(cx, cy + dy, self.current_block):
dy += 1
return dy
def advance_piece(self):
speed = Game.NORMAL_DROP_SPEED
if self.did_user_move_or_spin:
speed = Game.MOVED_DROP_SPEED
elif self.soft_drop:
speed = Game.FAST_DROP_SPEED
speed *= 1 + (Game.DROP_CLEAR_SCALE * (self.level - 1))
self.subcell_drop += speed * self.frame_time()
move_cell = int(self.subcell_drop)
self.subcell_drop -= move_cell
if self.hard_drop:
move_cell = self.cells_for_hard_drop()
if not move_cell:
return
cp = self.current_block_pos
if not self.intersects_board(
cp.x, cp.y + move_cell, self.current_block
):
self.score += move_cell * Game.DROP_POINTS * self.level
self.current_block_pos = Cell(cp.x, cp.y + move_cell)
# don't place the piece if the user moved it
elif not self.did_user_move_or_spin:
if self.hard_drop:
self.score += move_cell * Game.HARD_DROP_POINTS * self.level
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 calc_clear_speed(self):
return Game.CLEAR_SPEED * (
1 - (Game.DROP_CLEAR_SCALE * (self.level - 1))
)
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.clearing_rows.add(y)
if self.clearing_rows:
self.score += (
Game.POINTS_PER_LINE[len(self.clearing_rows) - 1]
* self.level
)
else:
speed = self.calc_clear_speed()
if self.clearing_frames >= speed:
if not any(self.board[next(iter(self.clearing_rows))]):
self.cleared_this_level += len(self.clearing_rows)
if self.cleared_this_level >= 10:
self.level += self.cleared_this_level // 10
self.cleared_this_level %= 10
# Order matters here
for y in sorted(self.clearing_rows):
drop_rows_above_for_clear(y)
self.clearing_rows = set()
self.clearing_frames = speed
else:
for y in self.clearing_rows:
do_single_clear(y)
self.clearing_frames = 0
else:
self.clearing_frames += 1
def draw_score_and_instructions(self):
text = (
"Press <h> \nfor help\nor pause.\n"
"\n"
f"Level: {self.level:02}\n"
f"Lines: {self.cleared_this_level:02}\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")
@lru_cache(maxsize=4)
def get_scaled_piece(
self, piece: Tetromino, scale: float | int, alpha: int = 255
) -> 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)
if alpha != 255:
os_surf.set_alpha(alpha)
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_action_help_string(Game.GAME_ACTION_MAP)
+ "\n\n"
+ Game.make_score_help_string()
)
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 draw_ghost_piece(self):
x, y = self.current_block_pos
y += self.cells_for_hard_drop()
surf = self.get_scaled_piece(self.current_block, 1, 0x80)
self.screen.blit(
surf,
((x + 1) * Game.CELL_SIZE.width, y * Game.CELL_SIZE.height),
)
def process_clear_actions(self):
# allow rotating the next piece during clear
while self.pending_actions:
match self.pending_actions.pop(0):
case Action.Type.ROTATE_LEFT:
self.next_piece = self.next_piece.rotated(-1)
case Action.Type.ROTATE_RIGHT:
self.next_piece = self.next_piece.rotated(1)
def game_loop(self):
self.soft_drop = False
self.hard_drop = False
self.did_user_move_or_spin = 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:
if not self.current_block:
self.swap_in_next_piece()
self.process_game_actions()
self.advance_piece()
else:
self.process_clear_actions()
self.screen.fill("black")
self.draw_board_content()
if self.current_block:
self.draw_current_block()
self.draw_ghost_piece()
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.was_move_down = None
self.inhibit_next_move = 0.0
self.cur_random_peice_seq = None
self.clear_board()
self.next_piece = None
self.swap_in_next_piece(True)
self.held_piece = None
self.did_swap = False
self.subcell_move = 0
self.subcell_drop = 0
self.clearing_rows = set()
# immediately clear the first block
self.score = 0
self.level = 1
self.cleared_this_level = 0
# depends on level
self.clearing_frames = self.calc_clear_speed()
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 move_piece_back_to_top(self):
self.current_block_pos = Cell(
self.BOARD_SIZE.width // 2
- math.ceil(self.current_block.bounding_box_width / 2),
0 - self.current_block.y_adj,
)
def swap_in_next_piece(self, force: bool = False):
self.did_swap = 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()
self.move_piece_back_to_top()
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(
">",
[
"** ",
" ** ",
" *",
" ** ",
"** ",
],
),
Font.Glyph(
"/",
[
" *",
" * ",
" * ",
" * ",
"* ",
],
),
Font.Glyph(
"*",
[
" ",
"* *",
" * ",
"* *",
" ",
],
),
]
if __name__ == "__main__":
game = Game()
game.run()