Как написать игру на питоне для андроид

В этой статье мы напишем классическую «Змейку» на Python с помощью инструмента для создания GUI Kivy.

Kivy — это популярный инструмент для создания пользовательских интерфейсов, который отлично подходит для разработки приложений и несложных игр. Одним из основных достоинств Kivy является портируемость — возможность безболезненного переноса ваших проектов с одной платформы на другую. Наша «Змейка» будет работать на платформе Android.

Kivy эффективно использует Cython — язык программирования, сочетающий в себе оптимизированность C++ и синтаксис Python — что положительно сказывается на производительности. Также Kivy активно использует GPU для графических процессов, освобождая CPU для других вычислений.

Рекомендуемые ресурсы для начала работы с Kivy:

  • официальная документация;
  • Wiki по Kivy;
  • примеры готовых проектов.

Устанавливаем Kivy

Зависимости

Прим. перев. Код проверен на Ubuntu 16.04, Cython 0.25, Pygame 1.9.4.dev0, Buildozer 0.33, Kyvi 1.10.

Для правильной работы Kivy нам требуется три основных пакета: Cython, pygame и python-dev. Если вы используете Ubuntu, вам также может понадобиться библиотека gstreamer, которая используется для поддержки некоторых видеовозможностей фреймворка.

Устанавливаем Cython:

sudo pip install cython

Устанавливаем зависимости pygame:

sudo apt-get build-dep python-pygame
sudo apt-get install python-dev build-essential

Устанавливаем pygame:

sudo pip install hg+http://bitbucket.org/pygame/pygame

Устанавливаем gstreamer:

sudo apt-get install gstreamer1.0-libav

Добавляем репозиторий Kivy:

sudo add-apt-repository ppa:kivy-team/kivy
sudo apt-get update

Устанавливаем:

sudo apt-get install python-kivy

Buildozer

Этот пакет нам понадобится для упрощения процесса установки нашего Android-приложения:

sudo pip install buildozer

Нам также понадобится Java JDK. И если вы используете 64-битную систему, вам понадобятся 32-битные версии зависимостей.

Устанавливаем Java JDK:

sudo apt-get install openjdk-7-jdk

Устанавливаем 32-битные зависимости:

sudo dpkg --add-architecture i386
sudo apt-get update
sudo apt-get install libncurses5:i386 libstdc++6:i386 zlib1g:i386

Оно работает?

Прежде чем начать писать нашу «Змейку», давайте проверим, правильно ли у нас все установилось. Иначе в дальнейшем может обнаружиться, что проект не компилируется из-за какого-нибудь недостающего пакета.

Для проверки напишем старый добрый «Hello, world!».

Приступим к созданию проекта. Нужно перейти в рабочую папку и выполнить команду:

buildozer init

Теперь откроем файл с расширением .spec в любом текстовом редакторе и изменим следующие строки:

  • имя нашего приложения title = Hello World;
  • название пакета package.name = helloworldapp;
  • домен пакета (нужен для android/ios сборки) package.domain = org.helloworldapp;
  • закомментируйте эти строки, если они ещё не закомментированы:
# version.regex = __version__ = ['"](.*)['"]
# version.filename = %(source.dir)s/main.py
  • строка version = 1.0.0 должна быть раскомментированной.

Создайте файл main.py и добавьте в него следующий код:

import kivy
kivy.require('1.8.0') # Ваша версия может отличаться

from kivy.app import App
from kivy.uix.button import Button

class DummyApp(App):
    def build(self):
        return Button(text="Hello World")

if __name__ == '__main__':
    DummyApp().run()

Теперь все готово к сборке. Вернемся к терминалу.

buildozer android debug # Эта команда создает apk файл в папке ./bin

buildozer android debug deploy # Если вы хотите установить apk непосредственно на ваше устройство

Примечание В случае возникновения каких-либо ошибок установите значение log_level = 2 в файле buildozer.spec. Это даст более развернутое описание ошибки. Теперь мы точно готовы приступить к написанию «Змейки».

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

Цели

В этой части урока мы напишем игровой движок нашей «Змейки». И под созданием игрового движка подразумевается:

1. Написание классов, которые станут скелетом нашего приложения.
2. Предоставление им правильных методов и свойств, чтобы мы могли управлять ими по своему усмотрению.
3. Соединение всего в основном цикле приложения.

Классы

Теперь давайте разберем нашу игру на составные элементы: змея и игровое поле. Змея состоит из двух основных элементов: головы и хвоста. И надо не забыть, что змее нужно что-то есть.

Таким образом нам потребуется организовать следующую иерархию виджетов:

Игровое поле (Playground)
    Фрукты (Fruit)
    Змея (Snake)
        Голова (SnakeHead)
        Хвост (SnakeTail)

Мы объявим наши классы в файлах main.py и snake.kv, чтобы отделить дизайн от логики:

main.py

import kivy
kivy.require('1.8.0') 

# Импортируем элементы Kivy, которые будем использовать в классах
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.properties import ObjectProperty


class Playground(Widget):
    # Привязываем переменным элементы из .kv
    fruit = ObjectProperty(None)
    snake = ObjectProperty(None)


class Fruit(Widget):
    pass


class Snake(Widget):
    head = ObjectProperty(None)
    tail = ObjectProperty(None)


class SnakeHead(Widget):
    pass


class SnakeTail(Widget):
    pass


class SnakeApp(App):

    def build(self):
        game = Playground()
        return game

if __name__ == '__main__':
    SnakeApp().run()

snake.kv

#:kivy 1.8.0

<Playground>
    snake: snake_id
    fruit: fruit_id

    Snake:
        id: snake_id

    Fruit:
        id: fruit_id

<Snake>
    head: snake_head_id
    tail: snake_tail_id

    SnakeHead:
        id: snake_head_id

    SnakeTail:
        id: snake_tail_id

Свойства

Теперь, когда мы реализовали классы, можно задуматься о содержимом.

Playground — это корневой виджет. Мы разделим его на сетку. Эта матрица поможет позиционировать и перемещать объекты по полю. Представление каждого дочернего виджета будет занимать одну клетку. Также нужно реализовать возможность сохранения счета и изменения частоты появления фруктов.

И последнее, но не менее важное: нужно реализовать управление вводом, но сделаем мы это в следующем разделе.

class Playground(Widget):
    fruit = ObjectProperty(None)
    snake = ObjectProperty(None)

    # Задаем размер сетки
    col_number = 16
    row_number = 9

    # Игровые переменные
    score = NumericProperty(0)
    turn_counter = NumericProperty(0)
    fruit_rythme = NumericProperty(0)

    # Одработка входных данных
    touch_start_pos = ListProperty()
    action_triggered = BooleanProperty(False)

Змея

Объект змеи не должен содержать ничего, кроме двух ее деталей: головы и хвоста.

Для головы мы должны знать текущее положение и направление движения для правильного графического представления: если змея движется направо — рисуем треугольник повернутый вправо, если змея движется влево — рисуем треугольник повернутый влево.

Позиция и направление будут соответствовать определенным инструкциям рисования. Для рисования треугольника нам нужно шесть точек (две координаты на одну вершину). Эти координаты будут не ячейками сетки, а определенными пикселями на холсте

Наконец, нам надо хранить информацию об объекте, нарисованном на холсте, чтобы удалить его позже (например, для перезапуска игры). Мы добавим логическую переменную, указывающую, должен ли объект быть нарисован на холсте:

class SnakeHead(Widget):
    # Направление головы и ее позиция
    direction = OptionProperty(
        "Right", options=["Up", "Down", "Left", "Right"])
    x_position = NumericProperty(0)
    y_position = NumericProperty(0)
    position = ReferenceListProperty(x_position, y_position)

    # Представление на холсте
    points = ListProperty([0]*6) 
    object_on_board = ObjectProperty(None)
    state = BooleanProperty(False)

Теперь хвост. Он состоит из блоков (изначально трех), занимающих одну ячейку. Когда «Змейка» будет двигаться, мы будем убирать самый последний блок хвоста и добавлять новый на предыдущую позицию головы:

class SnakeTail(Widget):
    # Длинна хвоста. Измеряется в количестве блоков
    size = NumericProperty(3)

    # Позицию каждого блока хвоста мы будем хранить здесь
    blocks_positions = ListProperty()

    # Обьекты (виджеты) хвоста будут находиться в этой переменной
    tail_blocks_objects = ListProperty()

Фрукт

Поведение фрукта похоже на поведение змеиной головы. Нам понадобится отслеживать его позицию, состояние и интервал появления:

class Fruit(Widget):
    # Эти значения будем использовать для определения частоты появления fruit_rhythme
    duration = NumericProperty(10) # Продолжительность существования
    interval = NumericProperty(3) # Продолжительность отсутствия

    # Представление на поле
    object_on_board = ObjectProperty(None)
    state = BooleanProperty(False)

В классе SnakeApp будет происходить запуск нашего приложения:

class SnakeApp(App):
    game_engine = ObjectProperty(None)

    def build(self):
        self.game_engine = Playground()
        return self.game_engine

Кое-что еще: нужно задать размеры виджетов. Каждый элемент будет занимать одну ячейку поля. Значит:

  • высота виджета = высота поля / количество строк сетки;
  • ширина виджета = ширина поля / количество колонок сетки.

Также нам нужно добавить виджет отображающий текущий счет.

Теперь snake.kv выглядит так:

#:kivy 1.8.0

<Playground>
    snake: snake_id
    fruit: fruit_id

    Snake:
        id: snake_id
        width: root.width/root.col_number
        height: root.height/root.row_number

    Fruit:
        id: fruit_id
        width: root.width/root.col_number
        height: root.height/root.row_number

    Label:
        font_size: 70
        center_x: root.x + root.width/root.col_number*2
        top: root.top - root.height/root.row_number
        text: str(root.score)

<Snake>
    head: snake_head_id
    tail: snake_tail_id

    SnakeHead:
        id: snake_head_id
        width: root.width
        height: root.height

    SnakeTail:
        id: snake_tail_id
        width: root.width
        height: root.height

Методы

Начнем с класса змеи. Мы хотим устанавливать ее в начальное положение и получать информацию о том, где она находится и как взаимодействует с объектами поля:

class Snake(Widget):
...
    def move(self):
        """
        Движение змеи будет происходить в 3 этапа:
            - сохранить текущее положение головы.
            - передвинуть голову на одну позицию вперед.
            - переместить последний блок хвоста на предыдущие координаты головы .
        """
        next_tail_pos = list(self.head.position)
        self.head.move()
        self.tail.add_block(next_tail_pos)

    def remove(self):
        """
        Здесь мы опишем, удаление элементов хвоста и головы
        """
        self.head.remove()
        self.tail.remove()

    def set_position(self, position):
        self.head.position = position

    def get_position(self):
        """
        Положение змеи равно положению ее головы на поле.
        """
        return self.head.position

    def get_full_position(self):
        """
        Но иногда нам нужно будет узнавать, какое пространство занимает змея.
        """
        return self.head.position + self.tail.blocks_positions

    def set_direction(self, direction):
        self.head.direction = direction

    def get_direction(self):
        return self.head.direction 

Мы назвали ряд методов. Теперь давайте их реализуем. Начнем с remove() и add_block():

class SnakeTail(Widget):
...

    def remove(self):
        # Сбрасываем счетчик длины
        self.size = 3

        # Удаляем каждый блок хвоста
        for block in self.tail_blocks_objects:
            self.canvas.remove(block)

        # Обнуляем списки с координатами блоков
        # и их представления на холсте
        self.blocks_positions = []
        self.tail_blocks_objects = []

    def add_block(self, pos):
        """
        Здесь действуем в 3 этапа : 
            - Передаем позицию нового блока как аргумент и добавляем блок в список объектов. 
            - Проверяем равенство длины хвоста и количества блоков и изменяем, если требуется.
            - Рисуем блоки на холсте, до тех пор, пока количество нарисованных блоков не станет равно длине хвоста.
        """
        # Добавляем координаты блоков в список
        self.blocks_positions.append(pos)

        # Делаем проверку соответствия количеству блоков змеи на холсте и переменной отражающей длину
        if len(self.blocks_positions) > self.size:
            self.blocks_positions.pop(0)

        with self.canvas:
            # Рисуем блоки используя координаты из списка
            for block_pos in self.blocks_positions:
                x = (block_pos[0] - 1) * self.width
                y = (block_pos[1] - 1) * self.height
                coord = (x, y)
                block = Rectangle(pos=coord, size=(self.width, self.height))

                # Добавляем новый блок к списку объектов
                self.tail_blocks_objects.append(block)

                # Делаем проверку длины и удаляем лишние блоки с холста, если необходимо
                if len(self.tail_blocks_objects) > self.size:
                    last_block = self.tail_blocks_objects.pop(0)
                    self.canvas.remove(last_block)

Теперь работаем с головой. Она будет иметь две функции: move() и remove():

class SnakeHead(Widget):
    # Представление головы на "сетке"
    direction = OptionProperty(
        "Right", options=["Up", "Down", "Left", "Right"])
    x_position = NumericProperty(0)
    y_position = NumericProperty(0)
    position = ReferenceListProperty(x_position, y_position)

    # Представление головы на поле
    points = ListProperty([0] * 6)
    object_on_board = ObjectProperty(None)
    state = BooleanProperty(False)

    def is_on_board(self):
        return self.state

    def remove(self):
        if self.is_on_board():
            self.canvas.remove(self.object_on_board)
            self.object_on_board = ObjectProperty(None)
            self.state = False

    def show(self):
        """
        Размещаем голову на холсте.
        """
        with self.canvas:
            if not self.is_on_board():
                self.object_on_board = Triangle(points=self.points)
                self.state = True  
            else:
                # Если объект должен быть на поле - удаляем старую голову
                # и рисуем новую
                self.canvas.remove(self.object_on_board)
                self.object_on_board = Triangle(points=self.points)

    def move(self):
        """
        Не самое элегантное решение, но это работает. 
        Здесь мы описываем изображение треугольника для каждого положения головы.
        """
        if self.direction == "Right":
            # Обновляем позицию
            self.position[0] += 1

            # Вычисляем положения точек
            x0 = self.position[0] * self.width
            y0 = (self.position[1] - 0.5) * self.height
            x1 = x0 - self.width
            y1 = y0 + self.height / 2
            x2 = x0 - self.width
            y2 = y0 - self.height / 2
        elif self.direction == "Left":
            self.position[0] -= 1
            x0 = (self.position[0] - 1) * self.width
            y0 = (self.position[1] - 0.5) * self.height
            x1 = x0 + self.width
            y1 = y0 - self.height / 2
            x2 = x0 + self.width
            y2 = y0 + self.height / 2
        elif self.direction == "Up":
            self.position[1] += 1
            x0 = (self.position[0] - 0.5) * self.width
            y0 = self.position[1] * self.height
            x1 = x0 - self.width / 2
            y1 = y0 - self.height
            x2 = x0 + self.width / 2
            y2 = y0 - self.height
        elif self.direction == "Down":
            self.position[1] -= 1
            x0 = (self.position[0] - 0.5) * self.width
            y0 = (self.position[1] - 1) * self.height
            x1 = x0 + self.width / 2
            y1 = y0 + self.height
            x2 = x0 - self.width / 2
            y2 = y0 + self.height

        # Записываем положения точек
        self.points = [x0, y0, x1, y1, x2, y2]

        # Рисуем голову
        self.show()

