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

Экраны и состояния

Вы научитесь описывать игру как набор состояний и переходов и напишете функцию next_state, которая переключает экраны.

Теория + практика~35 минутСредний

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

Игра — это несколько экранов

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

Игра устроена так же. В каждый момент она находится в одном режиме, и от режима зависит всё: что делают клавиши, движется ли мир, что нарисовано на экране. Официальное название такого режима — состояние игры (по-английски state). Хранить его проще всего в обычной переменной со строкой: state = "menu".

У «Кометы» будет четыре состояния:

СостояниеЧто происходитЖивёт ли мир
"menu"Название игры и ожидание стартанет, рисуем только меню
"play"Сама игра: корабль, метеоры, выстрелыда, обновляется каждый кадр
"pause"Игра остановлена, но видна на экраненет, всё заморожено
"gameover"Жизни кончились, показываем итогнет, ждём решения игрока

Заметьте: одна и та же клавиша в разных состояниях значит разное. Enter в меню начинает игру, а на экране «Игра окончена» запускает её заново. Без состояний пришлось бы городить кучу переменных-флагов вроде in_menu, is_paused, is_dead и следить, чтобы два из них случайно не оказались истинными одновременно. Одна переменная state такой путаницы не допускает.

Переходы: что и когда переключает экран

Состояния нужно уметь менять. Смена одного состояния другим называется переходом. Что запускает переход? Не клавиша напрямую, а действие — короткая строка, которая говорит, что произошло: "start" (начать игру), "pause" (поставить на паузу), "resume" (продолжить), "die" (жизни кончились), "menu" (вернуться в меню).

Зачем промежуточное слово, если можно сразу писать «нажали P — пауза»? Затем, что клавиша P — это событие pygame, а "pause" — смысл, который мы ему придаём. Один и тот же смысл могут вызывать разные клавиши (P и Esc), а правила переходов можно описать и проверить, вообще не зная про клавиши и окна. Помните главный приём курса: логика отдельно от отрисовки. Правила переходов — чистая логика, поэтому они поселятся в logic.py.

Выпишем все разрешённые переходы в таблицу: в левой колонке — где мы сейчас и что произошло, в правой — куда попадаем.

БылоДействиеСтало
"menu""start""play"
"play""pause""pause"
"pause""resume""play"
"play""die""gameover"
"gameover""start""play"
"pause""menu""menu"
"gameover""menu""menu"

Набор состояний плюс таблица переходов между ними называется конечным автоматом (по-английски finite-state machine). Название страшнее сути: это ровно та схема, по которой работает светофор, турникет в метро или кнопка плеера «играть/пауза».

А что с парами, которых нет в таблице?

Например, что значит действие "pause" в меню? Ничего: ставить на паузу нечего. Правило простое: если такого перехода в таблице нет, состояние не меняется. Тогда случайное нажатие не может сломать игру, и нам не нужно проверять в коде кучу «а можно ли сейчас?».

Таблица в коде: TRANSITIONS и next_state

Таблицу переходов удобно хранить в словаре — вы уже встречали его, когда собирали sounds: пары «ключ → значение», где по ключу мгновенно находится значение. У нас ключом будет сразу пара «состояние плюс действие». Для этого подходит кортеж — тот самый неизменяемый набор в скобках, как (x, y, w, h) у прямоугольников. Ключ-кортеж работает как адрес «дом и квартира»: одного номера дома мало, нужны оба.

Второй новый инструмент — метод словаря .get(ключ, запасное_значение). Он ищет ключ и возвращает значение; а если ключа нет, возвращает запасное, вместо того чтобы падать с ошибкой. Как раз то, что нам надо: «нет такого перехода — остаёмся где были».

Python · kometa/logic.py
TRANSITIONS = {
    ("menu", "start"): "play",
    ("play", "pause"): "pause",
    ("pause", "resume"): "play",
    ("play", "die"): "gameover",
    ("gameover", "start"): "play",
    ("pause", "menu"): "menu",
    ("gameover", "menu"): "menu",
}


