Перейти к основному содержимому

Python — Tetris

Разработчику Начальный уровень

О практикуме

Tetris (тетрис) — классическая головоломка, придуманная Алексеем Пажитновым в 1984 году в Москве. Из падающих фигур из четырёх клеток (тетромино) нужно заполнять ряды на поле шириной 10 клеток. Полная горизонтальная линия исчезает, верхние блоки опускаются, за это начисляются очки. Скорость падения растёт с уровнем; игра заканчивается, когда новая фигура не помещается у верхней границы.

Название происходит от греческого tetra («четыре») и «теннис» — любимой игры автора. С 1980-х Tetris стал эталоном простых правил и глубокого skill ceiling: за десять минут можно понять механику, а годами оттачивать скорость, предвидение и работу с очередью фигур.

В этом практикуме соберём полноценный прототип на Python 3 и Pygame — без спрайтов из оригинала, на цветных квадратах, зато с разбором сетки, вращения, очистки линий, очков, уровней, «призрака», 7-bag-рандома, hold и экранов меню.

Для кого материал
Нужны базовые Python (классы, списки, двумерные массивы, циклы) и знакомство с Pygame из статьи Разработка игр на Python. Каждый этап — запускаемый код: после шага проект можно запустить и увидеть новую механику.

Управление в финальной версии

КлавишаДействие
/ Сдвиг фигуры влево / вправо
Ускоренное падение (soft drop)
или XПоворот по часовой стрелке
ZПоворот против часовой стрелки
ПробелМгновенный сброс (hard drop)
CHold — отложить фигуру (этап 20)
PПауза
RПерезапуск после game over
EscВыход

Маршрут чтения

  1. Архитектура — как устроен проект до первой строки кода.
  2. Зависимости и структура папок — окружение и файлы.
  3. Этап 0 — минимальный запуск — чёрное окно и игровой цикл.
  4. Этапы 1–18 — по одной механике за шаг.
  5. Этапы 19–20 — продвинутые улучшения (7-bag, hold, lock delay, DAS).
  6. Итоговая структура и самопроверка.

Оглавление этапов

ЭтапТема
0Минимальное окно
1settings.py
2Поле и сетка
3Формы тетромино
4Класс Piece
5Сетка board
6Гравитация
7Движение ← / →
8Вращение
9Soft drop
10Фиксация
11Hard drop
12Очистка линий
13Очки и уровни
14NEXT
15Ghost piece
16HUD и состояния
17Модули game/
18Класс Game
197-bag randomizer
20Hold, lock delay, DAS

Что должно получиться

МеханикаОписание
ПолеСетка 10×20 клеток
Фигуры7 тетромино (I, O, T, S, Z, J, L) с цветами
ПадениеТаймер гравитации, ускорение на
ВращениеПоворот с простыми wall kick-сдвигами
ЛинииУдаление заполненных рядов, сдвиг блоков вниз
ОчкиКлассическая таблица NES + рост уровня каждые 10 линий
HUDСчёт, линии, уровень, превью следующей фигуры
ПризракПолупрозрачная проекция места приземления
СостоянияМеню, игра, пауза, game over
7-bagЧестная очередь из семи фигур без длинных серий одного типа
HoldОдна «запасная» фигура на обмен

Архитектура

Прежде чем писать код, зафиксируем что из чего состоит и как данные текут по кадру.

Игровой цикл

Любая игра на Pygame крутит один и тот же цикл. В Tetris порядок шагов важен — сначала ввод и логика, потом отрисовка.

На каждом кадре внутри обновления выполняется цепочка:

  1. Прочитать нажатые клавиши (движение, поворот, hard drop).
  2. Сдвинуть активную фигуру по таймеру гравитации (или быстрее при soft drop).
  3. Проверить столкновения с границами поля и зафиксированными блоками.
  4. При «приземлении» — записать клетки фигуры в сетку board.
  5. Найти и удалить полные горизонтальные линии, сдвинуть верхние ряды вниз.
  6. Обновить счёт, линии и уровень; ускорить гравитацию.
  7. Создать новую фигуру из очереди; при переполнении верха — game over.

Два представления координат

Tetris живёт в логической сетке и экранных пикселях. Их нельзя смешивать в одной переменной.

СистемаОсиЕдиницаПример
Сетка (логика)col (0…9), row (0…19)клеткафигура в (3, 0)
Экран (Pygame)x вправо, y внизпиксель(120, 40)

Перевод:

screen_x = MARGIN + col * CELL_SIZE
screen_y = MARGIN + row * CELL_SIZE

Pygame использует экранные координаты: начало (0, 0) — левый верхний угол.

Экран (пиксели)
┌──────────────────────────────────────────────────┐
│ MARGIN │
│ ┌────────────────────┐ ┌──────────────────┐ │
│ │ ■ ■ □ □ □ □ □ □ □ □ │ │ NEXT: │ │
│ │ □ □ □ □ □ □ □ □ □ □ │ │ [T-фигура] │ │
│ │ □ □ □ □ □ □ □ □ □ □ │ │ │ │
│ │ ... 10×20 сетка │ │ SCORE: 1200 │ │
│ │ □ □ ■ ■ ■ ■ □ □ □ □ │ │ LINES: 7 │ │
│ │ ■ ■ ■ ■ ■ ■ ■ ■ □ □ │ │ LEVEL: 1 │ │
│ └────────────────────┘ └──────────────────┘ │
│ игровое поле боковая панель │
└──────────────────────────────────────────────────┘

Модель данных — сетка board

Поле храним как двумерный список board[row][col]:

  • 0 — пустая клетка;
  • 1…7 — индекс цвета зафиксированного блока (по типу тетромино).

Активная (падающая) фигура не записывается в board, пока не «застынет». Её рисуем отдельно поверх сетки.

Тетромино

Семь классических фигур — I, O, T, S, Z, J, L. Каждая занимает до четырёх клеток.

Удобное представление — список смещений (dx, dy) относительно «якорной» точки фигуры (piece.x, piece.y):

# Фигура T в «нулевом» повороте (вид сверху)
T_SHAPE = [(0, 0), (-1, 0), (1, 0), (0, 1)]
# центр влево вправо вниз

Поворот на 90° по часовой стрелке для каждой клетки:

def rotate_cw(cells):
return [(-dy, dx) for dx, dy in cells]

Фигура O (квадрат 2×2) при повороте совпадает сама с собой — это нормально.

Справочник семи фигур (вид сверху, «нулевой» поворот):

I (cyan) O (yellow) T (purple)
· ■ · · ■ ■ · ■ ·
■ ■ ■ ■ ■ ■ ■ ■ ■

S (green) Z (red) J (blue) L (orange)
■ ■ ■ · ■ · · · · ■
■ · · ■ ■ ■ ■ ■ ■ ■ ■
ФигураКлетокОсобенность при вращении
I4Единственная «палка»; нужны wall kick ±2 у стены
O4Не меняет форму — поворот можно пропустить
T4Центр вращения — «головка» буквы T
S, Z4Зеркальные парами; часто путают новички
J, L4Уголок влево / вправо

Точка спавна

Новая фигура появляется над видимым полем, якорь в (SPAWN_COL, SPAWN_ROW). Для ширины 10 стандартный столбец — 4 или 5 (центр). Фигура I в горизонтали шире остальных — при спавне проверяйте can_place, иначе I иногда «вылезает» за правую стену.