А что там с фруктами? Мы должны уметь помещать их в заданные координаты и удалять, когда нам это понадобится:

class Fruit(Widget):
...

    def is_on_board(self):
        return self.state

    def remove(self, *args):
        # Удаляем объект с поля и указываем, что он сейчас стерт
        if self.is_on_board():
            self.canvas.remove(self.object_on_board)
            self.object_on_board = ObjectProperty(None)
            self.state = False

    def pop(self, pos):
        self.pos = pos  # объявляем, что фрукт находится на поле

        # Рисуем фрукт
        with self.canvas:
            x = (pos[0] - 1) * self.size[0]
            y = (pos[1] - 1) * self.size[1]
            coord = (x, y)

            # Сохраняем представление и обновляем состояние объекта
            self.object_on_board = Ellipse(pos=coord, size=self.size)
            self.state = True

Почти готово, не сдавайтесь! Теперь нужно организовать весь игровой процесс, который будет происходить в классе Playground. Рассмотрим логику игры: она начинается с того, что змея помещается в случайные координаты. Игра обновляется при каждом перемещении змеи. Во время обновлений мы проверяем направление змеи и ее положение. Если змея сталкивается сама с собой или выходит за пределы поля — мы засчитываем поражение и игра начинается сначала.

Как будет осуществляться управление? Когда игрок касается экрана, мы сохраняем координаты касания. Когда палец будет перемещаться, мы будем сравнивать новое положение с исходным. Если позиция будет изменена на 10 % от размера экрана, мы будем определять это как инструкцию и обрабатывать ее:

class Playground(Widget):
   ...

    def start(self):
        # Добавляем змею на поле
        self.new_snake()

        # Начинаем основной цикл обновления игры
        self.update()

    def reset(self):
        # Сбрасываем игровые переменные
        self.turn_counter = 0
        self.score = 0

        # Удаляем образы змеи и фрукта с поля
        self.snake.remove()
        self.fruit.remove()

    def new_snake(self):
        # Генерируем случайные координаты
        start_coord = (
            randint(2, self.col_number - 2), randint(2, self.row_number - 2))

        # Устанавливаем для змеи новые координаты
        self.snake.set_position(start_coord)

        # Генерируем случайное направление
        rand_index = randint(0, 3)
        start_direction = ["Up", "Down", "Left", "Right"][rand_index]

        # Задаем змее случайное направление
        self.snake.set_direction(start_direction)

    def pop_fruit(self, *args):
        # Генерируем случайные координаты для фрукта
        random_coord = [
            randint(1, self.col_number), randint(1, self.row_number)]

        # получаем координаты всех клеток занимаемых змеей
        snake_space = self.snake.get_full_position()

        # Если координаты фрукта совпадают с координатами клеток змеи - генерируем 
        # новые координаты 
        while random_coord in snake_space:
            random_coord = [
                randint(1, self.col_number), randint(1, self.row_number)]

        # Помещаем образ фрукта на поле
        self.fruit.pop(random_coord)

    def is_defeated(self):
        """
        Проверяем, является ли позиция змеи проигрышной.
        """
        snake_position = self.snake.get_position()

        # Если змея кусает свой хвост - поражение
        if snake_position in self.snake.tail.blocks_positions:
            return True

        # Если вышла за пределы поля - поражение
        if snake_position[0] > self.col_number 
                or snake_position[0] < 1 
                or snake_position[1] > self.row_number 
                or snake_position[1] < 1:
            return True

        return False

    def update(self, *args):
        """
        Используется для смены игровых ходов.
        """
        # Перемещаем змею на следующую позицию
        self.snake.move()

        # Проверяем на поражение
        # Если поражение - сбрасываем игру
        if self.is_defeated():
            self.reset()
            self.start()
            return

        # Проверяем, находится ли фрукт на поле
        if self.fruit.is_on_board():
            # Если змея съела фрукт - увеличиваем счет и длину змеи
            if self.snake.get_position() == self.fruit.pos:
                self.fruit.remove()
                self.score += 1
                self.snake.tail.size += 1

        # Увеличиваем счетчик ходов
        self.turn_counter += 1

    def on_touch_down(self, touch):
        self.touch_start_pos = touch.spos

    def on_touch_move(self, touch):
        # Вычисляем изменение позиции пальца
        delta = Vector(*touch.spos) - Vector(*self.touch_start_pos)


        # Проверяем, изменение > 10% от размера экрана:
        if not self.action_triggered 
                and (abs(delta[0]) > 0.1 or abs(delta[1]) > 0.1):
            # Если да, задаем змее подходящее направление
            if abs(delta[0]) > abs(delta[1]):
                if delta[0] > 0:
                    self.snake.set_direction("Right")
                else:
                    self.snake.set_direction("Left")
            else:
                if delta[1] > 0:
                    self.snake.set_direction("Up")
                else:
                    self.snake.set_direction("Down")
            # Здесь мы регистрируем, что действие закончено, для того, чтобы оно не             # происходило более одного раза за ход
            self.action_triggered = True

    def on_touch_up(self, touch):
        # Указываем, что мы готовы принять новые инструкции
        self.action_triggered = False

Основной цикл

Здесь происходят процессы, устанавливающие положение фрукта, управляющие движением змеи и определяющие проигрыш:

   def update(self, *args):
        """
        Используется для смены игровых ходов.
        """
        # Регистрация последовательности появления фруктов в планировщике событий
        if self.turn_counter == 0:
            self.fruit_rythme = self.fruit.interval + self.fruit.duration
            Clock.schedule_interval(
                self.fruit.remove, self.fruit_rythme)
        elif self.turn_counter == self.fruit.interval:
            self.pop_fruit()
            Clock.schedule_interval(
                self.pop_fruit, self.fruit_rythme)
...
        # Каждое обновление будет происходить ежесекундно (1'')
        Clock.schedule_once(self.update, 1)

Нужно добавить обработчик для события сброса игры:

    def reset(self):
...
        Clock.unschedule(self.pop_fruit)
        Clock.unschedule(self.fruit.remove)
        Clock.unschedule(self.update)

Теперь мы можем протестировать игру.

Одна важная деталь. Чтобы приложение запустилось с правильным разрешением экрана, нужно сделать так:

class SnakeApp(App):
    game_engine = ObjectProperty(None)

    def on_start(self):
        self.game_engine.start()
...

И вуаля! Теперь вы можете запустить приложение. Остается только упаковать его с помощью buildozer и загрузить на устройство.

Создаем экраны

В приложении будет два экрана: приветствия и игровой. Также будет всплывающее меню настроек. Сначала мы сделаем макеты наших виджетов в .kv-файле, а потом напишем соответствующие классы Python.

Внешняя оболочка

PlaygroundScreen содержит только игровое поле:

<PlaygroundScreen>:
    game_engine: playground_widget_id

    Playground:
        id: playground_widget_id

Основной экран будет состоять из трех встроенных виджетов: название приложения, кнопки запуска игры и кнопки вызывающая меню настроек:

<WelcomeScreen>
    AnchorLayout:
        anchor_x: "center"

        BoxLayout:
            orientation: "vertical"
            size_hint: (0.5, 1)
            spacing: 10

            Label:
                size_hint_y: .4
                text: "Ouroboros"
                valign: "bottom"
                bold: True
                font_size: 50
                padding: 0, 0

            AnchorLayout:
                anchor_x: "center"
                size_hint_y: .6

                BoxLayout:
                    size_hint: .5, .5
                    orientation: "vertical"
                    spacing: 10

                    Button:
                        halign: "center"
                        valign: "middle"
                        text: "Play"

                    Button:
                        halign: "center"
                        valign: "middle"
                        text: "Options"

Всплывающее окно будет занимать ¾ экрана приветствия. Оно будет содержать виджеты, необходимые для установки параметров, и кнопку «Сохранить».

Подготовим макет:

<OptionsPopup>
    title: "Options"
    size_hint: .75, .75

    BoxLayout:
        orientation: "vertical"
        spacing: 20

        GridLayout:
            size_hint_y: .8
            cols: 2

        AnchorLayout:
            anchor_x: "center"
            size_hint: 1, .25

            Button:
                size_hint_x: 0.5
                text: "Save changes"
                on_press: root.dismiss()

Классы

На экране приветствия требуется только метод show_popup(), который будет вызываться при нажатии кнопки настроек на главном экране. Нам не нужно определять что-либо еще для кнопки включения игры, потому что она будет использовать способность своего родителя обращаться к диспетчеру экрана:

class WelcomeScreen(Screen):
    options_popup = ObjectProperty(None)

    def show_popup(self):
        # Создаем экземпляр всплывающего окна и отображаем на экране
        self.options_popup = OptionsPopup()
        self.options_popup.open()

Теперь нужно сделать так, чтоб экран приветствия показывался при запуске игры, а игра начиналась только тогда, когда будет показано игровое поле:

class PlaygroundScreen(Screen):
    game_engine = ObjectProperty(None)

    def on_enter(self):
        # Показываем экран и начинаем игру
        self.game_engine.start()

Также нам нужно подготовить класс окна настроек, иначе привязка будет работать не правильно:

class OptionsPopup(Popup):
    pass

Теперь добавим ScreenManager в приложение и зарегистрируем два экрана:

class SnakeApp(App):
    screen_manager = ObjectProperty(None)

    def build(self):
        # Объявление SkreenManager как свойства класса
        SnakeApp.screen_manager = ScreenManager()

        # Создание экземплров экранов
        ws = WelcomeScreen(name="welcome_screen")
        ps = PlaygroundScreen(name="playground_screen")

        # Регистрация экранов в SkreenManager
        self.screen_manager.add_widget(ws)
        self.screen_manager.add_widget(ps)

        return self.screen_manager

Теперь нужно сказать кнопкам, что делать, когда на них нажимают:

<WelcomeScreen>
...
                    Button:
...
                        on_press: root.manager.current = "playground_screen"

                    Button:
...
                        on_press: root.show_popup()

<OptionsPopup>
...
            Button:
...
                on_press: root.dismiss()

После проигрыша нужно возвращаться обратно на экран приветствия:

class Playground(Widget):
...
    def update(self, *args):
...
        # Проверяем проигрыш
        # Если это произошло,
        # показываем экран приветствия
        if self.is_defeated():
            self.reset()
            SnakeApp.screen_manager.current = "welcome_screen"
            return
…

Добавляем настройки

У нас будет всего два параметра:

  1. Включение/отключение границ. Если границы включены, при выхождении змеи за пределы экрана засчитывается проигрыш. Если границы выключены, змея будет появляться на другой стороне, если выходит за пределы.
  2. Скорость змеи.

Добавляем необходимые виджеты во всплывающее окно:

<OptionsPopup>
    border_option_widget: border_option_widget_id
    speed_option_widget: speed_option_widget_id
    
    title: "Options"
    size_hint: .75, .75

    BoxLayout:
        orientation: "vertical"
        spacing: 20

        GridLayout:
            size_hint_y: .8
            cols: 2

            Label:
                text: "Borders"  
                halign: "center"

            Switch:
                id: border_option_widget_id

            Label: 
                text: "Game speed"
                halign: "center"

            Slider:
                id: speed_option_widget_id
                max: 10
                min: 1
                step: 1
                value: 1

Теперь подготовим классы, чтобы они могли изменяться вместе с настройками. Если границы включены, будем рисовать очертание игрового поля. Также добавим возможность изменения частоты обновления:

class Playground(Widget):
...
    # Пользовательские настройки
    start_speed = NumericProperty(1)
    border_option = BooleanProperty(False)
...
    #Игровые переменные
...
    start_time_coeff = NumericProperty(1)
    running_time_coeff = NumericProperty(1)
...
    def start(self):
        # Если границы включены, рисуем прямоугольник вокруг поля
        if self.border_option:
            with self.canvas.before:
                Line(width=3.,
                     rectangle=(self.x, self.y, self.width, self.height))

        # Вычисляем коэффициент изменения частоты обновления игры
        # (по умолчанию 1.1, максимальный 2)
        self.start_time_coeff += (self.start_speed / 10)
        self.running_time_coeff = self.start_time_coeff
...
    def reset(self):
        # Сбрасываем игровые переменные
...
        self.running_time_coeff = self.start_time_coeff
...
    def is_defeated(self):
...
        # Если змея вышла за границы, которые были включены -- поражение
        if self.border_option:
            if snake_position[0] > self.col_number 
                    or snake_position[0] < 1 
                    or snake_position[1] > self.row_number 
                    or snake_position[1] < 1:
                return True

        return False

    def handle_outbound(self):
        """
        Используется для перемещения змеи на противоположную сторону
        (только если границы выключены)
        """
        position = self.snake.get_position()
        direction = self.snake.get_direction()

        if position[0] == 1 and direction == "Left":
            self.snake.tail.add_block(list(position))
            self.snake.set_position([self.col_number + 1, position[1]])
        elif position[0] == self.col_number and direction == "Right":
            self.snake.tail.add_block(list(position))
            self.snake.set_position([0, position[1]])
        elif position[1] == 1 and direction == "Down":
            self.snake.tail.add_block(list(position))
            self.snake.set_position([position[0], self.row_number + 1])
        elif position[1] == self.row_number and direction == "Up":
            self.snake.tail.add_block(list(position))
            self.snake.set_position([position[0], 0])

    def update(self, *args):
...
        # Изменяем частоту появления фрукта
        if self.turn_counter == 0:
...
            Clock.schedule_interval(
                self.fruit.remove, self.fruit_rythme / self.running_time_coeff)
        elif self.turn_counter == self.fruit.interval:
...
            Clock.schedule_interval(
                self.pop_fruit, self.fruit_rythme / self.running_time_coeff)

        # Проверяем, пересечение змеей границ
        # если пересекает -- переносим на противоположную сторону
        if not self.border_option:
            self.handle_outbound()
...
        # Проверяем готовность фрукта
        if self.fruit.is_on_board():
            if self.snake.get_position() == self.fruit.pos:
...
                self.running_time_coeff *= 1.05
...
        Clock.schedule_once(self.update, 1 / self.running_time_coeff)

Изменим всплывающее окно так, чтобы оно могло передавать значения:

class OptionsPopup(Popup):
    border_option_widget = ObjectProperty(None)
    speed_option_widget = ObjectProperty(None)

    def on_dismiss(self):
        Playground.start_speed = self.speed_option_widget.value
        Playground.border_option = self.border_option_widget.active

Готово. Теперь можно упаковать проект и играть:

buildozer android debug # Создает apk-файл в папке ./bin
 
buildozer android debug deploy # Установка на Android-устройство

Перевод статьи «Make a Snake game for Android written in Python»

Привет!

Много людей хотели бы начать программировать на андроид, но Android Studio и Java их отпугивают. Почему? Потому, что это в некотором смысле из пушки по воробьям. «Я лишь хочу сделать змейку, и все!»

Начнем! (бонус в конце)

Зачем создавать еще один туториал по змейке на Kivy? (необязательно для прочтения)

Если вы — питонист, и хотите начать разработу простых игр под андроид, вы должно быть уже загуглили «змейка на андроиде» и нашли это (Eng) или ее перевод (Рус). И я тоже так сделал. К сожалению, я нашел статью бесполезной по нескольким причинам:

Плохой код

Мелкие недостатки:

  1. Использование «хвоста» и «головы» по отдельности. В этом нет необходимости, так как в змее голова — первая часть хвоста. Не стоит для этого всю змею делить на две части, для которых код пишется отдельно.
  2. Clock.schedule от self.update вызван из… self.update.
  3. Класс второго уровня (условно точка входа из первого класса) Playground объявлен в начале, но класс первого уровня SnakeApp объявлен в конце файла.
  4. Названия для направлений («up», «down», …) вместо векторов ( (0, 1), (1, 0)… ).

Серьезные недостатки:

  1. Динамичные объекты (к примеру, фрукт) прикреплены к файлу kv, так что вы не можете создать более одного яблока не переписав половину кода
  2. Чудная логика перемещения змеи вместо клетка-за-клеткой.
  3. 350 строк — слишком длинный код.

Статья неочевидна для новичков

Это мое ЛИЧНОЕ мнение. Более того, я не гарантирую, что моя статья будет более интересной и понятной. Но постараюсь, а еще гарантирую:

  1. Код будет коротким
  2. Змейка красивой (относительно)
  3. Туториал будет иметь поэтапное развитие

Результат не комильфо


Нет расстояния между клетками, чудной треугольник, дергающаяся змейка.

Знакомство

Первое приложение

Пожалуйста, удостовертесь в том, что уже установили Kivy (если нет, следуйте инструкциям) и запустите
buildozer init в папке проекта.

Запустим первую программу:

main.py

from kivy.app import App
from kivy.uix.widget import Widget

class WormApp(App):
    def build(self):
        return Widget()

if __name__ == '__main__':
    WormApp().run()

Мы создали виджет. Аналогично, мы можем создать кнопку или любой другой элемент графического интерфейса:

from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.button import Button

class WormApp(App):
    def build(self):
        self.but = Button()
        self.but.pos = (100, 100)
        self.but.size = (200, 200)
        self.but.text = "Hello, cruel world"

        self.form = Widget()
        self.form.add_widget(self.but)
        return self.form

if __name__ == '__main__':
    WormApp().run()

Ура! Поздравляю! Вы создали кнопку!

Файлы .kv

Однако, есть другой способ создавать такие элементы. Сначала объявим нашу форму:

from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.button import Button


class Form(Widget):
    def __init__(self):
        super().__init__()
        self.but1 = Button()
        self.but1.pos = (100, 100)
        self.add_widget(self.but1)


class WormApp(App):
    def build(self):
        self.form = Form()
        return self.form


if __name__ == '__main__':
    WormApp().run()

Затем создаем «worm.kv» файл.

worm.kv

<Form>:
    but2: but_id

    Button:
        id: but_id
        pos: (200, 200)

Что произошло? Мы создали еще одну кнопку и присвоим id but_id. Теперь but_id ассоциировано с but2 формы. Это означает, что мы можем обратиться к button с помощью but2:

class Form(Widget):
    def __init__(self):
        super().__init__()
        self.but1 = Button()
        self.but1.pos = (100, 100)
        self.add_widget(self.but1)   #
        self.but2.text = "OH MY"

Графика

Далее создадим графический элемент. Сначала объявим его в worm.kv:

<Form>:

<Cell>:
    canvas:
        Rectangle:
            size: self.size
            pos: self.pos

Мы связали позицию прямоугольника с self.pos и его размер с self.size. Так что теперь эти свойства доступны из Cell, например, как только мы создаем клетку, мы можем менять ее размер и позицию:

class Cell(Widget):
    def __init__(self, x, y, size):
        super().__init__()
        self.size = (size, size)   # Как можно заметить, мы можем поменять self.size который есть свойство "size" прямоугольника
        self.pos = (x, y)

class Form(Widget):
    def __init__(self):
        super().__init__()
        self.cell = Cell(100, 100, 30)
        self.add_widget(self.cell)

Окей, мы создали клетку.

Необходимые методы

Давайте попробуем двигать змею. Чтобы это сделать, мы можем добавить функцию Form.update и привязать к расписанию с помощью Clock.schedule.

from kivy.app import App
from kivy.uix.widget import Widget
from kivy.clock import Clock

class Cell(Widget):
    def __init__(self, x, y, size):
        super().__init__()
        self.size = (size, size)
        self.pos = (x, y)


class Form(Widget):
    def __init__(self):
        super().__init__()
        self.cell = Cell(100, 100, 30)
        self.add_widget(self.cell)

    def start(self):
        Clock.schedule_interval(self.update, 0.01)

    def update(self, _):
        self.cell.pos = (self.cell.pos[0] + 2, self.cell.pos[1] + 3)


class WormApp(App):
    def build(self):
        self.form = Form()
        self.form.start()
        return self.form


if __name__ == '__main__':
    WormApp().run()

Клетка будет двигаться по форме. Как вы можете видеть, мы можем поставить таймер на любую функцию с помощью Clock.

Далее, создадим событие нажатия (touch event). Перепишем Form:

class Form(Widget):
    def __init__(self):
        super().__init__()
        self.cells = []

    def start(self):
        Clock.schedule_interval(self.update, 0.01)

    def update(self, _):
        for cell in self.cells:
            cell.pos = (cell.pos[0] + 2, cell.pos[1] + 3)

    def on_touch_down(self, touch):
        cell = Cell(touch.x, touch.y, 30)
        self.add_widget(cell)
        self.cells.append(cell)

Каждый touch_down создает клетку с координатами = (touch.x, touch.y) и размером = 30. Затем, мы добавим ее как виджет формы И в наш собственный массив (чтобы позднее обращаться к нему).

Теперь каждое нажатие на форму генерирует клетку.

Няшные настройки

Так как мы хотим сделать красивую змейку, мы должны логически разделить графическую и настоящую позиции.

Зачем?

Много причин делать это. Вся логика должна быть соединена с так называемой настоящей позицией, а вот графическая — есть результат настоящей. Например, если мы хотим сделать отступы, настоящая позиция будет (100, 100) пока графическая — (102, 102).

P. S. Мы бы этим не парились если бы имели дело с on_draw. Но теперь мы не обязаны перерисовать форму лапками.

Давайте изменим файл worm.kv:

<Form>:

<Cell>:
    canvas:
        Rectangle:
            size: self.graphical_size
            pos: self.graphical_pos

и main.py:

...
from kivy.properties import *
...
class Cell(Widget):
    graphical_size = ListProperty([1, 1])
    graphical_pos = ListProperty([1, 1])

    def __init__(self, x, y, size, margin=4):
        super().__init__()
        self.actual_size = (size, size)
        self.graphical_size = (size - margin, size - margin)
        self.margin = margin
        self.actual_pos = (x, y)
        self.graphical_pos_attach()

    def graphical_pos_attach(self):
        self.graphical_pos = (self.actual_pos[0] - self.graphical_size[0] / 2, self.actual_pos[1] - self.graphical_size[1] / 2)
...
class Form(Widget):
    def __init__(self):
        super().__init__()
        self.cell1 = Cell(100, 100, 30)
        self.cell2 = Cell(130, 100, 30)
        self.add_widget(self.cell1)
        self.add_widget(self.cell2)
...

Появился отступ, так что это выглядит лучше не смотря на то, что мы создали вторую клетку с X = 130 вместо 132. Позже мы будем делать мягкое передвижение, основанное на расстоянии между actual_pos и graphical_pos.

Программирование червяка

Объявление

Инициализируем config в main.py

class Config:
    DEFAULT_LENGTH = 20
    CELL_SIZE = 25
    APPLE_SIZE = 35
    MARGIN = 4
    INTERVAL = 0.2
    DEAD_CELL = (1, 0, 0, 1)
    APPLE_COLOR = (1, 1, 0, 1)

(Поверьте, вы это полюбите!)

Затем присвойте config приложению:

class WormApp(App):
    def __init__(self):
        super().__init__()
        self.config = Config()
        self.form = Form(self.config)
    
    def build(self):
        self.form.start()
        return self.form

Перепишите init и start:

class Form(Widget):
    def __init__(self, config):
        super().__init__()
        self.config = config
        self.worm = None

    def start(self):
        self.worm = Worm(self.config)
        self.add_widget(self.worm)
        Clock.schedule_interval(self.update, self.config.INTERVAL)

Затем, Cell:

class Cell(Widget):
    graphical_size = ListProperty([1, 1])
    graphical_pos = ListProperty([1, 1])

    def __init__(self, x, y, size, margin=4):
        super().__init__()
        self.actual_size = (size, size)
        self.graphical_size = (size - margin, size - margin)
        self.margin = margin
        self.actual_pos = (x, y)
        self.graphical_pos_attach()

    def graphical_pos_attach(self):
        self.graphical_pos = (self.actual_pos[0] - self.graphical_size[0] / 2, self.actual_pos[1] - self.graphical_size[1] / 2)

    def move_to(self, x, y):
        self.actual_pos = (x, y)
        self.graphical_pos_attach()

    def move_by(self, x, y, **kwargs):
        self.move_to(self.actual_pos[0] + x, self.actual_pos[1] + y, **kwargs)

    def get_pos(self):
        return self.actual_pos

    def step_by(self, direction, **kwargs):
        self.move_by(self.actual_size[0] * direction[0], self.actual_size[1] * direction[1], **kwargs)

Надеюсь, это было более менее понятно.

И наконец Worm:

class Worm(Widget):
    def __init__(self, config):
        super().__init__()
        self.cells = []
        self.config = config
        self.cell_size = config.CELL_SIZE
        self.head_init((100, 100))
        for i in range(config.DEFAULT_LENGTH):
            self.lengthen()

    def destroy(self):
        for i in range(len(self.cells)):
            self.remove_widget(self.cells[i])
        self.cells = []

    def lengthen(self, pos=None, direction=(0, 1)):
        # Если позиция установлена, мы перемещаем клетку туда, иначе - в соответствии с данным направлением
        if pos is None:
            px = self.cells[-1].get_pos()[0] + direction[0] * self.cell_size
            py = self.cells[-1].get_pos()[1] + direction[1] * self.cell_size
            pos = (px, py)
        self.cells.append(Cell(*pos, self.cell_size, margin=self.config.MARGIN))
        self.add_widget(self.cells[-1])

    def head_init(self, pos):
        self.lengthen(pos=pos)

Давайте создадим нашего червячка.

Движение

Теперь подвигаем ЭТО.

Тут просто:

class Worm(Widget):
...
    def move(self, direction):
        for i in range(len(self.cells) - 1, 0, -1):
            self.cells[i].move_to(*self.cells[i - 1].get_pos())
        self.cells[0].step_by(direction)

class Form(Widget):
    def __init__(self, config):
        super().__init__()
        self.config = config
        self.worm = None
        self.cur_dir = (0, 0)

    def start(self):
        self.worm = Worm(self.config)
        self.add_widget(self.worm)
        self.cur_dir = (1, 0)
        Clock.schedule_interval(self.update, self.config.INTERVAL)

    def update(self, _):
        self.worm.move(self.cur_dir)

Оно живое! Оно живое!

Управление

Как вы могли судить по первой картинке, управление змеи будет таким:

class Form(Widget):
...
    def on_touch_down(self, touch):
        ws = touch.x / self.size[0]
        hs = touch.y / self.size[1]
        aws = 1 - ws
        if ws > hs and aws > hs:
            cur_dir = (0, -1)         # Вниз
        elif ws > hs >= aws:
            cur_dir = (1, 0)          # Вправо
        elif ws <= hs < aws:
            cur_dir = (-1, 0)         # Влево
        else:
            cur_dir = (0, 1)           # Вверх
        self.cur_dir = cur_dir

Здорово.

Создание фрукта

Сначала объявим.

class Form(Widget):
...
    def __init__(self, config):
        super().__init__()
        self.config = config
        self.worm = None
        self.cur_dir = (0, 0)
        self.fruit = None
...
    def random_cell_location(self, offset):
        x_row = self.size[0] // self.config.CELL_SIZE
        x_col = self.size[1] // self.config.CELL_SIZE
        return random.randint(offset, x_row - offset), random.randint(offset, x_col - offset)

    def random_location(self, offset):
        x_row, x_col = self.random_cell_location(offset)
        return self.config.CELL_SIZE * x_row, self.config.CELL_SIZE * x_col

    def fruit_dislocate(self):
        x, y = self.random_location(2)
        self.fruit.move_to(x, y)
...
    def start(self):
        self.fruit = Cell(0, 0, self.config.APPLE_SIZE, self.config.MARGIN)
        self.worm = Worm(self.config)
        self.fruit_dislocate()
        self.add_widget(self.worm)
        self.add_widget(self.fruit)
        self.cur_dir = (1, 0)
        Clock.schedule_interval(self.update, self.config.INTERVAL)

Текущий результат:

Теперь мы должны объявить несколько методов Worm:

class Worm(Widget):
...
    # Тут соберем позиции всех клеток
    def gather_positions(self):
        return [cell.get_pos() for cell in self.cells]
    # Проверка пересекается ли голова с другим объектом
    def head_intersect(self, cell):
        return self.cells[0].get_pos() == cell.get_pos()

Другие бонусы функции gather_positions

Кстати, после того, как мы объявили gather_positions, мы можем улучшить fruit_dislocate:

class Form(Widget):
    def fruit_dislocate(self):
        x, y = self.random_location(2)
        while (x, y) in self.worm.gather_positions():
            x, y = self.random_location(2)
        self.fruit.move_to(x, y)

На этот моменте позиция яблока не сможет совпадать с позиции хвоста

… и добавим проверку в update()

class Form(Widget):
...
    def update(self, _):
        self.worm.move(self.cur_dir)
        if self.worm.head_intersect(self.fruit):
            directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
            self.worm.lengthen(direction=random.choice(directions))
            self.fruit_dislocate()

