$ sudo teach IT
Модуль 5 · Графика и звук · Урок 5.4

Практикум: оживляем «Комету»

Вы соберёте «Комету» с настоящими картинками, вращающимися метеорами, взрывами, звуками и музыкой, а клавишей M выключите звук.

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

За этот модуль вы научились загружать картинки, вращать их, листать кадры взрыва и заставлять играть звуки. Но всё это лежало в отдельных кусочках по разным урокам. Сегодня соберём всё вместе: получим три полных файла — make_assets.py, sprites.py и main.py — и одну новую мелочь, которая наведёт порядок в громкости.

Громкость: один регулятор на всё

Представьте звуковой микшер на концерте. Есть общий регулятор громкости и отдельные ползунки у гитары, барабанов и вокала. Итоговая громкость каждого инструмента — это общий уровень, умноженный на его собственный. А кнопка «Mute» просто обнуляет всё сразу. В «Комете» то же самое: общий уровень, уровень выстрела, взрыва, подбора кристалла и музыки. В прошлом уроке всё было проще и грубее: громкость выстрела и музыки стояла числами прямо в коде (0,4 и 0,3), а клавиша M ставила музыку на паузу и проверяла muted перед каждым эффектом. Теперь заменим это одним расчётом громкости.

Официально громкость в pygame — число от 0.0 (тишина) до 1.0 (максимум). Её задают методом Sound.set_volume(...) для коротких эффектов и функцией pygame.mixer.music.set_volume(...) для музыки. Расчёт — чистая арифметика, поэтому вынесем его в logic.py, где нет pygame и где его можно проверить без окна.

Функция effect_volume(master, effect, muted) получает общий уровень master, уровень конкретного звука effect и флаг muted. Если звук выключен, она возвращает 0.0. Иначе перемножает уровни, зажимает результат в диапазон от 0 до 1 (знакомая clamp) и округляет до трёх знаков.

Python · kometa/logic.py (добавить в конец файла)
def effect_volume(master, effect, muted):
    if muted:
        return 0.0
    return round(clamp(master * effect, 0.0, 1.0), 3)

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

  • if muted: return 0.0 — выключенный звук сразу получает нулевую громкость, считать больше нечего.
  • master * effect — общий уровень, умноженный на уровень этого звука: при 0.8 и 0.5 получится 0.4.
  • clamp(..., 0.0, 1.0) — даже если кто-то ошибся и поставил уровень 2, громкость не выйдет за пределы от 0 до 1.
  • round(..., 3) — округляем до тысячных, чтобы не тащить в игру длинные хвосты вроде 0.1108889.

Проверьте функцию отдельно, без окна:

Терминал
python -c "from logic import effect_volume; print(effect_volume(0.8, 0.5, False), effect_volume(0.8, 0.9, True), effect_volume(2, 1, False))"
Вывод
0.4 0.0 1.0

План сборки

Меняются четыре файла. Таблица показывает, что где происходит, а ниже каждый файл дан целиком, чтобы вы могли просто сверить свой с образцом.

ФайлЧто в нём
logic.pyДобавляется effect_volume. Остальное не меняется.
make_assets.pyРисует 4 картинки и 6 кадров взрыва, синтезирует 4 звука. Запускается один раз.
sprites.pyКартинки и звуки берутся из assets/: load_image, load_frames, load_sound. Метеор вращается, добавлен класс Explosion.
main.pyПодключены взрывы, звуки, музыка и клавиша M. Громкости ставятся через effect_volume.

Практика: собираем проект

Работаем в папке kometa/, виртуальное окружение включено.

Шаг 1. make_assets.py целиком

Этот скрипт рисует все картинки и синтезирует все звуки. Он не часть игры: запускаете его один раз, и он наполняет папку assets/. Замените файл целиком:

Python · kometa/make_assets.py
import math
import random
import wave
from array import array
from pathlib import Path

import pygame

from logic import ship_points

ASSETS = Path(__file__).parent / "assets"
EXPLOSION_COUNT = 6
RATE = 22050


def make_ship():
    image = pygame.Surface((48, 40), pygame.SRCALPHA)
    pygame.draw.circle(image, (255, 170, 60), (24, 35), 4)
    pygame.draw.circle(image, (255, 230, 140), (24, 35), 2)
    points = ship_points(24, 22, 20)
    pygame.draw.polygon(image, (90, 200, 255), points)
    pygame.draw.polygon(image, (255, 255, 255), points, 2)
    pygame.draw.line(image, (190, 235, 255), (24, 8), (24, 26), 2)
    pygame.draw.circle(image, (20, 60, 110), (24, 20), 4)
    return image


