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

Python — Match3

Разработчику

О практикуме

Вы соберёте классическую головоломку «три в ряд» (Match-3) на Python 3 и Pygame: сетка цветных фишек, выбор и обмен соседних клеток, поиск линий из трёх и более одинаковых, удаление, падение, дозаполнение сверху, каскады и счёт очков.

Материал устроен от общего к частному:

  1. Архитектура — модули, данные, состояния игры.
  2. Зависимости и каркас проекта.
  3. Минимальный запускаемый код — пустое окно.
  4. Этап 0 — логика в консоли — опционально, без Pygame.
  5. Этапы 1–14 — каждый этап добавляет одну механику; после каждого шага проект запускается.
  6. Отладка и горячие клавиши.
  7. Тесты логики поля.
  8. Дальнейшее развитие — подсказки, тупиковое поле, анимация, спец-фишки.
  9. Задания для самостоятельной работы.

База по Pygame и игровому циклу — в Разработка игр на Python. Оглавление практикумов — Практикум разработки игр — о разделе.

Содержание по разделам

РазделО чём
ТерминыSwap, каскад, валидный ход
Входные навыкиPython, списки, циклы
АрхитектураМодули, FSM, сложность алгоритмов
Зависимостиvenv, pygame, типичные сбои
Этапы 1–14Пошаговый код
Отладкаprint_grid, сброс поля
Тестыpytest для find_matches
РасширенияПродакшен-фичи

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


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

МеханикаОписание
ПолеСетка 8×8 (настраивается)
Фишки5–6 типов (цвета)
ХодКлик по фишке → клик по соседу → обмен
ПравилоОбмен разрешён, только если после него есть совпадение
Совпадение3+ подряд по горизонтали или вертикали
КаскадПосле удаления — падение, дозаполнение, повторный поиск совпадений
ОчкиЗа каждую убранную фишку; бонус за длинные линии
СтартПоле без готовых линий из трёх

Ориентир по времени: базовый прототип (этапы 1–12) — 3–6 часов с перерывами; модули, тесты и polish — ещё 2–4 часа.


Термины Match-3

ТерминЗначение в этом практикуме
Фишка (gem)Одна клетка сетки с типом 0 … GEM_TYPES-1
Swap (обмен)Перестановка двух соседних фишек
Валидный ходОбмен, после которого есть хотя бы одно совпадение
Match / совпадениеЛиния из 3 и более одинаковых по строке или столбцу
ClearЗамена совпавших клеток на EMPTY (-1)
ГравитацияСдвиг фишек вниз в каждом столбце
RefillЗаполнение пустых клеток сверху новыми типами
Каскад (combo)Цепочка волн: совпадение → падение → refill → снова совпадение без нового клика
ResolveОдин проход «очистить → упасть → дозаполнить» или весь цикл каскада
Стабильное полеfind_matches возвращает пустое множество

В коммерческих головоломках (Candy Crush, Bejeweled, Homescapes) те же примитивы дополняют целями уровня (собрать N синих), препятствиями (лёд, цепи) и спец-фишками (ракета, бомба). Наш прототип закрывает ядро жанра; расширения — в конце статьи.


Входные навыки

Достаточно основ Python:

  • списки и вложенные списки (grid[row][col]);
  • for, while, if;
  • функции и return;
  • random.randrange;
  • кортежи (row, col) и множества set для координат совпадений.

Pygame можно не знать заранее — минимальный цикл разобран в 312 и повторён в минимальном запуске.

Сначала консоль — по желанию
Если хотите отладить find_matches и каскад без окна — пройдите этап 0, затем переходите к Pygame.


Архитектура

Match-3 удобно делить на модель (что на поле) и представление (как рисуем в Pygame). Ввод и анимации связываются через машину состояний.

Слои

Файлы проекта (целевая структура)

К концу практикума в папке match3/ будут такие файлы:

ФайлОтветственность
config.pyРазмер поля, число типов фишек, цвета, FPS, размер клетки
board.pyСетка, генерация без стартовых матчей, обмен, гравитация, дозаполнение
match_finder.pyПоиск всех клеток, входящих в линии ≥ 3
game.pyСостояния игры, очки, цикл «удалить → упасть → проверить снова»
main.pypygame.init, цикл кадров, события, вызов отрисовки

На первых этапах всё лежит в main.py; на этапе 13 код переносится в модули без изменения поведения.

Модель поля

Поле — двумерный список целых чисел grid[row][col]:

  • 0 … GEM_TYPES-1 — тип фишки (цвет);
  • -1 — пустая клетка (после удаления, до дозаполнения).

Индексация: row = 0верх экрана, col = 0лево. Это совпадает с тем, как мы рисуем прямоугольники в Pygame.

col=0 col=1 col=2
row=0 [0] [1] [2] ← верх экрана (y маленький в Pygame)
row=1 [3] [4] [5]
row=2 [6] [7] [8] ← низ (гравитация тянет сюда)

Клик мыши: col = (mx - PAD) // CELL, row = (my - PAD) // CELL. Сначала столбец, потом строка — как в матрице grid[row][col].

Игровой цикл кадра

В Match-3 на каждом кадре (60 FPS) порядок такой:

ФазаКогдаЧто делаем
СобытияВсегдаpygame.event.get(), выход, мышь
ЛогикаПо клику или в RESOLVINGОбмен, каскад, очки
ОтрисовкаКаждый кадрfill → фишки → счёт → flip
ВремяКонец кадраclock.tick(FPS)

Логику каскада держите одной функцией resolve_board — так проще тестировать и позже вставить анимацию между шагами resolve.

Сложность алгоритмов

На поле ROWS × COLS (у нас 8×8 = 64 клетки):

ОперацияОценкаКомментарий
find_matchesO(ROWS × COLS)Два линейных прохода
swap_produces_matchO(ROWS × COLS)Два swap + два поиска
apply_gravityO(ROWS × COLS)По столбцам
refillO(ROWS × COLS)В худшем случае несколько попыток типа на клетку
resolve_boardO(волны × ROWS × COLS)Волн обычно 1–5 за ход

Для 8×8 перебор всех соседних пар для подсказки (64×4 соседа) тоже мгновенный — оптимизация не нужна, пока поле не станет 20×20.

Машина состояний

СостояниеИгрок можетЛогика
IDLEВыбирать фишки, менять паруЖдём ввод
RESOLVINGНичего (или только смотреть)Удаление, гравитация, каскады
GAME_OVERЗакрыть окноОпционально — нет ходов

Переходы:

IDLE --(валидный swap)--> RESOLVING
RESOLVING --(нет совпадений, поле стабильно)--> IDLE
RESOLVING --(каскад, есть новые матчи)--> RESOLVING

Анимацию сдвига фишек можно добавить позже; в этом практикуме каскад мгновенный — проще отладить логику.

Алгоритмы (кратко)

Поиск совпадений — два прохода по строкам и столбцам: для каждой линии считаем длину серии одинаковых значений; если длина ≥ 3, все клетки серии попадают в множество matched.

Валидный обмен — поменять две соседние клетки местами, вызвать find_matches; если множество пустое — вернуть клетки обратно.

Гравитация — для каждого столбца снизу вверх собираем непустые фишки, записываем вниз столбца, сверху остаются -1.

Дозаполнение — для каждой -1 сверху вниз ставим случайный тип (на старте поля — с проверкой, что не создаём линию из трёх сразу).

Каскад — цикл: matches = find_matches() → если не пусто → очистить → гравитация → refill → снова, пока matches пусто.

Формула очков (этап 12+)

КомпонентФормула в прототипе
За фишку10 очков за клетку в matched
Длинная линия+5 за каждую клетку сверх 3 в сумме len(matched) (упрощённо)
КаскадМножитель 1.0 + 0.5 × (номер_волны - 1)

Пример: первая волна убрала 3 фишки → 3×10 = 30. Вторая волна убрала 4 → (40 + 5) × 1.5 = 67 (округление через int).


Зависимости и каркас проекта

Требования

  • Python 3.10+ (подойдёт 3.11, 3.12);
  • pip;
  • терминал в папке проекта.

Виртуальное окружение (рекомендуется)

mkdir match3
cd match3
python -m venv .venv

Windows (PowerShell):

.\.venv\Scripts\Activate.ps1

Linux / macOS:

source .venv/bin/activate

Установка Pygame

pip install pygame

Проверка:

python -c "import pygame; print(pygame.version.ver)"

Опционально зафиксируйте версию:

pip freeze > requirements.txt

В requirements.txt обычно одна строка, например pygame==2.6.1.

Каркас каталога (пока один файл)

match3/
main.py # пока весь код здесь
requirements.txt # по желанию
test_board.py # после раздела «Тесты»

Если Pygame не ставится или окно не открывается

СимптомЧто попробовать
No module named 'pygame'Активировано ли venv; pip install pygame в том же Python, что запускает python main.py
Чёрное окно и сразу закрытиеЗапуск из терминала — увидите traceback
Windows: ошибка SDL / DLLОбновите Python с python.org; установите Visual C++ Redistributable
macOS: окно не в фокусеРазрешите доступ к вводу для Terminal / IDE
Linux headless (SSH без дисплея)Нужен X11/Wayland или пройдите этап 0

Один интерпретатор
В VS Code / Cursor выберите интерпретатор из match3/.venv, иначе расширение и терминал могут ставить пакеты в разные окружения.


Этап 0 — логика в консоли

Цель — проверить find_matches, обмен и каскад в терминале. Файл board_cli.py в match3/:

import random

ROWS, COLS, GEM_TYPES = 5, 5, 4
EMPTY = -1
CHARS = "RGBY" # символ на экране для типов 0..3


def print_grid(g):
for row in g:
line = ""
for cell in row:
line += "." if cell == EMPTY else CHARS[cell]
print(line)
print()


def find_matches(g):
matched = set()
for row in range(ROWS):
run_type, run_start, run_len = None, 0, 0
for col in range(COLS + 1):
t = g[row][col] if col < COLS else None
if t == run_type and t is not None and t >= 0:
run_len += 1
else:
if run_len >= 3 and run_type is not None:
for c in range(run_start, run_start + run_len):
matched.add((row, c))
run_type, run_start = t, col
run_len = 1 if t is not None and t >= 0 else 0
for col in range(COLS):
run_type, run_start, run_len = None, 0, 0
for row in range(ROWS + 1):
t = g[row][col] if row < ROWS else None
if t == run_type and t is not None and t >= 0:
run_len += 1
else:
if run_len >= 3 and run_type is not None:
for r in range(run_start, run_start + run_len):
matched.add((r, col))
run_type, run_start = t, row
run_len = 1 if t is not None and t >= 0 else 0
return matched