SPAWN_COL = 4
SPAWN_ROW = 0 # верхний ряд; часть клеток может быть с row < 0 в Guideline — у нас упрощённо 0

SRS и wall kick
В оригинальных Guideline Tetris используется система SRS с таблицами сдвигов при вращении у стены. Для учебного проекта достаточно упрощённых wall kick: если поворот невозможен, пробуем сдвинуть фигуру на (−1, 0), (+1, 0), (0, −1) клетку.

Слои приложения

СлойОтветственностьПримеры сущностей
ВводКлавиши, пауза, перезапускKEYDOWN, get_pressed()
МирРазмеры сетки, таймеры, очередь фигурBoard, COLS, ROWS
АкторыПадающая фигура, следующая фигураPiece, next_kind
ПравилаГравитация, фиксация, линии, очки, уровеньGame, clear_lines()
ПредставлениеСетка, фигуры, HUD, оверлеиdraw_board, draw_hud

Слой правил не рисует напрямую — он меняет состояние; слой представления только читает состояние и выводит кадр.

Поток одного кадра (PLAYING)

Порядок в коде: сначала handle_event, затем update (гравитация и lock), в конце draw. Призрак рисуем до активной фигуры, чтобы она была поверх.

Алгоритм коллизий

Функция can_place(board, cells, ax, ay) — сердце физики Tetris. Для каждой клетки фигуры (dx, dy):

  1. Вычислить абсолютные координаты col = ax + dx, row = ay + dy.
  2. Если col < 0 или col >= COLSстена, место занято.
  3. Если row >= ROWSпол, место занято.
  4. Если row >= 0 и board[row][col] != 0столкновение с застывшим блоком.
  5. Если row < 0 — клетка «над потолком» видимой зоны; для учебного прототипа это допустимо (фигура ещё не полностью вошла на экран).
def can_place(board, cells, ax, ay):
for dx, dy in cells:
col, row = ax + dx, ay + dy
if col < 0 or col >= COLS or row >= ROWS:
return False
if row >= 0 and board[row][col]:
return False
return True

Все движения (try_move, try_rotate, hard_drop, ghost_row) сводятся к вызовам can_place с разными (ax, ay) или наборами cells.

Конечный автомат состояний

Рекомендуемые константы

КонстантаЗначениеСмысл
COLS10Ширина поля в клетках
ROWS20Высота видимого поля
CELL_SIZE30Размер клетки в пикселях
SIDEBAR_W160Ширина панели справа
MARGIN24Отступ от края окна
FPS60Кадров в секунду
LINES_PER_LEVEL10Линий до следующего уровня

Скорость падения (интервал между автоматическими шагами вниз, в секундах) уменьшается с уровнем:

def gravity_interval(level):
# level 0 → ~0.8 с, level 9 → ~0.1 с (упрощённая таблица)
return max(0.05, 0.8 - level * 0.07)

Таблица очков (стиль NES)

Линий за разБазовые очкиС множителем уровня
1 (Single)4040 × (level + 1)
2 (Double)100100 × (level + 1)
3 (Triple)300300 × (level + 1)
4 (Tetris)12001200 × (level + 1)

Дополнительно за каждую клетку soft drop — 1 очко, за hard drop — 2 очка за клетку.

Структура файлов (целевая)

К этапу 6 достаточно одного main.py. Дальше проект раскладываем по модулям.

tetris/
├── main.py # точка входа, цикл while
├── settings.py # константы, цвета, FPS
├── game/
│ ├── __init__.py
│ ├── tetrominoes.py # формы, цвета, rotate_cw / rotate_ccw
│ ├── board.py # сетка, фиксация, очистка линий
│ ├── piece.py # Piece — активная фигура
│ ├── hud.py # счёт, next, оверлеи
│ └── game.py # класс Game — правила партии
└── requirements.txt

Диаграмма объектов


Зависимости и подготовка окружения

Требования

  • Python 3.10+ (удобны match/case; на 3.9 код работает, если заменить match на if/elif).
  • Pygame 2.5+ — единственная внешняя библиотека.

Установка

mkdir tetris && cd tetris
python -m venv .venv

Активация виртуального окружения:

  • Windows (PowerShell): .venv\Scripts\Activate.ps1
  • Linux / macOS: source .venv/bin/activate
pip install pygame
python -c "import pygame; print('Pygame', pygame.ver)"

Файл requirements.txt:

pygame>=2.5.0

Первичная структура

На этапе 0 создайте только main.py. Папку game/ добавим на этапе 14.

Delta time
На всех этапах таймеры считаем через dt = clock.tick(FPS) / 1000.0 — секунды с прошлого кадра. Так интервал гравитации остаётся предсказуемым при просадках FPS.

Как проходить практикум
Создайте папку tetris/, копируйте код после каждого этапа, запускайте python main.py. Если что-то сломалось — сверьтесь с блоком «Самопроверка» в конце этапа.


Этап 0 — минимальный запускаемый код

Цель — окно, цикл событий, выход по крестику и Esc, стабильные 60 FPS.

Создайте main.py:

import sys
import pygame

pygame.init()

SCREEN_W, SCREEN_H = 360, 660
FPS = 60

screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("Tetris — этап 0")
clock = pygame.time.Clock()

running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False

screen.fill((12, 14, 22))
pygame.display.flip()
dt = clock.tick(FPS) / 1000.0

pygame.quit()
sys.exit()

Запуск:

python main.py

Самопроверка этапа 0

  • Окно открывается без traceback.
  • Фон тёмный, без мерцания.
  • Esc и крестик закрывают программу.

На следующих этапах не удаляем цикл — только расширяем тело while.


Этап 1 — константы и файл настроек

Цель — вынести все числа и цвета в settings.py.

settings.py:

# Сетка
COLS = 10
ROWS = 20
CELL_SIZE = 30

# Размеры окна
MARGIN = 24
BOARD_W = COLS * CELL_SIZE
BOARD_H = ROWS * CELL_SIZE
SIDEBAR_W = 160
SCREEN_W = MARGIN * 2 + BOARD_W + SIDEBAR_W
SCREEN_H = MARGIN * 2 + BOARD_H

FPS = 60

# Игровые правила
LINES_PER_LEVEL = 10
SOFT_DROP_BONUS = 1 # очков за клетку при удержании ↓
HARD_DROP_BONUS = 2 # очков за клетку при hard drop
SPAWN_COL = 4
SPAWN_ROW = 0
LOCK_DELAY = 0.5 # секунд до фиксации (этап 20)
SOFT_DROP_INTERVAL = 0.05

# Цвета (R, G, B)
COLOR_BG = (12, 14, 22)
COLOR_GRID = (28, 32, 48)
COLOR_GRID_LINE = (40, 46, 66)
COLOR_TEXT = (220, 225, 235)
COLOR_GHOST = (100, 110, 130)
COLOR_SIDEBAR = (18, 22, 34)

# Цвета тетромино (индекс 1…7)
COLORS = {
1: (0, 240, 240), # I — cyan
2: (240, 240, 0), # O — yellow
3: (160, 0, 240), # T — purple
4: (0, 240, 0), # S — green
5: (240, 0, 0), # Z — red
6: (0, 0, 240), # J — blue
7: (240, 160, 0), # L — orange
}

