За этот модуль у нас набрались четыре помощника: move двигает, direction_from_keys переводит клавиши в направление, clamp держит в рамках, wrap заворачивает звёзды. Но в цикле они пока стоят в ряд россыпью, и каждую строку приходится держать в голове. В этом практикуме мы соберём движение корабля в одну функцию, а цикл приведём в порядок.
Что соберём
К концу урока у вас будет готовая игровая «сцена»: звёзды медленно плывут сверху вниз и возвращаются наверх, а корабль в нижней половине окна плавно летает по стрелкам или WASD, не выходит за стены и по диагонали не разгоняется. Выглядит так же, как после прошлого урока, но устроено лучше: всё про движение корабля лежит в одном месте, в logic.py, а main.py только читает клавиши и рисует.
Зачем это нужно? Представьте повара, который каждый раз берёт яйцо, разбивает, взбивает, солит и жарит по отдельности. А можно записать рецепт «омлет» и просить одно: «сделай омлет». Функция, которая делает за один вызов целый шаг, и есть такой рецепт. К тому же рецепт можно проверить отдельно от кухни, то есть от окна, и именно это мы потом сделаем в задаче.
Шаг корабля одной функцией
Что входит в один шаг корабля? Нужно знать, где он сейчас (x, y), куда его толкают (направление dx, dy), как быстро (speed), сколько времени прошло (dt) и где стены. Получив всё это, функция возвращает новое место корабля. Такая функция, которая ничего не рисует и ничего не запоминает, а только считает ответ по входным числам, называется чистой. Чистые функции удобно проверять: подставил числа, посмотрел на ответ.
Стены передадим одним параметром, bounds. Это кортеж из четырёх чисел: левый край, верхний, правый, нижний, именно в таком порядке. Кортеж похож на список, но его нельзя менять после создания, поэтому он хорошо подходит для «констант в одной упаковке». Внутри функции кортеж распаковывают: одной строкой раздают его значения четырём переменным. Вы уже делали так с dx, dy = direction_from_keys(...).
def step_ship(x, y, dx, dy, speed, dt, bounds):
"""Один шаг корабля: сдвиг по направлению и зажим в рамки bounds."""
min_x, min_y, max_x, max_y = bounds
x, y = move(x, y, dx * speed, dy * speed, dt)
return clamp(x, min_x, max_x), clamp(y, min_y, max_y)
Разбор по строкам
def step_ship(x, y, dx, dy, speed, dt, bounds):— функция принимает позицию, направление, скорость в пикселях в секунду, время кадра и рамки полёта.min_x, min_y, max_x, max_y = bounds— распаковка кортежа: первое число попадает вmin_x, второе вmin_yи так далее. Порядок в кортеже и порядок имён должны совпадать.x, y = move(x, y, dx * speed, dy * speed, dt)— скорость по каждой оси равна направлению, умноженному наspeed;moveприбавляет путь за кадр. Здесьdtиспользуется один раз, внутриmove.return clamp(x, min_x, max_x), clamp(y, min_y, max_y)— после движения каждая координата зажимается между своими границами; функция возвращает пару чисел.
Заметьте: мы не написали ни одной новой формулы. step_ship просто складывает уже знакомые move и clamp в нужном порядке: сначала сдвинуть, потом зажать. Ровно это стояло у нас в цикле: сначала move, потом два clamp.
Ловушка: порядок в bounds
В кортеже (min_x, min_y, max_x, max_y) сначала идут оба минимума, потом оба максимума, а не «x, y, x, y». Если собрать (SHIP_HALF, WIDTH - SHIP_HALF, SHIP_MIN_Y, SHIP_MAX_Y), корабль окажется зажат в странных рамках, и ошибки не будет: Python не знает, что вы имели в виду.
Порядок в цикле
Теперь пройдёмся по кадру целиком. Порядок здесь важен: каждый шаг опирается на результат предыдущего.
- Время кадра.
dt = clock.tick(FPS) / 1000. Без него нечем умножать скорость. - События. Закрытие окна и
Escразбираем из очереди событий. - Клавиши.
get_pressed()даёт состояние кнопок,direction_from_keysпревращает его вdx,dy. - Корабль.
step_shipполучает направление и возвращает новую позицию. - Звёзды. Каждой звезде пересчитываем
yчерезwrap. - Рисование. Заливка фона, звёзды, корабль и в самом конце
flip().
Почему рисование последним? Пока мы считаем, на экране ещё прошлый кадр; нарисованное и показанное должно соответствовать уже посчитанному состоянию. Если двигать звёзды после рисования, на экране окажется состояние кадра «минус один»: на глаз разницы нет, но порядок «посчитал, потом нарисовал» держите как правило, иначе потом в игре с метеорами начнётся путаница.
Откуда берётся SHIP_BOUNDS
Кортеж границ мы собираем один раз, до цикла: SHIP_BOUNDS = (SHIP_HALF, SHIP_MIN_Y, WIDTH - SHIP_HALF, SHIP_MAX_Y). Константы остаются прежними, мы лишь упаковали их в одну «коробочку» и передаём в step_ship целиком.
Практика: собираем проект
Работаем в проекте kometa/. Меняются два файла: в logic.py добавится step_ship, а в main.py цикл станет короче. Ниже оба файла целиком, чтобы вы могли сверить свои.
Шаг 1. logic.py
Допишите step_ship в конец файла, после wrap. Функции move и clamp должны стоять выше неё, иначе вызывать из неё нечего. Полный файл:
# Логика игры: только чистый Python, без pygame.
import math
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
def direction_from_keys(left, right, up, down):
"""Направление из четырёх клавиш: длина 1, а если стоим, то (0.0, 0.0)."""
dx = right - left
dy = down - up
length = math.hypot(dx, dy)
if length == 0:
return 0.0, 0.0
return dx / length, dy / length
def clamp(value, low, high):
"""Зажать value между low и high."""
return max(low, min(value, high))
def wrap(value, low, high):
"""Завернуть value по кругу в промежуток от low до high (high не входит)."""
return low + (value - low) % (high - low)
def step_ship(x, y, dx, dy, speed, dt, bounds):
"""Один шаг корабля: сдвиг по направлению и зажим в рамки bounds."""
min_x, min_y, max_x, max_y = bounds
x, y = move(x, y, dx * speed, dy * speed, dt)
return clamp(x, min_x, max_x), clamp(y, min_y, max_y)
Проверим новую функцию без окна, в терминале, из папки kometa при включённом виртуальном окружении. Корабль у правой стены (x = 900) летит вправо ещё полсекунды:
python -c "from logic import step_ship; print(step_ship(900, 500, 1, 0, 360, 0.5, (24, 320, 936, 600)))"
(936, 500.0)
Без стены корабль оказался бы на x = 1080, а функция остановила его на правой границе 936. Попробуйте поменять 900 на 100: должно получиться (280.0, 500.0), потому что стена далеко и корабль просто долетел.
Шаг 2. main.py
Теперь главный файл. Новое по сравнению с прошлым уроком: строка SHIP_BOUNDS, импорт step_ship и один вызов вместо пяти строк: vx, vy, move и двух clamp. Импорты move и clamp из main.py уберите: теперь они нужны только внутри logic.py. Остальное осталось как в прошлом уроке.
import random
import pygame
from logic import direction_from_keys, ship_points, step_ship, wrap
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
SHIP_SPEED = 360
SHIP_HALF = 24
SHIP_MIN_Y = HEIGHT // 2
SHIP_MAX_Y = HEIGHT - 40
STAR_SPEED = 40
# Рамки полёта: (левый край, верхний, правый, нижний)
SHIP_BOUNDS = (SHIP_HALF, SHIP_MIN_Y, WIDTH - SHIP_HALF, SHIP_MAX_Y)
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
running = True
while running:
dt = clock.tick(FPS) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
keys = pygame.key.get_pressed()
left = keys[pygame.K_LEFT] or keys[pygame.K_a]
right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
up = keys[pygame.K_UP] or keys[pygame.K_w]
down = keys[pygame.K_DOWN] or keys[pygame.K_s]
dx, dy = direction_from_keys(left, right, up, down)
ship_x, ship_y = step_ship(ship_x, ship_y, dx, dy, SHIP_SPEED, dt, SHIP_BOUNDS)
for star in stars:
star[1] = wrap(star[1] + STAR_SPEED * star[2] * dt, 0, HEIGHT)
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 direction_from_keys, ship_points, step_ship, wrap— берём только то, что вызывается в этом файле.SHIP_BOUNDS = (SHIP_HALF, SHIP_MIN_Y, WIDTH - SHIP_HALF, SHIP_MAX_Y)— рамки полёта одним кортежем; порядок — левый, верхний, правый, нижний.dx, dy = direction_from_keys(left, right, up, down)— направление из клавиш; диагональ уже нормирована.ship_x, ship_y = step_ship(ship_x, ship_y, dx, dy, SHIP_SPEED, dt, SHIP_BOUNDS)— весь шаг корабля: сдвиг и зажим. Результат записываем обратно в обе переменные.star[1] = wrap(star[1] + STAR_SPEED * star[2] * dt, 0, HEIGHT)— звезда сдвигается вниз, тем быстрее, чем она крупнее, и возвращается наверх через нижний край.
Шаг 3. Запуск и проверка
Запустите игру: python main.py. Должно получиться так: на тёмном фоне плывут звёзды, крупные быстрее мелких; корабль слушается стрелок и WASD, по диагонали летит с той же скоростью, что и прямо, упирается в левую и правую стены и не поднимается выше середины окна; Esc закрывает окно.
Проверьте себя.
- Зажмите стрелку вправо: корабль останавливается у правой стены, а не уезжает за неё?
- Зажмите вверх, потом вниз: корабль ходит только между серединой окна и линией чуть выше нижнего края?
- Зажмите вправо и вверх вместе: скорость по диагонали такая же, как по прямой, без рывка?
- Нажмите Esc: окно закрывается без ошибок в терминале?
- Звёзды никогда не заканчиваются: небо не пустеет, сколько ни ждите?
Частые ошибки
dt применили дважды
Что видно: корабль летает очень медленно, почти ползёт. Почему: в main.py скорость умножили на dt и ещё раз передали dt в step_ship, так что путь за кадр уменьшился в десятки раз. Как исправить: в step_ship передаём SHIP_SPEED и dt как есть, умножение делает функция сама, ровно один раз.
Скорость умножили заранее
Что видно: корабль улетает к стене за долю секунды. Почему: передали dx * SHIP_SPEED вместо dx, а внутри функция умножает ещё раз. Как исправить: в step_ship уходит чистое направление dx, dy (числа от -1 до 1) и отдельно SHIP_SPEED.
Перепутаны bounds
Что видно: корабль не поднимается или застревает у странной линии. Почему: порядок в кортеже не совпал с min_x, min_y, max_x, max_y. Как исправить: сверьтесь с подписью-комментарием над SHIP_BOUNDS, левый край идёт первым, нижний последним.
Результат не записан
Что видно: корабль стоит на месте, хотя клавиши нажаты. Почему: вызвали step_ship(...) отдельной строкой, а ответ нигде не сохранили. Как исправить: ship_x, ship_y = step_ship(...).
Звёзды после отрисовки
Что видно: всё работает, но звёзды отстают на кадр. Почему: пересчёт стоит после flip() или после рисования. Как исправить: держите порядок «события, клавиши, корабль, звёзды, рисование».
Что важно запомнить
step_ship(x, y, dx, dy, speed, dt, bounds)делает весь шаг корабля:move, затемclampпо каждой оси.bounds— кортеж(min_x, min_y, max_x, max_y); в проекте он называетсяSHIP_BOUNDSи собирается один раз до цикла.- Функция возвращает новую позицию, поэтому результат всегда записываем:
ship_x, ship_y = step_ship(...). dtприменяется ровно один раз — внутриmove; направление не умножаем на скорость заранее.- Порядок кадра:
dt, события, клавиши, корабль, звёзды, рисование иflip(). - Чистые функции без pygame в
logic.pyможно проверить в терминале и задачей, не открывая окно.
Дальше в игре появятся метеоры, лазеры и кристаллы, и отдельных чисел x, y станет мало: нужны прямоугольники Rect, которые хранят и место, и размер.