$ sudo teach IT
Модуль 2 · Движение · Урок 2.1

Скорость и время кадра

Вы научитесь двигать объект формулой «позиция плюс скорость на dt» и выносить такие расчёты в файл logic.py, который не знает про окно.

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

Раньше мы заставили круг-«комету» ехать вправо строкой x += 120 * dt, а потом нарисовали звёздное небо и корабль, который пока стоит как вкопанный. Не хватает движения, причём такого, которое выглядит одинаково на любом компьютере. В этом уроке мы разберём, откуда берётся эта формула, и заодно вынесем расчёты в отдельный файл logic.py — он станет «мозгом» игры, а корабль в конце сам поплывёт по экрану.

Скорость: сколько пикселей в секунду

Вспомните спидометр. Он показывает «60 км/ч», а не «60 километров за кадр»: скорость всегда привязана ко времени. В игре то же самое, только вместо километров — пиксели, а вместо часа — секунда. Корабль со скоростью 120 значит «за каждую секунду он сдвигается на 120 пикселей», неважно, сколько кадров за эту секунду успел показать компьютер.

Официально это называется скорость в пикселях в секунду. Объект на плоскости может двигаться и вбок, и вверх-вниз, поэтому скорость раскладывают на две части: vx — скорость по горизонтали, vy — по вертикали. Пара (vx, vy) вместе называется вектором скорости; пока для нас это просто две обычные переменные с числами.

Направление задаёт знак. Помните, как устроены координаты окна: x растёт вправо, а y растёт вниз. Значит, чтобы лететь вверх, скорость vy должна быть отрицательной.

vxvyКуда летит объект
1200вправо
-1200влево
0-60вверх
0200вниз
90-90вправо и вверх (по диагонали)
Python · фрагмент
vx = 120   # вправо, 120 пикселей в секунду
vy = -60   # вверх, 60 пикселей в секунду (минус = вверх)

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

  • vx = 120 — обычное присваивание: переменная vx хранит скорость по горизонтали; плюс значит «вправо».
  • vy = -60 — скорость по вертикали; минус значит «в сторону уменьшения y», то есть к верхнему краю окна.
  • Текст после # — комментарий, Python его не читает.

Формула движения

Если вы идёте 5 километров в час и прошло полчаса, вы прошли 2,5 километра: скорость умножить на время. Игра делает то же самое на каждом кадре. С прошлого кадра прошло dt секунд (напомним: это «время кадра в секундах») — значит, объект сдвинулся на «скорость умножить на dt» пикселей.

Формула движения выглядит так: новый_x = x + vx * dt и новый_y = y + vy * dt. Знакомая запись x += 120 * dt — это сокращение от x = x + 120 * dt.

Python · фрагмент
x = 100
vx = 120
dt = 0.5          # с прошлого кадра прошла полсекунды
x = x + vx * dt   # 100 + 120 * 0.5
print(x)          # 160.0

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

  • x = 100 — корабль стоит в точке 100 по горизонтали.
  • dt = 0.5 — для примера взяли очень медленный кадр; в игре dt около 0,017.
  • x = x + vx * dt — сначала Python считает правую часть: 120 * 0.5 это 60.0, потом прибавляет к 100 и записывает результат обратно в x.
  • print(x) — выводит 160.0. Точка в конце: деление и умножение на дробное dt дают число с дробной частью (float).

Почему на разных компьютерах всё одинаково

Пусть скорость 120 пикселей в секунду. Посмотрите, что происходит за одну секунду на разных частотах кадров:

FPSdt (секунд)Шаг за кадрКадров за секундуИтого за секунду
300,03334 пикселя30120 пикселей
600,01672 пикселя60120 пикселей
1440,0069около 0,83 пикселя144120 пикселей

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

Что будет без dt

Если написать просто x += 2, то шаг фиксирован «2 пикселя за кадр». На 60 FPS это 120 пикселей в секунду, на 30 FPS — только 60, а на 144 FPS — уже 288. Одна и та же игра на разных компьютерах превратится в черепаху или в ракету.

Обратная задача: сколько секунд лететь

