1413 lines
38 KiB
Python
1413 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
|
|
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
|
|
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.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):
|
|
self.cur_random_peice_seq = None
|
|
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()
|