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

Некоторое время тому назад я решил попробовать написать что-то на Python под Android. Такой странный для многих выбор обусловлен тем, что я люблю Python и люблю Android, а ещё люблю делать необычное (ну хорошо, не самое обычное). В качестве фреймворка был выбран Kivy — фактически, безальтернативный вариант, но он мне очень понравился. Однако, по нему не так уж много информации (нет, документация отличная, но иногда её недостаточно), особенно на русском языке, а некоторые вещи хоть и можно реализовать, но их то ли никто раньше не делал, то ли не счёл нужным поделиться информацией. Ну а я счёл :) И этот пост тому результатом.

Под катом я расскажу обо всех этапах разработки, о том, как развивалась простая идея и как для этого приходилось искать новые возможности, о возникших подводных камнях и багах, о неочевидных решениях и устаревшей документации :) Цель — описать в одном тексте основные пункты, чтобы человеку, решившему написать что-то немного сложнее игры Pong из официального туториала, не приходилось перерывать официальный форум поддержки и StackOverflow и тратить часы на то, что делается за пару минут, если знаешь, как.

0. Если вы впервые слышите о Kivy…

… то всё зависит от того, любите ли вы Python и Android, и интересно ли вам в этом разобраться. Если нет — проще забить :) А если да, то начать нужно с официальной документации, гайдов, и уже упомянутого официального туториала по игре Pong — это даст базовое представление о фреймворке и его возможностях. Я же не буду останавливаться на столь тривиальных вещах (тем более, для понимания базовых принципов туториал отлично подходит) и сразу пойду дальше. Будем считать, что это было вступление :)

1. Немного о моей игре

Для начала нужна была идея. Мне хотелось что-то достаточно простое, чтобы оценить возможности фреймворка, но и достаточно интересное и оригинальное, чтобы не программировать ради программирования (это здорово, но когда это не единственная цель — это ещё лучше). Я неплохо проектирую интерфейсы, но не умею рисовать, поэтому игра должна была быть простая графически, или вообще текстовая. И тут так уж сложилось, что у меня есть заброшенный сайт с цитатами, с которого я когда-то начинал свой путь в web-разработке (я о нём даже писал на Хабре много лет назад). Поэтому идея возникла такая: игра-викторина «Угадай цитату». В русскоязычном Google Play ничего подобного не было, а в англоязычном была пара поделок низкого качества с сотней скачиваний.

Почти сразу же стало понятно, что просто так отгадывать цитату за цитатой — скучно. Так появились первые «фишки», которые, в итоге, и определили итоговую игру. В первую очередь это были тематические пакеты (то есть пакеты цитат, объединённые одной темой или автором) и баллы (которые начисляются за отгадывание цитат и прохождение пакетов, и тратятся на подсказки и разблокировку новых тем), а также статистика, достижения и избранное.

Первые проблемы начались с первого же экрана…

2. Kivy тормоз или я что-то делаю не так?

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

Так как цитаты и темы хранятся в БД, то, само собой, кнопки с пакетами генерируются динамически. И вот тут-то я обнаружил, что происходит это очень медленно: примерно полсекунды на список из 20 кнопок. Возможно, это и не очень много при загрузке приложения, но при переходе на главный экран из других внутренних экранов приложения — непозволительно много. Здесь стоит отметить, что кнопка к тому моменту уже представляла собой, на самом деле, набор из нескольких элементов, визуально составляющих одну кнопку:

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

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

Исходный код тестового приложения

main.py:

from kivy.app import App
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.uix.boxlayout import BoxLayout
from kivy.properties import ObjectProperty, StringProperty
from kivy.clock import Clock

from time import time

class ListScreen(Screen):

    items_box = ObjectProperty(None)

    def on_enter(self):
        start = time()
        for i in range(0,50):
            self.items_box.add_widget(ListItem('Item '+str(i)))
        self.items_box.bind(minimum_height=self.items_box.setter('height'))
        print time()-start

    def on_leave(self):
        self.items_box.clear_widgets()

class ListItem(BoxLayout):

    title = StringProperty('')

    def __init__(self, title, **kwargs):
        super(ListItem, self).__init__(**kwargs)
        self.title = title

class ListApp(App):

    sm = ScreenManager()
    screens = {}

    def build(self):
        self.__create_screens()
        ListApp.sm.add_widget(ListApp.screens['list1'])
        Clock.schedule_interval(self._switch, 1)
        return ListApp.sm

    def _switch(self, *args):
        ListApp.sm.switch_to(ListApp.screens['list1' if ListApp.sm.current != 'list1' else 'list2'])

    def __create_screens(self):
        ListApp.screens['list1'] = ListScreen(name='list1')
        ListApp.screens['list2'] = ListScreen(name='list2')

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

list.kv:

<ListScreen>:
    items_box: items_box
    BoxLayout:
        orientation: "vertical"
        AnchorLayout:
            size_hint_y: 0.1
            padding: self.width*0.1, self.height*0.05
            Label:
                font_size: root.height*0.05
                text: "Some list"
        ScrollView:
            size_hint_y: 0.9
            size: self.size
            BoxLayout:
                id: items_box
                orientation: "vertical"
                padding: self.width*0.1, 0
                size_hint_y: None

<ListItem>:
    orientation: "horizontal"
    size_hint_y: None
    height: app.sm.height*0.1
    Label:
        font_size: app.sm.height*0.025
        text: root.title
        size_hint_x: 0.9
        text_size: self.size
        valign: "middle"
    CheckBox
        size_hint_x: 0.1

Запустил на своём стареньком Moto G (gen3) и получил:

11-28 11:44:09.525  1848  2044 I python  : 0.5793800354
11-28 11:44:10.853  1848  2044 I python  : 0.453143119812
11-28 11:44:12.544  1848  2044 I python  : 0.633069992065
11-28 11:44:13.697  1848  2044 I python  : 0.369570970535
11-28 11:44:14.988  1848  2044 I python  : 0.594089031219

И далее в том же духе. Поиск по этому вопросу ничего не дал, поэтому я обратился к разработчикам. И получил ответ: «Создание виджетов относительно медленное, особенно в зависимости от того, что они содержат. Для создания больших списков лучше использовать RecycleView». Здесь хочу пояснить, почему я вообще описываю этот момент, ведь описание RecycleView есть в документации. Да, действительно, есть, но мало кто способен изучить и запомнить всю документацию перед тем, как начнёт разработку, и найти нужный инструмент бывает непросто, особенно если он нигде не описан в контексте решения конкретной проблемы. Теперь же он описан :)

Исходный код тестового приложения с RecycleView

main.py:

from kivy.app import App
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.properties import ObjectProperty
from kivy.clock import Clock

from time import time

class ListScreen(Screen):

    recycle_view = ObjectProperty(None)
    items_box = ObjectProperty(None)

    def on_enter(self):
        start = time()
        for i in range(0,50):
            self.recycle_view.data.append({'title': 'item'+str(i)})
        print time()-start

    def on_leave(self):
        self.recycle_view.data = []

class ListApp(App):

    sm = ScreenManager()
    screens = {}

    def build(self):
        self.__create_screens()
        ListApp.sm.add_widget(ListApp.screens['list1'])
        Clock.schedule_interval(self._switch, 1)
        return ListApp.sm

    def _switch(self, *args):
        ListApp.sm.switch_to(ListApp.screens['list1' if ListApp.sm.current != 'list1' else 'list2'])

    def __create_screens(self):
        ListApp.screens['list1'] = ListScreen(name='list1')
        ListApp.screens['list2'] = ListScreen(name='list2')

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

list.kv:

<ListScreen>:
    recycle_view: recycle_view
    items_box: items_box
    BoxLayout:
        orientation: "vertical"
        AnchorLayout:
            size_hint_y: 0.1
            padding: self.width*0.1, self.height*0.05
            Label:
                font_size: root.height*0.05
                text: "Some list"
        RecycleView:
            id: recycle_view
            size_hint: 1, 0.9
            viewclass: "ListItem"
            RecycleBoxLayout:
                id: items_box
                orientation: "vertical"
                padding: self.width*0.1, 0
                default_size_hint: 1, None
                size_hint: 1, None
                height: self.minimum_height

<ListItem@BoxLayout>:
    orientation: "horizontal"
    size_hint: 1, None
    height: app.sm.height*0.1
    title: ''
    Label:
        font_size: app.sm.height*0.025
        text: root.title
        size_hint_x: 0.9
        text_size: self.size
        valign: "middle"
    CheckBox
        size_hint_x: 0.1

11-29 13:11:58.196 13121 13203 I python  : 0.00388479232788
11-29 13:11:59.192 13121 13203 I python  : 0.00648307800293
11-29 13:12:00.189 13121 13203 I python  : 0.00288391113281
11-29 13:12:01.189 13121 13203 I python  : 0.00324606895447
11-29 13:12:03.188 13121 13203 I python  : 0.00265002250671

Более чем в 100 раз быстрее. Впечатляет, не правда ли?

В завершение следует упомянуть, что RecycleView — не панацея. Он не подходит, если размер элемента зависит от содержимого (например, Label, размер которого меняется в зависимости от количества текста).

3. Сервисы. Автозапуск и перезапуск

Следующая проблема, с которой я столкнулся, не поддавалась решению так долго, что я уже малодушно подумывал счесть данный фреймворк непригодным и забить :) Проблема была с сервисами (в Android так называется процессы, выполняющиеся в фоновом режиме). Создать сервис не так уж и сложно — немного сбивает с толку устаревшая документация, но и только. Однако, в большинстве случаев, много ли толку от сервиса, который, во-первых, не запускается автоматически при загрузке телефона, а во-вторых, не перезапускается, если «выбросить» приложение свайпом из диспетчера задач? По-моему, нет.

На тот момент по этой теме была всего лишь одна статья в официальной wiki, но она, хоть и называлась «Starting Kivy service on bootup», на самом деле всего лишь рассказывала, как при загрузке телефона запустить приложение, но не его сервис (да, такое тоже бывает полезно, но значительно реже, как по мне). Ту статью я, в итоге, переписал, а здесь расскажу подробности.

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

