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

Границы экрана

Вы научитесь удерживать корабль в окне функцией clamp и пускать звёзды по кругу функцией wrap.

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

Корабль уже слушается клавиш, но стоит подержать стрелку подольше, и он улетает за край окна. Нужны границы. В этом уроке мы добавим в logic.py две маленькие функции, clamp и wrap: первая не выпускает корабль из окна, вторая заставляет звёзды плыть по кругу.

Зажать в рамки: clamp

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

В программировании такое действие называют ограничением значения диапазоном, по-английски clamp («зажим», как тиски, которые держат деталь в рамках). Функции clamp(value, low, high) передают три числа: само значение, нижнюю границу и верхнюю. Она возвращает: low, если значение меньше нижней границы; high, если больше верхней; и само значение, если оно внутри.

Длинная цепочка из if не понадобится: в Python есть встроенные min(a, b) — меньшее из чисел и max(a, b) — большее. Посмотрим на них по отдельности.

Python · фрагмент
print(min(1000, 960))
print(max(-30, 0))
print(max(0, min(-30, 960)))

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

  • min(1000, 960) — из двух чисел берёт меньшее, поэтому печатает 960. Так получается потолок: результат никогда не больше 960.
  • max(-30, 0) — берёт большее, печатает 0. Так получается пол: результат никогда не меньше 0.
  • max(0, min(-30, 960)) — сначала внутренний min(-30, 960) даёт -30 (потолок не мешает), потом max(0, -30) поднимает результат до 0. Получилось 0.

Остаётся собрать пол и потолок в одну функцию. Откройте logic.py и допишите в самый конец:

Python · kometa/logic.py
def clamp(value, low, high):
    """Зажать value между low и high."""
    return max(low, min(value, high))

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

  • def clamp(value, low, high): — объявляем функцию с тремя параметрами: значение, нижняя и верхняя границы.
  • min(value, high) — сначала ставим потолок: если значение больше high, берётся high.
  • max(low, ...) — потом ставим пол: если после потолка получилось меньше low, берётся low.
  • return — отдаёт результат. Внутри диапазона оба шага ничего не меняют, и вернётся само значение.

Порядок аргументов важен

Границы идут именно в порядке low, потом high. Если перепутать их местами (clamp(x, 960, 0)), пол окажется выше потолка, и результат будет странным.

Где можно летать кораблю

Теперь решим, какие именно границы поставить. Координата корабля — его центр, а нарисован он вокруг этого центра: нос на 24 пикселя выше, крылья в стороны почти на столько же. Если зажать ship_x между 0 и WIDTH, центр дойдёт до самой стенки, и половина корабля окажется за окном. Поэтому границы отступают внутрь на «полуширину» корабля: это поля.

По вертикали корабль держится в нижней половине экрана: сверху падают метеоры. Границы в таблице:

ЧтоМинимумМаксимум
ship_xSHIP_HALF (24)WIDTH - SHIP_HALF (936)
ship_ySHIP_MIN_Y = HEIGHT // 2 (320)SHIP_MAX_Y = HEIGHT - 40 (600)

Здесь // — знакомое деление нацело: 640 // 2 даёт ровно 320, середину окна по высоте. Числа уходят в константы, как SHIP_SPEED раньше. В цикле зажим идёт сразу после движения:

Python · фрагмент
vx = dx * SHIP_SPEED
vy = dy * SHIP_SPEED
ship_x, ship_y = move(ship_x, ship_y, vx, vy, dt)
ship_x = clamp(ship_x, SHIP_HALF, WIDTH - SHIP_HALF)
ship_y = clamp(ship_y, SHIP_MIN_Y, SHIP_MAX_Y)

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

  • ship_x, ship_y = move(...) — как и раньше: сдвигаем корабль на скорость, умноженную на dt. Здесь он мог на мгновение выйти за границу.
  • ship_x = clamp(ship_x, SHIP_HALF, WIDTH - SHIP_HALF) — возвращаем координату обратно в диапазон от 24 до 936. Если корабль внутри, значение не меняется.
  • ship_y = clamp(ship_y, SHIP_MIN_Y, SHIP_MAX_Y) — то же по вертикали: выше середины экрана и ниже 600 корабль не попадёт.

