$ sudo teach IT
Модуль 6 · Состояния игры · Урок 6.2

Меню и текст

Вы сделаете меню с заголовком по центру, выбором пунктов стрелками, мигающей подсказкой и затемнением на паузе.

Теория + практика~30 минутНовичок

Состояния у нас уже есть: игра умеет быть в меню, в игре, на паузе и на экране «Игра окончена». Но выглядит всё это пока как заготовка: строчка текста в левом углу и больше ничего. В этом уроке мы сделаем настоящее меню: заголовок по центру окна, пункты, которые выбираются стрелками, мигающую подсказку, затемнение на паузе и экран с итоговыми очками.

Текст по центру

Наша функция draw_text кладёт надпись в точку, которую мы указали, а точка — это её левый верхний угол. Для счёта в углу экрана это удобно. Для заголовка — нет: слово «КОМЕТА» длиннее слова «Играть», и если для обоих указать один и тот же x, их середины окажутся в разных местах.

Представьте табличку на двери кабинета. Её не прибивают за левый край: сначала отмеряют середину двери, потом вешают табличку так, чтобы середина таблички совпала с серединой двери. С текстом в pygame делаем то же самое. Картинка текста (результат render) умеет сообщать свой прямоугольник — Rect, и этот прямоугольник можно поставить так, чтобы совпал центр. Официально это метод get_rect с именованным параметром center.