def make_meteor():
    image = pygame.Surface((64, 64), pygame.SRCALPHA)
    pygame.draw.circle(image, (120, 124, 140), (32, 32), 31)
    for cx, cy, r in [(22, 24, 8), (42, 40, 10), (40, 18, 5)]:
        pygame.draw.circle(image, (90, 94, 110), (cx, cy), r)
        pygame.draw.circle(image, (70, 74, 90), (cx, cy), r, 2)
    pygame.draw.circle(image, (70, 74, 90), (32, 32), 31, 3)
    pygame.draw.circle(image, (175, 180, 198), (20, 16), 4)
    return image


def make_crystal():
    image = pygame.Surface((32, 40), pygame.SRCALPHA)
    points = [(16, 2), (30, 20), (16, 38), (2, 20)]
    pygame.draw.polygon(image, (60, 220, 170), points)
    pygame.draw.polygon(image, (150, 255, 225), [(16, 2), (2, 20), (16, 20)])
    pygame.draw.polygon(image, (30, 150, 120), [(16, 20), (30, 20), (16, 38)])
    pygame.draw.polygon(image, (255, 255, 255), points, 2)
    return image


def make_laser():
    image = pygame.Surface((8, 28), pygame.SRCALPHA)
    pygame.draw.rect(image, (255, 90, 120), (0, 0, 8, 28), border_radius=4)
    pygame.draw.rect(image, (255, 230, 235), (3, 3, 2, 22), border_radius=1)
    return image


def make_explosion_frame(index, count):
    t = index / (count - 1)
    radius = 10 + int(t * 36)
    alpha = int(255 - t * 204)
    image = pygame.Surface((96, 96), pygame.SRCALPHA)
    center = (48, 48)
    pygame.draw.circle(image, (255, 120, 40, alpha), center, radius)
    pygame.draw.circle(image, (255, 200, 80, alpha), center, int(radius * 0.7))
    pygame.draw.circle(image, (255, 245, 200, alpha), center, int(radius * 0.35))
    return image


def save(image, name):
    pygame.image.save(image, ASSETS / name)


def tone_samples(freq, duration, rate, volume):
    count = int(duration * rate)
    samples = []
    for i in range(count):
        t = i / rate
        value = math.sin(2 * math.pi * freq * t) * volume
        samples.append(int(value * 32767))
    return samples


def noise_samples(duration, rate, volume):
    count = int(duration * rate)
    samples = []
    for _ in range(count):
        value = random.uniform(-1, 1) * volume
        samples.append(int(value * 32767))
    return samples


def fade_out(samples):
    count = len(samples)
    result = []
    for i in range(count):
        k = 1 - i / count
        result.append(int(samples[i] * k))
    return result


def note_freq(semitones):
    return 440 * 2 ** (semitones / 12)


def save_wav(path, samples, rate):
    with wave.open(str(path), "wb") as f:
        f.setnchannels(1)
        f.setsampwidth(2)
        f.setframerate(rate)
        f.writeframes(array("h", samples).tobytes())


ASSETS.mkdir(exist_ok=True)
save(make_ship(), "ship.png")
save(make_meteor(), "meteor.png")
save(make_crystal(), "crystal.png")
save(make_laser(), "laser.png")
for i in range(EXPLOSION_COUNT):
    save(make_explosion_frame(i, EXPLOSION_COUNT), "explosion_" + str(i) + ".png")

shot = fade_out(tone_samples(880, 0.15, RATE, 0.5))
boom = fade_out(noise_samples(0.5, RATE, 0.6))
pickup = tone_samples(660, 0.08, RATE, 0.4) + fade_out(tone_samples(990, 0.14, RATE, 0.4))

melody = [0, 3, 7, 3, 0, 3, 7, 12, 10, 7, 5, 7, 3, 0, 3, 0]
music = []
for n in melody:
    note = tone_samples(note_freq(n), 0.25, RATE, 0.3)
    music = music + fade_out(note)