from time import sleep

if __name__ == '__main__':
    while True:
        print "myapp service"
        sleep(5)

Из приложения мы запускаем его методом основного класса при помощи PyJnius:


from jnius import autoclass
# ... #
class GuessTheQuoteApp(App):
# ... #
    def __start_service(self):
        service = autoclass('com.madhattersoft.guessthequote.ServiceGuessthequoteservice')
        mActivity = autoclass('org.kivy.android.PythonActivity').mActivity
        service.start(mActivity, "")

Если APK собран правильно, при запуске приложения сервис будет стартовать, но этого недостаточно.

Для начала, попробуем сделать так, чтобы он перезапускался при остановке приложения (например, при снятии его из диспетчера задач). Конечно, можно было бы использовать startForeground, но это уже не совсем фоновое выполнение задачи :) Для него потребуется, как минимум, уведомление — это не всегда подходит. В данном случае идеально подходит флаг START_STICKY, но мы же пишем на Python, что делает задачу не столь тривиальной — по крайней мере, при помощи PyJnius она уже не решается.

Честно говоря, она вообще решается достаточно криво, поскольку я пока не готов становиться одним из разработчиков Python4Android, благодаря которому всё это счастье вообще работает. А изменения нужно вносить именно в код Python4Android. А конкретно, нам нужен файл .buildozer/android/platform/build/dists/guessthequote/src/org/kivy/android/PythonService.java в котором в функции startType() мы меняем флаг START_NOT_STICKY на START_STICKY:

public int startType() {
    return START_STICKY;
}

Ура, сервис рестартится. Всё? Конечно, нет :) Потому что он тут же валится с ошибкой:

E AndroidRuntime: Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'android.os.Bundle android.content.Intent.getExtras()' on a null object reference

Проблема в функции onStartCommand(Intent intent, int flags, int startId), поскольку после перезапуска intent у нас null. Что ж, перепишем и её:

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
    if (pythonThread != null) {
        Log.v("python service", "service exists, do not start again");
        return START_NOT_STICKY;
    }
    if (intent != null) { 
        startIntent = intent;
        Bundle extras = intent.getExtras();
        androidPrivate = extras.getString("androidPrivate");
        androidArgument = extras.getString("androidArgument");
        serviceEntrypoint = extras.getString("serviceEntrypoint");
        pythonName = extras.getString("pythonName");
        pythonHome = extras.getString("pythonHome");
        pythonPath = extras.getString("pythonPath");
        pythonServiceArgument = extras.getString("pythonServiceArgument");

        pythonThread = new Thread(this);
        pythonThread.start();

        if (canDisplayNotification()) {
            doStartForeground(extras);
        }
    } else {
        pythonThread = new Thread(this);
        pythonThread.start();
    }

    return startType();
}

Увы:

F DEBUG   : Abort message: 'art/runtime/java_vm_ext.cc:410] JNI DETECTED ERROR IN APPLICATION: GetStringUTFChars received NULL jstring'

Проблема в том, что функция nativeStart() не получает нужных Extras. К сожалению, два из них мне пришлось захардкодить. В итоге выглядит это так:

@Override
public void run(){
    String package_root = getFilesDir().getAbsolutePath();
    String app_root =  package_root + "/app";
    File app_root_file = new File(app_root);
    PythonUtil.loadLibraries(app_root_file);
    this.mService = this;

    if (androidPrivate == null) {
        androidPrivate = package_root;
    }
    if (androidArgument == null) {
        androidArgument = app_root;
    }
    if (serviceEntrypoint == null) {
        serviceEntrypoint = "./service/main.py"; // hardcoded
    }
    if (pythonName == null) {
        pythonName = "guessthequoteservice"; // hardcoded
    }
    if (pythonHome == null) {
        pythonHome = app_root;
    }
    if (pythonPath == null) {
        pythonPath = package_root;
    }
    if (pythonServiceArgument == null) {
        pythonServiceArgument = app_root+":"+app_root+"/lib";
    }

    nativeStart(
        androidPrivate, androidArgument,
        serviceEntrypoint, pythonName,
        pythonHome, pythonPath,
        pythonServiceArgument);
    stopSelf();
}

Теперь всё.

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

Для начала понадобится разрешение RECEIVE_BOOT_COMPLETED — это просто. А затем — BroadcastReceiver, его придётся добавить в AndroidManifest вручную, но это тоже не проблема. Проблема в том, что в нём писать :)

Решение для запуска приложения (не сервиса) выглядит так:

package com.madhattersoft.guessthequote;

import android.content.BroadcastReceiver;
import android.content.Intent;
import android.content.Context;
import org.kivy.android.PythonActivity;

public class MyBroadcastReceiver extends BroadcastReceiver {
    public void onReceive(Context context, Intent intent) {
        Intent ix = new Intent(context, PythonActivity.class);
        ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(ix);
    }
}

Сначала я попытался просто переписать его для сервиса:

package com.madhattersoft.guessthequote;

import android.content.BroadcastReceiver;
import android.content.Intent;
import android.content.Context;
import com.madhattersoft.guessthequote.ServiceGuessthequoteservice;

public class MyBroadcastReceiver extends BroadcastReceiver {
    public void onReceive(Context context, Intent intent) {
        Intent ix = new Intent(context, ServiceGuessthequoteservice.class);
        ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startService(ix);
    }
}

Ага, разогнался :)

E AndroidRuntime: java.lang.RuntimeException: Unable to start service com.madhattersoft.guessthequote.ServiceGuessthequoteservice@8c96929 with Intent { cmp=com.madhattersoft.guessthequote/.ServiceGuessthequoteservice }: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String android.os.Bundle.getString(java.lang.String)' on a null object reference

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


package import com.madhattersoft.guessthequote;

import android.content.BroadcastReceiver;
import android.content.Intent;
import android.content.Context;
import com.madhattersoft.guessthequote.ServiceGuessthequoteservice;

public class MyBroadcastReceiver extends BroadcastReceiver {
    public void onReceive(Context context, Intent intent) {
        String package_root = context.getFilesDir().getAbsolutePath();
        String app_root =  package_root + "/app";
        Intent ix = new Intent(context, ServiceGuessthequoteservice.class);
        ix.putExtra("androidPrivate", package_root);
        ix.putExtra("androidArgument", app_root);
        ix.putExtra("serviceEntrypoint", "./service/main.py");
        ix.putExtra("pythonName", "guessthequoteservice");
        ix.putExtra("pythonHome", app_root);
        ix.putExtra("pythonPath", package_root);
        ix.putExtra("pythonServiceArgument", app_root+":"+app_root+"/lib");
        ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startService(ix);
    }
}

Фух :)

Локализация и мультиязычность

В целом, для локализации можно использовать gettext, или же поступить ещё проще — создать папку lang, в ней по файлу на каждый язык (например, en.py и ru.py), определить там все слова и фразы в виде переменных/констант, и далее подключить нужный модуль. Примерно так:

if autoclass('java.util.Locale').getDefault().getLanguage() in ('ru', 'uk', 'be'):
    import lang.ru as lang
else:
    import lang.en as lang
GuessTheQuote.lang = lang

Статическая переменная использована для того, чтобы языковые константы было удобно использовать в kv-файле:

app.lang.some_phrase

Это, в общем-то, довольно тривиально, а основное, о чём я хотел рассказать в аспекте локализации — как задать константы в res/values/strings.xml и отдельных локализациях. Зачем это нужно? Как минимум, чтобы задать название приложения на разных языках, а также чтобы прописать такие константы, как app_id для сервисов Google Play и facebook_app_id для сервисов Facebook.

По-умолчанию P4A генерирует strings.xml следующего содержания:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">Guess The Quote</string>
    <string name="private_version">1517538478.81</string>
    <string name="presplash_color">#2EBCB2</string>
    <string name="urlScheme">kivy</string>
</resources>

При этом название приложения и цвет фона экрана загрузки можно задать в buildozer.spec. На первый взгляд, этого достаточно, но это только в том случае, если приложение одноязычное, и дополнительные строковые константы не нужны, а это как-то минималистично :) Конечно, никто не запрещает вручную прописать всё необходимое, но при следующей сборке оно затрётся. Также можно вручную создать папки с локализациями, например, values-ru, но они при новых сборках они не будут обновляться. Поэтому лучше ещё раз подправить P4A, а именно, файл .buildozer/android/platform/build/dists/guessthequote/build.py следующим образом:

# оригинальный код, в текущей версии P4A начинается на строке 370

render(
        'strings.tmpl.xml',
        'res/values/strings.xml',
        args=args,
        url_scheme=url_scheme,
        )

# заменяем на усовершенствованный :)

local_args = {'be': argparse.Namespace(**vars(args)), 'ru': argparse.Namespace(**vars(args)), 'uk': argparse.Namespace(**vars(args))}
for key in local_args:
    local_args[key].name = u'Угадай цитату!' # ну захардкодил, да, ну не готов я пока сделать свой форк P4A и buildozer, чтобы сделать это через передачу параметра

for i in os.listdir('res'):
    if i[:6] == 'values':
        render(
            'strings.tmpl.xml',
            'res/'+i+'/strings.xml',
            args=(args if i == 'values' else local_args[i[7:10]]),
            url_scheme=url_scheme,
        )

# и ещё один фрагмент, в текущей версии P4A начиная со строки 388

with open(join(dirname(__file__), 'res',
                'values', 'strings.xml')) as fileh:
    lines = fileh.read()

with open(join(dirname(__file__), 'res',
                'values', 'strings.xml'), 'w') as fileh:
    fileh.write(re.sub(r'"private_version">[0-9.]*<',
                        '"private_version">{}<'.format(
                            str(time.time())), lines))

# тоже заменяем на аналогичный в цикле

for i in os.listdir('res'):
    if i[:6] == 'values':
        with open(join(dirname(__file__), 'res',
                        i, 'strings.xml')) as fileh:
            lines = fileh.read()

        with open(join(dirname(__file__), 'res',
                        i, 'strings.xml'), 'w') as fileh:
            fileh.write(re.sub(r'"private_version">[0-9.]*<',
                                '"private_version">{}<'.format(
                                    str(time.time())), lines))

