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

Практикум: меню и пауза

Вы соберёте полный цикл игры — меню, игра, пауза, «Игра окончена» и снова — и вынесете разбор клавиш в функцию handle_key.

Практикум~35 минутСредний

В этом модуле «Комета» научилась жить в нескольких состояниях, но куски лежат отдельными фрагментами, а клавиши разбираются длинной цепочкой elif. Наведём порядок: опишем всё схемой, вынесем разбор клавиш в функцию и соберём main.py целиком.

Схема: какое состояние, какая клавиша, что дальше

Представьте пульт от телевизора: кнопка «ОК» в меню настроек подтверждает выбор, а при просмотре канала показывает список программ. Смысл нажатия зависит от того, в каком режиме вы находитесь. У игры так же: Enter в меню запускает игру, после проигрыша начинает заново, а во время полёта не значит ничего.

Программисты называют такую схему конечным автоматом (finite state machine). Переходы между состояниями у нас уже есть — это next_state. Не хватает второй таблицы: какая клавиша в каком состоянии что означает. Вот она целиком.

СостояниеКлавишаДействиеЧто дальше
"menu"Enter"start"игра (или выход, если выбран пункт «Выход»)
"menu"стрелки вверх и вниз"up", "down"меняется выбранный пункт
"menu"Esc"quit"программа закрывается
"play"P или Esc"pause"состояние "pause"
"pause"P или Esc"resume"снова "play"
"pause"Q"menu"возврат в меню
"gameover"Enter"start"новая игра
"gameover"Q"menu"возврат в меню
"gameover"Esc"quit"программа закрывается
любоеM"mute"звук включён или выключен
любоеF1"boxes"рамки отладки показаны или скрыты

Все остальные пары (Enter во время полёта, Q в меню) — пустые клетки: игра не реагирует. Esc в игре ставит паузу и не закрывает программу.

Есть и переход, которого нет в таблице: "die" («жизни кончились»). Его не нажимает игрок, его порождает сама игра, когда lives доходит до нуля.

Шаг 1. Переводчик клавиш: handle_key в logic.py

Сейчас в цикле стоит длинная лесенка elif, которую невозможно проверить без окна. Поступим как с next_state: выпишем таблицу словарём и сделаем маленькую функцию-переводчика. Она принимает состояние и имя клавиши, а возвращает действие — строку вроде "pause" — или None, если клавиша ничего не значит.

Имя клавиши нам даст сам pygame: функция pygame.key.name(event.key) превращает код клавиши в короткую строку. Нужные нам имена: pygame.K_RETURN даёт "return", pygame.K_ESCAPE — "escape", pygame.K_p — "p", стрелки — "up" и "down", pygame.K_F1 — "f1". В main.py это будет одна строка:

Python · фрагмент
if event.type == pygame.KEYDOWN:
    key_name = pygame.key.name(event.key)

Для logic.py такое имя — просто обычная строка: pygame там не нужен, и файл по-прежнему не импортирует его. Добавьте в конец logic.py:

Python · kometa/logic.py (добавить в конец файла)
KEY_ACTIONS = {
    ("menu", "return"): "start",
    ("menu", "up"): "up",
    ("menu", "down"): "down",
    ("menu", "escape"): "quit",
    ("play", "p"): "pause",
    ("play", "escape"): "pause",
    ("pause", "p"): "resume",
    ("pause", "escape"): "resume",
    ("pause", "q"): "menu",
    ("gameover", "return"): "start",
    ("gameover", "escape"): "quit",
    ("gameover", "q"): "menu",
}

for _state in ("menu", "play", "pause", "gameover"):
    KEY_ACTIONS[(_state, "m")] = "mute"
    KEY_ACTIONS[(_state, "f1")] = "boxes"