Формулу можно прочитать и в обратную сторону. Если расстояние известно, а скорость задана, то время в пути равно «расстояние делить на скорость». Например, 600 пикселей на скорости 120 пикселей в секунду займут 600 / 120 = 5 секунд. А если скорость нулевая или отрицательная, объект до цели не доберётся никогда — делить на ноль нельзя, и такой случай нужно обработать отдельно. Эта мини-задача ждёт вас в конце урока.

Логика отдельно от отрисовки

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

Файл с кодом на Python называется модулем, а чужие функции из него подключают командой import. Наша «кухня» — модуль logic.py. Главное правило курса: в logic.py нет строки import pygame. Там живут только чистые функции на обычном Python: числа на входе, числа на выходе, никакого окна.

Выигрыш сразу три. Такую функцию можно проверить в терминале за долю секунды, не открывая окно. Её легко читать, потому что в ней нет рисования. И задачи этого курса проверяют именно такие функции: в песочнице сайта pygame нет, а вот обычный Python есть.

Python · фрагмент
from logic import move, ship_points

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

  • from logic import ... — «возьми из модуля logic (то есть из файла logic.py)…».
  • move, ship_points — …«две функции, и разреши звать их по имени». Дальше в main.py мы пишем просто move(...), без приставки.
  • Условие: main.py и logic.py лежат в одной папке kometa/, и запускаем мы игру из неё.

Функция move: возвращаем новую позицию

Нам нужно в одном месте написать «сдвинь точку» и потом пользоваться этим для корабля, метеоров, лазеров. Для этого годится функция: на вход она получает позицию, скорость и время кадра, а на выход отдаёт новую позицию. Старые переменные функция не трогает — она только считает и возвращает ответ.

Из функции нужно вернуть сразу два числа: новые x и y. В Python для этого есть кортеж (tuple) — неизменяемая упорядоченная пара или набор значений. Запись return a, b без скобок как раз возвращает кортеж из двух элементов. Принимающая сторона раскладывает его обратно на две переменные — это называется распаковкой: x, y = move(...).

Python · kometa/logic.py
def move(x, y, vx, vy, dt):
    """Новая позиция объекта через dt секунд при скорости (vx, vy)."""
    new_x = x + vx * dt
    new_y = y + vy * dt
    return new_x, new_y

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

  • def move(x, y, vx, vy, dt): — объявляем функцию с пятью параметрами: где объект сейчас (x, y), как он движется (vx, vy) и сколько времени прошло (dt).
  • new_x = x + vx * dt — формула движения по горизонтали.
  • new_y = y + vy * dt — та же формула по вертикали. Для vy = -60 число new_y получится меньше y: объект поднимается.
  • return new_x, new_y — возвращает кортеж из двух чисел. Без return функция вернула бы None, и вся работа пропала бы.

А так функцию зовёт main.py. Это одна строка внутри игрового цикла:

Python · фрагмент
ship_x, ship_y = move(ship_x, ship_y, vx, vy, dt)

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

  • move(ship_x, ship_y, vx, vy, dt) — отдаём функции текущее положение корабля, его скорость и dt этого кадра.
  • ship_x, ship_y = ... — распаковка: первое число из кортежа ложится в ship_x, второе — в ship_y. Так положение корабля обновляется. Если забыть левую часть, результат просто потеряется и корабль не сдвинется.

Проверяем без окна

Вот зачем мы выносили логику: теперь её можно проверить, не запуская игру. Команда python -c "..." выполняет одну строку кода и сразу выходит. Откройте терминал в папке kometa/ (с активированным виртуальным окружением, как в начале курса) и спросите у функции ответ.

Терминал
python -c "from logic import move; print(move(100, 200, 120, 0, 0.5))"
python -c "from logic import move; print(move(480, 560, 0, -60, 2))"
Вывод
(160.0, 200.0)
(480, 440)

Первый ответ сходится с ручным расчётом: 100 + 120 * 0,5 = 160, а y не изменился, потому что vy равно нулю. Во втором примере корабль на высоте 560 летит вверх 2 секунды со скоростью 60: 560 - 120 = 440. Числа целые, потому что и dt мы тоже дали целое.

Привычка на всю жизнь