Ну а все необходимые вам строковые константы нужно прописать в файле .buildozer/android/platform/build/dists/guessthequote/templates/strings.tmpl.xml

Продолжение следует

Если статья покажется сообществу интересной, во второй части я опишу самые интересные вещи: покупки в приложении, интеграцию сервисов Google Play Games и Facebook SDK, и подготовку release version с последующей публикацией в Google Play, а также подготовлю проект на Github с модулями для реализации описанных задач. Если вам интересны ещё какие-то подробности — напишите в комментариях, постараюсь по возможности осветить.

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

Как вы относитесь к написанию приложений под Android на Python?


57.52%
положительно: разнообразие возможностей полезно
153


16.17%
положительно, но в текущем виде это реализовано криво
43


19.92%
нейтрально: всё имеет право на существование
53


2.63%
отрицательно, потому что в текущем виде это реализовано криво
7


3.76%
отрицательно: Android заточен под Java, вот и нечего тут
10

Проголосовали 266 пользователей.

Воздержались 45 пользователей.

В этой статье мы напишем классическую «Змейку» на 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/

Introduction

Kivy can build applications for desktop and mobile including Android and iOS.
The same code base can be used for both desktop and mobile, depending
on what hardware devices you are trying to access.

This guide will walk through the basics of installing Kivy,
building simple applications, building and packaging them for
desktop and Android.

For an example of a complete project, check out my
Bitcoin Price Checker example
with the Live Stream on YouTube.

Alternatives for desktop application programming with Python are
PyQt5, Tkinter, and wxPython.
Those packages have a more traditional desktop UI but lack the Android and iOS
build capabilities the same way Kivy has. Qt technically supports Android
but it is not easy to build with PyQt5.

Important concepts

There are a few concepts to understand in mind when building a Kivy application:

  • Layouts — Screen layouts are defined in a .kv template file using a special Kivy language
  • Widgets — The UI elements that get packed in to layout elements. Buttons, for example.
  • A layout is just another type of widget

To create a Kivy application in Python, there are a few easy steps:

  1. Create a class that inherits the Kivy main app class kivy.app.App
  2. Define a build() method in the class that returns a root widget; anything from a single Button (kivy.uix.button.Button) to a complex GridLayout (kivy.uix.gridlayout.GridLayout)).
  3. Create an instance of your class and call the .run() method.

That is all there is to a Kivy app at a high level.

Installation

You will need to install the Python package kivy at a minimum.
There is also an optional package called plyer that contains a number
of cross-platform APIs for accessing device features like notifications
and GPS. Another package used for building Android and iOS is named buildozer.

You have a few options for installing Kivy:

  • From source: https://github.com/kivy/kivy
  • Download it from the Kivy website
  • Use pip to install the packages

To use pip to install Kivy:

# The core package
python -m pip install kivy

# For additional cross-platform APIs like notifications, GPS, and vibrator
python -m pip install plyer
# For Android/iOS building (Linux only)
python -m pip install buildozer

# You probablyalso need cython for the Android builds
python -m pip install cython
# Or use your system package like this for Fedora:
sudo dnf install python3-Cython

On Fedora, I needed to install ncurses-compat-libs to resolve the error:
$HOME/.buildozer/android/platform/android-ndk-r17c/toolchains/llvm/prebuilt/linux-x86_64/bin/clang: error while loading shared libraries: libtinfo.so.5: cannot open shared object file: No such file or directory.

# For Fedora might be needed
dnf install ncurses-compat-libs

In Ubuntu, others report similar: https://github.com/kivy/buildozer/issues/841

# Ubuntu fixes
sudo apt-get install libncurses5-dev libncursesw5-dev
sudo apt-get install libtinfo5

In Windows, you may need to install other dependencies.
Official instrucations: Installation in Windows

# Other pip packages to install
python -m pip install pypiwin32
python -m pip install kivy_deps.glew
# One of the backends:
python -m pip install kivy_deps.sdl2
python -m pip install kivy.deps.angle
# If needed for video
python -m pip install kivy.deps.gstreamer

Check installed version

Once Kivy is installed, you can verify it is installed
properly and check what version you have installed.

To check what version of Kivy you have, you run pip list to check
the Kivy package version, or just open your Python interpreter and
import kivy and inspect kivy.__version__.

>>> import kivy
[INFO   ] [Kivy        ] v1.11.1
[INFO   ] [Kivy        ] Installed at "/usr/local/lib64/python3.7/site-packages/kivy/__init__.py"
[INFO   ] [Python      ] v3.7.4 (default, Jul  9 2019, 16:32:37) 
[GCC 9.1.1 20190503 (Red Hat 9.1.1-1)]
[INFO   ] [Python      ] Interpreter at "/usr/bin/python3"
>>> kivy.__version__
'1.11.1'

Start a new project

A Kivy application can be as simple as a single .py file.
This is an example of a minimal application that loads a window with one widget, a button:

# main.py
# Modified from https://kivy.org/doc/stable/guide/basic.html
import kivy
kivy.require('1.11.1')  # Set to your Kivy version
from kivy.app import App
from kivy.uix.button import Button


class MyApp(App):
    def build(self):
        return Button(text='This is a button.')


MyApp().run()

Run the application by executing the Python file:

python main.py

Use the Kv language

In the first example we just returned a single root widget, the button.
You can use methods like .add_widget() to pack in nested widgets,
but it can be tedious to do all the layout building programmatically.

This is why Kivy created the Kv language for defining widget trees.
It is similar to YAML but defines the heirarchy of widgets.
It is easier to work with visually and creates separation between the
view layer and the controller code.

While the Kv language is optional, and you don’t have to use it,
it is the preferred way to build layouts and worth
getting familiar with.
Read more about the KV language in the official Kv language guide

You can provide KV templates as separate .kv files or as hard-coded
strings inside your Python code.

When building a user interface, you first need to understand the layouts.
The layouts will allow you to organize the screen.
For example, do you want to stack up
a list of buttons vertically, or do you want to have a main
frame with a top and bottom bar, or do you want a 3×3 grid of buttons?

Read more about layouts.
These are some of the common layout types:

  • kivy.uix.boxlayout.BoxLayout
  • kivy.uix.gridlayout.GridLayout
  • kivy.uix.stacklayout.StackLayout
  • kivy.uix.pagelayout.PageLayout

Once you have a layout (which you can nest inside other layouts),
you can start putting widgets in it.
Widgets are things like buttons, text fields, and checkboxes.
Read more about widgets in the official Widget Guide.

Here is an example .kv file that uses a box layout to hold
two buttons. This creates two buttons side-by-side with no text.
While it is valid and demonstrates the simplest case, it’s
not very useful.

# example.kv
BoxLayout:
    Button:
    Button:

Once you have a .kv file ready, you need to load it in your application.
By default, it tries to map your app class to a .kv based off of the name.
For example, if your app is named PracticeApp it will look for a file named
practice.kv. I prefer to be more explicit and use kivy.lang.builder.Builder.load_file()
to load a specific file. Alternatively, if you don’t want to use a file at all, you can use
kivy.lang.builder.Builder.load_string() to load a string with the Kv contents.

Here is an example of how to load the simple example.kv file we created a few lines above.

# main.py
import kivy
kivy.require('1.11.1')  # Set to your Kivy version
from kivy.app import App
from kivy.lang.builder import Builder


class MyApp(App):
    def build(self):
        return Builder.load_file('example.kv')


MyApp().run()

A button generally needs some text and and a callback function to be useful though.
We can modify the .kv file to look more like this:

# example.kv
BoxLayout:
    Button:
        id: btn1
        text: "Button 1"
        on_press: print("%s was pressed" % btn1.text)
    Button:
        text: "Button 2"
        on_press: print(8 * 8)

You can reference other methods from the on_press and other events.
For example in the Button object, referencing self would reference
the Button object, and referencing root would reference the root
widget in the heirarchy (BoxLayout in this case).

A more powerful way to do this would be to create a custom class
that inherits the BoxLayout so we can extend its behavior.

We can swap out the BoxLayout in the .kv file and replace it with our
CustomBoxLayout widget.

# example.kv
MyCustomBoxLayout:
    Button:
        text: "Press me"
        on_press: root.custom_callback()
    Button:

We haven’t actually created the CustomBoxLayout class though.
We need to add one in our .py file like this:

# main.py
import kivy
kivy.require('1.11.1')  # Set to your Kivy version
from kivy.app import App
from kivy.lang.builder import Builder
from kivy.uix.boxlayout import BoxLayout


class MyCustomBoxLayout(BoxLayout):
    def custom_callback(self):
        print('Custom callback called.')


class MyApp(App):
    def build(self):
        return Builder.load_file('example.kv')


MyApp().run()

Running the above example would load the .kv file and hook up the callback specified
in the .kv file with the method defined in the .py file.

Use Plyer to access mobile device hardware

Plyer is a Kivy package that provides an API to hardware devices
like accelerometer, screen brightness, camera, compass, and GPS.
It also provides access to some utilities like notifications,
which work across desktop and mobile. It’s a very ambitious
library that has some cool features. Refer to
the Plyer documentation
for the latest information.

You can also find code examples at:
https://github.com/kivy/plyer/tree/master/examples

As of September 2019, I found several of the examples did not work
on my Android device. The compatibility still seems to be a
bit questionable. Be sure to test out of a module works on device.

python -m pip install plyer

Then you can try building some of the Plyer examples.
See the section further down about how to use buildozer to build an .apk file for Android.

Build and package

Now that we have looked at installing Kivy and building a simple application,
let’s look at how to run and package a Kivy application for distribution.
We’ll look at desktop applications and then Android. I don’t cover
iOS because I don’t have any experience with it, but Kivy does support it.

Build for desktop