def resolve_once(g):
matched = find_matches(g)
if not matched:
return 0
for r, c in matched:
g[r][c] = EMPTY
# гравитация + refill — скопируйте из этапов 8–9 или упростите:
for col in range(COLS):
col_vals = [g[r][col] for r in range(ROWS) if g[r][col] != EMPTY]
for r in range(ROWS):
g[r][col] = EMPTY
for i, v in enumerate(col_vals):
g[ROWS - len(col_vals) + i][col] = v
for r in range(ROWS):
if g[r][col] == EMPTY:
g[r][col] = random.randrange(GEM_TYPES)
return len(matched)


if __name__ == "__main__":
# тестовое поле: тройка красных в верхней строке
grid = [
[0, 0, 0, 1, 2],
[1, 2, 3, 0, 1],
[2, 3, 0, 1, 2],
[3, 0, 1, 2, 3],
[0, 1, 2, 3, 0],
]
print("До:")
print_grid(grid)
print("Совпадения:", find_matches(grid))
waves = 0
while find_matches(grid):
cleared = resolve_once(grid)
waves += 1
print(f"Волна {waves}, убрано {cleared}")
print_grid(grid)

Запуск: python board_cli.py. Вы должны увидеть исчезновение RRR и падение символов. Тот же find_matches переносится в Pygame на этапе 5.


Минимальный запускаемый код

Перед сеткой убедитесь, что окно открывается и закрывается. Создайте main.py:

import pygame
import sys

pygame.init()

WINDOW_W, WINDOW_H = 640, 720
screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
pygame.display.set_caption("Match-3 — практикум")

clock = pygame.time.Clock()
FPS = 60

BG = (28, 32, 48)

running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False

screen.fill(BG)
pygame.display.flip()
clock.tick(FPS)

pygame.quit()
sys.exit()

Запуск:

python main.py

Ожидаемое поведение: тёмно-синее окно, закрытие крестиком. Это тот же каркас, что в Разработка игр на Python, с фиксированным FPS через clock.tick.

clock.tick(60) ограничивает частоту кадров, а не скорость игры: вся логика Match-3 у нас пошаговая по клику, поэтому отвязка от dt (дельта-времени) на старте не обязательна. Анимации позже понадобят dt = clock.tick(FPS) / 1000.0.


Этап 1 — сетка и отрисовка фишек

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

Замените main.py на:

import pygame
import random
import sys

# --- конфигурация (позже переедет в config.py) ---
COLS, ROWS = 8, 8
GEM_TYPES = 6
CELL = 64
PAD = 40
BOARD_W = COLS * CELL
BOARD_H = ROWS * CELL
WINDOW_W = BOARD_W + PAD * 2
WINDOW_H = BOARD_H + PAD * 2 + 60
FPS = 60

GEM_COLORS = [
(231, 76, 60),
(46, 204, 113),
(52, 152, 219),
(241, 196, 15),
(155, 89, 182),
(230, 126, 34),
]
BG = (28, 32, 48)
GRID_LINE = (45, 52, 72)
SELECT = (255, 255, 255)

pygame.init()
screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
pygame.display.set_caption("Match-3")
clock = pygame.time.Clock()
font = pygame.font.SysFont("consolas", 22)


def new_grid():
return [[random.randrange(GEM_TYPES) for _ in range(COLS)] for _ in range(ROWS)]


grid = new_grid()
selected = None # (row, col) или None


def cell_rect(row, col):
x = PAD + col * CELL
y = PAD + row * CELL
return pygame.Rect(x, y, CELL, CELL)


def draw_board():
for row in range(ROWS):
for col in range(COLS):
rect = cell_rect(row, col)
pygame.draw.rect(screen, GRID_LINE, rect, 1)
inner = rect.inflate(-8, -8)
color = GEM_COLORS[grid[row][col]]
pygame.draw.rect(screen, color, inner, border_radius=10)
if selected is not None:
r, c = selected
pygame.draw.rect(screen, SELECT, cell_rect(r, c), 3)


running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False

screen.fill(BG)
draw_board()
pygame.display.flip()
clock.tick(FPS)

pygame.quit()
sys.exit()

Проверка: восемь рядов разноцветных «конфет», без ошибок в консоли.

Константы CELL и PAD задают геометрию: при смене CELL на 48 пересчитываются WINDOW_W / WINDOW_H. Для слабых машин можно снизить FPS до 30 — логика от этого не меняется.

Вынесите перевод пикселей в клетку в одну функцию — она понадобится и для клика, и для drag:

def pos_to_cell(mx, my):
col = (mx - PAD) // CELL
row = (my - PAD) // CELL
if 0 <= row < ROWS and 0 <= col < COLS:
return row, col
return None

Этап 2 — поле без стартовых троек

Цель — при генерации ни одной готовой линии из трёх.

Добавьте функции перед grid = new_grid():

