Python — Match3
Разработчику
О практикуме
Match-3 ("три в ряд") — жанр головоломок, где игрок меняет местами соседние элементы на сетке, чтобы собрать линию из трёх и более одинаковых. После совпадения элементы исчезают, остальные падают вниз, сверху появляются новые — и иногда возникает каскад без дополнительного хода. Так устроены Bejeweled, Candy Crush и сотни клонов; в разделе о компьютерных играх этот жанр относят к casual / puzzle.
В этом практикуме вы соберёте полированный прототип на Python 3 и Pygame — в одном файле match3.py. Не учебные прямоугольники, а:
- многоугольные камни пяти типов (рубин, сапфир, изумруд, топаз, аметист);
- рамка поля, градиентный фон, шапка со счётом и подвал с подсказкой;
- анимированный обмен, удаление, падение и дозаполнение;
- drag-and-drop и выбор двумя кликами;
- частицы, всплывающий текст, комбо, лёгкая тряска экрана.
Особый акцент — геометрия окна. В Match-3 ошибка на один пиксель в PLAY_OX или CELL_GAP даёт эффект "клик мимо клетки". Здесь все размеры выводятся из формул, а координаты "экран → поле → локальная Surface доски" согласованы заранее.
Нужны базовые Python (списки, классы, for/while) и общее представление об игровом цикле Pygame из статьи Разработка игр на Python. Полезна, но не обязательна, змейка или Pong в Lab — там те же Rect, события мыши и clock.tick. Каждый этап даёт запускаемый код: после шага игра открывается и показывает новую механику.
Упрощённый прототип (цветные квадраты, мгновенный каскад, два клика) хорош, чтобы понять find_matches за вечер. Здесь мы идём дальше — анимации как в мобильных головоломках, предрендер графики, класс Match3Game и отточенная вёрстка. Логику каскада можно сравнить с практикумом Python — Tetris (сетка, гравитация, заполнение пустот).
Как проходить практикум
- Прочитайте Практикум разработки игр — о разделе и подготовьте окружение — зависимости.
- Идите по этапам 0–17 строго по порядку: после каждого шага обновляйте
match3.pyи запускайтеpython match3.py. - В конце каждого этапа отметьте пункты Самопроверка; если поведение расходится — сверьтесь с полной ревизией на GitHub.
- Прочитайте блок Разбор под этапом — там объясняется, зачем нужны ключевые строки, а не только "что вставить".
Перед стартом проверьте
- Python 3.10+ (
python --version). - Терминал открыт в папке
match3/, активирован venv. - Прочитан раздел игровой цикл и знакомство с
pygame.Rect.
Что получится
| Механика | Описание |
|---|---|
| Поле | Сетка 8×8, 5 типов камней |
| Ход | Drag к соседу или два клика по соседним клеткам |
| Правило | Обмен только если после него есть совпадение; иначе откат с анимацией |
| Каскад | Удаление → падение → дозаполнение → повтор без нового клика |
| FX | Частицы, всплывающий счёт, комбо ×N, тряска экрана |
| UI | Шапка с очками, подвал с мигающей подсказкой |
Оценка времени — 6–10 часов при прохождении всех этапов; этапы 0–8 можно уложить в один вечер (~2,5 ч).
Управление в финальной версии
| Действие | Поведение |
|---|---|
| ЛКМ + drag | Потянуть камень к соседней клетке |
| Два клика | Выбрать камень, кликнуть соседа — обмен |
| Крестик | Выход |
Маршрут чтения
- Архитектура — слои, координаты, цикл кадра, словарь терминов.
- Зависимости — venv, pygame, типичные сбои.
- Этапы 0–17 — по одной подсистеме за шаг, с разбором кода.
- Полная ревизия на GitHub — эталонный
match3.pyдля копирования. - Отладка и дальнейшее развитие.
Словарь перед кодом
| Термин | Определение |
|---|---|
| Клетка (tile) | Одна позиция сетки 8×8; рисуется фон _draw_cell и поверх — камень |
| Камень (gem) | Элемент головоломки; тип 1…5, у каждого своя форма и цвет |
| Swap (обмен) | Перестановка двух соседних камней (по вертикали или горизонтали) |
| Match (совпадение) | Три и более одинаковых камня подряд в строке или столбце |
| Валидный ход | Swap, после которого на поле есть хотя бы один match |
| Каскад | Цепочка: match → удаление → падение → дозаполнение → снова match без нового клика |
| Комбо | Счётчик волн удаления подряд; влияет на бонус к очкам |
| is_busy | Флаг "идёт анимация"; пока True, ввод мыши блокируется |
| Предрендер | Графика (фон, рамка, камни) рисуется один раз в Surface и потом только blit |
Карта этапов
| Этап | Фокус | Новое поведение |
|---|---|---|
| 0 | Цикл Pygame | Окно нужного размера |
| 1 | Геометрия | Константы, grid_to_pixel, рамка |
| 2 | Фон | Градиент, make_board_frame |
| 3 | Клетки | _draw_cell, сетка на доске |
| 4 | Камни | gem_polygon, render_gem, кэш |
| 5 | Модель | Match3Game, поле без стартовых троек |
| 6 | Совпадения | find_matches, has_match_at |
| 7 | Клики | Выбор и обмен (мгновенный) |
| 8 | Валидность | try_swap, откат без матча |
| 9 | Анимация swap | ease_out_cubic, start_swap_animation |
| 10 | Удаление | removing_tiles, частицы |
| 11 | Падение | falling_tiles, гравитация |
| 12 | Каскад | update как машина состояний, is_busy |
| 13 | Drag | on_mouse_down/move/up |
| 14 | Очки | Комбо, FloatingText, тряска |
| 15 | UI | Шапка, подвал, BackgroundGlow |
| 16 | Полировка | Idle bob, кольцо выбора, hover |
| 17 | Финал | Сборка и чек-лист |
Весь проект живёт в match3.py — как в эталоне. Не разносите по модулям, пока не пройдёте практикум: так проще сверять этапы.
Архитектура
Прежде чем писать код, зафиксируем что из чего состоит и как данные текут по кадру. Match-3 удобно мыслить как разделение модели (числа в grid) и представления (как это рисуется). Анимации — третий слой: они не меняют правила мгновенно, а отображают уже принятое решение логики.
Модель и представление
| Слой | Где в коде | Отвечает на вопрос |
|---|---|---|
| Модель | Match3Game.grid, find_matches, try_swap |
Что лежит в клетках? Есть ли match? Можно ли ход? |
| Анимация | swap_anim, removing_tiles, falling_tiles |
Как плавно показать уже решённый шаг? |
| Представление | draw, BG_SURFACE, GEM_CACHE |
Как это выглядит на экране? |
Модель не должна зависеть от FPS: swap в grid выполняется сразу, а игрок видит движение 11 кадров. Так проще отлаживать: можно временно отключить анимации и проверить только find_matches.
Слои (схема)
flowchart TB
subgraph render [Отрисовка]
BG[BG_SURFACE + BackgroundGlow]
Frame[FRAME_SURFACE]
Board[локальная Surface доски]
Gems[GEM_CACHE + draw_gem]
FX[Particle + FloatingText]
end
subgraph logic [Match3Game]
Grid[grid y x — int 0..5]
Match[find_matches]
Swap[try_swap + swap_anim]
Fall[falling_tiles + gravity]
Remove[removing_tiles]
end
subgraph input [Ввод]
Drag[drag start / move / up]
Click[два клика]
end
input --> logic
logic --> render
Координатная система
В Pygame начало координат (0, 0) — левый верх экрана, ось X растёт вправо, ось Y — вниз. Это совпадает с индексами grid[row][col]: row = 0 — верхняя строка поля.
Игра использует три системы координат одновременно:
- Экранные — мышь
event.pos, шапка, подвал,grid_to_pixel. - Позиция доски —
(BOARD_OX, BOARD_OY); сюда кладётсяFRAME_SURFACEи локальная Surface. - Локальные —
(0, 0)в левом верху Surface размеромBOARD_W × BOARD_H;tile_rect_localсчитает только их.
Зачем два вида координат для сетки? Drag считает смещение в пикселях экрана (grid_to_pixel), а отрисовка камней идёт на отдельной Surface доски, которую потом одним blit накладывают на экран (вместе с тряской). Локальные координаты не сбрасываются при shake_x / shake_y.
Экран (WIDTH × HEIGHT)
┌─ HEADER_H ─────────────────────────────┐
│ Match-3 [ ОЧКИ pill ] │
├─ FRAME ────────────────────────────────┤
│ ┌─ BOARD_W × BOARD_H ─────────────┐ │
│ │ GRID_INSET │ │
│ │ ┌─ BOARD_INNER ─────────────┐ │ │
│ │ │ 8×8 клеток TILE_SIZE │ │ │ ← PLAY_OX, PLAY_OY (экран)
│ │ │ с зазором CELL_GAP │ │ │
│ │ └───────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
├─ FOOTER_H ─────────────────────────────┤
│ подсказка │
└────────────────────────────────────────┘
| Константа | Значение | Смысл |
|---|---|---|
TILE_SIZE |
58 | Ширина/высота камня |
CELL_GAP |
5 | Зазор между клетками |
BOARD_PAD |
18 | Внутренний отступ доски |
FRAME |
14 | Толщина внешней рамки окна |
GRID_INSET |
BOARD_PAD + FRAME // 2 - 3 |
Сдвиг сетки внутри рамки (подогнан) |
PLAY_OX, PLAY_OY |
экранные | Левый верх игровой сетки |
LOCAL_PLAY_X/Y |
локальные | То же на Surface доски |
Формула ширины сетки (без отступов рамки):
BOARD_INNER = GRID_SIZE * TILE_SIZE + (GRID_SIZE - 1) * CELL_GAP
Пример для эталона: 8 * 58 + 7 * 5 = 464 + 35 = 499 px — столько занимает только клетки и зазоры.
Hit-test мыши (перевод пикселя в индексы):
col = (mx - PLAY_OX) // (TILE_SIZE + CELL_GAP)
row = (my - PLAY_OY) // (TILE_SIZE + CELL_GAP)
Шаг (TILE_SIZE + CELL_GAP) — "шаг сетки": камень плюс зазор до следующей клетки. Целочисленное деление // даёт номер клетки; дробная часть отбрасывается — клик anywhere внутри клетки попадает в неё.
Путать col, row с x, y экрана. В эталоне get_tile_pos возвращает (col, row), а в grid индексация grid[row][col] — сначала строка, потом столбец. Тот же порядок, что в матрицах и в Tetris.
Модель поля
grid[row][col] — двумерный список целых:
0— пустая клетка (после удаления, до приземления нового камня);1 … 5— тип камня.
Почему типы начинаются с 1, а не с 0? Ноль зарезервирован под "пусто" — одно условие if color == 0 в find_matches и отрисовке.
Индексация — grid[y][x], где y — строка (0 сверху), x — столбец (0 слева).
x=0 x=1 x=2
y=0 [1] [3] [2] ← верх экрана
y=1 [5] [1] [4]
y=2 [2] [2] [2] ← тройка изумрудов (тип 3)
Списки анимаций вместо FSM
Вместо явных состояний IDLE / SWAP / FALL эталон использует списки активных анимаций:
swap_anim— один обмен илиNone;removing_tiles— клетки, которые исчезают;falling_tiles— камни в движении по столбцу.
Метод is_busy() возвращает True, если любой из этих объектов не пуст. Это проще расширять, чем большой switch по enum — см. сравнение с машиной состояний в Ping Pong.
Игровой цикл кадра
sequenceDiagram
participant M as main
participant G as Match3Game
M->>G: on_mouse_* (если не is_busy)
M->>G: update()
Note over G: swap → remove → fall → cascade
M->>G: draw(screen)
M->>M: display.flip()
Пока is_busy() — ввод блокируется. Порядок в main тот же, что в игровом цикле Pygame:
clock.tick(FPS)— ограничение 60 кадров в секунду.- Обработка событий (
QUIT, мышь). game.update()— анимации и каскадная логика.game.draw(screen)— отрисовка.pygame.display.flip()— показ кадра.
Термины в коде эталона
| Термин | Где | Поведение |
|---|---|---|
| Swap | try_swap, swap_anim |
Обмен соседей; при отсутствии match — откат с обратной анимацией |
| Match | find_matches |
Список линий; каждая линия — список (y, x) |
| Каскад | конец update |
После стабилизации анимаций снова find_matches или гравитация |
| Комбо | start_removal_animation |
combo += 1; бонус 10 * combo к очкам; сброс после apply_gravity_with_animation |
| is_busy | ввод + hover | Блокирует клики и drag |
Зависимости и каркас проекта
Требования
- Python 3.10+ (подойдёт 3.11, 3.12);
- pip;
- Pygame 2.x — нужны скруглённые прямоугольники
border_radiusвpygame.draw.rectи стабильная работаSRCALPHAдля полупрозрачности.
Виртуальное окружение
Изолированный venv не даёт смешать пакеты системы и проекта — типичная причина No module named 'pygame' при запуске из IDE.
mkdir match3
cd match3
python -m venv .venv
Windows (PowerShell):
.\.venv\Scripts\Activate.ps1
pip install pygame
Linux / macOS:
source .venv/bin/activate
pip install pygame
Проверка:
python -c "import pygame; print(pygame.version.ver)"
Опционально зафиксируйте версию:
pip freeze > requirements.txt
Структура каталога
match3/
match3.py # весь код практикума
requirements.txt # по желанию
Если что-то не запускается
| Симптом | Вероятная причина | Что сделать |
|---|---|---|
No module named 'pygame' |
venv не активирован или другой Python | Активируйте .venv; в Cursor выберите интерпретатор из match3/.venv |
| Окно мигает и закрывается | Ошибка в коде | Запускайте из терминала — увидите traceback |
| Windows, ошибка SDL / DLL | Нет VC++ runtime | Установите Visual C++ Redistributable |
border_radius не работает |
Старый Pygame | pip install -U pygame |
| Linux без дисплея | Нет X11/Wayland | Запускайте локально с монитором или пропустите этапы 0–4 и начните с логики из этапа 5 |
Терминал, кнопка Run и расширение Python в IDE должны указывать на один и тот же Python из match3/.venv.
Этап 0. Минимальный запуск
Цель — окно с уже рассчитанным размером финальной игры и стабильный цикл 60 FPS.
На этом шаге мы не берём произвольные 640×480. Размер окна выводится из сетки — шапка, поле и подвал уже на своих местах, и на этапе 1 не придётся переделывать layout.
Что нового по сравнению с "голым" Pygame
- константы
GRID_SIZE,TILE_SIZE,CELL_GAPзадают сетку; HEADER_HиFOOTER_Hрезервируют место под UI;WIDTH/HEIGHT— итоговый размер окна.
Создайте match3.py:
Самопроверка
- Окно ~534×652 px (не стандартное 640×480).
- Закрывается крестиком без traceback.
Разбор этапа 0
| Строка / блок | Зачем |
|---|---|
BOARD_INNER = GRID_SIZE * TILE_SIZE + (GRID_SIZE - 1) * CELL_GAP |
Ширина/высота игровой сетки с зазорами |
BOARD_W = BOARD_INNER + BOARD_PAD * 2 |
Добавляем внутренний отступ доски |
WIDTH = BOARD_W + FRAME * 2 |
Рамка окна слева и справа |
HEIGHT = HEADER_H + BOARD_H + FRAME * 2 + FOOTER_H |
Шапка + доска + рамка + подвал |
clock.tick(FPS) |
Ограничение 60 кадров/с; без него цикл крутится на 100% CPU |
pygame.display.flip() |
Показ нарисованного кадра (double buffering) |
Каркас while running → события → рисование → flip повторяется во всех играх на Pygame — см. Разработка игр на Python.
Этап 1. Геометрия и перевод координат
Цель — зафиксировать константы раскладки и две функции перевода координат.
Это ключевой этап практикума. Ошибка здесь даёт "клики мимо", drag не попадает в соседа, камни не по центру ячеек.
Две функции — два контекста
grid_to_pixel(col, row)— экранные пиксели (drag, отладка);tile_rect_local(col, row)— локальныйpygame.Rectна Surface доски (отрисовка).
Добавьте после расчёта HEIGHT:
В цикл отрисовки — нарисуйте контур игровой зоны (временная отладка):
pygame.draw.rect(screen, (200, 170, 255), (PLAY_OX, PLAY_OY, BOARD_INNER, BOARD_INNER), 2)
for row in range(GRID_SIZE):
for col in range(GRID_SIZE):
px, py = grid_to_pixel(col, row)
pygame.draw.rect(screen, (90, 60, 140), (px, py, TILE_SIZE, TILE_SIZE), 1)
Самопроверка
- 8×8 клеток с равномерным зазором 5 px.
- Сетка не прилипает к краям окна — есть шапка и подвал.
Разбор GRID_INSET
BOARD_PAD— отступ контента внутри Surface рамки (BOARD_W × BOARD_H).FRAME— поля окна вокруг доски (между краем окна и Surface).BOARD_OX = FRAME,BOARD_OY = HEADER_H + FRAME— где на экране начинается Surface доски.GRID_INSET = BOARD_PAD + FRAME // 2 - 3— сдвиг сетки внутри рамки;- 3подогнано визуально, чтобы камни смотрелись по центру "окна" доски.
Если меняете TILE_SIZE или CELL_GAP, пересчитайте все производные и при необходимости подправьте - 3 на - 2 или - 4.
Проверка формулы на бумаге для col=2, row=1:
x = PLAY_OX + 2 * (58 + 5) = PLAY_OX + 126
y = PLAY_OY + 1 * 63 = PLAY_OY + 63
Каждый следующий столбец сдвигается на TILE_SIZE + CELL_GAP = 63 px.
Этап 2. Градиентный фон и рамка доски
Цель — один раз нарисовать фон и рамку в Surface, дальше только blit.
Идея предрендера — тяжёлые операции (градиент построчно, несколько скруглённых rect, тень) выполняются при старте, а не 60 раз в секунду. В кадре остаётся два быстрых blit. Тот же приём — кэш камней GEM_CACHE на этапе 4.
Добавьте палитру и хелперы:
В цикле вместо fill:
screen.blit(BG_SURFACE, (0, 0))
screen.blit(FRAME_SURFACE, (BOARD_OX, BOARD_OY))
Уберите временный контур с этапа 1 или оставьте поверх рамки для сверки.
Самопроверка
- Вертикальный градиент фона.
- Скруглённая "коробка" доски с тенью.
Разбор этапа 2
| Функция | Идея |
|---|---|
lerp_color(c1, c2, t) |
Линейная интерполяция RGB; t=0 → c1, t=1 → c2 |
make_gradient_bg |
Для каждой строки y считаем t = y / (h-1) и рисуем горизонтальную линию |
make_board_frame |
SRCALPHA — прозрачность; inflate(-FRAME*2) — внутренний прямоугольник "окна" |
shadow = outer.move(0, 6) |
Тень смещена вниз на 6 px — иллюзия глубины |
border_radius скругляет углы без ручной геометрии — требует Pygame 2.x.
Этап 3. Клетки сетки
Цель — нарисовать ячейки на локальной Surface доски.
Каждая клетка — двухслойный скруглённый прямоугольник. Камень рисуется поверх на этапе 4. Отдельная Surface board позволяет сдвигать всё поле при тряске (этап 14) одним blit.
CELL_COLOR = (22, 18, 42)
CELL_INSET = (12, 10, 28)
def draw_cell(surface, rect, highlight=False):
r = rect.inflate(-1, -1)
pygame.draw.rect(surface, CELL_INSET, r, border_radius=10)
pygame.draw.rect(surface, CELL_COLOR, r.inflate(-2, -2), border_radius=9)
if highlight:
ACCENT = (255, 210, 80)
glow = pygame.Surface((r.w + 8, r.h + 8), pygame.SRCALPHA)
pygame.draw.rect(glow, (*ACCENT, 70), glow.get_rect(), border_radius=12, width=2)
surface.blit(glow, glow.get_rect(center=r.center))
В цикле:
board = pygame.Surface((BOARD_W, BOARD_H), pygame.SRCALPHA)
for row in range(GRID_SIZE):
for col in range(GRID_SIZE):
draw_cell(board, tile_rect_local(col, row))
screen.blit(board, (BOARD_OX, BOARD_OY))
Самопроверка
- 64 тёмные ячейки со скруглением внутри рамки.
Разбор этапа 3
rect.inflate(-1, -1)— чуть уменьшаем rect, чтобы между соседними ячейками оставался визуальный зазорCELL_GAP.highlight=True(позже) — полупрозрачныйSurfaceс обводкойACCENT; используется для hover на этапе 16.- Порядок отрисовки: сначала все ячейки, потом камни — иначе фон перекроет камни.
Этап 4. Многоугольные камни
Цель — процедурная графика камней и кэш GEM_CACHE.
Пять типов — пять форм (октагон, ромб, шестиугольник, звезда, четырёхугольник). Кэш (kind, size) не даёт перерисовывать полигоны каждый кадр.
Добавьте import math и палитру камней:
COLORS = {
1: (255, 95, 109),
2: (120, 210, 255),
3: (120, 230, 140),
4: (255, 210, 80),
5: (200, 130, 255),
}
GEM_DARK = {1: (180, 40, 60), 2: (40, 120, 200), 3: (40, 160, 70),
4: (200, 150, 20), 5: (130, 60, 200)}
GEM_LIGHT = {1: (255, 180, 190), 2: (200, 240, 255), 3: (200, 255, 210),
4: (255, 240, 180), 5: (240, 200, 255)}
Функции gem_polygon, render_gem, draw_gem — скопируйте из полной ревизии на GitHub (комментарий # ── Pre-rendered assets ──). Кратко, что внутри:
render_gem(kind, size)— один раз рисует камень вSurfaceразмеромsize×size, кладёт вGEM_CACHE[(kind, size)].draw_gem(surface, rect, color_id, scale, alpha, rotation, glow)— достаёт из кэша, при необходимости масштабирует/крутит, рисует ореол иblitпо центруrect.
После определения функций:
GEM_CACHE = {}
for k in COLORS:
render_gem(k, TILE_SIZE)
В отрисовку доски — случайные камни:
import random
grid = [[random.randint(1, 5) for _ in range(GRID_SIZE)] for _ in range(GRID_SIZE)]
# в цикле после draw_cell:
cid = grid[row][col]
if cid:
draw_gem(board, tile_rect_local(col, row), cid)
Самопроверка
- Пять разных форм — октагон, ромб, шестиугольник, звезда, "квадрат".
- Блик и тень у каждого камня.
Разбор render_gem
- Тень — вершины со сдвигом
(+2, +3), цветGEM_DARKс alpha. - Тело — основной полигон
COLORS[kind]. - Внутренний блик — уменьшенный полигон, смесь
baseиGEM_LIGHT. - Facet — ещё меньший полигон для грани.
- Sparkle — белые круги разного радиуса.
draw_gem при анимации вызывает smoothscale и rotate — поэтому статичный кэш остаётся одним, а на экране камень может сжиматься и крутиться (этапы 9–10).
Этап 5. Класс Match3Game и чистый старт
Цель — собрать игровое состояние в класс и не допускать готовых троек при старте.
В commercial Match-3 стартовое поле без автоматических совпадений — стандарт: иначе игра начинается с каскада без участия игрока. Метод has_match_at проверяет только левых и верхних соседей — достаточно при заполнении слева направо, сверху вниз.
Поля класса на этом этапе
grid— модель поля;selected,hover— ввод (позже);ensure_no_matches_on_start— перегенерация "плохих" клеток.
Оберните состояние в класс:
Замените глобальный grid на game = Match3Game() и используйте game.grid в draw.
Самопроверка
- Перезапуск 5 раз — нигде нет трёх подряд.
Разбор этапа 5
| Метод | Назначение |
|---|---|
has_match_at(x, y) |
Есть ли тройка, заканчивающаяся в (x,y) по горизонтали или вертикали |
ensure_no_matches_on_start |
Пока есть "плохие" клетки — перебрасываем тип; цикл конечен на 8×8 |
get_tile_pos |
Hit-test: (None, None) вне сетки; иначе (col, row) |
get_tile_pos использует полуинтервал [PLAY_OX, PLAY_OX + BOARD_INNER) — клик ровно на правой границе не попадает в поле, это нормально.
Этап 6. Поиск совпадений
Цель — find_matches() возвращает список линий; каждая линия — список координат (y, x).
Алгоритм — два прохода (строки, затем столбцы). Для каждой строки расширяем серию одинаковых color, пока сосед справа совпадает. Если длина серии ≥ 3 (end - x >= 2 означает минимум 3 клетки — x, x+1, x+2), добавляем линию.
Пример на бумаге — строка [2, 2, 2, 4, 4]:
- серия
2сx=0доend=2→ линия[(y,0), (y,1), (y,2)]; - серия
4длины 2 — не match.
Для отладки временно подсвечивайте matched-клетки жёлтым draw_cell(..., highlight=True).
Самопроверка
- Вручную поставьте тройку в
grid— подсветка на трёх клетках.
Разбор этапа 6
- Пустые клетки (
color == 0) разрывают серию — после падения (этап 11) в одной строке могут быть "дыры". - Форма "Г" (две перпендикулярные тройки) даёт две линии в
matches; очки суммируются по каждой (этап 14). - Сложность O(GRID_SIZE²) — для 8×8 перебор мгновенный.
Этап 7. Выбор и мгновенный обмен
Цель — связать мышь с моделью: два клика по соседним клеткам меняют grid (пока без проверки и анимации).
Соседство по Manhattan distance: abs(x1-x2) + abs(y1-y2) == 1 — только вверх/вниз/влево/вправо, без диагоналей.
В main подключите события:
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
game.on_mouse_down(event.pos)
elif event.type == pygame.MOUSEMOTION:
game.on_mouse_move(event.pos)
elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
game.on_mouse_up(event.pos)
def swap_tiles(self, x1, y1, x2, y2):
self.grid[y1][x1], self.grid[y2][x2] = self.grid[y2][x2], self.grid[y1][x1]
def on_mouse_down(self, pos):
x, y = self.get_tile_pos(pos)
if x is None or self.grid[y][x] == 0:
return
# заглушка — позже drag
pass
def on_mouse_up(self, pos):
pass
Обработчик в main — MOUSEBUTTONDOWN/UP/MOTION → методы game.
Реализуйте два клика — первый — selected = (x,y), второй по соседу (abs(dx)+abs(dy)==1) — swap_tiles.
Самопроверка
- Соседние камни меняются местами; несоседний клик — новый выбор.
Разбор этапа 7
swap_tiles— симметричный обмен через tuple unpacking в Python.- Координаты в
selectedхраните как(col, row)в эталоне drag; вgridдоступ —grid[row][col]. - Позже этап 13 добавит drag, но логика "два клика" останется запасным путём.
Этап 8. Только валидные ходы
Цель — обмен откатывается, если после него нет match.
Правило "только валидные ходы" — стандарт жанра: игрок не может испортить поле бессмысленным swap. На этом этапе откат мгновенный; на этапе 9 тот же откат станет анимацией.
def try_swap(self, x1, y1, x2, y2):
self.swap_tiles(x1, y1, x2, y2)
if self.find_matches():
# позже: start_removal_animation
pass
else:
self.swap_tiles(x1, y1, x2, y2) # откат
Самопроверка
- Бессмысленный обмен не меняет поле.
- Обмен, собирающий тройку, остаётся (пока без удаления).
Разбор этапа 8
Паттерн "попробовать → проверить → откатить":
swap_tiles— применить ход.find_matches()— есть ли выигрыш?- Если нет — второй
swap_tilesвозвращает grid.
На этапе 9 шаг 3 заменится на обратную анимацию, но модель уже должна быть согласована.
Этап 9. Анимация обмена
Цель — визуализировать swap: камни едут по дуге ~11 кадров; невалидный ход — обратная анимация.
Easing — функция ease_out_cubic(t) замедляет движение к концу (как в UI-анимациях). progress от 0 до 1; t = ease_out_cubic(progress) подставляется в lerp позиций. Дополнительный lift = sin(progress * π) * 6 приподнимает камни mid-air.
Важно: try_swap сначала меняет grid, потом запускает анимацию. Callback on_done после кадров проверяет match или откатывает модель.
Константы:
SWAP_FRAMES = 11
def ease_out_cubic(t):
return 1 - (1 - t) ** 3
В классе:
В update уменьшайте swap_anim["frame"]; в draw интерполируйте позиции с ease_out_cubic и lift = sin(progress * pi) * 6 — см. репозиторий на GitHub, метод draw.
Самопроверка
- Плавный обмен ~11 кадров.
- Невалидный ход визуально "отскакивает" назад.
Разбор этапа 9
Поле swap_anim |
Смысл |
|---|---|
from, to |
Клетки обмена |
c1, c2 |
Цвета на момент старта (не читаем из grid во время anim) |
frame |
Счётчик кадров до callback |
callback |
Вызывается при frame <= 0; может быть None (обратный откат) |
В draw для swap используйте _occupied_by_anim, чтобы не рисовать статичные камни в тех же клетках (этап 16).
Этап 10. Удаление и частицы
Цель — matched-клетки исчезают с FX: pop + shrink + частицы.
Список removing_tiles хранит [gx, gy, timer, color_val, elapsed]. Пока timer > 0, камень рисуется поверх пустой клетки в grid (там уже 0).
ease_out_back — лёгкий "перелёт" scale в начале удаления; затем ease_out_cubic сжимает камень.
REMOVE_FRAMES = 20
def ease_out_back(t):
c1 = 1.70158
c3 = c1 + 1
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2
Класс Particle и spawn_explosion — из репозитория на GitHub. start_removal_animation:
- помечает клетки в
removing_tilesкак[gx, gy, timer, color_val, elapsed]; - ставит
grid[gy][gx] = 0; - начисляет очки (упрощённо пока
self.score += 10 * len(match)).
Самопроверка
- Тройка "взрывается" частицами и сжимается.
- На поле остаются нули до падения.
Разбор этапа 10
spawn_explosionсоздаёт 16–26 точек + звёзды — координаты в локальной системе доски (tile_rect_local).- Класс
Particleиспользует__slots__— меньше памяти при сотнях частиц за сессию. - Не вызывайте
find_matchesвручную после удаления — этап 12 сделает это вupdate.
Этап 11. Падение и дозаполнение
Цель — гравитация по столбцам с анимацией; новые камни падают сверху.
Логика столбца (аналог Tetris, но только вниз):
- Собрать все непустые
(old_y, color)снизу вверх. - Записать их в нижние строки; верх —
0до приземления. - Для каждого сдвига — запись в
falling_tilesс дробнымcurrent_yдля плавности.
FALL_SPEED = 0.62 px/кад — не привязан к dt; на 60 FPS скорость стабильна.
FALL_SPEED = 0.62
BOUNCE_FRAMES = 6
apply_gravity_with_animation для каждого столбца:
- Собрать непустые
(old_y, color). - Для сдвинувшихся — добавить в
falling_tiles[x, float(old_y), new_y, color, 0]. - Для пустот сверху — новые камни с
old_yотрицательным. - Обновить
grid(верхние строки = 0 до приземления).
В update двигаете tile[1] к target_y; при достижении — записываете в grid.
Самопроверка
- После удаления камни падают (не телепорт).
- Сверху появляются новые камни.
Разбор этапа 11
abs(tile[1] - target_y) < 0.05— порог "приземления"; без него камни могут дрожать вечно.- Новые камни стартуют с отрицательным
old_y— визуально "падают" из-за верхней границы поля. self.combo = 0в концеapply_gravity_with_animation— новая фаза каскада после падения (этап 14).
Этап 12. Каскад и is_busy
Цель — связать анимации в автоматический каскад и заблокировать ввод на время FX.
Каскад в update — когда все три очереди пусты:
- если есть match →
start_removal_animation; - иначе если в
gridесть0→apply_gravity_with_animation.
Это сердце Match-3: один клик игрока может запустить длинную цепочку без дальнейших кликов.
def is_busy(self):
return bool(self.removing_tiles) or bool(self.falling_tiles) or self.swap_anim is not None
В конце update, когда ничего не анимируется:
if not self.removing_tiles and not self.falling_tiles and not self.swap_anim:
matches = self.find_matches()
if matches:
self.start_removal_animation(matches)
elif any(0 in row for row in self.grid):
self.apply_gravity_with_animation()
Блокируйте ввод: if self.is_busy(): return в on_mouse_down.
Самопроверка
- Один ход может дать 2+ волны исчезновений.
- Клики во время анимации игнорируются.
Разбор этапа 12
flowchart TD
A[update: анимации тикают] --> B{is_busy?}
B -->|нет| C{find_matches}
C -->|есть| D[start_removal]
C -->|нет| E{есть 0 в grid?}
E -->|да| F[apply_gravity]
E -->|нет| G[ждём клик]
D --> A
F --> A
Без is_busy быстрые клики во время падения ломают grid — типичный баг junior-проектов.
Этап 13. Drag-and-drop
Цель — UX как в мобильных головоломках: потянуть камень к соседу.
Порог dist > 14 — минимальное смещение в px, чтобы считать направление (влево/вправо/вверх/вниз). max_drag = TILE_SIZE * 0.48 — камень не уезжает дальше половины клетки.
Поля класса:
self.dragging = False
self.drag_start = None
self.drag_current_pos = None
on_mouse_down— начать drag, запомнить клетку.on_mouse_move— обновитьdrag_current_pos,hover = get_tile_pos.on_mouse_up— если сосед по drag (dist > 14, направление поdx/dy) —try_swap; иначе логика двух кликов из этапа 7.
Отрисовка drag — полупрозрачная тень + увеличенный камень, подсветка целевой клетки — см. блок if self.dragging в draw репозитория на GitHub.
Самопроверка
- Drag вправо/влево/вверх/вниз на соседа запускает swap.
- Короткий клик без drag по-прежнему работает как выбор.
Разбор этапа 13
grid_to_pixel+ смещение мыши от центра камня →dx,dyдля drag-визуала.- Тень (
alpha=80, сдвиг(3,5)) + основной камень (scale=1.12,glow=0.5) — параallax-подсказка глубины. - Если drag короткий — срабатывает ветка два клика в
on_mouse_up(см. репозиторий на GitHub).
Этап 14. Очки, комбо, всплывающий текст
Цель — награда за ход ощущается "сочно" — цифры, комбо, juice (тряска).
Формула за линию (упрощённо):
pts = (10 * match_len + (match_len - 3) * 15) * match_len # в эталоне умножается ещё на длину
bonus = 10 * combo # за каждую клетку в волне
display_scoreдогоняетscoreс шагомdiff // 4— счётчик "накручивается", а не прыгает.shake_timer/shake_power— случайный сдвигboard_originна 3–10 кадров.
self.score = 0
self.display_score = 0
self.combo = 0
self.best_combo = 0
self.shake_timer = 0
self.shake_power = 0
self.floating_texts = []
В start_removal_animation:
self.combo += 1;bonus = 10 * self.combo;pts = 10 * match_len + (match_len - 3) * 15на линию;- при
combo > 1—FloatingText(..., "COMBO ×N!", ...); - при ≥4/≥5 клеток за волну —
shake_timer,shake_power.
В update: плавный display_score (diff // 4); в draw: shake_x/y = randint(-power, power).
Самопроверка
- Счёт в шапке догоняет реальный с задержкой.
- Каскад показывает "COMBO ×2".
- Большое совпадение слегка трясёт доску.
Разбор этапа 14
| Эффект | Класс / поля | Зачем |
|---|---|---|
| Всплывающий +N | FloatingText |
Мгновенная обратная связь |
| COMBO ×N | FloatingText, big=True |
Подчёркивает каскад |
| Тряска | shake_timer, shake_power |
"Juice" без изменения логики |
| Плавный счёт | display_score |
Читаемость в шапке |
FloatingText.draw рисует обводку чёрным — текст читается на любом фоне доски.
Этап 15. Шапка, подвал, фоновое свечение
Цель — UI вокруг поля и атмосфера фона.
- Шапка (
_draw_header) — бренд, декоративные мини-камни (render_gem(cid, 22)), pill со счётом. - Подвал (
_draw_footer) — подсказка управления;hint_alphaпульсирует 100↔255. - BackgroundGlow — медленно пульсирующие орбы; рисуются до доски, не перекрывают gameplay.
Шрифты: SysFont("Segoe UI", …) на Windows; блок except — fallback на bitmap font Pygame (см. Lab — Pygame).
pygame.font.init()
font_title = pygame.font.SysFont("Segoe UI", 34, bold=True)
font_sm = pygame.font.SysFont("Segoe UI", 16)
font_score = pygame.font.SysFont("Segoe UI", 28, bold=True)
# fallback: pygame.font.Font(None, ...) в except
- Шапка — заголовок "Match-3", мини-камни, pill "ОЧКИ" справа.
- Подвал: "Потяните камень к соседу…" с пульсом
hint_alpha. BackgroundGlow— 5 полупрозрачных орбов на фоне.
Самопроверка
- Очки в pill с разделителем тысяч (пробел).
- Подсказка внизу мигает.
Разбор этапа 15
f"{self.display_score:,}".replace(",", " ")— разделитель тысяч пробелом (локаль RU).footer_y = HEADER_H + FRAME * 2 + BOARD_H— подвал под доской, не под окном.- Орбы
BackgroundGlowиспользуют цвета изCOLORS— визуальная связь с камнями на поле.
Этап 16. Полировка камней
Цель — "живое" поле без изменения правил.
| Приём | Код | Эффект |
|---|---|---|
| Idle bob | sin(tick * 0.04 + col * 0.7 + row * 0.5) * 1.5 |
Лёгкое покачивание |
| Hover | _draw_cell(..., highlight=True) |
Подсветка клетки под курсором |
| Selection | пульсирующее кольцо ACCENT |
Выбранный камень |
_occupied_by_anim |
skip static draw | Нет двойных спрайтов |
Реализация — в методе draw репозитория на GitHub: блоки "static gems", "selection ring", проверка _occupied_by_anim.
Самопроверка
- Камни слегка "дышат".
- При swap не дублируются статичный и анимированный камень.
Разбор этапа 16
Фаза col * 0.7 + row * 0.5 рассинхронизирует bob — поле не качается одной "волной".
Этап 17. Финальная сборка
Цель — вынести цикл в main(), пройти чек-лист, свериться с репозиторием на GitHub.
Функция main() — единственная точка входа после if __name__ == "__main__". Глобальные screen, clock, предрендеры остаются на уровне модуля; состояние партии — только в Match3Game.
Порядок в цикле важен: clock.tick → события → update → draw → flip. События до update — стандарт Pygame (Разработка игр на Python, Ping Pong).
Чек-лист
| # | Проверка | Ожидание |
|---|---|---|
| 1 | Старт | Нет готовых троек |
| 2 | Drag | Swap с анимацией |
| 3 | Невалидный ход | Откат |
| 4 | Каскад | 2+ волны, комбо |
| 5 | is_busy | Нет ввода во время FX |
| 6 | Геометрия | Клик попадает в клетку по всему полю |
| 7 | UI | Очки, подсказка, мини-камни в шапке |
Типичные ошибки
| Симптом | Причина | Решение |
|---|---|---|
| Клик мимо клеток | Перепутаны PLAY_OX и PAD |
Используйте формулы эталона |
| Двойные камни | Нет _occupied_by_anim |
Пропускайте занятые клетки в static draw |
| Зависание | falling_tiles не удаляются |
Проверьте abs(y - target) < 0.05 |
| Откат не работает | swap в grid до анимации | Эталон: swap в try_swap до anim, откат в callback |
Карта этапов (кратко)
0 окно → 1 геометрия → 2–4 графика → 5 модель → 6 match → 7–8 swap
→ 9 anim swap → 10 remove → 11 fall → 12 каскад → 13 drag
→ 14 очки → 15 UI → 16 polish → 17 main
Запишите GIF геймплея с каскадом и drag, выложите match3/ на GitHub с README (pip install pygame, python match3.py) — наглядный пункт для junior game dev рядом с Battle City или Tetris.
Полная ревизия. Эталонный match3.py
Готовый эталон — в публичном репозитории на GitHub: Spirzen/Match3
Склонируйте или скачайте ZIP, положите match3.py в папку match3/, установите pygame и запустите python match3.py.
Разбор эталона
Геометрия
- Все размеры окна выводятся из
TILE_SIZE,CELL_GAP,GRID_SIZE;GRID_INSETвизуально центрирует сетку (этап 1). grid_to_pixel— экран;tile_rect_local— Surface доски (архитектура).
Логика
try_swapменяетgridсразу; анимация иллюстрирует; callback проверяет match или откатывает (этап 9).- Каскад в конце
update, когда очереди анимаций пусты (этап 12). comboсбрасывается вapply_gravity_with_animation— новая "волна" после падения.
Графика
GEM_CACHE+draw_gem— статика и FX без перерисовки полигонов (этап 4).Particle,FloatingText,BackgroundGlow— juice (этапы 10–15).
Ввод
- Drag с порогом 14 px и fallback на два клика (этап 13).
is_busyблокирует ввод на всём протяжении каскада.
Отладка
| Приём | Действие |
|---|---|
| Запуск из терминала | Traceback виден сразу; не закрывайте окно двойным кликом |
print(game.grid) |
Снимок поля после update; ищите "висящие" 0 |
FPS = 10 |
Замедлить swap/fall для пошагового наблюдения |
Контур PLAY_OX |
pygame.draw.rect(screen, (255,0,0), (PLAY_OX, PLAY_OY, BOARD_INNER, BOARD_INNER), 1) |
| Подсветка match | Временно highlight=True для клеток из find_matches() |
Типичные симптомы
| Симптом | Куда смотреть |
|---|---|
| Клик мимо | Этап 1, PLAY_OX, (TILE_SIZE + CELL_GAP) |
| Два камня в одной клетке | Этап 16, _occupied_by_anim |
| Каскад не стартует | Этап 12, блок в конце update |
| Зависшие падающие | Этап 11, порог 0.05 у target_y |
Если логика расходится с эталоном — откройте полную ревизию на GitHub и сравните методы Match3Game по одному.
Дальнейшее развитие
После этапа 17 игра полностью играбельна. Ниже — направления в порядке возрастания сложности; внедряйте по одному и перезапускайте после каждого.
| Направление | Сложность | Идея |
|---|---|---|
Клавиша R |
★☆☆ | ensure_no_matches_on_start() заново |
Подсказка H |
★★☆ | Перебор соседних пар + пробный try_swap без anim |
has_any_move |
★★☆ | Перетасовка при тупике |
| Звук | ★★☆ | pygame.mixer в start_removal_animation |
| Спец-фишки 4/5 | ★★★ | Ракета / бомба за длинные линии |
| Уровни с целью | ★★★ | 500 очков за N ходов |
Соседние треки для закрепления техник:
- Python — Ping Pong — события, FSM,
Rect; - Python — Tetris — сетка, гравитация, каскадное заполнение;
- Pygame — мини-игры — короткие разборы до полного практикума.
Задания для самостоятельной работы
| # | Задание | Критерий | Подсказка |
|---|---|---|---|
| 1 | Клавиша R — новое поле |
Без стартовых троек | KEYDOWN, ensure_no_matches_on_start |
| 2 | Подсказка по H |
Подсветка валидной пары | Перебор соседей, как в расширениях |
| 3 | Звук match | wav при удалении | Разработка игр на Python, mixer |
| 4 | Спец-фишка за 4 в ряд | Очищает строку | Расширить find_matches |
| 5 | Сохранение рекорда | best_score в JSON |
json.dump при выходе |
См. также
- Практикум разработки игр — о разделе
- Разработка игр на Python
- Pygame — мини-игры
- Компьютерные игры — о разделе
- Battle City на GitHub · Python — Tetris · Python — Ping Pong