You can share your app in a few ways, starting from simplest to most complex packaging:

  • As source code — let people download your source, install Python and Kivy, and run your script themselves.
  • As a Python package — Package it with distutils and a setup.py that users can install with python setup.py install
  • Push the Python package to pypi.org so others can install it with pip install
  • Use PyInstaller to package a .exe or Mac/Linux equivalent. This can create a directory with the .exe and supporting
    files or a standalone .exe (slower to startup). Distribute this as a .zip for others to download and run.
    Check out my PyInstaller Tutorial to learn more about how to use it.
  • Create an system-specific installer package (e.g. MSI installer with InnoSetup for Window, or a .deb package for Ubuntu/Debian)
    that install the package created with PyInstaller. Check out my Debian Package Tutorial to learn how to make a .deb.

For more details on packaging, refer to the official documentation for Windows packaging and OSX packaging.

PyInstaller notes

If using PyInstaller to package the application,
there is a special step needed in order to ensure the SDL and glew DLLs are included in the final package.
These notes are taken from the official instructions at:
https://kivy.org/doc/stable/guide/packaging-windows.html#pyinstaller-default-hook.

To use PyInstaller to get a properly built package with the right DLLs,
modify the PyInstaller .spec file to have at the top:

from kivy_deps import sdl2, glew

And in the coll object, after the last positional argument, add:

*[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins)],

This will allow it to find and include the right dependencies since they are included in the code.
This helps with errors like unable to get window.

If you want to create an installer, you need to build an installer for each platform.
For example, you can use Inno Setup to build
Windows installer wizards.

Build for Android

Let’s look at the basic steps for building an Android .apk with a Python Kivy application.
For more in-depth details on building for Android, check out the official documentation pages:

  • Packaging for Android
  • Android guide

Test adb

The adb tool is crucial to Android tooling. Buildozer has a way
to use adb. The one special thing to remember when calling adb
through the buildozer command is that you need to add two dashes (--)
before passing the actual command for adb.

To verify everything is working, check the adb version.
Running the version command will also output the full path
to the adb executable that it is using if you want to run it directly yourself.

python -m buildozer -v android adb -- version
# adb path will be something like:
# $HOME/.buildozer/android/platform/android-sdk/platform-tools/adb

You can also use adb to list devices like this:

python -m buildozer -v android adb -- devices

Configure the buildozer.spec file

The buildozer.spec file has all the configuration for
the Android and iOS build parameters.
Generate a template buildozer spec file using init command:

# Generates a `buildozer.spec` file
python -m buildozer init

With the generated buildozer.spec file, edit it to specify things like:

  • Project name
  • Version
  • Source directory
  • Files to include and exclude
  • Required Python packages
  • Icon image — icon.filename a 512×512 PNG
  • Splash loading image — presplash.filename a 512×512 PNG
  • Change allowed orientations — orientation = all
  • Log level (log_level = 2)
  • Android Permissions
  • Android API level

Build the APK file

Once you have your Python Kivy application
and a buildozer.spec file for your project.
You can build a debug APK that does not require signing
or a release app that is signed and ready for the Google Play Store.

Build the debug APK

To build the debug APK which doesn’t require any app signing,
runthe android debug command for buildozer.

python3 -m buildozer -v android debug

This will generate an APK file in the bin/ directory with a name like:
myapp-0.1-debug.apk.

Build the release APK

To build the release APK there is more involved than the debug one.
You need to first have a keystore with a key ready to sign the app.

See my Java Keytool Tutorial to
learn how to generate and manage keystores. Once you have a keystore,
come back and the rest will make sense here.

Also refer to the Kivy Wiki: Creating a Release APK.

The main thing Buildozer needs to have is your keystore information.
You can set these values as environment variables.
You need to provide the keystore path, keystore password,
key alias, and key password.

export P4A_RELEASE_KEYSTORE=$HOME/.keystore
export P4A_RELEASE_KEYSTORE_PASSWD=s3cr3t
export P4A_RELEASE_KEYALIAS_PASSWD=s3cr3t
export P4A_RELEASE_KEYALIAS=mykey

Once your keystore environment variables are set, you can run the release build command:

# Requires the buildozer.spec file
python3 -m buildozer -v android release

Release app on the Google Play Store

Once you have a signed release APK from the previous step, you can publish it to the Google Play Store.
I have a dedicated tutorial about this topic.
Check out How to Publish Android Apps to Google Play Store.

Install the app

Install the application to a device (physical or virtual)
by running android debug deploy. This will install it
to all connected devices.

python -m buildozer -v android debug deploy

You can also use buildozer to serve the current directory with
an HTTP server. This is useful so any Android device on the network
can open the HTTP URL in a web browser and download the .apk and install
it on the device without the need to be connected via USB.

# Serve the current HTTP directory on port 8000
python -m buildozer serve
# Essentially the same as Python 3's http.server
python -m http.server

You’d then visit http://<your-hostname-or-ip>/ and navigate to the .apk file.

Run the app

You can build, install, and run the app all in one step with:

python -m buildozer -v android debug deploy run
  • The built .apk file is about 13MB.
  • The RAM consumption of simple example in debug is about 60MB.

The stats did not change much between release and debug mode.

View debug log

Use logcat to view the device logs

python -m buildozer -v android logcat

If you run with multiple devices attached it won’t tail the logs.
If you have one device then you will get the stream of log messages.
You can build, install, run, and dump logs in a single step.
You will get a ton of log messages so it’s best to output it to a file like this:

python -m buildozer -v android debug deploy run logcat 2>log.txt

Other common tasks

Now that we have looked at installing Kivy,
creating simple applications,
and how to package them for distribution,
we have all the knowledge needed to create
an app.

At this point you should understand how to
get everything installed, create an application from scratch,
creating custom widgets and callbacks with the Kv language,
and how to build the app in to a .exe for desktop and a .apk for Android.

All that information should be enough to continue exploring Kivy
and building your own applications.

Kivy also includes packages for many things, including:

  • animation
  • async
  • audio
  • canvas drawing
  • shaders
  • 3D rendering

Refer to the official Kivy documentation
for the latest and most accurate information.
For code references, check out the collection of official
Kivy examples.

To learn more, follow the official Pong game tutorial.
It shows you how to build a Pong game using Rectangle and Ellipse widgets
on a canvas widget, and use the built-it collide_widget() method
available on widgets to detect collisions like the ball hitting the paddle.

Also check out the Paint app tutorial which shows you how to detect touches and respond by
drawing an Ellipse (circle) at the touch location.

Conclusion

After reading this, you should have a good understanding of what Kivy is
and how you can use it. You should understand how to create desktop apps
and mobile apps by building layouts using the .kv language. Additionally,
you should know how to use the plyer package to access cross-platform
features and hardware devices.

References

  • Official Kivy documentation
  • Kivy Source
  • Kv language guide
  • Kivy examples
  • Paint app tutorial
  • Pong game tutorial
  • Widget Guide
  • Plyer documentation
  • Plyer examples
  • PyInstaller Tutorial
  • InnoSetup
  • Windows packaging
  • OSX packaging
  • Android packaging
  • Android Kivy guide
  • Buildozer source
  • Inno Setup Installer
  • PyQt5 Tutorial
  • Python Tkinter Tutorial
  • wxPython
  • Bitcoin Price Checker
  • Live Stream making Bitcoin Price Checker
  • Java Keytool Tutorial
  • Kivy Wiki: Creating a Release APK
  • How to Publish Android Apps to Google Play Store

В наши дни каждый разработчик может столкнуться с необходимостью работы над мобильным или веб-приложением на Python. В Python нет встроенных инструментов для мобильных устройств, тем не менее существуют пакеты, которые можно использовать для создания мобильных приложений. Это Kivy, PyQt и даже библиотека Toga от Beeware.

Содержание

  • Принципы работы фреймворка Kivy Python
  • Установка Kivy
  • Работа с виджетами в Kivy
  • Запуск программы «Hello, Kivy!»
  • Отображение виджета Image в Kivy Python
  • Разметка (Layout) в UI Kivy
  • Добавление событий в Kivy
  • Использование языка дизайна KV
  • Создание приложения Kivy Python
  • Создаем apk приложения для Android на Python
  • Создание приложений для iPhone (iOS) на Python
  • Создание exe приложений для Windows на Python используя Kivy
  • Создание приложений для macOS на Python используя Kivy

Библиотеки являются основными элементами мобильного мира Python. Однако, говоря о Kivy, нельзя игнорировать преимущества данного фреймворка при работе с мобильными приложениями. Внешний вид приложения автоматически подстраивается под все платформы, разработчику при этом не нужно компилировать код после каждой поправки. Кроме того, здесь для создания приложений можно использовать чистый синтаксис Python.

В руководстве будут разобраны следующие темы:

  • Работа с виджетами Kivy;
  • Планировка UI и лейауты;
  • Добавление событий;
  • Использование языка KV;
  • Создание приложения-калькулятора;
  • Упаковка приложения для iOS, Android, Windows и macOS.

Разбор данного руководства предполагает, что читатель знаком с объектно-ориентированным программированием. Для введения в курс дела можете просмотреть статью об Объектно-ориентированном программировании (ООП) в Python 3.

Приступим!

Принципы работы фреймворка Kivy Python

Kivy был создан в 2011 году. Данный кросс-платформенный фреймворк Python работает на Windows, Mac, Linux и Raspberry Pi. В дополнение к стандартному вводу через клавиатуру и мышь он поддерживает мультитач. Kivy даже поддерживает ускорение GPU своей графики, что во многом является следствием использования  OpenGL ES2. У проекта есть лицензия MIT, поэтому библиотеку можно использовать бесплатно и вкупе с коммерческим программным обеспечением.

Во время разработки приложения через Kivy создается интуитивно понятный интерфейс (Natural user Interface), или NUI. Его главная идея в том, чтобы пользователь мог легко и быстро приспособиться к программному обеспечению без чтения инструкций.

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

Установка Kivy

У Kivy есть множество зависимостей, поэтому лучше устанавливать его в виртуальную среду Python. Можно использовать встроенную библиотеку Python venv или же пакет virtualenv.

Виртуальная среда Python создается следующим образом:

$ python3 m venv my_kivy_project

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

Есть вопросы по Python?

На нашем форуме вы можете задать любой вопрос и получить ответ от всего нашего сообщества!

Telegram Чат & Канал

Вступите в наш дружный чат по Python и начните общение с единомышленниками! Станьте частью большого сообщества!

Паблик VK

Одно из самых больших сообществ по Python в социальной сети ВК. Видео уроки и книги для вас!

Для использования виртуальной среды ее нужно активировать. На Mac или Linux это можно сделать, выполнив следующую команду, будучи внутри папки my_kivy_project:

Команда для Windows точно такая же, но активировать скрипт нужно в другом месте — через папку Scripts, а не bin.

После активации виртуальной среды Python можно запустить pip для установки Kivy. На Linux и Mac нужно выполнить следующую команду:

$ python m pip install kivy

Инсталляция на Windows несколько сложнее. В официальной документации фреймворка изучите пункт, касающийся установки Kivy на Windows. Пользователи Mac также могут скачать файл dmg и установить Kivy данным образом.

В случае возникновения проблем во время установки Kivy на вашу платформу изучите дополнительные инструкции, с которыми можно ознакомиться через страницу загрузки.

Работа с виджетами в Kivy

Виджеты — это отображаемые на экране элементы управления, которыми пользователь может оперировать. Любой инструментарий графического интерфейса пользователя поставляется с набором виджетов. Типичными представителями виджетов, что вы не раз использовали, являются кнопки, выпадающие списки и вкладки. Внутри фреймворка Kivy встроено много виджетов.

Запуск программы «Hello, Kivy!»

Принцип работы Kivy можно уловить, взглянув на следующее приложение «Hello, World!»:

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

Каждому приложению Kivy требуется создать подкласс App и переопределить метод build().  Сюда вы помещаете код UI или вызываете другие функции, которые определяют код UI. В данном случае создается виджет Label и передается text, size_hint и pos_hint. Последние два аргумента не обязательны.

size_hint говорит Kivy о размерах что нужно использовать при создании виджета. Используются два числа:

  1. Первое число x указывает на размер ширины элемента управления.
  2. Второе число y указывает на размер высоты элемента управления.

Значение обоих чисел должно быть в промежутке между 0 и 1. Значение по обоих показателей по умолчанию равно 1. Также можно задействовать pos_hint, что используется для позиционирования виджета. В коде, размещенном выше, указывается, что виджет должен быть размещен в центре осей x и y.

Для запуска приложения нужно инициализировать класс MainApp и вызвать метод run(). После этих действий на экране появится следующее:

App Hello from Kivy

Kivy также выводит в stdout довольно много текста:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

[INFO   ] [Logger      ] Record log in /home/mdriscoll/.kivy/logs/kivy_190607_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

Это может быть полезно для отладки приложения.

Далее добавим виджет Image и посмотрим, чем он отличается от Label.

Отображение виджета Image в Kivy Python

В Kivy есть несколько видов виджетов, связанных с изображениями. Для загрузки картинок с жесткого диска можно задействовать Image, а при использовании адреса URL подойдет AsyncImage. К следующем примере берется стандартный класс Image:

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

В данном коде импортируется Image из подпакета kivy.uix.image. Класс Image принимает много разных параметров, однако единственным для нас нужным является source, что указывает Kivy, какое изображение должно быть загружено. Здесь передается полный путь к выбранному изображению. Оставшаяся часть кода такая же, как и в прошлом примере.

После запуска кода должно выводиться нечто подобное:

Load Image Kivy

Текст из предыдущего примера был заменен картинкой.

Теперь рассмотрим, как добавить и оптимально расположить несколько виджетов в приложении.

Разметка (Layout) в UI Kivy

У каждого фреймворка есть свой собственный метод для размещения виджетов. К примеру, в wxPython используются классификаторы, а в Tkinter будет задействован лейаут, или менеджер геометрии. В Kivy за это отвечают Лейауты (Layouts). Доступно несколько различных типов Лейаутов. Чаще всего используются следующие виды:

  • BoxLayout;
  • FloatLayout;
  • GridLayout.

Найти полный список доступных Лейаутов можно в документации Kivy. Рабочий исходный код можно найти в kivy.uix.

Рассмотрим BoxLayout на примере следующего кода:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

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

Здесь из kivy.uix.boxlayout импортируется модуль BoxLayout и затем устанавливается. После этого создается список цветов, которые представляют собой цвета RGB (Red-Blue-Green).

В конечном итоге формируется цикл для range из 5, результатом чего является кнопка btn для каждой итерации. Сделаем вещи немного интереснее и поставим в качестве фона кнопки background_color случайный цвет. Теперь можно добавить кнопку в лейаут при помощи layout.add_widget(btn).

После запуска кода выведется нечто подобное:

Kivy Hbox Layout

Здесь представлены 5 кнопок, окрашенных случайным образом, по одной для каждой итерации цикла for.

Во время создания лейаута следует учитывать следующие аргументы:

  • padding: Отступ padding между лейаутом и его дочерними элементами уточняется в пикселях. Для этого можно выбрать один из трех способов:
    1. Список из четырех аргументов: [padding_left, padding_top, padding_right, padding_bottom]
    2. Список из двух аргументов: [padding_horizontal, padding_vertical]
    3. Один аргумент: padding=10
  • spacing: При помощи данного аргумента добавляется расстояние между дочерними виджетами.
  • orientation: Позволяет изменить значение orientation для BoxLayout по умолчанию — с горизонтального на вертикальное.

Добавление событий в Kivy

Как и многие другие инструментарии GUI, по большей части Kivy полагается на события. Фреймворк отзывается на нажатие клавиш, кнопки мышки или прикосновение к сенсорному экрану. В Kivy задействован концепт Часов (Clock), что дает возможность создать своего рода график для вызова определенных функций в будущем.

В Kivy также используется концепт Свойств (Properties), что работает с EventDispatcher. Свойства помогают осуществить проверку достоверности. Они также запускают события, когда виджет меняет размер или позицию.

Добавим событие для кнопки из предыдущего кода:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

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(‘Вы нажали на кнопку!’)

if __name__ == ‘__main__’:

    app = MainApp()

    app.run()

В данном коде вызывается button.bind(), а событие on_press ссылается на MainApp.on_press_button().

Этот метод неявно принимает экземпляр виджета, который является самим объектом кнопки. Сообщение будет выводиться на stdout всякий раз при нажатии пользователем на кнопку.

Использование языка дизайна KV

Kivy предоставляет язык дизайна KV, что можно использовать в приложениях Kivy. Язык KV позволяет отделить дизайн интерфейса от логики приложения. Он придерживается принципа разделения ответственности и является частью архитектурного паттерна Модель-Представление-Контроллер (Model-View-Controller).  Предыдущий пример можно обновить, используя язык KV:

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(‘Вы нажали на кнопку!’)

if __name__ == ‘__main__’:

    app = ButtonApp()

    app.run()

С первого взгляда данный код может показаться несколько странным, так как кнопка Button создается без указания атрибутов или привязывания к ним событий. Здесь Kivy автоматически ищет файл с таким же названием, что и у класса, только строчными буквами и без части App в названии класса.

В данном случае названием класса является ButtonApp, поэтому Kivy будет искать файл button.kv. Если такой файл существует, и он также форматирован должным образом, тогда Kivy использует его при загрузке UI. Попробуйте создать такой файл и добавить следующий код:

<Button>:

    text: ‘Press me’

    size_hint: (.5, .5)

    pos_hint: {‘center_x’: .5, ‘center_y’: .5}

    on_press: app.on_press_button()

Действия каждой строки:

  • Строка 1 соответствует вызову Button в коде Python. Kivy должен осмотреть инициализированный объект для определения кнопки;
  • Строка 2 устанавливает text кнопки;
  • Строка 3 устанавливает ширину и высоту при помощи size_hint;
  • Строка 4 устанавливает позицию кнопки через pos_hint;
  • Строка 5 устанавливает обработчик событий on_press. Для указания Kivy места обработчика событий используется app.on_press_button(). Здесь Kivy будет искать метод .on_press_button() в классе Application.

Вы можете установить все ваши виджеты и лейауты внутри одного или нескольких файлов языка KV. Язык KV также поддерживает импорт модулей Python в KV, создавая динамичные классы, и это далеко не предел. Ознакомиться с полным перечнем его возможностей можно в гиде Kivy по языку KV.

Теперь мы можем приступить к созданию настоящего рабочего приложения.

Создание приложения Kivy Python

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

  • Сложение;
  • Вычитание;
  • Умножение;
  • Деление.

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

Kivy Calculator

Теперь, когда у нас есть в наличии целевой UI, может составить код:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

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

Калькулятор работает следующим образом:

  • В строках с 8 по 10 создается список operators и несколько полезных значений, last_was_operator и last_button, которые будут использованы чуть позже.
  • В строках с 11 по 15 создается лейаут верхнего уровня main_layout, к нему также добавляется виджет только для чтения TextInput.
  • В строках с 16 по 21 создается вложенный список из списков, где есть большая часть кнопок для калькулятора.
  • В строке 22 начинается цикл for для кнопок. Для каждого вложенного списка делается следующее:
    1. В строке 23 создается BoxLayout с горизонтальной ориентацией.
    2. В строке 24 начинается еще один цикл for для элементов вложенного списка.
    3. В строках с 25 по 39 создаются кнопки для ряда и связываются обработчиком событий, после чего кнопки добавляются к горизонтальному BoxLayout из строки 23.
    4. В строке 31 этот лейаут добавляется к main_layout.
  • В строках с 33 по 37 создается кнопка равно (=) и привязывается к обработчику событий, после чего она добавляется к main_layout.

Далее создается обработчик событий .on_button_press(). Код будет выглядеть следующим образом:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

def on_button_press(self, instance):

    current = self.solution.text

    button_text = instance.text

    if button_text == «C»:

        # Очистка виджета с решением

        self.solution.text = «»

    else:

        if current and (

            self.last_was_operator and button_text in self.operators):

            # Не добавляйте два оператора подряд, рядом друг с другом

            return

        elif current == «» and button_text in self.operators:

            # Первый символ не может быть оператором

            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

