Kivy 蓝图(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:制作远程桌面应用程序

为了总结上一章开始的网络主题,让我们构建另一个客户端-服务器应用程序——一个远程桌面应用程序。这次我们的应用程序将解决一个更复杂的实际任务,并使用“真实”的应用层协议进行通信。

让我们暂时讨论一下手头的任务。首先,目的是:一个典型的远程桌面程序允许用户通过局域网或互联网远程访问其他计算机。这种应用程序通常用于临时技术支持或远程协助,例如,大公司中的 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 服务器正在工作。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_05_01.jpg

由 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 桌面。如果代码正确无误,它应该会产生以下截图所示的图片:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_05_02.jpg

通过 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>

尝试调整浏览器窗口大小,注意图像如何缩小以适应。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_05_03.jpg

在浏览器中查看的远程桌面,已缩放到浏览器窗口大小

你也可能注意到,在重新加载时图像闪烁。这是因为我们在图片完全加载之前立即向用户显示 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)

在此代码中,我们首先计算“实际”的点击坐标:图片可能被缩小以适应浏览器宽度,所以我们计算比例并将点击位置乘以该比例:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_05_04.jpg

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 服务器,或者它可能被配置成不符合用户对主机名的期望。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_05_05.jpg

简单且明确的登录表单

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

现在,是时候从登录屏幕切换到远程桌面屏幕了。以下截图总结了到目前为止我们所做的工作:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_05_06.jpg

一个只读(仍然无法将点击传递回主机)的远程桌面应用

发送点击

远程桌面查看器已经准备好,具有滚动和帧之间的即时转换功能(完全没有闪烁)。最后剩下的一件事是实现发送点击到主机。为此,我们将监听图像上的 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方块。每个回合玩家将所有方块移动到所选的方向:上、右、下或左。如果一个方块无法向该方向前进(没有可用空间),则它将保持在原地。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_06_01.jpg

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;实际上,这是一个非常简单的计算,所以即使小部件在调用之间的大小没有改变,也不会造成伤害:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_06_02.jpg

在这里,大小指的是宽度或高度,因为所有相关对象都是正方形的。

接下来,我们渲染背景。我们清除 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

这张截图总结了到目前为止我们所做的工作:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_06_03.jpg

比赛场地,目前没有任何有趣的东西

棋盘数据结构

为了能够处理游戏逻辑,我们需要保留棋盘的内部表示。为此,我们将使用一个简单的二维数组(从技术上讲,是一个列表的列表)。棋盘的空白状态如下所示:

[[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

如果你在更改后运行程序,你应该会看到两个瓷砖随机添加到板的各个位置。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_06_04.jpg

实际生成瓷砖

移动瓷砖

为了有效地实现移动,我们需要将每个输入事件映射到一个方向向量。然后,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. 如果所有其他方法都失败了,我们找不到满足上述任一条件的单元格,这意味着我们无法移动了——游戏到此结束。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_06_05.jpg

游戏结束:没有有效的移动

运行这个测试的一个合适的地方是在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。最终结果如下所示:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_01.jpg

Kivy Bird 游戏截图

让我们更仔细地看看游戏,将其分解成逻辑部分,创建一个用于开发的工程概要:

  • 背景:场景由多个以不同速度移动的层组成,从而产生整洁的虚假深度(视差效果)。这种运动是恒定的,与任何游戏事件无关;这使得背景成为实现的一个理想起点。

  • 障碍物(管道):这是一个独立的图形层,它以恒定的速度向玩家移动。与背景不同,管道会以不同的相对高度进行程序化调整,以保持玩家可以通过的间隙。与管道碰撞将结束游戏。

  • 可玩角色(小鸟):这个精灵只垂直移动,不断向下坠落。玩家可以通过点击或轻触屏幕上的任何位置来推动小鸟向上,一旦小鸟碰到地面、天花板或管道,游戏就结束了。

这大致是我们将要编写的实现顺序。

创建一个动画背景

我们将使用以下图像来创建游戏的背景:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_02.jpg

背景图像

注意,所有这些都可以无缝水平平铺——这不是一个严格的要求,但仍然是一个期望的特性,因为这样背景看起来更美观。

如描述中提到的,背景始终在运动,与游戏的其他部分无关。这种效果可以通过至少两种方式实现:

  • 使用直接的方法,我们只需在背景中移动一个巨大的纹理多边形(或任意数量的多边形)。在这种情况下创建无缝循环动画可能需要一些工作。

  • 实现相同视觉效果的一种更有效的方法是创建多个静态多边形(每个层一个),覆盖整个视口,然后仅对纹理坐标进行动画处理。使用可平铺的纹理,这种方法会产生无缝且视觉上令人愉悦的结果,并且总体工作量更小——无需重新定位对象。

我们将采用第二种方法,因为它既简单又有效。让我们从包含布局的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)

如果做得正确,到目前为止的代码将生成类似于以下背景:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_03.jpg

带纹理的静态背景

动画背景

在继续处理应用程序的其他部分之前,我们需要做的是添加背景动画。首先,我们向 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(管道面对间隙的固定大小加厚部分)组成。我们将使用以下图像来绘制管道:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_04.jpg

管道图像

如果前面的解释没有引起你的共鸣,请参阅本章的第一幅插图,你将立即理解这是什么意思。

再次,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

从概念上讲,这非常简单:在画布上渲染了四个矩形,按照源文件中出现的顺序列出:

  • 下部管道主体

  • 下部管道盖

  • 上部管道主体

  • 上部管道盖

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_09.jpg

从矩形组成管道

此列表依赖于 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列表索引的映射:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_05.jpg

纹理坐标到矩形多边形的映射

注意

纹理映射通常使用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,为玩家创造一条独特的路径。以下截图总结了到目前为止无限循环的结果:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_06.jpg

移动管道和背景 – 一个 Flappy Bird 主题的屏保

介绍基维鸟

接下来在我们的列表中是可玩的角色,即生物上不可能存在的基维鸟:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_07.jpg

稀有物种,基维鸟精灵

这次将不会有与纹理相关的任何花哨的东西;实际上,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

这将停止游戏,直到用户触发另一个交互,重新开始游戏。编写碰撞检测代码最有回报的部分是进行游戏测试,以多种有趣的方式触发游戏结束状态:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_08.jpg

探索失败的不同方法(拼贴)

如果没有胜利条件,那么至少失败应该是有趣的。

制作音效

这一部分将不会特别关注 Kivy 鸟游戏本身;更多的是概述可以用于向游戏或应用程序添加音效的各种工具。

声音效果的最大问题很少是技术性的。创建高质量的音效不是一件小事,而且软件工程师通常不是技艺高超的音乐家或音频工程师。此外,大多数应用程序实际上在没有声音的情况下也是可用的,这就是为什么音频很容易在开发过程中被故意忽视或忽略。

幸运的是,有一些工具可以方便地制作出质量不错的音效,同时不需要任何特定领域的知识。一个完美的例子是Bfxr,这是一个专门针对偶尔进行游戏开发的合成器。它可以在www.bfxr.net免费获得。

Bfxr 工具系列的用法归结为点击预设按钮,直到生成一个好听的声音,然后点击保存到磁盘以将结果存储为.wav(未压缩声音)文件。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/7849OS_07_10.jpg

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)的存在理由:它们的存在是为了在更舒适的抽象(例如使用小部件和布局)背后隐藏渲染管道的细节。

低级渲染管道的工作方式如下:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_01.jpg

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 的。以下图展示了在这个设置中对顶点分配索引的情况:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_02.jpg

平面上的散点

目前,顶点没有连接,所以它们最多形成点云,而不是有结构的多边形形状。为了解决这个问题,我们需要指定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)顶点的重复使用。

这在下图中得到了说明。颜色不必在意;它们完全是解释性的,还不是“真实”的颜色。我们很快就会在屏幕上给事物上色。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_03.jpg

由顶点构建三角形

这基本上总结了在 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 颜色。

如果你现在运行程序,你会看到类似于以下截图的输出:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_04.jpg

基本着色器在实际应用中的效果:默认变换和平滑颜色

最后,我们得到了我们努力的结果。现在它可能并不特别有趣,但总比没有好。让我们摆弄一下,看看这会带我们走向何方。

程序化着色

除了总是返回相同值之外,另一种计算颜色的懒惰方法是,从相应着色器中立即可用的某物中推导它,例如,片段坐标。

假设我们想要按照以下方式计算每个像素的 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 的除法——网格的大小,为了简单起见,内联显示。

这将替换之前看到的纯色,以以下渐变的形式:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_05.jpg

基于片段坐标计算颜色

彩色顶点

通过给顶点赋予它们自己的颜色,可以以数据驱动的方式产生类似的效果。为此,我们需要扩展顶点格式以包含另一个每顶点属性,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 的工作方式。下一张截图显示了程序的输出:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_06.jpg

将颜色作为顶点属性传递

纹理映射

为了总结这一系列简单的演示,让我们将纹理应用到我们的矩形上。再一次,我们需要扩展顶点格式的定义,这次是为每个顶点分配纹理坐标:

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的文件,并将其转换为可用的纹理。为了演示,我们将使用以下图像:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_08.jpg

用于演示的纹理

至于着色器,它们与之前的版本没有太大区别。顶点着色器只是简单地传递未受影响的纹理坐标:

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);
}

