В прошлом уроке вы открыли окно, которое живёт, пока вы не нажмёте крестик: внутри уже крутится цикл while running. Но крутится он как попало — непонятно, сколько раз в секунду, и на экране ничего не движется. Сейчас превратим его в настоящий игровой цикл с часами и запустим по экрану первую «комету».
Три шага кадра
Представьте кинопроектор. Плёнка — это стопка неподвижных картинок, проектор быстро сменяет их, и глаз видит движение. Игра устроена так же: она показывает картинку за картинкой, а каждую такую картинку называют кадром. И перед каждым кадром программа делает три дела по порядку.
| Шаг | Вопрос | Пример |
|---|---|---|
| События | Что случилось снаружи? | игрок нажал клавишу или закрыл окно |
| Обновление | Что изменилось в мире? | комета сдвинулась, очков стало больше |
| Отрисовка | Как это выглядит сейчас? | закрасить фон, нарисовать комету, показать кадр |
Официальное название этой повторяющейся тройки — игровой цикл (game loop). Цикл while running из прошлого урока и есть игровой цикл, пока в нём лишь один шаг — события. Остальные два добавим сейчас.
Часы и FPS
Если цикл не сдерживать, он гоняет кадры так быстро, как позволяет компьютер: тысячи в секунду, и процессор впустую работает на полную. Нужен метроном, который говорит: «не чаще 60 раз в секунду». Количество кадров в секунду называют FPS (frames per second); 60 — привычная частота мониторов, на один кадр остаётся около 16,7 миллисекунды.
В pygame метроном — объект pygame.time.Clock (часы). Его метод tick(FPS) вызывают в цикле один раз за кадр: если кадр был готов слишком быстро, часы чуть подождут.
FPS = 60
clock = pygame.time.Clock()
while running:
clock.tick(FPS)
Разбор по строкам
FPS = 60— константа: сколько кадров в секунду мы разрешаем. Имя большими буквами — договорённость для значений, которые не меняются.clock = pygame.time.Clock()— создаём часы один раз, до цикла.clock.tick(FPS)— вызывается внутри цикла на каждом кадре и ждёт, чтобы кадров было не больше 60 в секунду.
tick ограничивает только сверху
Часы не умеют ускорять слабый компьютер. Если он не успевает рисовать 60 кадров, их будет меньше — хоть 40, хоть 25. Что с этим делать, расскажет следующий раздел.
dt — время кадра
Вы едете на машине со скоростью 50 километров в час. Сколько вы проехали? Это зависит от времени в пути: путь равен скорости, умноженной на время. С кометой так же. Если сдвигать её на 2 пикселя за кадр, на быстром компьютере (60 кадров) она пролетит 120 пикселей в секунду, а на медленном (30 кадров) — только 60. Игра будет идти с разной скоростью у разных людей.
Выход — задавать скорость в пикселях в секунду, а за кадр считать сдвиг как скорость, умноженная на время, которое этот кадр длился. Это время называют dt (от delta time, «разница во времени»). Нужное число уже есть: clock.tick(FPS) возвращает, сколько миллисекунд прошло с предыдущего вызова. Делим на 1000 и получаем секунды.
while running:
dt = clock.tick(FPS) / 1000
Разбор по строкам
dt = ...— в переменнуюdtпопадает время прошлого кадра в секундах, например 0.0167.clock.tick(FPS)— и ждёт, и возвращает число миллисекунд с прошлого вызова./ 1000— в секунде тысяча миллисекунд, поэтому деление переводит миллисекунды в секунды.
| Скорость | FPS | dt | Сдвиг за кадр | Сдвиг за секунду |
|---|---|---|---|---|
| 120 пикселей в секунду | 60 | 0.0167 | 2 | 120 |
| 120 пикселей в секунду | 30 | 0.0333 | 4 | 120 |
Совет
Ставьте строку с dt первой в цикле. Тогда dt всегда готов к моменту, когда вам нужно двигать объекты.
flip и fill
Окно, которое вы видите, — сцена в театре. Всё, что программа рисует, попадает не прямо на сцену, а на холст за кулисами. В pygame холст — это поверхность (Surface); главная поверхность screen появилась, когда вы вызвали set_mode. Чтобы зритель увидел готовую картинку, нужно поднять занавес: pygame.display.flip() выводит нарисованное в окно.
Есть и подвох: холст сам не очищается. Всё нарисованное в прошлом кадре остаётся на нём. Поэтому отрисовку начинают с screen.fill(...) — заливки всего холста фоном. Порядок всегда один: залить, нарисовать, показать.
screen.fill(BG_COLOR)
pygame.draw.circle(screen, COMET_COLOR, (comet_x, HEIGHT // 2), 12)
pygame.display.flip()
Разбор по строкам
screen.fill(BG_COLOR)— закрашивает весь холст цветом фона и стирает прошлый кадр.pygame.draw.circle(...)— рисует круг. Аргументы по порядку: поверхность, цвет, центр, радиус.COMET_COLOR— цвет — три числа от 0 до 255 (красный, зелёный, синий); подробно разберём в следующем уроке.(comet_x, HEIGHT // 2)— центр круга:comet_xслева направо,HEIGHT // 2— середина высоты (//делит нацело).12— радиус круга в пикселях.pygame.display.flip()— показывает кадр в окне.
Что будет, если нарушить порядок
Без fill круг каждый кадр оставляет свой след, и вместо точки получается сплошной шлейф. Если flip стоит до рисования, на экран попадает картинка прошлого кадра, и всё запаздывает.
Первое движение
Движение — это изменение координаты от кадра к кадру. Чтобы комета ехала вправо, увеличиваем её x на скорость * dt. Проверим на простом примере без окна: сколько пролетит круг за секунду?
SPEED = 120
x = 0
dt = 1 / 60
for frame in range(60):
x += SPEED * dt
print(round(x))
120
Разбор по строкам
SPEED = 120— скорость: 120 пикселей в секунду.dt = 1 / 60— при 60 FPS каждый кадр длится одну шестидесятую секунды.for frame in range(60)— повторяем 60 раз — это ровно одна секунда игры.x += SPEED * dt— сдвиг за кадр: скорость на время кадра (около двух пикселей).print(round(x))— выводим итог, округлённый до целого, потому что дробные кусочки накапливаются с крошечной погрешностью.
Теперь поменяйте компьютер на слабый: dt = 1 / 30 и range(30). Кадров вдвое меньше, каждый вдвое длиннее, а итог тот же — 120. Без dt (x += 2) на тех же 30 кадрах получилось бы 60. В этом весь смысл dt: картина мира не зависит от мощности компьютера.
Осталось сделать, чтобы комета, улетев за правый край, появлялась снова слева: после движения проверяем if comet_x > WIDTH: и возвращаем comet_x = 0.
Практика
Соберём всё вместе. Откройте kometa/main.py из прошлого урока и замените его содержимое целиком. Для разнообразия комета теперь летит быстрее — 240 пикселей в секунду.
import pygame
WIDTH = 960
HEIGHT = 640
FPS = 60
TITLE = "Комета"
BG_COLOR = (12, 14, 32)
COMET_COLOR = (255, 200, 90)
COMET_SPEED = 240
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption(TITLE)
clock = pygame.time.Clock()
comet_x = 0
running = True
while running:
# время прошлого кадра в секундах
dt = clock.tick(FPS) / 1000
# 1. события
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 2. обновление
comet_x += COMET_SPEED * dt
if comet_x > WIDTH:
comet_x = 0
# 3. отрисовка
screen.fill(BG_COLOR)
pygame.draw.circle(screen, COMET_COLOR, (comet_x, HEIGHT // 2), 12)
pygame.display.flip()
pygame.quit()
Разбор по строкам
FPS = 60— новая константа — потолок частоты кадров.COMET_COLOR, COMET_SPEED— цвет кометы и её скорость в пикселях в секунду; вынесены в константы, чтобы удобно менять.clock = pygame.time.Clock()— часы, создаются один раз до цикла.comet_x = 0— координата кометы по горизонтали; до цикла, потому что должна запоминаться между кадрами.dt = clock.tick(FPS) / 1000— первая строка цикла: ждём нужное время и узнаём длительность кадра в секундах.comet_x += COMET_SPEED * dt— шаг обновления: сдвиг на скорость, умноженную на время кадра.if comet_x > WIDTH— когда комета ушла за правый край, возвращаем её к левому.screen.fill(...)— начало отрисовки: стираем прошлый кадр.pygame.display.flip()— последняя строка отрисовки: показываем готовый кадр.
Запустите и проверьте
В терминале, в папке kometa с активированным окружением, выполните:
python main.py
Должно получиться тёмное окно с жёлтым кругом, который за четыре секунды плывёт слева направо и снова появляется слева. Закройте окно крестиком — программа завершится без ошибок.
Поэкспериментируйте
- Закомментируйте строку
screen.fill(BG_COLOR)(поставьте#в начале) — увидите шлейф. Верните строку. - Поставьте
FPS = 15: комета будет двигаться рывками, но пересечёт окно за те же четыре секунды. Верните 60. - Поменяйте
COMET_SPEEDна 480 и убедитесь, что она летит вдвое быстрее.
Проверьте себя
- Окно открывается, комета летит слева направо и возвращается.
- Закрытие крестиком работает.
- Без
fillпоявляется шлейф, сfill— нет. - При изменении
FPSвремя пролёта через окно не меняется.
Частые ошибки
Часы внутри цикла событий
- Что видно: кадры то мелькают без ограничения, то движение дёргается.
- Почему: строка
clock.tick(FPS)попала внутрьfor event in pygame.event.get(). Событий в кадре может быть ноль, один или десять, значит, и часы сработают столько же раз, аdtпосчитается неверно. - Как исправить: вызывайте
tickодин раз за кадр, в самом началеwhile, снаружи цикла по событиям.
Движение без dt
- Что видно: на вашем компьютере всё нормально, а у друга игра ползёт или носится.
- Почему: строка вроде
x += 2двигает объект на фиксированный шаг за кадр, а кадров у всех разное количество. - Как исправить: хранить скорость в пикселях в секунду и писать
x += speed * dt.
flip до рисования
- Что видно: картинка запаздывает на кадр, при движении возникают странные подёргивания.
- Почему:
flipпоказывает то, что уже нарисовано, а рисование вы ещё не выполнили. - Как исправить: держите порядок:
fill, рисование фигур,flipпоследней строкой кадра.
Что важно запомнить
- Кадр состоит из трёх шагов: события, обновление, отрисовка. Цикл
while runningповторяет их снова и снова. clock.tick(FPS)не даёт циклу работать быстрее FPS кадров в секунду, а вернуть он может время прошлого кадра в миллисекундах.dt = clock.tick(FPS) / 1000— время кадра в секундах; движение пишем какx += скорость * dt.- Скорость в игре хранится в пикселях в секунду, а не в пикселях за кадр.
- Порядок отрисовки:
screen.fill, рисуем фигуры,pygame.display.flip().
Круг летит, и время кадра под контролем. В следующем уроке разберёмся, как рисовать сцену по-настоящему: координаты, цвета и фигуры из pygame.draw.