Kivy-蓝图-全-

Kivy 蓝图(全)

原文:zh.annas-archive.org/md5/9c3f4e93a52e8d38197e54db421b1d0d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

移动应用早已不再是“新热点”,如今用户通常期望新的软件——无论是视频游戏还是社交网络——都有一个移动版本。类似的趋势也影响了桌面操作系统;编写跨平台软件,曾经是不常见的,迅速成为了一种规范。即使是通常仅限于桌面 Microsoft 操作系统的游戏开发者,现在也可以看到他们在为许多新游戏(例如,在撰写本文时,Steam 托管了超过一百款在 Mac 上运行的游戏,以及超过五十款在 Linux 上运行的游戏)开发 Mac 和 Linux 版本。

这对于初创公司和独立开发者来说尤其有价值:构建真正跨平台的软件可以扩大潜在受众,从而增加销量,并在过程中可能创造良好的舆论。

然而,编写可移植的软件可能是一个非常资源密集的过程,这对小型开发者的影响远大于大型企业。

尤其是许多平台都有一个首选的编程语言和软件开发工具包SDK):iOS 应用大多是用 Objective-C 和 Swift 编写的,Android 推荐使用不太理想的 Java 编程语言,而微软则推广使用.NET 框架,特别是 C#,来构建 Windows 软件。

使用这些工具允许你利用操作系统的原生用户界面和底层功能,但它也自动防止了代码重用。这意味着即使你对所有涉及的编程语言和接口都同样精通,移植代码可能仍然需要相当多的时间,并引入新的错误。

编写一次,运行任何地方

这种整个情况产生了对一种通用、多平台编程方式的需求。这个问题并不是全新的:1995 年由 Sun 提出的解决方案之一是 Java 编程语言。它的营销承诺——“编写一次,运行任何地方”——从未实现,而且该语言本身使用起来非常繁琐。这导致了众多对该口号的嘲讽变体,最终以“编写一次,逃离一次”为高潮,指的是许多开发者放弃了 Java,转而使用更好的编程语言,包括 Python。

顺便提一下,Kivy——本书的主要主题——是一个图形用户界面库,它简化了多平台 Python 应用的创建。Kivy 工具包的主要特性如下:

  • 兼容性:基于 Kivy 的应用在 Linux、Mac OS X、Windows、Android 和 iOS 上都能运行——所有这些都来自同一个代码库。

  • 自然用户界面:Kivy 弥合了不同输入方法之间的差距,允许你使用类似的代码处理多种可能的用户交互,无论是鼠标事件还是多点触控手势。

  • 快速硬件加速图形:OpenGL 渲染使 Kivy 适合创建图形密集型应用,如视频游戏,同时也通过平滑的过渡改善了用户体验。

  • Python 的使用:Kivy 应用是用 Python 编写的,Python 是一种较好的通用编程语言。除了本身具有可移植性、表达性和可读性之外,Python 还具有有用的标准库和丰富的第三方包生态系统,即 Python 包索引PyPI)。

谈到第三方包,Kivy 可以被视为许多经过实战检验的组件的超集:其功能的大部分依赖于知名的库,如 Pygame、SDL 和 GStreamer。然而,Kivy 提供的 API 非常高级且统一。

值得一提的是,Kivy 是一款免费且开源的 MIT 许可软件。在实践中,这意味着您可以在不支付许可费用的情况下将其用于商业用途。它的完整源代码托管在 GitHub 上,因此您也可以修复错误或为其添加新功能。

本书涵盖的内容

第一章,《构建时钟应用》为使用 Kivy 编写应用提供了一个温和的介绍。它涵盖了 Kivy 语言、布局、小部件和计时器。到本章结束时,我们将构建一个简单的时钟应用,类似于您手机中的时钟应用。

第二章,《构建绘图应用》进一步探讨了 Kivy 框架的组件和功能。生成的绘图应用展示了内置小部件的定制、在画布上绘制任意形状以及处理多点触控事件。

第三章,《Android 声音录制器》作为编写基于 Kivy 的 Android 应用的示例。它展示了如何使用 Pyjnius 互操作性层将 Java 类加载到 Python 中,这使得我们可以将 Android API 调用与基于 Kivy 的用户界面混合使用。

第四章,《Kivy 网络编程》是一本从零开始构建网络应用的实战指南。它涵盖了从创建简单协议到用 Python 编写服务器和客户端软件的多个主题,并以 Kivy 聊天应用作为总结。

第五章,《制作远程桌面应用》展示了编写客户端-服务器应用的另一种方式。本章的程序基于 HTTP 协议——互联网背后的协议。我们首先开发了一个命令行 HTTP 服务器,然后使用 Kivy 构建远程桌面客户端应用。

第六章,《制作 2048 游戏》将指导您构建一个可玩的游戏副本。我们展示了更复杂的 Kivy 功能,例如创建自定义小部件、使用 Kivy 属性进行数据绑定以及处理触摸屏手势。

第七章,编写 Flappy Bird 克隆介绍了另一个基于 Kivy 的游戏,这次是一个类似于著名 Flappy Bird 标题的街机游戏。在本章中,我们讨论了纹理坐标和音效的使用,实现了街机物理和碰撞检测。

第八章,介绍着色器展示了在 Kivy 应用程序中使用 GLSL 着色器的用法。在本教程中,您将了解 OpenGL 原语,如索引和顶点,然后编写直接在 GPU 上运行的极快级代码。

第九章,制作射击游戏从上一章结束的地方继续:我们使用 GLSL 的知识来构建一个侧滚动射击游戏。在此过程中,开发了一个可重用的粒子系统类。本项目总结了整个系列,并利用了书中解释的许多技术,例如碰撞检测、触摸屏控制、音效等。

附录,Python 生态系统为您提供了更多关于 Python 库和工具的信息。

设置工作环境

本节简要讨论了有效遵循叙述、实现和运行 Kivy 应用程序所需的条件。一台运行现代操作系统的个人电脑——Mac、Linux 或 Windows 计算机——是隐含的。

关于 Python 的说明

Python 是本书中使用的首选编程语言;虽然不是严格必要的,但对其有良好的了解可能会有所帮助。

在撰写本文时,广泛使用中的 Python 有两个不兼容的版本。Python 2.7 非常稳定,但不再积极开发,而 Python 3 是一个较新的、略带争议的版本,为语言带来了许多改进,但偶尔会破坏兼容性。

本书中的代码应该在大致上适用于 Python 的两个版本,但可能需要稍作调整才能完全兼容 Python 3;为了获得最佳效果,建议您使用 Python 2.7 或您系统上可用的最新 Python 2 版本。

注意

在大多数平台上,不需要单独安装 Python 用于 Kivy 开发:它可能预装在系统上(Mac OS X)、与 Kivy 捆绑(MS Windows),或者作为依赖项包含(Linux,尤其是 Ubuntu)。

安装和运行 Kivy

您可以从官方网站(kivy.org/)下载 Kivy;只需选择合适的版本并按照说明操作。整个过程应该相当直接和简单。

安装和运行 Kivy

Kivy 下载

要检查安装是否正常工作,请按照以下说明操作:

  • 在 Mac 上:

    1. 打开Terminal.app

    2. 运行kivy

    3. 应该出现 Python 提示符>>>。输入import kivy

    4. 命令应该无错误完成,并打印类似 [INFO] Kivy v1.8.0 的消息。

  • 在 Linux 机器上:

    1. 打开一个终端。

    2. 运行 python

    3. 应该出现 Python 提示符 >>>。输入 import kivy

    4. 命令应该打印类似 [INFO] Kivy v1.8.0 的消息。

  • 在 Windows 系统上:

    1. 双击 Kivy 软件包目录内的 kivy.bat

    2. 在命令提示符中输入 python

    3. 输入 import kivy

    4. 命令应该打印类似 [INFO] Kivy v1.8.0 的消息。

    安装和运行 Kivy

    终端会话

运行 Kivy 应用程序(基本上是一个 Python 程序)的方式类似:

  • 在 Mac 上,使用 kivy main.py

  • 在 Linux 上,使用 python main.py

  • 在 Windows 上,使用 kivy.bat main.py(或将 main.py 文件拖放到 kivy.bat 之上)。

编码注意事项

编程通常涉及大量文本处理;因此,选择一个好的文本编辑器非常重要。这就是为什么我强烈建议在考虑其他选项之前先尝试使用 Vim。

Vim 是目前可用的优秀文本编辑器之一;它高度可配置,专为有效文本编辑而构建(比典型替代品更有效)。Vim 拥有一个充满活力的社区,正在积极维护,并且预安装在许多类 Unix 操作系统中——包括 Mac OS X 和 Linux。已知(至少一些)Kivy 框架的开发者也喜欢使用 Vim。

这里有一些针对 Vim 用户快速了解 Kivy 的技巧:

  • Python-mode (github.com/klen/python-mode) 对于编写 Python 代码非常出色。它提供了许多额外功能,例如样式和静态检查器、智能完成以及重构支持。

  • GLSL 着色器的源代码可以使用 vim-glsl 语法正确高亮显示(github.com/tikhomirov/vim-glsl)。

  • Kivy 纹理映射(即 .atlas 文件,在第八章 介绍着色器 中介绍),基本上是 JSON 格式,因此您可以使用,例如 vim-json (github.com/elzr/vim-json),并将以下行添加到您的 .vimrc 文件中,以创建文件关联:

    au BufNewFile,BufRead *.atlas set filetype=json
    
  • Kivy 布局文件(.kv)处理起来稍微复杂一些,因为它们与 Python 类似,但并不真正像 Python 那样解析。Kivy 仓库中有一个不完整的 Vim 插件,但在撰写本文时,Vim 内置的 YAML 支持更好地突出显示这些文件(这显然可能在将来发生变化)。要将 .kv 文件作为 YAML 加载,请将以下行添加到您的 .vimrc 文件中:

    au BufNewFile,BufRead *.kv set filetype=yaml
    

显然,您并不 必须 使用 Vim 来跟随本书的示例——这只是一个建议。现在让我们写一点代码,怎么样?

Hello, Kivy

当学习一门新的编程语言或技术时,学生通常会首先看到的是一个传统的 "hello, world" 程序。在 Python 中它看起来是这样的:

print('hello, world')

Kivy 的“hello, world”版本稍微长一些,由两个文件组成,即一个 Python 模块和一个 .kv 布局定义。

代码

Kivy 应用程序的入口通常称为 main.py,其内容如下:

from kivy.app import App

class HelloApp(App):
    pass

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

如您所见,这使用了 Kivy 的 App 类,对其没有任何添加,并调用了 run() 方法。

布局

布局文件通常以应用程序类名命名,在本例中为 HelloApp,不带 App 后缀且为小写:hello.kv。它包含以下行:

Label:
    text: 'Hello, Kivy'

这是一个非常简单的 Kivy 布局定义,仅包含一个 Label 小部件,其中包含所需文本。布局文件允许以简洁、声明性的方式构建复杂的组件层次结构,这在此处未显示,但将在本书的整个过程中被大量使用。

如果我们现在运行程序(有关详细信息,请参阅安装和运行 Kivy部分),这将是我们得到的结果:

布局

我们由 Kivy 驱动的第一个应用程序

现在您已经准备好进入第一章,并开始编写真正的程序。

本书面向的对象

本书旨在为那些熟悉 Python 语言并希望以最小麻烦使用 Python 构建桌面和移动应用程序的程序员编写。虽然了解 Kivy 对您有所帮助,但并非必需——框架的每个方面在首次使用时都会进行描述。

在本书的各个部分,我们将 Kivy 与 Web 开发实践进行类比。然而,对后者的深入了解也不是必需的,以跟随叙述。

规范

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“Kivy 应用的入口通常称为 main.py。”

代码块设置如下:

from kivy.app import App

class HelloApp(App):
    pass

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

当我们希望将您的注意力引向代码块的一个特定部分时,相关的行或项目将以粗体显示:

# In Python code
LabelBase.register(name="Roboto",
    fn_regular="Roboto-Regular.ttf",
    fn_bold="Roboto-Bold.ttf",
    fn_italic="Roboto-Italic.ttf",
    fn_bolditalic="Roboto-BoldItalic.ttf")

任何命令行输入或输出都如下所示:

pip install -U twisted

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“第一个事件处理器是为开始停止按钮。”

注意

警告或重要注意事项如下所示:

小贴士

小贴士

读者反馈

我们欢迎读者反馈。请告诉我们您对本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中受益的标题。

向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。

如果你在一个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。

下载示例代码

你可以从你购买的所有 Packt 出版物的账户中下载示例代码文件www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。获取最新源代码的另一种方法是克隆 GitHub 仓库github.com/mvasilkov/kb

下载本书的彩色图像

我们还为你提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助你更好地理解输出的变化。你可以从以下链接下载此文件:www.packtpub.com/sites/default/files/downloads/7849OS_ColorImages.pdf

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——如果你能向我们报告这一点,我们将不胜感激。通过这样做,你可以帮助其他读者避免挫败感,并帮助我们改进本书的后续版本。如果你发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入你的勘误详情来报告它们。一旦你的勘误得到验证,你的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果你在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送链接到疑似盗版材料的方式,与我们联系 <copyright@packtpub.com>

我们感谢你在保护我们的作者和我们提供有价值内容的能力方面的帮助。

问题

如果您在这本书的任何方面遇到问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一章. 构建时钟应用

本书将引导你创建九个小 Kivy 程序,每个程序都类似于 Kivy 框架在现实世界中的实际应用案例。在许多情况下,框架将与适合当前任务的 Python 模块一起使用。我们将看到 Kivy 提供了大量的灵活性,使我们能够以干净、简洁的方式解决各种不同的问题。

让我们从简单开始。在本章中,我们将构建一个简单的时钟应用,其概念类似于 iOS 和 Android 中内置的应用程序。在本章的第一部分,我们将创建一个非交互式的数字时钟显示并对其进行样式设计,使我们的程序具有类似 Android 的扁平外观。我们还将简要讨论事件驱动的程序流程和 Kivy 主循环,介绍用于执行重复任务的计时器,例如每帧更新屏幕。

在本章的第二部分,我们将添加计时器显示和控制功能,创建一个适合任何屏幕尺寸和方向的流畅布局。计时器自然需要用户交互,我们将最后实现它。

本章介绍的重要主题如下:

  • Kivy 语言的基礎,这是一个用于布局小部件的内置领域特定语言DSL

  • 样式(以及最终子类化)内置的 Kivy 组件

  • 加载自定义字体和格式化文本

  • 调度和监听事件

我们完成的程序,如以下截图所示,将只有大约 60 行长,Python 源代码和 Kivy 语言(.kv)界面定义文件各占一半。

构建时钟应用

我们将要构建的时钟应用最终外观。

起始点

我们在序言中的“Hello, Kivy”示例是本应用的合适起点。我们只需要添加一个布局容器,BoxLayout,这样我们就可以在屏幕上放置多个小部件。

到目前为止,这是完整的源代码:

# File: main.py
from kivy.app import App

class ClockApp(App):
    pass

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

# File: clock.kv
BoxLayout:
    orientation: 'vertical'

    Label:
        text: '00:00:00'

目前,它的外观和行为与之前看到的“Hello, world”应用完全一样。BoxLayout容器允许两个或更多子小部件并排存在,垂直或水平堆叠。给定一个嵌套小部件,如前面的代码所示,BoxLayout将所有可用的屏幕空间填充为它,因此实际上几乎看不见(就像Label是根小部件一样,接管了应用程序窗口)。我们将在稍后更详细地回顾布局。

注意

注意,虽然我们可以将main.py文件命名为任何我们想要的名称,但clock.kv文件是由 Kivy 自动加载的,因此必须以应用程序类的名称命名。例如,如果我们的应用程序类名为FooBarApp,相应的.kv文件应命名为foobar.kv(类名转换为小写且不带-app后缀)。严格遵循此命名约定可以让我们避免手动加载 Kivy 语言文件,这无疑是件好事——更少的代码行数就能达到相同的结果。

现代用户界面

在撰写本文时,扁平化设计范式在界面设计领域正流行,系统地接管了每一个平台,无论是 Web、移动还是桌面。这种范式转变的突出例子包括 iOS 7 及其后续版本和 Windows 8 及其后续版本。互联网公司随后效仿,在 2014 年 Google I/O 大会上提出了“材料设计原则”,以及许多其他 HTML5 框架,包括那些已经建立起来的,例如 Bootstrap。

方便的是,扁平化设计强调内容而非展示,省略了照片般的阴影和详细的纹理,转而使用纯色和简单的几何形状。这种设计在程序上创建起来比“老式”的拟物化设计要简单得多,后者往往视觉丰富且富有艺术性。

注意

拟物主义是用户界面设计的一种常见方法。其特点是应用程序在视觉上模仿其现实世界的对应物,例如,一个计算器应用程序具有与廉价物理计算器相同的按钮布局和外观感觉。这可能有助于或可能不助于用户体验(取决于你问谁)。

为了更简单、更流畅的界面而放弃视觉细节,这似乎是今天每个人都正在走的方向。另一方面,仅从彩色矩形等元素中构建一个独特、令人难忘的界面自然具有挑战性。这就是为什么扁平化设计通常与良好的排版同义;根据应用的不同,文本几乎总是 UI 的一个重要部分,因此我们希望它看起来很棒。

设计灵感

仿效是最高形式的恭维,我们将仿效来自 Android 4.1 姜饼的时钟设计。这种设计的独特之处在于字体粗细对比。直到 4.4 KitKat 版本中进行了更改,默认时钟曾经看起来是这样的:

设计灵感

在 Android 姜饼版锁屏上看到的时钟。

使用的字体是 Roboto,这是 Google 在 Android 4.0 冰激凌三明治中取代 Droid 字体家族的 Android 字体。

Roboto 可用于商业用途,并受 Apache 许可的宽松许可。可以从 Google Fonts 或来自出色的 Font Squirrel 库www.fontsquirrel.com/fonts/roboto下载。

加载自定义字体

当谈到排版时,Kivy 默认使用 Droid Sans——谷歌早期的字体。用自定义字体替换 Droid 很容易,因为 Kivy 允许我们为文本小部件(在这种情况下,Label)指定font_name属性。

在最简单的情况下,当我们只有一种字体变体时,我们可以在小部件的定义中直接分配.ttf文件名:

Label:
    font_name: 'Lobster.ttf'

然而,对于上述设计,我们希望有不同的字体重量,所以这种方法行不通。原因是字体的每个变体(例如,粗体或斜体)通常都生活在单独的文件中,而我们只能将一个文件名分配给font_name属性。

在我们的用例中,涉及到多个.ttf文件,LabelBase.register静态方法提供了更好的解决方案。它接受以下参数(所有参数都是可选的),以 Roboto 字体家族为例:

# In Python code
LabelBase.register(name="Roboto",
    fn_regular="Roboto-Regular.ttf",
    fn_bold="Roboto-Bold.ttf",
    fn_italic="Roboto-Italic.ttf",
    fn_bolditalic="Roboto-BoldItalic.ttf")

在调用此方法后,可以设置小部件的font_name属性为之前注册的字体家族名称,在这种情况下是Roboto

这种方法有两个限制需要注意:

  • Kivy 只接受 TrueType .ttf字体文件。如果字体打包为 OpenType .otf.woff这样的网络字体格式,您可能需要先进行转换。这可以通过 FontForge 编辑器轻松完成,该编辑器可以在fontforge.org/找到。

  • 每种字体最多有四种可能的样式:正常、斜体、粗体和粗斜体。对于像 Droid Sans 这样的旧字体家族来说,这没问题,但许多现代字体包括从 4 种到 20 种以上的样式,具有不同的字体重量和其他功能。我们即将使用的 Roboto 至少有 12 种样式。

加载自定义字体

Roboto 字体的六种字体重量

第二点强制我们选择在应用程序中使用的字体样式,因为我们不能随意使用所有 12 种,这本身就是一个糟糕的想法,因为它会导致文件大小大幅增加,例如在 Roboto 字体家族中,可能增加到 1.7 兆字节。

对于这个特定的应用程序,我们只需要两种样式:一种较轻的样式(Roboto-Thin.ttf)和一种较重的样式(Roboto-Medium.ttf),我们分别将它们分配给fn_regularfn_bold

from kivy.core.text import LabelBase

LabelBase.register(name='Roboto',
                   fn_regular='Roboto-Thin.ttf',
                   fn_bold='Roboto-Medium.ttf')

这段代码应该放在main.py中的__name__ == '__main__'行之后,因为它需要在从 Kivy 语言定义创建界面之前运行。当应用程序类被实例化时,可能已经太晚执行这种基本初始化了。这就是为什么我们必须提前这样做。

现在我们已经设置了自定义字体,接下来要做的就是将其分配给我们的Label小部件。这可以通过以下代码完成:

# In clock.kv
Label:
    text: '00:00:00'
    font_name: 'Roboto'
    font_size: 60

文本格式化

目前最流行且普遍使用的标记语言无疑是 HTML。另一方面,Kivy 实现了一种 BBCode 的变体,这是一种曾经用于在许多论坛上格式化帖子的标记语言。与 HTML 的明显区别是 BBCode 使用方括号作为标签分隔符。

以下标签在 Kivy 中可用:

BBCode 标签 文本效果
[b]...[/b] 加粗
[i]...[/i] 斜体
[font=Lobster]...[/font] 更改字体
[color=#FF0000]...[/color] 使用 CSS 类似的语法设置颜色
[sub]...[/sub] 下标(位于行下方的文本)
[sup]...[/sup] 上标(位于行上方的文本)
[ref=name]...[/ref] 可点击区域,HTML 中的 <a href="…">
[anchor=name] 命名位置,HTML 中的 <a name="…">

提示

这绝对不是一份详尽的参考,因为 Kivy 正在积极开发,自本文编写以来可能已经发布了多个版本,增加了新功能并改进了现有功能。请参考官方网站(kivy.org)上找到的 Kivy 文档,以获取最新的参考手册。

让我们回到我们的项目。为了达到所需的格式(小时加粗,其余文本使用 fn_regular 轻细字体),我们可以使用以下代码:

Label:
    text: '[b]00[/b]:00:00'
    markup: True

Kivy 的 BBCode 风味只有在我们也设置了小部件的 markup 属性为 True 时才有效,如前述代码所示。否则,你将直接在屏幕上看到字符串 [b]…[/b] 被显示出来,这显然不是我们想要的。

注意,如果我们想使整个文本加粗,没有必要将所有内容都放在 [b]…[/b] 标签内;我们只需将小部件的 bold 属性设置为 True。同样的方法也适用于斜体、颜色、字体名称和大小——几乎所有的配置都可以全局设置,从而影响整个小部件,而不需要修改标记。

更改背景颜色

在本节中,我们将调整窗口的背景颜色。窗口背景(OpenGL 渲染器的“清除颜色”)是全局 Window 对象的一个属性。为了更改它,我们在 main.py 中的 __name__ == '__main__' 行之后添加此代码:

from kivy.core.window import Window
from kivy.utils import get_color_from_hex

Window.clearcolor = get_color_from_hex('#101216')

get_color_from_hex 函数并非严格必需,但使用起来很方便,因为它允许我们使用 CSS 风格的 (#RRGGBB) 颜色代替 (R, G, B) 元组来编写代码。而且使用 CSS 颜色至少有以下两个优点:

  • 阅读时的认知负担更小:当你熟悉这种表示法时,#FF0080 值会立即被识别为颜色,而 (255, 0, 128) 只是一组数字,其用途可能因上下文而异。#FF0080 的浮点变体 (1.0, 0.0, 0.50196) 甚至更糟。

  • 简单且明确的搜索:元组可以任意格式化,而 CSS 类似的颜色表示法是统一的,尽管不区分大小写。在大多数文本编辑器中进行不区分大小写的搜索非常简单,相比之下,在漫长的 Python 列表中定位给定元组的所有实例则可能更具挑战性,这可能需要正则表达式等工具,因为元组的格式不需要保持一致。

提示

关于 #RRGGBB 颜色格式的更多信息可以在 Mozilla 开发者网络中找到:developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_started/Color

我们将在稍后讨论 Kivy 的设计相关特性。同时,让我们让我们的应用程序真正显示时间。

使时钟滴答

UI 框架大多是事件驱动的,Kivy 也不例外。与“常规”过程代码的区别很简单——事件驱动代码需要经常返回到主循环;否则,它将无法处理来自用户(如指针移动、点击或窗口调整大小)的事件,界面将“冻结”。如果你是长期使用 Microsoft Windows 的用户,你可能熟悉那些经常无响应和冻结的程序。在我们的应用程序中绝对不能让这种情况发生。

实际上,这意味着我们无法在我们的程序中简单地编写一个无限循环:

# Don't do this
while True:
    update_time()  # some function that displays time
    sleep(1)

从技术上讲,这可能可行,但应用程序的 UI 将保持在“未响应”状态,直到用户或操作系统强制停止应用程序。与其采取这种错误的方法,我们应牢记 Kivy 内部有一个主循环正在运行,我们需要通过利用事件和计时器来利用它。

事件驱动架构还意味着在许多地方,我们将监听事件以响应各种条件,无论是用户输入、网络事件还是超时。

许多程序监听的一个常见事件是 App.on_start。如果在这个应用程序类中定义了具有此名称的方法,那么一旦应用程序完全初始化,该方法就会被调用。另一个在许多程序中都会找到的事件示例是 on_press,当用户点击、轻触或以其他方式与按钮交互时,它会触发。

说到时间和计时器,我们可以使用内置的 Clock 类轻松地安排我们的代码在未来运行。它公开以下静态方法:

  • Clock.schedule_once:在超时后运行一次函数

  • Clock.schedule_interval:定期运行函数

注意

任何有 JavaScript 背景的人都会很容易识别这两个函数。它们与 JS 中的 window.setTimeoutwindow.setInterval 完全一样。实际上,尽管 API 完全不同,Kivy 的编程模型与 JavaScript 非常相似。

重要的是要理解,所有源自 Clock 的定时事件都作为 Kivy 主事件循环的一部分运行。这种方法与线程不同,调度这种阻塞函数可能会阻止其他事件及时或根本无法被调用。

更新屏幕上的时间

要访问包含时间的 Label 小部件,我们将给它一个唯一的标识符(id)。稍后,我们可以根据它们的 id 属性轻松查找小部件——这又是一个与网络开发非常相似的概念。

通过添加以下内容修改 clock.kv

Label:
    id: time

就这些!现在我们可以直接使用root.ids.time的表示法(在我们的例子中是BoxLayout)从我们的代码中访问这个Label小部件。

ClockApp类的更新包括添加一个显示时间的update_time方法,如下所示:

def update_time(self, nap):
    self.root.ids.time.text = strftime('[b]%H[/b]:%M:%S')

现在让我们安排更新函数在程序启动后每秒运行一次:

def on_start(self):
    Clock.schedule_interval(self.update_time, 1)

如果我们现在运行应用程序,我们会看到显示的时间每秒都在更新。用尼尔·阿姆斯特朗的话来说,这是人类迈出的一小步,但对于 Kivy 初学者来说是一大步。

值得注意的是,strftime函数的参数是如何将之前描述的 Kivy 的 BBCode-like 标签与特定函数的 C 样式格式指令结合起来的。对于不熟悉的人来说,这里有一个关于strftime格式化基本内容的快速且不完整的参考:

格式字符串(区分大小写) 结果输出
%S 秒,通常是0059
%M 分钟,从0059
%H 按照 24 小时制的时,从0023
%I 按照 12 小时制的时,从0112
%d 月份中的天数,从0131
%m 月份(数字),从0112
%B 月份(字符串),例如,“十月”
%Y 四位数的年份,例如2016

小贴士

关于显示时间的最完整和最新的文档,请参阅官方参考手册——在这种情况下,Python 标准库参考,位于docs.python.org/

使用属性绑定小部件

我们不仅可以通过 Python 代码为每个需要访问的小部件硬编码一个 ID,还可以在 Kivy 语言文件中创建一个属性并为其赋值。这样做的主要动机是DRY原则和更清晰的命名,代价是代码行数稍微多了些。

这样的属性可以这样定义:

# In main.py
from kivy.properties import ObjectProperty
from kivy.uix.boxlayout import BoxLayout

class ClockLayout(BoxLayout):
    time_prop = ObjectProperty(None)

在这个代码片段中,我们基于BoxLayout为我们的应用程序创建一个新的根小部件类。它有一个自定义属性,time_prop,它将引用我们需要从 Python 代码中引用的Label

此外,在 Kivy 语言文件clock.kv中,我们必须将这个属性绑定到一个相应的id上。自定义属性看起来和行为与默认属性没有区别,并且使用完全相同的语法:

ClockLayout:
    time_prop: time

    Label:
        id: time

这段代码通过使用新定义的属性root.time_prop.text = "demo",使Label小部件从 Python 代码中可访问,而无需知道小部件的 ID。

描述的方法比之前展示的方法更便携,并且消除了在重构时需要保持 Kivy 语言文件中的小部件标识符与 Python 代码同步的需求。否则,选择依赖属性还是通过root.ids从 Python 访问小部件,这是一个编码风格的问题。

在本书的后面部分,我们将探讨 Kivy 属性的更高级用法,这有助于几乎无需费力地进行数据绑定。

布局基础

为了在屏幕上排列小部件,Kivy 提供了一系列Layout类。LayoutWidget的子类,用作其他小部件的容器。每个布局都以独特的方式影响其子元素的位置和大小。

对于这个应用,我们不需要任何花哨的东西,因为所需的用户界面相当直观。这是我们想要实现的目标:

布局基础

完成时钟应用程序界面的布局原型。

为了构建这个,我们将使用BoxLayout,它基本上是一个一维网格。我们已经在clock.kv文件中有了BoxLayout,但由于它只有一个子元素,所以它不会影响任何事情。一个只有一个单元格的矩形网格实际上就是这样一个矩形。

Kivy 布局几乎总是试图填满屏幕,因此我们的应用程序将自动适应任何屏幕大小和方向变化。

如果我们在BoxLayout中添加另一个标签,它将占据一半的屏幕空间,具体取决于方向:垂直盒布局从上到下增长,水平布局从左到右。

你可能已经猜到,为了在垂直布局中创建一排按钮,我们只需将另一个水平盒布局嵌入到第一个布局中即可。布局是小部件,因此它们可以以任意和创造性的方式嵌套,以构建复杂的界面。

完成布局

将三个小部件堆叠到BoxLayout中通常会使每个小部件占据可用大小的三分之一。由于我们不想让按钮与时钟显示相比这么大,我们可以向水平(内部)BoxLayout添加一个height属性,并将其垂直size_hint属性设置为None

size_hint属性是一个包含两个值的元组,影响小部件的宽度和高度。我们将在下一章讨论size_hint对不同布局的影响;现在,我们只需说,如果我们想为宽度或高度使用绝对数字,我们必须相应地将size_hint设置为None;否则,分配大小将不起作用,因为小部件将继续计算它自己的大小而不是使用我们提供的值。

在更新clock.kv文件以考虑计时器显示和控制后,它应该看起来类似于以下内容(注意布局的层次结构):

BoxLayout:
    orientation: 'vertical'

    Label:
        id: time
        text: '[b]00[/b]:00:00'
        font_name: 'Roboto'
        font_size: 60
        markup: True

    BoxLayout:
        height: 90
        orientation: 'horizontal'
        padding: 20
        spacing: 20
        size_hint: (1, None)

        Button:
            text: 'Start'
            font_name: 'Roboto'
            font_size: 25
            bold: True

        Button:
            text: 'Reset'
            font_name: 'Roboto'
            font_size: 25
            bold: True

    Label:
        id: stopwatch
        text: '00:00.[size=40]00[/size]'
        font_name: 'Roboto'
        font_size: 60
        markup: True

如果我们现在运行代码,我们会注意到按钮没有填满BoxLayout内部的所有可用空间。这种效果是通过使用布局的paddingspacing属性实现的。Padding 与 CSS 非常相似,将子元素(在我们的例子中是按钮)从布局的边缘推开,而 spacing 控制相邻子元素之间的距离。这两个属性默认为零,旨在达到最大的小部件密度。

减少重复

这个布局是可行的,但有一个严重的问题:代码非常重复。我们可能想要做的每一个更改都必须在文件中的多个地方进行,很容易错过其中一个,从而引入不一致的更改。

注意

为了继续与网络平台的类比,在 CSS层叠样式表)成为普遍可用之前,样式信息是直接写入围绕文本的标签中的。它看起来像这样:

<p><font face="Helvetica">Part 1</font></p>
<p><font face="Helvetica">Part 2</font></p>

使用这种方法,更改任何单个元素的属性很容易,但调整整个文档外观的属性则需要大量的手动劳动。如果我们想在下一版本的页面上更改字体为 Times,我们就必须搜索并替换 Helvetica 这个词的每个出现,同时确保运行文本中没有这个词,因为它可能偶尔也会被替换。

另一方面,使用样式表,我们将所有的样式信息移动到一个 CSS 规则中:

p {font-family: Helvetica}

现在我们只需要一个地方来处理文档中每个段落的样式;不再需要搜索和替换来更改字体或任何其他视觉属性,如颜色或填充。请注意,我们仍然可以稍微调整单个元素的属性:

<p style="font-family: Times">Part 3</p>

因此,通过实现 CSS,我们没有失去任何东西,实际上没有权衡;这解释了为什么在互联网上采用样式表非常快(特别是考虑到规模)并且非常成功。CSS 到今天仍在广泛使用,没有概念上的变化。

在 Kivy 中,我们不需要为我们的聚合样式或类规则使用不同的文件,就像在网页开发中通常所做的那样。我们只需在 BoxLayout 外部向 clock.kv 文件添加一个如下定义:

<Label>:
    font_name: 'Roboto'
    font_size: 60
    markup: True

这是一个类规则;它的作用类似于之前信息框中描述的 CSS 选择器。每个 Label 都从 <Label> 类规则继承所有属性。(注意尖括号。)

现在,我们可以从每个单独的 Label 中移除 font_namefont_sizemarkup 属性。作为一个一般规则,总是努力将每个重复的定义移动到类中。这是一个众所周知的最佳实践,称为不要重复自己DRY)。像前一个代码片段中所示的变化,在这样一个玩具项目中可能看起来微不足道,但最终会使我们的代码更加整洁和易于维护。

如果我们想覆盖某个小部件的属性,只需像往常一样添加即可。立即属性比从类定义中继承的属性具有优先级。

小贴士

请记住,类定义与在同一个 .kv 文件中定义的小部件完全不同。虽然语法在很大程度上是相同的,但类只是一个抽象定义;它本身不会创建一个新的小部件。因此,如果我们以后不使用它,添加类定义不会对应用程序引入任何更改。

命名类

之前描述的直接方法中存在的一个明显问题是,我们只能有一个名为Label的类。一旦我们需要将不同的属性集应用于同一种类型的控件,我们就必须为它们定义自己的自定义类。此外,覆盖框架的内置类,如LabelButton,可能会在整个应用程序中产生不希望的结果,例如,如果另一个组件正在使用我们底层更改的控件。

幸运的是,这很容易解决。让我们为按钮创建一个命名类,RobotoButton

<RobotoButton@Button>:
    font_name: 'Roboto'
    font_size: 25
    bold: True

@符号之前的部分指定了新的类名,后面跟着我们要扩展的控件类型(在 Python 中,我们会说class RobotoButton(Button):),然后可以使用这个结果类代替通用的Button类:

RobotoButton:
    text: 'Start'

使用类规则允许我们在clock.kv文件中减少重复行的数量,并提供一种一致的方式来使用类定义调整类似的控件。接下来,让我们使用这个功能来自定义所有按钮。

按钮样式

平面 UI 范式的一个较暗的角落是可点击元素的外观,例如按钮;没有普遍接受的方式来设计它们。

例如,现代 UI 风格(以前称为 Metro,如 Windows 8 所示)非常激进,可点击元素看起来主要是单色矩形,几乎没有或没有明显的图形特征。其他供应商,如苹果,使用鲜艳的渐变;添加圆角的趋势也很明显,尤其是在网页设计中,因为 CSS3 提供了专门用于此的语法。微妙的阴影,虽然有些人认为这是异端,但也不是闻所未闻。

在这方面,Kivy 非常灵活。该框架不对视觉施加任何限制,并提供了许多有用的功能来实现您喜欢的任何设计。我们接下来要讨论的一个实用功能是 9-patch 图像缩放,它用于设计可能具有边框的按钮和类似控件。

9-patch 缩放

一个好的缩放算法的动机很简单:几乎不可能为每个按钮提供像素完美的图形,尤其是对于包含(不同数量的)文本的问题按钮。均匀缩放图像很简单实现,但结果最多是平庸的,部分原因是由于长宽比失真。

另一方面,非均匀的 9-patch 缩放产生了无妥协的质量。想法是将图像分成静态和可缩放的部分。以下图像是一个假设的可缩放按钮。中间部分(以黄色显示)是工作区域,其余部分都是边框:

9-patch 缩放

红色区域可以在一个维度上拉伸,而蓝色区域(角落)始终保持完整。这可以从以下屏幕截图中看出:

9-patch 缩放

蓝色显示的角是完全静态的,可能包含几乎所有内容。红色显示的边框在一维(顶部和底部边可以水平拉伸,左侧和右侧边可以垂直拉伸)上是可伸缩的。唯一将均匀缩放的图像部分是内矩形,即工作区域,黄色显示;因此,通常会用单色来绘制它。如果有的话,它还将包含分配给按钮的文本。

使用 9-patch 图像

对于这个教程,我们将使用一个简单的平面按钮,带有 1 像素的边框。我们可以为所有按钮重用这个纹理,或者选择不同的纹理,例如用于重置按钮。以下是一个正常状态下的按钮纹理,具有平面颜色和 1 像素边框:

使用 9-patch 图像

对应于按下状态的纹理——即前一个图像的反转——如下所示:

使用 9-patch 图像

现在,为了应用 9-patch 魔法,我们需要告诉 Kivy 具有限制伸缩性的边框的大小,如前所述(默认情况下,图像将均匀缩放)。让我们回顾一下 clock.kv 文件,并添加以下属性:

<RobotoButton@Button>:
    background_normal: 'button_normal.png'
    background_down: 'button_down.png'
    border: (2, 2, 2, 2)

border 属性的值排序与 CSS 中的顺序相同:上、右、下、左(即从上开始按顺时针方向)。与 CSS 不同,我们不能为所有边提供单个值;至少在当前 Kivy 版本(1.8)中,border: 2 的表示法会导致错误。

小贴士

将所有边框设置为相同值的最短方法是 Python 语法 border: [2] * 4,这意味着取一个包含单个元素 2 的列表,并重复四次。

还要注意,虽然可见的边框只有一像素宽,但我们将自定义按钮的 border 属性分配为 2。这是由于渲染器的纹理拉伸行为:如果“切割线”两边的像素颜色不匹配,结果将是一个渐变,而我们希望是纯色。

在类规则概述中,我们提到在部件实例上声明的属性会优先于具有相同名称的类规则属性。这可以用来选择性地覆盖 background_*border 或任何其他属性,例如,在重用边框宽度定义的同时分配另一个纹理:

RobotoButton:
    text: 'Reset'
    background_normal: 'red_button_normal.png'
    background_down: 'red_button_down.png'

现在我们的按钮已经具有样式,但它们仍然没有任何功能。我们朝着目标迈出的下一步是使计时器工作。

计时

虽然计时器和常规时钟最终都只是显示时间,但在功能上它们完全不同。墙钟是一个严格递增的单调函数,而计时器时间可以被暂停和重置,减少计数器。更实际地说,区别在于操作系统可以轻松地将其内部墙钟暴露给 Python,无论是直接作为datetime对象,还是在strftime()函数的情况下透明地暴露。后者可以在没有datetime参数的情况下调用,以格式化当前时间,这正是我们需要用于墙钟显示的。

对于创建计时器的任务,我们首先需要构建自己的非单调时间计数器。这很容易实现,因为我们不需要使用 Python 的时间函数,多亏了 Kivy 的Clock.schedule_interval事件处理器,它接受调用之间的时间差作为参数。这正是以下代码中nap参数的作用:

def on_start(self):
    Clock.schedule_interval(self.update, 0.016)

def update(self, nap):
    pass

时间以秒为单位,也就是说,如果应用程序以 60 fps 运行并且每帧调用我们的函数,平均睡眠时间将是60^(−1)* = 0.016(6)*。

使用此参数后,跟踪经过的时间变得简单,可以通过简单的增量来实现:

class ClockApp(App):
    sw_seconds = 0

    def update(self, nap):
        self.sw_seconds += nap

我们刚刚创建的这个计时器,按照定义并不是一个计时器,因为现在用户实际上无法停止它。然而,让我们首先更新显示以递增的时间,这样我们就可以在实现它们时立即看到控制的效果。

格式化计时器的时间

对于主要的时间显示,格式化很简单,因为标准库函数strftime为我们提供了一系列现成的原语,可以将datetime对象转换为可读的字符串表示,根据提供的格式字符串。

此函数有一些限制:

  • 它只接受 Python datetime对象(而计时器我们只有经过的浮点秒数sw_seconds

  • 它没有为秒的小数部分提供格式化指令

前面的datetime限制可以很容易地规避:我们可以将我们的sw_seconds变量转换为datetime。但后者的不足使得这变得不必要,因为我们希望我们的表示以秒的分数结束(精确到 0.01 秒),所以strftime格式化就不够了。因此,我们实现自己的时间格式化。

计算值

首先,我们需要计算必要的值:分钟、秒和秒的分数。数学很简单;以下是计算分钟和秒的一行代码:

minutes, seconds = divmod(self.sw_seconds, 60)

注意使用divmod函数。这是一个简写,相当于以下内容:

minutes = self.sw_seconds / 60
seconds = self.sw_seconds % 60

虽然更简洁,但divmod版本在大多数 Python 解释器上也应该表现得更好,因为它只执行一次除法。在今天的机器上,浮点除法非常有效,但如果我们在每一帧运行大量此类操作,如视频游戏或模拟,CPU 时间将迅速增加。

小贴士

通常,作者倾向于不同意关于过早优化是邪恶的常见格言;许多导致性能不佳和标准不高的不良做法可以并且应该很容易避免,而不会影响代码质量,不这样做无疑是过早的悲观化。

还要注意,minutesseconds 的值仍然是浮点数,因此在我们打印之前需要将它们转换为整数:int(minutes)int(seconds)

现在只剩下百分之一秒;我们可以这样计算它们:

int(seconds * 100 % 100)

放置停表

我们已经拥有了所有值;让我们将它们组合起来。在 Python 中格式化字符串是一项相当常见的任务,与 Python 的 Zen 命令“应该有一个——最好是只有一个——明显的做法来做这件事”(www.python.org/dev/peps/pep-0020/)相反,存在几种常见的字符串格式化惯用法。我们将使用其中最简单的一种,即操作符 %,它在某种程度上类似于在其他编程语言中常见的 sprintf() 函数:

def update_time(self, nap):
    self.sw_seconds += nap
    minutes, seconds = divmod(self.sw_seconds, 60)
    self.root.ids.stopwatch.text = (
        '%02d:%02d.[size=40]%02d[/size]' %
        (int(minutes), int(seconds),
         int(seconds * 100 % 100)))

由于我们现在有了秒的分数,之前使用的 1 fps 刷新频率已经不再足够。让我们将其设置为 0,这样 update_time 函数就会在每一帧被调用:

Clock.schedule_interval(self.update_time, 0)

小贴士

今天,大多数显示器以 60 fps 的刷新率运行,而我们的值精确到 1/100 秒,即每秒变化 100 次。虽然我们可以尝试以正好 100 fps 的速度运行我们的函数,但完全没有必要这样做:对于用户来说,在常见的硬件上不可能看到差异,因为显示器的更新频率最多也只有每秒 60 次。

话虽如此,大多数时候你的代码应该独立于帧率工作,因为它依赖于用户的硬件,而且无法预测应用程序最终会运行在什么机器上。即使今天的智能手机也有截然不同的系统规格和性能,更不用说笔记本电脑和台式计算机了。

就这样;如果我们现在运行应用程序,我们会看到一个递增的计数器。它目前还没有交互性,这将是我们的下一个目标。

停表控制

通过按钮按下事件控制应用程序非常简单。我们只需要使用以下代码来实现这一点:

def start_stop(self):
    self.root.ids.start_stop.text = ('Start'
        if self.sw_started else 'Stop')
    self.sw_started = not self.sw_started

def reset(self):
    if self.sw_started:
        self.root.ids.start_stop.text = 'Start'
        self.sw_started = False
    self.sw_seconds = 0

第一个事件处理程序是为 开始停止 按钮的。它改变状态(sw_started)和按钮标题。第二个处理程序将一切恢复到初始状态。

我们还需要添加状态属性来跟踪停表是正在运行还是暂停:

class ClockApp(App):
    sw_started = False
    sw_seconds = 0

    def update_clock(self, nap):
        if self.sw_started:
            self.sw_seconds += nap

我们修改 update_clock 函数,使其仅在停表开始时增加 sw_seconds,即 sw_started 被设置为 True。最初,停表没有开始。

clock.kv 文件中,我们将这些新方法绑定到 on_press 事件:

RobotoButton:
    id: start_stop
    text: 'Start'
    on_press: app.start_stop()

RobotoButton:
    id: reset
    text: 'Reset'
    on_press: app.reset()

小贴士

在 Kivy 语言中,我们有几个上下文相关的引用可供使用。它们如下:

  • self:这始终指当前小部件;

  • root:这是给定作用域的最外层小部件;

  • app:这是应用程序类实例。

如您所见,实现按钮的事件处理并不困难。到目前为止,我们的应用程序提供了与计时器的交互,使用户能够启动、停止和重置它。为了本教程的目的,我们已经完成了。

摘要

在本章中,我们构建了一个功能性的 Kivy 应用程序,准备部署到例如 Google Play 或另一个应用商店供公众使用。这需要一些额外的工作,打包过程是平台特定的,但最困难的部分——编程——已经完成。

通过时钟应用程序,我们成功地展示了 Kivy 应用程序开发周期的许多方面,而没有使代码变得不必要地冗长或复杂。保持代码简短和简洁是框架的一个主要特点,因为它使我们能够快速地进行实验和迭代。能够以极少的旧代码阻碍,实现新的功能部分是无价的。Kivy 确实符合其作为快速应用程序开发库的描述。

在本书(以及 Kivy 开发总体上)中,我们将遇到的一个普遍原则是,我们的程序和 Kivy 都不是孤立存在的;我们始终拥有整个平台可供使用,包括丰富的 Python 标准库,以及从 Python“奶酪店”——位于pypi.python.orgPython 包索引PyPI)以及其他地方可用的许多其他库,以及底层的操作系统服务。

我们还可以轻松地重新配置许多面向 Web 开发的资产,重用来自 CSS 框架(如 Bootstrap)的字体、颜色和形状。并且无论如何,都应该看看谷歌的材料设计原则——这不仅仅是一组设计资产,而是一本完整的指南,它使我们能够在不牺牲应用程序的个性或“性格”的情况下,实现一致且美观的用户界面。

当然,这仅仅是开始。本书中本章简要讨论的许多功能将在后面的章节中更深入地探讨。

第二章:构建绘画应用程序

在第一章《构建时钟应用程序》中,我们使用 Kivy 的标准组件:布局、文本标签和按钮构建了一个应用程序。我们能够在保持非常高层次抽象的同时显著自定义这些组件的外观——使用完整的控件,而不是单个图形原语。这对于某些类型的应用程序来说很方便,但并不总是理想的,并且正如你很快就会看到的,Kivy 框架还提供了用于较低层次抽象的工具:绘制点和线。

我认为,通过构建绘画应用程序来玩自由形式的图形是最好的方式。我们的应用程序完成时,将与 Windows 操作系统捆绑的 MS Paint 应用程序有些相似。

与 Microsoft Paint 不同,我们的 Kivy Paint 应用程序将完全跨平台,包括运行 Android 和 iOS 的移动设备。此外,我们还将故意省略“真实”软件中常见的许多图像处理功能,例如矩形选择、图层和将文件保存到磁盘。实现它们可以成为你的一项良好练习。

小贴士

关于移动设备:虽然使用 Kivy 构建 iOS 应用程序当然可能,但如果你没有 iOS 或 Kivy 开发的经验,这仍然是非平凡的。因此,建议你首先为易于使用的平台编写代码,这样你可以快速更新你的代码并运行应用程序,而无需构建二进制文件等。在这方面,由于 Kivy Launcher,Android 开发要简单得多,它是一个通用的环境,可以在 Android 上运行 Kivy 应用程序。它可在 Google Play 上找到,网址为 play.google.com/store/apps/details?id=org.kivy.pygame

能够立即启动和测试你的应用程序而不需要编译,这是 Kivy 开发中一个极其重要的方面。这使得程序员能够快速迭代并现场评估可能的解决方案,这对于快速应用开发(RAD)和整体敏捷方法至关重要。

除了窗口大小调整(这在移动设备上不太常用)之外,Kivy 应用程序在各种移动和桌面平台上表现相似。因此,在发布周期后期之前,完全有可能只编写和调试桌面或 Android 版本的程序,然后填补任何兼容性差距。

我们还将探讨 Kivy 应用程序提供的两个独特且几乎相互排斥的功能:多指控制,适用于触摸屏设备,以及在桌面计算机上更改鼠标指针。

坚持其以移动端为先的方法,Kivy 提供了一个多触控输入的模拟层,可以用鼠标使用。它可以通过右键点击触发。然而,这种多触控模拟并不适合任何实际应用,除了调试;当在桌面运行时,它将在应用的生成版本中被关闭。

这就是本章结束时我们的应用将看起来像这样:

构建绘画应用

Kivy 绘画应用,绘画工具另行销售

设置舞台

初始时,我们应用的全部表面都被根小部件占据,在这种情况下,那就是用户可以绘画的画布。我们不会在稍后为工具区域分配任何屏幕空间。

如你所知,根小部件是层次结构中最外层的小部件。每个 Kivy 应用都有一个,它可以基本上是任何东西,取决于期望的行为。如第一章中所示,构建时钟应用BoxLayout是一个合适的选择作为根小部件;由于我们没有对它有额外的要求,布局被设计成作为其他控制容器的功能。

在绘画应用的情况下,我们需要其根小部件满足更多有趣的要求;用户应该能够画线,可能利用多触控功能,如果可用。目前,Kivy 没有内置的控制适合这项任务,因此我们需要自己创建。

构建新的 Kivy 小部件很简单。一旦我们的类从 Kivy 的Widget类继承,我们就可以开始了。所以,最简单的没有特殊功能的自定义小部件,以及使用它的小程序,可以像这样实现:

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

class CanvasWidget(Widget):
    pass

class PaintApp(App):
    def build(self):
        return CanvasWidget()

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

这是我们的绘画应用起始点的完整列表,main.py,包括PaintApp类。在未来的章节中,我们将省略像这样的简单样板代码;这个例子提供是为了完整性。

小贴士

Widget类通常作为基类,就像 Python 中的object或 Java 中的Object。虽然可以在应用中使用它“原样”,但Widget本身非常有限。它没有视觉外观,也没有在程序中立即有用的属性。另一方面,继承Widget相当直接且在许多不同场景中很有用。

调整外观

首先,让我们调整我们应用的外观。这并不是一个关键的功能,但请耐心,因为这些定制通常被请求,而且设置起来也很容易。我将简要描述我们在上一章中覆盖的属性,并添加一些新的调整,例如窗口大小和鼠标光标的更改。

视觉外观

我坚信,任何绘图应用程序的背景颜色最初应该是白色的。你可能已经从第一章中熟悉了这个设置。以下是我们在__name__ == '__main__'行之后添加的代码行,以实现所需的效果:

from kivy.core.window import Window
from kivy.utils import get_color_from_hex

Window.clearcolor = get_color_from_hex('#FFFFFF')

你可能希望将大多数import行放在它们通常所在的位置,即在程序文件的开始附近。正如你很快就会学到的,Kivy 中的某些导入实际上是顺序相关的,并且有副作用,最值得注意的是Window对象。在行为良好的 Python 程序中很少出现这种情况,导入语句的副作用通常被认为是不良的应用程序设计。

窗口大小

桌面应用程序的另一个常见调整属性是窗口大小。以下更改对移动设备将完全没有影响。

值得注意的是,默认情况下,桌面上的 Kivy 窗口可以被最终用户调整大小。我们将在稍后学习如何禁用此功能(仅为了完整性;通常这不是一个好主意)。

小贴士

当你针对已知规格的移动设备时,以编程方式设置窗口大小也是一个方便的做法。这允许你使用目标设备的正确屏幕分辨率在桌面上测试应用程序。

要分配初始窗口大小,将下一代码片段插入到读取from kivy.core.window import Window的行之上。在Window对象导入之前应用这些设置至关重要;否则,它们将没有任何效果:

from kivy.config import Config

Config.set('graphics', 'width', '960')
Config.set('graphics', 'height', '540')  # 16:9

此外,你可能还想通过添加以下一行来禁用窗口调整大小:

Config.set('graphics', 'resizable', '0')

请除非你有非常充分的理由,否则不要这样做,因为从用户那里移除这些微不足道的定制通常是一个坏主意,并且很容易破坏整体用户体验。仅在一个分辨率下构建像素完美的应用程序很有吸引力,但许多客户(尤其是移动用户)可能不会满意。另一方面,Kivy 布局使得构建可伸缩界面变得可行。

鼠标光标

下一个通常仅适用于桌面应用程序的定制选项是更改鼠标指针。Kivy 没有对此进行抽象,因此我们将在一个较低级别上工作,直接从 Pygame 导入和调用方法,Pygame 是基于 SDL 的窗口和 OpenGL 上下文提供者,通常在桌面平台上被 Kivy 使用。

如果你选择实现此代码,应始终有条件地运行。大多数移动设备和一些桌面应用程序不会有 Pygame 窗口,我们当然希望避免因为鼠标光标这样的琐碎且非必要的事情而导致程序崩溃。

简而言之,这是 Pygame 使用的鼠标指针格式:

鼠标光标

用于描述自定义鼠标指针的 ASCII 格式

此记法中的每个字符代表一个像素:'@' 是黑色,'-' 是白色;其余的都是透明的。所有行都必须具有相同的宽度,且能被八整除(这是底层 SDL 实现强加的限制)。

当在应用程序中使用时,它应该看起来像下一张截图所示(图像被显著放大,显然):

鼠标光标

Kivy Paint 应用程序的鼠标光标:一个十字准星

注意

然而,在撰写本文时,某些操作系统普遍可用的 Pygame 版本在 pygame.cursors.compile() 函数中存在一个在白色和黑色之间切换的漏洞。检测受影响的 Pygame 版本是不切实际的,所以我们将在代码中包含正确工作的函数,而不会调用可能存在漏洞的同名函数版本。

正确的函数 pygame_compile_cursor(),它将 Pygame 的鼠标光标定义转换为 Simple DirectMedia LayerSDL)所期望的,Pygame 的后端库,可在网上找到:goo.gl/2KaepD

现在,为了将生成的光标实际应用到应用程序窗口中,我们将用以下代码替换 PaintApp.build 方法:

from kivy.base import EventLoop
class PaintApp(App):
    def build(self):
        EventLoop.ensure_window()
        if EventLoop.window.__class__.__name__.endswith('Pygame'):
            try:
                from pygame import mouse
                # pygame_compile_cursor is a fixed version of
                # pygame.cursors.compile
                a, b = pygame_compile_cursor()
                mouse.set_cursor((24, 24), (9, 9), a, b)
            except:
                pass

        return CanvasWidget()

代码相当直接,但其中的一些方面可能需要解释。以下是一个快速浏览:

  • EventLoop.ensure_window():此函数调用会阻塞执行,直到我们有了应用程序窗口(EventLoop.window)。

  • if EventLoop.window.__class__.__name__.endswith('Pygame'):此条件检查窗口类名(不是对代码进行断言的最佳方式,但在这个情况下有效)。我们只想为特定的窗口提供者运行我们的鼠标光标定制代码,在这种情况下,是 Pygame。

  • 代码的剩余部分,包含在 try ... except 块中,是 Pygame 特定的 mouse.set_cursor 调用。

  • 变量 ab 构成了 SDL 使用的鼠标内部表示,即 XOR 和 AND 掩码。它们是二进制的,应被视为 SDL 的一个不透明的实现细节。

注意

如同往常,请参阅官方参考手册以获取完整的 API 规范。Pygame 文档可在 www.pygame.org 找到。

当我们在比 Kivy 更低的抽象级别工作时,这种整个情况并不常见,但无论如何,不要害怕有时深入研究实现细节。因为 Kivy 不提供对这些事物的有意义的抽象,所以可以实现许多只有底层库才能实现的事情。这尤其适用于非跨平台功能,如操作系统依赖的应用程序互操作性、通知服务等等。

再次强调,此图总结了在此特定情况下设置鼠标指针时我们遍历的抽象级别:

鼠标光标

Kivy、Pygame、SDL 和底层操作系统抽象之间的关系

幸运的是,我们不必直接与操作系统交互——跨平台功能可能很难正确实现。这正是 SDL 所做的事情。

注意

虽然我们不直接与 SDL 交互,但你可能仍然想查看位于www.libsdl.org/的文档——这将为你提供关于 Kivy 最终依赖的低级 API 调用的视角。

多指模拟

默认情况下,当在桌面系统上运行时,Kivy 为多指操作提供了一种模拟模式。它通过右键点击激活,并生成永久触摸,以半透明的红色点形式渲染;同时也可以在按住右鼠标按钮的情况下拖动。

此功能对于调试可能很有用,尤其是在你没有真实的多指触摸设备进行测试时;另一方面,用户不会期望将此功能绑定到右键点击。可能最好禁用此功能,以免我们的用户被这种不太有用或明显的模拟模式所困惑。为此,请在初始化序列中添加以下内容:

Config.set('input', 'mouse', 'mouse,disable_multitouch')

如果你在开发过程中实际上使用此功能进行调试,可以在此时将其条件化(或暂时注释掉)。

绘图触摸

为了说明一个可能的触摸输入响应场景,让我们每次用户触摸(或点击)屏幕时都画一个圆。

Widget有一个on_touch_down事件,这对于这项任务非常有用。目前我们只对每个触摸的坐标感兴趣,它们可以通过以下方式访问:

class CanvasWidget(Widget):
    def on_touch_down(self, touch):
        print(touch.x, touch.y)

此示例在触摸发生时打印触摸的位置。要在屏幕上绘制某些内容,我们将使用Widget.canvas属性。Kivy 的Canvas是一个逻辑可绘制表面,它抽象化了底层的 OpenGL 渲染器。与低级图形 API 不同,画布是状态化的,并保留了添加到其中的绘图指令。

谈及绘图原语,许多都可以从kivy.graphics包中导入。绘图指令的例子包括ColorLineRectangleBezier等。

对画布的简要介绍

Canvas API 可以直接调用,也可以使用with关键字作为上下文处理器。简单的(直接)调用如下所示:

self.canvas.add(Line(circle=(touch.x, touch.y, 25)))

这会将一个带有参数的Line原语添加到图形指令队列中。

小贴士

如果你想要立即尝试这段代码,请参阅下一节,在屏幕上显示触摸,以获取在 Paint 应用上下文中使用画布指令的更全面的示例。

使用上下文处理器通常看起来更美观,也更简洁,尤其是在应用多个指令时。以下示例展示了这一点,其功能与之前的self.canvas.add()代码片段等效:

with self.canvas:
    Line(circle=(touch.x, touch.y, 25))

这可能比直接方法更难理解。选择要使用的代码风格是个人偏好的问题,因为它们达到相同的效果。

注意,如前所述,每个后续调用都会添加到画布中,而不会影响之前应用的指令;在核心上,画布是一个随着每次将表面渲染到屏幕上而增长的指令数组。请记住:我们旨在达到 60 fps 的刷新率,我们当然不希望这个列表无限增长。

例如,一种在即时模式渲染表面(如 HTML5 的<canvas>)上正确工作的编码实践是通过用背景色覆盖来擦除之前绘制的图形。这在浏览器中相当直观且工作正常:

// JavaScript code for clearing the canvas
canvas.rect(0, 0, width, height)
canvas.fillStyle = '#FFFFFF'
canvas.fill()

相反,在 Kivy 中,这种模式仍然只是添加绘图指令;它首先渲染所有现有的原语,然后用矩形覆盖它们。这看起来几乎正确(画布在视觉上是空的),但做的是错误的事情:

# Same code as JavaScript above. This is wrong, don't do it!
with self.canvas:
    Color(1, 1, 1)
    Rectangle(pos=self.pos, size=self.size)

提示

就像内存泄漏一样,这个错误可能长时间不被注意,悄无声息地积累渲染指令并降低性能。多亏了今天设备中强大的显卡,包括智能手机,渲染通常非常快。所以在调试时很难意识到存在开销。

为了在 Kivy 中正确清除画布(即移除所有绘图指令),你应该使用本章后面展示的canvas.clear()方法。

显示屏幕上的触摸

我们将很快实现一个清除屏幕的按钮;在此期间,让我们显示屏幕上的触摸。我们移除了对print()的调用,并在CanvasWidget类定义中添加了以下方法:

class CanvasWidget(Widget):
    def on_touch_down(self, touch):
        with self.canvas:
            Color(*get_color_from_hex('#0080FF80'))
            Line(circle=(touch.x, touch.y, 25), width=4)

这会在我们的小部件接收到的每个触摸周围绘制一个空心的圆。Color指令设置了以下Line原语的颜色。

注意

注意,颜色格式(此处为#RRGGBBAA)并不严格遵循 CSS 规范,因为它有第四个组成部分,即 alpha 通道(透明度)。这种语法变化应该是显而易见的。它类似于例如在别处常见的rgb()rgba()表示法。

你可能也注意到了我们在这里如何非常不寻常地使用Line,绘制的是圆而不是直线。许多 Kivy 图形原语都像这样强大。例如,任何画布指令,如RectangleTriangle原语,都可以通过source参数渲染背景图像。

如果你正在跟随,到目前为止的结果应该如下所示:

显示屏幕上的触摸

显示屏幕上的触摸

到目前为止的完整源代码,用于生成前面的演示,如下所示:

# In main.py
from kivy.app import App
from kivy.config import Config
from kivy.graphics import Color, Line
from kivy.uix.widget import Widget
from kivy.utils import get_color_from_hex

class CanvasWidget(Widget):
    def on_touch_down(self, touch):
        with self.canvas:
            Color(*get_color_from_hex('#0080FF80'))
            Line(circle=(touch.x, touch.y, 25), width=4)

class PaintApp(App):
    def build(self):
        return CanvasWidget()

if __name__ == '__main__':
    Config.set('graphics', 'width', '400')
    Config.set('graphics', 'height', '400')
    Config.set('input', 'mouse',
               'mouse,disable_multitouch')

    from kivy.core.window import Window
    Window.clearcolor = get_color_from_hex('#FFFFFF')

    PaintApp().run()

为了使示例代码简洁,我们排除了非必要的鼠标光标部分。在此阶段,伴随的 Kivy 语言文件 paint.kv 完全缺失——相反,应用类中的 build() 方法返回根小部件。

注意 import Window 行的异常位置。这是由于前面已经提到的该特定模块的副作用。Config.set() 调用应在此 import 语句之前进行,才能产生任何效果。

接下来,我们将向我们的小程序添加更多功能,使其与期望的绘图应用行为保持一致。

清除屏幕

目前,清除我们小程序屏幕的唯一方法是重新启动它。让我们向我们的 UI 添加一个按钮,用于从画布中删除所有内容,目前这个 UI 非常简约。我们将重用之前应用的按钮外观,因此在主题方面不会有任何新变化;有趣的部分在于定位。

在我们的第一个程序中,第一章 的时钟应用,构建时钟应用,我们没有进行任何显式的定位,因为所有内容都是由嵌套的 BoxLayouts 维持位置的。然而,现在我们的程序没有布局,因为根小部件就是我们的 CanvasWidget,我们没有实现任何定位其子部件的逻辑。

在 Kivy 中,没有显式布局意味着每个小部件都完全控制其位置和大小(这在许多其他 UI 工具包中几乎是默认状态,例如 Delphi、Visual Basic 等)。

要将新创建的删除按钮定位到右上角,我们进行以下操作:

# In paint.kv
<CanvasWidget>:
    Button:
        text: 'Delete'
        right: root.right
        top: root.top
        width: 80
        height: 40

这是一个属性绑定,表示按钮的 righttop 属性应与根小部件的属性相应地保持同步。我们也可以在这里进行数学运算,例如 root.top – 20。其余部分相当直接,因为 widthheight 是绝对值。

还要注意,我们没有为 <CanvasWidget> 定义一个类规则,而没有指定其超类。这是因为这次我们正在扩展之前在 Python 代码中定义的具有相同名称的现有类。Kivy 允许我们增强所有现有的小部件类,包括内置的,如 <Button><Label>,以及自定义的。

这说明了使用 Kivy 语言描述对象视觉属性的一种常见良好实践。同时,最好将所有程序流程结构,如事件处理程序,保持在 Python 的一侧。这种关注点的分离使得 Python 源代码及其相应的 Kivy 语言对应物都更容易阅读和跟踪。

传递事件

如果你一直跟随着这个教程并且已经尝试点击按钮,你可能已经注意到(甚至猜到)它不起作用。它没有做任何有用的事情显然是因为缺少即将要实现的点击处理器。更有趣的是,点击根本无法穿透,因为没有视觉反馈;相反,通常的半透明圆圈被画在按钮上,这就是全部。

这种奇怪的效果发生是因为我们在 CanvasWidget.on_touch_down 处理器中处理了所有的触摸事件,而没有将它们传递给子元素,因此它们无法做出反应。与 HTML 的 文档对象模型 (DOM) 不同,Kivy 中的事件不是从嵌套元素向上冒泡到其父元素。它们是相反的方向,从父元素向下到子元素,也就是说,如果父元素将它们传递出去,而它没有这么做。

这可以通过明确执行以下类似代码来修复:

# Caution: suboptimal approach!
def on_touch_down(self, touch):
    for widget in self.children:
        widget.on_touch_down(touch)

实际上,这基本上就是默认行为 (Widget.on_touch_down) 已经做的事情,所以我们不妨调用它,使代码更加简洁,如下所示:

def on_touch_down(self, touch):
    if Widget.on_touch_down(self, touch):
        return

默认的 on_touch_down 处理器如果事件实际上以有意义的方式被处理,也会返回 True。触摸按钮会返回 True,因为按钮会对其做出反应,至少会改变其外观。这正是我们需要来取消我们自己的事件处理,这在当前情况下相当于绘制圆圈,因此方法中的第二行有 return 语句。

清除画布

现在我们转向最简单也是最实用的 删除 按钮部分——一个触摸处理器,它可以擦除一切。清除画布相当简单,所以为了使这个功能工作,我们需要做的所有事情都在这里。是的,总共只有两行代码:

def clear_canvas(self):
    self.canvas.clear()

不要忘记将此方法作为事件处理器添加到 paint.kv 文件中:

Button:
    on_release: root.clear_canvas()

它可以工作,但同时也移除了 删除 按钮本身!这是因为按钮是 CanvasWidget 的子元素(自然地,因为 CanvasWidget 是根元素,所有元素都是它的直接或间接子元素)。虽然按钮本身没有被删除(点击它仍然会清除屏幕),但其画布(Button.canvas)从 CanvasWidget.canvas.children 层级中移除,因此不再渲染。

解决这个问题非常直接的方法如下:

def clear_canvas(self):
    self.canvas.clear()
    self.canvas.children = [widget.canvas
                            for widget in self.children]

然而,这样做并不好,因为小部件可能会进行自己的初始化并按不同的方式排列。解决这个问题的更好方法是执行以下操作:

  1. 从“有罪”的元素(在这种情况下是 CanvasWidget)中移除所有子元素。

  2. 清除画布。

  3. 最后,重新添加子元素,以便它们可以正确地初始化渲染。

代码的修订版本稍微长一些,但工作正常且更健壮:

class CanvasWidget(Widget):
    def clear_canvas(self):
        saved = self.children[:]  # See below
        self.clear_widgets()
        self.canvas.clear()
        for widget in saved:
            self.add_widget(widget)

一条可能需要解释的行是saved = self.children[:]表达式。[:]操作是一个数组复制(字面上,“创建一个包含这些相同元素的新数组”)。如果我们写成saved = self.children,这意味着我们正在复制一个数组的指针;稍后,当我们调用self.clear_widgets()时,它将从self.childrensaved中删除所有内容,因为它们在内存中引用的是同一个对象。这就是为什么需要self.children[:]。(我们刚才讨论的行为是 Python 的工作方式,并且与 Kivy 无关。)

注意

如果你对 Python 中的切片语法不熟悉,请参阅 StackOverflow 论坛上的stackoverflow.com/questions/509211以获取示例。

在这个阶段,我们已经在某种程度上可以用蓝色气泡来绘画,如下面的截图所示。这显然不是我们绘画应用的最终行为,所以请继续阅读下一节,我们将使其能够绘制实际的线条。

清除画布

删除按钮的全貌及其令人敬畏的荣耀。还有,用圆形“画笔”绘画

连接点

我们的应用程序已经有了清除屏幕的功能,但仍然只绘制圆圈。让我们改变它,以便我们可以绘制线条。

为了跟踪连续的触摸事件(点击并拖动),我们需要添加一个新的事件监听器,on_touch_move。每次回调被调用时,它都会收到事件发生的最新位置。

如果我们每一刻只有一条线(就像在桌面上的典型做法一样,因为无论如何只有一个鼠标指针),我们就可以在self.current_line中保存我们正在绘制的线。但由于我们从一开始就旨在支持多点触控,我们将采取另一种方法,并将每条正在绘制的线存储在相应的touch变量中。

这之所以有效,是因为对于从开始到结束的每一次连续触摸,所有回调都接收相同的touch对象。还有一个touch.ud属性,其类型为dict(其中ud是用户数据的缩写),它专门用于在事件处理程序调用之间保持触摸特定的属性。最初,touch.ud属性是一个空的 Python 字典,{}

我们接下来要做的就是:

  • on_touch_down处理程序中,创建一条新线并将其存储在touch.ud字典中。这次,我们将使用普通的直线而不是我们之前用来说明单个触摸会落在何处的那种花哨的圆形线条。

  • on_touch_move中,将一个新的点添加到对应线的末尾。我们正在添加一条直线段,但由于事件处理程序每秒将被调用多次,最终结果将是一系列非常短的段,但看起来仍然相当平滑。

小贴士

更高级的图形程序正在使用复杂的算法来使线条看起来像是绘制在真实的物理表面上。这包括使用贝塞尔曲线使线条即使在高分辨率下也看起来无缝,以及从指针移动的速度或压力中推断线条的粗细。我们在这里不会实现这些,因为它们与 Kivy 无关,但将这些技术添加到最终的 Paint 应用程序中可能对读者来说是一项很好的练习。

代码,正如我们刚才所描述的,列示如下:

from kivy.graphics import Color, Line

class CanvasWidget(Widget):
    def on_touch_down(self, touch):
        if Widget.on_touch_down(self, touch):
            return

        with self.canvas:
            Color(*get_color_from_hex('#0080FF80'))
            touch.ud['current_line'] = Line(
                points=(touch.x, touch.y), width=2)

    def on_touch_move(self, touch):
        if 'current_line' in touch.ud:
            touch.ud['current_line'].points += (touch.x, touch.y)

这种简单的方法是有效的,我们能够在画布上绘制无聊的蓝色线条。现在让我们给用户选择颜色的能力,然后我们就更接近一个真正有用的绘画应用程序了。

颜色调色板

每个绘画程序都附带一个调色板来选择颜色,在我们到达本节的结尾时,我们的也不例外,很快就会实现。

从概念上讲,调色板只是可用的颜色列表,以易于选择正确颜色的方式呈现。在一个完整的图像编辑器中,它通常包括系统上可用的所有颜色(通常是完整的 24 位真彩色或 16777216 种独特的颜色)。这种包含所有颜色的调色板的常规表示通常如下所示:

颜色调色板

真彩色调色板窗口的插图

另一方面,如果我们不想与流行的专有图像编辑应用程序竞争,我们不妨提供有限的调色板选择。对于在图形方面几乎没有背景的人来说,这甚至可能构成竞争优势——选择看起来搭配得当的颜色是困难的。正是出于这个原因,互联网上有一些调色板可以普遍用于 UI 和图形设计。

在这个教程中,我们将使用 Flat UI 风格指南(可在designmodo.github.io/Flat-UI/找到),它基于一组精心挑选的颜色,这些颜色搭配在一起效果很好。或者,你也可以自由选择你喜欢的任何其他调色板,这纯粹是审美偏好。

注意

在颜色领域有很多东西要学习,尤其是颜色兼容性和适合特定任务。低对比度的组合可能非常适合装饰元素或大标题,但对于主要文章的文字来说则不够;然而,出人意料的是,非常高的对比度,如白色与黑色,对眼睛来说并不容易,而且很快就会使眼睛疲劳。

因此,关于颜色的一个很好的经验法则是,除非你对你的艺术技能绝对自信,否则最好坚持使用由他人成功使用的既定调色板。一个好的开始是从你最喜欢的操作系统或桌面环境的指南开始。以下是一些例子:

有许多更多适用于各种任务的调色板可供选择——如果您感兴趣,可以检查 Google,或者在您最喜欢的操作系统和程序上使用颜色选择器。

子类化按钮

由于我们正在寻找一个相对较短的固定颜色列表,因此最适合表示此类列表的用户界面控件可能是切换或单选按钮。Kivy 的ToggleButton非常适合这项任务,但它有一个不幸的限制:在切换组中,所有按钮可能一次全部取消选中。这意味着在绘图应用程序的上下文中,没有选择任何颜色。(在这种情况下,一个可能的选项是回退到默认颜色,但这可能会让用户感到惊讶,所以我们不会采取这种方法。)

好消息是,凭借 Python 的OOP(面向对象编程)功能,我们可以轻松地子类化ToggleButton并修改其行为以完成我们需要的任务,即禁止取消选中当前选中的按钮。在此调整之后,将始终只选择一个颜色。

在此情况下,子类化还将实现另一个目标:对于一个调色板,我们希望每个按钮都涂上其独特的颜色。虽然我们当然可以使用之前用于为按钮分配背景图像的技术,但这将需要我们制作大量的不同背景图像。相反,我们将使用背景颜色属性,该属性可以从paint.kv文件中分配。

这种架构允许我们在paint.kv文件中保持调色板定义的非常可读的声明性形式,同时将实现细节从我们的方式中排除在子类中——这正是面向对象程序应有的样子。

取消取消选择的能力

首先,让我们创建不能同时全部取消选中的切换按钮。

为了说明问题(并创建将作为起点的基础实现),让我们使用标准的 Kivy ToggleButton小部件实现所需的 UI。这部分完全是声明性的;让我们只需将以下代码添加到paint.kv文件的<CanvasWidget>部分底部:

BoxLayout:
    orientation: 'horizontal'
    padding: 3
    spacing: 3
    x: 0
    y: 0
    width: root.width
    height: 40

    ToggleButton:
        group: 'color'
        text: 'Red'

    ToggleButton:
        group: 'color'
        text: 'Blue'
        state: 'down'

我们在这里使用熟悉的 BoxLayout 组件,作为单个颜色按钮的工具栏。布局小部件本身被绝对定位,xy 都设置为 0(即左下角),占据 CanvasWidget 的全部宽度。

每个 ToggleButton 都属于同一个组,'color',这样最多只能同时选中其中一个(state: 'down')。

覆盖标准行为

如前所述,内置的 ToggleButton 行为并不完全是我们需要的单选按钮;如果你点击已选中的按钮,它将被取消选中,整个切换组将没有选中的元素。

为了解决这个问题,让我们按照以下方式子类化 ToggleButton

from kivy.uix.behaviors import ToggleButtonBehavior
from kivy.uix.togglebutton import ToggleButton

class RadioButton(ToggleButton):
    def _do_press(self):
        if self.state == 'normal':
            ToggleButtonBehavior._do_press(self)

就这样。只有当按钮未被选中时(其 state'normal',而不是 'down'),我们才允许按钮像平常一样切换。

现在剩下的只是将 paint.kv 文件中的每个 ToggleButton 实例替换为我们的自定义类 RadioButton 的名称,并立即看到按钮行为的变化。

这是 Kivy 框架的一个主要卖点:仅用几行代码,你就可以覆盖内置的函数和方法,实现几乎无与伦比的灵活性。

小贴士

要在 Kivy 语言中使用,RadioButton 定义应位于 main.py 模块中或导入其作用域。由于我们目前只有一个 Python 文件,这不是问题,但随着你的应用程序的增长,请记住这一点:自定义 Kivy 小部件,就像其他 Python 类或函数一样,在使用之前必须导入。

着色按钮

现在我们按钮的行为已经正确,下一步是着色。我们想要达到的效果如下所示:

着色按钮

绘图应用的颜色调色板,鲜艳且吸引人

为了实现这一点,我们将使用 background_color 属性。在 Kivy 中,背景色充当色调而不是纯色;我们首先需要准备一个纯白色的背景图像,当它被着色时,将给出我们想要的颜色。这样,我们只需要为任意数量的任意颜色按钮准备两个按钮纹理(正常状态和按下状态)。

我们在这里使用的图像与我们之前为 第一章 中的时钟应用准备的图像没有太大区别,只是现在按钮的主要区域是白色,以便着色,而选中状态具有黑色边框:

着色按钮

颜色按钮的纹理,其中白色区域将使用背景色属性进行着色

一种新的按钮类型

这次,我们可以在 paint.kv 文件中完成大部分工作,包括创建一个新的按钮类。新类将被命名为 ColorButton

<ColorButton@RadioButton>:
    group: 'color'
    on_release: app.canvas_widget.set_color(self.background_color)
    background_normal: 'color_button_normal.png'
    background_down: 'color_button_down.png'
    border: (3, 3, 3, 3)

如您所见,我们将 group 属性移动到这里,以避免在调色板定义中重复 group: 'color' 行。

我们还分配了一个事件处理器on_release,当按下ColorButton时将被调用。每个按钮都将其background_color属性传递给事件处理器,所以剩下的只是将此颜色分配给画布。此事件将由CanvasWidget处理,它需要从PaintApp类中公开,如下所示:

class PaintApp(App):
    def build(self):
        # The set_color() method will be implemented shortly.
        self.canvas_widget = CanvasWidget()
        self.canvas_widget.set_color(
            get_color_from_hex('#2980B9'))
        return self.canvas_widget

这种安排的原因是我们不能在先前的paint.kv类定义中使用root快捷方式;它将指向ColorButton本身(类规则中的根定义确实就是类规则本身,因为它在paint.kv的顶层定义)。我们还可以在此设置默认颜色,如代码片段所示。

当我们在main.py模块中时,让我们也实现CanvasWidget上的set_color()方法,它将作为ColorButton点击的事件处理器。所涉及的方法非常直接:

def set_color(self, new_color):
    self.canvas.add(Color(*new_color))

只需设置传递给参数的颜色。就是这样!

定义调色板

接下来是创意部分:定义实际的调色板。在所有基础工作就绪后,让我们从paint.kv中删除旧的RadioButton定义,并重新开始。

要使用熟悉的 CSS 颜色表示法,我们需要将适当的函数导入到paint.kv文件中。是的,它可以导入函数,就像常规 Python 模块一样。

将此行添加到paint.kv的开头:

#:import C kivy.utils.get_color_from_hex

这与以下 Python 代码(为了简洁,使用了较短的别名,因为我们将会大量使用它)完全相同:

from kivy.utils import get_color_from_hex as C

如前所述,我们将使用 Flat UI 颜色为本例,但请随意选择您喜欢的调色板。定义本身看起来是这样的:

BoxLayout:
    # ...
    ColorButton:
        background_color: C('#2980b9')
        state: 'down'

    ColorButton:
        background_color: C('#16A085')

    ColorButton:
        background_color: C('#27AE60')

这种表示法尽可能清晰。对于每个ColorButton小部件,只需定义一个属性,即其background_color属性。其他所有内容都继承自类定义,包括事件处理器。

这种架构的美丽之处在于,现在我们可以添加任意数量的此类按钮,并且它们将正确对齐并执行。

设置线宽

我们将要实现的最后一个也是最简单的功能是一个简单的线宽选择器。如以下截图所示,我们正在重用之前部分的颜色调色板中的资产和样式。

设置线宽

线宽选择器

这个 UI 使用了另一个RadioButton子类,无创意地命名为LineWidthButton。将以下声明追加到paint.kv文件中:

<LineWidthButton@ColorButton>:
    group: 'line_width'
    on_release: app.canvas_widget.set_line_width(self.text)
    color: C('#2C3E50')
    background_color: C('#ECF0F1')

ColorButton的关键区别在前面代码中已突出显示。这些新按钮属于另一个单选组,并且在交互时触发另一个事件处理器。除此之外,它们非常相似。

布局同样简单,以与调色板相同的方式构建,只是它是垂直的:

BoxLayout:
    orientation: 'vertical'
    padding: 2
    spacing: 2
    x: 0
    top: root.top
    width: 80
    height: 110

    LineWidthButton:
        text: 'Thin'

    LineWidthButton:
        text: 'Normal'
        state: 'down'

    LineWidthButton:
        text: 'Thick'

注意

注意,我们新的事件监听器 CanvasWidget.set_line_width 将接受被按下的按钮的 text 属性。为了简单起见,它是这样实现的,因为这允许我们为每个小部件定义一个独特的属性。

在现实世界的场景中,这种方法并不是严格禁止的,也不是特别不常见,但仍然有点可疑:当我们决定将应用程序翻译成中文或希伯来语时,这些文本标签会发生什么?

改变线宽

当用户界面的每一部分都到位后,我们最终可以将事件监听器附加到将要应用所选线宽的绘画上。我们将基于提供的内联按钮文本映射,在 CanvasWidget.line_width 中存储线宽的数值,并在开始绘制新线条时在 on_touch_down 处理程序中使用它。简而言之,这些是修订后的 CanvasWidget 类的相关部分:

class CanvasWidget(Widget):
    line_width = 2

    def on_touch_down(self, touch):
        # ...
        with self.canvas:
            touch.ud['current_line'] = Line(
                points=(touch.x, touch.y),
                width=self.line_width)

    def set_line_width(self, line_width='Normal'):
        self.line_width = {
 'Thin': 1, 'Normal': 2, 'Thick': 4
 }[line_width]

这就结束了 Kivy Paint 应用程序的教程。如果你现在启动程序,你可能会画出一幅美丽的作品。(我做不到,正如你可能从插图中注意到的。)

摘要

在本章中,我们强调了开发基于 Kivy 的应用程序的一些常见实践,例如自定义主窗口、更改鼠标光标、窗口大小和背景颜色、使用画布指令以编程方式绘制自由形式的图形,以及正确处理所有支持平台上的触摸事件,考虑到多点触控。

在构建 Paint 应用程序之后,关于 Kivy 的一个明显的事实是它如何开放和多功能。Kivy 不是提供大量刚性的组件,而是利用简单构建块的组合性:图形原语和行为。这意味着虽然 Kivy 没有捆绑很多有用的现成小部件,但你可以在几行高度可读的 Python 代码中组装出任何你需要的东西。

模块化 API 设计因其几乎无限的灵活性而效果显著。最终结果完美地满足了应用程序的独特需求。客户想要一些令人惊叹的东西,比如一个三角形按钮——当然,你还可以在上面添加纹理,大约只需要三行代码左右。(相比之下,尝试使用 WinAPI 创建一个三角形按钮。那就像凝视深渊,只是不那么富有成效。)

这些自定义 Kivy 组件通常也最终会变得可重用。实际上,你可以轻松地从 main.py 模块中导入 CanvasWidget 并在另一个应用程序中使用它。

自然用户界面

还值得一提的是,我们的第二个应用程序比第一个应用程序互动得多:它不仅对按钮点击做出响应,还对任意的多点触控手势也做出响应。

所有可用的窗口表面都能响应触摸,一旦对最终用户来说变得明显,就没有认知上的负担去绘画,尤其是在触摸屏设备上。你只需用手指在屏幕上画画,就像是在一个物理表面上,而且你的手指足够脏,可以在上面留下痕迹。

这种界面,或者说是没有这种界面,被称为NUI自然用户界面)。它有一个有趣的特征:NUI 应用程序可以被小孩子甚至宠物使用——那些能够看到和触摸屏幕上图形对象的存在。这实际上是一个自然、直观的界面,一种“无需思考”的事情,与例如 Norton Commander 的界面形成对比,后者在当年被称为直观。让我们面对现实:那是个谎言。直觉在以任何实际方式应用于蓝屏、双面板 ASCII 艺术程序方面是不适用的。

在下一章中,我们将构建另一个基于 Kivy 的程序,这次仅限于 Android 设备。它将展示 Python 代码和 Java 类之间的互操作性,这些 Java 类构成了 Android API。

第三章。Android 录音机

在上一章中,我们简要讨论了 Kivy 应用程序,通常是跨平台的,其部分代码可能在选定的系统上条件性工作,从而增强某些用户的体验并执行其他特定平台的任务。

有时,这几乎是免费的;例如,如果 Kivy 检测到目标系统支持多点触控,多点触控就会正常工作——你不需要编写任何代码来启用它,只需考虑几个指针事件同时触发以供不同触控使用的情况。

其他平台相关任务包括由于各种原因在其他系统上无法运行的代码。还记得 Paint 应用程序中的鼠标光标自定义吗?那段代码使用了 Pygame 提供的低级绑定来调用 SDL 光标例程,只要你有 SDL 和 Pygame 运行,这是完全正常的。因此,为了使我们的应用程序多平台,我们采取了预防措施,避免在不兼容的系统上进入特定的代码路径;否则,它会导致我们的程序崩溃。

否则,Kivy 应用程序通常可以在所有支持的平台上移植——Mac、Windows、Linux、iOS、Android 和 Raspberry Pi——没有显著的问题。直到它们不再如此;我们将在下一节讨论这个原因。

Android 的录音机

Kivy 支持广泛的平台

在本章中,我们将涵盖以下主题:

  • 使用Pyjnius库实现 Python 和 Java 之间的互操作性

  • 在运行 Android 操作系统的设备(或模拟器)上测试 Kivy 应用程序

  • 从 Python 中与 Android 的声音 API 协同工作,这允许你录制和播放音频文件

  • 制作类似 Windows Phone 概念的拼图用户界面布局

  • 使用图标字体通过矢量图标改善应用程序的展示

编写平台相关代码

本书中的大多数项目都是跨平台的,这得益于 Kivy 的极高可移植性。然而,这次我们将有意识地构建一个单平台的应用程序。这无疑是一个严重的限制,会减少我们的潜在用户群;另一方面,这也给了我们依赖特定平台绑定的机会,这些绑定提供了扩展功能。

这种绑定的需求源于 Kivy 力求尽可能实现跨平台,并在它支持的每个系统上提供类似的用户体验。这本身就是一个巨大的特性;作为加分项,我们还有能力编写一次代码,在各个地方运行,几乎不需要任何调整。

然而,跨平台的缺点是,你只能依赖每个系统支持的核心理念功能。这个“最低共同分母”功能集包括在屏幕上渲染图形、如果有声卡则播放声音、接受用户输入以及其他不多的事情。

由于每个 Kivy 应用程序都是用 Python 编写的,因此它也可以访问庞大的 Python 标准库。它促进了网络通信,支持多种应用程序协议,并提供了许多通用算法和实用函数。

然而,“纯 Kivy”程序的输入输出IO)能力仅限于大多数平台上存在的功能。这仅占一个普通计算机系统(如智能手机或平板电脑)实际能做的极小一部分。

让我们来看看现代移动设备的 API 表面(为了本章的目的,让我们假设它正在运行 Android)。我们将把所有内容分成两部分:由 Python 和/或 Kivy 直接支持的内容,以及不支持的内容。

以下是在 Python 或 Kivy 中直接可用的功能:

  • 硬件加速图形

  • 带有可选多点触控的触摸屏输入

  • 音频播放(在撰写本文时,播放仅支持持久存储中的文件)

  • 网络,假设存在互联网连接

以下是不支持或需要外部库的功能:

  • 调制解调器,支持语音通话和短信

  • 使用内置摄像头录制视频和拍照

  • 使用内置麦克风来录制声音

  • 与用户账户关联的应用程序数据云存储

  • 蓝牙和其他近场网络功能

  • 定位服务和 GPS

  • 指纹识别和其他生物识别安全

  • 运动传感器,即加速度计和陀螺仪

  • 屏幕亮度控制

  • 震动和其他形式的触觉反馈

  • 电池充电水平

注意

对于“不支持”列表中的大多数条目,已经存在不同的 Python 库来填补空白,例如用于低级声音记录的 Audiostream 和用于处理许多平台特定任务的 Plyer。

所以,这些功能并不是完全不可用给你的应用程序;实际上,挑战在于这些功能片段在不同平台(或者甚至是同一平台的连续版本,例如 Android)上极其碎片化;因此,你最终不得不编写特定平台的、不可移植的代码。

如您从前面的比较中可以看到,Android 上提供了许多功能,但只有部分由现有的 Python 或 Kivy API 覆盖。这为在您的应用程序中使用平台特定功能留下了巨大的未开发潜力。这不仅仅是一个限制,而是一个机会。简而言之,您将很快学会如何从 Python 代码中利用任何 Android API,使您的 Kivy 应用程序几乎可以做任何事情。

将您的应用程序的范围缩小到只有一小部分系统的一个优势是,有一些全新的程序类只能在具有合适硬件规格的移动设备上运行(或甚至有意义)。这些包括增强现实应用程序、陀螺仪控制的游戏、全景相机等等。

介绍 Pyjnius

为了充分利用我们选择的平台,我们将使用特定于平台的 API,碰巧这个 API 是 Java,因此主要是面向 Java 的。我们将构建一个录音应用程序,类似于在 Android 和 iOS 中常见的应用程序,尽管更简单。与纯 Kivy 不同,底层的 Android API 确实为我们提供了编程记录声音的方法。

本章的其余部分将贯穿这个小录音程序的开发过程,使用优秀的 Pyjnius 库来展示 Python-Java 的互操作性,这是 Kivy 开发者制作的另一个伟大项目。我们选择的概念——录音和播放——故意很简单,以便在不引起主题的纯粹复杂性和大量实现细节的过多干扰的情况下,概述这种互操作性的功能。

Pyjnius 最有趣的特性是它不提供自己的“覆盖”API 来覆盖 Android 的 API,而是允许你直接从 Python 中使用 Java 类。这意味着你可以完全访问本地的 Android API 和官方的 Android 文档,这对于 Java 开发来说显然更合适,而不是 Python。然而,这仍然比完全没有 API 参考要好。

注意,你不需要在本地安装 Pyjnius 来完成教程,因为我们显然不会在用于开发的机器上运行调用 Android Java 类的代码。

Pyjnius 的源代码、参考手册和一些示例可以在官方仓库github.com/kivy/pyjnius找到。

小贴士

我们将仅在 Android 开发和互操作性的背景下讨论 Pyjnius,但请记住,你也可以用桌面 Java 进行同样的集成。这是一个有趣的特性,因为从 Python 脚本 Java API 的另一个选项是 Jython,它相当慢且不完整。另一方面,Pyjnius 允许你使用官方的 Python 解释器(CPython),以及像 NumPy 这样的众多库,这有助于非常快速的计算。

因此,如果你绝对必须从 Python 调用 Java 库,那么请务必考虑 Pyjnius 作为一个好的互操作变体。

模拟 Android

如前所述,本章的项目仅针对 Android,因此它不会在你的电脑上工作。如果你没有备用 Android 设备,或者如果你不觉得在教程的目的上玩真实的物理设备很舒服,请不要担心。有高质量的 Android 模拟器可以帮助你克服这个小小的障碍,并在你的桌面上玩 Android 操作系统。

目前市面上最好的模拟器之一是 Genymotion(之前称为 AndroVM),它建立在 Oracle 的 VirtualBox 虚拟机之上。你可以从官方网站 www.genymotion.com/ 获取免费副本;在撰写本文时,他们的许可非常宽松,允许几乎无限制的免费个人使用。

VM 软件的安装对于每个模拟器和主机操作系统组合都大不相同,所以我们现在不会提供过于详细的说明。毕竟,这些事情现在应该是用户友好的,包括说明书和图形用户界面。确实,我们已经进入了技术的黄金时代。

即使最后一句话并不完全是讽刺的,但在设置和使用 Android 模拟的虚拟机时,也有一些事情需要考虑:

  • 总是使用最新的 Android 版本。向后兼容性或缺乏兼容性可能相当糟糕;调试操作系统级别的错误一点也不有趣。

  • 不要犹豫,在网上搜索解决方案。Android 社区非常庞大,如果你有问题,这意味着你很可能并不孤单。

  • Kivy Launcher 应用程序,你可能觉得它非常有用,可以用来测试你自己的程序,可以从官方 Kivy 网站以 .apk 文件的形式获取,kivy.org/;这对于没有访问 Google Play 的模拟 Android 设备来说将非常有用。

  • 最后,市面上有许多不同质量、兼容性各异的模拟器。如果事情似乎随机崩溃并停止工作,也许你应该尝试另一个虚拟机或 Android 发行版。调整虚拟机的配置也可能有所帮助。

下一个截图展示了运行最新版本 Android 的 Genymotion 虚拟机,并安装了可用的 Kivy Launcher:

模拟 Android

运行 Android 4.4.2 并安装了 Kivy Launcher 的 Genymotion 虚拟机

Metro UI

当我们谈论这个话题时,让我们构建一个类似于 Windows Phone 主屏幕的用户界面。这个概念,基本上是一个各种尺寸的彩色矩形(瓷砖)的网格,在某个时候被称为 Metro UI,但由于商标问题后来更名为 Modern UI。不管叫什么名字,这就是它的样子。这将给你一个大致的想法,了解在应用程序开发过程中我们将要达到的目标:

Metro UI

设计灵感 - 带有瓷砖的 Windows Phone 主屏幕

显然,我们不会完全复制它,而是制作一个类似于所描述的用户界面。以下列表基本上总结了我们要追求的独特特性:

  • 所有的元素都对齐到矩形网格

  • UI 元素具有与 第一章 中讨论的相同扁平外观,构建时钟应用程序(瓷砖使用明亮的纯色,没有阴影或圆角)

  • 被认为更有用的(对于“有用”的任意定义)瓷砖更大,因此更容易点击

如果这听起来对你来说很简单,那么你绝对是对的。正如你很快就会看到的,Kivy 实现这样的 UI 非常直接。

按钮们

首先,我们将调整一个 Button 类,就像我们在之前的程序中做的那样。它类似于 Paint 应用程序中的 ColorButton (第二章, 构建 Paint 应用程序):

<Button>:
    background_normal: 'button_normal.png'
    background_down: 'button_down.png'
    background_color: C('#95A5A6')
    font_size: 40

我们设置的背景纹理是纯白色,利用了在创建调色板时使用的相同技巧。background_color 属性充当着色色,将一个纯白色纹理分配给它相当于在 background_color 中绘制按钮。这次我们不想有边框。

第二个(按下 background_down)纹理是 25% 透明的白色。与应用程序的纯黑色背景颜色结合,我们得到了按钮分配的相同背景颜色的稍微深一点的色调:

按钮

按钮的正常(左)和按下(右)状态——背景颜色设置为 #0080FF

网格结构

布局构建起来稍微复杂一些。在没有现成的类似现代 UI 的瓷砖布局可用的情况下,我们将使用内置的 GridLayout 小部件来模拟它。它表现得就像我们之前使用的 BoxLayout 小部件一样,只是在两个维度上而不是一个维度上,因此没有 orientation: 'horizontal''vertical' 属性——GridLayout 小部件同时具备这两个属性。

如果不是最后一个要求,这样一个布局就能满足我们的所有需求:我们想要有大小不同的按钮。目前,GridLayout 不允许合并单元格来创建更大的按钮(如果能有一个类似于 HTML 中的 rowspancolspan 属性的功能那就太好了)。因此,我们将采取相反的方向:从根 GridLayout 开始,使用大单元格,并在一个单元格内添加另一个 GridLayout 来细分它。

由于嵌套布局在 Kivy 中表现良好,我们得到了以下 Kivy 语言结构(让我们将文件命名为 recorder.kv):

#:import C kivy.utils.get_color_from_hex

GridLayout:
    padding: 15

    Button:
        background_color: C('#3498DB')
        text: 'aaa'

    GridLayout:
        Button:
            background_color: C('#2ECC71')
            text: 'bbb1'

        Button:
            background_color: C('#1ABC9C')
            text: 'bbb2'

        Button:
            background_color: C('#27AE60')
            text: 'bbb3'

        Button:
            background_color: C('#16A085')
            text: 'bbb4'

    Button:
        background_color: C('#E74C3C')
        text: 'ccc'

    Button:
        background_color: C('#95A5A6')
        text: 'ddd'

为了运行此代码,你需要一个标准的 main.py 模板作为应用程序的入口点。尝试自己编写这段代码作为练习。

小贴士

请参考第一章的开头。应用程序的类名将不同,因为它应该反映之前展示的 Kivy 语言文件的名称。

注意嵌套的 GridLayout 小部件与外部的、较大的按钮处于同一级别。如果你查看之前的 WinPhone 主屏幕截图,这应该会很有意义:一组四个较小的按钮占据与一个较大的按钮相同的空间(一个外部网格单元格)。嵌套的 GridLayout 是这些较小按钮的容器。

视觉属性

在外部网格上,padding 提供了一些距离屏幕边缘的空间。其他视觉属性在 GridLayout 实例之间共享,并移动到一个类中,结果在 recorder.kv 内部的代码如下:

<GridLayout>:
    cols: 2
    spacing: 10
    row_default_height:
        (0.5 * (self.width - self.spacing[0]) -
        self.padding[0])
    row_force_default: True

注意

值得注意的是,paddingspacing 都实际上是列表,而不是标量。spacing[0] 属性指的是水平间距,然后是垂直间距。然而,我们可以使用前面代码中显示的单个值来初始化 spacing;然后这个值将被用于所有内容。

每个网格由两列和一些间距组成。row_default_height 属性更复杂:我们不能只是说,“让行高等于单元格宽度。”相反,我们手动计算所需的高度,其中 0.5 是因为我们有两个列:

视觉属性

如果我们不应用这个调整,网格内的按钮将填充所有可用的垂直空间,这是不希望的,尤其是在按钮不多的情况下(每个按钮最终都会变得太大)。相反,我们希望所有按钮都整齐划一,底部左侧留有空白,嗯,就是空白。

以下是我们应用 "现代 UI" 磁贴的截图,这是前面代码的结果:

视觉属性

到目前为止的 UI – 可点击的、大小可变的磁贴,与我们设计灵感不太相似

可缩放矢量图标

我们可以应用到应用程序 UI 中的一个很好的收尾细节是使用图标,而不仅仅是文本,在按钮上。当然,我们可以简单地加入一堆图片,但让我们借鉴现代网络开发中的一个有用技术,使用图标字体——正如你很快就会看到的,这些提供了极大的灵活性,而且不花任何成本。

图标字体

图标字体本质上与常规字体类似,只是它们的符号与语言的字母无关。例如,你输入 "P" 时,会渲染出 Python 的标志而不是字母;每个字体都会发明自己的记忆法来分配字母到图标。

这可能是使用图标字体唯一的缺点——使用这种字体大量代码的可读性并不好,因为字符-图标映射几乎不明显。这可以通过使用常量而不是直接输入符号来缓解。

还有不使用英语字母的字体,它们将图标映射到 Unicode 的 "私有用途区域" 字符代码。这是一种技术上正确构建此类字体的方法,但应用程序对这种 Unicode 功能的支持各不相同——不是每个平台在这方面表现都相同,尤其是在移动平台上。我们将为我们的应用使用的字体不分配私有用途字符,而是使用 ASCII(普通英语字母)。

使用图标字体的理由

在网络上,图标字体解决了与(光栅)图像常见的一些问题:

  • 首先要考虑的是,位图图像不易缩放,在调整大小时可能会变得模糊——某些算法比其他算法产生更好的结果,但截至目前,“最佳实践”仍然不完美。相比之下,矢量图像按定义是无限可缩放的。

  • 包含矢量图形(如图标和 UI 元素)的位图图像文件通常比矢量格式大。这显然不适用于编码为 JPEG 的照片。

  • 此外,图标字体通常只是一个文件,包含任意数量的图标,这意味着只需要一次 HTTP 往返。常规图标(图像)通常在单独的文件中,导致显著的 HTTP 开销;有减轻这种影响的方法,例如 CSS 精灵,但它们并不被普遍使用,并且也有它们自己的问题。

  • 在图标字体的情况下,颜色更改实际上只需一秒钟——你只需在 CSS 文件中添加color: red(例如)即可做到这一点。同样,大小、旋转和其他不涉及改变图像几何形状的属性也是如此。实际上,这意味着对图标进行微调不需要图像编辑器,这在处理位图时通常是必需的。

其中一些观点对 Kivy 应用程序来说并不适用,但总的来说,在当代网络开发中使用图标字体被认为是一种良好的实践,特别是由于有许多免费的高质量字体可供选择——这意味着有成百上千的图标可以包含在你的项目中。

小贴士

两个免费字体(包括那些可以免费用于商业用途的字体)的绝佳来源是Font Squirrelwww.fontsquirrel.com)和Google Fontswww.google.com/fonts)。不要在意这些网站的一般网络开发方向,大多数字体在离线程序中的可用性与在网络上一样,甚至更好。因为浏览器的支持仍然不是理想的。

真正重要的是文件格式:目前 Kivy 只支持 True Type(.ttf)格式。幸运的是,这已经是目前最流行的字体格式。此外,将任何其他格式的字体转换为.ttf格式也是可能的。

在 Kivy 中使用图标字体

在我们的应用程序中,我们将使用由 John Caserta 设计的 Modern Pictograms(版本 1)免费字体。以下是其外观的一瞥:

在 Kivy 中使用图标字体

Modern Pictograms 图标字体的一小部分图标样本

要将字体加载到我们的 Kivy 程序中,我们将使用在 第一章 中概述的相同过程,构建时钟应用程序。在这种情况下,这并不是严格必要的,因为图标字体很少有不同的字体粗细和样式。然而,通过显示名称(Modern Pictograms)而不是文件名(modernpics.ttf)来访问字体是一个更好的方法。你可以稍后通过只更新路径的一次出现来重命名或移动字体文件,而不必在每个使用字体的地方更新。

到目前为止的代码(在 main.py 中)看起来像这样:

from kivy.app import App
from kivy.core.text import LabelBase

class RecorderApp(App):
    pass

if __name__ == '__main__':
    LabelBase.register(name='Modern Pictograms',
                       fn_regular='modernpics.ttf')

    RecorderApp().run()

字体的实际使用发生在 recorder.kv 内。首先,我们希望再次更新 Button 类,以便我们可以在文本中使用标记标签来更改字体。这在上面的代码片段中显示:

<Button>:
    background_normal: 'button_normal.png'
    background_down: 'button_down.png'
    font_size: 24
    halign: 'center'
 markup: True

halign: 'center' 属性意味着我们希望按钮内的每一行文本都居中。markup: True 属性是显而易见的,并且是必需的,因为按钮定制的下一步将严重依赖于标记。

现在我们可以更新按钮定义。以下是一个例子:

Button:
    background_color: C('#3498DB')
    text:
        ('[font=Modern Pictograms][size=120]'
        'e[/size][/font]\nNew recording')

通常,在 Kivy 语言文件中不需要在字符串周围使用括号;这种语法仅在声明多行时有用。这种表示法实际上等同于在同一行上写一个长字符串。

注意 [font][size] 标签内的字符 'e'。这是图标代码。我们应用程序中的每个按钮都将使用不同的图标,更改图标相当于在 recorder.kv 文件中替换一个字母。Modern Pictograms 字体的代码完整映射可以在其官方网站 modernpictograms.com/ 上找到。

小贴士

为了手动探索图标字体,你需要使用字体查看器。通常,无论操作系统如何,你的机器上都会有一个现成的查看器。

  • 字符映射 程序是 Windows 的一部分

  • 在 Mac 上,有一个内置的应用程序叫做 Font Book

  • Linux 有多个查看器,取决于你选择的桌面环境,例如,GNOME 中的 gnome-font-viewer

或者,只需在网上搜索。流行的字体通常有一些在线的用户手册,解释字符映射。

简而言之,这就是我们在按钮上添加图标后应用程序的 UI 看起来是什么样子:

在 Kivy 中使用图标字体

声音录制器应用程序界面 - 一个具有来自 Modern Pictograms 字体的矢量图标的现代 UI

这已经非常接近原始的 Modern UI 外观了。

注意

你可能会想知道顶部右角的小绿色按钮的用途是什么。答案是,目前它们仅仅是为了数量。实际上我们需要实现的三个按钮——录音、播放、删除——不足以说明 Modern UI 的概念,因为它需要更多的多样性才能看起来稍微有趣一些。

在 Android 上进行测试

目前,我们的应用程序还不包含任何不可移植的代码,但让我们逐步转向我们选择的平台,并在 Android 上进行测试。进行此操作的唯一先决条件是安装并运行Kivy Launcher应用程序的 Android 设备,无论是物理的还是虚拟的。

为 Kivy Launcher 打包应用程序几乎微不足道。我们将添加两个文件,android.txticon.png,到其他源(在这种情况下,main.pyrecorder.kv)所在的同一文件夹,然后将文件夹复制到 Android 设备的 SD 卡上的/Kivy目录下。目录结构应类似于以下内容:

在 Android 上进行测试

Kivy Launcher 的 SD 卡目录结构

当您启动 Kivy Launcher 时,它将显示它搜索项目的完整路径。这可能很有用,例如,当您没有 SD 卡时。

android.txt文件的格式相当明显:

title=App Name
author=Your Name
orientation=portrait

标题和作者字段只是显示在应用程序列表中的字符串。方向可以是纵向(垂直,高度 > 宽度)或横向(水平,宽度 > 高度),具体取决于应用程序首选的宽高比。

图标icon.png是可选的,如果省略,则将保持空白。建议添加它,因为根据图标查找应用程序要容易得多,而且如果您计划将生成的应用程序发布到 Google Play 商店,您无论如何都需要一个图标。

注意,图标的文件名不可自定义,main.py的文件名也不可自定义,它必须指定应用程序的入口点;否则,Kivy Launcher 不会启动应用程序。

当所有文件就绪后,您在启动 Kivy Launcher 时应该能在列表中看到您的录音程序:

在 Android 上进行测试

Kivy Launcher 应用程序列表,包含我们在本书的整个过程中编写的每个应用程序

小贴士

如果您看到一条包含放置文件指示的消息,请重新检查您的路径——遗憾的是,在撰写本文时,Kivy Launcher 搜索项目所在的目录不容易配置。这可能在未来的版本中得到改善。

现在您可以通过点击列表中的相应条目来启动您的应用程序。这是在 Android 上测试 Kivy 程序的最简单方法——只需复制文件,您就设置好了(与打包.apk文件相比,后者相对简单,但涉及更多步骤)。

使用原生 API

完成了应用的用户界面部分后,我们现在将转向原生 API,并使用合适的 Android Java 类MediaRecorderMediaPlayer来实现声音录制和播放逻辑。

技术上,Python 和 Java 都是面向对象的,乍一看,这些语言可能看起来相当相似。然而,面向对象原则的应用却有着根本的不同。与 Python 相比,许多 Java API 都存在(或者根据您询问的人不同,可能会非常享受)过度架构和过度使用面向对象范式的问题。所以,不要对其他非常简单的任务可能需要您导入和实例化很多类而感到惊讶。

注意

1913 年,弗拉基米尔·列宁就 Java 架构写道:

要打破这些类的阻力,只有一个方法,那就是在我们周围的社会中找到能够构成扫除旧事物、创造新事物的力量的力量。

那篇论文当时没有提到 Python 或 Pyjnius,但信息很明确——即使在一百年前,在当代社会中过度使用类也不是很受欢迎。

幸运的是,手头的任务相对简单。要使用 Android API 录制声音,我们只需要以下五个 Java 类:

  • android.os.Environment:此类提供了访问许多有用环境变量的权限。我们将使用它来确定 SD 卡挂载的路径,以便我们可以保存录制的音频文件。直接硬编码'/sdcard/'或类似的常量很有诱惑力,但在实践中,每个其他 Android 设备的文件系统布局都不同。所以,即使是为了教程的目的,我们也不应该这样做。

  • android.media.MediaRecorder:此类是我们的主要工作马。它便于捕捉音频和视频并将其保存到文件系统中。

  • android.media.MediaRecorder$AudioSourceandroid.media.MediaRecorder$AudioEncoder,和android.media.MediaRecorder$OutputFormat:这些是枚举,包含我们需要传递给MediaRecorder各种方法的参数。

提示

Java 类命名方案

类名中的美元符号通常表示该类是内部的。这并不是一个精确的启发式方法,因为您可以在没有任何逻辑的情况下自己声明一个类似的名字——'$'是 Java 变量和类名中可用的字符,与例如 JavaScript 等语言类似。然而,这种非常规的命名是不被提倡的。

加载 Java 类

将上述 Java 类加载到您的 Python 应用程序中的代码如下:

from jnius import autoclass

Environment = autoclass('android.os.Environment')
MediaRecorder = autoclass('android.media.MediaRecorder')
AudioSource = autoclass('android.media.MediaRecorder$AudioSource')
OutputFormat = autoclass('android.media.MediaRecorder$OutputFormat')
AudioEncoder = autoclass('android.media.MediaRecorder$AudioEncoder')

如果您此时尝试运行程序,您将收到一个错误,类似于以下内容:

  • ImportError: 没有名为 jnius 的模块:如果您在机器上没有安装 Pyjnius,您将遇到此错误。

  • jnius.JavaException: 类未找到 'android/os/Environment':如果您安装了 Pyjnius,但尝试加载的 Android 类缺失(例如,在桌面机器上运行时),您将遇到此错误。

这是一种罕见的情况,收到错误意味着我们做的一切都是正确的。从现在开始,我们应该在 Android 设备或模拟器内部进行所有测试,因为代码不再是跨平台的了。它明确依赖于 Android 特定的 Java 功能。

现在我们可以无缝地在我们的 Python 代码中使用 Java 类。

小贴士

请记住,这些类的文档是为与 Java 一起使用而编写的,而不是 Python。你可以在 Google 官方的 Android 开发者门户上查找它 developer.android.com/reference/packages.html——最初将代码示例从 Java 转换为 Python 可能看起来有些令人生畏,但实际上非常简单(如果有些啰嗦)。

查找存储路径

让我们用一个简单的例子来说明实际的多语言 API 使用。在 Java 中,我们会这样做来找出 SD 卡挂载的位置:

import android.os.Environment;

String path = Environment.getExternalStorageDirectory()
.getAbsolutePath();

当转换为 Python 时,此代码的读取方式如下:

Environment = autoclass('android.os.Environment')
path = Environment.getExternalStorageDirectory().getAbsolutePath()

这与之前代码中显示的完全相同,只是用 Python 而不是 Java 编写的。

当我们在做这件事的时候,也让我们记录这个值,这样我们就可以在 Kivy 日志中看到 getAbsolutePath 方法返回给我们的代码的确切路径:

from kivy.logger import Logger
Logger.info('App: storage path == "%s"' % path)

在我的测试设备上,这会在 Kivy 日志中产生以下行:

[INFO] App: storage path == "/storage/sdcard0"

从设备读取日志

当你在开发期间从终端运行 Kivy 应用程序时,日志会立即在同一终端窗口中显示。这个非常有用的功能在应用程序在 Kivy 启动器内部运行时也是可用的,尽管不太容易访问。

要读取 Kivy 日志,导航到设备上你的应用程序所在的文件夹(例如,SD 卡上的 /Kivy/Recorder)。在这个文件夹内部,Kivy 启动器创建了一个名为 .kivy 的另一个目录,其中包含默认配置和一些杂项服务信息。每次应用程序启动时,都会在 .kivy/logs 下创建一个日志文件。

或者,如果你已经安装了 Android SDK,你可以在设备上启用 USB 调试,然后使用 adb logcat 命令在一个地方查看所有 Android 日志,包括 Kivy 日志。这会产生关于设备内部发生的各种内部过程的大量信息,例如各种硬件的激活和去激活、应用程序窗口状态的变化等等。

日志在调试奇怪的程序行为或当应用程序拒绝启动时非常有价值。Kivy 还会在那里打印关于运行时环境的各种警告,例如缺少库或功能、Python 模块加载失败以及其他潜在问题。

录制声音

现在,让我们深入 Android API 的兔子洞,实际上从麦克风录制声音。以下代码基本上是将 Android API 文档翻译成 Python。如果您对这段代码的原始 Java 版本感兴趣,可以在developer.android.com/guide/topics/media/audio-capture.html找到——它太长了,无法在这里包含。

下面的代码是初始化MediaRecorder对象的准备代码:

storage_path = (Environment.getExternalStorageDirectory()
                .getAbsolutePath() + '/kivy_recording.3gp')

recorder = MediaRecorder()

def init_recorder():
    recorder.setAudioSource(AudioSource.MIC)
    recorder.setOutputFormat(OutputFormat.THREE_GPP)
    recorder.setAudioEncoder(AudioEncoder.AMR_NB)
    recorder.setOutputFile(storage_path)
    recorder.prepare()

这就是典型的、直接的、冗长的 Java 初始化方式,用 Python 逐字重写。

您可以在这里调整输出文件格式和编解码器,例如,将AMR_NB自适应多速率编解码器,针对语音优化,因此在 GSM 和其他移动电话网络中广泛使用)更改为AudioEncoder.AAC高级音频编解码器标准,是一种更通用的编解码器,类似于 MP3)。这样做可能没有很好的理由,因为内置麦克风的动态范围可能不适合录制音乐,但选择权在您手中。

现在是时候来点乐趣了,“开始/结束录音”按钮。以下代码片段使用了与第一章中实现计时器开始/停止按钮时相同的逻辑:

class RecorderApp(App):
    is_recording = False

    def begin_end_recording(self):
        if (self.is_recording):
            recorder.stop()
            recorder.reset()
            self.is_recording = False
            self.root.ids.begin_end_recording.text = \
                ('[font=Modern Pictograms][size=120]'
                 'e[/size][/font]\nBegin recording')
            return

        init_recorder()
        recorder.start()
        self.is_recording = True
        self.root.ids.begin_end_recording.text = \
            ('[font=Modern Pictograms][size=120]'
             '%[/size][/font]\nEnd recording')

如您所见,这里也没有应用任何火箭科学:我们只是存储了当前状态,is_recording,然后根据它采取行动,即:

  1. 开始或停止MediaRecorder对象(高亮部分)。

  2. 翻转is_recording标志。

  3. 更新按钮文本,使其反映当前状态(见以下截图)。

需要更新的应用程序的最后部分是recorder.kv文件。我们需要调整“开始/结束录音”按钮,使其调用我们的begin_end_recording()函数:

Button:
        id: begin_end_recording
        background_color: C('#3498DB')
        text:
            ('[font=Modern Pictograms][size=120]'
            'e[/size][/font]\nBegin recording')
        on_press: app.begin_end_recording()

就这样!如果您现在运行应用程序,很可能会记录下要存储在 SD 卡上的声音文件。然而,在这样做之前,请先查看下一节。您创建的按钮看起来可能如下所示:

录音声音

开始录音和结束录音——这个按钮概括了我们的应用程序到目前为止的功能

主要注意事项——权限

编写本文时,默认的 Kivy 启动器应用程序没有记录声音所需的权限,android.permission.RECORD_AUDIO。这导致MediaRecorder实例初始化时立即崩溃。

有许多方法可以减轻这个问题。首先,最简单的一个:为了这个教程,我们已提供修改后的 Kivy Launcher,其中已启用必要的权限。你可以在书籍的源代码存档中找到KivyLauncherMod.apk。该包的最新版本也可在github.com/mvasilkov/kivy_launcher_hack下载。

在安装提供的.apk文件之前,请从你的设备中删除现有版本的应用,如果有的话。

或者,如果你愿意处理为 Google Play 打包 Kivy 应用的繁琐细节,你可以从源代码自行构建 Kivy Launcher。完成这项工作所需的所有信息都可以在官方 Kivy GitHub 账户github.com/kivy中找到。

第三种可行的选择(也可能比前一个更容易)是调整现有的 Kivy Launcher 应用。为此,你可以使用apktool(code.google.com/p/android-apktool/)。你需要采取的确切步骤如下:

  1. 下载官方的KivyLauncher.apk文件,并从命令行提取它,假设 apktool 在你的路径中:

    apktool d -b -s -d KivyLauncher.apk KivyLauncher
    
  2. 将必要的权限声明添加到AndroidManifest.xml文件中:

    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    
  3. 以这种方式重新打包.apk

    apktool b KivyLauncher KivyLauncherWithChanges.apk
    
  4. 使用jarsigner实用程序对生成的.apk文件进行签名。请查看关于在developer.android.com/tools/publishing/app-signing.html#signing-manually手动签名 Android 包的官方文档。

由于此过程,修改后的 Kivy Launcher 包将能够录制声音。

小贴士

你可以用同样的方式添加各种其他权限,以便在 Python 代码中使用 Pyjnius 利用它们。例如,为了访问 GPS API,你的应用需要android.permission.ACCESS_FINE_LOCATION权限。

所有可用的权限都在 Android 文档developer.android.com/reference/android/Manifest.permission.html中列出。

播放声音

要使声音播放工作变得容易;不需要此权限,API 也更为简洁。我们只需要加载一个额外的类,MediaPlayer

MediaPlayer = autoclass('android.media.MediaPlayer')
player = MediaPlayer()

以下是在用户按下播放按钮时运行的代码。我们还会在删除文件部分使用reset_player()函数;否则,可能有一个稍微长一点的函数:

def reset_player():
    if (player.isPlaying()):
        player.stop()
    player.reset()

def restart_player():
    reset_player()
    try:
        player.setDataSource(storage_path)
        player.prepare()
        player.start()
    except:
        player.reset()

每个 API 调用的详细情况可以在官方文档中找到,但总体来说,这个列表相当直观:将播放器重置到初始状态,加载声音文件,然后播放。文件格式会自动确定,这使得我们的任务变得稍微容易一些。

小贴士

在实践中,这样的代码应该始终被包裹在一个 try ... catch 块中。可能会有太多事情出错:文件可能丢失或以错误的格式创建,SD 卡可能被拔掉或无法读取,以及其他同样可怕的事情,如果有机会,这些事情一定会让你的程序崩溃。在进行输入输出操作时,一个好的经验法则是宁可靠安全,不可靠后悔

删除文件

这个最后的功能将使用 java.io.File 类,它与 Android 并不严格相关。官方 Android 文档的一个优点是,它还包含了这些核心 Java 类的引用,尽管它们比 Android 操作系统早了十多年。实现文件删除的实际代码正好是一行;它在下述列表中突出显示:

File = autoclass('java.io.File')

class RecorderApp(App):
    def delete_file(self):
        reset_player()
 File(storage_path).delete()

首先,我们通过调用 reset_player() 函数停止播放(如果有),然后删除文件——简单直接。

有趣的是,Java 中的 File.delete() 方法在发生灾难性故障时不会抛出异常,因此在这种情况下不需要执行 try ... catch。一致性,无处不在的一致性。

注意

一个细心的读者会注意到,我们也可以使用 Python 自己的 os.remove() 函数来删除文件。与纯 Python 实现相比,这样做在 Java 中并没有什么特别之处;它也更快。另一方面,作为 Pyjnius 的演示,java.io.File 与其他 Java 类一样有效。

还要注意,这个函数在桌面操作系统上也能完美运行,因为它不是 Android 特有的;你只需要安装 Java 和 Pyjnius,这个功能才能工作。

到目前为止,随着用户界面和所有三个主要功能的完成,我们的应用程序对于本教程的目的来说已经完整了。

摘要

编写不可移植的代码有其优点和缺点,就像任何其他全局架构决策一样。然而,这个特定的选择尤其困难,因为切换到本地 API 通常发生在项目早期,并且在后期阶段可能完全不切实际地撤销。

这种方法的重大优势在本章的开头已经讨论过:使用平台特定代码,你可以几乎做你平台能做的任何事情。没有人为的限制;你的 Python 代码对相同的底层 API 的访问是无限的。

从另一方面来看,依赖于单一平台是有风险的,原因有很多:

  • 仅 Android 的市场规模就比 Android 加上 iOS 的市场规模要小(这适用于几乎所有的操作系统组合)。

  • 随着你使用的每个平台特定功能,将程序移植到新系统变得更加困难。

  • 如果项目只运行在一个平台上,可能只需要一个政治决定就足以将其扼杀。被谷歌封禁的可能性高于同时被 App Store 和 Google Play 踢出的可能性。(同样,这几乎适用于每一组应用程序市场。)

现在你已经充分了解了各种选项,那么在开发每一个应用程序时,做出明智的选择就取决于你了。

关于 UI 的一些话

在任何情况下,你都不应该犹豫去借鉴和重新实现在其他地方看到的思想(以及布局、字体、颜色等等)。归功于巴勃罗·毕加索的这句话,“好艺术家借鉴;伟大的艺术家偷窃”,简洁地总结了今天的网络和应用程序开发。 (“偷窃”部分是比喻性的:请实际上不要真的去偷东西。)

此外,让我们明确一点:仅仅因为微软决定在许多最新的移动和桌面产品中使用“现代 UI”,并不意味着这种设计本身有什么优点。我们确信的是,由于微软操作系统的普及,这种用户界面范式将立即被用户识别出来,无论这种普及是幸运的还是不幸的。

在下一章中,我们将放下 Java,使用流行的 Python 网络框架Twisted构建一个简单的基于客户端-服务器架构的聊天程序。

第四章:Kivy 网络

之前,我们讨论了在扩展功能集的同时缩小应用兼容性的权衡,例如,仅使用原生 API 进行重负载处理的 Android 应用。现在,让我们探索相反的极端,并基于不妥协、普遍可用的功能——网络——来构建一个应用。

在本章中,我们将构建一个聊天应用,其概念与互联网中继聊天IRC)类似,但更加简单。

虽然我们的小应用当然不能取代像 Skype 这样的企业级巨无霸,但到本章结束时,我们的应用将支持互联网上的多用户消息传递。这对于小型友好群体来说已经足够了。

友好实际上是一个要求,因为我们有意简化事情,没有实现身份验证。这意味着用户可以轻易地模仿彼此。调整应用程序以适应敌对环境和灾难性事件(如政治辩论)的任务留给你去完成,如果你特别有冒险精神的话。

我们还旨在实现尽可能广泛的兼容性,至少在服务器端;你甚至可以使用Telnet发送和接收消息。虽然不如图形 Kivy 应用那么美观,但 Telnet 在 Windows 95 甚至 MS-DOS 上运行得很好。与恐龙聊天吧!

注意

为了更准确地反映历史,Telnet 协议是在 1973 年标准化的,因此它甚至早于 8086 CPU 和 x86 架构。相比之下,MS-DOS 要现代得多,而 Windows 95 几乎可以说是计算的未来。

本章将涵盖以下重要主题:

  • 使用 Python 编写和测试自定义服务器,采用Twisted框架

  • 在不同抽象级别上开发几个客户端应用,从使用原始套接字的简单终端程序到事件驱动的 Twisted 客户端

  • 使用 Kivy ScreenManager更好地组织应用 UI

  • 使用ScrollView容器有效地在屏幕上展示长文本小部件

我们的应用程序将采用集中式、客户端-服务器架构;这种拓扑在互联网上非常常见,许多网站和应用都是这样工作的。您很快就会看到,与去中心化、点对点网络相比,实现起来也相当简单。

注意

为了本章的目的,我们不区分局域网LAN)和互联网,因为在抽象的这一层,这基本上是不相关的。然而,请注意,如果正确部署您的应用程序以供互联网大规模消费,这需要许多额外的知识,从设置安全的 Web 服务器和配置防火墙到使代码跨多个处理器核心甚至多台物理机器扩展。在实践中,这可能没有听起来那么可怕,但本身仍然是一项非同小可的任务。

编写聊天服务器

让我们从服务器端代码开始开发,这样在我们开始编写客户端之前就有了一个连接的端点。为此,我们将使用一个优秀的Twisted框架,该框架将许多常见的低级别网络任务简化为少量干净、相对高级的 Python 代码。

小贴士

兼容性通知

Twisted 在撰写本文时不支持 Python 3,因此我们假设以下所有 Python 代码都是针对 Python 2.7 编写的。最终应该很容易将其移植到 Python 3,因为没有任何故意的不兼容设计决策。(相关地,我们还将完全忽略与 Unicode 相关的问题,因为正确解决这些问题取决于 Python 版本。)

Twisted 是一个事件驱动的、低级别的服务器框架,与Node.js(实际上,Node.js 的设计受到了 Twisted 的影响)非常相似。与 Kivy 类似,事件驱动的架构意味着我们不会将代码结构化为循环;相反,我们将多个事件监听器绑定到我们认为对我们应用有用的那些事件上。硬核、低级别的网络操作,如处理传入连接和与原始数据包一起工作,由 Twisted 在启动服务器时自动执行。

注意

为了在你的机器上安装 Twisted,请在终端中运行常规命令:

pip install -U twisted

有几点需要注意:

  • 很可能,你需要成为 root(管理员或“超级用户”)才能执行系统范围内的安装。如果你使用 Mac OS 或 Linux,当收到访问被拒绝的错误消息时,尝试在命令前加上sudo

  • 如果你没有安装 pip,请尝试使用easy_install twisted命令(或者easy_install pip)。

  • 或者,请遵循官方 pip 安装指南pip.pypa.io/en/latest/installing.html。这也涵盖了 Windows。

协议定义

让我们讨论我们将要使用的与聊天服务器通信的协议。由于应用程序将非常简单,我们不会使用像 XMPP 这样的完整协议,而是将创建一个仅包含我们需要的位的裸骨协议。

在本教程的上下文中,我们只想在协议级别实现从客户端到服务器的两条消息——连接到服务器(进入聊天室)以及实际上与其他用户交谈。服务器发送回客户端的所有内容都会被渲染;没有服务事件在服务器上发起。

我们的协议将是文本格式,类似于许多其他应用层协议,包括广泛使用的 HTTP。这是一个非常实用的特性,因为它使得调试和相关活动更加容易。与二进制协议相比,文本协议通常被认为更具可扩展性和未来适应性。纯文本的缺点主要是其大小;二进制枚举通常更紧凑。在这种情况下,这基本上是不相关的,而且可以通过压缩轻松缓解(这正是许多服务器在 HTTP 情况下所做的事情)。

现在我们来回顾构成我们应用程序协议的各个消息:

  • 连接到服务器不会传达除用户现在在聊天室的事实之外的其他信息,因此我们将每次只发送单词CONNECT。这条消息没有参数化。

  • 在聊天室里说话更有趣。有两个参数:昵称和文本消息本身。让我们定义这种消息的格式为A:B,其中A是昵称(作为直接后果,昵称不能包含冒号:字符)。

从这个规范中,我们可以推导出一个有效的算法(伪代码):

if ':' not in message
    then
        // it's a CONNECT message
        add this connection to user list
    else
        // it's a chat message
        nickname, text := message.split on ':'
        for each user in user list
            if not the same user:
                send "{nickname} said: {text}"

测试相同用户是为了减少用户自己的消息回传给他们的不必要的传输(回声)。

服务器源代码

在 Twisted 框架的帮助下,我们的伪代码可以几乎直接地翻译成 Python。以下列表包含我们server.py应用程序的完整源代码:

from twisted.internet import protocol, reactor

transports = set()

class Chat(protocol.Protocol):
    def dataReceived(self, data):
        transports.add(self.transport)

        if ':' not in data:
            return

        user, msg = data.split(':', 1)

        for t in transports:
            if t is not self.transport:
                t.write('{0} says: {1}'.format(user, msg))

class ChatFactory(protocol.Factory):
    def buildProtocol(self, addr):
        return Chat()

reactor.listenTCP(9096, ChatFactory())
reactor.run()

操作原理

这是帮助你理解我们的服务器是如何工作的控制流程概述:

  • 最后一行,reactor.run(),启动监听端口 9096 的ChatFactory服务器

  • 当服务器接收到输入时,它调用dataReceived()回调

  • dataReceived()方法实现了协议部分的伪代码,根据需要向其他已连接客户端发送消息

客户端连接的集合被称为transports。我们无条件地将当前传输self.transport添加到集合中,因为在现有元素的情况下,这是一个无操作,为什么要费那个劲。

列表中的其余部分严格遵循算法。因此,除了发送原始消息的用户之外,每个已连接的用户都将收到通知,< 用户名 > says: < 消息文本 >.

注意

注意我们实际上并没有检查连接消息是否说CONNECT。这是紧密遵循乔恩·波斯尔在 1980 年 TCP 规范中提出的网络鲁棒性原则的例子:发送时要保守,接受时要宽容

除了简化本例中的代码外,我们还获得了一个向前兼容性的选项。假设在未来客户端的版本中,我们向协议中添加了一条新消息,即名为WHARRGARBL的虚构消息,根据其名称,它确实做了一些真正令人惊叹的事情。而不是因为收到格式不正确的消息(在这种情况下,因为版本不匹配)而崩溃,旧版本的服务器将简单地忽略这些消息并继续运行。

具体来说,这个方面——版本之间的兼容性——可以通过多种策略轻松处理。然而,在涉及网络,尤其是公共网络时,也存在一些更困难的问题,包括恶意用户试图破坏你的系统并故意使其崩溃。因此,实际上并不存在过度夸大的服务器稳定性。

测试服务器

以通常运行任何 Python 程序的方式运行服务器:

python server.py

此命令不应产生任何可见的输出。服务器只是静静地坐着,等待客户端连接。然而,在已知的宇宙中没有任何客户端程序能够使用这个协议,因为我们大约在一页半之前就编造了它。我们如何确保服务器能正常工作?

幸运的是,这种“鸡生蛋,蛋生鸡”的问题在这个领域非常普遍,因此有许多有用的工具可以做到这一点——向任何服务器发送任意字节,并接收和显示服务器发送回的任意字节。

适用于篡改使用文本协议的服务器的标准程序之一是 Telnet。像许多“老式”Unix 风格的实用程序一样,Telnet 是一个既可以用作交互式程序,也可以作为更大批处理(shell)脚本一部分的命令行程序。

大多数操作系统都预装了telnet命令。如果没有,那么你可能正在使用 Windows 7 或更高版本。在这种情况下,你可以按照以下截图所示,转到控制面板 | 程序和功能 | 启用或关闭 Windows 功能

测试服务器

然后,确保Telnet 客户端复选框已勾选,如下所示:

测试服务器

Telnet 接受两个参数:要连接的服务器的名称和端口号。为了使用 telnet 连接到聊天服务器,你首先需要启动server.py,然后在另一个终端中运行:

telnet 127.0.0.1 9096

或者,你可以在大多数系统中使用localhost作为主机名,因为这等同于127.0.0.1;两者都表示当前机器。

如果一切顺利,你将打开一个交互式会话,你输入的每一行都会发送到服务器。现在,使用我们之前讨论的聊天协议,你可以与服务器进行通信:

CONNECT
User A:Hello, world!

将不会有输出,因为我们以这种方式编程服务器,使其不会将消息回显给原始作者——这将是一种浪费。所以,让我们再打开另一个终端(以及一个 Telnet 会话),这样我们就有两个同时连接的用户。

当一切正常时,聊天会话看起来是这样的:

测试服务器

网络上的交互式聊天达到最佳状态

注意

如果由于某种原因,无论是技术原因还是其他原因,您无法在您的系统上使用 Telnet,请不要对此感到特别难过,因为这项测试不是成功完成教程所必需的。

然而,这里有一些(非常个人化,甚至可以说是亲密的)建议,这些建议与您的职业比与本书的主题更相关:为自己做点好事,获取一个 Mac OS 或 Linux 系统,或者也许在同一台机器上使用双启动。这些类 Unix 操作系统比 Windows 更适合软件开发,而且生产力的提升完全值得适应新环境的不便。

通过这一点,我们可以得出结论:我们的服务器正在正常工作:两个 Telnet 窗口正在良好地通信。现在后端工作已经完成,让我们构建一个跨平台的 GUI 聊天客户端。

屏幕管理器

让我们从一个新概念开始 UI 开发,即屏幕管理。我们手头的应用程序,即聊天客户端,是一个合适的例子。将会有两个应用程序状态,具有不同的 UI,彼此完全独立:

  • 登录屏幕,用户在此输入要连接的主机名和所需的昵称:屏幕管理器

  • 聊天室屏幕,实际对话发生的地方:屏幕管理器

从概念上讲,这些都是聊天应用程序前端的应用程序状态。

一种简单的 UI 分离方法将涉及根据某个变量管理可见和隐藏的控件,该变量持有当前所需的 UI 状态。当小部件数量增加时,这会变得非常繁琐,而且样板代码本身就不太有趣。

正因如此,Kivy 框架为我们提供了一个专门针对此任务定制的容器小部件,即ScreenManager。此外,ScreenManager支持短动画来可视化屏幕切换,并提供多种预构建的过渡效果可供选择。它可以完全声明性地从 Kivy 语言文件中使用,而不需要接触 Python 代码。

让我们这样做。在chat.kv文件中添加以下代码:

ScreenManager:
    Screen:
        name: 'login'

        BoxLayout:
            # other UI controls -- not shown

            Button:
                text: 'Connect'
                on_press: root.current = 'chatroom'

    Screen:
        name: 'chatroom'

        BoxLayout:
            # other UI controls -- not shown

            Button:
                text: 'Disconnect'
                on_press: root.current = 'login'

这是程序的基本结构:我们在根目录有一个ScreenManager,为每个我们想要的 UI 状态(第一个将默认显示)有一个Screen容器。在Screen内部是通常的 UI:布局、按钮以及我们迄今为止看到的一切。我们很快就会接触到它。

我们刚才看到的代码还包括屏幕切换按钮,每个Screen实例一个。为了切换应用程序状态,我们需要将所需屏幕的名称分配给ScreenManagercurrent属性。

自定义动画

如前所述,当切换屏幕时发生的简短动画可以自定义。Kivy 提供了多种此类动画,位于kivy.uix.screenmanager包中:

过渡类名称 视觉效果
NoTransition 没有动画,立即显示新屏幕。
SlideTransition 滑动新屏幕。传递'left'(默认)、'right''up''down'以选择效果的方向。
SwapTransition 理论上,这个类模拟了 iOS 屏幕切换动画。但实际效果与理论相差甚远。
FadeTransition 淡出屏幕,然后淡入。
WipeTransition 使用像素着色器实现的平滑方向过渡。
FallOutTransition 将旧屏幕缩小到窗口中心并使其透明,从而显示新屏幕。
RiseInTransition FallOutTransition的完全相反:从中心生长新屏幕,重叠并隐藏旧的一个。

.kv文件中设置这些内容有一个小问题:默认情况下不会导入过渡动画,因此您需要使用以下语法(在chat.kv的顶部)导入您想要使用的动画:

#:import RiseInTransition kivy.uix.screenmanager.RiseInTransition

现在您可以将其分配给ScreenManager。请注意,这是一个 Python 类实例化,因此结尾的括号是必需的:

ScreenManager:
    transition: RiseInTransition()

登录屏幕布局

在登录屏幕内部,布局方面与上一章的录音应用非常相似:一个GridLayout解决了在网格上对齐组件的任务。

本书尚未使用的是TextInput小部件。Kivy 的文本输入几乎与按钮的行为完全相同,唯一的区别是您可以在其中输入文本。默认情况下,TextInput是多行的,因此我们将multiline属性设置为False,因为在应用程序的上下文中,多行文本输入没有太多意义。

当在未连接物理键盘的设备上运行时,Kivy 将回退到虚拟屏幕键盘,就像原生应用一样。

这是实现登录屏幕布局的代码(在同一个 Kivy 语言文件chat.kv中的ScreenManager下):

Screen:
    name: 'login'

    BoxLayout:
        orientation: 'vertical'

        GridLayout:
            Label:
                text: 'Server:'

            TextInput:
                id: server
                text: '127.0.0.1'

            Label:
                text: 'Nickname:'

            TextInput:
                id: nickname
                text: 'Kivy'

        Button:
            text: 'Connect'
            on_press: root.current = 'chatroom'

在这里,我们添加了两个文本字段,ServerNickname,以及相应的标签,还有一个连接按钮。按钮的事件处理程序目前与实际的网络无关,只是切换到聊天室,但这种情况将在不久的将来改变。

要制作单行的TextInput,需要一些有趣的样式。除了将其multiline属性设置为False外,我们还想将文本垂直居中(否则,它将粘在控制的顶部,底部留下很大的间隙)。我们可以使用如下方式使用填充属性来实现正确的对齐:

<TextInput>:
    multiline: False
    padding: [10, 0.5 * (self.height – self.line_height)]

这条padding行将左右填充设置为 10,上下填充计算为(小部件高度 - 一行文本高度)× 0.5

这是最终屏幕的显示效果;它与我们在本书的编写过程中制作的其他应用程序非常相似。

登录屏幕布局

聊天应用登录屏幕

我们现在可以开始编写连接服务器的代码,但首先让我们让主屏幕,即聊天室,工作起来。这将使我们能够立即进行有意义的测试。

聊天室屏幕布局

接下来在我们的列表中是聊天室屏幕。它包含一个用于长篇对话的ScrollView小部件,由于这是第一次在这本书中出现滚动小部件,让我们仔细看看它是如何工作的。

生成滚动小部件最简单的.kv片段如下:

<ChatLabel@Label>:
    text_size: (self.width, None)  # Step 1
    halign: 'left'
    valign: 'top'
    size_hint: (1, None)  # Step 2
    height: self.texture_size[1]  # Step 3

ScrollView:
    ChatLabel:
        text: 'Insert very long text with line\nbreaks'

如果您添加足够的文本使其溢出屏幕,它就会开始滚动,类似于您在 iOS 或 Android 中期望的长列表项。

这是这种布局的工作原理:

  1. 我们将自定义Label子类的text_size宽度(第一个值)限制为小部件的可用宽度,并通过将第二个值设置为None让它根据其内容选择高度。

  2. 然后,我们将垂直size_hint(第二个值)设置为None,以强制小部件的高度独立于其容器计算。否则,它将被父元素限制,因此将没有可滚动的内容。

  3. 现在,我们可以将小部件的高度设置为等于texture_size的高度(请注意,索引通常是零基的,所以第二个值确实是texture_size[1])。这将迫使ChatLabel比包含它的ScrollView小部件更大。

  4. ScrollView检测到其子小部件大于可用屏幕空间时,启用滚动。在移动设备上它按常规工作,并在桌面上添加鼠标滚轮支持。

滚动模式

您还可以自定义ScrollView的滚动回弹效果,以模仿对应平台的原生行为(尽管在概念上相似,但与原生组件相比,仍然看起来明显不同)。截至写作时,Android 风格的边缘发光效果不是默认支持的;可用的选项如下:

  • ScrollEffect:此效果允许您在到达末尾时突然停止滚动。这与桌面程序通常的工作方式相似,因此如果所讨论的应用程序主要针对桌面,则此行为可能是有吸引力的。

  • DampedScrollEffect:这是默认效果。它与 iOS 中找到的回弹效果相似。这可能是移动设备上最好的模式。

  • OpacityScrollEffect:此效果类似于DampedScrollEffect,在滚动过内容边缘时增加了透明度。

要使用这些设置之一,从 kivy.effects 模块导入它,并将其分配给 ScrollView.effect_cls 属性,类似于刚刚讨论的 ScreenManager 过渡。我们不会使用这个,因为 DampedScrollEffect 已经非常适合我们的应用程序。

考虑到所有这些点,这是聊天室屏幕布局的样子(在 chat.kv 中):

Screen:
    name: 'chatroom'

    BoxLayout:
        orientation: 'vertical'

        Button:
            text: 'Disconnect'
            on_press: root.current = 'login'

        ScrollView:
            ChatLabel:
                id: chat_logs
                text: 'User says: foo\nUser says: bar'

        BoxLayout:
            height: 90
            orientation: 'horizontal'
            padding: 0
            size_hint: (1, None)

            TextInput:
                id: message

            Button:
                text: 'Send'
                size_hint: (0.3, 1)

最后一行,size_hint,将 Button 小部件的横向比例设置为 0.3,低于默认的 1。这使得 发送 按钮比消息输入字段更小。

为了将消息区域的背景设置为白色,我们可以使用以下代码:

<ScrollView>:
    canvas.before:
        Color:
            rgb: 1, 1, 1
        Rectangle:
            pos: self.pos
            size: self.size

这将在每次其他绘图操作之前无条件地绘制一个白色矩形在 ScrollView 后面。别忘了调整 <ChatLabel> 类,将文本颜色设置为在浅色背景上可读:

#:import C kivy.utils.get_color_from_hex

<ChatLabel@Label>:
    color: C('#101010')

到目前为止,我们已经有了这些:

滚动模式

没有有意义对话的聊天室屏幕

再次强调,断开连接 按钮只是切换屏幕,而不会在幕后进行任何网络操作。这实际上是下一个主题;正如你很快就会看到的,用 Python 实现简单的网络程序在复杂性方面与用 Kivy 构建简单的用户界面并没有太大区别。

将应用上线

这是最有趣的部分!我们将与服务器建立连接,发送和接收消息,并向用户显示有意义的输出。

但首先,让我们看看聊天客户端的最小、纯 Python 实现,看看发生了什么。这是使用套接字进行通信的低级代码。在实际应用中,使用更高层次的抽象,如 Twisted,几乎总是建议的;但如果你不熟悉底层概念,可能很难理解代码背后的实际发生情况,这使得调试变成了猜测。

构建简单的 Python 客户端

在以下列表中,我们使用内置的 readline() 函数从控制台读取用户输入,并使用 print() 函数显示输出。这意味着使用这个简单的客户端与使用 Telnet 并无太大区别——UI 由终端窗口中的相同纯文本组成——但这次我们是自己从头开始使用套接字实现的。

我们将需要一些 Python 模块,所有这些模块都来自标准库:socketsys(用于 sys.stdin,标准输入文件描述符)和 select 模块,以实现高效等待数据可用。假设一个新的文件,让我们称它为 client.py

import select, socket, sys

这个程序根本不需要外部依赖;这是最纯粹的 Python。

注意

注意,在 Windows 上,由于实现细节,select 无法像套接字那样轮询文件描述符,因此我们的代码将无法正确运行。由于这只是一个低级网络演示,而不是最终产品,我们不会将其移植到边缘系统。

现在,我们打开到服务器的连接并执行通常的CONNECT握手:

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 9096))
s.send('CONNECT')

下一个有趣的部分:我们等待标准输入(意味着用户输入了某些内容)或s套接字(意味着服务器发送了某些内容给我们)上的数据变得可用。等待是通过使用select.select()调用来实现的:

rlist = (sys.stdin, s)
while 1:
    read, write, fail = select.select(rlist, (), ())
    for sock in read:
        if sock == s:  # receive message from server
            data = s.recv(4096)
            print(data)
        else:  # send message entered by user
            msg = sock.readline()
            s.send(msg)

然后,根据新可用数据源的不同,我们要么在收到来自服务器的消息时将其打印到屏幕上,要么将其发送到服务器,如果它是来自本地用户的消息。再次强调,这基本上就是 Telnet 所做的工作,但没有错误检查。

如您所见,在低级网络中,并没有什么本质上不可能或疯狂复杂的事情。但是,就其价值而言,原始套接字仍然相当难以处理,我们很快将展示相同代码的高级方法。然而,这正是任何框架底层的运作方式;最终,总是套接字在承担重活,只是被不同的抽象(API)所呈现。

小贴士

注意,在这个教程中,我们故意没有进行广泛错误检查,因为这会使代码量增加 2-3 倍,并使其难以控制。

在网络上可能会出现很多问题;它比人们通常认为的要脆弱得多。所以如果你计划与 Skype 等软件竞争,准备好进行大量的错误检查和测试:例如,网络问题如数据包丢失和全国范围内的防火墙,肯定会在某个时刻出现。无论你的架构计划多么周密,使网络服务高度可用都是一项艰巨的任务。

Kivy 与 Twisted 的集成

我们的低级客户端代码不适合 Kivy 应用程序的另一个原因是它依赖于自己的主循环(即while 1:部分)。要使这段代码与驱动 Kivy 的事件循环良好地协同工作,需要做一些工作。

相反,让我们利用作为 Kivy 的一部分分发的 Twisted 集成。这也意味着相同的网络库将在客户端和服务器上使用,使代码在整个系统中更加统一。

要使 Kivy 的主循环与 Twisted 良好地协同工作,需要在导入 Twisted 框架之前运行以下代码:

from kivy.support import install_twisted_reactor
install_twisted_reactor()

from twisted.internet import reactor, protocol

代码应该在main.py文件的非常开头部分。这是至关重要的,否则一切都将以一种神秘的方式停止工作。

现在,让我们使用 Twisted 来实现聊天客户端。

ChatClient 和 ChatClientFactory

在 Twisted 方面,实际上要做的事情很少,因为框架负责处理与实际网络相关的一切。这些类主要用于将程序的“移动部件”连接起来。

ClientFactory子类ChatClientFactory在初始化时仅存储 Kivy 应用程序实例,以便我们可以在以后传递事件给它。请看以下代码:

class ChatClientFactory(protocol.ClientFactory):
    protocol = ChatClient

    def __init__(self, app):
        self.app = app

相对应的ChatClient类监听 Twisted 的connectionMadedataReceived事件,并将它们传递给 Kivy 应用程序:

class ChatClient(protocol.Protocol):
    def connectionMade(self):
        self.transport.write('CONNECT')
        self.factory.app.on_connect(self.transport)

    def dataReceived(self, data):
        self.factory.app.on_message(data)

注意无处不在的CONNECT握手。

这与使用原始套接字的代码非常不同,对吧?同时,这非常类似于在server.py服务器端发生的事情。但是,我们不是真正处理事件,而是将它们传递给app对象。

UI 集成

为了最终看到整个画面,让我们将网络代码连接到 UI,并编写缺失的 Kivy 应用程序类。以下是对chat.kv文件需要应用的累积更新:

Button:  # Connect button, found on login screen
    text: 'Connect'
    on_press: app.connect()

Button:  # Disconnect button, on chatroom screen
    text: 'Disconnect'
    on_press: app.disconnect()

TextInput:  # Message input, on chatroom screen
    id: message
    on_text_validate: app.send_msg()

Button:  # Message send button, on chatroom screen
    text: 'Send'
    on_press: app.send_msg()

注意按钮不再切换屏幕,而是调用app上的方法,类似于ChatClient事件处理。

在完成这些之后,我们现在需要实现 Kivy 应用程序类中缺失的五个方法:两个用于来自 Twisted 代码的服务器端事件(on_connecton_message),以及另外三个用于用户界面事件(connectdisconnectsend_msg)。这将使我们的聊天应用程序真正可用。

客户端应用程序逻辑

让我们从大致的生命周期顺序开始编写程序逻辑:从connect()disconnect()

connect()方法中,我们获取用户提供的服务器昵称字段的值。然后,昵称被存储在self.nick中,Twisted 客户端连接到指定的主机,如下面的代码所示:

class ChatApp(App):
    def connect(self):
        host = self.root.ids.server.text
        self.nick = self.root.ids.nickname.text
        reactor.connectTCP(host, 9096,
                           ChatClientFactory(self))

现在,调用ChatClient.connectionMade()函数,将控制权传递给on_connect()方法。我们将使用这个事件将连接存储在self.conn中并切换屏幕。正如之前讨论的,按钮不再直接切换屏幕;相反,我们依赖于更具体的事件处理器,如这个:

# From here on these are methods of the ChatApp class
def on_connect(self, conn):
    self.conn = conn
    self.root.current = 'chatroom'

现在是主要部分:发送和接收消息。实际上,这非常直接:要发送消息,我们从TextInput获取消息文本,从self.nick获取我们的昵称,将它们连接起来,然后将生成的行发送到服务器。我们还在屏幕上回显相同的消息并清除消息输入框。代码如下:

def send_msg(self):
    msg = self.root.ids.message.text
    self.conn.write('%s:%s' % (self.nick, msg))
    self.root.ids.chat_logs.text += ('%s says: %s\n' %
                                     (self.nick, msg))
    self.root.ids.message.text = ''

接收消息是完全微不足道的;因为我们没有主动跟踪它们,只需将新到达的消息显示在屏幕上,然后换行,就完成了:

def on_message(self, msg):
    self.root.ids.chat_logs.text += msg + '\n'

最后剩下的方法是disconnect()。它确实做了它所说的:关闭连接并执行一般清理,以便将事物恢复到程序首次启动时的状态(特别是清空chat_logs小部件)。最后,它将用户送回登录屏幕,以便他们可以跳转到另一个服务器或更改昵称。代码如下:

    def disconnect(self):
        if self.conn:
            self.conn.loseConnection()
            del self.conn
        self.root.current = 'login'
        self.root.ids.chat_logs.text = ''

这样,我们的应用程序终于有了发送和接收聊天消息的能力。

客户端应用程序逻辑

聊天应用程序运行中

小贴士

注意事项

在测试期间,server.py脚本显然应该始终运行;否则,我们的应用程序将没有连接的端点。目前,这将导致应用程序停留在登录屏幕;如果没有on_connect()调用,用户将无法进入聊天室屏幕。

此外,当在 Android 上进行测试时,请确保输入服务器的正确 IP 地址,因为它将不再是127.0.0.1——那总是本地机器,所以在 Android 设备上这意味着是设备本身而不是你正在工作的电脑。使用ifconfig实用程序(在 Windows 上称为ipconfig以增加混淆)来确定你机器的正确网络地址。

跨应用互操作性

结果应用程序的一个有趣特性(除了它实际上能工作之外)是它与本章中提到的所有客户端都兼容。用户可以使用 Telnet、纯 Python 客户端或 Kivy UI 程序连接到服务器——核心功能对所有用户都是同样可用的。

这与互联网的运作方式非常相似:一旦你有一个定义良好的协议(如 HTTP),许多无关的各方可以开发服务器和客户端,它们最终将是互操作的:Web 服务器、Web 浏览器、搜索引擎爬虫等等。

协议是 API 的一种高级形式,它是语言和系统无关的,就像一个好的基础应该那样。虽然不是很多网络开发者熟悉例如 2007 年发布的微软 Silverlight 的 API,但在这个领域工作的任何人至少都了解 HTTP 的基础,它是在 1991 年记录的。这种普及程度几乎不可能通过库或框架来实现。

增强和视觉享受

现在我们的聊天基本上已经工作,我们可以给它添加一些最后的修饰,例如改进聊天日志的展示。由于客户端已经显示了服务器发送的所有内容,我们可以轻松地使用 Kivy 标记(类似于BBCode的标记语言,在第一章中讨论,构建时钟应用)来样式化对话日志。

要做到这一点,让我们为每个用户分配一个颜色,然后用这个颜色绘制昵称并使其加粗。这将有助于可读性,并且通常比单色的纯文本墙看起来更美观。

我们将使用Flat UI调色板而不是生成纯随机颜色,因为生成看起来搭配在一起时看起来好的显著不同的颜色本身就不是一件容易的事情。

发送的消息(由当前用户发送的消息)不是来自服务器,而是由客户端代码添加到聊天日志中。因此,我们将使用恒定颜色直接在客户端上绘制当前用户的昵称。

在这次更新之后,聊天服务器的最终代码server.py如下所示:

colors = ['7F8C8D', 'C0392B', '2C3E50', '8E44AD', '27AE60']

class Chat(protocol.Protocol):
    def connectionMade(self):
        self.color = colors.pop()
        colors.insert(0, self.color)

给定一个有限的颜色列表,我们从列表的末尾弹出一个颜色,然后将其重新插入到列表的前端,创建一个旋转缓冲区。

小贴士

如果你熟悉标准库中的更高级的itertools模块,你可以像这样重写我们刚才看到的代码:

import itertools
colors = itertools.cycle(('7F8C8D', 'C0392B', '2C3E50', '8E44AD', '27AE60'))
def connectionMade(self):
    self.color = colors.next()
    # next(colors) in Python 3

现在,我们将讨论将消息传递给客户端的部分。期望效果的标记非常简单:[b][color]Nickname[/color][/b]。利用它的代码同样简单:

for t in transports:
    if t is not self.transport:
        t.write('[b][color={}]{}:[/color][/b] {}'
                .format(self.color, user, msg))

main.py中的客户端也更新以匹配格式,如前所述。这里有一个常量颜色,与服务器分配的不同,这样当前用户总是突出显示。代码如下:

def send_msg(self):
    msg = self.root.ids.message.text
    self.conn.write('%s:%s' % (self.nick, msg))
    self.root.ids.chat_logs.text += (
        '[b][color=2980B9]{}:[/color][/b] {}\n'
        .format(self.nick, msg))

然后,我们将对话日志小部件ChatLabelmarkup属性设置为True,如下面的代码片段所示,我们(几乎)完成了:

<ChatLabel@Label>:
    markup: True

然而,在我们用这种方法解决问题之前(实际上这里确实至少有一个严重的问题),这是必须的最终截图。这就是最终对话屏幕的样子:

增强和视觉享受

彩色的聊天记录有助于可读性,并且通常看起来更好,更“精致”

转义特殊语法

如前所述,这个代码的一个缺点是,现在我们在协议中有特殊的语法,在客户端以某种方式解释。用户可以伪造(或者纯粹偶然地使用,纯粹是巧合)BBCode 风格的标记,造成不想要的视觉错误,例如分配非常大的字体大小和难以阅读的颜色。例如,如果某个用户发布了一个未关闭的[i]标签,聊天室中所有随后的文本都将被设置为斜体。这相当糟糕。

为了防止用户以随机的方式突出显示文本,我们需要转义消息中可能存在的所有标记。幸运的是,Kivy 提供了一个函数来完成这项工作,即kivy.utils.escape_markup。不幸的是,这个函数自 2012 年以来就存在 bug。

有很高的可能性,当你阅读这本书的时候,这个函数已经被修复了,但为了完整性,这里有一个可行的实现:

def esc_markup(msg):
    return (msg.replace('&', '&amp;')
            .replace('[', '&bl;')
            .replace(']', '&br;'))

通过这种方式,所有对 Kivy 标记特殊字符都被替换为 HTML 风格的字符实体,因此通过这个函数传递的标记将按原样显示,并且不会以任何方式影响富文本属性。

我们需要在两个地方调用这个函数,在服务器发送消息给客户端时,以及在客户端显示来自 self(当前用户)的消息时。

server.py中,相关代码如下:

t.write('[b][color={}]{}:[/color][/b] {}'
        .format(self.color, user,
                esc_markup(msg)))

main.py中,实现方式类似:

self.root.ids.chat_logs.text += (
    '[b][color=2980B9]{}:[/color][/b] {}\n'
    .format(self.nick, esc_markup(msg)))

在这里,漏洞已被修复;现在,如果用户选择这样做,他们可以安全地向彼此发送 BBCode 标记。

注意

有趣的是,这种类型的 bug 在互联网应用中也非常普遍。当应用于网站时,它被称为跨站脚本XSS),它允许造成比仅仅更改字体和颜色更多的损害。

不要忘记在所有可能涉及命令(如标记、内联脚本,甚至是 ANSI 转义码)与数据混合的场景中对所有用户输入进行清理;忽视这一点将是一场灾难,只是等待发生。

下一步

显然,这仅仅是开始。当前的实施方案仍然存在大量的缺陷:用户名没有被强制要求唯一,没有历史记录,也没有支持在其他人离线时获取已发送的消息。因此,网络状况不佳且频繁断开连接将使这个应用程序基本无法使用。

但重要的是,这些问题肯定是可以解决的,我们已经有了一个工作原型。在创业界,拥有一个原型是一个吸引人的特质,尤其是在筹集资金时;如果你主要是为了娱乐而编程,那就更是如此,因为看到一款工作产品是非常有动力的(相比之下,观察一堆尚未运行的代码就没有那么有动力了)。

摘要

正如我们在本章中看到的,客户端-服务器应用程序开发(以及一般而言,应用层面的网络)并不一定本质上复杂。即使是利用套接字的底层代码也是相当容易管理的。

当然,在编写大量使用网络的程序时,有许多灰色区域和难以处理的问题。这些问题的例子包括处理高延迟、恢复中断的连接,以及在大量节点(尤其是点对点或多主节点,当没有任何机器拥有完整数据集时)上进行同步。

另一类相对较新的网络问题是政治问题。最近,不同压迫程度的政府正在实施互联网法规,从相对合理(例如,封锁推广恐怖主义的资源)到完全荒谬(例如,禁止像维基百科、主要新闻网站或视频游戏这样的教育网站)。这种类型的连接问题也以其高附带损害而闻名,例如,如果内容分发网络CDN)崩溃,那么许多链接到它的网站将无法正常工作。

然而,通过仔细的编程和测试,确实有可能克服每一个障碍,并向用户交付一个质量卓越的产品。丰富的 Python 基础设施为你承担了部分负担,正如我们在聊天程序中所展示的那样:许多底层细节都通过 Kivy 和 Twisted 这两个优秀的 Python 库得到了抽象化。

考虑到普遍的可用性,这个领域的可能性几乎是无尽的。我们将在下一章讨论和实施一个更有趣的网络应用程序用例,所以请继续阅读。

第五章:制作远程桌面应用程序

为了总结上一章开始的网络主题,让我们构建另一个客户端-服务器应用程序——一个远程桌面应用程序。这次我们的应用程序将解决一个更复杂的实际任务,并使用“真实”的应用层协议进行通信。

让我们暂时讨论一下手头的任务。首先,目的是:一个典型的远程桌面程序允许用户通过局域网或互联网远程访问其他计算机。这种应用程序通常用于临时技术支持或远程协助,例如,大公司中的 IT 人员。

其次,关于术语:主机机器是被远程控制的那一台(运行远程控制服务器),而客户端是控制主机的系统。远程系统管理基本上是用户通过另一台计算机系统(客户端)作为代理与主机机器进行交互的过程。

因此,整个努力归结为以下活动:

  • 在客户端收集相关用户输入(如鼠标和键盘事件)并将其应用于主机

  • 从主机机器发送任何相关输出(通常是屏幕截图,有时是音频等)回客户端

这两个步骤会重复执行,直到会话结束,机器之间的连接关闭。

我们之前讨论的定义非常广泛,许多商业软件包在功能完整性上竞争,有些甚至允许你远程玩视频游戏——带有加速图形和游戏控制器输入。我们将限制我们工作的范围,以便项目可以在合理的时间内完成:

  • 对于用户输入,只接受并发送点击(或轻触,在此上下文中没有区别)。

  • 对于输出,只捕获屏幕截图,因为捕获声音并通过网络传输可能对教程来说过于具有挑战性。

  • 仅支持 Windows 主机。任何较新的 Windows 版本都应没问题;建议使用 Windows 7 或更高版本。我们假设是桌面操作系统,而不是 WinRT 或 Windows Phone。客户端没有这样的限制,因为它运行的是便携式 Kivy 应用程序。

最后一点是不幸的,但既然每个系统都使用不同的 API 来截图和模拟点击,我们仍然应该从最流行的一个开始。可以在稍后添加对其他主机系统的支持;这本身并不复杂,只是非常特定于平台。

备注

关于操作系统选择:如果你不使用 Windows 操作系统,不用担心。这和之前的 Android 一样:你可以在虚拟机中轻松运行 Windows。VirtualBox VM 是桌面虚拟化的首选解决方案,并且可以从官方网站免费获取www.virtualbox.org/

在 Mac 上,Parallels 在可用性和操作系统集成方面是一个更好的选择。唯一的可能缺点是它的价格昂贵。

在本章中,我们将介绍以下感兴趣的主题:

  • 使用 Flask 微框架在 Python 中编写 HTTP 服务器

  • 使用Python Imaging LibraryPIL)进行截图

  • 利用 WinAPI 功能在 Windows 上模拟点击

  • 设计一个简单的 JavaScript 客户端并用于测试

  • 最后,为我们的远程桌面服务器构建一个基于 Kivy 的 HTTP 客户端应用

服务器

为了简化测试和可能的未来集成,我们希望这次让我们的服务器使用一个成熟的应用层协议。让我们使用超文本传输协议HTTP);除了相对简单和易于测试之外,它还具有至少两个更有价值的特性:

  • 丰富的库支持,包括服务器端和客户端。这显然是 HTTP 作为互联网(迄今为止最大和最受欢迎的网络)的推动力。

  • 与许多其他协议不同,对于 HTTP,我们可以编写一个非常简单的概念验证 JavaScript 客户端,该客户端在网页浏览器中运行。这虽然与本书的主题没有直接关系,但在许多场景中可能很有用,尤其是在调试时。

我们将利用 Flask 库来构建服务器。还有一个流行的 Python 网络框架 Django,也非常推荐。然而,Django 项目通常最终会变得比较庞大,所以我们将坚持使用 Flask 来构建这个简单的服务器。

要在服务器上安装 Flask,以下命令就足够了:

pip install Flask

如果您还没有安装pip,请首先尝试运行easy_install pip命令。根据您的 Python 设置,您可能需要以具有足够权限的特权用户身份运行此命令。

在 Windows 上,Python 的设置通常比 Mac OS 或 Linux 复杂得多;请参阅上一章中关于 Python 包管理的更详细信息。或者,您可以直接跳转到官方 pip 参考,网址为pip.pypa.io/en/latest/installing.html。本文件涵盖了在所有支持的操作系统上安装pip

注意

注意,与上一章我们构建的项目(聊天应用)类似,Kivy 框架不需要在服务器上安装。我们的服务器端代码是无头运行的,没有任何用户界面——除了偶尔的命令行输出。

Flask 网络服务器

一个网络服务器通常由一系列绑定到不同 URL 的处理程序组成。这种绑定通常被称为路由。Flask(以及其他框架)的目标之一是消除这种绑定,并使向程序添加新路由变得容易。

最简单的单页 Flask 服务器(让我们称它为server.py)如下所示:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello, Flask'

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=7080, debug=True)

在 Flask 中,使用装饰器如@app.route('/')来指定路由,当你只有少量不同的 URL 时,这非常完美。

'/' 路由是服务器根目录;当你将域名输入地址栏时,这是默认设置。要在你的浏览器中打开我们刚刚编写的简单网站,只需在同一台机器上访问 http://127.0.0.1:7080(别忘了先启动服务器)。当你这样做时,应该会看到一个 Hello, Flask 消息,确认我们的玩具 HTTP 服务器正在工作。

Flask 网络服务器

由 Flask 驱动的简约网站

对于不熟悉网络服务器的人来说,可能会对 app.run() 中的奇怪参数感到好奇,即 0.0.0.0 IP 地址。这不是一个有效的 IP 地址,你可以连接到它,因为它本身并不指定一个网络接口(它是不可路由的)。在服务器的上下文中,绑定到这个 IP 地址通常意味着我们希望我们的应用程序监听所有 IPv4 网络接口——也就是说,响应来自机器上所有可用 IP 地址的请求。

这与默认的本地主机(或 127.0.0.1)设置不同:仅监听本地主机 IP 允许来自同一台机器的连接,因此,这种操作模式对于调试或测试可能很有帮助。然而,在这个例子中,我们使用了一个更面向生产的设置,0.0.0.0——这使得机器可以从外部世界(通常是局域网)访问。请注意,这并不会自动绕过路由器;它应该适用于您的局域网,但要使其在全球范围内可访问可能需要额外的配置。

此外,别忘了允许服务器通过防火墙,因为它的优先级高于应用程序级别的设置。

注意

端口号选择

端口号本身并没有太多意义;重要的是你在服务器和客户端使用相同的数字,无论是网页浏览器还是 Kivy 应用。

请注意,在几乎所有的系统中,端口号低于 1024 的端口只能由特权用户账户(root 或管理员)打开。那个范围内的许多端口已经被现有的服务占用,因此不建议为应用程序的特定需求选择低于 1024 的端口号。

HTTP 协议的默认端口号是 80,例如,www.w3.org/www.w3.org:80/ 相同,通常你不需要指定它。

你可能会注意到,Python 的网络开发非常简单——只有几行长的 Python 脚本就可以让你启动一个动态网络服务器。预期的是,并不是所有的事情都是这么简单;一些事情并不是立即以可重用库的形式可用。

顺便说一句,这可以被视为一种竞争优势:如果你在实现一个非平凡的功能时遇到困难,那么这种东西的实例可能不多,甚至没有,这使得你的产品最终更加独特和具有竞争力。

高级服务器功能 - 截图

一旦我们确定了协议和服务器端工具包,接下来的挑战包括从客户端获取截图和模拟点击。快速提醒一下:在本节中,我们将仅涵盖 Windows 特定的实现;添加 Mac 和 Linux 支持留作读者的练习。

幸运的是,PIL 正好有我们需要的函数;通过调用PIL.ImageGrab.grab(),我们得到 Windows 桌面的 RGB 位图截图。剩下的只是将其连接到 Flask,以便通过 HTTP 正确提供截图。

我们将使用一个旧的和基本上不再维护的 PIL 模块的分支,称为Pillow。顺便说一句,Pillow 是一个许多开发者使用的优秀开源项目;如果你想为 Python 用户空间做出贡献,那就不用再看了。一个好的起点将是 Pillow 的官方文档pillow.readthedocs.org/

按照你安装 Flask 的方式安装库:

pip install Pillow

Pillow 为 Windows 预包装了二进制文件,因此你不需要在机器上安装编译器或 Visual Studio。

现在我们已经准备就绪。以下代码演示了如何从 Flask 服务器提供截图(或任何其他 PIL 位图):

from flask import send_file
from PIL import ImageGrab
from StringIO import StringIO

@app.route('/desktop.jpeg')
def desktop():
    screen = ImageGrab.grab()
    buf = StringIO()
    screen.save(buf, 'JPEG', quality=75)
    buf.seek(0)
    return send_file(buf, mimetype='image/jpeg')

如果你不太熟悉StringIO,它是一个存储在内存中而不是写入磁盘的文件类似对象。这种“虚拟文件”在需要使用期望在虚拟数据上有一个文件对象的 API 时非常有用。在这个例子中,我们不想将截图存储在物理文件中,因为截图是临时的,按定义不可重复使用。不断将数据写入磁盘的巨大开销是不合理的;通常更好(并且更快)的是分配一块内存,并在响应发送后立即释放它。

代码的其他部分应该是显而易见的。我们通过PIL.ImageGrab.grab()调用获取screen图片,使用screen.save()将其保存为有损、低质量的 JPEG 文件以节省带宽,最后将图像以'image/jpeg'的 MIME 类型发送给用户,这样它将被网络浏览器立即识别为正确类型的图片。

注意

在这种情况下,就像在其他许多情况下一样,低质量实际上是系统的理想属性;我们正在优化吞吐量和往返速度,而不是单个帧的视觉质量。

对于低质量代码的含义也是如此:有时能够快速制作一个原型实际上是非常好的,例如,当摆弄新概念或进行市场研究时。

虽然一开始看起来很奇怪,但buf.seek(0)调用是必需的,以重置StringIO实例;否则,它位于数据流的末尾,不会向send_file()提供任何内容。

现在你可以通过将你的浏览器指向http://127.0.0.1:7080/desktop.jpeg来测试我们迄今为止的服务器实现,并查看运行server.py脚本的机器的 Windows 桌面。如果代码正确无误,它应该会产生以下截图所示的图片:

高级服务器功能 – 截图功能

通过 Flask 服务器看到的 Windows 桌面(片段)

这里有趣的部分是路由,"desktop.jpeg"。根据文件命名 URL 已经成为一种惯例,因为古老的网络服务器工具,如个人主页PHP),一种适合构建简单动态站点的玩具编程语言,是在物理文件上操作的。这意味着基本上没有路由的概念——你只需在地址栏中输入脚本的名称,就可以在服务器上运行它。

显然,这为网络服务器安全留下了巨大的漏洞,包括(但不限于)通过输入例如'/../../etc/passwd'来远程查看系统配置文件,以及能够上传并运行恶意脚本作为特洛伊木马(后门),最终用于控制服务器。

Python 网络框架大多已经吸取了这个教训。虽然你可以尝试使用 Python 复制这样一个不安全的设置,但这既不简单,也强烈不建议这样做。此外,Python 库通常不会默认捆绑有不良的 PHP 风格配置。

今天,直接从文件系统中提供实际文件并不罕见,但主要用于静态文件。尽管如此,我们有时会像文件一样命名动态路由(例如/index.html/desktop.jpeg等),以传达用户应该从这样的 URL 中期望的内容类型。

模拟点击

截图部分完成后,服务器上需要实现的最后一个非平凡的功能是点击模拟。为此,我们不会使用外部库;我们将使用 WinAPI(直接或间接为所有 Windows 应用程序提供动力的底层编程接口),通过内置的 Python ctypes模块来实现。

但首先我们需要从 URL 中获取点击坐标。让我们使用类似这样的常规GET参数:/click?x=100&y=200。在浏览器中手动测试应该很简单,与可能需要额外软件来模拟的 POST 和其他 HTTP 方法相比。

Flask 内置了一个简单的 URL 参数解析器,它们可以通过以下代码片段访问:

from flask import request

@app.route('/click')
def click():
    try:
        x = int(request.args.get('x'))
        y = int(request.args.get('y'))
    except TypeError:
        return 'error: expecting 2 ints, x and y'

在原型设计时,这里推荐进行错误处理,因为很容易忘记或发送格式不正确的参数,所以我们正在检查这一点——从 GET 请求参数中获取数字的能力。如果看到这个明显的错误消息,响应也有助于调试,因为它完全清楚发生了什么以及在哪里查找问题——在传递给 /click 的参数的代码中。

在我们获得点击的坐标后,需要调用 WinAPI。我们需要两个函数,这两个函数都位于 user32.dll 中:SetCursorPos() 函数用于移动鼠标指针,mouse_event() 函数用于模拟一系列鼠标相关事件,例如鼠标按钮的按下或释放。

注意

顺便说一下,user32.dll 中的 32 部分与你的系统是 32 位还是 64 位无关。Win32 API 首次出现在 Windows NT 中,它比 AMD64(x86_64)架构早至少 7 年,被称为 Win32,而不是较旧的 16 位 WinAPI。

mouse_event() 函数的第一个参数是一个事件类型,它是一个 C 枚举(换句话说,一组整数常量)。为了提高可读性,让我们在我们的 Python 代码中定义这些常量,因为使用字面量 2 表示 鼠标按下4 表示 鼠标释放 并不是很直观。这相当于以下几行代码:

import ctypes
user32 = ctypes.windll.user32  # this is the user32.dll reference

MOUSEEVENTF_LEFTDOWN = 2
MOUSEEVENTF_LEFTUP = 4

提示

关于 WinAPI 函数和常量的完整参考,请访问 Microsoft 开发者网络MSDN)网站,或者更具体地说,以下链接:

由于内容量较大,在这里重现此内容是不可行的,而且我们无论如何也不会使用大多数可用功能;WinAPI 包罗万象,几乎可以做任何事情,通常有多种方式。

这是最有趣的部分:我们实际上可以模拟点击。 (函数的第一部分,其中 xyGET 参数中获取,保持不变。) 代码如下:

@app.route('/click')
def click():
    try:
        x = int(request.args.get('x'))
        y = int(request.args.get('y'))
    except:
        return 'error'

    user32.SetCursorPos(x, y)
    user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
    user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
    return 'done'

如果你尝试大声阅读代码,这个函数就会做它所说的:该函数将鼠标移动到所需位置,然后模拟左键鼠标点击(按钮按下和释放是两个独立的行为)。

现在,你应该能够手动控制宿主机器上的鼠标光标。尝试访问一个 URL,例如 http://127.0.0.1:7080/click?x=10&y=10,并确保屏幕的左上角有东西。你会注意到那个项目是如何被选中的。

你甚至可以快速刷新页面来执行双击。这可能需要你在另一台机器上运行浏览器;别忘了用实际的宿主 IP 地址替换 127.0.0.1

JavaScript 客户端

在本节中,我们将简要介绍 JavaScript 远程桌面客户端原型开发,这主要是因为我们使用了 HTTP 协议。这个简单的客户端将在浏览器中运行,并作为我们接下来要构建的 Kivy 远程桌面应用程序的原型。

如果你不太熟悉 JavaScript,不要担心;这种语言很容易上手,根据代码风格,甚至可能看起来与 Python 相似。我们还将使用 jQuery 来处理重负载,例如 DOM 操作和 AJAX 调用。

小贴士

在生产环境中,jQuery 的使用可能会受到批评(这是合理的),尤其是在追求精简、高性能的代码库时。然而,对于快速原型设计或虚荣 Web 应用,jQuery 非常出色,因为它可以快速编写出功能性的,尽管不是最优的,代码。

对于一个 Web 应用,我们需要提供一个完整的 HTML 页面,而不仅仅是 Hello, Flask。为此,让我们创建一个名为 static 的文件夹中的 index.html 文件,这是 Flask 期望找到它的位置:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Remote Desktop</title>
    </head>
    <body>
        <script src="img/"></script>
        <script>
            // code goes here
        </script>
    </body>
</html>

上述列表是一个非常基本的 HTML5 文档。目前它没有任何特殊功能:jQuery 是从官方 CDN 加载的,但仅此而已——还没有任何动态部分。

要从 Flask 提供这个新文件,将 server.py 中的 index() 函数替换为以下代码:

@app.route('/')
def index():
    return app.send_static_file('index.html')

这与之前提到的 desktop() 函数工作原理相同,但这次是从磁盘读取一个真实文件。

无限截图循环

首先,让我们显示一个连续的屏幕录制:我们的脚本将每两秒请求一个新的截图,然后立即显示给用户。由于我们正在编写一个 Web 应用,所有复杂的事情实际上都是由浏览器处理的:一个 <img> 标签加载图像并在屏幕上显示,我们几乎不需要做任何工作。

这里是这个功能的算法:

  1. 移除旧的 <img> 标签(如果有)

  2. 添加一个新的 <img> 标签

  3. 2 秒后重复

在 JavaScript 中,可以这样实现:

function reload_desktop() {
    $('img').remove()
    $('<img>', {src: '/desktop.jpeg?' +
                Date.now()}).appendTo('body')
}

setInterval(reload_desktop, 2000)

这里有两件事可能需要一些额外的洞察:

  • $() jQuery 函数用于选择页面上的元素,以便我们可以对它们执行各种操作,例如 .remove().insert()

  • Date.now() 返回当前时间戳,即自 1970 年 1 月 1 日以来的毫秒数。我们使用这个数字来防止缓存。每次调用都会不同;因此,当附加到(否则恒定的)/desktop.jpeg URL 时,时间戳将使其对网络浏览器来说是唯一的。

让我们也将图像缩小,使其不超过浏览器窗口的宽度,并移除任何边距。这也很简单实现;只需在 HTML 文档的 <head> 部分添加这个小样式表:

<style>
    body { margin: 0 }
    img { max-width: 100% }
</style>

尝试调整浏览器窗口大小,注意图像如何缩小以适应。

无限截图循环

在浏览器中查看的远程桌面,已缩放到浏览器窗口大小

你也可能注意到,在重新加载时图像闪烁。这是因为我们在图片完全加载之前立即向用户显示 desktop.jpeg。比视觉故障更严重的是下载的固定时间框架,我们任意选择为两秒。在慢速网络连接的情况下,用户将无法完成下载并看到他们桌面的完整图片。

我们将在 Kivy 远程桌面客户端的实现中解决这些问题。

将点击事件传递给主机

这是更有趣的部分:我们将捕获 <img> 元素上的点击事件并将它们传递到服务器。这是通过在(反直觉地)<body> 元素上使用 .bind() 实现的。这是因为我们不断地添加和删除图片,所以绑定到图片实例上的任何事件在下次刷新后都会丢失(而且不断地重新绑定它们既是不必要的重复也是错误的)。代码列表如下:

function send_click(event) {
    var fac = this.naturalWidth / this.width
    $.get('/click', {x: 0|fac * event.clientX,
                     y: 0|fac * event.clientY})
}

$('body').on('click', 'img', send_click)

在此代码中,我们首先计算“实际”的点击坐标:图片可能被缩小以适应浏览器宽度,所以我们计算比例并将点击位置乘以该比例:

将点击事件传递给主机

JavaScript 中的 0|expression 语法是 Math.floor() 的更优替代品,因为它既更快又更简洁。还有一些微小的语义差异,但在这个阶段(如果有的话)并不重要。

现在,利用 jQuery 的 $.get() 辅助函数,我们将前一次计算的结果发送到服务器。由于我们很快就会显示一个新的截图,所以不需要处理服务器的响应——如果我们的最后操作有任何效果,它将通过视觉反映出来。

使用这个简单的远程桌面客户端,我们已经有能力查看远程主机的屏幕,启动并控制在该机器上运行的程序。现在,让我们在 Kivy 中重新实现这个原型,并在过程中改进它,特别是使其更适合在移动设备上使用,添加滚动并消除闪烁。

Kivy 远程桌面应用

是时候使用 Kivy 构建一个功能齐全的远程桌面客户端了。我们可以从上一个应用程序中重用几个东西,来自 第四章 的 Chat 应用程序,Kivy 网络。从概念上讲,这些应用程序相当相似:每个应用程序都有两个屏幕,其中一个屏幕类似于带有服务器 IP 地址的登录表单。让我们利用这种相似性,并在我们的全新 remotedesktop.kv 文件中重用 chat.kv 文件的部分,特别是实际上没有变化的 ScreenManager 设置。

登录表单

以下列表定义了登录表单。它由三个元素组成——字段标题、输入字段本身和登录按钮——位于屏幕顶部的行中:

Screen:
    name: 'login'

    BoxLayout:
        orientation: 'horizontal'
        y: root.height - self.height

        Label:
            text: 'Server IP:'
            size_hint: (0.4, 1)

        TextInput:
            id: server
            text: '10.211.55.5'  # put your server IP here

        Button:
            text: 'Connect'
            on_press: app.connect()
            size_hint: (0.4, 1)

这次只有一个输入字段,服务器 IP。实际上,如果你可以从给定的机器解析主机名,你也可以输入它,但让我们坚持这种命名方式,因为它更不模糊。局域网可能没有 DNS 服务器,或者它可能被配置成不符合用户对主机名的期望。

登录表单

简单且明确的登录表单

IP 地址并不是非常用户友好,但在这里我们没有太多选择——构建一个自动发现网络服务来避免这种情况,虽然在现实场景中非常理想,但也可能非常复杂(而且可能因为可用的技术和可能的注意事项数量众多而值得有自己的书籍)。

注意

为了在复杂场景中处理机器,例如连接到位于路由器后面的机器,你需要了解基本的网络知识。如前所述,这基本上超出了本工作的范围,但这里有一些快速提示:

  • 当所有你的机器都坐在同一个网络中(从路由器的同一侧拓扑连接)时,测试网络应用程序要容易得多。

  • 将前面的观点推向极端意味着在每个物理机器上的 VM 中运行每个测试盒。这样,你可以模拟你想要的任何网络拓扑,而无需每次想要调整某些东西时重新排列物理线缆。

  • 要查看分配给计算机每个网络接口的每个 IP 地址,请在 Mac 或 Linux 机器上运行ifconfig,或在 Windows 上运行ipconfig。通常,你的外部(互联网)IP 地址不会显示在输出中,但你的本地(局域网)网络地址是。

关于登录屏幕没有太多可说的,因为它完全由我们在本书的讨论过程中已经讨论过的构建块组成。让我们继续到第二个屏幕,最终到驱动客户端-服务器引擎的源代码。

远程桌面屏幕

这是我们的应用程序中的第二个也是最后一个屏幕,远程桌面屏幕。在主机机器屏幕足够大的情况下,它将在两个维度上可滚动。鉴于在今天的移动设备中,全高清(1080p 及以上)的分辨率并不罕见,更不用说桌面计算机了,所以我们可能根本不需要滚动。

我们可以根据与我们在第四章中构建的聊天室面板相似的原则构建一个可滚动的布局,即Kivy 网络。如前所述,滚动将是二维的;一个额外的区别是我们这次不想有任何过度滚动(反弹)效果,以避免不必要的混淆。我们向用户展示的是一个(远程)桌面,操作系统的桌面通常没有这个功能。

在这个屏幕背后的remotedesktop.kv代码实际上非常简洁。让我们看看它的不同部分是如何为手头的任务做出贡献的:

Screen:
    name: 'desktop'

    ScrollView:
        effect_cls: ScrollEffect

        Image:
            id: desktop
            nocache: True
            on_touch_down: app.send_click(args[1])
            size: self.texture_size
            size_hint: (None, None)

为了使滚动工作,我们将ScrollViewImage结合使用,这可能会比可用的屏幕空间更大。

ScrollView中,我们将effect_cls: ScrollEffect设置为禁用越界滚动;如果您希望保留越界滚动行为,只需删除此行。由于ScrollEffect名称默认未导入,我们不得不导入它:

#:import ScrollEffect kivy.effects.scroll.ScrollEffect

Imagesize_hint属性设置为(None, None)至关重要;否则,Kivy 会缩放图像以适应,这在当前情况下是不希望的。将size_hint属性设置为None意味着让我手动设置大小

然后,我们就是这样做的,将size属性绑定到self.texture_size。使用此设置,图像将与服务器提供的desktop.jpeg纹理大小相同(这显然取决于主机机的物理桌面大小,因此我们无法将其硬编码)。

还有nocache: True属性,它指示 Kivy 永远不要缓存由定义是临时的桌面图像。

最后但同样重要的是,Image的一个有趣属性是其on_touch_down处理程序。这次,我们想要传递触摸事件的精确坐标和其他属性,这正是args[1]的含义。如果您在疑惑,args[0]是被点击的部件;在这种情况下,那就是图像本身(我们只有一个Image实例,因此没有必要将其传递给事件处理程序)。

Kivy 中的截图循环

现在,我们将使用 Python 将所有这些组合在一起。与 JavaScript 实现相比,我们不会完全免费获得图像加载和相关功能,所以代码会多一点;然而,实现这些功能相当简单,同时还能更好地控制整个过程,您很快就会看到。

为了异步加载图像,我们将使用 Kivy 内置的Loader类,来自kivy.loader模块。程序流程将如下所示:

  1. 当用户在填写完服务器 IP字段后点击或轻触登录屏幕上的连接按钮时,将调用RemoteDesktopApp.connect()函数。

  2. 它将控制权传递给reload_desktop()函数,该函数从/desktop.jpeg端点开始下载图像。

  3. 当图像加载完成后,Loader调用desktop_loaded(),将图像放在屏幕上并安排下一次调用reload_desktop()。因此,我们得到一个从主机系统异步无限循环检索截图的循环。

图像在成功加载后放在屏幕上,所以这次不会有像 JavaScript 原型中那样的闪烁。(在 JS 中也可以解决,当然,但这不是本文的目的。)

让我们更仔细地看看main.py中提到的上述函数:

from kivy.loader import Loader

class RemoteDesktopApp(App):
    def connect(self):
        self.url = ('http://%s:7080/desktop.jpeg' %
                    self.root.ids.server.text)
        self.send_url = ('http://%s:7080/click?' %
                         self.root.ids.server.text)
        self.reload_desktop()

我们保存 url/desktop.jpeg 的完整位置以及服务器 IP)和 send_url(将点击传递给主机的 /click 端点位置),然后传递执行到 RemoteDesktopApp.reload_desktop() 函数,这个函数也非常简洁:

def reload_desktop(self, *args):
    desktop = Loader.image(self.url, nocache=True)
    desktop.bind(on_load=self.desktop_loaded)

在前面的函数中,我们开始下载图像。当下载完成时,新加载的图像将被传递到 RemoteDesktopApp.desktop_loaded()

不要忘记通过传递 nocache=True 参数禁用默认的积极缓存。省略此步骤将导致 desktop.jpeg 图像只加载一次,因为其 URL 保持不变。在 JavaScript 中,我们通过在 URL 后追加 ?timestamp 来解决这个问题,使其变得独特,我们当然可以在 Python 中模仿这种行为,但这是一种黑客行为。Kivy 指定 nocache 的方式更干净、更易读。

在这里,你可以观察到图像下载过程的最终结果:

from kivy.clock import Clock

def desktop_loaded(self, desktop):
    if desktop.image.texture:
        self.root.ids.desktop.texture = \
            desktop.image.texture

    Clock.schedule_once(self.reload_desktop, 1)

    if self.root.current == 'login':
        self.root.current = 'desktop'

这个函数接收新的图像,desktop。然后,我们继续用新加载的纹理替换屏幕上的纹理,并安排在下一秒发生截图循环的下一个迭代。

小贴士

在我们的第一个项目中(第一章, 构建时钟应用),我们简要讨论了 Clock 对象。在那里,我们通过调用 schedule_interval() 来执行周期性操作,类似于 JavaScript 中的 setInterval();在这种情况下,我们想要一次调用,schedule_once(),类似于 JS 中的 setTimeout()

现在,是时候从登录屏幕切换到远程桌面屏幕了。以下截图总结了到目前为止我们所做的工作:

Kivy 中截图循环

一个只读(仍然无法将点击传递回主机)的远程桌面应用

发送点击

远程桌面查看器已经准备好,具有滚动和帧之间的即时转换功能(完全没有闪烁)。最后剩下的一件事是实现发送点击到主机。为此,我们将监听图像上的 on_touch_down 事件,并将触摸坐标传递给事件处理函数 send_click()

这是在 remotedesktop.kv 中发生的地方:

Screen:
    name: 'desktop'

    ScrollView:
        effect_cls: ScrollEffect

        Image:
            on_touch_down: app.send_click(args[1])
            # The rest of the properties unchanged

为了将其置于上下文中,以下是 class RemoteDesktopApp 中的 Python 对应部分:

def send_click(self, event):
    params = {'x': int(event.x),
              'y': int(self.root.ids.desktop.size[1] -
                       event.y)}
    urlopen(self.send_url + urlencode(params))

我们收集点击坐标,并通过 Python 标准库中的网络相关函数使用 HTTP GET 请求将它们发送到服务器。

这里一个主要的注意事项是坐标系:在 Kivy 中,y 轴向上,而在 Windows 和其他地方(例如,在浏览器中)通常是向下(例如,在浏览器中)。为了解决这个问题,我们从桌面高度中减去 event.y

另一个稍微不那么成问题的一面是跨不同 Python 版本使用 Python 的标准库:在从 Python 2 到 Python 3 的过渡中,urllib[2] 模块的结构发生了显著变化。

为了应对这些变化,我们可以使用以下方式进行导入:

try:  # python 2
    from urllib import urlencode
except ImportError:  # python 3
    from urllib.parse import urlencode

try:  # python 2
    from urllib2 import urlopen
except ImportError:  # python 3
    from urllib.request import urlopen

虽然这个方法并不特别美观,但它应该能帮助你完成 Python 升级,如果你尝试的话。(实际上,针对固定版本的 Python 也是完全可以接受的。事实上,在撰写本文时,包括 Python 编程语言的创造者 Guido van Rossum 的雇主在内的许多公司就是这样做的。)

注意

在这种情况下,Python 标准库完全足够;然而,如果你在任何时候发现自己正在编写重复、无聊且缺乏想象力的 HTTP 相关代码,考虑使用 Kenneth Reitz 的优秀的Requests库。访问python-requests.org/获取更多信息及示例。它的语法简洁明了,非常出色。强烈推荐,这个库堪称艺术品。

接下来是什么

现在,你有一个主要按预期工作的远程桌面应用,尤其是在局域网或快速互联网连接下。像往常一样,还有很多额外的问题需要解决,以及许多新功能需要实现,如果你对此感兴趣并且愿意进一步探讨这个话题的话:

  • 将鼠标移动作为单独的事件发送。这也可能适用于双击、拖放等。

  • 尝试考虑网络延迟。如果用户连接速度慢,你可以在服务器上进一步降低图像质量以补偿。向用户提供视觉线索,表明后台正在发生某些事情,也有帮助。

  • 使服务器跨平台,以便在 Mac、Linux 上运行,甚至可能在 Android 和 Chrome OS 上运行。

此外,记住这是一个行业强任务。客观上,构建这样的软件就很困难,更不用说让它完美无瑕且速度极快了。Kivy 在 UI 方面有很大帮助,简化了图像下载和缓存,但仅此而已。

因此,如果你在实施过程中遇到某些东西不能立即工作,不要担心——在这种情况下,试错法并不罕见。有时,你只需要一步一步地前进。

在网络领域有很多东西要学习,而且在这个领域有知识的人很少,而且非常受重视,所以深入研究计算机之间通信的话题肯定是有回报的。

摘要

这构成了远程桌面应用的使用说明。生成的应用实际上可以用于简单的任务,例如,偶尔点击 iTunes 中的播放按钮或关闭一个程序。更复杂的需求,特别是管理任务,可能需要更复杂的软件。

我们还构建了一个由 Flask 驱动的网络服务器,能够动态生成图像并与主机系统交互。在此基础上,我们还推出了一个具有几乎相同功能的“轻量级”JavaScript 版本的应用。这个故事的核心是,我们的 Kivy 应用并非孤立存在。实际上,我们甚至在与客户端应用的运行原型一起构建服务器——这一切都是在编写任何与 Kivy 相关的代码之前完成的。

作为一条一般规则,按照这样的顺序构建你的软件,以便你可以立即测试其每个部分,这非常有帮助。我这里不是在谈论测试驱动开发TDD),因为关于全面、纯粹基于测试的编程是否有助于事业,是有争议的。但即使只是能够调整每个功能组件,也比一开始就编写一大堆代码要高效得多。

最后,当涉及到网络 GUI 应用时,Kivy 配置得相当完善。例如,我们在上一章中使用的 Twisted 集成,以及通过网络加载纹理的支持——这些功能极大地帮助构建多用户、互联网应用。

现在,让我们跳到另一个完全不同的主题:Kivy 游戏开发。

第六章:制作 2048 游戏

在接下来的几章中,我们将构建一系列越来越复杂的游戏项目,以展示与游戏开发相关的一些常见概念:状态管理、控制、音效和基于快速着色器的图形,仅举几例。

在一开始需要考虑的重要事情是,没有任何方法实际上是游戏开发独有的:还有其他类别的软件使用与视频游戏相同的算法和性能技巧。

然而,让我们从小处着手,逐步过渡到更复杂的事情。我们的第一个项目是重新实现相对知名的2048游戏。

本章将介绍在开发游戏时实际上必需的许多 Kivy 技术:

  • 创建具有自定义外观和行为的 Kivy 小部件

  • 在画布上绘制并利用内置的图形指令

  • 使用绝对定位在屏幕上任意排列小部件(而不是依赖于结构化布局)

  • 使用 Kivy 内置的动画支持平滑地移动小部件

使用绝对坐标定位小部件可能听起来是在习惯了布局类之后的一种倒退,但在高度交互式应用程序(如游戏)中是必要的。例如,许多桌面游戏的矩形游戏场可以用GridLayout表示,但即使是基本的动画,如从单元格到单元格的移动,也会很难实现。这样的任务注定要包括以某种形式进行小部件的重父化;这本身几乎就抵消了使用固定布局的任何好处。

关于游戏

对于初学者来说,2048 游戏是一种数学谜题,玩家需要合并数字以达到 2048,甚至可能超过 2048,达到 4096 和 8192(尽管这可能很有挑战性,所以 2048 是一个足够难以达到的胜利条件)。游戏板是一个 4×4 的方形网格。一开始大部分是空的,只有几个2方块。每个回合玩家将所有方块移动到所选的方向:上、右、下或左。如果一个方块无法向该方向前进(没有可用空间),则它将保持在原地。

关于游戏

2048 游戏板(原游戏截图)

当两个具有相同数值的方块接触(或者说,尝试移动到对方上方)时,它们会合并成一个,并相加,将方块的名义值增加到下一个 2 的幂。因此,进度看起来是这样的:2,4,8,16,...,2048 等等;程序员通常会发现这个序列很熟悉。每回合结束后,在随机空位中会生成另一个2方块。

2048 游戏的原版有时也会创建4而不是2;这是一个不太重要的特性,本章不会涉及,但实现起来应该相当简单。

如果玩家没有有效的移动可用(棋盘被填充成一个不幸的组合,其中没有相同值的瓷砖相邻),游戏就会结束。你可以在gabrielecirulli.github.io/2048/上玩原始的 2048 游戏。

游戏玩法概念和概述

游戏通常非常具有状态性:应用程序会经过多个独特的状态,例如起始屏幕、世界地图、城镇屏幕等,具体取决于游戏的细节。当然,每个游戏都非常不同,并且没有许多方面在大量游戏中是共同的。

其中一个方面,而且非常基础,就是大多数游戏要么有赢的条件,要么有输的条件,通常两者都有。这听起来可能很微不足道,但这些条件和相关的游戏状态可能会对玩家的参与度和对游戏的感知产生巨大影响。

注意

有些游戏设计上完全是无限的,规则中没有任何“游戏结束”状态的含义(既不赢也不输),在玩家动机方面非常难以正确实现。这类游戏通常还会提供强烈的局部优势和劣势状态来补偿。

例如,虽然你无法在《魔兽世界》或遵循相同设计概念的许多其他 MMORPG 游戏中赢得游戏或完全死亡并进入“游戏结束”状态,但你确实会因为忽视角色的健康和统计数据而受到惩罚,需要执行游戏内的复活和相关任务,如修复损坏的装备。

此外,如果你特别擅长,你通常可以与其他高技能玩家组队,获得其他玩家无法获得的物品(因此对不良或休闲玩家不可用)。这包括许多 Boss 遭遇战、突袭以及那些难以获得的成就。

2048 中提到的上述失败条件——当棋盘上没有可用的移动时游戏结束——在实践中效果很好,因为它使得游戏在结束时逐渐变得更加困难。

在游戏开始时,游戏本身并不难:玩家基本上可以完全随机地移动,没有任何策略。新瓷砖以相同的值添加到棋盘上,因此在最初的几轮中,不可能填满所有单元格并耗尽有效的移动,即使是有意为之——所有的瓷砖都是兼容的,可以组合,无论玩家选择向哪个方向移动。

然而,随着你在游戏中进一步前进,棋盘上的变化引入,空闲单元格变得越来越稀缺。由于不同的值不能合并在一起,棋盘管理很快就会成为一个问题。

游戏玩法机制是使 2048 如此吸引人的原因:它很容易开始,规则简单,并且在整个游戏过程中不会改变,2048 在游戏初期不会惩罚实验性的行为,即使是以明显次优的行为形式,直到游戏后期。

随机性,或者缺乏随机性

由于所有瓦片(最多 16 个)同时移动,如果玩家没有密切注意,一些由此产生的情况可能没有被预见。尽管这个算法是完全确定性的,但它给人的感觉是带有一点随机性。这也通过使 2048 感觉更像街机游戏,略带不可预测性和惊喜,来帮助提高参与度。

这通常是一件好事:随机遭遇(或者更准确地说,像这种情况中被感知为随机的遭遇)可以为其他方面线性的过程增添活力,使游戏玩法更有趣。

2048 项目概述

总结来说,以下是该游戏的定义特征:

  • 游戏场(即棋盘)是 4×4 个单元格

  • 在每一回合中发生以下动作:

    • 玩家将所有瓦片移动到所选方向

    • 合并两个具有相同数值的瓦片会产生一个数值更大的瓦片

    • 在空白空间中生成一个新的2瓦片

  • 玩家通过创建一个2048瓦片获胜

  • 当没有有效的移动剩下时(也就是说,没有可能的移动可以再改变棋盘上的情况时),游戏结束

这个清单将在稍后派上用场,因为它形成了我们在本章中将要实现的基本技术概述。

什么使 2048 成为项目选择的好选择?

有些人可能会问,重新实现现有游戏是否是一个明智的想法。答案是,无论如何都是肯定的;更详细的解释将在下面提供。

当谈论实际软件开发时,这一点稍微有些离题。但重新创建一个知名项目的理由可能并不明显。如果这个章节的方法对你来说没有冗长的解释就完全合理,那么请随意跳到下一节,那里将开始实际开发。

为了支持选择 2048(以及整体上“重新实现轮子”的方法),让我们首先假设以下情况:游戏开发在许多不同层面上都极具挑战性:

  • 有趣的游戏设计很难找到。游戏机制必须有一个中心思想,这可能需要一定程度的创造力。

  • 一款好游戏需要游戏玩法不是过于复杂,否则可能会迅速导致挫败感,但也不能过于简单,否则会变得无聊。平衡这一点听起来可能一开始很简单,但通常很难做到恰到好处。

  • 有些算法比其他算法更难。在平坦的瓦片地图上寻找路径是容易接近的,但在动态的任意三维空间中寻找路径则完全是另一回事;为射击游戏设计的人工智能AI)可能很简单,但仍能提供出色的结果,而策略游戏中的 AI 则必须聪明且不可预测,以提供足够的挑战和多样性。

  • 注意细节以及使游戏变得出色的打磨程度,即使是该领域的专业人士也可能感到压倒性,甚至令人难以承受。

这个列表绝对不是详尽的,它的目的不是让所有人都远离游戏开发,而是要传达一个观点——有很多事情可能会出错,所以不要犹豫,将一些任务外包给第三方。这增加了你交付一个可工作的项目的可能性,并减少了相关的挫折感。

在游戏开发中(尤其是像本书项目这样的零预算、偶尔的努力)的一个常用方法就是避免昂贵的创意搜索,尤其是在游戏玩法方面。如果你不能让项目走出大门,其独特性几乎毫无价值。这就是为什么在构建新游戏时,应该尽可能多地重用现有元素。

当然,你不必逐字复制别人的想法——调整游戏的每个方面都可以很有趣,并且是一项非常有回报的努力。

事实上,大多数游戏都借鉴了前人的想法、游戏玩法,有时甚至包括视觉属性,整体上变化非常小(这并不一定是好事,只是当今行业的状态,无论好坏)。

简单性作为一项特性

回到 2048 游戏来说,值得注意的是,它的规则非常简单,几乎可以说是微不足道的。然而,乐趣因素却不可思议地高;2048 在相当长的一段时间里非常受欢迎,无数的衍生作品充斥着互联网和应用商店。

仅此一点就使得 2048 游戏值得从头开始重建,尤其是为了学习目的。让我们假设,到这一点,你已经完全确信 2048 是一个绝佳的项目选择,并且渴望继续实际开发。

创建 2048 游戏板

到目前为止,我们一直依赖于现有的 Kivy 小部件,根据需要对其进行定制以适应我们的特定用例。对于这个应用程序,我们将构建我们自己的独特小部件:Board(游戏区域)和Tile

让我们从简单的事情开始,为游戏区域创建背景。最缺乏想象力的方法就是使用静态图像;这种方法有很多问题,例如,它不能正确支持多种可能的屏幕尺寸(记住,我们同时谈论的是桌面和移动设备,所以屏幕尺寸可能会有很大的变化)。

相反,我们将创建一个Board小部件,它将游戏区域的图形渲染到其画布上。这样,游戏板的定位和大小将在 Kivy 语言文件中以声明性方式给出,就像我们之前使用过的其他小部件一样(例如文本标签和按钮)。

可能最容易开始的事情确实是设置游戏板的定位和大小。为了高效地完成这项任务,我们可以使用FloatLayout;这是 Kivy 提供的简单布局类之一,它只使用大小和位置提示。以下列表基本上总结了FloatLayout的使用(此代码位于game.kv文件中):

#:set padding 20

FloatLayout:
    Board:
        id: board
        pos_hint: {'center_x': 0.5, 'center_y': 0.5}
        size_hint: (None, None)
        center: root.center
        size: [min(root.width, root.height) - 2 * padding] * 2

在这里,Board 小部件在屏幕上水平和垂直居中。为了考虑到任何可能的屏幕方向或纵横比,我们通过选择屏幕较小的边(宽度或高度)并减去两次填充(我们希望两侧有相同的间隙)来计算棋盘大小。棋盘是正方形的,所以其尺寸相等。

提示

size: 行上的 [...] * 2 技巧是 Python 的一个相当标准的特性,用于在初始化数据结构时避免多次重复相同的值,例如,[1] * 3 等于 [1, 1, 1]

为了避免与算术乘法混淆,我们应谨慎使用此功能。然而,在生产环境中,您应考虑在适当的地方使用此语法,因为它比手动编写相同的重复列表或元组更简洁。

为了看到我们迄今为止的工作结果,我们需要定义 Board 小部件本身并使其渲染某些内容(默认情况下,空小部件是完全不可见的)。这将在 main.py 文件中完成:

from kivy.graphics import BorderImage
from kivy.uix.widget import Widget

spacing = 15

class Board(Widget):
    def __init__(self, **kwargs):
        super(Board, self).__init__(**kwargs)
        self.resize()

    def resize(self, *args):
        self.cell_size = (0.25 * (self.width - 5 * spacing), ) * 2
        self.canvas.before.clear()
        with self.canvas.before:
            BorderImage(pos=self.pos, size=self.size,
                        source='board.png')

    on_pos = resize
    on_size = resize

game.kvpadding 的定义类似,我们在 Python 源代码的顶部定义 spacing。这是两个相邻单元格之间的距离,以及从棋盘边缘到附近单元格边缘的距离。

resize() 方法在本部分代码中起着核心作用:它在创建 Board 小部件(直接从 __init__())或重新定位(借助 on_poson_size 事件回调)时被调用。如果小部件确实进行了调整大小,我们预先计算新的 cell_size;实际上,这是一个非常简单的计算,所以即使小部件在调用之间的大小没有改变,也不会造成伤害:

创建 2048 棋盘

在这里,大小指的是宽度或高度,因为所有相关对象都是正方形的。

接下来,我们渲染背景。我们清除 canvas.before 图形指令组,并用原语(目前仅由 BorderImage 表示)填充它。与 canvas.aftercanvas 相比,canvas.before 组在渲染小部件时首先执行。这使得它非常适合需要位于任何子图形之下的背景图像。

注意

画布指令组是 Kivy 组织底层图形操作的方式,例如将图像数据复制到画布、绘制线条和执行原始 OpenGL 调用。有关使用画布的简要介绍,请参阅第二章,构建绘图应用程序

单个画布指令,存在于 kivy.graphics 命名空间中,在概念上是 canvas 对象(或 canvas.beforecanvas.after)的子对象,就像叶小部件是容器或根小部件的子对象一样。代码中的分层定义看起来也非常相似。

然而,一个重要的区别是,小部件具有复杂的生命周期,可以在屏幕上对齐,响应事件,并执行更多操作。相反,渲染指令只是那样——主要是用于绘图的自我包含的基本原语。例如,Color指令更改队列中后续指令的颜色(色调);Image在画布上绘制图像;等等。

目前,背景只是一个矩形。由于背景图片board.png的使用,它具有圆角,这是通过BorderImage指令实现的——一种在第一章中描述的 9 宫格技术,构建时钟应用,类似于本书中所有前例中实现带边框按钮的方式。

遍历单元格

我们的竞技场是二维的,通过嵌套for循环可以非常明显地遍历二维数组,如下所示:

for x in range(4):
    for y in range(4):
        # code that uses cell at (x, y)

不仅难以操作,增加了两层缩进,而且当在程序中的许多地方使用时,还会导致代码重复,这是不希望的。在 Python 中,我们可以使用如这里所示的生成器函数重构此代码:

# In main.py
def all_cells():
    for x in range(4):
        for y in range(4):
            yield (x, y)

生成器函数本身看起来与前面代码片段中显示的直接方法相似。然而,它的使用却更加清晰:

for x, y in all_cells():
    # code that uses cell at (x, y)

这基本上是运行两个嵌套循环的相同代码,但那些细节被抽象化了,因此我们有一个整洁的一行代码,它也比穿插在每个坐标上的直接for循环代码更可定制。

在以下代码中,我们将把板坐标(指代板上的单元格,而不是屏幕上渲染对象的像素坐标)称为board_xboard_y

渲染空单元格

游戏板的整体位置和大小由Board小部件的位置定义,但单个单元格的位置尚未确定。接下来,我们将计算每个单元格在屏幕上的坐标,并在画布上绘制所有单元格。

考虑到spacing,屏幕上单元格的位置可以这样计算:

# In main.py
class Board(Widget):
    def cell_pos(self, board_x, board_y):
        return (self.x + board_x *
                (self.cell_size[0] + spacing) + spacing,
                self.y + board_y *
                (self.cell_size[1] + spacing) + spacing)

画布操作通常期望绝对坐标,这就是为什么我们要将Board的位置(self.xself.y)添加到计算值中。

现在我们能够遍历竞技场,并根据每个单元格的板位置计算其在屏幕上的位置,剩下要做的就是实际上在画布上渲染单元格。按照以下方式调整canvas.before代码应该足够:

from kivy.graphics import Color, BorderImage
from kivy.utils import get_color_from_hex

with self.canvas.before:
    BorderImage(pos=self.pos, size=self.size,
                source='board.png')
    Color(*get_color_from_hex('CCC0B4'))
    for board_x, board_y in all_cells():
        BorderImage(pos=self.cell_pos(board_x, board_y),
                    size=self.cell_size,
                    source='cell.png')

在渲染图像时,Color指令与本书中之前讨论过的目的相同(例如,在第二章中,构建绘图应用):它允许每个瓦片有不同的颜色,同时使用相同的(白色)图像作为纹理。

此外,请注意 cell_poscell_size 的使用——这些是实际的屏幕坐标(以像素为单位)。它们根据应用程序窗口的大小而变化,通常仅用于在屏幕上绘制某些内容。对于游戏逻辑,我们将使用更简单的棋盘坐标 board_xboard_y

这张截图总结了到目前为止我们所做的工作:

渲染空单元格

比赛场地,目前没有任何有趣的东西

棋盘数据结构

为了能够处理游戏逻辑,我们需要保留棋盘的内部表示。为此,我们将使用一个简单的二维数组(从技术上讲,是一个列表的列表)。棋盘的空白状态如下所示:

[[None, None, None, None],
 [None, None, None, None],
 [None, None, None, None],
 [None, None, None, None]]

None 的值表示单元格为空。可以使用嵌套列表推导式初始化描述的数据结构,如下面的代码片段所示:

class Board(Widget):
    b = None

    def reset(self):
        self.b = [[None for i in range(4)]
                  for j in range(4)]

我们称先前的函数为 reset(),因为它不仅会在事先初始化数据结构,而且在游戏结束后也会将游戏状态恢复到空白状态。

列表推导式的使用并非绝对必要;这种表示法只是比之前展示的列表列表形式更为简洁。如果你认为(如之前所示)的原始形式更易读,那么在初始化网格时,完全可以使用它。

变量命名

简短的名字 b 被认为是合适的,因为这个属性应该被视为类的内部属性,因此它不参与外部 API(或缺乏 API)。我们还将在这个代码中大量使用这个变量,这也起到了减少输入的作用,类似于常用的循环迭代变量 ij

在 Python 中,通常使用前导下划线来表示私有字段,例如 _name。我们在这里并不严格遵循这一惯例,部分原因是因为当与非常短的名字一起使用时,这看起来不太好。这个类的大部分内容都是应用程序内部的,几乎无法作为单独的模块重用。

在所有目的和意义上,将 Board.b 视为一个局部变量,特别是 Board 在我们的应用程序中充当单例:在任何给定时间点,应该只有一个实例。

调用 reset()

游戏开始时,我们应该调用 Board.reset() 来初始化棋盘的内部表示。这样做的地方是应用程序的 on_start 回调,如下面的代码片段所示:

# In main.py
from kivy.app import App

class GameApp(App):
    def on_start(self):
        board = self.root.ids.board
        board.reset()

测试通行性

我们目前还没有什么巧妙的东西可以放入网格中,但这并不妨碍我们编写通行性检查,can_move()。这个辅助函数测试我们是否可以在棋盘的指定位置放置一个瓷砖。

检查有两重。首先,我们需要确保提供的坐标在一般情况下是有意义的(也就是说,不要超出棋盘),这个检查将存在于一个名为 valid_cell() 的单独函数中。然后,我们查找棋盘单元格以查看它是否为空(等于 None)。如果移动是合法的且单元格是空的,则返回值将为 True,否则为 False

前面的句子可以直译为 Python:

# In main.py, under class Board:
def valid_cell(self, board_x, board_y):
    return (board_x >= 0 and board_y >= 0 and
            board_x <= 3 and board_y <= 3)

def can_move(self, board_x, board_y):
    return (self.valid_cell(board_x, board_y) and
            self.b[board_x][board_y] is None)

这些方法将在编写负责瓦片移动的代码时使用。但首先,我们需要创建瓦片。

制作瓦片

本章的这一部分致力于构建 Tile 小部件。与我们在前面看到的 Board 小部件相比,瓦片在性质上更加动态。为了解决这个问题,我们将在 Tile 类上创建多个 Kivy 属性,以便任何对瓦片的可见更改都会自动导致重新绘制它。

Kivy 属性与常规 Python 属性不同:Python 中的属性基本上只是一个绑定到类实例的变量,可能还与获取器和设置器函数相关联。在 Kivy 中,属性具有一个额外的功能:它们在更改时发出事件,因此你可以观察有趣的属性并根据需要调整其他相关变量,或者可能重新绘制屏幕。

大部分工作都在幕后进行,无需你的干预:当你对例如小部件的 possize 发出更改时,会触发一个事件(分别是 on_poson_size)。

有趣的是,在 .kv 文件中定义的所有属性都会自动传播。例如,你可以写一些如下内容:

Label:
    pos: root.pos

root.pos 属性改变时,这个标签的 pos 值也会改变;它们可以轻松地保持同步。

当创建 Tile 小部件时,我们将利用这一特性。首先,让我们声明在渲染小部件时应考虑的有趣属性:

# In main.py
from kivy.properties import ListProperty, NumericProperty

class Tile(Widget):
    font_size = NumericProperty(24)
    number = NumericProperty(2)  # Text shown on the tile
    color = ListProperty(get_color_from_hex(tile_colors[2]))
    number_color = ListProperty(get_color_from_hex('776E65'))

这就是绘制瓦片所需的所有内容;属性名称应该是相当自解释的,可能唯一的例外是 color,它是瓦片的背景颜色。number 属性表示瓦片的面值

小贴士

如果你现在想运行此代码,请将 tile_colors[2] 替换为实际的颜色值,例如,'#EEE4DA'。我们将在本节的稍后部分正确定义 tile_colors 列表。

接下来,在 game.kv 文件中,我们定义构成我们小部件的图形元素:

<Tile>:
    canvas:
        Color:
            rgb: self.color

        BorderImage:
            pos: self.pos
            size: self.size
            source: 'cell.png'

    Label:
        pos: root.pos
        size: root.size
        bold: True
        color: root.number_color
        font_size: root.font_size
        text: str(root.number)

来自 Tile 类的自定义属性被突出显示。请注意,在 canvas 声明内部,self 指的是 <Tile>,而不是画布本身。这是因为 canvas 只是相应小部件的一个属性。另一方面,Label 是一个独立的小部件,因此它使用 root.XXX 来引用 <Tile> 属性。在这种情况下,它是顶级定义,所以它有效。

瓦片初始化

在原始 2048 游戏中,瓷砖的背景颜色根据它们的数值而变化。我们将实现相同的效果,为此我们需要一个颜色映射,number → color

以下颜色列表接近原始 2048 游戏使用的颜色:

# In main.py
colors = (
    'EEE4DA', 'EDE0C8', 'F2B179', 'F59563',
    'F67C5F', 'F65E3B', 'EDCF72', 'EDCC61',
    'EDC850', 'EDC53F', 'EDC22E')

为了将它们映射到数字,在 2048 中这些数字是 2 的幂,我们可以使用以下代码:

tile_colors = {2 ** i: color for i, color in
               enumerate(colors, start=1)}

这正是我们需要的映射,以瓷砖编号为键,相应的颜色为值:

{2: 'EEE4DA',
 4: 'EDE0C8',
 # ...
 1024: 'EDC53F',
 2048: 'EDC22E'}

在颜色就绪后,我们可以编写Tile类的初始化器,即Tile.__init__方法。它将主要只是分配所讨论的瓷砖的属性,如下所示:

class Tile(Widget):
    font_size = NumericProperty(24)
    number = NumericProperty(2)
    color = ListProperty(get_color_from_hex(tile_colors[2]))
    number_color = ListProperty(get_color_from_hex('776E65'))

    def __init__(self, number=2, **kwargs):
        super(Tile, self).__init__(**kwargs)
        self.font_size = 0.5 * self.width
        self.number = number
        self.update_colors()

    def update_colors(self):
        self.color = get_color_from_hex(
            tile_colors[self.number])
        if self.number > 4:
            self.number_color = \
                get_color_from_hex('F9F6F2')

让我们简要地谈谈我们在这里看到的每个属性:

  • font_size: 这设置为cell_size的一半。这基本上是一个任意值,看起来还不错。我们无法在这里直接使用绝对字体大小,因为板子是按比例缩放以适应窗口的;最佳方法是保持字体大小与缩放一致。

  • number:这是从调用函数传递的,默认为2

  • color(瓷砖的背景颜色):这源于之前讨论的映射,基于number的值。

  • number_color:这是基于number属性选择的,但变化较少。只有两种颜色:一种深色(默认),用于浅色背景,以及一种较浅的颜色,用于在明亮背景上提供更好的对比度,因为数字增加;因此有检查(if self.number > 4)。

其他所有内容都以kwargs(关键字参数)的形式传递给超类。这包括位置和大小属性,这恰好是下一节的主题。

颜色存在于它们自己的辅助函数中,update_colors(),因为稍后我们需要在合并瓷砖时更新它们。

值得注意的是,在这个阶段,你可以使用类似以下的方式创建一个瓷砖:

tile = Tile(pos=self.cell_pos(x, y), size=self.cell_size)
self.add_widget(tile)

结果,屏幕上会出现一个新的瓷砖。(前面的代码应该位于Board类中。或者,将所有self引用更改为Board实例。)

调整瓷砖大小

瓷砖的另一个问题是它们没有意识到它们应该随着板子的大小调整而保持同步。如果你放大或缩小应用程序窗口,板子会调整其大小和位置,但瓷砖不会。我们将解决这个问题。

让我们从更新所有相关Tile属性的一次性辅助方法开始:

class Tile(Widget):
    # Other methods skipped to save space

    def resize(self, pos, size):
        self.pos = pos
        self.size = size
        self.font_size = 0.5 * self.width

虽然这个方法不是必需的,但它使下面的代码更加简洁。

实际的调整大小代码将位于Board.resize()方法的末尾,该方法由 Kivy 属性绑定调用。在这里,我们可以遍历所有瓷砖,并根据新的cell_sizecell_pos值调整它们的度量:

def resize(self, *args):
    # Previously-seen code omitted

    for board_x, board_y in all_cells():
        tile = self.b[board_x][board_y]
        if tile:
            tile.resize(pos=self.cell_pos(board_x, board_y),
                        size=self.cell_size)

这种方法与我们之前看到的自动属性绑定正好相反:我们以集中和明确的方式完成所有调整大小操作。一些程序员可能会觉得这种方式更易于阅读且不那么神秘(例如,Python 代码允许你在事件处理器等内部设置断点;相反,如果需要,Kivy 语言文件更难进行有意义的调试)。

实现游戏逻辑

现在我们已经构建了实现 2048 游戏所需的所有组件,让我们继续更有趣的事情:生成、移动和合并瓷砖。

从随机空单元格中开始生成新瓷砖是合乎逻辑的。这样做的方法如下:

  1. 找到所有当前为空的单元格。

  2. 从步骤 1 中找到的单元格中随机选择一个。

  3. 在步骤 2 确定的位置创建一个新的瓷砖。

  4. 将其添加到内部网格(Board.b),并使用 add_widget() 添加到板小部件本身(以便 Kivy 进行渲染)。

行动序列应该是显而易见的;以下 Python 实现此算法也非常简单:

# In main.py, a method of class Board:
def new_tile(self, *args):
    empty_cells = [(x, y) for x, y in all_cells()  # Step 1
                   if self.b[x][y] is None]

    x, y = random.choice(empty_cells)  # Step 2
    tile = Tile(pos=self.cell_pos(x, y),  # Step 3
                size=self.cell_size)
    self.b[x][y] = tile  # Step 4
    self.add_widget(tile)

新的瓷砖在游戏开始时以及每次移动后生成。我们很快就会涉及到移动瓷砖,现在我们可以实现开始时生成瓷砖:

def reset(self):
    self.b = [[None for i in range(4)]
              for j in range(4)]  # same as before
    self.new_tile()
    self.new_tile()  # put down 2 tiles

如果你在更改后运行程序,你应该会看到两个瓷砖随机添加到板的各个位置。

实现游戏逻辑

实际生成瓷砖

移动瓷砖

为了有效地实现移动,我们需要将每个输入事件映射到一个方向向量。然后,Board.move() 方法将接受这样的向量并相应地重新排列板。方向向量通常是归一化的(其长度等于一),在我们的情况下,我们只需将其添加到当前瓷砖的坐标中,就可以得到其可能的新位置。

2048 游戏只允许四种移动选项,因此键盘映射定义非常简短:

from kivy.core.window import Keyboard

key_vectors = {
    Keyboard.keycodes['up']: (0, 1),
    Keyboard.keycodes['right']: (1, 0),
    Keyboard.keycodes['down']: (0, -1),
    Keyboard.keycodes['left']: (-1, 0),
}

在这个列表中,我们指的是箭头键,在 Kivy 的预定义 keycodes 字典中恰当地命名为 'up''right''down''left'

在 Kivy 中,可以通过 Window.bind() 方法实现监听键盘事件,如下面的代码所示:

# In main.py, under class Board:
def on_key_down(self, window, key, *args):
    if key in key_vectors:
        self.move(*key_vectors[key])

# Then, during the initialization (in GameApp.on_start())
Window.bind(on_key_down=board.on_key_down)

Board.move() 方法因此被调用。它接受从 key_vectors[key] 中解包的方向向量分量,dir_xdir_y

控制迭代顺序

在我们真正构建 Board.move() 方法之前,我们需要使 all_cells() 生成器函数可定制;正确的迭代顺序取决于移动方向。

例如,当向上移动时,我们必须从每一列的最顶部的单元格开始。这样我们可以确保所有瓷砖都将紧密排列在板的顶部。在迭代错误的情况下,你不可避免地会看到来自底部单元格的孔洞,因为它们在达到顶部可用位置之前撞到了顶部的单元格。

考虑到这个新的要求,我们可以轻松地编写一个新版本的生成函数,如下所示:

def all_cells(flip_x=False, flip_y=False):
    for x in (reversed(range(4)) if flip_x else range(4)):
        for y in (reversed(range(4)) if flip_y else range(4)):
            yield (x, y)

你也可以只写(3, 2, 1, 0)而不是reversed(range(4))。在这种情况下,直接枚举比产生它的迭代器更简洁。是否这样做是个人偏好的问题,并且不会以任何方式影响功能。

实现 move()方法

现在,我们可以构建Board.move()函数的最简单版本。目前,它将仅便于移动瓦片,但我们将很快将其升级以合并瓦片。

下面是这个函数算法的概述:

  1. 遍历所有(现有)瓦片。

  2. 对于每个瓦片,将其移动到指定的方向,直到它撞到另一个瓦片或游戏场边界。

  3. 如果瓦片的坐标保持不变,则继续到下一个瓦片。

  4. 动画化瓦片的过渡到新坐标,并继续到下一个瓦片。

Python 实现紧密遵循之前的描述:

def move(self, dir_x, dir_y):
    for board_x, board_y in all_cells(dir_x > 0, dir_y > 0):
        tile = self.b[board_x][board_y]
        if not tile:
            continue

        x, y = board_x, board_y
        while self.can_move(x + dir_x, y + dir_y):
            self.b[x][y] = None
            x += dir_x
            y += dir_y
            self.b[x][y] = tile

        if x == board_x and y == board_y:
            continue  # nothing has happened

        anim = Animation(pos=self.cell_pos(x, y),
                         duration=0.25, transition='linear')
        anim.start(tile)

在这个列表中,你可以看到我们之前构建的can_move()函数的使用。

Animation API 在浏览器中的工作方式类似于 CSS 过渡。我们需要提供:

  • 我们想要动画化的属性值(在这个例子中,是pos

  • 持续时间,以秒为单位

  • 过渡类型('linear'表示在整个路径上速度相等)

考虑到所有这些,Kivy 通过将小部件从当前状态转换为新的状态,渲染出平滑的动画。

注意

所有过渡类型都在 Kivy 手册中详细说明(kivy.org/docs/api-kivy.animation.html)。这里的内容太多,无法在此提供有意义的摘要。

绑定触摸控制

让我们再实现触摸控制(滑动),除了我们之前实现的键盘绑定。由于 Kivy 中鼠标输入事件的处理方式与触摸相同,我们的代码也将支持鼠标手势。

要做到这一点,我们只需要向Board类添加一个事件处理程序:

from kivy.vector import Vector

# A method of class Board:
def on_touch_up(self, touch):
    v = Vector(touch.pos) - Vector(touch.opos)
    if v.length() < 20:
        return

    if abs(v.x) > abs(v.y):
        v.y = 0
    else:
        v.x = 0

    self.move(*v.normalize())

在此代码中,我们将任意手势转换为所需的单位向量,以便Board.move()函数能够正常工作。完整的操作步骤如下:

  1. if v.length() < 20:条件检查消除非常短的手势。如果旅行距离非常短,那么可能是一个点击或轻触,而不是滑动。

  2. if abs(v.x) > abs(v.y):条件将向量的较短分量设置为 0。因此,剩余的分量指定了方向。

  3. 然后,我们只需将向量归一化并将其输入到Board.move()中。

这最后一个观点正是你不应该发明自己的方式来表示数学上可表达的事物,如方向的原因。

所有人都知道向量,当你使用它们时,你几乎可以免费获得与其他任何库的兼容性;但是如果你要重新发明轮子并定义另一种表示,例如,UP = 0RIGHT = 1等等——那么,你现在就独自一人置身于寒冷、黑暗的虚无之中,与世界上的其他事物不一致。说真的,除非你有至少两个非常好的理由,否则不要这样做。

合并瓷砖

现在,我们将讨论游戏最后有趣的部分:当瓷砖相互碰撞时合并。下面的代码出人意料地并不复杂;人们可能会预期它比这更难。

我们将构建另一个辅助函数,can_combine()。在概念上与can_move()非常相似,这个函数如果我们可以将当前瓷砖与提供的位置的瓷砖合并,即如果坐标相同且位置包含具有相同值的瓷砖,则返回True

这是描述的方法的不完整列表。将此函数与其对应函数can_move()进行比较,你会注意到它们几乎完全相同:

def can_combine(self, board_x, board_y, number):
    return (self.valid_cell(board_x, board_y) and
            self.b[board_x][board_y] is not None and
            self.b[board_x][board_y].number == number)

在有了这个函数之后,我们现在可以扩展Board.move()函数以支持合并单元格。

只需在while self.can_move()移动块之后添加以下片段:

if self.can_combine(x + dir_x, y + dir_y,
                    tile.number):
    self.b[x][y] = None
    x += dir_x
    y += dir_y
    self.remove_widget(self.b[x][y])
    self.b[x][y] = tile
    tile.number *= 2
    tile.update_colors()

小贴士

如果你对代码布局不确定,请参阅此项目的完整源代码。本书所有源代码的最新版本可在github.com/mvasilkov/kb找到。

再次强调,此代码与移动逻辑相似,有两个显著的不同之处。我们要合并的瓷砖使用remove_widget()移除,剩余瓷砖的数字被更新,这意味着我们还需要更新其颜色。

因此,我们的瓷砖愉快地合并,它们的值相加。如果不是接下来讨论的最后几件事,游戏现在绝对可以玩。

添加更多瓷砖

我们的游戏确实应该在每一回合结束后生成新的瓷砖。更进一步的是,这需要在动画序列的末尾完成,当受上一回合影响的瓷砖完成移动后。

幸运的是,有一个合适的事件,Animation.on_complete,这正是我们要在这里使用的。由于我们同时运行多个动画,动画的数量等于活动瓷砖的数量,我们只需要将事件绑定到第一个Animation实例——它们都同时开始并且具有相同的持续时间,所以同步批量中第一个和最后一个动画之间的时间差不应该有明显的差异。

实现位于我们之前看到的同一个Board.move()方法中:

def move(self, dir_x, dir_y):
    moving = False

    # Large portion of the code is omitted to save trees

        if x == board_x and y == board_y:
            continue  # nothing has happened

        anim = Animation(pos=self.cell_pos(x, y),
                         duration=0.25, transition='linear')
        if not moving:
            anim.on_complete = self.new_tile
            moving = True

        anim.start(tile)

一旦动画结束并且触发on_complete事件,就会调用new_tile(),游戏继续。

我们引入名为moving的布尔标志的原因是确保new_tile()在每个回合中不会调用超过一次。跳过这个检查会导致棋盘在短时间内被新的标题淹没。

同步回合

你可能已经注意到,当前动画瓷砖的实现中存在一个错误:玩家可以在前一个回合结束之前开始新的回合。解决这个问题的最简单方法是将移动的持续时间大大增加,例如,增加到 10 秒:

# This is for demonstration only
anim = Animation(pos=self.cell_pos(x, y),
                 duration=10, transition='linear')

我们可以修复这个错误的简单方法是忽略在瓷砖移动过程中对move()的后续调用。为了做到这一点,我们必须扩大之前看到的moving标志的作用范围。从现在起,它将成为Board类的一个属性。我们也在相应地调整move()方法:

class Board(Widget):
 moving = False

    def move(self, dir_x, dir_y):
 if self.moving:
 return

        # Again, large portion of the code is omitted

            anim = Animation(pos=self.cell_pos(x, y),
                             duration=0.25,
                             transition='linear')
 if not self.moving:
                anim.on_complete = self.new_tile
 self.moving = True

            anim.start(tile)

不要忘记在new_tile()中将moving重置回False,否则瓷砖在第一回合之后将不再移动。

游戏结束

游戏中缺少的另一件事是“游戏结束”状态。我们在本章开头讨论了胜利和失败的条件,因此以相同的话题结束实现是符合风格的。

胜利条件

测试玩家是否成功组装了一个 2048 瓷砖可以在Board.move()函数中合并瓷砖时值加倍的唯一地方轻松完成:

tile.number *= 2
if (tile.number == 2048):
    print('You win the game')

小贴士

注意,报告游戏结束条件的特定 UI 故意省略了。创建另一个带有按钮和一些文本的简单屏幕会不必要地使章节变得杂乱,而不会增加书中已有的内容。

换句话说,实现视觉上吸引人的游戏结束状态再次留作练习——我们只会建议一个检测它们的算法。

为了测试游戏结束条件,可能需要将胜利要求严重降低,例如将2048替换为64,但别忘了在公开发布前将其改回!

失败条件

这个算法稍微复杂一些,因此可以用多种方式编写。最直接的方法可能是在每次移动之前完全遍历棋盘,以测试棋盘是否陷入死胡同:

def is_deadlocked(self):
    for x, y in all_cells():
        if self.b[x][y] is None:
            return False  # Step 1

        number = self.b[x][y].number
        if self.can_combine(x + 1, y, number) or \
                self.can_combine(x, y + 1, number):
            return False  # Step 2
    return True  # Step 3

解释:对于棋盘上的每个瓷砖,我们正在测试以下内容:

  1. 找到一个空单元格?这立刻意味着我们并没有陷入死胡同——另一个瓷砖可以移动到那个单元格。

  2. 否则,如果选中的瓷砖可以与右侧或下方的瓷砖组合,那么我们就没问题,因为我们有一个可能的移动。

  3. 如果所有其他方法都失败了,我们找不到满足上述任一条件的单元格,这意味着我们无法移动了——游戏到此结束。

失败条件

游戏结束:没有有效的移动

运行这个测试的一个合适的地方是在new_tile()方法中:

def new_tile(self, *args):
    empty_cells = [(x, y) for x, y in all_cells()
                   if self.b[x][y] is None]

    # Spawning a new tile (omitted)

    if len(empty_cells) == 1 and self.is_deadlocked():
        print('Game over (board is deadlocked)')

    self.moving = False  # See above, "Synchronizing turns"

预设条件(len(empty_cells) == 1)允许我们减少检查的频率:在棋盘还没有满的情况下测试失败是没有意义的。值得注意的是,我们的is_deadlocked()方法在这种情况下也会正确返回False,所以这纯粹是一个优化,不会以任何方式影响“业务逻辑”。

小贴士

这种方法在性能上仍然略逊一筹,可以通过增加代码长度来改进:一个明显的优化是跳过最后一行和最后一列,然后在每次迭代中不必检查边界,这是can_combine()函数隐式执行的。

然而,这种检查的收益可以忽略不计,因为这种检查最多每回合运行一次,我们大多数时间都在等待用户输入。

接下来该做什么

游戏现在终于可以玩了,但确实有许多可以改进的地方。如果你愿意进一步探索 2048 的概念,可以考虑以下任务:

  • 添加更多动画——它们在感知交互方面大有裨益。

  • 作为额外的激励因素,添加分数计数器和相关的基础设施(例如,保存高分并将其传输到全球服务器端排行榜)。

  • 调整游戏规则,使其与原始的 2048 游戏完全一致。

  • 为了进行更具挑战性的实验,构建一个可以提前预测无果游戏会话的算法。作为一个玩家,我非常希望收到一条通知,内容为:“无论你做什么,7 次回合后游戏就结束了,感谢你的参与。”

  • 完全改变规则。添加多人竞技场死亡匹配模式——发挥创意。

注意

如果你感兴趣,想看看另一个更完整的 Kivy 实现的 2048 游戏,请查看github.com/tito/2048。这个项目由核心 Kivy 开发者 Mathieu Virbel 编写,包括 Google Play 集成、成就和排行榜等功能。

应该假设阅读他人的代码是学习的好方法。

摘要

在本章中,我们构建了一个可玩复制的 2048 游戏。我们还展示了一些可以在其他类似项目中重用的实现细节:

  • 创建一个可伸缩的棋盘,使其在任何分辨率和方向上都能适应屏幕

  • 组合自定义的方块,并利用 Kivy 的Animation API 实现平滑的移动

  • 将玩家的控制映射到触摸屏手势和键盘方向键上,以适应用户可能期望从游戏中得到的任何控制方案

Kivy 框架在游戏开发方面支持得很好;特别是画布渲染和对动画的支持,在构建视频游戏时非常有用。在 Kivy 中进行原型设计也是可行的,尽管比在 JavaScript 中(现代浏览器是一个非常强大的平台,特别是在廉价原型设计方面尤其难以超越)要困难一些。

结果生成的 Python 程序也是跨平台的,除非你以某种方式使用特定于操作系统的 API,这会阻止其他系统运行。这意味着从技术上讲,你的游戏可以被每个人玩,达到最广泛的受众。

使用 Kivy 也不会与在主要应用分发平台上发布你的作品相冲突,无论是苹果应用商店、谷歌应用商店,甚至是 Steam。

当然,与像虚幻引擎或 Unity 这样的完整游戏引擎相比,Kivy 缺乏许多功能以及大部分工具链。这是因为 Kivy 是一个通用 UI 框架,而不是一个游戏引擎本身;有人可能会争论,这种在各自功能集上对截然不同的软件类别进行的比较是不正确的。

总结来说,Kivy 是一个不错的选择,适用于偶尔的独立游戏开发。愤怒的小鸟本可以用 Python 和 Kivy 来实现,这可能是你当时错过的机会规模。 (但请不要为此感到难过,这只是一句鼓励的话。Rovio 成功游戏标题的道路也并不容易。)

这引出了下一章的主题:使用 Kivy 编写街机游戏。它将以各种非常规的方式利用 Kivy 小部件的熟悉概念,创建一个交互式的横向卷轴环境,让人联想到另一款备受好评的独立游戏,Flappy Bird。

第七章。编写 Flappy Bird 克隆版

在第六章《制作 2048 游戏》中,我们已经对简单的游戏开发进行了尝试,以著名的2048谜题为例。这是逻辑上的延续:我们将构建一个街机游戏,更具体地说是一个Flappy Bird风格的横版滚动游戏。

Flappy Bird 是由 Dong Nguyen 在 2013 年发布的一款简单却极具吸引力的移动游戏;到 2014 年 1 月底,它已成为 iOS 应用商店下载量最大的免费游戏。从游戏设计角度来看,Flappy Bird 现象非常有趣。游戏只包含一个动作(在屏幕上任意位置点击以弹起小鸟,改变其轨迹)和一个玩家活动(在不触碰障碍物的情况下飞过障碍物间的缝隙)。这种简单且重复的游戏玩法最近已经成为一种趋势,以下章节将进行解释。

移动游戏设计中的简约主义

经典的二维街机游戏类型最近在移动设备上重新焕发生机。目前有很多复古游戏的商业再发行,价格标签几乎是唯一与 30 年前的原始标题不同的地方——这些包括 Dizzy、Sonic、Double Dragon 和 R-Type 等,仅举几个例子。

许多这些游戏在新环境中共享的一个巨大失望是控制方案的不便:现代设备中普遍存在的触摸屏和陀螺仪并不能很好地替代游戏手柄,甚至不能替代。这一事实也成为新标题的卖点——从零开始设计一个考虑到可用控制方案的游戏可以是一个巨大的胜利。

一些开发者通过在前期彻底简化事物来解决这个问题:事实证明,简单玩具市场非常大,尤其是低成本或免费(可选,广告支持)的标题。

特征非常有限的控制和游戏玩法的游戏确实可以变得非常受欢迎,Flappy Bird 刚好落在了甜蜜的点上,提供了极具挑战性、简约且易于接触的游戏玩法。在本章中,我们将使用 Kivy 重新实现这款特定的游戏设计。我们将介绍许多新事物:

  • 模拟非常简单的街机物理

  • 使用 Kivy 小部件作为功能齐全的游戏精灵,包括任意定位和二维变换,如旋转

  • 实现基本的碰撞检测

  • 制作和实现游戏音效

我们正在构建的游戏没有胜利条件,与障碍物最轻微的碰撞就会结束游戏。在原始的 Flappy Bird 标题中,玩家们竞争更高的分数(通过不撞到任何东西而通过的管道数量)。尽管如此,与上一章类似,计分板的实现故意留给你作为练习。

项目概述

我们的目标是创建一个在概念上与原始《Flappy Bird》相似但视觉不同的游戏。它被不富有想象力地称为Kivy Bird。最终结果如下所示:

项目概览

Kivy Bird 游戏截图

让我们更仔细地看看游戏,将其分解成逻辑部分,创建一个用于开发的工程概要:

  • 背景:场景由多个以不同速度移动的层组成,从而产生整洁的虚假深度(视差效果)。这种运动是恒定的,与任何游戏事件无关;这使得背景成为实现的一个理想起点。

  • 障碍物(管道):这是一个独立的图形层,它以恒定的速度向玩家移动。与背景不同,管道会以不同的相对高度进行程序化调整,以保持玩家可以通过的间隙。与管道碰撞将结束游戏。

  • 可玩角色(小鸟):这个精灵只垂直移动,不断向下坠落。玩家可以通过点击或轻触屏幕上的任何位置来推动小鸟向上,一旦小鸟碰到地面、天花板或管道,游戏就结束了。

这大致是我们将要编写的实现顺序。

创建一个动画背景

我们将使用以下图像来创建游戏的背景:

创建动画背景

背景图像

注意,所有这些都可以无缝水平平铺——这不是一个严格的要求,但仍然是一个期望的特性,因为这样背景看起来更美观。

如描述中提到的,背景始终在运动,与游戏的其他部分无关。这种效果可以通过至少两种方式实现:

  • 使用直接的方法,我们只需在背景中移动一个巨大的纹理多边形(或任意数量的多边形)。在这种情况下创建无缝循环动画可能需要一些工作。

  • 实现相同视觉效果的一种更有效的方法是创建多个静态多边形(每个层一个),覆盖整个视口,然后仅对纹理坐标进行动画处理。使用可平铺的纹理,这种方法会产生无缝且视觉上令人愉悦的结果,并且总体工作量更小——无需重新定位对象。

我们将采用第二种方法,因为它既简单又有效。让我们从包含布局的kivybird.kv文件开始:

FloatLayout:
    Background:
        id: background
        canvas:
            Rectangle:
                pos: self.pos
                size: (self.width, 96)
                texture: self.tx_floor

            Rectangle:
                pos: (self.x, self.y + 96)
                size: (self.width, 64)
                texture: self.tx_grass

            Rectangle:
                pos: (self.x, self.height - 144)
                size: (self.width, 128)
                texture: self.tx_cloud

提示

从现在开始,“魔法数字”主要指的是纹理尺寸:96是地面高度,64是草的高度,144是云的某种任意高度。在生产代码中硬编码这类东西通常是不受欢迎的,但为了简单和最小化示例代码的大小,我们偶尔会这样做。

如您所见,这里根本没有任何移动部件,只是沿着屏幕顶部和底部边缘定位了三个矩形。此场景依赖于纹理作为 Background 类的属性(以 tx_ 开头)公开,我们将实现它。

加载可平铺纹理

我们将从一个用于加载可平铺纹理的辅助函数开始:此功能将在以下代码中大量使用,因此最好在前面将其抽象化。

做法之一是创建一个中间的 Widget 子类,然后将其用作自定义小部件的基类(在 main.py 中):

from kivy.core.image import Image
from kivy.uix.widget import Widget

class BaseWidget(Widget):
    def load_tileable(self, name):
        t = Image('%s.png' % name).texture
        t.wrap = 'repeat'
        setattr(self, 'tx_%s' % name, t)

需要创建辅助函数的部分是 t.wrap = 'repeat'。我们需要将此应用于每个平铺纹理。

在此期间,我们还使用 tx_ 命名约定后跟图像文件名的方式存储新加载的纹理。例如,调用 load_tileable('grass') 将加载名为 grass.png 的文件,并将生成的纹理存储在 self.tx_grass 属性中。这种命名逻辑应该很容易理解。

背景小部件

能够方便地加载纹理后,我们现在可以按照以下方式实现 Background 小部件:

from kivy.properties import ObjectProperty

class Background(BaseWidget):
    tx_floor = ObjectProperty(None)
    tx_grass = ObjectProperty(None)
    tx_cloud = ObjectProperty(None)

    def __init__(self, **kwargs):
        super(Background, self).__init__(**kwargs)

        for name in ('floor', 'grass', 'cloud'):
            self.load_tileable(name)

如果在此处运行代码,您将看到扭曲的纹理拉伸以填充相应的矩形;在没有明确给出纹理坐标的情况下会发生这种情况。为了修复此问题,我们需要调整每个纹理的 uvsize 属性,该属性表示纹理重复多少次以填充多边形。例如,uvsize(2, 2) 表示纹理填充矩形的一个四分之一。

此辅助方法将用于设置 uvsize 到适当的值,以便我们的纹理不会扭曲:

def set_background_size(self, tx):
    tx.uvsize = (self.width / tx.width, -1)

注意

负纹理坐标,如本例所示,意味着纹理会被翻转。Kivy 使用此效果来避免昂贵的光栅操作,将加载任务转移到 GPU(显卡),后者设计用于轻松处理这些操作。

此方法依赖于背景的宽度,因此每次小部件的 size 属性更改时,使用 on_size() 回调来调用它是合适的。这保持了每个纹理的 uvsize 同步,例如,当用户手动调整应用程序窗口大小时:

def on_size(self, *args):
    for tx in (self.tx_floor, self.tx_grass, self.tx_cloud):
        self.set_background_size(tx)

如果做得正确,到目前为止的代码将生成类似于以下背景:

背景小部件

带纹理的静态背景

动画背景

在继续处理应用程序的其他部分之前,我们需要做的是添加背景动画。首先,我们向 KivyBirdApp 应用程序类添加一个大约每秒运行 60 次的单调计时器:

from kivy.app import App
from kivy.clock import Clock

class KivyBirdApp(App):
    def on_start(self):
        self.background = self.root.ids.background
        Clock.schedule_interval(self.update, 0.016)

    def update(self, nap):
        self.background.update(nap)

目前 update() 方法只是将控制权传递给 Background 小部件的类似方法。此方法的范围将在我们程序中有更多移动部件时扩展。

Background.update() 中,我们更改纹理原点(即名为 uvpos 的属性)以模拟移动:

def update(self, nap):
    self.set_background_uv('tx_floor', 2 * nap)
    self.set_background_uv('tx_grass', 0.5 * nap)
    self.set_background_uv('tx_cloud', 0.1 * nap)

def set_background_uv(self, name, val):
    t = getattr(self, name)
    t.uvpos = ((t.uvpos[0] + val) % self.width, t.uvpos[1])
    self.property(name).dispatch(self)

再次,有趣的事情发生在辅助函数 set_background_uv() 中:

  • 它增加 uvpos 属性的第一个组件,水平移动纹理原点

  • 它在纹理属性上调用 dispatch(),表示它已更改

画布指令(在 kivybird.kv 中)监听此变化并相应地做出反应,以更新的原点渲染纹理。这导致动画平滑。

控制不同图层动画速度的乘数(参见所有 set_background_uv() 调用的第二个参数)被任意选择以创建所需的视差效果。这纯粹是装饰性的;你可以随意更改它们,以见证它对动画的影响。

背景现在已经完成,接下来我们列表上的下一件事是制作管道。

制作管道

管道分为两部分,下部和上部,中间有一个间隙供玩家通过。每一部分,反过来,又由可变长度的主体和管道盖,或 pcap(管道面对间隙的固定大小加厚部分)组成。我们将使用以下图像来绘制管道:

制作管道

管道图像

如果前面的解释没有引起你的共鸣,请参阅本章的第一幅插图,你将立即理解这是什么意思。

再次,kivybird.kv 文件中的布局提供了一个方便的起点:

<Pipe>:
    canvas:
        Rectangle:
            pos: (self.x + 4, self.FLOOR)
            size: (56, self.lower_len)
            texture: self.tx_pipe
            tex_coords: self.lower_coords

        Rectangle:
            pos: (self.x, self.FLOOR + self.lower_len)
            size: (64, self.PCAP_HEIGHT)
            texture: self.tx_pcap

        Rectangle:
            pos: (self.x + 4, self.upper_y)
            size: (56, self.upper_len)
            texture: self.tx_pipe
            tex_coords: self.upper_coords

        Rectangle:
            pos: (self.x, self.upper_y - self.PCAP_HEIGHT)
            size: (64, self.PCAP_HEIGHT)
            texture: self.tx_pcap

    size_hint: (None, 1)
    width: 64

从概念上讲,这非常简单:在画布上渲染了四个矩形,按照源文件中出现的顺序列出:

  • 下部管道主体

  • 下部管道盖

  • 上部管道主体

  • 上部管道盖

制作管道

从矩形组成管道

此列表依赖于 Pipe 对象的许多属性;类似于 Background 小部件的实现,这些属性用于将算法的 Python 实现与小部件的图形表示(画布指令)连接起来。

管道属性的概述

Pipe 小部件的所有有趣属性都在以下代码片段中展示:

from kivy.properties import (AliasProperty,
                             ListProperty,
                             NumericProperty,
                             ObjectProperty)

class Pipe(BaseWidget):
    FLOOR = 96
    PCAP_HEIGHT = 26
    PIPE_GAP = 120

    tx_pipe = ObjectProperty(None)
    tx_pcap = ObjectProperty(None)

    ratio = NumericProperty(0.5)
    lower_len = NumericProperty(0)
    lower_coords = ListProperty((0, 0, 1, 0, 1, 1, 0, 1))
    upper_len = NumericProperty(0)
    upper_coords = ListProperty((0, 0, 1, 0, 1, 1, 0, 1))

    upper_y = AliasProperty(
        lambda self: self.height - self.upper_len,
        None, bind=['height', 'upper_len'])

首先,在 ALL_CAPS 中设置常量:

  • FLOOR:这是地面水平(地板纹理的高度)

  • PCAP_HEIGHT:这是管道盖的高度,也来自相应的纹理

  • PIPE_GAP:这是留给玩家的通道大小

接下来是纹理属性 tx_pipetx_pcap。它们的使用方式与 Background 类中找到的方式相同:

class Pipe(BaseWidget):
    def __init__(self, **kwargs):
        super(Pipe, self).__init__(**kwargs)

        for name in ('pipe', 'pcap'):
            self.load_tileable(name)

ratio 属性指示间隙的位置:0.5(默认值)表示中心,0 是屏幕底部(在地面上),而 1 是屏幕顶部(在天空)。

lower_lenupper_len 属性表示管道长度,不包括盖子。这些是从 ratio 和可用的屏幕高度派生出来的。

upper_y 别名是一个辅助工具,用于减少输入;它是即时计算的,始终等于 height - upper_len(参见实现)。

这给我们留下了两个重要的属性,用于为画布指令设置纹理坐标,即lower_coordsupper_coords

设置纹理坐标

Background小部件的实现中,我们调整了纹理的自身属性,如uvsizeuvpos,以控制其渲染。这种方法的问题在于它会影响所有纹理实例。

只要纹理不在不同的几何形状上重复使用,这正好是背景的情况。然而,这一次,我们需要按画布原语控制纹理坐标,所以我们不会触摸uvsizeuvpos。相反,我们将使用Rectangle.tex_coords

Rectangle.tex_coords属性接受一个包含八个数字的列表或元组,将纹理坐标分配给矩形的角落。以下截图显示了坐标到tex_coords列表索引的映射:

设置纹理坐标

纹理坐标到矩形多边形的映射

注意

纹理映射通常使用uv变量而不是xy,这使得区分几何和纹理坐标更容易,这些坐标在代码中通常交织在一起。

实现管道

这个话题一开始可能听起来很复杂,所以让我们稍微简化一下:我们只会在管道上垂直调整平铺,我们只需要调整tex_coords的第五和第七个元素来实现我们的高尚目标。此外,tex_coords中的值与uvsize中的值具有相同的意义。

简而言之,以下函数根据管道长度调整坐标以实现正确的平铺:

def set_coords(self, coords, len):
    len /= 16  # height of the texture
    coords[5:] = (len, 0, len)  # set the last 3 items

真的很简单吗?接下来要做的是一项既无聊又并不复杂的数学工作:根据ratio和屏幕高度计算管道的长度。代码如下:

def on_size(self, *args):
    pipes_length = self.height - (
        Pipe.FLOOR + Pipe.PIPE_GAP + 2 * Pipe.PCAP_HEIGHT)
    self.lower_len = self.ratio * pipes_length
    self.upper_len = pipes_length - self.lower_len
    self.set_coords(self.lower_coords, self.lower_len)
    self.set_coords(self.upper_coords, self.upper_len)

这段代码非常明显,位于on_size()处理程序中,以保持所有相关属性与屏幕大小同步。为了反映对ratio的更改,我们可以发出以下函数调用:

self.bind(ratio=self.on_size)

你可能已经注意到,我们还没有更改这个属性。这是因为管道的整个生命周期将由应用程序类KivyBirdApp处理,你很快就会看到。

创建管道

结果表明,为了创建一个看似无尽的管道森林的错觉,我们只需要一屏幕的管道,因为我们可以在屏幕外回收它们并将它们推到队列的后面。

我们将创建间距约为屏幕宽度一半的管道,给玩家留出一些操作空间;这意味着屏幕上一次只能看到三个管道。为了保险起见,我们将创建四个管道。

以下代码片段包含了对所述算法的实现:

class KivyBirdApp(App):
    pipes = []

    def on_start(self):
        self.spacing = 0.5 * self.root.width
        # ...

    def spawn_pipes(self):
        for p in self.pipes:
            self.root.remove_widget(p)

        self.pipes = []

        for i in range(4):
            p = Pipe(x=self.root.width + (self.spacing * i))
            p.ratio = random.uniform(0.25, 0.75)
            self.root.add_widget(p)
            self.pipes.append(p)

应将pipes列表的使用视为实现细节。我们本可以遍历子小部件的列表来访问管道,但这样做更方便。

spawn_pipes()方法的开始处的清理代码将允许我们稍后轻松地重新启动游戏。

我们也在这个函数中随机化每个管道的ratio。请注意,范围被人为地限制在[0.25, 0.75],而技术上它是[0, 1]——缩小这个空间使得游戏更容易玩,从一门到另一门需要的垂直操作更少。

移动和回收管道

与我们移动纹理的uvpos属性以模仿移动的背景不同,管道实际上是移动的。这是修改后的KivyBirdApp.update()方法,它涉及重新定位和回收管道:

def update(self, nap):
    self.background.update(nap)

    for p in self.pipes:
        p.x -= 96 * nap
        if p.x <= -64:  # pipe gone off screen
            p.x += 4 * self.spacing
            p.ratio = random.uniform(0.25, 0.75)

与之前的动画一样,96是一个临时的时间乘数,恰好适用;增加它会使游戏节奏更快。

当推回一个管道时,我们再次随机化它的ratio,为玩家创造一条独特的路径。以下截图总结了到目前为止无限循环的结果:

移动和回收管道

移动管道和背景 – 一个 Flappy Bird 主题的屏保

介绍基维鸟

接下来在我们的列表中是可玩的角色,即生物上不可能存在的基维鸟:

介绍基维鸟

稀有物种,基维鸟精灵

这次将不会有与纹理相关的任何花哨的东西;实际上,Bird类将从 Kivy 的Image小部件(kivy.uix.image.Image)派生出来,以完全避免进行任何复杂的渲染。

kivybird.kv中,我们需要涉及之前描述的鸟的图像的最小属性;其初始位置和大小如下所示:

Bird:
    id: bird
    pos_hint: {'center_x': 0.3333, 'center_y': 0.6}
    size: (54, 54)
    size_hint: (None, None)
    source: 'bird.png'

这是 Python 中Bird类的初始实现:

from kivy.uix.image import Image as ImageWidget

class Bird(ImageWidget):
    pass

是的,它什么也不做。很快,我们将通过添加基本的物理和其他东西来破坏它,但首先我们需要在应用程序类中做一些基础工作,以便使游戏状态化。

修改后的应用程序流程

现在,我们将模仿原始游戏:

  1. 首先,我们将只显示小鸟坐在那里,没有任何管道或重力。这种状态在代码中将表示为playing = False

  2. 用户一旦与游戏互动(无论是点击或触摸屏幕上的任何位置,还是按下键盘上的空格键),状态就会变为playing = True,管道开始生成,重力开始影响小鸟,它像一块石头一样掉入想象中的死亡。用户需要继续与游戏互动,以保持小鸟在空中。

  3. 如果发生碰撞,游戏将回到playing = False状态,直到下一次用户互动,然后从步骤 2 重新启动这个过程。

为了实现这一点,我们需要接收用户输入。幸运的是,这几乎是微不足道的,特别是我们只对事件发生的事实感兴趣(例如,我们不是检查点击或触摸的位置——在这个游戏中,整个屏幕就是一个大按钮)。

接受用户输入

让我们立即查看实现,因为在这个特定主题上几乎没有讨论的余地:

from kivy.core.window import Window, Keyboard

class KivyBirdApp(App):
    playing = False

    def on_start(self):
        # ...
        Window.bind(on_key_down=self.on_key_down)
        self.background.on_touch_down = self.user_action

    def on_key_down(self, window, key, *args):
        if key == Keyboard.keycodes['spacebar']:
            self.user_action()

    def user_action(self, *args):
        if not self.playing:
            self.spawn_pipes()
            self.playing = True

这是我们将要需要的整个用户输入处理:on_key_down事件处理键盘输入,检查特定键(在这种情况下,是空格键)。on_touch_down事件处理其余操作——点击、轻触等。两者最终都会调用user_action()方法,该方法进而调用spawn_pipes()并将playing设置为True(仅在需要时)。

学习直线下降飞行

接下来,我们将实现重力,使我们的鸟至少能向一个方向飞行。为此,我们将引入一个新的Bird.speed属性和一个新的常量——自由落体的加速度。速度向量将每帧向下增长,从而产生均匀加速的下降动画。

以下列表包含描述的射击小鸟的实现:

class Bird(ImageWidget):
    ACCEL_FALL = 0.25

    speed = NumericProperty(0)

    def gravity_on(self, height):
        # Replace pos_hint with a value
        self.pos_hint.pop('center_y', None)
        self.center_y = 0.6 * height

    def update(self, nap):
        self.speed -= Bird.ACCEL_FALL
        self.y += self.speed

playing变为True时,将调用gravity_on()方法。将高亮行插入到KivyBirdApp.user_action()方法中:

if not self.playing:
    self.bird.gravity_on(self.root.height)
    self.spawn_pipes()
    self.playing = True

此方法实际上重置了小鸟的初始位置,并通过从pos_hint中移除'center_y'约束来允许垂直运动。

注意

self.bird引用与之前见过的self.background类似。以下代码片段应位于KivyBirdApp.on_start()方法中:

self.background = self.root.ids.background
self.bird = self.root.ids.bird

这只是为了方便。

我们还需要在KivyBirdApp.update()中调用Bird.update()。同时,这也是放置一个保护器的完美机会,防止在游戏对象未播放时进行无用的更新:

    def update(self, nap):
        self.background.update(nap)
        if not self.playing:
 return  # don't move bird or pipes

 self.bird.update(nap)
        # rest of the code omitted

正如你所见,无论发生什么情况,都会调用Background.update()方法;其他所有操作只有在必要时才会被调用。

在这个实现中缺少的是保持空中飞行的能力。这是我们下一个要讨论的主题。

保持飞行状态

实现 Flappy Bird 风格的跳跃飞行非常简单。我们只需暂时覆盖Bird.speed,将其设置为正值,然后让它在小鸟继续下落时正常衰减。让我们向Bird类添加以下方法:

ACCEL_JUMP = 5

def bump(self):
    self.speed = Bird.ACCEL_JUMP

现在我们需要在KivyBirdApp.user_action()函数的末尾添加对self.bird.bump()的调用,在那里,一切就绪:我们可以通过连续按空格键或点击视口内部来保持在空中。

旋转小鸟

旋转小鸟是一个简短的主题,与小鸟的物理特性无关,而是专注于视觉效果。如果小鸟向上飞行,它的鼻子应该指向屏幕右上角的一般方向,当它下降时,则指向屏幕右下角。

估算角度的最简单方法是用Bird.speed的值:

class Bird(ImageWidget):
    speed = NumericProperty(0)
    angle = AliasProperty(
        lambda self: 5 * self.speed,
        None, bind=['speed'])

再次强调,这里显示的乘数完全是任意的。

现在,为了实际上旋转精灵,我们可以在kivybird.kv文件中引入以下定义:

<Bird>:
    canvas.before:
        PushMatrix
        Rotate:
            angle: root.angle
            axis: (0, 0, 1)
            origin: root.center

    canvas.after:
        PopMatrix

这个操作改变了 OpenGL 为这个精灵使用的本地坐标系,可能会影响所有后续的渲染。别忘了保存(PushMatrix)和恢复(PopMatrix)坐标系状态;否则,可能会发生灾难性的故障,整个场景可能会扭曲或旋转。

注意

反过来也是如此:如果你遇到无法解释的应用程序范围内的渲染问题,查找未正确作用域的低级 OpenGL 指令。

在这些更改之后,鸟应该正确地调整自己的飞行轨迹。

碰撞检测

对于游戏玩法来说,最重要的是碰撞检测,当鸟与地面、天花板或管道碰撞时,游戏结束。

检查我们是否达到了地面或天花板,就像比较 bird.y 与地面水平或屏幕高度(在第二次比较时考虑到鸟本身的高度)。在 KivyBirdApp 中,我们有以下代码

def test_game_over(self):
    if self.bird.y < 90 or \
            self.bird.y > self.root.height - 50:
        return True

    return False

当寻找与管道的碰撞时,情况稍微复杂一些,但并不十分复杂。我们可以将接下来的检查分为两部分:首先,我们使用 Kivy 内置的 collide_widget() 方法测试水平碰撞,然后检查垂直坐标是否在进入的管道的 lower_lenupper_len 属性所规定的范围内。

因此,KivyBirdApp.test_game_over() 方法的修订版如下所示:

    def test_game_over(self):
        screen_height = self.root.height

        if self.bird.y < 90 or \
                self.bird.y > screen_height - 50:
            return True

        for p in self.pipes:
            if not p.collide_widget(self.bird):
                continue

            # The gap between pipes
            if (self.bird.y < p.lower_len + 116 or
                self.bird.y > screen_height - (
                    p.upper_len + 75)):
                return True

        return False

这个函数仅在所有检查都失败时返回 False。这可以进一步优化,以一次测试最多一个管道(在屏幕上与鸟大致在同一区域的管道;考虑到它们之间有足够的间隔,这样的管道最多只有一个)。

游戏结束

那么,当确实发现碰撞时会发生什么?实际上,非常少;我们只需将 self.playing 切换为 False,就结束了。检查可以在所有其他计算完成后添加到 KivyBirdApp.update() 的底部:

def update(self, nap):
    # ...
    if self.test_game_over():
        self.playing = False

这将停止游戏,直到用户触发另一个交互,重新开始游戏。编写碰撞检测代码最有回报的部分是进行游戏测试,以多种有趣的方式触发游戏结束状态:

游戏结束

探索失败的不同方法(拼贴)

如果没有胜利条件,那么至少失败应该是有趣的。

制作音效

这一部分将不会特别关注 Kivy 鸟游戏本身;更多的是概述可以用于向游戏或应用程序添加音效的各种工具。

声音效果的最大问题很少是技术性的。创建高质量的音效不是一件小事,而且软件工程师通常不是技艺高超的音乐家或音频工程师。此外,大多数应用程序实际上在没有声音的情况下也是可用的,这就是为什么音频很容易在开发过程中被故意忽视或忽略。

幸运的是,有一些工具可以方便地制作出质量不错的音效,同时不需要任何特定领域的知识。一个完美的例子是Bfxr,这是一个专门针对偶尔进行游戏开发的合成器。它可以在www.bfxr.net免费获得。

Bfxr 工具系列的用法归结为点击预设按钮,直到生成一个好听的声音,然后点击保存到磁盘以将结果存储为.wav(未压缩声音)文件。

制作音效

Bfxr 的用户界面一开始可能看起来不太友好,但实际上非常容易使用

这是一款在提高生产力方面非常出色的工具。使用 Bfxr,你可以在几分钟内就创造出可用的音效——而且这些音效将(大部分)仅适用于你的应用程序。对于许多业余游戏开发者来说,这个程序真正是一个变革性的工具,这不是字面上的意思。

Kivy 音效播放

在程序方面,Kivy 提供的播放 API 非常简单:

from kivy.core.audio import SoundLoader

snd = SoundLoader.load('sound.wav')
snd.play()

play()方法开始播放,就这么多。嗯,实际上并不是:这种简单的方法存在一些问题,尤其是在游戏中。

在许多游戏场景中,可能需要连续快速地播放相同的音效,以便样本重叠。以自动射击为例。Kivy 的Sound类(尽管并非特有,例如 HTML5 中的<audio>标签也有类似行为)的问题在于它只允许在任何给定时间播放一个样本实例。

选项如下:

  • 等待之前的播放结束(默认行为,所有后续事件都将静音)

  • 对于每个事件停止并重新开始播放,这也是一个问题(这可能会引入不必要的延迟、点击或其他音频伪影)

为了解决这个问题,我们需要创建一个Sound对象的池(实际上是一个队列),以便每次调用play()都涉及另一个Sound。当队列耗尽时,我们将其重置并从头开始。给定足够大的队列,我们可以完全消除上述Sound限制。实际上,这样的池的大小很少超过 10。

让我们看看描述技术的实现:

class MultiAudio:
    _next = 0

    def __init__(self, filename, count):
        self.buf = [SoundLoader.load(filename)
                    for i in range(count)]

    def play(self):
        self.buf[self._next].play()
        self._next = (self._next + 1) % len(self.buf)

使用方法如下:

snd = MultiAudio('sound.wav', 5)
snd.play()

构造函数的第二个参数代表池的大小。注意我们如何保持与现有Sound API 的基本兼容性,即play()方法。这允许在简单场景中使用代码作为Sound对象的直接替换。

为 Kivy Bird 游戏添加音效

为了用一个实际例子结束,让我们为我们在本章中编写的 Kivy Bird 游戏添加音效。

有两种常见的事件可能需要配乐,即鸟儿爬升和鸟儿与物体碰撞触发游戏结束状态。

前者事件,由点击或轻触引发,确实可能非常频繁地连续发生;我们将为此使用一个样本池。后者,游戏结束,不可能发生得那么快,所以将其保留为普通的 Sound 对象是完全可以的:

snd_bump = MultiAudio('bump.wav', 4)
snd_game_over = SoundLoader.load('game_over.wav')

此代码使用了之前布置的 MultiAudio 类。唯一剩下的事情就是在适当的位置放置对 play() 方法的调用,如下面的代码片段所示:

if self.test_game_over():
    snd_game_over.play()
    self.playing = False

def user_action(self, *args):
    snd_bump.play()

从现在开始,游戏玩法将伴随着撕心裂肺的声音。这标志着 Kivy 鸟游戏教程的结束;我希望你们喜欢。

摘要

在本章中,我们使用简单的构建块,如画布指令和小部件,制作了一个小的 Kivy 游戏。

作为 UI 工具包,Kivy 做了很多正确的事情,其非凡的灵活性允许你构建几乎任何东西,无论是另一个无聊的 Twitter 客户端还是一款视频游戏。值得特别提一下的是 Kivy 对属性的实现——这些属性在组织数据流方面非常有帮助,并帮助我们有效地消除无用的更新(例如,在属性未更改的情况下重绘)。

关于 Kivy 的另一件可能令人惊讶且反直觉的事情是其相对较高的性能——尤其是在 Python 并非以其极快的速度而闻名的情况下。这主要是因为 Kivy 的底层子系统是用 Cython 编写的,并编译成非常快速的机器码,性能水平与例如 C 语言相当。此外,如果正确实施,使用硬件加速的图形几乎可以保证平滑的动画。

我们将在下一章探讨提高渲染性能的主题。

第八章. 引入着色器

恭喜您已经走得很远了!最后两章将与本书的其他部分有所不同,因为我们将对 Kivy 采取完全不同的视角,深入探讨 OpenGL 渲染器的底层细节,例如OpenGL 着色语言GLSL)。这将使我们能够以极小的开销编写高性能代码。

从对 OpenGL 的非科学介绍开始,我们将继续编写一个用于星系演示(基本上是一个屏幕保护程序)的快速精灵引擎,最后是一个射击游戏(通常简称为shmup)。本章的代码将作为下一章的基础,而本书中的其他项目大多是自包含的。我们将在本章打下基础,然后在下一章在此基础上构建,将技术演示转变为可玩的游戏。

本章试图以足够的详细程度涵盖许多复杂主题,但它实在过于简短,无法作为全面参考指南。此外,作为一项标准,OpenGL 的发展非常迅速,不断引入新特性并淘汰过时的内容。因此,如果您在章节中提供的材料与客观现实之间存在差异,请查阅相关资料——很可能您正处于计算技术的光明未来,那里的事物已经发生了显著变化。

需要提前说明的是,这里讨论的高性能渲染方法,尽管与常规的 Kivy 代码截然不同,但大部分仍然与之兼容,可以与普通小部件并行使用。因此,仅将应用程序中资源消耗大的部分用 GLSL 实现——否则这些部分将成为性能瓶颈——是完全可行的。

对 OpenGL 的非科学介绍

本节将快速介绍 OpenGL 的基本知识。在这里,几乎不可能有意义地总结标准的所有细节;因此,它被称为“非科学的”、“表面的”。

OpenGL 是一个流行的底层图形 API。它是标准化的,几乎无处不在。桌面和移动操作系统通常都附带 OpenGL 的实现(在移动设备上,是 OpenGL ES,这是标准的一个功能受限子集;在这里,ES代表嵌入式系统)。现代网络浏览器也实现了 OpenGL ES 的一个变体,称为 WebGL。

广泛的分布和明确的兼容性使 OpenGL 成为跨平台应用程序的良好目标,尤其是视频游戏和图形工具包。Kivy 也依赖于 OpenGL 在所有支持的平台上进行渲染。

概念和并行性

OpenGL 在基本原语上操作,例如屏幕上的单个顶点和像素。例如,我们可以向其提供三个顶点并渲染一个三角形,从而为每个受影响的像素计算颜色(取决于下一张图中描述的管道)。你可能已经猜到,在这个抽象级别上工作非常繁琐。这基本上概括了高级图形框架(包括 Kivy)的存在理由:它们的存在是为了在更舒适的抽象(例如使用小部件和布局)背后隐藏渲染管道的细节。

低级渲染管道的工作方式如下:

概念和并行性

OpenGL 管道(简化版)

上述图示的完整解释如下:

  • 应用程序为 OpenGL 提供一个顶点数组(点)、允许我们重用这些点的索引,以及其他任意值(称为统一变量)。

  • 对于每个顶点,都会调用顶点着色器,如果需要则对其进行变换,并可选择执行其他计算。然后,它的输出传递给相应的片段着色器。

  • 对于每个受影响的像素,都会调用片段着色器(有时称为像素着色器),计算该像素的颜色。通常,它会考虑顶点着色器的输出,但也可能返回,例如,一个常量颜色。

  • 像素在屏幕上渲染,并执行其他簿记任务;这些任务对我们来说目前没有兴趣。

注意

一起用作批次的顶点集合通常称为模型网格。它不一定是连续的,也可能由散布的多个多边形组成;这种模型的理由将在稍后提及。

OpenGL 背后高速运行的“秘密配方”是其固有的巨大并行性。前面提到的函数(即顶点和像素着色器)本身可能并不疯狂快速,但它们在 GPU 上同时调用时,着色器引入的延迟通常不会随着着色器复杂性的增加而呈指数增长;在良好的硬件上,这种增长可以接近线性。

为了量化这一点,考虑到今天个人计算机(在撰写本书时),我们谈论的是具有 2 到 16 个 CPU 核心的通用硬件的多任务处理和并行编程。另一方面,中档显卡实际上拥有数千个 GPU 核心;这使得它们能够并行运行更多的计算。

然而,每个任务都是独立运行的。与通用编程中的线程不同,着色器不能在没有显著降低性能的情况下等待其他着色器的输出,除非管道架构暗示了这一点(如前所述,顶点着色器将值传递给片段着色器)。当你开始编写 GLSL 时,这种限制可能会让你感到有些难以理解。

这也是为什么一些算法可以在 GPU 上高效运行,而其他算法则不能。有趣的是,现代加密函数如bcrypt是专门设计来降低高度并行化实现的性能——这使得此类函数在本质上更加安全,因为它限制了暴力攻击的有效性。

性能提升,或缺乏提升

重要的是要理解,始终使用原始 OpenGL 调用并不会带来即时性能提升;在许多情况下,像 Kivy 这样的高级框架会做得很好。例如,当在屏幕上的某个位置渲染多边形时,大致会发生以下操作序列:

  1. 多边形的几何形状和位置在 Python 中定义。

  2. 顶点、索引和相关资产(如纹理)被上传到图形驱动器。

  3. 调用顶点着色器。它应用必要的变换,包括定位、旋转、缩放等。

  4. 最后,调用相应的片段着色器;这会产生可能显示在屏幕上的光栅图像。

无论您是使用 Kivy 小部件来完成这项任务还是坚持编写原始 OpenGL 命令和 GLSL 着色器——性能和结果可能相同,最多只有微小的差异。这是因为 Kivy 在幕后运行非常相似的 OpenGL 代码。

换句话说,这个例子几乎没有低级优化的潜力,这正是像Kivy Bird这样的游戏应该以最高级别的抽象实现的原因。基本上,我们可以在 Kivy Bird 中优化掉创建一个或两个小部件,但这几乎无法衡量。

提高性能

那么,我们实际上如何提高性能呢?答案是,通过减少 Python 端的工作量并将相似对象批量一起渲染。

让我们考虑这样一个场景,我们需要渲染超过 9,000 个类似的多边形(例如,一个粒子系统,地面上散落的秋叶或在太空中的星团)。

如果我们为单个多边形使用 Kivy 小部件,我们正在创建大量仅用于将自身序列化为 OpenGL 指令的 Python 对象。此外,每个小部件都有自己的顶点集,它将其提供给图形驱动器,从而发出过多的 API 调用并创建大量独特(但非常相似)的网格。

手动操作,我们至少可以做以下事情:

  • 避免实例化许多 Python 类,只需将所有坐标保存在一个数组中。如果我们以适合直接 OpenGL 消费的格式存储它们,则无需进行序列化步骤。

  • 将所有几何形状组合成一个单一模型,从而大大减少 API 调用。批处理始终是一种很好的优化,因为它允许 OpenGL 更好地并行执行任务。

我们将在本章结束时实现所描述的方法。

仔细看看 GLSL

作为一种语言,GLSL 与 C 语言密切相关;特别是在语法上,它们非常相似。GLSL 是强静态类型(比 C 语言更强)。

如果您不熟悉 C 语法,这里有一个非常快速的基础。首先,与 Python 不同,在类似 C 的语言中,缩进是不重要的,并且必须以分号结束语句。逻辑块被大括号包围。

GLSL 支持 C 和 C++风格的注释:

/* ANSI C-style comment */
// C++ one-line comment

变量声明格式为[type] [name] [= optional value];

float a; // this has no direct Python equivalent
int b = 1;

函数使用[type] [name] ([arguments]) { [body of function] }语法定义:

float pow2(float x)
{
    return x * x;
}

控制结构是这样写的:

if (x < 9.0)
{
    x = 9.0;
}

大部分就是这样;无论您是否有 C 编程背景,您现在都应该能够阅读 GLSL 代码。

着色器的入口点由main()函数指定。在以下代码中,我们将将顶点着色器和片段着色器合并到一个文件中;因此,每个文件将有两个main()函数。这些函数看起来是这样的:

void main(void)
{
    // code
}

特殊的void类型表示没有值,并且与 Python 的NoneType不同,您不能声明void类型的变量。在先前的main()函数中,返回值和参数都被省略了;因此,函数的声明读作void main(void)。着色器不是从函数返回计算结果,而是将其写入特殊内置变量,如gl_Positiongl_FragColor等,具体取决于着色器类型和所需效果。这也适用于输入参数。

GLSL 的类型系统紧密地反映了其使用域。与 C 语言不同,它具有高度专业化的向量矩阵类型;这些类型支持对它们的数学操作(因此,您只需使用mat1 * mat2语法即可乘以矩阵;这有多酷!)。在计算机图形学中,矩阵通常用于处理坐标系,您很快就会看到这一点。

在下一节中,我们将编写几个简单的 GLSL 着色器来演示之前讨论的一些概念。

在 Kivy 中使用自定义着色器

除了 GLSL 之外,我们还需要有初始化窗口、加载着色器等常规 Python 代码。以下程序将作为一个良好的起点:

from kivy.app import App
from kivy.base import EventLoop
from kivy.graphics import Mesh
from kivy.graphics.instructions import RenderContext
from kivy.uix.widget import Widget

class GlslDemo(Widget):
    def __init__(self, **kwargs):
        Widget.__init__(self, **kwargs)
        self.canvas = RenderContext(use_parent_projection=True)
        self.canvas.shader.source = 'basic.glsl'
        # Set up geometry here.

class GlslApp(App):
    def build(self):
        EventLoop.ensure_window()
        return GlslDemo()

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

在这个例子中,我们只创建了一个名为GlslDemo的小部件;它将托管所有渲染。RenderContext是一个可定制的Canvas子类,允许我们轻松替换着色器,如列表所示。basic.glsl文件包含顶点着色器和片段着色器;我们将在下一分钟了解它。

注意,这次我们根本不使用 Kivy 语言,因为没有计划布局层次结构,所以没有相应的glsl.kv文件。相反,我们将通过从GlslApp.build()方法返回它来手动指定根小部件。

需要调用EventLoop.ensure_window(),因为我们希望在运行GlslDemo.__init__()时能够访问 OpenGL 功能,例如 GLSL 编译器。如果在那个时间点还没有应用程序窗口(更重要的是,没有相应的 OpenGL 上下文),程序将会崩溃。

构建几何形状

在我们开始编写着色器之前,我们需要一些可以渲染的内容——一系列顶点,即模型。我们将坚持使用一个简单的矩形,它由两个具有共同斜边的直角三角形组成(细分是因为基线多边形本质上都是三角形的)。

注意

Kivy 虽然大部分是二维的,但它在任何方面都没有强加这种限制。另一方面,OpenGL 本质上是三维的,因此你可以无缝地使用真实模型来创建现代外观的游戏,甚至可以将它们与常规 Kivy 小部件混合用于 UI(游戏菜单等)。这本书中没有进一步详细说明这种可能性,但背后的机制与这里描述的完全相同。

这是GlslDemo小部件更新的__init__()方法,下面是它的说明:

def __init__(self, **kwargs):
    Widget.__init__(self, **kwargs)
    self.canvas = RenderContext(use_parent_projection=True)
    self.canvas.shader.source = 'basic.glsl'

    fmt = ( # Step 1
        (b'vPosition', 2, 'float'),
    )

    vertices = ( # Step 2
        0,   0,
        255, 0,
        255, 255,
        0,   255,
    )

    indices = (0, 1, 2, 2, 3, 0)  # Step 3

    with self.canvas:
        Mesh(fmt=fmt, mode='triangles',  # Step 4
             indices=indices, vertices=vertices)

让我们逐步分析这个函数,因为它在继续处理更复杂的事情之前是至关重要的:

  • 当编写利用 OpenGL 的代码时,你首先会注意到没有内置的标准顶点格式供我们遵守;相反,我们需要自己定义这样的格式。在最简单的情况下,我们只需要每个顶点的位置;这被称为vPosition。我们的矩形是二维的,所以我们将传递两个坐标,默认情况下是浮点数。因此,我们得到的结果行是(b'vPosition', 2, 'float')

  • 现在我们已经决定了顶点的格式,是时候将这些顶点放入一个数组中,这个数组很快就会被交给渲染器。这正是vertices = (...)这一行所做的。重要的是这个元组是扁平且无结构的。我们将单独定义记录格式,然后紧密地打包所有值,没有字段分隔符等——所有这些都是在效率的名义下进行的。这也是 C 结构通常的工作方式。

  • 需要索引来复制(重用)顶点。通常情况下,一个顶点会被多个三角形使用。我们不会在顶点数组中直接重复它,而是通过在索引数组中重复它的索引来实现——这通常更小,因此整个结构最终占用的内存更少,与单个顶点的尺寸成比例。下一节将更详细地解释索引。

  • 在所有必需的数据结构就绪后,我们最终可以使用同名的 Kivy 画布指令Mesh组装网格。现在,它将在正常小部件渲染过程中渲染,这有一个很好的副作用,即与其他 Kivy 小部件的可组合性。我们的 GLSL 代码可以毫不费力地与所有先前的发展一起使用。这当然是一件好事。

备注

在本章中,我们一直在 C 语言的意义上使用“数组”这个词——一个包含同质数据的连续内存区域。这与具有相同名称的 Python 数据结构只有试探性的关系;实际上,在 Python 方面,我们主要使用元组或列表作为替代。

展示索引

为了更好地解释 OpenGL 索引,让我们可视化我们的示例。这些是从前面的示例代码中获取的顶点,格式为(x, y):

vertices = (
    0,   0,
    255, 0,
    255, 255,
    0,   255,
)

索引只是这样——vertices列表中顶点的序列号,它是基于 0 的。以下图展示了在这个设置中对顶点分配索引的情况:

展示索引的插图

平面上的散点

目前,顶点没有连接,所以它们最多形成点云,而不是有结构的多边形形状。为了解决这个问题,我们需要指定indices列表——它将现有的顶点分组成三角形。其定义,再次从示例代码中获取,如下所示:

indices = (
    0, 1, 2, # Three vertices make a triangle.
    2, 3, 0, # And another one.
)

我们在这里构建了两个三角形:第一个由顶点 0 到 2 组成,第二个由顶点 2、3 和 0 组成。注意 0^(th)和 2^(nd)顶点的重复使用。

这在下图中得到了说明。颜色不必在意;它们完全是解释性的,还不是“真实”的颜色。我们很快就会在屏幕上给事物上色。

展示索引的插图

由顶点构建三角形

这基本上总结了在 OpenGL 相关代码中使用索引的用途和用法。

备注

在 OpenGL 中优化数据结构内存大小的趋势与节省 RAM 本身关系不大——在大多数情况下,显卡接口吞吐量是一个更严重的瓶颈,因此我们旨在每帧传递更多内容,而不仅仅是压缩数据以节省经济成本。这种区别虽然非常重要,但在一开始并没有什么不同。

编写 GLSL

这将是事情变得更有趣的地方。一会儿,我们将编写在 GPU 上执行的 GLSL 代码。正如我们已经提到的,它类似于 C 语言,并且非常快。

让我们从基础知识开始。Kivy 期望顶点着色器和片段着色器都位于同一个文件中,使用特殊语法分隔,即'---vertex''---fragment'(在下一个代码片段中显示)。重要的是要强调,这两个分隔符和$HEADER$语法都是 Kivy 特有的;它们不是任何标准的一部分,你不会在其他地方看到它们。

这就是典型 Kivy 着色器文件的样板代码:

---vertex
$HEADER$

void main(void)
{
    // vertex shader
    gl_Position = ...
}

---fragment
$HEADER$

void main(void)
{
    // fragment shader
    gl_FragColor = ...
}

从此以后,我们将省略大部分样板代码以缩短列表——但请记住,它始终被假定存在;否则,事情可能不会按预期工作,或者根本不会工作。

$HEADER$宏是上下文相关的,根据着色器的类型意味着不同的事情。

在顶点着色器内部,$HEADER$是一个大致相当于以下代码的快捷方式:

varying vec4 frag_color;
varying vec2 tex_coord0;

attribute vec2 vPosition;
attribute vec2 vTexCoords0;

uniform mat4  modelview_mat;
uniform mat4  projection_mat;
uniform vec4  color;
uniform float opacity;

在片段着色器中,$HEADER$扩展为以下代码:

varying vec4 frag_color;
varying vec2 tex_coord0;

uniform sampler2D texture0;

(为了清晰起见,一些不太重要的部分已被删除。)

显然,这些可能在 Kivy 的未来版本中发生变化。

存储类和类型

在之前的代码中,变量不仅被注释为类型,还被注释为存储限定符。以下是两者的简要概述:

存储类
attribute
uniform
varying
常用数据类型
float
vec2, vec3, vec4
mat2, mat3, mat4
sampler2D

基本着色器

现在,不再有更多的预备知识,让我们编写我们的第一个也是最简单的着色器,它没有任何特殊的功能。

默认的顶点着色器读取:

void main(void)
{
    vec4 pos = vec4(vPosition.xy, 0.0, 1.0);
    gl_Position = projection_mat * modelview_mat * pos;
}

这将每个顶点的位置转换为 Kivy 首选的坐标系,原点位于左下角。

注意

我们不会尝试描述坐标变换的细微差别,因为这个主题对于一个入门级教程来说太复杂了。此外,甚至不需要完全理解这段代码,或者读完这本书。

如果你对这个主题有更全面的描述感兴趣,可以在www.learnopengles.com/understanding-opengls-matrices/找到关于 OpenGL 坐标空间和矩阵使用的简洁摘要。

最简单的片段着色器是一个返回常量颜色的函数:

void main(void)
{
    gl_FragColor = vec4(1.0, 0.0, 0.5, 1.0);
}

这为每个像素输出一个等于#FF007F的 RGBA 颜色。

如果你现在运行程序,你会看到类似于以下截图的输出:

基本着色器

基本着色器在实际应用中的效果:默认变换和平滑颜色

最后,我们得到了我们努力的结果。现在它可能并不特别有趣,但总比没有好。让我们摆弄一下,看看这会带我们走向何方。

程序化着色

除了总是返回相同值之外,另一种计算颜色的懒惰方法是,从相应着色器中立即可用的某物中推导它,例如,片段坐标。

假设我们想要按照以下方式计算每个像素的 RGB 颜色:

  • R 通道将与 x 坐标成比例

  • G 通道将与 y 坐标成比例

  • B 将是 RG 的平均值。

这个简单的算法可以很容易地在一个片段着色器中实现,如下所示:

void main(void)
{
    float r = gl_FragCoord.x / 255.0;
    float g = gl_FragCoord.y / 255.0;
    float b = 0.5 * (r + g);
    gl_FragColor = vec4(r, g, b, 1.0);
}

内置变量 gl_FragCoord 包含相对于应用程序窗口的片段坐标(不一定代表整个物理像素)。为了将颜色分量放入 [0...1] 范围内,需要进行 255.0 的除法——网格的大小,为了简单起见,内联显示。

这将替换之前看到的纯色,以以下渐变的形式:

过程着色

基于片段坐标计算颜色

彩色顶点

通过给顶点赋予它们自己的颜色,可以以数据驱动的方式产生类似的效果。为此,我们需要扩展顶点格式以包含另一个每顶点属性,vColor。在 Python 代码中,这相当于以下定义:

fmt = (
    (b'vPosition', 2, 'float'),
    (b'vColor', 3, 'float'),
)

vertices = (
    0,   0,   0.462, 0.839, 1,
    255, 0,   0.831, 0.984, 0.474,
    255, 255, 1,     0.541, 0.847,
    0,   255, 1,     0.988, 0.474,
)

indices = (0, 1, 2, 2, 3, 0)

使用更新的格式,顶点现在由五个浮点数组成,比之前多了两个。保持 vertices 列表与格式同步至关重要;否则,会发生奇怪的事情。

根据我们的声明,vColor 是一个 RGB 颜色,对于一个顶点着色器,我们最终需要 RGBA。我们不会为每个顶点传递一个常量 alpha 通道,而是在顶点着色器中填充它,类似于我们如何将 vPositionvec2 扩展到 vec4

这就是我们的修订版顶点着色器的外观:

attribute vec3 vColor;

void main(void)
{
    frag_color = vec4(vColor.rgb, 1.0);
    vec4 pos = vec4(vPosition.xy, 0.0, 1.0);
    gl_Position = projection_mat * modelview_mat * pos;
}

GLSL 语法如 vColor.rgbvPosition.xy 被称为 swizzling。它们可以用来高效地操作向量的部分,类似于 Python 切片的概念。

单独来说,vColor.rgb 只是意味着“取前三个向量分量”;在 Python 代码中,我们会写成 vColor[:3]。例如,也可以轻松地使用 vColor.bgr 反转颜色通道的顺序,或者只取一个通道使用 vColor.ggg(这将使生成的图片变为灰度图)。

可以以这种方式处理多达四个向量分量,使用 .xyzw.rgba 或更神秘的 .stpq 语法;它们都做完全相同的事情。

完成这些后,片段着色器变得非常简单:

void main(void)
{
    gl_FragColor = frag_color;
}

有趣的是,我们得到了顶点之间的颜色插值,这产生了平滑的渐变;这就是 OpenGL 的工作方式。下一张截图显示了程序的输出:

彩色顶点

将颜色作为顶点属性传递

纹理映射

为了总结这一系列简单的演示,让我们将纹理应用到我们的矩形上。再一次,我们需要扩展顶点格式的定义,这次是为每个顶点分配纹理坐标:

fmt = (
    (b'vPosition', 2, 'float'),
    (b'vTexCoords0', 2, 'float'),
)

vertices = (
    0,   0,   0, 1,
    255, 0,   1, 1,
    255, 255, 1, 0,
    0,   255, 0, 0,
)

纹理坐标通常在[0...1]范围内,原点在左上角——请注意,这与 Kivy 的默认坐标系不同。如果在某个时候,你看到纹理无缘无故地翻转过来,首先检查纹理坐标——它们很可能是罪魁祸首。

在 Python 方面,我们还需要注意的一件事是加载纹理并将其传递给渲染器。这是如何操作的:

from kivy.core.image import Image

with self.canvas:
    Mesh(fmt=fmt, mode='triangles',
         indices=indices, vertices=vertices,
         texture=Image('kivy.png').texture)

这将从当前目录加载一个名为kivy.png的文件,并将其转换为可用的纹理。为了演示,我们将使用以下图像:

纹理映射

用于演示的纹理

至于着色器,它们与之前的版本没有太大区别。顶点着色器只是简单地传递未受影响的纹理坐标:

void main(void)
{
    tex_coord0 = vTexCoords0;
    vec4 pos = vec4(vPosition.xy, 0.0, 1.0);
    gl_Position = projection_mat * modelview_mat * pos;
}

片段着色器使用插值的tex_coord0坐标在texture0纹理上执行查找,从而返回相应的颜色:

void main(void)
{
    gl_FragColor = texture2D(texture0, tex_coord0);
}

当代码组合在一起时,它会产生预期的结果:

纹理映射

简单的 GLSL 纹理映射

总结来说,这篇关于着色器的介绍应该已经给了你足够的勇气去尝试编写自己的小型基于着色器的程序。最重要的是,如果某些事情不太理解,不要感到害怕——GLSL 是一个复杂的话题,系统地学习它不是一件小事情。

然而,这确实有助于你更好地理解底层的工作原理。即使你每天不编写底层代码,你仍然可以使用这些知识来识别和避免性能瓶颈,并通常改善你应用程序的架构。

制作星域应用

借助我们对 GLSL 的新认识,让我们构建一个星域屏保,即星星在屏幕中心逃离到边缘的非交互式演示,在想象中的离心力或其他因素的影响下。

小贴士

由于动态视觉效果难以明确描述,截图在这方面也不是很有帮助,因此运行本章附带的代码以更好地了解正在发生的事情。

从概念上讲,每颗星星都会经历相同的动作序列:

  1. 它会在屏幕中心附近随机生成。

  2. 星星会向屏幕中心相反的方向移动,直到它不再可见。

  3. 然后,它会重新生成,回到起点。

我们还将使星星在接近屏幕边缘时加速并增大尺寸,以模拟假深度。

以下屏幕截图尝试(或者更具体地说,由于演示的高度动态性而失败)说明最终结果将看起来像什么:

制作星域应用

屏幕截图无法传达晕动症,但它确实存在

应用程序结构

新的应用程序类与我们本章早期所做的工作非常相似。类似于前面讨论的例子,我们并没有使用 Kivy 语言来描述(不存在的)小部件层次结构,因此没有 starfield.kv 文件。

该类包含两个方法,如下所示:

from kivy.base import EventLoop
from kivy.clock import Clock

class StarfieldApp(App):
    def build(self):
        EventLoop.ensure_window()
        return Starfield()

    def on_start(self):
        Clock.schedule_interval(self.root.update_glsl,
                                60 ** -1)

build() 方法创建并返回根小部件 Starfield;它将负责所有数学和渲染——基本上,应用程序中发生的所有事情。

on_start() 处理器告诉上述根小部件在应用程序启动后每秒更新 60 次通过调用其 update_glsl() 方法。

Starfield 类也被分为两部分:有常规的 __init__() 方法,负责创建数据结构,以及 update_glsl() 方法,它推进场景(计算每个恒星更新的位置)并在屏幕上渲染恒星。

数据结构和初始化器

现在我们来回顾一下初始化代码:

from kivy.core.image import Image
from kivy.graphics.instructions import RenderContext
from kivy.uix.widget import Widget

NSTARS = 1000

class Starfield(Widget):
    def __init__(self, **kwargs):
        Widget.__init__(self, **kwargs)
        self.canvas = RenderContext(use_parent_projection=True)
        self.canvas.shader.source = 'starfield.glsl'

        self.vfmt = (
            (b'vCenter',     2, 'float'),
            (b'vScale',      1, 'float'),
            (b'vPosition',   2, 'float'),
            (b'vTexCoords0', 2, 'float'),
        )

        self.vsize = sum(attr[1] for attr in self.vfmt)

        self.indices = []
        for i in range(0, 4 * NSTARS, 4):
            self.indices.extend((
                i, i + 1, i + 2, i + 2, i + 3, i))

        self.vertices = []
        for i in range(NSTARS):
            self.vertices.extend((
                0, 0, 1, -24, -24, 0, 1,
                0, 0, 1,  24, -24, 1, 1,
                0, 0, 1,  24,  24, 1, 0,
                0, 0, 1, -24,  24, 0, 0,
            ))

        self.texture = Image('star.png').texture

        self.stars = [Star(self, i) for i in range(NSTARS)]

NSTARS 是恒星的总数;尝试增加或减少它以改变星场的密度。关于性能,即使是配备慢速集成英特尔显卡的中等机器也能轻松支持数千颗恒星。任何半数以上的专业图形硬件都能轻松处理数万同时渲染的精灵。

与前面的例子不同,这次我们不会立即用最终的有用数据填充索引和顶点;相反,我们将准备占位符数组,稍后作为 update_glsl() 例程的一部分进行持续更新。

vfmt 顶点格式包括以下属性;其中一部分在本章中已经展示过:

顶点属性 其功能
vCenter 这表示恒星在屏幕上的中心点坐标
vScale 这是恒星的大小因子,1 表示原始大小(48 × 48 像素)
vPosition 这是每个顶点相对于恒星中心点的位置
vTexCoords0 这指的是纹理坐标

我们尚未提到的属性 vsize 是顶点数组中单个顶点的长度。它从顶点格式中计算出来,是其中间列的和。

vertices 列表包含了我们需要保留的几乎所有关于恒星的数据;然而,由于它是扁平的且没有隐式结构,操作起来非常不便。这就是辅助类 Star 发挥作用的地方。它封装了访问和更新顶点数组中选定条目的详细信息,这样我们就不必在代码中计算偏移量。

Star 类还跟踪一些不属于顶点格式的属性,即极坐标(从中心点的 angledistance)以及 size,这些属性会随时间增加。

这是 Star 类的初始化:

import math
from random import random

class Star:
    angle = 0
    distance = 0
    size = 0.1

    def __init__(self, sf, i):
        self.sf = sf
        self.base_idx = 4 * i * sf.vsize
        self.reset()

    def reset(self):
        self.angle = 2 * math.pi * random()
        self.distance = 90 * random() + 10
        self.size = 0.05 * random() + 0.05

在这里,base_idx 是这个星星在顶点数组中的第一个顶点的索引;我们还保留了对 Starfield 实例的引用,sf,以便以后能够访问 vertices

当调用 reset() 函数时,星星的属性将恢复到默认(略微随机化)的值。

推进场景

Starfield.update_glsl() 方法实现了星系运动算法,并且经常在应用程序类的 on_start() 处理程序中由 Kivy 的时钟调用。其源代码如下:

from kivy.graphics import Mesh

def update_glsl(self, nap):
    x0, y0 = self.center
    max_distance = 1.1 * max(x0, y0)

    for star in self.stars:
        star.distance *= 2 * nap + 1
        star.size += 0.25 * nap

        if (star.distance > max_distance):
            star.reset()
        else:
            star.update(x0, y0)

    self.canvas.clear()

    with self.canvas:
        Mesh(fmt=self.vfmt, mode='triangles',
             indices=self.indices, vertices=self.vertices,
             texture=self.texture)

首先,我们计算距离限制,max_distance,之后星星将在屏幕中心附近重生。然后,我们遍历星星列表,让它们运动并在途中略微放大。逃离终端距离的星星将被重置。

函数的最后部分看起来应该很熟悉。它与前面示例中看到的渲染代码相同。必须调用 canvas.clear(),否则每次调用都会添加一个新的网格,迅速将图形卡压到停机状态。

最后尚未公开的 Python 代码片段是 Star.update() 方法。它刷新属于一颗星星的四个顶点,将新的坐标写入 vertices 数组中的适当位置:

def iterate(self):
    return range(self.j,
                 self.j + 4 * self.sf.vsize,
                 self.sf.vsize)

def update(self, x0, y0):
    x = x0 + self.distance * math.cos(self.angle)
    y = y0 + self.distance * math.sin(self.angle)

    for i in self.iterate():
        self.sf.vertices[i:i + 3] = (x, y, self.size)

iterate() 辅助函数仅用于方便,本可以内联,但没有任何多余的可读性,所以让我们保持这种方式。

再次强调(有意为之),整个内存映射过程旨在消除在每一帧中序列化我们众多对象的需求;这有助于性能。

编写相应的 GLSL

在以下程序中使用的着色器也让人联想到我们之前看到的;它们只是稍微长一点。这是顶点着色器:

attribute vec2  vCenter;
attribute float vScale;

void main(void)
{
    tex_coord0 = vTexCoords0;
    mat4 move_mat = mat4
        (1.0, 0.0, 0.0, vCenter.x,
         0.0, 1.0, 0.0, vCenter.y,
         0.0, 0.0, 1.0, 0.0,
         0.0, 0.0, 0.0, 1.0);
    vec4 pos = vec4(vPosition.xy * vScale, 0.0, 1.0) * move_mat;
    gl_Position = projection_mat * modelview_mat * pos;
}

简而言之,我们正在将所有顶点的相对坐标乘以 vScale 因子,按比例调整网格大小,然后将它们平移到由 vCenter 属性给出的位置。move_mat 矩阵是平移矩阵,这是一种你可能或可能不记得的线性代数课程中的仿射变换方法。

为了补偿,片段着色器非常简单:

void main(void)
{
    gl_FragColor = texture2D(texture0, tex_coord0);
}

其最终目的是将这美好的事物呈现在屏幕上:

编写相应的 GLSL

星系纹理,放大查看

就这样。我们的星系现在已经完成,准备好用肉眼(或任何其他你能想到的用途)进行天文观测。

摘要

本章旨在(并希望成功)向您介绍一个充满顶点、索引和着色器的美丽硬件加速的低级 OpenGL 和 GLSL 开发世界。

直接编程 GPU 是一个疯狂强大的概念,而这种力量总是伴随着责任。着色器比常规 Python 代码更难以掌握;调试可能需要相当程度的猜测工作,而且没有方便的交互式环境,比如 Python 的 REPL,可以提及。尽管如此,编写原始 GLSL 是否对任何特定应用有用并没有明确的启发式方法——这应该根据具体情况来决定。

本章中的示例故意设计得简单,以便作为轻松的学习体验,而不是对认知能力的测试。这主要是因为 GLSL 编程是一个非常复杂、错综复杂的学习主题,有众多书籍和在线教程致力于掌握它,而这短短的一章绝对不是 OpenGL 所有内容的全面指南。

到目前为止,我们仅仅只是触及了可能性的表面。下一章将利用我们在这里编写的代码,做一些更有趣的事情:创建一个速度极快的射击游戏。

第九章。制作射击游戏

欢迎来到 Kivy 蓝图的最后一章。在本教程中,我们将构建一个射击游戏(或简称为shmup)——一个关于无限射击的快节奏动作游戏。

这是一个小预览,以激发你的兴趣:

制作射击游戏

游戏中:玩家(在左侧)试图摧毁无防御能力的飞碟(在右侧)

同时在屏幕上展示具有许多动态部分的应用程序,尤其是移动(或多平台)游戏,很大程度上依赖于一个强大的渲染器。这是我们将在本书末尾尝试开发的,部分基于第八章中讨论的星系屏幕保存在源代码,介绍着色器

本章还将涵盖以下主题:

  • 在 Kivy 中使用纹理图集,包括手动解包纹理坐标以用于底层代码

  • 基于 GLSL 的粒子系统的进一步开发以及使用粒子系统创建不同的游戏内实体

  • 实现二维射击游戏的机制——一个适合鼠标和触摸屏的控制方案,基本的子弹碰撞检测,等等

与本书中的所有其他代码一样,你可以在 GitHub 上找到所有源代码的最新版本,在github.com/mvasilkov/kb。在跟随教程时定期查阅应用程序的完整源代码,因为这有助于理解上下文,并使程序流程更加易于理解。

项目的局限性

我们正在编写的射击游戏在功能上将会非常简约。特别是,以下是一些限制条件:

  • 为了清晰起见,省略了整个激励部分(胜利和游戏结束条件)。一旦你对游戏玩法满意,它应该很容易实现。

  • 在我们的游戏版本中,只有一个敌人类型,并且有一个简单的移动模式。

  • 故意错过了许多优化机会,以使代码更加简洁和易于阅读。

这些留作读者的练习,如果你愿意做更多的工作。但首先,让我们回顾一下 Kivy 中纹理图是如何工作的——我们将在粒子系统代码中稍后依赖它们。

一瞥纹理图集

纹理图集(也称为精灵图集)是将应用程序中使用的图像组合成更大的复合纹理的方法。与我们在以前的项目中那样加载大量单个图像相比,这样做有几个优点:

  • 应用程序应该打开得更快;通常情况下,读取一个大型文件比读取几个较小的文件要便宜。这听起来可能微不足道,直到你拥有数百个纹理,那时它就会变得相当明显——尤其是在网络上:HTTP 请求增加了相当大的开销,这可能会在连接受限的移动设备上成为一项交易破坏者。

  • 当不需要重新绑定纹理时,渲染也会变得稍微快一些。有效地使用纹理图意味着只有纹理坐标会受到任何可能导致切换纹理的变化的影响。

  • 前一点也使得纹理图更适合在只有一个大型模型的情况下使用,例如我们的基于 GLSL 的渲染器。再次强调,纹理坐标成本低,重新绑定纹理并不昂贵。

注意

如果你来自 HTML 和 CSS 背景,那么你可能已经听说过在网页开发中使用的非常类似的方法,称为 CSS 图标。其基本原理相同,只是没有 GLSL 部分。由于网络应用主要是通过网络交付的,因此即使是少量图像,使用 CSS 图标也能带来显著的收益——每个消除的 HTTP 请求都很重要。

在本章的这一部分,我们将回顾以下主题:

  • 使用 Kivy 内置的 CLI 工具创建纹理图

  • .atlas 文件格式和结构

  • 在基于 Kivy 的应用程序中使用纹理图

如果你已经熟悉 Kivy 纹理图,可以直接跳到 使用 GLSL 的纹理图即席使用 部分。

创建纹理图

与网络开发不同,网络开发中尚未出现用于此特定任务的标准工具,Kivy 框架附带了一个有用的命令行工具用于纹理图。其调用方式如下:

python –m kivy.atlas <atlas_name> <texture_size> <images…>

在 Mac 上,将 python 替换为 kivy——这是 Kivy.app 提供的用于调用 Kivy 感知 Python 解释器的传统命令。

这将创建两个或更多文件,具体取决于所有图像是否都适合单个请求大小的复合纹理。在本教程中,我们假设 texture_size 的值足够大,可以包含每张最后的图像。

所有输出文件都将以你在命令行上提供的 atlas_name 参数命名:

  • 纹理图索引将被命名为 <atlas_name>.atlas

  • 纹理有一个额外的序号后缀——<atlas_name>-0.png(这个总是创建的),<atlas_name>-1.png,依此类推

纹理图结构

任何给定纹理图的索引,.atlas,基本上是一个 JavaScript 对象表示法JSON)文件,描述了单个纹理在图上的位置。其内容看起来如下(格式化以提高可读性):

{
    "game-0.png": {
        "player": [2, 170, 78, 84],
        "bullet": [82, 184, 24, 16]
    }
}

纹理将以其对应的源图像命名,不带扩展名:一个 foo.png 文件将变成 foo。每个记录中的数字描述了大型纹理中的一个区域:[x, y, width, height],其中所有值都是以像素为单位。

合成纹理大致相当于从图像拼接中预期的效果,如下一图所示。通常,它会被紧密地打包以有效地利用大部分面积。

注意

在创建图集时,Kivy 会小心处理单个精灵的边缘,考虑到与舍入相关的可能渲染伪影。这就是为什么可能会注意到精灵边缘有额外的像素。这种效果并不总是可见的,但当它出现时,请不要担心——这是设计的一部分,并且有很好的原因。

图集结构

纹理图集仅仅是较小图像的拼接

在常规基于 Kivy 的代码中使用图集,就像用特殊协议 atlas:(在概念上类似于 http:)替换文件路径一样简单。正如你已经知道的,Kivy 语言中的常规文件引用看起来类似于下面的代码片段:

Image:
    source: 'flags/Israel.png'

然而,图集引用将使用以下标记:

Image:
    source: 'atlas://flags/Israel'

继续阅读以获取创建和使用传统 Kivy 应用中图集的完整(尽管非常简单)示例,这有点像“hello world”。

以简单的方式使用 Kivy 图集

为了本例的目的,让我们从我们之前的两个项目中借用两个图标,并将它们命名为 icon_clock.pngicon_paint.png

以简单的方式使用 Kivy 图集

用于制作示例图集的单独图标

要创建图集,我们打开终端并输入以下命令:

kivy -m kivy.atlas icons 512 icon_clock.png icon_paint.png

记住,当不在 Mac 上时,将 kivy 命令替换为 python。然而,kivy.atlas 部分在所有系统上保持不变。

图集实用工具应该会返回类似以下内容:

[INFO] Kivy v1.8.0
[INFO] [Atlas] create an 512x512 rgba image
('Atlas created at', 'icons.atlas')
1 image have been created

在成功完成上述命令后,应在同一文件夹中出现几个新文件——icons.atlasicons-0.png

小贴士

在此阶段,可以安全地删除源图像。建议你仍然保留它们,以防将来需要重建图集,例如,在添加新图像或替换现有图像时。

图集已准备就绪。至于使用,我们可以在几行 Python 和 Kivy 语言中创建一个简单的演示应用。

Python 源文件 basic.py 包含一个基本的 Kivy 应用:

from kivy.app import App

class BasicApp(App):
    pass

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

这非常简单,仅用于(自动)加载在 basic.kv 文件中定义的布局。伴随的 Kivy 语言文件内容如下:

BoxLayout:
    orientation: 'horizontal'

    Image:
        source: 'atlas://icons/icon_clock'

    Image:
        source: 'atlas://icons/icon_paint'

这种简单的图集使用会产生以下布局,这基本上是你从源代码中看到的内容:

以简单的方式使用 Kivy 图集

Kivy 图集的基本用法

如你所见,除了前面描述的 atlas: 协议之外,这个例子中没有什么新或引人入胜的内容。所以,让我们继续解析纹理图集及其更高级的使用。

在 GLSL 中使用图集的临时用法

Kivy 内置对图集的支持对于简单情况来说效果很好,但对我们基于 GLSL 的自定义应用程序来说就不那么好了,该应用程序自行管理所有渲染、纹理等。

幸运的是,.atlas 文件格式是 JSON,这意味着我们可以轻松地使用 Python 标准库中的 json 模块来解析它。之后,我们应该能够将文件中给出的像素坐标转换为 UV 坐标,以便用于 OpenGL 程序。

由于我们知道每个纹理的绝对大小,我们也可以轻松地计算每个精灵相对于中心的顶点位置。这有助于轻松渲染精灵的“原始形式”,保持大小和宽高比不变。

UV 映射的数据结构

总的来说,每个精灵有很多值。为了保持可维护性,我们可以定义一个轻量级的记录类型(一个命名元组)来将这些值组合在一起:

from collections import namedtuple

UVMapping = namedtuple('UVMapping', 'u0 v0 u1 v1 su sv')

如果你不太熟悉 Python 中的命名元组,从用户的角度来看,这基本上等同于以下无逻辑类型,在概念上类似于 C 中的 struct 组合类型:

class UVMapping:
    def __init__(self, u0, v0, u1, v1, su, sv):
        self.u0 = u0  # top left corner
        self.v0 = v0  # ---
        self.u1 = u1  # bottom right corner
        self.v1 = v1  # ---
        self.su = su  # equals to 0.5 * width
        self.sv = sv  # equals to 0.5 * height

注意,这段代码纯粹是说明性的,前面的 namedtuple() 定义实际上并没有扩展到这一点——然而,用户界面与此相似。

每个字段的含义在以下表中给出:

字段 描述
u0, v0 精灵左上角 UV 坐标
u1, v1 精灵右下角 UV 坐标
su 精灵宽度除以 2;这个值在构建顶点数组时很有用
sv 精灵高度除以 2;这与前面的字段类似

命名字段提供了对记录内不同值的直接访问,这大大提高了可读性:tup.v1 比起 tup[3] 来说读起来要好得多。同时,UVMapping 实质上是一个元组,一个不可变且内存高效的具有所有字段的数据结构,如果需要,可以通过索引访问。

编写图集加载器

现在,让我们编写一个函数来实现到目前为止所讨论的所有内容:JSON 解析、坐标修正等等。这个函数也将用于最终的程序:

import json
from kivy.core.image import Image

def load_atlas(atlas_name):
    with open(atlas_name, 'rb') as f:
        atlas = json.loads(f.read().decode('utf-8'))

    tex_name, mapping = atlas.popitem()
    tex = Image(tex_name).texture
    tex_width, tex_height = tex.size

    uvmap = {}
    for name, val in mapping.items():
        x0, y0, w, h = val
        x1, y1 = x0 + w, y0 + h
        uvmap[name] = UVMapping(
            x0 / tex_width, 1 - y1 / tex_height,
            x1 / tex_width, 1 - y0 / tex_height,
            0.5 * w, 0.5 * h)

    return tex, uvmap

小贴士

请记住,我们只支持最简单的情况:只有一个复合纹理的图集。这可能是最有用的配置,所以这种限制对我们的代码几乎没有任何影响,尤其是考虑到图集生成完全在我们控制之下。

我们正在反转纵轴,因为坐标最初是在 Kivy 的坐标系中给出的,而使用以左上角为原点的 OpenGL 坐标系会更好。否则,精灵将会颠倒过来(顺便说一句,这对我们这个小游戏中的大多数精灵来说不是问题。这意味着这样的错误可能已经在代码库中存在很长时间了——未被发现且实际上无害)。

load_atlas('icons.atlas') 调用返回从 icons-0.png 加载的复合纹理以及图集中包含的每个纹理的描述:

>>> load_atlas('icons.atlas')

(<Texture size=(512, 512)...>,
 {'icon_paint': UVMapping(u0=0.2578125, v0=0.00390625,
                          u1=0.5078125, v1=0.25390625,
                          su=64.0, sv=64.0),
  'icon_clock': UVMapping(...)})

(数字在你这里可能显然会有所不同。)

这种数据格式足以从纹理中挑选出单个精灵并在屏幕上渲染——这正是我们接下来要做的。

从图集中渲染精灵

带着上述函数,让我们修改之前的演示,使用相同的纹理图和 GLSL。

源代码 tex_atlas.py 在概念上与第八章中的简单 GLSL 示例相似,着色器介绍。它使用 load_atlas() 函数来填充顶点数组:

from kivy.graphics import Mesh
from kivy.graphics.instructions import RenderContext
from kivy.uix.widget import Widget

class GlslDemo(Widget):
    def __init__(self, **kwargs):
        Widget.__init__(self, **kwargs)
        self.canvas = RenderContext(use_parent_projection=True)
        self.canvas.shader.source = 'tex_atlas.glsl'

        fmt = (
            (b'vCenter',     2, 'float'),
            (b'vPosition',   2, 'float'),
            (b'vTexCoords0', 2, 'float'),
        )

        texture, uvmap = load_atlas('icons.atlas')

        a = uvmap['icon_clock']
        vertices = (
            128, 128, -a.su, -a.sv, a.u0, a.v1,
            128, 128,  a.su, -a.sv, a.u1, a.v1,
            128, 128,  a.su,  a.sv, a.u1, a.v0,
            128, 128, -a.su,  a.sv, a.u0, a.v0,
        )
        indices = (0, 1, 2, 2, 3, 0)

        b = uvmap['icon_paint']
        vertices += (
            256, 256, -b.su, -b.sv, b.u0, b.v1,
            256, 256,  b.su, -b.sv, b.u1, b.v1,
            256, 256,  b.su,  b.sv, b.u1, b.v0,
            256, 256, -b.su,  b.sv, b.u0, b.v0,
        )
        indices += (4, 5, 6, 6, 7, 4)

        with self.canvas:
            Mesh(fmt=fmt, mode='triangles',
                 vertices=vertices, indices=indices,
                 texture=texture)

代码本质上只是将 load_atlas() 函数的输出数据复制到 vertices 数组中,除了常规的 GLSL 初始化序列。我们选择了两个不同的记录:icon_clock(为了简洁存储为 a 变量)和 icon_paint(命名为 b,与 a 类似),然后将它们推入顶点数组中。

在这个例子中,我们的顶点格式非常简单:

  • vCenter:这是精灵在屏幕上的位置。对于给定精灵的所有顶点,这个值应该是相同的

  • vPosition:这是相对于精灵中心的顶点位置,不受之前值的影响

  • vTexCoords0:这是每个顶点的纹理坐标(UV)。它决定了将渲染大纹理的哪个部分。

从这些中,只有精灵位置(列表中的前两列原始数字)不能从 UV 映射中推导出来;其他所有内容都来自 load_atlas() 调用。

这里是相应的着色器代码,tex_atlas.glsl

---vertex
$HEADER$

attribute vec2 vCenter;

void main(void)
{
    tex_coord0 = vTexCoords0;
    mat4 move_mat = mat4
        (1.0, 0.0, 0.0, vCenter.x,
         0.0, 1.0, 0.0, vCenter.y,
         0.0, 0.0, 1.0, 0.0,
         0.0, 0.0, 0.0, 1.0);
    vec4 pos = vec4(vPosition.xy, 0.0, 1.0) * move_mat;
    gl_Position = projection_mat * modelview_mat * pos;
}

---fragment
$HEADER$

void main(void)
{
    gl_FragColor = texture2D(texture0, tex_coord0);
}

这只有最小的功能——定位和纹理——内置。最终游戏中将使用类似的着色器,并添加相对尺寸属性 vScale

小贴士

如果你不懂着色器代码中的内容,请回到第八章,着色器介绍:它包含了一些你可能认为与当前讨论相关的示例。

最终的结果,尽管可能看起来不太有趣,在这里展示:

从图集中渲染精灵

使用 GLSL 从图集中渲染精灵

通过这种方式,我们可以继续开发一个通用的基于精灵的粒子系统,它将反过来成为所有游戏对象的基础。

设计一个可重用的粒子系统

在本节中,我们将编写一个粒子系统,稍后它将被用来创建游戏中的几乎所有东西——宇宙飞船、子弹等等。这是在屏幕上有许多类似对象且运动和交互逻辑非常简单的情况下使用的通用方法。

这个主题将利用前一章的代码。实际上,前一章的整体星系屏幕保护程序是一个很好的粒子系统示例;然而,它缺乏必要的可配置性,并且不能轻易地重新使用。我们将在不显著更改 GLSL 和相关代码的情况下改变这一点。

注意

值得注意的是,所选的方法——为每个粒子渲染纹理四边形——在低级渲染方面并不是最优的。优点在于,它非常直接,易于推理,并且与任何支持 GLSL 的 OpenGL 实现兼容。

如果你选择更系统地学习 OpenGL,你可能想用点精灵或类似的概念来替换四边形;这些增强功能超出了本书的范围。

类层次结构

我们的粒子系统 API 将包括两个类:PSWidget,负责所有渲染,以及一个轻量级的Particle类来表示单个粒子。

这些类将按设计紧密耦合,这在经典面向对象编程中通常是不受欢迎的,但在此情况下可以显著提高性能:粒子将直接访问渲染器中的顶点数组以修改网格——考虑到许多粒子同时处于活动状态,减少复制可以带来巨大的优势。

粒子系统小部件的实现与其他我们迄今为止看到的基于 GLSL 的小部件没有实质性区别,除了现在它旨在被继承以供实际使用。PSWidgetParticle都是抽象基类,也就是说,不能通过调用例如PSWidget()等方式直接实例化。

有不同的方法来强制这种限制。我们可以使用 Python 标准库中的abc模块来创建真正的抽象基类(abc实际上就是指这个)。虽然这可能被认为对 Java 程序员等有用,但这并不是 Python 开发者通常采取的方法。

为了简单起见,我们将编写适当的占位符(存根),对于所有需要重写的方法抛出NotImplementedError异常。这将使基类在未使用元类和复杂继承的情况下在技术上不可用,正如abc模块所建议的。

PSWidget 渲染器类

不再赘述,让我们看看PSWidget的创建:

class PSWidget(Widget):
    indices = []
    vertices = []
    particles = []

    def __init__(self, **kwargs):
        Widget.__init__(self, **kwargs)
        self.canvas = RenderContext(use_parent_projection=True)
        self.canvas.shader.source = self.glsl

        self.vfmt = (
            (b'vCenter', 2, 'float'),
            (b'vScale', 1, 'float'),
            (b'vPosition', 2, 'float'),
            (b'vTexCoords0', 2, 'float'),
        )

        self.vsize = sum(attr[1] for attr in self.vfmt)

        self.texture, self.uvmap = load_atlas(self.atlas)

这实际上是我们已经在所有 GLSL 示例中看到的基本相同的初始化过程,只是有些字段留空(它们是从子类借用的,子类必须设置这些)。self.glsl属性将保存着色器的文件名,而self.atlas是纹理图的文件名,它将作为此渲染器实例的唯一纹理来源。

注意我们在这里没有填充顶点数组:这项工作留给子类来完成。然而,我们应该提供一个简单的方法,让子类能够与我们的内部数据结构一起工作。因此,以下PSWidget方法被用来轻松添加大量类似的粒子:

def make_particles(self, Cls, num):
    count = len(self.particles)
    uv = self.uvmap[Cls.tex_name]

    for i in range(count, count + num):
        j = 4 * i
        self.indices.extend((
            j, j + 1, j + 2, j + 2, j + 3, j))

        self.vertices.extend((
            0, 0, 1, -uv.su, -uv.sv, uv.u0, uv.v1,
            0, 0, 1,  uv.su, -uv.sv, uv.u1, uv.v1,
            0, 0, 1,  uv.su,  uv.sv, uv.u1, uv.v0,
            0, 0, 1, -uv.su,  uv.sv, uv.u0, uv.v0,
        ))

        p = Cls(self, i)
        self.particles.append(p)

这将实例化请求的Cls类粒子数量(num),将它们添加到部件的self.particles列表中,并同时在self.vertices中填充。每种粒子类型都应该公开一个tex_name属性,用于在 UV 映射中查找正确的精灵,该映射是从早期从图集(PSWidget.uvmap)派生出来的。

严格来说,这个辅助函数是可选的,但非常有用。对这个方法的调用将在渲染之前包含在部件的具体类的初始化过程中。

部件基类的最后一部分是渲染:

def update_glsl(self, nap):
    for p in self.particles:
        p.advance(nap)
        p.update()

    self.canvas.clear()

    with self.canvas:
        Mesh(fmt=self.vfmt, mode='triangles',
             indices=self.indices, vertices=self.vertices,
             texture=self.texture)

canvas.clear()调用开始,这几乎与所有基于 GLSL 的示例中使用的代码相同。函数的开始部分更有趣。我们遍历所有粒子,并对每个粒子调用两个方法:advance()方法计算粒子的新状态(由粒子自己决定这样做,不一定类似于任何可见的变化),而update()方法则将必要的数据与顶点数组中的内部状态保持同步,如果有的话。

提示

这种关注点的分离,虽然对性能不是最佳,但有助于提高可读性。当(如果)优化时,首先考虑以下要点:

  • 这个循环可以并且应该被并行化,全部或部分并行化。

  • 这段代码也可以在另一个线程中运行,并且不是每帧都更新(同样,这种优化可能适用于选定的粒子类,例如不影响主程序流程的背景内容)。

这些方法,以及粒子的其他实现细节,将在接下来的章节中描述。

粒子类

在下面的代码中,我们回顾了另一个基类,Particle,它代表单个精灵。它类似于星系项目中的Star类,但没有运动逻辑(这将在后续的子类中实现):

class Particle:
    x = 0
    y = 0
    size = 1

    def __init__(self, parent, i):
        self.parent = parent
        self.vsize = parent.vsize
        self.base_i = 4 * i * self.vsize
        self.reset(created=True)

    def update(self):
        for i in range(self.base_i,
                       self.base_i + 4 * self.vsize,
                       self.vsize):
            self.parent.vertices[i:i + 3] = (
                self.x, self.y, self.size)

    def reset(self, created=False):
        raise NotImplementedError()

    def advance(self, nap):
        raise NotImplementedError()

在构造函数中存储对*parent* PSWidget的引用,允许我们在以后与之交互。这主要发生在update()方法中,如前一个列表所示——它改变多边形的四个顶点,以保持它们与粒子期望的位置和缩放(xysize属性)同步。

这个新类中至少有一个方法在Star类中不存在,即advance()方法。由于没有默认的推进场景行为,这个方法必须被重写。粒子决定如何随时间变化。正如你很快就会看到的,粒子系统可以用来创建显著不同的效果。

调用reset()方法重新初始化结束其生命周期(例如,已离开屏幕或耗尽其 TTL)的粒子。这非常特定于粒子系统,但通常任何这样的系统都有一种将粒子恢复到空白或随机初始状态的概念。再次强调,这里没有我们可以调用的任何巧妙默认行为,所以这里只有一个占位符。

注意

从虚拟方法中引发NotImplementedError是一种通知开发者最好在派生类中定义此方法的方式。我们本可以完全省略最后两个方法,但这会导致一个不那么相关的错误,AttributeError。即使在没有默认实现的情况下,保留方法签名也是很好的,并且可以减少你的同伴(或者你未来的自己,如果你在长时间之后重新访问相同的代码)的猜测工作。

新关键字参数created背后的想法,它传递给reset(),很简单。一些粒子系统在粒子首次生成时可能需要额外的(或仅仅是不同的)初始化程序。这种行为的良好例子是新的星系(我们很快就会介绍),星星在屏幕的右侧生成。如果我们不考虑刚刚创建的状态,那么所有的星星确实会出现在屏幕的最右侧,具有相同的x坐标,从而形成一条直线。这种图形错误显然是不希望的,因此如果将created设置为True,我们将完全随机化星星的位置,从而得到良好的初始分布。

调用reset()将明确表示粒子的后续重生比首次初始化更为频繁,因此created标志默认为False

到目前为止,我们已经完成了基本类的设计。正如你很快就会看到的,一旦将技术细节抽象出来,游戏本身的实现就变得非常直接。在接下来的章节中,我们将以这里概述的粒子系统基础知识为基础,以创新的方式构建各种东西,从背景到交互式游戏对象,如子弹。

编写游戏

我们的应用程序结构围绕前面描述的构建块:我们的根小部件是一个名为GamePSWidget子类,所有游戏中的实体都将从Particle类派生。

下面是简洁的基础应用程序:

from kivy.base import EventLoop
from kivy.clock import Clock

class Game(PSWidget):
    glsl = 'game.glsl'
    atlas = 'game.atlas'

    def initialize(self):
        pass

class GameApp(App):
    def build(self):
        EventLoop.ensure_window()
        return Game()

    def on_start(self):
        self.root.initialize()
        Clock.schedule_interval(
            self.root.update_glsl, 60 ** -1)

下面是这里引用的外部文件的详细信息:

  • game.glsl着色器文件与上一章中的starfield.glsl相同

  • game.atlas纹理图包含以下纹理:

    • star:这种纹理类似于我们之前项目中使用的星星

    • player:这是一个面向右侧(即运动的一般方向)的宇宙飞船

    • trail:这是从飞船火箭发动机发射出的火球(单个火焰粒子)

    • bullet:这是由飞船想象中的前方加农炮发射出的投射物

    • ufo:这是一个面向左侧(并且逆流而行)的外星飞碟

之前的代码在屏幕上还没有渲染任何内容,因为我们还没有填充顶点数组。让我们从背景开始,首先实现星星。

实现星星

我们再次构建一个简单的星系。这次它从右向左滚动以模仿运动,在概念上类似于我们之前构建的 Kivy Bird 游戏。

为了创建一个简单的视差效果(就像在 Kivy Bird 中一样),我们将星星分配到三个平面上,然后设置它们的大小和速度以匹配。位于更高编号平面的星星比位于较低平面的星星更大,移动速度也更快。当一颗星星离开屏幕时,它会在随机位置和随机平面上重生。

让我们回顾一下实现新改进星域的粒子系统代码:

from random import randint, random

class Star(Particle):
    plane = 1
    tex_name = 'star'

    def reset(self, created=False):
        self.plane = randint(1, 3)

        if created:
            self.x = random() * self.parent.width
        else:
            self.x = self.parent.width

        self.y = random() * self.parent.height
        self.size = 0.1 * self.plane

    def advance(self, nap):
        self.x -= 20 * self.plane * nap
        if self.x < 0:
            self.reset()

tex_name属性是必需的,它指的是game.atlas内部的纹理。

重置会随机化一颗星球的平面和位置,这取决于该方法是否在初始化(created=True)期间被调用;这一做法的合理性已在之前描述。

最后一个方法advance()很简单:将精灵向左移动,直到它离开屏幕,然后重置它。

为了使用我们新的粒子系统,我们需要使用来自PSWidgetmake_particles()辅助函数添加一些星星。这发生在Game.initialize()中:

def initialize(self):
    self.make_particles(Star, 200)

最后,我们劳动的一些可见成果:

实现星星

再次审视星域

制作宇宙飞船

我们始终需要一艘宇宙飞船(因为我们正在构建单人游戏),这意味着实现将是一个退化情况,即粒子系统只有一个粒子。这样做是为了与代码的其他部分统一。我们无法通过以不同的方式设计这个特定的对象来获得任何好处。

玩家的宇宙飞船将始终固定在指针位置。为了实现这种效果,我们存储指针的位置在一个名为Game属性的player_xplayer_y对中,然后在更新场景时将宇宙飞船精灵放置在这些坐标上。为了保存指针位置,我们可以使用以下代码:

from kivy.core.window import Window

class Game(PSWidget):
    def update_glsl(self, nap):
        self.player_x, self.player_y = Window.mouse_pos

        PSWidget.update_glsl(self, nap)

由于宇宙飞船完全在玩家的控制之下,我们无法在粒子类中实现其他逻辑——除了将精灵移动到最后的指针位置:

class Player(Particle):
    tex_name = 'player'

    def reset(self, created=False):
        self.x = self.parent.player_x
        self.y = self.parent.player_y

    advance = reset

如您所见,reset()advance()方法是一样的。我们几乎没有什么其他可以做的事情。

不要忘记实际产生这种类型的粒子:

def initialize(self):
    self.make_particles(Star, 200)
    self.make_particles(Player, 1)

到目前为止,最终结果类似于无尽空虚中孤独的宇宙飞船:

制作宇宙飞船

宇宙飞船跟随鼠标,使用户直接控制

创建火焰尾迹

在科幻设定中,每一艘值得尊敬的宇宙飞船后面都会有一道火焰尾迹。构成这种尾迹的粒子使用以下算法:

  1. 在发动机附近产生一个粒子,其大小是随机的。粒子的尺寸也是其存活时间TTL)。

  2. 然后,它以恒定的速度从宇宙飞船上远离,在移动的过程中尺寸缩小。

  3. 当粒子缩小到正常大小的约 10%时,它们会被重置并重新开始它们的旅程。

给定许多粒子,这种效果在运动中可能看起来很漂亮。遗憾的是,截图无法传达动态效果,所以请确保运行示例代码以获得更好的印象。

简而言之,粒子系统的实现如下:

class Trail(Particle):
    tex_name = 'trail'

    def reset(self, created=False):
        self.x = self.parent.player_x + randint(-30, -20)
        self.y = self.parent.player_y + randint(-10, 10)

        if created:
            self.size = 0
        else:
            self.size = random() + 0.6

    def advance(self, nap):
        self.size -= nap
        if self.size <= 0.1:
            self.reset()
        else:
            self.x -= 120 * nap

这直接实现了之前提到的算法,同时依赖于相同的player_xplayer_y属性来确定飞船的当前位置。

如前所述,我们应该为效果分配许多粒子,以便看起来更好:

def initialize(self):
    self.make_particles(Star, 200)
    self.make_particles(Trail, 200)
    self.make_particles(Player, 1)

这是结果的截图:

创建火焰轨迹

真空中燃烧的火焰:看起来很整洁,但并不支持现实主义

最后两个尚未实现的粒子系统,即敌人和子弹,是不同的。与之前我们看到的其他粒子类别不同,其中所有实例在任何给定时间都会同时显示,子弹和敌人不会一次性全部生成;两者都等待一个特殊事件发生,然后通过发射一颗子弹或生成一个单个敌人来增加对象的数量。

尽管如此,我们希望在事先分配一定数量的此类粒子,因为无谓地增长和缩小顶点数组会使代码变得复杂,并且不可取。

关键是要给粒子添加一个新的布尔字段,以表示它是否处于活动状态(显示在屏幕上)或不是,然后根据需要激活这样的粒子。这种方法将很快演示。

制作子弹

我们希望在我们持续按住鼠标按钮或触摸屏幕时,我们的船的炮塔能够开火。使用新添加的firing属性来表示触发器被拉动,这种设置很容易实现:

class Game(PSWidget):
    firing = False
    fire_delay = 0

    def on_touch_down(self, touch):
        self.firing = True
        self.fire_delay = 0

    def on_touch_up(self, touch):
        self.firing = False

为了在射击之间添加短暂的延迟,我们引入了另一个属性,fire_delay。这个变量每帧都会减少,直到达到零,然后生成一颗新的子弹,并将fire_delay增加。当firingTrue时,这个循环会继续:

def update_glsl(self, nap):
    self.player_x, self.player_y = Window.mouse_pos

    if self.firing:
 self.fire_delay -= nap

    PSWidget.update_glsl(self, nap)

现在,让我们看看之前提到的粒子的活动状态。最初,所有子弹都是非活动的(即active=False),并且从屏幕上移除(子弹的坐标设置为x=-100y=-100,这实际上阻止了它们渲染)。代码如下:

class Bullet(Particle):
    active = False
    tex_name = 'bullet'

    def reset(self, created=False):
        self.active = False
        self.x = -100
        self.y = -100

当遍历子弹时,我们会跳过那些非活动的子弹,除非fire_delay达到零。在这种情况下,我们会激活一颗子弹,并将其放在玩家前面,将fire_delay变量重置以重置倒计时。

活跃的子弹移动方式就像星星一样,尽管方向相反。与星星不同,离屏的子弹不会自动重生。它们会回到非活动池中,从视野中消失。代码如下:

def advance(self, nap):
    if self.active:
        self.x += 250 * nap
        if self.x > self.parent.width:
            self.reset()

    elif (self.parent.firing and
          self.parent.fire_delay <= 0):
        self.active = True
        self.x = self.parent.player_x + 40
        self.y = self.parent.player_y
        self.parent.fire_delay += 0.3333

fire_delay属性设置为三分之一秒,这显然会导致平均自动射击速率为每秒三RPS)。

制作子弹

火力全开的宇宙飞船

实现敌人

敌人在概念上与子弹相似,但由于它们会持续生成,我们不需要像firing这样的标志——一个spawn_delay属性就足够了。顺便说一句,这是update_glsl()方法的最终演变:

class Game(PSWidget):
    spawn_delay = 1

    def update_glsl(self, nap):
        self.player_x, self.player_y = Window.mouse_pos

        if self.firing:
            self.fire_delay -= nap

        self.spawn_delay -= nap

        PSWidget.update_glsl(self, nap)

在初始化过程中,我们创建了一个预定义数量的敌人,最初是处于非活动状态的。为了稍后实现与子弹的碰撞检测,我们还单独存储了一个子弹列表(Game.particles列表的切片):

def initialize(self):
    self.make_particles(Star, 200)
    self.make_particles(Trail, 200)
    self.make_particles(Player, 1)
    self.make_particles(Enemy, 25)
    self.make_particles(Bullet, 25)

    self.bullets = self.particles[-25:]

相应的粒子类将是迄今为止最复杂的,主要是因为更复杂的移动模式。除了沿x轴的恒定移动速度外,每个敌人还有一个随机的垂直移动向量v。当一个此类粒子即将从顶部或底部边缘离开屏幕时,粒子的v属性会相应地改变符号,将敌人反射回可见屏幕空间。

其他规则与子弹的工作方式相似:当敌人到达屏幕的对面边缘时,它会重置并实际上消失,直到再次生成。代码如下:

class Enemy(Particle):
    active = False
    tex_name = 'ufo'
    v = 0

    def reset(self, created=False):
        self.active = False
        self.x = -100
        self.y = -100
        self.v = 0

    def advance(self, nap):
        if self.active:
            if self.check_hit():  # Step 1
                self.reset()
                return

            self.x -= 200 * nap  # Step 2
            if self.x < -50:
                self.reset()
                return

            self.y += self.v * nap  # Step 3
            if self.y <= 0:
                self.v = abs(self.v)
            elif self.y >= self.parent.height:
                self.v = -abs(self.v)

        elif self.parent.spawn_delay <= 0:  # Step 4
            self.active = True
            self.x = self.parent.width + 50
            self.y = self.parent.height * random()
            self.v = randint(-100, 100)
            self.parent.spawn_delay += 1

这个列表可能看起来相对较长,但实际上算法非常简单易懂:

  1. 检查我们是否被子弹击中并重置(见下一节)。

  2. 水平移动,检查我们是否离开了视图,并重置。

  3. 垂直移动,检查我们是否要离开视图,并改变向量符号。

  4. 如果spawn_delay结束,则生成另一个敌人并增加spawn_delay

碰撞检测

Enemy类另一个我们之前没有见过的有趣方面是check_hit()方法。敌人可以与两种东西发生碰撞:玩家的宇宙飞船和子弹。为了简单起见,让我们说主角是无敌的,碰撞只会摧毁敌人;然而,一个碰撞的子弹也会消失。代码如下:

def check_hit(self):
    if math.hypot(self.parent.player_x - self.x,
                  self.parent.player_y - self.y) < 60:
        return True

    for b in self.parent.bullets:
        if not b.active:
            continue

        if math.hypot(b.x - self.x, b.y - self.y) < 30:
            b.reset()
            return True

这个碰撞检测例程尽可能简单:math.hypot()仅计算中心点之间的距离,因此我们假设所有对象在这个检查中都是近似圆形的。我们也尝试不与非活动子弹发生碰撞,这是显而易见的——逻辑上非活动实体不存在,而且它们在物理上位于可见屏幕空间之外。所以,它们可能不会与屏幕上的对象发生碰撞。

这标志着游戏的第一个(勉强)可玩版本。

碰撞检测

发现敌人

完善细节

这个游戏可以在很多方面进行改进,尤其是在游戏玩法方面。不言而喻,这个原型目前不具备市场价值。

如果你对进一步开发这个项目感兴趣,以下是一些家庭作业的建议:

  • 首先,这个游戏迫切需要一个“游戏结束”状态。胜利是可选的,但失败是必须的;否则,它根本不具备竞争性,就像一个互动式屏保。

  • 另一个明显的改进是添加内容——多样性是王道。更多的敌人,更多的攻击模式,以及可能让选定的敌人能够向玩家射击的能力。难度级别的逐步提高也属于同一范畴:游戏的后期阶段应该带来更大的敌人浪潮,更快的生成时间等等。

  • 添加音效可能是最简单的增强。有关详细信息,请参阅第七章,编写 Flappy Bird 克隆。相同的MultiAudio类也可以在这个项目中轻松重用。或者,您可以查看本章附带的示例代码。

摘要

本章的主要观点是粒子系统可以用于非常不同的用途。也许这并不是你今天听到的最令人惊叹的聪明想法,所以我们还是来总结一下整本书的主要观点,不管是一些小的实现细节。

在这篇冗长的文章中,我们仅仅触及了使用 Python 和 Kivy 可以轻松完成的表面功夫。可能性的领域是广阔且多样化的:

  • 桌面和移动端的实用应用程序

  • 内容创建的应用,无论是图形或文本编辑器,甚至可能是声音合成器

  • 用于聊天的网络应用程序,其他社交网络方式,以及远程控制程序

  • 视频游戏

在这本书的过程中,我们也强调了与任何新技术高效合作的一些有用原则:

  • 将您在其他问题领域(如 Web 开发)的经验应用到 Kivy 中。Kivy 是不同的,但也不是那么不同;许多方法实际上可以在很大程度上不同的环境中重用。

  • 尝试理解幕后发生的事情。理解框架的内部工作原理将有助于调试。

  • 与前一点相关,如果文档缺失或不清楚,请阅读源代码。毕竟,它是 Python,所以自然是非常易读的。

  • 不要犹豫,在网络上搜索解决问题的方案和对感兴趣的主题进行搜索。很多时候,别人已经遇到过这个问题并找到了解决方案。

总的来说,我真诚地希望您享受了这次旅程。如果您有任何问题或想要讨论某些事情,请随时通过以下邮箱联系我 <mvasilkov@gmail.com>(并期待极其缓慢的回复!)。

附录 A. Python 生态系统

本书并不试图回答您可能对 Kivy 提出的所有问题,或举例说明编写 Kivy 应用程序的所有可能方法;它应该作为编写 Python 中具有用户界面的各种程序的实际、动手介绍。

Kivy 的主要成就是弥合了 Python 工具链与 Android 和 iOS 移动应用开发之间的差距。与 Qt(PyQt 和 PySide)的绑定不同,Kivy 本质上是 Pythonic 的(除了像Window对象这样的小实现细节)。这两个方面本身就使 Kivy 成为编写下一个面向用户的应用程序时值得考虑的可行选项。

尽管如此,我强烈建议您探索庞大的 Python 生态系统。恰好有很多 Python 模块设法保持低调。实际上,Kivy 就是其中之一,被例如相对更受欢迎的 Qt 框架所掩盖(它们显然不属于同一个领域,但一个常见的误解是它们大部分可以互换,原因不明)。

以下注释列表包含了一些有趣的 Python 模块,既有广为人知的,也有相对不为人知的。这可以作为编写新应用程序的灵感,或者仅仅作为展示 Python 生物群落中发现的丰富性的示例。显然,这个列表远远不够完整,甚至不够详尽:在您的 Python 职业生涯中,您肯定会发现许多其他独特的库和工具。

所以,这就是它了,一个高度主观的、按无特定顺序排列的精选、令人惊叹的 Python 包列表:

  • Requests:这是一个广为人知、极其有用的 HTTP 包,具有可读性、一致的接口。它极大地简化了编写各种 HTTP 客户端。

  • Pyjnius:在第三章“Android 录音机”中进行了更详细的讨论,这个模块提供了一个无缝且相对轻量级的 Java 互操作性层。这个工具让你能够在 Python 环境中重用任何 Java 库,无论是在 Android 还是桌面。不用说,Java 库(用途可疑)数量众多,因此 Pyjnius 可能在许多场合都很有用。

  • mitmproxy(中间人代理):这是一个用于捕获和编辑 HTTP 流量的工具包。mitmproxy 的可能用途包括调试和测试网络应用程序、安全审计以及与其他不知情程序玩耍。它还可以被用作网络屏幕来过滤流量;这种特定用途在当今政府中很受欢迎。可能会让你得到 KGB 的工作,也可能不会。

  • music21:这是一个在麻省理工学院开发的计算机辅助音乐学工具包,它提供了一种处理符号音乐数据的方法。它允许你读取、编写和操作乐谱,对古典音乐代表性语料库进行复杂的音乐和统计分析,进行算法作曲等等。

  • Pydub:这是一个具有一致、Pythonic 接口的音频处理库。它允许你执行许多音频编辑任务,如切片、连接、交叉淡入淡出等。Pydub 使用 ffmpeg 进行转码,这意味着它支持大多数流行的文件格式。

  • Django:这是一个无疑非常流行的用于构建动态、数据库支持的网站的 Web 框架。然而,许多人可能没有意识到,Django 还可以用于许多其他几乎无关的任务,包括以下内容:

    • 使用 Django 模型和对象关系映射(ORM)制作强大的数据库操作命令行工具

    • 使用优秀的 Django 模板引擎来处理几乎所有需要模板引擎的事情

    • 构建静态网站

  • RenPy(风格化为 Ren'Py):这是一个视觉小说引擎,用于专业游戏开发。视觉小说,一度在日本以外几乎无人知晓,现在越来越受欢迎,一些由 RenPy 驱动的游戏标题已经在 AppStore、Google Play、Steam 以及其他软件发行渠道上可供消费。

故意省略了 NumPy、SciPy、IPython 等,因为它们无处不在,几乎没有必要重新介绍。

注意

如果你对此主题有点兴趣并想了解更多,有几个网站可能很有用:

提供解决现实世界问题的实用解决方案的众多软件包是当今 Python 编程的一个显著特征。代码重用很好,因此,在任何情况下,你都不应犹豫从丰富的 Python 生态系统中借用,并在可能的情况下回馈。

作为前文讨论的直接后果,Python 通过许多被普遍认为非平凡的事物扩展了你的工具箱。这包括科学计算的一定子集、安全且可扩展的 Web 服务器、网络服务、快速硬件加速的图形等功能。

这是有理由选择 Python 作为你下一个项目的,这也提供了一些洞察,为什么许多功能强大的项目——例如 Kivy——是用 Python(以及相关语言,例如 RPython)编写的。

这个故事的意义在于:了解你的生态系统,它将通过承担大量繁重的工作而带来巨大的回报,使你更加高效,头发柔顺光滑。因为拥有适合工作的正确工具是不可或缺的

posted @ 2025-09-18 14:36  绝不原创的飞龙  阅读(72)  评论(0)    收藏  举报