Обновите main.py:

import sys
import pygame
import settings as S

pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
pygame.display.set_caption("Tetris — этап 1")
clock = pygame.time.Clock()

running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False

screen.fill(S.COLOR_BG)
pygame.display.flip()
dt = clock.tick(S.FPS) / 1000.0

pygame.quit()
sys.exit()

Самопроверка

  • Импорт settings as S работает без ошибок.
  • Окно шире, чем на этапе 0 (есть место под боковую панель).

Этап 2 — отрисовка игрового поля

Цель — нарисовать прямоугольник поля и сетку 10×20.

Добавьте в main.py:

def board_origin():
return S.MARGIN, S.MARGIN


def draw_board(surface):
ox, oy = board_origin()
field = pygame.Rect(ox, oy, S.BOARD_W, S.BOARD_H)
pygame.draw.rect(surface, S.COLOR_GRID, field)

# Вертикальные линии
for col in range(S.COLS + 1):
x = ox + col * S.CELL_SIZE
pygame.draw.line(
surface, S.COLOR_GRID_LINE,
(x, oy), (x, oy + S.BOARD_H),
)

# Горизонтальные линии
for row in range(S.ROWS + 1):
y = oy + row * S.CELL_SIZE
pygame.draw.line(
surface, S.COLOR_GRID_LINE,
(ox, y), (ox + S.BOARD_W, y),
)

pygame.draw.rect(surface, S.COLOR_GRID_LINE, field, width=2)


def draw_sidebar(surface):
ox = S.MARGIN + S.BOARD_W + 12
oy = S.MARGIN
panel = pygame.Rect(ox, oy, S.SIDEBAR_W - 12, S.BOARD_H)
pygame.draw.rect(surface, S.COLOR_SIDEBAR, panel, border_radius=6)
pygame.draw.rect(surface, S.COLOR_GRID_LINE, panel, width=1, border_radius=6)

В цикле перед flip():

screen.fill(S.COLOR_BG)
draw_board(screen)
draw_sidebar(screen)
pygame.display.flip()

Самопроверка

  • Слева — сетка 10×20 с тонкими линиями.
  • Справа — тёмная панель под HUD.
  • Поле не выходит за границы окна.

Этап 3 — формы тетромино

Цель — описать 7 фигур как словари смещений и функции поворота.

Создайте tetrominoes.py (позже перенесём в game/tetrominoes.py):

import settings as S

# Смещения (dx, dy) относительно (x, y) фигуры — «нулевой» поворот
SHAPES = {
"I": [(0, 0), (-1, 0), (1, 0), (2, 0)],
"O": [(0, 0), (1, 0), (0, 1), (1, 1)],
"T": [(0, 0), (-1, 0), (1, 0), (0, 1)],
"S": [(0, 0), (1, 0), (0, 1), (-1, 1)],
"Z": [(0, 0), (-1, 0), (0, 1), (1, 1)],
"J": [(0, 0), (-1, 0), (0, 1), (0, 2)],
"L": [(0, 0), (1, 0), (0, 1), (0, 2)],
}

KIND_TO_ID = {
"I": 1, "O": 2, "T": 3, "S": 4, "Z": 5, "J": 6, "L": 7,
}


def rotate_cw(cells):
"""Поворот 90° по часовой стрелке."""
return [(-dy, dx) for dx, dy in cells]


def rotate_ccw(cells):
"""Поворот 90° против часовой стрелки."""
return [(dy, -dx) for dx, dy in cells]


def color_for_kind(kind):
return S.COLORS[KIND_TO_ID[kind]]

В main.py добавьте отладочную отрисовку одной фигуры T в центре поля:

import tetrominoes as T


def draw_cells(surface, cells, anchor_x, anchor_y, color):
ox, oy = board_origin()
for dx, dy in cells:
col = anchor_x + dx
row = anchor_y + dy
px = ox + col * S.CELL_SIZE
py = oy + row * S.CELL_SIZE
rect = pygame.Rect(px + 1, py + 1, S.CELL_SIZE - 2, S.CELL_SIZE - 2)
pygame.draw.rect(surface, color, rect, border_radius=3)

После draw_board(screen):

draw_cells(screen, T.SHAPES["T"], 4, 2, T.color_for_kind("T"))

Самопроверка

  • В верхней части поля видна фиолетовая T-фигура из 4 клеток.
  • Клетки чуть меньше ячейки сетки (отступ 1 px).
  • (Опционально) цикл по T.SHAPES.keys() рисует все 7 фигур в ряд для проверки цветов.

Проверка всех фигур
Для отладки временно добавьте цикл: for i, kind in enumerate(T.SHAPES): draw_cells(..., kind, i * 2, 2, T.color_for_kind(kind)) — в верхней части поля появится «радуга» из семи тетромино.


Этап 4 — класс Piece

Цель — инкапсулировать активную фигуру: тип, позиция, клетки, отрисовка.

Добавьте в main.py (позже вынесем в game/piece.py):

class Piece:
def __init__(self, kind, x, y):
self.kind = kind
self.x = x
self.y = y
self.cells = list(T.SHAPES[kind])
self.color_id = T.KIND_TO_ID[kind]

def world_cells(self):
"""Абсолютные координаты клеток на сетке."""
return [(self.x + dx, self.y + dy) for dx, dy in self.cells]

def draw(self, surface):
draw_cells(surface, self.cells, self.x, self.y, T.color_for_kind(self.kind))

Замените отладочный вызов draw_cells(..., "T", ...) на:

active = Piece("T", 4, 2)
# ...
active.draw(screen)

Самопроверка

  • T-фигура по-прежнему на месте.
  • Смена Piece("I", 3, 1) в коде показывает cyan-палку из четырёх клеток.

Этап 5 — пустая сетка board и отрисовка блоков

Цель — двумерный массив поля и функция рисования зафиксированных блоков.

def new_board():
return [[0 for _ in range(S.COLS)] for _ in range(S.ROWS)]


def draw_locked_blocks(surface, board):
ox, oy = board_origin()
for row in range(S.ROWS):
for col in range(S.COLS):
cell = board[row][col]
if cell:
px = ox + col * S.CELL_SIZE
py = oy + row * S.CELL_SIZE
rect = pygame.Rect(px + 1, py + 1, S.CELL_SIZE - 2, S.CELL_SIZE - 2)
pygame.draw.rect(surface, S.COLORS[cell], rect, border_radius=3)

Перед циклом:

board = new_board()
# «застывшие» блоки для проверки отрисовки
board[18][3] = 6 # J — синий
board[18][4] = 6
board[19][3] = 6
board[19][4] = 6
board[19][5] = 7 # L — оранжевый
active = Piece("T", 4, 2)

В цикле:

draw_locked_blocks(screen, board)
active.draw(screen)

Самопроверка

  • Внизу поля видны синие и оранжевые блоки.
  • Падающая T-фигура рисуется поверх сетки.

Этап 6 — гравитация (автоматическое падение)

Цель — фигура сама опускается вниз через фиксированный интервал.

GRAVITY_INTERVAL = 0.8 # секунд между шагами (пока без уровней)

Добавьте проверку «можно ли сдвинуть вниз» (пока без board — только границы):

