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

Управление с клавиатуры

Вы научитесь вести корабль стрелками и WASD, отличать «нажали» от «держат» и не давать диагонали ускорять корабль.

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

Корабль уже умеет плыть по формуле «новая позиция = старая + скорость × dt», но плывёт сам по себе, как лодка без гребца, а скорость мы вписали в код числом. Пора отдать руль игроку: научим программу слышать клавиатуру, различать «клавишу нажали» и «клавишу держат» и превращать четыре кнопки в направление полёта.

Два способа слушать клавиатуру

Представьте два устройства. Первое — дверной звонок: он звонит один раз в момент нажатия, и неважно, сколько вы потом держите палец на кнопке. Второе — педаль газа с датчиком: вы можете спросить в любую секунду «нажата ли она прямо сейчас?» и получите ответ «да» или «нет». Клавиатура в Pygame умеет и то и другое.

Звонок называется событием нажатия, pygame.KEYDOWN. Оно приходит в очередь событий ровно один раз, когда клавишу нажали; какая именно клавиша, узнаём из event.key и сравниваем с константой вроде pygame.K_SPACE. Датчик называется pygame.key.get_pressed(): функция возвращает «табличку состояний», в которой по константе клавиши смотрим, зажата ли она сейчас.

Python · фрагмент
import pygame

# внутри игрового цикла
for event in pygame.event.get():
    if event.type == pygame.KEYDOWN:
        if event.key == pygame.K_SPACE:
            print("пробел нажали")

keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
    print("стрелка влево зажата")

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

  • for event in pygame.event.get(): — уже знакомый перебор очереди событий: за кадр там может накопиться несколько событий.
  • if event.type == pygame.KEYDOWN: — нас интересуют только события «клавишу нажали».
  • if event.key == pygame.K_SPACE: — внутри события спрашиваем, какая это клавиша. K_SPACE — пробел.
  • keys = pygame.key.get_pressed() — берём состояние всей клавиатуры на этот момент; делаем это каждый кадр.
  • if keys[pygame.K_LEFT]: — в квадратных скобках — константа нужной клавиши; получаем True, если она зажата прямо сейчас.

Разница хорошо видна в таблице.

Событие KEYDOWNget_pressed()
Когда срабатываетодин раз, в момент нажатияв каждом кадре, пока клавиша зажата
Что даётразовое действиеплавное непрерывное действие
Для чего у насвыход по Esc, позже выстрел и паузаполёт корабля

Названия констант запоминайте так: стрелки — K_LEFT, K_RIGHT, K_UP, K_DOWN; пробел — K_SPACE; Esc — K_ESCAPE; буквы — строчные: K_a, K_d, K_w, K_s.

Ловушка: порядок в кадре

Состояние клавиш обновляется, когда программа разбирает очередь событий. Поэтому get_pressed() вызываем в каждом кадре, после цикла for event in pygame.event.get(). Если взять «табличку» один раз до цикла, она так и останется застывшей.

Из четырёх клавиш — в направление

Возьмём четыре вопроса «да/нет»: нажата ли клавиша влево, вправо, вверх, вниз. Чтобы получить направление по горизонтали, делаем так: вправо засчитываем как +1, влево как −1. Нажаты обе — плюс и минус гасят друг друга, получается 0, и корабль стоит. По вертикали то же самое, помним только, что ось y смотрит вниз, поэтому dy = down - up.

Здесь пригодится факт про Python: True и False на самом деле числа — True равно 1, а False равно 0 (тип bool — разновидность целого числа). С ними можно считать, как с обычными числами.

Python · фрагмент
left = True
right = False
print(right - left)
print(True + True)
Вывод
-1
2

False - True это 0 − 1, то есть −1: клавиша влево тянет по оси x в минус. А True + True это 1 + 1 = 2. Итого направление по горизонтали — dx = right - left, по вертикали — dy = down - up. Каждое из чисел — −1, 0 или 1.

Чтобы работали и стрелки, и WASD, соединяем две клавиши словом or («или»): результат True, если зажата хотя бы одна из них.

Python · фрагмент
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]

Если у вас включена русская раскладка, переключитесь на английскую перед проверкой WASD: так клавиши буквально совпадут с константами.

Почему по диагонали быстрее

Если зажать вправо и вверх, получится dx = 1 и dy = -1. Корабль сдвинется и на один шаг вправо, и на один вверх сразу. Но по клетчатой бумаге диагональ клетки длиннее её стороны: прямая, соединяющая противоположные углы, больше 1. Значит, за секунду корабль пролетит больше, чем по прямой, и диагональ получится быстрее.

Длину диагонали даёт теорема Пифагора: длина = √(dx² + dy²). Для (1, 1) это √2, примерно 1,414.

Клавиши(dx, dy)Длина
только вправо(1, 0)1
вправо и вверх(1, −1)1,414
вправо и вверх, после исправления(0,707, −0,707)1

Исправление называется нормированием вектора: делим обе компоненты на длину, и длина становится ровно 1, куда бы ни смотрело направление. Длину считает функция math.hypot(dx, dy) из модуля math — та самая теорема Пифагора в одном вызове. Соберём всё в функцию для logic.py.

Python · kometa/logic.py
import math


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

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

  • import math — подключаем модуль с математическими функциями; hypot лежит в нём.
  • dx = right - left — горизонталь: вправо +1, влево −1, обе вместе — 0 (булевы значения считаются как 1 и 0).
  • dy = down - up — вертикаль; вверх — минус, как договорились про ось y.
  • length = math.hypot(dx, dy) — длина вектора: 0, 1 или около 1,414.
  • if length == 0: — ни одна клавиша не нажата (или две противоположные погасили друг друга). Делить на ноль нельзя, поэтому сразу возвращаем «стоим».
  • return dx / length, dy / length — нормируем: возвращаем пару из двух чисел. Функция ничего не знает про Pygame — ей достаточно четырёх True/False.

