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

Практикум: корабль летит

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

Практикум~25 минутНовичок

За этот модуль у нас набрались четыре помощника: move двигает, direction_from_keys переводит клавиши в направление, clamp держит в рамках, wrap заворачивает звёзды. Но в цикле они пока стоят в ряд россыпью, и каждую строку приходится держать в голове. В этом практикуме мы соберём движение корабля в одну функцию, а цикл приведём в порядок.

Что соберём

К концу урока у вас будет готовая игровая «сцена»: звёзды медленно плывут сверху вниз и возвращаются наверх, а корабль в нижней половине окна плавно летает по стрелкам или WASD, не выходит за стены и по диагонали не разгоняется. Выглядит так же, как после прошлого урока, но устроено лучше: всё про движение корабля лежит в одном месте, в logic.py, а main.py только читает клавиши и рисует.

Зачем это нужно? Представьте повара, который каждый раз берёт яйцо, разбивает, взбивает, солит и жарит по отдельности. А можно записать рецепт «омлет» и просить одно: «сделай омлет». Функция, которая делает за один вызов целый шаг, и есть такой рецепт. К тому же рецепт можно проверить отдельно от кухни, то есть от окна, и именно это мы потом сделаем в задаче.

Шаг корабля одной функцией

Что входит в один шаг корабля? Нужно знать, где он сейчас (x, y), куда его толкают (направление dx, dy), как быстро (speed), сколько времени прошло (dt) и где стены. Получив всё это, функция возвращает новое место корабля. Такая функция, которая ничего не рисует и ничего не запоминает, а только считает ответ по входным числам, называется чистой. Чистые функции удобно проверять: подставил числа, посмотрел на ответ.

Стены передадим одним параметром, bounds. Это кортеж из четырёх чисел: левый край, верхний, правый, нижний, именно в таком порядке. Кортеж похож на список, но его нельзя менять после создания, поэтому он хорошо подходит для «констант в одной упаковке». Внутри функции кортеж распаковывают: одной строкой раздают его значения четырём переменным. Вы уже делали так с dx, dy = direction_from_keys(...).

Python · kometa/logic.py (новая функция)
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 не знает, что вы имели в виду.

Порядок в цикле

Теперь пройдёмся по кадру целиком. Порядок здесь важен: каждый шаг опирается на результат предыдущего.

  1. Время кадра. dt = clock.tick(FPS) / 1000. Без него нечем умножать скорость.
  2. События. Закрытие окна и Esc разбираем из очереди событий.
  3. Клавиши. get_pressed() даёт состояние кнопок, direction_from_keys превращает его в dx, dy.
  4. Корабль. step_ship получает направление и возвращает новую позицию.
  5. Звёзды. Каждой звезде пересчитываем y через wrap.
  6. Рисование. Заливка фона, звёзды, корабль и в самом конце 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 · kometa/logic.py
# Логика игры: только чистый 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. Остальное осталось как в прошлом уроке.

Python · kometa/main.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, которые хранят и место, и размер.

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