Add text, score, and game over

This commit is contained in:
2026-04-30 22:07:42 -07:00
parent ef1d8cc30e
commit 51cb128122
+699 -48
View File
@@ -1,7 +1,8 @@
import pygame import pygame
import pygame.draw as draw import pygame.draw as draw
from collections import namedtuple, defaultdict from collections import namedtuple, defaultdict
from typing import NamedTuple, Any from typing import NamedTuple, Any, Iterable, Optional
from functools import cache
import random import random
import math import math
from enum import Enum, auto from enum import Enum, auto
@@ -80,6 +81,7 @@ class Action(NamedTuple):
desc: str desc: str
class Type(Enum): class Type(Enum):
# In Game
ROTATE_LEFT = auto() ROTATE_LEFT = auto()
ROTATE_RIGHT = auto() ROTATE_RIGHT = auto()
DROP = auto() DROP = auto()
@@ -87,15 +89,136 @@ class Action(NamedTuple):
MOVE_LEFT = auto() MOVE_LEFT = auto()
MOVE_RIGHT = auto() MOVE_RIGHT = auto()
# Game over
RESTART = auto()
@staticmethod @staticmethod
def make_map(*acts): def make_map(*acts):
return {act.key: act for act in 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: class Game:
PLACE_POINTS = 10
CLEAR_POINTS = 100
MOVE_SPEED = 20 # cells per second MOVE_SPEED = 20 # cells per second
NORMAL_DROP_SPEED = 3 # cells per second NORMAL_DROP_SPEED = 3 # cells per second
FAST_DROP_SPEED = 10 # cells per second FAST_DROP_SPEED = 30 # cells per second
CLEAR_SPEED = 5 # frames per block CLEAR_SPEED = 5 # frames per block
BOARD_SIZE = Size(10, 20) # Cell count BOARD_SIZE = Size(10, 20) # Cell count
CELL_SIZE = Size(30, 30) CELL_SIZE = Size(30, 30)
@@ -125,8 +248,7 @@ class Game:
Tetromino("green", [[0, 1, 1], [1, 1, 0]]), Tetromino("green", [[0, 1, 1], [1, 1, 0]]),
Tetromino("red", [[1, 1, 0], [0, 1, 1]]), Tetromino("red", [[1, 1, 0], [0, 1, 1]]),
] ]
GAME_ACTION_MAP = Action.make_map(
ACTION_MAP = Action.make_map(
Action( Action(
pygame.K_z, pygame.K_z,
Action.Type.ROTATE_LEFT, Action.Type.ROTATE_LEFT,
@@ -161,15 +283,462 @@ class Game:
"Move the current piece right.", "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 @staticmethod
def random_tetromino(): def random_tetromino():
return random.choice(Game.TETROMINOS) return random.choice(Game.TETROMINOS)
def __init__(self): def __init__(self):
self.screen = None self._game_over = False
self.clock = None
self.running = 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): def draw_block(self, x: int, y: int, color):
"""Draw a block at (X, Y). Coordinates are for the top left corner.""" """Draw a block at (X, Y). Coordinates are for the top left corner."""
@@ -259,12 +828,17 @@ class Game:
screen_x, screen_y, self.current_block.color 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): def handle_key_event(self, typ, key):
if key not in Game.ACTION_MAP: if typ == pygame.KEYDOWN:
return if key in self.action_map:
elif typ == pygame.KEYDOWN: self.pending_actions.append(self.action_map[key].type)
act = Game.ACTION_MAP[key]
self.pending_actions.append(act.type)
self.key_states[key] = True self.key_states[key] = True
elif typ == pygame.KEYUP: elif typ == pygame.KEYUP:
self.key_states[key] = False self.key_states[key] = False
@@ -274,10 +848,10 @@ class Game:
for key, state in self.key_states.items(): for key, state in self.key_states.items():
if ( if (
state state
and key in self.ACTION_MAP and key in self.action_map
and self.ACTION_MAP[key].repeat and self.action_map[key].repeat
): ):
self.pending_actions.append(self.ACTION_MAP[key].type) self.pending_actions.append(self.action_map[key].type)
# now process new events # now process new events
for event in pygame.event.get(): for event in pygame.event.get():
match event.type: match event.type:
@@ -287,7 +861,10 @@ class Game:
self.handle_key_event(event.type, event.key) self.handle_key_event(event.type, event.key)
def swap_with_hold(self): def swap_with_hold(self):
pass 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): def intersects_board(self, x: int, y: int, piece: Tetromino):
pw, ph = piece.width, piece.height pw, ph = piece.width, piece.height
@@ -314,7 +891,7 @@ class Game:
return return
def move_current_piece(self, dir: Direction): def move_current_piece(self, dir: Direction):
dx = dir * Game.MOVE_SPEED / Game.FRAMERATE dx = dir * Game.MOVE_SPEED * self.frame_time()
self.subcell_move += dx self.subcell_move += dx
move_cell = int(self.subcell_move) move_cell = int(self.subcell_move)
self.subcell_move -= move_cell self.subcell_move -= move_cell
@@ -325,7 +902,7 @@ class Game:
if not self.intersects_board(*new_pos, self.current_block): if not self.intersects_board(*new_pos, self.current_block):
self.current_block_pos = new_pos self.current_block_pos = new_pos
def process_actions(self): def process_game_actions(self):
if not ( if not (
{Action.Type.MOVE_LEFT, Action.Type.MOVE_RIGHT} {Action.Type.MOVE_LEFT, Action.Type.MOVE_RIGHT}
& set(self.pending_actions) & set(self.pending_actions)
@@ -347,6 +924,7 @@ class Game:
self.move_current_piece(Direction.RIGHT) self.move_current_piece(Direction.RIGHT)
def place_piece(self, x: int, y: int, piece: Tetromino): def place_piece(self, x: int, y: int, piece: Tetromino):
self.score += Game.PLACE_POINTS
for dy, row in enumerate(piece.shape): for dy, row in enumerate(piece.shape):
for dx, cell in enumerate(row): for dx, cell in enumerate(row):
if cell: if cell:
@@ -356,7 +934,7 @@ class Game:
speed = ( speed = (
Game.FAST_DROP_SPEED if self.doing_drop else Game.NORMAL_DROP_SPEED Game.FAST_DROP_SPEED if self.doing_drop else Game.NORMAL_DROP_SPEED
) )
self.subcell_drop += speed / Game.FRAMERATE self.subcell_drop += speed * self.frame_time()
move_cell = int(self.subcell_drop) move_cell = int(self.subcell_drop)
self.subcell_drop -= move_cell self.subcell_drop -= move_cell
cp = self.current_block_pos cp = self.current_block_pos
@@ -372,8 +950,7 @@ class Game:
self.place_piece(cp.x, cp.y + dy, self.current_block) self.place_piece(cp.x, cp.y + dy, self.current_block)
self.current_block = None self.current_block = None
return return
# TODO Game over self.game_over = True
print("Game over!")
@staticmethod @staticmethod
def generate_clear_cells(): def generate_clear_cells():
@@ -399,6 +976,7 @@ class Game:
if not self.clearing_rows: if not self.clearing_rows:
for y, row in enumerate(self.board): for y, row in enumerate(self.board):
if all(row): if all(row):
self.score += self.CLEAR_POINTS
self.clearing_rows.add(y) self.clearing_rows.add(y)
elif not any(self.board[next(iter(self.clearing_rows))]): elif not any(self.board[next(iter(self.clearing_rows))]):
for y in self.clearing_rows: for y in self.clearing_rows:
@@ -413,29 +991,105 @@ class Game:
else: else:
self.clearing_frames += 1 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): def loop(self):
self.running = True self.running = True
while self.running: while self.running:
self.doing_drop = False
self.handle_events() self.handle_events()
if self.game_over:
self.maybe_clear_rows() self.game_over_loop()
if not self.clearing_rows:
self.process_actions()
self.advance_piece()
else: else:
# ignore input while clearing self.game_loop()
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()
pygame.display.flip() pygame.display.flip()
self.clock.tick(Game.FRAMERATE) self.clock.tick(Game.FRAMERATE)
@@ -449,25 +1103,22 @@ class Game:
0, 0,
) )
def clear_board(self):
self.board = make_matrix(*Game.BOARD_SIZE)
def init(self): def init(self):
pygame.init() pygame.init()
self.screen = pygame.display.set_mode(Game.WINDOW_SIZE, pygame.SCALED) self.screen = pygame.display.set_mode(Game.WINDOW_SIZE, pygame.SCALED)
pygame.display.set_caption("Tetris") pygame.display.set_caption("Tetris")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.gray_overlay = self.make_gray_overlay()
pygame.key.stop_text_input() pygame.key.stop_text_input()
self.key_states = defaultdict(lambda: False) self.key_states = defaultdict(lambda: False)
self.pending_actions = [] self.pending_actions = []
# Game state self.start_new_game()
self.board = make_matrix(*Game.BOARD_SIZE)
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
def run(self): def run(self):
self.init() self.init()