Python-安卓应用构建教程-使用-Kivy-和-AndroidStudio-全-

Python 安卓应用构建教程:使用 Kivy 和 AndroidStudio(全)

原文:Building Android Apps in Python Using Kivy with Android Studio

协议:CC BY-NC-SA 4.0

一、为 Android 应用开发准备 Kivy

本章介绍了用 Python 构建跨平台应用的 Kivy 框架。它首先向您展示如何在桌面计算机上准备 Kivy 开发环境。我们将创建一个简单的 Kivy 桌面应用。我们还将安装 Buildozer,并使用它从桌面应用构建一个 Android Studio 项目。创建一个 APK 文件,签名后可以安装在 Android 设备上或部署在 Google Play 上。

什么令人失望?

Kivy 是一个跨平台的 Python 框架,用于构建具有自然用户界面(NUI)的应用。由于是跨平台的,Kivy 代码将在 Windows、Linux、Mac、Android 和 IOS 设备上不变地运行。Kivy 的界面是自然的,这意味着任何用户都可以很容易地与界面自然交互。用户不必花费数小时来学习如何使用该界面。交互可以通过带有非触摸屏的光标或使用多点触摸功能来实现。

Kivy 基于许多创建用户界面的低级库。在本书中,将使用简单的直接媒体层(SDL)。SDK 是一个跨平台的库,用于通过 OpenGL 对图形硬件进行底层访问。其他库也是可用的,比如 PyGame。开发人员不会直接与这些库交互,因为 Kivy 是抽象的。它将开发人员从不必要的细节中分离出来,并提供一个简单的界面来访问设备的功能。例如,在 Java 中,开发人员必须编写大量代码才能访问 Android 摄像头并在 Android Studio 中捕捉图像。在 Kivy,这些细节很多都是隐藏的。通过一种非常简单的方式,只需几行代码,用户就可以捕捉图像、上传和播放音频文件、处理触摸事件等等。

Kivy 也是模块化的。这意味着 Kivy 项目可以被组织成许多独立的模块。模块可以在多个项目中使用。在非模块化的库中,没有正在创建的功能的分离,因此我们必须在每个新的应用中为每个特性重复它们。

Kivy 安装

为了构建一个 Kivy 移动应用,最好先构建一个桌面应用。这样,我们至少知道在我们转移到移动应用之前,事情运行良好。这是因为调试桌面应用更容易。因此,本章从在桌面上准备 Kivy 开发环境开始。

我们可以使用 pip 安装程序来安装 Kivy,它从 Python 包索引(PyPI)中获取库来安装最新的稳定版本,或者指定 GitHub 项目的链接来安装其最新的开发版本。我们需要确保 pip 安装正确。如果您打算使用 Python 2,那么在终端中键入pip。对于 Python 3 使用pip3。因为 Python 2 预装在 Ubuntu 中,所以预计pip命令会成功。如果没有,则使用以下命令:

ahmed-gad@ubuntu:-$sudo apt install python-pip

如果您打算使用 Python 3 并且pip3终端命令失败,您必须使用以下命令安装它:

ahmed-gad@ubuntu:-$sudo apt install python3-pip

安装 pip 后,我们可以用它来安装 Kivy。在安装它之前,我们需要确保安装了 Cython。如果之前没有安装 Cython,请使用pippip3安装。以下是安装 Cython for Python 2 的命令:

ahmed-gad@ubuntu:-$pip install cython

之后,我们可以使用下面的命令开始 Kivy 安装。注意,它也安装在 Python 2 中。

ahmed-gad@ubuntu:-$pip install kivy

目前,我们可以在构建移动应用之前构建 Kivy 桌面应用。让我们从构建 hello world 应用开始。应用显示一个显示"Hello World"文本的窗口。

构建简单的桌面 Kivy 应用

构建 Kivy 应用的第一步是创建一个扩展了kivy.app.App类的新类。根据清单 1-1 ,该类被称为TestApp。第二步是覆盖kivy.app.App类中名为build的函数。这个函数放置运行应用时出现在屏幕上的小部件(GUI 元素)。在这个例子中,使用Label类从kivy.uix.label模块创建一个标签,其文本属性被分配给"Hello World"文本。注意,kivy.uix模块保存了 GUI 上出现的所有 Kivy 小部件。最后,定制类被实例化以运行应用。构建这样一个应用的完整代码如清单 1-1 所示。

import kivy.app
import kivy.uix.label

class TestApp(kivy.app.App):

    def build(self):
        return kivy.uix.label.Label(text="Hello World")

app = TestApp()
app.run()

Listing 1-1Build Your First Kivy Application

如果应用保存在名为test.py的文件中,我们可以使用以下命令从终端运行它:

ahmed-gad@ubuntu:-$python test.py

图 1-1 显示了运行应用后出现的窗口。它只是一个简单的窗口,文本在中间。请注意,Test一词显示在窗口的标题栏中。注意,自定义类名是TestApp,由两个字组成,TestApp。当类中有单词App时,Kivy 自动将应用标题设置为它前面的文本,在本例中是Test

img/481739_1_En_1_Fig1_HTML.jpg

图 1-1

清单 1-1 中应用的窗口

可以使用TestApp类构造函数的 title 参数来更改应用的标题。清单 1-2 对清单 1-1 做了两处修改。第一个变化是将应用的标题设置为Hello。第二个变化是我们可以构建一个没有build()功能的应用。删除此函数,类将为空。为此,添加了pass关键字。

import kivy.app

class TestApp(kivy.app.App):
    pass

app = TestApp(title="Hello")
app.run()

Listing 1-2Changing the Application Title

我们目前使用这个build()函数来保存应用小部件,所以通过删除它,应用窗口将是空的,如图 1-2 所示。在接下来的章节中,将讨论 KV 语言在应用中添加窗口小部件。

img/481739_1_En_1_Fig2_HTML.jpg

图 1-2

运行清单 1-2 中的代码后出现的窗口

安装 Buildozer 并创建 buildozer.init 文件

在创建了这样一个简单的桌面应用并确保一切按预期运行之后,我们可以开始构建移动应用了。为此,我们使用了一个名为 Buildozer 的项目,它负责自动化打包所需工具的过程,使应用在移动端运行。如前所述,我们可以直接使用 pip 来安装最新的稳定版本,也可以指定 GitHub 链接来安装最新的开发版本。我们可以根据以下命令使用 pip 进行安装:

ahmed-gad@ubuntu:-$pip install buildozer

为了构建 Android 应用,Python 文件必须命名为main.py,并且位于项目的根目录下。Buildozer 使用这个文件作为最终 Java 项目中的主要活动。创建这个文件后,我们需要指定一些关于项目的属性。这些属性对于构建 Android 应用非常重要。这些属性被添加到名为buildozer.spec的文件中。您不需要从头开始创建这个文件,因为您可以使用下面的命令自动创建它。只要确保在main.py所在的同一个路径下执行即可(即在项目根目录下执行)。

ahmed-gad@ubuntu:-$buildozer init

假设保存项目文件的文件夹名为NewApp,发出该命令后,项目目录树如下:

  • 新应用
    • bin

    • .buildozer

    • main.py

    • buildozer.spec

除了需求之外,.buildozer文件夹还包含 Android 项目。在我们成功构建项目之后,bin文件夹保存了生成的 APK 文件。

清单 1-3 中给出了buildozer.spec文件的抽象版本。我们来讨论一下现有的字段,一个一个来。title定义了应用标题,在安装应用后显示给用户。package.namepackage.domain字段很重要,因为它们定义了应用在 Google Play 发布时的 ID。source.dir属性表示 Python 源文件main.py的位置。如果它与buildozer.spec文件位于同一个文件夹中,那么它只是被设置为一个点(.)。

[app]

# (str) Title of your application

title = FirstApp

# (str) Package name

package.name = kivyandroid

# (str) Package domain (needed for android/ios packaging)

package.domain = com.gad

# (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,wav

# (list) Source files to exclude (let empty to not exclude anything)

source.exclude_exts = gif

# (list) List of inclusions using pattern matching

#source.include_patterns = assets/∗,img/∗.png

# (list) List of exclusions using pattern matching

#source.exclude_patterns = license,img/∗/∗.jpg

# (list) List of directory to exclude (let empty to not exclude anything)

source.exclude_dirs = bin

# (str) Application versioning (method 1)

version = 0.1

# (list) Application requirements

# comma separated e.g. requirements = sqlite3,kivy

requirements = kivy, numpy

# (str) Custom source folders for requirements

# Sets custom source for any requirements with recipes

requirements.source.numpy = /home/ahmedgad/numpy

# (str) Presplash of the application

presplash.filename = %(source.dir)s/presplash.png

# (str) Icon of the application

icon.filename = %(source.dir)s/logo.png

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

orientation = landscape

#

# OSX Specific

#

# change the major version of python used by the app

osx.python_version = 3

# Kivy version to use

osx.kivy_version = 1.10.1

#

# Android specific

#

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

fullscreen = 1

# (list) Permissions

android.permissions = INTERNET, CAMERA

# (int) Android API to use

android.api = 26

# (int) Minimum API required

android.minapi = 19

# (int) Android SDK version to use

#android.sdk = 27

# (str) Android NDK version to use

#android.ndk = 18b

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

android.ndk_path = /home/ahmedgad/.buildozer/android/platform/android-ndk-r18b

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

android.sdk_path = /home/ahmedgad/.buildozer/android/platform/android-sdk-linux

Listing 1-3Fields Inside the buildozer.spec File

假设我们在项目中使用了一些必须打包在 Android 应用中的资源。这可以用不同的方法来完成。尽管很简单,但如果没有成功完成,可能会浪费几个小时的调试时间。

一种简单的方法是在source.include_exts属性中指定这种资源的扩展。例如,如果所有带有。巴布亚新几内亚。jpg,还有。wav 扩展将被打包在应用中,那么属性将如下所示。如果该字段为空,则将包括根目录中的所有文件。

source.include_exts = png, jpg, wav

source.include_exts属性相反,有一个名为source.exclude_exts的属性定义了要从打包中排除的扩展。如果为空,则不排除任何文件。

还有source.include_patternssource.exclude_patterns分别创建要包含和排除的模式。请注意,它们是使用#进行注释的。

source.exclude_exts类似,有一个名为source.exclude_dirs的属性定义了要排除的目录。例如,bin文件夹是存在的,但是我们对包含它不感兴趣。这减小了 APK 文件的大小。

version属性定义了 Android 应用的版本。当您将应用的新版本上传到 Google Play 时,此属性必须更改为比您之前使用的更高的数字。

requirements属性中,您可以声明在 Python 代码中导入的所需库。例如,如果导入了 NumPy,则 NumPy 必须是该属性中的一项。每个需求将在第一次使用时被下载。

如果您下载了一个需求,并且想要在应用中使用它,而不是下载一个新的,那么您必须在用需求的名称替换了<requirement-name>之后,在requirements.source.<requirement-name>属性中定义这个需求的目录。例如,使用requirements.source.numpy为 NumPy 定义路径。

属性定义了用作应用图标的图像。它可以是 PNG 文件。当加载应用时,会为用户显示一个由presplash.filename属性定义的图像。Kivy 徽标用作默认图像。

orientation属性定义了应用支持的方向。可以设置为landscapeportrait使用一个方向。要根据设备的方向进行设置,请将其设置为all

osx.python_versionosx.kivy_version属性分别定义了正在使用的 Python 和 Kivy 的版本。

如果应用将以全屏模式运行,则将fullscreen属性设置为1。这将在应用运行时隐藏通知栏。

android.permissions属性设置应用运行所需的权限。例如,如果您在应用中访问摄像机,那么必须在该属性中声明CAMERA权限。

最近,谷歌禁止用户上传针对少于 26 个 API 的应用。因此,为了将应用发布到 Google Play,该应用必须面向至少 26 个 API。android.apiandroid.minapi字段定义了要使用的目标和最低 API 版本。重要的是不要将android.api设置为小于 26 的值。

android.sdkandroid.ndk字段设置用于构建应用的 SDK 和 NDK 的版本。如果没有这样的版本,可以下载。您也可以下载这些需求并在android.ndk_path?? 属性中指定它们的路径。

文件中有更多的字段可以帮助你。您可以通过向下滚动到buildozer.spec文件来了解更多信息。从他们的名字可以推断出他们的工作。请注意,系统不会要求您使用文件中的所有字段。只使用你需要的。

Buildozer 模板

注意,使用 Buildozer 构建 Android 应用类似于 Python 和 Android(即 Java)之间的桥梁。开发者创建一个 Python 项目,Buildozer 根据buildozer.spec文件中定义的规范将其转换成 Android Java 项目。为此,Buildozer 拥有根据这些属性中使用的值填充的模板。假设在buildozer.spec文件中指定的包名是kivyandroid,您可以在这里显示的目录中找到模板,因为项目根目录被命名为NewApp

NewApp/.buildozer/android/platform/build/dists/kivyandroid/templates

名为x.y的文件的模板是x.tmpl.y。例如,AndroidManifest.xml文件的模板叫做AndroidManifest.tmpl.xml。用于build.gradle文件的模板叫做build.tmpl.gradle

build.gradle

清单 1-4 中显示了build.tmpl.gradle文件中负责指定目标和最小 API 的部分。minSdkVersion字段保存应用支持的最低 API 级别。targetSdkVersion字段保存目标 API 级别。对于minSdkVersion,如果将变量值{{args.min_sdk_version}}替换为静态值,比如 19,那么无论buildozer.spec file内部的android.minapi属性中指定什么值,最小 API 都将是 19。这个对targetSdkVersion也适用。

android {
   ...
   defaultConfig {
      minSdkVersion {{ args.min_sdk_version }}
      targetSdkVersion {{ android_api }}

      ...
   }
    ...

Listing 1-4Specifying the Target and Minimum APIs in Gradle

AndroidManifest.xml

AndroidManifest.templ.xml文件中,清单 1-5 给出了负责声明在buildozer.spec文件的android.permissions属性中定义的应用权限的部分。第一行允许写入外部存储器。因为这样的权限是绝对的,并且不依赖于在buildozer.spec文件中定义的权限,这意味着即使您没有指定任何权限,这样的权限也是存在的。

    ...
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
{ % for perm in args.permissions %}
{ % if '.' in perm %}
<uses-permission android:name = "{{ perm }}" / >
{ % else %}
<uses-permission android:name = "android.permission.{{ perm }}" />
{ % endif %}
{ % endfor %}
    ...

Listing 1-5Declaring the Application Permission Inside Android Manifest

对于定义的自定义权限,有一个for循环,它遍历android.permissions属性中的每个值。对于每个值,都会为其创建一个<uses-permission>元素。

strings.xml

另一个模板名为strings.tmpl.xml,它负责生成为应用定义字符串资源的strings.xml文件。清单 1-6 显示了这个模板的内容。第一个字符串名为app_name,它定义了应用的名称。通过将{{ args.name }}替换为buildozer.spec文件中title属性的值来检索名称。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">{{ args.name }}</string>
    <string name="private_version">{{ private_version }}</string>
    <string name="presplash_color">{{ args.presplash_color }}</string>
    <string name="urlScheme">{{ url_scheme }}</string>
</resources>

Listing 1-6Specifying the String Resources Inside the strings.xml File

在基于这样的模板准备好所需的文件后,Buildozer 构建 Android 项目。项目的主要活动是PythonActivity.java,它可以在下面的路径中找到。在本书的后面,Android 项目将在 Android Studio 中用于编写一些 Java 功能。

/ NewApp/.buildozer/android/platform/build/dists/kivyandroid/build/src/main/java/org/kivy/android/

在构建应用之前,有一些工具必须可用,如 Android SDK 和 NDK。安装构建 Android 应用所需工具的一个简单方法是使用终端命令,如下所示。它根据buildozer.specandroid.sdkandroid.ndk属性定义的版本下载 SDK 和 NDK。

ahmed-gad@ubuntu:-$buildozer android debug

Buildozer 准备了所有的需求,以保证成功地构建应用。例如,它将所需的 Python 库打包到 APK 中,如果尚未下载,则下载它们。它还获取其他工具,如 SDK、NDK 和 Python-4-Android (P4A)。

如果您的互联网连接速度很快,这个命令会让您的生活更轻松。但是这种方式对于低速互联网连接非常耗时,因此不可靠。如果互联网连接中断,就没有机会继续下载。下载所需的 Python 库并不困难,因为与 SDK 和 NDK 相比,它们的大小并不大。根据我的经验,我浪费了很多时间试图多次下载这样的需求。

更好的解决方案是使用 Buildozer 最小化下载的数据量。这是通过手工(离线)准备大部分需求,然后将它们链接到 Buildozer 来完成的。您可以通过使用支持暂停和恢复下载过程的更可靠的软件来做到这一点。离线准备需求是很有趣的,因为它能够选择您在应用中需要的任何东西的精确版本。离线下载需求的一个有趣的原因是当我们对构建多个共享一些需求的项目感兴趣的时候。每次创建项目时,都会下载相同的需求。或者,我们可以只下载一次需求,并将它们链接到 Buildozer。因此,我们不需要为每个单独的项目下载相同的文件。

准备 Android SDK 和 NDK

从适用于 Linux 的 Android SDK 开始,在指定准确的版本号后,可以从该页面( http://dl.google.com/android/android-sdk_r{{rev}}-linux.tgz )下载。此 URL 使用 SDK 修订版 24 ( http://dl.google.com/android/android-sdk_r24-linux.tgz )。正如扩展名所反映的,它将作为压缩文件下载。

因为下载的 SDK 没有一些组件,包括 SDK 工具、SDK 平台、SDK 平台工具和 SDK 构建工具,所以我们也需要下载它们。SDK 平台用于针对特定的 Android 平台。SDK 平台工具用于支持目标 Android 平台的特性。SDK 构建工具用于构建应用和创建 APK。SDK 工具提供了开发和调试工具。例如,在我们为目标平台构建了 APK 文件之后,这些工具用于运行和调试它。

在指定版本号后,可以从该页面( https://dl-ssl.google.com/android/repository/tools_r{{rev}}-linux.zip )下载 SDK 工具。比如 SDK Tools 22 . 6 . 2 修订版可以从这个页面下载( https://dl-ssl.google.com/android/repository/tools_r22.6.2-linux.zip )。在指定目标平台和版本号后,可以从该页面( https://dl.google.com/android/repository/android-{{platform}}_r{{rev}}.zip )下载 SDK 平台。比如这是下载 SDK 平台 19 修订版 4 的 URL(https://dl.google.com/android/repository/android-19_r04.zip)。SDK 平台工具可以从这个网址( https://dl.google.com/android/repository/platform-tools_r{{rev}}-linux.zip )下载,指定版本号后。比如这是下载 SDK 平台工具修订版 19 的 URL(https://dl.google.com/android/repository/platform-tools_r19.0.1-linux.zip)。

在指定版本号后,可以从这个 URL ( https://dl.google.com/android/repository/build-tools_r{{rev}}-linux.zip )下载 SDK 构建工具。这是 SDK 构建工具 19.1 版本的 URL(https://dl.google.com/android/repository/build-tools_r19.1-linux.zip)。

类似于下载 SDK,NDK 可以从这个网址( http://dl.google.com/android/ndk/android-ndk-r{{rev}}c-linux-x86_64.tar.bz2 )下载。此 URL 对应于 NDK 版本 9 ( http://dl.google.com/android/ndk/android-ndk-r9c-linux-x86_64.tar.bz2 )。

P4A 项目也可以使用 Buildozer 自动下载和安装。但是如果我们对使用项目的开发版本感兴趣,我们不能依赖 Buildozer,而必须从 GitHub 中克隆它( https://github.com/kivy/python-for-android )。

在成功下载需求之后,比如 SDK 及其工具、NDK 和 P4A,还有一个步骤,那就是将它们链接到 Buildozer。这个过程是这样的。

假设安装的 Buildozer 位于/home/ahmedgad/.buildozer/中,压缩后的 Android SDK 和 NDK 会被解压到/home/ahmedgad/.buildozer/android/platform/路径。如果 SDK 和 NDK 文件夹分别命名为android-sdk-linuxandroid-ndk-r9c,那么 SDK 的完整路径就是/home/ahmedgad/.buildozer/android/platform/android-sdk-linux,NDK 的完整路径就是/home/ahmedgad/.buildozer/android/platform/android-ndk-r9c。在 SDK 的 Tools 文件夹中,SDK 工具将被提取出来。下载的 SDK 平台将被解压到 SDK 内的platforms文件夹中。SDK 平台工具将被提取到 SDK 内的platform-tools文件夹中。下载的 SDK 构建工具将被解压到build-tools文件夹中,也在 SDK 中。SDK 的目录树如下,其中...表示父目录中有一些文件和文件夹:

  • android-sdk-linux
    • build-tools

      • 19.1.0
        • . . .
    • platforms

      • android-19
        • . . .
    • platforms-tools

      • . . .
    • tools

      • . . .
    • . . .

请注意,您可以从位于 SDK 工具中的 Android 工具使用 SDK 管理器来管理 SDK。可以使用以下命令访问它:

ahmed-gad@ubuntu:-$. android-sdk-linux/tools/android

Android SDK 管理器如图 1-3 所示。使用管理器,我们可以查看已经安装的工具、可用的更新和可供下载的新工具。确保所需的工具可用。

img/481739_1_En_1_Fig3_HTML.jpg

图 1-3

Android SDK 管理器

之后,我们需要分别使用android.sdk_pathandroid.ndk_path字段在应用的buildozer.spec文件中指定 SDK 和 NDK 的路径。如果克隆的 P4A 项目的路径是/home/ahmedgad/python-for-android 它将被分配给buildozer.spec文件中的p4a.source_dir字段。

准备 Python 需求

先说按项目下载需要的 Python 库。通常,我们不必离线下载它们,因为 Buildozer 本身会下载所有需要的 Python 库,并将它们自动链接到项目。它根据buildozer.spec文件的requirements字段知道需要哪些库。例如,如果我们的项目需要 NumPy 和 Kivy,该字段将如下所示:

requirements = kivy,numpy

只需在该字段中指定所需的库,Buildozer 就会为您下载它们。对于每个库,在/NewApp/.buildozer/android/platform/build/packages路径中创建一个带有其名称的文件夹,其中NewApp是项目的根目录。例如,如果 NumPy 是一个必需的库,那么在这个路径中会有一个名为numpy的文件夹。在每个库的文件夹中,库将以压缩文件的形式下载。这样做的目的是缓存这个库,这样就可以只下载一次,而不是每次构建项目时都下载。注意,到目前为止,这个库还没有安装,只是被下载/缓存了。

注意,库不必存在于packages文件夹中。还有一种方法可以告诉 Buildozer 所需的库驻留在哪里。假设先前在packages文件夹外下载了一个库,我们只想告诉 Buildozer。这个库的路径在buildozer.spec文件中指定。在将LIB_NAME替换为库名后,requirements.source.LIB_NAME字段接受这些库的路径。例如,NumPy 的路径在requirements.source.numpy字段中指定。

假设我们的项目需要 NumPy 1.15.2,我们可以从这个页面( https://github.com/numpy/numpy/releases/download/v1.15.2/numpy-1.15.2.zip )下载。如果这个库位于/home/ahmedgad/numpy中,需要添加到buildozer.spec文件中来帮助构建者使用这个库的字段如下。这个过程可以对所有类型的需求重复进行。

requirements.source.numpy = /home/ahmedgad/numpy

当 Buildozer 构建项目时,它会检查所需的库是否被缓存到了packages文件夹中,或者它的路径是使用requirements.source字段显式指定的。如果找到了这些库,将会使用它们。如果没有,Buildozer 必须将它们下载到packages文件夹中。

在 Buildozer 找到所有需要的库之后,它开始安装它们。库被安装到/NewApp/.buildozer/android/platform/build/build/other_builds路径。类似于创建一个文件夹来保存每个下载的库,该路径中也有一个文件夹来保存已安装的库。例如,将有一个名为numpy的文件夹来保存 NumPy 库的安装。

构建和运行简单的 Kivy Android 应用

在我们安装了所需的库之后,buildozer android debug命令将成功完成,APK 文件将被导出到项目的bin目录中。我们可以将这个文件复制到 Android 设备上,安装它,然后运行应用。我们还可以使用这个命令在 Android 设备上自动构建、部署和运行应用:

ahmed-gad@ubuntu:-$buildozer android debug deploy run

这需要使用 USB 电缆将 Android 设备连接到机器,并启用 USB 调试。要在设备中启用 USB 调试,请转到设置,选择列表末尾的开发人员选项项,并将 USB 调试复选框的状态切换为打开。如果“开发人员选项”项不可见,请转到“设置”下的“关于设备”项,并按七次 tab 键。(注意,根据 Android OS 版本,步骤可能会有一些变化。)

我们还可以通过将logcat附加到该命令来查看设备的日志,如下所示:

ahmed-gad@ubuntu:-$buildozer android debug deploy run logcat

图 1-4 显示了应用在 Android 设备上运行后的外观。

img/481739_1_En_1_Fig4_HTML.jpg

图 1-4

在 Android 中运行 Kivy 应用

摘要

到本章结束时,我们已经使用 Kivy 跨平台 Python 库成功创建了一个非常简单的 Android 应用。请记住,相同的 Python 代码用于创建桌面和移动应用。我们通过在台式计算机上准备 Kivy 开发环境来开始应用开发过程。然后,我们创建了一个简单的应用来检查一切是否按预期运行。这个应用只是在一个窗口中显示文本。

成功运行桌面应用后,我们开始移动(Android)应用部署。它是通过安装 Buildozer 为 Kivy 应用生产 Android Studio 项目开始的。Kivy 项目属性在一个名为buildozer.spec的文件中指定。这些属性包括应用标题、请求的权限、所需的资源、Android SDK 和 NDK 的路径等等。该项目包括 Android Studio 所需的所有资源,如 Gradle、Manifest、Strings 等。为这个项目创建了一个 APK 文件,它可以安装在 Android 设备上,甚至可以在签署后部署在 Google Play 上。

二、使用 KV 语言实现逻辑和图形界面的分离

在前一章中,我们准备了 Kivy 开发环境并创建了一个非常简单的桌面应用。然后我们安装了 Buildozer 来构建移动应用。正如我们之前讨论的,成功构建的工具和需求可以使用 Buildozer 自动安装,也可以离线下载并链接。通过准备 Android SDK、NDK 和所需的 Python 库,我们可以成功地构建一个使用与桌面应用相同的 Python 代码的 Android 应用。这是因为 Kivy 是一个跨平台库。

在这一章中,我们将通过在 GUI 上放置更多的小部件来丰富我们的应用,并添加受益于这些小部件接收的数据的逻辑。开始时,GUI 部件将使用 Python 代码添加到kivy.app.App类的build()函数中。我们将创建一个具有三个小部件(按钮、文本输入和标签)的应用。一旦按下按钮,文本输入的数据将显示在文本标签上。随着我们添加更多的小部件,Python 代码将变得复杂,从而更难调试。出于这个原因,我们将使用 KV 语言将 GUI 与 Python 逻辑分开。这样,Python 代码将专用于逻辑。我们开始吧。

将 TextInput 小部件添加到 GUI

在前一章中,我们创建了一个只有一个静态标签部件的应用。在本章中,我们首先添加一个名为TextInput的新部件。从它的名字可以很容易地推断出这个小部件允许用户输入文本。通过实例化kivy.uix.textinput.TextInput类来创建TextInput小部件。这个类的构造函数接收一个名为text的属性,它是小部件中的默认文本。清单 2-1 用这个小部件创建了一个完整的应用。

import kivy.app
import kivy.uix.textinput

class TestApp(kivy.app.App):

    def build(self):
        return kivy.uix.textinput.TextInput(text="Hello World")

app = TestApp()
app.run()

Listing 2-1Adding a TextInput Widget to the GUI

如果运行这个 Python 脚本,将会显示如图 2-1 所示的窗口。整个窗口只是文本输入。您可以编辑输入的文本。

img/481739_1_En_2_Fig1_HTML.jpg

图 2-1

在清单 2-1 中创建的应用的窗口

我们可以在 GUI 中添加一个按钮小部件。点击按钮,文本输入的数据将被接收并打印在print语句中。现在有一个问题。在build()函数中,只返回一个小部件。现在我们需要返回两个小部件(按钮和文本输入)。我们如何做到这一点?解决方案是将这些小部件添加到一个容器中,然后返回这个容器。Kivy 中的容器是布局。

Kivy 中有不同类型的布局,比如框、相对、网格、堆栈等等。每种布局都有其排列内部子部件的方式。例如,box 布局垂直或水平排列子元素。网格布局将窗口分割成由行和列组成的矩阵,然后在矩阵单元中插入小部件。默认情况下,窗口平均分配给所有小部件。

通过在 Python 中添加更多小部件来丰富 GUI 应用

清单 2-2 中的代码创建了一个应用,其中按钮和文本输入被插入到一个框布局中。应用的 GUI 是在build()函数中创建的。首先,按钮被创建为来自kivy.uix.button.Button类的一个实例。它接受参数text,该参数接受显示在按钮上的文本。按钮和文本输入都保存在变量中以备后用。按钮部件保存在my_button变量中,而文本输入保存在text_input变量中。

盒子布局是作为来自kivy.uix.boxlayout.BoxLayout类的实例创建的。它保存在box_layout变量中。为了指定小部件是垂直还是水平添加到布局中,在类构造函数中指定了orientation参数。其默认值为horizontal 表示小部件将从左向右水平插入。在这个例子中,方向被设置为垂直,所以小部件将从上到下插入,其中插入的第一个元素将被放置在窗口的顶部,插入的最后一个元素将被放置在窗口的底部。通过指定要添加的微件的名称,使用add_widget功能将微件插入布局。最后,布局返回。

import kivy.app
import kivy.uix.textinput
import kivy.uix.button
import kivy.uix.boxlayout

class TestApp(kivy.app.App):

    def build(self):
        my_button = kivy.uix.button.Button(text="Click me")
        text_input = kivy.uix.textinput.TextInput(text="Data inside TextInput")

        box_layout = kivy.uix.boxlayout.BoxLayout(orientation="vertical")
        box_layout.add_widget(widget=button)
        box_layout.add_widget(widget=textInput)

        return box_layout

app = TestApp()
app.run()

Listing 2-2Adding More Than One Widget to the Application

运行应用后,将出现如图 2-2 所示的窗口。它被垂直分为两个大小相等的部分,并根据布局中的放置顺序进行放置。

img/481739_1_En_2_Fig2_HTML.jpg

图 2-2

在清单 2-2 中创建的应用窗口

到目前为止,单击该按钮不会导致任何操作。使用bind()功能来处理按钮动作。它接受反映动作的参数。要处理按钮按下动作,使用on_press参数。该参数被分配给一个函数,该函数在动作被触发时被调用。

处理按钮按压

清单 2-3 中的代码创建了一个名为button_press()的函数,当按钮被按下时该函数被调用。它接受触发动作的小部件作为参数。该功能附加在按钮上,以便在按下按钮时执行。该函数在每次按下按钮时打印一条消息,根据press_count变量显示按钮被按下的次数。它在函数调用结束时递增。

import kivy.app
import kivy.uix.textinput
import kivy.uix.button
import kivy.uix.boxlayout

class TestApp(kivy.app.App):

    press_count = 1
    def button_press(self, button_pressed):
        print("Button Pressed", TestApp.press_count, "Times")
        TestApp.press_count = TestApp.press_count + 1

    def build(self):
        my_button = kivy.uix.button.Button(text="Click me")
        my_button.bind(on_press=TestApp.button_press)
        text_input = kivy.uix.textinput.TextInput(text="Data inside TextInput")

        box_layout = kivy.uix.boxlayout.BoxLayout(orientation="vertical")
        box_layout.add_widget(widget=my_button)
        box_layout.add_widget(widget=text_input)

        return box_layout

app = TestApp()
app.run()

Listing 2-3Handling a Button Press

按下按钮四次后打印的信息如图 2-3 所示。

img/481739_1_En_2_Fig3_HTML.jpg

图 2-3

每次按下按钮时都会打印一条消息

从文本输入接收数据

可以修改应用,以便打印插入到TextInput小部件中的文本。如前所述,当按钮被按下时,它的回调函数button_press()将被调用。在这个函数中,可以返回并打印TextInput小部件中的文本。为了能够访问该函数中的TextInput小部件,该小部件存储在关键字self引用的当前对象中。新应用的代码如清单 2-4 所示。

import kivy.app
import kivy.uix.textinput
import kivy.uix.button
import kivy.uix.boxlayout

class TestApp(kivy.app.App):

    def button_press(self, button_pressed):
        input_data = self.text_input.text
        print(input_data)

    def build(self):
        my_button = kivy.uix.button.Button(text="Click me")
        my_button.bind(on_press=self.button_press)
        self.text_input = kivy.uix.textinput.TextInput(text="Data inside TextInput")

        box_layout = kivy.uix.boxlayout.BoxLayout(orientation="vertical")
        box_layout.add_widget(widget=my_button)
        box_layout.add_widget(widget=self.text_input)

        return box_layout

app = TestApp()
app.run()

Listing 2-4Receiving Text from the TextInput upon a Button Press

按下按钮后,将使用text属性获取TextInput小部件中的当前文本,并打印到终端。

在文本标签上显示文本

在当前状态下,我们必须打开窗口,按下按钮,然后到终端上查看打印出来的消息。如果再次按下按钮,我们必须到终端查看输出,等等。我们可以通过在窗口内部的标签部件上打印消息来使生活变得更容易。因此,我们根本不需要打开终端。与上一个应用相比,所做的更改包括使用kivy.uix.label.Label类创建一个新的标签小部件,将其添加到框布局中,将其附加到当前对象(self)以便在button_press()函数中访问它,并根据从TextInput小部件接收的输入更改其文本。新的应用如清单 2-5 所示。

import kivy.app
import kivy.uix.label
import kivy.uix.textinput
import kivy.uix.button
import kivy.uix.boxlayout

class TestApp(kivy.app.App):

    def button_press(self, button_pressed):
        self.text_label.text = self.text_input.text

    def build(self):
        self.text_input = kivy.uix.textinput.TextInput(text="Data inside TextInput")
        my_button = kivy.uix.button.Button(text="Click me")
        my_button.bind(on_press=self.button_press)
        self.text_label = kivy.uix.label.Label(text="Waiting for Button Press")

        box_layout = kivy.uix.boxlayout.BoxLayout(orientation="vertical")
        box_layout.add_widget(widget=self.text_label)
        box_layout.add_widget(widget=my_button)
        box_layout.add_widget(widget=self.text_input)

        return box_layout

app = TestApp()
app.run()

Listing 2-5Adding a Label Widget to the Application

应用窗口如图 2-4 所示。当按下按钮时,TextInput小工具内的文本显示在标签上。

img/481739_1_En_2_Fig4_HTML.jpg

图 2-4

按下按钮后,在标签小部件中显示 TextInput 中的文本

嵌套小部件

对于最后一个应用,在 box 布局中只添加了三个小部件作为子部件。因为此布局的方向设置为垂直,所以窗口高度将在三个子窗口中平均分配,但每个子窗口将占据窗口的整个宽度。换句话说,每个孩子将占用窗口高度的三分之一,但会扩展窗口的整个宽度。调试清单 2-5 中的代码非常容易,因为只有几个小部件。通过添加更多的小部件,代码变得更加复杂,难以调试。我们可以在清单 2-6 中的下一个应用中添加更多的小部件。在前面的应用中,每个小部件占据了整个窗口的宽度。在这个应用中,窗口的宽度分为两个小部件。

在清单 2-6 的应用代码中,窗口将有一个名为box_layout的垂直方向的根框布局。这个布局将有三个子布局。这个布局的顶层子元素是一个名为text_label的标签。剩下的两个子节点是名为box_layout1box_layout2的盒子布局(子节点本身就是布局)。每个子框布局的方向都是水平的(也就是说,子框是从左向右插入的)。每个子布局将有两个子部件(按钮和文本输入)。当每个子布局的按钮被按下时,同级TextInput小部件内的文本将显示在标签上。

import kivy.app
import kivy.uix.label
import kivy.uix.textinput
import kivy.uix.button
import kivy.uix.boxlayout

class TestApp(kivy.app.App):
    def button1_press(self, button_pressed):
        self.text_label.text = self.text_input1.text

    def button2_press(self, button_pressed):
        self.text_label.text = self.text_input2.text

    def build(self):
        self.text_label = kivy.uix.label.Label(text="Waiting for Button Press")

        self.text_input1 = kivy.uix.textinput.TextInput(text="TextInput 1")
        my_button1 = kivy.uix.button.Button(text="Click me")
        my_button1.bind(on_press=self.button1_press)

        self.text_input2 = kivy.uix.textinput.TextInput(text="TextInput 2")
        my_button2 = kivy.uix.button.Button(text="Click me")
        my_button2.bind(on_press=self.button2_press)

        box_layout = kivy.uix.boxlayout.BoxLayout(orientation="vertical")

        box_layout1 = kivy.uix.boxlayout.BoxLayout(orientation="horizontal")
        box_layout1.add_widget(widget=self.text_input1)
        box_layout1.add_widget(widget=my_button1)

        box_layout2 = kivy.uix.boxlayout.BoxLayout(orientation="horizontal")
        box_layout2.add_widget(widget=self.text_input2)
        box_layout2.add_widget(widget=my_button2)

        box_layout.add_widget(self.text_label)
        box_layout.add_widget(box_layout1)
        box_layout.add_widget(box_layout2)

        return box_layout

app = TestApp()
app.run()

Listing 2-6Creating Nested Widgets

图 2-5 显示了运行清单 2-6 中的应用后的窗口。每个按钮都有一个回调函数。例如,button1_press()与第一个按钮(my_button1)关联。当按下给定盒子布局中的一个按钮时,来自同一个盒子布局中的TextInput小部件的文本显示在标签上。

img/481739_1_En_2_Fig5_HTML.jpg

图 2-5

嵌套小部件

在添加了更多的 widget 之后,显然很难推导出应用的 widget 树。例如,确定给定父母的子女并不容易。因此,接下来我们将使用 KV 语言,它以结构化的方式构建 GUI 的小部件树。

使用 KV 语言

KV 语言(kvlang 或 Kivy 语言)以可读的方式创建了一个小部件树,帮助我们调试应用的 GUI。它使用缩进来标记给定父级的子级。它还使用缩进来标记给定小部件的属性。使用 KV 语言的另一个好处是 Python 逻辑与 GUI 的分离。微件树创建在扩展名为.kv的文件中。因此,我们可以独立于 Python 代码来修改小部件树。注意,我们不必将模块导入 KV 文件来使用小部件。比如为了用一个盒子布局,我们就写BoxLayout

在 KV 文件中,总是有一个没有任何缩进的小部件。这是根小部件,它对应于清单 2-6 中代码所示的box_layout小部件。这个小部件的属性和子部件缩进四个空格。清单 2-7 显示了清单 2-6 中先前应用的 KV 文件的内容。

BoxLayout:
    orientation: "vertical"
    Label:
        text: "Waiting for Button Press"
        id: text_label
    BoxLayout:
        orientation: "horizontal"
        TextInput:
            text: "TextInput 1"
            id: text_input1
        Button:
            text: "Click me"
            on_press: app.button1_press()
    BoxLayout:
        orientation: "horizontal"
        TextInput:
            text: "TextInput 2"
            id: text_input2
        Button:
            text: "Click me"
            on_press: app.button2_press()

Listing 2-7Using the KV Language to Separate the Python Logic from the GUI

根据所需的顺序将小部件添加到树中,以产生与上一个应用相同的结果。值得一提的是,Python 代码中需要引用的字段都被赋予了 id。它们是TextInputLabel小部件。此外,使用on_press属性将on_press动作附加到按钮上,该属性被分配给一个使用关键字app调用的函数。kvlang 中的这个关键字指的是使用这个 KV 文件的 Python 文件。因此,app.button1_press()意味着调用链接到这个 KV 文件的 Python 文件内部名为button1_press的函数。这里的问题是如何将 Python 文件链接到 KV 文件。这很容易。

在 Python 文件中创建的类被命名为TestApp。Kivy 提取单词App之前的文本,即Test。在将文本转换成小写(Test变成了test)之后,Kivy 在 Python 文件的同一个文件夹中搜索一个名为test.kv的 KV 文件。如果找到这样的文件,Kivy 会将它隐式链接到 Python 文件。如果没有找到,应用将启动,但会有一个空白窗口。请注意,build()功能被删除。如果 Python 代码中存在这个函数,而 Kivy 没有找到 KV 文件,那么应用将不会运行。

在 KV 文件中创建小部件树之后,Python 代码如清单 2-8 所示。现在 Python 代码调试起来非常简单。无论小部件树是使用 Python 还是 KV 语言创建的,应用的工作方式都是一样的。

需要注意的是如何在 Python 代码中访问 KV 文件中创建的小部件。一旦小部件被赋予一个 ID,您就可以使用root.ids字典来引用它。关键字root指的是 KV 文件中的根框布局小部件。通过按所需小部件的 ID 索引字典,它将被返回,因此我们能够访问它的属性并覆盖它。

import kivy.app

class TestApp(kivy.app.App):

    def button1_press(self):
        self.root.ids['text_label'].text = self.root.ids['text_input1'].text

    def button2_press(self):
        self.root.ids['text_label'].text = self.root.ids['text_input2'].text

app = TestApp()
app.run()

Listing 2-8Python Code for the Application in Listing 2-6 After Defining the GUI in a KV File

使用 load_file()调用 KV

假设 KV 文件的名称不是test.kv而是test1.kv,Kivy 将无法隐式定位 KV 文件。在这种情况下,我们必须显式指定来自kivy.lang.Builder类的load_file()函数内的文件路径,如清单 2-9 所示。这个函数的结果由build()函数返回。

import kivy.app
import kivy.lang

class TestApp(kivy.app.App):

    def button1_press(self):
        self.root.ids['text_label'].text = self.root.ids['text_input1'].text

    def button2_press(self):
        self.root.ids['text_label'].text = self.root.ids['text_input2'].text

    def build(self):
        return kivy.lang.Builder.load_file("test1.kv")

app = TestApp()
app.run()

Listing 2-9Explicitly Specifying the Path of the KV File

使用 load_string()调用 KV

还可以使用kivy.lang.Builder类中的load_string()函数在 Python 文件中编写 KV 语言代码。代码用三重引号括起来,如清单 2-10 所示。请注意,不建议使用这种方式,因为它没有将逻辑与可视化分开。

import kivy.app
import kivy.lang

class TestApp(kivy.app.App):

    def button1_press(self):
        self.root.ids['text_label'].text = self.root.ids['text_input1'].text

    def button2_press(self):
        self.root.ids['text_label'].text = self.root.ids['text_input2'].text

    def build(self):
        return kivy.lang.Builder.load_string(

"""

BoxLayout:

    orientation: "vertical"
    Label:
        text: "Waiting for Button Press"
        id: text_label
    BoxLayout:
        orientation: "horizontal"
        TextInput:
            text: "TextInput 1"
            id: text_input1
        Button:
            text: "Click me"
            on_press: app.button1_press()
    BoxLayout:
        orientation: "horizontal"
        TextInput:
            text: "TextInput 2"
            id: text_input2
        Button:
            text: "Click me"
            on_press: app.button2_press()
""")

app = TestApp()
app.run()

Listing 2-10Adding the KV Language Code Within the Python File

注意,本章并没有创建 Android 应用;重点是添加更多的小部件和构建应用。不要担心根据本章讨论的内容构建 Android 应用,因为它非常简单。准备好buildozer.spec文件后,按照第一章中讨论的步骤使用 Buildozer 构建 APK 文件。

摘要

既然我们已经到了本章的末尾,让我们快速回顾一下前两章讨论的内容。在前一章中,我们为开发桌面应用准备了 Kivy 环境。然后我们安装了 Buildozer 来开发 Android 应用。我们简单地从创建一个单标签小部件显示文本的例子开始。之后,使用布局将更多的小部件添加到应用中。使用on_press处理按钮按压动作。由于我们在小部件树中嵌套了更多的小部件,调试应用变得更加困难。出于这个原因,引入了 KV 语言,这样我们就可以构建小部件树,并将 GUI 从 Python 逻辑中分离出来。

在下一章中,我们将介绍相机小部件,这样我们就可以非常容易地访问相机。在确保成功创建桌面应用之后,我们将转到构建相应的 Android 应用,并看看如何使用 Kivy 非常直观地访问 Android 相机小部件。因为访问 Android 摄像头需要权限,下一章讨论在buildozer.spec文件内部添加权限。这些权限将反映在 Google Play 中,供任何用户在安装应用之前查看。下一章还讨论了 Kivy 的一个比较重要的特性,就是 Canvas。Canvas 用于在小部件上绘图和进行转换。

三、将安卓相机共享给一个 HTTP 服务器

在前两章中,我们为开发桌面应用准备了 Kivy 环境。在确保一切按预期运行之后,我们安装了 Buildozer 来构建 Android 应用。我们在书中创建的第一个应用非常简单;一个标签小部件显示了一些文本。之后,使用布局将更多的小部件添加到应用中。使用on_press处理按钮按压动作。创建了一个嵌套的小部件树,这使得调试变得更加复杂。出于这个原因,KV 语言被引入来构建小部件树,并将 GUI 与 Python 逻辑分离开来。

本章讨论如何访问和使用 Android 摄像头来捕捉图像并与 HTTP 服务器共享图像。Camera小部件用于访问 Android 摄像头。在确保桌面上的一切都正常工作后,我们使用 Buildozer 来构建 Android 应用。适当的权限在buildozer.init文件中指定。默认情况下,Android 摄像头旋转 90 度,Kivy 画布用于处理这个问题。将讨论三个画布实例— canvascanvas.beforecanvas.after。为了将一条给定指令的效果限制在某些小部件上,我们讨论了PushMatrixPopMatrix指令。

在适当的角度预览相机后,将捕获图像,以便将它们上传到 HTTP 服务器。服务器是使用 Flask 创建的,并在台式计算机上运行。使用服务器的 IPv4 地址和端口号,对 Python 库的请求将使用 Kivy Android 应用和 HTTP POST消息上传捕获的图像。

本章最后在服务器的网络浏览器中创建一个 Android 摄像头的实时预览。为了节省时间,图像以字节数组的形式保存在设备存储器中,而不是保存在设备存储器中。这样的字节数组然后被上传到服务器。然后,服务器解释这些字节数组,以便在 web 浏览器的 HTML 页面上显示图像。

绝望的相机小部件

Python 中有不同的库可以访问相机,比如 OpenCV 和 PyGame。Kivy 还支持一个名为Camera的小部件来访问相机。它更简单,因为它不要求使用库。使用小部件的 APK 文件比打包库的 APK 文件小。

清单 3-1 显示了在BoxLayout根小部件中有一个Camera小部件的 KV 文件。使用resolution属性指定首选分辨率。如果可能,以 1280x720 的分辨率捕捉图像。请注意Camera小部件大小和分辨率之间的差异。小部件大小设置应用 GUI 上小部件的大小,而分辨率定义捕获图像的像素密度。

play属性指定是否在应用启动后播放摄像机。如果设置为True,相机将在应用启动后播放。这个简单的 KV 文件是访问摄像机所需的最少代码。

BoxLayout:
    Camera:
        resolution: 1280,720
        play: True

Listing 3-1Adding the Camera Widget to the Widget Tree

与这个 KV 文件相关的 Python 代码也非常简单,如清单 3-2 所示。只需创建一个扩展了kivy.app.App类的类,并覆盖它的build()函数。因为这个函数是空的,所以将 Python 代码链接到 KV 文件的唯一方法是将其命名为test.kv

import kivy.app

class TestApp(kivy.app.App):

    def build(self):
        pass

app = TestApp()
app.run()

Listing 3-2Python File Associated with the KV File in Listing 3-1

应用窗口如图 3-1 所示。

img/481739_1_En_3_Fig1_HTML.jpg

图 3-1

使用相机小部件访问相机

访问 Android 摄像头

至此,我们已经创建了一个访问摄像机的桌面应用。让我们开始构建 Android 应用。

如第一章所述,应用的 Python 文件必须命名为main.py。为了获得访问 Android 摄像头的权限,android.permissions字段必须指定如下权限:

android.permissions=Camera

之后,可以根据下面的命令使用 Buildozer 构建 Android 应用。请记住,该命令在调试模式下构建 APK 文件,将其部署到 USB 连接的 Android 设备,并在安装后运行应用。

ahmed-gad@ubuntu:-$buildozer android debug deploy run

图 3-2 显示了使用 Android 应用捕获的图像之一。逆时针旋转 90 度。要解决这个问题,小部件必须顺时针旋转 90 度。因为顺时针旋转使用负角度,所以需要旋转-90 度。Kivy 支持将变换应用于其小部件的画布。

img/481739_1_En_3_Fig2_HTML.jpg

图 3-2

使用通过相机小部件访问的 Android 相机捕捉图像

不鼓励的画布

我们绘制的区域通常被称为画布。在 Kivy 中,画布是定义小部件图形表示的指令容器,而不是绘图区域。Kivy 中有三个画布实例— canvascanvas.beforecanvas.after。因此,可以为每个小部件分配这三个不同的实例。

在这三个实例中,画布可以执行两种类型的指令— contextvertex。顶点指令画在部件上。例如,如果要在一个小部件上绘制一个矩形或一条直线,它就是一个顶点指令。上下文指令不画任何东西,只是改变事物在屏幕上的显示方式。例如,上下文指令可以通过改变小部件的旋转、平移和缩放来变换小部件。

在添加 canvas 指令之前,必须将一个canvas实例附加到感兴趣的小部件。之后,我们可以添加说明。例如,清单 3-3 中的代码将canvas实例附加到一个Label小部件,并使用Rectangle顶点指令绘制一个矩形。

BoxLayout:
    Label:
        canvas:
            Rectangle:
                pos: 0,0
                size: 200, 200

Listing 3-3Adding Canvas to the Label Widget to Draw a Rectangle

矩形放置在像素(0,0)处,该像素是左下角对应的 Kivy 坐标系的原点(除了RelativeLayoutScatterLayout)。矩形的宽度和高度设置为 200 像素。因此,矩形从左下角开始,在水平和垂直方向延伸 200 个像素。图 3-3 为矩形。

img/481739_1_En_3_Fig3_HTML.jpg

图 3-3

在标签小部件上绘制一个矩形

我们可以使用Color上下文指令改变矩形的颜色。该指令使用rgb属性接受 RGB 颜色,其中每个通道被赋予一个介于 0 和 1 之间的值。在清单 3-4 中,红色是指定的颜色。

理解上下文指令应用于窗口小部件和它们下面的顶点指令是非常重要的。如果在上下文指令之前添加了小部件或顶点指令,则不会应用上下文指令。在本例中,如果在Rectangle顶点指令之后添加了Color顶点指令,矩形将被涂成红色。

BoxLayout:
    Label:
        canvas:
            Color:
                rgb: 1, 0, 0
            Rectangle:
                pos: root.pos
                size: 200,200

Listing 3-4Using the Color Context Instruction to Change the Rectangle Color

使用清单 3-4 中的 KV 文件运行应用后,结果如图 3-4 所示。矩形根据Color指令着色。

img/481739_1_En_3_Fig4_HTML.jpg

图 3-4

在标签小部件上绘制一个红色矩形

我们可以根据清单 3-5 为第二个标签小部件重复前面的指令。应用的颜色是绿色而不是红色,矩形位于窗口的中心。

BoxLayout:
    Label:
        canvas:
            Color:
                rgb: 1, 0, 0
            Rectangle:
                pos: root.pos
                size: 200,200
    Label:
        canvas:
            Color:
                rgb: 0, 1, 0
            Rectangle:
                pos: root.width/2-100, root.height/2-100
                size: 200,200

Listing 3-5Two Label Widgets Assigned to Two Canvas Instances to Draw Two Rectangles

应用窗口如图 3-5 所示。

img/481739_1_En_3_Fig5_HTML.jpg

图 3-5

使用两条矩形指令绘制两个矩形

使用完顶点指令后,我们可以开始使用第二类指令,也就是上下文。非常重要的是要注意,在确定应该在哪里应用指令之前,必须应用上下文指令。假设我们旋转一个使用Rectangle vertex 指令创建的矩形。在这种情况下,必须在Rectangle指令之前添加旋转矩形的上下文指令。如果上下文指令添加在Rectangle指令之后,矩形将不会改变。这是因为上下文指令仅在渲染绘图之前有效。渲染绘图后,上下文指令不起作用。

旋转小部件的上下文指令称为Rotate。根据清单 3-6 ,这个上下文指令加在Rectangle顶点指令之前旋转矩形。使用Rotate指令的angle属性,旋转到-45,顺时针旋转。旋转轴(或多个轴)可以使用axis属性定义。值 0,0,1 表示绕 Z 轴旋转。

默认情况下,旋转相对于坐标系的原点(0,0)。在这个例子中,我们对围绕(0,0)点旋转不感兴趣,而是围绕窗口中心点旋转。使用origin属性,我们可以改变旋转原点到窗口的中心。

BoxLayout:
    Label:
        canvas:
            Color:
                rgb: 1, 0, 0
            Rectangle:
                pos: root.pos
                size: 200,200
    Label:
        canvas:
            Color:
                rgb: 0, 1, 0
            Rotate:
                angle: -45
                axis: 0,0,1
                origin: root.width/2, root.height/2
            Rectangle:
                pos: root.width/2, root.height/2
                size: 200,200

Listing 3-6Using the Rotation Context Instruction to Rotate the Rectangle

图 3-6 显示了旋转矩形后的结果。

img/481739_1_En_3_Fig6_HTML.jpg

图 3-6

使用旋转上下文指令旋转矩形

在前面的例子中,为了影响绘图,上下文指令如ColorRotate必须在顶点指令如Rectangle之前添加到画布实例中。顶点指令必须写入 KV 文件中放置目标小部件的行之前的一行。例如,如果小部件位于第 5 行,那么顶点指令必须位于第 5 行之前,而不是之后。在前面的例子中,我们能够控制上下文指令在顶点指令之前的位置。在某些情况下,这是不可能的。

让我们考虑清单 3-7 中所示的应用,其中我们想要旋转一个按钮。

BoxLayout:
    Button:
        text: "Rotate this Button"

Listing 3-7A Button To Be Rotated

如果根据清单 3-8 将 canvas 实例添加到Button小部件中,那么 canvas 实例中的Rotate上下文指令将被添加到我们想要旋转的Button小部件之后,而不是之前。因此Rotate上下文指令不会影响小部件。我们需要在小部件之前而不是之后添加上下文指令。我们将讨论这个问题的两种解决方案。

BoxLayout:
    Button:
        text: "Rotate this Button"
        canvas:
            Rotate:
                angle: 45
                origin: root.width/2, root.height/2

Listing 3-8The Rotate Context Instruction Has Been Added After the Button Widget and Thus Does Not Affect It

对于给定的父部件,添加到 canvas 实例中的指令不仅会应用到父部件,还会应用到子部件。基于这个特征,我们可以找到我们的第一个解决方案。如果我们想在给定的小部件上执行上下文指令,我们可以将该指令添加到其父部件的 canvas 实例中。这样的指令将影响父部件和它的子部件。清单 3-9 实现了这个解决方案。注意,上下文指令不仅影响使用顶点指令如Rectangle绘制的内容,还会影响小部件。

BoxLayout:
    canvas:
        Rotate:
            angle: 45
            origin: root.width/2, root.height/2
    Button:
        text: "Rotate this Button"

Listing 3-9Placing the Canvas Instance Inside the Parent Widget in Order to Affect Its Children

结果如图 3-7 所示。我们成功地解决了这个问题,但是还有一个问题。

img/481739_1_En_3_Fig7_HTML.jpg

图 3-7

将画布添加到其父级后,按钮小部件旋转成功

之前的解决方案不仅旋转了按钮,还旋转了它的父按钮。如果有另一个孩子而不是按钮,它也会被旋转。清单 3-10 中显示的 KV 文件有一个不应该旋转的Label小部件。不幸的是,它会被旋转,如图 3-8 所示。

img/481739_1_En_3_Fig8_HTML.jpg

图 3-8

旋转上下文指令影响按钮和标签小部件

BoxLayout:
    canvas:
        Rotate:
            angle: 45
            origin: root.width/2, root.height/2
    Label:
        text: "Do not Rotate this Label"
    Button:
        text: "Rotate this Button"

Listing 3-10Adding the Context Instruction to the Parent Widget Affects All of its Children

画布.之前

前面的解决方案是将上下文指令添加到父部件,这会影响所有子部件。没有办法只将这种效果应用于特定的孩子。为了解决这个问题,这个解决方案将根据清单 3-11 中的 KV 文件使用canvas.before实例而不是canvas。该小部件中的指令将在呈现小部件之前执行。因此,如果在其中添加了Rotate内容指令,Button小部件将会成功旋转。

BoxLayout:
    Label:
        text: "Do not Rotate this Label"
    Button:
        text: "Rotate this Button"
        canvas.before:
            Rotate:
                angle: 45
                origin: root.width/2, root.height/2

Listing 3-11Using canvas.before Rather Than canvas to Rotate the Button Widget

应用窗口如图 3-9 所示。仅旋转Button小部件;Label保持不变。

img/481739_1_En_3_Fig9_HTML.jpg

图 3-9

使用 canvas.before 实例仅旋转一个子级

在前面的例子中,有一个技巧。我们要旋转的小部件被添加到小部件树的末尾,这就是为什么Label不受旋转的影响。如果在Button之后添加了Label,那么ButtonLabel小部件将被旋转。修改后的代码如清单 3-12 所示,应用窗口如图 3-10 所示。为什么Label widget 被旋转了?

img/481739_1_En_3_Fig10_HTML.jpg

图 3-10

在 canvas.before 被添加到 Label 之前后,按钮和标签小部件被旋转

BoxLayout:
    Button:
        text: "Rotate this Button"
        canvas.before:
            Rotate:
                angle: 45
                origin: root.width/2, root.height/2
    Label:
        text: "Do not Rotate this Label"

Listing 3-12Placing the canvas.before Instruction Before the Label Widget

Kivy 中的画布指令不限于它们被添加到的小部件。一旦一个指令被添加到任何一个小部件中,它就会影响到其他的小部件,直到有什么东西取消了这个指令的效果。例如,如果Button小部件旋转 45 度,那么它后面的小部件也将旋转 45 度。如果Label小部件出现在Button之后,我们不希望它被旋转,我们可以将Label小部件旋转-45 度,以便将其恢复到初始状态。取消Label小部件旋转的 KV 文件如清单 3-13 所示。应用窗口如图 3-11 所示。请注意,标签首先旋转 45 度,然后旋转-45 度。如果在Button之后有一个以上的小工具,那么将它们全部旋转以返回到它们的初始状态将是令人厌烦的。更好的解决方案是将Rotate上下文指令的效果限制在Button小部件上。

img/481739_1_En_3_Fig11_HTML.jpg

图 3-11

向左旋转-45 度后,左侧小部件保持不变

BoxLayout:
    Button:
        text: "Rotate this Button"
        canvas.before:
            Rotate:
                angle: 45
                origin: root.width/2, root.height/2
    Label:
        text: "Do not Rotate this Label"
        canvas.before:
            Rotate:
                angle: -45
                origin: root.width/2, root.height/2

Listing 3-13Rotating the Label by -45 Degrees to Cancel the Effect of the Button Rotation

canvas.after、PushMatrix 和 PopMatrix

为了避免将Rotate指令应用到Button小部件下面的小部件,并将效果限制到Button小部件,Kivy 提供了PushMatrixPopMatrix指令。想法是保存由旋转、平移和缩放表示的当前上下文状态。保存状态后,我们可以对Button小部件应用旋转。旋转后的Button小部件渲染成功后,我们可以恢复保存的上下文状态。因此,只有Button小部件将被旋转,所有其他小部件将保留它们的上下文状态。

清单 3-14 显示了使用PushMatrixPopMatrix的 KV 文件。生成的窗口与图 3-11 所示的窗口相同。

BoxLayout:
    Button:
        text: "Rotate this Button"
        canvas.before:
            PushMatrix:
            Rotate:
                angle: 45
                origin: root.width/2, root.height/2
        canvas.after:
            PopMatrix:
    Label:
        text: "Do not Rotate this Label"

Listing 3-14Using PushMatrix and PopMatrix to Limit the Effect of the Context Instructions

注意,PushMatrix指令被插入到canavs.before实例中,而PopMatrix指令被插入到canvas.after实例中。在canvas.after内增加了PopMatrix指令,确保只有在Button旋转成功后才会执行。如果该指令添加到canvas.before,则按钮不会旋转。事实上,按钮将根据Rotate指令旋转,然后在呈现旋转后的按钮之前恢复上下文状态。因此,我们不会感觉到旋转的影响。

相机旋转

在理解了画布和它们的指令是如何工作的之后,我们可以旋转Camera小部件,这是我们最初的目标。我们可以使用清单 3-15 中所示的 KV 文件构建应用。该文件使用前面讨论过的指令(canvas.beforecanvas.afterPushMatrixPopMatrix)。在进一步发展之前,熟悉它们是很重要的。请注意,我们正在更改 KV 文件,而没有更改 Python 代码。

BoxLayout:
    Camera:
        resolution: 1280, 720
        play: True
        canvas.before:
            PushMatrix:
            Rotate:
                angle: -90
                axis: 0,0,1
                origin: root.width/2, root.height/2
        canvas.after:
            PopMatrix:

Listing 3-15The KV File That Rotates the Camera Widget

为了展示完整的想法,清单 3-2 中使用的 Python 代码在清单 3-16 中重复出现。

记住将 KV 文件的名称设置为test.kv,以便在将它们转换成小写字母后匹配类名中单词App之前的字符。还要记得将CAMERA添加到buildozer.spec文件的android.permissions字段,以获得使用相机的权限。

import kivy.app

class TestApp(kivy.app.App):

    def build(self):
        pass

app = TestApp()
app.run()

Listing 3-16Python Code Associated with the KV File in Listing 3-15

图 3-12 显示了在 Android 设备上运行的应用。小部件放置在正确的角度。

img/481739_1_En_3_Fig12_HTML.jpg

图 3-12

相机部件被正确地放置在 Android 应用中

在本章开始之前,让我们快速回顾一下本书到目前为止我们所讨论的内容。我们为构建桌面和 Android 应用准备了 Kivy 开发环境。所需的主要工具是 Kivy 和 Buildozer。我们首先创建了一个简单的应用,其中只使用了一个小部件。为了向应用窗口添加多个 Kivy 小部件,我们讨论了 Kivy BoxLayout容器。为了允许用户与应用交互,我们讨论了如何设置和获取文本小部件,如TextInputLabel。使用on_press处理按钮按下动作。

Camera小工具也用于使用 Kivy 访问相机。因为使用 Android 相机捕获的图像默认是逆时针旋转 90 度,所以我们必须将Camera小部件顺时针旋转-90 度。Kivy 中的 canvas 实例允许我们使用上下文指令对小部件进行转换。此外,画布有顶点指令,用于在小部件上绘制形状,如矩形。讨论了其他画布实例,它们是canvas.beforecanvas.after。为了将画布指令的效果限制在选定的小部件上,我们讨论了PushMatrixPopMatrix指令。

在本章的下一节,我们将扩展之前创建的应用,以便查看摄像机、捕捉图像并将其上传到 HTTP 服务器。我们构建了一个应用,不仅可以查看相机,还可以捕捉图像。HTTP 服务器是使用运行在 PC 上的 Flask API 创建的。服务器有一个基于其 IPv4 地址和端口号的开放套接字,它等待请求上传文件的请求。使用requests Python 库,Kivy 应用使用 HTTP POST消息将捕获的图像上传到服务器。一旦服务器收到来自 Kivy 应用的 HTTP POST消息,它就将文件上传到一个选定的目录。我们开始吧。

使用 Kivy 捕捉和保存图像

我们现在想要修改清单 3-15 和 3-16 中编写的应用,以便在按钮按下时捕获并保存图像。为此,一个Button小部件将被添加到窗口的末尾,如清单 3-17 所示。问题是,我们如何捕捉相机图像?一般来说,当一个 widget 调用export_to_png()函数时,就会抓取一个 widget 的图像(即截图)并以 PNG 文件的形式保存到指定的目录下。如果Camera小部件调用了这个函数,相机图像将被保存为一个 PNG 文件。

为了更好地理解清单 3-17 中所示的 KV 文件,有一些注释。BoxLayout的方向被设置为vertical以确保小部件垂直排列,这样按钮就可以被添加到布局的末尾。在Button小部件之前添加了Camera小部件,这样按钮就被添加到了窗口的末尾。

当按钮被按下时,Camera小部件被赋予一个 IDcamera以在 Python 代码中访问它,从而调用export_to_png()函数。当on_press动作被触发时,Python 代码中的capture()函数将被调用。

BoxLayout:
    orientation: "vertical"
    Camera:
        id: camera
        size_hint_y: 18
        resolution: (1280, 720)
        play: True
        canvas.before:
            PushMatrix:
            Rotate:
                angle: -90
                origin: root.width/2, root.height/2
        canvas.after:
            PopMatrix:
    Button:
        text: "Capture"
        size_hint_y: 1
        on_press: app.capture()

Listing 3-17Adding a Button that Captures an Image When Pressed

最后要注意的是,size_hint_y属性被添加到了CameraButton小部件中。微件的尺寸根据BoxLayout自动计算。该属性提示布局使给定小部件的高度变大、变小或等于另一个小部件的高度。例如,如果相机的size_hint_y设置为 2,按钮设置为 1,那么Camera小工具的高度将是按钮高度的两倍。如果相机设置为 3,按钮设置为 1,那么相机高度将是按钮高度的三倍。如果两者都设置为相同的数字,那么两个小部件的高度将相等。在这个例子中,我们给Camera小部件分配了一个大值,给按钮分配了一个小值,这样就不会在屏幕上隐藏太多的区域。

size_hint_ y property类似,还有一个size_hint_x property控制小部件的宽度。

准备好 KV 文件后,我们需要讨论清单 3-18 中所示的 Python 文件。注意,这个类被命名为PycamApp。因此,KV 文件应该被命名为pycam.kv,以便隐式地使用它。在capture()函数中,Camera小部件根据其 ID 被提取到camera变量中。该变量调用export_to_png()函数,该函数接受保存捕获图像的路径。您可以更改此路径来自定义应用。

from kivy.app import App

class PycamApp(App):

    def capture(self):
        camera = self.root.ids["camera"]
        camera.export_to_png("/storage/emulated/0/captured_image_kivy.png")

    def build(self):
        pass

app = PycamApp()
app.run()

Listing 3-18Python File Associated with KV File in Listing 3-17 that Captures an Image on Button Press

构建并运行 Android 应用后,其窗口如图 3-13 所示。按下按钮,摄像机图像将保存在指定的目录中。这个图像将被发送到服务器。所以,让我们开始构建服务器。

img/481739_1_En_3_Fig13_HTML.jpg

图 3-13

按下按钮时捕捉图像

使用 Flask 构建 HTTP 服务器

保存捕获的图像后,我们将其发送到 HTTP 服务器。服务器是使用运行在 PC 上的 Flask API 创建的。因为 Flask 超出了本书的范围,所以我们不会详细讨论它。你可以在我的书《CNN使用深度学习的实际计算机视觉应用》的第七章中阅读更多关于 Flask 的内容(Apress,2018)。

清单 3-19 列出了构建 HTTP 服务器的 Flask 代码。让服务器监听文件上传请求。首先,使用flask.Flask类创建一个应用实例。该类的构造函数接受import_name参数,该参数被设置为包含 Flask Python 文件的文件夹名称。

import flask
import werkzeug

app = flask.Flask(import_name="FlaskUpload")

@app.route('/', methods = ['POST'])
def upload_file():
    file_to_upload = flask.request.files['media']
    file_to_upload.save(werkzeug.secure_filename(file_to_upload.filename))
    print('File Uploaded Successfully.')
    return 'SUCCESS'

app.run(host="192.168.43.231", port=6666, debug=True)

Listing 3-19Building the HTTP Server Using Flask

在这段代码的结尾,应用使用 IPv4 地址 192.168.43.231 和端口号 6666 监听请求。

如果您不知道您的 IPv4 地址,请使用ifconfig终端命令。如果没有找到该命令,使用该命令安装net-tools:

ahmed-gad@ubuntu:-$sudo apt install net-tools

之后,可以执行ifconfig命令,如图 3-14 所示。

img/481739_1_En_3_Fig14_HTML.jpg

图 3-14

使用 ifconfig 终端命令确定 IPv4 地址

debug参数控制是否激活调试模式。当调试模式打开时,对服务器的更改将自动应用,而无需重新启动它。

装饰器告诉 Flask 当一个 URL 被访问时执行哪个函数。URL 作为参数被添加到装饰器中,函数被添加到装饰器的下面,在这个例子中是upload_file()。URL 设置为/ 表示服务器的根目录。因此,当用户访问http://192.168.43.231/:6666时,与服务器根目录相关联的route()装饰器将接收这个请求并执行upload_file()功能。

route()装饰器接受一个名为methods的参数,该参数接收函数将作为列表响应的 HTTP 消息的类型。因为我们只对POST HTTP 消息感兴趣,所以它将被设置为['POST']

要上传的数据作为键和值的字典发送。密钥是文件的 ID,值是文件本身。在upload_file()函数中,使用flask.request.files字典获取要上传的文件。它接收引用要上传的文件的密钥。使用的钥匙是media。这意味着当我们准备 Kivy 应用时,要上传的图像的键必须设置为media。文件被返回到一个变量中,在我们的例子中是file_to_upload

如果我们对根据文件的原始名称保存文件感兴趣,可以使用filename属性返回它的名称。因为一些文件被命名为欺骗服务器并执行非法操作,所以使用werkzeug.secure_filename()函数返回安全文件名。返回安全文件名后,使用save()功能保存文件。文件保存成功后,控制台上会出现一条打印消息,服务器会向客户端发送一个单词SUCCESS作为响应。

请注意,服务器接受任何文件扩展名。你可以阅读更多关于 Flask 的内容,了解如何上传带有特定扩展名的文件。

使用将文件上传到 HTTP 服务器的请求

在修改 Android 应用来上传文件之前,我们可以构建一个客户端作为桌面应用,它使用requests库来上传文件。它将比移动应用更容易调试。该应用不包括 Kivy 代码,因此我们将使用终端命令与服务器交互。客户端代码如清单 3-20 所示。

import requests

files = {'media': open('/home/ahmedgad/Pictures/test.png', 'rb')}
try:
    requests.post('http://192.168.43.231:6666/', files=files)
except requests.exceptions.ConnectionError:
    print("Connection Error! Make Sure Server is Active.")

Listing 3-20Using Requests to Upload the Captured Image to the Server

准备好保存要上传的文件的字典。它只有一个键值对。如前所述,这个键将被设置为media,因为服务器正在等待这个键。使用接收其路径的open()函数打开文件。rb参数指定文件在二进制模式下以只读方式打开。

requests.post()函数接收两个参数。第一个是 URL,在指定其套接字详细信息(IPv4 地址和端口)后,将它定向到服务器的根目录。第二个是字典。try and catch 语句用于检查连接建立是否有问题。这确保了即使在建立连接时出现错误,应用也不会崩溃。

在构建了服务器和客户机之后,我们可以开始运行服务器了。服务器没有 GUI,因此它的界面是终端。因为服务器是一个常规的 Python 文件,我们可以根据使用的 Python 版本,在关键字pythonpython3后键入 Python 文件名来运行它。如果它的名字是FlaskServer.py,它将使用下面的命令执行。记住使用正确的路径来定位 Python 文件。

ahmed-gad@ubuntu:~/Desktop/FlaskUpload$python3 FlaskServer.py

我们将看到指示服务器正在成功运行的信息性消息,如图 3-15 所示。

img/481739_1_En_3_Fig15_HTML.jpg

图 3-15

运行 Flask HTTP 服务器

运行客户端应用并按下按钮后,服务器接收到 HTTP POST消息,并根据图 3-16 中控制台上显示的消息成功上传文件。响应的 HTTP 状态代码是 200,这意味着请求成功完成。

img/481739_1_En_3_Fig16_HTML.jpg

图 3-16

Kivy 应用捕获的图像被成功上传到 Flask HTTP 服务器

使用 Kivy Android 应用上传相机拍摄的图像

在确定桌面客户端应用运行良好后,我们就可以准备 Android 应用了。与清单 3-18 相比,变化将出现在capture()函数内部,如清单 3-21 所示。

import kivy.app
import requests

class PycamApp(kivy.app.App):

    def capture(self):
        camera = self.root.ids['camera']
        im_path = '/storage/emulated/0/'
        im_name = 'captured_image_kivy.png'
        camera.export_to_png(im_path+im_name)
        files = {'media': open(im_path+im_name, 'rb')}

        try:
            self.root.ids['capture'].text = "Trying to Establish a Connection..."
            requests.post('http://192.168.43.231:6666/', files=files)
            self.root.ids['capture'].text = "Capture Again!"
        except requests.exceptions.ConnectionError:
            self.root.ids['capture'].text = "Connection Error! Make Sure Server is Active."

    def build(self):
        pass

app = PycamApp()
app.run()

Listing 3-21Capturing and Uploading Images to the Server Using the Android Application

因为服务器可能脱机,我们需要向用户反映这一点。单击 Capture 按钮后,应该会显示一条消息,通知用户连接正在建立。如果出现连接错误,还会显示一条消息来反映这一情况。

为了能够更改按钮的文本,我们必须在 Python 文件中访问它。为了访问小部件,它必须有一个 ID。清单 3-17 中之前的 KV 文件没有给按钮分配 ID。清单 3-22 中修改后的 KV 文件为Button小部件分配一个 ID。分配的 ID 是capture

BoxLayout:
    orientation: "vertical"
    id: root_widget
    Camera:
        id: camera
        size_hint_y: 18
        resolution: (1280, 720)
        play: True
        canvas.before:
            PushMatrix:
            Rotate:
                angle: -90
                origin: root.width/2, root.height/2
        canvas.after:
            PopMatrix:
    Button:
        id: capture
        text: "Capture"
        size_hint_y: 1
        on_press: app.capture()

Listing 3-22Assigning an ID to the Button to Access it Inside the Python File

因为与之前的应用相比,小部件的排列没有改变,所以应用窗口将与之前的应用相同。

请求中的动态 IP 地址

现在,应用依赖于服务器的静态 IPv4 地址。如果服务器使用动态主机配置协议(DHCP),服务器的 IPv4 地址可能会改变,因此我们必须用新地址重建应用。为了使这个过程动态,我们可以使用一个TextInput小部件,其中可以输入服务器的 IPv4 地址。在将字典发送到服务器之前,获取来自小部件的文本以构建 URL。修改后的 KV 文件如清单 3-23 所示。为了访问这个小部件,它被分配了 ID ip_address

BoxLayout:
    orientation: "vertical"
    id: root_widget
    Camera:
        id: camera
        size_hint_y: 18
        resolution: (1280, 720)
        play: True
        canvas.before:
            PushMatrix:
            Rotate:
                angle: -90
                origin: root.width/2, root.height/2
        canvas.after:
            PopMatrix:
    TextInput:
        text: "192.168.43.231"
        id: ip_address
        size_hint_y: 1
    Button:
        id: capture
        text: "Capture"
        size_hint_y: 1
        on_press: app.capture()

Listing 3-23Adding a TextInput Widget to Enter the IPv4 Address of the Server

使用TextInput小部件后的 Python 代码如清单 3-24 所示。

import kivy.app
import requests

class PycamApp(kivy.app.App):

    def capture(self):
        camera = self.root.ids['camera']
        im_path = '/storage/emulated/0/'
        im_name = 'captured_image_kivy.png'
        camera.export_to_png(im_path+im_name)

        ip_addr = self.root.ids['ip_address'].text
        url = 'http://'+ip_addr+':6666/'
        files = {'media': open(im_path+im_name, 'rb')}

        try:
            self.root.ids['capture'].text = "Trying to Establish a Connection..."
            requests.post(url, files=files)
            self.root.ids['capture'].text = "Capture Again!"
        except requests.exceptions.ConnectionError:
            self.root.ids['capture'].text = "Connection Error! Make Sure Server is Active."

    def build(self):
        pass

app = PycamApp()
app.run()

Listing 3-24Fetching the IPv4 Address from the TextInput Widget

应用窗口如图 3-17 所示。请注意,按钮的文本被更改为“再次捕获!”这意味着根据 Python 代码成功上传了文件。记得在按下按钮之前运行服务器。尝试使用不同的 IPv4 地址,并注意按钮文本如何变化以反映存在连接错误。

img/481739_1_En_3_Fig17_HTML.jpg

图 3-17

使用 TextInput 小部件输入服务器的 IPv4 地址后的图像捕获应用

Flask 服务器日志消息也会出现在终端上,如图 3-18 所示。这些消息反映了服务器从 IPv4 地址为 192.168.43.1 的客户端收到了类型为POST的消息。

img/481739_1_En_3_Fig18_HTML.jpg

图 3-18

收到文件后记录来自服务器的消息

此时,我们成功地创建了一个 Kivy Android 应用,该应用将使用相机捕获的单个图像共享到 HTTP 服务器。这是通过将图像保存到设备存储器,然后上传它。

本章的剩余部分通过在服务器的 web 浏览器中实时预览 Android 摄像头来扩展该应用。这是通过捕获相机图像并将其作为字节数组存储在设备内存中来实现的。为了节省时间,图像不会作为文件存储在设备存储器中。使用 HTML POST消息将字节数组上传到 Flask 服务器,如本章前面所述。服务器接收每个图像,并使用<img>元素将其显示在 HTML 页面上。我们通过连续捕捉图像、将图像上传到服务器以及更新和刷新 HTML 页面来创建实时 Android 摄像头预览。

捕获相机图像并将其存储到存储器中

在本章前面,使用 Kivy export_to_png()函数将Camera小部件图像捕获并保存为 PNG 文件。当一个小部件调用这个函数时,会从小部件中截取一个屏幕截图,并以 PNG 文件的形式保存到函数中指定的目录中。使用 HTTP POST消息将保存的 PNG 文件上传到服务器。

如果我们希望使用 Android 相机连续预览捕获的图像,我们必须捕获Camera小部件,将每个捕获的图像保存为 PNG 文件,并将它们发送到服务器。每次捕获图像时保存文件非常耗时。此外,不需要将文件保存在存储器中。我们只需要将捕获的图像尽快发送到服务器。没有必要延迟传输过程。出于这些原因,我们可以使用工具来捕获图像并将像素保存在内存中,而不是保存在文件中。我们可以使用 OpenGL 中的glReadPixels()函数,或者使用get_region()函数返回小部件的纹理。这两种功能都可以捕获图像并将其保存到设备内存中,而不是保存为文件。这加快了进程。

glReadPixels()功能在kivy.graphics.opengl模块中可用。其签名如下:

kivy.graphics.opengl.glReadPixels(x, y, width, height, format, type)

该函数的工作原理是从 Kivy 应用窗口中捕获一个区域的图像。该区域从使用xy参数定位的左下角开始。使用widthheight参数指定区域的宽度和高度。使用前四个参数,成功地指定了区域。该区域从(x, y)开始,水平向左延伸等于width的值,垂直向上延伸等于height的值。因为我们对捕获放置Camera小部件的区域感兴趣,所以我们可以返回这个小部件的坐标,并将它们分配给四个参数。

在返回该区域的像素之前,需要指定一些其他参数来控制像素如何保存到内存中。

format参数指定像素数据的格式。它有不同的值,如GL_REDGL_GREENGL_BLUEGL_RGBGL_RGBA等等。这些值存在于kivy.graphics.opengl模块中。我们对捕捉 RGB 图像感兴趣,因此将使用GL_RGB值。

type参数指定像素数据的类型。它有不同的值,如GL_UNSIGNED_BYTEGL_BYTEGL_UNSIGNED_SHORTGL_SHORTGL_UNSIGNED_INT等等。这些值存在于kivy.graphics.opengl模块中。我们对将图像保存为字节数组感兴趣,因此使用了GL_UNSIGNED_BYTE参数。

该函数还有另外两个可选参数,名为arrayoutputType,我们不想编辑它们。

另一种捕获小部件图像的方法是使用kivy.graphics.texture.Texture类的get_region()方法。对于任何有纹理的小部件,我们可以调用这个函数来返回它的纹理。它有四个参数,如下所示。它们与glReadPixels()函数中的前四个参数相同。

get_region(x, y, width, height)

您可以使用这些函数中的任何一个从Camera小部件中捕捉图像,并将结果保存在内存中。对于get_region(),它与有纹理的部件一起工作。一些小部件没有纹理,比如TextInput,因此我们不能使用get_region()。另一方面,glReadPixels()捕捉图像时不关心小工具是否有纹理。

为了使事情更简单,我们可以使用get_region()。清单 3-25 中显示了使用get_region()捕捉图像并将其保存到内存中的完整代码。

我们将开始构建一个桌面应用来简化调试。在本章的最后,我们可以构建 Android 应用。

使用 KF 文件中的 ID 获取Camera小部件,以确定其左下方的位置(camera.xcamera.y)。这是对其分辨率(camera.resolution)的补充,以返回捕获图像的大小,其中camera.resolution[0]是宽度,camera.resolution[1]是高度。这四个值被分配给get_region()方法中的四个参数。

get_region()方法返回一个TextureRegion类的实例。为了返回纹理的像素,我们可以使用pixels属性。它以像素的形式返回小部件的纹理,以无符号字节数组的形式显示RGBA格式。这个数组保存在内存中。在这个例子中,字节数组保存在pixels_data变量中。这个变量中的数据稍后将被发送到服务器。

import kivy.app
import PIL.Image

class PycamApp(kivy.app.App):

    def capture(self):
        camera = self.root.ids['camera']
        print(camera.x, camera.y)

        pixels_data = camera.texture.get_region(x=camera.x, y=camera.y, width=camera.resolution[0], height=camera.resolution[1]).pixels

        image = PIL.Image.frombytes(mode="RGBA",size=(int(camera.resolution[0]), int(camera.resolution[1])), data=pixels_data)
        image.save('out.png')

    def build(self):
        pass

app = PycamApp()
app.run()

Listing 3-25Capturing a Camera Image Using get_region()

现在,我们可以调试应用以确保一切按预期运行。这是通过使用 Python 图像库( PIL )保存捕获的图像。因为get_region().pixels返回一个 RGBA 格式的字节数组,我们需要从该数组中构造图像。PIL 的frombytes()函数支持从字节数组构建图像。该函数通过指定图像模式来接受模式参数,在本例中是字符串“RGBA”。我们还在size参数中将图像大小指定为一个元组,并在数据参数中指定原始字节数据。

注意,这个函数接受整数形式的大小。最好将Camera小部件的宽度和高度转换成整数。这是因为Camera小部件返回的宽度和高度可能是float。使用save()功能保存frombytes()功能返回的图像。

清单 3-26 中显示了与之前的 Python 代码一起用于构建桌面应用的 KV 文件。除了删除了TextInput小部件之外,这个文件与上一个例子中最后一个应用使用的文件相同,因为我们目前没有兴趣联系服务器。

BoxLayout:
    orientation: "vertical"
    Camera:
        id: camera
        size_hint_y: 18
        resolution: (1280, 720)
        play: True
    Button:
        id: capture
        text: "Capture"
        size_hint_y: 1
        on_press: app.capture()

Listing 3-26KV File for the Application in Listing 3-25

运行应用后,窗口如图 3-19 所示。

img/481739_1_En_3_Fig19_HTML.jpg

图 3-19

使用 get_region()捕获并保存到设备内存中的图像

如果你点击按钮,get_region().pixels将捕获Camera小部件的区域并保存在内存中。

到目前为止,我们已经使用get_region()方法成功捕获了一幅图像,并将其保存到设备内存中。下一步是将这个图像发送到 Flask 服务器。

使用 HTTP POST 消息将捕获的图像发布到 Flask 服务器

在前面的例子中,图像以字节数组的形式保存在内存中,并准备好发送给服务器。清单 3-27 显示了将数组发送到 Flask 服务器的 Python 代码。

import kivy.app
import requests

class PycamApp(kivy.app.App):

    def capture(self):
        camera = self.root.ids['camera']
        print(camera.x, camera.y)

        pixels_data = camera.texture.get_region(x=camera.x, y=camera.y, width=camera.resolution[0], height=camera.resolution[1]).pixels

        ip_addr = self.root.ids['ip_address'].text
        url = 'http://'+ip_addr+':6666/'
        files = {'media': pixels_data}

        try:
            self.root.ids['capture'].text = "Trying to Establish a Connection..."
            requests.post(url, files=files)
            self.root.ids['capture'].text = "Capture Again!"
        except requests.exceptions.ConnectionError:
            self.root.ids['capture'].text = "Connection Error! Make Sure Server is Active."

    def build(self):
        pass

app = PycamApp()
app.run()

Listing 3-27Uploading the Bytes Array to the Server

get_region()方法返回的字节数组被插入到将被发送到服务器的字典中。注意,字典被分配给了requests.post()函数的files参数。这意味着字节数组将作为文件在服务器上接收。

代码中的其他部分都按照前面的例子中讨论的那样工作。请注意,我们对在客户端使用 PIL 不再感兴趣。

在添加了TextInput小部件之后,客户端应用的 KV 文件如清单 3-28 所示。

BoxLayout:
    orientation: "vertical"
    Camera:
        id: camera
        size_hint_y: 18
        resolution: (1280, 720)
        play: True
    TextInput:
        text: "192.168.43.231"
        id: ip_address
        size_hint_y: 1
    Button:
        id: capture
        text: "Capture"
        size_hint_y: 1
        on_press: app.capture()

在向服务器发送图像之前

在服务器端,我们将接收上传的字节数组文件。该文件将被读取,以便使用PIL.Image.frombytes()函数将其内容转换成图像,如前所述。为了使用这个函数将字节数组转换成图像,它在size参数中接收返回图像的大小。使用不同的尺寸而不是正确的尺寸可能会降低图像质量。因此,我们需要知道服务器端的图像大小。我们如何做到这一点?

从客户端到服务器的每个POST消息都包含要上传的文件。我们还可以在该消息中发送图像大小。不幸的是,这将在每条消息中发送更多的数据,因为每次上传图像时都会发送图像大小。因为图像大小是固定的,所以我们不需要发送多次。

更好的解决方案是在发送任何图像之前向服务器发送一条POST HTTP 消息。此消息告诉服务器它将在下一条消息中接收的图像的大小。当服务器在新邮件中收到上传的图像时,它可以使用以前收到的图像大小。由于这些原因,一个新的Button小部件被添加到小部件树的末尾。当按下时,Camera小部件的大小将被获取并通过POST HTTP 消息上传到服务器。

清单 3-28 显示了客户端 Kivy 应用修改后的 KV 文件。新按钮被分配了 ID cam_size。当按下此按钮时,将执行 Python 代码中的cam_size()函数。

BoxLayout:
    orientation: "vertical"
    Camera:
        id: camera
        size_hint_y: 18
        resolution: (1280, 720)
        play: True
    TextInput:
        text: "192.168.43.231"
        id: ip_address
        size_hint_y: 1
    Button:
        id: capture
        text: "Capture"
        size_hint_y: 1
        on_press: app.capture()
    Button:
        id: cam_size
        text: "Configure Server"
        size_hint_y: 1
        on_press: app.cam_size()

Listing 3-28KV File for the Client-Side Application

添加cam_size()函数后,客户端 Kivy 应用的 Python 代码如清单 3-29 所示。创建一个字典来保存要上传的图像的宽度和高度(即Camera小部件)。这些数据将作为参数发送到服务器的/camSize目录中,因此使用了requests.post()函数的params参数。如果消息成功发送到服务器,那么新添加的Button widget 就没用了。因此,将使用delete_widget()功能将其从窗口小部件树中删除。

import kivy.app
import requests

class PycamApp(kivy.app.App):

    def cam_size(self):
        camera = self.root.ids['camera']
        cam_width_height = {'width': camera.resolution[0], 'height': camera.resolution[1]}

        ip_addr = self.root.ids['ip_address'].text
        url = 'http://'+ip_addr+':6666/camSize'

        try:
            self.root.ids['cam_size'].text = "Trying to Establish a Connection..."
            requests.post(url, params=cam_width_height)
            self.root.ids['cam_size'].text = "Done."
            self.root.remove_widget(self.root.ids['cam_size'])
        except requests.exceptions.ConnectionError:
            self.root.ids['cam_size'].text = "Connection Error! Make Sure Server is Active."

    def capture(self):
        camera = self.root.ids['camera']
        print(camera.x, camera.y)

        pixels_data = camera.texture.get_region(x=camera.x, y=camera.y, width=camera.resolution[0], height=camera.resolution[1]).pixels

        ip_addr = self.root.ids['ip_address'].text
        url = 'http://'+ip_addr+':6666/'
        files = {'media': pixels_data}

        try:
            self.root.ids['capture'].text = "Trying to Establish a Connection..."
            requests.post(url, files=files)
            self.root.ids['capture'].text = "Capture Again!"
        except requests.exceptions.ConnectionError:
            self.root.ids['capture'].text = "Connection Error! Make Sure Server is Active."

    def build(self):
        pass

app = PycamApp()
app.run()

Listing 3-29Informing the Server by the Width and Height of the Captured Images

图 3-20 显示了客户端应用窗口。

img/481739_1_En_3_Fig20_HTML.jpg

图 3-20

添加服务器配置按钮后的应用窗口

准备好客户端 Kivy 应用后,下一步是准备服务器端 Flask 应用。

在服务器上处理接收到的图像

在客户端成功构建 Kivy 应用后,下一步是准备服务器端 Flask 应用。它首先接收上传图像的尺寸(宽度和高度),然后接收上传的图像。应用的 Python 代码如清单 3-30 所示。

有称为cam_widthcam_height的变量是在任何函数之外定义的。这些变量保存图像的宽度和高度。当 KV 文件中 ID 为cam_size的按钮被按下时,URL 为/camSize的路由装饰器执行cam_size()功能。在这个函数中,使用flask.request.args字典从客户端接收Camera小部件的宽度和高度作为参数。它们被分配给先前创建的两个变量。为了使用这些变量而不是创建新的变量,我们在函数的开头将它们定义为global

在分配数据之前,记得将接收数据的类型转换为integerint(float())变量保证转换没有错误。

import flask
import PIL.Image

app = flask.Flask(import_name="FlaskUpload")

cam_width = 0
cam_height = 0

@app.route('/camSize', methods = ['POST'])
def cam_size():
    global cam_width
    global cam_height

    cam_width = int(float(flask.request.args["width"]))
    cam_height = int(float(flask.request.args["height"]))

    print('Width',cam_width,'& Height',cam_height,'Received Successfully.')

    return "OK"

@app.route('/', methods = ['POST'])
def upload_file():
    global cam_width
    global cam_height

    file_to_upload = flask.request.files['media'].read()

    image = PIL.Image.frombytes(mode="RGBA", size=(cam_width, cam_height), data=file_to_upload)
    image.save('out.png')

    print('File Uploaded Successfully.')

    return 'SUCCESS'

app.run(host="192.168.43.231", port=6666, debug=True)

Listing 3-30Restoring Images from the Received Bytes Arrays at the Server

upload_file()功能类似于本章前面使用的功能。它使用flask.request.files字典接收上传的文件。使用read()功能读取上传的文件。使用PIL.Image.frombytes()功能将接收到的文件转换成图像。仅出于调试目的,在开发客户端应用时,图像将保存到 PNG 文件中。

准备好客户端和服务器应用后,我们可以根据图 3-21 进行测试。通过运行客户端 Kivy 应用并按下 ID 为cam_size的按钮,图像大小(宽度和高度)将被发送到服务器,该按钮将被删除。按下另一个按钮后,图像将被捕获并作为字节数组文件发送到服务器。该文件在服务器上被读取,并返回字节数组。使用PIL.Image.frombytes()函数将该数组转换成图像。图 3-21 显示一切正常。

img/481739_1_En_3_Fig21_HTML.jpg

图 3-21

上传字节数组后的图像尺寸被发送到服务器

此时,Kivy 客户端和 Flask 服务器应用都可以很好地相互协作,以便上传单个图像。为了从客户机向服务器连续发送图像,我们可以构建一个 HTML 页面来显示接收到的图像。

使用 HTML 页面保存和显示接收到的图像

到目前为止,我们成功地构建了客户端和服务器端应用。客户端应用发送图像,其宽度和高度也被发送到服务器。客户端将图像作为字节数组发送给服务器。服务器使用接收到的宽度和高度将数组保存为 PNG 文件。

因为我们对显示接收到的图像感兴趣,所以我们将构建一个非常简单的 HTML 页面,该页面包含一个<img>元素,其中要显示的上传图像的路径被分配给了src属性。在接收并保存上传的图像为 PNG 文件后,服务器应用根据上传图像的路径更新src属性后创建 HTML 文件。然后,使用webbrowser模块的open()功能在网络浏览器中打开 HTML 页面。该函数接受页面 URL 作为参数。更新后的服务器应用如清单 3-31 所示。

import flask
import PIL.Image
import webbrowser

app = flask.Flask(import_name="FlaskUpload")

cam_width = 0
cam_height = 0

@app.route('/camSize', methods = ['POST'])
def cam_size():
    global cam_width
    global cam_height

    cam_width = int(float(flask.request.args["width"]))
    cam_height = int(float(flask.request.args["height"]))

    print('Width',cam_width,'& Height',cam_height,'Received Successfully.')

    return "OK"

@app.route('/', methods = ['POST'])
def upload_file():
    global cam_width
    global cam_height

    file_to_upload = flask.request.files['media'].read()

    image = PIL.Image.frombytes(mode="RGBA", size=(cam_width, cam_height), data=file_to_upload)
    image.save('out.png')

    print('File Uploaded Successfully.')

    html_code = '<html><head><title>Displaying Uploaded Image</title></head><body><h1>Displaying Uploaded Image</h1><img src="out.png" alt="Uploaded Image at the Flask Server"/></body></html>'

    html_url = "/home/ahmedgad/Desktop/FlaskUpload/test.html"
    f = open(html_url,'w')
    f.write(html_code)
    f.close()

    webbrowser.open(html_url)

    return 'SUCCESS'

app.run(host="192.168.43.231", port=6666, debug=True)

Listing 3-31Displaying the Restored Images on the Server on an HTML Page

HTML 代码作为文本写在html_code变量中。清单 3-32 中显示了更好的可视化格式代码。除了<img>元素之外,<h1>元素在它上面打印一个标题。HTML 代码根据html_url变量中指定的路径写入 HTML 文件。

<html>
<head>
<title>Displaying Uploaded Image</title>
</head>
<body>
<h1>Uploaded Image to the Flask Server</h1>
<img src="out.png" alt="Uploaded Image at the Flask Server"/>
</body>
</html>

Listing 3-32HTML Page to Display the Images

在客户端捕获图像,上传到服务器,更新并显示 HTML 页面后,结果如图 3-22 所示。请注意,应用会在浏览器中为每个上传的图像打开一个新的选项卡。这将是一个麻烦,当我们试图不断上传图像。

img/481739_1_En_3_Fig22_HTML.jpg

图 3-22

将上传的图像显示到服务器上的 HTML 页面

显示接收的图像而不保存

在客户端应用中,为了避免保存每个上传的图像,我们使用了get_region()方法。我们需要对服务器端应用做同样的事情。

目前,服务器接收字节数组,使用 PIL 将其保存到 PNG 文件,并在 web 浏览器中显示。我们需要删除将图像保存为 PNG 文件的中间步骤。因此,我们需要将上传的图像以字节数组的形式直接显示在 web 浏览器上。这是通过将图像的字节数组内联到作为 base64 编码图像的<img> HTML 元素的src属性中来实现的。

为了将字节数组编码为 base64,使用 base64 Python 模块。确保它安装在您的机器上。清单 3-33 显示了更新后的服务器端应用。

请注意,我们不再需要使用 PIL。这是因为我们对将字节数组转换成图像或保存图像都不感兴趣。

import flask
import webbrowser
import base64

app = flask.Flask(import_name="FlaskUpload")

cam_width = 0
cam_height = 0

@app.route('/camSize', methods = ['POST'])
def cam_size():
    global cam_width
    global cam_height

    cam_width = int(float(flask.request.args["width"]))
    cam_height = int(float(flask.request.args["height"]))

    print('Width',cam_width,'& Height',cam_height,'Received Successfully.')

    return "OK"

@app.route('/', methods = ['POST'])
def upload_file():
    global cam_width
    global cam_height

    file_to_upload = flask.request.files['media'].read()

    print('File Uploaded Successfully.')

    im_base64 = base64.b64encode(file_to_upload)

    html_code = '<html><head><meta http-equiv="refresh" content="1"><title>Displaying Uploaded Image</title></head><body><h1>Uploaded Image to the Flask Server</h1><img src="data:;base64,' + im_base64.decode(
    'utf8') + '" alt="Uploaded Image at the Flask Server"/></body></html>'

    html_url = "/home/ahmedgad/Desktop/FlaskUpload/test.html"
    f = open(html_url,'w')
    f.write(html_code)
    f.close()

    webbrowser.open(html_url)

    return 'SUCCESS'

app.run(host="192.168.43.231", port=6666, debug=True)

Listing 3-33Inlining the Bytes Array Into the src Attribute of the <img> HTML Tag

下面显示了使用b64encode()函数将图像转换为 base64 编码的代码行。该函数接受一个字节数组,因此它由file_to_upload变量中上传的数据提供。

im_base64 = base64.b64encode(file_to_upload)

im_base64变量保存 base64 编码的图像。这个变量中的值作为一个数据 URL 被分配给<img>元素的src属性。使用的网址是data:;base64,。注意,URL 不直接接受字节数组,而是在使用encode('utf8')函数将其转换成字符串之后接受。你可以阅读更多关于数据 URL 的内容。

记住,我们必须将上传的字节数组图像转换成 PIL 图像,以便旋转它。然后,PIL 图像被转换回字节数组,以便使用 base64 进行编码。通过这样做,我们不必将图像保存为外部文件。

不断上传图像到服务器

以前,单个图像被上传到服务器。现在,我们想不断上传图像到服务器。要做到这一点,客户端和服务器端的应用都将发生变化。

点击客户端 Kivy 应用的捕获按钮后,应用进入一个无限的while循环。在每次迭代中,一个摄像机图像被捕获并通过POST HTTP 消息发送到服务器。更新后的 Kivy 应用如列表 3-34 所示。

import kivy.app
import requests

class PycamApp(kivy.app.App):

    def cam_size(self):
        camera = self.root.ids['camera']
        cam_width_height = {'width': camera.resolution[0], 'height': camera.resolution[1]}

        ip_addr = self.root.ids['ip_address'].text
        url = 'http://'+ip_addr+':6666/camSize'

        try:
            self.root.ids['cam_size'].text = "Trying to Establish a Connection..."
            requests.post(url, params=cam_width_height)
            self.root.ids['cam_size'].text = "Done."
            self.root.remove_widget(self.root.ids['cam_size'])
        except requests.exceptions.ConnectionError:
            self.root.ids['cam_size'].text = "Connection Error! Make Sure Server is Active."

    def capture(self):
        while True:
            camera = self.root.ids['camera']

            pixels_data = camera.texture.get_region(x=camera.x, y=camera.y, width=camera.resolution[0], height=camera.resolution[1).pixels

            ip_addr = self.root.ids['ip_address'].text
            url = 'http://'+ip_addr+':6666/'
            files = {'media': pixels_data}

            try:
                self.root.ids['capture'].text = "Trying to Establish a Connection..."
                requests.post(url, files=files)
                self.root.ids['capture].text = "Capture Again!"
            except requests.exceptions.ConnectionError:
                self.root.ids['capture'].text = "Connection Error! Make Sure Server is Active."

    def build(self):
        pass

app = PycamApp()
app.run()

Listing 3-34Client-Side Application for Continuously Capturing and Uploading Images to the Server

在服务器端 Flask 应用中,为每个上传的图像打开一个新的浏览器选项卡。当我们想要持续上传图像时,这是一个问题。为了解决这个问题,我们使用一个名为html_opened的标志变量。默认设置为False,表示不打开标签页。上传第一张图片后,它将被设置为True,因此应用将不会打开任何其他标签。清单 3-35 中显示了更新后的 Flask 应用。

import flask
import base64
import webbrowser

app = flask.Flask(import_name="FlaskUpload")

cam_width = 0
cam_height = 0

html_opened = False

@app.route('/camSize', methods = ['GET', 'POST'])
def cam_size():
    global cam_width
    global cam_height

    cam_width = int(float(flask.request.args["width"]))
    cam_height = int(float(flask.request.args["height"]))

    print('Width',cam_width,'& Height',cam_height,'Received Successfully.')

    return "OK"

@app.route('/', methods = ['POST'])
def upload_file():
    global cam_width
    global cam_height
    global html_opened

    file_to_upload = flask.request.files['media'].read()

    print('File Uploaded Successfully.')

    im_base64 = base64.b64encode(file_to_upload)

    html_code = '<html><head><meta http-equiv="refresh" content="0.5"><title>Displaying Uploaded Image</title></head><body><h1>Uploaded Image to the Flask Server</h1><img src="data:;base64,'+im_base64.decode('utf8')+'" alt="Uploaded Image at the Flask Server"/></body></html>'

    html_url = "/home/ahmedgad/Desktop/FlaskUpload/templates/test.html"
    f = open(html_url,'w')
    f.write(html_code)
    f.close()

    if html_opened == False:
        webbrowser.open(html_url)
        html_opened = True

    return "SUCCESS"

app.run(host="192.168.43.231", port=6666, debug=True)

Listing 3-35Server-Side Application for Continuously Receiving the Uploaded Images and Displaying Them in the Web Browser

服务器应用的另一个变化是使用了一个<meta>标签,每 0.5 秒刷新一次 HTML 页面。

使用时钟控制图像上传速率

前面的应用使用 UI 线程将图像上传到服务器。这会挂起应用,并阻止用户与其小部件进行交互。

最好在另一个线程中而不是 UI 线程中执行耗时的操作。在我们的应用中,这种解决方案是不可行的。这是因为如果我们创建了一个新的线程,它仍然需要在每次捕获图像时从 UI 线程访问Camera小部件。

另一个解决方案是在一个新的线程而不是 UI 线程中将图像上传到服务器。这使得应用的 UI 比以前响应更快。此外,我们可以通过控制上传图像到服务器的速度来减缓这个过程。

使用kivy.clock.Clock对象,我们可以安排一个函数调用在将来执行。因为我们对将来多次执行该函数感兴趣,所以kivy.clock.Clock.schedule_interval()函数是一个不错的选择。它接受要执行的函数以及两次执行之间的秒数。Kivy 应用的修改代码如清单 3-36 所示。间隔设置为 0.5 秒。记得匹配schedule_interval()函数中上传图片和<meta>标签中刷新 HTML 页面的秒数。

import kivy.app
import requests
import kivy.clock
import kivy.uix.screenmanager
import threading

class Configure(kivy.uix.screenmanager.Screen):
    pass

class Capture(kivy.uix.screenmanager.Screen):
    pass

class PycamApp(kivy.app.App):
    num_images = 0

    def cam_size(self):
        camera = self.root.ids['camera']
        cam_width_height = {'width': camera.resolution[0], 'height': camera.resolution[1]}

        ip_addr = self.root.ids['ip_address'].text
        port_number = self.root.ids['port_number'].text
        url = 'http://' + ip_addr + ':' + port_number + '/camSize'

        try:
            self.root.ids['cam_size'].text = "Trying to Establish a Connection..."
            requests.post(url, params=cam_width_height)
            self.root.ids['cam_size'].text = "Done."
            self.root.current = "capture"
        except requests.exceptions.ConnectionError:
            self.root.ids['cam_size'].text = "Connection Error! Make Sure Server is Active."

    def capture(self):
        kivy.clock.Clock.schedule_interval(self.upload_images, 0.5)

    def upload_images(self, ∗args):
        self.num_images = self.num_images + 1
        print("Uploading image", self.num_images)

        camera = self.root.ids['camera']

        print("Image Size ", camera.resolution[0], camera.resolution[1])
        print("Image corner ", camera.x, camera.y)

        pixels_data = camera.texture.get_region(x=camera.x, y=camera.y, width=camera.resolution[0], height=camera.resolution[1]).pixels

        ip_addr = self.root.ids['ip_address'].text
        port_number = self.root.ids['port_number'].text
        url = 'http://' + ip_addr + ':' + port_number + '/'
        files = {'media': pixels_data}

        t = threading.Thread(target=self.send_files_server, args=(files, url))
        t.start()

    def build(self):
        pass

    def send_files_server(self, files, url):
        try:
            requests.post(url, files=files)
        except requests.exceptions.ConnectionError:
            self.root.ids['capture'].text = "Connection Error! Make Sure Server is Active."

app = PycamApp()
app.run()

Listing 3-36Uploading the Images in a New Thread

在这个例子中,创建了一个名为upload_images()的新函数来保存负责捕获和上传每张图片的代码。该函数为每个上传的图像增加一个名为num_images的变量。在该功能中,仅使用camera.texture.get_region()捕捉图像。为了上传它,在这个函数的末尾创建了一个新线程。

使用threading模块中的Thread类,我们可以创建新的线程。在该类的构造函数中,指定了线程target,它可以是线程运行后调用的函数。如果该函数接受参数,我们可以使用构造函数的args参数传递它们。

在我们的应用中,创建了一个名为send_files_server()的回调函数,它接受上传到服务器的图像以及服务器 URL。

运行 Kivy 和 Flask 应用后,打印到终端的消息表明执行成功。

Kivy 应用的终端执行如图 3-23 所示。

img/481739_1_En_3_Fig23_HTML.jpg

图 3-23

客户端应用的终端执行

图 3-24 显示了烧瓶应用的输出。

img/481739_1_En_3_Fig24_HTML.jpg

图 3-24

服务器端 Flask 应用的终端执行

我们现在已经创建了一个桌面 Kivy 应用,它访问相机,连续捕获图像,将它们上传到 Flask 服务器,并在 web 浏览器中显示捕获的图像。下一步是构建 Android 应用。

构建实时摄像头预览 Android 应用

我们需要对客户端桌面 Kivy 应用进行一些更改,以使其适合作为 Android 应用。

我们必须使用Rotate上下文指令将Camera小部件旋转-90 度,因为 Android 摄像头默认旋转 90 度。这在本章前面已经讨论过了。旋转小部件的 KV 文件如清单 3-37 所示。

BoxLayout:
    orientation: "vertical"
    Camera:
        id: camera
        size_hint_y: 18
        resolution: (1280, 720)
        play: True
        canvas.before:
            PushMatrix:
            Rotate:
                angle: -90
                origin: root.width/2, root.height/2
        canvas.after:
            PopMatrix:
    TextInput:
        text: "192.168.43.231"
        id: ip_address
        size_hint_y: 1
    Button:
        id: capture
        text: "Capture"
        size_hint_y: 1
        on_press: app.capture()
    Button:
        id: cam_size
        text: "Configure Server"
        size_hint_y: 1
        on_press: app.cam_size()

Listing 3-37Rotating the Image 90 Degrees for the Android Application

请注意,旋转Camera小部件并不意味着上传到服务器的图像也会被旋转。这个操作只是旋转显示摄像机图像的Camera小部件。捕获的图像仍然旋转 90 度。因此,我们需要修改 Flask 应用,以便在 web 浏览器中显示之前将每个捕获的图像旋转-90°。如清单 3-38 所示。字节数组被转换成旋转 90 度的 PIL 图像。最后,旋转后的图像被转换回字节数组,以便根据 base64 进行编码。

import flask
import base64
import PIL.Image
import webbrowser

app = flask.Flask(import_name="FlaskUpload")

cam_width = 0
cam_height = 0

html_opened = False

@app.route('/camSize', methods = ['GET', 'POST'])
def cam_size():
    global cam_width
    global cam_height

    cam_width = int(float(flask.request.args["width"]))
    cam_height = int(float(flask.request.args["height"]))

    print('Width',cam_width,'& Height',cam_height,'Received Successfully.')

    return "OK"

@app.route('/', methods = ['POST'])
def upload_file():
    global cam_width
    global cam_height
    global html_opened

    file_to_upload = flask.request.files['media'].read()

    image = PIL.Image.frombytes(mode="RGBA", size=(cam_width, cam_height), data=file_to_upload)
    image = image.rotate(-90)

    print('File Uploaded Successfully.')

    im_base64 = base64.b64encode(image.tobytes())

    html_code = '<html><head><meta http-equiv="refresh" content="0.5"><title>Displaying Uploaded Image</title></head><body><h1>Uploaded Image to the Flask Server</h1><img src="data:;base64,'+im_base64.decode('utf8')+'" alt="Uploaded Image at the Flask Server"/></body></html>'

    html_url = "/home/ahmedgad/Desktop/FlaskUpload/templates/test.html"
    f = open(html_url,'w')
    f.write(html_code)
    f.close()

    if html_opened == False:
        webbrowser.open(html_url)
        html_opened = True

    return "SUCCESS"

app.run(host="192.168.43.231", port=6666, debug=True)

Listing 3-38Rotating the Captured Images at the Server by 90 Degrees

准备好客户端和服务器端应用后,我们可以根据下面的终端命令构建 Android 应用。

ahmedgad@ubuntu:~/Desktop/NewApp$ buildozer android debug deploy run logcat

确保将路径更改为应用根目录,其中存在buildozer.spec文件,并激活虚拟环境(如果您在虚拟环境中准备了开发环境)。

Android 应用的窗口如图 3-25 所示。

img/481739_1_En_3_Fig25_HTML.jpg

图 3-25

Android 应用的窗口,用于持续上传图像

点击捕获按钮后,Android 应用会连续捕获图像并上传到服务器,在服务器上以 HTML 页面显示。图 3-26 显示了显示的图像之一。

img/481739_1_En_3_Fig26_HTML.jpg

图 3-26

在服务器的网络浏览器中显示的上传图像

摘要

本章讨论了通过Camera小部件访问 Android 摄像头。在构建 Android 应用之前,我们创建了一个桌面应用来确保一切按预期运行。我们使用 Buildozer 构建了 Android 应用。为了获得访问 Android 摄像头的许可,我们必须更新buildozer.init文件中的android.permissions字段。因为安卓摄像头默认旋转 90 度,所以必须旋转回来。这是使用基维画布完成的。讨论了三个画布实例— canvascanvas.beforecanvas.after。为了将给定指令的效果限制在某些小部件上,我们讨论了PushMatrixPopMatrix指令。

在适当的角度预览相机后,图像被捕获,以便上传到 HTTP 服务器。服务器是使用 Flask 创建的,并在台式计算机上运行。使用服务器的 IPv4 地址和端口号,requests Python 库将使用 Kivy Android 应用和 HTTP POST消息上传捕获的图像。

在本章的最后,我们在服务器的网络浏览器中预览了 Android 摄像头。为了节省时间,图像以字节数组的形式保存在设备存储器中,而不是保存在设备存储器中。这样的字节数组然后被上传到服务器。然后,服务器解释这些字节数组,并通过网页浏览器中的 HTML 页面显示图像。

在下一章中,通过将按钮分离到不同的屏幕中,为实时预览项目创建了一个更方便的设计。Kivy 支持用于构建屏幕的Screen类和用于管理此类屏幕的ScreenManager类。我们可以从一个屏幕导航到另一个屏幕。为了理解如何创建一个有多个屏幕的应用,下一章从讨论如何创建定制的小部件开始。

四、创建和管理多个屏幕

在前一章中,我们使用相机小部件访问了 Android 相机。引入了 Kivy 画布来调整摄像机的旋转。为了限制给定 canvas 指令对某些小部件的影响,我们讨论了PushMatrixPopMatrix指令。之后,我们创建了一个 Android Kivy 应用来连续捕捉图像并将它们发送到 Flask 服务器,后者将它们显示在一个 HTML 页面中。

在这一章中,我们通过将按钮分离到不同的屏幕来创建一个更方便的设计。Kivy 支持用于构建屏幕的Screen类和用于管理此类屏幕的ScreenManager类。我们可以从一个屏幕导航到另一个屏幕。本章从讨论如何创建定制小部件开始,这将帮助我们理解如何创建一个具有多个屏幕的应用。

修改现有小部件

Kivy 支持许多现有的小部件,比如ButtonLabelTextInput等等。它支持修改现有的小部件来覆盖它们的默认行为。我们可以使用Label小部件作为测试用例。

Label类包含一些默认值作为它的属性。例如,默认情况下,text 属性设置为空字符串,文本颜色为白色,默认字体大小等于 15 SP(与缩放比例无关的像素)。我们将根据清单 4-1 中显示的 KV 代码覆盖这三个属性。标签的文字设置为"Hello",文字颜色为红色,字体大小为 50 SP。

Label:
    text: "Hello"
    color: 1,0,0,1
    font_size: "50sp"

Listing 4-1Overriding Properties of a Widget Inside the KV File

清单 4-2 中显示的 Python 代码创建了一个名为TestApp的新类,该类扩展了用于构建新应用的kivy.app.App类。它假设您将之前的 KV 代码保存在一个名为test.kv的文件中。

import kivy.app

class TestApp(kivy.app.App):
    pass

app = TestApp()
app.run()

Listing 4-2The Generic Code for Building a Kivy Application

当您运行应用时,您将在图 4-1 中看到结果。属性已正确更改。您可能想知道这些属性是否会因新标签而改变。我们可以通过创建一个新的标签小部件来回答这个问题。

img/481739_1_En_4_Fig1_HTML.jpg

图 4-1

一个只有标签部件的 Kivy 应用

清单 4-3 中的新 KV 代码创建了一个保存两个标签的BoxLayout。第一个标签的属性是根据前面的示例设置的,而第二个标签只是将其文本更改为“第二个标签”。

BoxLayout:
    Label:
        text: "Hello"
        color: 1,0,0,1
        font_size: "50sp"
    Label:
        text: "Second Label"

Listing 4-3Adding Two Label Widgets to the Application Inside the BoxLayout Root Widget

运行应用后,第二个标签没有根据图 4-2 中的窗口改变颜色和字体大小。原因是这两个标签都是Label类的独立实例。当创建一个新实例时,它从Label类继承属性的默认值。如果给定实例的某些属性发生了更改,这并不意味着其他实例的属性也会发生更改。为了使两个标签具有相同的文本颜色,我们可以改变Label类的color属性。因此,它的所有实例都将继承这种颜色。

img/481739_1_En_4_Fig2_HTML.jpg

图 4-2

仅覆盖一个标签小部件的属性,而将另一个设置为默认值

为了编辑 KV 文件中的类,类名被插入到<>之间,没有任何缩进。清单 4-4 中的 KV 文件覆盖了Label类的文本颜色和字体大小属性。通过创建Label类的两个实例,两者都将根据图 4-3 继承文本颜色和字体大小。

img/481739_1_En_4_Fig3_HTML.jpg

图 4-3

更改所有标签小部件的属性

BoxLayout:
    Label:
        text: "Hello"
    Label:
        text: "Second Label"

<Label>:
    color: 1,0,0,1
    font_size: "50sp"

Listing 4-4Editing a Class in the KV Language

创建自定义小部件

清单 4-4 中的代码修改了Label类,使得它的所有实例都具有指定的文本颜色和字体大小。先前的属性会丢失。有时,我们可能会对此类属性以前的默认值感兴趣。

为了保持Label类以前的属性,我们可以创建一个新的自定义类来扩展Label类。这个自定义类继承了父类Label的默认属性,我们也可以修改它的一些属性。

清单 4-5 中的 KV 代码创建了一个名为CustomLabel的新定制类,它继承了Label类。因此,如果您需要创建一个带有默认属性的标签小部件,您可以实例化Label类。要使用修改后的属性,实例化CustomLabel类。在这个例子中,第一个标签是CustomLabel类的一个实例,其中文本颜色和字体大小被改变。第二个标签是Label类的一个实例,具有这两个属性的默认值。

BoxLayout:
    CustomLabel:
        text: "Hello"
    Label:
        text: "Second Label"

<CustomLabel@Label>:
    color: 1,0,0,1
    font_size: "50sp"

Listing 4-5Creating a New Custom Label Widget by Extending the Label Class Inside the KV File

使用该 KV 文件运行应用后的结果如图 4-4 所示。

img/481739_1_En_4_Fig4_HTML.jpg

图 4-4

使用自定义标签小部件

在 Python 中定义自定义类

在清单 4-5 中,在 KV 文件中创建了一个名为CustomLabel的新定制类,它继承了Label类并修改了它的一些属性。在 KV 文件中进行继承限制了新类的能力,因为我们不能在其中编写函数。

我们可以创建新的类,并在 Python 代码中进行继承。然后,我们将在 KV 文件中引用这个类来修改它的属性。这有助于在新的自定义类中编写 Python 函数。清单 4-6 中的例子创建了一个名为CustomLabel的新的空类,它扩展了Label类。

import kivy.app
import kivy.uix.label

class CustomLabel(kivy.uix.label.Label):
    pass

class TestApp(kivy.app.App):
    pass

app = TestApp()
app.run()

Listing 4-6Inheriting the Label Class Within the Python File

test.kv文件的内容如清单 4-7 所示。注意,我们只是引用了 KV 中现有的类,而不是像上一节那样创建它。

BoxLayout:
    CustomLabel:
        text: "Hello"
    Label:
        text: "Second Label"

<CustomLabel>:
    color: 1,0,0,1
    font_size: "50sp"

Listing 4-7Referring to the Custom Class Created in the Python File Inside the KV File

我们可以通过在名为MyLayout的 Python 文件中创建一个扩展了BoxLayout类的类来稍微改变一下前面的应用,如清单 4-8 所示。因为这个类继承了BoxLayout类,所以我们可以在任何使用BoxLayout的地方使用它。例如,我们可以用新的类替换 KV 文件中的BoxLayout

import kivy.app
import kivy.uix.label
import kivy.uix.boxlayout

class CustomLabel(kivy.uix.label.Label):
    pass

class MyLayout(kivy.uix.boxlayout.BoxLayout):
    pass

class TestApp(kivy.app.App):
    def build(self):
        return MyLayout()

app = TestApp()
app.run()

Listing 4-8Creating a New Custom Layout by Extending the BoxLayout Class

清单 4-9 中给出了新的 KV 文件。它通过在<>之间添加名称来引用自定义的MyLayout类。这个类有两个子部件,分别是CustomLabelLabel

注意,我们必须在TestApp类中定义build()函数来返回MyLayout类的一个实例。这是因为 KV 文件本身不会为TestApp返回布局。KV 文件简单地创建了两个名为MyLayoutCustomLabel的定制小部件。

<MyLayout>:
    CustomLabel:
        text: "Hello"
    Label:
        text: "Second Label"

<CustomLabel>:
    color: 1,0,0,1
    font_size: "50sp"

Listing 4-9Referencing the Custom BoxLayout Class Inside the KV File

我们还可以根据清单 4-10 中的 KV 文件返回 KV 文件中 TestApp 类的布局。在本例中,KV 文件定义了两个新的小部件,并返回了一个名为MyLayout的小部件。这个小部件代表了TestApp类的布局。Python 代码目前不必实现build()函数。

MyLayout:

<MyLayout>:
    CustomLabel:
        text: "Hello"
    Label:
        text: "Second Label"

<CustomLabel>:
    color: 1,0,0,1
    font_size: "50sp"

Listing 4-10Using the Custom BoxLayout Class

此时,我们能够在 Python 文件中创建一个新的类来扩展一个小部件类,在 KV 文件中引用它,并修改它的一些属性。这使我们能够开始学习如何创建一个具有多个屏幕的应用。

创建和管理屏幕

以前,在构建应用时会创建一个自定义类来扩展kivy.app.App类。该应用有一个窗口,我们可以在其中添加小部件。所有小工具都在一个屏幕内。有时,我们需要将同一个应用的小部件组织到不同的屏幕中,每个屏幕做不同的工作。Kivy 里的屏幕和 Android 里的活动差不多。一个 Android 应用可以有多个活动,一个 Kivy 应用可以有多个屏幕。

为了创建一个屏幕,我们将扩展kivy.uix.screenmanager.Screen类,而不是扩展kivy.app.App类。清单 4-11 显示了创建两个名为Screen1Screen2的类的 Python 文件,每个屏幕一个,扩展了Screen类。还有一个应用类叫做TestApp

import kivy.app
import kivy.uix.screenmanager

class Screen1(kivy.uix.screenmanager.Screen):
    pass

class Screen2(kivy.uix.screenmanager.Screen):
    pass

class TestApp(kivy.app.App):
    pass

app = TestApp()
app.run()

Listing 4-11Creating Two Screens by Extending the Screen Class

根据清单 4-11 中的 Python 代码,创建了两个空屏幕。它们的布局在与该应用相关的test.kv文件中给出,如清单 4-12 所示。注意屏幕类名写在<>之间。每个屏幕都有一个name属性。两个屏幕的名字分别是Screen1Screen2。有一个屏幕管理器有两个孩子,这是两个屏幕。屏幕管理器有一个名为current的属性,它告诉窗口中哪个屏幕当前是活动的。该属性接受屏幕名称。每个屏幕都有一个名为manager的属性,对应于屏幕的管理者。我们可以用它来访问 KV 文件中的管理器。

ScreenManager:
   Screen1:
   Screen2:

<Screen1>:
    name: "Screen1"
    Button:
        text: "Button @ Screen 1"
        on_press: root.manager.current = "Screen2"

<Screen2>:
    name: "Screen2"
    Button:
        text: "Button @ Screen 2"
        on_press: root.manager.current = "Screen1"

Listing 4-12Defining the Layout of the Two Screens and Adding Them as Children to the ScreenManager Class

为了从一个屏幕切换到另一个屏幕,我们在每个屏幕上添加了一个按钮。当这样的按钮被按下时,使用root.manager.current属性改变当前屏幕。在第一个屏幕中,当前屏幕变为第二个屏幕。第二个屏幕的情况正好相反。如果当前属性没有在屏幕管理器中指定,它默认为管理器中的第一个屏幕。图 4-5 显示了运行应用后的结果。

img/481739_1_En_4_Fig5_HTML.jpg

图 4-5

屏幕管理器中添加的第一个屏幕显示为应用启动屏幕

点击按钮使用管理器的current属性改变当前屏幕,如图 4-6 所示。

img/481739_1_En_4_Fig6_HTML.jpg

图 4-6

从一个屏幕移动到另一个屏幕

我们可以明确指定当应用开始使用current属性时应该显示哪个屏幕,如清单 4-13 所示。当应用启动时,它将打开第二个屏幕。

ScreenManager:
   current: "Screen2"
   Screen1:
   Screen2:

Listing 4-13Using the current Property of the ScreenManager Class to Explicitly Specify the Startup Screen

访问屏幕内的小部件

添加屏幕及其管理器后,小组件树如下所示。根小部件是ScreenManager,它包含两个子部件Screen。每个屏幕都有一个Button小部件。为了理解如何访问树中的特定部件,研究部件树是很重要的。

  • 应用
    • Root(屏幕管理器)
      • 屏幕

        1. 纽扣
      • 屏幕 2

        1. 纽扣

假设我们需要从 KV 文件访问第一个屏幕中的按钮。我们如何做到这一点?首先,我们需要使用app关键字访问应用本身。然后,使用root关键字访问应用的根小部件。注意,根小部件是一个ScreenManager。因此,当前命令是app.root。根小部件中的子部件是可以使用screens属性访问的屏幕。app.root.screens命令返回管理器中可用屏幕的列表,如下一行所示:

[<Screen name="Screen1">, <Screen name="Screen2">]

第一个屏幕是列表的第一个元素,因此可以使用索引 0 来访问。因此,访问第一个屏幕的完整命令是app.root.screens[0]

在访问目标屏幕后,我们可以像以前一样使用ids字典访问其中的按钮。假设按钮的 ID 为b1。如果是这种情况,访问该按钮的命令如下:

app.root.screens[0].ids["b1"]

在创建屏幕并使用屏幕管理器控制它们之后,我们可以开始修改前面的项目,将小部件分隔在两个屏幕上。

修改实时摄像机捕捉应用以使用屏幕

在前一章的清单 3-37 和 3-38 中,创建了一个 Kivy 应用,该应用持续捕获要发送到 HTTP 服务器的图像,在那里接收到的图像显示在一个 HTML 页面中。配置和捕获图像所需的所有小部件都在同一个屏幕上。在本节中,他们将被分成不同的屏幕,每个屏幕都有特定的工作要做。

第一步是通过添加两个屏幕来准备 Python 文件。第一个屏幕用要捕捉的图像的宽度和高度配置服务器。第二个屏幕捕捉图像并将其发送到服务器。清单 4-14 中修改后的 Python 代码有两个新类,名为ConfigureCapture,它们扩展了Screen类。

import kivy.app
import requests
import kivy.clock
import kivy.uix.screenmanager
import threading

class Configure(kivy.uix.screenmanager.Screen):
    pass

class Capture(kivy.uix.screenmanager.Screen):
    pass

class PycamApp(kivy.app.App):
    num_images = 0

    def cam_size(self):
        camera = self.root.screens[1].ids['camera']
        cam_width_height = {'width': camera.resolution[0], 'height': camera.resolution[1]}

        ip_addr = self.root.screens[0].ids['ip_address'].text

        port_number = self.root.screens[0].ids['port_number'].text
        url = 'http://' + ip_addr + ':' + port_number + '/camSize'

        try:
            self.root.screens[0].ids['cam_size'].text = "Trying to Establish a Connection..."
            requests.post(url, params=cam_width_height)
            self.root.screens[0].ids['cam_size'].text = "Done."
            self.root.current = "capture"
        except requests.exceptions.ConnectionError:
            self.root.screens[0].ids['cam_size'].text = "Connection Error! Make Sure Server is Active."

    def capture(self):
        kivy.clock.Clock.schedule_interval(self.upload_images, 1.0)

    def upload_images(self, *args):
        self.num_images = self.num_images + 1
        print("Uploading image", self.num_images)

        camera = self.root.screens[1].ids['camera']

        print("Image Size ", camera.resolution[0], camera.resolution[1])
        print("Image corner ", camera.x, camera.y)

        pixels_data = camera.texture.get_region(x=camera.x, y=camera.y, width=camera.resolution[0], height=camera.resolution[1]).pixels

        ip_addr = self.root.screens[0].ids['ip_address'].text

        port_number = self.root.screens[0].ids['port_number'].text
        url = 'http://' + ip_addr + ':' + port_number + '/'
        files = {'media': pixels_data}

        t = threading.Thread(target=self.send_files_server, args=(files, url))
        t.start()

    def build(self):
        pass

    def send_files_server(self, files, url):
        try:
            requests.post(url, files=files)
        except requests.exceptions.ConnectionError:
            self.root.screens[1].ids['capture'].text = "Connection Error! Make Sure Server is Active."

app = PycamApp()
app.run()

Listing 4-14Using the Screen Class to Redesign the Live Camera Capture Application Created in Listing 3-37

KV 文件中的小部件树如下所示。请注意,小部件在两个屏幕上是分开的。

  • 应用
    • 根(ScreenManager)

    • 配置屏幕

      • BoxLayout

        • Label

        • TextInput ( ip_address)

        • TextInput ( port_number)

        • Button ( cam_size)

      • 捕获屏幕

        • BoxLayout
          • Camera ( camera)

          • Button ( capture)

应用的 KV 文件如清单 4-15 所示,其中每个屏幕都有一个BoxLayout用于分组其小部件。配置屏幕有一个Label小部件,为用户显示说明。有两个TextInput小部件,用户可以在其中输入 IPv4 地址和服务器监听请求的端口号。它还包括Button小部件,用于根据摄像机的尺寸发送POST消息。捕捉屏幕包括相机小部件本身和一个开始捕捉图像的按钮。

两个屏幕都分组在ScreenManager下。请注意,配置屏幕是添加到管理器的第一个屏幕,因此它将在应用启动时显示。

ScreenManager:
    Configure:
    Capture:

<Capture>:
    name: "capture"
    BoxLayout:
        orientation: "vertical"
        Camera:
            id: camera
            size_hint_y: 18
            resolution: (1024, 1024)
            allow_stretch: True
            play: True
            canvas.before:
                PushMatrix:
                Rotate:
                    angle: -90
                    origin: root.width/2, root.height/2
            canvas.after:
                PopMatrix:
        Button:
            id: capture
            font_size: 30
            text: "Capture"
            size_hint_y: 1
            on_press: app.capture()

<Configure>:
    name: "configure"

    BoxLayout:
        orientation: "vertical"
        Label:
            text: "1) Enter the IPv4 address of the server.\n2) Enter the port number. \n3) Press the Configure Server button. \nMake sure that the server is active."
            font_size: 20
            text_size: self.width, None
            size_hint_y: 1
        TextInput:
            text: "192.168.43.231"
            font_size: 30
            id: ip_address
            size_hint_y: 1
        TextInput:
            text: "6666"
            font_size: 30
            id: port_number
            size_hint_y: 1
        Button:
            id: cam_size
            font_size: 30
            text_size: self.width, None
            text: "Configure Server"
            size_hint_y: 1
            on_press: app.cam_size()

Listing 4-15The KV File of the Live Camera Capture Project After Using Screens

一旦用户按下配置服务器的按钮,就会返回摄像头小部件的尺寸,并根据从TextInput小部件中检索到的 IPv4 地址和端口号向服务器发送一条POST消息。第一屏如图 4-7 所示。

img/481739_1_En_4_Fig7_HTML.jpg

图 4-7

指定 IP 和端口号的应用的主屏幕

消息发送成功后,管理器的当前屏幕变为截图屏幕,如图 4-8 所示。在该屏幕中,用户可以按下捕获按钮,以便开始捕获并将捕获的图像发送到服务器。

img/481739_1_En_4_Fig8_HTML.jpg

图 4-8

应用中的第二个屏幕,在这里可以捕捉图像并将其发送到服务器

请注意如何从小部件树中访问小部件。如前一节所述,ScreenManager是根,有两个屏幕。每个屏幕都有许多小部件,可以使用它们的 id 进行访问。例如,可以使用以下命令从 KV 文件访问 Camera 小部件。

app.root.screens[1].ids['camera']

在这个项目中,我们对从 KV 文件引用小部件不感兴趣,而是从 Python 文件引用。例如,必须从PycamApp类的cam_size()函数内部访问 Camera 小部件。在这种情况下,与前一个命令的区别在于如何访问应用。可以使用self关键字来引用它。因此,用于访问 Python 中的 Camera 小部件的命令如下。

self.root.screens[1].ids['camera']

我们使用索引为1screen,因为 Camera 小部件位于其中。这样,我们成功地从索引为1的第二个屏幕访问了一个小部件。如果我们需要访问 ID 为ip_addressTextInput小部件,这可以在 Python 代码的第一个屏幕中找到,那么使用下一个命令。除了小部件的 ID 之外,只需指定屏幕的索引。

self.root.screens[0].ids['ip_address']

要访问端口号,使用下一个命令:

self.root.screens[0].ids['port_number']

完成服务器端和客户端应用后,我们就可以开始发布它们了。

发布服务器端应用

为了从 Python 项目创建可执行文件,我们可以使用 PyInstaller 库。我们可以使用pip install pyinstaller命令安装这个库。

在构建可执行文件之前,我们可以稍微改变一下服务器应用。这是因为它不允许我们更改 IPv4 地址和端口号。我们曾经使用以下终端命令来执行服务器应用:

ahmedgad@ubuntu:~/Desktop$ python3 FlaskServer.py

当从终端执行一个 Python 文件时,一些参数在sys.argv列表中传递给它。如果在终端中没有指定参数,那么列表中将有一个包含 Python 脚本名称的项目,可以通过以下命令访问该项目:

sys.argv[0]

参数可以列在 Python 脚本的名称之后。例如,下一个命令将 IPv4 地址和端口号作为参数传递给脚本。

ahmedgad@ubuntu:~/Desktop$ python3 FlaskServer.py 192.168.43.231 6666

为了访问 Python 脚本中的 IPv4 地址并将其存储在名为ip_address的变量中,我们使用了下一个命令。使用索引1,因为它是列表中的第二个参数。

ip_address = sys.argv[1]

同样,使用下一个命令将端口号存储到port_number变量中。请注意,使用了索引2

port_number = sys.argv[2]

清单 4-16 中列出的服务器应用的新 Python 代码从终端参数中获取 IPv4 地址和端口号。在app.run()方法中,主机和端口参数从ip_addressport_number变量中取值,而不是静态定义的。

import flask
import PIL.Image
import base64
import webbrowser
import sys
import os

app = flask.Flask(import_name="FlaskUpload")

cam_width = 0
cam_height = 0

html_opened = False

@app.route('/camSize', methods = ['GET', 'POST'])
def cam_size():
    global cam_width
    global cam_height

    cam_width = int(float(flask.request.args["width"]))
    cam_height = int(float(flask.request.args["height"]))

    print('Width',cam_width,'& Height',cam_height,'Received Successfully.')

    return "OK"

@app.route('/', methods = ['POST'])
def upload_file():
    global cam_width
    global cam_height
    global html_opened

    file_to_upload = flask.request.files['media'].read()

    image = PIL.Image.frombytes(mode="RGBA", size=(cam_width, cam_height), data=file_to_upload)
    image = image.rotate(-90)
    print('File Uploaded Successfully.')

    im_base64 = base64.b64encode(image.tobytes())

    html_code = '<html><head><meta http-equiv="refresh" content="1"><title>Displaying Uploaded Image</title></head><body><h1>Uploaded Image to the Flask Server</h1><img src="data:;base64,'+im_base64.decode('utf8')+'" alt="Uploaded Image at the Flask Server"/></body></html>'

    # The HTML page is not required to be opened from the Python code but open it yourself externally.
    html_url = os.getcwd()+"/templates/test.html"
    f = open(html_url,'w')
    f.write(html_code)
    f.close()

    if html_opened == False:
        webbrowser.open(html_url)
        html_opened = True

    return "SUCCESS"

ip_address = sys.argv[1]#"192.168.43.231"
port_number = sys.argv[2]#6666
app.run(host=ip_address, port=port_number, debug=True, threaded=True)

Listing 4-16Modified Python Code for the Server-Side Application for Fetching the IPv4 Address and Port Number from the Command-Line Arguments

安装后,可以使用以下命令将项目转换为可执行文件。只需将<python-file-name>替换为服务器的 Python 文件名。--onefile选项使 PyInstaller 生成一个二进制文件。只要未指定 Python 文件的完整路径,请确保在执行该文件的位置执行该命令。

pyinstaller --onefile <python-file-name>.py

命令完成后,二进制文件将存在于dist文件夹中,根据 Python 文件名命名。PyInstaller 为正在使用的操作系统创建一个可执行文件。如果在 Linux 机器上执行这个命令,那么就会产生一个 Linux 二进制文件。如果在 Windows 中执行,那么将创建一个 Windows 可执行文件(.exe)。

可执行文件可以存放在您选择的存储库中,用户可以下载并运行服务器。Linux 可执行文件可在此页面下载,文件名为: https://www.linux-apps.com/p/1279651 。因此,为了运行服务器,只需下载文件并运行下面的终端命令。记得根据 CamShare 的当前路径更改终端的路径。

ahmedgad@ubuntu:~/Desktop$ python3 CamShare 192.168.43.231 6666

将客户端 Android 应用发布到 Google Play

之前的 APK 文件只是用于调试,不能在 Google Play 上发布,因为它只接受发布版 apk。为了创建应用的发布版本,我们使用下面的命令:

ahmedgad@ubuntu:~/Desktop$ buildozer android release

为了在 Google Play 上被接受,在您的发布 APK 上签名非常重要。有关签署 APK 的说明,请阅读本页: https://github.com/kivy/kivy/wiki/Creating-a-Release-APK 。还记得将目标 API 级别至少设置为 26,如前所述。

您可以在 Google Play 创建一个开发者帐户来发布您自己的应用。CamShare Android 应用可在此处获得: https://play.google.com/store/apps/details?id=camshare.camshare.myapp

您可以下载 Android 应用,将其连接到服务器,并捕捉将在服务器上的 HTML 页面中显示的图像。

摘要

作为总结,本章介绍了通过扩展 Kivy 小部件来构建定制小部件。这允许我们编辑它们的属性一次,并多次使用它们。本章还介绍了用于跨多个屏幕组织应用小部件的ScreenScreenManager类。为了指定应用一启动就显示哪个屏幕,ScreenManager的当前属性被设置为所需屏幕的名称。这些屏幕用于重新设计第三章中现场摄像机捕捉项目的界面。

在下一章中,将应用本章和所有前面章节中介绍的 Kivy 概念来创建一个多关卡跨平台游戏,在该游戏中,玩家收集大量随机分布在屏幕上的硬币。会有怪物试图杀死玩家。下一章通过让游戏开发变得非常简单并解释每一行代码,让读者从零到英雄。

五、在 Kivy 建立你的第一个多关卡游戏

前一章介绍了 Kivy,这样我们就可以开始构建跨平台的应用。作为应用概念的一种方式,我们创建了一个 Android 应用,它可以捕获图像并不断地将它们发送到 Flask 服务器。

本章通过创建一个多关卡跨平台游戏来应用这些相同的概念,玩家在每个级别都有一个任务,就是收集随机分布在屏幕上的硬币。怪物试图杀死收集硬币的玩家。这款游戏可以在不同的平台上成功运行,而无需我们修改任何一行代码。在你学习如何构建游戏之前,我们将介绍一些新概念,包括FloatLayout和动画。

浮动布局

在前面的章节中,BoxLayout小部件用于对多个小部件进行分组。小部件以有序的方式添加到该布局中,根据方向可以是水平的或垂直的。小部件的大小是由布局计算的,对它的控制很小。在本章我们将要创建的游戏中,一些小部件不会遵循预先定义的顺序。我们需要定制它们的尺寸,并自由地将它们移动到任何位置。比如根据触摸位置放置主角。出于这个原因,我们将使用FloatLayout小部件。它根据每个小部件中指定的 x 和 y 坐标放置小部件。

清单 5-1 显示了用于构建 Kivy 应用的通用代码,其中的子类被命名为TestApp

import kivy.app

class TestApp(kivy.app.App):
    pass

app = TestApp()
app.run()

Listing 5-1Generic Python Code to Build a Kivy Application

基于类名,KV 文件必须被命名为test.kv,以便隐式地检测它。test.kv文件内容如清单 5-2 所示。正好有一个FloatLayout小部件带着一个子,叫Button。注意在Button小部件中有两个重要的字段— size_hintpos_hint。与BoxLayout相比,使用FloatLayout添加的小部件可能不会扩展整个屏幕的宽度或高度。

FloatLayout:
    Button:
        size_hint: (1/4, 1/4)
        pos_hint: {'x': 0.5,'y': 0.5}
        text: "Hello"

Listing 5-2KV File with FloatLayout as the Root Widget

如果您运行该应用,您将会看到图 5-1 中的窗口。

img/481739_1_En_5_Fig1_HTML.jpg

图 5-1

在浮动布局中添加了一个按钮

默认情况下,小部件被添加到父窗口的(0,0)位置,该位置对应于窗口的左下角。因此,我们需要移动小部件,以避免将它们放置在彼此之上。pos_hint字段接受一个字典,其中有两个字段指定小部件左下角和窗口左下角之间的距离。该距离相对于父尺寸。

x 的值为 0.5 意味着按钮将离开窗口的左侧 50%的父宽度。y 值为 0.5 意味着按钮将远离窗口底部父高度的 50%。这样,Button小部件的左下角从布局的中心开始。请注意,相对定位是处理不同尺寸屏幕的有效方式。

size_hint字段指定小部件相对于其父尺寸的尺寸。它接受一个保存小部件相对宽度和高度的元组。在此示例中,按钮的宽度和高度被设置为 1/4,这意味着按钮大小是父大小的 40%(即,四分之一)。

注意pos_hintsize_hint字段不能保证改变小部件的大小或位置。小部件只是给父部件一个提示,它希望根据指定的值来设置它的位置和大小。有些布局听从它的请求,至于哪些布局忽略了它。在前面的例子中,如果根据清单 5-3 中的代码将FloatLayout替换为BoxLayout,那么根据图 5-2 的布局将不会应用一些提示。请注意,默认方向是水平的。

BoxLayout:
    Button:
        size_hint: (1/4, 1/4)
        pos_hint: {'x': 0.5,'y': 0.5}
        text: "Hello"

Listing 5-3Horizontal BoxLayout Orientation Does Not Listen to the Width Hint

因为按钮是其水平BoxLayout父级中的唯一子级,所以它的左下角应该从(0,0)位置开始。根据图 5-2 ,按钮不是从(0,0)位置开始。它的 x 坐标如预期的那样从 0 开始,但是它的 y 坐标从父代高度的一半开始。结果家长只是听了关于 Y 位置的暗示。

img/481739_1_En_5_Fig2_HTML.jpg

图 5-2

即使宽度提示设置为 1/4,该按钮也会扩展整个屏幕宽度

关于按钮大小,它应该覆盖整个窗口,因为它是父窗口中的唯一子窗口。这在前面的例子中没有发生。高度是父项高度的 1/4,但宽度扩展到父项的整个宽度。

总结一下,当pos_hintsize_hint字段与BoxLayout一起使用时,只有高度和 Y 位置发生了变化,而宽度和 X 位置没有变化。原因是水平方向的BoxLayout只听取与 Y 轴相关的提示(例如,高度和 Y 位置)。如果根据清单 5-4 使用垂直方向,宽度和 X 位置会改变,但是高度和 Y 位置不会根据图 5-3 改变。这就是为什么FloatLayout被用来动态定位和调整窗口小部件的大小。

img/481739_1_En_5_Fig3_HTML.jpg

图 5-3

即使高度提示设置为 1/4,该按钮也会扩展屏幕的整个高度

BoxLayout:
    orientation: 'vertical'
    Button:
        size_hint: (1/4, 1/4)
        pos_hint: {'x': 0.5,'y': 0.5}
        text: "Hello"

Listing 5-4Vertical Orientation for BoxLayout Does Not Listen to the Height Hint

注意,pos_hint域改变了 X 和 Y 坐标。如果我们只对改变一个而不是两个感兴趣,我们可以在字典中指定。请注意,字典中还有其他需要指定的项目,如toprightcenter_xcenter_y

此外,size_hint字段指定了宽度和高度。我们可以使用size_hint_x来指定宽度,或者使用size_hint_y来指定高度。因为水平方向的BoxLayout不会改变小部件的 X 位置和宽度,所以我们可以避免指定它们。清单 5-5 使用较少的提示产生了相同的结果。

BoxLayout:
    Button:
        size_hint_y: 1/4
        pos_hint: {'y': 0.5}
        text: "Hello"

Listing 5-5Just Specifying the Height Hint Using size_hint_y

假设我们想向FloatLayout添加两个小部件,其中第一个小部件从(0,0)位置开始,延伸到布局的中心,第二个小部件从父窗口宽度和高度的 75%开始,延伸到它的右上角。清单 5-6 显示了构建这样一个小部件树所需的 KV 文件。结果如图 5-4 所示。

img/481739_1_En_5_Fig4_HTML.jpg

图 5-4

在 FloatLayout 中添加两个按钮

FloatLayout:
    Button:
        size_hint: (0.5, 0.5)
        text: "First Button"
    Button:
        size_hint: (0.25, 0.25)
        pos_hint: {'x': 0.75, 'y': 0.75}
        text: "Second Button"

Listing 5-6Adding Two Buttons Inside FloatLayout

第一个按钮大小的size_hint字段的宽度和高度都设置为 0.5,使其大小为窗口大小的 50%。它的pos_hint被省略,因为小部件默认从(0,0)位置开始。

第二个按钮的pos_hint对于 x 和 y 都设置为 0.75,使其从距离父按钮的宽度和高度 75%的位置开始。其size_hint设置为 0.25,使按钮延伸到右上角。

动画

为了在 Kivy 中创建一个游戏,动画是必不可少的。它使事情进展顺利。例如,我们可能对制作一个沿着特定路径移动的怪物的动画感兴趣。在 Kivy 中,只需使用kivy.animation.Animation类就可以创建动画。让我们创建一个带有图像小部件的应用,并通过改变它的位置来制作动画。

清单 5-7 显示了应用的 KV 文件。根小部件是FloatLayout,它包含两个子小部件。第一个子小部件是一个 ID 为character_imageImage小部件,它显示由源字段指定的图像。当设置为True时,allow_stretch属性拉伸图像以覆盖Image小工具的整个区域。

有一个Button小部件,当它被按下时会启动动画。因此,Python 文件中一个名为start_char_animation()的函数与on_press事件相关联。

FloatLayout:
    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        allow_stretch: True
        source: "character.png"
    Button:
        size_hint: (0.3, 0.3)
        text: "Start Animation"
        on_press: app.start_char_animation()

Listing 5-7Adding an Image to the Widget Tree Using the Image Widget

Python 文件的实现如清单 5-8 所示。在TestApp类内部,实现了start_char_animation()函数。在char_animation变量中创建了一个kivy.animation.Animation类的实例。该类接受要动画显示的目标小部件的属性。因为我们对改变Image小部件的位置感兴趣,所以pos_hint属性作为输入参数被提供给Animation类构造函数。

请注意,不可能将小部件中未定义的属性制作成动画。例如,我们不能动画显示width属性,因为它没有在小部件中定义。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_animation = kivy.animation.Animation(pos_hint={'x':0.8, 'y':0.6})
        char_animation.start(character_image)

app = TestApp()
app.run()

Listing 5-8Building and Starting the Animation Over an Image

为了让小部件中的属性具有动画效果,我们必须提供属性名及其新值。动画从属性的前一个值开始,在本例中是 KV 文件中的pos_hint字段指定的值,即{'x': 0.2, 'y': 0.6},并在Animation类的构造函数中指定的值{'x': 0.8, 'y': 0.6}处结束。因为只是 x 位置有变化,所以图像会水平移动。

调用Animation类的start()方法来启动动画。这个方法接受目标小部件的 ID,在这个小部件中,我们希望动画显示在Animation类构造函数中指定的属性。

当我们单击Button小部件时,start_char_animation()功能被执行,动画开始。图 5-5 显示了按下按钮之前和之后窗口的显示。默认情况下,动画需要一秒钟才能完成。这个时间可以使用duration参数来改变。

img/481739_1_En_5_Fig5_HTML.jpg

图 5-5

动画开始前后。左图显示原始图像,右图显示动画结束后的结果

请注意,我们可以在同一个动画实例中制作多个属性的动画。这是通过用逗号分隔不同的属性来实现的。清单 5-9 动画显示图像的大小和位置。通过将其从(0.15,0.15)更改为(0.2,0.2),大小加倍。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_animation = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6}, size_hint=(0.2, 0.2), duration=1.5)
        char_animation.start(character_image)

app = TestApp()
app.run()

Listing 5-9Animating Multiple Properties Within the Same Animation Instance

运行应用并按下按钮后,动画结束后的结果如图 5-6 所示。请注意,持续时间变为 1.5 秒。

img/481739_1_En_5_Fig6_HTML.jpg

图 5-6

动画显示图像的 pos_hint 和 size_hint 属性后的结果

即使动画仍然工作,单击“更多”按钮也不会移动或更改图像的大小。事实上,每次按下按钮后都会执行start_char_animation()功能。对于每一次按压,根据附加的小部件创建并启动一个Animation实例。第一次谈论pos_hint属性时,在 KV 文件中指定的pos_hint属性的旧值和在Animation类构造函数中指定的新值是不同的。这就是图像从 x=0.2 移动到 x=0.8 的原因。动画化图像后,其pos_hint属性将为{'x': 0.8, 'y': 0.6}

再次制作图像动画时,开始值和结束值将等于{'x': 0.8, 'y': 0.6}。这就是为什么图像小部件的位置没有变化。Kivy 支持循环动画,但是循环前一个动画是没有意义的。属性中必须至少有一个其他值,这样小部件才能从一个值转到另一个值。在循环动画之前,我们需要向pos_hint属性添加另一个值。

单个动画接受给定属性的单个值,但是我们可以在另一个动画中添加另一个值,并将这些动画连接在一起。

加入动画

加入动画有两种方式—顺序和并行。在连续动画中,当一个动画结束时,下一个动画开始,并持续到最后一个动画。在这种情况下,使用+操作符将它们连接起来。在并行动画中,所有动画同时开始。使用&操作符将它们连接起来。

清单 5-10 显示了两个动画顺序连接的例子。第一个动画实例名为char_anim1 通过将pos_hint属性更改为{'x': 0.8, 'y': 0.6},将图像水平向右移动,就像前面所做的一样。第二个动画实例名为char_anim2,将小部件垂直移动到底部的新位置{'x': 0.8, 'y': 0.2}。使用+操作符连接两个动画,结果存储在all_anim1变量中。加入的动画通过调用start()方法开始。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_anim1 = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6})
        char_anim2 = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.2})
        all_anim = char_anim1 + char_anim2
        all_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-10Joining Animations Sequentially

按下按钮后,运行所有动画后的结果如图 5-7 所示。

img/481739_1_En_5_Fig7_HTML.jpg

图 5-7

将多个要应用于图像小工具的动画按顺序连接起来

图 5-8 中显示了pos_hint属性改变的路径概要。图像从 KV 文件中指定的{'x': 0.2, 'y': 0.6}开始。运行第一个动画后,它移动到新的位置{'x': 0.8, 'y': 0.6}。最后,它在运行第二个动画后移动到{'x': 0.8, 'y': 0.2}。该位置保持图像的当前位置。

img/481739_1_En_5_Fig8_HTML.jpg

图 5-8

根据清单 5-10 中定义的两个动画的图像小部件的路径

动画完成后,如果再次按下按钮会发生什么?加入的动画将再次开始。在第一个动画中,它将图像的位置从当前位置更改为其参数pos_hint中指定的新位置,该参数为{'x': 0.8, 'y': 0.6}。因为当前位置{'x': 0.8, 'y': 0.2}与新位置{'x': 0.8, 'y': 0.6}不同,所以图像会移动。图像的当前位置将是{'x': 0.8, 'y': 0.6}

运行第一个动画后,第二个动画开始,它将图像从当前位置{'x': 0.8, 'y': 0.6}移动到其pos_hint参数中指定的新位置,即{'x': 0.8, 'y': 0.2}。因为位置不同,图像会移动。每次按下按钮都重复这个过程。请注意,如果没有备份,KV 文件中属性的初始值会在动画结束后丢失。

每个动画需要一秒钟才能完成,因此合并动画的总时间为两秒钟。您可以使用duration参数控制每个动画的持续时间。

因为pos_hint属性改变的值不止一个,所以我们可以循环前面的动画。根据清单 5-11 ,我们通过将动画实例的repeat属性设置为True来做到这一点。这在两个动画之间创建了一个无限循环。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_anim1 = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6})
        char_anim2 = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.2})
        all_anim = char_anim1 + char_anim2
        all_anim.repeat = True
        all_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-11Repeating Animations by Setting the repeat Property to True

可以使用Animation类构造函数中的t参数来改变动画过渡。默认是linear。有不同类型的过渡,如in_backin_quadout_cubic以及许多其他类型。您也可以使用transition属性返回它。清单 5-12 显示了第一个动画的过渡设置为out_cubic的例子。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_anim1 = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6}, t='out_cubic')
        char_anim2 = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.2})
        all_anim = char_anim1 + char_anim2
        all_anim.repeat = True
        all_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-12Setting the Transition Property to out_cubic

取消动画

为了停止分配给给定小部件中所有属性的所有动画,我们调用了cancel_all()函数。它停止所有被调用的动画。

我们可以在窗口小部件树中添加另一个按钮,当我们单击它时,它会停止所有动画。新的 KV 文件如清单 5-13 所示。当按下该按钮时,执行stop_animation()功能。请注意,此按钮的位置已经改变,以避免将其放在前面的按钮上。

FloatLayout:
    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        allow_stretch: True
        source: "character.png"
    Button:
        size_hint: (0.3, 0.3)
        text: "Start Animation"
        on_press: app.start_char_animation()
    Button:
        size_hint: (0.3, 0.3)
        text: "Stop Animation"
        pos_hint: {'x': 0.3}
        on_press: app.stop_animation()

Listing 5-13Adding a Button Widget to Stop Running Animations

Python 文件如清单 5-14 所示。在stop_animation()函数中,调用cancel_all()函数来停止所有与 ID 为character_image的小部件相关联的动画。当动画被取消时,动画属性的当前值被保存。当动画再次开始时,这些值用作开始值。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_anim1 = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6})
        char_anim2 = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.2})
        all_anim = char_anim1 + char_anim2
        all_anim.repeat = True
        all_anim.start(character_image)

    def stop_animation(self):
        character_image = self.root.ids['character_image']
        kivy.animation.Animation.cancel_all(character_image)

app = TestApp()
app.run()

Listing 5-14Stopping Running Animations Upon Press of the Button Widget

这样,我们能够开始和停止与给定小部件相关的所有属性的动画。我们还可以使用cancel_all()指定选定的属性来停止其动画,同时保持其他属性。我们不只是提供小部件引用,而是添加一个需要停止的属性列表,用逗号分隔。

图像小工具的动画源属性

在先前的应用中,当其位置改变时,显示相同的静态图像。如果我们想让角色行走,最好是随着其位置的变化而改变图像,以给人一种行走角色的印象。例如,我们通过改变它的腿和手的位置来做到这一点。图 5-9 显示了角色在不同位置的一些图像。当角色移动时,我们也可以改变显示的图像。这使得游戏更加真实。因为图像是使用Image小部件中的source属性指定的,所以我们需要激活这个属性来改变显示的图像。问题是我们如何制作source属性的动画?

img/481739_1_En_5_Fig9_HTML.jpg

图 5-9

不同的图像来反映角色的运动

在前面的例子中,pos_hintsize_hint属性是动态的,它们接受数值。但是source属性接受一个指定图像名称的字符串。有可能将字符串属性动画化吗?不幸的是,动画只改变数值。我们可以要求Animation类将一个属性从一个数值(如 1.3 )更改为另一个数值(如 5.8 )。但是我们不能要求它将一个属性从一个字符串值(如character1.png)更改为另一个字符串值(如character2.png)。那么,我们如何制作这个动画呢?

一个懒惰的解决方案包括四个步骤。我们向Image小部件添加一个新的属性,假设它被命名为im_num,它将被分配一个引用图像索引的数字。然后我们激活这个属性来生成当前的图像编号。第三步是返回动画生成的每个值。最后一步是使用生成的数字创建图像名称,方法是创建一个由图像扩展名前面的数字组成的字符串,并将Image小部件的source属性设置为返回的图像名称。该过程的总结如图 5-10 所示。让我们应用这些步骤。

img/481739_1_En_5_Fig10_HTML.jpg

图 5-10

制作 Image 小部件的 source 属性动画的步骤

第一步,清单 5-15 显示了添加im_num属性后的 KV 文件。注意 Python 允许我们向已经存在的类添加新的属性。新属性的值为 0,表示角色的第一个图像。

FloatLayout:
    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
    Button:
        size_hint: (0.3, 0.3)
        text: "Start Animation"
        on_press: app.start_char_animation()

Listing 5-15Adding the im_num Property to Change the Image Using Animation

第二步很简单。我们只是将一个名为im_num的参数添加到Animation类的构造函数中。该参数被分配给最后一个要使用的索引。如果有八个图像的索引从 0 到 7,则该参数被指定为 7。清单 5-16 显示了 Python 代码。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_anim = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6}, im_num=7)
        char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-16Adding the im_num Argument to the Animation

第三步,我们要回答这个问题,“我们如何返回动画生成的当前值?”答案很简单。为了在给定小部件的名为 X 的属性值发生变化时得到通知,我们在该小部件中添加了一个名为on_X的事件。此事件被分配给一个 Python 函数,每次属性值更改时都会调用该函数。因为我们的目标字段被命名为im_num,所以该事件将被称为on_im_num

清单 5-17 显示了添加该事件后修改后的 KV 文件。每次im_num字段的值改变时,Python 文件内的函数change_char_im()将被调用。

FloatLayout:
    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
        on_im_num: app.change_char_im()
    Button:
        size_hint: (0.3, 0.3)
        text: "Start Animation"
        on_press: app.start_char_animation()

Listing 5-17Adding the on_im_num Event to the Image Widget to Be Notified When the Image Changes

清单 5-18 显示了添加这个函数后修改后的 Python 代码。每次改变时,打印im_num的值。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        print(character_image.im_num)

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_anim = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6}, im_num=7)
        char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-18Handling the on_im_num Event to Print the im_num When Changed

在第四步中,将返回的数字连接到图像扩展名,以返回表示图像名称的字符串。这个字符串被分配给图像模块的source属性。根据清单 5-19 ,这项工作是在修改后的change_char_im()函数内部完成的。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_anim = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6}, im_num=7)
        char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-19Changing the Source Image of the Image Widget when im_num Changes

请注意,动画会在动画属性的起始值和结束值之间插入浮点数。所以,会有 0.1,2.6,4.3 等值。因为图像名称中有整数,所以im_num属性中的浮点值应该改为整数。

在将其转换为整数后,可以将其与图像扩展名连接起来,以返回表示图像名称的字符串。这个字符串被分配给图像模块的source属性。记住将图像设置在 Python 文件的当前目录下。否则,在图像名称前添加路径。

使用最新的 Python 和 KV 文件运行应用并按下按钮后,图像应该会随着时间的推移而改变。图 5-11 显示了角色在使用动画改变其图像时的四张截图。

img/481739_1_En_5_Fig11_HTML.jpg

图 5-11

当角色移动时,角色图像改变

屏幕触摸事件

到目前为止,当点击按钮时,角色会移动。移动路径仅限于输入到动画类构造函数中的路径。我们需要改变这一点,以便根据整个屏幕上的触摸位置自由移动角色。请注意,Kivy 中的触摸指的是鼠标按压或触摸屏幕。为了做到这一点,我们需要获得屏幕上的触摸位置,然后动画角色移动到那个位置。

为了返回屏幕上的触摸位置,需要使用三个触摸事件,分别是on_touch_upon_touch_downon_touch_move。我们只是对触摸按下时获取触摸位置感兴趣,所以使用了on_touch_down事件。

根据清单 5-20 中修改的 KV 文件,该事件被添加到根小部件(即FloatLayout)。请注意,将触摸事件与布局本身或其一个子布局绑定并不重要,因为它们不检测冲突,因此无法检测触摸位置的边界。它们总是返回整个窗口上的触摸位置。

FloatLayout:
    on_touch_down: app.touch_down_handler(*args)

    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
        on_im_num: app.change_char_im()
    Button:
        size_hint: (0.3, 0.3)
        text: "Start Animation"
        on_press: app.start_char_animation()

Listing 5-20Using the on_touch_down Event to Return the Screen Touch Position

该事件接受一个功能,该功能将在每次触摸屏幕时执行。Python 文件内的touch_down_handler()函数将响应触摸而执行。事件生成的所有参数都可以使用args变量传递给处理程序。这有助于访问 Python 函数内部的触摸位置。

清单 5-21 展示了实现touch_down_handler()函数的修改后的 Python 文件。该函数只是在 args 中打印从事件接收的参数。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def touch_down_handler(self, *args):
        print(args)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def start_char_animation(self):
        character_image = self.root.ids['character_image']
        char_anim = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.6}, im_num=7)
        char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-21Handling the touch_down_handler() to Get the Screen Touch Position

根据给定的输出,传递给函数的args是一个具有两个元素的元组。第一个元素指示哪个小部件与事件相关联。第二个元素给出了关于触摸和触发它的设备的信息。例如,通过单击鼠标左键触发触摸事件。

(<kivy.uix.floatlayout.FloatLayout object at 0x7fcb70cf4250>, <MouseMotionEvent button="left" device="mouse" double_tap_time="0" dpos="(0.0, 0.0)" dsx="0.0" dsy="0.0" dsz="0.0" dx="0.0" dy="0.0" dz="0.0" grab_current="None" grab_exclusive_class="None" grab_list="[]" grab_state="False" id="mouse1" is_double_tap="False" is_mouse_scrolling="False" is_touch="True" is_triple_tap="False" opos="(335.0, 206.99999999999997)" osx="0.45702592087312416" osy="0.37981651376146786" osz="0.0" ox="335.0" oy="206.99999999999997" oz="0.0" pos="(335.0, 206.99999999999997)" ppos="(335.0, 206.99999999999997)" profile="['pos', 'button']" psx="0.45702592087312416" psy="0.37981651376146786" psz="0.0" push_attrs="('x', 'y', 'z', 'dx', 'dy', 'dz', 'ox', 'oy', 'oz', 'px', 'py', 'pz', 'pos')" push_attrs_stack="[]" px="335.0" py="206.99999999999997" pz="0.0" shape="None" spos="(0.45702592087312416, 0.37981651376146786)" sx="0.45702592087312416" sy="0.37981651376146786" sz="0.0" time_end="-1" time_start="1563021796.776788" time_update="1563021796.776788" triple_tap_time="0" ud="{}" uid="1" x="335.0" y="206.99999999999997" z="0.0">)

事件指定触摸位置有不同的方式。例如,pos属性根据窗口以像素为单位指定位置,而spos返回相对于窗口大小的位置。因为我们游戏中的所有位置都是相对于窗口大小的,spos 用来指定角色移动到的位置。

以前,移动角色的动画是在 Python 文件内的start_char_animation()函数中创建和启动的。该函数使用角色移动到的静态位置。使用触摸事件后,角色将移动到touch_down_handler()函数中触摸事件的spos属性返回的位置。因此,start_char_animation()功能的标题将改变以接收触摸位置。清单 5-22 显示了修改后的 Python 文件。

请注意spos属性是如何从args返回的。因为它位于 args 的第二个元素(即 index 1)中,所以使用了args[1]

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def touch_down_handler(self, args):
        self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0], 'y': touch_pos[1]}, im_num=7)
        char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-22Moving the Character According to the Touch Position

因为现在动画是通过触摸屏幕开始的,所以 KV 文件中不需要按钮。清单 5-23 给出了移除该按钮后修改后的 KV 文件。

FloatLayout:
    on_touch_down: app.touch_down_handler(args)

    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
        on_im_num: app.change_char_im()

Listing 5-23Removing the Button That Starts the Animation

运行应用并触摸窗口后,角色将移动到被触摸的位置。因为小部件的位置反映了左下角将要放置的位置,所以将该位置直接提供给图像小部件的pos_hint属性会使其左下角从触摸位置开始,并根据在size_hint属性中指定的大小进行扩展。如图 5-12 所示。将小工具居中在触摸位置更方便。我们如何做到这一点?

img/481739_1_En_5_Fig12_HTML.jpg

图 5-12

图像小工具的左下角放置在触摸位置

目前,小部件的中心比触摸位置大,水平方向大其宽度的一半,垂直方向大其高度的一半。根据以下等式计算中心坐标:

widgetCenterX = touchPosX + widgetWidth/2
widgetCenterY = touchPosY + widgetHeight/2

为了根据触摸位置使小部件居中,我们可以从touchPosX中减去widgetWidth/2,从touchPosY中减去widgetHeight/2。结果将如下所示:

widgetCenterX = (touchPosX - widgetWidth/2) + widgetWidth/2 = touchPosX
widgetCenterY = (touchPosY + widgetHeight/2) + widgetWidth/2 = touchPosY

这样,小工具将在触摸位置居中。清单 5-24 显示了在start_char_animation()函数中修改动画位置后的 Python 代码。注意,widgetWidth等于size_hint[0]widgetHeight等于size_hint[1]

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):
    def touch_down_handler(self, args):
        self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0]-character_image.size_hint[0]/2,'y': touch_pos[1]-character_image.size_hint[1]/2},im_num=7)
        char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-24Moving the Center of the Image Widget to the Touch Position

图 5-13 显示触摸屏幕后的结果。角色的中心位于触摸位置。

img/481739_1_En_5_Fig13_HTML.jpg

图 5-13

图像小工具的中心放置在触摸位置上

on _ 完成

每次触摸屏幕时,都会创建一个动画实例。它动画显示了Image小部件的pos_hintim_num属性。对于第一次屏幕触摸,这两个属性随着角色的移动和图像的改变而被激活。更多地触摸屏幕只会移动角色,但不幸的是,im_num属性不会改变。因此,在第一次触摸屏幕后,将有一个静态图像显示在小工具上。为什么会这样?

KV 文件中im_num的初始值为 0。第一次触摸屏幕时,动画开始播放,因此im_num从 0 到 7 播放动画。动画完成后,存储在im_num中的当前值将是 7。

再次触摸屏幕,im_num将从当前值 7 变为新值,也是 7。结果,显示的图像没有变化。解决方法是在启动start_char_animation()函数内的动画之前,将im_num的值重置为 0。

动画完成后,角色预计处于稳定状态,因此将显示带有im_num=0的第一个图像。但是完成动画后存储在im_num中的值是 7,而不是 0。最好在动画完成后将im_num重置为 0。

幸运的是,Animation类有一个名为on_complete的事件,在动画完成时被触发。我们可以将我们的动画绑定到这个事件,这样每次完成时都会执行一个回调函数。回调函数被命名为char_animation_completed()。在这个函数中,我们可以强制im_num返回 0。将on_complete事件绑定到角色动画后,修改后的 Python 文件在清单 5-25 中列出。这个on_complete事件向回调函数发送参数,这些参数是触发事件的动画和与之关联的小部件。这就是回调函数接受它们进入args的原因。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def touch_down_handler(self, args):
        self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num))+".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0]-character_image.size_hint[0]/2, 'y': touch_pos[1]-character_image.size_hint[1]/2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-25Resetting the im_num Property to 0 After the Animation Completes

在回调函数中,Image小部件的im_num被改回 0。因此,每次动画完成时,小部件上显示的图像将被重置。

将角色动画制作正确后,我们就可以开始给游戏添加怪物了。玩家/角色在与怪物相撞时死亡。

向游戏中添加怪物

怪物将被添加到 KV 文件中,就像添加角色一样。我们只是在 KV 文件中为怪物创建了一个Image小部件。新的 KV 文件如清单 5-26 所示。

FloatLayout:
    on_touch_down: app.touch_down_handler(args)

    Image:
        id: monster_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.8, 'y': 0.8}
        source: "10.png"
        im_num: 10
        allow_stretch: True
        on_im_num: app.change_monst_im()

    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
        on_im_num: app.change_char_im()

Listing 5-26Adding an Image Widget for the Monster

怪物的Image部件将具有角色中定义的属性。这些属性是用于引用 Python 文件中的小部件的 ID,size_hint用于设置相对于屏幕大小的小部件大小,pos_hint用于相对于屏幕放置小部件,source用于将图像名称保存为字符串,allow_stretch用于拉伸图像以覆盖图像的整个区域,im_num用于保存小部件上显示的图像编号。为了使角色和怪物的图像编号不同,怪物图像编号将从 10 开始。图 5-14 显示了怪物的图像。

img/481739_1_En_5_Fig14_HTML.jpg

图 5-14

怪物的图像序列

on_im_num事件将通过一个名为change_monst_im()的回调函数与im_num属性相关联,以在每次发生变化时访问 Python 文件中的值。

在我们准备好 KV 文件后,我们将看到的应用窗口如图 5-15 所示。

img/481739_1_En_5_Fig15_HTML.jpg

图 5-15

角色和怪物图像部件出现在屏幕上

请注意,怪物Image小部件位于 KV 文件中的字符小部件之前(即,在小部件树中)。这使得角色的 Z 指数低于怪物的 Z 指数,从而画在它的上面,如图 5-16 所示。

img/481739_1_En_5_Fig16_HTML.jpg

图 5-16

角色图像小部件出现在怪物图像小部件的上方

开启 _ 启动

角色的动画在每次触摸屏幕时开始,但是怪物的动画必须在应用启动后开始。那么,我们可以在 Python 文件的什么地方启动这个怪物呢?根据 Kivy 应用生命周期,一旦应用启动,就会执行名为on_start()的方法。这是开始怪物动画的好地方。

在我们添加了change_monst_im()on_start()函数来处理怪物的动画之后,Python 文件如清单 5-27 所示。除了改变怪物Image控件的source属性外,change_monst_im()功能与change_char_im(),类似。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def touch_down_handler(self, args):
        self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num))+".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num))+".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0]-character_image.size_hint[0]/2, 'y': touch_pos[1]-character_image.size_hint[1]/2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.0}, im_num=17, duration=2.0)+kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.8}, im_num=10, duration=2.0)
        monst_anim.repeat = True

        monst_anim.start(monster_image)

app = TestApp()
app.run()

Listing 5-27Adding the Functions to Handle the Monster Animation

on_start()函数中,创建了两个顺序连接的动画来制作怪物Image小部件的pos_hintim_num属性的动画。

根据 KV 文件怪物的初始位置是{'x':0.8, 'y':0.8}。第一个动画将该位置更改为{'x':0.8, 'y':0.0},第二个动画将其更改回{'x':0.8, 'y':0.8}。这个动画循环发生,因为动画实例monst_animrepeat属性被设置为True。为了简单起见,怪物在固定的路径上移动。在接下来的部分,我们将改变它的运动是随机的。

因为 KV 文件中怪物的im_num属性的初始值设置为 10,所以第一个动画的im_num设置为 17。因此,第一个动画将图像编号从 10 更改为 17。第二个动画将该属性设置为 10,以便将图像编号从 17 改回 10。每个动画持续两秒钟。

动画与 monster Image 小部件相关联,该小部件使用其在monster_image变量中的 ID 返回。

冲突

到目前为止,角色和怪物的pos_hintim_num属性的动画工作正常。我们需要修改游戏,使角色在与怪物相撞时被杀死。

Kivy 中碰撞的工作方式是,它检查两个小部件的边界框之间的交集。内置的 Kivy 函数就是这样做的。例如,该命令检测两个图像小部件之间的冲突:

character_image.collide_widget(monster_image)

我们必须不断检查两个小部件之间的冲突。因此,需要将上述命令添加到定期执行的内容中。

每当小部件使用pos_hint属性改变其位置时,就会触发on_pos_hint事件。该事件将在每次触发时执行一个回调函数。因为怪物图像小部件不断改变它的位置,所以我们可以将事件绑定到那个小部件。

请注意,如果你打算稍后杀死怪物,怪物将不会改变它的位置,因此on_pos_hint将永远不会被发射,因此没有碰撞检查。如果有其他可能杀死角色的物体,并且你完全依赖与被杀死的怪物相关联的事件进行碰撞检测,则角色不会被杀死。你得找别的东西来检查碰撞。一种解决方案是将on_pos_hint事件与每个可能杀死角色的对象绑定。

首先,将on_pos_hint事件添加到 KV 文件中,如清单 5-28 所示。它与一个叫做monst_pos_hint()的回调函数相关联。

FloatLayout:
    on_touch_down: app.touch_down_handler(args)

    Image:
        id: monster_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.8, 'y': 0.8}
        source: "10.png"
        im_num: 10
        allow_stretch: True
        on_im_num: app.change_monst_im()
        on_pos_hint: app.monst_pos_hint()

    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
        on_im_num: app.change_char_im()

Listing 5-28Using the on_pos_hint Event to Return the Monster Position

在清单 5-29 所示的 Python 文件的末尾实现了monst_pos_hint()函数。它在character_imagemonster_image属性中返回角色和怪物部件,然后调用collide_widget()函数。到目前为止,如果根据if声明发生了碰撞,将会打印一条消息。

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def touch_down_handler(self, args):
        self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num))+".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num))+".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0]-character_image.size_hint[0]/2, 'y': touch_pos[1]-character_image.size_hint[1]/2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.0}, im_num=17, duration=2.0)+kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.8}, im_num=10, duration=2.0)
        monst_anim.repeat = True

        monst_anim.start(monster_image)

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']
        if character_image.collide_widget(monster_image):
            print("Character Killed")

app = TestApp()
app.run()

Listing 5-29Handling the monst_pos_hint() Callback Function

调整 collide_widget()

collide_widget()函数过于严格,因为如果两个小部件之间至少有一行或一列交集,它将返回True。实际上,这种情况并不经常发生。根据图 5-17 ,这样一个函数返回True,因为两个窗口小部件的边界框有交集。结果就是,这个角色即使没碰过怪物也会被杀死。

img/481739_1_En_5_Fig17_HTML.jpg

图 5-17

当小部件框的外部边界发生冲突时,即使图像没有接触,collide_widget()也会返回 True

我们可以通过添加另一个条件来调整collide_widget()函数,该条件检查碰撞区域是否超过字符大小的预定义百分比。这让我们在说有碰撞的时候更加自信。monst_pos_hint()功能修改如清单 5-30 所示。

def monst_pos_hint(self):
    character_image = self.root.ids['character_image']
    monster_image = self.root.ids['monster_image']

    character_center = character_image.center
    monster_center = monster_image.center

    gab_x = character_image.width / 2
    gab_y = character_image.height / 2
    if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
        print("Character Killed")

Listing 5-30Tuning the collide_widget() Function to Return True Only When the Character and Monster Touch Each Other

新条件的结论是,如果两个窗口小部件的当前中心之间的差异至少是字符大小的一半,则发生碰撞。这是通过确保两个中心的 X 和 Y 坐标之差分别小于字符宽度和高度的一半来实现的。

调节collide_widget()功能后,对于图 5-18 和 5-19 所示的情况,条件返回False。因此,结果更加真实。

img/481739_1_En_5_Fig18_HTML.jpg

图 5-18

没有发生碰撞,因为没有超过列表 5-30 中定义的怪物和角色图像中心之间的最大间隙。完整的 Python 代码如清单 5-31 所示

import kivy.app
import kivy.animation

class TestApp(kivy.app.App):

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def touch_down_handler(self, args):
        self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num))+".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num))+".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0]-character_image.size_hint[0]/2, 'y': touch_pos[1]-character_image.size_hint[1]/2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.0}, im_num=17, duration=2.0)+kivy.animation.Animation(pos_hint={'x': 0.8, 'y': 0.8}, im_num=10, duration=2.0)
        monst_anim.repeat = True
        monst_anim.start(monster_image)

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width/2
        gab_y = character_image.height/2
        if character_image.collide_widget(monster_image) and abs(character_center[0]-monster_center[0])<=gab_x and abs(character_center[1]-monster_center[1])<=gab_y:
            print("Character Killed")

app = TestApp()
app.run()

Listing 5-31Complete Code for the Game in Which the Work for the Animation and Collision Is Completed Successfully

随机怪物运动

在清单 5-31 中的前一个应用中,我们做了很好的工作,成功地制作了角色和怪物的动画。但是怪物在固定的路径上移动。在本节中,我们将修改它的运动,使它看起来是随机的。这个想法非常类似于角色Image部件的动画。

使用一个名为start_char_animation()的函数激活角色,该函数接受角色移动到的新位置。因为只创建了一个动画,所以可以重复。为了在完成后重复动画,将on_complete事件附加到角色的动画中。名为char_animation_completed()的回调函数与该事件相关联。当动画完成时,这个回调函数被执行,它为新的动画准备角色。我们想让怪物的动作也像这样。这适用于清单 5-32 中所示的修改后的 Python 文件。

创建了两个新函数,分别是start_char_animation()char_animation_completed()start_char_animation()函数接受怪物移动到的新位置,作为名为new_pos的参数。然后它创建一个动画实例,根据新的位置改变pos_hint属性。它还将 KV 文件中的im_num属性从初始值 10 更改为 17。

import kivy.app
import kivy.animation
import random

class TestApp(kivy.app.App):

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(), random.uniform())
        self.start_monst_animation(new_pos=new_pos)

    def touch_down_handler(self, args):
        self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2

        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            print("Character Killed")

app = TestApp()
app.run()

Listing 5-32Repeating the Monster Animation by Handling the on_complete Event of the Animation

on_start()函数中,start_monst_animation()被调用,输入参数被指定为一个随机值。因为只有一个动画,所以动画不能通过将 repeat 属性设置为True来重复自身。因此,on_complete事件被附加到动画上,以便在动画完成后执行回调函数monst_animation_completed()。这给了我们再次开始动画的机会。

在回调函数内部,怪物的im_num属性再次被重置为 10。使用 random 模块中的uniform()函数,为新位置的 X 和 Y 坐标生成一个随机值。返回值是介于 0.0 和 1.0 之间的浮点数。新位置被用作 monster 小部件的左下角。

假设随机返回的位置是(0.0,1.0),这使得角色的底线从屏幕的末端开始。这样一来,怪物就会被隐藏起来。这也适用于位置(1.0,0.0)和(1.0,1.0)。

为了确保怪物在屏幕上始终可见,我们必须考虑它的宽度和高度。将怪物的左下角定位在新的随机位置后,怪物必须有一个适合其宽度和高度的空间。因此,X 的最大可能值是1-monster_width,Y 的最大可能值是1-monster_height。这为怪物在窗口中任何生成的位置完全可见腾出了空间。

修改后的 Python 代码如清单 5-33 所示。在之前的应用中,所有怪物移动的持续时间是 2.0 秒。在新代码中,使用random.uniform()随机返回持续时间。结果,怪物在随机时间内移动到随机生成的位置。

import kivy.app
import kivy.animation
import random

class TestApp(kivy.app.App):

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2

        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            print("Character Killed")

app = TestApp()
app.run()

Listing 5-33Randomly Changing the Position and Duration of the Monster Animation

杀死角色

在清单 5-33 中的上一个游戏中,即使怪物与角色发生碰撞,一切仍然正常。我们需要修改应用,使角色在被杀死时停止移动。我们这样做是为了确保它的动画不会再次开始。

touch_down_handler()功能中,角色总是朝着屏幕上被触摸的位置移动,即使在碰撞之后。在清单 5-34 中列出的修改后的 Python 代码中,通过使用一个名为character_killed的标志变量来指示角色是否被杀死,这个问题得到了解决。这样的变量默认设置为False,意味着游戏还在运行,角色还活着。touch_down_handler()函数中的if语句确保角色动画仅在标志设置为False时工作。因为标志与类相关联,所以可以通过在类名前面加上(TestApp.character_killed)来访问它。

当在mons_pos_hint()函数中检测到碰撞时,采取两个动作,将character_killed标志的值改为True,并取消所有正在运行的动画(即角色和怪物)。

import kivy.app
import kivy.animation
import random

class TestApp(kivy.app.App):
    character_killed = False

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        if TestApp.character_killed == False:
            self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2

        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

app = TestApp()
app.run()

Listing 5-34Adding the character_killed Flag to Determine Whether the Character Animation Could Start Again

monst_pos_hint()功能内的标志变为True时,角色动画无法停止。请注意,在标志值变为True后,仍有一个运行的动画响应先前触摸的位置。这意味着角色将继续移动,直到动画完成,然后停止移动。为了在碰撞发生时停止移动动画,我们可以使用cancel_all()功能取消动画。因此,一旦碰撞发生,取消动画将停止它。更改标志值会阻止动画再次开始。

因为怪物动画一旦被取消,用户就没有办法启动了,取消这样的动画就足够了。

角色杀戮动画

根据图 5-19 ,当角色在之前的应用中被杀死时,它会保留由im_num属性指定的图像编号。图像没有反映人物的死亡。

img/481739_1_En_5_Fig19_HTML.jpg

图 5-19

当与怪物发生碰撞时,角色图像停止在其最新状态

我们可以改变形象,给人更好的印象。为此,将使用图 5-20 中显示的图像。

img/481739_1_En_5_Fig20_HTML.jpg

图 5-20

当角色被杀死时显示的图像序列

一旦发生碰撞,只有在根据这些图像制作图像动画的monst_pos_hint()函数内部创建动画后,才会开始。如果这些图像的编号从 91 到 95,修改后的 Python 代码如清单 5-35 所示。新动画只是将im_num属性更改为 95。

需要记住的是,角色动画被取消后,im_num数字会保持在 0 到 7 之间。例如,如果它的值是 5,那么运行新的动画将从 5 到 95。因为我们对从 91 开始感兴趣,所以在动画开始之前,im_num属性值被设置为 91。

import kivy.app
import kivy.animation
import random

class TestApp(kivy.app.App):
    character_killed = False

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        if TestApp.character_killed == False:
            self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            TestApp.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-35Running an Animation When the Character Is Killed

根据清单 5-35 中的代码,发生碰撞时结果如图 5-21 所示。

img/481739_1_En_5_Fig21_HTML.jpg

图 5-21

人物形象在与怪物碰撞时会发生变化

添加硬币

玩家的任务是收集分布在屏幕上的大量硬币。一旦收集到正确数量的硬币,游戏的当前级别就完成了,另一个级别开始了。因此,应用的下一步是在小部件树中添加表示硬币的图像小部件。让我们从添加一个代表一枚硬币的图片部件开始。

根据 Kivy 应用的生命周期,build()方法可用于准备小部件树。因此,这是向应用添加新部件的好方法。清单 5-36 中所示的 Python 代码实现了build()方法来添加单个图像小部件。记住导入kivy.uix.image模块,以便访问Image类。

import kivy.app
import kivy.animation
import kivy.uix.image
import random

class TestApp(kivy.app.App):
    character_killed = False

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        if TestApp.character_killed == False:
            self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def build(self):
        coin = kivy.uix.image.Image(source="coin.png", size_hint=(0.05, 0.05), pos_hint={'x': 0.5, 'y': 0.5}, allow_stretch=True)
        self.root.add_widget(coin, index=-1)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            TestApp.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)

            print("Character Killed")

app = TestApp()
app.run()

Listing 5-36Adding an Image Widget to the Widget Tree Representing the Coin Before the Application Starts

新的小部件使用了sourcesize_hintpos_hintallow_stretch属性。硬币图像来源如图 5-22 所示。

img/481739_1_En_5_Fig22_HTML.jpg

图 5-22

图像角点

新的小部件被返回到coin变量。之后,使用add_widget()方法将它添加到小部件树中。因为窗口小部件树中的最后一个窗口小部件出现在前面窗口小部件的顶部,所以index参数用于改变硬币的 Z 索引。微件的默认 Z 索引是 0。硬币 Z 指数设置为-1,出现在角色和怪物的后面。

运行应用后,我们会看到如图 5-23 所示的窗口。我们可以在窗户上放更多的硬币。

img/481739_1_En_5_Fig23_HTML.jpg

图 5-23

将硬币添加到角色和怪物图像小部件旁边

添加硬币的一种方法是固定它们在屏幕上的位置。在这个游戏中,位置是随机的。修改后的build()函数如清单 5-37 所示,其中一个for循环向窗口小部件树添加了五个硬币图像窗口小部件。注意,在类头中定义了一个名为num_coins的变量,它保存硬币部件的数量。

uniform()函数用于返回每枚硬币的 x 和 y 坐标,并考虑硬币在屏幕上的显示位置。我们这样做是通过从返回的随机数中减去宽度和高度。怪物的随机位置也是这样产生的。

import kivy.app
import kivy.animation
import kivy.uix.image
import random

class TestApp(kivy.app.App):
    character_killed = False
    num_coins = 5

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        if TestApp.character_killed == False:
            self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def build(self):
        coin_width = 0.05

        coin_height = 0.05

        for k in range(TestApp.num_coins):
            x = random.uniform(0, 1 - coin_width)
            y = random.uniform(0, 1 - coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y},allow_stretch=True)
            self.root.add_widget(coin, index=-1)

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            TestApp.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-37Adding Multiple Image Widgets Representing the Coins on the Screen

因为硬币的定位是随机的,所以在使用修改后的build()函数运行应用后,有可能大部分甚至全部硬币都在一个小区域内,如图 5-24 所示。我们需要保证每个硬币与下一个硬币之间的距离最小。该距离可以是水平的或垂直的。

img/481739_1_En_5_Fig24_HTML.jpg

图 5-24

硬币可能彼此非常接近

放置硬币的方法是将屏幕分成与要添加的硬币数量相等的多个垂直部分。如图 5-25 所示。一枚硬币随机放在一个区域的任意位置。

img/481739_1_En_5_Fig25_HTML.jpg

图 5-25

平分屏幕宽度以放置硬币

修改后的build()功能如清单 5-38 所示。因为屏幕是垂直分割的,每个部分将覆盖窗口的整个高度,但其宽度受到所用硬币数量的限制。因此,截面宽度在section_width变量中计算。

import kivy.app
import kivy.animation
import kivy.uix.image
import random

class TestApp(kivy.app.App):
    character_killed = False
    num_coins = 5
    coins_ids = {}

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        if TestApp.character_killed == False:
            self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def build(self):
        coin_width = 0.05
        coin_height = 0.05

        section_width = 1.0/TestApp.num_coins
        for k in range(TestApp.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True, id="coin"+str(k))
            self.root.add_widget(coin, index=-1)
            TestApp.coins_ids['coin'+str(k)] = coin

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            TestApp.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91

            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)

app = TestApp()
app.run()

Listing 5-38Splitting the Screen Width Uniformly to Add Multiple Image Widgets Representing the Coins

每枚硬币都可以放在该区域的边界内。因为对截面高度没有限制,硬币 y 坐标的计算如前所示。为了将硬币放置在该部分指定的宽度内,选择 x 坐标的范围被限制在它的开始和结束列。起始值由section_width*k定义,而结束值由section_width*(k+1)-coin_width定义。请注意,coin_width被减去,以确保硬币在截面边界内。

对于第一枚硬币,循环变量 k 值为 0,因此起始值为 0.0,但结束值为section_width-coin_width。给定section_width等于 0.2,coin_width等于 0.05,第一段的范围为 0.0:0.15 。对于第二枚硬币,k 将是 1,因此起始值是section_width,而结束值是section_width*2-coin_width。因此,第二段的范围是 0.2:0.35 。同样,其余部分的范围为 0.4:0.550.6:0.750.8:0.95

我们曾经使用根部件的ids字典来引用 Python 文件中的子部件。不幸的是,ids字典不包含对 Python 文件中动态添加的小部件的引用。为了以后能够引用这些小部件,它们的引用保存在类头中定义的coins_ids字典中。在字典中,每枚硬币都有一个字符串键,由从 0 开始的硬币编号后的单词coin组成。因此,这些键是coin0coin1coin2coin3coin4

图 5-26 显示了运行应用后的结果。硬币分布得更好。放置硬币后,下一步是允许玩家收集它们。

img/481739_1_En_5_Fig26_HTML.jpg

图 5-26

将硬币均匀地分布在屏幕上

根据以下输出打印硬币位置:

{'coin0': <kivy.uix.image.Image object at 0x7f0c56ff4388>, 'coin1': <kivy.uix.image.Image object at 0x7f0c56ff44c0>, 'coin2': <kivy.uix.image.Image object at 0x7f0c56ff4590>, 'coin3': <kivy.uix.image.Image object at 0x7f0c56ff4660>, 'coin4': <kivy.uix.image.Image object at 0x7f0c56ff4730>}

收集硬币

为了收集硬币,我们需要检测角色和所有尚未收集的硬币之间的碰撞。为了在每次改变时访问字符位置,on_pos_hint事件被绑定到 KV 文件中的字符图像小部件。因此,修改后的 KV 文件列在清单 5-39 中。事件被赋予回调函数char_pos_hint()

FloatLayout:
    on_touch_down: app.touch_down_handler(args)

    Image:
        id: monster_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.8, 'y': 0.8}
        source: "10.png"
        im_num: 10
        allow_stretch: True
        on_im_num: app.change_monst_im()
        on_pos_hint: app.monst_pos_hint()

    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
        on_im_num: app.change_char_im()
        on_pos_hint: app.char_pos_hint()

Listing 5-39Adding the on_pos_hint Event to Return the Character Position

根据清单 5-40 中列出的 Python 文件中该函数的实现,它遍历字典中的条目(即硬币)并返回循环头中定义的coin_keycurr_coin变量中每个条目(即硬币)的键值。检测碰撞的方式与检测角色和怪物之间的碰撞的方式相同。

如果两个小部件的边界有交集,即使是在一行或一列中,collide_widget()也会返回True。为了对其进行调优,需要比较两个小部件的中心。如果中心之间的差异超过预定阈值,则表明发生了碰撞。

一旦角色和硬币发生碰撞,通过调用remove_widget()方法,硬币Image小部件将从小部件树中移除。这确保了小部件在被收集后变得隐藏。角色与硬币碰撞的检测类似于用怪物计算,除了在处理硬币时减少gab_xgab_y变量,因为它们的尺寸小于怪物的尺寸。

import kivy.app
import kivy.animation
import kivy.uix.image
import random

class TestApp(kivy.app.App):
    character_killed = False
    num_coins = 5
    coins_ids = {}

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos, anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        if TestApp.character_killed == False:
            self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def build(self):
        coin_width = 0.05
        coin_height = 0.05

        section_width = 1.0/TestApp.num_coins
        for k in range(TestApp.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            self.root.add_widget(coin, index=-1)
            TestApp.coins_ids['coin'+str(k)] = coin

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            TestApp.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)

    def char_pos_hint(self):
        character_image = self.root.ids['character_image']
        character_center = character_image.center

        gab_x = character_image.width / 3
        gab_y = character_image.height / 3

        for coin_key, curr_coin in TestApp.coins_ids.items():
            curr_coin_center = curr_coin.center
            if character_image.collide_widget(curr_coin) and abs(character_center[0] - curr_coin_center[0]) <= gab_x and abs(character_center[1] - curr_coin_center[1]) <= gab_y:
                print("Coin Collected", coin_key)
                self.root.remove_widget(curr_coin)

app = TestApp()
app.run()

Listing 5-40Handling the char_pos_hint() Function to Detect Collision with the Coins

以前的应用存在问题。即使在硬币从部件树中删除后,字典中仍然有一个条目。因此,即使收集了所有项目,循环也要经历五次迭代,并且表现得好像没有硬币没有被收集一样。

为了确保从字典中检测到小部件,我们可以跟踪在一个名为coins_to_delete的空列表中收集的硬币,该列表在char_pos_hint()函数中定义,如清单 5-41 所示。对于收集到的每枚硬币,使用append()函数将其在coins_ids字典中的关键字添加到列表中。

def char_pos_hint(self):
    character_image = self.root.ids['character_image']
    character_center = character_image.center

    gab_x = character_image.width / 3
    gab_y = character_image.height / 3
    coins_to_delete = []

    for coin_key, curr_coin in TestApp.coins_ids.items():
        curr_coin_center = curr_coin.center
        if character_image.collide_widget(curr_coin) and abs(character_center[0] - curr_coin_center[0]) <= gab_x and abs(character_center[1] - curr_coin_center[1]) <= gab_y:
            print("Coin Collected", coin_key)
            coins_to_delete.append(coin_key)
            self.root.remove_widget(curr_coin)

    if len(coins_to_delete) > 0:
        for coin_key in coins_to_delete:
            del TestApp.coins_ids[coin_key]

Listing 5-41Removing the Coins Once They Are Collected

循环结束后,if语句根据列表的长度确定列表是否为空。如果其长度小于 1,则在前一个循环中没有收集到硬币,因此没有要从字典中删除的项目(即硬币)。如果列表的长度大于或等于 1(即大于 0),这意味着有一些来自前一循环的硬币。

为了从字典中删除硬币,一个for循环遍历列表中的元素。注意列表元素代表每枚硬币的钥匙,比如coin0。因此,存储在列表中的关键字将被用作字典的索引,以返回相关的硬币图像小部件。使用 Python 中的del命令,可以从字典中删除该条目。通过这样做,我们已经从部件树和字典中完全删除了硬币。收集完所有硬币后,字典中的条目数将为零,循环将无用。

完整级别

在之前的申请中,没有关于收集的硬币数量的指示。根据清单 5-42 所示的修改后的 KV 文件,在屏幕的左上角增加了一个小的Label控件来显示收集到的硬币数量。标签被赋予一个 IDnum_coins_collected,以便在 Python 代码中更改它的文本。

FloatLayout:
    on_touch_down: app.touch_down_handler(args)

    Label:
        id: num_coins_collected
        size_hint: (0.1, 0.02)
        pos_hint: {'x': 0.0, 'y': 0.97}
        text: "Coins 0"
        font_size: 20

    Image:
        id: monster_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.8, 'y': 0.8}
        source: "10.png"
        im_num: 10
        allow_stretch: True
        on_im_num: app.change_monst_im()
        on_pos_hint: app.monst_pos_hint()

    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
        on_im_num: app.change_char_im()
        on_pos_hint: app.char_pos_hint()

Listing 5-42Displaying the Number of Collected Coins in a Label Widget Placed at the Top of the Screen

Python 文件内部的char_pos_hint()函数修改为根据当前收集的硬币数量更新添加的标签文本字段。文件如清单 5-43 所示。首先,在类中定义一个名为num_coins_collected的变量,并赋予其初始值 0。如果角色和任何硬币之间发生冲突,那么该变量增加 1,然后Label小部件更新。

因为完成收集所有硬币的任务就意味着当前关卡的结束,不如做点什么来表示关卡的结束。如果num_coins_collected变量中收集的硬币数量等于num_coins变量中的硬币数量,一个标签将被动态添加到小部件树中,并显示"Level Completed"消息。除了创建这个小部件,角色和怪物动画被取消。注意,通过取消怪物动画,它的位置不会改变,因此monst_pos_hint()回调函数不会被执行。

import kivy.app
import kivy.animation
import kivy.uix.image
import kivy.uix.label
import random

class TestApp(kivy.app.App):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        if TestApp.character_killed == False:
            self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, ‘y’: touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17, duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def build(self):
        coin_width = 0.05
        coin_height = 0.05

        section_width = 1.0/TestApp.num_coins
        for k in range(TestApp.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            self.root.add_widget(coin, index=-1)
            TestApp.coins_ids['coin'+str(k)] = coin

    def on_start(self):
        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2

        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            TestApp.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)

    def char_pos_hint(self):
        character_image = self.root.ids['character_image']
        character_center = character_image.center

        gab_x = character_image.width / 3
        gab_y = character_image.height / 3
        coins_to_delete = []

        for coin_key, curr_coin in TestApp.coins_ids.items():
            curr_coin_center = curr_coin.center
            if character_image.collide_widget(curr_coin) and abs(character_center[0] - curr_coin_center[0]) <= gab_x and abs(character_center[1] - curr_coin_center[1]) <= gab_y:
                coins_to_delete.append(coin_key)
                self.root.remove_widget(curr_coin)
                TestApp.num_coins_collected = TestApp.num_coins_collected + 1

                self.root.ids['num_coins_collected'].text = "Coins "+str(TestApp.num_coins_collected)
                if TestApp.num_coins_collected == TestApp.num_coins:
                    kivy.animation.Animation.cancel_all(character_image)
                    kivy.animation.Animation.cancel_all(self.root.ids['monster_image'])
                    self.root.add_widget(kivy.uix.label.Label(pos_hint={'x': 0.1, 'y': 0.1}, size_hint=(0.8, 0.8), font_size=90, text="Level Completed"))

        if len(coins_to_delete) > 0:
            for coin_key in coins_to_delete:
                del TestApp.coins_ids[coin_key]

app = TestApp()
app.run()

Listing 5-43Updating the Label Displaying the Number of Collected Coins and Displaying a Message When the Level Completes

图 5-27 显示关卡完成后的结果。

img/481739_1_En_5_Fig27_HTML.jpg

图 5-27

当关卡完成时,会显示一条消息

音效

一个没有音效的游戏不是一个很好的游戏。声音是用户体验的一个重要因素。你可以给游戏中发生的每一个动作添加音效。对于我们的游戏,我们会在角色死亡时、完成一关时以及收集硬币时添加音效。这是对背景音乐的补充,有助于玩家参与游戏。

Kivy 提供了一个非常简单的接口,使用kivy.core.audio模块中的SoundLoader类来播放声音。清单 5-44 中显示了修改后的 Python 文件,声音在该文件中被加载和播放。

import kivy.app
import kivy.animation
import kivy.uix.image
import kivy.uix.label
import random
import kivy.core.audio
import os

class TestApp(kivy.app.App):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

    def char_animation_completed(self, *args):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0

    def monst_animation_completed(self, *args):
        monster_image = self.root.ids['monster_image']
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def touch_down_handler(self, args):
        if TestApp.character_killed == False:
            self.start_char_animation(args[1].spos)

    def change_char_im(self):
        character_image = self.root.ids['character_image']
        character_image.source = str(int(character_image.im_num)) + ".png"

    def change_monst_im(self):
        monster_image = self.root.ids['monster_image']
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def start_char_animation(self, touch_pos):
        character_image = self.root.ids['character_image']
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2,'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def start_monst_animation(self, new_pos, anim_duration):
        monster_image = self.root.ids['monster_image']
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17,duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def build(self):
        coin_width = 0.05
        coin_height = 0.05

        section_width = 1.0/TestApp.num_coins
        for k in range(TestApp.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            self.root.add_widget(coin, index=-1)
            TestApp.coins_ids['coin'+str(k)] = coin

    def on_start(self):
        music_dir = os.getcwd()+"/music/"
        self.bg_music = kivy.core.audio.SoundLoader.load(music_dir+"bg_music_piano.wav")
        self.bg_music.loop = True

        self.coin_sound = kivy.core.audio.SoundLoader.load(music_dir+"coin.wav")
        self.level_completed_sound = kivy.core.audio.SoundLoader.load(music_dir+"level_completed_flaute.wav")
        self.char_death_sound = kivy.core.audio.SoundLoader.load(music_dir+"char_death_flaute.wav")

        self.bg_music.play()

        monster_image = self.root.ids['monster_image']
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self):
        character_image = self.root.ids['character_image']
        monster_image = self.root.ids['monster_image']

        character_center = character_image.center

        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y:
            self.bg_music.stop()
            self.char_death_sound.play()
            TestApp.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)

    def char_pos_hint(self):
        character_image = self.root.ids['character_image']
        character_center = character_image.center

        gab_x = character_image.width / 3
        gab_y = character_image.height / 3
        coins_to_delete = []

        for coin_key, curr_coin in TestApp.coins_ids.items():
            curr_coin_center = curr_coin.center
            if character_image.collide_widget(curr_coin) and abs(character_center[0] - curr_coin_center[0]) <= gab_x and abs(character_center[1] - curr_coin_center[1]) <= gab_y:
                self.coin_sound.play()
                coins_to_delete.append(coin_key)
                self.root.remove_widget(curr_coin)
                TestApp.num_coins_collected = TestApp.num_coins_collected + 1
                self.root.ids['num_coins_collected'].text = "Coins "+str(TestApp.num_coins_collected)
                if TestApp.num_coins_collected == TestApp.num_coins:
                    self.bg_music.stop()
                    self.level_completed_sound.play()
                    kivy.animation.Animation.cancel_all(character_image)
                    kivy.animation.Animation.cancel_all(self.root.ids['monster_image'])
                    self.root.add_widget(kivy.uix.label.Label(pos_hint={'x': 0.1, 'y': 0.1}, size_hint=(0.8, 0.8), font_size=90, text="Level Completed"))

        if len(coins_to_delete) > 0:
            for coin_key in coins_to_delete:
                del TestApp.coins_ids[coin_key]

app = TestApp()
app.run()

Listing 5-44Adding Sound Effects to the Game

播放声音文件有两个步骤。我们必须首先使用SoundLoader类的load()方法加载声音文件。这个方法接受在music_dir变量中指定的声音文件路径。该变量使用os模块通过os.getcwd()函数返回当前目录。假设声音文件存储在当前目录下名为music的文件夹中,文件的完整路径是os.getcwd()和名为music的文件之间的连接。

所有声音文件都是在应用的TestApp类的on_start()方法中准备的。背景声音文件被加载到bg_music变量中。收集硬币、角色死亡和关卡完成的声音文件分别存储在变量coin_soundchar_death_soundlevel_completed_sound中。注意,这些变量中的每一个都与引用当前对象的self相关联。这有助于在on_start()方法之外控制声音文件。当引用该方法之外的声音文件时,记得使用self

第二步是使用play()方法播放文件。对于背景音乐,在on_start()方法内播放。在char_pos_hint()回调函数中与硬币发生碰撞后,会发出硬币声音。

收集完所有硬币后会播放关卡完成声音。因为关卡已经完成,不再需要背景音乐,因此通过调用stop()方法来停止。

最后与怪物发生碰撞后在monst_pos_hint()回调函数内部播放角色死亡音。加入音效后玩游戏比以前更有趣。

游戏背景

我们可以改变游戏的背景,使之更吸引人,而不是默认的黑色背景。您可以使用纹理、动画图像或静态图像作为背景。

根据清单 5-45 中所示的 KV 文件,游戏背景采用静态图像。使用canvas.beforeFloatLayout内绘制。这保证了图像将覆盖整个窗口。

FloatLayout:
    on_touch_down: app.touch_down_handler(args)
    canvas.before:
        Rectangle:
            size: self.size
            pos: self.pos
            source: 'bg.jpg'

    Label:
        id: num_coins_collected
        size_hint: (0.1, 0.02)
        pos_hint: {'x': 0.0, 'y': 0.97}
        text: "Coins 0"
        font_size: 20

    Image:
        id: monster_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.8, 'y': 0.8}
        source: "10.png"
        im_num: 10
        allow_stretch: True
        on_im_num: app.change_monst_im()
        on_pos_hint: app.monst_pos_hint()

    Image:
        id: character_image
        size_hint: (0.15, 0.15)
        pos_hint: {'x': 0.2, 'y': 0.6}
        source: "0.png"
        im_num: 0
        allow_stretch: True
        on_im_num: app.change_char_im()
        on_pos_hint: app.char_pos_hint()

Listing 5-45Adding a Background Image to the Game

图 5-28 添加背景后的游戏。

img/481739_1_En_5_Fig28_HTML.jpg

图 5-28

向屏幕添加背景图像

游戏开发概述

这个游戏目前只有一个关卡,我们需要增加更多的关卡。在添加更多的关卡之前,对游戏开发到目前为止的进展有一个总体的了解是很重要的。

图 5-29 显示了游戏执行的流程。因为我们的 Kivy 应用实现了build()on_start()方法,根据 Kivy 应用的生命周期,它们将在我们的任何自定义函数之前执行。这从build()功能开始,直到游戏结束,因为角色被杀死或者所有的硬币被收集,关卡完成。每个功能都按执行顺序列出,直到游戏结束,其任务列在右边。

img/481739_1_En_5_Fig29_HTML.jpg

图 5-29

游戏执行的流程

图 5-30 列出了处理服务于角色和怪物动画的事件的回调函数。它还列出了前一个应用中使用的五个类变量。

img/481739_1_En_5_Fig30_HTML.jpg

图 5-30

处理角色和怪物动画的类变量和回调函数的概要

从图 5-30 中,你可以看到怪物的运行需要下面列出的四个函数。请注意,该角色使用的函数与这些函数类似,但名称有所不同。

  • start_monst_animation()

  • change_monst_im()

  • monst_pos_hint()

  • monst_animation_completed()

摘要

该游戏现在有一个角色,使用动画,根据触摸位置移动。一个怪物随机移动,也使用动画。角色在与怪物相撞时被杀死。当它被杀死时,会启动一个一次性动画,改变角色的图像以反映死亡。一些硬币均匀地分布在屏幕上,玩家的任务是收集所有的硬币。屏幕上方的标签显示收集的硬币数量。当角色和硬币发生碰撞时,硬币消失,标签更新。当所有的硬币被收集,水平是完整的。

六、完成并发布您的第一个游戏

在前一章中,我们开始开发一个多层次的跨平台游戏,其中一个角色根据触摸位置移动,使用动画。玩家的工作是在屏幕上收集一些均匀分布的硬币。一个怪物试图攻击玩家以杀死它。当玩家和怪物发生碰撞时,玩家死亡,关卡结束。直到上一章结束,游戏才有了单关。

在这一章中,我们继续通过增加更多的关卡来开发游戏。第四章介绍的屏幕将用于组织游戏界面。将会添加更多的怪物。火将被引入,通过预先定义的路径来来回回。随着游戏的开发,重要的问题将会被讨论和解决。在下一章的最后,这款游戏将在 Google Play 上发布,供 Android 设备上的任何用户下载和安装。

给游戏增加更多关卡

我们的游戏只有一个关卡,玩家必须收集五个硬币才能完成。任务完成后,"Level Completed"消息显示在Label小工具中。在这一部分,更多的关卡将被添加到游戏中,这样玩家就可以从一个关卡进入另一个。在构建这个多关卡游戏的时候,我们会尽量遵循不重复自己(干)的原则。

在 Kivy 中创建多关卡游戏的最佳方式是使用多个屏幕,每个屏幕包含一级的小部件树。因此,我们将使用ScreenScreenManager类来构建和管理多个屏幕。让我们从创建我们将在开发游戏时使用的模板开始。

创建多个屏幕

正如我们在第四章中讨论的,在 Python 文件中为每个要创建的屏幕创建了一个类。因此,为了创建一个具有两个屏幕的应用,在 Python 文件中创建了三个类。一个类用于应用,两个类用于两个屏幕。在我们的游戏中,我们将开始在前一关的基础上增加另一关。所以,一共有两个层次。每个级别都有一个屏幕,因此为每个屏幕创建了一个新的类。将有一个额外的屏幕被用作游戏主屏幕,玩家可以从这里进入任何级别。因此,清单 6-1 中显示的 Python 文件将有三个屏幕。为代表级别的两个屏幕创建的类被命名为Level1Level2。主屏幕类被命名为MainScreen。因为类是空的,所以pass关键字作为虚拟类体被添加。

import kivy.app
import kivy.uix.screenmanager

class TestApp(kivy.app.App):
    pass

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    pass

class Level2(kivy.uix.screenmanager.Screen):
    pass

app = TestApp()
app.run()

Listing 6-1Adding a Level and a Main Screen to the Game Using Screens

在清单 6-2 中的 KV 文件中,ScreenManager被用作应用小部件树中的根小部件。它有三个子代,分别是三个 screens 类的实例,第一个名为level1,第二个名为level2,主屏幕名为main。通过将屏幕类名括在<>之间,可以定义 KV 文件中每个屏幕的布局。

记住,我们之前创建的关卡有一个FloatLayout来保存所有的游戏部件。因为每个屏幕代表一个游戏关卡,它将有一个子级FloatLayout来保存关卡中的所有小部件。这个布局将有一个使用canvas.before内的Rectangle顶点指令关联的背景图像。第一级和第二级图像的名称分别为bg_lvl1.jpgbg_lvl2.jpg

ScreenManager:
    MainScreen:
    Level1:
    Level2:

<MainScreen>:
    name: "main"
    BoxLayout:
        Button:
            text: "Go to Level 1"
            on_press: app.root.current="level1"
        Button:
            text: "Go to Level 2"
            on_press: app.root.current="level2"
<Level1>:
    name: "level1"
    FloatLayout:
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl1.jpg"

<Level2>:
    name: "level2"
    FloatLayout:
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos

                source: "bg_lvl2.jpg"

Listing 6-2Defining the Widget Tree of the Second Level of the Game

记住,ScreenManager类有一个名为current的属性,它接受要在窗口中显示的屏幕名称。如果没有明确指定,它默认为添加到窗口小部件树的第一个屏幕,也就是MainScreen屏幕。该屏幕有一个带有两个子按钮的BoxLayout。每个按钮负责转换到一个游戏级别。在我们运行应用后,我们会看到图 6-1 中的窗口。

img/481739_1_En_6_Fig1_HTML.jpg

图 6-1

游戏的主画面有两个关卡

通过准备这些 KV 和 Python 文件,我们设置了构建两级游戏所遵循的模板。建立更多的关卡将会重复我们后面将要讨论的步骤。

在 KV 文件中添加定制小部件

在之前的游戏中,单人关卡在FloatLayout里面有三个小部件。这些小部件是一个用于打印收集硬币数量的Label,一个用于怪物的Image小部件,以及另一个用于角色的Image小部件。为了构建这两个级别,我们不必为每个屏幕复制三个小部件。

我们可以只创建一次并多次使用,而不是为每个级别创建一个单独的Image小部件。这是通过为游戏中的每个元素创建一个自定义小部件,然后在每个屏幕中重用它。清单 6-3 中修改后的 KV 文件创建了每个级别所需的三个定制小部件。开始时,两个级别之间会有微小的差异。稍后,我们将在第二关中添加更多的怪物和硬币。

名为MonsterCharacter的两个定制小部件分别代表怪物和角色。它们是用来扩展Image小部件的。使用@字符以 KV 为单位进行扩展。记得用<>括起定制小部件的名称。

创建了另一个名为NumCoinsCollected的定制小部件,它扩展了Label小部件来打印收集的硬币数量。每个小部件中的代码与我们在上一个游戏中使用的完全相同。

ScreenManager:
    MainScreen:
    Level1:
    Level2:

<MainScreen>:
    name: "main"
    BoxLayout:
        Button:
            text: "Go to Level 1"
            on_press: app.root.current="level1"
        Button:
            text: "Go to Level 2"
            on_press: app.root.current="level2"
<Level1>:
    name: "level1"
    FloatLayout:
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl1.jpg"
        NumCollectedCoins:
        Monster:
        Character:

<Level2>:
    name: "level2"
    FloatLayout:
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl2.jpg"
        NumCollectedCoins:
        Monster:
        Character:

<NumCollectedCoins@Label>:
    id: num_coins_collected

    size_hint: (0.1, 0.02)
    pos_hint: {'x': 0.0, 'y': 0.97}
    text: "Coins 0"
    font_size: 20

<Monster@Image>:
    id: monster_image
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.8, 'y': 0.8}
    source: "10.png"
    im_num: 10
    allow_stretch: True
    on_im_num: app.change_monst_im(self)
    on_pos_hint: app.monst_pos_hint(self)

<Character@Image>:
    id: character_image
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.2, 'y': 0.6}
    source: "0.png"
    im_num: 0
    allow_stretch: True
    on_im_num: app.change_char_im(self)
    on_pos_hint: app.char_pos_hint(self)

Listing 6-3Creating Custom Widgets for Game Elements

记住,on_im_numon_pos_hint事件被绑定到MonsterCharacter小部件。所有级别的所有Character小部件的事件都使用相同的回调函数来处理,它们是change_char_im()char_pos_hint(),存在于应用类TestApp中。同样的过程也适用于Monster小部件。因为同一个函数将处理不同小部件触发的事件,所以知道哪个小部件触发了事件非常重要。这就是函数接受self参数的原因,该参数指的是触发事件的小部件。

给小部件分配 id

为了在每个屏幕中使用这三个定制小部件,我们创建了一个实例。如果从任一自定义小部件中获取多个实例,就会出现问题。自定义小部件被赋予 id。因为这种小部件的每个实例都继承了它的所有属性,所以如果没有改变,所有实例都将具有相同的 ID。

在 Kivy 中,同一个小部件树中的两个小部件不能有相同的 ID。注意,这两个屏幕仍然在同一个小部件树中,因为它们分组在ScreenManager根小部件下。出于这个原因,我们应该从定制小部件模板中移除 ID,并将其添加到实例中,如清单 6-4 中的新 KV 文件所示。

为了便于操作,同一个小部件的 id 反映了它们所在屏幕的索引。此外,不同屏幕上的相同小部件被赋予相同的名称,除了其 ID 末尾的屏幕索引。例如,第一个屏幕中的Monster小部件被赋予一个 ID monster_image_lvl1,反映出它驻留在索引为 1 的屏幕中。这个小部件在索引为 2 的屏幕中被赋予一个 ID monster_image_lvl2。请注意,主屏幕的索引为 0。

因为我们需要向每个屏幕的FloatLayout小部件添加硬币,所以它们被赋予了 id,以便在 Python 代码中引用它们。他们被赋予了 idlayout_lvl1layout_lvl2

ScreenManager:
    MainScreen:
    Level1:
    Level2:

<MainScreen>:
    name: "main"
    BoxLayout:
        Button:
            text: "Go to Level 1"
            on_press: app.root.current="level1"
        Button:
            text: "Go to Level 2"
            on_press: app.root.current="level2"
<Level1>:
    name: "level1"
    FloatLayout:
        id: layout_lvl1
        on_touch_down: app.touch_down_handler(1, args)
        canvas.before:
            Rectangle:
                size: self.size

                pos: self.pos
                source: "bg_lvl1.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl1
        Monster:
            id: monster_image_lvl1
        Character:
            id: character_image_lvl1

<Level2>:
    name: "level2"
    FloatLayout:
        id: layout_lvl2
        on_touch_down: app.touch_down_handler(2, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl2.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl2
        Monster:
            id: monster_image_lvl2
        Character:
            id: character_image_lvl2

<NumCollectedCoins@Label>:
    size_hint: (0.1, 0.02)
    pos_hint: {'x': 0.0, 'y': 0.97}
    text: "Coins 0"
    font_size: 20

<Monster@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.8, 'y': 0.8}

    source: "10.png"
    im_num: 10
    allow_stretch: True
    on_im_num: app.change_monst_im(self)
    on_pos_hint: app.monst_pos_hint(self)

<Character@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.2, 'y': 0.6}
    source: "0.png"
    im_num: 0
    allow_stretch: True
    on_im_num: app.change_char_im(self)
    on_pos_hint: app.char_pos_hint(self)

Listing 6-4Removing IDs from Custom Widgets and Adding Them Inside the Instances

注意,我们需要将on_touch_down事件绑定到FloatLayout。这是因为游戏中的主角根据触摸的位置移动。在之前的游戏中,记得我们创建了回调函数touch_down_handler()来处理接受args参数的事件。

请注意,所有屏幕都将使用相同的事件处理程序。因此,为了正确处理它,知道哪个屏幕触发了事件是非常重要的。这个概念适用于 Python 文件中的所有共享函数。因此,代表屏幕索引的附加参数被添加到函数中。通过这样做,我们可以引用触发事件的确切屏幕。

这是 KV 文件创建两个游戏关卡所需的大部分工作。后面会有简单的补充。让我们继续看 Python 文件。

游戏类别变量

在之前的单关游戏中,TestApp类中定义了四个变量,分别是character_killednum_coinsnum_coins_collectedcoins_ids。注意,如果一个变量被添加到应用类TestApp,它将在每个屏幕上共享。因此,两个屏幕将使用相同的变量。为了解决这个问题,将在每个屏幕类中定义这样的变量,如清单 6-5 中的 Python 文件所示。

import kivy.app
import kivy.uix.screenmanager

class TestApp(kivy.app.App):
    pass

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}

app = TestApp()
app.run()

Listing 6-5Adding Variables Inside the Level Classes

屏幕 on_pre_enter 事件

根据第五章的图 5-31 和图 5-32,它们总结了执行的游戏流程,build()方法是根据 Kivy 应用生命周期在我们的代码中执行的第一个函数。这个方法用于将硬币的部件添加到部件树中。

因为该功能在应用级执行,所以它不会区分不同的屏幕。因为我们需要在每个屏幕上添加硬币,所以我们应该使用一个名为 per screen 的方法。因此,build()方法不再适用。

Screen类提供了一个名为on_pre_enter的事件,当屏幕在显示之前将要被使用时,该事件被触发。它的回调功能将是添加硬币部件的好地方。该事件被绑定到清单 6-6 中 KV 文件的每个屏幕。名为screen_on_pre_enter()的回调函数为两个屏幕处理这样的事件。为了知道哪个屏幕触发了该事件,这个回调函数接受一个引用ScreenManager中屏幕索引的参数。

ScreenManager:
    MainScreen:
    Level1:
    Level2:

<MainScreen>:
    name: "main"
    BoxLayout:
        Button:
            text: "Go to Level 1"
            on_press: app.root.current="level1"
        Button:
            text: "Go to Level 2"
            on_press: app.root.current="level2"

<Level1>:
    name: "level1"
    on_pre_enter: app.screen_on_pre_enter(1)
    FloatLayout:
        id: layout_lvl1
        on_touch_down: app.touch_down_handler(1, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl1.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl1
        Monster:
            id: monster_image_lvl1
        Character:
            id: character_image_lvl1

<Level2>:
    name: "level2"
    on_pre_enter: app.screen_on_pre_enter(2)
    FloatLayout:
        id: layout_lvl2
        on_touch_down: app.touch_down_handler(2, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl2.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl2
        Monster:
            id: monster_image_lvl2
        Character:
            id: character_image_lvl2

<NumCollectedCoins@Label>:
    size_hint: (0.1, 0.02)
    pos_hint: {'x': 0.0, 'y': 0.97}
    text: "Coins 0"
    font_size: 20

<Monster@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.8, 'y': 0.8}
    source: "10.png"
    im_num: 10
    allow_stretch: True
    on_im_num: app.change_monst_im(self)
    on_pos_hint: app.monst_pos_hint(self)

<Character@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.2, 'y': 0.6}
    source: "0.png"
    im_num: 0
    allow_stretch: True
    on_im_num: app.change_char_im(self)
    on_pos_hint: app.char_pos_hint(self)

Listing 6-6Binding the on_pre_enter Event to Each Screen

向游戏关卡添加硬币

screen_on_pre_enter()回调函数中,硬币被添加到每个屏幕的FloatLayout小部件中,如清单 6-7 中的 Python 文件所示。请注意如何检索num_coins屏幕类变量。它使用其 ID 返回。根据回调函数头中的screen_num参数,该数字被附加到layout_lvl字符串,以便返回触发on_pre_enter事件的屏幕内部的布局。

创建硬币Image小部件后,根据随机模块中使用uniform()函数随机生成的位置,小部件被添加到屏幕的FloatLayout小部件内。

最后,小部件引用被插入到 screen 类中定义的coins_ids字典中。它的访问方式类似于num_coins变量。

import kivy.app
import kivy.uix.screenmanager
import random

class TestApp(kivy.app.App):

    def screen_on_pre_enter(self, screen_num):
        coin_width = 0.05
        coin_height = 0.05

        curr_screen = self.root.screens[screen_num]

        section_width = 1.0/curr_screen.num_coins
        for k in range(curr_screen.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            curr_screen.ids['layout_lvl'+str(screen_num)].add_widget(coin, index=-1)
            curr_screen.coins_ids['coin'+str(k)] = coin

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}

app = TestApp()
app.run()

Listing 6-7Adding Coins to the Screen Inside the screen_on_pre_enter() Callback Function

使用索引引用 Python 中的屏幕

使用下一行在curr_screen变量中返回对当前屏幕类的引用。关键字self指的是TestApp类。root返回根小部件,也就是ScreenManager。为了访问管理器内的屏幕,返回ScreenManager内的screens列表。

curr_screen = self.root.screens[screen_num]

打印时,该列表的内容显示在显示屏幕名称的下一行。

[<Screen name="main">, <Screen name="level1">, <Screen name="level2">]

名为level1的屏幕是第二个元素,因此索引为 1。level2屏幕的索引为 2,主屏幕的索引为 0。返回类引用后,我们可以访问其中的任何变量。图 6-2 显示运行应用后的level2屏幕。

img/481739_1_En_6_Fig2_HTML.jpg

图 6-2

第二层屏幕

屏幕 on_enter 事件

我们用 screen 类的 on_pre_enter 事件的回调替换了build()方法。根据 Kivy 应用生命周期,我们之前游戏中要调用的下一个方法是on_start()方法。同样,这个方法在整个应用中只存在一次。我们需要使用一种方法来区分不同的屏幕。

Screen类有一个名为on_enter的事件,它正好在屏幕显示之前被触发。为了将该事件绑定到屏幕上,修改后的 KV 文件如清单 6-8 所示。类似于on_pre_enter事件,有一个名为screen_on_enter()的回调函数与所有屏幕相关联。它接受一个引用触发事件的屏幕索引的数字。

ScreenManager:
    MainScreen:
    Level1:
    Level2:

<MainScreen>:
    name: "main"
    BoxLayout:
        Button:
            text: "Go to Level 1"
            on_press: app.root.current="level1"
        Button:
            text: "Go to Level 2"
            on_press: app.root.current="level2"

<Level1>:
    name: "level1"
    on_pre_enter: app.screen_on_pre_enter(1)
    on_enter: app.screen_on_enter(1)
    FloatLayout:
        id: layout_lvl1
        on_touch_down: app.touch_down_handler(1, args)
        canvas.before:
            Rectangle:
                size: self.size

                pos: self.pos
                source: "bg_lvl1.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl1
        Monster:
            id: monster_image_lvl1
        Character:
            id: character_image_lvl1

<Level2>:
    name: "level2"
    on_pre_enter: app.screen_on_pre_enter(2)
    on_enter: app.screen_on_enter(2)
    FloatLayout:
        id: layout_lvl2
        on_touch_down: app.touch_down_handler(2, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl2.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl2
        Monster:
            id: monster_image_lvl2
        Character:
            id: character_image_lvl2

<NumCollectedCoins@Label>:
    size_hint: (0.1, 0.02)
    pos_hint: {'x': 0.0, 'y': 0.97}

    text: "Coins 0"
    font_size: 20

<Monster@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.8, 'y': 0.8}
    source: "10.png"
    im_num: 10
    allow_stretch: True
    on_im_num: app.change_monst_im(self)
    on_pos_hint: app.monst_pos_hint(self)

<Character@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.2, 'y': 0.6}
    source: "0.png"
    im_num: 0
    allow_stretch: True
    on_im_num: app.change_char_im(self)
    on_pos_hint: app.char_pos_hint(self)

Listing 6-8Binding the on_enter Event to the Screens Inside the KV File

清单 6-9 展示了实现screen_on_enter()回调函数后的 Python 文件。在引用当前屏幕和使用其 ID 的Monster小部件(通过将屏幕号附加到monster_image_lvl字符串)后,一切都类似于上一个游戏的on_start()方法。

import kivy.app
import kivy.uix.screenmanager
import random

class TestApp(kivy.app.App):

    def screen_on_pre_enter(self, screen_num):
        coin_width = 0.05
        coin_height = 0.05

        curr_screen = self.root.screens[screen_num]

        section_width = 1.0/curr_screen.num_coins
        for k in range(curr_screen.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)

            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            self.root.screens[screen_num].ids['layout_lvl'+str(screen_num)].add_widget(coin, index=-1)
            self.root.screens[screen_num].coins_ids['coin'+str(k)] = coin

    def screen_on_enter(self, screen_num):
        curr_screen = self.root.screens[screen_num]
        monster_image = curr_screen.ids['monster_image_lvl'+str(screen_num)]
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False

    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}

app = TestApp()
app.run()

Listing 6-9Implementing the screen_on_enter() Callback Function Inside the Python File

怪物动画

screen_on_enter()函数调用start_monst_animation()函数来启动怪物的动画。该功能是根据清单 6-10 中修改后的 Python 文件实现的。在之前的游戏中,动画开始时使用当前多关卡游戏中预定义的 ID 引用怪物Image小部件。因此,这个函数被修改,以便接受Monster小部件来运行动画。

import kivy.app
import kivy.uix.screenmanager
import random

class TestApp(kivy.app.App):

    def screen_on_pre_enter(self, screen_num):
        coin_width = 0.05
        coin_height = 0.05

        curr_screen = self.root.screens[screen_num]

        section_width = 1.0/curr_screen.num_coins
        for k in range(curr_screen.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            curr_screen.ids['layout_lvl'+str(screen_num)].add_widget(coin, index=-1)
            curr_screen.coins_ids['coin'+str(k)] = coin

    def screen_on_enter(self, screen_num):
        curr_screen = self.root.screens[screen_num]
        monster_image = curr_screen.ids['monster_image_lvl'+str(screen_num)]
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def start_monst_animation(self, monster_image, new_pos, anim_duration):
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17,duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

def monst_animation_completed(self, *args):

    monster_image = args[1]
    monster_image.im_num = 10

    new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
    self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False

    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}

app = TestApp()
app.run()

Listing 6-10Starting the Monster Animation Inside the screen_on_enter() callback Function

怪物动画在完成后触发on_complete事件,并有一个名为monst_animaiton_completed()的回调函数,该函数在清单 6-10 中实现。

注意 Monster 小部件是如何被引用的。这个事件在args变量中传递参数。当它被打印出来时,看起来如下所示:

(<kivy.animation.Animation object at 0x7f9eeb0f2a70>, <WeakProxy to <kivy.factory.Monster object at 0x7f9eeb15f8d0
≫
)

它是一个元组,其中索引 0 处的第一个元素引用触发事件的动画,索引 1 处的第二个元素是与动画相关联的小部件。因此,我们可以通过用索引 1 索引args来直接引用小部件。

处理 Monster on_pos_hint 和 on_im_num 事件

根据清单 6-8 中的 KV 文件,Monster widget 触发on_pos_hinton_im_num events,使用monst_pos_hint()change_monst_im()回调函数处理。清单 6-11 中的新 KV 文件显示了它们的实现。

这两个函数都接受触发事件的小部件。在函数内部,我们不仅需要怪物小部件,还需要角色小部件。我们如何引用那个小部件?

import kivy.app
import kivy.uix.screenmanager
import random
import kivy.clock
import functools

class TestApp(kivy.app.App):

    def screen_on_pre_enter(self, screen_num):
        coin_width = 0.05
        coin_height = 0.05

        curr_screen = self.root.screens[screen_num]

        section_width = 1.0/curr_screen.num_coins
        for k in range(curr_screen.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            curr_screen.ids['layout_lvl'+str(screen_num)].add_widget(coin, index=-1)
            curr_screen.coins_ids['coin'+str(k)] = coin

    def screen_on_enter(self, screen_num):
        curr_screen = self.root.screens[screen_num]
        monster_image = curr_screen.ids['monster_image_lvl'+str(screen_num)]
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def start_monst_animation(self, monster_image, new_pos, anim_duration):
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17,duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def monst_animation_completed(self, *args):
        monster_image = args[1]
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self, monster_image):
        screen_num = int(monster_image.parent.parent.name[5:])
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y and curr_screen.character_killed == False:
            curr_screen.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)
            kivy.clock.Clock.schedule_once(functools.partial(self.back_to_main_screen, curr_screen.parent), 3)

    def change_monst_im(self, monster_image):
        screen_num = int(monster_image.parent.parent.name[5:])
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def back_to_main_screen(self, screenManager, *args):
        screenManager.current = "main"

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}

app = TestApp()
app.run()

Listing 6-11Handling the on_pos_hint and on_im_num Events of the Monster Widget

引用 Screen FloatLayout 使用其子级

我们可以使用parent属性引用Monster小部件的父部件FloatLayout。从Floatlayout中,我们可以再次使用父属性来引用它的parent屏幕。引用屏幕后,我们可以使用name属性访问它的名称。在 KV 文件中,两级屏幕的名称末尾有一个数字,表示屏幕索引。返回屏幕索引的行如下所示。指数从 5 开始。

在当前只有两级的游戏中,屏幕的指数是 1 和 2。因此,我们可以只使用-1而不是5:来返回屏幕名称末尾的数字。但是只有当屏幕的索引是从 0 到 9 的单个数字时,这才会起作用。如果屏幕的索引为 10,它将不起作用。

为了将索引转换成数字,使用了int()函数。

screen_num = int(monster_image.parent.parent.name[5:])

返回的索引保存在screen_num变量中。该索引可用于根据 ID 引用Character小部件,就像之前对Monster小部件所做的那样。只需将它附加到character_image_lvl字符串中。

返回主屏幕

在角色被杀死后,应用会转到主屏幕。这是通过调度使用kivy.clock.Clock.schedule_interval()回调函数在三秒钟后将ScreenManager的当前属性更改为main来完成的。名为back_to_main_screen()的函数被调用,它接受ScreenManager。为了将参数传递给schedule_interval()函数,我们使用了functools.partial()函数。

至此,我们完成了Monster小部件的所有必需工作。让我们继续看一下Character小部件。

使用 on_touch_down 事件处理角色运动

根据清单 6-8 中的 KV 文件,FloatLayout有一个名为on_touch_down的事件,它是使用touch_down_handler()回调函数处理的,如下一个 Python 文件所示。该函数接受screen_num参数中的屏幕索引。它使用该索引来引用在 screen 类中定义的character_killed属性。

为了启动动画,回调函数调用启动Character小部件动画的start_char_animation()函数。它在清单 6-12 中实现。这个函数接受代表屏幕索引的screen_num参数。它的实现与之前游戏中讨论的完全相同,除了使用screen_num参数引用Character小部件。

import kivy.app
import kivy.uix.screenmanager
import random
import kivy.clock
import functools

class TestApp(kivy.app.App):

    def screen_on_pre_enter(self, screen_num):
        coin_width = 0.05
        coin_height = 0.05

        curr_screen = self.root.screens[screen_num]

        section_width = 1.0/curr_screen.num_coins
        for k in range(curr_screen.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            curr_screen.ids['layout_lvl'+str(screen_num)].add_widget(coin, index=-1)
            curr_screen.coins_ids['coin'+str(k)] = coin

    def screen_on_enter(self, screen_num):
        curr_screen = self.root.screens[screen_num]
        monster_image = curr_screen.ids['monster_image_lvl'+str(screen_num)]
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def start_monst_animation(self, monster_image, new_pos, anim_duration):
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17,duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def monst_animation_completed(self, *args):
        monster_image = args[1]
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self, monster_image):
        screen_num = int(monster_image.parent.parent.name[5:])
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y and curr_screen.character_killed == False:
            curr_screen.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)
            kivy.clock.Clock.schedule_once(functools.partial(self.back_to_main_screen, curr_screen.parent), 3)

    def change_monst_im(self, monster_image):
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def touch_down_handler(self, screen_num, args):
        curr_screen = self.root.screens[screen_num]
        if curr_screen.character_killed == False:
            self.start_char_animation(screen_num, args[1].spos)

    def start_char_animation(self, screen_num, touch_pos):
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2,'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def char_animation_completed(self, *args):
        character_image = args[1]
        character_image.im_num = 0

    def back_to_main_screen(self, screenManager, *args):
        screenManager.current = "main"

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}

app = TestApp()
app.run()

Listing 6-12Implementing the start_char_animation() Callback Function to Start the Animation of the Character Widget

因为on_complete事件被绑定到动画,所以它的回调函数char_animation_completed()是在清单 6-12 中的 Python 文件中实现的。如前所述,它使用args参数引用Character小部件。

处理字符 on_pos_hint 和 on_im_num 事件

类似于Monster小部件,KV 文件中定义的Character小部件触发两个事件— on_pos_hinton_im_num。它们的回调函数如清单 6-13 中的 Python 文件所示实现,这与前面讨论的类似。

当所有的硬币被收集后,应用返回到主屏幕,就像之前角色被杀死时一样。

import kivy.app
import kivy.uix.screenmanager
import random
import kivy.clock
import functools

class TestApp(kivy.app.App):

    def screen_on_pre_enter(self, screen_num):
        coin_width = 0.05
        coin_height = 0.05

        curr_screen = self.root.screens[screen_num]

        section_width = 1.0/curr_screen.num_coins
        for k in range(curr_screen.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            curr_screen.ids['layout_lvl'+str(screen_num)].add_widget(coin, index=-1)
            curr_screen.coins_ids['coin'+str(k)] = coin

    def screen_on_enter(self, screen_num):
        curr_screen = self.root.screens[screen_num]
        monster_image = curr_screen.ids['monster_image_lvl'+str(screen_num)]
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def start_monst_animation(self, monster_image, new_pos, anim_duration):
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17,duration=anim_duration)
        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def monst_animation_completed(self, *args):
        monster_image = args[1]

        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self, monster_image):
        screen_num = int(monster_image.parent.parent.name[5:])
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y and curr_screen.character_killed == False:
            curr_screen.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95)
            char_anim.start(character_image)
            kivy.clock.Clock.schedule_once(functools.partial(self.back_to_main_screen, curr_screen.parent), 3)

    def change_monst_im(self, monster_image):
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def touch_down_handler(self, screen_num, args):
        curr_screen = self.root.screens[screen_num]
        if curr_screen.character_killed == False:
            self.start_char_animation(screen_num, args[1].spos)

    def start_char_animation(self, screen_num, touch_pos):
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2,'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def char_animation_completed(self, *args):
        character_image = args[1]
        character_image.im_num = 0

    def char_pos_hint(self, character_image):
        screen_num = int(character_image.parent.parent.name[5:])
        character_center = character_image.center

        gab_x = character_image.width / 3
        gab_y = character_image.height / 3
        coins_to_delete = []
        curr_screen = self.root.screens[screen_num]

        for coin_key, curr_coin in curr_screen.coins_ids.items():
            curr_coin_center = curr_coin.center
            if character_image.collide_widget(curr_coin) and abs(character_center[0] - curr_coin_center[0]) <= gab_x and abs(character_center[1] - curr_coin_center[1]) <= gab_y:
                coins_to_delete.append(coin_key)
                curr_screen.ids['layout_lvl'+str(screen_num)].remove_widget(curr_coin)
                curr_screen.num_coins_collected = curr_screen.num_coins_collected + 1
                curr_screen.ids['num_coins_collected_lvl'+str(screen_num)].text = "Coins "+str(curr_screen.num_coins_collected)
                if curr_screen.num_coins_collected == curr_screen.num_coins:
                    kivy.animation.Animation.cancel_all(character_image)
                    kivy.clock.Clock.schedule_once(functools.partial(self.back_to_main_screen, curr_screen.parent), 3)                    kivy.animation.Animation.cancel_all(curr_screen.ids['monster_image_lvl'+str(screen_num)])

                    curr_screen.ids['layout_lvl'+str(screen_num)].add_widget(kivy.uix.label.Label(pos_hint={'x': 0.1, 'y': 0.1}, size_hint=(0.8, 0.8), font_size=90, text="Level Completed"))

        if len(coins_to_delete) > 0:
            for coin_key in coins_to_delete:
                del curr_screen.coins_ids[coin_key]

    def change_char_im(self, character_image):
        character_image.source = str(int(character_image.im_num)) + ".png"

    def back_to_main_screen(self, screenManager, *args):
        screenManager.current = "main"

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}

app = TestApp()

app.run()

Listing 6-13Handling the on_pos_hint and on_im_num Events of the Character Widget

到上一步为止,已经成功创建了一个两级游戏,它使用的代码几乎与之前的单级游戏相同。但是在玩的时候会出现一些问题。你发现什么问题了吗?不用担心;我们将在下一节讨论它们。

游戏的问题

之前的游戏中存在六个问题。大部分是由于Screen类中定义的变量初始化不当(即Level1Level2)。

问题 1:角色在关卡开始后立即死亡

第一个问题发生在玩一个关卡时角色被杀死,然后我们试图重新开始那个关卡。这个角色很容易在关卡开始时被杀死,这取决于怪物的动作。我们来讨论一下这个问题。

当怪物和角色的位置满足monst_pos_hint()回调函数中的条件时,这意味着角色和怪物发生了碰撞,因此角色被杀死。三秒钟后,应用将自动重定向到主屏幕。当玩家再次开始相同的关卡时,角色和怪物存在于角色被杀死的相同位置。这可能会导致在新关卡开始后立即杀死角色,因为玩家将无法足够快地移动角色。

在图 6-3 所示的情况下,角色和怪物正在向箭头所指的方向移动。因为他们朝不同的方向移动,怪物的新位置可能会远离当前角色的位置。因此,角色不会被杀死。不管怎样,我们必须解决这个问题。

img/481739_1_En_6_Fig3_HTML.jpg

图 6-3

角色和怪物部件正在相互远离

解决办法

在关卡结束后,我们必须重置角色和怪物的位置。事实上,我们必须重置从上一次游戏时间计算的所有内容,例如收集的硬币数量。

最好是在关卡结束后重置位置,而不是在再次开始关卡之前,以确保没有碰撞的机会。Screen类提供了一个名为on_pre_leave的事件,该事件在离开屏幕之前被触发。在它的回调函数中,我们可以改变角色和怪物的位置,以确保它们在下一关开始时彼此远离。

清单 6-14 中显示了用于将on_pre_leave绑定到两个屏幕的修改后的 KV 文件。有必要将此事件绑定到每个级别的屏幕。名为screen_on_pre_leave()的回调函数被附加到这个事件。它接受一个定义屏幕索引的参数,以便访问 Python 文件中的屏幕及其小部件。

ScreenManager:
    MainScreen:
    Level1:
    Level2:

<MainScreen>:
    name: "main"
    BoxLayout:
        Button:
            text: "Go to Level 1"
            on_press: app.root.current="level1"
        Button:
            text: "Go to Level 2"
            on_press: app.root.current="level2"

<Level1>:
    name: "level1"
    on_pre_enter: app.screen_on_pre_enter(1)
    on_pre_leave: app.screen_on_pre_leave(1)
    on_enter: app.screen_on_enter(1)
    FloatLayout:
        id: layout_lvl1
        on_touch_down: app.touch_down_handler(1, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos

                source: "bg_lvl1.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl1
        Monster:
            id: monster_image_lvl1
        Character:
            id: character_image_lvl1

<Level2>:
    name: "level2"
    on_pre_enter: app.screen_on_pre_enter(2)
    on_pre_leave: app.screen_on_pre_leave(2)
    on_enter: app.screen_on_enter(2)
    FloatLayout:
        id: layout_lvl2
        on_touch_down: app.touch_down_handler(2, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl2.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl2
        Monster:
            id: monster_image_lvl2
        Character:
            id: character_image_lvl2

<NumCollectedCoins@Label>:
    size_hint: (0.1, 0.02)
    pos_hint: {'x': 0.0, 'y': 0.97}
    text: "Coins 0"
    font_size: 20

<Monster@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.8, 'y': 0.8}

    source: "10.png"
    im_num: 10
    allow_stretch: True
    on_im_num: app.change_monst_im(self)
    on_pos_hint: app.monst_pos_hint(self)

<Character@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.2, 'y': 0.6}
    source: "0.png"
    im_num: 0
    allow_stretch: True
    on_im_num: app.change_char_im(self)
    on_pos_hint: app.char_pos_hint(self)

Listing 6-14Binding the on_pre_leave to the Screens

该功能的实现如清单 6-15 所示。它将怪物和角色的位置设置为 KV 文件中使用的相同位置。

def screen_on_pre_leave(self, screen_num):
    curr_screen = self.root.screens[screen_num]

    curr_screen.ids['monster_image_lvl' + str(screen_num)].pos_hint = {'x': 0.8, 'y': 0.8}
    curr_screen.ids['character_image_lvl' + str(screen_num)].pos_hint = {'x': 0.2, 'y': 0.6}

Listing 6-15Implementing the screen_on_pre_leave() Callback Function

问题 2:角色在重新开始同一个关卡后没有移动

角色被杀死后,monst_pos_hint()回调函数将character_killed类变量设置为True。即使在重新启动同一级别后,该标志仍然有效。根据 Python 文件中的 touch _down_handler()回调函数,只有该标志为False时才会启动角色动画。

解决办法

为了解决这个问题,必须将character_killed标志重置为False。这允许touch_down_handler()功能激活角色移动到被触摸的位置。

因为这个值在屏幕启动之前必须是 false,所以我们可以在on_pre_enter事件的screen_on_pre_enter()回调函数内部重置它。修改后的功能如清单 6-16 所示。这允许角色在重新开始被杀死的关卡时移动。

def screen_on_pre_enter(self, screen_num):
    curr_screen = self.root.screens[screen_num]
    curr_screen.character_killed = False

    coin_width = 0.05
    coin_height = 0.05

    section_width = 1.0 / curr_screen.num_coins
    for k in range(curr_screen.num_coins):
        x = random.uniform(section_width * k, section_width * (k + 1) - coin_width)
        y = random.uniform(0, 1 - coin_height)
        coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y},
                                    allow_stretch=True)
        curr_screen.ids['layout_lvl' + str(screen_num)].add_widget(coin, index=-1)
        curr_screen.coins_ids['coin' + str(k)] = coin

Listing 6-16Allowing the Character to Move When Replaying a Level It Was Killed in Previously

问题 3:角色形象从一个死的形象开始

在角色被杀死后,在monst_pos_hint()回调函数的末尾有一个动画,在进入主屏幕之前,将它的im_num从 91 变为 95。动画结束后,im_num属性将被设置为 95。

如果重启关卡,im_num的值还是 95,也就是死角色形象。我们可以用同样的方法重置怪物的图像。

解决办法

解决方法是将角色的im_num属性值改为 0,表示一个活着的角色。怪物图像也被重置为 0。这可以在screen_on_pre_enter()回调函数中更改,在清单 6-17 中进行了修改。

def screen_on_pre_enter(self, screen_num):
    curr_screen = self.root.screens[screen_num]
    curr_screen.character_killed = False
    curr_screen.ids['character_image_lvl' + str(screen_num)].im_num = 0
    curr_screen.ids['monster_image_lvl' + str(screen_num)].im_num = 10

    coin_width = 0.05
    coin_height = 0.05

    section_width = 1.0 / curr_screen.num_coins
    for k in range(curr_screen.num_coins):
        x = random.uniform(section_width * k, section_width * (k + 1) - coin_width)
        y = random.uniform(0, 1 - coin_height)
        coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y},
                                    allow_stretch=True)
        curr_screen.ids['layout_lvl' + str(screen_num)].add_widget(coin, index=-1)
        curr_screen.coins_ids['coin' + str(k)] = coin

Listing 6-17Forcing the im_num property of the Character Widget to 0 Before Starting a Level

问题 4:未收集的硬币会留在下一次相同关卡的试玩中

当角色被杀死时,会有一些剩余的硬币没有被收集。不幸的是,当玩家试图重玩相同的关卡时,这些硬币将保持不被收集。我们来讨论一下为什么会出现这种情况。

假设我们从主屏幕中选择了第二关,根据在类中定义的num_coins有八个硬币。在屏幕开始之前,硬币是在on_pre_enter事件的回调函数中创建的,这个事件就是screen_on_pre_enter()。在该函数中,为每个硬币创建一个新的Image小部件,并作为子小部件添加到FloatLayout。为了引用添加的小部件,硬币也被添加到在Screen类中定义的coins_ids字典中。根据图 6-4 打印执行回调函数前后的布局内容和字典。注意,小部件中的子部件是使用children属性返回的。

img/481739_1_En_6_Fig4_HTML.jpg

图 6-4

在调用 on_pre_enter()回调函数之前和之后打印硬币字典和游戏布局

在触发on_pre_enter之前,字典是空的,正如在类中初始化的那样,并且布局只有三个子元素,它们是在 KV 文件中添加的(CharacterMonsterNumCollectedCoins)。

在事件在screen_on_pre_enter()函数中被处理后,八个硬币Image部件被创建并添加到布局和字典中。因此,布局总共有 11 个子节点,字典有 8 个条目。

图 6-5 显示随机分布在屏幕上的硬币。

img/481739_1_En_6_Fig5_HTML.jpg

图 6-5

在屏幕上随机分配硬币

假设人物在收集到八个硬币中的两个后被杀死,如图 6-6 。请注意,左上角标签中当前显示的文本反映了只收集了两枚硬币。结果,屏幕中剩下六个硬币。

img/481739_1_En_6_Fig6_HTML.jpg

图 6-6

人物在收集了 8 个硬币中的 2 个后被杀死

玩家希望在on_pre_enter被触发之前,当他们重新开始那个关卡时,在布局中有一个空字典和三个子部件。但是根据图 6-7 ,这并没有发生。在处理事件之前,布局有九个子节点而不是三个子节点,字典有六个子节点而不是零个子节点。

img/481739_1_En_6_Fig7_HTML.jpg

图 6-7

硬币字典不会在重玩一关后重置,它会储存先前游戏中的硬币信息

在第一次玩第二关时收集了两枚硬币后,剩余的六枚硬币不会从字典中删除。注意,字典中有键— coin2coin4—它们是前一次收集的,因为它们的键在字典中丢失了。

在关卡结束后,必须重置布局和词典。

注意,游戏不重置字典也能很好地运行,但最好将所有类变量重置为初始值。执行screen_on_pre_enter()回调函数后,未被重置的字典内容和布局如图 6-8 所示。有八个新的Image小部件代表添加到布局中的新硬币,因此布局中的孩子总数现在是 17。在这 17 个部件中,有三个部件分别代表CharacterMonsterNumCollectedCoins。剩下的六个小部件是上次玩 2 级游戏时没有收集的硬币Image小部件。

img/481739_1_En_6_Fig8_HTML.jpg

图 6-8

在上次关卡试玩中没有收集到的硬币可以在再次玩该关卡时获得

字典有八个条目,这意味着之前的六个硬币从字典中删除了。之前的六个小部件没有被删除,而是被新的小部件覆盖,因为字典对条目使用相同的键(coin0coin7)。注意,带有关键字coin2coin4的项目,即之前收集的硬币,被添加到字典的末尾。

当屏幕显示时,结果如图 6-9 所示。请注意,上一次玩第二关时的前六枚硬币会显示在屏幕上,因为它们存在于FloatLayout中。但是这些硬币不能被收集。那是因为它们的位置是从coins_ids字典中检索的。因为之前的六个硬币被新的八个小部件覆盖了,所以我们无法访问它们的位置。因此,你只能从屏幕上显示的 14 个硬币中选择 8 个。

img/481739_1_En_6_Fig9_HTML.jpg

图 6-9

当重新玩这个关卡时,会出现上一关没有收集到的硬币

解决办法

因为我们需要确保coins_ids字典是空的,并且布局没有以前的硬币Image部件,我们可以在screen_on_pre_enter()回调函数中重置它们。

修改后的功能在清单 6-18 中列出。为了从FloatLayout中移除一个小部件,我们必须引用这个小部件。请记住,上一次试验中对 coins 小部件的所有引用都存储在coins_ids字典中。我们可以获取每个字典条目,返回硬币小部件,然后将其传递给remove_widget()函数,该函数由使用其 ID 和函数的screen_num参数获取的FloatLayout调用。循环结束后,使用{}重置字典。

def screen_on_pre_enter(self, screen_num):
    curr_screen = self.root.screens[screen_num]
    curr_screen.character_killed = False
    curr_screen.ids['character_image_lvl' + str(screen_num)].im_num = 0
    curr_screen.ids['monster_image_lvl' + str(screen_num)].im_num = 10

    for key, coin in curr_screen.coins_ids.items():
        curr_screen.ids['layout_lvl' + str(screen_num)].remove_widget(coin)
    curr_screen.coins_ids = {}

    coin_width = 0.05
    coin_height = 0.05

    section_width = 1.0 / curr_screen.num_coins
    for k in range(curr_screen.num_coins):
        x = random.uniform(section_width * k, section_width * (k + 1) - coin_width)
        y = random.uniform(0, 1 - coin_height)
        coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y},
                                    allow_stretch=True)
        curr_screen.ids['layout_lvl' + str(screen_num)].add_widget(coin, index=-1)
        curr_screen.coins_ids['coin' + str(k)] = coin

Listing 6-18Resetting the Coins Dictionary and Layout Before Playing a Level

这样做的话,在当前的试玩中就不会出现先前试玩中给定关卡的硬币了。我们还可以打印子布局和字典条目,如图 6-10 所示,以确保一切按预期运行。第二次启动相同的级别后,布局只包含 KV 文件中定义的三个小部件。还有,字典是空的。

img/481739_1_En_6_Fig10_HTML.jpg

图 6-10

打印布局子项和硬币字典,以确保一次试验中的硬币不会出现在下一次试验中

问题 NumCollectedCoins 标签小部件不以“硬币 0”文本开始

图 6-11 显示了角色收集到两枚硬币后被杀死的结果。标签显示"Coins 2",确认收集了两枚硬币。

img/481739_1_En_6_Fig11_HTML.jpg

图 6-11

角色在收集了两枚硬币后被杀死

当级别重复时,我们期望看到标签上的文本"Coins 0",但是显示的是"Coins 2",如图 6-12 所示。

img/481739_1_En_6_Fig12_HTML.jpg

图 6-12

标签中的文本尚未重置

解决办法

为了解决这个问题,根据清单 6-19 中修改后的screen_on_pre_enter()回调函数,在屏幕进入之前,标签上显示的文本必须重置为"Coins 0"

def screen_on_pre_enter(self, screen_num):
    curr_screen = self.root.screens[screen_num]
    curr_screen.character_killed = False
    curr_screen.ids['character_image_lvl' + str(screen_num)].im_num = 0
    curr_screen.ids['monster_image_lvl' + str(screen_num)].im_num = 10
    curr_screen.ids['num_coins_collected_lvl' + str(screen_num)].text = "Coins 0"

    for key, coin in curr_screen.coins_ids.items():
        curr_screen.ids['layout_lvl' + str(screen_num)].remove_widget(coin)
    curr_screen.coins_ids = {}

    coin_width = 0.05
    coin_height = 0.05

    section_width = 1.0 / curr_screen.num_coins
    for k in range(curr_screen.num_coins):
        x = random.uniform(section_width * k, section_width * (k + 1) - coin_width)
        y = random.uniform(0, 1 - coin_height)
        coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y},
                                    allow_stretch=True)
        curr_screen.ids['layout_lvl' + str(screen_num)].add_widget(coin, index=-1)
        curr_screen.coins_ids['coin' + str(k)] = coin

Listing 6-19Resetting the Label Text to “Coins 0” Before Entering a Screen

问题 6:在接下来的试验中,收集的硬币数量不会从 0 开始

重置显示在Label小部件上的文本并不能完全解决之前的问题。我们的目标是当关卡再次开始时,将收集的硬币数量重置为 0。当收集到一枚硬币时,我们希望数字增加 1,文本从“硬币 0”变为“硬币 1”,但这并没有发生。我们确实重置了关卡开始时显示的文本,但这并没有完全解决问题。

标签上显示的数字取自类中定义的num_coins_collected变量。从图 6-12 可知,变量值为 2。因为它的值没有被重置,所以当级别再次开始时,它将保持相同的值。当下一次尝试收集到单个硬币时,该数字将增加 1 为 3,如图 6-13 所示。因此,我们需要在接下来的试验中将变量重置为从 0 开始。

img/481739_1_En_6_Fig13_HTML.jpg

图 6-13

收集的硬币数量从上次游戏停止的地方开始计算

解决办法

在清单 6-20 的screen_on_pre_enter()函数中,num_coins_collected类变量的值被修改为 0。

def screen_on_pre_enter(self, screen_num):
    curr_screen = self.root.screens[screen_num]
    curr_screen.character_killed = False
    curr_screen.num_coins_collected = 0
    curr_screen.ids['character_image_lvl' + str(screen_num)].im_num = 0
    curr_screen.ids['monster_image_lvl' + str(screen_num)].im_num = 10
    curr_screen.ids['num_coins_collected_lvl' + str(screen_num)].text = "Coins 0"

    for key, coin in curr_screen.coins_ids.items():
        curr_screen.ids['layout_lvl' + str(screen_num)].remove_widget(coin)
    curr_screen.coins_ids = {}

    coin_width = 0.05
    coin_height = 0.05

    curr_screen = self.root.screens[screen_num]

    section_width = 1.0 / curr_screen.num_coins
    for k in range(curr_screen.num_coins):
        x = random.uniform(section_width * k, section_width * (k + 1) - coin_width)
        y = random.uniform(0, 1 - coin_height)

        coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y},
                                    allow_stretch=True)
        curr_screen.ids['layout_lvl' + str(screen_num)].add_widget(coin, index=-1)
        curr_screen.coins_ids['coin' + str(k)] = coin

Listing 6-20Resetting the num_coins_collected to 0 Before Starting a Screen

完整的游戏实现

在解决了应用中的所有问题后,游戏的完整代码将在此部分列出。清单 6-21 显示了 KV 文件。Python 文件的完整代码如清单 6-22 所示。

KV 文件

ScreenManager:
    MainScreen:
    Level1:
    Level2:

<MainScreen>:
    name: "main"
    BoxLayout:
        Button:
            text: "Go to Level 1"
            on_press: app.root.current="level1"
        Button:
            text: "Go to Level 2"
            on_press: app.root.current="level2"

<Level1>:
    name: "level1"
    on_pre_enter: app.screen_on_pre_enter(1)
    on_pre_leave: app.screen_on_pre_leave(1)
    on_enter: app.screen_on_enter(1)
    FloatLayout:
        id: layout_lvl1
        on_touch_down: app.touch_down_handler(1, args)
        canvas.before:
            Rectangle:
                size: self.size

                pos: self.pos
                source: "bg_lvl1.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl1
        Monster:
            id: monster_image_lvl1
        Character:
            id: character_image_lvl1

<Level2>:
    name: "level2"
    on_pre_enter: app.screen_on_pre_enter(2)
    on_pre_leave: app.screen_on_pre_leave(2)
    on_enter: app.screen_on_enter(2)
    FloatLayout:
        id: layout_lvl2
        on_touch_down: app.touch_down_handler(2, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl2.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl2
        Monster:
            id: monster_image_lvl2
        Character:
            id: character_image_lvl2

<NumCollectedCoins@Label>:
    size_hint: (0.1, 0.02)
    pos_hint: {'x': 0.0, 'y': 0.97}
    text: "Coins 0"
    font_size: 20

<Monster@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.8, 'y': 0.8}
    source: "10.png"
    im_num: 10
    allow_stretch: True
    on_im_num: app.change_monst_im(self)
    on_pos_hint: app.monst_pos_hint(self)

<Character@Image>:
    size_hint: (0.15, 0.15)

    pos_hint: {'x': 0.2, 'y': 0.6}
    source: "0.png"
    im_num: 0
    allow_stretch: True
    on_im_num: app.change_char_im(self)
    on_pos_hint: app.char_pos_hint(self)

Listing 6-21Complete KV File of the Game

Python 文件

清单 6-22 中的 Python 文件包含了播放音效的代码,就像我们在之前的单级游戏中所做的一样。

import kivy.app
import kivy.uix.screenmanager
import random
import kivy.core.audio
import os
import functools

class TestApp(kivy.app.App):

    def screen_on_pre_leave(self, screen_num):
        curr_screen = self.root.screens[screen_num]

        curr_screen.ids['monster_image_lvl'+str(screen_num)].pos_hint = {'x': 0.8, 'y': 0.8}
        curr_screen.ids['character_image_lvl'+str(screen_num)].pos_hint = {'x': 0.2, 'y': 0.6}

    def screen_on_pre_enter(self, screen_num):
        curr_screen = self.root.screens[screen_num]
        curr_screen.character_killed = False
        curr_screen.num_coins_collected = 0
        curr_screen.ids['character_image_lvl'+str(screen_num)].im_num = 0
        curr_screen.ids['monster_image_lvl'+str(screen_num)].im_num = 10
        curr_screen.ids['num_coins_collected_lvl'+str(screen_num)].text = "Coins 0"

        for key, coin in curr_screen.coins_ids.items():
            curr_screen.ids['layout_lvl'+str(screen_num)].remove_widget(coin)
        curr_screen.coins_ids = {}

        coin_width = 0.05
        coin_height = 0.05

        curr_screen = self.root.screens[screen_num]

        section_width = 1.0/curr_screen.num_coins
        for k in range(curr_screen.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            curr_screen.ids['layout_lvl'+str(screen_num)].add_widget(coin, index=-1)
            curr_screen.coins_ids['coin'+str(k)] = coin

    def screen_on_enter(self, screen_num):
        music_dir = os.getcwd()+"/music/"
        self.bg_music = kivy.core.audio.SoundLoader.load(music_dir+"bg_music_piano.wav")
        self.bg_music.loop = True

        self.coin_sound = kivy.core.audio.SoundLoader.load(music_dir+"coin.wav")
        self.level_completed_sound = kivy.core.audio.SoundLoader.load(music_dir+"level_completed_flaute.wav")
        self.char_death_sound = kivy.core.audio.SoundLoader.load(music_dir+"char_death_flaute.wav")

        self.bg_music.play()

        curr_screen = self.root.screens[screen_num]
        monster_image = curr_screen.ids['monster_image_lvl'+str(screen_num)]
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def start_monst_animation(self, monster_image, new_pos, anim_duration):
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17,duration=anim_duration)

        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def monst_animation_completed(self, *args):
        monster_image = args[1]
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self, monster_image):
        screen_num = int(monster_image.parent.parent.name[5:])
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y and curr_screen.character_killed == False:
            self.bg_music.stop()
            self.char_death_sound.play()
            curr_screen.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            kivy.animation.Animation.cancel_all(monster_image)

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95, duration=1.0)
            char_anim.start(character_image)
            kivy.clock.Clock.schedule_once(functools.partial(self.back_to_main_screen, curr_screen.parent), 3)

    def change_monst_im(self, monster_image):
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def touch_down_handler(self, screen_num, args):
        curr_screen = self.root.screens[screen_num]

        if curr_screen.character_killed == False:
            self.start_char_animation(screen_num, args[1].spos)

    def start_char_animation(self, screen_num, touch_pos):
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2,'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def char_animation_completed(self, *args):
        character_image = args[1]
        character_image.im_num = 0

    def char_pos_hint(self, character_image):
        screen_num = int(character_image.parent.parent.name[5:])
        character_center = character_image.center

        gab_x = character_image.width / 3
        gab_y = character_image.height / 3
        coins_to_delete = []
        curr_screen = self.root.screens[screen_num]

        for coin_key, curr_coin in curr_screen.coins_ids.items():
            curr_coin_center = curr_coin.center
            if character_image.collide_widget(curr_coin) and abs(character_center[0] - curr_coin_center[0]) <= gab_x and abs(character_center[1] - curr_coin_center[1]) <= gab_y:
                self.coin_sound.play()
                coins_to_delete.append(coin_key)
                curr_screen.ids['layout_lvl'+str(screen_num)].remove_widget(curr_coin)
                curr_screen.num_coins_collected = curr_screen.num_coins_collected + 1
                curr_screen.ids['num_coins_collected_lvl'+str(screen_num)].text = "Coins "+str(curr_screen.num_coins_collected)
                if curr_screen.num_coins_collected == curr_screen.num_coins:
                    self.bg_music.stop()
                    self.level_completed_sound.play()

                    kivy.animation.Animation.cancel_all(character_image)
                    kivy.clock.Clock.schedule_once(functools.partial(self.back_to_main_screen, curr_screen.parent), 3)
                    kivy.animation.Animation.cancel_all(curr_screen.ids['monster_image_lvl'+str(screen_num)])

        if len(coins_to_delete) > 0:
            for coin_key in coins_to_delete:
                del curr_screen.coins_ids[coin_key]

    def change_char_im(self, character_image):
        character_image.source = str(int(character_image.im_num)) + ".png"

    def back_to_main_screen(self, screenManager, *args):
        screenManager.current = "main"

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False

    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}

app = TestApp()
app.run()

Listing 6-22Complete Python File of the Game

添加更多关卡别

现在我们已经解决了所有的问题,我们已经成功地创建了一个两级游戏。我们可以通过以下步骤轻松地为游戏添加更多关卡:

  1. 为 Python 文件中的级别创建一个类,该类扩展了Screen类并初始化了前面的四个类变量。

  2. 创建一个自定义小部件,定义 KV 文件中的类的布局。记得更改添加到类中的三个小部件的 id(MonsterCharacterNumCoinsCollected),使屏幕索引位于末尾。

  3. 将自定义小部件的实例作为ScreenManager的子部件添加到应用小部件树中。

  4. 不要忘记在主屏幕内添加一个按钮,以便进入新的级别。

通过遵循这四个步骤,我们可以根据需要添加更多的级别。

添加更多的怪物

每个级别只能有一个怪物,这使得游戏很无聊,因为没有新的挑战。为了在代码改动最少的情况下增加更多的怪物,我们应该怎么做?让我想想。

如果我们想添加更多的怪物,每一个必须被唯一识别。这是因为每个怪物的位置必须与角色位置进行比较,以防发生碰撞。区分不同怪物的方法是在 KV 文件中给每个怪物一个不同的 ID。通过参考每个怪物的 ID,我们可以唯一地识别每个怪物。

monster ID 的一般形式如下:

monster<monst_index>_image_lvl<lvl_index>

其中monst_index指的是怪物索引lvl_index指的是它们各自开始的等级索引。例如,如果每个指数从 1 开始,第三级中第二个怪物的 ID 将是monster2_image_lvl3。三级的布局有两个怪物,如清单 6-23 所示。

<Level3>:
    name: "level3"
    on_pre_enter: app.screen_on_pre_enter(3)
    on_pre_leave: app.screen_on_pre_leave(3)
    on_enter: app.screen_on_enter(3)
    FloatLayout:
        id: layout_lvl3
        on_touch_down: app.touch_down_handler(3, args)
        canvas.before:
            Rectangle:
                pos: self.pos
                size: self.size
                source: "bg_lvl3.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl3
        Monster:
            id: monster1_image_lvl3
        Monster:
            id: monster2_image_lvl3
        Character:
            id: character_image_lvl3

Listing 6-23Defining the Layout for Level 3 in Which Two Monsters Exist

请注意,我们在以下每个回调函数的 Python 代码中使用了 monster ID 来指代 monster:

  • screen_on_pre_leave():重置怪物位置

  • screen_on_pre_enter():重置im_num属性

  • screen_on_enter():开始动画

  • monst_pos_hint():角色被杀死后,取消其动画

  • char_pos_hint():收集所有硬币完成关卡后,取消动画

当前指定 ID 的形式并不反映 monster 索引。因此,我们必须改变它。

例如在char_pos_hint()里面,怪物动画是按照这一行取消的:

kivy.animation.Animation.cancel_all(curr_screen.ids['monster_image_lvl' + str(screen_num)])

当有一个以上的怪物时,必须对所有的怪物重复上一行。我们可以使用一个for循环轻松地实现这一点,该循环遍历所有怪物,根据怪物索引和级别(屏幕)索引准备它们的 ID,然后使用这个 ID 作为ids字典的键。

为了创建一个for循环,我们需要定义关卡中怪物的数量。这就是我们添加一个新的类变量命名为num_monsters的原因。添加该变量后的 3 级类(Level3)的定义如清单 6-24 所示。

class Level3(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 12
    num_coins_collected = 0
    coins_ids = {}
    num_monsters = 2

Listing 6-24Adding the num_monsters Variable Inside the Class Header of Level 3

清单 6-25 显示了添加在char_pos_hint()中的for循环,以访问每个怪物并取消其动画。怪物索引由(i + 1)返回,因为循环变量i从 0 开始,而第一个怪物索引是 1。

for i in range(curr_screen.num_monsters): kivy.animation.Animation.cancel_all(curr_screen.ids['monster' + str(i + 1) + '_image_lvl' + str(screen_num)])

Listing 6-25Accessing the Monsters and Cancelling Their Animations

同样,我们可以编辑剩余的四个回调函数来访问每个怪物,然后对其应用所需的操作。

每关有多个怪物的游戏

清单 6-26 中列出了游戏的修改后的 Python 代码,该代码支持每一关使用多个怪物。请注意,num_monsters类变量必须添加到所有级别的类中。在前两个级别中,它被设置为 1。对于第三类,是 2。如果这个变量设置为 0 呢?

import kivy.app
import kivy.uix.screenmanager
import random
import kivy.core.audio
import os
import functools

class TestApp(kivy.app.App):

    def screen_on_pre_leave(self, screen_num):
        curr_screen = self.root.screens[screen_num]

        for i in range(curr_screen.num_monsters):
            curr_screen.ids['monster'+str(i+1)+'_image_lvl'+str(screen_num)].pos_hint = {'x': 0.8, 'y': 0.8}
        curr_screen.ids['character_image_lvl'+str(screen_num)].pos_hint = {'x': 0.2, 'y': 0.6}

    def screen_on_pre_enter(self, screen_num):
        curr_screen = self.root.screens[screen_num]
        curr_screen.character_killed = False
        curr_screen.num_coins_collected = 0
        curr_screen.ids['character_image_lvl'+str(screen_num)].im_num = 0
        for i in range(curr_screen.num_monsters):
            curr_screen.ids['monster'+str(i+1)+'_image_lvl'+str(screen_num)].im_num = 10
        curr_screen.ids['num_coins_collected_lvl'+str(screen_num)].text = "Coins 0"

        for key, coin in curr_screen.coins_ids.items():
            curr_screen.ids['layout_lvl'+str(screen_num)].remove_widget(coin)
        curr_screen.coins_ids = {}

        coin_width = 0.05
        coin_height = 0.05

        curr_screen = self.root.screens[screen_num]

        section_width = 1.0/curr_screen.num_coins
        for k in range(curr_screen.num_coins):
            x = random.uniform(section_width*k, section_width*(k+1)-coin_width)
            y = random.uniform(0, 1-coin_height)
            coin = kivy.uix.image.Image(source="coin.png", size_hint=(coin_width, coin_height), pos_hint={'x': x, 'y': y}, allow_stretch=True)
            curr_screen.ids['layout_lvl'+str(screen_num)].add_widget(coin, index=-1)
            curr_screen.coins_ids['coin'+str(k)] = coin

    def screen_on_enter(self, screen_num):
        music_dir = os.getcwd()+"/music/"
        self.bg_music = kivy.core.audio.SoundLoader.load(music_dir+"bg_music_piano.wav")
        self.bg_music.loop = True

        self.coin_sound = kivy.core.audio.SoundLoader.load(music_dir+"coin.wav")
        self.level_completed_sound = kivy.core.audio.SoundLoader.load(music_dir+"level_completed_flaute.wav")
        self.char_death_sound = kivy.core.audio.SoundLoader.load(music_dir+"char_death_flaute.wav")

        self.bg_music.play()

        curr_screen = self.root.screens[screen_num]
        for i in range(curr_screen.num_monsters):
            monster_image = curr_screen.ids['monster'+str(i+1)+'_image_lvl'+str(screen_num)]
            new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]/4), random.uniform(0.0, 1 - monster_image.size_hint[1]/4))
            self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.0, 3.0))
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(1.5, 3.5))

    def start_monst_animation(self, monster_image, new_pos, anim_duration):
        monst_anim = kivy.animation.Animation(pos_hint={'x': new_pos[0], 'y': new_pos[1]}, im_num=17,duration=anim_duration)

        monst_anim.bind(on_complete=self.monst_animation_completed)
        monst_anim.start(monster_image)

    def monst_animation_completed(self, *args):
        monster_image = args[1]
        monster_image.im_num = 10

        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0]), random.uniform(0.0, 1 - monster_image.size_hint[1]))
        self.start_monst_animation(monster_image=monster_image, new_pos= new_pos,anim_duration=random.uniform(1.5, 3.5))

    def monst_pos_hint(self, monster_image):
        screen_num = int(monster_image.parent.parent.name[5:])
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]

        character_center = character_image.center
        monster_center = monster_image.center

        gab_x = character_image.width / 2
        gab_y = character_image.height / 2
        if character_image.collide_widget(monster_image) and abs(character_center[0] - monster_center[0]) <= gab_x and abs(character_center[1] - monster_center[1]) <= gab_y and curr_screen.character_killed == False:
            self.bg_music.stop()
            self.char_death_sound.play()
            curr_screen.character_killed = True

            kivy.animation.Animation.cancel_all(character_image)
            for i in range(curr_screen.num_monsters):
                kivy.animation.Animation.cancel_all(curr_screen.ids['monster'+str(i+1)+'_image_lvl'+str(screen_num)])

            character_image.im_num = 91
            char_anim = kivy.animation.Animation(im_num=95, duration=1.0)
            char_anim.start(character_image)
            kivy.clock.Clock.schedule_once(functools.partial(self.back_to_main_screen, curr_screen.parent), 3)

    def change_monst_im(self, monster_image):
        monster_image.source = str(int(monster_image.im_num)) + ".png"

    def touch_down_handler(self, screen_num, args):
        curr_screen = self.root.screens[screen_num]
        if curr_screen.character_killed == False:
            self.start_char_animation(screen_num, args[1].spos)

    def start_char_animation(self, screen_num, touch_pos):
        curr_screen = self.root.screens[screen_num]
        character_image = curr_screen.ids['character_image_lvl'+str(screen_num)]
        character_image.im_num = 0
        char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2,'y': touch_pos[1] - character_image.size_hint[1] / 2}, im_num=7)
        char_anim.bind(on_complete=self.char_animation_completed)
        char_anim.start(character_image)

    def char_animation_completed(self, *args):
        character_image = args[1]
        character_image.im_num = 0

    def char_pos_hint(self, character_image):
        screen_num = int(character_image.parent.parent.name[5:])
        character_center = character_image.center

        gab_x = character_image.width / 3
        gab_y = character_image.height / 3
        coins_to_delete = []
        curr_screen = self.root.screens[screen_num]

        for coin_key, curr_coin in curr_screen.coins_ids.items():
            curr_coin_center = curr_coin.center
            if character_image.collide_widget(curr_coin) and abs(character_center[0] - curr_coin_center[0]) <= gab_x and abs(character_center[1] - curr_coin_center[1]) <= gab_y:
                self.coin_sound.play()
                coins_to_delete.append(coin_key)
                curr_screen.ids['layout_lvl'+str(screen_num)].remove_widget(curr_coin)
                curr_screen.num_coins_collected = curr_screen.num_coins_collected + 1
                curr_screen.ids['num_coins_collected_lvl'+str(screen_num)].text = "Coins "+str(curr_screen.num_coins_collected)
                if curr_screen.num_coins_collected == curr_screen.num_coins:
                    self.bg_music.stop()

                    self.level_completed_sound.play()
                    kivy.animation.Animation.cancel_all(character_image)
                    kivy.clock.Clock.schedule_once(functools.partial(self.back_to_main_screen, curr_screen.parent), 3)
                    for i in range(curr_screen.num_monsters):
                        kivy.animation.Animation.cancel_all(curr_screen.ids['monster' + str(i + 1) + '_image_lvl' + str(screen_num)])

        if len(coins_to_delete) > 0:
            for coin_key in coins_to_delete:
                del curr_screen.coins_ids[coin_key]

    def change_char_im(self, character_image):
        character_image.source = str(int(character_image.im_num)) + ".png"

    def back_to_main_screen(self, screenManager, *args):
        screenManager.current = "main"

class MainScreen(kivy.uix.screenmanager.Screen):
    pass

class Level1(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 5
    num_coins_collected = 0
    coins_ids = {}
    num_monsters = 1

class Level2(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 8
    num_coins_collected = 0
    coins_ids = {}
    num_monsters = 1

class Level3(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 12
    num_coins_collected = 0
    coins_ids = {}
    num_monsters = 2

app = TestApp()
app.run()

Listing 6-26Python File for Supporting More Than One Monster Within the Game

如果num_monsters变量设置为 0,则不会执行for循环,因为角色不会被激活,因此其位置不会改变。on_pos_hint事件不会被激发。因为碰撞检测发生在事件的回调函数中,所以怪物和角色之间不会发生碰撞。怪物将是可见的,因为它被添加到 KV 文件中。清单 6-27 显示了创建三个级别后修改后的 KV 文件。

ScreenManager:
    MainScreen:
    Level1:
    Level2:
    Level3:

<MainScreen>:
    name: "main"
    BoxLayout:
        Button:
            text: "Go to Level 1"
            on_press: app.root.current="level1"
        Button:
            text: "Go to Level 2"
            on_press: app.root.current="level2"
        Button:
            text: "Go to Level 3"
            on_press: app.root.current = "level3"

<Level1>:
    name: "level1"
    on_pre_enter: app.screen_on_pre_enter(1)

    on_pre_leave: app.screen_on_pre_leave(1)
    on_enter: app.screen_on_enter(1)
    FloatLayout:
        id: layout_lvl1
        on_touch_down: app.touch_down_handler(1, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl1.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl1
        Monster:
            id: monster_image_lvl1
        Character:
            id: character_image_lvl1

<Level2>:
    name: "level2"
    on_pre_enter: app.screen_on_pre_enter(2)
    on_pre_leave: app.screen_on_pre_leave(2)
    on_enter: app.screen_on_enter(2)
    FloatLayout:
        id: layout_lvl2
        on_touch_down: app.touch_down_handler(2, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl2.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl2

        Monster:
            id: monster_image_lvl2
        Character:
            id: character_image_lvl2

<Level3>:
    name: "level3"
    on_pre_enter: app.screen_on_pre_enter(3)
    on_pre_leave: app.screen_on_pre_leave(3)
    on_enter: app.screen_on_enter(3)
    FloatLayout:
        id: layout_lvl3
        on_touch_down: app.touch_down_handler(3, args)
        canvas.before:
            Rectangle:
                size: self.size
                pos: self.pos
                source: "bg_lvl3.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl3
        Monster:
            id: monster1_image_lvl3
        Monster:
            id: monster2_image_lvl3
        Character:
            id: character_image_lvl3

<NumCollectedCoins@Label>:
    size_hint: (0.1, 0.02)
    pos_hint: {'x': 0.0, 'y': 0.97}
    text: "Coins 0"
    font_size: 20

<Monster@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.8, 'y': 0.8}
    source: "10.png"
    im_num: 10
    allow_stretch: True
    on_im_num: app.change_monst_im(self)
    on_pos_hint: app.monst_pos_hint(self)

<Character@Image>:
    size_hint: (0.15, 0.15)
    pos_hint: {'x': 0.2, 'y': 0.6}

    source: "0.png"
    im_num: 0
    allow_stretch: True
    on_im_num: app.change_char_im(self)
    on_pos_hint: app.char_pos_hint(self)

Listing 6-27Adding Three Levels Inside the KV File

关于小部件属性的提示

每个级别中的每个小部件都有许多属性,您应该决定在哪里定义它们。以下是一些提示:

  • 类中所有小部件共享的属性应该添加到 Python 文件的类头中。

  • 在特定小部件的所有实例之间共享但在其他类型的小部件之间不共享的属性应该添加到 KV 文件中的自定义小部件的定义中。

  • 从一个小部件的一个实例到同一个小部件的另一个实例发生变化的属性应该添加到 KV 文件中自定义小部件实例的定义中。

这些规则有助于避免重复代码的某些部分。例如,假设我们想要定义一个属性,该属性定义所有级别中所有怪物的动画持续时间。因为该属性在所有级别的所有怪物中都是静态的,所以我们可以在应用类中创建一个变量来完成这项工作。如果变量被添加到级别Screen类中,相同的变量将在每个类中重复,这不是必需的。

如果可以为不同等级的怪物分配不同的动画持续时间,那么这个变量不能在应用类中定义,最好将其添加到Screen类头中。

如果怪物持续时间动画要为每个怪物改变,即使是在同一级别,我们需要为每个Monster小部件的实例分配一个属性。

下一节将改变角色和怪物动画来应用这种理解。

更改动画持续时间

在之前的游戏中,角色和所有怪物的动画持续时间在所有关卡中都是固定的。持续时间可以改变,以使游戏更具挑战性。例如,如果怪物移动得更快,玩家必须快速做出决定。为了改变持续时间,我们必须问自己定义持续时间的属性应该在哪里指定。

关于角色,我们想改变它在每一关的持续时间。因为它在级别中只存在一次,所以应该在 Python 级别类中指定字符持续时间。清单 6-28 显示了添加名为char_anim_duration的属性后的Level3类头。

class Level3(kivy.uix.screenmanager.Screen):
    character_killed = False
    num_coins = 12
    num_coins_collected = 0
    coins_ids = {}
    char_anim_duration = 1.2
    num_monsters = 2

Listing 6-28Adding the char_anim_duration Property to the Class of Level 3

关于怪物,我们也想改变他们在每个级别的持续时间。但是因为每个级别有不止一个怪物,我们不能在类头中指定它们的持续时间,而是在 KV 小部件实例中指定。清单 6-29 指定了 KV 文件内三级两个怪物的持续时间。因为怪物动画是通过设置最小和最大可能值随机生成的,所以属性monst_anim_duration_lowmonst_anim_duration_high设置持续时间的最小和最大值。

<Level3>:
    name: "level3"
    on_pre_enter: app.screen_on_pre_enter(3)
    on_pre_leave: app.screen_on_pre_leave(3)
    on_enter: app.screen_on_enter(3)
    FloatLayout:
        id: layout_lvl3
        on_touch_down: app.touch_down_handler(3, args)
        canvas.before:
            Rectangle:
                pos: self.pos
                size: self.size
                source: "levels-bg/bg_lvl3.jpg"
        NumCollectedCoins:
            id: num_coins_collected_lvl3
        Monster:
            id: monster1_image_lvl3
            monst_anim_duration_low: 1.0
            monst_anim_duration_high: 1.6
        Monster:
            id: monster2_image_lvl3
            monst_anim_duration_low: 1.0
            monst_anim_duration_high: 2.0
        Character:
            id: character_image_lvl3

Listing 6-29Specifying the Monster Duration Within the KV File

在设置了两者的持续时间之后,我们需要在 Python 代码中引用它们。角色动画在start_char_animation()回调函数中被引用一次。清单 6-30 显示了修改后的函数,它引用了类中定义的char_anim_duration变量。

def start_char_animation(self, screen_num, touch_pos):
    curr_screen = self.root.screens[screen_num]
    character_image = curr_screen.ids['character_image_lvl' + str(screen_num)]
    character_image.im_num = character_image.start_im_num
    char_anim = kivy.animation.Animation(pos_hint={'x': touch_pos[0] - character_image.size_hint[0] / 2, 'y': touch_pos[1] - character_image.size_hint[1] / 2},im_num=character_image.end_im_num, duration=curr_screen.char_anim_duration)
    char_anim.bind(on_complete=self.char_animation_completed)
    char_anim.start(character_image)

Listing 6-30Referencing the char_anim_duration Variable to Return the Duration of the Monster Character

关于怪物动画,引用两次。第一个引用在screen_on_enter()回调函数中,以便在屏幕启动时立即启动动画。在被修改为使用Monster小部件中定义的monst_anim_duration_lowmonst_anim_duration_high后,它被列在清单 6-31 中。

def screen_on_enter(self, screen_num):
    music_dir = os.getcwd() + "/music/"
    self.bg_music = kivy.core.audio.SoundLoader.load(music_dir + "bg_music_piano.wav")
    self.bg_music.loop = True

    self.coin_sound = kivy.core.audio.SoundLoader.load(music_dir + "coin.wav")
    self.level_completed_sound = kivy.core.audio.SoundLoader.load(music_dir + "level_completed_flaute.wav")
    self.char_death_sound = kivy.core.audio.SoundLoader.load(music_dir + "char_death_flaute.wav")

    self.bg_music.play()

    curr_screen = self.root.screens[screen_num]
    for i in range(curr_screen.num_monsters):
        monster_image = curr_screen.ids['monster' + str(i + 1) + '_image_lvl' + str(screen_num)]
        new_pos = (random.uniform(0.0, 1 - monster_image.size_hint[0] / 4),
                   random.uniform(0.0, 1 - monster_image.size_hint[1] / 4))
        self.start_monst_animation(monster_image=monster_image, new_pos=new_pos, anim_duration=random.uniform(monster_image.monst_anim_duration_low, monster_image.monst_anim_duration_high))

    for i in range(curr_screen.num_fires):
        fire_widget = curr_screen.ids['fire' + str(i + 1) + '_lvl' + str(screen_num)]
        self.start_fire_animation(fire_widget=fire_widget, pos=(0.0, 0.5), anim_duration=5.0)

Listing 6-31Setting the Monster Animation Duration Inside the screen_on_enter() Callback Function

怪物动画第二次被引用是在名为monst_animation_completed()的回调函数内部,该函数在清单 6-32 中列出。它会在动画完成后重复播放。

def monst_animation_completed(self, *args):
    monster_image = args[1]
    monster_image.im_num = monster_image.start_im_num

    new_pos = (
    random.uniform(0.0, 1 - monster_image.size_hint[0] / 4), random.uniform(0.0, 1 - monster_image.size_hint[1] / 4))
    self.start_monst_animation(monster_image=monster_image, new_pos=new_pos,
                               anim_duration=random.uniform(monster_image.monst_anim_duration_low, monster_image.monst_anim_duration_high))

Listing 6-32Setting the Monster Animation Duration Inside the monst_animation_completed() Callback Function

更多事情要做

请注意,您可以对游戏做更多的事情来使它变得更有趣。这是由你的想象力提出新的想法,使游戏更有趣。以之前游戏的这些变化为例:

  • NumCollectedCoins标签除了显示每关的硬币总数外,还可以显示收集到的硬币数量。这有助于玩家知道还有多少硬币需要收集。

  • 可以添加新标签来显示级别编号。

  • 除了怪物之外,还可以投掷火焰来杀死角色。

  • 我们可以等待两到三次碰撞,而不是在第一次碰撞中杀死角色。

  • 角色也可以用火杀死怪物。

  • 与其创建使用相同图像的新怪物,不如添加一个新的Monster小部件,使用不同的图像。可以添加新的属性start_im_numend_im_num来指定属性im_num的第一个和最后一个值。属性被添加到自定义小部件中,而不是它们的实例中,因为它们在所有怪物和角色中都是相同的。这使得创造新的怪物更加容易。

  • 限制角色每次触摸可以移动的空间。目前没有限制,但是我们可以限制角色每次触摸移动屏幕宽度和高度的四分之一。

  • 将怪物动画的随机范围pos_hint更改为靠近角色的当前位置。

  • 要求玩家在限定时间内收集一定数量的硬币。

  • 添加奖励等级。

  • 当玩家在一次触摸中收集到五个硬币时,为玩家创建一个奖金。

  • 仅当通过将完成的最高级别的编号保存在文件中来完成级别 i-1 时,才打开编号为 I 的级别。这个数字在每个级别完成后更新,并在应用启动时恢复。

  • 根据碰撞次数评定完成的等级。例如,无碰撞两次启动,10 次碰撞两次启动,10 次以上碰撞三次启动。

根据附录中显示的 KV 和 Python 文件,其中一些更改将应用到游戏中。

以前,一个按钮指的是主屏幕内的每个级别。这个按钮被一个名为ImageButton的新定制小部件取代,它是ButtonImage小部件的混合体。这些新的小部件具有 source 属性,可以添加图像,还可以处理on_presson_release事件。

参考级别的ImageButton小部件被添加到GridLayout中。这将小部件排列在一个网格中,而不是一个盒子中,这有助于向屏幕添加更多的小部件。在GridLayout中添加背景图像,如图 6-14 所示。

在主屏幕的顶部添加了一个新的按钮,它将应用导航到一个新的屏幕,给出的类名为AboutUs,打印关于开发人员的详细信息。记住将这个新屏幕作为子屏幕添加到ScreenManager中,以免影响关卡的索引。

img/481739_1_En_6_Fig14_HTML.jpg

图 6-14

向主屏幕添加背景图像

一个名为Fire的新部件扩展了Label部件,用来表示杀死玩家的投掷火焰。使用canvas.before,使用矩形顶点指令添加一个火焰图像背景。在小部件的实例中,添加了两个名为fire_start_pos_hintfire_end_pos_hint的属性。它们指明了火势蔓延的路径。因为在同一个关卡中可能有不止一个Fire小部件,所以它们被赋予了类似于怪物被赋予 id 的 id。例如,第三层中的第一个和第二个Fire小部件分别具有fire1_lvl3fire2_lvl3id。

类似于num_monsters类变量,有一个名为num_fires的变量保存级别中Fire小部件的数量。当一个操作被应用到Fires上时,一个循环遍历所有的操作。

处理新的Fire小部件的位置和碰撞的方式与Monster小部件非常相似。on_pos_hint事件被附加到Fire小部件,该小部件使用回调函数fire_pos_hint()来处理。在这个函数中,火的位置与怪物的位置进行比较。当碰撞发生时,most_pos_hint()函数中的相同内容会重复。

20 级的屏幕如图 6-15 所示,有八个Fire小部件向不同方向移动。

img/481739_1_En_6_Fig15_HTML.jpg

图 6-15

20 级的屏幕,有八个火的部件

根据图 6-15 ,在NumCollectedCoins旁边增加一个标签,显示当前级别号。这个小部件在 KV 文件中被命名为LevelNumber

在新游戏中,角色不再在第一次碰撞中被杀死。在每个级别的num_collision_level类变量中指定了最大碰撞次数。另一个名为num_collisions_hit的类变量从 0 开始,并在每次发生冲突时递增。当num_collision_levelnum_collisions_hit中的数值相等时,角色就会死亡。

关卡编号旁边还有一个红色的横条,代表角色剩余的碰撞次数。这个条是使用名为RemainingLifePercent的自定义小部件中的矩形和颜色顶点指令创建的,它扩展了Label小部件。对于每次碰撞,负责碰撞检测的回调函数(即monst_pos_hint()fire_pos_hint())中的小部件的大小都会减小。

一个名为Monster2的新怪物小部件被创建,它的行为几乎与Monster小部件相同。不同之处在于它为im_num属性使用了新的值。因此,新属性start_im_numend_im_num接受图像编号的第一个和最后一个数字。在 Python 代码中,这些属性中的值用于动画中。根据图 6-16 显示的屏幕,第 16 关使用了Monster2的两个实例。

img/481739_1_En_6_Fig16_HTML.jpg

图 6-16

16 级中使用的两种不同的怪物

这两个属性也在Character小部件中使用,使它们都以同样的方式运行。角色还有dead_start_im_numdead_end_im_num属性,指的是角色被杀死时显示的图像的编号。

提供这些文件(imagesaudio)的正确路径很重要。新游戏将图像组织到以下三个文件夹中:

img/481739_1_En_6_Fig17_HTML.jpg

图 6-17

关于我们屏幕

  • levels-bg:保存图像名称为bg_lvl<num>.jpg after replacing <num>的各层背景图像,图像编号为层号。请记住,级别编号从 1 开始。

  • levels-images:保存主屏幕上显示的ImageButton控件使用的背景图像,图像名称为<num>.png,将<num>替换为等级号。

  • other-images:保存ImageButton小工具上显示的图像,参照AboutUs屏幕,背景图像名为About-Us.png。在那个屏幕里面,有另一个ImageButton,指的是带有名为Main-Screen.png的背景图像的主屏幕。“关于我们”屏幕如图 6-17 所示。

所有音频文件都在music文件夹中,文件名如下:

  • bg_music_piano_flute.wav:主屏幕的背景音乐。

  • bg_music_piano.wav:每一关的背景音乐。

  • char_death_flaute.wav:在角色被杀死后播放。

  • 收集硬币时玩。

  • level_completed_flaute.wav:完成一关后玩。

您可以使用 https://onlinesequencer.net 免费创建背景序列。

只有当第一关完成后,游戏才会开启第一关。这个想法是启用主屏幕中所有已完成级别的所有ImageButton小部件。这是对启用新级别的ImageButton的补充。这就需要知道游戏开始时玩家完成的最后一关。为了做到这一点,游戏使用pickle库将玩家的进度保存在一个文件中。为此,创建了两个新方法— read_game_info()activate_levels()

read_game_info()方法读取一个名为game_info的文件,该文件存储了一个包含两个条目的字典。第一个项目有一个钥匙lastlvl,代表玩家完成的最后一关。第二项有一个键congrats_displayed_once。该物品有助于玩家在完成所有关卡后显示一次祝贺信息。默认情况下,该项的值为False,表示消息尚未显示。当完成所有关卡并显示祝贺信息时,它变为True

on_start()方法内部,调用read_game_info()方法,并使用pickle.load()函数返回存储在字典中的内容(即字典)。字典值被返回,然后作为参数传递给第二个方法activate_levels()。这个方法遍历所有的ImageButton窗口小部件,并根据最后一关完成的次数激活它们。

注意,game_info文件在使用pickle.dump()函数完成每一关后,在char_pos_hint()回调函数中被更新。

在 Google Play 发布游戏

在测试游戏并确保它如预期那样运行之后,我们可以在 Google Play 上发布它,供用户下载和玩。用于发布 CamShare 应用的相同步骤将在本游戏中重复。

在完成 APK 签名过程之后,使用下面的命令创建一个发布 APK。记得将项目的最小 API 设置为至少 26。

ahmedgad@ubuntu:~/Desktop$ buildozer android release

如果游戏的标题为CoinTex,将会在 Google Play 上提供,如图 6-18 。

img/481739_1_En_6_Fig18_HTML.jpg

图 6-18

这款游戏的安卓版可以在 Google Play 上找到

完整游戏

附录中提供了 CoinTex 的完整源代码。

摘要

总之,这一章通过增加更多的关卡继续发展我们在第五章开始的游戏。游戏界面是用屏幕组织的。增加了更多的怪物。在开发游戏时,我们报道了出现的问题并讨论了它们的解决方案。我们在 Google Play 上以 CoinTex 的名字发布了这款适用于 Android 设备的游戏,供任何用户下载和安装。通过完成这个游戏,你将对 Kivy 的许多特性有一个坚实的理解。

Python 是开发人员构建 Android 应用的好方法,但它不能构建像使用 Java Android Studio 开发的丰富应用,Java Android Studio 是构建 Android 应用的官方 IDE。下一章将介绍如何使用 Android Studio 来丰富用 Kivy 创建的 Python 应用。如果 Python 不支持某个特性,不要担心,因为它可以在 Android Studio 中添加。

七、使用 Android Studio 中的 Kivy 项目

当 Python 开发者知道可以使用 Python 创建 Android 应用时,首先要问的问题之一是 Python 是否可以像在 Android Studio 中使用原生 Android 语言(即 Java)开发的那样开发丰富的 Android 应用。不幸的是,与 Java 相比,Python 在构建 Android(移动)应用时能力有限。但好消息是有办法丰富用 Python 创建的 Android 应用。

Kivy 团队开发了一些 Python 库,允许开发人员以比在 Android Studio 中更简单的方式访问 Android 功能。只需几行 Python 代码,我们就可以访问一个 Android 特性。其中一个库是 Plyer,这一章将会讨论。只需一行代码,就可以将通知推送到 Android 通知栏。

因为 Plyer 还在开发中,它的一些特性还没有实现。另一种解决方案是使用一个名为 Pyjnius 的库在 Python 中反映 Java 类。这涉及到在 Python 文件中编写 Java 代码。这有助于我们访问一些 Android 功能,但仍有一些未解决的问题。在 Python 中反映 Java 类会增加开发应用的复杂性。此外,在反射一些 Java 类时会出现一些异常。因此,使用 Plyer 和 Pyjnius 不足以访问所有 Android 功能。

记住,正如在第一章中提到的,当我们使用 Buildozer 构建 Kivy 应用时,会创建一个 Android Studio 项目。这个项目可以很容易地导入到 Android Studio 中,然后我们可以在那里继续开发应用。这使我们能够构建我们想要的任何特性。

在 Kivy 中,打包一些 Python 库有问题,比如 OpenCV。如果我们不能从使用 OpenCV 的 Kivy 应用构建 Android 应用,我们可以避免在 Python 中使用 OpenCV,然后在 Android Studio 中使用它。这种解决方法将在本章末尾进行说明。

普洛伊

在前几章创建的应用中,我们使用了 Kivy 支持的两个主要 Android 功能——访问摄像头和播放音频。一切都是用 Python 完成的。但 Android 中还有其他功能可能会让生活变得更容易,我们无法在 Kivy 中访问这些功能。我们可以在硬币游戏中使用陀螺仪来移动主角。对于 CamShare,当客户端和服务器之间的连接丢失时,我们可能会将通知推送到通知栏。但是我们如何用 Kivy(也就是用 Python)来实现呢?不幸的是,只有 Kivy 无法访问这些功能。

Kivy 团队创建了一个名为 Pyjnius 的库来访问 Python 中的 Java 类。因此,如果不能从 Python 访问某个特性,可以从 Java 访问它。注意,Java 代码是在 Python 文件中编写的,因此代码不是 Python 化的。

为了解决这个问题,该团队创建了一个名为Plyer的库来访问 Python 代码中的 Android 功能。它还不发达,一些功能在当前版本(1.3.3.dev0)中不可用,如摄像头、音频和 Wi-Fi。这个界面非常容易学习,并且它用最少的 Python 代码就可以访问这些特性。在 https://plyer.readthedocs.io/en/latest 可获得供应商文件。

在本节中,我们将讨论一个简单的例子,在这个例子中,使用 Plyer 将通知推送到通知栏并改变方向。在前几章创建的应用中,我们在构建 Android 应用之前,在台式计算机上对它们进行了测试。在本章中,我们不能在构建 Android 应用之前在桌面上测试应用,因为所使用的库(例如,Plyer)正在 Android 设备上运行。如果运行 Android 应用时出现异常,最好使用 logcat 来监控。

改变方向和推送通知

使用plyer.notification,我们可以将通知推送到 Android 设备的通知栏。这个模块有一个名为notify的函数,它接受许多参数来初始化通知。

为了改变器件方向,plyer.orientation存在。它有以下三个功能:

  • set_landscape(reverse=False):如果反转参数为True,则将方向设置为横向。

  • set_portrait(reverse=False):如果反转参数为True,则将方向设置为纵向。

  • set_sensor(mode='any|landscape|portrait'):根据模式参数中指定的值设置方向。

清单 7-1 显示了带有三个按钮部件的应用的 KV 文件。第一个执行show_notification()函数,将通知推送到通知栏。

当按下第二个按钮时,方向变为横向。注意plyer.orientation是在 KV 文件中导入的。在 KV 文件中调用set_landscape()函数。第三个按钮的工作方式与第二个类似,但是调用set_portrait()功能将方向改为纵向。

#:import orientation plyer.orientation

BoxLayout:
    orientation: "vertical"
    Button:
        text: "Show Notification"
        on_press: app.show_notification()
    Button:
        text: "Portrait"
        on_press: orientation.set_portrait(reverse=True)
    Button:
        text: "Landscape"
        on_press: orientation.set_landscape(reverse=True)

Listing 7-1KV File with Buttons for Pushing a Notification Message and Changing the Orientation of the Screen

Python 文件如清单 7-2 所示。show_notification()函数只有一行代码调用notify()函数。标题和消息参数出现在通知栏上。

import kivy.app

import plyer

class PushNotificationApp(kivy.app.App):

    def show_notification(self):
        plyer.notification.notify(title='Test', message='Notification using Plyer')

app = PycamApp()
app.run()

Listing 7-2Pushing a Notification Message to the Android Notification Bar

在构建 Android 应用之前,我们通常在台式计算机上运行应用进行测试。因为 Plyer 是安卓包,所以不能在台式电脑上测试,必须直接在安卓设备上运行。为了使调试过程更容易,我们使用logcat来跟踪错误堆栈。请记住,在构建、部署和运行 APK 文件时,可以使用以下命令激活logcat:

ahmedgad@ubuntu:~/Desktop$ buildozer android debug deploy run logcat

在 Android 设备上部署并运行应用,然后按下第一个按钮,我们将看到如图 7-1 所示的通知。应用图标用作通知的图标。这是推送通知所需的最少代码。

img/481739_1_En_7_Fig1_HTML.jpg

图 7-1

使用 Plyer 在 Python 中创建的 Android 通知消息

控制 Android 手电筒

使用plyer.flash可以在 Plyer 中访问 Android 手电筒。清单 7-3 中的 KV 文件创建了三个按钮部件,用于打开、关闭和释放闪光灯。下面列出了用于执行此类工作的函数:

  • plyer.flash.on():打开闪光灯

  • plyer.flash.off():关闭闪光灯

  • plyer.flash.release():释放闪光灯

#:import flash plyer.flash

BoxLayout:
    orientation: "vertical"
    Button:
        text: "Turn On"
        on_press: flash.on()
    Button:
        text: "Turn Off"
        on_press: flash.off()

    Button:
        text: "Release"
        on_press: flash.release()

Listing 7-3Controlling the Android Flashlight Using Plyer in Python

清单 7-4 中的 Python 文件除了创建一个扩展了kivy.app.App类的新类之外什么也没做。

import kivy.app
import plyer

class PycamApp(kivy.app.App):
    pass

app = PycamApp()
app.run()

Listing 7-4Generic Python File for Developing a Kivy Application

运行应用后,我们将看到如图 7-2 所示的窗口。记住将CAMERAFLASHLIGHT作为项目列在buildozer.spec文件的android.permissions属性中。

您可以扩展这个应用来创建在指定时间内打开和关闭手电筒的闪光模式。

img/481739_1_En_7_Fig2_HTML.jpg

图 7-2

一个控制手电筒的 Android 应用

前面的例子展示了 Plyer 库在 Python 代码中访问 Android 特性是多么简单。不幸的是,Plyer 被限制在一些功能上,不能做一个 Android 开发者想做的所有事情。有一些不受支持的功能,如显示祝酒辞,还有一些功能在某些平台上没有实现,如访问摄像头和播放音频。我们可以使用 Pyjnius 库通过 Java 代码访问这些缺失的特性。

皮涅乌斯

Pyjnius 是 Kivy 团队开发的一个库,用于访问 Python 代码中的 Java 类,以便在 Python Kivy 项目中使用当前不支持的 Android 功能。该库有一个名为autoclass()的核心函数,它接受 Java 类名并返回一个代表该 Java 类的变量。这个过程叫做反射

清单 7-5 中显示了一个非常简单的例子。它使用java.lang.System类中的println()方法打印一条消息。第一条语句导入 Pyjnius 库。然后,通过将 Java 类的名称作为输入传递给autoclass()函数,Java 类在 Python 中得到反映。本例中返回的名为System的变量代表该类,但是用 Python 编写。我们可以像在 Java 中一样访问这个类中的方法。

import jnius
System = jnius.autoclass("java.lang.System")
System.out.println("Hello Java within Python")

Listing 7-5Printing a Message in Python Using the System Class in Java

另一个简单的例子是读取文本文件的内容。在读取文件之前,我们必须在 Python 中反映所有必需的 Java 类。建议我们先准备好 Java 文件,然后再转换成 Python。读取文本文件所需的 Java 代码如清单 7-6 所示。

import java.io.BufferedReader
import java.io.FileReader

FileReader textFile = new FileReader("pycam.kv");
BufferedReader br = new BufferedReader(textFile);

StringBuilder sb = new StringBuilder();
String line = br.readLine();

while (line != null) {
    System.out.println(line);
    line = br.readLine();
}

Listing 7-6Reading a Text File in Java

清单 7-6 中的 Java 代码使用了三个类。第一个是用于读取文件的java.io.FileReader类。为了读取读取文件中的行,使用了java.io.BufferedReader类。最后,我们可以使用java.lang.System类在控制台上打印每一行。注意,java.lang包已经被导入到任何 Java 类中,因此我们不需要在 Java 代码中为它添加一个import语句。准备好 Java 代码后,我们需要使用 Pyjnius 将其嵌入到 Python 中。

清单 7-6 中的前三个类在 Python 中有所体现,如清单 7-7 所示,使用了jnius.autoclass()函数。然后文件名作为输入传递给FileReader类的构造函数,结果返回给textFile变量。在本例中,读取了之前在清单 7-3 中创建的 KV 文件,用于在 Android 中控制手电筒。file 对象在textFile变量中返回,该变量作为输入传递给BufferedReader类的构造函数。结果在bufferedReader变量中返回。

import jnius

FileReader = jnius.autoclass("java.io.FileReader")
BufferedReader = jnius.autoclass("java.io.BufferedReader")
System = jnius.autoclass("java.lang.System")

textFile = FileReader("pycam.kv")

bufferedReader = BufferedReader(text_file)
line = bufferedReader.readLine()

while line != None:
    System.out.println(line)
    line = bufferedReader.readLine()

Listing 7-7Reading a Text File Using Java Classes Reflected in Python Using Pyjnius

为了从文件中读取一行,我们调用了readLine() Java 方法。为了读取文件中的所有行,只要返回的行不是None,就会创建一个while循环来打印这些行。注意None是指 Java 中的null。打印到控制台的线条如图 7-3 所示。

img/481739_1_En_7_Fig3_HTML.jpg

图 7-3

使用 Java 类在 Python 中读取文本文件的结果

在理解了 Pyjnius 库背后的基本概念之后,我们可以使用它来访问 Android 中的特性。

使用 Pyjnius 在 Android 中播放音频

我们可以从讨论 Android 中用来播放音频的 Java 代码开始,思考如何用 Pyjnius 用 Python 来写。代码如清单 7-8 所示,其中在android.media包中使用了一个名为MediaPlayer的类。实例化该类并将实例返回到mediaPlayer变量后,我们可以调用所需的方法来加载和播放音频文件。

setDataSource()方法接受要播放的音频文件的文件名。prepare()方法准备媒体播放器。最后,start()方法开始播放音频文件。为了捕捉在播放文件出现问题时抛出的异常,对这些方法的调用被一个try-catch块所限制。如果有异常,使用System类将消息打印到控制台。

import android.media.MediaPlayer;

MediaPlayer mediaPlayer = new MediaPlayer();

try {
    fileName = "bg_music_piano.wav";
    mediaPlayer.setDataSource(fileName);
    mediaPlayer.prepare();
    mediaPlayer.start();
} catch {
    System.out.println("Error playing the audio file.");
}

Listing 7-8Java Code to Play an Audio File in Android

完成 Java 代码后,我们接下来需要讨论在 Python 中反映这些代码的过程。

这个类可以用之前讨论过的autoclass()函数在 Python 中反映出来。如果从那个反射类创建的对象被命名为mediaPlayer,方法将被调用,就像在 Java 代码中那样。

关于 Python 中异常的处理,有两点值得一提。第一个是 Python 中的块是用缩进定义的。第二个是 Java 中的异常是在catch块中处理的。在 Python 中,块名是except。在 Python 和 Java 之间进行这样的映射是很重要的。使用 Pyjnius 播放音频文件的 Python 代码如清单 7-9 所示。

import jnius

MediaPlayer = jnius.autoclass("android.media.MediaPlayer")

mp = MediaPlayer()

try:
    fileName = "bg_music_piano.wav"
    mp.setDataSource(fileName)
    mp.prepare()
    mp.start()
except:
    print("Error Playing the Audio File")

Listing 7-9Playing an Audio File in Python by Reflecting Java Classes Using Pyjnius

基于清单 7-9 中的代码,我们可以创建一个播放、暂停和停止音频的应用。应用的 KV 文件如清单 7-10 所示。有一个BoxLayout有五个小部件。前两个小部件是Labels,后三个是Buttons

第一个Label根据播放音频文件的进度更新。这是通过使用Rectangle顶点指令在canvas.before内绘制一个矩形来完成的,根据Color指令,矩形被涂成红色。这个Label小部件的默认大小是 0.0,这意味着当应用启动时它是隐藏的。大小根据播放进度而变化。

第二个Label小部件在播放文件时显示信息,以毫秒表示文件的持续时间,以毫秒表示文件正在播放的位置,以及从 0.0%到 100.00%的进度百分比。

BoxLayout:
    orientation: "vertical"
    Label:
        id: audio_pos
        size_hint_x: 0.0
        size: (0.0, 0.0)
        canvas.before:
            Color:
                rgb: (1, 0, 0)
            Rectangle:
                pos: self.pos
                size: self.size
    Label:
        id: audio_pos_info
        text: "Audio Position Info"
    Button:
        text: "Play"
        on_release: app.start_audio()
    Button:
        text: "Pause"
        on_release: app.pause_audio()
    Button:
        text: "Stop"
        on_release: app.stop_audio()

Listing 7-10KV File for Playing an Audio File in Android

这三个按钮负责启动、暂停和停止音频。当on_release事件被触发时,回调函数start_audio()pause_audio()stop_audio()被调用。

图 7-4 显示了应用在 Android 设备上运行后的窗口。

img/481739_1_En_7_Fig4_HTML.jpg

图 7-4

一个播放音频文件的 Android 应用

Python 文件如清单 7-11 所示。它实现了与三个Button小部件相关的回调函数。start_audio()函数完成前面讨论的工作,从反射MediaPlayer类直到使用start()方法播放音频文件。注意,MediaPlayer的实例被设置为使用self关键字引用的当前对象中的一个属性,以便在定义它的函数之外访问它。可以通过self.mediaPlayer访问。

创建一个名为prepare_audio的类变量来确定音频文件之前是否被加载。它被初始化为False表示文件没有被加载。如果它的值是False,则start_audio()函数加载并启动该文件。启动后,其值被设置为True

如果音频文件只是暂停,我们不必再次重新加载文件。因此,该文件将通过调用if语句的else部分中的start()方法来启动。这将从暂停前的位置继续播放音频文件。如果暂停后准备好文件,文件将从头开始播放。

请注意,如果播放音频文件出现问题,except块会更改第二个Label的文本以指示错误。

import kivy.app
import jnius
import os
import kivy.clock

class AudioApp(kivy.app.App):
    prepare_audio = False

    def start_audio(self):
        if AudioApp.prepare_audio == False:
            MediaPlayer = jnius.autoclass("android.media.MediaPlayer")
            self.mediaPlayer = MediaPlayer()
            try:
                fileName = os.getcwd()+"/bg_music_piano.wav"
                self.mediaPlayer.setDataSource(fileName)
                self.mediaPlayer.prepare()

                kivy.clock.Clock.schedule_interval(self.update_position, 0.1)

                self.mediaPlayer.start()
                AudioApp.prepare_audio = True
            except:
                self.current_pos.text = "Error Playing the Audio File"
                print("Error Playing the Audio File")
        else:
                self.mediaPlayer.start()

    def pause_audio(self):
        if AudioApp.prepare_audio == True:
            self.mediaPlayer.pause()

    def stop_audio(self):
        if AudioApp.prepare_audio == True:
            self.mediaPlayer.stop()
            AudioApp.prepare_audio = False

    def update_position(self, *args):
        audioDuration = self.mediaPlayer.getDuration()
        currentPosition = self.mediaPlayer.getCurrentPosition()
        pos_percent = float(currentPosition)/float(audioDuration)

        self.root.ids['audio_pos'].size_hint_x = pos_percent

        self.root.ids['audio_pos_info'].text = "Duration: "+str(audioDuration) + "\nPosition: " + str(currentPosition)+"\nPercent (%): "+str(round(pos_percent*100, 2))

app = AudioApp()
app.run()

Listing 7-11Kivy Application for Playing an Audio File in Android Based on Reflected Java Classes Using Pyjnius

使用kivy.clock.Clock类中的schedule_interval()函数,每 0.1 秒执行一次名为update_position()的回调函数。这将根据音频文件中播放的当前毫秒数更新 KV 文件中的两个Label小部件。

首先,使用audioDuration变量中的getDuration()方法返回音频文件持续时间。使用currentPosition变量中的getCurrentPosition()方法返回当前位置,以毫秒为单位。进度百分比的计算方法是用audioDuration除以currentPosition。因为这些变量中的值是整数,并且预期结果在 0.0 和 1.0 之间,也就是 float,所以我们必须使用float()函数将它们的数据类型从 integer 改为 float。文件持续时间、当前位置和当前百分比显示在 KV 文件内的第二个Label小部件上。

注意,显示播放音频文件的进度类似于更新 CoinTex 游戏的角色的生命百分比。在讨论了处理第一个按钮的on_release事件的函数之后,我们可以讨论其余两个按钮的函数。

第二个Button小部件使用pause()方法暂停播放的音频文件。根据它的回调函数pause_audio(),一个if语句确保文件在执行这个方法之前被播放,因为它必须为一个活动的音频文件被调用。

最后一个按钮通过调用stop()方法来停止音频。类似于pause_audio()回调函数,stop_audio()函数有一个if语句,确保音频文件在停止播放之前正在播放。调用此方法后,只有再次准备文件后,才能再次播放文件。因此,我们必须执行在start_audio()函数的if语句中准备音频文件的代码。

图 7-5 显示了播放音频文件并在毫秒 5943 暂停后的结果,这相当于整个文件持续时间的 19.44%。第一个标签宽度是屏幕宽度的 19.44%。

img/481739_1_En_7_Fig5_HTML.jpg

图 7-5

通过在 Python 中反映 Java 类来播放 Android 中的音频文件

这个例子很容易在 Python 中使用 Java 代码。不幸的是,即使是简单的操作,比如显示一条 toast 消息,这个过程也不简单。在 Java 中,toast 只使用一行代码显示,如下所示:

Toast.makeText(this, "Hello Java", Toast.LENGTH_LONG).show();

这个过程需要的不仅仅是在 Python 中反映Toast类(android.widget.Toast)。例如,我们必须知道要显示的文本实际上是CharSequence类的一个实例。因此,为了将文本转换成适合文本参数的类型,必须反射这个类。而且,toast 只在 UI 线程内创建,不在线程外创建。因此,它可以显示在一个Runnable实例中。与 Java 示例相比,开发人员需要做大量的工作。

请记住,使用 Pyjnius 的目的是让 Kivy 开发人员能够更简单地用 Python 构建使用 Java 特性的 Android 应用。如果过程会变得复杂,我不推荐用它来写 Java 代码。它可以用于简单的任务。

但是如果我们不能在 Python 中编写 Java 代码,我们在哪里编写呢?答案就在 Java 文件中。

理解使用推土机构建的 android 项目

记住 Python-for-Android 是 Python(即 Kivy)和 Android(即 Java)之间的桥梁。使用 Buildozer,从 Python Kivy 项目自动构建一个 Android Java 项目。构建完 Android 项目后,Java 项目存在于以下路径中,假设 Python 项目的根目录为NewAppbuildozer.spec文件内指定的包名为kivyandroid

NewApp/.buildozer/android/platform/build/dists/kivyandroid

如果您熟悉 Android Studio 项目的结构,您可以在项目中导航,找到常规项目中所有必要的文件和目录。

在项目的根目录下,存在build.gradleAndroidManifest.xml文件。我们将不会详细讨论这些文件,而只是获得帮助我们理解如何从 Android Studio 管理 Android 项目的想法。

AndroidManifest.xml文件如清单 7-12 所示。它从<manifest>元素开始。在这个元素的头中,指定了包名和应用版本。它首先使用 package 属性指定应用包名,该属性设置为com.test.kivyandroid。这是buildozer.spec文件中的package.domainpackage.name属性串联的结果。

android:versionCode属性是一个表示应用内部版本号的整数值,用于确定该版本是否比另一个版本新。在将应用上传到 Google Play 时,这个整数有助于它知道有新版本的应用通知用户更新。但是这个整数不会显示给用户。属性的值是一个字符串,显示给用户。

android:installLocation允许我们指定应用的安装位置。如果内部存储器已满,则设置为auto以在外部存储器上安装应用。

为了支持广泛的设备,<supports-screens>元素没有对目标屏幕设置限制,而是将其所有属性设置为True

<uses-sdk>元素使用android:minSdkVersionandroid:targetSdkVersion属性指定最小和目标 SDK。尽量以可能的最高 SDK 版本为目标,但将目标 SDK 设置为至少 26 个,因为 Google Play 不再接受目标 SDK 低于 26 的应用。

<uses-permission>元素设置应用所需的权限。请记住第一章,在 Android 项目根目录下的 templates 文件夹中有一个名为AndroidManifest.tmpl.xml的文件,它循环遍历buildozer.spec文件中的android.permissions属性,以便为每个请求的权限创建一个<uses-permission>元素。WRITE_EXTERNAL_STORAGE元素总是被应用请求。

<?
xml version="1.0" encoding="utf-8"
?>

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="com.test.kivyandroid"
      android:versionCode="3"
      android:versionName="0.3"
      android:installLocation="auto">

    <supports-screens
            android:smallScreens="true"
            android:normalScreens="true"
            android:largeScreens="true"
            android:anyDensity="true"
            android:xlargeScreens="true" />

    <uses-sdk android:minSdkVersion="18" android:targetSdkVersion= "26" />

    <!-- OpenGL ES 2.0 -->
    <uses-feature android:glEsVersion="0x00020000" />

    <!-- Allow writing to external storage -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application android:label="@string/app_name"
                 android:icon="@drawable/icon"
                 android:allowBackup="true"
                 android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
                 android:hardwareAccelerated="true" >

        <meta-data android:name="wakelock" android:value="0"/>

        <activity android:name="org.kivy.android.PythonActivity"
                  android:label="@string/app_name"
                  android:configChanges="keyboardHidden|orientation|screenSize"
                  android:screenOrientation="portrait" >

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            </activity>

    </application>

</manifest>

Listing 7-12The AndroidManifest.xml File Inside the Kivy Android Studio Project

当构建一个 Android 项目时,会有一个包含多个活动(即 Java 类)的应用。AndroidManifest.xml文件的结构反映了这一点。它有一个名为<application>的元素,用于声明整个应用。使用<activity>元素,我们可以声明每个单独的活动。<application>元素将一个或多个<activity>元素作为子元素。

<application>元素开始,它的头中有许多属性来定义应用属性。例如,应用名称是使用android:label属性指定的。这被设置为向用户显示的字符串。您可以将其设置为原始字符串,但最好将其设置为字符串资源,以便在应用的其他部分引用它,我们将在后面看到这一点。

在 Kivy built 项目中,指定应用名称的字符串被添加为 ID 为app_name的字符串资源。这里显示的路径中的strings.xml文件保存了应用使用的字符串资源。这个文件在templates目录下有一个名为strings.tmpl.xml的模板。

NewApp/.buildozer/android/platform/build/dists/kivyandroid/src/main/res/values

该文件如清单 7-13 所示。有一个名为app_name的元素,它保存了应用的名称(CoinTex)。为了访问AndroidManifest.xml文件中名为app_name的字符串资源中的值,我们首先需要使用@string引用strings.xml资源文件,然后使用@string/app_name指定字符串资源的 ID。

strings.xml文件也有许多字符串资源,比如presplash_color,它是应用启动时显示的屏幕背景。

<?
xml version="1.0" encoding="utf-8"
?>

<resources>
    <string name="app_name">CoinTex</string>
    <string name="private_version">1544693478.42</string>
    <string name="presplash_color">#000000</string>
    <string name="urlScheme">kivy</string>
</resources>

Listing 7-13The strings.xml File Inside the Kivy Android Studio Project

<application>元素有另一个名为android:icon的属性来设置应用图标。它被设置为 ID 为icon的可提取资源。可绘制资源文件位于以下路径。注意,ID 图标指的是一个名为 icon 的图像(位图文件),其扩展名可以是.png.jpg.gif

NewApp/.buildozer/android/platform/build/dists/kivyandroid/src/main/res/drawable

如果将buildozer.spec文件的fullscreen属性设置为 1,android:theme属性将应用隐藏通知栏的样式。

<application>元素还有另一个名为android:hardwareAccelerated的属性,该属性被设置为True,以平滑地渲染屏幕上显示的图形。

<activity>元素声明了应用中的活动。只有一个名为org.kivy.android.PythonActivity的活动,它是主活动。此活动的 Java 类位于以下路径中。通过定位应用的 Java 类,我们可以添加任何需要执行的 Java 代码。我们稍后会看到这一点。

NewApp/.buildozer/android/platform/build/dists/kivyandroid/src/main/java/org/kivy/android

<activity>元素头中,android:label属性被设置为 ID 为app_name的字符串资源中的值。这是用于在重用的<application>元素中设置应用名称的相同资源。这就是为什么使用字符串资源很重要。

当某些事件发生时,例如在活动运行时隐藏键盘,默认情况下活动会重新启动。android:configChanges属性决定了在不重启的情况下由活动处理的一组配置更改。在我们的项目中,这个属性有三个值,除了orientationscreenSize之外还有keyboardHidden,用于处理屏幕方向从横向到纵向的变化,反之亦然。

android: screenOrientation属性设置设备的方向。它反映了存储在buildozer.spec文件的orientation属性中的值。

<activity>元素有一个名为<intent-filter>的子元素,它声明了活动的意图。这就是活动能做到的。其他应用可以调用您的活动,因为它使用了在该元素中声明的功能。为了允许接受意图,<intent-filter>元素必须至少有一个<action >元素。在我们的活动中,它的动作被显示为名字android.intent.action.MAIN。这意味着父活动是应用的入口点(即,活动在打开应用后打开)。为了在设备的应用启动器中列出应用,添加了名称等于android.intent.category.LAUNCHER<category>元素。

这是对AndroidManifest.xml文件的快速概述。请注意,该文件中的值可能会被build.gradle文件覆盖。该文件中有趣的部分是声明最小 SDK 和目标 SDK 的部分,如清单 7-14 所示。很好理解,SDK 最小版本是 18,目标 SDK 是 26,版本号是整数 3,版本名是字符串 0.3。注意,这些值与AndroidiManifest.xml文件中定义的值相同。

记住,有一个名为build.tmpl.gradle的模板文件,它接受来自buildozer.spec文件的这些值,并生成build.gradle文件。它也位于AndroidManifest.tmpl.xmlstrings.tmpl.xml文件中的templates目录下。

android {
   compileSdkVersion 18
   buildToolsVersion '28.0.3'
   defaultConfig {
      minSdkVersion 18
      targetSdkVersion 26
      versionCode 3
      versionName '0.3'
      }

Listing 7-14Specifying the Minimum SDK and Target SDK Versions Inside the build.gradle File

当在 Android Studio 中操作 Android 项目时,Android Studio 将搜索在build.gradle文件中指定的buildToolsVersioncompileSdkVersion字段的值。如果找不到它们,项目将无法构建。您可以将这些版本更改为适合您系统的版本。

项目主要活动

根据上一节,主活动类被命名为PythonActivity,它位于org.kivy.android包中。由于扩展了Activity类(android.app.Activity,该类不是一个活动,而是由于扩展了SDLActivity类,后者又根据这里显示的类头扩展了Activity类。

public class PythonActivity extends SDLActivity{}

SDLActivity类存在于org.libsdl.app包中。该文件位于如下所示的路径中。注意,我们使用 SDL 作为 Kivy 的后端,这就是为什么这个类的名字中有 SDL。如果你用的是 PyGame,可能会有一些变化。

NewApp/.buildozer/android/platform/build/dists/kivyandroid/src/main/java/org/libsdl/app

因为任何 Android 应用的入口点都是onCreate()方法,所以我们可以讨论一下。除了方法之外,PythonActivity类顶部的几行代码如清单 7-15 所示。前两行导入名为SDLActivityResourceManager的类。

我们曾经在 Android 项目中找到一个名为R.java的类来管理资源,例如维护它们的 id。对于 Kivy 项目,这个类被一个名为ResourceManager的类所替代,这个类在org.renpy.android包中。这就是为什么在onCreate()方法的开头从这个类创建一个实例。

名为mActivity的公共静态类变量被创建并初始化为null。在onCreate()方法中,这个变量被设置为引用当前活动,因为它被赋予了当前对象this,引用活动。注意,我们可以使用这个变量在 Python 脚本中使用 Pyjnius 来访问主活动。这是通过反射PythonActivity类,然后访问它的属性mActivity来实现的。

import org.libsdl.app.SDLActivity;
import org.renpy.android.ResourceManager;
import android.os.Bundle;
...
public class PythonActivity extends SDLActivity {
    private static final String TAG = "PythonActivity";
    public static PythonActivity mActivity = null;
    ...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        Log.v(TAG, "My oncreate running");
        resourceManager = new ResourceManager(this);

        Log.v(TAG, "About to do super onCreate");
        super.onCreate(savedInstanceState);
        Log.v(TAG, "Did super onCreate");

        this.mActivity = this;
        this.showLoadingScreen();

        new UnpackFilesTask().execute(getAppRoot());

        Toast.makeText(this, "Working on the Kivy Project in Android Studio", Toast.LENGTH_LONG).show();
    }
...
}

Listing 7-15The onCreate() Method Inside the PythonActivity Class

调用活动内部名为showLoadingScreen()的方法。这个方法只是在一个ImageView中加载预刷图像。记住,这个图像的名称是在buildozer.spec文件的presplash.filename属性中指定的。该方法如清单 7-16 所示。

该方法工作如下。如果ImageView不是在mImageView变量中创建的,它会通过加载预渲染图像资源来创建它。使用resourceManager实例中的getIdentifier()方法将资源标识符返回到presplashId变量中。此方法接受资源名称及其种类,并返回一个表示 ID 的整数。这就是为什么presplashId变量类型是整数。注意,在常规的 Android 项目中,findViewById()方法返回 id。

预喷涂图像位于此路径NewApp/src/main/res/drawable/presplash.jpg。因为资源是一个图像,所以它的种类是可绘制的,因此它被添加到drawable目录中。这个目录位于res目录下,以表示它包含资源。资源的名称是presplash,它是不带扩展名的资源文件名。

在返回它的 ID 之后,原始图像文件被打开并返回给is变量,它是InputStream类的一个实例。然后使用BitmapFactory类的decodeStream()方法将原始数据解码为图像。数据被返回给一个名为bitmap的变量。

之后,ImageView类的一个实例被返回到mImageView变量,显示在其上的图像使用setUmageBitmap()方法进行设置。它接受我们之前创建的bitmap变量。

记住,加载屏幕的背景颜色被保存为 ID 为presplash_colorstrings.xml文件中的字符串资源。为了返回字符串资源中的值,使用了ResourceManager类中的getString()方法。使用ImageView类的setBackgroundColor()方法,mImageView实例的背景颜色被改变。为了填充父尺寸,为ImageView指定了一些参数。

protected void showLoadingScreen() {
    if (mImageView == null) {
        int presplashId = this.resourceManager.getIdentifier("presplash", "drawable");
        InputStream is = this.getResources().openRawResource(presplashId);
        Bitmap bitmap = null;
        try {
            bitmap = BitmapFactory.decodeStream(is);
        } finally {
            try {
                is.close();
            } catch (IOException e) {};
        }

        mImageView = new ImageView(this);
        mImageView.setImageBitmap(bitmap);

        String backgroundColor = resourceManager.getString("presplash_color");
        if (backgroundColor != null) {
            try {
                mImageView.setBackgroundColor(Color.parseColor(backgroundColor));
            } catch (IllegalArgumentException e) {}
        }
        mImageView.setLayoutParams(new ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.FILL_PARENT,
                ViewGroup.LayoutParams.FILL_PARENT));
        mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
    }

    if (mLayout == null) {
        setContentView(mImageView);
    } else if (PythonActivity.mImageView.getParent() == null){
        mLayout.addView(mImageView);
    }
}

Listing 7-16The showLoadingScreen() Method

有一个名为mLayout的变量引用包含所有视图的活动布局。根据下一行,这个变量在SDLActivity类中被定义为受保护的静态变量:

protected static ViewGroup mLayout;

为了显示会前图像视图,必须将图像视图添加到活动布局中。为了给布局添加一个视图,使用了addView()方法。使用命令mLayout.addView(mImageView),预绘制图像视图被添加到由mLayout变量引用的活动布局中。

注意以下几点非常重要:

  • 该应用没有布局 XML 资源文件。因此,布局是在 Java 代码中动态创建的。

  • SDLActivity类的静态mLayout变量中定义的活动只有一个布局。因此,添加到布局中的视图是可见的,直到我们删除它们。例如,在应用开始之前(即,在加载结束之后),必须从布局中移除预溅图像视图。

在屏幕完成加载后,根据PythonActivity类中名为removeLoadingScreen()的方法移除预渲染图像视图。这将在本章后面讨论。

请注意,如果之前没有创建布局,则使用setContentView()功能将图像本身作为主视图显示在屏幕上。

在加载屏幕上显示预溅图像而不是直接启动应用的目的是在应用启动之前必须加载文件。在项目根目录中,有一个名为python-install的文件夹,其中包含构建应用所需的所有 Python 文件。

在显示加载屏幕后,onCreate()方法通过从扩展了AsyncTaskUnpackFilesTask类创建一个实例来启动一个后台线程。请注意,在加载屏幕处于活动状态时,运行该线程后会显示一条提示消息。

列表 7-17 中显示了这个类的类头和一些代码块。注意,这个类嵌套在PythonActivity类中,因此不需要import语句。

UnpackFilesTask类实现了AsyncTask类中的doInBackground()回调方法,该方法在实例创建后立即启动。在它内部,调用了一个在UnpackFilesTask类内部名为unpackData()的方法。它加载项目文件。

import android.os.AsyncTask;
    ...
private class UnpackFilesTask extends AsyncTask<String, Void, String> {
    @Override
    protected String doInBackground(String... params) {
        ...
        unpackData("private", app_root_file);
        return null;
    }

    @Override
    protected void onPostExecute(String result) {
        ...
        mActivity.finishLoad();

        mActivity.showLoadingScreen();
    }

...

}

Listing 7-17The UnpackFilesTask Class

文件加载后,SDL 调用一个名为keepActive()的方法。如清单 7-18 所示。这个方法在SDLActvity类中定义,在PythonActivity类中实现。

@Override
public void keepActive() {
  if (this.mLoadingCount > 0) {
    this.mLoadingCount -= 1;
    if (this.mLoadingCount == 0) {
      this.removeLoadingScreen();
    }
  }
}

Listing 7-18Implementation of the keepActive() Method

该方法调用另一个名为removeLoadingScreen()的方法,该方法从活动布局中移除预绘制图像视图。它的实现如清单 7-19 所示。它创建一个在应用 UI 上运行的线程。在这个线程中,使用引用活动布局的getParent()方法返回mImageView变量的父级。使用removeView()方法,从布局中移除mImageView。为了删除该变量中的值,将其值返回到null

public void removeLoadingScreen() {
  runOnUiThread(new Runnable() {
    public void run() {
      if (PythonActivity.mImageView != null &&
            PythonActivity.mImageView.getParent() != null) {
        ((ViewGroup)PythonActivity.mImageView.getParent()).removeView(        PythonActivity.mImageView);
        PythonActivity.mImageView = null;
      }
    }
  });
}

Listing 7-19Implementation of the removeLoadingScreen() Method

执行removeLoadingScreen()方法后,活动布局将为空。这意味着我们已经准备好用新的 UI 元素填充布局,就像 Python 应用中定义的那样。为此,UnpackFilesTask类还实现了onPostExecute()回调方法,该方法在类线程完成执行时被执行。这种方法在 UI 线程上执行,因此能够管理 UI。在该方法内部,执行另一个名为finishLoad()的方法。这个方法在SDLActivity类中定义。这个类头和 finishLoad()方法的一些行都显示在清单 7-20 中。

import android.widget.AbsoluteLayout;
import android.view.*;
import android.app.*;
    ...
public class SDLActivity extends Activity {
    ...
    protected static ViewGroup mLayout;
    ...

    protected void finishLoad() {
        ...
        mLayout = new AbsoluteLayout(this);
        mLayout.addView(mSurface);

        setContentView(mLayout);
    }

...

}

Listing 7-20Setting the Application Layout Inside the finishLoad() Method Within the SDLActivity Class

请注意,完成加载屏幕的目标不仅仅是启动应用。另一个目的是让 UI 布局由SDLActivity管理,因为它是负责准备应用 UI 的人。finishLoad()方法在mLayout实例中准备一个布局。稍后我们将使用这种方法向主活动的布局添加视图(PythonActivity)。

显示 Python 中的 Toast 消息

在 PythonActivity 类内部,有一个名为toastError()的方法,如清单 7-21 所示。它根据输入字符串参数显示一条 toast 消息。它使用runOnUIThread()在 UI 上运行一个线程。使用保存在mActivity变量中的PythonActivity实例调用这个方法。

public void toastError(final String msg) {

    final Activity thisActivity = this;

    runOnUiThread(new Runnable () {
        public void run() {
            Toast.makeText(thisActivity, msg, Toast.LENGTH_LONG).show();
        }
    });

    // Wait to show the error.
    synchronized (this) {
        try {
            this.wait(1000);
        } catch (InterruptedException e) {
        }
    }
}

Listing 7-21Implementation of the toastError() Method to Display Toast Messages

我们可以从这个方法中获益,在 Python 代码中显示 toast 消息。唯一要做的就是反射PythonActivity类并访问它的mActivity属性,这将调用toastError()方法。应用的 KV 文件如清单 7-22 所示。根节点BoxLayout有一个按钮,当释放时它调用一个叫做show_toast()的方法。因为 toast 颜色较深,所以添加了一个白色标签,以便更容易看到 toast 消息。

BoxLayout:
    orientation: "vertical"
    Button:
        text: "Show Toast"
        on_release: app.show_toast()
    Label:
        canvas.before:
            Color:
                rgb: (1, 1, 1)
            Rectangle:
                pos: self.pos
                size: self.size

Listing 7-22KV File for Displaying a Toast Method Using Python

显示 toast 的 Python 文件如清单 7-23 所示。使用jnius.autoclass仅反射一次PythonActivity。从该类返回mActivity属性。在show_toast()方法内部,使用mActivity变量调用toastError()方法。

import kivy.app
import jnius
import kivy.uix.button

PythonActivity = jnius.autoclass("org.kivy.android.PythonActivity")
mActivity = PythonActivity.mActivity

class ToastApp(kivy.app.App):

    def show_toast(self):
        mActivity.toastError("Test Toast :)")

app = ToastApp()
app.run()

Listing 7-23Displaying a Toast Message Within Python by Reflecting the PythonActivity Class

松开按钮后,吐司显示如图 7-6 所示。

img/481739_1_En_7_Fig6_HTML.jpg

图 7-6

使用 Python 显示的 toast 消息

在 Android Studio 中打开 Kivy Android 项目

从前面的讨论中,我们至少对 Buildozer 创建的 Android 项目是如何工作的有了基本的了解。下一步是在 Android Studio 中编辑这个项目。

到目前为止,Linux 已经被用来开发项目。这是因为 Buildozer 只在 Linux 中受支持。生成 Android 项目后,我们可以使用我们选择的操作系统在 Android Studio 中操作它。在本节中,Windows 用于导入和编辑项目。在开始之前,确保 Android Studio 工作正常,并且在gradle.build文件中列出了构建工具和 SDK 的版本。

在编辑项目之前,我们需要导入它。只需从文件菜单中选择打开的项目。出现一个窗口,如图 7-7 所示,用于导航到项目的路径。项目名称旁边会出现 Android Studio 图标,表示这是 Android Studio 理解的项目。单击“确定”打开项目。

img/481739_1_En_7_Fig7_HTML.jpg

图 7-7

在 Android Studio 中导入使用 Buildozer 构建的 Kivy Android Studio 项目

项目打开后,项目文件将出现在窗口的左侧。Android视图中文件的结构如图 7-8 所示。此视图帮助您以简单的方式查看文件,而不管它们在项目中的实际位置。例如,不必要的文件不会显示,任何相关的文件都被分组在一起。

img/481739_1_En_7_Fig8_HTML.jpg

图 7-8

Android Studio 中导入项目的结构

manifests组中,列出了项目中使用的所有清单。这个项目有一个名为AndroidManifst.xml的清单文件。Java 主活动类(PythonActivity)位于java组中。我们很容易推断出这个类在org.kivy.android包中。build.gradle文件位于名为Gradle Scripts的组中。res组包含所有的资源,比如strings.xml文件。通过选择Project视图,您可以查看存储在磁盘上的项目文件。

导入项目后,我们可以根据图 7-9 在仿真器或 USB 连接设备上运行项目。

img/481739_1_En_7_Fig9_HTML.jpg

图 7-9

选择用于运行 Android 应用的 USB 连接设备

单击 OK 后,项目将在所选设备上构建、部署、安装和运行。图 7-10 显示了显示 toast 消息的修改后的加载屏幕。如果一切顺利,那么我们做得很好。

img/481739_1_En_7_Fig10_HTML.jpg

图 7-10

在应用加载时修改 Kivy presplash 图像并显示一条 toast 消息

加载屏幕持续几秒钟。当用户等待应用打开时,我们可以播放一些有趣的音乐或改变屏幕的布局。清单 7-24 中的代码在应用加载时播放音乐。

protected void onCreate(Bundle savedInstanceState) {
    Log.v(TAG, "My oncreate running");
    resourceManager = new ResourceManager(this);

    Log.v(TAG, "About to do super onCreate");
    super.onCreate(savedInstanceState);
    Log.v(TAG, "Did super onCreate");

    this.mActivity = this;
    this.showLoadingScreen();

    new UnpackFilesTask().execute(getAppRoot());

    Toast.makeText(this, "Working on the Kivy Project in Android Studio", Toast.LENGTH_LONG).show();

    int music_id = resourceManager.getIdentifier("music", "raw");
    MediaPlayer music = MediaPlayer.create(this, music_id);
    music.start();
}

Listing 7-24Playing Music While the Kivy Application Is Loading

音乐文件作为war添加到 Android 项目中。根据图 7-11 ,这是通过在res组内创建一个目录并在其中添加音乐文件来实现的。添加资源有助于在应用中轻松检索它们。这比使用资源在设备中的路径来引用资源要好。

img/481739_1_En_7_Fig11_HTML.jpg

图 7-11

将音乐文件作为原始资源添加到 Android Studio 项目中

在将原始资源添加到项目中之后,我们必须在 Java 代码中引用它。请记住,Kivy Android 项目中的资源是使用org.renpy.android包中名为ResourceManager的类来操作的。

在上一节中,名为presplash的可绘制资源的 ID 是使用该类中的getIdentifier()方法返回的。类似地,使用此方法返回原始资源的标识符。只需指定合适的namekind参数。

因为资源文件名是music.mp3,所以name参数被设置为music。它的kind被设置为raw,因为它存在于raw文件夹中。因为资源的 ID 是整数,所以 ID 被返回给 integer 类型的music_id变量。

运行应用后,音乐将在加载屏幕出现时立即播放。文件加载完成后,它会自动停止。

向 PythonActivity 布局添加视图

默认情况下,预溅图像是加载屏幕时活动布局上显示的唯一视图。我们可以改变这个布局来添加更多的视图。请记住,该活动只有一个布局,因此,一旦不需要新添加的视图,我们就会将其删除。

当屏幕加载时,我们可以添加更多视图的地方是PythonActivity类的showLoadingScreen()方法。根据清单 7-25 中所示的修改方法,我们可以在活动布局中添加一个单独的TextView。在mImageView变量中定义的预渲染图像视图被添加到布局后,添加TextView。代码省略了一些部分,重点放在将TextView添加到布局的部分。

来自TextView类的一个实例被返回到在PythonActivity类中定义的名为loadingTextView的静态变量中。不将这个变量作为方法的局部变量的原因是,我们需要稍后在removeLoadingScreen()方法中访问它来移除它。

使用setText()方法指定文本视图中显示的文本。使用setTextSize()方法改变其文本大小。准备好文本视图后,使用addView()方法将其添加到布局中。

static TextView loadingTextView = null;
    ...
  protected void showLoadingScreen() {
    ...
  if (mLayout == null) {
      setContentView(mImageView);
  } else if (PythonActivity.mImageView.getParent() == null){
      mLayout.addView(mImageView);

      // Adding Custom Views to the Layout
      loadingTextView = new TextView(this);
      loadingTextView.setText("Kivy application is loading. Please wait ...");
      loadingTextView.setTextSize(30);

      mLayout.addView(loadingTextView);
  }
  }

Listing 7-25Adding a TextView to the Loading Screen While the Kivy Application Is Loading

在我们运行应用后,文本视图将会出现,如图 7-12 所示。

img/481739_1_En_7_Fig12_HTML.jpg

图 7-12

将 Android TextView 添加到 Kivy 应用的加载屏幕

假设文本视图没有从布局中移除,它将在应用启动后保持可见,如图 7-13 所示。

img/481739_1_En_7_Fig13_HTML.jpg

图 7-13

加载时添加到应用布局中的 TextView 在应用启动后仍然可见

请记住,发生这种情况是因为在加载屏幕和应用启动后,应用只有一种布局。因此,移除removeLoadingScreen()方法中的视图非常重要,如清单 7-26 所示。类似于移除预渲染图像视图,返回文本视图的父视图。父节点调用removeView()方法来移除它。

public void removeLoadingScreen() {
  runOnUiThread(new Runnable() {
    public void run() {
      if (PythonActivity.mImageView != null &&
            PythonActivity.mImageView.getParent() != null) {
        ((ViewGroup)PythonActivity.mImageView.getParent()).removeView(PythonActivity.mImageView);
        PythonActivity.mImageView = null;
        ((ViewGroup)PythonActivity.loadingTextView.getParent()).removeView(PythonActivity.loadingTextView);
      }
    }
  });
}

Listing 7-26Removing the TextView Inside the removeLoadingScreen() Method

总之,一个视图被添加到活动布局中,只要屏幕在加载,这个视图就一直存在。加载过程结束后,视图将被删除。如果视图没有从父视图中删除,那么在加载 Python 中定义的应用 UI 后,它仍然可见。

假设在加载步骤结束后,我们需要向活动布局添加一个视图。我们在 Java 代码的什么地方添加了这个视图?让我们在下一节讨论这个问题。

SDLSurface

为了确定在文件加载之后,应用布局可见之前,在项目中添加视图的合适位置,讨论一下SDLSurface类是很重要的。

根据这里显示的SDLSurface类头,在SDLActivity Java 文件中,有一个名为SDLSurface的类扩展了android.view.SurfaceView Android 类。

class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, View.OnKeyListener, View.OnTouchListener, SensorEventListener

SurfaceView是一个 Android 组件,我们可以在其中绘制应用 UI。Kivy 项目 KV 文件中指定的 UI 小部件绘制在这个SurfaceView中。

SurfaceView实现了一个名为SurfaceHolder.Callback的接口,它提供了许多回调方法来帮助接收关于表面的信息。例如,当创建表面时,回调方法surfaceCreated()被调用。当表面被破坏和改变时,分别调用surfaceDestroyed()surfaceChanged()回调方法。发生变化的一个例子是创建这个类的一个实例,并在表面上画一些东西。

为了处理硬件按键,SurfaceView实现了名为View.OnKeyListener的第二个接口。它有一个名为onKey()的回调方法,当按下一个硬件键时就会被调用。为了处理触摸事件,实现了View.OnTouchListener。它有一个名为onTouch()的回调方法。

SurfaceView类实现的最终接口是SensorEventListener。它监听传感器数据的变化。它有两种回调方法— onAccuracyChanged()onSensorChanged()。两者都在类中实现了,但是onAccuracyChanged()是空的。onSensorChanged()用于监控器件方向。

作为在SDLSurface类中定义的所有方法的总结,它的头文件和方法签名如清单 7-27 所示。对SDLSurface类的组件有一个基本的概念会有所帮助。您可以查看所有这些方法的实现,以获得关于该类如何工作的更多信息。

class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
    View.OnKeyListener, View.OnTouchListener, SensorEventListener  {
        ...
    public SDLSurface(Context context) {
        ...
    }

    public void handleResume() {
        ...
    }

    public Surface getNativeSurface() {
        ...
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        ...
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        ...
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        ...
            final Thread sdlThread = new Thread(new SDLMain(), "SDLThread");
        ...
            sdlThread.start();
        ...
    }

    // unused
    @Override
    public void onDraw(Canvas canvas) {}

    // Key events
    @Override
    public boolean onKey(View  v, int keyCode, KeyEvent event) {
        ...
    }

    // Touch events
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        ...
   }

    // Sensor events
    public void enableSensor(int sensortype, boolean enabled) {
        ...
    }

    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) {
        // TODO
    }

    @Override
    public void onSensorChanged(SensorEvent event) {
        ...
    }
}

Listing 7-27Methods Inside the SDLSurface Class

在实现的SurfaceHolder.Callback接口的surfaceChanged()回调方法中有两行值得一提。这些代码行创建并启动了一个Thread类的实例到sdlThread变量中,该变量引用了主 SDL 线程。在Thread类构造函数中指定了两个参数,它们是目标Runnable对象和线程名。

目标是扩展了Runnable接口的SDLMain类的一个实例。注意,SDLMain类存在于SDLActivity Java 文件中。如清单 7-28 所示。

class SDLMain implements Runnable {
    @Override
    public void run() {
        SDLActivity.nativeInit(SDLActivity.mSingleton.getArguments());
    }
}

Listing 7-28Implementation of the SDLMain Class

这个类实现了run()方法,该方法调用一个名为nativeInit()的静态函数。它的头在SDLActivity类头中定义,如下所示。

public static native int nativeInit(Object arguments);

这不是 Java 方法,而是 C 函数。这个方法是 SDL 图书馆的入口点。这个方法和其他 C 方法可以在位于/NewApp/libs/rmeabi-v7a目录下的名为libSDL2.so的 SDL 的源文件中找到。

到了这一步,C 文件就可以执行了,应用将启动并准备好与用户交互。我们可以回到我们的问题,在屏幕加载完成后,我们在 Java 项目的什么地方添加出现在应用活动中的视图。让我们沿着执行链找到合适的位置。

现在可以清楚地看到,SDLMain类中的nativeInit()函数是执行 SDL 库和呈现应用 UI 的入口点。因此,我们必须在调用这个函数之前添加我们的视图。回到这个链,在SDLSurface类的surfaceChanged()方法中创建了一个SDLMain类的实例。记住surfaceChanged()方法是SurfaceHolder.Callback接口内部的回调方法,它监听SurfaceView中的变化。

因为我们希望在应用布局启动后添加视图,所以我们需要找到创建SurfaceView类实例的位置。这是此类课程的第一个变化。记住,有一个名为SDLSurface的类扩展了SurfaceView类。在SDLActivity类的finishLoad()方法中创建了SDLSurface的一个实例。该表面存储在一个名为mSurface的变量中,该变量被声明为静态的,并在类头中受到保护,如下所示:

protected static SDLSurface mSurface;

清单 7-29 显示了创建这样一个实例的finishLoad()方法部分。创建后,它将作为子视图添加到存储在mLayout变量中的应用布局中。最后,使用setContentView()方法将布局设置为活动布局。如果我们需要给活动布局添加一个视图,可以在调用setContentView()方法之前添加。

protected void finishLoad() {
    ...
    mSurface = new SDLSurface(getApplication());
    ...
    mLayout = new AbsoluteLayout(this);
    mLayout.addView(mSurface);

    setContentView(mLayout);
}

Listing 7-29Instantiating the SDLSurface Class Within the finishLoad() Method

记住,finishLoad()方法是在UnpackFilesTask类的onPostExecute()回调方法中调用的。那是在应用加载它的文件之后。我们可以假设SDLActivity类的finishLoad()方法是准备活动布局的起点。向活动布局添加视图是一个好主意,这些视图只有在应用加载步骤结束后才可见。

finishLoad()方法的最后,如清单 7-30 所示,创建了两个视图,分别是TextViewButton。使用setTextSize()方法改变文本大小,使用setTextColor()方法将颜色设置为红色。

protected void finishLoad() {
    ...
    // Set up the surface
    mSurface = new SDLSurface(getApplication());

    if(Build.VERSION.SDK_INT >= 12) {
        mJoystickHandler = new SDLJoystickHandler_API12();
    }
    else {
        mJoystickHandler = new SDLJoystickHandler();
    }

    mLayout = new AbsoluteLayout(this);
    mLayout.addView(mSurface);

    // Adding Custom Views to the Layout
    TextView appTextView = new TextView(this);
    appTextView.setText("Loaded successfully.");
    appTextView.setTextColor(Color.parseColor("#ff0000"));
    appTextView.setTextSize(20);

    Button appButton = new Button(this);
    appButton.setText("Show Toast");
    appButton.setTextColor(Color.parseColor("#ff0000"));
    appButton.setTextSize(20);

    appButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            Toast.makeText(getApplicationContext(), "Toast on Java Button Click.", Toast.LENGTH_LONG).show();
        }

    });

    LinearLayout newLayout = new LinearLayout(this);
    newLayout.setOrientation(LinearLayout.HORIZONTAL);

    newLayout.addView(appTextView);
    newLayout.addView(appButton);

    mLayout.addView(newLayout);

    setContentView(mLayout);
}

Listing 7-30Adding a TextView and a Button to the Application Layout Inside the finishLoad() Method

通过使用setOnClickListener()方法指定点击监听器来处理按钮点击动作。这个方法在实现了它的onClick()方法之后接受了一个OnClickListener()接口的实例。单击按钮时,将执行此方法中的代码。单击该按钮会显示一条提示消息。

这两个新视图作为子视图添加到水平方向的父视图LinearLayout中。最后,线性布局作为子布局添加到活动布局中。

图 7-14 显示了点击 Java 中添加的新按钮后的结果。

img/481739_1_En_7_Fig14_HTML.jpg

图 7-14

向 Kivy 应用添加 Android 视图

我们可以修改 toast 消息,以便在单击按钮时打印活动布局中的所有子视图。清单 7-31 中的代码为此修改了onClickListener()回调方法。使用getChildCount()方法返回布局中子视图的数量。为了检索特定的视图,getChildAt()方法接受一个引用视图在父视图中位置的索引,其中索引 0 引用添加到父视图中的第一个子视图。

类似地,除了它们的数量之外,还显示了在前面的例子中创建的LinearLayout中定义的子视图。

appButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        int numLayoutChildren = mLayout.getChildCount();
        String mLayoutChildren = "Num layout children : "+numLayoutChildren+
                "\nChild 1: "+mLayout.getChildAt(0)+
                ",\nChild 2: "+mLayout.getChildAt(1);

        LinearLayout childLayout = (LinearLayout) mLayout.getChildAt(1);
        int numChildLayoutChildren = childLayout.getChildCount();
        String childLayoutChildren = "Child LinearLayout children : "+
                numChildLayoutChildren+"\nChild 1: "+
                childLayout.getChildAt(0)+
                ",\nChild 2: "+childLayout.getChildAt(1);

        String toastString = mLayoutChildren+"\n"+childLayoutChildren;

        Toast.makeText(getApplicationContext(), toastString, Toast.LENGTH_LONG).show();
    }
});

Listing 7-31Editing the onClickListener() Callback Method of the Button View to Print All Layout Child Views Inside a Toast Message

图 7-15 显示点击按钮后的提示信息。它显示布局中的子元素数量为 2。第一个是SDLSurface类的实例,第二个是LinearLayout类的实例。LinearLayout里面的孩子也印出来了。SDLSurface视图包含 Kivy 应用中定义的所有小部件。在后面的小节中,我们将讨论如何处理这类小部件的事件。

img/481739_1_En_7_Fig15_HTML.jpg

图 7-15

单击按钮视图后,显示关于布局中子视图的信息

总之,为了在屏幕加载时向布局添加视图,在PythonActivity类的showLoadingScreen()方法中添加代码。加载完成后,SDL 调用了keepAlive()方法,该方法调用了一个名为removeLoadingScreen()的方法,在该方法中,视图可以在加载后从布局中删除。项目文件加载后,UnpackFilesTask类的onPostExecute()回调方法调用SDLActivity类内名为finishLoad()的方法。这是在屏幕加载后向布局添加视图的好地方。

SDL 如何检测 Kivy 中定义的 UI 小部件?

您可能已经注意到,上一个示例中添加的按钮隐藏了 Kivy 中定义的Label小部件上显示的单词server的一部分。为什么会这样?

当表面被创建时,它的大小根据surfaceChanged()回调方法中的onNativeResize()本地函数调用被设置为整个窗口,如清单 7-32 所示。SDLActivity表面的宽度和高度被设置为窗口的宽度和高度。这没有给在 Java 中添加新视图留下空间。视图堆叠在彼此的顶部。要添加到活动布局中的最新视图的 Z 顺序出现在它们之前添加的视图的顶部。

重要的是将变量mWidthmHeight设置为等于表面的宽度和高度。稍后会引用它们来处理 Kivy 中添加的小部件的触摸事件。

public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
    ...
    mWidth = width;
    mHeight = height;
    SDLActivity.onNativeResize(width, height, sdlFormat, mDisplay.getRefreshRate());
    ...
}

Listing 7-32Setting the Surface Size to the Window Size Inside the SurfaceChanged() Method

在前面的例子中,在添加了 SDL 表面(mSurface)之后,新的线性布局(newLayout)被添加到活动布局(mLayout)。这就是新视图出现在 SDL 表面顶部的原因。

LinearLayout的方向设置为水平。对于水平线性布局,默认宽度和高度设置为WRAP_CONTENT,这意味着线性布局不会填充整个活动布局大小。它只覆盖了容纳其子视图的最小空间。这就是为什么SDLSurface仍然可见。

如果根据清单 7-33 中的finishLoad()方法将 SDLSurface 添加到活动布局中,那么线性布局中的TextViewButton将被 SDL 表面隐藏。

protected void finishLoad() {
    ...
    mLayout.addView(newLayout);
    mLayout.addView(mSurface);
    ...
}

Listing 7-33Adding the SDL Surface to the Activity Layout Before Adding the Custom LinearLayout

我们想到的给每个视图提供独特空间的一个想法是改变 SDL 表面的大小,使其不填满窗口的整个高度。当添加到布局中时,addView()方法可以接受另一个指定给定视图的布局参数的参数。根据清单 7-34 ,当在finishLoad()方法中向布局添加 SDL 曲面时,我们可以将其高度更改为布局高度的 3/4。

屏幕宽度和高度返回到 width 和 height 变量。因为活动布局充满了它的屏幕,我们可以互换使用它们。它们是相对于布局定位 SDL 表面所必需的。addView()方法接受布局参数作为第二个参数。注意,使用了AbsoluteLayout类,因为活动布局是该类的一个实例。

protected void finishLoad() {
    ...
    // Return screen size to position the SDL surface relative to it.
    DisplayMetrics displayMetrics = new DisplayMetrics();
    getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
    int width = displayMetrics.widthPixels;
    int height = displayMetrics.heightPixels;

    // Specifying the new height and Y position of the SDL surface.
    mLayout.addView(mSurface, new AbsoluteLayout.LayoutParams(width, height-height/4, 0, height/4));
    ...
}

Listing 7-34Setting the Height of the SDL Surface to be 3/4 of the Layout Height

表面的宽度被设置为等于屏幕宽度,但是高度被设置为height-height/4。也就是说,布局高度的 3/4 用于 SDL 表面,而活动布局高度的 1/4 是空的,将容纳线性布局。布局从左上角(0,0)开始。如果不改变这个位置,SDL 表面和新的线性布局将出现在彼此之上,如图 7-16 所示。空白区域出现在布局的底部。

img/481739_1_En_7_Fig16_HTML.jpg

图 7-16

SDL 表面覆盖了布局高度的 3/4

Y 位置更改为height/4,以便在顶部为新添加的视图留出 1/4 的布局高度。该效果如图 7-17 所示。

img/481739_1_En_7_Fig17_HTML.jpg

图 7-17

从左下角开始将 SDL 表面放置到布局中

在 Java 中处理 Kivy 控件的触摸事件

活动布局包含作为子视图的SDLSurface。这个视图包含 Kivy 应用的 KV 文件中定义的所有小部件。在本节中,我们将讨论如何在 Android Studio 项目中处理这些小部件的事件。

在 Python 应用的 KV 文件中,四个小部件被添加到一个BoxLayout中。它有四个子部件(一个Button,两个TextInput,一个Label)。由于它的垂直方向,小部件跨越了BoxLayout的整个宽度,但是高度是相等的。屏幕高度除以 4,因此每个小工具的高度是屏幕高度的 25%(1/4)x1.0=0.25)。

因为 SDL 表面的(0,0)点从左上角开始,所以按钮小部件在布局中覆盖的区域的 Y 坐标范围从 0.75 到 1.0。SDL 将该范围内的任何触摸事件与按钮部件相关联。

按钮上方的TextInput控件的 Y 范围从 0.5 到 0.75。因此,SDL 将该区域中的任何触摸事件与该小部件相关联。对第二个TextInput小部件(0.25 到 0.5)和Label (0.0 到 0.25)重复相同的过程。

将表面高度改为屏幕高度的 3/4=0.75 后,小部件的 Y 范围会发生变化。例如,Label widget Y 坐标将从 0.0 开始,但结束于新表面高度的 1/4,即 0.75,也就是0.25x0.75=0.1875。第一个TextInput的 Y 坐标将从 0.1875 开始,到 0.375 结束。因此,从 0.1875 到 0.25 的屏幕 Y 范围现在与TextInput小部件相关联。

通过计算 SDL 表面中每个小部件的 Y 坐标范围,SDL 可以很容易地确定触摸事件的目的地,并采取适当的行动。例如,如果触摸事件的位置是(x=0.1,y=0.9),则是对Button小部件的触摸,因此将调用 Kivy 应用中定义的回调函数。Kivy 小部件的触摸事件处理是在SDLSurface类的onTouch()回调方法中完成的,该回调方法位于SDLActivity Java 文件中。该方法负责处理触摸事件的部分如清单 7-35 所示。

此方法接受目标视图和事件。触摸事件可能有多个要采取的动作,包括按压、释放、移动等等。出于这个原因,一个switch块过滤动作以做出正确的决定。

首先,使用整数变量action中的getActionMasked()方法从事件中返回事件的动作。例如,当触摸被按下时,返回的整数是 0。释放时,整数为 1。

与动作相关的数字在MotionEvent类头中被定义为静态变量。例如,ACTION_DOWN变量的值为 0,而ACTION_UP变量的值为 1。

public boolean onTouch(View v, MotionEvent event) {
    ...
        int action = event.getActionMasked();
        switch(action) {
             case MotionEvent.ACTION_MOVE:
                 for (i = 0; i < pointerCount; i++) {
                     pointerFingerId = event.getPointerId(i);
                     x = event.getX(i) / mWidth;
                     y = event.getY(i) / mHeight;
                     p = event.getPressure(i);
                     SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
                 }
                 break;

             case MotionEvent.ACTION_UP:
             case MotionEvent.ACTION_DOWN:
                 i = 0;
             case MotionEvent.ACTION_POINTER_UP:
             case MotionEvent.ACTION_POINTER_DOWN:
                 if (i == -1) {
                     i = event.getActionIndex();
                 }

                 pointerFingerId = event.getPointerId(i);
                 x = event.getX(i) / mWidth;
                 y = event.getY(i) / mHeight;
                 p = event.getPressure(i);
                 SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
                 break;

             case MotionEvent.ACTION_CANCEL:
                 for (i = 0; i < pointerCount; i++) {
                     pointerFingerId = event.getPointerId(i);
                     x = event.getX(i) / mWidth;
                     y = event.getY(i) / mHeight;
                     p = event.getPressure(i);
                     SDLActivity.onNativeTouch(touchDevId, pointerFingerId, MotionEvent.ACTION_UP, x, y, p);
                 }
                 break;

             default:
                 break;
    ...
}

Listing 7-35Handling the Touch Events of Kivy Widgets Inside Android Studio

在像ACTION_POINTER_DOWN这样的动作中,使用getX()getY()方法返回触摸事件的 x 和 y 坐标。为了知道其相对于 SDL 表面大小的位置,将它们除以存储在mWidthmHeight变量中的表面的宽度和高度。请记住,这些变量在surfaceChanged()回调方法中被设置为等于表面的宽度和高度。最后,调用本地函数onNativeTouch()根据 Python 编写的指令处理动作。该方法接受触摸坐标来决定哪个小部件接收事件。

对于动作ACTION_UPACTION_DOWNACTION_POINTER_UPACTION_POINTER_DOWN,执行相同的代码。这就是为什么与动作ACTION_UPACTION_DOWNACTION_POINTER_UP相关的情况没有break声明的原因。例如,如果动作号为 1,则switch程序块进入与ACTION_UP相关的案例。因为这个案例没有break语句,所以执行下一个案例,也就是ACTION_DOWN。此外,该案例没有break语句,因此进入下一个案例。

类似地,与ACTION_POINTER_UP关联的下一个案例没有break语句,因此与ACTION_POINTER_DOWN关联的案例进入。因为这个案例在结尾包含了一个break语句,所以它的代码被执行,开关断开。

至此,我们已经讨论了使用 Kivy 项目中的 Buildozer 创建的 Java 项目的最重要部分。这有助于我们理解事情是如何工作的,并修改项目以添加与项目一起工作的 Java 组件。

请记住,在 Android Studio 中从事 Kivy 项目的一个目标是,即使使用 Pyjnius,在 Python 中实现某些操作的复杂性也会增加。在下一节中,用 Java 处理一个 Kivy 按钮触摸事件。

在 Android Studio 中处理 Kivy 按钮点击

Python-for-Android 支持一系列可以打包在 Android APK 文件中的库。该列表可在 https://github.com/kivy/python-for-android/tree/master/pythonforandroid/recipes 找到。这些库的例子有 Plyer、requests、OpenCV、NumPy 和 Pyjnius。其中一些库得到了很好的支持并且运行良好,但其他的则不然。通常,从一个使用图像处理库(如 NumPy 和 OpenCV)的 Kivy 项目构建一个 APK 有点挑战性,并且预计会失败。GitHub 中的一些问题尚未修复,尽管 Kivy 开发人员正在努力解决它们。

通过实现在 Python 中不容易实现的组件,我们可以从在 Android Studio 中编辑 Kivy 项目中受益。

在本节中,将创建一个 Kivy 应用,用户在其中选择一个图像文件。Python 中有未处理的Button小部件。在 Android Studio 中,按钮的触摸事件在前面讨论过的onTouch()回调方法中处理。触摸按钮时,使用 OpenCV 库处理选定的图像。我们将从讨论 Kivy 应用开始,然后下载并导入 OpenCV,以便处理按钮触摸事件。

气馁

应用的 KV 文件如清单 7-36 所示。有一个名为FileChooserIconView的新小部件,它显示设备的文件系统。属性指定显示文件和文件夹的路径。它被设置为当前目录。dirselect决定用户是否可以选择目录。它被设置为 False 以防止用户选择目录。将multiselect属性设置为False以避免选择多个文件。我们只需要选择一个图像文件。当用户做出选择时,触发on_selection事件。使用名为load_file()的回调函数在 Python 文件中处理它。

选择图像文件后,通过将图像的 source 属性设置为选择的文件目录,图像显示在Image小部件上。一个Label小部件显示信息性消息,比如指示用户是否选择了图像。

Python 中不处理Button小部件。它将在 Android Studio 项目中处理。

BoxLayout:
    orientation: "vertical"
    Image:
        id: img
    FileChooserIconView:
        id: fileChooser
        path: "."
        dirselect: False
        on_selection: app.load_file()
    Label:
        font_size: 20
        text_size: self.width, None
        id: selectedFile
        text: "Please choose an image (png/jpg)."
    Button:
        font_size: 20
        text: "Process image in Java"

Listing 7-36KV File for a Kivy Application with a Button to be Handled Within Android Studio

图 7-18 显示了运行应用后的结果。注意,Image小部件上没有显示图像,因此它的颜色只是一个白色区域。

img/481739_1_En_7_Fig18_HTML.jpg

图 7-18

在清单 7-36 中创建的应用窗口

Python 文件如清单 7-37 所示。应用类被命名为AndroidStudioApp,因此 KV 文件名必须是androidstudio.kv,以便将其隐式链接到应用。该类实现了load_file()回调函数。即使选择了一个目录,也会调用该函数。

import kivy.app
import shutil
import os

class AndroidStudioApp(kivy.app.App):

    def load_file(self):
        fileChooser = self.root.ids['fileChooser']
        selectedFile = self.root.ids['selectedFile']
        img = self.root.ids['img']

        file_dir = fileChooser.selection[0]
        file_path, file_ext = os.path.splitext(file_dir)
        file_name = file_path.split('/')[-1]
        print(file_name, file_ext)

        if file_ext in ['.png', '.jpg']:
            img.source = file_dir
            try:
                shutil.copy(file_dir, '.')
                selectedFile.text = "**Copied**\nSelected File Directory : " + file_dir
                os.rename(".join([file_name, file_ext]), ".join(["processImageJavaOpenCV", file_ext]))
            except:
                selectedFile.text = "**Not Copied - File Already Exists**\nSelected File Directory : " + file_dir
                print("File already exists")
                os.rename(".join([file_name, file_ext]), ".join(["processImageJavaOpenCV", file_ext]))
        else:
            selectedFile.text = "The file you chose is not an image. Please choose an image (png/jpg)."

app = AndroidStudioApp()
app.run()

Listing 7-37Python File of the Application in which an Image File Is Loaded to be Processed in Android Studio Using OpenCV

该函数从访问 KV 文件中定义的小部件开始。文件选择器返回到fileChooser变量。即使禁用多选,所选的文件目录也会返回到一个列表中,可以使用selection属性访问该列表。Index 0 将所选文件的目录作为字符串返回到file_dir变量中。

因为不能保证所选文件是图像,所以我们需要通过检查其扩展名来验证它是图像。使用os.path模块中的splitext()函数,文件目录被分为扩展名和路径。扩展返回到file_ext变量,路径返回到file_path变量。文件路径可以如下所示:

NewApp/kivy/img

使用split()函数对file_path进行分割,以便将文件名返回给file_name变量。分离器是/。结果是如下所示的列表。文件名是可以使用-1索引的最后一个元素。该文件名稍后将用于重命名选定的文件。

['NewApp', 'kivy', 'img']

将选定的文件扩展名与目标扩展名列表进行比较。png和。jpg。如果列表等于其中任何一个,我们就可以确定选择的文件是一个图像。因此,Image小部件的 source 属性被设置为图像目录。

如果扩展名不是.png.jpg,则在Label小工具上打印一条消息,通知用户选择不正确。

使用shutil库中的copy()函数将图像从其原始路径复制到当前目录。如果文件已经存在于目标目录中,这个副本将抛出异常。这就是为什么它被写在try块中的原因。在except块中,Label小工具指示该图像存在于当前目录中,如图 7-19 所示。

img/481739_1_En_7_Fig19_HTML.jpg

图 7-19

选择当前目录中存在的图像文件

在 Java 文件中,我们需要一种方法来表示图像文件的名称。我们可以通过将 Java 文件中要处理的图像的名称设置为一个固定的名称来标准化这个过程。选中的名字是processImageJavaOpenCV。使用os.rename()函数,指定旧的和新的文件名。使用join()函数,文件名及其扩展名被连接起来。这是在tryexcept模块内完成的。

在确保 Python 应用运行良好之后,我们需要使用 Buildozer 构建 Android 项目。产生的项目将在 Android Studio 中打开,就像上一个项目中所做的那样。请记住将编译 SDK 和构建工具版本设置为您的 PC 所需的版本。

检测 Java 内部按钮控件的触摸事件

请记住,所有 Kivy 小部件都绘制在SDLActivity类中定义的 SDL 表面内。当触摸表面时,在onTouch()回调方法中返回触摸事件位置。这个位置与每个 Kivy 小部件的区域进行比较。该事件与其区域中发生触摸的部件相关联。在本节中,我们将检测 Kivy 按钮的区域。

与上一节“在 Java 中处理 Kivy 小部件的触摸事件”中的例子类似,小部件被添加到 KV 文件中的一个垂直方向的BoxLayout中。因此,屏幕高度在所有子部件之间平均分配。注意,屏幕和BoxLayout可以互换使用,因为布局充满了屏幕。

因为屏幕有四个小部件,每个小部件的高度是屏幕高度的 25%。因此,按钮小部件相对于屏幕高度的 Y 坐标值从 Y=0.75 开始,到 Y=1.0 结束。在屏幕的这个区域发生的任何触摸事件都与Button小部件相关联。

修改后的处理按钮触摸事件的onTouch()方法如清单 7-38 所示。如果我们对触摸释放后的事件处理感兴趣,代码将被写入与ACTION_UP相关联的案例中。

使用if语句,将触摸位置的 Y 坐标与按钮的 Y 值范围进行比较。如果触摸落在此范围内,处理触摸事件所需的操作被添加到if块中。目前,只显示了一条祝酒词。稍后,在将 OpenCV 与项目链接后,一些操作将应用于所选图像。

因为 Kivy 项目中没有与小部件相关联的动作,所以我们不需要调用onNativeTouch()。返回触摸事件的 X 和 Y 坐标就足以确定目标小部件。

if块的末尾有一个break语句,以避免执行更多的情况。如果触摸在范围之外,将通过执行与ACTION_POINTER_DOWN相关的案例中的代码来应用之前遵循的正常程序。

public boolean onTouch(View v, MotionEvent event) {
    ...
         switch(action) {
             case MotionEvent.ACTION_MOVE:
    ...
             case MotionEvent.ACTION_UP:
                 if (i == -1) {
                     i = event.getActionIndex();
                 }
                 x = event.getX(i) / mWidth;
                 y = event.getY(i) / mHeight;
                 if (y >= 0.75){
                     Toast.makeText(this.getContext(), "Button Clicked", Toast.LENGTH_LONG).show();
                 break;
                 }
             case MotionEvent.ACTION_DOWN:
    ...
             case MotionEvent.ACTION_POINTER_UP:
             case MotionEvent.ACTION_POINTER_DOWN:
    ...
             case MotionEvent.ACTION_CANCEL:
    ...
}

Listing 7-38Handling the Touch Event of the Kivy Button Widget Within the onTouch() Method of the Android Studio Project

用修改后的onTouch()方法构建并运行 Android 项目后,按下按钮,结果如图 7-20 所示。下一步是将项目与 OpenCV 链接起来,以便处理图像。

img/481739_1_En_7_Fig20_HTML.jpg

图 7-20

处理 Android Studio 中 Kivy 按钮小部件的触摸事件

在 Android Studio 中导入 OpenCV

OpenCV 支持一个可以在 Android 设备上工作的版本。目前 Android 的最新版本是 3.4.4。可以在 https://sourceforge.net/projects/opencvlibrary/files/3.4.4/opencv-3.4.4-android-sdk.zip 下载。

OpenCV 将作为一个模块导入 Android Studio,以便在 Java 代码中访问它。在 Android Studio 的文件菜单中,进入新建,然后选择导入模块。这将打开一个窗口,询问模块源目录。您可以复制并粘贴该目录,也可以在文件系统中导航找到它。如果将压缩文件解压到名为opencv-3.4.4-android-sdk的文件夹中,那么要复制粘贴的目录就是\opencv-3.4.4-android-sdk\OpenCV-android-sdk\sdk\java。当您进入目录时,模块名称会自动检测为openCVLibrary344

由于 SDK 和构建工具的版本不一致,导入模块后可能会出现错误。导入的模块中有一个build.gradle文件。您可以更改编译、最低和目标 SDK 版本,以匹配您计算机上的版本。这是对构建工具版本的补充。这类似于我们之前在项目本身的build.gradle文件中所做的。

下一步是将导入的库作为依赖项添加到 Android 项目中。选择“文件”菜单,然后选择“项目结构”菜单项。根据图 7-21 ,这将打开一个窗口,其中项目名称位于模块部分下。OpenCV 库也在列表中。

img/481739_1_En_7_Fig21_HTML.jpg

图 7-21

Android Studio 项目的结构

转到“依赖项”选项卡,然后单击“添加”按钮。然后选择模块依赖,如图 7-22 。这将打开另一个窗口,其中列出了 OpenCV。选择它,然后单击“确定”将其添加为依赖项。再次单击“确定”关闭“项目结构”窗口。

img/481739_1_En_7_Fig22_HTML.jpg

图 7-22

在 Android Studio 项目中添加 OpenCV 作为依赖库

最后一步是在 OpenCV \OpenCV\opencv-3.4.4-android-sdk\OpenCV-android-sdk\sdk\native下的这个路径中复制一个名为libs的文件夹。将文件夹名称改为jniLibs后,粘贴到 Android Studio 项目下的\KivyAndroidStudio\src\main目录中。经过这一步,OpenCV 就可以使用了。

对所选图像应用 Canny 滤镜

在使用 OpenCV 之前,我们必须确保它已经加载。这可以使用清单 7-39 中的if语句来完成。如果库没有成功加载,您可以在它的块中处理它。它可以被添加到SDLActivity类的onCreate()方法中。

if (!OpenCVLoader.initDebug()) {
    // Handle initialization error
}

Listing 7-39An if Statement to Ensure Loading OpenCV

清单 7-40 显示了加载 OpenCV 的修改后的onCreate()方法。

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    DisplayMetrics displayMetrics = new DisplayMetrics();
    getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
    screenWidth = displayMetrics.widthPixels;
    screenHeight = displayMetrics.heightPixels;

    if (!OpenCVLoader.initDebug()) {
        // Handle initialization error
    }

    SDLActivity.initialize();
    // So we can call stuff from static callbacks
    mSingleton = this;
}

Listing 7-40Loading OpenCV Within the onCreate() Method

OpenCV 加载后,我们就可以使用它了。清单 7-41 中的代码将选中的图像读入一个名为inputImage的变量。它假设图像扩展名是.jpg。可以加一点修改支持.png.jpg

Canny 边缘检测器应用于该图像。Canny 的合成图像保存到另一个名为outputImage的变量中。该图像被保存为一个.jpg文件。

一个ImageView被添加到 SDL 活动布局中,显示保存的图像。ImageView被添加到与 KV 文件中定义的文件选择器小部件相关联的区域。

为了在屏幕上正确定位ImageView,需要屏幕宽度和高度。在SDLActivity类头中定义了两个名为screenWidthscreenHeight的静态整数变量。在onCreate()方法中,屏幕宽度和高度被计算并分配给它们。

Toast.makeText(this.getContext(), "Canny is being processed...", Toast.LENGTH_LONG).show();
Mat inputImage = Imgcodecs.imread("processImageJavaOpenCV.jpg");
Mat outputImage = inputImage.clone();
Imgproc.Canny(inputImage, outputImage, 100, 300, 3, true);
Imgcodecs.imwrite("RESULTImageJavaOpenCV.jpg", outputImage);
File cannyImage = new File("RESULTImageJavaOpenCV.jpg");
ImageView imageView = new ImageView(this.getContext());

if(cannyImage.exists()){
    Bitmap myBitmap = BitmapFactory.decodeFile(cannyImage.getAbsolutePath());
    imageView.setImageBitmap(myBitmap);
    SDLActivity.mLayout.addView(imageView, new AbsoluteLayout.LayoutParams(SDLActivity.screenWidth, SDLActivity.screenHeight/4, 0, SDLActivity.screenHeight/4));
}

Listing 7-41Using OpenCV to Apply the Canny Edge Detector to the Loaded Image

清单 7-41 中的代码可以添加到与ACTION_UP关联的案例中的onTouch()方法中。这将在按下 Kivy 中定义的Button小部件后执行代码。图 7-23 显示了我们按下按钮后的结果。

img/481739_1_En_7_Fig23_HTML.jpg

图 7-23

OpenCV 用于将 Canny 边缘检测器应用于加载的图像

摘要

Python 本身不能构建丰富的 Android 应用,这就是为什么本章讨论了用 Kivy 以不同的方式丰富 Python 中创建的 Android 应用。我们讨论的第一个库是 Plyer,它允许我们使用原生 Python 代码访问 Android 特性。因为许多特性还没有在 Plyer 中实现,所以讨论了另一个名为 Pyjnius 的库,它反映了 Python 中访问 Java 特性的 Java 类。不幸的是,Pyjnius 增加了构建 Java 特性的复杂性,这不是丰富 Android 应用的推荐方法。最理想的方法是编辑使用 Buildozer 导出的 Android Studio 项目。这样,在 Android 应用中可以做的事情就没有限制了。在导入导出的 Android Studio 项目后,Python 中没有的任何内容都可以添加到 Java 中。

本章首先讨论了项目,在进行任何编辑之前突出显示了项目中的重要文件。这些文件包括主 Java 活动、清单文件、字符串资源文件等等。之后,这些文件经过编辑,为 Android 应用添加了更多功能。这包括编辑加载屏幕布局、向主活动添加 Android 视图、处理两个小部件的点击动作、显示 toast 消息等等。本章还在 Android Studio 项目中引入了 OpenCV,以便在图像上应用简单的滤镜。

最后,本章证明了即使 Python 在构建 Android 应用方面受到限制,用 Python 创建丰富的 Android 应用也是可能的。这是通过编辑导出的 Android Studio 项目并进行必要的更改以添加更多功能来实现的。

posted @ 2024-08-09 17:41  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报