def next_state(state, action):
    return TRANSITIONS.get((state, action), state)

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

  • TRANSITIONS = {...} — имя константы пишем заглавными, как WIDTH: таблицу во время игры никто не меняет. Каждая строка внутри — одна строка нашей таблицы.
  • ("menu", "start"): "play" — ключ-кортеж ("menu", "start"), значение "play": «в меню действие start ведёт в play».
  • def next_state(state, action) — функция принимает текущее состояние и действие и возвращает новое состояние. Сама ничего не меняет: присвоить результат переменной — забота вызывающего.
  • (state, action) — собираем из двух аргументов кортеж-ключ, такой же, как в таблице.
  • .get(..., state) — нашли ключ: вернули новое состояние; не нашли: вернули прежнее state.

Проверим на трёх случаях: разрешённый переход, запрещённый и совсем неизвестное состояние.

Python · фрагмент
print(next_state("menu", "start"))
print(next_state("menu", "pause"))
print(next_state("lobby", "start"))
Вывод
play
menu
lobby

Первая строка — переход по таблице. Во второй пары ("menu", "pause") в таблице нет, поэтому остаёмся в menu. В третьей незнакомое состояние "lobby" тоже не ломает функцию: она вернёт его же. Исключений и длинных цепочек if нет, а новый переход — это одна новая строка в словаре.

Ключом словаря не может быть список

Ключи должны быть неизменяемыми: строка, число, кортеж. Если написать ["menu", "start"] в квадратных скобках, Python ответит TypeError: unhashable type: 'list'. В таблице переходов всегда круглые скобки.

Цикл с состояниями

Теперь посмотрим, как состояние управляет самим игровым циклом. Каждый кадр мы делаем три дела — разбираем события, обновляем мир и рисуем, — и в каждом случае смотрим на state.

События

Клавиша может значить разное в зависимости от состояния, поэтому в обработчике KEYDOWN мы сначала спрашиваем, где находимся. А вот сам переход всегда делаем через next_state, а не присваиванием state = "pause": так все правила остаются в одном месте.

Python · фрагмент
for event in pygame.event.get():
    if event.type == pygame.QUIT:
        running = False
    elif event.type == pygame.KEYDOWN:
        if state == "menu" and event.key == pygame.K_RETURN:
            state = next_state(state, "start")
        elif state == "play" and event.key == pygame.K_p:
            state = next_state(state, "pause")
        elif state == "pause" and event.key == pygame.K_p:
            state = next_state(state, "resume")

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

  • state == "menu" and event.key == pygame.K_RETURN — обе части должны быть верны: мы в меню и нажат Enter. В другом состоянии Enter эту ветку не заденет.
  • state = next_state(state, "start") — спрашиваем у таблицы, куда идти из текущего состояния по действию "start", и записываем ответ обратно в state.
  • elif ... K_p (два раза) — одна клавиша P означает "pause" в игре и "resume" на паузе; какое именно, решает состояние.

Почему здесь именно elif, а не серия if

Если заменить elif на обычные if, то после первого перехода (play → pause) проверка следующей ветки в том же событии увидит уже новое состояние "pause" и тут же вернёт игру назад. Для игрока это выглядит так: нажал P — ничего не произошло. Одно событие — не больше одного перехода, поэтому цепочка if / elif обязательна.

Обновление мира

Мир оживляем только в состоянии "play". Это и есть пауза: мы просто перестаём вызывать update, и спрайты замирают там, где были.

Python · фрагмент
if state == "play":
    all_sprites.update(dt, keys)

Всё, что двигает мир, живёт внутри этого if: спрайты, таймеры появления, неуязвимость, перезарядка. Если хоть один таймер остался снаружи, на паузе он продолжит тикать, и после возврата в игру вас ждёт сюрприз.

Отрисовка

Рисуем тоже по состоянию: мир в "play" и "pause" (на паузе он виден, но неподвижен), а в "menu" и "gameover" — свой экран. Порядок кадра остаётся прежним: события, обновление, рисование, flip. Меняется только то, какие ветки в нём выполняются.

