Привет, Хабр! У каждого разработчика в серьезном проекте наступает момент, когда хочется отвлечься и написать что-то для души. Что-то простое, классическое, но в то же время увлекательное. Часто такие "внутренние пет-проекты" становятся «пасхальными яйцами» — секретами для самых любопытных пользователей.
Сегодня мы расскажем, как и зачем мы спрятали в нашем приложении для стеганографии «ChameleonLab» классический «Тетрис». Это не просто история о «пасхалке», а пошаговый гайд с подробным разбором кода на Python и PyQt6, который покажет, что, несмотря на кажущуюся простоту, создание «Тетриса» — это интересная задача с множеством подводных камней.

Зачем нужны «пасхальные яйца»?
«Пасхальное яйцо» — это визитная карточка команды, способ оставить свой след в проекте и подмигнуть пользователю. Это создает особую связь, превращая программу из бездушного инструмента в продукт, сделанный людьми для людей. От about:robots
в Firefox до симулятора полетов в старом Excel — «пасхалки» делают цифровой мир немного теплее. Вдохновившись этой идеей, мы тоже решили добавить в наше приложение небольшой сюрприз.
Разбираем «Тетрис»: пошаговая реализация на PyQt6
На первый взгляд, «Тетрис» кажется простой игрой. Но когда начинаешь писать код, сталкиваешься с интересными задачами: как описать фигуры и их вращение? Как эффективно проверять столкновения? Как организовать игровой цикл? Давайте разберем наше решение по шагам на основе файла page_
tetris.py
.

