HTML5-游戏开发示例-全-
HTML5 游戏开发示例(全)
原文:
zh.annas-archive.org/md5/ac24016062d8c41033eab359b723a8c5译者:飞龙
前言
HTML5 承诺将成为在线游戏的热门新平台。HTML5 游戏在电脑、智能手机、平板电脑、iPhone 和 iPad 上运行。成为今天最早开发 HTML5 游戏的开发者之一,并为明天做好准备!
本书将向您展示如何使用最新的 HTML5 和 CSS3 网络标准来构建卡牌游戏、绘图游戏、物理游戏,甚至是网络多人游戏。有了这本书,您将通过清晰的系统教程构建六个示例游戏。
HTML5、CSS3 和相关 JavaScript API 是 Web 的最新热门话题。这些标准为我们带来了新的 HTML5 游戏市场。借助它们的新力量,我们可以使用 HTML5 元素、CSS3 属性和 JavaScript 来设计在浏览器中玩的游戏。
本书分为 10 章,每章专注于一个主题。在构建书中的六个游戏时,您将学习如何绘制游戏对象、动画化它们、添加音频、连接玩家,以及使用 Box2D 物理引擎构建物理游戏。
本书涵盖的内容
第一章, 介绍 HTML5 游戏,介绍了 HTML5、CSS3 和相关 JavaScript API 的新特性。它演示了我们可以使用这些特性制作哪些游戏及其优势。
第二章, 开始基于 DOM 的游戏开发,通过在 DOM 和 jQuery 中创建传统的乒乓球游戏来启动游戏开发之旅。
第三章, 在 CSS3 中构建卡牌匹配游戏,引导您了解 CSS3 的新特性,并讨论了如何在 DOM 和 CSS3 中创建记忆卡牌匹配游戏。
第四章, 使用 Canvas 和绘图 API 构建 Untangle 游戏,介绍了一种在网页中绘制事物并与它们交互的新方法,使用新的 canvas 元素。这也演示了如何在触摸设备上处理拖动操作。
第五章, 构建 Canvas 游戏大师班,将 Untangle 游戏扩展以展示我们如何在 Canvas 中绘制渐变和图像。它还讨论了精灵表动画和多层管理。
第六章, 为您的游戏添加音效,通过使用音频元素为游戏添加音效和背景音乐。它讨论了网络浏览器之间的音频格式能力,并在本章结束时创建了一个由键盘驱动的音乐游戏。
第七章, 保存游戏进度,将 CSS3 记忆匹配游戏扩展以展示我们如何使用本地存储 API 来存储和恢复游戏进度,并记录最佳分数。
第八章,使用 WebSockets 构建多人绘画猜谜游戏,讨论了允许浏览器与 socket 服务器建立持久连接的 WebSockets API。这使得多个玩家能够实时一起玩游戏。本章末尾创建了一个绘画猜谜游戏。
第九章,使用 Box2D 和 Canvas 构建物理赛车游戏,教你如何将著名的物理引擎 Box2D 集成到我们的画布游戏中。它讨论了如何创建物理体、施加力、将它们连接起来、将图形与物理关联,并最终创建一个平台赛车游戏。
第十章,部署 HTML5 游戏,分享了我们可以发布游戏的不同方式。它讨论了将网络包装成原生应用以发布到苹果的 App Store。
附录,快速问答答案,给出了每个章节中快速问答问题的答案。
你需要这本书的内容
你需要最新的现代网络浏览器、一个好的文本编辑器,以及 HTML、CSS 和 JavaScript 的基本知识。在第八章,使用 WebSockets 构建多人绘画猜谜游戏中,我们需要 Node.js 服务器,我们将在那一章帮助你安装。
这本书的适用对象
这本书是为那些对 HTML、CSS 和 JavaScript 有基本知识,并希望创建在浏览器上运行的 Canvas 或 DOM 游戏的设计师而写的。
部分
在这本书中,你会发现一些经常出现的标题(行动时间、刚才发生了什么?、快速问答和英雄试炼)。
为了清楚地说明如何完成一个程序或任务,我们按照以下方式使用这些部分:
行动时间 – 标题
-
行动 1
-
行动 2
-
行动 3
指令通常需要一些额外的解释以确保它们有意义,因此它们后面跟着这些部分:
刚才发生了什么?
本节解释了你刚刚完成的任务或指令的工作原理。
你还会在书中找到一些其他的学习辅助工具,例如:
快速问答 – 标题
这些是简短的多项选择题,旨在帮助你测试自己的理解。
英雄试炼 – 标题
这些是实际挑战,它们为你提供了实验你所学知识的想法。
规范
你还会在书中找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"在代码编辑器中打开index.html文件"。
代码块设置为如下:
var matchingGame = {};
matchingGame.deck = [
'cardAK', 'cardAK',
'cardAQ', 'cardAQ',
'cardAJ', 'cardAJ',
'cardBK', 'cardBK',
'cardBQ', 'cardBQ',
'cardBJ', 'cardBJ',
];
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
$(function(){
matchingGame.deck.sort(shuffle);
for(var i=0;i<11;i++){
$(".card:first-child").clone().appendTo("#cards");
}
新 术语 和 重要 词汇 以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的单词,在文本中会这样显示:“在 MAC 中,点击获取代码标签,你将看到以下截图;这显示了如何将此字体嵌入我们的网页中的指南。”
注意
警告或重要注意事项以这样的框显示。
小贴士
小技巧和窍门以这样的方式显示。
读者反馈
我们始终欢迎读者的反馈。让我们知道您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要发送给我们一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com下载示例代码文件,适用于您购买的所有 Packt 出版社的书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/7770OS_ColoredImages.pdf下载此文件。
错误更正
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详情。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误部分下的现有错误列表中。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分。
盗版
在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者和我们提供有价值内容的能力方面所提供的帮助。
问题
如果您在这本书的任何方面遇到问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力
第一章. HTML5 游戏介绍
超文本标记语言,HTML,在过去几十年中一直在塑造互联网。它定义了网页中内容的结构以及相关页面之间的链接。HTML 从版本 2 发展到 HTML 4,再到 XHTML 1.1。多亏了网络应用程序和社交网络应用程序,我们现在进入了 HTML5 时代。
层叠样式表 (CSS*) 定义了网页的视觉呈现方式。它为所有 HTML 元素及其状态(如悬停和激活)的样式进行定义。
JavaScript 是网页的逻辑控制器。它使网页变得动态,并提供页面与用户之间的客户端交互。它通过 文档对象模型 (DOM) 访问 HTML。它通过它们的 API 控制新的 HTML 特性。
大多数桌面和移动设备都配备了现代网络浏览器。这些最新的网络技术为我们带来了新的游戏市场——HTML5 游戏。借助这些技术的强大功能,我们可以使用 HTML5 元素、CSS3 属性和 JavaScript 设计可在大多数浏览器和移动设备上玩的游戏。
在本章中,我们将涵盖以下主题:
-
探索 HTML5 的新特性
-
讨论是什么让我们对 HTML5 和 CSS3 如此兴奋
-
预览后续章节中将要构建的游戏
-
准备开发环境
那么,让我们开始吧。
探索 HTML5 的新特性
HTML5 和 CSS3 引入了许多新特性。在动手创建游戏之前,让我们先概述一下这些新特性,看看我们如何利用它们来创建游戏。
Canvas
Canvas 是一个 HTML5 元素,在低级别上提供绘制形状和位图操作功能。我们可以将 Canvas 元素想象成一个动态的图像标签。传统的 <img> 标签显示一个静态图像。这个图像在加载后通常是静态的。我们可以将 <img> 标签更改为另一个图像源或应用样式到图像上,但我们不能修改图像的位图上下文本身。
另一方面,Canvas 类似于客户端动态的 <img> 标签。我们可以在其中加载图像,绘制形状,并使用 JavaScript 与之交互。
Canvas 在 HTML5 游戏开发中扮演着重要的角色。这是我们本书的主要关注领域之一。
音频
背景音乐和音效是游戏设计中的基本元素。HTML5 通过 audio 标签提供了原生的音频支持。多亏了这个特性,我们不需要专有的 Flash Player 来在我们的 HTML5 游戏中播放音效。然而,在网络上使用 Web Audio 也有一些限制。我们将在第六章 添加音效到您的游戏中讨论 audio 标签的使用。
触摸事件
除了传统的键盘和鼠标事件外,我们还可以使用触摸事件来处理单点和多点触摸事件。我们可以设计一个适用于移动设备的触摸游戏。我们还可以通过观察触摸模式来处理手势。
GeoLocation
GeoLocation 允许网页检索用户的计算机的经纬度。例如,谷歌的 Ingress 游戏就利用 GeoLocation 允许玩家在他们的真实城市中玩游戏。几年前,当每个人都在使用台式机上网时,这个功能可能并不那么有用。我们不需要很多需要用户道路准确位置的事情。我们可以通过分析 IP 地址来获取大致的位置。
现在,越来越多的用户使用功能强大的智能手机上网。WebKit 和其他现代移动浏览器都在每个人的口袋里。GeoLocation 允许我们设计可以与位置输入一起使用的移动应用程序和游戏。
WebGL
WebGL 通过在网页浏览器中提供一组 3D 图形 API 来扩展 Canvas 元素。这些 API 遵循 OpenGL ES 2.0 标准。WebGL 为 HTML5 游戏提供了一个强大的 GPG 加速的 3D 渲染 API。一些 3D 游戏引擎支持 WebGL 的导出,包括流行的 Unity 引擎。我们可以期待看到更多使用 WebGL 等待发布的 HTML5 3D 游戏。
使用 WebGL 创建游戏的技术与使用 Canvas 的技术相当不同。在 WebGL 中创建游戏需要处理 3D 模型并使用类似于 OpenGL 的 API。因此,本书不会讨论 WebGL 游戏开发。
由于 GPU 渲染支持,WebGL 的性能优于 2D Canvas。一些库允许游戏使用 Canvas 2D 绘图 API,并且工具通过在 WebGL 上绘制来渲染画布以获得性能提升。Pixi.js (www.pixijs.com)、EaselJS (blog.createjs.com/webgl-support-easeljs/) 和 WebGL-2D (github.com/corbanbrook/webgl-2d) 是其中的一些工具。
WebSocket
WebSocket 是 HTML5 规范的一部分,用于将网页连接到 socket 服务器。它为我们提供了一个浏览器和服务器之间的持久连接。这意味着客户端不需要在短时间内轮询服务器以获取新数据。每当有数据更新时,服务器都会将更新推送到浏览器。这个特性的一个好处是,游戏玩家可以几乎实时地相互交互。当一个玩家做了某事并向服务器发送数据时,我们可以向单个玩家发送更新以创建一对一的实时页面播放,或者我们可以迭代服务器中的所有连接,向每个连接的浏览器发送事件以确认玩家刚刚做了什么。这为构建多人 HTML5 游戏创造了可能性。
Local storage
HTML5 为网络浏览器提供了一种持久数据存储解决方案。
Local Storage 持久存储键值对数据。在浏览器终止后,数据仍然存在。此外,数据不限于只能由创建它的浏览器访问。它对所有具有相同域的浏览器实例都是可用的。多亏了 Local Storage,我们可以在网络浏览器中轻松地本地保存游戏状态,如进度和获得的成就。
浏览器上的另一个数据库是 IndexedDB。它也是键值对,但它允许存储对象并使用条件查询数据。
离线应用程序
通常,我们需要网络连接来浏览网页。有时,我们可以浏览缓存的离线网页。这些缓存的离线网页通常很快就会过期。随着 HTML5 引入的下一个离线应用程序,我们可以声明我们的缓存清单。这是一个将要存储以供未来无网络连接时访问的文件列表。
使用缓存清单,我们可以将所有游戏图形、游戏控制 JavaScript 文件、CSS 样式表和 HTML 文件本地存储。我们还可以将我们的 HTML5 游戏打包为桌面或移动设备上的离线游戏。玩家甚至可以在飞行模式下玩游戏。以下来自 Pie Guy 游戏(mrgan.com/pieguy)的截图显示了在没有网络连接的 iPhone 上玩 HTML5 游戏;注意表示离线状态的飞机符号:

发现 CSS3 中的新特性
CSS 是表示层,HTML 是内容层。它定义了 HTML 的外观。当我们使用 HTML5 创建游戏时,我们不能忽视 CSS,尤其是基于 DOM 的游戏。我们可能纯粹使用 JavaScript 通过 Canvas 元素创建和设计游戏。然而,当我们创建基于 DOM 的 HTML5 游戏时,我们需要 CSS。因此,让我们看看 CSS3 中有什么新特性,以及我们如何使用这些新特性来创建游戏。
与直接在 Canvas 的绘图板上绘制和交互不同,新的 CSS3 属性让我们能够以不同的方式对 DOM 进行动画处理。这使得制作更复杂的基于 DOM 的浏览器游戏成为可能。
CSS3 过渡
传统上,当我们对一个元素应用新的样式时,样式会立即改变。CSS3 过渡在目标元素的样式变化过程中渲染中间样式。例如,在这里,我们有一个蓝色盒子,当我们进行鼠标悬停时,我们想将其变为深蓝色。我们可以通过以下代码片段来实现:
HTML:
<a href="#" class="box"></a>
CSS:
a.box {
display: block;
width: 100px;
height: 100px;
background: blue;
}
a.box:hover {
background: darkblue;
}
当我们进行鼠标悬停时,盒子会立即变为深蓝色。应用 CSS3 过渡后,我们可以为特定持续时间和缓动值进行样式插值:
a.box {
transition: all 0.5s ease-out;
}
小贴士
下载示例代码
对于您购买的所有 Packt Publishing 书籍,您可以从您的账户下载示例代码文件,账户地址为 www.packtpub.com。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
在过去,我们需要 JavaScript 来计算和渲染中间样式;这比使用 CSS3 过渡要慢得多,因为浏览器原生地使效果发生。
注意
由于一些 CSS3 规范仍在草案阶段且尚未固定,不同浏览器供应商的实现可能与 W3C 规范存在一些细微差异。因此,浏览器供应商倾向于使用供应商前缀来实现他们的 CSS3 属性,以防止冲突。
Safari 使用 -webkit- 前缀。Opera 使用 -o- 前缀。Firefox 使用 -moz- 前缀,IE 使用 -ms- 前缀。Chrome 以前使用 -webkit-,但现在在切换到 Blink 引擎后不再使用任何前缀。现在声明 CSS3 属性(如 flex)变得有些复杂,因为需要为多个浏览器编写多行相同的规则。我们可以期待在属性规范固定后,前缀将被删除。
为了使本书中的代码更简洁,我将使用非供应商前缀来表示本书中所有的属性。我建议您使用基于 JavaScript 的库来自动为不同浏览器添加所需的供应商前缀。Prefix-Free 库(leaverou.github.io/prefixfree/)就是其中之一。
或者,如果您正在使用预处理器,编译过程也可能为您添加必要的供应商前缀。
CSS3 转换
CSS3 转换允许我们缩放元素、旋转它们以及平移它们的位置。CSS3 转换分为二维和三维。通过结合变换原点、三维旋转和平移,我们可以在三维空间中动画化二维图形。
CSS3 动画
CSS3 过渡是一种动画类型。它声明了元素两种样式之间的缓动动画。
CSS3 动画在动画方面更进一步。我们可以定义动画的关键帧。每个关键帧包含一组在任何特定时刻应该改变的性质。它就像一系列应用于目标元素的 CSS3 过渡,按顺序应用。
AT-AT 行走者(anthonycalzadilla.com/css3-ATAT/index-bones.html)展示了使用 CSS3 动画关键帧、变换和过渡创建骨骼动画的精彩示例。这将在以下图中展示:

创建 HTML5 游戏的好处
我们已经探讨了 HTML5 和 CSS3 的一些新功能。有了这些功能,我们可以在浏览器上创建 HTML5 游戏。但为什么我们需要这样做?创建 HTML5 游戏有什么好处?
自由和开放标准
网络标准是开放和免费的。相比之下,第三方工具通常是专有的,并且需要付费。随着公司关注点的变化,它们的支持可能会下降。HTML5 的标准化和开放性确保我们将拥有支持它的浏览器。
支持多平台
由于现代浏览器内置了对所有 HTML5 特性的支持,我们不需要用户预先安装任何第三方插件来播放任何文件。这些插件并不标准。它们通常需要额外的插件安装,你可能无法安装。例如,全球数以百万计的 Apple iOS 设备在其移动 Safari 中不支持第三方插件,例如 Flash Player。无论原因如何,Apple 不允许 Flash Player 在其移动 Safari 上运行,相反,他们浏览器中拥有的是 HTML5 和相关网络标准。我们可以通过创建针对移动设备优化的 HTML5 游戏来触及这部分用户基础。
特定场景下的原生应用程序渲染性能
当我们在 Canvas 中编写游戏代码时,有一些渲染引擎可以将我们的 Canvas 绘图代码转换为 OpenGL,从而在原生移动设备上渲染。这意味着尽管我们仍在为网页浏览器编写游戏,但我们的游戏可以通过原生应用程序 OpenGL 渲染在移动设备上获得优势。Ejecta (impactjs.com/ejecta) 和 CocoonJS (ludei.com/cocoonjs) 是这样的两个引擎。
打破常规浏览器游戏的界限
在传统的游戏设计中,我们在一个边界框内构建游戏。我们在电视上玩视频游戏。我们在带有矩形边界的网页浏览器中玩 Flash 游戏。
通过发挥创意,我们不再受限于矩形游戏舞台。我们可以利用页面上的所有元素来享受乐趣。
Twitch (reas.com/twitch/) 是一个来自 Chrome Experiments 的游戏。它是一系列迷你游戏,玩家需要将球从起点运送到终点。有趣的是,每个迷你游戏都是一个小的浏览器窗口。当球到达该迷你游戏的终点时,它会被转移到新创建的迷你游戏浏览器中继续旅程。以下截图显示了 Twitch 的整个地图以及各个独立的网页浏览器:

构建 HTML5 游戏
多亏了 HTML5 和 CSS3 的新特性,我们现在可以在浏览器中创建整个游戏。我们可以控制 DOM 中的每一个元素。我们可以使用 CSS3 对每个文档对象进行动画处理。我们有 Canvas 来动态绘制事物并与它们交互。我们有音频元素来处理背景音乐和音效。我们还拥有本地存储来保存游戏数据,以及 WebSocket 来创建实时多人游戏。大多数现代浏览器已经支持这些特性。现在是时候构建 HTML5 游戏了。
其他人在用 HTML5 做什么
这是一个很好的机会,通过观看使用不同技术制作的 HTML5 游戏,来研究不同 HTML5 游戏的性能。
可口可乐的 Ahh 活动
可口可乐曾推出一项名为Ahh(ahh.com)的活动,其中包含许多互动小游戏。这些互动结合了多种技术,包括画布和设备旋转。大多数游戏在桌面和移动设备上都能很好地运行。

以小行星风格的收藏夹
来自瑞典的网页设计师 Erik 创建了一个有趣的收藏夹。这是一个适用于任何网页的小行星风格游戏。是的,任何网页!它展示了与任何网页交互的一种不寻常的方式。它在你阅读的网站上创建了一个飞机。然后你可以使用箭头键驾驶飞机,并使用空格键开火。有趣的是,子弹会摧毁页面上的 HTML 元素。你的目标是摧毁你选择的网页上的所有东西。这个收藏夹是打破常规浏览器游戏边界的另一个例子。它告诉我们,在设计 HTML5 游戏时,我们可以跳出思维定势。
以下截图显示了飞机正在摧毁网页上的内容:

这个收藏夹可以在kickassapp.com安装。你甚至可以设计你控制的宇宙飞船。
X-Type
以 Canvas 为基础的游戏引擎 Impact 的制作者,为包括网络浏览器、iOS 和 Wii U 在内的不同平台创建了这个 X-Type(phoboslab.org/xtype/)射击游戏。以下截图显示了游戏在 iPhone 上运行得非常流畅。

Cursors.io
Cursors.io (cursors.io) 展示了一个设计精良的实时多人游戏。每个用户控制一个匿名的鼠标光标,通过将光标移动到绿色出口来穿越游戏的各个关卡。游戏的乐趣在于玩家必须帮助他人前进到下一级。有一些开关,某些光标点击它们来解锁门。匿名玩家必须承担起帮助他人的角色。有人会取代你的位置,这样你就可以前进到下一级。帮助你的人越多,你在游戏中成功的几率就越高。如果只有少数玩家在玩,而你无法体验游戏,我已以 12 倍速录制了我的游戏画面(在vimeo.com/109414542),让你一窥这个多人游戏是如何运作的。以下截图展示了这一过程:

注意
我们将在第八章中讨论构建多人游戏,使用 WebSocket 构建多人绘画猜谜游戏。
本书将要创建的内容
在接下来的章节中,我们将构建六个游戏。我们首先将创建一个基于 DOM 的乒乓球游戏,可以在同一台机器上由两名玩家玩。然后,我们将使用 CSS3 动画创建一个记忆匹配游戏。接下来,我们将使用 Canvas 创建一个解谜游戏。稍后,我们将构建一个包含音频元素的音乐游戏。然后,我们将使用 WebSocket 创建一个多人绘画猜谜游戏。最后,我们将使用 Box2D JavaScript 端口创建一个物理赛车游戏的原型。以下截图显示了我们在第三章中将要构建的记忆匹配游戏,使用 CSS3 构建卡牌匹配游戏。你可以在makzan.net/html5-games/card-matching/上玩这个游戏。

准备开发环境
开发 HTML5 游戏的环境与设计网站类似。我们需要网络浏览器和一款优秀的文本编辑器。哪款文本编辑器好是一个永无止境的争论。每种文本编辑器都有其自身的优势,所以只需选择你最喜欢的一款。我个人推荐具有多个光标的文本编辑器,例如 Sublime Text 或 Brackets。对于浏览器,我们需要支持最新 HTML5 和 CSS3 规范的现代浏览器,并为我们提供方便的调试工具。
现在互联网上有几种现代浏览器可供选择。它们是 Apple Safari (apple.com/safari/)、Google Chrome (www.google.com/chrome/)、Mozilla Firefox (mozilla.com/firefox/)和 Opera (opera.com)。这些浏览器支持本书中将要讨论的大多数特性。我个人使用 Chrome,因为它拥有强大的内置开发者工具。强大的开发者工具使其在网页和游戏开发者中非常受欢迎。
我们还需要安卓手机和 iPad/iPhone 来测试移动设备上的游戏。模拟器也可能有效,但使用真实设备进行测试可以得到更接近现实使用的测试结果。
摘要
在本章中,我们学习了关于 HTML5 游戏的基本信息。
具体来说,我们涵盖了 HTML5 和 CSS3 的新特性。我们在后续章节中向您展示了我们将使用哪些技术来创建游戏——Canvas、音频、CSS 动画以及更多新特性被引入。我们将有许多新特性可以探索。我们讨论了为什么我们要创建 HTML5 游戏——我们希望满足网络标准,适应移动设备,并打破游戏的界限。我们审视了几种使用不同技术创建的现有 HTML5 游戏,我们也将使用这些技术。在我们自己创建游戏之前,您可以测试这些游戏。我们还预览了书中将要构建的游戏。最后,我们准备好了我们的开发环境。
现在我们对 HTML5 游戏有了背景信息,我们准备在下一章创建我们的第一个基于 DOM、由 JavaScript 驱动的游戏。
第二章:使用基于 DOM 的游戏开发入门
*我们在 第一章 介绍 HTML5 游戏 中对整本书将要学习的内容有一个大致的了解。从这一章开始,我们将进入许多通过实践学习的内容,并且每个部分将专注于一个主题。在深入探讨 CSS3 动画和 HTML5 Canvas 游戏之前,让我们从传统的基于 DOM 的游戏开发开始。我们将在这个章节中通过一些基本技术来热身。
在这一章中,我们将做以下事情:
-
设置我们的第一个游戏——乒乓球
-
使用 jQuery JavaScript 库学习基本定位
-
获取鼠标输入
-
创建显示分数的乒乓球游戏
-
学习如何分离数据和视图渲染逻辑
我们将创建一个乒乓球游戏,玩家可以通过鼠标输入与电脑对战。你可以在 makzan.net/html5-games/pingpong/ 尝试这个游戏。
以下截图显示了本章结束时游戏的外观:

因此,让我们开始制作我们的乒乓球游戏。
注意
在撰写本书时,jQuery 版本是 2.1.3。我们在示例中使用的 jQuery 函数是基本函数,应该在未来版本中也能正常工作。
准备基于 DOM 的游戏的 HTML 文档
每个网站、网页和 HTML5 游戏都是从默认的 HTML 文档开始的。此外,文档从基本的 HTML 代码开始。我们将从 index.html 开始我们的 HTML5 游戏开发之旅。
行动时间——安装 jQuery 库
我们将从零开始创建我们的 HTML5 乒乓球游戏。这听起来好像我们即将准备所有的事情。幸运的是,我们可以使用一个 JavaScript 库来帮助我们。jQuery 是一个专为轻松导航 DOM 元素、操作它们、处理事件和创建异步远程调用而设计的 JavaScript 库。我们将在本书中使用这个库来操作 DOM 元素。它将帮助我们简化我们的 JavaScript 逻辑:
-
创建一个名为
pingpong的新文件夹作为我们的项目目录。 -
在
pingpong文件夹内,我们将创建以下文件结构,包括三个文件夹——js、css和images——以及一个index.html文件:index.html js/ js/pingpong.js css/ css/pingpong.css images/ -
现在,是时候下载 jQuery 库了。访问
jquery.com/。 -
选择 下载 jQuery 并点击 下载压缩的、用于生产的 jQuery 2.1.3。
-
将
jquery-2.1.3.min.js保存到我们在步骤 2 中创建的js文件夹中。 -
在文本编辑器中打开
index.html并插入一个空的 HTML 模板:<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Ping Pong</title> <link rel="stylesheet" href="css/pingpong.css"> </head> <body> <header> <h1>Ping Pong</h1> </header> <div id="game"> <!-- game elements to be here --> </div> <footer> This is an example of creating a Ping Pong Game. </footer> <script src="img/jquery-2.1.3.min.js"></script> <script src="img/pingpong.js"></script></body> </html> -
最后,我们必须确保 jQuery 已成功加载。为此,将以下代码放入
js/pingpong.js文件中:(function($){ $(function(){ // alert a message alert("Welcome to the Ping Pong battle."); }); })(jQuery); -
保存
index.html文件并在浏览器中打开它。你应该会看到一个显示我们文本的警告窗口。这意味着我们的 jQuery 已经正确设置:![Time for action – installing the jQuery library]()
刚才发生了什么?
我们刚刚使用 jQuery 创建了一个基本的 HTML5 页面,并确保 jQuery 被正确加载。
新的 HTML5 doctype
在 HTML5 中,DOCTYPE和meta标签被简化了。
在 HTML 4.01 中,我们使用以下代码声明 doctype:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
这是一行很长的代码,对吧?在 HTML5 中,文档类型声明(doctype)已经不能再简单了:
<!DOCTYPE html>
我们甚至没有在声明中包含 HTML 版本。这是因为 HTML5 现在是一个没有版本号的活标准。
Header 和 footer
HTML5 带来了许多新特性和改进,其中之一是语义。HTML5 添加了新元素来提高语义。我们只使用了两个元素:header和footer。Header为部分或整个页面提供了一个介绍。因此,我们将h1标题放在 header 中。Footer,正如其名所示,包含部分或页面的页脚信息。
注意
语义化 HTML 意味着标记本身提供了有意义的信息给内容,而不仅仅是定义视觉外观。
放置 JavaScript 代码的最佳实践
我们将 JavaScript 代码放在</body>标签的关闭之前,并在页面中的所有内容之后。将代码放在那里的原因而不是将其放在<head></head>部分中,是有原因的。
通常,浏览器从顶部到底部加载内容并渲染它们。如果 JavaScript 代码放在head部分,那么文档的内容将不会加载,直到所有 JavaScript 代码都加载完毕。实际上,如果浏览器在页面中间加载 JavaScript 代码,所有的渲染和加载都会暂停。这就是为什么我们希望在可能的情况下将 JavaScript 代码放在底部。这样,我们可以更快地将 HTML 内容传递给读者。
在撰写本书时,最新的 jQuery 版本是 2.1.3。这就是为什么我们的代码示例中的 jQuery 文件被命名为jquery-2.1.3.min.js。文件名中的版本号确保了网络开发者不会在不同项目中混淆相同文件名的不同版本。这个版本号会不同,但使用方式应该是相同的,除非 jQuery 有重大变化而不向后兼容。
注意
请注意,一些 JavaScript 库需要在加载任何 HTML 元素之前放置<head>标签。当您使用第三方库时,请检查它们是否有这样的要求。
选择 jQuery 文件
对于 jQuery 库,目前有两个主要版本;它们是 1.x 和 2.x。1.x 版本保持与旧浏览器的向后兼容性,主要是针对 IE 6、7 和 8 版本。由于我们的 HTML5 游戏针对现代浏览器,我们选择了不再支持 IE 8 或更旧版本的 2.x 版本。
包含 jQuery 库有两种常见方式。我们可以下载一个 托管 版本或使用 CDN 版本。托管版本意味着我们下载文件,并自行托管文件。CDN 代表内容分发网络。jQuery 文件托管在几个中央服务器上,以提高文件下载时间。对于 CDN 版本,我们可以在 code.jquery.com 找到 URL。我们可以直接在 HTML 中使用 <script> 标签包含文件,如下所示:<script src="img/jquery.min.js"></script>。
否则,我们可以在文件名中指定版本号,如下所示:<script src="img/jquery-2.1.3.min.js"></script>。
在作用域内运行 jQuery
我们需要在我们的 JavaScript 代码执行之前确保页面已准备好。否则,当我们尝试访问尚未加载的元素时,可能会出错。jQuery 通过以下代码提供了一种在页面准备好后执行代码的方法:
jQuery(document).ready(function(){
// code here.
});
大多数情况下,我们使用 $ 符号来表示 jQuery。这是一个使调用我们的 jQuery 函数变得更容易的快捷方式。因此,本质上,我们使用以下代码:
$(function(){
// code here.
});
当我们调用 $(something) 时,我们实际上是在调用 jQuery(something)。
如果在一个项目中使用多个 JavaScript 库,可能会出现 $ 变量冲突。为了最佳实践,我们使用一个 匿名函数 将 jQuery 对象传递到函数作用域,使其成为 $ 符号:
(function($){
// jQuery code here with $.
})(jQuery);
匿名函数是一个没有名称的函数定义。这就是为什么它被称为匿名函数。由于我们不能再引用这个函数,匿名函数总是自我执行。JavaScript 的变量作用域绑定到函数作用域。我们经常使用匿名函数来控制某些变量的可用性。例如,在我们的例子中,我们将 jQuery 作为 $ 变量传递到函数中。
在页面准备好后运行我们的代码
$(function_callback) 是 DOM 元素的 ready 事件的另一个快捷方式。我们需要 jQuery ready 函数的原因是防止在 HTML DOM 元素加载之前执行 JavaScript 逻辑。我们在 jQuery ready 函数中定义的函数在所有 HTML 元素加载后执行。
它与以下代码行相同:
$(document).ready(function_callback);
注意
注意,jQuery 的 ready 事件在 HTML 结构(DOM 树)加载后触发。然而,这并不意味着内容,例如实际图像内容,已经加载。另一方面,浏览器的 onload 事件在所有内容(包括图像)加载后触发。
快速问答
Q1. 将 JavaScript 代码放在哪里是最好的位置?
-
在
<head>标签之前 -
在
<head></head>元素内部 -
在
<body>标签之后 -
在
</body>标签之前
下载图像资源
在这一步,我们需要一些图形文件。你可以从代码包中下载图形文件,或者从 mak.la/book-assets/ 下载。
在资源包中,你可以找到 第二章 的图像文件。下载后,将这些文件放入 images 文件夹。应该有四个文件,如下面的截图所示:

设置乒乓球游戏元素
我们已经做好了准备,现在是时候设置乒乓球游戏了。以下图表显示了游戏元素的放置方式。游戏元素包含我们的游乐场和之后的计分板。在游乐场内部,我们放置了两个装饰元素,即挡板手,它作为足球机的把手。然后,我们有两个挡板元素——一个在左边,一个在右边。

行动时间 - 将乒乓球游戏元素放置在 DOM 中
-
我们将继续从我们的 jQuery 安装示例,并在文本编辑器中打开
index.html文件。 -
然后,我们将使用 DIV 节点在主体中创建以下游乐场和
game对象。游乐场内部有两个挡板和一个球,并且游乐场被放置在游戏内部:<div id="game"> <div id="playground"> <div class="paddle-hand right"></div> <div class="paddle-hand left"></div> <div id="paddleA" class="paddle"></div> <div id="paddleB" class="paddle"></div> <div id="ball"></div> </div> </div> -
我们现在已经准备好了
game对象的结构,现在是时候为它们应用样式了。我们将向pingpong.css文件中添加以下样式:#game { position: relative; width: 400px; height: 200px; } #playground{ background: url(../images/playground.png); background-size: contain; width: 100%; height: 100%; position: absolute; top: 0; left: 0; overflow: hidden; cursor: pointer; } #ball { background: #fbb; position: absolute; width: 20px; height: 20px; left: 150px; top: 100px; border-radius: 10px; } -
然后,我们将在
pingpong.css文件内部追加以下代码来定义两个挡板的尺寸和位置:.paddle { background-size: contain; top: 70px; position: absolute; width: 30px; height: 70px; } #paddleA { left: 50px; background-image: url(../images/football-player-left.png); } #paddleB { right: 50px; background-image: url(../images/football-player.png); } -
我们将继续在
pingpong.css文件中的样式,并定义paddle-hands,这是挡板的装饰:.paddle-hand { background: url(../images/football-player-hand.png) 50% 0 repeat-y; background-size: contain; width: 30px; height: 100%; position: absolute; top: 0; } .left.paddle-hand { left: 50px; } .right.paddle-hand { right: 50px; } -
现在我们完成了 CSS 样式,让我们转到
js/pingpong.js文件来处理 JavaScript 的逻辑。我们需要一个函数来根据位置数据更新挡板的 DOM 元素。为此,我们将用以下代码替换当前代码:(function($){ // data definition var pingpong = { paddleA: { x: 50, y: 100, width: 20, height: 70 }, paddleB: { x: 320, y: 100, width: 20, height: 70 }, }; // view rendering function renderPaddles() { $("#paddleB").css("top", pingpong.paddleB.y); $("#paddleA").css("top", pingpong.paddleA.y); } renderPaddles(); })(jQuery); -
现在,我们将在一个浏览器中测试设置。在浏览器中打开
index.html文件;我们应该看到一个类似于以下截图的屏幕:![行动时间 - 将乒乓球游戏元素放置在 DOM 中]()
发生了什么?
让我们看看我们刚刚使用的 HTML 代码。HTML 页面包含标题、页脚信息,以及一个具有 ID game 的 DIV 元素。game 节点包含一个名为 playground 的子节点,该子节点又包含三个子节点——两个挡板和球。
我们通常通过准备一个结构良好的 HTML 层次结构来开始 HTML5 游戏开发。HTML 层次结构帮助我们将类似的游戏对象(这些是一些 DIV 元素)组合在一起。如果你曾经使用过 Adobe Flash 制作过动画,这有点像将资产分组到电影剪辑中。我们也可以将其视为游戏对象的层,以便我们能够轻松地选择和设置它们的样式。
使用 jQuery
jQuery 命令通常包含两个主要部分:选择和修改。选择使用 CSS 选择器语法来选择网页中所有匹配的元素。修改操作修改所选元素,例如添加或删除子元素或样式。使用 jQuery 通常意味着将选择和修改操作链在一起。
例如,以下代码选择所有具有 box 类的元素并设置 CSS 属性:
$(".box").css({"top":"100px","left":"200px"});
理解基本的 jQuery 选择器
jQuery 是关于选择元素并在其上执行操作的工具。我们需要一个方法来在整个 DOM 树中选择我们需要的元素。jQuery 从 CSS 中借用了选择器。选择器提供了一组模式来匹配元素。以下表格列出了我们将在这本书中使用的最常见和有用的选择器:
| 选择器模式 | 含义 | 示例 |
|---|---|---|
$("Element") | 选择所有具有给定标签名的元素 | $("p") 选择所有 p 标签。$("body") 选择 body 标签。 |
| $("#id") | 选择具有给定 ID 属性的元素 | 考虑以下代码:
<div id="box1"></div>
<div id="box2"></div>
$("#box1") 选择高亮显示的元素。|
| $(".className") | 选择所有具有给定类属性的元素 | 考虑以下代码:
<div class="apple"></div>
<div class="apple"></div>
<div class="orange"></div>
<div class="banana"></div>
$(".apple") 选择设置了 class 为 apple 的高亮显示元素。|
| $("selector1, selector2, selectorN") | 选择所有匹配给定选择器的元素 | 考虑以下代码:
<div class="apple"></div>
<div class="apple"></div>
<div class="orange"></div>
<div class="banana"></div>
$(".apple, .orange") 选择 class 设置为 apple 或 orange 的高亮显示元素。|
理解 jQuery CSS 函数
jQuery 的 css 函数用于获取和设置所选元素的 CSS 属性。这被称为获取和设置模式,许多 jQuery 函数都遵循这种模式。
下面是使用 css 函数的一般定义:
.css(propertyName)
.css(propertyName, value)
css 函数接受以下表格中列出的几种类型的参数:
| 函数类型 | 参数定义 | 讨论内容 |
|---|---|---|
.css(propertyName) |
propertyName 是一个 CSS 属性 |
该函数返回所选元素的给定 CSS 属性的值。例如,以下代码返回 body 元素的 background-color 属性的值:$("body").css("background-color") 它只会读取值,而不会修改属性值。 |
css(propertyName, value) |
propertyName 是一个 CSS 属性,value 是为该属性设置的值。 |
该函数将给定的 CSS 属性修改为给定的值。例如,以下代码将所有具有 box 类的元素的背景颜色设置为红色:$(".box").css("background-color","#ff0000") |
使用 jQuery 在 DOM 中操作游戏元素
我们使用 jQuery 初始化了球拍的游戏元素。我们将进行一个实验,看看我们应该如何使用 jQuery 来放置游戏元素。
理解绝对定位的行为
当一个 DOM 节点被设置为 absolute 位置时,left 和 top 属性可以被视为一个 坐标。我们可以将 left/top 属性视为 X/Y 坐标,其中 Y 轴正方向向下。以下图表显示了它们之间的关系。左侧是实际的 CSS 值,右侧是我们编程游戏时的心理坐标系:

默认情况下,left 和 top 属性指的是网页的左上角。如果此 DOM 节点的任何父节点明确设置了 position 样式为 relative 或 absolute,则这个参考点就不同了。left 和 top 属性的参考点变为该父节点的左上角。
这就是为什么我们需要将游戏设置为相对位置,并将所有游戏元素设置为绝对位置。以下是我们示例中的代码片段,显示了元素的定位值:
#game{
position: relative;
}
#playground,
#ball,
#paddle {
position: absolute;
}
以更好的方式声明全局变量
全局变量 是可以在整个文档中访问的变量。任何在函数外部声明的变量都是全局变量。例如,在以下示例代码片段中,a 和 b 是全局变量,而 c 是一个 局部变量,它只存在于函数内部:
var a = 0;
var b = "xyz";
function something(){
var c = 1;
}
由于全局变量在整个文档中都是可用的,如果我们将不同的 JavaScript 库集成到同一个网页中,它们可能会增加变量名冲突的机会。作为良好的实践,我们应该尽量减少全局变量的使用。
在前面的 行动时间 部分,我们有一个对象来存储游戏数据。我们不是仅仅把这个对象放在全局作用域中,而是创建了一个名为 pingpong 的对象,并将数据放在里面。
此外,当我们像上一节讨论的那样将所有逻辑放入自执行函数中时,我们使游戏的数据对象在函数作用域内局部化。
注意
在函数作用域内声明变量而不使用 var,即使变量是在函数作用域内定义的,也会将变量放入全局作用域。因此,我们总是使用 var 声明变量。
突击测验
Q1. 如果你想选择所有标题元素,应该使用哪个 jQuery 选择器?
-
$("#header") -
$(".header") -
$("header") -
$(header)
获取鼠标输入
在前面的章节中,您学习了如何使用 CSS 和 jQuery 显示游戏对象。接下来,我们需要在游戏中创建一种从玩家那里获取输入的方法。在本节中,我们将讨论鼠标输入。
行动时间 - 通过鼠标输入移动 DOM 对象
我们将要创建一个传统的乒乓球游戏。游乐场的左右两侧都有一个挡板。一个球放在游乐场的中间。玩家可以通过使用鼠标控制右侧的挡板,上下移动它。我们将专注于鼠标输入,并将球的运动留到后面的章节:
-
让我们继续我们的
pingpong目录。 -
接下来,在
js/pingpong.js文件中的pingpong数据对象内添加一个playground对象。这个对象存储与playground相关的变量:// data definition var pingpong = { paddleA: { x: 50, y: 100, width: 20, height: 70 }, paddleB: { x: 320, y: 100, width: 20, height: 70 }, playground: { offsetTop: $("#playground").offset().top, } }; -
然后,创建以下处理鼠标的进入、移动和离开事件的函数,并将其放置在
js/pingpong.js文件中:function handleMouseInputs() { // run the game when mouse moves in the playground. $('#playground').mouseenter(function(){ pingpong.isPaused = false; }); // pause the game when mouse moves out the playground. $('#playground').mouseleave(function(){ pingpong.isPaused = true; }); // calculate the paddle position by using the mouse position. $('#playground').mousemove(function(e){ pingpong.paddleB.y = e.pageY - pingpong.playground.offsetTop; }); } -
在上一节中,我们有了
renderPaddles函数。在本节中,我们定义了一个render函数,并调用 paddle 渲染逻辑。然后我们通过requestAnimationFrame函数在下一个浏览器重绘时调用render函数。function render() { renderPaddles(); window.requestAnimationFrame(render); } -
最后,我们创建一个
init函数来执行初始逻辑。function init() { // view rendering window.requestAnimationFrame(render); // inputs handleMouseInputs(); } -
最后,你需要调用
init函数来启动我们的游戏逻辑:(function($){ // All our existing code // Execute the starting point init(); })(jQuery); -
让我们测试游戏中的
paddle控制。在网页浏览器中打开index.html页面。尝试在游乐场区域内上下移动鼠标。右侧的挡板应该跟随鼠标的移动。
发生了什么?
我们处理了鼠标事件,根据鼠标位置移动 paddle。您可以在makzan.net/html5-games/pingpong-wip-step3/上玩当前正在开发的游戏版本。
获取鼠标事件
jQuery 提供了几个实用的鼠标事件,其中最基本的是点击、鼠标按下和鼠标抬起。我们跟踪鼠标进入和鼠标离开事件来开始和暂停游戏。我们还使用鼠标移动事件来获取鼠标位置,并根据游乐场区域的鼠标位置更新 paddle 位置。
我们需要根据游乐场的左上角来获取光标的y位置。鼠标事件中的Y值是鼠标光标从页面左上角的位置。然后我们通过$("#playground").offset().top减去游乐场的位置。
我们通过使用鼠标的X和Y值来更新 paddle 的Y值数据。当在浏览器重绘期间,render函数更新 paddle 视图时,这个值最终会在屏幕上反映出来。
RequestAnimationFrame
时间间隔用于执行游戏循环。game循环计算游戏逻辑,计算游戏对象的运动。
requestAnimationFrame功能用于根据数据更新视图。我们使用requestAnimationFrame功能来更新视图,因为视图只需要在浏览器决定的最佳场景中更新。
requestAnimationFrame的间隔不是固定的。当浏览器处于前端时,requestAnimationFrame功能会频繁运行。当电池电量低或浏览器处于后台时,浏览器会降低requestAnimationFrame功能的执行频率。
我们现在只使用RequestAnimationFrame来处理与视图相关的逻辑。在后面的部分,我们需要处理游戏数据计算。对于数据计算,我们将使用setInterval,因为setInterval函数总是在固定的时间间隔内执行。这就是为什么我们使用setInterval函数进行游戏逻辑计算,而使用动画帧进行视图渲染。
检查控制台窗口
我们现在正在编写更复杂的逻辑代码。保持对开发者工具控制台的关注是一个好习惯。您可以通过在 Windows 上按F12或在 Mac OS 上按command + option + I来切换开发者工具。如果代码中包含任何错误或警告,错误信息将出现在那里。它报告任何找到的错误以及包含错误的代码行。在测试 HTML5 游戏时,打开控制台窗口非常有用且重要。我经常看到人们陷入困境,不知道代码为什么不起作用。原因可能是他们有拼写错误或语法错误,并且在几个小时与代码斗争之前没有检查控制台窗口。
以下截图显示,在js/pingpong.js文件的第二十五行存在一个错误。错误信息是赋值中的左侧无效。在检查代码后,我发现我在设置 jQuery 中的 CSS top属性时错误地使用了等号(=):
$("#paddleA").css("top"=top+5);
// instead of the correct code:
// $("#paddleA").css("top", top+5);
错误显示如下:

使用 JavaScript 间隔移动 DOM 对象
想象一下,现在我们可以让小红球在游乐场周围移动。当球击中挡板时,它会弹开。当球穿过对手的挡板并击中挡板后面的游乐场边缘时,玩家将获得分数。所有这些动作都是通过 jQuery 操作 HTML 页面内的 DIV 的位置来实现的。为了完成这个乒乓球游戏,我们的下一步是移动球。
行动时间 - 使用 JavaScript 间隔移动球
我们将使用该函数创建一个计时器。计时器每 30 毫秒移动球一点。我们还将改变球运动的方向,一旦它击中游乐场边缘。现在让我们让球移动起来:
-
我们将以我们最后的例子,监听多个键盘输入,作为起点。
-
在文本编辑器中打开
js/pingpong.js文件。 -
在现有的
pingpong.playground对象中,我们将其更改为以下代码,为游乐场添加高度和宽度。playground: { offsetTop: $("#playground").offset().top, height: parseInt($("#playground").height()), width: parseInt($("#playground").width()), }, -
我们现在正在移动球,并且需要全局存储球的状态。我们将球相关的变量放在
pingpong对象中:var pingpong = { //existing data ball: { speed: 5, x: 150, y: 100, directionX: 1, directionY: 1 } } -
我们定义了一个
gameloop函数,并在每个游戏循环迭代中移动球:function gameloop() { moveBall(); } -
我们定义了检查球是否撞击游乐场四个边界墙的函数:
function ballHitsTopBottom() { var y = pingpong.ball.y + pingpong.ball.speed * pingpong.ball.directionY; return y < 0 || y > pingpong.playground.height; } function ballHitsRightWall() { return pingpong.ball.x + pingpong.ball.speed * pingpong.ball.directionX > pingpong.playground.width; } function ballHitsLeftWall() { return pingpong.ball.x + pingpong.ball.speed * pingpong.ball.directionX < 0; } -
然后,我们定义了两个函数,在任意玩家获胜后重置游戏。
function playerAWin() { // reset the ball; pingpong.ball.x = 250; pingpong.ball.y = 100; // update the ball location variables; pingpong.ball.directionX = -1; } function playerBWin() { // reset the ball; pingpong.ball.x = 150; pingpong.ball.y = 100; pingpong.ball.directionX = 1; } -
是时候定义
moveBall函数了。该函数检查游乐场的边界,当球碰到边界时改变球的方向,并在所有这些计算之后设置新的球位置。让我们在 JavaScript 文件中放置以下moveBall函数定义:function moveBall() { // reference useful varaibles var ball = pingpong.ball; // check playground top/bottom boundary if (ballHitsTopBottom()) { // reverse direction ball.directionY *= -1; } // check right if (ballHitsRightWall()) { playerAWin(); } // check left if (ballHitsLeftWall()) { playerBWin(); } // check paddles here // update the ball position data ball.x += ball.speed * ball.directionX; ball.y += ball.speed * ball.directionY; } -
我们已经计算了球的移动。接下来,我们想要渲染视图,根据数据更新球的位置。为此,定义一个新的
renderBall函数,如下所示。function renderBall() { var ball = pingpong.ball; $("#ball").css({ "left" : ball.x + ball.speed * ball.directionX, "top" : ball.y + ball.speed * ball.directionY }); } -
现在,我们需要更新
render函数,以便根据更新的游戏数据渲染球的更新:function render() { renderBall(); renderPaddles(); window.requestAnimationFrame(render); } -
以下代码行是新的
init函数,我们在其中使用setInterval函数添加了gameloop逻辑:function init() { // set interval to call gameloop logic in 30 FPS pingpong.timer = setInterval(gameloop, 1000/30); // view rendering window.requestAnimationFrame(render); // inputs handleMouseInputs(); } -
我们已经准备好了每 33.3 毫秒移动一次球的代码。保存所有文件,并在网络浏览器中打开
index.html以测试它。球拍的工作方式与上一个例子一样,球应该在游乐场周围移动。
刚才发生了什么?
我们刚刚成功使球在游乐场周围移动。我们有一个每秒运行 30 次常规游戏逻辑的循环。在这个游戏循环中,我们每次移动球 5 像素。你可以尝试游戏,并在 makzan.net/html5-games/pingpong-wip-step6/ 上查看正在进行的代码。
球有三个属性:速度、以及 x 和 y 方向。速度定义了球在每一步移动多少像素。X/Y 方向的值要么是 1,要么是 -1。我们使用以下方程来移动球:
new_ball_x = ball_x_position + speed * direction_x
new_ball_y = ball_y_position + speed * direction_y
方向值乘以移动量。当方向是 1 时,球向轴的正方向移动。当方向是 -1 时,球向轴的负方向移动。通过切换 x 和 y 方向,我们可以使球在四个方向上移动。
我们将球的 X 和 Y 值与游乐场 DIV 元素的四个边缘进行比较。这检查球的下一个位置是否超出边界,然后,我们在 1 和 -1 之间切换方向以创建反弹效果。
使用 setInterval 函数创建 JavaScript 计时器
我们有一个计时器来循环并定期移动球。这可以通过 JavaScript 中的 setInterval 函数来完成。
这里是 setInterval 函数的一般定义:
setInterval(expression, milliseconds)
setInterval 函数需要两个必需的参数。额外的参数作为参数传递给函数:
| 参数 | 定义 | 讨论 |
|---|
| expression | 要执行的功能回调或代码表达式 | 表达式可以是一个功能回调的引用或内联代码表达式。内联代码表达式用引号引用,而功能回调的引用则不用。例如,以下代码每 100 毫秒调用一次hello函数:
setInterval(hello, 100);
以下代码每 100 毫秒调用一次hi函数,并传递参数:
setInterval("hi('Makzan')", 100);
|
milliseconds |
表达式每次执行之间的持续时间,以毫秒为单位 | 间隔的单位是毫秒。因此,将其设置为 1000 意味着每秒运行一次表达式。 |
|---|
理解游戏循环
我们有一个计时器,每 33.3 毫秒执行一些与游戏相关的代码,因此这些代码每秒执行 30 次。这种频率被称为每秒帧数,或 FPS。在游戏开发中,这个计时器被称为游戏循环。
在游戏循环中,我们将执行几个常见操作:
-
处理用户输入,这是我们刚才做的
-
更新游戏对象的状态,包括位置和外观
-
检查游戏是否结束
游戏循环中实际执行的内容在不同类型的游戏中有所不同,但目的相同。游戏循环定期执行以计算游戏数据。
分离数据和视图逻辑
我们已经分离了数据和视图逻辑。我们使用setInterval处理数据,使用requestAnimationFrame进行视图渲染。数据专注于所有游戏数据的计算,包括基于计算的对象尺寸和位置。视图逻辑专注于根据不断更新的游戏数据更新界面。
在我们的渲染函数中,视图更新 DOM 元素的 CSS。想象一下,如果我们稍后要在 Canvas 中渲染游戏或使用任何其他技术,我们的视图渲染逻辑可以使用特定方法根据相同的游戏数据渲染视图。游戏数据的计算独立于我们用于渲染游戏界面的技术。
开始碰撞检测
在上一节中移动球时,我们已经检查了游乐场的边界。现在,我们可以用键盘控制拍子,并观察球在游乐场中移动。现在缺少什么?我们无法与球互动。我们控制拍子,但球就像它们不存在一样穿过它们。这是因为我们遗漏了拍子和移动球之间的碰撞检测。
行动时间 – 用拍子击球
我们将使用类似于检查边界的检查方法来检查碰撞:
-
打开我们在上一节中使用的
js/pingpong.js文件。 -
在
moveBall函数中,我们已经在那里预留了放置碰撞检测代码的位置。找到带有//checkpaddleshere的行。 -
让我们把以下代码放在那里。该代码检查球是否与任一拍子重叠,并在它们重叠时将球弹开:
// Variables for checking paddles var ballX = ball.x + ball.speed * ball.directionX; var ballY = ball.y + ball.speed * ball.directionY; // check moving paddle here, later. // check left paddle if (ballX >= pingpong.paddleA.x && ballX < pingpong.paddleA.x + pingpong.paddleA.width) { if (ballY <= pingpong.paddleA.y + pingpong.paddleA.height && ballY >= pingpong.paddleA.y) { ball.directionX = 1; } } // check right paddle if (ballX + pingpong.ball.radius >= pingpong.paddleB.x && ballX < pingpong.paddleB.x + pingpong.paddleB.width) { if (ballY <= pingpong.paddleB.y + pingpong.paddleB.height && ballY >= pingpong.paddleB.y) { ball.directionX = -1; } } -
在浏览器中测试游戏,球现在在击中左右球拍后会弹开。当它击中游乐场的左右边缘时,它也会重置到游乐场的中心。
发生了什么?
我们通过使球在重叠球拍时弹开来修改了球。让我们看看我们是如何检查球和左球拍之间的碰撞的。
首先,我们检查球的 x 位置是否小于左球拍的右边沿。右边沿是 left 值加上球拍的 width。

然后,我们检查球的 y 位置是否在球拍的上边缘和下边缘之间。上边缘是 top 值,下边缘是 top 值加上球拍的 height。

如果球的位置通过了这两个检查,我们就让球弹开。这就是我们检查它的方法,它只是一个基本的碰撞检测。
我们通过检查它们的位置和宽/高来确定两个对象是否重叠。这种碰撞检测在矩形对象中效果很好,但不适用于圆形和其他形状。下面的截图说明了问题。以下图中显示的碰撞区域是假阳性。它们的边界框发生碰撞,但实际形状并没有重叠。这是一个经典且高效的方法来检查碰撞。它可能不是很准确,但计算速度快。

对于特殊形状,我们需要更高级的碰撞检测技术,我们将在后面讨论。
尝试一下英雄
我们已经在游乐场上放置了两个球拍。我们为什么不通过在中间场地上添加一个替代球拍来使游戏更具挑战性呢?这就像在足球机器中拥有守门员和前锋一样。
控制左球拍的运动
计算机控制左球拍。我们希望创建一个函数,使左球拍追逐球。
是时候自动移动左球拍了
为自动移动我们的球拍执行以下操作:
-
让我们继续使用我们的
pingpong.jsJavaScript 文件。我们创建一个函数来跟踪球的 y 位置。function autoMovePaddleA() { var speed = 4; var direction = 1; var paddleY = pingpong.paddleA.y + pingpong.paddleA.height/2; if (paddleY > pingpong.ball.y) { direction = -1; } pingpong.paddleA.y += speed * direction; } -
然后,在游戏循环函数内部,我们调用我们的
autoMovePaddleA函数。autoMovePaddleA();
发生了什么?
我们创建了一个基于球的 y 位置的逻辑来移动左球拍。你可以尝试使用当前进度在 makzan.net/html5-games/pingpong-wip-step6/ 玩游戏。
由于我们已经在 renderPaddles 函数中实现了视图渲染,在这个部分,我们只需要更新球拍的数据,视图将自动更新。
我们使球拍的速度慢于球的速度。否则,玩家永远无法战胜电脑,因为电脑控制的球拍可以始终接住球并将其弹回,如果它们具有相同的速度。
在 HTML 中动态显示文本
在前面的章节中,我们已经实现了基本的游戏机制。我们的乒乓球游戏缺少一个显示两名玩家分数的记分板。我们讨论了如何使用 jQuery 修改所选元素的 CSS 样式。我们也可以使用 jQuery 修改所选元素的内容吗?是的,我们可以。
行动时间 – 显示两名玩家的分数
我们将创建一个基于文本的记分板,并在任何一名球员得分时更新分数:
-
我们正在改进现有的游戏,以便我们可以使用最后一个示例作为起点。
-
在文本编辑器中打开
index.html。我们将添加记分板的 DOM 元素。 -
将
#scoreboardHTML 结构添加到 index.html 中的#gameDIV 内。#gameDIV 变成以下形式:<div id="game"> <div id="playground"> <div class="paddle-hand right"></div> <div class="paddle-hand left"></div> <div id="paddleA" class="paddle"></div> <div id="paddleB" class="paddle"></div> <div id="ball"></div> </div> <div id="scoreboard"> <div class="score"> A : <span id="score-a">0</span></div> <div class="score"> B : <span id="score-b">0</span></div> </div> </div> -
现在,让我们转到 JavaScript 部分。打开
js/pingpong.js文件。 -
我们需要两个额外的变量来存储玩家的分数。在现有的
pingpong数据对象中添加它们的分数变量:var pingpong = { scoreA : 0, // score for player A scoreB : 0, // score for player B // existing pingpong data goes here. } -
查找
playerAWin函数。我们在那里增加玩家 A 的分数并使用以下代码更新记分板:// player B lost. pingpong.scoreA += 1; $("#score-a").text(pingpong.scoreA); -
我们可以在上一步中添加类似的代码,在
playerBWin函数中更新玩家 B 的分数,当玩家 A 失败时:// player A lost. pingpong.scoreB += 1; $("#score-b").text(pingpong.scoreB); -
让我们转到
css/pingpong.css文件。将以下样式放入文件,使记分板看起来更美观:/* Score board */ #scoreboard { position: absolute; bottom: 0; left: 0; width: 100%; padding: 5px; color: lightgrey; } -
是时候测试我们最新的代码了。在网页浏览器中打开
index.html。尝试通过控制两个球拍来玩游戏并失去一些分数。记分板应该正确地计数。
刚才发生了什么?
我们刚刚使用了另一个常见的 jQuery 函数:text() 来实时更改游戏内容。
text() 函数获取或更新所选元素的文本内容。以下是 text() 函数的一般定义:
.text()
.text(string)
当我们使用不带参数的 text() 函数时,它返回匹配元素的文本内容。当我们使用参数时,它将所有匹配元素的文本内容设置为给定的字符串。
例如,提供以下 HTML 结构:
<p>My name is <span id="myname" class="name">Makzan</span>.</p>
<p>My pet's name is <span id="pet" class="name">
Co-co</span>.</p>
以下 jQuery 调用返回 Makzan:
$("#myname").text(); // returns Makzan
然而,在以下 jQuery 调用中,它将所有匹配元素设置为给定的 HTML 内容:
$(".name").text("Mr. Mystery")
执行 jQuery 命令会得到以下 HTML 结果:
<p>My name is <span id="myname" class="name">Mr. Mystery</span></p>
<p>My pet's name is <span id="pet" class="name">Mr. Mystery</span></p>
英雄尝试 – 赢得游戏
假设游戏是一个广告。我们设置整个游戏游乐场使用指针光标,以便用户知道游戏是可点击的,并链接到其他地方。尝试使用 jQuery 的 click 事件处理链接到 handleMouseInputs 函数的广告。
摘要
在本章中,你学习了使用 HTML5 和 JavaScript 创建简单乒乓球游戏的基本技巧。具体来说,我们创建了我们的第一个 HTML5 游戏——乒乓球。在游戏中,我们使用 jQuery 操作 DOM 对象。我们能够使用鼠标事件获取光标位置。我们使用一个非常简单的方法来检测与边界框的碰撞。在逻辑上,我们将数据处理和视图渲染分离。我们还讨论了如何创建游戏循环以及移动球和挡板。
现在我们通过创建一个简单的基于 DOM 的游戏已经热身,我们准备使用 CSS3 的新特性来创建更高级的基于 DOM 的游戏。在下一章中,我们将创建使用 CSS3 过渡和变换的游戏。
第三章. 使用 CSS3 构建卡牌匹配游戏
CSS3 引入了许多令人兴奋的特性。在本章中,我们将探索并使用其中的一些特性来创建卡牌匹配游戏。CSS3 决定了游戏对象的外观和动画,而 jQuery 库帮助我们定义游戏逻辑。
在本章中,我们将:
-
使用动画转换玩牌
-
使用新的 CSS3 属性翻转玩牌
-
创建整个记忆匹配游戏
-
在我们的游戏中嵌入自定义的 Web 字体
您可以通过以下 URL 尝试卡牌匹配游戏,以一窥本章我们将实现的内容:
makzan.net/html5-games/card-matching/
因此,让我们开始吧。
使用 CSS3 过渡移动游戏对象
在第一章 介绍 HTML5 游戏 中,当我们概述新的 CSS3 特性时,我们瞥见了 CSS3 的过渡和转换模块。我们经常希望通过平滑属性来动画化游戏对象。过渡是为此目的设计的 CSS 属性。假设我们在网页上有一个玩牌,并想在五秒内将其移动到另一个位置。我们不得不使用 JavaScript 设置计时器并编写自己的函数来每隔几毫秒改变位置。通过使用transition属性,我们只需要指定起始和结束样式以及持续时间。浏览器优化输出并完成所有缓和和中间动画。
让我们看看一些示例来理解这一点。
行动时间 – 移动玩牌
在这个例子中,我们将把两张玩牌放置在网页上,并将它们转换到不同的位置、缩放和旋转。我们将通过设置过渡来缓动转换:
-
要做到这一点,创建一个新的项目文件夹,其结构如下。
css3transition.css和index.html文件目前是空的,我们将在稍后添加代码。jquery-2.1.3.min.js文件是我们之前章节中使用的 jQuery 库。index.html js/ js/jquery-2.1.3.js css/ css/css3transition.css images/ -
在这个例子中,我们使用了两个玩牌图形图像。它们是
AK.png和AQ.png。这些图像可在代码包中找到,或者您可以从书籍资产网站上下载它们:mak.la/book-assets/。 -
将两张牌图像放入
images文件夹中。 -
下一步是编写带有两个卡牌 DIV 元素的 HTML 代码。当页面加载时,我们将对这些两个卡牌元素应用 CSS 过渡样式:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Getting Familiar with CSS3 Transition</title> <link rel="stylesheet" href="css/css3transition.css" /> </head> <body> <header> <h1>Getting Familiar with CSS3 Transition</h1> </header> <section id="game"> <div id="cards"> <div id="card1" class="card cardAK"></div> <div id="card2" class="card cardAQ"></div> </div> <!-- #cards --> </section> <!-- #game --> <footer> <p>This is an example of transitioning cards.</p> </footer> <script src="img/jquery-2.1.3.min.js"></script> <script> $(function(){ $("#card1").addClass("move-and-scale"); $("#card2").addClass("rotate-right"); }); </script> </body> </html> -
是时候通过 CSS 定义玩牌的视觉样式了。这包括基本的 CSS 2.1 属性和 CSS3 的新属性。在以下代码中,新的 CSS3 属性被突出显示:
body { background: LIGHTGREY; } /* defines styles for each card */ .card { width: 80px; height: 120px; margin: 20px; position: absolute; transition: all 1s linear; } /* set the card to corresponding playing card graphics */ .cardAK { background: url(../images/AK.png); } .cardAQ { background: url(../images/AQ.png); } /* rotate the applied DOM element 90 degree */ .rotate-right { transform: rotate3d(0, 0, 1, 90deg); } /* move and scale up the applied DOM element */ .move-and-scale { transform: translate3d(150px, 150px, 0) scale3d(1.5, 1.5, 1); } -
让我们保存所有文件并在浏览器中打开
index.html文件。两张牌应该像以下截图所示那样动画化:![行动时间 – 移动玩牌]()
发生了什么?
我们通过使用 CSS3 过渡来插值 transform 属性,创建了两个动画效果。
下面是 CSS transform 的用法:
transform: transform-function1 transform-function2;
transform 属性的参数是函数。有两组函数:2D 和 3D 变换函数。CSS transform 函数旨在移动、缩放、旋转和倾斜目标 DOM 元素。以下部分展示了变换函数的用法。
2D 变换函数
2D rotate 函数在给定的正参数上顺时针旋转元素,在给定的负参数上逆时针旋转元素:
rotate(angle)
translate 函数通过给定的 x 和 y 位移来移动元素:
translate (tx, ty)
我们可以通过调用 translateX 和 translateY 函数独立地平移 x 或 y 轴,如下所示:
translateX(number)
translateY(number)
scale 函数通过给定的 sx 和 sy 向量来缩放元素。如果我们只传递第一个参数,那么 sy 将与 sx 具有相同的值:
scale(sx, sy)
此外,我们可以独立地缩放 x 和 y 轴,如下所示:
scaleX(number)
scaleY(number)
3D 变换函数
3D 旋转函数通过给定的 [x, y, z] 单位向量在 3D 空间中旋转元素。例如,我们可以通过使用 rotate3d(0, 1, 0, 60deg) 来旋转 y 轴 60 度:
rotate3d(x, y, z, angle)
我们也可以通过调用以下便捷函数仅旋转一个轴:
rotateX(angle)
rotateY(angle)
rotateZ(angle)
与 2D translate 函数类似,translate3d 允许我们在三个轴上移动元素:
translate3d(tx, ty, tz)
translateX(tx)
translateY(ty)
translateZ(tz)
此外,scale3d 在 3D 空间中缩放元素:
scale3d(sx, sy, sz)
scaleX(sx)
scaleY(sy)
scaleZ(sz)
我们刚才讨论的 transform 函数是常见的,我们将多次使用它们。还有一些其他未讨论的 transform 函数,它们是 matrix、skew 和 perspective。
如果你想找到最新的 CSS 变换工作规范,你可以访问 W3C CSS 变换模块网站:www.w3.org/TR/css3-3d-transforms/。
使用 CSS3 过渡插值样式
CSS3 中有大量新特性。过渡模块是其中之一,它对游戏设计影响最大。
什么是 CSS3 过渡?W3C 用一句话解释它:
CSS 过渡允许 CSS 值在指定的时间内平滑地改变。
通常,当我们更改元素的任何属性时,属性会立即更新到新值。过渡会减慢变化过程。它在给定的时间内创建从旧值到新值的平滑过渡。
下面是 transition 属性的用法:
transition: property_name duration timing_function delay
下表解释了在 transition 属性中使用的每个参数:
| 参数 | 定义 |
|---|---|
property_name |
这是应用过渡的属性名称。它可以设置为 all。 |
duration |
这表示过渡所需要的时间。 |
timing_function |
timing函数定义了起始值和结束值之间的插值。默认值是ease。通常,我们会使用ease、ease-in、ease-out和linear。 |
delay |
delay参数将过渡的开始延迟给定秒数。 |
我们可以在一行中放置多个transition属性。例如,以下代码在 0.3 秒内过渡不透明度,在 0.5 秒内过渡背景颜色:
transition: opacity 0.3s, background-color 0.5s
我们也可以通过以下属性单独定义每个过渡属性:
transition-property, transition-duration, transition-timing-function and transition-delay
小贴士
CSS3 模块
根据 W3C 的说法,CSS3 与 CSS 2.1 不同,因为 CSS 2.1 只有一个规范。CSS3 被划分为不同的模块。每个模块都是单独审查的。例如,有一个过渡模块、2D/3D 转换模块和弹性盒子布局模块。
将规范划分为模块的原因是因为 CSS3 各部分的进展速度并不相同。一些 CSS3 特性相对稳定,例如边框半径,而一些尚未稳定。将整个规范划分为不同的部分允许浏览器厂商支持稳定的模块。在这种情况下,进展缓慢的特性不会减慢整个规范的进展。CSS3 规范的目标是标准化网络设计中最常见的视觉用法,而这个模块符合这一目标。
尝试一下英雄
我们已经对扑克牌进行了平移、缩放和旋转。那么我们尝试在示例中更改不同的值如何?rotate3d函数中有三个轴。如果我们旋转其他轴会发生什么?通过自己实验代码来熟悉转换和过渡模块。
创建翻牌效果
想象一下,我们现在不仅要在周围移动扑克牌,还想翻转卡片元素,就像我们翻转一张真实的扑克牌一样。通过使用rotation transform函数,现在可以创建翻牌效果。
行动时间 - 使用 CSS3 翻转卡片
我们将开始一个新的项目,并在点击扑克牌时创建翻牌效果:
-
让我们继续使用之前的代码示例。
-
现在卡片包含两个面:正面和背面。在 HTML 代码的
body标签中替换以下代码:<section id="game"> <div id="cards"> <div class="card"> <div class="face front"></div> <div class="face back cardAK"></div> </div> <!-- .card --> <div class="card"> <div class="face front"></div> <div class="face back cardAQ"></div> </div> <!-- .card --> </div> <!-- #cards --> </section> <!-- #game --> <script src="img/jquery-2.1.3.min.js"></script> -
然后,在
css文件夹中创建一个新的css3flip.css文件来测试翻转效果。 -
在
index.html文件中,将 CSS 外部链接更改为css3flip.css文件:<link rel="stylesheet" href="css/css3flip.css" /> -
现在,让我们向
css3flip.css添加样式:#game { background: #9c9; padding: 5px; } /* Define the 3D perspective view and dimension of each card. */ .card { perspective: 600px; width: 80px; height: 120px; } -
每张卡片都有两个面。我们将逐渐旋转卡片的面。因此,我们通过 CSS3 的
transition属性定义面的过渡。我们还隐藏了背面可见性。我们稍后会看到这个属性的详细内容:.face { border-radius: 10px; width: 100%; height: 100%; position: absolute; transition: all .3s; backface-visibility: hidden; } -
现在,是时候为每个单独的面添加样式了。正面比背面有更高的 z-index:
.front { background: #966; } .back { background: #eaa; transform: rotate3d(0,1,0,-180deg); } -
当我们翻转卡片时,我们将正面旋转到背面,将背面旋转到正面。我们还交换了正面和背面的 z-index:
.card-flipped .front { transform: rotate3d(0,1,0,180deg); } .card-flipped .back { transform: rotate3d(0,1,0,0deg); } .cardAK { background: url(../images/AK.png); } .cardAQ { background: url(../images/AQ.png); } -
接下来,我们将在加载 jQuery 库后添加逻辑,以便在点击卡片时切换卡片翻转状态:
<script> (function($){ $(function(){ $("#cards").children().each(function(index) { // listen the click event on each card DIV element. $(this).click(function() { // add the class "card-flipped". // the browser will animate the styles // between current state and card-flipped state. $(this).toggleClass("card-flipped"); }); }); }); })(jQuery); </script> -
样式和脚本现在都已准备好。让我们保存所有文件,并在我们的网页浏览器中预览。点击玩牌来翻转它,再点击一次来翻转回来。
![动作时间 – 使用 CSS3 翻转卡片]()
发生了什么?
我们创建了一个可以通过鼠标点击切换的卡片翻转效果。你可以在makzan.net/html5-games/simple-card-flip/上尝试该示例。
该示例使用了多个 CSS 转换属性和 JavaScript 来处理鼠标点击事件。
使用 jQuery 的 toggleClass 函数切换类
当鼠标点击卡片时,我们将card-flipped类应用到卡片元素上。在第二次点击时,我们希望移除应用的card-flipped样式,以便卡片再次翻转。这被称为切换类样式。
jQuery 提供了一个方便的函数toggleClass,可以根据类是否应用来自动添加或移除类。
要使用该函数,我们只需将想要切换的类作为参数传递。
例如,以下代码为 ID 为card1的元素添加或移除card-flipped类:
$("#card1").toggleClass("card-flipped");
toggleClass函数可以同时接受多个类的切换指令。我们可以传入多个类名,并用空格分隔它们。以下是一个同时切换两个类的示例:
$("#card1").toggleClass("card-flipped scale-up");
介绍 CSS 的 perspective 属性
CSS3 让我们能够在 3D 空间中呈现元素,并且我们已经能够将元素在 3D 空间中转换。perspective属性定义了 3D 视角视图的外观。你可以将值视为你观察对象时的距离。你越靠近,观察对象上的透视扭曲就越多。
以下两个 3D 立方体演示了不同的视角值如何改变元素的视角:

尝试一下
立方体是通过将六个面放在一起并应用每个面的 3D 转换来创建的。它使用了我们讨论过的技术。尝试创建一个立方体并实验一下perspective属性。
以下网页对创建 CSS3 立方体的过程进行了全面解释,并说明了如何使用键盘控制立方体的旋转:
paulrhayes.com/experiments/cube-3d/
介绍 backface-visibility 属性
在backface-visibility属性被引入之前,页面上的所有元素都向访客展示了它们的正面。实际上,元素的前面或背面没有概念,因为展示正面是唯一的选择。当 CSS3 引入了三轴旋转时,我们可以旋转一个元素,使其面朝后。试着看看你的手掌,然后旋转你的手腕,你的手掌转动,你看到了手掌的背面。旋转的元素也会发生这种情况。
CSS3 引入了一个名为backface-visibility的属性来定义我们是否可以看到元素的背面。默认情况下,它是可见的。以下图示展示了backface-visibility属性的两种不同行为:

注意
你可以在其官方 Webkit 博客上阅读有关 CSS 3D 变换中不同属性和函数的更详细的信息:webkit.org/blog/386/3d-transforms/。
创建一个卡牌匹配记忆游戏
我们已经学习了一些基本的 CSS 技术。现在,让我们使用这些技术来制作一个游戏。我们将制作一个卡牌游戏。这个卡牌游戏将使用变换来翻转卡片,使用过渡来移动卡片,使用 JavaScript 来处理逻辑,以及使用一个新的 HTML5 特性,称为自定义数据属性来存储自定义数据。不用担心,我们将逐步讨论每个组件。
下载牌的精灵图
在卡牌翻转示例中,我们使用了两张不同牌的图形。现在,我们将为整副牌准备图形。虽然我们在匹配游戏中只会使用六张牌,但我们仍会准备整副牌,这样我们就可以在其他可能创建的卡牌游戏中重用这些图形。
一副牌中有 52 张牌,我们还有一个用于背面的图形。与其使用 53 个单独的文件,不如将单独的图形放入一个大的精灵图文件中。精灵图是一种图形技术,它将一个图形的纹理加载到内存中,并为每个游戏组件显示图形的一部分。
使用大精灵图而不是单独的图像文件的一个好处是我们可以减少 HTTP 请求的数量。当浏览器加载网页时,它会创建一个新的 HTTP 请求来加载每个外部资源,包括 JavaScript 文件、CSS 文件和图像。为每个分离的小文件建立新的 HTTP 请求需要相当多的时间。将图形组合到一个文件中,大大减少了请求的数量,从而提高了游戏在浏览器中加载时的响应速度。
将图形放在一个文件中的另一个好处是避免文件格式标题的开销并减少 HTTP 请求的数量。加载包含 53 个图像的精灵图的时间比加载 53 个不同图像(每个文件都有文件标题)的时间要快。
以下一副扑克牌的图形是在 Adobe Illustrator 中绘制并对齐的。您可以从mak.la/deck.png下载它。
注意
您可以使用 Instant Sprite Generator (instantsprite.com)创建自己的精灵图集。在css-tricks.com/css-sprites/上的文章详细解释了为什么以及如何创建和使用 CSS 精灵图集。

设置游戏环境
一旦图形准备就绪,我们需要设置一个静态页面,其中包含准备好的并放置在游戏区域中的游戏对象。这样,稍后添加游戏逻辑和交互就更容易了:
行动时间 - 准备卡片匹配游戏
在向我们的匹配游戏添加复杂的游戏逻辑之前,让我们准备 HTML 游戏结构和所有 CSS 样式:
-
让我们继续我们的项目。在
js文件夹内创建一个名为matchgame.js的新文件。 -
将
index.html文件替换为以下 HTML 代码:<!DOCTYPE html> <html lang="en"> <head> <meta charset=utf-8> <title>CSS3 Matching Game</title> <link rel="stylesheet" href="css/matchgame.css" /> </head> <body> <header> <h1>CSS3 Matching Game</h1> </header> <section id="game"> <div id="cards"> <div class="card"> <div class="face front"></div> <div class="face back"></div> </div> <!-- .card --> </div> <!-- #cards --> </section> <!-- #game --> <footer> <p>This is an example of creating a matching game with CSS3.</p> </footer> <script src="img/jquery-2.1.3.min.js"></script> <script src="img/matchgame.js"></script> </body> </html> -
为了使游戏更具吸引力,我准备了游戏桌和页面的背景图像。这些图形资产可以在代码示例包中找到。背景图像是可选的,它们不会影响游戏玩法和匹配游戏的逻辑。
-
我们还将扑克牌的精灵图集图形放入图像文件夹。然后,我们将从
mak.la/deck.png下载deck.png文件,并将其保存在图像文件夹中。 -
为我们的游戏创建一个专门的 CSS 文件,命名为
matchgame.css,并将其放入css文件夹中。 -
现在,在我们编写任何逻辑之前,让我们为匹配游戏添加样式。打开
matchgame.css并添加以下 body 样式:body { text-align: center; background: BROWN url(../images/bg.jpg); } -
我们将继续为
game元素添加样式。这将游戏的主要区域:#game { border-radius: 10px; border: 1px solid GRAY; background: DARKGREEN url(../images/table.jpg); width: 500px; height: 460px; margin: 0 auto; display: flex; justify-content: center; align-items: center; } -
我们将所有卡片元素放入一个名为
cards的父 DOM 中。通过这样做,我们可以轻松地将所有卡片在游戏区域中居中:#cards { position: relative; width: 380px; height: 400px; } -
对于每张卡片,我们定义一个
perspective属性来给它一个视觉深度效果:.card { perspective: 600px; width: 80px; height: 120px; position: absolute; transition: all .3s; } -
每张卡片有两个面。背面将在稍后旋转,我们将定义过渡属性来动画化样式变化。我们还想确保背面是隐藏的:
.face { border-radius: 10px; width: 100%; height: 100%; position: absolute; transition-property: opacity, transform, box-shadow; transition-duration: .3s; backface-visibility: hidden; } -
现在,我们将设置正面和背面的样式。它们几乎与翻牌示例相同,除了我们现在为它们提供背景图像和阴影盒:
.front { background: GRAY url(../images/deck.png) 0 -480px; } .back { background: LIGHTGREY url(../images/deck.png); transform: rotate3d(0,1,0,-180deg); } .card:hover .face, .card-flipped .face { box-shadow: 0 0 10px #aaa; } .card-flipped .front { transform: rotate3d(0,1,0,180deg); } .card-flipped .back { transform: rotate3d(0,1,0,0deg); } -
当任何卡片被移除时,我们希望它淡出。因此,我们声明一个具有 0 不透明度的
card-removed类:.card-removed { opacity: 0; } -
为了从扑克牌图集的精灵图中显示不同的扑克牌图形,我们将卡片的背景裁剪到不同的背景位置:
.cardAJ {background-position: -800px 0;} .cardAQ {background-position: -880px 0;} .cardAK {background-position: -960px 0;} .cardBJ {background-position: -800px -120px;} .cardBQ {background-position: -880px -120px;} .cardBK {background-position: -960px -120px;} .cardCJ {background-position: -800px -240px;} .cardCQ {background-position: -880px -240px;} .cardCK {background-position: -960px -240px;} .cardDJ {background-position: -800px -360px;} .cardDQ {background-position: -880px -360px;} .cardDK {background-position: -960px -360px;} -
我们已经定义了很多 CSS 样式。现在是时候为 JavaScript 的逻辑编写代码了。我们将打开
js/matchgame.js文件,并在其中放入以下代码:$(function(){ // clone 12 copies of the card for(var i=0; i<11; i++){ $(".card:first-child").clone().appendTo("#cards"); } // initialize each card's position $("#cards").children().each(function(index) { // align the cards to be 4x3 ourselves. var x = ($(this).width() + 20) * (index % 4); var y = ($(this).height() + 20) * Math.floor(index / 4); $(this).css("transform", "translateX(" + x + "px) translateY(" + y + "px)"); }); }); -
现在,我们将保存所有文件并在浏览器中预览游戏。游戏应该样式良好,并且应该有 12 张卡片出现在中央。然而,我们目前还不能点击卡片,因为我们还没有为卡片设置任何交互逻辑。
![行动时间 – 准备卡片匹配游戏]()
发生了什么?
我们在 HTML 中创建了游戏结构,并应用了样式到 HTML 元素上。您可以在 makzan.net/html5-games/card-matching-wip-step1/ 找到当前进度下游戏的运行示例。
当网页加载并准备就绪后,我们也使用了 jQuery 在游戏区域创建 12 张卡片。翻动和移除卡片的效果样式也已准备就绪,稍后将通过游戏逻辑应用到卡片上。
由于我们为每个卡片使用绝对定位,因此需要我们自己将卡片对齐成 4x3 的瓷砖。在 JavaScript 逻辑中,我们通过循环遍历每个卡片,通过计算循环索引来对齐它:
$("#cards").children().each(function(index) {
// align the cards to be 4x3 ourselves.
var x = ($(this).width() + 20) * (index % 4);
var y = ($(this).height() + 20) * Math.floor(index / 4);
$(this).css("transform", "translateX(" + x + "px) translateY(" + y + "px)");
});
JavaScript 中的 % 符号是 取模运算符,它返回除法后的余数。余数用于在循环卡片时获取列数。以下图表显示了索引号与行/列之间的关系:

另一方面,除法用于获取行数,这样我们就可以将卡片定位在相应的行上。
以索引 3 为例;3 % 4 等于 3。因此,索引 3 的卡片位于第三列。而 3 / 4 等于 0,所以它位于第一行。
让我们选择另一个数字来看看公式是如何工作的。让我们看看索引 8;8 % 4 等于 0,它位于第一列。8 / 4 等于 2,所以它位于第三行。
使用 jQuery 克隆 DOM 元素
在我们的 HTML 结构中,我们只有一个卡片,而在结果中我们有 12 张卡片。这是因为我们使用了 jQuery 中的 clone 函数来克隆卡片元素。在克隆目标元素后,我们调用了 appendTo 函数将克隆的卡片元素作为子元素添加到卡片元素中:
$(".card:first-child").clone().appendTo("#cards");
使用子元素过滤器在 jQuery 中选择元素的第一个子元素
当我们选择卡片元素并克隆它时,我们使用了以下选择器:
$(".card:first-child")
:first-child 过滤器是一个 子元素过滤器,它选择给定父元素的第一个子元素。
除了 :first-child,我们还可以通过使用 :last-child 来选择最后一个子元素。
注意
您也可以在 jQuery 文档中检查其他与子元素相关的选择器:api.jquery.com/category/selectors/child-filter-selectors/。
垂直对齐 DOM 元素
我们将卡片 DIV 放在游戏元素的中央。CSS3 弹性盒布局模块引入了一种简单的方法来实现 垂直居中对齐,如下所示:
display: flex;
justify-content: center;
align-items: center;
弹性盒子模块定义了容器中有额外空间时元素的排列。我们可以通过使用 CSS2 属性display,并设置为box值,以及一个新的 CSS3 属性值,将元素设置为灵活盒子容器的特定行为。
justify-content和align-items是两个属性,用于定义如何对齐以及水平和垂直方向上如何使用额外的空闲空间。我们可以通过将这两个属性都设置为center来居中元素。
垂直对齐只是弹性盒子布局模块的一部分。在网页设计中制作布局时,它非常强大。你可以在 W3C 模块页面([www.w3.org/TR/css3-flexbox/](http://www.w3.org/TR/css3-flexbox/))或 CSS3 技巧网站上找到更多关于该模块的信息([http://css-tricks.com/snippets/css/a-guide-to-flexbox/](http://css-tricks.com/snippets/css/a-guide-to-flexbox/))。
使用 CSS 精灵和背景位置
CSS 精灵表是一个包含许多单独图形的大图像。大精灵表图像被应用于元素的背景图像。我们可以通过将背景位置移动到固定宽度和高度的元素中,来裁剪出每个图形。
我们牌组的图像包含总共 53 个图形。为了更容易地演示背景位置,让我们假设我们有一个包含三张牌图像的图像,如下面的截图所示:

在 CSS 样式表中,我们将卡片元素设置为宽度 80 像素和高度 120 像素,并将背景图像设置为大牌组图像。如果我们想要左上角的图形,我们将背景位置中x轴和y轴的值都设置为 0。如果我们想要第二个图形,我们将背景图像向左移动 80 像素。这意味着将 X 位置设置为-80px,Y 位置设置为 0。由于我们有一个固定的宽度和高度,只有裁剪的 80 x 120 区域显示了背景图像。以下截图中的矩形显示了可查看区域:

将游戏逻辑添加到匹配游戏中
现在让我们想象一下,我们手中拿着一副真实的牌,并设置匹配游戏。
我们首先在我们的手中洗牌,然后将每张牌背面朝上放在桌子上。为了使游戏更容易进行,我们将牌排列成 4 x 3 的数组。现在,游戏已经设置好了,我们准备开始玩。
我们拿起一张牌并翻转它使其正面朝上。我们再拿起一张牌并使其向上。之后,我们有两种可能的行为。如果两张牌的图案相同,我们就拿走这两张牌。否则,我们将它们放回背面朝下,就像我们从未触摸过它们一样。游戏继续进行,直到我们配对所有的牌。
在我们心中有了这个逐步场景之后,代码流程将更加清晰。实际上,这个例子中的代码与我们用真实牌玩的过程完全相同。我们只需要将人类语言替换成 JavaScript 代码。
行动时间 – 向配对游戏添加游戏逻辑
在上一个示例中,我们已经准备好了游戏环境,并决定游戏逻辑应该与玩一副真实的牌相同。现在是时候编写 JavaScript 逻辑了:
-
让我们从我们最后的配对游戏示例开始。我们已经设置了 CSS,现在,是时候在
js/matchgame.js文件中添加游戏逻辑了。 -
游戏是要匹配一副扑克牌的配对。我们现在有 12 张牌,所以我们需要六对扑克牌。将以下代码放入
js/matchgame.js文件中。数组声明了六对卡片模式:var matchingGame = {}; matchingGame.deck = [ 'cardAK', 'cardAK', 'cardAQ', 'cardAQ', 'cardAJ', 'cardAJ', 'cardBK', 'cardBK', 'cardBQ', 'cardBQ', 'cardBJ', 'cardBJ', ]; -
我们在上一章的 jQuery 的
ready函数中排列了卡片。现在我们需要在ready函数中准备和初始化更多的代码。为此,将ready函数更改为以下代码。更改后的代码在此处突出显示:$(function(){ matchingGame.deck.sort(shuffle); for(var i=0;i<11;i++){ $(".card:first-child").clone().appendTo("#cards"); } $("#cards").children().each(function(index) { var x = ($(this).width() + 20) * (index % 4); var y = ($(this).height() + 20) * Math.floor(index / 4); $(this).css("transform", "translateX(" + x + "px) translateY(" + y + "px)"); // get a pattern from the shuffled deck var pattern = matchingGame.deck.pop(); // visually apply the pattern on the card's back side. $(this).find(".back").addClass(pattern); // embed the pattern data into the DOM element. $(this).attr("data-pattern",pattern); // listen the click event on each card DIV element. $(this).click(selectCard); }); }); -
与玩一副真实的牌类似,我们首先想做的事情是洗牌。为此,我们需要在 JavaScript 文件中添加以下
shuffle函数:function shuffle() { return 0.5 - Math.random(); } -
当我们点击卡片时,我们翻转它并安排检查函数。因此,我们必须将以下代码附加到 JavaScript 文件中:
function selectCard() { // we do nothing if there are already two card flipped. if ($(".card-flipped").size() > 1) { return; } $(this).addClass("card-flipped"); // check the pattern of both flipped card 0.7s later. if ($(".card-flipped").size() === 2) { setTimeout(checkPattern,700); } } -
当两张卡片被打开时,以下函数被执行。它控制我们是否移除卡片或将其翻转回来:
function checkPattern() { if (isMatchPattern()) { $(".card-flipped").removeClass("card-flipped").addClass("card-removed"); $(".card-removed").bind("transitionend",removeTookCards); } else { $(".card-flipped").removeClass("card-flipped"); } } -
现在是模式检查函数的时间。以下函数访问打开卡片的自定义模式属性,并将它们进行比较,看它们是否属于同一模式:
function isMatchPattern() { var cards = $(".card-flipped"); var pattern = $(cards[0]).data("pattern"); var anotherPattern = $(cards[1]).data("pattern"); return (pattern === anotherPattern); } -
匹配的卡片淡出后,我们执行以下函数来移除卡片:
function removeTookCards() { $(".card-removed").remove(); } -
游戏逻辑现在准备好了。让我们在浏览器中打开游戏的 HTML 文件并玩游戏。记得检查 开发者工具 中的控制台窗口,看看是否有任何错误。
以下截图显示了 CSS3 卡片配对游戏:

发生了什么?
我们已经编写了 CSS3 配对游戏的逻辑。这个逻辑为扑克牌添加了鼠标点击交互,并控制了模式检查的流程。你可以尝试游戏并查看完整的源代码在 makzan.net/html5-games/card-matching-wip-step2/。
CSS 过渡结束后执行代码
游戏结束后,我们移除了配对的卡片。我们可以通过使用 transitionend 事件来安排在过渡结束后执行一个函数。以下来自我们代码示例的代码片段给配对的卡片添加了一个 card-removed 类以启动过渡。然后,它将 transitionend 事件绑定到移除卡片,包括 DOM,之后:
$(".card-flipped").removeClass("card-flipped").addClass("card-removed");
$(".card-removed").bind("transitionend", removeTookCards);
翻转卡片后的代码执行延迟
游戏逻辑流程的设计方式与玩真实牌组相同。一个很大的不同之处在于我们使用了几个setTimeout函数来延迟代码的执行。当点击第二张牌时,我们通过以下代码示例片段安排checkPattern函数在 0.7 秒后执行:
if ($(".card-flipped").size() == 2) {
setTimeout(checkPattern, 700);
}
我们延迟函数调用的原因是为了给玩家留出时间来记住牌型。
在 JavaScript 中随机化数组
JavaScript 中没有内置的数组随机化函数。我们必须自己编写。幸运的是,我们可以从内置的数组排序函数中获得帮助。
下面是sort函数的用法:
sort(compare_function);
sort函数接受一个可选参数:
| 参数 | 定义 | 讨论 |
|---|---|---|
compare_function |
一个定义数组排序顺序的函数。compare_function需要两个参数。 |
sort函数通过使用compare函数比较数组中的两个元素。因此,compare函数需要两个参数。当compare函数返回任何大于 0 的值时,它将第一个参数放在比第二个参数低的索引处。当返回值小于 0 时,它将第二个参数放在比第一个参数低的索引处。 |
这里的技巧是我们使用了返回介于-0.5 和 0.5 之间的随机数的compare函数:
anArray.sort(shuffle);
function shuffle(a, b) {
return 0.5 - Math.random();
}
通过在compare函数中返回一个随机数,sort函数以不一致的方式对相同的数组进行排序。换句话说,我们洗牌。
注意
来自 Mozilla 开发者网络的以下链接提供了关于使用sort函数的详细解释,包括示例:
developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/sort
使用 HTML5 自定义数据属性存储内部自定义数据
我们可以通过使用自定义数据属性在 DOM 元素中存储自定义数据。我们可以使用data-前缀创建一个自定义属性名,并为其分配一个值。
例如,我们可以在以下代码中将自定义数据嵌入到列表元素中:
<ul id="games">
<li data-chapter="2" data-difficulty="easy">Ping-Pong</li>
<li data-chapter="3" data-difficulty="medium">Matching Game</li>
</ul>
这是 HTML5 规范中提出的新功能。根据 W3C 的说法,自定义数据属性旨在存储私有于页面或应用程序的自定义数据,对于这些数据没有更适合的属性或元素。
W3C 还指出,这个自定义数据属性“旨在由网站的脚本使用,而不是一个通用的扩展机制,用于公开可用的元数据。”
我们正在编写匹配游戏,并将我们自己的数据嵌入到牌元素中;因此,自定义数据属性非常适合我们的使用。
我们使用自定义属性在每张牌中存储牌型,以便我们可以通过比较模式值来检查 JavaScript 中翻开的牌是否匹配。此外,模式还用于将玩牌样式化为相应的图形:
$(this).find(".back").addClass(pattern);
$(this).attr("data-pattern",pattern);
突击测验
Q1. 根据 W3C 关于自定义数据属性的指南,以下哪个陈述是正确的?
-
我们可以创建一个
data-link属性来存储css标签的链接。 -
我们可以访问第三方游戏门户网站中的自定义数据属性。
-
我们可以在每个玩家的 DOM 元素中存储
data-score属性,以在我们的网页中排序排名。 -
我们可以在每个玩家的 DOM 元素中创建一个
ranking属性来存储排名数据。
使用 jQuery 访问自定义数据属性
在匹配游戏示例中,我们使用了 jQuery 库中的 attr 函数来访问我们的自定义数据:
pattern = $(this).attr("data-pattern");
attr 函数返回给定属性名的值。例如,我们可以通过调用以下代码来获取所有 a 标签中的链接:
$("a").attr("href");
对于 HTML5 自定义数据属性,jQuery 为我们提供了一个额外的函数来访问 HTML5 自定义数据属性。这个函数就是 data 函数。
data 函数是为了将自定义数据嵌入到 HTML 元素的 jQuery 对象中而设计的。它是在 HTML5 自定义数据属性之前设计的。
这里是 data 函数的使用方法:
.data(key)
.data(key,value)
data 函数接受两种类型的函数:
| 函数类型 | 参数定义 | 讨论 |
|---|---|---|
.data(key) |
key 参数是一个字符串,用于命名数据条目。 |
当只提供 key 参数时,data 函数读取与 jQuery 对象关联的数据,并返回相应的值。在最近的 jQuery 更新中,此函数扩展以支持 HTML5 自定义数据属性。 |
.data(key, value) |
key 参数是一个字符串,用于命名数据条目。value 参数是要与 jQuery 对象关联的数据。 |
当同时提供 key 和 value 参数时,data 函数将新的数据条目设置到 jQuery 对象中。value 参数可以是任何 JavaScript 类型,包括数组和对象。 |
为了支持 HTML5 自定义数据属性,jQuery 扩展了 data 函数,使其能够访问在 HTML 代码中定义的自定义数据。
现在,让我们看看我们如何使用 data 函数。考虑以下 HTML 代码中的一行:
<div id="target" data-custom-name="HTML5 Games"></div>
现在,使用前面的代码行,我们可以通过在 jQuery 中调用 data 函数来访问 data-custom-name 属性:
$("#target").data("customName")
这将返回 "HTML5 Games"。
注意
请记住,attr 方法总是会返回一个字符串值。然而,data 方法会尝试将 HTML 字符串值转换为 JavaScript 值,例如数字或布尔值。
快速问答
Q1. 给定以下 HTML 代码,哪些 jQuery 语句读取自定义分数数据并以整数格式返回 100?
<div id="game" data-score="100"></div>
-
$("#game").attr("data-score"); -
$("#game").attr("score"); -
$("#game").data("data-score"); -
$("#game").data("score");
尝试一下英雄
我们已经创建了 CSS3 匹配游戏。那么,这里缺少了什么?游戏逻辑没有检查游戏是否结束。当游戏结束时,尝试添加“你赢了!”的文本。你还可以通过使用本章讨论的技术来动画化文本。
制作其他扑克牌游戏
这种 CSS3 扑克牌方法适合创建卡片游戏。卡片有两面,适合翻动。过渡特性适合移动卡片。有了移动和翻动,我们只需定义游戏规则,就能充分利用卡片游戏。
尝试一下英雄
你能使用扑克牌图形和翻动技术来创建另一个游戏吗?比如扑克?
将网络字体嵌入到我们的游戏中
多年来,我们一直在使用有限的字体来设计网页。我们无法使用我们想要的任何字体,因为浏览器从访客的本地机器加载字体,我们无法控制或确保访客拥有我们想要的字体。
尽管我们可以将网络字体嵌入到 Internet Explorer 5 中,但格式有限,我们必须等待浏览器厂商支持嵌入最常见的 TrueType 字体格式。
想象一下,我们可以通过嵌入不同风格的网络字体来控制游戏的情绪。然后我们可以用我们想要的字体设计游戏,并更多地控制游戏的吸引力。让我们尝试将网络字体嵌入到我们的记忆匹配游戏中。
开始行动——从 Google 字体目录嵌入字体
Google Fonts目录是一个提供免费使用网络字体的网络字体服务。我们将嵌入从 Google Fonts 目录选择的网络字体:
-
前往
google.com/fonts的 Google 字体目录网站。 -
在字体目录中,有一个列表,列出了在开源许可下可用的网络字体,可以免费使用。
-
选择其中一个,然后点击字体名称以进入下一步。在这个例子中,我使用了Droid Serif。
-
在你点击字体后,字体目录会显示关于该字体的详细信息。在这里,我们可以执行几个操作,例如预览字体、选择变体,最重要的是,获取字体嵌入代码。
-
在 MAC 上,点击获取代码标签,你会看到以下截图;这显示了如何将此字体嵌入到我们的网页中的指南。或者,在 Windows 上,你可以点击使用标签,你会找到获取代码的说明:
![开始行动——从 Google 字体目录嵌入字体]()
-
复制 Google 提供的
link标签,并将其粘贴到 HTML 代码中。这应该放在任何其他样式定义之前:<link href='http://fonts.googleapis.com/css?family=Droid+Serif:regular,bold&subset=latin' rel='stylesheet' type='text/css'> -
现在,我们可以使用字体来设置我们的文本样式。将 body 的字体家族属性设置为以下代码:
body { font-family: 'Droid Serif', Arial, serif; } -
保存所有文件并打开
index.html文件。浏览器将从谷歌服务器下载字体并将其嵌入到网页中。请注意字体;它们应该被加载并渲染为我们所选的谷歌字体。嵌入谷歌字体目录中的字体 - 行动时间
发生了什么?
我们刚刚使用一个不常见的网络字体来设计我们的游戏。该字体由谷歌字体目录托管并交付。
除了使用字体目录外,我们还可以通过使用@font face来嵌入我们的字体文件。以下链接提供了一个嵌入字体的安全方法:
www.fontspring.com/blog/the-new-bulletproof-font-face-syntax
小贴士
在嵌入字体之前检查字体许可
通常,字体许可并不涵盖其在网页上的使用。在嵌入字体之前,请务必检查许可。谷歌字体目录中列出的所有字体都受开源许可协议的约束,可以在任何网站上使用。您可以在www.google.com/fonts/attribution查看目录中列出的单个字体的许可。
选择不同的字体交付服务
谷歌字体目录只是字体交付服务之一。Typekit (typekit.com) 和 Fontdeck (fontdeck.com) 是另外两种提供数百种高质量字体的字体服务,这些服务通过年度订阅计划提供。
摘要
在本章中,你学习了如何使用 CSS3 的不同新特性来创建游戏。
具体来说,我们介绍了如何构建一个基于 CSS3 样式和动画的卡牌游戏。你学习了如何通过使用过渡模块来转换和动画化游戏对象。我们可以通过透视深度错觉来翻转卡片。我们还从在线字体交付服务中嵌入网络字体。
现在你已经了解了在 CSS3 特性的帮助下创建基于 DOM 的 HTML5 游戏,我们将探讨另一种方法——使用新的canvas标签和绘图 API——在下一章中创建 HTML5 游戏。
第四章:使用 Canvas 和绘图 API 构建 Untangle 游戏
HTML5 中的一个新亮点特性是canvas元素。我们可以将其视为一个动态区域,在其中我们可以使用脚本绘制图形和形状。
网站上的图像已经静态了好几年。有动画 GIF,但它们不能与访客交互。Canvas 是动态的。我们在 Canvas 中绘制和修改上下文,通过 JavaScript 绘图 API 动态地绘制。我们还可以向 Canvas 添加交互,从而制作游戏。
在前两章中,我们讨论了基于 DOM 的游戏开发,使用了 CSS3 和一些 HTML5 特性。在接下来的两章中,我们将专注于使用新的 HTML5 特性来创建游戏。在本章中,我们将探讨一个核心特性,Canvas,以及一些基本的绘图技术。
在本章中,我们将涵盖以下主题:
-
介绍 HTML5
canvas元素 -
在 Canvas 中绘制圆形
-
在
canvas元素中绘制线条 -
使用鼠标事件与 Canvas 中绘制的对象交互
-
检测线段交点
-
在触摸设备上支持拖放功能
Untangle 谜题游戏是一款玩家被给予一些线条连接的圆形的游戏。这些线条可能相交,玩家需要拖动圆形,以便没有线条再相交。
以下截图预览了我们将通过本章实现的那个游戏:

您也可以在以下 URL 尝试游戏:
makzan.net/html5-games/untangle-wip-dragging/
因此,让我们从头开始制作我们的 Canvas 游戏。
介绍 HTML5 canvas元素
W3C 社区表示,canvas元素和绘图函数如下:
一个分辨率相关的位图 Canvas,可以用于实时渲染图表、游戏图形或其他视觉图像。
canvas元素包含绘图上下文,实际的图形和形状是通过 JavaScript 绘图 API 绘制的。使用canvas与常规 HTML DOM 元素之间有一个关键区别。Canvas 是即时模式,而 DOM 是保留模式。我们使用元素和属性描述 DOM 树,浏览器为我们渲染和跟踪对象。在 Canvas 中,我们必须自己管理所有属性和渲染。浏览器不会保留我们绘制的相关信息。它只保留绘制的像素数据。
在 Canvas 中绘制圆形
让我们从 Canvas 的基本形状——圆形开始我们的绘图。
行动时间——在 Canvas 中绘制彩色圆形
-
首先,让我们为示例设置新的环境。即一个包含
canvas元素、一个 jQuery 库以帮助我们进行 JavaScript 编程、一个包含实际绘图逻辑的 JavaScript 文件以及一个样式表的 HTML 文件:index.html js/ js/jquery-2.1.3.js js/untangle.js js/untangle.drawing.js js/untangle.data.js js/untangle.input.js css/ css/untangle.css images/ -
将以下 HTML 代码放入
index.html文件中。这是一个包含canvas元素的基本 HTML 文档:<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Drawing Circles in Canvas</title> <link rel="stylesheet" href="css/untangle.css"> </head> <body> <header> <h1>Drawing in Canvas</h1> </header> <canvas id="game" width="768" height="400"> This is an interactive game with circles and lines connecting them. </canvas> <script src="img/jquery-2.1.3.min.js"></script> <script src="img/untangle.data.js"></script> <script src="img/untangle.drawing.js"></script> <script src="img/untangle.input.js"></script> <script src="img/untangle.js"></script> </body> </html> -
使用 CSS 在
untangle.css中设置 Canvas 的背景颜色:canvas { background: grey; } -
在
untangle.jsJavaScript 文件中,我们放置了一个 jQuerydocumentready函数并在其中绘制了一个颜色圆圈:$(document).ready(function(){ var canvas = document.getElementById("game"); var ctx = canvas.getContext("2d"); ctx.fillStyle = "GOLD"; ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI*2, true); ctx.closePath(); ctx.fill(); }); -
在网络浏览器中打开
index.html文件,我们将得到以下截图:![动手实践 – 在 Canvas 中绘制颜色圆圈]()
发生了什么?
我们刚刚创建了一个简单的Canvas 上下文,上面有圆圈。对于canvas元素本身并没有很多设置。我们设置了 Canvas 的宽度和高度,就像我们固定了真实绘制纸张的尺寸一样。此外,我们还为 Canvas 分配了一个 ID 属性,以便在 JavaScript 中更容易引用:
<canvas id="game" width="768" height="400">
This is an interactive game with circles and lines connecting them.
</canvas>
当网络浏览器不支持 Canvas 时,添加回退内容
并非每个网络浏览器都支持canvas元素。canvas元素提供了一个简单的方法来提供回退内容,如果浏览器不支持canvas元素。该内容同时也为任何屏幕阅读器提供了有意义的信息。canvas元素打开和关闭标签内的任何内容都是回退内容。如果浏览器支持该元素,则此内容将被隐藏。在回退内容中提供有用信息是一种良好的做法。例如,如果canvas标签的目的是动态图片,我们可能考虑在那里放置一个<img>替代元素。或者,我们也可以提供一些链接到现代网络浏览器,以便访客可以轻松升级他们的浏览器。
Canvas 上下文
当我们在 Canvas 中绘制时,实际上是在调用canvas 渲染上下文的绘图 API。你可以将 Canvas 和上下文的关系想象为 Canvas 是框架,上下文是真正的绘图表面。目前,我们有2d、webgl和webgl2作为上下文选项。在我们的例子中,我们将通过调用getContext("2d")使用 2D 绘图 API。
var canvas = document.getElementById("game");
var ctx = canvas.getContext("2d");
使用 Canvas 弧线函数绘制圆和形状
没有绘制圆的函数。Canvas 绘图 API 提供了一个绘制不同弧线(包括圆)的函数。arc函数接受以下参数:
| 参数 | 讨论 |
|---|---|
X |
弧在x轴上的中心点。 |
Y |
弧在y轴上的中心点。 |
radius |
半径是中心点和弧线周界的距离。在绘制圆时,较大的半径意味着较大的圆。 |
startAngle |
起始点是弧度。它定义了在圆周上开始绘制弧线的位置。 |
endAngle |
结束点是弧度。弧线从起始角度的位置绘制到这个结束角度。 |
counter-clockwise |
这是一个布尔值,指示从 startingAngle 到 endingAngle 的弧是按顺时针还是逆时针方向绘制的。这是一个可选参数,默认值为 false。 |
将角度转换为弧度
arc 函数中使用的角度参数是以 弧度 为单位的,而不是以 度 为单位。如果你熟悉度数角度,你可能需要在将值放入弧度函数之前将度数转换为弧度。我们可以使用以下公式来转换角度单位:
radians = π/180 x degrees
在画布中执行路径绘制
当我们调用 arc 函数或其他路径绘制函数时,我们并不是立即在画布中绘制路径。相反,我们将它添加到路径列表中。这些路径将不会绘制,直到我们执行绘制命令。
有两个绘图执行命令:一个用于填充路径,另一个用于绘制轮廓。
我们通过调用 fill 函数来填充路径,通过调用 stroke 函数来绘制路径的轮廓,我们将在绘制线条时使用它:
ctx.fill();
为每种样式开始路径
fill 和 stroke 函数用于填充和绘制画布中的路径,但不会清除路径列表。以下是一个代码片段示例。在用红色填充我们的圆之后,我们添加其他圆并用绿色填充它们。代码的结果是两个圆都被绿色填充,而不是只有新的圆被绿色填充:
var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');
ctx.fillStyle = "red";
ctx.arc(100, 100, 50, 0, Math.PI*2, true);
ctx.fill();
ctx.arc(210, 100, 50, 0, Math.PI*2, true);
ctx.fillStyle = "green";
ctx.fill();
这是因为,在调用第二个 fill 命令时,画布中的路径列表包含两个圆。因此,fill 命令用绿色填充了两个圆,并覆盖了红色圆的颜色。
为了解决这个问题,我们想要确保每次绘制新形状之前都调用 beginPath。
beginPath 函数清空路径列表,所以下次我们调用 fill 和 stroke 命令时,它们将只应用于最后一个 beginPath 之后的所有路径。
尝试一下英雄
我们刚刚讨论了一个代码片段,我们原本打算绘制两个圆:一个红色,一个绿色。代码最终绘制了两个绿色的圆。我们如何向代码中添加一个 beginPath 命令,以便正确地绘制一个红色圆和一个绿色圆?
闭合路径
closePath 函数将从最新路径的最后一个点到路径的第一个点绘制一条直线。这被称为闭合路径。如果我们只是要填充路径而不绘制轮廓线,closePath 函数不会影响结果。以下截图比较了在半圆上调用 closePath 和不调用 closePath 的结果:

突击测验
Q1. 如果我们只想填充颜色而不绘制轮廓线,我们是否需要在绘制的形状上使用 closePath 函数?
-
是的,我们需要使用
closePath函数。 -
不,使用
closePath函数并不重要。
将圆形绘制封装在函数中
绘制圆形是一个我们将大量使用的常见函数。现在创建一个绘制圆形的函数,而不是输入几行代码会更好。
将圆形绘制代码放入函数中的行动时间
让我们编写一个函数来绘制圆形,然后在画布上绘制一些圆形。我们将把代码放在不同的文件中,以使代码更简单:
-
在我们的代码编辑器中打开
untangle.drawing.js文件,并输入以下代码:if (untangleGame === undefined) { var untangleGame = {}; } untangleGame.drawCircle = function(x, y, radius) { var ctx = untangleGame.ctx; ctx.fillStyle = "GOLD"; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI*2, true); ctx.closePath(); ctx.fill(); }; -
打开
untangle.data.js文件,并将以下代码放入其中:if (untangleGame === undefined) { var untangleGame = {}; } untangleGame.createRandomCircles = function(width, height) { // randomly draw 5 circles var circlesCount = 5; var circleRadius = 10; for (var i=0;i<circlesCount;i++) { var x = Math.random()*width; var y = Math.random()*height; untangleGame.drawCircle(x, y, circleRadius); } }; -
然后打开
untangle.js文件。用以下代码替换 JavaScript 文件中的原始代码:if (untangleGame === undefined) { var untangleGame = {}; } // Entry point $(document).ready(function(){ var canvas = document.getElementById("game"); untangleGame.ctx = canvas.getContext("2d"); var width = canvas.width; var height = canvas.height; untangleGame.createRandomCircles(width, height); }); -
在网页浏览器中打开 HTML 文件以查看结果:
![将圆形绘制代码放入函数中的行动时间]()
刚才发生了什么?
绘制圆形的代码在页面加载并准备好后执行。我们使用循环在画布的随机位置绘制几个圆形。
将代码分割成文件
我们正在将代码放入不同的文件。目前,有untangle.js、untangle.drawing.js和untangle.data.js文件。untangle.js是游戏的入口点。然后我们将与上下文绘制相关的逻辑放入untangle.drawing.js,将与数据处理相关的逻辑放入untangle.data.js文件。
我们使用untangleGame对象作为跨所有文件访问的全局对象。在每个 JavaScript 文件的开头,我们有以下代码来创建此对象(如果它不存在的话):
if (untangleGame === undefined) {
var untangleGame = {};
}
在 JavaScript 中生成随机数
在游戏开发中,我们经常使用random函数。我们可能想要随机召唤一个怪物让玩家战斗,我们可能想要在玩家取得进展时随机掉落奖励,或者我们可能想要一个随机数作为掷骰子的结果。在这段代码中,我们将圆形随机放置在画布上。
要在 JavaScript 中生成随机数,我们使用Math.random()函数。random函数没有参数。它总是返回一个介于 0 和 1 之间的浮点数。这个数等于或大于 0 且小于 1。使用random函数有两种常见方式。一种是在给定范围内生成随机数。另一种是生成真或假值。
| 用法 | 代码 | 讨论 |
|---|---|---|
| 在 A 和 B 之间获取一个随机整数 | Math.floor(Math.random()*B)+A |
Math.floor()函数截断给定数字的小数点。以Math.floor(Math.random()*10)+5为例。Math.random()返回一个介于 0 到 0.9999…之间的十进制数。Math.random()*10是一个介于 0 到 9.9999…之间的十进制数。Math.floor(Math.random()*10)是一个介于 0 到 9 之间的整数。最后,Math.floor(Math.random()*10) + 5是一个介于 5 到 14 之间的整数。 |
| 获取一个随机的布尔值 | (Math.random() > 0.495) |
(Math.random() > 0.495) 表示 50%的false和 50%的true。我们可以进一步调整真假比例。(Math.random() > 0.7) 表示大约 70%的false和 30%的true。 |
保存圆的位置
当我们在开发基于 DOM 的游戏时,例如我们在前几章中构建的游戏,我们通常将游戏对象放入 DIV 元素中,并在代码逻辑中稍后访问它们。在基于 Canvas 的游戏开发中则不同。
为了在 Canvas 中绘制游戏对象后访问它们,我们需要自己记住它们的状态。假设现在我们想知道画了多少个圆以及它们的位置,我们需要一个数组来存储它们的位置。
行动时间 - 保存圆的位置
-
在文本编辑器中打开
untangle.data.js文件。 -
在 JavaScript 文件中添加以下
circle对象定义代码:untangleGame.Circle = function(x,y,radius){ this.x = x; this.y = y; this.radius = radius; } -
现在我们需要一个数组来存储圆的位置。向
untangleGame对象中添加一个新数组:untangleGame.circles = []; -
当在 Canvas 中绘制每个圆时,我们在
circles数组中保存圆的位置。在createRandomCircles函数中调用drawCircle函数之前,添加以下行:untangleGame.circles.push(new untangleGame.Circle(x,y,circleRadius)); -
步骤完成后,我们应该在
untangle.data.js文件中有以下代码:if (untangleGame === undefined) { var untangleGame = {}; } untangleGame.circles = []; untangleGame.Circle = function(x,y,radius){ this.x = x; this.y = y; this.radius = radius; }; untangleGame.createRandomCircles = function(width, height) { // randomly draw 5 circles var circlesCount = 5; var circleRadius = 10; for (var i=0;i<circlesCount;i++) { var x = Math.random()*width; var y = Math.random()*height; untangleGame.circles.push(new untangleGame.Circle(x,y,circleRadius)); untangleGame.drawCircle(x, y, circleRadius); } }; -
现在我们可以测试网页中的代码。在 Canvas 中绘制随机圆时,此代码与上一个示例之间没有视觉上的差异。这是因为我们保存了圆,但没有更改任何影响外观的代码。我们只是确保它看起来相同,没有新的错误。
发生了什么?
我们保存了每个圆的位置和半径。这是因为 Canvas 绘制是即时模式。我们无法直接访问 Canvas 中绘制的对象,因为没有这样的信息。所有线条和形状都作为像素绘制在 Canvas 上,我们无法将线条或形状作为单独的对象访问。想象一下我们在真正的画布上作画。我们无法只是移动油画中的房子,同样,我们也不能直接操作canvas元素中绘制的任何项目。
在 JavaScript 中定义基本类定义
我们可以在 JavaScript 中使用面向对象编程。我们可以定义一些用于我们的对象结构。Circle对象为我们提供了一个数据结构,使我们能够轻松地存储x和y位置以及半径。
定义了Circle对象后,我们可以使用以下代码创建一个新的Circle实例,并使用x、y和半径值:
var circle1 = new Circle(100, 200, 10);
注意
对于 JavaScript 中面向对象编程的更详细用法,请查看以下链接的 Mozilla 开发者中心:
developer.mozilla.org/en/Introduction_to_Object-Oriented_JavaScript
尝试一下英雄
我们在 Canvas 上随机画了几个圆。它们具有相同的样式和大小。我们随机画圆的大小怎么样?用不同的颜色填充圆?尝试修改代码,然后玩玩绘图 API。
在 Canvas 上画线
现在我们这里有几个圆,那么我们如何用线连接它们?让我们在每个圆之间画一条直线。
动手时间 - 在每个圆之间画直线
-
打开我们在圆绘制示例中刚刚使用的
index.html文件。 -
将 h1 中的文字从在 Canvas 上画圆改为在 Canvas 上画线。
-
打开
untangle.data.jsJavaScript 文件。 -
我们定义一个
Line类来存储我们需要的每条线的相关信息:untangleGame.Line = function(startPoint, endPoint, thickness) { this.startPoint = startPoint; this.endPoint = endPoint; this.thickness = thickness; } -
保存文件并切换到
untangle.drawing.js文件。 -
我们需要两个额外的变量。在 JavaScript 文件中添加以下行:
untangleGame.thinLineThickness = 1; untangleGame.lines = []; -
我们在
untangle.drawing.js文件中现有的drawCircle函数之后添加以下drawLine函数。untangleGame.drawLine = function(ctx, x1, y1, x2, y2, thickness) { ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.lineWidth = thickness; ctx.strokeStyle = "#cfc"; ctx.stroke(); } -
然后我们定义一个新的函数,它遍历圆列表并在每对圆之间画一条线。在 JavaScript 文件中添加以下代码:
untangleGame.connectCircles = function() { // connect the circles to each other with lines untangleGame.lines.length = 0; for (var i=0;i< untangleGame.circles.length;i++) { var startPoint = untangleGame.circles[i]; for(var j=0;j<i;j++) { var endPoint = untangleGame.circles[j]; untangleGame.drawLine(startPoint.x, startPoint.y, endPoint.x, endPoint.y, 1); untangleGame.lines.push(new untangleGame.Line(startPoint, endPoint, untangleGame.thinLineThickness)); } } }; -
最后,我们打开
untangle.js文件,并在调用untangleGame.createRandomCircles函数之后,在 jQuery 文档ready函数的末尾添加以下代码:untangleGame.connectCircles(); -
在网页浏览器中测试代码。我们应该看到有线条连接到每个随机放置的圆:
![动手时间 - 在每个圆之间画直线]()
发生了什么?
我们通过连接每个生成的圆增强了我们的代码。你可以在以下 URL 找到一个工作示例:
makzan.net/html5-games/untangle-wip-connect-lines/
与我们保存圆位置的方式类似,我们有一个数组来保存我们绘制的每条线段。我们声明一个线段类定义来存储线段的一些基本信息。也就是说,我们保存起点和终点以及线的粗细。
介绍线条绘制 API
有一些绘图 API 可以用来绘制和样式化线条:
| 线条绘制函数 | 讨论 |
|---|---|
moveTo |
moveTo函数就像我们手里拿着笔,在纸上移动它,但不碰触纸面。 |
lineTo |
这个函数就像把笔放在纸上,然后画一条直线到目标点。 |
lineWidth |
lineWidth函数设置我们之后绘制的线条的粗细。 |
stroke |
stroke函数用于执行绘图。我们设置一个包含moveTo、lineTo或样式函数的集合,并最终调用stroke函数在 Canvas 上执行。 |
我们通常使用moveTo和lineTo对来绘制线条。就像在现实世界中一样,我们在纸上移动我们的笔到线条的起点并放下笔来绘制线条。然后,继续绘制另一条线条或移动到另一个位置再绘制。这正是我们在画布上绘制线条的流程。
注意
我们刚刚演示了如何绘制一条简单的线条。我们可以为画布中的线条设置不同的样式。有关线条样式的更多详细信息,请参阅 W3C 的样式指南www.w3.org/TR/2dcontext/#line-styles和 Mozilla 开发者中心的教程developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Applying_styles_and_colors。
使用鼠标事件与画布中绘制的对象交互
到目前为止,我们已经展示了我们可以根据我们的逻辑动态地在画布中绘制形状。游戏开发中缺少的一部分是输入。
现在,想象一下我们可以在画布上拖动圆圈,并且连接的线条会跟随圆圈移动。在本节中,我们将向画布添加鼠标事件以使我们的圆圈可拖动。
行动时间 – 在画布中拖动圆圈
-
让我们继续使用之前的代码。打开
html5games.untangle.js文件。 -
我们需要一个函数来清除画布上的所有绘制内容。将以下函数添加到
untangle.drawing.js文件的末尾:untangleGame.clear = function() { var ctx = untangleGame.ctx; ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height); }; -
我们还需要两个额外的函数来绘制所有已知的圆圈和线条。将以下代码添加到
untangle.drawing.js文件中:untangleGame.drawAllLines = function(){ // draw all remembered lines for(var i=0;i<untangleGame.lines.length;i++) { var line = untangleGame.lines[i]; var startPoint = line.startPoint; var endPoint = line.endPoint; var thickness = line.thickness; untangleGame.drawLine(startPoint.x, startPoint.y, endPoint.x, endPoint.y, thickness); } }; untangleGame.drawAllCircles = function() { // draw all remembered circles for(var i=0;i<untangleGame.circles.length;i++) { var circle = untangleGame.circles[i]; untangleGame.drawCircle(circle.x, circle.y, circle.radius); } }; -
我们已经完成了
untangle.drawing.js文件。让我们切换到untangle.js文件。在 jQuery 文档就绪函数内部,在函数结束之前,我们添加以下代码,它创建了一个游戏循环以持续绘制圆圈和线条:// set up an interval to loop the game loop setInterval(gameloop, 30); function gameloop() { // clear the Canvas before re-drawing. untangleGame.clear(); untangleGame.drawAllLines(); untangleGame.drawAllCircles(); } -
在继续实现输入处理代码之前,让我们将以下代码添加到
untangle.js文件中的 jQuery 文档就绪函数中,该代码调用我们将定义的handleInput函数:untangleGame.handleInput(); -
是时候实现我们的输入处理逻辑了。切换到
untangle.input.js文件,并将以下代码添加到文件中:if (untangleGame === undefined) { var untangleGame = {}; } untangleGame.handleInput = function(){ // Add Mouse Event Listener to canvas // we find if the mouse down position is on any circle // and set that circle as target dragging circle. $("#game").bind("mousedown", function(e) { var canvasPosition = $(this).offset(); var mouseX = e.pageX - canvasPosition.left; var mouseY = e.pageY - canvasPosition.top; for(var i=0;i<untangleGame.circles.length;i++) { var circleX = untangleGame.circles[i].x; var circleY = untangleGame.circles[i].y; var radius = untangleGame.circles[i].radius; if (Math.pow(mouseX-circleX,2) + Math.pow(mouseY-circleY,2) < Math.pow(radius,2)) { untangleGame.targetCircleIndex = i; break; } } }); // we move the target dragging circle // when the mouse is moving $("#game").bind("mousemove", function(e) { if (untangleGame.targetCircleIndex !== undefined) { var canvasPosition = $(this).offset(); var mouseX = e.pageX - canvasPosition.left; var mouseY = e.pageY - canvasPosition.top; var circle = untangleGame.circles[untangleGame.targetCircleIndex]; circle.x = mouseX; circle.y = mouseY; } untangleGame.connectCircles(); }); // We clear the dragging circle data when mouse is up $("#game").bind("mouseup", function(e) { untangleGame.targetCircleIndex = undefined; }); }; -
在网页浏览器中打开
index.html。应该有五个圆圈,它们通过线条连接。尝试拖动圆圈。被拖动的圆圈将跟随鼠标光标移动,并且连接的线条也会随之移动。![行动时间 – 在画布中拖动圆圈]()
发生了什么?
我们已经设置了三个鼠标事件监听器。它们是鼠标按下、移动和抬起事件。我们还创建了游戏循环,它根据圆圈的新位置更新画布绘制。您可以在以下位置查看示例的当前进度:makzan.net/html5-games/untangle-wip-dragging-basic/。
在画布中的圆圈上检测鼠标事件
在讨论了基于 DOM 的开发和基于 Canvas 的开发之间的区别后,我们无法直接监听在画布中绘制的任何形状的鼠标事件。不存在这样的事情。我们无法监听在画布中绘制的任何形状的事件。我们只能获取 canvas 元素的鼠标事件,并计算 Canvas 的相对位置。然后根据鼠标的位置更改游戏对象的状态,最后在画布上重新绘制。
我们如何知道我们点击的是圆? 我们可以使用 点在圆内 的公式。这是检查圆的中心点和鼠标位置之间的距离。当距离小于圆的半径时,鼠标点击圆。我们使用这个公式来获取两点之间的距离:距离 = (x2-x1)² + (y2-y1)²。
以下图表显示,当中心点和鼠标光标之间的距离小于半径时,光标位于圆内:

我们使用的以下代码解释了如何在鼠标按下事件处理程序中应用距离检查,以确定鼠标光标是否在圆内:
if (Math.pow(mouseX-circleX,2) + Math.pow(mouseY-circleY,2) < Math.pow(radius,2)) {
untangleGame.targetCircleIndex = i;
break;
}
注意
请注意,Math.pow 是一个昂贵的函数,可能在某些场景中损害性能。如果性能是一个关注点,我们可能使用边界框碰撞检测,这在第二章中有所介绍,基于 DOM 的游戏开发入门。
当我们知道鼠标光标正在按下画布中的圆时,我们将其标记为目标圆,以便在鼠标移动事件中拖动。在鼠标移动事件处理程序中,我们将目标拖动圆的位置更新为最新的光标位置。当鼠标抬起时,我们清除目标圆的引用。
快速问答
Q1. 我们能否直接访问在画布中已经绘制的形状?
-
是的
-
不可以
Q2. 我们可以使用哪种方法来检查一个点是否在圆内?
-
点的坐标小于圆心的坐标。
-
点和圆心之间的距离小于圆的半径。
-
点的 x 坐标小于圆的半径。
-
点和圆心之间的距离大于圆的半径。
游戏循环
在第二章,基于 DOM 的游戏开发入门中,我们讨论了 游戏循环 方法。在乒乓球游戏中,游戏循环处理键盘输入并更新基于 DOM 的游戏对象的位置。
在这里,游戏循环被用来重新绘制画布以展示后续的游戏状态。如果我们改变状态后不重新绘制画布,比如圆的位置,我们就看不到它。
清除画布
当我们拖动圆时,我们会重新绘制 Canvas。问题是 Canvas 上已经绘制的形状不会自动消失。我们将继续向 Canvas 添加新的路径,最终搞乱 Canvas 上的所有东西。以下截图是如果我们不断拖动圆而不在每次重新绘制时清除 Canvas 会发生的情况:

由于我们已经将所有游戏状态保存在 JavaScript 中,我们可以安全地清除整个 Canvas,并使用最新的游戏状态绘制更新的线和圆。要清除 Canvas,我们使用 Canvas 绘图 API 提供的 clearRect 函数。clearRect 函数通过提供一个矩形裁剪区域来清除矩形区域。它接受以下参数作为裁剪区域:
context.clearRect(x, y, width, height)
| 参数 | 定义 |
|---|---|
x |
矩形裁剪区域的左上角点,位于 x 轴上。 |
y |
矩形裁剪区域的左上角点,位于 y 轴上。 |
width |
矩形区域的宽度。 |
height |
矩形区域的宽度。 |
x 和 y 值设置要清除的区域左上角的位置。width 和 height 值定义要清除的区域大小。要清除整个 Canvas,我们可以将 (0,0) 作为左上角位置,并将 Canvas 的宽度和高度提供给 clearRect 函数。以下代码清除整个 Canvas 上绘制的所有内容:
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
快速问答
Q1. 我们能否使用 clearRect 函数清除 Canvas 的一部分?
-
是
-
否
Q2. 以下代码是否清除绘制在 Canvas 上的内容?
ctx.clearRect(0, 0, ctx.canvas.width, 0);
-
是
-
否
在 Canvas 中检测线段相交
在 Canvas 中,我们有可拖动的圆和连接的线。有些线段相交,有些则不相交。现在想象一下,我们想要区分相交的线段。我们需要一些数学公式来检查它们,然后加粗那些相交的线段。
行动时间 – 区分相交的线段
让我们增加相交线段的粗细,以便在 Canvas 中区分它们
-
在文本编辑器中打开
untangle.drawing.js文件。 -
我们有
thinLineThickness变量作为默认线粗细。我们添加以下代码来定义粗体线的粗细:untangleGame.boldLineThickness = 5; -
打开
untangle.data.js文件。我们创建一个函数来检查给定的两条线是否相交。将以下函数添加到 JavaScript 文件的末尾:untangleGame.isIntersect = function(line1, line2) { // convert line1 to general form of line: Ax+By = C var a1 = line1.endPoint.y - line1.startPoint.y; var b1 = line1\. startPoint.x - line1.endPoint.x; var c1 = a1 * line1.startPoint.x + b1 * line1.startPoint.y; // convert line2 to general form of line: Ax+By = C var a2 = line2.endPoint.y - line2.startPoint.y; var b2 = line2\. startPoint.x - line2.endPoint.x; var c2 = a2 * line2.startPoint.x + b2 * line2.startPoint.y; // calculate the intersection point var d = a1*b2 - a2*b1; // parallel when d is 0 if (d === 0) { return false; } // solve the interception point at (x, y) var x = (b2*c1 - b1*c2) / d; var y = (a1*c2 - a2*c1) / d; // check if the interception point is on both line segments if ((isInBetween(line1.startPoint.x, x, line1.endPoint.x) || isInBetween(line1.startPoint.y, y, line1.endPoint.y)) && (isInBetween(line2.startPoint.x, x, line2.endPoint.x) || isInBetween(line2.startPoint.y, y, line2.endPoint.y))) { return true; } // by default the given lines is not intersected. return false; }; // return true if b is between a and c, // we exclude the result when a==b or b==c untangleGame.isInBetween = function(a, b, c) { // return false if b is almost equal to a or c. // this is to eliminate some floating point when // two value is equal to each other // but different with 0.00000...0001 if (Math.abs(a-b) < 0.000001 || Math.abs(b-c) < 0.000001) { return false; } // true when b is in between a and c return (a < b && b < c) || (c < b && b < a); }; -
让我们继续
untangle.data.js文件。我们定义以下函数来检查我们的线段是否相交,并用粗体标记该线段。将以下新函数追加到文件末尾:untangle.updateLineIntersection = function() { // checking lines intersection and bold those lines. for (var i=0;i<untangleGame.lines.length;i++) { for(var j=0;j<i;j++) { var line1 = untangleGame.lines[i]; var line2 = untangleGame.lines[j]; // we check if two lines are intersected, // and bold the line if they are. if (isIntersect(line1, line2)) { line1.thickness = untangleGame.boldLineThickness; line2.thickness = untangleGame.boldLineThickness; } } } } -
最后,我们在两个地方更新线段相交。打开
untangle.js文件。在 jQuery 文档就绪函数中添加以下代码行,可能是在游戏循环函数之前:untangleGame.updateLineIntersection(); -
然后,打开
untangle.input.js文件,并在鼠标移动事件处理程序中添加相同的代码。 -
是时候在网页浏览器中测试交点了。当在 Canvas 中查看圆和线时,有交点的线应该比没有交点的线更粗。尝试拖动圆来改变交点关系,线将变细或变粗。
发生了什么?
我们已经在我们现有的圆拖动示例中添加了一些检查线段交点的代码。线段交点代码涉及一些数学公式来获取两条直线的交点,并检查该点是否在我们提供的线段内。您可以在以下位置查看示例的当前进度:makzan.net/html5-games/untangle-wip-intersected-lines/。
让我们看看数学元素,看看它是如何工作的。
判断两条线段是否相交
根据我们从几何学中学到的交点方程,给定两条通用形式的直线,我们可以得到交点。
什么是通用形式?在我们的代码中,我们有一个线段的起点和终点在 x 和 y 坐标中。这是一个线段,因为它只是数学中线的一部分。直线的通用形式表示为 Ax + By = C。
以下图表以通用形式解释了直线上的线段:

我们可以通过以下方程将点 1 在x1、y1和点 2 在x2、y2的线段转换为通用形式:
A = y2-y1
B = x1-x2
C = A * x1 + B * y2
现在我们有一个直线方程 AX+BY = C,其中 A、B、C 是已知的,而 X 和 Y 是未知的。
我们正在检查两条直线是否相交。我们可以将两条直线都转换为通用形式,并得到两个直线方程:
Line 1: A1X+B1Y = C1
Line 2: A2X+B2Y = C2
通过将两个通用形式方程组合起来,X 和 Y 是两个未知变量。然后我们可以解这两个方程,得到 x 和 y 的交点。
如果 A1 * B2 - A2 * B1 为零,则两条直线平行,没有交点。否则,我们可以使用以下方程得到交点:
X = (B2 * C1 – B1 * C2) / (A1 * B2 – A2 * B1)
Y = (A1 * C2 – A2 * C1) / (A1 * B2 – A2 * B1)
这些通用形式的交点仅表明两条直线不平行,并且将在某一点相交。这并不保证交点位于两条线段上。
以下图表显示了交点和给定线段可能的两种结果。在左图中,交点不在两个线段之间;在这种情况下,两个线段没有相交。在右图中,点位于两个线段之间,因此这两个线段相交:

因此,我们需要另一个名为isInBetween的函数来确定提供的值是否在起始值和结束值之间。然后我们使用这个函数来检查方程的交点是否在我们检查的两个线段之间。
在得到线段交点的结果后,我们画一条粗线来指示那些相交的线。
为平板电脑添加触摸支持
拖放是平板电脑和移动设备中触摸设备的常见手势。目前,我们的游戏不支持这些触摸设备。我们想在本文档的这一节中为我们的游戏添加触摸支持。
行动时间 – 添加触摸输入支持
让我们的平板用户通过以下步骤拖放我们的圆圈:
-
默认情况下,iOS 设备中的
canvas元素有一个选区高亮显示。我们想要去掉这个高亮部分,以使拖动交互更平滑。请将以下 CSS 规则添加到canvasCSS 中。请注意,我们在这里使用webkit供应商前缀,因为这个规则在撰写本书时是特定于webkit的:canvas { /* for iOS devices */ -webkit-tap-highlight-color: transparent; } -
打开
untangle.input.js文件。我们在上一步中在 Canvas 上绑定了鼠标事件。现在我们添加了对触摸事件的支持。我们使用了MouseEvent.pageX和pageY来计算鼠标位置。对于触摸设备,可能会有多个触摸。我们修改了我们的代码来添加触摸支持:$("#game").bind("mousedown touchstart", function(e) { // disable default drag to scroll behavior e.preventDefault(); // touch or mouse position var touch = e.originalEvent.touches && e.originalEvent.touches[0]; var pageX = (touch||e).pageX; var pageY = (touch||e).pageY; var canvasPosition = $(this).offset(); var mouseX = pageX - canvasPosition.left; var mouseY = pageY - canvasPosition.top; // existing code goes here. } -
我们类似地修改了
mousemove事件。我们绑定了mousemove和touchmove事件,并计算触摸位置:$("#game").bind("mousemove touchmove", function(e) { // disable default drag to scroll behavior e.preventDefault(); // touch or mouse position var touch = e.originalEvent.touches && e.originalEvent.touches[0]; var pageX = (touch||e).pageX; var pageY = (touch||e).pageY; var canvasPosition = $(this).offset(); var mouseX = pageX - canvasPosition.left; var mouseY = pageY - canvasPosition.top; // existing code goes here. } -
对于原始的
mouseup事件处理器,我们添加了touchend处理:$("#game").bind("mouseup touchend", function(e) { // existing code goes here. }
发生了什么?
我们刚刚为 untangle 游戏添加了触摸支持。你可以在以下位置找到代码和示例:makzan.net/html5-games/untangle-wip-dragging/。
在 CSS 中,我们通过将-webkit-tap-highlight-color设置为透明来禁用默认的点击高亮。我们需要使用供应商前缀-webkit-,因为这个规则仅适用于 WebKit,特别是为他们的触摸设备特别设计的。
处理触摸
我们使用以下代码获取touch事件对象:
var touch = e.originalEvent.touches && e.originalEvent.touches[0];
触摸数组包含屏幕上所有的当前触摸。由于我们在 jQuery 中处理触摸事件,我们需要访问originalEvent来访问触摸,因为这些是浏览器原生事件,而不是 jQuery 事件。
在这个游戏中,我们只关心一个触摸,这就是为什么我们只检查touches[0]参数。在使用数组表示法之前,我们也确认了originalEvent.touches存在,否则,在非触摸设备上浏览器会抛出错误。
然后我们访问touch对象或鼠标事件对象的pageX属性。如果touch对象存在,JavaScript 使用touch.pageX。否则,JavaScript 使用鼠标事件的pageX属性:
var pageX = (touch||e).pageX;
鼠标移动和触摸移动
我们为mousedown/touchstart、mousemove/touchmove和mouseup/touchend事件重用了相同的逻辑。通常mousedown和touchstart在拖拽开始时具有非常相似的逻辑。mouseup和touchend事件在拖拽结束时也具有相似的逻辑。然而,mousemove和touchmove事件却有一个细微的差别。在带有鼠标输入的桌面设备上,mousemove事件总是在鼠标移动时触发,无论鼠标按钮是否按下。这就是为什么我们需要使用一个变量targetCircleIndex来确定按钮是否被按下,然后在鼠标移动时选择一个特定的圆。另一方面,touchmove事件仅在手指实际按下屏幕并拖拽时发生。这种差异有时可能会影响我们处理逻辑的不同方式。
摘要
你在本章中学习了关于使用新的 HTML5 canvas元素和绘图 API 绘制形状和创建交互的很多知识。
具体来说,你学会了在画布上绘制圆形和线条。我们添加了与画布中绘制的路径相关的鼠标事件和触摸拖拽交互。我们借助数学公式来确定线条的交点。我们将复杂的代码逻辑分离到不同的文件中,以便代码可维护。我们将逻辑分为数据、绘制和输入。
现在你已经了解了画布中的基本绘图函数和绘图 API,你准备好学习一些 Canvas 中的高级绘图技术了。在下一章中,我们将通过继续代码示例来创建一个解谜游戏。你还将学习更多 Canvas 绘图技术,例如绘制文本、绘制图像和创建多个绘图层。
第五章. 构建 Canvas 游戏进阶课程
在前一章中,我们探索了一些基本的画布上下文绘制 API,并创建了一个名为 Untangle 的游戏。在这一章中,我们将通过使用一些其他的上下文绘制 API 来增强这个游戏。
在这一章中,你将学习以下内容:
-
实现 Untangle 游戏逻辑
-
在画布中使用自定义网络字体填充文本
-
在画布中绘制图像
-
动画精灵图集图像
-
构建多个画布层
以下截图是我们将通过本章构建的最终结果的预览。这是一个基于 Canvas 的 Untangle 游戏,具有动画游戏指南和几个细微之处:

你也可以尝试最终的游戏示例:makzan.net/html5-games/untangle/。
那我们就开始吧。
制作 Untangle 谜题游戏
现在我们已经创建了一个交互式的画布,我们可以拖动圆圈,以及与其它线条交叉的连接圆圈的线条。我们为什么不把它变成一个游戏呢?有一些预定义的圆圈和线条,我们的目标是拖动圆圈,使得没有线条交叉。这被称为Untangle 解谜游戏。
行动时间 - 在 Canvas 中制作 Untangle 谜题游戏
让我们在我们的线交叉代码中添加游戏逻辑:
-
我们还需要两个用于游戏逻辑的文件。创建两个新的文件,命名为
untangle.game.js和untangle.levels.js,并将它们放入js文件夹中。 -
在文本编辑器中打开
index.html文件。添加以下代码以包含我们新创建的文件。将代码放在包含js/untangle.js文件之前:<script src="img/untangle.levels.js"></script> <script src="img/untangle.game.js"></script> -
仍然在
index.html文件中,我们在canvas元素之后添加以下代码。它显示了游戏级别信息:<p>Puzzle <span id="level">0</span>, Completeness: <span id="progress">0</span>%</p> -
打开
untangle.levels.js文件。将以下级别数据定义代码放入文件中。这是为玩家提供的预定义级别数据,它是一组定义圆圈放置位置及其初始连接方式的集合:if (untangleGame === undefined) { var untangleGame = {}; } untangleGame.levels = [ { circles : [ {x : 400, y : 156}, {x : 381, y : 241}, {x : 84, y : 233}, {x : 88, y : 73}], relationship : [ {connectedPoints : [1,2]}, {connectedPoints : [0,3]}, {connectedPoints : [0,3]}, {connectedPoints : [1,2]} ] }, { circles : [ {x : 401, y : 73}, {x : 400, y : 240}, {x : 88, y : 241}, {x : 84, y : 72}], relationship : [ {connectedPoints : [1,2,3]}, {connectedPoints : [0,2,3]}, {connectedPoints : [0,1,3]}, {connectedPoints : [0,1,2]} ] }, { circles : [ {x : 192, y : 155}, {x : 353, y : 109}, {x : 493, y : 156}, {x : 490, y : 236}, {x : 348, y : 276}, {x : 195, y : 228}], relationship : [ {connectedPoints : [2,3,4]}, {connectedPoints : [3,5]}, {connectedPoints : [0,4,5]}, {connectedPoints : [0,1,5]}, {connectedPoints : [0,2]}, {connectedPoints : [1,2,3]} ] } ]; -
在文本编辑器中打开
untangle.game.js文件。我们将把游戏逻辑放入这个文件。 -
这是一个新文件,所以我们定义
untangleGame对象在文件的开始部分:if (untangleGame === undefined) { var untangleGame = {}; } -
继续在
untangle.game.js文件中。将以下变量添加到文件中。它们存储游戏的当前级别和进度:untangleGame.currentLevel = 0; untangleGame.levelProgress = 0; -
在开始每个级别时,我们需要设置初始级别数据。为了使代码更易于阅读,我们创建了一个函数。将以下代码追加到
untangle.game.jsJavaScript 文件中:untangleGame.setupCurrentLevel = function() { untangleGame.circles = []; var level = untangleGame.levels[untangleGame.currentLevel]; for (var i=0; i<level.circles.length; i++) { untangleGame.circles.push(new untangleGame.Circle(level.circles[i].x, level.circles[i].y, 10)); } untangleGame.levelProgress = 0; untangleGame.connectCircles(); untangleGame.updateLineIntersection(); untangleGame.checkLevelCompleteness(); untangleGame.updateLevelProgress(); } -
这是一个包含多个级别的游戏。我们需要检查玩家是否解决了当前级别的谜题,并跳转到下一个谜题。将以下函数添加到
untangle.game.js文件的末尾:untangleGame.checkLevelCompleteness = function () { if (untangleGame.levelProgress === 100) { if (untangleGame.currentLevel+1 < untangleGame.levels.length) { untangleGame.currentLevel+=1; } untangleGame.setupCurrentLevel(); } } -
我们需要一个额外的函数来更新游戏进度。将以下函数添加到
untangle.game.js文件的末尾:untangleGame.updateLevelProgress = function() { // check the untangle progress of the level var progress = 0; for (var i=0; i<untangleGame.lines.length; i++) { if (untangleGame.lines[i].thickness === untangleGame.thinLineThickness) { progress+=1; } } var progressPercentage = Math.floor(progress/untangleGame.lines.length*100); untangleGame.levelProgress = progressPercentage; $("#progress").text(progressPercentage); // display the current level $("#level").text(untangleGame.currentLevel); } -
打开
untangle.input.js文件。我们在鼠标移动事件处理程序中添加以下代码,以更新级别进度:untangleGame.updateLevelProgress(); -
我们在鼠标抬起事件处理程序中添加以下代码来检查玩家是否完成了级别:
untangleGame.checkLevelCompleteness(); -
现在在编辑器中打开
untangle.js文件。在 jQuery 文档的ready函数内部,我们有一些设置圆和线的代码。它们现在被我们的级别设置代码所替换。删除对untangleGame.createRandomCircles和untangleGame.connectCircles函数的调用。用以下代码替换它们:untangleGame.setupCurrentLevel(); -
最后,在代码编辑器中打开
untangle.drawing.js文件。我们将connectCircles函数替换为根据级别数据连接圆的函数:untangleGame.connectCircles = function() { // set up all lines based on the circles relationship var level = untangleGame.levels[untangleGame.currentLevel]; untangleGame.lines.length = 0; for (var i in level.relationship) { var connectedPoints = level.relationship[i].connectedPoints; var startPoint = untangleGame.circles[i]; for (var j in connectedPoints) { var endPoint = untangleGame.circles[connectedPoints[j]]; untangleGame.lines.push(new untangleGame.Line(startPoint, endPoint, untangleGame.thinLineThickness)); } } } -
保存所有文件并在浏览器中测试游戏。我们可以拖动圆圈,线宽将指示是否与其他线相交。在鼠标拖动过程中,当检测到更多或更少的线相交时,级别完成百分比应发生变化。如果我们解开了谜题,即没有线相交时,游戏将跳到下一级。当游戏达到最后一级时,它将不断显示最后一级。这是因为我们还没有添加游戏结束界面。
![动手实践 – 在 Canvas 中制作 Untangle 拼图游戏]()
刚才发生了什么?
我们已经将游戏逻辑添加到我们的画布中,以便我们可以玩本章中创建的圆形拖拽代码。这一节更改了相当多的代码。您可以在以下链接找到带有未压缩源代码的工作示例:makzan.net/html5-games/untangle-wip-gameplay/。
让我们回顾一下我们添加到 untangleGame 对象中的变量。以下表格列出了这些变量的描述和用法:
| 变量 | 描述 |
|---|---|
circleRadius |
所有绘制圆的半径设置。 |
thinLineThickness |
绘制细线时的线宽。 |
boldLineThickness |
绘制粗线时的线宽。 |
circles |
一个数组,用于在画布中存储所有绘制的圆。 |
lines |
一个数组,用于在画布中存储所有绘制的线。 |
targetCircle |
跟踪我们正在拖动的圆。 |
levels |
以 JSON 格式存储每个级别的所有初始数据。 |
currentLevel |
一个数字,帮助您记住当前级别。 |
levelProgress |
所有线中非相交线的百分比。 |
定义级别数据
在每个级别中,我们都有一个 Untangle 拼图的圆的初始位置。级别数据设计为一个对象的数组。每个对象包含每个级别的数据。在每个级别的数据内部,有三个属性:级别编号、圆和连接圆的线。以下表格显示了每个级别数据中的属性:
| 级别属性 | 定义 | 讨论 |
|---|---|---|
circles |
定义级别中圆圈位置的数组。 | 这定义了当设置级别时圆圈的初始放置方式。 |
| relationships | 定义哪些圆圈相互连接的关系数组。 | 每个级别中都有一些连接圆圈的线条。我们设计线条连接方式,以确保每个级别都有一个解决方案。每个关系数组的索引表示目标圆圈。线条关系的值定义了哪个圆圈连接到目标圆圈。例如,以下代码表示目标圆圈连接到圆圈 1 和圆圈 2:
{"connectedPoints" : [1,2]}
|
确定升级
当没有线条相互交叉时,级别就完成了。我们遍历每条线,看看有多少线条是细的。细线意味着它们没有与其他线条交叉。我们可以使用细线来计算所有线条的比例,以获得级别的完成百分比:
var progress = 0;
for (var i in untangleGame.lines) {
if (untangleGame.lines[i].thickness === untangleGame.thinLineThickness) {
progress+=1;
}
}
var progressPercentage = Math.floor(progress/untangleGame.lines.length * 100);
当进度达到 100%时,我们可以简单地确定级别已经完成。
显示当前级别和完成进度
我们在 Canvas 游戏下方显示了一句话,描述当前级别的状态和进度。它用于向玩家显示游戏状态,让他们知道他们在游戏中正在取得进展:
<p>Puzzle <span id="level">0</span>, Completeness: <span id="progress">0</span>%</p>
我们使用在第二章中讨论的 jQuery text 函数,基于 DOM 的游戏开发入门,来更新完成进度:
$("#progress").text(progressPercentage);
尝试一下英雄
到目前为止,我们在示例 Untangle 谜题游戏中只定义了三个级别。但只有三个级别玩起来还不够有趣。为什么不给游戏添加更多级别呢?如果你想不出级别,试着在互联网上搜索类似的 untangle 游戏,并从中获得一些灵感。
在 Canvas 上绘制文本
想象一下,现在我们想在 Canvas 内部直接显示进度级别。Canvas 为我们提供了在 Canvas 内部绘制文本的方法。
操作时间 – 在 canvas 元素内显示进度级别文本
-
我们将继续使用我们的 Untangle 游戏。在文本编辑器中打开
untangle.drawing.jsJavaScript 文件。在gameloop函数中的 Canvas 绘制代码之后添加以下代码,该代码在 Canvas 内部绘制当前级别和进度文本:untangleGame.drawLevelProgress = function() { var ctx = untangleGame.ctx; ctx.font = "26px Arial"; ctx.fillStyle = "WHITE"; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; ctx.fillText("Puzzle "+untangleGame.currentLevel+", Completeness: " + untangleGame.levelProgress + "%", 60, ctx.canvas.height-60); } -
打开
untangle.js文件。我们在gameloop函数中放入以下代码:untangleGame.drawLevelProgress(); -
保存文件,并在网页浏览器中预览
index.html。我们会看到文本现在被绘制在 Canvas 内部。![操作时间 – 在 canvas 元素内显示进度级别文本]()
发生了什么?
我们刚刚在基于 Canvas 的游戏中绘制了标题和级别进度文本。我们通过使用fillText函数在 Canvas 上绘制文本。以下表格显示了如何使用该函数:
fillText(string, x, y);
| 参数 | 定义 |
|---|---|
String |
我们将要绘制的文本 |
X |
文本绘制的x坐标 |
Y |
文本绘制的y坐标 |
这是绘制文本的基本设置。还有更多绘图上下文属性可以设置文本绘制:
| 上下文属性 | 定义 | 讨论 |
|---|
| context.font | 文本的字体样式 | 这与我们用于在 CSS 中声明字体样式的语法相同。例如,以下代码将字体样式设置为 20 像素粗体,使用 Arial 字体:
ctx.font = "bold 20px Arial";
|
| context.textAlign | 文本对齐 | 对齐方式定义了文本的对齐方式。它可以有以下值之一:
-
start -
end -
left -
right -
center
例如,如果我们想在 Canvas 的右边缘放置一些文本,使用left对齐意味着我们需要计算文本的宽度,以便知道文本的 x 坐标。在这种情况下使用右对齐,我们只需直接将 x 位置设置为 Canvas 宽度。文本将自动放置在 Canvas 的右边缘。|
| context.textBaseline | 文本基线 | 以下列出了textBaseline属性的常见值:
-
top -
middle -
bottom -
alphabet
与文本对齐类似,当我们要将文本放置在 Canvas 底部时,bottom基线非常有用。fillText函数的y位置基于文本的底部基线而不是顶部。alphabet基线根据小写字母对齐 y 位置。以下截图显示了使用alphabet基线的文本绘制效果。|
注意
请注意,Canvas 中的文本绘制被视为位图图像数据。这意味着访客无法选择文本;搜索引擎无法索引文本;我们无法搜索文本。因此,我们应该仔细考虑是否要在 Canvas 内绘制文本,或者直接将其放置在 DOM 中。或者,我们应该将canvas元素内的备用文本更改为反映绘制文本。
快速问答 - 在 Canvas 中绘制文本
Q1. 如果我们要在 Canvas 的右下角附近绘制一些文本,哪种对齐和基线设置更好?
-
左对齐,底部基线。
-
居中对齐,字母基线。
-
右对齐,底部基线。
-
居中对齐,中间基线。
Q2. 我们将使用最新的开放网络标准制作一本具有翻页效果的逼真书籍。以下哪个设置更好?
-
在 Canvas 中绘制逼真的书籍,包括所有文本和翻页效果。
-
将所有文本和内容放入 DOM 中,并在 Canvas 中绘制逼真的翻页效果。
在 Canvas 中使用嵌入的 Web 字体
我们在记忆中使用了自定义字体,与上一章中的游戏相匹配。自定义字体嵌入在 Canvas 中同样有效。让我们在我们的 Untangle 游戏中进行一个在 Canvas 中绘制自定义字体的实验。
实践时间 - 将 Google 网络字体嵌入到 canvas 元素中
让我们使用手写风格的字体绘制 Canvas 文本:
-
首先,前往 Google 字体目录并选择一种手写风格的字体。我使用了Rock Salt字体,您可以从以下网址获取它:
www.google.com/fonts/specimen/Rock+Salt。 -
Google 字体目录提供了一个 CSS 链接代码,我们可以将其添加到游戏中以嵌入字体。将以下 CSS 链接添加到
index.html的头部:<link href='http://fonts.googleapis.com/css?family=Rock+Salt' rel='stylesheet' type='text/css'> -
下一步是使用字体。我们打开
untangle.drawing.jsJavaScript 文件,并在drawLevelProgress函数中将上下文的font属性修改为以下内容:ctx.font = "26px 'Rock Salt'"; -
现在是时候在我们的网页浏览器中打开游戏以测试结果了。Canvas 中绘制的文本现在使用的是我们在 Google 字体目录中选择的字体。
![动手时间 – 将 Google 网络字体嵌入到 canvas 元素中]()
刚才发生了什么?
我们只是选择了一个网络字体并将其嵌入到 Canvas 中绘制文本。这表明我们可以在 Canvas 中填充文本的字体家族中像其他 DOM 元素一样进行样式设置。
提示
有时候,尽管单词数量相同,不同字体的文本宽度也会有所不同。在这种情况下,我们可以使用measureText函数来获取我们绘制的文本宽度。Mozilla 开发者网络解释了如何使用该函数,请参阅:developer.mozilla.org/en/Drawing_text_using_a_canvas#measureText()。
在 Canvas 中绘制图像
我们已经在 Canvas 内部绘制了一些文本。那么绘制图像呢?是的。绘制图像和图像处理是 Canvas 的一个大特性。
动手时间 – 向游戏中添加图形
我们将要为游戏绘制一个黑板背景:
-
从代码示例包或以下网址下载图形文件:
mak.la/book-assets。图形文件包括我们本章需要的所有图形。 -
将新下载的图形文件放入一个名为
images的文件夹中。 -
现在是真正加载图像的时候了。在我们刚刚下载的图形文件中有一个
board.png文件。这是一个黑板图形,我们将它绘制在 Canvas 上作为背景。在上一步骤中添加的代码之后添加以下代码:untangleGame.loadImages = function() { // load the background image untangleGame.background = new Image(); untangleGame.background.onerror = function() { console.log("Error loading the image."); } untangleGame.background.src = "images/board.png"; }; -
由于图像加载需要时间,我们还需要确保在绘制之前它已经被加载:
untangleGame.drawBackground = function() { // draw the image background untangleGame.ctx.drawImage(untangleGame.background, 0, 0); }; -
打开
untangle.js文件,在 jQuery 文档的ready函数中:untangleGame.loadImages(); -
在
untangle.js文件中的gameloop函数里,我们在清除上下文之后、绘制其他任何内容之前,在 Canvas 上绘制图像:untangleGame.drawBackground(); -
接下来,我们不希望 Canvas 设置背景颜色,因为我们有一个带有透明边框的 PNG 背景。打开
untangle.css文件并移除 Canvas 中的背景属性。 -
现在,保存所有文件并在网络浏览器中打开
index.html文件。背景应该在那里,手写字体应该与我们的黑板主题相匹配。![添加图形到游戏的时间 - 添加图形]()
发生了什么?
我们刚刚在 canvas 元素内部绘制了一个图像。您可以在以下 URL 中找到工作示例:
makzan.net/html5-games/untangle-wip-graphics1/
在画布上绘制图像有两种常见方法。我们可以引用现有的 <img> 标签,或者动态地在 JavaScript 中加载图像。
这里是如何在 canvas 中引用现有的图像标签的,假设我们有以下 img 标签在 HTML 中:
<img id="board" src="img/board.png">
我们可以使用以下 JavaScript 代码在画布上绘制图像:
var img = document.getElementById('board');
context.drawImage(img, x, y);
这里是另一个代码片段,用于在不将 <img> 标签附加到 DOM 中的情况下加载图像。如果我们将在 JavaScript 中加载图像,我们需要确保图像在画布上绘制之前已经加载。因此,我们在图像的 onload 事件之后绘制图像:
var board = new Image();
board.onload = function() {
context.drawImage(board, x, y);
}
board.src = "images/board.png";
小贴士
设置 onload 事件处理程序和分配图像 src 时,顺序很重要。
当我们将 src 属性分配给图像,并且如果图像被浏览器缓存,一些浏览器会立即触发 onload 事件。如果我们把 onload 事件处理程序放在分配 src 属性之后,我们可能会错过它,因为它在我们设置事件处理程序之前被触发。
在我们的示例中,我们使用了后一种方法。我们创建了一个 Image 对象并加载了背景。
在加载图像时,我们还应该处理另一个事件,即 onerror 事件。当我们访问额外的网络数据时,它特别有用。我们使用以下代码片段来检查我们示例中的错误:
untangleGame.background.onerror = function() {
console.log("Error loading the image.");
}
尝试一下英雄
现在加载错误现在只会在控制台中显示一条消息。通常玩家不会查看控制台。我们是否可以向画布写入一条消息,告诉玩家游戏未能加载游戏资源?
使用 drawImage 函数绘制图像
使用 drawImage 函数在画布上绘制图像有三种方法。我们可以在给定的坐标上不进行任何修改地绘制图像,我们也可以在给定的坐标上使用缩放因子绘制图像,或者甚至裁剪图像并只绘制剪切区域。
drawImage 函数接受多个参数:
-
在
drawImage(image, x, y);中出现的每个参数都在以下表格中解释:参数 定义 讨论 image我们将要绘制的图像的引用。 我们可以通过使用现有的 img元素或创建 JavaScriptImage对象来获取图像引用。x图像将在画布坐标中放置的 x 位置。 x 和 y 坐标是我们放置图像相对于其左上角的位置。 y图像将在画布坐标中放置的 y 位置。 -
在
drawImage(image, x, y, width, height);函数中出现的每个参数都在以下表格中进行了说明:参数 定义 讨论 image我们将要绘制的图像引用。 我们可以通过获取现有的 img元素或创建 JavaScriptImage对象来获取图像引用。x图像在 Canvas 坐标系中放置的x位置。 x 和 y 坐标是相对于图像的左上角放置图像的位置。 y图像在 Canvas 坐标系中放置的y位置。 width最终绘制图像的宽度。 如果宽度和高度与原始图像不同,我们将对图像应用缩放。 height最终绘制图像的高度。 -
在
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, width, height);函数中出现的每个参数都在以下表格中进行了说明:参数 定义 讨论 image我们将要绘制的图像引用。 我们可以通过获取现有的 img元素或创建 JavaScriptImage对象来获取图像引用。sx剪裁区域左上角的x坐标。 通过将剪裁区域的x、y、宽度、高度一起定义,可以确定一个矩形剪裁区域。给定的图像将通过这个矩形进行剪裁。 sy剪裁区域左上角的y坐标。 sWidth剪裁区域的宽度。 sHeight剪裁区域的高度。 dx图像在 Canvas 坐标系中放置的x位置。 x 和 y 坐标是相对于图像的左上角放置图像的位置。 dy图像在 Canvas 坐标系中放置的y位置。 width最终绘制图像的宽度。 如果剪裁的宽度和高度与剪裁维度不同,我们将对剪裁的图像应用缩放。 height最终绘制图像的高度。
英雄尝试 – 优化背景图像
在示例中,我们在每次调用 gameloop 函数时将黑板图像作为背景绘制。由于我们的背景是静态的,并且不会随时间变化,因此不断清除并重新绘制它是在浪费 CPU 资源。我们如何优化这个问题?在后面的部分,我们将游戏分成多个层级,以避免重新绘制静态的背景图像。
装饰基于 Canvas 的游戏
我们已经增强了 Canvas 游戏,添加了渐变和图像。在继续前进之前,让我们装饰我们的 Canvas 游戏网页。
实施动作 – 为游戏添加 CSS 样式和图像装饰
我们将构建一个居中对齐的布局,包含游戏标题:
-
在文本编辑器中打开
index.html文件。使用一个分组 DOM 元素来设置布局样式对我们来说更容易。我们将所有元素放入一个具有id页面的部分中。用以下内容替换 HTML 文件的内容:<section id="page"> <header> <h1>Untangle Puzzle Game in Canvas</h1> </header> <canvas id="game" width="768" height="400"> This is an interactive game with circles and lines connecting them. </canvas> <p>Puzzle <span id="level">0</span>, Completeness: <span id="progress">0</span>%</p> <footer> <p>This is an example of Untangle Puzzle Game in Canvas.</p> </footer> </section> -
让我们在
untangle.css文件中应用 CSS 到页面布局。用以下代码替换现有内容:html, body { background: url(../images/title_bg.png) 50% 0 no-repeat, url(../images/bg_repeat.png) 50% 0 repeat-y #889ba7; margin: 0; color: #111; } #game{ position:relative; } #page { width: 820px; min-height: 800px; margin: 0 auto; padding: 0; text-align: center; text-shadow: 0 1px 5px rgba(60,60,60,.6); } header { height: 88px; padding-top: 36px; margin-bottom: 50px; font-family: "Rock Salt", Arial, sans-serif; font-size: 14px16px; text-shadow: 0 1px 0 rgba(200,200,200,.5); color: #121; } -
是时候保存所有文件并在网页浏览器中预览游戏了。我们应该看到一个标题带和一个居中对齐的精美布局。以下截图显示了结果:
![行动时间 – 为游戏添加 CSS 样式和图像装饰]()
发生了什么?
我们刚刚装饰了包含我们的基于 Canvas 的游戏的网页。尽管我们的游戏基于 Canvas 绘图,但这并不限制我们用图形和 CSS 样式装饰整个网页。
注意
canvas 元素的默认背景
canvas 元素的默认背景是透明的。如果我们不对 Canvas 设置任何背景 CSS 样式,它将是透明的。当我们的绘图不是矩形时,这很有用。在这个例子中,纹理布局背景显示在 Canvas 区域内。
快速问答 – 设置 Canvas 背景
Q1. 我们如何设置 Canvas 背景为透明?
-
将背景颜色设置为
#ffffff。 -
不做任何事情。默认情况下它是透明的。
在 Canvas 中动画化精灵图集
我们首先在 第三章 中使用了 精灵图集 图像,使用 CSS3 构建纸牌匹配游戏,当显示一副扑克牌时。
行动时间 – 制作游戏指南动画
在图片文件夹中有一个名为 guide_sprite.png 的图形文件。它是一个包含动画每个步骤的游戏指南图形。

让我们用 动画 将这个指南绘制到我们的游戏中:
-
在文本编辑器中打开
untangle.drawing.jsJavaScript 文件。 -
在
untangleGame.loadImages函数中,添加以下代码:// load the guide sprite image untangleGame.guide = new Image(); untangleGame.guide.onload = function() { // setup timer to switch the display frame of the guide sprite untangleGame.guideFrame = 0; setInterval(untangleGame.guideNextFrame, 500); } untangleGame.guide.src = "images/guide_sprite.png"; -
仍然在
untangleGame.drawing.js文件中,我们添加以下函数,每 500 毫秒将当前帧移动到下一帧:untangleGame.guideNextFrame = function() { untangleGame.guideFrame++; // there are only 6 frames (0-5) in the guide animation. // we loop back the frame number to frame 0 after frame 5. if (untangleGame.guideFrame > 5) { untangleGame.guideFrame = 0; } } -
接下来,我们在
untangleGame.drawing.js文件中定义drawGuide函数。这个函数根据当前帧绘制指南动画:untangleGame.drawGuide = function() { var ctx = untangleGame.ctx; // draw the guide animation if (untangleGame.currentLevel === 0) { // the dimension of each frame is 80x130. var nextFrameX = untangleGame.guideFrame * 80; ctx.drawImage(untangleGame.guide, nextFrameX, 0, 80, 130, 325, 130, 80, 130); } }; -
让我们切换到
untangle.js文件。在gameloop函数中,我们在结束gameloop函数之前调用指南绘图函数。untangleGame.drawGuide(); -
让我们在网页浏览器中通过打开
index.html文件来观看动画。以下截图展示了游戏指南的动画。指南动画将在玩家升级前播放并循环:![行动时间 – 制作游戏指南动画]()
发生了什么?
当使用 drawImage 上下文函数时,我们只能绘制图像的一个区域。
以下截图逐步展示了动画过程。矩形是剪切区域:

我们使用名为guideFrame的变量来控制显示哪个帧。每个帧的宽度是 80。因此,我们通过乘以宽度和当前帧号来获取剪切区域的 x 位置:
var nextFrameX = untangleGame.guideFrame * 80;
ctx.drawImage(untangleGame.guide, nextFrameX, 0, 80, 130, 325, 130, 80, 130);
guideFrame变量通过以下guideNextFrame函数每 500 毫秒更新一次:
untangleGame.guideNextFrame = function() {
untangleGame.guideFrame += 1;
// there are only 6 frames (0-5) in the guide animation.
// we loop back the frame number to frame 0 after frame 5.
if (untangleGame.guideFrame > 5) {
untangleGame.guideFrame = 0;
}
}
在开发游戏时,动画精灵是一种常用的技术。在开发传统视频游戏时使用精灵动画有一些好处。这些原因可能不适用于网络游戏开发,但使用精灵表动画还有其他好处:
-
所有帧都作为一个文件加载,所以一旦精灵文件被加载,整个动画就准备好了。
-
将所有帧放入一个文件意味着我们可以减少从网络浏览器到服务器的 HTTP 请求。如果每个帧都是一个文件,浏览器会多次请求该文件,而现在它只需请求一个文件并使用一个 HTTP 请求。
-
将不同的图像放入一个文件也减少了文件的重复,这有助于减少重复文件的头部、尾部和元数据。
-
将所有帧放入一个图像中意味着我们可以轻松地剪切图像以显示任何帧,而无需复杂的代码来更改图像源。
精灵表动画通常用于角色动画。以下截图是我在一个名为邻居的 HTML5 游戏中使用的愤怒猫的精灵动画。

在本例中,我们通过剪切帧并自行设置计时器来构建精灵表动画。当处理大量动画时,我们可能希望使用第三方精灵动画插件或创建自己的 Canvas 精灵动画,以更好地重用和管理逻辑代码。
注意
精灵动画是 HTML5 游戏开发中的一个重要主题,有许多在线资源讨论了这个主题。以下是一些链接:
Simurai 的精灵动画教程(simurai.com/blog/2012/12/03/step-animation/)讨论了我们可以如何仅使用 CSS 制作精灵动画。
Spritely (www.spritely.net/),另一方面,通过 CSS 在 DOM 元素上提供精灵动画。当我们想要在不使用 Canvas 的情况下动画化精灵时,这很有用。
创建多层 Canvas 游戏
现在所有东西都绘制到了没有其他状态来区分绘制项的上下文中。我们可能将 Canvas 游戏分成不同的层,并编写逻辑来控制一次绘制一个层。
行动时间 - 将游戏分为四层
我们将把我们的 Untangle 游戏分为四层:
-
在
index.html中,我们需要更改或替换当前的canvas标签,如下所示。它应该在部分内包含几个 Canvas:<section id="layers"> <canvas id="bg" width="768" height="440"> This is an interactive game with circles and lines connecting them. </canvas> <canvas id="guide" width="768" height="440"></canvas> <canvas id="game" width="768" height="440"></canvas> <canvas id="ui" width="768" height="440"></canvas> </section> -
我们还需要将一些样式应用到画布上,以便它们相互重叠,从而创建多层效果。同时,我们必须准备一个
fadeout类和一个dim类,以便使目标透明。将以下代码添加到untangle.css文件中:#layers { position: relative; margin: 0 auto; width:768px; height: 440px; } #layers canvas{ top: 0; left: 0; position: absolute; } #guide { opacity: 0.7; transition: opacity 0.5s ease-out; } #guide.fadeout { opacity: 0; } #ui { transition: opacity 0.3s ease-out; } #ui.dim { opacity: 0.3; } -
打开
untangle.jsJavaScript 文件。我们修改代码以支持层功能。首先,我们添加一个数组来存储每个 Canvas 的上下文引用。在文件的开始处添加它,在 jQuery 文档就绪函数之前,并在untangleGame定义之后:untangleGame.layers = []; -
然后,我们在 jQuery 文档就绪函数中删除以下代码行。
var canvas = document.getElementById("game"); untangleGame.ctx = canvas.getContext("2d"); -
我们用以下代码替换我们删除的代码。我们获取每个 Canvas 层的上下文引用并将它们存储在数组中:
// prepare layer 0 (bg) var canvas_bg = document.getElementById("bg"); untangleGame.layers[0] = canvas_bg.getContext("2d"); // prepare layer 1 (guide) var canvas_guide = document.getElementById("guide"); untangleGame.layers[1] = canvas_guide.getContext("2d"); // prepare layer 2 (game) var canvas = document.getElementById("game"); var ctx = canvas.getContext("2d"); untangleGame.layers[2] = ctx; // prepare layer 3 (ui) var canvas_ui = document.getElementById("ui"); untangleGame.layers[3] = canvas_ui.getContext("2d"); -
让我们切换到
untangle.drawing.js文件。我们将在几个地方更新上下文引用以支持多层。 -
现在有四个 Canvas 上下文,我们可以清除。找到现有的
clear函数,并将其替换为以下内容:untangleGame.clear = function(layerIndex) { var ctx = untangleGame.layers[layerIndex]; ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height); }; -
在
drawCircle和drawLine函数中,将var ctx = untangleGame.ctx;替换为以下代码:var ctx = untangleGame.layers[2]; -
在
drawLevelProgress函数中,将var ctx = untangleGame.ctx;替换为以下代码:var ctx = untangleGame.layers[3]; -
在
drawBackground函数中,我们将现有代码替换为以下代码,该代码在索引为0的背景层上绘制:untangleGame.drawBackground = function() { // draw the image background var ctx = untangleGame.layers[0]; ctx.drawImage(untangleGame.background, 0, 0); }; -
然后,我们转到
loadImages函数。向函数中添加以下代码。它绘制一次背景:untangleGame.background.onload = function() { untangleGame.drawBackground(); } -
在
drawGuide函数中,将var ctx = untangleGame.ctx;替换为以下代码:var ctx = untangleGame.layers[1]; -
实际上,我们也在这个函数中淡出指南层。因此,我们将整个
drawGuide函数替换为以下内容:untangleGame.drawGuide = function() { var ctx = untangleGame.layers[1]; // draw the guide animation if (untangleGame.currentLevel < 2) { // the dimension of each frame is 80x130. var nextFrameX = untangleGame.guideFrame * 80; ctx.drawImage(untangleGame.guide, nextFrameX, 0, 80, 130, 325, 130, 80, 130); } // fade out the guideline after level 0 if (untangleGame.currentLevel === 1) { $("#guide").addClass('fadeout'); } }; -
在
guideNextFrame函数内部,我们清除指南层并重新绘制它。向函数末尾添加以下代码:untangleGame.clear(1); untangleGame.drawGuide(); -
在圆形拖动过程中,我们不希望我们的进度文本层阻挡游戏元素。因此,我们将定义一个额外的函数,在有任何游戏圆形重叠层时降低进度层的透明度:
untangleGame.dimUILayerIfNeeded = function() { // get all circles, // check if the ui overlap with the game objects var isOverlappedWithCircle = false; for(var i in untangleGame.circles) { var point = untangleGame.circles[i]; if (point.y > 280) { isOverlappedWithCircle = true; } } if (isOverlappedWithCircle) { $("#ui").addClass('dim'); } else { $("#ui").removeClass('dim'); } }; -
我们已经完成了
untangle.drawing.js文件。让我们切换回untangle.js文件。在gameloop函数中,我们删除对drawBackground和drawGuide函数的调用。然后,我们调用dimUILayerIfNeeded函数。我们还在每个游戏循环中清除层 2 游戏元素和层 3 关卡进度。现在gameloop函数变为以下内容:function gameloop() { // clear the canvas before re-drawing. untangleGame.clear(2); untangleGame.clear(3); untangleGame.drawAllLines(); untangleGame.drawAllCircles(); untangleGame.drawLevelProgress(); untangleGame.dimUILayerIfNeeded(); } -
最后,打开
untangle.input.js文件。我们曾在#gameCanvas 上设置了鼠标按下、移动和抬起事件监听器。由于游戏 Canvas 现在是重叠的,我们之前在gameCanvas 上的鼠标事件监听器不再触发。我们可以将监听器更改为监听其父#layersDIV 的事件,该 DIV 具有与 Canvas 相同的定位和尺寸:$("#layers"). bind("mousedown touchstart", function(e){ // existing code that handles mousedown and touchstart. }); $("#layers"). bind("mousemove touchmove", function(e) { // existing code that handles mousemove and touchmove. }); $("#layers"). bind("mouseup touchend", function(e){ // existing code that handles mouseup and touchend. }); -
保存所有文件,并在网页浏览器中检查我们的代码更改。游戏应该显示得好像我们没有做任何改变一样。尝试将圆圈拖动到黑板底部边缘附近。关卡进度文本应该变暗到低透明度。当你完成第一关时,指南动画将优雅地淡出。以下截图显示了半透明度的关卡进度:
![行动时间 – 将游戏分为四层]()
发生了什么?
我们将我们的工作游戏分为四层。在这一节中有相当多的变化。你可以尝试在:makzan.net/html5-games/untangle/上的工作示例。通过观察源代码,你可以查看未压缩的代码示例。
现在总共有四个 Canvas。每个 Canvas 负责一个层。层被分为背景、游戏指南、游戏本身以及显示关卡进度的用户界面。
默认情况下,Canvas,就像其他元素一样,是依次放置的。为了重叠所有 Canvas 以构建层效果,我们应用了absolute定位。
以下截图显示了我们的游戏中的四个层。默认情况下,后来添加的 DOM 位于先添加的 DOM 之上。因此,bg Canvas 位于底部,ui 位于顶部:

通过使用不同的层,我们可以为每一层创建特定的逻辑。例如,这个游戏中的背景是静态的。我们只绘制一次。指南层是一个 6 帧动画,每帧 500 毫秒。我们在 500 毫秒的间隔中重新绘制指南层。游戏层和 UI 层是核心游戏逻辑,我们每秒绘制 30 次。
将 CSS 技术与 Canvas 绘图混合
我们正在创建一个基于 Canvas 的游戏,但我们并不局限于仅使用 Canvas 绘图 API。每个层都是一个独立的 Canvas 层。我们可以将 CSS 技术应用于任何层。现在,关卡进度信息在另一个具有 ID ui 的 Canvas 中。在这个例子中,我们混合了我们在第三章中讨论的 CSS 技术,使用 CSS3 构建匹配卡片游戏。
当我们在 Canvas 周围拖动圆圈时,它们可能会覆盖关卡信息。在绘制 UI Canvas 层时,我们检查是否有任何圆圈的坐标太低且与文本重叠。然后我们降低 UI Canvas CSS 的透明度,以免分散玩家对圆圈的注意力。
当玩家升级后,我们也淡出指南动画。这是通过使用 CSS 过渡渐变将整个guide Canvas 的透明度淡出到 0 来完成的。由于guide Canvas 只负责那个动画,隐藏该 Canvas 不会影响其他元素:
if (untangleGame.currentLevel === 1) {
$("#guide").addClass('fadeout');
}
小贴士
仅清除更改区域以提升 Canvas 性能
我们可以使用清除函数来仅清除 Canvas 上下文的一部分。这将提高性能,因为它避免了每次都重绘整个 Canvas 上下文。这是通过标记自上次绘制以来状态已更改的上下文的'脏'区域来实现的。
在我们的示例中,在指南 Canvas 层中,我们可能考虑仅清除精灵表图像绘制的区域,而不是整个 Canvas。
在简单的 Canvas 示例中,我们可能看不到显著的区别,但当我们有一个包含许多精灵图像动画和复杂形状绘制的复杂 Canvas 游戏时,它有助于提高性能。
尝试一下英雄
当玩家进入第 2 级时,我们淡出指南。那么,当玩家拖动任何圆圈时,我们是否应该淡出指南动画?我们该如何做到这一点?
摘要
在本章中,你学习了如何在 Canvas 中绘制梯度、文本和图像。具体来说,我们构建了 Untangle 游戏逻辑,并使用了几个高级 Canvas 技术,包括在绘制图像时使用裁剪功能进行精灵表动画。我们通过堆叠几个canvas元素将游戏分为几个层级。这使我们能够分别和具体地处理游戏渲染的不同部分。最后,我们在基于 Canvas 的游戏中混合了 CSS 过渡动画。
在这本书中,我们没有提到的是 Canvas 中的位图操作。Canvas 上下文是一个位图数据,我们可以对每个像素应用操作。例如,我们可以在 Canvas 中绘制一个图像,并应用类似 Photoshop 的滤镜到图像上。我们不会在书中涵盖这一点,因为图像操作是一个高级主题,其应用可能不与游戏开发相关。
现在你已经学习了在 Canvas 中构建游戏以及为游戏对象(如游戏角色)制作动画,我们准备在下一章中为我们的游戏添加音频组件和音效。
我们将在第九章中回到基于 Canvas 的游戏,使用 Box2D 和 Canvas 构建物理赛车游戏。
第六章。为您的游戏添加声音效果
在前面几章中,我们讨论了几种绘制游戏对象的技术。在本章中,我们将专注于使用 HTML5 规范中引入的 audio 标签。我们可以添加声音效果、背景音乐,并通过 JavaScript API 控制音频。此外,我们将在本章中构建一个音乐游戏。这是一个需要玩家在正确的时间击中正确的弦来产生音乐的游戏。
在本章中,你将学习以下主题:
-
为 播放 按钮添加声音效果
-
构建迷你钢琴音乐游戏
-
将音乐游戏与 播放 按钮链接
-
为游戏添加键盘和触摸输入
-
创建键盘驱动的音乐游戏
-
使用等级数据记录和游戏结束事件完成音乐游戏
你可以在:makzan.net/html5-games/audiogame/ 上播放游戏示例。
以下截图显示了我们将通过本章创建的最终结果:

那么,让我们开始吧。
为播放按钮添加声音效果
在前面的 Untangle 游戏示例中,我们有一些鼠标交互。现在想象一下,我们想要在鼠标交互中添加声音效果。这需要我们指导游戏使用音频文件。我们将使用 audio 标签在按钮被点击时创建声音效果。
添加到播放按钮的声音效果 - 行动时间
我们将从代码包中提供的代码示例开始。我们将有一个类似于以下截图所示的文件夹结构:

执行以下步骤集以向 播放 按钮添加声音效果:
-
index.html文件包含 HTML 的基本结构。现在让我们将以下代码添加到index.html文件的主体部分:<div id="game"> <section id="menu-scene" class="scene"> <a href="#game"><span>Play</span></a> </section> </div> <audio id="buttonover"> <source src="img/button_over.aac" /> <source src="img/button_over.ogg" /> </audio> <audio id="buttonactive"> <source src="img/button_active.aac" /> <source src="img/button_active.ogg" /> </audio> -
HTML 文件成功运行了样式表。该文件可以在名为
audiogame.css的代码包中找到。 -
接下来,我们在 JavaScript 文件中创建基本的代码结构。在
audiogame.js文件中添加以下 JavaScript 代码:(function($){ var audiogame = { // game init method initGame: function() { this.initMedia(); this.handlePlayButton(); }, // init medias initMedia: function() { // TODO: init media related logic }, handlePlayButton: function() { // TODO: logic for the play button } }; // init function when the DOM is ready $(function(){ audiogame.initGame(); }); })(jQuery); -
然后我们存储音频标签的引用。在
initMedia函数中添加以下代码:initMedia: function() { // get the references of the audio element. this.buttonOverSound = document.getElementById("buttonover"); this.buttonOverSound.volume = 0.3; this.buttonActiveSound = document.getElementById("buttonactive"); this.buttonActiveSound.volume = 0.3; }, -
我们在 JavaScript 文件中为按钮添加声音效果。在
handlePlayButton函数中添加以下 JavaScript 代码:handlePlayButton: function() { var game = this; // listen the button event that links to #game $("a[href='#game']") .hover(function(){ game.buttonOverSound.currentTime = 0; game.buttonOverSound.play(); },function(){ game.buttonOverSound.pause(); }) .click(function(){ game.buttonActiveSound.currentTime = 0; game.buttonActiveSound.play(); return false; }); } -
在浏览器中打开
index.html文件。在那里,你应该看到一个黄色背景上的 播放 按钮,如下面的截图所示。尝试将鼠标移至按钮上并点击它。当你悬停在按钮上时,你应该能听到声音,当你点击按钮时,应该能听到另一个声音:![添加到播放按钮的声音效果 - 行动时间]()
发生了什么事?
我们刚刚创建了一个基本的 HTML5 游戏布局,将播放按钮放置在页面中间。JavaScript 文件处理按钮的鼠标悬停和点击事件,并播放相应的音效。
定义音频元素
使用audio标签的最简单方法是提供源文件。以下代码片段显示了我们可以如何定义一个音频元素:
<audio>
<source src="img/button_active.aac" >
<source src="img/button_active.ogg" >
<!-- Any code for browser that does not support audio tag -->
</audio>
除了设置audio标签的源文件外,我们还可以通过使用几个属性来获得额外的控制。以下表格显示了我们可以为音频元素设置的属性:
| 参数 | 定义 | 说明 |
|---|
| src | 定义音频元素的源文件 | 当我们在audio标签中使用src属性时,它指定了一个音频文件的源文件。例如,在以下代码中,我们加载了一个 Ogg 格式的音效文件:
<audio src='sound.ogg'>
如果我们想指定多个不同格式的文件,那么我们就在音频元素内部使用source标签。以下代码指定了具有不同格式的audio标签,以支持不同的网络浏览器:
<audio>
<source src='sound.ogg'>
<source src='sound.aac'>
<source src='sound.wav'>
</audio>
|
| autoplay | 指定音频在加载后自动播放 | 自动播放作为一个独立的属性使用。这意味着以下两行代码没有区别:
<audio src='file.ogg' autoplay>
<audio src='file.ogg autoplay="autoplay">
|
loop |
指定音频在播放结束后从开头再次播放 | 这也是一个独立的属性。 |
|---|
| preload | 指定音频源在页面加载时加载 | preload属性可以取以下任一值:
-
preload="auto" -
preload="metadata" -
preload="none"
当preload作为一个独立的属性并设置为auto时,浏览器将预加载音频。当preload设置为metadata时,浏览器不会预加载音频的内容。然而,它将加载音频的元数据,如时长和大小。当preload设置为none时,浏览器将不会预加载音频。内容和元数据将在播放时加载。|
controls |
显示音频的播放控制 | controls属性是一个独立的属性。它指示浏览器在音频位置显示播放控制。 |
|---|
以下截图显示了 Chrome 显示控制:

播放声音
我们可以通过调用getElementById函数来获取音频元素的引用。然后,通过调用play函数来播放它。以下代码播放了buttonactive音频:
<audio id="buttonactive">
<source src="img/button_active.aac" />
<source src="img/button_active.ogg" />
</audio>
<script>
document.getElementById("buttonactive").play();
</script>
play函数从已播放时间开始播放音频,该时间存储在currentTime属性中。currentTime的默认值是零。以下代码从 3.5 秒处播放音频:
document.getElementById("buttonactive").currentTime = 3.5;
document.getElementById("buttonactive").play();
jQuery 选择器与浏览器选择器
我们之前使用 jQuery 的查询选择器 $("#buttonactive") 来选择元素。我们对选中的元素应用 DOM 操作,例如切换类或获取文本内容。在这个例子中,我们使用 document.getElementById("buttonactive") 来获取元素的引用。这是因为我们正在使用浏览器上的 Web Audio API 对该元素进行操作。我们不需要 jQuery 对象,我们想要的是浏览器 DOM 元素。
另一种方法是使用 jQuery 选择元素,并通过其 .get() 方法检索 jQuery 对象的 DOM 元素。
暂停声音
与播放按钮类似,我们可以通过使用 pause 函数来暂停音频元素的播放。以下代码将暂停 buttonactive 音频元素:
<script>
document.getElementById("buttonactive").pause();
</script>
注意
没有用于停止音频元素的 stop 函数。相反,我们可以暂停音频并重置元素的 currentTime 属性为 0。以下代码展示了如何停止音频元素:
function stopAudio(){
document.getElementById("buttonactive").pause();
document.getElementById("buttonactive").currentTime = 0;
}
调整声音音量
我们也可以设置音频元素的音量。音量必须在 0 到 1 之间。我们可以将音量设置为 0 以静音,或设置为 1 以获得最大音量。以下代码片段将 buttonactive 音频的音量设置为 30%:
document.getElementById("buttonactive").volume = 0.3;
使用 jQuery 鼠标悬停事件
jQuery 提供了一个 hover 函数来定义当我们将鼠标悬停在 DOM 元素上和移出时应该执行的行为。以下是使用 hover 函数的方法:
.hover(handlerIn, handlerOut);
hover 函数的参数解释如下:
| 参数 | 讨论 |
|---|---|
handlerIn |
当鼠标移入时执行该函数。 |
handlerOut |
这是可选的。当鼠标移出时执行该函数。当未提供此函数时,移出行为与第一个函数相同。 |
在以下代码中,当鼠标移入时我们将播放鼠标悬停声音效果,并在鼠标移出时暂停声音:
$("a[href='#game']").hover(function(){
audiogame.buttonOverSound.currentTime = 0;
audiogame.buttonOverSound.play();
},function(){
audiogame.buttonOverSound.pause();
});
WebAudio 文件格式
当我们定义音频元素的源时,我们使用 AAC 格式和 Ogg 格式文件。Ogg 是一个免费且开源的媒体容器格式,在 Mozilla Firefox 中得到支持。有一些应用程序可以将音频文件转换为 Ogg 文件。Audacity 就是其中之一。此外,还有一些方便使用的在线工具。Online-Convert (audio.online-convert.com) 就是其中之一。
注意
我们没有使用 MP3 格式,是因为许可证费用。根据 MP3 许可网站 (www.mp3licensing.com/royalty/games.html),在发行超过 5,000 份副本的分布式游戏中使用 MP3 的版税为每款游戏 2,500 美元。
以下表格显示了在撰写本书时最新流行网络浏览器支持的音频格式:
| 浏览器 | Ogg | AAC | WAV |
|---|---|---|---|
| Firefox | 是 | 是 | 是 |
| Safari | - | 是 | 是 |
| Chrome | 是 | 是 | 是 |
| Opera | 是 | 是 | 是 |
| 因特网浏览器 | - | 是 | - |
快速问答 - 使用音频标签
Q1. 我们如何停止一个 audio 元素播放?
-
使用
stop函数。 -
使用
pause函数并将currentTime的值重置为0。 -
将
currentTime的值重置为0。
Q2. 我们如何将回退内容放置在不支持 audio 标签的浏览器中显示?
构建迷你钢琴音乐游戏
想象一下,我们现在不仅正在播放音效,而且还在使用 audio 标签播放完整的歌曲。随着歌曲的播放,还有一些音乐点向下移动,作为音乐的视觉表现。
为音乐游戏创建基本背景的时间行动
首先,我们将在 Canvas 上绘制一些路径作为音乐播放的背景。
-
我们将继续使用我们的示例并绘制背景。在文本编辑器中打开
index.html文件,并添加以下突出显示的代码,该代码定义了带有两个 Canvas 的游戏场景:<div id="game"> <div id="menu-scene" class="scene"> <a href="#game"><span>Play</span></a> </div> <div id="game-scene" class="scene"> <canvas id="game-canvas" width="320" height="440"> This is an interactive audio game with some music notes moving from top to bottom. </canvas> </div> </div> -
我们在 HTML 文件中添加了一个游戏场景。我们希望将其放在菜单场景的顶部,因此我们在
audiogame.css中添加以下样式以使游戏场景具有absolute位置:#game { position: relative; width: 320px; height: 440px; overflow: hidden; } .scene { position: absolute; width: 100%; height: 100%; } #menu-scene { background: url(../images/menu_bg.png); display: flex; justify-content: center; align-items: center; } #game-scene { background: url(../images/game_bg.png); top: -440px; } #game-scene.show-scene { top: 0; transition: top 0.3s ease-out; } -
现在,我们将继续到 JavaScript 部分。打开
html5games.audio.jsJavaScript 文件。 -
在 播放 按钮的点击处理程序中,我们添加了以下突出显示的代码:
$("a[href='#game']").click(function(){ // existing code here. $("#game-scene").addClass('show-scene'); return false; });
保存所有文件并在浏览器中打开 index.html。当我们点击 播放 按钮时,应该有一个滑动动画来显示音乐播放场景。以下截图序列显示了滑动动画:

发生了什么?
我们使用 Canvas 创建了一个游戏场景。在这个音乐游戏示例中,我们介绍了 HTML5 游戏中的基本场景管理。我们创建了一个连接菜单场景和游戏场景的过渡。
创建游戏中的场景
在游戏中创建 场景 与创建 层 类似,就像我们在上一章中所做的那样。场景是一个包含多个子元素的 DOM 元素。所有子元素都定位在绝对位置。现在我们的示例中有两个场景。以下代码片段显示了一个整个游戏中的可能场景结构,包括游戏结束场景、信用场景和排行榜场景:
<div id="game">
<div id="menu-scene" class="scene"></div>
<div id="game-scene" class="scene"></div>
<div id="gameover-scene" class="scene"></div>
<div id="credit-scene" class="scene"></div>
<div id="leaderboard-scene" class="scene"></div>
</div>
以下截图显示场景被放置在网页的相同位置。它与层结构非常相似。区别在于我们将通过显示和隐藏每个场景来控制场景:

在 CSS3 中创建滑动效果
当点击播放按钮时,游戏场景从顶部滑动进入。这个场景转换效果是通过使用 CSS3 过渡移动游戏场景来实现的。游戏场景的初始位置具有负的顶部值。然后我们通过过渡将顶部位置从负值变为零,因此它从顶部动画到正确的位置。
使滑动效果起作用的一个重要因素是设置场景的父DIV的溢出为hidden。如果没有隐藏溢出,即使顶部位置为负,游戏场景仍然是可见的。因此,将场景的父DIV设置为隐藏溢出是很重要的。
以下截图说明了游戏场景的滑动过渡。#game DIV 是菜单场景和游戏场景的父元素。当我们将.show-scene类添加到游戏场景时,它将顶部值设置为 0,并带有过渡效果:

尝试英雄 - 创建不同的场景过渡效果
我们为游戏显示时场景的过渡制作了一个滑动效果。通过使用 JavaScript 和 CSS3,我们可以创造性地制作许多不同的场景过渡效果。尝试为游戏添加你自己的过渡效果,例如淡入、从右侧推入,甚至使用 3D 旋转翻转。
可视化音乐回放
如果你曾经玩过《舞力全开》、《吉他英雄》或《Tap Tap Revenge》游戏,那么你可能对音乐点向下或向上移动以及玩家在它们移动到正确位置时击打音乐点的情况很熟悉。以下截图展示了《Tap Tap Revenge》游戏:

我们将在audio标签中播放一首歌曲,并在画布中实现类似的音乐可视化。
行动时间 - 在音乐游戏中创建回放可视化
为了在音乐游戏中创建回放可视化,你需要执行以下步骤:
-
我们需要一个既有旋律部分又有基座的歌曲。从下载的文件或从
media文件夹中的代码包中复制minuet_in_g.ogg、minuet_in_g.aac、minuet_in_g_melody.ogg和minuet_in_g_melody.aac文件。 -
然后,添加带有歌曲作为源文件的
audio标签。打开index.html文件并添加以下代码:<audio id="melody"> <source src="img/minuet_in_g_melody.aac" /> <source src="img/minuet_in_g_melody.ogg" /> </audio> <audio id="base"> <source src="img/minuet_in_g.aac" /> <source src="img/minuet_in_g.ogg" /> </audio> -
音乐可视化主要在 JavaScript 中完成。在文本编辑器中打开
audiogame.jsJavaScript 文件。 -
添加一个
MusicNote对象类型来表示音乐数据,并添加一个Dot对象类型来表示在画布中音乐音符的视觉点,如下所示:function MusicNote(time,line){ this.time = time; this.line = line; } function Dot(distance, line) { this.distance = distance; this.line = line; this.missed = false; } -
然后,我们需要几个游戏变量来存储
MusicNote实例、Dot实例和其他信息。关卡数据是一系列由分号分隔的时间和出现的线路。我们将在稍后的部分记录和创建我们自己的数据。关卡数据表示音乐音符应该出现的时间和线路:var audiogame = { // an array to store all music notes data. musicNotes: [], leveldata: "1.592,3;1.984,2;2.466,1;2.949,2;4.022,3;", // the visual dots drawn on the canvas. dots: [], // for storing the starting time startingTime: 0, // reference of the dot image dotImage: new Image(), // existing code inside audiogame object. } -
关卡数据以序列化字符串格式存储。我们有一个以下函数来提取
MusicNote对象实例中的字符串并将其存储在数组中:var audiogame = { // existing code inside audiogame object. setupLevelData: function() { var notes = this.leveldata.split(";"); // store the total number of dots this.totalDotsCount = notes.length; for(var i=0, len=notes.length; i<len; i++) { var note = notes[i].split(","); var time = parseFloat(note[0]); var line = parseInt(note[1]); var musicNote = new MusicNote(time,line); this.musicNotes.push(musicNote); } }, } -
在
initMedia函数内部添加以下代码。它引用了melody和base音频标签,并加载了点图像以供以后使用:initMedia: function() { // existing code goes here. // melody and base this.melody = document.getElementById("melody"); this.base = document.getElementById("base"); // load the dot image this.dotImage.src = "images/dot.png"; } -
在
initGame函数内部添加以下代码。它引用了canvas和canvasContext变量,以供以后使用:initGame: function() { // existing code goes here. this.canvas = document.getElementById("game-canvas"); this.canvasContext = this.canvas.getContext('2d'); } -
在 JavaScript 文件中添加以下两个函数。
startGame函数设置开始时间,并延迟执行playMusic函数。后者函数播放旋律和基音音频:var audiogame = { // existing code goes here. startGame: function() { var date = new Date(); this.startingTime = date.getTime(); this.registerMusicPlayback(); }, registerMusicPlayback: function() { // play both the melody and base this.melody.play(); this.base.play(); // pause for 3550ms to sync with the music dots movement. this.melody.pause(); this.base.pause(); setTimeout(this.playMusic.bind(this), 3550); }, playMusic: function() { this.melody.play(); this.base.play(); }, }; -
将以下
gameloop函数添加到 JavaScript 中。gameloop函数在游戏顶部创建新的点,并将现有的音符向下移动:var audiogame = { // existing code goes here. gameloop: function() { var canvas = this.canvas; var ctx = this.canvasContext; // show new dots // if the game is started if (this.startingTime !== 0) { for(var i=0, len=this.musicNotes.length; i<len; i++) { var date = new Date(); var elapsedTime = (date.getTime() - this.startingTime)/1000; var note = this.musicNotes[i]; var timeDiff = note.time - elapsedTime; // When time difference is short enough. if (timeDiff >= 0 && timeDiff <= 0.03) { var dot = new Dot(ctx.canvas.height-150, note.line); this.dots.push(dot); } } } // loop again to remove dots that are out of the screen. for(var i=this.dots.length-1; i>=0; i--) { // remove missed dots after moved to the bottom if (this.dots[i].distance < -100) { this.dots.splice(i, 1); } } // move the dots for(var i=0, len=this.dots.length; i<len; i++) { this.dots[i].distance -= 2.5; } // only clear the dirty area, that is the middle area ctx.clearRect(ctx.canvas.width/2-200, 0, 400, ctx.canvas.height); // draw the music note dots for(var i=0, len=this.dots.length; i<len; i++) { // draw the music dot. ctx.save(); var center = canvas.width/2; var dot = this.dots[i]; var x = center-100 if (dot.line === 2) { x = center; } else if (dot.line === 3) { x = center+100; } ctx.translate(x, ctx.canvas.height-80-this.dots[i].distance); ctx.drawImage(this.dotImage, -this.dotImage.width/2, -this.dotImage.height/2); ctx.restore(); } } }; -
现在,在 jQuery ready 函数的末尾添加以下代码:
audiogame.setupLevelData(); setInterval(audiogame.gameloop.bind(audiogame), 30); -
最后,我们在播放按钮的点击事件处理程序中调用
startGame函数:game.startGame(); -
保存所有文件,并在网络浏览器中打开
index.html文件。以下截图显示了音乐播放,音乐点出现在顶部并向下移动:![时间动作 – 在音乐游戏中创建回放可视化]()
发生了什么?
我们刚刚构建了一个功能齐全的音乐游戏。这是基本回放功能。它播放歌曲,同时旋律和基音部分有一些音乐点向下移动。
选择适合音乐游戏的正确歌曲
在选择音乐游戏的歌曲时,我们必须小心版权问题,因为这通常需要你支付使用费或与歌曲版权所有者达成协议。如果你正在构建一个即将在游戏行业取得成功的商业音乐游戏,并且收益可以抵消版权使用费用,那么这是可以接受的。然而,在这里作为一个书籍示例,我们将使用一首无版权的歌曲。这就是为什么我们使用古典歌曲G 小调的瞬间,这是一首公共领域的免费歌曲,也是由计算机软件生成的,没有版权表演。
注意
即使歌曲本身是免费的,音乐的表现也可以受到版权保护。
在移动设备上播放音频
在移动设备上播放音频有一些限制,特别是 iOS 和 Android。最新的 Android 设备上搭载的 Chrome 浏览器只能播放由用户触发的音频。这就是为什么我们无法在超时后直接播放音频。我们需要在点击处理程序之后立即播放音频,然后暂停音频一段时间以同步音频与我们的音乐点。在 iOS 中,也有类似的用户触发限制。我们无法在移动 Safari 中通过编程控制音频音量。我们可能无法在移动 Safari 中降低旋律的音量。除此之外,游戏仍然可以玩。
存储和提取歌曲水平数据
在“时间 *动作—在音乐游戏中创建回放可视化”部分显示的水平数据只是整个水平数据的一部分。这是一个非常长的字符串,存储音乐音符信息,包括时间和行。它以以下格式存储,这是我提出的:
music_current_time, line; music_current_time, line; …
每个音乐点数据包含两件信息:出现的时间和显示的线路。这些数据由逗号分隔。每个音乐点数据由分号分隔。你可以选择任何字符来分隔数据,只要分隔符不与数据内容冲突即可。例如,选择数字或句点在这里会是一个糟糕的选择。以下代码通过分割分号和逗号将等级字符串提取到一个MusicNote对象中:
musicNotes = [];
leveldata = "1.592,3;1.984,2;2.466,1;2.949,2;4.022,3;";
function setupLevelData() {
var notes = audiogame.leveldata.split(";");
for(var i=0, len=notes.length; i<len; i++) {
var note = notes[i].split(",");
var time = parseFloat(note[0]);
var line = parseInt(note[1]);
var musicNote = new MusicNote(time,line);
musicNotes.push(musicNote);
}
}
等级数据字符串是通过键盘记录的,我们将在本章后面讨论记录过程。
小贴士
等级数据在这里只包含几个音乐点。在代码包中,有完整歌曲的整个等级数据。
JavaScript 的parseInt函数有一个可选的第二个参数。它定义了要解析的数字的基数。默认情况下,它使用十进制,但parseInt会在字符串以零开头时将其解析为八进制。例如,parseInt("010")返回结果 8 而不是 10。如果我们想得到十进制数,则可以使用parseInt("010",10)来指定基数。
获取游戏的已过时间
尽管我们可以通过访问currentTime属性来知道音频元素的已过时间,但我们想从游戏开始的时间获取时间。
我们可以通过在游戏开始时存储当前计算机时间,然后减去当前时间值来获取已过时间。
我们通过使用Date对象来获取当前计算机时间。以下代码片段展示了我们如何使用startingTime来获取已过时间,它是以毫秒为单位的:
// starting game
var date = new Date();
audiogame.startingTime = date.getTime();
// some time later
var date = new Date();
var elapsedTime = (date.getTime() - audiogame.startingTime)/1000;
以下截图显示了在控制台中运行的上述代码片段:

创建音乐点
在gameloop函数中,我们检查所有的MusicNote实例,看是否是创建该音符视觉点的时间。以下代码展示了我们用来创建视觉音乐点的逻辑:
if (audiogame.startingTime !== 0) {
for(var i in audiogame.musicNotes) {
// get the elapsed time from beginning of the melody
var date = new Date();
var elapsedTime = (date.getTime() - audiogame.startingTime)/1000;
var note = audiogame.musicNotes[i];
// check whether the dot appear time is as same as the elapsed time
var timeDiff = note.time - elapsedTime;
if (timeDiff >= 0 && timeDiff <= 0.03) {
// create the dot when the appear time is within one frame of the elapsed time
var dot = new Dot(ctx.canvas.height-150, note.line);
audiogame.dots.push(dot);
}
}
}
基本上,我们获取游戏的已过时间,并将其与每个音符的当前时间进行比较。如果音符的当前时间与已过时间之间的时间差在 30 毫秒以内,那么我们就创建视觉点实例,并让gameloop函数绘制它。
移动音乐点
游戏开始和音乐开始之间存在时间差。游戏在歌曲开始播放前几秒钟就开始了。这是因为我们需要在音乐开始之前显示并移动音乐点。
当点在灰色线上时,音乐点应该与歌曲匹配。音乐点从游戏顶部出现并向下移动到灰色线。我们延迟音乐播放以等待点从顶部移动到底部。在这个例子中,这大约是 3.55 秒,所以我们延迟了 3.55 秒的音乐播放。这个延迟在播放不同歌曲时可能会有所不同。因此,如果我们扩展游戏以支持多首歌曲播放,我们可能会稍后存储此信息。
当点被创建时,它放置在给定的距离。每次执行gameloop函数时,我们都会将所有点的距离减少 2.5。这个距离存储在每个dot对象中,表示它离灰色线的距离:
for(var i=0, len=this.dots.length; i<len; i++) {
audiogame.dots[i].distance -= 2.5;
}
点的y位置是通过灰色线计算的,减去距离如下:
// draw the dot
ctx.save();
var x = ctx.canvas.width/2-100
if (audiogame.dots[i].line === 2) {
x = ctx.canvas.width/2;
}
else if (audiogame.dots[i].line === 3) {
x = ctx.canvas.width/2+100;
}
ctx.translate(x, ctx.canvas.height-80-audiogame.dots[i].distance);
ctx.drawImage(audiogame.dotImage, -audiogame.dotImage.width/2, -audiogame.dotImage.height/2);
以下截图显示了灰色线和每个点之间的距离。当距离为零时,它正好在灰色线上:

创建一个键盘驱动的迷你钢琴音乐游戏
现在我们可以点击播放按钮。音乐游戏滑入并播放歌曲,音符从上往下落下。我们的下一步是添加交互到音乐音符。因此,我们将添加键盘事件来控制三条线击中音乐音符。
行动时间 - 创建迷你钢琴音乐游戏
执行以下步骤:
-
我们想在按下键盘时显示一个指示。打开
index.html文件并添加以下高亮的 HTML:<div id="game-scene" class="scene"> <!-- existing code goes here --> <div id="hit-line-1" class="hit-line hide"></div> <div id="hit-line-2" class="hit-line hide"></div> <div id="hit-line-3" class="hit-line hide"></div> </div> -
然后,我们可能想通知访客他们可以通过按下J、K和L键来玩游戏。修改页脚内容如下:
<footer> <p>This is an example of making audio game in HTML5\. Press J, K, L to play. </p> </footer> -
现在,我们将继续到样式表。打开
css/audiogame.css文件并将以下代码放在文件末尾:#hit-line-1 { left: 35px; top: 335px; } #hit-line-2 { left: 135px; /* 320/2-50/2 */ top: 335px; } #hit-line-3 { left: 235px; top: 335px; } -
接下来,我们将在 JavaScript 部分添加键盘事件。打开
audiogame.jsJavaScript 文件并在 audiogame 对象内部添加以下代码:initKeyboardListener: function() { var game = this; // keydown $(document).keydown(function(e){ // our target is J(74), K(75), L(76) var line = e.which-73; game.hitOnLine(line); }); $(document).keyup(function(e){ var line = e.which-73; $('#hit-line-'+line).removeClass('show'); $('#hit-line-'+line).addClass('hide'); }); }, hitOnLine: function (lineNo) { $('#hit-line-'+lineNo).removeClass('hide'); $('#hit-line-'+lineNo).addClass('show'); // check if hit a music note dot for(var i=this.dots.length-1; i>=0; i--) { if (lineNo === this.dots[i].line && Math.abs(this.dots[i].distance) < 20) { // remove the hit dot from the dots array this.dots.splice(i, 1); } } }, -
最后,在
initGame函数中调用initKeyboardListener函数:initGame: function() { // existing code goes here. this.initKeyboardListener(); }, -
现在保存所有文件并在浏览器中打开游戏。尝试按下J、K和L键。当按下键时,三个击中线条指示器应该出现并淡出。如果音乐点在按下正确键时通过水平线,则它消失:
![行动时间 - 创建迷你钢琴音乐游戏]()
发生了什么?
我们刚刚为我们音乐游戏添加了键盘交互。当击中键时,会有一个发光动画。当在正确的时间按下正确的键时,音乐点将消失。您可以查看以下 URL 查看当前进度的示例:makzan.net/html5-games/audiogame-wip-keyboard/。
通过按下键来击中三条音乐线
我们使用J,K和L键来击中游戏中的三条音乐线。J键控制左侧线,K键控制中间线,L键控制右侧线。
也有一个指示显示我们刚刚触发了音乐线。这是通过在水平线和垂直线的交点放置以下图像来完成的:

接下来,我们可以使用以下 jQuery 代码来控制显示和隐藏触发的指示图形:
$(document).keydown(function(e){
var line = e.which-73;
$('#hit-line-'+line).removeClass('hide');
$('#hit-line-'+line).addClass('show');
});
$(document).keyup(function(e){
var line = e.which-73;
$('#hit-line-'+line).removeClass('show');
$('#hit-line-'+line).addClass('hide');
});
J,K和L键控制音乐线 1 到 3。由于 J,K 和 L 的键码分别是 74,75 和 76,我们可以通过从键码中减去 73 来确定是哪条线。
在按键按下时确定音乐点击中
如果点几乎在灰色水平线上,则距离接近零。这有助于我们确定点是否击中了灰色线。通过检查按键按下事件和点距离,我们可以确定是否成功击中了音乐点。以下代码片段显示,当距离足够近时,我们认为点被击中;在这种情况下,它是在 20 像素以内:
// check whether we hit a music note dot
for(var i=this.dots.length-1; i>=0; i--) {
if (lineNo === this.dots[i].line && Math.abs(this.dots[i].distance) < 20) {
// remove the hit dot from the dots array
this.dots.splice(i, 1);
}
}
坚定地,当我们击中音乐点时,我们会移除音乐点。未击中的点仍然会穿过灰色线并移动到底部。这创造了基本的游戏玩法,玩家必须通过在歌曲播放时正确地在正确的时间击中音乐点来消除所有的音乐点。
注意
当我们在迭代中移除数组内的元素时,我们通常从后向前迭代,以避免在数组中删除元素后的空引用错误。
根据给定的索引从数组中移除一个元素
当我们触发音乐点时,我们会从数组中移除音乐点数据(因此它将不再被绘制)。要移除数组中的元素,我们使用splice函数。以下代码行从给定索引的数组中移除一个元素:
array.splice(index, 1);
splice函数使用起来有点棘手。这是因为它允许我们在数组中添加或移除元素。然后,它将移除的元素作为另一个数组返回。
这就是我们的splice函数的使用方法:
array.splice(index, length, element1, element2, …, elementN);
以下表格显示了如何使用参数:
| 参数 | 定义 | 讨论 |
|---|---|---|
index |
指定要添加或移除的数组中元素的索引 | 索引从 0 开始。0 表示第一个元素,1 表示第二个,以此类推。我们也可以使用负索引,例如-1,表示最后一个元素,-2,表示倒数第二个元素,依此类推。 |
length |
指定我们想要移除的元素数量 | 将 0 放入表示我们不移除任何元素。 |
element1, element2, … elementN |
要添加到数组中的新元素 | 这是可选的。在这里放入一个元素列表表示我们在给定的索引处添加元素。 |
注意
Mozilla 开发者网络链接讨论了splice函数的不同用法,请参阅:developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/splice。
尝试一下英雄
在类似商业音乐游戏中,当玩家击中或错过音乐点时,有一些文字会显示出来。我们如何将这个功能添加到我们的游戏中?
为迷你钢琴游戏添加额外功能
我们已经在游戏中创建了基本的交互。我们可以通过添加旋律音量反馈来进一步改进游戏,这将使表演更加逼真,并计算表演的成功率。
根据玩家调整音乐音量
想象一下,现在我们正在一场表演中演奏音乐。我们击打音乐点来演奏旋律。如果我们错过任何一个,那么我们就无法很好地完成表演,旋律就会消失。
行动时间 - 移除未击中的旋律音符
我们将存储一些游戏统计数据,并使用它们来调整旋律音量。我们将继续使用我们的 JavaScript 文件:
-
首先,在变量声明区域添加以下变量:
var audiogame = { totalSuccessCount: 0, // storing the success count of last 5 results. successCount: 5, // existing code goes here. }; -
我们不仅想要移除一个点,还要通过使用键盘击中它来跟踪结果。在
hitOnLine函数内部添加以下代码:// check if hit a music note dot for(var i in audiogame.dots) { if (lineNo === audiogame.dots[i].line && Math.abs(audiogame.dots[i].distance) < 20) { // remove the hit dot from the dots array audiogame.dots.splice(i, 1); // increase the success count audiogame.successCount+=1; // keep only 5 success count max. audiogame.successCount = Math.min(5, audiogame.successCount); // increase the total success count audiogame.totalSuccessCount +=1; } } -
在
gameloop函数中,我们计算所有未击中的点并存储结果。然后,我们可以使用这些统计数据来获取游戏的成功率。将以下代码添加到gameloop函数中:// loop again to remove dots that's out or the screen. // existing code goes here. // check missed dots for(var i=this.dots.length-1; i>=0; i--) { if (!audiogame.dots[i].missed && audiogame.dots[i].distance < -10) { // mark the dot as missed if it's not marked before audiogame.dots[i].missed = true; // reduce the success count audiogame.successCount -= 1; // reset the success count to 0 if it is lower than 0. audiogame.successCount = Math.max(0, audiogame.successCount); } // remove missed dots after moved to the bottom if (audiogame.dots[i].distance < -100) { audiogame.dots.splice(i, 1); } } // calculate the percentage of the success in last 5 music dots var successPercent = audiogame.successCount / 5; // prevent the successPercent to exceed range(fail safe) successPercent = Math.max(0, Math.min(1, successPercent)); // move the dots // existing code goes here. -
最后,我们通过使用成功率来调整旋律音量。在刚刚添加的
gameloop函数代码之后放置以下代码:audiogame.melody.volume = successPercent; -
保存所有文件,并在浏览器中测试我们的游戏。当玩家继续很好地玩游戏时,旋律会继续播放。当玩家错过几个音乐点时,旋律会消失,只有基础音会播放。
发生了什么?
我们刚刚使用玩家的表现作为旋律音量的反馈。这给玩家一种我们真的在演奏音乐的感觉。当我们表现不佳时,旋律音量会降低,歌曲听起来也很糟糕。你可以尝试以下 URL 中的工作示例:makzan.net/html5-games/audiogame-wip-volume/。
从游戏中移除点
我们希望在点掉到下界以下或被玩家击中时移除点。游戏循环在游戏画布上显示点列表中的所有点。我们可以通过从点的数组中移除其数据来移除点图形。我们将使用以下splice函数来移除目标索引处的条目:
audiogame.dots.splice(index, 1);
将成功次数存储在最后五次结果中
在我们的游戏中,我们需要存储最后五个结果中的成功计数来计算成功率。我们可以通过使用表示这个的计数器来实现这一点。当一个点成功击中时,计数器增加 1,但是当玩家未能击中点时,计数器减少 1。
如果我们将计数器限制在 0 到 5 的范围内,那么计数器就代表了最后几个结果中的成功计数。
尝试一下英雄
我们在上一个章节中讨论了如何在 Untangle 游戏中显示游戏进度。我们能否在音乐游戏中应用类似的技术?我们有玩家在游戏中的成功率。我们是否可以将它显示为游戏顶部的百分比条形图?
将音乐音符记录为级别数据
游戏依赖于级别数据来播放。如果没有级别数据,播放可视化将不起作用。如果播放可视化不起作用,我们也不能播放。那么我们如何记录这些级别数据呢?
想象一下,现在音乐正在播放,但游戏中没有出现任何音乐点。我们仔细聆听音乐,并在音乐播放时按下J、K、L键。音乐结束后,我们打印出所有按下的键和时间。然后,这些数据将用于音乐的播放可视化。
时间行动 – 添加记录音乐级别数据的函数
执行以下步骤:
-
首先,我们创建一个变量来在录制模式和正常播放模式之间切换。打开
html5games.audio.js文件,并添加以下代码:var audiogame = { isRecordMode : true, //existing code here -
接下来,我们在
keydown事件处理器中添加以下突出显示的代码。这段代码将所有按下的键存储在一个数组中,并在按下分号键时将它们打印到控制台:if (game.isRecordMode) { // print the stored music notes data when press ";" (186) if (e.which === 186) { var musicNotesString = ""; for(var i=0, len=game.musicNotes.length; i<len; i++) { musicNotesString += game.musicNotes[i].time + "," + game.musicNotes[i].line+";"; } console.log(musicNotesString); } var currentTime = game.melody.currentTime.toFixed(3); var note = new MusicNote(currentTime, e.which-73); game.musicNotes.push(note); } -
最后,我们想要确保在录制模式下不执行
setupLevelData和gameloop函数。这些函数仅用于播放模式:if (!audiogame.isRecordMode) { audiogame.setupLevelData(); setInterval(audiogame.gameloop.bind(audiogame), 30); } -
现在在浏览器中打开
index.html文件。点击播放按钮后,游戏开始,音乐播放但没有音乐音符。尝试按照音乐节奏按下J、K和L键。音乐结束后,按下分号键将级别数据打印到控制台。以下截图显示了控制台显示的级别数据字符串:![时间行动 – 添加记录音乐级别数据的函数]()
刚才发生了什么?
我们刚刚为我们的游戏添加了一个录制功能。现在我们可以录制我们的音乐音符。我们可以通过设置audiogame.isRecordMode变量为true和false来切换录制模式和播放模式。
在每次按键时,我们获取旋律的经过时间,并创建一个带有时间和行号的MusicNote实例。以下代码展示了我们如何记录按下的键。在保存之前,currentTime被截断到两位小数:
var currentTime = audiogame.melody.currentTime.toFixed(3);
var note = new MusicNote(currentTime, e.which-73);
audiogame.musicNotes.push(note);
我们还捕获分号键,将所有记录的 MusicNote 数据打印成一个字符串。字符串遵循 time,line;time,line; 格式,因此我们可以直接复制打印的字符串并将其粘贴为关卡数据来播放。
注意
toFixed 函数使用给定的尾随小数位数格式化数字。在我们的例子中,我们使用它来获取带有 3 位尾随小数的当前时间。
添加触摸支持
现在游戏在桌面浏览器上运行良好。但我们希望使游戏在移动设备上也能玩。
行动时间 – 在控制台中指示游戏结束事件
我们针对水平线和垂直线之间的 3 个交点。
-
我们在那里定义了三个 DIV 元素来显示当按下 J、K 和 L 键时的图形。我们修改 HTML 以向这些元素添加一个 data-line-no 属性:
<div id="hit-line-1" data-line-no="1" class="hit-line hide"></div> <div id="hit-line-2" data-line-no="2" class="hit-line hide"></div> <div id="hit-line-3" data-line-no="3" class="hit-line hide"></div> -
我们转向 JavaScript。我们在
audiogame对象内部定义了一个新函数:initTouchAndClick: function() { var game = this; $('.hit-line').bind('mousedown touchstart', function() { var line = $(this).data('lineNo') * 1; // parse in int game.hitOnLine(line); return false; }); $('.hit-line').bind('mouseup touchend', function(){ var line = $(this).data('lineNo') * 1; // parse in int $('#hit-line-'+line).removeClass('show'); $('#hit-line-'+line).addClass('hide'); }); }, -
我们在
initGame函数中调用我们新创建的initTouchAndClick函数:initGame: function() { // existing code goes here. this.initTouchAndClick(); }, -
我们现在可以在移动浏览器中打开游戏,并用手指玩游戏。
刚才发生了什么?
我们已经为游戏添加了一个触摸事件。HTML 元素中的 data-line-no 属性让我们知道玩家正在触摸哪一行。然后我们调用与 keydown 事件调用的相同的 hitOnLine 函数,它共享一些处理命中或未命中的代码。
在播放完成事件中处理音频事件
我们现在可以玩游戏了,但没有指示游戏何时结束。想象一下,现在我们想知道游戏完成时我们玩得怎么样。我们将捕获旋律结束信号并显示游戏的成功率。
行动时间 – 在控制台中指示游戏结束事件
执行以下步骤:
-
打开
audiogame.jsJavaScript 文件。 -
在 jQuery ready 函数中添加以下代码:
$(audiogame.melody).bind('ended', onMelodyEnded); -
在文件末尾添加以下事件处理函数:
// show game over scene on melody ended. function onMelodyEnded() { console.log('song ended'); alert ('success percent: ' + audiogame.totalSuccessCount / audiogame.totalDotsCount * 100 + '%'); } })(jQuery); -
是时候保存所有文件并在网络浏览器中玩游戏了。当游戏结束时,我们应该看到一个弹出警告,显示成功率。
刚才发生了什么?
我们刚刚监听了音频元素的 ended 事件,并用处理函数处理了它。
处理音频事件
音频元素中还有许多其他事件。以下表格列出了几个常用音频事件:
| 事件 | 讨论 |
|---|---|
ended |
发送音频元素完成播放时 |
play |
发送音频元素播放或恢复时 |
pause |
发送音频元素暂停时 |
progress |
当音频元素正在下载时定期发送 |
timeupdate |
当 currentTime 属性改变时发送 |
这里我们只列出了几个常用的事件;你可以参考 Mozilla 开发者中心中的完整音频事件列表:developer.mozilla.org/En/Using_audio_and_video_in_Firefox#Media_events。
尝试一下英雄
在我们的音乐游戏中,当游戏结束时,我们在控制台打印出成功率。我们是否可以在游戏结束时添加一个游戏结束场景并显示它呢?在显示游戏结束场景时使用动画过渡也会很好。
注意
我们已经管理了声音资源,并使用原生的 JavaScript API 播放音频。有时管理大量音频的加载和播放会变得很麻烦。有一些 JavaScript 库可以帮助你更容易地管理 HTML5 音频。以下是一些:
-
SoundJS (
www.createjs.com/SoundJS) -
Buzz (
buzz.jaysalvat.com) -
AudioJS (
kolber.github.io/audiojs/)
摘要
在本章中,你学习了如何使用 HTML5 音频元素,并构建了一个音乐游戏。具体来说,我们通过使用 HTML 音频标签和相关 JavaScript API 来管理和控制音频播放。你学习了改变音频标签行为的不同属性。我们利用音频标签创建了一个基于键盘的 canvas 游戏。我们还通过在键盘输入和触摸输入之间共享通用逻辑,使游戏能够在触摸设备上运行。我们使用一种特殊模式创建游戏,这种模式有助于游戏关卡设计师创建关卡数据。
你在我们的 HTML5 游戏中学习了如何添加音乐和音效。现在,我们准备在下一章中通过添加排行榜来构建一个更完整的游戏,用于存储游戏分数。
第七章。保存游戏进度
本地存储是 HTML5 的新规范。它允许网站在浏览器中本地存储信息,并在以后访问存储的数据。这对于游戏开发来说是一个有用的功能,因为我们可以用它作为内存槽,在网页浏览器中本地保存任何游戏数据。
我们将在我们在第三章中构建的 CSS3 卡片匹配游戏中添加存储游戏数据的功能。除了存储和加载游戏数据外,我们还将使用纯 CSS3 样式通知玩家,当他们打破纪录时,会出现一个漂亮的 3D 丝带。
在本章中,我们将涵盖以下主题:
-
使用 HTML5 本地存储存储数据
-
在本地存储中保存对象
-
当玩家打破新纪录时,通过漂亮的丝带效果通知他们
-
保存整个游戏进度
您可以在以下链接尝试最终游戏:makzan.net/html5-games/card-matching/.
以下截图显示了本章我们将创建的最终结果:

那么,让我们开始吧。
使用 HTML5 本地存储存储数据
记得我们在第三章中制作的 CSS3 卡片匹配游戏吗?想象一下,我们现在已经发布了我们的游戏,玩家们正在尽力在游戏中表现良好。
我们想显示玩家是否比上次玩得更好或更差。我们将保存最新的分数,并通过比较分数来通知玩家他们这次是否表现得更好。
我们可能想要这样做的原因是,当玩家表现更好时,它会给玩家一种自豪感,他们可能会沉迷于我们的游戏,试图获得更高的分数,这对我们来说是有益的。
创建游戏结束对话框
在实际将任何内容保存到本地存储之前,我们需要一个游戏结束屏幕。我们在前面的章节中制作了一些游戏。我们制作了乒乓球游戏、卡片匹配游戏、解谜游戏和音乐游戏。在这些游戏中,我们没有创建任何游戏结束屏幕。想象一下,我们现在正在玩我们在第三章中构建的 CSS3 卡片匹配游戏,使用 CSS3 制作卡片匹配游戏。我们成功匹配并移除了所有卡片。一旦完成游戏,屏幕就会弹出并显示完成游戏所需的时间。
行动时间 – 创建一个基于已玩时间的游戏结束对话框
我们将继续使用我们在第三章中制作的卡片匹配游戏的代码,使用 CSS3 制作卡片匹配游戏。执行以下步骤:
-
打开 CSS3 匹配游戏文件夹作为我们的工作目录。
-
从以下 URL 下载背景图片(我们将将其用作弹出窗口的背景):
mak.la/book-assets -
将图片放在
images文件夹中。 -
在任何文本编辑器中打开
index.html。 -
我们需要一个字体用于游戏结束弹出窗口。将以下字体嵌入 CSS 添加到
head部分:<link href="http://fonts.googleapis.com/css?family=Orbitron:400,700" rel="stylesheet" type="text/css"> -
在
game部分之前,我们添加一个名为timer的div来显示经过的游戏时间。此外,我们添加一个新的popup部分,包含弹出对话框的 HTML 标记:<div id="timer"> Elapsed time: <span id="elapsed-time">00:00</span> </div> <section id="game"> <div id="cards"> <div class="card"> <div class="face front"></div> <div class="face back"></div> </div> <!-- .card --> </div> <!-- #cards --> </section> <!-- #game --> <section id="popup" class="hide"> <div id="popup-bg"> </div> <div id="popup-box"> <div id="popup-box-content"> <h1>You Won!</h1> <p>Your Score:</p> <p><span class='score'>13</span></p> </div> </div> </section> -
现在我们将转到样式表。因为它只是用于样式,还没有与我们的逻辑相关联,所以我们可以简单地从代码示例包中的
01-gameover-dialog复制matchgame.css文件。 -
是时候编辑游戏的逻辑部分了。在编辑器中打开
matchgame.js文件。 -
在 jQuery 的
ready函数中,我们需要一个变量来存储游戏的经过时间。然后,我们创建一个计时器来每秒计算游戏时间,如下所示:$(document).ready(function(){ ... // reset the elapsed time to 0. matchingGame.elapsedTime = 0; // start the timer matchingGame.timer = setInterval(countTimer, 1000); } -
接下来,我们添加一个
countTimer函数,它将每秒执行一次。它以分钟和秒的格式显示经过的秒数:function countTimer() { matchingGame.elapsedTime++; // calculate the minutes and seconds from elapsed time var minute = Math.floor(matchingGame.elapsedTime / 60); var second = matchingGame.elapsedTime % 60; // add padding 0 if minute and second is less than 10 if (minute < 10) minute = "0" + minute; if (second < 10) second = "0" + second; // display the elapsed time $("#elapsed-time").html(minute+":"+second); } -
在我们之前编写的
removeTookCards函数中,添加以下高亮显示的代码,在移除所有卡片后执行游戏结束逻辑:function removeTookCards() { $(".card-removed").remove(); // check whether all cards are removed and show game over if ($(".card").length === 0) { gameover(); } } -
最后,我们创建以下
gameover函数。它停止计时器,在游戏结束弹出窗口中显示经过时间,并最终显示弹出窗口:function gameover() { // stop the timer clearInterval(matchingGame.timer); // set the score in the game over popup $(".score").html($("#elapsed-time").html()); // show the game over popup $("#popup").removeClass("hide"); } -
现在,保存所有文件,并在浏览器中打开游戏。尝试完成卡片匹配游戏,游戏结束屏幕将弹出,如下面的截图所示:
![行动时间 – 创建带有经过时间的游戏结束对话框]()
刚才发生了什么?
我们使用 CSS3 过渡动画来显示游戏结束弹出窗口。我们通过玩家完成游戏所用的时间来衡量分数。
计时
我们使用时间间隔来计算经过时间。我们提供一个间隔,例如 1 秒,浏览器将在提供的间隔执行我们的逻辑。在逻辑内部,我们计算经过的秒数。我们需要记住,setInterval不能保证逻辑在给定的时间间隔精确执行。它是一个近似值。如果您需要更精确的经过时间,您可以从开始时间减去时间戳。
在浏览器中保存分数
想象一下,我们现在将要显示玩家上次玩得有多好。游戏结束屏幕包括作为最后得分的经过时间以及当前游戏得分。玩家可以比较这次和上次的表现。
行动时间 – 保存游戏分数
-
首先,我们需要在
popup部分添加一些标记来显示最后得分。在index.html中的popup-box中添加以下 HTML。更改的代码已高亮显示:<section id="popup" class="hide"> <div id="popup-bg"> </div> <div id="popup-box"> <div id="popup-box-content"> <h1>You Won!</h1> <p>Your Score:</p> <p><span class='score'>13</span></p> <p> <small>Last Score: <span class='last-score'>20</span> </small> </p> </div> </div> </section> -
然后,我们打开
matchgame.js来修改gameover函数中的某些游戏逻辑。 -
在
gameover函数中添加以下突出显示的代码。它从本地存储中加载保存的分数,并将其显示为上次的游戏分数。然后,我们将当前分数保存在本地存储中:function gameover() { // stop the timer clearInterval(matchingGame.timer); // display the elapsed time in the game over popup $(".score").html($("#elapsed-time").html()); // load the saved last score from local storage var lastElapsedTime = localStorage.getItem("last-elapsed-time"); // convert the elapsed seconds //into minute:second format // calculate the minutes and seconds // from elapsed time var minute = Math.floor(lastElapsedTime / 60); var second = lastElapsedTime % 60; // add padding 0 if (minute < 10) minute = "0" + minute; if (second < 10) second = "0" + second; // display the last elapsed time in game over popup $(".last-score").html(minute+":"+second); // save the score in local storage localStorage.setItem("last-elapsed-time", matchingGame.elapsedTime); // show the game over popup $("#popup").removeClass("hide"); } -
现在是时候保存所有文件并在浏览器中测试游戏了。当你第一次完成游戏时,最后得分应该是
00:00。然后,尝试第二次完成游戏。游戏结束弹窗将显示你上次玩游戏时经过的时间。以下截图显示了带有当前和上次得分的游戏结束界面:![操作时间 – 保存游戏分数]()
发生了什么?
我们刚刚构建了一个基本的计分系统,该系统比较玩家的分数和他们的上次分数。
使用本地存储存储和加载数据
我们可以通过使用 localStorage 对象的 setItem 函数来存储数据,如下所示:
localStorage.setItem(key, value);
以下表格显示了该函数的用法:
| 参数 | 定义 | 描述 |
|---|---|---|
key |
键是用于识别条目的记录名称 | 键是一个字符串,每个记录都有一个唯一的键。向现有键写入新值将覆盖旧值。 |
value |
值是要存储的数据 | 这可以是任何数据,但最终存储的是字符串。我们将在稍后讨论这一点。 |
在我们的示例中,我们使用键 last-elapsed-item 通过以下代码将游戏经过的时间保存为分数:
localStorage.setItem("last-elapsed-time", matchingGame.elapsedTime);
与 setItem 相辅相成,我们可以通过以下方式使用 getItem 函数获取存储的数据:
localStorage.getItem(key);
该函数返回给定键的存储值。当尝试获取不存在的键时,它返回 null。这可以用来检查我们是否为特定键存储了任何数据。
本地存储保存字符串值
本地存储以键值对的形式存储数据。键和值都是字符串。如果我们保存数字、布尔值或任何非字符串类型,则浏览器在保存时将值转换为字符串。在稍后的部分,我们将使用 JSON 对对象和数组进行转换。
通常,当我们从本地存储中加载保存的值时会出现问题。无论我们保存的类型是什么,加载的值都是一个字符串。在使用之前,我们需要显式地将值解析为正确的类型。
例如,如果我们将浮点数保存到本地存储中,则在加载时需要使用 parseFloat 函数。以下代码片段显示了如何使用 parseFloat 来检索存储的浮点数:
var score = 13.234;
localStorage.setItem("game-score",score);
// result: stored "13.234".
var gameScore = localStorage.getItem("game-score");
// result: get "13.234" into gameScore;
gameScore = parseFloat(gameScore);
// result: 13.234 floating value
在前面的代码片段中,如果我们忘记将 gameScore 从字符串转换为浮点数,则操作可能是不正确的。例如,如果我们不使用 parseFloat 函数将 gameScore 增加 1,则结果将是 13.2341 而不是 14.234。因此,请确保将本地存储中的值转换为正确的类型。
小贴士
本地存储的大小限制
通过localStorage存储的数据在各个域上都有大小限制。这个大小限制在不同的浏览器中可能略有不同。通常,大小限制是 5MB。如果超过限制,则在将键值设置到localStorage时,浏览器会抛出QUOTA_EXCEEDED_ERR异常。
将本地存储对象视为关联数组
除了使用setItem和getItem函数外,我们还可以将localStorage对象视为关联数组,并使用方括号访问存储条目。例如,考虑以下代码行:
localStorage.setItem("last-elapsed-time", elapsedTime);
var lastElapsedTime = localStorage.getItem("last-elapsed-time");
我们可以用以下代码替换前面的代码块,并将localStorage作为数组访问:
localStorage["last-elapsed-time"] = elapsedTime;
var lastElapsedTime = localStorage["last-elapsed-time"];
在本地存储中保存对象
现在,假设我们不仅保存分数,还保存创建排名时的日期和时间。我们可以为得分和游戏时的日期时间保存两个单独的键,或者将两个值打包到一个对象中并存储在本地存储中。
我们将所有游戏数据打包到一个对象中并存储。
行动时间 – 将时间与分数一起保存
执行以下步骤:
-
首先,从我们的 CSS3 卡片匹配游戏中打开
index.html文件。 -
将 HTML 标记替换为最后得分,使用以下 HTML(它在游戏结束弹出窗口中显示得分和日期时间):
<p> <small>Last Score: <span class='last-score'>0</span><br> Saved on: <span class='saved-time'></span> </small> </p> -
HTML 标记现在已准备好。我们将继续进行游戏逻辑。在文本编辑器中打开
html5games.matchgame.js文件。 -
我们将修改
gameover函数。将以下高亮代码添加到gameover函数中。它获取游戏结束时当前日期和时间,并将格式化的日期和时间与经过的时间一起打包到本地存储中:function gameover() { // stop the timer clearInterval(matchingGame.timer); // display the elapsed time in the game over popup $(".score").html($("#elapsed-time")); // load the saved last score and save time from local storage var lastScore = localStorage.getItem("last-score"); // check if there is no saved record lastScoreObj = JSON.parse(lastScore); if (lastScoreObj === null) { // create an empty record if there is no saved record lastScoreObj = {"savedTime": "no record", "score": 0}; } var lastElapsedTime = lastScoreObj.score; // convert the elapsed seconds into minute:second format // calculate the minutes and seconds from elapsed time var minute = Math.floor(lastElapsedTime / 60); var second = lastElapsedTime % 60; // add padding 0 if minute and second is less than 10 if (minute < 10) minute = "0" + minute; if (second < 10) second = "0" + second; // display the last elapsed time in game over popup $(".last-score").html(minute+":"+second); // display the saved time of last score var savedTime = lastScoreObj.savedTime; $(".saved-time").html(savedTime); // get the current datetime var currentTime = new Date(); // convert date time to string var now = currentTime.toLocaleString(); //construct the object of datetime and game score var obj = { "savedTime": now, "score": matchingGame.elapsedTime}; // save the score into local storage localStorage.setItem("last-score", JSON.stringify(obj)); // show the game over popup $("#popup").removeClass("hide"); } -
我们将保存文件并在网页浏览器中打开游戏。
-
当我们第一次完成游戏时,我们将得到一个类似于以下截图的屏幕,它将显示我们的游戏得分和状态,表明没有之前的记录:
![行动时间 – 将时间与分数一起保存]()
-
现在尝试重新加载页面并再次玩游戏。当我们第二次完成游戏时,游戏结束对话框将显示我们的保存记录。以下截图显示了它应该看起来像什么:
![行动时间 – 将时间与分数一起保存]()
发生了什么?
我们在 JavaScript 中使用了Date对象来获取游戏结束时当前日期和时间。此外,我们将游戏结束的日期和时间以及游戏经过的时间打包到一个对象中,并将其保存在本地存储中。保存的对象被编码为 JSON 字符串。它还会从存储中加载最后保存的日期和时间以及游戏经过的时间,并将其从字符串解析回 JavaScript 对象。
在 JavaScript 中获取当前日期和时间
JavaScript 中的Date对象用于处理日期和时间。当我们从Date对象创建一个实例时,默认情况下它存储当前日期和时间。我们可以通过使用toLocaleString方法来获取字符串表示形式。
除了字符串表示形式,我们还可以操作日期对象中的每个组件。以下表格列出了Date对象中一些获取日期和时间的有用函数:
| 函数 | 描述 |
|---|---|
getFullYear |
返回四位数的年份 |
getMonth |
返回整数月份,从 0 开始(1 月是 0,12 月是 11) |
getDate |
返回月份中的天数,从 1 开始 |
getDay |
返回星期几,从 0 开始(星期天是 0,星期六是 6) |
getHours |
返回小时,从 0 到 23 |
getMinutes |
返回分钟 |
getSeconds |
返回秒数 |
getMilliseconds |
返回三位数的毫秒数 |
getTime |
返回自 1970 年 1 月 1 日 00:00 以来的毫秒数 |
注意
Mozilla 开发者网络提供了关于使用Date对象的详细参考,请参阅:developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Date。
使用原生的 JSON 将对象编码为字符串
我们在第四章 使用 Canvas 和绘图 API 构建 Untangle 游戏 中使用了 JSON 来表示游戏关卡数据。
JSON 是一种机器友好的对象表示法格式,易于解析和生成。在这个例子中,我们将最终经过的时间和日期时间打包到一个对象中。然后,我们将对象编码为 JSON。现代网络浏览器都内置了对 JSON 的原生支持。我们可以通过使用以下stringify函数轻松地将任何 JavaScript 对象编码为 JSON:
JSON.stringify(anyObject);
通常,我们只为stringify函数的第一个参数使用。这是我们打算编码为字符串的对象。以下代码片段演示了编码 JavaScript 对象的结果:
var jsObj = {};
jsObj.testArray = [1,2,3,4,5];
jsObj.name = 'CSS3 Matching Game';
jsObj.date = '8 May, 2011';
JSON.stringify(jsObj);
// result: {"testArray":[1,2,3,4,5],"name":"CSS3 Matching Game","date":"8 May, 2011"}
注意
stringify方法可以很好地解析具有数据结构的对象到字符串。然而,它不能将任何对象转换为字符串。例如,如果我们尝试将 DOM 元素传递给它,它将返回一个错误。如果我们传递一个Date对象,它将返回表示日期的字符串。否则,它将丢弃解析对象的全部方法定义。
从 JSON 字符串加载存储的对象
JSON的完整形式是JavaScript 对象 表示法。从名称中,我们知道它使用 JavaScript 的语法来表示对象。因此,将 JSON 格式的字符串解析回 JavaScript 对象非常容易。
以下代码片段展示了我们如何使用 JSON 对象中的解析函数:
JSON.parse(jsonFormattedString);
我们可以在Web Inspector中打开控制台来测试 JSON JavaScript 函数。以下截图显示了当我们对对象进行编码并解析时运行我们刚才讨论的代码片段的结果:

在控制台窗口中检查本地存储
在我们将某些内容保存到本地存储之后,我们可能想在编写加载部分之前知道确切保存了什么。我们可以通过使用Web Inspector中的存储面板来检查我们保存的内容。它列出了同一域名下的所有保存的键值对。以下截图显示我们有一个last-score键,其值为{"savedTime":"23/2/2011 19:27:02","score":23}。
该值是我们用来将对象编码为 JSON 的JSON.stringify函数的结果。您也可以尝试直接在本地存储中保存对象:

注意
除了localStorage之外,还有其他未讨论的存储方法。IndexedDB是另一个选项。查看以下链接以获取更多详细信息:developer.mozilla.org/en/IndexedDB。
当玩家打破新纪录时,通过漂亮的横幅效果通知玩家
想象一下,我们想要通过通知玩家他们与上次得分相比打破了新纪录来鼓励他们。我们想在横幅上显示New Record文本。多亏了新的 CSS3 属性,我们可以在 CSS 中完全创建横幅效果。
Time for action – creating a ribbon in CSS3
我们将创建一个新的纪录横幅,并在玩家打破上次得分时显示它。所以,执行以下步骤:
-
首先,打开
index.html,我们将添加横幅 HTML 标记。 -
在
popup-box之后和popup-box-content之前添加以下高亮的 HTML 代码:<div id="popup-box"> <div class="ribbon hide"> <div class="ribbon-body"> <span>New Record</span> </div> <div class="triangle"></div> </div> <div id="popup-box-content"> ... -
接下来,我们需要关注样式表。整个横幅效果都是在 CSS 中完成的。在文本编辑器中打开
matchgame.css文件。 -
在
popup-box的样式表中,我们需要给它添加一个相对定位。我们这样做如下:#popup-box { position: relative; } -
然后,我们需要在 CSS 文件中添加以下样式,以创建横幅效果:
.ribbon.hide { display: none; } .ribbon { float: left; position: absolute; left: -7px; top: 165px; z-index: 0; font-size: .5em; text-transform: uppercase; text-align: right; } .ribbon-body { height: 14px; background: #ca3d33; padding: 6px; z-index: 100; box-shadow: 2px 2px 0 rgba(150,120,70,.4); border-radius: 0 5px 5px 0; color: #fff; text-shadow: 0px 1px 1px rgba(0,0,0,.3); } .triangle { position: relative; height: 0px; width: 0; left: -5px; top: -32px; border-style: solid; border-width: 6px; border-color: transparent #882011 transparent transparent; z-index: -1; } -
最后,我们需要稍微修改一下游戏结束的逻辑。打开
html5games.matchgame.js文件,定位到gameover函数。 -
将以下代码添加到
gameover函数中,该函数比较当前得分与最后得分以确定新纪录:if (lastElapsedTime === 0 || matchingGame.elapsedTime < lastElapsedTime) { $(".ribbon").removeClass("hide"); } -
我们将在网页浏览器中测试这款游戏。尝试慢慢完成一个游戏,然后快速完成另一个游戏。当你打破最后得分时,游戏结束弹窗会显示一个漂亮的NEW RECORD横幅,如下面的截图所示:
![Time for action – creating a ribbon in CSS3]()
发生了什么?
我们刚刚使用纯 CSS3 样式创建了一个带绶带效果,并借助 JavaScript 来显示和隐藏它。绶带由一个三角形和覆盖其上的矩形组成,如下面的截图所示:

现在,我们如何在 CSS 中创建一个三角形?我们可以通过将宽度和高度都设置为0并只绘制一个边框来创建一个三角形。三角形的尺寸由边框宽度决定。以下是我们用于新记录绶带的三角形 CSS 代码:
.triangle {
position: relative;
height: 0px;
width: 0;
left: -5px;
top: -32px;
border-style: solid;
border-width: 6px;
border-color: transparent #882011 transparent transparent;
z-index: -1;
}
注意
以下 PVM Garage 网站提供了关于纯 CSS3 绶带使用的详细说明:
尝试一下英雄 - 只保存和比较最快的时间
每次游戏结束时,它会将最后得分与当前得分进行比较。然后,它保存当前得分。那么,将代码更改为保存最高分,并在打破最高分时显示新的记录绶带怎么样?
保存整个游戏进度
我们通过添加游戏结束屏幕和存储最后游戏记录来增强了我们的 CSS3 牌匹配游戏。想象一下,玩家正在游戏中,意外关闭了网络浏览器。一旦玩家再次打开游戏,游戏将从开始处重新开始,玩家正在玩的游戏就会丢失。使用本地存储,我们可以将整个游戏数据编码为 JSON 并存储起来。这样,玩家可以在以后继续他们的游戏。
我们将把游戏数据打包成一个对象,并每秒将其保存在本地存储中。
行动时间 - 在本地存储中保存所有必要游戏数据
我们将继续使用我们的 CSS3 牌匹配游戏:
-
打开
matchgame.jsJavaScript 文件。 -
在声明
matchingGame变量之后,在 JavaScript 文件顶部添加以下代码。此代码创建一个名为savingObject的对象来保存牌组数组、移除的牌和当前已过时间:matchingGame.savingObject = {}; matchingGame.savingObject.deck = []; // array to store which card is removed by their index. matchingGame.savingObject.removedCards = []; // store the counting elapsed time. matchingGame.savingObject.currentElapsedTime = 0; -
在 jQuery
ready函数中,添加以下突出显示的代码。它将牌组的顺序克隆到savingObject中。此外,它还为 DOM 中的每张牌分配一个索引:$(document).ready(function(){ // existing code goes here. // shuffling the deck matchingGame.deck.sort(shuffle); // copying the deck into saving object. matchingGame.savingObject.deck = matchingGame.deck.slice(); // clone 12 copies of the card DOM for(var i=0;i<11;i++){ $(".card:first-child").clone().appendTo("#cards"); } // existing code goes here. // embed the pattern data into the DOM element. $(this).attr("data-pattern",pattern); // save the index into the DOM element, //so we know which is the next card. $(this).attr("data-card-index",index); ... -
我们有一个
countTimer函数,每秒执行一次。我们在countTimer函数中添加以下突出显示的代码。它在savingObject中保存当前已过时间,并将对象保存在本地存储中:function countTimer() { matchingGame.elapsedTime++; // save the current elapsed time in savingObject. matchingGame.savingObject.currentElapsedTime = matchingGame.elapsedTime; ... // save the game progress saveSavingObject(); } -
当玩家找到匹配的牌对时,游戏会移除牌。我们在
removeTookCards函数中将原始的$(".card-removed").remove();代码替换为以下突出显示的代码。它在savingObject中记住哪些牌被移除:function removeTookCards() { // add each removed card into the array // which stores the removed cards $(".card-removed").each(function(){ matchingGame.savingObject.removedCards.push($(this).data("card-index")); $(this).remove(); }); // check whether all cards are removed and show game over if ($(".card").length === 0) { gameover(); } } -
游戏结束时,我们必须从本地存储中删除保存的游戏数据。在
gameover函数的末尾添加以下代码:function gameover() { // existing code goes here. //at last, we clear the saved savingObject localStorage.removeItem("savingObject"); } -
最后,我们使用一个函数将
savingObject保存在本地存储中:function saveSavingObject() { // save the encoded saving object in local storage localStorage["savingObject"] = JSON.stringify(matchingGame.savingObject); } -
我们已经修改了很多代码,现在是时候在网页浏览器中测试游戏了。游戏运行后,尝试清除几对匹配的卡片。然后,打开Web Inspector中的存储面板。本地存储应包含类似于以下截图中的条目:
![行动时间 – 将所有必要游戏数据保存到本地存储]()
这是一个带有
savingObject键和包含长字符串的 JSON 格式的值的记录。该 JSON 字符串包含洗好的牌组、移除的卡片和当前已过的时间
发生了什么?
我们已经将所有必要的游戏数据输入到一个名为savingObject的对象中。这个savingObject包含了我们稍后重新创建游戏所需的所有信息。它包括卡片的顺序、移除的卡片和当前已过的时间。我们将在下一节中实现游戏恢复逻辑。
最后,我们每秒将savingObject保存到localStorage中。该对象使用我们在本章前面使用的stringify函数进行编码。
从本地存储中删除记录
游戏结束后,我们需要删除保存的记录。否则,新游戏将无法开始。本地存储提供了一个removeItem函数来删除特定的记录。以下是使用该函数删除具有给定键的记录的方法:
localStorage.removeItem(key);
小贴士
如果你想删除所有存储的记录,则可以使用localStorage.clear()函数。
在 JavaScript 中克隆数组
我们在savingObject中克隆了洗好的牌组,这样我们就可以在游戏恢复时使用牌组的顺序来重新创建卡片。然而,我们不能通过将数组赋值给另一个变量来复制数组。以下代码未能将数组a复制到数组b中:
var a = [1,2,3,4,5];
var b = a;
a.pop();
// result:
// a: [1,2,3,4]
// b: [1,2,3,4]
slice函数提供了一个简单的方法来克隆只包含原始类型元素的数组,例如整数数组或字符串数组。只要数组中不包含另一个数组或对象作为元素,我们就可以使用slice函数来克隆数组。以下代码成功地将数组a复制到b中:
var a = [1,2,3,4,5];
var b = a.slice();
a.pop();
// result:
// a: [1,2,3,4]
// b: [1,2,3,4,5]
slice函数通常用于通过从现有数组中选择一系列元素来创建一个新数组。当使用不带任何参数的slice函数时,它会克隆整个数组。Mozilla 开发者网络提供了关于slice函数的详细信息,请参阅:developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/slice。
恢复游戏进度
我们已经保存了游戏进度,但尚未编写恢复游戏的逻辑。所以,让我们继续到恢复部分。
行动时间 – 从本地存储恢复游戏
执行以下步骤:
-
打开
matchgame.jsJavaScript 文件。 -
在 jQuery 文档的
ready函数中,我们使用了上一局游戏中保存的牌组顺序,而不是重新洗牌。在 jQuery 的ready函数中添加以下高亮代码:$(document).ready(function(){ // reset the elapsed time to 0. matchingGame.elapsedTime = 0; // start the timer matchingGame.timer = setInterval(countTimer, 1000); // shuffling the deck matchingGame.deck.sort(shuffle); // re-create the saved deck var savedObject = savedSavingObject(); if (savedObject !== undefined) { matchingGame.deck = savedObject.deck; } // copying the deck into saving object. matchingGame.savingObject.deck = matchingGame.deck.slice(); }); -
仍然在 jQuery 文档的
ready函数中,我们将以下高亮代码添加到函数的末尾。它移除了在保存的数据中被标记为已删除的任何卡片。我们还从保存的值中恢复了保存的已过时间:$(document).ready(function(){ // existing card creation code goes here. // removed cards that were removed in savedObject. if (savedObject !== undefined) { matchingGame.savingObject.removedCards = savedObject.removedCards; // find those cards and remove them. for(var i in matchingGame.savingObject.removedCards) { $(".card[data-card-index="+matchingGame.savingObject.removedCards[i]+"]").remove(); } } // reset the elapsed time to 0. matchingGame.elapsedTime = 0; // restore the saved elapsed time if (savedObject !== undefined) { matchingGame.elapsedTime = savedObject.currentElapsedTime; matchingGame.savingObject.currentElapsedTime = savedObject.currentElapsedTime; } }); -
最后,我们创建以下函数来从本地存储中检索
savingObject:// Returns the saved savingObject from the local storage. function savedSavingObject() { // returns the saved saving object from local storage var savingObject = localStorage["savingObject"]; if (savingObject !== undefined) { savingObject = JSON.parse(savingObject); } return savingObject; } -
保存所有文件,并在网络浏览器中打开游戏。尝试通过移除几对匹配的卡片来玩游戏。然后,关闭浏览器窗口并再次打开游戏。游戏应该从我们关闭窗口时的状态恢复,如下面的截图所示:
![开始行动时间 – 从本地存储恢复游戏]()
发生了什么?
我们通过解析整个游戏状态的保存 JSON 字符串完成了游戏的加载部分。
然后,我们从加载的对象savingObject中恢复了已过时间和牌组的顺序。恢复这两个属性只是变量赋值的问题。难点在于重新创建移除卡片的过程。在游戏保存部分,我们使用一个自定义的数据属性data-card-index为每个卡片的 DOM 分配了一个索引。我们在保存游戏时存储了每个被移除卡片的索引,这样我们就可以知道在加载游戏时哪些卡片被移除了。然后,当游戏设置时,我们可以移除这些卡片。以下代码在 jQuery 游戏的ready函数中移除卡片:
if (savedObject !== undefined) {
matchingGame.savingObject.removedCards = savedObject.removedCards;
// find those cards and remove them.
for(var i in matchingGame.savingObject.removedCards) {
$(".card[data-card-index="+matchingGame.savingObject.removedCards[i]+"]").remove();
}
}
提示
使用存储事件跟踪存储变化
有时候,我们可能想要监听localStorage中的变化。我们可以通过监听storage事件来实现。当localStorage中的任何内容发生变化时,都会触发这个事件。以下来自深入 HTML5的链接提供了关于如何使用该事件的详细讨论:diveintohtml5.org/storage.html#storage-event。
小测验 – 使用本地存储
Q1. 考虑以下每个陈述是否正确:
-
我们可以直接在本地存储中保存和恢复对象数据。
-
我们可以通过将对象编码成字符串来在本地存储中保存对象的数据。
-
我们可以使用
localStorage["hello"] = "world"来在本地存储中保存键为"hello"的值为"world"。
缓存游戏以实现离线访问
我们可以通过使用 AppCache 清单文档来启用离线缓存。当页面首次从互联网加载后,其相关文件将被缓存到设备中,用户即使在离线模式(如飞行模式)下也可以加载页面并玩游戏。
开始行动时间 – 添加 AppCache 清单
执行以下步骤将游戏离线化:
-
在
index.html文件中,我们添加一个manifest属性:<html lang="en" manifest="game.appcache"> -
然后,我们创建一个名为
game.appcache的文件,内容如下:CACHE MANIFEST # 2015-03-01:v3 CACHE: index.html css/matchgame.css images/bg.jpg images/deck.png images/popup_bg.jpg images/table.jpg js/jquery-1.11.2.min.js js/html5games.matchgame.js # Resources that require the user to be online. NETWORK: * -
为了测试缓存,我们需要将游戏在线托管。将项目文件夹上传到网络服务器,然后打开游戏并检查控制台,我们应该会看到浏览器下载或使用 AppCache 资源的消息,如下面的截图所示:
![添加 AppCache 清单的操作时间]()
发生了什么?
我们刚刚将一个 AppCache 清单文件添加到了我们的 index.html 文件中。现在一旦游戏加载完成,它就可以在离线和飞行模式下工作。
AppCache 文件
AppCache 文件是一个纯文本文件。它以 CACHE MANIFEST 开头。有两个部分:缓存和网络。我们通过在 AppCache 文件中使用带有 CACHE: 和 NETWORK: 的行来指定它们。可以为未缓存的文件提供可选的回退部分,用于回退资源。
在缓存部分,我们逐行指定我们想要缓存的文件。我们需要明确指定每个文件。在网络部分,我们指定如果文件未在缓存部分列出,则应该访问网络的文件。如果我们没有指定文件,即使有互联网连接,浏览器也不会获取非缓存的文件。大多数情况下,通配符(*)适用并且工作得很好。
任何以 # 开头的行都是注释。我们通常使用一行注释来指定缓存文件的版本。原因是浏览器一旦缓存了资源,它就不会更新缓存的文件,直到清单文件本身发生变化。因此,注释行可以强制浏览器更新缓存的资源。
注意
HTML5Rocks 有以下文章提供了更多关于使用 AppCache 文件的信息,包括使用 JavaScript 处理缓存的事件。查看它:www.html5rocks.com/en/tutorials/appcache/beginner/。
摘要
在本章中,你学习了如何在网页浏览器中使用本地存储来保存游戏数据。具体来说,我们在键值对本地存储中保存和检索了基本数据。我们将对象编码为 JSON 格式的字符串,并将字符串解析回 JavaScript 对象。我们保存了整个游戏进度,这样即使游戏进行到中途也能继续。我们还通过使用 AppCache 将游戏离线化。从视觉上看,我们使用纯 CSS3 样式创建了一个漂亮的 3D 条带作为新的成就徽章。
现在你已经学习了如何通过使用本地存储来改进我们之前的游戏,你准备好进入下一章了,在那里你将学习一个名为 WebSockets 的高级功能,我们可以使用它来实现玩家之间的实时交互。
第八章. 使用 WebSocket 构建多人画图猜谜游戏
在前几章中,我们构建了几个本地单人游戏。在本章中,我们将借助 WebSocket 构建一个多人游戏。WebSocket 允许我们创建基于事件的客户端-服务器架构。消息会在所有连接的浏览器之间即时传递。我们将结合 Canvas 绘图、JSON 数据打包以及在前几章中学到的几种技术来构建画图猜谜游戏。
在本章中,我们将学习以下主题:
-
尝试一个现有的多用户绘图板,该绘图板通过 WebSocket 显示来自不同连接用户的绘画
-
安装由 node.js 实现的 WebSocket 服务器
-
从浏览器连接服务器
-
使用 WebSocket API 创建即时聊天室
-
在 Canvas 中创建一个多用户绘图板
-
通过整合聊天室和绘图与游戏逻辑来构建画图猜谜游戏
以下截图显示了本章我们将创建的画图猜谜游戏:

那么,让我们继续吧。
安装 WebSocket 服务器
HTML5 WebSocket 为浏览器提供了一个连接到后端服务器的客户端 API。该服务器必须支持 WebSocket 协议才能保持连接的持续性。
安装 Node.js WebSocket 服务器
在本节中,我们将下载并安装一个名为 Node.js 的服务器,我们可以在其上安装 WebSocket 模块。
是时候安装 Node.js 了
-
访问包含 Node.js 服务器源代码的网址,
nodejs.org。 -
点击页面上的 安装 按钮。这将根据您的操作系统下载安装包。
-
按照安装程序的说明安装 Node.js 软件包。安装完成后,我们可以通过以下命令检查 Node.js 是否已安装,并查看其版本:
$ node --version -
前面的命令应该打印出 node.js 的版本号。在我的情况下,它是版本 0.12.0:
v0.12.0 -
我们还需要检查是否已通过以下命令安装了
npm软件包管理器:$ npm --version -
前面的命令应该打印出 npm 的版本号,即 Node.js 软件包管理器。在我的情况下,它是版本 2.5.1。
刚才发生了什么?
我们刚刚下载并安装了 Node.js 服务器。我们将在这一环境之上构建服务器逻辑。WebSocket 服务器不一定运行在 Node.js 上。WebSocket 协议有不同的服务器端实现。我们选择 Node.js 是因为它使用 JavaScript,我们在前几章构建了四个 HTML5 游戏后对其比较熟悉。
从 Node.js 分支出来的一个名为 io.js (iojs.org)。在撰写本书时,io.js 仍然非常新。如果你计划在未来项目中使用 Node.js,值得检查这两个平台上的最新状态和它们之间的差异。
注意
在某些 Linux 发行版中,二进制文件被重命名为nodejs。你可以使用以下命令将nodejs的符号链接创建到node。你可能需要sudo来运行此命令:
ln -s "$(which nodejs)" /usr/bin/node
创建一个发送连接数的 WebSocket 服务器
我们刚刚安装了node.js服务器。现在,我们将使用 WebSockets 构建一些内容。想象一下,我们想要一个服务器,它可以接受来自浏览器的连接,然后将连接数发送给所有用户。
行动时间 - 运行 WebSocket 服务器
-
为我们的代码创建一个项目文件夹。在其内部,创建一个名为
server的新目录。 -
使用终端或 shell 命令提示符切换到我们新创建的文件夹。
-
输入以下命令以安装 WebSocket 服务器:
npm install --save ws -
在
server目录下创建一个名为server.js的新文件,内容如下:var port = 8000; // Server code var WebSocketServer = require('ws').Server; var server = new WebSocketServer({ port: port }); server.on('connection', function(socket) { console.log("A connection established"); }); console.log("WebSocket server is running."); console.log("Listening to port " + port + "."); -
打开终端并切换到服务器目录。
-
输入以下命令以执行服务器:
node server.js -
如果一切正常,你应该会得到以下结果:
$ node server.js WebSocket server is running. Listening to port 8000.
刚才发生了什么?
我们刚刚创建了一个简单的服务器逻辑,初始化了 WebSockets 库并监听了连接事件。
初始化 WebSocket 服务器
在Node.JS中,不同的功能被封装到模块中。当我们需要特定模块的功能时,我们使用require来加载它。我们在服务器逻辑中使用以下代码加载 WebSockets 模块并初始化服务器:
var WebSocketServer = require('ws').Server;
var server = new WebSocketServer({ port: port });
由于ws模块由npm管理,它被安装在一个名为node_modules的文件夹中。当我们仅使用名称要求一个库时,Node.js 运行时会查找node_modules文件夹中的该模块。
我们使用8000作为服务器的端口号,客户端通过这个端口号连接到服务器。我们可以选择不同的端口号,但必须确保所选端口号没有被其他常见服务器服务占用。
在服务器端监听连接事件
node.js服务器是事件驱动的。这意味着大多数逻辑都是在某个事件被触发时执行的。我们在示例中使用的以下代码监听connection事件并处理它:
server.on('connection', function(socket) {
console.log("A connection established");
});
connection事件带有 socket 参数。我们稍后需要存储这个 socket,因为我们使用这个对象与连接的客户端交互。
创建一个客户端连接到 WebSocket 服务器并获取总连接数
在上一个例子中,我们构建了服务器,现在,我们将构建一个连接到我们的 WebSocket 服务器并接收服务器消息的客户端。消息将包含来自服务器的总连接数。
行动时间 - 在 WebSocket 应用程序中显示连接数
执行以下步骤:
-
创建一个名为
client的新目录。 -
在
client文件夹中创建一个名为index.html的 HTML 文件。 -
现在,在我们的 HTML 文件中添加一些标记。为此,将以下代码放入
index.html文件中:<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>WebSockets demo for HTML5 Games Development: A Beginner's Guide</title> </head> <body> <!-- game elements goes here later --> <script src="img/jquery-2.1.3.min.js"></script> <script src="img/html5games.websocket.js"></script> </body> </html> -
创建一个名为
js的目录,并将 jQuery JavaScript 文件放入其中。 -
创建一个名为
html5games.websockets.js的新文件,如下所示:var websocketGame = { } // init script when the DOM is ready. $(function(){ // check if existence of WebSockets in browser if (window["WebSocket"]) { // create connection websocketGame.socket = new WebSocket("ws://127.0.0.1:8000"); // on open event websocketGame.socket.onopen = function(e) { console.log('WebSocket connection established.'); }; // on close event websocketGame.socket.onclose = function(e) { console.log('WebSocket connection closed.'); }; } }); -
在完成这些步骤后,我们应该在我们的项目目录中创建以下文件夹结构:
![Time for action – showing the connection count in a WebSocket application]()
-
我们现在将测试代码。首先,你需要使用
node在终端中的 server 目录运行我们的server.js代码,即使用node server.js。 -
接下来,在客户端目录中,使用网页浏览器打开
index.html文件两次,以便我们有两个客户端实例并行运行。 -
检查服务器终端。应该有类似于以下内容的日志消息,指示连接信息和总连接数:
$ node server.js WebSocket server is running. Listening to port 8000. A connection established. A connection established. -
在两个网页浏览器中,打开 开发者工具 中的控制台。你应该在控制台中看到 WebSocket 连接已建立 的消息。
发生了什么?
我们刚刚构建了一个客户端,该客户端建立了与我们在上一节中构建的服务器的 WebSocket 连接。然后客户端会将从服务器接收到的任何消息打印到 开发者工具 的 检查器 中的控制台面板。
建立 WebSocket 连接
在任何支持 WebSocket 的浏览器中,我们可以通过创建一个新的 WebSocket 实例来建立连接,以下代码所示:
var socket = new WebSocket(url);
url 参数是一个包含 WebSocket URL 的字符串。在我们的例子中,我们正在本地运行我们的服务器。因此,我们使用的 URL 是 ws://127.0.0.1:8000,其中 8000 代表我们连接的服务器端口号。它是 8000,因为当我们在服务器端构建逻辑时,服务器正在监听端口号 8000。
WebSocket 客户端事件
与服务器类似,客户端也有几个 WebSocket 事件。以下表格列出了我们将用于处理 WebSocket 的事件:
| 事件名称 | 描述 |
|---|---|
onopen |
当建立到服务器的连接时触发 |
onmessage |
当收到来自服务器的任何消息时触发 |
onclose |
当服务器关闭连接时触发 |
onerror |
当连接出现任何错误时触发 |
向所有连接的浏览器发送消息
一旦服务器接收到新的 connection 事件,我们将连接的更新计数发送给所有客户端。向所有客户端发送消息很简单。我们只需在 server 实例中调用 sendAll 函数,并将 string 类型的消息作为参数即可。
以下代码片段向所有连接的浏览器发送服务器消息:
var message = "a message from server";
server.sendAll(message);
Time for action – sending total count to all users
执行以下步骤以创建游戏的基础逻辑:
-
在服务器文件夹中,我们创建一个名为
game.js的新文件。我们将在这个文件中存储房间和游戏逻辑。 -
我们定义了一个
User类,该类存储套接字连接对象并创建一个随机 ID。function User(socket) { this.socket = socket; // assign a random number to User. // Long enough to make duplication chance less. this.id = "1" + Math.floor( Math.random() * 1000000000); } -
我们还定义了一个
Room类。我们在这个类中存储用户实例的集合。function Room() { this.users = []; } -
我们在
Room类中定义了两个实例方法,用于管理用户的添加和删除。Room.prototype.addUser = function(user){ this.users.push(user); var room = this; // handle user closing user.socket.onclose = function(){ console.log('A connection left.'); room.removeUser(user); } }; Room.prototype.removeUser = function(user) { // loop to find the user for (var i=this.users.length; i >= 0; i--) { if (this.users[i] === user) { this.users.splice(i, 1); } } }; -
然后,我们定义另一个负责向房间中所有已连接用户发送消息的方法:
Room.prototype.sendAll = function(message) { for (var i=0, len=this.users.length; i<len; i++) { this.users[i].socket.send(message); } }; -
在继续之前,我们需要将新定义的
User和Room类导出,以便其他文件可以使用它们:module.exports.User = User; module.exports.Room = Room; -
在
server.js文件中,我们用以下代码替换连接处理器,该代码将用户计数发送给所有已连接的用户:var User = require('./game').User; var Room = require('./game').Room; var room1 = new Room(); server.on('connection', function(socket) { var user = new User(socket); room1.addUser(user); console.log("A connection established"); var message = "Welcome " + user.id + " joining the party. Total connection: " + room1.users.length; room1.sendAll(message); }); -
我们接着转向客户端。在clients | js文件夹内的
html5games.websocket.js文件中,我们添加一个处理器来打印从服务器接收到的消息。// on message event websocketGame.socket.onmessage = function(e) { console.log(e.data); }; -
最后,我们测试代码。在服务器目录中执行
node server.js来启动服务器。然后打开index.html文件,我们应该在控制台看到以下类似的截图:![行动时间 - 向所有用户发送总计数]()
发生了什么事?
我们在game.js文件中定义了两个类,User和Room,我们使用它们来管理所有连接的套接字。
定义类和实例方法
在 JavaScript 中,面向对象编程是通过使用函数和原型来实现的。当我们通过调用new Room()创建一个房间实例时,浏览器会将Room.prototype中的所有属性和方法克隆到实例中。
处理新连接的用户
对于每个已连接的用户,我们需要通过事件处理器与他们交互。我们将用户对象添加到数组中以方便管理。我们需要处理用户断开连接时的onclose事件。为此,我们将该用户从数组中删除。
导出模块
在game.js文件中定义我们的类之后,我们将它们导出。通过将它们导出到模块中,我们可以使用require方法在其他文件中导入它们,如下所示:
var User = require('./game').User;
var Room = require('./game').Room;
向客户端发送消息
WebSockets 具有从服务器向用户发送消息的能力。传统上,客户端请求服务器,然后服务器响应。在套接字服务器中,所有用户都是连接的,因此消息可以双向触发和发送。在这里,我们遍历所有用户以发送广播消息:
Room.prototype.send = function(message) {
for (var i=0, len=this.users.length; i<len; i++) {
this.users[i].socket.send(message);
}
};
然后,我们通过使用onmessage事件处理器在客户端监听服务器消息。
// on message event
websocketGame.socket.onmessage = function(e) {
console.log(e.data);
};
使用 WebSockets 构建聊天应用
我们现在知道有多少浏览器已连接。假设我们想要构建一个聊天室,用户可以在各自的浏览器中输入消息,并将消息立即发送给所有已连接的用户。
向服务器发送消息
我们将让用户输入一条消息,然后将消息发送到node.js服务器。服务器然后将消息转发给所有已连接的浏览器。一旦浏览器接收到消息,它就会在聊天区域显示出来。在这种情况下,用户在加载网页后就会连接到即时聊天室。
行动时间 - 通过 WebSockets 向服务器发送消息
-
首先,编写服务器逻辑。
-
打开
servergame.js。向文件中添加以下处理用户消息的功能:Room.prototype.handleOnUserMessage = function(user) { var room = this; user.socket.on("message", function(message){ console.log("Receive message from " + user.id + ": " + message); }); }; -
在调用我们新创建函数的
Room.prototype.addUser方法内部添加以下代码:this.handleOnUserMessage(user); -
现在,转到
client文件夹。 -
打开
index.html文件,在body部分添加以下标记。这为用户提供输入以向服务器发送消息:<input type="text" id="chat-input" autocomplete="off"> <input type="button" value="Send" id="send"> -
然后,将以下代码添加到
html5games.websocket.jsJavaScript 文件中。当用户点击send按钮或按Enter键时,这将向服务器发送消息:$("#send").click(sendMessage); $("#chat-input").keypress(function(event) { if (event.keyCode === 13) { sendMessage(); } }); function sendMessage() { var message = $("#chat-input").val(); websocketGame.socket.send(message); $("#chat-input").val(""); } -
在测试我们的代码之前,检查服务器终端,看看 node 服务器是否仍在运行。按Ctrl + C来终止它,然后使用
nodeserver.js命令再次运行它。 -
在网页浏览器中打开
index.html。你应该看到一个带有发送按钮的输入文本字段,如下面的截图所示:![执行动作 – 通过 WebSockets 向服务器发送消息]()
-
在输入文本字段中输入一些内容,然后点击发送按钮或按Enter键。输入文本将被清除。
-
现在,切换到服务器终端,你会看到服务器正在打印我们刚刚发送的文本。你还可以将浏览器和服务器终端并排放置,以查看消息是如何从客户端瞬间发送到服务器的。以下截图显示了包含来自两个连接浏览器的服务器终端:
![执行动作 – 通过 WebSockets 向服务器发送消息]()
刚才发生了什么?
我们通过为用户添加一个输入文本字段来扩展我们的连接示例,让他们在那里输入一些文本并发送出去。文本作为消息发送到 WebSocket 服务器。然后,服务器将在终端打印收到的消息。
从客户端向服务器发送消息
为了从客户端向服务器发送消息,我们在WebSocket实例中调用以下send方法:
websocketGame.socket.send(message);
在以下代码片段中,我们从输入文本字段获取消息并发送到服务器:
var message = $("#chat-input").val();
websocketGame.socket.send(message);
在服务器端接收消息
在服务器端,我们需要处理从客户端发送的刚刚的消息。我们在 WebSocket node.js库的连接实例中有一个名为message的事件。我们可以监听连接消息事件以接收每个客户端连接的消息。
以下代码片段显示了如何使用消息事件监听器在服务器终端打印消息:
socket.on("message", function(message){
console.log("Receive message: " + message);
});
将服务器端接收到的每条消息发送出去以创建聊天室
在最后一个示例中,服务器可以接收来自浏览器的消息。然而,服务器除了在终端打印收到的消息外,没有做任何事情。因此,我们将向服务器添加一些逻辑来发送消息。
执行动作 – 向所有连接的浏览器发送消息
执行以下步骤:
-
在服务器文件夹中打开
game.js文件以查看服务器端逻辑。 -
将以下突出显示的代码添加到消息事件监听器处理器中:
user.socket.on("message", function(message){ console.log("Receive message from " + user.id + ": " + message); // send to all users in room. var msg = "User " + user.id + " said: " + message; room.sendAll(msg); }); -
服务器端的实现到此结束。接下来,转到
client文件夹并打开index.html文件。 -
我们想在聊天历史区域显示聊天消息。为此,将以下代码添加到 HTML 文件中:
<ul id="chat-history"></ul> -
接下来,我们需要客户端 JavaScript 来处理从服务器接收到的消息。我们使用它将消息打印到控制台面板中,将
onmessage事件处理器中的console.log代码替换为以下突出显示的代码:socket.onmessage = function(e) { $("#chat-history").append("<li>"+e.data+"</li>"); }; -
让我们测试我们的代码。通过按Ctrl + C终止任何正在运行的 node 服务器。然后,再次运行服务器。
-
打开两次
index.html文件并将它们并排放置。在文本字段中输入一些内容并按Enter键。消息将出现在所有打开的浏览器中。如果你打开了多个 HTML 文件实例,消息将出现在所有浏览器中。以下截图显示了两个浏览器并排显示聊天历史记录:![Time for action – sending messages to all connected browsers]()
发生了什么?
这是之前示例的扩展。我们讨论了服务器如何将连接计数发送给所有已连接的客户端。我们还讨论了客户端如何向服务器发送消息。在这个例子中,我们将这两种技术结合起来,让服务器将接收到的消息发送给所有已连接的用户。
Comparing WebSockets with polling approaches
如果你曾经使用服务器端语言和数据库构建过网页聊天室,那么你可能想知道 WebSocket 实现与传统实现之间的区别。
传统的聊天室方法通常通过使用轮询方法实现。客户端定期向服务器请求更新。服务器以没有更新或更新数据的方式对客户端进行响应。然而,传统方法有几个问题。客户端直到下一次请求服务器之前都不会收到来自服务器的更新数据。这意味着数据更新会随着时间周期性地延迟,响应不够即时。如果我们想通过缩短轮询持续时间来改善这个问题,那么将利用更多的带宽,因为客户端需要不断向服务器发送请求。
以下图表显示了客户端和服务器之间的请求。它显示发送了许多无用的请求,但服务器在没有新数据的情况下对客户端进行了响应:

有一种更好的轮询方法叫做长轮询:客户端向服务器发送请求并等待响应。与传统的轮询方法不同,服务器不会在“无更新”时响应,而是在需要将某些内容推送到服务器时才响应。在这种情况下,服务器可以在有更新时随时向客户端推送内容。一旦客户端从服务器收到响应,它就会创建另一个请求并等待下一个服务器通知。以下图表显示了长轮询方法,其中客户端请求更新,服务器只在有更新时响应:

在 WebSocket 方法中,请求的数量远少于轮询方法。这是因为客户端和服务器之间的连接是持久的。一旦建立连接,只有当有更新时,客户端或服务器才会发送请求。例如,当客户端想要向服务器更新某些内容时,它会向服务器发送消息。服务器也只在需要通知客户端数据更新时向客户端发送消息。在连接期间不会发送其他无用的请求。因此,利用的带宽更少。以下图表显示了 WebSocket 方法:

使用 Canvas 和 WebSocket 制作共享绘图白板
假设我们想要一个共享的草图板。任何人都可以在草图板上绘制内容,其他人都可以查看。我们学习了客户端和服务器之间如何通信消息。我们将进一步发送绘图数据。
构建本地绘图草图板
在我们处理数据发送和服务器处理之前,让我们专注于制作一个绘图白板。我们将使用 Canvas 来构建一个本地绘图草图板。
实践时间 - 使用 Canvas 制作本地绘图白板
执行以下步骤:
-
在本节中,我们将只关注客户端。打开
index.html文件并添加以下canvas标记:<canvas id='drawing-pad' width='500' height='400'> </canvas> -
我们将在 Canvas 上绘制一些内容,为此我们需要 Canvas 的鼠标位置。我们在第四章中这样做过,即使用 Canvas 和绘图 API 构建 Untangle 游戏。向 Canvas 添加以下样式:
<style> canvas{position:relative;} </style> -
然后,打开
html5games.websocket.jsJavaScript 文件以添加绘图逻辑。 -
在 JavaScript 文件顶部将
websocketGame全局对象替换为以下变量:var websocketGame = { // indicates if it is drawing now. isDrawing : false, // the starting point of next line drawing. startX : 0, startY : 0, } // canvas context var canvas = document.getElementById('drawing-pad'); var ctx = canvas.getContext('2d'); -
在 jQuery 的
ready函数中,添加以下鼠标事件处理代码。该代码处理鼠标的按下、移动和抬起事件:// the logic of drawing in the Canvas $("#drawing-pad").mousedown(function(e) { // get the mouse x and y relative to the canvas top-left point. var mouseX = e.originalEvent.layerX || e.offsetX || 0; var mouseY = e.originalEvent.layerY || e.offsetY || 0; websocketGame.startX = mouseX; websocketGame.startY = mouseY; websocketGame.isDrawing = true; }); $("#drawing-pad").mousemove(function(e) { // draw lines when is drawing if (websocketGame.isDrawing) { // get the mouse x and y // relative to the canvas top-left point. var mouseX = e.originalEvent.layerX || e.offsetX || 0; var mouseY = e.originalEvent.layerY || e.offsetY || 0; if (!(mouseX === websocketGame.startX && mouseY === websocketGame.startY)) { drawLine(ctx, websocketGame.startX, websocketGame.startY,mouseX,mouseY,1); websocketGame.startX = mouseX; websocketGame.startY = mouseY; } } }); $("#drawing-pad").mouseup(function(e) { websocketGame.isDrawing = false; }); -
最后,我们有以下函数来在 Canvas 上根据给定的起始点和结束点绘制线条:
function drawLine(ctx, x1, y1, x2, y2, thickness) { ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.lineWidth = thickness; ctx.strokeStyle = "#444"; ctx.stroke(); } -
保存所有文件并打开
index.html文件。你应该看到一个空白区域,你可以使用鼠标在这里绘制东西。绘图尚未发送到服务器,所以其他人无法查看你的绘图:![动手实践 – 使用 Canvas 创建本地绘图白板]()
刚才发生了什么?
我们刚刚创建了一个本地绘图板。这就像一个白板,玩家可以通过拖动鼠标在 Canvas 上绘图。然而,绘图数据尚未发送到服务器;所有绘图都仅在本地显示。
drawing line 函数与我们之前在 第四章 中使用的相同,即 使用 Canvas 和绘图 API 构建 Untangle 游戏。我们也使用了相同的代码来获取相对于 canvas 元素的鼠标位置。然而,鼠标事件的逻辑与 第四章 不同。
在 Canvas 中绘图
当我们在电脑上绘图时,通常意味着我们点击 Canvas 并拖动鼠标(或笔)。线条会一直绘制,直到鼠标按钮抬起。然后,用户点击另一个地方并再次拖动以绘制线条。
在我们的示例中,我们有一个名为 isDrawing 的布尔标志,用来指示用户是否在绘图。默认情况下,isDrawing 标志为 false。当鼠标按钮位于某个点时,我们将标志设置为 true。当鼠标移动时,我们在移动的点与鼠标按钮按下时的最后一个点之间画线。然后,当鼠标按钮抬起时,我们将 isDrawing 标志设置为 false。这就是绘图逻辑的工作方式。
英雄试炼 – 使用颜色绘图
我们能否通过添加颜色支持来修改绘图草图板?比如添加红色、蓝色、绿色、黑色和白色的五个按钮?玩家可以在绘图时选择颜色。或者,我们也可以为用户提供不同的笔宽选项。
将绘图发送到所有连接的浏览器
我们将进一步通过将我们的绘图数据发送到服务器,并让服务器将绘图发送到所有连接的浏览器。
动手实践 – 通过 WebSocket 发送绘图
执行以下步骤:
-
首先,我们需要修改服务器逻辑。打开
game.js文件,并在文件开头添加两个常量,如下所示:// Constants var LINE_SEGMENT = 0; var CHAT_MESSAGE = 1; -
在
Room.prototype.addUser方法中,在方法开头添加以下代码:this.users.push(user); var room = this; // tell others that someone joins the room var data = { dataType: CHAT_MESSAGE, sender: "Server", message: "Welcome " + user.id + " joining the party. Total connection: " + this.users.length }; room.sendAll(JSON.stringify(data)); -
我们使用 JSON 格式的字符串来传递绘图动作和聊天消息。在用户消息事件处理程序的用户套接字上添加以下代码:
user.socket.on("message", function(message){ console.log("Receive message from " + user.id + ": " + message); // construct the message var data = JSON.parse(message); if (data.dataType === CHAT_MESSAGE) { // add the sender information into the message data object. data.sender = user.id; } // send to all clients in room. room.sendAll(JSON.stringify(data)); }); -
在
server.js文件中,由于现在由Room.addUser方法处理,因此不需要向房间发送欢迎消息。从server.js文件中删除以下代码:room1.sendAll(message); -
在客户端,我们需要逻辑来响应服务器,使用相同的数据对象定义。在client目录下的js目录中打开
html5games.websocket.jsJavaScript 文件。 -
将以下常量添加到
websocketGame全局变量中。这些相同的常量及其相同的值也在服务器端逻辑中定义。// Contants LINE_SEGMENT : 0, CHAT_MESSAGE : 1, -
当在客户端处理消息事件时,将 JSON 格式的字符串转换回数据对象。如果数据是聊天消息,则将其显示为聊天历史,否则将其绘制在 Canvas 上作为线段。将
onmessage事件处理器替换为以下代码:websocketGame.socket.onmessage = function(e) { // check if the received data is chat or line segment console.log("onmessage event:",e.data); var data = JSON.parse(e.data); if (data.dataType === websocketGame.CHAT_MESSAGE) { $("#chat-history").append("<li>" + data.sender + " said: "+data.message+"</li>"); } else if (data.dataType === websocketGame.LINE_SEGMENT) { drawLine(ctx, data.startX, data.startY, data.endX, data.endY, 1); } }; -
当鼠标移动时,我们不仅会在 Canvas 中绘制线条,还会将线条数据发送到服务器。将以下高亮代码添加到
mousemove事件处理器中:$("#drawing-pad").mousemove(function(e) { // draw lines when is drawing if (websocketGame.isDrawing) { // get the mouse x and y // relative to the canvas top-left point. var mouseX = e.originalEvent.layerX || e.offsetX || 0; var mouseY = e.originalEvent.layerY || e.offsetX || 0; if (!(mouseX === websocketGame.startX && mouseY === websocketGame.startY)) { drawLine(ctx, websocketGame.startX, websocketGame.startY,mouseX,mouseY,1); // send the line segment to server var data = {}; data.dataType = websocketGame.LINE_SEGMENT; data.startX = websocketGame.startX; data.startY = websocketGame.startY; data.endX = mouseX; data.endY = mouseY; websocketGame.socket.send(JSON.stringify(data)); websocketGame.startX = mouseX; websocketGame.startY = mouseY; } } }); -
最后,我们需要修改发送消息的逻辑。现在,我们在将消息发送到服务器时,将其打包成一个对象并以 JSON 格式进行格式化。将
sendMessage函数更改为以下代码:function sendMessage() { var message = $("#chat-input").val(); // pack the message into an object. var data = {}; data.dataType = websocketGame.CHAT_MESSAGE; data.message = message; websocketGame.socket.send(JSON.stringify(data)); $("#chat-input").val(""); } -
保存所有文件并重新启动服务器。
-
在两个浏览器实例中打开
index.html文件。 -
首先,尝试聊天室功能,输入一些消息并发送它们。然后,尝试在 Canvas 上绘制一些东西。两个浏览器应该显示相同的绘图,如下面的截图所示:
![行动时间 – 通过 WebSockets 发送绘图]()
发生了什么?
我们刚刚构建了一个多用户绘图板。这与我们在本章开头尝试的绘图板类似。我们通过发送一个复杂的数据对象作为消息,扩展了你在构建聊天室时学到的内容。
定义数据对象以在客户端和服务器之间通信
当一个消息中包含大量数据时,为了在服务器和客户端之间正确通信,我们必须定义一个客户端和服务器都能理解的数据对象。
数据对象中有几个属性。以下表格列出了属性及其原因:
| 属性名称 | 我们为什么需要这个属性 |
|---|---|
dataType |
这是一个重要的属性,帮助我们理解整个数据。数据要么是聊天消息,要么是绘图线段数据。 |
sender |
如果数据是聊天消息,客户端需要知道谁发送了消息。 |
message |
当数据类型是聊天消息时,我们当然需要将消息内容本身包含到数据对象中。 |
startX |
当数据类型是绘图线段时,我们包括线的起点的x/y坐标。 |
startY |
|
endX |
当数据类型是绘图线段时,我们包括线的终点的x/y坐标。 |
endY |
此外,我们还在客户端和服务器端定义了以下常量;这些常量用于dataType属性:
// Contants
LINE_SEGMENT : 0,
CHAT_MESSAGE : 1,
使用这些常数,我们可以用以下可读的代码来比较数据类型,而不是使用无意义的整数:
if (data.dataType === websocketGame.CHAT_MESSAGE) {…}
将绘图线条数据打包成 JSON 以发送
在上一章中,我们使用JSON.stringify函数将 JavaScript 对象存储到本地存储中的 JSON 格式字符串。现在,我们需要在服务器和客户端之间以字符串格式发送数据。我们使用相同的方法将绘图线条数据打包成一个对象,并以 JSON 字符串的形式发送。
以下代码片段展示了我们如何在客户端打包线段数据,并以 JSON 格式字符串的形式将其发送到服务器:
// send the line segment to server
var data = {};
data.dataType = websocketGame.LINE_SEGMENT;
data.startX = startX;
data.startY = startY;
data.endX = mouseX;
data.endY = mouseY;
websocketGame.socket.send(JSON.stringify(data));
在从其他客户端接收到绘图线条后重新创建绘图线条
JSON 解析通常与stringify配对。当我们从服务器接收到消息时,我们必须将其解析为 JavaScript 对象。以下客户端的代码解析数据,并根据数据更新聊天历史或绘制线条:
var data = JSON.parse(e.data);
if (data.dataType === websocketGame.CHAT_MESSAGE) {
$("#chat-history").append("<li>"+data.sender+" said: "+data.message+"</li>");
}
else if (data.dataType === websocketGame.LINE_SEGMENT) {
drawLine(ctx, data.startX, data.startY, data.endX, data.endY, 1);
}
构建多人画猜游戏
在本章早期,我们构建了一个即时聊天室。此外,我们刚刚构建了一个多用户绘图板。那么,将这两种技术结合起来构建一个画猜游戏怎么样?画猜游戏是一种游戏,其中一名玩家被分配一个单词来绘制。所有其他玩家不知道这个单词,根据绘图来猜测单词。绘制者和正确猜出单词的玩家得分。
行动时间 - 构建画猜游戏
我们将按照以下方式实现画猜游戏的游戏流程:
-
首先,我们将在客户端添加游戏逻辑。
-
在客户端目录中打开
index.html文件。在发送按钮之后添加以下重启按钮:<input type="button" value="Restart" id="restart"> -
打开
html5games.websocket.jsJavaScript 文件。 -
我们需要一些额外的常数来确定游戏过程中的不同状态。将以下突出显示的代码添加到文件顶部:
// Constants LINE_SEGMENT : 0, CHAT_MESSAGE : 1, GAME_LOGIC : 2, // Constant for game logic state WAITING_TO_START : 0, GAME_START : 1, GAME_OVER : 2, GAME_RESTART : 3, -
此外,我们还想添加一个标志来指示这位玩家负责绘图。将以下布尔全局变量添加到代码中:
isTurnToDraw : false, -
当客户端从服务器接收到消息时,它会解析消息并检查它是否是聊天消息或线条绘图。现在我们有一种新的消息类型名为
GAME_LOGIC,用于处理游戏逻辑。游戏逻辑消息包含不同游戏状态的不同数据。将以下代码添加到onmessage事件处理器中:else if (data.dataType === websocketGame.GAME_LOGIC) { if (data.gameState === websocketGame.GAME_OVER) { websocketGame.isTurnToDraw = false; $("#chat-history").append("<li>" + data.winner +" wins! The answer is '"+data.answer+"'.</li>"); $("#restart").show(); } if (data.gameState === websocketGame.GAME_START) { // clear the Canvas. canvas.width = canvas.width; // hide the restart button. $("#restart").hide(); // clear the chat history $("#chat-history").html(""); if (data.isPlayerTurn) { websocketGame.isTurnToDraw = true; $("#chat-history").append("<li>Your turn to draw. Please draw '" + data.answer + "'.</li>"); } else { $("#chat-history").append("<li>Game Started. Get Ready. You have one minute to guess.</li>"); } } } -
在客户端逻辑中还有最后一步。我们希望通过向服务器发送重启信号来重启游戏。同时,我们清除绘图和聊天历史。为此,在
html5games.websocket.js文件中添加以下代码。// restart button $("#restart").hide(); $("#restart").click(function(){ canvas.width = canvas.width; $("#chat-history").html(""); $("#chat-history").append("<li>Restarting Game.</li>"); // pack the restart message into an object. var data = {}; data.dataType = websocketGame.GAME_LOGIC; data.gameState = websocketGame.GAME_RESTART; websocketGame.socket.send(JSON.stringify(data)); $("#restart").hide(); }); -
现在是时候转向服务器端了。我们需要更多的状态来控制游戏流程。将
game.js文件开头的常数替换为以下代码。// Constants var LINE_SEGMENT = 0; var CHAT_MESSAGE = 1; var GAME_LOGIC = 2; // Constant for game logic state var WAITING_TO_START = 0; var GAME_START = 1; var GAME_OVER = 2; var GAME_RESTART = 3; -
在之前的示例中,服务器端只是负责将任何传入的消息发送给所有已连接的浏览器。这对于多人游戏是不够的。服务器将作为游戏主持人,控制游戏流程和获胜条件的确定。我们通过
GameRoom扩展Room类,使其能够处理游戏流程。 -
现在,将以下代码添加到
game.js文件的末尾。这是名为GameRoom的新类的构造函数,它初始化游戏逻辑。function GameRoom() { // the current turn of player index. this.playerTurn = 0; this.wordsList = ['apple','idea','wisdom','angry']; this.currentAnswer = undefined; this.currentGameState = WAITING_TO_START; // send the game state to all players. var gameLogicData = { dataType: GAME_LOGIC, gameState: WAITING_TO_START }; this.sendAll(JSON.stringify(gameLogicData)); } -
然后,我们将现有的
Room功能扩展到GameRoom原型中,以便GameRoom默认可以访问Room类的原型函数。// inherit Room GameRoom.prototype = new Room(); -
在
GameRoom类中定义以下addUser函数。在现有的GameRoom代码之后附加代码。这保留了原始房间的addUser函数并添加了额外的逻辑,等待足够多的玩家加入以开始游戏:GameRoom.prototype.addUser = function(user) { // a.k.a. super(user) in traditional OOP language. Room.prototype.addUser.call(this, user); // start the game if there are 2 or more connections if (this.currentGameState === WAITING_TO_START && this.users.length >= 2) { this.startGame(); } }; -
与之前的示例不同,其中服务器只是将用户消息传递给所有已连接的客户端,现在服务器需要确定用户的消息是否是游戏流程的一部分。在现有的
GameRoom逻辑之后附加以下代码;它覆盖了原始房间的handleOnUserMessage函数,并引入了新的逻辑来处理聊天消息、线段和游戏流程的控制:GameRoom.prototype.handleOnUserMessage = function(user) { var room = this; // handle on message user.socket.on('message', function(message){ console.log("[GameRoom] Receive message from " + user.id + ": " + message); var data = JSON.parse(message); if (data.dataType === CHAT_MESSAGE) { // add the sender information into the message data object. data.sender = user.id; } room.sendAll(JSON.stringify(data)); // check if the message is guessing right or wrong if (data.dataType === CHAT_MESSAGE) { console.log("Current state: " + room.currentGameState); if (room.currentGameState === GAME_START) { console.log("Got message: " + data.message + " (Answer: " + room.currentAnswer + ")"); } if (room.currentGameState === GAME_START && data.message === room.currentAnswer) { var gameLogicData = { dataType: GAME_LOGIC, gameState: GAME_OVER, winner: user.id, answer: room.currentAnswer }; room.sendAll(JSON.stringify(gameLogicData)); room.currentGameState = WAITING_TO_START; // clear the game over timeout clearTimeout(room.gameOverTimeout); } } if (data.dataType === GAME_LOGIC && data.gameState === GAME_RESTART) { room.startGame(); } }); }; -
让我们继续
GameRoom逻辑。将以下新函数添加到game.js文件中。这个函数在房间内创建一个新的游戏,通过选择一个玩家作为绘图者,其他玩家作为猜测者;然后,随机为绘图者选择一个单词:GameRoom.prototype.startGame = function() { var room = this; // pick a player to draw this.playerTurn = (this.playerTurn+1) % this.users.length; console.log("Start game with player " + this.playerTurn + "'s turn."); // pick an answer var answerIndex = Math.floor(Math.random() * this.wordsList.length); this.currentAnswer = this.wordsList[answerIndex]; // game start for all players var gameLogicDataForAllPlayers = { dataType: GAME_LOGIC, gameState: GAME_START, isPlayerTurn: false }; this.sendAll(JSON.stringify(gameLogicDataForAllPlayers)); // game start with answer to the player in turn. var gameLogicDataForDrawer = { dataType: GAME_LOGIC, gameState: GAME_START, answer: this.currentAnswer, isPlayerTurn: true }; // the user who draws in this turn. var user = this.users[this.playerTurn]; user.socket.send(JSON.stringify(gameLogicDataForDrawer)); // game over the game after 1 minute. gameOverTimeout = setTimeout(function(){ var gameLogicData = { dataType: GAME_LOGIC, gameState: GAME_OVER, winner: "No one", answer: room.currentAnswer }; room.sendAll(JSON.stringify(gameLogicData)); room.currentGameState = WAITING_TO_START; },60*1000); room.currentGameState = GAME_START; }; -
最后,我们导出
GameRoom类,以便其他文件,如server.js,可以访问GameRoom类:module.exports.GameRoom = GameRoom; -
在
server.js中,我们必须调用我们的新GameRoom构造函数而不是通用的Room构造函数。将原始与Room相关的代码替换为以下GameRoom代码:var GameRoom = require('./game').GameRoom; var room1 = new GameRoom(); -
我们将保存所有文件并重新启动服务器。然后,在两个浏览器实例中启动
index.html文件。一个浏览器将从服务器接收到一条消息,告知玩家绘制一些东西。另一方面,另一个浏览器将告知玩家在一分钟内猜测其他玩家正在绘制的内容。 -
被告知绘制东西的玩家可以在画布上绘制。这些绘画被发送给所有其他已连接的玩家。被告知猜测的玩家不能在画布上绘制任何东西。相反,玩家在文本字段中输入他们的猜测并发送给服务器。如果猜测正确,则游戏结束。否则,游戏将继续进行,直到一分钟倒计时结束。
![行动时间 - 构建绘制和猜测游戏]()
发生了什么?
我们刚刚在 WebSockets 和 Canvas 中创建了一个多人绘制和猜测游戏。游戏与多用户草图板的主要区别在于,现在服务器控制游戏流程,而不是让所有用户绘制。
继承 Room 类
在 JavaScript 中,我们可以使用新类继承一个已定义的类。我们定义GameRoom类,它继承自Room类。GameRoom类将具有它继承的Room逻辑以及专门为游戏流程设计的额外逻辑。继承是通过将类的实例创建到原型中实现的,如下所示:
GameRoom.prototype = new Room();
现在,GameRoom具有从Room继承的原型方法。我们可以在GameRoom中定义更多逻辑,例如startGame方法。我们还可以通过在GameRoom类中定义具有相同名称的新原型方法来覆盖现有逻辑;例如,我们覆盖了handleOnUserMessage方法来发送游戏开始和获胜逻辑。
有时候,我们希望扩展现有的逻辑而不是用新的逻辑替换旧的逻辑。在这种情况下,我们需要执行用同名方法覆盖的逻辑。我们可以使用以下代码来执行原始原型中的方法;我们在addUser方法中使用了这种方法来保持原始逻辑:
// a.k.a. super(user) in traditional OOP language.
Room.prototype.addUser.call(this, user);
控制多人游戏的游戏流程
控制多人游戏的游戏流程比单机游戏要复杂得多。我们可以简单地使用几个变量来控制单机游戏的游戏流程,但我们必须使用消息传递来通知每个玩家特定的更新游戏流程。
首先,我们需要以下高亮的GAME_LOGIC常量来指定dataType。我们使用这些数据来发送和接收与游戏逻辑控制相关的消息:
// Constants
var LINE_SEGMENT = 0;
var CHAT_MESSAGE = 1;
var GAME_LOGIC = 2;
游戏流程中有几个状态。在游戏开始之前,连接的玩家正在等待游戏开始。一旦有多人游戏所需的足够连接,服务器就会向所有玩家发送游戏逻辑消息,通知他们游戏开始。
当游戏结束时,服务器向所有玩家发送游戏结束状态。然后,游戏结束,游戏逻辑暂停,直到任何玩家点击重启按钮。一旦点击重启按钮,客户端就会向服务器发送游戏重启状态,指示服务器准备新游戏。然后,游戏再次开始。
我们在客户端和服务器中都声明了以下四个游戏状态常量,以便他们理解:
// Constant for game logic state
var WAITING_TO_START = 0;
var GAME_START = 1;
var GAME_OVER = 2;
var GAME_RESTART = 3;
以下服务器端的代码包含一个索引,指示现在是哪个玩家的回合:
var playerTurn = 0;
发送到玩家(轮到他的玩家)的数据与发送到其他玩家的数据不同。其他玩家只收到以下数据,带有游戏开始信号:
var gameLogicDataForAllPlayers = {
dataType: GAME_LOGIC,
gameState: GAME_START,
isPlayerTurn: false
};
另一方面,轮到抽卡的玩家会收到以下数据,包含单词信息:
var gameLogicDataForDrawer = {
dataType: GAME_LOGIC,
gameState: GAME_START,
answer: this.currentAnswer,
isPlayerTurn: true
};
房间和游戏房间
到本例结束时,我们创建了两种类型的房间:一个普通房间和一个游戏房间。具体来说,普通房间具有最基本的功能:管理用户和房间内的聊天。游戏房间是在普通房间的基础上构建的,为管理一个画线猜画游戏流程添加了另一个大型逻辑块。游戏流程包括等待游戏开始、开始游戏、确定游戏结束和触发超时。所有这些游戏流程控制都被封装到GameRoom类中。
在未来,我们可以通过添加不同类型的游戏来轻松扩展多玩家游戏。例如,我们可以通过创建一个TicTacToeGameRoom类来创建一个两人井字棋游戏,该类在GameRoom中共享类似的等待和重启游戏逻辑。然而,TicTacToeGameRoom类将处理其他游戏流程,例如传递游戏板数据和处理平局。由于所有游戏逻辑都封装在特定的游戏房间中,不同类型的多人游戏不会相互影响。
改进游戏
我们刚刚创建了一个可玩的多玩家游戏。然而,还有很多可以改进的地方。在接下来的章节中,我们列出了两种可能的改进方法。
改进样式
游戏现在看起来非常简单。我们可以通过添加 CSS 样式和装饰图像来改善其视觉外观。在代码包中,你可以找到一个示例,其中应用了额外的 CSS 样式来使游戏看起来更好。你也可以尝试不同的样式效果。
在每个游戏中存储画出的线条
在游戏中,画图者画线,其他玩家猜测画的是什么。现在,想象有两个玩家正在玩游戏,第三个玩家加入他们。由于没有地方存储画出的线条,第三个玩家看不到画图者画了什么。这意味着第三个玩家必须等到游戏结束才能开始玩。
尝试一下英雄
我们如何让晚加入游戏的玩家继续游戏而不丢失画出的线条?我们如何为新连接的玩家重建绘画?将当前游戏的全部绘画数据存储在服务器上怎么样?
改进答案检查机制
服务器端的答案检查将消息与currentAnswer变量进行比较,以确定玩家是否猜对了。如果字母大小写不匹配,则答案被视为错误。当答案是"apples"而玩家猜的是"apple"时,告诉玩家他们的答案是错误的看起来很奇怪。
尝试一下英雄
我们如何改进答案检查机制?是否可以考虑改进答案检查逻辑,当使用不同的字母大小写或甚至相似的词语时,将答案视为正确?当前的游戏在风格上相当简单。请为游戏添加装饰,使其对玩家更具吸引力。
摘要
在本章中,我们学到了很多关于将浏览器连接到 WebSockets 的知识。几乎实时地,一个浏览器的消息和事件被发送到所有连接的浏览器。
具体来说,我们学习了如何通过利用现有的多人绘图板来绘制,WebSockets 提供实时事件。它显示了其他连接用户的绘图。我们选择了 Node.js 作为服务器端的 WebSocket 服务器。通过使用这个服务器,我们可以轻松构建一个基于事件的服务器来处理来自浏览器的 WebSocket 请求。我们讨论了服务器和客户端之间的关系,比较了 WebSockets 与其他方法,如长轮询。我们构建了一个即时聊天室应用程序。我们还学习了如何实现一个服务器脚本,将所有传入的消息发送到其他连接的浏览器。我们还学习了如何在客户端显示从服务器接收到的消息。接下来,我们构建了一个多用户绘图板,并通过将聊天与绘图板集成,最终构建了一个画图猜谜游戏。
现在你已经学会了如何构建多人游戏,我们准备在下一章中借助物理引擎来构建物理游戏。
第九章. 使用 Box2D 和 Canvas 构建物理赛车游戏
2D 物理引擎是游戏开发中的热门话题。借助物理引擎,我们只需定义环境和简单规则,就能轻松创建一个可玩的游戏。以现有游戏为例,在愤怒的小鸟游戏中,玩家操控小鸟摧毁敌人的城堡。在割绳子游戏中,糖果掉入怪物的嘴里,以进入下一关。
在本章中,我们将学习以下主题:
-
安装 Box2D JavaScript 库
-
在物理世界中创建静态地面体
-
在画布上绘制物理世界
-
在物理世界中创建动态盒子
-
前进世界时间
-
为游戏添加轮子
-
创建物理赛车
-
使用键盘输入给汽车添加力
-
在 Box2D 世界中检查碰撞
-
为我们的赛车游戏添加关卡支持
-
用图形替换 Box2D 轮廓绘制
-
添加最终细节,使游戏更有趣
以下截图显示了本章结束时我们将得到的结果;这是一个玩家将汽车移动到目标点的赛车游戏:

你也可以在 makzan.net/html5-games/car-game/ 上玩游戏,以一窥最终结果。
那么,让我们开始吧。
安装 Box2D JavaScript 库
现在,假设我们想要创建一个赛车游戏。我们给汽车施加力使其向前移动。汽车在斜坡上移动,然后飞入空中。之后,汽车落在目标斜坡上,游戏结束。物理世界的每个部分的碰撞都影响着这个运动。如果我们必须从头开始制作这个游戏,那么我们必须至少计算每个部分的速率和角度。幸运的是,物理库帮助我们处理所有这些物理问题。我们只需要创建物理模型并在画布上展示它。我们使用的引擎是 Box2D。
Box2D 是一个 2D 物理仿真引擎。原始的 Box2D 是由 Erin Catto 用 C 语言编写的。后来被移植到 Flash ActionScript。后来,其 2.1a 版本被移植到 JavaScript。你可以在他们的 Google Code 项目中找到 Box2D 2.1a 的 JavaScript 版本,网址为 code.google.com/p/box2dweb/。
注意
在撰写本书时,Google Code 宣布他们将在 2016 年关闭。为了以防原始仓库不可访问,我已经将该库分叉到一个 URL (github.com/makzan/Box2DWeb-Fork)。
行动时间 – 安装 Box2D 物理库
我们将设置 Box2D 库。我们必须执行以下步骤来准备我们的项目:
-
首先,让我们设置我们的游戏项目。创建一个具有以下文件结构的文件夹。HTML 文件包含一个带有空内容的 HTML 模板,并包含所有脚本和样式文件。您可以在代码包中找到完整文档的源代码。请还将 Box2D 源文件下载到
js文件夹中。![是时候安装 Box2D 物理库了]()
-
在 HTML 主体中,我们必须定义一个画布,如下所示:
<canvas id="game" width="1300" height="600"></canvas> -
我们必须然后为我们将要在游戏中使用的几个 Box2D 类别设置别名;这使得在代码中引用它们更容易:
// Box2D alias var b2Vec2 = Box2D.Common.Math.b2Vec2 , b2BodyDef = Box2D.Dynamics.b2BodyDef , b2Body = Box2D.Dynamics.b2Body , b2FixtureDef = Box2D.Dynamics.b2FixtureDef , b2World = Box2D.Dynamics.b2World , b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape , b2CircleShape = Box2D.Collision.Shapes.b2CircleShape , b2DebugDraw = Box2D.Dynamics.b2DebugDraw , b2RevoluteJointDef = Box2D.Dynamics.Joints.b2RevoluteJointDef; -
现在,我们将创建一个空的世界来测试我们的 Box2D 库安装。打开
box2dcargame.jsJavaScript 文件,并在文件中放入以下代码以创建世界:var carGame = { } var canvas; var ctx; var canvasWidth; var canvasHeight; function initGame() { carGame.world = createWorld(); console.log("The world is created. ",carGame.world); // get the reference of the context canvas = document.getElementById('game'); ctx = canvas.getContext('2d'); canvasWidth = parseInt(canvas.width); canvasHeight = parseInt(canvas.height); }; // Create and return the Box2D world. function createWorld() { // Define the gravity var gravity = new b2Vec2(0, 10); // set to allow sleeping object var allowSleep = true; // finally create the world with // gravity and sleep object parameter. var world = new b2World(gravity, allowSleep); return world; } // After all the definition, we init the game. initGame(); -
在网页浏览器中打开
index.html文件。我们应该看到一个没有任何内容的灰色画布。
我们还没有将物理世界呈现到画布上。这就是为什么我们在页面上只看到一个空白的画布。然而,我们在控制台日志中打印了新创建的世界。以下截图显示了控制台跟踪具有以 m_ 开头的许多属性的世界对象。这些都是世界的物理状态:

发生了什么事?
我们刚刚安装了 Box2D JavaScript 库并创建了一个空的世界来测试安装。
使用 b2World 创建一个新的世界
b2World 类是 Box2D 环境中的核心类。我们所有的物理体,包括地面和汽车,都是在该世界中创建的。以下代码显示了如何创建一个世界:
var world = new b2World(gravity, doSleep);
b2World 类初始化时需要两个参数,如下表所示,包括它们的描述:
| 参数 | 类型 | 讨论 |
|---|---|---|
gravity |
b2Vec2 | 这代表了世界的重力 |
doSleep |
Bool | 这定义了世界是否忽略休眠对象 |
设置世界的重力
我们必须定义世界的重力。重力由 b2Vec2 定义。b2Vec2 类是一个 x 和 y 轴的向量。因此,以下代码使用 10 个单位向下定义了重力:
var gravity = new b2Vec2(0, 10);
将 Box2D 设置为忽略休眠对象
休眠体是一个动态体,它跳过模拟直到它醒来。物理库计算世界中所有体的数学数据和碰撞。当世界中存在太多体需要每帧计算时,性能会变慢。当一个休眠体与另一个对象碰撞时,它会醒来,然后再次进入休眠模式,直到下一次碰撞。
在物理世界中创建一个静态地面体
世界现在是空的。如果我们要在那里放置对象,对象将会落下并最终离开我们的视线。现在,假设我们想在世界上创建一个静态地面体,以便对象可以站在上面。我们可以在 Box2D 中做到这一点。
是时候创建世界中的地面了
按照以下步骤创建静态地面:
-
打开
box2dcargame.jsJavaScript 文件。 -
在文件中定义以下
pxPerMeter变量;这是 Box2D 世界中的单位设置:var pxPerMeter = 30; // 30 pixels = 1 meter -
将以下函数添加到 JavaScript 文件的末尾;这创建了一个固定体作为游乐场:
function createGround() { var bodyDef = new b2BodyDef; var fixDef = new b2FixtureDef; bodyDef.type = b2Body.b2_staticBody; bodyDef.position.x = 250/pxPerMeter; bodyDef.position.y = 370 /pxPerMeter; fixDef.shape = new b2PolygonShape(); fixDef.shape.SetAsBox(250/pxPerMeter, 25/pxPerMeter); fixDef.restitution = 0.4; // create the body from the definition. var body = carGame.world.CreateBody(bodyDef); body.CreateFixture(fixDef); return body; } -
在创建世界后,在
initGame函数中调用createGround函数,如下所示:createGround(); -
由于我们仍在定义逻辑并且尚未以视觉方式展示物理世界,如果我们打开浏览器,我们将看不到任何东西。然而,养成尝试并检查控制台窗口以查找错误消息的习惯是值得的。
刚才发生了什么?
我们已经使用形状和体定义创建了一个地面体。这是一个常见的流程,我们将经常使用它来在世界上创建不同类型的物理体。所以,让我们深入了解我们是如何做到这一点的。
像素每米
在 Box2D 中,大小和位置单位以米计算。我们在屏幕上使用像素。因此,我们定义了一个变量来转换米和屏幕像素之间的单位。我们将值设置为 30,这意味着 30 像素等于 1 米。你可以探索你物理世界中的不同值。
我们不应该使用 1 像素等于 1 米,否则我们的对象在 Box2D 规模下会变得非常大。想象一下,我们有一辆宽度为 100 px 的汽车,它将变成 100 米长,这完全不现实。通过定义 30 px/meter 或任何合理的值,屏幕上宽度为 100 px 的对象在模拟中大约是 3.33 米长,这是 Box2D 可以很好地处理的。更多详情,请参阅 Box2D 手册第 1.7 节,www.box2d.org/manual.html。
使用夹具创建形状
夹具包含物理属性及其形状。物理属性定义密度、摩擦和恢复,其中恢复基本上是物体的弹性。形状定义了几何数据。形状可以是圆形、矩形或多边形。我们之前示例中使用的以下代码定义了一个箱形形状定义。SetAsBox函数接受两个参数:半宽和半高。这是一个半值,所以形状的最终面积是值的四倍:
fixDef.shape = new b2PolygonShape();
fixDef.shape.SetAsBox(250/pxPerMeter, 25/pxPerMeter);
fixDef.restitution = 0.4;
创建一个体
在定义了夹具之后,我们就可以使用给定的形状定义创建一个体定义。然后,我们设置体的初始位置,最后请求世界实例根据我们的体定义创建一个体。以下代码展示了如何使用给定的形状定义在世界中创建一个体:
bodyDef.type = b2Body.b2_staticBody;
bodyDef.position.x = 250/pxPerMeter;
bodyDef.position.y = 370 /pxPerMeter;
// create the body from the definition.
var body = carGame.world.CreateBody(bodyDef);
body.CreateFixture(fixDef);
一个物体可以是静态物体或动态物体。静态物体是不可移动的,并且不会与其他静态物体发生碰撞。因此,这些物体可以用作地面或墙壁,成为关卡环境。另一方面,动态物体会根据与其他物体(静态或动态)的碰撞以及重力而移动。我们将在后面创建一个动态箱子体。
使用恢复系数属性设置弹跳效果
恢复系数的值在 0 到 1 之间。在我们的例子中,箱子正落在地面上。当地面和箱子上的恢复系数都是 0 时,箱子根本不会弹跳。当箱子或地面有一个恢复系数为 1 时,碰撞是完全弹性的。
提示
当两个物体相撞时,该碰撞的恢复系数是两个物体各自恢复系数的最大值。因此,如果一个恢复系数为 0.4 的箱子掉在恢复系数为 0.6 的地面上,这个碰撞将使用 0.6 来计算弹跳速度。
在画布中绘制物理世界
我们已经创建了地面,但它只存在于数学模型中。我们在画布上没有看到任何东西,因为我们还没有在上面绘制任何东西。为了展示物理的样子,我们必须根据物理世界绘制一些东西。
是时候将物理世界绘制到画布上了
执行以下步骤以绘制有用的调试视图:
-
首先,打开
box2dcargame.jsJavaScript 文件:var shouldDrawDebug = false; -
添加一个绘制调试线的函数:
function showDebugDraw() { shouldDrawDebug = true; //setup debug draw var debugDraw = new b2DebugDraw(); debugDraw.SetSprite(document.getElementById('game').getContext('2d')); debugDraw.SetDrawScale(pxPerMeter); debugDraw.SetFillAlpha(0.3); debugDraw.SetLineThickness(1.0); debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit); carGame.world.SetDebugDraw(debugDraw); carGame.world.DrawDebugData(); } -
在
initGame方法的末尾添加showDebugDraw函数调用:showDebugDraw(); -
现在,在浏览器中重新打开游戏,我们应该在画布中看到地体的轮廓,如下面的截图所示:
![是时候将物理世界绘制到画布上了]()
发生了什么?
我们刚刚定义了一个方法,该方法要求 Box2D 引擎在画布中绘制物理体。在我们成功添加自己的图形之前,这对于调试很有用。我们可以通过SetFlags方法设置要显示的内容。
标志是位变量。这意味着标志中的每个位都控制一种绘制类型。我们通过使用位运算符或(|)组合标志。例如,我们使用以下代码显示形状和关节。
debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
除了形状和关节之外,还有不同类型的调试绘制:
| 位标志 | 讨论 |
|---|---|
e_aabbBit |
这会绘制所有的边界框 |
e_centerOfMassBit |
这会绘制质心 |
e_controllerBit |
这会绘制所有的动态控制器 |
e_jointBit |
这会绘制所有的关节连接 |
e_pairBit |
这会绘制广泛的碰撞对 |
e_shapeBit |
这会绘制所有的形状 |
在物理世界中创建动态箱子
想象一下,我们将一个盒子放入世界中。盒子从空中落下,最终撞到地面。盒子弹起一点,最终再次落在地面上。这与我们在上一节中创建的不同。在上一节中,我们创建了一个静态地面,它是不可移动的,不能受到重力的影响。现在,我们将创建一个动态的盒子。
动作时间 – 将动态盒子放入世界
执行以下步骤以创建我们的第一个动态物体:
-
打开我们的 JavaScript 文件,并将以下盒子创建代码添加到页面加载事件处理程序中。将代码放置在
createGround函数之后:// temporary function function createBox() { var bodyDef = new b2BodyDef; var fixDef = new b2FixtureDef; bodyDef.type = b2Body.b2_dynamicBody; bodyDef.position.x = 50/pxPerMeter; bodyDef.position.y = 210/pxPerMeter; fixDef.shape = new b2PolygonShape(); fixDef.shape.SetAsBox(20/pxPerMeter, 20/pxPerMeter); var body = carGame.world.CreateBody(bodyDef); body.CreateFixture(fixDef); return body; } -
我们需要调用我们新创建的
createBox函数。在initGame中调用createGround函数之后放置以下代码。 -
现在,我们将在浏览器中测试物理世界。你应该会看到在给定的初始位置创建了一个盒子。然而,盒子并没有掉下来;这是因为我们还需要做一些事情来让它掉下来:
![动作时间 – 将动态盒子放入世界]()
刚才发生了什么?
我们刚刚在世界上创建了一个动态物体。与不可移动的地面物体相比,这个盒子受到重力的影响,并且在碰撞期间速度会发生变化。当一个物体包含任何质量或密度的形状时,它就是一个动态物体。否则,它是静态的。因此,我们为盒子定义了一个密度。Box2D 会使其成为动态的,并自动根据物体的密度和大小计算质量。
前进世界时间
盒子是动态的,但它没有掉下来。我们在这里做错了什么吗?答案是:没有。我们已经正确设置了盒子,但我们忘记了在物理世界中前进时间。
在 Box2D 物理世界中,所有计算都是在系统迭代中完成的。世界根据当前步骤计算所有物体的物理变换。当我们移动step到下一个级别时,世界会根据新的状态再次计算。
动作时间 – 设置世界步骤循环
我们将通过执行以下步骤来使世界时间前进:
-
为了前进世界步骤,我们必须定期调用世界实例中的
step函数。我们使用了setTimeout来持续调用step函数。将以下函数放入我们的 JavaScript 逻辑文件中:function updateWorld() { // Move the physics world 1 step forward. carGame.world.Step(1/60, 10, 10); // display the build-in debug drawing. if (shouldDrawDebug) { carGame.world.DrawDebugData(); } } -
接下来,我们将在
initGame方法中设置一个时间间隔:setInterval(updateWorld, 1/60); -
我们将在浏览器中再次模拟世界。盒子在初始化位置创建,并正确地落在地面上。以下截图显示了盒子掉落在地面上的序列:
![动作时间 – 设置世界步骤循环]()
刚才发生了什么?
我们已经推进了世界的时间。现在,物理库以每秒 60 次的频率模拟世界。在游戏循环中,我们调用 Step 函数到 Box2D 世界。Step 函数模拟物理世界向前一步。在步骤中,物理引擎计算世界中发生的一切,包括力和重力。
向游戏中添加轮子
现在,我们在游戏中有一个盒子。想象一下,我们现在创建两个圆形体作为轮子。然后,我们将拥有汽车的基本组件——车身和轮子。
行动时间 – 在世界中放置两个圆形
我们将通过以下步骤添加两个圆形到世界中:
-
打开
html5games.box2dcargame.jsJavaScript 文件以添加轮子体。 -
在盒子创建代码之后添加以下代码。这调用我们将要编写的
createWheel函数,以创建一个圆形形状的体:// create two wheels in the world createWheel(25, 230); createWheel(75, 230); -
现在让我们来处理
createWheel函数。我们设计这个函数,在给定的世界中,在给定的 x 和 y 坐标处创建一个圆形体。为此,将以下函数放入我们的 JavaScript 逻辑文件中:function createWheel(x, y) { var bodyDef = new b2BodyDef; var fixDef = new b2FixtureDef; bodyDef.type = b2Body.b2_dynamicBody; bodyDef.position.x = x/pxPerMeter; bodyDef.position.y = y/pxPerMeter; fixDef.shape = new b2CircleShape(); fixDef.shape.SetRadius(10/pxPerMeter); fixDef.density = 1.0; fixDef.restitution = 0.1; fixDef.friction = 4.3; var body = carGame.world.CreateBody(bodyDef); body.CreateFixture(fixDef); return body; } -
我们现在将在网页浏览器中重新加载物理世界。这次,我们应该看到以下截图所示的结果,其中有一个盒子和两个轮子从空中落下:
![行动时间 – 在世界中放置两个圆形]()
发生了什么?
当模拟物理世界时,盒子和轮子都会掉落并相互碰撞以及与地面碰撞。
创建圆形体与创建盒子体类似。唯一的区别是我们使用 CircleDef 类而不是盒子形状定义。在圆形定义中,我们使用 radius 属性而不是 extents 属性来定义圆形大小。
创建物理汽车
我们已经准备好了汽车盒子体和两个轮子体。我们离制作汽车只差一步。想象一下,现在我们有一根胶棒来将轮子粘到汽车体上。然后,汽车和轮子将不再分离,我们将拥有汽车。我们可以使用 关节 来实现这一点。在本节中,我们将使用 joint 将轮子和汽车体粘在一起。
行动时间 – 使用旋转关节连接盒子和两个圆形
执行以下步骤以创建带有盒子和轮子的汽车:
-
我们目前只在工作逻辑部分。在文本编辑器中打开我们的 JavaScript 逻辑文件。
-
创建一个名为
createCarAt的函数,该函数接受坐标作为参数。然后,将体和轮子创建代码移动到这个函数中。之后,添加以下高亮的关节创建代码。最后,返回汽车体:function createCarAt(x, y) { var bodyDef = new b2BodyDef; var fixDef = new b2FixtureDef; // car body bodyDef.type = b2Body.b2_dynamicBody; bodyDef.position.x = 50/pxPerMeter; bodyDef.position.y = 210/pxPerMeter; fixDef.shape = new b2PolygonShape(); fixDef.density = 1.0; fixDef.friction = 1.5; fixDef.restitution = .4; fixDef.shape.SetAsBox(40/pxPerMeter, 20/pxPerMeter); carBody = carGame.world.CreateBody(bodyDef); carBody.CreateFixture(fixDef); // creating the wheels var wheelBody1 = createWheel(x-25, y+20); var wheelBody2 = createWheel(x+25, y+20); // create a joint to connect left wheel with the car body var jointDef = new b2RevoluteJointDef(); jointDef.Initialize(carBody, wheelBody1, new b2Vec2( (x-25)/pxPerMeter , (y+20)/pxPerMeter )); carGame.world.CreateJoint(jointDef); // create a joint to connect right wheel with the car body var jointDef = new b2RevoluteJointDef(); jointDef.Initialize(carBody, wheelBody2, new b2Vec2( (x+25)/pxPerMeter , (y+20)/pxPerMeter )); carGame.world.CreateJoint(jointDef); return carBody; } -
在
initGame函数中,我们创建了两个轮子。从initGame函数中移除调用createWheel函数的这些代码行。 -
然后,我们只需要创建一个具有初始位置的汽车。在调用
createGround函数后,将以下代码添加到initGame函数中:carGame.car = createCarAt(50, 210); -
是时候保存文件并在浏览器中运行物理世界了。在这个时候,车轮和车身还不是分离的部件。它们粘合在一起形成一个汽车,并正确地落在地面上,如下面的截图所示:
![操作时间 – 使用旋转关节连接方框和两个圆圈]()
发生了什么?
关节有助于在两个物体(或物体和世界之间)之间添加约束。有许多种类的关节,而我们在这个例子中使用的是旋转关节。
使用旋转关节在两个物体之间创建锚点
旋转关节通过一个共同的锚点将两个物体粘合在一起。然后,两个物体粘合在一起,并且只能根据共同的锚点旋转。以下截图的左侧显示了两个物体通过一个锚点连接。在我们的代码示例中,我们将锚点设置为正好是车轮的中心点。以下截图的右侧显示了如何设置关节。车轮旋转,因为旋转原点在中心。这种设置使汽车和车轮看起来更真实:

有其他类型的关节,以不同的方式有用。关节对于创建游戏环境很有用,由于有几种类型的关节,每种类型的关节都值得尝试,你应该考虑如何使用它们。以下链接包含 Box2D 手册,解释了每种类型的关节以及我们如何在不同的环境设置中使用它们:www.box2d.org/manual.html#_Toc258082974。
使用键盘输入向汽车添加力
我们现在已经准备好了汽车。让我们用键盘来移动它。
操作时间 – 向汽车添加力
执行以下步骤以获取键盘输入:
-
在文本编辑器中打开
box2dcargame.jsJavaScript 文件。 -
在页面加载事件处理程序中,我们在代码的开头添加了以下
keydown事件处理程序。它监听右箭头键和左箭头键,以在不同方向上施加力:$(document).keydown(function(e){ switch(e.keyCode) { case 39: // right arrow key to apply force towards right var force = new b2Vec2(100, 0); carGame.car.ApplyForce(force, carGame.car.GetWorldCenter()); return false; break; case 37: // left arrow key to apply force towards left var force = new b2Vec2(-100, 0); carGame.car.ApplyForce(force, carGame.car.GetWorldCenter()); return false; break; } }); -
我们已经向物体添加了力。我们需要在每一步中清除力,否则力会累积:
function updateWorld() { // existing code goes here. // Clear previous applied force. carGame.world.ClearForces(); } -
保存文件并在浏览器中运行我们的游戏。当你按下箭头键时,汽车开始移动。如果你持续按住键,世界将不断向汽车添加力,使其加速:
![操作时间 – 向汽车添加力]()
发生了什么?
我们刚刚与我们的车身创建了交互。我们可以通过按箭头键来左右移动汽车。现在游戏看起来越来越有趣了。
对物体施加力
我们可以通过在物体中调用ApplyForce函数来对任何物体施加力。以下代码显示了该函数的用法:
body.ApplyForce(force, point);
这个函数接受两个参数,如下表所示:
| 参数 | 类型 | 讨论 |
|---|---|---|
force |
b2Vec2 |
这是施加到物体上的力向量 |
point |
b2Vec2 |
这是施加力的点 |
清除力
在我们对物体施加力之后,该力会持续作用于该物体,直到我们清除它。在大多数情况下,我们在每一步之后清除力。
理解 ApplyForce 和 ApplyImpulse 之间的区别
除了 ApplyForce 函数之外,我们还可以通过使用 ApplyImpulse 函数移动任何物体。这两个函数都会移动物体,但它们使用不同的方法。如果我们想改变物体的实例速度,那么我们就在物体上使用一次 ApplyImpulse 来改变其速度以达到我们的目标值。另一方面,我们需要持续对物体施加力以增加速度。
例如,如果我们想增加汽车的速度,类似于踩油门,我们需要对汽车施加力。如果我们正在创建一个需要踢球开始的游戏,我们可能使用 ApplyImpulse 函数向球的物体添加实例冲量。
尝试一下英雄
你能想到一个不同的情况,我们将在其中需要向物体施加力或冲量吗?
向我们的游戏环境添加斜坡
现在,我们可以移动汽车。然而,环境还不够有趣,无法进行游戏。想象一下,现在有一些斜坡供汽车跳跃,并且有两个平台之间的一个间隙,玩家必须驾驶汽车飞越。不同的斜坡设置将使游戏更有趣。
行动时间 - 使用斜坡创建世界
执行以下步骤在物理世界中创建斜坡:
-
我们打开游戏逻辑 JavaScript 文件。
-
在
createGround函数中,我们将函数更新为接受四个参数。更改的代码如下所示:function createGround(x, y, width, height, rotation) { var bodyDef = new b2BodyDef; var fixDef = new b2FixtureDef; bodyDef.type = b2Body.b2_staticBody; bodyDef.position.x = x /pxPerMeter; bodyDef.position.y = y /pxPerMeter; bodyDef.angle = rotation * Math.PI / 180; fixDef.shape = new b2PolygonShape(); fixDef.shape.SetAsBox(width/pxPerMeter, height/pxPerMeter); fixDef.restitution = 0.4; fixDef.friction = 3.5; // create the body from the definition. var body = carGame.world.CreateBody(bodyDef); body.CreateFixture(fixDef); return body; } -
现在,我们有一个创建地面物体的函数。我们将现在用以下代码替换页面加载处理函数中的地面创建代码:
// create the ground createGround(250, 270, 250, 25, 0); // create a ramp createGround(500, 250, 65, 15, -10); createGround(600, 225, 80, 15, -20); createGround(1100, 250, 100, 15, 0); -
保存文件,并在浏览器中预览游戏。我们现在应该看到一个斜坡和一个目标平台,如下面的截图所示。尝试通过让汽车跳过斜坡到达目的地而不掉下来来控制汽车。如果失败,请刷新页面重新开始游戏:
![行动时间 - 使用斜坡创建世界]()
发生了什么?
我们刚刚将地面盒子的创建代码封装在一个函数中,这样我们就可以轻松地创建地面物体的组合。这些地面物体组合了游戏关卡环境。
此外,这是我们第一次旋转物体。我们通过使用 rotation 属性来设置物体的旋转,该属性接受弧度值。通过设置盒子的旋转,我们可以在游戏中设置具有不同斜率的斜坡。
尝试一下英雄
现在我们已经设置了一个斜坡,我们可以在环境中驾驶汽车。那么,使用不同类型的关节来设置游乐场怎么样?例如,使用滑轮关节作为提升装置怎么样?另一方面,包含一个带有中心关节的动态板怎么样?
在 Box2D 世界中检查碰撞
Box2D 物理库自动计算所有碰撞。想象一下,我们现在设置一个地面身体作为目标。当玩家成功将汽车移动到撞击目标时,玩家获胜。由于 Box2D 已经计算了所有碰撞,我们只需要获取检测到的碰撞列表并确定我们的汽车是否撞击了目标地面。
行动时间 – 检查汽车和目标身体的碰撞
执行以下步骤来处理碰撞:
-
再次,我们从游戏逻辑开始。在文本编辑器中打开
box2dcargame.jsJavaScript 文件。 -
我们在创建地面的代码中设置了一个目标地面,并将其分配给
carGame全局对象实例中的gamewinWall引用,如下所示:carGame.gamewinWall = createGround(1200, 215, 15, 25, 0); -
接下来,我们转到
step函数。在每一步中,我们从世界中获取完整的接触列表并检查是否有两个碰撞对象是汽车和目标地面:function checkCollision() { // loop all contact list // to check if the car hits the winning wall. for (var cn = carGame.world.GetContactList(); cn != null; cn = cn.GetNext()) { var body1 = cn.GetFixtureA().GetBody(); var body2 = cn.GetFixtureB().GetBody(); if ((body1 === carGame.car && body2 === carGame.gamewinWall) || (body2 === carGame.car && body1 === carGame.gamewinWall)) { if (cn.IsTouching()) { console.log("Level Passed!"); } } } } -
当我们调用我们的游戏循环函数
updateWorld时,我们调用我们新创建的碰撞检查函数。checkCollision(); -
我们现在将保存代码并在浏览器中再次打开游戏。这次,我们必须打开控制台窗口来跟踪当汽车撞到墙时是否得到通过关卡的输出。尝试完成游戏,一旦汽车撞到目标,我们应该在控制台中看到输出:
![行动时间 – 检查汽车和目标身体的碰撞]()
发生了什么?
我们通过检查碰撞接触创建了游戏胜利逻辑。当汽车成功到达目标地面对象时,玩家获胜。
获取碰撞接触列表
在每一步中,Box2D 计算所有碰撞并将它们放入world实例的接触 列表中。我们可以通过使用carGame.world.GetContactList()函数来获取接触列表。返回的接触列表是一个链表。我们可以通过以下for循环遍历整个链表:
for (var cn = carGame.world.GetContactList(); cn != null; cn = cn.GetNext()) {
// We have fixture 1 and fixture 2 of each contact node.
var body1 = cn.GetFixtureA().GetBody();
var body2 = cn.GetFixtureB().GetBody();
}
当我们获取到碰撞的形状时,我们检查该形状的身体是汽车还是目标身体。由于汽车形状可能在固定装置 1 或固定装置 2 中,同样也适用于gamewinWall,我们需要检查这两种组合。额外的isTouching函数提供了更精确的固定装置之间的碰撞检查。
if ((body1 === carGame.car && body2 === carGame.gamewinWall) ||
(body2 === carGame.car && body1 === carGame.gamewinWall))
{
if (cn.IsTouching()) {
console.log("Level Passed!");
}
}
来吧,英雄
我们在 第七章 中创建了一个游戏结束对话框,保存游戏进度。那么,我们是否可以使用那种技术在这里创建一个对话框,当玩家击中胜利墙壁时显示他们通过了关卡?这在我们添加不同关卡设置到游戏中作为关卡过渡时也将非常有用。
重新启动游戏
你可能已经在上一个例子中尝试刷新页面几次,以使汽车成功跳到目的地。现在想象一下,如果我们能按一个键来重新初始化世界。然后,我们可以通过试错法直到成功。
行动时间 – 按下 R 键重新启动游戏
我们将把 R 键分配为我们的游戏的重启键。现在,让我们执行以下步骤:
-
再次,我们只需要更改 JavaScript 文件。在文本编辑器中打开
box2dcargame.jsJavaScript 文件。 -
我们需要一个函数来移除所有身体:
function removeAllBodies() { // loop all body list to destroy them for (var body = carGame.world.GetBodyList(); body != null; body = body.GetNext()) { carGame.world.DestroyBody(body); } } -
我们将创建世界、坡道和汽车代码移动到一个名为
restartGame的函数中。它们最初位于页面加载处理函数中:function restartGame() { removeAllBodies(); // create the ground createGround(250, 270, 250, 25, 0); // create a ramp createGround(500, 250, 65, 15, -10); createGround(600, 225, 80, 15, -20); createGround(1100, 250, 100, 15, 0); // create a destination ground carGame.gamewinWall = createGround(1200, 215, 15, 25, 0); // create a car carGame.car = createCarAt(50, 210); } -
然后,在
initGame函数中,我们调用restartGame函数以如下方式初始化游戏:restartGame(); -
最后,我们将以下突出显示的代码添加到
keydown处理程序中,以便在按下 R 键时重新启动游戏:$(document).keydown(function(e){ switch(e.keyCode) { case 39: // right arrow key to apply force towards right var force = new b2Vec2(300, 0); carGame.car.ApplyForce(force, carGame.car.GetWorldCenter()); break; case 37: // left arrow key to apply force towards left var force = new b2Vec2(-300, 0); carGame.car.ApplyForce(force, carGame.car.GetWorldCenter()); break; case 82: // r key to restart the game restartGame(); break; } }); -
当玩家通过关卡时,我们是否应该重新启动游戏?为此,在检查汽车和胜利旗帜之间碰撞的逻辑中添加以下突出显示的代码:
console.log("Level Passed!"); restartGame(); -
是时候在浏览器中测试游戏了。尝试玩游戏并按下 R 键重新启动游戏。
发生了什么事?
我们重构了代码以创建一个 restartGame 函数。每次调用此函数时,世界都会被销毁并重新初始化。我们可以通过创建世界变量的新世界实例来销毁现有世界并创建一个新空的世界,如下所示:
carGame.world = createWorld();
尝试一下英雄
现在唯一重新启动游戏的方法是按重启键。我们是否可以在世界的底部创建一个检查任何掉落汽车的地面?当汽车掉落并击中底部地面时,我们知道玩家失败了,然后他们可以重新启动游戏。
为我们的汽车游戏添加关卡支持
想象一下,在完成每场比赛后,我们可以升级到下一个环境设置。我们将需要每个级别的几个环境设置。
行动时间 – 使用关卡数据加载游戏
我们将重构我们的代码以支持从关卡数据结构中加载静态地面身体。让我们通过执行以下步骤来完成这项工作:
-
在文本编辑器中打开
box2dcargame.jsJavaScript 文件。 -
我们将需要每个级别的每个地面设置。在 JavaScript 文件顶部放置以下代码。这是一个级别数组。每个级别是另一个包含位置、尺寸和旋转的静态地面身体的数组:
var carGame = { currentLevel: 0 } carGame.levels = new Array(); carGame.levels[0] = [{"type":"car","x":50,"y":210,"fuel":20}, {"type":"box","x":250, "y":270, "width":250, "height":25, "rotation":0}, {"type":"box","x":500,"y":250,"width":65,"height":15, "rotation":-10}, {"type":"box","x":600,"y":225,"width":80,"height":15, "rotation":-20}, {"type":"box","x":950,"y":225,"width":80,"height":15, "rotation":20}, {"type":"box","x":1100,"y":250,"width":100,"height":15, "rotation":0}, {"type":"win","x":1200,"y":215,"width":15,"height":25, "rotation":0}]; carGame.levels[1] = [{"type":"car","x":50,"y":210,"fuel":20}, {"type":"box","x":100, "y":270, "width":190, "height":15, "rotation":20}, {"type":"box","x":380, "y":320, "width":100, "height":15, "rotation":-10}, {"type":"box","x":666,"y":285,"width":80,"height":15, "rotation":-32}, {"type":"box","x":950,"y":295,"width":80,"height":15, "rotation":20}, {"type":"box","x":1100,"y":310,"width":100,"height":15, "rotation":0}, {"type":"win","x":1200,"y":275,"width":15,"height":25, "rotation":0}]; carGame.levels[2] = [{"type":"car","x":50,"y":210,"fuel":20}, {"type":"box","x":100, "y":270, "width":190, "height":15, "rotation":20}, {"type":"box","x":380, "y":320, "width":100, "height":15, "rotation":-10}, {"type":"box","x":686,"y":285,"width":80,"height":15, "rotation":-32}, {"type":"box","x":250,"y":495,"width":80,"height":15, "rotation":40}, {"type":"box","x":500,"y":540,"width":200,"height":15, "rotation":0}, {"type":"win","x":220,"y":425,"width":15,"height":25, "rotation":23}]; -
将
restartGame函数替换为以下代码。这更改了函数以接受level参数。然后根据级别数据创建地面或汽车:function restartGame(level) { carGame.currentLevel = level; // destroy existing bodies. removeAllBodies();// create the world // create a ground in our newly created world // load the ground info from level data for(var i=0;i<carGame.levels[level].length;i++) { var obj = carGame.levels[level][i]; // create car if (obj.type === "car") { carGame.car = createCarAt(obj.x, obj.y); continue; } var groundBody = createGround(obj.x, obj.y, obj.width, obj.height, obj.rotation); if (obj.type === "win") { carGame.gamewinWall = groundBody; } } } -
在页面加载处理函数中,通过提供
currentLevel来调用restartGame函数,如下所示:restartGame(carGame.currentLevel); -
我们还需要在重启键处理程序中提供
currentLevel值:case 82: // r key to restart the game restartGame(carGame.currentLevel); break; -
最后,更改游戏胜利逻辑中以下突出显示的代码。当汽车撞击目的地时,我们在游戏中提升一个级别:
if ((body1 === carGame.car && body2 === carGame.gamewinWall) || (body2 === carGame.car && body1 === carGame.gamewinWall)) { if (cn.IsTouching()) { console.log("Level Passed!"); restartGame(carGame.currentLevel+1); } } -
我们现在将在网络浏览器中运行游戏。完成关卡后,游戏应该从下一级重新启动:
![行动时间 – 使用关卡数据加载游戏]()
刚才发生了什么?
我们刚刚创建了一个用于存储级别的数据结构。然后,我们使用给定的级别编号创建游戏,并使用级别数据构建世界。
每个级别数据都是一个对象数组。每个对象包含世界中每个地面物体的属性。这包括基本属性,如位置、大小和旋转。还有一个名为type的属性。它定义了该物体是普通框体、汽车数据还是目的地胜利地面:
carGame.levels[0] = [{"type":"car","x":50,"y":210,"fuel":20},
{"type":"box","x":250, "y":270, "width":250, "height":25, "rotation":0},
{"type":"box","x":500,"y":250,"width":65,"height":15,"rotation":-10},
{"type":"box","x":600,"y":225,"width":80,"height":15,"rotation":-20},
{"type":"box","x":950,"y":225,"width":80,"height":15,"rotation":20},
{"type":"box","x":1100,"y":250,"width":100,"height":15,"rotation":0},
{"type":"win","x":1200,"y":215,"width":15,"height":25,"rotation":0}];
在创建世界时,我们使用以下代码遍历级别数组中的所有对象。然后根据类型创建汽车和地面物体,并引用游戏胜利的地面:
for(var i=0;i<carGame.levels[level].length;i++) {
var obj = carGame.levels[level][i];
// create car
if (obj.type === "car") {
carGame.car = createCarAt(obj.x,obj.y);
continue;
}
var groundBody = createGround(obj.x, obj.y, obj.width, obj.height, obj.rotation);
if (obj.type === "win") {
carGame.gamewinWall = groundBody;
}
}
尝试一下英雄
现在,我们为游戏设置了几个级别。关于复制级别数据以创建更多有趣的关卡如何?创建你自己的级别并与它们一起玩耍。这就像一个孩子搭建积木并与之玩耍一样。
用图形替换 Box2D 轮廓绘制
我们创建了一个至少可以玩几个级别的游戏。然而,它们只是一些轮廓框。我们甚至无法在游戏中区分目的地物体和其他地面物体。想象一下,目的地是一个赛车旗帜,有一个代表它的汽车图形。这将使游戏的目的更加清晰。
行动时间 – 向游戏中添加旗帜图形和汽车图形
执行以下步骤在物理对象上绘制两个图形:
-
我们将首先下载本例所需的图形。要下载图形,请访问
mak.la/book-assets。 -
将本章的图像文件放入
images文件夹。 -
现在,是时候编辑
index.html文件了。将以下 HTML 标记添加到body部分:<div id="asset"> <img id="flag" src='images/flag.png'> <img id="bus" src="img/bus.png"> <img id="wheel" src="img/wheel.png"> </div> -
我们想隐藏包含我们的
img标签的资产 DIV。打开cargame.css文件,并添加以下 CSS 规则以使资产 DIV 不在我们的视线中:#asset { position: absolute; top: -9999px; } -
我们现在将进入逻辑部分。打开
box2dcargame.jsJavaScript 文件。 -
在
restartGame函数中,添加突出显示的代码以将flag图像的引用分配给胜利的目的地旗帜:if (obj.type === "win") { carGame.gamewinWall = groundBody; groundBody.SetUserData( document.getElementById('flag') ); } -
接下来,将
bus图像标签的引用分配给汽车形状的用户数据。在创建汽车框定义时,添加以下突出显示的代码:function createCarAt(x, y) { var bodyDef = new b2BodyDef; var fixDef = new b2FixtureDef; // car body bodyDef.type = b2Body.b2_dynamicBody; bodyDef.userData = document.getElementById('bus'); // existing code goes here. }注意
我们过去使用 jQuery 的
$(selector)方法来获取元素的引用。jQuery 选择器返回一个包含额外 jQuery 数据的元素对象数组。如果我们想获取原始文档元素引用,那么我们可以使用document.getElementById方法或$(selector).get(0)。因为$(selector)返回一个数组,所以get(0)给出列表中的第一个原始文档元素。 -
然后,我们需要处理轮子。我们将
wheel图像标签分配给轮子身体的userData属性。在createWheel函数中添加以下突出显示的代码function createWheel(x, y) { var bodyDef = new b2BodyDef; var fixDef = new b2FixtureDef; bodyDef.type = b2Body.b2_dynamicBody; bodyDef.userData = document.getElementById('wheel'); // existing code goes here } -
我们必须在画布上绘制图像。在
box2dcargame.js文件中创建一个新的drawWorld函数,代码如下。// drawing functions function drawWorld(world, context) { for (var body = carGame.world.GetBodyList(); body != null; body = body.GetNext()) { if (body.GetUserData() !== null && body.GetUserData() !== undefined) { // the user data contains the reference to the image var img = body.GetUserData(); // the x and y of the image. We have to subtract the half width/height var x = body.GetPosition().x; var y = body.GetPosition().y; var topleftX = - $(img).width()/2; var topleftY = - $(img).height()/2; context.save(); context.translate(x * pxPerMeter,y * pxPerMeter); context.rotate(body.GetAngle()); context.drawImage(img, topleftX, topleftY); context.restore(); } } } -
最后,在
updateWorld函数中调用drawWorld函数:function updateWorld() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); // existing code goes here. // render graphics drawWorld(carGame.world, ctx); } -
保存所有文件,并在网页浏览器中运行游戏。我们应该看到一个黄色的公交车图形,两个轮子和一个旗帜作为目的地。现在玩游戏,当公交车碰到旗帜时,游戏应该进入下一关:
![添加旗帜图形和汽车图形到游戏的时间 - action]()
刚才发生了什么?
我们现在以最少的图形展示我们的游戏。至少,玩家可以很容易地知道他们控制的是什么,以及他们应该去哪里。
Box2D 库使用画布来渲染物理世界。因此,我们可以应用我们学到的所有关于画布的技术。在第五章中,构建 Canvas 游戏大师班,我们学习了使用drawImage函数在画布中显示图像。我们使用这项技术来在物理世界的画布中绘制旗帜图形。
在形状和身体中使用 userData
我们如何知道哪个物理身体需要显示为旗帜图像?每个 Box2D 形状和身体都有一个名为userData的属性。这个属性用于存储与该形状或身体相关的任何自定义数据。例如,我们可能存储图形文件的文件名,或者直接存储对图像标签的引用。
我们有一个图像标签列表,引用游戏中需要的图形资产。然而,我们不想显示图像标签——它们只是为了加载和引用的目的。我们通过以下 CSS 样式将资产图像标签的位置设置在 HTML 边界之外来隐藏它们。我们不使用display:none,因为我们无法获取完全不显示的元素的宽度和高度。我们需要宽度和高度来正确地在物理世界中定位图形:
#asset {
position: absolute;
top: -9999px;
}
根据物理身体的当前状态在每一帧中绘制图形
Box2D 的绘制只是为了在替换我们的图形之前进行开发使用。
以下代码检查形状是否有分配给它的用户数据。在我们的例子中,用户数据用于引用图形资产的image标签。我们获取image标签,并将其传递给 Canvas 上下文的drawImage函数以绘制。
Box2D 中的所有矩形和圆形形状都以中心点为原点。然而,画布中的图像绘制需要左上角点。因此,我们既有x和y坐标,也有左上角x和y点的偏移量,这是图像负半宽度和高度:
if (body.GetUserData() !== null && body.GetUserData() !== undefined) {
// the user data contains the reference to the image
var img = body.GetUserData();
// the x and y of the image.
// We have to subtract the half width/height
var x = body.GetPosition().x;
var y = body.GetPosition().y;
var topleftX = - $(img).width()/2;
var topleftY = - $(img).height()/2;
context.save();
context.translate(x,y);
context.rotate(s.GetBody().GetRotation());
context.drawImage(img, topleftX, topleftY);
context.restore();
}
在画布中旋转和移动图像
我们使用了drawImage函数直接用坐标绘制图像。然而,这里的情况不同。我们需要旋转绘制的图像。这是通过在绘制之前旋转上下文并在之后恢复旋转来完成的。我们可以通过保存上下文状态,平移它,旋转它,然后调用restore函数来实现。以下代码展示了如何在一个给定的位置和旋转下绘制图像。topleftX和topleftY是从图像中心原点到左上角点的偏移距离:
context.save();
context.translate(x,y);
context.rotate(s.GetBody().GetRotation());
context.drawImage(img, topleftX, topleftY);
context.restore();
小贴士
我们不需要让物理体区域与其图形完全相同。例如,如果我们有一个圆形的鸡,我们可以在物理世界中用球体来表示它。使用简单的物理体可以大大提高性能。
尝试一下英雄
我们已经学习了使用 CSS3 过渡来动画化得分板。那么,将它应用到这个赛车游戏中怎么样?此外,给汽车添加一些引擎声音怎么样?只需尝试应用本书中学到的知识,为玩家提供完整的游戏体验。
添加最后的修饰使游戏更有趣
想象一下,现在我们想要发布游戏。游戏逻辑基本上已经有了,但与黑白环境相比,它看起来相当丑陋。在本节中,我们将添加一些最后的修饰,使游戏更具吸引力。我们还将应用一些约束来限制ApplyForce的时间。这个约束使游戏更有趣,因为它要求玩家在施加过多力量到车上之前先思考。
动手实践 – 装饰游戏和添加燃料限制
执行以下步骤将我们的调试绘制转换为丰富的图形游戏:
-
首先,我们需要一些用于起始屏幕、游戏胜利屏幕以及每个级别的环境背景的背景图像。这些图形可以从名为
box2d_final_game的代码包中找到。以下屏幕截图显示了本节中我们需要的一些图形:![动手实践 – 装饰游戏和添加燃料限制]()
-
打开
index.html文件,将画布元素替换为以下标记。这创建了两个额外的游戏组件,名为current level和fuel remaining,并将游戏组件组合到一个game-containerDIV 中:<section id="game-container"> <canvas id="game" width='1300' height='600' class="startscreen"></canvas> <div id="fuel" class="progressbar"> <div class="fuel-value" style="width: 100%;"></div> </div> <div id="level"></div> </section> -
接下来,我们将从代码包中复制
cargame.css文件。这个文件包含了游戏的一些类样式定义。应用了新的样式表后,游戏应该看起来与以下屏幕截图中的相似:![动手实践 – 装饰游戏和添加燃料限制]()
-
现在,我们将继续到 JavaScript 部分。打开
html5games.box2dcargame.js文件。 -
更新
carGame对象声明,添加以下附加变量:var carGame = { // game state constant STATE_STARTING_SCREEN : 1, STATE_PLAYING : 2, STATE_GAMEOVER_SCREEN : 3, state : 0, fuel: 0, fuelMax: 0, currentLevel: 0 } -
现在,我们有起始屏幕。不是一次性开始游戏,而是页面加载。我们将显示起始屏幕并等待玩家点击游戏画布。将以下逻辑添加到
initGame函数中:// set the game state as "starting screen" carGame.state = carGame.STATE_STARTING_SCREEN; // start the game when clicking anywhere in starting screen $('#game').click(function(){ if (carGame.state === carGame.STATE_STARTING_SCREEN) { // change the state to playing. carGame.state = carGame.STATE_PLAYING; // start new game restartGame(carGame.currentLevel); } }); -
接下来,我们需要处理玩家通过所有级别后的游戏获胜屏幕。在获胜旗帜碰撞检查逻辑中,我们使用以下逻辑来确定是否显示下一级或结束屏幕。在文件中找到
console.log("Level Passed!");代码,并将restartGame函数调用替换为以下代码:if (cn.IsTouching()) { console.log("Level Passed!"); if (carGame.currentLevel < carGame.levels.length - 1) { restartGame(carGame.currentLevel+1); } else { // show game over screen $('#game').removeClass().addClass('gamebg_won'); // clear the physics world carGame.world = createWorld(); } } -
然后,我们将处理游戏背景。我们为每个级别的设置准备了每个游戏背景。我们将在
restartGame函数中切换背景,这相当于重建世界:$("#level").html("Level " + (level+1)); // change the background image to fit the level $('#game').removeClass().addClass('gamebg-level'+level); -
现在有了游戏图形,我们不再需要物理对象轮廓绘制。我们可以通过将
shouldDrawDebug对象设置为false来关闭调试绘制:var shouldDrawDebug = false; -
最后,让我们添加一些约束。记住,在我们的级别数据中,我们包括了一个神秘的燃料数据,用于汽车。这是汽车燃料含量的指示器。我们将使用这个燃料来限制玩家的输入。每次对汽车施加力时,燃料都会减少。一旦燃料耗尽,玩家就不能再施加任何额外的力。这种限制使得游戏更有趣。
-
使用以下逻辑更新箭头键的
keydown函数。新的代码在这里突出显示:switch(e.keyCode) { case 39: // right arrow key to apply force towards right if (carGame.fuel > 0) { var force = new b2Vec2(300, 0); carGame.car.ApplyForce(force, carGame.car.GetWorldCenter()); carGame.fuel -= 1; $(".fuel-value").width(carGame.fuel/carGame.fuelMax * 100 +'%'); } return false; break; case 37: // left arrow key to apply force towards left if (carGame.fuel > 0) { var force = new b2Vec2(-300, 0); carGame.car.ApplyForce(force, carGame.car.GetWorldCenter()); carGame.fuel -= 1; $(".fuel-value").width(carGame.fuel/carGame.fuelMax * 100 +'%'); } return false; break; case 82: // r key to restart the game restartGame(carGame.currentLevel); break; } -
此外,在重启游戏函数中的创建汽车逻辑中,我们初始化燃料如下:
// create car if (obj.type === "car") { carGame.car = createCarAt(obj.x,obj.y); carGame.fuel = obj.fuel; carGame.fuelMax = obj.fuel; $(".fuel-value").width('100%'); continue; } -
现在,在浏览器中运行游戏。我们应该得到五个图形级别。以下截图显示了最后四个级别的样子:
![时间行动 – 装饰游戏和添加燃料限制]()
-
通过所有级别后,我们将得到以下获胜屏幕:
![时间行动 – 装饰游戏和添加燃料限制]()
发生了什么?
我们刚刚用更多图形装饰了我们的游戏。我们还绘制了每个级别的环境,一个背景图像。以下截图说明了视觉地面如何表示逻辑物理盒子。与汽车和获胜旗帜不同,地面图形与物理地面无关。这只是一个背景图像,其中的图形位于它们相应的位置。我们可以使用这种方法,因为那些逻辑盒子永远不会移动:

然后,我们可以为每个级别准备几个 CSS 样式,样式名称中包含级别编号,例如.gamebg-level1和.gamebg-level2。通过将每个类与每个级别背景相关联,我们可以使用以下代码在切换级别时更改背景:
$('#game').removeClass().addClassddClass('gamebg-level'+level);
在施加力时添加燃料以添加约束
现在,我们通过提供有限的燃料来限制玩家的输入。当玩家对汽车施加力时,燃料会减少。我们使用了以下 keydown 逻辑来减少燃料并防止汽车燃料耗尽时施加额外的力:
case 39:
if (carGame.fuel > 0) {
var force = new b2Vec2(300, 0);
carGame.car.ApplyForce(force, carGame.car.GetCenterPosition());
carGame.fuel -= 1;
$(".fuel-value").width(carGame.fuel/carGame.fuelMax * 100 +'%');
}
以 CSS3 进度条的形式呈现剩余燃料
在我们的游戏中,我们将剩余燃料以进度条的形式呈现。进度条实际上是一个嵌套在另一个 DIV 中的 DIV。以下标记显示了进度条的结构。外部 DIV 定义了最大值,内部 DIV 显示实际值:
<div id="fuel" class="progressbar">
<div class="fuel-value" style="width: 100%;"></div>
</div>
以下截图说明了进度条的结构:

使用这种结构,我们可以通过设置宽度为百分比值来显示具体的进度。我们使用以下代码根据燃料的百分比更新进度条:
$(".fuel-value").width(carGame.fuel/carGame.fuelMax * 100 +'%');
这是设置进度条并使用宽度样式控制它的基本逻辑。
为平板电脑添加触摸支持
我们在 第六章 为您的游戏添加音效 中添加了触摸支持。在本游戏中,我们将添加触摸支持以使其可在平板电脑上玩。
行动时间 – 添加触摸支持
执行以下步骤以使我们的游戏在具有触摸输入的平板电脑上运行:
-
在
index.html文件中,我们在#game-container结束前添加以下触摸控制:<div id="left-button" class="touch-control"></div> <div id="right-button" class="touch-control"></div> <div id="restart-button" class="touch-control">Restart</div> -
我们还可以在
<head>标签内添加一个<meta>标签来控制视口,使游戏适应 iPad 的 1024 像素宽度。<meta name="viewport" content="width=device-width, initial-scale=0.78, minimum-scale=0.78, maximum-scale=0.78"> -
对于这些控制,我们添加了一些基本样式来定位它们。为此,将以下代码追加到
cargame.css文件中:.touch-control { position: absolute; } #left-button { top: 0; left: 0; width: 50%; height: 100%; } #right-button { top: 0; right: 0; width: 50%; height: 100%; } #restart-button { top: 0; left: 50%; left: calc( 50% - 50px ); width: 100px; height: 50px; text-align: center; line-height: 50px; } -
现在,我们转到
box2dcargame.js文件,并添加一个名为handleTouchInputs()的函数:function handleTouchInputs() { // Touch support if (!window.Touch) { $('.touch-control').hide(); } else { $('#right-button').bind('touchstart', function(){ if (carGame.state === carGame.STATE_STARTING_SCREEN) { // change the state to playing. carGame.state = carGame.STATE_PLAYING; // start new game restartGame(carGame.currentLevel); } else { carGame.isRightButtonActive = true; } }); $('#left-button').bind('touchstart', function(){ if (carGame.state === carGame.STATE_STARTING_SCREEN) { // change the state to playing. carGame.state = carGame.STATE_PLAYING; // start new game restartGame(carGame.currentLevel); } else { carGame.isLeftButtonActive = true; } }); $('#right-button').bind('touchend', function() { carGame.isRightButtonActive = false; }); $('#left-button').bind('touchend', function() { carGame.isLeftButtonActive = false; }); $('#restart-button').bind('touchstart', function(){ restartGame(carGame.currentLevel); }) } } -
我们在
initGame函数中调用handleTouchInputs函数:handleTouchInputs(); -
我们持续施加力直到触摸更新事件。我们可以稍微调整值以适应平板电脑。为此,在现有的
updateWorld函数末尾添加以下代码:// apply force based on the touch event if (carGame.isRightButtonActive) { if (carGame.fuel > 0) { var force = new b2Vec2(50, 0); carGame.car.ApplyForce(force, carGame.car.GetWorldCenter()); carGame.fuel -= 0.1; $(".fuel-value").width(carGame.fuel/carGame.fuelMax * 100 +'%'); } } else if (carGame.isLeftButtonActive) { if (carGame.fuel > 0) { var force = new b2Vec2(-50, 0); carGame.car.ApplyForce(force, carGame.car.GetWorldCenter()); carGame.fuel -= 0.1; $(".fuel-value").width(carGame.fuel/carGame.fuelMax * 100 +'%'); } } -
保存所有文件,并在平板电脑上运行游戏,例如 iPad 或 Android,我们应该能够通过按下游戏的左右两侧来控制汽车。我们还可以通过按下重启按钮来重新开始关卡。
发生了什么?
我们刚刚为我们的游戏添加了触摸支持,使其可在平板电脑上玩。我们为左右力创建了两个触摸区域。我们还创建了一个仅在触摸设备上可见的重启按钮:

我们监听这些按钮上的 touchstart 和 touchend 事件。touchstart 事件与持续触发事件的 keydown 事件不同。我们需要一个布尔值来知道触摸是否开始,并跟踪直到结束。在触摸按下期间,我们在 updateWorld 方法中施加力。频率不同,因此我们调整了力的值和燃料消耗,使其在平板电脑上运行得更好。
控制 viewport 缩放
当设计移动网页时,我们经常使用 viewport 来告诉浏览器使用设备宽度作为网页的 viewport 宽度:
<meta name="viewport" content="width=device-width, initial-scale=1">
在游戏中,尤其是需要频繁点击的游戏中,我们可能想要通过设置相同的值到最小缩放和最大缩放来固定缩放功能。此外,我们可以控制缩放值来调整游戏以适应平板设备。
<meta name="viewport" content="width=device-width, initial-scale=0.78, minimum-scale=0.78, maximum-scale=0.78">
特定于触摸的按钮
平板和移动设备上没有键盘。我们必须为这些设备创建屏幕输入。在这个游戏示例中,我们创建了三个屏幕按钮:左键、右键和重新开始按钮。我们通过检查window.Touch的可用性来隐藏这些按钮:
if (!window.Touch) {
$('.touch-control').hide();
}
摘要
在本章中,你学习了如何使用 Box2D 物理引擎在画布中创建汽车冒险游戏。
具体来说,我们使用 JavaScript 物理引擎设置游戏。然后,我们在物理世界中创建了静态和动态物体。我们通过使用关节来约束物体和车轮来设置汽车。我们通过向汽车添加力来控制汽车,通过键盘输入。最后,我们在物理世界中添加碰撞来决定游戏结束和升级。我们现在已经学会了如何使用 Box2D 物理库来创建基于画布的物理游戏。
在下一章中,我们将讨论不同的分销渠道并将我们的游戏放入原生 Mac 应用程序中。
第十章:部署 HTML5 游戏
我们在本书中创建了几款 HTML5 游戏。在本章中,我们讨论了几种方法,通过这些方法我们可以部署我们的游戏,让其他人能够玩到它们。
在本章中,你将学习以下主题:
-
将游戏部署到网页。
-
将游戏作为移动网页应用程序部署。
-
将游戏封装成 OS X 应用程序。
-
将游戏部署到应用商店。
部署 HTML5 游戏有不同的渠道。我们可以在普通网页上发布游戏,或者将其作为移动网页应用程序部署。否则,我们还可以在 Chrome 网络商店部署游戏。对于原生应用程序商店,根据游戏类型我们有不同的选择。我们选择桌面或移动应用程序商店来部署我们的游戏。对于桌面游戏,我们可以将其部署到 Mac App Store 或 Windows Store。对于移动设备游戏,我们可以将它们部署到 iOS 应用商店和 Android 应用商店。
将 HTML5 游戏直接部署到应用程序商店的最直接方法是通过目标平台提供的 Web View 组件来托管 HTML 文件和相关资源。
准备部署材料
在部署游戏时,我们通常需要准备商店列表。这意味着我们需要制作应用程序图标、几个截图和游戏描述。某些商店可能可选地接受简短的游戏玩法视频。
将游戏放在网上
服务器的需求取决于我们在游戏中使用的技术。对于只涉及客户端 HTML、CSS 和 JavaScript 的游戏,我们可以使用任何网络托管,包括静态网站托管服务。通常,这些静态托管服务允许你轻松地通过 ZIP 文件或通过云存储(如 Dropbox)上传网站。
Amazon S3 也是托管静态网站的一个经济实惠的选择。例如,我的 HTML5 游戏托管在 S3 上,使用 Amazon CloudFront 作为内容分发网络 (CDN) 以提升缓存和加载性能。你可以在 makzan.net/html5-games-list/ 查看 HTML5 游戏网站。
另一种流行的免费托管静态网站的方式是通过 GitHub 页面。GitHub 是一个托管 Git 仓库的服务,并为每个仓库提供静态网站托管功能。你可以在他们的指南中了解更多信息:help.github.com/categories/github-pages-basics/。
注意
本章中提到的某些服务要求你使用 Git 版本控制系统将代码推送到他们的服务器。Git 是一个代码版本控制系统。你可以通过在线资源 git-scm.com/book/ 了解更多。
托管 node.js 服务器
对于需要服务器的游戏,例如多人游戏,我们需要托管游戏服务器。以我们的画猜游戏为例;我们需要一个支持运行 Node.js 服务器的托管服务。要获取支持运行 Node.js 的托管服务列表,请访问:github.com/joyent/node/wiki/Node-Hosting。
其中一些,如 Heroku,在低使用量时是免费的,当您的应用程序变得流行并需要使用更多服务器资源时,您将开始收费。这种定价模式对我们来说在以不支付高昂的服务器租赁费用的情况下测试游戏是很好的。
将移动网页应用部署在首页
通过配置几个 meta 标签,我们可以使游戏能够在移动设备的首页上安装。
行动时间 – 为移动网页应用添加 meta 标签
我们将以音频游戏为例开始。让我们执行以下步骤:
-
在代码编辑器中打开
index.html文件。 -
在头部部分添加以下代码。
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black"> <link rel="apple-touch-icon" href="game-icon.png"> <link rel="apple-touch-startup-image" href="launch-screen.png"> -
在 iOS 设备或模拟器上测试游戏。为此,请尝试点击 分享 按钮,然后选择 添加到主屏幕。您应该看到游戏图标和名称。继续将游戏添加到主屏幕。
-
然后,从首页打开游戏。它将以全屏模式打开。
-
双击首页按钮以启用应用程序切换屏幕。您将能够看到应用程序有自己的位置,类似于原生安装的应用程序。
注意
如果您在 Mac 上进行开发,您可以使用 Apple 免费开发 IDE Xcode 中的 iOS 模拟器。只需将 HTML 文件拖入模拟器,您就可以在移动 Safari 中测试您的游戏。
发生了什么?
我们添加了几个被移动操作系统识别的 meta 标签,特别是 iOS。移动网页应用的概念是在 2007 年第一代 iPhone 发布时引入的。我们告诉系统我们的网页浏览器能够像应用程序一样显示。然后,系统从用户的角度使网页非常类似于应用程序。
默认图标大小为 60 x 60。我们还可以通过指定 iPhone 和 iPad 的每个维度来提供像素完美的图标:
<link rel="apple-touch-icon" href="default-icon-60x60.png">
<link rel="apple-touch-icon" sizes="76x76" href="icon-ipad.png">
<link rel="apple-touch-icon" sizes="120x120" href="icon-iphone-retina.png">
<link rel="apple-touch-icon" sizes="152x152" href="icon-ipad-retina.png">
将 HTML5 游戏构建成 Mac OS X 应用程序
在本节中,我将向您展示我们如何使用 Web View 包装 HTML5 游戏并将其构建成原生应用程序。本节包括在不同开发环境中的其他编程语言的代码。
行动时间——将 HTML5 游戏放入 Mac 应用程序
按照 Mac Xcode 中的步骤进行操作。我们需要一台 Mac 和 Apple Xcode 来创建 Mac OS X 应用程序。如果您还没有安装,请从 Mac App Store 下载 Xcode。
注意
即使您没有 Mac,您也可以看看我们如何将 Web View 与应用程序包装在一起。概念比本节中的步骤更重要。
-
启动 Xcode 并创建一个新项目。在OS X下选择Cocoa Application:
![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
在选项视图中,将游戏名称作为产品名称。组织名称可以是你的名字或公司的名字。使用反向域名作为组织标识符。选择Objective-C作为此代码示例。我们保留其他选项的默认值。
![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
从左侧面板打开
Main.storyboard文件。在右下角面板中,选择第三个标签(如下面的截图所示,以蓝色突出显示)。将Web View组件拖入窗口视图。当你将其拖放到视图中时,Web View 应变为全宽和高:![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
在窗口底部,有几个图标可以配置你如何处理应用窗口的调整大小。保持Web View选中状态,然后选择出现的第二个图标,如图所示。点击顶部0输入旁边的四个间距图标,将它们变成实心红色线条:
![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
在顶部选择四个间距后,点击添加约束按钮。这告诉 Web View 在窗口调整大小时保持所有四个边缘之间的0间距。
![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
然后,我们将窗口大小设置为适合我们的游戏。为此,选择窗口。在右上角面板中,选择第五个标签。然后,我们将窗口的大小设置为正好 1,300 px 宽和 600 px 高:
![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
然后,我们在视图菜单中启用显示辅助编辑器选项。保持左侧的
Main.storyboard选项,并在右侧面板中打开ViewController.h文件。 -
在左侧面板中,识别Web View组件。右键单击组件并将其拖动到
ViewController.h文件中的界面部分。这允许我们为组件命名以便将来参考。将 Web View 组件命名为gameWebView:![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
我们现在已经配置了视图。让我们继续到代码部分。我们使用了 WebKit 框架的一部分 WebView 组件。我们需要将其包含在项目中。为此,在左侧面板中选择CarGame项目。在通用标签下的链接框架和库部分中,点击加号图标以添加 WebKit 框架:
![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
现在,我们应该在链接框架和库部分看到
WebKit.framework:![将 HTML5 游戏放入 Mac 应用的时间——将 HTML5 游戏放入 Mac 应用]()
-
点击
ViewController.m文件,并在viewDidLoad函数中放入以下代码:NSURL *url = [NSURL URLWithString:@"http://makzan.net/html5-games/car-game/"]; NSURLRequest *request = [NSURLRequest requestWithURL:url]; [[self.gameWebView mainFrame] loadRequest:request]; -
现在,你的
ViewController.m文件应该看起来像下面的截图:![将 HTML5 游戏放入 Mac 应用的时间]()
-
最后,点击以下截图所示的 Xcode 左上角的播放按钮:
![将 HTML5 游戏放入 Mac 应用的时间]()
点击播放按钮后,应用程序将构建并打开一个窗口,显示我们的赛车游戏,如下面的截图所示:
![将 HTML5 游戏放入 Mac 应用的时间]()
发生了什么?
我们只是使用 WebView 组件将我们的游戏包装在一个本地应用程序中。我们使用 Xcode 和 Objective-C 来演示包装 WebView 背后的场景。实际上,你可以在其他语言和平台上应用相同的技巧,例如在 iOS 中使用 Swift,甚至使用 Windows 平台上的 WebView 组件构建 Windows 应用程序。
使用 WebView 将 HTML5 游戏构建成移动应用
我们简要了解了将游戏包装在 WebView 中的工作原理。它与将 WebView 包装在移动应用中非常相似。例如,在 iOS 中,我们使用 Xcode 创建一个 iPhone 或 iPad 项目,并在默认视图中添加一个 WebView。在 WebView 内部,我们通过在 Mac 应用部分中使用类似的技术来加载 HTML 文件。
对于 Android 应用,我们可以使用 Android Studio。Android 使用不同的编程语言;它使用 Java,但概念是相同的。我们在主视图中创建一个 WebView 组件,并通过 URL 加载我们的 HTML5 游戏。
请注意,我们需要一个证书才能将 iOS 应用部署到应用商店。为了获得证书,我们需要加入苹果开发者计划,该计划需要支付年度费用。对于 Mac 应用,我们可以自行分发游戏,或者使用 Mac 开发者证书将其部署到 Mac App Store。
使用 PhoneGap 构建
另一个选项是将 Web 应用构建成 Android 和 iPhone 应用——通过使用 PhoneGap 构建服务。该服务允许你上传一个包含 Web 游戏的 ZIP 文件。然后,它使用 WebView 来显示 HTML,类似于我们之前的 WebView 示例。

不同之处在于 PhoneGap 通过其 JavaScript API 提供了几个硬件资源。由于我们的游戏没有使用任何 PhoneGap API,它几乎与我们自己包装 WebView 并使用 PhoneGap 构建的结果相同。
如果你没有任何本地编程经验,PhoneGap 或类似的云构建服务是一个不错的选择。如果你对本地开发环境感到舒适,我更喜欢自己包装 WebView。这为我们未来的开发提供了更多的灵活性,以防我们需要混合本地和 WebView 来制作混合应用程序。
注意
除了 PhoneGap 构建之外,还有其他服务试图将 HTML5 游戏放入原生应用平台。CocoonJS (www.ludei.com/cocoonjs/) 是为此目的的另一个平台。CocoonJS 试图将 canvas 绘图 API 转换为操作系统的 OpenGL 命令,以获得更高的性能。
应用商店的审查流程
每个部署渠道都有不同的审查流程。例如,苹果通常在允许其应用商店上架之前,需要 1 到 4 周的时间来审查应用程序。另一方面,谷歌通常需要几个小时来审查 Play Store 中的应用程序。如果你是新手,通常需要额外一周的时间来熟悉其配置工具。所以,如果你需要在截止日期前将游戏推上应用商店,比如客户项目,请提前 4 周做好准备。
注意
我们没有详细介绍将游戏上传到应用商店的过程,因为它们的配置和商店列表可能会随时间变化。重要的是要准备好所有材料和目标构建。所有材料准备就绪后,上传和为每个商店配置不应成为负担。
摘要
在本章中,我们学习了将游戏发布到不同平台的方法。具体来说,我们讨论了静态网站托管服务来部署我们的 HTML5 游戏。我们列出了运行 node.js 的服务器。我们更新了我们的代码,使其与主页面的 Web 应用兼容。我们尝试将我们的 HTML5 游戏放入 Xcode 的 Web View 中。我们还讨论了移动应用的构建及其审查流程。
我们在九个章节中讨论了使用 CSS3 和 JavaScript 制作 HTML5 游戏的不同方面。我们学习了如何在 DOM 中构建传统的乒乓球游戏,在 CSS3 中构建匹配卡片游戏,以及使用 Canvas 构建解谜游戏。然后,我们探讨了如何向游戏中添加声音,并围绕它创建了一个迷你钢琴音乐游戏。接下来,我们讨论了通过使用本地存储来保存和加载游戏状态。我们还使用 WebSockets 构建了一个实时多人游戏的画图猜谜游戏。然后,在本章中,我们创建了一个带有物理引擎的赛车游戏。最后,我们讨论了如何将我们的 HTML5 游戏部署到不同的平台。
在整本书中,我们构建了不同类型的游戏,并学习了制作 HTML5 游戏所需的一些基本技术。下一步是继续并部署你自己的游戏。为了帮助你开发自己的游戏,有一些资源可能会有所帮助。以下列表提供了 HTML5 游戏开发的一些有用链接:
-
通用 HTML5:
-
HTML5 游戏开发 (
www.html5gamedevelopment.com/) -
HTML5 Rocks (
www.html5rocks.com/)
-
-
HTML5 游戏引擎
-
ImpactJS (
impactjs.com/) -
CreateJS (
createjs.com/) -
Phaser (
phaser.io/)
-
-
游戏精灵和纹理
-
失落的花园 (
lunar.lostgarden.com/labels/free%20game%20graphics.html) -
HasGraphics 图像、纹理和瓦片集 (
hasgraphics.com/category/sprites/) -
细腻图案 (
subtlepatterns.com)
-
附录 A. 突击测验答案
第二章,开始基于 DOM 的游戏开发
准备 HTML 文档以进行基于 DOM 的游戏
突击测验
| Q1 | 4 |
|---|
设置乒乓球游戏元素
突击测验
| Q1 | 3 |
|---|
第三章,使用 CSS3 构建匹配卡片游戏
使用 HTML5 自定义数据属性存储内部自定义数据
突击测验
| Q1 | 3 |
|---|
使用 jQuery 访问自定义数据属性
突击测验
| Q1 | 4 |
|---|
第四章,使用画布和绘图 API 构建解缠游戏
在画布中绘制圆形
突击测验
| Q1 | 2 |
|---|
使用鼠标事件与画布中绘制的对象交互
在画布中的圆中检测鼠标事件
突击测验
| Q1 | 2 |
|---|---|
| Q2 | 2 |
清除画布
突击测验
| Q1 | 1 |
|---|---|
| Q2 | 4 |
第五章,构建 Canvas 游戏大师班
在画布中绘制文本
突击测验 - 在画布中绘制文本
| Q1 | 3 |
|---|---|
| Q2 | 2 |
在画布中绘制图像
突击测验 - 设置画布背景样式
| Q1 | 2 |
|---|
第六章,为您的游戏添加音效
为播放按钮添加音效
突击测验 - 使用音频标签
| Q1 | 2 |
|---|---|
| Q2 | 将回退内容放在 <audio> 标签内 |
第七章,保存游戏进度
保存整个游戏进度
突击测验 - 使用本地存储
| Q1 | false |
|---|---|
| Q2 | true |
| Q3 | true |

































































浙公网安备 33010602011771号