Почти все виджеты приложения вызывают .on_button_press(). Это работает следующим образом:

  • Строка 41 принимает аргумент instance, в результате чего можно узнать, какой виджет вызвал функцию.
  • Строки между 42 и 43 извлекают и хранят значения solution и text кнопки.
  • Строки c 45 по 47 проверяют, на какую кнопку нажали. Если пользователь нажимает с, тогда очищается solution. В противном случае используется утверждение else.
  • Строка 49 проверяет, было ли у решения предыдущее значение.
  • Строки с 50 по 52 проверяют, была ли последняя нажатая кнопка оператором. Если да, тогда solution обновляться не будет. Это необходимо для предотвращения создания двух операций в одном ряду. К примеру, 1 * / будет недействительным утверждением.
  • Строки с 53 по 55 проверяют, является ли первый символ оператором. Если да, тогда solution обновляться не будет, так как первое значение не может быть значением оператора.
  • Строки с 56 по 58 переходят к условию else. Если никакое из предыдущих значений не найдено, тогда обновляется solution.
  • Строка 59 устанавливает last_button к метке последней нажатой кнопки.
  • Строка 60 устанавливает last_was_operator к значению True или False в зависимости от того, был символ оператором или нет.

Последней частью кода будет .on_solution():

def on_solution(self, instance):

    text = self.solution.text

    if text:

        solution = str(eval(self.solution.text))

        self.solution.text = solution

Здесь берется текущий текст из solution и используется встроенный в Python eval() для исполнения. Если пользователь создал формулу вроде 1+2, тогда eval() запустит код и вернет результат. В конце результат устанавливается как новое значение виджета solution.

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

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

KV Calculator

Полный текст кода примера калькулятора представлен ниже:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

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»:

            # Очистка виджета с решением

            self.solution.text = «»

        else:

            if current and (

                self.last_was_operator and button_text in self.operators):

                # Не добавляйте два оператора подряд, рядом друг с другом

                return

            elif current == «» and button_text in self.operators:

                # Первый символ не может быть оператором

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

Пришло время разместить приложение в Google Play или в AppStore!

По завершении составления кода вы можете поделиться своим приложением с другими. Хорошим способом сделать это может стать превращение вашего кода в приложения для смартфона на Android. Для этого вначале нужно установить пакет buildozer через pip:

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

После этого создается файл buildozer.spec, который будет использован для конфигурации сборки. К примеру, первые две строчки файла спецификации можно редактировать следующим образом:

[app]

# (str) Название вашего приложения

title = KvCalc

# (str) Название упаковки

package.name = kvcalc

# (str) Домен упаковки (нужен для упаковки android/ios)

package.domain = org.kvcalc

Не бойтесь посмотреть оставшуюся часть файла для выяснения того, что еще можно поменять.

На данный момент приложение почти готово к сборке, однако для начала нужно установить зависимости для buildozer.  После их установки скопируйте ваше приложение калькулятора в новую папку и переименуйте его в main.py. Этого требует buildozer. Если файл будет назван неверно, тогда процесс сборки завершится неудачей.

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

$ buildozer v android debug

Этап сборки займет время! На моем компьютере на это ушло около 15-20 минут. Здесь все зависит от вашего железа, так что времени может потребоваться еще больше. Расслабьтесь, налейте чашечку кофе или прогуляйтесь. Buildozer скачает те элементы Android SDK, которые нужны для процесса сборки. Если все идет по плану, тогда в папке bin появится файл под названием, напоминающим что-то вроде kvcalc-0.1-debug.apk.

Далее требуется связать телефон Android с компьютером и перенести туда файл apk. Затем откройте менеджер файлов телефона и кликните на файл apk. Android должен спросить, хотите ли вы установить приложение. Есть вероятность появления предупреждения, ведь приложение было скачано не из Google Play. Тем не менее, вы по-прежнему сможете установить его.

Вот как выглядит калькулятор, запущенный на Samsung S9:

KV Calc Android

У buildozer также есть несколько других команд, которые вы можете использовать. Изучите документацию, чтобы подробнее узнать об этом.

При необходимости добиться более детального управления упаковку можно осуществить через python-for-android. Здесь это обсуждаться не будет, но если интересно, ознакомьтесь, как еще можно быстро начать проект.

Создание приложений для iPhone (iOS) на Python

Инструкция для сборки приложения для iOS будет немного сложнее, нежели для Android. Для получения последней информации всегда проверяйте обновления официальной документации Kivy.

Вам нужен будет компьютер с операционной системой OS X: MacBook или iMac. На Linux или Windows вы не сможете создать приложения для Apple.

Перед упаковкой приложения для iOS на Mac необходимо выполнить следующие команды:

$ brew install autoconf automake libtool pkgconfig

$ brew link libtool

$ sudo easy_install pip

$ sudo pip install Cython==0.29.10

После успешной установки нужно скомпилировать при использования следующих команд:

$ git clone git://github.com/kivy/kivyios

$ cd kivyios

$ ./toolchain.py build python3 kivy

Если вы получаете ошибку, где говорится, что iphonesimulator не найден, тогда поищите способ решение проблемы на StackOverflow, после чего попробуйте запустить команды вновь.

Если вы получаете ошибки SSL, тогда скорее всего у вас не установлен OpenSSL от Python. Следующая команда должна это исправить:

$ cd /Applications/Python 3.7/

$ ./Install Certificates.command

Теперь вернитесь назад и запустите команду toolchain опять.

После успешного выполнения всех указанных выше команд можете создать проект Xcode при помощи использования скрипта toolchain. Перед созданием проекта Xcode переименуйте ваше главное приложение в main.py, это важно. Выполните следующую команду.

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

Здесь должна быть папка под названием title, внутри которой будет проект Xcode. Теперь можно открыть проект Xcode и работать над ним отсюда. Обратите внимание, что если вы хотите разместить свое приложение на AppStore, вам понадобится создать аккаунт разработчика на developer.apple.com и заплатить годовой взнос.

Создание exe приложений для Windows на Python используя Kivy

Упаковать приложение Kivy для Windows можно при помощи PyInstaller. Если ранее вы никогда не работали с ним, тогда изучите тему использования PyInstaller для упаковки кода Python в исполняемый файл.

Для установки PyInstaller можно использовать pip:

$ pip install pyinstaller

Следующая команда упакует ваше приложение:

Команда создаст исполняемый файл Windows, а вместе с ним еще несколько других файлов. Аргумент -w говорит PyInstaller, что приложение открывается в оконном режиме и не является приложение для командной строки. Если вы хотите, чтобы PyInstaller создал только один исполняемый файл, тогда можете передать в дополнение к -w аргумент --onefile.

Создание приложений для macOS на Python используя Kivy

Как и в случае с Windows, для создания исполняемого файла Mac можно также использовать PyInstaller. Единственным условием является запуск следующей команды на Mac:

$ pyinstaller main.py w onefile

Результатом станет один исполняемый файл в папке dist. Название исполняемого файла будет таким же, как и название файла Python, что был передан PyInstaller.

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

Заключение

Kivy является действительно интересным фреймворком GUI, что можно использовать для создания пользовательских интерфейсов и мобильных приложений для Android и iOS. Внешне приложения Kivy будут отличаться от нативных приложений выбранной платформы. В том случае, если вы хотите выделяться на фоне конкурентов, это может быть выгодным преимуществом.

В данном руководстве были рассмотрены основы Kivy, среди которых стоит выделить добавление виджетов, привязку событий, планировку виджетов и лейауты, а также использование языка KV. В результате мы получили рабочее приложение Kivy и рассмотрели способы его переноса на другие платформы, в том числе мобильные.

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

Рекомендации

Для дальнейшего изучения Kivy ознакомьтесь со следующими ресурсами:

  • Гид программирования на Kivy 
  • Документация по упаковке приложений Kivy
  • Сборка приложений GUI через Python

Чтобы посмотреть, как создать приложение с графическим интерфейсом при использовании другого GUI фреймфорка Python, можете ознакомиться со статьями о wxPython.

Являюсь администратором нескольких порталов по обучению языков программирования Python, Golang и Kotlin. В составе небольшой команды единомышленников, мы занимаемся популяризацией языков программирования на русскоязычную аудиторию. Большая часть статей была адаптирована нами на русский язык и распространяется бесплатно.

E-mail: vasile.buldumac@ati.utm.md

Образование
Universitatea Tehnică a Moldovei (utm.md)

  • 2014 — 2018 Технический Университет Молдовы, ИТ-Инженер. Тема дипломной работы «Автоматизация покупки и продажи криптовалюты используя технический анализ»
  • 2018 — 2020 Технический Университет Молдовы, Магистр, Магистерская диссертация «Идентификация человека в киберпространстве по фотографии лица»

If you’re a Python developer thinking about getting started with mobile development, then the Kivy framework is your best bet. With Kivy, you can develop platform-independent applications that compile for iOS, Android, Windows, MacOS, and Linux. In this article, we’ll cover:

  • Getting started with Kivy
    • Creating the RandomNumber class
  • Outsourcing the interface
    • A note on file naming
    • Applying the box layout
    • Kivy color values
    • Building the rest of the UI
  • Generating the random number function
  • Manually testing the app
  • Compiling our app for Android, Windows, and iOS

To follow along with this article, you should be familiar with Python. Let’s get started!

Getting started with Kivy

First, you’ll need a new directory for your app. Make sure you have Python installed on your machine and open a new Python file. You’ll need to install the Kivy module from your terminal using either of the commands below. To avoid any package conflicts, be sure you’re installing Kivy in a virtual environment:

pip install kivy 
//
pip3 install kivy 

Once you have installed Kivy, you should see a success message from your terminal that looks like the screenshots below:

Kivy installation

Kivy installation

A successful Kivy installation

