JavaScript-游戏构建指南-全-

JavaScript 游戏构建指南(全)

原文:Building JavaScript games

协议:CC BY-NC-SA 4.0

零、简介

当我在 20 世纪 70 年代第一次学习编程时,你通常会编写相当无聊的程序;例如打印前 100 个素数。从那以后发生了很多变化,学习编程变得更加令人兴奋。还有什么比创造自己的电脑游戏更令人兴奋的呢?玩游戏很有趣,但创造游戏更有趣。现在是你来决定谜题的复杂程度,敌人的行为和武器的威力。

在过去的 30 年里,我用许多不同的语言开发了许多游戏。我创作了可以在可编程计算器、文本终端、大型计算机上运行的游戏,最近还可以在移动设备和网络浏览器上运行。每一次,让电脑做我想让它做的事情,并让玩家参与高要求的挑战,都是非常令人满意的。这本书将带给你快乐。

这本书将教你如何用 JavaScript 编程,这是网络上最重要的语言。它提供了对语言和编程范例的彻底处理。当然,也有许多其他的书试图做同样的事情。但这本书的独特之处在于,它以最令人兴奋的方式做到了这一点:在创造游戏的同时。而且不仅仅是原型游戏,而是看起来很美,实际上玩起来很有趣的完整游戏。

这本书围绕重要的游戏开发概念展开,你可以将这些概念直接应用到你创建的游戏中。你学习游戏循环,精灵和动画,玩家互动,关卡,高分,甚至一些基本的游戏物理。在此过程中,您将逐渐了解 JavaScript 的所有主要语言概念。

这本书使用 HTML5(准确地说是画布)来运行游戏。HTML5 是新的网络标准,所有现代浏览器都支持它,包括个人电脑、平板电脑和智能手机。因此,您可以将自己创建的游戏放在任何网站上,这样您所有的朋友(以及世界上的其他人)都可以玩并享受这些游戏。一旦你创建了书中的例子,你可以开始改变它们,然后继续设计和编程你自己的原创游戏。

一旦你的游戏达到足够的质量,你就可以在全世界发行,甚至出售。这本书包含了一些关于制作和出版你自己的游戏的章节,让你开始。但是,不要忘记,游戏开发是一项多学科的活动。除了程序员(你)之外,你可能需要一个艺术家来创造视觉效果,还需要一个人来为游戏制作音频。但是这样的人很容易在网上找到。而当你有了一个强大的团队,你就可以组建自己的游戏公司。很多成功的公司都是这样开始的。

因此,阅读这本书可能是你在游戏开发职业道路上的第一步。用你制作的游戏给你自己和游戏社区带来惊喜。享受旅程。

—马克·奥维马斯

介绍

随着 HTML5 标准的出现,基于 web 的应用开发变得非常流行。越来越多的游戏公司转向用 JavaScript 开发游戏,因为目前这是唯一真正独立于平台的方法,可以在我们现在拥有的各种设备上工作,从台式电脑到智能手机和平板电脑。在这本书里,你学习如何制作自己的游戏。同时,你深入学习了过去十年中最流行的编程语言之一:JavaScript。在你读完这本书之后,你将能够制作出可以进行商业开发的游戏,这些游戏可以在任何浏览器的 PC 或 MAC 上运行,也可以在平板电脑或智能手机上运行。您获得的技能将帮助您创建专业外观的游戏,并帮助您构建其他类型的基于 web 的应用。正如你将看到的,构建游戏和玩游戏一样有趣(甚至更多!).

这本书是给谁的

这本书是给任何有兴趣学习如何创建自己的游戏的人的。如果你之前没有(JavaScript)编程经验,不要担心。这本书教你所有你需要知道的。如果你已经知道如何编程,那么这本书对你来说仍然是有趣的。我将向您展示如何为游戏开发设计一个完整的软件架构,以满足 2D 游戏程序员的所有需求。这本书举例说明了这种软件架构在四个不同游戏中的用法。这些游戏的代码是精心开发的,考虑到了组织代码的适当方式,并使其干净、健壮、易于扩展。

本书的结构

本书的每一章都有自己的示例程序集。你可以在属于这本书的网站上找到所有的例子。我基于这些例子解释所有的编程概念。

这本书在全球分为六个部分。以下是每个部分的概述。

第一部分

这一部分概述了 JavaScript 编程语言,并介绍了它的主要特性。我将介绍最重要的游戏编程结构——游戏循环,并向您展示如何用 JavaScript 实现它。我用一个使用 HTML5 canvas 对象的非常简单的 JavaScript 应用来说明游戏循环。您将学习对表示游戏世界有用的变量和数据结构,并了解如何在程序中包含游戏素材,如精灵和声音。

第二部分

这一部分着重于你创建的第一个游戏:画家游戏。游戏的目标是收集三种不同颜色的颜料:红色、绿色和蓝色。颜料从空中落在由气球保持漂浮的罐子里,在颜料从屏幕底部落下之前,你必须确保每个罐子都有正确的颜色。我向你展示了如何通过阅读鼠标、键盘或触摸输入来对玩家的行为做出反应。我引入了作为对象蓝图的(也称为该类的实例)。您将了解到构造函数方法是负责创建它们所属的类的实例的方法。

您将学习如何编写自己的方法、属性和类,以及如何使用这些编程概念来设计不同的游戏对象类。你看游戏对象应该如何相互作用。作为这种交互的一个例子,您将看到如何处理游戏对象之间的基本碰撞。您将了解继承是如何在 JavaScript 中实现的,以便游戏对象类可以按层次构建。向您介绍了多态性的概念,它允许您自动调用方法的正确版本。通过添加一些额外的功能,如动作效果、声音、音乐以及维护和显示分数,您就完成了画师游戏。

第三部分

你在本书中开发的第二个游戏是宝石果酱:一个益智游戏,玩家需要找到宝石的组合。每当玩家做出有效的宝石组合,他们就获得点数。您首先要处理在不同移动设备上观看游戏的问题。您将看到如何自动调整画布大小以适应不同的屏幕大小,或者因为播放器旋转手机或平板电脑屏幕。引入了一种方法来自动缩放子画面并缩放鼠标和触摸位置以对此进行补偿,从而在不同画布大小之间无缝切换。

你将学习如何创建游戏对象的结构。引入场景图作为这种结构的表示。你还会了解到游戏对象的局部和全局(世界)位置。游戏对象之间的交互是通过向游戏对象添加标识符来实现的,因此您可以在列表或层次结构中搜索它们。为了完成游戏,你添加了漂亮的视觉效果,如闪光。

第四部分

这部分介绍游戏企鹅配对,这是一个益智游戏,目标是让成对的企鹅颜色相同。玩家可以通过点击并选择企鹅移动的方向来移动企鹅。一只企鹅移动,直到它被游戏中的另一个角色(企鹅、海豹、鲨鱼或冰山)阻止或从游戏场地掉落,在这种情况下,企鹅落入水中并被饥饿的鲨鱼吃掉。在游戏的不同关卡中,你引入新的游戏元素来保持游戏的刺激。例如,有一种特殊的企鹅可以与任何其他企鹅匹配,企鹅可以卡在一个洞里(意味着它们不能再动了),你可以在板上放置吃企鹅的鲨鱼。

我引入了精灵带和精灵片的概念,允许你在同一个图像中存储几个精灵。您可以为菜单创建各种有用的 GUI 元素,例如开/关按钮和滑块按钮。您将学习一个用于处理不同游戏状态的类设计,比如菜单、标题屏幕等等。你会看到不同的状态如何成为游戏循环的一部分,以及你如何在它们之间切换。

许多游戏由不同的关卡组成。尤其是在拼图、迷宫类游戏等休闲游戏中,游戏可能会有几百个关卡。您将看到如何使用对象文字的力量来表示基于瓦片的游戏世界。您还将看到如何使用 HTML5 本地存储来存储玩家在游戏中的进度,并在游戏再次开始时调用这些信息。您将了解到 JSON 是序列化对象文字的有用工具。

第五部分

你在这本书里开发的最后一个游戏是一个叫滴答滴答的平台游戏。你首先设计出游戏的框架,这个框架主要是基于为之前的游戏编写的代码。你会看到如何添加动画:在你到目前为止开发的游戏中,游戏对象可以在屏幕上四处移动,但是在游戏中添加像奔跑的角色这样的东西稍微有点挑战性。

在嘀嗒嘀嗒游戏中,角色需要与游戏世界进行互动,这需要一个基本的物理系统。物理学有两个方面:赋予角色跳跃或坠落的能力,以及处理和响应角色与其他游戏对象之间的碰撞。你也给游戏中的敌人增加一些基础智力。因此,玩家有不同的游戏选项,必须制定不同的策略来完成每一关。你利用遗传来创造敌人行为的多样性。为了完成游戏,您需要在背景中添加山脉和云彩,以使游戏在视觉上更具吸引力。

第六部分

这本书的最后一部分讨论了游戏制作和出版。这部分的内容很大程度上是基于对两位游戏行业人士的采访。第一个是 Mark Overmars,Gamemaker 工具的创造者,现任 Tingly Games 的 CTO。第二个是彼得·维斯特巴卡(Peter Vesterbacka),Rovio Entertainment 的雄鹰,愤怒的小鸟游戏的创造者。这一部分包含了彼得和马克关于游戏制作和游戏出版的许多想法和提示。

涵盖了各种主题,包括编写连贯的 JavaScript 代码、使用第三方库、为您的游戏创建/购买游戏素材(如精灵和声音)、在游戏制作团队工作、游戏的各种测试阶段、处理本地化以及销售和营销游戏的策略。

注意这本书有一个附带的网站,你可以在那里下载所有的示例程序、附带的游戏资源(精灵和声音)以及其他额外的东西。网址是www.apress.com/9781430265382。去那里按照说明拿额外的材料。

获取和安装工具

为了用 HTML5 和 JavaScript 开发电脑游戏,在你的电脑上安装一些工具会很有用。显然,你需要某种浏览器来运行和测试你正在开发的游戏。你甚至可能想在你的电脑上安装几种不同的浏览器,以确保你的游戏能在所有主流浏览器上运行。JavaScript 刚刚发明的时候,浏览器处理 JavaScript 代码的方式有很多不同。一些脚本在一个浏览器上运行良好,但在其他浏览器上出现错误。幸运的是,这在今天已经不是什么问题了。本书提供的几乎所有代码都可以在任何浏览器上正常运行。但在某些情况下,您必须处理浏览器差异。所以,我建议你至少安装两个浏览器来测试你的游戏。在 Windows 电脑上,你已经有了 Internet Explorer,在 Mac 电脑上,你已经有了 Safari。对于测试游戏来说,我发现火狐浏览器(www.mozilla.org/en-US/firefox/new)和 Chrome 浏览器(https://www.google.com/chrome)效果相当不错。Chrome 有一个叫做开发者工具的东西,可以通过进入工具image开发者工具进入菜单。在那里,您可以看到一个控制台(用于调试),在脚本中设置断点,等等。当你想用 Firefox 测试你的游戏时,你必须安装一个名为 Firebug ( http://getfirebug.com/)的插件,它的功能集类似于 Chrome 的开发者工具。

除了可以让你测试游戏的浏览器之外,安装一个编辑器来编辑 JavaScript 和 HTML 文件也很有用。显然,您可以用任何文本编辑器来做这件事。然而,有几个专注于 web 开发的编辑器是可用的。这意味着它们提供了诸如代码补全、语法高亮、代码重构等特性。作为编辑环境的一部分,这些都是非常有用的东西。有付费和免费的编辑。好的付费编辑是 WebStorm ( www.jetbrains.com/webstorm)。一个好的免费编辑器的例子是 Komodo Edit ( www.activestate.com/komodo-edit)。另一个优秀的免费编辑器是 Notepad++ ( http://notepad-plus-plus.org)。虽然 Notepad++不是专门针对 JavaScript 开发的,但是它有很多编辑 HTML 和 JavaScript 文件的有用特性,包括语法高亮。

示例程序

除了这本书,我还提供了大量展示 HTML5 游戏编程各个方面的示例程序。您可以在该书的信息页面上的源代码/下载选项卡下找到源代码的链接。该选项卡位于页面相关标题部分的下方。

示例集合包含在一个 zip 文件中。下载完这个文件后,把它解压到某个地方。当你查看解压文件的文件夹时,你会看到许多不同的文件夹。书中的每一章都有自己的文件夹。例如,如果你想运行企鹅配对游戏的最终版本,进入属于第二十三章的文件夹,双击位于子文件夹 PenguinPairsFinal 中的文件PenguinPairs.html。您的浏览器将打开并运行示例游戏企鹅配对

如您所见,有相当多的不同文件与这个特定的示例相关。如果你去第一章的文件夹,可以看到一个更简单的例子,在那里你可以找到一些非常基本的 html 5 JavaScript 应用的例子。您可以通过双击每个示例的 HTML 文件来运行它们。

联系作者

如果您对本书有任何疑问,请随时通过以下电子邮件地址直接联系我:j.egges@uu.nl

一、设计

本章讲述了编程语言是如何随着时间的推移而演变的。自从 20 世纪 90 年代互联网兴起以来,已经开发了许多语言和工具来支持它。最著名的语言之一是 HTML,用于创建网站。与 JavaScript 和 CSS 样式表一起,它允许创建可由浏览器显示的动态网站。我将在本章中详细讨论 HTML 和 JavaScript,您将看到如何结合使用 HTML5 画布和 JavaScript 创建一个简单的 web 应用。

计算机和程序

在你开始处理 HTML 和 JavaScript 之前,这一部分简要介绍了计算机和编程的一般知识。之后,您将学习如何结合 JavaScript 创建一个简单的 HTML 页面。

处理器和内存

一般来说,计算机由一个处理器存储器组成。这适用于所有现代电脑,包括游戏机、智能手机和平板电脑。我把内存定义为你可以读取和/或写入的东西。内存有不同的种类,主要区别在于数据传输和数据访问的速度。有的内存可以读写任意多次,有的内存只能读,有的内存只能写。计算机中的主处理器称为中央处理器。计算机上最常见的其他处理器是图形处理单元(GPU)* 。甚至现在的 CPU 本身也不再是一个单一的处理器,而是通常由许多核心组成。*

输入输出设备,比如鼠标、游戏手柄、键盘、显示器、打印机、触摸屏等等,乍一看似乎不属于处理器内存的范畴。然而,抽象地说,它们实际上是内存。触摸屏是只读存储器,打印机是只写存储器。

处理器的主要任务是执行指令。执行这些指令的效果是记忆被改变了。特别是我对内存的定义非常宽泛,处理器执行的每条指令都会以某种方式改变内存。你可能不希望计算机只执行一条指令。一般来说,你有一个很长的要执行的指令列表——“把这部分内存移到那边,清空这部分内存,在屏幕上画这个精灵,检查玩家是否按了游戏手柄上的一个键,当你在那里的时候煮点咖啡”——而且(正如你可能预料的那样),这样一个由计算机执行的指令列表被称为程序

程序

总之,程序是一长串改变计算机内存的指令。但是,程序本身也存储在内存中。在程序中的指令被执行之前,它们被存储在硬盘、DVD 或 u 盘上;或在云端;或者任何其他存储介质上。当它们需要被执行时,程序被移动到机器的内部存储器。

组合在一起形成程序的指令需要以某种方式表达。计算机不能掌握用简单英语输入的指令,这就是为什么你需要 JavaScript 之类的编程语言。在实践中,指令被编码为文本,但是你需要按照一种非常严格的方式写下来,根据一套定义编程语言的规则。存在许多编程语言,因为当有人想到一种稍微好一点的方式来表达某种类型的指令时,他们的方法通常会成为一种新的编程语言。很难说有多少种编程语言,因为那取决于你是否把一种语言的所有版本和方言都计算在内;但可以说有成千上万个。

幸运的是,没有必要学习所有这些不同的语言,因为它们有许多相似之处。在早期,编程语言的主要目标是利用计算机的新的可能性。然而,最近的语言致力于使编写程序可能引起的混乱变得有序。共享相似属性的编程语言被认为属于相同的编程范例。范式指的是一组常用的实践。

早期:命令式编程

一大群编程语言属于命令式范式。因此,这些语言被称为祈使语。命令式语言是基于改变计算机内存的指令。因此,它们非常适合上一节描述的处理器-内存模型。JavaScript 是命令式语言的一个例子。

在早期,编写电脑游戏程序是一项非常困难的任务,需要高超的技巧。像流行的雅达利 VCS 游戏机只有 128 字节的 RAM(随机存取存储器),可以使用最多 4096 字节的 ROM(只读存储器),其中必须包含程序和游戏数据。这大大限制了可能性。例如,大多数游戏都有一个对称的关卡设计,因为这样可以将内存需求减半。这些机器也非常慢。

编写这样的游戏是用汇编语言完成的。汇编语言是第一种命令式编程语言。每种类型的处理器都有自己的汇编指令,所以每种处理器的汇编语言都是不同的。因为可用的内存量如此有限,游戏程序员擅长挤出最后一点内存,并执行极其聪明的黑客操作来提高效率。然而,最终的程序是不可读的,除了最初的程序员,任何人都不能理解。幸运的是,这不是问题,因为在那时,游戏通常是由一个人开发的。

一个更大的问题是,因为每个处理器都有自己的汇编语言版本,所以每当一个新的处理器出现时,所有现有的程序都必须为该处理器完全重写。因此,出现了对独立于处理器的编程语言的需求。这就产生了诸如 Fortran (公式翻译器)和 BASIC (初学者通用符号指令代码)等语言。BASIC 在 20 世纪 70 年代非常流行,因为它出现在早期的个人电脑中,如 1978 年的 Apple II、1979 年的 IBM-PC 以及它们的后代。不幸的是,这种语言从未被标准化过,所以每个计算机品牌都使用自己的 BASIC 方言。

注意我努力确定命令式编程语言的范例,这一事实意味着还有其他不基于指令的编程范例。这可能吗?处理器不执行指令怎么办?处理器总是执行指令,但这并不意味着编程语言包含指令。例如,假设您构建了一个非常复杂的电子表格,在表格中的不同单元格之间有许多链接。您可以将这个活动称为编程,将空的电子表格称为程序,准备处理数据。在这种情况下,程序不是基于指令,而是基于细胞之间的功能链接。除了这些函数式编程语言,还有基于命题逻辑的语言——逻辑编程语言——比如 Prolog。这两种类型的编程语言一起形成了声明性范例

过程化编程:命令式+过程

随着程序变得越来越复杂,显然需要一种更好的方法来组织所有这些指令。在过程化编程范式 中,相关指令被分组在过程(或函数,或方法,后者是更常见的现代名称)。因为过程化编程语言仍然包含指令,所以所有的过程化语言也是命令式的。

一种众所周知的过程语言是 C. 这种语言是由贝尔实验室定义的,贝尔实验室在 20 世纪 70 年代末致力于 Unix 操作系统的开发。因为操作系统是一种非常复杂的程序,贝尔实验室想用过程语言来编写它。该公司定义了一种叫做 C 的新语言(因为它是早期叫做 A 和 B 的原型的继承者)。Unix 的哲学是每个人都可以为操作系统编写自己的扩展,用 C 编写这些扩展也是有意义的。结果,C 成为 20 世纪 80 年代最重要的过程语言,也是在 Unix 世界之外。

c 仍然被大量使用,尽管它正在缓慢但肯定地为更现代的语言让路,尤其是在游戏行业。这些年来,游戏变成了更大的程序,它们是由团队而不是个人创建的。游戏代码的可读性、可重用性和易调试性非常重要。此外,从财务角度来看,减少程序员在游戏上的工作时间变得越来越重要。尽管 C 语言在这方面比汇编语言好得多,但以结构化的方式编写非常大的程序仍然很困难。

面向对象编程:过程+对象

像 C 这样的过程语言允许你在过程中对指令进行分组(也称为方法)。就在他们意识到指令属于同一组的时候,程序员发现一些方法也属于同一组。面向对象的范例 让程序员将方法组合成一个叫做的东西。这几组方法可以改变的内存叫做对象。一个类可以描述像吃豆人游戏中的幽灵一样的东西。那么每个单独的幽灵对应于该类的一个对象。这种思考编程的方式在应用于游戏时是非常强大的。

每个人都已经在用 C 编程了,所以一种新的语言诞生了,这种语言很像 C,除了它允许程序员使用类和对象。这种语言被称为 C++ (两个加号表示它是 C 的继承者)。C++的第一个版本可以追溯到 1978 年,官方标准出现在 1981 年。

尽管语言 C++是标准的,但 C++并不包含在不同类型的操作系统上编写基于 Windows 的程序的标准方法。在苹果电脑、Windows 电脑或 Unix 电脑上编写这样的程序是完全不同的任务,这使得在不同的操作系统上运行 C++程序成为一个复杂的问题。最初,这不被认为是一个问题;但是随着互联网变得越来越流行,在不同的操作系统上运行相同的程序变得越来越方便。

一种新的编程语言的时机已经成熟:一种可以在不同操作系统上标准化使用的语言。这种语言需要类似于 C++,但这也是一个很好的机会,从语言中删除一些旧的 C 语言的东西,以简化事情。语言 Java 履行了这个角色(Java 是一个以咖啡闻名的印尼岛屿)。Java 是硬件制造商 Sun 在 1995 年推出的,当时采用了一种革命性的商业模式:软件是免费的,公司计划通过支持来赚钱。对 Sun 来说同样重要的是需要与日益流行的微软软件竞争,微软软件不能在 Sun 生产的 Unix 计算机上运行。

Java 的新奇之处之一是,这种语言被设计成程序不会意外地干扰同一台计算机上运行的其他程序。在 C++中,这变成了一个严重的问题:如果出现这样的错误,它可能会使整个计算机崩溃,或者更糟——邪恶的程序员可能会引入病毒和间谍软件。

网络应用

Java 的一个有趣的方面是它可以在浏览器中作为一个所谓的“??”小程序“??”运行。这使得在互联网上共享程序成为可能。但是,运行 Java 小程序需要安装插件;此外,Java 小应用无法直接与浏览器的元素进行交互。当然,浏览器的另一个主要任务是显示 HTML 页面。 HTML 是一种文档格式化语言,是超文本标记语言的缩写。它的目标是提供一种根据一组标记来组织文档的方法,这些标记表示文档的不同部分,如标题或段落。HTML 是由当时在欧洲粒子物理研究所工作的物理学家蒂姆·伯纳斯·李在 20 世纪 80 年代末发明的。他想为 CERN 的研究人员提供一种方便使用和共享文件的方式。因此,在给同事的备忘录中,他提出了一个基于互联网的超文本系统。Berners-Lee 指定了一小组 HTML 查看器可以识别的标签。HTML 的第一个版本包含了 18 个这样的标签,其中 11 个仍然存在于现代 HTML 中。

随着因特网变得可以公开访问,HTML 成了全世界建立网站的通用语言。当时非常流行的浏览器 Mosaic 引入了一个新的标签、img、,它可以用来在 HTML 文档中加入一张图片。此外,HTML 语言的许多新版本是由不同的组织起草的,这些组织提议对一些浏览器已经实现的某些元素进行标准化,如表格或填写表单。1995 年,HTML 工作组设计了 HTML 2.0 标准,将所有这些元素合并成一个标准。在那之后,万维网联盟(W3C) 被创建来维护和更新 HTML 标准。HTML 的新版本 HTML 3.2 是在 1997 年 1 月定义的。同年 12 月,W3C 推荐 HTML4 最后,HTML4.01 在 2000 年 5 月成为新接受的标准。目前,W3C 正在敲定 HTML 的第五个版本,HTML5,,在你阅读这本书的时候,它很可能会成为新的官方 HTML 标准。

以防你从未建立过网站,这是一个简单的 HTML 页面的样子:

<!DOCTYPE html>
<html>
<head>
<title>Useful website</title>
</head>
<body>
This is a very useful website.
</body>
</html>

开发浏览器的公司很快意识到他们需要一种方法来使页面更加动态。第一个 HTML 标准(2.0)主要是针对标记文本的(这也是 HTML 最初被发明的原因)。然而,网站用户需要按钮和字段,并且需要一个规范来指示如果用户与页面交互会发生什么。换句话说,网站需要变得更加动态。当然,Java 也有它的小程序,但是这些小程序是完全独立运行的。applet 无法修改 HTML 页面的元素。

网景公司开发了网景导航器浏览器,与微软公司就哪种浏览器将成为人人使用的主要浏览器展开了激烈的竞争。Netscape 在其现有的一些工具中使用了编程语言 Java,该公司希望设计一种轻量级的解释语言,以吸引非专业程序员(如网站设计师)。这种语言将能够与网页接口,并动态地读取或修改其内容。网景公司发明了一种叫做 ?? 的语言 LiveScript 来完成这个角色。不久之后,该公司将这种脚本语言的名称改为 JavaScript ,因为它源于 Java 语言,也可能是因为人们已经认识到了 Java 这个名字。 JavaScript 包含在 Netscape Navigator 2.0 中。

JavaScript 作为一种脚本语言很快获得了广泛的成功,使网站变得更加动态。微软也将它包含在 Internet Explorer 3.0 中,但将其命名为 JScript ,因为它与 Netscape 最初定义的版本略有不同。1996 年,Netscape 向 ECMA 标准化组织提交了 JavaScript,该组织将这种语言重新命名为 ECMAScript(尽管大家仍然称它为 JavaScript)。最终在 1999 年被接受为标准的版本是当前所有浏览器都支持的版本。ECMAScript 标准的最新版本是 2011 年发布的 5.1 版。正在开发中的 ECMAScript 6 引入了许多有用的新特性,比如类和函数参数的默认值。

由于所有主流浏览器都支持它,JavaScript 已经成为网站的主要编程语言。因为它最初被认为是一种轻量级的解释脚本语言,直到现在程序员才开始使用 JavaScript 来开发更复杂的基于 web 的应用。尽管 JavaScript 可能没有 Python 和 C#等现代编程语言的所有特性,但它仍然是一种非常强大的语言,这一点你会在阅读本书时发现。目前,JavaScript 是唯一一种与 HTML 集成的语言,可以在不同平台的不同浏览器上工作。与 HTML5 一起,它已经成为 web 开发的强大框架。

编程游戏

这本书的目的是教你如何编写游戏程序。游戏很有趣(有时也很有挑战性!)节目。他们处理大量不同的输入和输出设备,游戏创造的想象世界可能极其复杂。

直到 20 世纪 90 年代初,游戏都是为特定平台开发的。例如,如果程序员不花大力气使游戏程序适应不同的硬件,为特定游戏机编写的游戏就不能在任何其他设备上使用。对于 PC 游戏来说,这种影响甚至更糟。如今,操作系统提供了一个硬件抽象层 ,所以程序不必处理计算机内部所有不同类型的硬件。在此之前,每个游戏都需要为每个显卡和声卡提供自己的驱动程序;因此,为某个特定游戏编写的代码并不能被另一个游戏重用。在 20 世纪 80 年代,街机游戏极其流行,但由于计算机硬件的不断变化和改进,为它们编写的代码几乎没有一个可以被重新用于更新的游戏。

随着游戏变得越来越复杂,操作系统变得越来越独立于硬件,游戏公司开始重用早期游戏的代码是有意义的。如果您可以简单地使用以前发布的游戏中的程序,为什么要为每个游戏编写全新的渲染程序或碰撞检查程序呢?游戏引擎 这个术语是在 20 世纪 90 年代创造的,当时《毁灭战士》和《雷神之锤》等第一人称射击游戏成为非常受欢迎的流派。这些游戏非常受欢迎,以至于它们的制造商 id Software 决定将部分游戏代码作为单独的软件授权给其他游戏公司。转售核心游戏代码作为游戏引擎是一项有利可图的努力,因为其他公司愿意花大价钱购买许可证,将引擎用于他们自己的游戏。这些公司不再需要从头开始编写他们自己的游戏代码——他们可以重用游戏引擎中包含的程序,并更多地关注图形模型、角色、关卡等。

今天有许多不同的游戏引擎。一些游戏引擎是专门为游戏控制台或操作系统等平台构建的。其他游戏引擎可以在不同的平台上使用,而不必更改使用游戏引擎代码的程序。这对于希望在不同平台上发布游戏的游戏公司来说尤其有用。现代游戏引擎为游戏开发人员提供了许多功能,如 2D 和 3D 渲染引擎,粒子和灯光、声音、动画、人工智能、脚本等特殊效果。游戏引擎被频繁使用,因为开发所有这些不同的工具是一项繁重的工作,游戏公司更愿意将时间和精力投入到创造美丽的环境和挑战关卡上。

由于核心游戏功能和游戏本身(关卡、角色等等)之间的严格分离,许多游戏公司雇佣的艺术家比程序员多。然而,程序员对于改进游戏引擎代码仍然是必要的,对于编写程序来处理游戏引擎中不包含的或特定于游戏的事情也是必要的。此外,游戏公司经常开发软件来支持游戏的开发,例如关卡编辑程序、以正确格式导出模型和动画的 3D 建模软件的扩展、原型工具等等。

对于 JavaScript,还没有一个人人都在使用的引擎。大多数人用 JavaScript 编写相对简单的游戏,以确保游戏可以在不同的设备上运行,尤其是功能有限的设备。因此,程序员不使用引擎,而是直接使用 HTML5 元素如canvas来编写游戏。然而,这种情况正在迅速改变。如果你在谷歌中输入 javascript 游戏引擎 ,你会发现许多引擎可以作为开发自己游戏的基础。这本书的目标是教你如何编程游戏;但是你不会用引擎,因为我想教你语言的核心和它的可能性。这不是游戏引擎的手册。事实上,读完这本书后,你将能够建立自己的游戏引擎。我不是说你应该这样做,但是你可以更好地从头开始编写游戏程序,更快地理解游戏引擎库是如何工作的。

开发游戏

开发游戏通常使用两种方法。图 1-1 说明了这些方法:外部方法包含内部方法。当人们第一次学习编程时,他们通常会立即开始编写代码,这导致了一个编写、测试、修改的紧密循环。相比之下,专业程序员在写第一行代码之前,会花大量的前期时间做设计工作。

9781430265382_Fig01-01.jpg

图 1-1 。小规模和大规模编程

小规模:编辑-解释-运行

当你想用 JavaScript 构建一个游戏时,你需要编写一个包含多行指令的程序。使用文本编辑器,您可以编辑正在处理的脚本。一旦你写下这些指令,你就启动浏览器(最好是一个常用浏览器程序的最新版本)并尝试运行该程序。当一切正常时,浏览器解释并执行脚本。

然而,大多数时候,事情并不那么容易。首先,你给浏览器/解释器的源代码应该包含有效的 JavaScript 代码,因为你不能指望浏览器执行一个包含随机胡扯的脚本。浏览器检查源代码是否符合 JavaScript 语言的语言规范。否则,它会产生一个错误,脚本会停止。当然,程序员努力写出正确的 JavaScript 程序,但是很容易出现错别字,而且写出正确程序的规则非常严格。因此,在解释阶段,您肯定会遇到错误。

在解决小错误的几次迭代之后,浏览器会解释整个脚本,而不会遇到任何问题。下一步,浏览器执行或者运行脚本。在许多情况下,您会发现脚本并没有完全按照您想要的那样运行。当然,您努力正确地表达了您希望脚本做的事情,但是很容易犯概念性的错误。

所以你回到编辑那里,修改剧本。然后你再次打开浏览器,尝试解释/运行脚本,希望你没有犯新的打字错误。你可能会发现早先的问题已经解决了,只是意识到虽然脚本在做一些不同的事情,但它仍然没有完全按照你想要的那样去做。又回到了编辑那里。欢迎来到程序员的生活!

大规模:设计-指定-实施

一旦你的游戏变得越来越复杂,就开始敲键盘直到你完成不再是一个好主意。在你开始实现(编写和测试游戏)之前,还有另外两个阶段。

首先,你必须设计游戏。你在开发什么类型的游戏?你的游戏的目标受众是谁?这是 2D 游戏还是 3D 游戏?你想要什么样的游戏模式?游戏中有哪些类型的角色,他们的能力如何?特别是当你和其他人一起开发一个游戏时,你必须写一些包含所有这些信息的设计文档,这样每个人都同意他们在开发什么游戏!即使是你自己开发游戏,写下游戏的设计也是一个好主意。设计阶段实际上是游戏开发中最困难的任务之一。

一旦明确了游戏应该做什么,下一步就是为程序提供一个全局结构。这被称为规范阶段。你还记得面向对象编程范式在方法中组织指令,在类中组织方法吗?在规格说明阶段,您需要概述游戏所需的类以及这些类中的方法。在这个阶段,你只需要描述一个方法将做什么,而不是它是如何完成的。然而,请记住,你不能指望方法做不可能的事情:它们必须在以后实现。

当游戏规范完成后,你可以开始实现阶段,这通常意味着要经历几次编辑-解释-运行循环。之后,你可以让其他人玩你的游戏。在很多情况下,你会意识到游戏设计中的一些想法并不那么有效。所以,你重新开始,改变设计,然后改变规格,最后做一个新的实现。你让其他人再玩你的游戏,然后…嗯,你明白了。编辑-解释-运行循环包含在一个更大规模的循环中:设计-指定-实现循环(见图 1-1 )。尽管这本书主要关注于实现阶段,你可以在第三十章中读到更多关于设计游戏的内容。

构建您的第一个 Web 应用

在本节中,您将使用 JavaScript 构建几个非常简单的示例应用。在本章的前面,你看到了一个基本的 HTML 页面:

<!DOCTYPE html>
<html>
<head>
<title>Useful website</title>
</head>
<body>
This is a very useful website.
</body>
</html>

打开文本编辑程序,如记事本,将此文本复制粘贴到其中。将文件另存为扩展名为.html的文件。然后双击该文件,在浏览器中打开它。你会看到一个几乎是空的 HTML 页面,如图 1-2 所示。在 HTML 中,标签用于组织文档中的信息。您可以识别这些标签,因为它们被放在尖括号中。每种不同类型的内容都放在这样的标签之间。通过检查标记名前面是否有斜杠,可以区分开始标记和结束标记。例如,文档的标题放在开始标签<title>和结束标签</title>之间。标题本身又是由<head></head>标记分隔的标题、 的一部分。标题包含在 html 部分,由<html></html>标记分隔。如您所见,HTML 标记系统允许您逻辑地组织文档内容。总的 HTML 文档有一种树形结构,其中html元素是树的根;根由headbody等元素组成,这些元素又由更多的分支组成。

9781430265382_Fig01-02.jpg

图 1-2 。一个非常简单的 HTML 页面

一旦你创建了一个 HTML 文档,你可以对它应用一个样式。例如,您可能想要更改 HTML 文档各部分的布局,或者您可能想要使用不同的字体或应用背景色。样式可以被定义为 HTML 文档的一部分,或者你可以使用 CSS(层叠样式表)文件来定义样式。

虽然我们没有在本书中详细介绍样式表(CSS 文件) ,但我有限地使用它们来正确定位浏览器窗口中的游戏内容。例如,这个简单的样式表将 html 页面及其正文的边距设置为 0:

html, body {
    margin: 0;
}

如果您希望您的 HTML 页面使用 CSS 文件(样式表),您只需将下面一行添加到<head>部分:

<link rel="stylesheet" type="text/css" href="game-layout.css"/>

我将在本书的大部分游戏示例中使用前面的样式表。在第十三章的中,我将扩展样式表以允许内容自动缩放和定位到不同的设备。

您还可以在 HTML 文档本身中更改样式,而不是使用 CSS 文件来定义样式。这是通过设置标签的属性来实现的。例如,以下 HTML 页面的主体有一个属性标签style,该标签被设置为将背景色更改为蓝色(显示的页面见图 1-3 ):

<!DOCTYPE html>
<html>
<head>
<title>BasicExample</title></head>
<body style="background:blue">
That's a very nice background.
</body>
</html>

9781430265382_Fig01-03.jpg

图 1-3 。蓝色背景的简单网页

你可以通过使用一个style属性来改变样式的不同方面,如示例所示。例如,看看下面的 HTML 文档:

<!DOCTYPE html>
<html>
<head>
<title>BasicExample</title></head>
<body>
<div style="background:blue;font-size:40px;">Hello, how are you?</div>
<div style="background:yellow;font-size:20px;">I'm doing great, thank you!</div>
</body>
</html>

如果您查看body的内容,您会看到它包含两个部分。每个部分都包含在div标签中,这些标签是div用来将一个 HTML 文档分成个部分。您可以为每个分区应用不同的风格。在本例中,第一个分区的背景为蓝色,字体大小为 40 像素,第二个分区的背景为黄色,字体大小为 20 像素(参见图 1-4 )。

9781430265382_Fig01-04.jpg

图 1-4 。由两个部分组成的网页,每个部分都有不同的背景颜色和字体大小

除了给 HTML 元素添加一个style属性,您还可以使用 JavaScript 来修改该元素的样式。例如,您可以使用 JavaScript 更改正文的背景颜色,如下所示:

<!DOCTYPE html>
<html>
<head>
<title>BasicExample</title><script>
    changeBackgroundColor = function () {
        document.body.style.background = "blue";
    }
    document.addEventListener('DOMContentLoaded', changeBackgroundColor);
</script>
</head>
<body>
That's a very nice background.
</body>
</html>

浏览器显示的页面看起来和第一个例子完全一样(如图 1-2 所示),但是使用 JavaScript 来做这件事和给body标签添加一个属性有一个重要的区别:JavaScript 脚本动态地改变颜色。发生这种情况是因为脚本包含以下行:

document.addEventListener('DOMContentLoaded', changeBackgroundColor);

在 JavaScript 应用中,您可以访问 HTML 页面中的所有元素。而当事情发生时,你可以指示浏览器执行指令。在这里,您指出当页面完成加载时应该执行changeBackgroundColor函数。

HTML 和 JavaScript 中有许多不同类型的事件。例如,您可以在 HTML 文档中添加一个按钮,并在用户单击该按钮时执行 JavaScript 指令。这里有一个说明这一点的 HTML 文档(参见图 1-5 ):

<!DOCTYPE html>
<html>
<head>
<title>BasicExample</title>
<script>
    sayHello = function () {
        alert("Hello World!");
    }
    document.addEventListener('click', sayHello);
</script>
</head>
<body>
<button>Click me</button>
</body>
</html>

9781430265382_Fig01-05.jpg

图 1-5 。包含按钮的 HTML 页面。当用户单击该按钮时,会显示一个警告

这种动态交互 之所以成为可能,是因为浏览器可以执行 JavaScript 代码。如果你想设计游戏,能够定义玩家应该如何与游戏互动是至关重要的。

HTML5 画布

新 HTML 标准的一个优点是它提供了一些标签,使得 HTML 文档更加灵活。添加到标准中的一个非常重要的标签是canvas标签,它允许您在 HTML 文档中绘制 2D 和 3D 图形。这里有一个简单的例子:

<!DOCTYPE html>
<html>
<head>
<title>BasicExample</title>
</head>
<body>
<div id="gameArea">
    <canvas id="mycanvas" width="800" height="480"></canvas>
</div>
</body>
</html>

这里你可以看到身体包含了一个名为gameArea的分部。在这个 division 中有一个canvas元素,它有许多属性。它有一个标识符(mycanvas,它有一个宽度和高度。您可以使用 JavaScript 再次修改这个canvas元素中的内容。例如,下面的代码通过使用一些 JavaScript 指令改变了canvas元素的背景颜色:

<!DOCTYPE html>
<html>
<head>
<title>BasicExample</title>
<script>
    changeCanvasColor = function () {
        var canvas = document.getElementById("mycanvas");
        var context = canvas.getContext("2d");
        context.fillStyle = "blue";
        context.fillRect(0, 0, canvas.width, canvas.height);
    }
    document.addEventListener('DOMContentLoaded', changeCanvasColor);
</script>
</head>
<body>
<div id="gameArea">
    <canvas id="mycanvas" width="800" height="480"></canvas>
</div>
</body>
</html>

changeCanvasColor函数中,首先找到canvas元素。这是一个 HTML 文档元素,你可以在上面绘制 2D 和 3D 图形。在代码中准备好这个元素非常有用,因为这样就可以轻松地检索画布的信息,比如它的宽度或高度。为了在画布上执行操作(比如在上面画画),你需要一个画布上下文。画布上下文提供了在画布上绘图的功能。当您检索画布上下文时,您需要指明您是想要在二维还是三维空间中进行绘制。在本例中,您将获得一个二维画布上下文。您可以使用它来选择背景填充颜色,并用该颜色填充画布。图 1-6 显示了浏览器显示的 HTML 页面。接下来的章节将更详细地介绍canvas元素,以及如何用它来创建游戏。

9781430265382_Fig01-06.jpg

图 1-6 。在网页上显示 HTML5 画布并用颜色填充它

单独文件中的 JavaScript

除了在 HTML 文档中编写所有的 JavaScript 代码,您还可以在一个单独的文件中编写 JavaScript 代码,并将该文件包含在 HTML 文档中:

<!DOCTYPE html>
<html>
<head>
<title>BasicExample</title>
<script src="BasicExample.js"></script>
</head>
<body>
<div id="gameArea">
    <canvas id="mycanvas" width="800" height="480"></canvas>
</div>
</body>
</html>

JavaScript 文件BasicExample.js包含以下代码:

changeCanvasColor = function () {
    var canvas = document.getElementById("mycanvas");
    var context = canvas.getContext("2d");
    context.fillStyle = "blue";
    context.fillRect(0, 0, canvas.width, canvas.height)
}
document.addEventListener('DOMContentLoaded', changeCanvasColor);

在许多情况下,这样做是可取的。通过将脚本代码从 HTML 文档中分离出来,在不同的网站上查找或使用代码就容易多了。本书中使用的所有例子都将 JavaScript 代码从 HTML 文档中分离出来,很好地组织在一个或多个 JavaScript 文件中。

你学到了什么

在本章中,您学习了:

  • 计算机是如何工作的,它们由处理器计算事物和内存存储事物组成
  • 编程语言是如何从汇编语言发展到现代编程语言如 JavaScript 的
  • 如何使用 HTML5 和 JavaScript 创建一个简单的 web 应用*

二、游戏编程基础

本章涵盖了游戏编程的基本要素,并为后面的章节提供了一个起点。首先,你学习任何游戏的基本框架,包括一个游戏世界和一个游戏循环。通过查看各种示例,比如一个改变背景颜色的简单应用,您将看到如何在 JavaScript 中创建这个框架。最后,我将讨论如何通过在适当的地方使用注释、布局和空白来澄清你的代码。

游戏的积木

这一节讲的是游戏的构建模块。我从总体上讨论游戏世界,然后向您展示使用更新-绘制循环来改变游戏世界的过程,该循环不断更新游戏世界,然后在屏幕上绘制游戏世界。

游戏世界

让游戏成为如此好的娱乐形式的原因是,你可以探索一个想象的世界,在那里做你在现实生活中永远不会做的事情。你可以骑在龙的背上,摧毁整个太阳系,或者创造一个由用想象语言说话的角色组成的复杂文明。你在其中玩游戏的这个虚拟世界被称为游戏世界。游戏世界可以是非常简单的领域,如俄罗斯方块世界,也可以是复杂的虚拟世界,如侠盗猎车手和魔兽世界。

当游戏在电脑或智能手机上运行时,该设备会维护游戏世界的内部表示。这种表现和你玩游戏时在屏幕上看到的一点也不像。它主要由描述物体位置的数字组成,敌人可以从玩家那里获得多少生命值,玩家的库存中有多少物品,等等。幸运的是,该程序还知道如何创建一个视觉上令人愉悦的世界表示,并显示在屏幕上。否则,玩电脑游戏可能会令人难以置信地无聊,玩家必须筛选一页页的数字,以找出他们是救了公主还是死于可怕的死亡。玩家永远看不到游戏世界的内部表示,但游戏开发者看到了。当你想开发一款游戏的时候,你还需要设计如何在内部表现你的游戏世界。编写你自己的游戏的部分乐趣在于你可以完全控制它。

另一个需要意识到的重要事情是,就像现实世界一样,游戏世界也在不断变化。怪物移动到不同的地点,天气变化,汽车没油,敌人被杀,等等。此外,玩家实际上影响着游戏世界的变化!因此,仅仅在电脑内存中存储游戏世界的图像是不够的。一个游戏还需要不断地记录玩家在做什么,因此,更新这个表示。此外,游戏需要通过在电脑显示器、电视或智能手机屏幕上显示游戏世界来为玩家展示。处理这一切的过程被称为游戏循环

游戏循环

游戏循环处理游戏的动态方面。游戏运行时会发生很多事情。玩家按下游戏手柄上的按钮或触摸他们设备的屏幕,由关卡、怪物和其他角色组成的不断变化的游戏世界需要保持最新。还有爆炸、声音等等特效。所有这些需要游戏循环处理的不同任务都可以组织成两类:

  • 与更新和维护游戏世界相关的任务
  • 与向玩家显示游戏世界相关的任务

游戏循环连续执行这些任务,一个接一个(见图 2-1 )。作为一个例子,让我们看看如何在像吃豆人这样的简单游戏中处理用户导航。游戏世界主要由一个迷宫组成,里面有几个讨厌的鬼魂在四处游荡。Pac-Man 位于这个迷宫的某个地方,正朝着某个方向前进。在第一个任务(更新和维护游戏世界)中,你检查玩家是否按下了箭头键。如果是这样,你需要根据玩家希望吃豆人走的方向来更新吃豆人的位置。还有,因为那个动作,吃豆人可能吃了一个白点,增加了分数。你需要检查它是否是关卡中的最后一个点,因为这意味着玩家已经完成了关卡。最后,如果它是一个较大的白点,鬼需要被渲染成不活动的。然后你需要更新游戏世界的其他部分。幽灵的位置需要更新,您必须决定是否应该在某个地方展示水果以获得奖励积分,您需要检查 Pac-Man 是否与其中一个幽灵发生碰撞(如果幽灵不是不活动的),等等。你可以看到,即使在像吃豆人这样的简单游戏中,在第一个任务中也需要做很多工作。从现在开始,我将把这个与更新和维护游戏世界相关的不同任务的集合称为Update动作。

9781430265382_Fig02-01.jpg

图 2-1 。游戏循环,不断更新然后绘制游戏世界

第二组任务与向玩家显示游戏世界有关。在吃豆人游戏的情况下,这意味着绘制迷宫、鬼魂、吃豆人和对玩家来说很重要的游戏信息,例如他们已经获得了多少分,他们还剩下多少条命,等等。这些信息可以显示在游戏屏幕的不同区域,例如顶部或底部。这部分显示器也叫平视显示器 (HUD)。现代 3D 游戏的绘图任务要复杂得多。这些游戏需要处理光照和阴影、反射、剔除、爆炸等视觉效果,等等。我将游戏循环中处理与向玩家显示游戏世界相关的所有任务的部分称为Draw动作。

用 JavaScript 构建游戏应用

前一章展示了如何创建简单的 JavaScript 应用。在那个 JavaScript 应用中,您看到指令被分组到一个函数中,如下:

function changeBackgroundColor () {
    document.body.style.background = "blue";
}

这种分组的想法与 JavaScript 是一种过程化语言的想法是一致的:指令被分组到过程/函数中。第一步是用 JavaScript 建立一个简单的游戏循环。看看下面的例子:

var canvas = undefined;
var canvasContext = undefined;

function start () {
    canvas = document.getElementById("myCanvas");
    canvasContext = canvas.getContext("2d");
    mainLoop();
}

document.addEventListener('DOMContentLoaded', start);

function update () {
}

function draw () {
}

function mainLoop () {
    canvasContext.fillStyle = "blue";
    canvasContext.fillRect(0, 0, canvas.width, canvas.height);
    update();
    draw();
    window.setTimeout(mainLoop, 1000 / 60);
}

如您所见,这个脚本中有几个不同的函数。当 HTML 文档的主体已经加载时,调用start函数,因为这个指令:

document.addEventListener('DOMContentLoaded', start);

start函数中,您检索画布和画布上下文;你将它们存储在变量中,这样你就可以在程序的其他部分使用它们(稍后会详细介绍)。然后,你执行另一个叫做mainLoop的功能。这个函数又包含其他指令。两个指令负责设置背景颜色。然后你调用update函数,接着是draw函数。这些函数中的每一个都可能包含其他指令。调用的最后一条指令如下:

window.setTimeout(mainLoop, 1000 / 60);

这只是在等待一段时间(本例中为 1000/60 = 16.6 毫秒)后,再次调用mainLoop函数。再次调用mainLoop函数时,设置画布背景颜色,并调用updatedraw函数。目前,updatedraw是空的,但是你可以开始用指令填充它们来更新和绘制一个游戏世界。注意,在循环迭代之间使用setTimeout等待并不总是最好的解决方案。有时,这种方法可能会受到超出您控制范围的事件的负面影响,例如速度较慢的计算机、浏览器中打开的其他标签、需要处理能力的并发运行的应用等等。当你必须处理敏感的时间操作时(比如玩家需要存活五分钟),你可能不想依赖setTimeout,而是依赖于某种系统,该系统在特定的时间点安排事件,并在update函数中检查这些事件是否已经发生。

当您运行示例程序时,会持续执行updatedraw函数:更新、绘制、更新、绘制、更新、绘制、更新、绘制、绘制、更新、绘制、更新、绘制、更新、绘制等等。此外,这是以非常高的速度发生的。这个特殊的例子创建了一个简单的游戏循环,以每秒 60 帧的速度运行。这种循环被称为固定时间步长循环、循环,在休闲游戏中非常流行。你也可以设计不同的程序,让游戏尽可能多的执行循环,而不是每秒 60 次。

注意当你创建依赖于(游戏)循环的程序时,你可能想要避免在实现和测试的早期阶段使用全自动循环。您可能会创建一个无限循环,这可能会意外地使开发机器陷入困境。相反,您可以将循环设置为运行有限的次数,或者您可以让循环在每次按下按钮时运行一次。大多数浏览器也支持 JavaScript 的调试。例如,在 Firebug(在 Firefox 浏览器中)中,您可以在循环中的某个点放置一个断点。这样,您就可以跟踪程序运行时发生了什么。

这本书向你展示了很多不同的方法来填充updatedraw函数,以完成你在游戏中需要执行的任务。在这个过程中,我还介绍了许多对游戏(和其他应用)有用的编程技术。下一节将更详细地介绍基本的游戏应用。然后,你用额外的指令填充这个游戏的基本框架。

程序的结构

这一节将更详细地讨论程序的结构。在早期,许多计算机程序只将文本写到屏幕上,而不使用图形。这种基于文本的应用被称为控制台应用。除了将文本打印到屏幕上,这些应用还可以读取用户在键盘上输入的文本。因此,与用户的任何交流都是以问题/答案序列的形式进行的(Do you want to format the hard drive (Y/N)? Are you sure (Y/N)?等等)。在基于 Windows 的操作系统流行起来之前,这种基于文本的界面在文本编辑程序、电子表格、数学应用甚至游戏中非常普遍。这些游戏被称为基于文本的冒险、,它们以文本形式描述游戏世界。然后,玩家可以输入命令与游戏世界互动,如go westpick up matchesXyzzy。这类早期游戏的例子有 Zork 和 Adventure。虽然它们现在看起来已经过时了,但是玩起来仍然很有趣!

仍然可以用 JavaScript 等语言编写控制台应用。虽然看到如何编写这样的应用很有趣,但我更喜欢专注于用图形编程现代游戏。

应用类型

控制台应用只是一种应用的一个例子。另一种非常常见的类型是 Windows 应用。这样一个应用显示一个包含窗口、按钮和图形用户界面 (GUI)的其他部分的屏幕。这种类型的应用通常是事件驱动的 : 它对点击按钮或选择菜单项等事件做出反应。

另一种应用是在手机或平板电脑上运行的应用。在这些类型的应用中,屏幕空间通常是有限的,但是新的交互可能性是可用的,例如用于找出设备位置的 GPS、检测设备方向的传感器以及触摸屏。

开发应用时,编写一个能在所有不同平台上运行的程序是一个相当大的挑战。创建 Windows 应用与创建应用有很大不同。并且在不同类型的应用之间重用代码很困难。由于这个原因,,基于网络的应用变得越来越流行。在这种情况下,应用存储在服务器上,用户在 web 浏览器中运行程序。这种应用有很多例子:想想基于网络的电子邮件程序或社交网站。在这本书里,你将学习如何开发基于网络的游戏。

注意并非所有的项目都属于一种应用类型。一些 Windows 应用可能有一个控制台组件,例如浏览器中的 JavaScript 控制台。游戏通常也有一个窗口组件,如清单屏幕、配置菜单等等。如今,一个节目的界限实际上已经变得不那么清晰了。想象一下,一个多人游戏有数万名玩家,每个人都在平板电脑上运行一个应用,或者在台式电脑上运行一个应用,而这些程序与同时在许多服务器上运行的复杂程序进行通信。在这种情况下,什么构成了节目?它是什么类型的节目?

功能

记住,在命令式程序中,指令正在做程序的实际工作:它们被一个接一个地执行。这改变了内存和/或屏幕,因此用户注意到程序正在做一些事情。在 BasicGame 程序中,并不是程序中的所有行都是指令。指令的一个例子是行context.fillRect(0, 0, canvas.width, canvas.height);,它指示画布用前面指令中指定的颜色在屏幕上画一个矩形。因为这个矩形恰好是画布的大小,所以整个画布的颜色都改变了。

因为 JavaScript 是一种过程化语言,所以指令可以被分组到函数中。在 JavaScript 中,指令并不一定是函数的一部分。例如,BasicGame 程序中的以下指令不属于函数:

var canvas = undefined;

但是,函数非常有用。它们防止了代码的重复,因为指令只在一个地方,并且它们允许程序员通过调用一个名字来容易地执行那些指令。函数中的指令分组是用大括号({})完成的。这种组合在一起的指令块被称为函数的。在主体上面,你写了函数的。函数头的一个例子如下:

function mainLoop ()

这个头包含了函数的(在这里是mainLoop)。作为一名程序员,你可以为一个函数选择任何名字。你已经看到游戏循环由两部分组成:updatedraw。在编程术语中,这些部分被建模为函数,正如您在示例程序中看到的那样。在这些函数中,您可以放置您想要执行的指令,以便更新或绘制游戏世界。函数名前面是单词function,名字后面是一对括号。这些用于向在函数内部执行的指令提供信息。例如,看看下面的标题:

function playAudio (audioFileId)

在这个头中,函数的名字是playAudio;在括号之间你可以看到单词audioFileId。显然,playAudio函数需要一个音频文件标识符,这样它就知道应该播放哪个音频文件。

语法图

如果你不知道 JavaScript 这种语言的规则,那么用这种语言编程会很困难。这本书使用所谓的语法图 来解释语言是如何构成的。编程语言的语法指的是定义什么是有效程序的正式规则(换句话说:编译器或解释器可以读取的程序)。相比之下,程序的语义指的是程序的实际含义。为了说明语法和语义之间的区别,看看短语“你所有的基础都是属于我们的”。从语法上来说,这个短语是无效的(英语口译员肯定会抱怨它)。然而,这个短语的意思是非常清楚:你显然因为一个说着糟糕英语的外星种族而失去了所有的基础。

注意短语“你所有的基地都是属于我们的”来自电子游戏《零翼》(1991,Sega Mega Drive)的开场过场动画,是对日文原版的拙劣翻译。从那以后,这个短语出现在我的文章、电视剧、电影、网站和书中(比如这篇!).

解释器可以检查程序的语法:任何违反规则的程序都会被拒绝。不幸的是,解释器不能检查程序的语义是否符合程序员的想法。所以如果一个程序在语法上是正确的,这并不能保证它在语义上是正确的。但是如果它在语法上不正确,它就根本不能运行。语法图有助于您可视化编程语言(如 JavaScript)的规则。例如,图 2-2 是一个简化的语法图,展示了如何在 JavaScript 中定义一个函数。

9781430265382_Fig02-02.jpg

图 2-2 。函数表达式的语法图

您可以使用语法图构建 JavaScript 代码,方法是从图的左上角开始,在本例中是从单词函数开始,然后按照箭头指示进行操作。当你到达灰点时,你的代码就完成了。这里你可以清楚地看到一个函数定义是以function关键字开始的;然后你写下函数的名字。之后,你写括号。在这些括号之间,您可以(可选地)编写任意数量的由逗号分隔的参数、。接下来你写一些指令,都在大括号里。之后,你就完成了,因为你已经到达了灰点。在本书中,我使用语法图来展示如何根据 JavaScript 语言的语法规则来构建代码。

调用函数

当指令canvasContext.fillRect(0, 0, canvas.width, canvas.height);被执行时,你调用fillRect函数。换句话说,你希望程序执行函数fillRect中的指令。这组指令正是你在这个例子中所需要的:即,用一种颜色填充一个矩形。但是,您需要给这个函数一些额外的信息,因为它需要知道应该填充的矩形的大小。参数提供了这些额外的信息。正如您在语法图中看到的,一个函数可以有多个参数。当一个函数被调用时,你总是在它后面写括号,括号内是参数(如果需要的话)。

为了使用fillRect功能,你需要知道哪些指令被组合在一起吗?不,你没有!这是在函数中对指令进行分组的好处之一。您(或其他程序员)可以在不知道其工作原理的情况下使用该函数。通过智能地将指令分组到函数中,就有可能编写出可重用的程序片段,可以在许多不同的上下文中使用。fillRect函数就是一个很好的例子。它可以用于各种应用,您不需要知道该功能如何工作才能使用它。您唯一需要知道的是,它将矩形的尺寸作为参数。

更新并绘制

BasicGame 示例中的游戏循环包含updatedraw函数。因为一个函数基本上是一组指令,每次调用update函数时,函数中的指令都会被执行。draw也是如此。

例如,假设您想要一个简单的游戏,在鼠标指针的位置绘制一个气球。当你移动鼠标时,气球也跟着移动。对于updatedraw功能,您可以如下操作。在update函数中,您需要执行一条指令来检索鼠标指针的当前位置,并将其存储在内存中。在draw功能中,您需要执行一个在存储位置显示一个气球图像的指令。当然,你还不知道这些说明是否存在(剧透:它们存在!),而且你还不知道说明书是什么样子的。此外,你可能想知道为什么会这样。你不是在移动气球,你只是在存储在update函数中的位置画气球。回想一下,updatedraw功能以非常高的速度执行(每秒 60 次)。由于这种高速率,在不同的位置绘制气球会使它看起来像是在移动(但实际上并没有)。这就是所有游戏世界是如何绘制的,玩家是如何被诱惑去认为世界是运动的。实际上,你只是在不同的位置快速绘制图像。请继续关注——您将回到这个示例,并在以后让它工作起来!

程序布局

本节讨论程序源代码的布局。您首先会看到如何在代码中添加澄清性注释。然后,您将学习如何通过使用单行或多行、空白和缩进来尽可能清晰地编写指令。

备注

对于程序的读者来说(另一个程序员,或者几个月后你自己,当你忘记了程序是如何工作的细节),在程序中添加一些说明性的注释是非常有用的。编译器完全忽略了这些注释,但它们有助于程序更容易理解。JavaScript 中有两种方法来标记代码中的注释:

  • 符号组合/**/之间的所有内容都被忽略(可以有多行注释)。
  • 符号组合//和行尾之间的所有内容都被忽略。

在代码中放置注释来解释指令组、参数的含义或完整的类是很有用的。如果你使用注释,那么做是为了阐明代码,而不是用文字重新编写代码:你可以假设你的代码的读者知道 JavaScript。为了说明这一点,下面的注释行增加了指令的清晰度:

// Set the background color to green.
canvasContext.fillStyle = "green";
canvasContext.fillRect(0, 0, canvas.width, canvas.height);

这也是一个注释,但是它没有阐明指令的作用:

/* Pass the value "green" to the fillStyle variable of canvasContext and call the fillRect method of canvasContext with the parameters 0, 0, canvas.width and canvas. */
canvasContext.fillStyle = "green";
canvasContext.fillRect(0, 0, canvas.width, canvas.height);

在测试程序时,还可以使用注释符号来临时删除程序中的指令。一旦你完成程序,不要忘记删除你的代码中被注释掉的部分,因为当其他开发人员查看你的源代码时,它们会导致混乱。

指令与行

关于如何将 JavaScript 程序的文本分布到文本文件的各个行上,并没有严格的规则。通常你把每条指令都写在一个单独的行上,即使这对于编译器理解程序是不必要的。有时,如果为了让程序更清晰,程序员会在一行上写多条指令。此外,有时一条很长的指令(包含函数/方法调用和许多不同的参数)可以分布在多行中(您在本书后面也会看到这一点)。

空白和缩进

如您所见,BasicGame 示例大量使用了空格。每个函数之间有一个空行,每个等号和它两边的表达式之间也有空格。间距可以帮助程序员澄清代码。对于浏览器/解释器来说,空格没有任何意义。空格真正重要的唯一地方是在单独的单词之间:不允许将function update()写成functionupdate()。同样,你也不能在单词中间多写一个空格。在按字面解释的文本中,空格也按字面理解。是有区别的

canvasContext.fillStyle = "blue";

canvasContext.fillStyle = "b l u e";

但除此之外,任何地方都允许额外的空格。以下是放置额外空白的好地方:

  • 在每个逗号和分号后面(但不是前面)。
  • 等号的左右(=)。你可以在指令canvasContext.fillStyle = "blue";中看到这样的例子。
  • 在行首,因此方法和类的主体相对于包围主体的大括号缩进(通常是四个位置)。

大多数编辑程序通过自动执行缩进来帮你一点忙。此外,编辑器会自动在代码中的特定位置放置空格,以增加可读性。

你学到了什么

在本章中,您学习了:

  • 游戏的骨架是什么,由游戏循环和循环所作用的游戏世界组成
  • 如何构建一个游戏程序,它由几个不同的函数组成,这些函数检索画布,还有构成游戏循环的updatedraw函数
  • JavaScript 程序的基本布局规则,包括如何在代码中放置注释,以及在何处放置额外的空白以提高代码的可读性

三、创造一个游戏世界

本章向你展示了如何通过在内存中存储信息来创建一个游戏世界。它介绍了基本类型和变量,以及如何使用它们来存储或更改信息。接下来,您将看到如何在由成员变量和方法组成的对象中存储更复杂的信息。

基本类型和变量

前几章讨论了几次内存。您已经看到了如何执行类似于canvasContext.fillStyle = "blue";的简单指令来设置在画布上绘制形状时应该填充的颜色。在本章的例子中,你使用内存来临时存储信息,以便记住一些简单计算的结果。在这个 DiscoWorld 的例子中,您根据过去的时间改变背景颜色。

类型

类型,或数据类型,代表不同种类的结构化信息。前面的例子使用了不同种类的信息作为参数传递给函数。例如,函数fillRect需要四个整数作为信息,BasicGame 示例中的start函数需要一个引用画布的文本标识符,而同一示例中的updatedraw函数根本不需要任何信息。浏览器/解释器可以区分所有这些不同类型的信息,在许多情况下,甚至可以将一种类型的信息转换成另一种类型。例如,在 JavaScript 中,可以使用单引号或双引号来表示文本。例如,下面两条指令也是如此:

canvas = document.getElementById("myCanvas");

canvas = document.getElementById('myCanvas');

浏览器能够在不同种类的信息之间自动转换。例如,以下内容不会导致语法错误:

canvas = document.getElementById(12);

作为参数传递的数字将被简单地转换成文本。当然,在这种情况下,没有 ID 为 12 的画布,所以程序将不再正确。但是,如果您要按如下方式替换画布 ID,那么该程序将正常工作:

<canvas id="12" width="800" height="480"></canvas>

浏览器会自动在文本和数字之间转换。

大多数编程语言都比 JavaScript 严格得多。在 Java 和 C#等语言中,类型之间的转换是在非常有限的基础上完成的。大多数情况下,您必须明确地告诉编译器需要进行类型之间的转换。这种类型转换也称为转换

在类型转换方面有更严格的政策的原因是什么?首先,明确定义函数或方法期望哪种类型作为参数,可以让其他程序员更容易理解如何使用该函数。请看下面的标题示例:

function playAudio (audioFileId)

只看这个头,你不能确定audioFileId是数字还是文字。在 C#中,类似方法的标头如下所示:

void playAudio(string audioFileId)

你可以看到在这个头中,不仅提供了一个名字,还提供了一个属于这个名字的类型。这种情况下的类型是string,在 C#中表示文本(一串字符)。再者,方法名前面是单词void,表示该方法没有可存储的结果(我在第七章中更多地谈到有结果的方法/函数)。

变量的声明和赋值

将信息存储在 JavaScript 中并在以后使用很容易。您需要做的是提供一个您在引用此信息时使用的名称。这个名字叫做变量。当你想在你的程序中使用一个变量时,在你实际使用它之前,声明它是一个好主意。这是你如何声明一个变量:

var red;

在本例中,red是变量的名称。您可以在程序中使用该变量来存储以后需要的信息。

声明变量时,不需要提供存储的信息类型。变量只是内存中一个有名字的位置。相当多的编程语言要求在声明变量时固定变量的类型。例如,C++或 Java 等语言就是这种情况。然而,许多脚本语言(包括 JavaScript)允许你声明一个变量而不定义它的类型。当一门语言不需要类型定义来声明变量时,那么这门语言就有了松散类型。在 JavaScript 中,你可以一次声明多个变量。例如:

var red, green, fridge, grandMa, applePie;

这里您声明了五个不同的变量,现在您可以在您的程序中使用它们。当您声明这些变量时,它们还不包含值。在这种情况下,这些变量被视为未定义的。您可以使用赋值指令给变量赋值。例如,我们给变量red赋值,如下:

red = 3;

分配指令由以下部分组成:

  • 应该赋值的变量的名称
  • =标志
  • 变量的新值
  • 分号

您可以通过中间的等号识别赋值指令。然而,在 JavaScript 中,最好将这个符号理解为“变成”而不是“等于”。毕竟,变量还不等于等号右边的值——它在指令执行后变成了那个值。描述赋值指令的语法图见图 3-1 。

9781430265382_Fig03-01.jpg

图 3-1 。赋值指令的语法图

现在你已经看到了一条声明变量的指令,和另一条在变量中存储值的指令。但是,如果在声明变量时已经知道要在变量中存储哪个值,则可以将变量的声明和对变量的第一次赋值结合起来:

var red = 3;

执行该指令时,存储器将包含值 3,如图图 3-2 所示。

9781430265382_Fig03-02.jpg

图 3-2 。变量声明和赋值后的内存

以下是更多数值变量声明和赋值的几个例子:

var age = 16;
var numberOfBananas;
numberOfBananas = 2;
var a, b;
a = 4;
var c = 4, d = 15, e = -3;
c = d;
numberOfBananas = age + 12;

在这个例子的第四行,你可以看到在一个声明中声明多个变量是可能的。您甚至可以在一个声明中执行多个带有赋值的声明,如示例代码的第六行所示。在赋值的右边,你可以放其他变量或者数学表达式,就像你在最后两行看到的。指令c = d;导致存储在变量d中的值也存储在变量c中。因为变量d包含值 15,所以执行完这条指令后,变量c也包含值 15。最后一条指令将存储在变量age (16)中的值加上 12,并将结果存储在变量numberOfBananas(现在的值是 28—很多香蕉!).总之,执行完这些指令后,内存看起来类似于图 3-3 中描述的内容。

9781430265382_Fig03-03.jpg

图 3-3 。多变量声明和赋值后的内存概述

在图 3-4 的中显示了声明变量的语法。

9781430265382_Fig03-04.jpg

图 3-4 。带有可选初始化的变量声明的语法图

全局变量和严格模式

不用在使用变量之前声明它,在 JavaScript 中也可以不声明就开始使用变量。例如,考虑以下指令:

var a = 3;
var b;
b = 4;
x = a + b;

正如你所看到的,变量ab是通过使用var关键字在前两条指令中声明的。变量x从来没有被声明过,但是它被用来存储两个变量的和。JavaScript 允许这样做。然而,这是非常糟糕的做法,原因如下。简单地使用一个变量而不声明它的问题是,JavaScript 解释器会在您没有意识到的情况下自动为您声明该变量。如果您碰巧在其他地方使用了一个同名的变量,您的程序可能会显示您不期望的行为,因为该变量已经存在。此外,如果你使用许多不同的变量,你也必须跟踪这些全局变量。但是下面的例子显示了一个更大的问题:

var myDaughtersAge = 12;
var myAge = 36;
var ourAgeDifference = myAge - mydaughtersAge;

在编写这些指令时,您会期望变量ourAgeDifference包含值 24 (36 减 12)。然而现实中会未定义。原因是第三条指令有错别字。变量名不应该是mydaughtersAge,而是myDaughtersAge。浏览器/解释器没有停止脚本并报告错误,而是悄悄地声明了一个新的全局变量mydaughtersAge。因为这个变量是未定义的(它还没有引用一个值),所以用这个变量做的任何计算也将是未定义的。因此,变量ourAgeDifference也是未定义的。

这类问题真的很难解决。幸运的是,新的 EMCAScript 5 标准有一种叫做严格模式的东西。当在严格模式下解释脚本时,不允许在没有声明变量的情况下使用变量。如果您希望在严格模式下解释脚本,您唯一需要做的就是在脚本的开头添加一行,如下所示:

"use strict";
var myDaughtersAge = 12;
var myAge = 36;
var ourAgeDifference = myAge - mydaughtersAge;

字符串/指令"use strict";告诉解释器应该在严格模式下解释脚本。如果您现在尝试运行该脚本,浏览器将停止该脚本,并报告一个错误,即某个变量未经声明就被使用。

除了检查变量是否在使用前声明之外,严格模式还包括其他一些东西,使得编写正确的 JavaScript 代码更加容易。此外,JavaScript 标准的新版本很可能会接近严格模式所施加的 JavaScript 语法限制。

我强烈建议您在严格模式下编写所有的 JavaScript 代码。为了设置模型,本书中剩下的所有例子都是以严格模式编程的。它为程序员省去了很多麻烦,代码也为将来的 JavaScript 版本做好了准备。

指令和表达式

如果您查看语法图中的元素,您可能会注意到赋值右边的值或程序片段被称为一个表达式。那么表达式和指令有什么区别呢?两者的区别在于,指令以某种方式改变内存,而表达式有一个值。指令的例子有方法调用和赋值,正如您在上一节中看到的。指令经常使用表达式。下面是一些表达的例子:

16
numberOfBananas
2
a + 4
numberOfBananas + 12 - a
-3
"myCanvas"

所有这些表达式都代表某种类型的值。除了最后一行,所有的表达式都是数字。最后一个表达式是一个字符串。除了数字和字符串,还有其他种类的表达式。我在本书中讨论了最重要的几个问题。例如,在接下来的部分我将讨论带有运算符的表达式,和第七章描述了使用函数或方法作为表达式。

运算符和更复杂的表达式

本节讨论 JavaScript 知道的不同操作符。您将了解每个运算符的优先级,从而知道计算的执行顺序。您还会看到,在 JavaScript 中,表达式有时会非常复杂。例如,一个变量可以由多个值组成,或者它甚至可以引用一个函数。

算术运算符

在数字表达式中,可以使用以下算术运算符:

  • +添加
  • -减去
  • *倍增
  • /划分
  • %除法余数(读作“模数”)

乘法使用星号是因为数学中常用的符号(∙和×)在电脑键盘上找不到。在 JavaScript 中不允许完全省略这个操作符,数学中也是这样做的(例如,在公式中),因为这会引起由多个字符组成的变量的混淆。

当使用除法运算符/时,在某些情况下,结果是一个实数(而不是整数)。例如,在执行以下指令后,变量y包含值 0.75:

var y = 3/4;

特殊运算符%给出除法余数。例如,14%3的结果是 2,456%10的结果是 6。结果总是介于 0 和运算符右侧的值之间。如果除法的结果是整数,则结果为 0。

运营商的优先级

当在一个表达式中使用多个运算符时,优先的常规算术规则适用:先乘后加。因此,表达式1+2*3的结果是 7,而不是 9。加法和减法具有相同的优先级,乘法和除法也是如此。

如果一个表达式包含多个相同优先级的运算符,则该表达式从左到右计算。所以,10-5-2的结果是 3,不是 7。当您想偏离这些标准的优先级规则时,可以使用括号:例如,(1+2)*33+(6-5)。在实践中,这样的表达式一般也包含变量;否则,您可以自己计算结果(9 和 4)。

不禁止使用多余的括号:例如,1+(2*3)。如果你愿意,你可以完全疯狂地使用这个:((1)+(((2)*3)))。然而,如果你这样做了,你的程序将更难阅读。

总之,一个表达式可以是一个常量值(比如 12),可以是一个变量,可以是圆括号中的另一个表达式,也可以是一个表达式后跟一个运算符再跟另一个表达式。图 3-5 显示了表示表达式的(部分)语法图。

9781430265382_Fig03-05.jpg

图 3-5 。表达式的部分语法图

将函数赋给变量

在 JavaScript 中,函数(指令组)存储在内存中。因此,函数本身也是表达式。所以,把一个函数赋给一个变量是可能的。例如:

var someFunction = function () {
    // do something
}

这个例子声明了一个变量someFunction并给它赋值。这个变量引用的值是一个匿名函数。如果要执行该函数中包含的指令,可以使用变量名调用它,如下:

someFunction();

那么这种定义函数的方式和你已经看到的方式有什么区别呢?

function someFunction () {
    // do something
}

其实没多大区别。最主要的是,通过用传统的方式定义函数(不使用变量),函数在使用之前不必定义。当浏览器解释一个 JavaScript 文件时,它分两个阶段完成。在第一阶段,浏览器构建一个可用功能列表。在第二阶段,浏览器解释脚本的其余部分。这是必要的,因为为了正确地解释脚本,浏览器需要知道哪些功能是可用的。例如,这段 JavaScript 代码运行良好,即使函数是在调用后定义的:

someFunction();
function someFunction () {
    // do something
}

然而,如果函数被赋值给一个变量,那么这仅在第二阶段被解释。这意味着这段代码会导致一个错误:

someFunction();
var someFunction = function () {
    // do something
}

浏览器会抱怨脚本访问了一个还没有声明的变量someFunction。在定义了函数之后调用它是非常好的:

var someFunction = function () {
    // do something
}
someFunction();

由多个值组成的变量

除了包含单个值,变量还可以由多个值组成。这类似于你在函数中所做的,将指令组合在一起。比如:

function mainLoop () {
    canvasContext.fillStyle = "blue";
    canvasContext.fillRect(0, 0, canvas.width, canvas.height);
    update();
    draw();
    window.setTimeout(mainLoop, 1000 / 60);
}

您可以通过调用mainLoop函数来执行所有这些指令。使用大括号将属于该函数的指令分组。与分组指令类似,也可以将变量分组到一个更大的变量中。这个更大的变量包含多个值。看看下面的例子:

var gameCharacter = {
    name : "Merlin",
    skill : "Magician",
    health : 100,
    power : 230
};

这是一个复合变量的例子。变量gameCharacter由几个值组成,每个值都有一个名称和该名称引用的值。所以,从某种意义上说,gameCharacter变量是由其他变量组成的。你可以看到,就像在函数体中一样,变量在大括号中分组。每个子变量都有一个名字,在冒号后面指定这个变量引用的值。由大括号括起来的名称和值组成的表达式称为对象文字。 图 3-6 显示了一个对象文字表达式的(部分)语法图。

9781430265382_Fig03-06.jpg

图 3-6 。对象文字表达式的(部分)语法图

在对gameCharacter变量进行声明和初始化后,内存将如图 3-7 中的所示。

9781430265382_Fig03-07.jpg

图 3-7 。创建复合变量后的内存结构

您可以访问复合变量中的数据,如下所示:

gameCharacter.name = "Arjan";
var damage = gameCharacter.power * 10;

正如你所看到的,你可以通过在一个点后写变量的名字来访问属于gameCharacter的变量。JavaScript 甚至允许您在声明和初始化复合变量之后修改它的结构。例如,看看下面的代码:

var anotherGameCharacter = {
    name : "Arthur",
    skill : "King",
    health : 25,
    power : 35000
};

anotherGameCharacter.familyName = "Pendragon";

变量anotherGameCharacter现在由五部分组成:nameskillhealthpowerfamilyName

因为变量也可以指向函数,所以你甚至可以包含一个指向函数的子变量。例如,您可以将anotherGameCharacter定义如下:

var anotherGameCharacter = {
    name : "Arthur",
    familyName : "Pendragon",
    skill : "King",
    health : 25,
    power : 35000,
    healMe : function () {
        anotherGameCharacter.health = 100;
    }
};

和以前一样,在给变量赋值后,可以给变量添加一个函数部分:

anotherGameCharacter.killMe = function () {
    anotherGameCharacter.health = 0;
};

您可以像访问其他变量一样调用这些函数。以下指令完全恢复游戏角色的健康:

anotherGameCharacter.healMe();

如果你想杀死这个角色,anotherGameCharacter.killMe();指令会完成任务。以这种方式构造变量和函数的好处在于,您可以将相关的数据和函数组合在一起。这个例子将属于同一个游戏角色的变量分组。它还增加了一些对这个游戏角色有用的功能。从现在开始,如果一个函数属于一个变量,我就称这个函数为方法。我将把一个由其他变量组成的变量称为对象。如果一个变量是对象的一部分,我称这个变量为成员变量

你大概可以想象对象和方法有多强大。它们提供了一种将结构带入复杂游戏世界的方式。如果 JavaScript 没有这种能力,您将不得不在程序开始时声明一个很长的变量列表,而不知道变量之间是如何关联的,也不知道您可以用它们做什么。通过将对象中的变量分组并提供属于这些对象的方法,您可以编写更容易理解的程序。在下一节中,您将在一个简单的示例中使用这种能力,在画布上移动一个正方形。

移动广场游戏

本节研究一个在画布上移动一个方块的简单程序。其目的是为了说明两件事:

  • 游戏循环中的updatedraw部分如何更详细地工作
  • 如何使用对象来构建程序

在开始编写这个程序之前,让我们再看一遍 BasicGame 示例的代码:

var canvas = undefined;
var canvasContext = undefined;

function start () {
    canvas = document.getElementById("myCanvas");
    canvasContext = canvas.getContext("2d");
    mainLoop();
}

document.addEventListener('DOMContentLoaded', start);

function update () {
}

function draw () {
    canvasContext.fillStyle = "blue";
    canvasContext.fillRect(0, 0, canvas.width, canvas.height);
}

function mainLoop () {
    update();
    draw();
    window.setTimeout(mainLoop, 1000 / 60);
}

这里有几个变量声明和几个函数来处理这些变量。有了关于在对象中将变量分组的新知识,让我们弄清楚所有这些变量和函数都属于一个游戏应用,如下所示:

"use strict";

var Game = {
    canvas : undefined,
    canvasContext : undefined
};

Game.start = function () {
    Game.canvas = document.getElementById("myCanvas");
    Game.canvasContext = Game.canvas.getContext("2d");
    Game.mainLoop();
};

document.addEventListener('DOMContentLoaded', Game.start);

Game.update = function () {
};

Game.draw = function () {
    Game.canvasContext.fillStyle = "blue";
    Game.canvasContext.fillRect(0, 0, Game.canvas.width, Game.canvas.height);
};

Game.mainLoop = function () {
    Game.update();
    Game.draw();
    window.setTimeout(mainLoop, 1000 / 60);
};

这里您要做的主要事情是创建一个名为Game的复合变量(对象)。这个对象有两个成员变量 : canvascanvasContext。此外,你添加了一些方法到这个对象中,包括共同形成游戏循环的方法。您单独定义属于这个对象的方法(换句话说,它们不是变量声明和初始赋值的一部分)。原因是您现在可以很容易地将组成对象的数据与处理数据的方法区分开来。还要注意,正如我所承诺的,你将指令"use strict";添加到程序中!

现在让我们扩展这个例子,让它显示一个更小的矩形在屏幕上移动。您希望随着时间的推移更改矩形的 x 位置。为此,您必须将矩形的当前 x 位置存储在一个变量中。这样,您可以在update方法中为该变量赋值(在这里您可以改变游戏世界),并在draw方法中使用该变量在屏幕上绘制矩形(在这里您可以在屏幕上绘制游戏世界)。添加这个变量的逻辑位置是作为Game对象的一部分,所以你如下声明并初始化这个对象:

var Game = {
    canvas : undefined,
    canvasContext : undefined,
    rectanglePosition : 0
};

您使用变量rectanglePosition 来存储矩形的 x 位置。在draw方法中,您可以使用该值在屏幕上的某个地方绘制一个矩形。在本例中,您绘制了一个较小的矩形,它没有覆盖整个画布,因此您可以看到它四处移动。这是新的draw方法:

Game.draw = function () {
    Game.canvasContext.fillStyle = "blue";
    Game.canvasContext.fillRect(Game.rectanglePosition, 100, 50, 50);
}

现在你唯一需要做的就是计算矩形的 x 位置。你在update方法中这样做,因为改变矩形的 x 位置意味着你在更新游戏世界。在这个简单的例子中,让我们根据经过的时间来改变矩形的位置。在 JavaScript 中,您可以使用以下两条指令来获取当前系统时间:

var d = new Date();
var currentSystemTime = d.getTime();

您以前没有见过第一行中使用的那种符号。现在,让我们假设new Date()创建了一个复合变量(对象),其中填充了日期和时间信息以及一些有用的方法。其中一种方法就是getTime。你在对象d上调用该方法,并将其结果存储在变量currentSystemTime中。该变量现在包含自 1970 年 1 月 1 日以来经过的毫秒数。).可想而知,这个数字是相当大的。如果您想将 x 位置设置为该值,您需要一个高分辨率的计算机显示器。这台显示器肯定不适合你的房间(或者任何房间,就此而言)。相反,您可以将系统时间除以画布的宽度,取该除法的余数,并将其用作矩形的 x 位置。这样,你总是得到一个介于零和画布宽度之间的 x 位置。下面是完成这项工作的完整的update方法:

Game.update = function () {
    var d = new Date();
    Game.rectanglePosition = d.getTime() % Game.canvas.width;
};

如你所知,updatedraw方法被顺序调用,大约每秒 60 次。每当这种情况发生时,系统时间已经改变(因为时间已经过去),这意味着矩形的位置将被改变,并且它将被绘制在与以前不同的位置。

在这个例子正常工作之前,您还需要做一件事情。如果你像这样运行程序,屏幕上会出现一个蓝色条。原因是您当前正在旧矩形的顶部绘制新矩形。为了解决这个问题,每次在画布上再次绘制之前,你都需要清空画布。清除画布是通过clearRect方法完成的。此方法清除给定大小的矩形中绘制的任何东西。例如,这条指令清除整个画布:

Game.canvasContext.clearRect(0, 0, Game.canvas.width, Game.canvas.height);

为了方便起见,您将这条指令放在一个名为clearCanvas的方法中,如下所示:

Game.clearCanvas = function () {
    Game.canvasContext.clearRect(0, 0, Game.canvas.width, Game.canvas.height);
};

你唯一要做的就是确保在调用updatedraw之前调用这个方法。你在mainLoop方法:中这样做

Game.mainLoop = function() {
    Game.clearCanvas();
    Game.update();
    Game.draw();
    window.setTimeout(Game.mainLoop, 1000 / 60);
};

现在这个例子完成了!双击属于本章的文件夹中的MovingSquare.html文件,即可运行该程序。图 3-8 显示了它的样子。

9781430265382_Fig03-08.jpg

图 3-8 。MovingSquare 示例的输出

研究矩形的位置是如何随时间变化的。试着让下面的一些事情正常工作(如果你有其他想法,也不要犹豫尝试一下):

  • 使矩形从右向左移动。
  • 使矩形从上到下移动。
  • 使矩形在屏幕上对角移动。
  • 让矩形以两倍的速度移动。

变量的范围

声明变量的地方会影响到允许使用变量的地方。看看 MovingSquare 程序中的变量d。这个变量在update方法中声明(并赋值)。因为它是在update方法中声明的,所以只允许在这个方法中使用它。例如,不允许在draw方法中再次使用这个变量。当然,你可以在draw方法中声明另一个名为d的变量,但是重要的是要意识到在update中声明的d变量在那种情况下不会是在draw方法中声明的同一个d变量。

或者,如果你在对象级别声明一个变量,你可以在任何地方使用它,只要你把对象的名字放在它前面。您需要在updatedraw方法中使用矩形的 x 位置,因为在update方法中,您更新这个位置,而在draw方法中,您使用它在 x 位置绘制一个矩形。因此,逻辑上这个变量需要在对象级别声明,这样所有属于这个对象的方法都可以使用这个变量。

可以使用变量的地方统称为变量的范围。在这个例子中,变量d的范围是update方法,变量Game.rectanglePosition的范围是全局范围。

你学到了什么

在本章中,您学习了:

  • 如何使用变量在内存中存储基本信息
  • 如何创建由成员变量和方法组成的对象
  • 如何使用update方法通过变量改变游戏世界和draw方法在屏幕上显示游戏世界

四、游戏素材

前面的章节已经向你展示了如何通过编写你自己的游戏循环方法作为一个名为Game的对象的一部分来制作一个非常基本的游戏应用。您已经看到了 JavaScript 中的哪些指令检索画布以及用于在画布上执行操作的画布上下文。你已经看到了一些简单的例子,其中你改变了背景颜色。您还通过使用当前系统时间和游戏循环方法在屏幕上移动了一个矩形。本章展示了如何在屏幕上绘制图像,这是制作好看游戏的第一步。在计算机图形学中,这些图像也被称为精灵。精灵通常是从文件中加载的。这意味着任何绘制精灵的程序不再仅仅是一组孤立的指令,而是依赖于存储在某处的游戏素材。这立即引入了许多您需要考虑的事情:

  • 你可以从哪个位置载入精灵?
  • 如何从图像文件中检索信息?
  • 你如何在屏幕上画一个精灵?

本章回答了这些问题。

声音是另一种类型的游戏素材。它的处理方式与精灵非常相似。所以,在这一章的最后,你也看到了你如何在你的游戏中回放音乐和音效。

注意精灵的名字来自精灵,这是一种创建用于视频游戏的二维、部分透明光栅图形的过程。在早期,创建这些二维图像需要大量的手工工作;但它产生了一种特殊的图像风格,启发人们创造自己的类似图像,从而产生了一种被称为像素艺术或精灵艺术的艺术技巧。

定位精灵

在程序可以使用任何种类的素材之前,它需要知道在哪里寻找这些素材。默认情况下,充当解释器的浏览器在 JavaScript 文件所在的文件夹中查找精灵。看看属于这一章的 SpriteDrawing 示例。您会在 HTML 文件和 JavaScript 文件所在的文件夹中看到一个名为spr_balloon.png的文件。你可以加载这个精灵并把它画在屏幕上。

装载精灵

现在让我们看看如何从文件中加载精灵。一旦你这样做了,你通过使用一个变量把它存储在内存的某个地方。在几个不同的游戏循环方法中都需要这个变量。在start方法中,加载精灵并将其存储在变量中。在draw方法中,您可以访问该变量以便在屏幕上绘制精灵。因此,你给Game对象添加了一个名为balloonSprite的变量。在这里你可以看到Game变量的声明及其初始化:

var Game = {
    canvas : undefined,
    canvasContext : undefined,
    balloonSprite : undefined
};

Gamestart方法中,你给这些变量赋值。您已经看到了如何检索画布和画布上下文。就像Game一样,画布和画布上下文是由其他变量(或对象)组成的对象。如果你加载了一个 sprite,你就有了一个代表 sprite 的对象。您可以定义一个包含图像中所有信息的对象变量:

Game.balloonSprite = {
    src : "spr_balloon.png",
    width : 35,
    height : 63,
    ...
}

当你想为你的游戏加载数百个精灵时,这就成了问题。每次,你都必须通过使用一个对象文字来定义这样一个对象。此外,您必须确保不会在对象中意外地使用其他变量名,因为这样会导致图像的不一致表示。幸运的是,您可以通过使用类型来避免这个麻烦。

类型基本上是对该类型的对象应该是什么样子的定义;这是一个物体的蓝图。比如 JavaScript 知道一个叫Image的类型。该类型指定图像对象应该具有宽度、高度、源文件等等。有一个非常简单的方法来创建一个类型为Image的对象,使用new关键字:

Game.balloonSprite = new Image();

这比键入变量应该包含的所有内容要容易得多。这个表达基本上对你有用。通过使用类型,您现在有了一个创建对象的简单方法,并且您可以确保这些对象总是具有相同的结构。当一个对象被构造成具有由Image类型指定的结构时,你说这个对象属于 Image类型。

你还没有指出什么数据应该包含在这个变量中。您可以通过将文件名分配给src变量来设置该图像的源文件,该变量始终是Image对象的一部分:

Game.balloonSprite.src = "spr_balloon.png";

一旦设置了src变量,浏览器就开始加载文件。浏览器会自动填充widthheight变量的数据,因为它可以从源文件中提取这些信息。

有时,加载源文件需要一段时间。例如,文件可以存储在世界另一端的网站上。这意味着,如果您试图在设置源文件后立即绘制图像,您可能会遇到麻烦。因此,在开始游戏之前,您需要确保每个图像都已加载。有一种非常简洁的方法可以做到这一点,那就是使用一个事件处理器函数。 在第七章中,你看这是怎么回事。现在,假设加载图像的时间不会超过半秒。通过使用setTimeOut方法,您在 500 毫秒的延迟后调用mainLoop方法:

window.setTimeout(Game.mainLoop, 500);

这就完成了start方法,现在看起来像这样:

Game.start = function () {
    Game.canvas = document.getElementById("myCanvas");
    Game.canvasContext = Game.canvas.getContext("2d");
    Game.balloonSprite = new Image();
    Game.balloonSprite.src = "spr_balloon.png";
    window.setTimeout(Game.mainLoop, 500);
};

精灵可以从任何位置加载。如果你在用 JavaScript 开发游戏,那么考虑一下精灵的组织是个好主意。例如,你可以将游戏中的所有精灵放在一个名为精灵的子文件夹中。然后你必须如下设置源文件:

Game.balloonSprite.src = "sprites/spr_balloon.png";

或者,您甚至可能没有使用自己的图像,而是引用了在另一个网站上找到的图像:

Game.balloonSprite.src = "
http://www.somewebsite.cimg/spr_balloon.png";

JavaScript 允许你从任何你想要的地方加载图像文件。只要确保从另一个网站加载图像时,图像文件的位置是固定的。否则,如果该网站的管理员决定在不通知您的情况下移动所有内容,您的游戏将无法运行。

绘图精灵

加载一个精灵并把它存储在内存中并不意味着精灵被绘制在屏幕上。为此,您需要在draw方法中做一些事情。要在画布上的某个地方绘制一个精灵,可以使用drawImage方法,它是画布上下文对象的一部分。在 JavaScript 中,当一个图像被绘制在某个位置时,那个位置总是指的是图像左上角的。下面是在屏幕左上角绘制精灵的指令:

Game.canvasContext.drawImage(sprite, 0, 0, sprite.width, sprite.height,
    0, 0, sprite.width, sprite.height);

drawImage方法有许多不同的参数。例如,您可以指定要在哪个位置绘制精灵,或者是否只绘制精灵的一部分。你可以简单地调用这个方法并完成它。然而,如果你正在考虑你想要构建的未来游戏,你可以使用一个绘制状态来绘制精灵。

一个绘图状态基本上是一组参数和转换,它们将应用于在该状态下绘制的所有事物。使用绘制状态而不是单独调用drawImage方法的好处是,你可以用精灵做更复杂的转换。例如,使用绘图状态,您可以旋转或缩放精灵,这在游戏中是非常有用的功能。创建一个新的绘图状态是通过调用save方法来完成的:

Game.canvasContext.save();

然后,您可以在此绘图状态下应用各种变换。例如,您可以将精灵移动(或平移)到某个位置:

Game.canvasContext.translate(100, 100);

如果你现在调用drawImage方法,精灵将被绘制在位置(100,100)。完成绘制后,您可以按如下方式移除绘制状态:

Game.canvasContext.restore();

为了方便起见,让我们定义一个为您完成所有这些工作的方法:

Game.drawImage = function (sprite, position) {
    Game.canvasContext.save();
    Game.canvasContext.translate(position.x, position.y);
    Game.canvasContext.drawImage(sprite, 0, 0, sprite.width, sprite.height,
        0, 0, sprite.width, sprite.height);
    Game.canvasContext.restore();
};

通过查看参数,可以看出,这个方法需要两条信息:应该绘制的精灵和应该绘制的位置。sprite 的类型应该是Image(尽管在 JavaScript 中定义函数时不容易实现这一点)。位置是一个由x部分和y部分组成的对象变量。当你调用这个方法时,你必须提供这个信息。例如,可以在位置(100,100)绘制气球精灵,如下所示:

Game.drawImage(Game.balloonSprite, { x : 100, y : 100 });

您使用大括号来定义一个包含xy组件的对象文字。如你所见,允许在调用方法的指令中定义一个对象。或者,您可以首先定义一个对象,将其存储在一个变量中,然后使用该变量调用drawImage方法:

var balloonPos = {
    x : 100,
    y : 100
};
Game.drawImage(Game.balloonSprite, balloonPos);

这段代码做的事情与前面对drawImage的调用完全一样,除了要写得更长。您可以简单地将drawImage方法调用放入draw方法中,气球将被绘制在所需的位置:

Game.draw = function () {
    Game.drawImage(Game.balloonSprite, { x : 100, y : 100 });
};

图 4-1 显示了程序在浏览器中的输出。

9781430265382_Fig04-01.jpg

图 4-1 。SpriteDrawing 程序的输出

同样,请注意,如果您告诉浏览器在给定的位置绘制一个 sprite,sprite 的左上角部分将被绘制在那里。

移动精灵

现在你可以在屏幕上画一个精灵了,你可以使用游戏循环让它移动,就像你在第三章的 MovingSquare 例子中对正方形所做的那样。让我们对这个程序做一个小小的扩展,根据经过的时间改变气球的位置。为了做到这一点,你必须把气球的位置存储在某个地方。您需要在update方法中计算这个位置,并在draw方法中在那个位置绘制气球。因此,您向表示位置的Game对象添加一个变量,如下所示:

var Game = {
    canvas : undefined,
    canvasContext : undefined,
    balloonSprite : undefined,
    balloonPosition : { x : 0, y : 50 }
};

正如您所看到的,您将位置定义为由Game对象中的两个变量(xy)组成的对象。现在,您可以向update方法添加一条指令,根据经过的时间修改 x 位置,就像您在 MovingSquare 示例中所做的那样。下面是update的方法:

Game.update = function () {
    var d = new Date();
    Game.balloonPosition.x = d.getTime() % Game.canvas.width;
};

现在剩下要做的唯一一件事就是确保在屏幕上用draw方法绘制气球时使用了balloonPosition变量:

Game.drawImage(Game.balloonSprite, Game.balloonPosition);

加载和绘制多个精灵

只用纯白色背景构建游戏有些无聊。通过显示背景精灵,你可以让你的游戏看起来更有吸引力。这意味着你必须在start方法中加载另一个精灵,并扩展draw方法来绘制它。这个程序的最终版本叫做 FlyingSprite,你可以在属于本章的 sample 文件夹中找到完整的源代码。如果您在浏览器中打开 FlyingSprite 程序,您会看到现在绘制了两个精灵:一个背景和一个气球。为此,您添加另一个变量来包含背景精灵。像balloonSprite变量一样,这个变量也是Game对象的一部分:

var Game = {
    canvas : undefined,
    canvasContext : undefined,
    backgroundSprite : undefined,
    balloonSprite : undefined,
    balloonPosition : { x : 0, y : 50 }
};

另外,在draw方法中,现在有两个对drawImage方法的调用,而不是一个:

Game.draw = function () {
    Game.drawImage(Game.backgroundSprite, { x : 0, y : 0 });
    Game.drawImage(Game.balloonSprite, Game.balloonPosition);
};

这些方法的调用顺序非常重要!因为你想让气球出现在背景的上面,你先画背景,然后画气球。如果你反过来做,背景会画在气球上,你就看不到了(自己试试)。图 4-2 显示了程序的输出。

9781430265382_Fig04-02.jpg

图 4-2 。FlyingSprite 程序的输出

每次你想在屏幕上画一个精灵,你就在draw方法中添加一个对drawImage方法的调用。您可以在屏幕上的不同位置多次绘制一个精灵。例如,如果你想在背景的不同位置画几个气球,你只需为每个你想画的气球调用drawImage,并把想要的位置作为参数传递,如下所示:

Game.draw = function () {
    Game.drawImage(Game.backgroundSprite, { x : 0, y : 0 });
    Game.drawImage(Game.balloonSprite, { x : 0, y : 0 });
    Game.drawImage(Game.balloonSprite, { x : 100, y : 0 });
    Game.drawImage(Game.balloonSprite, { x : 200, y : 0 });
    Game.drawImage(Game.balloonSprite, { x : 0, y : 300 });
    Game.drawImage(Game.balloonSprite, { x : 200, y : 300 });
};

再次,注意你绘制精灵的顺序。

你也可以同时绘制多个移动的精灵。对于每个气球,您可以定义它自己的位置变量,该变量在update方法中更新:

Game.update = function () {
    var d = new Date();
    Game.balloonPosition1.x = d.getTime() % Game.canvas.width;
    Game.balloonPosition2.x = (d.getTime() + 100) % Game.canvas.width;
    Game.balloonPosition3.x = (d.getTime() + 200) % Game.canvas.width;
};

draw方法中,您使用这些位置同时绘制移动和静止的气球:

Game.draw = function () {
    Game.drawImage(Game.backgroundSprite, Game.balloonPosition1);
    Game.drawImage(Game.balloonSprite, Game.balloonPosition2);
    Game.drawImage(Game.balloonSprite, Game.balloonPosition3);
    Game.drawImage(Game.balloonSprite, { x : 200, y : 0 });
    Game.drawImage(Game.balloonSprite, { x : 0, y : 300 });
    Game.drawImage(Game.balloonSprite, { x : 200, y : 300 });
};

摆弄一下这个例子。想出在屏幕上画移动气球的不同方法。尝试几个不同的位置值。你能让一些气球比另一些移动得更快或更慢吗?

音乐和声音

另一种常用的游戏素材是声音。大多数游戏都有音效和背景音乐。出于各种原因,这些都很重要。音效提供了重要的线索,向用户表明发生了什么事情。例如,当用户点击按钮时播放卡嗒声向用户提供了按钮确实被按下的反馈。听到脚步声表明敌人可能就在附近,尽管玩家可能还没有看到他们。听到远处有铃声响起,可以表明有事情要发生了。在这方面,老游戏 Myst 是一个经典,因为许多关于如何进步的线索通过声音传递给了玩家。

水滴声、树风声和远处汽车声等大气音效增强了体验,给人一种身临其境的感觉。它们使环境更加生动,即使屏幕上什么也没有发生。

注意音乐在玩家体验环境和行动的过程中起着至关重要的作用。音乐可以用来制造紧张、悲伤、快乐和许多其他情绪。然而,在游戏中处理音乐比在电影中要困难得多。在电影中,很清楚将要发生什么,所以音乐可以完美地匹配。但是在游戏中,部分动作是在玩家的控制之下。现代游戏使用自适应音乐,这种音乐随着游戏剧情的发展而不断变化。

如果你想在游戏中实现更高级的音乐和声音处理,基本的 JavaScript 声音引擎是不行的。改用 Web Audio ( http://www.w3.org/TR/webaudio/),这是一个高级库,用于处理和合成许多现代浏览器支持的音频。

在 JavaScript 中,播放背景音乐或声音效果非常容易。要使用声音,您首先需要一个可以播放的声音文件。在 FlyingSpriteWithSound 程序中,你播放文件snd_music.mp3,它作为背景音乐。与存储和使用精灵类似,您向存储音乐数据的Game对象添加一个变量。因此,Game对象的声明和初始化如下:

var Game = {
    canvas : undefined,
    canvasContext : undefined,
    backgroundSprite : undefined,
    balloonSprite : undefined,
    balloonPosition : { x : 0, y : 50 },
    backgroundMusic : undefined
};

为了加载音效或背景音乐,您需要向start方法添加一些指令。JavaScript 提供了一种类型,您可以将其用作创建表示声音的对象的蓝图。这种类型叫做Audio。您可以创建该类型的对象,并开始加载声音,如下所示:

Game.backgroundMusic = new Audio();
Game.backgroundMusic.src = "snd_music.mp3";

正如你所看到的,这几乎和加载精灵的方式一样。现在,您可以调用定义为该对象一部分的方法,并且可以设置该对象的成员变量。例如,以下指令告诉浏览器开始播放存储在Game.backgroundMusic变量中的音频:

Game.backgroundMusic.play();

您希望降低背景音乐的音量,以便稍后播放(更大声)的音效。按照以下说明设置音量:

Game.backgroundMusic.volume = 0.4;

volume成员变量一般是 0 到 1 之间的值,其中 0 表示没有声音,1 表示以最大音量播放声音。

从技术上来说,背景音乐和音效没有区别。正常情况下,背景音乐以较低的音量播放;许多游戏会循环播放背景音乐,这样当歌曲结束时,音频会从头开始播放。你稍后会看到如何去做。你在本书中开发的所有游戏都使用这两种声音(背景音乐和声音效果)来使游戏更加刺激。

注意在游戏中使用声音和音乐时,你需要注意一些事情。声音对一些播放器来说很烦人,所以如果你使用音效或音乐,确保播放器有办法关掉它们。还有,不要强迫玩家等到一个声音播放完了才可以继续。您可能已经创作了一首很棒的歌曲,想在介绍屏幕显示时播放,但是玩家并不是为了听您的音乐而启动您的游戏,他们只是想玩!同样的原理也适用于游戏中的视频序列。总是为用户提供一个跳过这些的方法(即使你让你最喜欢的家庭成员提供僵尸声音)。最后,加载声音和音乐可能需要时间,尤其是当文件托管在网站上时。尽可能使用小的声音文件。

你学到了什么

在本章中,您学习了:

  • 如何将精灵和声音等游戏资源加载到内存中
  • 如何在屏幕上绘制多个精灵并移动它们
  • 如何在游戏中播放背景音乐和音效*

五、知道玩家在做什么

在这一章中,你开始创建一个名为 Painter 的游戏。在这个游戏中,你需要显示在屏幕上移动的精灵。您已经看到了一些加载和显示精灵的例子。此外,您已经看到了使用当前时间信息来改变精灵的位置是可能的。您可以在这些示例的基础上开始创建 Painter。此外,您还将学习如何处理游戏中玩家的输入。你会看到如何检索玩家正在做什么,以及游戏世界如何根据这些信息而变化。从 FlyingSprite 程序的一个简单扩展开始,它在鼠标指针的位置绘制一个气球。下一章将探讨其他类型的输入,如键盘和触摸输入。

跟随鼠标指针的精灵

现在你知道了如何在屏幕上显示精灵,让我们看看你是否可以使用玩家输入来控制精灵的位置。为此,您必须找出鼠标的当前位置。本节向您展示了如何检索这个位置,以及如何使用它来绘制一个跟随鼠标指针的 sprite。

检索鼠标位置

看看本书示例中的程序 Balloon1。它和 FlyingSprite 程序没有太大区别。在 FlyingSprite 中,您通过使用系统时间来计算气球的位置:

var d = new Date();
Game.balloonPosition.x = d.getTime() * 0.3 % Game.canvas.width;

您计算的位置存储在变量balloonPosition中。现在您想要创建一个程序,其中气球位置与当前鼠标位置相同,而不是基于经过的时间进行计算。使用事件很容易获得当前鼠标位置。

在 JavaScript 中,您可以处理许多不同种类的事件。事件示例如下:

  • 玩家移动鼠标。
  • 玩家左键点击。
  • 玩家点击一个按钮。
  • 已经加载了一个 HTML 页面。
  • 从网络连接接收消息。
  • 精灵已完成加载。

当这样的事件发生时,你可以选择执行指令。例如,当玩家移动鼠标时,您可以执行一些指令来检索新的鼠标位置,并将其存储在一个变量中,这样您就可以使用它在该位置绘制一个精灵。一些 JavaScript 对象可以帮助您做到这一点。例如,当您显示一个 HTML 页面时,document变量让您可以访问页面中的所有元素。但是,更重要的是,这个变量还允许您访问用户通过使用鼠标、键盘或触摸屏与文档交互的方式。

您已经以多种方式使用了这个变量。例如,这里使用document从 HTML 页面中检索 canvas 元素:

Game.canvas = document.getElementById("myCanvas");

除了getElementByIddocument对象还有很多其他的方法和成员变量。例如,有一个名为onmousemove的成员变量,您可以给它赋值。这个成员变量不是指数值或字符串,而是指函数/方法。每当鼠标移动时,浏览器都会调用该函数。然后,您可以在函数中编写指令,以您希望的任何方式处理该事件。因此,这类函数被称为事件处理程序。使用事件处理函数是一种非常有效的处理输入的方式。

另一种方法是将指令放入游戏循环中,在每次迭代中检索当前鼠标位置或当前按下的键。虽然这样做可行,但比使用事件处理程序要慢得多,因为你必须在每次迭代中检查输入,而不是只在玩家实际做某件事的时候才检查。

事件处理函数有一个特定的头。它包含一个参数,当调用该函数时,该参数包含一个提供事件信息的对象。例如,下面是一个空的事件处理函数:

function handleMouseMove(evt) {
    // do something here
}

如您所见,该函数只有一个参数evt,它将包含关于需要处理的事件的信息。现在您可以将该函数分配给onmousemove变量:

document.onmousemove = handleMouseMove;

现在,每次移动鼠标,都会调用handleMouseMove函数。您可以在此函数中输入指令,从evt对象中提取鼠标位置。例如,这个事件处理函数获取鼠标的 x 位置和 y 位置,并将它们存储在变量balloonPosition : 中

function handleMouseMove(evt) {
    Game.balloonPosition = { x : evt.pageX, y : evt.pageY };
}

evt对象的pageXpageY成员变量包含鼠标相对于页面的位置,意味着页面的左上角有坐标(0,0)。你可以在图 5-1 中看到一些鼠标位置的例子:在浏览器中运行程序时,其中三个角标有它们各自的位置。

9781430265382_Fig05-01.jpg

图 5-1 。左上角、右上角和右下角的鼠标位置

因为Draw方法只是在鼠标位置绘制气球,所以气球现在跟随鼠标。图 5-2 显示了它的样子。您可以看到鼠标指针下方绘制的气球;当你移动指针时,它会跟踪它。

9781430265382_Fig05-02.jpg

图 5-2 。气球 1 项目截图

你可以在图 5-2 中看到,气球并没有出现在指针的正下方。这是有原因的,下一节将详细讨论这个问题。现在,只要记住精灵被视为一个矩形。左上角与指针尖端对齐。气球看起来没有对齐,因为气球是圆形的,没有延伸到矩形的角上。

除了pageXpageY,你还可以使用clientXclientY,它们也给出了鼠标的位置。然而,clientXclientY并没有把滚动考虑进去。假设你计算鼠标位置如下:

Game.balloonPosition = { x : evt.clientX, y : evt.clientY };

图 5-3 显示了现在可能出现的问题。由于滚动,clientY值小于 480,即使鼠标位于背景图像的底部。因此,不再在鼠标位置绘制气球。所以我建议一直用pageXpageY,不要用clientXclientY。然而,在某些情况下,不考虑滚动可能是有用的——例如,如果您正在开发一个令人讨厌的广告,即使用户试图滚动它,它也会一直出现在浏览器视图的中间。

9781430265382_Fig05-03.jpg

图 5-3 。鼠标指针在背景精灵的底部,但是鼠标的 y 位置是 340(而不是 480 ),因为clientY没有考虑滚动

更改精灵的来源

当您运行 Balloon1 示例时,请注意绘制的气球使得 sprite 的左上角位于当前鼠标位置。当您在某个位置绘制精灵时,默认行为是在该位置绘制精灵的左上角。如果执行下面的指令

Game.drawImage(someSprite, somePosition);

名为someSprite的子画面被绘制在屏幕上,使得其左上角位于位置somePosition。你也可以把精灵的左上角叫做它的原点。那么,如果要改变这个原点呢?例如,假设您想要在位置somePosition绘制精灵someSprite的中心。嗯,你可以通过使用Image类型的widthheight变量来计算。让我们声明一个名为origin的变量,并将精灵的中心存储在其中:

var origin = { x : someSprite.width / 2, y : someSprite.height / 2 };

现在,如果你想用这个不同的原点绘制精灵someSprite,你可以这样做:

var pos = { x : somePosition.x - origin.x,
            y : somePosition.y - origin.y };
Game.drawImage(someSprite, pos);

通过从位置中减去原点,子画面被绘制在一个偏移量处,使得位置somePosition指示子画面的中心。除了自己计算相对于原点的位置,canvas 上下文中的drawImage方法也有一种指定原点偏移量的方法。这里有一个例子:

Game.canvasContext.save();
Game.canvasContext.translate(position.x, position.y);
Game.canvasContext.drawImage(sprite, 0, 0, sprite.width, sprite.height,
     -origin.x, -origin.y, sprite.width, sprite.height);
Game.canvasContext.restore();

在本例中,第一步是保存当前绘图状态。然后,应用变换。你从翻译到一个给定的位置开始。然后您调用drawImage方法,在该方法中您必须提供许多不同的参数:将绘制哪个精灵以及(使用四个参数)应该绘制精灵的哪个部分。您可以通过指示 sprite 的左上角坐标和应该绘制的矩形部分的大小来做到这一点。在这个简单的例子中,您想要绘制整个 sprite,所以左上角的坐标是点(0,0)。您绘制一个矩形部件,其宽度和高度与整个 sprite 相同。这也表明,可以使用该特性在一个图像文件中存储多个精灵,而只需将该文件加载到内存中一次。在本书的后面,在第十八章中,你会看到一个很好的方法来做到这一点,并将其整合到你的游戏应用中。

接下来,您可以指定位置偏移。您可以在前面的代码中看到,您将该偏移量设置为负的原点值。换句话说,你从当前位置减去原点。这样,左上角的坐标就移动到原点。假设您有一个宽度和高度为 22 像素的球精灵。假设你想在位置(0,0)画这个球,这个位置是屏幕的左上角。根据你选择的原点,结果是不同的。图 5-4 显示了用两个不同的原点在位置(0,0)绘制一个球精灵的两个例子。左边的例子显示球的原点在左上角,右边的例子显示球的原点在精灵的中心。

9781430265382_Fig05-04.jpg

图 5-4 。在位置(0,0)绘制一个球精灵,原点在精灵的左上角(左)或精灵的中心(右)

您可能已经注意到,在 JavaScript 中,从一个位置减去另一个位置有点麻烦:

var pos = { x : somePosition.x - origin.x,
            y : somePosition.y - origin.y };

如果你能这样写就更好了:

var pos = somePosition - origin;

不幸的是,这在 JavaScript 中是不可能的。一些编程语言(如 Java 和 C#)支持运算符重载。这允许程序员定义当两个对象使用加号运算符“相加”时应该发生什么。然而,并没有失去一切。可以定义在对象文字上执行这些算术运算的方法,比如上面定义的方法。第八章对此有更详细的论述。

现在你知道了如何在不同的原点绘制精灵,例如,你可以绘制一个气球,使其底部中心与鼠标指针相连。要了解这一点,请看气球 2 计划。您声明了一个额外的成员变量,用于存储气球精灵的来源:

var Game = {
    canvas : undefined,
    canvasContext : undefined,
    backgroundSprite : undefined,
    balloonSprite : undefined,
    mousePosition : { x : 0, y : 0 },
    balloonOrigin : { x : 0, y : 0 }
};

你只能在精灵载入后计算原点。所以,为了确保万无一失,您可以使用下面的指令在draw方法中计算原点:

Game.balloonOrigin = { x : Game.balloonSprite.width / 2,
                       y : Game.balloonSprite.height };

原点设置为精灵宽度的一半,但为其全高。换句话说,这个原点就是精灵的底部中心,这正是你想要的。用draw方法计算原点不理想;如果您可以只计算一次原点,就在图像加载之后,那就更好了。后来,你发现了一个更好的方法。

现在可以扩展Game对象中的drawImage方法,使其支持在不同的原点绘制精灵。您唯一需要做的就是添加一个额外的位置参数,并将该参数中的xy值传递给画布上下文的drawImage方法。下面是完整的方法:

Game.drawImage = function (sprite, position, origin) {
    Game.canvasContext.save();
    Game.canvasContext.translate(position.x, position.y);
    Game.canvasContext.drawImage(sprite, 0, 0, sprite.width, sprite.height,
        -origin.x, -origin.y, sprite.width, sprite.height);
    Game.canvasContext.restore();
};

draw方法中,您现在可以计算原点并将其传递给drawImage方法,如下所示:

Game.draw = function () {
    Game.drawImage(Game.backgroundSprite, { x : 0, y : 0 }, { x : 0, y : 0 });
    Game.balloonOrigin = { x : Game.balloonSprite.width / 2,
                           y : Game.balloonSprite.height };
    Game.drawImage(Game.balloonSprite, Game.mousePosition, Game.balloonOrigin);
};

使用鼠标位置来旋转炮管

画师游戏的一个特点是包含了一个根据鼠标位置旋转的炮管。这个大炮是由玩家控制的,目的是发射颜料球。您可以使用本章中讨论的工具编写程序的一部分来完成这项工作。您可以在本章的 Painter1 示例中看到这一点。

要做到这一点,您必须声明一些成员变量。首先,你需要变量来存储背景和炮管精灵。你还需要存储当前的鼠标位置,就像你在本章前面的例子中所做的那样。然后,因为你旋转炮管,你需要存储它的位置,它的原点,和它当前的旋转。最后,您需要画布和画布上下文,以便您可以绘制游戏对象。像往常一样,所有这些变量都被声明为Game对象的成员:

var Game = {
    canvas : undefined,
    canvasContext : undefined,
    backgroundSprite : undefined,
    cannonBarrelSprite : undefined,
    mousePosition : { x : 0, y : 0 },
    cannonPosition : { x : 72, y : 405 },
    cannonOrigin : { x : 34, y : 34 },
    cannonRotation : 0
};

定义Game变量时,炮管的位置和原点都被赋予一个值。炮管的位置是这样选择的,它正好适合已经画在背景上的炮座。桶图像包含一个圆形零件,实际的桶附着在该零件上。您希望桶围绕圆形部分的中心旋转。这意味着你必须把这个中心作为原点。因为圆形部分在 sprite 的左侧,并且这个圆的半径是 cannon-barrel sprite 高度的一半(68 像素高),所以将 barrel 原点设置为(34,34),如代码所示。

为了以一个角度绘制炮管,当你在屏幕上绘制炮管精灵时,你需要应用一个旋转。这意味着您必须扩展drawImage方法,以便它可以考虑旋转。应用旋转是通过作为画布上下文一部分的rotate方法完成的。您还可以向drawImage方法添加一个参数,让您指定对象应该旋转的角度。这是新版本的drawImage方法的样子:

Game.drawImage = function (sprite, position, rotation, origin) {
    Game.canvasContext.save();
    Game.canvasContext.translate(position.x, position.y);
    Game.canvasContext.rotate(rotation);
    Game.canvasContext.drawImage(sprite, 0, 0, sprite.width, sprite.height,
        -origin.x, -origin.y, sprite.width, sprite.height);
    Game.canvasContext.restore();
};

start方法中,加载两个精灵:

Game.backgroundSprite = new Image();
Game.backgroundSprite.src = "spr_background.jpg";
Game.cannonBarrelSprite = new Image();
Game.cannonBarrelSprite.src = "spr_cannon_barrel.png";

下一步是在游戏循环中实现这些方法。直到现在,update方法一直是空的。现在你有充分的理由使用它了。在update方法中,您更新了游戏世界,在这种情况下,这意味着您计算了绘制炮管的角度。这个怎么算?看一下图 5-5 。

9781430265382_Fig05-05.jpg

图 5-5 。基于鼠标指针位置计算桶的角度

如果你记得你的数学课,你可能记得三角形的角度可以用三角函数来计算。在这种情况下,您可以使用正切函数计算角度,如下所示:

image

换句话说,角度由下式给出

image

通过计算当前鼠标位置和炮管位置之间的差值,可以计算对边和邻边的长度,如下所示:

var opposite = Game.mousePosition.y - Game.cannonPosition.y;
var adjacent = Game.mousePosition.x - Game.cannonPosition.x;

现在,您必须使用这些值来计算反正切。你是怎么做到的?幸运的是,JavaScript 知道一个Math对象可以提供帮助。Math对象包含许多有用的数学函数,包括三角函数,如正弦、余弦和正切,以及它们的逆反正弦、反余弦和反正切。Math对象中的两个函数计算反正切。第一个版本采用单个值作为参数。你不能在这种情况下使用这个版本:当鼠标直接在桶上时,会发生被零除的情况,因为相邻是零。

对于需要计算反正切同时考虑可能的奇点的情况,有一种替代的反正切方法。atan2方法将相反和相邻的长度作为单独的参数,并在这种情况下返回 90 度的等效弧度。你可以用这个方法计算角度,如下:

Game.cannonRotation = Math.atan2(opposite, adjacent);

这些指令都放在update里。下面是完整的方法:

Game.update = function () {
    var opposite = Game.mousePosition.y - Game.cannonPosition.y;
    var adjacent = Game.mousePosition.x - Game.cannonPosition.x;
    Game.cannonRotation = Math.atan2(opposite, adjacent);
};

剩下唯一要做的就是用draw方法在屏幕上绘制精灵,在正确的位置和角度:

Game.draw = function () {
    Game.clearCanvas();
    Game.drawImage(Game.backgroundSprite, { x : 0, y : 0 }, 0,
         { x : 0, y : 0 });
    Game.drawImage(Game.cannonBarrelSprite, Game.cannonPosition,
         Game.cannonRotation, Game.cannonOrigin);
};

你学到了什么

在本章中,您学习了:

  • 如何使用事件处理程序读取当前鼠标位置,以及如何在当前鼠标位置绘制精灵
  • 如何画一个有角度的精灵
  • 如何根据鼠标位置改变绘制精灵的角度

六、对玩家输入做出反应

在这一章中,你会看到你的游戏程序是如何对按键做出反应的。为了做到这一点,您需要一个名为if的指令,它在条件满足时执行一条指令(或一组指令)。您还将学习如何将代码更多地组织成对象和方法。

游戏中的对象

到目前为止,所有的示例程序都有一个名为Game的大对象。这个对象由许多变量组成,用于存储画布及其上下文、精灵、位置等等。这是 Painter1 示例中的Game对象的外观:

var Game = {
    canvas : undefined,
    canvasContext : undefined,
    backgroundSprite : undefined,
    cannonBarrelSprite : undefined,
    mousePosition : { x : 0, y : 0 },
    cannonPosition : { x : 72, y : 405 },
    cannonOrigin : { x : 34, y : 34 },
    cannonRotation : 0
};

正如你所看到的,它已经包含了相当多的变量,即使对于一个只画背景和旋转大炮的简单程序也是如此。随着你开发的游戏变得越来越复杂,这个变量列表会越来越大,结果,代码会变得更难被其他开发者理解(对你来说,当你几个月不看代码的时候)。问题是你把所有东西都存储在一个叫做Game的大对象中。从概念上讲,这是有意义的,因为Game包含了与画家游戏相关的一切。然而,如果你把事情分开一点,代码会更容易理解。

如果您查看Game对象的内容,您可以看到某些变量以某种方式组合在一起。例如,canvascanvasContext变量属于同一类,因为它们都与画布有关。此外,相当多的变量存储关于大炮的信息,例如它的位置或它的旋转。您可以将相关的变量分组到不同的对象中,以便在代码中更清楚地说明这些变量是相关的。例如,看看这个例子:

var Canvas2D = {
    canvas : undefined,
    canvasContext : undefined
};

var Game = {
    backgroundSprite : undefined,
};

var cannon = {
    cannonBarrelSprite : undefined,
    position : { x : 72, y : 405 },
    origin : { x : 34, y : 34 },
    rotation : 0
};

var Mouse = { position : { x : 0, y : 0 } };

正如您所看到的,现在您有了几个不同的对象,每个对象都包含一些之前分组在Game对象中的变量。现在看哪些变量属于大炮,哪些变量属于画布就容易多了。好的方面是你可以对方法做同样的事情。例如,您可以将清除画布并在其上绘制图像的方法添加到Canvas2D对象中,如下所示:

Canvas2D.clear = function () {
    Canvas2D.canvasContext.clearRect(0, 0, this.canvas.width,
        this.canvas.height);
};

Canvas2D.drawImage = function (sprite, position, rotation, origin) {
    // canvas drawing code
};

使用不同的对象,而不是包含游戏所有内容的单一对象,会使你的代码更容易阅读。当然,只有当你以一种逻辑方式将变量分布在对象上时,这才是真的。 即使是简单的游戏,也有很多方法可以组织代码。所有开发人员都有自己的风格。当你继续读下去,你会发现这本书也遵循某种风格。你可能不同意这种风格,或者有时你处理问题的方式可能与本书不同。没关系。编程问题几乎没有唯一正确的解决方案。

回到对象的分布,您可以看到我们以大写字符开始命名大多数对象(例如Canvas2D),但是cannon对象以小写字符开始。我们这样做是有原因的,我们将在后面详细讨论。现在,我们只能说以大写字母开头的对象对任何游戏的都有用,但是名称以小写字母开头的对象只用于特定的游戏。在这种情况下,你可以想象Canvas2D对象可以在任何 HTML5 游戏中使用,但是cannon对象只对画师游戏有用。**

装载精灵

现在你在游戏中有了不同的物体,你在哪里加载精灵呢?你可以在Game对象的start方法中加载所有的精灵,但是另一个选择是添加一个类似的方法到cannon对象,并加载属于那里的大炮的精灵。哪种方法更好?

在该对象的初始化方法中加载属于cannon对象的精灵是有道理的。这样,您可以从代码中清楚地看到哪些精灵属于哪个对象。然而,这也意味着如果你为不同的游戏对象重用同一个图像,你必须多次加载这个精灵。对于在浏览器中运行的游戏,这意味着浏览器必须从服务器下载图像文件,这可能需要时间。更好的选择是在游戏开始时加载游戏需要的所有精灵。为了清楚地将精灵与程序的其余部分分开,您将它们存储在一个名为sprites的对象中。该对象在程序的顶部声明为空对象:

var sprites = {};

Game.start方法中用精灵填充这个变量。对于要加载的每个 sprite,创建一个 Image 对象,然后将其源设置为 sprite 位置。因为您已经使用了相当多不同的精灵,所以您从另一个包含属于画师游戏的所有精灵的素材文件夹中加载这些精灵。这样,你就不必为书中所有使用这些精灵的不同例子复制这些图像文件。以下是加载本章中 Painter2 示例所需的各种精灵的说明:

var spriteFolder = "../../assets/Painter/sprites/";
sprites.background = new Image();
sprites.background.src = spriteFolder + "spr_background.jpg";
sprites.cannon_barrel = new Image();
sprites.cannon_barrel.src = spriteFolder + "spr_cannon_barrel.png";
sprites.cannon_red = new Image();
sprites.cannon_red.src = spriteFolder + "spr_cannon_red.png";
sprites.cannon_green = new Image();
sprites.cannon_green.src = spriteFolder + "spr_cannon_green.png";
sprites.cannon_blue = new Image();
sprites.cannon_blue.src = spriteFolder + "spr_cannon_blue.png";

这里使用了+操作符来连接文本。例如,表达式spriteFolder + "spr_background.jpg"的值是"../../assets/Painter/sprites/spr_background.jpg"。精灵文件夹路径看起来有点复杂。这../../ bit 表示您在层次结构中向上移动了两个目录。这是必要的,因为示例目录Painter2Painter2aassets目录不在同一层。您将这些图像存储在属于sprites对象的变量中。稍后,当您需要检索精灵时,您可以访问该对象。下一步是处理玩家的按键。

处理按键事件

在前一章中,您看到了如何使用事件处理程序来读取鼠标的当前位置。以非常相似的方式,您可以对玩家按住键盘上的一个键的事件做出反应。同样,您可以通过定义事件处理程序来实现这一点。您需要存储被按住的键,以便以后可以访问它并利用该信息做一些事情。存储哪个键被按下的最简单方法是使用键码。键码基本上就是代表某个键的数字。例如,空格键可能是数字 13,或者 A 键可能是数字 65。那么,为什么这些键使用这些特定的数字,而不是其他的呢?因为字符码表 是标准化的,而且这些年来出现了不同的标准。

在 20 世纪 70 年代,程序员认为 2 6 = 64 个符号就足以表示您可能需要的所有符号:26 个字符、10 个数字和 28 个标点符号(逗号、分号等等)。尽管这意味着小写和大写字符没有区别,但这在当时并不是问题。

在 20 世纪 80 年代,人们使用 2 7 = 128 种不同的符号:26 个大写字符、26 个小写字符、10 个数字、33 个标点符号和 33 个特殊字符(行尾、制表、嘟嘟声等等)。这些符号的顺序被称为 ASCII :美国信息交换标准代码。这对英语来说很好,但对法语、德语、荷兰语、西班牙语等其他语言来说还不够。

结果在 90 年代,用 2 8 = 256 个符号构造了新的码表;不同国家最常见的字母也有所体现。从 0 到 127 的符号与 ASCII 中的相同,但符号 128 到 255 用于表示属于给定语言的特殊字符。根据语言(英语、俄语、印度语等),使用不同的代码表。例如,西欧代码表是 Latin1。对于东欧,使用另一个代码表(波兰语和捷克语有许多特殊的口音,在 Latin1 表中没有更多的空间)。希腊、俄罗斯、希伯来和印度的梵文字母都有自己的代码表。这是处理不同语言的合理方式,但是如果您想同时存储不同语言的文本,事情就变得复杂了。此外,包含超过 128 个符号的语言(如普通话)也不可能用这种格式来表示。

二十一世纪初,编码标准再次扩展为包含 2 16 = 65536 个不同符号的表。这张表可以很容易地包含世界上所有的字母表,包括许多不同的标点符号和其他符号。如果你曾经遇到一个外星物种,这张表可能也有空间来表示外星人语言中的字符。码表叫做 Unicode 。Unicode 的前 256 个符号与 Latin1 码表的符号相同。

回到您想要为示例存储的按键代码,让我们添加一个包含最后按下的按键的简单变量:

var Keyboard = { keyDown : -1 };

当变量被初始化时,它包含一个包含值-1 的keyDown变量。该值表示玩家当前没有按下任何键。当玩家按下一个键时,你必须将键码存储在变量Keyboard.keyDown中。你可以通过编写一个事件处理程序 来存储当前被按下的键。下面是这个事件处理程序的样子:

function handleKeyDown(evt) {
    Keyboard.keyDown = evt.keyCode;
}

如您所见,该函数获取一个事件作为参数。该事件对象有一个名为keyCode的变量,包含玩家当前按下的键的键码。

您在Game.start中分配这个事件处理函数,如下所示:

document.onkeydown = handleKeyDown;

现在,每当玩家按下一个键,键码就会被储存起来,这样你就可以在你的游戏中使用它了。但是当玩家释放按键时会发生什么呢?Keyboard.keyDown的值应该再次被赋值为-1,这样你就知道玩家当前没有按任何键。这是通过键向上事件处理程序完成的。下面是该处理程序的头部和主体:

function handleKeyUp(evt) {
    Keyboard.keyDown = -1;
}

如你所见,这很简单。您唯一需要做的就是将值-1 赋给Keyboard对象中的keyDown变量。最后,您在Game.start中分配这个功能:

document.onkeyup = handleKeyUp;

现在你已经准备好处理游戏中的按键了。注意,这种处理按键的方式有点局限。例如,没有办法跟踪同时按键,例如玩家同时按下 A 和 B 键。后来,在第十三章中,你扩展了Keyboard对象来考虑这一点。

条件执行

作为如何使用Keyboard对象做某事的一个简单例子,让我们扩展 Painter1 程序,在炮管顶部绘制一个彩球。通过按 R、G 或 B 键,玩家可以将加农炮的颜色改为红色、绿色或蓝色。图 6-1 显示了程序的截图。

9781430265382_Fig06-01.jpg

图 6-1 。Painter2 程序的屏幕截图

你需要加载三个额外的精灵,每个彩球一个。这是通过以下三条指令完成的:

sprites.cannon_red = Game.loadSprite(spriteFolder + "spr_cannon_red.png");
sprites.cannon_green = Game.loadSprite(spriteFolder + "spr_cannon_green.png");
sprites.cannon_blue = Game.loadSprite(spriteFolder + "spr_cannon_blue.png");

您向cannon对象添加一个initialize方法,在该方法中,您向属于该对象的变量赋值。这种方法叫从Game.start。这样,游戏开始时大炮就被初始化了:

Game.start = function () {
    Canvas2D.initialize("myCanvas");
    document.onkeydown = handleKeyDown;
    document.onkeyup = handleKeyUp;
    document.onmousemove = handleMouseMove;
    ...
    cannon.initialize();
    window.setTimeout(Game.mainLoop, 500);
};

cannon.initialize方法中,你给属于加农炮的变量赋值。这是完整的方法:

cannon.initialize = function() {
    cannon.position = { x : 72, y : 405 };
    cannon.colorPosition = { x : 55, y : 388 };
    cannon.origin = { x : 34, y : 34 };
    cannon.currentColor = sprites.cannon_red;
    cannon.rotation = 0;
};

如您所见,您有两个位置变量:一个用于炮管,一个用于彩色球体。此外,您添加了一个变量,该变量引用应该绘制的球体的当前颜色。最初,您将红色球体精灵分配给该变量。

为了明确区分对象,你还可以给cannon对象添加一个draw方法。在这种方法中,您绘制炮管和炮管上的彩色球体:

cannon.draw = function () {
    Canvas2D.drawImage(sprites.cannon_barrel, cannon.position, cannon.rotation,
        cannon.origin);
    Canvas2D.drawImage(cannon.currentColor, cannon.colorPosition, 0,
        { x : 0, y : 0 });
};

这个draw方法从Game.draw调用如下:

Game.draw = function () {
    Canvas2D.clear();
    Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
        { x : 0, y : 0 });
    cannon.draw();
};

这样,您可以更容易地看到哪个绘图指令属于哪个对象。现在,准备工作已经完成,您可以开始处理玩家的按键。直到现在,你写的所有指令都必须一直执行。例如,程序总是需要绘制背景精灵和炮管精灵。但是现在你遇到一种情况,只有在满足某些条件的情况下才需要执行指令。例如,只有当玩家按下 G 键时,你才需要将球的颜色改为绿色。这种指令被称为条件指令,它使用了一个新的关键字:if

使用if指令,您可以提供一个条件,如果这个条件成立,就执行一个指令块(总的来说,这有时也被称为分支)。以下是一些条件示例:

  • 演奏者按下了 G 键。
  • 游戏开始后经过的秒数大于 1,000。
  • 气球精灵就在屏幕的正中央。
  • 怪物吃掉了你的角色。

这些条件可以是。条件是一个表达式,因为它有一个值(或者是或者是)。这个值也被称为一个布尔值。使用if指令,如果条件为真,您可以执行一组指令。看看这个例子if指令:

if (Game.mousePosition.x > 200) {
    Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
        { x : 0, y : 0 });
}

条件总是放在括号中。接下来是一组指令,用大括号括起来。在本例中,只有当鼠标的 x 位置大于 200 时,才会绘制背景。因此,如果您在屏幕上向左移动鼠标太远,背景就不会被绘制出来。如果需要,您可以在大括号之间放置多个指令:

if (Game.mousePosition.x > 200) {
    Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
        { x : 0, y : 0 });
    cannon.draw();
}

如果只有一条指令,您可以省略大括号来稍微缩短代码:

if (Game.mousePosition.x > 200)
    Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
        { x : 0, y : 0 });

在这个例子中,你想只在玩家按下 R、G 或 B 键时改变炮管的颜色。这意味着你必须检查这些键中的一个是否被按下。使用Keyboard对象,检查 R 键是否被按下的条件如下:

Keyboard.keyDown === 82

===运算符比较两个值,如果相同则返回 true,否则返回 false。这个比较运算符的左边是Keyboard对象中的keyDown变量的值。右边是对应 R 键的键码。现在,您可以在cannonupdate方法中使用它,如下所示:

if (Keyboard.keyDown === 82)
    cannon.currentColor = sprites.cannon_red;

有点烦人的是,为了理解程序中发生的事情,你必须记住所有这些关键代码。您可以通过定义第二个名为Keys的变量来简化工作,该变量包含最常见的键码,如下所示:

var Keys = {
    A: 65, B: 66, C: 67, D: 68, E: 69, F: 70,
    G: 71, H: 72, I: 73, J: 74, K: 75, L: 76,
    M: 77, N: 78, O: 79, P: 80, Q: 81, R: 82,
    S: 83, T: 84, U: 85, V: 86, W: 87, X: 88,
    Y: 89, Z: 90
};

现在,如果你想知道键 R 的数字,你可以简单地访问变量Keys.R,并且if指令变得更加清晰:

if (Keyboard.keyDown === Keys.R)
    cannon.currentColor = sprites.cannon_red;

比较运算符

if指令头中的条件是一个返回真值的表达式:。当表达式的结果为时,执行if指令的主体。在这些情况下,您可以使用比较运算符。以下操作符可用:

  • <小于
  • <=小于或等于
  • >大于
  • >=大于或等于
  • ===等于
  • !==不等于

这些运算符可用于任意两个数字之间。在这些操作符的左边和右边,您可以放入常量值、变量或带有加法、乘法或任何您想要的内容的完整表达式。使用三个等号(===)测试两个值是否相等。这与表示赋值的单个等号非常不同。这两个运算符之间的差异非常重要:

x = 5;表示:的值 5 赋给x

x === 5的意思:x等于 5 吗?

*因为您已经见过单等于和三等于运算符,所以您可能想知道是否还有双等于运算符。有。double-equals 运算符也比较值,但是如果这些值的类型不同,该运算符会转换其中一个值,以使类型匹配。这种转换听起来很有用,但是会导致一些奇怪的行为。这里有几个例子:

'' == '0' // false
0 == '' // true!
0 == '0' // true!

三重等于运算符在这三种情况下都会返回 false,因为类型不同。一般来说,最好避免使用双等号运算符。三倍等于运算符更容易预测,这使得在程序中使用它时会出现更少的 bug 和错误。

注意在现有的 JavaScript 库或代码片段中,您可能会经常遇到双等号运算符。编程习惯很难改变。

逻辑运算符

在逻辑术语中,条件也称为谓词。在逻辑中用于连接谓词的操作符(而非)也可以在 JavaScript 中使用。他们有一个特殊的符号:

  • &&是逻辑 and 运算符。
  • ||是逻辑运算符。
  • !是逻辑而不是运算符。

您可以使用这些操作符来检查复杂的逻辑语句,以便只在非常特殊的情况下执行指令。比如你可以画一个“你赢了!”只有玩家积分超过 10000,敌人生命值为 0,玩家生命值大于 0 时才叠加:

if (playerPoints > 10000 && enemyLifeForce === 0 && playerLifeForce > 0)
    Canvas2D.drawimage(winningOverlay, { x : 0, y : 0 }, 0, { x : 0, y : 0 });

布尔类型

使用比较运算符或用逻辑运算符连接其他表达式的表达式也有类型,就像使用算术运算符的表达式一样。毕竟,这样一个表达式的结果是一个值:两个真值之一。在逻辑上,这些值被称为。在 JavaScript 中,这些真值由关键字truefalse表示。

除了用于表达if指令中的条件,逻辑表达式还可以应用于许多不同的情况。逻辑表达式类似于算术表达式,只是类型不同。例如,您可以将逻辑表达式的结果存储在变量中,将其作为参数传递,或者在另一个表达式中再次使用该结果。

逻辑值的类型是布尔,以英国数学家和哲学家乔治·布尔(1815–1864)的名字命名。以下是布尔变量声明和赋值的示例:

var test;
test = x > 3 && y < 5;

在这种情况下,例如,如果x包含值 6,而y包含值 3,布尔表达式x > 3 && y < 5将计算为true,该值将存储在变量test中。您也可以将布尔值truefalse直接存储在变量中:

var isAlive = false;

布尔变量对于存储游戏中不同对象的状态非常方便。例如,您可以使用一个布尔变量来存储玩家是否还活着,玩家当前是否正在跳跃,一个关卡是否完成,等等。您可以在if指令中使用布尔变量作为表达式:

if (isAlive)
   // do something

在这种情况下,如果表达式isAlive的计算结果为true,则执行if指令的主体。您可能认为这段代码会产生一个编译器错误,您需要对布尔变量进行比较,如下所示:

if (isAlive === true)
    // do something

然而,这种额外的比较是不必要的。在if指令中的条件表达式必须评估为 truefalse。因为布尔变量已经表示了这两个值中的一个,所以不需要进行比较。事实上,如果需要之前的比较,您还需要再次将结果与布尔值进行比较:

if ((isAlive === true) === true)
    // do something

更糟的是:

if ((((((isAlive === true) === true) === true) === true) === true) === true)
    // do something

综上所述,不要把事情搞得比实际更复杂。如果结果已经是一个布尔值,你就不用和任何东西比较了。

您可以使用 Boolean 类型存储复杂的表达式,这些表达式可以是true或 fa l se。让我们再看几个例子:

var a = 12 > 5;
var b = a && 3 + 4 === 8;
var c = a || b;
if (!c)
    a = false;

在你继续阅读之前,试着在执行完这些指令后确定变量abc的值。在第一行中,您声明并初始化一个布尔值a。存储在该布尔值中的真值由表达式12 > 5计算得出,其结果为true。然后将该值分配给变量a。在第二行中,您声明并初始化了一个新变量b,其中存储了一个更复杂的表达式的结果。这个表达式的第一部分是变量a,它包含值true。表达式的第二部分是比较表达式3 + 4 === 8。这个比较不成立(3 + 4 不等于 8),所以它的计算结果是false,因此逻辑也产生false。因此,该指令执行后,变量b包含值false

第三条指令将变量ab的逻辑运算结果存储在变量c中。因为a包含值true,所以这个运算的结果也是true,这个结果被赋给c。最后,还有一个if指令,它将值false赋给变量a,但前提是!c的计算结果为true。在这种情况下,ctrue,所以!cfalse,这意味着if指令的主体没有被执行。因此,所有指令执行完毕后,ac都包含值trueb包含值false

做这种练习表明很容易犯逻辑错误。这个过程类似于您调试代码时所做的事情。一步一步,你通过指令,并确定在不同阶段的变量的值。一个简单的混淆就可能导致你认为是true的东西变成false

将枪管对准鼠标指针

在前面的章节中,您已经看到了如何使用if指令来检查玩家是否按下了 R 键。现在,假设你想在鼠标左键按下的情况下更新炮管的角度。为了处理鼠标按键,您还需要两个事件处理程序:一个用于处理用户按下鼠标按键的事件,另一个用于处理用户释放鼠标按键的事件。这类似于按下和释放键盘上的一个键。每当按下或释放鼠标按钮时,事件对象中的which变量会告诉您是哪个按钮(1 是左按钮,2 是中按钮,3 是右按钮)。您可以向Mouse对象添加一个布尔变量,指示鼠标按钮是否被按下。让我们对鼠标左键这样做:

var Mouse = {
    position : { x : 0, y : 0 },
    leftDown : false
};

您还必须添加两个处理函数,为leftDown变量赋值。下面是两个函数:

function handleMouseDown(evt) {
    if (evt.which === 1)
        Mouse.leftDown = true;
}

function handleMouseUp(evt) {
    if (evt.which === 1)
        Mouse.leftDown = false;
}

如您所见,您使用if指令来确定鼠标左键是被按下还是被释放。根据条件的真值,执行指令体。当然,您需要将这些处理程序分配给文档中适当的变量,以便在按下或释放鼠标按钮时调用它们:

document.onmousedown = handleMouseDown;
document.onmouseup = handleMouseUp;

现在,在cannonupdate方法中,只有当鼠标左键被按下时才更新炮管角度:

if (Mouse.leftDown) {
    var opposite = Mouse.position.y - this.position.y;
    var adjacent = Mouse.position.x - this.position.x;
    cannon.rotation = Math.atan2(opposite, adjacent);
}

假设您想在玩家释放鼠标左键后将角度重置为零。你可以添加另一个if指令,就像这样:

if (!Mouse.leftDown)
    cannon.rotation = 0;

对于更复杂的情况,这种解决方案将变得更难理解。有一种更好的方式来处理这种情况:使用一个带有替代if指令。当if指令中的条件不为真时,执行替代指令;你可以使用else关键字:

if (Mouse.leftDown) {
    var opposite = Mouse.position.y - this.position.y;
    var adjacent = Mouse.position.x - this.position.x;
    cannon.rotation = Math.atan2(opposite, adjacent);
} else
    cannon.rotation = 0;

这条指令做的事情和前面两条if指令完全一样,但是你只需要写一次条件。执行 Painter2 程序,看看它能做什么。请注意,只要松开鼠标左键,炮管的角度就为零。

带有替代指令的if指令的语法由图 6-2 中的语法图表示。一条if指令的主体可以由括号内的多条指令组成,因为一条指令也可以是指令的,如图 6-3 中的语法图所定义。

9781430265382_Fig06-02.jpg

图 6-2 。if指令的语法图

9781430265382_Fig06-03.jpg

图 6-3 。指令块的语法图(本身就是一条指令)

许多不同的选择

当有多个类别的值时,你可以用if指令找出你在处理哪种情况。第二个测试放在第一个if指令的else之后,这样只有当第一个测试失败时才执行第二个测试。第三个测试可以放在第二个if指令的else之后,依此类推。

下面的片段决定了玩家属于哪个年龄段,这样你就可以绘制不同的玩家精灵了:

if (age < 4)
    Canvas2D.drawImage(sprites.babyPlayer, playerPosition, 0,
        { x : 0, y : 0 });
else if (age < 12)
         Canvas2D.drawImage(sprites.youngPlayer, playerPosition, 0,
             { x : 0, y : 0 });
     else if (age < 65)
              Canvas2D.drawImage(sprites.adultPlayer, playerPosition, 0,
                  { x : 0, y : 0 });
          else
              Canvas2D.drawImage(sprites.oldPlayer, playerPosition, 0,
                  { x : 0, y : 0 });

在每个else(除了最后一个)之后是另一个if指令。对于婴儿来说,babyPlayer精灵被画出来,其余的指令被忽略(毕竟它们在else之后)。而老玩家则通过所有测试(年龄小于 4?小于 12 岁?65 岁以下?)在你得出结论之前,你必须画出oldPlayer精灵。

我在这个程序中使用了缩进来表示哪个else属于哪个if。当有许多不同的类别时,程序的文本变得越来越不可读。因此,作为通常规则的一个例外,在else之后的指令应该缩进,你可以使用一个简单的布局来处理复杂的if指令:

if (age < 4)
    Canvas2D.drawImage(sprites.babyPlayer, playerPosition, 0,
        { x : 0, y : 0 });
else if (age < 12)
    Canvas2D.drawImage(sprites.youngPlayer, playerPosition, 0,
        { x : 0, y : 0 });
else if (age < 65)
    Canvas2D.drawImage(sprites.adultPlayer, playerPosition, 0,
        { x : 0, y : 0 });
else
    Canvas2D.drawImage(sprites.oldPlayer, playerPosition, 0, { x : 0, y : 0 });

这里的额外优势是,使用这种布局,可以更容易地看到指令处理了哪些情况。您还可以看到,示例代码使用多种选择来处理cannon对象的update方法中的三种不同颜色:

if (Keyboard.keyDown === Keys.R)
    cannon.currentColor = sprites.cannon_red;
else if (Keyboard.keyDown === Keys.G)
    cannon.currentColor = sprites.cannon_green;
else if (Keyboard.keyDown === Keys.B)
    cannon.currentColor = sprites.cannon_blue;

if指令旁边,有一个叫做switch的指令,它更适合处理许多不同的选择。参见第二十一章更多关于如何使用switch的信息。

切换炮管的行为

作为使用if指令处理鼠标按键的最后一个例子,让我们试着处理鼠标按键点击而不是鼠标按键按下。你知道如何用一个if指令来检查鼠标按钮当前是否被按下,但是你如何发现玩家是否已经点击了(在按钮没有被按下的时候按下了它)?看程序画师 2a。在这个程序中,当你点击鼠标左键后,炮管会跟随鼠标指针旋转。再次点击时,大炮停止跟随鼠标指针。

这种切换行为的问题在于,你只知道在update方法中鼠标的当前状态。这些信息不足以确定点击何时发生,因为点击在一定程度上是由上次在update方法中发生的事情定义的。如果发生以下两种情况,您可以说玩家点击了鼠标按钮:

  • 目前,鼠标按钮已按下。
  • 在最后一次调用update方法时,鼠标按钮没有按下。

您向Mouse对象添加了一个额外的布尔变量leftPressed,该变量指示鼠标是否被按下。如果您收到一个鼠标按下事件(覆盖项目符号列表中的第一个项目),并且变量Mouse.leftDown尚未为真(对应于第二个项目符号项目),您需要将该变量设置为true。这是扩展的handleMouseDown事件处理程序的样子:

function handleMouseDown(evt) {
    if (evt.which === 1) {
        if (!Mouse.leftDown)
            Mouse.leftPressed = true;
        Mouse.leftDown = true;
    }
}

这里你还可以看到一个嵌套 if指令的例子,这意味着if指令本身包含一个或多个if指令。现在,您可以通过编写一条检查鼠标左键是否被按下的if指令来编写切换炮管行为所需的代码:

if (Mouse.leftPressed)
    cannon.calculateAngle = !cannon.calculateAngle;

if指令的主体中,切换calculateAngle变量。这是cannon对象的布尔成员变量。为了获得切换行为,您使用逻辑而不是操作符。对变量calculateAngle进行运算的结果再次存储在变量calculateAngle中。因此,如果该变量包含值true,则在同一个变量中存储值false,反之亦然。结果是每次执行该指令时,calculateAngle变量的值都会切换。

现在,您可以在另一个if指令中使用该变量来确定您是否应该更新角度:

if (cannon.calculateAngle) {
    var opposite = Mouse.position.y - this.position.y;
    var adjacent = Mouse.position.x - this.position.x;
    cannon.rotation = Math.atan2(opposite, adjacent);
} else
    cannon.rotation = 0;

为了完成这个例子,你需要做一些额外的簿记工作。目前,变量Mouse.leftPressed从未被重置。因此,在每次执行游戏循环后,您将Mouse.leftPressed重置为false。您添加一个reset方法到Mouse对象来完成这个任务,如下所示:

Mouse.reset = function() {
    Mouse.leftPressed = false;
};

最后,从Game对象中的mainLoop方法调用该方法:

Game.mainLoop = function() {
    Game.update();
    Game.draw();
    Mouse.reset();
    window.setTimeout(Game.mainLoop, 1000 / 60);
};

你学到了什么

在本章中,您学习了:

  • 如何使用if指令对鼠标点击和按钮按压做出反应
  • 如何使用布尔值为这些指令制定条件
  • 如何将if指令用于不同的备选方案*

七、基本游戏对象

在这一章中,你开始更多地组织画师游戏的源代码。这是必要的,因为一个游戏的源代码包含许多行代码。在前面的例子中,您开始对不同对象上的变量进行分组(例如Canvas2Dcannon)。在本章中,您将继续通过使用更多的对象和将代码拆分到不同的文件来构建您的代码。

使用单独的 JavaScript 文件

您可能已经注意到的一件事是,您的 JavaScript 文件变得相当大。拥有一个包含所有代码的大文件并不理想,因为这样很难找到程序的某些部分。将代码拆分到多个源文件中更有意义。一个好的方法是将不同的 JavaScript 对象分割到不同的 JavaScript 文件中,这样每个 JavaScript 文件都包含其中一个对象的代码。Painter3 示例程序包含前面章节中介绍的对象,但是每个对象都在自己的 JavaScript 文件中描述。现在浏览代码和理解程序的结构变得容易多了。您甚至可以将这些文件放在单独的目录中,以指示哪些对象属于同一个目录。例如,您可以将KeyboardMouse JavaScript 文件放在一个名为input的目录中。这样,很明显这两个文件都包含与处理输入相关的代码。

在浏览器中加载这些独立的 JavaScript 文件有点棘手。在前面的示例中,您已经看到了加载 JavaScript 文件的以下代码行:

<script src="FlyingSpriteWithSound.js"></script>

您可能希望通过在 HTML 文件中添加更多的这些script元素来加载 Painter3 程序中使用的 JavaScript 文件,如下所示:

<script src="input/Keyboard.js"></script>
<script src="input/Mouse.js"></script>
<script src="Canvas2D.js"></script>
<script src="system/Keys.js"></script>
<script src="Painter.js"></script>
<script src="Cannon.js"></script>

不幸的是,如果你试图采用这种添加越来越多script元素的方法,你会遇到麻烦。因为 JavaScript 文件是从某个地方的服务器上检索的,所以没有办法确定哪个 JavaScript 文件将首先完成加载。假设浏览器可以加载的第一个文件是Painter.js。浏览器无法解释该文件中的代码,因为代码引用了其他文件中的代码(如Canvas2D对象)。这也适用于其他文件。因此,为了实现这一点,您需要确保文件以某种顺序加载,使得尊重文件之间现有的依赖关系。换句话说:如果文件 A 需要文件 B,你需要在文件 A 之前加载文件 B。

在 JavaScript 中,可以修改 HTML 页面;因此,理论上,您可以向 HTML 页面添加一个额外的 script 元素,然后开始加载另一个 JavaScript 文件。通过巧妙使用事件处理程序,您可以想象编写 JavaScript 代码,以预定义的顺序加载其他 JavaScript 文件。您也可以使用其他人已经编写的代码,而不是自己编写所有这些代码。这是结构化代码的另一个优点:它使代码对其他应用更有用。

对于这本书,我选择使用一个名为 LABjs ( http://labjs.com/)的动态脚本加载工具。这是一个非常简单的脚本,可以让您动态地按照预定义的顺序加载其他 JavaScript 文件。下面是使用 LABjs 按照正确的顺序加载所有 JavaScript 文件的代码:

<script src="../LAB.min.js"></script>
<script>
    $LAB.script('input/Keyboard.js').wait()
        .script('input/Mouse.js').wait()
        .script('Canvas2D.js').wait()
        .script('system/Keys.js').wait()
        .script('Painter.js').wait()
        .script('Cannon.js').wait(function () {
            Game.start('mycanvas');
        });
</script>

如您所见,使用 LABjs 非常简单。您只需调用一系列的scriptwait方法。最后一个wait方法调用获取一个要执行的函数作为参数。在这个函数中,你开始游戏。通过改变script方法调用的顺序,您可以改变脚本加载的顺序。当您开发游戏或其他更大的 JavaScript 应用时,使用这样的脚本非常有用,因为它使得开发和维护代码更加容易。有关显示加载不同 JavaScript 文件的完整示例,请参见 Painter3。这里的另一个改进是我将画布元素的名称作为参数传递给了start方法。这样,JavaScript 游戏代码可以使用任何画布名称。

你可能不希望游戏的最终(发布)版本使用这样的方法,因为浏览器将不得不加载许多 JavaScript 文件。此时,最好使用另一个程序将所有 JavaScript 文件合并成一个大文件,这样加载速度会更快。此外,通常的做法是对代码结构进行一些优化,使脚本文件尽可能小。这个过程叫做缩小。第三十章更详细地讨论了这一点。

以错误的方式加载游戏素材

之前,我谈到了浏览器以任意顺序加载文件,因为这些文件必须从服务器中检索。同样的规则也适用于加载游戏资源,比如精灵和声音。这是你到目前为止用来加载游戏素材的方法:

var sprite = new Image();
sprite.src = "someImageFile.png";
var anotherSprite = new Image();
anotherSprite.src = "anotherImageFile.png";
// and so on

看起来很简单。对于每个你想要加载的精灵,你创建一个Image对象,并给它的src变量赋值。将src变量设置为某个值并不意味着图像会立即加载。它只是告诉浏览器开始从服务器检索图像。根据互联网连接的速度,这可能需要一段时间。如果您尝试过早绘制图像,浏览器将会因为访问错误(尝试绘制尚未加载的图像)而停止脚本。为了避免这个问题,在前面的例子中精灵是这样加载的:

sprites.background = new Image();
sprites.background.src = spriteFolder + "spr_background.jpg";
sprites.cannon_barrel = new Image();
sprites.cannon_barrel.src = spriteFolder + "spr_cannon_barrel.png";
sprites.cannon_red = new Image();
sprites.cannon_red.src = spriteFolder + "spr_cannon_red.png";
sprites.cannon_green = new Image();
sprites.cannon_green.src = spriteFolder + "spr_cannon_green.png";
sprites.cannon_blue = new Image();
sprites.cannon_blue.src = spriteFolder + "spr_cannon_blue.png";
cannon.initialize();
window.setTimeout(Game.mainLoop, 500);

注意最后一行代码,粗体。在设置了所有Image对象的src变量之后,您告诉浏览器在执行主循环之前等待 500 毫秒。这样,浏览器应该有足够的时间来加载精灵。但是网速太慢怎么办?那么 500 毫秒可能不够。或者网速真的很快怎么办?那么你让玩家不必要地等待。为了解决这个问题,您需要程序在执行主循环之前等待所有图像都已加载。您将看到如何使用事件处理函数正确地做到这一点。但在此之前,让我们再多谈一点关于方法和函数的内容。

方法和功能

您已经看到并使用了相当多不同种类的方法和函数。例如,Canvas2D.drawImage方法和cannon.update方法之间有一个明显的区别:后者没有任何参数,而前者有(精灵、它的位置、它的旋转和它的原点)。此外,一些函数/方法可以有一个对象的结果值,该值可以在执行方法调用的指令中使用——例如,通过将结果存储在一个变量中:

var n = Math.random();

这里,您调用被定义为Math对象的一部分的random函数,并将它的结果存储在变量n中。显然,random提供了一个可以存储的结果值。另一方面,Canvas2D.drawImage方法不提供可以存储在变量中的结果。当然,这个方法确实有某种效果,因为它在屏幕上绘制了一个精灵,这也可以被认为是方法调用的结果。然而,当我谈论一个方法的结果时,我并不是说这个方法对一个对象有某种影响。我的意思是方法调用返回一个可以存储在变量中的值。这也称为方法或函数的返回值 。在数学中,函数有结果是很常见的。数学函数 imagex 值作为参数,并返回其平方值作为结果。如果你愿意,你可以用 JavaScript 写这个数学函数:

var square = function(x) {
    return x*x;
}

如果你看看这个方法的头,你会看到它有一个名为x的参数。因为函数返回值,所以可以将该值存储在变量中:

var sx = square(10);

该指令执行后,变量sx将包含值 100。在函数体中,您可以使用关键字return来指示函数返回的实际值。在square的情况下,函数返回表达式x*x的结果。注意,执行return指令也会终止函数中其余指令的执行。放置在和return指令之后的任何指令都不会被执行。例如,考虑以下函数:

var someFunction = function() {
    return 12;
    var tmp = 45;
}

在这个例子中,第二条指令(var tmp = 45;)永远不会被执行,因为它之前的指令结束了函数。这是return指令的一个非常方便的特性,您可以将它用于您的优势:

var squareRoot = function(x) {
    if (x < 0)
        return 0;
    // Calculate the square root, we are now sure that x >=0.
}

在这个例子中,您使用return指令来防止方法用户的错误输入。您不能计算负数的平方根,所以在您进行任何计算或引发任何恼人的、潜在的难以调试的错误之前,您需要处理x为负的情况。

没有返回值的方法的一个例子是cannon.handleInput。因为这个方法没有返回值,所以不需要在方法体中使用return关键字,尽管这样做有时还是有用的。例如,假设您只想在鼠标位于屏幕左侧时更改大炮的颜色。您可以通过以下方式实现这一点:

cannon.handleInput = function () {
    if (Mouse.position.x > 10)
        return;
    if (Keyboard.keyDown === Keys.R)
        cannon.currentColor = sprites.cannon_red;
    else if (Keyboard.keyDown === Keys.G)
        // etc.
};

在这个方法中,首先检查鼠标的 x 位置是否大于 10。如果是这种情况,就执行return指令。此后的任何指令将不再执行。

请注意,无论何时调用没有返回值的方法,它都没有可以存储在变量中的结果。例如:

var what = cannon.handleInput();

因为cannon.handleInput没有返回值,所以在这条指令执行后,变量what将具有值undefined

如果一个方法或函数有一个返回值,这个值不一定要存储在变量中。你也可以直接在if指令中使用它,就像你在cannon.handleInput方法中所做的一样:

if (Math.random() > 0.5)
    // do something

这里,Math.random方法返回一个数字,如果该数字大于 0.5,则执行if指令的主体。有值的东西和没有值的东西之间的区别是你以前见过的:这和你在指令(没有值)和表达式(有值)之间看到的区别是一样的。所以,这意味着Math.random()是一个表达式,而cannon.handleInput();是一个指令。这两者之间的第二个区别是,表达式从不以分号结尾,而指令总是以分号结尾,除非指令是一个块。

声明与参数

变量的声明与写在方法头中的参数有很多共同之处。事实上,这些参数也是声明,但是有一些不同:

  • 变量在方法体中声明;参数在方法头的括号中声明。
  • 变量通过使用赋值指令来获取值;调用方法时,参数会自动获取一个值。
  • 变量声明以单词var开头;参数声明不会。
  • 变量声明以分号结束;参数声明不会。

以正确的方式加载游戏素材

为了让 sprite 加载更容易一点,让我们给Game对象添加一个方法loadSprite:

Game.loadSprite = function(imageName) {
    var image = new Image();
    image.src = imageName;
    return image;
}

加载不同精灵的代码现在变得更短了:

var sprFolder = "../../assets/Painter/sprites/";
sprites.background = Game.loadSprite(sprFolder + "spr_background.jpg");
sprites.cannon_barrel = Game.loadSprite(sprFolder + "spr_cannon_barrel.png");
sprites.cannon_red = Game.loadSprite(sprFolder + "spr_cannon_red.png");
sprites.cannon_green = Game.loadSprite(sprFolder + "spr_cannon_green.png");
sprites.cannon_blue = Game.loadSprite(sprFolder + "spr_cannon_blue.png");

然而,处理加载精灵所花费的时间的问题还没有解决。为了解决这个问题,你需要做的第一件事就是记录你加载了多少精灵。这可以通过向Game对象添加一个名为spritesStillLoading的变量来实现:

var Game = {
    spritesStillLoading : 0
};

最初,该变量被设置为值 0。每次加载一个 sprite,变量就增加 1。从逻辑上来说,您可以在loadSprite方法中这样做:

Game.loadSprite = function(imageName) {
    var image = new Image();
    image.src = imageName;
    Game.spritesStillLoading += 1;
    return image;
}

所以现在,每次你加载一个精灵,spritesStillLoading变量就会增加。接下来,每当一个精灵完成加载时,你想要减少这个变量。这可以通过使用事件处理函数来完成。您将这个函数分配给image对象中的变量onload。在函数体中,变量递减。下面是添加事件处理程序的loadSprite方法的版本:

Game.loadSprite = function (imageName) {
    var image = new Image();
    image.src = imageName;
    Game.spritesStillLoading += 1;
    image.onload = function () {
        Game.spritesStillLoading -= 1;
    };
    return image;
};

现在spritesStillLoading变量准确地表示了还有多少精灵需要被加载。您可以使用该信息等待主循环的开始,直到该变量包含值 0。为此,您需要创建两个循环方法:一个素材加载循环和一个主游戏循环。在素材加载循环中,您只需检查是否还有必须加载的精灵。如果是这种情况,您再次调用素材加载循环。如果所有的精灵都已经被加载,你调用主循环方法。下面是素材加载循环方法:

Game.assetLoadingLoop = function () {
    if (Game.spritesStillLoading > 0)
        window.setTimeout(Game.assetLoadingLoop, 1000 / 60);
    else {
        Game.initialize();
        Game.mainLoop();
    }
};

您使用if指令来检查仍在加载的精灵数量是否大于 0。如果是这种情况,您在短暂的延迟后再次调用assetLoadingLoop方法。一旦所有的精灵都被加载,就会执行if指令的else部分。在这一部分中,您调用了Game对象中的initialize方法,然后调用了mainLoop方法。在initialize方法中,所有的游戏对象都被初始化(在本例中,只有cannon对象)。在加载完所有精灵后进行初始化是很有用的,因为精灵数据在初始化对象时可能会很有用。例如,如果你想计算一个覆盖图的位置,使它画在屏幕的中心,你需要知道精灵的宽度和高度。该信息仅在精灵完成加载时可用。有关完整的概述,请参见 Painter3 示例,该示例阐释了新的 sprite 加载代码。

编写一个更有效的游戏循环

到目前为止,您已经使用了window.setTimeout方法来创建一个运行的游戏循环。虽然这段代码很好,但不幸的是它不是很高效。大多数浏览器提供了一种更有效的方式来实现这一点,这种方式专门针对交互式绘图应用,如游戏。问题是,并不是所有的浏览器和版本都使用相同的方法名。最常用的浏览器的新版本都使用了window.requestAnimationFrame方法。不过火狐老版本用的是window.mozRequestAnimationFrame,Safari 老版本和 Chrome 用的是window.webkitRequestAnimationFrame。您希望尽可能少地处理特定于浏览器的代码,所以让我们想出一种方法,使用更快的方法来运行游戏循环,而不必了解不同浏览器制作和版本使用的不同方法。因为大多数浏览器已经使用了window.requestAnimationFrame方法,您可以如下扩展该方法的定义:

window.requestAnimationFrame = window.requestAnimationFrame ||
    window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame ||
    window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
    function (callback) {
        window.setTimeout(callback, 1000 / 60);
    };

您在这里使用||操作符来确定window.requestAnimationFrame名称所指的方法。如果没有定义第一个选项(例如,如果您正在使用一个较旧的浏览器),您将检查任何较旧的名称。如果没有优化的游戏循环方法可用,你让requestAnimationFrame指向一个调用window.setTimeout方法的函数。在 JavaScript 中,window变量是一个全局名称空间的容器。这意味着您可以调用前面有或没有window变量的requestAnimationFrame方法。电话:

window.requestAnimationFrame(callbackFunction);

相当于

requestAnimationFrame(callbackFunction);

Painter3 示例使用优化的游戏循环方法来运行素材加载循环和游戏循环,如下所示:

Game.assetLoadingLoop = function () {
    if (Game.spritesStillLoading > 0)
        window.requestAnimationFrame(Game.assetLoadingLoop);
    else {
        Game.initialize();
        Game.mainLoop();
    }
};

同样,下面是Game对象中的mainLoop方法:

Game.mainLoop = function () {
    Game.handleInput();
    Game.update();
    Game.draw();
    Mouse.reset();
    window.requestAnimationFrame(Game.mainLoop);
};

浏览器以不同的方式处理事情是 JavaScript 开发人员面临的一个主要挑战,尽管在浏览器如何执行 JavaScript 代码的标准化方面已经取得了很大进展。当您开发 JavaScript 游戏时,您可能会在某个时候在浏览器中遇到这些差异。因此,在发布游戏之前,一定要在最常用的浏览器上测试你的游戏!

将通用代码与游戏专用代码分开

以前,你没有区分可以用于许多不同游戏的代码和特定于一个游戏的代码。您编写的一些代码,比如Game.mainLoop方法,可能对其他 JavaScript 游戏也有用。这同样适用于您编写的所有加载精灵的代码。既然您已经看到了在不同脚本文件上分割代码的方法,那么您可以利用这一点。通过将通用代码从特定于 Painter 的代码中分离出来,以后重用该通用代码将会更加容易。如果你想重用加载精灵的代码,你只需将包含代码的源文件包含到你的新游戏应用中,而不是在画师游戏代码中寻找你需要的。这样做的另一个原因是为了让你以后能更快地创建类似的游戏。你可能会发现自己正在开发一个与你之前开发的游戏相似的游戏。通过以这种方式组织代码,您可以更快地开发出新游戏,同时也不会重新发明轮子。

Painter4 示例创建了一个单独的Game.js文件,其中包含了Game对象和许多属于该对象的有用方法。专用于 Painter 的零件已被移到Painter.js文件中。在这个文件中,有一个加载精灵的方法和一个初始化游戏的方法。此外,一个名为PainterGameWorld.js的新文件处理游戏中的各种对象。在以前版本的 Painter 中,这个游戏世界只包含一个背景图像和一门大炮。在下一节中,您将向这个游戏世界添加一个。然后,画家游戏世界由一个对象定义,该对象确保所有游戏对象都被更新和绘制。这是painterGameWorld对象定义的一部分:

var painterGameWorld = {
};

painterGameWorld.handleInput = function (delta) {
    ball.handleInput(delta);
    cannon.handleInput(delta);
};

painterGameWorld.update = function (delta) {
    ball.update(delta);
    cannon.update(delta);
};

painterGameWorld.draw = function () {
    Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
        { x : 0, y : 0 });
    ball.draw();
    cannon.draw();
};

当你初始化游戏时,你初始化游戏对象,你告诉Game对象管理游戏世界的对象是painterGameWorld,如下:

Game.initialize = function () {
    cannon.initialize();
    ball.initialize();
    Game.gameWorld = painterGameWorld;
};

Game.mainLoop方法中,你现在只需要确保在gameWorld变量(它指的是painterGameWorld)上调用正确的方法:

Game.mainLoop = function () {
    Game.gameWorld.handleInput();
    Game.gameWorld.update();
    Canvas2D.clear();
    Game.gameWorld.draw();
    Mouse.reset();
    requestAnimationFrame(Game.mainLoop);
};

因此,你已经很好地分离了一般的游戏代码(在Game.js中)和特定于游戏的代码,包括加载精灵和初始化游戏(Painter.js)以及更新和绘制画师游戏对象(PainterGameWorld.js)。任何其他特定于 Painter 的游戏对象都是在各自的脚本文件中定义的(比如Cannon.js)。

给游戏世界添加一个球

在前面的章节中,您看到了如何通过加载单独的 JavaScript 文件、将通用游戏代码与特定于画师的代码分开、正确加载资源以及创建更高效的游戏循环来使您的 JavaScript 游戏应用更加灵活和高效。在本节中,您将通过添加一个由cannon对象射出的球来扩展画师游戏。为此,您添加一个ball对象。

您以与cannon对象非常相似的方式设置了ball对象。在 Painter4 的例子中,你看到了一个 Painter 游戏的版本,它给游戏世界增加了一个球(见图 7-1 )。通过点击游戏屏幕上的任何地方,球都可以从大炮中射出。而且,球会随着大炮一起变色。您可以在Ball.js文件中找到描述ball对象的代码。就像cannon对象一样,ball由许多变量组成,比如位置、球的当前颜色和球精灵的来源。因为球在移动,你还需要存储它的速度。这个速度是一个矢量,定义了球的位置如何随时间变化。例如,如果球的速度为(0,1),那么每秒钟,球的 y 位置增加 1 个像素(意味着球落下)。最后,球可以有两种状态:要么是因为从大炮中射出而在空中飞行,要么是等待被射出(所以它不动)。为此,您向ball对象添加一个额外的布尔变量shooting。这是 ball对象结构的完整定义:

var ball = {
};

ball.initialize = function() {
    ball.position = { x : 65, y : 390 };
    ball.velocity = { x : 0, y : 0 };
    ball.origin = { x : 0, y : 0 };
    ball.currentColor = sprites.ball_red;
    ball.shooting = false;
};

9781430265382_Fig07-01.jpg

图 7-1 。包含炮管和飞行球的画师 4 例子的截图

在你在本书中开发的游戏中,大多数物体都有位置和速度。因为这本书只涉及 2D 游戏,所以位置和速度都是由一个x和一个y变量组成的变量。当你更新这些游戏对象时,你需要根据速度向量和经过的时间来计算新的位置。在本章的后面,你会看到如何做到这一点。

为了能够使用ball对象,你需要更多的精灵。在Game.``loadAssets方法中,你加载红色、绿色和蓝色的球精灵。根据加农炮的颜色,你可以在以后改变球的颜色。这是扩展的loadAssets方法:

Game.loadAssets = function () {
    var loadSprite = function (sprite) {
        return Game.loadSprite("../../assets/Painter/sprites/" + sprite);
    };

    sprites.background = loadSprite("spr_background.jpg");
    sprites.cannon_barrel = loadSprite("spr_cannon_barrel.png");
    sprites.cannon_red = loadSprite("spr_cannon_red.png");
    sprites.cannon_green = loadSprite("spr_cannon_green.png");
    sprites.cannon_blue = loadSprite("spr_cannon_blue.png");
    sprites.ball_red = loadSprite("spr_ball_red.png");
    sprites.ball_green = loadSprite("spr_ball_green.png");
    sprites.ball_blue = loadSprite("spr_ball_blue.png");
};

在这里,您可以看到在 JavaScript 中使 sprite 加载调用更具可读性的一种好方法。您声明了一个引用函数的局部变量loadSprite。该函数将 sprite 图像名称作为参数,并调用Game.loadSprite方法。作为该方法的一个参数,您可以传递 sprite 的文件夹路径和 sprite 的名称。最后,该函数返回Game.loadSprite方法的结果。

创造球

让我们回到ball对象。在那个对象的initialize方法中,你必须给成员变量赋值,就像在cannon对象中一样。当游戏开始时,球不应该移动。因此,你把球的速度设为零。此外,您最初将球设置到零位置。这样,它就藏在大炮后面,所以当球不动的时候,你看不见它。您最初将球的颜色设置为红色,并将shooting 成员变量设置为false。下面是完整的方法:

ball.initialize = function() {
    ball.position = { x : 0, y : 0 };
    ball.velocity = { x : 0, y : 0 };
    ball.origin = { x : 0, y : 0 };
    ball.currentColor = sprites.ball_red;
    ball.shooting = false;
};

initialize方法旁边,您还添加了一个reset方法,用于重置球的位置及其射门状态:

ball.reset = function () {
    ball.position = { x : 0, y : 0 };
    ball.shooting = false;
};

当球从大炮中射出后飞出屏幕时,您可以通过调用此方法来重置它。此外,您向ball对象添加了一个draw方法。如果球没有投篮,你不想让球员看到它。因此,只有当球正在射击时,才绘制球精灵:

ball.draw = function () {
    if (!ball.shooting)
        return;
    Canvas2D.drawImage(ball.currentColor, ball.position, ball.rotation,
        ball.origin);
};

你可以在这个方法的主体中看到,只有在球不投篮的时候,你才使用return关键字来画球。在painterGameWorld对象中,你必须调用球上的游戏循环方法。例如,这是painterGameWorld中的draw方法,由此调用ball.draw方法:

painterGameWorld.draw = function () {
    Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
        { x : 0, y : 0 });
    ball.draw();
    cannon.draw();
};

请注意游戏对象的绘制顺序:首先是背景图像,然后是球,然后是大炮。

投篮

玩家可以在游戏画面中点击鼠标左键射出一个彩球。球的速度和移动方向由玩家点击的位置决定。球应该向那个位置的方向移动;玩家离大炮越远,球的速度就越高。这是用户控制球的速度的直观方式。无论何时你设计一个游戏,都要仔细考虑用户的指令是如何被接收的,以及最自然或有效的处理方式是什么。

为了处理输入,您向ball对象添加了一个handleInput方法。在这个方法中,你可以通过使用Mouse对象来检查玩家是否点击了左键:

if (Mouse.leftPressed)
    // do something...

然而,因为在任何时候都只能有一个球在空中,所以只有当球还没有在空中时,你才想做些什么。这意味着你必须检查球的投篮状态。如果球已经射出了,你就不必处理鼠标点击。所以,你用一个额外的条件来扩展你的if指令,这个额外的条件是球当前不在空中:

if (Mouse.leftPressed && !ball.shooting)
    // do something...

如您所见,您将两个逻辑操作符(&&!)结合使用。由于逻辑而非 ( !)运算符,只有当shooting变量的值为false时,if指令中的整个条件才会计算为true:换句话说,球当前没有射门。

if指令中,您需要做几件事情。你知道玩家点击了某个地方,球必须从大炮中射出。您需要做的第一件事是将变量shooting设置为正确的值,因为球的状态需要更改为“当前正在射门”:

ball.shooting = true;

因为球现在正在移动,你需要给它一个速度。这个速度是玩家点击位置方向的向量。你可以通过鼠标位置减去球的位置来计算这个方向。因为速度有一个 x 分量和一个 y 分量,所以需要对两个维度都这样做:

ball.velocity.x = (Mouse.position.x - ball.position.x);
ball.velocity.y = (Mouse.position.y - ball.position.y);

以这种方式计算速度也给出了期望的效果,即当用户点击离大炮更远时,速度更大,因为这样鼠标位置和球位置之间的差异也更大。然而,如果你现在玩这个游戏,球会移动得有点慢。因此,你将这个速度乘以一个常数值,这个常数值给出了球在这个游戏中可用的速度:

ball.velocity.x = (Mouse.position.x - ball.position.x) * 1.2;
ball.velocity.y = (Mouse.position.y - ball.position.y) * 1.2;

我在测试了不同数值的游戏玩法后,选择了常量值 1.2。每款游戏都有许多这样的游戏参数,你需要在游戏测试时调整这些参数以确定它们的最佳值。为这些参数找到正确的值对于一个玩得好的平衡游戏来说是至关重要的,你需要确保你选择的值不会使游戏过于容易或困难。例如,如果选择 0.3 而不是 1.2 这个常量值,球的移动速度会慢得多。这将使比赛变得更加困难,甚至可能使比赛无法进行,因为球可能永远也不能到达最远的地方。

如果您将handleInput方法添加到ball中,它不会被自动调用。您需要在painterGameWorld对象中显式地这样做。因此,您向该对象的handleInput方法添加了一条额外的指令:

painterGameWorld.handleInput = function () {
    ball.handleInput();
    cannon.handleInput();
};

更新球

在对象中将相关的变量和方法组合在一起的一个很大的优点是,您可以保持每个对象相对较小和清晰。您可以设计或多或少反映游戏中各种游戏对象的对象。在这种情况下,你有一个大炮和球的对象。目标是每个游戏对象处理与该对象相关的玩家输入。你也想让游戏对象自己更新和绘制。这就是你给ball增加了一个update方法和一个draw方法的原因,所以你可以在painterGameWorld的游戏循环方法中调用这些方法。

ball.update里面,你需要定义球的行为。根据球当前是否正在射门,这种行为是不同的。这是完整的方法:

ball.update = function (delta) {
    if (ball.shooting) {
        ball.velocity.x = ball.velocity.x * 0.99;
        ball.velocity.y = ball.velocity.y + 6;
        ball.position.x = ball.position.x + ball.velocity.x * delta;
        ball.position.y = ball.position.y + ball.velocity.y * delta;
    }
    else {
        if (cannon.currentColor === sprites.cannon_red)
            ball.currentColor = sprites.ball_red;
        else if (cannon.currentColor === sprites.cannon_green)
            ball.currentColor = sprites.ball_green;
        else
            ball.currentColor = sprites.ball_blue;
        ball.position = cannon.ballPosition();
        ball.position.x = ball.position.x - ball.currentColor.width / 2;
        ball.position.y = ball.position.y - ball.currentColor.height / 2;
    }
    if (painterGameWorld.isOutsideWorld(ball.position))
        ball.reset();
};

正如您在这个方法的头文件中看到的,它有一个名为delta的参数。这个参数是必要的,因为为了计算球的新位置,你需要知道从上一次调用update到现在已经过了多长时间。这个参数在一些游戏对象的handleInput方法中也很有用——例如,如果你想知道玩家移动鼠标的速度,那么你需要知道已经过了多长时间。Painter4 示例扩展了每个具有游戏循环方法(handleInputupdatedraw)的对象,以便将自上次更新以来经过的时间作为参数传递。

但是 delta 的值在哪里计算呢?你是怎么计算的?在本例中,您在Game.mainLoop方法中这样做:

Game.mainLoop = function () {
    var delta = 1 / 60;
    Game.gameWorld.handleInput(delta);
    Game.gameWorld.update(delta);
    Canvas2D.clear();
    Game.gameWorld.draw();
    Mouse.reset();
    requestAnimationFrame(Game.mainLoop);
};

因为您希望游戏循环每秒执行 60 次,所以您按如下方式计算delta值:

var delta = 1 / 60;

这种在游戏循环中计算过去时间的方式被称为固定时间步长 。如果你有一台非常慢的计算机,每秒钟不能执行 60 次游戏循环,你仍然告诉你的游戏对象,从上次到现在只过了 1/60 秒,尽管这可能不是真的。因此,游戏时间不同于真实时间。另一种方法是通过访问系统时间来计算实际经过时间 。以下是您的操作方法:

var d = new Date();
var n = d.getTime();

变量n现在包含自 1970 年 1 月 1 日以来的毫秒数(!).每次你运行游戏循环,你可以存储这个时间,减去你上次运行游戏循环所存储的时间。那会给你已经过去的真实时间。在这种情况下,没有固定的时间步长,因为经过的时间取决于所使用的计算机/设备的速度、操作系统中进程的优先级、玩家是否同时在执行其他任务等等。所以这种在游戏中处理时间的方法叫做变时步

可变时间步长在需要高帧速率的游戏中特别有用:例如,在第一人称射击游戏中,相机运动可能非常快,因为相机是由玩家直接控制的。在这些情况下,可变的时间步长与尽可能频繁地调用游戏循环方法相结合,可以产生更流畅的动作和更愉快的游戏体验。可变时间步长的缺点是,即使玩家暂时在做一些不同的事情(比如在游戏中打开菜单或保存游戏),时间也会继续。一般来说,如果玩家在浏览物品时发现他们的角色在游戏世界中被杀了,他们不会很高兴。所以,作为一个游戏开发者,你需要在使用可变时间步长时解决这些问题。

使用可变时间步长可能干扰游戏可玩性的另一个例子是玩家临时切换到另一个应用(或浏览器中的标签)。这种情况经常发生,尤其是当你开发在浏览器中运行的游戏时。这也是你在本书中使用固定时间步长的主要原因之一。当播放器切换到另一个选项卡时,非活动选项卡中的 JavaScript 代码执行会自动暂停,直到播放器返回。当使用固定时间步长时,当玩家重新激活标签时,游戏简单地从暂停的地方继续,因为游戏对象不关心已经过去的实际时间,只关心固定的增量值。

让我们回到ball.update方法。如果您查看方法的主体,您可以看到第一部分由一条if指令组成。if的条件是ball.shooting变量应该有值true。因此,如果球当前正在移动,则执行if指令的主体。这个主体同样由四个指令组成。前两条指令更新速度,后两条更新位置。第一条指令更新速度的 x 方向。您将速度乘以值 0.99,其效果是速度缓慢降低。这样做是为了模拟空气摩擦。第二条指令在每次更新中增加 y 速度。这样做是为了模拟重力 对球的影响。总的来说,x 和 y 方向上的速度变化导致了似是而非的球行为。当然,在现实世界中,重力不是 6。但是话说回来,你的现实世界也不是由像素组成的。游戏世界中的物理并不总是准确地代表现实世界中的物理。当你想在你的游戏中加入某种形式的物理(无论是非常简单还是非常复杂),最重要的部分不是物理是真实的,而是游戏是可玩的。这就是为什么在战略游戏中,飞机会像士兵在地面上行走一样快。如果游戏对这两个物体使用真实的速度,这将导致游戏无法进行。

球的当前位置通过向其 x 和 y 分量添加速度来更新。以下是执行此操作的说明:

ball.position.x = ball.position.x + ball.velocity.x * delta;
ball.position.y = ball.position.y + ball.velocity.y * delta;

如您所见,这是使用delta变量的地方。您根据速度和自上次更新以来经过的时间来计算球的新位置。您将每个速度维度乘以delta变量中的值,并将结果添加到球的当前位置。这样,如果您决定使用更高或更低的帧速率,游戏对象移动的速度将不会改变。

在过去,计算机速度非常慢,以至于不存在固定时间步长的概念。游戏开发人员假设每个人都将在同样慢的机器上玩游戏,所以他们尽可能频繁地调用游戏循环方法,并简单地用一个恒定的速度因子更新对象的位置。结果,当电脑变得更快时,这些游戏变得越来越难玩了!玩家不喜欢这样。因此,在计算速度和位置时,一定要考虑经过的时间。

如果球目前没有投篮,你可以改变它的颜色。在这种情况下,您可以通过检索加农炮的当前颜色并相应地更改球的颜色来实现。这样,你就能确定球的颜色总是和大炮的颜色相匹配。您需要一个if指令来处理不同的情况,如下所示:

if (cannon.currentColor === sprites.cannon_red)
    ball.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
    ball.currentColor = sprites.ball_green;
else
    ball.currentColor = sprites.ball_blue;

您还可以更新球的位置:

ball.position = cannon.ballPosition();
ball.position.x = ball.position.x - ball.currentColor.width / 2;
ball.position.y = ball.position.y - ball.currentColor.height / 2;

你为什么改变立场?当球不在空中时,玩家可以通过旋转炮管来修改它的射击位置。因此,您需要在这里计算正确的球位置,以确保它与炮管的当前方向匹配。为了做到这一点,您向cannon添加了一个名为ballPosition 的新方法,在该方法中,您根据桶的方向计算球的位置。使用正弦和余弦函数,可以如下计算新位置:

cannon.ballPosition = function() {
    var opp = Math.sin(cannon.rotation) * sprites.cannon_barrel.width * 0.6;
    var adj = Math.cos(cannon.rotation) * sprites.cannon_barrel.width * 0.6;
    return { x : cannon.position.x + adj, y : cannon.position.y + opp };
};

正如你所看到的,你将对面和相邻的边乘以值 0.6,这样球就被画到了旋转桶的一半以上。该方法返回一个新的复合对象,该对象具有包含球的所需 x 和 y 位置的xy变量。

在你获得了想要的球的位置后,你从中减去球精灵的宽度和高度的一半。这样,球就被很好地画在了炮管的中间。

ball.update方法的第二部分也是一个if指令:

if (painterGameWorld.isOutsideWorld(ball.position))
    ball.reset();

方法的这一部分处理当球在游戏世界之外时发生的事件。为了计算这是否为真,您将一个名为isOutsideWorld的方法添加到painterGameWorld中。这个方法的目标是检查一个给定的位置是否在游戏世界之外。你用一些简单的规则来定义游戏世界的边界。记住屏幕的左上方是原点。如果一个物体的 x 位置小于零或者大于屏幕的宽度,那么这个物体就在游戏世界之外。如果一个物体的 y 位置大于屏幕的高度,那么它也在游戏世界之外。注意,如果一个物体的 y 位置小于零,我不会说它在游戏世界之外。为什么不呢?我选择这样做是为了让玩家可以在空中投篮,让球在再次落下之前暂时停留在屏幕上方。你经常会在平台游戏中看到类似的效果,角色可以跳起来,部分消失在屏幕之外,而不是从屏幕底部掉下来(这通常意味着角色的即时死亡)。

如果您查看这个方法的头部,您会看到它需要一个参数,一个位置:

painterGameWorld.isOutsideWorld = function (position)

如果你想检查一个位置是否在屏幕之外,你需要知道屏幕的宽度和高度。在诸如 Painter 这样的 HTML5 游戏中,这对应于画布的大小。Painter4 将名为size的变量添加到Game中。当调用Game.start方法时,所需的屏幕尺寸作为参数传递。下面是扩展的Game.start方法:

Game.start = function (canvasName, x, y) {
    Canvas2D.initialize(canvasName);
    Game.size = { x : x, y : y };
    Keyboard.initialize();
    Mouse.initialize();
    Game.loadAssets();
    Game.assetLoadingLoop();
};

isOutsideWorld方法中,您使用Game.size变量来确定一个位置是否在游戏世界之外。该方法的主体由一条使用关键字return计算布尔值的指令组成。逻辑操作用于涵盖位置在游戏世界之外的不同情况:

return position.x < 0 || position.x > Game.size.x || position.y > Game.size.y;

如你所见,你不介意 y 坐标小于零。这允许你把球放在屏幕上方,然后再掉回来。

让我们回到ball.update方法。第二条if指令在其条件中调用isOutsideWorld方法;如果这个方法返回值true,那么ball.reset方法被执行。或者,用更简单的话来说:如果球飞出了屏幕,它就被放在大炮旁边,准备好被玩家再次射击。在这里,您可以看到在方法中对指令进行分组的另一个优点:像isOutsideWorld这样的方法可以在程序的不同部分中被重用,这节省了开发时间,并产生了更短、可读性更好的程序。例如,isOutsideWorld可能在游戏后期对颜料罐也有用,用来测试它们是否从屏幕上掉了下来。

最后,确保在painterGameWorld.update方法中调用ball.update方法:

painterGameWorld.update = function (delta) {
    ball.update(delta);
    cannon.update(delta);
};

当您运行 Painter4 示例时,您可以看到现在可以瞄准加农炮,选择颜色,并发射一个球。在下一章中,你将在这个游戏中加入颜料罐。但是为了做到这一点,我必须引入一个新的 JavaScript 编程概念:原型

你学到了什么

在本章中,您学习了:

  • 如何在不同的源文件中分离代码
  • 如何让游戏循环更高效
  • 不同种类的方法/函数(有/没有参数,有/没有返回值)
  • 固定时间步长和可变时间步长的区别
  • 如何在游戏世界中添加一个飞行球

八、游戏对象类型

在前面的章节中,你已经看到了如何创建一个包含一些不同游戏对象的游戏世界,比如一门大炮和一个球。你已经看到了如何让游戏对象相互作用。例如,ball对象根据大炮的颜色更新其颜色。在这一章中,您将向游戏世界添加掉落的油漆罐。但是,在这样做之前,您必须重新检查如何在 JavaScript 中创建和管理对象。我引入类的概念是为了创建多种特定类型的游戏对象。然后,将类的概念应用到 Painter 游戏应用的其他部分。此外,你学习如何在游戏中融入随机性。

创建多个相同类型的对象

到目前为止,在 Painter 中每个游戏对象只需要一个实例。只有一门大炮和一个球。这同样适用于 JavaScript 代码中的所有其他对象。有一个单独的Game对象,一个单独的Keyboard对象,一个单独的Mouse对象,等等。您可以通过声明一个引用空对象或复合对象的变量并向其添加有用的方法来创建这些对象。例如,下面是如何创建ball对象:

var ball = {
};

ball.initialize = function() {
    ball.position = { x : 0, y : 0 };
    // etc.
};

ball.handleInput = function (delta) {
    if (Mouse.leftPressed && !ball.shooting) {
        // do something
    }
};

// etc.

假设你想在画师游戏中能够同时射出三个球。如果你像现在这样创建对象,你将创建两个变量,ball2ball3,并复制两次用于ball对象的代码。出于几个原因,这不是一个很好的解决方案。首先,复制代码意味着你必须处理版本管理问题。举个例子,如果你在update方法代码中发现了一个 bug 怎么办?你必须确保将改进后的代码复制到其他ball对象中。如果你忘记了一个副本,当你认为你解决了它的时候,这个 bug 仍然存在。另一个问题是,这种方法不能很好地扩展。如果你想延长游戏,让玩家可以同时射 20 个球,会发生什么?你复制代码 20 次吗?还要注意,JavaScript 文件越大,浏览器下载和解释它们的时间就越长。所以,如果你不想让你的玩家等待脚本加载太久,最好避免复制代码。最后,重复的代码看起来很难看,弄乱了您的源代码文件,并且很难找到您需要的代码的其他部分,导致过多的滚动和编码效率的总体降低。

幸运的是,这个问题有一个非常好的解决方案。这是一个叫做原型的 JavaScript 编程结构。原型允许你为一个对象定义一种蓝图,包括它包含的变量和方法。一旦定义了这个原型,您就可以使用这个原型用一行代码创建对象了!你已经用过类似的东西了。看看这行代码:

var image = new Image();

在这里,您创建一个image对象,它使用Image原型 来构造自己。

定义原型很容易。看看这个例子:

function Dog() {
}
Dog.prototype.bark = function () {
    console.log("woof!");
};

这就创建了一个名为Dog 的函数。当这个函数与关键字new一起被调用时,一个对象被创建。JavaScript 中的每个函数都有一个原型,它包含了通过调用函数和new关键字创建的对象的信息。这个例子定义了一个名为bark的方法,它是Dog原型的一部分。这个词不仅仅是为了美观。使用它,您表明您正在向Dog的原型添加东西。每当你创建一个Dog对象时,只有属于其原型的东西才是对象的一部分。下面是如何创建一个新的Dog对象:

var lucy = new Dog();

因为lucy是根据Dog函数中的原型创建的,lucy对象包含一个名为bark的方法:

lucy.bark(); // outputs "woof!" to the console

好的一面是,你现在可以创建许多会叫的狗,但是你只需要定义一次bark方法:

var max = new Dog();
var zoe = new Dog();
var buster = new Dog();
max.bark();
zoe.bark();
buster.bark();

当然,这本书的目标不是向你展示如何成为一个养狗人,而是如何创造游戏。而且对于游戏来说,原型概念是非常强大的。它允许你将游戏中使用的实际物体与它们应该如何被构造分开。

作为练习,让我们应用原型原理来创建一个ball对象。为此,您需要定义一个函数。我们称这个函数为Ball,我们将initialize方法 添加到原型中:

function Ball() {
}
Ball.prototype.initialize = function() {
    // ball object initialization here
};

initialize方法中,您必须定义作为您创建的每个ball对象的一部分的变量。问题是,你还没有创建一个对象——你只有一个函数和一个包含initialize方法的原型。那么在initialize方法的主体中,如何引用这个方法所属的对象呢?在 JavaScript 中,this关键字用于此目的。在一个方法中,this总是指该方法所属的对象。使用该关键字,您可以填充initialize方法的主体:

Ball.prototype.initialize = function() {
    this.position = { x : 0, y : 0 };
    this.velocity = { x : 0, y : 0 };
    this.origin = { x : 0, y : 0 };
    this.currentColor = sprites.ball_red;
    this.shooting = false;
};

现在,您可以创建任意数量的球并初始化它们:

var ball = new Ball();
var anotherBall = new Ball();
ball.initialize();
anotherBall.initialize();

每次创建新球时,原型中的任何方法都会添加到对象中。当对ball对象调用initialize方法时,this指的是ball。在anotherBall上调用时,this是指anotherBall

你实际上可以把你写的代码缩短一点。当Ball本身已经是一个被调用的函数时,为什么还要添加一个initialize方法呢?您可以简单地在该函数中执行初始化,如下所示:

function Ball() {
    this.position = { x : 0, y : 0 };
    this.velocity = { x : 0, y : 0 };
    this.origin = { x : 0, y : 0 };
    this.currentColor = sprites.ball_red;
    this.shooting = false;
}

现在当你创建球时,它们在创建时被初始化:

var ball = new Ball();
var anotherBall = new Ball();

因为Ball是一个函数,如果你想的话,你甚至可以传递参数:

function Ball(pos) {
    this.position = pos;
    this.velocity = { x : 0, y : 0 };
    this.origin = { x : 0, y : 0 };
    this.currentColor = sprites.ball_red;
    this.shooting = false;
}
var ball = new Ball({ x : 0, y : 0});
var anotherBall = new Ball({ x : 100, y : 100});

因为Ball函数负责初始化(或构造)对象,所以这个函数也被称为构造函数 。构造函数和原型中定义的方法一起被称为。当一个对象是根据一个类创建的时候,你也说这个对象把那个类作为类型。在前面的例子中,ball对象有一个类型Ball,因为它是使用Ball构造函数及其原型创建的。一个类是一个对象的蓝图,因此它描述了两件事:

  • 包含在对象中的数据。对于球,这些数据包括位置、速度、原点、当前颜色和一个指示球是否正在射门的变量。通常,这些数据在构造函数中初始化。
  • 操纵数据的方法。在Ball类中,这些方法是游戏循环方法(handleInputupdatedrawreset)。

你可以很容易地将游戏循环方法转换成Ball原型中的方法,只需用this替换ball。比如这里的handleInput法 :

Ball.prototype.handleInput = function (delta) {
    if (Mouse.leftPressed && !this.shooting) {
        this.shooting = true;
        this.velocity.x = (Mouse.position.x - this.position.x) * 1.2;
        this.velocity.y = (Mouse.position.y - this.position.y) * 1.2;
    }
};

查看属于本章的 Painter5 示例中的Ball.js文件。您可以看到Ball类及其所有方法。请注意,我没有给球添加任何功能;我只是应用原型原理来定义球的蓝图。

类和对象的概念非常强大。它构成了面向对象编程范例的基础。JavaScript 是一种非常灵活的语言,因为它不强迫你使用类。如果你想的话,你可以只使用函数来编写脚本(这就是你到目前为止所做的)。但是因为类是一个如此强大的编程概念,并且在(游戏)行业中被广泛使用,所以本书尽可能地利用了它们。通过学习如何正确使用类,你可以设计出更好的软件,用任何编程语言。

注意在编写游戏时,你经常要在做一件事需要多长时间和多久做一次之间做出权衡。在 Painter 的例子中,如果你只打算创建一个或两个球,那么就不值得为这些球创建一个类。然而,通常情况下,事情会慢慢扩大。在你意识到之前,你正在复制和粘贴几十行代码,因为你没有创建一个更简单的方法来完成它。当您设计类时,考虑适当设计的长期收益,即使这需要短期的牺牲,例如必须做一些额外的编程工作以使类设计更加通用。

构建游戏对象作为游戏世界的一部分

现在你已经看到了如何创建类,你需要重新思考你的游戏对象是在哪里构造的。直到现在,游戏对象被声明为全局变量,因此,它们在任何地方都是可访问的。例如,这是创建cannon对象的方法:

var cannon = {
};
cannon.initialize = function() {
    cannon.position = { x : 72, y : 405 };
    cannon.colorPosition = { x : 55, y : 388 };
    cannon.origin = { x : 34, y : 34 };
    cannon.currentColor = sprites.cannon_red;
    cannon.rotation = 0;
};

在 Painter5 的例子中,这是Cannon类的构造函数:

function Cannon() {
    this.position = { x : 72, y : 405 };
    this.colorPosition = { x : 55, y : 388 };
    this.origin = { x : 34, y : 34 };
    this.currentColor = sprites.cannon_red;
    this.rotation = 0;
}

在球的update方法中,您需要检索大炮的当前颜色,以便更新球的颜色。这是你在上一章中是如何做到的:

if (cannon.currentColor === sprites.cannon_red)
    ball.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
    ball.currentColor = sprites.ball_green;
else
    ball.currentColor = sprites.ball_blue;

当使用 JavaScript 原型方法定义一个类时,您必须用this替换ball(因为没有对象的命名实例)。所以前面的代码被翻译成

if (cannon.currentColor === sprites.cannon_red)
    this.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
    this.currentColor = sprites.ball_green;
else
    this.currentColor = sprites.ball_blue;

但是如果加农炮也是用一个类构造的,你如何引用cannon对象呢?这就引出了两个问题:

  • 游戏对象是在代码的什么地方构造的?
  • 如果这些游戏对象不是全局变量,你如何引用它们?

从逻辑上来说,游戏对象应该在游戏世界构建的时候就被构建。这就是为什么 Painter5 示例在PainterGameWorld类中创建游戏对象(之前是painterGameWorld对象)。下面是该类的部分构造函数:

function PainterGameWorld() {
    this.cannon = new Cannon();
    this.ball = new Ball();
    // create more game objects if needed
}

所以,这回答了第一个问题,却引出了另一个问题。如果在创建游戏世界的时候创建了游戏对象,那么在哪里调用PainterGameWorld的构造函数来创建游戏世界呢?如果您打开Game.js文件,您会看到使用原型方法定义了另一个类:Game_Singleton。这是它的构造函数:

function Game_Singleton() {
    this.size = undefined;
    this.spritesStillLoading = 0;
    this.gameWorld = undefined;
}

正如你所看到的,这个类能够构造在前一章中使用的Game对象。Game_Singleton类 有一个initialize方法,在那里创建游戏世界对象:

Game_Singleton.prototype.initialize = function () {
    this.gameWorld = new PainterGameWorld();
};

好了,你已经发现了游戏世界的构建。但是Game_Singleton对象的实例是在哪里构造的呢?你需要这个实例来访问游戏世界,这反过来会让你访问游戏对象。如果您查看Game.js文件的最后一行,您会看到这条指令:

var Game = new Game_Singleton();

最后一个实际的变量声明!所以通过变量Game,可以接入游戏世界;通过这个对象,你可以访问游戏世界中的游戏对象。例如,这是您到达cannon对象的方式:

Game.gameWorld.cannon

你可能会问,为什么这么复杂?为什么不像以前那样简单地将每个游戏对象声明为全局变量呢?有几个原因。首先,通过在不同的地方声明许多全局变量,您的代码变得更加难以重用。假设您想在另一个也使用球和大炮的应用中使用 Painter 的部分代码。现在,您必须仔细检查代码,找到声明全局变量的位置,并确保它们对您的应用有用。最好在一个地方声明这些变量(比如PainterGameWorld类),这样更容易找到这些声明。

使用许多全局变量的第二个问题是,您丢弃了变量之间存在的任何结构或关系。在画家游戏中,很明显大炮和球是游戏世界的一部分。如果通过让游戏对象成为游戏世界对象的一部分来明确表达这种关系,代码会变得更容易理解。

一般来说,尽可能避免全局变量是个好主意。在画师游戏中,主要的全局变量是Game。这个变量由一个包含游戏世界的树形结构组成,游戏世界又包含游戏对象,游戏对象又包含其他变量(如位置或精灵)。

使用新的结构,其中Game对象是其他对象的树结构,您现在可以访问cannon对象来检索球的所需颜色,如下所示:

if (Game.gameWorld.cannon.currentColor === sprites.cannon_red)
    this.currentColor = sprites.ball_red;
else if (Game.gameWorld.cannon.currentColor === sprites.cannon_green)
    this.currentColor = sprites.ball_green;
else
    this.currentColor = sprites.ball_blue;

有时候在纸上画出游戏对象的树结构,或者创建一个图表,你可以在以后用适当的名字放置引用,这是很有用的。随着你开发的游戏变得越来越复杂,这样一个树提供了一个有用的概述,什么对象属于哪里,它让你在处理代码时不必在心里重新创建这个树。

编写具有多个实例的类

现在,您可以构造多个相同类型的对象,让我们在 Painter 游戏中添加几个颜料罐。这些颜料罐应该被赋予随机的颜色,它们应该从屏幕的顶部落下。一旦它们从屏幕底部掉出,你给它们分配一种新的颜色,然后把它们移回顶部。对于玩家来说,似乎每次都有不同的颜料罐落下。实际上,您只需要三个重复使用的油漆桶对象。在PaintCan类中,你定义一个画框是什么,它的行为是什么。然后,您可以创建该类的多个实例。在PainterGameWorld类中,您将这些实例存储在三个不同的成员变量中,这些变量在PainterGameWorld构造函数中声明并初始化:

function PainterGameWorld() {
    this.cannon = new Cannon();
    this.ball = new Ball();
    this.can1 = new PaintCan(450);
    this.can2 = new PaintCan(575);
    this.can3 = new PaintCan(700);
}

PaintCan级与BallCannon级的区别在于油漆罐的位置不同。这就是为什么在构造颜料罐时要将坐标值作为参数传递。该值表示油漆罐的所需 x 位置。y 位置不必提供,因为它将根据每个颜料罐的 y 速度来计算。为了让事情更有趣,你让罐子以不同的随机速度落下。(如何做到这一点将在本章后面解释。)为了计算这个速度,你想知道一个油漆罐应该具有的最小速度,这样它才不会掉得太慢。为此,您添加一个包含值的成员变量minVelocity。因此,这是PaintCan类的构造函数:

function PaintCan(xPosition) {
    this.currentColor = sprites.can_red;
    this.velocity = new Vector2();
    this.position = new Vector2(xPosition, -200);
    this.origin = new Vector2();
    this.reset();
}

就像大炮和球一样,油漆罐也有一定的颜色。默认情况下,选择红色油漆罐精灵。最初,你设置油漆罐的 y 位置,这样它就被绘制在屏幕顶部的外面,这样在游戏的后期,你就可以看到它落下。在PainterGameWorld构造函数中,您调用这个构造函数三次来创建三个PaintCan对象,每个对象都有不同的 x 位置。

因为颜料罐不处理任何输入(只有球和大炮会这样做),所以这个类不需要一个handleInput方法。然而,油漆罐确实需要更新。你想做的事情之一就是让颜料罐在随机的时刻以随机的速度落下。但是你怎么能这样做呢?

处理游戏中的随机性

油漆罐行为最重要的部分之一是它的某些方面应该是不可预测的。你不希望每个罐子都以可预测的速度或时间落下。你想增加一个随机性的因素,这样玩家每次开始一个新游戏,游戏都会不一样。当然,你也需要控制这种随机性。你不希望一个罐子花三个小时从顶部落到底部,而另一个罐子只花一毫秒。速度应该是随机的,但在可玩的速度范围内。

随机性实际上是什么意思?通常,游戏和其他应用中的随机事件或值由一个随机数生成器 管理。在 JavaScript 中,有一个属于Math对象的random方法。你可能想知道:计算机如何生成一个完全随机的数字?现实中随机性存在吗?随机性不就是一种你还不能完全预测并因此称之为“随机”的行为表现吗?好吧,我们不要太哲学了。在游戏世界和电脑程序中,你可以精确预测将要发生什么,因为电脑只能做你告诉它做的事情。因此,严格地说,计算机不能产生完全随机的数字。假装可以产生随机数的一种方法是从一个预定义的非常大的数字表中选择一个数字。因为你不是真的产生随机数,这被称为一个伪随机数发生器。大多数随机数生成器可以生成一个范围内的数,例如 0 或 1 之间的数,但它们通常也可以生成任意数或另一个范围内的数。范围内的每个数字都有相等的机会被生成。在统计学中,这样的分布称为均匀分布

假设当你开始一个游戏时,你开始通过在桌子上走来产生“随机”数字。因为数字表不会改变,所以每次玩游戏时,都会生成相同的随机数序列。为了避免这个问题,你可以在开始的时候指出你想从表格中的不同的位置开始。您在表格中开始的位置也被称为随机数发生器的种子。通常,每次启动程序时,种子的值都是不同的,比如当前系统时间。

你如何使用随机数发生器在你的游戏世界中创造随机性?假设你想在用户进门的 75%的时候制造一个敌人。在这种情况下,您会生成一个介于 0 和 1 之间的随机数。如果数字小于或等于 0.75,你就产生了一个敌人;否则你不会。由于均匀分布,这将准确地导致您所需要的行为。以下 JavaScript 代码说明了这一点:

var spawnEnemyProbability = Math.random();
if (spawnEnemyProbability >=0.75)
    // spawn an enemy
else
    // do something else

如果你想计算一个介于 0.5 和 1 之间的随机速度,你生成一个介于 0 和 1 之间的随机数,将这个数除以 2,然后加上 0.5:

var newSpeed = Math.random()/2 * 0.5;

在理解“真正的”随机性方面,人类并不比计算机强多少。这就是为什么你的 MP3 播放器在随机播放模式下有时会一遍又一遍地播放同样的歌曲。您认为自然出现的条纹是非随机的,而实际上它们是随机的。这意味着程序员有时不得不创建一个在人类看来是随机的函数——即使它不是真正随机的。

在游戏中,你必须非常小心地处理随机性。一个错误设计的产生随机单位的机制可能会让某些玩家更频繁地产生某种类型的单位,给他们一个不公平的优势。此外,当你设计游戏时,确保随机事件不会对结果产生太大影响。例如,不要让玩家在完成 80 级高挑战平台游戏后掷骰子,让掷骰子的结果决定玩家是否死亡。

计算随机速度和颜色和

每当一个罐子落下时,你想要为它创建一个随机的速度和颜色。您可以使用Math.random方法来帮助您做到这一点。让我们首先来看看创建一个随机速度。为了简洁起见,在名为calculateRandomVelocityPaintCan类中用一个单独的方法来实现。当你想初始化罐子的速度时,你可以调用这个方法。这里你使用成员变量minVelocity来定义颜料罐下落时的最小速度。这个变量在reset方法中被赋予一个初始值,这个方法是从构造函数中调用的

PaintCan.prototype.reset = function () {
    this.moveToTop();
    this.minVelocity = 30;
};

当计算随机速度时,使用这个最小速度值,在calculateRandomVelocity方法中:

PaintCan.prototype.calculateRandomVelocity = function () {
    return { x : 0, y : Math.random() * 30 + this.minVelocity };
};

该方法只包含一条指令,该指令返回一个表示速度的对象。x 方向的速度为零,因为罐子不是水平移动的——它们只会落下。y 速度是使用随机数生成器计算的。你将这个随机值乘以 30,并将成员变量minVelocity中存储的值相加,以获得minVelocityminVelocity+30之间的正 y 速度。

要计算随机颜色,您也可以使用随机数发生器,但您希望在几个离散选项(红色、绿色或蓝色)中进行选择。问题是Math.random返回一个介于零和一之间的实数。你想要的是生成一个 0、1 或 2 的随机整数。然后你可以使用一个if指令来处理不同的情况。幸运的是,Math.floor法可以帮上忙。Math.floor返回小于作为参数传递的值的最大整数。例如:

var a = Math.floor(12.34); // a will contain the value 12
var b = Math.floor(199.9999); // b will contain the value 199
var c = Math.floor(-3.44); // c will contain the value -4

这个例子结合了Math.randomMath.floor来生成一个随机数 0、1 或 2:

var randomval = Math.floor(Math.random() * 3);

使用这种方法,您可以计算一个随机值,然后使用一个if指令来选择油漆罐的颜色。这个任务是通过calculateRandomColor方法完成的。下面是该方法的样子:

PaintCan.prototype.calculateRandomColor = function () {
    var randomval = Math.floor(Math.random() * 3);
    if (randomval == 0)
        return sprites.can_red;
    else if (randomval == 1)
        return sprites.can_green;
    else
        return sprites.can_blue;
};

现在您已经编写了这两种生成随机值的方法,您可以在定义油漆罐的行为时使用它们。

更新油漆罐

PaintCan类中的update方法至少应该做以下事情:

  • 设置一个随机创建的速度和颜色,如果罐头目前还没有下降
  • 通过添加速度来更新罐位置
  • 检查罐子是否完全掉落,并在那种情况下重置它

对于第一个任务,您可以使用一个if指令来检查罐子当前是否没有移动(速度等于零)。此外,您希望引入一点不可预测性,以确定罐头何时出现。为了达到这种效果,只有当某个生成的随机数小于阈值 0.01 时,才能指定随机速度和颜色。由于均匀分布,大约 100 个随机数中只有 1 个小于 0.01。因此,if指令的主体有时会被执行,甚至当一个罐子的速度为零时。在if指令的主体中,你使用了之前定义的两种方法来生成随机速度和随机颜色:

if (this.velocity.y === 0 && Math.random() < 0.01) {
    this.velocity = this.calculateRandomVelocity();
    this.currentColor = this.calculateRandomColor();
}

您还需要通过添加当前速度来更新罐子位置,再次考虑游戏时间,就像您处理球一样:

this.position.x = this.position.x + this.velocity.x * delta;
this.position.y = this.position.y + this.velocity.y * delta;

现在您已经初始化了 can 并更新了它的位置,您需要处理特殊情况。对于油漆罐,你得检查它是否已经掉落在游戏世界之外。如果是这样,就需要重新设置。好的是你已经写了一个方法来检查某个位置是否在游戏世界之外:在PainterGameWorld类中的isOutsideWorld方法。您现在可以再次使用该方法来检查罐子的位置是否在游戏世界之外。如果是这种情况,您需要重新设置罐子,使其再次位于屏幕外部的顶部。完整的if指令变成了

if (Game.gameWorld.isOutsideWorld(this.position))
    this.moveToTop();

最后,为了让游戏更有挑战性,每次更新循环时,稍微提高罐子的最小速度:

this.minVelocity = this.minVelocity + 0.01;

因为最小速度缓慢增加,游戏随着时间的推移变得更加困难。

在屏幕上画罐子

为了在屏幕上绘制油漆桶,您向PaintCan 类添加一个draw方法,该方法简单地在期望的位置绘制油漆桶精灵。在PainterGameWorld类中,您调用不同游戏对象上的handleInputupdatedraw方法。例如PainterGameWorld中的draw方法如下:

PainterGameWorld.prototype.draw = function () {
    Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
        { x : 0, y : 0 });
    this.ball.draw();
    this.cannon.draw();
    this.can1.draw();
    this.can2.draw();
    this.can3.draw();
};

Painter5 示例的所有代码都可以在本章的示例文件夹中找到。图 8-1 显示了油漆工人 5 示例的屏幕截图,现在有三个掉落的油漆罐。

9781430265382_Fig08-01.jpg

图 8-1 。画家 5 示例的屏幕截图,其中有一门大炮、一个球和三个掉落的颜料罐

将位置和速度表示为矢量

您已经看到,类是一个有价值的概念,因为它们定义了对象的结构,以及通过方法修改这些对象的行为。当您需要多个相似的对象(例如三个油漆桶)时,这尤其有用。类非常有用的另一个领域是定义基本的数据结构和操作这些结构的方法。你已经见过的一个常见结构是一个表示二维位置或速度向量的对象:

var position = { x : 0, y : 0 };
var anotherPosition = { x : 35, y : 40 };

不幸的是,下面的指令是不允许的:

var sum = position + anotherPosition;

原因是加法运算符不是为这样的复合对象定义的。当然,您可以定义一个方法来完成这项工作。但是其他一些方法也是有用的。例如,如果你能减去这些向量,乘以它们,计算它们的长度,等等,那就太好了。为了做到这一点,让我们创建一个Vector2类。首先定义构造函数:

function Vector2(x, y) {
    this.x = x;
    this.y = y;
}

您现在可以创建一个对象,如下所示:

var position = new Vector2(0,0);

如果你能初始化一个向量而不需要一直传递两个参数,那就太好了。一种方法是检查x和/或y是否未定义。如果是这种情况,只需将成员变量初始化为 0,如下:

function Vector2(x, y) {
    if (typeof x === 'undefined')
        this.x = 0;
    else
        this.x = x;
    if (typeof y === 'undefined')
        this.y = 0;
    else
        this.y = y;
}

在 JavaScript 中使用typeof关键字来返回变量的类型。这里你用它来检查xy是否有一个已定义的类型。如果是这种情况,可以将作为参数传递的值赋给成员变量。否则,将值指定为 0。JavaScript 知道写下这种if指令的一个更简短的版本。这是相同方法的样子,只是缩短了:

function Vector2(x, y) {
    this.x = typeof x !== 'undefined' ? x : 0;
    this.y = typeof y !== 'undefined' ? y : 0;
}

这段代码做的事情与带有完整的if指令的版本完全一样,但是它要短得多。问号前面是条件。然后,在问号后面,有两个值的选项,用冒号隔开。当使用这个较短的版本时,请确保您的代码仍然可读。本书仅使用较短的版本来检查参数是否已定义。这有好处;例如,您可以用各种方式创建Vector2对象:

var position = new Vector2(); // create a vector (0, 0)
var anotherPosition = new Vector2(35, 40); // create a vector (35, 40)
var yetAnotherPosition = new Vector2(-1); // create a vector (-1, 0)

现在你可以给Vector2类添加一些有用的方法,这样用向量进行计算就变得更容易了。例如,下面的方法制作一个 vector 的副本:

Vector2.prototype.copy = function () {
    return new Vector2(this.x, this.y);
};

如果你想从不同的游戏对象中复制位置或速度,这很方便。此外,比较矢量也很有用。equals方法为你做了这个:

Vector2.prototype.equals = function (obj) {
    return this.x === obj.x && this.y === obj.y;
};

您还可以定义一些基本操作,如向量的加、减、乘和除。首先,让我们定义一个向现有向量添加向量的方法:

Vector2.prototype.addTo = function (v) {
    this.x = this.x + v.x;
    this.y = this.y + v.y;
    return this;
};

您可以按如下方式使用此方法:

var position = new Vector2(10, 10); // create a vector (10, 10)
var anotherPosition = new Vector2(20, 20); // create a vector (20, 20)
position.addTo(anotherPosition); // now represents the vector (30, 30)

addTo方法的最后一条指令返回this。原因是你可以做所谓的操作符链接。因为addTo方法返回一个向量作为结果,所以您可以对该结果调用方法。例如:

var position = new Vector2(10, 10); // create a vector (10, 10)
var anotherPosition = new Vector2(20, 20); // create a vector (20, 20)
position.addTo(anotherPosition).addTo(anotherPosition);
// position now represents the vector (50, 50)

根据传递给addTo方法的参数的类型,您可以做一些不同的事情。如果参数是一个数字,你只需把这个数字加到向量的每个元素上。如果它是一个向量,你用已经描述过的方法执行运算。一种方法是使用您之前见过的typeof操作符,如下所示:

Vector2.prototype.addTo = function (v) {
    if (typeof v === 'Vector2') {
        this.x = this.x + v.x;
        this.y = this.y + v.y;
    }
    else if (typeof v === 'Number') {
        this.x = this.x + v;
        this.y = this.y + v;
    }
    return this;
};

您使用一个if指令来确定被传递的参数的类型,并相应地执行加法操作。另一种确定类型的方法是使用constructor变量,它是 JavaScript 中每个对象的一部分(就像prototype是每个函数的一部分一样)。这是addTo方法的一个版本,它使用了constructor变量,而不是typeof运算符:

Vector2.prototype.addTo = function (v) {
    if (v.constructor === Vector2) {
        this.x = this.x + v.x;
        this.y = this.y + v.y;
    }
    else if (v.constructor === Number) {
        this.x = this.x + v;
        this.y = this.y + v;
    }
    return this;
};

addTo方法将一个向量添加到一个现有的向量中。您还可以定义一个add方法,将两个向量相加并返回一个新向量。为此,您可以重用copyaddTo方法:

Vector2.prototype.add = function (v) {
    var result = this.copy();
    return result.addTo(v);
};

您现在可以执行以下操作:

var position = new Vector2(10, 10); // create a vector (10, 10)
var anotherPosition = new Vector2(20, 20); // create a vector (20, 20)
var sum = position.add(anotherPosition); // creates a new vector (30, 30)

在本例中,positionanotherPosition在第三条指令中保持不变。创建一个新的 vector 对象,它包含操作数向量中值的总和。

看看 Painter6 示例中的Vector2.js文件,在这里可以看到Vector2类的完整定义。它定义了这个类中最常见的向量运算,包括本节讨论的加法方法。因此,在画师游戏中使用矢量要容易得多。

在所有游戏对象中使用Vector2类型来表示位置和速度。例如,这是Ball类的新构造函数:

function Ball() {
    this.position = new Vector2();
    this.velocity = new Vector2();
    this.origin = new Vector2();
    this.currentColor = sprites.ball_red;
    this.shooting = false;
}

感谢Vector2类中的方法,您可以根据球的速度直观地更新球的位置,只需一行代码:

this.position.addTo(this.velocity.multiply(delta));

参数的默认值

在完成本章之前,让我们再看一下Vector2构造函数是如何定义的:

function Vector2(x, y) {
    this.x = typeof x !== 'undefined' ? x : 0;
    this.y = typeof y !== 'undefined' ? y : 0;
}

由于方法体内部的赋值指令,即使您在调用构造函数方法时没有传递任何参数,您仍将创建一个有效的Vector2对象。如果没有定义参数xy,则使用默认值。您可以利用这种情况,因为依赖默认值可以简化代码编写。举个例子,这是在屏幕上绘制背景图像的指令:

Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0, { x : 0, y : 0 });

通过让drawImage方法 自动为位置、旋转和原点参数提供默认值,可以使这个方法调用更加简洁:

Canvas2D_Singleton.prototype.drawImage = function (sprite, position,
                                                   rotation, origin) {
    position = typeof position !== 'undefined' ? position : Vector2.zero;
    rotation = typeof rotation !== 'undefined' ? rotation : 0;
    origin = typeof origin !== 'undefined' ? origin : Vector2.zero;
    // remaining drawing code here
    ...
}

然后绘制背景,如下所示:

Canvas2D.drawImage(sprites.background);

虽然参数的默认值在创建紧凑的代码时非常有用,但是请确保您总是为您的方法提供文档,指定如果方法的调用方没有提供所有参数时将使用哪些默认值。

你学到了什么

在本章中,您学习了:

  • 如何使用原型机制定义类
  • 如何创建一个类型/类的多个实例
  • 如何增加游戏的随机性以增加可玩性

九、颜色和碰撞

到目前为止,您已经实现了 Painter 游戏的很大一部分。您已经看到了如何使用原型机制定义游戏对象类。通过使用这些类,您可以更好地控制游戏对象的结构以及如何创建特定类型的游戏对象。您将这些类定义分隔在不同的文件中。这样,当你在未来的游戏中需要一个具有相同行为的大炮或球时,你可以简单地复制这些文件并在你的游戏中创建这些游戏对象的实例。

当您更仔细地查看类的定义时,您会发现类定义了对象的内部结构(它由哪些变量组成)以及以某种方式操作该对象的方法。这些方法可以帮助更精确地定义一个对象的可能性和局限性。例如,如果有人想重用Ball类,他们不需要很多关于球是如何构造的详细信息。简单地创建一个实例并调用 game-loop 方法就足以在游戏中添加一个飞行球。一般来说,当你设计一个程序时,无论是游戏还是完全不同的应用,清楚地定义某个类的对象的可能性是很重要的。方法是做到这一点的一种方式。本章向你展示了另一种定义对象可能性的方法:通过定义属性。本章还介绍了一种表示颜色的类型,并展示了如何处理球和颜料罐之间的碰撞(如果发生这种情况,颜料罐需要改变颜色)。

表现颜色的不同方式

在 Painter 的早期版本中,您已经相当实际地处理了颜色。例如,在Cannon类中,您通过使用currentColor变量跟踪当前颜色,该变量最初指向红色的 cannon sprite:

this.currentColor = sprites.cannon_red;

你在Ball类中做了类似的事情,除了你让同名的变量指向彩色的球精灵。虽然这样做很好,但当球的颜色需要根据大炮的颜色而改变时,这就有点不方便了:

if (Game.gameWorld.cannon.currentColor === sprites.cannon_red)
    this.currentColor = sprites.ball_red;
else if (Game.gameWorld.cannon.currentColor === sprites.cannon_green)
    this.currentColor = sprites.ball_green;
else
    this.currentColor = sprites.ball_blue;

在这个if指令中,你需要处理所有三种不同的颜色;此外,现在Ball类需要了解Cannon类使用的精灵。如果可以更统一地定义颜色,并在所有游戏对象类中使用该定义来表示不同的颜色,不是更好吗?当然会!现在开始在游戏中统一颜色使用的另一个原因是,如果你决定增加游戏中可能的颜色数量(到 4、6、10 或更多),当前的方法将需要更长的编程时间。

属于本章的 Painter7 示例添加了一个Color.js JavaScript 文件。要定义不同的颜色,可以使用与定义不同键类似的方法。这个文件定义了一个名为Color的变量。Color变量包含许多子变量,每个子变量代表一种不同的颜色。您可以如下定义颜色:

var Color = {
    red : 1,
    blue : 2,
    yellow : 3,
    green : 4,
    // and so on
}

然而,这种方法并不是一个好的解决方案。用数字来表示颜色并不是一个坏主意,但是你不应该自己编一个没有人知道的编号方案。在 HTML 中已经有了一个定义颜色的标准,它使用以十六进制形式表示的整数,您也可以使用这个标准。好处是这种方法被广泛使用,被广泛理解,被工具广泛支持(比如 Adobe 的 Kuler,比如 at http://kuler.adobe.com)。

在 HTML 标准中,您可以通过使用十六进制表示来定义网页中元素的颜色。例如:

<body style="background: #0000FF">
That's a very nice background.
</body>

在这种情况下,您指定主体的背景颜色应该是颜色蓝色。十六进制表示让您定义红、绿、蓝(RGB)值中的颜色,其中 00 表示没有颜色分量, FF 表示颜色分量最大。

#符号不是数字的一部分,它只是向浏览器表明后面是十六进制数而不是十进制数。所以,#0000FF十六进制数代表蓝色,#00FF00是绿色,#FF0000是红色。当然,颜色分量的任何混合或渐变都可以用类似的方式来定义。#808080为灰色,#800080为紫色,#FF00FF为洋红色。

下面是Color变量的一部分:

var Color = {
    aliceBlue: "#F0F8FF",
    antiqueWhite: "#FAEBD7",
    aqua: "#00FFFF",
    aquamarine: "#7FFFD4",
    azure: "#F0FFFF",
    beige: "#F5F5DC",
    bisque: "#FFE4C4",
    black: "#000000",
    blanchedAlmond: "#FFEBCD",
    blue: "#0000FF",
    blueViolet: "#8A2BE2",
    brown: "#A52A2A",
    // and so on
}

有关更完整的颜色列表,请参见Color.js文件。您现在可以开始在您的类中使用这些颜色定义。

对象的受控数据访问

三个游戏对象类代表一个特定颜色的对象:CannonBallPaintCan。为了简单起见,让我们从如何修改Cannon类来使用上一节中的颜色定义开始。到目前为止,Cannon构造函数看起来是这样的:

function Cannon() {
    this.position = new Vector2(72, 405);
    this.colorPosition = new Vector2(55, 388);
    this.origin = new Vector2(34, 34);
    this.currentColor = sprites.cannon_red;
    this.rotation = 0;
}

您可以做的是添加另一个成员变量,给出加农炮的当前颜色。因此,新的Cannon构造函数如下所示:

function Cannon() {
    this.position = new Vector2(72, 405);
    this.colorPosition = new Vector2(55, 388);
    this.origin = new Vector2(34, 34);
    this.currentColor = sprites.cannon_red;
    this.color = Color.red;
    this.rotation = 0;
}

然而,这并不理想。您现在存储了冗余数据,因为颜色信息由两个变量表示。此外,当加农炮的颜色改变时,如果您忘记改变两个变量中的一个,您可能会以这种方式引入错误。

另一种方法是不存储对当前 sprite 的引用。这将是构造函数:

function Cannon() {
    this.position = new Vector2(72, 405);
    this.colorPosition = new Vector2(55, 388);
    this.origin = new Vector2(34, 34);
    this.color = Color.red;
    this.rotation = 0;
}

这也不是一种理想的方法,因为每次调用draw方法时,您都需要查找正确的 sprite。

一个解决方案是定义两个方法,允许Cannon类的用户检索和设置颜色信息。然后,您可以保持构造函数不变,但添加方法来读取和写入颜色值。例如,您可以将以下两个方法添加到Cannon原型中:

Cannon.prototype.getColor = function () {
    if (this.currentColor === sprites.cannon_red)
        return Color.red;
    else if (this.currentColor === sprites.cannon_green)
        return Color.green;
    else
        return Color.blue;
};
Cannon.prototype.setColor = function (value) {
    if (value === Color.red)
        this.currentColor = sprites.cannon_red;
    else if (value === Color.green)
        this.currentColor = sprites.cannon_green;
    else if (value === Color.blue)
        this.currentColor = sprites.cannon_blue;
};

现在Cannon类的用户不需要知道在内部,您使用一个 sprite 来确定大炮的当前颜色。用户可以简单地传递颜色定义来读取或写入加农炮的颜色:

myCannon.setColor(Color.blue);
var cannonColor = myCannon.getColor();

有时,程序员称这类方法为gettersetter。在许多面向对象的编程语言中,方法是访问对象内部数据的唯一方式,因此对于每个需要在类外部访问的成员变量,程序员都添加了一个 getter 和一个 setter。JavaScript 提供了一个对面向对象编程语言来说相对较新的特性:属性。属性是 getter 和 setter 的替代品。它定义了从对象中检索数据时会发生什么,以及向对象内部的数据赋值时会发生什么。

只读属性

按照基于原型的编程范式,您希望能够向类添加属性。JavaScript 有一个叫做defineProperty的简便方法可以让你做到这一点。这个方法是对象的一部分,俗称ObjectObject还有几个其他有用的方法,您稍后会了解到。defineProperty方法需要三个参数:

  • 应添加属性的原型(例如,Cannon.prototype)
  • 属性的名称(例如,color)
  • 一个最多包含两个变量的对象:getset

getset变量都应该指向一个函数,该函数应该在属性被读取或写入时被执行。然而,可以只定义一个getset零件。如果属性只读取信息而不能更改信息,这将非常有用。如果一个属性只读取信息,它被称为只读属性 。下面是一个简单的例子,它是您添加到Cannon类中的只读属性:

Object.defineProperty(Cannon.prototype, "center",
    {
        get: function () {
            return new Vector2(this.currentColor.width / 2,
                this.currentColor.height / 2);
        }
    });

如您所见,您向defineProperty方法提供了三个参数:原型、名称和对象。这个楼盘的名字叫center。它的目标是提供代表大炮的精灵的中心。因为不可能改变中心的值,所以这个属性只有一个get部分。这反映在作为第三个参数传递的对象中,该对象包含一个指向函数的变量get。以下是使用该属性的方法:

var cannonCenter = cannon.center;

很简单,不是吗?同样,您可以添加一个提供加农炮高度的属性,如下所示:

Object.defineProperty(Cannon.prototype, "height",
    {
        get: function () {
            return this.currentColor.height;
        }
    });

你甚至可以定义一个属性ballPosition来计算球应该在的位置:

Object.defineProperty(Cannon.prototype, "ballPosition",
    {
        get: function () {
            var opposite = Math.sin(this.rotation) *
                sprites.cannon_barrel.width * 0.6;
            var adjacent = Math.cos(this.rotation) *
                sprites.cannon_barrel.width * 0.6;
            return new Vector2(this.position.x + adjacent,
                this.position.y + opposite);
        }
    });

就像处理方法一样,使用this关键字来引用属性所属的对象。属于本章的 Painter7 示例将属性添加到不同的类中。例如,Ball类也包含一个center属性。结合您添加到Vector2中的便捷的方法,您现在可以在一行代码中根据大炮的旋转来计算球的新位置:

this.position = Game.gameWorld.cannon.ballPosition.subtractFrom(this.center);

定义类、方法和属性的好处是你的代码变得更短,更容易理解。例如,您还可以将以下属性添加到Vector2:

Object.defineProperty(Vector2, "zero",
    {
        get: function () {
            return new Vector2();
        }
    });

现在你有一个非常简单的方法来创建一个二维向量,如下:

var position = Vector2.zero;

从现在开始,我使用属性和方法来定义对象的行为和数据访问。通过在类中定义有用的属性和方法,游戏代码通常会变得更短,更容易阅读。例如,在您拥有包含有用方法和属性的类之前,您必须这样计算球的位置:

this.position = Game.gameWorld.cannon.ballPosition();
this.position.x = this.position.x - this.currentColor.width / 2;
this.position.y = this.position.y - this.currentColor.height / 2;

正如您在本节前面所看到的,新的方法要短得多。这种效果在你在本书中开发的游戏代码中随处可见,我鼓励你接受类、方法和属性所提供的力量!

检索加农炮的颜色

在本章中,您定义了一个名为Color的新类型。因此,让我们结合使用该类型和属性来读取和写入大炮的颜色。根据currentColor变量指向的 sprite,您希望返回不同的颜色值。为了实现这一点,您需要在Cannon类中添加一个名为color的属性。在该属性的get部分,您使用一个if指令来找出返回哪种颜色。就像有返回值的方法一样,使用return关键字来指示属性应该返回什么值:

Object.defineProperty(Cannon.prototype, "color",
    {
        get: function () {
            if (this.currentColor === sprites.cannon_red)
                return Color.red;
            else if (this.currentColor === sprites.cannon_green)
                return Color.green;
            else
                return Color.blue;
        }
    });

现在,您可以使用该属性来访问加农炮的颜色。例如,您可以将它存储在一个变量中,如下所示:

var cannonColor = cannon.Color;

您还希望能够为加农炮颜色赋值。为此,您必须定义属性的set部分。在那个部分,您需要修改currentColor变量的值。当在另一个方法中使用该属性时,会提供此值。例如,它可能是这样的指令:

cannon.color = Color.Red;

同样,您使用一条if指令来确定currentColor变量的新值应该是什么。右边的赋值作为参数传递给set部件。完整的属性如下所示:

Object.defineProperty(Cannon.prototype, "color",
    {
        get: function () {;
            if (this.currentColor === sprites.cannon_red)
                return Color.red;
            else if (this.currentColor === sprites.cannon_green)
                return Color.green;
            else
                return Color.blue;
        },
        set: function (value) {
            if (value === Color.red)
                this.currentColor = sprites.cannon_red;
            else if (value === Color.green)
                this.currentColor = sprites.cannon_green;
            else if (value === Color.blue)
                this.currentColor = sprites.cannon_blue;
        }
    });

这是一个可以读写的属性示例。你添加一个color属性到所有的彩色游戏对象类型:CannonBallPaintCan。在getset部分的代码中唯一的区别是用来表示颜色的精灵。例如,这是Ball类的color属性:

Object.defineProperty(Ball.prototype, "color",
    {
        get: function () {
            if (this.currentColor === sprites.ball_red)
                return Color.red;
            else if (this.currentColor === sprites.ball_green)
                return Color.green;
            else
                return Color.blue;
        },
        set: function (value) {
            if (value === Color.red)
                this.currentColor = sprites.ball_red;
            else if (value === Color.green)
                this.currentColor = sprites.ball_green;
            else if (value === Color.blue)
                this.currentColor = sprites.ball_blue;
        }
    });

因为您已经定义了这些属性,所以现在您可以根据大炮的颜色非常容易地更改球的颜色,只需一行代码:

this.color = Game.gameWorld.cannon.color;

查看 Painter7 示例,了解它如何以及在何处使用属性来使代码更易于阅读。对于一些程序员来说,属性乍一看可能很奇怪,因为它们被用来获取和设置方法。然而,属性确实有更直观的意义。它们是降低代码行复杂性的好方法。

处理球和罐子之间的碰撞

Painter7 示例通过处理球和罐子之间的碰撞来扩展游戏。如果两个对象发生冲突,你必须在两个对象之一的update方法中处理这个冲突。在这种情况下,您可以选择在Ball类或PaintCan类中处理冲突。Painter7 在PaintCan类中处理碰撞,因为如果你要在Ball类中处理碰撞,你需要重复同样的代码三次,每个油漆罐一次。通过在PaintCan类中处理碰撞,您可以自动获得这种行为,因为每个类都可以自己检查是否与球碰撞。

虽然可以用许多不同的方法进行冲突检查,但是这里使用一个非常简单的方法。如果两个对象中心之间的距离小于某个值,则可以定义这两个对象之间存在碰撞。游戏世界中任何时候球的中心位置是通过将球精灵的中心与球的位置相加来计算的。您可以用类似的方法计算油漆罐的中心。因为您添加了一些很好的属性来计算游戏对象的中心,所以让我们使用它们来计算球和油漆罐之间的距离,如下所示:

var ball = Game.gameWorld.ball;
var distance = ball.position.add(ball.center).subtractFrom(this.position)
    .subtractFrom(this.center);

现在你已经计算了这个向量,你必须检查它在 x 和 y 方向的长度是否小于某个给定值。如果距离向量的 x 分量的绝对值小于中心的 x 值,则意味着球对象在罐的 x 范围内。同样的原理也适用于 y 方向。如果这对于 x 和 y 分量都成立,你可以说球和罐子碰撞了。您可以编写一条if指令来检查这种情况:

if (Math.abs(distance.x) < this.center.x &&
    Math.abs(distance.y) < this.center.y) {
    // handle the collision
}

您使用Math.abs方法来计算距离分量的绝对值。如果球和罐子有碰撞,你需要把罐子的颜色改成球的颜色。

接下来,你必须重新设置球,以便它可以再次被拍摄。以下两条指令正是这样做的:

this.color = ball.color;
ball.reset();

您可以尝试 Painter7 示例,查看球和颜料罐之间的碰撞是否得到了正确处理。

您可能已经注意到,这里使用的碰撞检测方法不是很精确。在第二十六章中,你会看到一种更好的方法来处理每像素级别的碰撞,尽管如果你不小心的话,这会让你的游戏运行得更慢。

注意最后,像本节中所写的简单代码行在玩家体验中产生了很大的不同。当你构建你的游戏应用时,你会发现有时候对玩家来说最小的东西也要花最长的时间来编程,而最大的变化只用一两行就实现了!

你学到了什么

在本章中,您学习了:

  • 如何向类中添加属性
  • 如何处理游戏对象之间的基本碰撞
  • 如何定义具有不同颜色的游戏对象

十、有限的生命

在这一章中,你通过给玩家有限数量的生命来使画家游戏更有趣。如果玩家错过太多颜料罐,他们就会死。这一章讨论了如何处理这个问题,以及如何向玩家显示当前的生命值。为了实现后者,您需要学习一些编程结构,以便多次重复一组指令。

维持生命的数量

为了在游戏中引入一些危险和努力工作的激励,您想要限制玩家可以允许从屏幕底部掉落的错误颜色的颜料罐的数量。Painter8 示例将这种行为添加到游戏中,并使用五个限制。

选择五个颜料罐的限制是作为游戏设计者和开发者必须做出的决定的许多例子之一。如果你只给玩家一条命,那么这个游戏就太难玩了。给玩家几百条命就消除了玩家玩好游戏的动机。确定这些参数通常是通过游戏测试和确定合理的参数值来实现的。除了自己测试游戏之外,你还可以请你的朋友或家人玩你的游戏,以了解这些参数的取值。

为了存储寿命限制,您向PainterGameWorld类添加了一个额外的成员变量:

this.lives = 5;

最初在PainterGameWorld类的构造函数中将这个值设置为 5。现在,只要颜料罐落在屏幕之外,您就可以更新该值。您在PaintCan类的update方法中执行这个检查。因此,您必须在该方法中添加一些指令来处理这种情况。你唯一需要做的事情是检查颜料罐的颜色是否与它通过屏幕底部时的目标颜色相同。如果是这种情况,你必须减少PainterGameWorld类中的lives计数器。

在这样做之前,您必须扩展PaintCan类,以便PaintCan对象知道当它们从屏幕底部掉出来时需要有一个目标颜色。当您在PainterGameWorld中创建PaintCan对象时,Painter8 将此目标颜色作为参数传递:

this.can1 = new PaintCan(450, Color.red);
this.can2 = new PaintCan(575, Color.green);
this.can3 = new PaintCan(700, Color.blue);

您将目标颜色存储在每个颜料罐的变量中,正如您在PaintCan的构造函数中看到的:

function PaintCan(xPosition, targetColor) {
    this.currentColor = sprites.can_red;
    this.velocity = Vector2.zero;
    this.position = new Vector2(xPosition, -200);
    this.origin = Vector2.zero;
    this.targetColor = targetColor;
    this.reset();
}

您现在可以扩展PaintCanupdate方法,这样它就可以处理颜料罐落在屏幕底部之外的情况。如果发生这种情况,您需要将颜料罐移回屏幕顶部。如果油漆桶的当前颜色与目标颜色不匹配,则生命数减少一:

if (Game.gameWorld.isOutsideWorld(this.position)) {
    if (this.color !== this.targetColor)
        Game.gameWorld.lives = Game.gameWorld.lives - 1;
    this.moveToTop();
}

在某些时候,你可能想要减少不止一个生命的数量。为了方便起见,你可以把惩罚变成一个变量:

var penalty = 1;
if (Game.gameWorld.isOutsideWorld(this.position)) {
    if (this.color !== this.targetColor)
        Game.gameWorld.lives = Game.gameWorld.lives - penalty;
    this.moveToTop();
}

这样,如果你愿意,你可以引入更严厉的惩罚,或者动态惩罚(第一次失误要付出一条生命的代价,第二次失误要付出两条生命的代价,以此类推)。你也可以想象有时一种特殊的颜料会掉下来。如果球员用正确颜色的球射击罐子,油漆罐颜色不匹配的惩罚暂时为零。你能想出在画家游戏中处理点球的其他方法吗?

向玩家指示生命的数量

显然,玩家想知道他们做得怎么样。所以,你必须在屏幕上显示玩家还剩多少条命。在画师游戏中,你可以在屏幕的左上角显示一些气球。利用你所拥有的知识,你可以使用一个if指令来做这件事:

if (lives === 5) {
    // Draw the balloon sprite 5 times in a row
} else if (lives === 4) {
    // Draw the balloon sprite 4 times in a row
} else if (lives === 3)
    // And so on...

这不是一个很好的解决方案。这导致了大量的代码,你不得不多次复制相同的指令。好在有更好的解决方案:迭代

多次执行指令

JavaScript 中的迭代是多次重复指令的一种方式。看看下面的代码片段:

var val = 10;
while (val >=3)
    val = val - 3;

第二条指令称为while循环。该指令由一种头(while (val >=3))和一种体(val = val - 3;)组成,与if指令的结构非常相似。标题由单词while组成,后跟括号中的条件。身体本身就是一个指令。在这种情况下,指令从变量中减去 3。然而,它也可以是另一种指令,比如调用一个方法或访问一个属性。图 10-1 显示了while指令的语法图。

9781430265382_Fig10-01.jpg

图 10-1 。while指令的语法图

当执行while指令时,其主体被多次执行。事实上,只要头文件中的条件产生true,主体就会被执行。在这个例子中,条件是val变量包含一个等于或大于 3 的值。一开始,变量包含值 10,所以它肯定大于 3。因此,while指令的主体被执行,之后变量val包含值 7。然后再次评估该条件。变量仍然大于 3,所以再次执行主体,之后变量val包含值 4。再次,值大于 3,所以再次执行主体,val将包含值 1。此时,条件被评估,但它不再是true。因此,重复的指令结束。因此,在这段代码执行之后,变量val包含值 1。事实上,您在这里编程的是使用while指令的整数除法。当然,在这种情况下,更简单的方法是使用下面一行代码来实现相同的结果:

var val = 10 % 3;

如果你想在屏幕上画出玩家的生命数,你可以使用一个while指令来非常有效地完成:

var i = 0;
while (i < numberOfLives) {
    Canvas2D.drawImage(sprites.lives,
        new Vector2(i * sprites.lives.width + 15, 60));
    i = i + 1;
}

在这个while指令中,只要变量i包含一个小于numberOfLives的值(假设这个变量在其他地方被声明并初始化为某个值),就执行主体。每执行一次主体,你就在屏幕上画出 sprite,然后把i加 1。结果就是你在屏幕上画的精灵正好是numberOfLives的两倍!所以,你在这里使用变量i作为计数器

注意你从等于零的i开始,直到i达到与numberOfLives相同的值。这意味着while指令的主体针对i的值 0、1、2、3 和 4 执行。结果,身体被执行五次。

正如你所看到的,一条while指令的主体可能包含不止一条指令。如果主体包含不止一条指令,那么这些指令需要放在大括号中,就像使用if指令一样。

绘制精灵的位置取决于i的值。这样,你可以把每个精灵画得更靠右一点,这样它们就可以很好地排成一行。第一次执行 body 时,在 x 位置 15 绘制 sprite,因为i是 0。下一次迭代,你在 x 位置sprites.lives.width + 15绘制精灵,下一次迭代在2 * sprites.lives.width + 15绘制,以此类推。在这种情况下,你不仅要使用计数器来确定你执行指令的频率,还要改变指令做什么。这是像while这样的迭代指令的一个非常强大的特性。由于循环行为,while指令也被称为while 循环。图 10-2 显示了画家游戏的截图,其中生命的数量显示在屏幕的左上角。

9781430265382_Fig10-02.jpg

图 10-2 。画家游戏向玩家展示剩余的生命数

递增计数器的更短符号

许多while指令,尤其是那些使用计数器的指令,都有一个包含变量递增指令的主体。这可以通过以下指令完成:

i = i + 1;

顺便说一下,特别是由于这种类型的指令,将赋值表示为“是”是不明智的。i的值当然永远不可能和i + 1相同,但是i 的值就变成了i的旧值,加 1。这种类型的指令在程序中非常常见,因此存在一种特殊的、更短的符号来做完全相同的事情:

i++;

++可以表示为“递增”因为这个操作符放在它所操作的变量之后,所以++操作符被称为后缀操作符。要使变量增加 1 以上,还有另一种表示法

i += 2;

这意味着和

i = i + 2;

其他基本算术运算也有类似的记法,例如:

i -= 12; // this is the same as i = i – 12
i *= 0.99; // this is the same as i = i * 0.99
i /=5; // this is the same as i = i / 5
i--; // this is the same as i = i – 1

这种符号非常有用,因为它允许您编写更短的代码。例如,下面的代码:

Game.gameWorld.lives = Game.gameWorld.lives – penalty;

...变成:

Game.gameWorld.lives -= penalty;

当你查看属于本章的示例代码时,你会发现这种更短的符号被用在许多不同的类中,以使代码更紧凑。

更紧凑的循环语法

许多while指令使用计数变量,因此具有以下结构:

var i;
i = *begin value*
;
while (i < *end value* ) {
    // do something useful using i
    i++;
}

因为这种指令很常见,所以有一个更简洁的符号:

var i;
for (i = *begin value* ; i < *end value* ; i++ ) {
    // do something useful using i
}

这条指令的含义和前面的while指令完全一样。在这种情况下使用for指令的优点是,所有与计数器有关的东西都被很好地组合在指令头中。这减少了您忘记递增计数器的指令的机会(导致无限循环)。在“使用i做一些有用的事情”只包含一条指令的情况下,您可以省去大括号,这使得符号更加简洁。同样,你可以在for指令的头部移动变量i的声明。例如,看看下面的代码片段:

for (var i = 0; i < this.lives; i++) {
    Canvas2D.drawImage(sprites.lives,
        new Vector2(i * sprites.lives.width + 15, 60));
}

这是一个非常紧凑的指令,它递增计数器并在不同的位置绘制精灵。该指令相当于下面的while指令:

var i = 0;
while (i < this.lives) {
    Canvas2D.drawImage(sprites.lives,
        new Vector2(i * sprites.lives.width + 15, 60));
    i = i + 1;
}

这是另一个例子:

for (var i = this.lives - 1; i >=0; i--)
    Canvas2D.drawImage(sprites.lives,
        new Vector2(i * sprites.lives.width + 15, 60));

这条for指令等同于哪条while指令?在这种情况下,增加或减少计数器在实践中有区别吗?图 10-3 包含了for指令的语法图。

9781430265382_Fig10-03.jpg

图 10-3 。for指令的语法图

几个特例

在处理whilefor循环时,您需要了解一些特殊情况。以下小节讨论了这些情况。

完全没有重复

有时while指令头中的条件在开始时已经是false。请看下面的代码片段:

var x = 1;
var y = 0;
while (x < y)
    x++;

在这种情况下,while指令的主体不会被执行——一次也不会!因此,在这个例子中,变量x保留值 1。

无限重复

使用while指令(在较小程度上还有for指令)的危险之一是,如果你不小心,它们可能永远不会结束。你可以很容易地写出这样的指令:

while (1 + 1 === 2)
    x = x + 1;

在这种情况下,x的值会无限增加。这是因为条件1 + 1 === 2总是产生true,不管在指令体中做了什么。这个例子很容易避免,但是由于编程错误,一个while指令经常在一个无限循环中结束。考虑以下示例:

var x = 1;
var n = 0;
while (n < 10)
    x = x * 2;
    n = n + 1;

这段代码的意图是将x的值翻十倍。但遗憾的是,程序员忘了把主体中的两条指令放在大括号之间。程序的布局暗示了这一意图,但是脚本解释器并不关心这一点。只有x=x*2;指令是重复的,所以n的值永远不会大于或等于十。在while指令之后,指令n=n+1;将被执行,但是程序永远不会到达那里。程序员真正的意思是

var x = 1;
var n = 0;
while (n < 10) {
    x = x * 2;
    n = n + 1;
}

如果你的电脑或移动设备因为忘记在你的while指令周围加上括号而陷入昏迷后,你不得不扔掉它,那将是一个遗憾。幸运的是,操作系统可以强制停止一个程序的执行,即使它还没有完成。甚至现在的浏览器也可以检测无限期运行的脚本,在这种情况下,浏览器可以停止脚本的执行。一旦这样做了,你就可以开始寻找程序挂起的原因。虽然这种问题在程序中偶尔会出现,但作为游戏程序员,你有责任确保一旦游戏公开发布,这种编程错误已经从游戏代码中删除。这就是为什么正确的测试如此重要。

一般来说,如果你写的程序在启动时似乎不做任何事情,或者如果它无限期挂起,检查一下while指令中发生了什么。一个非常常见的错误是忘记递增计数器变量,因此while指令的条件永远不会变成false,并且while循环会无限期地继续下去。许多其他编程错误可能会导致无限循环。事实上,无限循环是如此普遍,以至于加州库比蒂诺的一条街道以它们命名——苹果总部就坐落在这条街上!

嵌套重复

while指令或for指令的主体是一条指令。这个指令可以是一个赋值,一个方法调用,一个由大括号分隔的指令块,或者另一个whilefor循环。比如:

var x, y;
for (y=0; y<7; y++)
    for (x=0; x<y; x++)
        Canvas2D.drawImage(sprites.lives,
            new Vector2(x * sprites.lives.width, y * sprites.lives.height));

在这个片段中,变量y从 0 计数到 7。对于y的每一个值,执行主体,它由一个for指令组成。第二个for指令使用计数器x,该计数器具有作为上限的y的值。因此,在外部for指令的每个进程中,内部for指令会持续更长时间。重复的指令在使用xy计数器的值计算的位置绘制一个黄色气球精灵。这个循环的结果是许多气球被放置成三角形(见图 10-4 )。

9781430265382_Fig10-04.jpg

图 10-4 。三角形的气球

该形状的第一条线包含零个气球。原因是此时y的值仍然为 0,这意味着内部的for指令执行了 0 次。

重新开始游戏

当玩家失去所有生命时,游戏就结束了。你如何处理这个?在画师游戏的情况下,你想在屏幕上显示一个游戏。玩家可以按下鼠标左键,然后重新开始游戏。为了将它添加到游戏中,当游戏开始时,你加载一个额外的精灵,它在屏幕上显示游戏:

sprites.gameover = loadSprite("spr_gameover_click.png");

现在你可以在每个游戏循环方法中使用一个if指令来决定你应该做什么。如果游戏结束了,你不希望大炮和球再处理输入;你只是想听听玩家是否按下了鼠标键。如果发生这种情况,你重置游戏。因此,PainterGameWorld类中的handleInput方法现在包含以下指令:

if (this.lives > 0) {
    this.ball.handleInput(delta);
    this.cannon.handleInput(delta);
}
else {
    if (Mouse.leftPressed)
        this.reset();
}

您将一个reset方法添加到PainterGameWorld类中,这样您就可以将游戏重置为其初始状态。这意味着重置所有的游戏对象。您还需要将生命数重置为 5。下面是PainterGameWorld中完整的reset方法:

PainterGameWorld.prototype.reset = function () {
    this.lives = 5;
    this.cannon.reset();
    this.ball.reset();
    this.can1.reset();
    this.can2.reset();
    this.can3.reset();
};

对于update方法,如果游戏没有结束,你只需要更新游戏对象。因此,你首先用一个if指令检查你是否需要更新游戏对象。如果不是(换句话说:生命的数量已经达到零),则从方法返回:

if (this.lives <= 0)
    return;
this.ball.update(delta);
this.cannon.update(delta);
this.can1.update(delta);
this.can2.update(delta);
this.can3.update(delta);

最后,在draw方法中,你绘制游戏对象,如果玩家没有生命了,游戏就结束。这导致了以下结构:

PainterGameWorld.prototype.draw = function () {
    // draw the game world
    ...
    for (var i = 0; i < this.lives; i++) {
        Canvas2D.drawImage(sprites.lives,
            new Vector2(i * sprites.lives.width + 15, 60));
    }
    if (this.lives <= 0) {
        Canvas2D.drawImage(sprites.gameover,
            new Vector2(Game.size.x - sprites.gameover.width,
            Game.size.y - sprites.gameover.height).divideBy(2));
    }
};

你可以看到你使用了屏幕的尺寸和游戏的尺寸来很好地将它放置在屏幕的中央。图 10-5 显示了在游戏世界顶部绘制的游戏 Over overlay 的截图。

9781430265382_Fig10-05.jpg

图 10-5 。游戏结束!

在图 10-5 中,请注意覆盖层上的游戏并没有完全隐藏其他物体和背景。原因是游戏 Over sprite 有一些透明像素。通常,精灵有透明的部分,所以精灵似乎是游戏世界的一部分。气球、球、颜料罐和炮管都是部分透明的,这就是它们无缝融入游戏世界的原因。当设计精灵时,你需要确保图像的透明度值设置正确。虽然正确地做这件事可能需要大量的工作,但现代的图像编辑工具,如 Adobe Photoshop,给了你许多定义图像透明度的方法。只要确保你保存的图像格式支持透明,如 PNG。

注意你可以使用覆盖图的透明度来控制玩家看到的内容。在某些情况下,您可能希望事情变得模糊不清(例如时间敏感游戏中的“暂停”屏幕)或能够被看到(例如 Painter 中的屏幕上的游戏)。

你学到了什么

在本章中,您学习了:

  • 如何存储和显示玩家当前拥有的生命数
  • 如何使用whilefor指令重复一组指令
  • 当玩家没有剩余生命时,如何重新开始游戏

十一、组织游戏对象

在前面的章节中,你已经看到了如何使用类来对属于同一类的变量进行分组。本章着眼于不同类型的游戏对象之间的相似性,以及如何用 JavaScript 表达这些相似性。

游戏对象之间的相似性

如果你看看画师游戏中不同的游戏对象,你会发现它们有很多共同点。例如,球、大炮和颜料罐都使用三个精灵,分别代表三种不同的颜色。此外,游戏中的大多数物体都有位置和速度。此外,所有游戏对象都需要一个方法来绘制它们,一些游戏对象有一个处理输入的方法,一些游戏对象有一个update方法、 等等。现在,这些类有相似之处并不是一个问题。浏览器或游戏玩家不会对此抱怨。但是,很遗憾,你必须一直复制代码。举个例子,BallPaintCan类都有widthheight属性:

Object.defineProperty(Ball.prototype, "width",
    {
        get: function () {
            return this.currentColor.width;
        }
    });
Object.defineProperty(Ball.prototype, "height",
    {
        get: function () {
            return this.currentColor.height;
        }
    });

代码是完全一样的,但是你必须为两个类复制它。并且每次你想添加一个不同种类的游戏对象,你可能需要再次复制这些属性。在这种情况下,幸运的是,这些属性并不复杂,但是在应用中,您还复制了许多其他内容。例如,Painter 游戏中的大多数游戏对象类都定义了以下成员变量:

this.currentColor = *some sprite*
;
this.velocity = Vector2.zero;
this.position = Vector2.zero;
this.origin = Vector2.zero;
this.rotation = 0;

各种游戏对象的draw方法看起来也很相似。例如,下面是BallPaintCan类的draw方法:

Ball.prototype.draw = function () {
    if (!this.shooting)
        return;
    Canvas2D.drawImage(this.currentColor, this.position, this.rotation, 1,
        this.origin);
};
PaintCan.prototype.draw = function () {
    Canvas2D.drawImage(this.currentColor, this.position, this.rotation, 1,
        this.origin);
};

同样,代码在不同的类中是非常相似的,你每次创建一个新的游戏对象时都要复制它。一般来说,最好避免复制大量代码。为什么会这样?因为如果在某个时候你意识到那部分代码中有错误,你必须在你复制它的地方改正它。在像 Painter 这样的小游戏中,这不是什么大问题。但是当你开发一个拥有数百个不同游戏对象类的商业游戏时,这就变成了一个严重的维护问题。此外,你并不总是知道一个小游戏会走多远。如果您不小心,您可能会复制大量代码(以及与之相关的错误)。随着游戏的成熟,留意在哪里优化代码是一个好主意,即使这意味着一些额外的工作来找到这些重复并巩固它们。对于这种特殊的情况,你需要考虑不同种类的游戏对象是如何相似的,以及你是否可以将这些相似性组合在一起,就像你在前面的章节中对成员变量进行分组一样。

从概念上讲,很容易说出球、颜料罐和大炮之间的相似之处:它们都是游戏对象。基本上都可以画在某个位置;它们都有一个速度(即使是大炮,但它的速度为零);它们都有红色、绿色或蓝色。此外,它们中的大多数处理某种类型的输入并被更新。

遗产

使用 JavaScript 中的原型,可以将这些相似之处组合在一个泛型类中,然后定义其他类,这些类是这个泛型类的特殊版本。在面向对象的行话中,这被称为继承 ,这是一个非常强大的语言特性。在 JavaScript 中,继承是通过原型机制 实现的。考虑以下示例:

function Vehicle() {
    this.numberOfWheels = 4;
    this.brand = "";
}
Vehicle.prototype.what = function() {
    return "nrOfWheels = " + this.numberOfWheels + ", brand = " + this.brand;
};

这里有一个非常简单的表示车辆的类的例子(你可以想象这对交通模拟游戏很有用)。简单来说,一辆车由多个车轮和一个品牌来定义。Vehicle类 也有一个名为what的方法,返回车辆的描述。如果您想创建一个在表格中显示车辆列表的网站,这可能会很有用。您可以按如下方式使用该类:

var v = new Vehicle();
v.brand = "volkswagen";
console.log(v.what()); // outputs "nrOfWheels = 4, brand = volkswagen"

有不同类型的交通工具,如汽车、自行车、摩托车等等。对于其中一些类型,您可能希望存储附加信息。例如,对于一辆汽车,存储它是否是敞篷车可能是有用的;对于摩托车,它有多少个气缸;等等。您可以使用 JavaScript 中基于原型的继承机制来实现这一点。下面是一个名为Car的类的例子:

function Car(brand) {
    Vehicle.call(this);
    this.brand = brand;
    this.convertible = false;
}
Car.prototype = Object.create(Vehicle.prototype);

在这个类声明中有一些新的东西。在底部,你给Carprototype对象赋值。你可以通过使用Object.create方法来做到这一点。在这种情况下,您复制了Vehicleprototype对象,并将该副本存储在Carprototype对象中。换句话说,Car现在拥有与Vehicle相同的功能,包括what方法:

var c = new Car("mercedes");
console.log(c.what()); // outputs "nrOfWheels = 4, brand = mercedes"

Car的构造函数中有下面一行:

Vehicle.call(this);

这里发生的是使用调用Car构造函数时创建的同一个对象调用Vehicle构造函数。本质上,你是在告诉解释器,你当前操作的Car对象(this)实际上也是一个* Vehicle 对象。所以你可以看到继承的两个重要方面:*

  • 对象之间有关系(一个Car对象也是一个Vehicle)。
  • 从另一个类继承的类复制其功能(Car对象与Vehicle对象具有相同的成员变量、属性和方法)。

因为Car继承自Vehicle,所以你也说CarVehicle子类或者派生类,或者说VehicleCar超类,或者父类,或者基类。类之间的继承关系应用广泛;而在一个好的类设计中,可以解释为“是一种。”在这个例子中,关系很清楚:汽车是一种交通工具。反过来也不总是对的。交通工具并不总是汽车。Vehicle可能还有其他子类,例如:

function Motorbike(brand) {
    Vehicle.call(this);
    this.numberOfWheels = 2;
    this.brand = brand;
    this.cylinders = 4;
}
Motorbike.prototype = Object.create(Vehicle.prototype);

摩托车也是一种交通工具。Motorbike类从Vehicle继承而来,并添加了自己的自定义成员变量来指示气缸数。图 11-1 说明了类的层次结构。对于这个层次结构的更扩展版本,参见图 11-4 。

9781430265382_Fig11-01.jpg

图 11-1 。Vehicle及其子类的继承图

游戏对象和继承

“是一种”关系也适用于画家游戏中的游戏对象。球是一种游戏对象,颜料罐和大炮也是。你可以通过定义一个名为ThreeColorGameObject的类名,让你的游戏对象类从这个类名中继承,从而在程序中明确这种继承关系。然后你可以把所有定义三色游戏对象的东西放在那个类中,球、大炮和油漆罐将是那个类的特殊版本。

让我们更详细地看看这个ThreeColorGameObject 级。您将游戏中不同类型的游戏对象通常使用的成员变量放入这个类中。您可以如下定义该类的基本框架:

function ThreeColorGameObject() {
    this.currentColor = undefined;
    this.velocity = Vector2.zero;
    this.position = Vector2.zero;
    this.origin = Vector2.zero;
    this.rotation = 0;
    this.visible = true;
}

ThreeColorGameObject继承的每个类都有一个速度,一个位置,一个原点,一个旋转,等等。这很好,因为现在你只在一个地方定义这些成员变量,它们可以在任何继承自ThreeColorGameObject的类中使用。

这个构造函数中仍然缺少的一点是处理三种不同颜色的方法。在 Painter 的例子中,每个游戏对象类型都有三个不同的精灵,每个精灵代表一种不同的颜色。当你定义ThreeColorGameObject类时,你还不知道使用哪个精灵,因为它们将取决于游戏对象的最终类型(大炮使用精灵而不是球或油漆桶)。为了解决这个问题,让我们如下扩展构造函数:

function ThreeColorGameObject(sprColorRed, sprColorGreen, sprColorBlue) {
    this.colorRed = sprColorRed;
    this.colorGreen = sprColorGreen;
    this.colorBlue = sprColorBlue;
    this.currentColor = this.colorRed;
    this.velocity = Vector2.zero;
    this.position = Vector2.zero;
    this.origin = Vector2.zero;
    this.rotation = 0;
    this.visible = true;
}

无论何时继承这个类,都可以定义成员变量colorRedcolorGreencolorBlue的值。

现在您需要定义基本的游戏循环方法。绘制游戏对象的方法很简单。您可能已经注意到这个类中添加了一个成员变量visible。您可以使用这个成员变量来切换游戏对象的可见性。在draw方法中,只有当游戏对象应该可见时,才在屏幕上绘制精灵:

ThreeColorGameObject.prototype.draw = function () {
    if (!this.visible)
        return;
    Canvas2D.drawImage(this.currentColor, this.position, this.rotation, 1,
        this.origin);
};

该类的update方法包含一条更新游戏对象当前位置的指令:

ThreeColorGameObject.prototype.update = function (delta) {
    this.position.addTo(this.velocity.multiply(delta));
};

最后,添加一些方便的属性来获取和设置颜色,并检索对象的尺寸。例如,这是用于读取和写入对象颜色的属性:

Object.defineProperty(ThreeColorGameObject.prototype, "color",
    {
        get: function () {
            if (this.currentColor === this.colorRed)
                return Color.red;
            else if (this.currentColor === this.colorGreen)
                return Color.green;
            else
                return Color.blue;
        },
        set: function (value) {
            if (value === Color.red)
                this.currentColor = this.colorRed;
            else if (value === Color.green)
                this.currentColor = this.colorGreen;
            else if (value === Color.blue)
                this.currentColor = this.colorBlue;
        }
    });

如你所见,这里使用了彩色的 sprite 成员变量。任何从ThreeColorGameObject继承的类现在也有这个属性。这为您节省了大量的代码复制!关于完整的ThreeColorGameObject类,参见属于本章的 Painter9 示例。

Cannon 作为 ThreeColorGameObject 的子类

现在你已经为彩色游戏对象创建了一个非常基本的类,你可以通过从这个类继承来为你游戏中的实际游戏对象重用这个基本行为。我们先来看一下Cannon类。因为您已经定义了基本的ThreeColorGameObject类,所以您可以创建Cannon类作为该类的子类,如下所示:

function Cannon() {
    // to do...
}
Cannon.prototype = Object.create(ThreeColorGameObject.prototype);

通过复制ThreeColorGameObject.prototype对象来创建Cannon.prototype对象。但是,您仍然需要在构造函数方法中编写代码。

因为Cannon继承自ThreeColorGameObject,所以需要调用ThreeColorGameObject类的构造函数。此构造函数需要三个参数。因为您正在创建一个Cannon对象,所以您想要将彩色的加农炮精灵传递给该构造函数。幸运的是,你可以通过call方法传递这些精灵,如下所示:

ThreeColorGameObject.call(this, sprites.cannon_red, sprites.cannon_green,
    sprites.cannon_blue);

第二,你设置大炮的位置和原点,就像你在最初的Cannon类中所做的那样:

this.position = new Vector2(72, 405);
this.origin = new Vector2(34, 34);

剩下的工作(分配三个颜色精灵和初始化其他成员变量)已经在ThreeColorGameObject构造函数中完成了!注意,在子类中设置成员变量之前,首先调用超类的构造函数是很重要的。否则,当调用ThreeColorGameObject构造函数时,您为加农炮选择的位置和原点值将被重置为零。

现在已经定义了新版本的Cannon类,您可以开始向该类添加属性和方法,就像您之前所做的一样。例如,下面是handleInput方法:

Cannon.prototype.handleInput = function (delta) {
    if (Keyboard.down(Keys.R))
        this.currentColor = this.colorRed;
    else if (Keyboard.down(Keys.G))
        this.currentColor = this.colorGreen;
    else if (Keyboard.down(Keys.B))
        this.currentColor = this.colorBlue;
    var opposite = Mouse.position.y - this.position.y;
    var adjacent = Mouse.position.x - this.position.x;
    this.rotation = Math.atan2(opposite, adjacent);
};

如您所见,您可以毫无问题地访问成员变量,如currentColorrotation。因为Cannon继承自ThreeColorGameObject,所以它包含相同的成员变量、属性和方法。

重写超类的方法

除了添加新的方法和属性,你还可以选择用替换Cannon类中的方法。例如,ThreeColorGameObject有如下的draw方法:

ThreeColorGameObject.prototype.draw = function () {
    if (!this.visible)
        return;
    Canvas2D.drawImage(this.currentColor, this.position,
        this.rotation, 1, this.origin);
};

对于加农炮来说,这种方法并不完全如你所愿。你想画大炮的颜色,但你也想画炮管。替换一个方法非常容易。您只需将该方法重新定义为Cannon原型的一部分:

Cannon.prototype.draw = function () {
    if (!this.visible)
        return;
    var colorPosition = this.position.subtract(this.size.divideBy(2));
    Canvas2D.drawImage(sprites.cannon_barrel, this.position, this.rotation, 1,
        this.origin);
    Canvas2D.drawImage(this.currentColor, colorPosition);
};

用面向对象的行话来说,当你替换子类中从超类继承的方法时,你说你覆盖了该方法。在这种情况下,您覆盖了来自ThreeColorGameObjectdraw方法。类似地,如果您愿意,您可以覆盖一个属性,或者甚至通过让它们引用undefined来删除属性和方法。一旦创建了一个Cannon对象,您就拥有了 JavaScript 提供的修改该对象的全部灵活性。

注意即使你在这个例子中覆盖了一个方法,JavaScript 也不像 Java 或 C#等其他语言那样使用override关键字。

如果你看一看属于本章的 Painter9 示例中的Cannon.js文件,你可以看到Cannon类的定义比以前的版本小得多,也更容易阅读,因为所有通用的游戏对象成员都放在了ThreeColorGameObject类中。将代码组织在不同的类和子类中有助于减少代码复制,并使设计更加简洁。但是,有一个警告:你的类结构(哪个类从哪个类继承)必须正确。请记住,只有当类之间存在“是一种”关系时,类才应该从其他类继承。为了说明这一点,假设您想在屏幕顶部添加一个指示器,显示球当前的颜色。您可以为此创建一个类,并让它从Cannon类继承,因为它需要以类似的方式处理输入:

function ColorIndicator() {
    Cannon.call(this, ...);
    // etc.
}

然而,这是一个非常糟糕的想法。颜色指示器当然不是一种大炮,这样设计您的类会让其他开发人员非常不清楚这些类的用途。此外,颜色指示器还会旋转,这没有任何意义。类继承图应该有逻辑性并且容易理解。每当你写一个继承自另一个类的类时,问问你自己这个类是否真的是你继承的类的一种。如果不是,那么你必须重新考虑你的设计。

球课

您以与Cannon类非常相似的方式定义新的Ball类。就像在Cannon类中一样,你继承了ThreeColorGameObject类。唯一不同的是,你必须添加一个额外的成员变量来指示球当前是否正在射门:

function Ball() {
    ThreeColorGameObject.call(this, sprites.ball_red, sprites.ball_green,
        sprites.ball_blue);
    this.shooting = false;
    this.reset();
}
Ball.prototype = Object.create(ThreeColorGameObject.prototype);

当一个Ball实例被创建时,你需要调用ThreeColorGameObject构造函数,就像你对Cannon类所做的那样。在这种情况下,您将球精灵作为参数传递。另外,你需要给shooting变量一个初始值false,你通过调用reset方法来重置球。

Ball类清楚地说明了当你从另一个类继承时会发生什么。每个Ball实例由从ThreeColorGameObject继承的部分和在Ball类中定义的部分组成。图 11-2 显示了没有使用继承的Ball对象的内存的样子。图 11-3 也显示了一个Ball实例,但是使用了本章介绍的继承机制。

9781430265382_Fig11-02.jpg

图 11-2 。Ball类(无继承)的实例使用的内存概述

9781430265382_Fig11-03.jpg

图 11-3 。Ball类的一个实例(从ThreeColorGameObject继承而来)

你可能会对这两个图形和它们呈现的结构感到有点困惑。稍后,本章将更详细地讨论内存结构。现在,假设由多个成员变量组成的复杂对象(如CannonBall实例)的存储方式不同于简单的数字或布尔。这意味着什么,以及你应该如何在你的代码中正确地处理它,在这一章的结尾有所涉及。

ThreeColorGameObject类中的update方法只包含一行代码,它根据游戏对象的速度、经过的时间和当前位置来计算游戏对象的新位置:

this.position.addTo(this.velocity.multiply(delta));

球应该做得更多。球的速度应该更新,以纳入阻力和重力;球的颜色需要的话要更新;而如果球飞出了屏幕,就要复位到原来的位置。您可以简单地从先前版本的Ball类中复制update方法,这样它就可以替换ThreeColorGameObjectupdate方法。一个稍微好一点的方法是在Ball类中定义update方法,但是重用ThreeColorGameObject中最初的update方法。这可以通过使用call方法来完成,方式非常类似于您使用它来调用超类的构造函数。下面是Ball.update方法的新版本:

Ball.prototype.update = function (delta) {
    ThreeColorGameObject.prototype.update.call(this, delta);
    if (this.shooting) {
        this.velocity.x *= 0.99;
        this.velocity.y += 6;
    }
    else {
        this.color = Game.gameWorld.cannon.color;
        this.position = Game.gameWorld.cannon.ballPosition
            .subtractFrom(this.center);
    }
    if (Game.gameWorld.isOutsideWorld(this.position))
        this.reset();
};

看这个方法的第一条指令。您正在访问ThreeColorGameObjectprototype对象,它包含一个update函数。你在传递this对象的同时调用这个update函数,所以Ball对象被更新,但是根据ThreeColorGameObject中定义的update方法。最后,您将delta参数传递给该调用。好的一面是,这种方法允许您将更新过程的不同部分(在本例中)分开。任何具有位置和速度的游戏对象都需要在游戏循环的每次迭代中根据其速度更新其位置。您在ThreeColorGameObjectupdate方法中定义了这个行为,这样您就可以为从ThreeColorGameObject继承的任何类重用它!

多态性

因为有了继承机制,你不必总是知道一个变量指向什么类型的对象。考虑下面的声明和初始化:

var someKindOfGameObject = new Cannon();

在代码的其他地方,你这样做:

someKindOfGameObject.update(delta);

现在假设您更改了声明和初始化,如下所示:

var someKindOfGameObject = new Ball();

需要把调用改成update方法吗?不,你不需要,因为游戏循环方法被调用的方式是在ThreeColorGameObject类中定义的。当你在someKindOfGameObject变量上调用update方法时,它实际引用的是哪个游戏对象并不重要。唯一重要的是定义了update方法,并且它只需要一个参数:自最后一次update调用以来经过的时间。因为解释器会跟踪它是哪个对象,所以会自动调用正确版本的update方法。

这种效应被称为多态性,有时会非常方便。多态性允许您更好地分离代码。假设一家游戏公司想要发布其游戏的扩展。例如,它可能想引入一些新的敌人,或者玩家可以学习的技能。公司可以将这些扩展作为泛型EnemySkill类的子类来提供。实际的游戏代码将会使用这些对象,而不需要知道它在处理哪种特殊技能或敌人。它只是调用泛型类中定义的方法。

类的层次结构

在这一章中,你已经看到了几个从基本游戏对象类继承的类的例子。只有当这两个类之间的关系可以描述为“是一种”时,一个类才应该从另一个类继承比如:a BallThreeColorGameObject的一种。事实上,等级制度并没有到此为止。你可以写另一个继承自Ball类的类,比如BouncingBall,它可以是一个标准球的特殊版本,可以从油漆罐上反弹,而不仅仅是与它们碰撞。你还可以创建另一个继承自BouncingBall的类BouncingElasticBall,它是一个球,当它在油漆桶上反弹时会根据它的弹性变形。每次从一个类继承时,都可以免费从基类中获得数据(编码在成员变量中)和行为(编码在方法和属性中)。

商业游戏有一个不同游戏对象的等级体系,有许多不同的级别。回到本章开始的交通模拟例子,你可以想象一个非常复杂的各种不同车辆的层次结构。图 11-4 显示了这样一个层次结构的例子。该图使用箭头来指示类之间的继承关系。

9781430265382_Fig11-04.jpg

图 11-4 。交通模拟游戏中复杂的游戏对象层次

在继承树的最底层是一个GameObject类。这个类只包含非常基本的信息,比如游戏对象的位置或速度。对于每个子类,可以添加与特定类及其子类相关的新成员(变量、方法或属性)。例如,变量numberOfWheels通常属于Vehicle类,而不属于MovingGameObject(因为船没有轮子)。变量flightAltitude属于Airplane类,变量bellIsWorking属于Bicycle类。

当你决定你的类的结构时,你必须做出许多决定。没有单一的最佳等级;而且,根据应用的不同,一种层次结构可能比另一种更有用。例如,这个例子首先根据物体用来移动自身的媒介来划分MovingGameObject类:土地、空气或水。之后,这些类又分为不同的子类:机动化或非机动化。你可以反过来做这件事。对于某些类,它们在层次结构中的位置并不完全清楚:你说摩托车是一种特殊类型的自行车(有马达的那种)吗?还是一种特殊的机动车辆(只有两个轮子的那种)?

重要的是,类本身之间的关系是清晰的。帆船是船,但船并不总是帆船。自行车是一种交通工具,但不是每一种交通工具都是自行车。

值与参考值

在你读完这一章之前,让我们看看对象和变量是如何在内存中被处理的。当处理基本类型如数字或布尔时,变量与内存中的位置直接相关。比如看下面的声明和初始化:

var i = 12;

该指令执行后,存储器看起来如图图 11-5 所示。

9781430265382_Fig11-05.jpg

图 11-5 。数字变量的内存使用

现在您可以创建一个新变量j并将变量i的值存储在该变量中:

var j = i;

图 11-6 显示了执行该指令后内存的样子。

9781430265382_Fig11-06.jpg

图 11-6 。声明和初始化两个数字变量后的内存使用情况

如果你给j变量赋另一个值,例如通过执行指令j = 24,产生的内存使用如图图 11-7 所示。

9781430265382_Fig11-07.jpg

图 11-7 。更改j变量值后的内存使用

现在让我们看看当您使用更复杂类型的变量时会发生什么,比如Cannon类。考虑以下代码:

var cannon1 = new Cannon();
var cannon2 = cannon1;

看一下前面使用数字类型的例子,您会期望现在内存中有两个Cannon对象:一个存储在变量cannon1中,另一个存储在cannon2中。然而,事实并非如此!其实cannon1cannon2?? 都是指同一个物体。第一条指令后(创建Cannon对象),内存如图图 11-8 所示。

9781430265382_Fig11-08.jpg

图 11-8 。内存中的一个Cannon对象

在这里,您可以看到基本类型(如数字和布尔值)与更复杂的类型(如Cannon类)在内存中的表示方式有很大的不同。在 JavaScript 中,所有非原始类型的对象,比如数字、布尔值和字符,都存储为引用而不是值。这意味着像cannon1这样的变量并不直接包含Cannon对象,但是它包含了对它的引用。图 11-8 通过将cannon1表示为一个包含指向一个对象的箭头的块来表示它是一个引用。如果你现在声明了cannon2变量并将cannon1的值赋给它,你可以在图 11-9 中看到新的情况。

9781430265382_Fig11-09.jpg

图 11-9 。指向同一个对象的两个变量

结果是,如果你改变加农炮的颜色如下

cannon2.color = Color.red;

那么表达式cannon1.color将是Color.red,因为cannon1cannon2指的是同一个对象!这对对象在方法中的传递方式也有影响。例如,ThreeColorGameObject的构造函数方法期望三个精灵作为参数。因为精灵不是 JavaScript 中的基本类型,所以您实际上是在传递对这些精灵的引用。理论上,这意味着您可以在ThreeColorGameObject构造函数中修改精灵。将基本类型(比如数字)作为参数传递给方法是通过值发生的,所以改变方法中的值没有影响。考虑下面的函数

function square(f) {
    f = f * f;
}

现在是以下指令:

var someNumber = 10;
square(someNumber);

执行完这些指令后,someNumber的值仍然是 10(而不是 100)。这是为什么?因为当调用square函数时,number 参数通过值传递给。变量f是方法中的一个局部变量,最初包含变量someNumber的值。在该方法中,局部变量f被更改为包含f * f,但这不会更改someNumber变量,因为它是内存中的另一个位置。因为非原始对象是通过引用传递的,所以下面的示例将导致对象的更改值作为参数传递:

function square(obj) {
    obj.f = obj.f * obj.f;
}

var myObject = { f : 10 };
square(myObject);
// myObject.f now contains the value 100.

每当 JavaScript 脚本运行时,内存中都有大量的引用和值。例如,如果您查看图 11-2 和 11-3 ,您会看到Ball对象既包含值,也包含对其他对象的引用(例如Vector2对象或Image对象)。

空的和未定义的

每当您在 JavaScript 中声明一个变量时,最初它的值被设置为undefined :

var someVariable;
console.log(someVariable); // will print 'undefined'.

在 JavaScript 中,你也可以指出一个变量被定义了,但是当前没有引用任何对象。这是通过使用null关键字完成的:

var anotherCannon = null;

因为你还没有创建一个对象(使用new关键字),内存看起来像图 11-10 中描述的那样。

9781430265382_Fig11-10.jpg

图 11-10 。一个指向null的变量

因此,指示一个变量还没有指向任何东西是通过给它赋值null来完成的。甚至可以在 JavaScript 程序中检查变量是否指向一个对象,就像这样:

if (anotherCannon === null)
    anotherCannon = new Cannon();

在这个例子中,你检查变量是否等于null(没有指向一个对象)。如果是这样,你使用new关键字创建一个Cannon实例,之后内存中的情况再次改变(见图 11-11 )。

9781430265382_Fig11-11.jpg

图 11-11 。记忆中的最后情境

由您决定何时使用nullundefined。不是所有的程序员都用同样的方式做这件事。我们建议你用undefined来表示一个变量不存在,用null来表示这个变量存在但还没有引用任何对象。

你学到了什么

在本章中,您学习了:

  • 如何使用继承来构建层次结构中的相关类
  • 如何重写子类中的方法来为该类提供特定的行为
  • 如何从超类中调用方法,比如构造函数方法
  • nullundefined的含义

十二、完成画家游戏

在本章中,您将通过添加一些额外的功能(如动作效果、声音和音乐)以及维护和显示分数来完成画师游戏。最后,您将更详细地了解字符和字符串。

添加运动效果

为了使游戏更具视觉吸引力,您可以在颜料罐的移动中引入漂亮的旋转效果,以模拟风和摩擦对下落运动的影响。属于本章的 Painter10 程序是游戏的最终版本,在易拉罐中添加了这种运动效果。添加这样的效果并不复杂。由于您在上一章所做的工作,只需要在PaintCan类的update方法中添加一行代码。因为PaintCanThreeColorGameObject的子类,它已经有了一个rotation成员变量,在屏幕上绘制 sprite 时会自动考虑到这个变量!

为了达到运动效果,你使用了Math.sin的方法。通过让该值依赖于罐的当前位置,可以根据该位置得到不同的值。然后使用这个值在精灵上应用一个旋转。这是您添加到PaintCan.update方法中的代码行:

this.rotation = Math.sin(this.position.y / 50) * 0.05;

该指令使用颜料罐位置的 y 坐标来获得不同的旋转值。此外,你把它除以 50,得到一个很好的慢速运动;将结果乘以 0.05,以降低正弦的幅度,使旋转看起来更真实。如果您愿意,可以尝试不同的值,看看它们如何影响颜料罐的行为。

创建精灵

即使你不是艺术家,自己制作简单的精灵也会有所帮助。它能让你快速制作出游戏的原型——也许会发现你内心也有一个艺术家。要创建精灵,你首先需要好的工具。大多数艺术家使用像 Adobe Photoshop 这样的绘画程序或像 Adobe Illustrator 这样的矢量绘图程序,但其他人使用像 Microsoft Paint 或更广泛和免费的 GIMP 这样的简单工具。每个工具都需要练习。浏览一些教程,并确保对许多不同的特性有所了解。通常,你想要的东西可以用一种简单的方式实现。

最好是,为你的游戏对象创建非常大的图像,然后将它们缩小到所需的尺寸。这样做的好处是,你可以在以后的游戏中更改所需的尺寸,并且可以消除由于图像由像素表示而产生的锯齿效应。缩放图像时,抗锯齿技术会混合颜色,使图像保持平滑。如果您保持图像中游戏对象的外部透明,那么,当您缩放时,边界像素将自动变为部分透明。只有当你想创建经典的像素样式时,你才应该按照实际需要的大小来创建精灵。

最后,在网上四处看看。有很多精灵可以免费使用。确保检查许可条款,这样你使用的精灵包对于你正在构建的东西是合法的。然后你可以把它们作为你自己精灵的基础。但是最后,要意识到当你和一个有经验的艺术家一起工作时,你的游戏质量会显著提高。

添加声音和音乐

另一种让游戏更有趣的方法是添加一些声音。这个游戏同时使用了背景音乐和音效。为了使 JavaScript 中的声音处理变得更简单,您添加了一个Sound类,允许您回放和循环声音。下面是该类的构造函数:

function Sound(sound, looping) {
    this.looping = typeof looping !== 'undefined' ? looping : false;
    this.snd = new Audio();
    if (this.snd.canPlayType("audio/ogg")) {
        this.snd.src = sound + ".ogg";
    } else if (this.snd.canPlayType("audio/mpeg")) {
        this.snd.src = sound + ".mp3";
    } else // we cannot play audio in this browser
        this.snd = null;
}

因为不是所有的浏览器都能够播放所有不同类型的音乐,所以您添加了一个if指令,根据浏览器可以播放的类型来加载不同的声音类型。类似于创建Image对象(用于表示精灵),您创建一个Audio对象,并将其源初始化为需要加载的声音文件。除了声音文件之外,您还添加了一个looping变量来指示声音是否应该循环。一般来说,背景音乐要循环播放;声音效果(如发射彩球)不应该。

除了构造函数之外,还要添加一个名为play的方法。在这个方法中,加载声音,并将名为autoplay的属性设置为 true。这样做的结果是,声音将在加载后立即开始播放。如果声音不需要循环,就完成了,可以从方法返回。如果您确实需要循环播放声音,您需要在声音播放完毕后重新加载并再次播放声音。Audio类型允许你给所谓的事件附加功能。当事件发生时,执行您附加的函数。例如音频已经开始播放的事件,或者音频已经结束播放的事件。

这本书很少使用事件和事件处理。但是,许多 JavaScript 概念依赖于它们。例如,键盘按键和鼠标动作都会产生你应该在游戏中处理的事件。在这种情况下,您希望在音频播放完毕后执行一项功能。下面是完整的play方法:

Sound.prototype.play = function () {
    if (this.snd === null)
        return;
    this.snd.load();
    this.snd.autoplay = true;
    if (!this.looping)
        return;
    this.snd.addEventListener('ended', function () {
        this.load();
        this.autoplay = true;
    }, false);
};

最后,添加一个属性来更改正在播放的声音的音量。这特别有用,因为通常你希望音效比背景音乐更响亮。在一些游戏中,这些音量可以被玩家改变(在本书的后面,你会看到如何去做)。每当你在游戏中引入声音时,确保总是提供音量或者至少静音控制。没有静音功能的游戏将会遭到用户通过评论的愤怒!下面是volume属性,很简单:

Object.defineProperty(Sound.prototype, "volume",
    {
        get: function () {
            return this.snd.volume;
        },
        set: function (value) {
            this.snd.volume = value;
        }
    });

Painter.js(加载所有资源的文件)中,你加载声音并将它们存储在一个变量中,就像你对精灵所做的那样:

var sounds = {};

下面是如何使用刚刚创建的Sound类加载相关的声音:

var loadSound = function (sound, looping) {
    return new Sound("../../assets/Painter/sounds/" + sound, looping);
};

sounds.music = loadSound("snd_music");
sounds.collect_points = loadSound("snd_collect_points");
sounds.shoot_paint = loadSound("snd_shoot_paint");

现在在游戏过程中播放声音非常容易。例如,当游戏初始化时,您开始以低音量播放背景音乐,如下所示:


sounds.music.volume = 0.3;
sounds.music.play();

你也想玩音效。比如球员投篮,他们就想听到!所以,当他们开始投篮时,你播放这个音效。这在Ball类的handleInput方法中处理:

Ball.prototype.handleInput = function (delta) {
    if (Mouse.leftPressed && !this.shooting) {
        this.shooting = true;
        this.velocity = Mouse.position.subtract(this.position)
            .multiplyWith(1.2);
        sounds.shoot_paint.play();
    }
};

同样,当正确颜色的颜料罐从屏幕上掉落时,您也可以播放声音。

保持分数

分数往往是激励玩家继续玩下去的非常有效的方法。高分在这方面特别有效,因为它们给游戏引入了竞争因素:你想比 AAA 或 XYZ 更好(许多早期街机游戏只允许高分列表中的每个名字有三个字符,导致名字非常有想象力)。高分是如此激励人心,以至于第三方系统的存在将它们纳入游戏。这些系统让用户与世界上成千上万的其他玩家进行比较。在画师游戏中,保持简单,在存储当前分数的PainterGameWorld类中添加一个成员变量score:

function PainterGameWorld() {
    this.cannon = new Cannon();
    this.ball = new Ball();
    this.can1 = new PaintCan(450, Color.red);
    this.can2 = new PaintCan(575, Color.green);
    this.can3 = new PaintCan(700, Color.blue);
    this.score = 0;
    this.lives = 5;
}

玩家从零分开始。每次油漆罐落在屏幕外,分数就会更新。如果有一罐颜色正确的罐子从屏幕上掉了下来,就加 10 分。如果罐子不是正确的颜色,玩家失去一条生命。

分数是一场比赛所谓的经济的一部分。游戏的经济基本上描述了游戏中不同的成本和优点,以及它们如何相互作用。当你制作自己的游戏时,考虑它的经济性总是有用的。东西有什么成本,作为玩家执行不同的动作有什么收获?这两件事是相互平衡的吗?

您在PaintCan类中更新分数,在这里您可以检查罐子是否落在屏幕之外。如果是这样,你检查它是否有正确的颜色,并相应地更新分数和玩家生存的数量。然后您将PaintCan对象移动到顶部,以便它可以再次落下:

if (Game.gameWorld.isOutsideWorld(this.position)) {
    if (this.color === this.targetColor) {
        Game.gameWorld.score += 10;
        sounds.collect_points.play();
    }
    else
        Game.gameWorld.lives -= 1;
    this.moveToTop();
}

最后,每当一个颜色正确的罐子从屏幕上掉下来,你就播放一个声音。

更完整的 Canvas2D 类

除了在屏幕上画精灵,你还想在屏幕上画当前的分数(否则维护它就没多大意义了)。到目前为止,您只在画布上绘制了图像。HTML5 canvas 元素还允许在其上绘制文本。为了绘制文本,您扩展了Canvas2D_Singleton类。

当您修改 canvas drawing 类时,您还想做些别的事情。既然您已经将所有变量组织到对象中,这些对象可以使用类来创建,可以从其他类继承,现在是考虑应该在哪里更改哪些信息的好时机。例如,您可能只想更改Canvas2D_Singleton类中的canvascanvasContext变量。例如,您不需要在Cannon类中访问这些变量。在Cannon类中,您只想使用通过 canvas drawing 类中的方法提供的高级行为。

不幸的是,JavaScript 没有办法直接控制对变量的访问。一个邪恶的程序员可以在他们程序的某个地方写下下面一行代码:

Canvas2D.canvas = null;

执行完这行代码,屏幕上什么也画不出来!当然,没有一个正常的程序员会故意写这样的东西,但是让你的类的用户尽可能清楚他们应该改变什么数据,什么数据是类内部的,不应该被修改,这是一个好主意。一种方法是在任何内部变量的名字上加一些东西。这本书给所有的内部变量加上了下划线,这些变量不应该在它们所属的类之外被改变。例如,下面是遵循此规则的Canvas2D_Singleton类的修改后的构造函数:

function Canvas2D_Singleton() {
    this._canvas = null;
    this._canvasContext = null;
}

您还向该类添加了一个新方法drawText,该方法可用于在屏幕上的特定位置绘制文本。drawText方法与drawImage方法非常相似。在这两种情况下,您都使用 canvas 上下文在绘制文本之前执行转换。这允许您在画布上的任意位置绘制文本。此外,您可以更改文本的颜色和文本对齐方式(左对齐、居中或右对齐)。查看属于本章的 Painter10 示例,以了解该方法的主体。

现在使用这种方法在屏幕上绘制文本很容易。例如,这会在屏幕的左上角绘制一些绿色文本:

Canvas2D.drawText("Hello, how are you doing?", Vector2.zero, Color.green);

字符和字符串

在包括 JavaScript 在内的大多数编程语言中,一个字符序列被称为字符串。就像数字或布尔值一样,字符串是 JavaScript 中的基本类型。字符串也是不可变的。这意味着字符串一旦创建,就不能更改。当然,仍然有可能用另一根弦替换这根弦。例如:

var name = "Patrick";
name = "Arjan";

在 JavaScript 中,字符串由单引号或双引号字符分隔。如果字符串以双引号开始,它应该以双引号结束。所以,这是不允许的:

var country = 'The Netherlands";

当你将一个字符串赋给一个变量时,这个字符串被称为常量。除了字符串值,常量值还可以是数字、布尔值、undefinednull,如图 12-1 中的语法图所示。

9781430265382_Fig12-01.jpg

图 12-1 。常量值的语法图

使用单引号和双引号

当使用字符串值并将它们与其他变量组合时,您必须小心使用哪种类型的引号(如果有的话)。如果您忘记了引号,您就不再是在编写文本或字符,而是 JavaScript 程序的一部分!有很大的区别

  • 字符串"hello"和变量名hello
  • 字符串'123'和值123
  • 字符串值'+'和运算符+

特殊字符

特殊字符,仅仅因为它们是特殊的,并不总是容易用引号之间的单个字符来表示。因此,一些特殊的符号有特殊的符号使用反斜杠符号,如下:

  • '\n'为行尾符号
  • '\t'为制表符号

这就引入了一个新问题:如何表示反斜杠字符本身。反斜杠字符用双反斜杠表示。以类似的方式,反斜杠符号用于表示单引号和双引号本身的字符:

  • '\\'为反斜杠符号
  • '\''"'"为单引号字符
  • "\""'"'为双引号字符

如您所见,您可以在由双引号分隔的字符串中使用不带反斜杠的单引号,反之亦然。图 12-2 中给出了表示所有这些符号的语法图。

9781430265382_Fig12-02.jpg

图 12-2 。符号语法图

字符串操作

在 Painter 游戏中,您将字符串值与drawText方法结合使用,在屏幕上的某个地方以所需的字体绘制某种颜色的文本。在这种情况下,你需要在屏幕的左上角写下当前的分数。分数保存在一个名为score的成员变量中。该变量在PaintCanupdate方法中增加或减少。鉴于文本的一部分(乐谱)一直在变化,你如何构建应该打印在屏幕上的文本?这个解决方案叫做字符串串联,意思是一段接一段的粘贴文本。在 JavaScript(以及许多其他编程语言)中,这是使用加号来完成的。例如,表达式"Hi, my name is " + "Arjan"产生字符串"Hi, my name is Arjan"。在本例中,您连接了两段文本。也可以将一段文本和一个数字连接起来。例如,表达式"Score: " + 200产生字符串"Score: 200"。你可以用一个变量来代替常量。因此,如果变量score包含值 175,那么表达式"Score: " + score的计算结果为"Score: 175"。通过编写这个表达式作为drawText方法的参数,您总是在屏幕上绘制当前的分数。对drawText方法的最后一次调用变成了

Canvas2D.drawText("Score: " + this.score, new Vector2(20, 22), Color.white);

注意:连接只有在处理文本时才有意义。例如,不可能“连接”两个数字:表达式1 + 2的结果是3,而不是12。当然,您可以将表示为文本的数字:"1" + "2"连接成"12"。通过使用单引号或双引号来区分文本和数字。

其实在表情"Score: " + 200里偷偷做的就是一个型转换或者。在连接到另一个字符串之前,数值200被自动转换为字符串"200"

如果你想把一个字符串值转换成一个数值,事情会变得有点复杂。对于解释器来说,这不是一个容易执行的操作,因为不是所有的字符串都可以转换成数值。为此,JavaScript 有一些有用的内置函数。例如,这是将字符串转换为整数的方法:

var x = parseInt("10");

如果作为参数传递的字符串不是整数,则parseInt函数的结果是该数字的整数部分:

var y = parseInt("3.14"); // y will contain the value 3

为了解析带小数的数字,JavaScript 有parseFloat函数:

y = parseFloat("3.14"); // y will contain the value 3.14

如果字符串不包含有效的数字,那么尝试使用这两个函数之一解析它的结果是常数NaN(不是数字;另见图 12-1。

最后几句话

祝贺您,您已经完成了您的第一个游戏!图 12-3 包含了最终比赛的截图。在开发这个游戏的过程中,你学到了很多重要的概念。在下一个游戏中,你将继续你已经完成的工作。同时,别忘了玩游戏!你会注意到几分钟后变得非常困难,因为油漆罐下降的速度越来越快。

9781430265382_Fig12-03.jpg

图 12-3 。画师最终版本截图

谁玩游戏?

你可能认为游戏主要是年轻男性玩的,但这完全不是事实。很大一部分人玩游戏。2013 年,美国有 1.83 亿活跃游戏玩家,超过总人口的一半(包括婴儿)。他们在许多不同的设备上玩游戏。36%的人在智能手机上玩游戏,25%的人在无线设备上玩游戏(资料来源:娱乐软件协会(ESA),2013 年)。

如果你开发一款游戏,你最好先想想你想要它的受众。小孩子的游戏不同于中年妇女的游戏。游戏应该有不同种类的游戏,不同的视觉风格和不同的目标。

虽然主机游戏往往发生在大型 3D 世界,但网站和移动设备上的休闲游戏通常是 2D,并且大小有限。此外,主机游戏被设计成可以(并且需要)玩几个小时,而休闲游戏通常被设计成只玩几分钟。也有许多类型的严肃游戏,这是用来训练专业人员的游戏,如消防员、市长和医生。

意识到你喜欢的游戏不一定是你的目标受众喜欢的游戏。

你学到了什么

在本章中,您学习了:

  • 如何在游戏中加入音乐和音效
  • 如何维护和显示分数
  • 如何使用字符串来表示和处理文本

十三、适应不同的设备

这一章讲述了如何让游戏适应不同的设备。到目前为止,您一直在开发只能在有键盘和鼠标的设备上工作的示例,比如笔记本电脑或台式机。JavaScript 程序的一个好处是它们也可以在智能手机和平板电脑上运行,这是一个正在蓬勃发展的市场。让你的游戏运行在这样的平台上会为你的游戏带来很多额外的玩家。为了在智能手机或平板电脑上玩游戏,你需要处理触摸输入,就像处理键盘和鼠标输入一样。

另一件你需要注意的事情是你想要制作游戏的设备上的各种屏幕尺寸。本章向您展示如何创建自动适应任何屏幕大小的游戏,无论是巨大的 24 英寸桌面显示器还是微小的智能手机屏幕。

允许画布改变大小

为了允许自动调整屏幕尺寸,你需要做的第一件事是放置canvas元素,使得画布自动缩放到页面的大小。一旦完成,你可以检索画布的大小,将其与游戏屏幕的实际大小进行比较,并执行缩放操作。在 Painter 中,这是您在 HTML 页面上放置canvas元素的方式:

<div id="gameArea">

    <canvas id="mycanvas" width="800" height="480"> </canvas>

</div>

如您所见,您定义了一个名为gameAreadiv元素。在这个div元素中是一个单独的canvas元素。为了让canvas元素自动缩放,你需要将它的宽度和高度设置为浏览器窗口宽度和高度的 100%。这样,当浏览器窗口改变大小时,canvas元素也会随之改变大小。此外,你要尽可能使你显示的页面整洁(没有空白)。为了做到这一点,您使用了样式表。样式表是定义网页元素外观的好方法。不同的 HTML 页面可以使用相同的样式表来确保统一的设计。这是示例游戏的样式表:

html, body {
    margin: 0;
}
#gameArea {
    position: absolute;
}

#mycanvas {
    position: absolute;
    width: 100%;
    height: 100%;
}

深入讨论样式表的可能性不在本书的范围内,但是如果你想了解更多,你可以阅读 Simon Collision 的CSS Web 开发入门 (Apress,2006)或者 Jon Ducket 的 HTML 和 CSS(Wiley,2011)。

在这个特殊的例子中,您要做几件事情。首先,定义htmlbody元素没有边距。这样,如果你愿意,画布可以完全填满屏幕。然后,定义被称为gameAreamycanvas的东西在页面上的绝对位置。这样,不管 HTML 页面中还有什么其他元素,这些元素总是被放置在它们想要的位置。最后,您指出mycanvas的宽度和高度是屏幕的 100%。结果,画布现在填满了浏览器的整个显示,并自动缩放到不同的分辨率。

注意让画布填满整个浏览器并不一定是所有设置的最佳解决方案。在某些情况下,用户可能很难在不意外点击浏览器之外的情况下与浏览器窗口边缘的元素进行交互。在这种情况下,您可以考虑增加边距。

设置本地游戏大小

既然画布会自动缩放到浏览器窗口的大小,您就不能再将画布分辨率用作原生游戏分辨率。如果你这样做了,你将不得不在调整画布大小时重新计算所有游戏对象的位置。更好的方法是定义一个本地游戏大小。然后,在绘制对象时,缩放它们的位置和大小,使其与实际的画布大小相匹配。

第一步是能够指出本地游戏的大小应该是多少。当您调用Game.start方法时,您可以这样做。 你扩展了这个方法,让它接受两个额外的参数来定义游戏的宽度和高度,然后从 HTML 页面调用它:

Game.start('gameArea', 'mycanvas', 1440, 1080);

Jewel Jam 游戏的原生分辨率较大(1440 × 1080),所以如果你想让游戏在所有智能手机和平板电脑上正常运行,缩放肯定是必要的。您还可以传递包含画布的div元素的名称。原因是你改变了这个div的边距,这样游戏就可以很好地显示在屏幕中间,以防屏幕比例与游戏大小比例不同(见图 13-1 )。

9781430265382_Fig13-01.jpg

图 13-1 。你必须确保游戏屏幕总是很好地显示在浏览器窗口的中间!

Game.start方法中,你将游戏的大小存储在一个成员变量_size中。您还定义了一个只读属性size 来访问成员变量。通过这种方式,你向Game类的用户表明,他们不应该在游戏运行时改变游戏的原生尺寸。您将游戏区域和画布的标识符传递给Canvas2D.initialize方法,因为您必须在属于Canvas2D的方法中完成缩放和定位精灵的跑腿工作。这就是完整的Game.start方法:

Game_Singleton.prototype.start = function (divName, canvasName, x, y) {
    this._size = new Vector2(x, y);
    Canvas2D.initialize(divName, canvasName);
    this.loadAssets();
    this.assetLoadingLoop();
};

Canvas2D中,你存储了一个对div元素的引用,这样你就可以在以后使用它来改变边距。检索该元素的方式与检索canvas元素的方式相同:

this._canvas = document.getElementById(canvasName);
this._div = document.getElementById(divName);

您需要计算每次调整浏览器窗口大小时要应用的缩放因子。您可以通过附加事件处理函数来实现这一点,如下所示:

window.onresize = Canvas2D_Singleton.prototype.resize;

现在,每当调整窗口大小时,就会调用resize方法 。最后,您显式调用resize方法:

this.resize();

通过调用这个方法,在游戏开始时根据浏览器窗口大小计算出合适的比例。

调整游戏大小

每当调整窗口大小时,就调用resize方法。当这种情况发生时,你需要计算两件事:

  • 绘制精灵所需的比例
  • 游戏区域的边距,以便在浏览器窗口的中间很好地绘制游戏

在开始计算这些东西之前,将canvasdiv元素存储在两个局部变量中。这样做可以节省一些编写工作,因为您需要在这个方法中经常访问这些元素:

var gameCanvas = Canvas2D._canvas;
var gameArea = Canvas2D._div;

现在你来计算一下原生游戏大小的比例:

var widthToHeight = Game.size.x / Game.size.y;

下一步是计算浏览器窗口比率。首先通过访问innerWidthinnerHeight变量来获取浏览器窗口的大小。一旦你有了这些,你就可以计算浏览器窗口比例:

var newWidth = window.innerWidth;
var newHeight = window.innerHeight;
var newWidthToHeight = newWidth / newHeight;

你不希望游戏屏幕看起来被压扁或拉长,所以你必须确保当窗口大小改变时,比例不会改变。当新的比例大于原生游戏尺寸比例时,这意味着浏览器窗口太宽。因此,您需要重新计算宽度以修正比率,如下所示:

if (newWidthToHeight > widthToHeight) {
    newWidth = newHeight * widthToHeight;
}

另一种情况是新比率较小,意味着浏览器窗口太高。在这种情况下,您需要重新计算高度:

newHeight = newWidth / widthToHeight;

现在你已经计算出了正确的宽度和高度,你首先改变gameArea div使其具有这个高度和宽度。只需通过编辑元素的样式,即可实现如下操作:

gameArea.style.width = newWidth + 'px';
gameArea.style.height = newHeight + 'px';

为了在屏幕中间显示gameArea元素,您还需要定义边距,如下:

gameArea.style.marginTop = (window.innerHeight - newHeight) / 2 + 'px';
gameArea.style.marginLeft = (window.innerWidth - newWidth) / 2 + 'px';
gameArea.style.marginBottom = (window.innerHeight - newHeight) / 2 + 'px';
gameArea.style.marginRight = (window.innerWidth - newWidth) / 2 + 'px';

将新的宽度和高度与实际的宽度和高度之差除以 2,并将这些值设置为边距。例如,如果期望的游戏屏幕宽度是 800,但是窗口实际上是 900 像素宽,那么您可以在每边创建 50 像素的边距,以便将元素绘制在屏幕的中间。最后,更改画布的大小,使其具有所需的宽度和高度(以及正确的比例):

gameCanvas.width = newWidth;
gameCanvas.height = newHeight;

绘制精灵的比例现在可以很容易地计算出来了。您可以定义一个属性来完成这项工作:

Object.defineProperty(Canvas2D_Singleton.prototype, "scale",
    {
        get: function () {
            return new Vector2(this._canvas.width / Game.size.x,
                this._canvas.height / Game.size.y);
        }
    });

当您绘制图像时,您只需在执行绘制操作之前应用此比例。这是通过向Canvas2D.drawImage方法添加几行代码来实现的——一行用于检索标尺,一行用于应用标尺:

var canvasScale = this.scale;
this._canvasContext.scale(canvasScale.x, canvasScale.y);

Canvas2D.drawText方法中做同样的事情。完整的代码,请看属于本章的 JewelJam1 例子。

重新设计鼠标输入处理

做完所有这些之后,您已经在divcanvas元素周围移动了相当多的距离。在画师游戏中,你假设画布总是画在屏幕的左上角。在这里,情况不再如此。如果你想检测鼠标在屏幕上的点击,当计算鼠标在游戏屏幕上的本地位置时,你需要考虑画布在屏幕上的位置。此外,因为您应用了缩放,所以您还需要相应地缩放鼠标位置。

这是重新审视输入处理类MouseKeyboard设计方式的好机会。你从未正确实现的一件事是处理键盘或鼠标按钮的上下相对于键盘或鼠标按钮按下。此外,在Mouse类中,你只考虑了左键点击;而在Keyboard类中,同一时间只能按下一个键。您可以通过 JavaScript 的原型系统使用面向对象编程的力量来设计更好的解决方案。

第一步是创建一个简单的类,可以存储按钮的状态(不管是按键还是鼠标按钮)。我们姑且称这个类为ButtonStateButtonState类非常简单,只有两个(boolean)成员变量:一个指示按钮是否按下,另一个指示按钮是否被按下。下面是完整的类:

function ButtonState() {
    this.down = false;
    this.pressed = false;
}

现在您使用Mouse类中的 ButtonState实例来表示鼠标左键、中键和右键的状态。这是Mouse的新构造者的样子:

function Mouse_Singleton() {
    this._position = Vector2.zero;
    this._left = new ButtonState();
    this._middle = new ButtonState();
    this._right = new ButtonState();
    document.onmousemove = handleMouseMove;
    document.onmousedown = handleMouseDown;
    document.onmouseup = handleMouseUp;
}

如您所见,按钮的成员变量位于与每个位置相关联的成员变量旁边。您可以添加只读属性来访问这些变量。例如,如果您想检查鼠标中键是否按下,您可以使用下面一行简单的代码来完成:

if (Mouse.middle.down)
    // do something

在事件处理函数中,您正在更改成员变量的值。在handleMouseMove事件处理函数中,您必须计算鼠标位置。这也是您确保鼠标位置根据应用于画布的缩放和偏移进行缩放和移动的地方。下面是完整的handleMouseMove功能:

function handleMouseMove(evt) {
    var canvasScale = Canvas2D.scale;
    var canvasOffset = Canvas2D.offset;
    var mx = (evt.pageX - canvasOffset.x) / canvasScale.x;
    var my = (evt.pageY - canvasOffset.y) / canvasScale.y;
    Mouse._position = new Vector2(mx, my);
}

现在,无论何时移动鼠标,都会正确计算鼠标位置。接下来您需要做的是处理鼠标按钮按下和按下事件。鼠标按钮只有在当前按下时才会被按下,而不是在之前的游戏循环迭代中。对于鼠标左键,您可以这样表达:

if (evt.which === 1) {
    if (!Mouse._left.down)
        Mouse._left.pressed = true;
    Mouse._left.down = true;
}

evt.which值表示您是在处理鼠标左键(1)、中键(2)还是右键(3)。请看一下 JewelJam1 示例,了解完整的handleMouseDown事件处理函数。在handleMouseUp事件处理程序中,您再次将down变量设置为false。下面是完整的函数:

function handleMouseUp(evt) {
    handleMouseMove(evt);

    if (evt.which === 1)
        Mouse._left.down = false;
    else if (evt.which === 2)
        Mouse._middle.down = false;
    else if (evt.which === 3)
        Mouse._right.down = false;
}

您可以看到在这里也调用了handleMouseMove函数。这样做是为了确保当您按下鼠标按钮时,鼠标位置可用。如果您省略了这一行,玩家在没有移动鼠标的情况下开始游戏并按下了鼠标按钮,游戏将试图在没有位置信息的情况下处理鼠标按钮的按下。这就是为什么你在handleMouseDownhandleMouseUp函数中都调用了handleMouseMove函数(尽管后者可能不是必需的)。

最后,在每个游戏循环迭代结束时,Mouse对象被重置。这里你唯一需要做的事情就是将pressed变量再次设置为:】

Mouse_Singleton.prototype.reset = function () {
    this._left.pressed = false;
    this._middle.pressed = false;
    this._right.pressed = false;
};

通过在每次游戏循环迭代后将pressed变量设置为false,可以确保鼠标或按键只被处理一次。

数组

你也可以重新设计键盘输入处理,现在你已经有了这个ButtonState类——但是在你这么做之前,让我们引入你需要的另一个概念,叫做数组。数组基本上是一个编号列表。看看下面的例子:

var emptyArray = [];
var intArray = [4, 8, 15, 16, 23, 42];

这里你可以看到数组变量的两个声明和初始化。第一个声明是一个空数组(没有元素)。第二个变量intArray,引用一个长度为 6 的数组。您可以通过索引来访问数组中的元素,其中数组中的第一个元素的索引为 0:

var v = intArray[0]; // contains the value 4
var v2 = intArray[4]; // contains the value 23

您使用方括号来访问数组中的元素。您也可以使用相同的方括号来修改数组中的值:

intArray[1] = 13; // intArray now is [4, 13, 15, 16, 23, 42]

还可以向数组中添加一个元素:

intArray.push(-3); // intArray now is [4, 13, 15, 16, 23, 42, -3]

最后,每个数组都有一个length变量,您可以访问它来检索长度:

var l = intArray.length; // contains the value 7

您可以结合使用数组和for循环来做有趣的事情。这里有一个例子:

for (var i = 0; i < intArray.length; i++) {
    intArray[i] += 10;
}

这将遍历数组中的所有元素,每个元素加 10。所以,这段代码执行后,intArray指的是[14, 23, 25, 26, 33, 52, 7]

除了以您刚才看到的方式初始化数组之外,还有另一种创建数组的方式,如下所示:

var anotherArray = new Array(3);

此示例创建一个大小为 3 的数组,然后您可以填充该数组:

anotherArray[0] = "hello";

您甚至可以用 JavaScript 创建多维数组。例如:

var tictactoe = new Array(3);
tictactoe[0] = ['x', 'o', ' '];
tictactoe[1] = [' ', 'x', 'o'];
tictactoe[2] = [' ', 'o', 'x'];

所以,数组的元素也可以是数组。这些类型的网格结构在表示像国际象棋这样的游戏中的游戏场时特别有用。因为数组是作为引用存储的(就像类的实例一样),所以使用这样的二维数组表示网格在计算上不是很高效。用一维数组表示网格有一个简单的方法,如下:

var rows = 10, cols = 15;
var myGrid = new Array(rows * cols);

现在可以按如下方式访问第i行和第j列的元素:

var elem = myGrid[i * cols + j];

图 13-2 显示了表达式语法图的另一部分,带有指定数组的语法。下一章将更详细地讨论在游戏中使用数组来表示结构和网格。

9781430265382_Fig13-02.jpg

图 13-2 。表达式的部分语法图

使用数组处理键盘输入

让我们看看如何使用数组更有效地处理按键。因为现在有了ButtonState类,所以也可以用它来处理键盘输入,并为每个按键状态存储一个ButtonState实例。所以,在Keyboard_Singleton的构造函数中,创建一个包含所有可能的关键状态的数组,如下:

this._keyStates = [];
for (var i = 0; i < 256; ++i)
    this._keyStates.push(new ButtonState());

现在,只要检测到按键按下,您就可以设置按键的按下状态,如下所示:

function handleKeyDown(evt) {
    var code = evt.keyCode;
    if (code < 0 || code > 255)
        return;
    if (!Keyboard._keyStates[code].down)
        Keyboard._keyStates[code].pressed = true;
    Keyboard._keyStates[code].down = true;
}

如您所见,您可以像处理鼠标按钮一样处理按键。添加到Keyboard_Singleton中的以下两种方法使得检测按键是否被按下变得非常容易:

Keyboard_Singleton.prototype.pressed = function (key) {
    return this._keyStates[key].pressed;
};
Keyboard_Singleton.prototype.down = function (key) {
    return this._keyStates[key].down;
};

JewelJam 游戏中,你不需要键盘,因为所有的互动都是通过鼠标或触摸屏进行的。后面的例子再次使用了键盘。这也意味着您基本上可以忽略任何发生的键盘事件。如果你没有在游戏循环方法中加入任何键盘操作,游戏会完全忽略玩家按下的任何键。

触摸屏输入

因为智能手机和平板电脑也有网络浏览器,所以可以在这些设备上运行 JavaScript 应用。实际上,JavaScript 是目前唯一一种无需重写代码就能开发出运行在台式机、笔记本电脑、手机和平板电脑上的应用的跨平台方法。例如,您可以将最终的画师示例放在服务器上,然后在平板电脑上访问网页。但是,你不能玩这个游戏,因为你没有处理过触摸输入。在本节中,您将为游戏添加触摸输入功能。幸运的是,这在 JavaScript 中非常容易做到!

在开始编写 JavaScript 代码之前,您需要注意 HTML 页面中的一些事情。原因是平板电脑和智能手机设备定义了网页上的默认触摸行为,例如滚动或缩放网页的能力。这些互动会干扰玩游戏,所以你要把它们关掉。这可以在 HTML 文件的标题中完成,方法是在标题中添加以下标记:

<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">

HTML 中的 视区 定义了视图中的页面部分。例如,当您导航到一个页面时,它会自动放大一部分,这是通过修改视口的大小来实现的。在meta标签中,你可以定义视口的属性。这个例子告诉浏览器三件事:视窗的宽度与设备的宽度相同,第一次查看页面时页面的缩放比例为 1,用户不能缩放页面。结果,所有改变 HTML 页面的定位或缩放的触摸交互都被关闭。

现在,您可以开始编写 JavaScript 代码来处理触摸事件。JavaScript 中触摸输入的工作方式是,每次用户触摸屏幕时,都会生成一个touchstart事件 ,并为该触摸分配一个唯一的标识符。当用户用另一个手指触摸屏幕时,另一个带有新的唯一标识符的touchstart事件发生。当用户在屏幕上移动手指时,会产生一个touchmove事件,并且可以从该事件中获得正在移动的手指的唯一标识符。类似地,当用户停止触摸屏幕时,生成一个touchend事件,并且可以再次从该事件中检索已经停止触摸屏幕的手指的标识符。

为了处理触摸,创建一个类Touch_Singleton和相关的单个实例Touch,类似于您创建KeyboardMouse对象的方式。在类的构造函数中,创建一个数组,在其中存储每次触摸和相关信息。此外,您还可以存储每次触摸是否是按压。触摸按压意味着在先前的游戏循环迭代中手指没有触摸屏幕,但是在当前的迭代中手指触摸了屏幕。此外,您必须附加事件处理函数。下面是完整的构造函数方法:

function Touch_Singleton() {
    this._touches = [];
    this._touchPresses = [];
    document.addEventListener('touchstart', handleTouchStart, false);
    document.addEventListener('touchend', handleTouchEnd, false);
    document.addEventListener('touchcancel', handleTouchEnd, false);
    document.addEventListener('touchleave', handleTouchEnd, false);
    document.body.addEventListener('touchmove', handleTouchMove, false);
}

对于浏览器来说,处理触摸相对较新(相对于键盘和鼠标输入)。因此,并非所有浏览器都使用相同的术语。这就是为什么您需要为不同种类的touchend事件添加事件监听器。然而,最终,您处理三种类型的事件:touchstarttouchmovetouchend、。它们都有自己的事件处理函数。

当用户开始触摸屏幕时,调用handleTouchStart功能。 在这个函数中,您只需将触摸事件存储在_touches数组中,以便以后需要时可以检索数据。然而,用户可能会同时用多个手指触摸屏幕。因此,该事件包含一系列新的触摸。你可以用变量changedTouches来访问这个数组。对于该数组中的每个元素,将触摸事件数据添加到_touches数组中。因为这些都是新的内容,所以您还需要将true值添加到_touchPresses变量中。这是完整的方法:

function handleTouchStart(evt) {
    evt.preventDefault();

    var touches = evt.changedTouches;
    for (var i = 0; i < touches.length; i++) {
        Touch._touches.push(touches[i]);
        Touch._touchPresses.push(true);

    }
}

注意,这个函数中的第一条指令是evt.preventDefault();。该方法调用确保触摸事件不用于默认行为(如滚动)。您还将该指令放在其他触摸事件处理程序函数中。在handleTouchEnd中,你需要从你的类中的两个数组中移除触摸。为此,您必须搜索整个数组,以找到属于特定 touch ID 的数据。为了使这变得简单一点,您在您的类中添加了一个方法getTouchIndexFromId,它会为您找到数组中的索引。稍后,您可以调用此方法来查找触摸在数组中的存储位置,然后移除该元素。

在该方法中,使用一个for循环遍历数组中的所有元素。当您找到与您正在寻找的触摸数据的标识符相匹配的触摸时,您返回该数据。如果找不到触摸数据,则返回值-1。下面是完整的方法:

Touch_Singleton.prototype.getTouchIndexFromId = function (id) {
    for (var i = 0, l = this._touches.length; i < l; ++i) {
        if (this._touches[i].identifier === id)
            return i;
    }
    return -1;
};

handleTouchEnd函数中, 用一个for循环遍历changedTouches变量。对于每一次触摸,您找到相应的索引并从两个数组(_touches_touchPresses)中移除触摸。从数组中移除一个元素是通过splice方法完成的。该方法采用一个索引和一个数字:索引指示应该从哪里移除元素,第二个参数指示应该移除多少个元素。这里有一个例子:

var myArray = [2, 56, 12, 4];
myArray.splice(0,1); // myArray now contains [56, 12, 4]
myArray.splice(1,2); // myArray now contains [56]

以下是完整的handleTouchEnd功能:

function handleTouchEnd(evt) {
    evt.preventDefault();
    var touches = evt.changedTouches;
    for (var i = 0; i < touches.length; ++i) {
        var id = Touch.getTouchIndexFromId(touches[i].identifier);
        Touch._touches.splice(id, 1);
        Touch._touchPresses.splice(id, 1);

    }
}

最后,在handleTouchMove、、中,您必须为已经改变的触摸更新触摸信息。这意味着你必须替换数组中的一个元素。splice方法接受第三个参数,在这个参数中您可以指定一个替换元素。看看属于这一章的代码,看看handleTouchMove事件处理函数的实现。

使得处理触摸输入更容易

您已经看到了 JavaScript 是如何处理触摸输入的。你可以在Touch_Singleton类中再添加一些方法,让它在游戏中更容易使用。首先是一个简单的方法来检索一个给定索引触摸的位置:

Touch_Singleton.prototype.getPosition = function (index) {
    var canvasScale = Canvas2D.scale;
    var canvasOffset = Canvas2D.offset;
    var mx = (this._touches[index].pageX - canvasOffset.x) / canvasScale.x;
    var my = (this._touches[index].pageY - canvasOffset.y) / canvasScale.y;
    return new Vector2(mx, my);
};

请注意,您必须应用与鼠标位置相同的位置和比例校正。触摸事件中的pageXpageY变量提供了玩家触摸屏幕的坐标。

检查你是否触摸了屏幕的某个区域也很有用。为此,您可以在 JewelJam1 示例中添加一个名为Rectangle的类。这是一个非常简单的类,类似于Vector2,用于表示矩形。还可以添加几个简单的方法来检查两个矩形是否相交(对碰撞检查有用)以及矩形是否包含某个位置。查看Rectangle.js文件,了解如何构造这个类。

您可以使用矩形来定义屏幕的各个部分。containsTouch方法检查作为参数提供给方法的矩形中是否有触摸

Touch_Singleton.prototype.containsTouch = function (rect) {
    for (var i = 0, l = this._touches.length; i < l; ++i) {
        if (rect.contains(this.getPosition(i)))
            return true;
    }
    return false;
};

在方法体中有一个for循环,它为数组中的每次触摸检查它的位置是否在矩形内。您重用了之前定义的getPosition方法。根据结果,该方法返回truefalse。还将以下属性添加到Touch :

Object.defineProperty(Touch_Singleton.prototype, "isTouchDevice",
    {
        get: function () {
            return ('ontouchstart' in window) || (navigator.msMaxTouchPoints > 0);
        }
    });

这是一个非常简单的属性,用于检查您当前是否在使用触摸设备。并非所有的浏览器都以相同的方式检测到这一点,所以这是将其封装在方法或属性中的一个很好的理由。这样,您只需处理一次浏览器之间的这些差异。之后,访问Touch.isTouchDevice属性,就完成了。

向画师添加触摸输入

现在你已经有了这个漂亮的Touch物体,如果不在画师游戏中加入触感,那就太可惜了。属于本章的示例代码有一个带触摸输入的 Painter 版本。查看示例代码,特别是添加了触摸输入的Cannon.js文件。在触控界面中,你可以点击炮管中的彩球来切换到不同的颜色。在大炮外面敲击瞄准大炮。之后松开触球。

为了将触摸输入处理和鼠标输入处理分开,您可以如下重写handleInput方法:

Cannon.prototype.handleInput = function (delta) {
    if (Touch.isTouchDevice)
        this.handleInputTouch(delta);
    else
        this.handleInputMouse(delta);
};

现在你写两个不同的方法来处理触摸和鼠标输入。这就是handleInputTouch方法的样子:

Cannon.prototype.handleInputTouch = function (delta) {
    var rect = this.colorSelectRectangle;
    if (Touch.containsTouchPress(rect)) {
        if (this.currentColor === this.colorRed)
            this.currentColor = this.colorGreen;
        else if (this.currentColor === this.colorGreen)
            this.currentColor = this.colorBlue;
        else
            this.currentColor = this.colorRed;
    } else if (Touch.touching && !Touch.containsTouch(rect)) {
        var opposite = Touch.position.y - this.position.y;
        var adjacent = Touch.position.x - this.position.x;
        this.rotation = Math.atan2(opposite, adjacent);
    }
};

首先,检索代表加农炮部分的矩形,您可以触摸它来选择不同的颜色。添加一个简单的属性来计算这个矩形。然后检查矩形是否包含触摸按压。如果是这种情况,你改变颜色。通过使用一个if指令,你可以很容易地在三种颜色之间循环。

如果玩家正在触摸屏幕的某个地方,但不是在矩形内,你改变大炮的目标指向那个位置。还可以看看Ball类,看看它是如何处理触摸输入的!最后,另一件好事是根据应用是在平板电脑上运行还是在台式计算机上运行来加载不同的精灵:

if (Touch.isTouchDevice)
    sprites.gameover = loadSprite("spr_gameover_tap.png");
else
    sprites.gameover = loadSprite("spr_gameover_click.png");

当游戏在触摸设备上运行时,覆盖图有文本“点击继续”;否则显示“点击继续”。

注意许多现代笔记本电脑都包含触摸屏和键盘,如何自动确定玩家想要使用触摸还是键盘输入并不总是显而易见的。玩家可能希望在一些游戏中使用触摸屏,而在另一些游戏中使用键盘。一个可能的解决方案是同时接受两个输入。另一种解决方案是让玩家通过菜单设置来自己选择输入法。

你学到了什么

在本章中,您学习了:

  • 如何根据不同的设备自动调整游戏屏幕的尺寸
  • 如何根据游戏画面的尺寸自动校正鼠标位置
  • 什么是数组以及如何使用数组
  • 如何使用触摸界面来控制游戏中发生的事情

十四、结构中的游戏对象

在前一章中,你看到了如何使用数组来表示事物的列表。例如,您使用一个数组来跟踪玩家触摸触摸屏的位置,或者玩家当前在键盘上按了多少个键。

数组也可以用在很多其他情况下。您可以使用它们来存储更复杂的对象,如画家游戏中的球或颜料罐。一般来说,为游戏对象提供一些结构是有用的,而不是简单地将它们都声明为游戏世界中的成员变量。许多游戏将它们的游戏对象放在某种游戏层级中。例如,你可以有一个游戏对象Car,它由其他游戏对象组成,比如轮子、传动系统、马达、窗户、座椅等等。这些对象中的每一个又可以由更小的游戏对象组成,等等。

在某些情况下,游戏对象必须遵守游戏世界中的某种结构。很多桌游或者益智游戏都有这个要求。这些游戏强加了一套规则,将棋子绑定到棋盘上的特定位置或配置。例如,在国际象棋游戏中,棋子只能(有意义地)放在棋盘上的白格子和黑格子上。你不能把你的皇后放在两个方格中间。在电脑游戏中,这种限制更容易实施:你只需要确保你放置游戏对象的位置是有效的。在这一章中,你将看到如何将层次和结构融入到电脑游戏中。

网格中的游戏对象

通常,棋盘游戏和益智游戏是基于将物体放置在某种网格中。这种游戏有很多例子:国际象棋、俄罗斯方块、井字游戏、数独游戏、糖果粉碎游戏等等。通常这些游戏的目标是以某种方式修改网格的配置以获得分数。在俄罗斯方块中,必须构建完全填充的行;在数独游戏中,数字属性必须适用于行、列和子网格。游戏 JewelJam 也采用了网格结构。问题是,你如何在你的游戏中表现这些网格状的结构?

首先让我们来看一个简单的例子,你想画一个背景精灵,在这个基础上,画一个十行五列的网格,网格中的每个位置都填充了一个精灵。做这件事的程序叫做 JewelJam2 ,你可以在属于本章的示例代码文件夹中找到它。

创建精灵网格

前一章展示了如何创建数组。让我们在下一个例子中使用这个特性来创建一个二维游戏场。程序 JewelJam2 包含了创建一个游戏世界的指令,这个游戏世界由一个可以被操纵的精灵网格组成。在这个特殊的例子中,你没有在网格中存储实际的精灵,而是一个代表它们的整数。这样,你可以根据这个数字选择画哪个精灵,甚至可以用网格中的数字进行计算。当你开始游戏时,你加载了三个宝石精灵和背景精灵:

Game.loadAssets = function () {
    var loadSprite = function (sprite) {
        return Game.loadSprite("../assets/sprites/" + sprite);
    };
    sprites.background = loadSprite("spr_background.jpg");
    sprites.single_jewel1 = loadSprite("spr_single_jewel1.png");
    sprites.single_jewel2 = loadSprite("spr_single_jewel2.png");
    sprites.single_jewel3 = loadSprite("spr_single_jewel3.png");
};

JewelJamGameWorld类中,创建了一个表示二维游戏场的数组。在上一章中,您看到了如何使用一维数组来表示二维网格:

var myGrid = new Array(rows * cols);
var someElement = myGrid[i * cols + j];

因此,让我们在JewelJamGameWorld类中创建这样一个网格:

this.rows = 10;
this.columns = 5;
this.grid = new Array(this.rows * this.columns);

为了在访问该网格时使事情变得简单一些,定义以下两种方法来获取和设置网格中的值:

JewelJamGameWorld.prototype.setGridValue = function (x, y, value) {
    var index = y * this.columns + x;
    this.grid[index] = value;
};
JewelJamGameWorld.prototype.getGridValue = function (x, y) {
    var index = y * this.columns + x;
    return this.grid[index];
};

正如你所看到的,你只需应用上一章解释的技巧。最初,你用你装载的三个宝石精灵中的一个随机填充网格。您可以这样做:

for (var i = 0; i < this.rows * this.columns; i++) {
    var randomval = Math.floor(Math.random() * 3) + 1;
    if (randomval === 1)
        this.grid[i] = sprites.single_jewel1;
    else if (randomval === 2)
        this.grid[i] = sprites.single_jewel2;
    else
        this.grid[i] = sprites.single_jewel3;
}

for循环体中的第一条指令从集合{1,2,3}中生成一个随机数。在这条指令中,使用Math.random得到一个介于 0 和 1 之间的值,将该值乘以 3(得到一个介于 0 和 3 之间的值),然后将其向下舍入并加 1,得到一个介于 1 和 3 之间的值。根据随机数的值,在if指令中选择不同的精灵。在网格数组中存储一个对 sprite 的引用。

JavaScript 中有一种很好的方法可以缩短这段代码,因为 JavaScript 允许您通过使用类似数组的语法来访问对象的成员变量。例如,假设您定义了以下对象:

var person = {
    name : "Arjan",
    gender : "male",
    married : true
};

您可以按常规方式访问成员变量,如下所示:

person.name = "John";

有一个指令是等价的。看起来是这样的:

person["name"] = "John";

因此,当访问对象的成员变量时,可以使用常规语法,或者可以将成员作为用字符串索引的数组来访问。你会问,这为什么有用?嗯,字符串可以连接起来,所以你可以写一些聪明的代码,根据随机生成的数字选择不同的精灵。这里是和以前一样的for循环,但是现在你使用这个特性来编写更短的代码——这很容易适应四个或更多的宝石类型!

for (var i = 0; i < this.rows * this.columns; i++) {
    var randomval = Math.floor(Math.random() * 3) + 1;
    this.grid[i] = sprites["single_jewel" + randomval];
}

绘制网格

现在你有了一个随机选择的宝石精灵网格,你可以在屏幕上画网格了。这里的挑战是,你需要计算每个宝石应该画的位置。该位置取决于您要绘制的宝石的行和列索引。因此,您使用一个嵌套的for循环遍历所有的行和列,然后在每个行和列的索引处绘制宝石。要取回宝石,您可以使用之前定义的getGridValue方法。下面是完整的draw方法:

JewelJamGameWorld.prototype.draw = function (delta) {
    Canvas2D.drawImage(sprites.background);
    for (var row = 0; row < this.rows; row++) {
        for (var col = 0; col < this.columns; col++) {
            var position = new Vector2(85 + col * 85, 150 + row * 85);
            Canvas2D.drawImage(this.getGridValue(col, row), position);
        }
    }
};

在这段代码中,您可以看到使用网格的优势。通过使用索引,你可以非常方便地计算出精灵的位置。整个网格应该以(85,150)的偏移量绘制,所以您将 85 加到本地position变量的x-坐标,150 加到y-坐标。要计算精灵的实际位置,将指数乘以 85 (精灵的宽度和高度)得到最终位置。偏移值可以存储在脚本开始时的配置变量中。这样,如果不同的级别使用不同的背景精灵,您只需要更新该变量,而不必通过绘制代码来更新偏移量。后来,你看到了另一种处理方式。图 14-1 显示了 JewelJam2 示例的截图。

9781430265382_Fig14-01.jpg

图 14-1 。JewelJam2 示例程序的屏幕截图

网格操作

因为你已经在网格中组织了游戏世界的一部分,你现在可以聪明地使用for循环来将行为添加到网格中。在本例中,您添加了一个将每一行下移一行的特性。这意味着最后一行消失了,您需要为第一行生成新的(随机)值。让我们添加一个名为moveRowDown的方法来做这件事。“下移”一行是什么意思?基本上,您只需将索引为y的行中的值复制到索引为y + 1的行中。让我们把它放在一个for循环中:

for (var y = 1; y < this.rows - 1; y++) {
    for (var x = 0; x < this.columns; x++) {
        this.setGridValue(x, y + 1, this.getGridValue(x, y));
    }
}

外部的for循环从第 0 行开始迭代,直到this.rows - 1。这意味着最后一行不会向下移动。而这就是你想要的,因为最后一行下面没有行!内部的for循环遍历列(从 0 到this.columns,并将位置(xy)处的值复制到位置(xy + 1)。在这个内部for循环完成后,行y的内容被复制到行y + 1。然而,如果您尝试运行这个for循环,您会注意到它没有您想要的行为:第一行的内容被复制到它下面的所有行!这怎么可能?

这是一个为什么思考循环如何工作很重要的例子。这种情况下的问题是你忘记了循环是顺序的。让我们看看发生了什么。第一次进入循环时,将第 0 行的内容复制到第 1 行。第二次进入循环时,将第 1 行的内容复制到第 2 行。但是,第 1 行已经被第 0 行的内容替换了,所以您正在将第 0 行的内容复制(间接)到第 2 行!

你如何解决这个问题?实际上,你只需要对算法做一个简单的改变。不是从第 0 行开始,一直到最后一行,而是从最后一行开始,一直向上,直到第一行。修改后的算法如下所示:

for (var y = this.rows - 2; y >=0; y--) {
    for (var x = 0; x < this.columns; x++) {
        this.setGridValue(x, y + 1, this.getGridValue(x, y));
    }
}

在这种情况下,从索引 8 处的行开始,将其内容复制到索引 9 处的行。之后,将第 7 行复制到第 8 行,依此类推。与以前版本的算法不同,这种方法是可行的,因为您是从底部向上工作的,并且只对您不再需要考虑的行进行修改:一旦您将值从第 7 行复制到第 8 行,在算法的剩余部分中就不会再看到第 8 行。

当你在你的程序中使用循环时,你会遇到那些刚刚描述的错误。当发生这种情况时,最好的办法是在纸上画一个图,看看发生了什么,并写下循环正在做什么,一次又一次地迭代。调试器也很有帮助,因为它允许您在任何地方停止代码并检查变量的值。

向下移动所有行后,剩下唯一要做的就是为第一行生成新的随机值。这可以通过一条for指令来完成,该指令为行中的每一项检索一个随机数:

for (x = 0; x < this.columns; x++) {
     var randomval = Math.floor(Math.random() * 3) + 1;
     this.setGridValue(x, 0, sprites["single_jewel" + randomval]);
}

网格带来更多可能性

为了熟悉多维数组是如何工作的,您可以尝试自己编写一些其他的网格操作。例如,您能否编写一个方法void removeRow(int i)来删除给定索引处的一行,并为顶行创建新值?能不能写一个方法,对行执行一个循环操作(所有行下移,最后一行变成第一行)?向上移动行怎么样?还是动柱?可以在这样的网格上创建许多不同的操作。这些操作对许多不同的游戏都很有用。例如,从网格中删除一行是在俄罗斯方块游戏中经常使用的操作。像宝石迷阵这样的游戏需要能够从一行或一列中移除一些项目并再次填充网格的操作。

除了可以在网格上执行的操作之外,您还必须考虑网格包含的项目。在本例中,您使用了一个包含子画面引用的二维网格。对于更复杂的游戏,用一个由个游戏对象组成的网格来代替是很有用的,这样你就可以向网格中的对象添加更多的行为和交互。

游戏对象的层次结构

这一节向你展示如何创建一个游戏对象的层次结构。首先定义一个非常基本的GameObject类,然后添加支持将游戏对象放入层次结构的代码。

游戏对象的剖析

大多数游戏都有相当复杂的游戏对象结构。首先,可能有一个由各种运动物体层组成的背景(山脉、空气、树木等等)。然后是玩家可以与之互动的四处移动的物体。这些物体可能是玩家的敌人,所以它们需要一定程度的智能;它们也可以是更静态的,如电源、树、门或梯子。

有时物体甚至没有精灵形状的物理外观。例如,玩家的当前得分也可以是一个游戏对象,但不是有一个小精灵与之相关联,而是有一个字体在某处显示当前得分。或者想象一个游戏,其中一个看不见的敌人必须被击败,它的位置只能通过它对周围环境的影响来看。其他游戏对象甚至更复杂:由其他游戏对象组成的游戏对象。

假设你有一个代表房子的游戏对象。它可能由许多其他游戏对象组成,如门、楼梯、窗户和厨房(厨房本身又由不同的游戏对象组成)。

在益智游戏的情况下,代表游戏场地的网格也可以被认为是由其他游戏对象的网格组成的游戏对象。给定这些不同类型的游戏对象以及它们之间的关系,你可以说游戏对象通常形成了层次的一部分。这个层级可以是完全扁平化的,就像第一个示例游戏,画师;但是在接下来的章节中解释的宝石果酱游戏有一个复杂的游戏对象层次结构。

许多游戏使用游戏对象的层次结构。特别是在 3D 游戏中,由于三维环境的复杂性,这样的层次非常重要。3D 游戏中的对象通常不是由精灵来表示,而是由一个或多个 3D 模型来表示。层次结构的优点是这些对象可以组合在一起,这样,如果你拿起一个花瓶,里面有一个写有魔法文字的卷轴,卷轴会随着花瓶一起移动。这种层次也被称为场景图 ,因为它们将场景(环境)呈现为一个类似图形的结构。

在画师游戏中,游戏对象的基本类型由ThreeColorGameObject类表示。很明显,不是所有的游戏对象都有三种可能的颜色,一个当前位置和一个当前速度。到目前为止,这是你表示游戏对象的方式,仅仅是因为这对于你正在处理的基本例子来说已经足够了。如果你想开发更大、更复杂的游戏,你必须放弃一个游戏对象是三色精灵的基本前提。但是,什么是游戏对象呢?从某种意义上说,游戏对象可以是你想要的任何东西。因此,您可以定义以下类来表示游戏对象:

function GameObject() {
}

好吧,这可能有点过了。现在,让我们假设任何游戏对象都有一个位置和一个速度,但是游戏对象如何出现(如果它出现)是你还没有处理的事情。此外,您希望能够设置一个可见性标志,以便您可以选择不绘制某些游戏对象。因此,让我们用这三个成员变量创建一个通用的GameObject类:

function GameObject() {
    this.position = Vector2.zero;
    this.velocity = Vector2.zero;
    this._visible = true;
}

如果你想要一个由精灵代表的游戏对象,你可以从这个基类继承并添加必要的成员变量。

您还添加了主要的游戏循环方法:handleInputupdatedraw。因为您还不知道游戏对象应该如何处理输入,以及它应该如何在屏幕上绘制,所以您将这两个方法留空。在update方法中,就像在ThreeColorGameObject类中一样,根据游戏对象的速度和经过的时间来更新它的当前位置:

GameObject.prototype.update = function (delta) {
    this.position.addTo(this.velocity.multiply(delta));
};

游戏对象之间的关系

如果你想在游戏对象之间建立某种层次,你需要识别哪个游戏对象是哪个游戏对象的一部分。就层次而言,这意味着你需要建立一个游戏对象可以有一个父游戏对象。对于游戏对象本身来说,知道父母是谁是非常有用的。因此,GameObject类需要一个引用游戏对象父对象的成员变量:

this.parent = null;

例如,想象一个名为playingField的物体,它包含了游戏场上的所有宝石。然后playingField对象可以被认为是这些宝石的。但并非所有游戏对象都有父对象。例如,根对象没有父对象。你怎么能表明一个游戏对象没有父对象呢?您需要将parent成员变量的值设置为“nothing”——用 JavaScript 编程术语来说,就是null

既然您已经向 game-object 类添加了一个父类,那么您必须处理一些管理上的麻烦,以确保游戏对象之间的父子关系得到正确维护;但是你过会儿回到那个。因为游戏对象的等级制度,你需要对一些事情做出决定。

本地与全球立场

如你所知,每个游戏对象都有一个包含其位置的变量。直到现在,每个游戏对象都被直接定位在游戏世界中。尽管这种方法很好,但它可能不是理想的解决方案。考虑运动场游戏对象。为了将游戏区域与背景精灵对齐,您希望将它放置在位置(85,150)。然而,所有的子对象(网格中的宝石)可能也有相同的位置偏移量(85,150)。事实上,在前面的示例中,您必须将这个偏移量应用于网格中的所有项目:

var position = new Vector2(85 + col * 85, 150 + row * 85);
Canvas2D.drawImage(this.getGridValue(col, row), position);

尽管将该偏移应用于所有游戏对象(游戏场对象的子对象)有点麻烦,但这是可行的。一旦子对象变得更加复杂,并且子对象本身也需要正确定位,问题就变得更加严重。如果你改变比赛场地的位置会发生什么?你必须更新挂在它下面的所有游戏物体的位置。有一个更好的方法来做到这一点:你必须区分本地和世界位置。游戏对象的世界位置是其在游戏世界中的绝对 x -和y-坐标。游戏对象的局部位置是其相对于父游戏对象位置的位置。那么,你需要在每个游戏对象中存储这两个位置吗?否:您只需要存储本地位置。您可以通过将游戏对象的本地位置添加到父对象的世界位置来计算世界位置。如果没有父位置,那么本地位置与世界位置相同。您可以向GameObject类添加一个属性来为您完成这项工作:

Object.defineProperty(GameObject.prototype, "worldPosition",
    {
        get: function () {
            if (this.parent !== null)
                return this.parent.worldPosition.addTo(this.position);
            else
                return this.position.copy();
        }
    });

使用该属性,您现在可以获得游戏对象的本地位置(存储在position成员变量中)和世界位置(通过worldPosition属性访问)。如您所见,您通过将本地位置添加到父对象的世界位置来计算世界位置。反过来,父对象的世界位置是通过获取其本地位置并将其添加到其父对象的世界位置来计算的。这一直持续到你到达一个没有父对象的游戏对象,在这种情况下,世界位置是本地位置的副本。例如,宝石的世界位置是通过将根对象的(本地)位置、游戏场对象的本地位置加上它自己的本地位置相加来计算的。这正是当您访问它的worldPosition属性时得到的行为。在worldPosition属性本身中调用worldPosition属性可能看起来很奇怪,但这是完全有效的 JavaScript 代码。事实上,您正在使用一种叫做递归的编程技术(稍后您将了解更多)。

游戏对象的层

当你想要绘制一个游戏对象时,你可以使用worldPosition属性作为一种便捷的方式来找出在屏幕上的何处绘制游戏对象。唯一的问题是你不知道游戏对象在层级中的绘制顺序。看着宝石果酱游戏,你明明希望背景先画,游戏场才画;否则,玩家只会看到背景。

如果你能以某种方式作为游戏对象的一部分指出它应该在什么时候被绘制,那就太好了。一种方法是引入。您可以为每个游戏对象分配一个层,分配给它的层决定了何时应该绘制该对象。您可以使用整数以非常简单的方式表示这些层。较低的图层编号表示将较早绘制对象。因此,您可以将第 0 层指定给背景精灵游戏对象,将第 1 层指定给运动场游戏对象,确保在运动场之前绘制背景。直接在属于GameObject类的成员变量中存储层:

this.layer = 0;

使用层的一个小缺点是无法保证同一层中对象的绘制顺序。所以,如果你希望一个对象总是在另一个之后被绘制,那么这个对象必须在一个更高的层中。

关于GameObject类的完整视图,请参见 JewelJam3 示例中的代码。当然,简单地给GameObject类添加一个layer成员变量是不够的:你必须用这个信息做一些事情。下一节看几个不同的游戏对象子类。其中之一是GameObjectList类,它由多个其他游戏对象组成。在这个类中,您将看到如何使用layer变量以正确的顺序绘制对象。

不同种类的游戏对象

本节介绍几个有用的游戏对象,它们都被定义为GameObject的子类。首先定义一个简单的基于精灵的游戏对象。然后你移动到游戏对象的列表和网格。

精灵游戏对象

最常见的游戏对象之一是一个有位置和速度的精灵。因为 position 和 velocity 是两个已经在GameObject类中可用的成员变量,所以您可以从这个类继承,然后添加一个成员变量来存储 sprite 和一个成员变量来存储 sprite 的原点。在这个类的构造函数中,你必须将 sprite 作为参数传递,因为你在继承,你必须调用基类的构造函数,这样对象的GameObject部分也被构造。此构造函数需要一个表示层的参数。最后,您必须替换/覆盖draw方法。这个方法在GameObject中是空的,因为你决定游戏对象不一定有一个附属的精灵。在被覆盖的draw方法中,在屏幕上绘制精灵,并使用worldPosition属性计算精灵在屏幕上的实际位置。下面是SpriteGameObject类的简化版本:

function SpriteGameObject(sprite, layer) {
    GameObject.call(this, layer);
    this.sprite = sprite;
    this.origin = Vector2.zero;
}

SpriteGameObject.prototype = Object.create(GameObject.prototype);

SpriteGameObject.prototype.draw = function () {
    if (!this.visible)
        return;
    Canvas2D.drawImage(this.sprite, this.worldPosition, 0, 1, this.origin);
};

请看一下 JewelJam3 示例代码中该类的完整版本。该版本增加了一些有用的属性,例如用于检索 sprite 游戏对象宽度的属性。

游戏对象列表

下一种类型的游戏对象由其他游戏对象的列表组成。这是一个非常有用的类型,因为它允许你创建游戏对象的层次结构。例如,游戏对象需要是其他游戏对象的列表,因为它包含背景精灵游戏对象以及游戏场。要表示一个包含其他游戏对象列表的游戏对象,可以使用一个名为GameObjectList的类。这个类继承自GameObject类,所以游戏对象列表本身也是一个游戏对象。这样,你可以把它当作一个普通的游戏对象,给它一个位置,一个速度,一个绘图层,或者一个父游戏对象。此外,列表中的游戏对象本身可以是其他游戏对象的列表。这个GameObjectList类的设计允许你定义游戏对象的层次结构。要管理游戏对象列表,您需要添加一个包含(子)游戏对象的数组成员变量。这里是GameObjectList的完整构造器:

function GameObjectList(layer) {
    GameObject.call(this, layer);
    this._gameObjects = [];
}

GameObjectList类的目标之一是处理列表中的游戏对象。这意味着,如果你调用一个GameObjectList实例的draw方法,这个实例将绘制列表中的所有游戏对象。如果调用了handleInput方法或update方法,需要遵循相同的程序。下面是GameObjectList : 中定义的update方法

GameObjectList.prototype.update = function (delta) {
    for (var i = 0, l = this._gameObjects.length; i < l; ++i)
        this._gameObjects[i].update(delta);
};

所以,GameObjectList本身并不定义任何行为;它只是管理它所包含的游戏对象的行为。对于update方法,你不关心游戏对象自我更新的顺序。对于draw方法,你确实关心,因为你想先画出层数最少的游戏对象。最健壮的方法是在每次调用draw方法的开始对游戏对象列表进行排序。之后,你可以使用一个for循环,根据游戏对象在列表中的顺序,一个接一个地绘制它们。draw方法的主体看起来像这样:

if (!this.visible)
    return;
// sort the list of game objects
...
for (var i = 0, l = this._gameObjects.length; i < l; ++i)
    this._gameObjects[i].draw();

因为排序可能相当复杂,所以不是在绘制游戏对象时进行排序(每秒必须进行 60 次),而是在将游戏对象添加到列表中时进行排序。这样,你只需要在必要的时候对游戏对象列表进行排序。在 JavaScript 中对数组进行排序非常容易。数组有一个你可以调用的sort函数。比如:

var myArray = ["named", "must", "your", "fear", "be", "before", "banish", "it", "you", "can"];
myArray.sort();
/* myArray now refers to ["banish", "be", "before", "can", "fear", "it", "must", "named", "you", "your"]; */

默认情况下,sort函数按字母顺序对数组进行排序。然而,如果你有一个数组包含比字符串更复杂的东西,比如游戏对象,会发生什么呢?在这种情况下,您可以为sort提供一个排序函数作为参数。这个函数应该指示数组中任意两个对象的顺序。你可以自己写这个函数。例如,下面是对sort函数的调用,该函数根据游戏对象所在的层对其进行排序:

this._gameObjects.sort(function (a, b) {
        return a.layer - b.layer;
    });

当排序函数返回正数时,ab要“大”,应该放在b之后,反之亦然。您可以编写一个名为add的方法,将一个游戏对象添加到列表中,然后对列表进行排序。该方法还将游戏对象列表指定为您添加的游戏对象的父对象。下面是完整的方法:

GameObjectList.prototype.add = function (gameobject) {
    this._gameObjects.push(gameobject);
    gameobject.parent = this;
    this._gameObjects.sort(function (a, b) {
        return a.layer - b.layer;
    });
};

因为您确保游戏对象被添加到正确的位置,draw方法只包含一个for循环:

GameObjectList.prototype.draw = function () {
    if (!this.visible)
        return;
    for (var i = 0, l = this._gameObjects.length; i < l; ++i)
        this._gameObjects[i].draw();
};

这样,你的绘制操作保持非常高效,因为你不用每次都对游戏对象列表进行排序!不过,这样做有一个小小的缺点。考虑下面的代码片段:

var obj1 = new SpriteGameObject(spr, 1);
var obj2 = new SpriteGameObject(spr, 2);
var objects = new GameObjectList(0);
objects.add(obj1);
objects.add(obj2);
obj2.layer = 0;

这个片段创建了两个 sprite 游戏对象,并将它们添加到游戏对象列表中。add方法调用确保它们被添加到正确的位置(在这种情况下,添加的顺序恰好与层排序一致)。然而,在那之后你改变了对象obj2的层索引,但是游戏对象的列表没有改变,这意味着obj1仍然会在obj2之前绘制。因此,有可能破坏系统。在这种情况下,强烈推荐清晰的文档来指导开发人员不要做这种讨厌的事情!例如,你可以在add方法的定义上添加一个警告注释,告诉用户只考虑对象的当前图层值。另一个选择是给layer变量声明添加一个注释,说明当图层改变时,绘制顺序不会自动更新。处理这个问题的一个更好、更可靠的方法是添加一个属性,通过该属性可以更改层,该属性会自动对对象所属父对象的绘制顺序进行排序。

为了完整起见,GameObjectList类还包含一些其他有用的方法。方法从列表中删除所有的游戏对象。方法从列表中删除一个对象;因为对象不再是列表的一部分,它的父对象被设置为null

现在,您可以从自己创建的分层绘制机制以及层次结构中获益。为了让你的代码更清晰,你可以定义几个不同的层作为一个变量(完整代码见JewelJam.js):

var ID = {};
...
ID.layer_background = 1;
ID.layer_objects = 20;

现在看一下下面的代码片段:

function JewelJamGameWorld(layer) {
    GameObjectList.call(this, layer);
    this.add(new SpriteGameObject(sprites.background, ID.layer_background));
    var rows = 10, columns = 5;
    var grid = new JewelGrid(rows, columns, ID.layer_objects);
    grid.position = new Vector2(85, 150);
    grid.cellWidth = 85;
    grid.cellHeight = 85;
    this.add(grid);
    for (var i = 0; i < rows * columns; i++) {
        grid.add(new Jewel());
    }
}

JewelJamGameWorld.prototype = Object.create(GameObjectList.prototype);

这是重新创建宝石果酱游戏层次结构所需的部分代码。JewelJameGameWorld类继承自GameObjectList。因此,您可以使用add方法将游戏对象添加到游戏世界中!

首先添加一个代表背景的 sprite 游戏对象。你将图层ID.layer_background分配给这个对象。然后,你在ID.layer_objects层创建一个JewelGrid(稍后会详细讨论)。最后,用Jewel对象填充这个网格。这样,您就创建了一个相关游戏对象的层次结构,这些对象是按照正确的顺序自动绘制的!此外,因为您还处理了其他游戏循环方法的调用,所以在创建层次结构时,您不必再考虑这一点。

游戏对象的网格

正如您创建了一个类GameObjectList来表示游戏对象列表一样,您也可以创建一个类GameObjectGrid来表示游戏对象的网格。然而,这两个类别在概念上有很大的不同。首先,GameObjectList类没有说明它所包含的游戏对象的位置。另一方面,GameObjectGrid将所有游戏对象关联到一个网格,这反过来意味着它们都在网格上有一个位置。但是每个游戏对象也有一个position成员变量。

位置看起来是一式两份存储的,但事实上游戏对象在世界中的位置不一定总是与它们在网格中的位置相同。由网格指示的位置可以被认为是游戏对象的锚位置 (它们所属的位置)。然而,游戏对象的实际位置可以不同。通过将锚点位置与实际的游戏对象位置结合使用,您可以获得很好的运动效果,其中游戏对象在网格上平滑移动,同时仍然属于特定的网格位置。这种效果被大量使用的一个游戏的例子是俄罗斯方块:玩家可以将方块移动到网格上的不同位置,但是因为网格锚点位置与实际的游戏对象位置不同,所以方块移动很平稳。如果您运行 JewelJam3 示例,如果您使用鼠标或手指(在带有触摸屏的设备上)向左或向右拖动其中一行,您也可以看到这种效果的演示。

因为可以用常规数组表示二维结构,GameObjectGrid类是GameObjectList的子类。您需要做一些额外的事情来使GameObjectGrid类按照您想要的方式运行。首先,您需要能够计算锚点位置,这意味着您需要知道网格中单个元素(单元格)的大小。因此,还要添加两个成员变量来存储网格中单个单元格的大小。此外,您可以在成员变量中存储所需的行数和列数。当创建一个GameObjectGrid实例时,这些值必须作为参数传递给构造函数。这是完整的构造函数方法:

function GameObjectGrid(rows, columns, layer) {
    GameObjectList.call(this, layer);
    this.cellWidth = 0;
    this.cellHeight = 0;
    this._rows = rows;
    this._columns = columns;
}
GameObjectGrid.prototype = Object.create(GameObjectList.prototype);

此外,向该类添加两个属性,以便可以读取行数和列数:

Object.defineProperty(GameObjectGrid.prototype, "rows", {
    get: function () {
        return this._rows;
    }
});
Object.defineProperty(GameObjectGrid.prototype, "columns", {
    get: function () {
        return this._columns;
    }
});

因为你继承了GameObjectList,所以你已经有了一个添加游戏对象的方法。然而,在这门课上,你需要做一些稍微不同的事情。因为游戏对象被放置在一个(平面)网格中,绘制顺序不再重要。当你添加一个游戏对象时,你不想对数组进行排序。此外,您希望将游戏对象的位置设置为它在网格中的期望位置。要做到这一点,您可以重写来自GameObjectListadd方法,如下所示:

GameObjectGrid.prototype.add = function (gameobject) {
    var row = Math.floor(this._gameObjects.length / this._columns);
    var col = this._gameObjects.length % this._columns;
    this._gameObjects.push(gameobject);
    gameobject.parent = this;
    gameobject.position = new Vector2(col * this.cellWidth, row * this.cellHeight);
};

正如您在这个示例中看到的,您从数组中的目标位置计算行和列的索引。然后,将游戏对象放入数组,设置其父对象,并使用计算出的行和列索引来确定其位置。为了方便起见,添加另一个方法,允许您在网格中的特定行和列索引处添加游戏对象:

GameObjectGrid.prototype.addAt = function (gameobject, col, row) {
    this._gameObjects[row * this._columns + col] = gameobject;
    gameobject.parent = this;
    gameobject.position = new Vector2(col * this.cellWidth, row * this.cellHeight);
};

宝石网格

对于 Jewel Jam 游戏,您希望在网格上执行一些基本操作,包括将一行中的元素向左或向右移动。比如,当玩家向左拖动网格中的第三行时,除了最左边的元素之外,所有的元素都要向左移动,最左边的元素就变成了最右边的元素。因为这种操作并不是每个使用网格的游戏都需要的,所以让我们创建一个继承自GameObjectGrid的类JewelGrid,然后将您需要的操作添加到该类中。下面是JewelGrid类的构造方法:

function JewelGrid(rows, columns, layer) {
    GameObjectGrid.call(this, rows, columns, layer);
    this.dragging = false;
    this._dragRow = 0;
    this._draggingLastX = 0;
    this._touchIndex = 0;
}
JewelGrid.prototype = Object.create(GameObjectGrid.prototype);

它包括一些成员变量,您需要这些变量来存储与用户正在进行的拖动相关的信息。稍后当您学习如何获得这种拖动行为时,您会看到更多的细节。

通过将第一个元素存储在临时对象中,将其他对象向左移动一列,最后将存储在临时对象中的元素放在最后一列,可以将一行中的列向左移动。您可以添加一个方法shiftRowLeft来做这件事。因为该方法只应用于一行,所以必须将行索引作为参数传递。完整的方法如下:

JewelGrid.prototype.shiftRowLeft = function (selectedRow) {
    var firstObj = this.at(0, selectedRow);
    var positionOffset = firstObj.position.x;
    for (var x = 0; x < this._columns - 1; x++) {
        this._gameObjects[selectedRow * this._columns + x]
            = this._gameObjects[selectedRow * this._columns + x + 1];
    }
    this._gameObjects[selectedRow * this._columns + (this._columns - 1)] = firstObj;
    firstObj.position = new Vector2(this._columns * this.cellWidth + positionOffset,
        selectedRow * this.cellHeight);
};

除了将最左边的元素移动到最右边的列并移动所有其他元素之外,您还可以更改从最左边的对象更改为最右边的对象的对象的位置。通过在执行移位操作之前将第一个元素的任何现有位置偏移存储在局部变量中,然后将该偏移添加到新位置,可以考虑第一个元素的任何现有位置偏移。这种位置变化的结果是一个很好的运动效果,稍后你会看到。方法shiftRowRight与此方法类似;参见 JewelJam3 的示例代码。

您还想添加一个方法,为您提供任意游戏对象在网格中的锚点位置。这个方法以后会有用的。作为参数,这个方法需要一个游戏对象,它返回一个包含锚点位置的Vector2对象。下面是完整的方法:

GameObjectGrid.prototype.getAnchorPosition = function (gameobject) {
    var l = this._gameObjects.length;
    for (var i = 0; i < l; i++)
        if (this._gameObjects[i] === gameobject) {
            var row = Math.floor(i / this.columns);
            var col = i % this.columns;
            return new Vector2(col * this.cellWidth, row * this.cellHeight);
        }
    return Vector2.zero;
};

这个方法使用一个for指令来寻找作为参数传递的游戏对象。一旦找到这个对象,就可以根据网格中的行和列索引以及单元格大小来计算它的锚位置。如果没有找到对象,则返回零向量(Vector2.Zero)。因为这种方法对几乎所有的网格都有用,所以将它添加到GameObjectGrid类中。

在网格上平稳移动

为了让对象在网格上平滑移动,可以使用属于GameObject类的velocityposition成员变量。您使用从GameObjectGrid实例中检索的锚点位置来计算属于该位置的游戏对象的速度。其效果是,当游戏对象不完全在锚点位置时,它会自动开始向该位置移动。

为此,您引入了另一个名为Jewel的类,它表示网格中的一个游戏对象(在本例中,是一种宝石)。这个游戏对象是SpriteGameObject的子类。在该类的构造函数中,随机选择三个宝石精灵中的一个,如下所示:

function Jewel(layer) {
    var randomval = Math.floor(Math.random() * 3) + 1;
    var spr = sprites["single_jewel" + randomval];
    SpriteGameObject.call(this, spr, layer);
}

在这个游戏对象中唯一需要改变的是update方法,因为绘制精灵已经在基类中被正确处理了。update方法需要做什么?首先你需要调用update方法的原始版本,这样物体的位置总是根据它的速度更新:

SpriteGameObject.prototype.update.call(this, delta);

然后你需要找出这个游戏对象的锚点位置。您可以通过从父实例(通常应该是一个JewelGrid实例)调用getAnchorPosition来实现这一点:

var anchor = this.parent.getAnchorPosition(this);

最后,修改游戏对象的速度,使其向锚点位置移动:

this.velocity = anchor.subtractFrom(this.position).multiplyWith(15);

如您所见,您通过获取目标位置(即锚点位置)和当前位置之间的差值来计算速度。要获得更快的运动效果,请将该值乘以 15。当游戏对象的位置被更新时,该速度被添加到位置向量,结果游戏对象向目标位置移动。对于完整的Jewel类,参见宝石 3 示例。

拖动网格中的行

在这一章中你要做的最后一件事是添加拖拽行为到网格中,这样玩家就可以左右移动行。您可以分两步定义拖动行为。首先,根据玩家用鼠标或手指拖动的位置,确定行中元素的新位置。然后,根据玩家拖动行的距离,向左或向右移动元素。

您可以为鼠标和触摸输入定义这种拖动行为。因此,您将handleInput方法分成两部分,每一部分都在一个特定的输入类型方法中定义:

JewelGrid.prototype.handleInput = function (delta) {
    if (Touch.isTouchDevice)
        this.handleInputTouch(delta);
    else
        this.handleInputMouse(delta);
};

因为拖动行为是特定于宝石果酱游戏的,所以您在JewelGrid中处理输入。让我们先来看看鼠标拖动行为。您需要检测到玩家已经开始在网格中拖动。只有当鼠标左键被按下并且玩家没有拖动时,这才是可能的。如果是这种情况,您需要确定玩家是在网格内拖动还是在网格外拖动。在后一种情况下,您不需要做任何事情。在前一种情况下,您需要存储一些与播放器拖动位置相关的信息。以下是完整的代码:

if (Mouse.left.down && !this.dragging) {
    var rect = new Rectangle(this.worldPosition.x, this.worldPosition.y, this.columns * this.cellHeight, this.rows * this.cellWidth);
    if (Mouse.containsMouseDown(rect)) {
        this.dragging = true;
        this._dragRow = Math.floor((Mouse.position.y - this.worldPosition.y) / this.cellHeight);
        this._draggingLastX = Mouse.position.x - this.worldPosition.x;
    }
}

您使用dragging变量来跟踪玩家是否在拖动。如果玩家已经开始拖动,您计算玩家正在拖动哪一行,并将其存储在_dragRow成员变量中。最后,您计算鼠标当前拖动的网格中的本地 x 位置。当你根据玩家拖动的多少重新放置所有的宝石时,这将是有用的。

接下来检查第二种情况,玩家没有拖动。如果是这种情况,您将dragging变量设置为false:

if (!Mouse.left.down) {
    this.dragging = false;
}

现在,您已经执行了准备步骤来确定玩家是否在拖动,如果玩家确实在拖动,您需要采取行动。第一步是根据玩家向左或向右拖动了多少来重新定位宝石。计算鼠标的新位置:

var newpos = Mouse.position.x - this.worldPosition.x;

然后通过将新位置和最后拖动位置之间的差值加到每个宝石的x-坐标上,重新定位该行中的每个宝石:

for (var i = 0; i < this.columns; i++) {
    var currObj = this.at(i, this._dragRow);
    currObj.position.x += (newpos - this._draggingLastX);
}

现在您检查是否需要向左或向右移动一行。首先检查最左边的对象是否被向左拖动了超过单元格宽度的一半。拖动时检查newpos是否小于最后一个x-位置,可以判断玩家是否向左拖动。如果是这种情况,则将该行向左移动:

var firstObj = this.at(0, this._dragRow);
if (firstObj.position.x < -this.cellWidth / 2 && newpos - this._draggingLastX < 0)
    this.shiftRowLeft(this._dragRow);

类似地,检查最右边的对象是否向右拖动了超过一半的单元格宽度。如果是这种情况,您将该行向右移动:

var lastObj = this.at(this.columns - 1, this._dragRow);
if (lastObj.position.x > (this.columns - 1) * this.cellWidth + this.cellWidth / 2 &&
    newpos - this._draggingLastX > 0)
    this.shiftRowRight(this._dragRow);

最后,更新最后一个拖动位置,使其包含新计算的位置。这样,您可以在下一次调用update时执行相同的拖动和移动操作:

this._draggingLastX = newpos;

处理触摸拖动的方式非常类似于鼠标拖动。你需要做一些额外的行政工作。首先,您需要跟踪当前在网格中拖动的手指。当玩家开始拖动时,将属于该手指的触摸指数存储在成员变量中:

this._touchIndex = Touch.getIndexInRect(rect);

然后,您可以使用存储的触摸指数来检索手指的位置:

pos = Touch.getPosition(this._touchIndex);

然后你做同样的事情来处理鼠标拖动。有关处理触摸和鼠标拖动的完整代码,请参见 JewelJam3 示例中的JewelGrid类。

创建游戏对象

既然您已经定义了所有这些不同类型的游戏对象,您可以将它们创建为游戏世界的一部分。您已经简要了解了如何做到这一点。首先你添加一个背景图片:

this.add(new SpriteGameObject(sprites.background, ID.layer_background));

然后创建一个网格来包含宝石:

var rows = 10, columns = 5;
var grid = new JewelGrid(rows, columns, ID.layer_objects);
grid.position = new Vector2(85, 150);
grid.cellWidth = 85;
grid.cellHeight = 85;
this.add(grid);

最后,使用一个for循环用Jewel对象填充网格:

for (var i = 0; i < rows * columns; i++) {
    grid.add(new Jewel());
}

由于您创建的游戏对象的层次结构,游戏循环调用会自动传播到层次结构中的所有游戏对象。由于这种层次结构,您可以做的另一件好事是修改父对象的位置,之后子对象会相应地自动移动。例如,尝试将grid对象放在另一个位置:

grid.position = new Vector2(300, 100);

这一变化的结果如图 14-2 中的所示。正如您所看到的,所有的子对象都被移动了,行拖动机制和以前一样工作。在这里,你可以看到在这样一个层次中放置游戏对象的真正力量:你可以很好地控制对象在屏幕上的放置方式。你甚至可以完全疯狂,给网格一个速度,让它在屏幕上移动!

9781430265382_Fig14-02.jpg

图 14-2 。将比赛场地移动到另一个位置

你学到了什么

在本章中,您学习了:

  • 如何在场景图中组织游戏对象
  • 如何创建游戏对象的结构化集合,比如网格和列表
  • 本地和全球位置之间的差异
  • 如何利用场景图绘制和更新游戏对象,使场景图成为游戏不可分割的一部分
  • 如何定义鼠标和触摸输入的拖动行为

十五、游戏编程

这一章研究了宝石果酱游戏的游戏性编程。首先,它谈到了游戏对象之间的互动。然后,它介绍了几个新的游戏对象,在游戏中使用。最后,你处理这个游戏的主要游戏元素:找到宝石的组合并正确处理它们。

游戏对象之间的交互

本节着眼于游戏对象如何相互通信。在游戏对象可以做到这一点之前,你首先需要一种方法让这些对象找到彼此的

前一章将游戏世界描述为游戏对象的层级结构。这些游戏对象中的每一个都可以处理用户输入,并且可以表现出某种行为。例如,宝石网格游戏对象检查玩家是否拖动了网格中的一行,并在需要时执行行移动操作。这是一个典型的例子,说明游戏中的游戏对象是如何设计的。它们处理来自玩家的输入,并对其作出反应,这反过来会影响其他游戏对象(例如宝石在网格上的位置)。在 Painter 游戏中,您看到球和颜料罐以类似的方式相互作用。在更复杂的游戏中,许多不同的游戏对象相互交互。

挑战在于,这些游戏对象是如何找到彼此的?在画家游戏的情况下,PainterGameWorld类 具有引用每个游戏对象的成员变量。这不是一个很好的解决方案,因为它让游戏世界完全依赖于它所在的游戏。在 Jewel Jam 游戏世界中,您只有一个GameObject实例的层次结构,这使得查找特定的游戏对象变得更加复杂。

你应该以这样一种方式设计你的类,使得它们可以在许多不同的游戏中使用。在游戏《宝石果酱》的上一个版本中,游戏对象之间的交互非常少,所以你可以通过简单的设计逃脱。这已经行不通了。例如,宝石网格需要能够找到代表玩家分数的对象,以便当玩家组合出有效的宝石时,它可以更新这个分数。网格还需要找到移动的宝石车,以便在需要时可以将宝石车移回左侧。要做到这些,你需要一种方法在游戏世界中找到对象,而不使游戏世界依赖于特定于游戏的代码。

给游戏对象分配标识符

当你想找到游戏对象时,给它们分配标识符会很有帮助。 不是所有的对象都需要标识符;一般来说,只有那些与其他物体相互作用的物体才会这样。您可以使用之前用于图层的相同类型的标识符。例如,在这里您可以看到一些对宝石果酱游戏有用的标识符:

ID.title = 1;
ID.help_frame = 2;
ID.jewel_cart = 3;
ID.grid = 4;
ID.game_over = 5;
ID.score = 6;
ID.double_timer = 7;
ID.triple_timer = 8;

你需要做的就是扩展游戏对象类,这样你就可以给游戏对象分配一个标识符。先说GameObject类。在这个类中,您添加了一个成员变量来存储一个标识符,并向GameObject构造函数方法添加了一个参数,这样您就可以在创建对象时将 ID 与对象相关联:

function GameObject(layer, id) {
    this.layer = typeof layer !== 'undefined' ? layer : 0;
    this.id = typeof id !== 'undefined' ? id : 0;
    this.parent = null;
    this.position = Vector2.zero;
    this.velocity = Vector2.zero;
    this._visible = true;
}

因为不是所有的对象都需要一个 ID(或一个层),所以如果没有定义参数,就给这些变量分配默认值。

因为所有游戏对象都继承自GameObject类,所以它们也有一个标识符。在许多情况下,您必须更新GameObject子类的构造函数,以便它们将标识符传递给基类(GameObject)的构造函数。例如,更新后的SpriteGameObject构造函数如下:

function SpriteGameObject(sprite, layer, id) {
    GameObject.call(this, layer, id);
    this.sprite = sprite;
    this.origin = Vector2.zero;
}

大多数GameObject子类都是这样更新的。看一下JewelJam4的例子,看看所有不同的游戏对象类型是如何做到这一点的。

寻找游戏对象

尽管给游戏对象分配标识符可能是个好主意,但只有当你也提供一种方法让找到这些游戏对象时,它才是有用的。 为了了解如何做到这一点,让我们向GameObjectList类添加一个方法find,该方法查看游戏对象列表,以查看它们中是否有任何一个具有所请求的标识符。如果找到游戏对象,则该方法返回对该游戏对象的引用;否则,它返回null。该方法的标题如下:

GameObjectList.prototype.find = function (id)

现在你唯一要做的就是编写算法,它检查列表中的游戏对象,如果列表中包含匹配标识符的游戏对象,就返回它。您可以使用一条for指令来做这件事(尽管您也可以使用一条while指令来做同样的事情)。在for循环中,检查当前游戏对象的标识符是否与作为参数传递给方法的请求标识符相匹配。如果是,则返回该对象。如果你没有从for循环体中的方法返回,这意味着列表中没有一个游戏对象有请求的 ID,所以方法返回null。然后,find方法的主体变成了

for (var i = 0, l = this._gameObjects.length; i < l; i++) {
    if (this._gameObjects[i].id === id)
        return this._gameObjects[i];
}
return null;

注意,一旦执行了return指令,就立即从该方法返回。这意味着剩余的游戏对象不再被检查。此外,你不检查游戏对象是否有重复的 id。如果多个游戏对象具有相同的 ID,这个方法返回它找到的第一个。

递归

有一件事你没有考虑到。当然,列表中的一个或多个游戏对象本身也可能是类型GameObjectList。如果该游戏对象包含一个具有您所寻找的 ID 的游戏对象,那么上一节中的方法将找不到它,因为该方法只检查存储在当前对象列表中的游戏对象(this)。你如何解决这个问题?首先你需要检查一个对象是否是某种类型的实例。为此,您可以使用instanceof关键字:

if (someObject instanceof GameObjectList)
   // do something

instanceof关键字之前,你把要检查的对象;在关键字后面输入。如果对象是给定的类型,那么表达式产生true。如果没有,结果就是false。因此,您可以像前面的例子一样在if指令中使用它。如果你知道这个对象属于GameObjectList类型,你可以试着在这个对象代表的游戏对象列表中找到你要找的游戏对象。下面的代码正是这样做的:

for (var i = 0, l = this._gameObjects.length; i < l; ++i) {
    if (this._gameObjects[i].id === id)
        return this._gameObjects[i];
    if (this._gameObjects[i] instanceof GameObjectList) {
        var list = this._gameObjects[i]._gameObjects;
        for (var i2 = 0, l2 = list.length; i2 < l2; ++i) {
            if (list[i2].id === id)
                return list[i2];
        }
    }
}
return null;

因此,现在您检查每个游戏对象,以确定它是否属于类型GameObjectList。如果是这样,就遍历列表中的 _ gameObjects变量,在其中寻找游戏对象。

你现在完成了吗?不完全是。如果list中的一个游戏对象是GameObjectList类型的怎么办?这意味着你必须添加另一层来检查列表中的某个游戏对象是否可能对应于你正在寻找的 ID。但是其中一个游戏对象也可以是GameObjectList类型。显然,这种方法并不理想。但是,您可以做些事情来避免这种无限搜索问题。为什么不再用find的方法?请看下面的代码:

for (var i = 0, l = this._gameObjects.length; i < l; ++i) {
    if (this._gameObjects[i].id === id)
        return this._gameObjects[i];
    if (this._gameObjects[i] instanceof GameObjectList) {
        var obj = this._gameObjects[i].find(id);
        if (obj !== null)
            return obj;
    }
}
return null;

这段代码看起来可能有点奇怪。你实际上是在调用你正在编写的方法。为什么会这样?想想当对一个对象调用find方法时会发生什么。如果你正在寻找的游戏对象在列表中,那么这个方法返回那个对象。此外,该方法对同样属于GameObjectList类型的每个对象调用find方法。如果这些方法调用都没有找到对象,方法返回null。并且每个find方法调用也调用属于它的类型为GameObjectList的对象上的find方法。这个find方法调用的大树在你到达游戏对象层级的底部时结束。换句话说,在某个时候不再有列表:只有游戏对象。然后所有的find方法调用的结果(每个调用要么是null要么是一个游戏对象)通过返回值被发送回来。最后,该方法的第一个调用者获取对象(如果在树中的某个地方找到了它),或者如果没有找到携带所请求 ID 的对象,则获取null。这种搜索策略也被称为深度优先,因为您在检查列表中的其余对象之前调用子对象上的find方法。

当一个方法调用自己时,这被称为递归。递归是一个非常强大的工具,因为它允许你执行这些复杂的搜索算法,而不必写很多代码。然而,要注意递归,因为你可能会写一个无限调用自己的方法。假设您想使用递归计算两个(正)整数的乘积,方法是将它们相加:

function product(a, b) {
    return b + product(a-1, b);
}

这段代码没有检查如果a等于 0,乘积是否应该返回 0。因此,该方法无限地调用自己,导致一个无限循环,类似于如果您忘记在while指令中递增计数器会发生的情况。当然,这种递归方法的正确版本是

function product(a, b) {
    if (a === 0)
        return 0;
    else
        return b + product(a-1, b);
}

关键是递归方法应该在某个地方有一个终止条件,这样在某些情况下,方法不会调用自己,而是做其他事情。在这个例子中,终止条件是a === 0。如果发生这种情况,该方法不会调用自身,而是返回 0(这是正确的,因为任何数字乘以 0 都会得到 0)。

进入游戏世界

虽然您可以在一个GameObjectList实例中寻找一个具有特定 ID 的游戏对象,但是您需要访问到代表游戏世界的对象。引用该对象的变量是Game类的成员变量。所以,每当你想寻找一个特定的对象时,你可以在Game.gameWorld对象上调用find方法,因为该对象也继承自GameObjectList。虽然你在画家和宝石果酱游戏中都只有一个单一的游戏世界,但对于更复杂的游戏来说肯定不是这样。游戏中的每个关卡都可以是一个独立的游戏世界。甚至一个关卡可以包含几个不同的游戏世界。所以,明智的做法是做好准备,这样你写的类在这些情况下也会有用。

为了做到这一点,您依赖于编码到泛型GameObject类中的父子关系。对于每一个游戏对象,你可以假设如果你到达了游戏对象所属的层级的根,这个根就是那个特定游戏对象的游戏世界。因此,您可以通过遍历父列表来找到根,从而轻松检索游戏世界。您将一个名为root的属性添加到GameObject类中来完成这项工作,它依赖于递归:

Object.defineProperty(GameObject.prototype, "root",
    {
        get: function () {
            if (this.parent === null)
                return this;
            else
                return this.parent.root;
        }
    });

属性中的代码非常简单。如果你的当前父级不是null(意味着你有一个父级),你向那个父级请求根游戏对象。如果父对象是null,这意味着你当前操作的游戏对象是层次的根,这意味着它是游戏世界。在这种情况下,您返回当前对象。

既然您已经为游戏对象创建了一种找到彼此的简单方法,接下来的部分将介绍宝石果酱游戏所需的几种游戏对象类型。

宝石班

为了让Jewel类更好地为游戏做准备,你需要改变这个类中的一些东西。最大的变化是,你想引入更多种类的宝石,这个对象可以代表。基本上,有三种变化:宝石的形状可以变化,宝石的颜色可以变化,宝石的数量可以变化(一个、两个或三个宝石)。所以,宝石可以有三种属性(形状、颜色和数量)。同样,对于每个属性,有三种变化:三种不同的形状、三种不同的颜色和三个不同的数字。总的来说,这意味着有 3 × 3 × 3 = 27 种可能的宝石配置(参见图 15-1 )。

9781430265382_Fig15-01.jpg

图 15-1 。宝石果酱游戏 中使用的不同宝石类型概述

不用创建 27 个不同的图像文件,您可以将所有不同的种类存储在一个图像文件中(同样,参见图 15-1 中的图像)。在单个图像文件中存储多个图像有几个好处。首先,它为艺术家提供了一种将相关图像组合在一个文件中的方法;其次,就内存使用和加载速度而言,加载单个文件并绘制该文件的一部分比单独加载所有图像要高效得多。

在这种情况下,图像文件中宝石的顺序很重要,您将在后面看到。当您创建一个Jewel实例时,您通过在成员变量variation中存储一个 0 到 26 之间的随机数(覆盖 27 个品种)来随机选择要表示的宝石。因此,Jewel构造者变成了

function Jewel(layer, id) {
    SpriteGameObject.call(this, sprites.jewels, layer, id);
    this.variation = Math.floor(Math.random() * 27);
}

因为Jewel类知道它将使用哪个 sprite,所以您将 sprite ( sprites.jewels)直接传递给基类构造函数。

现在唯一需要修改的就是draw方法。你不想画整个精灵,而只是它的一部分:包含这个对象代表的宝石的部分。为此,首先必须扩展Canvas2D.drawImage方法,因为它需要能够只绘制精灵的一部分。幸运的是,这很容易做到。HTML5 canvas 元素的drawImage方法有四个参数 ,用它们可以指定想要绘制的 sprite 部分。要指出你想画精灵的哪一部分,你可以使用一个Rectangle对象。Canvas2D.drawImage方法的头如下:

Canvas2D_Singleton.prototype.drawImage = function (sprite, position, rotation,
    scale, origin, sourceRect)

该方法的最后一个参数是矩形,它定义了应该绘制源图像的哪一部分。在方法体中,使用这个Rectangle对象的成员变量如下:

this._canvasContext.drawImage(sprite, sourceRect.x, sourceRect.y,
    sourceRect.width, sourceRect.height, -origin.x * scale, -origin.y * scale,
    sourceRect.width * scale, sourceRect.height * scale);

第二到第五个参数表示应该绘制图像的哪一部分。最后两个参数表示图像在画布上投影的宽度和高度。您将矩形的宽度和高度乘以缩放比例,以便用户可以根据需要缩放所绘制的图像。因为您正在缩放精灵,所以您还需要缩放原点,这发生在第三行代码中。

Jewel类的draw方法中,您需要根据variation成员变量的值来确定您想要绘制宝石精灵的哪一部分:

var imagePart = new Rectangle(this.variation * this.height, 0, this.height,
    this.height);

这里你要画的线段是一个正方形,它的宽度和高度与原始精灵的高度相同。矩形的位置(由Rectangle构造函数中的前两个参数给出)通过将 sprite 的高度乘以变化指数来计算。因此,变化指数越高,矩形越向右移动。最后,调用Canvas2D.drawImage方法在屏幕上绘制宝石。下面是完整的draw方法:

Jewel.prototype.draw = function () {
    if (!this.visible)
        return;
    var imagePart = new Rectangle(this.variation * this.height, 0,
        this.height, this.height);
    Canvas2D.drawImage(this.sprite, this.worldPosition, 0, 1, this.origin,
        imagePart);
};

保持当前分数

让这个游戏变得更有趣的下一步是添加一些游戏对象,这些对象与游戏的玩法以及奖励如何给予玩家有关。在这个游戏中,你将给予玩家的奖励表示为点数:分数。玩家每找到一个有效的宝石组合,就获得 10 分。这个当前分数应该存储在一个变量或对象中。同样,分数应该写在屏幕上,这样玩家就知道他们得了多少分。

现在,您看到了另一个优点,即不需要特别假设每个游戏对象都由一个精灵来表示。游戏对象使用字体在屏幕上显示自己。为了使这个更加通用,首先引入一个名为Label 的类,它只是在屏幕的某个位置写一些文本。这个类非常类似于SpriteGameObject类,除了你在屏幕上绘制文本而不是精灵。为此,您需要存储要书写的文本和使用的字体。您还可以存储其他文本属性,例如文本的对齐方式和字体大小。下面是Label的构造器:

function Label(fontname, fontsize, layer, id) {
    GameObject.call(this, layer, id);

    this.color = Color.black;
    this.origin = Vector2.zero;
    this._fontname = typeof fontname !== 'undefined' ?
        fontname : "Courier New";
    this._fontsize = typeof fontsize !== 'undefined' ? fontsize : "20px";
    this._contents = "";
    this._align = "left";
    this._size = Vector2.zero;
}

您在构造函数中赋值的最后一个成员变量是_size。了解文本的大小(以像素为单位的高度和宽度)对于在正确的位置绘制文本非常有用。因为只有在知道文本是什么的情况下才能计算出文本的大小,所以最初将这个值设置为Vector2.zero

你如何计算文字的大小?在 HTML 中没有一种简单的方法可以做到这一点。这段代码使用了一个简单的技巧:动态地将文本添加到 HTML 页面,在绘制时计算它的大小,然后再次移除它。下面是为您完成这项工作的函数:

function calculateTextSize(fontname, fontsize, text) {
    var div = document.createElement("div");
    div.style.position = "absolute";
    div.style.left = -1000;
    div.style.top = -1000;
    document.body.appendChild(div);
    text = typeof text !== 'undefined' ? text : "M";
    div.style.fontSize = "" + fontsize;
    div.style.fontFamily = fontname;
    div.innerHTML = text;
    var size = new Vector2(div.offsetWidth, div.offsetHeight);
    document.body.removeChild(div);
    return size;
}

首先创建一个div元素,在其中放置文本。您将元素的位置设置为(-1000,1000),以便将其绘制在屏幕之外。放置文本并计算其大小,然后存储在一个Vector2对象中。该对象作为该函数的结果返回。虽然这不是解决问题的好方法,但是它很容易做到,而且效果很好。当你编程时,有时这些快速而肮脏的方法是可以接受的,但是要小心不要总是使用它们。在这种情况下,您别无选择,因为在 HTML 中没有替代方法。

Label类中,当文本内容被设置时,你计算文本的大小。这是在text属性中完成的:

Object.defineProperty(Label.prototype, "text",
    {
        get: function () {
            return this._contents;
        },
        set: function (value) {
            this._contents = value;
            this._size = calculateTextSize(this._fontname, this._fontsize,
                value);
        }
    });

为了在屏幕上绘制文本,Label类还需要一个draw方法。在屏幕上绘制文本是通过调用Canvas2D.drawText方法完成的。绘制文本时使用文本标签的世界位置,这样基于文本的游戏对象也可以成为层次的一部分。下面是完整的draw方法:

Label.prototype.draw = function () {
    if (!this.visible)
        return;
    Canvas2D.drawText(this._contents, this.worldPosition, this.origin,
        this.color, this._align, this._fontname, this._fontsize);
};

现在您定义了一个继承自Label类的ScoreGameObject类。分数由文本内容表示。好的一点是 JavaScript 在画图的时候会自动把一个整数转换成文本。因此,您只需将文本设置为 0:

function ScoreGameObject(fontName, fontSize, layer, id) {
    Label.call(this, fontName, fontSize, layer, id);
    this.text = 0;
    this._align = "right";
}
ScoreGameObject.prototype = Object.create(Label.prototype);

您还添加了一个score属性,允许您检索或修改当前分数:

Object.defineProperty(ScoreGameObject.prototype, "score",
    {
        get: function () {
            return this._contents;
        },
        set: function (value) {
            if (value >=0)
                this.text = value;
        }
    });

最后,你添加一个reset方法,这样当游戏结束时,你就可以调用这个方法将分数重置为零:

ScoreGameObject.prototype.reset = function () {
    this.text = 0;
};

现在您已经有了这个类,您可以简单地创建它的一个实例并将其添加到游戏世界中。将这些指令放在JewelJamGameWorld类中,并添加一个框架覆盖图,您可以在上面绘制当前的分数。这些指令完成了所有工作:

var scoreFrame = new SpriteGameObject(sprites.frame_score, ID.layer_overlays);
scoreFrame.position = new Vector2(20, 20);
this.add(scoreFrame);

var score = new ScoreGameObject("Segoe UI Mono", "40px", ID.layer_overlays_1,
    ID.score);
score.position = new Vector2(270, 35);
score.color = Color.white;
this.add(score);

您将分数游戏对象分配给层ID.layer_overlays,因此它被绘制在背景和分数框的顶部。您还可以为框架和乐谱选择适当的位置。最后,您为score游戏对象分配一个 ID ID.score,以便其他对象可以在需要时检索它。

一辆移动的宝石车

为了让游戏更刺激,你可以给玩家增加一种压力感。在宝石果酱游戏中,这是通过绘制一辆缓慢离开屏幕的宝石车来完成的。一旦宝石车在屏幕之外,游戏就结束了。每当玩家找到一个正确的宝石组合时,宝石车就会向后移动一点。

宝石车出现在JewelCart类中。你在里面定义了一些东西。首先,您定义当玩家找到正确的组合时,宝石车应该向后移动多少。这存储在push成员变量中。您还需要设置一个最小的 x 位置,这样宝石车就不会被拖过游戏场地。你可以在minxpos变量中这样做。你添加一个pushCart方法,当玩家找到正确的宝石组合时可以调用。因为这个类已经继承了SpriteGameObject的大部分特性,所以这个类相当小。请看一下JewelJam4的例子,看看这个类的代码。

将该类的一个实例添加到游戏世界,如下所示:

var jewelCart = new JewelCart(sprites.jewelcart, ID.layer_objects, ID.jewel_cart);
jewelCart.position = new Vector2(410, 230);
jewelCart.minxpos = 410;
this.add(jewelCart);

jewel cart 对象也有一个 ID,这样以后需要推送时就可以找到它。您还可以将其位置和最小 x 位置设置为适当的值。如果您查看JewelCart.js文件,您会看到您给购物车分配了一个正的x-速度。因为JewelCartSpriteGameObject的子类,而后者又是GameObject的子类,update方法根据它的速度更新购物车的位置(假设这个方法是从其他地方调用的)。图 15-2 显示了带有新游戏对象(宝石、分数和宝石车)的游戏截图。

9781430265382_Fig15-02.jpg

图 15-2 。JewelJam4 示例的屏幕截图

处理宝石组合

每当玩家通过拖动行在中间一列构建了一个有效的组合时,游戏检查该组合是否有效。如果是这样,游戏会增加分数,将宝石车推回,从格子中移除宝石组合,并添加新的宝石。JewelGrid类有一个处理行拖动的handleInput方法。在该类的update方法中,你需要确定玩家是否做出了有效的宝石组合。

查找有效组合

update方法中,您需要检查中间一列中所有三颗相邻宝石的组合,看它们是否形成有效的组合。为了帮助实现这一点,您添加了一个方法isValidCombination,它有如下的头:

JewelGrid.prototype.isValidCombination = function (a, b, c)

这个方法接受三个Jewel对象,它返回一个布尔值,表明这三个宝石是否形成一个有效的组合。所以现在的问题是,你如何评估三个宝石是否形成有效的组合?回想一下,有效的组合意味着每个属性(颜色、形状、数量),所有三个宝石应该具有相同或不同的价值。为了让事情变得简单一点,让我们用从 0 到 2 的三个整数对每个宝石进行编码。假设第一个整数代表颜色(黄色、蓝色或红色),第二个整数代表形状(菱形、椭圆形或圆形),最后一个整数代表宝石的数量(一个、两个或三个)。例如,使用这种编码方案,您可以将蓝色椭圆形单颗宝石编码为(1,0,0)。黄色圆形单宝石定义为(0,2,0),红色椭圆形三宝石定义为(2,1,2)。

注意人类可能会试图以不同的方式找到有效的宝石组合,只需简单地查看刚刚移动的那一行以及上面和下面的那一行。计算机可以在主循环中运行定期检查,以检查中间一列中三个的所有组合,即使玩家没有碰它,就像这里所做的那样。计算机可以做到这一点,所以程序员这样写;但是如果你是编程新手,你可能需要一段时间来适应这种思维方式。

现在让我们看看您是否可以使用这种编码方案来找到三个宝石的有效组合(让我们称它们为宝石 A、B 和 C)。对于每一颗宝石,你必须比较颜色、形状和数量。这些属性中的每一个要么对所有的宝石都相同,要么对所有的宝石都不同。例如,如果 A 的颜色编码值为 0,B 的值为 0,C 的值也为 0,则该条件适用于该颜色,因为所有三种宝石都具有相同的颜色(黄色)。如果宝石都是蓝色(A 色= 1,B 色= 1,C 色= 1)或红色(A 色= 2,B 色= 2,C 色= 2),情况也是如此。最后,如果所有颜色都不相同,则条件成立:A 色、B 色和 C 色的顺序是 0、1 和 2。如果你观察这些不同组合的总和,你会看到一个有趣的性质:0 + 0 + 0 = 0,1 + 1 + 1 = 3,2 + 2 + 2 = 6,0 + 1 + 2 = 3。换句话说:总和能被三整除。此外,任何其他可能的值组合都是而不是能被 3 整除。所以你可以说,对于每个属性(颜色、形状、数量),每个宝石的编码值之和一定能被三整除。如果这个总和由变量sum表示,那么在 JavaScript 代码中,条件sum % 3 === 0必须成立!所以,如果你计算每个财产的总和,并确定它能被三整除,你就找到了三个宝石的有效组合。如您所见,有时基础数学对于编写高效代码非常有用。在这种情况下,另一种选择是编写大量处理不同情况的if指令,这肯定会导致代码变慢。

剩下唯一要做的就是从每颗宝石上检索编码。到目前为止,您只有一个数字:精灵中宝石的偏移量。这是一个从 0 到 26 的数字。如果你再看一遍图 15-1 ,你会看到前面九颗宝石是黄色的,后面九颗是蓝色的,最后九颗是红色的。因此,如果将variation变量除以 9,就会得到一个介于 0 和 2 之间的值,该值代表颜色!除法的其余部分是一个从 0 到 8 的数。如果你把这个数除以 3,你会得到一个从 0 到 2 的数来代表这个形状。除法的其余部分是一个从 0 到 2 的数字,它代表宝石的数量。通过使用这个概念,您可以构造一个算法来计算每个属性的这些值,并检查这些属性的总和是否能被 3 整除。请看下面的算法:

var curra = a.variation;
var currb = b.variation;
var currc = c.variation;
var divider = 9;

for (var i = 0; i < 3; i += 1) {
    if ((Math.floor(curra / divider) + Math.floor(currb / divider)
         + Math.floor(currc / divider)) % 3 !== 0)
        return false;
    curra = curra % divider;
    currb = currb % divider;
    currc = currc % divider;
    divider = Math.floor(divider / 3);
}
return true;

首先,使用每个宝石的variation成员变量,检索代表您正在处理的宝石的值。然后定义一个等于 9 的divider数(首先除以 9)。接下来定义一条运行三次的for指令。在for指令的主体中,您设置了一个条件,即三个变化指数的总和除以divider应该能被 3 整除。如果不是这种情况,则返回false,因为组合条件对于其中一个属性不成立。将除数的剩余部分分配给包含当前变化指数的每个变量。然后,将除数除以 3。如果您退出for指令,这意味着在所有情况下if指令中的条件都是true,意味着您找到了一个有效的组合。因为找到了有效的组合,所以返回值true

从网格中移除宝石

update方法中,您现在可以使用isValidCombination方法来确定是否存在有效的组合。为此,您使用一条while指令来评估中间一列中三颗宝石的所有序列:

var middleCol = Math.floor(this._columns / 2);
var i = 0;
while (i < this._rows - 2) {
    if (this.isValidCombination(this.at(middleCol, i),
        this.at(middleCol, i + 1), this.at(middleCol, i + 2))) {
        // do something
    }
    else
        i++;
}

当您找到一个有效的组合时,您需要从网格中移除这些宝石,并插入新的宝石。为此,您定义了一个名为removeJewel的方法,它从网格中移除一个宝石并插入一个新的宝石。为了创造一个漂亮的“落下”动作,你把这些宝石放在格子上方的不同位置。您将期望的 y 位置作为参数传递给removeJewel方法 ,这样它就知道新宝石应该位于何处。完整的方法就变成了

JewelGrid.prototype.removeJewel = function (x, y, newYPosition) {
    for (var row = y; row > 0; row -= 1)
        this._gameObjects[row * this._columns + x] =
            this._gameObjects[(row - 1) * this._columns + x];
    var jewel = new Jewel();
    this.addAt(jewel, x, 0);
    jewel.position.y = newYPosition;
};

update方法中,您调用removeJewel三次来移除形成有效组合的三个宝石:

this.removeJewel(middleCol, i, -this.cellHeight);
this.removeJewel(middleCol, i + 1, -this.cellHeight * 2);
this.removeJewel(middleCol, i + 2, -this.cellHeight * 3);

随着网格的更新,宝石对象和它们在网格上的目标位置之间的位置差异导致了期望的下落效果。最后,因为引入新的宝石可能意味着有三个宝石的新的有效组合,所以使用指令i = 0;将计数器i重置为零。

更新其他游戏对象

现在网格已经更新了,您可以将注意力集中在其他需要更新的游戏对象上。第一个需要更新的游戏对象是score,因为如果你处理一个有效的组合,分数应该会增加。您使用find方法来检索 score 对象,并为分数增加 10 分,如下所示:

var score = this.root.find(ID.score);
score.score += 10;

此外,因为您找到了有效的组合,所以您推回了宝石车:

var jewelCart = this.root.find(ID.jewel_cart);
jewelCart.pushCart();

关于完整的程序,参见本章的JewelJam4示例。

你学到了什么

在本章中,您学习了:

  • 如何组织游戏对象并给它们分配 id
  • 如何编程游戏性方面和游戏对象之间的交互
  • 如何检测宝石果酱游戏中有效的宝石组合

十六、游戏状态

在前一章中,你编写了宝石果酱游戏的主要游戏元素。然而,这场游戏还远未结束。例如,当宝石车从屏幕上消失时,什么也没有发生。同样,当你启动程序时,游戏立即开始,没有任何警告。仍然需要的是一种在游戏中加入菜单和覆盖的方法,这样玩家可以改变设置、获得帮助或开始玩游戏。例如,当玩家在一个菜单屏幕中时,与游戏的交互类型与玩家在解决一个关卡或试图尽可能长时间生存时非常不同。在编写游戏程序时,你必须考虑如何将这些不同的游戏状态融合在一起,并在它们之间切换。

现代游戏有许多不同的游戏状态,比如菜单、地图、清单、闪屏、介绍电影等等。这一章展示了如何给宝石果酱游戏添加不同的游戏状态。因为这个游戏还不是很复杂,所以您可以对当前的类进行一些简单的扩展。然而,如果你想构建一个商业游戏,游戏状态管理需要被恰当地处理。在第二十章中,这本书讨论了一个使用类的软件设计,这些类能够以一种非常好的通用方式处理游戏状态。

添加标题屏幕

为了让游戏更加完整,你可以做的第一件事就是添加一个标题画面。标题屏幕允许玩家做好玩游戏的准备,而不是立即进入游戏。您可以扩展JewelJamGameWorld类,这样它就可以加载并显示由单个图像组成的标题屏幕。您为此创建了一个SpriteGameObject实例,为其分配 ID ID.title,并将其添加到游戏世界中:

var titleScreen = new SpriteGameObject(sprites.title, ID.layer_overlays_2,
    ID.title);
this.add(titleScreen);

你分配图层ID.layer_overlays_2,这样你可以确保标题被绘制在所有东西的上面。但是您必须做一些额外的工作来正确处理输入和更新游戏世界,因为您希望游戏只在标题屏幕不再可见时才开始。你可以通过给handleInput方法添加一些指令来区分两种状态:显示标题屏幕的状态和玩游戏的状态:

var titleScreen = this.root.find(ID.title);
if (titleScreen.visible) {
    if (Mouse.left.pressed || Touch.isPressing)
        titleScreen.visible = false;
    return;
}
GameObjectList.prototype.handleInput.call(this, delta);

查看if指令,你可以看到如果标题屏幕可见,你只有在玩家按下鼠标左键或触摸屏幕时才会做出反应。在这种情况下,您将标题屏幕的可见性标志设置为false,这样它就不再被绘制了。在此之后,您从方法返回,因此每当标题屏幕可见时,游戏唯一的反应是玩家按下鼠标左键或触摸屏幕。如果标题屏幕不可见,您可以对游戏世界中的所有游戏对象调用handleInput方法:换句话说,当玩家在玩游戏时,游戏会对玩家做出应有的反应。

对于update方法,你遵循非常相同的过程,只有当标题不可见时,你才更新游戏世界:

var titleScreen = this.root.find(ID.title);
if (titleScreen.visible)
    return;
GameObjectList.prototype.update.call(this, delta);

当玩家开始游戏时,他们现在会在游戏开始前看到一个标题屏幕(见图 16-1 )。你还没说完。在下一节中,您将添加一个简单的按钮 GUI 元素来显示一个帮助框架。

9781430265382_Fig16-01.jpg

图 16-1 。宝石果酱标题屏幕

添加按钮以显示帮助框

本节解释了如何在游戏中添加一个按钮,用来显示帮助框。为此,您需要在程序中添加另一个类Button。您继承了SpriteGameObject类,并添加了一些简单的行为来检查玩家是否按下了按钮。在Button类中,你声明了一个布尔成员变量来指示按钮是否被按下。然后你覆盖handleInput方法来检查玩家是否点击了鼠标左键或者触摸了屏幕上的按钮。如果当时鼠标或触摸位置在 sprite 的边界内,您知道玩家已经按下按钮,并且您将成员变量的值设置为true。如何检查鼠标位置是否在 sprite 的边界内?通过使用Rectangle类。第一步,构建一个包含精灵的Rectangle对象。这个矩形的位置应该是精灵的世界位置减去它的原点。矩形的宽度和高度与精灵的宽度和高度相同。为了方便起见,让我们给SpriteGameObject添加一个名为boundingBox的属性,它会为您计算这个矩形:

Object.defineProperty(SpriteGameObject.prototype, "boundingBox",
    {
        get: function () {
            var leftTop = this.worldPosition.subtractFrom(this.origin);
            return new Rectangle(leftTop.x, leftTop.y, this.width,
                this.height);
        }
    });

您必须在这里使用世界位置,因为您想要检查鼠标指针或触摸是否在 sprite 的实际世界位置上。您添加一个简单的方法containsRectangle,它检查一个点是否在矩形内:

Rectangle.prototype.contains = function (v) {
    v = typeof v !== 'undefined' ? v : new Vector2();
    return (v.x >=this.left && v.x <= this.right &&
        v.y >=this.top && v.y <= this.bottom);
};

这段代码的第一行检查参数v是否已经定义。如果没有,你只需要给它赋一个零矢量。然后检查 x 值是否位于矩形的左右两边,以及 y 值是否位于上下两边。

您还可以向MouseTouch类添加方法,帮助您轻松解决问题。例如,这是检查玩家是否在给定的矩形内按下了鼠标左键的方法:

Mouse_Singleton.prototype.containsMousePress = function (rect) {
    return this._left.pressed && rect.contains(this._position);
};

Button类中,您使用这些方法来确定按钮是否被按下和/或按下。下面是ButtonhandleInput方法:

Button.prototype.handleInput = function (delta) {
    var boundingBox = this.boundingBox;
    this.pressed = this.visible && (Touch.containsTouchPress(boundingBox) ||
        Mouse.containsMousePress(boundingBox));
    this.down = this.visible && (Touch.containsTouch(boundingBox) ||
        Mouse.containsMouseDown(boundingBox));
};

现在你有了一个按钮类,你可以给游戏世界添加一个帮助按钮(见JewelJamGameWorld类):

this.helpButton = new Button(sprites.button_help, ID.layer_overlays);
this.helpButton.position = new Vector2(1268, 20);
this.add(this.helpButton);

因为您希望在玩家按下帮助按钮时显示一个帮助框,所以您也向游戏世界添加了一个帮助框。您将它的可见性标志设置为false,因此它还不可见:

var helpFrame = new SpriteGameObject(sprites.frame_help, ID.layer_overlays,
    ID.help_frame);
helpFrame.position = new Vector2(636, 120);
helpFrame.visible = false;
this.add(helpFrame);

现在你必须确保当玩家按下帮助按钮时,帮助框的可见性被切换。您可以在JewelJamGameWorld类的handleInput方法中使用下面的if指令来实现这一点:

var helpFrame = this.root.find(ID.help_frame);
if (this.helpButton.pressed) {
    helpFrame.visible = !helpFrame.visible;
}

请注意,if正文中的指令是一个开关。基本上是一种更短的写法。

if (helpFrame.visible)
    helpFrame.visible = false;
else
    helpFrame.visible = true;

当帮助框可见时,您希望能够通过按下鼠标左键或触摸屏幕来移除它。所以,最后的if指令稍微复杂一些:

var helpFrame = this.root.find(ID.help_frame);
if (this.helpButton.pressed ||
    (helpFrame.visible && (Mouse.left.pressed || Touch.isPressing))) {
    helpFrame.visible = !helpFrame.visible;
}

最后,你必须确保游戏在显示帮助框时不会更新。只有当帮助框不可见时,你才可以通过更新游戏对象在update方法中做到这一点:

var helpFrame = this.root.find(ID.help_frame);
if (!helpFrame.visible)
    GameObjectList.prototype.update.call(this, delta);

还有一件事你必须注意,按下帮助按钮,玩家暂停游戏。如果你一直显示宝石格子,这为玩家提供了一种方法来获得额外的时间来尝试寻找宝石组合。当然,这不是你想要的!幸运的是,在目前的阶级结构中,这是非常容易解决的。将下面两行添加到update方法中:

var grid = this.root.find(ID.grid);
grid.visible = !helpFrame.visible;

这些行确保无论何时帮助框架可见,网格不可见,反之亦然。图 16-2 显示了显示帮助框时的游戏。

9781430265382_Fig16-02.jpg

图 16-2 。游戏中帮助框显示截图

遮掩

向玩家展示信息的一种非常常见的方式是使用覆盖图。覆盖图基本上是图像,可以显示在游戏世界的顶部,以呈现信息或提供用户界面,如菜单、小地图、状态信息等。上一节中介绍的帮助框是覆盖的另一个例子。

覆盖图可以呈现全新的游戏状态(例如“游戏结束”覆盖图),或者它们可以通过向玩家提供信息来补充游戏世界。例如,许多策略游戏提供关于选择的单位数量、可用资源、正在进行的建造过程、收集的物品等信息。这种覆盖通常总是在屏幕上,它们一起被称为平视显示器 (HUD)。 Jewel Jam 有一个非常基本的 HUD:它由一个显示当前分数的框架和一个帮助按钮组成,用户可以按下该按钮来查看带有帮助信息的框架。

在 HUD 旁边,当宝石车移出屏幕时,您想要显示一个“游戏结束”覆盖图。您将这个覆盖图添加到游戏世界,并将其可见性设置为false:

var gameOver = new SpriteGameObject(sprites.gameover, ID.layer_overlays_1,
    ID.game_over);
gameOver.visible = false;
gameOver.position = gameOver.screenCenter;
this.add(gameOver);

您希望将覆盖图很好地放置在屏幕的中间。因为这是您可能会经常使用的东西,所以让我们给SpriteGameObject添加一些有用的属性来处理它。首先,如果您想在屏幕中央绘制 sprite,可以添加一个计算 sprite 位置的属性。一旦你知道了屏幕的大小,精灵的大小和它的来源,这就相对容易计算了。以下是完整的属性:

Object.defineProperty(SpriteGameObject.prototype, "screenCenter",
    {
        get: function () {
           return Game.size.subtract(this.size).divideBy(2).addTo(this.origin);
        }
    });

特别是在 HUD 元素的情况下,你有时不想在中间显示一个 sprite 相反,例如,您可能希望它位于屏幕底部的中间。为了适应这种情况,您可以将以下两个属性添加到SpriteGameObject :

Object.defineProperty(SpriteGameObject.prototype, "screenCenterX",
    {
        get: function () {
            return (Game.size.x - this.width) / 2 + this.origin.x;
        }
    });
Object.defineProperty(SpriteGameObject.prototype, "screenCenterY",
    {
        get: function () {
            return (Game.size.y - this.height) / 2 + this.origin.y;
        }
    });

现在让我们扩展JewelJamGameWorld以在需要时显示“游戏结束”覆盖图。首先,添加一个方法来检查宝石车是否在屏幕之外:

JewelJamGameWorld.prototype.gameOver = function () {
    var jewelCart = this.root.find(ID.jewel_cart);
    return jewelCart.position.x > Game.size.x;
};

然后你可以在handleInput方法中使用这个方法,这样玩家就可以按下鼠标左键或者触摸屏幕来重新开始游戏:

if (this.gameOver()) {
    if (Mouse.left.pressed || Touch.isPressing)
        this.reset();
    return;
}

您覆盖了reset方法,因为您需要在游戏重启时做一点额外的工作。值得注意的是,你必须将一些覆盖图的可见性设置为false,这样当游戏重启时它们就不会显示在屏幕上。除此之外,您只需从基类中调用reset方法,这样游戏世界中的所有游戏对象都会被重置:

JewelJamGameWorld.prototype.reset = function () {
    GameObjectList.prototype.reset.call(this);
    var gameOver = this.root.find(ID.game_over);
    gameOver.visible = false;
    var titleScreen = this.root.find(ID.title);
    titleScreen.visible = false;
    var helpFrame = this.root.find(ID.help_frame);
    helpFrame.visible = false;
};

现在只剩下一件事要做了。如果游戏结束,您必须将“游戏结束”覆盖的可见性设置为true。你在JewelJamGameWorldupdate方法中这样做:

var gameOver = this.root.find(ID.game_over);

if (this.gameOver()) {
    gameOver.visible = true;
}

图 16-3 显示了“游戏结束”状态的截图。

9781430265382_Fig16-03.jpg

图 16-3 。太糟糕了…

设计游戏

虽然在游戏开发团队中,程序员通常不负责游戏的设计,但是对这个过程有一个基本的了解仍然是非常有用的。程序员必须将游戏设计转化为代码,并且必须能够向设计师建议什么可行,什么难以实现。为了合作成功,每个人都必须说同一种语言。

设计一个游戏主要包括定义游戏机制、游戏设置和游戏关卡。游戏机制包括游戏规则、玩家控制游戏的方式、目标和挑战以及奖励结构。心理学和教育科学在这里扮演着重要的角色。他们帮助你理解玩家如何进入状态(他们完全投入到游戏中的情绪);目标、挑战和奖励如何相互支持;以及如何改变和适应游戏的难度。

游戏的设定涉及故事、人物和游戏发生的虚拟世界。一个好的故事对玩家来说是一个强大的动力,在玩的时候发现这个故事是一个非常令人满意的任务。角色需要引起玩家的共鸣,从而赋予必须执行的任务以意义。游戏世界增强了这些方面,并使游戏适应特定的人口统计。

关卡设计有时由特殊的关卡设计师来完成,但在较小的团队中,这通常是游戏设计师的责任。精心的关卡设计会带来良好的学习曲线。它让玩家不断受到挑战和激励,应该会带来惊喜。

已经有很多关于游戏设计的书,强烈建议你去读一些。你也可以在像www.gamasutra.com这样的网站上找到很多关于游戏开发各个方面的信息。最后,本书第三十章和第三十一章讲的是游戏制作和出版。

你学到了什么

在本章中,您学习了:

  • 如何添加一个 HUD 和覆盖到游戏中
  • 如何定义一个显示框架的简单按钮
  • 如何处理一些不同的游戏状态,比如标题屏幕和“游戏结束”状态

十七、完成宝石果酱游戏

在这一章中,你将完成宝石果酱游戏。作为第一步,当出现三个宝石的多个组合时,你给玩家额外的分数。第二,你通过在游戏中显示宝石上的闪光来增加一个漂亮的视觉效果。最后,你添加声音和音乐。

多个组合的额外积分

当出现多个组合时,您希望给玩家额外的分数。每当玩家组合三颗宝石时,新的随机宝石会被添加到游戏场上。这些宝石可以形成新的组合。此外,宝石落下还可以形成新的组合。在有两个或三个组合的情况下,你奖励玩家额外的分数。为了做到这一点,你必须计算一个玩家找到了多少种组合。你可以在JewelGrid类中通过引入一个额外的变量nrCombis : 来做到这一点

var nrCombis = 0;

每当你找到一个有效的组合,你就增加这个变量。现在,您可以使用if指令来检查何时应该奖励额外积分:

if (nrCombis === 2) {
    score.score += 50;
}
else if (nrCombis >= 3) {
    score.score += 100;
}

您还想向玩家显示一条消息,说明他们在两次或三次组合的情况下获得了额外的分数。为此,您可以显示一个覆盖几秒钟。作为第一步,让我们加载两个覆盖图,并将它们添加到游戏世界的JewelJamGameWorld类:

var doubleOverlay = new SpriteGameObject(sprites.double, ID.layer_overlays);
doubleOverlay.position = new Vector2(800, 400);
this.add(doubleOverlay);
var tripleOverlay = new SpriteGameObject(sprites.triple, ID.layer_overlays);
tripleOverlay.position = new Vector2(800, 400);
this.add(tripleOverlay);

一旦玩家得到多种组合,你需要在屏幕上显示这个覆盖图几秒钟。为了能够做到这一点,你首先需要了解更多关于你如何在游戏中处理时间的知识。

游戏时间

时间是游戏中非常重要的概念。例如,它用于测量玩家执行任务的速度,根据物体的速度更新物体的位置,记录玩家上次击败敌人的时间,确定游戏中当前是白天还是夜晚,等等。为了适应这些事情,游戏引擎通常包含许多类来处理时间的不同方面。因为时间在游戏中非常重要,游戏循环方法如handleInputupdate总是有一个参数delta来指示自上次游戏循环迭代以来已经过了多长时间。游戏时间不一定要和现实世界的时间一样。在游戏中,时间可以走得快三倍,或者慢十倍,或者是游戏设计者想要的任何速度。例如,一个游戏设计者可以决定在一个模拟游戏中,晚上的时间过得更快,因为晚上没有太多事情发生。游戏时间只有在游戏开始后才开始。此外,游戏时间可以被中断。例如,如果玩家切换到浏览器中的另一个标签,游戏时间暂停,因为浏览器停止执行脚本(而实时继续)。

游戏开始,游戏时间为零:零小时,零分钟,零秒已经过去。每次执行游戏循环方法时,您都会得到一个参数,表示已经过了多长时间。本书中的例子遵循固定时间步长范式,这意味着你假设每秒游戏循环迭代次数是固定的,而不管机器有多快或多慢。当您查看Game对象中的这行代码时,您可以看到这一点:

var delta = 1 / 60;

因此,即使游戏循环每秒运行不到 60 次,游戏中的所有对象都表现得好像游戏循环每秒正好运行 60 次。这种方式的好处是用户中断(比如切换到另一个标签页)只是暂停游戏时间,这样当用户回来时,他们就可以像什么都没发生一样继续玩。缺点是在非常慢的电脑上,游戏可能会慢很多。

另一个选择是使用实时。这样一来,无论电脑的速度有多快(或者玩家是否切换到另一个标签),游戏总是会继续。当使用实时时,每秒钟调用游戏循环方法的次数也可能超过 60 次。中断问题可以通过跟踪游戏是否暂停来解决。基本上,当游戏暂停时,你需要保持一个增加的时间偏移量。许多第一人称射击游戏遵循这一策略,因为在这类游戏中,拥有高帧率至关重要,这样玩家才能与游戏世界自然互动。在第一人称射击游戏中,玩家很可能会完全投入到游戏中,他们不会梦想中断游戏来看新闻或玩数独游戏!

控制游戏对象的可见性

在本节中,您将创建一个基于计时器控制游戏对象可见性的类。我们把这个类叫做VisibilityTimer。这个类的思想是你可以给它分配一个目标游戏对象,默认情况下可见性设置为false;当您启动计时器时,目标对象变得可见,直到计时器达到其最大值。您可以将这样的计时器连接到一个覆盖图,以便在屏幕上显示该覆盖图一段时间。看看属于这一章的JewelJamFinal节目;它包含一个名为VisibilityTimer.js的文件,该文件包含完整的VisibilityTimer类。

可见性计时器对象需要跟踪几件事情。首先,您需要存储您想要控制其可见性的目标对象。您还可以存储该对象在计时器启动时应该可见的总时间。最后,当计时器运行时,你必须在它停止之前保持时间的剩余。每次调用update方法时,该值都会更新。因此,VisibilityTimer类继承了GameObject类。

创建计时器时,假设它没有运行,因此剩余时间被设置为 0。您还可以将计时器运行的总时间设置为 1 秒:

function VisibilityTimer(target, layer, id) {
    GameObject.call(this, layer, id);
    this._target = target;
    this._timeLeft = 0;
    this.totalTime = 1;
}

update方法中,然后从_timeLeft变量中减去以秒为单位的游戏时间。如果该变量包含一个小于零的值,您将目标可见性设置为false:

VisibilityTimer.prototype.update = function (delta) {
    if (this._timeLeft > 0) {
        this._timeLeft -= delta;
        this._target.visible = true;
    } else
        this._target.visible = false;
};

最后,添加一个名为startVisible的方法,将总时间分配给变量_timeLeft,并将目标可见性状态设置为true

现在你可以使用VisibilityTimer类来控制宝石果酱游戏中双重和三重组合覆盖的可见性。当您创建覆盖对象时,您也创建了以这些覆盖为目标的VisibilityTimer实例:

var doubleOverlay = new SpriteGameObject(sprites.double, ID.layer_overlays);
doubleOverlay.position = new Vector2(800, 400);
var doubleTimer = new VisibilityTimer(doubleOverlay, ID.layer_overlays, ID.double_timer);
this.add(doubleOverlay);
this.add(doubleTimer);

var tripleOverlay = new SpriteGameObject(sprites.triple, ID.layer_overlays);
tripleOverlay.position = new Vector2(800, 400);
var tripleTimer = new VisibilityTimer(tripleOverlay, ID.layer_overlays, ID.triple_timer);
this.add(tripleOverlay);
this.add(tripleTimer);

当两个或三个宝石组合出现时,你启动该特定覆盖层的能见度计时器。例如,这是 double 组合的代码(参见JewelGrid类的update方法):

if (nrCombis === 2) {
    score.score += 50;
    var doubleTimer = this.root.find(ID.double_timer);
    doubleTimer.startVisible();
}

您可以通过运行本章提供的JewelJamFinal程序来查看计时器的运行情况。

一片闪光的地方

在这一节中,您将为游戏添加一些视觉效果。 目前,宝石是显示在屏幕上的精灵。让我们给它们添加一个漂亮的视觉效果:闪光。你可以通过在屏幕上指定一个矩形,在随机的位置画出闪光,用一种通用的方法来实现。你也希望能够指出这个矩形的闪光物有多密集。然后你可以创建不同大小的矩形,并将其附加到游戏对象上。图 17-1 显示了屏幕上你想要添加闪光的区域。在本章的后面,图 17-2 显示了将会显示给玩家的最终输出。

*9781430265382_Fig17-01.jpg

图 17-1 。矩形表示你想在游戏 中添加闪光的地方

构造函数

让我们创建一个GlitterField类,允许你给游戏对象添加闪光。这个类继承自GameObject类,因此您可以很容易地将其附加到场景图中。GlitterField类的构造函数有几个参数。下面是构造函数的头和部分体:

function GlitterField(density, width, height, layer, id) {
    GameObject.call(this, layer, id);
    this.width = width;
    this.height = height;
    // To do: initialize the glitter field
}

第一个参数是闪光场的密度。这表示同时可以看到多少闪光。然后是widthheight参数,它们表示矩形的大小。最后,layerid参数被传递给基本构造函数。在构造函数的主体中,您将 glitter 字段的宽度和高度存储在成员变量中。

添加闪光物

闪光场是一个包含多个闪光的矩形,取决于所需的密度。因此,你需要一个数组来维护这些闪光的位置。这是在成员变量positions中完成的,该变量是在构造函数 中初始化的数组

this.positions = [];

你用一些随机产生的位置来填充这个列表。要在字段中随机生成一个位置,您可以添加一个方法createRandomPosition来完成这项工作。这种方法很简单;您只需在闪光矩形内生成一个随机位置:

GlitterField.prototype.createRandomPosition = function () {
    return new Vector2(Math.random() * this.width, Math.random() *
        this.height);
};

要用随机值填充position数组,可以使用一条for指令:

for (var i = 0; i < density; i++) {
    this.positions.push(this.createRandomPosition());
}

要画出闪光的部分,你需要的不仅仅是一个位置。您希望添加一种视觉效果,让闪光平滑地出现和消失。你可以通过先增大后减小的比例来画出它们。这意味着你也需要为你正在画的每个闪光保持当前的比例。你可以在另一个名为scales的变量数组中这样做,这个数组也在构造函数中初始化:

this.scales = [];

每当您向positions数组添加一个新位置时,您也向scales数组添加一个小数位数 0。所以最后的for指令变成了

for (var i = 0; i < density; i++) {
    this.positions.push(this.createRandomPosition());
    this.scales.push(0);
}

更新闪光区域

在构造函数中,将每个闪光的比例设置为 0。结果是当闪光被画出时,玩家看不到它们。在update方法中,您再次增加和减少该刻度,直到它回到零。当这种情况发生时,你为那个闪光产生另一个随机位置,所以它出现在其他地方。将以下两个成员变量添加到GlitterField类中:

this._scaleMax = 1;
this._scaleStep = 0.05;

第一个变量指定闪光的最大比例(1 是原始大小)。第二个变量_scaleStep表示标尺应该增加或减少多快

你不希望同时增加每个闪光的比例——你希望闪光随机出现。因此,在update方法中,你遍历列表中所有闪光的位置和比例,根据一个随机数的值,开始增加比例:

for (var i = 0; i < this.scales.length; i += 1) {
    if (this.scales[i] === 0 && Math.random() < 0.01)
        this.scales[i] += this._scaleStep;
}

只有当比例为零且随机数值小于 0.01 时,才开始增加比例。这确保了不是所有的比例都立即增加。当一个标度不再为零时,你增加它:

else if (this.scales[i] !== 0) {
    this.scales[i] += this._scaleStep;
    // more code to come here
}

您不能无限地增加比例,您需要再次减少比例。但是你怎么知道你应该增加还是减少规模呢?在update方法中,你不知道你是在斜率的上升部分还是下降部分。这里可以用一个小技巧:让刻度从零运行到两倍于最大刻度的,然后,在draw方法中,从那个值计算真实刻度(零到最大刻度意味着增加刻度,最大刻度到两倍最大刻度意味着减少刻度)。在update方法中,您添加了一条if指令来处理当规模大于最大规模两倍时的情况:

if (this.scales[i] >=this._scaleMax * 2) {
    this.scales[i] = 0;
    this.positions[i] = this.createRandomPosition();
}

当这种情况发生时,你重置比例为零,并为一个新的闪光产生一个新的随机位置。

绘制闪光区域

在闪光区域的draw方法中,你必须在屏幕上以想要的比例画出所有的闪光。你想把这些闪光画在中心,因为否则缩放动画不会给出想要的结果。所以,你计算这个原点曾经在方法调用开始时:

var origin = new Vector2(sprites.glitter.width / 2,
    sprites.glitter.height / 2);

然后添加一个for指令,遍历所有闪光的刻度和位置。您仍然需要在update方法中根据数组中存储的小数位值来计算实际的小数位值。如果该值在 0 和最大刻度之间,您不必做任何事情(刻度在增加)。如果该值介于最大刻度和两倍最大刻度之间,则需要将该值转换为递减刻度。这是使用以下说明完成的:

var scale = this.scales[i];
if (this.scales[i] > this._scaleMax)
    scale = this._scaleMax * 2 - this.scales[i];

每个闪光都应该根据闪光区域的世界位置来绘制。因此,计算闪光位置如下:

var pos = this.worldPosition. addTo(this.positions[i]);

剩下唯一要做的事情就是使用Canvas2D中的drawImage方法,在期望的位置以期望的比例绘制闪光:

Canvas2D.drawImage(sprites.glitter, pos, 0, scale, origin);

完整的GlitterField类,看属于本章的JewelJamFinal程序。

给游戏对象添加闪光

现在你已经创建了通用的GlitterField类,你可以给你的游戏对象添加一些简单的扩展来给它们添加闪光。你想做到这一点与品味,而不是盲目的球员,增加一些闪光的宝石网格以及移动的宝石车。

您希望闪光在宝石网格的顶部显示为矩形。为了简洁地做到这一点,您将宝石网格和伴随的闪光区域存储在一个单独的GameObjectList实例中。然后,您可以将一个位置分配给该实例一次,闪烁字段和宝石网格将在该位置绘制。首先你创建这个实例,名为playingField:

var playingField = new GameObjectList(ID.layer_objects);
playingField.position = new Vector2(85, 150);
this.add(playingField);

然后你创建网格,就像你在游戏的前几个版本中做的那样。然而,现在你将它添加到playingField列表中,而不是直接添加到游戏世界中:

var rows = 10, columns = 5;
var grid = new JewelGrid(rows, columns, ID.layer_objects, ID.grid);
grid.cellWidth = 85;
grid.cellHeight = 85;
grid.reset();
playingField.add(grid);

最后,将一个GlitterField实例添加到游戏区域,如下所示:

playingField.add(new GlitterField(2, columns * grid.cellWidth,
    rows * grid.cellHeight, ID.layer_overlays_1));

请注意,你把闪光领域的覆盖层,所以它的顶部绘制的宝石网格。

让我们也添加一些闪光的宝石车。这个例子做的有点不同,向您展示了将游戏对象附加到其他游戏对象的各种可能性。在宝石车的例子中,您向JewelCart类引入了一个名为glitters的额外成员变量。这个变量指的是与宝石车相关联的GlitterField实例:

this.glitters = new GlitterField(2, 435, 75);

您可以为闪光场指定适当的密度、宽度和高度。因为您希望根据购物车的位置绘制 glitter 字段,所以您使购物车成为 glitter 字段的父字段:

this.glitters.parent = this;

最后,你给闪光区域一个相对于购物车的局部位置,这样闪光就被画在了正确的位置(在购物车顶部闪亮宝石的周围):

this.glitters.position = new Vector2(275, 475);

剩下唯一要做的就是确保闪光的部分被画出来。为此,您需要扩展draw方法,并在购物车绘制完成后显式调用 glitter 字段的draw方法。下面是完整的方法:

JewelCart.prototype.draw = function () {
    SpriteGameObject.prototype.draw.call(this);
    this.glitters.draw();
};

关于完整的JewelCart类,请参见JewelJamFinal示例。摆弄闪光场的参数设置。您可以看到更改密度、比例和比例步长变量的效果。图 17-2 有点过火了!

9781430265382_Fig17-02.jpg

图 17-2 。非常闪亮的宝石截图

添加音乐和声音效果

就像在画师游戏中,你想在游戏中加入音乐和音效,让游戏更吸引人。如您所见,在 JavaScript 中播放音乐和声音非常容易。您可以使用之前为 Painter 创建的Sound类。这是重用代码的另一个很好的例子:您曾经创建了Sound类,并且您正在将它用于本书中的所有游戏!

许多为宝石果酱设计的类将对本书中的其他游戏有用。当你开始构建自己的游戏时,你可能会得到一个你使用的相似类的集合。在构建游戏时,提前考虑是个好主意。哪些类可以在其他项目中重用?设计一个类的最好方法是什么,这样你以后可以再次使用它?随着您开发越来越多的类,保留一个您可以重用的这些类的列表可能会很有用。然后,当你开发新项目和实现你可能没有想到的事情时,你可以快速浏览列表。

当游戏开始时,一旦加载了资源,就开始播放背景音乐,如下所示:

sounds.music.volume = 0.3;
sounds.music.play();

当你得到一个有效的宝石组合(单个、两个或三个)时,你可以播放不同的音效(参见JewelGrid类):

if (nrCombis === 1) {
    sounds.combi.play();
}
else if (nrCombis === 2) {
    score.score += 50;
    var doubleTimer = this.root.find(ID.double_timer);
    doubleTimer.startVisible();
    sounds.double.play();
}
else if (nrCombis >=3) {
    score.score += 100;
    var tripleTimer = this.root.find(ID.triple_timer);
    tripleTimer.startVisible();
    sounds.triple.play();
}

最后,你在游戏结束时播放一个声音(见JewelJamGameWorld类):

var gameOver = this.root.find(ID.game_over);
if (this.gameOver() && !gameOver.visible) {
    gameOver.visible = true;
    sounds.gameover.play();
    return;
}

这就完成了宝石果酱游戏。运行属于本章的JewelJamFinal应用就可以玩游戏了。作为一个练习,看看你是否能够用新的特性来扩展这个游戏。例如,您可以在制作宝石组合时添加额外的动画效果。或者加个排行榜/高分榜怎么样?在任何情况下,快乐的宝石狩猎!

排行榜

为什么游戏会有排行榜和高分榜?早期的游戏没有它们,因为游戏机中没有半永久存储。所以,在两次游戏之间,什么也记不住。这也是没有保存游戏选项的原因,这反过来对游戏机制产生了重要影响:玩家总是不得不从头开始,即使他们很有经验。

一旦有了储物空间,设计师们就开始推出排行榜。比其他人更好总是给人一种满足感,这为球员增加了一个重要的目标。但这只有在多人在同一台设备上玩游戏时才有意义。如果你是唯一的玩家,你唯一能做的就是努力打败自己。幸运的是,现在的电脑和游戏机都连上了互联网。这样一来,你就可以在网上储存排行榜,与全世界竞争。

但是这增加了一个额外的问题:一个目标只有在可以达到的时候才是有趣的。成为几百万玩家中的最佳玩家对大多数人来说是遥不可及的。所以,全球排行榜实际上会降低玩家的满意度。为了弥补这一点,游戏通常会引入子排行榜。例如,您看到一个仅限于您所在国家或本周得分的排行榜。还可以看到自己在朋友中的排名。精心设计你的游戏评分系统,以及这些分数在排行榜上的显示方式,可以极大地提高玩家的满意度。

你学到了什么

在本章中,您学习了:

  • 如何建立一个短时间显示覆盖图的计时器
  • 如何创建一个闪光的领域,并将其附加到游戏对象上
  • 如何在宝石果酱游戏中播放音效和音乐**

十八、精灵表

在这一章中,你开始构建企鹅配对游戏的第一个元素。这个游戏比前几个游戏要复杂得多。看看它使用的游戏素材数量就知道了。就像你在宝石果酱游戏中做的那样,你使用包含几个不同精灵的图像。这种技术在很多游戏中都有使用。在宝石果酱游戏中,你用它在一个精灵中存储一串宝石。然而,使用条带并不总是一个好主意。特别是当一个图像在一个条带中包含许多不同的子画面时,该条带可能会变得太长,以至于图形硬件无法处理。这可以通过将精灵存储在由多行和多列组成的中来解决。图 18-1 显示了这样一个精灵表的例子。

9781430265382_Fig18-01.jpg

图 18-1 。一张子画面的示例(四列两行)

示例程序概述

为了测试一张精灵的加载和显示,让我们创建一个名为PenguinPairs1的简单示例程序。这个示例项目显示了一个背景,然后在背景的顶部绘制一只企鹅(或海豹)。使用向左和向右箭头键,您可以选择应该显示表单的哪一部分。

为了更容易上手,你可以从之前的游戏中复制几个类。首先你需要GameObject类和GameObjectList类。你还需要自己编写的用于编程游戏的基础类,比如KeyboardTouchMouseGameCanvas2DVector等等。你修改了SpriteGameObject职业,这样它就可以处理精灵了。就像在宝石果酱游戏中,一个类代表游戏世界;在这种情况下,该类被称为PenguinPairsGameWorld。在该类的构造函数方法中,加载一个背景图像:

this.add(new SpriteGameObject(sprites.background_level, ID.layer_background));

您还可以将企鹅精灵添加到游戏世界中,并将其放置在屏幕中间的某个位置:

this.penguin = new SpriteGameObject(sprites.penguin, ID.layer_objects);
this.penguin.position = new Vector2(500, 420);
this.add(this.penguin);

加载精灵表

在 Jewel Jam 游戏中,SpriteGameObject实例保存了对 sprite 的引用,它由类型为Image的对象表示。为了处理 sprite 工作表,您创建了一个名为SpriteSheet的新类,您可以直接使用它来代替Image对象。向该类添加特定的功能,允许您维护工作表中的行数和列数,并可以选择要绘制的工作表的不同元素。在loadAssets方法中,背景和企鹅精灵都被加载;你在那里使用了SpriteSheet类,正如你在loadAssets方法的主体中看到的:

Game.loadAssets = function () {
    var loadSprite = function (sprite) {
        return new SpriteSheet("../assets/sprites/" + sprite);
    };
    sprites.background_level = loadSprite("spr_background_level.jpg");
    sprites.penguin = loadSprite("spr_penguin@4x2.png");
};

企鹅雪碧(spr_penguin@4x2.png)的名字很奇特。这是因为您在SpriteSheet类中使用了一个技巧,让您在文件名中指定 sprite 工作表的尺寸。在本例中,企鹅精灵有四列两行。SpriteSheet构造函数分析精灵的名字并相应地确定尺寸。有三种可能性:

  • 图像是一个单独的精灵:在这种情况下,文件名末尾没有定义。比如:spr_wall.png
  • 图像是一条精灵:在这种情况下,你在@字符后面提供一个整数。比如:spr_field@2.png
  • 图像是一张精灵的图片:文件名中提供了两个维度(列和行)。比如:spr_penguin@4x2.png

注意如果您下载示例代码或开放源代码及相关素材,您可能会发现与本章中使用的命名约定类似的文件命名约定。这些命名约定看似随意,但它们可能是基于编程的。一定要分析这里使用的技巧类型的任何新代码。重命名文件可能看起来无害,但它可能会比您想象的更糟糕。

在一个SpriteSheet实例中,您必须像以前一样加载图像,并且您必须存储一些关于它所代表的 sprite 表的信息。首先加载图像,并在SpriteSheet实例中存储对它的引用:

Game._spritesStillLoading += 1;
Game._totalSprites += 1;

this._image = new Image();
this._image.src = imageName;
this._image.onload = function () {
    Game._spritesStillLoading -= 1;
};

这段代码首先在Game对象中设置两个状态变量,这样你就可以跟踪有多少精灵仍在加载,以及总共有多少精灵。然后创建一个Image实例,设置它的源,并定义onload事件处理程序,这与您为之前的游戏所做的非常相似。

您还需要存储 sprite 表中的行数和列数。您可以从文件名中提取这些信息,但是让我们将这两项默认设置为 1:

this._sheetColumns = 1;
this._sheetRows = 1;

要检索这些变量的实际值,需要从作为参数传递给构造函数的字符串中提取它们。JavaScript 有一个非常方便的方法叫做split,可以将字符串分割成小块。作为一个参数,split方法接受一个分隔符,它返回一个字符串数组。这里有几个split能做什么的例子:

var s1 = "abcabcabc";
var s2 = "spring,summer,autumn,winter";
var s3 = "game";
var result = s1.split('c'); /* result now contains a 3 item array,
                               ["ab", "ab", "ab"] */
result = s2.split(','); /* result now contains a 4 item array,
                               ["spring", "summer", "autumn", "winter"] */
result = s2.split(','); /* result now contains a 1 item array, ["game"] */

您需要对文件名做的第一件事是删除其中的路径信息。为此,您使用斜杠(/)作为分隔符来拆分字符串。然后从数组中取出最后一个元素,它应该包含完整的文件名:

var pathSplit = imageName.split('/');
var fileName = pathSplit[pathSplit.length - 1];

下一步是删除文件扩展名。这意味着您必须使用句点(.)作为分隔符来调用split方法。数组中的第一个元素将包含不带扩展名的文件名:

var fileNameNoExt = fileName.split('.')[0];

然后,使用@-作为分隔符来拆分不带扩展名的文件名,如下所示:

var fileSplit = fileNameNoExt.split("@");

fileSplit变量指的是一个数组。如果@-没有出现在字符串中,fileSplit变量中的元素个数为 1(原字符串)。如果数组中的元素数大于 1,则表明文件名包含了有关图纸尺寸的信息。您还知道,在这种情况下,数组的最后一个元素包含该信息。使用x字符作为分隔符再次拆分最后一个元素。这一切都在下面的if指令中表达出来:

if (fileSplit.length <= 1)
    return;
var colRow = fileSplit[fileSplit.length - 1].split("x");
// deal with the sheet dimension data

现在有两种可能。colRow数组包含一个或两个元素。在这两种情况下,您都知道数组中的第一个元素表示工作表中的列数,所以您只需将它存储在_sheetColumns变量中:

this._sheetColumns = colRow[0];

如果长度为 2,还需要在 _ sheetRows变量:中存储第二个字符串元素

if (colRow.length === 2)
    this._sheetRows = colRow[1];

还有一种可能性是数组包含两个以上的元素。这里不处理这种情况(在这种情况下,只存储列数)。在SpriteGameObject类中,假设存储了对SpriteSheet的引用,而不是对Image的引用。创建SpriteGameObject实例时,sprite 工作表应该作为参数传递,这与您之前所做的非常相似。您还可以存储当前的工作表索引。这个数字表示SpriteGameObject应该从 sprite 表中提取哪个元素。下面是完整的构造函数:

function SpriteGameObject(sprite, layer, id) {
    GameObject.call(this, layer, id);
    this.sprite = sprite;
    this.origin = Vector2.zero;
    this._sheetIndex = 0;
}

注意您已经看到了如何通过使用split方法来分析字符串内容。在 JavaScript 中,操纵字符串数据的方式还有很多。要获得更完整的概述,请访问www.w3schools.com/js/js_strings.asp,这是一个关于 JavaScript 字符串的优秀教程。

管理精灵表

你已经在宝石果酱游戏中看到了如何处理一条精灵。你必须改变draw方法来绘制精灵的一部分。这里你需要做一些额外的管理工作来确保 sprite 工作表的正常运行。首先要做的是向SpriteSheet类添加一个width属性和一个height属性,这两个属性考虑了 sprite 表的列号和行号:

Object.defineProperty(SpriteSheet.prototype, "width",
    {
        get: function () {
            return this._image.width / this._sheetColumns;
        }
    });

Object.defineProperty(SpriteSheet.prototype, "height",
    {
        get: function () {
            return this._image.height / this._sheetRows;
        }
    });

此外,还会添加一个属性来计算工作表中的元素数,元素数定义为行数乘以列数:

Object.defineProperty(SpriteSheet.prototype, "nrSheetElements",
    {
        get: function () {
            return this._sheetRows * this._sheetColumns;
        }
    });

还可以给SpriteSheet添加一些更有用的属性。看一下属于章节的PenguinPairs1例子,看完整的SpriteSheet类。

下一步是能够在 sprite 表中绘制一个元素。这是在SpriteSheet类的draw方法中完成的。这个方法需要几个参数,特别是应该绘制元素的位置、它的原点以及应该绘制哪个元素。后者由 sprite 表中的一个索引表示,并作为sheetIndex参数传递。首先,您需要将这个索引值转换成工作表中的列和行索引。您按如下方式计算列索引:

var columnIndex = sheetIndex % this._sheetColumns;

基本上,您可以将工作表索引视为从左到右、从上到下传递给工作表中所有元素的值。通过在表索引上应用模数操作符,可以“丢弃”元素所在行之前的行,这样就只剩下列索引了。同样,通过将表索引除以列数来计算行索引:

var rowIndex = Math.floor(sheetIndex / this._sheetColumns) % this._sheetRows;

为了确保索引在行数范围之内,您将根据行数执行取模运算。现在,您可以使用widthheight属性构建一个矩形,指示应该绘制的 sprite 部分:

var imagePart = new Rectangle(columnIndex * this.width, rowIndex * this.height,
    this.width, this.height);

最后,在屏幕上绘制精灵部件,如下所示:

Canvas2D.drawImage(this._image, position, 0, 1, origin, imagePart, mirror);

SpriteGameObject类现在很简单。首先添加一些有用的属性来获取精灵的宽度和高度:

Object.defineProperty(SpriteGameObject.prototype, "width",
    {
        get: function () {
            return this.sprite.width;
        }
    });
Object.defineProperty(SpriteGameObject.prototype, "height",
    {
        get: function () {
            return this.sprite.height;
        }
    });

您还可以添加一个属性来读取或写入 sprite 游戏对象的当前工作表索引。所选元素应在可能的元素索引范围内。您可以在属性的set部分检查这一点:

Object.defineProperty(SpriteGameObject.prototype, "sheetIndex",
    {
        get: function () {
            return this._sheetIndex;
        },
        set: function (value) {
            if (value >=0 && value < this.sprite.nrSheetElements)
                this._sheetIndex = value;
        }
    });

查看PenguinPairs1示例中的SpriteGameObject.js文件,了解一些其他可以添加的属性示例。SpriteGameObject的最后一个方法是draw,这很简单,因为大部分工作已经在SpriteSheet类中完成了:

SpriteGameObject.prototype.draw = function () {
    if (this._visible)
        this.sprite.draw(this.worldPosition, this.origin, this._sheetIndex);
};

最终确定示例

PenguinPairs1的例子中,你在屏幕上画了一个背景和一只企鹅(见图 18-2 )。要测试您的新SpriteGameObject类,通过按左右箭头按钮修改当前选择的工作表索引。这在PenguinPairsGameWorld类:中很容易做到

if (Keyboard.pressed(Keys.left))
    this.penguin.sheetIndex--;
else if (Keyboard.pressed(Keys.right))
    this.penguin.sheetIndex++;

9781430265382_Fig18-02.jpg

图 18-2 。PenguinPairs1 示例程序的屏幕截图。那条鲨鱼看起来很饿,那只企鹅看起来很好吃!

因为您在SpriteSheet类中处理所有的 sprite-sheet 方面,所以直接在屏幕的正中央绘制企鹅,如下所示:

this.penguin.position = this.penguin.screenCenter;

这将适用于任何尺寸的 sprite 工作表!尝试一下PenguinPairs1程序,并试验一下代码,看看它是如何工作的。

你学到了什么

在本章中,您学习了:

  • 如何使用split方法分析字符串
  • 如何处理游戏中的精灵表

十九、菜单和设置

在 Jewel Jam 游戏中,您看到了一些向游戏中添加 GUI 元素的基本示例,如按钮或框架。在这一章中,您将向 Penguin Pairs 游戏添加一些 GUI 元素,例如开/关按钮和滑块按钮。您还将看到如何读取和存储游戏设置,如音乐音量和是否允许提示。

设置菜单

当想到菜单时,你可能会想到下拉菜单(如文件或编辑)或应用顶部的按钮。但是,菜单可以是灵活的,尤其是在游戏中,菜单通常是按照游戏的风格设计的,并且在许多情况下可以覆盖部分屏幕甚至整个屏幕。作为一个例子,让我们看看如何定义一个包含两个控件的基本选项菜单屏幕:一个用于打开或关闭提示,另一个用于控制音乐的音量。首先,您需要绘制这些控件周围的元素。向菜单添加背景,然后添加文本标签来描述提示控件。你可以使用宝石果酱游戏中的Label类来实现。你定义应该绘制的文本,并把它放在适当的位置(下面的代码摘自PenguinPairsGameWorld ):

var background = new SpriteGameObject(sprites.background_options,
    ID.layer_background);
this.add(background);
var onOffLabel = new Label("Arial", "60px", ID.layer_overlays);
onOffLabel.text = "Hints";
onOffLabel.position = new Vector2(150, 360);
onOffLabel.color = Color.darkBlue;
this.add(onOffLabel);

同样,为音乐音量控制器添加一个文本标签。有关完整代码,请参见本章的PenguinPairs2示例。

添加开/关按钮

下一步是添加一个开/关按钮,在游戏过程中显示(或不显示)提示。在本章的后面,您将看到如何使用这个按钮的值。就像你为宝石果酱游戏中的Button类所做的一样,你为开/关按钮创建了一个特殊的类,名为OnOffButton(这并不奇怪)。该类是SpriteGameObject的子类,它期望一个精灵条包含两个精灵:一个用于关闭状态,一个用于开启状态(参见图 19-1 )。

9781430265382_Fig19-01.jpg

图 19-1 。用于开/关按钮的精灵条

按钮的一个重要方面是你需要能够读取和设置它是开还是关。因为按钮基于长度为 2 的 sprite 条,所以您可以定义如果工作表索引为 0,按钮处于 off 状态,如果工作表索引等于 1,按钮处于 on 状态。然后,您可以添加一个获取和设置该值的布尔属性。该属性的定义如下:

Object.defineProperty(OnOffButton.prototype, "on",
    {
        get: function () {
            return this.sheetIndex === 1;
        },
        set: function (value) {
            if (value)
                this.sheetIndex = 1;
            else
                this.sheetIndex = 0;
        }
    });

最后,您需要处理按钮上的鼠标点击,以切换按钮的开和关状态。与您在Button类中所做的类似,您在handleInput方法中检查鼠标左键是否被按下,以及鼠标位置是否在按钮的边界框内。对于触摸输入,您遵循类似的过程。如果播放器已按下按钮,则需要修改纸张索引。如果表索引是 0,它应该变成 1,反之亦然。下面是完整的handleInput方法:

OnOffButton.prototype.handleInput = function (delta) {
    if (!this.visible)
        return;
    if (Touch.containsTouchPress(this.boundingBox) ||
        Mouse.containsMousePress(this.boundingBox))
        this.sheetIndex = 1 - this.sheetIndex;
};

请注意,只有当按钮可见时才处理输入。在PenguinPairsGameWorld类中,你添加一个OnOffButton实例到游戏世界,在期望的位置:

this.onOffButton = new OnOffButton(sprites.button_offon, ID.layer_overlays);
this.onOffButton.position = new Vector2(650, 340);
this.onOffButton.on = GameSettings.hints;
this.add(this.onOffButton);

在这个例子中,您使用了一个变量GameSettings,其中存储了与游戏相关的任何设置。在这种情况下,您需要维护一个指示是否应该在屏幕上显示提示的设置。稍后,本章将对此进行更详细的讨论。

添加滑块按钮

接下来添加第二种 GUI 控件:滑块。这个滑块将控制游戏中背景音乐的音量。它由两个 sprite 组成:一个代表工具条的 back sprite 和一个代表实际滑块的 front sprite。因此,Slider类继承自GameObjectList。因为 back sprite 有边框,所以在移动或绘制滑块时需要考虑到这一点。因此,您还需要定义左右边距,这些边距定义了 back sprite 左右两侧的边框宽度。您还可以将滑块定位在比背景精灵略低的位置,以适应顶部边框。完整的构造函数如下:

function Slider(sliderback, sliderfront, layer) {
    GameObjectList.call(this, layer);
    this.dragging = false;
    this.draggingId = -1;
    this.leftmargin = 5;
    this.rightmargin = 7;

    this.back = new SpriteGameObject(sliderback);
    this.front = new SpriteGameObject(sliderfront, 1);
    this.front.position = new Vector2(this.leftmargin, 8);
    this.add(this.back);
    this.add(this.front);
}

正如你所看到的,你还设置了一个布尔变量draggingfalse和一个变量draggingId为-1。您需要这些变量来跟踪玩家何时拖动滑块以及触摸 ID 是什么,以便在需要时更新滑块位置,即使鼠标指针/触摸位置不在 back sprite 的边界内。

下一步是添加一个属性value ,允许您检索和设置滑块的值。您希望值为 0 表示滑块完全移动到左侧,值为 1 表示滑块完全移动到右侧。你可以通过查看精灵的位置来计算当前值,并查看它向右移动了多少。因此,以下代码行根据滑块位置计算滑块值:

return (this.front.position.x - this.back.position.x - this.leftmargin) /
       (this.back.width - this.front.width - this.leftmargin - this.rightmargin);

在分数的上半部分,计算前面的精灵向右移动了多远。您可以根据后面的位置加上左边距来计算。然后用它除以滑块可以移动的总长度。这条return指令形成了 tT5【何】??value房产的一部分。对于属性的set部分,您需要将一个介于零和一之间的值转换为前滑块x-位置。这相当于重写先前的公式,使得前 x 位置是未知的,然后计算如下:

var newxpos = value * (this.back.width - this.front.width - this.leftmargin –
    this.rightmargin) + this.back.position.x + this.leftmargin;

剩下要做的就是用正确的 x 位置创建新的前方位置向量:

this.front.position = new Vector2(newxpos, this.front.position.y);

现在您已经有了设置和获取滑块值的方法,您需要编写代码来处理玩家输入。类似于你在以前的课程中所做的,你分别处理触摸和鼠标输入。对于每种类型的输入,添加一个特定的方法,然后从handleInput方法中调用:

Slider.prototype.handleInput = function (delta) {
    GameObjectList.prototype.handleInput.call(this, delta);
    if (Touch.isTouchDevice) {
        this.handleInputTouch(delta);
    } else {
        this.handleInputMouse(delta);
    }
};

您仍然需要处理触摸输入来将滑块拖动到新的位置。第一步是检查玩家是否正在触摸屏幕。如果不是这样,您只需将拖动状态变量重置为初始值,就大功告成了:

if (!Touch.isTouching) {
    this.dragging = false;
    this.draggingId = -1;
    return;
}

如果执行了写在这个if指令之后的指令,你就知道玩家正在触摸屏幕。

你需要检查玩家是否真的触摸了按钮。如果是这种情况,您可以为拖动状态变量指定新值:

if (Touch.containsTouch(this.back.boundingBox)) {
    this.draggingId = Touch.getIndexInRect(this.back.boundingBox);
    this.dragging = true;
}

如果玩家正在拖动,最后一步是更新滑块位置。第一步是检索玩家触摸屏幕的位置:

var touchPos = Touch.getPosition(this.draggingId);

接下来你计算滑块的 x 位置应该是什么。因为触摸位置是在世界坐标中,所以从它减去 back sprite 的世界位置来获得滑块的局部位置。你还要从这个数字中减去滑块宽度的一半,这样玩家触摸屏幕的地方就在滑块的中心。这是使用以下表达式计算的:

touchPos.x - this.back.worldPosition.x - this.front.width / 2

但是,您需要做更多的工作,因为您必须确保滑块不能移动到其范围之外。因此,您需要将滑块位置固定在一定范围内。在 JavaScript 中,您可以动态地用额外的方法来扩充对象,所以让我们给可以执行这个箝位操作的Math对象添加一个方法:

Math.clamp = function (value, min, max) {
    if (value < min)
        return min;
    else if (value > max)
        return max;
    else
        return value;
};

现在您使用该方法来计算滑块的箝位值,并将其存储为滑块的新x-位置:

this.front.position.x = Math.clamp(touchPos.x - this.back.worldPosition.x -
    this.front.width / 2, this.back.position.x + this.leftmargin,
    this.back.position.x + this.back.width - this.front.width –
    this.rightmargin);

这就完成了处理触摸输入的代码。

你用非常相似的方式处理鼠标输入。看看Slider类,看看完整的handleInputTouchhandleInputMouse方法。

PenguinPairs类中,你给游戏世界添加了一个滑块:

this.musicSlider = new Slider(sprites.slider_bar, sprites.slider_button,
    ID.layer_overlays);
this.musicSlider.position = new Vector2(650, 500);
this.add(this.musicSlider);

然后,您可以使用该类中的value属性来设置滑动条,使其与背景音乐的当前音量相匹配,只需一行代码:

this.musicSlider.value = sounds.music.volume;

最后,在PenguinPairsGameWorld类的update方法中,您检索滑块的当前值,并使用它来更新背景音乐的音量:

sounds.music.volume = this.musicSlider.value;

注意大多数游戏都包含一些菜单屏幕。通过这些屏幕,玩家可以设置选项、选择关卡、观看成就和暂停游戏。创建所有这些额外的屏幕可能是大量的工作,对实际的游戏没有贡献,所以开发者倾向于在它们上面投入较少的精力。但这是一个非常错误的决定。

一位艺术家曾经说过,“你的游戏和它最差的屏幕一样好。”如果其中一个菜单屏幕质量很差,玩家会觉得游戏没有完成,开发者没有付出足够的努力。因此,确保你所有的菜单屏幕看起来漂亮,易于使用和导航。

仔细想想你在这些屏幕里放了什么。你可能会想为所有的东西创建选项:游戏的难度、播放的音乐、背景的颜色等等。但是请记住,你是应该创造游戏的人,而不是玩家。你或你的艺术家应该决定什么能给游戏带来最有趣的玩法和最引人注目的视觉风格,而不是用户。

尽量避免选项。比如玩家真的应该设置难度吗?难道不能通过监控玩家的进度来自动适应难度吗?你真的需要一个关卡选择屏幕吗?你不能简单地记起玩家最后一次在哪里,然后立即在那里继续吗?让你的界面尽可能简单!

读取和存储游戏设置

让滑块控制背景音乐的音量并不复杂。现在,假设您想要一个开/关按钮来控制玩家是否可以按下按钮来查看提示。您应该将此选项信息存储在哪里?在这个例子中,它存储在一个名为GameSettings的特殊变量中。在声明spritessounds变量的同一个地方声明这个变量——即PenguinPairs.js文件:

var GameSettings = {
    hints: true
};

这个变量现在随处可见。但是,请注意,它不会在游戏中持续存在:如果您关闭游戏,稍后在浏览器中打开它,该变量将默认为其原始设置。在第二十一章中,你会看到一种跨不同游戏维护数据的方法。

在选项菜单中,确保该变量的值始终与提示按钮的状态相同。这是在PenguinPairsGameWorldupdate方法中完成的,您使用添加到按钮的on属性:

GameSettings.hints = this.onOffButton.on;

这就完成了选项菜单。图 19-2 显示了它的样子。

9781430265382_Fig19-02.jpg

图 19-2 。PenguinPairs2示例的屏幕截图

你学到了什么

在本章中,您学习了:

  • 如何创建带有各种按钮和滑块的菜单
  • 如何检索按钮和滑块的值,并将这些信息转换成游戏设置

二十、游戏状态管理

通常情况下,当一个游戏应用启动时,你不会立即开始玩。例如,在 Jewel Jam 游戏中,您会在玩之前看到一个标题屏幕。更复杂的游戏有选项菜单,选择不同级别的菜单,完成一个级别后显示高分的屏幕,选择不同角色和属性的菜单,等等。在 Jewel Jam 中,添加标题屏幕并不困难,因为标题屏幕本身几乎没有交互。然而,当你看上一章的例子时,你会发现用一些选项和控件构建一个屏幕会产生大量的代码。你可以想象,当你给游戏添加更多的菜单和屏幕时,管理哪些对象属于哪个屏幕以及何时应该绘制或更新它们将是一件痛苦的事情。

通常,这些不同的菜单和屏幕被称为游戏状态。在一些节目中,他们被称为场景,负责管理场景的对象是导演。有时,在游戏模式和游戏状态之间存在区别。在这种情况下,像菜单、主游戏屏幕等等是游戏模式而“关卡完成”和“游戏结束”是游戏状态

这本书遵循一个简化的范例,把所有的事情都叫做游戏状态。为了处理这些不同的游戏状态,你需要一个管理器。在本章中,您将开发这种结构所需的主要类,并了解如何使用它来显示不同的菜单并在它们之间切换,同时保持代码的清晰分离。

管理游戏状态的基础

当您想要正确处理游戏状态时,您需要确保以下几点:

  • 游戏状态应该完全独立运行。换句话说,当你在玩游戏的时候,你不想处理选项菜单屏幕或者“游戏结束”屏幕。
  • 应该有一个简单的方法来定义游戏状态,找到游戏状态,并在它们之间切换。这样,当玩家按下标题屏幕中的选项按钮时,您可以轻松切换到选项菜单状态。

在你到目前为止看到的例子中,总是有某种游戏世界类;它被称为PainterGameWorldJewelJamGameWorldPenguinPairsGameWorld,这取决于你正在构建的游戏。从游戏状态的角度来看,每个世界代表一个游戏状态。您需要为每个不同的状态定义这样一个类。好的一面是,您已经有了很多代码来帮助您做到这一点。GameObjectList类特别有用,因为它表示游戏对象的列表,这是表示游戏状态的充分基础。在之前的游戏中,代表游戏世界的类继承自GameObjectList类。在本书剩余的例子中,代表游戏状态的类也将从GameObjectList继承。因此,如果你有一个选项菜单,一个标题屏幕,一个级别选择屏幕,和一个帮助菜单,你为每一个游戏状态创建一个单独的类。您唯一需要提供的是一种管理游戏中各种游戏状态的方法。你可以通过创建一个游戏状态管理器来做到这一点。

游戏状态管理器

在本节中,您将创建一个名为GameStateManager的类。因为在一个游戏中应该只有一个游戏状态管理器,所以你遵循单例设计模式 ,其中类被称为GameStateManager_Singleton,你将(单个)实例存储在一个全局变量GameStateManager中,就像你对Canvas2DGame这样的类所做的那样:

var GameStateManager = new GameStateManager_Singleton();

您以这样的方式设置这个类,它允许您存储不同的游戏状态(即不同的GameObjectList实例),您可以选择当前的游戏状态,然后管理器自动调用当前活动游戏状态的游戏循环方法。

为了存储不同的游戏状态,你需要一个数组。您还可以定义一个附加变量来跟踪当前活动的游戏状态。因此,这是游戏状态管理器的构造函数:

function GameStateManager_Singleton() {
    this._gameStates = [];
    this._currentGameState = null;
}

现在您需要定义一个方法,将游戏状态添加到数组中。在该方法中,使用push将一个元素添加到数组的末尾。您还将当前活动的游戏状态设置为刚刚添加的状态。下面是完整的add方法:

GameStateManager_Singleton.prototype.add = function (gamestate) {
    this._gameStates.push(gamestate);
    this._currentGameState = gamestate;
    return this._gameStates.length - 1;
};

正如您在查看方法中的最后一条指令时所看到的,您返回了数组中游戏状态的索引。您这样做是因为稍后它将为您提供一个简单的方法来找到您添加的游戏状态。您将这个索引作为标识符值存储在ID变量中。例如,添加标题菜单状态并在添加后存储其 ID 可以在一行代码中完成,如下所示:

ID.game_state_title = GameStateManager.add(new TitleMenuState());

因为游戏状态标识符正好对应于数组中游戏状态的索引,所以您可以编写一个非常简单的get方法来检索游戏状态,给定一个 ID:

GameStateManager_Singleton.prototype.get = function (id) {
    if (id < 0 || id >=this._gameStates.length)
        return null;
    else
        return this._gameStates[id];
};

切换到另一个游戏状态也很简单。这是通过调用switchTo方法来完成的:

GameStateManager_Singleton.prototype.switchTo = function (id) {
    this._currentGameState = this.get(id);
};

处理不同的游戏循环方法非常简单。你必须在当前活跃的游戏状态下调用它们。例如,GameStateManagerhandleInput方法如下:

GameStateManager_Singleton.prototype.handleInput = function (delta) {
    if (this._currentGameState !== null)
        this._currentGameState.handleInput(delta);
};

对于其他游戏循环方法,您可以遵循类似的过程。为了使游戏状态管理器成为游戏不可或缺的一部分,您在Game.mainLoop方法中调用它的游戏循环方法:

Game_Singleton.prototype.mainLoop = function () {
    var delta = 1 / 60;
    GameStateManager.handleInput(delta);
    GameStateManager.update(delta);
    Canvas2D.clear();
    GameStateManager.draw();

    Keyboard.reset();
    Mouse.reset();
    Touch.reset();

    requestAnimationFrame(Game.mainLoop);
};

添加状态并在它们之间切换

现在你有了游戏状态管理器,你可以开始添加不同的状态了。一个非常基本的游戏状态是标题菜单状态。在PenguinPairs3示例中,您向应用中添加了一个表示该状态的类TitleMenuState。因为这个状态包含几个不同的游戏对象,所以让它从GameObjectList类继承。在这个类的构造函数中,您添加了这个状态所需的游戏对象:一个背景和三个按钮。你可以重用你之前为宝石果酱游戏开发的Button类。这里是TitleMenuState的构造器:

function TitleMenuState(layer) {
    GameObjectList.call(this, layer);

    this.add(new SpriteGameObject(sprites.background_title,
        ID.layer_background));

    this.playButton = new Button(sprites.button_play, ID.layer_overlays);
    this.playButton.position = new Vector2(415, 540);
    this.add(this.playButton);

    this.optionsButton = new Button(sprites.button_options, ID.layer_overlays);
    this.optionsButton.position = new Vector2(415, 650);
    this.add(this.optionsButton);

    this.helpButton = new Button(sprites.button_help, ID.layer_overlays);
    this.helpButton.position = new Vector2(415, 760);
    this.add(this.helpButton);
}

因为您需要在按钮被按下时做一些事情,所以您必须覆盖handleInput方法。在该方法中,您检查每个按钮是否都被按下,如果是,则切换到另一个状态。例如,如果玩家按下玩游戏按钮,您需要切换到级别菜单:

if (this.playButton.pressed)
    GameStateManager.switchTo(ID.game_state_levelselect);

您可以为其他两个按钮添加类似的替换选项。现在标题菜单状态基本完成了。在PenguinPairs类中,您唯一需要做的事情就是创建一个TitleMenuState的实例,并将其添加到游戏状态管理器中。你对游戏中的其他州做同样的事情。之后,您将当前状态设置为标题菜单,以便玩家在游戏开始时看到标题菜单:

ID.game_state_title = GameStateManager.add(new TitleMenuState());
ID.game_state_help = GameStateManager.add(new HelpState());
ID.game_state_options = GameStateManager.add(new OptionsMenuState());
ID.game_state_levelselect = GameStateManager.add(new LevelMenuState());

// the current game state is the title screen
GameStateManager.switchTo(ID.game_state_title);

帮助和选项菜单状态的设置方式类似于TitleMenuState。在类构造函数中,您将您的游戏对象添加到游戏世界中,并覆盖handleInput方法以在状态之间切换。例如,帮助和选项菜单状态都包含返回标题屏幕的后退按钮:

if (this.backButton.pressed)
    GameStateManager.switchTo(ID.game_state_title);

看看PenguinPairs3示例中的HelpStateOptionsMenuState类,了解不同的状态是如何设置的,以及如何在状态之间切换。

级别菜单状态

稍微复杂一点的游戏状态是关卡菜单。您希望玩家能够从关卡按钮的网格中选择一个关卡。您希望能够用这些关卡按钮显示三种不同的状态,因为一个关卡可以被锁定、解锁但尚未被玩家解决,或者已经解决。为了做到这一点,你需要某种跨游戏的持久存储,这将在下一章讨论。对于关卡按钮的每一种不同状态,你可以使用不同的精灵。因为你还不能玩当前版本的游戏,你只需显示每个关卡的“锁定”状态。

在创建LevelMenuState类之前,您需要添加一个继承自GameObjectList的名为LevelButton的类。在LevelButton类中,您跟踪两件事:按钮是否被按下,以及按钮引用的级别索引:

function LevelButton(levelIndex, layer, id) {
    GameObjectList.call(this, layer, id);
    this.pressed = false;
    this.levelIndex = levelIndex;
    // to do: create the button sprites
}

因为按钮有三种不同的状态,所以要加载三个精灵,每种状态一个。如果玩家完成了一关,按钮上会显示一只彩色企鹅。因为有几只不同颜色的企鹅,所以通过根据级别索引改变工作表索引来选择彩色按钮:

this._levelSolved = new SpriteGameObject(sprites.level_solved,
    ID.layer_overlays);
this._levelUnsolved = new SpriteGameObject(sprites.level_unsolved,
    ID.layer_overlays);
this._levelLocked = new SpriteGameObject(sprites.level_locked,
    ID.layer_overlays_2);
this.add(this._levelSolved);
this.add(this._levelUnsolved);
this.add(this._levelLocked);

this._levelSolved.sheetIndex = levelIndex;

最后,您添加一个绘制在企鹅腹部的文本标签,这样玩家就可以看到每个按钮所指的级别:

var textLabel = new Label("Arial", "20px", ID.layer_overlays_1);
textLabel.text = levelIndex + 1;
textLabel.position = new Vector2(this._levelSolved.width - textLabel.width,
    this._levelSolved.height - textLabel.height + 50).divideBy(2);
textLabel.color = Color.black;
this.add(textLabel);

handleInput、和方法中,你检查按钮是否被按下。在鼠标输入的情况下,鼠标位置应该在精灵的边界框内,并且玩家必须按下鼠标左键。如果你正在处理一个触摸界面,玩家的手指应该在屏幕上的按钮区域内按下。最后,玩家只能按下按钮,如果它是可见的。下面是完整的handleInput方法:

LevelButton.prototype.handleInput = function (delta) {
    if (Touch.isTouchDevice)
        this.pressed = this.visible &&
            Touch.containsTouchPress(this._levelLocked.boundingBox);
    else
        this.pressed = this.visible && Mouse.left.pressed &&
            this._levelLocked.boundingBox.contains(Mouse.position);
};

如果你看一下PenguinPairs3例子中的LevelButton类,它还包括widthheight属性,当你在屏幕上放置关卡按钮时,你需要用到它们。

现在您已经有了一个基本的LevelButton类,您可以在LevelMenuState类中添加关卡按钮。在这个例子中,您使用一条for指令向菜单添加了 12 个级别按钮。根据计数器变量(i)的值,计算按钮所属的行和列。这些信息,连同关卡按钮的宽度和高度,帮助你计算每个关卡按钮的最终位置:

this.levelButtons = [];

for (var i = 0; i < 12; i += 1) {
    var row = Math.floor(i / 5);
    var column = i % 5;
    var level = new LevelButton(i, ID.layer_overlays);
    level.position = new Vector2(column * (level.width + 30) + 155,
        row * (level.height + 5) + 230);
    this.add(level);
    this.levelButtons.push(level);
}

因为稍后您必须检查每个按钮是否都被按下了,所以您不仅要将按钮添加到游戏状态,还要将每个按钮的引用存储在一个名为levelButtons的数组中。当你想确定玩家是否点击了某个关卡按钮时,这个数组就派上了用场。你用一个叫做getSelectedLevel的方法来检查这个。这个方法在LevelMenuState类中,遍历数组中的所有级别按钮,并返回属于第一个被按下的按钮的级别索引。如果玩家没有按任何按钮,该方法返回-1。下面是完整的方法:

LevelMenuState.prototype.getSelectedLevel = function () {
    for (var i = 0, j = this.levelButtons.length; i < j; i += 1) {
        if (this.levelButtons[i].pressed)
            return this.levelButtons[i].levelIndex;
    }
    return -1;
};

然后,您可以在handleInput方法中使用该方法:

LevelMenuState.prototype.handleInput = function (delta) {
    GameObjectList.prototype.handleInput.call(this, delta);

    var selectedLevel = this.getSelectedLevel();
    if (selectedLevel != -1) {
        // start playing the level
    }
    else if (this.back.pressed)
        GameStateManager.switchTo(ID.game_state_title);
};

正如你所看到的,给游戏添加不同的状态并在它们之间切换并不困难,只要你事先考虑软件的设计。通过预先考虑需要哪些类,以及游戏的功能应该如何在它们之间划分,你可以在以后节省很多时间。在下一章中,您将通过创建实际的级别来进一步扩展这个示例。图 20-1 显示了一级菜单状态的截图。

9781430265382_Fig20-01.jpg

图 20-1 。企鹅配对中的等级菜单屏幕

处理错误

在你读完这一章之前,让我们再多谈一点关于处理 JavaScript 中的错误。特别是当游戏变得更复杂时,你需要考虑当错误发生时会发生什么。举一个具体的例子,在GameStateManager类的get方法中,只有当作为参数传递的标识符落在数组中的元素范围内时,才返回游戏状态:

GameStateManager_Singleton.prototype.get = function (id) {
    if (id < 0 || id >=this._gameStates.length)
        return null;
    else
        return this._gameStates[id];
};

这样做是因为,否则,游戏状态管理器的用户可能会意外地访问其范围之外的数组。然而,您在这里使用的方法可能不是最健壮的方法。首先,当索引太大或太小时,返回null。这意味着使用该方法的代码需要知道该方法可能会返回null。结果,同样的代码必须在一个if指令中检查这一点,以确保你没有试图在一个null引用上调用一个方法。另一个问题是,你不能用这种方法避免所有的数组越界错误。例如:

var oops = GameStateManager.get(3.4);

当然,数组只能用整数值访问,所以如果你不处理传递非整数,程序就会崩溃。这可以通过按如下方式访问阵列来避免:

return this._gameStates[Math.floor(id)];

但这真的是你想要的吗?如果用户试图访问数组中的元素 3.4(它显然不存在),您是否希望返回一个对象,即假设索引 3.4 处的对象存在?也许最好让用户知道这是不可能的,并停止该方法的执行。

还有其他一些例子说明了处理错误的正确方法。有时这些情况的发生并不是因为程序中的错误,而是因为发生了程序员无法控制的事情。例如,也许你的游戏玩家有一个很好的主意,在设置一个在线游戏时禁用网络适配器。或者,由于病毒在服务器上肆虐,一些您期望在那里的文件被破坏了。如果你依赖于其他公司开发的软件,一个新的版本可能会破坏你的代码,因为规范已经改变了。有几种方法可以处理这些类型的错误。一种方法是什么都不做,让程序崩溃。对于游戏开发者来说,这是一个廉价的解决方案(至少最初是这样),但它并不能产生一个非常健壮、用户友好的游戏。您还可以维护一个错误代码列表。如果出现错误,该方法将返回这个错误代码,用户将需要筛选一个大文档,详细说明每个错误代码以及如何解决它。还有一种方法是根本不报告错误,并尝试在程序中解决它。尽管有时这是一个好的解决方案,但在许多情况下,您根本无法解决错误。例如,如果在大型多人在线游戏中网络连接突然中断,除了向玩家报告这一情况,你别无选择。

包括游戏在内的大多数应用都通过使用异常来处理错误。 异常是被方法抛出,方法的调用者要处理它。例如,看看下面的方法定义:

GameStateManager_Singleton.prototype.get = function (id) {
    if (id < 0 || id >=this._gameStates.length)
        throw "game state id out of range";
    else if (Math.floor(id) !== id)
        throw "game state id should be an integer number";
    else
        return this._gameStates[id];
};

这段代码在实际访问数组之前处理各种情况。如果id超出范围或者不是整数(换句话说,数字的 floor 函数不返回数字本身),该方法停止执行并抛出一个异常(在本例中是一个字符串值)。为了处理这个错误,你可以调用try-catch:-块中的get函数

try {
    var myGameState = GameStateManager.get(someId);
    // do something with the game state
} catch (e) {
    console.log("Error: " + e);
}

如您所见,可能抛出异常的方法调用在try指令的主体中。如果有异常,程序继续在catch部分的主体中运行。如果没有异常,那么catch体不会被执行。

catch部分,异常被捕获。在单词catch后面是一个包含被抛出对象的参数。在前面的例子中,get方法可以抛出一个包含错误信息的字符串。这不是做这件事的最好方法。更复杂的程序通常定义异常类的层次结构。此类的实例可以包含异常发生的时间和方法、该方法的参数值、自定义错误信息等信息。不同的类可以用来表示不同类型的错误。因为本书中的游戏很简单,所以它们不会以这种方式处理错误。但是考虑一下你想如何处理游戏中的错误是一个好主意,也许异常可以帮助你以更健壮的方式处理错误。

让我们回到try指令。您可以在该指令的正文中放置多条指令。但是一旦出现异常,执行就会停止,并且执行catch的主体。因此,try之后的剩余指令可以假设它们被执行时没有异常。

trycatch部分的主体需要在大括号之间,即使主体中只有一条指令。这有点不合逻辑,例如,ifwhile指令中可能会省略括号。

catch部分之后,可以放置一个finally部分,无论是否捕获到某种异常,都会执行这个部分。比如:

try {
    openServerConnection(ipAddress);
    // communicate with the server
    ...
}
catch (e) {
    console.log("Error while communicating with the server: " + e);
finally {
   // always close the server connection
   closeServerConnection();
}

在这个例子中,finally部分避免了复制代码。不管在与服务器通信时是否有错误,您都需要在完成后关闭连接。

总之,图 20-2 显示了异常相关表达式的语法图。试着想出迄今为止你在游戏中见过的其他例子,在这些例子中,异常可能是有用的。

9781430265382_Fig20-02.jpg

图 20-2 。异常相关表达式的语法图

你学到了什么

在本章中,您学习了:

  • 如何使用游戏状态管理器定义不同的游戏状态
  • 如何根据玩家的动作在游戏状态之间切换
  • 如何利用异常正确处理游戏中的错误

二十一、存储和调用游戏数据

许多游戏由不同的关卡组成。尤其是在解谜、迷宫类的休闲游戏中,一个游戏可能有几百个关卡。到目前为止,你的游戏一直依靠随机性来保持游戏的趣味性。虽然随机性是实现可玩性的强大工具,但在很多情况下,游戏设计者希望对游戏的进展有更多的控制。这种控制一般通过设计来实现。每个关卡都有自己的游戏世界,玩家必须在其中实现某种目标。

使用到目前为止您所看到的工具,您可以想象,对于游戏中的每个关卡,您都可以编写一个特定的类,在其中用游戏对象填充特定的关卡,并添加您想要的行为。这种方法有一些缺点。最大的缺点是你把游戏逻辑(游戏玩法、获胜条件等等)和游戏内容混在了一起。这意味着每次你想给游戏添加另一个关卡时,你都必须编写一个新的类,这导致了当一个游戏被加载到浏览器中时,需要检索大量的类。此外,如果一个游戏设计师想给你开发的游戏增加一个关卡,他需要深入了解你的代码是如何工作的。设计者在编写代码时犯的任何错误都会导致你的游戏出现错误或崩溃。

更好的方法是将关卡信息与实际的游戏代码分开存储。当游戏加载时,关卡信息被检索。理想情况下,信息需要以非程序员能够理解和使用的简单格式存储。这样,关卡可以由某人来设计,而不需要那个人知道游戏如何将数据转换成可玩的游戏关卡。JavaScript 是一种非常适合表示结构化信息的语言。这很容易,主要是因为可以在 JavaScript 中定义对象文字。看看下面的例子:

var ticTacToeSaveGame = {
    scorePlayerX : 2,
    scorePlayerO : 1,
    currentStatus : ["x x",
                     "oox",
                     "o "],
    turn : "x"
}

这个变量描述了两个玩家的井字游戏的游戏状态。第一个玩家赢了两局,第二个玩家只赢了一局。游戏的当前状态存储在一个字符串数组中。最后,放置 x 标记的玩家轮到他们了。通过编辑这个变量,你可以很容易地改变分数或游戏的当前状态。您甚至可以通过向currentStatus变量添加列和行来决定让棋盘变得更大。所有这些都可以在不知道井字游戏实际工作原理的情况下完成。游戏设计者可以编辑以这种方式构建的数据,游戏可以从这样的变量中读取数据,并让玩家继续他们的游戏。此外,由于 JavaScript 对象文字,这种格式相对容易理解,即使对于很少或没有编程经验的人也是如此。最后,随着游戏变大,开发游戏的团队也会变大。通过将关卡设计和之前介绍的东西(比如精灵表)分开,你可以让擅长游戏设计和图形设计的非程序员帮助你更有效地创建令人敬畏的游戏。

你可以用类似的方式处理企鹅配对中的不同等级。在这一章中,你将看到如何在你的游戏中建立这样一个关卡加载方案。你要看的另一件事是在不同的会话中存储和调用游戏的状态。诸如 Painter 和 Jewel Jam 之类的游戏不会保留玩家以前玩该游戏时的任何信息,这在那些游戏中并不重要。然而,在企鹅配对的情况下,这很重要,因为你不希望玩家每次启动游戏都要从头开始。如果玩家完成了一个关卡,浏览器应该会在玩家下一次启动游戏时记住,这样玩家就可以从他们停止的地方继续。

层次的结构

我们先来看看企鹅配对游戏里一个关卡里能有什么样的东西。首先,有某种背景图像。让我们假设这个背景在您加载关卡时是固定的,所以没有必要在文本文件中存储任何关于它的信息。

在这一层有许多不同的动物,如企鹅、海豹和鲨鱼。还有冰山,企鹅可以移动的背景块,还有其他一些东西。您希望将所有这些信息存储在一个结构化变量中。一种可能是存储每个对象的位置和类型,但这将使变量变得复杂。另一种可能是将关卡分成小块,也叫瓦片 。每个方块都有特定的类型(可能是企鹅、运动场瓷砖、透明瓷砖、企鹅等等)。一个方块可以用一个字符来表示,你可以将关卡的结构存储在一个变量中,就像你对井字游戏所做的一样,例如:

var myLevel = {
    tiles : ["#.......#",
                            "#...r...#",
                            "#.......#",
                            "#. .#",
                            "#. .#",
                            "#. .#",
                            "#.......#",
                            "#...r...#",
                            "#.......#"]
};

在这个级别定义中,定义了许多不同的块。冰山(墙)瓷砖由#符号定义,企鹅由r字符定义,背景瓷砖由.字符定义,空瓷砖由空格字符定义。现在您可以编写一个方法,使用这些信息来创建图块并将它们存储在某个地方(可能在一个GameObjectGrid实例中)。这意味着你需要不同类型的瓷砖:企鹅可以站在上面的普通瓷砖,透明的背景瓷砖,以及企鹅可以碰撞的墙壁(冰山)瓷砖。

瓷砖类

首先,让我们写一个基本的Tile类。 这个类是SpriteGameObject类的子类。现在,你不考虑更复杂的项目,如企鹅,海豹和鲨鱼。您只需查看背景(透明)瓷砖、普通瓷砖和墙壁(冰山)瓷砖。让我们引入一个变量来表示这些不同种类的瓷砖:

var TileType = {
    normal: 0,
    background: 1,
    wall: 2
};

Tile类是SpriteGameObject的基本扩展。在构造函数中,您声明一个成员变量type来存储一个实例所代表的图块类型:

function Tile(sprite, layer) {
    SpriteGameObject.call(this, sprite, layer);
    this.type = TileType.normal;
}

为了适应透明拼贴,只有当拼贴不是背景拼贴时,才覆盖draw方法来绘制 sprite:

Tile.prototype.draw = function () {
    if (this.type === TileType.background)
        return;
    SpriteGameObject.prototype.draw.call(this);
};

当您加载关卡时,您为每个角色创建一个图块,并将其存储在一个网格结构中,如GameObjectGrid

其他级别信息

除了瓷砖,您还需要在levelData变量中存储一些其他的东西:

  • 级别是否被锁定
  • 关卡是否已经被玩家解决
  • 关卡的提示
  • 要制作的对数
  • 提示箭头的位置和方向

因此,您可以在变量中定义一个完整级别,如下所示:

var myLevel = {
    locked : true,
    solved : false,
    hint : "Don't let the penguins fall in the water!",
    nrPairs : 1,
    hint_arrow_x : 3,
    hint_arrow_y : 1,
    hint_arrow_direction : 2,
    tiles : ["#.......#",
                            "#...r...#",
                            "#.......#",
                            "#. .#",
                            "#. .#",
                            "#. .#",
                            "#.......#",
                            "#...r...#",
                            "#.......#"]
};

您需要为每个级别定义这样一个变量。将所有这些级别存储在一个数组中是有意义的。因为级别信息需要随处可用,所以您将级别信息存储在一个全局变量中。一般来说,如果可能的话,应该避免使用全局变量,原因如下:

  • 全局名称空间将被大量的全局变量弄得混乱不堪,这可能会降低脚本的执行速度。
  • 如果两个不同的 JavaScript 文件碰巧使用相同的全局变量,就会发生冲突。
  • 您源代码变得不容易阅读,因为很难了解哪些数据在哪里使用。

在这种情况下,您使用一个全局变量,因为级别数据需要随处可访问。然而,你可以做一些事情来确保你使用的是一个全局变量。你要做的一件事是用大写字母写变量名,以强调它不同于其他普通变量。您还可以显式地将变量附加到全局域(在 JavaScript 中称为window)。下面是变量初始化:

window.LEVELS = [];

现在您唯一需要做的就是用级别信息填充这个变量。对于每一级,使用push方法 : 向数组添加一个条目

window.LEVELS.push({
    locked : false,
    solved : false,
    hint : "Click on a penguin and select the arrow to let
                             it move towards the other penguin.",
    nrPairs : 1,
    hint_arrow_x : 4,
    hint_arrow_y : 3,
    hint_arrow_direction : 3,
    tiles : ["#########",
                            "#.......#",
                            "#...r...#",
                            "#.......#",
                            "#.......#",
                            "#.......#",
                            "#...r...#",
                            "#.......#",
                            "#########"]
});

这个例子是第一个层次。如你所见,第一关的锁定状态被设置为false,所以玩家被允许玩这一关。所有其他级别的锁定状态被设置为true。当玩家完成一个级别,你更新这个状态。等级在levels.js文件中定义。这是一个 JavaScript 文件,但它位于PenguinPairs4示例的assets文件夹中,因为这些数据与其说是代码,不如说是素材。此外,这样设计者可以在assets文件夹中工作,改变精灵和等级数据,而不必查看游戏运行代码。

播放状态

在前一章中,你看到了如何创建多个游戏状态,比如标题屏幕、关卡选择菜单和选项菜单。在本节中,您添加了一个播放状态。游戏状态基本上由一系列关卡组成,每个关卡都有自己的游戏世界。对于标题屏幕和选项菜单这样的状态,您可以创建一个GameObjectList的子类。然而,在这里这没有太大的意义,因为游戏状态需要在游戏世界之间切换。因此,你不会从GameObjectList继承。但是你确实想定义游戏循环方法,比如updatedraw。您可以通过引入一个新的类IGameLoopObject,稍微改变软件设计来适应这一点。这个类唯一做的事情是提供游戏循环的任何对象部分应该拥有的方法的定义。下面是完整的类:

function IGameLoopObject() {
}

IGameLoopObject.prototype.handleInput = function (delta) {};
IGameLoopObject.prototype.update = function (delta) {};
IGameLoopObject.prototype.draw = function () {};
IGameLoopObject.prototype.reset = function () {};

这个类被称为IGameLoopObject,而不是例如GameLoopObject,因为在软件设计中,这样的类通常被称为接口。接口非常有用,因为它们为程序员提供了当一个类实现那个接口(换句话说,从接口类继承)时可以预期的方法(或属性)种类的信息。相当多的编程语言都有一个特殊的编程结构,可以让你创建这些接口。JavaScript 不是这种情况,但是您仍然可以使用这个概念来获得相同的结果。

接口构成了所有拥有游戏循环方法的对象的基础。您可以更改示例中的现有类来遵循这种方法。例如,GameObject类现在也继承自IGameLoopObject:

function GameObject(layer, id) {
    IGameLoopObject.call(this);

    // initialize the game object...
}
GameObject.prototype = Object.create(IGameLoopObject.prototype);

看看PenguinPairs4例子中的类,看看IGameLoopObject类是如何集成到程序设计中的。如您所见,该示例添加了一个PlayingState类,它也继承自IGameLoopObject:

function PlayingState() {
    IGameLoopObject.call(this);

    // initialize the playing state...
}
PlayingState.prototype = Object.create(IGameLoopObject.prototype);

在播放状态下创建关卡

在本节中,您将从存储在全局windows.LEVELS变量中的数据创建游戏中的关卡。为了表示一个级别,您创建了一个继承自GameObjectListLevel类。对于每个需要创建的级别,您创建一个Level实例,并根据全局LEVELS变量中的数据填充它。在PlayingState构造函数中,您初始化一个数组,在其中存储所有这些实例。您还可以存储玩家当前正在玩的关卡:

this.currentLevelIndex = -1;
this.levels = [];

然后您调用一个方法loadLevels,它负责从级别数据创建Level实例:

this.loadLevels();

loadLevels方法中,您放置了一个for循环,在其中您创建了Level实例。在Level构造函数中,你将关卡数据转换成实际的游戏对象,这些对象是每个关卡的一部分:

PlayingState.prototype.loadLevels = function () {
    for (var currLevel = 0; currLevel < window.LEVELS.length; currLevel++)
        this.levels.push(new Level(currLevel));
};

创建Level实例

Level构造函数中,你必须创建属于那个级别的不同游戏对象。作为第一步,您检索级别数据并将其存储在一个名为levelData : 的变量中

function Level(levelIndex) {
    GameObjectList.call(this);
    var levelData = window.LEVELS[levelIndex];
    this.levelIndex = levelIndex;

    // to do: fill this level with game objects according to the level data
}

你还需要跟踪企鹅和海豹等动物。您在一个单独的数组中这样做,以便以后可以快速查找它们。这同样适用于某些级别的鲨鱼:

this.animals = [];
this.sharks = [];

现在你可以开始创建游戏对象来填充游戏世界。首先向游戏世界添加一个背景图像:

this.add(new SpriteGameObject(sprites.background_level, ID.layer_background));

然后你读水平的宽度和高度。您可以通过检索tiles数组的长度以及该数组中单个字符串的长度来确定它们:

var width = levelData.tiles[0].length;
var height = levelData.tiles.length;

然后创建一个GameObjectList实例来包含游戏区域,就像在宝石果酱游戏中一样。你把这个游戏场放在屏幕的正中央:

var playingField = new GameObjectList(ID.layer_objects);
playingField.position = new Vector2((Game.size.x - width * 73) / 2, 100);
this.add(playingField);

现在您需要从levelData变量中检索图块信息。您重用了GameObjectGrid类来表示瓷砖网格。要读取所有的瓷砖,你使用一个嵌套的for指令。看看下面几行代码:

var tileField = new GameObjectGrid(height, width, ID.layer_objects, ID.tiles);
tileField.cellHeight = 72;
tileField.cellWidth = 73;
for (var row = 0; row < height; row++) {
    for (var col = 0; col < width; col++) {
        // handle the tile 'levelData.tiles[row][col]' here
    }
}

首先创建一个GameObjectGrid实例,并将网格中一个单元格的宽度和高度设置为给定的大小。然后开始读取包含瓷砖信息的字符。

现在,根据您从表达式levelData.tiles[row][col]中获得的角色,您需要创建不同种类的游戏对象并将它们添加到网格中。你可以使用一个if指令来实现:

if (levelData.tiles[row][col] === '.')
    // create an empty tile
else if (levelData.tiles[row][col] === ' ')
    // create a background tile
else if (levelData.tiles[row][col] === 'r')
    // create a penguin tile
//... and so on

原则上,这种代码是可行的。但是每次都要写一个复杂的条件。很容易犯错误,比如变量名拼写错误或者忘记加括号。还有另一种选择,可以让你以稍微干净的方式写这个。JavaScript 提供了一种特殊的处理案例的指令:switch

注意当以基于文本的格式定义等级时,你必须决定每个字符代表哪种对象。这些决定影响了关卡设计者和开发者的工作,前者必须在关卡数据文件中输入字符,后者必须编写代码来解释关卡数据。这显示了文档是多么重要,即使是在活动开发期间。有一个“备忘单”是很好的,这样当你写这段代码时,你就不必记住所有关卡设计的想法。如果你和设计师一起工作,这样的备忘单也很有用,可以确保你们在同一页上。

使用switch处理备选方案

switch指令允许您指定替代方案,以及每个替代方案应执行的指令。例如,前面有多个替代项的if指令可以重写为一个switch指令如下:

switch(levelData.tiles[row][col]) {
    case '.': // create an empty tile
              break;
    case ' ': // create a background tile
              break;
    case 'r': // create a penguin tile
              break;
}

switch指令有一些便利的属性,这使得它在处理不同的选择时非常有用。请看下面的代码示例:

if (x === 1)
    one();
else if (x === 2) {
    two();
    alsoTwo();
} else if (x === 3 || x === 4)
    threeOrFour();
else
    more();

您可以用如下的switch指令重写它:

switch(x) {
    case 1: one();
             break;
    case 2: two();
             alsoTwo();
             break;
    case 3:
    case 4: threeOrFour();
             break;
    default: more();
             break;
}

当执行switch指令时,计算括号之间的表达式。然后执行字case和特定值之后的指令。如果没有对应于该值的案例,则执行default关键字之后的指令。不同情况背后的值需要是常量值(数字、字符、双引号中的字符串或声明为常量的变量)。

break指令

如果不小心,switch指令不仅会执行相关案例后面的指令,还会执行其他案例后面的指令。您可以通过在每个案例后放置特殊的break指令来防止这种情况。break指令的基本意思是,“停止执行你当前所在的switchwhilefor指令。”如果在前面的例子中没有break指令,那么在x === 2的情况下,将调用方法twoalsoTwo,以及方法threeOrFourmore

在某些情况下,这种行为是有用的,这样,在某种意义上,不同的案例可以相互交流。但是,当这样做时,您必须小心,因为这可能导致错误——例如,如果程序员忘记将break指令放在某个地方,这将导致非常奇怪的行为。当您使用switch指令时,请确保案例始终由break指令分隔。唯一的例外是当你在一组指令前写多个case标签时,就像你在例 3 和例 4 中所做的那样。switch指令的语法是指令语法图的一部分。图 21-1 显示了属于switch指令的那部分图表。

9781430265382_Fig21-01.jpg

图 21-1 。switch指令的语法图

装载不同种类的瓷砖

您可以使用switch指令来加载所有不同的图块和游戏对象。对于levelData.tiles变量中的每个字符,您需要执行不同的任务。例如,当字符“.”时被读取,您需要创建一个正常的运动场瓷砖。下面的指令就是这样做的:

t = new Tile(sprites.field, ID.layer_objects);
t.sheetIndex = row + col % 2;
tileField.addAt(t, col, row);
break;

用于图块的精灵是由两个不同精灵组成的条带。通过使用公式row + col % 2切换工作表索引,您会得到一个交替的棋盘图案,正如您通过运行属于本章的示例程序所看到的。另一个例子是添加透明背景拼贴:

t = new Tile(sprites.wall, ID.layer_objects);
t.type = TileType.background;
tileField.addAt(t, col, row);
break;

尽管背景精灵是不可见的,你仍然可以加载一个属于这个图块的精灵。为什么会这样?因为Tile类继承自SpriteGameObject类,后者需要一个 sprite。当然,另一个选择是修改SpriteGameObject类,这样它就可以处理一个名为null的精灵。然而,在这种情况下,您遵循提供精灵的简单解决方案,即使玩家永远不会看到它。当你必须安置一只企鹅时,需要做两件事:

  • 放置普通瓷砖。
  • 放置一只企鹅。

因为企鹅需要在棋盘上走来走去,而你需要与它们互动,所以你创建了一个类Animal来表示一种动物,比如企鹅或海豹。在本节的后面,您将看到这个类的样子。为了跟踪游戏中的动物,您维护了一个数组作为Level类的成员变量,正如您之前看到的:

this.animals = [];

switch指令中,你创建一个普通的瓷砖和一只企鹅,如下所示:

t = new Tile(sprites.field, ID.layer_objects);
t.sheetIndex = row + col % 2;
tileField.addAt(t, col, row);
var animalSprite = sprites.penguin;
if (levelData.tiles[row][col] === levelData.tiles[row][col].toUpperCase())
    animalSprite = sprites.penguin_boxed;
var p = new Animal(levelData.tiles[row][col], animalSprite, ID.layer_objects_1);
p.position = t.position.copy();
p.initialPosition = t.position.copy();
playingField.add(p);
this.animals.push(p);
break;

你也在做一些其他的事情。例如,你希望普通动物和被装箱的动物(不能移动的动物)有所不同。您可以通过使用大写或小写字符来进行区分。JavaScript 知道一个方法toUpperCase将一个字符转换成它的大写变体。您在一个if指令的条件中使用该方法来确定应该使用的素材的名称。创建了Animal对象后,你将它的位置设置为你创建的图块的位置,这样它就被正确放置了。您还将名为initialPosition的变量设置为相同的值。你这样做是为了当玩家卡住并按下重试按钮时,你可以知道每个动物在关卡中的原始位置。

Animal构造函数中,您将字符作为参数传递。这样做是为了在构造函数中决定应该选择哪个元素。你也可以检查角色,看看你是否在和一只被装箱的动物打交道。装箱状态存储在Animal类的布尔成员变量中:

this.boxed = (color === color.toUpperCase());

在下一条指令中,你使用一个叫做indexOf的方法,根据作为参数传递的字符,聪明地计算出你想要显示的动物。方法检查字符串中一个字符的第一个索引。例如:

"game".indexOf('a') // returns 1
"over".indexOf('x') // returns -1
"penguin pairs".indexOf('n') // returns 2

以下是如何使用该方法计算动物的床单指数:

this.sheetIndex = "brgyopmx".indexOf(color.toLowerCase());

您将字符转换为小写,这样指令对正常企鹅和盒装企鹅都有效。为了完成Animal类,您添加了一些方便的方法来检查您是否正在处理一个特殊情况,比如一只五彩企鹅、一个空盒子或一只海豹。有关完整的Animal类,请参见属于本章的示例程序PenguinPairs4

最后,在企鹅配对游戏中还有鲨鱼。鲨鱼是相对简单的动物,它们不能被玩家控制(非常像在现实生活中!).因此,您没有使用Animal类,而是为它们使用了SpriteGameObject,它包含了您需要的一切。你遵循一个与企鹅相似的程序。您创建了一个图块和一条鲨鱼,并将鲨鱼存储在一个数组中,以便以后可以轻松找到它们:

t = new Tile(sprites.field);
t.sheetIndex = row + col % 2;
tileField.addAt(t, col, row);
var s = new SpriteGameObject(sprites.shark, ID.layer_objects_1);
s.position = t.position.copy();
playingField.add(s);
this.sharks.push(s);
break;

现在你已经在switch指令中处理了所有这些不同的情况,你可以加载每个级别了。看看示例中的Level类,了解完整的级别创建过程。图 21-2 显示了一个关卡加载后的截图。

9781430265382_Fig21-02.jpg

图 21-2 。企鹅配对游戏中的一个关卡

维持玩家的进度

为了完成这一章,这一节将向你展示一个跟踪玩家在不同阶段的进度的好方法。你希望游戏记住玩家最后一次玩游戏时的位置。有几种方法可以做到这一点。一种方法是让玩家来做这项工作,默认情况下简单地向玩家开放所有关卡。这是一个解决方案,但它并没有真正激励玩家按顺序解决每个关卡。另一种方法是在服务器上使用一个可以被 JavaScript 应用访问的数据库。然后,您可以在服务器上存储玩家信息,并通过这种方式跟踪玩家。这是可行的,但也不理想,因为除了 JavaScript 应用之外,还需要一台服务器启动并运行。第三个选择是使用 HTML5 中引入的一个叫做 HTML5 网络存储的特性。

HTML5 web 存储提供了一种以两种不同方式存储信息的方法,使用两个不同的变量。如果你给变量code.sessionStorage,赋值,这个信息会一直保留到应用运行的标签关闭。如果给变量window.localStorage赋值,该值将在不同的会话中保留。后者是一个非常有用的选择。例如,您可以执行以下操作:

window.localStorage.playerName = "Bridget";

下一次开始游戏时,您可以读取变量的值,并通过记住她的名字来让玩家大吃一惊。然而,使用本地存储有一些附加条件。首先,很容易意外清除localStorage 变量,因为任何 JavaScript 程序都可以访问它。此外,用户可以从浏览器菜单中显式地这样做,因此您的程序不应该依赖于以这种方式存储的数据,而没有数据的备份或默认值。现代浏览器在私有模式下运行时,通常会禁用本地存储。如果你的游戏严重依赖本地存储,并且被禁用了,以某种方式通知玩家可能是个好主意。

另一个限制是,您只能在本地存储中将字符串存储为值。这意味着您想要存储的任何复杂数据都必须转换为字符串值。当您读取数据时,您需要解析字符串并将数据转换回来。因为windows.LEVELS变量包含所有级别数据,包括每个级别的锁定/已解决状态,所以您希望将该对象转换为字符串,并将其完整地存储在本地存储中。问题是,你如何把这样一个复杂的变量转换成一个字符串,然后再转换回来?

JavaScript 再次拯救了我们!这种语言的一个真正伟大的特性是 JavaScript 允许无缝地转换成和转换成对象文字的字符串,比如windows.LEVELS。这是使用JSON对象完成的。JavaScript Object Notation(JSON)是一个将结构化对象表示为字符串的开放标准,很像 XML。JavaScript 有几个有用的方法可以自动将对象文字转换成这样的字符串。例如,要将所有级别数据作为 JSON 字符串存储在本地存储中,您只需要以下代码行:

localStorage.penguinPairsLevels = JSON.stringify(window.LEVELS);

从 JSON 字符串到对象文字同样简单:

window.LEVELS = JSON.parse(localStorage.penguinPairsLevels);

Level类中,您添加了两个方法loadLevelsStatuswriteLevelsStatus,它们从本地存储中读取级别信息,并将其写入本地存储。您在这些方法中添加了一些检查,以确保本地存储实际上是可用的(只有在较新的浏览器中才有)。下面是两种方法的定义:

PlayingState.prototype.loadLevelsStatus = function () {
    if (localStorage && localStorage.penguinPairsLevels) {
        window.LEVELS = JSON.parse(localStorage.penguinPairsLevels);
    }
};
PlayingState.prototype.writeLevelsStatus = function () {
    if (!localStorage)
        return;
    localStorage.penguinPairsLevels = JSON.stringify(window.LEVELS);
};

您在PlayingState的构造函数中调用loadLevelsStatus方法,这样当游戏开始时,就可以使用本地存储中更新的关卡信息。每当玩家完成一个关卡,你就调用writeLevelsStatus方法。这样,下次玩家开始游戏时,游戏会记住玩家已经完成的关卡。

在练习中,尝试通过存储更多信息来扩展PenguinPairs4示例。例如,目前游戏不记得玩家对音乐音量的偏好或是否应该显示提示。你能创建一个在不同阶段保留这些信息的游戏版本吗?

保存游戏的诅咒

大多数游戏都有一个让玩家保存进度的机制。这通常用于三种方式之一:以后继续玩,当玩家在游戏中失败时返回到以前的保存点,或者利用替代策略或故事情节。这些可能性听起来都很合理,但也带来了问题;当你设计游戏时,你必须仔细考虑何时(以及如何)允许玩家保存和加载游戏状态。

例如,在较老的第一人称射击游戏中,所有的敌人都在游戏世界中的固定位置。玩家之间的一个常见策略是保存游戏,跑进一个房间看看敌人在哪里(这导致了即时死亡),加载保存的游戏,并根据敌人的位置信息,仔细清理房间。这让游戏玩起来轻松了很多,但这绝对不是创作者的本意。这可以通过使保存游戏或加载已保存的游戏变得困难来部分补救。其他游戏只允许在特定的保存点保存。有些人甚至将到达保存点作为挑战的一部分。但这可能会导致挫败感,因为如果有一个非常困难的地方,玩家可能不得不一遍又一遍地重放游戏的各个部分。最有趣的游戏是那些你永远不需要返回来节省点数的游戏,因为你从来没有真正失败过,但这是非常难以设计的。

所以仔细想想你的储蓄机制。什么时候允许保存?你允许多少种不同的扑救?游戏中的保存是如何工作的?玩家如何加载保存的游戏?玩家保存或加载游戏需要花费一些东西吗?所有这些决定都会影响游戏性和玩家满意度。

你学到了什么

在本章中,您学习了:

  • 如何创建基于磁贴的游戏世界
  • 如何使用switch指令处理不同的情况
  • 如何使用本地存储检索和存储液位状态数据

二十二、企鹅配对

在这一章中,你将为企鹅配对游戏编写主要的游戏程序。您将学习如何在棋盘上移动企鹅,以及当企鹅与另一个游戏对象(如鲨鱼或另一只企鹅)发生碰撞时该怎么办。

选择企鹅

在你移动企鹅之前,你需要能够选择一只企鹅。当您单击企鹅或海豹等动物时,会出现四个箭头,允许您控制动物的移动方向。为了显示这些箭头并处理输入,您可以添加一个名为AnimalSelector的类。 因为动物选择器包含四个箭头,所以它继承自GameObjectList类。您还想添加一个漂亮的视觉效果,以便当玩家将鼠标移动到其中一个箭头上时,它会变得更暗。您可以通过添加一个包含两个精灵的类Arrow来实现这个效果:一个用于常规箭头,一个用于当您悬停在箭头上时的箭头图像。Arrow类还应该能够显示四个可能方向中的任何一个方向的箭头。

箭类

你可以有一个单一的箭头图像,并根据所需的方向旋转它,但是这个例子通过使用一个包含指向所有四个方向的箭头的图像使事情变得简单(见图 22-1 )。因此,当你加载精灵时,工作表索引指示显示哪个箭头。对于悬停状态,加载另一个包含相同箭头图像的精灵,顺序相同,但颜色更深。

9781430265382_Fig22-01.jpg

图 22-1 。包含四个箭头的精灵,每个箭头指向不同的方向

注意我选择使用两个精灵来实现悬停状态并没有什么特别的原因。您也可以将八个箭头图像放在一个 sprite 工作表中,并使用它来代替这里使用的两个 sprite 工作表。

因为一个Arrow实例或多或少像一个按钮一样起作用,所以它继承自Button类。在构造函数中,首先通过调用超类的构造函数来创建实例的超类部分。这将加载第一个箭头图像。然后定义第二个 sprite,arrowHover,当鼠标悬停在它上面时,它包含箭头图像。默认情况下,这个精灵是不可见的,所以您将其可见性状态设置为false。您还将该 sprite 的父级设置为Arrow实例,以便它被绘制在正确的位置。下面是完整的构造函数:

function Arrow(sheetIndex, layer, id) {
    Button.call(this, sprites.arrow, layer, id);
    this.sheetIndex = sheetIndex;
    this.arrowHover = new SpriteGameObject(sprites.arrow_hover);
    this.arrowHover.sheetIndex = sheetIndex;
    this.arrowHover.visible = false;
    this.arrowHover.parent = this;
}

作为参数传递给Arrow构造函数的工作表索引被传递给实际的子画面,以便选择正确的箭头方向。

handleInput方法中,通过计算鼠标位置是否在箭头精灵的边界框内来检查悬停精灵是否应该可见。只有当游戏不在触摸设备上运行时,您才需要这样做,因此在计算可见性状态时,您需要考虑这个条件:

Arrow.prototype.handleInput = function (delta) {
    Button.prototype.handleInput.call(this, delta);
    this.arrowHover.visible = !Touch.isTouchDevice &&
        this.boundingBox.contains(Mouse.position);
};

最后,覆盖draw方法,这样就可以添加一条线来绘制悬停精灵:

Arrow.prototype.draw = function () {
    Button.prototype.draw.call(this);
    this.arrowHover.draw();
};

动物选择器

当玩家点击动物时,动物选择器使用Arrow类显示四个箭头(见图 22-2 )。这四个箭头作为成员变量存储在AnimalSelector类中。因为选择器控制一种特定的动物,所以你也必须跟踪它控制哪一种动物。因此,您还添加了一个成员变量selectedAnimal,它包含了对目标动物的引用。在构造函数方法中,您创建了四个Arrow对象,并按如下方式适当地放置它们:

function AnimalSelector(layer, id) {
    GameObjectList.call(this, layer, id);
    this._arrowright = new Arrow(0);
    this._arrowright.position = new Vector2(this._arrowright.width, 0);
    this.add(this._arrowright);
    this._arrowup = new Arrow(1);
    this._arrowup.position = new Vector2(0, -this._arrowright.height);
    this.add(this._arrowup);
    this._arrowleft = new Arrow(2);
    this._arrowleft.position = new Vector2(-this._arrowright.width, 0);
    this.add(this._arrowleft);
    this._arrowdown = new Arrow(3);
    this._arrowdown.position = new Vector2(0, this._arrowright.height);
    this.add(this._arrowdown);
    this.selectedAnimal = null;
    this.visible = false;
}

9781430265382_Fig22-02.jpg

图 22-2 。当玩家点击一只企鹅时,会显示四个箭头,这样玩家可以选择企鹅应该移动的方向

handleInput方法中,首先检查选择器是否可见。如果不是,则不需要处理输入:

if (!this.visible)
    return;

然后检查是否按下了其中一个箭头。如果是这样,你就可以计算出想要的动物速度:

var animalVelocity = Vector2.zero;
if (this._arrowdown.pressed)
    animalVelocity.y = 1;
else if (this._arrowup.pressed)
    animalVelocity.y = -1;
else if (this._arrowleft.pressed)
    animalVelocity.x = -1;
else if (this._arrowright.pressed)
    animalVelocity.x = 1;
animalVelocity.multiplyWith(300);

如果玩家点击了鼠标左键或触摸了屏幕(在哪里并不重要),您可以再次将动物选择器的状态设置为不可见:

if (Mouse.left.pressed || Touch.containsTouchPress(Game.screenRect))
    this.visible = false;

最后,如果你计算的速度不为零,并且有一只目标企鹅,你更新它的速度:

if (this.selectedAnimal !== null && animalVelocity.isZero)

    this.selectedAnimal.velocity = animalVelocity;

Animal类的handleInput方法中,你必须处理点击动物。但是,在某些情况下,您不必处理这个问题:

  • 这只动物在冰的一个洞里。
  • 动物是看不见的。
  • 这只动物已经在移动了。

在所有这些情况下,你不做任何事情,你从方法返回:

if (!this.visible || this.boxed || !this.velocity.isZero)
    return;

如果玩家没有触摸或点击动物,你也可以从方法返回。因此,您添加下面的if指令来验证这一点:

if (Touch.isTouchDevice) {
    if (!Touch.containsTouchPress(this.boundingBox))
        return;
} else {
    if (!Mouse.left.pressed || !this.boundingBox.contains(Mouse.position))
        return;
}

你需要考虑的最后一种情况是,如果玩家触摸或点击了动物,但动物选择器已经可见。在这种情况下,您不需要做任何事情,您可以从方法返回:

var animalSelector = this.root.find(ID.animalSelector);
if (animalSelector.visible)
    return;

现在您已经处理了所有情况,您可以使选择器可见,设置其位置,并将动物指定为选择器的目标动物。以下说明涵盖了这些内容:

animalSelector.position = this.position;
animalSelector.visible = true;
animalSelector.selectedAnimal = this;

如您所见,正确处理用户输入有时会很复杂。你需要考虑玩家可能采取的所有行动,并恰当地处理输入。如果你做得不好,你就冒着在游戏中引入错误的风险,这些错误会导致游戏崩溃(这很糟糕)或者玩家作弊(这甚至更糟,尤其是在在线多人游戏中)。

你刚刚写的指令允许玩家随意选择动物,并告诉它们向特定的方向移动。现在你需要处理动物、游戏场地和其他游戏对象之间的交互。

以相反的顺序处理输入

在屏幕上绘制对象的顺序很重要。例如,如果在绘制背景图像之前先绘制企鹅,玩家将永远看不到企鹅。然而,对象处理输入的顺序不应该和它们被绘制的顺序一样!在企鹅配对游戏中,这将导致意想不到的行为。

假设两只企鹅在操场上挨着,你点击其中一只。然后出现四个箭头。因为两只企鹅紧挨着,所以其中一个箭头画在了另一只企鹅的上方(见图 22-3 )。如果你点击那个箭头,会发生什么?被选中的企鹅向左移动,还是你选择另一只企鹅?

9781430265382_Fig22-03.jpg

图 22-3 。点击左箭头会发生什么?

这个问题的结果取决于每个游戏对象处理输入的顺序。如果企鹅在企鹅选择器之前处理输入,那么企鹅选择器将移动到另一只企鹅。如果先调用选择器的handleInput方法,那么选中的企鹅会向左移动。一般来说,当你开发程序时,你想控制程序的行为。这意味着你必须选择处理输入的顺序,并确保总是这样。在这种情况下,期望的行为是选定的企鹅向左移动。一般来说,你会希望绘制在顶部的对象首先处理输入。换句话说,您需要在列表中的对象上调用handleInput方法,调用顺序与绘制顺序相反。这可以很容易地用下面的for指令来完成,你把它放在GameObjectList. handleInput方法体中:

GameObjectList.prototype.handleInput = function (delta) {
    for (var i = this._gameObjects.length - 1; i >=0; i--)
        this._gameObjects[i].handleInput(delta);
};

因此,绘制在顶部的对象现在首先处理输入。这再次说明了在指定播放器界面时,获得这样的细节是多么重要。对玩家来说不直观的界面会很快导致挫败感——玩家可能会因为界面问题而不再想玩你的游戏。

更新动物

动物和其他游戏对象之间的交互是在Animal类的update方法中完成的。在Animal类中这样做的主要原因是每个动物处理自己的交互。如果你在游戏中添加了多个动物(就像你在这里所做的),你不需要改变任何处理交互的代码。首先,如果动物不可见或者速度为零,你不必更新它。因此,update方法中的第一条指令是

SpriteGameObject.prototype.update.call(this, delta);
if (!this.visible || this.velocity.isZero)
    return;

可以看到,首先调用基类的update方法。因为SpriteGameObject类没有覆盖update方法,所以它调用了GameObject类中定义的update方法,该方法通过添加速度乘以游戏时间来更新对象的位置。现在你要检查动物是否与另一个游戏对象发生碰撞。因为您在update方法开始时所做的检查,所以您只对可见和移动的动物进行检查。

如果动物在移动,你需要知道它正在移动到哪个格子。然后,您可以检查它是哪种类型的图块,以及是否有其他游戏对象位于该图块。为此,您向Animal类添加一个名为currentBlock的属性。你怎么能计算出动物正在进入的瓷砖呢?当一只企鹅向左移动时,可以计算出该瓷砖的 x 指数如下:

var tileField = this.root.find(ID.tiles);
var xIndex = Math.floor(this.position.x / tileField.cellWidth);

因为Math.floor产生的最接近的整数比它作为参数得到的值小,所以你会在精灵的左边位置结束。然而,这只在动物向左移动时找到正确的 x 指数。当动物移动到右侧时,您需要计算企鹅精灵的最右侧像素移动到的区块。为了解决这个问题,如果 x 速度为正,则在计算的 x 指数上加 1。你做一些类似的事情来计算 y 指数。以下是currentBlock属性的完整头部和主体:

Object.defineProperty(Animal.prototype, "currentBlock",
    {
        get: function () {
            var tileField = this.root.find(ID.tiles);
            var p = new Vector2(Math.floor(this.position.x /
                tileField.cellWidth),Math.floor(this.position.y /
                tileField.cellHeight));
            if (this.velocity.x > 0)
                p.x++;
            if (this.velocity.y > 0)
                p.y++;
            return p;
        }
    });

下一步是找出动物正在移动到哪种瓷砖。为此,您必须向 tile 字段添加一些方法。要正确地做到这一点,您需要添加一个继承自GameObjectGrid的名为TileField的类,并向该类添加一些方法。一种方法检查给定的 xy 索引是否在图块区域之外。这种方法叫做isOutsideField,很简单:

TileField.prototype.isOutsideField = function (pos) {
    return (pos.x < 0 || pos.x >=this.columns || pos.y < 0 || pos.y >=
        this.rows);
};

该方法在另一个方法getTileType中使用,该方法检索给定图块位置的图块类型。在这种方法中,首先要检查的是该点是否在平铺区域之外。如果是这种情况,则返回背景(透明)平铺类型:

if (this.isOutsideField(pos))
    return TileType.background;

在所有其他情况下,您可以通过从图块字段获取Tile对象并返回其类型: 来检索图块类型

return this.at(pos.x, pos.y).type;

现在你可以回到Animal.update的方法,检查动物是否从瓷砖地上掉了下来。如果是这样,你将动物的可见性设置为false并将其速度设置为零,以确保动物在不可见时不会无限移动:

var target = this.currentBlock;
var tileField = this.root.find(ID.tiles);
if (tileField.getTileType(target) === TileType.background) {
    this.visible = false;
    this.velocity = Vector2.zero;
}

另一种可能是动物撞到了墙砖。如果是这样,它必须停止移动:

else if (tileField.getTileType(target) === TileType.wall)
    this.stopMoving();

停止移动并不像听起来那么容易。您可以简单地将动物的速度设置为零,但这样动物的一部分就会在另一个图块中。你需要把动物放在刚刚离开的格子上。方法stopMoving完成 正是如此。在这个方法中,你首先要计算旧瓷砖的位置。您可以从动物当前移动到的区块的 xy 索引开始。这些是作为参数传递的。例如,如果动物的速度是向量 (300,0) (向右移动),则需要从 x 索引中减去 1,以获得动物正在移出的图块的 x 索引。如果动物的速度是 (0,-300) (向上移动),那么您需要将* 1 加到 y 索引上,以获得动物正在移出的图块的 y 索引。你可以通过标准化速度矢量并从 xy 索引中减去它来实现。这是可行的,因为规范化一个向量会产生一个长度为 1(单位长度)的向量。因为动物只能在 xy 方向移动,而不能沿对角线方向移动,所以在第一个示例中,您最终得到一个矢量 (1,0) ,在第二个示例中得到一个矢量 (0,-1) 。因此,您将动物的位置设置为它刚刚移出的图块的位置,如下所示:*

var tileField = this.root.find(ID.tiles);
this.velocity.normalize();
var currBlock = this.currentBlock;
this.position = new Vector2(Math.floor(currBlock.x - this.velocity.x) *
    tileField.cellWidth, Math.floor(currBlock.y - this.velocity.y) *
    tileField.cellHeight);

请注意,您将位置乘以平铺字段中单元格的宽度和高度。这是因为图块索引与屏幕上的实际像素位置不同。图块索引仅指示图块在网格中的位置,而动物位置需要以像素表示为屏幕上的位置。

最后,将动物的速度设置为零,使其保持在新位置:

this.velocity = Vector2.zero;

遇见其他游戏对象

您仍然需要检查动物是否与另一个游戏对象发生碰撞,例如另一只企鹅或鲨鱼。有一些特殊类型的动物:

  • 五彩企鹅
  • 空盒子
  • 密封

您可以向Animal类添加一些方法来确定您是否正在处理这些特殊情况。例如,如果纸张索引等于 7 并且没有装箱,您正在处理一个印章:

Animal.prototype.isSeal = function () {
    return this.sheetIndex === 7 && !this.boxed;
};

如果工作表索引是 7 并且是有框的,则您处理的是一个空框:

Animal.prototype.isEmptyBox = function () {
    return this.sheetIndex === 7 && this.boxed;
};

最后,如果纸张索引是 6 并且没有装箱,那么您将处理一只多色企鹅:

Animal.prototype.isMulticolor = function () {
    return this.sheetIndex === 6 && !this.boxed;
};

首先,你要检查动物要进入的区域是否有鲨鱼。为此,您检索关卡并使用来自Level类的findSharkAtPosition方法来找出是否有鲨鱼:

var s = this.root.findSharkAtPosition(target);
if (s !== null && s.visible) {
    // handle the shark interaction
}

findSharkAtPosition方法很简单;看看属于本章的示例代码中的方法。如果企鹅遇到鲨鱼,企鹅会被吃掉,鲨鱼会带着满满的肚子离开赛场。在游戏中,这意味着企鹅永远停止移动,鲨鱼和企鹅都变得看不见了。下面几行代码实现了这一点:

s.visible = false;
this.visible = false;
this.stopMoving();

接下来要检查的是是否有另一只企鹅或海豹。为此,您可以使用来自Level类的findAnimalAtPosition方法。按如下方式取回动物:

var a = this.root.findAnimalAtPosition(target);

如果方法返回null或者动物不可见,你不需要做任何事情,你可以从方法返回:

if (a === null || !a.visible)
    return;

你解决的第一个案例是企鹅和海豹相撞。在这种情况下,企鹅什么都不用做——它只是停止移动:

if (a.isSeal())
    this.stopMoving();

下一种情况是如果动物撞上一个空盒子。如果是这种情况,通过将盒子的纸张索引设置为动物的纸张索引来移动盒子内的动物,并使动物不可见:

else if (a.isEmptyBox()) {
    this.visible = false;
    a.sheetIndex = this.sheetIndex;
}

如果动物a的表索引与企鹅的表索引相同,或者其中一只企鹅是多色的,则您拥有一对有效的企鹅,并使两只企鹅都不可见:

else if (a.sheetIndex === this.sheetIndex || this.isMulticolor() || a.isMulticolor()) {
    a.visible = false;
    this.visible = false;
}

你还必须在屏幕的左上角显示一个额外的对子,但是你将在下一节中处理这个问题。最后,在所有其他情况下,企鹅停止移动:

else
    this.stopMoving();

保持线对的数量

为了保持对子的数量并在屏幕上很好地绘制出来,您向游戏中添加了另一个名为PairList的类。PairList类继承自SpriteGameObject类。它由一个框架组成,在框架的顶部绘制了许多小精灵,指示所需的对子数。因为您想要指示配对的颜色,所以您将这些配对作为整数值存储在一个数组中。这个数组是PairList类的成员变量:

this._pairs = [];

您将整数值放在这个数组中,因为您可以定义每一对的颜色以及总共需要多少对。在成员变量pairSprite中,存储代表一对的 sprite,并将对列表设置为该 sprite 的父级:

this._pairSprite = new SpriteGameObject(sprites.penguin_pairs);
this._pairSprite.parent = this;

那个精灵的图像是一个精灵带,彩色的一对以和企鹅一样的方式排列(见图 22-4 )。如果仍然需要制作一对,则条带(纸张索引 7)中最右边的图像是您显示的图像。因此,如果_pairs数组包含值{0, 0, 2, 7, 7},这意味着玩家已经做了两对蓝色企鹅和一对绿色企鹅,玩家需要再做两对才能完成关卡。

9781430265382_Fig22-04.jpg

图 22-4 。包含所有可能的图像对的子画面

您将参数nrPairs传递给PairList类的构造函数,这样您就知道数组应该有多大。然后填充数组,以便将每个元素设置为空槽(工作表索引 7):

for (var i = 0; i < nrPairs; i++)
    this._pairs.push(7);

您还向该类添加了一个方法addPair,该方法在数组中查找第一次出现的值 7,并用作为参数传递的索引来替换它:

PairList.prototype.addPair = function (index) {
    var i = 0;
    while (i < this._pairs.length && this._pairs[i] !== 7)
        i++;
    if (i < this._pairs.length)
        this._pairs[i] = index;
};

这个例子使用了一个while指令来增加i变量,直到你找到一个空的点。

现在你添加一个有用的属性来检查玩家是否完成了关卡。如果配对列表不再包含任何值 7(意味着所有空位都已被配对替换),则该级别完成:

Object.defineProperty(PairList.prototype, "completed",
    {
        get: function () {
            for (var i = 0, l = this._pairs.length; i < l; i++)
                if (this._pairs[i] === 7)
                    return false;
            return true;
        }
    });

最后,你需要用draw方法在屏幕上画出对子。这里您使用一个for指令来遍历对列表中的所有索引。对于每个索引,在适当的位置绘制正确的精灵。请注意,您使用相同的精灵,并简单地用不同的工作表索引绘制多次:

PairList.prototype.draw = function () {
    SpriteGameObject.prototype.draw.call(this);
    if (!this.visible)
        return;
    for (var i = 0, l = this._pairs.length; i < l; i++) {
        this._pairSprite.position = new Vector2(110 + i * this.height, 8);
        this._pairSprite.sheetIndex = this._pairs[i];
        this._pairSprite.draw();
    }
};

对 base draw方法的调用确保首先绘制背景帧。

现在您已经有了PairList类,您可以在Level类中创建它的一个实例,将它添加到游戏世界中,并将其放在屏幕左上角附近:

var pairList = new PairList(levelData.nrPairs, ID.layer_overlays, ID.pairList);

pairList.position = new Vector2(20, 15);
this.add(pairList);

Animal类中,如果一只动物遇到另一只相同颜色的企鹅,或者两只动物中有一只是多色企鹅,你就在列表中添加一对:

else if (a.sheetIndex === this.sheetIndex || this.isMulticolor() ||
    a.isMulticolor()) {
    a.visible = false;
    this.visible = false;
    this.root.find(ID.pairList).addPair(this.sheetIndex);
}

完整示例见本章的PenguinPairs5程序。在下一章中,您将为企鹅配对游戏添加最后的润色,您将看到一种更好的方法来将游戏通用代码(如SpriteGameObject类或GameStateManager类)与游戏特定代码(如PairList类)分开。

你学到了什么

在本章中,您学习了:

  • 如何编程一个游戏对象选择器
  • 如何模拟不同种类的游戏对象之间的交互
  • 如何保持和抽牌手所做的对子数*

二十三、完成企鹅配对游戏

在这一章中,你将完成企鹅配对游戏。第一步是稍微重组一下你的代码,这样其他程序就可以更容易地使用其中的一部分。然后你通过扩展用户界面和添加音效来完成游戏。

将代码分成不同的模块

尤其是在开发更复杂的应用时,将相关的类组合在一起是有意义的。例如,如果你正在开发一个复杂的游戏,将会有与模拟物理相关的类,做人工智能如路径规划的类,提供网络可玩性的类,用户界面类,等等。尽管本书中使用的例子并不复杂,但是您仍然可以看到只对特定游戏有用的类和跨不同游戏使用的类之间的明显区别。例如,这三个游戏都有一些基本游戏对象的概念,宝石果酱游戏和企鹅配对游戏都使用网格作为其游戏场的基础。此外,在几层上绘制的游戏对象的层次结构的概念在不同的游戏中使用。总的来说,这本书坚持在不同的游戏中使用相似的类。事实上,您复制了宝石果酱游戏中的类,并在企鹅配对游戏中使用它们。

正如本书前面几次讨论的那样,将代码复制到不同的项目中是一件坏事。复制代码意味着 bug 也可以被复制;如果您做了任何更改或改进,您将不得不在复制代码的所有地方都这样做。如何避免不同游戏之间复制代码?实现这一点的最佳方式是将游戏专用代码与游戏通用代码分开。通过将通用类放在一个单独的文件夹中,您可以更容易地在其他游戏项目中重用这些代码。通过在不同的游戏项目中选择智能文件夹结构,您可以轻松确保不必为每个项目复制通用代码。在本节中,您将建立这种结构,并看到一种在 JavaScript 中区分通用类和特定于游戏的类的简洁方法,即使用一种叫做名称空间的概念。

作为名称空间的变量

名称空间通常在编程语言中使用,为类的归属提供一些结构。许多编程语言都在语言规范中包含了名称空间支持。JavaScript 不属于这些语言中的一种,但是使用该语言中的现有功能来建立类似的东西是非常容易的。

您已经在示例代码中看到,变量是对象组。这些对象可以是对象文字、字符串、数字,甚至是函数。因为类是由 JavaScript 中的函数定义的,所以您可以将类组合在一个变量中,使该变量充当名称空间。例如,假设您想要创建一个 JavaScript 游戏引擎,其中包含您在本书中构建的所有通用类和对象。我们姑且称这个游戏引擎为powerupjs。您可以开始如下定义您的类:

var powerupjs = {
    GameObject : function(layer, id) {
        ...
    },
    GameObjectList : function(layer, id) {
        ...
    }
};

现在,每当你想使用类GameObject,你输入powerupjs.GameObject。在 JavaScript 代码中,这会让用户清楚地知道GameObject属于powerupjs名称空间。这很棒,但是这意味着你必须把所有的类放在一个 JavaScript 文件中,这并没有真正提高你的程序的可读性。让我们研究一下如何以更聪明的方式做到这一点。

名称空间的设计模式

为了使在 JavaScript 中使用名称空间更容易,您使用了一个设计模式。第二十章在讨论单例(只允许一个实例的类)时简要地谈到了设计模式。这种单例设计模式使用如下:

function MySingletonClass_Singleton () {
    ...
}

// add methods and properties here
MySingletonClass_Singleton.prototype.myMethod() = function () {
    ...
};
...
var MySingletonClass = new MySingletonClass_Singleton();

// now we can use the variable as a single instance
MySingletonClass.myMethod();

将类放入名称空间的设计模式使用 JavaScript 机制,让您可以同时定义和调用函数。也许您还记得以前在定义请求下一次游戏循环迭代的函数时使用过这个方法:

var requestAnimationFrame = (function () {
        return window.requestAnimationFrame ||
            window.webkitRequestAnimationFrame ||
            window.mozRequestAnimationFrame ||
            window.oRequestAnimationFrame ||
            window.msRequestAnimationFrame ||
            function (callback) {
                window.setTimeout(callback, 1000 / 60);
            };
    })();

这里的变量requestAnimationFrame包含一个函数的结果,这个函数被定义并立即调用。以非常相似的方式,您可以将类的定义放在这样的函数中。看看下面的例子:

var powerupjs = (function (module) {

    function Vector2(x, y) {
        this.x = typeof x !== 'undefined' ? x : 0;
        this.y = typeof y !== 'undefined' ? y : 0;
    }

    Object.defineProperty(Vector2, "zero",
        {
            get: function () {
                return new powerupjs.Vector2();
            }
        });

    Vector2.prototype.equals = function (obj) {
        return this.x === obj.x && this.y === obj.y;
    };

    // add more methods and properties
    ...

    module.Vector2 = Vector2;
    return module;

})({});

这个例子创建了一个函数,它需要一个对象文本module作为参数。在函数中,您创建了Vector2类并定义了它的属性和方法。您将该类赋给module变量中的一个变量,然后返回该变量。通过向函数传递一个空的对象文字来执行该函数。函数的结果存储在变量powerupjs中,该变量现在包含一个名为Vector2的变量,该变量引用类Vector2。对于您定义的下一个类,您将传递变量powerupjs,而不是一个空的对象文字,这样powerupjs变量就被所有应该在powerupjs名称空间中的类填充了。通过使用智能 JavaScript 语法,您可以让这变得更好。考虑下面的类定义:

var powerupjs = (function (powerupjs) {

    function Vector2(x, y) {
        this.x = typeof x !== 'undefined' ? x : 0;
        this.y = typeof y !== 'undefined' ? y : 0;
    }
    // etc.

    powerupjs.Vector2 = Vector2;
    return powerupjs;

})(powerupjs || {});

为了清楚起见,这里您将参数module重命名为powerupjs;不是传递一个空的对象文字,而是传递表达式powerupjs || {}。如果定义了变量,这个表达式的结果是powerupjs,否则是一个空的对象文字。您将这个名称空间设计模式添加到所有通用游戏类中。不管这些类被添加到名称空间的顺序如何,第一次添加时,你从一个空的对象文字开始,之后,powerupjs变量被定义并用其余的类补充。属于本章的示例代码包括一个名为powerupjs的文件夹;在这个文件夹中是所有通用的游戏类,它们都在powerupjs名称空间中。本书剩余的例子重用了powerupjs模块(或库)作为示例游戏的基础。

名称空间模式对于将相关的类组合在一起是一个非常有用的模式。无论何时构建复杂的应用,使用名称空间都是一个好主意。这样,您就可以清楚地向代码的用户展示这些类是如何相互关联的。您甚至可以更极端,使用完全相同的设计模式将名称空间分组到其他更大的名称空间中。

名称空间还提供了一点额外的安全性。例如,看看命名空间中的以下类定义:

var powerupjs = (function (powerupjs) {

    function GameStateManager_Singleton() {
        this._gameStates = [];
        this._currentGameState = null;
    }

    // add methods/properties here
    ...

    powerupjs.GameStateManager = new GameStateManager_Singleton();
    return powerupjs;

})(powerupjs || {});

这个例子展示了GameStateManager类的定义,它是一个单例类。这是一个 singleton 的事实可以通过你将类的一个实例分配给powerupjs.GameStateManager而不是类定义本身来看出来。真正好的是,类定义现在被命名空间封装了——不再可能在其他 JavaScript 文件中访问GameStateManager_Singleton,从而确保只能使用该类的单个实例,这正是 singleton 设计模式的要点!

封装是封闭类定义的函数的结果。您可以用其他方式来控制哪些函数或类在哪里可用。例如,也许一个类的一些方法应该是内部的(或者是私有的)。您可以这样做:

var powerupjs = (function (powerupjs) {

    ...

    function privateMethod(obj) {
        // do something with the object
        obj._currentGameState = ...;
    }

    GameStateManager_Singleton.prototype.publicMethod() {
        privateMethod(this);
        ...
    }

    powerupjs.GameStateManager = new GameStateManager_Singleton();
    return powerupjs;

})(powerupjs || {});

在这个例子中,方法privateMethod可以在GameStateManager实例上执行操作,并且可以从对象中的其他方法调用它,但是该方法不能从其他 JavaScript 类访问。

在模块和文件夹中组织类有助于为一组相关类的结构提供更好的感觉。图 23-1 显示了如何将powerupjs模块组织到不同的文件夹中。当你创建一个模块时,为模块的用户提供一个如图图 23-1 所示的图表是一个好主意。此外,因为一个模块可以由许多不同的类组成,所以您可能还想提供一些文档来描述该模块的总体思想。在powerupjs的例子中,重要的是让用户知道该模块严重依赖于一个运行中的游戏循环,该循环具有更新和绘制自己的游戏对象。此外,最好详细描述每个方法做什么,它期望什么样的参数,调用方法做什么,以及任何特殊情况。本书的最后一部分更详细地讨论了文档,并且您还学习了一些使文档更容易阅读和更容易被您的类的用户访问的方法。

9781430265382_Fig23-01.jpg

图 23-1 。powerupjs模块 的模块和文件夹结构概述

整理用户界面

在本节中,您将完成用户界面。首先,您将看到如何在游戏中添加提示机制。然后,您将看到如何重置并进入下一个级别。你通过添加音效来完成游戏。

显示提示

既然你已经重组了你的代码,企鹅配对游戏中还会增加一些新的功能。首先,您希望能够在用户单击按钮时显示提示。该提示由一个可见一秒钟的橙色箭头组成。当您加载关卡时,您从levelData变量中读取提示位置和方向,并创建一个SpriteGameObject实例来加载箭头,选择正确的工作表索引,并在将其添加到游戏世界之前对其进行适当定位:

var hint = new powerupjs.SpriteGameObject(sprites.arrow_hint, ID.layer_objects_2);
hint.sheetIndex = levelData.hint_arrow_direction;
hint.position = new powerupjs.Vector2(levelData.hint_arrow_x * 73,
    levelData.hint_arrow_y * 72);
playingField.add(hint);

为了临时显示箭头,您重用了宝石果酱游戏中的VisibilityTimer类。您创建了该类的一个实例,并将其添加到游戏世界中:

this.hintVisible = new VisibilityTimer(hint);
playingField.add(this.hintVisible);

您还可以添加一个按钮,玩家可以单击该按钮在屏幕上显示提示:

this.hintButton = new powerupjs.Button(sprites.button_hint, ID.layer_overlays);
this.hintButton.position = new powerupjs.Vector2(916, 20);
this.add(this.hintButton);

最后,您扩展了LevelhandleInput方法来处理被按下的提示按钮:

if (this.hintButton.pressed)
    this.hintVisible.startVisible();

提示按钮只有在可见的情况下才能被按下,在某些情况下它不应该是可见的:

  • 玩家走完第一步后,提示按钮应该消失,重试按钮应该出现。
  • 如果玩家选择关闭选项菜单中的提示,提示按钮应该永远不可见。

对于第一种情况,您需要跟踪玩家何时开始第一步行动。您向Level类添加了一个额外的成员变量firstMoveMade。当你给一个动物一个速度,这是在AnimalSelector类中完成的。一旦玩家点击了一个箭头,动物开始移动,你就将firstMoveMade变量设置为true:

this.selectedAnimal.velocity = animalVelocity;
this.root.firstMoveMade = true;

第二,你必须处理游戏选项菜单中的提示设置。您可以在Level类的update方法中这样做。您只需检查GameSettings变量中提示设置的值,并相应地更新提示和重试按钮的可见性状态:

this.hintButton.visible = GameSettings.hints && !this.firstMoveMade;
this.retryButton.visible = !this.hintButton.visible;

从这两行代码中可以看出,提示按钮只有在GameSettings.hintstrue且玩家尚未迈出第一步的情况下才可见。重试按钮的可见性状态总是与提示按钮的可见性状态相反。因此,如果提示按钮可见,重试按钮不可见,反之亦然。

重置级别

在玩家移动了几个动物后,可能会出现关卡无法解决的情况。与其退出并重启游戏,不如给玩家一个重置关卡到初始状态的方法。

由于在游戏对象类中到处都正确地实现了reset方法,重置一个关卡到它的初始状态变得非常容易。你必须在所有的游戏对象上调用reset方法,然后在Level类本身中处理重置的事情。你唯一需要做的就是将firstMoveMade变量设置为false,这样玩家就可以再次查看提示:

Level.prototype.reset = function () {
    powerupjs.GameObjectList.prototype.reset.call(this);
    this.firstMoveMade = false;
};

注意企鹅配对游戏有许多扩展方式。例如,您能否编写代码来确定某个级别是否仍然可解?如果发生这种情况,你可以通过向用户显示消息来延长游戏。你可能对如何改进游戏有自己的想法。通过修改和添加示例,您可以随意尝试它们。

进入下一阶段

当玩家完成一关(万岁!),你想显示一个鼓励的覆盖图(截图见图 23-2 )。当玩家点击或轻敲屏幕时,将显示下一关。因为您创建了GameStateManager类,所以让我们通过添加另一个状态来利用它:LevelFinishedState。这个状态唯一能做的就是显示覆盖图,并对玩家的点击做出反应。因为覆盖图显示在关卡的顶部,所以您仍然需要对游戏状态做一些事情。因此,您将它存储在成员变量中。此外,您加载一个覆盖图,将其放置在屏幕中央,并将其添加到游戏世界中。下面是完整的构造函数方法:

function LevelFinishedState() {
    powerupjs.GameObjectList.call(this);
    this.playingState = powerupjs.GameStateManager.get(ID.game_state_playing);
    this.overlay = new powerupjs.SpriteGameObject(sprites.level_finished, ID.layer_overlays);
    this.overlay.position = this.overlay.screenCenter;
    this.add(this.overlay);
}

9781430265382_Fig23-02.jpg

图 23-2 。玩家完成一关后显示的覆盖图截图

您希望在播放状态上显示覆盖图,但是您不希望播放状态能够再处理输入(否则玩家仍然可以移动企鹅)。因此,您只需要在playingState对象上调用updatedraw方法,而不是handleInput方法。

LevelFinishedStatehandleInput方法中,你检查玩家是否按下了鼠标键或者轻击了屏幕。如果是这样,你将当前状态设置为播放状态,并对其调用nextLevel方法:

LevelFinishedState.prototype.handleInput = function (delta) {
    if (powerupjs.Touch.isTouchDevice) {
        if (!powerupjs.Touch.containsTouchPress(this.overlay.boundingBox))
            return;
    }
    else if (!powerupjs.Mouse.left.pressed)
        return;
    powerupjs.GameStateManager.switchTo(ID.game_state_playing);
    this.playingState.nextLevel();
};

nextLevel方法是如何工作的?它必须处理两种可能性。第一种可能是玩家完成了最后一关。在这种情况下,您将返回到级别菜单状态。在所有其他情况下,你增加当前级别索引,并为玩家解锁下一个级别。最后,因为您更改了关卡状态,所以您将它写入本地存储,以便玩家下次开始游戏时,游戏会记住玩家已经解决了哪些关卡。完整的nextLevel方法如下所示:

PlayingState.prototype.nextLevel = function () {
    if (this.currentLevelIndex >=window.LEVELS.length - 1)
        powerupjs.GameStateManager.switchTo(ID.game_state_levelselect);
    else {
        this.goToLevel(this.currentLevelIndex + 1);
        window.LEVELS[this.currentLevelIndex].locked = false;
    }
    this.writeLevelsStatus();
};

你唯一需要做的就是确保当玩家获胜时,游戏进入关卡完成状态。您可以通过使用Level类的completed属性,在播放状态的update方法中实现这一点:

if (this.currentLevel.completed) {
    window.LEVELS[this.currentLevelIndex].solved = true;
    powerupjs.GameStateManager.switchTo(ID.game_state_levelfinished);
}

如果玩家已经完成了关卡,您将该关卡的solved状态设置为true,并且您将当前游戏状态更改为关卡完成状态。

教程

正如你可能已经注意到的,企鹅配对游戏的前几关也是一个教程,解释这个游戏应该怎么玩。当你创造一个游戏时,玩家必须学会如何玩它。如果你不告诉玩家挑战和目标是什么,以及如何控制游戏,他们很可能会感到沮丧,停止游戏。

一些游戏提供了大量的帮助文件,用很长的文字解释故事和控制。玩家不再想阅读这样的文档或屏幕。他们想直接进入游戏。你必须在玩家玩的时候教育他们。

您可以创建几个特定的教程级别,玩家可以在其中练习控制,而不会严重影响游戏本身的进度。这种方法很受休闲游戏玩家的欢迎,作为对游戏的介绍。经验丰富的游戏玩家更喜欢立即投入行动。注意不要在教程中解释所有的内容。只解释基本的控制。解释游戏中需要的更高级的控制:例如,使用简单的弹出消息,或者在 HUD 中的可见位置。

教程在自然地融入游戏故事时效果最好。例如,游戏角色可能开始在他们安全的家乡跑来跑去,学习基本的移动控制。接下来角色和几个朋友一起练习格斗。之后,玩家进入树林,试图用弓射一些鸟。这将为游戏后期的战斗提供所有需要的练习。

你应该确保你的教程水平的工作,并确保玩家记住控制,即使他们把游戏放了几天。否则,他们可能再也不会回来玩游戏了。

添加声音效果

为了完成游戏,你应该在适当的地方添加声音和音乐。您可能还记得,选项菜单中的一个选项是更改背景音量。您可以使用下面的代码行来实现这一点:

sounds.music.volume = this.musicSlider.value;

PenguinPairs课上,你开始播放音乐:

sounds.music.play();

同样,你在适当的时候播放音效,就像你在宝石果酱游戏中做的那样。例如,每当制作一对企鹅时,就播放一个声音效果(参见Animal类中的update方法):

sounds.pair.play();

如果你看看属于这一章的PenguinPairsFinal例子,你就可以看到完整的游戏是如何工作的,音效是在哪里回放的,当然你也可以自己玩游戏。

团队合作

第一代游戏是由程序员创造的。他们做了所有的工作。他们设计了游戏机制,他们创造了艺术(仅由几个像素组成),他们用汇编语言编写了游戏程序。所有的工作都集中在编程上。游戏机制经常被改编成可以有效编程的东西。

但是当更多的内存变得可用时,这种情况慢慢改变了。用有限数量的像素和颜色创建看起来很花哨的对象成为了一种艺术形式,这样的像素艺术家开始在开发游戏中发挥重要作用。在早期,没有绘图程序(没有足够强大的计算机能做到这一点)。像素化的角色被设计在绘图纸上,然后被转换成十六进制数字,输入到游戏代码中。

随着计算机能力和 CD 等存储媒体的增加,艺术变得越来越重要,艺术家也随之发展。3D 图形和动画变得普遍,导致新的专家可以使用新的工具和技术来支持这种工作。如今,艺术家构成了游戏制作团队的大多数。

在某种程度上,设计游戏成了一项独立的工作。游戏机制被调整到用户群的兴趣,并且越来越基于心理学和教育科学的原则。这需要单独的专业知识。故事扮演了一个重要的角色,导致了作家的加入。这些团队被扩展到包括制作人、音响工程师、作曲家和许多其他类型的人。今天,顶级游戏的团队可以由数百人组成。但是没有程序员,什么都做不了。

最后一点

在本书的这一部分,你已经创建了一个比之前的示例游戏更复杂的游戏,宝石果酱。你可能已经注意到职业的数量已经变得相当大了,你越来越依赖于游戏软件的某种设计。例如,你在一个树形结构中组织游戏对象,并使用一个类来处理游戏状态。在更基本的层面上,你假设游戏对象负责处理它们的输入,更新它们自己,并在屏幕上绘制它们自己。您可能不同意这些设计选择中的一些(或全部)。或许,在读完这本书之后,你已经对游戏软件应该如何设计有了自己的想法。这是一件好事。我在本书中提出的设计并不是做事的唯一方式。设计总是可以被评估和改进,甚至被抛弃,被完全不同的东西所取代。所以,不要犹豫,批判地看待我提出的设计,尝试其他设计。通过尝试不同的方法来解决问题,您可以更好地理解问题,并因此成为更好的软件开发人员。

你学到了什么

在本章中,您学习了:

  • 如何将类分组到命名空间中
  • 如何将一个级别重置为其初始状态,并处理进入下一个级别

二十四、主游戏结构

在这一章中,你将为滴答滴答游戏设计一个框架。因为你已经为之前的游戏做了很多工作,所以你可以依赖很多已经存在的类。事实上,你是在前一章的powerupjs命名空间/库中分组的类上构建游戏的。这意味着你已经有了处理游戏状态和设置的基本设计,游戏对象的层次结构,等等。稍后,您可以通过添加与动画游戏对象相关的类来扩展powerupjs库。你可以在图书馆看到这些课程;它们将在下一章讨论。

游戏结构概述

这个游戏的结构与企鹅配对游戏非常相似。有一个标题屏幕允许玩家进入等级选择菜单或帮助页面(见图 24-1 )。为了简单起见,您不需要实现选项页面,尽管添加它会很简单,因为您可以使用与 Penguin Pairs 中相同的方法。因为菜单结构非常相似,所以这里不讨论。您可以在TickTick1文件夹中看到包含属于本章的示例代码的代码。

9781430265382_Fig24-01.jpg

图 24-1 。滴答滴答游戏的标题画面

PlayingState类保持当前等级,处理载入和保存等级状态(已解决/锁定),就像企鹅配对游戏一样。游戏状态创建了Level对象,每个对象包含一个基于瓷砖的游戏世界,同样非常类似于企鹅配对的构造方式。

级别的结构

我们先来看看嘀嗒嘀嗒里什么样的东西可以在一个等级里。首先,有一个背景图像。现在,您显示一个简单的背景精灵;不需要在 level 数据变量中存储任何相关信息。也有不同种类的块,玩家可以跳,随着水滴,敌人,玩家的开始位置,和玩家必须到达的结束位置。就像在企鹅配对游戏中一样,你将等级信息存储在一个全局变量中。这个变量存储在本地存储器中,以便当玩家完成一个级别时,浏览器在玩家下一次玩游戏时记住它。当然,这是假设玩家没有同时清空本地存储器。

使用瓷砖定义标高,其中每个瓷砖都有特定的类型(墙、背景等)。然后,在 level 数据变量中用一个字符来表示每种瓷砖类型。就像在企鹅配对游戏中一样,您可以在与游戏场地相对应的二维空间中以文本的形式显示关卡。在实际的图块旁边,还存储了一个提示和级别定义。这里你可以看到在LEVELS全局变量中存储第一级的指令:

window.LEVELS.push({
    hint : "Pick up all the water drops and reach the exit in time.",
    locked : false,
    solved : false,
    tiles : ["....................",
                ".................X..",
                "..........##########",
                "....................",
                "WWW....WWWW.........",
                "---....####.........",
                "....................",
                "WWW.................",
                "###.........WWWWW...",
                "............#####...",
                "....WWW.............",
                "....###.............",
                "....................",
                ".1........W.W.W.W.W.",
                "####################"]
});

该级别定义定义了许多不同的单幅图块和对象。例如,墙砖由#符号定义,水滴由W字符定义,玩家的开始位置由1字符定义。如果在特定的位置没有牌,你使用.字符。对于平台游戏,您需要不同类型的瓷砖:玩家可以站在上面或与之碰撞的墙壁瓷砖,以及指示该位置没有障碍物的背景/透明瓷砖。您还想定义一个平台图块。这种瓷砖的特性是玩家可以像墙砖一样站在上面,但是如果他们站在下面,他们可以从下面跳过去。这种磁贴在很多经典的平台游戏中都有使用,这里不收录就太可惜了!在级别数据变量中,平台瓦片由一个-字符表示。表 24-1 给出了滴答滴答游戏中不同牌的完整列表。

表 24-1 。Tick Tick 游戏中不同种类的牌概述

|

性格;角色;字母

|

瓷砖描述

|
| --- | --- |
| . | 背景瓷砖 |
| # | 瓷面砖 |
| ^ | 墙砖(热的) |
| * | 墙砖(冰) |
| - | 平台瓷砖 |
| + | 平台瓷砖(热) |
| @ | 平台瓷砖(冰) |
| X | 末端瓷砖 |
| W | 水滴 |
| 1 | 开始牌(初始玩家位置) |
| R | 火箭敌人(向左移动) |
| r | 火箭敌人(向右移动) |
| S | 闪亮的敌人 |
| T | 龟敌 |
| A | 火焰敌人(随机速度和方向变化) |
| B | 火焰敌人(玩家跟随) |
| C | 火焰敌人(巡逻) |

水滴

每一关的目标是收集所有的水滴。每个水滴都由一个WaterDrop类的实例来表示。这个类是一个SpriteGameObject子类,但是你想给它添加一点行为:水滴应该上下弹跳。你可以用update方法做到这一点。首先你计算一个反弹偏移量,你可以把它加到水滴的当前位置上。这个反弹偏移量存储在成员变量_bounce中,该变量在构造函数 中初始设置为 0

this._bounce = 0;

为了计算每个游戏循环迭代中的反弹偏移,您使用了一个正弦函数。根据水滴的 x 位置,你可以改变正弦信号的相位,这样就不会所有的水滴同时上下移动:

var t = powerupjs.Game.totalTime * 3 + this.position.x;
this._bounce = Math.sin(t) * 5;

将反弹值加到水滴的 y 位置:

this.position.y += this._bounce;

+=运算符将反弹值加到 y 位置(关于这些类型运算符的更多信息,参见第十章)。然而,简单地将反弹值加到 y 位置是不正确的,因为这是反弹偏移——换句话说,是相对于原始 y 位置的偏移。要获得原始的 y 位置,您需要从update方法的第一条指令中的 y 位置减去反弹偏移量:

this.position.y -= this._bounce;

这是可行的,因为此时,_bounce变量仍然包含前一次游戏循环迭代的反弹偏移量。所以,从 y 位置中减去就得到原始的 y 位置。

在下一章,你会添加更多的游戏对象,比如玩家和各种各样的敌人。但是我们先来看看在 Tick Tick 这样的平台游戏中如何定义瓦片。

瓷砖类

Tile类与企鹅配对中使用的非常相似,但也有一些不同。首先,在变量中定义不同的图块类型:

var TileType = {
    background: 0,
    normal: 1,
    platform: 2
};

Tile类中,然后声明一个成员变量type来存储一个实例所代表的图块类型。除了这些基本的牌类型,还有冰牌和热牌,它们是普通牌或平台牌的特殊版本。在级别数据变量中,一个冰砖由*字符表示(如果是平台砖,则由@字符表示),一个热砖由^字符表示(对于平台版本,则由+字符表示)。您向Tile类添加两个布尔成员变量及其相关属性来表示这些不同种类的图块。下面是完整的Tile构造函数:

function Tile(sprite, tileTp, layer, id) {
    sprite = typeof sprite !== 'undefined' ? sprite : sprites.wall;
    powerupjs.SpriteGameObject.call(this, sprite, layer, id);

    this.hot = false;
    this.ice = false;
    this.type = typeof tileTp !== 'undefined' ? tileTp : TileType.background;
}

如您所见,您检查了是否定义了spritetileTp变量。如果不是,就给它们分配一个默认值。这允许您创建Tile实例,而不必一直传递参数。例如,以下指令创建了一个简单的背景(透明)单幅图块:

var myTile = new Tile();

现在,让我们看看Level类和Tile实例是如何创建的。

水平等级

本节展示了Level类是如何在 Tick Tick 中设计的。这与企鹅配对的方式非常相似。在Level类的构造函数中,你做了几件事:

  • 创建背景精灵游戏对象。
  • 添加退出按钮。
  • 从关卡数据中创建基于图块的游戏世界。

前两个很简单。看看示例代码中的Level类,看看它们是如何工作的。创建基于磁贴的游戏世界是在一个叫做loadTiles的独立方法中完成的。根据等级指数的不同,创造出不同的游戏世界。第一步是创建一个具有所需高度和宽度的GameObjectGrid实例,取自levelData变量:

var tiles = new powerupjs.GameObjectGrid(levelData.tiles.length,
    levelData.tiles[0].length, 1, ID.tiles);
this.add(tiles);

您设置网格中每个单元格的宽度和高度,以便游戏对象网格知道在屏幕上的何处绘制图块:

tiles.cellWidth = 72;
tiles.cellHeight = 55;

然后创建Tile对象,并将它们添加到GameObjectGrid对象中:

for (var y = 0, ly = tiles.rows; y < ly; ++y)
    for (var x = 0, lx = tiles.columns; x < lx; ++x) {
        var t = this.loadTile(levelData.tiles[y][x], x, y);
        tiles.add(t, x, y);
    }

嵌套的for循环检查从级别数据变量中读取的所有字符。你使用一个叫做loadTile的方法,它为你创建一个Tile对象,给定一个角色和格子中瓷砖的 x -和y-位置。

loadTile方法中,您希望根据作为参数传递的字符加载不同的图块。对于每种类型的图块,您向Level类添加一个方法来创建这种特殊类型的图块。例如,LoadWaterTile加载一个顶部有水滴的背景拼贴:

Level.prototype.loadWaterTile = function (x, y) {
    var tiles = this.find(ID.tiles);
    var w = new WaterDrop(ID.layer_objects);
    w.origin = w.center.copy();
    w.position = new powerupjs.Vector2((x + 0.5) * tiles.cellWidth,
        (y + 0.5) * tiles.cellHeight - 10);
    this._waterdrops.add(w);
    return new Tile();
};

这个特殊的例子创建了一个WaterDrop实例,并将其放置在图块的中心。您将每个水滴放置在比中心高 10 个像素的位置,这样水滴就不会在它下面的瓷砖上反弹。查看Level类,了解如何在每一层创建不同的图块和对象。图 24-2 显示了第一关中物体的截图(除了玩家角色,你会在后面的章节中处理)。

9781430265382_Fig24-02.jpg

图 24-2 。属于滴答滴答第一关的游戏世界

你学到了什么

在本章中,您学习了:

  • 如何设置滴答滴答游戏的总体结构
  • 如何创建一个弹跳水滴

二十五、动画

在这一章中,你将看到如何给你的游戏添加动画。在你到目前为止开发的游戏中,游戏对象可以在屏幕上四处移动,但是在游戏中添加一些像跑步的角色稍微有点挑战性。在这一章中,你要编写一个程序,其中包含一个在屏幕上从左向右行走的角色。玩家按下左右箭头键来控制角色。在这个特殊的例子中,您没有添加触摸界面控件,但是稍后您将看到如何在触摸设备上控制移动的角色。

本书中没有明确涉及的另一件事是使用大多数现代设备内置的加速度计。您可以在 JavaScript 中通过处理诸如ondeviceorientation(或者 Firefox 中的onmozorientation)和ondevicemotion之类的事件来访问这些数据,这些事件提供了与设备当前加速度相关的数据。如果你觉得你能胜任,你可以尝试扩展本章中的例子,这样它就能以一种有意义的方式对这些事件做出反应。

什么是动画?

在你研究如何设计一个角色在屏幕上走来走去之前,你首先要考虑什么是动画。要理解这一点,你必须回到 20 世纪 30 年代,当时几家动画工作室(其中包括华特·迪士尼)制作了第一部黑白动画片。

一部卡通片实际上是一系列非常快速的静止图像,也称为。电视以非常高的速度绘制这些帧,大约每秒 25 次。当图像每次都发生变化时,你的大脑会将其解释为运动。人类大脑的这一特殊功能(也称为 phi 现象)非常有用,尤其是当你想要编写需要包含移动或动画对象的游戏时。

你已经在本书开发的游戏中使用过这个特性。每次调用draw方法,你就在屏幕上画一个新的“框架”。通过每次在不同的位置绘制精灵,你给人一种精灵在移动的感觉。然而,这并不是真正发生的事情:你只是在每秒钟内多次在不同的位置绘制精灵,这使得玩家认为精灵在移动。

以类似的方式,你可以画一个行走或奔跑的角色。除了移动精灵之外,您每次绘制的精灵都略有不同。通过绘制一系列精灵,每个精灵代表行走运动的一部分,您可以创建一个角色在屏幕上行走的幻觉。图 25-1 中给出了一个子画面序列的例子。

9781430265382_Fig25-01.jpg

图 25-1 。代表行走动作的图像序列

游戏中的动画

在游戏中放动画有不同的原因。当你创作 3D 游戏时,动画通常是增强真实感所必需的,但对于 2D 游戏来说,情况并非总是如此。尽管如此,动画可以极大地丰富游戏。

动画将物体变得栩栩如生。但是动画制作并不复杂。角色闭上和睁开眼睛的简单动画会产生一种强烈的感觉,即角色是活的。动画角色也更容易让人产生共鸣。如果你看一个类似剪绳的游戏,主角(名为 Om Nom)简直就是坐在一个角落里。但是这个角色时不时会做一些有趣的动作,让你知道它在那里,并希望你给它带食物。这为玩家创造了继续玩游戏的非常有效的动机。

动画还有助于将玩家的注意力吸引到某个对象、任务或事件上。例如,在按钮上有一个小动画可以让玩家更清楚地知道他们必须按下按钮。而一颗跳动的水滴或一颗旋转的星星表明这个物体应该被收集或避开。动画也可以用来提供反馈。当你用鼠标点击一个按钮向下移动时,很明显这个按钮点击成功了。

然而,制作动画是一项繁重的工作。因此,事先仔细考虑哪里需要动画,哪里可以避免动画,以节省时间和金钱。

动画课

对于动画角色,通常为每种类型的运动设计一个精灵。图 25-1 中的例子是一个动画角色的精灵。在企鹅配对游戏的开发过程中,您设计了代表一条或一张图片的SpriteSheet类。您可以将该类与一个新类Animation结合使用。除了精灵表,动画需要额外的信息。例如,您想要指示每一帧应该在屏幕上显示多长时间。你也希望能够循环你的动画,这意味着一旦你到达最后一帧,第一帧应该再次显示。循环动画非常有用:例如,在行走角色的情况下,您只需绘制一个行走循环,然后循环动画以获得连续的行走运动。然而,并不是所有的动画都应该是循环的。例如,一个垂死的动画不应该循环播放(那会对角色非常残忍)。下面是Animation类的完整构造函数:

function Animation(sprite, looping, frameTime) {
    this.sprite = sprite;
    this.frameTime = typeof frameTime !== 'undefined' ? frameTime : 0.1;
    this.looping = looping;
}

动画游戏对象

Animation类提供了表现动画的基础。本节介绍一种新的游戏对象:动画游戏对象,它使用了这个类。AnimatedGameObject类是SpriteGameObject的子类。

动画游戏对象可能包含许多不同的动画,因此您可以拥有一个可以执行不同(动画)动作的角色,如行走、奔跑、跳跃等。每个动作都由一个动画来表示。根据玩家的输入,您可以更改当前活动的动画。然后,根据经过的时间和当前活动动画的属性(例如它是否循环),确定应该在屏幕上显示的精灵的工作表索引。

要存储不同的动画,可以使用复合对象。对于每个动画,都要向该对象添加一个变量。您还需要一个变量来跟踪当前活动的动画。最后,还有一个额外的成员变量:_time。这个变量跟踪显示当前帧还需要多长时间,后面会解释。下面是AnimatedGameObject的完整构造器:

function AnimatedGameObject(layer, id) {
    powerupjs.SpriteGameObject.call(this, null, layer, id);

    this._animations = {};
    this._current = null;
    this._time = 0;
}

您还向该类添加了两个方法:loadAnimationplayAnimation。第一种方法创建一个Animation对象,并将其添加到_animations变量:

AnimatedGameObject.prototype.loadAnimation = function (animname, id, looping,
    frametime) {
    this._animations[id] = new powerupjs.Animation(animname, looping,
        frametime);
};

如前所述,AnimatedGameObject类是SpriteGameObject的子类。这意味着当这个对象被绘制在屏幕上时,它试图绘制成员变量sprite指向的 sprite 工作表。但是,请注意,当您在AnimatedGameObject构造函数中调用基构造函数时,您将null作为参数传递:

function AnimatedGameObject(layer, id) {
    powerupjs.SpriteGameObject.call(this, null
, layer, id);
    ...
}

您需要将属于当前运行动画的精灵分配给sprite成员变量,这样这个动画就可以在屏幕上绘制了。您可以很容易地做到这一点,因为每个Animation实例都包含一个对它应该激活的 sprite 的引用。将这个精灵分配给精灵成员变量是在playAnimation方法中完成的。

在该方法中,您首先检查想要播放的动画是否已经在播放。如果是,您不必做任何其他事情,您可以从方法返回:

if (this._current === this._animations[id])
    return;

接下来,将当前工作表索引和_time变量设置为 0,并根据作为参数传递的 ID 分配当前活动的动画:

this._sheetIndex = 0;
this._time = 0;
this._current = this._animations[id];

最后,将sprite成员变量设置为应该绘制的 sprite:

this.sprite = this._current.sprite;

播放动画

您已经定义了一些用于加载和选择动画的有用的类和方法。你仍然需要能够播放一个动画。到底是什么意思?这意味着你必须根据已经过去的时间来确定应该显示哪一帧,并在屏幕上绘制该帧。计算应该画哪一帧是在AnimatedGameObject类的update方法中完成的。因为动画中的每一帧都对应于某个工作表索引,所以您只需计算哪个工作表索引对应于当前帧。从SpriteGameObject继承的draw方法不需要修改。

update方法中,你要计算应该画哪一帧。但是这意味着你需要知道从最后一帧画出来到现在过了多长时间。如果在每次调用update方法时增加帧索引,动画会播放得太快。因此,你在成员变量_time中保存了自最后一帧被绘制以来已经过去的时间。您在update方法的开头更新这个变量:

this._time += delta;

现在,您可以计算应该显示的帧的索引。为此,您使用一条while指令:

while (this._time > this._current.frameTime) {
    this._time -= this._current.frameTime;
    this._sheetIndex++;
    if (this._sheetIndex >=this.sprite.nrSheetElements)
        if (this._current.looping)
            this._sheetIndex = 0;
        else
            this._sheetIndex = this.sprite.nrSheetElements - 1;
}

这里发生了什么?只要_time变量包含一个大于frameTime的值,while指令就会继续。在while指令中,你从_time变量中减去帧时间。假设每一帧显示的时间被设置为 1 秒。您输入update方法,并将经过的时间添加到_time成员变量中。假设这个变量现在包含值 1.02,这意味着您当前显示的帧已经显示了 1.02 秒。这意味着您应该显示下一帧。你可以通过增加当前显示的帧的索引来实现,这是while循环中的第二条指令。然后更新_time变量并减去帧时间(1 秒),因此_time的新值变为 0.02。您将这段代码放在一个while指令中,而不是一个if指令中,这样您就可以确保始终显示正确的帧,即使自上次更新以来经过的时间是帧时间的数倍。例如,如果_time的新值是 3.4,您需要向前移动三帧,并从_time变量中减去三次帧时间。while指令会处理这一点。

在增加当前帧索引后,你必须注意在你过了最后一帧后会发生什么。为此,您需要检查纸张索引是否大于或等于this.sprite.nrSheetElements。根据您是否希望动画循环,您可以将工作表索引重置为 0,或者将其设置为工作表中的最后一个元素。

玩家阶层

要使用上一节中介绍的AnimatedGameObject类,您需要从它继承。因为玩家将控制动画角色,所以让我们定义一个Player类,它是AnimatedGameObject的子类。在这个类中,您加载属于播放器的动画并处理来自播放器的输入。在Player构造函数中,加载这个角色所需的动画。在本例中,您希望角色行走或静止不动。所以,你通过调用loadAnimation方法两次来加载两个动画。您希望这两个动画都循环,因此您将循环参数设置为true :

this.loadAnimation(sprites.idle, "idle", true);
this.loadAnimation(sprites.run, "run", true, 0.05);

因为空闲动画只包含单个工作表元素,所以不需要指定帧时间。对于正在运行的动画,您指定每一帧应该显示五百分之一秒。当应用启动时,角色的空闲动画应该播放:

this.playAnimation("idle");

你也改变了玩家的出身。如果你想画在地板上移动的动画角色,使用角色精灵底部的一个点作为它的原点是很有用的。此外,正如您稍后看到的,这对于冲突检查非常有用。由于这些原因,你将播放器的原点定义为 sprite 元素底部的中心点:

this.origin = new powerupjs.Vector2(this.width / 2, this.height);

现在你需要在这个类中处理玩家的输入。当玩家按下左或右箭头键时,角色的速度应该改变。您可以在handleInput方法中使用if指令:来实现这一点

var walkingSpeed = 400;
if (powerupjs.Keyboard.down(powerupjs.Keys.left))
    this.velocity.x = -walkingSpeed;
else if (powerupjs.Keyboard.down(powerupjs.Keys.right))
    this.velocity.x = walkingSpeed;
else
    this.velocity.x = 0;

注意我为walkingSpeed参数选择了 400 的值。摆弄这个值,看看它如何改变角色的行为。为这样的参数选择正确的值对游戏性有很大的影响。选择“恰到好处”的价值观很重要。用各种各样的玩家测试游戏可以帮助你决定这些值应该是什么,这样游戏才感觉自然。

使用图 25-1 所示的精灵可以让你制作一个向右走的角色的动画。要设置向左行走的角色的动画,可以使用另一个精灵。然而,有一个更简单的方法来实现这一点:当你绘制精灵时,镜像精灵。镜像精灵对于任何种类的精灵游戏对象都很有用,所以在SpriteGameObject类中,您添加了一个成员变量mirror,它指示精灵是否应该被镜像。在SpriteSheetdraw方法中,您将mirror变量的值传递给Canvas2D.drawImage,如下所示:

powerupjs.Canvas2D.drawImage(this._image, position, 0, 1, origin, imagePart,
    mirror);

你必须扩展Canvas2D使其支持绘制镜像精灵。你可以通过使用下面的指令将精灵负向缩放来实现:

if (mirror) {
    this._canvasContext.scale(scale * canvasScale.x * -1, scale *
        canvasScale.y);
    ...
}

下一步,你必须转换和旋转画布上下文,同时考虑精灵的镜像状态。这里没有详细介绍,但是您可以查看一下Canvas2D类,看看它是如何实现的。为了结束输入处理,如果玩家正在移动:,根据速度设置mirror状态

if (this.velocity.x != 0)
    this.mirror = this.velocity.x < 0;

update方法中,您根据速度选择播放哪个动画。如果速度为零,则播放空闲动画;否则,播放跑步动画:

if (this.velocity.x === 0)
    this.playAnimation("idle");
else
    this.playAnimation("run");

最后,您调用基类中的update方法,以确保动画游戏对象版本的update方法也被调用。

为了测试您的动画类,您创建了一个单独的AnimationState实例,并将其添加到游戏状态管理器中:

ID.game_state_animation = powerupjs.GameStateManager.add(new AnimationState());
powerupjs.GameStateManager.switchTo(ID.game_state_animation);

AnimationState类中,您创建了一个Player实例,将其设置在所需的位置,并将其添加到游戏世界:

function AnimationState(layer) {
    powerupjs.GameObjectList.call(this, layer);
    var player = new Player();
    player.position = new powerupjs.Vector2(50, 300);
    this.add(player);
}

如果你运行程序,你会看到一个可以用左右箭头键控制的动画角色(见图 25-2 )。如果角色走出可见屏幕,它不只是在屏幕外“停止”——而是继续前进。因此,如果你按住右箭头键 5 秒钟,你需要按住左箭头键 5 秒钟,以及获得角色回来。

9781430265382_Fig25-02.jpg

图 25-2 。在画布底部从右向左移动的动画角色

绕过这种能够离开屏幕边缘的行为的一种方法是实现换行:如果角色离开屏幕的右侧,它会重新出现在左侧,反之亦然。通过在代码中添加一个if指令,可以很容易地实现换行,该指令检查字符的当前位置,并根据该位置选择将字符移动到屏幕的另一端。你能自己改变例子来添加包装吗?

你学到了什么

在本章中,您学习了:

  • 如何创建和控制动画
  • 如何构建一个由多个动画组成的动画游戏对象

二十六、游戏物理学

在上一章中,您看到了如何创建动画角色。您还了解了如何从本地存储中加载关卡和关卡状态,以及如何构建基于图块的游戏世界。最重要的一个方面仍然缺失:定义角色如何与游戏世界互动。你可以让一个角色从左向右移动,但是如果你只是简单地把角色放在关卡中,它只能在屏幕的底部行走。这还不够。您希望角色能够跳到墙砖上,并在它离开墙砖时掉下来,并且您不希望角色从屏幕边缘掉下来。对于这些东西,你需要实现一个基本的物理系统。因为它是与世界交互的角色,所以你在Player类中实现这个物理。处理物理有两个方面:赋予角色跳跃或坠落的能力,处理角色与其他游戏对象之间的碰撞并对这些碰撞做出响应。

锁定游戏世界中的角色

你要做的第一件事就是锁定游戏世界中的角色。在第二十五章的例子中,角色可以毫无问题地走出屏幕。你可以通过在屏幕左右放置一堆虚拟的墙式瓷砖来解决这个问题。然后你假设你的碰撞检测机制(你还没有写)将确保角色不能穿过这些墙。你只想防止角色走出屏幕的左侧或右侧。角色应该能够跳出屏幕顶部的视线。这个角色还应该能够通过地上的一个洞从游戏世界中掉下来(很明显,会死掉)。

为了在屏幕的左右两侧构建虚拟的墙砖堆,您必须向墙砖网格添加一些行为。你不想修改GameObjectGrid类。这种行为与游戏对象的网格无关,但它是你的平台游戏特有的。因此,您定义了一个名为TileField的新类,而继承了GameObjectGrid类。您向名为getTileType的类添加一个方法,该方法返回给定其在网格上的 xy 位置的图块的类型。这种方法的好处是允许这些索引落在网格中有效索引的之外。例如,询问位置(-2,500)的牌的牌类型就可以了。通过在该方法中使用if指令,您可以检查 x 步进是否超出范围。如果是,则返回一个普通的(墙)瓷砖类型:

if (x < 0 || x >= this.columns)
    return TileType.normal;

如果 y 指数超出范围,你返回一个背景平铺类型,这样角色可以跳过屏幕的顶部或者掉进一个洞:

if (y < 0 || y >= this.rows)
    return TileType.background;

如果两个if指令的条件都是false,这意味着网格中实际图块的类型被请求,因此您检索该图块并返回其图块类型:

return this.at(x, y).type;

完整的类可以在属于本章的示例程序TickTick2中找到。如果您想以更符合 JavaScript 哲学的方式扩展GameObjectGrid类,您可以在一个单独的 JavaScript 文件中将getTileType方法直接添加到GameObjectGrid类中。您可以调用文件GameObjectGrid_ext.js,它将包含一个添加到GameObjectGrid的方法,该方法将是:

GameObjectGrid.prototype.getTileType = function (x, y) {
    if (x < 0 || x >= this.columns)
        return TileType.normal;
    if (y < 0 || y >= this.rows)
        return TileType.background;
    return this.at(x, y).type;
};

通过这种方式,您不需要创建一个新的类,而是简单地向GameObjectGrid注入您需要的行为。

将字符设置在正确的位置

当你从关卡数据变量中加载关卡时,你使用字符1来表示玩家角色开始的关卡。基于该图块的位置,您必须创建Player对象并将其设置在正确的位置。为此,您向Level类添加一个方法loadStartTile。在这种方法中,首先检索图块字段,然后计算角色的起始位置。因为角色的原点是精灵底部中心的,你可以如下计算这个位置:

var startPosition = new powerupjs.Vector2((x + 0.5) * tiles.cellWidth,
    (y + 1) * tiles.cellHeight);

请注意,您使用了瓷砖的宽度和高度,并将它们乘以角色应该位于的位置的 xy 索引。单元格宽度乘以x + 0.5,因此字符被放置在图块位置的中间,单元格高度乘以y + 1,以将字符放置在图块的底部。然后,您可以创建Player对象并将其添加到游戏世界:

this.add(new Player(startPosition, ID.layer_objects, ID.player));

最后,您仍然需要在这里制作一个可以存储在网格中的实际瓷砖,因为每个字符应该代表一个瓷砖。在这种情况下,您可以创建一个背景单幅图块,放置在角色站立的位置:

return new Tile();

跳跃…

你已经看到了一个角色如何向左或向右行走。你如何处理跳跃和坠落?如果游戏在有键盘的设备上运行,当玩家按下空格键时,角色就会跳跃。

使用空格键跳跃在很大程度上是一种传统。游戏中常用的还有其他按键,比如用 Q 和 E 扫射;用 W、A、D、X 定向移动;用 S 停止或刹车;等等。在你的游戏中使用这些或多或少被接受的标准会给你的用户提供更好的体验,因为他们已经知道这个界面了。

当玩家按下空格键跳跃时,基本上意味着角色获得一个负 y-速度。这可以在Player类的handleInput方法中轻松完成

if (powerupjs.Keyboard.pressed(powerupjs.Keys.space))
    this.jump();

jump方法如下:

Player.prototype.jump = function (speed) {
    speed = typeof speed !== 'undefined' ? speed : 1100;
    this.velocity.y = -speed;
};

所以,在不提供任何参数值的情况下调用jump方法的效果是y-速度被设置为 1100。我随机选择了这个数字。使用更大的数字意味着角色可以跳得更高。较低的数字意味着角色必须更频繁地去健身房,或者戒烟。我选择了这个值,这样角色可以跳得足够高来够到瓷砖,但又不会高到让游戏变得太容易(这样角色就可以跳到关卡的末尾)。

这种方法有一个小问题:你总是允许玩家的角色跳跃,不管角色当前的情况如何。因此,如果角色正在跳下或跌落悬崖,你允许玩家让角色跳回安全的地方。这不是你真正想要的。你想让角色只在站在地上的时候跳。这是可以通过观察角色与墙壁或平台瓷砖(角色可以站立的唯一瓷砖)之间的碰撞来检测的。现在让我们假设您尚未编写的碰撞检测算法将会处理这个问题,并通过使用一个成员变量来跟踪角色是否在地面上:

this.onTheGround = true;

有时候,在编写一个类之前,有必要用英语(相对于 JavaScript)勾画出一个类,这样你就可以编写游戏的其他部分了。在碰撞检测的情况下也是如此。在构建冲突检测算法之前,您无法对其进行测试,但是在创建并测试该算法之前,您不会想要构建它。一个必须先发生,所以你必须在心理上知道另一个发生了什么,并计划它或做笔记。CollisionTest 例子是我写的一个程序,用来测试独立于游戏的碰撞检测算法。您可能会发现,在某些情况下,编写单独的测试程序有助于您理解部分代码应该如何工作。

如果这个成员变量是true,你就知道这个角色是站在地上的。你现在可以改变最初的if指令,这样它只允许一个角色从地上跳,而不允许从空中跳:

if (powerupjs.Keyboard.pressed(powerupjs.Keys.space) && this.onTheGround)
    this.jump();

如果你在没有键盘的设备上玩游戏(比如平板电脑或智能手机),你必须以不同的方式处理玩家的输入。一种方法是在屏幕上添加几个按钮,只有当触摸输入可用时,这些按钮才能控制玩家角色。这是在创建Level对象时完成的:

if (powerupjs.Touch.isTouchDevice) {
    var walkLeftButton = new powerupjs.Button(sprites.buttons_player,
        ID.layer_overlays, ID.button_walkleft);
    walkLeftButton.position = new powerupjs.Vector2(10, 500);
    this.add(walkLeftButton);
    var walkRightButton = new powerupjs.Button(sprites.buttons_player,
        ID.layer_overlays, ID.button_walkright);
    walkRightButton.position = new powerupjs.Vector2(walkRightButton.width +
20, 500);
    walkRightButton.sheetIndex = 1;
    this.add(walkRightButton);
    var jumpButton = new powerupjs.Button(sprites.buttons_player,
        ID.layer_overlays, ID.button_jump);
    jumpButton.position = new powerupjs.Vector2(powerupjs.Game.size.x –
        jumpButton.width - 10, 500);
    jumpButton.sheetIndex = 2;
    this.add(jumpButton);
}

控制字符的方式与处理键盘输入的方式非常相似:

if (powerupjs.Touch.isTouchDevice) {
    var jumpButton = this.root.find(ID.button_jump);
    if (jumpButton.pressed && this.onTheGround)
        this.jump();
}

这是一个很好的例子,说明了如何自动调整游戏界面以适应不同的设备。只有当触摸显示屏可用时,才会添加按钮。另一种选择是在设备中使用内置传感器,例如加速度计。涂鸦跳跃是一个使用这种传感器来控制角色的游戏的好例子。

…然后下落

你目前唯一改变 y 速度的地方是在handleInput方法中,当玩家想要跳跃的时候。因为y-速度无限期地保持 1100 的值,角色在屏幕外的空中向上移动,离开地球的大气层,进入外层空间。因为你不是在做一个关于太空炸弹的游戏,你必须对此做些什么。你忘了加到游戏世界的是重力

您可以遵循一个简单的方法来模拟重力对角色速度的影响。在每个更新步骤中,在 y 方向的速度上增加一个小值:

this.velocity.y += 55;

如果角色有一个负的速度,这个速度慢慢变小,直到它达到零,然后又开始增加。效果是角色跳到某个高度,然后又开始往下掉,就像在现实世界里一样。然而,冲突检测机制现在变得更加重要。如果没有碰撞检测,角色会在游戏开始时就开始倒下!

碰撞检测

检测游戏对象之间的碰撞是模拟交互式游戏世界的一个非常重要的部分。碰撞检测在游戏中用于许多不同的事情:检测角色是否走过电源,检测角色是否与投射物碰撞,检测角色与墙壁或地板之间的碰撞,等等。鉴于这种非常常见的情况,你在以前的游戏中不需要碰撞检测几乎是很奇怪的。还是你没有?请看来自画师游戏的PaintCan类中的update方法的代码

var ball_center = Game.gameWorld.ball.center;
var ball_position = Game.gameWorld.ball.position;
var distance = ball_position.add(ball_center).subtractFrom(this.position)
    .subtractFrom(this.center);
if (Math.abs(distance.x) < this.center.x && Math.abs(distance.y) <
this.center.y) {
    this.color = Game.gameWorld.ball.color;
    Game.gameWorld.ball.reset();
}

您在这里所做的是检测球和油漆罐之间的碰撞(尽管是非常基本的方式)。你取每个物体的中心位置,看看这两个位置之间的距离是否小于某个值。如果是这样,你说它们碰撞了,你改变了罐子的颜色。如果您更仔细地观察这种情况,您可以看到您正在用基本形状表示游戏对象,例如,并且您通过验证中心之间的距离是否小于圆的半径之和来检查它们是否相互碰撞。

这是第一个,在游戏中进行碰撞检查的简单例子。当然,这不是一种非常精确的检查碰撞的方法。球的形状可以近似为圆形,但油漆罐看起来一点也不像圆形。因此,在某些情况下,当没有碰撞时会检测到碰撞,有时当精灵实际碰撞时不会检测到碰撞。尽管如此,许多游戏在进行碰撞检测时还是会使用圆形和矩形等简化形状来代表物体。因为这些形状将对象约束在其中,所以它们也被称为边界圆边界框 。Tick Tick 游戏使用轴对齐的边界框,意味着你不考虑边不平行于 x -和y-轴的框。

不幸的是,使用包围盒进行碰撞检测并不总是足够精确。当游戏对象彼此靠近时,它们的边界形状可能会相交(从而触发碰撞),但实际对象不会。并且当游戏对象被动画化时,它的形状可以随着时间而改变。您可以使边界形状更大,以便对象在任何情况下都适合它,但这将导致更多错误的碰撞触发器。

对此的解决方案是在每个像素的基础上检查碰撞。基本上,您可以编写一个算法,遍历 sprite 中的非透明像素(使用嵌套的for指令),并检查这些像素中的一个或多个是否与另一个 sprite 中的一个像素冲突(同样,通过使用嵌套的for指令遍历它们)。通常,这种高度详细的碰撞检测对于浏览器游戏来说成本太高(即使浏览器变得越来越快)。另一方面,您不必经常执行这个相当昂贵的任务。只有当两个边界形状相交时才需要这样做。然后你只需要对实际相交的形状部分做同样的操作。此外,如果你聪明的话,你可以决定哪种对象应该使用逐像素碰撞检测,这样你就可以只对边界框不能很好工作的对象使用它。

当你使用圆形和矩形来检测碰撞时,你需要处理三种情况(参见图 26-1 ):

  • 一个圆与另一个圆相交。
  • 一个圆与一个矩形相交。
  • 一个矩形与另一个矩形相交。

9781430265382_Fig26-01.jpg

图 26-1 。不同类型的碰撞:圆-圆,圆-矩形和矩形-矩形

第一种情况是最简单的。你唯一需要做的就是检查两个中心之间的距离是否小于半径之和。您已经看到了如何做到这一点的示例。

对于圆与矩形相交的情况,可以使用以下方法:

  1. 找到矩形上最靠近圆心的点。
  2. 计算这个点到圆心的距离。
  3. 如果这个距离小于圆的半径,就有碰撞。

让我们假设您想要找出类型为Rectangle的对象是否与由类型为\expr{Vector2}的对象和半径表示的圆相交。通过巧妙的钳制值,可以找到最接近圆心的点。箝位最大值和最小值之间的一个值通过以下方法完成:

Math.clamp = function (value, min, max) {
    if (value < min)
        return min;
    else if (value > max)
        return max;
    else
        return value;
};

现在看一下下面的代码:

Vector2 closestPoint = Vector2.zero;
closestPoint.x = Math.clamp(circleCenter.x, rectangle.left, rectangle.right);
closestPoint.y = Math.clamp(circleCenter.y, rectangle.top, rectangle.bottom);

通过夹紧矩形边缘之间中心的 xy 值,可以找到最近的点。如果圆的中心在矩形内,这种方法也有效,因为在这种情况下箝位不起作用,并且最近的点与圆的中心相同。下一步是计算最近点和圆心之间的距离:

Vector2 distance = closestPoint.subtract(circleCenter);

如果这个距离小于半径,就会发生碰撞:

if (distance.length < circleRadius)
    // collision!

最后一种情况是检查两个矩形是否冲突。为了进行计算,您需要了解两个矩形的以下信息:

  • 最小的x-矩形(rectangle.left)的值
  • 最小的y-矩形(rectangle.top)的值
  • 最大x-矩形(rectangle.right)的值
  • 最大的y-矩形(rectangle.bottom)的值

假设您想知道矩形 A 是否与矩形 b 冲突。在这种情况下,您必须检查以下条件:

  • A.left (A 的最小x-值)< = B.right (B 的最大x-值)
  • A.right (A 的最大x-值)> = B.left (B 的最小x-值)
  • A.top (A 的最小y-值)< = B.bottom (B 的最大y-值)
  • A.bottom (A 最大的y-值)> = B.top (B 最小的y-值)

如果所有这些条件都满足,那么矩形 A 和 B 就发生了碰撞。为什么会有这些特殊情况?让我们看看第一个条件,看看如果它不为真会发生什么。假设A.left > B.right取而代之。在这种情况下,矩形 A 完全位于矩形 B 的右侧,因此它们不会发生碰撞。如果第二个条件不成立(换句话说,A.right < B.left,那么矩形 A 完全位于 B 的左侧,这意味着它们也不会发生碰撞。你自己也要检查一下另外两个条件。总之,这些条件表明,如果矩形 A 既不完全位于 B 的左侧、右侧、顶部,也不完全位于 B 的底部,那么这两个矩形就会发生碰撞。

在 JavaScript 中,编写检查矩形间冲突的代码很容易。如果你看一下Rectangle类,你可以看到一个方法intersects为你做这件事:

Rectangle.prototype.intersects = function (rect) {
    return (this.left <= rect.right && this.right >= rect.left &&
        this.top <= rect.bottom && this.bottom >= rect.top);
};

检索边界框

为了有效地处理游戏中的碰撞,SpriteGameObject类有一个属性boundingBox,它返回精灵的边界框:

Object.defineProperty(SpriteGameObject.prototype, "boundingBox",
    {
        get: function () {
            var leftTop = this.worldPosition.subtractFrom((this.origin));
            return new powerupjs.Rectangle(leftTop.x, leftTop.y, this.width,
this.height);
        }
    });

正如你所看到的,为了计算盒子的正确位置,它考虑了精灵的原点。还要注意,边界框的位置是用世界位置表示的。当进行碰撞检测时,您希望知道对象在世界上的位置——您不关心它们在游戏对象层次结构中的本地位置。

逐像素碰撞检测

除了boundingBox属性,您还可以在SpriteGameObject类中添加一个方法collidesWith来处理冲突检测。然而,仅仅检查边界框是否重叠通常是不够的。图 26-2 显示了两个精灵的边界框重叠的例子,但是图像实际上并没有碰撞。这可能是因为你绘制的精灵 的部分可以是透明的。因此,如果您想要进行精确的碰撞检测,您需要查看像素级别是否存在碰撞。只有在子画面重叠的矩形中的给定位置,两个子画面都有不透明的像素时,才会发生像素级的冲突。

9781430265382_Fig26-02.jpg

图 26-2 。两个精灵没有碰撞,但是它们的边界框重叠的例子

访问图像中的像素颜色数据

要进行逐像素碰撞检测,您需要访问图像的像素颜色数据。这在 HTML/JavaScript 中并不难做到,但是在了解如何实现之前,您需要知道逐像素碰撞检测是一个开销很大的操作(稍后您会明白为什么)。如果你在游戏世界中的每个精灵之间这样做,你将面临游戏无法在旧的移动设备或平板电脑上运行的风险。因此,只有在真正必要时才进行逐像素碰撞检测。

因为每像素碰撞检测是昂贵的,所以以这样一种方式设计代码是有意义的,即对于某些精灵来说很容易关闭它。为此,在SpriteSheet类中维护一个布尔变量,该变量指示是否应该对该精灵进行逐像素碰撞检测。因为检索像素颜色数据是昂贵的,所以当精灵被加载时,你检索所有的数据并把它存储在一个叫做碰撞遮罩 的数组中。为什么检索像素颜色数据很昂贵?因为为了检索这些数据,你需要首先绘制精灵,然后从画布中检索颜色数据。您不想在玩家可以看到的主画布上绘制这些精灵,所以您在Canvas2D类中定义了另一个画布来实现这个目的:

this._pixeldrawingCanvas = document.createElement('canvas');

SpriteSheet类添加一个名为createPixelCollisionMask的方法,在该方法中,在像素绘图画布上绘制精灵,然后从该画布中提取像素颜色数据。初始化将包含碰撞遮罩的数组,并确保像素绘图画布的大小正确:

this._collisionMask = [];
var w = this._image.width;
var h = this._image.height;
powerupjs.Canvas2D._pixeldrawingCanvas.width = w;
powerupjs.Canvas2D._pixeldrawingCanvas.height = h;

然后使用画布上下文来绘制精灵:

var ctx = powerupjs.Canvas2D._pixeldrawingCanvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.drawImage(this._image, 0, 0, w, h, 0, 0, w, h);
ctx.restore();

canvas 上下文有一个方法getImageData ,它检索每个像素的颜色数据并将其存储在一个数组中。因此,让我们检索当前显示在画布上的所有像素:

var imageData = ctx.getImageData(0, 0, w, h);

变量现在指的是一个非常大的数字数组。对于每个像素,数组中有四个数字,每个数字都在 0 到 255 之间。前三个数字是决定像素颜色的 R(红色)、G(绿色)和 B(蓝色)值。第四个数字是决定像素透明度的 A (alpha)值。alpha 值为 0 表示像素完全透明,值为 255 表示像素不透明。在碰撞遮罩中,您只需要存储 alpha 值,因为碰撞对象的颜色并不重要:重要的是哪些像素代表这些对象。因此,您使用一条for指令来遍历数组,并将每个第四个值存储在碰撞掩码数组中,如下所示:

for (var x = 3, l = w * h * 4; x < l; x += 4) {
    this._collisionMask.push(imageData.data[x]);
}

当创建一个SpriteSheet实例时,只有当用户在调用构造函数时将参数createCollisionMask设置为true时,才会计算碰撞遮罩。例如,您表示希望在加载播放器精灵时对播放器进行精确的碰撞检测:

sprites.player_idle = loadSprite("player/spr_idle.png", true);
sprites.player_run = loadSprite("player/spr_run@13.png", true);
sprites.player_jump = loadSprite("player/spr_jump@14.png", true);

另一方面,您不需要这些图块的精确信息,因为它们或多或少都是矩形的,所以使用矩形边界框就足够了:

sprites.wall = loadSprite("tiles/spr_wall.png");
sprites.wall_hot = loadSprite("tiles/spr_wall_hot.png");
sprites.wall_ice = loadSprite("tiles/spr_wall_ice.png");
sprites.platform = loadSprite("tiles/spr_platform.png");
sprites.platform_hot = loadSprite("tiles/spr_platform_hot.png");
sprites.platform_ice = loadSprite("tiles/spr_platform_ice.png");

为了使访问碰撞遮罩变得更容易,您在SpriteSheet类中添加了一个getAlpha方法来访问碰撞遮罩,同时考虑当前在工作表中选择的元素以及子画面是否被镜像绘制。下面是该方法的标题:

SpriteSheet.prototype.getAlpha = function (x, y, sheetIndex, mirror)

作为参数,该方法需要 xy 像素坐标、工作表索引以及子画面是否被镜像。首先要做的是检查是否有一个碰撞遮罩与这个 sprite sheet 关联,因为不是所有的 sprite 都有这样的遮罩。如果没有碰撞遮罩,只需返回值 255(完全不透明):

if (this._collisionMask === null)
    return 255;

然后,您计算对应于当前工作表索引的列和行索引,使用与在draw方法中相同的方式:

var columnIndex = sheetIndex % this._sheetColumns;
var rowIndex = Math.floor(sheetIndex / this._sheetColumns) % this._sheetRows;

然后,您可以计算图像中的实际像素坐标(或纹理),将工作表索引给出的 sprite 元素考虑在内。通过将一个工作表元素的宽度乘以列索引并加上本地的 x 值来计算 x 坐标:

var textureX = columnIndex * this.width + x;

但是,如果精灵是镜像的,则使用稍微不同的计算方法:

if (mirror)
    textureX = (columnIndex + 1) * this.width - x - 1;

这里发生的事情是,你从 sprite 元素的右边开始,然后减去 x 得到本地的 x 坐标。对于 y 坐标,不需要检查镜像,因为在游戏引擎中你只允许水平镜像:

var textureY = rowIndex * this.height + y;

基于图像中的 xy 坐标,现在计算碰撞遮罩中的相应索引,如下所示:

var arrayIndex = Math.floor(textureY * this._image.width + textureX);

为了确保万无一失,您检查您计算的索引是否落在数组的范围内。如果不是,则返回 0(完全透明):

if (arrayIndex < 0 || arrayIndex >= this._collisionMask.length)
    return 0;

这样,如果您试图访问不存在的像素,getAlpha方法也会返回一个逻辑结果。最后,返回存储在碰撞遮罩中的 alpha 值:

return this._collisionMask[arrayIndex];

为了方便起见,您还向SpriteGameObject添加了一个getAlpha方法,该方法使用正确的参数从SpriteSheet调用getAlpha方法:

SpriteGameObject.prototype.getAlpha = function (x, y) {
    return this.sprite.getAlpha(x, y, this._sheetIndex, this.mirror);
};

注意不是所有的浏览器都允许你访问像素颜色数据。例如,如果你在电脑上将 HTML 页面作为本地文件打开,Chrome 和 Firefox 就不允许这种访问。Internet Explorer 确实允许这样做,所以要测试逐像素碰撞检测,您可以使用该浏览器或将文件放在服务器上,以便使用这两种浏览器中的任何一种。在 TickTick2 的例子中,我注释掉了TickTick.js中的collisionMask参数,因此游戏可以在所有浏览器上运行,但是当然在这种情况下游戏不会执行逐像素碰撞检测。

计算重叠矩形

SpriteGameObject中的collidesWith方法处理碰撞检测的两个步骤:首先检查边界框是否相交,然后在重叠矩形中执行逐像素碰撞检测。该方法的第一步是确定是否需要进行任何碰撞检测。如果两个对象中的任何一个不可见,或者如果它们的边界框不相交,那么从方法:返回

if (!this.visible || !obj.visible ||
    !this.boundingBox.intersects(obj.boundingBox))
    return false;

下一步是计算两个边界框的重叠部分。因为在处理碰撞检测时,这是一个很有用的计算方法,所以在Rectangle类中添加一个名为intersection的方法,该方法返回一个矩形,表示作为参数传递的矩形(边界框)和调用该方法的矩形对象(this)之间的重叠。

为了计算这个重叠矩形,你需要知道矩形的最小和最大 xy 坐标(见图 26-3 )。将Rectangle类中一些有用的属性与Math对象的minmax方法结合使用,可以很容易地计算出这些值:

var xmin = Math.max(this.left, rect.left);
var xmax = Math.min(this.right, rect.right);
var ymin = Math.max(this.top, rect.top);
var ymax = Math.min(this.bottom, rect.bottom);

9781430265382_Fig26-03.jpg

图 26-3 。使用最小和最大 xy 坐标计算重叠矩形

现在,您可以计算重叠矩形的位置和大小,并从方法返回它:

return new powerupjs.Rectangle(xmin, ymin, xmax - xmin, ymax - ymin);

SpriteGameObjectcollidesWith方法中,通过从Rectangle类中调用intersection方法来存储重叠矩形:

var intersect = this.boundingBox.intersection(obj.boundingBox);

检查重叠矩形 中的像素

重叠矩形的坐标用世界坐标表示,因为两个边界框都用世界坐标表示。你首先需要找出重叠矩形在两个重叠精灵中的位置。因此,你需要减去每个精灵的世界位置和它的原点来找到每个精灵中的局部重叠矩形:

var local = intersect.position.subtractFrom(this.worldPosition
.subtractFrom(this.origin));
var objLocal = intersect.position.subtractFrom(obj.worldPosition
.subtractFrom(obj.origin));

要检查重叠矩形内是否有碰撞,使用嵌套的for指令遍历矩形中的所有像素:

for (var x = 0; x < intersect.width; x++)
    for (var y = 0; y < intersect.height; y++) {
        // check transparency of pixel (x, y)...
}

在这个嵌套的for指令中,你检查两个像素在这些局部位置是否都是而不是透明的。如果是这种情况,你有一个碰撞。您使用getAlpha方法来检查这两个像素:

if (this.getAlpha(Math.floor(local.x + x), Math.floor(local.y + y)) !== 0
    && obj.getAlpha(Math.floor(objLocal.x + x), Math.floor(objLocal.y + y)) !== 0)
    return true;

既然已经实现了基本的碰撞检测方法,您可以通过调用collidesWith方法来检查两个游戏对象是否发生碰撞:

if (this.collidesWith(enemy))
    // ouch...

处理字符-图块冲突

在 Tick Tick 游戏中,您需要检测角色和瓷砖之间的碰撞。你可以在一个名为handleCollisions的方法中做到这一点,这个方法是从Player类中的update方法调用的。这个想法是,你做所有的计算 跳跃,下落,并且首先跑(你在这一章的开始就做了)。如果角色和瓷砖之间发生碰撞,您可以修正角色的位置,使其不再发生碰撞。在handleCollisions方法中,你走过方格并检查角色和你正在检查的方格之间是否有冲突。

您不需要检查网格中的所有图块,只需检查靠近角色当前位置的图块。您可以计算距离角色位置最近的牌,如下所示:

var tiles = this.root.find(ID.tiles);
var x_floor = Math.floor(this.position.x / tiles.cellWidth);
var y_floor = Math.floor(this.position.y / tiles.cellHeight);

现在你可以使用嵌套的for指令来查看角色周围的瓷砖。为了考虑快速跳跃和下落,你在 y 方向上考虑了更多的瓷砖。在嵌套的for指令中,然后检查角色是否与瓷砖发生碰撞。但是,只有当图块是而不是背景图块时,您才需要这样做。完成所有这些工作的代码如下:

for (var y = y_floor - 2; y <= y_floor + 1; ++y)
    for (var x = x_floor - 1; x <= x_floor + 1; ++x) {
        var tileType = tiles.getTileType(x, y);
        if (tileType === TileType.background)
            continue;
        var tileBounds = new powerupjs.Rectangle(x * tiles.cellWidth, y *
            tiles.cellHeight, tiles.cellWidth, tiles.cellHeight);
        if (!tileBounds.intersects(this.boundingBox))
            continue;
    }

如您所见,您没有直接访问Tile对象。原因是有时,因为角色靠近屏幕边缘,所以 xy 索引可能为负。这里您看到了使用添加到TileField类中的getTileType方法的优势。您并不关心您是否真的在处理一个图块:只要您知道它的类型和边界框,您就可以完成您的工作。

在嵌套的for指令中,还会看到一个新的关键字:continue。这个关键字可以在forwhile指令中使用,以停止执行循环的当前迭代,并继续下一个迭代。在这种情况下,如果图块的类型为background,则剩余的指令不再执行,您继续增加x并开始新的迭代以检查下一个图块。结果是只考虑不属于类型background的图块。continue关键字与break相关,它完全停止循环。与break不同,continue只停止当前迭代。

然而,这些代码并不总是正确地工作。特别是当角色站在瓷砖上时,计算边界框时的舍入误差会导致算法认为角色不是站在地上。然后,角色的速度会增加,结果角色可能会从瓷砖中掉下来。为了补偿任何舍入误差,可以将边界框的高度增加 1:

var boundingBox = this.boundingBox;
boundingBox.height += 1;
if (!tileBounds.intersects(boundingBox))
    continue;
// handle the collision

处理碰撞

现在,您可以检测游戏世界中角色和瓷砖之间的碰撞,您必须确定当碰撞发生时该做什么。有几种可能性。你可以让游戏崩溃(如果你想把你的游戏卖给很多人,这并不好),你可以警告用户他们不应该与游戏中的物体碰撞(导致许多弹出消息),或者你可以在角色与物体碰撞时自动纠正角色的位置。

为了纠正角色的位置,你需要知道碰撞有多糟糕。例如,如果角色撞上了右边的墙,你必须知道你要向左移动角色多远才能取消碰撞。这也被称为交点深度。让我们用一个叫做calculateIntersectionDepth的方法来扩展Rectangle类,该方法计算两个Rectangle对象在 xy 方向的相交深度。在这个例子中,这些矩形是角色的边界框和与之碰撞的瓷砖的边界框。

可以通过首先确定矩形中心之间的最小允许距离来计算相交深度,使得两个矩形之间没有碰撞:

var minDistance = this.size.addTo(rect.size).divideBy(2);

然后计算两个矩形中心之间的实际距离:

var distance = this.center.subtractFrom(rect.center);

现在,您可以计算最小允许距离和实际距离之间的差异,以获得相交深度。如果你观察两个中心之间的实际距离,两个维度都有两种可能( xy ):距离要么是负的,要么是正的。例如,如果 x 距离为负,这意味着矩形rect被放置在矩形this的右侧(因为rect.center.x > this.center.x)。如果矩形this代表该字符,这意味着您必须将该字符移动到左侧来纠正这个交叉点。因此,你将 x 相交深度返回为一个值,可以计算为-minDistance.x - distance.x。为什么呢?因为有碰撞,所以两个矩形之间的距离小于minDistance。并且因为distance是负的,所以表达式-minDistance.x - distance.x给出两者之差作为负。如果distance为正,表达式minDistance.x - distance.x给出两者之间的差。同样的推理也适用于 y 距离。然后,您可以按如下方式计算深度:

var depth = powerupjs.Vector2.zero;
if (distance.x > 0)
    depth.x = minDistance.x - distance.x;
else
    depth.x = -minDistance.x - distance.x;
if (distance.y > 0)
    depth.y = minDistance.y - distance.y;
else
    depth.y = -minDistance.y - distance.y;

最后,您返回深度向量作为该方法的结果:

return depth;

当您知道角色与瓷砖发生碰撞时,您可以使用刚刚添加到Rectangle类中的方法来计算相交深度:

var depth = boundingBox.calculateIntersectionDepth(tileBounds);

现在你已经计算了相交深度,有两种方法可以解决这个碰撞:在 x 方向移动角色,或者在 y 方向移动角色。通常,您希望将角色移动尽可能短的距离,以避免不自然的运动或位移。所以,如果 x 深度小于 y 深度,你在 x 方向移动角色;否则,沿 y 方向移动。您可以使用if指令来检查这一点。当比较两个深度尺寸时,你必须考虑到它们可能是负数。您可以通过比较绝对值来解决这个问题:

if (Math.abs(depth.x) < Math.abs(depth.y)) {
    // move character in the x direction
}

如果与瓷砖发生碰撞,是否总是要移动角色?这取决于瓷砖的类型。请记住,TileType用于表示三种可能的牌类型:TileType.backgroundTileType.normalTileType.platform。如果角色碰撞的瓷砖是背景瓷砖,你肯定不想移动角色。此外,在向 x 方向移动的情况下,您希望角色能够穿过平台瓷砖。因此,只有当角色与瓷砖(TileType.normal)发生碰撞时,才需要移动角色来纠正碰撞。在这种情况下,通过将 x 深度值添加到角色位置来移动角色:

if (tileType === TileType.normal)
    this.position.x += depth.x;

如果你想在 y 方向修正字符位置,事情会变得稍微复杂一些。因为你正在处理在 y 方向的运动,这也是一个确定角色是否在地面上的好地方。在handleCollisions方法的开始,您将isOnTheGround成员变量设置为false。所以,出发点是假设地上的人物是而不是。在有些的情况下,是在地面上,你要把变量设置成true。你如何能检查角色是否在地面上?如果它不在地面上,它一定在下落。如果是下降,那么先前的 y 位置小于当前位置。为了访问前一个 y 位置,在每次调用handleCollisions方法结束时,将它存储在一个成员变量中:

this._previousYPosition = this.position.y;

现在很容易确定角色是否在地面上。如果先前的 y 位置小于角色正在碰撞的瓷砖的顶部,并且该瓷砖是而不是背景瓷砖,那么角色正在下落并且已经到达瓷砖。如果是这样,您将isOnTheGround变量设置为true并将 y 速度设置为 0:

if (this._previousYPosition <= tileBounds.top && tileType !==
TileType.background) {
    this.onTheGround = true;
    this.velocity.y = 0;
}

在某些情况下,您仍然需要纠正字符位置。如果你与墙砖相撞,你总是想纠正角色的位置。如果角色与平台瓷砖发生碰撞,您只需在角色站在瓷砖顶部时纠正角色位置。如果isOnTheGround变量设置为true,则后者仅为true。因此,您可以将这一切写入下面的if指令:

if (tileType === TileType.normal || this.onTheGround)
    this.position.y += depth.y + 1;

请注意,要校正位置,您需要添加一个额外的像素来补偿您添加到边界框高度的额外像素。

你学到了什么

在本章中,您学习了:

  • 如何在环境中约束角色
  • 如何模拟跳跃和坠落
  • 如何处理游戏中的碰撞

二十七、聪明的敌人

作为开发滴答滴答游戏的下一步,让我们通过添加危险的敌人给玩家带来一些危险。如果玩家接触到敌人,玩家死亡。敌人通常不受玩家控制(那会让事情变得太容易)。因此,你需要定义某种聪明(或者愚蠢)的行为。你不希望这些敌人太聪明:玩家应该能完成关卡。毕竟,这是玩游戏的目标:赢得游戏。好的是,你可以建立不同类型的敌人,表现出不同类型的行为。因此,玩家有不同的游戏选项,必须制定不同的策略来完成关卡。

定义敌人的行为会导致一些非常复杂的代码,有许多不同的状态、推理、路径规划等等。在这一章中,你会看到一些不同类型的敌人:一枚火箭,一只打喷嚏的乌龟(说真的),斯巴基,和几个不同的巡逻敌人。这一章并不涉及玩家应该如何与敌人互动——你只需要定义他们的基本行为。

火箭

最基本的敌人之一是火箭。一枚火箭从屏幕的一边飞到另一边,过了一段时间后再次出现。如果玩家接触到火箭,玩家就会死亡。在关卡描述中,你用 rR 人物来表示一个火箭敌人应该被放置在一个关卡中。例如,考虑以下级别描述:

window.LEVELS.push({
    hint : "Many, many, many, many, many rockets...",
    locked : true,
    solved : false,
    tiles : ["....................",
                "r..W...........X....",
                "...--..W.......--...",
                "....W.--........W..R",
                "...--..........--...",
                "r..W......W....W....",
                "...--....--....--...",
                "....W...........W...",
                "...--........W.--...",
                "r..W........--.W....",
                "...--..........--...",
                "....W...........W..R",
                "...--..........--...",
                ".1..................",
                "######..####..######"]
});

小写的 r 表示火箭应该从左往右飞,大写的 R 表示应该从右往左飞(参见表 24-1 )。

制造和重置火箭

让我们创建一个代表这种特殊敌人的Rocket类。您继承了AnimatedGameObject类,因为火箭是动画的。在构造函数中,初始化Rocket对象。您需要加载火箭动画并播放它,然后您需要检查动画是否应该镜像。因为动画中火箭向右移动,如果火箭向左移动,您需要镜像它。您还可以存储火箭的起始位置,这样当它移出屏幕时,您可以将它放回那个位置。最后,您需要一个变量spawnTime来跟踪火箭应该何时出现。这是完整的构造函数:

function Rocket(moveToLeft, startPosition, layer, id) {
    powerupjs.AnimatedGameObject.call(this, layer, id);
    this.spawnTime = 0;
    this.startPosition = startPosition;
    this.mirror = moveToLeft;

    this.loadAnimation(sprites.rocket, "default", true, 0.5);
    this.playAnimation("default");
    this.origin = new powerupjs.Vector2(this.width / 2, this.height);
    this.reset();
}

构造函数中的最后一条指令是对reset方法的调用。在这个方法中,您将火箭的当前位置设置为起始位置,将可见性设置为false(因此火箭最初是不可见的),并将速度设置为零。您还可以使用随机数生成器来计算一个随机时间(以秒为单位),在该时间之后火箭应该出现并开始移动。你把这个时间存储在成员变量spawnTime中。您将这些指令放在一个单独的reset方法中,因为您稍后也会调用这个方法,在火箭飞出屏幕之后。

编程火箭行为

火箭的行为(像往常一样)编码在update方法中。基本上,火箭表现出两种主要类型的行为:要么它是可见的并从屏幕的一端移动到另一端,要么它是不可见的并等待出现。通过查看spawnTime变量的值,可以确定火箭处于两种状态中的哪一种。如果这个变量包含一个大于零的值,火箭就在等待生成。如果该值小于或等于零,则火箭可见,并从屏幕的一端移动到另一端。

我们来看第一种情况。如果火箭正在等待产卵,您只需从产卵时间中减去自最后一次update调用以来已经过去的时间:

if (this.spawnTime > 0) {
    this.spawnTime -= delta;
    return;
}

第二种情况稍微复杂一些。火箭从屏幕的一端移动到另一端。因此,您将可见性状态设置为true,根据火箭移动的方向计算其速度,并更新其位置:

this.visible = true;
this.velocity.x = 600;
if (this.mirror)
    this.velocity.x *= -1;

最后,你要检查火箭是否已经飞出屏幕。如果是这样的话,火箭应该重置。你可以使用边界框来检查火箭是否在屏幕之外。如果包围屏幕的边界框不与火箭的边界框相交,您知道火箭在屏幕之外,并重置它:

var screenBox = new powerupjs.Rectangle(0, 0, powerupjs.Game.size.x,
powerupjs.Game.size.y);
if (!screenBox.intersects(this.boundingBox))
    this.reset();

这就完成了Rocket类,除了与玩家的交互,这是你在下一章会更详细看到的。有关完整的类,请参见属于本章的TickTick3示例代码。图 27-1 显示了本章第一节中定义的级别的屏幕截图。

9781430265382_Fig27-01.jpg

图 27-1 。一个有许多火箭飞来飞去的关卡

巡逻的敌人

火箭是一种基本上没有智能行为的敌人。它从左向右飞,或者从右向左飞,直到飞出屏幕,然后它自己复位。你也可以添加稍微聪明一点的敌人,比如一个巡逻的敌人。让我们设置一些不同类型的巡逻敌人,你可以添加到游戏中。

基本的巡逻敌人类

PatrollingEnemy类类似于Rocket类。你希望巡逻的敌人被动画化,所以它从AnimatedGameObject类继承而来。你还需要在被覆盖的update方法中定义敌人的行为。巡逻的敌人的基本行为是从左到右再走回来。如果敌人到达一个缺口或一面墙砖,敌人停止行走,等待一段时间,然后转身。你可以在关卡中的任意位置放置敌人。对于玩家来说,你需要定义一些基本的物理概念,比如下落和跳跃。你不会为PatrollingEnemy类这么做,因为你为这个游戏定义的敌人只会从左到右来回走动。

PatrollingEnemy类的构造函数中,你为巡逻的敌人角色加载主动画(一个愤怒的火焰,如图图 27-2 所示)。最初,你设置一个正的速度,这样敌人开始向右走。你还初始化了另一个名为_waitTime的成员变量,它记录了敌人在它行走的平台的一边等待了多久:

this._waitTime = 0;
this.velocity.x = 120;
this.loadAnimation(sprites.flame, "default", true);
this.playAnimation("default");

9781430265382_Fig27-02.jpg

图 27-2 。几个巡逻的敌人

update方法中,你要区分两种情况:敌人是在走还是在等。您可以通过查看_waitTime变量来区分这些状态。如果这个变量包含正值,说明敌人在等待。如果变量包含零或更小的值,则敌人正在行走。当敌人在等待时,你不必做太多。就像你在Rocket类中做的一样,你从_waitTime变量中减去游戏时间。如果等待时间已经到了零,你需要把角色转过来。下面是实现这一点的代码:

if (this._waitTime > 0) {
    this._waitTime -= delta;
    if (this._waitTime <= 0)
        this.turnAround();
}

turnAround方法简单地反转速度并镜像动画:

PatrollingEnemy.prototype.turnAround = function () {
    this.mirror = !this.mirror;
    this.velocity.x = 120;
    if (this.mirror)
        this.velocity.x = -this.velocity.x;
};

如果敌人现在正在行走,而不是等待,你需要发现它是否已经到达它行走的平台的边缘。它在两种情况下达到了一个边缘:要么是有一个缺口,所以敌人无法进一步移动,要么是一个墙砖挡住了去路。你使用敌人的包围盒来找到这些信息。如果敌人向左走,你检查最左边的 x 值是否已经到达墙砖或平台的边界。如果敌人向右走,你检查最右边的 x 值。您可以如下计算这个 x 值:

var tiles = this.root.find(ID.tiles);
var posX = this.boundingBox.left;
if (!this.mirror)
    posX = this.boundingBox.right;

现在,您计算这个 x 值所在的区块。你可以通过将 x 值除以图块的宽度来计算。为了确保您总是得到正确的(下限)瓦片索引,您使用了Math.floor方法:

var tileX = Math.floor(posX / tiles.cellWidth);

用类似的方法,你可以计算出敌人当前所站的那张牌的 y 指数:。

var tileY = Math.floor(this.position.y / tiles.cellHeight);

注意,因为你用雪碧的来代表敌人的位置,所以你得到的 y 指数就是敌人下面的那个平铺的指数。

接下来你必须检查敌人是否已经到达墙砖或平台的边界。如果计算出的索引处的牌是背景牌,则敌人已经到达平台的边界,必须停止行走。如果索引(tileX, tileY - 1)处的瓷砖(换句话说,紧挨着敌人的瓷砖)是墙砖,敌人也必须停止行走。为了停止行走,您为等待时间指定一个正值,并将 x 速度设置为零:

if (tiles.getTileType(tileX, tileY - 1) === TileType.normal ||
    tiles.getTileType(tileX, tileY) === TileType.background) {
    this._waitTime = 0.5;
    this.velocity.x = 0;
}

不同类型的敌人

可以通过引入几个品种让巡逻的敌人稍微有趣一点。这里你可以用继承的力量写几个PatrollingEnemy类的子类来定义不同的敌人行为。

例如,你可以通过让敌人偶尔改变方向来创造一个更难以预测的敌人。在这一点上,你也可以改变敌人的行走速度为一个随机值。您可以通过定义一个继承自PatrollingEnemy类的类UnpredictableEnemy来实现这一点。因此,默认情况下,它表现出与普通敌人相同的行为。您覆盖了update方法,添加了几行代码,随机改变敌人行走的方向和速度。因为您重用了大部分的PatrollingEnemy类代码,所以UnpredictableEnemy类相当短。下面是完整的类定义:

"use strict";

function UnpredictableEnemy(layer, id) {
    PatrollingEnemy.call(this, layer, id);
}

UnpredictableEnemy.prototype = Object.create(PatrollingEnemy.prototype);

UnpredictableEnemy.prototype.update = function (delta) {
    PatrollingEnemy.prototype.update.call(this, delta);
    if (this._waitTime <= 0 && Math.random() < 0.01) {
        this.turnAround();
        this.velocity.x = Math.sign(this.velocity.x) * Math.random() * 300;
    }
};

如您所见,您使用了一个if指令来检查随机生成的数字是否低于某个值。因此,在少数情况下,条件会产生true。在if指令的主体中,你首先让敌人掉头,然后你计算一个新的 x 速度。请注意,您将随机生成的速度乘以旧速度值的符号。这是为了确保新的速度设置在正确的方向上。您还首先调用基类的update方法,以便选择正确的动画,处理与玩家的冲突,等等。

我能想到的另一个变种是跟随玩家的敌人,而不是简单地从左到右再回来。同样,您继承了PatrollingEnemy类。这里有一个类叫做PlayerFollowingEnemy :

"use strict";

function PlayerFollowingEnemy(layer, id) {
    PatrollingEnemy.call(this, layer, id);
}

PlayerFollowingEnemy.prototype = Object.create(PatrollingEnemy.prototype);

PlayerFollowingEnemy.prototype.update = function (delta) {
    PatrollingEnemy.prototype.update.call(this, delta);

    var player = this.root.find(ID.player);
    var direction = player.position.x - this.position.x;
    if (Math.sign(direction) !== Math.sign(this.velocity.x) &&
        player.velocity.x !== 0 && this.velocity.x !== 0)
        this.turnAround();
};

这个职业定义了一个在玩家移动时跟随玩家的敌人。这是通过检查敌人当前是否在玩家站立的方向行走来完成的(只考虑 x 方向)。否则,敌人会掉头。只有当玩家不在 x 方向移动时(换句话说,玩家的 x 速度为零),你才能限制敌人的智力。

你不应该让敌人太聪明。此外,不要让他们走得太快——如果敌人在跟踪他们时走得明显比玩家快,这将是一个短暂的游戏。玩家要打败敌人,这样玩家才能赢得游戏。玩一个敌人太聪明或者不可战胜的游戏并不好玩,除非你喜欢一次又一次地死去!

其他类型的敌人

你可以加入游戏的另一个敌人是打喷嚏的乌龟(见图 27-3 )。你会问,为什么是乌龟?为什么是打喷嚏的那个?这个问题我真的没有答案。但这个敌人背后的想法是,它既有消极的一面,也有积极的一面。不好的一面是,乌龟打喷嚏的时候会长尖刺,你不要碰它。但是如果乌龟不打喷嚏,你可以用它跳得更高。因为您现在还没有处理交互,所以您现在只添加了动画乌龟。可以用乌龟跳 5 秒,然后它打喷嚏长尖刺 5 秒,之后又回到之前的状态 5 秒,以此类推。

9781430265382_Fig27-03.jpg

图 27-3 。不要跳到带刺的乌龟身上!

敌人由Turtle职业代表,它的设置方式与之前的敌人相似。一只海龟有两种状态:它是空闲的,或者它打了个喷嚏,因此有危险的刺。在这种情况下,您维护两个成员变量来跟踪海龟处于哪种状态以及在该状态下已经过了多长时间:waitTime变量跟踪当前状态下还剩多少时间,而sneezing变量跟踪海龟是否在打喷嚏。同样,在update方法中,你处理两个阶段之间的转换,就像你处理火箭和巡逻的敌人一样。我在这里不再赘述,因为代码和其他敌人职业非常相似。如果你想看完整的代码,可以查看本章解决方案中的TickTick3程序。

火花是你加入游戏的最后一种敌人。就像其他敌人一样,斯巴基有两种状态(见图 27-4 )。Sparky 是一个非常危险的,喜欢电的敌人。他静静地悬在空中,直到他收到一束能量。当那发生时,他摔倒了。当斯巴基悬在空中时,他并不危险;但他一倒下,就不要碰他!看看Sparky类就知道代码了。

9781430265382_Fig27-04.jpg

图 27-4 。斯巴基通电时很危险

敌方软件架构

所有这些不同类型的敌人看起来不同,行为也不同,但他们通常有一个共同的职业设计。您也许可以设计一种更好的方法来定义这些敌人,使用几个泛型类来定义状态和它们之间的转换。每个过渡都可能有附加条件,例如必须经过一定的时间或者动画应该结束播放。这样的结构被称为有限状态机。这是人工智能系统中非常常见的技术。如果你准备好迎接挑战,试着写一个有限状态机库,并重新定义现有的敌人来使用它!

装载不同类型的敌人

现在你已经定义了不同种类的敌人,剩下唯一要做的就是在你读取等级数据变量时加载它们。不同敌人的精灵通过角色来识别。您将这些敌方角色存储在一个GameObjectList对象中,该对象是在Level类构造函数:中创建的

this._enemies = new powerupjs.GameObjectList(ID.layer_objects);

根据你在加载关卡时读取的角色,你调用不同的方法来加载敌人,通过在Level类中的switch指令中添加一些情况:

case 'R':
    return this.loadRocketTile(x, y, true);
case 'r':
    return this.loadRocketTile(x, y, false);
case 'S':
    return this.loadSparkyTile(x, y);
case 'T':
    return this.loadTurtleTile(x, y);
case 'A':
case 'B':
case 'C':
    return this.loadFlameTile(x, y, tileType);

装载敌人很简单。你只需创建一个你想要添加的敌人的实例,设置它的位置,并将其添加到游戏对象的_enemies列表中。举个例子,下面是龟敌的装载方法:

Level.prototype.loadTurtleTile = function (x, y) {
    var tiles = this.find(ID.tiles);
    var enemy = new Turtle(ID.layer_objects);
    enemy.position = new powerupjs.Vector2((x + 0.5) * tiles.cellWidth,
        (y + 1) * tiles.cellHeight + 25);
    this._enemies.add(enemy);
    return new Tile();
};

你现在已经定义了一些不同种类的敌人,他们有着不同的智力和能力。根据你游戏的需要,由你来定义更聪明,更狡猾,甚至更愚蠢的敌人。你没有把任何物理学应用到敌人身上;然而,一旦你开始建造更聪明的敌人,例如,可以跳跃或跌倒,你将需要像你为玩家所做的那样实现物理。作为一个练习,试着去思考你如何能让这些敌人变得更有能力,而不必依赖物理。当玩家在附近时,你能让他们移动得更快吗?你能创造一个向玩家发射粒子的敌人吗?可能性是无穷无尽的,所以自己试试这些东西吧!

你学到了什么

在本章中,您学习了:

  • 如何定义不同种类的敌人
  • 如何使用继承来创造敌人行为的多样性

二十八、添加玩家互动

在这一章中,您将在玩家和关卡中的对象之间添加更多的交互。目前,玩家可以四处走动,一个基本的物理系统允许玩家跳跃,与墙砖碰撞,或从屏幕上摔下来。首先你看一种非常简单的互动:收集水滴。然后,您将看到如何创建允许玩家在冰上滑行的行为。最后,你要关注程序中处理游戏中各种玩家-敌人互动的部分。

收集水滴

首先要添加的是玩家收集水滴的可能性。如果炸弹人物与水滴碰撞,玩家收集水滴。在这种情况下,你使下降看不见。

一旦玩家收集了一个液滴,让它隐形并不是解决只画未收集的液滴问题的唯一方法,但这是最简单的方法之一。另一种方法是维护一个已经收集的水滴列表,然后只画那些玩家仍然需要找到的水滴,但是这种技术需要更多的代码。

检查玩家是否与水滴碰撞的地方在WaterDrop级。原因很清楚:和以前一样,每个游戏对象都要为自己的行为负责。如果在WaterDrop类中处理这些碰撞,每个水滴都会检查是否与玩家发生碰撞。你用update方法写这段代码。第一步是检索播放器:

var player = this.root.find(ID.player);

如果水滴当前可见,使用collidesWith方法检查它是否与玩家碰撞。如果是,您将拖放的可见性状态设置为false。您还可以播放声音,让玩家知道水滴已被收集:

if (this.collidesWith(player)) {
    this.visible = false;
    sounds.water_collected.play();
}

稍后,您可以通过检查每个水滴的可见性来确定关卡是否完成。如果所有的水滴都看不见,你知道玩家已经收集了所有的水滴。

冰块

你可以添加到游戏中的另一种互动是玩家在冰上行走时的特殊行为。当玩家在冰上移动时,您希望角色以恒定的速度继续滑动,并且在玩家释放箭头键时不停止移动。尽管继续滑行并不完全现实(在现实生活中,你会滑行并减速),但它确实会导致玩家容易理解的可预测行为,这在许多情况下比实现现实主义更重要。要实现这一点,你必须做两件事:

  • 扩展handleInput方法来处理在冰上移动。
  • 计算玩家是否站在冰上。

你在Player类的成员变量walkingOnIce中跟踪玩家是否站在冰上。现在让我们假设这个变量在别的地方被更新了,让我们看看扩展handleInput方法。当角色在冰上行走时,你首先要做的是增加玩家的行走速度。你可以这样做:

var walkingSpeed = 400;
if (this.walkingOnIce) {
    walkingSpeed *= 1.5;
}

速度乘以的值是一个影响游戏性的变量。选择正确的值很重要——太快,关卡就变得不可玩了;太慢了,而且冰面在任何有意义的方面都和普通的人行道没有什么不同。

如果玩家不是在冰上行走,而是站在地上,你需要将 x 速度设置为零,这样当玩家不再按下箭头键或某个触摸按钮时,角色就会停止移动。为了实现这一点,您将前面的if指令扩展如下:

var walkingSpeed = 400;
if (this.walkingOnIce) {
    walkingSpeed *= 1.5;
    this.velocity.x = Math.sign(this.velocity.x) * walkingSpeed;
} else if (this.onTheGround)
    this.velocity.x = 0;

然后你处理玩家的输入。如果玩家按下左或右箭头键,您设置适当的 x 速度:

if (powerupjs.Keyboard.down(powerupjs.Keys.left))
    this.velocity.x = -walkingSpeed;
else if (powerupjs.Keyboard.down(powerupjs.Keys.right))
    this.velocity.x = walkingSpeed;

类似地,如果游戏是在触摸设备上进行的,您可以检查玩家是否正在触摸其中一个按钮,并相应地调整玩家角色的速度。

你唯一需要做的就是找出玩家是否在冰上行走,并相应地更新walkingOnIce成员变量。您已经在handleCollisions方法中查看了玩家周围的瓷砖,所以要扩展该方法来检查玩家是否在冰上行走,您只需要添加几行代码。在这个方法的开始,你假设玩家不是在冰上行走:

this.walkingOnIce = false;

玩家只有在地面上才能在冰上行走。你在下面的if指令中检查它们是否在地面上:

if (this._previousYPosition <= tileBounds.top && tileType !== TileType.background) {
    this.onTheGround = true;
    this.velocity.y = 0;
}

要检查玩家所站的瓷砖是否是冰瓷砖,您必须从瓷砖字段中检索瓷砖并检查其ice属性。这样做很简单:

var currentTile = tiles.at(x, y);

最后,您更新了walkingOnIce变量。你使用一个逻辑操作符,这样如果玩家只是部分在冰砖上,变量也被设置为true:

if (currentTile !== null) {
    this.walkingOnIce = this.walkingOnIce || currentTile.ice;
}

只有当currentTile变量没有指向null时,才执行这条指令。你使用逻辑来计算玩家是否在冰上行走,以便考虑所有周围的牌。效果是角色继续移动,直到它不再站在冰砖上(甚至不是部分地)。

敌人与玩家相撞

最后一种要添加的互动是与敌人的碰撞。在很多情况下,当玩家与敌人发生碰撞时,会导致玩家死亡。在某些情况下,你必须做一些特殊的事情(比如跳到海龟身上时跳得特别高)。在玩家方面,你必须加载一个额外的显示玩家死亡的动画。因为您不想在玩家死亡后处理玩家输入,所以您需要更新玩家当前的存活状态。您可以使用在Player类的构造函数中设置为true的成员变量alive来做到这一点。在handleInput方法中,你检查玩家是否还活着。如果不是,你从方法返回,所以你不处理任何输入:

if (!this.alive)
    return;

你还加了一个叫die的方法让玩家死掉。玩家有两种死法:掉进游戏屏幕外的洞里和与敌人相撞。因此,您向die方法传递一个布尔参数,以指示玩家是因摔倒还是因与敌人相撞而死亡。

die方法中,您要做几件事情。首先你要检查玩家是否已经死亡。如果是这样,你什么都不做就从方法返回(毕竟一个玩家只能死一次)。你将变量alive设置为false。然后你将 x 方向的速度设置为零,以阻止玩家向左或向右移动。你没有重置 y 的速度,所以玩家继续下落:当你死亡时,重力并没有消失。接下来,你决定玩家死亡时播放哪种声音。如果玩家摔死,产生的声音和死于敌人之手截然不同(不要真实尝试这个;相信我的话)。如果玩家因为与敌人碰撞而死亡,你也给玩家一个向上的速度。这种向上的速度不太现实,但它确实提供了一个很好的视觉效果(见图 28-1 )。最后,你播放die动画。完整的方法如下:

Player.prototype.die = function (falling) {
    if (!this.alive)
        return;
    this.alive = false;
    this.velocity.x = 0;
    if (falling) {
        sounds.player_fall.play();
    }
    else {
        this.velocity.y = -900;
        sounds.player_die.play();
    }
    this.playAnimation("die");
};

9781430265382_Fig28-01.jpg

图 28-1 。玩家在与敌人相撞后死亡

您可以在update方法中通过计算玩家的 y 位置是否落在屏幕之外来检查玩家是否会摔死。如果是这种情况,你调用die方法:

var tiles = this.root.find(ID.tiles);
if (this.boundingBox.top >=tiles.rows * tiles.cellHeight)
    this.die(true);

update方法的开始,您调用超类的update方法来确保动画被更新:

powerupjs.AnimatedGameObject.prototype.update.call(this, delta);

接下来你做物理和碰撞(即使玩家死了,仍然需要做)。然后你检查玩家是否还活着。如果没有,就完成了,从方法返回。

现在玩家可以以各种可怕的方式死去,你必须扩展敌人的职业来处理碰撞。在Rocket类中,您添加了一个名为checkPlayerCollision的方法,您在 rocket 的update方法中调用该方法。在checkPlayerCollision方法中,你只是简单的检查玩家是否与火箭相撞。如果是这种情况,您可以在Player对象上调用die方法。完整的方法如下:

Rocket.prototype.checkPlayerCollision = function () {
    var player = this.root.find(ID.player);
    if (this.collidesWith(player))
        player.die(false);
};

在巡逻的敌人的情况下,你做完全相同的事情。您向该类添加相同的方法,并从update方法中调用它。Sparky类中的版本略有不同:只有当 Sparky 正在通电时,玩家才会死亡。因此,你改变方法如下:

Sparky.prototype.checkPlayerCollision = function () {
    var player = this.root.find(ID.player);
    if (this.idleTime <= 0 && this.collidesWith(player))
        player.die(false);
};

最后,敌人增加了更多的行为。你从检查乌龟是否与玩家发生碰撞开始。如果不是这样,你只需从checkPlayerCollision方法返回,因为你已经完成了:

var player = this.root.find(ID.player);
if (!this.collidesWith(player))
    return;

如果发生碰撞,有两种可能。首先是乌龟目前在打喷嚏。在这种情况下,玩家死亡:

if (this.sneezing)
    player.die(false);

第二种情况是乌龟处于等待模式,玩家正跳到乌龟身上。在这种情况下,玩家应该做一个超高的跳跃。检查玩家是否跳到海龟身上的一个简单方法是看一下 y 速度。假设速度为正,玩家跳到海龟身上。所以,你调用jump方法让玩家跳得特别高:

else if (player.velocity.y > 0 && player.alive)
    player.jump(1500);

当然,只有当玩家还活着的时候你才想这么做。

现在你有了主要的交互编程。在下一章中,你通过在背景中添加山脉和移动的云来完成这个游戏。您还可以添加管理级别之间转换的代码。

死还是不死?

我在这一节做了一个选择,玩家接触敌人时会立即死亡。另一个选择是给玩家几条命,或者给玩家增加一个健康指标,每次玩家碰到一个敌人,健康指标就会减少。

在游戏中加入多个生命或健康指标可以让游戏变得更有趣,但你也必须确保关卡仍然具有足够的挑战性。只有当游戏的等级比本章例子中的等级高得多时,健康条才有意义。您还需要添加侧滚动,以便级别可以比单个屏幕更大。

实现侧边滚动并不困难:你可以根据随玩家移动的相机偏移量来绘制游戏世界中的所有游戏对象。作为一个挑战,尝试用侧滚来扩展滴答滴答游戏,并为玩家添加一个健康栏。

你学到了什么

在本章中,您学习了:

  • 如何设计各种玩家与水滴和敌人的互动
  • 如何编程 ice tile 行为
  • 如何在某些情况下导致玩家死亡

二十九、完成滴答滴答游戏

在这一章,你完成滴答滴答游戏。首先你添加一个计时器,这样玩家就有有限的时间来完成每一关。然后你在背景中添加一些山和云,使游戏在视觉上更有趣。最后,您通过添加两个额外的游戏状态来完成关卡:“游戏结束”状态和“关卡完成”状态。

添加计时器

我们先来看看给游戏添加一个定时器。您不希望计时器占用太多的屏幕空间,所以您使用它的文本版本。因此,TimerGameObject类继承了Label类。您希望能够暂停计时器(例如,当关卡完成时),所以您添加了一个布尔变量running来指示计时器是否正在运行。您还将剩余时间存储在一个名为_timeLeft的变量中。您重写了reset方法来初始化定时器对象。你需要给玩家 30 秒来完成每一关。结果,下面是完整的reset方法:

TimerGameObject.prototype.reset = function () {
    powerupjs.Label.prototype.reset.call(this);
    this._timeLeft = 30;
    this.running = true;
};

为了方便起见,您还添加了一个属性gameOver,指示计时器是否已经到达零。稍后使用该属性来处理玩家没有及时完成关卡的事件:

Object.defineProperty(TimerGameObject.prototype, "gameOver",
    {
        get: function () {
            return this._timeLeft <= 0;
        }
    });

现在您唯一需要做的就是实现update方法来编程定时器行为。作为第一步,您只需更新正在运行的计时器。因此,如果计时器没有运行,那么从方法:返回

if (!this.running)
    return;

然后,像往常一样,从当前剩余时间中减去经过的游戏时间:

this._timeLeft -= delta;

接下来,创建要在屏幕上打印的文本。您可以简单地在屏幕上打印秒数,但是让我们使计时器更通用一些,这样也可以定义一个既能处理分钟又能处理秒钟的计时器。例如,如果您想定义一个从两分钟开始倒计时的计时器,您可以按如下方式初始化它:

this._timeLeft = 120;

你想在屏幕上显示“2:00”而不是“120”。为此,您需要在update方法中计算还剩多少分钟。你用Math.floor方法来做这个:

var minutes = Math.floor(this._timeLeft / 60);

使用这种方法,您可以确保分钟数不会超过允许值。例如,Math.floor(119)给出的结果是 1,这正是您所需要的,因为剩余 119 秒转化为 1 分钟,剩余 119–60 = 59 秒。

通过计算_timeLeft除以 60 后的余数,得到秒数。为了只有整数,您还需要对秒数进行舍入,但是您使用了Math.ceil方法。这个方法总是向上取整:例如,Math.ceil(1.2)的结果是 2。你总是想取整,因为你需要确保只有在真的没有剩余时间的时候才显示零秒。下面是你计算秒数的方法:

var seconds = Math.ceil(this._timeLeft % 60);

因为您不想显示负时间,所以您添加了下面的if指令:

if (this._timeLeft < 0)
    minutes = seconds = 0;

注意,这里使用运算符链接?? 来设置分钟和秒钟。下面的if指令做的完全一样:

if (this._timeLeft < 0) {
    minutes 0;
    seconds = 0;
}

现在您已经计算了剩余的分钟数和秒数,您可以创建一个在屏幕上绘制的字符串:

this.text = minutes + ":" + seconds;
if (seconds < 10)
    this.text = minutes + ":0" + seconds;

您将文本的颜色设置为黄色,以便更好地适应游戏的设计:

this.color = powerupjs.Color.yellow;

最后,如果玩家剩下的时间不多了,你要警告他们。当在屏幕上打印文本时,您可以通过在红色和黄色之间交替来做到这一点。您可以通过一条if指令和对模数运算符的巧妙使用来做到这一点:

if (this._timeLeft <= 10 && seconds % 2 === 0)
    this.color = powerupjs.Color.red;

尽管以这种方式计算时间对于 Tick Tick 游戏来说已经足够了,但是您可能会发现自己想要进行更复杂的时间计算。JavaScript 有一个Date对象,它代表时间并允许更高级的时间处理,包括时区、转换为字符串等等。

使计时器走得更快或更慢

根据玩家所走的瓷砖种类,时间应该走得更快或更慢。在热瓷砖上行走会加快时间流逝的速度,而在冰瓷砖上行走会减慢时间流逝的速度。为了允许计时器以不同的速度运行,您在TimerGameObject类中引入了一个乘数值。这个值存储为一个成员变量,您最初将乘数设置为 1:

this.multiplier = 1;

在计时器运行时考虑这个乘数是相当容易的。您只需用update方法中的乘数乘以经过的时间,就可以了:

this._timeLeft -= delta * this.multiplier;

现在你可以改变时间流逝的速度,你可以根据玩家行走的瓷砖类型来改变时间流逝的速度。在Player类中,您已经维护了一个变量walkingOnIce,它指示玩家是否在冰砖上行走。为了处理热瓷砖,您定义了另一个变量walkingOnHot,其中您跟踪玩家是否在热瓷砖上行走。要确定这个变量的值,您可以使用与walkingOnIce变量相同的方法。在handleCollisions方法中,您最初将这个变量设置为false :

this.walkingOnHot = false;

然后,添加一行代码,根据玩家当前所处的区块更新变量的值:

this.walkingOnHot = this.walkingOnHot || currentTile.hot;

关于完整的代码,请参见属于TickTickFinal示例的Player类。

使用walkingOnIcewalkingOnHot变量,您现在可以更新计时器乘数。你在玩家的update方法:中这样做

var timer = this.root.find(ID.timer);
if (this.walkingOnHot)
    timer.multiplier = 2;
else if (this.walkingOnIce)
    timer.multiplier = 0.5;
else
    timer.multiplier = 1;

从游戏设计的角度来看,明确地让玩家知道在热瓷砖上行走可以缩短完成关卡的时间,这可能是个好主意。您可以通过短暂显示一个警告叠层或更改计时器的显示颜色来实现这一点。您也可以播放警告声音。另一种可能是将背景音乐改为更疯狂的音乐,让玩家意识到有些事情已经改变了。

适应玩家的技能

改变计时器的速度可以使关卡更容易或更难。你可以延长游戏时间,这样在某些情况下,如果玩家拿起一个特殊的物品,计时器就会停止或者向后移动几秒钟。你甚至可以让等级进程自适应,这样如果玩家死得太频繁,每级 30 秒的最大时间就会增加。但是,这样做要小心。如果你以一种过于明显的方式帮助玩家,玩家会意识到这一点并调整他们的策略(换句话说,玩家会为了让关卡更容易而玩得更差)。此外,玩家可能觉得他们没有被认真对待。一个更好的处理适应每一关最大时间的方法是允许玩家(部分)将以前关卡剩余的时间转移到当前关卡。这样,困难的水平可以变得更容易,但玩家必须做一些事情来实现这一点。你也可以考虑增加难度等级,难度越高,计时越快,好处也越多,比如可以获得更多点数、额外物品或玩家的额外能力。休闲游戏玩家可以选择“我可以玩吗,爸爸?”难度级别,而熟练的玩家可以选择极具挑战性的“我是死亡化身”级别。

当计时器到达零时

当玩家没有按时完成关卡时,炸弹爆炸,游戏结束。Player类中的一个布尔成员变量表示播放器是否已经爆炸。然后,将名为explode的方法添加到启动爆炸的类中。这是完整的方法:

Player.prototype.explode = function () {
    if (!this.alive || this.finished)
        return;
    this.alive = false;
    this.exploded = true;
    this.velocity = powerupjs.Vector2.zero;
    this.playAnimation("explode");
    sounds.player_explode.play();
};

首先,如果玩家角色一开始就不存在,或者玩家完成了关卡,那么玩家角色就不能爆炸。在这两种情况下,您只需从方法返回。然后,将活动状态设置为false,将分解状态设置为true。您将速度设置为零(爆炸不会移动)。然后,播放“爆炸”动画。该动画存储在一个 sprite 表中,由爆炸的 25 帧组成。最后,你播放一个合适的声音。

因为重力也不再影响爆炸的角色,所以只有当玩家没有爆炸时才进行重力物理:

if (!this.exploded)
    this.velocity.y += 55;

Level类的update方法中,您检查计时器是否已经到零,如果是,您调用explode方法:

if (timer.gameOver)
    player.explode();

画山画云

为了让关卡背景更有趣一点,我们给它加上山和云。您可以在Level构造函数中这样做。先来看看怎么加几座山。为此,您可以使用一条for指令。在指令体中,创建一个精灵游戏对象,给它一个位置,并将其添加到backgrounds列表中。这是完整的for指令:

for (var i = 0; i < 5; i++) {
    var sprid = "mountain_" + (Math.ceil(Math.random()*2));
    var mountain = new powerupjs.SpriteGameObject(sprites[sprid], ID.layer_background_2);
    mountain.position = new powerupjs.Vector2(Math.random() *
        powerupjs.Game.size.x - mountain.width / 2,
        powerupjs.Game.size.y - mountain.height);
    backgrounds.add(mountain);
}

第一步是创建精灵游戏对象。你想在不同的山精灵中随机选择。因为有两个山精灵,所以创建一个随机数(1 或 2)在它们之间进行选择。您使用这个数字来创建对应于这个 sprite 的 ID。

然后你计算山的位置。 x 位置是随机选择的,你使用一个固定的 y 位置,这样山就在合适的高度(你不希望山悬在空中)。最后,山脉对象被添加到backgrounds列表中。

对于云,你做一些稍微复杂的事情。你希望云从左向右移动,反之亦然,如果云从屏幕上消失,你希望新的云出现。要做到这一点,您需要在游戏中添加一个Clouds类。在Level构造函数中创建这个类的一个实例,并赋予它一个比背景本身和山脉更高的层值。这确保了云被画在山的前面:

var clouds = new Clouds(ID.layer_background_3);
backgrounds.add(clouds);

因为Clouds类包含许多移动的云,所以它是GameObjectList类的子类。在构造函数中,您使用一个for指令来创建一些云并将它们添加到列表中。每个云都被赋予一个随机的位置和一个随机的速度。看看TickTickFinal例子中Clouds类的构造函数,看看这是如何实现的。

Clouds类也有一个update方法,在这个方法中,您可以检查云是否已经退出屏幕。因为您需要为每个云游戏对象做这件事,所以您使用一个for指令来遍历列表中的所有云对象。如果云已经退出屏幕,您可以创建一个具有随机位置和速度的新云对象。云可以出现在屏幕的左侧或右侧。如果一朵云位于屏幕外的左侧,并且它的 x 速度为,你就知道它已经退出了屏幕。如果云位于屏幕外的右侧且其速度为正时也是如此。您可以在下面的if指令中为云c捕获这两种情况:**

if ((c.velocity.x < 0 && c.position.x + c.width < 0) ||
   (c.velocity.x > 0 && c.position.x > powerupjs.Game.size.x)) {
    // remove this cloud and add a new one
}

移除云很容易:

this.remove(c);

然后创建一个新的云游戏对象:

var cloud = new powerupjs.SpriteGameObject(sprites["cloud_" + Math.ceil(Math.random()*5)]);

你给这个云分配一个 x 的速度,它可以是正的也可以是负的。云的 y 速度总是为零,所以云只水平移动:

cloud.velocity = new powerupjs.Vector2(((Math.random() * 2) - 1) * 20, 0);

请注意,在本指令中,您计算一个介于-1 和 1 之间的随机数,然后将该数乘以 20。这允许你随机创建速度为正或负的云。通过将屏幕高度乘以 0 到 1 之间的一个随机数,你可以计算出一个随机的云 y 位置。从这个数字中减去云高度的一半,以确保不会生成完全绘制在屏幕下方的云:

var cloudHeight = Math.random() * powerupjs.Game.size.y - cloud.height / 2;

根据云移动的方向,您可以将云放置在屏幕的左边界或右边界:

if (cloud.velocity.x < 0)
    cloud.position = new powerupjs.Vector2(powerupjs.Game.size.x, cloudHeight);
else
    cloud.position = new powerupjs.Vector2(-cloud.width, cloudHeight);

现在,您将新的云添加到列表中:

this.add(cloud);

图 29-1 显示了一个背景中有山脉和移动的云的关卡的截图。

9781430265382_Fig29-01.jpg

图 29-1 。背景中有山脉和移动的云的滴答滴答水平

在您完成本节之前,让我们再看一遍完整的代码:

for (var i = 0, l = this.length; i < l; ++i) {
    var c = this.at(i);
    if (/* c is outside of the screen */) {
        this.remove(c);
        var cloud = new powerupjs.SpriteGameObject(...);
        // calculate cloud position and velocity
        // ...
        this.add(cloud);
    }
}

仔细看看这个循环:在用一条for指令遍历列表时,您正在向列表中添加和删除对象。这可能很危险,因为您在for指令体中修改了列表的长度,而i的值取决于列表的长度。如果不小心的话,您可能会遇到这样的情况:您从正在遍历的列表中删除了一个项目,但是i仍然会递增,直到它达到列表的旧长度,当您试图访问超出其界限的列表时,会导致错误。在这种特殊的情况下,你不会遇到麻烦,因为每当你删除一个云,你添加了一个新的;但是在编写这类操作的程序时,你必须非常小心。确保程序在所有情况下都能正确运行的一个方法是使用breakreturn调用简单地跳出循环。这样,一旦以某种方式修改了列表,就停止了循环。

最终确定级别晋升

为了完成游戏,您仍然需要添加游戏状态来处理玩家输掉或赢得一个级别的事件。除了“关卡完成”游戏状态之外,这里还有一个明确的“游戏结束”游戏状态。这些状态以一种相当简单的方式编码,就像你在以前的游戏中那样。您可以在属于本章的TickTickFinal示例中的GameOverStateLevelFinished状态类中找到完整的代码。

为了确定玩家是否已经完成了一个关卡,您需要向Level类添加一个completed属性来检查两件事情:

  • 玩家收集了所有的水滴了吗?
  • 玩家到达出口标志了吗?

这两件事都很容易检查。要检查玩家是否到达结束符号,您可以查看他们的边界框是否相交。检查玩家是否收集了所有的水滴可以通过验证所有的水滴都是不可见的来完成。这是完整的属性:

Object.defineProperty(Level.prototype, "completed",
    {
        get: function () {
            var player = this.find(ID.player);
            var exit = this.find(ID.exit);
            if (!exit.collidesWith(player))
                return false;
            for (var i = 0, l = this._waterdrops.length; i < l; ++i) {
                if (this._waterdrops.at(i).visible)
                    return false;
            }
            return true;
        }
    });

Level类的update方法中,您检查该级别是否完成。如果是这样,你调用Player类中的levelFinished方法,它播放“庆典”动画:

if (this.completed && timer.running) {
    player.levelFinished();
    timer.running = false;
    window.LEVELS[this._levelIndex].solved = true;
}

你也停止了计时器,因为播放器完成了。再者,你把这一关的已解决状态设置为true,这样下次玩家开始游戏时,浏览器就会记住。在PlayingState类中,你根据关卡的状态处理切换到其他状态。下面是该类的update方法中相应的代码行:

PlayingState.prototype.update = function (delta) {
    this.currentLevel.update(delta);

    if (this.currentLevel.gameOver)
        powerupjs.GameStateManager.switchTo(ID.game_state_gameover);
    else if (this.currentLevel.completed)
        powerupjs.GameStateManager.switchTo(ID.game_state_levelfinished);
};

处理等级转换的代码相当简单,几乎是企鹅配对游戏中使用的代码的翻版。看看TickTickFinal例子中的代码,看看这是如何做到的。

您现在已经看到了如何使用常见的元素来构建平台游戏,例如收集物品、躲避敌人、游戏物理、从一个级别进入另一个级别等等。到此为止了吗?那要看你了。要让 Tick Tick 成为商业上可行的游戏,还有很多工作要做。你可能想定义更多的东西:更多的关卡,更多的敌人,更多不同的物品,更多的挑战,更多的声音。你可能还想介绍一些我没有提到的东西:通过网络与其他玩家一起玩,侧边滚动,维护高分列表,在关卡之间播放游戏电影,以及你能想到的其他有趣的东西。使用 Tick Tick 游戏作为您自己游戏的起点。

这本书的最后一部分涵盖了在用 JavaScript 开发游戏和应用时需要了解的一些有用的东西。我将更详细地讨论文档,以及一些保护游戏代码和让玩家更快下载游戏的方法。

你学到了什么

在本章中,您学习了:

  • 如何给关卡添加计时器
  • 如何创建由山和云组成的动画背景*

三十、制作游戏

本章涵盖了几个与制作游戏相关的主题。我先谈设计 HTML5 游戏,然后谈开发。我还简要介绍了游戏素材的制作。最后,你会看到制作游戏的操作方面,比如如何在同一代码上与多人合作,以及如何在游戏制作团队中工作。马克·奥维马斯和彼得·维斯特巴卡在文中分享了关于这些话题的想法和技巧。

Peter vester backa:“html 5 和 JavaScript 在游戏开发方面被称为未来已经有很长时间了。他们还没有完全发挥他们的潜力,但我看到了很多希望。与此同时,原生开发工具、界面和易用性也有了很大改进。当然,在一个理想的世界中,能够到处使用 JavaScript 和 HTML5 代码就太好了。我认为本地应用和 HTML5/JavaScript 应用都有空间。”

设计游戏

这不是一本关于游戏设计的书。游戏设计是一个很大的研究领域,很多书都是关于这个主题的。欧内斯特·亚当斯的《游戏设计基础》这本书是一个很好的开始阅读游戏设计的地方。另一本有趣的书是《??:游戏设计的艺术:透镜之书》,作者杰西·谢尔(CRC 出版社,2008)。

马克·奥维马斯:“在设计我们的游戏时,我们总是牢记代码需要高效。例如,我们不会设计一款游戏,其中数万个角色在屏幕上移动很重要,或者许多视觉事情同时发生,或者非常流畅的运动至关重要。”

这一部分不包括设计过程本身;相反,它主要讨论用 JavaScript 编写基于 web 的应用如何影响游戏设计。前面的引用是这种方法的一个例子:因为你想让你的游戏在各种设备上玩得好,你需要设计允许有效实现的游戏。

马克:“许多设备没有键盘,所以你的游戏需要允许通过触摸输入来控制。对于一些游戏来说,键盘控制更加自然。一个挑战是确保使用键盘玩游戏不会比使用触摸输入更容易。如果你开发一款游戏,让人们在网上对战,或者开发一款使用在线高分列表的游戏,要注意不要因为一组用户使用特定的输入法,就让他们比其他用户拥有更大的优势。”

彼得:“愤怒的小鸟成功的秘密之一是它是第一款在设计时就考虑到触摸设备的游戏。当你为触摸设备设计游戏时,它通常会不同于为带控制器的游戏机设计的游戏。始终为相关平台开发最佳体验。在 PlayStation 4 上玩和在 iPad 上玩是非常不同的体验,因为情况和背景非常不同。在一种情况下,你可以在沙发上坐几个小时在游戏机上玩游戏,而在手机上玩游戏可能只需要几分钟。两者都可以是很棒的体验,但方式非常不同,设计应该考虑到这一点。”

在许多 JavaScript 游戏中,精灵会根据设备的不同而放大或缩小。您已经在本书开发的游戏中看到了如何做到这一点。这本书没有考虑到的一点是,设备之间的长宽比是完全不同的。例如,iPad 的屏幕相对来说是方形的,不像 iPhone 6 的屏幕更像矩形。

在当前的游戏实现中,不同的纵横比意味着游戏屏幕周围有白色(或黑色)空间。如果你在 iPhone 5 上显示一个为 iPad 长宽比设计的游戏,几乎三分之一的屏幕都是空白的!在设计游戏时,尝试调整用户界面、游戏场地、覆盖位置等的设计以适应设备的长宽比是有意义的。理想情况下,游戏应该自动调整其整体布局,以适应每个设备的大小和长宽比。

Mark:“除了长宽比,还有人像和风景模式的选择。在手机上,你通常想使用纵向模式,但在台式电脑或电视上,横向模式更有意义。你的模式选择也取决于游戏的类型,你是否想使用纵向或横向模式,或者你是否希望两者都允许。

在我们的游戏中,游戏元素的定位依赖于长宽比。例如,在屏幕的顶部放置一个用户界面,在其下方是游戏区。如果有可用的空间,游戏区域将下移,以便元素在屏幕上的布局看起来更好。按钮的位置根据屏幕的长宽比而变化。但是,请注意,您也要相应地调整交互(手指位置)。因此,您不能总是使用设备的全屏。此外,如果您想要放置广告横幅,必须从可用于播放的屏幕部分中减去广告横幅的空间。这也意味着你必须使用容易缩放或者可以部分展示而不会从设计中带走任何东西的艺术品。"

当你设计在各种设备上运行的游戏时,你不能总是使用设备上所有可用的功能。例如,如果你的游戏严重依赖玩家倾斜设备,那么这个游戏就不能在台式机上玩。台式机显然没有检测倾斜的传感器(虽然那会很好玩!).此外,您通常依赖于浏览器的版本和已经实现的内容。音频就是一个很好的例子。不同的浏览器以不同的格式播放音频(或者根本不播放)。因此,你不应该设计一个将音频作为设计关键部分的游戏。像吉他英雄这样的游戏很难移植到 JavaScript,因为它们依赖于对音频的精确控制以及测量音频和玩家正在做的事情之间的同步性。

如果你制作一个游戏,你需要在为最小公分母(换句话说,功能最少的设备)创建游戏从而不使用更多现代设备的许多功能与创建一个使用那些功能但不能在旧设备上玩的游戏之间进行权衡。如果你想把你的游戏卖给一个游戏门户(一个托管许多不同游戏的网站),这个门户会有一个你的游戏需要支持的设备列表。所以,在很多情况下,如果你想通过门户发布你的游戏,你根本没有选择。

Mark:“游戏设计最重要的一个方面就是关卡设计。我们花了很多时间来调整关卡,使得每一关的难度都有很好的进展。所有影响游戏的参数都存储在设置文件中。然后,设计师可以修改这些设置文件,将这些文件的新版本推送到服务器,并立即使用新设置玩游戏。”

开发游戏

如果你想开发游戏,你需要知道如何编程,但你也需要知道解决编程问题的常用解决方案或方法。最重要的是,其中一些解决方案可能比其他解决方案更通用,一些解决方案可能比其他解决方案更有效。从这个意义上说,编程通常是在快速解决特定问题和花时间一次性解决一类问题之间的权衡。尤其是在游戏行业,由于紧迫的截止日期,通常很少有时间来解决各类问题。所以作为游戏行业的开发者,你需要非常仔细地考虑你选择的解决问题的方法。另一方面,编写好的、可重用的代码并不总是比编写快速而不可靠的代码花费更多的时间。随着您获得更多的编程经验,您会注意到您开始形成一种思维模式,这种思维模式可以让您快速判断某个编程问题需要哪种解决方案。让我们考虑决定解决方案的几个方面。

第三方库

许多游戏和应用依赖于不同开发人员编写的代码。这就是为什么当你写代码时,你要以这样一种方式来写,即代码是有逻辑的,并且易于其他开发者理解。通常,开发人员将相关代码分组到库中。例如,开发人员可以创建处理游戏中的物理的类,并在库中发布这些代码,以便在任何需要物理的游戏中使用。开发人员已经用许多不同的编程语言创建了许多库,包括 JavaScript。例如,jQuery 是一个众所周知的 JavaScript 库,用于在网站上创建界面。还有一些工具将库与开发环境相结合来创建完整的游戏,比如 Unity ( http://unity3d.com)有一个脚本引擎,它使用了一种非常类似于 JavaScript 的东西,叫做 UnityScript。另一个值得一看的游戏引擎是 Cocos2D ( www.cocos2d-x.org)。当你想开发一个商业游戏时,考虑使用这样的库或游戏引擎是一个好主意,因为它们允许你将游戏作为原生应用导出到各种平台。

Peter:“在 Rovio,我们的大多数游戏都是 iOS、Android 等系统的原生代码。我们确实有一个使用 WebGL 的 HTML5 版本的愤怒的小鸟,但我们暂时主要做原生移动应用开发。我们有自己多年来在内部开发的工具,所以我们可以非常容易地编写一次代码,然后将其部署到任何平台。对于一些项目,我们使用 Unity,这也使我们能够将代码部署到各种操作系统和设备上。”

Mark:“在很大程度上,我们开发了自己引擎,编写了自己的库。我们这样做是因为从代码中挤出最后一滴效率对我们来说非常重要。我们发现有许多设计精美、非常通用的库,正因为如此,它们很慢,很难适应我们的框架和工作方式。在少数情况下,我们会使用库,例如游戏物理。我们确实经常使用第三方开发的工具,如代码编辑器或混淆/缩小工具,如 Closure 有关闭包、混淆和缩小的更多信息,请参见[第三十一章]

在本书中,您只使用了一个第三方库——Lab.js——您使用它来更容易地加载多个 JavaScript 文件。您可以选择使用更多的库,而不是从头开始编写所有代码。就本书而言,我的目标是教你 JavaScript 的重要编程习惯,以及它们如何应用于游戏编程。我选择最小化使用的库的数量,这样我可以保持代码简单明了,并且符合我在书中提出的游戏编程的一般方法。作为一名开发人员,您经常需要在使用他人编写的库和自己从头开始编程之间做出选择。如果这个库写得很好,并且做了一些你需要的事情,那么在你的游戏中使用它是很有意义的。你不必做所有的工作来编写别人已经写好的类。此外,如果一个库有很多用户,那么库代码中的主要错误可能已经被解决了。总而言之,如果你使用库,你的游戏代码可能会比你自己编程更健壮。最后,由于库通常是为通用目的而开发的,您可能会发现您合并的库解决了您刚刚在游戏中发现的问题,因此您可以简单地使用库中已经存在的额外功能。

在某些情况下,图书馆带来的麻烦比它们的价值更大。首先,库通常是在某种许可模式下发布的。如果您想在您的商业游戏中使用开源库,许可证可能不允许您出售包含该库的游戏代码。因此,使用库会限制您对代码的处理,因为并非所有代码都是您编写的。

Mark:“库的另一个问题是许可证并不总是明确定义的,特别是因为你是用 JavaScript 发布源代码。此外,最终我们喜欢将所有的 JavaScript 代码放在一个缩小且模糊的文件中,这一过程在使用第三方库时并不总是正确。”

如果你使用一个库,你可以避免写所有的代码,但是你依赖于库的限制。如果您事先没有适当地调查这个库是否真的能解决您的问题,您可能会花很大的力气将这个库集成到您的代码中,然后发现它实际上并没有做您需要它做的重要事情。还有,有时候从头开始写代码而不是使用库是个好主意,因为这样做会迫使你在深层次上理解问题;因此,您可能会找到对您的应用更有效的解决方案。最后,如果您从头开始编写所有代码,那么扩展或修改代码会更容易,因为是您编写的。

总的来说,作为一名开发者,你必须对游戏的哪些部分你想自己编程(这需要时间,但会让你更好地理解)以及哪些部分你想使用一个库(这能更快地给出结果,但可能并不完全符合你的需求)。

代码效率

JavaScript 程序可以在许多不同的设备上运行,从高端台式机到平板电脑和智能手机。这有时会限制可用的计算能力。因此,JavaScript 程序拥有高效的代码至关重要。这取决于程序员如何解决特定的编程问题。通常,有许多可能的解决方案。例如,考虑创建一个数组并用数字 0、1、2、3 等填充它的简单问题。有一种方法可以做到这一点:

var myArray = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19};

您也可以使用一个for循环:

var myArray = {};
for (var i = 0; i < 20; i++)
    myArray.push(i);

这里还有另一个解决方案:

var myArray = {0};
while (myArray.length < 20)
    myArray.push(myArray.length);

这些解决方案都提供了一个大小为 20 的数组,包含数字 0-19。但是您选择哪种解决方案可能取决于上下文。第一种解决方案(写出数组)非常简单,通过查看代码,可以立即清楚地看到代码执行后数组的内容。对于较小的数组定义,这种方法非常有效。然而,如果您需要一个大小为 300 的数组,这是行不通的。第二种解决方案使用了一个for循环,在这种情况下更合适,因为改变数组的期望长度只需要改变for指令头中的一个数字。第三个解决方案使用了一个while循环来解决这个问题。它避免了声明一个计数器变量(i)。但是,这种解决方案可能不如第二种解决方案有效,因为在每次迭代中,必须检索两次数组的长度(一次在头中,一次在正文中)。

当你写代码时,总是预先考虑解决特定问题的各种解决方案,并选择最适合的一个。这不一定总是最有效的解决方案。如果一个解决方案效率稍低,但能产生更清晰的代码,那么选择那个解决方案可能是个好主意。有工具可以测量代码中的瓶颈。对于 Firefox,Firebug 工具有一个分析器,可以分析你的代码,让你知道哪里花了最多的时间。类似地,Chrome 的开发工具包括一个基准套件,可以分析 JavaScript 的性能。

能够为一个问题选择最佳解决方案需要了解在解释和执行代码时会发生什么。有时候低效率可能很容易解决,但是你不知道它们的存在。考虑下面的基本for指令,它将数组中的每个元素递增 1:

for (var i = 0; i < myArray.length; i++)
    myArray[i] += 1;

这条for指令很简单,但是效率不是很高。在循环的每次迭代中,检索数组的长度并与计数器进行比较。因为检索数组的长度需要时间,所以下面的for指令更有效:

for (var i = 0, l = myArray.length; i < l; i++)
    myArray[i] += 1;

在这种情况下,您检索数组的长度并将其存储在一个变量中。每次迭代都执行循环的条件,但是因为它现在使用变量而不是直接检索长度,所以代码更有效。

您可以进一步改进代码。递增计数器是通过以下指令完成的:

i++

让我们更详细地看看这条指令。可以按如下方式使用它:

var i = 10;
var j = i++;

尽管第二条指令看起来有点奇怪,但它是完全有效的 JavaScript 代码。这是因为i++也是一个表达式,所以它有一个结果可以存储在一个变量中。i++表达式的结果是在增加之前i?? 的值。结果,在第二条指令被执行后,j将包含值 10,而i将包含值 11。因此,i++需要创建一个临时变量来存储该值并返回。然而,在前面的for循环中,您不使用那个临时值。还有另一种增加变量的方法,如下所示:

++i

这与i++做的完全一样,除了它返回i的新值:

var i = 10;
var j = ++i; // j and i both contain the value 11

因为它返回新值,所以您不必存储旧值,从而消除了对临时变量的需要。您可以在for循环中利用这一点,使其更加有效:

for (var i = 0, l = myArray.length; i < l; ++i)
    myArray[i] += 1;

这些效率的提高看似微不足道,但如果对一个包含数千个粒子的阵列每秒执行 60 次for循环,那么效率的微小提高可能会决定一个流畅运行的游戏和一个不可玩的游戏,尤其是在计算能力有限的移动设备上。一些浏览器可能会在解释和运行 JavaScript 代码时进行优化,这种低效相对容易被自动检测到。但是,并非所有浏览器或版本都可以执行相同的优化。通过确保你的代码本身已经是高效的,你的游戏将在更多的平台上更流畅地运行。

Mark:“一般来说,图形是主要瓶颈。对于一些设备来说,把所有的东西都放在一个大的精灵中并画出来会更有效率。对于其他设备,这种方法没有帮助,因为瓶颈是绘制的像素数量。因此,我们并不总是知道瓶颈在哪里,因为瓶颈因设备、浏览器类型和版本、操作系统版本等而异。我们的观点是,我们需要尽可能高效地做每一件事。传统上,我们会使用一个分析器来找到瓶颈,然后尝试相应地优化代码。在 JavaScript 中,这是不可行的,因为有各种不同的设备和浏览器,更不用说对设备、浏览器和操作系统的某些组合使用分析器了。”

这本书里开发的游戏根本没有把重点放在效率上。还有很多可以改进的地方,尤其是在绘制图形的时候。目前,本书中的游戏在每次游戏循环迭代中都会重绘整个图像。在许多情况下,这是不必要的。屏幕的大部分不会改变,为什么要重画呢?HTML5 画布只允许你重画画布屏幕的一部分。如果你重写代码,使得屏幕的静态部分不被重绘,游戏会更有效率。例如,如果在数独游戏中玩家什么也没做,就没有必要重画任何东西。如果您使用动画效果,如闪光,只需重画显示该效果的屏幕部分。另一种提高游戏效率的方法是制作一个高分辨率版本和一个低分辨率版本。根据设备的功能,您可以自动选择应该使用哪个版本的游戏。

代码效率很重要,但不应该以代码清晰为代价。在许多情况下,效率没有编写清晰的代码重要。如果由于代码效率低下,按钮点击被延迟了百分之一秒,玩家不会注意到。另一方面,如果您决定将所有输入处理代码放在一个方法中以避免方法调用开销,您的代码将很难被其他人理解,包括未来的您。

马克:“在许多情况下,效率不是问题,但打嗝是问题。打嗝的一个重要原因是纹理交换。对于视频内存有限的设备,当游戏运行时,精灵将被交换进出内存,导致额外的计算。我们所做的就是将游戏中相同地方使用的精灵分组到一个精灵表中。例如,用于标题屏幕的子画面与用于级别选择屏幕的子画面放置在不同的子画面上。打嗝的另一个原因是垃圾收集(销毁不再使用的对象,释放内存)。不幸的是,没有办法控制垃圾收集何时发生。任何对象都属于垃圾收集的范畴。当你的游戏使用许多小物体如向量时,这就成了一个问题。在这种情况下,尽量减少创建新对象的数量,或者将 x 和 y 值传递给方法,而不是使用 vector 对象。

代码一致性

当你写代码时,另一件非常重要的事情是确保你的代码是连贯的。一致性可以在几个层面上实现。首先,一致性在代码的设计中很重要。例如,在本书的所有游戏中,我假设游戏循环做三件事:

  • 处理玩家输入
  • 更新游戏世界
  • 绘制游戏世界

这是我做出的代码设计决定,但是其他开发人员可能会做出不同的选择。例如,一些游戏引擎不区分处理玩家输入和更新游戏世界。其他游戏引擎将绘制视为高度独立的过程,不属于游戏对象类。重要的是,这样的设计决策在整个游戏中得到连贯的应用。如果处理输入和更新游戏世界应该是两个独立的过程,这应该在所有的类中都很明显。在代码设计中可以看到一致性的另一个例子是您处理只需要一个实例的对象的方式,例如游戏状态管理器或负责在画布上绘图的对象(Canvas2D)。对于所有这些对象,本书的例子虔诚地使用了 Singleton 设计模式。当你开始为自己的游戏编程时,明确地思考你所做的设计选择,并在编程时连贯地应用它们。

一致性在代码的结构层次上也很重要。每个游戏对象类对于每个游戏循环元素都有一个单独的方法。这些方法在每个类中都有完全相同的头,因此它们需要相同的参数。例如,在所有的游戏对象类中,update方法只有一个参数delta。如果你在类的结构上是一致的,那些类的用户就知道会发生什么。另一个例子是将通用类如GameObjectList(可用于许多不同的游戏)与游戏专用类如WaterDrop分开,将通用类放在一个名为powerupjs的名称空间中。同样,这有助于其他开发人员理解如何使用这些类以及它们属于哪里。

最后,代码应该在词法层次上是一致的。确保所有的方法都有相似的命名约定。一些开发人员喜欢方法和属性名称总是以大写字符开头。本书遵循的惯例是,变量、方法和属性名称以小写字符开头,而类名以大写字符开头。此外,任何不应该在类外直接访问的变量前面都有一个下划线字符。在您的代码中有这样的约定是一件好事。这使得你的代码更容易理解。本书遵循的另一个惯例是,在由多个单词组成的名称中,后面的每个单词都以大写字符开头:

function GameObjectList() {
    ...
}
GameObjectList.prototype.handleInput = function() {
    ...
};
var thisIsAVeryLongVariableName;

这种命名变量的方式在编程中很常见。有些人试图定义命名方案的标准,比如匈牙利符号。在匈牙利符号中,变量名也包含关于它们类型的信息。看看下面的例子:

var bIsAlive = true;

b字符告诉你这个变量是一个布尔变量。这可能是在变量名中编码的有用信息,因为 JavaScript 不要求程序员在声明变量时提供变量的类型。您可能会在其他开发人员编写的代码中遇到匈牙利符号,尽管现在它的使用越来越少,因为编译器和开发环境可以自动提供关于变量的各种信息,例如变量的范围、它所代表的类型等等。

Mark:“在 JavaScript 中,你可以用一百种不同的方式编写代码。所以,在你开始开发之前,想想你最终真正需要的是什么。如果你从一个错误的方法开始,你会因为那个选择而遇到很多问题。然而,做出正确的选择并不总是容易的。很多情况下,当你开始开发的时候,游戏的设计还没有完成。有时候你最终意识到,你需要游戏中的某种视觉效果,但代码中没有地方放它。”

制作游戏内容

如果你想让你的游戏好看,你需要好的游戏资源。表现出一致性的好游戏素材会让你的游戏对玩家更有吸引力。这不仅包括视觉效果,还包括音效和背景音乐。一般来说,声音和音乐被低估了,但它们是营造氛围的重要因素。看一部没有声音的电影比当你听到在情感上支持正在发生的事情的音乐和给角色正在做的事情赋予形体的声音效果时看电影要有趣得多。游戏也需要音乐和音效,就像电影一样。

首先,你可以购买预制的精灵包。这里有几个网站的例子,在那里你可以免费得到精灵,购买精灵,或者雇佣艺术家为你创造精灵:

  • www.supergameasset.com
  • www.graphic-buffet.com
  • www.hireanillustrator.com
  • http://opengameart.org
  • www.3dfoin.com
  • www.content-pack.com

就像精灵一样,你也可以为你的游戏购买音乐和音效。看看这些网站:

  • www.soundrangers.com
  • www.indiegamemusic.com
  • www.stereobot.com
  • http://audiojungle.net
  • www.arteriamusic.com
  • https://soundcloud.com

如果你已经用这些股票素材创建了一些游戏,你就可以更容易地与其他独立开发者建立联系。你开发的游戏将形成一个作品集,展示你作为游戏开发者的能力。

在游戏制作团队工作

当你和其他人一起玩游戏时,你需要有不同技能的人。一般一个游戏制作团队都有一个领导整体制作流程的项目经理,游戏设计师,关卡设计师,美工,音效师,测试员,当然还有程序员。有时一个人扮演多个角色。比如,项目经理也可以是首席游戏设计师。测试人员和程序员通常是同一批人。

彼得:“我们有许多艺术家、设计师、程序员、专家和问答测试员。有很多合作。现在很多人都参与了游戏制作。

当一家游戏公司发展壮大时,一个挑战就是寻找人才。此外,忠于你的创业根基也很重要,这样你才不会变成一个又大又慢的公司。试着在你的公司里保持敏捷,并把公司的等级制度保持在最低限度。就像他们说的,文化早餐吃策略。保持创业文化的活力,确保你能把事情做好。这适用于许多成长中的公司。挑战在于保持心态,让事情保持运转和敏捷。"

Mark:“因为我们是一个相当小的团队,项目经理也是我们的游戏设计师。有时这会导致问题。项目经理希望游戏按时完成,而设计师希望不断改进或改变游戏。在这种情况下,项目经理应该告诉设计师应该做这个游戏,如果两者是同一个人,这是很难做到的。”

你可能已经掌握了制作一个游戏所需的各种元素,但并不认为你可以自己做所有的事情。如果你是一名优秀的程序员,并不意味着你也是一名优秀的艺术家。而且,对你来说不幸的是,最初往往是视觉效果决定了一个人是否会尝试你的游戏。和一个艺术家组成一个团队是个好主意,也许还可以和一个游戏设计师和一个音频专家组成一个团队。试着和其他人联系,这样你们可以一起创作游戏。活跃在社交网络中,开自己的博客,在论坛上发帖,等等。你将成为一名独立开发者,所以去看看像www.indiegames.com这样的网站,了解其他独立开发者在做什么。活跃在像全球游戏堵塞(www.globalgamejam.org)这样的游戏堵塞中,结识其他开发者。看看 HTML5 开发者论坛,比如 HTML5 游戏开发者(www.html5gamedevs.com)或者 Web 开发者论坛(www.webdeveloper.com/forum/forum.php)。

如果你在一个游戏上和多个开发者一起工作,你需要找到一种方法来共享代码并一起工作。你可以使用版本管理工具,比如 Subversion ( http://subversion.apache.org)来实现。Subversion 的替代品有 Git ( http://git-scm.com)和 Mercurial ( http://mercurial.selenic.com)。网上也有类似的工具,结合版本管理工具提供云存储。这允许您处理代码并将其提交给服务器,之后其他开发人员可以检索代码并使用它。这种在线代码和版本管理工具的例子有 GitHub ( https://github.com)和 Bitbucket ( https://bitbucket.org)。

与其他开发人员合作时,你需要考虑的另一件事是记录你的代码。如果您在源文件中以某种格式编写文档,有工具可以自动读取该文档并创建 HTML 文件,以漂亮的布局显示它。这类工具的例子有 YUIDoc ( http://yui.github.io/yuidoc)和 Doxygen ( www.stack.nl/~dimitri/doxygen)。文档是非常有用的,但是并不是所有的游戏开发者都花时间正确地记录他们正在编写的代码。

Mark:“我们用合流( www.atlassian.com/software/confluence )来管理一个游戏制作项目。在这个工具中,我们存储了很多关于我们的框架以及如何使用它的信息。我们已经为我们内部的游戏引擎编写了适当的文档,但并不是真正为游戏本身编写的。我们的游戏一般都是单人开发,然后完成。我们确实为每个游戏编写了一份文档,全面描述了游戏代码的结构。”

最后,想想你如何组织你的团队。许多人忘记了这一点,只是开始一起工作,但这可能会很快导致问题,因为团队中人们的角色和期望是不同的。如果有人认为他们是主要的游戏设计者,而团队中的另一个人认为这个人对事业并不重要,这将对团队的合作产生影响。当你在做一个项目时,想想你为什么要这么做,并确保和你一起工作的其他人也同意你的观点。你团队中的一个成员可能想做一个有创意的声明,但另一个成员可能只是想赚很多钱。

彼得:“扁平的组织通常比深层次的结构运作得更好。非常重要的一点是,你要创造一个环境,让人们知道他们应该做什么,这样就有了明确的方向和领导。给人们提供有目的的工作是很重要的,不管你是在游戏中编码、做艺术品还是从事营销工作。”

马克:“如果你想创建一家游戏公司,在选择你想合作的人时要非常谨慎。如果你一开始就选错了人,你会因为不得不在过程中改变团队而浪费时间。在开始阶段花很多时间来找到合适的人。”

三十一、发布游戏

彼得·维斯特巴卡:“如今,地球上几乎每个人都有一部智能手机,这使它成为有史以来最大的游戏平台。而我们才刚刚开始;它仍在大规模增长。移动是体量所在,重心所在。”

这本书的最后一章讲述了几个与让你的游戏走向世界相关的话题。首先,我介绍了测试和检查代码质量,以确保你的游戏能在许多不同的平台上运行良好。然后我说几个你想发布游戏需要考虑的事情,比如本地化和代码优化。本章最后讨论了游戏的销售和营销。如前一章所述,彼得·维斯特巴卡和马克·奥维马斯提供了许多建议和想法。

测试和检查代码质量

任何你想公开发布的软件都应该在发布前经过测试。特别是在 JavaScript/HTML5 游戏的情况下,玩家将拥有各种不同的设备,这些设备运行不同的操作系统,使用不同的浏览器和浏览器版本。你必须确保你的游戏能为尽可能多的玩家正常运行。

测试软件有许多不同的方法。您可以通过简单地检查代码、检查类和方法的结构来测试代码,而无需实际运行程序。您还可以通过运行程序并尝试各种不同的场景和参数值来测试程序,以确保代码完成预期的工作。确保代码不会做不希望它做的事情也很重要。如果玩家提供了无效的输入或者网络连接中断,游戏应该不会崩溃。

您可以手动进行软件测试,但也可以通过编写特殊的测试脚本来尝试各种不同的参数,从而自动进行测试。这个自动过程也被称为单元测试,因为你单独测试部分(单元)代码。

当你差不多写完代码的时候,测试还有另外两个主要阶段: alpha 测试和 beta 测试。Alpha 测试是由一组人在内部完成的,通常是开发人员自己。Alpha 测试很重要,因为它确保了开发人员构建的所有组件都能正常工作。在内部 alpha 测试之后,软件也可以进行外部测试。这被称为 beta 测试:软件由一组外部用户使用。通常,beta 测试不仅有助于消除 bug,还能发现一个程序是否能在各种设备上运行。在游戏的情况下,beta 测试也有助于验证游戏是否如预期的那样,教程等级是否清晰,以及等级进展是否顺利。在游戏行业,这一步被称为游戏测试。内部和外部都可以做 playtesting。无论如何,不要推迟游戏测试,直到你的游戏接近完成。有时候游戏测试的结果可能意味着游戏运行方式的重大改变。在这个过程中,你知道得越早越好。

马克·奥维马斯:“将不同的测试阶段分开很重要。开发人员试图在编写代码的同时测试他们的代码,但是他们不可能对所有设备和所有平台都这样做。我们的游戏设计师很快就会得到游戏的早期版本,然后就可以开始进行关卡设计和游戏性测试。这些测试也由公司中的其他人完成(但通常不是开发人员)。一旦我们认为游戏完成了,我们就把游戏放到网上,但是对公众是隐藏的。然后,我们在所有可能的设备和平台上测试游戏。我们首先测试游戏本身的所有方面是否都正常工作,这是我们不必在所有设备上检查的事情。第二,我们让许多不同的人在不同的设备和平台上试用游戏,我们让他们通过一个基本的检查清单来验证音频工作正常,屏幕工作正常,字体可见,等等。如果我们在那个时候发现了游戏中的主要问题,我们就让它下线,然后从头再来一遍整个过程。

如果一切顺利,游戏会被送到一个更注重游戏性的外部测试小组。我们希望在未来进一步扩展和改进这一流程。

过去,我们还雇佣了外部人员在各种不同的设备和平台上进行广泛的测试。这种情况下的主要目标是广泛测试我们的引擎。很可能我们很快会再做一次,因为从中得出了很多有用的东西。另一方面,这也让我们意识到我们的引擎实际上工作得很好,这是一件很好的事情。我们公司确实有很多不同的设备和浏览器,但不可能什么都有。令人沮丧的是,例如,如果你看看 Android,你的游戏可能在 Android 4.0 和 4.2 上运行,但在 Android 4.1 上则不行。不幸的是,一切运转得有多好是没有顺序的。"

与其他编程语言相比,在编写 JavaScript 代码时,代码写得好是非常重要的。调试和测试 JavaScript 代码比常规应用更难,因为涉及到太多的变量。因此,在让代码在各种设备上运行之前,确保代码运行良好是至关重要的。因为 JavaScript 允许松散的键入,所以许多代码编辑环境很难非常有效地自动完成代码,这是一个遗憾,因为代码编辑器中的代码完成功能可以为您节省大量时间——与其说是键入时间,不如说是您不必花费时间浏览在线帮助来查找您想要使用的方法是如何拼写的以及它需要什么参数。

JSLint ( www.jslint.com)是一个帮助你写出更好代码的工具。JSLint 是所谓的代码质量检查器。它会检查您的代码中通常被认为是不良编码实践的东西。例如,JavaScript 允许在声明变量之前使用它们(尽管在严格模式下这是不允许的)。JSLint 检查您的代码不包含任何未声明变量的使用。代码质量检查有用的另一个例子是 JSLint 报告以下类型的if指令:

if (a = 3) {
    // do something
}

if指令的条件是赋值而不是比较。这是语法上有效的 JavaScript 代码,但程序员可能是这个意思:

if (a === 3) {
    // do something
}

代码质量检查器有助于发现这类编程错误。此外,如果程序员实际上打算在if的条件中进行赋值,JSLint 报告它仍然是一件好事,因为在条件中给变量赋值是一种非常糟糕的编程实践!

部署

当你想发行你的游戏时,你需要考虑一些事情。首先你需要确保全世界的玩家都能理解你的游戏。虽然许多人可以阅读和书写英语,但如果你的游戏能够适应玩家的地区,那就很好了:例如,将游戏中使用的所有文本翻译成玩家的语言。这个过程叫做本地化。如果你想正确地做到这一点,你需要尽可能地将文本与实际的游戏代码分开。这包括按钮上显示的文本和当播放器悬停在用户界面元素上时显示的帮助文本。如果你想让你的游戏被翻译成世界上任何一种语言,本地化的成本会很高;但是,您可以通过确保游戏代码不包含任何文本元素,并且只使用引用在单个位置定义的文本的变量来降低成本。

如果你在游戏中使用语音,那么本地化可能会变得非常昂贵。不幸的是,自动文本到语音转换系统在现实中还不太现实,所以你需要让配音演员录制你想发布游戏的任何语言的语音音频。

你可以设计你的游戏来最小化本地化成本,例如通过主要依靠视觉来与玩家交流。“警告:你只剩下十秒钟了!”需要翻译,但闪烁的计时器不需要。大多数游戏都试图将显示给用户的文本最小化,但是教程关卡经常包含文本。当然,如果你有一个单词搜索游戏或任何其他游戏,其中的游戏性严重依赖于文本操作,你肯定需要仔细考虑本地化。

Mark:“我们有自己的本地化工具。每个游戏都有自己的字典文件,用唯一的 ID 存储游戏中所有语言的文本。我们制作了一个特殊的工具来编辑这些文本。很多文本不是游戏特有的,它们是我们框架的一部分,所以我们把它们和游戏特有的文本一起导出。我们的工具还可以将这些数据生成为电子表格,这样我们就可以将这些数据发送给翻译人员,他们只需填写一列即可。对于本地化,重要的是您的所有代码都使用 Unicode,因为您希望能够使用亚洲字体。”

彼得:“我们用《愤怒的小鸟》创造了一个在任何地方都能玩的游戏,因为它大部分是视觉化的。我们将游戏中的小文本本地化。虽然你可以为非常本地化的市场制作游戏,但我们设计游戏时使用的文字非常少,因此几乎不需要做任何本地化。”

除了本地化之外,你还需要确保在发布游戏时代码尽可能的紧凑。本书中的所有示例代码都分布在几个文件中。当你发布你的游戏时,理想的情况是你希望所有的代码都在一个 JavaScript 文件中。你也可能不希望玩家能够容易地理解你的代码。例如,如果你已经编写了一个创新的算法来非常有效地处理游戏中的物理问题,你可能希望避免其他人从你的游戏中复制代码,这样你就可以保持竞争优势。帮助你做所有这些事情的一个非常有用的工具是 Google Closure ( https://developers.google.com/closure)。Closure 允许你将你的 JavaScript 文件编译成一个优化大小的文件,这样玩家可以快速下载。您可以选择闭包编译器用来生成优化的 JavaScript 文件的优化级别。闭包可能的最高优化级别被称为高级优化级别。如果你打开并阅读这样一个高度优化的文件,你将无法理解其中的代码。因此,这种高级优化模式也被称为代码混淆模式。模糊处理非常有用,因为它可以保护您的代码不被他人复制。

Mark:“我们使用 Google 的 Closure 编译器进行缩小和混淆,它可以在三种不同的模式下运行。前两种模式基本上只是缩小版。第三种模式是一种重新编译,其中代码被重新安排,不使用的部分被删除,等等。我们的条件是,我们发布的任何游戏代码都需要能够经历那个过程,并且在之后仍然能够工作。”

当您从外部库调用代码时,代码混淆可能会带来问题。在这种情况下,模糊处理不应该改变你从那个库中调用的函数或变量的名字,因为那样名字就不再匹配了。您必须要么向混淆器提供指令,这样它就不会在这些情况下进行重命名操作,要么避免使用外部库。

出售你的游戏

如果你想以创作 HTML5 游戏为生,有相当多不同的可能性。用你的游戏赚钱的一个非常简单的方法是把它们放在你的网站上并添加广告横幅——例如,使用 Google AdWords。如果你有很多访问者,你将从广告中获得收入。但是,这个广告收入会很低,除非你吸引了很多玩家。也许更有利可图的方法是将你的游戏卖给门户网站。大多数门户网站免费提供他们的游戏,但他们也从广告中赚钱。也有一些经纪人网站,你可以付费购买你的游戏,比如游戏经纪(https://gamebrokerage.com)。

马克:“向门户网站出售游戏可以在收入共享的基础上进行,你可以分享广告收入。收入分成要么由门户支付给开发者,要么由开发者支付给门户。第一种情况,门户在你的游戏中投放广告,付给你一部分收入;第二种情况,开发者在游戏中投放广告,按收入的一定比例支付给门户网站。最后,你也可以通过固定费用的模式来销售你的游戏,在这种模式下,你向一个门户网站交付一个游戏就可以获得一次报酬。因为 HTML5 游戏市场相对年轻,门户网站仍然愿意与创作和销售游戏的个人打交道。但越来越多的 HTML5 游戏公司正在起步,因此这种情况可能不会持续很长时间。”

另一种尝试销售游戏的方式是通过 Android 或 iOS 上的应用商店。你不能直接把一个 HTML5 游戏发布到那些商店,需要先转换成原生格式。有一些包装器工具可以帮你做到这一点,比如 CocoonJS ( https://www.ludei.com/cocoonjs)和 PhoneGap ( http://phonegap.com /)。将你的游戏发布为原生应用的好处是,除了在应用商店销售游戏赚的钱之外,你还可以引入应用内购买等为你提供额外收入的东西。但是,请注意,使用 PhoneGap 之类的包装工具可能会导致性能滞后,需要进行彻底的测试。包装器生成的应用可能看起来像本机应用,但它实际上是运行在查看器中的 web 应用。

一旦你的游戏上市,你就可以开始考虑用它们赚钱的其他方法了。例如,如果你的游戏允许玩家互相对战,你可以引入一种订阅服务,让玩家在支付月费后可以访问你的游戏。玩家可以在你的服务器上存储他们的个人资料和他们在游戏中的成就。

试着想想用你的游戏赚钱的其他方法。有时,让已经拥有庞大关系网的另一方参与进来会有所帮助。为你选择的慈善机构创建一个游戏,并与它分享收益。你将帮助一个慈善机构,同时它将为你做市场营销!另一种试图用你的游戏赚钱的方法是依靠众筹。有专门为游戏众筹的网站,比如 Gambitious ( https://gambitious.com)。如果你打算在 Steam ( http://store.steampowered.com)上发布你的游戏,看看它为游戏开发者提供的出售作品的机制。例如,它有一个名为早期访问的机制,允许人们购买和玩尚未完成的游戏。这可能是一个有用的机制,你可以建立一个玩家网络,获得动力,获得反馈和错误报告,并提供定期的游戏更新。最后,看看 True Valhalla ( www.truevalhalla.com/blog)的博客,里面谈到了很多用 HTML5 游戏赚钱的不同方法。

营销

既然你正在编写自己的游戏,你可能已经开始考虑如何让它们在现实世界中运行。也许你不想仅仅为了成就而创造一个游戏,而是想用它赚点钱。幸运的是,现在发布游戏很容易。对于移动设备,在将游戏导出到特定的移动平台后,您可以将游戏提交到应用商店。如果你通过一个门户网站销售你的游戏,那么这个门户网站将(部分)为你负责营销。当然,你也可以让你的游戏出现在你自己的网站上。

彼得:“如果你看看我们在《愤怒的小鸟》和《??》之前发布的 51 款游戏,它们实际上都是非常好的游戏。主要的挑战是市场非常艰难。App Store 并不存在——你必须有良好的关系才能把你的游戏推出去。让我们成功的是应用商店。突然之间,任何人都可以使用数字发行,所以你可以创建一个像愤怒的小鸟这样的游戏,并立即分发给粉丝。

但是现在游戏的发行变得如此容易,这又带来了另一个挑战。任何人都可以通过 App Store 发布游戏。因此,有大量的游戏和应用。当然,你需要做出伟大的游戏。但是已经有很多很棒的游戏了。事情是这样的,每有一只愤怒的小鸟,就有许多“不那么”的愤怒的小鸟。“那些游戏中有许多实际上是很棒的游戏,但是没有人知道它们。那么如何让你的游戏脱颖而出呢?以愤怒的小鸟为例,都是关于人物的。而另一方面,这也是一个品牌提出了一个有趣的问题:为什么鸟儿会生气?如果你认真做游戏,你就必须认真做营销和品牌。仅仅制作优秀的游戏是不够的。”

显然,挑战在于让你的游戏可见。在 iOS 和 Android 上,每天都有超过 300 款新游戏出现。大部分都是少数人玩的。如果你为这个游戏创建自己的网站,你将如何吸引访问者?

首先,你需要制作一款优质的游戏。如果游戏不好,人们不会玩它。找其他有其他技能的人来帮助你。不要过于雄心勃勃:你不会创造下一个光环!设定合理的目标。从小而精的游戏开始。不要相信自己的判断:和别人谈论你的游戏,让他们玩原型,以确保玩家确实喜欢它。当你的游戏接近完成时,制定一个营销计划。你可以在任何地方发布关于游戏的消息,制作新闻包,制作视频,向博客和其他网站发送信息,等等。人们只会在听说你的游戏后才会玩。不要期望在你将游戏发布到应用商店后,这种情况会自动发生;你需要制定一个计划。在你的游戏发布之前,建立一个潜在玩家的网络——对你的工作感兴趣的人。为您的公司和/或您正在创建的游戏创建一个脸书群组。一定要和 Twitter 等社交网络上的关注者交流。鼓励其他人玩你的游戏,并写下来。

我在上一节提到了 Steam 上的早期访问机制。这样一个开放的开发机制,从营销的角度来看也是很有趣的。它允许你吸引玩家到你的游戏中,并让他们参与到游戏的开发中来。通过让玩家参与游戏开发的早期阶段,你可以在玩家和游戏之间建立一种非常牢固的纽带,因为他们感觉自己是开发过程的一部分。如果你聪明的话,这些玩家会成为你游戏的销售人员,为你做大量的营销工作。

彼得:“对我们来说,这一切都是为了我们的粉丝和品牌。品牌的力量愤怒的小鸟让我们在广告等传统营销手段上花更少的钱。如果你看看典型的游戏开发工作室,我认为人们没有意识到他们需要在营销上花多少钱。通常,游戏开发成本只是营销费用的一小部分。

我们用我们的游戏和我们在营销方面的行动来建立品牌。我们从非常强势的角色开始,并以此为中心打造品牌。要建立一个品牌,有多少家公司就有多少种不同的方式。如果你看看传统上品牌是如何建立的,你会发现这也开始在游戏行业发生,比如通过电视广告。就游戏而言,这与在任何行业建立品牌没有太大区别。"

最后的想法

这本书涵盖了 JavaScript 编程的许多方面。你现在已经在 JavaScript 编程,尤其是游戏编程方面打下了坚实的基础。通过阅读马克和彼得的思想,你可能已经意识到,世界变化很快。iOS 和 Android 等操作系统会定期更新新功能。移动设备的速度越来越快。几年前流行的设备现在已经过时了。在这中间是 HTML5 和 JavaScript。JavaScript 日益重要的一个显著例子是桌面/PC 游戏行星毁灭 ( www.uberent.com/pa),它的整个 GUI 都是用 JavaScript 和 HTML5 创建的!预测未来是不可能的,但有一点是肯定的:HTML5 和 JavaScript 将会继续存在。我希望这本书能帮助你掌握这门语言,并为你自己探索游戏编程提供一个良好的起点。我将用马克和彼得在采访中说的两件事来结束这本书:

马克:“想想你想要实现什么。例如,你可能只是有一个创造性的想法,你想实施。这和想靠制作游戏谋生是非常不同的目标。很多人玩你的游戏并不总是重要的。制作游戏本身就是一种奖励。”

彼得:“不要盲目复制,而是尝试做不同的事情,而不是做其他人都在做的事情。想想怎么才能从其他几十万游戏中脱颖而出。惊喜和喜悦。给人惊喜不用花什么钱。也就是说,尽可能多地从别人那里学习。然后做好自己的事。”

第一部分:入门指南

本书的第一部分涵盖了用 JavaScript 开发游戏应用的基础知识。您会看到许多结合 HTML 和 JavaScript 的简单例子。我将向您介绍 HTML5 标准以及随之而来的新的 HTML 元素,特别是 canvas。这一部分涵盖了核心 JavaScript 编程结构,如指令、表达式、对象和函数。此外,我还介绍了游戏循环以及如何加载和绘制精灵(图像)。

第二部分:创造丰富多彩的游戏

在这一部分中,你开发了一个名为画师的游戏(见图 II-1 )。在你开发这个游戏的同时,我也介绍了一些在游戏编程时非常有用的新技术,比如在类和方法中组织指令、条件指令、迭代等等。

9781430265382_Part02-01.jpg

图二-1 。创造丰富多彩的游戏

画家游戏的目标是收集三种不同颜色的颜料:红色、绿色和蓝色。颜料从空中落在由气球保持漂浮的罐子里,在颜料从屏幕底部落下之前,你必须确保每个罐子都有正确的颜色。您可以通过向下落的罐子发射所需颜色的颜料球来改变颜料的颜色。您可以使用键盘上的 R、G 和 B 键选择拍摄的颜色。你可以在游戏画面中左键点击射出一个彩球。通过点击远离油漆大炮,你给球一个更高的速度。你点击的地方也决定了大炮射击的方向。每有一个罐子落在正确的箱子里,你就得到 10 分。对于每个颜色错误的罐子,你失去一条生命(由屏幕左上角的黄色气球指示)。你可以通过下载属于第十二章的示例代码 zip 文件来运行这个游戏的最终版本。双击PainterFinal文件夹中的Painter.html文件开始玩游戏。

第三部分:宝石果酱

宝石果酱是一款益智游戏,在其中你试图找到宝石的组合(见图 III-1 )。不过要小心:宝石车正在慢慢移动。一旦购物车离开屏幕,你的时间就到了!棋盘由十行五列组成。游戏场上的宝石根据三种属性而不同:颜色(红色、蓝色或黄色)、形状(菱形、球形或椭圆形)和数量(一颗、两颗或三颗宝石)。

9781430265382_Part03-01.jpg

图三-1 。宝石果酱游戏

玩家可以使用鼠标或触摸屏(如果在手机或平板电脑上玩游戏)向左或向右移动行。目标是找到中间一列中三个相邻宝石的匹配组合。如果所有对象的每个属性都相同或不同,则三个宝石的组合是有效的。例如,黄色单菱形对象、蓝色单菱形对象和红色单菱形对象形成有效的组合,因为每个对象的颜色不同,而所有对象的形状和数量都相同。黄色的球体对象、黄色的双菱形对象和黄色的三椭圆对象也形成了一个有效的组合,因为这三个对象具有相同的颜色、不同的形状和不同的数字。黄色菱形、红色双球体和蓝色双椭圆的组合是无效的,因为尽管每个对象的颜色和形状都不同,但菱形对象的数量与其他两个不同。另一方面,黄色菱形、红色双球体和蓝色三椭圆的组合是有效的。

一旦玩家通过移动行找到了一个有效的组合,他们按下空格键,组成该组合的宝石就会消失,剩下的宝石会落下来填充空槽,三个新的宝石会从屏幕顶部落下。当玩家按下空格键时,如果中间栏中同时出现两个或三个组合,则会获得额外的分数,并且屏幕上会显示一个覆盖图,指示出现了两个或三个组合。

在接下来的章节中,你开发这个游戏。如果你想玩完整版来感受一下游戏是如何运作的,运行属于第十七章的例子!

第四部分:企鹅配对

在书的这一部分,你开发游戏企鹅配对(见图 IV-1 )。我介绍了一些游戏编程的新技术,比如精灵表、更好的游戏状态管理、在会话之间存储游戏数据等等。

9781430265382_Part04-01.jpg

图四-1 。企鹅配对游戏

企鹅配对是一个益智游戏,目标是让成对的企鹅颜色相同。玩家可以通过点击或轻拍企鹅来移动它们,并选择企鹅应该移动的方向。企鹅移动,直到它被游戏中的另一个角色(可以是企鹅、海豹、鲨鱼或冰山)阻止,或者它从游戏场地掉落,在这种情况下,它会掉进水里,被饥饿的鲨鱼吃掉。在游戏的不同关卡中,你引入新的游戏元素来保持游戏的刺激。例如,有一种特殊的企鹅可以与任何其他企鹅匹配,企鹅可以卡在一个洞里(意味着它们不能移动),吃企鹅的鲨鱼可以放在板上。你可以通过尝试属于第二十三章的示例程序来运行这个游戏的最终版本。在浏览器中打开PenguinPairs.html文件,即可立即开始播放。

第五部分:滴答滴答

前几章已经向你展示了如何构建几种不同类型的游戏。在这一部分,你用动画角色,物理,和不同的水平建立一个平台游戏。游戏的名字叫滴答滴答(见图 V-1 ),故事围绕一颗稍微有点压力的炸弹展开,这颗炸弹将在 30 秒内爆炸。这意味着游戏中的每一关都应该在 30 秒内完成。如果玩家收集到所有提神的水滴并及时到达终点面板,则一个关卡完成。

9781430265382_Part05-01.jpg

图 V-1 。滴答滴答的游戏

这款平台游戏包含许多其他游戏中常见的基本元素:

  • 应该可以玩不同的关卡。
  • 这些关卡应该从一个单独的文件中加载,这样就可以在不知道游戏代码如何工作的情况下改变它们。
  • 游戏应该支持玩家和敌人的动画角色。
  • 玩家应该控制可以跑或跳的玩家角色的动作。
  • 游戏中应该有一些基本的物理学来管理坠落,与物体碰撞,在平台上跳跃等等。

这是一个很长的列表!幸运的是,您可以重用许多已经开发的类。接下来的章节将介绍清单上的所有项目。如果你想玩完整版的滴答滴答游戏,获取第二十九章的样本代码,并打开TickTickFinal文件夹中的TickTick.html文件。

第六部分:进入广阔的天地

现在你已经知道如何用 JavaScript 编写游戏了。但是接下来呢?你如何创造一个成熟的游戏,并为市场做好准备?为了发布你的游戏,你需要做些什么?你应该如何营销它?本书的最后一部分涵盖了这些主题。这一部分由两章组成。第一个涉及游戏制作,包括游戏设计、游戏开发和游戏制作的运营方面。第二章涉及游戏出版,包括从游戏中赚钱的模式,营销你的游戏,并使你的游戏可以在不同的语言和文化背景下玩。

因为听取游戏行业人士的建议非常有用,所以我采访了两位重要的人物。首先,我采访了马克·奥维马斯:他开发了 GameMaker 应用,这是一个快速创建游戏的伟大工具。GameMaker 已经发展成为一个成熟的应用,现在由 Mark 部分拥有的 Yoyo games 公司维护。他也是 Tingly games 的联合创始人兼首席技术官,该公司使用内部构建的游戏引擎开发所谓的 JavaScript 问候游戏。

第二,我采访了 Rovio Entertainment 公司的彼得·维斯特巴卡(Peter Vesterbacka ),该公司以其世界闻名的《愤怒的小鸟》( Angry Birds)系列而闻名。彼得与公司的创建有很大关系。2003 年,他在惠普工作期间组织了一次游戏制作比赛,目标是创作出最好的多人手机游戏。这是在 Android 和 iOS 出现之前的事情了。诺基亚刚刚推出了其首款智能手机。在芬兰阿尔托大学学习的三个人——尼克拉斯、亚诺和金——参加了一个名为“卷心菜世界之王”的游戏,并赢得了比赛。彼得建议他们开一家公司,他们照做了。51 个游戏之后,在 2009 年,他们创造了第 52 个游戏,叫做愤怒的小鸟。彼得是公司所谓的雄鹰。他把自己的主要角色描述为“确保公司大事发生得足够快。”Peter 参与了公司的许多不同方面,包括营销和品牌,他希望帮助公司朝着新的创新方向发展。

接下来的两章主要基于我对马克和彼得的采访。他们两个都是真正鼓舞人心的人。在整篇文章中,你会发现他们对游戏制作和出版的看法,他们分享了许多有用的技巧和诀窍。

posted @ 2024-08-19 17:19  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报