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 有一个叫做开发者工具的东西,可以通过进入工具开发者工具进入菜单。在那里,您可以看到一个控制台(用于调试),在脚本中设置断点,等等。当你想用 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 说明了这些方法:外部方法包含内部方法。当人们第一次学习编程时,他们通常会立即开始编写代码,这导致了一个编写、测试、修改的紧密循环。相比之下,专业程序员在写第一行代码之前,会花大量的前期时间做设计工作。
图 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
元素是树的根;根由head
和body
等元素组成,这些元素又由更多的分支组成。
图 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>
图 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 )。
图 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>
图 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
元素,以及如何用它来创建游戏。
图 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
动作。
图 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
函数时,设置画布背景颜色,并调用update
和draw
函数。目前,update
和draw
是空的,但是你可以开始用指令填充它们来更新和绘制一个游戏世界。注意,在循环迭代之间使用setTimeout
等待并不总是最好的解决方案。有时,这种方法可能会受到超出您控制范围的事件的负面影响,例如速度较慢的计算机、浏览器中打开的其他标签、需要处理能力的并发运行的应用等等。当你必须处理敏感的时间操作时(比如玩家需要存活五分钟),你可能不想依赖setTimeout
,而是依赖于某种系统,该系统在特定的时间点安排事件,并在update
函数中检查这些事件是否已经发生。
当您运行示例程序时,会持续执行update
和draw
函数:更新、绘制、更新、绘制、更新、绘制、更新、绘制、绘制、更新、绘制、更新、绘制、更新、绘制等等。此外,这是以非常高的速度发生的。这个特殊的例子创建了一个简单的游戏循环,以每秒 60 帧的速度运行。这种循环被称为固定时间步长循环、循环,在休闲游戏中非常流行。你也可以设计不同的程序,让游戏尽可能多的执行循环,而不是每秒 60 次。
注意当你创建依赖于(游戏)循环的程序时,你可能想要避免在实现和测试的早期阶段使用全自动循环。您可能会创建一个无限循环,这可能会意外地使开发机器陷入困境。相反,您可以将循环设置为运行有限的次数,或者您可以让循环在每次按下按钮时运行一次。大多数浏览器也支持 JavaScript 的调试。例如,在 Firebug(在 Firefox 浏览器中)中,您可以在循环中的某个点放置一个断点。这样,您就可以跟踪程序运行时发生了什么。
这本书向你展示了很多不同的方法来填充update
和draw
函数,以完成你在游戏中需要执行的任务。在这个过程中,我还介绍了许多对游戏(和其他应用)有用的编程技术。下一节将更详细地介绍基本的游戏应用。然后,你用额外的指令填充这个游戏的基本框架。
程序的结构
这一节将更详细地讨论程序的结构。在早期,许多计算机程序只将文本写到屏幕上,而不使用图形。这种基于文本的应用被称为控制台应用。除了将文本打印到屏幕上,这些应用还可以读取用户在键盘上输入的文本。因此,与用户的任何交流都是以问题/答案序列的形式进行的(Do you want to format the hard drive (Y/N)? Are you sure (Y/N)?
等等)。在基于 Windows 的操作系统流行起来之前,这种基于文本的界面在文本编辑程序、电子表格、数学应用甚至游戏中非常普遍。这些游戏被称为基于文本的冒险、,它们以文本形式描述游戏世界。然后,玩家可以输入命令与游戏世界互动,如go west
、pick up matches
或Xyzzy
。这类早期游戏的例子有 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
)。作为一名程序员,你可以为一个函数选择任何名字。你已经看到游戏循环由两部分组成:update
和draw
。在编程术语中,这些部分被建模为函数,正如您在示例程序中看到的那样。在这些函数中,您可以放置您想要执行的指令,以便更新或绘制游戏世界。函数名前面是单词function
,名字后面是一对括号。这些用于向在函数内部执行的指令提供信息。例如,看看下面的标题:
function playAudio (audioFileId)
在这个头中,函数的名字是playAudio
;在括号之间你可以看到单词audioFileId
。显然,playAudio
函数需要一个音频文件标识符,这样它就知道应该播放哪个音频文件。
语法图
如果你不知道 JavaScript 这种语言的规则,那么用这种语言编程会很困难。这本书使用所谓的语法图 来解释语言是如何构成的。编程语言的语法指的是定义什么是有效程序的正式规则(换句话说:编译器或解释器可以读取的程序)。相比之下,程序的语义指的是程序的实际含义。为了说明语法和语义之间的区别,看看短语“你所有的基础都是属于我们的”。从语法上来说,这个短语是无效的(英语口译员肯定会抱怨它)。然而,这个短语的意思是非常清楚:你显然因为一个说着糟糕英语的外星种族而失去了所有的基础。
注意短语“你所有的基地都是属于我们的”来自电子游戏《零翼》(1991,Sega Mega Drive)的开场过场动画,是对日文原版的拙劣翻译。从那以后,这个短语出现在我的文章、电视剧、电影、网站和书中(比如这篇!).
解释器可以检查程序的语法:任何违反规则的程序都会被拒绝。不幸的是,解释器不能检查程序的语义是否符合程序员的想法。所以如果一个程序在语法上是正确的,这并不能保证它在语义上是正确的。但是如果它在语法上不正确,它就根本不能运行。语法图有助于您可视化编程语言(如 JavaScript)的规则。例如,图 2-2 是一个简化的语法图,展示了如何在 JavaScript 中定义一个函数。
图 2-2 。函数表达式的语法图
您可以使用语法图构建 JavaScript 代码,方法是从图的左上角开始,在本例中是从单词函数开始,然后按照箭头指示进行操作。当你到达灰点时,你的代码就完成了。这里你可以清楚地看到一个函数定义是以function
关键字开始的;然后你写下函数的名字。之后,你写括号。在这些括号之间,您可以(可选地)编写任意数量的由逗号分隔的参数、。接下来你写一些指令,都在大括号里。之后,你就完成了,因为你已经到达了灰点。在本书中,我使用语法图来展示如何根据 JavaScript 语言的语法规则来构建代码。
调用函数
当指令canvasContext.fillRect(0, 0, canvas.width, canvas.height);
被执行时,你调用的fillRect
函数。换句话说,你希望程序执行函数fillRect
中的指令。这组指令正是你在这个例子中所需要的:即,用一种颜色填充一个矩形。但是,您需要给这个函数一些额外的信息,因为它需要知道应该填充的矩形的大小。参数提供了这些额外的信息。正如您在语法图中看到的,一个函数可以有多个参数。当一个函数被调用时,你总是在它后面写括号,括号内是参数(如果需要的话)。
为了使用fillRect
功能,你需要知道哪些指令被组合在一起吗?不,你没有!这是在函数中对指令进行分组的好处之一。您(或其他程序员)可以在不知道其工作原理的情况下使用该函数。通过智能地将指令分组到函数中,就有可能编写出可重用的程序片段,可以在许多不同的上下文中使用。fillRect
函数就是一个很好的例子。它可以用于各种应用,您不需要知道该功能如何工作才能使用它。您唯一需要知道的是,它将矩形的尺寸作为参数。
更新并绘制
BasicGame 示例中的游戏循环包含update
和draw
函数。因为一个函数基本上是一组指令,每次调用update
函数时,函数中的指令都会被执行。draw
也是如此。
例如,假设您想要一个简单的游戏,在鼠标指针的位置绘制一个气球。当你移动鼠标时,气球也跟着移动。对于update
和draw
功能,您可以如下操作。在update
函数中,您需要执行一条指令来检索鼠标指针的当前位置,并将其存储在内存中。在draw
功能中,您需要执行一个在存储位置显示一个气球图像的指令。当然,你还不知道这些说明是否存在(剧透:它们存在!),而且你还不知道说明书是什么样子的。此外,你可能想知道为什么会这样。你不是在移动气球,你只是在存储在update
函数中的位置画气球。回想一下,update
和draw
功能以非常高的速度执行(每秒 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";
中看到这样的例子。 - 在行首,因此方法和类的主体相对于包围主体的大括号缩进(通常是四个位置)。
大多数编辑程序通过自动执行缩进来帮你一点忙。此外,编辑器会自动在代码中的特定位置放置空格,以增加可读性。
你学到了什么
在本章中,您学习了:
- 游戏的骨架是什么,由游戏循环和循环所作用的游戏世界组成
- 如何构建一个游戏程序,它由几个不同的函数组成,这些函数检索画布,还有构成游戏循环的
update
和draw
函数 - JavaScript 程序的基本布局规则,包括如何在代码中放置注释,以及在何处放置额外的空白以提高代码的可读性
三、创造一个游戏世界
本章向你展示了如何通过在内存中存储信息来创建一个游戏世界。它介绍了基本类型和变量,以及如何使用它们来存储或更改信息。接下来,您将看到如何在由成员变量和方法组成的对象中存储更复杂的信息。
基本类型和变量
前几章讨论了几次内存。您已经看到了如何执行类似于canvasContext.fillStyle = "blue";
的简单指令来设置在画布上绘制形状时应该填充的颜色。在本章的例子中,你使用内存来临时存储信息,以便记住一些简单计算的结果。在这个 DiscoWorld 的例子中,您根据过去的时间改变背景颜色。
类型
类型,或数据类型,代表不同种类的结构化信息。前面的例子使用了不同种类的信息作为参数传递给函数。例如,函数fillRect
需要四个整数作为信息,BasicGame 示例中的start
函数需要一个引用画布的文本标识符,而同一示例中的update
和draw
函数根本不需要任何信息。浏览器/解释器可以区分所有这些不同类型的信息,在许多情况下,甚至可以将一种类型的信息转换成另一种类型。例如,在 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 。
图 3-1 。赋值指令的语法图
现在你已经看到了一条声明变量的指令,和另一条在变量中存储值的指令。但是,如果在声明变量时已经知道要在变量中存储哪个值,则可以将变量的声明和对变量的第一次赋值结合起来:
var red = 3;
执行该指令时,存储器将包含值 3,如图图 3-2 所示。
图 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 中描述的内容。
图 3-3 。多变量声明和赋值后的内存概述
在图 3-4 的中显示了声明变量的语法。
图 3-4 。带有可选初始化的变量声明的语法图
全局变量和严格模式
不用在使用变量之前声明它,在 JavaScript 中也可以不声明就开始使用变量。例如,考虑以下指令:
var a = 3;
var b;
b = 4;
x = a + b;
正如你所看到的,变量a
和b
是通过使用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)*3
和3+(6-5)
。在实践中,这样的表达式一般也包含变量;否则,您可以自己计算结果(9 和 4)。
不禁止使用多余的括号:例如,1+(2*3)
。如果你愿意,你可以完全疯狂地使用这个:((1)+(((2)*3)))
。然而,如果你这样做了,你的程序将更难阅读。
总之,一个表达式可以是一个常量值(比如 12),可以是一个变量,可以是圆括号中的另一个表达式,也可以是一个表达式后跟一个运算符再跟另一个表达式。图 3-5 显示了表示表达式的(部分)语法图。
图 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 显示了一个对象文字表达式的(部分)语法图。*
图 3-6 。对象文字表达式的(部分)语法图
在对gameCharacter
变量进行声明和初始化后,内存将如图 3-7 中的所示。
图 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
现在由五部分组成:name
、skill
、health
、power
和familyName
。
因为变量也可以指向函数,所以你甚至可以包含一个指向函数的子变量。例如,您可以将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 没有这种能力,您将不得不在程序开始时声明一个很长的变量列表,而不知道变量之间是如何关联的,也不知道您可以用它们做什么。通过将对象中的变量分组并提供属于这些对象的方法,您可以编写更容易理解的程序。在下一节中,您将在一个简单的示例中使用这种能力,在画布上移动一个正方形。
移动广场游戏
本节研究一个在画布上移动一个方块的简单程序。其目的是为了说明两件事:
- 游戏循环中的
update
和draw
部分如何更详细地工作 - 如何使用对象来构建程序
在开始编写这个程序之前,让我们再看一遍 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
的复合变量(对象)。这个对象有两个成员变量 : canvas
和canvasContext
。此外,你添加了一些方法到这个对象中,包括共同形成游戏循环的方法。您单独定义属于这个对象的方法(换句话说,它们不是变量声明和初始赋值的一部分)。原因是您现在可以很容易地将组成对象的数据与处理数据的方法区分开来。还要注意,正如我所承诺的,你将指令"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;
};
如你所知,update
和draw
方法被顺序调用,大约每秒 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);
};
你唯一要做的就是确保在调用update
和draw
之前调用这个方法。你在mainLoop
方法:中这样做
Game.mainLoop = function() {Game.clearCanvas();Game.update();Game.draw();window.setTimeout(Game.mainLoop, 1000 / 60);
};
现在这个例子完成了!双击属于本章的文件夹中的MovingSquare.html
文件,即可运行该程序。图 3-8 显示了它的样子。
图 3-8 。MovingSquare 示例的输出
研究矩形的位置是如何随时间变化的。试着让下面的一些事情正常工作(如果你有其他想法,也不要犹豫尝试一下):
- 使矩形从右向左移动。
- 使矩形从上到下移动。
- 使矩形在屏幕上对角移动。
- 让矩形以两倍的速度移动。
变量的范围
声明变量的地方会影响到允许使用变量的地方。看看 MovingSquare 程序中的变量d
。这个变量在update
方法中声明(并赋值)。因为它是在update
方法中声明的,所以只允许在这个方法中使用它。例如,不允许在draw
方法中再次使用这个变量。当然,你可以在draw
方法中声明另一个名为d
的变量,但是重要的是要意识到在update
中声明的d
变量在那种情况下不会是在draw
方法中声明的同一个d
变量。
或者,如果你在对象级别声明一个变量,你可以在任何地方使用它,只要你把对象的名字放在它前面。您需要在update
和draw
方法中使用矩形的 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
};
在Game
的start
方法中,你给这些变量赋值。您已经看到了如何检索画布和画布上下文。就像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
变量,浏览器就开始加载文件。浏览器会自动填充width
和height
变量的数据,因为它可以从源文件中提取这些信息。
有时,加载源文件需要一段时间。例如,文件可以存储在世界另一端的网站上。这意味着,如果您试图在设置源文件后立即绘制图像,您可能会遇到麻烦。因此,在开始游戏之前,您需要确保每个图像都已加载。有一种非常简洁的方法可以做到这一点,那就是使用一个事件处理器函数。 在第七章中,你看这是怎么回事。现在,假设加载图像的时间不会超过半秒。通过使用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 });
您使用大括号来定义一个包含x
和y
组件的对象文字。如你所见,允许在调用方法的指令中定义一个对象。或者,您可以首先定义一个对象,将其存储在一个变量中,然后使用该变量调用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 显示了程序在浏览器中的输出。
图 4-1 。SpriteDrawing 程序的输出
同样,请注意,如果您告诉浏览器在给定的位置绘制一个 sprite,sprite 的左上角部分将被绘制在那里。
移动精灵
现在你可以在屏幕上画一个精灵了,你可以使用游戏循环让它移动,就像你在第三章的 MovingSquare 例子中对正方形所做的那样。让我们对这个程序做一个小小的扩展,根据经过的时间改变气球的位置。为了做到这一点,你必须把气球的位置存储在某个地方。您需要在update
方法中计算这个位置,并在draw
方法中在那个位置绘制气球。因此,您向表示位置的Game
对象添加一个变量,如下所示:
var Game = {canvas : undefined,canvasContext : undefined,balloonSprite : undefined,balloonPosition : { x : 0, y : 50 }
};
正如您所看到的,您将位置定义为由Game
对象中的两个变量(x
和y
)组成的对象。现在,您可以向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 显示了程序的输出。
图 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");
除了getElementById
,document
对象还有很多其他的方法和成员变量。例如,有一个名为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
对象的pageX
和pageY
成员变量包含鼠标相对于页面的位置,意味着页面的左上角有坐标(0,0)。你可以在图 5-1 中看到一些鼠标位置的例子:在浏览器中运行程序时,其中三个角标有它们各自的位置。
图 5-1 。左上角、右上角和右下角的鼠标位置
因为Draw
方法只是在鼠标位置绘制气球,所以气球现在跟随鼠标。图 5-2 显示了它的样子。您可以看到鼠标指针下方绘制的气球;当你移动指针时,它会跟踪它。
图 5-2 。气球 1 项目截图
你可以在图 5-2 中看到,气球并没有出现在指针的正下方。这是有原因的,下一节将详细讨论这个问题。现在,只要记住精灵被视为一个矩形。左上角与指针尖端对齐。气球看起来没有对齐,因为气球是圆形的,没有延伸到矩形的角上。
除了pageX
和pageY
,你还可以使用clientX
和clientY
,它们也给出了鼠标的位置。然而,clientX
和clientY
并没有把滚动考虑进去。假设你计算鼠标位置如下:
Game.balloonPosition = { x : evt.clientX, y : evt.clientY };
图 5-3 显示了现在可能出现的问题。由于滚动,clientY
值小于 480,即使鼠标位于背景图像的底部。因此,不再在鼠标位置绘制气球。所以我建议一直用pageX
和pageY
,不要用clientX
和clientY
。然而,在某些情况下,不考虑滚动可能是有用的——例如,如果您正在开发一个令人讨厌的广告,即使用户试图滚动它,它也会一直出现在浏览器视图的中间。
图 5-3 。鼠标指针在背景精灵的底部,但是鼠标的 y 位置是 340(而不是 480 ),因为clientY
没有考虑滚动
更改精灵的来源
当您运行 Balloon1 示例时,请注意绘制的气球使得 sprite 的左上角位于当前鼠标位置。当您在某个位置绘制精灵时,默认行为是在该位置绘制精灵的左上角。如果执行下面的指令
Game.drawImage(someSprite, somePosition);
名为someSprite
的子画面被绘制在屏幕上,使得其左上角位于位置somePosition
。你也可以把精灵的左上角叫做它的原点。那么,如果要改变这个原点呢?例如,假设您想要在位置somePosition
绘制精灵someSprite
的中心。嗯,你可以通过使用Image
类型的width
和height
变量来计算。让我们声明一个名为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)绘制一个球精灵的两个例子。左边的例子显示球的原点在左上角,右边的例子显示球的原点在精灵的中心。
图 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
方法,使其支持在不同的原点绘制精灵。您唯一需要做的就是添加一个额外的位置参数,并将该参数中的x
和y
值传递给画布上下文的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 。
图 5-5 。基于鼠标指针位置计算桶的角度
如果你记得你的数学课,你可能记得三角形的角度可以用三角函数来计算。在这种情况下,您可以使用正切函数计算角度,如下所示:
换句话说,角度由下式给出
通过计算当前鼠标位置和炮管位置之间的差值,可以计算对边和邻边的长度,如下所示:
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
对象的内容,您可以看到某些变量以某种方式组合在一起。例如,canvas
和canvasContext
变量属于同一类,因为它们都与画布有关。此外,相当多的变量存储关于大炮的信息,例如它的位置或它的旋转。您可以将相关的变量分组到不同的对象中,以便在代码中更清楚地说明这些变量是相关的。例如,看看这个例子:
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 表示您在层次结构中向上移动了两个目录。这是必要的,因为示例目录Painter2
和Painter2a
与assets
目录不在同一层。您将这些图像存储在属于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 显示了程序的截图。
图 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 键的键码。现在,您可以在cannon
的update
方法中使用它,如下所示:
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 中,这些真值由关键字true
和false
表示。
除了用于表达if
指令中的条件,逻辑表达式还可以应用于许多不同的情况。逻辑表达式类似于算术表达式,只是类型不同。例如,您可以将逻辑表达式的结果存储在变量中,将其作为参数传递,或者在另一个表达式中再次使用该结果。
逻辑值的类型是布尔,以英国数学家和哲学家乔治·布尔(1815–1864)的名字命名。以下是布尔变量声明和赋值的示例:
var test;
test = x > 3 && y < 5;
在这种情况下,例如,如果x
包含值 6,而y
包含值 3,布尔表达式x > 3 && y < 5
将计算为true
,该值将存储在变量test
中。您也可以将布尔值true
和false
直接存储在变量中:
var isAlive = false;
布尔变量对于存储游戏中不同对象的状态非常方便。例如,您可以使用一个布尔变量来存储玩家是否还活着,玩家当前是否正在跳跃,一个关卡是否完成,等等。您可以在if
指令中使用布尔变量作为表达式:
if (isAlive)// do something
在这种情况下,如果表达式isAlive
的计算结果为true
,则执行if
指令的主体。您可能认为这段代码会产生一个编译器错误,您需要对布尔变量进行比较,如下所示:
if (isAlive === true)// do something
然而,这种额外的比较是不必要的。在if
指令中的条件表达式必须评估为 true
或false
。因为布尔变量已经表示了这两个值中的一个,所以不需要进行比较。事实上,如果需要之前的比较,您还需要再次将结果与布尔值进行比较:
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;
在你继续阅读之前,试着在执行完这些指令后确定变量a
、b
和c
的值。在第一行中,您声明并初始化一个布尔值a
。存储在该布尔值中的真值由表达式12 > 5
计算得出,其结果为true
。然后将该值分配给变量a
。在第二行中,您声明并初始化了一个新变量b
,其中存储了一个更复杂的表达式的结果。这个表达式的第一部分是变量a
,它包含值true
。表达式的第二部分是比较表达式3 + 4 === 8
。这个比较不成立(3 + 4 不等于 8),所以它的计算结果是false
,因此逻辑和也产生false
。因此,该指令执行后,变量b
包含值false
。
第三条指令将变量a
和b
的逻辑或运算结果存储在变量c
中。因为a
包含值true
,所以这个运算的结果也是true
,这个结果被赋给c
。最后,还有一个if
指令,它将值false
赋给变量a
,但前提是!c
的计算结果为true
。在这种情况下,c
是true
,所以!c
是false
,这意味着if
指令的主体没有被执行。因此,所有指令执行完毕后,a
和c
都包含值true
,b
包含值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;
现在,在cannon
的update
方法中,只有当鼠标左键被按下时才更新炮管角度:
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);
} elsecannon.rotation = 0;
这条指令做的事情和前面两条if
指令完全一样,但是你只需要写一次条件。执行 Painter2 程序,看看它能做什么。请注意,只要松开鼠标左键,炮管的角度就为零。
带有替代指令的if
指令的语法由图 6-2 中的语法图表示。一条if
指令的主体可以由括号内的多条指令组成,因为一条指令也可以是指令的块,如图 6-3 中的语法图所定义。
图 6-2 。if
指令的语法图
图 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 });elseCanvas2D.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 });
elseCanvas2D.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);
} elsecannon.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
指令用于不同的备选方案*