Корабль уже слушается клавиш, но стоит подержать стрелку подольше, и он улетает за край окна. Нужны границы. В этом уроке мы добавим в logic.py две маленькие функции, clamp и wrap: первая не выпускает корабль из окна, вторая заставляет звёзды плыть по кругу.
Зажать в рамки: clamp
Представьте ползунок громкости. Громче максимума он не поедет, тише нуля — тоже: упёрся в край и стоит. Именно это нам и нужно для корабля: что бы ни насчитала формула движения, координата должна остаться между двумя границами.
В программировании такое действие называют ограничением значения диапазоном, по-английски clamp («зажим», как тиски, которые держат деталь в рамках). Функции clamp(value, low, high) передают три числа: само значение, нижнюю границу и верхнюю. Она возвращает: low, если значение меньше нижней границы; high, если больше верхней; и само значение, если оно внутри.
Длинная цепочка из if не понадобится: в Python есть встроенные min(a, b) — меньшее из чисел и max(a, b) — большее. Посмотрим на них по отдельности.
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 и допишите в самый конец:
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_x | SHIP_HALF (24) | WIDTH - SHIP_HALF (936) |
ship_y | SHIP_MIN_Y = HEIGHT // 2 (320) | SHIP_MAX_Y = HEIGHT - 40 (600) |
Здесь // — знакомое деление нацело: 640 // 2 даёт ровно 320, середину окна по высоте. Числа уходят в константы, как SHIP_SPEED раньше. В цикле зажим идёт сразу после движения:
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.
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.
Это ровно то, что нужно. Границы могут начинаться не с нуля, поэтому сдвигаем отсчёт к нулю, берём остаток от длины промежутка и сдвигаем обратно:
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 возвращает звезду наверх:
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) функции из урока:
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 и три блока в цикле. Остальное осталось как в прошлом уроке.
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]) — так получается параллакс.
Дальше — практикум: соберём полёт целиком, от клавиш до звёздного фона.