У «Кометы» теперь есть меню, пауза и экран «Игра окончена», а вот сложность одна и та же с первой секунды до последней: метеоры падают с тем же темпом и через минуту, и через десять. Опытному игроку скучно, а новичку, наоборот, хочется начать с чего-нибудь полегче. В этом уроке игра научится взрослеть вместе с игроком: чем больше очков, тем быстрее и чаще летят метеоры.
Кривая сложности: растём ступеньками
Представьте тренажёрный зал. Если в первый день повесить на штангу максимальный вес, вы не поднимете её и уйдёте домой. Если всегда оставлять самый лёгкий вес, мышцы не вырастут и станет скучно. Хороший тренер добавляет по небольшому «блину» после каждого успешного подхода: чуть-чуть тяжелее, чем вчера, но не невозможно.
В играх за это отвечает кривая сложности — правило, по которому трудность зависит от прогресса игрока. У нас прогресс измеряется очками, а трудность складывается из двух вещей: насколько быстро падают метеоры и как часто они появляются. Растить сложность будем ступеньками: каждые 200 очков наступает новый уровень, и в этот момент всё становится чуть труднее. Ступеньки удобнее плавного роста: игрок чувствует границу («ага, я перешёл на следующий уровень») и получает маленькую награду за успех.
Всё держится на трёх маленьких функциях, которые не знают ничего про Pygame, а значит, живут в logic.py и проверяются так же просто, как clamp и next_state:
level_for_score(score)— по очкам говорит, какой сейчас уровень;meteor_speed(level)— по уровню говорит, с какой скоростью (пикселей в секунду) падают метеоры;spawn_interval(level)— по уровню говорит, через сколько секунд появляется следующий метеор.
Цепочка получается такой: очки → уровень → скорость и интервал. Чтобы поменять сложность игры, не нужно трогать цикл и спрайты: достаточно поправить числа в трёх функциях.
От очков к уровню: целочисленное деление
Простыми словами: нужно понять, сколько «полных двухсотен» набрал игрок. Сто девяносто девять очков — ни одной полной двухсотни, значит, пока первый уровень. Двести — одна полная, значит, второй. Четыреста пятьдесят — две полные (и ещё пятьдесят в запасе), значит, третий.
Для такого подсчёта в 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, ...): если по ошибке очки окажутся отрицательными, уровень всё равно не опустится ниже первого.
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.
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: она берёт большее из двух чисел, поэтому результат никогда не окажется меньше границы.
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). Округление до трёх знаков убирает этот «хвост», и результат удобно сравнивать в проверках.
Посмотрим, как это выглядит по всем уровням: на последних строках таблицы видно, как пол останавливает снижение.
| Уровень | С каких очков | Скорость, пикс/с | Интервал, с |
|---|---|---|---|
| 1 | 0 | 180 | 0.9 |
| 2 | 200 | 210 | 0.83 |
| 3 | 400 | 240 | 0.76 |
| 5 | 800 | 300 | 0.62 |
| 8 | 1400 | 390 | 0.41 |
| 10 | 1800 | 450 | 0.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.
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 не меняется.
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
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 и замените её на длительность надписи. Интервал первого уровня теперь берётся из функции, поэтому старая константа не нужна.
LEVEL_BANNER_TIME = 1.5
Строку, которая создаёт таймер, замените так:
spawn_timer = SpawnTimer(spawn_interval(1))
Шаг 4. Уровень в состоянии игры и в new_game
Найдите блок, который начинается с state = "menu" и заканчивается running = True, и замените его целиком. Добавились две переменные — level и level_banner — и три строки в new_game, которые возвращают сложность к началу.
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": три правки. Первая — таймер надписи уменьшается рядом с неуязвимостью. Вторая — метеор создаётся со скоростью уровня. Третья — после подсчёта очков за выстрелы проверяем, не вырос ли уровень. Остальные строки блока остаются как были; они скрыты за ....
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 появились в уроке про меню.
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.pySCORE_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и интервал таймера к началу.
Меню, пауза, «Игра окончена» и уровни готовы, и каждая деталь работает по отдельности. В следующем практикуме соберём их вместе, проверим все переходы между экранами и доведём управление до конца.