InspiredPython 中文系列教程(一)
用 PyGame 制作自己的塔防游戏
Author Mickey Petersen
在这个课程中,你将学习如何使用 PyGame 从头开始编写一个 2d 塔防游戏。写一堆意大利面条式的代码很容易;但是编写可维护且易于扩展的代码却不是这样。塔防游戏是学习如何编写一个真实的游戏来测试你作为 Python 程序员的技能的绝佳场所。它也是许多其他 2d 游戏的完美模板。
This is what you’ll be building!
编写游戏的部分挑战是,一旦你超越了真正的基础,许多不同的学科就会抬头。在本课程中,您将学习以下技能:
What is a Game Loop?
游戏如何在屏幕上以一种易于维护和易于理解的方式更新和显示事物?
游戏循环是所有游戏的基石,无论大小。您将学习如何创建一个,以及如何用它来处理键盘和鼠标输入、图形渲染、更新屏幕上实体的物理特性等等。
State Machines and Transitions
很少有游戏只有一个屏幕,因此只有一种状态。大多数游戏都有一个主菜单,一个计分板,真正的游戏,可能还有更多玩家在游戏中互动的状态。理解如何在这些不同的概念之间转换你的游戏的状态对于编写一个没有意大利面条代码的游戏是至关重要的。
您将了解到有限状态机,这是计算机科学中的一个重要概念,以及它如何轻松地将一组复杂的令人困惑的需求转换成整洁的代码。
Lazy evaluation, Generators and Iterables
通过自由使用 Python 的itertools库和生成器,可以很容易地跟踪事物的位置——并计算事物的下一个位置,例如飞行的子弹。
掌握 Python 的一个部分,这个部分得到了大多数开发人员的支持,因为它们比普通的 for 循环更难推理。
Drawing and manipulating graphics
了解什么是精灵,如何操纵它移动、旋转、缩放,以及如何以一种清晰、易于理解的方式高效地完成。
Level Editing
您将编写一个完整的关卡编辑器,能够使用您自己构建的简单 UI 来放置和编辑组成塔防游戏的所有实体。
关卡编辑器是游戏的核心部分,包括如何编写保存和加载功能的细节,所以你可以和朋友分享你的关卡。
Path finding and Recursion
了解递归,这是一个强大的编程概念,可以在地图中找到有效的路径让敌人通过。您将了解基本的图论以及深度优先搜索如何用于遍历地图并找到从起点到终点的路线。
Vector Mathematics
掌握确保子弹沿直线射向目标所需的数学知识;你的敌人顺利地穿过地图;以及如何使用简单的算法制作简单的基于文本的动画。
您将了解简单的矢量算术、插值和基本的仿射变换(如缩放和旋转)。
Object-Oriented Programming (OOP)
提高您对类和对象的理解,以及如何最好地利用继承、工厂模式和 Python 的数据类,使用简单的类简洁地描述您的游戏世界。
Animation
了解如何将图像帧链接到简单的动画中,以便敌人穿过屏幕并在被爆炸的投射物击中时倒下。
Collision Detection
重要信息:炮塔如何知道何时向它瞄准的敌人开火?子弹打在敌人身上呢?
你准备好了吗?我们来编码吧!
简介和课程计划
因此,在我们开始认真编写代码之前,让我们看一下课程计划,以及您将学到什么。
课程计划
-
简介和课程计划
-
课程计划
-
课程形式
-
配置 Python 环境
-
创建一个简单的 Python 包来托管我们的游戏
-
安装和运行演示游戏
-
游戏的媒体资产
-
-
游戏循环控制器和初始化 PyGame
-
状态机:它们如何工作,做什么?
-
为我们的游戏建立一个模板(你也可以在其他项目中重复使用)
-
精灵,精灵处理和如何与他们互动。
-
编写 2d 切片引擎和地图编辑器
-
精灵动画、运动学和矢量数学
-
路径查找和递归基础
-
碰撞检测和如何使用遮罩
-
菜单、声音、文本和摘要。
这是课程计划,以及我们将如何从这里开始。我已经做了特别的努力来确保我以我期望你把它们加入游戏的速度来介绍它们,而不一定是按照你自己写的顺序,如果你坐下来写你的第一个游戏的话。我认为,这是我想重复的一个要点:将东西绘制到屏幕上还有一段时间,因为我认为在继续之前我们打下良好的基础是至关重要的。
必需的知识
在您开始之前,我将简要介绍一下我认为您应该了解的内容。你知道这一点并不是一条铁定的规则,但这将使课程更加平易近人。
A basic understanding of Python
列表、循环、字典、类、函数等等。你不一定要成为面向对象编程专家,但是了解一点关于继承的知识会大有帮助。
我们将编写自己的 Python 包,这主要是一个注意细节的例子。但是知道如何用pip安装包以及你的平台可能有的任何其他补充需求(这主要与 Linux 用户相关。)
Basic mathematics
没错。数学。这是无法逃避的。主要是算术,但是我们将转向向量数学的主题——但是仅仅是基础。如果你熟悉简单的笛卡尔坐标系和加减数字,你会做得很好。
差不多就是这样。你不需要事先了解 PyGame 或游戏开发;但是你确实需要一种想要学习它的渴望!
图形资产
这款游戏配有高质量的图形,随时可用。你可以在演示中找到它们。
课程形式
本课程不是你在游戏演示中找到的源代码的对等重复,但我会解释它的所有方面,所以在本课程结束时,你将完全理解编写塔防游戏(和许多其他 2d 游戏)所需的一切!)或者坐下来修改提供的演示中的所有内容。提供的演示完全正常;它有资产,音效,一个工作级编辑器和游戏。它还展示了您将在本课程中读到的所有内容,因此,如果您遇到困难,或者更喜欢查看完整的解决方案,我们鼓励您回头参考它。
您可以通过多种方式学习本课程,每种方式都有自己的学习途径,具体取决于您的喜好:
-
你可以拿着这个演示开始修改,并把它作为基线或者作为你自己游戏项目的灵感。
-
您可以将本课程作为参考,仅当您需要关于特定部分的建议时才回头参考,并通过提供的演示巩固您的学习成果。
-
你可以按照课程和实现每一件事,一步一步,一路试验。
或者以上两种情况的结合——选择权在你。
好了,我们开始吧!
让我们用 Python 写一个 Game Boy 模拟器
Author Mickey Petersen
对于 20 世纪 80 年代和 90 年代古板、陈旧的游戏平台,有很多东西值得一提。其中最主要的是怀旧——如果你足够大,还记得它们的话——以及对游戏和计算机硬件都更简单、更容易理解的时代的喜爱。
The 4.194304 MHz monochrome Game Boy released in 1989.
但对于那些不知道的人来说,Game Boy 是一款标志性的便携式游戏设备,旨在使用你插入设备背面的墨盒来玩电脑游戏。最初的 Game Boy 是单色显示器;一个 8 位 Z80 风格的 CPU,4.194304 MHz 高达 8 KiB 的工作内存和 8 KiB 的视频内存;和 4 个立体声声道。它还有一个液晶显示屏、一个方向游戏手柄和几个按键。热门游戏包括超级马里奥、俄罗斯方块,当然还有神奇宝贝。
在这门课中,我将教你如何用 Python 写一个 Game Boy 模拟器。
什么是模拟器?
先简单说一下什么是仿真器什么是。在我们的例子中,我们将编写的 Game Boy 模拟器将模仿我们需要的组成 Game Boy 的许多组件,以使其工作。但是,这句话有很多模糊的上下文:模仿某事是什么意思?你是如何模拟 LCD 的复杂性的——你甚至尝试过或者仅仅满足于在屏幕上画图吗?CPU,内存和物理按钮呢?那保存游戏的墨盒呢?
决定什么是好的或坏的模拟器归结为仔细的权衡。将源系统的所有硬件缺陷或软件缺陷完美地呈现给主机系统并不总是可行的(或有用的)。还有其他的考虑,比如主机系统是否能够准确地模拟它。
因此,一个模拟器编写者的工作是选择你的战斗,并承认有些事情(在我们的例子中是某些游戏)如果没有不成比例的工作量对的投资回报,就无法工作。不同的硬件如何相互作用的复杂性,以及程序员为保证每个周期的性能所经历的极端长度,意味着如果你真的想要一个周期精确的仿真器,你必须实现大量晦涩难懂的行为。现在,随着教育的“投资回报”急剧下降,我将不再努力追求那种水平的竞争。
一个周期精确的仿真器试图模仿原始系统的特性,使得仿真系统的时序尽可能接近真实系统。某些游戏和应用程序需要实现这种级别的模拟,但并非所有游戏和应用程序都需要。
但话说回来,这些错综复杂的组件如何交互绝对是我们会遇到的事情:有许多边缘情况和设计怪癖,我们必须权衡试图准确反映真实系统的好处,而不是简单地宣布它“足够好”并继续前进。
因为这是一门教育课程,所以主要的重点是教授一些概念,这些概念会产生一个能够运行一些 Game Boy 卡盒的尚可的仿真器。因此,即使你表面上对 Game Boy 不感兴趣,你从课程中学到的东西也会让你对软件和系统编程有深刻的理解。
目标受众
你不需要成为 Python 或系统编程方面的专家,也可以尝试一下。整个课程旨在温和地介绍你将要学习的每个概念。这并不意味着你会坐下来,在一个周末就把它完成,即使你是;这是一项相当大的任务,但值得庆幸的是,回报是构建模拟器的体验。
你会学到什么
CPU Design and CPU Architecture
Z80 风格的 CPU 与现代英特尔 CPU 可以追溯到的基础 8080 CPU 设计非常相似。虽然 Z80 与 Game Boy 中的 CPU 相似,但并不完全相同:它很可能是一款名为 LR35902 的英特尔 8080 风格的夏普 CPU,但我将使用 Z80 这个术语,尽管它不是 100%真实的。原因是除了提到 Game Boy 之外,互联网上关于 Sharp CPU 的文档很少。Z80——尽管它在许多方面与夏普不同——相当相似,并且有大量在线文档。
当然,现代的 CPU 非常复杂;但是旧的 Z80 不是,这使得它非常适合理解 CPU 如何实际工作的基本原理。
您将使用 Python 编写一个完整的“CPU ”,包括寄存器、标志、内存控制器、指令执行等等。
Assembly Language Programming
您将很好地理解 Z80 汇编语言(以及至今仍在使用的一般概念)以及它是如何支撑我们今天所做的一切的。基于寄存器的 CPU 很可能是你正在阅读的计算机的动力,并且对汇编语言有足够的了解,可以令人信服地推理出 CPU 如何机器码被解码并执行,这将很好地为你服务。
How to write a Disassembler
如果汇编程序将汇编语言转换成 CPU 可以理解的机器代码,反汇编程序则相反:它将机器代码转换回汇编语言。作为模拟器工作的一部分,当你有条不紊地研究 CPU 规范或调试开发过程中会出现的问题时,你需要这样做。
Interrupt Handling
问问你自己,当你按下 Game Boy 游戏手柄上的键时会发生什么?对于忙于执行组成电脑游戏的代码的 CPU 来说,物理按钮的按下是如何表现为可操作的事情的?这个问题(以及许多其他事件)的答案是中断处理程序,这是系统编程的另一个核心部分,它允许在完全没有并发性的 CPU 中处理事件。
Interactive Debugger
您将编写一个交互式调试器,能够单步执行每条机器代码指令;评估简单的表达式,如查看内存或寄存器;当到达代码的特定部分时放置断点;还有更多。
Bit manipulation (or “bit twiddling”)
问问你自己,一台只理解 0 和 1 的计算机如何理解数字 2?它是如何把加到那个数上的,一个 CPU 只能对 8 位或者 16 位数进行运算是什么意思?课程结束时,你将对二进制数有一个坚实的理解;CPU 如何加减和表示有符号和无符号数;如何对二进制数进行“位操作”,例如在位域中设置或重置标志;以及《格列佛游记》和《CPU 字节序》令人费解的词源。
Vertical Blanking, Blitting, Scrolling and Sprites
将东西画到屏幕上是 CPU、内存、显示器及其显示控制器之间精心编排的芭蕾舞。在真正的硬件上出错,你只会打印垃圾,甚至损坏它!幸运的是,这在模拟器中并不重要,但是你仍然需要理解屏幕空白;向屏幕发送信息;以及 Game Boy 如何滚动屏幕并显示移动图形。
Performance Profiling and Code Optimization
Python 是…不快的。您可能会惊讶地听到,即使半精确地模拟一个不起眼的 4.19 MHz CPU 也会对您的 CPU 造成影响!所以一旦该说的都说了,该做的都做了,你就会学到一些加快速度的技巧。
Python
如果我不包括这一点,我将是失职。在整个课程中,我打算向你展示我将如何在考虑到可测试性和良好设计的情况下开发仿真器的关键部分。我将使用 Python 3.10 的 匹配案例模式匹配 ,因为它有许多诱人的用例,并且这里有很多关于关注点分离的内容——当你必须开发一个复杂的组件网络,但仍然必须独立编写和测试时,这是一个关键的概念。
目标和非目标
让我们来谈谈目标和非目标。目标是一个正常运行的 Game Boy 模拟器和一个调试套件来帮助进一步开发它;但是旅程比目的地更重要。在这个过程中,你会学到很多非常酷的概念和技术。完成一个项目感觉很好,但是这是一项任务,超过 70-80%的每一个百分点的增量都将花费越来越多的时间,因为你将不得不获得许多复杂的时间安排和设计怪癖来取得进展。那么, me 的最终目标就是尽我们所能,并从中获取乐趣和学习,这远远达不到的周期精度。但这不应该让你气馁:一旦你很好地理解了 Game Boy 仿真的机制,你就应该拥有自己进一步发展所需的工具和知识。
所以,简而言之:
Emphasis on Readability over Performance
我写的代码将强调可读性,而不是性能。当您还不了解瓶颈可能在哪里或在什么地方时,很容易陷入不必要的性能优化。
课程计划
如果你想写一个 Game Boy 模拟器,有相当多的必读材料,但是关于这个主题的主流文档——虽然非常好——确实假设了系统编程的流利程度,我当然会首先解释。
所以计划大致如下:
-
简介(这就是你现在正在读的东西
-
什么是游戏机,什么是模拟器
-
建议和要求的资源
-
从操作码 JSON 中读取操作码
-
-
理解操作码和操作数
-
如何读取和实现操作码和操作数
-
编写一个基本的反汇编程序(和我们未来的解码器)
-
什么是盒式只读存储器?
-
从盒式磁带中读取元数据
-
-
编写 Z-80 CPU 的 Python 框架
-
程序计数器
-
寄存器和标志
-
钻头旋转基础
-
-
存储体和存储控制器
-
编写交互式调试器
-
实现一些基本的 CPU 操作码
-
比特旋转和操作数字
-
ALU 和算术操作码
-
中断处理
-
图形、平铺地图、背景和显示
-
游戏手柄和内存条切换
-
性能优化
-
声音
建议和要求的资源
你不能在真空中编写游戏模拟器。这条路线站在巨人的肩膀上。有大量的发烧友为游戏男孩的各种化身制作的文档。Game Boy 经过了很多很多年的艰苦逆向工程,已经成为我们可以利用的文档和资源的集合。
也有许多其他的游戏机模拟器,如果你被卡住了,看看他们是怎么做的也没什么不好意思的。你会被卡住的。一旦你更进一步,你就不会有任何问题去推测这些模拟器是如何工作的,并且你可以很快检查你的理解是否正确。
我将包括与我们进展到的每个阶段的进展水平大致匹配的代码示例,但有一点需要注意的是,像所有半成品一样,它当然不会是 100%正确的参考实现——您只有在最后才真正到达那个阶段——但它应该很好地作为 you 的参考或起点。随着我们对 Game Boy 及其内部的理解不断加深,每一次进步都会带来变化、修正和改变。
所需资源
我应该先列出 Game Boy 开发社区,因为这是一个关于 Game Boy 几乎每个部分的精彩网站。你在下面看到的很多信息也可以在上面的 GBDC 链接中找到。
技术参考
《潘文档》是游戏男孩各方面的优秀技术参考。这是我们将使用的两个主要文档来源之一。我会经常提到这一点。
我强烈建议你浏览一下,感受一下内容是如何编排的,以及编写一个 Game Boy 模拟器需要什么。但是,不要让大量的概念、术语和你需要做的事情使你气馁,尽管我们将一步一步地循序渐进。
另一个很棒的参考手册是 Game Boy CPU 手册。它包含更详细的描述,特别是 CPU 操作码和系统的各个方面如何相互作用。你应该阅读并收藏这两本。因为后者是没有超链接的 PDF,我建议你打印出来。不过,请注意,文档中有一些不准确的地方;鼓励您将信息与完整技术参考进行交叉参考。
Game Boy:完整的技术参考是另一个资源,它也非常好,特别关注内存库(暂时不用担心)和 CPU 指令。
尽管作者声称这本书“陈旧且无人维护”,但实际上它仍然是一本有用且相关的参考手册。
操作码引用
有一个 Game Boy CPU 指令周期表,这是一种有趣的显示信息的方式,你可以在我上面列出的技术参考中找到相同的信息。非常有用的是同样的 JSON 下载;稍后我将使用它来自动化编写反汇编程序的许多繁琐工作。
盒式 ROMS
优秀的自制中心充满了游戏、演示和各种各样的酷 rom 供你尝试。不言而喻,我不能告诉你如何下载你以前玩过的 game boy 盒式光盘,因为这当然侵犯了版权;但是仍然有大量的免费 rom 供你尝试。
模拟器
有许多针对 Game Boy 的仿真器,具有不同的硬件精度和特性。为了开发一个 Game Boy 模拟器,我推荐你看看 BGB,因为它有一个优秀的调试器。我可以确定它在 Windows 上运行得很好,在 Linux 上运行 WINE 也很好。
可选但有用的资源
汇编程序和操作码手册
如果你想让为游戏男孩编写汇编语言 rom,RGBASM 文档和 Rednex 游戏男孩开发系统是很有用的。如果你想用一个真正的 ROM 测试某些 CPU 指令,你可以。该文档是 Game Boy 汇编语言编程的入门读物。
精灵和瓷砖
游戏男孩图形渲染器很有用,在很久以后,如果你想检查你的 sprite 和 tile 图形实现是否正确。你可以给它输入一串字节,它会告诉你它的图形。
读取和解析操作码和操作数
操作码和操作数
一个操作码是 CPU 必须对操作码的操作数执行的动作,如果有的话。因此,如果1 + 2是人类的指令,那么操作码将是 CPU 的+,而1和2是操作码的操作数。
你写的所有代码都会以这样或那样的方式,把自己简化成一组你运行代码的 CPU 能够理解的指令。需要多少步骤取决于你使用的语言和工具:写汇编语言,差距很小;写 Python 是一个巨大的鸿沟。在许多方面,像 Python 这样的解释的语言类似于 CPU,因为解释器模仿了组成计算机的大部分架构,以便提供一个“统一的”环境,在那里你可以编写一次代码,并合理地假设它将在解释器运行的地方运行。
因此,解释型语言的好处是,如果你能编写解释器并使它在深奥的计算机硬件上工作,那么你就能期望为解释器编写的大量工作也能运行。具体来说,Python 是在一个虚拟机上运行的,我认为这很能说明问题。
考虑以下代码片段:
>>> def add(a, b): return a + b >>> import dis >>> dis.dis(add) 2 0 LOAD_FAST 0 (a) 2 LOAD_FAST 1 (b) 4 BINARY_ADD 6 RETURN_VALUE ^--- Mnemonic ^^^^^ Argument ^--- Offset
使用dis模块,我可以通过它的dis.dis()函数运行任何对象,并且将对象分解成它的组成指令。尽管 Python 使用的术语与 Z80 CPU 看待事物的方式并不完全一一对应,但我认为这是一个合理的复制。我建议你用 Python 反汇编一些东西,感受一下 Python 的 VM 如何理解你的代码。注意,dis.dis函数没有显示操作码,但是dis.get_instructions()显示了。
同样,您可以要求 Python 为您提供我们的函数add的字节编译代码:
>>> add.__code__.co_code b'|\x00|\x01\x17\x00S\x00'
By the way …
类似于 ??,我必须指出 Python 的字节码在实现中有各种各样的余量来支持 Python 的动态特性。这意味着具体化的字节码不是源代码的 100%镜像。不过,这不是 Z80 的问题。
字节码是我们代码 的浓缩表示,Game Boy 卡带 rom 也是如此。盒式只读存储器也存储数据——图形、音乐等等——你不一定能以原始字节形式区分代码和数据。
如果你从 rom 中挑选出一个值为144的字节,你怎么知道它是代码还是一段音乐的一个小片段呢?
获取操作码元数据
我们将要模拟的 Z80 有大约 500 条指令。这听起来可能很多,但大多数都是彼此的变体,我们可以通过一些巧妙的思考一次敲出几把。
好,我们需要一个完整的操作码列表。你在上面看到的反汇编代码是这些东西如何被写出供人使用的一个相当典型的代表。它通常看起来有点像这样:
<addr> <opcode> <mnemonic> [<operand> ...] [; commentary ]
addr是指令所在的内存地址——我们现在可以忽略它——还有你现在知道的opcode和mnemonic;operand条目的列表是可选的,因为不是所有的操作码都有它们,但是如果它们在那里,我们会显示它们。最后是一个带有;的评论,表示一个评论。
我建议你从操作码表下载 JSON 并使用它。你没有有去:你当然可以复制你在表格或参考手册中看到的说明。
操作码 JSON 分为两个主要部分:cbprefixed和unprefixed。现在,我建议您将每一个都视为不同的操作码段。有一个很好的理由,我们将详细讨论 ROM 中操作码的结构以及如何读取它们。目前,惟一的目标是获取这个结构化的 JSON 文件,并将其转换成可以用 Python 轻松查询的内容。
任何一个键本身都是一个{ opcode_1: details_1, ..., opcode_n: details_n }的字典,就像这样:
"0xFF": { "flags": { "C": "-", "H": "-", "N": "-", "Z": "-" }, "immediate": true, "operands": [ { "immediate": true, "name": "38H" } ], "cycles": [ 16 ], "bytes": 1, "mnemonic": "RST" }
这是一条带有助记符RST的指令。您能使用我之前编写的模板挑选出编写人类可读表单所需的信息吗?
指令和操作数数据类
解析这个文件应该是一个 zinch。格式绝对可以保持原样;它足够详细,使人类可读。
但是,我更喜欢数据类:
from dataclasses import dataclass from typing import Literal @dataclass(frozen=True) class Operand: immediate: bool name: str bytes: int value: int | None adjust: Literal["+", "-"] | None def create(self, value): return Operand(immediate=self.immediate, name=self.name, bytes=self.bytes, value=value, adjust=self.adjust) @dataclass class Instruction: opcode: int immediate: bool operands: list[Operand] cycles: list[int] bytes: int mnemonic: str comment: str = "" def create(self, operands): return Instruction(opcode=self.opcode, immediate=self.immediate, operands=operands, cycles=self.cycles, bytes=self.bytes, mnemonic=self.mnemonic)
Python 中的十六进制
不熟悉十六进制?没问题。快速速成课程。(稍后我们还将讨论二进制,因为它更重要!)
您可能已经看到操作码字典中的键是这样的字符串:"0xFF"。十六进制使用的基数为 16,而不是十进制(基数为 10)或二进制(基数为 2)。在二进制中,你用0和1计数;十进制用0通至9;而十六进制是0到9,然后A到F产生一个“数”的序列像这样:0123456789ABCDEF。
十六进制通常以0x(或 Z80 行话中的$,但 Python 不识别该符号)为前缀,二进制以0b为前缀。在 Python 中尝试一下:
>>> 0xFF 255 >>> 0b1111_1111 255 >>> hex(255) '0xff' >>> bin(255) '0b11111111'
一个字节可以代表0-255,与0x0-0xFF相同。每个字节为 8 位,通常分为两个各 4 位的半字节:
>>> 0b0000_1111 15 >>> hex(15) '0xf' >>> 0xF 15
是的,你可以把_塞进数字里,包括十六进制和二进制符号,作为一种视觉辅助。很酷吧。
这两个半字节通常被称为“高”和“低”。哪一个是高或低——左侧或右侧——归结为 CPU 的字节序,这个话题我们将在稍后认真讨论二进制数时讨论。
具有讽刺意味的是,除了让人类更容易推理出二进制数之外,没有理由使用十六进制数,因为十六进制数、字节、位和半字节都是 2 的幂:2、4、8、16。
现在,因为 Z80 是一个 8 位的 CPU(16 位支持算术和寻址),你需要一次处理半字节、位和(最多)2 个字节。十六进制使它更容易,但如果你喜欢,你可以自由使用小数!
解析操作码
所以回到操作码解析器。我们已经看到,操作码有十六进制值的字符串表示。但是我们需要先对它们进行解析。幸运的是 Python 可以为我们做到这一点:
>>> int("0xFF", base=16) 255
int()函数接受一个可选的基,它也能够理解自己的符号:
>>> int("0b0110111", base=2) 55
所以这应该能解决问题。
说到底,我现在有两本词典,包含每一套独特的说明:
>>> instructions[0xFF] Instruction(opcode=255, immediate=True, operands=[ Operand(immediate=True, name='38H', bytes=None, value=None, adjust=None) ], cycles=[16], bytes=1, mnemonic='RST', comment='')
有了它,我可以很快得到一个独特的列表,里面列出了每种字典的所有助记符:
>>> {inst.mnemonic for inst in instructions.values()} {'ADC', 'ADD', 'AND', 'CALL', # ... etc ... 'SUB', 'XOR'}
这样,你就有了一个操作码的工作列表。我建议您为指令和操作数编写一个漂亮的打印机,这样您就可以看到类似于我之前展示的模板的文本表示。
结论和下一步措施
Opcodes and operands is the machine code that powers your computer
我们已经简要地介绍了 CPU 是什么,它做什么——以后还会有更多的介绍——以及操作码和操作数的作用。它是你的 CPU 的编程语言。但是正如我所展示的,像 Python 这样的高级语言及其解释语言;编译时发出的字节码;它用来运行字节码的虚拟机,与 CPU 的角色没有什么不同。
Hexadecimals, Binary and Numbers writ large
十六进制是程序员的助手。除了帮助我们推理二进制数之外,它们与计算没有什么关系,这是一个我还没有涉及到的话题,因为要真正理解它们需要深入研究。可以说,二进制数是 CPU 的命脉。
Emulation is the act of replicating the conditions that allow code to run unmodified on a different host
但是要注意的是,并不是所有的东西都那么容易被复制!让事情变得恰到好处既是一门艺术,也是一门科学。我们的目标是编写一个 Game Boy 模拟器,但要围绕*、周期准确度*和性能进行权衡。
The Game Boy CPU is a hybrid of multiple different CPU types
我将它称为 Z80,因为它相当接近 CPU,但它并不完全相同。这是一款夏普 LR35902,它的灵感绝对来自英特尔 8080 和 Z80。但是在线上没有关于该模型的官方参考文档,您可以找到的大多数文档都将您引回到 Game Boy 模拟器。
如果你想阅读 Z80,记住夏普有一个不同的指令集(但有很多重叠);它缺少一些寄存器和标志。
后续步骤
在下一部分,我们将看看如何编写一个反汇编程序,并使用我们刚刚读到的操作码。知道如何表示 CPU 将要执行的代码是一个重要的调试助手。
掌握结构模式匹配
Author Mickey Petersen
如果你不熟悉术语结构模式匹配,那么你并不孤单。直到大约 10-15 年前,这个特性在函数式编程语言之外是看不到的。然而,它的使用已经普及;今天你可以在 C#、Swift 和 Ruby 中找到类似的特性。Python 3.10 曾经是小众语言的领地,现在你可以尝试了。
1def greet_person(p): 2 """Let's greet a person""" 3 match p: 4 case {"greeting": greeting, "name": name}: 5 print(f"{greeting}, {name}") 6 case {"name": name}: 7 print(f"Hello, {name}!") 8 case {"greeting": _} | {}: 9 print("I didn't quite catch your name?") 10 case str() as person if person.isupper(): 11 print("No need to shout - I'm not deaf") 12 case str() as person: 13 print(f"Nice to meet you, {person}.") 14 case _: 15 print("I didn't quite understand that!")
函数式编程学派的信徒们肯定会喜欢它;不得不与无数业务规则引擎纠缠不清的经验丰富的开发人员也可以期待一些缓刑。但是日常用例呢?是什么让结构模式匹配对典型的 Python 项目有用?它到底是什么,当你不用 it 就能解决复杂的问题时,你为什么要采用它?
总的概念——我将很快向您介绍它是如何工作的——是计算机科学和(尤其是)函数式编程的核心。渗透在所有这些不同的语言和他们自己对这个特性的理解中的是一个共同的词汇和对什么是模式匹配以及它试图解决的问题的理解。一旦你掌握了 Python 中模式匹配的要点,你就会认识到——并且知道如何应用——这些概念。
诱人的是,我留下了预示上述新特性的代码片段。看起来不算太糟,对吧?这是一个尝试智能格式化问候语的功能:
>>> greet_person({"greeting": "Say my name"}) I didn't quite catch your name? >>> greet_person("Walter") Nice to meet you, Walter. >>> greet_person({"greeting": "Howdy", "name": "Cosmo"}) Howdy, Cosmo
但是在greet_person中,没有什么是你不能用一系列if语句完成的。这就是模式匹配试图做的事情的关键所在:删除if语句和“getter”的冗长和乏味,这些语句和“getter”询问对象的结构以提取您想要的信息。在greet_person中,我希望——理想情况下——几条信息:一条greeting和一条name,并且在它们中的一些或全部丢失的情况下,处理得当。
操纵数据结构是编程的核心部分,模式匹配系统可以帮助您实现这一点。当您对对象、字典、列表、元组和集合使用if语句、isinstance调用、异常和成员测试时,您这样做是为了确保数据的结构匹配一个或多个模式。这就是一个特别模式匹配引擎的样子。
以传统的方式考虑上面的match代码:
def greet_person_alt(p): msg = "I didn't quite understand that!" if isinstance(p, dict): if 'greeting' in p: greeting = p['greeting'] if 'name' in p: name = p['name'] msg = f"{greeting}, {name}" else: # ... etc ... else: # ... etc ... else: # ... etc ... print(msg)
这只是整个磨难的一部分,我也没有努力变聪明。但是正如您所看到的,深度嵌套的if语句很容易遗漏一条业务规则或者将它放在错误的位置;更糟糕的是,您必须解析整个结构,以找出进行更改的正确位置。更不用说它的大小了。只需添加一些规则或复杂的检查来确定正确的问候格式,您将不得不创建您自己的 home brew 匹配引擎——这种方法根本无法扩展。
这就把我们带到了结构模式匹配的核心:关键词match和case。在编程的每个方面,这都是一个已经存在并将会存在的问题:
-
你是否有一个非常深的嵌套字典,你必须检查是否有键和它们的值?你可以使用结构模式匹配器。
-
您是否有依赖于自定义对象(如
Customer或Sales对象)中某些属性的复杂业务规则?你可以使用结构模式匹配器。 -
您必须解析来自其他系统的文件输出或数据流吗?可能从一系列原语(字符串、整数等)中转换它们。)到一个
namedtuple,字典还是自定义 dataclass 对象?你可以使用结构模式匹配器。
所以让我们来看看它到底是如何工作的。
模式匹配器语法剖析
match声明
match <expression>: case <pattern 1> [<if guard>]: <handle pattern 1> case <pattern n> [<if guard>]: <handle pattern n>
好了,现在是时候介绍一些术语了。match语句是一个软关键字,并带有一个表达式(想想:类似于变量赋值的右边),它成为你的case子句的主题。
软关键字,如match语句,是这样一个关键字,如果在明确属于match模式匹配块的而不是部分的上下文中使用,它不会导致语法错误。
这意味着你可以继续使用match作为变量或函数名。
match语句不是函数,也不返回任何东西。它简单地划分了一个或多个case子句的开始,就像一串if语句。
当 Python 执行模式匹配器代码时,它只是按照编写case子句的顺序,检查第一个匹配的子句。可以有多个匹配的case子句,但是只使用它遇到的第一个与匹配的子句。因此,排序很重要。
The match statement picks the first match that evaluates to true
所以试着按照你希望匹配的顺序排列case语句。从这个意义上来说,这类似于您如何对一系列if-elif-else语句进行排序。
case条款
每个case子句代表一个或多个模式,您希望将它们与match语句中定义的主题进行匹配。
在类 C 语言中,你必须在switch-case语句中使用break,否则代码将直接进入下一种情况。这在这里是不可能的:最多有一个case条款被执行。的确,C-likes 中的switch-case与模式匹配完全不同,所以不要把它们混淆。
case子句采用一个或多个模式。每个模式可以依次拥有自己的子模式。
By the way …
一个case子句可以选择有一个守卫,这是一个if语句,允许您应用布尔条件,这些条件必须为 true 以匹配case子句。有点像列表理解中的if语句。
每个case子句获取一个语句代码块,如果子句是match块中第一个匹配主题的子句,则执行该语句代码块。如果您想使用return、yield或者,比方说,与case子句语句块中的数据库对话,您可以而且应该这样做。如果主题匹配,就在这里放置所有必须调用的逻辑。
The match-case statements may well become the center of your code in some applications
有限状态机;具有声明性模式和递归的行走树和树状结构;微服务中处理传入请求的无限循环;ETL 应用程序的一部分,在生成 JSON 并放入另一个系统之前,从一个活动系统中读入原始数据。天空是极限。
什么是模式?
我需要提到的第一件事是,你将在case语句中编写的代码与你将在语句外编写的代码完全不同!
当你写一个模式时,你描述了结构,case子句应该测试主题。这为你打开了许多其他方式无法获得的途径。您可以深度嵌套字典、列表和元组,Python 的匹配引擎将细致地打开每一层,并检查该结构是否与任何case子句匹配。
考虑之前的例子:
def greet_person(p): """Let's greet a person""" match p: case {"greeting": greeting, "name": name}: print(f"{greeting}, {name}") # ... etc ...
让我们仔细看看那条case条款。它只有一种模式,这种模式要求:
-
科目是一本字典。
-
字典至少包含两个键,一个名为
"greeting",另一个名为"name"。 -
并且这两个键的值被绑定到命名绑定
greeting和name。
因此,如果您传递给greet_person任何不符合这三个标准的东西,case语句匹配失败,match语句继续下一个case语句。
什么是捕获模式?
到目前为止,唯一令人困惑的部分是绑定名称。是的,它们看起来非常像变量。但是它们是而不是变量,即使它是你代码中任何其他部分的字典,它们也会是。这是因为它是一个捕获模式,是模式匹配引擎的重要组成部分。
当 Python 必须将主题映射到case块中的模式时,它可以将找到的值绑定到您给定的名称。它们被称为名称绑定或绑定名称,因为它们是作为模式匹配过程的一部分被捕获的。一旦它们被绑定,你就可以像使用变量一样使用它们。至关重要的是,只有当 Python 试图进行模式匹配时,它们才表现出不是变量的短暂性质。
事实上,如果case子句成功,我们print问候,在这一点上,一切又有意义了。
You can use named bindings to match large swathes of the subject
所以你绝不仅仅局限于一本字典的价值。你很快就会看到,我们能做的远不止这些。
但是请记住,命名绑定不是变量。还有一个尴尬的问题,当一个模式部分匹配,但最终失败时会发生什么。但是我将在后面的章节中讨论这些问题,因为它们也值得仔细研究。
A named binding itself matches (or not!) parts of your pattern
事实上,我可以捕获字典的值,但是当然有一个隐含的假设:键首先存在,并且具有某个值,即使这个值是None。
因此——这一点很重要——命名绑定本身会影响您希望主题匹配的模式。
A pattern is declarative and not imperative
回想一下命令式编程正在编写告诉 Python 做什么的代码。有了模式你就不用告诉 Python 该做什么;相反,你声明你想要的结果或结局,并且你期望 Python 能找出本质的细节。
By the way …
特别是 PEP-634、PEP-635 和 PEP-636
这非常重要,如果你想真正理解模式匹配是如何工作的,记住模式是声明性的是至关重要的。考虑一下之前的例子:Python 是如何做的?我的意思是,它是在几个 PEP 规范 中记载的,当然还有模式匹配器的源代码。
但是——除了问题和引擎限制——这在这里并不重要。要使用结构化模式匹配引擎,您必须定义对您和 Python 有意义的模式,并相信 Python 会想出如何为您找到答案。
现在你知道了,模式是一种表达期望的结构的方式,一个主题必须具有该结构才能与模式匹配。这种结构几乎可以是任何东西。但是你也可以提取你最感兴趣的结构部分。这是使结构模式匹配有用的关键部分。
写作模式
文字模式
理论上,最简单的模式类型,文字模式匹配文字,如字符串、布尔值、数字和None。
def literal_pattern(p): match p: case 1: print("You said the number 1") case 42: print("You said the number 42") case "Hello": print("You said Hello") case True: print("You said True") case 3.14: print("You said Pi") case _: print("You said something else")
字面模式匹配器必须做出许多假设,以大多数人对 Python 的直觉的方式工作。这意味着要制定一些明确的例外,否则大多数人会感到困惑。
文字模式检查是通过相等检查(a == b)进行的,但是有几个特殊情况的异常和陷阱你应该知道。
>>> assert 1.0 == 1 >>> assert 1.1 != 1
浮点和整数通过相等检查进行比较。所以一些浮点数自然会等于它们的整数对应物。
您可以使用类型约束int()或float()强制 Python 选择其中之一,如下所示:
case int(1): print("You said the integer 1") # or case float(1.0): print("You said the floating point number 1.0")
如果将布尔值与0和1文字混合使用,则需要预先考虑:
>>> assert True == 1 >>> assert isinstance(True, bool) >>> assert isinstance(True, int)
True和False都是bool和int,因此True == 1和上面的文字模式示例中的case True子句永远不会运行,因为case 1首先匹配它!
解决方法是确保case True语句在 case 1之前运行。这将解决问题:1将匹配case 1,True将匹配case True。
原因是True、False、None通过身份 ( a is b)匹配*,像这样:*
>>> assert True is not 1 >>> assert False is not 0
在大多数代码库中,这不会是一个问题,但仍然值得了解。我推荐你阅读 真理和谬误 来理解为什么混淆平等和身份验证会让你陷入困境。
作为模式
当您编写模式时,您可能希望在您的模式中进行某些声明,Python 必须遵守这些声明以使模式匹配。但是如果您还想将该声明绑定到一个以后可以使用的名称,那么您必须使用as模式。
def as_pattern(p): match p: case int() as number: print(f"You said a {number=}") case str() as string: print(f"Here is your {string=}")
这里有两种模式。一个是类型声明,必须匹配字符串,另一个是整数。注意,与文字模式中的例子不同,我没有指定特定的字符串或整数,尽管我当然可以。
当我调用代码时,它会如您所料地工作,因为as语句将左边匹配的值绑定到右边的名称。
>>> as_pattern("Inspired Python") Here is your string='Inspired Python' >>> as_pattern(42) You said a number=42
AS Patterns make it possible to bind grouped declarations
例如,如果没有 AS 模式,您只能将泛型数据绑定在一个模式中,而不能将其约束为一种类型。
护卫队
严格来说,护卫不算模式。在一个模式被匹配之后,但是在case块内的代码被执行之前,它们被调用*。*
def greet_person(p): """Let's greet a person""" match p: # ... etc ... case str() as person if person.isupper(): print("No need to shout - I'm not deaf") case str() as person: print(f"Nice to meet you, {person}.")
这个greet_person例子的特点是一个守卫。就像列表理解中可选的if一样,你可以选择在case块上附加一个守卫。如果您想根据绑定到模式中名称的值来做出决策,那么它们是很重要的。
在这个例子中,greet_person函数检查一个人的名字是否是大写的,如果是,礼貌地要求他们不要大喊大叫。
因此,即使模式匹配,如果保护不正确,整个case子句失败,并且match语句继续下一个。
Guards let you evaluate the bound names from a pattern and apply additional checks
与模式的声明性质不同,保护中的表达式可能有副作用或其他复杂的逻辑,如下所示:
match json.loads(record): case {"user_id": user_id, "name": name} if not has_user(user_id): return create_user(user_id=user_id, name=name) case {"user_id": user_id}: return get_user(user_id) case _: raise ValueError('Record is invalid')
因此,您可以在应用程序中构建模式并应用从功能角度来看有意义的约束,而无需关心从数据结构中提取数据的细节。
或者模式
想要在单个case语句中匹配两个或多个模式是一个常见的特性。多亏了 Python 的模式识别系统,你不再局限于单一模式。您可以在case子句级别组合多个模式,也可以在单个模式中组合。尤其是后者,尤其强大。
一个重要的警告是,即使模式样式被正式命名为或模式,实际的语法要求您使用|和而不是 or。
1def or_pattern(p): 2 match p: 3 case ("Hello" | "Hi" | "Howdy") as greeting: 4 print(f"You said {greeting=}") 5 case { 6 "greeting": "Hi" | "Hello", 7 "name": ({"first_name": name} | {"name": name}), 8 }: 9 print(f"Salutations, {name}")
请注意,每一个突出显示的行都使用|而从不使用or。除了这种语法上的怪癖,所有东西的行为方式都与 Python 的其他部分非常相似。我特意在第 3 行的 OR 模式周围添加了括号,以确保as语句使关系清晰,尽管这不是严格要求的。
或模式最强大的特性是能够将它们嵌套在您希望进行模式匹配的数据结构中。
让我们更仔细地分析第 5 & 6 行。
最顶层的模式是一个字典,要求名为"greeting"的键必须存在。但是与我给出的第一个例子不同,这个例子期望"Hi" | "Hello"作为"greeting"值的子模式。所以"Hi"或"Hello"都是有效的问候。
第 6 行更具体一些。必须有一个键"name",并且它必须有一个以*"first_name"或"name"为键的字典作为值。任何一个的值都绑定到名称name。*
*Sub-patterns are powerful and expressive
以声明方式描述我们想要的东西的好处再次显现出来。在您的应用程序中拥有一个漂亮整洁的数据结构(以及理解它的代码)并不少见,但是,像大多数事情一样,它会随着时间的推移而发展和变化。事实上,您仍然需要同时支持遗留格式和较新的格式。 OR Patterns 结合在现有模式中嵌入子模式的能力,使得它具有可读性、表达性,并且扩展和理解起来很简单。
When you bind a name in an OR Pattern it must be present in all OR patterns
注意,在第 6 行,我将键"first_name"和"name"的值绑定到了name。不可能在一个或模式的一部分有一个绑定变量,而在另一部分没有。如果这是可能的,那将意味着一些有界变量将是未定义的,难以推理。
There are no equivalent AND patterns or NOT patterns
你只能得到或者模式。但这通常没问题;您可以约束您定义的模式来精确匹配您所需要的,这应该有望消除对而不是模式和以及模式的需要。
通配符模式
通常你想把和搭配起来,以表明你根本不在乎实际值,只是因为那里有和。在 Python 中,这个角色一直由_担当:
_, name = ["Greetings", "Elaine"]
这是一种模式。您可能在一些示例的末尾看到过这种模式:
match p: # ... etc ... case _: # ... do something. ...
那是一个通配符符号,它匹配任何东西。因为您可以将整个 subject 表示为_,所以当其他case子句都不匹配时,它可以作为一个后备来匹配任何内容。
您也可以用它们来查询结构,例如,忽略列表中您不关心的元素:
def wildcardpattern(p): match p: case [_, middle, _]: print(middle)
模式[_, middle, _]从正好三个元素的列表中提取倒数第二个元素。你不能引用通配符元素,因为它们是未绑定的;它们没有名称,也不能使用。任何在代码块中使用_的尝试都将寻找实际的变量_,如果这样的变量在作用域内的话。
但是,如果您愿意,您可以用as指定一个通配符来绑定它:
def wildcardpattern(p): match p: case [_ as first, middle, _ as last]: print(middle)
但是这看起来相当迟钝,所以我建议您避免这样做,而是使用您自己选择的绑定名称。
您还可以使用*rest语法来表示任意的元素序列,或者使用**kwargs来表示关键字参数,如下所示:
def star_wildcard(p): match p: case [_, _, *rest]: print(rest) case {"name": _, **rest}: print(rest)
该模式返回*rest,一个未知数量的元素序列,前提是它前面有两个匿名(通配符)元素:
>>> star_wildcard([1,2,3,4,5]) [3, 4, 5]
它的行为与您对字典的预期一样:
>>> star_wildcard({"name": "Cosmo", "age": 42, "last_name": "Kramer"}) {'age': 42, 'last_name': 'Kramer'}
尽管 Python 在推断列表或字典的结构方面相当聪明,但是一次不能有一个以上的*rest或**kwargs标记。所以如果你想要复杂的 Prolog 风格的有限关系和回溯,你需要自己做一些跑腿的工作。
Do not bind things you do not need
尽管您可以将大多数东西绑定到一个模式中,但是如果您不需要绑定,您应该避免这样做。通配符指示 Python 忽略该值,这样模式匹配器可以决定最有效的方式来返回您所关心的绑定名称。
如果您不关心绑定值,那么最好使用*_和**_而不是命名变量。
You can use wildcards in guards
因此,这是完全合理的,并且是一种有效的方式来约束一个模式,使其超出单独使用一个模式所能合理实现的范围:
match p: case [_, _, *rest] if sum(rest) > 10: print(rest)
价值模式
这可能是 Python 模式匹配实现中最有争议和争论的部分。
到目前为止,我写的所有东西都与静态模式有关。也就是说,我将它们输入到一个 Python 文件中,并且没有以任何方式在模式本身中包含从常量、变量或函数参数中导出的值。题材,没错,但不是图案。
回想一下,捕获模式是模式的值被绑定到名称的地方。
当您编写这样的代码时,问题就出现了:
PREFERRED_GREETING = "Hello" def value_pattern(p): match p: case {"greeting": PREFERRED_GREETING, "name": name} as d: print(d) case _: print("No match!")
它看起来很好,而且很有效。但是有一个问题。PREFERRED_GREETING是一个绑定名称,它隐藏了同名的模块常量。
所以结果是:
>>> value_pattern({"greeting": "Salutations", "name": "Elaine"}) {'greeting': 'Salutations', 'name': 'Elaine'}
这不是我们想要的答案。遗漏"greeting"键,它将完全不匹配:
>>> value_pattern({"name": "Elaine"}) No match!
原因是关于语法的一个未解决的争论。在典型的使用模式匹配的语言中,比如 LISP,你可以(这里简化一点)用引用或取消引用来表示它是(或者不是)一个变量或符号。
Python 没有这个功能。我承认,这是一个难以解决的问题,因为语法和符号会变得更加复杂,而且概念仅限于语言的这一特性。基本上,如果有一种方法可以将PREFERRED_GREETING标记为值(也许是.PREFERRED_GREETING或$PREFERRED_GREETING——没关系)或者反过来,那么上面看到的问题就可以得到解决:每个捕获模式都可以与来自该模式之外的值明确区分开来。
使用值模式的唯一方法是将值放在 Python 可以推断出需要属性访问的地方。
import constants def value_pattern_working(p): match p: case {"greeting": constants.PREFERRED_GREETING, "name": name} as d: print(d) case _: print("No match!")
这是可行的,因为constants是一个模块,而getattr(constants, 'PREFERRED_GREETING')是属性访问的一个例子。另一种方法是将常量放入枚举中,如果可以的话,这是一种更好的方法。枚举是象征性的,它捕获了一个名字和一个值,当你把它和模式匹配结合起来时,它就是一场天作之合。
You cannot use plain variables, arguments or constants
Python 把它们和捕获模式混淆了,这是一个大混乱。在可能的情况下,您应该避免将值传递到模式匹配引擎中,除非您在属性查找之后对它们进行门控(例如,some_customer.user_id而不是user_id)
This is likely to be a source of bugs
小心行事,决定用一种标准的方式来表示您希望与模式匹配引擎共享的常量或变量值:
-
一个邓德类(
namedtuple,dataclasses 等)。)来存放您希望使用的值 -
一个简单的包装类,它公开了一个属性,该属性具有您希望在模式中使用的值
-
如果可能的话,使用枚举
-
在一个模块中存储常量和其他模块级的东西,并显式地引用它,就像这样:
constants.MY_VALUE
序列模式
序列是列表和元组,或者从抽象基类collections.abc.Sequence继承的任何东西。请注意,模式匹配引擎将而不是扩展任何类型的可重复项。
不像 Python 的其他部分那样,list("Hello")是生成字符串字符列表的合法方式,这个用例确实适用于这里。字符串和字节被视为文字模式,而不被视为序列模式。
正如您现在所看到的,列表和元组按照您期望的方式运行。
You cannot represent sets in a pattern
可以在 subject 中使用它们,但是不能在case子句中使用模式匹配或 set 构造。我建议你使用守卫来检查平等性,如果这是你想要做的。
映射(“字典”)模式
这里的映射意味着字典(或任何使用collections.abc.Mapping的东西),到目前为止您也已经看到了如何做。当您对字典进行模式匹配时,需要注意的一点是,您在case子句中指定的模式意味着针对主题的子集检查:
match {"a": 1, "b": 2}: case {"a": 1} as d: print(d)
case子句匹配全长字典。如果你不想让这么做,你应该用一个守卫来执行:
match {"a": 1, "b": 2}: case {"a": 1, **rest} as d if not rest: print(d)
守卫检查字典的其余部分是否为空,如果为空,则只允许匹配。
Dictionary entries must exist when the pattern matching takes place
依靠defaultdict创建元素作为模式匹配过程的副作用是行不通的,并且模式匹配尝试不会创建任何元素。匹配器使用对象的get(k)方法将主题的键和值与映射模式进行匹配。
班级模式
匹配像字典和列表这样的基本结构是有用的,但是在较大的应用程序中,您通常会在复合对象中获取这些知识,并依靠封装来呈现数据的同构视图。
幸运的是,Python 3.10 可以处理大多数对象结构,或者不需要工作,或者只需要很少的工作。
from collections import namedtuple Customer = namedtuple("Customer", "name product") def read_customer(p): match p: case Customer(name=name, product=product): print(f"{name}, you must really like {product}.")
namedtuple和dataclasses都使用了模式匹配引擎。如上例所示,从对象中提取属性确实非常简单。
>>> read_customer(Customer(name="George", product="bosco")) George, you must really like bosco.
现在让我们来考虑一个反模式。也就是说,将导致副作用的复杂代码放在定制类的__init__构造函数中:
class Connection: def connect(self): print(f"Connecting to server {self.host}") # ... do something complicated ... def __init__(self, host, port): self.host = host self.port = port self.connect() def parse_connection(p): match p: case Connection(host=host, port=port): print(f"This Connection object talks to {host}")
当你用一个给定的host和port创建一个Connection的实例时,调用connect()方法,作为一个演示,打印一条消息说它正在连接到主机。
>>> connection = Connection(host="example.com", port="80") Connecting to server example.com
>>> parse_connection(connection) This Connection object talks to example.com
请注意,Python 足够聪明,不会在模式匹配步骤中创建Connection的实例。(如果它这样做了,我们会看到另一条“连接到服务器”的消息。)
因此,即使你的方法有副作用,也有一些安全措施来避免直接导致它们。
话虽如此,如果可能的话,你应该把这种逻辑转移到一个专门为你工作的类方法中。
摘要
唷。这是一个很大的特性,在本系列的第二部分中,除了您在这里看到的相当简单的例子之外,我将向您展示一些真实的用例。
这是一个很大的特性,有很多问题——特别是围绕捕获和价值模式——但是我认为好处远远大于坏处。Python 3.11 很可能也有一个解决这个问题的完美方案。
我相信结构模式匹配会减少错误。尤其是当您处理不完整的数据或需要转换的结构化数据时。即使您不是数据科学家或者不从事 ETL 工作,这也是我们都需要做的一件常见的事情,我确信它会在大多数 Python 开发人员的心中占据一席之地。
Pattern matching is declarative not imperative
你应该考虑你在一个case子句中写的任何东西,以声明的方式表示数据的结构。在 Python 的其他地方,你没有能力限定你的数据结构是什么样子的(字典、命名元组或自定义对象等)。)而且还有选择性匹配和从数据中提取含义的能力。
从数据中转换和提取信息已经是一项艰巨的工作,但是 Python 的模式匹配库使它变得容易得多。
Beware Value and Capture Patterns
因为它们是同一的。不幸的是。我相信 Python 的未来版本会减弱这种尖锐的边缘,但是在这之前,您应该遵守我之前给出的建议,不要在没有首先在属性查找之后保护它的情况下将变量或常量传递给模式匹配引擎。
Pattern Matching encourages code without side effects
由于声明性的和(主要是!)Python 探查您编写的主题和模式的非侵入性方式。您应该考虑如何将这些概念应用到代码的其他部分。
如果您发现使用模式匹配引擎会在您的代码中产生副作用,那么我会花时间来思考您的代码是否做了正确的事情,如果您无法找到一种方法来做同样的工作。*
使用假设测试您的 Python 代码
Author Mickey Petersen
我可以想到几个 Python 包,它们极大地提高了我编写的软件的质量。其中两个是 pytest 和假说。前者添加了一个用于编写测试和夹具的人体工程学框架,以及一个功能丰富的测试运行程序。后者增加了基于属性的测试,可以使用聪明的算法找出除了最顽固的 bug 之外的所有 bug,这就是我们将在本课程中探索的包。
在一个普通的测试中,您通过生成一个或多个要测试的输入来与您想要测试的代码交互,然后验证它是否返回正确的答案。但是,这又提出了一个诱人的问题:你没有测试的所有输入怎么办?你的代码覆盖工具可能会报告 100%的测试覆盖率,但是这并不意味着代码没有 bug。
假设的一个定义特性是它能够以如下方式自动生成测试用例:
Reproducible
重复调用您的测试会产生可重复的结果,即使假设使用随机性来生成数据。
Methodical
你会得到一个详细的答案,解释你的测试是如何失败的以及为什么失败。假设清楚地表明了你,人类,如何重现导致你的测试失败的不变量。
Configurable
你可以改进它的策略,告诉它应该或不应该搜索哪里或什么。如果代码生成了无意义的数据,你没有必要为了迎合假设而修改代码。
所以让我们看看假设如何帮助你发现代码中的错误。
安装和使用假设
你可以通过输入pip install hypothesis来安装假设。它几乎没有自己的依赖项,应该可以在任何地方安装和运行。
默认情况下,Hypothesis 会插入 pytest 和 unittest,因此您不必做任何事情就可以让它工作。此外,Hypothesis 附带了一个 CLI 工具,您可以使用hypothesis调用它。但一会儿我们会详细讨论这个问题。
我将自始至终使用 pytest 来演示假设,但是它与内置的 unittest 模块一起工作也很好。
一个简单的例子
在我深入研究假说的细节之前,让我们从一个简单的例子开始:一个天真的 CSV 作者和读者。一个看起来很简单的话题:用逗号分隔数据字段,然后再读回来有多难?
但是,当然 CSV 是非常难做对的。美国和英国使用'.'作为十进制分隔符,但在世界上的大部分地区,他们使用',',这当然会导致立即失败。于是你开始引用事物,现在你需要一个状态机,可以区分引用的和未引用的;嵌套的引号呢,等等。
naive CSV reader 和 writer 是许多复杂项目的优秀替代者,这些复杂项目的需求表面上看起来很简单,但是存在大量您必须考虑的边缘情况。
def naive_write_csv_row(fields): return ",".join(f'"{field}"' for field in fields) def naive_read_csv_row(row): return [field[1:-1] for field in row.split(",")]
在这里,作者只需在用','将每个字段连接在一起之前用引号将它们串起来。读者做相反的事情:它假设每个字段在被逗号分割后都被引用。
一个简单的往返 pytest 证明了代码“有效”:
def test_write_read_csv(): fields = ["Hello", "World"] formatted_row = naive_write_csv_row(fields) parsed_row = naive_read_csv_row(formatted_row) assert fields == parsed_row
显然如此:
$ pytest test.py::test_write_read_csv test.py::test_write_read_csv PASSED [100%]
对于许多代码来说,这是测试开始和结束的地方。几行代码来测试几个函数,这些函数以任何人都能阅读和理解的方式表现出来。现在让我们看看假设检验是什么样子的,以及当我们运行它时会发生什么:
import hypothesis.strategies as st from hypothesis import given @given(fields=st.lists(st.text(), min_size=1, max_size=10)) @example([","]) def test_read_write_csv_hypothesis(fields): formatted_row = naive_write_csv_row(fields) parsed_row = naive_read_csv_row(formatted_row) assert fields == parsed_row
乍一看,这里没有什么是你猜不到的,即使你不知道假设。我要求参数fields有一个从生成文本的一个元素到十个元素的列表。除此之外,测试的运行方式与之前完全相同。
现在看看我运行测试时会发生什么:
$ pytest test.py::test_read_write_csv_hypothesis E AssertionError: assert [','] == ['', ''] test.py:44: AssertionError ----- Hypothesis ---- Falsifying example: test_read_write_csv_hypothesis( fields=[','], ) FAILED test.py::test_read_write_csv_hypothesis - AssertionError: assert [','] == ['', '']
假说很快找到了一个破坏我们代码的例子。事实证明,[',']列表破坏了我们的代码。在通过 CSV 编写器和读取器来回传递代码之后,我们得到了两个字段——发现了我们的第一个 bug。
简而言之,这就是假设的作用。但是我们来详细看一下。
理解假说
使用假设策略
简而言之,假设使用许多可配置的策略生成数据。策略从简单到复杂。一个简单的策略可能会产生布尔;另一个整数。您可以组合策略来制作更大的列表,例如匹配您想要测试的特定模式或结构的列表或字典。您可以基于某些约束来限制它们的输出,比如只有正整数或特定长度的字符串。如果有特别复杂的需求,也可以自己写策略。
策略是进入基于属性的测试(??)的大门,也是假设如何工作的基本部分。您可以在其文档的策略参考或hypothesis.strategies模块中找到所有策略的详细列表。
感受每种策略在实践中的作用的最佳方式是从hypothesis.strategies模块导入它们,并在实例上调用example()方法:
>>> import hypothesis.strategies as st >>> st.integers().example() 14633 >>> st.lists(st.floats(), min_size=5).example() [-3.402823466e+38, inf, -1.7976931348623157e+308, 3.330825410893303e+16, -2.2250738585072014e-308]
您可能已经注意到,floats 示例在列表中包含了inf。默认情况下,所有的策略都会——在可行的情况下——尝试测试你能产生的所有合法的(但可能是模糊的)形式的价值。这一点尤其重要,因为像inf或NaN这样的极限情况是合法的浮点值,但是,我想,你通常不会对自己进行测试。
这也是假说试图在你的代码中发现错误的一个支柱:通过测试你自己可能会忽略的边缘情况。如果你问它一个text()策略,你很可能得到西方字符,因为你是一个 unicode 和转义编码垃圾的大杂烩。理解为什么假设会产生它所产生的例子,这是一种思考你的代码如何与它无法控制的数据交互的有用方法。
现在,如果它只是从无穷无尽的数字或字符串来源中生成文本或数字,它就不会像实际上的和一样捕捉到那么多错误。原因是你写的每一个测试都受到一系列从你设计的策略中抽取的例子的影响。如果一个测试用例失败了,它会被放在一边再次测试,但是如果可能的话,会减少输入的子集。在假设中,这被称为缩小搜索空间,试图找到可能导致代码失败的最小结果。因此,如果它能找到一个只有 3 或 4 的字符串,而不是 10,000 长度的字符串,它会尝试向您显示。
过滤和映射策略
如果策略不符合您的要求,您可以将假设告诉filter或map它所举的例子,以进一步减少假设:
>>> st.integers().filter(lambda num: num > 0 and num % 8 == 0).example() 20040
这里我要求的是数字大于 0 且能被 8 整除的整数。然后,假设将尝试生成满足您对其施加的约束的示例。
你也可以map,它的工作方式和 filter 差不多。这里我要求小写 ASCII,然后大写:
>>> st.text(alphabet=string.ascii_lowercase, min_size=5).map(lambda x: x.upper()).example() 'RDMBYRRONWQRZWHREEH'
话虽如此,当您没有to(我可以要求以大写 ASCII 字符开始)时,使用任一种都可能导致较慢的策略。
第三个选项flatmap,让你从策略中建立策略;但这值得更仔细的研究,所以我稍后会谈到它。
写作策略
您可以通过用|或st.one_of()组合策略来告诉假设从的多个策略中选择*:*
>>> st.lists(st.none() | st.floats(), min_size=3).example() [2.00001, None, 1.1754943508222875e-38]
当您必须从多个来源的示例中提取单个数据点时,这是一个基本特性。
约束和可满足性
当你让假设举一个例子时,它会考虑你可能对它施加的约束:只有正整数;只有加起来正好是 100 的数字列表;您可能申请的任何filter()通话;诸如此类。这些都是制约因素。你拿走了曾经无限的(也就是说,相对于你从中得出例子的策略)的东西,并引入了额外的限制,约束了它能给你的可能的价值范围。
但是考虑一下,如果我通过了什么都不会产生的过滤器会发生什么:
>>> st.integers().filter(lambda num: num > 0).filter(lambda num: num < 0).example() Unsatisfiable: Unable to satisfy assumptions of example_generating_inner_function
在某一点上,假设将放弃,并宣布它无法找到任何满足该策略及其约束的东西。
Make sure your strategies are satisfiable
假设过一会儿就放弃了,如果它不能举出一个例子的话。通常这表明你设置的约束中有一个不变量,使得很难或不可能从中得出例子。在上面的例子中,我要求同时小于零和大于零的数字,这是一个不可能的要求。
用函数编写可重用策略
如您所见,策略是简单的函数,它们的行为也是如此。因此,您可以将每个策略重构为可重用的模式:
import string def generate_westernized_name(min_size=2): return (st.text(alphabet=string.ascii_letters, min_size=min_size) .map(lambda name: name.capitalize())) @given(first_name=generate_westernized_name(min_size=5)) def test_create_customer(first_name): # ... etc ...
这种方法的好处是,如果您发现假设没有考虑到的边缘情况,您可以在一个地方更新模式,并观察它对代码的影响。它既实用又可组合。
这种方法的一个警告是,你不能画出例子,并期望假设行为正确。所以我不建议你在一个策略上调用example()只是为了把它传递给另一个策略。
为此,你需要一个@composite装饰师。
陈述策略
如果前面的方法在本质上是毫不掩饰的功能性的,那么这种方法就是必须的。
@composite decorator 让您编写命令性的 Python 代码。如果您不能用内置的策略轻松构建您的策略,或者如果您需要对它发出的值进行更细粒度的控制,您应该考虑@composite策略。
不要像上面那样返回一个复合策略对象,而是使用一个特殊的函数来绘制示例,您可以在修饰函数中访问这个函数。
from hypothesis.strategies import composite @composite def generate_full_name(draw): first_name = draw(generate_westernized_name()) last_name = draw(generate_westernized_name()) return (last_name, first_name)
此示例绘制两个随机化的名称,并将它们作为元组返回:
>>> generate_full_name().example() ('Mbvn', 'Wfyybmlc')
请注意,@composite装饰器传入了一个特殊的draw可调用函数,您必须用它来绘制样本。你不能——嗯,你可以,但是你不应该——在你得到的策略对象上使用example()方法。这样做将破坏假设正确合成测试用例的能力。
因为代码是命令式的,你可以根据自己的喜好随意修改绘制的示例。但是如果给你一个你不喜欢的例子,或者一个打破了你不想测试的已知不变量的例子呢?为此,您可以使用assume()函数来陈述假设必须满足的假设,如果您试图从generate_full_name中提取一个例子的话。
假设first_name和last_name一定不相等:
from hypothesis import assume @composite def generate_full_name(draw): first_name = draw(generate_westernized_name()) last_name = draw(generate_westernized_name()) assume(first_name != last_name) return (last_name, first_name)
像 Python 中的assert语句一样,assume()函数教导假设什么是有效的例子,什么不是。你可以用它来产生复杂的复合策略。
如果你用@composite编写命令式策略,我建议你遵循以下经验法则:
Avoid filtering drawn examples yourself
如果你想画一系列的例子来初始化,比方说,一个列表或者一个自定义对象,它们的值满足一定的标准,你应该使用filter,在可能的情况下,使用assume来教导假设为什么你画的值并没有任何好处。
上面的例子使用assume()来教导假设first_name和last_name一定不相等。
Separate functional and non-functional strategies
如果你能把你的功能策略放在不同的功能中,你应该这样做。它鼓励代码重用,如果您的策略失败了(或者没有生成您期望的那种示例),您可以依次检查每个策略。大型嵌套策略更难解开,更难推理。
Only write @composite strategies if you must
如果你能用filter和map或者内置的约束(比如min_size或者max_size)来表达你的需求,你应该这样做。使用assume的命令式策略可能需要更多的时间来集中在一个有效的例子上。
@example:明确测试某些值
偶尔,您会遇到一些失败或曾经失败的案例,您希望确保假设没有忘记测试它们,或者向您自己或您的开发伙伴表明某些值会导致问题,应该明确地进行测试。
装饰者就是这么做的:
from hypothesis import example @given(fields=st.lists(st.text(), min_size=1, max_size=10)) @example([","]) def test_read_write_csv_hypothesis(fields): # ... etc ...
你想加多少就加多少。
假设示例:罗马数字转换器
假设我想写一个简单的罗马数字转换程序。
SYMBOLS = { "I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000, } def to_roman(number: int): numerals = [] while number >= 1: for symbol, value in SYMBOLS.items(): if value <= number: numerals.append(symbol) number -= value break return "".join(numerals) def test_to_roman_numeral_simple(number): numeral = to_roman(number) assert set(numeral) and set(numeral) <= set(SYMBOLS.keys())
在这里,我将罗马数字收集到numerals中,一次一个,通过循环有效数字的SYMBOLS,从number中减去符号的值,直到 while 循环的条件(number >= 1)为False。
该测试也很简单,并作为一个烟雾测试。我生成一个随机整数,用to_roman转换成罗马数字。当该说的都说了,该做的都做了,我把数字串变成一个集合,并检查集合中的所有成员都是合法的罗马数字。
现在,如果我运行 pytest,它似乎会挂起。但由于假设的调试模式,我可以检查为什么:
$ pytest -s --hypothesis-verbosity=debug test_roman.py::test_to_roman_numeral_simple
Trying example: test_to_roman_numeral_simple(
number=4870449131586142254,
)
啊。它没有像人类通常做的那样用很小的数字进行测试,而是用了一个非常大的数字……非常慢。
好了,至少有一个问题。这其实并不是一个 bug ,但却是需要考虑的事情:限制最大值。我只打算限制这个测试,但是在代码中限制它也是合理的。
将max_value更改为合理的值,比如st.integers(max_value=5000),测试失败,并出现另一个错误:
$ pytest test_roman.py::test_to_roman_numeral_simple
Falsifying example: test_to_roman_numeral_simple(
number=0,
)
似乎我们的代码不能处理数字 0!哪个…是正确的。罗马人并没有像我们今天这样真正使用数字零;那项发明是后来才出现的,所以他们有一堆变通办法来处理某些东西的缺失。但在我们的例子中,这并不重要。让我们也设置min_value=1,因为也不支持负数:
$ pytest test_roman.py::test_to_roman_numeral_simple
1 passed in 0.09s
好吧…还不错。我们已经证明,在我们定义的数值范围内给定一个随机的数字组合,我们确实会得到类似于罗马数字的东西。
关于假设的最困难的事情之一是以一种测试其属性的方式将问题框定到你的可测试代码中,但是没有你,开发者,预先(必然地)知道答案。因此,测试我们的to_roman函数是否至少有些半相干的东西的一个简单方法是检查它是否能生成我们之前在SYMBOLS中定义的数字:
@given(numeral_value=st.sampled_from(tuple(SYMBOLS.items()))) def test_to_roman_numeral_sampled(numeral_value): numeral, value = numeral_value assert to_roman(value) == numeral
在这里,我从前面的SYMBOLS元组中的采样。采样算法将决定它想给我们什么值,我们所关心的是给我们像("I", 1)或("V", 5)这样的例子来比较。
所以让我们再次运行 pytest:
$ pytest test_roman.py
Falsifying example: test_to_roman_numeral_sampled(
numeral_value=('V', 5),
)
FAILED test.py::test_to_roman_numeral_sampled -
AssertionError: assert 'IIIII' == 'V'
哎呀。罗马数字V等于5,然而我们得到了五个IIIII?更仔细的研究发现,事实上,代码只有产生的序列I等于我们传递给它的数。我们的代码中有一个逻辑错误。
在上面的例子中,我遍历了SYMBOLS字典中的元素,但是因为它是有序的,所以第一个元素总是I。由于最小的可表示值是 1,我们最终得到了这个答案。从技术上来说,是正确的,因为你可以只用I来计数,但它不是很有用。
尽管修复它很容易:
import operator def to_roman(number: int): numerals = [] g = operator.itemgetter(1) ordered_numerals = sorted(SYMBOLS.items(), key=g, reverse=True) while number >= 1: for symbol, value in ordered_numerals: if value <= number: number -= value numerals.append(symbol) break return "".join(numerals)
重新运行测试会产生一个通过。现在我们知道,至少我们的to_roman函数能够映射等于SYMBOLS中任何符号的数字。
现在,试金石是接受给我们的数字,并理解它。因此,让我们编写一个将罗马数字转换回十进制的函数:
def from_roman(numeral: str): carry = 0 numerals = list(numeral) while numerals: symbol = numerals.pop(0) value = SYMBOLS[symbol] carry += value return carry @given(number=st.integers(min_value=1, max_value=5000)) def test_roman_numeral(number): numeral = to_roman(number) value = from_roman(numeral) assert number == value
像to_roman一样,我们遍历每个字符,获得数字的数值,并将其添加到运行总数中。该测试是一个简单的往返测试,因为to_roman有一个反函数 from_roman(反之亦然),因此 :
assert to_roman(from_roman('V')) == 'V' assert from_roman(to_roman(5)) == 5
By the way …
可逆函数更容易测试,因为您可以将一个函数的输出与另一个函数的输入进行比较,并检查它是否产生原始值。但是,并不是每个函数都有反函数。
运行测试会产生一个通过:
$ pytest test_roman.py::test_roman_numeral
1 passed in 0.09s
所以现在我们处于一个非常好的位置。但是在我们的罗马数字转换器中有一个小小的疏忽:它们不尊重一些数字的减法规则。例如VI为 6;但是IV是 4。值XI是 11;而IX是 9。只有一些(叹气)数字展现了这种属性。
所以我们再写一个测试。这一次它会失败,因为我们还没有编写修改后的代码。幸运的是,我们知道我们必须适应的减法数字:
SUBTRACTIVE_SYMBOLS = { "IV": 4, "IX": 9, "XL": 40, "XC": 90, "CD": 400, "CM": 900, } @given(numeral_value=st.sampled_from(tuple(SUBTRACTIVE_SYMBOLS.items()))) def test_roman_subtractive_rule(numeral_value): numeral, value = numeral_value assert from_roman(numeral) == value assert to_roman(value) == numeral
很简单的测试。检查某些数字是否产生值,以及这些值是否产生正确的数字。
有了一个广泛的测试套件,我们应该有信心对代码进行修改。如果我们弄坏了什么东西,我们之前的测试就会失败。
def from_roman(numeral: str): carry = 0 numerals = list(numeral) while numerals: symbol = numerals.pop(0) value = SYMBOLS[symbol] try: value = SUBTRACTIVE_SYMBOLS[symbol + numerals[0]] numerals.pop(0) except (IndexError, KeyError): pass carry += value return carry
数字减去 ?? 的规则是相当主观的。字典里有最常见的。所以我们需要做的就是在数目字列表前面读一下,看看是否存在一个两位数的数字,它有一个规定的值,然后我们用它来代替通常的值。
1def to_roman(number: int): 2 numerals = [] 3 g = operator.itemgetter(1) 4 ordered_numerals = sorted( 5 (SYMBOLS | SUBTRACTIVE_SYMBOLS).items(), 6 key=g, 7 reverse=True, 8 ) 9 while number >= 1: 10 for symbol, value in ordered_numerals: 11 if value <= number: 12 numerals.append(symbol) 13 number -= value 14 break 15 return "".join(numerals)
变化很简单。两个数字符号字典的结合就是 。代码已经知道如何将数字转化为数字——我们只是增加了一些。
By the way …
此方法需要 Python 3.9 或更高版本。阅读 如何合并字典
如果操作正确,运行测试应该会通过:
$ pytest test_roman.py
5 passed in 0.15s
仅此而已。我们现在有了有用的测试和一个功能性的罗马数字转换器,可以轻松地进行转换。但是我们没有做的一件事是创建一个使用st.text()生成罗马数字的策略。生成有效和无效罗马数字以测试转换器耐用性的自定义组合策略留给您作为练习。
在本课程的下一部分,我们将探讨更高级的测试策略。
摘要
Hypothesis is a capable test generator
不像faker这样的工具为设备或演示生成逼真的测试数据,假设是一个基于属性的测试器。它使用试探法和聪明的算法来寻找破坏你的代码的输入。
Hypothesis assumes you understand the problem domain you want to model
测试一个没有反函数来比较结果的函数——就像我们双向工作的罗马数字转换器——你经常不得不把你的代码当作一个黑盒来处理,在那里你放弃对输入和输出的控制。这更难,但会使代码不那么脆弱。
Hypothesis augments your existing test suite
混合搭配测试完全没问题。假设对于清除你永远不会想到的不变量是有用的。将它与已知的输入和输出结合起来,开始前 80%的测试,并增加假设来抓住剩下的 20%。
塔防游戏:游戏模式和碰撞检测
Author Mickey Petersen
我们已经为投射物和敌人找到了路径和运动。现在是考虑碰撞检测的时候了。我们的炮塔需要一种方法来检测他们视线范围内的敌人,我们希望我们的飞行弹丸能够检测到他们何时击中了敌人。
塔防游戏:寻路
Author Mickey Petersen
与其让关卡设计师画出敌人行走的路径,不如用图论和一点递归自动生成一条路径。我们想要一种算法,如果有不止一个入口或出口,也能生成所有合法的路径组合。
塔防游戏:动画和运动学
Author Mickey Petersen
一个塔防游戏需要移动:敌人应该缓慢地穿过战场,我们的炮塔的视野应该充满威胁地扫过,寻找可以发射炮弹的目标。
同时,我们也需要考虑路径寻找和动画。实际上,它们都是相关的,因为它是关于运动——运动学。
所有这些事情都发生在几秒钟和几分钟内,我们必须将它们封装到一个系统中,该系统与我们的游戏循环和我们想要的帧速率一起工作。有很多方法可以做到这一点,比如用累加器变量来保存状态的循环。但是还有另一种方法:用itertools、生成器和懒惰评估。
塔防游戏:磁贴引擎和地图编辑器
Author Mickey Petersen
我们的塔防游戏需要一个简单的类似网格的系统来放置某些精灵,比如道路,因为这样可以更容易地在以后建立一个路径查找算法,这样敌人就可以从头到尾找到他们的路。
尽管网格很有用,但我们不会仅限于此;灌木、树木和其他图形装饰根本不需要网格,所以无论我们建造什么,它都应该适用。
塔防游戏:处理精灵
Author Mickey Petersen
精灵是你在屏幕上绘制的实体——通常是图形。在我们的塔防游戏中,我们需要很多这样的东西:投射物、敌人、构成游戏区域的植物以及 HUD 的文本。
更复杂的是,它们都需要自己独特的功能来使我们的游戏感觉完整和专业:我们的弹丸在空中飞行,所以它们需要移动和旋转。当它们在撞击时爆炸,它们需要分裂成碎片,所以我们需要考虑动画和碰撞检测。我们的敌人需要移动和死亡动画,他们应该沿着从产卵到逃跑点的路径平稳地行走。
是时候让我们看看如何在 PyGame 中建造精灵了。
塔防游戏:基本游戏模板
Author Mickey Petersen
是时候开始处理事件并把东西画到屏幕上了。基于之前的状态机和控制器层,我们需要一个简单的抽象来表示我们需要的每个屏幕。幸运的是,通过类继承和一点前瞻性的思考,有一个简单的方法可以做到这一点。
塔防游戏:有限状态自动机/状态机
Author Mickey Petersen
复杂的代码库——游戏通常也很复杂——往往依赖于大量的状态,通常在变量中捕获。从游戏中的一个屏幕导航到另一个屏幕涉及到很多变化:你需要渲染不同的东西;您使用的键绑定也可能会改变;也许你需要清除旧的对象,比如当你从游戏切换到比分屏幕时。
但是没有不断增加的变量来表示你的代码应该做什么——比如,is_in_menu,has_won_game,is_in_level_editor等等。–你应该考虑使用有限状态自动机,或者通常称为状态机,形式化你的有状态代码。
为了提高我们代码的可扩展性,是时候考虑我们如何有效地使用简单的状态机来表示游戏的状态,以及 OOP 和继承如何帮助关注点的分离。
什么是有状态?
所有有用的计算机程序都捕捉某种形式的状态 ??。在一个玩具应用程序中,询问你的名字并重复它,它可能是一个名为name的变量,使我们的应用程序有状态——也就是说,我们的程序正在存储,并可以随意调用我们显式给它的信息,或者它通过从文件或数据库中读取的东西隐式生成的信息。不管如何,我们通常将它存储在变量中,或者更一般地,存储在内存的某个地方,不管我们可能选择使用什么样的松散的内存定义。
那么,为什么有状态很重要呢?因为在简单的变量中捕捉重要的业务逻辑,并在此基础上添加一层又一层的信息实在是太容易了。
到处都是布尔人
考虑尝试编写一个有几个屏幕的简单游戏,以及如何跟踪它在哪个屏幕上:
-
当你开始游戏时第一次遇到的主菜单,或者当你退出游戏或地图编辑器时退出。
-
你玩真正游戏的主游戏
-
一个记录你的分数和表现的输赢屏幕
-
关卡编辑器屏幕
-
配置/选项屏幕
诸如此类。
天真地,你可以用无数的布尔值来存储你的游戏当前应该向用户显示的内容:
in_main_menu = True in_game_playing = False in_score_screen = False in_level_editor = False in_options_screen = False # ... etc ...
所以,每次你从一个屏幕(和游戏的功能部分)切换到另一个,你必须记得更新所有的布尔标志。
如果你不小心将两个设为True,你的游戏很可能会以尴尬的方式中断。你可以在屏幕上呈现两种不同的东西。此外,您可能有一组路径,用户必须通过这些路径才能到达某些屏幕;例如,从主菜单转到乐谱屏幕通常是不可能的。
这并不是说布尔方法不好。布尔变量非常有用,演示程序很好地利用了它们。
但是有一种更容易理解的方式来捕捉游戏的状态。
有限状态自动机/状态机
有限状态自动机只是计算机科学中计算模型的一个方面。我们不会深入讨论这个问题,因为对于大多数人来说,状态机的概念是非常直观的,尤其是对于程序员来说,他们经常在不知道这是一门正式学科的情况下创建状态机。
An example of the possible transitions our game is able to make. Each box represents a state and the directed arrows the possible paths.
你可能对流程图很熟悉,比如包含的例子。这是一个国家机器。从圆形开始,您可以按照箭头的方向从一个方块过渡到下一个方块。该示例与演示中使用的状态机非常相似。
它还显示了您可以或不可以转换到的位置和内容。不过,对于这种没有图表的直观游戏,对于非常大的游戏或应用程序,你会希望有一个工具能够将它们绘制到屏幕上。
在我们的情况下,我们可以保持简单。在演示的大部分地方,我不强制执行合法转换,除了对防止崩溃或其他严重问题至关重要的地方,但是在更大的应用程序中,您肯定希望这样做!
考虑一个电子商务网站。您需要确保在电子商务系统更新到ship_merchandise之前,客户首先经历has_paid状态!应用程序中的大量逻辑错误可直接归因于这种错误。
你可以用多种方式代表州。我认为用 Python 表示机器状态最简单的方法是使用来自enum模块的Enum类。
枚举类
import enum class GameState(enum.Enum): """ Enum for the Game's State Machine. Every state represents a known game state for the game engine. """ # Unknown state, indicating possible error or misconfiguration. unknown = "unknown" # The state the game engine would rightly be set to before # anything is initialized or configured. initializing = "initializing" # The game engine is initialized: pygame is configured, the sprite # images are loaded, etc. initialized = "initialized" # The game engine is in map editing mode map_editing = "map_editing" # The game engine is in game playing mode game_playing = "game_playing" # The game engine is in the main menu main_menu = "main_menu" # The game engine is rendering the game ended screen. game_ended = "game_ended" # The game engine is exiting and is unwinding quitting = "quitting" class StateError(Exception): """ Raised if the game is in an unexpected game state at a point where we expect it to be in a different state. For instance, to start the game loop we must be initialized. """
您可能想跳过这一步,只使用字符串。避免这种诱惑。字符串是有用的,它们完全能够表示对应用程序重要的信息,但是它们缺少枚举的一些特性:
Enums record a name and associated value for each member
枚举类就像常规类一样。您可以编写文档字符串并添加方法。每个枚举还记录名称(左侧)及其组成值(右侧)
每个枚举元素都记得它的name和value属性:
>>> GameState.starting <GameState.starting: 'starting'>
>>> GameState.starting.value 'starting'
>>> GameState.starting.name 'starting'
它们知道有效和无效的名称,您可以从它的值创建一个枚举元素:
>>> GameState("bad name") ValueError: 'bad name' is not a valid GameState
>>> GameState("game_ended") <GameState.game_ended: 'game_ended'>
Enums are typed, and aid with code completion and type hinting
因此,如果你声明一个变量或参数为GameState,你的编辑器将帮助你完成代码。
Enums are iterable and support membership testing
因此,您可以在循环中使用它们来捕捉所有元素:
>>> list(GameState) [<GameState.unknown: 'unknown'>, <GameState.starting: 'starting'>, <GameState.initialized: 'initialized'>, <GameState.map_editing: 'map_editing'>, <GameState.game_playing: 'game_playing'>, <GameState.main_menu: 'main_menu'>, <GameState.game_ended: 'game_ended'>, <GameState.quitting: 'quitting'>]
并检查成员资格:
>>> GameState.map_editing in GameState True
Enums are symbolic
这是需要理解的最重要的事情。枚举代表一个符号——在GameState中,每个值都是一个字符串,但它也可以是一个 int 或其他原始类型。但是通常精确的值并不重要,重要的是它在任何地方都是相同的和一致的。这真的是要记住的事情。你传递的不是一个字符串,或者一个整数,而是一个符号(GameState),带有name 和,是的,一个value。
这意味着您想要完美完成的所有常规条件检查。如果你使用IntEnum,你将获得枚举元素行为类似数字的优势,这意味着>、<等等也可以工作。
本课程将大量使用枚举来表示具有象征意义的事物。
把所有的放在一起
现在我们可以利用我们新发现的知识,从上一章开始,用一个基本的状态机来扩展TowerGame。
@dataclass class TowerGame: ... state: GameState ... @classmethod def create(cls, ...): return cls(..., state=GameState.initializing) def set_state(self, new_state): self.state = new_state def assert_state_is(self, *expected_states: GameState): """ Asserts that the game engine is one of `expected_states`. If that assertions fails, raise `StateError`. """ if not self.state in expected_states: raise StateError( f"Expected the game state to be one of {expected_states} not {self.state}" ) def start_game(self): self.assert_state_is(GameState.initialized) self.set_state(GameState.main_menu) self.loop() def loop(self): while self.state != GameState.quitting: if self.state == GameState.main_menu: # pass control to the game menu's loop elif self.state == GameState.map_editing: # ... etc ... elif self.state == GameState.game_playing: # ... etc ... self.quit() def init(self): self.assert_state_is(GameState.initializing) ... self.set_state(GameState.initialized)
这里我添加了几个助手函数来帮助处理状态转换。我们现在可以断言,无论何时,游戏状态是一个或多个已知状态。如果我们在调用init之前调用它,那么start_game就会出错。而init本身如果不在GameState.initializing状态就不会运行。
这意味着我们现在终于也可以写出一些主要的loop代码了:只要我们不处于quitting状态,它的 while 循环就会一直循环下去。目前,循环将检查我们是否处于许多游戏状态中的一个,并且——虽然还没有写出来,因为我们还没有到那一步——将控制权交给我们游戏代码的另一部分。
为什么会这样?因为这个loop是一个控制器。它的目标不是在屏幕上做任何繁重的绘图工作,也不应该处理键盘输入本身。你绝对可以用让做到这一点:你有一个screen可以利用,你还可以监听键盘和鼠标事件。那么为什么不这样做呢?嗯:
Every game state represents vastly different requirements
考虑一个主菜单。我们希望——因为我们在一定程度上接近演示——一个菜单项,可能还有一些花哨的图形效果和一个标志来展示我们的酷游戏。但那…不是我们想要的地图编辑器。其实和主菜单完全不一样。
那么你将如何处理两个非常矛盾的需求呢?用if语句。很多人。别忘了,你想要绘制到屏幕上的每一个对象、精灵或资源都需要从TowerGame中访问。因此,您最终会得到一个level属性来存储地图编辑器的级别细节;一个menu_group用于当你在主菜单中时呈现的菜单项;游戏本身的score和hud。
你最终会得到几十种不同的东西,这些东西只在某些情况下适用,而且只在某些游戏状态下适用。
我们将创建一个类结构来代表我们在屏幕上绘制东西所需要的一切,而不是将我们自己提交给一个混乱的开发体验;处理键盘和鼠标输入;以此类推,并在一个漂亮,干净,易于理解的结构。
演示的状态机是基本的,但是如果您愿意,您可以做一些事情来改进它:
Enforcing only legitimate transitions
目前,你可以用你喜欢的任何状态调用set_state,即使这样做没有意义。就像在已经是initialized之后又回到了initializing。您可以扩展它来检查和强制执行只有从当前状态的有效状态转换是可能的。这样,如果你把它发送到错误的状态,你的游戏就会出错。这是在较大的代码库中捕捉严重错误的有效方法。
Separating the state machine into a new class
不要把它集成到TowerGame中,你可以创建一个独立的类,它也接受一个状态枚举类作为它的转换源,而不是硬编码,就像我在TowerGame中做的那样。
后续步骤
我们有一个骨架类可以初始化我们的游戏,我们有一个状态机能够跟踪它的当前状态,并强制它在继续之前必须处于特定的状态。
如果你愿意,那里还有很多事情要做,但这已经足够让我们开始了。
现在我们需要构建一个简单的类结构,让我们能够表现一个游戏屏幕——从而表现一个独特的游戏状态——以及我们为什么要这么做。过了那个,就该开始构建游戏了!
摘要
State Machines are useful abstractions
它们对于需要排序的事情非常有用——例如,首先初始化,然后显示主菜单——但它们不是万能的。有时一两个布尔数更容易推理;你可能最终会有太多的州什么都不做,或者过于分散。
这是一个平衡的行为,所以把状态机想象成你工具箱中的另一个工具。
Enums represent symbolic values
它们有一个实际值和一个 Python 友好的属性名,但是它们的主要目的是消除函数之间传递的内容的任何模糊性。字符串很快就会失去上下文,如果你改变了它们,就需要小心翼翼地更新。
塔防游戏:游戏循环和初始化 PyGame
Author Mickey Petersen
你如何指导你的电脑游戏持续地在屏幕上画东西,并伴随着鼓点,确保没有尴尬的停顿或抖动?倾听键盘和鼠标的输入,或者更新你的游戏的比分板呢?
做错其中任何一件事,或者忘记去做,你的游戏就会出问题。更糟糕的是,它可能会以你在自己的机器上不一定会发现的方式出错。
这就是为什么所有的电脑游戏——无论大小——都有一个(或者更多!)游戏循环确保游戏以可重复且稳定的方式执行其最重要的任务。
是时候编写我们的骷髅游戏循环了,它将在我们塔防游戏的整个开发过程中为我们服务。
PyGame
如果你以前从未使用过 Pygame,有几件事你应该知道。Pygame 是我所说的“低级”库。它的主要目的是提供一个画布——字面意思是——你可以在上面画画,有各种各样有用的游戏开发原语和助手来帮助你构建。
开箱后,您将获得:
-
简单的雪碧 管理
-
用于简单碰撞检测的原语
-
基本混音
-
感谢 SDL 图书馆的软件或硬件加速的 2d 画布绘制
-
向量和仿射变换(如旋转和缩放)
-
键盘/鼠标和事件处理
-
许多绘图图元,如圆形、直线、矩形等。
By the way …
Sprite 是一个过时的术语,指绘制到屏幕上的 2d 图像。20 世纪 80 年代和 90 年代的游戏机通常都配备了针对精灵绘制和处理进行优化的硬件。
差不多就是这样。起初,这似乎不是一件好事,但对于 Python 开发人员来说,绝对是!
你将会学到更多用这些原语构建游戏的知识,并且,正如你将会看到的,一旦理解了基本概念,你就可以用这些原语做很多很酷的事情。
正在初始化 PyGame
初始化 PyGame 将遵循我们游戏的所有其他方面的相同初始化,看起来应该有点像这样:
-
游戏开始了
-
PyGame 被设置和初始化
-
我们需要的资产已经装载完毕
-
游戏进入下一个任务,不管是什么,比如显示菜单或介绍
然而,这里重要的是,我们想做点 2 和 3 一次。当 PyGame 初始化时,它必须在后台做大量的内务处理,它绝对不希望被初始化超过一次——如果你这样做的话,就等着崩溃和错误吧!
我们还应该确保只加载一次资产;正如你将会看到的,我们希望保持一个高的帧速率并且加载资产是一点也不快。
事实上,这也适用于许多其他事情:如果我们只需要做一次,我们应该努力不要做超过一次。听起来显而易见,但很容易错过一些东西,而且出错会影响游戏的帧率。
帧速率——以每秒帧来衡量——是我们更新屏幕的速率。60 FPS 是本教程的目标,但并不是所有的游戏都需要这么高(甚至更高)的帧速率。
让我们从导入 PyGame 开始:
>>> import pygame
现在初始化 PyGame。
def init(screen_rect: pygame.Rect, fullscreen: bool = False): pygame.init() window_style = pygame.FULLSCREEN if fullscreen else 0 # We want 32 bits of color depth bit_depth = pygame.display.mode_ok(screen_rect.size, window_style, 32) screen = pygame.display.set_mode(screen_rect.size, window_style, bit_depth) pygame.mixer.pre_init( frequency=44100, size=32, # N.B.: 2 here means stereo, not the number of channels to use # in the mixer channels=2, buffer=512, ) pygame.font.init() return screen
这个函数完成了相当多的工作,所以让我们来看看每个部分:
-
每个游戏都占据一定的屏幕大小——这就是
screen_rect的用途。正如你马上会看到的,我稍后会将它定义为我们的图形块大小的倍数。 -
如果我们指定那个函数参数,我们可以在
fullscreen模式下运行游戏 -
我们坚持 32 位色深。这样,计算机使用的三种颜色通道:红色、绿色和蓝色,每种通道有 8 位。另一个 8 位通道用于 alpha 通道,当像素被绘制到屏幕上时,它控制像素的透明度。
颜色深度很重要。我们经常使用半透明像素。因此,即使你对低位深度像素艺术感兴趣,你可能仍然想要 32 位,没有别的原因,只是为了使无缝阿尔法混合成为可能。
-
我们也用合理的默认值初始化混音器。
-
最后,我们初始化字体渲染引擎,这样我们可以稍后在屏幕上绘制文本。
现在,在这一点上,你可以用一个合适的矩形大小运行这个init函数:
>>> init(pygame.Rect(0, 0, 1024, 768)) <Surface(1024x768x32 SW)>
如果操作正确,应该会出现一个带有黑色画布的窗口。但是你很快就会意识到,它对事件没有反应;不是从你的键盘或鼠标,也不是从你的操作系统!您的操作系统将很快报告它似乎挂起。
这是因为我们没有响应来自操作系统的事件。操作系统在这里完成所有繁重的工作:它提取数据,从你的外围设备中读取数据,并将它们传输到窗口和底层事件循环,这些循环通常会处理那些事件消息。
那么,我们如何处理这些事件呢?
游戏循环
简化到最简单的部分,一个游戏循环只不过是一个无限循环,其中有一些东西:
while True: do_something() do_something_else()
当然,事情从来没有这么简单。首先,你需要将那些你只想运行一次的事情——比如初始化 py game——和那些你肯定想多次运行的事情分开,比如在屏幕上画图,或者从鼠标或键盘上读取事件。
事实上,我们的游戏循环的存在是为了集中我们必须在每一帧做的活动。其中一些是:
-
把东西画到屏幕上
-
告诉我们所有的精灵更新他们自己:敌人不断穿越他们正在走的道路;子弹不停的飞。
-
检查是否有我们应该处理的碰撞:炮塔发现敌人;或者子弹击中敌人。
-
处理操作系统和键盘/鼠标事件
-
…以及检查我们是赢了还是输了游戏之类的事情;是否会滋生更多的敌人;等等。
最后,指示 PyGame 等待下一个周期的运行。
游戏刻度、帧数和循环
一个游戏滴答是你在上面看到的游戏循环的一次完整迭代。
如果我们的目标是每秒 60 帧,那么我们可以计算出我们在每个游戏滴答上可以花费的最多时间(或者说我们可以花费在准备单帧上的时间):
frame length = 1 second / desired frame rate
正如我解释我们的游戏循环一样,这大约是我们必须做上述所有事情的最大 16 毫秒的时间。如果你花的时间太长,你的帧速率会下降,低于预定目标;更糟糕的是,如果你的游戏循环运行时间变化很大,你将会有滞后和不稳定的控制。
然而,过高的帧速率也会使我们的游戏变得不必要的复杂。一般来说,更容易将物理(在我们的例子中,子弹行进的速度,或敌人移动的速度)等事情与游戏的帧速率联系起来。
这样,我们就可以对某事物的变化率作出一般的陈述,它是游戏节拍的倍数。例如,如果一颗子弹穿过游戏地图,覆盖 1000 个像素,移动一个像素需要多长时间?最重要的是,这是一个游戏设计的决定,而不是一个技术决定。
如果我们希望它在 2 秒内从地图的一端移动到另一端,那么它应该移动:
pixels per tick = 1000 pixels / (60 fps * 2 seconds)
大约是每刻度 8.3 个像素。
这是一种简单而有效的测量每分笔成交点变化率的方法。这种方法在这里工作得很好,因为您可以用简单的分数直观地进行推理:30 个刻度是半秒;60 是一秒;120 是 2。
因此,我们需要跟踪我们想要的帧速率。
DESIRED_FPS = 60
我将使用 Python 中常见的大写字母符号,它们是常量和,并在模块级全局定义。在演示中,您可以在constants.py中找到它们。
通常,你会使用一个计时器来计算你的游戏循环执行了多长时间,然后等待期望帧长度和实际花费时间之间的差,以保持稳定的帧速率。
在 PyGame 中,我们不必这样做,因为 PyGame 可以为我们做到:
clock = pygame.time.Clock() while True: # ... do game loop stuff ... pygame.display.set_caption(f"FPS {round(clock.get_fps())}") clock.tick(DESIRED_FPS)
PyGame 会计算出需要等待多长时间来保持你的帧速率。帧速率会略有不同;它会向上或向下移动几帧,但毫无疑问,这主要归功于 Python 的垃圾收集器。
设计游戏循环
我谈论一个游戏循环,单个的,好像一个游戏应该只有一个。但是你可以很容易地拥有不止一个;当我们讨论有限状态自动机的时候,我们会谈到原因。多个游戏循环的主要好处是,我们可以为游戏的每个主要的、独特的部分都有一个循环:主菜单、游戏、输赢分数屏幕和地图编辑。
为什么?嗯,我们希望将我们的关注点分开,意思是彼此无关的东西:例如,主菜单和输赢分数屏幕根本不应该知道彼此。混淆他们的状态——屏幕上的敌人和炮塔,地图等。都非常不同。你可以将它们合并成一个大的、讨厌的游戏循环,但是你最终会得到大量的if语句!
最好想一个简单的设计来避免这种混乱。在演示中,正如我在这里将要做的,我将使用类和基本的 OOP 原则来分离我们的关注点。
不用说,设计游戏循环背后的总体想法是让它足够柔韧,以服务于不同的用例,而不是用不必要的抽象给它镀太多的金。
所以我们想要的是一个类作为我们游戏的主要入口点,然后可以将控制权交给游戏循环:
-
它必须有一些初始化 PyGame 和任何其他一次性的行动。
-
它应该封装这些初始化动作的结果,就像我们通过调用
init()得到的screen对象一样。 -
当事件发生时,它应该知道如何将工作传递给游戏的其他部分:玩家想要编辑地图;玩家输掉或赢得游戏;等等。
这里有一个粗略的模板让我们开始。我们会继续添加内容。
from dataclasses import dataclass @dataclass class TowerGame: screen: pygame.Surface screen_rect: pygame.Rect fullscreen: bool @classmethod def create(cls, fullscreen=False): game = cls( screen=None, screen_rect=SCREENRECT, fullscreen=fullscreen, ) game.init() return game def loop(self): pass def quit(self): pygame.quit() def start_game(self): self.loop() def init(self): pygame.init() window_style = pygame.FULLSCREEN if self.fullscreen else 0 # We want 32 bits of color depth bit_depth = pygame.display.mode_ok(self.screen_rect.size, window_style, 32) screen = pygame.display.set_mode(self.screen_rect.size, window_style, bit_depth) pygame.mixer.pre_init( frequency=44100, size=32, # N.B.: 2 here means stereo, not the number of channels to # use in the mixer channels=2, buffer=512, ) pygame.font.init() self.screen = screen
我们需要引入一个新的常数SCREENRECT,它代表画布的宽度和高度,也就是我们在初始化时从 PyGame 得到的屏幕表面:
# Replace width and height with the desired size of the game window. SCREENRECT = pygame.Rect(0, 0, width, height)
loop方法目前还是空白。我们以后再处理。你现在知道的init方法:它是以前的方法,但是稍微修改了一下,以便更好地利用类。
值得注意的是方法create,一个类方法,以及@dataclass的使用。两者都是有用的模式,所以现在让我们来讨论一下它们,因为它们会定期出现。
什么是数据类?
用 Python 编写类的一个恼人的特性是必须手动分配传递给构造函数的属性:
class SomeClass: def __init__(self, a, b, ...): self.a = a # ... etc ...
另一个是生成一个__repr__方法,打印对象内部状态的漂亮表示,这对于 Python shell 中的调试和交互式开发非常有用。
这些只是dataclasses模块旨在解决的众多特性中的两个。它能做的远不止这些。但这是我们目前的两个主要优势。
不是创建一个显式的构造函数,而是在应用了@dataclass decorator 之后,使用 Python 的类型注释来表达类的需求:
@dataclass class MyClass: a: int b: str
如您所见,您可以在类本身上使用简单的类型提示来定义构造函数参数。还有一个fields方法,用于处理那些不能用类型提示轻松捕获的事情:比如自动创建对象的实例,并在实例化对象时将它们分配给属性。
虽然我在演示中使用了类型提示,但从技术上来说,你不必这样做。你可以用typing.Any来表示你不介意是什么类型。
我们将在本课程中大量使用数据类:它可以节省大量时间,让我们专注于比基本的家务管理更重要的事情。然而,正如您将看到的,PyGame 的一些内置类不使用 dataclasses,在这些情况下,我们将不得不恢复到在构造函数中创建和赋值的“经典”方法。
因为您用构造函数应该具有的属性来注释您的类,所以您实际上根本不需要__init__构造函数。其实必须用__post_init__来代替。但是正如您将在下面看到的,当您必须在对象实例化期间执行某些操作时,通常会有更聪明的方法来实现它。
因为这是一个值得单独开设一门课程的广泛话题,所以我会让你参考data class 文档以获得更多信息。
create工厂类方法
我在 Python(或其他语言)中避免做的一件事就是复杂的__init__构造函数。用 Python 创建构造函数太容易了,结果做了太多事情。因为构造函数总是被调用,所以当一个类被实例化时,你永远不能真正地告诉 Python 类,当你实例化这个对象时,请不要在构造函数中做一些你要做的事情——不能没有构造函数本身的特征标志,或者一些其他形式的解决方法,比如从它继承并希望你能以那种方式解决它。
以此为例:
class Foo: def init(self): # ... as before ... def __init__(self, screen, screen_rect): self.screen = screen self.screen_rect = screen_rect self.init()
这是一种非常常见的模式。人们接受一些参数——这没问题——并把它们存储在对象上(这也很好),然后他们进行一个或多个复杂的操作——改变对象内部或外部状态的“一次性”操作。在我们的例子中,一堆只能做一次的事情,比如通过调用self.init()初始化 PyGame。
但是,如果我不希望这样呢?也许我需要两个对象,但只初始化了其中一个。也许我传入一个已经初始化的屏幕,这样我就不需要初始化两次了?上面的代码没有这个用例的启示。这个问题在测试中很突出,因为你经常想要询问和测试类的一部分。
当然,您可以创建一个开关do_not_initialize或类似的东西,但是如果您有四个不同的可变动作呢?你有四个开关吗?谁来测试所有的组合工作?
回到create方法。它有一个@classmethod,所以它接受类 ( TowerGame作为cls参数),而不是像__init__那样接受实例。这意味着我们可以调用TowerGame.create(...)并让它返回一个对象,就像普通的实例化调用一样,但有一个额外的好处:
我们可以控制对象的初始化方式和参数(如果有的话)。在这里,我在实例化了TowerGame的实例后,立即调用上的 init ,从而确保它已经设置好了。
@classmethod def create(cls, fullscreen=False): game = cls( screen=None, screen_rect=SCREENRECT, fullscreen=fullscreen, ) game.init() return game
但是如果我想的话,我也可以做TowerGame(screen=existing_screen , ...),我不必担心重新初始化已经初始化的东西,因为构造函数不会自动初始化。
The create factory pattern is almost always better than a top-heavy constructor
您可以拥有任意多的这些类方法,您也不必将它们命名为create。
当我知道对象可能以多种方式使用时,我喜欢有一个,如果我觉得手动创建类很乏味,或者如果我想设置一些“合理的默认值”,我想在实例化对象的大部分时间里设置这些默认值。这是一种捕捉你对一个对象做的所有小事情的方式,在你在一个类方法中实例化它,而不是在你代码的其他地方。
您可以在演示中查看这种模式的大量实例。
You get to have your cake and eat it, too
您的普通构造函数不受影响;如果说有什么不同的话,那就是减轻了可能做得过多的负担。在程序员使用一个对象之前,确保它的内部状态是正确的,这确实是一件合理合法的事情。
使用一个单独的构造器类方法来做这件事,并让默认的__init__只做最基本的事情,这确保了你可以两者都做。
With dataclasses you don’t need the __init__ constructor at all
如您所见,这两种方法配合得很好:数据类为您处理构造函数的内容;类方法实现复杂对象初始化和缺省值的更复杂的部分。
运行游戏循环
当我们开始游戏时,我们需要一种运行游戏循环的方法。
def start_game(): game = TowerGame.create() game.loop() if __name__ == "__main__": start_game()
创建一个新文件——在演示中称为main.py——并在其中放入类似的东西。当你运行python -m tower.main时,它应该调用start_game并调用我们的(空)循环。
后续步骤
我们需要设计实际的游戏循环,因为我们的游戏有几个不同的屏幕(一个菜单,游戏编辑器,实际的游戏,和一个比分屏幕),我们需要一种方式来考虑这个游戏的设计。这就是状态机发挥作用的地方。
摘要
Dataclasses automate a lot of tedium
它们几乎总是普通类的直接升级——老实说,我们编写的大多数类都是普通的。本课程只是触及了数据类的皮毛,所以我建议您牢记它们的用途,并在您从事的其他项目中尝试它们。
The class method factory pattern captures common requirements
很多时候,你会创建一个对象,然后马上针对那个对象进行若干活动:分配这个;称之为。如果是一次性的,没问题。但通常,这是一种通用模式,是您需要经常做的事情,或者至少表明您的类和应用程序期望有一套通用的标准。
您可以使用 classmethod 模式来帮助您管理这一点。
塔防游戏:菜单、声音和文本
Author Mickey Petersen
我们就要结束游戏了。是时候在 UI 上花些功夫了,这样我们就可以保存和加载我们制作的地图并播放它们。为此,我们需要一个小菜单系统。然后还有在游戏中加入声音的问题。
塔防游戏:入门
Author Mickey Petersen
Python 包管理并不是最容易理解的事情。然而,重要的是我们要恰当地捕捉游戏的需求,并将它们提交到 Python 包中。
入门指南
配置 Python 环境
您需要使用 Python 3.8 或更高版本才能轻松完成本课程。演示本身明确要求 3.8 或更高版本。您可以使用旧版本,但是如果您使用旧版本,某些次要的 Python 语言特性和标准库附件将不可用。
如果您想运行演示,您必须确保您的 Python 版本安装了tkinter,这是一个在演示中用来处理打开和保存级别的 UI 工具包。
对自己的游戏要求并不严格,但可能需要在 Ubuntu 上安装python3.X-tk,或者在 Windows 和 Mac 上安装时确保选中 TKinter。
你可以通过输入import tkinter来检查它是否工作。
我还建议你在虚拟环境中开发,这是一种分离你的包的方式,这样它们就不会和其他项目重叠。
现在,让我们创建一个包来托管我们的游戏。
创建 Python 包
Python 的包管理因复杂和混乱而获得了(应得的)坏名声。但是知道如何创建包是很难的事情之一,除非你做过几次。幸运的是,大多数包——甚至是您可能最终部署在某个地方的服务器上的包——通常都可以使用我下面向您展示的简单方法。
我们的目标是有一个给定包名的合适的包——演示叫做tower,但是你应该选择一个你自己的名字——包含包需求、源代码和资产。
-
创建一个空目录。在本例中,我将其命名为
tower。 -
在内部,如果您想要版本控制,可以选择
git init。 -
现在,我们需要创建两个文件。第一,
setup.py:from setuptools import setup setup()这个占位符文件将有助于您的代码的未来。叫做
pyproject.toml:[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" -
现在是最重要的文件,
setup.cfg:[metadata] name = tower version = 1.0 description = Your description long_description = A longer description license = Your license here classifiers = Programming Language :: Python :: 3 [options] zip_safe = false packages = find: install_requires = click==8.* pygame==2.* structlog [options.package_data] tower.assets.gfx = *.png tower.assets.audio = *.wav, *.ogg tower.assets.levels = *.json [options.entry_points] # Optional, but if you want to keep it, # make sure you have a function called # main() in tower.main! console_scripts = tower = tower.main:main这是演示中使用的示例文件(但是为了简单起见,去掉了注释并做了一些调整。)可以用自己的替换细节。
不过,重要的部分值得仔细研究:
-
install_requires是在安装软件包时要安装的软件包的换行符列表。这当然很重要,因为我们需要这些依赖来运行我们的代码。 -
name是安装的软件包的名称。 -
packages自动检测您的包裹。 -
[options.package_data]是一个位置列表,Python 应该在这些位置寻找通常不会包含的东西,比如我们的媒体资产。 -
创建一个快捷方式,您可以从您的命令提示符或终端调用。它叫做
tower,它运行tower.main中的main功能。
这个声明性配置指定了包的结构。
-
-
现在是目录结构。创建一个名为
tower的目录(或者您喜欢的任何名称。)-
在
tower目录中,创建一个名为__init__.py的空白文件。这是你的项目的根,这样当你输入import tower时,一切都正常。 -
如果您想要使用与我在上面的
setup.cfg例子中指定的相同的目录结构,您将需要更多的目录和文件:
setup.py setup.cfg tower/ ├── assets │ ├── __init__.py │ ├── audio │ │ ├── __init__.py │ ├── gfx │ │ ├── __init__.py │ └── levels │ └── __init__.py ├── __init__.py └── main.py -
随意移动东西;需要记住的重要一点是,tower的每个子目录都有一个__init__.py文件。如果你不想要main.py,那么别忘了更新console_scripts。
现在,您已经准备好测试您的项目安装工作了。首先,我们将以可编辑模式安装它,这意味着我们告诉 setuptools(以及 Python)在您创建文件的目录中找到代码,而不是通常为您安装但不想编辑的包保留的不透明目录结构。
为此,使用setup.cfg转到您的包的根目录并运行:
$ python -m pip install --editable .
Pip 现在将安装您的软件包。完成后,您应该会看到类似这样的内容:
[ ... lots of output ... ]
Successfully installed tower-1.0
您可以通过在一个python shell 中键入以下内容来确认它的工作:
>>> import tower
如果您遇到错误,请检查您是否正确地复制了包结构,并且如果您重命名了内容,那么您是否一直这样做。或者,您可以使用演示中的结构开始。
如果一切正常,您将成功导入您的包,并且您已经准备好了。
Understanding package management is important
是的,我们只是在编写一个游戏,但是这种知识在一个大型的 webapp 上就像在其他任何东西上一样有效。我们的需求——事实上,除了 PyPi 上最复杂的包之外,其他包的需求——是捕获需求的一个简单例子,如果您想要包含非 python 代码,还可以捕获这些资产的位置。
Doing it properly will help you down the line
我看到很多 Python 开发者通过修改sys.path或其他类似的诡计来颠覆打包过程。这种方法可以很好地工作,直到它不工作——比如当你想和其他人共享你的代码,或者使用从你的包中动态导入资源的能力(我们将对我们的媒体资产做同样的事情)。)
花一点时间来熟悉这个过程。也许有一天你会被要求创建一个包或者用一个包解决问题。
If you mess up, revert to a known state
对于 Git 这样的源代码控制系统来说,这是最容易做到的。您还应该确保删除了<package name>.egg-info目录,因为 setuptools 喜欢在该目录中缓存状态。
There are third-party alternatives available also
像诗歌这样的工具有望使这个过程变得更容易或更易于管理,但是我们离摒弃和取代现有的包装机械还有很长很长的路要走。所以即使你使用诗歌,大多数其他包都不会。您最终会发现自己正在与编写包的标准方法进行交互、调试或扩展。
安装和播放塔防演示
本课程附带的源代码演示了您将要学习的所有概念。我建议您安装并使用它,只是为了对最终目标有一个感觉,并作为一个工作示例,在您自己的游戏遇到问题时可以参考。
首先,下载演示并将其解压缩到一个目录中。
接下来,您必须安装它:
$ cd <demo path>
$ python -m pip install --editable .
如果在虚拟环境中完成,您现在就可以重新编辑、运行和试验演示了。你可以通过输入tower launch或python -m tower.main launch来启动它。
如果操作正确,您应该会看到主菜单出现。
游戏附带一个演示关卡来演示游戏的玩法。在游戏中选择“Play”,导航到tower/assets/levels目录,打开“demo.json”即可找到。
声音和图形资产
在tower/assets下的演示中,你可以找到一些资产,或者你可以使用自己的资产, itch.io 是一个很好的来源。资产是包层次结构的一部分,所以我们可以使用 Python 的导入机制来导入它们,而不必担心找到它们在文件系统中的位置。
摘要
花一点时间建立一个合适的包结构并开始工作将会带来回报。
Packages capture third-party requirements
跟踪你的软件包所依赖的第三方软件包的版本可以避免版本漂移和兼容性问题。一个常见的问题是,一个较新版本的软件包发布了,而你无意中使用了一个你的软件从未测试过的软件包版本。
You can also include non-Python assets, like data files
Python 包可以包含数据文件资产,如图像、声音效果等。-如果你告诉它。稍后我们将使用它以一种可维护的方式将我们的资产加载到 pygame 中,避免弄乱显式文件路径。