def creates_match(grid, row, col, gem_type):
"""Создаст ли gem_type в (row,col) горизонтальную или вертикальную тройку."""
if col >= 2 and grid[row][col - 1] == gem_type and grid[row][col - 2] == gem_type:
return True
if row >= 2 and grid[row - 1][col] == gem_type and grid[row - 2][col] == gem_type:
return True
return False


def new_grid():
g = [[0] * COLS for _ in range(ROWS)]
for row in range(ROWS):
for col in range(COLS):
while True:
t = random.randrange(GEM_TYPES)
if not creates_match(g, row, col, t):
g[row][col] = t
break
return g

Удалите старую однострочную версию new_grid с двойным randrange.

Проверка: визуально нет трёх одинаковых подряд ни по строке, ни по столбцу. Перезапустите несколько раз — условие должно сохраняться.


Этап 3 — выбор фишки мышью

Цель — первый клик выделяет клетку, повторный клик по той же снимает выделение.

В цикл событий добавьте:

elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mx, my = event.pos
col = (mx - PAD) // CELL
row = (my - PAD) // CELL
if 0 <= row < ROWS and 0 <= col < COLS:
if selected == (row, col):
selected = None
else:
selected = (row, col)

Проверка: белая рамка перескакивает по клеткам; повторный клик снимает рамку.


Этап 4 — обмен соседних фишек

Цель — второй клик по соседу (вверх/вниз/влево/вправо) меняет две клетки местами; клик по несоседу — новый выбор.

Добавьте:

def are_adjacent(a, b):
(r1, c1), (r2, c2) = a, b
return abs(r1 - r2) + abs(c1 - c2) == 1


def swap_cells(g, a, b):
(r1, c1), (r2, c2) = a, b
g[r1][c1], g[r2][c2] = g[r2][c2], g[r1][c1]

Обработчик мыши замените на:

elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mx, my = event.pos
col = (mx - PAD) // CELL
row = (my - PAD) // CELL
if 0 <= row < ROWS and 0 <= col < COLS:
cell = (row, col)
if selected is None:
selected = cell
elif selected == cell:
selected = None
elif are_adjacent(selected, cell):
swap_cells(grid, selected, cell)
selected = None
else:
selected = cell

Проверка: соседние фишки меняются местами; несоседний клик переносит выделение.

Пока любой обмен
На следующем этапе «плохие» ходы будут откатываться. Сейчас полезно убедиться, что координаты клика и swap_cells работают верно.


Этап 5 — поиск совпадений

Цель — функция возвращает множество координат (row, col), входящих в линии длины ≥ 3.

Добавьте в main.py (или отдельный файл позже):

def find_matches(g):
matched = set()

# горизонтали
for row in range(ROWS):
run_type = None
run_start = 0
run_len = 0
for col in range(COLS + 1):
t = g[row][col] if col < COLS else None
if t == run_type and t is not None and t >= 0:
run_len += 1
else:
if run_len >= 3 and run_type is not None and run_type >= 0:
for c in range(run_start, run_start + run_len):
matched.add((row, c))
run_type = t
run_start = col
run_len = 1 if t is not None and t >= 0 else 0

# вертикали
for col in range(COLS):
run_type = None
run_start = 0
run_len = 0
for row in range(ROWS + 1):
t = g[row][col] if row < ROWS else None
if t == run_type and t is not None and t >= 0:
run_len += 1
else:
if run_len >= 3 and run_type is not None and run_type >= 0:
for r in range(run_start, run_start + run_len):
matched.add((r, col))
run_type = t
run_start = row
run_len = 1 if t is not None and t >= 0 else 0

return matched

Временно подсветите совпадения после каждого кадра (для отладки) — в draw_board после рисования фишки:

if (row, col) in find_matches(grid):
pygame.draw.rect(screen, (255, 255, 255), inner, 2)

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

Проверка: тройка и четвёрка в ряд подсвечиваются; угол «Г» из двух троек подсвечивает все шесть клеток.

Разбор примера на бумаге

Поле 4×4 (цифры — типы фишек):

1 2 1 3
2 1 1 1 ← здесь четыре «1» подряд
3 2 1 2
1 3 2 3

find_matches вернёт все четыре клетки (1,1)…(1,4) по строке 1. Если та же «1» стоит в (0,2) и (2,2), вертикаль не добавится автоматически, пока там нет трёх подряд по столбцу — алгоритм ищет серии отдельно по горизонтали и вертикали, затем объединяет в set.

Пересечение двух троек (форма «Г»):

0 0 0 1
1 0 2 1
1 0 2 1
1 1 1 1

В matched попадут 5 клеток с «0» (горизонталь) и 3 с «1» внизу слева (вертикаль) — всего до 6 уникальных координат в set.


Этап 6 — только валидные ходы

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

def swap_produces_match(g, a, b):
swap_cells(g, a, b)
ok = len(find_matches(g)) > 0
swap_cells(g, a, b)
return ok

В обработчике вместо прямого swap_cells:

elif are_adjacent(selected, cell):
if swap_produces_match(grid, selected, cell):
swap_cells(grid, selected, cell)
selected = None

Уберите отладочную подсветку из draw_board, если мешает.