def can_place(board, cells, ax, ay):
for col, row in [(ax + dx, ay + dy) for dx, dy in cells]:
if col < 0 or col >= S.COLS or row >= S.ROWS:
return False
if row >= 0 and board[row][col]:
return False
return True

В Piece:

def try_move(self, board, dx, dy):
if can_place(board, self.cells, self.x + dx, self.y + dy):
self.x += dx
self.y += dy
return True
return False

Перед циклом:

gravity_timer = 0.0
board = new_board()
active = Piece("T", 4, 0)

В update-части цикла (перед отрисовкой):

gravity_timer += dt
if gravity_timer >= GRAVITY_INTERVAL:
gravity_timer = 0.0
if not active.try_move(board, 0, 1):
pass # на этапе 10 здесь будет фиксация

Самопроверка

  • T-фигура падает примерно раз в 0.8 с.
  • На дне поля фигура останавливается (не выходит за ROWS).

Этап 7 — управление влево и вправо

Цель — клавиши / сдвигают фигуру, если нет столкновения.

Обработка в цикле (в блоке for event):

elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_LEFT:
active.try_move(board, -1, 0)
elif event.key == pygame.K_RIGHT:
active.try_move(board, 1, 0)

DAS (Delayed Auto Shift)
В коммерческом Tetris при удержании стрелки фигура сначала сдвигается один раз, затем — с задержкой быстро повторяет шаг. Для прототипа достаточно по одному шагу на нажатие; DAS реализуем на этапе 20.

Самопроверка

  • / двигают фигуру по клеткам.
  • У стен и застылого «пола» фигура не проходит сквозь блоки.

Этап 8 — вращение

Цель — поворот по / X (CW) и Z (CCW) с упрощёнными wall kick.

В Piece:

def try_rotate(self, board, direction=1):
if self.kind == "O":
return True # квадрат не меняется

old = self.cells
new_cells = T.rotate_cw(old) if direction == 1 else T.rotate_ccw(old)

# Пробуем поворот и небольшие сдвиги (wall kick)
for kick_dx, kick_dy in [(0, 0), (-1, 0), (1, 0), (0, -1), (-2, 0), (2, 0)]:
if can_place(board, new_cells, self.x + kick_dx, self.y + kick_dy):
self.cells = new_cells
self.x += kick_dx
self.y += kick_dy
return True
return False

В обработке KEYDOWN:

elif event.key in (pygame.K_UP, pygame.K_x):
active.try_rotate(board, direction=1)
elif event.key == pygame.K_z:
active.try_rotate(board, direction=-1)

Самопроверка

  • вращает фигуру у дна и у стен.
  • У левой стены I-палка после поворота сдвигается и не застревает в стене (wall kick).
  • O-фигура не «дёргается» при нажатии поворота.

Этап 9 — soft drop и подсчёт очков за падение

Цель — удержание ускоряет падение; за каждый шаг вниз — бонусные очки.

Перед циклом:

score = 0

В Piece.try_move можно добавить необязательный callback; проще обработать в цикле:

keys = pygame.key.get_pressed()
soft_drop = keys[pygame.K_DOWN]

if soft_drop:
# Отдельный быстрый таймер soft drop (~0.05 с)
if gravity_timer >= 0.05:
if active.try_move(board, 0, 1):
score += S.SOFT_DROP_BONUS
gravity_timer = 0.0
else:
interval = GRAVITY_INTERVAL
if gravity_timer >= interval:
gravity_timer = 0.0
active.try_move(board, 0, 1)

Порядок веток
На этом этапе логику soft drop и обычной гравитации держите в одном месте цикла, чтобы не вызывать двойной шаг вниз за кадр.

Временно выведите счёт в угол:

font = pygame.font.SysFont("consolas", 20)
label = font.render(f"Score: {score}", True, S.COLOR_TEXT)
screen.blit(label, (S.MARGIN + S.BOARD_W + 20, S.MARGIN + 20))

Самопроверка

  • заметно ускоряет падение.
  • Счёт растёт при удержании .

Этап 10 — фиксация фигуры на поле

Цель — когда вниз сдвинуть нельзя, записать клетки в board и выдать следующую фигуру.

def lock_piece(board, piece):
for col, row in piece.world_cells():
if 0 <= row < S.ROWS and 0 <= col < S.COLS:
board[row][col] = piece.color_id

Функция спавна:

import random

def spawn_piece(kind=None):
kind = kind or random.choice(list(T.SHAPES.keys()))
return Piece(kind, S.SPAWN_COL, S.SPAWN_ROW)

Единая функция гравитации — собирает soft drop, обычное падение и фиксацию (заменяет разрозненные фрагменты этапов 6, 9 и 10):

def tick_gravity(board, active, gravity_timer, dt, level, score_ref):
"""
score_ref — список [score], чтобы менять счёт из функции.
Возвращает (active, gravity_timer, locked).
"""
keys = pygame.key.get_pressed()
soft_drop = keys[pygame.K_DOWN]
gravity_timer += dt
locked = False

interval = S.SOFT_DROP_INTERVAL if soft_drop else gravity_interval(level)

if gravity_timer >= interval:
gravity_timer = 0.0
if active.try_move(board, 0, 1):
if soft_drop:
score_ref[0] += S.SOFT_DROP_BONUS
else:
lock_piece(board, active)
locked = True

return active, gravity_timer, locked

В игровом цикле:

score_box = [score]
active, gravity_timer, locked = tick_gravity(
board, active, gravity_timer, dt, level, score_box,
)
score = score_box[0]

if locked:
lines_cleared = clear_lines(board) # очки — этап 13
active = spawn_piece()
if not can_place(board, active.cells, active.x, active.y):
running = False # этап 16 → GAME_OVER

Lock delay
В Guideline Tetris есть задержка перед фиксацией (lock delay), чтобы игрок успел сдвинуть фигуру в последний момент. До этапа 20 фиксируем сразу; там добавим таймер LOCK_DELAY.

Самопроверка

  • После приземления T-фигура остаётся на поле цветными блоками.
  • Сразу появляется новая случайная фигура сверху.
  • Можно сложить несколько рядов блоков.

Этап 11 — hard drop (Пробел)

Цель — мгновенно опустить фигуру до упора, начислить очки, зафиксировать.

В Piece:

def hard_drop(self, board):
dropped = 0
while self.try_move(board, 0, 1):
dropped += 1
return dropped

В KEYDOWN:

elif event.key == pygame.K_SPACE:
steps = active.hard_drop(board)
score += steps * S.HARD_DROP_BONUS
lock_piece(board, active)
active = spawn_piece()

Уберите двойную фиксацию — после hard drop не ждите следующего тика гравитации. Вынесите общую логику:

def after_lock(board, active, score, lines_total, level):
"""Фиксация уже выполнена — очистка, очки, новый спавн."""
cleared = clear_lines(board)
if cleared:
lines_total += cleared
score += LINE_SCORES.get(cleared, 0) * (level + 1)
level = lines_total // S.LINES_PER_LEVEL
active = spawn_piece(next_kind)
next_kind = random_kind()
gravity_timer = 0.0
return active, next_kind, score, lines_total, level

Вызывайте after_lock и из hard drop, и из tick_gravity при locked=True.