save_wav(ASSETS / "shot.wav", shot, RATE)
save_wav(ASSETS / "boom.wav", boom, RATE)
save_wav(ASSETS / "pickup.wav", pickup, RATE)
save_wav(ASSETS / "music.wav", music, RATE)
print("Картинки и звуки сохранены в", ASSETS)

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

  • ASSETS, EXPLOSION_COUNT, RATE — папка для файлов, число кадров взрыва и число отсчётов звука в секунду.
  • make_ship, make_meteor, make_crystal, make_laser — рисуют картинки на прозрачных поверхностях.
  • make_explosion_frame(index, count) — один кадр взрыва: чем больше номер, тем крупнее и прозрачнее круг.
  • save и save_wav — записывают картинку и звук в файл.
  • tone_samples, noise_samples, fade_out, note_freq — строительные блоки звука: тон, шум, затухание, частота ноты.
  • ASSETS.mkdir(exist_ok=True) — создаёт папку, если её ещё нет; дальше идут сохранения картинок, кадров взрыва и четырёх звуков.

Запустите и посмотрите, что получилось:

Терминал
python make_assets.py
ls assets

На Windows вместо ls assets напишите dir assets. В конце должна появиться строка Картинки и звуки сохранены в ..., а в папке — 14 файлов:

Вывод
boom.wav       explosion_1.png  explosion_5.png  pickup.wav
crystal.png    explosion_2.png  laser.png        ship.png
explosion_0.png  explosion_3.png  meteor.png     shot.wav
explosion_4.png  music.wav

Пока скрипт не запускался, этих файлов нет.

Папка assets обязательна

Пока make_assets.py не запускался, игра не стартует: load_image не найдёт файл и сообщит о FileNotFoundError. Если вы изменили рисунок или мелодию, запустите скрипт снова — он перезапишет файлы.

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

Теперь спрайты: все картинки берём из файлов, метеор вращается, взрыв листает кадры. Замените файл целиком:

Python · kometa/sprites.py
import random
from pathlib import Path

import pygame

from logic import direction_from_keys, frame_index, step_ship

ASSETS = Path(__file__).parent / "assets"
METEOR_MIN_SIZE = 20
METEOR_MAX_SIZE = 48
PLAYER_SPEED = 360
PLAYER_RADIUS = 16
CRYSTAL_RADIUS = 14
CRYSTAL_SPEED = 90
OFFSCREEN_MARGIN = 40
LASER_SPEED = 700
EXPLOSION_FRAME_TIME = 0.06


def load_image(name):
    return pygame.image.load(ASSETS / name).convert_alpha()


def load_sound(name):
    return pygame.mixer.Sound(str(ASSETS / name))


def load_frames(prefix, count):
    frames = []
    for i in range(count):
        frames.append(load_image(f"{prefix}_{i}.png"))
    return frames


class Player(pygame.sprite.Sprite):
    def __init__(self, x, y, bounds):
        super().__init__()
        self.image = load_image("ship.png")
        self.rect = self.image.get_rect()
        self.radius = PLAYER_RADIUS
        self.pos = pygame.Vector2(x, y)
        self.bounds = bounds
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, keys):
        left = keys[pygame.K_LEFT] or keys[pygame.K_a]
        right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
        up = keys[pygame.K_UP] or keys[pygame.K_w]
        down = keys[pygame.K_DOWN] or keys[pygame.K_s]
        dx, dy = direction_from_keys(left, right, up, down)
        x, y = step_ship(self.pos.x, self.pos.y, dx, dy, PLAYER_SPEED, dt, self.bounds)
        self.pos.x, self.pos.y = x, y
        self.rect.center = (round(self.pos.x), round(self.pos.y))


class Meteor(pygame.sprite.Sprite):
    def __init__(self, width, height):
        super().__init__()
        self.size = random.randint(METEOR_MIN_SIZE, METEOR_MAX_SIZE)
        self.radius = self.size // 2
        self.original = pygame.transform.scale(load_image("meteor.png"), (self.size, self.size))
        self.image = self.original
        self.rect = self.image.get_rect()
        self.height = height
        x = random.randint(self.radius, width - self.radius)
        self.pos = pygame.Vector2(x, -self.radius)
        self.vel = pygame.Vector2(0, random.randint(150, 260))
        self.angle = 0
        self.spin = random.randint(-90, 90)
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, *args):
        self.pos += self.vel * dt
        self.angle += self.spin * dt
        self.image = pygame.transform.rotate(self.original, self.angle)
        self.rect = self.image.get_rect(center=(round(self.pos.x), round(self.pos.y)))
        if self.pos.y > self.height + OFFSCREEN_MARGIN:
            self.kill()