Проверка: «бессмысленный» обмен визуально не меняет поле; обмен, собирающий линию, срабатывает.


Этап 7 — удаление совпадений

Цель — matched-клетки становятся пустыми (-1).

EMPTY = -1


def clear_matches(g, matched):
for row, col in matched:
g[row][col] = EMPTY

После успешного обмена вызовите (пока без каскада):

if swap_produces_match(grid, selected, cell):
swap_cells(grid, selected, cell)
matched = find_matches(grid)
clear_matches(grid, matched)

В draw_board для EMPTY не рисуйте цветную фишку (останется только сетка).

Проверка: после хода тройка исчезает (пустые клетки).


Этап 8 — гравитация

Цель — фишки в столбце падают вниз, заполняя пустоты.

def apply_gravity(g):
for col in range(COLS):
stack = []
for row in range(ROWS):
if g[row][col] != EMPTY:
stack.append(g[row][col])
for row in range(ROWS - len(stack)):
g[row][col] = EMPTY
offset = ROWS - len(stack)
for i, gem in enumerate(stack):
g[offset + i][col] = gem

После clear_matches добавьте apply_gravity(grid).

Проверка: пустоты снизу, цветные блоки «осели» вниз столбца.


Этап 9 — дозаполнение сверху

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

def refill_column(g, col):
for row in range(ROWS):
if g[row][col] == EMPTY:
while True:
t = random.randrange(GEM_TYPES)
if not creates_match(g, row, col, t):
g[row][col] = t
break


def refill(g):
for col in range(COLS):
refill_column(g, col)

После гравитации: refill(grid).

Проверка: пустое поле после хода снова полностью цветное; сверху появились новые фишки.


Этап 10 — каскады

Цель — пока есть совпадения, повторять удаление → гравитация → дозаполнение; начислять очки за все волны.

score = 0


def resolve_board(g):
global score
total_cleared = 0
cascade = 0
while True:
matched = find_matches(g)
if not matched:
break
cascade += 1
mult = 1 + (cascade - 1) * 0.5 # бонус каскада
total_cleared += len(matched)
score += int(len(matched) * 10 * mult)
clear_matches(g, matched)
apply_gravity(g)
refill(g)
return total_cleared

После валидного обмена вместо ручного clear/gravity/refill:

swap_cells(grid, selected, cell)
resolve_board(grid)

На экране выводите счёт (в основном цикле после draw_board):

label = font.render(f"Очки: {score}", True, (220, 220, 230))
screen.blit(label, (PAD, WINDOW_H - 50))

Проверка: один ход может дать несколько волн исчезновений; счёт растёт быстрее при каскадах.


Этап 11 — машина состояний

Цель — во время RESOLVING игрок не меняет поле; исключаются гонки кликов и каскада.

IDLE = "idle"
RESOLVING = "resolving"
game_state = IDLE

Обработчик мыши оберните:

elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if game_state != IDLE:
continue
# ... остальной код выбора и обмена ...

После успешного обмена:

game_state = RESOLVING
swap_cells(grid, selected, cell)
resolve_board(grid)
game_state = IDLE
selected = None

Если позже добавите анимацию, RESOLVING будет длиться несколько кадров, а resolve_board вызовете один раз в конце анимации.

Проверка: быстрые клики во время каскада не ломают сетку.


Этап 12 — длинные линии и комбо

Цель — больше очков за 4+ в ряд и за каскад.

В resolve_board замените начисление на:

base = 0
for row, col in matched:
base += 10
# удлинённые серии: грубая оценка через размер matched на линии
bonus = max(0, len(matched) - 3) * 5
mult = 1.0 + (cascade - 1) * 0.5
score += int((base + bonus) * mult)

Проверка: четвёрка в ряд даёт заметно больше очков, чем одна тройка.


Отладка и горячие клавиши

Добавьте в проект после этапа 10 — ускоряет поиск багов.

Печать поля в консоль

def debug_print_grid(g):
for row in g:
print(" ".join("." if c == EMPTY else str(c) for c in row))
print()

Вызовите debug_print_grid(grid) сразу после resolve_board — увидите, остались ли -1 или «висящие» типы.

Сброс поля и принудительный каскад

В цикл событий:

elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_r:
grid[:] = new_grid()
score = 0 # или score[0] = 0
selected = None
elif event.key == pygame.K_d:
debug_print_grid(grid)
print("matches:", find_matches(grid))
КлавишаДействие
RНовое случайное поле без стартовых троек
DDump сетки и список совпадений в терминал

Подсветка «есть ли ход»

После resolve_board, если долго нет валидных пар, вызовите has_any_move(grid) из раздела расширений и выведите текст «Нет ходов» на экран.


Тесты логики поля

Вынесите find_matches в match_finder.py, установите pytest: pip install pytest.

Файл test_match_finder.py:

import pytest
from match_finder import find_matches

ROWS, COLS = 8, 8
EMPTY = -1


def empty_grid():
return [[EMPTY] * COLS for _ in range(ROWS)]


def test_horizontal_three():
g = empty_grid()
for c in range(3):
g[0][c] = 2
assert find_matches(g) == {(0, 0), (0, 1), (0, 2)}


def test_vertical_four():
g = empty_grid()
for r in range(4):
g[r][3] = 1
assert len(find_matches(g)) == 4


