Раньше мы заставили круг-«комету» ехать вправо строкой x += 120 * dt, а потом нарисовали звёздное небо и корабль, который пока стоит как вкопанный. Не хватает движения, причём такого, которое выглядит одинаково на любом компьютере. В этом уроке мы разберём, откуда берётся эта формула, и заодно вынесем расчёты в отдельный файл logic.py — он станет «мозгом» игры, а корабль в конце сам поплывёт по экрану.
Скорость: сколько пикселей в секунду
Вспомните спидометр. Он показывает «60 км/ч», а не «60 километров за кадр»: скорость всегда привязана ко времени. В игре то же самое, только вместо километров — пиксели, а вместо часа — секунда. Корабль со скоростью 120 значит «за каждую секунду он сдвигается на 120 пикселей», неважно, сколько кадров за эту секунду успел показать компьютер.
Официально это называется скорость в пикселях в секунду. Объект на плоскости может двигаться и вбок, и вверх-вниз, поэтому скорость раскладывают на две части: vx — скорость по горизонтали, vy — по вертикали. Пара (vx, vy) вместе называется вектором скорости; пока для нас это просто две обычные переменные с числами.
Направление задаёт знак. Помните, как устроены координаты окна: x растёт вправо, а y растёт вниз. Значит, чтобы лететь вверх, скорость vy должна быть отрицательной.
| vx | vy | Куда летит объект |
|---|---|---|
| 120 | 0 | вправо |
| -120 | 0 | влево |
| 0 | -60 | вверх |
| 0 | 200 | вниз |
| 90 | -90 | вправо и вверх (по диагонали) |
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.
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 пикселей в секунду. Посмотрите, что происходит за одну секунду на разных частотах кадров:
| FPS | dt (секунд) | Шаг за кадр | Кадров за секунду | Итого за секунду |
|---|---|---|---|---|
| 30 | 0,0333 | 4 пикселя | 30 | 120 пикселей |
| 60 | 0,0167 | 2 пикселя | 60 | 120 пикселей |
| 144 | 0,0069 | около 0,83 пикселя | 144 | 120 пикселей |
Слабый компьютер даёт редкие кадры, но каждый шаг у него крупнее; мощный даёт частые кадры с маленькими шагами. Сумма за секунду одна и та же, поэтому корабль летит с одинаковой скоростью, а различается только плавность.
Что будет без 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 есть.
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(...).
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. Это одна строка внутри игрового цикла:
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, без 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) остаётся как был.
- После
import pygameдобавьте пустую строку и строку импорта:from logic import move, ship_points. Старое определениеship_pointsвы вырезали изmain.pyв шаге 1, поэтомуdraw_shipтеперь берёт эту функцию изlogic.py. - Перед игровым циклом, после определения
draw_ship, заведите положение и скорость корабля. - Внутри цикла, после разбора событий и перед
screen.fill, добавьте сдвиг: сначала считаем, потом рисуем. - Найдите вызов
draw_ship(screen, WIDTH // 2, HEIGHT - 80)и замените координаты наship_x, ship_y, то естьdraw_ship(screen, ship_x, ship_y).
Файл целиком, чтобы вы могли сверить свой:
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 вместо жёстко заданных чисел.