Написали функцию — сразу спросите у неё два-три ответа, которые легко посчитать в уме. Если сходится с вашим расчётом, можно подключать к игре. Если нет — искать ошибку проще в маленькой функции, чем в окне, где всё мельтешит.

Практика: корабль плывёт

Соберём движение корабля. Корабль пока плывёт сам: скорость жёстко задана, управление клавиатурой появится в следующем уроке.

Шаг 1. Создайте logic.py

В папке kometa/ создайте файл logic.py. Функцию ship_points перенесите из main.py (вырежьте её оттуда целиком и вставьте сюда), а под ней допишите move.

Python · kometa/logic.py
# Логика игры: только чистый Python, без pygame.


def ship_points(x, y, size):
    """Три вершины треугольника-корабля: нос, левый и правый углы."""
    return [
        (x, y - size),
        (x - size * 0.7, y + size * 0.6),
        (x + size * 0.7, y + size * 0.6),
    ]


def move(x, y, vx, vy, dt):
    """Новая позиция объекта через dt секунд при скорости (vx, vy)."""
    new_x = x + vx * dt
    new_y = y + vy * dt
    return new_x, new_y

Шаг 2. Проверьте функцию в терминале

Повторите две команды из предыдущего раздела и убедитесь, что вывод совпадает: (160.0, 200.0) и (480, 440). Заодно проверьте, что ничего не сломалось с кораблём: python -c "from logic import ship_points; print(ship_points(100, 100, 10))" выведет список из трёх пар, первая из которых (100, 90).

Шаг 3. Подключите logic.py к main.py

