$ sudo teach IT
Модуль 1 · Первое окно · Урок 1.3

Игровой цикл

Вы построите цикл «события, обновление, отрисовка», ограничите FPS и заставите круг лететь по экрану с одинаковой скоростью на любом компьютере.

Теория + практика~25 минутНовичок

В прошлом уроке вы открыли окно, которое живёт, пока вы не нажмёте крестик: внутри уже крутится цикл while running. Но крутится он как попало — непонятно, сколько раз в секунду, и на экране ничего не движется. Сейчас превратим его в настоящий игровой цикл с часами и запустим по экрану первую «комету».

Три шага кадра

Представьте кинопроектор. Плёнка — это стопка неподвижных картинок, проектор быстро сменяет их, и глаз видит движение. Игра устроена так же: она показывает картинку за картинкой, а каждую такую картинку называют кадром. И перед каждым кадром программа делает три дела по порядку.

ШагВопросПример
СобытияЧто случилось снаружи?игрок нажал клавишу или закрыл окно
ОбновлениеЧто изменилось в мире?комета сдвинулась, очков стало больше
ОтрисовкаКак это выглядит сейчас?закрасить фон, нарисовать комету, показать кадр

Официальное название этой повторяющейся тройки — игровой цикл (game loop). Цикл while running из прошлого урока и есть игровой цикл, пока в нём лишь один шаг — события. Остальные два добавим сейчас.

Часы и FPS

Если цикл не сдерживать, он гоняет кадры так быстро, как позволяет компьютер: тысячи в секунду, и процессор впустую работает на полную. Нужен метроном, который говорит: «не чаще 60 раз в секунду». Количество кадров в секунду называют FPS (frames per second); 60 — привычная частота мониторов, на один кадр остаётся около 16,7 миллисекунды.

В pygame метроном — объект pygame.time.Clock (часы). Его метод tick(FPS) вызывают в цикле один раз за кадр: если кадр был готов слишком быстро, часы чуть подождут.

Python · фрагмент
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 и получаем секунды.

Python · фрагмент
while running:
    dt = clock.tick(FPS) / 1000

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

  • dt = ... — в переменную dt попадает время прошлого кадра в секундах, например 0.0167.
  • clock.tick(FPS) — и ждёт, и возвращает число миллисекунд с прошлого вызова.
  • / 1000 — в секунде тысяча миллисекунд, поэтому деление переводит миллисекунды в секунды.
СкоростьFPSdtСдвиг за кадрСдвиг за секунду
120 пикселей в секунду600.01672120
120 пикселей в секунду300.03334120

Совет

Ставьте строку с dt первой в цикле. Тогда dt всегда готов к моменту, когда вам нужно двигать объекты.

flip и fill

Окно, которое вы видите, — сцена в театре. Всё, что программа рисует, попадает не прямо на сцену, а на холст за кулисами. В pygame холст — это поверхность (Surface); главная поверхность screen появилась, когда вы вызвали set_mode. Чтобы зритель увидел готовую картинку, нужно поднять занавес: pygame.display.flip() выводит нарисованное в окно.

Есть и подвох: холст сам не очищается. Всё нарисованное в прошлом кадре остаётся на нём. Поэтому отрисовку начинают с screen.fill(...) — заливки всего холста фоном. Порядок всегда один: залить, нарисовать, показать.

Python · фрагмент
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. Проверим на простом примере без окна: сколько пролетит круг за секунду?

Python · фрагмент
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 пикселей в секунду.

Python · kometa/main.py
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.

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