Самопроверка

  • Пробел сбрасывает фигуру на дно или на другие блоки.
  • Счёт прыгает пропорционально высоте сброса.

Этап 12 — очистка заполненных линий

Цель — удалить полные ряды, сдвинуть верхние блоки вниз.

def clear_lines(board):
"""Возвращает количество удалённых линий."""
cleared = 0
row = S.ROWS - 1
while row >= 0:
if all(board[row][col] for col in range(S.COLS)):
del board[row]
board.insert(0, [0] * S.COLS)
cleared += 1
else:
row -= 1
return cleared

После lock_piece (и в ветке hard drop):

lines_cleared = clear_lines(board)

Для проверки можно временно заспавнить почти полный ряд.

Самопроверка

  • Заполненный ряд исчезает.
  • Блоки выше опускаются на одну клетку.
  • Два полных ряда за один lock удаляются оба.

Этап 13 — очки, линии и уровни

Цель — таблица NES, рост уровня, ускорение гравитации.

LINE_SCORES = {1: 40, 2: 100, 3: 300, 4: 1200}


def gravity_interval(level):
return max(0.05, 0.8 - level * 0.07)

Таблица скоростей (упрощённая модель NES)

УровеньИнтервал падения, сКомментарий
00.80Медленный старт для обучения
10.73После 10 линий
50.45Заметное ускорение
90.17Высокий темп
15+0.05Потолок — дальше не ускоряем

Guideline vs NES
Современный Tetris Guideline использует дискретную таблицу из ~20 уровней скорости (до 1G — одна клетка за кадр). Линейная формула 0.8 - level * 0.07 проще для учебного кода; при желании замените на массив GRAVITY_TABLE = [0.8, 0.7, ...].

Перед циклом:

score = 0
lines_total = 0
level = 0

После clear_lines:

if lines_cleared:
lines_total += lines_cleared
score += LINE_SCORES.get(lines_cleared, 0) * (level + 1)
level = lines_total // S.LINES_PER_LEVEL

В гравитации замените GRAVITY_INTERVAL на:

interval = gravity_interval(level)
if not soft_drop and gravity_timer >= interval:
...

Самопроверка

  • За одну линию на уровне 0 начисляется 40 очков.
  • После 10 линий уровень становится 1, падение ускоряется.
  • Tetris (4 линии) даёт заметный скачок счёта.

Этап 14 — очередь «следующая фигура»

Цель — игрок видит, что придёт после текущей; спавн из очереди 7-bag (упрощённо — random).

def random_kind():
return random.choice(list(T.SHAPES.keys()))

Перед циклом:

next_kind = random_kind()
active = spawn_piece(next_kind)
next_kind = random_kind()

После lock / hard drop:

active = spawn_piece(next_kind)
next_kind = random_kind()

Отрисовка превью на боковой панели:

def draw_next(surface, kind):
ox = S.MARGIN + S.BOARD_W + 28
oy = S.MARGIN + 60
font = pygame.font.SysFont("consolas", 18)
title = font.render("NEXT", True, S.COLOR_TEXT)
surface.blit(title, (ox, oy - 28))

preview = Piece(kind, 0, 0)
# Центрируем мини-превью в панели
cells = preview.cells
min_dx = min(dx for dx, dy in cells)
max_dx = max(dx for dx, dy in cells)
min_dy = min(dy for dx, dy in cells)
pw = (max_dx - min_dx + 1) * S.CELL_SIZE
start_col = 1 - min_dx
start_row = 1 - min_dy
draw_cells(surface, cells, start_col, start_row, T.color_for_kind(kind))
# Смещение превью в панель — добавьте смещение origin в draw_cells или рисуйте в локальных координатах

Удобнее отдельная функция с фиксированным origin панели:

def draw_next_preview(surface, kind):
px = S.MARGIN + S.BOARD_W + 36
py = S.MARGIN + 72
font = pygame.font.SysFont("consolas", 18)
surface.blit(font.render("NEXT", True, S.COLOR_TEXT), (px, py - 24))

cells = T.SHAPES[kind]
min_dx = min(dx for dx, dy in cells)
min_dy = min(dy for dx, dy in cells)
for dx, dy in cells:
x = px + (dx - min_dx) * (S.CELL_SIZE - 4)
y = py + (dy - min_dy) * (S.CELL_SIZE - 4)
rect = pygame.Rect(x, y, S.CELL_SIZE - 6, S.CELL_SIZE - 6)
pygame.draw.rect(surface, T.color_for_kind(kind), rect, border_radius=2)

Самопроверка

  • Справа отображается следующая фигура.
  • После lock текущая совпадает с тем, что было в NEXT.

Этап 15 — призрак (ghost piece)

Цель — полупрозрачная проекция, куда упадёт фигура при текущем положении.

В Piece:

def ghost_row(self, board):
ghost_y = self.y
while can_place(board, self.cells, self.x, ghost_y + 1):
ghost_y += 1
return ghost_y

Отрисовка призрака до активной фигуры:

def draw_ghost(surface, piece, board):
gy = piece.ghost_row(board)
ghost_cells = piece.cells
color = S.COLOR_GHOST
draw_cells(surface, ghost_cells, piece.x, gy, color)

В цикле:

draw_ghost(screen, active, board)
active.draw(screen)

Самопроверка

  • Серый контур фигуры виден у «дна» траектории.
  • При движении влево/вправо призрак следует за фигурой.

Этап 16 — HUD и экраны состояний

Цель — меню, пауза, game over; счёт, линии, уровень на панели.

STATE_MENU = "MENU"
STATE_PLAYING = "PLAYING"
STATE_PAUSED = "PAUSED"
STATE_GAME_OVER = "GAME_OVER"

state = STATE_MENU

Функции HUD:

def draw_hud(surface, score, lines, level):
px = S.MARGIN + S.BOARD_W + 24
y = S.MARGIN + 180
font = pygame.font.SysFont("consolas", 18)
for label, value in [("SCORE", score), ("LINES", lines), ("LEVEL", level)]:
surface.blit(font.render(f"{label}:", True, S.COLOR_TEXT), (px, y))
surface.blit(font.render(str(value), True, S.COLOR_TEXT), (px, y + 22))
y += 56