def test_L_shape_union():
g = empty_grid()
for c in range(3):
g[2][c] = 0
for r in range(3):
g[r][0] = 0
m = find_matches(g)
assert (2, 0) in m
assert len(m) >= 5


def test_empty_ignored():
g = empty_grid()
g[0][0] = g[0][1] = g[0][2] = EMPTY
assert len(find_matches(g)) == 0

Запуск из папки match3/: pytest -q.

Тесты до Pygame
Сначала зелёные тесты на find_matches, потом графика — так вы отделяете баг логики от бага координат клика.


Этап 13 — разбиение по модулям

Цель — тот же код, но читаемая структура из архитектуры.

config.py

COLS, ROWS = 8, 8
GEM_TYPES = 6
CELL = 64
PAD = 40
BOARD_W = COLS * CELL
BOARD_H = ROWS * CELL
WINDOW_W = BOARD_W + PAD * 2
WINDOW_H = BOARD_H + PAD * 2 + 60
FPS = 60
EMPTY = -1

GEM_COLORS = [
(231, 76, 60),
(46, 204, 113),
(52, 152, 219),
(241, 196, 15),
(155, 89, 182),
(230, 126, 34),
]
BG = (28, 32, 48)
GRID_LINE = (45, 52, 72)
SELECT = (255, 255, 255)

match_finder.py

Перенесите find_matches, импортируйте ROWS, COLS, EMPTY из config.

board.py

Перенесите creates_match, new_grid, swap_cells, are_adjacent, swap_produces_match, clear_matches, apply_gravity, refill, refill_column. Импорты: config, find_matches из match_finder.

game.py

from board import (
new_grid,
swap_cells,
swap_produces_match,
resolve_board,
are_adjacent,
)

IDLE = "idle"
RESOLVING = "resolving"


class Game:
def __init__(self):
self.grid = new_grid()
self.score = 0
self.state = IDLE
self.selected = None

def handle_click(self, cell):
if self.state != IDLE:
return
if self.selected is None:
self.selected = cell
return
if self.selected == cell:
self.selected = None
return
if not are_adjacent(self.selected, cell):
self.selected = cell
return
if swap_produces_match(self.grid, self.selected, cell):
self.state = RESOLVING
swap_cells(self.grid, self.selected, cell)
resolve_board(self.grid, self)
self.selected = None
self.state = IDLE
else:
self.selected = None

В board.py сделайте resolve_board(g, game) так, чтобы он увеличивал game.score (или передавайте список [0] как в финальном main.py).

main.py (тонкий)

import pygame
import sys
from config import WINDOW_W, WINDOW_H, FPS, BG, PAD, CELL, ROWS, COLS, GEM_COLORS, GRID_LINE, SELECT
from game import Game

# draw_board принимает game.grid, game.selected
# цикл: events -> game.try_select_or_swap -> draw -> flip

Рефакторинг без поломок
Переносите по одному файлу и запускайте игру после каждого. Сначала config, затем match_finder, затем board, в конце game и упростите main.


Этап 14 — финальная сборка и чек-лист

Ниже — единый main.py для тех, кто прошёл этапы 1–12 и хочет сверить результат (без разбиения на модули). Сохраните как main.py в match3/.

import pygame
import random
import sys

COLS, ROWS = 8, 8
GEM_TYPES = 6
CELL = 64
PAD = 40
WINDOW_W = COLS * CELL + PAD * 2
WINDOW_H = ROWS * CELL + PAD * 2 + 60
FPS = 60
EMPTY = -1

GEM_COLORS = [
(231, 76, 60), (46, 204, 113), (52, 152, 219),
(241, 196, 15), (155, 89, 182), (230, 126, 34),
]
BG = (28, 32, 48)
GRID_LINE = (45, 52, 72)
SELECT = (255, 255, 255)

IDLE, RESOLVING = "idle", "resolving"


def creates_match(g, row, col, gem_type):
if col >= 2 and g[row][col - 1] == gem_type and g[row][col - 2] == gem_type:
return True
if row >= 2 and g[row - 1][col] == gem_type and g[row - 2][col] == gem_type:
return True
return False


def new_grid():
g = [[0] * COLS for _ in range(ROWS)]
for row in range(ROWS):
for col in range(COLS):
while True:
t = random.randrange(GEM_TYPES)
if not creates_match(g, row, col, t):
g[row][col] = t
break
return g


def find_matches(g):
matched = set()
for row in range(ROWS):
run_type, run_start, run_len = None, 0, 0
for col in range(COLS + 1):
t = g[row][col] if col < COLS else None
if t == run_type and t is not None and t >= 0:
run_len += 1
else:
if run_len >= 3 and run_type is not None and run_type >= 0:
for c in range(run_start, run_start + run_len):
matched.add((row, c))
run_type, run_start = t, col
run_len = 1 if t is not None and t >= 0 else 0
for col in range(COLS):
run_type, run_start, run_len = None, 0, 0
for row in range(ROWS + 1):
t = g[row][col] if row < ROWS else None
if t == run_type and t is not None and t >= 0:
run_len += 1
else:
if run_len >= 3 and run_type is not None and run_type >= 0:
for r in range(run_start, run_start + run_len):
matched.add((r, col))
run_type, run_start = t, row
run_len = 1 if t is not None and t >= 0 else 0
return matched


