Python — Tetris
О практикуме
Tetris (тетрис) — классическая головоломка, придуманная Алексеем Пажитновым в 1984 году в Москве. Из падающих фигур из четырёх клеток (тетромино) нужно заполнять ряды на поле шириной 10 клеток. Полная горизонтальная линия исчезает, верхние блоки опускаются, за это начисляются очки. Скорость падения растёт с уровнем; игра заканчивается, когда новая фигура не помещается у верхней границы.
Название происходит от греческого tetra («четыре») и «теннис» — любимой игры автора. С 1980-х Tetris стал эталоном простых правил и глубокого skill ceiling: за десять минут можно понять механику, а годами оттачивать скорость, предвидение и работу с очередью фигур.
В этом практикуме соберём полноценный прототип на Python 3 и Pygame — без спрайтов из оригинала, на цветных квадратах, зато с разбором сетки, вращения, очистки линий, очков, уровней, «призрака», 7-bag-рандома, hold и экранов меню.
Управление в финальной версии
| Клавиша | Действие |
|---|---|
← / → | Сдвиг фигуры влево / вправо |
↓ | Ускоренное падение (soft drop) |
↑ или X | Поворот по часовой стрелке |
Z | Поворот против часовой стрелки |
Пробел | Мгновенный сброс (hard drop) |
C | Hold — отложить фигуру (этап 20) |
P | Пауза |
R | Перезапуск после game over |
Esc | Выход |
Маршрут чтения
- Архитектура — как устроен проект до первой строки кода.
- Зависимости и структура папок — окружение и файлы.
- Этап 0 — минимальный запуск — чёрное окно и игровой цикл.
- Этапы 1–18 — по одной механике за шаг.
- Этапы 19–20 — продвинутые улучшения (7-bag, hold, lock delay, DAS).
- Итоговая структура и самопроверка.
Оглавление этапов
| Этап | Тема |
|---|---|
| 0 | Минимальное окно |
| 1 | settings.py |
| 2 | Поле и сетка |
| 3 | Формы тетромино |
| 4 | Класс Piece |
| 5 | Сетка board |
| 6 | Гравитация |
| 7 | Движение ← / → |
| 8 | Вращение |
| 9 | Soft drop |
| 10 | Фиксация |
| 11 | Hard drop |
| 12 | Очистка линий |
| 13 | Очки и уровни |
| 14 | NEXT |
| 15 | Ghost piece |
| 16 | HUD и состояния |
| 17 | Модули game/ |
| 18 | Класс Game |
| 19 | 7-bag randomizer |
| 20 | Hold, 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 порядок шагов важен — сначала ввод и логика, потом отрисовка.
На каждом кадре внутри обновления выполняется цепочка:
- Прочитать нажатые клавиши (движение, поворот, hard drop).
- Сдвинуть активную фигуру по таймеру гравитации (или быстрее при soft drop).
- Проверить столкновения с границами поля и зафиксированными блоками.
- При «приземлении» — записать клетки фигуры в сетку
board. - Найти и удалить полные горизонтальные линии, сдвинуть верхние ряды вниз.
- Обновить счёт, линии и уровень; ускорить гравитацию.
- Создать новую фигуру из очереди; при переполнении верха — 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)
■ ■ ■ · ■ · · · · ■
■ · · ■ ■ ■ ■ ■ ■ ■ ■
| Фигура | Клеток | Особенность при вращении |
|---|---|---|
| I | 4 | Единственная «палка»; нужны wall kick ±2 у стены |
| O | 4 | Не меняет форму — поворот можно пропустить |
| T | 4 | Центр вращения — «головка» буквы T |
| S, Z | 4 | Зеркальные парами; часто путают новички |
| J, L | 4 | Уголок влево / вправо |
Точка спавна
Новая фигура появляется над видимым полем, якорь в (SPAWN_COL, SPAWN_ROW). Для ширины 10 стандартный столбец — 4 или 5 (центр). Фигура I в горизонтали шире остальных — при спавне проверяйте can_place, иначе I иногда «вылезает» за правую стену.
SPAWN_COL = 4
SPAWN_ROW = 0 # верхний ряд; часть клеток может быть с row < 0 в Guideline — у нас упрощённо 0
Слои приложения
| Слой | Ответственность | Примеры сущностей |
|---|---|---|
| Ввод | Клавиши, пауза, перезапуск | 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):
- Вычислить абсолютные координаты
col = ax + dx,row = ay + dy. - Если
col < 0илиcol >= COLS— стена, место занято. - Если
row >= ROWS— пол, место занято. - Если
row >= 0иboard[row][col] != 0— столкновение с застывшим блоком. - Если
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.
Конечный автомат состояний
Рекомендуемые константы
| Константа | Значение | Смысл |
|---|---|---|
COLS | 10 | Ширина поля в клетках |
ROWS | 20 | Высота видимого поля |
CELL_SIZE | 30 | Размер клетки в пикселях |
SIDEBAR_W | 160 | Ширина панели справа |
MARGIN | 24 | Отступ от края окна |
FPS | 60 | Кадров в секунду |
LINES_PER_LEVEL | 10 | Линий до следующего уровня |
Скорость падения (интервал между автоматическими шагами вниз, в секундах) уменьшается с уровнем:
def gravity_interval(level):
# level 0 → ~0.8 с, level 9 → ~0.1 с (упрощённая таблица)
return max(0.05, 0.8 - level * 0.07)
Таблица очков (стиль NES)
| Линий за раз | Базовые очки | С множителем уровня |
|---|---|---|
| 1 (Single) | 40 | 40 × (level + 1) |
| 2 (Double) | 100 | 100 × (level + 1) |
| 3 (Triple) | 300 | 300 × (level + 1) |
| 4 (Tetris) | 1200 | 1200 × (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.
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)
Самопроверка
-
←/→двигают фигуру по клеткам. - У стен и застылого «пола» фигура не проходит сквозь блоки.
Этап 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)
Временно выведите счёт в угол:
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.Самопроверка
- После приземления 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)
| Уровень | Интервал падения, с | Комментарий |
|---|---|---|
| 0 | 0.80 | Медленный старт для обучения |
| 1 | 0.73 | После 10 линий |
| 5 | 0.45 | Заметное ускорение |
| 9 | 0.17 | Высокий темп |
| 15+ | 0.05 | Потолок — дальше не ускоряем |
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_timer — move 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 линий | |
| 7 | NEXT, 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 · Python — Ping Pong.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Пошаговый практикум — Battle City на Python и Pygame: архитектура, 16 этапов, полные листинги, сравнение с NES-оригиналом, отладка и расширения. Пошаговый практикум Match-3 на Python и Pygame — архитектура, 14 этапов, консольный прототип, отладка, тесты, подсказки, анимация и спец-фишки. Пошаговый практикум — аркада Ping Pong (Pong) на Python и Pygame: архитектура, баланс, зависимости, 14 этапов до прототипа, бонус — substeps и звук. Пошаговый практикум — гоночная мини-игра на Python и Pygame: архитектура, физика, зависимости, 16 этапов до заезда с кругами, таймером, соперниками и полировкой. Пошаговый практикум — hack and slash в духе Diablo на Python и Pygame: архитектура, гейм-дизайн, зависимости, 18 обязательных этапов и 4 бонусных до полноценного ARPG-прототипа. Пошаговый практикум — карточный roguelike на Python и Pygame: архитектура, формулы боя, 17 этапов кода, моддинг JSON и сверка с AutoBattler (Тени Шпиля). Пошаговый практикум — survivor-like в духе Vampire Survivors на Java (Swing, Java2D): архитектура, гейм-дизайн, Maven, 18 этапов с полным кодом ключевых систем и карта расширений до Java Survivors. Пошаговый практикум — карточный roguelike в браузере на TypeScript, React и Vite: архитектура, dispatch, 16 этапов, cardEffects, PWA и деплой. Эталон — OnlineCardGame («Приключения Урала Батыра»).Python — Battle City
Python — Match3
Python — Ping Pong
Python — Racing
Python — диаблоид
Python — карточная стратегия
Java — Java Survivors
TypeScript — OnlineCardGame