Python · фрагмент
text_image = font.render("Привет", True, WHITE)
text_rect = text_image.get_rect(center=(WIDTH // 2, 200))
screen.blit(text_image, text_rect)

Разбор по строкам

  • text_image = font.render("Привет", True, WHITE) — превращаем строку в картинку, как делали для очков и жизней.
  • text_image.get_rect(center=(WIDTH // 2, 200)) — просим у картинки её прямоугольник и сразу ставим его центр в точку: по горизонтали — середина окна, по вертикали — 200 пикселей от верха.
  • screen.blit(text_image, text_rect) — кладём картинку на экран: вместо пары чисел можно передать Rect, тогда берётся его левый верхний угол, уже посчитанный так, чтобы центр совпал с нужной точкой.

Эти три строки пригодятся для каждой надписи, поэтому спрячем их в функцию, как раньше прятали draw_text. Шрифт передаём параметром: скоро у нас будет два размера.

Python · фрагмент
def draw_center(surface, text, y, use_font, color=WHITE):
    image = use_font.render(text, True, color)
    rect = image.get_rect(center=(WIDTH // 2, y))
    surface.blit(image, rect)

Теперь любая надпись по центру — одна строка: draw_center(screen, "Пауза", 300, font). Параметр y задаёт высоту центра надписи, а x функция берёт сама — середину окна.

Шрифты разных размеров

Заголовок должен быть крупнее подсказок. Размер шрифта — это второй параметр в pygame.font.Font(None, размер): число пикселей по высоте строки. Значит, для заголовка нужен ещё один шрифт, и создать его надо до цикла, рядом с первым.

Python · фрагмент
font = pygame.font.Font(None, 32)
font_big = pygame.font.Font(None, 96)

draw_center(screen, "КОМЕТА", 150, font_big, SELECT_COLOR)

Здесь font — обычный шрифт из прошлых уроков, а font_big — крупный, для названия игры. Цвет SELECT_COLOR — обычная константа-кортеж, как WHITE; свои цвета меню мы заведём в практике.

Шрифт не создают в цикле

Строку pygame.font.Font(None, 96) не стоит писать внутри кадра: pygame будет заново читать файл шрифта 60 раз в секунду. Шрифты создаём один раз до цикла, а в кадре только вызываем render. Сам render тоже не бесплатный: для надписей, которые не меняются (как «КОМЕТА»), можно один раз получить картинку до цикла и каждый кадр только класть её на экран. Для нашего меню это необязательно — надписей мало и компьютер справится, но привычку полезно знать.

Пункты меню и выбранный пункт

Меню — это список строк и номер того, на что мы сейчас указываем. Так делают пункты в любом приложении: есть перечень вариантов и подсвеченный маркер. Номер такого маркера называют индексом (мы с ним встречались, когда брали элементы списка по номеру: у первого пункта индекс 0, у второго — 1).

Python · фрагмент
MENU_ITEMS = ["Играть", "Выход"]
menu_index = 0

Рисовать пункты будем циклом по списку. Нам нужен не только сам пункт, но и его номер, чтобы понять, выбран ли он. Для этого в Python есть встроенная функция enumerate («нумеровать»): она проходит по списку и выдаёт пары «номер, элемент». Пары мы уже умеем распаковывать — так же мы писали for x, y, size in stars.

Python · фрагмент
print(list(enumerate(["Играть", "Выход"])))
Вывод
[(0, 'Играть'), (1, 'Выход')]
Python · фрагмент
def draw_items():
    for number, item in enumerate(MENU_ITEMS):
        y = 300 + number * 50
        if number == menu_index:
            draw_center(screen, "> " + item, y, font, SELECT_COLOR)
        else:
            draw_center(screen, item, y, font, GRAY)

Разбор по строкам

  • for number, item in enumerate(MENU_ITEMS): — на каждом шаге number — номер пункта (0, 1, ...), item — его текст.
  • y = 300 + number * 50 — пункты идут столбиком: первый на высоте 300, второй на 350 и так далее, шаг 50 пикселей.
  • if number == menu_index: — сравниваем номер пункта с номером выбранного: совпало — этот пункт выбран.
  • "> " + item — у выбранного пункта спереди приписываем стрелку-указатель и красим его другим цветом, чтобы выбор было видно даже без цвета.
  • else: draw_center(screen, item, y, font, GRAY) — остальные пункты рисуем спокойным серым.

Цвета SELECT_COLOR (яркий, например жёлтый) и GRAY (серый) — обычные константы вроде WHITE: кортежи из трёх чисел. Добавим их в практике.

Выбор стрелками по кругу

Игрок нажимает «вниз» — индекс растёт на 1, «вверх» — падает на 1. Если на этом остановиться, получатся две ошибки: на последнем пункте «вниз» даст индекс, которого нет в списке (ошибка IndexError), а на первом «вверх» даст -1, и меню начнёт указывать на последний пункт случайно, только потому что в Python отрицательные номера считаются с конца списка.

Мы хотим аккуратно: после последнего пункта идёт первый, перед первым — последний. Это движение по кругу, как стрелка на циферблате: после 12 снова 1. Уже знакомый нам инструмент для круга — остаток от деления (%), на нём построена функция wrap. Официально такой индекс называют циклическим. Вынесем правило в чистую функцию logic.py, без pygame, чтобы его можно было проверить отдельно:

Python · kometa/logic.py
def menu_move(index, step, count):
    return (index + step) % count

Разбор по строкам

  • index — номер выбранного пункта сейчас.
  • step — на сколько сдвинуться: 1 — вниз, -1 — вверх.
  • count — сколько всего пунктов; обычно len(MENU_ITEMS).
  • (index + step) % count — складываем и берём остаток от деления на число пунктов: результат всегда от 0 до count - 1.
ВызовСчитаемРезультат
menu_move(0, 1, 3)(0 + 1) % 31
menu_move(2, 1, 3)(2 + 1) % 30 — с последнего на первый
menu_move(0, -1, 3)(0 - 1) % 32 — с первого на последний
menu_move(1, 5, 3)(1 + 5) % 30 — большой шаг тоже ходит по кругу
menu_move(0, 1, 1)(0 + 1) % 10 — один пункт, некуда идти

Отрицательные числа и %

В Python остаток от деления на положительное число никогда не отрицателен: -1 % 3 равно 2. Поэтому наша функция работает и «вверх» без отдельной проверки. В некоторых других языках результат был бы -1, и там пришлось бы писать запасной вариант.

Обработка нажатий в цикле событий выглядит так: на стрелку мы просим у menu_move новый индекс и записываем его в menu_index.

Python · фрагмент
if event.key == pygame.K_DOWN:
    menu_index = menu_move(menu_index, 1, len(MENU_ITEMS))
elif event.key == pygame.K_UP:
    menu_index = menu_move(menu_index, -1, len(MENU_ITEMS))

Клавиша Enter в pygame называется pygame.K_RETURN. Нажатие на неё выполняет действие пункта: для «Играть» — начать новую игру и перейти в состояние "play", для «Выход» — остановить цикл. Как именно это встроится в наш main.py, увидим в практике.

Мигающая подсказка

Под пунктами меню обычно мигает «Нажмите Enter» — она привлекает взгляд. Мигание мы уже делали, когда корабль мерцал после удара: функция blink_visible(time_left, period) по времени отвечает «рисовать или пропустить». Только там мы отдавали ей остаток неуязвимости, а здесь нужно общее время игры. Его даёт pygame.time.get_ticks() — число миллисекунд с момента запуска pygame.init(). Делим на 1000 и получаем секунды.

Python · фрагмент
seconds = pygame.time.get_ticks() / 1000
if blink_visible(seconds, 0.5):
    draw_center(screen, "Нажмите Enter", 520, font, WHITE)

Разбор по строкам

  • pygame.time.get_ticks() / 1000 — сколько секунд идёт программа: 0.0, 0.016, 0.033 и так далее.
  • blink_visible(seconds, 0.5) — полсекунды True, полсекунды False, и так по кругу; время идёт вперёд само, поэтому мигание не требует таймера.
  • if ...: draw_center(...) — когда ответ True — рисуем подсказку, когда False — кадр остаётся без неё.

Название параметра time_left тут не совсем точное (мы передаём не остаток, а прошедшее время), но функции всё равно: ей важно лишь, какой по счёту полупериод сейчас идёт.

Затемнение на паузе

На паузе красиво оставить на экране застывшую игру, но приглушить её, а поверх написать «ПАУЗА». Для этого нужен тёмный полупрозрачный слой, как тонированное стекло: сквозь него всё видно, но тусклее. В pygame такой слой — обычная поверхность (Surface) нужного размера, залитая чёрным, у которой мы задали прозрачность методом set_alpha. Значение от 0 (слой невидим) до 255 (слой полностью закрывает картинку).

Python · фрагмент
shade = pygame.Surface((WIDTH, HEIGHT))
shade.fill((0, 0, 0))
shade.set_alpha(150)

screen.blit(shade, (0, 0))

Разбор по строкам

  • pygame.Surface((WIDTH, HEIGHT)) — пустая поверхность размером с окно.
  • shade.fill((0, 0, 0)) — заливаем её чёрным.
  • shade.set_alpha(150) — делаем полупрозрачной: чуть больше половины от 255, игра под ней остаётся различимой.
  • screen.blit(shade, (0, 0)) — накладываем слой на весь экран, от левого верхнего угла.

Порядок важен: сначала рисуем игру (как будто она идёт), потом кладём shade, потом поверх пишем «ПАУЗА». Слой shade создаём один раз до цикла — как шрифты.

Практика: меню, пауза и «Игра окончена»

Берём наш main.py таким, каким он получился в прошлом уроке: с состояниями, звуками и взрывами. Заглушки экранов (по одной строке текста) заменим на настоящие функции. Меняем logic.py и четыре места в main.py.

Шаг 1. menu_move в logic.py

Добавьте в конец logic.py, после next_state, уже знакомую функцию:

Python · kometa/logic.py (добавить в конец файла)
def menu_move(index, step, count):
    return (index + step) % count

Шаг 2. Импорт

В строку импорта из logic добавьте имя menu_move (оно встаёт по алфавиту перед next_state):

Python · kometa/main.py (замените строку импорта)
from logic import Cooldown, SpawnTimer, blink_visible, effect_volume, menu_move, next_state, wrap

Шаг 3. Шрифт, цвета, слой и функции экранов

Найдите в main.py функцию draw_text (она стоит сразу после создания font) и вставьте этот код сразу под ней, перед созданием звёзд. Константы и функции стоят до цикла, поэтому screen, font, WHITE, WIDTH и HEIGHT к этому моменту уже существуют.

Python · kometa/main.py (после draw_text)
font_big = pygame.font.Font(None, 96)

TITLE_COLOR = (90, 200, 255)
SELECT_COLOR = (255, 220, 80)
GRAY = (150, 150, 170)
RED = (255, 90, 120)

shade = pygame.Surface((WIDTH, HEIGHT))
shade.fill((0, 0, 0))
shade.set_alpha(150)

MENU_ITEMS = ["Играть", "Выход"]
menu_index = 0


def draw_center(surface, text, y, use_font, color=WHITE):
    image = use_font.render(text, True, color)
    rect = image.get_rect(center=(WIDTH // 2, y))
    surface.blit(image, rect)


def draw_menu():
    draw_center(screen, "КОМЕТА", 170, font_big, TITLE_COLOR)
    for number, item in enumerate(MENU_ITEMS):
        y = 300 + number * 50
        if number == menu_index:
            draw_center(screen, "> " + item, y, font, SELECT_COLOR)
        else:
            draw_center(screen, item, y, font, GRAY)
    if blink_visible(pygame.time.get_ticks() / 1000, 0.5):
        draw_center(screen, "Стрелки - выбор, Enter - подтвердить", 520, font, WHITE)


def draw_pause():
    screen.blit(shade, (0, 0))
    draw_center(screen, "ПАУЗА", 260, font_big, WHITE)
    draw_center(screen, "P или Esc - продолжить, Q - в меню", 330, font, GRAY)


def draw_gameover():
    draw_center(screen, "ИГРА ОКОНЧЕНА", 220, font_big, RED)
    draw_center(screen, "Очки: " + str(score), 300, font, WHITE)
    draw_center(screen, "Enter - ещё раз, Q - в меню, Esc - выход", 360, font, GRAY)

Разбор по строкам

  • font_big = pygame.font.Font(None, 96) — второй, крупный шрифт для заголовков; создан один раз.
  • TITLE_COLOR, SELECT_COLOR, GRAY, RED — четыре новых цвета: голубой для названия, жёлтый для выбранного пункта, серый для остальных, красный для конца игры.
  • shade — затемняющий слой для паузы, подготовленный заранее: размер окна, чёрный, прозрачность 150.
  • MENU_ITEMS и menu_index — пункты меню и номер выбранного; первый пункт выбран с самого начала.
  • draw_center(...) — надпись с центром в середине окна по горизонтали и на высоте y.
  • draw_menu() — заголовок, затем пункты циклом с enumerate, затем мигающая подсказка: полсекунды видна, полсекунды нет.
  • draw_pause() — сначала затемнение, поверх него слово «ПАУЗА» и подсказка про клавиши. Игру под слоем рисует основной код цикла.
  • draw_gameover() — заголовок красным, под ним итог str(score) и подсказка про клавиши; score читаем как обычную переменную, сбрасывать её здесь не нужно.

Шаг 4. Стрелки и Enter в меню

В цепочке KEYDOWN найдите ветку elif state == "menu" and event.key == pygame.K_RETURN: с двумя строками внутри и замените её на эти три ветки. Остальные ветки цепочки остаются как были (в примере они скрыты за ..., как и первые строки цикла — они даны только для ориентира):

Python · kometa/main.py (блок KEYDOWN в цикле)
while running:
    ...
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_F1:
                show_boxes = not show_boxes
            elif event.key == pygame.K_m:
                muted = not muted
                apply_volumes()
            elif state == "menu" and event.key == pygame.K_DOWN:
                menu_index = menu_move(menu_index, 1, len(MENU_ITEMS))
            elif state == "menu" and event.key == pygame.K_UP:
                menu_index = menu_move(menu_index, -1, len(MENU_ITEMS))
            elif state == "menu" and event.key == pygame.K_RETURN:
                if menu_index == 0:
                    state = next_state(state, "start")
                    new_game()
                else:
                    running = False
            ...

Разбор по строкам

  • K_DOWN, K_UP — стрелки вниз и вверх; индекс меняем только через menu_move, поэтому он не выйдет за список.
  • K_RETURN: if menu_index == 0 — пункт «Играть»: переход в "play" и чистая партия, как раньше.
  • else: running = False — пункт «Выход»: цикл заканчивается, после него выполнится pygame.quit().

Все ветки по-прежнему лежат в одной цепочке if / elif, поэтому одно нажатие Enter не может сработать дважды.

Шаг 5. Рисуем экраны функциями

В конце цикла замените цепочку if state == "menu": ... elif state == "gameover": ..., которая рисовала заглушки (сам блок if state in ("play", "pause"): над ней не трогаем), вот на это. Строки while running:, ... и if state in ... показаны только для ориентира, flip() в конце уже есть:

Python · kometa/main.py (отрисовка экранов)
while running:
    ...
    if state in ("play", "pause"):
        ...
    if state == "menu":
        draw_menu()
    elif state == "pause":
        draw_pause()
    elif state == "gameover":
        draw_gameover()
    pygame.display.flip()

Затемнение на паузе ложится поверх мира и табло, потому что мы рисуем его после блока state in ("play", "pause").

Запуск и что должно получиться

Терминал
python main.py

В окне крупное «КОМЕТА» по центру, под ним два пункта: перед выбранным стоит «>» и он жёлтый. Стрелка вниз переносит выбор на «Выход», ещё одна стрелка вниз — снова на «Играть». Подсказка внизу мигает раз в полсекунды. Enter на «Играть» запускает игру, на «Выход» закрывает окно. По P экран темнеет, мир застывает, а по центру горит «ПАУЗА». Когда жизни кончаются, на экране красное «ИГРА ОКОНЧЕНА» и очки по центру.

Проверьте себя

  • Откройте меню и нажмите стрелку вверх на первом пункте: выбор должен перескочить на «Выход», а не пропасть и не вызвать ошибку.
  • Поменяйте в MENU_ITEMS список на три пункта, например добавьте «Помощь». Все три должны выбираться по кругу без других правок (Enter на «Помощь» пока попадёт в ветку else и закроет игру — это нормально, действие у пункта мы не придумывали).
  • Поставьте игру на паузу: игра должна быть видна сквозь тёмный слой. Измените set_alpha(150) на 220, затем на 60 и посмотрите разницу.
  • Измените период мигания подсказки с 0.5 на 0.2 — она должна мигать чаще. Верните 0.5.
  • Дойдите до конца игры: очки на экране должны совпадать с табло, которое видели в бою, и стоять по центру окна.

Частые ошибки

Заголовок сидит правее середины

Что видно: слово начинается ровно посередине окна и уезжает вправо. Почему: картинку положили в точку (WIDTH // 2, y) напрямую, а это её левый верхний угол. Как исправить: взять get_rect(center=...) и положить картинку по прямоугольнику, или пользоваться draw_center.

Меню падает с IndexError или указывает не туда

Что видно: на последнем пункте стрелка «вниз» роняет игру, либо на первом «вверх» выделение исчезает. Почему: индекс меняют вручную, menu_index += 1, и выходят за список. Как исправить: всегда менять индекс через menu_move(menu_index, шаг, len(MENU_ITEMS)).

Enter срабатывает дважды: в меню и сразу в игре

Что видно: по Enter игра стартует и в тот же миг происходит что-то лишнее: например, ставится пауза или игра сразу заканчивается. Почему: обработка клавиш написана несколькими независимыми if; первый переводит state в "play", а второй, стоящий ниже, видит уже "play" и обрабатывает то же самое нажатие по-своему. Как исправить: разбирать состояния одной цепочкой if state == "menu": ... elif state == "play": ... — тогда одно нажатие обрабатывает ровно одна ветка.

Затемнение «съело» экран целиком

Что видно: вместо приглушённой игры на паузе чёрный экран. Почему: забыли set_alpha (слой непрозрачный) или поставили 255. Как исправить: задать значение вроде 150 и проверять на глаз.

Что важно запомнить

  • Надпись по центру: image.get_rect(center=(WIDTH // 2, y)), затем blit(image, rect); оформляем в функцию draw_center.
  • Шрифты разных размеров создаём один раз до цикла: font и font_big.
  • Меню — список MENU_ITEMS и номер выбранного пункта menu_index; enumerate даёт пары «номер, элемент».
  • menu_move(index, step, count) возвращает (index + step) % count: индекс ходит по кругу и не выходит за список.
  • Мигание подсказки: blink_visible(pygame.time.get_ticks() / 1000, 0.5).
  • Затемнение: Surface размером с окно, fill, set_alpha (0–255) и blit поверх игры.
  • События разбираем одной цепочкой по состояниям, чтобы одно нажатие не сработало дважды.

Экраны получились красивыми, но игра остаётся одинаково лёгкой от первой секунды до последней. В следующем уроке добавим уровни сложности: чем больше очков, тем быстрее и чаще падают метеоры.

На следующий урок