Как вернуть радость простого программирования: пишем игру для терминала на Python

Сегодня простую компьютерную игру можно собрать за несколько минут: взять готовый движок, подключить библиотеку или просто попросить ИИ написать код. Но мне захотелось вернуться к истокам и попробовать обойтись самым минимумом средств.
Привет! Я Евгений Бартенев, техлид и автор курса «Python-разработчик» в Практикуме. В этой статье попробую написать автомобильные гонки примерно в 50 строк кода на Python. Вся «графика» будет состоять из обычных символов терминала, а по пути мы немного разберёмся с ANSI-последовательностями, неблокирующим вводом и тем, как вообще устроен терминал.
Моё знакомство с программированием началось задолго до появления современных IDE, Git и тысяч готовых библиотек. Первым компьютером был советский клон ZX Spectrum. Никакой операционной системы там не было — после включения компьютера сразу запускался интерпретатор BASIC, записанный в ПЗУ. Компьютер был моментально готов к работе: можно было вводить команды, сохранять программы на кассету и экспериментировать, сколько хватало терпения.
Позже появился IBM PC/AT 286, и тоже без графической среды. Компьютер загружал DOS, и на экране появлялась командная строка с мигающим курсором. Именно она была главным интерфейсом пользователя.
Компьютеры уже умели работать с графикой. На IBM PC существовали режимы CGA, EGA, позже VGA, а на домашних компьютерах вроде Spectrum графика вообще была одной из главных особенностей. Но работа с ней требовала значительно больше усилий. Процессоры работали на частотах в несколько мегагерц, а память измерялась сотнями килобайт, поэтому любой вывод на экран сильно влиял на время работы программы.
По этой причине огромное количество прикладных программ, утилит и даже игр продолжали работать в текстовом режиме. Терминал был невероятно быстрым. По сути, видеопамять представляла собой обычную таблицу символов, а для вывода изображения достаточно было записать нужный символ в нужную ячейку. Минимум вычислений — максимум скорости.

Именно в такой среде я написал одну из своих первых игр — автомобильные гонки. Никакой графики, никаких спрайтов. Только символы, из которых складывались дорога, автомобиль и препятствия. И, честно говоря, этого было вполне достаточно, чтобы провести за игрой не один вечер.
Игры в терминале
Современные разработчики обычно воспринимают терминал как место, где запускаются команды или отображаются логи. Но если посмотреть на него чуть иначе, окажется, что это почти готовый игровой экран.
Терминал представляет собой прямоугольную матрицу ячеек, каждая из которых может содержать один символ и иметь собственные цвета текста и фона. Классический размер экрана составлял 80 столбцов на 25 строк — именно отсюда, кстати, выросли многие соглашения об оформлении кода, которыми мы пользуемся до сих пор.
Например, рекомендации PEP 8 для Python советуют ограничивать длину строки кода 79 символами. Это значение появилось вовсе не случайно: предполагалось, что код должен полностью помещаться на стандартном терминальном экране шириной 80 символов, оставляя последний столбец свободным для удобства просмотра. Сегодня мониторы стали гораздо шире, но исторические корни по-прежнему хорошо заметны.
#душнила_которого_никто_не_звал_On
Вообще-то 80 колонок — это количество знаков, помещающихся в строке перфокарты IBM образца 1928 года. Вы сидите за 4K-монитором и переносите строку там, где сто лет назад заканчивался кусок картона. Терминалы унаследовали священное число, а линтер до сих пор его охраняет.
#душнила_которого_никто_не_звал_Off
Вы можете очистить экран, переместить курсор в нужную позицию и вывести туда любой символ. Если повторять эту операцию несколько раз в секунду, возникает иллюзия движения. Именно так устроены многие игры и анимации в терминале.
На самом деле для создания игры большего и не требуется.
В этой статье попробуем повторить этот путь, но уже на современном языке — Python. Забавно, что по духу он во многом напоминает тот самый BASIC: минимум служебного синтаксиса, код читается почти как обычный текст, а первую работающую программу можно написать буквально за несколько минут.
Проектируем игру
Прежде чем писать код, сформулируем задачу.
Мы будем делать классические вертикальные гонки. Игрок управляет автомобилем, который находится в нижней части дороги. Дорога постоянно движется навстречу машине, а сверху случайным образом появляются препятствия. На каждом игровом такте они смещаются вниз на одну строку.
Задача игрока максимально проста: вовремя перестраиваться влево и вправо, избегая столкновений. Чем дольше удаётся ехать, тем больше становится счёт.
Если посмотреть на игру глазами разработчика, компонентов оказывается совсем немного:
Есть игровое поле фиксированной ширины и высоты.
Есть автомобиль игрока, который занимает всего одну ячейку на экране.
Есть множество препятствий, каждое из которых тоже описывается всего двумя координатами.
А главное, есть игровой цикл, который через равные интервалы времени выполняет одну и ту же последовательность действий:
Считывает нажатия клавиш.
При необходимости перемещает автомобиль в сторону.
Сдвигает все препятствия вниз.
Создаёт новые препятствия
Перерисовывает экран.
Проверяет столкновения.
По сути, это и есть вся игра. Именно из этих простых кирпичиков строятся многие простые компьютерные игры.
Но просто написать игру сегодня уже не слишком интересно. Современные игровые библиотеки и ИИ позволяют сделать это буквально за несколько минут. Поэтому добавим себе несколько усложнений:
Во-первых, не будем использовать ИИ.
Во-вторых, будем использовать только стандартную библиотеку Python. Никаких сторонних пакетов, игровых движков или библиотек для работы с графикой.
В-третьих, игра будет работать исключительно в терминале. Вся графика будет отрисована только текстовыми символами — почти так же, как это делалось 40 лет назад.
И наконец, самое интересное ограничение: попробуем уместить всю игру примерно в 50 строк кода.
Для работы с клавиатурой мы будем использовать возможности терминала Unix-подобных систем, поэтому все примеры рассчитаны на Linux и macOS. Под Windows механизмы ввода устроены иначе, и код потребует небольших изменений.
Очень рекомендую адаптировать игру самостоятельно. Такая задача заставит вас разобраться с устройством терминала и обработкой нажатий клавиш клавиатуры в Windows гораздо лучше, чем копирование готового решения. А значит, кроме работающей игры вы получите ещё и немного новых знаний — пожалуй, это самая приятная часть небольшого проекта.
Выбираем символы для игры
Прежде чем переходить к программированию, нужно решить, как игра будет выглядеть в терминале.
Полноценной графики у нас нет, зато есть символы. Из них и предстоит собрать все элементы игрового мира: дорогу, автомобиль и препятствия.
Для границ дороги используем вертикальную черту —
|.Автомобиль игрока обозначим заглавной буквой
A. Она отдалённо напоминает машину, если смотреть на неё спереди.Для препятствий подойдёт символ решётки —
#.Пробел — свободная часть игрового поля.
В результате один кадр игры может выглядеть примерно так:
Счёт: 42
| |
| # |
| |
| # |
| |
| # |
| |
| A |Такого набора символов вполне достаточно. Игрок сразу понимает, где находится дорога, чем он управляет и чего следует избегать.
Конечно, можно было бы выбрать более выразительные Unicode-символы или даже эмодзи. Например, использовать 🚗 для автомобиля и 🪨 для препятствий. Но у такого решения есть неприятная особенность: не каждый отображаемый символ занимает ровно одно знакоместо.
Обычные латинские буквы и знаки вроде A, # и | практически всегда имеют ширину в одну терминальную ячейку. Эмодзи же могут занимать две ячейки, а некоторые Unicode-символы отображаются по-разному в зависимости от операционной системы, терминала и выбранного шрифта.
Для игры, в которой положение объекта определяется координатами, это критично. Если программа считает, что символ занимает одну ячейку, а при отрисовке объект займёт две — отображение и логика игры разрушатся: границы дороги не будут выровнены, автомобиль окажется не в той позиции, а столкновения будут происходить по неочевидным законам.
Поэтому для первой версии игры выберем самые простые однобайтовые символы. Это не самый лучший дизайн, но зато этот дизайн будет корректно отображаться почти в любом терминале.
#душнила_которого_никто_не_звал_On
Вообще-то байты, символы и знакоместа — три разные вещи. Именно поэтому Unicode сначала решает ваши проблемы, а затем создаёт новые, международные.
#душнила_которого_никто_не_звал_Off
Терминал как игровое поле
Теперь нужно понять, как из отдельных символов собрать целый экран.
Игровое поле представляет из себя двумерную матрицу. У неё есть ширина и высота, а каждая ячейка содержит один символ.