Определение пересечения головы и хвоста

Мы хотим узнать та же ли позиция у головы, что у какой-то клетки хвоста.

class Form(Widget):
...
    def __init__(self, config):
        super().__init__()
        self.config = config
        self.worm = None
        self.cur_dir = (0, 0)
        self.fruit = None
        self.game_on = True

    def update(self, _):
        if not self.game_on:
            return
        self.worm.move(self.cur_dir)
        if self.worm.head_intersect(self.fruit):
            directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
            self.worm.lengthen(direction=random.choice(directions))
            self.fruit_dislocate()
       if self.worm_bite_self():
            self.game_on = False

    def worm_bite_self(self):
        for cell in self.worm.cells[1:]:
            if self.worm.head_intersect(cell):
                return cell
        return False

Раскрашивание, декорирование, рефакторинг кода

Начнем с рефакторинга.

Перепишем и добавим

class Form(Widget):
...
    def start(self):
        self.worm = Worm(self.config)
        self.add_widget(self.worm)
        if self.fruit is not None:
            self.remove_widget(self.fruit)
        self.fruit = Cell(0, 0, self.config.APPLE_SIZE)
        self.fruit_dislocate()
        self.add_widget(self.fruit)
        Clock.schedule_interval(self.update, self.config.INTERVAL)
        self.game_on = True
        self.cur_dir = (0, -1)

    def stop(self):
        self.game_on = False
        Clock.unschedule(self.update)

    def game_over(self):
        self.stop()
...
    def on_touch_down(self, touch):
        if not self.game_on:
            self.worm.destroy()
            self.start()
            return
        ...

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

Теперь перейдим к декорированию и раскрашиванию.

worm.kv

<Form>:
    popup_label: popup_label
    score_label: score_label

    canvas:
        Color:
            rgba: (.5, .5, .5, 1.0)

        Line:
            width: 1.5
            points: (0, 0), self.size

        Line:
            width: 1.5
            points: (self.size[0], 0), (0, self.size[1])


    Label:
        id: score_label
        text: "Score: " + str(self.parent.worm_len)
        width: self.width

    Label:
        id: popup_label
        width: self.width


<Worm>:


<Cell>:
    canvas:
        Color:
            rgba: self.color
        Rectangle:
            size: self.graphical_size
            pos: self.graphical_pos

Перепишем WormApp:

class WormApp(App):
    def build(self):
        self.config = Config()
        self.form = Form(self.config)
        return self.form

    def on_start(self):
        self.form.start()

Раскрасим. Перепишем Cell in .kv:

<Cell>:
    canvas:
        Color:
            rgba: self.color

        Rectangle:
            size: self.graphical_size
            pos: self.graphical_pos

Добавим это к Cell.__init__:

self.color = (0.2, 1.0, 0.2, 1.0)    # 

и это к Form.start

self.fruit.color = (1.0, 0.2, 0.2, 1.0)

Превосходно, наслаждайтесь змейкой

Наконец, мы создадим надпись «game over»

class Form(Widget):
...
    def __init__(self, config):
    ...
        self.popup_label.text = ""
...
    def stop(self, text=""):
        self.game_on = False
        self.popup_label.text = text
        Clock.unschedule(self.update)

    def game_over(self):
        self.stop("GAME OVER" + " " * 5 + "ntap to reset")

И зададим «раненой» клетке красный цвет:

вместо

    def update(self, _):
    ...
        if self.worm_bite_self():
            self.game_over()
    ...

напишите

    def update(self, _):
        cell = self.worm_bite_self()
        if cell:
            cell.color = (1.0, 0.2, 0.2, 1.0)
            self.game_over()

Вы еще тут? Самая интересная часть впереди!

Бонус — плавное движение

Так как шаг червячка равен cell_size, выглядит не очень плавно. Но мы бы хотели шагать как можно чаще без полного переписывания логики игры. Таким образом, нам нужен механизм, который двигал бы наши графические позиции (graphical_pos) но не влиял бы на настоящие (actual_pos). Я написал следующий код:

smooth.py

from kivy.clock import Clock
import time


class Timing:
    @staticmethod
    def linear(x):
        return x

class Smooth:
    def __init__(self, interval=1.0/60.0):
        self.objs = []
        self.running = False
        self.interval = interval

    def run(self):
        if self.running:
            return
        self.running = True
        Clock.schedule_interval(self.update, self.interval)

    def stop(self):
        if not self.running:
            return
        self.running = False
        Clock.unschedule(self.update)

    def setattr(self, obj, attr, value):
        exec("obj." + attr + " = " + str(value))

    def getattr(self, obj, attr):
        return float(eval("obj." + attr))

    def update(self, _):
        cur_time = time.time()
        for line in self.objs:
            obj, prop_name_x, prop_name_y, from_x, from_y, to_x, to_y, start_time, period, timing = line
            time_gone = cur_time - start_time
            if time_gone >= period:
                self.setattr(obj, prop_name_x, to_x)
                self.setattr(obj, prop_name_y, to_y)
                self.objs.remove(line)
            else:
                share = time_gone / period
                acs = timing(share)
                self.setattr(obj, prop_name_x, from_x * (1 - acs) + to_x * acs)
                self.setattr(obj, prop_name_y, from_y * (1 - acs) + to_y * acs)
        if len(self.objs) == 0:
            self.stop()

    def move_to(self, obj, prop_name_x, prop_name_y, to_x, to_y, t, timing=Timing.linear):
        self.objs.append((obj, prop_name_x, prop_name_y, self.getattr(obj, prop_name_x), self.getattr(obj, prop_name_y), to_x,
                          to_y, time.time(), t, timing))
        self.run()