def are_adjacent(a, b):
(r1, c1), (r2, c2) = a, b
return abs(r1 - r2) + abs(c1 - c2) == 1


def swap_cells(g, a, b):
(r1, c1), (r2, c2) = a, b
g[r1][c1], g[r2][c2] = g[r2][c2], g[r1][c1]


def swap_produces_match(g, a, b):
swap_cells(g, a, b)
ok = len(find_matches(g)) > 0
swap_cells(g, a, b)
return ok


def clear_matches(g, matched):
for row, col in matched:
g[row][col] = EMPTY


def apply_gravity(g):
for col in range(COLS):
stack = [g[row][col] for row in range(ROWS) if g[row][col] != EMPTY]
for row in range(ROWS - len(stack)):
g[row][col] = EMPTY
for i, gem in enumerate(stack):
g[ROWS - len(stack) + i][col] = gem


def refill(g):
for col in range(COLS):
for row in range(ROWS):
if g[row][col] == EMPTY:
while True:
t = random.randrange(GEM_TYPES)
if not creates_match(g, row, col, t):
g[row][col] = t
break


def resolve_board(g, score_holder):
cascade = 0
while True:
matched = find_matches(g)
if not matched:
break
cascade += 1
mult = 1.0 + (cascade - 1) * 0.5
base = len(matched) * 10 + max(0, len(matched) - 3) * 5
score_holder[0] += int(base * mult)
clear_matches(g, matched)
apply_gravity(g)
refill(g)


pygame.init()
screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
pygame.display.set_caption("Match-3")
clock = pygame.time.Clock()
font = pygame.font.SysFont("consolas", 22)

grid = new_grid()
selected = None
game_state = IDLE
score = [0]


def cell_rect(row, col):
return pygame.Rect(PAD + col * CELL, PAD + row * CELL, CELL, CELL)


def draw_board():
for row in range(ROWS):
for col in range(COLS):
rect = cell_rect(row, col)
pygame.draw.rect(screen, GRID_LINE, rect, 1)
if grid[row][col] == EMPTY:
continue
inner = rect.inflate(-8, -8)
pygame.draw.rect(screen, GEM_COLORS[grid[row][col]], inner, border_radius=10)
if selected:
pygame.draw.rect(screen, SELECT, cell_rect(*selected), 3)


running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and game_state == IDLE:
mx, my = event.pos
col = (mx - PAD) // CELL
row = (my - PAD) // CELL
if 0 <= row < ROWS and 0 <= col < COLS:
cell = (row, col)
if selected is None:
selected = cell
elif selected == cell:
selected = None
elif are_adjacent(selected, cell):
if swap_produces_match(grid, selected, cell):
game_state = RESOLVING
swap_cells(grid, selected, cell)
resolve_board(grid, score)
selected = None
game_state = IDLE
else:
selected = None
else:
selected = cell

screen.fill(BG)
draw_board()
screen.blit(font.render(f"Очки: {score[0]}", True, (220, 220, 230)), (PAD, WINDOW_H - 50))
pygame.display.flip()
clock.tick(FPS)

pygame.quit()
sys.exit()

Чек-лист самопроверки

#ПроверкаОжидание
1Стартовое полеНет готовых троек
2Обмен не соседейТолько смена выделения
3Обмен без матчаПоле не меняется
4Обмен с матчемТройка+ исчезает
5КаскадНовые тройки после падения дают вторую волну
6СчётРастёт при каждой волне
7Быстрые кликиСетка остаётся согласованной
8R / DНовое поле и dump в консоль (отладка)
9pytestТесты из раздела тестов проходят

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

СимптомПричинаЧто сделать
Клик «мимо» клетокНеверные PAD / CELLПересчитайте (mx - PAD) // CELL
Все обмены запрещеныfind_matches до обмена видит старые линииПроверьте откат в swap_produces_match
Дыры не падаютГравитация сверху внизПустые должны оказаться вверху столбца
Бесконечный каскадrefill создаёт только тройкиИспользуйте creates_match при дозаполнении
Счёт скачетresolve вызывается дважды за кликОдин вызов после валидного swap
Подсветка не тамrow/col перепутаны с x/yИспользуйте pos_to_cell; grid[row][col]
creates_match в refillПроверяются только 2 соседа слева/сверхуДостаточно для дозаполнения сверху вниз по столбцу

Карта этапов (шпаргалка)

1 сетка → 2 без троек → 3 выбор → 4 swap → 5 find_matches
→ 6 валидный ход → 7 clear → 8 gravity → 9 refill
→ 10 каскад+очки → 11 FSM → 12 бонусы → 13 модули → 14 финал

Дальнейшее развитие

После этапа 14 прототип играбелен. Ниже — готовые куски кода для типичных улучшений; внедряйте по одному, после каждого запускайте игру и прогоняйте pytest.

НаправлениеСложностьРаздел
Подсказка★☆☆Подсказка хода
Нет ходов★★☆Проверка ходов
Счётчик ходов / цель★★☆Уровень по очкам
Анимация swap★★★Анимация
Спец-фишки★★★Спец-фишки
Звук и сохранение★★☆Звук и save

