Состояния у нас уже есть: игра умеет быть в меню, в игре, на паузе и на экране «Игра окончена». Но выглядит всё это пока как заготовка: строчка текста в левом углу и больше ничего. В этом уроке мы сделаем настоящее меню: заголовок по центру окна, пункты, которые выбираются стрелками, мигающую подсказку, затемнение на паузе и экран с итоговыми очками.
Текст по центру
Наша функция draw_text кладёт надпись в точку, которую мы указали, а точка — это её левый верхний угол. Для счёта в углу экрана это удобно. Для заголовка — нет: слово «КОМЕТА» длиннее слова «Играть», и если для обоих указать один и тот же x, их середины окажутся в разных местах.
Представьте табличку на двери кабинета. Её не прибивают за левый край: сначала отмеряют середину двери, потом вешают табличку так, чтобы середина таблички совпала с серединой двери. С текстом в pygame делаем то же самое. Картинка текста (результат render) умеет сообщать свой прямоугольник — Rect, и этот прямоугольник можно поставить так, чтобы совпал центр. Официально это метод get_rect с именованным параметром center.
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. Шрифт передаём параметром: скоро у нас будет два размера.
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, размер): число пикселей по высоте строки. Значит, для заголовка нужен ещё один шрифт, и создать его надо до цикла, рядом с первым.
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).
MENU_ITEMS = ["Играть", "Выход"]
menu_index = 0
Рисовать пункты будем циклом по списку. Нам нужен не только сам пункт, но и его номер, чтобы понять, выбран ли он. Для этого в Python есть встроенная функция enumerate («нумеровать»): она проходит по списку и выдаёт пары «номер, элемент». Пары мы уже умеем распаковывать — так же мы писали for x, y, size in stars.
print(list(enumerate(["Играть", "Выход"])))
[(0, 'Играть'), (1, 'Выход')]
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, чтобы его можно было проверить отдельно:
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) % 3 | 1 |
menu_move(2, 1, 3) | (2 + 1) % 3 | 0 — с последнего на первый |
menu_move(0, -1, 3) | (0 - 1) % 3 | 2 — с первого на последний |
menu_move(1, 5, 3) | (1 + 5) % 3 | 0 — большой шаг тоже ходит по кругу |
menu_move(0, 1, 1) | (0 + 1) % 1 | 0 — один пункт, некуда идти |
Отрицательные числа и %
В Python остаток от деления на положительное число никогда не отрицателен: -1 % 3 равно 2. Поэтому наша функция работает и «вверх» без отдельной проверки. В некоторых других языках результат был бы -1, и там пришлось бы писать запасной вариант.
Обработка нажатий в цикле событий выглядит так: на стрелку мы просим у menu_move новый индекс и записываем его в menu_index.
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 и получаем секунды.
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 (слой полностью закрывает картинку).
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, уже знакомую функцию:
def menu_move(index, step, count):
return (index + step) % count
Шаг 2. Импорт
В строку импорта из logic добавьте имя menu_move (оно встаёт по алфавиту перед next_state):
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 к этому моменту уже существуют.
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: с двумя строками внутри и замените её на эти три ветки. Остальные ветки цепочки остаются как были (в примере они скрыты за ..., как и первые строки цикла — они даны только для ориентира):
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() в конце уже есть:
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поверх игры. - События разбираем одной цепочкой по состояниям, чтобы одно нажатие не сработало дважды.
Экраны получились красивыми, но игра остаётся одинаково лёгкой от первой секунды до последней. В следующем уроке добавим уровни сложности: чем больше очков, тем быстрее и чаще падают метеоры.