В этом модуле «Комета» научилась жить в нескольких состояниях, но куски лежат отдельными фрагментами, а клавиши разбираются длинной цепочкой 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 это будет одна строка:
if event.type == pygame.KEYDOWN:
key_name = pygame.key.name(event.key)
Для logic.py такое имя — просто обычная строка: pygame там не нужен, и файл по-прежнему не импортирует его. Добавьте в конец 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 уже добавлен).
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().
Практика: запуск и проверка
Шаги:
- Добавьте
KEY_ACTIONSиhandle_keyв конецlogic.py. - Замените содержимое
main.pyкодом из шага 2. - Убедитесь, что папка
assets/на месте (иначе один раз запуститеmake_assets.py). - Активируйте виртуальное окружение и запустите игру.
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(); одно нажатие обрабатывается в одном месте один раз.
В следующем уроке мы научим игру помнить лучший результат: рекорд будет сохраняться в файл и не пропадёт после закрытия программы.