Соседние практикумы на Pygame — Ping Pong, Tetris; там те же приёмы цикла и состояния, другая игровая логика.

Подсказка хода

Перебор всех соседних пар на поле:

def find_hint(g):
for row in range(ROWS):
for col in range(COLS):
a = (row, col)
for dr, dc in ((0, 1), (1, 0)):
r2, c2 = row + dr, col + dc
if r2 < ROWS and c2 < COLS:
b = (r2, c2)
if swap_produces_match(g, a, b):
return a, b
return None

По клавише H подсветите обе клетки из find_hint(grid) жёлтым контуром. В коммерческих играх таймер бездействия запускает подсказку автоматически.

Проверка есть ли ходы

def has_any_move(g):
return find_hint(g) is not None

При старте уровня и после каждого resolve_board, если not has_any_move(grid) — перемешайте поле (grid[:] = new_grid()) или покажите экран «Нет ходов». Иначе игрок застрянет на случайной раскладке.

Гарантия «всегда есть ход» на больших полях достигается генераторами уровней; для учебного 8×8 достаточно перегенерации.

Уровень по очкам

TARGET_SCORE = 500
moves_left = 25

# после resolve_board:
moves_left -= 1
if score[0] >= TARGET_SCORE:
show_win = True
elif moves_left <= 0:
show_lose = True

Отрисуйте Цель: 500 и Ходы: {moves_left} под полем. Так появляется пазл с ограничением, как в мобильных Match-3.

Анимация обмена и падения

Идея: во время RESOLVING не вызывать полный resolve_board сразу, а разбить на шаги:

  1. Swap — 8–12 кадров: интерполяция позиции двух фишек (t от 0 до 1, pos = start + t * (end - start)).
  2. Clear — уменьшение alpha или scale matched-клеток 6 кадров, затем clear_matches.
  3. Fall — для каждой фишки, сменившей row, анимировать offset_y от старой строки к новой.
  4. Refill — новые фишки появляются сверху с offset_y < 0, опускаются на место.

Псевдокод lerp для обмена:

def lerp(a, b, t):
return a + (b - a) * t

# в update анимации:
t = min(1.0, elapsed_ms / 150.0)
draw_gem_at_pixel(lerp(x1, x2, t), lerp(y1, y2, t), gem_type)

Когда t >= 1, зафиксируйте swap в grid и переходите к следующей фазе. Полный resolve_board остаётся «математикой»; анимация только визуализирует те же шаги.

Спец-фишки 4 и 5 в ряд

Расширьте find_matches, чтобы возвращать не только set координат, но и тип бонуса для длинных серий:

Длина серииБонус (пример)
4ROCKET — очистить всю строку или столбец
5BOMB — очистить крест 3×3
6+RAINBOW — убрать все фишки одного типа с поля

В grid храните отрицательные коды или отдельную матрицу special[row][col]. При clear_matches если клетка — ракета, добавьте в matched все клетки строки.

Минимальный шаг: при len(horizontal_run) == 4 пометьте центральную клетку типом SPECIAL_ROCKET = 100 и при следующем включении в match очистите весь row.

Звук и сохранение

# после pygame.init()
pygame.mixer.init()
snd_match = pygame.mixer.Sound("match.wav") # короткий wav в папке проекта

# в resolve_board при len(matched) > 0:
snd_match.play()

Сохранение в save.json:

import json

def save_game(grid, score):
with open("save.json", "w", encoding="utf-8") as f:
json.dump({"grid": grid, "score": score}, f)

def load_game():
with open("save.json", encoding="utf-8") as f:
data = json.load(f)
return data["grid"], data["score"]

По S / L — save / load в обработчике KEYDOWN.

Свайп мышью (drag)

Вместо двух кликов: на MOUSEBUTTONDOWN запомнить клетку, на MOUSEBUTTONUP вычислить смещение; если |dx|+|dy| == CELL, сосед определён по знаку смещения. Так ближе к мобильному UX.

Сравнение с «большими» Match-3

ФичаНаш прототипCandy Crush / аналоги
Валидный swapДаДа
КаскадДаДа + FX
Цели уровняОпциональноСбор цветов, очистка плитки
ПрепятствияНетЖеле, блокеры
МонетизацияНетЖизни, бустеры
Уровень-дизайнСлучайная сеткаРучные / процедурные карты

Ядро совпадает; отличия — контент и мета-игра.


Задания для самостоятельной работы

#ЗаданиеКритерий готовности
1Поле 6×6 и 5 цветовИграбельно, тесты зелёные
2Клавиша H — подсказкаПодсвечивается валидная пара
3Счётчик каскада на экране«Комбо x3» при третьей волне
4Запрет обмена во время анимацииFSM + таймер 150 ms
5Спец-фишка за 4 в рядОчищает строку
6has_any_move + перетасовкаНет «мёртвых» полей
7Уровень «500 очков за 20 ходов»Экран победы / поражения
8Спрайты PNG вместо прямоугольниковpygame.image.load + blit

Портфолио
Выложите match3/ на GitHub с README (скриншот, pip install -r requirements.txt, python main.py) — готовый пункт в портфолио junior game dev.


См. также


См. также

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

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