Откройте main.py и внесите четыре правки. Остальной код (звёзды, цикл событий, draw_ship, flip) остаётся как был.

  1. После import pygame добавьте пустую строку и строку импорта: from logic import move, ship_points. Старое определение ship_points вы вырезали из main.py в шаге 1, поэтому draw_ship теперь берёт эту функцию из logic.py.
  2. Перед игровым циклом, после определения draw_ship, заведите положение и скорость корабля.
  3. Внутри цикла, после разбора событий и перед screen.fill, добавьте сдвиг: сначала считаем, потом рисуем.
  4. Найдите вызов draw_ship(screen, WIDTH // 2, HEIGHT - 80) и замените координаты на ship_x, ship_y, то есть draw_ship(screen, ship_x, ship_y).

Файл целиком, чтобы вы могли сверить свой:

Python · kometa/main.py
import random

import pygame

from logic import move, ship_points

WIDTH = 960
HEIGHT = 640
FPS = 60
TITLE = "Комета"
BG_COLOR = (12, 14, 32)
WHITE = (255, 255, 255)
SHIP_COLOR = (90, 200, 255)
FLAME_COLOR = (255, 170, 60)
SHIP_SIZE = 24

pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption(TITLE)
clock = pygame.time.Clock()

# Звёзды создаём один раз, до цикла
stars = []
for _ in range(120):
    stars.append([random.randint(0, WIDTH), random.randint(0, HEIGHT), random.randint(1, 3)])


def draw_ship(surface, x, y):
    pygame.draw.circle(surface, FLAME_COLOR, (x, y + SHIP_SIZE * 0.7), SHIP_SIZE // 4)
    points = ship_points(x, y, SHIP_SIZE)
    pygame.draw.polygon(surface, SHIP_COLOR, points)
    pygame.draw.polygon(surface, WHITE, points, 2)


# Положение корабля (дробные числа) и скорость в пикселях в секунду
ship_x = WIDTH // 2
ship_y = HEIGHT - 80
vx = 0
vy = -60   # для пробы: медленно плывём вверх

running = True
while running:
    dt = clock.tick(FPS) / 1000

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    ship_x, ship_y = move(ship_x, ship_y, vx, vy, dt)

    screen.fill(BG_COLOR)
    for x, y, size in stars:
        pygame.draw.circle(screen, WHITE, (x, y), size)
    draw_ship(screen, ship_x, ship_y)
    pygame.display.flip()

pygame.quit()

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

  • from logic import move, ship_points — берём две функции из logic.py; draw_ship по-прежнему зовёт ship_points, только теперь она лежит в другом файле.
  • ship_x = WIDTH // 2, ship_y = HEIGHT - 80 — стартовая точка та же, что была: по центру и у нижнего края. Раньше эти числа стояли прямо в вызове draw_ship, теперь это переменные, которые меняются.
  • vx = 0, vy = -60 — по горизонтали стоим, по вертикали поднимаемся на 60 пикселей в секунду.
  • Все четыре переменные стоят до цикла. Если завести их внутри цикла, корабль будет каждый кадр возвращаться на старт.
  • ship_x, ship_y = move(ship_x, ship_y, vx, vy, dt) — расчёт нового положения за этот кадр; dt уже посчитан первой строкой цикла, а рисование ещё впереди.
  • draw_ship(screen, ship_x, ship_y) — рисуем корабль там, где его насчитала логика.

Шаг 4. Запустите игру

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

Что должно получиться: звёздное небо, как раньше, а корабль медленно поднимается вверх. Примерно за 10 секунд он уйдёт за верхний край окна и пропадёт — это нормально, границы мы ограничим позже. Закройте окно и запустите заново, чтобы увидеть полёт ещё раз.

Теперь поэкспериментируйте. Поставьте vy = -240 — корабль улетит быстрее. Поставьте vx = 100 и vy = 0 — он поплывёт вправо. А потом смените FPS = 60 на FPS = 30: плавность упадёт, но время, за которое корабль пересекает окно, останется прежним — это и есть работа dt.

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

  • Корабль плывёт вверх, а не вниз; если вниз — проверьте знак у vy.
  • В файле logic.py нет слова pygame.
  • Команда python -c "from logic import move; print(move(100, 200, 120, 0, 0.5))" печатает (160.0, 200.0).
  • После смены FPS с 60 на 30 скорость полёта не изменилась.

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

Корабль стоит на месте, хотя ошибок нет

Скорее всего, вы написали move(ship_x, ship_y, vx, vy, dt) без ship_x, ship_y = слева. Функция честно посчитала новую позицию и вернула её, но никто её не принял: результат выброшен. Исправление — распаковать кортеж в переменные: ship_x, ship_y = move(...).

Корабль улетает за секунду или ползёт вечность

Видно по скорости: она в десятки раз не та. Причина обычно в единицах времени: clock.tick(FPS) возвращает миллисекунды (около 16 на 60 FPS), и если не поделить на 1000, шаг получится в тысячу раз крупнее. Строка должна быть dt = clock.tick(FPS) / 1000.

Ошибка ModuleNotFoundError: No module named 'logic'

Python не нашёл файл logic.py. Проверьте, что он лежит в той же папке, что и main.py, называется именно так (строчными буквами, с расширением .py) и что команду вы запускаете из папки kometa/.

В logic.py оказался import pygame

Игра при этом запустится и даже будет работать, поэтому ошибку легко не заметить. Но логика перестаёт быть «чистой»: её уже нельзя проверить в песочнице без pygame, и позже задачи курса просто не запустятся. Если вам понадобился pygame внутри logic.py, значит, в функцию просочилось рисование — верните его в main.py.

Корабль плывёт вниз, хотя хотели вверх

Вы перепутали знак. Ось y смотрит вниз, поэтому «вверх» это отрицательное vy: vy = -60, а не 60.

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

  • Скорость измеряется в пикселях в секунду; vx — по горизонтали, vy — по вертикали, минус у vy значит «вверх».
  • Формула движения: x + vx * dt и y + vy * dt. Благодаря dt скорость полёта не зависит от FPS.
  • Время в пути равно расстоянию, делённому на скорость; при нулевой или отрицательной скорости цель недостижима.
  • dt всегда в секундах: clock.tick(FPS) / 1000.
  • Расчёты живут в logic.py без import pygame, рисование и окно — в main.py; подключаем так: from logic import move, ship_points.
  • Функция move(x, y, vx, vy, dt) возвращает кортеж, а принимаем его распаковкой: ship_x, ship_y = move(...).
  • Чистую функцию проверяют прямо в терминале командой python -c "...".

Корабль теперь плывёт сам по себе, но летать он должен туда, куда мы скажем. В следующем уроке скоростью начнёт управлять клавиатура: стрелки и WASD будут выставлять vx и vy вместо жёстко заданных чисел.

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