def handle_key(state, key_name):
    return KEY_ACTIONS.get((state, key_name))

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

  • KEY_ACTIONS = {...} — словарь, у которого ключ — кортеж (состояние, имя_клавиши), а значение — действие. Это та же идея, что в TRANSITIONS, только пары другие.
  • ("play", "p"): "pause" и ("play", "escape"): "pause" — две разные клавиши ведут к одному действию: так мы получили «P или Esc».
  • for _state in (...) — цикл по четырём состояниям: для каждого дописываем клавиши m и f1, ведь они работают везде. Так проще, чем писать восемь одинаковых строк. Имя _state с подчёркиванием — обычная переменная, подчёркивание лишь напоминает, что она нужна только для цикла.
  • KEY_ACTIONS[(_state, "m")] = "mute" — добавление новой пары в готовый словарь: скобки у кортежа-ключа остаются.
  • KEY_ACTIONS.get((state, key_name)) — метод get возвращает значение по ключу, а если ключа нет, то None, а не ошибку. Неизвестная клавиша и неизвестное состояние попадают под это правило сами, отдельных проверок не нужно.

Действие и состояние — не одно и то же

handle_key отвечает на вопрос «что хотел игрок?», а next_state — на вопрос «куда теперь переходим?». Часть действий ("up", "mute", "quit") состояние не меняют, и main.py обрабатывает их сам. Цепочка такая: клавиша → действие → новое состояние.

Шаг 2. main.py целиком

Теперь соберём игру. Когда у игры четыре экрана, цикл с кусками внутри стал бы огромным, поэтому каждую работу выносим в функцию, а сам цикл остаётся коротким. Файлы sprites.py (как после урока 6.3) и make_assets.py в этом уроке не меняются. Файл main.py замените целиком (код из шага 1 в logic.py уже добавлен).

Python · kometa/main.py
import random

import pygame

from logic import (
    Cooldown,
    SpawnTimer,
    blink_visible,
    effect_volume,
    handle_key,
    level_for_score,
    menu_move,
    meteor_speed,
    next_state,
    spawn_interval,
    wrap,
)
from sprites import ASSETS, Crystal, Explosion, Laser, Meteor, Player, load_frames, load_sound

WIDTH = 960
HEIGHT = 640
FPS = 60
TITLE = "Комета"
BG_COLOR = (12, 14, 32)
WHITE = (255, 255, 255)
BOX_COLOR = (80, 255, 120)
CIRCLE_COLOR = (255, 220, 80)
TITLE_COLOR = (90, 200, 255)
SELECT_COLOR = (255, 220, 80)
GRAY = (150, 150, 170)
RED = (255, 90, 120)
SHIP_HALF = 24
SHIP_MIN_Y = HEIGHT // 2
SHIP_MAX_Y = HEIGHT - 40
SHIP_BOUNDS = (SHIP_HALF, SHIP_MIN_Y, WIDTH - SHIP_HALF, SHIP_MAX_Y)
STAR_SPEED = 40
CRYSTAL_INTERVAL = 2.5
SHOT_COOLDOWN = 0.25
START_LIVES = 3
INVULNERABLE_TIME = 1.5
BLINK_PERIOD = 0.1
METEOR_SCORE = 10
CRYSTAL_SCORE = 25
EXPLOSION_COUNT = 6
LEVEL_BANNER_TIME = 1.5
MASTER_VOLUME = 0.8
EFFECT_LEVELS = {"shot": 0.5, "boom": 0.9, "pickup": 0.7}
MUSIC_LEVEL = 0.4
MENU_ITEMS = ["Играть", "Выход"]

pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption(TITLE)
clock = pygame.time.Clock()
font = pygame.font.Font(None, 32)
font_big = pygame.font.Font(None, 96)
explosion_frames = load_frames("explosion", EXPLOSION_COUNT)

sounds = {
    "shot": load_sound("shot.wav"),
    "boom": load_sound("boom.wav"),
    "pickup": load_sound("pickup.wav"),
}
muted = False

pygame.mixer.music.load(str(ASSETS / "music.wav"))
pygame.mixer.music.play(-1)

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

stars = []
for _ in range(120):
    stars.append([random.randint(0, WIDTH), random.randint(0, HEIGHT), random.randint(1, 3)])