Next, navigate into your project folder. In the main.py file, we’ll need to import the Kivy module and specify which version we want. You can use Kivy v2.0.0, but if you have a smartphone that is older than Android v8, I recommend v1.9.0. You can mess around with the different versions during the build to see the differences in features and performance.

To specify the version, add the version number right after the import kivy line as follows:

kivy.require('1.9.0')

Creating the RandomNumber class

Now, we’ll create a class that will define our app; I’ll name mine RandomNumber. This class will inherit the app class from Kivy. Therefore, you need to import the app by adding from kivy.app import App:

class RandomNumber(App): 

In the RandomNumber class, you’ll need to add a function called build, which takes a self parameter. To actually return the UI, we’ll use the build function. For now, I have it returned as a simple label. To do this, you’ll need to import Label using the line from kivy.uix.label import Label:

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

class RandomNumber(App):
  def build(self):
    return Label(text="Random Number Generator")

Now, our app skeleton is complete! Before moving forward, you should create an instance of the RandomNumber class and run it in your terminal or IDE to see the interface:

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

class RandomNumber(App):
  def build(self):
    return Label(text="Random Number Generator")

randomApp = RandomNumber()
randomApp.run()

When you run the class instance with the text Random Number Generator, you should see a simple interface or window that looks like the screenshot below:

Simple interface after running the code

Simple interface after running the code

You won’t be able to run the text on Android until you’ve finished building the whole thing.

Outsourcing the interface

Next, we’ll need a way to outsource the interface. First, we’ll create a Kivy file in our directory that will house most of our design work.

A note on file naming

You’ll want to name this file the same name as your class using lowercase letters and a .kv extension. Kivy will automatically associate the class name and the filename, but it may not work on Android if they are exactly the same. (This might have been a glitch on my end, but you can mess around with it on your side. From what I tested, you have to write your Kivy file name in lowercase letters.)

Inside that .kv file, you need to specify the layout of your app, including elements like the label, buttons, forms, etc. Layouts in Kivy are of different types, but have the same function — they are all containers used to arrange widgets in ways that are specific to the chosen layout; you can read more information about different Kivy layouts in their getting started guide.

Applying the box layout

To keep this application simple, I will use the box layout. In a nutshell, the box layout arranges widgets and other elements in one of two orientations: vertical or horizontal. I’ll add three labels:

  1. One for the title RandomNumber
  2. One to serve as a placeholder for the random number that is generated _
  3. A Generate button that calls the generate function

Keep in mind that these labels will be stacked on top of each other.

My .kv file looks like the code below, but you can mess around with the different values to fit your requirements:

<boxLayout>:
    orientation: "vertical"
    Label:
        text: "Random Number"
        font_size: 30
        color: 0, 0.62, 0.96

    Label:
        text: "_"
        font_size: 30

    Button:
        text: "Generate"
        font_size: 15 

In the above code snippet, line 2 specifies the type of layout I am using for my app, and line 3 specifies the orientation I just mentioned. The rest of the lines are customizable, so you can specify how you want your UI elements to appear.

Kivy color values

The color values in Kivy are not your typical RGB values — they are normalized. To understand color normalization, you need to be aware that the distribution of color values is normally dependent on illumination. This varies depending on factors like lighting conditions, lens effects, and other factors.

To avoid this, Kivy accepts the (1, 1, 1) convention. This is Kivy’s representation of RGB’s (255, 255, 255). To convert your normal RGB values to Kivy’s convention, you need to divide all of your values by 255. That way, you get values from 01.

Building the rest of the UI

In the main.py file, you no longer need the Label import statement because the Kivy file takes care of your UI. However, you do need to import the boxlayout, which you will use in the Kivy file.

In your main file, add the import statement and edit your main.py file to read return BoxLayout() in the build method:

from kivy.uix.boxlayout import BoxLayout

If you run the command above, you should see a simple interface that has the random number title, the _ placeholder, and the clickable generate button:

Random number app is generated

Random number app is generated

Notice that you didn’t have to import anything additional for the Kivy file to work. Basically, when you run the app, it returns boxlayout by looking for a file inside the Kivy file that has the same name as your class. Keep in mind, this is a simple interface, so you can make your app as robust as you want. Be sure to check out the Kv language documentation for ideas.

Generating the random number function

Now that our app is almost done, we’ll need a simple function to generate random numbers when a user clicks the generate button. Then, it will render that random number into the app interface. To do this, we’ll need to change a few things in our files.

First, import the random module that you’ll use to generate a random number and create a function or method that calls the generated number. To import the random module, use the statement import random.

For this demonstration, I’ll use a range between 0 and 2000. Generating the random number is simple with the random.randint(0, 2000) one-liner. We’ll add this into our code in a moment.

Next, we’ll create another class that will be our own version of the box layout. Our class will inherit the box layout class, which houses the method to generate random numbers and render them on the interface:

class MyRoot(BoxLayout):
    def __init__(self):
        super(MyRoot, self).__init__()

After that, you need to create the generate method within that class, which will not only generate random numbers, but also manipulate the label that controls what is displayed as the random number in the Kivy file.

In order to accommodate this method, we’ll first need to make changes to the .kv file. Since the MyRoot class has inherited the box layout, you can make MyRoot the top level element in your .kv file:

<MyRoot>:
    BoxLayout:
        orientation: "vertical"
        Label:
            text: "Random Number"
            font_size: 30
            color: 0, 0.62, 0.96

        Label:
            text: "_"
            font_size: 30

        Button:
            text: "Generate"
            font_size: 15

Notice that you are still keeping all your UI specifications indented in the Box Layout. After this, you need to add an ID to the label to hold the generated numbers, making it easy to manipulate when the generate function is called. You need to specify the relationship between the id in this file and another in the main code at the top, just before the BoxLayout line:

<MyRoot>:
    random_label: random_label
    BoxLayout:
        orientation: "vertical"
        Label:
            text: "Random Number"
            font_size: 30
            color: 0, 0.62, 0.96

        Label:
            id: random_label
            text: "_"
            font_size: 30

        Button:
            text: "Generate"
            font_size: 15

This random_label: random_label line basically means that the label with the ID random_label will be mapped to random_label in the main.py file, so that any action that manipulates random_label will be mapped on the label with the specified name.

You can now create the method to generate the random number in the main.py file:

def generate_number(self):
    self.random_label.text = str(random.randint(0, 2000))

Notice how the class method manipulates the text attribute of the random_label by assigning it a new random number generated by the 'random.randint(0, 2000)' function. Since the random number generated is an integer, typecasting is required to make it a string — otherwise, you will get a type error in your terminal when you run it.

The MyRoot class should now look like the code below:

class MyRoot(BoxLayout):
    def __init__(self):
        super(MyRoot, self).__init__()

    def generate_number(self):
        self.random_label.text = str(random.randint(0, 2000))

Congratulations! You’re now done with the main file of the app.

Manually testing the app

The only thing left to do is making sure that you call this function when the generate button is clicked. You need only add the line on_press: root.generate_number() to the button selection part of your .kv file:

<MyRoot>:
    random_label: random_label
    BoxLayout:
        orientation: "vertical"
        Label:
            text: "Random Number"
            font_size: 30
            color: 0, 0.62, 0.96

        Label:
            id: random_label
            text: "_"
            font_size: 30

        Button:
            text: "Generate"
            font_size: 15
            on_press: root.generate_number()

Now, you can run this app:

Compiling our app for Android, Windows, and iOS

Before compiling our app for Android, I have some bad news for Windows users. You’ll need Linux or macOS to compile your Android application. However, you don’t need to have a separate Linux distribution — instead, you can use a virtual machine.

To compile and generate a full Android .apk application, we’ll use a tool called Buildozer. Install Buildozer through our terminal using one of the commands below:

pip3 install buildozer
//
pip install buildozer

Now, we’ll install some of Buildozer’s required dependencies. I am using Linux Ergo, so I’ll use Linux-specific commands. You should execute these commands one by one:

sudo apt update
sudo apt install -y git zip unzip openjdk-13-jdk python3-pip autoconf libtool pkg-config zlib1g-dev libncurses5-dev libncursesw5-dev libtinfo5 cmake libffi-dev libssl-dev

pip3 install --upgrade Cython==0.29.19 virtualenv 

# add the following line at the end of your ~/.bashrc file
export PATH=$PATH:~/.local/bin/

After executing the specific commands, run buildozer init. You should see an output similar to the screenshot below:

Buildozer successful initialization

Buildozer successful initialization

The command above creates a buildozer .spec file, which you can use to make specifications to your app, including the name of the app, the icon, etc. The .spec file should look like the code block below:

[app]

# (str) Title of your application
title = My Application

# (str) Package name
package.name = myapp

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

# (str) Source code where the main.py live
source.dir = .

# (list) Source files to include (let empty to include all the files)
source.include_exts = py,png,jpg,kv,atlas

# (list) List of inclusions using pattern matching
#source.include_patterns = assets/*,images/*.png

# (list) Source files to exclude (let empty to not exclude anything)
#source.exclude_exts = spec

# (list) List of directory to exclude (let empty to not exclude anything)
#source.exclude_dirs = tests, bin

# (list) List of exclusions using pattern matching
#source.exclude_patterns = license,images/*/*.jpg

# (str) Application versioning (method 1)
version = 0.1

# (str) Application versioning (method 2)
# version.regex = __version__ = ['"](.*)['"]
# version.filename = %(source.dir)s/main.py

# (list) Application requirements
# comma separated e.g. requirements = sqlite3,kivy
requirements = python3,kivy

# (str) Custom source folders for requirements
# Sets custom source for any requirements with recipes
# requirements.source.kivy = ../../kivy

# (list) Garden requirements
#garden_requirements =

# (str) Presplash of the application
#presplash.filename = %(source.dir)s/data/presplash.png

# (str) Icon of the application
#icon.filename = %(source.dir)s/data/icon.png

# (str) Supported orientation (one of landscape, sensorLandscape, portrait or all)
orientation = portrait

# (list) List of service to declare
#services = NAME:ENTRYPOINT_TO_PY,NAME2:ENTRYPOINT2_TO_PY

#
# OSX Specific
#