Порядок строк тоже существенен: сначала move, потом clamp. Тогда на экран попадает уже исправленная позиция.

Завернуть по кругу: wrap

С кораблём всё просто: упёрся и стоит. А что делать со звёздами? Они плывут вниз, и когда звезда уходит за нижний край, её нужно не останавливать, а вернуть наверх, чтобы небо не пустело. Вспомните старые игры вроде Pac-Man: герой уходит в туннель справа и выходит слева.

Такое «заворачивание по кругу» называют циклическим переносом, а нашу функцию — wrap («обернуть»). Поможет остаток от деления, в Python это знак %: то, что «не поместилось» при делении нацело. На часах: 13 часов — это один круг по 12 и ещё 1, поэтому 13 % 12 равно 1.

Python · фрагмент
print(13 % 12)
print(650 % 640)
print(-5 % 640)
Вывод
1
10
635

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

  • 13 % 12 — в 13 один раз помещается 12, остаётся 1.
  • 650 % 640 — вышли за нижний край на 10 пикселей — остаток 10: звезда появляется в 10 пикселях от верха.
  • -5 % 640 — в Python остаток при положительном делителе никогда не бывает отрицательным. Звезда вышла на 5 пикселей за верхний край — и вот она у самого низа, на 635.

Это ровно то, что нужно. Границы могут начинаться не с нуля, поэтому сдвигаем отсчёт к нулю, берём остаток от длины промежутка и сдвигаем обратно:

Python · kometa/logic.py
def wrap(value, low, high):
    """Завернуть value по кругу в промежуток от low до high (high не входит)."""
    return low + (value - low) % (high - low)

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

  • value - low — сколько пройдено от нижней границы. Для low = 0 это просто value.
  • high - low — длина промежутка (для высоты окна — 640). Именно по такому «кругу» мы ходим.
  • % (high - low) — остаток: сколько останется после того, как мы прошли целые круги.
  • low + ... — возвращаем отсчёт на место. Результат всегда от low включительно до high не включая.

Верхняя граница не включается

Значение ровно на high считается уже следующим кругом: wrap(640, 0, 640) вернёт 0.

Почему не обычный if

Можно было бы написать «если y > HEIGHT, вычесть HEIGHT». Пока звезда ползёт на пару пикселей за кадр, это работает. Но если окно на секунду подвиснет и dt вырастет, звезда прыгнет дальше чем на высоту экрана, и одно вычитание её не вернёт. Остаток от деления справляется с любым скачком: wrap(1300, 0, 640) вернёт 20. Про остаток подробнее можно прочитать в документации Python, а про min и max — в разделе встроенных функций.

Звёзды плывут

Звёзды лежат в списке stars, каждая — список из трёх чисел [x, y, size]: положение и размер (1, 2 или 3). Заставим их плыть вниз со скоростью, которая зависит от размера: у крупной звезды скорость больше. Глаз воспринимает это как глубину: близкое пролетает быстро, далёкое еле ползёт. Это называется параллакс.

Скорость звезды — это STAR_SPEED * size пикселей в секунду. При STAR_SPEED = 40 получится 40, 80 и 120 пикселей в секунду. Движение — знакомая формула «позиция плюс скорость на dt», а потом wrap возвращает звезду наверх:

Python · фрагмент
for star in stars:
    star[1] = wrap(star[1] + STAR_SPEED * star[2] * dt, 0, HEIGHT)

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

  • for star in stars: — берём по очереди каждую звезду; star — это сам список [x, y, size], а не его копия.
  • star[1] — второй элемент списка, то есть y. star[0] — x, star[2] — размер.
  • STAR_SPEED * star[2] * dt — сдвиг за один кадр: скорость, зависящая от размера, умноженная на время кадра.
  • wrap(..., 0, HEIGHT) — если звезда ушла ниже HEIGHT, она появляется сверху, продолжая путь с того места, где вышла.
  • star[1] = ... — записываем новое значение прямо в список. Это работает, потому что звёзды лежат в изменяемых списках; в кортеже так присвоить не получится.

Практика: корабль в рамках, звёзды в движении

Соберём всё в проекте kometa/. Нам нужно два файла: logic.py получит две функции, main.py — константы и три новых блока.