当代码组合在一起时,它会产生预期的结果:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_07.jpg

简单的 GLSL 纹理映射

总结来说,这篇关于着色器的介绍应该已经给了你足够的勇气去尝试编写自己的小型基于着色器的程序。最重要的是,如果某些事情不太理解,不要感到害怕——GLSL 是一个复杂的话题,系统地学习它不是一件小事情。

然而,这确实有助于你更好地理解底层的工作原理。即使你每天不编写底层代码,你仍然可以使用这些知识来识别和避免性能瓶颈,并通常改善你应用程序的架构。

制作星域应用

借助我们对 GLSL 的新认识,让我们构建一个星域屏保,即星星在屏幕中心逃离到边缘的非交互式演示,在想象中的离心力或其他因素的影响下。

小贴士

由于动态视觉效果难以明确描述,截图在这方面也不是很有帮助,因此运行本章附带的代码以更好地了解正在发生的事情。

从概念上讲,每颗星星都会经历相同的动作序列:

  1. 它会在屏幕中心附近随机生成。

  2. 星星会向屏幕中心相反的方向移动,直到它不再可见。

  3. 然后,它会重新生成,回到起点。

我们还将使星星在接近屏幕边缘时加速并增大尺寸,以模拟假深度。

以下屏幕截图尝试(或者更具体地说,由于演示的高度动态性而失败)说明最终结果将看起来像什么:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_09.jpg

屏幕截图无法传达晕动症,但它确实存在

应用程序结构

新的应用程序类与我们本章早期所做的工作非常相似。类似于前面讨论的例子,我们并没有使用 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);
}

其最终目的是将这美好的事物呈现在屏幕上:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/kivy-bp/img/B01620_08_10.jpg

星系纹理,放大查看

就这样。我们的星系现在已经完成,准备好用肉眼(或任何其他你能想到的用途)进行天文观测。

摘要

本章旨在(并希望成功)向您介绍一个充满顶点、索引和着色器的美丽硬件加速的低级 OpenGL 和 GLSL 开发世界。

直接编程 GPU 是一个疯狂强大的概念,而这种力量总是伴随着责任。着色器比常规 Python 代码更难以掌握;调试可能需要相当程度的猜测工作,而且没有方便的交互式环境,比如 Python 的 REPL,可以提及。尽管如此,编写原始 GLSL 是否对任何特定应用有用并没有明确的启发式方法——这应该根据具体情况来决定。

本章中的示例故意设计得简单,以便作为轻松的学习体验,而不是对认知能力的测试。这主要是因为 GLSL 编程是一个非常复杂、错综复杂的学习主题,有众多书籍和在线教程致力于掌握它,而这短短的一章绝对不是 OpenGL 所有内容的全面指南。

到目前为止,我们仅仅只是触及了可能性的表面。下一章将利用我们在这里编写的代码,做一些更有趣的事情:创建一个速度极快的射击游戏。

## 软件功能详细介绍 1. **文本片段管理**:可以添加、编辑、删除常用文本片段,方便快速调用 2. **分组管理**:支持创建多个分组,不同类型的文本片段可以分类存储 3. **热键绑定**:为每个文本片段绑定自定义热键,实现一键粘贴 4. **窗口置顶**:支持窗口置顶功能,方便在其他应用程序上直接使用 5. **自动隐藏**:可以设置自动隐藏,减少桌面占用空间 6. **数据持久化**:所有配置和文本片段会自动保存,下次启动时自动加载 ## 软件使用技巧说明 1. **快速添加文本**:在文本输入框中输入内容后,点击"添加内容"按钮即可快速添加 2. **批量管理**:可以同时编辑多个文本片段,提高管理效率 3. **热键冲突处理**:如果设置的热键与系统或其他软件冲突,会自动提示 4. **分组切换**:使用分组按钮可以快速切换不同类别的文本片段 5. **文本格式化**:支持在文本片段中使用换行符和制表符等格式 ## 软件操作方法指南 1. **启动软件**:双击"大飞哥软件自习室——快捷粘贴工具.exe"文件即可启动 2. **添加文本片段**: - 在主界面的文本输入框中输入要保存的内容 - 点击"添加内容"按钮 - 在弹出的对话框中设置热键和分组 - 点击"确定"保存 3. **使用热键粘贴**: - 确保软件处于运行状态 - 在需要粘贴的位置按下设置的热键 - 文本片段会自动粘贴到当前位置 4. **编辑文本片段**: - 选中要编辑的文本片段 - 点击"编辑"按钮 - 修改内容或热键设置 - 点击"确定"保存修改 5. **删除文本片段**: - 选中要删除的文本片段 - 点击"删除"按钮 - 在确认对话框中点击"确定"即可删除
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值