def draw_overlay(surface, title, hint):
overlay = pygame.Surface((S.SCREEN_W, S.SCREEN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 160))
surface.blit(overlay, (0, 0))
font_big = pygame.font.SysFont("consolas", 36, bold=True)
font_small = pygame.font.SysFont("consolas", 20)
t = font_big.render(title, True, S.COLOR_TEXT)
h = font_small.render(hint, True, S.COLOR_TEXT)
surface.blit(t, t.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H // 2 - 20)))
surface.blit(h, h.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H // 2 + 24)))

Обработка событий:

elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif state == STATE_MENU and event.key == pygame.K_SPACE:
state = STATE_PLAYING
board = new_board()
score = lines_total = level = 0
gravity_timer = 0.0
next_kind = random_kind()
active = spawn_piece(next_kind)
next_kind = random_kind()
elif state == STATE_PLAYING and event.key == pygame.K_p:
state = STATE_PAUSED
elif state == STATE_PAUSED and event.key == pygame.K_p:
state = STATE_PLAYING
elif state == STATE_GAME_OVER and event.key == pygame.K_r:
state = STATE_MENU

При невозможности спавна:

if not can_place(board, active.cells, active.x, active.y):
state = STATE_GAME_OVER

Обновление и отрисовка только при STATE_PLAYING; в конце draw:

if state == STATE_MENU:
draw_overlay(screen, "TETRIS", "Пробел — начать")
elif state == STATE_PAUSED:
draw_overlay(screen, "ПАУЗА", "P — продолжить")
elif state == STATE_GAME_OVER:
draw_overlay(screen, "GAME OVER", "R — в меню")

Самопроверка

  • Старт с экрана «Пробел — начать».
  • P ставит паузу с затемнением.
  • При переполнении верха — game over, R возвращает в меню.

Этап 17 — модули game/

Цель — разнести код по файлам, как в архитектуре.

Создайте структуру и полные файлы ниже. Корневой tetrominoes.py удалите — всё переезжает в пакет game/.

tetris/
├── main.py
├── settings.py
├── game/
│ ├── __init__.py # пустой
│ ├── tetrominoes.py
│ ├── board.py
│ ├── piece.py
│ └── hud.py

game/tetrominoes.py:

import settings as S

SHAPES = {
"I": [(0, 0), (-1, 0), (1, 0), (2, 0)],
"O": [(0, 0), (1, 0), (0, 1), (1, 1)],
"T": [(0, 0), (-1, 0), (1, 0), (0, 1)],
"S": [(0, 0), (1, 0), (0, 1), (-1, 1)],
"Z": [(0, 0), (-1, 0), (0, 1), (1, 1)],
"J": [(0, 0), (-1, 0), (0, 1), (0, 2)],
"L": [(0, 0), (1, 0), (0, 1), (0, 2)],
}

KIND_TO_ID = {
"I": 1, "O": 2, "T": 3, "S": 4, "Z": 5, "J": 6, "L": 7,
}

ALL_KINDS = list(SHAPES.keys())
LINE_SCORES = {1: 40, 2: 100, 3: 300, 4: 1200}


def rotate_cw(cells):
return [(-dy, dx) for dx, dy in cells]


def rotate_ccw(cells):
return [(dy, -dx) for dx, dy in cells]


def color_for_kind(kind):
return S.COLORS[KIND_TO_ID[kind]]


def color_for_id(color_id):
return S.COLORS[color_id]


def gravity_interval(level):
return max(0.05, 0.8 - level * 0.07)

game/board.py:

import pygame
import settings as S
from game.tetrominoes import color_for_id


def board_origin():
return S.MARGIN, S.MARGIN


def new_board():
return [[0 for _ in range(S.COLS)] for _ in range(S.ROWS)]


def can_place(board, cells, ax, ay):
for dx, dy in cells:
col, row = ax + dx, ay + dy
if col < 0 or col >= S.COLS or row >= S.ROWS:
return False
if row >= 0 and board[row][col]:
return False
return True


def lock_piece(board, piece):
for col, row in piece.world_cells():
if 0 <= row < S.ROWS and 0 <= col < S.COLS:
board[row][col] = piece.color_id


def clear_lines(board):
cleared = 0
row = S.ROWS - 1
while row >= 0:
if all(board[row]):
del board[row]
board.insert(0, [0] * S.COLS)
cleared += 1
else:
row -= 1
return cleared


def draw_cell(surface, col, row, color, inset=1):
ox, oy = board_origin()
px = ox + col * S.CELL_SIZE
py = oy + row * S.CELL_SIZE
rect = pygame.Rect(
px + inset, py + inset,
S.CELL_SIZE - 2 * inset, S.CELL_SIZE - 2 * inset,
)
pygame.draw.rect(surface, color, rect, border_radius=3)
highlight = tuple(min(255, c + 35) for c in color)
pygame.draw.line(surface, highlight, rect.topleft, (rect.right - 1, rect.top), 1)


def draw_board(surface):
ox, oy = board_origin()
field = pygame.Rect(ox, oy, S.BOARD_W, S.BOARD_H)
pygame.draw.rect(surface, S.COLOR_GRID, field)
for col in range(S.COLS + 1):
x = ox + col * S.CELL_SIZE
pygame.draw.line(surface, S.COLOR_GRID_LINE, (x, oy), (x, oy + S.BOARD_H))
for row in range(S.ROWS + 1):
y = oy + row * S.CELL_SIZE
pygame.draw.line(surface, S.COLOR_GRID_LINE, (ox, y), (ox + S.BOARD_W, y))
pygame.draw.rect(surface, S.COLOR_GRID_LINE, field, width=2)


def draw_locked_blocks(surface, board):
for row in range(S.ROWS):
for col in range(S.COLS):
if board[row][col]:
draw_cell(surface, col, row, color_for_id(board[row][col]))


def draw_ghost(surface, piece, board):
gy = piece.ghost_row(board)
for dx, dy in piece.cells:
draw_cell(surface, piece.x + dx, gy + dy, S.COLOR_GHOST, inset=3)

game/piece.py:

import random
import settings as S
from game import tetrominoes as T
from game.board import can_place


class Piece:
def __init__(self, kind, x=None, y=None):
self.kind = kind
self.x = S.SPAWN_COL if x is None else x
self.y = S.SPAWN_ROW if y is None else y
self.cells = list(T.SHAPES[kind])
self.color_id = T.KIND_TO_ID[kind]

def world_cells(self):
return [(self.x + dx, self.y + dy) for dx, dy in self.cells]

def draw(self, surface):
from game.board import draw_cell
for dx, dy in self.cells:
draw_cell(surface, self.x + dx, self.y + dy, T.color_for_kind(self.kind))

def try_move(self, board, dx, dy):
if can_place(board, self.cells, self.x + dx, self.y + dy):
self.x += dx
self.y += dy
return True
return False

def try_rotate(self, board, direction=1):
if self.kind == "O":
return True
new_cells = T.rotate_cw(self.cells) if direction == 1 else T.rotate_ccw(self.cells)
for kick_dx, kick_dy in [(0, 0), (-1, 0), (1, 0), (0, -1), (-2, 0), (2, 0)]:
if can_place(board, new_cells, self.x + kick_dx, self.y + kick_dy):
self.cells = new_cells
self.x += kick_dx
self.y += kick_dy
return True
return False

def hard_drop(self, board):
dropped = 0
while self.try_move(board, 0, 1):
dropped += 1
return dropped

def ghost_row(self, board):
gy = self.y
while can_place(board, self.cells, self.x, gy + 1):
gy += 1
return gy


def random_kind():
return random.choice(T.ALL_KINDS)


def spawn_piece(kind):
return Piece(kind)

game/hud.py:

import pygame
import settings as S
from game.tetrominoes import SHAPES, color_for_kind


def draw_sidebar(surface):
ox = S.MARGIN + S.BOARD_W + 12
oy = S.MARGIN
panel = pygame.Rect(ox, oy, S.SIDEBAR_W - 12, S.BOARD_H)
pygame.draw.rect(surface, S.COLOR_SIDEBAR, panel, border_radius=6)
pygame.draw.rect(surface, S.COLOR_GRID_LINE, panel, width=1, border_radius=6)


def draw_next_preview(surface, kind):
px = S.MARGIN + S.BOARD_W + 36
py = S.MARGIN + 72
font = pygame.font.SysFont("consolas", 18)
surface.blit(font.render("NEXT", True, S.COLOR_TEXT), (px, py - 24))
cells = SHAPES[kind]
min_dx = min(dx for dx, dy in cells)
min_dy = min(dy for dx, dy in cells)
size = S.CELL_SIZE - 6
for dx, dy in cells:
rect = pygame.Rect(
px + (dx - min_dx) * (S.CELL_SIZE - 4),
py + (dy - min_dy) * (S.CELL_SIZE - 4),
size, size,
)
pygame.draw.rect(surface, color_for_kind(kind), rect, border_radius=2)


def draw_hud(surface, score, lines, level):
px = S.MARGIN + S.BOARD_W + 24
y = S.MARGIN + 180
font = pygame.font.SysFont("consolas", 18)
for label, value in [("SCORE", score), ("LINES", lines), ("LEVEL", level)]:
surface.blit(font.render(f"{label}:", True, S.COLOR_TEXT), (px, y))
surface.blit(font.render(str(value), True, S.COLOR_TEXT), (px, y + 22))
y += 56


def draw_overlay(surface, title, hint):
overlay = pygame.Surface((S.SCREEN_W, S.SCREEN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 160))
surface.blit(overlay, (0, 0))
font_big = pygame.font.SysFont("consolas", 36, bold=True)
font_small = pygame.font.SysFont("consolas", 20)
t = font_big.render(title, True, S.COLOR_TEXT)
h = font_small.render(hint, True, S.COLOR_TEXT)
surface.blit(t, t.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H // 2 - 20)))
surface.blit(h, h.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H // 2 + 24)))

Импорты без циклов
board.py не импортирует Piece. piece.py тянет только can_place из board. Отрисовка клетки в Piece.draw через локальный импорт draw_cell — допустимый приём против циклического импорта.

Самопроверка

  • python main.py из корня tetris/ работает как на этапе 16.
  • Нет циклических импортов (board не импортирует piece, если piece импортирует board — только функции).

Этап 18 — класс Game и чистый main.py

Цель — собрать правила в один класс; в main.py остаётся только цикл.

game/game.py:

import random
import pygame
import settings as S
from game.board import (
new_board, can_place, lock_piece, clear_lines,
draw_board, draw_locked_blocks, draw_ghost,
)
from game.piece import Piece, spawn_piece, random_kind
from game.hud import draw_sidebar, draw_hud, draw_next_preview, draw_overlay
from game.tetrominoes import gravity_interval, LINE_SCORES


class Game:
def __init__(self):
self.state = "MENU"
self.board = new_board()
self.score = 0
self.lines_total = 0
self.level = 0
self.gravity_timer = 0.0
self.next_kind = random_kind()
self.active = spawn_piece(self.next_kind)
self.next_kind = random_kind()

def reset_play(self):
self.board = new_board()
self.score = 0
self.lines_total = 0
self.level = 0
self.gravity_timer = 0.0
self.next_kind = random_kind()
self.active = spawn_piece(self.next_kind)
self.next_kind = random_kind()
self.state = "PLAYING"

def _spawn_after_lock(self):
self.active = spawn_piece(self.next_kind)
self.next_kind = random_kind()
if not can_place(self.board, self.active.cells, self.active.x, self.active.y):
self.state = "GAME_OVER"

def _apply_line_score(self, cleared):
if not cleared:
return
self.lines_total += cleared
self.score += LINE_SCORES.get(cleared, 0) * (self.level + 1)
self.level = self.lines_total // S.LINES_PER_LEVEL

def handle_event(self, event):
if event.type != pygame.KEYDOWN:
return True

if event.key == pygame.K_ESCAPE:
return False

if self.state == "MENU" and event.key == pygame.K_SPACE:
self.reset_play()
elif self.state == "PLAYING":
if event.key == pygame.K_p:
self.state = "PAUSED"
elif event.key == pygame.K_LEFT:
self.active.try_move(self.board, -1, 0)
elif event.key == pygame.K_RIGHT:
self.active.try_move(self.board, 1, 0)
elif event.key in (pygame.K_UP, pygame.K_x):
self.active.try_rotate(self.board, 1)
elif event.key == pygame.K_z:
self.active.try_rotate(self.board, -1)
elif event.key == pygame.K_SPACE:
steps = self.active.hard_drop(self.board)
self.score += steps * S.HARD_DROP_BONUS
lock_piece(self.board, self.active)
self._apply_line_score(clear_lines(self.board))
self._spawn_after_lock()
elif self.state == "PAUSED" and event.key == pygame.K_p:
self.state = "PLAYING"
elif self.state == "GAME_OVER" and event.key == pygame.K_r:
self.state = "MENU"

return True

def update(self, dt):
if self.state != "PLAYING":
return

keys = pygame.key.get_pressed()
soft_drop = keys[pygame.K_DOWN]
self.gravity_timer += dt

if soft_drop:
if self.gravity_timer >= 0.05:
self.gravity_timer = 0.0
if self.active.try_move(self.board, 0, 1):
self.score += S.SOFT_DROP_BONUS
else:
lock_piece(self.board, self.active)
self._apply_line_score(clear_lines(self.board))
self._spawn_after_lock()
else:
interval = gravity_interval(self.level)
if self.gravity_timer >= interval:
self.gravity_timer = 0.0
if not self.active.try_move(self.board, 0, 1):
lock_piece(self.board, self.active)
self._apply_line_score(clear_lines(self.board))
self._spawn_after_lock()

def draw(self, surface):
draw_board(surface)
draw_sidebar(surface)
draw_locked_blocks(surface, self.board)

if self.state == "PLAYING":
draw_ghost(surface, self.active, self.board)
self.active.draw(surface)
draw_next_preview(surface, self.next_kind)
draw_hud(surface, self.score, self.lines_total, self.level)
elif self.state == "MENU":
draw_overlay(surface, "TETRIS", "Пробел — начать")
elif self.state == "PAUSED":
draw_ghost(surface, self.active, self.board)
self.active.draw(surface)
draw_next_preview(surface, self.next_kind)
draw_hud(surface, self.score, self.lines_total, self.level)
draw_overlay(surface, "ПАУЗА", "P — продолжить")
elif self.state == "GAME_OVER":
draw_hud(surface, self.score, self.lines_total, self.level)
draw_overlay(surface, "GAME OVER", f"Score: {self.score} · R — в меню")

Финальный main.py:

import sys
import pygame
import settings as S
from game.game import Game

pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
pygame.display.set_caption("Tetris")
clock = pygame.time.Clock()

game = Game()
running = True

while running:
dt = clock.tick(S.FPS) / 1000.0

for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif not game.handle_event(event):
running = False

game.update(dt)

screen.fill(S.COLOR_BG)
game.draw(screen)
pygame.display.flip()

pygame.quit()
sys.exit()

Самопроверка этапа 18

  • Весь игровой процесс работает как на этапе 16–17.
  • main.py короче 40 строк.
  • Можно добавить второй режим (например, Game(seed=42) для фиксированной последовательности) без переписывания цикла.

Этап 19 — 7-bag randomizer

Цель — заменить random.choice на мешок из семи фигур (стандарт Guideline): каждые 7 спавнов игрок гарантированно получает по одному экземпляру I, O, T, S, Z, J, L в случайном порядке. Это убирает «полоску» из пяти Z подряд и делает игру честнее.

Добавьте в game/tetrominoes.py (после ALL_KINDS):

import random


class SevenBag:
"""Очередь тетромино по правилу 7-bag."""

def __init__(self, seed=None):
self.rng = random.Random(seed)
self.queue = []
self._refill()

def _refill(self):
bag = ALL_KINDS[:]
self.rng.shuffle(bag)
self.queue.extend(bag)

def pop(self):
if len(self.queue) < 1:
self._refill()
return self.queue.pop(0)

def peek(self):
if len(self.queue) < 1:
self._refill()
return self.queue[0]

В Game.__init__ и reset_play:

from game.tetrominoes import SevenBag

self.bag = SevenBag()
self.next_kind = self.bag.pop()
self.active = spawn_piece(self.next_kind)
self.next_kind = self.bag.peek()

В _spawn_after_lock:

self.active = spawn_piece(self.next_kind)
self.bag.pop() # сняли текущую «next» из очереди
self.next_kind = self.bag.peek()

Предсказуемость для обучения
SevenBag(seed=42) даёт воспроизводимую последовательность — удобно отлаживать вращения и тестировать очистку линий.

Самопроверка

  • За 7 последовательных фигур встречаются все 7 типов (в любом порядке).
  • NEXT совпадает с реальным следующим спавном.
  • С seed=0 последовательность одинакова при каждом перезапуске.

Этап 20 — hold, lock delay и DAS

Цель — три улучшения «как в современном Tetris»: запасная фигура, пауза перед фиксацией и автоповтор сдвига при удержании стрелки.

Hold (клавиша C)

# В Game.__init__
self.hold_kind = None
self.hold_used = False
def _do_hold(self):
if self.hold_used:
return
self.hold_used = True
current = self.active.kind
if self.hold_kind is None:
self.hold_kind = current
self.active = spawn_piece(self.next_kind)
self.bag.pop()
self.next_kind = self.bag.peek()
else:
self.hold_kind, swap = current, self.hold_kind
self.active = spawn_piece(swap)
if not can_place(self.board, self.active.cells, self.active.x, self.active.y):
self.state = "GAME_OVER"

В handle_event при K_c вызовите _do_hold(). После каждого lock / spawn сбрасывайте self.hold_used = False.

Отрисовка hold в hud.py — по аналогии с draw_next_preview, блок «HOLD» над NEXT.

Lock delay

Когда фигура не может сдвинуться вниз, не фиксируйте сразу — запустите таймер:

# В Game
self.lock_timer = 0.0
self.on_ground = False

def _on_gravity_step(self, moved_down):
if moved_down:
self.on_ground = False
self.lock_timer = 0.0
return False
if not self.on_ground:
self.on_ground = True
self.lock_timer = 0.0
self.lock_timer += dt
if self.lock_timer >= S.LOCK_DELAY:
self._lock_current()
return True
return False

Любой успешный try_move или try_rotate сбрасывает on_ground и lock_timermove reset, игрок получает ещё LOCK_DELAY секунд.

DAS (Delayed Auto Shift)

DAS_DELAY = 0.15 # пауза до автоповтора, сек
DAS_RATE = 0.05 # интервал повторных шагов

# В Game.__init__
self.das_timer = 0.0
self.das_dir = 0

def _update_das(self, dt):
keys = pygame.key.get_pressed()
want = (keys[pygame.K_RIGHT] - keys[pygame.K_LEFT])
if want == 0:
self.das_dir = 0
self.das_timer = 0.0
return
if want != self.das_dir:
self.das_dir = want
self.das_timer = 0.0
self.active.try_move(self.board, want, 0)
return
self.das_timer += dt
interval = DAS_DELAY if self.das_timer < DAS_DELAY else DAS_RATE
if self.das_timer >= DAS_DELAY:
sub = self.das_timer - DAS_DELAY
steps = int(sub / DAS_RATE)
if steps > 0:
for _ in range(steps):
if not self.active.try_move(self.board, want, 0):
break
self.das_timer = DAS_DELAY + (sub % DAS_RATE)

Вызывайте _update_das(dt) в update до гравитации. Обработку K_LEFT / K_RIGHT в handle_event можно убрать — DAS заменяет одиночные нажатия.

Самопроверка этапа 20

  • C меняет текущую фигуру на hold (один раз за spawn).
  • У дна есть ~0.5 с на последний сдвиг/поворот перед фиксацией.
  • Удержание через 0.15 с начинает быстро повторять шаг.

Итоговая структура и самопроверка

Дерево проекта

tetris/
├── main.py
├── settings.py
├── requirements.txt
└── game/
├── __init__.py
├── tetrominoes.py
├── board.py
├── piece.py
├── hud.py
└── game.py

Полный чек-лист прототипа

#КритерийДа / нет
1Окно 10×20 + боковая панель, стабильные 60 FPS
2Все 7 тетромино с различимыми цветами
3Гравитация, soft drop, hard drop
4Вращение CW/CCW с wall kick у стен
5Фиксация, очистка линий, сдвиг блоков вниз
6Очки NES, линии, рост уровня каждые 10 линий
7NEXT, ghost piece, HUD
8Меню, пауза, game over
9Код в модулях game/*, main.py — только цикл
10(Опционально) 7-bag, hold, lock delay, DAS

Типичные ошибки

СимптомВероятная причинаЧто сделать
Фигура проходит сквозь блокиНет проверки can_place перед сдвигомВсе try_move / try_rotate только через can_place
Двойная фиксация за кадрLock и в гравитации, и в hard dropОдин метод _lock_current(); после hard drop сбросьте таймеры
I-фигура застревает у стеныНет kick ±2Добавьте (-2, 0) и (2, 0) в wall kick
Счёт за линии не растётclear_lines не вызывается до очков_apply_line_score(clear_lines(...)) после каждого lock
NEXT не совпадает со спавномПутают pop и peek в 7-bagСпавн из peek, после lock — pop и новый peek
Фигура «дёргается» на ↓Два таймера гравитацииОдин tick_gravity или один блок в Game.update
Game over при стартеСпавн вне поляПроверьте SPAWN_COL и can_place при reset
Hold без ограниченияНет hold_usedСбрасывайте hold_used = False после lock

Идеи для дальнейшего расширения

УлучшениеСложностьЧто даёт
Звукинизкаяpygame.mixer — rotate, line clear, Tetris, game over
SRS wall kicksвысокаяТаблицы сдвигов для I и JLSTZ
Таблица рекордовнизкаяЛучший счёт в highscore.json
T-spinвысокаяБонус за вращение T в «карман»
МультиplayerвысокаяДва поля, «мусорные» линии сопернику

Связь с историей игр

Tetris показал, что минималистичная механика + нарастающая сложность дают бесконечную реиграбельность. Сетка, таймеры и очередь фигур — тот же каркас, что в Match-3; здесь вы отработали его на каноническом примере рядом с практикумами Battle City и Match3.

Связанные материалы
Аркадный цикл и ввод — в практикуме Python — Ping Pong. Сетка, каскады и match-поиск — в Python — Match3. Общая база Pygame — Разработка игр на Python.


См. также: Практикум разработки игр — о разделе · Разработка игр на Python · Python — Ping Pong.

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").

Освоение главы0%