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

Уровни сложности

Вы научитесь делать так, чтобы игра становилась труднее по мере роста очков: считать уровень, скорость и частоту метеоров по формулам.

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

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

Кривая сложности: растём ступеньками

Представьте тренажёрный зал. Если в первый день повесить на штангу максимальный вес, вы не поднимете её и уйдёте домой. Если всегда оставлять самый лёгкий вес, мышцы не вырастут и станет скучно. Хороший тренер добавляет по небольшому «блину» после каждого успешного подхода: чуть-чуть тяжелее, чем вчера, но не невозможно.

В играх за это отвечает кривая сложности — правило, по которому трудность зависит от прогресса игрока. У нас прогресс измеряется очками, а трудность складывается из двух вещей: насколько быстро падают метеоры и как часто они появляются. Растить сложность будем ступеньками: каждые 200 очков наступает новый уровень, и в этот момент всё становится чуть труднее. Ступеньки удобнее плавного роста: игрок чувствует границу («ага, я перешёл на следующий уровень») и получает маленькую награду за успех.

Всё держится на трёх маленьких функциях, которые не знают ничего про Pygame, а значит, живут в logic.py и проверяются так же просто, как clamp и next_state:

  • level_for_score(score) — по очкам говорит, какой сейчас уровень;
  • meteor_speed(level) — по уровню говорит, с какой скоростью (пикселей в секунду) падают метеоры;
  • spawn_interval(level) — по уровню говорит, через сколько секунд появляется следующий метеор.

Цепочка получается такой: очки → уровень → скорость и интервал. Чтобы поменять сложность игры, не нужно трогать цикл и спрайты: достаточно поправить числа в трёх функциях.

От очков к уровню: целочисленное деление

Простыми словами: нужно понять, сколько «полных двухсотен» набрал игрок. Сто девяносто девять очков — ни одной полной двухсотни, значит, пока первый уровень. Двести — одна полная, значит, второй. Четыреста пятьдесят — две полные (и ещё пятьдесят в запасе), значит, третий.

Для такого подсчёта в Python есть оператор целочисленного деления //: он делит и отбрасывает остаток, оставляя только целую часть. Обычное деление / всегда возвращает дробь, а нам нужна именно целая часть. Посмотрите разницу:

Python · фрагмент
print(450 / 200)
print(450 // 200)
print(199 // 200)
print(200 // 200)
Вывод
2.25
2
0
1

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

  • 450 / 200 — обычное деление, результат дробный: 2.25. Номер уровня «два с четвертью» бессмыслен.
  • 450 // 200 — целочисленное деление: влезло две полные двухсотни, получилось 2.
  • 199 // 200 — до двухсот не дотянули, ни одной полной двухсотни: 0.
  • 200 // 200 — ровно одна: 1. Переход происходит в тот самый момент, когда набрано 200 очков.

Теперь уровень: нулю полных двухсотен соответствует первый уровень, поэтому к результату прибавляем единицу. Игра должна остановить рост на десятом уровне, иначе через час метеоры станут быстрее света, и это пройдёт через функцию min, которую вы уже знаете по clamp: она берёт меньшее из двух чисел. Ещё одна страховка — max(1, ...): если по ошибке очки окажутся отрицательными, уровень всё равно не опустится ниже первого.

Python · kometa/logic.py
SCORE_PER_LEVEL = 200
MAX_LEVEL = 10


def level_for_score(score):
    level = 1 + score // SCORE_PER_LEVEL
    return max(1, min(level, MAX_LEVEL))

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

  • SCORE_PER_LEVEL = 200 и MAX_LEVEL = 10 — числа вынесены в именованные константы: захотите сделать уровни по 300 очков, поправите одну строку, а не ищете двести по всему файлу.
  • 1 + score // SCORE_PER_LEVEL — единица плюс число полных двухсотен; приоритет у // выше, чем у +, поэтому скобки не нужны.
  • min(level, MAX_LEVEL) — потолок: даже при 100 000 очков получим десятый уровень.
  • max(1, ...) — пол: отрицательные очки дают первый уровень, а не нулевой или минус первый.

Проверим на пальцах: level_for_score(0) даёт 1, level_for_score(199) даёт 1, level_for_score(200) даёт 2, level_for_score(450) даёт 3, level_for_score(5000) даёт 10 (потолок), а level_for_score(-50) даёт 1 (пол).

Скорость метеоров растёт по формуле

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

Пусть на первом уровне метеоры падают со скоростью 180 пикселей в секунду, а каждый следующий уровень добавляет по 30. Сколько ступеней прошли к уровню level? Первый уровень — это ноль ступеней, второй — одна, третий — две. То есть ступеней на единицу меньше номера уровня: level - 1.

Python · kometa/logic.py
BASE_SPEED = 180
SPEED_STEP = 30


def meteor_speed(level):
    return BASE_SPEED + SPEED_STEP * (level - 1)

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

  • BASE_SPEED = 180 — скорость на первом уровне, пикселей в секунду.
  • SPEED_STEP = 30 — прибавка за каждый новый уровень.
  • SPEED_STEP * (level - 1) — сколько всего прибавили: на первом уровне 30 * 0 = 0, на третьем 30 * 2 = 60.
  • Итог: уровень 1 — 180, уровень 3 — 240, уровень 10 — 450.

Одна деталь: если все метеоры одного уровня полетят с одинаковой скоростью, они будут падать идеальными шеренгами, и игра станет похожа на тетрис. Поэтому функция отдаёт скорость «по уровню», а сам метеор при создании добавляет к ней небольшой случайный разброс, плюс-минус 40 пикселей в секунду. Так на одном и том же уровне встречаются и чуть медленные, и чуть быстрые метеоры, но среднее растёт вместе с уровнем. Разброс делает спрайт, а не функция: random в чистую логику лучше не тянуть, иначе её труднее проверять.

Частота метеоров: интервал, который не должен дойти до нуля

Со скоростью понятно: больше уровень — больше число. С частотой есть тонкость. В прошлых уроках метеоры рождал SpawnTimer(interval), а interval — это пауза между метеорами в секундах. Чтобы метеоров становилось больше, интервал должен уменьшаться: чем меньше пауза, тем чаще падают метеоры. Значит, на каждом уровне интервал не прибавляем, а вычитаем.

Тут и подстерегает ловушка. Если вычитать по 0,07 секунды на каждом уровне без остановки, на уровне 14 интервал станет нулём, а потом отрицательным. Пауза в ноль секунд означает «метеоров бесконечно много»: цикл внутри SpawnTimer.update не сможет закончиться, и игра зависнет. Отрицательная пауза хуже: она вообще не имеет смысла. Нужна нижняя граница: интервал не может стать меньше, скажем, четверти секунды. Для этого годится знакомая функция max: она берёт большее из двух чисел, поэтому результат никогда не окажется меньше границы.

Python · kometa/logic.py
BASE_INTERVAL = 0.9
INTERVAL_STEP = 0.07
MIN_INTERVAL = 0.25


def spawn_interval(level):
    interval = BASE_INTERVAL - INTERVAL_STEP * (level - 1)
    return round(max(MIN_INTERVAL, interval), 3)

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

  • BASE_INTERVAL = 0.9 — пауза на первом уровне: почти один метеор в секунду.
  • INTERVAL_STEP = 0.07 — на сколько секунд пауза сокращается с каждым уровнем.
  • BASE_INTERVAL - INTERVAL_STEP * (level - 1) — та же «лестница», только вниз: вычитаем по ступеньке.
  • max(MIN_INTERVAL, interval) — пол: что бы ни получилось в формуле, меньше 0,25 секунды не будет.
  • round(..., 3) — дроби в компьютере хранятся с крошечной неточностью (например, 0.9 - 0.07 * 2 даст не ровно 0.76). Округление до трёх знаков убирает этот «хвост», и результат удобно сравнивать в проверках.

Посмотрим, как это выглядит по всем уровням: на последних строках таблицы видно, как пол останавливает снижение.

УровеньС каких очковСкорость, пикс/сИнтервал, с
101800.9
22002100.83
34002400.76
58003000.62
814003900.41
1018004500.27

Почему цифры именно такие

Это стартовые значения, а не закон природы. Скорость 450 пикселей в секунду значит, что метеор пролетает экран высотой 640 за полторы секунды, а интервал 0,27 — почти четыре метеора в секунду. Это уже жарко, но играть можно. Как только игра будет готова, поиграйте и покрутите числа: баланс всегда подбирают руками и глазами. Благодаря константам и чистым функциям это делается правкой одной строки.

Игрок должен видеть, что стало труднее

Если сложность выросла, а игра никак об этом не сказала, игрок решит, что ему просто не повезло. Поэтому у каждого перехода должен быть сигнал. Мы дадим два: постоянная строка «Уровень: 3» в углу экрана рядом с очками и жизнями и короткая крупная надпись «Уровень 3!» посреди экрана на полторы секунды в момент перехода.

Для надписи понадобится обычная переменная-таймер обратного отсчёта, такая же, как invulnerable у неуязвимости: пока она больше нуля, надпись рисуется, а каждый кадр из неё вычитается dt. В момент перехода в неё кладётся 1,5.

И самое важное место. Пересчитать уровень недостаточно: нужно ещё передать новые числа тем, кто ими пользуется. Уровень сам по себе ничего не меняет в игре. Новая скорость нужна при создании следующего метеора (её получит Meteor в параметре speed), а новый интервал нужно записать в существующий spawn_timer.interval, иначе таймер продолжит отсчитывать старые 0,9 секунды до конца игры. Каждый кадр порядок такой: очки выросли, пересчитываем уровень; если он вырос по сравнению с прошлым кадром — обновляем интервал таймера и запускаем надпись.

Совет: уровень можно только повышать в рамках одной игры

Очки в игре только растут, поэтому уровень по ним не падает. Но при новой игре new_game() обязана вернуть level в 1, интервал таймера — в spawn_interval(1), а надпись — в 0. Иначе новая партия начнётся сразу на старой сложности.

Практика: уровни в «Комете»

Берём main.py таким, каким он получился после меню и паузы: с состояниями, звуками, взрывами и экранами. Меняем три файла: добавляем функции в logic.py, учим метеор принимать скорость в sprites.py и подключаем уровни в main.py.

Шаг 1. Три функции в logic.py

Добавьте в конец logic.py константы и функции из теории. Для интервала и уровня мы опираемся только на то, что уже знаем: //, min, max и round.

Python · kometa/logic.py (добавить в конец файла)
SCORE_PER_LEVEL = 200
MAX_LEVEL = 10
BASE_SPEED = 180
SPEED_STEP = 30
BASE_INTERVAL = 0.9
INTERVAL_STEP = 0.07
MIN_INTERVAL = 0.25


def level_for_score(score):
    level = 1 + score // SCORE_PER_LEVEL
    return max(1, min(level, MAX_LEVEL))


def meteor_speed(level):
    return BASE_SPEED + SPEED_STEP * (level - 1)


def spawn_interval(level):
    interval = BASE_INTERVAL - INTERVAL_STEP * (level - 1)
    return round(max(MIN_INTERVAL, interval), 3)

Эти функции не знают про Pygame, поэтому их можно проверить прямо в терминале, не открывая окно. Запустите Python в папке kometa и спросите несколько значений:

Терминал
python -c "from logic import *; print(level_for_score(450), meteor_speed(3), spawn_interval(3), spawn_interval(20))"
Вывод
3 240 0.76 0.25

Шаг 2. Метеор получает скорость снаружи

Раньше метеор сам выбирал скорость от 150 до 260. Теперь скорость приходит параметром speed, а метеор лишь добавляет к ней небольшой случайный разброс. В sprites.py добавьте константу рядом с остальными (после OFFSCREEN_MARGIN = 40) и замените начало класса Meteor до метода update; сам update не меняется.

Python · kometa/sprites.py (константа и __init__ метеора)
SPEED_SPREAD = 40


class Meteor(pygame.sprite.Sprite):
    def __init__(self, width, height, speed):
        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, speed + random.randint(-SPEED_SPREAD, SPEED_SPREAD))
        self.angle = 0
        self.spin = random.randint(-90, 90)
        self.rect.center = (round(self.pos.x), round(self.pos.y))

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

  • SPEED_SPREAD = 40 — разброс скорости: к уровневой скорости добавляется число от -40 до 40.
  • def __init__(self, width, height, speed) — третий параметр — средняя скорость падения, пикселей в секунду.
  • speed + random.randint(-SPEED_SPREAD, SPEED_SPREAD) — вместо прежнего randint(150, 260). На первом уровне получится от 140 до 220, на десятом — от 410 до 490.

Остальные строки класса прежние: размер, картинка, позиция, вращение. Не забудьте, что теперь Meteor(WIDTH, HEIGHT) без третьего аргумента вызовет ошибку; сейчас мы поправим вызов в main.py.

Шаг 3. Импорт и константа в main.py

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

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

Теперь найдите в начале файла строку SPAWN_INTERVAL = 0.8 и замените её на длительность надписи. Интервал первого уровня теперь берётся из функции, поэтому старая константа не нужна.

Python · kometa/main.py (вместо SPAWN_INTERVAL = 0.8)
LEVEL_BANNER_TIME = 1.5

Строку, которая создаёт таймер, замените так:

Python · kometa/main.py (вместо SpawnTimer(SPAWN_INTERVAL))
spawn_timer = SpawnTimer(spawn_interval(1))

Шаг 4. Уровень в состоянии игры и в new_game

Найдите блок, который начинается с state = "menu" и заканчивается running = True, и замените его целиком. Добавились две переменные — level и level_banner — и три строки в new_game, которые возвращают сложность к началу.

Python · kometa/main.py (блок от state = "menu" до running = True)
state = "menu"
score = 0
lives = START_LIVES
invulnerable = 0.0
level = 1
level_banner = 0.0
show_boxes = False


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)


running = True

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

  • level = 1 — текущий уровень; игра всегда начинается с первого.
  • level_banner = 0.0 — сколько секунд ещё показывать надпись «Уровень N!»; ноль — надписи нет.
  • global ..., level, level_banner — функция меняет эти переменные снаружи, поэтому их надо объявить глобальными, как score и lives.
  • spawn_timer.interval = spawn_interval(level) — возвращаем таймер на медленный темп первого уровня. Без этой строки новая партия после высокого уровня началась бы сразу с частыми метеорами.

Шаг 5. Пересчёт уровня в обновлении мира

В блоке if state == "play": три правки. Первая — таймер надписи уменьшается рядом с неуязвимостью. Вторая — метеор создаётся со скоростью уровня. Третья — после подсчёта очков за выстрелы проверяем, не вырос ли уровень. Остальные строки блока остаются как были; они скрыты за ....

Python · kometa/main.py (обновление мира)
while running:
    ...
    if state == "play":
        invulnerable = max(0.0, invulnerable - dt)
        level_banner = max(0.0, level_banner - dt)
        ...
        for _ in range(spawn_timer.update(dt)):
            meteor = Meteor(WIDTH, HEIGHT, meteor_speed(level))
            all_sprites.add(meteor)
            meteors.add(meteor)
        ...
        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

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

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

  • level_banner = max(0.0, level_banner - dt) — тот же приём, что у invulnerable: вычитаем время кадра, ниже нуля не уходим.
  • Meteor(WIDTH, HEIGHT, meteor_speed(level)) — новый метеор получает скорость текущего уровня.
  • new_level = level_for_score(score) — считаем, какой уровень соответствует очкам. Это делается каждый кадр, но стоит копейки.
  • if new_level > level: — действуем только в момент перехода, а не в каждом кадре уровня.
  • spawn_timer.interval = spawn_interval(level) — передаём новую паузу существующему таймеру: накопленное им время остаётся, и ритм меняется плавно.
  • level_banner = LEVEL_BANNER_TIME — включаем надпись на полторы секунды.

Кристаллы своего уровня не имеют: их скорость и частота остаются прежними, растёт только давление метеоров.

Шаг 6. Табло и надпись на экране

В блоке отрисовки внутри if state in ("play", "pause"): под строками «Очки» и «Жизни» добавьте уровень и надпись. Функция draw_center и шрифт font_big появились в уроке про меню.

Python · kometa/main.py (отрисовка)
while running:
    ...
    if state in ("play", "pause"):
        ...
        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)
    ...