class Crystal(pygame.sprite.Sprite):
    def __init__(self, width, height):
        super().__init__()
        self.radius = CRYSTAL_RADIUS
        self.image = load_image("crystal.png")
        self.rect = self.image.get_rect()
        self.height = height
        x = random.randint(self.radius, width - self.radius)
        self.pos = pygame.Vector2(x, -self.radius)
        self.vel = pygame.Vector2(0, CRYSTAL_SPEED)
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, *args):
        self.pos += self.vel * dt
        self.rect.center = (round(self.pos.x), round(self.pos.y))
        if self.pos.y > self.height + OFFSCREEN_MARGIN:
            self.kill()


class Laser(pygame.sprite.Sprite):
    def __init__(self, x, y):
        super().__init__()
        self.image = load_image("laser.png")
        self.rect = self.image.get_rect()
        self.rect.midbottom = (x, y)
        self.pos = pygame.Vector2(self.rect.center)
        self.vel = pygame.Vector2(0, -LASER_SPEED)

    def update(self, dt, *args):
        self.pos += self.vel * dt
        self.rect.center = (round(self.pos.x), round(self.pos.y))
        if self.pos.y < -OFFSCREEN_MARGIN:
            self.kill()


class Explosion(pygame.sprite.Sprite):
    def __init__(self, center, frames):
        super().__init__()
        self.frames = frames
        self.elapsed = 0.0
        self.image = frames[0]
        self.rect = self.image.get_rect(center=center)

    def update(self, dt, *args):
        self.elapsed += dt
        index = frame_index(self.elapsed, EXPLOSION_FRAME_TIME, len(self.frames), False)
        if index is None:
            self.kill()
        else:
            self.image = self.frames[index]

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

  • from logic import direction_from_keys, frame_index, step_ship — функции logic.py, которые нужны спрайтам; ship_points здесь больше не нужен, корабль нарисован заранее.
  • ASSETS = Path(__file__).parent / "assets" — путь к папке рядом с файлом, откуда бы вы ни запустили игру.
  • load_image, load_sound, load_frames — три загрузчика: картинка, звук и список кадров explosion_0.png ... explosion_5.png.
  • CRYSTAL_RADIUS и CRYSTAL_SPEED остались: картинка кристалла теперь из файла, но радиус для кругов столкновения и скорость падения по-прежнему нужны.
  • Meteor.original, angle, spin — исходная картинка, текущий угол и скорость вращения. Каждый кадр из original поворачивается заново, поэтому картинка не портится.
  • Explosion — хранит elapsed; frame_index по нему выбирает кадр, а когда кадры кончились (None), спрайт делает kill().

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

Главный файл. Новое: effect_volume в импортах, константы громкости, звуки и музыка до цикла, функции apply_volumes, play_sound и explode, клавиша M, вызовы play_sound в нужных местах цикла. Из прошлого урока уходят отдельные set_volume(0.4) и set_volume(0.3), проверка muted внутри play_sound и пара pause() / unpause(): всё это теперь делает apply_volumes. Замените файл целиком:

Python · kometa/main.py
import random

import pygame

from logic import Cooldown, SpawnTimer, blink_visible, effect_volume, 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)
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
SPAWN_INTERVAL = 0.8
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
MASTER_VOLUME = 0.8
EFFECT_LEVELS = {"shot": 0.5, "boom": 0.9, "pickup": 0.7}
MUSIC_LEVEL = 0.4

pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption(TITLE)
clock = pygame.time.Clock()
font = pygame.font.Font(None, 32)
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)


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()


apply_volumes()


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


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)
crystal_timer = SpawnTimer(CRYSTAL_INTERVAL)
shot_cooldown = Cooldown(SHOT_COOLDOWN)


def explode(center):
    all_sprites.add(Explosion(center, explosion_frames))
    play_sound("boom")


score = 0
lives = START_LIVES
invulnerable = 0.0
show_boxes = False
running = True
while running:
    dt = clock.tick(FPS) / 1000

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
            elif event.key == pygame.K_F1:
                show_boxes = not show_boxes
            elif event.key == pygame.K_m:
                muted = not muted
                apply_volumes()

    keys = pygame.key.get_pressed()
    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:
        print("Игра окончена. Очки:", score)
        running = False

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

    screen.fill(BG_COLOR)
    for x, y, size in stars:
        pygame.draw.circle(screen, WHITE, (x, y), size)
    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))
    pygame.display.flip()