#
# author = © Copyright Info

# change the major version of python used by the app
osx.python_version = 3

# Kivy version to use
osx.kivy_version = 1.9.1

#
# Android specific
#

# (bool) Indicate if the application should be fullscreen or not
fullscreen = 0

# (string) Presplash background color (for new android toolchain)
# Supported formats are: #RRGGBB #AARRGGBB or one of the following names:
# red, blue, green, black, white, gray, cyan, magenta, yellow, lightgray,
# darkgray, grey, lightgrey, darkgrey, aqua, fuchsia, lime, maroon, navy,
# olive, purple, silver, teal.
#android.presplash_color = #FFFFFF

# (list) Permissions
#android.permissions = INTERNET

# (int) Target Android API, should be as high as possible.
#android.api = 27

# (int) Minimum API your APK will support.
#android.minapi = 21

# (int) Android SDK version to use
#android.sdk = 20

# (str) Android NDK version to use
#android.ndk = 19b

# (int) Android NDK API to use. This is the minimum API your app will support, it should usually match android.minapi.
#android.ndk_api = 21

# (bool) Use --private data storage (True) or --dir public storage (False)
#android.private_storage = True

# (str) Android NDK directory (if empty, it will be automatically downloaded.)
#android.ndk_path =

# (str) Android SDK directory (if empty, it will be automatically downloaded.)
#android.sdk_path =

# (str) ANT directory (if empty, it will be automatically downloaded.)
#android.ant_path =

# (bool) If True, then skip trying to update the Android sdk
# This can be useful to avoid excess Internet downloads or save time
# when an update is due and you just want to test/build your package
# android.skip_update = False

# (bool) If True, then automatically accept SDK license
# agreements. This is intended for automation only. If set to False,
# the default, you will be shown the license when first running
# buildozer.
# android.accept_sdk_license = False

# (str) Android entry point, default is ok for Kivy-based app
#android.entrypoint = org.renpy.android.PythonActivity

# (str) Android app theme, default is ok for Kivy-based app
# android.apptheme = "@android:style/Theme.NoTitleBar"

# (list) Pattern to whitelist for the whole project
#android.whitelist =

# (str) Path to a custom whitelist file
#android.whitelist_src =

# (str) Path to a custom blacklist file
#android.blacklist_src =

# (list) List of Java .jar files to add to the libs so that pyjnius can access
# their classes. Don't add jars that you do not need, since extra jars can slow
# down the build process. Allows wildcards matching, for example:
# OUYA-ODK/libs/*.jar
#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar

# (list) List of Java files to add to the android project (can be java or a
# directory containing the files)
#android.add_src =

# (list) Android AAR archives to add (currently works only with sdl2_gradle
# bootstrap)
#android.add_aars =

# (list) Gradle dependencies to add (currently works only with sdl2_gradle
# bootstrap)
#android.gradle_dependencies =

# (list) add java compile options
# this can for example be necessary when importing certain java libraries using the 'android.gradle_dependencies' option
# see https://developer.android.com/studio/write/java8-support for further information
# android.add_compile_options = "sourceCompatibility = 1.8", "targetCompatibility = 1.8"

# (list) Gradle repositories to add {can be necessary for some android.gradle_dependencies}
# please enclose in double quotes 
# e.g. android.gradle_repositories = "maven { url 'https://kotlin.bintray.com/ktor' }"
#android.add_gradle_repositories =

# (list) packaging options to add 
# see https://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.PackagingOptions.html
# can be necessary to solve conflicts in gradle_dependencies
# please enclose in double quotes 
# e.g. android.add_packaging_options = "exclude 'META-INF/common.kotlin_module'", "exclude 'META-INF/*.kotlin_module'"
#android.add_gradle_repositories =

# (list) Java classes to add as activities to the manifest.
#android.add_activities = com.example.ExampleActivity

# (str) OUYA Console category. Should be one of GAME or APP
# If you leave this blank, OUYA support will not be enabled
#android.ouya.category = GAME

# (str) Filename of OUYA Console icon. It must be a 732x412 png image.
#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png

# (str) XML file to include as an intent filters in <activity> tag
#android.manifest.intent_filters =

# (str) launchMode to set for the main activity
#android.manifest.launch_mode = standard

# (list) Android additional libraries to copy into libs/armeabi
#android.add_libs_armeabi = libs/android/*.so
#android.add_libs_armeabi_v7a = libs/android-v7/*.so
#android.add_libs_arm64_v8a = libs/android-v8/*.so
#android.add_libs_x86 = libs/android-x86/*.so
#android.add_libs_mips = libs/android-mips/*.so

# (bool) Indicate whether the screen should stay on
# Don't forget to add the WAKE_LOCK permission if you set this to True
#android.wakelock = False

# (list) Android application meta-data to set (key=value format)
#android.meta_data =

# (list) Android library project to add (will be added in the
# project.properties automatically.)
#android.library_references =

# (list) Android shared libraries which will be added to AndroidManifest.xml using <uses-library> tag
#android.uses_library =

# (str) Android logcat filters to use
#android.logcat_filters = *:S python:D

# (bool) Copy library instead of making a libpymodules.so
#android.copy_libs = 1

# (str) The Android arch to build for, choices: armeabi-v7a, arm64-v8a, x86, x86_64
android.arch = armeabi-v7a

# (int) overrides automatic versionCode computation (used in build.gradle)
# this is not the same as app version and should only be edited if you know what you're doing
# android.numeric_version = 1

#
# Python for android (p4a) specific
#

# (str) python-for-android fork to use, defaults to upstream (kivy)
#p4a.fork = kivy

# (str) python-for-android branch to use, defaults to master
#p4a.branch = master

# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github)
#p4a.source_dir =

# (str) The directory in which python-for-android should look for your own build recipes (if any)
#p4a.local_recipes =

# (str) Filename to the hook for p4a
#p4a.hook =

# (str) Bootstrap to use for android builds
# p4a.bootstrap = sdl2

# (int) port number to specify an explicit --port= p4a argument (eg for bootstrap flask)
#p4a.port =


#
# iOS specific
#

# (str) Path to a custom kivy-ios folder
#ios.kivy_ios_dir = ../kivy-ios
# Alternately, specify the URL and branch of a git checkout:
ios.kivy_ios_url = https://github.com/kivy/kivy-ios
ios.kivy_ios_branch = master

# Another platform dependency: ios-deploy
# Uncomment to use a custom checkout
#ios.ios_deploy_dir = ../ios_deploy
# Or specify URL and branch
ios.ios_deploy_url = https://github.com/phonegap/ios-deploy
ios.ios_deploy_branch = 1.7.0

# (str) Name of the certificate to use for signing the debug version
# Get a list of available identities: buildozer ios list_identities
#ios.codesign.debug = "iPhone Developer: <lastname> <firstname> (<hexstring>)"

# (str) Name of the certificate to use for signing the release version
#ios.codesign.release = %(ios.codesign.debug)s


[buildozer]

# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

# (int) Display warning if buildozer is run as root (0 = False, 1 = True)
warn_on_root = 1

# (str) Path to build artifact storage, absolute or relative to spec file
# build_dir = ./.buildozer

# (str) Path to build output (i.e. .apk, .ipa) storage
# bin_dir = ./bin

#    -----------------------------------------------------------------------------
#    List as sections
#
#    You can define all the "list" as [section:key].
#    Each line will be considered as a option to the list.
#    Let's take [app] / source.exclude_patterns.
#    Instead of doing:
#
#[app]
#source.exclude_patterns = license,data/audio/*.wav,data/images/original/*
#
#    This can be translated into:
#
#[app:source.exclude_patterns]
#license
#data/audio/*.wav
#data/images/original/*
#


#    -----------------------------------------------------------------------------
#    Profiles
#
#    You can extend section / key with a profile
#    For example, you want to deploy a demo version of your application without
#    HD content. You could first change the title to add "(demo)" in the name
#    and extend the excluded directories to remove the HD content.
#
#[[email protected]]
#title = My Application (demo)
#
#[app:[email protected]]
#images/hd/*
#
#    Then, invoke the command line with the "demo" profile:
#
#buildozer --profile demo android debug

If you want to specify things like the icon, requirements, or loading screen, you should edit this file.

After making all the desired edits to your application, run buildozer -v android debug from your app directory to build and compile your application. This may take a while, especially if you have a slow machine.

After the process is done, your terminal should have some logs, one confirming that the build was successful:

Android successful build

You should also have an APK version of your app in your bin directory. This is the application executable that you will install and run on your phone:

Android APK in the bin directory

Congratulations! If you have followed this tutorial step by step, you should have a simple random number generator app on your phone. Play around with it and tweak some values, then rebuild. Running the rebuild will not take as much time as the first build.

Conclusion

As you can see, building a mobile application with Python is fairly straightforward, as long as you are familiar with the framework or module you are working with. Regardless, the logic is executed the same way: if you want to package the application for other platforms, you can check out the steps here. Keep in mind that for the Apple ecosystem, you’ll need to be on a Mac.

That being said, get familiar with the Kivy module and its widgets. You can never know everything all at once. You need only find a project and get your feet wet as early as possible. Happy coding!

Cut through the noise of traditional error reporting with LogRocket

LogRocket Dashboard Free Trial Banner

LogRocket is a digital experience analytics solution that shields you from the hundreds of false-positive errors alerts to just a few truly important items. LogRocket tells you the most impactful bugs and UX issues actually impacting users in your applications.

Then, use session replay with deep technical telemetry to see exactly what the user saw and what caused the problem, as if you were looking over their shoulder.

LogRocket automatically aggregates client side errors, JS exceptions, frontend performance metrics, and user interactions. Then LogRocket uses machine learning to tell you which problems are affecting the most users and provides the context you need to fix it.

Focus on the bugs that matter — try LogRocket today.

Понравилась статья? Поделить с друзьями:
  • Как написать игру на lazarus
  • Как написать имя на английском поля
  • Как написать имя мухаммад на арабском языке
  • Как написать имя мая на английском
  • Как написать имя красиво на телефоне