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.draw as draw
from collections import namedtuple, defaultdict
from typing import NamedTuple, Any
from typing import NamedTuple, Any, Iterable, Optional
from functools import cache
import random
import math
from enum import Enum, auto
@@ -80,6 +81,7 @@ class Action(NamedTuple):
desc: str
class Type(Enum):
# In Game
ROTATE_LEFT = auto()
ROTATE_RIGHT = auto()
DROP = auto()
@@ -87,15 +89,136 @@ class Action(NamedTuple):
MOVE_LEFT = auto()
MOVE_RIGHT = auto()
# Game over
RESTART = auto()
@staticmethod
def make_map(*acts):
return {act.key: act for act in acts}
class Font:
class Glyph(NamedTuple):
char: str
bitmap: list[str]
@property
def width(self):
return len(self.bitmap[0])
@property
def height(self):
return len(self.bitmap)
@property
def size(self) -> Size:
return Size(self.width, self.height)
def __init__(
self,
scale: int,
kern: int,
row_kern: int,
glyphs: Iterable[Glyph] | dict[str, Glyph],
):
self.scale = scale
self.kern = kern
self.row_kern = row_kern
if isinstance(glyphs, dict):
self.glyphs: dict[str, Font.Glyph] = dict(glyphs)
else:
self.glyphs: dict[str, Font.Glyph] = {g.char: g for g in glyphs}
def scaled(
self,
new_scale: int,
new_kern: Optional[int],
new_row_kern: Optional[int],
):
return Font(
new_scale,
self.kern if new_kern is None else new_kern,
self.row_kern if new_row_kern is None else new_row_kern,
self.glyphs,
)
@cache
def compute_extents(self, text: str) -> Size:
text = text.lower()
multi_row = False
total_width = 0
total_height = 0
last_width = 0
last_height = 0
for char in text:
if char == "\n":
multi_row = True
total_width = max(total_width, last_width)
last_width = 0
total_height += last_height
last_height = 0
else:
glyph = self.glyphs[char]
last_width += glyph.width * self.scale + self.kern
last_height = max(last_height, glyph.height * self.scale)
total_width = max(last_width, total_width) - self.kern
total_height += last_height
return Size(total_width, total_height + (multi_row * self.row_kern))
def render_text(
self, text: str, dest: pygame.surface.Surface, x: int, y: int, color
):
text = text.lower()
initial_x = x
max_height = 0
for char in text:
if char == "\n":
y += max_height + self.row_kern
max_height = 0
x = initial_x
else:
glyph = self.glyphs[char]
dest.blit(self.render_glyph(char, color), (x, y))
x += glyph.width * self.scale + self.kern
max_height = max(max_height, glyph.height * self.scale)
def center_text(
self, text: str, dest: pygame.surface.Surface, x: int, y: int, color
):
text = text.lower()
exts = self.compute_extents(text)
self.render_text(
text, dest, x - (exts.width // 2), y - (exts.height // 2), color
)
@cache
def render_glyph(self, char: str, color) -> pygame.surface.Surface:
char = char.lower()
glyph = self.glyphs[char]
size = tuple(map(lambda d: d * self.scale, glyph.size))
surf = pygame.surface.Surface(size, pygame.SRCALPHA)
for y, row in enumerate(glyph.bitmap):
for x, cell in enumerate(row):
if cell != " ":
pygame.draw.rect(
surf,
color,
(
x * self.scale,
y * self.scale,
self.scale,
self.scale,
),
)
return surf
class Game:
PLACE_POINTS = 10
CLEAR_POINTS = 100
MOVE_SPEED = 20 # cells per second
NORMAL_DROP_SPEED = 3 # cells per second
FAST_DROP_SPEED = 10 # 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)
@@ -125,8 +248,7 @@ class Game:
Tetromino("green", [[0, 1, 1], [1, 1, 0]]),
Tetromino("red", [[1, 1, 0], [0, 1, 1]]),
]
ACTION_MAP = Action.make_map(
GAME_ACTION_MAP = Action.make_map(
Action(
pygame.K_z,
Action.Type.ROTATE_LEFT,
@@ -161,15 +283,462 @@ class Game:
"Move the current piece right.",
),
)
GAME_OVER_ACTION_MAP = Action.make_map(
Action(pygame.K_r, Action.Type.RESTART, False, "Start a new game.")
)
FONT = Font(
6,
3,
3,
[
Font.Glyph(
" ",
[
" ",
" ",
" ",
" ",
" ",
],
),
Font.Glyph(
"a",
[
"*****",
"* *",
"*****",
"* *",
"* *",
],
),
Font.Glyph(
"b",
[
"**** ",
"* *",
"*****",
"* *",
"**** ",
],
),
Font.Glyph(
"c",
[
"*****",
"* *",
"* ",
"* *",
"*****",
],
),
Font.Glyph(
"d",
[
"**** ",
"* *",
"* *",
"* *",
"**** ",
],
),
Font.Glyph(
"e",
[
"*****",
"* ",
"**** ",
"* ",
"*****",
],
),
Font.Glyph(
"f",
[
"*****",
"* ",
"**** ",
"* ",
"* ",
],
),
Font.Glyph(
"g",
[
"*****",
"* ",
"* **",
"* *",
"*****",
],
),
Font.Glyph(
"h",
[
"* *",
"* *",
"*****",
"* *",
"* *",
],
),
Font.Glyph(
"i",
[
"*****",
" * ",
" * ",
" * ",
"*****",
],
),
Font.Glyph(
"j",
[
"*****",
" * ",
" * ",
"* * ",
" ** ",
],
),
Font.Glyph(
"k",
[
"* *",
"* * ",
"*** ",
"* * ",
"* *",
],
),
Font.Glyph(
"l",
[
"* ",
"* ",
"* ",
"* ",
"*****",
],
),
Font.Glyph(
"m",
[
"* *",
"** **",
"* * *",
"* *",
"* *",
],
),
Font.Glyph(
"n",
[
"* *",
"** *",
"* * *",
"* **",
"* *",
],
),
Font.Glyph(
"o",
[
" *** ",
"* *",
"* *",
"* *",
" *** ",
],
),
Font.Glyph(
"p",
[
"**** ",
"* *",
"**** ",
"* ",
"* ",
],
),
Font.Glyph(
"q",
[
" *** ",
"* *",
"* * *",
"* * ",
" ** *",
],
),
Font.Glyph(
"r",
[
"**** ",
"* *",
"**** ",
"* * ",
"* *",
],
),
Font.Glyph(
"s",
[
" ****",
"* ",
" *** ",
" *",
"**** ",
],
),
Font.Glyph(
"t",
[
"*****",
" * ",
" * ",
" * ",
" * ",
],
),
Font.Glyph(
"u",
[
"* *",
"* *",
"* *",
"* *",
" *** ",
],
),
Font.Glyph(
"v",
[
"* *",
"* *",
"* *",
" * * ",
" * ",
],
),
Font.Glyph(
"w",
[
"* *",
"* *",
"* * *",
"** **",
"* *",
],
),
Font.Glyph(
"x",
[
"* *",
" * * ",
" * ",
" * * ",
"* *",
],
),
Font.Glyph(
"y",
[
"* *",
" * * ",
" * ",
" * ",
" * ",
],
),
Font.Glyph(
"z",
[
"*****",
" * ",
" * ",
" * ",
"*****",
],
),
Font.Glyph(
"0",
[
" *** ",
"* *",
"* * *",
"* *",
" *** ",
],
),
Font.Glyph(
"1",
[
" ** ",
"* * ",
" * ",
" * ",
"*****",
],
),
Font.Glyph(
"2",
[
" *** ",
"* *",
" * ",
" * ",
" ****",
],
),
Font.Glyph(
"3",
[
" *** ",
"* *",
" ***",
"* *",
" *** ",
],
),
Font.Glyph(
"4",
[
"* * ",
"* * ",
"*****",
" * ",
" * ",
],
),
Font.Glyph(
"5",
[
"*****",
"* ",
"**** ",
" *",
"**** ",
],
),
Font.Glyph(
"6",
[
" ****",
"* ",
"**** ",
"* *",
" *** ",
],
),
Font.Glyph(
"7",
[
"*****",
" * ",
" * ",
" * ",
"* ",
],
),
Font.Glyph(
"8",
[
" *** ",
"* *",
" *** ",
"* *",
" *** ",
],
),
Font.Glyph(
"9",
[
" *** ",
"* *",
" ****",
" *",
"**** ",
],
),
Font.Glyph(
".",
[
" ",
" ",
" ",
" ",
" *",
],
),
Font.Glyph(
"!",
[
" *",
" *",
" *",
" ",
" *",
],
),
Font.Glyph(
":",
[
" ",
" *",
" ",
" *",
" ",
],
),
Font.Glyph(
"<",
[
" **",
" ** ",
"* ",
" ** ",
" **",
],
),
Font.Glyph(
">",
[
"** ",
" ** ",
" *",
" ** ",
"** ",
],
),
],
)
SMALL_FONT = FONT.scaled(4, 2, 2)
TINY_FONT = FONT.scaled(2, 1, 1)
SCORE_DISPLAY_BORDER = 5
CONTROLS_START_X = (BOARD_SIZE.width + 2) * CELL_SIZE.width
@staticmethod
def random_tetromino():
return random.choice(Game.TETROMINOS)
def __init__(self):
self.screen = None
self.clock = None
self.running = False
self._game_over = False
@property
def game_over(self):
return self._game_over
@game_over.setter
def game_over(self, newval):
if newval != self._game_over:
self.pending_actions = []
self._game_over = newval
if self._game_over:
self.action_map = Game.GAME_OVER_ACTION_MAP
else:
self.action_map = Game.GAME_ACTION_MAP
def draw_block(self, x: int, y: int, color):
"""Draw a block at (X, Y). Coordinates are for the top left corner."""
@@ -259,12 +828,17 @@ class Game:
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 key not in Game.ACTION_MAP:
return
elif typ == pygame.KEYDOWN:
act = Game.ACTION_MAP[key]
self.pending_actions.append(act.type)
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
@@ -274,10 +848,10 @@ class Game:
for key, state in self.key_states.items():
if (
state
and key in self.ACTION_MAP
and self.ACTION_MAP[key].repeat
and key in self.action_map
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
for event in pygame.event.get():
match event.type:
@@ -287,7 +861,10 @@ class Game:
self.handle_key_event(event.type, event.key)
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):
pw, ph = piece.width, piece.height
@@ -314,7 +891,7 @@ class Game:
return
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
move_cell = int(self.subcell_move)
self.subcell_move -= move_cell
@@ -325,7 +902,7 @@ class Game:
if not self.intersects_board(*new_pos, self.current_block):
self.current_block_pos = new_pos
def process_actions(self):
def process_game_actions(self):
if not (
{Action.Type.MOVE_LEFT, Action.Type.MOVE_RIGHT}
& set(self.pending_actions)
@@ -347,6 +924,7 @@ class Game:
self.move_current_piece(Direction.RIGHT)
def place_piece(self, x: int, y: int, piece: Tetromino):
self.score += Game.PLACE_POINTS
for dy, row in enumerate(piece.shape):
for dx, cell in enumerate(row):
if cell:
@@ -356,7 +934,7 @@ class Game:
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)
self.subcell_drop -= move_cell
cp = self.current_block_pos
@@ -372,8 +950,7 @@ class Game:
self.place_piece(cp.x, cp.y + dy, self.current_block)
self.current_block = None
return
# TODO Game over
print("Game over!")
self.game_over = True
@staticmethod
def generate_clear_cells():
@@ -399,6 +976,7 @@ class Game:
if not self.clearing_rows:
for y, row in enumerate(self.board):
if all(row):
self.score += self.CLEAR_POINTS
self.clearing_rows.add(y)
elif not any(self.board[next(iter(self.clearing_rows))]):
for y in self.clearing_rows:
@@ -413,29 +991,105 @@ class Game:
else:
self.clearing_frames += 1
def draw_score(self):
text = f"Score: {self.score:05}"
exts = self.TINY_FONT.compute_extents(text)
x = Game.CONTROLS_START_X + Game.SCORE_DISPLAY_BORDER
y = self.screen.get_size()[1] - Game.SCORE_DISPLAY_BORDER - exts.height
self.TINY_FONT.render_text(text, self.screen, x, y, "white")
def game_loop(self):
self.doing_drop = False
self.maybe_clear_rows()
if not self.clearing_rows:
self.process_game_actions()
self.advance_piece()
else:
# ignore input while clearing
self.pending_actions = []
if not self.current_block:
self.generate_new_block()
self.screen.fill("black")
if not self.clearing_rows:
self.draw_current_block()
self.draw_board_content()
self.draw_board_border()
self.draw_score()
def start_new_game(self):
self.clear_board()
self.generate_new_block()
self.held_piece = None
self.subcell_move = 0
self.subcell_drop = 0
self.clearing_rows = set()
# immediately clear the first block
self.clearing_frames = Game.CLEAR_SPEED
self.score = 0
self.held_piece = None
self.game_over = False
def handle_game_over_actions(self):
while self.pending_actions:
act = self.pending_actions.pop(0)
if act == Action.Type.RESTART:
self.start_new_game()
def make_gray_overlay(self):
s = pygame.surface.Surface(self.screen.get_size(), pygame.SRCALPHA)
pygame.draw.rect(s, "#44444466", (0, 0, *s.get_size()))
return s
def draw_gray_overlay(self):
self.screen.blit(self.gray_overlay, (0, 0))
def game_over_loop(self):
self.handle_game_over_actions()
if not self.game_over:
return
self.screen.fill("black")
self.draw_board_content()
self.draw_current_block()
self.draw_board_border()
self.draw_score()
self.draw_gray_overlay()
ss = self.screen.get_size()
exts = self.FONT.compute_extents("Game Over!")
self.FONT.center_text(
"Game Over!", self.screen, ss[0] // 2, ss[1] // 2, "white"
)
score_text = f"Score: {self.score:05}"
self.SMALL_FONT.center_text(
score_text,
self.screen,
ss[0] // 2,
ss[1] // 2 + exts.height + self.FONT.row_kern,
"white",
)
exts2 = self.SMALL_FONT.compute_extents(score_text)
self.SMALL_FONT.center_text(
"Press <r> to retry.",
self.screen,
ss[0] // 2,
ss[1] // 2 + exts.height + exts2.height + 2 * self.FONT.row_kern,
"white",
)
def loop(self):
self.running = True
while self.running:
self.doing_drop = False
self.handle_events()
self.maybe_clear_rows()
if not self.clearing_rows:
self.process_actions()
self.advance_piece()
if self.game_over:
self.game_over_loop()
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.game_loop()
pygame.display.flip()
self.clock.tick(Game.FRAMERATE)
@@ -449,25 +1103,22 @@ class Game:
0,
)
def clear_board(self):
self.board = make_matrix(*Game.BOARD_SIZE)
def init(self):
pygame.init()
self.screen = pygame.display.set_mode(Game.WINDOW_SIZE, pygame.SCALED)
pygame.display.set_caption("Tetris")
self.clock = pygame.time.Clock()
self.gray_overlay = self.make_gray_overlay()
pygame.key.stop_text_input()
self.key_states = defaultdict(lambda: False)
self.pending_actions = []
# Game state
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
self.start_new_game()
def run(self):
self.init()