player = Player(WIDTH // 2, HEIGHT - 80, SHIP_BOUNDS)
all_sprites = pygame.sprite.Group()
meteors = pygame.sprite.Group()
crystals = pygame.sprite.Group()
lasers = pygame.sprite.Group()
spawn_timer = SpawnTimer(spawn_interval(1))
crystal_timer = SpawnTimer(CRYSTAL_INTERVAL)
shot_cooldown = Cooldown(SHOT_COOLDOWN)

state = "menu"
menu_index = 0
score = 0
lives = START_LIVES
invulnerable = 0.0
level = 1
level_banner = 0.0
show_boxes = False
running = True


def apply_volumes():
    for name, sound in sounds.items():
        sound.set_volume(effect_volume(MASTER_VOLUME, EFFECT_LEVELS[name], muted))
    pygame.mixer.music.set_volume(effect_volume(MASTER_VOLUME, MUSIC_LEVEL, muted))


def play_sound(name):
    sounds[name].play()


def draw_text(surface, text, pos, color=WHITE):
    image = font.render(text, True, color)
    surface.blit(image, pos)


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 explode(center):
    all_sprites.add(Explosion(center, explosion_frames))
    play_sound("boom")


def new_game():
    global player, score, lives, invulnerable, level, level_banner
    all_sprites.empty()
    meteors.empty()
    crystals.empty()
    lasers.empty()
    player = Player(WIDTH // 2, HEIGHT - 80, SHIP_BOUNDS)
    spawn_timer.time = 0.0
    crystal_timer.time = 0.0
    score = 0
    lives = START_LIVES
    invulnerable = 0.0
    level = 1
    level_banner = 0.0
    spawn_timer.interval = spawn_interval(level)


def handle_event(event):
    global state, running, muted, show_boxes, menu_index
    if event.type == pygame.QUIT:
        running = False
        return
    if event.type != pygame.KEYDOWN:
        return

    action = handle_key(state, pygame.key.name(event.key))
    if action == "quit":
        running = False
    elif action == "mute":
        muted = not muted
        apply_volumes()
    elif action == "boxes":
        show_boxes = not show_boxes
    elif action == "up":
        menu_index = menu_move(menu_index, -1, len(MENU_ITEMS))
    elif action == "down":
        menu_index = menu_move(menu_index, 1, len(MENU_ITEMS))
    elif action == "start":
        if state == "menu" and MENU_ITEMS[menu_index] == "Выход":
            running = False
        else:
            state = next_state(state, "start")
            new_game()
    elif action in ("pause", "resume", "menu"):
        state = next_state(state, action)
        if action == "pause":
            pygame.mixer.music.pause()
        else:
            pygame.mixer.music.unpause()


def update_play(dt):
    global state, score, lives, invulnerable, level, level_banner
    keys = pygame.key.get_pressed()
    invulnerable = max(0.0, invulnerable - dt)
    level_banner = max(0.0, level_banner - dt)

    shot_cooldown.update(dt)
    if keys[pygame.K_SPACE] and shot_cooldown.ready():
        nose_x, nose_y = player.rect.midtop
        laser = Laser(nose_x, nose_y)
        all_sprites.add(laser)
        lasers.add(laser)
        shot_cooldown.start()
        play_sound("shot")

    for _ in range(spawn_timer.update(dt)):
        meteor = Meteor(WIDTH, HEIGHT, meteor_speed(level))
        all_sprites.add(meteor)
        meteors.add(meteor)
    for _ in range(crystal_timer.update(dt)):
        crystal = Crystal(WIDTH, HEIGHT)
        all_sprites.add(crystal)
        crystals.add(crystal)

    player.update(dt, keys)
    all_sprites.update(dt, keys)

    hits = pygame.sprite.spritecollide(player, meteors, True, pygame.sprite.collide_circle)
    for meteor in hits:
        explode(meteor.rect.center)
    if hits and invulnerable <= 0:
        lives -= 1
        invulnerable = INVULNERABLE_TIME

    caught = pygame.sprite.spritecollide(player, crystals, True, pygame.sprite.collide_circle)
    score += CRYSTAL_SCORE * len(caught)
    if caught:
        play_sound("pickup")

    shots = pygame.sprite.groupcollide(lasers, meteors, True, True)
    for hit_meteors in shots.values():
        score += METEOR_SCORE * len(hit_meteors)
        for meteor in hit_meteors:
            explode(meteor.rect.center)

    new_level = level_for_score(score)
    if new_level > level:
        level = new_level
        spawn_timer.interval = spawn_interval(level)
        level_banner = LEVEL_BANNER_TIME

    for star in stars:
        star[1] = wrap(star[1] + STAR_SPEED * star[2] * dt, 0, HEIGHT)

    if lives <= 0:
        state = next_state(state, "die")


def draw_play():
    all_sprites.draw(screen)
    if blink_visible(invulnerable, BLINK_PERIOD):
        screen.blit(player.image, player.rect)
    if show_boxes:
        for sprite in all_sprites:
            pygame.draw.rect(screen, BOX_COLOR, sprite.rect, 2)
        pygame.draw.rect(screen, BOX_COLOR, player.rect, 2)
        pygame.draw.circle(screen, CIRCLE_COLOR, player.rect.center, player.radius, 1)
        for meteor in meteors:
            pygame.draw.circle(screen, CIRCLE_COLOR, meteor.rect.center, meteor.radius, 1)
    draw_text(screen, "Очки: " + str(score), (16, 12))
    draw_text(screen, "Жизни: " + str(lives), (16, 44))
    draw_text(screen, "Уровень: " + str(level), (16, 76))
    if level_banner > 0:
        draw_center(screen, "Уровень " + str(level) + "!", 200, font_big, SELECT_COLOR)


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)


def draw_frame():
    screen.fill(BG_COLOR)
    for x, y, size in stars:
        pygame.draw.circle(screen, WHITE, (x, y), size)
    if state in ("play", "pause"):
        draw_play()
    if state == "menu":
        draw_menu()
    elif state == "pause":
        draw_pause()
    elif state == "gameover":
        draw_gameover()
    pygame.display.flip()


apply_volumes()

while running:
    dt = clock.tick(FPS) / 1000
    for event in pygame.event.get():
        handle_event(event)
    if state == "play":
        update_play(dt)
    draw_frame()

pygame.quit()

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

  • from logic import (... handle_key ...) — к прежнему списку добавилась только handle_key.
  • Константы, screen, шрифты, звуки, shade (чёрная полупрозрачная поверхность для паузы), группы и переменные состояния (state, menu_index, score, lives, level) создаются один раз до цикла.
  • new_game() — сбрасывает мир: группы, нового Player, очки, жизни, уровень, частоту метеоров. global нужен, чтобы функция меняла переменные файла.
  • handle_event(event) — единственное место разбора событий. Строка handle_key(state, pygame.key.name(event.key)) превращает клавишу в действие, а цепочка elif смотрит уже на действие.
  • Ветка "start": если выбран пункт «Выход», программа закрывается, иначе меняется состояние и вызывается new_game(). Паузы и возвраты в меню умещаются в одну ветку: next_state сама знает, куда идти.
  • update_play(dt) — весь мир; когда lives дошло до нуля, происходит переход "die".
  • draw_frame() рисует фон, звёзды, мир (в состояниях "play" и "pause"), затем нужный экран.
  • Главный цикл: handle_event для каждого события, update_play только в "play", потом draw_frame().

Пауза: мир стоит на месте

Как поставить игру на паузу? Представьте магнитофон: кнопка «пауза» не стирает запись и не перематывает её, а просто перестаёт двигать ленту. У нас лентой служит update_play(dt). Пока состояние "pause", цикл эту функцию не вызывает: метеоры, лазеры, таймеры и звёзды не получают dt и замирают. Но draw_frame() работает каждый кадр, поэтому игрок видит замороженный мир, а поверх него — затемнение shade и надпись «ПАУЗА».

Музыку тоже ставим на паузу: при действии "pause" вызывается pygame.mixer.music.pause(), а при возврате в игру или в меню — unpause().

Звук и пауза не мешают друг другу

Кнопка M выключает звук через громкость (apply_volumes), а пауза работает с самой музыкой. Два разных рычага: можно поставить паузу, нажать M и вернуться в игру — звук останется выключенным.

После смерти: «Игра окончена» и новая игра

Когда lives падает до нуля, update_play выполняет state = next_state(state, "die"), и состояние становится "gameover". Мир больше не обновляется, а draw_gameover() показывает «ИГРА ОКОНЧЕНА» и очки. Дальше: Enter — новая игра, Q — в меню, Esc — закрыть программу.

Самое важное — Enter: он должен не только переключить состояние на "play", но и вызвать new_game(), иначе игра начнётся со старыми метеорами и нулём жизней и тут же закончится снова.

Одно нажатие — одно действие

Событие KEYDOWN приходит один раз, и handle_event обрабатывает его один раз. Если бы мы ещё проверяли Esc через pygame.key.get_pressed(), пауза включилась бы и тут же выключилась. Разовые нажатия берите из событий, зажатые клавиши (стрелки, Пробел) — из get_pressed().

Практика: запуск и проверка

Шаги:

  1. Добавьте KEY_ACTIONS и handle_key в конец logic.py.
  2. Замените содержимое main.py кодом из шага 2.
  3. Убедитесь, что папка assets/ на месте (иначе один раз запустите make_assets.py).
  4. Активируйте виртуальное окружение и запустите игру.
Терминал
python main.py

Должно получиться: меню с заголовком «КОМЕТА» и пунктами «Играть» и «Выход», мигает подсказка. Enter на «Играть» открывает игру, P или Esc ставит паузу, на которой всё замерло и экран затемнён.

Саму функцию-переводчик можно проверить и без окна, прямо в терминале (это возможно, потому что logic.py не использует pygame):

Терминал
python -c "from logic import handle_key; print(handle_key('play', 'escape'), handle_key('play', 'return'))"
Вывод
pause None

Проверьте себя. Пройдите по кругу и сверьтесь со схемой:

  • В меню стрелки двигают маркер >, а на «Выход» Enter закрывает окно.
  • В игре Esc ставит паузу, а не закрывает окно; повторный Esc или P продолжает игру.
  • На паузе метеоры не двигаются, а Q возвращает в меню.
  • Дайте кораблю столкнуться три раза: появляется «ИГРА ОКОНЧЕНА» с очками; Enter запускает новую игру с тремя жизнями и нулём очков.
  • M и F1 работают в любом из четырёх экранов.

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

Esc в игре закрывает программу вместо паузы

Видно: нажали Esc, чтобы остановиться, а окно исчезло. Причина: в цикле остался старый код вида if event.key == pygame.K_ESCAPE: running = False, который срабатывает в любом состоянии. Исправление: удалите его. Закрывает программу только действие "quit", а handle_key выдаёт его для Esc лишь в "menu" и "gameover".

«Играть» запускает игру со старым миром

Видно: после новой игры сразу «Игра окончена» или остатки прошлых метеоров. Причина: в ветке "start" забыли вызвать new_game(). Исправление: вызывайте её после каждой смены состояния на "play" через старт.

Пауза включается и тут же выключается

Видно: нажали P, а игра как будто и не остановилась. Причина: одно нажатие обработано дважды — например, клавиша проверяется ещё и через get_pressed(). Исправление: один разбор событий в handle_event, а get_pressed() только для движения и стрельбы.

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

  • Смысл клавиши зависит от состояния: пара (state, key_name) переводится в действие функцией handle_key, а пара (state, action) — в новое состояние функцией next_state.
  • Обе таблицы — словари с ключами-кортежами, а .get спасает от неизвестных пар.
  • Имя клавиши даёт pygame.key.name(event.key); логика получает лишь строку.
  • Мир обновляется только в состоянии "play"; на паузе мы лишь рисуем и ставим музыку на pause().
  • При каждом старте игры вызывается new_game(); одно нажатие обрабатывается в одном месте один раз.

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

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