class XSmooth(Smooth):
    def __init__(self, props, timing=Timing.linear, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.props = props
        self.timing = timing

    def move_to(self, obj, to_x, to_y, t):
        super().move_to(obj, *self.props, to_x, to_y, t, timing=self.timing)

Тем, кому не понравился сей код

Этот модуль не есть верх элегантности. Я признаю это решение плохим. Но это только hello-world решение.

Так, вы лишь создаете smooth.py and и копируете код в файл.
Наконец, заставим ЭТО работать!

class Form(Widget):
...
    def __init__(self, config):
    ...
        self.smooth = smooth.XSmooth(["graphical_pos[0]", "graphical_pos[1]"])

Заменим self.worm.move() с

class Form(Widget):
...
    def update(self, _):
    ...
        self.worm.move(self.cur_dir, smooth_motion=(self.smooth, self.config.INTERVAL))

А это как методы Cell должны выглядить

class Cell(Widget):
...
    def graphical_pos_attach(self, smooth_motion=None):
        to_x, to_y = self.actual_pos[0] - self.graphical_size[0] / 2, self.actual_pos[1] - self.graphical_size[1] / 2
        if smooth_motion is None:
            self.graphical_pos = to_x, to_y
        else:
            smoother, t = smooth_motion
            smoother.move_to(self, to_x, to_y, t)

    def move_to(self, x, y, **kwargs):
        self.actual_pos = (x, y)
        self.graphical_pos_attach(**kwargs)

    def move_by(self, x, y, **kwargs):
        self.move_to(self.actual_pos[0] + x, self.actual_pos[1] + y, **kwargs)

Ну вот и все, спасибо за ваше внимание! Код снизу.

Демонстрационное видео как работает результат:

Финальный код

main.py

from kivy.app import App
from kivy.uix.widget import Widget
from kivy.clock import Clock
from kivy.properties import *
import random
import smooth


class Cell(Widget):
    graphical_size = ListProperty([1, 1])
    graphical_pos = ListProperty([1, 1])
    color = ListProperty([1, 1, 1, 1])

    def __init__(self, x, y, size, margin=4):
        super().__init__()
        self.actual_size = (size, size)
        self.graphical_size = (size - margin, size - margin)
        self.margin = margin
        self.actual_pos = (x, y)
        self.graphical_pos_attach()
        self.color = (0.2, 1.0, 0.2, 1.0)

    def graphical_pos_attach(self, smooth_motion=None):
        to_x, to_y = self.actual_pos[0] - self.graphical_size[0] / 2, self.actual_pos[1] - self.graphical_size[1] / 2
        if smooth_motion is None:
            self.graphical_pos = to_x, to_y
        else:
            smoother, t = smooth_motion
            smoother.move_to(self, to_x, to_y, t)

    def move_to(self, x, y, **kwargs):
        self.actual_pos = (x, y)
        self.graphical_pos_attach(**kwargs)

    def move_by(self, x, y, **kwargs):
        self.move_to(self.actual_pos[0] + x, self.actual_pos[1] + y, **kwargs)

    def get_pos(self):
        return self.actual_pos

    def step_by(self, direction, **kwargs):
        self.move_by(self.actual_size[0] * direction[0], self.actual_size[1] * direction[1], **kwargs)


class Worm(Widget):
    def __init__(self, config):
        super().__init__()
        self.cells = []
        self.config = config
        self.cell_size = config.CELL_SIZE
        self.head_init((100, 100))
        for i in range(config.DEFAULT_LENGTH):
            self.lengthen()

    def destroy(self):
        for i in range(len(self.cells)):
            self.remove_widget(self.cells[i])
        self.cells = []

    def lengthen(self, pos=None, direction=(0, 1)):
        if pos is None:
            px = self.cells[-1].get_pos()[0] + direction[0] * self.cell_size
            py = self.cells[-1].get_pos()[1] + direction[1] * self.cell_size
            pos = (px, py)
        self.cells.append(Cell(*pos, self.cell_size, margin=self.config.MARGIN))
        self.add_widget(self.cells[-1])

    def head_init(self, pos):
        self.lengthen(pos=pos)

    def move(self, direction, **kwargs):
        for i in range(len(self.cells) - 1, 0, -1):
            self.cells[i].move_to(*self.cells[i - 1].get_pos(), **kwargs)
        self.cells[0].step_by(direction, **kwargs)

    def gather_positions(self):
        return [cell.get_pos() for cell in self.cells]

    def head_intersect(self, cell):
        return self.cells[0].get_pos() == cell.get_pos()


class Form(Widget):
    worm_len = NumericProperty(0)

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.worm = None
        self.cur_dir = (0, 0)
        self.fruit = None
        self.game_on = True
        self.smooth = smooth.XSmooth(["graphical_pos[0]", "graphical_pos[1]"])

    def random_cell_location(self, offset):
        x_row = self.size[0] // self.config.CELL_SIZE
        x_col = self.size[1] // self.config.CELL_SIZE
        return random.randint(offset, x_row - offset), random.randint(offset, x_col - offset)

    def random_location(self, offset):
        x_row, x_col = self.random_cell_location(offset)
        return self.config.CELL_SIZE * x_row, self.config.CELL_SIZE * x_col

    def fruit_dislocate(self):
        x, y = self.random_location(2)
        while (x, y) in self.worm.gather_positions():
            x, y = self.random_location(2)
        self.fruit.move_to(x, y)

    def start(self):
        self.worm = Worm(self.config)
        self.add_widget(self.worm)
        if self.fruit is not None:
            self.remove_widget(self.fruit)
        self.fruit = Cell(0, 0, self.config.APPLE_SIZE)
        self.fruit.color = (1.0, 0.2, 0.2, 1.0)
        self.fruit_dislocate()
        self.add_widget(self.fruit)
        self.game_on = True
        self.cur_dir = (0, -1)
        Clock.schedule_interval(self.update, self.config.INTERVAL)
        self.popup_label.text = ""

    def stop(self, text=""):
        self.game_on = False
        self.popup_label.text = text
        Clock.unschedule(self.update)

    def game_over(self):
        self.stop("GAME OVER" + " " * 5 + "ntap to reset")

    def align_labels(self):
        try:
            self.popup_label.pos = ((self.size[0] - self.popup_label.width) / 2, self.size[1] / 2)
            self.score_label.pos = ((self.size[0] - self.score_label.width) / 2, self.size[1] - 80)
        except:
            print(self.__dict__)
            assert False

    def update(self, _):
        if not self.game_on:
            return
        self.worm.move(self.cur_dir, smooth_motion=(self.smooth, self.config.INTERVAL))
        if self.worm.head_intersect(self.fruit):
            directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
            self.worm.lengthen(direction=random.choice(directions))
            self.fruit_dislocate()
        cell = self.worm_bite_self()
        if cell:
            cell.color = (1.0, 0.2, 0.2, 1.0)
            self.game_over()
        self.worm_len = len(self.worm.cells)
        self.align_labels()

    def on_touch_down(self, touch):
        if not self.game_on:
            self.worm.destroy()
            self.start()
            return
        ws = touch.x / self.size[0]
        hs = touch.y / self.size[1]
        aws = 1 - ws
        if ws > hs and aws > hs:
            cur_dir = (0, -1)
        elif ws > hs >= aws:
            cur_dir = (1, 0)
        elif ws <= hs < aws:
            cur_dir = (-1, 0)
        else:
            cur_dir = (0, 1)
        self.cur_dir = cur_dir

    def worm_bite_self(self):
        for cell in self.worm.cells[1:]:
            if self.worm.head_intersect(cell):
                return cell
        return False


class Config:
    DEFAULT_LENGTH = 20
    CELL_SIZE = 25
    APPLE_SIZE = 35
    MARGIN = 4
    INTERVAL = 0.3
    DEAD_CELL = (1, 0, 0, 1)
    APPLE_COLOR = (1, 1, 0, 1)


class WormApp(App):
    def build(self):
        self.config = Config()
        self.form = Form(self.config)
        return self.form

    def on_start(self):
        self.form.start()


if __name__ == '__main__':
    WormApp().run()

smooth.py

from kivy.clock import Clock
import time


class Timing:
    @staticmethod
    def linear(x):
        return x


class Smooth:
    def __init__(self, interval=1.0/60.0):
        self.objs = []
        self.running = False
        self.interval = interval

    def run(self):
        if self.running:
            return
        self.running = True
        Clock.schedule_interval(self.update, self.interval)

    def stop(self):
        if not self.running:
            return
        self.running = False
        Clock.unschedule(self.update)

    def setattr(self, obj, attr, value):
        exec("obj." + attr + " = " + str(value))

    def getattr(self, obj, attr):
        return float(eval("obj." + attr))

    def update(self, _):
        cur_time = time.time()
        for line in self.objs:
            obj, prop_name_x, prop_name_y, from_x, from_y, to_x, to_y, start_time, period, timing = line
            time_gone = cur_time - start_time
            if time_gone >= period:
                self.setattr(obj, prop_name_x, to_x)
                self.setattr(obj, prop_name_y, to_y)
                self.objs.remove(line)
            else:
                share = time_gone / period
                acs = timing(share)
                self.setattr(obj, prop_name_x, from_x * (1 - acs) + to_x * acs)
                self.setattr(obj, prop_name_y, from_y * (1 - acs) + to_y * acs)
        if len(self.objs) == 0:
            self.stop()

    def move_to(self, obj, prop_name_x, prop_name_y, to_x, to_y, t, timing=Timing.linear):
        self.objs.append((obj, prop_name_x, prop_name_y, self.getattr(obj, prop_name_x), self.getattr(obj, prop_name_y), to_x,
                          to_y, time.time(), t, timing))
        self.run()


class XSmooth(Smooth):
    def __init__(self, props, timing=Timing.linear, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.props = props
        self.timing = timing

    def move_to(self, obj, to_x, to_y, t):
        super().move_to(obj, *self.props, to_x, to_y, t, timing=self.timing)

worm.kv

<Form>:
    popup_label: popup_label
    score_label: score_label

    canvas:
        Color:
            rgba: (.5, .5, .5, 1.0)

        Line:
            width: 1.5
            points: (0, 0), self.size

        Line:
            width: 1.5
            points: (self.size[0], 0), (0, self.size[1])
    Label:
        id: score_label
        text: "Score: " + str(self.parent.worm_len)
        width: self.width

    Label:
        id: popup_label
        width: self.width

<Worm>:


<Cell>:
    canvas:
        Color:
            rgba: self.color

        Rectangle:
            size: self.graphical_size
            pos: self.graphical_pos

Код, немного измененный @tshirtman

Мой код был проверен tshirtman, одним из участников Kivy, который предложил мне использовать инструкцию Point вместо создания виджета на каждую клетку. Однако мне не кажется сей код более простым для понимания чем мой, хотя он точно лучше в понимании разработки UI и gamedev. В общем, вот код:

main.py

from kivy.app import App
from kivy.uix.widget import Widget
from kivy.clock import Clock
from kivy.properties import *
import random
import smooth


class Cell:
    def __init__(self, x, y):
        self.actual_pos = (x, y)

    def move_to(self, x, y):
        self.actual_pos = (x, y)

    def move_by(self, x, y):
        self.move_to(self.actual_pos[0] + x, self.actual_pos[1] + y)

    def get_pos(self):
        return self.actual_pos


class Fruit(Cell):
    def __init__(self, x, y):
        super().__init__(x, y)


class Worm(Widget):
    margin = NumericProperty(4)
    graphical_poses = ListProperty()
    inj_pos = ListProperty([-1000, -1000])
    graphical_size = NumericProperty(0)

    def __init__(self, config, **kwargs):
        super().__init__(**kwargs)
        self.cells = []
        self.config = config
        self.cell_size = config.CELL_SIZE
        self.head_init((self.config.CELL_SIZE * random.randint(3, 5), self.config.CELL_SIZE * random.randint(3, 5)))
        self.margin = config.MARGIN
        self.graphical_size = self.cell_size - self.margin
        for i in range(config.DEFAULT_LENGTH):
            self.lengthen()

    def destroy(self):
        self.cells = []
        self.graphical_poses = []
        self.inj_pos = [-1000, -1000]

    def cell_append(self, pos):
        self.cells.append(Cell(*pos))
        self.graphical_poses.extend([0, 0])
        self.cell_move_to(len(self.cells) - 1, pos)

    def lengthen(self, pos=None, direction=(0, 1)):
        if pos is None:
            px = self.cells[-1].get_pos()[0] + direction[0] * self.cell_size
            py = self.cells[-1].get_pos()[1] + direction[1] * self.cell_size
            pos = (px, py)
        self.cell_append(pos)

    def head_init(self, pos):
        self.lengthen(pos=pos)

    def cell_move_to(self, i, pos, smooth_motion=None):
        self.cells[i].move_to(*pos)
        to_x, to_y = pos[0], pos[1]
        if smooth_motion is None:
            self.graphical_poses[i * 2], self.graphical_poses[i * 2 + 1] = to_x, to_y
        else:
            smoother, t = smooth_motion
            smoother.move_to(self, "graphical_poses[" + str(i * 2) + "]", "graphical_poses[" + str(i * 2 + 1) + "]",
                             to_x, to_y, t)

    def move(self, direction, **kwargs):
        for i in range(len(self.cells) - 1, 0, -1):
            self.cell_move_to(i, self.cells[i - 1].get_pos(), **kwargs)
        self.cell_move_to(0, (self.cells[0].get_pos()[0] + self.cell_size * direction[0], self.cells[0].get_pos()[1] +
                              self.cell_size * direction[1]), **kwargs)

    def gather_positions(self):
        return [cell.get_pos() for cell in self.cells]

    def head_intersect(self, cell):
        return self.cells[0].get_pos() == cell.get_pos()


class Form(Widget):
    worm_len = NumericProperty(0)
    fruit_pos = ListProperty([0, 0])
    fruit_size = NumericProperty(0)

    def __init__(self, config, **kwargs):
        super().__init__(**kwargs)
        self.config = config
        self.worm = None
        self.cur_dir = (0, 0)
        self.fruit = None
        self.game_on = True
        self.smooth = smooth.Smooth()

    def random_cell_location(self, offset):
        x_row = self.size[0] // self.config.CELL_SIZE
        x_col = self.size[1] // self.config.CELL_SIZE
        return random.randint(offset, x_row - offset), random.randint(offset, x_col - offset)

    def random_location(self, offset):
        x_row, x_col = self.random_cell_location(offset)
        return self.config.CELL_SIZE * x_row, self.config.CELL_SIZE * x_col

    def fruit_dislocate(self, xy=None):
        if xy is not None:
            x, y = xy
        else:
            x, y = self.random_location(2)
            while (x, y) in self.worm.gather_positions():
                x, y = self.random_location(2)
        self.fruit.move_to(x, y)
        self.fruit_pos = (x, y)

    def start(self):
        self.worm = Worm(self.config)
        self.add_widget(self.worm)
        self.fruit = Fruit(0, 0)
        self.fruit_size = self.config.APPLE_SIZE
        self.fruit_dislocate()
        self.game_on = True
        self.cur_dir = (0, -1)
        Clock.schedule_interval(self.update, self.config.INTERVAL)
        self.popup_label.text = ""

    def stop(self, text=""):
        self.game_on = False
        self.popup_label.text = text
        Clock.unschedule(self.update)

    def game_over(self):
        self.stop("GAME OVER" + " " * 5 + "ntap to reset")

    def align_labels(self):
        self.popup_label.pos = ((self.size[0] - self.popup_label.width) / 2, self.size[1] / 2)
        self.score_label.pos = ((self.size[0] - self.score_label.width) / 2, self.size[1] - 80)

    def update(self, _):
        if not self.game_on:
            return
        self.worm.move(self.cur_dir, smooth_motion=(self.smooth, self.config.INTERVAL))
        if self.worm.head_intersect(self.fruit):
            directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
            self.worm.lengthen(direction=random.choice(directions))
            self.fruit_dislocate()
        cell = self.worm_bite_self()
        if cell is not None:
            self.worm.inj_pos = cell.get_pos()
            self.game_over()
        self.worm_len = len(self.worm.cells)
        self.align_labels()

    def on_touch_down(self, touch):
        if not self.game_on:
            self.worm.destroy()
            self.start()
            return
        ws = touch.x / self.size[0]
        hs = touch.y / self.size[1]
        aws = 1 - ws
        if ws > hs and aws > hs:
            cur_dir = (0, -1)
        elif ws > hs >= aws:
            cur_dir = (1, 0)
        elif ws <= hs < aws:
            cur_dir = (-1, 0)
        else:
            cur_dir = (0, 1)
        self.cur_dir = cur_dir

    def worm_bite_self(self):
        for cell in self.worm.cells[1:]:
            if self.worm.head_intersect(cell):
                return cell
        return None


class Config:
    DEFAULT_LENGTH = 20
    CELL_SIZE = 26  # НЕ ЗАБУДЬТЕ, ЧТО CELL_SIZE - MARGIN ДОЛЖНО ДЕЛИТЬСЯ НА 4
    APPLE_SIZE = 36
    MARGIN = 2
    INTERVAL = 0.3
    DEAD_CELL = (1, 0, 0, 1)
    APPLE_COLOR = (1, 1, 0, 1)


class WormApp(App):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.form = None

    def build(self, **kwargs):
        self.config = Config()
        self.form = Form(self.config, **kwargs)
        return self.form

    def on_start(self):
        self.form.start()


if __name__ == '__main__':
    WormApp().run()

smooth.py

from kivy.clock import Clock
import time


class Timing:
    @staticmethod
    def linear(x):
        return x


class Smooth:
    def __init__(self, interval=1.0/60.0):
        self.objs = []
        self.running = False
        self.interval = interval

    def run(self):
        if self.running:
            return
        self.running = True
        Clock.schedule_interval(self.update, self.interval)

    def stop(self):
        if not self.running:
            return
        self.running = False
        Clock.unschedule(self.update)

    def set_attr(self, obj, attr, value):
        exec("obj." + attr + " = " + str(value))

    def get_attr(self, obj, attr):
        return float(eval("obj." + attr))

    def update(self, _):
        cur_time = time.time()
        for line in self.objs:
            obj, prop_name_x, prop_name_y, from_x, from_y, to_x, to_y, start_time, period, timing = line
            time_gone = cur_time - start_time
            if time_gone >= period:
                self.set_attr(obj, prop_name_x, to_x)
                self.set_attr(obj, prop_name_y, to_y)
                self.objs.remove(line)
            else:
                share = time_gone / period
                acs = timing(share)
                self.set_attr(obj, prop_name_x, from_x * (1 - acs) + to_x * acs)
                self.set_attr(obj, prop_name_y, from_y * (1 - acs) + to_y * acs)
        if len(self.objs) == 0:
            self.stop()

    def move_to(self, obj, prop_name_x, prop_name_y, to_x, to_y, t, timing=Timing.linear):
        self.objs.append((obj, prop_name_x, prop_name_y, self.get_attr(obj, prop_name_x), self.get_attr(obj, prop_name_y), to_x,
                          to_y, time.time(), t, timing))
        self.run()


class XSmooth(Smooth):
    def __init__(self, props, timing=Timing.linear, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.props = props
        self.timing = timing

    def move_to(self, obj, to_x, to_y, t):
        super().move_to(obj, *self.props, to_x, to_y, t, timing=self.timing)

worm.kv

<Form>:
    popup_label: popup_label
    score_label: score_label

    canvas:
        Color:
            rgba: (.5, .5, .5, 1.0)

        Line:
            width: 1.5
            points: (0, 0), self.size

        Line:
            width: 1.5
            points: (self.size[0], 0), (0, self.size[1])

        Color:
            rgba: (1.0, 0.2, 0.2, 1.0)

        Point:
            points: self.fruit_pos
            pointsize: self.fruit_size / 2

    Label:
        id: score_label
        text: "Score: " + str(self.parent.worm_len)
        width: self.width

    Label:
        id: popup_label
        width: self.width

<Worm>:
    canvas:
        Color:
            rgba: (0.2, 1.0, 0.2, 1.0)
        Point:
            points: self.graphical_poses
            pointsize: self.graphical_size / 2
        Color:
            rgba: (1.0, 0.2, 0.2, 1.0)
        Point:
            points: self.inj_pos
            pointsize: self.graphical_size / 2

Задавайте любые вопросы.

Table Of Contents

  • Pong Game Tutorial
    • Introduction
    • Getting Started
    • Add Simple Graphics
      • Explaining the Kv File Syntax
    • Add the Ball
      • PongBall Class
    • Adding Ball Animation
      • Scheduling Functions on the Clock
      • Object Properties/References
    • Connect Input Events
    • Where To Go Now?

Introduction¶

Welcome to the Pong tutorial

This tutorial will teach you how to write pong using Kivy. We’ll start with
a basic application like the one described in the Create an application and turn
it into a playable pong game, describing each step along the way.

../_images/pong.jpg

Here is a check list before starting this tutorial:

  • You have a working Kivy installation. See the Installing Kivy
    section for detailed descriptions

  • You know how to run a basic Kivy application. See Create an application
    if you don’t.

If you have read the programming guide, and understand both basic Widget
concepts (A Simple Paint App) and basic concepts of the kv language
(Kv language), you can probably skip the first 2
steps and go straight to step 3.

Note

You can find the entire source code, and source code files for each step in
the Kivy examples directory under tutorials/pong/

Ready? Sweet, let’s get started!

Getting Started¶

Getting Started

Let’s start by getting a really simple Kivy app up and running. Create a
directory for the game and a file named main.py

 1from kivy.app import App
 2from kivy.uix.widget import Widget
 3
 4
 5class PongGame(Widget):
 6    pass
 7
 8
 9class PongApp(App):
10    def build(self):
11        return PongGame()
12
13
14if __name__ == '__main__':
15    PongApp().run()

Go ahead and run the application. It should just show a black window at this
point. What we’ve done is create a very simple Kivy App,
which creates an instance of our PongGame Widget class and returns it as
the root element for the applications UI, which you should imagine at this
point as a hierarchical tree of Widgets. Kivy places this widget-tree in the
default Window. In the next step, we will draw the
Pong background and scores by defining how the PongGame widget looks.

Add Simple Graphics¶

Creation of pong.kv

We will use a .kv file to define the look and feel of the PongGame class.
Since our App class is called PongApp, we can simply create a file
called pong.kv in the same directory that will be automatically loaded
when the application is run. So create a new file called «pong.kv« and add
the following contents.

 1#:kivy 1.0.9
 2
 3<PongGame>:    
 4    canvas:
 5        Rectangle:
 6            pos: self.center_x - 5, 0
 7            size: 10, self.height
 8            
 9    Label:
10        font_size: 70  
11        center_x: root.width / 4
12        top: root.top - 50
13        text: "0"
14        
15    Label:
16        font_size: 70  
17        center_x: root.width * 3 / 4
18        top: root.top - 50
19        text: "0"

Note

COMMON ERROR: The name of the kv file, e.g. pong.kv, must match the name of the app,
e.g. PongApp (the part before the App ending).

If you run the app now, you should see a vertical bar in the middle, and two
zeros where the player scores will be displayed.

Explaining the Kv File Syntax¶

Before going on to the next step, you might want to take a closer look at
the contents of the kv file we just created and figure out what is going on.
If you understand what’s happening, you can probably skip ahead to the next
step.

On the very first line we have:

This first line is required in every kv file. It should start with #:kivy
followed by a space and the Kivy version it is intended for (so Kivy can make
sure you have at least the required version, or handle backwards compatibility
later on).

After that, we begin defining rules that are applied to all PongGame
instances:

Like Python, kv files use indentation to define nested blocks. A block defined
with a class name inside the < and > characters is a
Widget rule. It will be applied to any instance of
the named class. If you replaced PongGame with Widget in our example, all
Widget instances would have the vertical line and the two Label widgets inside
them because it would define these rules for all Widget instances.

Inside a rule section, you can add various blocks to define the style and
contents of the widgets they will be applied to. You can:

  • set property values

  • add child widgets

  • define a canvas section in which you can add Graphics instructions that
    define how the widget is rendered.

The first block inside the <PongGame> rule we have is a canvas block:

1<PongGame>:
2    canvas:
3        Rectangle:
4            pos: self.center_x - 5, 0
5            size: 10, self.height

So this canvas block says that the PongGame widget should draw some
graphics primitives. In this case, we add a rectangle to the canvas. We set
the pos of the rectangle to be 5 pixels left of the horizontal center of
the widget, and 0 for y. The size of the rectangle is set to 10 pixels
in width, and the widget’s height in height. The nice thing about defining the
graphics like this, is that the rendered rectangle will be automatically
updated when the properties of any widgets used in the value expression change.

Note

Try to resize the application window and notice what happens. That’s
right, the entire UI resizes automatically. The standard behaviour of the
Window is to resize an element based on its property size_hint. The
default widget size_hint is (1,1), meaning it will be stretched 100% in both
x-direction and y-direction and hence fill the available space.
Since the pos and size of the rectangle and center_x and top of the score
labels were defined within
the context of the PongGame class, these properties will automatically
update when the corresponding widget properties change. Using the Kv
language gives you automatic property binding. :)

The last two sections we add look pretty similar. Each of them adds a Label
widget as a child widget to the PongGame widget. For now, the text on
both of them is just set to “0”. We’ll hook that up to the actual
score once we have the logic implemented, but the labels already
look good since we set a bigger font_size, and positioned them relatively
to the root widget. The root keyword can be used inside the child block to
refer back to the parent/root widget the rule applies to (PongGame in this
case):

 1<PongGame>:
 2    # ...
 3
 4    Label:
 5        font_size: 70
 6        center_x: root.width / 4
 7        top: root.top - 50
 8        text: "0"
 9
10    Label:
11        font_size: 70
12        center_x: root.width * 3 / 4
13        top: root.top - 50
14        text: "0"

Add the Ball¶

Add the Ball

Ok, so we have a basic pong arena to play in, but we still need the players and
a ball to hit around. Let’s start with the ball. We’ll add a new PongBall
class to create a widget that will be our ball and make it bounce around.

PongBall Class¶

Here is the Python code for the PongBall class:

 1class PongBall(Widget):
 2
 3    # velocity of the ball on x and y axis
 4    velocity_x = NumericProperty(0)
 5    velocity_y = NumericProperty(0)
 6
 7    # referencelist property so we can use ball.velocity as
 8    # a shorthand, just like e.g. w.pos for w.x and w.y
 9    velocity = ReferenceListProperty(velocity_x, velocity_y)
10
11    # ``move`` function will move the ball one step. This
12    #  will be called in equal intervals to animate the ball
13    def move(self):
14        self.pos = Vector(*self.velocity) + self.pos

And here is the kv rule used to draw the ball as a white circle:

1<PongBall>:
2    size: 50, 50
3    canvas:
4        Ellipse:
5            pos: self.pos
6            size: self.size

To make it all work, you also have to add the imports for the
Properties Property classes used and the
Vector.

Here is the entire updated python code and kv file for this step:

main.py:
 1from kivy.app import App
 2from kivy.uix.widget import Widget
 3from kivy.properties import NumericProperty, ReferenceListProperty
 4from kivy.vector import Vector
 5
 6
 7class PongBall(Widget):
 8    velocity_x = NumericProperty(0)
 9    velocity_y = NumericProperty(0)
10    velocity = ReferenceListProperty(velocity_x, velocity_y)
11
12    def move(self):
13        self.pos = Vector(*self.velocity) + self.pos
14
15
16class PongGame(Widget):
17    pass
18
19
20class PongApp(App):
21    def build(self):
22        return PongGame()
23
24
25if __name__ == '__main__':
26    PongApp().run()
pong.kv:
 1#:kivy 1.0.9
 2
 3<PongBall>:
 4    size: 50, 50 
 5    canvas:
 6        Ellipse:
 7            pos: self.pos
 8            size: self.size          
 9
10<PongGame>:
11    canvas:
12        Rectangle:
13            pos: self.center_x - 5, 0
14            size: 10, self.height
15    
16    Label:
17        font_size: 70  
18        center_x: root.width / 4
19        top: root.top - 50
20        text: "0"
21        
22    Label:
23        font_size: 70  
24        center_x: root.width * 3 / 4
25        top: root.top - 50
26        text: "0"
27    
28    PongBall:
29        center: self.parent.center
30        

Note that not only a <PongBall> widget rule has been added, but also a
child widget PongBall in the <PongGame> widget rule.

Adding Ball Animation¶

Making the ball move

Cool, so now we have a ball, and it even has a move function… but it’s not
moving yet. Let’s fix that.

Scheduling Functions on the Clock¶

We need the move method of our ball to be called regularly. Luckily, Kivy
makes this pretty easy by letting us schedule any function we want using the
Clock and specifying the interval:

Clock.schedule_interval(game.update, 1.0/60.0)

This line for example, would cause the update function of the game object to
be called once every 60th of a second (60 times per second).

Object Properties/References¶

We have another problem though. We’d like to make sure the PongBall has its
move function called regularly, but in our code we don’t have any references
to the ball object since we just added it via the kv file
inside the kv rule for the PongGame class. The only reference to our
game is the one we return in the applications build method.

Since we’re going to have to do more than just move the ball (e.g.
bounce it off the walls and later the players racket), we’ll probably need
an update method for our PongGame class anyway. Furthermore, given that
we have a reference to the game object already, we can easily schedule its new
update method when the application gets built:

 1class PongGame(Widget):
 2
 3    def update(self, dt):
 4        # call ball.move and other stuff
 5        pass
 6
 7class PongApp(App):
 8
 9    def build(self):
10        game = PongGame()
11        Clock.schedule_interval(game.update, 1.0/60.0)
12        return game

However, that still doesn’t change the fact that we don’t have a reference to the
PongBall child widget created by the kv rule. To fix this, we can add an
ObjectProperty
to the PongGame class, and hook it up to the widget created in
the kv rule. Once that’s done, we can easily reference the ball property
inside the update method and even make it bounce off the edges:

 1class PongGame(Widget):
 2    ball = ObjectProperty(None)
 3
 4    def update(self, dt):
 5        self.ball.move()
 6
 7        # bounce off top and bottom
 8        if (self.ball.y < 0) or (self.ball.top > self.height):
 9            self.ball.velocity_y *= -1
10
11        # bounce off left and right
12        if (self.ball.x < 0) or (self.ball.right > self.width):
13            self.ball.velocity_x *= -1

Don’t forget to hook it up in the kv file, by giving the child widget an id
and setting the PongGame’s ball ObjectProperty to that id:

1<PongGame>:
2    ball: pong_ball
3
4    # ... (canvas and Labels)
5
6    PongBall:
7        id: pong_ball
8        center: self.parent.center

Note

At this point everything is hooked up for the ball to bounce around. If
you’re coding along as we go, you might be wondering why the ball isn’t
moving anywhere. The ball’s velocity is set to 0 on both x and y.
In the code listing below, a serve_ball method is
added to the PongGame class and called in the app’s build method. It sets a
random x and y velocity for the ball, and also resets the position, so we
can use it later to reset the ball when a player has scored a point.

Here is the entire code for this step:

main.py:
 1from kivy.app import App
 2from kivy.uix.widget import Widget
 3from kivy.properties import (
 4    NumericProperty, ReferenceListProperty, ObjectProperty
 5)
 6from kivy.vector import Vector
 7from kivy.clock import Clock
 8from random import randint
 9
10
11class PongBall(Widget):
12    velocity_x = NumericProperty(0)
13    velocity_y = NumericProperty(0)
14    velocity = ReferenceListProperty(velocity_x, velocity_y)
15
16    def move(self):
17        self.pos = Vector(*self.velocity) + self.pos
18
19
20class PongGame(Widget):
21    ball = ObjectProperty(None)
22
23    def serve_ball(self):
24        self.ball.center = self.center
25        self.ball.velocity = Vector(4, 0).rotate(randint(0, 360))
26
27    def update(self, dt):
28        self.ball.move()
29
30        # bounce off top and bottom
31        if (self.ball.y < 0) or (self.ball.top > self.height):
32            self.ball.velocity_y *= -1
33
34        # bounce off left and right
35        if (self.ball.x < 0) or (self.ball.right > self.width):
36            self.ball.velocity_x *= -1
37
38
39class PongApp(App):
40    def build(self):
41        game = PongGame()
42        game.serve_ball()
43        Clock.schedule_interval(game.update, 1.0 / 60.0)
44        return game
45
46
47if __name__ == '__main__':
48    PongApp().run()
pong.kv:
 1#:kivy 1.0.9
 2
 3<PongBall>:
 4    size: 50, 50 
 5    canvas:
 6        Ellipse:
 7            pos: self.pos
 8            size: self.size          
 9
10<PongGame>:
11    ball: pong_ball
12    
13    canvas:
14        Rectangle:
15            pos: self.center_x - 5, 0
16            size: 10, self.height
17    
18    Label:
19        font_size: 70  
20        center_x: root.width / 4
21        top: root.top - 50
22        text: "0"
23        
24    Label:
25        font_size: 70  
26        center_x: root.width * 3 / 4
27        top: root.top - 50
28        text: "0"
29    
30    PongBall:
31        id: pong_ball
32        center: self.parent.center
33        

Connect Input Events¶

Adding Players and reacting to touch input

Sweet, our ball is bouncing around. The only things missing now are the movable
player rackets and keeping track of the score. We won’t go over all the
details of creating the class and kv rules again, since those concepts were
already covered in the previous steps. Instead, let’s focus on how to move the
Player widgets in response to user input. You can get the whole code and kv
rules for the PongPaddle class at the end of this section.

In Kivy, a widget can react to input by implementing the
on_touch_down, the
on_touch_move and the
on_touch_up
methods. By default, the Widget class
implements these methods by just calling the corresponding method on all its
child widgets to pass on the event until one of the children returns True.

Pong is pretty simple. The rackets just need to move up and down. In fact it’s
so simple, we don’t even really need to have the player widgets handle the
events themselves. We’ll just implement the on_touch_move function for the
PongGame class and have it set the position of the left or right player based
on whether the touch occurred on the left or right side of the screen.

Check the on_touch_move handler:

1def on_touch_move(self, touch):
2    if touch.x < self.width/3:
3        self.player1.center_y = touch.y
4    if touch.x > self.width - self.width/3:
5        self.player2.center_y = touch.y

We’ll keep the score for each player in a
NumericProperty. The score labels of the PongGame
are kept updated by changing the NumericProperty score, which in turn
updates the PongGame child labels text property. This binding
occurs because Kivy properties automatically bind to any references
in their corresponding kv files. When the ball
escapes out of the sides, we’ll update the score and serve the ball
again by changing the update method in the PongGame class. The PongPaddle
class also implements a bounce_ball method, so that the ball bounces
differently based on where it hits the racket. Here is the code for the
PongPaddle class:

1class PongPaddle(Widget):
2
3    score = NumericProperty(0)
4
5    def bounce_ball(self, ball):
6        if self.collide_widget(ball):
7            speedup  = 1.1
8            offset = 0.02 * Vector(0, ball.center_y-self.center_y)
9            ball.velocity =  speedup * (offset - ball.velocity)

Note

This algorithm for ball bouncing is very simple, but will have strange behavior
if the ball hits the paddle from the side or bottom…this is something you could
try to fix yourself if you like.

And here it is in context. Pretty much done:

main.py:
 1from kivy.app import App
 2from kivy.uix.widget import Widget
 3from kivy.properties import (
 4    NumericProperty, ReferenceListProperty, ObjectProperty
 5)
 6from kivy.vector import Vector
 7from kivy.clock import Clock
 8
 9
10class PongPaddle(Widget):
11    score = NumericProperty(0)
12
13    def bounce_ball(self, ball):
14        if self.collide_widget(ball):
15            vx, vy = ball.velocity
16            offset = (ball.center_y - self.center_y) / (self.height / 2)
17            bounced = Vector(-1 * vx, vy)
18            vel = bounced * 1.1
19            ball.velocity = vel.x, vel.y + offset
20
21
22class PongBall(Widget):
23    velocity_x = NumericProperty(0)
24    velocity_y = NumericProperty(0)
25    velocity = ReferenceListProperty(velocity_x, velocity_y)
26
27    def move(self):
28        self.pos = Vector(*self.velocity) + self.pos
29
30
31class PongGame(Widget):
32    ball = ObjectProperty(None)
33    player1 = ObjectProperty(None)
34    player2 = ObjectProperty(None)
35
36    def serve_ball(self, vel=(4, 0)):
37        self.ball.center = self.center
38        self.ball.velocity = vel
39
40    def update(self, dt):
41        self.ball.move()
42
43        # bounce of paddles
44        self.player1.bounce_ball(self.ball)
45        self.player2.bounce_ball(self.ball)
46
47        # bounce ball off bottom or top
48        if (self.ball.y < self.y) or (self.ball.top > self.top):
49            self.ball.velocity_y *= -1
50
51        # went of to a side to score point?
52        if self.ball.x < self.x:
53            self.player2.score += 1
54            self.serve_ball(vel=(4, 0))
55        if self.ball.right > self.width:
56            self.player1.score += 1
57            self.serve_ball(vel=(-4, 0))
58
59    def on_touch_move(self, touch):
60        if touch.x < self.width / 3:
61            self.player1.center_y = touch.y
62        if touch.x > self.width - self.width / 3:
63            self.player2.center_y = touch.y
64
65
66class PongApp(App):
67    def build(self):
68        game = PongGame()
69        game.serve_ball()
70        Clock.schedule_interval(game.update, 1.0 / 60.0)
71        return game
72
73
74if __name__ == '__main__':
75    PongApp().run()

pong.kv:

 1#:kivy 1.0.9
 2
 3<PongBall>:
 4    size: 50, 50 
 5    canvas:
 6        Ellipse:
 7            pos: self.pos
 8            size: self.size          
 9
10<PongPaddle>:
11    size: 25, 200
12    canvas:
13        Rectangle:
14            pos: self.pos
15            size: self.size
16
17<PongGame>:
18    ball: pong_ball
19    player1: player_left
20    player2: player_right
21    
22    canvas:
23        Rectangle:
24            pos: self.center_x - 5, 0
25            size: 10, self.height
26    
27    Label:
28        font_size: 70  
29        center_x: root.width / 4
30        top: root.top - 50
31        text: str(root.player1.score)
32        
33    Label:
34        font_size: 70  
35        center_x: root.width * 3 / 4
36        top: root.top - 50
37        text: str(root.player2.score)
38    
39    PongBall:
40        id: pong_ball
41        center: self.parent.center
42        
43    PongPaddle:
44        id: player_left
45        x: root.x
46        center_y: root.center_y
47        
48    PongPaddle:
49        id: player_right
50        x: root.width - self.width
51        center_y: root.center_y
52        

Where To Go Now?¶

Have some fun

Well, the pong game is pretty much complete. If you understood all of the
things that are covered in this tutorial, give yourself a pat on the back and
think about how you could improve the game. Here are a few ideas of things
you could do:

  • Add some nicer graphics / images. (Hint: check out the
    source property on
    the graphics instructions like circle or
    Rectangle, to set an image as the
    texture.)

  • Make the game end after a certain score. Maybe once a player has 10
    points, you can display a large “PLAYER 1 WINS” label and/or add a main menu
    to start, pause and reset the game. (Hint: check out the
    Button and
    Label
    classes, and figure out how to use their add_widget and remove_widget
    functions to add or remove widgets dynamically.

  • Make it a 4 player Pong Game. Most tablets have Multi-Touch support, so
    wouldn’t it be cool to have a player on each side and have four
    people play at the same time?

  • Fix the simplistic collision check so hitting the ball with an end of
    the paddle results in a more realistic bounce.

Note

You can find the entire source code and source code files for each step in
the Kivy examples directory under tutorials/pong/

Watch Now This tutorial has a related video course created by the Real Python team. Watch it together with the written tutorial to deepen your understanding: Build Cross-Platform GUI Apps With Kivy

These days, developers are highly likely to be working on a mobile or web application. Python doesn’t have built-in mobile development capabilities, but there are packages you can use to create mobile applications, like Kivy, PyQt, or even Beeware’s Toga library.

These libraries are all major players in the Python mobile space. However, there are some benefits you’ll see if you choose to create mobile applications with Kivy. Not only will your application look the same on all platforms, but you also won’t need to compile your code after every change. What’s more, you’ll be able to use Python’s clear syntax to build your applications.

In this tutorial, you’ll learn how to:

  • Work with Kivy widgets
  • Lay out the UI
  • Add events
  • Use the KV language
  • Create a calculator application
  • Package your application for iOS, Android, Windows, and macOS

This tutorial assumes you’re familiar with object-oriented programming. If you’re not, then check out Object-Oriented Programming (OOP) in Python 3.

Let’s get started!

Understanding the Kivy Framework

Kivy was first released in early 2011. This cross-platform Python framework can be deployed to Windows, Mac, Linux, and Raspberry Pi. It supports multitouch events in addition to regular keyboard and mouse inputs. Kivy even supports GPU acceleration of its graphics, since they’re built using OpenGL ES2. The project uses the MIT license, so you can use this library for free and commercial software.

When you create an application with Kivy, you’re creating a Natural User Interface or NUI. The idea behind a Natural User Interface is that the user can easily learn how to use your software with little to no instruction.

Kivy does not attempt to use native controls or widgets. All of its widgets are custom-drawn. This means that Kivy applications will look the same across all platforms. However, it also means that your app’s look and feel will differ from your user’s native applications. This could be a benefit or a drawback, depending on your audience.

Installing Kivy

Kivy has many dependencies, so it’s recommended that you install it into a Python virtual environment. You can use either Python’s built-in venv library or the virtualenv package. If you’ve never used a Python virtual environment before, then check out Python Virtual Environments: A Primer.

Here’s how you can create a Python virtual environment:

$ python3 -m venv my_kivy_project

This will copy your Python 3 executable into a folder called my_kivy_project and add a few other subfolders to that directory.

To use your virtual environment, you need to activate it. On Mac and Linux, you can do that by executing the following while inside the my_kivy_project folder:

The command for Windows is similar, but the location of the activate script is inside of the Scripts folder instead of bin.

Now that you have an activated Python virtual environment, you can run pip to install Kivy. On Linux and Mac, you’ll run the following command:

$ python -m pip install kivy

On Windows, installation is a bit more complex. Check out the official documentation for how to install Kivy on Windows. (Mac users can also download a dmg file and install Kivy that way.)

If you run into any issues installing Kivy on your platform, then see the Kivy download page for additional instructions.

Working With Kivy Widgets

A widget is an onscreen control that the user will interact with. All graphical user interface toolkits come with a set of widgets. Some common widgets that you may have used include buttons, combo boxes, and tabs. Kivy has many widgets built into its framework.

Running a “Hello, Kivy!” Program

To see how Kivy works, take a look at the following “Hello, World!” application:

from kivy.app import App
from kivy.uix.label import Label

class MainApp(App):
    def build(self):
        label = Label(text='Hello from Kivy',
                      size_hint=(.5, .5),
                      pos_hint={'center_x': .5, 'center_y': .5})

        return label

if __name__ == '__main__':
    app = MainApp()
    app.run()

Every Kivy application needs to subclass App and override build(). This is where you’ll put your UI code or make calls to other functions that define your UI code. In this case, you create a Label widget and pass in its text, size_hint, and pos_hint. These last two arguments are not required.

size_hint tells Kivy the proportions to use when creating the widget. It takes two numbers:

  1. The first number is the x size hint and refers to the width of the control.
  2. The second number is the y size hint and refers to the height of the control.

Both of these numbers can be anywhere between 0 and 1. The default value for both hints is 1. You can also use pos_hint to position the widget. In the code block above, you tell Kivy to center the widget on the x and y axes.

To make the application run, you instantiate your MainApp class and then call run(). When you do so, you should see the following on your screen:

Hello World in Kivy

Kivy also outputs a lot of text to stdout:

[INFO   ] [Logger      ] Record log in /home/mdriscoll/.kivy/logs/kivy_19-06-07_2.txt
[INFO   ] [Kivy        ] v1.11.0
[INFO   ] [Kivy        ] Installed at "/home/mdriscoll/code/test/lib/python3.6/site-packages/kivy/__init__.py"
[INFO   ] [Python      ] v3.6.7 (default, Oct 22 2018, 11:32:17)
[GCC 8.2.0]
[INFO   ] [Python      ] Interpreter at "/home/mdriscoll/code/test/bin/python"
[INFO   ] [Factory     ] 184 symbols loaded
[INFO   ] [Image       ] Providers: img_tex, img_dds, img_sdl2, img_gif (img_pil, img_ffpyplayer ignored)
[INFO   ] [Text        ] Provider: sdl2(['text_pango'] ignored)
[INFO   ] [Window      ] Provider: sdl2(['window_egl_rpi'] ignored)
[INFO   ] [GL          ] Using the "OpenGL" graphics system
[INFO   ] [GL          ] Backend used <sdl2>
[INFO   ] [GL          ] OpenGL version <b'4.6.0 NVIDIA 390.116'>
[INFO   ] [GL          ] OpenGL vendor <b'NVIDIA Corporation'>
[INFO   ] [GL          ] OpenGL renderer <b'NVS 310/PCIe/SSE2'>
[INFO   ] [GL          ] OpenGL parsed version: 4, 6
[INFO   ] [GL          ] Shading version <b'4.60 NVIDIA'>
[INFO   ] [GL          ] Texture max size <16384>
[INFO   ] [GL          ] Texture max units <32>
[INFO   ] [Window      ] auto add sdl2 input provider
[INFO   ] [Window      ] virtual keyboard not allowed, single mode, not docked
[INFO   ] [Base        ] Start application main loop
[INFO   ] [GL          ] NPOT texture support is available

This is useful for debugging your application.

Next, you’ll try adding an Image widget and see how that differs from a Label.

Displaying an Image

Kivy has a couple of different image-related widgets to choose from. You can use Image to load local images from your hard drive or AsyncImage to load an image from a URL. For this example, you’ll stick with the standard Image class:

from kivy.app import App
from kivy.uix.image import Image

class MainApp(App):
    def build(self):
        img = Image(source='/path/to/real_python.png',
                    size_hint=(1, .5),
                    pos_hint={'center_x':.5, 'center_y':.5})

        return img

if __name__ == '__main__':
    app = MainApp()
    app.run()

In this code, you import Image from the kivy.uix.image sub-package. The Image class takes a lot of different parameters, but the one that you want to use is source. This tells Kivy which image to load. Here, you pass a fully-qualified path to the image. The rest of the code is the same as what you saw in the previous example.

When you run this code, you’ll see something like the following:

Showing an image with Kivy

The text from the previous example has been replaced with an image.

Now you’ll learn how to add and arrange multiple widgets in your application.

Laying Out the UI

Each GUI framework that you use has its own method of arranging widgets. For example, in wxPython you’ll use sizers, while in Tkinter you use a layout or geometry manager. With Kivy, you’ll use Layouts. There are several different types of Layouts that you can use. Here are some of the most common ones:

  • BoxLayout
  • FloatLayout
  • GridLayout

You can search Kivy’s documentation for a full list of available Layouts. You can also look in kivy.uix for the actual source code.

Try out the BoxLayout with this code:

import kivy
import random

from kivy.app import App
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout

red = [1,0,0,1]
green = [0,1,0,1]
blue =  [0,0,1,1]
purple = [1,0,1,1]

class HBoxLayoutExample(App):
    def build(self):
        layout = BoxLayout(padding=10)
        colors = [red, green, blue, purple]

        for i in range(5):
            btn = Button(text="Button #%s" % (i+1),
                         background_color=random.choice(colors)
                         )

            layout.add_widget(btn)
        return layout

if __name__ == "__main__":
    app = HBoxLayoutExample()
    app.run()

Here, you import BoxLayout from kivy.uix.boxlayout and instantiate it. Then you create a list of colors, which are themselves lists of Red-Blue-Green (RGB) colors. Finally, you loop over a range of 5, creating a button btn for each iteration. To make things a bit more fun, you set the background_color of the button to a random color. You then add the button to your layout with layout.add_widget(btn).

When you run this code, you’ll see something like this:

Using a Horizontal BoxLayout in Kivy

There are 5 randomly-colored buttons, one for each iteration of your for loop.

When you create a layout, there are a few arguments you should know:

  • padding: You can specify the padding in pixels between the layout and its children in one of three ways:
    1. A four-argument list: [padding_left, padding_top, padding_right, padding_bottom]
    2. A two-argument list: [padding_horizontal, padding_vertical]
    3. A singular argument: padding=10
  • spacing: You can add space between the children widgets with this argument.
  • orientation: You can change the default orientation of the BoxLayout from horizontal to vertical.

Adding Events

Like most GUI toolkits, Kivy is mostly event-based. The framework responds to user keypresses, mouse events, and touch events. Kivy has the concept of a Clock that you can use to schedule function calls for some time in the future.

Kivy also has the concept of Properties, which works with the EventDispatcher. Properties help you do validation checking. They also let you fire events whenever a widget changes its size or position.

Let’s add a button event to your button code from earlier:

from kivy.app import App
from kivy.uix.button import Button

class MainApp(App):
    def build(self):
        button = Button(text='Hello from Kivy',
                        size_hint=(.5, .5),
                        pos_hint={'center_x': .5, 'center_y': .5})
        button.bind(on_press=self.on_press_button)

        return button

    def on_press_button(self, instance):
        print('You pressed the button!')

if __name__ == '__main__':
    app = MainApp()
    app.run()

In this code, you call button.bind() and link the on_press event to MainApp.on_press_button(). This method implicitly takes in the widget instance, which is the button object itself. Finally, a message will print to stdout whenever the user presses your button.

Using the KV Language

Kivy also provides a design language called KV that you can use with your Kivy applications. The KV language lets you separate your interface design from the application’s logic. This follows the separation of concerns principle and is part of the Model-View-Controller architectural pattern. You can update the previous example to use the KV language:

from kivy.app import App
from kivy.uix.button import Button

class ButtonApp(App):
    def build(self):
        return Button()

    def on_press_button(self):
        print('You pressed the button!')

if __name__ == '__main__':
    app = ButtonApp()
    app.run()

This code might look a bit odd at first glance, as it creates a Button without setting any of its attributes or binding it to any events. What’s happening here is that Kivy will automatically look for a file that has the same name as the class in lowercase, without the App part of the class name.

In this case, the class name is ButtonApp, so Kivy will look for a file named button.kv. If that file exists and is properly formatted, then Kivy will use it to load up the UI. Go ahead and create this file and add the following code:

 1<Button>:
 2    text: 'Press me'
 3    size_hint: (.5, .5)
 4    pos_hint: {'center_x': .5, 'center_y': .5}
 5    on_press: app.on_press_button()

Here’s what each line does:

  • Line 1 matches the Button call in your Python code. It tells Kivy to look into the instantiated object for a button definition.
  • Line 2 sets the button’s text.
  • Line 3 sets the width and height with size_hint.
  • Line 4 sets the button’s position with pos_hint.
  • Line 5 sets the on_press event handler. To tell Kivy where the event handler is, you use app.on_press_button(). Here, Kivy knows will look in the Application class for a method called .on_press_button().

You can set up all of your widgets and layouts inside one or more KV language files. The KV language also supports importing Python modules in KV, creating dynamic classes, and much more. For full details, check out Kivy’s guide to the KV Language.

Now you’re ready to create a real application!

Creating a Kivy Application

One of the best ways to learn a new skill is by creating something useful. With that in mind, you’ll use Kivy to build a calculator that supports the following operations:

  • Addition
  • Subtraction
  • Multiplication
  • Division

For this application, you’ll need a series of buttons in some kind of layout. You’ll also need a box along the top of your app to display the equations and their results. Here’s a sketch of your calculator:

Kivy Calculator Mockup

Now that you have a goal for the UI, you can go ahead and write the code:

 1from kivy.app import App
 2from kivy.uix.boxlayout import BoxLayout
 3from kivy.uix.button import Button
 4from kivy.uix.textinput import TextInput
 5
 6class MainApp(App):
 7    def build(self):
 8        self.operators = ["/", "*", "+", "-"]
 9        self.last_was_operator = None
10        self.last_button = None
11        main_layout = BoxLayout(orientation="vertical")
12        self.solution = TextInput(
13            multiline=False, readonly=True, halign="right", font_size=55
14        )
15        main_layout.add_widget(self.solution)
16        buttons = [
17            ["7", "8", "9", "/"],
18            ["4", "5", "6", "*"],
19            ["1", "2", "3", "-"],
20            [".", "0", "C", "+"],
21        ]
22        for row in buttons:
23            h_layout = BoxLayout()
24            for label in row:
25                button = Button(
26                    text=label,
27                    pos_hint={"center_x": 0.5, "center_y": 0.5},
28                )
29                button.bind(on_press=self.on_button_press)
30                h_layout.add_widget(button)
31            main_layout.add_widget(h_layout)
32
33        equals_button = Button(
34            text="=", pos_hint={"center_x": 0.5, "center_y": 0.5}
35        )
36        equals_button.bind(on_press=self.on_solution)
37        main_layout.add_widget(equals_button)
38
39        return main_layout

Here’s how your calculator code works:

  • In lines 8 to 10, you create a list of operators and a couple of handy values, last_was_operator and last_button, that you’ll use later on.
  • In lines 11 to 15, you create a top-level layout main_layout and add a read-only TextInput widget to it.
  • In lines 16 to 21, you create a nested list of lists containing most of your buttons for the calculator.
  • In line 22, you start a for loop over those buttons. For each nested list you’ll do the following:
    • In line 23, you create a BoxLayout with a horizontal orientation.
    • In line 24, you start another for loop over the items in the nested list.
    • In lines 25 to 39, you create the buttons for the row, bind them to an event handler, and add the buttons to the horizontal BoxLayout from line 23.
    • In line 31, you add this layout to main_layout.
  • In lines 33 to 37, you create the equals button (=), bind it to an event handler, and add it to main_layout.

The next step is to create the .on_button_press() event handler. Here’s what that code looks like:

41def on_button_press(self, instance):
42    current = self.solution.text
43    button_text = instance.text
44
45    if button_text == "C":
46        # Clear the solution widget
47        self.solution.text = ""
48    else:
49        if current and (
50            self.last_was_operator and button_text in self.operators):
51            # Don't add two operators right after each other
52            return
53        elif current == "" and button_text in self.operators:
54            # First character cannot be an operator
55            return
56        else:
57            new_text = current + button_text
58            self.solution.text = new_text
59    self.last_button = button_text
60    self.last_was_operator = self.last_button in self.operators

Most of the widgets in your application will call .on_button_press(). Here’s how it works:

  • Line 41 takes the instance argument so you can access which widget called the function.

  • Lines 42 and 43 extract and store the value of the solution and the button text.

  • Lines 45 to 47 check to see which button was pressed. If the user pressed C, then you’ll clear the solution. Otherwise, move on to the else statement.

  • Line 49 checks if the solution has any pre-existing value.

  • Line 50 to 52 check if the last button pressed was an operator button. If it was, then solution won’t be updated. This is to prevent the user from having two operators in a row. For example, 1 */ is not a valid statement.

  • Lines 53 to 55 check to see if the first character is an operator. If it is, then solution won’t be updated, since the first value can’t be an operator value.

  • Lines 56 to 58 drop to the else clause. If none of the previous conditions are met, then update solution.

  • Line 59 sets last_button to the label of the last button pressed.

  • Line 60 sets last_was_operator to True or False depending on whether or not it was an operator character.

The last bit of code to write is .on_solution():

62def on_solution(self, instance):
63    text = self.solution.text
64    if text:
65        solution = str(eval(self.solution.text))
66        self.solution.text = solution

Once again, you grab the current text from solution and use Python’s built-in eval() to execute it. If the user created a formula like 1+2, then eval() will run your code and return the result. Finally, you set the result as the new value for the solution widget.

When you run this code, your application will look like this on a desktop computer:

Kivy Calculator

To see the full code for this example, expand the code block below.

Here’s the full code for the calculator:

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.textinput import TextInput

class MainApp(App):
    def build(self):
        self.operators = ["/", "*", "+", "-"]
        self.last_was_operator = None
        self.last_button = None
        main_layout = BoxLayout(orientation="vertical")
        self.solution = TextInput(
            multiline=False, readonly=True, halign="right", font_size=55
        )
        main_layout.add_widget(self.solution)
        buttons = [
            ["7", "8", "9", "/"],
            ["4", "5", "6", "*"],
            ["1", "2", "3", "-"],
            [".", "0", "C", "+"],
        ]
        for row in buttons:
            h_layout = BoxLayout()
            for label in row:
                button = Button(
                    text=label,
                    pos_hint={"center_x": 0.5, "center_y": 0.5},
                )
                button.bind(on_press=self.on_button_press)
                h_layout.add_widget(button)
            main_layout.add_widget(h_layout)

        equals_button = Button(
            text="=", pos_hint={"center_x": 0.5, "center_y": 0.5}
        )
        equals_button.bind(on_press=self.on_solution)
        main_layout.add_widget(equals_button)

        return main_layout

    def on_button_press(self, instance):
        current = self.solution.text
        button_text = instance.text

        if button_text == "C":
            # Clear the solution widget
            self.solution.text = ""
        else:
            if current and (
                self.last_was_operator and button_text in self.operators):
                # Don't add two operators right after each other
                return
            elif current == "" and button_text in self.operators:
                # First character cannot be an operator
                return
            else:
                new_text = current + button_text
                self.solution.text = new_text
        self.last_button = button_text
        self.last_was_operator = self.last_button in self.operators

    def on_solution(self, instance):
        text = self.solution.text
        if text:
            solution = str(eval(self.solution.text))
            self.solution.text = solution


if __name__ == "__main__":
    app = MainApp()
    app.run()

It’s time to deploy your application!

Packaging Your App for Android

Now that you’ve finished the code for your application, you can share it with others. One great way to do that is to turn your code into an application that can run on your Android phone. To accomplish this, first you’ll need to install a package called buildozer with pip:

Then, create a new folder and navigate to it in your terminal. Once you’re there, you’ll need to run the following command:

This will create a buildozer.spec file that you’ll use to configure your build. For this example, you can edit the first few lines of the spec file as follows:

[app]

# (str) Title of your application
title = KvCalc

# (str) Package name
package.name = kvcalc

# (str) Package domain (needed for android/ios packaging)
package.domain = org.kvcalc

Feel free to browse the rest of the file to see what else you can change.

At this point, you’re almost ready to build your application, but first, you’ll want to install the dependencies for buildozer. Once those are installed, copy your calculator application into your new folder and rename it to main.py. This is required by buildozer. If you don’t have the file named correctly, then the build will fail.

Now you can run the following command:

$ buildozer -v android debug

The build step takes a long time! On my machine, it took 15 to 20 minutes. Depending on your hardware, it may take even longer, so feel free to grab a cup of coffee or go for a run while you wait. Buildozer will download whatever Android SDK pieces it needs during the build process. If everything goes according to plan, then you’ll have a file named something like kvcalc-0.1-debug.apk in your bin folder.

The next step is to connect your Android phone to your computer and copy the apk file to it. Then you can open the file browser on your phone and click on the apk file. Android should ask you if you’d like to install the application. You may see a warning since the app was downloaded from outside Google Play, but you should still be able to install it.

Here’s the calculator running on my Samsung S9:

Kivy Calculator Running on Android Phone

The buildozer tool has several other commands you can use. Check out the documentation to see what else you can do.

You can also package the app using python-for-android if you need more fine-grained control. You won’t cover this here, but if you’re interested, check out the project’s quickstart.

Packaging Your App for iOS

The instructions for building an application for iOS are a bit more complex than Android. For the most up-to-date information, you should always use Kivy’s official packaging documentation. You’ll need to run the following commands before you can package your application for iOS on your Mac:

$ brew install autoconf automake libtool pkg-config
$ brew link libtool
$ sudo easy_install pip
$ sudo pip install Cython==0.29.10

Once those are all installed successfully, you’ll need to compile the distribution using the following commands:

$ git clone git://github.com/kivy/kivy-ios
$ cd kivy-ios
$ ./toolchain.py build python3 kivy

If you get an error that says iphonesimulator can’t be found, then see this StackOverflow answer for ways to solve that issue. Then try running the above commands again.

If you run into SSL errors, then you probably don’t have Python’s OpenSSL setup. This command should fix that:

$ cd /Applications/Python 3.7/
$ ./Install Certificates.command

Now go back and try running the toolchain command again.

Once you’ve run all the previous commands successfully, you can create your Xcode project using the toolchain script. Your main application’s entry point must be named main.py before you create the Xcode project. Here is the command you’ll run:

./toolchain.py create <title> <app_directory>

There should be a directory named title with your Xcode project in it. Now you can open that project in Xcode and work on it from there. Note that if you want to submit your application to the App Store, then you’ll have to create a developer account at developer.apple.com and pay their yearly fee.

Packaging Your App for Windows

You can package your Kivy application for Windows using PyInstaller. If you’ve never used it before, then check out Using PyInstaller to Easily Distribute Python Applications.

You can install PyInstaller using pip:

$ pip install pyinstaller

The following command will package your application:

This command will create a Windows executable and several other files. The -w argument tells PyInstaller that this is a windowed application, rather than a command-line application. If you’d rather have PyInstaller create a single executable file, then you can pass in the --onefile argument in addition to -w.

Packaging Your App for macOS

You can use PyInstaller to create a Mac executable just like you did for Windows. The only requirement is that you run this command on a Mac:

$ pyinstaller main.py -w --onefile

This will create a single file executable in the dist folder. The executable will be the same name as the Python file that you passed to PyInstaller. If you’d like to reduce the file size of the executable, or you’re using GStreamer in your application, then check out Kivy’s packaging page for macOS for more information.

Conclusion

Kivy is a really interesting GUI framework that you can use to create desktop user interfaces and mobile applications on both iOS and Android. Kivy applications will not look like the native apps on any platform. This can be an advantage if you want your application to look and feel different from the competition!

In this tutorial, you learned the basics of Kivy including how to add widgets, hook up events, lay out multiple widgets, and use the KV language. Then you created your first Kivy application and learned how to distribute it on other platforms, including mobile!

There are many widgets and concepts about Kivy that you didn’t cover here, so be sure to check out Kivy’s website for tutorials, sample applications, and much more.

Further Reading

To learn more about Kivy, check out these resources:

  • Kivy Programming Guide
  • Kivy Packaging Documentation
  • Build Desktop GUI Apps Using Python

To see how you might create a desktop application with another Python GUI framework, check out How to Build a Python GUI Application With wxPython.

Watch Now This tutorial has a related video course created by the Real Python team. Watch it together with the written tutorial to deepen your understanding: Build Cross-Platform GUI Apps With Kivy

Prerequisites: Introduction to Kivy, Hello World in Kivy

Kivymd is graphical user interface library in python based on kivy that allows you to develop multi-platform applications on Windows, MacOS, Android, iOS, Linux, and Raspberry Pi. The best thing about kivy is, it performs better than HTML5 cross-platform alternatives. Kivymd requires fewer lines of code compare to kivy. Kivymd is written in python using the kivy library.

In order to start KivyMD, you must first install the Kivy framework on your computer. Once you have installed Kivy, you can install KivyMD.

Installation

pip install kivymd

If you want to install the development version from the master branch, you should specify a link to zip archive:

pip install https://github.com/kivymd/KivyMD/archive/master.zip

Buttons

  • MDFloatingActionButton: 

To change MDFloatingActionButton background, use the md_bg_color parameter:

MDFloatingActionButton:
   icon: "android"
   md_bg_color: app.theme_cls.primary_color

The length of the shadow is controlled by the elevation_normal parameter:

MDFloatingActionButton:
    icon: "android"
    elevation_normal: 12
  • MDFlatButton:

To change the text colour of class MDFlatButton use the text_color parameter:

MDFlatButton:
   text: "MDFLATBUTTON"
   text_color: 0, 0, 1, 1

Step-by-step Approach:

There are three steps of creating an application with kivymd-

  • Inherit Kivymd’s App class which represents the window for our widgets
  • Create build() method, which will show the content of the widgets.
  • And at last calling of run() method.

Code blocks:

  • text: the text you want to show on screen.
  • halign: alignment of that text.
  • pos_hint: position from the text from the left and top (center_x =0.5 and center_y=0.5 represents the centre. of the screen).
  • icon: The type of icon you have to give for your button.

Below is the example of how we can create a simple application using kivy:

Python3

from kivymd.app import MDApp

from kivymd.uix.button import MDFloatingActionButton, MDFlatButton

from kivymd.uix.screen import Screen

from kivymd.icon_definitions import md_icons

class DemoApp(MDApp):

    def build(self):

        screen = Screen()

        btn1 = MDFlatButton(text='Hello GFG', pos_hint={'center_x': 0.5,

                                                        'center_y': 0.8})

        btn = MDFloatingActionButton(icon="android",

                                     pos_hint={'center_x': 0.5,

                                               'center_y': 0.5},

                                     )

        screen.add_widget(btn1)

        screen.add_widget(btn)

        return screen

DemoApp().run()

Output:

Prerequisites: Introduction to Kivy, Hello World in Kivy

Kivymd is graphical user interface library in python based on kivy that allows you to develop multi-platform applications on Windows, MacOS, Android, iOS, Linux, and Raspberry Pi. The best thing about kivy is, it performs better than HTML5 cross-platform alternatives. Kivymd requires fewer lines of code compare to kivy. Kivymd is written in python using the kivy library.

In order to start KivyMD, you must first install the Kivy framework on your computer. Once you have installed Kivy, you can install KivyMD.

Installation

pip install kivymd

If you want to install the development version from the master branch, you should specify a link to zip archive:

pip install https://github.com/kivymd/KivyMD/archive/master.zip

Buttons

  • MDFloatingActionButton: 

To change MDFloatingActionButton background, use the md_bg_color parameter:

MDFloatingActionButton:
   icon: "android"
   md_bg_color: app.theme_cls.primary_color

The length of the shadow is controlled by the elevation_normal parameter:

MDFloatingActionButton:
    icon: "android"
    elevation_normal: 12
  • MDFlatButton:

To change the text colour of class MDFlatButton use the text_color parameter:

MDFlatButton:
   text: "MDFLATBUTTON"
   text_color: 0, 0, 1, 1

Step-by-step Approach:

There are three steps of creating an application with kivymd-

  • Inherit Kivymd’s App class which represents the window for our widgets
  • Create build() method, which will show the content of the widgets.
  • And at last calling of run() method.

Code blocks:

  • text: the text you want to show on screen.
  • halign: alignment of that text.
  • pos_hint: position from the text from the left and top (center_x =0.5 and center_y=0.5 represents the centre. of the screen).
  • icon: The type of icon you have to give for your button.

Below is the example of how we can create a simple application using kivy:

Python3

from kivymd.app import MDApp

from kivymd.uix.button import MDFloatingActionButton, MDFlatButton

from kivymd.uix.screen import Screen

from kivymd.icon_definitions import md_icons

class DemoApp(MDApp):

    def build(self):

        screen = Screen()

        btn1 = MDFlatButton(text='Hello GFG', pos_hint={'center_x': 0.5,

                                                        'center_y': 0.8})

        btn = MDFloatingActionButton(icon="android",

                                     pos_hint={'center_x': 0.5,

                                               'center_y': 0.5},

                                     )

        screen.add_widget(btn1)

        screen.add_widget(btn)

        return screen

DemoApp().run()

Output:

Понравилась статья? Поделить с друзьями:
  • Как написать игру на котлин
  • Как написать игру на джава скрипт
  • Как написать игру на денди
  • Как написать игру на ассемблере для zx spectrum купить
  • Как написать игру на айпад