pygame.quit()

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

  • MASTER_VOLUME, EFFECT_LEVELS, MUSIC_LEVEL — все уровни громкости в одном месте: общий, отдельный для каждого эффекта и для музыки.
  • sounds — словарь звуков: ключ-имя, значение — загруженный Sound. Загружаем один раз до цикла.
  • pygame.mixer.music.play(-1) — музыка запускается один раз до цикла и сама повторяется (-1 — бесконечно).
  • apply_volumes() — проходит по всем звукам и ставит каждому set_volume(effect_volume(...)), потом то же для музыки. Вызывается при старте и при каждом нажатии M.
  • play_sound(name) — теперь просто проигрывает звук: проверять muted не нужно, у выключенного звука громкость 0.0.
  • explode(center) — создаёт Explosion в центре метеора и играет "boom". Зовётся и при столкновении с кораблём, и при попадании лазера.
  • play_sound("shot") — сразу после создания лазера и запуска перезарядки: один звук на один выстрел, а не на кадр.
  • if caught: play_sound("pickup") — звук кристалла один раз за кадр, где что-то поймано.
  • elif event.key == pygame.K_m — переключает muted и пересчитывает громкости. Музыка при этом не останавливается, а становится беззвучной, поэтому после включения играет с того же места.

Когда играть звук

Звуки играют ровно там, где происходит событие: выстрел — рядом с созданием лазера, взрыв — в explode, кристалл — после проверки caught. Правило простое: событие случилось один раз — play() вызвали один раз.

Шаг 4. Запуск

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

Что должно получиться: фоном играет мелодия; корабль, метеоры, кристалл и лазер — настоящие картинки; метеоры вращаются; при попадании лазера или столкновения на месте метеора вспыхивает и гаснет взрыв с глухим шумом. Выстрел щёлкает высоким тоном, кристалл звенит. Клавиша M выключает всё сразу, повторное нажатие возвращает звук.

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

  • В папке assets/ 14 файлов: четыре картинки, шесть кадров взрыва и четыре звука.
  • Метеоры вращаются в разные стороны, а края картинок остаются чистыми (прозрачными).
  • Каждый взрыв проигрывается один раз и исчезает; на экране не накапливаются старые кадры.
  • Зажатый Пробел даёт «пулемёт» по одному выстрелу каждые 0,25 с, а не сплошной гул.
  • Клавиша M выключает и музыку, и эффекты, повторное нажатие всё возвращает. Музыка не начинается заново.

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

FileNotFoundError при запуске игры

  • Что видно: в терминале ошибка с именем файла вроде assets/ship.png, окно не открывается.
  • Почему так: не запускали make_assets.py, либо он упал с ошибкой раньше, чем сохранил файлы, либо в имени файла опечатка.
  • Как исправить: перейдите в папку kometa/, выполните python make_assets.py, затем python main.py.

Музыка начинается заново и звучит «заикаясь»

  • Что видно: мелодия постоянно обрывается на первой ноте.
  • Почему так: pygame.mixer.music.play(-1) стоит внутри цикла, и музыка перезапускается каждый кадр.
  • Как исправить: вызывайте load и play(-1) один раз до цикла.

Громкость больше единицы или звук не выключается

  • Что видно: звук хрипит, либо после M что-то продолжает играть.
  • Почему так: в set_volume подставили число вне диапазона от 0 до 1, или забыли вызвать apply_volumes() после смены muted.
  • Как исправить: считайте громкость только через effect_volume (она зажимает результат) и вызывайте apply_volumes() при каждом переключении.

Метеор вращается и «дрожит» или растёт

  • Что видно: картинка расплывается и становится больше с каждым кадром.
  • Почему так: поворачивали уже повёрнутую картинку (self.image), а не исходную.
  • Как исправить: храните original и каждый кадр поворачивайте именно её.

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

  • Ассеты создаются отдельным скриптом make_assets.py один раз; игра только загружает файлы из assets/.
  • Файлы и звуки грузим до цикла, один раз; в цикле только показываем и вызываем play().
  • Громкость — число от 0 до 1. effect_volume(master, effect, muted) перемножает уровни, зажимает результат и при muted даёт 0.0.
  • Музыка включается один раз (play(-1)), а выключается нулевой громкостью: она не теряет место.
  • Событие произошло один раз — звук сыграли один раз: рядом с выстрелом, взрывом, подбором.

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

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