Надпись стоит внутри блока «игра или пауза», поэтому на паузе она замирает вместе с таймером (таймер уменьшается только в состоянии "play"), а на экране «Игра окончена» её нет.

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

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

Нажмите Enter на пункте «Играть». В левом углу три строки: очки, жизни и «Уровень: 1». Метеоры падают примерно раз в секунду. Как только счёт доходит до 200, на экране на полторы секунды вспыхивает жёлтое «Уровень 2!», табло показывает «Уровень: 2», а метеоры заметно чаще и быстрее. На высоких уровнях игра становится по-настоящему напряжённой.

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

  • Чтобы не ждать 200 очков, временно поставьте в logic.py SCORE_PER_LEVEL = 20: уровень будет расти за каждые два сбитых метеора. Убедитесь, что надпись вспыхивает при каждом переходе. Верните 200.
  • Дойдите до десятого уровня (при SCORE_PER_LEVEL = 20 это 180 очков): уровень дальше не растёт, интервал не падает ниже примерно 0,27 секунды.
  • Поставьте игру на паузу, пока висит надпись: она должна замереть, а после снятия паузы доработать остаток времени.
  • Проиграйте и нажмите Enter на экране «Игра окончена»: «Уровень: 1», метеоры снова редкие и медленные.
  • Попробуйте поменять SPEED_STEP с 30 на 60 и сравните ощущения, затем верните как было.

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