Шаг 1: Архитектура игры
Наша реализация состоит из четырех основных классов:
Tetromino
: Описывает одну фигурку (ее форму, цвет, вращение).TetrisBoard
: Главный класс, содержащий всю игровую логику: поле, падение фигур, проверку столкновений и удаление линий.NextPieceWidget
: Небольшой виджет для отображения следующей фигуры.TetrisPage
: Собирает все элементы вместе в единый интерфейс.
Шаг 2: Строительные блоки — Tetromino
Каждая из семи фигур состоит из 4 блоков. Описываем их координаты относительно центральной точки в статическом кортеже. Номер в coords_table
соответствует фигуре и в дальнейшем будет использоваться для ее цвета.
# ui/page_tetris.py
class Tetromino:
coords_table = (
((0, 0), (0, 0), (0, 0), (0, 0)), # Пустая фигура
((0, -1), (0, 0), (-1, 0), (-1, 1)), # Z
((0, -1), (0, 0), (1, 0), (1, 1)), # S
((0, -1), (0, 0), (0, 1), (0, 2)), # I (Палка)
((-1, 0), (0, 0), (1, 0), (0, 1)), # T
((0, 0), (1, 0), (0, 1), (1, 1)), # O (Квадрат)
((-1, -1), (0, -1), (0, 0), (0, 1)), # L
((1, -1), (0, -1), (0, 0), (0, 1)) # J
)
def __init__(self, shape=0):
self.coords = [[0,0] for _ in range(4)]
self.piece_shape = 0
self.set_shape(shape)
def set_shape(self, shape):
table = self.coords_table[shape]
for i in range(4):
self.coords[i] = list(table[i])
self.piece_shape = shape
Вращение фигуры — это классическое преобразование координат (x, y)
в (-y, x)
. Квадрат (shape = 5
) вращать не нужно, поэтому его мы пропускаем.
# ui/page_tetris.py
def rotated(self):
if self.piece_shape == 5: # Квадрат
return self
result = Tetromino(self.piece_shape)
for i in range(4):
result.coords[i][0] = -self.y(i)
result.coords[i][1] = self.x(i)
return result
Шаг 3: Игровое поле TetrisBoard — мозг игры
Это самый сложный и насыщенный логикой класс.
Представление поля и игровой цикл: Поле 10x22 клетки представляем в виде одномерного списка, где 0
— пустая ячейка. За движение отвечает QBasicTimer
, который вызывает событие timerEvent
каждые 400 миллисекунд и двигает фигуру вниз.
# ui/page_tetris.py
class TetrisBoard(QtWidgets.QFrame):
BoardWidth = 10
BoardHeight = 22
Speed = 400
def __init__(self):
super().__init__()
self.timer = QtCore.QBasicTimer()
self.board = [] # Будет инициализирован в init_game()
# ...
def timerEvent(self, event):
if event.timerId() == self.timer.timerId() and self.is_started and not self.is_paused:
self.one_line_down() # Двигаем фигуру вниз
else:
super().timerEvent(event)
Обработка ввода и столкновений: Это самая нетривиальная часть. На каждое нажатие клавиши мы не просто двигаем фигуру, а вызываем try_move
. Эта функция проверяет, будет ли новое положение фигуры корректным (в границах поля и не поверх других блоков). И только если проверка пройдена, реальные координаты фигуры (self.cur_x
, self.cur_y
) обновляются.
# ui/page_tetris.py
def keyPressEvent(self, event):
# ...
key = event.key()
if key == QtCore.Qt.Key.Key_Left:
self.try_move(self.cur_piece, self.cur_x - 1, self.cur_y)
elif key == QtCore.Qt.Key.Key_Right:
self.try_move(self.cur_piece, self.cur_x + 1, self.cur_y)
elif key == QtCore.Qt.Key.Key_Up:
self.try_move(self.cur_piece.rotated(), self.cur_x, self.cur_y)
# ...
def try_move(self, new_piece, new_x, new_y):
# Проверяем каждый из 4 блоков новой фигуры
for i in range(4):
x = new_x + new_piece.x(i)
y = new_y + new_piece.y(i)
# Если хотя бы один блок выходит за границы или попадает на занятую клетку...
if not (0 <= x < self.BoardWidth and 0 <= y < self.BoardHeight and self.shape_at(x, y) == 0):
return False # ...то движение невозможно
# Если все проверки пройдены, обновляем фигуру и ее позицию
self.cur_piece = new_piece
self.cur_x = new_x
self.cur_y = new_y
self.update() # Перерисовываем поле
return True
Падение и "замораживание" фигуры: Когда try_move
возвращает False
при движении вниз, это значит, что фигура приземлилась. Мы вызываем piece_dropped
, которая "впечатывает" блоки фигуры в массив self.board
, а затем запускает проверку на заполненные линии.
Удаление линий и подсчет очков: Этот процесс требует аккуратности. Мы ищем все ряды, где нет ни одной пустой ячейки (0
). Затем, для каждого такого ряда (в порядке снизу вверх), мы сдвигаем все находящиеся выше ряды на одну клетку вниз.
# ui/page_tetris.py
def remove_full_lines(self):
num_full_lines = 0
full_rows = []
for i in range(self.BoardHeight):
# Если в ряду i нет нулей (пустых клеток), он полный
if all(self.shape_at(j, i) != 0 for j in range(self.BoardWidth)):
full_rows.append(i)
if full_rows:
for row in sorted(full_rows, reverse=True):
# Начиная с удаляемого ряда и до самого верха...
for r in range(row, 0, -1):
# ...копируем в каждую клетку значение из клетки выше
for c in range(self.BoardWidth):
self.set_shape_at(c, r, self.shape_at(c, r - 1))
# Обновляем счет и перерисовываем
self.score += 10 * len(full_rows)
self.scoreChanged.emit(self.score)
Отрисовка: Визуализация происходит в paintEvent
. Мы проходим по всему нашему списку self.board
и, если ячейка не пустая, рисуем в ее координатах квадрат нужного цвета с помощью draw_square
. Поверх "замороженных" блоков рисуется текущая падающая фигура.
Шаг 4: Сборка интерфейса TetrisPage
На последнем шаге мы просто собираем все виджеты вместе в трехколоночном макете и связываем сигналы (например, scoreChanged
) с соответствующими слотами (обновление текста в QLabel
).
Как спрятать игру в приложении?
Теперь самое интересное. В главном окне программы (main_
window.py
) мы установили фильтр событий на логотип приложения. Код отслеживает клики мыши, запоминая время каждого. Если пользователь совершает 5 или более кликов за 2 секунды, программа считает, что секретный код введен, и переключает вид на TetrisPage
.
# ui/main_window.py
def eventFilter(self, obj, event):
# Если кликнули по логотипу...
if obj is self.logo_label and event.type() == QtCore.QEvent.Type.MouseButtonPress:
now = datetime.datetime.now()
# Отсеиваем старые клики (старше 2 секунд)
self.logo_clicks = [t for t in self.logo_clicks if (now - t).total_seconds() < 2]
self.logo_clicks.append(now)
# Если накопилось 5 быстрых кликов — запускаем игру!
if len(self.logo_clicks) >= 5:
self.logo_clicks = []
self.stacked_widget.setCurrentWidget(self.tetris_page)
return True # Событие обработано
return super().eventFilter(obj, event)
Заключение
Как видите, за кажущейся простотой «Тетриса» скрывается немало интересной логики. Его создание — прекрасное упражнение, которое затрагивает управление состоянием, обработку пользовательского ввода, и базовую игровую физику.
«Пасхальное яйцо» — это не просто скрытая игра, а знак уважения к классике, способ развлечь пользователя и показатель того, что команда вкладывает в свой продукт не только код, но и душу. Надеемся, наш гайд был полезен, и, конечно, попробуйте найти наш «Тетрис» сами!
Последнюю версию программы «Steganographia» от ChameleonLab для Windows и macOS можно скачать на нашем официальном сайте.
А чтобы быть в курсе обновлений, обсуждать новые функции и общаться с единомышленниками, присоединяйтесь к нашему Telegram-каналу: https://t.me/ChameleonLab
Комментарии (3)
al_shayda
02.09.2025 22:06>PyQt6
а чем, я интересуюсь, Вам понравился Qt 6? Чем приглянулся, для утилиты стеганографии со встроенным тетрисом?
SquareRootOfZero
Да вроде всё просто: одна большая матрица для "стакана", по маленькой на каждую фигуру - можно из нулей и единиц, можно из нулей и целых чисел от единицы до числа фигур, чтобы каждое число уже кодировало итоговый цвет в стакане. Я бы, наверное, взял только нули и единицы - столкновения легче проверять. Поворот фигуры - это поворот матрицы, уже есть готовое в numpy, если неохота тащить numpy - недолго самому сделать. Провернули, сложили с содержимым стакана, проверили, появились ли двойки: если да - отменили поворот, если нет - утвердили. То же с падением, только вместо вращения сдвиг на линию вниз. А вообще, задача прикольная, сам несколько раз писал ради изучения языка или графической библиотеки.
Lomakn Автор
Вы совершенно правы, отличный комментарий!
Метод, который вы описали, с матрицами для фигур и стакана — это классический и очень эффективный подход, особенно с NumPy.
В коде для статьи я пошел немного другим путем: использовал списки координат для фигур и проверку каждой точки для столкновений, чтобы реализовать всё без внешних библиотек в качестве челленджа. Это лишний раз доказывает, что это отличная задача с множеством правильных решений. Спасибо, что поделились своим видением!