От направления к скорости

Направление говорит только «куда»: числа от −1 до 1. Скорость в пикселях в секунду получаем, умножив направление на максимальную скорость корабля. Для неё заводим константу SHIP_SPEED = 360: за секунду корабль проходит 360 пикселей, окно шириной 960 он пересекает за 2,7 секунды. Если захочется быстрее или медленнее — правим одно число.

Python · фрагмент
SHIP_SPEED = 360

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_SPEED = 360 — скорость в пикселях в секунду; заглавные буквы — признак константы.
  • dx, dy = direction_from_keys(...) — функция возвращает пару, распаковываем её в две переменные, как делали с move.
  • vx = dx * SHIP_SPEED — скорость по x: направление 1 даёт 360, −1 даёт −360, 0 даёт 0.
  • ship_x, ship_y = move(...) — тот же move из прошлого урока; новые координаты записываем обратно в переменные.

Цепочка получилась такая: клавиши → направление → скорость → новая позиция. Каждое звено — одна простая функция или строка, и за любое из них можно проверить себя отдельно.

Esc — выход одним нажатием

Закрывать окно крестиком удобно, но в играх принято выходить и по Esc. Это разовое действие: нажали — закончили. Значит, это работа для события KEYDOWN, а не для get_pressed(). Через «табличку» можно было бы спросить «держат ли Esc», но нам нужно именно «нажали».

Python · фрагмент
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

Позже, когда в игре появятся меню и пауза, эта же клавиша станет открывать паузу — события нам для этого и понадобятся.

Практика: ведём корабль

Продолжаем проект kometa/. Нужно изменить два файла.

Шаг 1. Добавляем функцию в logic.py

Откройте logic.py. В нём уже есть ship_points и move. Добавьте import math сразу под комментарием в первой строке, а в конец — direction_from_keys. Файл целиком:

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

Шаг 2. Проверяем без окна

Так как в logic.py нет Pygame, проверить функцию можно прямо в терминале. Откройте терминал в папке kometa/ (виртуальное окружение активировано) и выполните:

Терминал
python -c "from logic import direction_from_keys; print(direction_from_keys(True, False, True, False))"
Вывод
(-0.7071067811865475, -0.7071067811865475)

Нажаты влево и вверх: обе компоненты по модулю 0,707, а их «длина» равна 1. Значит, нормирование работает.

Шаг 3. Подключаем клавиатуру в main.py

Теперь main.py. Что поменялось по сравнению с прошлым уроком: импортируем direction_from_keys, добавили SHIP_SPEED, убрали пробную скорость vx = 0 / vy = -60 (теперь vx и vy считаются из клавиш), в цикле событий появился выход по Esc и добавилось чтение клавиш. Остальное осталось как в прошлом уроке. Файл целиком:

Python · kometa/main.py
import random

import pygame

from logic import direction_from_keys, 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

SHIP_SPEED = 360

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)

    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()

Шаг 4. Запуск и что должно получиться

Запустите python main.py. Внизу окна на фоне звёзд стоит корабль. Он больше не плывёт сам: стрелки и WASD ведут его в любую сторону, по диагонали он летит так же быстро, как по прямой, а при зажатых влево и вправо одновременно стоит. Esc закрывает окно. Если вести корабль за край, он вылетит из окна — границы поставим в следующем уроке.

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

  • Корабль идёт вправо, пока зажата стрелка, и останавливается, когда её отпустили.
  • Клавиши W, A, S, D работают так же, как стрелки.
  • Диагональ не быстрее прямой: можно сравнить на глаз, пролетев окно по горизонтали и по диагонали.
  • Esc закрывает игру; повторный запуск работает без ошибок.
  • В терминале direction_from_keys(True, True, False, False) выводит (0.0, 0.0).

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

Корабль сдвигается на шаг на каждое нажатие

Движение положили в блок KEYDOWN. Событие приходит один раз за нажатие, поэтому и сдвиг один раз. Для плавного движения читаем get_pressed() в каждом кадре, а в KEYDOWN оставляем только разовые вещи вроде выхода.

Диагональ быстрее прямой

Забыли нормировать: вместо dx / length возвращаете сырые dx, dy. Длина диагонали получается 1,41, корабль летит примерно на 40 процентов быстрее. Проверьте, что функция делит на math.hypot(dx, dy).

AttributeError: module 'pygame' has no attribute 'K_A'

Константы букв пишутся строчными: pygame.K_a, а не pygame.K_A. Заглавной буквы у клавиши в Pygame нет — она всегда одна и та же клавиша.

Корабль не реагирует, или реагирует рывками

Проверьте, что keys = pygame.key.get_pressed() стоит внутри цикла while и после цикла по событиям. Если вызвать его один раз перед циклом, значения не обновятся никогда.

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

  • pygame.KEYDOWN приходит один раз при нажатии — для разовых действий; pygame.key.get_pressed() показывает, что зажато сейчас — для непрерывного движения.
  • Константы клавиш: pygame.K_LEFT, pygame.K_a, pygame.K_ESCAPE; буквы строчные.
  • True и False считаются как 1 и 0, поэтому dx = right - left.
  • Диагональ без нормирования быстрее; direction_from_keys делит вектор на math.hypot(dx, dy), и длина всегда не больше 1.
  • Скорость корабля: vx = dx * SHIP_SPEED, где SHIP_SPEED = 360 пикселей в секунду.
  • direction_from_keys лежит в logic.py, где нет Pygame, поэтому её проверяют без окна.

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

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