Метеорная стена: интервал дошёл до нуля

Что видно: на высоких уровнях метеоров становится так много, что экран забит, или игра вообще зависает. Почему: формула вычитает по 0,07 на каждом уровне, а нижней границы нет. В нуле или в минусе цикл внутри SpawnTimer.update никогда не заканчивается. Как исправить: оберните результат в max(MIN_INTERVAL, ...).

Уровень вырос, а метеоры как были

Что видно: надпись «Уровень 2!» появилась, а темп прежний. Почему: обновили только переменную level, а spawn_timer.interval не трогали, и таймер продолжает отсчитывать старые 0,9 секунды. Как исправить: при переходе обязательно записывать spawn_timer.interval = spawn_interval(level).

Дробный уровень из-за косой черты

Что видно: на табло «Уровень: 2.25» и надпись не к месту. Почему: в level_for_score написали / вместо //. Как исправить: использовать целочисленное деление.

Новая партия сразу трудная

Что видно: после «Игры окончена» метеоры сыплются как на десятом уровне, хотя табло показывает «Уровень: 1». Почему: в new_game забыли вернуть spawn_timer.interval. Как исправить: добавить в new_game строку spawn_timer.interval = spawn_interval(level) после level = 1.

Ошибка: Meteor() missing 1 required positional argument

Что видно: игра падает на первом метеоре. Почему: в sprites.py метеор уже требует speed, а в main.py остался вызов Meteor(WIDTH, HEIGHT). Как исправить: передать третий аргумент meteor_speed(level).

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

  • Сложность растёт ступеньками: очки → уровень → скорость и интервал. Каждая связь — маленькая чистая функция в logic.py.
  • level_for_score(score) = max(1, min(1 + score // 200, 10)): целочисленное деление, потолок и пол.
  • meteor_speed(level) = 180 + 30 * (level - 1); метеор добавляет разброс ±40, функция остаётся чистой.
  • spawn_interval(level) убывает, но не ниже 0,25: max защищает от нуля, round(..., 3) убирает неточный хвост дробей.
  • Пересчитали уровень — передайте результат тем, кто его использует: spawn_timer.interval и новые метеоры.
  • Реагируем на переход (new_level > level), а не на каждый кадр; сигнал игроку — «Уровень: N» на табло и надпись на 1,5 секунды.
  • new_game() возвращает level, level_banner и интервал таймера к началу.

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

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