Новая игра с чистого листа: new_game() и global

Когда игрок на экране «Игра окончена» жмёт Enter, игра должна начаться заново: очки в ноль, жизни снова три, на экране ни одного старого метеора. Собирать всё это руками в каждом месте, где стартует игра, неудобно — забудете одну переменную, и новая партия начнётся с мусором от прошлой. Поэтому весь сброс прячут в одну функцию, которую принято называть new_game(): что-то вроде кнопки «Новая партия» в настольной игре, где фишки сразу расставляются по местам.

Есть одна тонкая вещь. Переменные, которые функция создаёт присваиванием, по умолчанию локальные: они живут только внутри функции и пропадают после неё. Строка score = 0 внутри new_game() создаст маленькую временную score, а настоящая переменная игры останется со старым значением. Чтобы сказать Python «я имею в виду переменную снаружи, из модуля», перед присваиванием пишут ключевое слово global и перечисляют имена.

Python · фрагмент
score = 0
lives = 3


def new_game():
    global score, lives
    score = 0
    lives = 3
    meteors.empty()

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

  • global score, lives — «эти два имени принадлежат модулю, а не функции». Теперь присваивания ниже меняют настоящие переменные.
  • score = 0 и lives = 3 — возвращаем начальные значения.
  • meteors.empty() — метод группы спрайтов: убирает из неё все спрайты. Здесь global не нужен: мы не присваиваем имени meteors новое значение, а лишь вызываем метод у существующей группы.

Ловушка: функция сбрасывает «не то»

Без строки global игра не выдаст ошибки — она просто молча не сработает: после new_game() очки останутся прежними. Если сброс «ничего не делает», первым делом проверьте global.

Вызываем new_game() каждый раз, когда переходим в "play" из "menu" или "gameover". А вот при возврате с паузы не вызываем: пауза обязана сохранить партию как есть.

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

Подключаем состояния к нашему main.py — тому, что получился в конце предыдущего модуля, со звуками и взрывами. Экраны пока будут простыми: одна строка текста на каждый. Красивым меню займёмся в следующем уроке. Менять нужно logic.py и пять мест в main.py.

Шаг 1. Таблица переходов в logic.py

Добавьте в конец logic.py код из раздела про TRANSITIONS:

Python · kometa/logic.py (добавить в конец файла)
TRANSITIONS = {
    ("menu", "start"): "play",
    ("play", "pause"): "pause",
    ("pause", "resume"): "play",
    ("play", "die"): "gameover",
    ("gameover", "start"): "play",
    ("pause", "menu"): "menu",
    ("gameover", "menu"): "menu",
}


def next_state(state, action):
    return TRANSITIONS.get((state, action), state)

Шаг 2. Импорт, состояние и new_game() в main.py

Первая строка с импортом из logic получает ещё одно имя — next_state:

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

Дальше найдите функцию explode и переменные после неё (score = 0 и остальные, вплоть до running = True включительно). Замените этот кусок на следующий: появилась переменная state и функция new_game().

Python · kometa/main.py (после функции explode, включая running = True)
state = "menu"
score = 0
lives = START_LIVES
invulnerable = 0.0
show_boxes = False


def new_game():
    global player, score, lives, invulnerable
    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


running = True

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

  • state = "menu" — игра начинается в меню, а не сразу в бою.
  • global player, score, lives, invulnerable — все четыре имени мы здесь пересоздаём присваиванием, поэтому все четыре перечислены.
  • all_sprites.empty(), meteors.empty(), crystals.empty(), lasers.empty() — убираем старые метеоры, кристаллы, лазеры и взрывы. Забыть одну группу — значит начать новую партию с призраками прошлой.
  • player = Player(...) — корабль создаём заново с теми же аргументами, что и в начале файла: он возвращается на стартовую позицию.
  • spawn_timer.time = 0.0 и crystal_timer.time = 0.0 — обнуляем накопленное время таймеров появления, чтобы новая партия не началась с пачки метеоров сразу.
  • score, lives, invulnerable — возвращаем начальные значения; START_LIVES уже есть среди констант.