Шаг 1. Две функции в logic.py

Допишите в конец logic.py (после direction_from_keys) функции из урока:

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

Проверим их без окна, в терминале, из папки kometa при включённом виртуальном окружении:

Терминал
python -c "from logic import clamp, wrap; print(clamp(1000, 0, 960), wrap(650, 0, 640), wrap(-5, 0, 640))"
Вывод
960 10 635

Три числа: 960 — значение прижато к границе, 10 и 635 — звёзды, вышедшие за нижний и верхний край, вернулись с другой стороны.

Шаг 2. Обновлённый main.py

Ниже файл целиком. То, что появилось в этом уроке, помечено комментарием # НОВОЕ: четыре константы, импорт clamp и wrap и три блока в цикле. Остальное осталось как в прошлом уроке.

Python · kometa/main.py
import random

import pygame

from logic import clamp, direction_from_keys, move, ship_points, 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                # НОВОЕ: базовая скорость звёзд

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)

    vx = dx * SHIP_SPEED
    vy = dy * SHIP_SPEED
    ship_x, ship_y = move(ship_x, ship_y, vx, vy, dt)
    ship_x = clamp(ship_x, SHIP_HALF, WIDTH - SHIP_HALF)   # НОВОЕ
    ship_y = clamp(ship_y, SHIP_MIN_Y, SHIP_MAX_Y)         # НОВОЕ

    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 clamp, ..., wrap — в импорт добавили две новые функции.
  • SHIP_HALF, SHIP_MIN_Y, SHIP_MAX_Y — границы полёта корабля из таблицы выше.
  • STAR_SPEED = 40 — базовая скорость звёзд; настоящая — это значение, умноженное на размер.
  • ship_x = clamp(...) — сразу после move возвращаем корабль в рамки по каждой оси.
  • star[1] = wrap(...) — сдвигаем каждую звезду вниз и заворачиваем через нижний край.

Шаг 3. Запуск и проверка

Запустите игру: python main.py. Должно получиться так: звёзды медленно плывут сверху вниз, крупные быстрее мелких, а ушедшая за нижний край тут же появляется сверху; корабль слушается клавиш, но упирается в левую и правую стенки и не поднимается выше середины окна.

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

  • Держите стрелку влево: корабль останавливается у левого края, а не уходит за него наполовину?
  • Держите вверх: корабль останавливается на середине экрана?
  • Звёзды никогда не заканчиваются: небо не пустеет, сколько ни ждите?
  • Крупные звёзды движутся заметно быстрее мелких?

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

Перепутан порядок min и max

Что видно: clamp(5, 0, 10) даёт 0. Почему: записано min(low, max(value, high)) — пол и потолок поменялись ролями. Как исправить: помните схему «потолок внутри, пол снаружи»: max(low, min(value, high)).

Заворот через if

Что видно: после подвисания окна часть звёзд оказывается ниже экрана и исчезает навсегда. Почему: одно вычитание HEIGHT не возвращает звезду в окно, если она улетела дальше чем на высоту экрана. Как исправить: брать остаток через wrap.

Границы без учёта размера

Что видно: корабль наполовину уходит за стенку. Почему: координата — центр, а границы 0 и WIDTH относятся к краям окна. Как исправить: сделать отступ на половину корабля (SHIP_HALF).

Результат не записали в переменную

Что видно: написали clamp(ship_x, 24, 936) отдельной строкой, и ничего не изменилось. Почему: функция только возвращает новое число, саму переменную она не трогает. Как исправить: ship_x = clamp(...).

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

  • clamp(value, low, high) не выпускает значение из диапазона: max(low, min(value, high)).
  • Координата корабля — его центр, поэтому границы отступают на полуширину (SHIP_HALF); корабль летает в нижней половине окна.
  • Зажим ставим сразу после move, и результат всегда записываем в переменную.
  • wrap(value, low, high) заворачивает по кругу: low + (value - low) % (high - low); верхняя граница не включается.
  • Остаток от деления в Python при положительном делителе не бывает отрицательным: -5 % 640 равно 635.
  • Скорость звезды зависит от её размера (STAR_SPEED * star[2]) — так получается параллакс.

Дальше — практикум: соберём полёт целиком, от клавиш до звёздного фона.

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