Начало координат находится в левом верхнем углу терминала. Номера столбцов увеличиваются слева направо, а строки — сверху вниз.
Это отличается от привычной школьной системы координат, где ось y направлена вверх. Но для экранной графики такой подход естественен: первая выведенная строка находится сверху, следующая — под ней и так далее.
Координаты объектов традиционно задают двумя числами:
x — номер столбца;
y — номер строки.
Например, поле шириной девять знакомест и высотой восемнадцать строк можно представить как список строк. Если в одну из ячеек записать символ #, там появится препятствие. Если в нижнюю строку записать A, получим автомобиль игрока. В остальных ячейках ничего не будет, поэтому остальные места можно заполнить пробелами.
Например, если препятствие нужно разместить на координатах (3, 2) — помещаем символ # в четвёртый столбец третьей строки. Нумерация строк и колонок начинается с нуля, как всегда в коллекциях Python.
Автомобиль будет расположен в нижней строке игрового поля. Его вертикальная координата будет всегда постоянна, а горизонтальную координату игрок сможет изменять нажатиями клавиш.
Пишем код: формируем стартовый кадр
Каждое состояние игры будем превращать в отдельный кадр.
Сначала создадим пустую матрицу:
rows = [[" "] * width for _ in range(height)]
Это список строк, каждая из которых пока состоит только из пробелов.
#душнила_которого_никто_не_звал_On
Вообще-то это список списков символов. Слово «строка» здесь читается двояко (row/string), в паре мест сбивает.
#душнила_которого_никто_не_звал_Off
Теперь расставим препятствия. У каждого препятствия будут координаты x и y, поэтому символ # следует записать в ячейку с этими координатами:
rows[y][x] = "#"
Автомобиль размещаем в последней строке:
rows[-1][car] = "A"
Индекс -1 в Python означает последний элемент списка. Следовательно, машина всегда будет находиться в нижней части дороги.
Остаётся превратить отдельные символы в строки и добавить по краям границы дороги:
road = "\n".join("|" + "".join(row) + "|" for row in rows)
Внутренний вызов join() соединяет символы одной строки игрового поля. Затем слева и справа добавляются символы вертикальной черты — |. Внешний join() объединяет готовые строки с помощью символа перевода строки \n.
В результате двумерная матрица превращается в обычный текст, который можно вывести одной операцией print().
Предварительное объединение всех символов кадра в единую строку — это важный момент. Технически можно было бы напечатать каждый символ отдельно, но это долго и дорого. Терминальный вывод — хоть и не самая дорогая операция, но большое количество мелких вызовов может привести к мерцанию экрана и снизить скорость игры.
Будет гораздо эффективнее сначала полностью собрать кадр в памяти, а затем отправить его в терминал целиком.
Функция print() и проблема построчного вывода
Предположим, что мы сформировали первый кадр и вывели его через print(road).
Через долю секунды препятствия переместились, и мы печатаем следующий кадр — снова print(road).
Но новый кадр не заменит предыдущий. Он появится ниже него.
Терминал начнёт прокручиваться:
| # |
| |
| |
| A |
- - - Кадр 2
| |
| # |
| |
| A |
- - - Кадр 3
| |
| |
| # |
| A |Вместо анимации получится длинная лента — последовательность кадров с состояниями игры.
Можно очищать терминал перед каждым кадром командой clear или cls, но запуск таких команд десятки раз в секунду — слишком тяжёлое и медленное решение. Кроме того, экран при этом может заметно мерцать.
Нам нужен способ не добавлять новые строки, а возвращать курсор в начало экрана и рисовать следующий кадр вместо предыдущего.
Терминал умеет принимать не только обычный текст, но и специальные управляющие последовательности. С их помощью можно перемещать курсор, очищать экран, менять цвет символов и управлять режимом их отображения.
Чаще всего в таких командах используются ANSI escape-последовательности. Они начинаются с управляющего символа Escape, который в строке Python записывается как "\033". После него указывается команда для терминала.
Например, последовательность "\033[H" перемещает курсор в левый верхний угол.
Теперь перед каждым кадром можно возвращать курсор в начало экрана:
print("\033[H" + road)
Предыдущий кадр останется в терминале, и новый будет нарисован поверх него. Если размеры кадра не меняются — пользователь увидит плавное движение объектов, а не прокрутку текста.
Перед началом игры стоит один раз очистить экран:
print("\033[2J")
Последовательность \033[2J очищает содержимое терминала.
Текстовый курсор тоже нужно скрыть: он будет мешать игре:
print("\033[?25l")
А после завершения игры — обязательно вернуть:
print("\033[?25h")
Последовательность с l скрывает курсор, а последовательность с h снова делает его видимым.
Таким образом, перед запуском игрового цикла нам понадобится очистить экран и скрыть курсор. Затем на каждом кадре возвращать курсор в левый верхний угол, а после завершения игры вернуть курсор.
Здесь важно не забыть последний пункт. Если программа завершится, оставив курсор скрытым, терминал продолжит работать, но вводить команды будет очень неудобно: пользователь просто не увидит, где находится текущая позиция ввода.
Вывод без задержек
У функции print() есть ещё одна особенность: вывод может буферизоваться.
Это означает, что Python не всегда немедленно передаёт текст терминалу. Бывает, что данные временно сохраняются в буфере и выводятся позже, например — после накопления достаточного объёма текста или появления перевода строки.
Для обычной программы эта оптимизация полезна, но в игре каждый кадр должен появляться сразу. Поэтому будем использовать для print() параметр flush=True. Он заставляет Python немедленно отправить содержимое буфера в терминал.
Кроме того, по умолчанию print() добавляет перевод строки. В нашем случае это не нужно: положение курсора контролируется управляющими последовательностями. Поэтому укажем параметр end="".
Вывод одного кадра будет выглядеть примерно так:
print("\033[H" + road, end="", flush=True)
Мы перемещаем курсор в начало экрана, выводим полностью подготовленный кадр, не добавляем лишнего перевода строки и сразу отправляем текст в терминал.
С выводом разобрались. Теперь возникает более сложная задача: как управлять автомобилем.
Обработка нажатия клавиш
До сих пор мы занимались только одной половиной задачи — выводом информации на экран. Но любая игра представляет собой непрерывный обмен данными между игроком и программой. Значит, нам нужен способ получать нажатия клавиш так же быстро, как мы выводим изображение.
Самый очевидный способ прочитать данные с клавиатуры в Python — функция input():
command = input()
Она прекрасно подходит для диалоговых программ. Можно попросить пользователя ввести имя, число или команду, а затем обработать полученную строку.
Но для игры у input() есть две серьёзные проблемы.
Первая проблема: input() останавливает программу.
Когда интерпретатор доходит до этой функции, он ждёт, пока пользователь что-нибудь введёт. Пока ввод не завершён, игровой цикл не работает: препятствия не двигаются, экран не обновляется, время в игре останавливается.
Получается пошаговая игра:
Введите направление: a
Введите направление: d
Введите направление: d
Для некоторых жанров такой подход допустим, но в гонках дорога должна двигаться постоянно — независимо от того, нажимает игрок клавишу или нет.
Вторая проблема: при терминальном вводе необходимо нажать Enter.
Даже если игрок нажал клавишу a, программа обычно не получит её сразу. Терминал сохранит символ во внутреннем буфере и передаст его приложению только после нажатия Enter.
Для управления машиной в игре такая задержка неприемлема. Нам нужно другое поведение программы:
проверять клавиатуру на каждом кадре;
не останавливать игру, если клавиша не нажата;
получать клавишу сразу, без Enter;
после обработки ввода продолжать игровой цикл.
Обычная функция input() ничего такого не умеет.
Блокирующий и неблокирующий ввод
В Python данные можно получать не только с помощью input(), но и через стандартный поток ввода (sys.stdin). Стандартный ввод обычно работает в блокирующем режиме.
Это означает, что операция чтения ждёт появления данных:
symbol = sys.stdin.read(1)
Если пользователь ничего не нажимает, программа остановится на этой строке.
Для игры нужен неблокирующий ввод. Программа должна сначала спросить у операционной системы: «Есть ли сейчас данные, которые можно прочитать?» Если клавиша нажата — читаем её. Если нет — сразу продолжаем работу и рисуем следующий кадр.
В Unix-подобных системах, включая Linux и macOS, проверить готовность стандартного ввода можно с помощью модуля select:
select.select([sys.stdin], [], [], 0)
Последний аргумент — время ожидания в секундах. Ноль означает, что функция должна только проверить состояние ввода и сразу вернуть управление программе.
Если данные появились, их можно прочитать через файловый дескриптор стандартного ввода:
os.read(sys.stdin.fileno(), 1)
Таким образом, игровой цикл не будет ждать нажатий клавиш игрока. Автомобиль продолжит движение, кадры будут сменяться, а нажатия станут обрабатываться только тогда, когда действительно произойдут.
Однако одной проверки через select недостаточно. Терминал всё ещё может накапливать введённые символы и ждать Enter. Значит, на время игры нужно изменить его режим работы.
Канонический режим терминала
В каноническом режиме терминала операционная система собирает целую строку, позволяет пользователю редактировать её и передаёт программе только после нажатия Enter. Именно поэтому работают Backspace, удаление символов и привычный построчный ввод.
Для игры канонический режим нужно отключить. В Python для этого можно использовать модули стандартной библиотеки termios и tty. Модуль termios позволяет получить и изменить настройки терминала, а tty предоставляет готовые функции для переключения его режима.
Перед началом игры сохраним текущие настройки:
settings = termios.tcgetattr(sys.stdin)
Затем переключим терминал в режим cbreak:
tty.setcbreak(sys.stdin)
В этом режиме каждый символ становится доступен программе сразу после нажатия. Enter больше не требуется, но обработка некоторых специальных сигналов терминала сохраняется.
#душнила_которого_никто_не_звал_On
Вообще-то нажатия Enter ждёт не терминал, а линейная дисциплина драйвера в ядре ОС. Для статьи не критично, но
termios/ttyнастраивают именно её, и это объясняет, почему тут нужны системные модули.#душнила_которого_никто_не_звал_Off
После завершения игры исходные настройки терминала обязательно нужно восстановить:
termios.tcsetattr(
sys.stdin,
termios.TCSADRAIN,
settings,
)Именно поэтому игровой код удобно помещать в конструкцию try/finally:
settings = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(sys.stdin)
# Игровой цикл
finally:
termios.tcsetattr(
sys.stdin,
termios.TCSADRAIN,
settings,
)Блок finally выполняется даже при ошибке или досрочном завершении программы. Это защищает нас от ситуации, когда игра аварийно закрылась, а терминал остался в необычном режиме без нормального построчного ввода.
Почему стрелка — это не один символ
Управлять автомобилем будем стрелками влево и вправо.
На первый взгляд кажется, что при нажатии стрелки терминал должен передать программе один специальный символ. На практике клавиши со стрелками обычно кодируются последовательностью из нескольких байтов.
Например, стрелка вправо часто передаётся как "\x1b[C". А стрелка влево — как "\x1b[D". Первый символ \x1b — это Escape. За ним следуют квадратная скобка и буква, обозначающая направление. Поэтому чтение только одного байта нам не подойдёт. Программа получит Escape, но не узнает, какая именно стрелка была нажата.
Для распознавания стрелок прочитаем сразу несколько байтов:
os.read(sys.stdin.fileno(), 3)
Полученные байты нужно преобразовать в строку:
os.read(sys.stdin.fileno(), 3).decode(errors="ignore")
Параметр errors="ignore" не даст завершить программу с ошибкой, даже если терминал передаст неожиданную или неполную последовательность байтов.
Функция для считывания нажатий клавиш в итоге должна вести себя так:
def key():
if not select.select([sys.stdin], [], [], 0)[0]:
return ""
return os.read(
sys.stdin.fileno(),
3,
).decode(errors="ignore")Если клавиша не нажата, функция возвращает пустую строку. Если данные есть, она читает до трёх байтов и возвращает полученную последовательность.
В игровом цикле результат можно сравнивать с кодами стрелок:
pressed = key()
if pressed == "\x1b[D":
car -= 1
elif pressed == "\x1b[C":
car += 1Какие модули нам понадобятся
Теперь определим набор инструментов для первой версии игры:
Модуль
sysдаст доступ к стандартному вводуsys.stdin.Модуль
selectпозволит проверить, нажата ли клавиша, не останавливая игровой цикл.Модуль
osпонадобится для непосредственного чтения байтов из стандартного ввода.Модули
termiosиttyпозволят временно отключить обычный построчный режим терминала и отслеживать нажатие клавиши без Enter.
Кроме них позже понадобятся ещё два модуля:
random— для случайного появления препятствий;time— для паузы между кадрами и управления скоростью игры.
Все они входят в стандартную библиотеку Python. Устанавливать дополнительные пакеты не потребуется.
У этого решения есть одно ограничение: модули termios, tty и используемый нами способ чтения клавиатуры рассчитаны на Unix-подобные терминалы. Код будет работать в Linux и macOS, а также в совместимом терминальном окружении. Для классической консоли Windows потребовался бы другой механизм ввода, например модуль msvcrt.
Теперь у нас есть всё необходимое для взаимодействия с игроком:
набор символов для отображения игровых объектов;
двумерное поле, из которого можно собирать кадр;
ANSI-последовательности для обновления экрана;
неблокирующее чтение клавиатуры;
режим терминала, позволяющий получать клавиши без Enter.
Следующий шаг — определить состояние игры: размеры дороги, положение автомобиля, координаты препятствий и текущий счёт. После этого из этих элементов можно будет собрать настоящий игровой цикл.
Описываем состояние игры
Теперь, когда мы умеем выводить кадр и читать нажатия клавиш без остановки программы, можно заняться самой игрой.
Для начала нужно определить, какие данные будут меняться во время её работы.
У нас есть:
размеры дороги;
положение автомобиля;
координаты препятствий;
текущий счёт;
скорость обновления экрана.
Начнём с размеров игрового поля:
WIDTH = 9
HEIGHT = 18
Почему именно такие значения? Здесь нет какого-то специального расчёта. Поле должно быть достаточно широким, чтобы у игрока оставалось пространство для манёвра, но при этом не настолько большим, чтобы препятствия было слишком легко объезжать.
Высота влияет на то, сколько времени проходит между появлением препятствия в верхней части экрана и его приближением к автомобилю. Чем выше поле, тем раньше игрок замечает опасность и тем больше времени получает на реакцию.
Для небольшой терминальной игры девять столбцов и восемнадцать строк дают вполне подходящий баланс.
В отдельной константе зададим время паузы между двумя кадрами, в секундах:
SPEED = 0.12
При таком значении игра будет обновляться примерно восемь раз в секунду. Для современной графики это очень мало, но для терминальной игры — вполне достаточно. Объекты двигаются не плавно, а скачками между знакоместами, поэтому слишком высокая частота кадров здесь почти ничего не даст.
#душнила_которого_никто_не_звал_On
Вообще-то ваши восемь FPS — не константа, а пожелание, и на нагруженной машине игра честно становится медленнее. Нормальные разработчики тут считают дельту времени, но эти «нормальные разработчики» уж точно не уложатся в 50 строк.
#душнила_которого_никто_не_звал_Off
Кроме того, высокая скорость сделает игру слишком сложной: препятствия будут перемещаться быстрее, чем игрок сможет реагировать.
Объединим константы в одной строке:
WIDTH, HEIGHT, SPEED = 9, 18, 0.12
Положение автомобиля
Автомобиль находится в нижней строке игрового поля и может перемещаться только по горизонтали. Следовательно, хранить обе его координаты нет необходимости. Вертикальная позиция всегда одна и та же:
HEIGHT - 1
Нам достаточно сохранить только номер столбца — это будет стартовая позиция:
car = WIDTH // 2
Оператор // выполняет целочисленное деление. При ширине дороги, равной девяти, выражение даст значение 4.
0 1 2 3 4 5 6 7 8
AПри нажатии стрелки влево значение car уменьшается, а при нажатии стрелки вправо — увеличивается. Но просто изменять координату нельзя. Игрок не должен иметь возможность выехать за пределы дороги.
Можно было бы написать две отдельные проверки:
if pressed == "\x1b[D" and car > 0:
car -= 1
if pressed == "\x1b[C" and car < WIDTH - 1:
car += 1Такой вариант понятен, но занимает много места. Поскольку мы стараемся уместить программу примерно в пятьдесят строк, попробуем выразить то же самое компактнее.
Сначала определим направление движения:
(pressed == "\x1b[C") - (pressed == "\x1b[D")
# стрелка "вправо" стрелка "влево"На первый взгляд выражение выглядит необычно. Но в Python логические значения True и False ведут себя как числа 1 и 0.
Если нажата стрелка вправо, получится:
True - False
или:
1 - 0
Результат равен 1, поэтому машина сдвигается вправо.
Если нажата стрелка влево:
False - True
получаем -1.
Если стрелки не нажаты — результат равен нулю, положение автомобиля не меняется.
Теперь прибавим направление к текущей координате:
car + (pressed == "\x1b[C") - (pressed == "\x1b[D")
Остаётся ограничить результат границами дороги.
Координата автомобиля не может быть меньше нуля и больше, чем WIDTH - 1
Для ограничения диапазона удобно использовать функции min() и max():
car = max(
0,
min(
WIDTH - 1,
car + (pressed == "\x1b[C") - (pressed == "\x1b[D"),
),
)В компактной записи всё выражение занимает одну строку:
car = max(0, min(WIDTH - 1, car + (pressed == "\x1b[C") - (pressed == "\x1b[D")))Сначала min() не позволяет координате превысить правую границу, а затем max() не даёт ей стать меньше нуля.
В результате автомобиль всегда остаётся внутри дороги.
Как хранить препятствия
Каждое препятствие имеет две координаты — (x, y). Координата x определяет столбец, а y — строку. Например (2, 5) означает, что препятствие находится в третьем столбце шестой строки. Поскольку препятствий на экране может быть несколько, нам понадобится коллекция координат.
Можно было бы использовать список:
obstacles = []
Но в нашей игре важна не последовательность объектов, а сам факт наличия препятствия в определённой клетке. Два одинаковых препятствия с одними и теми же координатами нам тоже не нужны.
Поэтому удобнее использовать множество:
obstacles = set()
Множество автоматически хранит только уникальные значения. Если программа дважды попытается добавить препятствие в одну и ту же позицию, в коллекции всё равно останется только одна пара координат.
#душнила_которого_никто_не_звал_On
Вообще-то тут
setвыбран потому, что нужен O(1) поиск при проверке столкновения, а не потому что «дубликаты не нужны» — дубликаты в этой игре и так не появляются. Но объяснение через уникальность звучит понятнее, а правильный ответ приберегают для собеседования, где его спросят с формулировкой «а почему не список?» и будут ждать ровно этих трёх букв.#душнила_которого_никто_не_звал_Off
Добавить новое препятствие можно так:
obstacles.add((x, y))
А проверить наличие препятствия в конкретной позиции:
(x, y) in obstacles
Именно такая проверка понадобится при столкновении с автомобилем.
Кроме того, множество удобно пересобирать на каждом игровом такте, одновременно сдвигая все препятствия вниз.
Движение дороги
В действительности дорога в нашей игре никуда не движется. Её границы всегда остаются на одном месте. Иллюзия движения возникает из-за того, что все препятствия постепенно смещаются сверху вниз. Игрок воспринимает это так, будто автомобиль едет вперёд, а объекты на дороге приближаются к нему.
На каждом кадре увеличим вертикальную координату каждого препятствия на единицу:
(x, y + 1)
При этом препятствия, вышедшие за нижнюю границу экрана, нужно удалить.
Последняя допустимая строка имеет индекс:
HEIGHT - 1
Поэтому после перемещения новая координата должна быть меньше HEIGHT.
Пересобрать множество можно с помощью генератора множества:
obstacles = {
(x, y + 1)
for x, y in obstacles
if y + 1 < HEIGHT
}Здесь одновременно выполняются две операции.
Для каждого существующего препятствия вычисляется новая позиция на строку ниже:
(x, y + 1)
Но в новое множество попадают только те объекты, которые после перемещения всё ещё находятся внутри игрового поля:
if y + 1 < HEIGHT
Препятствия, прошедшие нижнюю границу, автоматически исчезают.
В компактной версии это снова можно записать в одну строку:
obstacles = {(x, y + 1) for x, y in obstacles if y + 1 < HEIGHT}Создание новых препятствий
Если только перемещать существующие препятствия, рано или поздно поле станет пустым. Поэтому на каждом игровом такте нужно случайным образом решать, появится ли новый объект в верхней строке. Для этого используем функцию random.random(). Она возвращает случайное число от нуля до единицы.
Например, условие: if random.random() < 0.22: срабатывает примерно в двадцати двух процентах случаев.
Если условие выполнилось, создадим препятствие в случайном столбце:
random.randrange(WIDTH)
Функция вернёт целое число от нуля до WIDTH - 1.
Вертикальная координата каждого нового препятствия всегда равна нулю, поскольку оно появляется в верхней строке:
obstacles.add((random.randrange(WIDTH), 0))Полностью создание препятствия выглядит так:
if random.random() < 0.22:
obstacles.add((random.randrange(WIDTH), 0))Вероятность 0.22 подобрана экспериментально. Если сделать её слишком маленькой, игра станет пустой и скучной. Если слишком большой, на дороге быстро образуется почти непроходимая стена.
#душнила_которого_никто_не_звал_On
Вообще-то «подобрано экспериментально» переводится как «я тыкал число, пока оно не перестало бесить». В статье это эмпирическая калибровка, в резюме — data-driven tuning, в грантовой заявке — стохастическая оптимизация гиперпараметров.
#душнила_которого_никто_не_звал_Off
Важно понимать, что вероятность проверяется на каждом кадре. Поэтому частота появления препятствий зависит не только от числа 0.22, но и от скорости игрового цикла. Если уменьшить паузу между кадрами, проверки станут происходить чаще, а значит, препятствия будут появляться чаще в пересчёте на реальное время.
В более серьёзной игре генерацию объектов стоило бы привязать ко времени. Но для нашей компактной программы достаточно простого вероятностного подхода.
Счёт
Счёт будет показывать, сколько игровых тактов удалось пережить игроку.
В начале игры он равен нулю:
score = 0
После каждого успешно завершённого кадра увеличим его:
score += 1
Это не совсем количество пройденных метров и не время в секундах. Скорее, условная мера продолжительности заезда. Поскольку скорость игры постоянна, счёт хорошо отражает результат: чем дольше игрок избегает столкновения, тем больше число.
Можно было бы увеличивать счёт только тогда, когда препятствие прошло мимо автомобиля, но такая логика потребовала бы дополнительного кода. Для маленькой игры счётчик кадров проще и понятнее.
Проверяем столкновение
Автомобиль находится в нижней строке поля. Его координаты можно представить как:
(car, HEIGHT - 1)
Если такая пара координат присутствует во множестве препятствий, значит, машина и препятствие оказались в одной клетке:
if (car, HEIGHT - 1) in obstacles:
Это и есть столкновение.
После него выводим итоговый результат и завершаем цикл:
print(f"\nАвария! Итоговый счёт: {score}")
breakПеревод строки \n нужен потому, что во время игры вывод выполняется с параметром end="". Без него сообщение могло бы появиться прямо в конце последней строки игрового поля.
Собираем игровой цикл
Теперь объединим все отдельные действия в единую систему.
Игровой цикл будет выполняться бесконечно:
while True:
На каждом его шаге происходит одна и та же последовательность:
Читаем клавишу
Изменяем положение автомобиля
Сдвигаем препятствия
При необходимости создаём новое препятствие
Рисуем кадр
Проверяем столкновение
Увеличиваем счёт
Делаем небольшую паузу
В коде все эти операции будут выглядеть так:
while True:
pressed = key()
car = max(
0,
min(
WIDTH - 1,
car + (pressed == "\x1b[C") - (pressed == "\x1b[D"),
),
)
obstacles = {
(x, y + 1)
for x, y in obstacles
if y + 1 < HEIGHT
}
if random.random() < 0.22:
obstacles.add((random.randrange(WIDTH), 0))
draw(car, obstacles, score)
if (car, HEIGHT - 1) in obstacles:
print(f"\nАвария! Итоговый счёт: {score}")
break
score += 1
time.sleep(SPEED)Порядок действий здесь имеет значение.
Сначала мы обрабатываем клавишу, чтобы игрок мог попытаться уйти от приближающегося препятствия. Затем все препятствия опускаются на одну строку, после чего может появиться новое.
Функция отрисовки вызывается только после изменения состояния. Поэтому на экране всегда отображается уже обновлённый кадр. После отрисовки проверяется столкновение. Это позволяет сначала показать момент аварии, а уже затем завершить игру.
#душнила_которого_никто_не_звал_On
Вообще-то в кадре аварии на месте столкновения остаётся A, символ автомобиля. Препятствие пропало, а автомобиль остался. Визуально игрок побеждает — однако по логике игры он проигрывает. В геймдизайне это называется непрозрачной обратной связью, а у игрока для этого есть другое слово, покороче.
#душнила_которого_никто_не_звал_Off
После того, как кадр обработан и выведен на экран, счёт увеличивается на единицу, а функция time.sleep() приостанавливает выполнение программы на короткое время:
time.sleep(SPEED)
Пауза задаёт игре ритм и одновременно ограничивает частоту обновления экрана.
Функция game()
Начальное состояние игры удобно собрать в отдельной функции:
def game():
car = WIDTH // 2
obstacles = set()
score = 0Но эти три переменные можно инициализировать в одной строке:
car, obstacles, score = WIDTH // 2, set(), 0
После объявления переменных записываем настройки терминала, включаем посимвольный ввод и запускаем игровой цикл.
Общая структура функции:
def game():
car, obstacles, score = WIDTH // 2, set(), 0
settings = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(sys.stdin)
print("\033[2J\033[?25l", end="")
while True:
# Обработка очередного кадра
finally:
termios.tcsetattr(
sys.stdin,
termios.TCSADRAIN,
settings,
)
print("\033[?25h", end="", flush=True)В начале блока try терминал переводится в режим cbreak.
Затем одной операцией очищаем экран и скрываем курсор:
print("\033[2J\033[?25l", end="")
ANSI-последовательности можно объединять в одной строке. Терминал последовательно выполнит обе команды.
Когда цикл завершится, блок finally восстановит настройки ввода и снова покажет курсор.
Логика игры готова! Осталось оформить функцию отрисовки, соединить части программы и пересчитать строки кода.
Собираем игру целиком
Все основные части у нас уже есть.
Мы умеем:
читать клавиши без остановки игрового цикла;
переводить терминал в посимвольный режим;
хранить положение автомобиля и препятствий;
сдвигать объекты вниз;
создавать новые препятствия;
собирать кадр в памяти;
перерисовывать экран с помощью ANSI-последовательностей;
проверять столкновение;
восстанавливать настройки терминала после завершения игры.
Остаётся соединить эти фрагменты в одну программу.
import os
import random
import select
import sys
import termios
import time
import tty
WIDTH, HEIGHT, SPEED = 9, 18, 0.12
def key():
if not select.select([sys.stdin], [], [], 0)[0]:
return ""
return os.read(sys.stdin.fileno(), 3).decode(errors="ignore")
def draw(car, obstacles, score):
rows = [[" "] * WIDTH for _ in range(HEIGHT)]
for x, y in obstacles:
rows[y][x] = "#"
rows[-1][car] = "A"
road = "\n".join("|" + "".join(row) + "|" for row in rows)
print(f"\033[HСчёт: {score}\n{road}", end="", flush=True)
def game():
car, obstacles, score = WIDTH // 2, set(), 0
settings = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(sys.stdin)
print("\033[2J\033[?25l", end="")
while True:
pressed = key()
car = max(
0,
min(
WIDTH - 1,
car
+ (pressed == "\x1b[C")
- (pressed == "\x1b[D"),
),
)
obstacles = {
(x, y + 1)
for x, y in obstacles
if y + 1 < HEIGHT
}
if random.random() < 0.22:
obstacles.add((random.randrange(WIDTH), 0))
draw(car, obstacles, score)
if (car, HEIGHT - 1) in obstacles:
print(f"\nАвария! Итоговый счёт: {score}")
break
score += 1
time.sleep(SPEED)
finally:
termios.tcsetattr(
sys.stdin,
termios.TCSADRAIN,
settings,
)
print("\033[?25h", end="", flush=True)
if __name__ == "__main__":
game()В таком виде код удобно читать и обсуждать, но он заметно превышает заявленные 50 строк. Главная причина — форматирование длинных выражений.
Если записать те же операции компактнее, не меняя саму логику, получится итоговая версия:
import os
import random
import select
import sys
import termios
import time
import tty
WIDTH, HEIGHT, SPEED = 9, 18, 0.12
def key():
if not select.select([sys.stdin], [], [], 0)[0]:
return ""
return os.read(sys.stdin.fileno(), 3).decode(errors="ignore")
def draw(car, obstacles, score):
rows = [[" "] * WIDTH for _ in range(HEIGHT)]
for x, y in obstacles:
rows[y][x] = "#"
rows[-1][car] = "A"
road = "\n".join("|" + "".join(row) + "|" for row in rows)
print(f"\033[HСчёт: {score}\n{road}", end="", flush=True)
def game():
car, obstacles, score = WIDTH // 2, set(), 0
settings = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(sys.stdin)
print("\033[2J\033[?25l", end="")
while True:
pressed = key()
car = max(0, min(WIDTH - 1, car + (pressed == "\x1b[C") - (pressed == "\x1b[D")))
obstacles = {(x, y + 1) for x, y in obstacles if y + 1 < HEIGHT}
if random.random() < 0.22:
obstacles.add((random.randrange(WIDTH), 0))
draw(car, obstacles, score)
if (car, HEIGHT - 1) in obstacles:
print(f"\nАвария! Итоговый счёт: {score}")
break
score += 1
time.sleep(SPEED)
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, settings)
print("\033[?25h", end="", flush=True)
if __name__ == "__main__":
game()Это уже та самая игра, к которой мы шли.
#душнила_которого_никто_не_звал_On
Вообще-то у автора в коде как минимум одна строка длиннее 79 символов — то есть автор сперва пишет про 80 колонок, а сам в 80 колонок не уместился. Но это, конечно, мелочь.
#душнила_которого_никто_не_звал_Off
Уложились ли мы в 50 строк?
Здесь всё зависит от способа подсчёта.
Если считать абсолютно все строки файла, включая импорты, пустые строки и конструкцию запуска, получится немного больше пятидесяти.
Если не учитывать пустые строки, программа укладывается примерно в заявленный объём.
Можно сократить код ещё сильнее:
объединить импорты;
убрать часть пустых строк;
записать некоторые условия в одну строку;
отказаться от отдельных функций;
использовать более короткие имена переменных.
Например, все импорты можно записать так:
import os, random, select, sys, termios, time, tty
Это сэкономит шесть строк.
Но такое форматирование противоречит обычным рекомендациям по стилю Python. Несколько импортов в одной строке читаются хуже, а чрезмерно короткие имена делают код менее понятным.
Главная мысль остаётся неизменной: полноценную игру с управлением, анимацией, случайной генерацией объектов, подсчётом очков и обработкой столкновений действительно можно написать примерно за 50 строк Python, используя только стандартную библиотеку.
#душнила_которого_никто_не_звал_On
Вообще-то это прототип автора занимает 50 строк. Остальные 15 256 строк появятся после фразы «а давайте теперь сделаем нормально».
#душнила_которого_никто_не_звал_Off
Запуск игры
Игра рассчитана на Linux и macOS, поскольку использует модули termios и tty. Также она должна запускаться в Unix-подобных терминальных окружениях.
Запуск из некоторых встроенных консолей IDE может не сработать. Такие консоли не всегда предоставляют программе настоящий терминал и могут не поддерживать ANSI-последовательности или изменение режима ввода.
Поэтому лучше использовать обычное приложение терминала:
Terminal — в macOS;
GNOME Terminal, Konsole и другие — в Linux;
терминал внутри WSL — в Windows.
После запуска появится дорога, в нижней части которой находится автомобиль:
Счёт: 0
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| A |Почти сразу сверху начнут появляться препятствия. Они будут приближаться к нижней части экрана, а задача игрока — перестраиваться между свободными столбцами.
Когда одно из препятствий совпадёт с позицией автомобиля, игра завершится и покажет результат.
Что в итоге?
Несмотря на простоту, в этой небольшой программе собрано большинство базовых приёмов, которые используются и в более серьёзных проектах. Здесь есть игровой цикл, модель состояния, обработка пользовательского ввода, генерация случайных событий, отрисовка кадра, проверка столкновений и корректное освобождение ресурсов после завершения работы.
Игру можно развивать практически бесконечно. Добавить разметку дороги, несколько полос движения, разные типы препятствий, постепенное увеличение скорости, таблицу рекордов, меню, паузу или поддержку Windows через модуль msvcrt. Но любая новая опция неизбежно увеличит объём программы.
Именно поэтому ограничение примерно в 50 строк оказалось интересным. Оно заставляет не только писать код, но и думать над архитектурой, искать простые решения и эффективно использовать возможности языка. Это уже не соревнование по сокращению количества символов, а хороший повод ещё раз вспомнить, что элегантный код — не всегда самый короткий, а тот, который решает задачу минимальными средствами, оставаясь понятным.
И, пожалуй, именно это больше всего напоминает программирование конца 80-х и начала 90-х. Тогда ограничения были не искусственными, а вполне реальными: несколько сотен килобайт памяти, медленный процессор и минимум доступных инструментов. Приходилось экономить каждый байт и каждую строчку не ради рекорда, а потому что в ином случае программа просто не помещалась в память или работала слишком медленно.
С тех пор компьютеры стали мощнее, а языки программирования — удобнее. Однако удовольствие от того, что из нескольких десятков строк кода вдруг рождается маленький живой мир, осталось совершенно таким же.
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.