Шаг 3. События зависят от состояния

Блок elif event.type == pygame.KEYDOWN: целиком заменяем на новый. Строки while running:, ... и первые две ветки с QUIT показаны только для ориентира — они у вас уже есть. Обратите внимание: Esc теперь не закрывает игру в любой момент — в бою он ставит на паузу, а закрывает только в меню и на экране «Игра окончена».

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_RETURN:
                state = next_state(state, "start")
                new_game()
            elif state == "play" and event.key in (pygame.K_p, pygame.K_ESCAPE):
                state = next_state(state, "pause")
            elif state == "pause" and event.key in (pygame.K_p, pygame.K_ESCAPE):
                state = next_state(state, "resume")
            elif state == "pause" and event.key == pygame.K_q:
                state = next_state(state, "menu")
            elif state == "gameover" and event.key == pygame.K_RETURN:
                state = next_state(state, "start")
                new_game()
            elif state == "gameover" and event.key == pygame.K_q:
                state = next_state(state, "menu")
            elif state in ("menu", "gameover") and event.key == pygame.K_ESCAPE:
                running = False

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

  • F1 и M — работают в любом состоянии, поэтому стоят первыми и состояние не проверяют. Звук можно выключить и в меню.
  • state == "menu" and event.key == pygame.K_RETURN — Enter в меню: переходим в "play" и сразу готовим чистую партию через new_game().
  • event.key in (pygame.K_p, pygame.K_ESCAPE) — оператор in спрашивает, есть ли значение среди перечисленных в скобках: подходит и P, и Esc.
  • state == "pause" and event.key == pygame.K_q — Q на паузе возвращает в меню (M уже занята под звук).
  • state == "gameover" — Enter запускает новую партию, Q ведёт в меню.
  • state in ("menu", "gameover") and ... K_ESCAPE — единственное место, где Esc закрывает программу. Эта ветка стоит последней, и она не мешает остальным: в бою и на паузе Esc уже обработан выше.
  • Всё в одной цепочке if / elif: за одно нажатие произойдёт максимум один переход.

Шаг 4. Мир живёт только в состоянии play

Теперь самое важное для паузы. Всё, что двигает мир, уходит внутрь if state == "play":. Строки while running: и ... в начале примера тоже лишь ориентир, их копировать не нужно. Возьмите кусок от строки keys = pygame.key.get_pressed() до цикла со звёздами включительно и замените его на этот. Почти весь код тот же, что и был, только сдвинут на один отступ вправо; настоящих изменений два: проверка состояния сверху и строка state = next_state(state, "die") на месте прежних print и running = False.

Python · kometa/main.py (обновление мира)
while running:
    ...
    keys = pygame.key.get_pressed()

    if state == "play":
        invulnerable = max(0.0, invulnerable - 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)
            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)

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

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

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

  • keys = ... — опрос клавиш остаётся снаружи: дёшево и безвредно, а в разных состояниях он не нужен, но и не мешает.
  • if state == "play": — единственные ворота, через которые проходит время. Метеоры, лазеры, перезарядка, неуязвимость, таймеры появления, звёзды и столкновения заморожены, пока состояние другое.
  • state = next_state(state, "die") — жизни кончились: просим у таблицы переход. Игра не закрывается, а переходит в "gameover"; со следующего кадра ветка play уже не выполняется.

Шаг 5. Рисуем по состоянию

Последний кусок цикла — от screen.fill(BG_COLOR) до pygame.display.flip() включительно. Заменяем его целиком (первые две строки примера снова только для ориентира):

