2025, Sep 26 19:21
Kivy Pong: смещённые столкновения из-за размера виджетов
После добавления ScreenManager в Kivy Pong столкновения выглядят смещёнными: геометрия виджетов не совпадает с canvas. Исправление: задайте виджетам size.
Добавление главного меню через ScreenManager к простому клону Pong на Kivy иногда приводит к странному визуальному багу: ракетки и мяч выглядят по центру, но столкновения и проверки границ ведут себя так, будто всё сдвинуто. Игра идёт, мяч отскакивает, но как будто от невидимых стен. Подсказки позиционирования и размера не помогают, а принудительное pos: 0, 0 лишь прибивает объекты в угол. Ниже — минимальный воспроизводимый пример такой ситуации и точное решение.
Сценарий проблемы
Следующий код связывает меню и игровой экран, запускает игровой цикл и рисует мяч и ракетки через KV-канвас. Логика работает, но столкновения будто смещены, а правая ракетка не вплотную прилегает к границе экрана.
Основной модуль:
from kivy.app import App
from kivy.properties import NumericProperty, ReferenceListProperty, ObjectProperty
from kivy.uix.screenmanager import Screen, ScreenManager, NoTransition
from kivy.uix.widget import Widget
from kivy.vector import Vector
from kivy.clock import Clock
class Bat(Widget):
    points = NumericProperty(0)
    def deflect_orb(self, orb):
        if self.collide_widget(orb):
            vx, vy = orb.momentum
            offset = (orb.center_y - self.center_y) / (self.height / 2)
            reflected = Vector(-1 * vx, vy)
            impulse = reflected * 1.1
            orb.momentum = impulse.x, impulse.y + offset
class Orb(Widget):
    speed_x = NumericProperty(0)
    speed_y = NumericProperty(0)
    momentum = ReferenceListProperty(speed_x, speed_y)
    def advance(self):
        self.pos = Vector(*self.momentum) + self.pos
class Arena(Widget):
    orb = ObjectProperty(None)
    left_bat = ObjectProperty(None)
    right_bat = ObjectProperty(None)
    def toss_orb(self, vel=(4, 0)):
        self.orb.center = self.center
        self.orb.momentum = vel
    def step_frame(self, dt):
        self.orb.advance()
        self.left_bat.deflect_orb(self.orb)
        self.right_bat.deflect_orb(self.orb)
        if (self.orb.y < 0) or (self.orb.top > self.height):
            self.orb.speed_y *= -1
        if self.orb.x < self.x:
            self.right_bat.points += 1
            self.toss_orb(vel=(4,0))
        if self.orb.x > self.width:
            self.left_bat.points += 1
            self.toss_orb(vel=(-4,0))
    def on_touch_move(self, touch):
        if touch.x < self.width / 3:
            self.left_bat.center_y = touch.y
        if touch.x > self.width - self.width / 3:
            self.right_bat.center_y = touch.y
class ArenaScreen(Screen):
    def on_enter(self, *args):
        match = Arena()
        self.add_widget(match)
        match.toss_orb()
        Clock.schedule_interval(match.step_frame, 1 / 60)
        return match
class MainMenu(Screen):
    pass
class PongSuite(App):
    def build(self):
        sm = ScreenManager(transition=NoTransition())
        sm.add_widget(MainMenu(name="menu"))
        sm.add_widget(ArenaScreen(name="game"))
        return sm
if __name__ == "__main__":
    PongSuite().run()
Файл KV:
#:kivy 2.3.1
<Orb>:
    canvas:
        Ellipse:
            pos: self.pos
            size: 50, 50
<Bat>:
    canvas:
        Rectangle:
            pos: self.pos
            size: 25, 200
<Arena>:
    orb: pong_orb
    left_bat: bat_left
    right_bat: bat_right
    canvas:
        Rectangle:
            pos: self.center_x - 5, 0
            size: 10, self.height
    Label:
        font_size: 70
        center_x: root.width / 4
        top: root.top - 50
        text: str(root.left_bat.points)
    Label:
        font_size: 70
        center_x: root.width * 3 / 4
        top: root.top - 50
        text: str(root.right_bat.points)
    Orb:
        id: pong_orb
        center: self.parent.center
    Bat:
        id: bat_left
        x: self.parent.x
        center_y: self.parent.center_y
    Bat:
        id: bat_right
        x: self.parent.width - self.width
        center_y: self.parent.center_y
<MainMenu>:
    start_btn: action_start
    BoxLayout:
        orientation: 'vertical'
        Button:
            id: action_start
            text: "[b]Start Game[/b]"
            markup: True
            width: 200
            height: 200
            on_press: root.manager.current = "game"
Что на самом деле не так
Игра рисует Ellipse для мяча размером 50×50 и Rectangle для каждой ракетки размером 25×200. Однако виджеты, которым принадлежат эти рисунки, сохраняют размер по умолчанию. Проверка показывает 100×100 для виджета мяча, и виджет ракетки тоже не подогнан под свой рисунок. Из‑за этого видимые формы и геометрия виджета расходятся. В результате размещение у краёв экрана и проверки столкновений работают так, словно объекты крупнее и смещены.
На первый взгляд кажется, что дело в раскладке, особенно после добавления ScreenManager, но первопричина — несоответствие размера виджета тому, что нарисовано на его canvas.
Решение
Задайте явные размеры самим виджетам так, чтобы они совпадали с примитивами на canvas. После этого правая ракетка выравнивается по границе, а столкновения совпадают с визуалом.
<Orb>:
    size: 50, 50
    canvas:
        Ellipse:
            pos: self.pos
            size: 50, 50
<Bat>:
    size: 25, 200
    canvas:
        Rectangle:
            pos: self.pos
            size: 25, 200
После этого обновления столкновения выглядят корректно, и правая ракетка стоит на месте (по крайней мере в Linux). Если изменить размер окна, столкновения остаются корректными, размеры объектов неизменны, а расстояния визуально увеличиваются. Нужен ли такой эффект — зависит от желаемого поведения.
Почему это важно помнить
В Kivy инструкции canvas определяют, что вы видите, но не меняют собственный размер виджета. Любая логика, завязанная на геометрию виджета — от позиционирования относительно экрана до взаимодействия элементов — использует размер виджета. Когда визуальные формы и границы виджета согласованы, исчезают непонятные смещения и «невидимые» хитбоксы.
Итог
Если вы рисуете игровые элементы на canvas вручную, всегда приводите размер виджета в соответствие с формой. В этом случае явные size: 50, 50 для мяча и size: 25, 200 для ракеток устраняют смещённые столкновения и проблемы с выравниванием по границам — без правок игрового цикла или настройки ScreenManager.
Статья основана на вопросе с StackOverflow от blindcat97 и ответе от furas.