Наша «Комета» уже умеет почти всё: летит, стреляет, считает очки и жизни. Но живёт она одной жизнью: окно открылось — сразу полетели метеоры, а когда жизни кончились, программа просто закрылась. Настоящей игре нужно меню перед стартом, пауза, чтобы отвлечься, и экран «Игра окончена» с возможностью сыграть ещё раз. В этом уроке научим игру переключаться между такими «экранами» по понятным правилам.
Игра — это несколько экранов
Представьте светофор. В каждый момент на нём горит ровно один сигнал, и от него зависит, что делают машины: на красном стоят, на зелёном едут. Сигналы меняются не как попало, а по правилам: после зелёного идёт жёлтый, после жёлтого — красный. Светофор ни разу не бывает «красным и зелёным одновременно», и из красного он не перескакивает в произвольный цвет.
Игра устроена так же. В каждый момент она находится в одном режиме, и от режима зависит всё: что делают клавиши, движется ли мир, что нарисовано на экране. Официальное название такого режима — состояние игры (по-английски 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(ключ, запасное_значение). Он ищет ключ и возвращает значение; а если ключа нет, возвращает запасное, вместо того чтобы падать с ошибкой. Как раз то, что нам надо: «нет такого перехода — остаёмся где были».
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.
Проверим на трёх случаях: разрешённый переход, запрещённый и совсем неизвестное состояние.
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": так все правила остаются в одном месте.
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, и спрайты замирают там, где были.
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 и перечисляют имена.
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:
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:
from logic import Cooldown, SpawnTimer, blink_visible, effect_volume, next_state, wrap
Дальше найдите функцию explode и переменные после неё (score = 0 и остальные, вплоть до running = True включительно). Замените этот кусок на следующий: появилась переменная state и функция new_game().
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 теперь не закрывает игру в любой момент — в бою он ставит на паузу, а закрывает только в меню и на экране «Игра окончена».
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.
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() включительно. Заменяем его целиком (первые две строки примера снова только для ориентира):
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.
Игра теперь умеет жить в нескольких состояниях, но экраны у неё пока выглядят как черновик. В следующем уроке сделаем настоящее меню: крупный заголовок по центру, пункты, которые выбираются стрелками, и мигающую подсказку.