Python · kometa/main.py (отрисовка)
while running:
    ...
    screen.fill(BG_COLOR)
    for x, y, size in stars:
        pygame.draw.circle(screen, WHITE, (x, y), size)

    if state in ("play", "pause"):
        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))
    if state == "menu":
        draw_text(screen, "КОМЕТА", (400, 260))
        draw_text(screen, "Enter - играть, Esc - выход", (310, 310))
    elif state == "pause":
        draw_text(screen, "ПАУЗА", (420, 260))
        draw_text(screen, "P или Esc - продолжить, Q - в меню", (250, 310))
    elif state == "gameover":
        draw_text(screen, "ИГРА ОКОНЧЕНА", (370, 240))
        draw_text(screen, "Очки: " + str(score), (415, 285))
        draw_text(screen, "Enter - ещё раз, Q - в меню, Esc - выход", (230, 335))
    pygame.display.flip()

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

  • if state in ("play", "pause"): — мир со всеми спрайтами, мигающим кораблём, рамками отладки и табло рисуем и в бою, и на паузе. На паузе кадры просто повторяют одну и ту же картинку, поэтому всё выглядит замороженным.
  • if state == "menu": ... elif ... — текстовые заглушки экранов. Координаты подобраны примерно по центру окна, точное центрирование будет в следующем уроке.
  • str(score) на экране конца игры — очки не сбрасываются, пока не вызвали new_game(), поэтому итог ещё доступен.
  • После цикла остаётся прежний pygame.quit().

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

Запустите игру из папки kometa/ с активным виртуальным окружением:

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

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

Музыка во всех состояниях продолжает играть — её мы не трогали, а выключается она по M. Остановить музыку на паузе можно через pygame.mixer.music.pause(); так мы и сделаем в практикуме 6.4, а пока достаточно того, что мир стоит.

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

  • Поставьте игру на паузу посреди налёта метеоров: ни один не должен сдвинуться, а очки — измениться.
  • Нажмите P в меню: ничего не произойдёт (пары ("menu", "pause") нет в таблице).
  • Доведите игру до конца и нажмите Enter: на экране нет старых метеоров, очки равны нулю, жизней три.
  • Нажмите P один раз — пауза включилась, а не мигнула и не пропала. Почему здесь важен elif?
  • Добавьте в TRANSITIONS строку для нового перехода, например из "pause" в "gameover", и убедитесь, что функцию next_state менять не нужно.

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

На паузе метеоры продолжают падать

Что видно: после P корабль замер, но метеоры летят, а после возврата в игру их стало больше.

Почему так: часть обновления мира осталась вне if state == "play": — например, meteors.update(dt) или таймер появления.

Как исправить: всё, что зависит от dt, переносите внутрь ветки play. Если что-то должно идти и на паузе, это осознанное исключение.

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

Что видно: нажали P — ничего не изменилось, надпись «ПАУЗА» не появилась.

Почему так: проверки в KEYDOWN написаны отдельными if. Первая переводит play в pause, вторая в том же событии видит pause и возвращает play.

Как исправить: соедините проверки в одну цепочку if / elif / elif: сработает только одна ветка.

Новая партия начинается со старыми метеорами и очками

Что видно: после «Игра окончена» и Enter на экране остались метеоры, очки не обнулились, жизней по-прежнему ноль, и игра сразу заканчивается.

Почему так: new_game() не вызвали при переходе, не очистили одну из групп либо забыли global и присваивания менялись только внутри функции.

Как исправить: проверьте, что рядом с каждым next_state(state, "start") стоит new_game(), что в нём очищены все четыре группы, а имена переменных перечислены в global.

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

  • Состояние игры — строка в переменной state: "menu", "play", "pause", "gameover". В каждый момент действует ровно одно.
  • Смена состояния — переход по действию ("start", "pause", "resume", "die", "menu"). Состояния плюс переходы называются конечным автоматом.
  • Правила переходов живут в словаре TRANSITIONS с ключами-кортежами (state, action), а next_state ищет в нём через .get(..., state): неизвестный переход не меняет состояние.
  • Цикл смотрит на state трижды: в событиях, в обновлении мира (только "play") и в отрисовке.
  • Переходы в KEYDOWN пишем цепочкой if / elif, чтобы за одно событие произошёл один переход.
  • new_game() собирает весь сброс партии в одном месте; присваивая внешним переменным внутри функции, объявляйте их через global.

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

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